diff --git a/.gitignore b/.gitignore index 155d451..046e4e9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ data/ .DS_Store coverage/ .claude/ +.env diff --git a/CLAUDE.md b/CLAUDE.md index f0caabe..dfd8340 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,10 +54,18 @@ E57 volontairement hors perimetre. 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. +- **`-GRID_STEP` doit etre adapte a CHAQUE niveau, pas fige sur la resolution du niveau 0.** Premiere + version du pipeline : un `GRID_STEP` unique (derive du niveau 0) reutilise sur les 6 niveaux "pour + rester comparable". En pratique ca fait exactement l'inverse de ce qu'on veut tester : une fois la + decimation plus grossiere que cette grille fixe, `matching cells %` s'effondre mecaniquement + (observe : 99% -> 58% -> 13% -> 3% -> 0.9% -> 0.2% sur 5 niveaux) parce que la grille est trop fine + pour les points restants, pas parce que le volume est vraiment devenu impossible a estimer. Ce n'est + pas un signal de robustesse utile, juste un artefact de resolution. **Fix retenu** : `GRID_STEP` par + niveau = pas spatial de CE niveau x `gridStepMultiplier` (le niveau 0 utilise `initialStep x + multiplier`). Chaque niveau a donc sa propre grille adaptee a sa propre densite de points — le + `matching cells %` refletera alors la vraie perte d'information due a la decimation, pas un + desalignement grille/densite. Voir `LevelResult.gridStep` (par niveau) et + `RunReport.params.gridStepMultiplier` (le seul parametre encore global). ### `-SS SPATIAL` / `-SS RANDOM` (sous-echantillonnage) @@ -163,7 +171,8 @@ manquantes), mais pourraient etre simplifies. ## 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. +- `gridStepMultiplier = 2` : grid step de `-VOLUME` a CHAQUE niveau = pas spatial de ce niveau x2 + (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. diff --git a/Dockerfile b/Dockerfile index a5a04e8..4db04db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -56,6 +56,6 @@ VOLUME ["/data"] EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ - CMD curl -fsS "http://localhost:${PORT:-3000}/" || exit 1 + CMD curl -fsS "http://localhost:${PORT:-3000}/health" || exit 1 ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index 8c98aa4..cbcedee 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ Application locale (Docker) qui, a partir d'un nuage de points (LAS/LAZ/COPC.laz 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 ; +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 @@ -25,14 +26,44 @@ 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). +## Authentification + +L'UI et l'API sont protegees par HTTP Basic Auth (identifiants dans `AUTH_USERNAME` / +`AUTH_PASSWORD_HASH`, hash bcrypt — jamais le mot de passe en clair). Seul `/health` reste public +(healthcheck Docker/Coolify). + +**Si ces deux variables ne sont pas definies, l'auth est desactivee** (pratique pour du dev local, +un warning est logge au demarrage). En production/Coolify, toujours les definir. + +Generer un hash pour un nouveau mot de passe : + +```bash +node -e "console.log(require('bcryptjs').hashSync('VOTRE_MOT_DE_PASSE', 10))" +``` + +Puis definir dans Coolify (onglet "Environment Variables" de l'application, **pas** dans +`docker-compose.yml` du repo, pour ne pas committer le hash) : + +``` +AUTH_USERNAME=nico +AUTH_PASSWORD_HASH=$2a$10$... # sortie de la commande ci-dessus +``` + +En local avec `docker compose`, un fichier `.env` (gitignore) a la racine du repo fonctionne aussi : + +``` +AUTH_USERNAME=nico +AUTH_PASSWORD_HASH=$2a$10$... +``` + ## 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. +- `HEALTHCHECK` integre au Dockerfile (`curl` sur `/health`, route publique non authentifiee) : + 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 : @@ -40,8 +71,9 @@ Le repo est pret pour un deploiement Coolify via webhook (push sur `main` -> bui 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`. +- Variables d'environnement a definir dans Coolify : `AUTH_USERNAME` / `AUTH_PASSWORD_HASH` + (obligatoire, voir "Authentification" plus haut), et optionnellement `MAX_UPLOAD_MB`, + `STATS_SAMPLE_CAP`, `CC_TIMEOUT_MS` si besoin de changer les defauts (voir tableau plus bas). - Le webhook de deploiement automatique est deja configure cote Coolify sur ce repo. ## Utilisation @@ -71,6 +103,8 @@ Le repo est pret pour un deploiement Coolify via webhook (push sur `main` -> bui |---|---|---| | `PORT` | 3000 | port interne du serveur | | `DATA_DIR` | `/data` | dossier de persistance (uploads, resultats, sqlite) | +| `AUTH_USERNAME` | _(non defini = auth off)_ | nom d'utilisateur HTTP Basic | +| `AUTH_PASSWORD_HASH` | _(non defini = auth off)_ | hash bcrypt du mot de passe | | `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 | diff --git a/docker-compose.yml b/docker-compose.yml index be29c54..8cc5481 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,11 @@ services: - MAX_UPLOAD_MB=2048 - STATS_SAMPLE_CAP=50000 - CC_TIMEOUT_MS=600000 + # Auth HTTP Basic (UI + API). Non definies = auth desactivee (dev local). Sur Coolify, + # definir ces deux variables dans l'onglet "Environment Variables" de l'application (pas ici, + # pour ne pas committer le hash dans le repo). Voir README pour generer AUTH_PASSWORD_HASH. + - AUTH_USERNAME=${AUTH_USERNAME:-} + - AUTH_PASSWORD_HASH=${AUTH_PASSWORD_HASH:-} restart: unless-stopped volumes: diff --git a/package-lock.json b/package-lock.json index 4647492..f50f43a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@nestjs/platform-express": "^10.4.15", "@nestjs/serve-static": "^4.0.2", "archiver": "^7.0.1", + "bcryptjs": "^2.4.3", "better-sqlite3": "^11.8.1", "multer": "^1.4.5-lts.1", "reflect-metadata": "^0.2.2", @@ -23,6 +24,7 @@ "@eslint/js": "^9.18.0", "@nestjs/cli": "^10.4.9", "@types/archiver": "^6.0.3", + "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", @@ -2142,6 +2144,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -3442,6 +3451,12 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, "node_modules/better-sqlite3": { "version": "11.10.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", diff --git a/package.json b/package.json index 6961e8d..6778c39 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@nestjs/platform-express": "^10.4.15", "@nestjs/serve-static": "^4.0.2", "archiver": "^7.0.1", + "bcryptjs": "^2.4.3", "better-sqlite3": "^11.8.1", "multer": "^1.4.5-lts.1", "reflect-metadata": "^0.2.2", @@ -29,6 +30,7 @@ "@nestjs/cli": "^10.4.9", "@types/archiver": "^6.0.3", "@types/better-sqlite3": "^7.6.13", + "@types/bcryptjs": "^2.4.6", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", "@types/multer": "^1.4.12", diff --git a/public/app.js b/public/app.js index fc3f27b..33de571 100644 --- a/public/app.js +++ b/public/app.js @@ -163,6 +163,7 @@ function levelRow(l, id) { ${l.level} ${l.spatialStep !== null ? fmt(l.spatialStep, 5) + ' m' : 'complet'} + ${l.gridStep !== null ? fmt(l.gridStep, 5) + ' m' : '-'} ${l.status === 'ok' ? l.pointCount.toLocaleString('fr-FR') : '-'} ${l.status === 'ok' ? fmt(l.pointRatio * 100, 2) + ' %' : '-'} ${l.status === 'ok' ? fmt(l.volume, 4) + ' m3' : '-'} @@ -179,7 +180,7 @@ function paintDetail(run) { - + @@ -192,7 +193,7 @@ function paintDetail(run) {
Zref
${fmt(r.params.zref, 4)} (${r.params.zrefSource})
Pas initial
${fmt(r.params.initialStep, 5)} m (${r.params.initialStepSource})
-
Grille volume
${fmt(r.params.gridStep, 5)} m
+
Grille volume
x${r.params.gridStepMultiplier} le pas spatial, adaptee a chaque niveau
Facteur / etapes
x${r.params.factor} sur ${r.params.steps}
` : ''; diff --git a/src/app.module.ts b/src/app.module.ts index 1c75b41..da6f359 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,15 +1,17 @@ import { Module } from '@nestjs/common'; import { ServeStaticModule } from '@nestjs/serve-static'; import * as path from 'path'; +import { HealthController } from './health.controller'; import { RunsModule } from './runs/runs.module'; @Module({ imports: [ ServeStaticModule.forRoot({ rootPath: path.resolve(__dirname, '..', 'public'), - exclude: ['/api*'], + exclude: ['/api*', '/health'], }), RunsModule, ], + controllers: [HealthController], }) export class AppModule {} diff --git a/src/auth/basic-auth.middleware.spec.ts b/src/auth/basic-auth.middleware.spec.ts new file mode 100644 index 0000000..01c8c3c --- /dev/null +++ b/src/auth/basic-auth.middleware.spec.ts @@ -0,0 +1,107 @@ +import * as bcrypt from 'bcryptjs'; +import { config } from '../config'; +import { basicAuthMiddleware } from './basic-auth.middleware'; + +function fakeReqRes(path: string, authHeader?: string) { + const req: any = { path, headers: authHeader ? { authorization: authHeader } : {} }; + const res: any = { + statusCode: 200, + headers: {} as Record, + setHeader(name: string, value: string) { + this.headers[name] = value; + }, + status(code: number) { + this.statusCode = code; + return this; + }, + send(_body?: string) { + return this; + }, + }; + return { req, res }; +} + +function basicHeader(user: string, pass: string): string { + return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64'); +} + +describe('basicAuthMiddleware', () => { + const originalUsername = config.authUsername; + const originalHash = config.authPasswordHash; + const passwordHash = bcrypt.hashSync('blidulphe', 4); // cost bas pour des tests rapides + + afterEach(() => { + (config as any).authUsername = originalUsername; + (config as any).authPasswordHash = originalHash; + }); + + it('laisse passer toutes les requetes si AUTH_USERNAME/AUTH_PASSWORD_HASH ne sont pas definis', () => { + (config as any).authUsername = null; + (config as any).authPasswordHash = null; + const middleware = basicAuthMiddleware(); + const { req, res } = fakeReqRes('/'); + const next = jest.fn(); + middleware(req, res, next); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('laisse toujours passer /health, meme sans authentification', () => { + (config as any).authUsername = 'nico'; + (config as any).authPasswordHash = passwordHash; + const middleware = basicAuthMiddleware(); + const { req, res } = fakeReqRes('/health'); + const next = jest.fn(); + middleware(req, res, next); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('renvoie 401 si aucun header Authorization n\'est fourni', () => { + (config as any).authUsername = 'nico'; + (config as any).authPasswordHash = passwordHash; + const middleware = basicAuthMiddleware(); + const { req, res } = fakeReqRes('/'); + const next = jest.fn(); + middleware(req, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(401); + expect(res.headers['WWW-Authenticate']).toMatch(/Basic/); + }); + + it('renvoie 401 pour un mauvais mot de passe', (done) => { + (config as any).authUsername = 'nico'; + (config as any).authPasswordHash = passwordHash; + const middleware = basicAuthMiddleware(); + const { req, res } = fakeReqRes('/', basicHeader('nico', 'mauvais-mdp')); + const next = jest.fn(); + middleware(req, res, next); + setImmediate(() => { + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(401); + done(); + }); + }); + + it('renvoie 401 pour un mauvais utilisateur', () => { + (config as any).authUsername = 'nico'; + (config as any).authPasswordHash = passwordHash; + const middleware = basicAuthMiddleware(); + const { req, res } = fakeReqRes('/', basicHeader('quelquun-dautre', 'blidulphe')); + const next = jest.fn(); + middleware(req, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(401); + }); + + it('laisse passer avec les bons identifiants', (done) => { + (config as any).authUsername = 'nico'; + (config as any).authPasswordHash = passwordHash; + const middleware = basicAuthMiddleware(); + const { req, res } = fakeReqRes('/', basicHeader('nico', 'blidulphe')); + const next = jest.fn(); + middleware(req, res, next); + setImmediate(() => { + expect(next).toHaveBeenCalledTimes(1); + done(); + }); + }); +}); diff --git a/src/auth/basic-auth.middleware.ts b/src/auth/basic-auth.middleware.ts new file mode 100644 index 0000000..1e1583d --- /dev/null +++ b/src/auth/basic-auth.middleware.ts @@ -0,0 +1,67 @@ +import { Logger } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import type { NextFunction, Request, Response } from 'express'; +import { config } from '../config'; + +const logger = new Logger('BasicAuth'); + +// Routes accessibles sans authentification (healthcheck Docker/Coolify). +const PUBLIC_PATHS = new Set(['/health']); + +/** + * Middleware HTTP Basic Auth applique a TOUTE l'application (UI statique + API), pour eviter + * d'exposer le pipeline sans authentification. Identifiants dans AUTH_USERNAME / AUTH_PASSWORD_HASH + * (hash bcrypt) - voir README pour generer un nouveau hash. Si l'une des deux variables est absente, + * l'auth est desactivee (dev local uniquement) avec un warning au demarrage. + */ +export function basicAuthMiddleware() { + const { authUsername, authPasswordHash } = config; + const enabled = Boolean(authUsername && authPasswordHash); + + if (!enabled) { + logger.warn('AUTH_USERNAME / AUTH_PASSWORD_HASH non definis : authentification DESACTIVEE.'); + } + + return (req: Request, res: Response, next: NextFunction) => { + if (!enabled || PUBLIC_PATHS.has(req.path)) { + next(); + return; + } + + const deny = () => { + res.setHeader('WWW-Authenticate', 'Basic realm="volume-app", charset="UTF-8"'); + res.status(401).send('Authentification requise'); + }; + + const header = req.headers.authorization; + if (!header || !header.startsWith('Basic ')) { + deny(); + return; + } + + let user: string; + let pass: string; + try { + const decoded = Buffer.from(header.slice('Basic '.length), 'base64').toString('utf-8'); + const idx = decoded.indexOf(':'); + user = decoded.slice(0, idx); + pass = decoded.slice(idx + 1); + } catch { + deny(); + return; + } + + if (user !== authUsername) { + deny(); + return; + } + + bcrypt + .compare(pass, authPasswordHash as string) + .then((ok) => { + if (ok) next(); + else deny(); + }) + .catch(() => deny()); + }; +} diff --git a/src/config.ts b/src/config.ts index a338c83..11e2af9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,8 +17,8 @@ export const config = { 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. + // 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, // En dessous de ce nombre de points, on arrete la decimation progressive (cloud devenu inexploitable) minPointsToContinue: 25, @@ -29,6 +29,10 @@ export const config = { maxUploadBytes: envInt('MAX_UPLOAD_MB', 2048) * 1024 * 1024, // Timeout par appel CloudCompare CLI (ms) ccTimeoutMs: envInt('CC_TIMEOUT_MS', 10 * 60 * 1000), + // Auth HTTP Basic (UI + API). Si l'une des deux variables est absente, l'auth est desactivee + // (pratique en dev local) - un warning est logge au demarrage dans ce cas, voir main.ts. + authUsername: process.env.AUTH_USERNAME ?? null, + authPasswordHash: process.env.AUTH_PASSWORD_HASH ?? null, }; export const uploadsDir = () => path.join(config.dataDir, 'uploads'); diff --git a/src/health.controller.ts b/src/health.controller.ts new file mode 100644 index 0000000..22cc0ca --- /dev/null +++ b/src/health.controller.ts @@ -0,0 +1,9 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { status: 'ok' }; + } +} diff --git a/src/main.ts b/src/main.ts index e16c376..9701456 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,11 +2,13 @@ import 'reflect-metadata'; import { NestFactory } from '@nestjs/core'; import { Logger } from '@nestjs/common'; import { AppModule } from './app.module'; +import { basicAuthMiddleware } from './auth/basic-auth.middleware'; import { config } from './config'; async function bootstrap() { const app = await NestFactory.create(AppModule, { bodyParser: true }); app.enableCors(); + app.use(basicAuthMiddleware()); 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})`); } diff --git a/src/pipeline/pipeline.service.ts b/src/pipeline/pipeline.service.ts index cc64c46..55656e8 100644 --- a/src/pipeline/pipeline.service.ts +++ b/src/pipeline/pipeline.service.ts @@ -73,11 +73,16 @@ export class PipelineService { const initialStep = params.initialStepOverride ?? stats.median_nn; const zref = params.zrefOverride ?? stats.bbox.zmin; - const gridStep = initialStep * config.gridStepMultiplier; + // La grille de calcul du volume est adaptee dynamiquement a CHAQUE niveau (grid_step = pas + // spatial du niveau x multiplicateur), plutot que figee sur la resolution du niveau 0. Une + // grille fixe forcait des cellules vides des que la decimation depassait la resolution de + // depart (matching cells % s'effondrait des le niveau 1) - voir CLAUDE.md. + const level0GridStep = initialStep * config.gridStepMultiplier; onProgress( `Pas initial=${initialStep.toFixed(5)}m (${params.initialStepOverride ? 'override' : 'auto, mediane NN'}), ` + - `grille volume=${gridStep.toFixed(5)}m, Zref=${zref.toFixed(4)} (${params.zrefOverride ? 'override' : 'auto, Zmin echantillon'}). ` + + `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]`, ); @@ -94,7 +99,7 @@ export class PipelineService { imagesDir, rawDir, workDir, - gridStep, + gridStep: level0GridStep, zref, refPointCount: totalPointCount, onProgress, @@ -120,7 +125,10 @@ export class PipelineService { } const spatialStep = initialStep * Math.pow(config.decimationFactor, i); - onProgress(`Niveau ${i} : decimation spatiale (pas=${spatialStep.toFixed(5)}m) depuis le niveau ${i - 1}...`); + const levelGridStep = spatialStep * config.gridStepMultiplier; + onProgress( + `Niveau ${i} : decimation spatiale (pas=${spatialStep.toFixed(5)}m, grille volume=${levelGridStep.toFixed(5)}m) depuis le niveau ${i - 1}...`, + ); try { const lvl = await this.computeLevel({ @@ -132,7 +140,7 @@ export class PipelineService { imagesDir, rawDir, workDir, - gridStep, + gridStep: levelGridStep, zref, refPointCount: totalPointCount, onProgress, @@ -150,6 +158,7 @@ export class PipelineService { levels.push({ level: i, spatialStep, + gridStep: levelGridStep, pointCount: 0, pointRatio: 0, volume: null, @@ -196,7 +205,7 @@ export class PipelineService { zrefSource: params.zrefOverride !== undefined ? 'override' : 'auto', initialStep, initialStepSource: params.initialStepOverride !== undefined ? 'override' : 'auto', - gridStep, + gridStepMultiplier: config.gridStepMultiplier, factor: config.decimationFactor, steps: config.decimationSteps, sampleStats: { @@ -228,6 +237,7 @@ export class PipelineService { return { level, spatialStep: null, + gridStep: null, pointCount: 0, pointRatio: 0, volume: null, @@ -279,7 +289,7 @@ export class PipelineService { 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); + return this.errorLevel(level, spatialStep, `Conversion niveau 0 echouee. Sortie:\n${res.stdout.slice(-1500)}`, t0, null, gridStep); } pointCount = n; await this.assertExists(path.join(workDir, binRelPath), 'conversion niveau 0', res.stdout); @@ -296,6 +306,7 @@ export class PipelineService { return { level, spatialStep, + gridStep: opts.gridStep, pointCount: 0, pointRatio: 0, volume: null, @@ -326,7 +337,7 @@ export class PipelineService { ); 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); + 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`); @@ -362,6 +373,7 @@ export class PipelineService { return { level, spatialStep, + gridStep, pointCount, pointRatio: refPointCount > 0 ? pointCount / refPointCount : 0, volume: vol.volume, @@ -379,10 +391,18 @@ export class PipelineService { }; } - private errorLevel(level: number, spatialStep: number | null, message: string, t0: number, binFile: string | null = null): LevelResult { + private errorLevel( + level: number, + spatialStep: number | null, + message: string, + t0: number, + binFile: string | null = null, + gridStep: number | null = null, + ): LevelResult { return { level, spatialStep, + gridStep, pointCount: 0, pointRatio: 0, volume: null, @@ -410,6 +430,7 @@ export function reportToCsv(report: RunReport): string { const headers = [ 'level', 'spatialStep', + 'gridStep', 'pointCount', 'pointRatio', 'volume', @@ -428,6 +449,7 @@ export function reportToCsv(report: RunReport): string { [ l.level, l.spatialStep ?? '', + l.gridStep ?? '', l.pointCount, l.pointRatio, l.volume ?? '', diff --git a/src/pipeline/pipeline.util.spec.ts b/src/pipeline/pipeline.util.spec.ts index 3301f1c..30e5e08 100644 --- a/src/pipeline/pipeline.util.spec.ts +++ b/src/pipeline/pipeline.util.spec.ts @@ -30,7 +30,7 @@ describe('reportToCsv', () => { zrefSource: 'auto', initialStep: 0.02, initialStepSource: 'auto', - gridStep: 0.04, + gridStepMultiplier: 2, 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 } }, @@ -39,6 +39,7 @@ describe('reportToCsv', () => { { level: 0, spatialStep: null, + gridStep: 0.04, pointCount: 1000, pointRatio: 1, volume: 12.5, @@ -57,6 +58,7 @@ describe('reportToCsv', () => { { level: 1, spatialStep: 0.04, + gridStep: 0.08, pointCount: 0, pointRatio: 0, volume: null, @@ -82,7 +84,7 @@ describe('reportToCsv', () => { 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', + 'level,spatialStep,gridStep,pointCount,pointRatio,volume,surface,addedVolume,removedVolume,matchingCellsPct,groundNonMatchingPct,ceilNonMatchingPct,status,warn,computeTimeMs,message', ); }); @@ -91,7 +93,7 @@ describe('reportToCsv', () => { 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(','); + const messageField = lines[2].split(',').slice(15).join(','); expect(messageField).not.toMatch(/,/); }); diff --git a/src/pipeline/types.ts b/src/pipeline/types.ts index 4a0a118..df3b074 100644 --- a/src/pipeline/types.ts +++ b/src/pipeline/types.ts @@ -1,6 +1,7 @@ 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 volume: number | null; @@ -32,7 +33,7 @@ export interface RunReport { zrefSource: 'override' | 'auto'; initialStep: number; initialStepSource: 'override' | 'auto'; - gridStep: number; + gridStepMultiplier: number; factor: number; steps: number; sampleStats: {
NiveauPas spatialPointsNiveauPas spatialGrille volumePoints % points vs L0VolumeSurface Matching cellsStatut