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).
This commit is contained in:
Nicolas Fryder 2026-07-10 15:21:37 +02:00
parent 7f2e4c323b
commit 86a75bd47b
12 changed files with 801 additions and 376 deletions

View file

@ -67,6 +67,41 @@ E57 volontairement hors perimetre.
desalignement grille/densite. Voir `LevelResult.gridStep` (par niveau) et
`RunReport.params.gridStepMultiplier` (le seul parametre encore global).
- **`-VOLUME` n'a AUCUNE option de remplissage de trous (empty cell filling), meme si `-RASTERIZE`
en a.** Verifie en clonant les sources (`qCC/ccCommandRaster.cpp`, `CommandVolume25D::process`) :
l'appel a `ccVolumeCalcTool::ComputeVolume(...)` est fait avec `ccRasterGrid::LEAVE_EMPTY` **code en
dur** pour le ground ET le ceil, non exposable via un flag CLI (`-EMPTY_FILL`, `-MAX_EDGE_LENGTH`
etc. existent bien comme constantes dans le fichier mais seule `CommandRasterize::process` les
consomme ; `CommandVolume25D::process` ne les lit jamais). **Contournement retenu** : avant chaque
`-VOLUME`, on rasterize+comble chaque nuage separement (`-RASTERIZE -GRID_STEP g -EMPTY_FILL INTERP
-MAX_EDGE_LENGTH m -OUTPUT_CLOUD -SAVE_CLOUDS FILE filled.bin`), on charge ensuite CE nuage comble
(pas l'original) dans `-VOLUME`. Verifie empiriquement (nuage synthetique avec trou d'occlusion
delibere, rayon 0.6m) : sans comblement, matching cells 96.4% / volume sous-estime ; avec
`-MAX_EDGE_LENGTH 0.3` (> diametre du trou), trou comble, matching cells 98.9% ; avec
`-MAX_EDGE_LENGTH 0.08` (< diametre du trou), AUCUN effet (identique au cas non comble) confirme
que le parametre borne bien la portee du remplissage, empechant de combler les parties concaves du
contour d'un objet (exactement le risque signale par l'utilisateur). Voir
`PipelineService.prepareCloudAtLevel` (bloc "Remplissage des trous").
- **Piege associe** : `-RASTERIZE ... -OUTPUT_CLOUD` avec `-SAVE_CLOUDS FILE "a.bin b.bin"` (2 noms)
echoue avec `Invalid parameter: specified 2 file names, but there are 1 clouds` — le nuage comble
**remplace** le nuage original dans la liste des entites chargees (pas un ajout), il ne faut donc
fournir qu'UN SEUL nom de fichier a `-SAVE_CLOUDS FILE`.
- Le nuage "comble" est un nuage-grille (un point par cellule non vide, ex: 39971 points pour une
grille 201x201 avec quelques cellules hors de l'enveloppe convexe) — PAS le nuage original avec des
points ajoutes. On le garde uniquement pour le calcul de volume (`raw/L{n}_filled.bin`) ; le nuage
BRUT (non comble) reste utilise pour l'export, les cartes de hauteur et le chainage de decimation,
afin de montrer les vrais trous de scan et ne pas faire chainer l'interpolation d'un niveau vers
le suivant (qui composerait l'erreur).
- **Piege critique de parsing** : CloudCompare formate les **grands nombres avec une virgule comme
separateur de milliers** dans le rapport texte (`Volume: 14,244.464657`, pas `14244.464657`). Une
regex naive `[-\d.eE]+` s'arrete a la virgule et tronque silencieusement la valeur (14 244 devient
**14**, sans aucune erreur) — bug reel decouvert en testant avec un vrai nuage industriel (volume
affiche a tort ~50m3 au lieu de ~14 244m3 avant fix, meme si dans ce cas precis c'etait en plus
combine a un mauvais choix de plan de reference, voir plus bas). **Fix** : regex `[-\d,.eE]+` puis
`.replace(/,/g, '')` avant `Number(...)`. Voir `CcRunnerService.parseVolumeReportFile` et le test
`cc-runner.service.spec.ts` ("grands volumes avec separateur de milliers").
### `-SS SPATIAL` / `-SS RANDOM` (sous-echantillonnage)
- `-SS SPATIAL <distance>` : distance minimale entre points, PAS un ratio de points cible. Le nombre
@ -105,6 +140,42 @@ E57 volontairement hors perimetre.
- `-SILENT` doit etre le tout premier argument (ou juste apres `-VERBOSITY`).
- `-PREC <n>` controle la precision decimale des exports ASCII (`-C_EXPORT_FMT ASC`).
## Mode comparaison a 2 nuages (`cloud_compare`)
En plus du mode `const_height` (nuage unique vs plan Z constant), un second mode compare 2 nuages
entre eux : le nuage du **haut** ("top", surface superieure de l'amas) et celui du **bas** ("bottom",
limite inferieure/base/socle). Motivation reelle : sur un vrai nuage industriel ("Amas 1 Nuage.las",
~150m de long), le mode `const_height` donnait un volume de ~50m3 alors que l'objet fait clairement
plus — cause identifiee par inspection de la heightmap : le nuage a une empreinte **diagonale** dans
sa bounding box (~150x113m), et surtout le plan Z constant (altitude min globale du nuage entier)
n'a aucun rapport avec la base reelle de l'amas si le fichier contient aussi du terrain environnant.
Avec le second nuage ("Plan d'ajustement NUAGE.las", une base fittee specifiquement sous l'amas), le
volume calcule est de ~14 244m3, stable a +/-4% meme a 16x de decimation (129 points) — la aussi
confirme par inspection visuelle (les deux nuages ont exactement la meme empreinte au sol).
**Enseignement general : le mode `const_height` n'est fiable QUE si le nuage ne contient QUE l'objet
mesure (pas de terrain/contexte environnant) ; sinon `cloud_compare` avec une base dediee est le seul
mode qui donne un chiffre correct.**
Implementation :
- **Appariement de densite** avant le niveau 0 : on calcule la distance mediane au plus proche voisin
de chaque nuage independamment, on prend le MAX des deux (`matchedSpacing`), puis on decime (une
seule fois, `-SS SPATIAL matchedSpacing`) le nuage le plus dense pour le ramener a la meme resolution
que l'autre — l'operation est un no-op si le nuage est deja plus clairseme que `matchedSpacing` (`-SS
SPATIAL` ne peut pas densifier). Le niveau 0 du mode `cloud_compare` EST ce nuage appari (contrairement
au mode `const_height` ou le niveau 0 est le nuage brut, jamais decime) : `spatialStep(niveau) =
initialStep * facteur^niveau` pour TOUS les niveaux 0..5 en mode compare (vs `null` puis
`initialStep * facteur^niveau` pour niveaux 1..5 en mode const_height).
- **Ordre de chargement pour `-VOLUME`** : `-O <top_comble.bin> -O <bottom_comble.bin> -VOLUME
-GRID_STEP g` (sans `-CONST_HEIGHT`, sans `-GROUND_IS_FIRST`). Verifie dans les sources
(`CommandVolume25D::process`) : le premier nuage charge = "ceil", le second = "ground" (sauf
`-GROUND_IS_FIRST` qui les inverse) — charger top puis bottom donne directement ceil=top,
ground=bottom, ce qui correspond au sens voulu.
- Remplissage des trous (voir section `-VOLUME` plus haut) applique aux DEUX nuages independamment,
a chaque niveau, avec le meme `gridStep`/`maxEdgeLength` adaptatifs que le mode single-cloud.
- La decimation des DEUX nuages est synchronisee (meme `spatialStep` a chaque niveau) ; si l'un des
deux tombe sous `minPointsToContinue`, toute la progression s'arrete (les deux surfaces sont
necessaires pour une comparaison valide).
## Paquet apt Debian `cloudcompare` (trixie, 2.13.2) — limitations et contournements
Choix d'archi : `debian:trixie-slim` + `apt-get install cloudcompare` plutot qu'une compilation depuis
@ -164,9 +235,14 @@ manquantes), mais pourraient etre simplifies.
- `docker-compose.yml` utilise un **volume nomme** (`app-data:/data`), pas un bind mount vers `./data` :
Coolify re-clone le repo a chaque deploiement, un bind mount relatif au checkout perdrait les
donnees (uploads, resultats, base sqlite) a chaque redeploy.
- `HEALTHCHECK` dans le Dockerfile (`curl` sur `/`) : Coolify l'utilise pour determiner si le
deploiement a reussi.
- `HEALTHCHECK` dans le Dockerfile (`curl` sur `/health`, route publique non authentifiee) : Coolify
l'utilise pour determiner si le deploiement a reussi.
- Le port d'ecoute est configurable via `PORT` (`config.ts` lit `process.env.PORT`, defaut 3000).
- Auth HTTP Basic globale (UI + API) via middleware Express (`app.use`, pas un Guard Nest) enregistre
dans `main.ts` **avant** `app.listen()` — verifie empiriquement que ca protege bien aussi les
fichiers statiques servis par `ServeStaticModule` (pas seulement les routes `@Controller`), malgre
le fait que `ServeStaticModule` s'enregistre via le systeme de modules Nest plutot que directement
sur l'instance `app`. Desactivable si `AUTH_USERNAME`/`AUTH_PASSWORD_HASH` absents (dev local).
## Constantes du pipeline (voir `src/config.ts`)
@ -175,5 +251,9 @@ manquantes), mais pourraient etre simplifies.
(niveau 0 : `initialStep x2`). Adapte dynamiquement, pas fige — voir section `-VOLUME` plus haut.
- `statsSampleCap = 50000` : nb de points echantillonnes pour estimer la distance mediane au plus
proche voisin (KD-tree scipy) qui sert de pas spatial initial.
- `minPointsToContinue = 25` : la decimation s'arrete si un niveau tombe en dessous.
- `maxEdgeLengthMultiplier = 3` : distance max d'interpolation Delaunay (remplissage de trous) a
CHAQUE niveau = gridStep de ce niveau x3. Override utilisateur possible (`maxEdgeLengthOverride`,
valeur absolue en metres) si ce defaut comble trop/pas assez pour un nuage donne.
- `minPointsToContinue = 25` : la decimation s'arrete si un niveau (ou l'un des 2 nuages en mode
compare) tombe en dessous.
- `matchingCellsWarnThreshold = 90` : seuil d'affichage "a verifier" dans l'UI.

View file

@ -1,18 +1,25 @@
# Volume 2.5D & robustesse a la decimation
Application locale (Docker) qui, a partir d'un nuage de points (LAS/LAZ/COPC.laz/BIN) :
Application locale (Docker) qui, a partir d'un ou deux nuages de points (LAS/LAZ/COPC.laz/BIN) :
1. calcule son volume en 2.5D via CloudCompare (plan de reference = altitude minimale du nuage) ;
2. le decime progressivement en methode **spatiale**, facteur **x2** sur **5 etapes** (le pas spatial double
a chaque etape, en partant d'un pas initial estime automatiquement = distance mediane au plus proche
voisin, calculee par KD-tree sur un echantillon) ;
3. recalcule le volume a chaque niveau avec le **meme plan de reference**, et une **grille de calcul
adaptee dynamiquement a la densite de chaque niveau** (grid step = pas spatial du niveau x2) pour
eviter des grilles artificiellement vides quand la decimation depasse la resolution de depart ;
4. assemble les 6 nuages (complet + 5 decimations) dans un seul fichier `.bin` CloudCompare, nomme et
consultable dans CloudCompare Desktop ;
5. produit un dossier de resultats : rapport CSV/JSON, cartes de hauteur par niveau, graphiques de
synthese (volume, nombre de points, robustesse) ;
1. calcule un volume en 2.5D via CloudCompare, selon 2 modes au choix :
- **Plan de reference Z constant** : un seul nuage, compare a un plan horizontal (altitude minimale
par defaut). Fiable uniquement si le nuage ne contient QUE l'objet mesure (pas de terrain/contexte
environnant) ;
- **Comparaison a un second nuage** : deux nuages, la **surface superieure** (sommet de l'amas) et
la **limite inferieure** (base/socle) — les densites sont d'abord appariees (le plus dense decime
pour matcher l'autre), puis les deux sont decimes ensemble a chaque etape ;
2. dans les deux modes, decime progressivement en methode **spatiale**, facteur **x2** sur **5 etapes**
(le pas spatial double a chaque etape, en partant d'un pas initial estime automatiquement = distance
mediane au plus proche voisin, calculee par KD-tree sur un echantillon) ;
3. recalcule le volume a chaque niveau avec une **grille de calcul adaptee dynamiquement a la densite
de chaque niveau** (grid step = pas spatial du niveau x2), et **comble les trous de scan** avant
chaque calcul (interpolation Delaunay bornee par une distance max, pour ne pas combler les parties
concaves du contour de l'objet) ;
4. assemble tous les nuages exploitables (complet + 5 decimations, x1 ou x2 selon le mode) dans un seul
fichier `.bin` CloudCompare, nomme et consultable dans CloudCompare Desktop ;
5. produit un dossier de resultats : rapport CSV/JSON, cartes de hauteur par niveau (et par nuage en
mode comparaison), graphiques de synthese (volume, nombre de points, robustesse) ;
6. expose tout ca dans une UI web simple avec historique des runs.
## Demarrage
@ -78,24 +85,33 @@ Le repo est pret pour un deploiement Coolify via webhook (push sur `main` -> bui
## Utilisation
1. "Nouveau run" -> choisir un fichier `.las`, `.laz`, `.copc.laz` ou `.bin`.
2. Optionnel : override du plan de reference Z (par defaut : altitude minimale d'un echantillon du
nuage) et/ou du pas spatial initial (par defaut : distance mediane au plus proche voisin).
1. "Nouveau run" -> choisir le type de comparaison :
- **Plan de reference Z constant** : un fichier `.las`, `.laz`, `.copc.laz` ou `.bin`.
- **Comparaison a un second nuage** : deux fichiers — "Surface superieure" (sommet de l'amas) et
"Limite inferieure" (base/socle de l'amas).
2. Optionnel (options avancees) : override du plan de reference Z (mode Z constant uniquement, par
defaut altitude minimale d'un echantillon), du pas spatial initial (par defaut : distance mediane
au plus proche voisin) et/ou de la distance max de remplissage des trous (par defaut : grille du
niveau x3 — augmenter si des trous de scan legitimes restent vides, diminuer si des creux du contour
de l'objet sont a tort combles).
3. Le run tourne en arriere-plan (une file d'attente sequentielle traite les runs un par un). La page
de detail se rafraichit automatiquement (polling 2s).
4. Une fois termine : tableau des 6 niveaux (points, volume, surface, % de cellules de grille
"matching"), cartes de hauteur, graphiques de synthese, et telechargements (`.bin` fusionne,
`.csv`, dossier complet en `.zip`).
4. Une fois termine : tableau des niveaux (points par nuage, volume, surface, % de cellules de grille
"matching"), cartes de hauteur (par nuage en mode comparaison), graphiques de synthese, et
telechargements (`.bin` fusionne, `.csv`, dossier complet en `.zip`).
### Lecture des resultats
- **% de cellules "matching"** (rapport de volume 2.5D de CloudCompare) : indique la part de la grille
de calcul ou les deux surfaces (nuage / plan de reference) sont effectivement comparables. Quand la
decimation depasse la resolution de la grille de calcul, ce pourcentage chute — c'est le signal
principal de perte de robustesse. Un niveau est marque "a verifier" des que ce taux passe sous 90 %
(configurable via `MATCHING_CELLS_WARN_THRESHOLD` cote code, `config.ts`).
- La decimation s'arrete automatiquement si un niveau tombe sous 25 points exploitables ; les niveaux
suivants sont marques "skipped" avec la raison.
de calcul ou les deux surfaces sont effectivement comparables. Un niveau est marque "a verifier" des
que ce taux passe sous 90 % (configurable via `matchingCellsWarnThreshold`, `config.ts`).
- La decimation s'arrete automatiquement si un niveau (ou l'un des deux nuages en mode comparaison)
tombe sous 25 points exploitables ; les niveaux suivants sont marques "skipped" avec la raison.
- **Choix du mode** : le mode "Z constant" n'est fiable que si le nuage uploade ne contient QUE
l'objet a mesurer. S'il contient aussi du terrain/contexte environnant (scan de site complet par
exemple), le plan Z constant (altitude minimale de tout le nuage) n'a souvent aucun rapport avec la
base reelle de l'objet et donne un volume trompeur — utiliser "Comparaison a un second nuage" avec
une base dediee dans ce cas.
## Parametres (variables d'environnement, voir `docker-compose.yml`)

View file

@ -22,6 +22,10 @@ function fmt(n, digits = 4) {
return n.toLocaleString('fr-FR', { maximumFractionDigits: digits });
}
function roleLabel(role) {
return role === 'top' ? 'haut' : role === 'bottom' ? 'bas' : '';
}
function statusBadge(status) {
const map = {
pending: 'bg-slate-700 text-slate-200',
@ -90,15 +94,45 @@ function renderNew() {
<h1 class="text-xl font-semibold mb-6">Nouveau run</h1>
<form id="upload-form" class="space-y-5 max-w-xl">
<div>
<label class="block text-sm font-medium mb-1">Nuage de points (.las, .laz, .copc.laz, .bin)</label>
<input required type="file" name="file" accept=".las,.laz,.bin"
class="block w-full text-sm text-slate-300 border border-slate-700 rounded-lg cursor-pointer bg-slate-900 focus:outline-none p-2" />
<label class="block text-sm font-medium mb-2">Type de comparaison</label>
<div class="flex gap-4 text-sm">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="mode" value="const_height" checked />
Plan de reference Z constant
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="mode" value="cloud_compare" />
Comparaison a un second nuage
</label>
</div>
</div>
<div id="mode-const-height">
<label class="block text-sm font-medium mb-1">Nuage de points (.las, .laz, .copc.laz, .bin)</label>
<input type="file" name="file" accept=".las,.laz,.bin"
class="block w-full text-sm text-slate-300 border border-slate-700 rounded-lg cursor-pointer bg-slate-900 focus:outline-none p-2" />
<p class="text-xs text-slate-500 mt-1">Le volume est calcule entre le nuage et un plan horizontal (altitude min par defaut).</p>
</div>
<div id="mode-cloud-compare" class="hidden space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Surface superieure (sommet de l'amas)</label>
<input type="file" name="fileTop" accept=".las,.laz,.bin"
class="block w-full text-sm text-slate-300 border border-slate-700 rounded-lg cursor-pointer bg-slate-900 focus:outline-none p-2" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Limite inferieure (base / socle de l'amas)</label>
<input type="file" name="fileBottom" accept=".las,.laz,.bin"
class="block w-full text-sm text-slate-300 border border-slate-700 rounded-lg cursor-pointer bg-slate-900 focus:outline-none p-2" />
</div>
<p class="text-xs text-slate-500">Les densites des deux nuages sont d'abord appariees (le plus dense est decime pour matcher l'autre), puis les deux sont decimes ensemble a chaque etape.</p>
</div>
<details class="border border-slate-800 rounded-lg p-3">
<summary class="cursor-pointer text-sm text-slate-400">Options avancees (optionnel)</summary>
<div class="mt-3 space-y-3">
<div>
<label class="block text-sm mb-1">Plan de reference Z (override)</label>
<div id="zref-override-wrap">
<label class="block text-sm mb-1">Plan de reference Z (override, mode Z constant uniquement)</label>
<input type="number" step="any" name="zrefOverride" placeholder="auto = Zmin de l'echantillon"
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
</div>
@ -107,15 +141,41 @@ function renderNew() {
<input type="number" step="any" name="initialStepOverride" placeholder="auto = distance mediane au plus proche voisin"
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
</div>
<div>
<label class="block text-sm mb-1">Distance max de remplissage des trous en metres (override)</label>
<input type="number" step="any" name="maxEdgeLengthOverride" placeholder="auto = grille du niveau x3"
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
<p class="text-xs text-slate-500 mt-1">Trop grand = comble aussi les creux du contour de l'amas (a eviter). Trop petit = les trous de scan restent vides.</p>
</div>
</div>
</details>
<button type="submit" class="bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded-lg font-medium">Lancer le run</button>
<p id="upload-error" class="text-red-400 text-sm hidden"></p>
</form>`;
document.getElementById('upload-form').addEventListener('submit', async (e) => {
const form = document.getElementById('upload-form');
const constDiv = document.getElementById('mode-const-height');
const compareDiv = document.getElementById('mode-cloud-compare');
const zrefWrap = document.getElementById('zref-override-wrap');
const constFileInput = form.querySelector('input[name=file]');
const topFileInput = form.querySelector('input[name=fileTop]');
const bottomFileInput = form.querySelector('input[name=fileBottom]');
form.querySelectorAll('input[name=mode]').forEach((radio) => {
radio.addEventListener('change', () => {
const isCompare = form.mode.value === 'cloud_compare';
constDiv.classList.toggle('hidden', isCompare);
compareDiv.classList.toggle('hidden', !isCompare);
zrefWrap.classList.toggle('hidden', isCompare);
constFileInput.required = !isCompare;
topFileInput.required = isCompare;
bottomFileInput.required = isCompare;
});
});
constFileInput.required = true;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const fd = new FormData(form);
const errEl = document.getElementById('upload-error');
errEl.classList.add('hidden');
@ -156,16 +216,30 @@ async function loadDetail(id) {
paintDetail(run);
}
function levelRow(l, id) {
function pointsCell(l) {
if (l.status !== 'ok') return '-';
return l.clouds.map((c) => {
const label = c.role === 'unique' ? '' : `${roleLabel(c.role)}: `;
return `${label}${c.pointCount.toLocaleString('fr-FR')} (${fmt(c.pointRatio * 100, 1)}%)`;
}).join('<br/>');
}
function stepCell(l) {
const c = l.clouds[0];
if (!c || c.spatialStep === null) return 'complet';
return fmt(c.spatialStep, 5) + ' m';
}
function levelRow(l) {
const warnCls = l.status === 'error' ? 'bg-red-950/40' : l.warn ? 'bg-amber-950/30' : '';
const statusTxt = l.status === 'ok' ? '' : `<div class="text-xs text-slate-400 mt-0.5">${escapeHtml(l.message || '')}</div>`;
return `
<tr class="border-b border-slate-800 ${warnCls}">
<td class="py-2 px-3 font-medium">${l.level}</td>
<td class="py-2 px-3">${l.spatialStep !== null ? fmt(l.spatialStep, 5) + ' m' : 'complet'}</td>
<td class="py-2 px-3">${stepCell(l)}</td>
<td class="py-2 px-3">${l.gridStep !== null ? fmt(l.gridStep, 5) + ' m' : '-'}</td>
<td class="py-2 px-3">${l.status === 'ok' ? l.pointCount.toLocaleString('fr-FR') : '-'}</td>
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.pointRatio * 100, 2) + ' %' : '-'}</td>
<td class="py-2 px-3">${l.maxEdgeLength !== null ? fmt(l.maxEdgeLength, 5) + ' m' : '-'}</td>
<td class="py-2 px-3">${pointsCell(l)}</td>
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.volume, 4) + ' m3' : '-'}</td>
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.surface, 3) + ' m2' : '-'}</td>
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.matchingCellsPct, 1) + ' %' : '-'}</td>
@ -175,32 +249,45 @@ function levelRow(l, id) {
function paintDetail(run) {
const r = run.report;
const isCompare = r && r.mode === 'cloud_compare';
const levelsTable = r ? `
<div class="overflow-x-auto border border-slate-800 rounded-lg mt-4">
<table class="w-full text-left text-sm">
<thead class="bg-slate-900 text-slate-400 text-xs uppercase">
<tr>
<th class="py-2 px-3">Niveau</th><th class="py-2 px-3">Pas spatial</th><th class="py-2 px-3">Grille volume</th><th class="py-2 px-3">Points</th>
<th class="py-2 px-3">% points vs L0</th><th class="py-2 px-3">Volume</th><th class="py-2 px-3">Surface</th>
<th class="py-2 px-3">Niveau</th><th class="py-2 px-3">Pas spatial</th><th class="py-2 px-3">Grille volume</th>
<th class="py-2 px-3">Max edge (remplissage)</th><th class="py-2 px-3">Points</th>
<th class="py-2 px-3">Volume</th><th class="py-2 px-3">Surface</th>
<th class="py-2 px-3">Matching cells</th><th class="py-2 px-3">Statut</th>
</tr>
</thead>
<tbody>${r.levels.map((l) => levelRow(l, run.id)).join('')}</tbody>
<tbody>${r.levels.map((l) => levelRow(l)).join('')}</tbody>
</table>
</div>` : '';
const densityBlock = r && r.params.densityMatch ? `
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3 text-sm mt-4">
<p class="text-slate-400 text-xs mb-1">Appariement de densite (niveau 0)</p>
<p>Pas haut: ${fmt(r.params.densityMatch.topSpacing, 5)} m &middot; Pas bas: ${fmt(r.params.densityMatch.bottomSpacing, 5)} m
&middot; Pas apparie: ${fmt(r.params.densityMatch.matchedSpacing, 5)} m
(${r.params.densityMatch.preDecimatedRole === 'none' ? 'aucun nuage decime, deja comparables' : `nuage ${roleLabel(r.params.densityMatch.preDecimatedRole)} decime pour matcher`})</p>
</div>` : '';
const params = r ? `
<dl class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm mt-4">
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Zref</dt><dd>${fmt(r.params.zref, 4)} (${r.params.zrefSource})</dd></div>
${!isCompare ? `<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Zref</dt><dd>${fmt(r.params.zref, 4)} (${r.params.zrefSource})</dd></div>` : ''}
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Pas initial</dt><dd>${fmt(r.params.initialStep, 5)} m (${r.params.initialStepSource})</dd></div>
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Grille volume</dt><dd>x${r.params.gridStepMultiplier} le pas spatial, adaptee a chaque niveau</dd></div>
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Remplissage trous</dt><dd>x${r.params.maxEdgeLengthMultiplier} la grille (${r.params.maxEdgeLengthSource})</dd></div>
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Facteur / etapes</dt><dd>x${r.params.factor} sur ${r.params.steps}</dd></div>
</dl>` : '';
</dl>
${densityBlock}` : '';
const heightmaps = r ? r.levels.filter((l) => l.imageFile).map((l) => `
const heightmaps = r ? r.levels.flatMap((l) => l.clouds.filter((c) => c.imageFile).map((c) => ({ level: l.level, c }))).map(({ level, c }) => `
<div class="bg-slate-900 border border-slate-800 rounded-lg p-2">
<img src="${API}/${run.id}/images/${l.imageFile.split('/').pop()}" class="w-full rounded" loading="lazy" />
<p class="text-xs text-slate-400 text-center mt-1">Niveau ${l.level}</p>
<img src="${API}/${run.id}/images/${c.imageFile.split('/').pop()}" class="w-full rounded" loading="lazy" />
<p class="text-xs text-slate-400 text-center mt-1">Niveau ${level}${c.role !== 'unique' ? ' - ' + roleLabel(c.role) : ''}</p>
</div>`).join('') : '';
const summaryCharts = run.status === 'done' ? ['volume_vs_level.png', 'points_vs_level.png', 'robustness_vs_level.png'].map((f) => `
@ -210,7 +297,7 @@ function paintDetail(run) {
const downloads = run.status === 'done' ? `
<div class="flex flex-wrap gap-2 mt-4">
<a href="${API}/${run.id}/download/bin" class="bg-slate-800 hover:bg-slate-700 px-3 py-2 rounded-lg text-sm">Nuage assemble (.bin)</a>
<a href="${API}/${run.id}/download/bin" class="bg-slate-800 hover:bg-slate-700 px-3 py-2 rounded-lg text-sm">Nuages assembles (.bin)</a>
<a href="${API}/${run.id}/download/csv" class="bg-slate-800 hover:bg-slate-700 px-3 py-2 rounded-lg text-sm">Rapport (.csv)</a>
<a href="${API}/${run.id}/download/zip" class="bg-slate-800 hover:bg-slate-700 px-3 py-2 rounded-lg text-sm">Dossier complet (.zip)</a>
</div>` : '';
@ -222,7 +309,10 @@ function paintDetail(run) {
<h1 class="text-xl font-semibold">${escapeHtml(run.originalFilename)}</h1>
${statusBadge(run.status)}
</div>
<p class="text-slate-400 text-sm mb-4">Cree le ${new Date(run.createdAt).toLocaleString('fr-FR')}${run.finishedAt ? ' - termine le ' + new Date(run.finishedAt).toLocaleString('fr-FR') : ''}</p>
<p class="text-slate-400 text-sm mb-4">
${r ? (isCompare ? 'Mode : comparaison a un second nuage' : 'Mode : plan de reference Z constant') : ''}
&middot; Cree le ${new Date(run.createdAt).toLocaleString('fr-FR')}${run.finishedAt ? ' - termine le ' + new Date(run.finishedAt).toLocaleString('fr-FR') : ''}
</p>
${errorBlock}
${params}
${levelsTable}

View file

@ -79,10 +79,17 @@ def cmd_summary(args: argparse.Namespace) -> int:
xs = [lvl["level"] for lvl in levels]
volumes = [lvl["volume"] for lvl in levels]
points = [lvl["pointCount"] for lvl in levels]
matching = [lvl["matchingCellsPct"] for lvl in levels]
v0 = volumes[0] if volumes else None
# En mode cloud_compare chaque niveau a 2 nuages (top/bottom) ; en mode const_height, 1 seul.
point_series = {}
for lvl in levels:
for c in lvl.get("clouds", []):
point_series.setdefault(c["role"], []).append(c["pointCount"])
role_colors = {"unique": "#16a34a", "top": "#16a34a", "bottom": "#ea580c"}
role_labels = {"unique": "Points", "top": "Points (haut)", "bottom": "Points (bas)"}
# --- Volume vs niveau ---
fig, ax = plt.subplots(figsize=(6, 4), dpi=130)
ax.plot(xs, volumes, marker="o", color="#2563eb")
@ -97,12 +104,15 @@ def cmd_summary(args: argparse.Namespace) -> int:
# --- Nombre de points (log) vs niveau ---
fig, ax = plt.subplots(figsize=(6, 4), dpi=130)
ax.semilogy(xs, points, marker="o", color="#16a34a")
for role, series in point_series.items():
ax.semilogy(xs[: len(series)], series, marker="o", color=role_colors.get(role, "#16a34a"), label=role_labels.get(role, role))
ax.set_xlabel("Niveau de decimation")
ax.set_ylabel("Nombre de points (log)")
ax.set_title("Nombre de points vs niveau de decimation")
ax.set_xticks(xs)
ax.grid(alpha=0.3, which="both")
if len(point_series) > 1:
ax.legend()
fig.tight_layout()
fig.savefig(outdir / "points_vs_level.png")
plt.close(fig)

View file

@ -86,6 +86,27 @@ describe('CcRunnerService', () => {
await fs.writeFile(tmpFile, 'contenu inattendu sans les champs voulus', 'utf-8');
await expect(service.parseVolumeReportFile(tmpFile)).rejects.toThrow(/Champ introuvable/);
});
it('parse correctement les grands volumes avec separateur de milliers (bug reel observe : "14,244.46" tronque a 14 sans le fix)', async () => {
const bigContent = [
'Volume: 14,244.464657',
'Surface: 10,915.250000',
'----------------------',
'Added volume: (+)14,335.948086',
'Removed volume: (-)91.483429',
'----------------------',
'Matching cells: 97.1%',
'Non-matching cells:',
' ground = 2.9%',
' ceil = 0.0%',
'Average neighbors per cell: 7.9 / 8.0',
].join('\n');
await fs.writeFile(tmpFile, bigContent, 'utf-8');
const result = await service.parseVolumeReportFile(tmpFile);
expect(result.volume).toBeCloseTo(14244.464657);
expect(result.surface).toBeCloseTo(10915.25);
expect(result.addedVolume).toBeCloseTo(14335.948086);
});
});
describe('findLatestFile', () => {

View file

@ -113,29 +113,36 @@ export class CcRunnerService {
return candidates[0].full;
}
/** Parse le fichier texte "VolumeCalculationReport_*.txt" genere par -VOLUME */
/**
* 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 Number(m[1]);
return toNumber(m[1]);
};
const numOpt = (re: RegExp): number | null => {
const m = text.match(re);
return m ? Number(m[1]) : null;
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]+)/),
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]+)/),
};
}
}

View file

@ -20,6 +20,11 @@ export const config = {
// Grille de calcul du volume = pas spatial DE CHAQUE NIVEAU x ce multiplicateur (adaptee
// dynamiquement a chaque niveau, pas figee sur la resolution du niveau 0 - voir CLAUDE.md).
gridStepMultiplier: 2,
// Remplissage des trous (occlusions de scan) avant -VOLUME : distance max d'interpolation
// Delaunay = gridStep du niveau x ce multiplicateur. Empeche de combler les parties concaves
// du contour de l'amas (qui doivent rester "vides") tout en bouchant les petits trous de scan.
// Voir CLAUDE.md : -VOLUME n'a PAS d'option de remplissage native, on passe par -RASTERIZE.
maxEdgeLengthMultiplier: 3,
// En dessous de ce nombre de points, on arrete la decimation progressive (cloud devenu inexploitable)
minPointsToContinue: 25,
// Seuil d'alerte robustesse : si "matching cells %" du rapport CloudCompare passe sous ce seuil,

View file

@ -3,21 +3,41 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import { config } from '../config';
import { CcRunnerService } from '../cloudcompare/cc-runner.service';
import { PyHelperService } from '../pyhelper/pyhelper.service';
import { LevelResult, RunParamsInput, RunReport } from './types';
import { PyHelperService, CloudStats } from '../pyhelper/pyhelper.service';
import { CloudRole, LevelCloudInfo, LevelResult, RunParamsInput, RunReport } 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 du nuage avec trous combles (INTERP), utilise pour -VOLUME */
filledBinAbs: string | null;
}
/**
* Orchestration complete d'un run :
* 1) statistiques (pas spatial initial, plan de reference Z)
* 2) niveau 0 = nuage complet -> volume + image
* 3) niveaux 1..N = decimation spatiale progressive (facteur 2, chainee depuis le niveau precedent)
* 4) assemblage des nuages exploitables dans un seul .bin
* 5) rapport CSV/JSON + graphiques de synthese
* Orchestration complete d'un run. Deux modes :
* - const_height : 1 nuage, compare a un plan Z constant (comme avant).
* - 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 (interpolation Delaunay, bornee par
* max-edge-length) avant chaque calcul de volume - voir CLAUDE.md, -VOLUME n'a pas cette option
* nativement, on passe par -RASTERIZE en amont.
*/
@Injectable()
export class PipelineService {
@ -30,8 +50,7 @@ export class PipelineService {
async execute(
runId: string,
inputPath: string,
originalFilename: string,
inputs: RunInputs,
workDir: string,
params: RunParamsInput,
onProgress: (msg: string) => void,
@ -43,124 +62,198 @@ export class PipelineService {
await fs.mkdir(imagesDir, { recursive: true });
await fs.mkdir(rawDir, { recursive: true });
// ---------- 0) Normalisation du format d'entree ----------
// Le paquet apt CloudCompare (Debian, utilise dans l'image Docker) ne fournit pas le plugin
// LAS/LAZ (seulement "Core I/O"). On convertit donc LAS/LAZ/COPC en ASCII XYZ via laspy avant
// de transmettre le nuage a CloudCompare, qui lit nativement l'ASCII sans plugin.
let normalizedInputPath = inputPath;
const lowerInput = inputPath.toLowerCase();
if (lowerInput.endsWith('.las') || lowerInput.endsWith('.laz')) {
onProgress('Conversion du nuage source (LAS/LAZ/COPC) en ASCII (CloudCompare ne lit pas le LAS nativement dans ce conteneur)...');
const xyzPath = path.join(workDir, 'input_converted.xyz');
await this.py.convertLasToXyz(inputPath, xyzPath);
normalizedInputPath = xyzPath;
}
const isCompare = params.mode === 'cloud_compare';
// ---------- 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 initial (echantillonnage + KD-tree)...');
onProgress('Estimation du pas spatial (echantillonnage + KD-tree)...');
const t0 = Date.now();
const sampleRes = await this.cc.run(
['-O', normalizedInputPath, '-SS', 'RANDOM', String(config.statsSampleCap), '-C_EXPORT_FMT', 'ASC', '-PREC', '6', '-SAVE_CLOUDS', 'FILE', 'sample.xyz'],
workDir,
);
const totalPointCount = this.cc.parseLoadedPointCount(sampleRes.stdout);
if (totalPointCount === null) {
throw new Error(`Impossible de lire le nuage d'entree. Sortie CloudCompare:\n${sampleRes.stdout.slice(-2000)}`);
const primaryStats = await this.computeStatsFor(primaryNorm, 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;
zref = params.zrefOverride ?? primaryStats.stats.bbox.zmin;
zrefSource = params.zrefOverride !== undefined ? 'override' : 'auto';
onProgress(
`Pas initial=${initialStep.toFixed(5)}m (${params.initialStepOverride ? 'override' : 'auto, mediane NN'}), ` +
`Zref=${zref.toFixed(4)} (${zrefSource}). [${Date.now() - t0}ms]`,
);
} else {
const secondaryStats = await this.computeStatsFor(secondaryNorm!, 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 samplePath = path.join(workDir, 'sample.xyz');
await this.assertExists(samplePath, 'echantillonnage du nuage (sample.xyz)', sampleRes.stdout);
const stats = await this.py.computeStats(samplePath);
const initialStep = params.initialStepOverride ?? stats.median_nn;
const zref = params.zrefOverride ?? stats.bbox.zmin;
// La grille de calcul du volume est adaptee dynamiquement a CHAQUE niveau (grid_step = pas
// spatial du niveau x multiplicateur), plutot que figee sur la resolution du niveau 0. Une
// grille fixe forcait des cellules vides des que la decimation depassait la resolution de
// depart (matching cells % s'effondrait des le niveau 1) - voir CLAUDE.md.
const level0GridStep = initialStep * config.gridStepMultiplier;
onProgress(
`Pas initial=${initialStep.toFixed(5)}m (${params.initialStepOverride ? 'override' : 'auto, mediane NN'}), ` +
`grille volume niveau 0=${level0GridStep.toFixed(5)}m (adaptee dynamiquement aux niveaux suivants), ` +
`Zref=${zref.toFixed(4)} (${params.zrefOverride ? 'override' : 'auto, Zmin echantillon'}). ` +
`[${Date.now() - t0}ms]`,
);
const maxEdgeLengthSource: 'override' | 'auto' = params.maxEdgeLengthOverride !== undefined ? 'override' : 'auto';
const levels: LevelResult[] = [];
let stopped = false;
let stopReason = '';
// ---------- 2) Niveau 0 : nuage complet ----------
onProgress('Niveau 0 (nuage complet) : conversion .bin + calcul de volume...');
const level0 = await this.computeLevel({
level: 0,
spatialStep: null,
sourceBinOrOriginal: normalizedInputPath,
isFirstConversion: true,
levelsDir,
imagesDir,
rawDir,
workDir,
gridStep: level0GridStep,
zref,
refPointCount: totalPointCount,
onProgress,
});
levels.push(level0);
// Etat courant par role : chemin du nuage BRUT (non rempli) du niveau precedent
const prevRaw: Partial<Record<CloudRole, string>> = {};
const refPointCount: Partial<Record<CloudRole, number>> = {};
// ---------- 3) Niveaux 1..N : decimation progressive ----------
let prevBinAbs = level0.binFile ? path.join(workDir, level0.binFile) : null;
let prevPointCount = level0.pointCount;
let stopped = level0.status !== 'ok';
let stopReason = level0.status !== 'ok' ? 'le niveau 0 a echoue' : '';
const rolesForThisRun: CloudRole[] = isCompare ? ['top', 'bottom'] : ['unique'];
for (let i = 1; i <= config.decimationSteps; i++) {
if (stopped || !prevBinAbs) {
for (let i = 0; i <= config.decimationSteps; i++) {
if (stopped) {
levels.push(this.skippedLevel(i, stopReason));
continue;
}
if (prevPointCount < config.minPointsToContinue) {
stopReason = `nuage trop clairseme apres le niveau ${i - 1} (${prevPointCount} points < seuil ${config.minPointsToContinue})`;
levels.push(this.skippedLevel(i, stopReason));
stopped = true;
continue;
}
const spatialStep = initialStep * Math.pow(config.decimationFactor, i);
const levelGridStep = spatialStep * config.gridStepMultiplier;
// 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) * config.gridStepMultiplier;
const maxEdgeLength = params.maxEdgeLengthOverride ?? gridStep * config.maxEdgeLengthMultiplier;
onProgress(
`Niveau ${i} : decimation spatiale (pas=${spatialStep.toFixed(5)}m, grille volume=${levelGridStep.toFixed(5)}m) depuis le niveau ${i - 1}...`,
`Niveau ${i} : ${spatialStep === null ? 'nuage complet' : `decimation (pas=${spatialStep.toFixed(5)}m)`}, ` +
`grille=${gridStep.toFixed(5)}m, remplissage trous (max edge=${maxEdgeLength.toFixed(5)}m)...`,
);
try {
const lvl = await this.computeLevel({
level: i,
spatialStep,
sourceBinOrOriginal: prevBinAbs,
isFirstConversion: false,
levelsDir,
imagesDir,
rawDir,
workDir,
gridStep: levelGridStep,
zref,
refPointCount: totalPointCount,
onProgress,
});
levels.push(lvl);
if (lvl.status === 'ok') {
prevBinAbs = lvl.binFile ? path.join(workDir, lvl.binFile) : null;
prevPointCount = lvl.pointCount;
} else {
const cloudResults: CloudPrepResult[] = [];
for (const role of rolesForThisRun) {
const sourcePath = i === 0 ? (role === 'bottom' ? secondaryNorm! : primaryNorm) : prevRaw[role]!;
const isFirstConversion = !isCompare && i === 0;
const res = await this.prepareCloudAtLevel({
level: i,
role,
spatialStep,
sourcePath,
isFirstConversion,
levelsDir,
imagesDir,
rawDir,
workDir,
gridStep,
maxEdgeLength,
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,
warn: true,
status: anyBad.status,
message: anyBad.message,
computeTimeMs: 0,
});
stopped = true;
stopReason = lvl.message ?? `echec au niveau ${i}`;
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].filledBinAbs!), '-VOLUME', '-GRID_STEP', String(gridStep), '-CONST_HEIGHT', String(zref)],
workDir,
{ autoSave: true },
);
} else {
volRes = await this.cc.run(
[
'-O',
this.relFrom(workDir, cloudResults[0].filledBinAbs!), // top = ceil
'-O',
this.relFrom(workDir, cloudResults[1].filledBinAbs!), // bottom = ground
'-VOLUME',
'-GRID_STEP',
String(gridStep),
],
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
const strayGrid = await this.cc.findLatestFile(workDir, { suffix: '.bin', after: beforeVolume });
const filledPaths = new Set(cloudResults.map((r) => r.filledBinAbs));
if (strayGrid && !filledPaths.has(strayGrid)) {
await fs.unlink(strayGrid).catch(() => undefined);
}
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,
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,
spatialStep,
gridStep: levelGridStep,
pointCount: 0,
pointRatio: 0,
gridStep,
maxEdgeLength,
clouds: rolesForThisRun.map((role) => ({ role, spatialStep, pointCount: 0, pointRatio: 0, binFile: null, imageFile: null })),
volume: null,
surface: null,
addedVolume: null,
@ -171,8 +264,6 @@ export class PipelineService {
warn: true,
status: 'error',
message: err.message,
binFile: null,
imageFile: null,
computeTimeMs: 0,
});
stopped = true;
@ -180,15 +271,21 @@ export class PipelineService {
}
}
// ---------- 4) Assemblage ----------
// ---------- Assemblage ----------
onProgress('Assemblage des nuages exploitables dans un seul fichier .bin...');
const okLevels = levels.filter((l) => l.status === 'ok' && l.binFile);
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 (okLevels.length > 0) {
const baseName = sanitizeBaseName(originalFilename);
if (okBinFiles.length > 0) {
const baseName = sanitizeBaseName(inputs.primaryFilename);
const mergedName = `${baseName}_all_levels.bin`;
const args: string[] = [];
for (const lvl of okLevels) args.push('-O', lvl.binFile!);
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);
@ -198,21 +295,26 @@ export class PipelineService {
const report: RunReport = {
runId,
originalFilename,
mode: params.mode,
originalFilename: inputs.primaryFilename,
originalFilenameBottom: inputs.secondaryFilename ?? null,
createdAt: new Date().toISOString(),
params: {
zref,
zrefSource: params.zrefOverride !== undefined ? 'override' : 'auto',
zrefSource,
initialStep,
initialStepSource: params.initialStepOverride !== undefined ? 'override' : 'auto',
gridStepMultiplier: config.gridStepMultiplier,
maxEdgeLengthMultiplier: config.maxEdgeLengthMultiplier,
maxEdgeLengthSource,
factor: config.decimationFactor,
steps: config.decimationSteps,
densityMatch,
sampleStats: {
sampleCount: stats.count,
meanNn: stats.mean_nn,
medianNn: stats.median_nn,
bboxApprox: stats.bbox,
sampleCount: primaryStats.stats.count,
meanNn: primaryStats.stats.mean_nn,
medianNn: primaryStats.stats.median_nn,
bboxApprox: primaryStats.stats.bbox,
},
},
levels,
@ -220,9 +322,8 @@ export class PipelineService {
};
await fs.writeFile(path.join(workDir, 'report.json'), JSON.stringify(report, null, 2), 'utf-8');
await fs.writeFile(path.join(workDir, 'report.csv'), this.toCsv(report), 'utf-8');
await fs.writeFile(path.join(workDir, 'report.csv'), reportToCsv(report), 'utf-8');
// ---------- 5) Graphiques de synthese ----------
onProgress('Generation des graphiques de synthese...');
try {
await this.py.renderSummary(path.join(workDir, 'report.json'), imagesDir);
@ -233,13 +334,41 @@ export class PipelineService {
return report;
}
/** Convertit LAS/LAZ/COPC en ASCII (le paquet apt CloudCompare n'a pas le plugin LAS) ; laisse .bin tel quel. */
private async normalizeInput(inputPath: string, workDir: string, tag: string, onProgress: (msg: string) => void): Promise<string> {
const lower = inputPath.toLowerCase();
if (!lower.endsWith('.las') && !lower.endsWith('.laz')) return inputPath;
onProgress(`Conversion du nuage (${tag}) LAS/LAZ/COPC en ASCII...`);
const xyzPath = path.join(workDir, `input_${tag}.xyz`);
await this.py.convertLasToXyz(inputPath, xyzPath);
return xyzPath;
}
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 stats = await this.py.computeStats(samplePath);
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,
spatialStep: null,
gridStep: null,
pointCount: 0,
pointRatio: 0,
maxEdgeLength: null,
clouds: [],
volume: null,
surface: null,
addedVolume: null,
@ -250,8 +379,6 @@ export class PipelineService {
warn: true,
status: 'skipped',
message: reason,
binFile: null,
imageFile: null,
computeTimeMs: 0,
};
}
@ -264,173 +391,131 @@ export class PipelineService {
}
}
private async computeLevel(opts: {
/**
* Prepare le nuage d'UN role a UN niveau : decimation (ou conversion initiale), export de la
* carte de hauteur, puis remplissage des trous (rasterize + interpolation Delaunay bornee) pour
* produire la version utilisee par -VOLUME. Le nuage BRUT (non rempli) 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;
sourceBinOrOriginal: string;
sourcePath: string;
isFirstConversion: boolean;
levelsDir: string;
imagesDir: string;
rawDir: string;
workDir: string;
gridStep: number;
zref: number;
maxEdgeLength: number;
refPointCount: number;
onProgress: (msg: string) => void;
}): Promise<LevelResult> {
const { level, spatialStep, workDir, gridStep, zref, refPointCount } = opts;
const t0 = Date.now();
}): Promise<CloudPrepResult> {
const { level, role, spatialStep, workDir, gridStep, maxEdgeLength, refPointCount } = opts;
const suffix = role === 'unique' ? '' : `_${role}`;
let binRelPath: string;
let pointCount: number;
if (opts.isFirstConversion) {
// Niveau 0 : simple conversion du fichier d'entree en .bin canonique
binRelPath = path.posix.join('levels', 'L0_full.bin');
const res = await this.cc.run(['-O', opts.sourceBinOrOriginal, '-C_EXPORT_FMT', 'BIN', '-SAVE_CLOUDS', 'FILE', binRelPath], workDir);
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.errorLevel(level, spatialStep, `Conversion niveau 0 echouee. Sortie:\n${res.stdout.slice(-1500)}`, t0, null, gridStep);
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 0', res.stdout);
await this.assertExists(path.join(workDir, binRelPath), `conversion niveau ${level}${suffix}`, res.stdout);
} else {
// Niveaux 1..N : decimation spatiale depuis le niveau precedent
const stepStr = spatialStep!.toFixed(6);
binRelPath = path.posix.join('levels', `L${level}_step${stepStr}.bin`);
binRelPath = path.posix.join('levels', `L${level}${suffix}_step${stepStr}.bin`);
const res = await this.cc.run(
['-O', opts.sourceBinOrOriginal, '-SS', 'SPATIAL', stepStr, '-C_EXPORT_FMT', 'BIN', '-SAVE_CLOUDS', 'FILE', binRelPath],
['-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 {
return this.badResult(
level,
role,
spatialStep,
gridStep: opts.gridStep,
pointCount: 0,
pointRatio: 0,
volume: null,
surface: null,
addedVolume: null,
removedVolume: null,
matchingCellsPct: null,
groundNonMatchingPct: null,
ceilNonMatchingPct: null,
warn: true,
status: 'skipped',
message: `decimation spatiale n'a produit aucun point exploitable (pas=${stepStr}m)`,
binFile: null,
imageFile: null,
computeTimeMs: Date.now() - t0,
};
`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}`, res.stdout);
await this.assertExists(path.join(workDir, binRelPath), `decimation niveau ${level}${suffix}`, res.stdout);
}
// Volume (necessite AUTO_SAVE ON, sinon CloudCompare ne genere pas le rapport texte - voir cc-runner.service.ts)
const beforeVolume = Date.now();
const volRes = await this.cc.run(
['-O', binRelPath, '-VOLUME', '-GRID_STEP', String(gridStep), '-CONST_HEIGHT', String(zref)],
workDir,
{ autoSave: true },
);
const reportFile = await this.cc.findLatestFile(workDir, { prefix: 'VolumeCalculationReport', suffix: '.txt', after: beforeVolume });
if (!reportFile) {
return this.errorLevel(level, spatialStep, `Rapport de volume introuvable au niveau ${level}. Sortie:\n${volRes.stdout.slice(-1500)}`, t0, binRelPath, gridStep);
}
const vol = await this.cc.parseVolumeReportFile(reportFile);
const rawReportDest = path.join(opts.rawDir, `L${level}_volume_report.txt`);
await fs.rename(reportFile, rawReportDest);
const rawBinAbs = path.join(workDir, binRelPath);
// AUTO_SAVE ON sauvegarde aussi une grille "*_HEIGHT_DIFFERENCE_*.bin" qu'on ne veut pas garder
const strayGrid = await this.cc.findLatestFile(workDir, { suffix: '.bin', after: beforeVolume });
if (strayGrid && strayGrid !== path.join(workDir, binRelPath)) {
await fs.unlink(strayGrid).catch(() => undefined);
}
// Carte de hauteur : export ASCII du nuage + binning numpy cote Python.
// (Le paquet apt CloudCompare, utilise dans l'image Docker, plante sur l'export GeoTIFF natif
// -RASTERIZE -OUTPUT_RASTER_Z faute de support GDAL compile - on reconstruit donc la grille
// nous-memes a partir d'un export ASCII, qui lui fonctionne sans plugin.)
// Carte de hauteur (a partir du nuage BRUT, pour montrer les vrais trous de scan)
let imageRelPath: string | null = null;
try {
const xyzExportRel = path.posix.join('raw', `L${level}_points.xyz`);
const xyzExportRel = path.posix.join('raw', `L${level}${suffix}_points.xyz`);
await this.cc.run(['-O', binRelPath, '-C_EXPORT_FMT', 'ASC', '-PREC', '6', '-SAVE_CLOUDS', 'FILE', xyzExportRel], workDir);
const xyzAbs = path.join(workDir, xyzExportRel);
await this.assertExists(xyzAbs, `export ASCII niveau ${level}`, '');
const pngName = `L${level}_heightmap.png`;
await this.assertExists(xyzAbs, `export ASCII niveau ${level}${suffix}`, '');
const pngName = `L${level}${suffix}_heightmap.png`;
const pngAbs = path.join(opts.imagesDir, pngName);
const title = spatialStep === null ? `Niveau 0 (complet, ${pointCount} pts)` : `Niveau ${level} (pas=${spatialStep.toFixed(4)}m, ${pointCount} pts)`;
const roleLabel = role === 'unique' ? '' : role === 'top' ? ' (haut)' : ' (bas)';
const title = spatialStep === null ? `Niveau ${level}${roleLabel} (complet, ${pointCount} pts)` : `Niveau ${level}${roleLabel} (pas=${spatialStep.toFixed(4)}m, ${pointCount} pts)`;
await this.py.renderHeightmap(xyzAbs, gridStep, pngAbs, title);
imageRelPath = path.posix.join('images', pngName);
} catch (err: any) {
this.logger.warn(`Image niveau ${level} non generee: ${err.message}`);
this.logger.warn(`Image niveau ${level}${suffix} non generee: ${err.message}`);
}
const warn = vol.matchingCellsPct < config.matchingCellsWarnThreshold;
// Remplissage des trous (INTERP Delaunay, borne par max-edge-length) pour le calcul de volume.
// -VOLUME n'a pas cette option nativement (voir CLAUDE.md) : on rasterize+comble+exporte en
// nuage AVANT de le passer a -VOLUME.
let filledBinAbs: string | null = null;
try {
const filledRel = path.posix.join('raw', `L${level}${suffix}_filled.bin`);
await this.cc.run(
['-O', binRelPath, '-RASTERIZE', '-GRID_STEP', String(gridStep), '-EMPTY_FILL', 'INTERP', '-MAX_EDGE_LENGTH', String(maxEdgeLength), '-OUTPUT_CLOUD', '-C_EXPORT_FMT', 'BIN', '-SAVE_CLOUDS', 'FILE', filledRel],
workDir,
);
const filledAbs = path.join(workDir, filledRel);
await this.assertExists(filledAbs, `remplissage niveau ${level}${suffix}`, '');
filledBinAbs = filledAbs;
} catch (err: any) {
this.logger.warn(`Remplissage niveau ${level}${suffix} echoue, utilisation du nuage brut pour le volume: ${err.message}`);
filledBinAbs = rawBinAbs;
}
return {
level,
spatialStep,
gridStep,
pointCount,
pointRatio: refPointCount > 0 ? pointCount / refPointCount : 0,
volume: vol.volume,
surface: vol.surface,
addedVolume: vol.addedVolume,
removedVolume: vol.removedVolume,
matchingCellsPct: vol.matchingCellsPct,
groundNonMatchingPct: vol.groundNonMatchingPct,
ceilNonMatchingPct: vol.ceilNonMatchingPct,
warn,
status: 'ok',
binFile: binRelPath,
imageFile: imageRelPath,
computeTimeMs: Date.now() - t0,
info: {
role,
spatialStep,
pointCount,
pointRatio: refPointCount > 0 ? pointCount / refPointCount : level === 0 ? 1 : 0,
binFile: binRelPath,
imageFile: imageRelPath,
},
rawBinAbs,
filledBinAbs,
};
}
private errorLevel(
level: number,
spatialStep: number | null,
message: string,
t0: number,
binFile: string | null = null,
gridStep: number | null = null,
): LevelResult {
private badResult(level: number, role: CloudRole, spatialStep: number | null, message: string, status: 'skipped' | 'error'): CloudPrepResult {
return {
level,
spatialStep,
gridStep,
pointCount: 0,
pointRatio: 0,
volume: null,
surface: null,
addedVolume: null,
removedVolume: null,
matchingCellsPct: null,
groundNonMatchingPct: null,
ceilNonMatchingPct: null,
warn: true,
status: 'error',
status,
message,
binFile,
imageFile: null,
computeTimeMs: Date.now() - t0,
info: { role, spatialStep, pointCount: 0, pointRatio: 0, binFile: null, imageFile: null },
rawBinAbs: null,
filledBinAbs: null,
};
}
private toCsv(report: RunReport): string {
return reportToCsv(report);
}
}
export function reportToCsv(report: RunReport): string {
const headers = [
'level',
'role',
'spatialStep',
'gridStep',
'maxEdgeLength',
'pointCount',
'pointRatio',
'volume',
@ -445,25 +530,33 @@ export function reportToCsv(report: RunReport): string {
'computeTimeMs',
'message',
];
const rows = report.levels.map((l) =>
[
l.level,
l.spatialStep ?? '',
l.gridStep ?? '',
l.pointCount,
l.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(','),
);
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, imageFile: 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';
}

View file

@ -21,9 +21,11 @@ describe('sanitizeBaseName', () => {
});
describe('reportToCsv', () => {
const baseReport: RunReport = {
const constHeightReport: RunReport = {
runId: 'run-1',
mode: 'const_height',
originalFilename: 'test.las',
originalFilenameBottom: null,
createdAt: '2026-01-01T00:00:00.000Z',
params: {
zref: 100,
@ -31,17 +33,19 @@ describe('reportToCsv', () => {
initialStep: 0.02,
initialStepSource: 'auto',
gridStepMultiplier: 2,
maxEdgeLengthMultiplier: 3,
maxEdgeLengthSource: 'auto',
factor: 2,
steps: 1,
densityMatch: null,
sampleStats: { sampleCount: 100, meanNn: 0.02, medianNn: 0.02, bboxApprox: { xmin: 0, xmax: 1, ymin: 0, ymax: 1, zmin: 100, zmax: 101 } },
},
levels: [
{
level: 0,
spatialStep: null,
gridStep: 0.04,
pointCount: 1000,
pointRatio: 1,
maxEdgeLength: 0.12,
clouds: [{ role: 'unique', spatialStep: null, pointCount: 1000, pointRatio: 1, binFile: 'levels/L0_full.bin', imageFile: 'images/L0_heightmap.png' }],
volume: 12.5,
surface: 100,
addedVolume: 12.5,
@ -51,16 +55,13 @@ describe('reportToCsv', () => {
ceilNonMatchingPct: 0,
warn: false,
status: 'ok',
binFile: 'levels/L0_full.bin',
imageFile: 'images/L0_heightmap.png',
computeTimeMs: 500,
},
{
level: 1,
spatialStep: 0.04,
gridStep: 0.08,
pointCount: 0,
pointRatio: 0,
maxEdgeLength: 0.24,
clouds: [{ role: 'unique', spatialStep: 0.04, pointCount: 0, pointRatio: 0, binFile: null, imageFile: null }],
volume: null,
surface: null,
addedVolume: null,
@ -71,34 +72,64 @@ describe('reportToCsv', () => {
warn: true,
status: 'skipped',
message: 'decimation, pas assez de points\navec une virgule, ici',
binFile: null,
imageFile: null,
computeTimeMs: 0,
},
],
mergedBinFile: 'test_all_levels.bin',
};
it('genere un en-tete et une ligne par niveau', () => {
const csv = reportToCsv(baseReport);
it('genere un en-tete et une ligne par (niveau, role)', () => {
const csv = reportToCsv(constHeightReport);
const lines = csv.trim().split('\n');
expect(lines).toHaveLength(3); // header + 2 niveaux
expect(lines).toHaveLength(3); // header + 2 niveaux (1 nuage chacun en mode const_height)
expect(lines[0]).toBe(
'level,spatialStep,gridStep,pointCount,pointRatio,volume,surface,addedVolume,removedVolume,matchingCellsPct,groundNonMatchingPct,ceilNonMatchingPct,status,warn,computeTimeMs,message',
'level,role,spatialStep,gridStep,maxEdgeLength,pointCount,pointRatio,volume,surface,addedVolume,removedVolume,matchingCellsPct,groundNonMatchingPct,ceilNonMatchingPct,status,warn,computeTimeMs,message',
);
});
it('genere une ligne par nuage (top ET bottom) en mode cloud_compare', () => {
const compareReport: RunReport = {
...constHeightReport,
mode: 'cloud_compare',
levels: [
{
level: 0,
gridStep: 0.04,
maxEdgeLength: 0.12,
clouds: [
{ role: 'top', spatialStep: 0.02, pointCount: 500, pointRatio: 1, binFile: 'levels/L0_top_full.bin', imageFile: null },
{ role: 'bottom', spatialStep: 0.02, pointCount: 480, pointRatio: 1, binFile: 'levels/L0_bottom_full.bin', imageFile: null },
],
volume: 5,
surface: 50,
addedVolume: 5,
removedVolume: 0,
matchingCellsPct: 97,
groundNonMatchingPct: 3,
ceilNonMatchingPct: 0,
warn: false,
status: 'ok',
computeTimeMs: 300,
},
],
};
const csv = reportToCsv(compareReport);
const lines = csv.trim().split('\n');
expect(lines).toHaveLength(3); // header + 2 lignes (top, bottom) pour le seul niveau
expect(lines[1]).toMatch(/^0,top,/);
expect(lines[2]).toMatch(/^0,bottom,/);
});
it('remplace les sauts de ligne et virgules dans les messages pour ne pas casser le CSV', () => {
const csv = reportToCsv(baseReport);
const csv = reportToCsv(constHeightReport);
const lines = csv.trim().split('\n');
expect(lines[2]).not.toContain('\n');
// le message ne doit contenir aucune virgule qui ajouterait une colonne supplementaire
const messageField = lines[2].split(',').slice(15).join(',');
const messageField = lines[2].split(',').slice(17).join(',');
expect(messageField).not.toMatch(/,/);
});
it('laisse les champs numeriques null en cellule vide plutot que "null"', () => {
const csv = reportToCsv(baseReport);
const csv = reportToCsv(constHeightReport);
const lines = csv.trim().split('\n');
expect(lines[2]).not.toContain('null');
});

View file

@ -1,9 +1,21 @@
export type RunMode = 'const_height' | 'cloud_compare';
export type CloudRole = 'unique' | 'top' | 'bottom';
export interface LevelCloudInfo {
role: CloudRole;
spatialStep: number | null; // null pour le niveau 0 (nuage complet, non decime)
pointCount: number;
pointRatio: number; // vs le niveau 0 du meme role
binFile: string | null; // chemin relatif au dossier du run (nuage brut, non rempli)
imageFile: string | null; // chemin relatif au dossier du run
}
export interface LevelResult {
level: number;
spatialStep: number | null; // null pour le niveau 0 (nuage complet, non decime)
gridStep: number | null; // grille de calcul du volume a CE niveau (adaptee dynamiquement a la densite, voir CLAUDE.md)
pointCount: number;
pointRatio: number; // vs niveau 0
gridStep: number | null; // grille de calcul du volume a CE niveau (adaptee dynamiquement, voir CLAUDE.md)
maxEdgeLength: number | null; // limite de remplissage des trous (Delaunay) a CE niveau
clouds: LevelCloudInfo[]; // 1 element en mode const_height, 2 (top puis bottom) en mode cloud_compare
volume: number | null;
surface: number | null;
addedVolume: number | null;
@ -14,28 +26,38 @@ export interface LevelResult {
warn: boolean;
status: 'ok' | 'skipped' | 'error';
message?: string;
binFile: string | null; // chemin relatif au dossier du run
imageFile: string | null; // chemin relatif au dossier du run
computeTimeMs: number;
}
export interface RunParamsInput {
zrefOverride?: number;
mode: RunMode;
zrefOverride?: number; // mode const_height uniquement
initialStepOverride?: number;
maxEdgeLengthOverride?: number; // les deux modes
}
export interface RunReport {
runId: string;
originalFilename: string;
mode: RunMode;
originalFilename: string; // fichier unique (const_height) ou nom du nuage du haut (cloud_compare)
originalFilenameBottom: string | null; // mode cloud_compare uniquement
createdAt: string;
params: {
zref: number;
zrefSource: 'override' | 'auto';
zref: number | null; // mode const_height uniquement
zrefSource: 'override' | 'auto' | null;
initialStep: number;
initialStepSource: 'override' | 'auto';
gridStepMultiplier: number;
maxEdgeLengthMultiplier: number;
maxEdgeLengthSource: 'override' | 'auto';
factor: number;
steps: number;
densityMatch: {
topSpacing: number;
bottomSpacing: number;
matchedSpacing: number;
preDecimatedRole: 'top' | 'bottom' | 'none';
} | null; // mode cloud_compare uniquement
sampleStats: {
sampleCount: number;
meanNn: number;

View file

@ -8,10 +8,10 @@ import {
Param,
Post,
Res,
UploadedFile,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import archiver from 'archiver';
import * as fs from 'fs';
@ -19,6 +19,7 @@ import * as fsp from 'fs/promises';
import * as path from 'path';
import { diskStorage } from 'multer';
import { config, uploadsDir } from '../config';
import { RunMode } from '../pipeline/types';
import { RunsService } from './runs.service';
const ALLOWED_EXT = ['.las', '.laz', '.copc.laz', '.bin'];
@ -28,43 +29,72 @@ function isAllowedFilename(name: string): boolean {
return ALLOWED_EXT.some((ext) => lower.endsWith(ext));
}
const uploadStorage = diskStorage({
destination: (_req, _file, cb) => {
const dir = path.join(uploadsDir(), 'incoming');
fs.mkdirSync(dir, { recursive: true });
cb(null, dir);
},
filename: (_req, file, cb) => {
cb(null, `${Date.now()}_${Math.random().toString(36).slice(2)}__${file.originalname}`);
},
});
@Controller('api/runs')
export class RunsController {
constructor(private readonly runs: RunsService) {}
@Post()
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: (_req, _file, cb) => {
const dir = path.join(uploadsDir(), 'incoming');
fs.mkdirSync(dir, { recursive: true });
cb(null, dir);
FileFieldsInterceptor(
[
{ name: 'file', maxCount: 1 },
{ name: 'fileTop', maxCount: 1 },
{ name: 'fileBottom', maxCount: 1 },
],
{
storage: uploadStorage,
limits: { fileSize: config.maxUploadBytes },
fileFilter: (_req, file, cb) => {
if (!isAllowedFilename(file.originalname)) {
cb(new BadRequestException(`Format non supporte. Extensions acceptees: ${ALLOWED_EXT.join(', ')}`) as any, false);
return;
}
cb(null, true);
},
filename: (_req, file, cb) => {
cb(null, `${Date.now()}_${Math.random().toString(36).slice(2)}__${file.originalname}`);
},
}),
limits: { fileSize: config.maxUploadBytes },
fileFilter: (_req, file, cb) => {
if (!isAllowedFilename(file.originalname)) {
cb(new BadRequestException(`Format non supporte. Extensions acceptees: ${ALLOWED_EXT.join(', ')}`) as any, false);
return;
}
cb(null, true);
},
}),
),
)
async create(@UploadedFile() file: Express.Multer.File, @Body() body: Record<string, string>) {
if (!file) throw new BadRequestException('Aucun fichier recu (champ "file" attendu)');
async create(
@UploadedFiles() files: { file?: Express.Multer.File[]; fileTop?: Express.Multer.File[]; fileBottom?: Express.Multer.File[] },
@Body() body: Record<string, string>,
) {
const mode: RunMode = body.mode === 'cloud_compare' ? 'cloud_compare' : 'const_height';
const zrefOverride = this.parseOptionalNumber(body.zrefOverride);
const initialStepOverride = this.parseOptionalNumber(body.initialStepOverride);
const maxEdgeLengthOverride = this.parseOptionalNumber(body.maxEdgeLengthOverride);
if (mode === 'const_height') {
const file = files.file?.[0];
if (!file) throw new BadRequestException('Aucun fichier recu (champ "file" attendu pour le mode plan Z constant)');
return this.runs.createRun({
tmpFilePath: file.path,
originalFilename: file.originalname,
params: { mode, zrefOverride, initialStepOverride, maxEdgeLengthOverride },
});
}
const top = files.fileTop?.[0];
const bottom = files.fileBottom?.[0];
if (!top || !bottom) {
throw new BadRequestException('Deux fichiers requis en mode comparaison : "fileTop" (surface superieure) et "fileBottom" (limite inferieure)');
}
return this.runs.createRun({
tmpFilePath: file.path,
originalFilename: file.originalname,
params: { zrefOverride, initialStepOverride },
tmpFilePath: top.path,
originalFilename: top.originalname,
tmpFilePathSecondary: bottom.path,
originalFilenameSecondary: bottom.originalname,
params: { mode, initialStepOverride, maxEdgeLengthOverride },
});
}

View file

@ -3,20 +3,22 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { resultsDir, uploadsDir } from '../config';
import { PipelineService } from '../pipeline/pipeline.service';
import { PipelineService, RunInputs } from '../pipeline/pipeline.service';
import { RunParamsInput } from '../pipeline/types';
import { DatabaseService, RunRow } from './database.service';
export interface CreateRunOptions {
tmpFilePath: string;
originalFilename: string;
/** mode cloud_compare uniquement : deuxieme fichier (limite inferieure) */
tmpFilePathSecondary?: string;
originalFilenameSecondary?: string;
params: RunParamsInput;
}
interface QueueJob {
id: string;
inputPath: string;
originalFilename: string;
inputs: RunInputs;
params: RunParamsInput;
}
@ -40,15 +42,33 @@ export class RunsService {
const destPath = path.join(runUploadDir, `input${ext}`);
await fs.rename(opts.tmpFilePath, destPath);
let secondaryDestPath: string | undefined;
if (opts.tmpFilePathSecondary && opts.originalFilenameSecondary) {
const extB = this.extensionOf(opts.originalFilenameSecondary);
secondaryDestPath = path.join(runUploadDir, `input_bottom${extB}`);
await fs.rename(opts.tmpFilePathSecondary, secondaryDestPath);
}
const displayName = opts.originalFilenameSecondary ? `${opts.originalFilename} vs ${opts.originalFilenameSecondary}` : opts.originalFilename;
this.db.insert({
id,
original_filename: opts.originalFilename,
original_filename: displayName,
status: 'pending',
created_at: new Date().toISOString(),
params_json: JSON.stringify(opts.params),
});
this.queue.push({ id, inputPath: destPath, originalFilename: opts.originalFilename, params: opts.params });
this.queue.push({
id,
inputs: {
primaryPath: destPath,
primaryFilename: opts.originalFilename,
secondaryPath: secondaryDestPath,
secondaryFilename: opts.originalFilenameSecondary,
},
params: opts.params,
});
void this.processQueue();
return { id };
@ -78,7 +98,7 @@ export class RunsService {
};
try {
const report = await this.pipeline.execute(job.id, job.inputPath, job.originalFilename, workDir, job.params, onProgress);
const report = await this.pipeline.execute(job.id, job.inputs, workDir, job.params, onProgress);
this.db.complete(job.id, JSON.stringify(report));
onProgress('Run termine avec succes.');
} catch (err: any) {