script-volume-auto/src/cloudcompare/cc-runner.service.ts
Nicolas Fryder 86a75bd47b Mode comparaison a 2 nuages + remplissage de trous + fix critique de parsing
- Nouveau mode "cloud_compare" (en plus de "const_height") : comparaison entre une surface
  superieure et une limite inferieure, avec appariement de densite avant le niveau 0 puis
  decimation synchronisee x2 sur 5 etapes. UI : selecteur de mode + upload double avec labels
  explicites (surface superieure / limite inferieure).
- Remplissage des trous de scan (interpolation Delaunay bornee par max-edge-length) avant chaque
  calcul de volume, dans les deux modes. -VOLUME n'ayant aucune option native pour ca (verifie dans
  les sources CloudCompare), on rasterize+comble+exporte en nuage avant de le passer a -VOLUME.
  Override utilisateur possible pour la distance max, afin de ne pas combler les parties concaves
  du contour de l'objet mesure.
- Fix critique : CloudCompare formate les grands nombres avec une virgule comme separateur de
  milliers ("Volume: 14,244.46") ; le parsing tronquait silencieusement a la premiere virgule
  (14 244 devenait 14). Trouve en testant avec un vrai nuage industriel.
- Validation avec 2 vrais nuages utilisateur : le mode Z constant donnait ~50m3 (plan de reference
  sans rapport avec la base reelle de l'amas, nuage contenant du contexte environnant) ; le nouveau
  mode comparaison donne ~14 244m3, stable a +/-4% meme a 16x de decimation.
- CLAUDE.md mis a jour avec tous ces findings (limitation -VOLUME/LEAVE_EMPTY, technique de
  contournement, piege du separateur de milliers, architecture du mode 2 nuages).
2026-07-10 15:21:37 +02:00

148 lines
5.8 KiB
TypeScript

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<CcResult> {
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<CcResult>((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<string | null> {
const candidates: Array<{ full: string; mtime: number }> = [];
const walk = async (dir: string): Promise<void> => {
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<VolumeReport> {
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]+)/),
};
}
}