Implementation initiale : calcul de volume 2.5D CloudCompare et test de robustesse par decimation spatiale progressive

- Backend NestJS orchestrant CloudCompare CLI (stats, decimation x2 sur 5 etapes, volume, assemblage .bin)
- Scripts Python (laspy, scipy, matplotlib) pour la conversion LAS/LAZ et la generation d'images
- Frontend web simple (Tailwind CDN) avec historique des runs
- Image Docker (debian:trixie-slim + cloudcompare apt + xvfb) prete pour deploiement Coolify
- Lint/typecheck/tests unitaires integres comme portes de qualite au build Docker
- CLAUDE.md documentant les comportements CloudCompare CLI verifies empiriquement
This commit is contained in:
Nicolas Fryder 2026-07-10 13:55:25 +02:00
commit 79daa30a8a
36 changed files with 12329 additions and 0 deletions

10
.dockerignore Normal file
View file

@ -0,0 +1,10 @@
node_modules
dist
data
*.log
.git
.gitignore
.claude
coverage
README.md
CLAUDE.md

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
* text=auto eol=lf
*.sh text eol=lf

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules/
dist/
data/
*.log
.DS_Store
coverage/
.claude/

170
CLAUDE.md Normal file
View file

@ -0,0 +1,170 @@
# CLAUDE.md
Notes pour toute future session travaillant sur ce repo. Ce fichier documente des faits
verifies empiriquement sur CloudCompare 2.13.2 (Windows officiel ET paquet apt Debian trixie) —
pas de la documentation officielle recopiee, mais des comportements observes directement, souvent
non documentes ou contredisant la doc/le wiki.
## Contexte du projet
Pipeline : nuage de points (LAS/LAZ/COPC.laz/BIN) -> calcul de volume 2.5D CloudCompare -> decimation
spatiale progressive x2 sur 5 etapes (chainee, chaque niveau decime depuis le precedent, pas depuis
l'original) -> volume recalcule a chaque niveau -> assemblage des 6 nuages dans un seul `.bin` ->
rapport CSV/JSON + images. UI web (NestJS + Tailwind CDN) avec historique des runs (SQLite).
E57 volontairement hors perimetre.
## CloudCompare CLI — comportements verifies
### `-VOLUME` (calcul de volume 2.5D)
- Sur un nuage **unique**, utiliser `-CONST_HEIGHT <valeur>` comme plan de reference (le nuage charge
est le "ceiling", le plan constant est le "ground"). Sans `-CONST_HEIGHT`, la commande attend DEUX
nuages charges.
- Syntaxe complete : `-VOLUME -GRID_STEP <val> [-VERT_DIR 0/1/2] [-CONST_HEIGHT <val>] [-GROUND_IS_FIRST] [-OUTPUT_MESH]`.
- **Piege majeur non documente** : le rapport texte `VolumeCalculationReport_<date>.txt` n'est genere
QUE si `-AUTO_SAVE ON` (le defaut). Avec `-AUTO_SAVE OFF` (qu'on utilise partout ailleurs pour
eviter de polluer le dossier de travail avec des fichiers intermediaires), la commande `-VOLUME`
s'execute (log "[2.5D VOLUME CALCULATION] finished") mais **aucun rapport n'est ecrit, aucune
erreur n'est loggee**. Il faut explicitement repasser `-AUTO_SAVE ON` juste pour l'appel `-VOLUME`.
Voir `CcRunnerService.run(args, cwd, { autoSave: true })`.
- Avec `-AUTO_SAVE ON`, `-VOLUME` sauvegarde AUSSI automatiquement une grille de difference de hauteur
nommee `<nom>_HEIGHT_DIFFERENCE_<date>.bin` qu'on ne veut pas garder — on la detecte (meme technique
que pour le rapport, cf ci-dessous) et on la supprime juste apres (voir pipeline.service.ts).
- **CloudCompare ecrit ses fichiers de sortie automatiques (rapport, grilles) relativement au dossier
du fichier charge via `-O`, pas au `cwd` du process.** Si on charge `-O levels/L0_full.bin`, le
rapport atterrit dans `levels/VolumeCalculationReport_*.txt`, pas a la racine du `cwd`. D'ou la
necessite d'une recherche **recursive** (`CcRunnerService.findLatestFile`) plutot qu'un simple
`readdir` du dossier de travail.
- Format du rapport texte (verifie, stable) — cle:valeur, une ligne par info, parsable par regex :
```
Volume: 7.834176
Surface: 100.774998
----------------------
Added volume: (+)7.975798
Removed volume: (-)0.141622
----------------------
Matching cells: 99.8%
Non-matching cells:
ground = 0.2%
ceil = 0.0%
Average neighbors per cell: 8.0 / 8.0
```
- **`Matching cells: X%`** est le meilleur signal natif de robustesse : c'est le % de cellules de la
grille de calcul ou les deux surfaces sont effectivement comparables. Il s'effondre des que la
decimation produit un nuage plus clairseme que la resolution de grille utilisee pour le calcul de
volume — exactement l'indicateur cherche pour ce projet (voir `matchingCellsWarnThreshold` dans
`config.ts`, defaut 90%).
- Le `-GRID_STEP` du calcul de volume doit rester **fixe** (le meme pour les 6 niveaux) pour que les
volumes restent comparables entre eux — sinon on mele l'effet de la decimation avec l'effet d'un
changement de resolution de calcul. On le derive une seule fois du pas spatial initial (voir plus
bas) et on le reutilise identique partout.
### `-SS SPATIAL` / `-SS RANDOM` (sous-echantillonnage)
- `-SS SPATIAL <distance>` : distance minimale entre points, PAS un ratio de points cible. Le nombre
de points obtenu depend de la densite reelle du nuage — jamais garanti a un pourcentage exact.
- `-SS RANDOM <n>` : sous-echantillonne a `n` points. Si `n` >= nombre de points du nuage, ne plante
pas (comportement observe : garde le nuage tel quel).
- Log de sortie a parser : `[SUBSAMPLE]` puis `Result: <N> points` (regex utilisee :
`/\[SUBSAMPLE\][\s\S]*?Result:\s*(\d+)\s*points/`).
- `-AUTO_SAVE OFF` fonctionne correctement ici (contrairement a `-VOLUME`) : pas de fichier auto-sauve
parasite, seul `-SAVE_CLOUDS FILE "nom.bin"` explicite produit un fichier, avec le nom voulu.
### `-SAVE_CLOUDS`
- `-SAVE_CLOUDS FILE "nom.ext"` : nom de sortie explicite (fonctionne, contrairement a ce que
certains threads du forum CloudCompare laissent penser).
- `-SAVE_CLOUDS ALL_AT_ONCE FILE "nom.bin"` avec **plusieurs nuages charges** (plusieurs `-O`) sans
`-MERGE_CLOUDS` : sauvegarde tous les nuages comme entites **distinctes** dans un seul `.bin` (le
format `.bin` de CloudCompare supporte nativement une hierarchie de plusieurs nuages). Verifie en
rouvrant le fichier : chaque nuage retrouve son point count exact, sans fusion de geometrie. C'est
la technique utilisee pour assembler les 6 niveaux de decimation dans un seul fichier de sortie.
**`-MERGE_CLOUDS` fusionnerait la geometrie en un seul nuage — a ne PAS utiliser ici.**
- Pas de commande `-RENAME_CLOUDS` (n'existe pas, testee = "Unknown or misplaced command"). Le nom de
chaque nuage dans le `.bin` fusionne est derive du nom de fichier source charge — on nomme donc les
fichiers intermediaires de facon descriptive (`L0_full.bin`, `L1_step0.05.bin`, ...) plutot que de
chercher a renommer les entites apres coup.
### Autres commandes
- `-RASTERIZE -GRID_STEP <val> -OUTPUT_RASTER_Z` : export GeoTIFF. **Fonctionne sur le build Windows
officiel, mais PLANTE (assertion `false` dans `ccRasterizeTool.cpp:ExportGeoTiff`, core dump) sur le
paquet apt Debian trixie**, faute de support GDAL compile dans ce paquet. Ne pas utiliser cette
voie dans l'image Docker — voir section "Paquet apt Debian" ci-dessous pour la solution retenue.
- Les commandes s'appliquent comme une **machine a etats sequentielle sur UN SEUL process** : on ne
peut pas "reprendre" une session precedente. Chaque etape logique du pipeline (stats, conversion,
decimation, volume, export) = un appel CLI independant, avec son propre `-O` de rechargement.
- `-SILENT` doit etre le tout premier argument (ou juste apres `-VERBOSITY`).
- `-PREC <n>` controle la precision decimale des exports ASCII (`-C_EXPORT_FMT ASC`).
## 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
les sources (des heures de build, fragile). Debian trixie est la seule distro testee avec une version
recente (2.13.2) directement en apt `main` (Ubuntu jammy/noble n'ont que 2.11.3 en `universe`).
Mais ce paquet est **allege par rapport au build officiel Windows** :
1. **Pas de plugin LAS/LAZ** (`dpkg -L cloudcompare` ne montre que
`libQCORE_IO_PLUGIN.so`, aucun `QLAS_IO_PLUGIN`). Ouvrir un `.las`/`.laz` donne :
`[Load] Can't guess file format: unhandled file extension 'las'`.
**Solution retenue** : conversion LAS/LAZ/COPC -> ASCII XYZ via `laspy` (Python, `python/las_to_xyz.py`)
*avant* tout traitement CloudCompare. L'import ASCII XYZ, lui, fonctionne nativement sans aucun
plugin (verifie : `CloudCompare -O fichier.xyz` marche directement sur ce paquet). Un `.copc.laz`
est lu par laspy comme un LAZ standard (l'indexation octree COPC est ignoree, seuls les points
comptent ici).
2. **Export GeoTIFF cassé** (voir `-RASTERIZE` ci-dessus). **Solution retenue** : les cartes de hauteur
sont generees nous-memes (`python/render_images.py heightmap`) via export ASCII du nuage +
binning `scipy.stats.binned_statistic_2d` + rendu `matplotlib`, sans passer par le rasterizer
CloudCompare.
3. **CloudCompare reste une appli Qt, meme en `-SILENT`** : plante avec `QXcbConnection: Could not
connect to display` sans serveur X. Necessite `xvfb`. On demarre UN Xvfb persistant dans
`docker/entrypoint.sh` (pas un `xvfb-run` par appel CLI, plus rapide et evite les conflits de
lockfile entre appels concurrents).
4. Si un futur besoin necessite E57 : le meme probleme de plugin manquant se posera probablement
(a verifier — `QE57_IO_PLUGIN` n'apparaissait pas non plus dans `dpkg -L cloudcompare`).
**Si CloudCompare est mis a jour dans une future version du paquet Debian et regagne GDAL/LAS**, ces
contournements resteront fonctionnels sans rien casser (ils n'utilisent jamais les fonctionnalites
manquantes), mais pourraient etre simplifies.
## Pieges d'implementation generaux (au-dela de CloudCompare)
- **Chemins `/c/...` (style MSYS/git-bash) vs chemins Windows natifs** : un `python.exe` natif Windows
ne comprend PAS `/c/Users/...` — seulement `C:/Users/...` ou `C:\Users\...`. `curl`/`cat`/`ls`
(MSYS) acceptent les deux, mais un process Windows natif lance depuis bash avec un chemin `/c/...`
en argument echouera silencieusement (`FileNotFoundError`) si ce chemin est passe tel quel a une
fonction Python (`open(...)`) plutot que d'etre resolu par le shell. Toujours utiliser des chemins
style `C:/...` quand on passe un chemin en argument a un outil natif Windows depuis bash.
- **TypeScript + closures + `let` mutable** : `tsc` peut perdre le narrowing d'une variable `let`
mutee dans une closure asynchrone appelee avant un `return` conditionnel (erreur `Property 'x' does
not exist on type 'never'`). Contournement : accumuler dans un tableau (`push`) plutot que de muter
une variable `best` capturee, puis trier/reduire a la fin.
- **Nest CLI + fichiers de test** : `nest build` doit utiliser `tsconfig.build.json` (qui exclut
`**/*.spec.ts`) pour ne pas emettre les tests dans `dist/`. Le `tsconfig.json` de base (sans
exclude) reste utilise par `tsc --noEmit` (script `typecheck`) et par `ts-jest`, pour que les tests
soient bien type-checkes.
- **`better-sqlite3`** necessite des outils de build natifs (python3/make/g++) — presents par defaut
dans l'image `node:20-bookworm` utilisee pour le stage de build, absents volontairement du stage
final (juste `node_modules` deja compile est copie).
## Deploiement (Coolify)
- Image construite en deux etapes : build (Node, avec lint+typecheck+test+compilation) puis runtime
(Debian + CloudCompare + xvfb + Python). Le build Docker **echoue** si lint, typecheck ou tests
echouent (`RUN npm run lint|typecheck|test` avant `RUN npm run build` dans le Dockerfile) — sert de
garde-fou avant tout deploiement automatique via webhook.
- `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.
- Le port d'ecoute est configurable via `PORT` (`config.ts` lit `process.env.PORT`, defaut 3000).
## Constantes du pipeline (voir `src/config.ts`)
- `decimationFactor = 2`, `decimationSteps = 5` (fixes par la spec, pas exposes en config utilisateur).
- `gridStepMultiplier = 2` : le grid step de `-VOLUME` = pas spatial initial x2.
- `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.
- `matchingCellsWarnThreshold = 90` : seuil d'affichage "a verifier" dans l'UI.

61
Dockerfile Normal file
View file

@ -0,0 +1,61 @@
# syntax=docker/dockerfile:1
# ---------- Etape 1 : build du backend NestJS ----------
FROM node:20-bookworm AS backend-build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY tsconfig.json tsconfig.build.json nest-cli.json eslint.config.mjs ./
COPY src ./src
# Portes de qualite : le build Docker (donc le deploiement Coolify) echoue si l'une de ces
# etapes echoue. Voir CLAUDE.md pour le detail des scripts.
RUN npm run lint
RUN npm run typecheck
RUN npm run test
RUN npm run build
RUN npm prune --omit=dev
# ---------- Etape 2 : image finale ----------
# Debian trixie fournit CloudCompare 2.13.2 directement en apt (paquet "cloudcompare"),
# ce qui evite une compilation depuis les sources (tres longue et fragile).
FROM debian:trixie-slim
ENV DEBIAN_FRONTEND=noninteractive \
NODE_ENV=production \
DATA_DIR=/data \
CLOUDCOMPARE_BIN=CloudCompare \
PYTHON_BIN=python3 \
DISPLAY=:99
RUN apt-get update && apt-get install -y --no-install-recommends \
cloudcompare \
xvfb \
x11-utils \
ca-certificates \
curl \
gnupg \
python3 \
python3-pip \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
COPY python/requirements.txt /app/python/requirements.txt
RUN pip3 install --break-system-packages --no-cache-dir -r /app/python/requirements.txt
WORKDIR /app
COPY --from=backend-build /app/node_modules ./node_modules
COPY --from=backend-build /app/dist ./dist
COPY package.json ./
COPY python ./python
COPY public ./public
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh && mkdir -p /data
VOLUME ["/data"]
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS "http://localhost:${PORT:-3000}/" || exit 1
ENTRYPOINT ["/entrypoint.sh"]

132
README.md Normal file
View file

@ -0,0 +1,132 @@
# Volume 2.5D & robustesse a la decimation
Application locale (Docker) qui, a partir d'un nuage 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 la **meme** grille et le **meme** plan de reference, pour rendre
les 6 mesures comparables ;
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) ;
6. expose tout ca dans une UI web simple avec historique des runs.
## Demarrage
```bash
docker compose up -d --build
```
Puis ouvrir http://localhost:8080
Les donnees (uploads, resultats, base sqlite) sont persistees dans un volume Docker nomme `app-data`
(pas un bind mount vers `./data`, voir "Deploiement Coolify" ci-dessous pour la raison).
## Deploiement Coolify
Le repo est pret pour un deploiement Coolify via webhook (push sur `main` -> build + deploy auto) :
- Le `Dockerfile` fait tourner **lint + typecheck + tests unitaires** avant de compiler — un commit qui
casse l'un de ces trois echoue le build, donc n'est jamais deploye.
- `HEALTHCHECK` integre au Dockerfile (`curl` sur `/`) : Coolify l'utilise pour verifier que le
deploiement est reellement sain.
- Persistance : le volume `/data` doit etre monte comme un **volume nomme/gere par Coolify**, pas un
bind mount vers le checkout git (Coolify re-clone le repo a chaque deploiement — un bind mount
relatif au checkout perdrait toutes les donnees a chaque redeploy). Deux options :
- Resource **"Docker Compose"** dans Coolify pointant sur `docker-compose.yml` du repo : le volume
nomme `app-data` declare dedans est deja correct.
- Resource **"Application"** (Dockerfile) : configurer un volume persistant sur `/data` depuis
l'onglet "Storage" de Coolify, et le port expose sur `3000` (variable `PORT`, lue par l'appli).
- Variables d'environnement optionnelles a definir dans Coolify si besoin de les changer par rapport
aux defauts (voir tableau plus bas) : `MAX_UPLOAD_MB`, `STATS_SAMPLE_CAP`, `CC_TIMEOUT_MS`.
- Le webhook de deploiement automatique est deja configure cote Coolify sur ce repo.
## 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).
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`).
### 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.
## Parametres (variables d'environnement, voir `docker-compose.yml`)
| Variable | Defaut | Description |
|---|---|---|
| `PORT` | 3000 | port interne du serveur |
| `DATA_DIR` | `/data` | dossier de persistance (uploads, resultats, sqlite) |
| `MAX_UPLOAD_MB` | 2048 | taille max d'un nuage uploade |
| `STATS_SAMPLE_CAP` | 50000 | nb de points echantillonnes pour estimer le pas spatial initial |
| `CC_TIMEOUT_MS` | 600000 | timeout par appel CloudCompare CLI |
## Formats supportes
`.las`, `.laz`, `.copc.laz` (lu comme un LAZ standard — l'indexation octree COPC n'est pas utilisee,
seuls les points le sont), `.bin` (format natif CloudCompare).
**E57 non supporte pour l'instant** (exclu volontairement du perimetre initial).
## Architecture
- **Backend** : NestJS (TypeScript), file d'attente de jobs en memoire (un run a la fois), SQLite
(`better-sqlite3`) pour l'historique des runs.
- **Orchestration du pipeline CloudCompare** : chaque etape (estimation stats, conversion, decimation,
volume, export) est un appel independant au CLI `CloudCompare -SILENT ...` (le CLI fonctionne comme
une machine a etats sur UNE session de process, donc chaque etape = un process).
- **Scripts Python** (`python/`) : `stats_helper.py` (distance au plus proche voisin via scipy KD-tree),
`render_images.py` (cartes de hauteur + graphiques de synthese via matplotlib/numpy),
`las_to_xyz.py` (conversion LAS/LAZ/COPC -> ASCII, voir "Limitation connue" ci-dessous).
- **Frontend** : HTML/JS vanilla + Tailwind (CDN), servi en statique par NestJS. Pas de framework, pas
de build front separe.
- **Image Docker** : `debian:trixie-slim` + paquet apt `cloudcompare` (2.13.2, evite une compilation
depuis les sources) + `xvfb` (CloudCompare reste une appli Qt, meme en `-SILENT` elle a besoin d'un
display) + Node 20 + Python 3.
## Limitations connues
- **Le paquet apt `cloudcompare` (Debian) ne fournit pas le plugin LAS/LAZ** (uniquement le plugin
"Core I/O"). Consequence : les fichiers LAS/LAZ/COPC sont convertis en ASCII XYZ via `laspy` (Python)
*avant* d'etre transmis a CloudCompare, qui lit l'ASCII nativement sans plugin. Ca fonctionne bien
mais perd les attributs LAS annexes (intensite, classification, RGB...) — non utilises par ce calcul
de volume de toute facon.
- **Le meme paquet plante (assertion GDAL) sur l'export GeoTIFF natif** (`-RASTERIZE
-OUTPUT_RASTER_Z`). Les cartes de hauteur sont donc reconstruites nous-memes (export ASCII du nuage +
binning numpy/scipy + rendu matplotlib) plutot que de dependre du rasterizer CloudCompare.
- Le plan de reference Z et le pas de grille de volume sont **approximatifs** quand ils sont
auto-detectes (calcules sur un echantillon aleatoire de points, pas la totalite du nuage) — largement
suffisant pour comparer les 6 niveaux entre eux, mais a corriger via l'override si une valeur exacte
est necessaire.
- Un seul run traite a la fois (file d'attente sequentielle) : adapte a un usage local mono-utilisateur,
pas concu pour un usage concurrent multi-utilisateurs.
## Developpement local (sans Docker)
Necessite CloudCompare installe localement + Python 3 avec `pip install -r python/requirements.txt`.
```bash
npm install
npm run lint # eslint
npm run typecheck # tsc --noEmit
npm run test # jest (tests unitaires)
npm run build
CLOUDCOMPARE_BIN="/chemin/vers/CloudCompare" PYTHON_BIN=python3 DATA_DIR=./data node dist/main.js
```
Voir [CLAUDE.md](CLAUDE.md) pour le detail des comportements CloudCompare CLI verifies empiriquement
(pieges non documentes, limitations du paquet apt Debian, etc.) — a lire avant de toucher au pipeline.

19
docker-compose.yml Normal file
View file

@ -0,0 +1,19 @@
services:
volume-app:
build: .
ports:
- "8080:3000"
volumes:
# Volume nomme (pas un bind mount vers ./data) : Coolify re-clone le repo a chaque
# deploiement, un bind mount relatif au checkout perdrait les donnees a chaque redeploy.
- app-data:/data
environment:
- PORT=3000
- DATA_DIR=/data
- MAX_UPLOAD_MB=2048
- STATS_SAMPLE_CAP=50000
- CC_TIMEOUT_MS=600000
restart: unless-stopped
volumes:
app-data:

23
docker/entrypoint.sh Normal file
View file

@ -0,0 +1,23 @@
#!/bin/sh
set -e
# CloudCompare reste une application Qt : meme en -SILENT elle a besoin d'un display.
# On demarre un Xvfb persistant pour tout le cycle de vie du conteneur plutot que
# d'en relancer un par appel CLI (plus rapide, evite les conflits de lockfile).
Xvfb "${DISPLAY:-:99}" -screen 0 1280x1024x24 -nolisten tcp &
XVFB_PID=$!
cleanup() {
kill "$XVFB_PID" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
# Attente que le display soit pret
for i in $(seq 1 20); do
if xdpyinfo -display "${DISPLAY:-:99}" >/dev/null 2>&1; then
break
fi
sleep 0.25
done
exec node dist/main.js

15
eslint.config.mjs Normal file
View file

@ -0,0 +1,15 @@
// @ts-check
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist/**', 'node_modules/**', 'public/**'] },
eslint.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
},
},
);

9
nest-cli.json Normal file
View file

@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"assets": []
}
}

9826
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

52
package.json Normal file
View file

@ -0,0 +1,52 @@
{
"name": "volume-decimation-app",
"version": "1.0.0",
"description": "Calcul de volume 2.5D CloudCompare + test de robustesse par decimation spatiale progressive",
"private": true,
"scripts": {
"build": "nest build",
"start": "node dist/main.js",
"start:dev": "nest start --watch",
"format": "prettier --write \"src/**/*.ts\"",
"lint": "eslint \"src/**/*.ts\"",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "jest"
},
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/serve-static": "^4.0.2",
"archiver": "^7.0.1",
"better-sqlite3": "^11.8.1",
"multer": "^1.4.5-lts.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"uuid": "^11.0.5"
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^10.4.9",
"@types/archiver": "^6.0.3",
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.7",
"@types/uuid": "^10.0.0",
"eslint": "^9.18.0",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"testEnvironment": "node"
}
}

238
public/app.js Normal file
View file

@ -0,0 +1,238 @@
const API = '/api/runs';
const app = document.getElementById('app');
let pollTimer = null;
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
async function api(path, opts) {
const res = await fetch(path, opts);
if (!res.ok) {
let msg = res.statusText;
try { const j = await res.json(); msg = j.message || j.error || msg; } catch {}
throw new Error(msg);
}
return res.json();
}
function fmt(n, digits = 4) {
if (n === null || n === undefined) return '-';
if (typeof n !== 'number') return String(n);
return n.toLocaleString('fr-FR', { maximumFractionDigits: digits });
}
function statusBadge(status) {
const map = {
pending: 'bg-slate-700 text-slate-200',
running: 'bg-blue-600/30 text-blue-300',
done: 'bg-emerald-600/30 text-emerald-300',
error: 'bg-red-600/30 text-red-300',
};
return `<span class="badge ${map[status] || 'bg-slate-700'}">${status}</span>`;
}
function router() {
const hash = location.hash || '#/';
stopPolling();
if (hash === '#/' || hash === '') return renderList();
if (hash === '#/new') return renderNew();
const m = hash.match(/^#\/runs\/([^/]+)$/);
if (m) return renderDetail(m[1]);
app.innerHTML = '<p class="text-slate-400">Page introuvable.</p>';
}
window.addEventListener('hashchange', router);
window.addEventListener('DOMContentLoaded', router);
// ---------------- Liste des runs ----------------
async function renderList() {
app.innerHTML = '<p class="text-slate-400">Chargement...</p>';
const runs = await api(API);
if (runs.length === 0) {
app.innerHTML = `
<div class="text-center py-20">
<p class="text-slate-400 mb-4">Aucun run pour le moment.</p>
<a href="#/new" class="inline-block bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded-lg font-medium">Lancer un nouveau run</a>
</div>`;
return;
}
const rows = runs.map((r) => `
<tr class="border-b border-slate-800 hover:bg-slate-900/50 cursor-pointer" onclick="location.hash='#/runs/${r.id}'">
<td class="py-2 px-3 font-mono text-xs text-slate-400">${r.id.slice(0, 8)}</td>
<td class="py-2 px-3">${escapeHtml(r.originalFilename)}</td>
<td class="py-2 px-3">${statusBadge(r.status)}</td>
<td class="py-2 px-3 text-sm text-slate-400">${new Date(r.createdAt).toLocaleString('fr-FR')}</td>
<td class="py-2 px-3 text-sm">${r.summary ? `${r.summary.levelsOk}/${r.summary.levelsTotal} niveaux` : '-'}</td>
<td class="py-2 px-3 text-sm">${r.summary && r.summary.volumeDeltaPct !== null ? fmt(r.summary.volumeDeltaPct, 2) + ' %' : '-'}</td>
</tr>`).join('');
app.innerHTML = `
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-semibold">Runs</h1>
<a href="#/new" class="bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded-lg font-medium text-sm">+ Nouveau run</a>
</div>
<div class="overflow-x-auto border border-slate-800 rounded-lg">
<table class="w-full text-left">
<thead class="bg-slate-900 text-slate-400 text-xs uppercase">
<tr>
<th class="py-2 px-3">ID</th><th class="py-2 px-3">Fichier</th><th class="py-2 px-3">Statut</th>
<th class="py-2 px-3">Cree le</th><th class="py-2 px-3">Niveaux OK</th><th class="py-2 px-3">Ecart volume (L0 -> dernier)</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
// ---------------- Nouveau run ----------------
function renderNew() {
app.innerHTML = `
<h1 class="text-xl font-semibold mb-6">Nouveau run</h1>
<form id="upload-form" class="space-y-5 max-w-xl">
<div>
<label class="block text-sm font-medium mb-1">Nuage de points (.las, .laz, .copc.laz, .bin)</label>
<input required type="file" name="file" accept=".las,.laz,.bin"
class="block w-full text-sm text-slate-300 border border-slate-700 rounded-lg cursor-pointer bg-slate-900 focus:outline-none p-2" />
</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>
<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>
<div>
<label class="block text-sm mb-1">Pas spatial initial en metres (override)</label>
<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>
</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) => {
e.preventDefault();
const form = e.target;
const fd = new FormData(form);
const errEl = document.getElementById('upload-error');
errEl.classList.add('hidden');
const btn = form.querySelector('button[type=submit]');
btn.disabled = true;
btn.textContent = 'Envoi...';
try {
const res = await fetch(API, { method: 'POST', body: fd });
const json = await res.json();
if (!res.ok) throw new Error(json.message || 'Echec de l\'envoi');
location.hash = `#/runs/${json.id}`;
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Lancer le run';
}
});
}
// ---------------- Detail d'un run ----------------
async function renderDetail(id) {
app.innerHTML = '<p class="text-slate-400">Chargement...</p>';
await loadDetail(id);
pollTimer = setInterval(async () => {
const run = await api(`${API}/${id}`);
if (run.status === 'pending' || run.status === 'running') {
paintDetail(run);
} else {
stopPolling();
paintDetail(run);
}
}, 2000);
}
async function loadDetail(id) {
const run = await api(`${API}/${id}`);
paintDetail(run);
}
function levelRow(l, id) {
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">${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.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>
<td class="py-2 px-3">${statusBadge(l.status)} ${l.warn && l.status === 'ok' ? '<span class="badge bg-amber-600/30 text-amber-300 ml-1">a verifier</span>' : ''}${statusTxt}</td>
</tr>`;
}
function paintDetail(run) {
const r = run.report;
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">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">Matching cells</th><th class="py-2 px-3">Statut</th>
</tr>
</thead>
<tbody>${r.levels.map((l) => levelRow(l, run.id)).join('')}</tbody>
</table>
</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>
<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>${fmt(r.params.gridStep, 5)} m</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>` : '';
const heightmaps = r ? r.levels.filter((l) => l.imageFile).map((l) => `
<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>
</div>`).join('') : '';
const summaryCharts = run.status === 'done' ? ['volume_vs_level.png', 'points_vs_level.png', 'robustness_vs_level.png'].map((f) => `
<div class="bg-slate-900 border border-slate-800 rounded-lg p-2">
<img src="${API}/${run.id}/images/${f}" class="w-full rounded" loading="lazy" onerror="this.parentElement.style.display='none'" />
</div>`).join('') : '';
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/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>` : '';
const errorBlock = run.error ? `<div class="bg-red-950/40 border border-red-900 rounded-lg p-3 text-sm text-red-300 mt-4">${escapeHtml(run.error)}</div>` : '';
app.innerHTML = `
<div class="flex items-center justify-between mb-2">
<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>
${errorBlock}
${params}
${levelsTable}
${downloads}
${heightmaps ? `<h2 class="text-lg font-semibold mt-8 mb-3">Cartes de hauteur par niveau</h2><div class="grid grid-cols-2 md:grid-cols-3 gap-3">${heightmaps}</div>` : ''}
${summaryCharts ? `<h2 class="text-lg font-semibold mt-8 mb-3">Graphiques de synthese</h2><div class="grid grid-cols-1 md:grid-cols-3 gap-3">${summaryCharts}</div>` : ''}
<h2 class="text-lg font-semibold mt-8 mb-2">Journal</h2>
<pre class="bg-black/60 border border-slate-800 rounded-lg p-3 text-xs text-slate-300 overflow-x-auto max-h-64 overflow-y-auto">${escapeHtml(run.log || '')}</pre>
`;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}

28
public/index.html Normal file
View file

@ -0,0 +1,28 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Volume 2.5D &amp; robustesse a la decimation</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; }
.badge { display: inline-flex; align-items: center; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; }
</style>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen">
<header class="border-b border-slate-800 bg-slate-900/60 sticky top-0 backdrop-blur z-10">
<div class="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
<a href="#/" class="text-lg font-semibold tracking-tight">Volume 2.5D &amp; robustesse decimation</a>
<nav class="text-sm text-slate-400 space-x-4">
<a href="#/" class="hover:text-slate-100">Runs</a>
<a href="#/new" class="hover:text-slate-100">Nouveau run</a>
</nav>
</div>
</header>
<main id="app" class="max-w-6xl mx-auto px-4 py-8"></main>
<script src="app.js"></script>
</body>
</html>

47
python/las_to_xyz.py Normal file
View file

@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""
Convertit un fichier LAS/LAZ/COPC (via laspy) en nuage ASCII XYZ brut, lisible
nativement par CloudCompare sans plugin LAS (le paquet apt Debian de CloudCompare
ne fournit pas le plugin LAS/LAZ - seulement le plugin "Core I/O").
Un .copc.laz est simplement lu comme un LAZ standard (les points sont
identiques, seule l'indexation octree COPC, inutile ici, est ignoree).
Usage:
python3 las_to_xyz.py --input nuage.laz --output nuage.xyz
"""
import argparse
import json
import sys
import laspy
import numpy as np
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--input", required=True)
parser.add_argument("--output", required=True)
args = parser.parse_args()
try:
total = 0
with laspy.open(args.input) as reader, open(args.output, "w", encoding="ascii") as out:
for chunk in reader.chunk_iterator(2_000_000):
xyz = np.column_stack([np.asarray(chunk.x), np.asarray(chunk.y), np.asarray(chunk.z)])
np.savetxt(out, xyz, fmt="%.6f")
total += xyz.shape[0]
except Exception as exc:
print(json.dumps({"error": f"conversion LAS/LAZ impossible: {exc}"}))
return 1
if total == 0:
print(json.dumps({"error": "le fichier LAS/LAZ ne contient aucun point"}))
return 1
print(json.dumps({"ok": True, "count": total}))
return 0
if __name__ == "__main__":
sys.exit(main())

155
python/render_images.py Normal file
View file

@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""
Generation des images du dossier de resultats:
- "heightmap" : construit une carte de hauteur (grille 2.5D) coloree en PNG directement a partir
d'un nuage ASCII XYZ (binning numpy). Le paquet apt CloudCompare (Debian) plante
sur l'export GeoTIFF (assertion GDAL manquante) - on reconstruit donc la grille
nous-memes plutot que de dependre de -RASTERIZE -OUTPUT_RASTER_Z.
- "summary" : genere les graphiques de synthese (volume/points/robustesse vs niveau de decimation)
a partir du report.json produit par le pipeline.
Usage:
python3 render_images.py heightmap --input cloud.xyz --gridstep 0.05 --output heightmap.png --title "Niveau 0"
python3 render_images.py summary --report report.json --outdir images/
"""
import argparse
import json
import sys
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from scipy.stats import binned_statistic_2d
def cmd_heightmap(args: argparse.Namespace) -> int:
try:
pts = np.loadtxt(args.input, dtype=np.float64, usecols=(0, 1, 2))
except Exception as exc:
print(json.dumps({"error": f"lecture xyz impossible: {exc}"}))
return 1
if pts.ndim == 1:
pts = pts.reshape(1, -1)
x, y, z = pts[:, 0], pts[:, 1], pts[:, 2]
xmin, xmax = float(np.min(x)), float(np.max(x))
ymin, ymax = float(np.min(y)), float(np.max(y))
nx = max(2, int(np.ceil((xmax - xmin) / args.gridstep)) + 1) if xmax > xmin else 2
ny = max(2, int(np.ceil((ymax - ymin) / args.gridstep)) + 1) if ymax > ymin else 2
# Grille bornee pour eviter une image demesuree si le pas est mal renseigne
nx, ny = min(nx, 2000), min(ny, 2000)
arr = None
if pts.shape[0] >= 1 and xmax > xmin and ymax > ymin:
stat, _, _, _ = binned_statistic_2d(x, y, z, statistic="mean", bins=[nx, ny])
arr = stat.T
fig, ax = plt.subplots(figsize=(6, 5.2), dpi=130)
if arr is None or not np.isfinite(arr).any():
ax.text(0.5, 0.5, "Aucune donnee exploitable", ha="center", va="center")
else:
im = ax.imshow(arr, cmap="terrain", origin="lower", extent=[xmin, xmax, ymin, ymax], aspect="auto")
cbar = fig.colorbar(im, ax=ax, shrink=0.85)
cbar.set_label("Altitude (m, moyenne par cellule)")
ax.set_title(args.title or Path(args.input).stem)
ax.set_xlabel("X (m)")
ax.set_ylabel("Y (m)")
fig.tight_layout()
fig.savefig(args.output)
plt.close(fig)
print(json.dumps({"ok": True, "output": args.output}))
return 0
def cmd_summary(args: argparse.Namespace) -> int:
with open(args.report, "r", encoding="utf-8") as f:
report = json.load(f)
levels = [lvl for lvl in report.get("levels", []) if lvl.get("status") == "ok"]
if not levels:
print(json.dumps({"error": "aucun niveau exploitable dans le rapport"}))
return 1
outdir = Path(args.outdir)
outdir.mkdir(parents=True, exist_ok=True)
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
# --- Volume vs niveau ---
fig, ax = plt.subplots(figsize=(6, 4), dpi=130)
ax.plot(xs, volumes, marker="o", color="#2563eb")
ax.set_xlabel("Niveau de decimation")
ax.set_ylabel("Volume (m3)")
ax.set_title("Volume calcule vs niveau de decimation")
ax.set_xticks(xs)
ax.grid(alpha=0.3)
fig.tight_layout()
fig.savefig(outdir / "volume_vs_level.png")
plt.close(fig)
# --- Nombre de points (log) vs niveau ---
fig, ax = plt.subplots(figsize=(6, 4), dpi=130)
ax.semilogy(xs, points, marker="o", color="#16a34a")
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")
fig.tight_layout()
fig.savefig(outdir / "points_vs_level.png")
plt.close(fig)
# --- Ecart relatif de volume + robustesse (matching cells %) vs niveau ---
fig, ax1 = plt.subplots(figsize=(6, 4), dpi=130)
if v0:
delta_pct = [100.0 * (v - v0) / v0 for v in volumes]
ax1.plot(xs, delta_pct, marker="o", color="#dc2626", label="Ecart volume vs niveau 0 (%)")
ax1.set_xlabel("Niveau de decimation")
ax1.set_ylabel("Ecart volume (%)", color="#dc2626")
ax1.set_xticks(xs)
ax1.grid(alpha=0.3)
ax2 = ax1.twinx()
ax2.plot(xs, matching, marker="s", linestyle="--", color="#7c3aed", label="Matching cells (%)")
ax2.set_ylabel("Matching cells (%)", color="#7c3aed")
ax2.set_ylim(0, 105)
fig.suptitle("Robustesse : ecart de volume et couverture de grille")
fig.tight_layout()
fig.savefig(outdir / "robustness_vs_level.png")
plt.close(fig)
print(json.dumps({"ok": True, "outdir": str(outdir)}))
return 0
def main() -> int:
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command", required=True)
p_heightmap = sub.add_parser("heightmap")
p_heightmap.add_argument("--input", required=True)
p_heightmap.add_argument("--gridstep", required=True, type=float)
p_heightmap.add_argument("--output", required=True)
p_heightmap.add_argument("--title", default=None)
p_heightmap.set_defaults(func=cmd_heightmap)
p_summary = sub.add_parser("summary")
p_summary.add_argument("--report", required=True)
p_summary.add_argument("--outdir", required=True)
p_summary.set_defaults(func=cmd_summary)
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
sys.exit(main())

5
python/requirements.txt Normal file
View file

@ -0,0 +1,5 @@
numpy>=1.26,<2.3
laspy[lazrs]>=2.5,<2.6
scipy>=1.11,<1.15
Pillow>=10.0,<11.0
matplotlib>=3.8,<3.10

72
python/stats_helper.py Normal file
View file

@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""
Calcule des statistiques (distance moyenne/mediane au plus proche voisin, bbox)
a partir d'un echantillon de points XYZ (fichier ASCII exporte par CloudCompare).
Usage:
python3 stats_helper.py --input sample.xyz
Sortie: un objet JSON unique sur stdout.
"""
import argparse
import json
import sys
import numpy as np
from scipy.spatial import cKDTree
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--input", required=True, help="Fichier ASCII XYZ (espace separe)")
args = parser.parse_args()
try:
pts = np.loadtxt(args.input, dtype=np.float64, usecols=(0, 1, 2))
except Exception as exc: # fichier vide, format inattendu, etc.
print(json.dumps({"error": f"lecture impossible: {exc}"}))
return 1
if pts.ndim == 1:
# une seule ligne -> un seul point
pts = pts.reshape(1, -1)
count = int(pts.shape[0])
if count < 2:
print(json.dumps({"error": f"pas assez de points dans l'echantillon ({count})"}))
return 1
bbox = {
"xmin": float(np.min(pts[:, 0])),
"xmax": float(np.max(pts[:, 0])),
"ymin": float(np.min(pts[:, 1])),
"ymax": float(np.max(pts[:, 1])),
"zmin": float(np.min(pts[:, 2])),
"zmax": float(np.max(pts[:, 2])),
}
tree = cKDTree(pts)
# k=2 : le plus proche voisin de chaque point est le point lui-meme (distance 0),
# le 2e plus proche est le vrai plus proche voisin.
dists, _ = tree.query(pts, k=2, workers=-1)
nn_dist = dists[:, 1]
nn_dist = nn_dist[np.isfinite(nn_dist) & (nn_dist > 0)]
if nn_dist.size == 0:
print(json.dumps({"error": "distances au plus proche voisin toutes nulles/invalides (points dupliques ?)"}))
return 1
result = {
"count": count,
"mean_nn": float(np.mean(nn_dist)),
"median_nn": float(np.median(nn_dist)),
"std_nn": float(np.std(nn_dist)),
"bbox": bbox,
"approximate": True,
}
print(json.dumps(result))
return 0
if __name__ == "__main__":
sys.exit(main())

15
src/app.module.ts Normal file
View file

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import * as path from 'path';
import { RunsModule } from './runs/runs.module';
@Module({
imports: [
ServeStaticModule.forRoot({
rootPath: path.resolve(__dirname, '..', 'public'),
exclude: ['/api*'],
}),
RunsModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,122 @@
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { CcRunnerService } from './cc-runner.service';
describe('CcRunnerService', () => {
const service = new CcRunnerService();
describe('parseLoadedPointCount', () => {
it('extrait le nombre de points du premier "Found one cloud with N points"', () => {
const log = `
[12:56:04] [LOAD]
[12:56:04] Opening file: 'test_cloud.las'
[12:56:04] Found one cloud with 250000 points
[12:56:04] [LOAD] finished in 0.02 s.
`;
expect(service.parseLoadedPointCount(log)).toBe(250000);
});
it('renvoie null si le nuage n\'a pas pu etre charge', () => {
const log = `[13:18:20] [Load] Can't guess file format: unhandled file extension 'las'`;
expect(service.parseLoadedPointCount(log)).toBeNull();
});
});
describe('parseSubsampleResult', () => {
it('extrait le nombre de points apres -SS SPATIAL/RANDOM', () => {
const log = `
[12:56:30] [SUBSAMPLE]
[12:56:30] \tResult: 25451 points
[12:56:30] [SUBSAMPLE] finished in 0.60 s.
`;
expect(service.parseSubsampleResult(log)).toBe(25451);
});
it('renvoie null si aucune section [SUBSAMPLE] n\'est presente', () => {
expect(service.parseSubsampleResult('rien a voir ici')).toBeNull();
});
});
describe('parseVolumeReportFile', () => {
// Format verifie empiriquement avec CloudCompare 2.13.2 (voir CLAUDE.md) :
// -VOLUME ne genere ce fichier que si AUTO_SAVE est ON.
const reportContent = [
'1\tVolume: 7.834176',
'2\tSurface: 100.774998',
'3\t----------------------',
'4\tAdded volume: (+)7.975798',
'5\tRemoved volume: (-)0.141622',
'6\t----------------------',
'7\tMatching cells: 99.8%',
'8\tNon-matching cells:',
'9\t ground = 0.2%',
'10\t ceil = 0.0%',
'11\tAverage neighbors per cell: 8.0 / 8.0',
'12\t',
].join('\n');
let tmpFile: string;
beforeEach(async () => {
tmpFile = path.join(os.tmpdir(), `VolumeCalculationReport_test_${Date.now()}.txt`);
await fs.writeFile(tmpFile, reportContent, 'utf-8');
});
afterEach(async () => {
await fs.unlink(tmpFile).catch(() => undefined);
});
it('parse toutes les valeurs du rapport CloudCompare', async () => {
const result = await service.parseVolumeReportFile(tmpFile);
expect(result).toEqual({
volume: 7.834176,
surface: 100.774998,
addedVolume: 7.975798,
removedVolume: 0.141622,
matchingCellsPct: 99.8,
groundNonMatchingPct: 0.2,
ceilNonMatchingPct: 0.0,
avgNeighborsCurrent: 8.0,
avgNeighborsRef: 8.0,
});
});
it('leve une erreur explicite si un champ attendu est absent', async () => {
await fs.writeFile(tmpFile, 'contenu inattendu sans les champs voulus', 'utf-8');
await expect(service.parseVolumeReportFile(tmpFile)).rejects.toThrow(/Champ introuvable/);
});
});
describe('findLatestFile', () => {
let dir: string;
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-runner-test-'));
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
it('trouve un fichier ecrit apres coup dans un sous-dossier (CloudCompare ecrit relativement au nuage charge, pas au cwd)', async () => {
const sub = path.join(dir, 'levels');
await fs.mkdir(sub);
const before = Date.now();
await new Promise((r) => setTimeout(r, 10));
const target = path.join(sub, 'VolumeCalculationReport_2026-01-01_00h00.txt');
await fs.writeFile(target, 'contenu', 'utf-8');
const found = await service.findLatestFile(dir, { prefix: 'VolumeCalculationReport', suffix: '.txt', after: before });
expect(found).toBe(target);
});
it('ignore les fichiers plus anciens que "after"', async () => {
const old = path.join(dir, 'old_report.txt');
await fs.writeFile(old, 'contenu', 'utf-8');
const after = Date.now() + 5000;
const found = await service.findLatestFile(dir, { suffix: '.txt', after });
expect(found).toBeNull();
});
});
});

View file

@ -0,0 +1,141 @@
import { Injectable, Logger } from '@nestjs/common';
import { spawn } from 'child_process';
import * as fs from 'fs/promises';
import * as path from 'path';
import { config } from '../config';
export interface CcResult {
stdout: string;
exitCode: number | null;
}
export interface VolumeReport {
volume: number;
surface: number;
addedVolume: number;
removedVolume: number;
matchingCellsPct: number;
groundNonMatchingPct: number;
ceilNonMatchingPct: number;
avgNeighborsCurrent: number | null;
avgNeighborsRef: number | null;
}
/**
* Wrapper bas niveau autour du binaire CloudCompare en ligne de commande.
* Chaque appel est un process independant (le CLI CloudCompare fonctionne comme
* une machine a etats sur les entites chargees pendant UN SEUL appel : on ne
* peut pas "continuer" une session precedente).
*/
@Injectable()
export class CcRunnerService {
private readonly logger = new Logger(CcRunnerService.name);
/**
* autoSave=true est necessaire pour -VOLUME : CloudCompare ne genere le rapport
* "VolumeCalculationReport_*.txt" que si l'auto-sauvegarde est active (verifie empiriquement,
* non documente). -SS / -SAVE_CLOUDS / -RASTERIZE fonctionnent en revanche correctement avec
* autoSave=false (evite de polluer le dossier de travail avec des fichiers intermediaires).
*/
async run(args: string[], cwd: string, opts: { autoSave?: boolean } = {}): Promise<CcResult> {
await fs.mkdir(cwd, { recursive: true });
const fullArgs = ['-SILENT', '-AUTO_SAVE', opts.autoSave ? 'ON' : 'OFF', ...args];
this.logger.debug(`CloudCompare ${fullArgs.join(' ')} (cwd=${cwd})`);
return new Promise<CcResult>((resolve, reject) => {
const child = spawn(config.cloudCompareBin, fullArgs, {
cwd,
windowsHide: true,
env: process.env,
});
let stdout = '';
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error(`CloudCompare timeout apres ${config.ccTimeoutMs}ms (args: ${fullArgs.join(' ')})`));
}, config.ccTimeoutMs);
child.stdout?.on('data', (d) => (stdout += d.toString()));
child.stderr?.on('data', (d) => (stdout += d.toString()));
child.on('error', (err) => {
clearTimeout(timer);
reject(new Error(`Impossible de lancer CloudCompare (${config.cloudCompareBin}): ${err.message}`));
});
child.on('close', (code) => {
clearTimeout(timer);
resolve({ stdout, exitCode: code });
});
});
}
/** Cherche "Found one cloud with N points" (premiere occurrence = nuage juste charge par -O) */
parseLoadedPointCount(log: string): number | null {
const m = log.match(/Found one cloud with (\d+) points/);
return m ? Number(m[1]) : null;
}
/** Cherche "Result: N points" (sortie de -SS SPATIAL/RANDOM) */
parseSubsampleResult(log: string): number | null {
const m = log.match(/\[SUBSAMPLE\][\s\S]*?Result:\s*(\d+)\s*points/);
return m ? Number(m[1]) : null;
}
/**
* Trouve le fichier le plus recent correspondant a un prefixe/suffixe, apres un instant donne.
* Recherche recursive : CloudCompare ecrit les rapports/rasters relativement au dossier du
* nuage charge via -O (ex: "levels/"), pas forcement au cwd du process.
*/
async findLatestFile(rootDir: string, opts: { prefix?: string; suffix?: string; after: number }): Promise<string | null> {
const candidates: Array<{ full: string; mtime: number }> = [];
const walk = async (dir: string): Promise<void> => {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(full);
continue;
}
if (opts.prefix && !entry.name.startsWith(opts.prefix)) continue;
if (opts.suffix && !entry.name.endsWith(opts.suffix)) continue;
const stat = await fs.stat(full);
const mtime = stat.mtimeMs;
if (mtime < opts.after - 2000) continue; // marge de securite horloge
candidates.push({ full, mtime });
}
};
await walk(rootDir);
if (candidates.length === 0) return null;
candidates.sort((a, b) => b.mtime - a.mtime);
return candidates[0].full;
}
/** Parse le fichier texte "VolumeCalculationReport_*.txt" genere par -VOLUME */
async parseVolumeReportFile(filePath: string): Promise<VolumeReport> {
const text = await fs.readFile(filePath, 'utf-8');
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]);
};
const numOpt = (re: RegExp): number | null => {
const m = text.match(re);
return m ? Number(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]+)/),
};
}
}

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { CcRunnerService } from './cc-runner.service';
@Module({
providers: [CcRunnerService],
exports: [CcRunnerService],
})
export class CloudCompareModule {}

36
src/config.ts Normal file
View file

@ -0,0 +1,36 @@
import * as path from 'path';
function envInt(name: string, fallback: number): number {
const v = process.env[name];
if (!v) return fallback;
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
export const config = {
port: envInt('PORT', 3000),
dataDir: process.env.DATA_DIR ?? path.resolve(process.cwd(), 'data'),
cloudCompareBin: process.env.CLOUDCOMPARE_BIN ?? 'CloudCompare',
pythonBin: process.env.PYTHON_BIN ?? 'python3',
// Pipeline constants (voir plan: facteur 2, 5 etapes)
decimationFactor: 2,
decimationSteps: 5,
// Nombre max de points echantillonnes pour estimer la distance moyenne au plus proche voisin
statsSampleCap: envInt('STATS_SAMPLE_CAP', 50000),
// Le pas de grille utilise pour TOUS les calculs de volume (fixe, derive du pas spatial initial)
// afin que les 6 niveaux restent comparables entre eux.
gridStepMultiplier: 2,
// 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,
// le resultat est signale comme peu fiable dans l'UI.
matchingCellsWarnThreshold: 90,
// Taille max d'upload (octets)
maxUploadBytes: envInt('MAX_UPLOAD_MB', 2048) * 1024 * 1024,
// Timeout par appel CloudCompare CLI (ms)
ccTimeoutMs: envInt('CC_TIMEOUT_MS', 10 * 60 * 1000),
};
export const uploadsDir = () => path.join(config.dataDir, 'uploads');
export const resultsDir = () => path.join(config.dataDir, 'results');
export const dbPath = () => path.join(config.dataDir, 'app.db');

14
src/main.ts Normal file
View file

@ -0,0 +1,14 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { AppModule } from './app.module';
import { config } from './config';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bodyParser: true });
app.enableCors();
await app.listen(config.port, '0.0.0.0');
new Logger('Bootstrap').log(`Application demarree sur http://0.0.0.0:${config.port} (data dir: ${config.dataDir})`);
}
bootstrap();

View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { CloudCompareModule } from '../cloudcompare/cloudcompare.module';
import { PyHelperModule } from '../pyhelper/pyhelper.module';
import { PipelineService } from './pipeline.service';
@Module({
imports: [CloudCompareModule, PyHelperModule],
providers: [PipelineService],
exports: [PipelineService],
})
export class PipelineModule {}

View file

@ -0,0 +1,447 @@
import { Injectable, Logger } from '@nestjs/common';
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';
export function sanitizeBaseName(filename: string): string {
const base = filename.replace(/\.[^./\\]+$/, '');
return base.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 80) || 'nuage';
}
/**
* 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
*/
@Injectable()
export class PipelineService {
private readonly logger = new Logger(PipelineService.name);
constructor(
private readonly cc: CcRunnerService,
private readonly py: PyHelperService,
) {}
async execute(
runId: string,
inputPath: string,
originalFilename: string,
workDir: string,
params: RunParamsInput,
onProgress: (msg: string) => void,
): Promise<RunReport> {
const levelsDir = path.join(workDir, 'levels');
const imagesDir = path.join(workDir, 'images');
const rawDir = path.join(workDir, 'raw');
await fs.mkdir(levelsDir, { recursive: true });
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;
}
// ---------- 1) Statistiques ----------
onProgress('Estimation du pas spatial initial (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 initialStep = params.initialStepOverride ?? stats.median_nn;
const zref = params.zrefOverride ?? stats.bbox.zmin;
const gridStep = initialStep * config.gridStepMultiplier;
onProgress(
`Pas initial=${initialStep.toFixed(5)}m (${params.initialStepOverride ? 'override' : 'auto, mediane NN'}), ` +
`grille volume=${gridStep.toFixed(5)}m, Zref=${zref.toFixed(4)} (${params.zrefOverride ? 'override' : 'auto, Zmin echantillon'}). ` +
`[${Date.now() - t0}ms]`,
);
const levels: LevelResult[] = [];
// ---------- 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,
zref,
refPointCount: totalPointCount,
onProgress,
});
levels.push(level0);
// ---------- 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' : '';
for (let i = 1; i <= config.decimationSteps; i++) {
if (stopped || !prevBinAbs) {
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);
onProgress(`Niveau ${i} : decimation spatiale (pas=${spatialStep.toFixed(5)}m) depuis le niveau ${i - 1}...`);
try {
const lvl = await this.computeLevel({
level: i,
spatialStep,
sourceBinOrOriginal: prevBinAbs,
isFirstConversion: false,
levelsDir,
imagesDir,
rawDir,
workDir,
gridStep,
zref,
refPointCount: totalPointCount,
onProgress,
});
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}`;
}
} catch (err: any) {
this.logger.error(`Niveau ${i} en erreur: ${err.message}`);
levels.push({
level: i,
spatialStep,
pointCount: 0,
pointRatio: 0,
volume: null,
surface: null,
addedVolume: null,
removedVolume: null,
matchingCellsPct: null,
groundNonMatchingPct: null,
ceilNonMatchingPct: null,
warn: true,
status: 'error',
message: err.message,
binFile: null,
imageFile: null,
computeTimeMs: 0,
});
stopped = true;
stopReason = err.message;
}
}
// ---------- 4) Assemblage ----------
onProgress('Assemblage des nuages exploitables dans un seul fichier .bin...');
const okLevels = levels.filter((l) => l.status === 'ok' && l.binFile);
let mergedBinFile: string | null = null;
if (okLevels.length > 0) {
const baseName = sanitizeBaseName(originalFilename);
const mergedName = `${baseName}_all_levels.bin`;
const args: string[] = [];
for (const lvl of okLevels) args.push('-O', lvl.binFile!);
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);
await this.assertExists(mergedAbs, 'fusion des nuages', mergeRes.stdout);
mergedBinFile = mergedName;
}
const report: RunReport = {
runId,
originalFilename,
createdAt: new Date().toISOString(),
params: {
zref,
zrefSource: params.zrefOverride !== undefined ? 'override' : 'auto',
initialStep,
initialStepSource: params.initialStepOverride !== undefined ? 'override' : 'auto',
gridStep,
factor: config.decimationFactor,
steps: config.decimationSteps,
sampleStats: {
sampleCount: stats.count,
meanNn: stats.mean_nn,
medianNn: stats.median_nn,
bboxApprox: stats.bbox,
},
},
levels,
mergedBinFile,
};
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');
// ---------- 5) Graphiques de synthese ----------
onProgress('Generation des graphiques de synthese...');
try {
await this.py.renderSummary(path.join(workDir, 'report.json'), imagesDir);
} catch (err: any) {
this.logger.warn(`Graphiques de synthese non generes: ${err.message}`);
}
return report;
}
private skippedLevel(level: number, reason: string): LevelResult {
return {
level,
spatialStep: null,
pointCount: 0,
pointRatio: 0,
volume: null,
surface: null,
addedVolume: null,
removedVolume: null,
matchingCellsPct: null,
groundNonMatchingPct: null,
ceilNonMatchingPct: null,
warn: true,
status: 'skipped',
message: reason,
binFile: null,
imageFile: null,
computeTimeMs: 0,
};
}
private async assertExists(p: string, what: string, ccLog: string) {
try {
await fs.access(p);
} catch {
throw new Error(`Etape "${what}" : fichier attendu introuvable (${p}). Sortie CloudCompare:\n${ccLog.slice(-2000)}`);
}
}
private async computeLevel(opts: {
level: number;
spatialStep: number | null;
sourceBinOrOriginal: string;
isFirstConversion: boolean;
levelsDir: string;
imagesDir: string;
rawDir: string;
workDir: string;
gridStep: number;
zref: number;
refPointCount: number;
onProgress: (msg: string) => void;
}): Promise<LevelResult> {
const { level, spatialStep, workDir, gridStep, zref, refPointCount } = opts;
const t0 = Date.now();
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);
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);
}
pointCount = n;
await this.assertExists(path.join(workDir, binRelPath), 'conversion niveau 0', 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`);
const res = await this.cc.run(
['-O', opts.sourceBinOrOriginal, '-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 {
level,
spatialStep,
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,
};
}
pointCount = n;
await this.assertExists(path.join(workDir, binRelPath), `decimation niveau ${level}`, 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);
}
const vol = await this.cc.parseVolumeReportFile(reportFile);
const rawReportDest = path.join(opts.rawDir, `L${level}_volume_report.txt`);
await fs.rename(reportFile, rawReportDest);
// AUTO_SAVE ON sauvegarde aussi une grille "*_HEIGHT_DIFFERENCE_*.bin" qu'on ne veut pas garder
const strayGrid = await this.cc.findLatestFile(workDir, { suffix: '.bin', after: beforeVolume });
if (strayGrid && strayGrid !== path.join(workDir, binRelPath)) {
await fs.unlink(strayGrid).catch(() => undefined);
}
// Carte de hauteur : export ASCII du nuage + binning numpy cote Python.
// (Le paquet apt CloudCompare, utilise dans l'image Docker, plante sur l'export GeoTIFF natif
// -RASTERIZE -OUTPUT_RASTER_Z faute de support GDAL compile - on reconstruit donc la grille
// nous-memes a partir d'un export ASCII, qui lui fonctionne sans plugin.)
let imageRelPath: string | null = null;
try {
const xyzExportRel = path.posix.join('raw', `L${level}_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`;
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)`;
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}`);
}
const warn = vol.matchingCellsPct < config.matchingCellsWarnThreshold;
return {
level,
spatialStep,
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,
};
}
private errorLevel(level: number, spatialStep: number | null, message: string, t0: number, binFile: string | null = null): LevelResult {
return {
level,
spatialStep,
pointCount: 0,
pointRatio: 0,
volume: null,
surface: null,
addedVolume: null,
removedVolume: null,
matchingCellsPct: null,
groundNonMatchingPct: null,
ceilNonMatchingPct: null,
warn: true,
status: 'error',
message,
binFile,
imageFile: null,
computeTimeMs: Date.now() - t0,
};
}
private toCsv(report: RunReport): string {
return reportToCsv(report);
}
}
export function reportToCsv(report: RunReport): string {
const headers = [
'level',
'spatialStep',
'pointCount',
'pointRatio',
'volume',
'surface',
'addedVolume',
'removedVolume',
'matchingCellsPct',
'groundNonMatchingPct',
'ceilNonMatchingPct',
'status',
'warn',
'computeTimeMs',
'message',
];
const rows = report.levels.map((l) =>
[
l.level,
l.spatialStep ?? '',
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(','),
);
return [headers.join(','), ...rows].join('\n') + '\n';
}

View file

@ -0,0 +1,103 @@
import { reportToCsv, sanitizeBaseName } from './pipeline.service';
import { RunReport } from './types';
describe('sanitizeBaseName', () => {
it('retire l\'extension et les caracteres non surs', () => {
expect(sanitizeBaseName('mon nuage (2026) v2.las')).toBe('mon_nuage_2026_v2');
});
it('gere le double-extension .copc.laz (ne retire que le dernier ".laz")', () => {
expect(sanitizeBaseName('zone_chantier.copc.laz')).toBe('zone_chantier_copc');
});
it('retombe sur un nom par defaut si le nom de base est vide apres extraction de l\'extension', () => {
expect(sanitizeBaseName('.las')).toBe('nuage');
});
it('tronque a 80 caracteres', () => {
const long = 'a'.repeat(200) + '.las';
expect(sanitizeBaseName(long).length).toBe(80);
});
});
describe('reportToCsv', () => {
const baseReport: RunReport = {
runId: 'run-1',
originalFilename: 'test.las',
createdAt: '2026-01-01T00:00:00.000Z',
params: {
zref: 100,
zrefSource: 'auto',
initialStep: 0.02,
initialStepSource: 'auto',
gridStep: 0.04,
factor: 2,
steps: 1,
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,
pointCount: 1000,
pointRatio: 1,
volume: 12.5,
surface: 100,
addedVolume: 12.5,
removedVolume: 0,
matchingCellsPct: 99,
groundNonMatchingPct: 1,
ceilNonMatchingPct: 0,
warn: false,
status: 'ok',
binFile: 'levels/L0_full.bin',
imageFile: 'images/L0_heightmap.png',
computeTimeMs: 500,
},
{
level: 1,
spatialStep: 0.04,
pointCount: 0,
pointRatio: 0,
volume: null,
surface: null,
addedVolume: null,
removedVolume: null,
matchingCellsPct: null,
groundNonMatchingPct: null,
ceilNonMatchingPct: null,
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);
const lines = csv.trim().split('\n');
expect(lines).toHaveLength(3); // header + 2 niveaux
expect(lines[0]).toBe(
'level,spatialStep,pointCount,pointRatio,volume,surface,addedVolume,removedVolume,matchingCellsPct,groundNonMatchingPct,ceilNonMatchingPct,status,warn,computeTimeMs,message',
);
});
it('remplace les sauts de ligne et virgules dans les messages pour ne pas casser le CSV', () => {
const csv = reportToCsv(baseReport);
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(14).join(',');
expect(messageField).not.toMatch(/,/);
});
it('laisse les champs numeriques null en cellule vide plutot que "null"', () => {
const csv = reportToCsv(baseReport);
const lines = csv.trim().split('\n');
expect(lines[2]).not.toContain('null');
});
});

47
src/pipeline/types.ts Normal file
View file

@ -0,0 +1,47 @@
export interface LevelResult {
level: number;
spatialStep: number | null; // null pour le niveau 0 (nuage complet, non decime)
pointCount: number;
pointRatio: number; // vs niveau 0
volume: number | null;
surface: number | null;
addedVolume: number | null;
removedVolume: number | null;
matchingCellsPct: number | null;
groundNonMatchingPct: number | null;
ceilNonMatchingPct: number | null;
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;
initialStepOverride?: number;
}
export interface RunReport {
runId: string;
originalFilename: string;
createdAt: string;
params: {
zref: number;
zrefSource: 'override' | 'auto';
initialStep: number;
initialStepSource: 'override' | 'auto';
gridStep: number;
factor: number;
steps: number;
sampleStats: {
sampleCount: number;
meanNn: number;
medianNn: number;
bboxApprox: { xmin: number; xmax: number; ymin: number; ymax: number; zmin: number; zmax: number };
};
};
levels: LevelResult[];
mergedBinFile: string | null;
}

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PyHelperService } from './pyhelper.service';
@Module({
providers: [PyHelperService],
exports: [PyHelperService],
})
export class PyHelperModule {}

View file

@ -0,0 +1,84 @@
import { Injectable, Logger } from '@nestjs/common';
import { spawn } from 'child_process';
import * as path from 'path';
import { config } from '../config';
const PYTHON_DIR = path.resolve(__dirname, '..', '..', 'python');
export interface CloudStats {
count: number;
mean_nn: number;
median_nn: number;
std_nn: number;
bbox: { xmin: number; xmax: number; ymin: number; ymax: number; zmin: number; zmax: number };
approximate: boolean;
}
/** Wrapper autour des scripts python/*.py (statistiques + generation d'images). */
@Injectable()
export class PyHelperService {
private readonly logger = new Logger(PyHelperService.name);
private async runJson(scriptRelPath: string, args: string[]): Promise<any> {
const scriptPath = path.join(PYTHON_DIR, scriptRelPath);
return new Promise((resolve, reject) => {
const child = spawn(config.pythonBin, [scriptPath, ...args], { windowsHide: true });
let stdout = '';
let stderr = '';
child.stdout.on('data', (d) => (stdout += d.toString()));
child.stderr.on('data', (d) => (stderr += d.toString()));
child.on('error', (err) => reject(new Error(`Impossible de lancer python (${config.pythonBin}): ${err.message}`)));
child.on('close', (code) => {
if (stderr) this.logger.warn(`${scriptRelPath} stderr: ${stderr.trim()}`);
const trimmed = stdout.trim();
if (!trimmed) {
reject(new Error(`${scriptRelPath} n'a produit aucune sortie (code ${code}). stderr: ${stderr}`));
return;
}
let parsed: any;
try {
parsed = JSON.parse(trimmed.split('\n').pop()!);
} catch {
reject(new Error(`Sortie JSON invalide de ${scriptRelPath}: ${trimmed}`));
return;
}
if (parsed.error) {
reject(new Error(`${scriptRelPath}: ${parsed.error}`));
return;
}
resolve(parsed);
});
});
}
computeStats(xyzSamplePath: string): Promise<CloudStats> {
return this.runJson('stats_helper.py', ['--input', xyzSamplePath]);
}
renderHeightmap(xyzPath: string, gridStep: number, outputPngPath: string, title: string): Promise<void> {
return this.runJson('render_images.py', [
'heightmap',
'--input',
xyzPath,
'--gridstep',
String(gridStep),
'--output',
outputPngPath,
'--title',
title,
]);
}
renderSummary(reportJsonPath: string, outDir: string): Promise<void> {
return this.runJson('render_images.py', ['summary', '--report', reportJsonPath, '--outdir', outDir]);
}
/**
* Le paquet apt CloudCompare (Debian) ne fournit pas le plugin LAS/LAZ (seulement "Core I/O").
* On convertit donc les entrees LAS/LAZ/COPC en ASCII XYZ via laspy avant de les transmettre
* a CloudCompare, qui lit nativement le format ASCII sans plugin.
*/
async convertLasToXyz(inputPath: string, outputXyzPath: string): Promise<{ count: number }> {
return this.runJson('las_to_xyz.py', ['--input', inputPath, '--output', outputXyzPath]);
}
}

View file

@ -0,0 +1,78 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import Database from 'better-sqlite3';
import * as fs from 'fs';
import { config, dbPath } from '../config';
export interface RunRow {
id: string;
original_filename: string;
status: 'pending' | 'running' | 'done' | 'error';
created_at: string;
finished_at: string | null;
params_json: string | null;
report_json: string | null;
log: string;
error: string | null;
}
@Injectable()
export class DatabaseService implements OnModuleInit {
private db!: Database.Database;
onModuleInit() {
fs.mkdirSync(config.dataDir, { recursive: true });
this.db = new Database(dbPath());
this.db.pragma('journal_mode = WAL');
this.db.exec(`
CREATE TABLE IF NOT EXISTS runs (
id TEXT PRIMARY KEY,
original_filename TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
finished_at TEXT,
params_json TEXT,
report_json TEXT,
log TEXT NOT NULL DEFAULT '',
error TEXT
);
`);
}
insert(row: Pick<RunRow, 'id' | 'original_filename' | 'status' | 'created_at' | 'params_json'>) {
this.db
.prepare(
`INSERT INTO runs (id, original_filename, status, created_at, params_json, log) VALUES (?, ?, ?, ?, ?, '')`,
)
.run(row.id, row.original_filename, row.status, row.created_at, row.params_json);
}
updateStatus(id: string, status: RunRow['status']) {
this.db.prepare(`UPDATE runs SET status = ? WHERE id = ?`).run(status, id);
}
appendLog(id: string, line: string) {
this.db.prepare(`UPDATE runs SET log = log || ? WHERE id = ?`).run(`[${new Date().toISOString()}] ${line}\n`, id);
}
complete(id: string, reportJson: string) {
this.db
.prepare(`UPDATE runs SET status = 'done', report_json = ?, finished_at = ? WHERE id = ?`)
.run(reportJson, new Date().toISOString(), id);
}
fail(id: string, error: string) {
this.db.prepare(`UPDATE runs SET status = 'error', error = ?, finished_at = ? WHERE id = ?`).run(error, new Date().toISOString(), id);
}
get(id: string): RunRow | undefined {
return this.db.prepare(`SELECT * FROM runs WHERE id = ?`).get(id) as RunRow | undefined;
}
list(): RunRow[] {
return this.db.prepare(`SELECT * FROM runs ORDER BY created_at DESC`).all() as RunRow[];
}
remove(id: string) {
this.db.prepare(`DELETE FROM runs WHERE id = ?`).run(id);
}
}

138
src/runs/runs.controller.ts Normal file
View file

@ -0,0 +1,138 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
Post,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import archiver from 'archiver';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as path from 'path';
import { diskStorage } from 'multer';
import { config, uploadsDir } from '../config';
import { RunsService } from './runs.service';
const ALLOWED_EXT = ['.las', '.laz', '.copc.laz', '.bin'];
function isAllowedFilename(name: string): boolean {
const lower = name.toLowerCase();
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({
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}`);
},
}),
limits: { fileSize: config.maxUploadBytes },
fileFilter: (_req, file, cb) => {
if (!isAllowedFilename(file.originalname)) {
cb(new BadRequestException(`Format non supporte. Extensions acceptees: ${ALLOWED_EXT.join(', ')}`) as any, false);
return;
}
cb(null, true);
},
}),
)
async create(@UploadedFile() file: Express.Multer.File, @Body() body: Record<string, string>) {
if (!file) throw new BadRequestException('Aucun fichier recu (champ "file" attendu)');
const zrefOverride = this.parseOptionalNumber(body.zrefOverride);
const initialStepOverride = this.parseOptionalNumber(body.initialStepOverride);
return this.runs.createRun({
tmpFilePath: file.path,
originalFilename: file.originalname,
params: { zrefOverride, initialStepOverride },
});
}
@Get()
list() {
return this.runs.list();
}
@Get(':id')
detail(@Param('id') id: string) {
return this.runs.getDetail(id);
}
@Delete(':id')
async remove(@Param('id') id: string) {
return this.runs.delete(id);
}
@Get(':id/download/bin')
async downloadBin(@Param('id') id: string, @Res() res: Response) {
const row = this.runs.getRowOrThrow(id);
const report = row.report_json ? JSON.parse(row.report_json) : null;
if (!report?.mergedBinFile) throw new NotFoundException('Fichier .bin fusionne non disponible pour ce run');
const filePath = path.join(this.runs.workDirFor(id), report.mergedBinFile);
res.download(filePath, report.mergedBinFile);
}
@Get(':id/download/csv')
async downloadCsv(@Param('id') id: string, @Res() res: Response) {
const filePath = path.join(this.runs.workDirFor(id), 'report.csv');
if (!fs.existsSync(filePath)) throw new NotFoundException('report.csv non disponible pour ce run');
res.download(filePath, `report_${id}.csv`);
}
@Get(':id/download/zip')
async downloadZip(@Param('id') id: string, @Res() res: Response) {
const dir = this.runs.workDirFor(id);
if (!fs.existsSync(dir)) throw new NotFoundException('Resultats non disponibles pour ce run');
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="run_${id}.zip"`);
const archive = archiver('zip', { zlib: { level: 6 } });
archive.on('error', (err) => {
res.status(500).end(`Erreur zip: ${err.message}`);
});
archive.pipe(res);
archive.directory(dir, false);
await archive.finalize();
}
@Get(':id/images/:filename')
async image(@Param('id') id: string, @Param('filename') filename: string, @Res() res: Response) {
const safeName = path.basename(filename);
if (safeName !== filename) throw new BadRequestException('Nom de fichier invalide');
const filePath = path.join(this.runs.workDirFor(id), 'images', safeName);
try {
await fsp.access(filePath);
} catch {
throw new NotFoundException('Image introuvable');
}
res.sendFile(filePath);
}
private parseOptionalNumber(v: string | undefined): number | undefined {
if (v === undefined || v === null || v === '') return undefined;
const n = Number(v);
if (!Number.isFinite(n)) throw new BadRequestException(`Valeur numerique invalide: ${v}`);
return n;
}
}

12
src/runs/runs.module.ts Normal file
View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PipelineModule } from '../pipeline/pipeline.module';
import { DatabaseService } from './database.service';
import { RunsController } from './runs.controller';
import { RunsService } from './runs.service';
@Module({
imports: [PipelineModule],
controllers: [RunsController],
providers: [DatabaseService, RunsService],
})
export class RunsModule {}

167
src/runs/runs.service.ts Normal file
View file

@ -0,0 +1,167 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
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 { RunParamsInput } from '../pipeline/types';
import { DatabaseService, RunRow } from './database.service';
export interface CreateRunOptions {
tmpFilePath: string;
originalFilename: string;
params: RunParamsInput;
}
interface QueueJob {
id: string;
inputPath: string;
originalFilename: string;
params: RunParamsInput;
}
@Injectable()
export class RunsService {
private readonly logger = new Logger(RunsService.name);
private queue: QueueJob[] = [];
private processing = false;
constructor(
private readonly db: DatabaseService,
private readonly pipeline: PipelineService,
) {}
async createRun(opts: CreateRunOptions): Promise<{ id: string }> {
const id = uuidv4();
const runUploadDir = path.join(uploadsDir(), id);
await fs.mkdir(runUploadDir, { recursive: true });
const ext = this.extensionOf(opts.originalFilename);
const destPath = path.join(runUploadDir, `input${ext}`);
await fs.rename(opts.tmpFilePath, destPath);
this.db.insert({
id,
original_filename: opts.originalFilename,
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 });
void this.processQueue();
return { id };
}
private async processQueue() {
if (this.processing) return;
this.processing = true;
try {
while (this.queue.length > 0) {
const job = this.queue.shift()!;
await this.runJob(job);
}
} finally {
this.processing = false;
}
}
private async runJob(job: QueueJob) {
this.db.updateStatus(job.id, 'running');
const workDir = path.join(resultsDir(), job.id);
await fs.mkdir(workDir, { recursive: true });
const onProgress = (msg: string) => {
this.logger.log(`[${job.id}] ${msg}`);
this.db.appendLog(job.id, msg);
};
try {
const report = await this.pipeline.execute(job.id, job.inputPath, job.originalFilename, workDir, job.params, onProgress);
this.db.complete(job.id, JSON.stringify(report));
onProgress('Run termine avec succes.');
} catch (err: any) {
this.logger.error(`Run ${job.id} en erreur: ${err.message}`);
this.db.appendLog(job.id, `ERREUR FATALE: ${err.message}`);
this.db.fail(job.id, err.message);
}
}
list() {
return this.db.list().map((r) => this.toSummary(r));
}
getDetail(id: string) {
const row = this.db.get(id);
if (!row) throw new NotFoundException(`Run ${id} introuvable`);
return {
id: row.id,
originalFilename: row.original_filename,
status: row.status,
createdAt: row.created_at,
finishedAt: row.finished_at,
params: row.params_json ? JSON.parse(row.params_json) : null,
report: row.report_json ? JSON.parse(row.report_json) : null,
log: row.log,
error: row.error,
};
}
getRowOrThrow(id: string): RunRow {
const row = this.db.get(id);
if (!row) throw new NotFoundException(`Run ${id} introuvable`);
return row;
}
workDirFor(id: string): string {
return path.join(resultsDir(), id);
}
async delete(id: string) {
this.getRowOrThrow(id); // leve NotFoundException si le run n'existe pas
this.db.remove(id);
const work = this.workDirFor(id);
const upload = path.join(uploadsDir(), id);
await fs.rm(work, { recursive: true, force: true });
await fs.rm(upload, { recursive: true, force: true });
return { id };
}
private toSummary(row: RunRow) {
let summary: any = null;
if (row.report_json) {
try {
const report = JSON.parse(row.report_json);
const okLevels = (report.levels ?? []).filter((l: any) => l.status === 'ok');
const first = okLevels[0];
const last = okLevels[okLevels.length - 1];
summary = {
levelsOk: okLevels.length,
levelsTotal: report.levels?.length ?? 0,
volumeLevel0: first?.volume ?? null,
volumeLastLevel: last?.volume ?? null,
volumeDeltaPct: first && last && first.volume ? (100 * (last.volume - first.volume)) / first.volume : null,
};
} catch {
summary = null;
}
}
return {
id: row.id,
originalFilename: row.original_filename,
status: row.status,
createdAt: row.created_at,
finishedAt: row.finished_at,
error: row.error,
summary,
};
}
private extensionOf(filename: string): string {
const lower = filename.toLowerCase();
if (lower.endsWith('.copc.laz')) return '.copc.laz';
const idx = lower.lastIndexOf('.');
return idx >= 0 ? filename.slice(idx) : '';
}
}

4
tsconfig.build.json Normal file
View file

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
}

23
tsconfig.json Normal file
View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": false,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": false,
"outDir": "./dist",
"baseUrl": "./",
"incremental": false,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true
}
}