- 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).
148 lines
5.8 KiB
TypeScript
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]+)/),
|
|
};
|
|
}
|
|
}
|