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:
parent
7f2e4c323b
commit
86a75bd47b
12 changed files with 801 additions and 376 deletions
86
CLAUDE.md
86
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>` : distance minimale entre points, PAS un ratio de points cible. Le nombre
|
||||
|
|
@ -105,6 +140,42 @@ E57 volontairement hors perimetre.
|
|||
- `-SILENT` doit etre le tout premier argument (ou juste apres `-VERBOSITY`).
|
||||
- `-PREC <n>` controle la precision decimale des exports ASCII (`-C_EXPORT_FMT ASC`).
|
||||
|
||||
## Mode comparaison a 2 nuages (`cloud_compare`)
|
||||
|
||||
En plus du mode `const_height` (nuage unique vs plan Z constant), un second mode compare 2 nuages
|
||||
entre eux : le nuage du **haut** ("top", surface superieure de l'amas) et celui du **bas** ("bottom",
|
||||
limite inferieure/base/socle). Motivation reelle : sur un vrai nuage industriel ("Amas 1 Nuage.las",
|
||||
~150m de long), le mode `const_height` donnait un volume de ~50m3 alors que l'objet fait clairement
|
||||
plus — cause identifiee par inspection de la heightmap : le nuage a une empreinte **diagonale** dans
|
||||
sa bounding box (~150x113m), et surtout le plan Z constant (altitude min globale du nuage entier)
|
||||
n'a aucun rapport avec la base reelle de l'amas si le fichier contient aussi du terrain environnant.
|
||||
Avec le second nuage ("Plan d'ajustement NUAGE.las", une base fittee specifiquement sous l'amas), le
|
||||
volume calcule est de ~14 244m3, stable a +/-4% meme a 16x de decimation (129 points) — la aussi
|
||||
confirme par inspection visuelle (les deux nuages ont exactement la meme empreinte au sol).
|
||||
**Enseignement general : le mode `const_height` n'est fiable QUE si le nuage ne contient QUE l'objet
|
||||
mesure (pas de terrain/contexte environnant) ; sinon `cloud_compare` avec une base dediee est le seul
|
||||
mode qui donne un chiffre correct.**
|
||||
|
||||
Implementation :
|
||||
- **Appariement de densite** avant le niveau 0 : on calcule la distance mediane au plus proche voisin
|
||||
de chaque nuage independamment, on prend le MAX des deux (`matchedSpacing`), puis on decime (une
|
||||
seule fois, `-SS SPATIAL matchedSpacing`) le nuage le plus dense pour le ramener a la meme resolution
|
||||
que l'autre — l'operation est un no-op si le nuage est deja plus clairseme que `matchedSpacing` (`-SS
|
||||
SPATIAL` ne peut pas densifier). Le niveau 0 du mode `cloud_compare` EST ce nuage appari (contrairement
|
||||
au mode `const_height` ou le niveau 0 est le nuage brut, jamais decime) : `spatialStep(niveau) =
|
||||
initialStep * facteur^niveau` pour TOUS les niveaux 0..5 en mode compare (vs `null` puis
|
||||
`initialStep * facteur^niveau` pour niveaux 1..5 en mode const_height).
|
||||
- **Ordre de chargement pour `-VOLUME`** : `-O <top_comble.bin> -O <bottom_comble.bin> -VOLUME
|
||||
-GRID_STEP g` (sans `-CONST_HEIGHT`, sans `-GROUND_IS_FIRST`). Verifie dans les sources
|
||||
(`CommandVolume25D::process`) : le premier nuage charge = "ceil", le second = "ground" (sauf
|
||||
`-GROUND_IS_FIRST` qui les inverse) — charger top puis bottom donne directement ceil=top,
|
||||
ground=bottom, ce qui correspond au sens voulu.
|
||||
- Remplissage des trous (voir section `-VOLUME` plus haut) applique aux DEUX nuages independamment,
|
||||
a chaque niveau, avec le meme `gridStep`/`maxEdgeLength` adaptatifs que le mode single-cloud.
|
||||
- La decimation des DEUX nuages est synchronisee (meme `spatialStep` a chaque niveau) ; si l'un des
|
||||
deux tombe sous `minPointsToContinue`, toute la progression s'arrete (les deux surfaces sont
|
||||
necessaires pour une comparaison valide).
|
||||
|
||||
## Paquet apt Debian `cloudcompare` (trixie, 2.13.2) — limitations et contournements
|
||||
|
||||
Choix d'archi : `debian:trixie-slim` + `apt-get install cloudcompare` plutot qu'une compilation depuis
|
||||
|
|
@ -164,9 +235,14 @@ manquantes), mais pourraient etre simplifies.
|
|||
- `docker-compose.yml` utilise un **volume nomme** (`app-data:/data`), pas un bind mount vers `./data` :
|
||||
Coolify re-clone le repo a chaque deploiement, un bind mount relatif au checkout perdrait les
|
||||
donnees (uploads, resultats, base sqlite) a chaque redeploy.
|
||||
- `HEALTHCHECK` dans le Dockerfile (`curl` sur `/`) : Coolify l'utilise pour determiner si le
|
||||
deploiement a reussi.
|
||||
- `HEALTHCHECK` dans le Dockerfile (`curl` sur `/health`, route publique non authentifiee) : Coolify
|
||||
l'utilise pour determiner si le deploiement a reussi.
|
||||
- Le port d'ecoute est configurable via `PORT` (`config.ts` lit `process.env.PORT`, defaut 3000).
|
||||
- Auth HTTP Basic globale (UI + API) via middleware Express (`app.use`, pas un Guard Nest) enregistre
|
||||
dans `main.ts` **avant** `app.listen()` — verifie empiriquement que ca protege bien aussi les
|
||||
fichiers statiques servis par `ServeStaticModule` (pas seulement les routes `@Controller`), malgre
|
||||
le fait que `ServeStaticModule` s'enregistre via le systeme de modules Nest plutot que directement
|
||||
sur l'instance `app`. Desactivable si `AUTH_USERNAME`/`AUTH_PASSWORD_HASH` absents (dev local).
|
||||
|
||||
## Constantes du pipeline (voir `src/config.ts`)
|
||||
|
||||
|
|
@ -175,5 +251,9 @@ manquantes), mais pourraient etre simplifies.
|
|||
(niveau 0 : `initialStep x2`). Adapte dynamiquement, pas fige — voir section `-VOLUME` plus haut.
|
||||
- `statsSampleCap = 50000` : nb de points echantillonnes pour estimer la distance mediane au plus
|
||||
proche voisin (KD-tree scipy) qui sert de pas spatial initial.
|
||||
- `minPointsToContinue = 25` : la decimation s'arrete si un niveau tombe en dessous.
|
||||
- `maxEdgeLengthMultiplier = 3` : distance max d'interpolation Delaunay (remplissage de trous) a
|
||||
CHAQUE niveau = gridStep de ce niveau x3. Override utilisateur possible (`maxEdgeLengthOverride`,
|
||||
valeur absolue en metres) si ce defaut comble trop/pas assez pour un nuage donne.
|
||||
- `minPointsToContinue = 25` : la decimation s'arrete si un niveau (ou l'un des 2 nuages en mode
|
||||
compare) tombe en dessous.
|
||||
- `matchingCellsWarnThreshold = 90` : seuil d'affichage "a verifier" dans l'UI.
|
||||
|
|
|
|||
64
README.md
64
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`)
|
||||
|
||||
|
|
|
|||
128
public/app.js
128
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() {
|
|||
<h1 class="text-xl font-semibold mb-6">Nouveau run</h1>
|
||||
<form id="upload-form" class="space-y-5 max-w-xl">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Type de comparaison</label>
|
||||
<div class="flex gap-4 text-sm">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="mode" value="const_height" checked />
|
||||
Plan de reference Z constant
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="mode" value="cloud_compare" />
|
||||
Comparaison a un second nuage
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mode-const-height">
|
||||
<label class="block text-sm font-medium mb-1">Nuage de points (.las, .laz, .copc.laz, .bin)</label>
|
||||
<input 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Limite inferieure (base / socle de l'amas)</label>
|
||||
<input type="file" name="fileBottom" accept=".las,.laz,.bin"
|
||||
class="block w-full text-sm text-slate-300 border border-slate-700 rounded-lg cursor-pointer bg-slate-900 focus:outline-none p-2" />
|
||||
</div>
|
||||
<p class="text-xs text-slate-500">Les densites des deux nuages sont d'abord appariees (le plus dense est decime pour matcher l'autre), puis les deux sont decimes ensemble a chaque etape.</p>
|
||||
</div>
|
||||
|
||||
<details class="border border-slate-800 rounded-lg p-3">
|
||||
<summary class="cursor-pointer text-sm text-slate-400">Options avancees (optionnel)</summary>
|
||||
<div class="mt-3 space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Plan de reference Z (override)</label>
|
||||
<div id="zref-override-wrap">
|
||||
<label class="block text-sm mb-1">Plan de reference Z (override, mode Z constant uniquement)</label>
|
||||
<input type="number" step="any" name="zrefOverride" placeholder="auto = Zmin de l'echantillon"
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
|
||||
</div>
|
||||
|
|
@ -107,15 +141,41 @@ function renderNew() {
|
|||
<input type="number" step="any" name="initialStepOverride" placeholder="auto = distance mediane au plus proche voisin"
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Distance max de remplissage des trous en metres (override)</label>
|
||||
<input type="number" step="any" name="maxEdgeLengthOverride" placeholder="auto = grille du niveau x3"
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm" />
|
||||
<p class="text-xs text-slate-500 mt-1">Trop grand = comble aussi les creux du contour de l'amas (a eviter). Trop petit = les trous de scan restent vides.</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<button type="submit" class="bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded-lg font-medium">Lancer le run</button>
|
||||
<p id="upload-error" class="text-red-400 text-sm hidden"></p>
|
||||
</form>`;
|
||||
|
||||
document.getElementById('upload-form').addEventListener('submit', async (e) => {
|
||||
const form = document.getElementById('upload-form');
|
||||
const constDiv = document.getElementById('mode-const-height');
|
||||
const compareDiv = document.getElementById('mode-cloud-compare');
|
||||
const zrefWrap = document.getElementById('zref-override-wrap');
|
||||
const constFileInput = form.querySelector('input[name=file]');
|
||||
const topFileInput = form.querySelector('input[name=fileTop]');
|
||||
const bottomFileInput = form.querySelector('input[name=fileBottom]');
|
||||
|
||||
form.querySelectorAll('input[name=mode]').forEach((radio) => {
|
||||
radio.addEventListener('change', () => {
|
||||
const isCompare = form.mode.value === 'cloud_compare';
|
||||
constDiv.classList.toggle('hidden', isCompare);
|
||||
compareDiv.classList.toggle('hidden', !isCompare);
|
||||
zrefWrap.classList.toggle('hidden', isCompare);
|
||||
constFileInput.required = !isCompare;
|
||||
topFileInput.required = isCompare;
|
||||
bottomFileInput.required = isCompare;
|
||||
});
|
||||
});
|
||||
constFileInput.required = true;
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const fd = new FormData(form);
|
||||
const errEl = document.getElementById('upload-error');
|
||||
errEl.classList.add('hidden');
|
||||
|
|
@ -156,16 +216,30 @@ async function loadDetail(id) {
|
|||
paintDetail(run);
|
||||
}
|
||||
|
||||
function levelRow(l, id) {
|
||||
function pointsCell(l) {
|
||||
if (l.status !== 'ok') return '-';
|
||||
return l.clouds.map((c) => {
|
||||
const label = c.role === 'unique' ? '' : `${roleLabel(c.role)}: `;
|
||||
return `${label}${c.pointCount.toLocaleString('fr-FR')} (${fmt(c.pointRatio * 100, 1)}%)`;
|
||||
}).join('<br/>');
|
||||
}
|
||||
|
||||
function stepCell(l) {
|
||||
const c = l.clouds[0];
|
||||
if (!c || c.spatialStep === null) return 'complet';
|
||||
return fmt(c.spatialStep, 5) + ' m';
|
||||
}
|
||||
|
||||
function levelRow(l) {
|
||||
const warnCls = l.status === 'error' ? 'bg-red-950/40' : l.warn ? 'bg-amber-950/30' : '';
|
||||
const statusTxt = l.status === 'ok' ? '' : `<div class="text-xs text-slate-400 mt-0.5">${escapeHtml(l.message || '')}</div>`;
|
||||
return `
|
||||
<tr class="border-b border-slate-800 ${warnCls}">
|
||||
<td class="py-2 px-3 font-medium">${l.level}</td>
|
||||
<td class="py-2 px-3">${l.spatialStep !== null ? fmt(l.spatialStep, 5) + ' m' : 'complet'}</td>
|
||||
<td class="py-2 px-3">${stepCell(l)}</td>
|
||||
<td class="py-2 px-3">${l.gridStep !== null ? fmt(l.gridStep, 5) + ' m' : '-'}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? l.pointCount.toLocaleString('fr-FR') : '-'}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.pointRatio * 100, 2) + ' %' : '-'}</td>
|
||||
<td class="py-2 px-3">${l.maxEdgeLength !== null ? fmt(l.maxEdgeLength, 5) + ' m' : '-'}</td>
|
||||
<td class="py-2 px-3">${pointsCell(l)}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.volume, 4) + ' m3' : '-'}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.surface, 3) + ' m2' : '-'}</td>
|
||||
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.matchingCellsPct, 1) + ' %' : '-'}</td>
|
||||
|
|
@ -175,32 +249,45 @@ function levelRow(l, id) {
|
|||
|
||||
function paintDetail(run) {
|
||||
const r = run.report;
|
||||
const isCompare = r && r.mode === 'cloud_compare';
|
||||
|
||||
const levelsTable = r ? `
|
||||
<div class="overflow-x-auto border border-slate-800 rounded-lg mt-4">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-slate-900 text-slate-400 text-xs uppercase">
|
||||
<tr>
|
||||
<th class="py-2 px-3">Niveau</th><th class="py-2 px-3">Pas spatial</th><th class="py-2 px-3">Grille volume</th><th class="py-2 px-3">Points</th>
|
||||
<th class="py-2 px-3">% points vs L0</th><th class="py-2 px-3">Volume</th><th class="py-2 px-3">Surface</th>
|
||||
<th class="py-2 px-3">Niveau</th><th class="py-2 px-3">Pas spatial</th><th class="py-2 px-3">Grille volume</th>
|
||||
<th class="py-2 px-3">Max edge (remplissage)</th><th class="py-2 px-3">Points</th>
|
||||
<th class="py-2 px-3">Volume</th><th class="py-2 px-3">Surface</th>
|
||||
<th class="py-2 px-3">Matching cells</th><th class="py-2 px-3">Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${r.levels.map((l) => levelRow(l, run.id)).join('')}</tbody>
|
||||
<tbody>${r.levels.map((l) => levelRow(l)).join('')}</tbody>
|
||||
</table>
|
||||
</div>` : '';
|
||||
|
||||
const densityBlock = r && r.params.densityMatch ? `
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3 text-sm mt-4">
|
||||
<p class="text-slate-400 text-xs mb-1">Appariement de densite (niveau 0)</p>
|
||||
<p>Pas haut: ${fmt(r.params.densityMatch.topSpacing, 5)} m · 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`})</p>
|
||||
</div>` : '';
|
||||
|
||||
const params = r ? `
|
||||
<dl class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm mt-4">
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Zref</dt><dd>${fmt(r.params.zref, 4)} (${r.params.zrefSource})</dd></div>
|
||||
${!isCompare ? `<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Zref</dt><dd>${fmt(r.params.zref, 4)} (${r.params.zrefSource})</dd></div>` : ''}
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Pas initial</dt><dd>${fmt(r.params.initialStep, 5)} m (${r.params.initialStepSource})</dd></div>
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Grille volume</dt><dd>x${r.params.gridStepMultiplier} le pas spatial, adaptee a chaque niveau</dd></div>
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Remplissage trous</dt><dd>x${r.params.maxEdgeLengthMultiplier} la grille (${r.params.maxEdgeLengthSource})</dd></div>
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Facteur / etapes</dt><dd>x${r.params.factor} sur ${r.params.steps}</dd></div>
|
||||
</dl>` : '';
|
||||
</dl>
|
||||
${densityBlock}` : '';
|
||||
|
||||
const heightmaps = r ? r.levels.filter((l) => l.imageFile).map((l) => `
|
||||
const heightmaps = r ? r.levels.flatMap((l) => l.clouds.filter((c) => c.imageFile).map((c) => ({ level: l.level, c }))).map(({ level, c }) => `
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-lg p-2">
|
||||
<img src="${API}/${run.id}/images/${l.imageFile.split('/').pop()}" class="w-full rounded" loading="lazy" />
|
||||
<p class="text-xs text-slate-400 text-center mt-1">Niveau ${l.level}</p>
|
||||
<img src="${API}/${run.id}/images/${c.imageFile.split('/').pop()}" class="w-full rounded" loading="lazy" />
|
||||
<p class="text-xs text-slate-400 text-center mt-1">Niveau ${level}${c.role !== 'unique' ? ' - ' + roleLabel(c.role) : ''}</p>
|
||||
</div>`).join('') : '';
|
||||
|
||||
const summaryCharts = run.status === 'done' ? ['volume_vs_level.png', 'points_vs_level.png', 'robustness_vs_level.png'].map((f) => `
|
||||
|
|
@ -210,7 +297,7 @@ function paintDetail(run) {
|
|||
|
||||
const downloads = run.status === 'done' ? `
|
||||
<div class="flex flex-wrap gap-2 mt-4">
|
||||
<a href="${API}/${run.id}/download/bin" class="bg-slate-800 hover:bg-slate-700 px-3 py-2 rounded-lg text-sm">Nuage assemble (.bin)</a>
|
||||
<a href="${API}/${run.id}/download/bin" class="bg-slate-800 hover:bg-slate-700 px-3 py-2 rounded-lg text-sm">Nuages assembles (.bin)</a>
|
||||
<a href="${API}/${run.id}/download/csv" class="bg-slate-800 hover:bg-slate-700 px-3 py-2 rounded-lg text-sm">Rapport (.csv)</a>
|
||||
<a href="${API}/${run.id}/download/zip" class="bg-slate-800 hover:bg-slate-700 px-3 py-2 rounded-lg text-sm">Dossier complet (.zip)</a>
|
||||
</div>` : '';
|
||||
|
|
@ -222,7 +309,10 @@ function paintDetail(run) {
|
|||
<h1 class="text-xl font-semibold">${escapeHtml(run.originalFilename)}</h1>
|
||||
${statusBadge(run.status)}
|
||||
</div>
|
||||
<p class="text-slate-400 text-sm mb-4">Cree le ${new Date(run.createdAt).toLocaleString('fr-FR')}${run.finishedAt ? ' - termine le ' + new Date(run.finishedAt).toLocaleString('fr-FR') : ''}</p>
|
||||
<p class="text-slate-400 text-sm mb-4">
|
||||
${r ? (isCompare ? 'Mode : comparaison a un second nuage' : 'Mode : plan de reference Z constant') : ''}
|
||||
· Cree le ${new Date(run.createdAt).toLocaleString('fr-FR')}${run.finishedAt ? ' - termine le ' + new Date(run.finishedAt).toLocaleString('fr-FR') : ''}
|
||||
</p>
|
||||
${errorBlock}
|
||||
${params}
|
||||
${levelsTable}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -113,29 +113,36 @@ export class CcRunnerService {
|
|||
return candidates[0].full;
|
||||
}
|
||||
|
||||
/** Parse le fichier texte "VolumeCalculationReport_*.txt" genere par -VOLUME */
|
||||
/**
|
||||
* Parse le fichier texte "VolumeCalculationReport_*.txt" genere par -VOLUME.
|
||||
* CloudCompare formate les grands nombres avec des virgules comme separateur de milliers
|
||||
* (ex: "Volume: 14,244.464657") - il faut les retirer avant conversion, sinon Number()
|
||||
* tronque silencieusement a la premiere virgule (14,244 devient 14). Verifie empiriquement
|
||||
* sur un vrai rapport genere par CC 2.13.2 - voir CLAUDE.md.
|
||||
*/
|
||||
async parseVolumeReportFile(filePath: string): Promise<VolumeReport> {
|
||||
const text = await fs.readFile(filePath, 'utf-8');
|
||||
const toNumber = (raw: string): number => Number(raw.replace(/,/g, ''));
|
||||
const num = (re: RegExp): number => {
|
||||
const m = text.match(re);
|
||||
if (!m) throw new Error(`Champ introuvable dans le rapport de volume (${re}): ${filePath}`);
|
||||
return Number(m[1]);
|
||||
return toNumber(m[1]);
|
||||
};
|
||||
const numOpt = (re: RegExp): number | null => {
|
||||
const m = text.match(re);
|
||||
return m ? Number(m[1]) : null;
|
||||
return m ? toNumber(m[1]) : null;
|
||||
};
|
||||
|
||||
return {
|
||||
volume: num(/Volume:\s*([-\d.eE]+)/),
|
||||
surface: num(/Surface:\s*([-\d.eE]+)/),
|
||||
addedVolume: num(/Added volume:\s*\(\+\)\s*([-\d.eE]+)/),
|
||||
removedVolume: num(/Removed volume:\s*\(-\)\s*([-\d.eE]+)/),
|
||||
matchingCellsPct: num(/Matching cells:\s*([-\d.eE]+)\s*%/),
|
||||
groundNonMatchingPct: num(/ground\s*=\s*([-\d.eE]+)\s*%/),
|
||||
ceilNonMatchingPct: num(/ceil\s*=\s*([-\d.eE]+)\s*%/),
|
||||
avgNeighborsCurrent: numOpt(/Average neighbors per cell:\s*([-\d.eE]+)\s*\//),
|
||||
avgNeighborsRef: numOpt(/Average neighbors per cell:.*\/\s*([-\d.eE]+)/),
|
||||
volume: num(/Volume:\s*([-\d,.eE]+)/),
|
||||
surface: num(/Surface:\s*([-\d,.eE]+)/),
|
||||
addedVolume: num(/Added volume:\s*\(\+\)\s*([-\d,.eE]+)/),
|
||||
removedVolume: num(/Removed volume:\s*\(-\)\s*([-\d,.eE]+)/),
|
||||
matchingCellsPct: num(/Matching cells:\s*([-\d,.eE]+)\s*%/),
|
||||
groundNonMatchingPct: num(/ground\s*=\s*([-\d,.eE]+)\s*%/),
|
||||
ceilNonMatchingPct: num(/ceil\s*=\s*([-\d,.eE]+)\s*%/),
|
||||
avgNeighborsCurrent: numOpt(/Average neighbors per cell:\s*([-\d,.eE]+)\s*\//),
|
||||
avgNeighborsRef: numOpt(/Average neighbors per cell:.*\/\s*([-\d,.eE]+)/),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 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 primaryStats = await this.computeStatsFor(primaryNorm, workDir, 'sample_primary.xyz');
|
||||
|
||||
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;
|
||||
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'}), ` +
|
||||
`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]`,
|
||||
`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 maxEdgeLengthSource: 'override' | 'auto' = params.maxEdgeLengthOverride !== undefined ? 'override' : 'auto';
|
||||
|
||||
const levels: LevelResult[] = [];
|
||||
let stopped = false;
|
||||
let stopReason = '';
|
||||
|
||||
// ---------- 2) Niveau 0 : nuage complet ----------
|
||||
onProgress('Niveau 0 (nuage complet) : conversion .bin + calcul de volume...');
|
||||
const level0 = await this.computeLevel({
|
||||
level: 0,
|
||||
spatialStep: null,
|
||||
sourceBinOrOriginal: normalizedInputPath,
|
||||
isFirstConversion: true,
|
||||
levelsDir,
|
||||
imagesDir,
|
||||
rawDir,
|
||||
workDir,
|
||||
gridStep: level0GridStep,
|
||||
zref,
|
||||
refPointCount: totalPointCount,
|
||||
onProgress,
|
||||
});
|
||||
levels.push(level0);
|
||||
// Etat courant par role : chemin du nuage BRUT (non rempli) du niveau precedent
|
||||
const prevRaw: Partial<Record<CloudRole, string>> = {};
|
||||
const refPointCount: Partial<Record<CloudRole, number>> = {};
|
||||
|
||||
// ---------- 3) Niveaux 1..N : decimation progressive ----------
|
||||
let prevBinAbs = level0.binFile ? path.join(workDir, level0.binFile) : null;
|
||||
let prevPointCount = level0.pointCount;
|
||||
let stopped = level0.status !== 'ok';
|
||||
let stopReason = level0.status !== 'ok' ? 'le niveau 0 a echoue' : '';
|
||||
const rolesForThisRun: CloudRole[] = isCompare ? ['top', 'bottom'] : ['unique'];
|
||||
|
||||
for (let i = 1; i <= config.decimationSteps; i++) {
|
||||
if (stopped || !prevBinAbs) {
|
||||
for (let i = 0; i <= config.decimationSteps; i++) {
|
||||
if (stopped) {
|
||||
levels.push(this.skippedLevel(i, stopReason));
|
||||
continue;
|
||||
}
|
||||
if (prevPointCount < config.minPointsToContinue) {
|
||||
stopReason = `nuage trop clairseme apres le niveau ${i - 1} (${prevPointCount} points < seuil ${config.minPointsToContinue})`;
|
||||
levels.push(this.skippedLevel(i, stopReason));
|
||||
stopped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const spatialStep = initialStep * Math.pow(config.decimationFactor, i);
|
||||
const levelGridStep = spatialStep * config.gridStepMultiplier;
|
||||
// Niveau 0 (const_height) = nuage complet, jamais decime : spatialStep=null.
|
||||
// Niveau 0 (cloud_compare) = pas apparie = initialStep (factor^0) ; niveaux suivants x2.
|
||||
const spatialStep = !isCompare && i === 0 ? null : initialStep * Math.pow(config.decimationFactor, i);
|
||||
const gridStep = (spatialStep ?? initialStep) * config.gridStepMultiplier;
|
||||
const maxEdgeLength = params.maxEdgeLengthOverride ?? gridStep * config.maxEdgeLengthMultiplier;
|
||||
|
||||
onProgress(
|
||||
`Niveau ${i} : decimation spatiale (pas=${spatialStep.toFixed(5)}m, grille volume=${levelGridStep.toFixed(5)}m) depuis le niveau ${i - 1}...`,
|
||||
`Niveau ${i} : ${spatialStep === null ? 'nuage complet' : `decimation (pas=${spatialStep.toFixed(5)}m)`}, ` +
|
||||
`grille=${gridStep.toFixed(5)}m, remplissage trous (max edge=${maxEdgeLength.toFixed(5)}m)...`,
|
||||
);
|
||||
|
||||
try {
|
||||
const lvl = await this.computeLevel({
|
||||
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,
|
||||
sourceBinOrOriginal: prevBinAbs,
|
||||
isFirstConversion: false,
|
||||
sourcePath,
|
||||
isFirstConversion,
|
||||
levelsDir,
|
||||
imagesDir,
|
||||
rawDir,
|
||||
workDir,
|
||||
gridStep: levelGridStep,
|
||||
zref,
|
||||
refPointCount: totalPointCount,
|
||||
onProgress,
|
||||
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,
|
||||
});
|
||||
levels.push(lvl);
|
||||
if (lvl.status === 'ok') {
|
||||
prevBinAbs = lvl.binFile ? path.join(workDir, lvl.binFile) : null;
|
||||
prevPointCount = lvl.pointCount;
|
||||
} else {
|
||||
stopped = true;
|
||||
stopReason = lvl.message ?? `echec au niveau ${i}`;
|
||||
stopReason = anyBad.message ?? `echec au niveau ${i}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---------- Volume (sur les nuages COMBLES) ----------
|
||||
const beforeVolume = Date.now();
|
||||
let volRes;
|
||||
if (!isCompare) {
|
||||
volRes = await this.cc.run(
|
||||
['-O', this.relFrom(workDir, cloudResults[0].filledBinAbs!), '-VOLUME', '-GRID_STEP', String(gridStep), '-CONST_HEIGHT', String(zref)],
|
||||
workDir,
|
||||
{ autoSave: true },
|
||||
);
|
||||
} else {
|
||||
volRes = await this.cc.run(
|
||||
[
|
||||
'-O',
|
||||
this.relFrom(workDir, cloudResults[0].filledBinAbs!), // top = ceil
|
||||
'-O',
|
||||
this.relFrom(workDir, cloudResults[1].filledBinAbs!), // bottom = ground
|
||||
'-VOLUME',
|
||||
'-GRID_STEP',
|
||||
String(gridStep),
|
||||
],
|
||||
workDir,
|
||||
{ autoSave: true },
|
||||
);
|
||||
}
|
||||
const reportFile = await this.cc.findLatestFile(workDir, { prefix: 'VolumeCalculationReport', suffix: '.txt', after: beforeVolume });
|
||||
if (!reportFile) {
|
||||
throw new Error(`Rapport de volume introuvable au niveau ${i}. Sortie:\n${volRes.stdout.slice(-1500)}`);
|
||||
}
|
||||
const vol = await this.cc.parseVolumeReportFile(reportFile);
|
||||
await fs.rename(reportFile, path.join(rawDir, `L${i}_volume_report.txt`));
|
||||
|
||||
// AUTO_SAVE ON sauvegarde aussi une grille "*_HEIGHT_DIFFERENCE_*.bin" qu'on ne veut pas garder
|
||||
const strayGrid = await this.cc.findLatestFile(workDir, { suffix: '.bin', after: beforeVolume });
|
||||
const filledPaths = new Set(cloudResults.map((r) => r.filledBinAbs));
|
||||
if (strayGrid && !filledPaths.has(strayGrid)) {
|
||||
await fs.unlink(strayGrid).catch(() => undefined);
|
||||
}
|
||||
|
||||
const warn = vol.matchingCellsPct < config.matchingCellsWarnThreshold;
|
||||
|
||||
levels.push({
|
||||
level: i,
|
||||
gridStep,
|
||||
maxEdgeLength,
|
||||
clouds: cloudResults.map((r) => r.info),
|
||||
volume: vol.volume,
|
||||
surface: vol.surface,
|
||||
addedVolume: vol.addedVolume,
|
||||
removedVolume: vol.removedVolume,
|
||||
matchingCellsPct: vol.matchingCellsPct,
|
||||
groundNonMatchingPct: vol.groundNonMatchingPct,
|
||||
ceilNonMatchingPct: vol.ceilNonMatchingPct,
|
||||
warn,
|
||||
status: 'ok',
|
||||
computeTimeMs: Date.now() - beforeVolume,
|
||||
});
|
||||
|
||||
for (const r of cloudResults) {
|
||||
prevRaw[r.info.role] = r.rawBinAbs!;
|
||||
if (i === 0) refPointCount[r.info.role] = r.info.pointCount;
|
||||
}
|
||||
|
||||
const tooSparse = cloudResults.some((r) => r.info.pointCount < config.minPointsToContinue);
|
||||
if (tooSparse) {
|
||||
stopped = true;
|
||||
stopReason = `nuage trop clairseme apres le niveau ${i} (seuil ${config.minPointsToContinue} points)`;
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Niveau ${i} en erreur: ${err.message}`);
|
||||
levels.push({
|
||||
level: i,
|
||||
spatialStep,
|
||||
gridStep: levelGridStep,
|
||||
pointCount: 0,
|
||||
pointRatio: 0,
|
||||
gridStep,
|
||||
maxEdgeLength,
|
||||
clouds: rolesForThisRun.map((role) => ({ role, spatialStep, pointCount: 0, pointRatio: 0, binFile: null, imageFile: null })),
|
||||
volume: null,
|
||||
surface: null,
|
||||
addedVolume: null,
|
||||
|
|
@ -171,8 +264,6 @@ export class PipelineService {
|
|||
warn: true,
|
||||
status: 'error',
|
||||
message: err.message,
|
||||
binFile: null,
|
||||
imageFile: null,
|
||||
computeTimeMs: 0,
|
||||
});
|
||||
stopped = true;
|
||||
|
|
@ -180,15 +271,21 @@ export class PipelineService {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------- 4) Assemblage ----------
|
||||
// ---------- Assemblage ----------
|
||||
onProgress('Assemblage des nuages exploitables dans un seul fichier .bin...');
|
||||
const okLevels = levels.filter((l) => l.status === 'ok' && l.binFile);
|
||||
const okBinFiles: string[] = [];
|
||||
for (const lvl of levels) {
|
||||
if (lvl.status !== 'ok') continue;
|
||||
for (const c of lvl.clouds) {
|
||||
if (c.binFile) okBinFiles.push(c.binFile);
|
||||
}
|
||||
}
|
||||
let mergedBinFile: string | null = null;
|
||||
if (okLevels.length > 0) {
|
||||
const baseName = sanitizeBaseName(originalFilename);
|
||||
if (okBinFiles.length > 0) {
|
||||
const baseName = sanitizeBaseName(inputs.primaryFilename);
|
||||
const mergedName = `${baseName}_all_levels.bin`;
|
||||
const args: string[] = [];
|
||||
for (const lvl of okLevels) args.push('-O', lvl.binFile!);
|
||||
for (const f of okBinFiles) args.push('-O', f);
|
||||
args.push('-C_EXPORT_FMT', 'BIN', '-SAVE_CLOUDS', 'ALL_AT_ONCE', 'FILE', mergedName);
|
||||
const mergeRes = await this.cc.run(args, workDir);
|
||||
const mergedAbs = path.join(workDir, mergedName);
|
||||
|
|
@ -198,21 +295,26 @@ export class PipelineService {
|
|||
|
||||
const report: RunReport = {
|
||||
runId,
|
||||
originalFilename,
|
||||
mode: params.mode,
|
||||
originalFilename: inputs.primaryFilename,
|
||||
originalFilenameBottom: inputs.secondaryFilename ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
params: {
|
||||
zref,
|
||||
zrefSource: params.zrefOverride !== undefined ? 'override' : 'auto',
|
||||
zrefSource,
|
||||
initialStep,
|
||||
initialStepSource: params.initialStepOverride !== undefined ? 'override' : 'auto',
|
||||
gridStepMultiplier: config.gridStepMultiplier,
|
||||
maxEdgeLengthMultiplier: config.maxEdgeLengthMultiplier,
|
||||
maxEdgeLengthSource,
|
||||
factor: config.decimationFactor,
|
||||
steps: config.decimationSteps,
|
||||
densityMatch,
|
||||
sampleStats: {
|
||||
sampleCount: stats.count,
|
||||
meanNn: stats.mean_nn,
|
||||
medianNn: stats.median_nn,
|
||||
bboxApprox: stats.bbox,
|
||||
sampleCount: primaryStats.stats.count,
|
||||
meanNn: primaryStats.stats.mean_nn,
|
||||
medianNn: primaryStats.stats.median_nn,
|
||||
bboxApprox: primaryStats.stats.bbox,
|
||||
},
|
||||
},
|
||||
levels,
|
||||
|
|
@ -220,9 +322,8 @@ export class PipelineService {
|
|||
};
|
||||
|
||||
await fs.writeFile(path.join(workDir, 'report.json'), JSON.stringify(report, null, 2), 'utf-8');
|
||||
await fs.writeFile(path.join(workDir, 'report.csv'), this.toCsv(report), 'utf-8');
|
||||
await fs.writeFile(path.join(workDir, 'report.csv'), reportToCsv(report), 'utf-8');
|
||||
|
||||
// ---------- 5) Graphiques de synthese ----------
|
||||
onProgress('Generation des graphiques de synthese...');
|
||||
try {
|
||||
await this.py.renderSummary(path.join(workDir, 'report.json'), imagesDir);
|
||||
|
|
@ -233,13 +334,41 @@ export class PipelineService {
|
|||
return report;
|
||||
}
|
||||
|
||||
/** Convertit LAS/LAZ/COPC en ASCII (le paquet apt CloudCompare n'a pas le plugin LAS) ; laisse .bin tel quel. */
|
||||
private async normalizeInput(inputPath: string, workDir: string, tag: string, onProgress: (msg: string) => void): Promise<string> {
|
||||
const lower = inputPath.toLowerCase();
|
||||
if (!lower.endsWith('.las') && !lower.endsWith('.laz')) return inputPath;
|
||||
onProgress(`Conversion du nuage (${tag}) LAS/LAZ/COPC en ASCII...`);
|
||||
const xyzPath = path.join(workDir, `input_${tag}.xyz`);
|
||||
await this.py.convertLasToXyz(inputPath, xyzPath);
|
||||
return xyzPath;
|
||||
}
|
||||
|
||||
private async computeStatsFor(normalizedPath: string, workDir: string, sampleFileName: string): Promise<{ totalPointCount: number; stats: CloudStats }> {
|
||||
const res = await this.cc.run(
|
||||
['-O', normalizedPath, '-SS', 'RANDOM', String(config.statsSampleCap), '-C_EXPORT_FMT', 'ASC', '-PREC', '6', '-SAVE_CLOUDS', 'FILE', sampleFileName],
|
||||
workDir,
|
||||
);
|
||||
const totalPointCount = this.cc.parseLoadedPointCount(res.stdout);
|
||||
if (totalPointCount === null) {
|
||||
throw new Error(`Impossible de lire le nuage d'entree. Sortie CloudCompare:\n${res.stdout.slice(-2000)}`);
|
||||
}
|
||||
const samplePath = path.join(workDir, sampleFileName);
|
||||
await this.assertExists(samplePath, 'echantillonnage du nuage', res.stdout);
|
||||
const stats = await this.py.computeStats(samplePath);
|
||||
return { totalPointCount, stats };
|
||||
}
|
||||
|
||||
private relFrom(workDir: string, absPath: string): string {
|
||||
return path.relative(workDir, absPath).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
private skippedLevel(level: number, reason: string): LevelResult {
|
||||
return {
|
||||
level,
|
||||
spatialStep: null,
|
||||
gridStep: null,
|
||||
pointCount: 0,
|
||||
pointRatio: 0,
|
||||
maxEdgeLength: null,
|
||||
clouds: [],
|
||||
volume: null,
|
||||
surface: null,
|
||||
addedVolume: null,
|
||||
|
|
@ -250,8 +379,6 @@ export class PipelineService {
|
|||
warn: true,
|
||||
status: 'skipped',
|
||||
message: reason,
|
||||
binFile: null,
|
||||
imageFile: null,
|
||||
computeTimeMs: 0,
|
||||
};
|
||||
}
|
||||
|
|
@ -264,173 +391,131 @@ export class PipelineService {
|
|||
}
|
||||
}
|
||||
|
||||
private async computeLevel(opts: {
|
||||
/**
|
||||
* Prepare le nuage d'UN role a UN niveau : decimation (ou conversion initiale), export de la
|
||||
* carte de hauteur, puis remplissage des trous (rasterize + interpolation Delaunay bornee) pour
|
||||
* produire la version utilisee par -VOLUME. Le nuage BRUT (non rempli) est conserve pour
|
||||
* chainer la decimation du niveau suivant et pour l'assemblage final.
|
||||
*/
|
||||
private async prepareCloudAtLevel(opts: {
|
||||
level: number;
|
||||
role: CloudRole;
|
||||
spatialStep: number | null;
|
||||
sourceBinOrOriginal: string;
|
||||
sourcePath: string;
|
||||
isFirstConversion: boolean;
|
||||
levelsDir: string;
|
||||
imagesDir: string;
|
||||
rawDir: string;
|
||||
workDir: string;
|
||||
gridStep: number;
|
||||
zref: number;
|
||||
maxEdgeLength: number;
|
||||
refPointCount: number;
|
||||
onProgress: (msg: string) => void;
|
||||
}): Promise<LevelResult> {
|
||||
const { level, spatialStep, workDir, gridStep, zref, refPointCount } = opts;
|
||||
const t0 = Date.now();
|
||||
}): Promise<CloudPrepResult> {
|
||||
const { level, role, spatialStep, workDir, gridStep, maxEdgeLength, refPointCount } = opts;
|
||||
const suffix = role === 'unique' ? '' : `_${role}`;
|
||||
let binRelPath: string;
|
||||
let pointCount: number;
|
||||
|
||||
if (opts.isFirstConversion) {
|
||||
// Niveau 0 : simple conversion du fichier d'entree en .bin canonique
|
||||
binRelPath = path.posix.join('levels', 'L0_full.bin');
|
||||
const res = await this.cc.run(['-O', opts.sourceBinOrOriginal, '-C_EXPORT_FMT', 'BIN', '-SAVE_CLOUDS', 'FILE', binRelPath], workDir);
|
||||
binRelPath = path.posix.join('levels', `L${level}${suffix}_full.bin`);
|
||||
const res = await this.cc.run(['-O', opts.sourcePath, '-C_EXPORT_FMT', 'BIN', '-SAVE_CLOUDS', 'FILE', binRelPath], workDir);
|
||||
const n = this.cc.parseLoadedPointCount(res.stdout);
|
||||
if (n === null) {
|
||||
return this.errorLevel(level, spatialStep, `Conversion niveau 0 echouee. Sortie:\n${res.stdout.slice(-1500)}`, t0, null, gridStep);
|
||||
return this.badResult(level, role, spatialStep, `Conversion niveau ${level}${suffix} echouee. Sortie:\n${res.stdout.slice(-1500)}`, 'error');
|
||||
}
|
||||
pointCount = n;
|
||||
await this.assertExists(path.join(workDir, binRelPath), 'conversion niveau 0', res.stdout);
|
||||
await this.assertExists(path.join(workDir, binRelPath), `conversion niveau ${level}${suffix}`, res.stdout);
|
||||
} else {
|
||||
// Niveaux 1..N : decimation spatiale depuis le niveau precedent
|
||||
const stepStr = spatialStep!.toFixed(6);
|
||||
binRelPath = path.posix.join('levels', `L${level}_step${stepStr}.bin`);
|
||||
binRelPath = path.posix.join('levels', `L${level}${suffix}_step${stepStr}.bin`);
|
||||
const res = await this.cc.run(
|
||||
['-O', opts.sourceBinOrOriginal, '-SS', 'SPATIAL', stepStr, '-C_EXPORT_FMT', 'BIN', '-SAVE_CLOUDS', 'FILE', binRelPath],
|
||||
['-O', opts.sourcePath, '-SS', 'SPATIAL', stepStr, '-C_EXPORT_FMT', 'BIN', '-SAVE_CLOUDS', 'FILE', binRelPath],
|
||||
workDir,
|
||||
);
|
||||
const n = this.cc.parseSubsampleResult(res.stdout);
|
||||
if (n === null || n === 0) {
|
||||
return {
|
||||
return this.badResult(
|
||||
level,
|
||||
role,
|
||||
spatialStep,
|
||||
gridStep: opts.gridStep,
|
||||
pointCount: 0,
|
||||
pointRatio: 0,
|
||||
volume: null,
|
||||
surface: null,
|
||||
addedVolume: null,
|
||||
removedVolume: null,
|
||||
matchingCellsPct: null,
|
||||
groundNonMatchingPct: null,
|
||||
ceilNonMatchingPct: null,
|
||||
warn: true,
|
||||
status: 'skipped',
|
||||
message: `decimation spatiale n'a produit aucun point exploitable (pas=${stepStr}m)`,
|
||||
binFile: null,
|
||||
imageFile: null,
|
||||
computeTimeMs: Date.now() - t0,
|
||||
};
|
||||
`decimation spatiale (${role}) n'a produit aucun point exploitable (pas=${stepStr}m)`,
|
||||
'skipped',
|
||||
);
|
||||
}
|
||||
pointCount = n;
|
||||
await this.assertExists(path.join(workDir, binRelPath), `decimation niveau ${level}`, res.stdout);
|
||||
await this.assertExists(path.join(workDir, binRelPath), `decimation niveau ${level}${suffix}`, res.stdout);
|
||||
}
|
||||
|
||||
// Volume (necessite AUTO_SAVE ON, sinon CloudCompare ne genere pas le rapport texte - voir cc-runner.service.ts)
|
||||
const beforeVolume = Date.now();
|
||||
const volRes = await this.cc.run(
|
||||
['-O', binRelPath, '-VOLUME', '-GRID_STEP', String(gridStep), '-CONST_HEIGHT', String(zref)],
|
||||
workDir,
|
||||
{ autoSave: true },
|
||||
);
|
||||
const reportFile = await this.cc.findLatestFile(workDir, { prefix: 'VolumeCalculationReport', suffix: '.txt', after: beforeVolume });
|
||||
if (!reportFile) {
|
||||
return this.errorLevel(level, spatialStep, `Rapport de volume introuvable au niveau ${level}. Sortie:\n${volRes.stdout.slice(-1500)}`, t0, binRelPath, gridStep);
|
||||
}
|
||||
const vol = await this.cc.parseVolumeReportFile(reportFile);
|
||||
const rawReportDest = path.join(opts.rawDir, `L${level}_volume_report.txt`);
|
||||
await fs.rename(reportFile, rawReportDest);
|
||||
const rawBinAbs = path.join(workDir, binRelPath);
|
||||
|
||||
// AUTO_SAVE ON sauvegarde aussi une grille "*_HEIGHT_DIFFERENCE_*.bin" qu'on ne veut pas garder
|
||||
const strayGrid = await this.cc.findLatestFile(workDir, { suffix: '.bin', after: beforeVolume });
|
||||
if (strayGrid && strayGrid !== path.join(workDir, binRelPath)) {
|
||||
await fs.unlink(strayGrid).catch(() => undefined);
|
||||
}
|
||||
|
||||
// Carte de hauteur : export ASCII du nuage + binning numpy cote Python.
|
||||
// (Le paquet apt CloudCompare, utilise dans l'image Docker, plante sur l'export GeoTIFF natif
|
||||
// -RASTERIZE -OUTPUT_RASTER_Z faute de support GDAL compile - on reconstruit donc la grille
|
||||
// nous-memes a partir d'un export ASCII, qui lui fonctionne sans plugin.)
|
||||
// Carte de hauteur (a partir du nuage BRUT, pour montrer les vrais trous de scan)
|
||||
let imageRelPath: string | null = null;
|
||||
try {
|
||||
const xyzExportRel = path.posix.join('raw', `L${level}_points.xyz`);
|
||||
const xyzExportRel = path.posix.join('raw', `L${level}${suffix}_points.xyz`);
|
||||
await this.cc.run(['-O', binRelPath, '-C_EXPORT_FMT', 'ASC', '-PREC', '6', '-SAVE_CLOUDS', 'FILE', xyzExportRel], workDir);
|
||||
const xyzAbs = path.join(workDir, xyzExportRel);
|
||||
await this.assertExists(xyzAbs, `export ASCII niveau ${level}`, '');
|
||||
const pngName = `L${level}_heightmap.png`;
|
||||
await this.assertExists(xyzAbs, `export ASCII niveau ${level}${suffix}`, '');
|
||||
const pngName = `L${level}${suffix}_heightmap.png`;
|
||||
const pngAbs = path.join(opts.imagesDir, pngName);
|
||||
const title = spatialStep === null ? `Niveau 0 (complet, ${pointCount} pts)` : `Niveau ${level} (pas=${spatialStep.toFixed(4)}m, ${pointCount} pts)`;
|
||||
const roleLabel = role === 'unique' ? '' : role === 'top' ? ' (haut)' : ' (bas)';
|
||||
const title = spatialStep === null ? `Niveau ${level}${roleLabel} (complet, ${pointCount} pts)` : `Niveau ${level}${roleLabel} (pas=${spatialStep.toFixed(4)}m, ${pointCount} pts)`;
|
||||
await this.py.renderHeightmap(xyzAbs, gridStep, pngAbs, title);
|
||||
imageRelPath = path.posix.join('images', pngName);
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Image niveau ${level} non generee: ${err.message}`);
|
||||
this.logger.warn(`Image niveau ${level}${suffix} non generee: ${err.message}`);
|
||||
}
|
||||
|
||||
const warn = vol.matchingCellsPct < config.matchingCellsWarnThreshold;
|
||||
// Remplissage des trous (INTERP Delaunay, borne par max-edge-length) pour le calcul de volume.
|
||||
// -VOLUME n'a pas cette option nativement (voir CLAUDE.md) : on rasterize+comble+exporte en
|
||||
// nuage AVANT de le passer a -VOLUME.
|
||||
let filledBinAbs: string | null = null;
|
||||
try {
|
||||
const filledRel = path.posix.join('raw', `L${level}${suffix}_filled.bin`);
|
||||
await this.cc.run(
|
||||
['-O', binRelPath, '-RASTERIZE', '-GRID_STEP', String(gridStep), '-EMPTY_FILL', 'INTERP', '-MAX_EDGE_LENGTH', String(maxEdgeLength), '-OUTPUT_CLOUD', '-C_EXPORT_FMT', 'BIN', '-SAVE_CLOUDS', 'FILE', filledRel],
|
||||
workDir,
|
||||
);
|
||||
const filledAbs = path.join(workDir, filledRel);
|
||||
await this.assertExists(filledAbs, `remplissage niveau ${level}${suffix}`, '');
|
||||
filledBinAbs = filledAbs;
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Remplissage niveau ${level}${suffix} echoue, utilisation du nuage brut pour le volume: ${err.message}`);
|
||||
filledBinAbs = rawBinAbs;
|
||||
}
|
||||
|
||||
return {
|
||||
level,
|
||||
spatialStep,
|
||||
gridStep,
|
||||
pointCount,
|
||||
pointRatio: refPointCount > 0 ? pointCount / refPointCount : 0,
|
||||
volume: vol.volume,
|
||||
surface: vol.surface,
|
||||
addedVolume: vol.addedVolume,
|
||||
removedVolume: vol.removedVolume,
|
||||
matchingCellsPct: vol.matchingCellsPct,
|
||||
groundNonMatchingPct: vol.groundNonMatchingPct,
|
||||
ceilNonMatchingPct: vol.ceilNonMatchingPct,
|
||||
warn,
|
||||
status: 'ok',
|
||||
info: {
|
||||
role,
|
||||
spatialStep,
|
||||
pointCount,
|
||||
pointRatio: refPointCount > 0 ? pointCount / refPointCount : level === 0 ? 1 : 0,
|
||||
binFile: binRelPath,
|
||||
imageFile: imageRelPath,
|
||||
computeTimeMs: Date.now() - t0,
|
||||
},
|
||||
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,13 +530,19 @@ export function reportToCsv(report: RunReport): string {
|
|||
'computeTimeMs',
|
||||
'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.spatialStep ?? '',
|
||||
c.role,
|
||||
c.spatialStep ?? '',
|
||||
l.gridStep ?? '',
|
||||
l.pointCount,
|
||||
l.pointRatio,
|
||||
l.maxEdgeLength ?? '',
|
||||
c.pointCount,
|
||||
c.pointRatio,
|
||||
l.volume ?? '',
|
||||
l.surface ?? '',
|
||||
l.addedVolume ?? '',
|
||||
|
|
@ -465,5 +556,7 @@ export function reportToCsv(report: RunReport): string {
|
|||
(l.message ?? '').replace(/[\r\n,]+/g, ' '),
|
||||
].join(','),
|
||||
);
|
||||
}
|
||||
}
|
||||
return [headers.join(','), ...rows].join('\n') + '\n';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,14 +29,7 @@ function isAllowedFilename(name: string): boolean {
|
|||
return ALLOWED_EXT.some((ext) => lower.endsWith(ext));
|
||||
}
|
||||
|
||||
@Controller('api/runs')
|
||||
export class RunsController {
|
||||
constructor(private readonly runs: RunsService) {}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: diskStorage({
|
||||
const uploadStorage = diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
const dir = path.join(uploadsDir(), 'incoming');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
|
@ -44,7 +38,22 @@ export class RunsController {
|
|||
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(
|
||||
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)) {
|
||||
|
|
@ -53,18 +62,39 @@ export class RunsController {
|
|||
}
|
||||
cb(null, true);
|
||||
},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
async create(@UploadedFile() file: Express.Multer.File, @Body() body: Record<string, string>) {
|
||||
if (!file) throw new BadRequestException('Aucun fichier recu (champ "file" attendu)');
|
||||
|
||||
async create(
|
||||
@UploadedFiles() files: { file?: Express.Multer.File[]; fileTop?: Express.Multer.File[]; fileBottom?: Express.Multer.File[] },
|
||||
@Body() body: Record<string, string>,
|
||||
) {
|
||||
const mode: RunMode = body.mode === 'cloud_compare' ? 'cloud_compare' : 'const_height';
|
||||
const zrefOverride = this.parseOptionalNumber(body.zrefOverride);
|
||||
const initialStepOverride = this.parseOptionalNumber(body.initialStepOverride);
|
||||
const maxEdgeLengthOverride = this.parseOptionalNumber(body.maxEdgeLengthOverride);
|
||||
|
||||
if (mode === 'const_height') {
|
||||
const file = files.file?.[0];
|
||||
if (!file) throw new BadRequestException('Aucun fichier recu (champ "file" attendu pour le mode plan Z constant)');
|
||||
return this.runs.createRun({
|
||||
tmpFilePath: file.path,
|
||||
originalFilename: file.originalname,
|
||||
params: { 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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue