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 desalignement grille/densite. Voir `LevelResult.gridStep` (par niveau) et
`RunReport.params.gridStepMultiplier` (le seul parametre encore global). `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` / `-SS RANDOM` (sous-echantillonnage)
- `-SS SPATIAL <distance>` : distance minimale entre points, PAS un ratio de points cible. Le nombre - `-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`). - `-SILENT` doit etre le tout premier argument (ou juste apres `-VERBOSITY`).
- `-PREC <n>` controle la precision decimale des exports ASCII (`-C_EXPORT_FMT ASC`). - `-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 ## 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 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` : - `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 Coolify re-clone le repo a chaque deploiement, un bind mount relatif au checkout perdrait les
donnees (uploads, resultats, base sqlite) a chaque redeploy. donnees (uploads, resultats, base sqlite) a chaque redeploy.
- `HEALTHCHECK` dans le Dockerfile (`curl` sur `/`) : Coolify l'utilise pour determiner si le - `HEALTHCHECK` dans le Dockerfile (`curl` sur `/health`, route publique non authentifiee) : Coolify
deploiement a reussi. 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). - 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`) ## 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. (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 - `statsSampleCap = 50000` : nb de points echantillonnes pour estimer la distance mediane au plus
proche voisin (KD-tree scipy) qui sert de pas spatial initial. 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. - `matchingCellsWarnThreshold = 90` : seuil d'affichage "a verifier" dans l'UI.

View file

@ -1,18 +1,25 @@
# Volume 2.5D & robustesse a la decimation # 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) ; 1. calcule un volume en 2.5D via CloudCompare, selon 2 modes au choix :
2. le decime progressivement en methode **spatiale**, facteur **x2** sur **5 etapes** (le pas spatial double - **Plan de reference Z constant** : un seul nuage, compare a un plan horizontal (altitude minimale
a chaque etape, en partant d'un pas initial estime automatiquement = distance mediane au plus proche par defaut). Fiable uniquement si le nuage ne contient QUE l'objet mesure (pas de terrain/contexte
voisin, calculee par KD-tree sur un echantillon) ; environnant) ;
3. recalcule le volume a chaque niveau avec le **meme plan de reference**, et une **grille de calcul - **Comparaison a un second nuage** : deux nuages, la **surface superieure** (sommet de l'amas) et
adaptee dynamiquement a la densite de chaque niveau** (grid step = pas spatial du niveau x2) pour la **limite inferieure** (base/socle) — les densites sont d'abord appariees (le plus dense decime
eviter des grilles artificiellement vides quand la decimation depasse la resolution de depart ; pour matcher l'autre), puis les deux sont decimes ensemble a chaque etape ;
4. assemble les 6 nuages (complet + 5 decimations) dans un seul fichier `.bin` CloudCompare, nomme et 2. dans les deux modes, decime progressivement en methode **spatiale**, facteur **x2** sur **5 etapes**
consultable dans CloudCompare Desktop ; (le pas spatial double a chaque etape, en partant d'un pas initial estime automatiquement = distance
5. produit un dossier de resultats : rapport CSV/JSON, cartes de hauteur par niveau, graphiques de mediane au plus proche voisin, calculee par KD-tree sur un echantillon) ;
synthese (volume, nombre de points, robustesse) ; 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. 6. expose tout ca dans une UI web simple avec historique des runs.
## Demarrage ## Demarrage
@ -78,24 +85,33 @@ Le repo est pret pour un deploiement Coolify via webhook (push sur `main` -> bui
## Utilisation ## Utilisation
1. "Nouveau run" -> choisir un fichier `.las`, `.laz`, `.copc.laz` ou `.bin`. 1. "Nouveau run" -> choisir le type de comparaison :
2. Optionnel : override du plan de reference Z (par defaut : altitude minimale d'un echantillon du - **Plan de reference Z constant** : un fichier `.las`, `.laz`, `.copc.laz` ou `.bin`.
nuage) et/ou du pas spatial initial (par defaut : distance mediane au plus proche voisin). - **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 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). de detail se rafraichit automatiquement (polling 2s).
4. Une fois termine : tableau des 6 niveaux (points, volume, surface, % de cellules de grille 4. Une fois termine : tableau des niveaux (points par nuage, volume, surface, % de cellules de grille
"matching"), cartes de hauteur, graphiques de synthese, et telechargements (`.bin` fusionne, "matching"), cartes de hauteur (par nuage en mode comparaison), graphiques de synthese, et
`.csv`, dossier complet en `.zip`). telechargements (`.bin` fusionne, `.csv`, dossier complet en `.zip`).
### Lecture des resultats ### Lecture des resultats
- **% de cellules "matching"** (rapport de volume 2.5D de CloudCompare) : indique la part de la grille - **% 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 de calcul ou les deux surfaces sont effectivement comparables. Un niveau est marque "a verifier" des
decimation depasse la resolution de la grille de calcul, ce pourcentage chute — c'est le signal que ce taux passe sous 90 % (configurable via `matchingCellsWarnThreshold`, `config.ts`).
principal de perte de robustesse. Un niveau est marque "a verifier" des que ce taux passe sous 90 % - La decimation s'arrete automatiquement si un niveau (ou l'un des deux nuages en mode comparaison)
(configurable via `MATCHING_CELLS_WARN_THRESHOLD` cote code, `config.ts`). tombe sous 25 points exploitables ; les niveaux suivants sont marques "skipped" avec la raison.
- La decimation s'arrete automatiquement si un niveau tombe sous 25 points exploitables ; les niveaux - **Choix du mode** : le mode "Z constant" n'est fiable que si le nuage uploade ne contient QUE
suivants sont marques "skipped" avec la raison. 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`) ## 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 }); return n.toLocaleString('fr-FR', { maximumFractionDigits: digits });
} }
function roleLabel(role) {
return role === 'top' ? 'haut' : role === 'bottom' ? 'bas' : '';
}
function statusBadge(status) { function statusBadge(status) {
const map = { const map = {
pending: 'bg-slate-700 text-slate-200', pending: 'bg-slate-700 text-slate-200',
@ -90,15 +94,45 @@ function renderNew() {
<h1 class="text-xl font-semibold mb-6">Nouveau run</h1> <h1 class="text-xl font-semibold mb-6">Nouveau run</h1>
<form id="upload-form" class="space-y-5 max-w-xl"> <form id="upload-form" class="space-y-5 max-w-xl">
<div> <div>
<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> <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" <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" /> 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>
<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"> <details class="border border-slate-800 rounded-lg p-3">
<summary class="cursor-pointer text-sm text-slate-400">Options avancees (optionnel)</summary> <summary class="cursor-pointer text-sm text-slate-400">Options avancees (optionnel)</summary>
<div class="mt-3 space-y-3"> <div class="mt-3 space-y-3">
<div> <div id="zref-override-wrap">
<label class="block text-sm mb-1">Plan de reference Z (override)</label> <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" <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" /> class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
</div> </div>
@ -107,15 +141,41 @@ function renderNew() {
<input type="number" step="any" name="initialStepOverride" placeholder="auto = distance mediane au plus proche voisin" <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" /> class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
</div> </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> </div>
</details> </details>
<button type="submit" class="bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded-lg font-medium">Lancer le run</button> <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> <p id="upload-error" class="text-red-400 text-sm hidden"></p>
</form>`; </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(); e.preventDefault();
const form = e.target;
const fd = new FormData(form); const fd = new FormData(form);
const errEl = document.getElementById('upload-error'); const errEl = document.getElementById('upload-error');
errEl.classList.add('hidden'); errEl.classList.add('hidden');
@ -156,16 +216,30 @@ async function loadDetail(id) {
paintDetail(run); 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 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>`; const statusTxt = l.status === 'ok' ? '' : `<div class="text-xs text-slate-400 mt-0.5">${escapeHtml(l.message || '')}</div>`;
return ` return `
<tr class="border-b border-slate-800 ${warnCls}"> <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 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.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.maxEdgeLength !== null ? fmt(l.maxEdgeLength, 5) + ' m' : '-'}</td>
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.pointRatio * 100, 2) + ' %' : '-'}</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.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.surface, 3) + ' m2' : '-'}</td>
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.matchingCellsPct, 1) + ' %' : '-'}</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) { function paintDetail(run) {
const r = run.report; const r = run.report;
const isCompare = r && r.mode === 'cloud_compare';
const levelsTable = r ? ` const levelsTable = r ? `
<div class="overflow-x-auto border border-slate-800 rounded-lg mt-4"> <div class="overflow-x-auto border border-slate-800 rounded-lg mt-4">
<table class="w-full text-left text-sm"> <table class="w-full text-left text-sm">
<thead class="bg-slate-900 text-slate-400 text-xs uppercase"> <thead class="bg-slate-900 text-slate-400 text-xs uppercase">
<tr> <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">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 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">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> <th class="py-2 px-3">Matching cells</th><th class="py-2 px-3">Statut</th>
</tr> </tr>
</thead> </thead>
<tbody>${r.levels.map((l) => levelRow(l, run.id)).join('')}</tbody> <tbody>${r.levels.map((l) => levelRow(l)).join('')}</tbody>
</table> </table>
</div>` : ''; </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 ? ` const params = r ? `
<dl class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm mt-4"> <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">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">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> <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"> <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" /> <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 ${l.level}</p> <p class="text-xs text-slate-400 text-center mt-1">Niveau ${level}${c.role !== 'unique' ? ' - ' + roleLabel(c.role) : ''}</p>
</div>`).join('') : ''; </div>`).join('') : '';
const summaryCharts = run.status === 'done' ? ['volume_vs_level.png', 'points_vs_level.png', 'robustness_vs_level.png'].map((f) => ` 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' ? ` const downloads = run.status === 'done' ? `
<div class="flex flex-wrap gap-2 mt-4"> <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/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> <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>` : ''; </div>` : '';
@ -222,7 +309,10 @@ function paintDetail(run) {
<h1 class="text-xl font-semibold">${escapeHtml(run.originalFilename)}</h1> <h1 class="text-xl font-semibold">${escapeHtml(run.originalFilename)}</h1>
${statusBadge(run.status)} ${statusBadge(run.status)}
</div> </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} ${errorBlock}
${params} ${params}
${levelsTable} ${levelsTable}

View file

@ -79,10 +79,17 @@ def cmd_summary(args: argparse.Namespace) -> int:
xs = [lvl["level"] for lvl in levels] xs = [lvl["level"] for lvl in levels]
volumes = [lvl["volume"] 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] matching = [lvl["matchingCellsPct"] for lvl in levels]
v0 = volumes[0] if volumes else None 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 --- # --- Volume vs niveau ---
fig, ax = plt.subplots(figsize=(6, 4), dpi=130) fig, ax = plt.subplots(figsize=(6, 4), dpi=130)
ax.plot(xs, volumes, marker="o", color="#2563eb") 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 --- # --- Nombre de points (log) vs niveau ---
fig, ax = plt.subplots(figsize=(6, 4), dpi=130) 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_xlabel("Niveau de decimation")
ax.set_ylabel("Nombre de points (log)") ax.set_ylabel("Nombre de points (log)")
ax.set_title("Nombre de points vs niveau de decimation") ax.set_title("Nombre de points vs niveau de decimation")
ax.set_xticks(xs) ax.set_xticks(xs)
ax.grid(alpha=0.3, which="both") ax.grid(alpha=0.3, which="both")
if len(point_series) > 1:
ax.legend()
fig.tight_layout() fig.tight_layout()
fig.savefig(outdir / "points_vs_level.png") fig.savefig(outdir / "points_vs_level.png")
plt.close(fig) plt.close(fig)

View file

@ -86,6 +86,27 @@ describe('CcRunnerService', () => {
await fs.writeFile(tmpFile, 'contenu inattendu sans les champs voulus', 'utf-8'); await fs.writeFile(tmpFile, 'contenu inattendu sans les champs voulus', 'utf-8');
await expect(service.parseVolumeReportFile(tmpFile)).rejects.toThrow(/Champ introuvable/); 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', () => { describe('findLatestFile', () => {

View file

@ -113,29 +113,36 @@ export class CcRunnerService {
return candidates[0].full; 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> { async parseVolumeReportFile(filePath: string): Promise<VolumeReport> {
const text = await fs.readFile(filePath, 'utf-8'); const text = await fs.readFile(filePath, 'utf-8');
const toNumber = (raw: string): number => Number(raw.replace(/,/g, ''));
const num = (re: RegExp): number => { const num = (re: RegExp): number => {
const m = text.match(re); const m = text.match(re);
if (!m) throw new Error(`Champ introuvable dans le rapport de volume (${re}): ${filePath}`); 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 numOpt = (re: RegExp): number | null => {
const m = text.match(re); const m = text.match(re);
return m ? Number(m[1]) : null; return m ? toNumber(m[1]) : null;
}; };
return { return {
volume: num(/Volume:\s*([-\d.eE]+)/), volume: num(/Volume:\s*([-\d,.eE]+)/),
surface: num(/Surface:\s*([-\d.eE]+)/), surface: num(/Surface:\s*([-\d,.eE]+)/),
addedVolume: num(/Added volume:\s*\(\+\)\s*([-\d.eE]+)/), addedVolume: num(/Added volume:\s*\(\+\)\s*([-\d,.eE]+)/),
removedVolume: num(/Removed volume:\s*\(-\)\s*([-\d.eE]+)/), removedVolume: num(/Removed volume:\s*\(-\)\s*([-\d,.eE]+)/),
matchingCellsPct: num(/Matching cells:\s*([-\d.eE]+)\s*%/), matchingCellsPct: num(/Matching cells:\s*([-\d,.eE]+)\s*%/),
groundNonMatchingPct: num(/ground\s*=\s*([-\d.eE]+)\s*%/), groundNonMatchingPct: num(/ground\s*=\s*([-\d,.eE]+)\s*%/),
ceilNonMatchingPct: num(/ceil\s*=\s*([-\d.eE]+)\s*%/), ceilNonMatchingPct: num(/ceil\s*=\s*([-\d,.eE]+)\s*%/),
avgNeighborsCurrent: numOpt(/Average neighbors per cell:\s*([-\d.eE]+)\s*\//), avgNeighborsCurrent: numOpt(/Average neighbors per cell:\s*([-\d,.eE]+)\s*\//),
avgNeighborsRef: numOpt(/Average neighbors per cell:.*\/\s*([-\d.eE]+)/), 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 // 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). // dynamiquement a chaque niveau, pas figee sur la resolution du niveau 0 - voir CLAUDE.md).
gridStepMultiplier: 2, 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) // En dessous de ce nombre de points, on arrete la decimation progressive (cloud devenu inexploitable)
minPointsToContinue: 25, minPointsToContinue: 25,
// Seuil d'alerte robustesse : si "matching cells %" du rapport CloudCompare passe sous ce seuil, // 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 * as path from 'path';
import { config } from '../config'; import { config } from '../config';
import { CcRunnerService } from '../cloudcompare/cc-runner.service'; import { CcRunnerService } from '../cloudcompare/cc-runner.service';
import { PyHelperService } from '../pyhelper/pyhelper.service'; import { PyHelperService, CloudStats } from '../pyhelper/pyhelper.service';
import { LevelResult, RunParamsInput, RunReport } from './types'; import { CloudRole, LevelCloudInfo, LevelResult, RunParamsInput, RunReport } from './types';
export function sanitizeBaseName(filename: string): string { export function sanitizeBaseName(filename: string): string {
const base = filename.replace(/\.[^./\\]+$/, ''); const base = filename.replace(/\.[^./\\]+$/, '');
return base.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 80) || 'nuage'; 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 : * Orchestration complete d'un run. Deux modes :
* 1) statistiques (pas spatial initial, plan de reference Z) * - const_height : 1 nuage, compare a un plan Z constant (comme avant).
* 2) niveau 0 = nuage complet -> volume + image * - cloud_compare : 2 nuages (surface du haut vs limite du bas), densites appariees au niveau 0
* 3) niveaux 1..N = decimation spatiale progressive (facteur 2, chainee depuis le niveau precedent) * puis decimation synchronisee x2 sur 5 etapes, compares l'un a l'autre a chaque niveau.
* 4) assemblage des nuages exploitables dans un seul .bin * Dans les deux cas, les trous de scan sont combles (interpolation Delaunay, bornee par
* 5) rapport CSV/JSON + graphiques de synthese * 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() @Injectable()
export class PipelineService { export class PipelineService {
@ -30,8 +50,7 @@ export class PipelineService {
async execute( async execute(
runId: string, runId: string,
inputPath: string, inputs: RunInputs,
originalFilename: string,
workDir: string, workDir: string,
params: RunParamsInput, params: RunParamsInput,
onProgress: (msg: string) => void, onProgress: (msg: string) => void,
@ -43,124 +62,198 @@ export class PipelineService {
await fs.mkdir(imagesDir, { recursive: true }); await fs.mkdir(imagesDir, { recursive: true });
await fs.mkdir(rawDir, { recursive: true }); await fs.mkdir(rawDir, { recursive: true });
// ---------- 0) Normalisation du format d'entree ---------- const isCompare = params.mode === 'cloud_compare';
// 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 // ---------- 0) Normalisation du/des format(s) d'entree ----------
// de transmettre le nuage a CloudCompare, qui lit nativement l'ASCII sans plugin. const primaryNorm = await this.normalizeInput(inputs.primaryPath, workDir, 'primary', onProgress);
let normalizedInputPath = inputPath; const secondaryNorm = inputs.secondaryPath ? await this.normalizeInput(inputs.secondaryPath, workDir, 'secondary', onProgress) : null;
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;
}
// ---------- 1) Statistiques ---------- // ---------- 1) Statistiques ----------
onProgress('Estimation du pas spatial initial (echantillonnage + KD-tree)...'); onProgress('Estimation du pas spatial (echantillonnage + KD-tree)...');
const t0 = Date.now(); const t0 = Date.now();
const sampleRes = await this.cc.run( const primaryStats = await this.computeStatsFor(primaryNorm, workDir, 'sample_primary.xyz');
['-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 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; let initialStep: number;
const zref = params.zrefOverride ?? stats.bbox.zmin; let zref: number | null = null;
// La grille de calcul du volume est adaptee dynamiquement a CHAQUE niveau (grid_step = pas let zrefSource: 'override' | 'auto' | null = null;
// spatial du niveau x multiplicateur), plutot que figee sur la resolution du niveau 0. Une let densityMatch: RunReport['params']['densityMatch'] = null;
// 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;
if (!isCompare) {
initialStep = params.initialStepOverride ?? primaryStats.stats.median_nn;
zref = params.zrefOverride ?? primaryStats.stats.bbox.zmin;
zrefSource = params.zrefOverride !== undefined ? 'override' : 'auto';
onProgress( onProgress(
`Pas initial=${initialStep.toFixed(5)}m (${params.initialStepOverride ? 'override' : 'auto, mediane NN'}), ` + `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)} (${zrefSource}). [${Date.now() - t0}ms]`,
`Zref=${zref.toFixed(4)} (${params.zrefOverride ? 'override' : 'auto, Zmin echantillon'}). ` +
`[${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 maxEdgeLengthSource: 'override' | 'auto' = params.maxEdgeLengthOverride !== undefined ? 'override' : 'auto';
const levels: LevelResult[] = []; const levels: LevelResult[] = [];
let stopped = false;
let stopReason = '';
// ---------- 2) Niveau 0 : nuage complet ---------- // Etat courant par role : chemin du nuage BRUT (non rempli) du niveau precedent
onProgress('Niveau 0 (nuage complet) : conversion .bin + calcul de volume...'); const prevRaw: Partial<Record<CloudRole, string>> = {};
const level0 = await this.computeLevel({ const refPointCount: Partial<Record<CloudRole, number>> = {};
level: 0,
spatialStep: null,
sourceBinOrOriginal: normalizedInputPath,
isFirstConversion: true,
levelsDir,
imagesDir,
rawDir,
workDir,
gridStep: level0GridStep,
zref,
refPointCount: totalPointCount,
onProgress,
});
levels.push(level0);
// ---------- 3) Niveaux 1..N : decimation progressive ---------- const rolesForThisRun: CloudRole[] = isCompare ? ['top', 'bottom'] : ['unique'];
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' : '';
for (let i = 1; i <= config.decimationSteps; i++) { for (let i = 0; i <= config.decimationSteps; i++) {
if (stopped || !prevBinAbs) { if (stopped) {
levels.push(this.skippedLevel(i, stopReason)); levels.push(this.skippedLevel(i, stopReason));
continue; 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); // Niveau 0 (const_height) = nuage complet, jamais decime : spatialStep=null.
const levelGridStep = spatialStep * config.gridStepMultiplier; // 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( 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 { try {
const lvl = await this.computeLevel({ 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, level: i,
role,
spatialStep, spatialStep,
sourceBinOrOriginal: prevBinAbs, sourcePath,
isFirstConversion: false, isFirstConversion,
levelsDir, levelsDir,
imagesDir, imagesDir,
rawDir, rawDir,
workDir, workDir,
gridStep: levelGridStep, gridStep,
zref, maxEdgeLength,
refPointCount: totalPointCount, refPointCount: refPointCount[role] ?? 0,
onProgress, });
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,
}); });
levels.push(lvl);
if (lvl.status === 'ok') {
prevBinAbs = lvl.binFile ? path.join(workDir, lvl.binFile) : null;
prevPointCount = lvl.pointCount;
} else {
stopped = true; 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) { } catch (err: any) {
this.logger.error(`Niveau ${i} en erreur: ${err.message}`); this.logger.error(`Niveau ${i} en erreur: ${err.message}`);
levels.push({ levels.push({
level: i, level: i,
spatialStep, gridStep,
gridStep: levelGridStep, maxEdgeLength,
pointCount: 0, clouds: rolesForThisRun.map((role) => ({ role, spatialStep, pointCount: 0, pointRatio: 0, binFile: null, imageFile: null })),
pointRatio: 0,
volume: null, volume: null,
surface: null, surface: null,
addedVolume: null, addedVolume: null,
@ -171,8 +264,6 @@ export class PipelineService {
warn: true, warn: true,
status: 'error', status: 'error',
message: err.message, message: err.message,
binFile: null,
imageFile: null,
computeTimeMs: 0, computeTimeMs: 0,
}); });
stopped = true; stopped = true;
@ -180,15 +271,21 @@ export class PipelineService {
} }
} }
// ---------- 4) Assemblage ---------- // ---------- Assemblage ----------
onProgress('Assemblage des nuages exploitables dans un seul fichier .bin...'); 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; let mergedBinFile: string | null = null;
if (okLevels.length > 0) { if (okBinFiles.length > 0) {
const baseName = sanitizeBaseName(originalFilename); const baseName = sanitizeBaseName(inputs.primaryFilename);
const mergedName = `${baseName}_all_levels.bin`; const mergedName = `${baseName}_all_levels.bin`;
const args: string[] = []; 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); args.push('-C_EXPORT_FMT', 'BIN', '-SAVE_CLOUDS', 'ALL_AT_ONCE', 'FILE', mergedName);
const mergeRes = await this.cc.run(args, workDir); const mergeRes = await this.cc.run(args, workDir);
const mergedAbs = path.join(workDir, mergedName); const mergedAbs = path.join(workDir, mergedName);
@ -198,21 +295,26 @@ export class PipelineService {
const report: RunReport = { const report: RunReport = {
runId, runId,
originalFilename, mode: params.mode,
originalFilename: inputs.primaryFilename,
originalFilenameBottom: inputs.secondaryFilename ?? null,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
params: { params: {
zref, zref,
zrefSource: params.zrefOverride !== undefined ? 'override' : 'auto', zrefSource,
initialStep, initialStep,
initialStepSource: params.initialStepOverride !== undefined ? 'override' : 'auto', initialStepSource: params.initialStepOverride !== undefined ? 'override' : 'auto',
gridStepMultiplier: config.gridStepMultiplier, gridStepMultiplier: config.gridStepMultiplier,
maxEdgeLengthMultiplier: config.maxEdgeLengthMultiplier,
maxEdgeLengthSource,
factor: config.decimationFactor, factor: config.decimationFactor,
steps: config.decimationSteps, steps: config.decimationSteps,
densityMatch,
sampleStats: { sampleStats: {
sampleCount: stats.count, sampleCount: primaryStats.stats.count,
meanNn: stats.mean_nn, meanNn: primaryStats.stats.mean_nn,
medianNn: stats.median_nn, medianNn: primaryStats.stats.median_nn,
bboxApprox: stats.bbox, bboxApprox: primaryStats.stats.bbox,
}, },
}, },
levels, 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.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...'); onProgress('Generation des graphiques de synthese...');
try { try {
await this.py.renderSummary(path.join(workDir, 'report.json'), imagesDir); await this.py.renderSummary(path.join(workDir, 'report.json'), imagesDir);
@ -233,13 +334,41 @@ export class PipelineService {
return report; 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 { private skippedLevel(level: number, reason: string): LevelResult {
return { return {
level, level,
spatialStep: null,
gridStep: null, gridStep: null,
pointCount: 0, maxEdgeLength: null,
pointRatio: 0, clouds: [],
volume: null, volume: null,
surface: null, surface: null,
addedVolume: null, addedVolume: null,
@ -250,8 +379,6 @@ export class PipelineService {
warn: true, warn: true,
status: 'skipped', status: 'skipped',
message: reason, message: reason,
binFile: null,
imageFile: null,
computeTimeMs: 0, 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; level: number;
role: CloudRole;
spatialStep: number | null; spatialStep: number | null;
sourceBinOrOriginal: string; sourcePath: string;
isFirstConversion: boolean; isFirstConversion: boolean;
levelsDir: string; levelsDir: string;
imagesDir: string; imagesDir: string;
rawDir: string; rawDir: string;
workDir: string; workDir: string;
gridStep: number; gridStep: number;
zref: number; maxEdgeLength: number;
refPointCount: number; refPointCount: number;
onProgress: (msg: string) => void; }): Promise<CloudPrepResult> {
}): Promise<LevelResult> { const { level, role, spatialStep, workDir, gridStep, maxEdgeLength, refPointCount } = opts;
const { level, spatialStep, workDir, gridStep, zref, refPointCount } = opts; const suffix = role === 'unique' ? '' : `_${role}`;
const t0 = Date.now();
let binRelPath: string; let binRelPath: string;
let pointCount: number; let pointCount: number;
if (opts.isFirstConversion) { if (opts.isFirstConversion) {
// Niveau 0 : simple conversion du fichier d'entree en .bin canonique binRelPath = path.posix.join('levels', `L${level}${suffix}_full.bin`);
binRelPath = path.posix.join('levels', 'L0_full.bin'); const res = await this.cc.run(['-O', opts.sourcePath, '-C_EXPORT_FMT', 'BIN', '-SAVE_CLOUDS', 'FILE', binRelPath], workDir);
const res = await this.cc.run(['-O', opts.sourceBinOrOriginal, '-C_EXPORT_FMT', 'BIN', '-SAVE_CLOUDS', 'FILE', binRelPath], workDir);
const n = this.cc.parseLoadedPointCount(res.stdout); const n = this.cc.parseLoadedPointCount(res.stdout);
if (n === null) { 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; 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 { } else {
// Niveaux 1..N : decimation spatiale depuis le niveau precedent
const stepStr = spatialStep!.toFixed(6); 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( 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, workDir,
); );
const n = this.cc.parseSubsampleResult(res.stdout); const n = this.cc.parseSubsampleResult(res.stdout);
if (n === null || n === 0) { if (n === null || n === 0) {
return { return this.badResult(
level, level,
role,
spatialStep, spatialStep,
gridStep: opts.gridStep, `decimation spatiale (${role}) n'a produit aucun point exploitable (pas=${stepStr}m)`,
pointCount: 0, 'skipped',
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,
};
} }
pointCount = n; 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 rawBinAbs = path.join(workDir, binRelPath);
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);
// AUTO_SAVE ON sauvegarde aussi une grille "*_HEIGHT_DIFFERENCE_*.bin" qu'on ne veut pas garder // Carte de hauteur (a partir du nuage BRUT, pour montrer les vrais trous de scan)
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.)
let imageRelPath: string | null = null; let imageRelPath: string | null = null;
try { 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); await this.cc.run(['-O', binRelPath, '-C_EXPORT_FMT', 'ASC', '-PREC', '6', '-SAVE_CLOUDS', 'FILE', xyzExportRel], workDir);
const xyzAbs = path.join(workDir, xyzExportRel); const xyzAbs = path.join(workDir, xyzExportRel);
await this.assertExists(xyzAbs, `export ASCII niveau ${level}`, ''); await this.assertExists(xyzAbs, `export ASCII niveau ${level}${suffix}`, '');
const pngName = `L${level}_heightmap.png`; const pngName = `L${level}${suffix}_heightmap.png`;
const pngAbs = path.join(opts.imagesDir, pngName); 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); await this.py.renderHeightmap(xyzAbs, gridStep, pngAbs, title);
imageRelPath = path.posix.join('images', pngName); imageRelPath = path.posix.join('images', pngName);
} catch (err: any) { } 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 { 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', status: 'ok',
info: {
role,
spatialStep,
pointCount,
pointRatio: refPointCount > 0 ? pointCount / refPointCount : level === 0 ? 1 : 0,
binFile: binRelPath, binFile: binRelPath,
imageFile: imageRelPath, imageFile: imageRelPath,
computeTimeMs: Date.now() - t0, },
rawBinAbs,
filledBinAbs,
}; };
} }
private errorLevel( private badResult(level: number, role: CloudRole, spatialStep: number | null, message: string, status: 'skipped' | 'error'): CloudPrepResult {
level: number,
spatialStep: number | null,
message: string,
t0: number,
binFile: string | null = null,
gridStep: number | null = null,
): LevelResult {
return { return {
level, status,
spatialStep,
gridStep,
pointCount: 0,
pointRatio: 0,
volume: null,
surface: null,
addedVolume: null,
removedVolume: null,
matchingCellsPct: null,
groundNonMatchingPct: null,
ceilNonMatchingPct: null,
warn: true,
status: 'error',
message, message,
binFile, info: { role, spatialStep, pointCount: 0, pointRatio: 0, binFile: null, imageFile: null },
imageFile: null, rawBinAbs: null,
computeTimeMs: Date.now() - t0, filledBinAbs: null,
}; };
} }
private toCsv(report: RunReport): string {
return reportToCsv(report);
}
} }
export function reportToCsv(report: RunReport): string { export function reportToCsv(report: RunReport): string {
const headers = [ const headers = [
'level', 'level',
'role',
'spatialStep', 'spatialStep',
'gridStep', 'gridStep',
'maxEdgeLength',
'pointCount', 'pointCount',
'pointRatio', 'pointRatio',
'volume', 'volume',
@ -445,13 +530,19 @@ export function reportToCsv(report: RunReport): string {
'computeTimeMs', 'computeTimeMs',
'message', 'message',
]; ];
const rows = report.levels.map((l) => 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, l.level,
l.spatialStep ?? '', c.role,
c.spatialStep ?? '',
l.gridStep ?? '', l.gridStep ?? '',
l.pointCount, l.maxEdgeLength ?? '',
l.pointRatio, c.pointCount,
c.pointRatio,
l.volume ?? '', l.volume ?? '',
l.surface ?? '', l.surface ?? '',
l.addedVolume ?? '', l.addedVolume ?? '',
@ -465,5 +556,7 @@ export function reportToCsv(report: RunReport): string {
(l.message ?? '').replace(/[\r\n,]+/g, ' '), (l.message ?? '').replace(/[\r\n,]+/g, ' '),
].join(','), ].join(','),
); );
}
}
return [headers.join(','), ...rows].join('\n') + '\n'; return [headers.join(','), ...rows].join('\n') + '\n';
} }

View file

@ -21,9 +21,11 @@ describe('sanitizeBaseName', () => {
}); });
describe('reportToCsv', () => { describe('reportToCsv', () => {
const baseReport: RunReport = { const constHeightReport: RunReport = {
runId: 'run-1', runId: 'run-1',
mode: 'const_height',
originalFilename: 'test.las', originalFilename: 'test.las',
originalFilenameBottom: null,
createdAt: '2026-01-01T00:00:00.000Z', createdAt: '2026-01-01T00:00:00.000Z',
params: { params: {
zref: 100, zref: 100,
@ -31,17 +33,19 @@ describe('reportToCsv', () => {
initialStep: 0.02, initialStep: 0.02,
initialStepSource: 'auto', initialStepSource: 'auto',
gridStepMultiplier: 2, gridStepMultiplier: 2,
maxEdgeLengthMultiplier: 3,
maxEdgeLengthSource: 'auto',
factor: 2, factor: 2,
steps: 1, 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 } }, sampleStats: { sampleCount: 100, meanNn: 0.02, medianNn: 0.02, bboxApprox: { xmin: 0, xmax: 1, ymin: 0, ymax: 1, zmin: 100, zmax: 101 } },
}, },
levels: [ levels: [
{ {
level: 0, level: 0,
spatialStep: null,
gridStep: 0.04, gridStep: 0.04,
pointCount: 1000, maxEdgeLength: 0.12,
pointRatio: 1, clouds: [{ role: 'unique', spatialStep: null, pointCount: 1000, pointRatio: 1, binFile: 'levels/L0_full.bin', imageFile: 'images/L0_heightmap.png' }],
volume: 12.5, volume: 12.5,
surface: 100, surface: 100,
addedVolume: 12.5, addedVolume: 12.5,
@ -51,16 +55,13 @@ describe('reportToCsv', () => {
ceilNonMatchingPct: 0, ceilNonMatchingPct: 0,
warn: false, warn: false,
status: 'ok', status: 'ok',
binFile: 'levels/L0_full.bin',
imageFile: 'images/L0_heightmap.png',
computeTimeMs: 500, computeTimeMs: 500,
}, },
{ {
level: 1, level: 1,
spatialStep: 0.04,
gridStep: 0.08, gridStep: 0.08,
pointCount: 0, maxEdgeLength: 0.24,
pointRatio: 0, clouds: [{ role: 'unique', spatialStep: 0.04, pointCount: 0, pointRatio: 0, binFile: null, imageFile: null }],
volume: null, volume: null,
surface: null, surface: null,
addedVolume: null, addedVolume: null,
@ -71,34 +72,64 @@ describe('reportToCsv', () => {
warn: true, warn: true,
status: 'skipped', status: 'skipped',
message: 'decimation, pas assez de points\navec une virgule, ici', message: 'decimation, pas assez de points\navec une virgule, ici',
binFile: null,
imageFile: null,
computeTimeMs: 0, computeTimeMs: 0,
}, },
], ],
mergedBinFile: 'test_all_levels.bin', mergedBinFile: 'test_all_levels.bin',
}; };
it('genere un en-tete et une ligne par niveau', () => { it('genere un en-tete et une ligne par (niveau, role)', () => {
const csv = reportToCsv(baseReport); const csv = reportToCsv(constHeightReport);
const lines = csv.trim().split('\n'); 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( 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', () => { 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'); const lines = csv.trim().split('\n');
expect(lines[2]).not.toContain('\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(17).join(',');
const messageField = lines[2].split(',').slice(15).join(',');
expect(messageField).not.toMatch(/,/); expect(messageField).not.toMatch(/,/);
}); });
it('laisse les champs numeriques null en cellule vide plutot que "null"', () => { 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'); const lines = csv.trim().split('\n');
expect(lines[2]).not.toContain('null'); 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 { export interface LevelResult {
level: number; 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, voir CLAUDE.md)
gridStep: number | null; // grille de calcul du volume a CE niveau (adaptee dynamiquement a la densite, voir CLAUDE.md) maxEdgeLength: number | null; // limite de remplissage des trous (Delaunay) a CE niveau
pointCount: number; clouds: LevelCloudInfo[]; // 1 element en mode const_height, 2 (top puis bottom) en mode cloud_compare
pointRatio: number; // vs niveau 0
volume: number | null; volume: number | null;
surface: number | null; surface: number | null;
addedVolume: number | null; addedVolume: number | null;
@ -14,28 +26,38 @@ export interface LevelResult {
warn: boolean; warn: boolean;
status: 'ok' | 'skipped' | 'error'; status: 'ok' | 'skipped' | 'error';
message?: string; message?: string;
binFile: string | null; // chemin relatif au dossier du run
imageFile: string | null; // chemin relatif au dossier du run
computeTimeMs: number; computeTimeMs: number;
} }
export interface RunParamsInput { export interface RunParamsInput {
zrefOverride?: number; mode: RunMode;
zrefOverride?: number; // mode const_height uniquement
initialStepOverride?: number; initialStepOverride?: number;
maxEdgeLengthOverride?: number; // les deux modes
} }
export interface RunReport { export interface RunReport {
runId: string; 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; createdAt: string;
params: { params: {
zref: number; zref: number | null; // mode const_height uniquement
zrefSource: 'override' | 'auto'; zrefSource: 'override' | 'auto' | null;
initialStep: number; initialStep: number;
initialStepSource: 'override' | 'auto'; initialStepSource: 'override' | 'auto';
gridStepMultiplier: number; gridStepMultiplier: number;
maxEdgeLengthMultiplier: number;
maxEdgeLengthSource: 'override' | 'auto';
factor: number; factor: number;
steps: number; steps: number;
densityMatch: {
topSpacing: number;
bottomSpacing: number;
matchedSpacing: number;
preDecimatedRole: 'top' | 'bottom' | 'none';
} | null; // mode cloud_compare uniquement
sampleStats: { sampleStats: {
sampleCount: number; sampleCount: number;
meanNn: number; meanNn: number;

View file

@ -8,10 +8,10 @@ import {
Param, Param,
Post, Post,
Res, Res,
UploadedFile, UploadedFiles,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileFieldsInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express'; import type { Response } from 'express';
import archiver from 'archiver'; import archiver from 'archiver';
import * as fs from 'fs'; import * as fs from 'fs';
@ -19,6 +19,7 @@ import * as fsp from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { diskStorage } from 'multer'; import { diskStorage } from 'multer';
import { config, uploadsDir } from '../config'; import { config, uploadsDir } from '../config';
import { RunMode } from '../pipeline/types';
import { RunsService } from './runs.service'; import { RunsService } from './runs.service';
const ALLOWED_EXT = ['.las', '.laz', '.copc.laz', '.bin']; const ALLOWED_EXT = ['.las', '.laz', '.copc.laz', '.bin'];
@ -28,14 +29,7 @@ function isAllowedFilename(name: string): boolean {
return ALLOWED_EXT.some((ext) => lower.endsWith(ext)); return ALLOWED_EXT.some((ext) => lower.endsWith(ext));
} }
@Controller('api/runs') const uploadStorage = diskStorage({
export class RunsController {
constructor(private readonly runs: RunsService) {}
@Post()
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: (_req, _file, cb) => { destination: (_req, _file, cb) => {
const dir = path.join(uploadsDir(), 'incoming'); const dir = path.join(uploadsDir(), 'incoming');
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
@ -44,7 +38,22 @@ export class RunsController {
filename: (_req, file, cb) => { filename: (_req, file, cb) => {
cb(null, `${Date.now()}_${Math.random().toString(36).slice(2)}__${file.originalname}`); 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(
FileFieldsInterceptor(
[
{ name: 'file', maxCount: 1 },
{ name: 'fileTop', maxCount: 1 },
{ name: 'fileBottom', maxCount: 1 },
],
{
storage: uploadStorage,
limits: { fileSize: config.maxUploadBytes }, limits: { fileSize: config.maxUploadBytes },
fileFilter: (_req, file, cb) => { fileFilter: (_req, file, cb) => {
if (!isAllowedFilename(file.originalname)) { if (!isAllowedFilename(file.originalname)) {
@ -53,18 +62,39 @@ export class RunsController {
} }
cb(null, true); cb(null, true);
}, },
}), },
),
) )
async create(@UploadedFile() file: Express.Multer.File, @Body() body: Record<string, string>) { async create(
if (!file) throw new BadRequestException('Aucun fichier recu (champ "file" attendu)'); @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 zrefOverride = this.parseOptionalNumber(body.zrefOverride);
const initialStepOverride = this.parseOptionalNumber(body.initialStepOverride); 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({ return this.runs.createRun({
tmpFilePath: file.path, tmpFilePath: file.path,
originalFilename: file.originalname, originalFilename: file.originalname,
params: { zrefOverride, initialStepOverride }, 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: 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 * as path from 'path';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { resultsDir, uploadsDir } from '../config'; import { resultsDir, uploadsDir } from '../config';
import { PipelineService } from '../pipeline/pipeline.service'; import { PipelineService, RunInputs } from '../pipeline/pipeline.service';
import { RunParamsInput } from '../pipeline/types'; import { RunParamsInput } from '../pipeline/types';
import { DatabaseService, RunRow } from './database.service'; import { DatabaseService, RunRow } from './database.service';
export interface CreateRunOptions { export interface CreateRunOptions {
tmpFilePath: string; tmpFilePath: string;
originalFilename: string; originalFilename: string;
/** mode cloud_compare uniquement : deuxieme fichier (limite inferieure) */
tmpFilePathSecondary?: string;
originalFilenameSecondary?: string;
params: RunParamsInput; params: RunParamsInput;
} }
interface QueueJob { interface QueueJob {
id: string; id: string;
inputPath: string; inputs: RunInputs;
originalFilename: string;
params: RunParamsInput; params: RunParamsInput;
} }
@ -40,15 +42,33 @@ export class RunsService {
const destPath = path.join(runUploadDir, `input${ext}`); const destPath = path.join(runUploadDir, `input${ext}`);
await fs.rename(opts.tmpFilePath, destPath); 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({ this.db.insert({
id, id,
original_filename: opts.originalFilename, original_filename: displayName,
status: 'pending', status: 'pending',
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
params_json: JSON.stringify(opts.params), 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(); void this.processQueue();
return { id }; return { id };
@ -78,7 +98,7 @@ export class RunsService {
}; };
try { 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)); this.db.complete(job.id, JSON.stringify(report));
onProgress('Run termine avec succes.'); onProgress('Run termine avec succes.');
} catch (err: any) { } catch (err: any) {