import { Injectable, Logger } from '@nestjs/common'; import { spawn } from 'child_process'; import * as fs from 'fs/promises'; import * as path from 'path'; import { config } from '../config'; export interface CcResult { stdout: string; exitCode: number | null; } export interface VolumeReport { volume: number; surface: number; addedVolume: number; removedVolume: number; matchingCellsPct: number; groundNonMatchingPct: number; ceilNonMatchingPct: number; avgNeighborsCurrent: number | null; avgNeighborsRef: number | null; } /** * Wrapper bas niveau autour du binaire CloudCompare en ligne de commande. * Chaque appel est un process independant (le CLI CloudCompare fonctionne comme * une machine a etats sur les entites chargees pendant UN SEUL appel : on ne * peut pas "continuer" une session precedente). */ @Injectable() export class CcRunnerService { private readonly logger = new Logger(CcRunnerService.name); /** * autoSave=true est necessaire pour -VOLUME : CloudCompare ne genere le rapport * "VolumeCalculationReport_*.txt" que si l'auto-sauvegarde est active (verifie empiriquement, * non documente). -SS / -SAVE_CLOUDS / -RASTERIZE fonctionnent en revanche correctement avec * autoSave=false (evite de polluer le dossier de travail avec des fichiers intermediaires). */ async run(args: string[], cwd: string, opts: { autoSave?: boolean } = {}): Promise { await fs.mkdir(cwd, { recursive: true }); const fullArgs = ['-SILENT', '-AUTO_SAVE', opts.autoSave ? 'ON' : 'OFF', ...args]; this.logger.debug(`CloudCompare ${fullArgs.join(' ')} (cwd=${cwd})`); return new Promise((resolve, reject) => { const child = spawn(config.cloudCompareBin, fullArgs, { cwd, windowsHide: true, env: process.env, }); let stdout = ''; const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error(`CloudCompare timeout apres ${config.ccTimeoutMs}ms (args: ${fullArgs.join(' ')})`)); }, config.ccTimeoutMs); child.stdout?.on('data', (d) => (stdout += d.toString())); child.stderr?.on('data', (d) => (stdout += d.toString())); child.on('error', (err) => { clearTimeout(timer); reject(new Error(`Impossible de lancer CloudCompare (${config.cloudCompareBin}): ${err.message}`)); }); child.on('close', (code) => { clearTimeout(timer); resolve({ stdout, exitCode: code }); }); }); } /** Cherche "Found one cloud with N points" (premiere occurrence = nuage juste charge par -O) */ parseLoadedPointCount(log: string): number | null { const m = log.match(/Found one cloud with (\d+) points/); return m ? Number(m[1]) : null; } /** Cherche "Result: N points" (sortie de -SS SPATIAL/RANDOM) */ parseSubsampleResult(log: string): number | null { const m = log.match(/\[SUBSAMPLE\][\s\S]*?Result:\s*(\d+)\s*points/); return m ? Number(m[1]) : null; } /** * Trouve le fichier le plus recent correspondant a un prefixe/suffixe, apres un instant donne. * Recherche recursive : CloudCompare ecrit les rapports/rasters relativement au dossier du * nuage charge via -O (ex: "levels/"), pas forcement au cwd du process. */ async findLatestFile(rootDir: string, opts: { prefix?: string; suffix?: string; after: number }): Promise { const candidates: Array<{ full: string; mtime: number }> = []; const walk = async (dir: string): Promise => { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { await walk(full); continue; } if (opts.prefix && !entry.name.startsWith(opts.prefix)) continue; if (opts.suffix && !entry.name.endsWith(opts.suffix)) continue; const stat = await fs.stat(full); const mtime = stat.mtimeMs; if (mtime < opts.after - 2000) continue; // marge de securite horloge candidates.push({ full, mtime }); } }; await walk(rootDir); if (candidates.length === 0) return null; candidates.sort((a, b) => b.mtime - a.mtime); return candidates[0].full; } /** * Parse le fichier texte "VolumeCalculationReport_*.txt" genere par -VOLUME. * CloudCompare formate les grands nombres avec des virgules comme separateur de milliers * (ex: "Volume: 14,244.464657") - il faut les retirer avant conversion, sinon Number() * tronque silencieusement a la premiere virgule (14,244 devient 14). Verifie empiriquement * sur un vrai rapport genere par CC 2.13.2 - voir CLAUDE.md. */ async parseVolumeReportFile(filePath: string): Promise { const text = await fs.readFile(filePath, 'utf-8'); const toNumber = (raw: string): number => Number(raw.replace(/,/g, '')); const num = (re: RegExp): number => { const m = text.match(re); if (!m) throw new Error(`Champ introuvable dans le rapport de volume (${re}): ${filePath}`); return toNumber(m[1]); }; const numOpt = (re: RegExp): number | null => { const m = text.match(re); return m ? toNumber(m[1]) : null; }; return { volume: num(/Volume:\s*([-\d,.eE]+)/), surface: num(/Surface:\s*([-\d,.eE]+)/), addedVolume: num(/Added volume:\s*\(\+\)\s*([-\d,.eE]+)/), removedVolume: num(/Removed volume:\s*\(-\)\s*([-\d,.eE]+)/), matchingCellsPct: num(/Matching cells:\s*([-\d,.eE]+)\s*%/), groundNonMatchingPct: num(/ground\s*=\s*([-\d,.eE]+)\s*%/), ceilNonMatchingPct: num(/ceil\s*=\s*([-\d,.eE]+)\s*%/), avgNeighborsCurrent: numOpt(/Average neighbors per cell:\s*([-\d,.eE]+)\s*\//), avgNeighborsRef: numOpt(/Average neighbors per cell:.*\/\s*([-\d,.eE]+)/), }; } }