diff --git a/CLAUDE.md b/CLAUDE.md index dfd8340..aa68424 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 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 ` 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 -O -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. diff --git a/README.md b/README.md index cbcedee..bc89d71 100644 --- a/README.md +++ b/README.md @@ -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`) diff --git a/public/app.js b/public/app.js index 33de571..e87554e 100644 --- a/public/app.js +++ b/public/app.js @@ -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() {

Nouveau run

- - + +
+ + +
+ +
+ + +

Le volume est calcule entre le nuage et un plan horizontal (altitude min par defaut).

+
+ + +
Options avancees (optionnel)
-
- +
+
@@ -107,15 +141,41 @@ function renderNew() {
+
+ + +

Trop grand = comble aussi les creux du contour de l'amas (a eviter). Trop petit = les trous de scan restent vides.

+
`; - 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('
'); +} + +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' ? '' : `
${escapeHtml(l.message || '')}
`; return ` ${l.level} - ${l.spatialStep !== null ? fmt(l.spatialStep, 5) + ' m' : 'complet'} + ${stepCell(l)} ${l.gridStep !== null ? fmt(l.gridStep, 5) + ' m' : '-'} - ${l.status === 'ok' ? l.pointCount.toLocaleString('fr-FR') : '-'} - ${l.status === 'ok' ? fmt(l.pointRatio * 100, 2) + ' %' : '-'} + ${l.maxEdgeLength !== null ? fmt(l.maxEdgeLength, 5) + ' m' : '-'} + ${pointsCell(l)} ${l.status === 'ok' ? fmt(l.volume, 4) + ' m3' : '-'} ${l.status === 'ok' ? fmt(l.surface, 3) + ' m2' : '-'} ${l.status === 'ok' ? fmt(l.matchingCellsPct, 1) + ' %' : '-'} @@ -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 ? `
- - + + + - ${r.levels.map((l) => levelRow(l, run.id)).join('')} + ${r.levels.map((l) => levelRow(l)).join('')}
NiveauPas spatialGrille volumePoints% points vs L0VolumeSurfaceNiveauPas spatialGrille volumeMax edge (remplissage)PointsVolumeSurface Matching cellsStatut
` : ''; + const densityBlock = r && r.params.densityMatch ? ` +
+

Appariement de densite (niveau 0)

+

Pas haut: ${fmt(r.params.densityMatch.topSpacing, 5)} m · Pas bas: ${fmt(r.params.densityMatch.bottomSpacing, 5)} m + · 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`})

+
` : ''; + const params = r ? `
-
Zref
${fmt(r.params.zref, 4)} (${r.params.zrefSource})
+ ${!isCompare ? `
Zref
${fmt(r.params.zref, 4)} (${r.params.zrefSource})
` : ''}
Pas initial
${fmt(r.params.initialStep, 5)} m (${r.params.initialStepSource})
Grille volume
x${r.params.gridStepMultiplier} le pas spatial, adaptee a chaque niveau
+
Remplissage trous
x${r.params.maxEdgeLengthMultiplier} la grille (${r.params.maxEdgeLengthSource})
Facteur / etapes
x${r.params.factor} sur ${r.params.steps}
-
` : ''; + + ${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 }) => `
- -

Niveau ${l.level}

+ +

Niveau ${level}${c.role !== 'unique' ? ' - ' + roleLabel(c.role) : ''}

`).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' ? ` ` : ''; @@ -222,7 +309,10 @@ function paintDetail(run) {

${escapeHtml(run.originalFilename)}

${statusBadge(run.status)} -

Cree le ${new Date(run.createdAt).toLocaleString('fr-FR')}${run.finishedAt ? ' - termine le ' + new Date(run.finishedAt).toLocaleString('fr-FR') : ''}

+

+ ${r ? (isCompare ? 'Mode : comparaison a un second nuage' : 'Mode : plan de reference Z constant') : ''} + · Cree le ${new Date(run.createdAt).toLocaleString('fr-FR')}${run.finishedAt ? ' - termine le ' + new Date(run.finishedAt).toLocaleString('fr-FR') : ''} +

${errorBlock} ${params} ${levelsTable} diff --git a/python/render_images.py b/python/render_images.py index 2a94804..35fb4c1 100644 --- a/python/render_images.py +++ b/python/render_images.py @@ -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) diff --git a/src/cloudcompare/cc-runner.service.spec.ts b/src/cloudcompare/cc-runner.service.spec.ts index 0a3d7c4..ebb0b89 100644 --- a/src/cloudcompare/cc-runner.service.spec.ts +++ b/src/cloudcompare/cc-runner.service.spec.ts @@ -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', () => { diff --git a/src/cloudcompare/cc-runner.service.ts b/src/cloudcompare/cc-runner.service.ts index 7fe1732..6f92e40 100644 --- a/src/cloudcompare/cc-runner.service.ts +++ b/src/cloudcompare/cc-runner.service.ts @@ -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 { 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]+)/), }; } } diff --git a/src/config.ts b/src/config.ts index 11e2af9..e23b210 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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, diff --git a/src/pipeline/pipeline.service.ts b/src/pipeline/pipeline.service.ts index 55656e8..7528cc3 100644 --- a/src/pipeline/pipeline.service.ts +++ b/src/pipeline/pipeline.service.ts @@ -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> = {}; + const refPointCount: Partial> = {}; - // ---------- 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 { + 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 { - const { level, spatialStep, workDir, gridStep, zref, refPointCount } = opts; - const t0 = Date.now(); + }): Promise { + 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'; } diff --git a/src/pipeline/pipeline.util.spec.ts b/src/pipeline/pipeline.util.spec.ts index 30e5e08..6f30250 100644 --- a/src/pipeline/pipeline.util.spec.ts +++ b/src/pipeline/pipeline.util.spec.ts @@ -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'); }); diff --git a/src/pipeline/types.ts b/src/pipeline/types.ts index df3b074..3322432 100644 --- a/src/pipeline/types.ts +++ b/src/pipeline/types.ts @@ -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; diff --git a/src/runs/runs.controller.ts b/src/runs/runs.controller.ts index a9bff4f..c351605 100644 --- a/src/runs/runs.controller.ts +++ b/src/runs/runs.controller.ts @@ -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) { - 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, + ) { + 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 }, }); } diff --git a/src/runs/runs.service.ts b/src/runs/runs.service.ts index b99f58d..81581d4 100644 --- a/src/runs/runs.service.ts +++ b/src/runs/runs.service.ts @@ -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) {