import { Injectable, Logger } from '@nestjs/common'; import * as fs from 'fs/promises'; import * as path from 'path'; import { config } from '../config'; import { CcRunnerService } from '../cloudcompare/cc-runner.service'; import { PyHelperService, CloudStats } from '../pyhelper/pyhelper.service'; import { CloudRole, EmptyCellStrategy, LevelCloudInfo, LevelResult, ProjectionType, RunParamsInput, RunReport, VertDir } from './types'; export function sanitizeBaseName(filename: string): string { const base = filename.replace(/\.[^./\\]+$/, ''); return base.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 80) || 'nuage'; } export interface RunInputs { /** Fichier unique (mode const_height) ou nuage du HAUT / surface superieure (mode cloud_compare) */ primaryPath: string; primaryFilename: string; /** Nuage du BAS / limite inferieure (mode cloud_compare uniquement) */ secondaryPath?: string; secondaryFilename?: string; } interface CloudPrepResult { status: 'ok' | 'skipped' | 'error'; message?: string; info: LevelCloudInfo; /** Chemin absolu du nuage brut (non rempli), utilise pour chainer la decimation suivante */ rawBinAbs: string | null; /** Chemin absolu (ASCII XYZ) du nuage avec trous combles, utilise pour -VOLUME et la carte de comparaison */ filledXyzAbs: string | null; } /** Convertit une strategie de remplissage en arguments CLI CloudCompare (-EMPTY_FILL ...). */ function emptyFillArgs(strategy: EmptyCellStrategy, customHeightValue: number | undefined, krigingKnn: number, maxEdgeLength: number): string[] { switch (strategy) { case 'leave_empty': return []; case 'min_height': return ['-EMPTY_FILL', 'MIN_H']; case 'max_height': return ['-EMPTY_FILL', 'MAX_H']; case 'custom_height': return ['-EMPTY_FILL', 'CUSTOM_H', '-CUSTOM_HEIGHT', String(customHeightValue ?? 0)]; case 'interpolate': return ['-EMPTY_FILL', 'INTERP', '-MAX_EDGE_LENGTH', String(maxEdgeLength)]; case 'kriging': return ['-EMPTY_FILL', 'KRIGING', '-KRIGING_KNN', String(krigingKnn)]; } } /** * Orchestration complete d'un run. Deux modes : * - const_height : 1 nuage, compare a un plan Z constant. * - cloud_compare : 2 nuages (surface du haut vs limite du bas), densites appariees au niveau 0 * puis decimation synchronisee x2 sur 5 etapes, compares l'un a l'autre a chaque niveau. * Dans les deux cas, les trous de scan sont combles (strategie configurable, mirroir de la boite de * dialogue "Compute Volume" de CloudCompare) avant chaque calcul de volume - voir CLAUDE.md, * -VOLUME n'a pas ces options nativement, on passe par -RASTERIZE en amont. */ @Injectable() export class PipelineService { private readonly logger = new Logger(PipelineService.name); constructor( private readonly cc: CcRunnerService, private readonly py: PyHelperService, ) {} async execute( runId: string, inputs: RunInputs, workDir: string, params: RunParamsInput, onProgress: (msg: string) => void, ): Promise { const levelsDir = path.join(workDir, 'levels'); const imagesDir = path.join(workDir, 'images'); const rawDir = path.join(workDir, 'raw'); await fs.mkdir(levelsDir, { recursive: true }); await fs.mkdir(imagesDir, { recursive: true }); await fs.mkdir(rawDir, { recursive: true }); const isCompare = params.mode === 'cloud_compare'; const gridStepMultiplier = params.gridStepMultiplierOverride ?? config.gridStepMultiplier; const emptyCellStrategy: EmptyCellStrategy = params.emptyCellStrategy ?? config.emptyCellStrategyDefault; const projectionType: ProjectionType = params.projectionType ?? config.projectionTypeDefault; const vertDir: VertDir = params.vertDir ?? config.vertDirDefault; const krigingKnn = params.krigingKnn ?? config.krigingKnnDefault; const customHeightValue = params.customHeightValue; // ---------- 0) Normalisation du/des format(s) d'entree ---------- const primaryNorm = await this.normalizeInput(inputs.primaryPath, workDir, 'primary', onProgress); const secondaryNorm = inputs.secondaryPath ? await this.normalizeInput(inputs.secondaryPath, workDir, 'secondary', onProgress) : null; // ---------- 1) Statistiques ---------- onProgress('Estimation du pas spatial (echantillonnage + KD-tree)...'); const t0 = Date.now(); const primaryStats = await this.computeStatsFor(primaryNorm.path, workDir, 'sample_primary.xyz'); let initialStep: number; let zref: number | null = null; let zrefSource: 'override' | 'auto' | null = null; let densityMatch: RunReport['params']['densityMatch'] = null; if (!isCompare) { initialStep = params.initialStepOverride ?? primaryStats.stats.median_nn; // z_p1 (1er percentile) plutot que bbox.zmin : un minimum absolu est ecrase par un seul point // aberrant (bruit multi-echo) et gonfle le volume de Δz x surface. Le percentile n'est en plus // pas biaise par le sous-echantillonnage (contrairement a median_nn) - voir CONCEPT.md §7.7. zref = params.zrefOverride ?? primaryStats.stats.z_p1; zrefSource = params.zrefOverride !== undefined ? 'override' : 'auto'; onProgress( `Pas initial=${initialStep.toFixed(5)}m (${params.initialStepOverride ? 'override' : 'auto, mediane NN'}), ` + `Zref=${zref.toFixed(4)} (${zrefSource}, percentile 1%). [${Date.now() - t0}ms]`, ); } else { const secondaryStats = await this.computeStatsFor(secondaryNorm!.path, workDir, 'sample_secondary.xyz'); const topSpacing = primaryStats.stats.median_nn; const bottomSpacing = secondaryStats.stats.median_nn; const matchedSpacing = Math.max(topSpacing, bottomSpacing); initialStep = params.initialStepOverride ?? matchedSpacing; const eps = matchedSpacing * 0.01; densityMatch = { topSpacing, bottomSpacing, matchedSpacing: initialStep, preDecimatedRole: topSpacing < initialStep - eps ? 'top' : bottomSpacing < initialStep - eps ? 'bottom' : 'none', }; onProgress( `Pas top=${topSpacing.toFixed(5)}m, pas bas=${bottomSpacing.toFixed(5)}m -> pas apparie=${initialStep.toFixed(5)}m ` + `(${params.initialStepOverride ? 'override' : 'auto, max des deux medianes NN'}). [${Date.now() - t0}ms]`, ); } const maxEdgeLengthSource: 'override' | 'auto' = params.maxEdgeLengthOverride !== undefined ? 'override' : 'auto'; const levels: LevelResult[] = []; let stopped = false; let stopReason = ''; // Etat courant par role : chemin du nuage BRUT (non rempli) du niveau precedent const prevRaw: Partial> = {}; const refPointCount: Partial> = {}; const rolesForThisRun: CloudRole[] = isCompare ? ['top', 'bottom'] : ['unique']; // Portee de remplissage des trous FIXE sur tous les niveaux (derivee de la grille du niveau 0), // et non recalculee a partir du gridStep de CHAQUE niveau. Avec un multiplicateur applique au // gridStep de chaque niveau, la portee double a chaque niveau (x32 au niveau 5) et finit par // combler des concavites reelles du contour de l'amas au lieu des seuls trous de scan - // biais systematique croissant avec la decimation, voir CONCEPT.md §7.3. Le niveau 0 a le meme // gridStep (= initialStep * gridStepMultiplier) en mode const_height (spatialStep=null) et en // mode cloud_compare (spatialStep = initialStep * factor^0 = initialStep). const gridStep0 = initialStep * gridStepMultiplier; const maxEdgeLengthDefault = gridStep0 * config.maxEdgeLengthMultiplier; for (let i = 0; i <= config.decimationSteps; i++) { if (stopped) { levels.push(this.skippedLevel(i, stopReason)); continue; } // Niveau 0 (const_height) = nuage complet, jamais decime : spatialStep=null. // Niveau 0 (cloud_compare) = pas apparie = initialStep (factor^0) ; niveaux suivants x2. const spatialStep = !isCompare && i === 0 ? null : initialStep * Math.pow(config.decimationFactor, i); const gridStep = (spatialStep ?? initialStep) * gridStepMultiplier; const maxEdgeLength = params.maxEdgeLengthOverride ?? maxEdgeLengthDefault; onProgress( `Niveau ${i} : ${spatialStep === null ? 'nuage complet' : `decimation (pas=${spatialStep.toFixed(5)}m)`}, ` + `grille=${gridStep.toFixed(5)}m, remplissage trous (${emptyCellStrategy}${emptyCellStrategy === 'interpolate' ? `, max edge=${maxEdgeLength.toFixed(5)}m` : ''})...`, ); try { const cloudResults: CloudPrepResult[] = []; for (const role of rolesForThisRun) { const sourcePath = i === 0 ? (role === 'bottom' ? secondaryNorm!.path : primaryNorm.path) : prevRaw[role]!; const isFirstConversion = !isCompare && i === 0; const res = await this.prepareCloudAtLevel({ level: i, role, spatialStep, sourcePath, isFirstConversion, levelsDir, rawDir, workDir, gridStep, maxEdgeLength, emptyCellStrategy, customHeightValue, krigingKnn, projectionType, vertDir, refPointCount: refPointCount[role] ?? 0, }); cloudResults.push(res); } const anyBad = cloudResults.find((r) => r.status !== 'ok'); if (anyBad) { levels.push({ level: i, gridStep, maxEdgeLength, clouds: cloudResults.map((r) => r.info), volume: null, surface: null, addedVolume: null, removedVolume: null, matchingCellsPct: null, groundNonMatchingPct: null, ceilNonMatchingPct: null, diffMapImage: null, warn: true, status: anyBad.status, message: anyBad.message, computeTimeMs: 0, }); stopped = true; stopReason = anyBad.message ?? `echec au niveau ${i}`; continue; } // ---------- Volume (sur les nuages COMBLES) ---------- const beforeVolume = Date.now(); let volRes; if (!isCompare) { volRes = await this.cc.run( [ '-O', this.relFrom(workDir, cloudResults[0].filledXyzAbs!), '-VOLUME', '-GRID_STEP', String(gridStep), '-CONST_HEIGHT', String(zref), '-VERT_DIR', String(vertDir), ], workDir, { autoSave: true }, ); } else { volRes = await this.cc.run( [ '-O', this.relFrom(workDir, cloudResults[0].filledXyzAbs!), // top = ceil '-O', this.relFrom(workDir, cloudResults[1].filledXyzAbs!), // bottom = ground '-VOLUME', '-GRID_STEP', String(gridStep), '-VERT_DIR', String(vertDir), ], workDir, { autoSave: true }, ); } const reportFile = await this.cc.findLatestFile(workDir, { prefix: 'VolumeCalculationReport', suffix: '.txt', after: beforeVolume }); if (!reportFile) { throw new Error(`Rapport de volume introuvable au niveau ${i}. Sortie:\n${volRes.stdout.slice(-1500)}`); } const vol = await this.cc.parseVolumeReportFile(reportFile); await fs.rename(reportFile, path.join(rawDir, `L${i}_volume_report.txt`)); // AUTO_SAVE ON sauvegarde aussi une grille "*_HEIGHT_DIFFERENCE_*.bin" qu'on ne veut pas // garder. Filtrage par prefixe (pas juste ".bin") : sur un run avec peu de points, les // L{n}_*.bin bruts fraichement ecrits (chaines depuis prepareCloudAtLevel) tombent aussi // dans la fenetre "after" de findLatestFile - un filtre ".bin" nu risquerait de supprimer // le nuage brut du niveau au lieu de la grille parasite, cassant le chainage de decimation. const strayGrid = await this.cc.findLatestFile(workDir, { prefix: '', suffix: '.bin', after: beforeVolume, mustContain: '_HEIGHT_DIFFERENCE_' }); if (strayGrid) { await fs.unlink(strayGrid).catch(() => undefined); } // ---------- Carte de comparaison (bleu->rouge, une seule image par niveau) ---------- let diffMapImage: string | null = null; try { const pngName = `L${i}_diffmap.png`; const pngAbs = path.join(imagesDir, pngName); const title = `Niveau ${i}` + (spatialStep === null ? ' (complet)' : ` (pas=${spatialStep.toFixed(4)}m)`); if (!isCompare) { await this.py.renderDiffmap(cloudResults[0].filledXyzAbs!, null, zref, gridStep, pngAbs, title); } else { await this.py.renderDiffmap(cloudResults[0].filledXyzAbs!, cloudResults[1].filledXyzAbs!, null, gridStep, pngAbs, title); } diffMapImage = path.posix.join('images', pngName); } catch (err: any) { this.logger.warn(`Carte de comparaison niveau ${i} non generee: ${err.message}`); } const warn = vol.matchingCellsPct < config.matchingCellsWarnThreshold; levels.push({ level: i, gridStep, maxEdgeLength, clouds: cloudResults.map((r) => r.info), volume: vol.volume, surface: vol.surface, addedVolume: vol.addedVolume, removedVolume: vol.removedVolume, matchingCellsPct: vol.matchingCellsPct, groundNonMatchingPct: vol.groundNonMatchingPct, ceilNonMatchingPct: vol.ceilNonMatchingPct, diffMapImage, warn, status: 'ok', computeTimeMs: Date.now() - beforeVolume, }); for (const r of cloudResults) { prevRaw[r.info.role] = r.rawBinAbs!; if (i === 0) refPointCount[r.info.role] = r.info.pointCount; } const tooSparse = cloudResults.some((r) => r.info.pointCount < config.minPointsToContinue); if (tooSparse) { stopped = true; stopReason = `nuage trop clairseme apres le niveau ${i} (seuil ${config.minPointsToContinue} points)`; } } catch (err: any) { this.logger.error(`Niveau ${i} en erreur: ${err.message}`); levels.push({ level: i, gridStep, maxEdgeLength, clouds: rolesForThisRun.map((role) => ({ role, spatialStep, pointCount: 0, pointRatio: 0, binFile: null })), volume: null, surface: null, addedVolume: null, removedVolume: null, matchingCellsPct: null, groundNonMatchingPct: null, ceilNonMatchingPct: null, diffMapImage: null, warn: true, status: 'error', message: err.message, computeTimeMs: 0, }); stopped = true; stopReason = err.message; } } // ---------- Assemblage ---------- onProgress('Assemblage des nuages exploitables dans un seul fichier .bin...'); const okBinFiles: string[] = []; for (const lvl of levels) { if (lvl.status !== 'ok') continue; for (const c of lvl.clouds) { if (c.binFile) okBinFiles.push(c.binFile); } } let mergedBinFile: string | null = null; if (okBinFiles.length > 0) { const baseName = sanitizeBaseName(inputs.primaryFilename); const mergedName = `${baseName}_all_levels.bin`; const args: string[] = []; for (const f of okBinFiles) args.push('-O', f); args.push('-C_EXPORT_FMT', 'BIN', '-SAVE_CLOUDS', 'ALL_AT_ONCE', 'FILE', mergedName); const mergeRes = await this.cc.run(args, workDir); const mergedAbs = path.join(workDir, mergedName); await this.assertExists(mergedAbs, 'fusion des nuages', mergeRes.stdout); mergedBinFile = mergedName; } const report: RunReport = { runId, mode: params.mode, originalFilename: inputs.primaryFilename, originalFilenameBottom: inputs.secondaryFilename ?? null, createdAt: new Date().toISOString(), params: { zref, zrefSource, initialStep, initialStepSource: params.initialStepOverride !== undefined ? 'override' : 'auto', gridStepMultiplier, gridStepMultiplierSource: params.gridStepMultiplierOverride !== undefined ? 'override' : 'auto', maxEdgeLengthMultiplier: config.maxEdgeLengthMultiplier, maxEdgeLengthSource, emptyCellStrategy, customHeightValue: customHeightValue ?? null, krigingKnn: emptyCellStrategy === 'kriging' ? krigingKnn : null, projectionType, vertDir, factor: config.decimationFactor, steps: config.decimationSteps, densityMatch, sampleStats: { sampleCount: primaryStats.stats.count, meanNn: primaryStats.stats.mean_nn, medianNn: primaryStats.stats.median_nn, bboxApprox: primaryStats.stats.bbox, }, coordinateOffset: { primary: primaryNorm.offsetX !== null ? { x: primaryNorm.offsetX, y: primaryNorm.offsetY! } : null, secondary: secondaryNorm?.offsetX !== null && secondaryNorm?.offsetX !== undefined ? { x: secondaryNorm.offsetX, y: secondaryNorm.offsetY! } : null, }, }, levels, mergedBinFile, }; await fs.writeFile(path.join(workDir, 'report.json'), JSON.stringify(report, null, 2), 'utf-8'); await fs.writeFile(path.join(workDir, 'report.csv'), reportToCsv(report), 'utf-8'); onProgress('Generation des graphiques de synthese...'); try { await this.py.renderSummary(path.join(workDir, 'report.json'), imagesDir); } catch (err: any) { this.logger.warn(`Graphiques de synthese non generes: ${err.message}`); } return report; } /** * Convertit LAS/LAZ/COPC en ASCII (le paquet apt CloudCompare n'a pas le plugin LAS) ; laisse .bin * tel quel. Applique un decalage global X/Y pour preserver la precision float32 de CloudCompare * en coordonnees projetees (voir CONCEPT.md §7.2). */ private async normalizeInput( inputPath: string, workDir: string, tag: string, onProgress: (msg: string) => void, ): Promise<{ path: string; offsetX: number | null; offsetY: number | null }> { const lower = inputPath.toLowerCase(); if (!lower.endsWith('.las') && !lower.endsWith('.laz')) return { path: inputPath, offsetX: null, offsetY: null }; onProgress(`Conversion du nuage (${tag}) LAS/LAZ/COPC en ASCII...`); const xyzPath = path.join(workDir, `input_${tag}.xyz`); const { offsetX, offsetY } = await this.py.convertLasToXyz(inputPath, xyzPath); return { path: xyzPath, offsetX, offsetY }; } private async computeStatsFor(normalizedPath: string, workDir: string, sampleFileName: string): Promise<{ totalPointCount: number; stats: CloudStats }> { const res = await this.cc.run( ['-O', normalizedPath, '-SS', 'RANDOM', String(config.statsSampleCap), '-C_EXPORT_FMT', 'ASC', '-PREC', '6', '-SAVE_CLOUDS', 'FILE', sampleFileName], workDir, ); const totalPointCount = this.cc.parseLoadedPointCount(res.stdout); if (totalPointCount === null) { throw new Error(`Impossible de lire le nuage d'entree. Sortie CloudCompare:\n${res.stdout.slice(-2000)}`); } const samplePath = path.join(workDir, sampleFileName); await this.assertExists(samplePath, 'echantillonnage du nuage', res.stdout); const rawStats = await this.py.computeStats(samplePath); // Correction du biais de sous-echantillonnage (voir CONCEPT.md §7.1) : la distance mediane au // plus proche voisin depend de la densite de points (median_nn ~ 1/sqrt(densite)). Un // sous-echantillon de taille fixe (statsSampleCap) a une densite plus faible que le nuage // complet des que ce dernier depasse statsSampleCap points, ce qui SURESTIME systematiquement // median_nn/mean_nn d'un facteur sqrt(totalPointCount/sampleCount). Sans cette correction, un // nuage LiDAR HD de 20M points (echantillon 50k, ratio 400x) voit son pas spatial estime // gonfle d'un facteur ~20 - toute la grille de calcul et le remplissage de trous en heritent. // No-op si le nuage complet est deja <= statsSampleCap (echantillon = nuage entier). const correctionFactor = rawStats.count > 0 ? Math.sqrt(totalPointCount / rawStats.count) : 1; const stats: CloudStats = { ...rawStats, mean_nn: rawStats.mean_nn * correctionFactor, median_nn: rawStats.median_nn * correctionFactor, std_nn: rawStats.std_nn * correctionFactor, }; return { totalPointCount, stats }; } private relFrom(workDir: string, absPath: string): string { return path.relative(workDir, absPath).split(path.sep).join('/'); } private skippedLevel(level: number, reason: string): LevelResult { return { level, gridStep: null, maxEdgeLength: null, clouds: [], volume: null, surface: null, addedVolume: null, removedVolume: null, matchingCellsPct: null, groundNonMatchingPct: null, ceilNonMatchingPct: null, diffMapImage: null, warn: true, status: 'skipped', message: reason, computeTimeMs: 0, }; } private async assertExists(p: string, what: string, ccLog: string) { try { await fs.access(p); } catch { throw new Error(`Etape "${what}" : fichier attendu introuvable (${p}). Sortie CloudCompare:\n${ccLog.slice(-2000)}`); } } /** * Prepare le nuage d'UN role a UN niveau : decimation (ou conversion initiale) puis remplissage * des trous (rasterize + strategie configurable, mirroir de la boite de dialogue CloudCompare) * pour produire la version ASCII utilisee par -VOLUME et la carte de comparaison. Le nuage BRUT * (non rempli, en .bin) est conserve pour chainer la decimation du niveau suivant et pour * l'assemblage final. */ private async prepareCloudAtLevel(opts: { level: number; role: CloudRole; spatialStep: number | null; sourcePath: string; isFirstConversion: boolean; levelsDir: string; rawDir: string; workDir: string; gridStep: number; maxEdgeLength: number; emptyCellStrategy: EmptyCellStrategy; customHeightValue: number | undefined; krigingKnn: number; projectionType: ProjectionType; vertDir: VertDir; refPointCount: number; }): Promise { const { level, role, spatialStep, workDir, gridStep, maxEdgeLength, refPointCount } = opts; const suffix = role === 'unique' ? '' : `_${role}`; let binRelPath: string; let pointCount: number; if (opts.isFirstConversion) { binRelPath = path.posix.join('levels', `L${level}${suffix}_full.bin`); const res = await this.cc.run(['-O', opts.sourcePath, '-C_EXPORT_FMT', 'BIN', '-SAVE_CLOUDS', 'FILE', binRelPath], workDir); const n = this.cc.parseLoadedPointCount(res.stdout); if (n === null) { return this.badResult(level, role, spatialStep, `Conversion niveau ${level}${suffix} echouee. Sortie:\n${res.stdout.slice(-1500)}`, 'error'); } pointCount = n; await this.assertExists(path.join(workDir, binRelPath), `conversion niveau ${level}${suffix}`, res.stdout); } else { const stepStr = spatialStep!.toFixed(6); binRelPath = path.posix.join('levels', `L${level}${suffix}_step${stepStr}.bin`); const res = await this.cc.run( ['-O', opts.sourcePath, '-SS', 'SPATIAL', stepStr, '-C_EXPORT_FMT', 'BIN', '-SAVE_CLOUDS', 'FILE', binRelPath], workDir, ); const n = this.cc.parseSubsampleResult(res.stdout); if (n === null || n === 0) { return this.badResult( level, role, spatialStep, `decimation spatiale (${role}) n'a produit aucun point exploitable (pas=${stepStr}m)`, 'skipped', ); } pointCount = n; await this.assertExists(path.join(workDir, binRelPath), `decimation niveau ${level}${suffix}`, res.stdout); } const rawBinAbs = path.join(workDir, binRelPath); // Remplissage des trous (rasterize + strategie configurable) pour le calcul de volume et la // carte de comparaison. -VOLUME n'a pas ces options nativement (voir CLAUDE.md) : on // rasterize+comble+exporte en ASCII AVANT de le passer a -VOLUME. let filledXyzAbs: string | null = null; try { const filledRel = path.posix.join('raw', `L${level}${suffix}_filled.xyz`); const fillArgs = emptyFillArgs(opts.emptyCellStrategy, opts.customHeightValue, opts.krigingKnn, maxEdgeLength); await this.cc.run( [ '-O', binRelPath, '-RASTERIZE', '-GRID_STEP', String(gridStep), '-VERT_DIR', String(opts.vertDir), '-PROJ', opts.projectionType.toUpperCase(), ...fillArgs, '-OUTPUT_CLOUD', '-C_EXPORT_FMT', 'ASC', '-PREC', '6', '-SAVE_CLOUDS', 'FILE', filledRel, ], workDir, ); const filledAbs = path.join(workDir, filledRel); await this.assertExists(filledAbs, `remplissage niveau ${level}${suffix}`, ''); filledXyzAbs = filledAbs; } catch (err: any) { this.logger.warn(`Remplissage niveau ${level}${suffix} echoue: ${err.message}`); return this.badResult(level, role, spatialStep, `Remplissage des trous echoue au niveau ${level}${suffix}: ${err.message}`, 'error'); } return { status: 'ok', info: { role, spatialStep, pointCount, pointRatio: refPointCount > 0 ? pointCount / refPointCount : level === 0 ? 1 : 0, binFile: binRelPath, }, rawBinAbs, filledXyzAbs, }; } private badResult(level: number, role: CloudRole, spatialStep: number | null, message: string, status: 'skipped' | 'error'): CloudPrepResult { return { status, message, info: { role, spatialStep, pointCount: 0, pointRatio: 0, binFile: null }, rawBinAbs: null, filledXyzAbs: null, }; } } export function reportToCsv(report: RunReport): string { const headers = [ 'level', 'role', 'spatialStep', 'gridStep', 'maxEdgeLength', 'pointCount', 'pointRatio', 'volume', 'surface', 'addedVolume', 'removedVolume', 'matchingCellsPct', 'groundNonMatchingPct', 'ceilNonMatchingPct', 'status', 'warn', 'computeTimeMs', 'message', ]; const rows: string[] = []; for (const l of report.levels) { const clouds = l.clouds.length > 0 ? l.clouds : [{ role: 'unique' as const, spatialStep: null, pointCount: 0, pointRatio: 0, binFile: null }]; for (const c of clouds) { rows.push( [ l.level, c.role, c.spatialStep ?? '', l.gridStep ?? '', l.maxEdgeLength ?? '', c.pointCount, c.pointRatio, l.volume ?? '', l.surface ?? '', l.addedVolume ?? '', l.removedVolume ?? '', l.matchingCellsPct ?? '', l.groundNonMatchingPct ?? '', l.ceilNonMatchingPct ?? '', l.status, l.warn, l.computeTimeMs, (l.message ?? '').replace(/[\r\n,]+/g, ' '), ].join(','), ); } } return [headers.join(','), ...rows].join('\n') + '\n'; }