Auth HTTP Basic + grille de volume adaptative par niveau

- Grille de calcul du volume adaptee dynamiquement au pas spatial de chaque niveau (au lieu d'une
  grille fixe derivee du niveau 0) : evite l'effondrement artificiel du "matching cells %" observe
  des le premier niveau de decimation (99% -> 58% -> 13% -> 3% -> 0.9% -> 0.2%), qui refletait un
  desalignement grille/densite plutot qu'une vraie perte d'information.
- Authentification HTTP Basic sur toute l'app (UI + API), identifiants en env (AUTH_USERNAME /
  AUTH_PASSWORD_HASH hashe bcrypt), desactivable en dev local si non definis. /health reste public
  pour le healthcheck Docker/Coolify.
This commit is contained in:
Nicolas Fryder 2026-07-10 14:34:27 +02:00
parent 79daa30a8a
commit 7f2e4c323b
17 changed files with 313 additions and 30 deletions

1
.gitignore vendored
View file

@ -5,3 +5,4 @@ data/
.DS_Store
coverage/
.claude/
.env

View file

@ -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.

View file

@ -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"]

View file

@ -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 |

View file

@ -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:

15
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -163,6 +163,7 @@ function levelRow(l, id) {
<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.gridStep !== null ? fmt(l.gridStep, 5) + ' m' : '-'}</td>
<td class="py-2 px-3">${l.status === 'ok' ? l.pointCount.toLocaleString('fr-FR') : '-'}</td>
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.pointRatio * 100, 2) + ' %' : '-'}</td>
<td class="py-2 px-3">${l.status === 'ok' ? fmt(l.volume, 4) + ' m3' : '-'}</td>
@ -179,7 +180,7 @@ function paintDetail(run) {
<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">Niveau</th><th class="py-2 px-3">Pas spatial</th><th class="py-2 px-3">Grille volume</th><th class="py-2 px-3">Points</th>
<th class="py-2 px-3">% points vs L0</th><th class="py-2 px-3">Volume</th><th class="py-2 px-3">Surface</th>
<th class="py-2 px-3">Matching cells</th><th class="py-2 px-3">Statut</th>
</tr>
@ -192,7 +193,7 @@ function paintDetail(run) {
<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">Grille volume</dt><dd>x${r.params.gridStepMultiplier} le pas spatial, adaptee a chaque niveau</dd></div>
<div class="bg-slate-900 border border-slate-800 rounded-lg p-3"><dt class="text-slate-400 text-xs">Facteur / etapes</dt><dd>x${r.params.factor} sur ${r.params.steps}</dd></div>
</dl>` : '';

View file

@ -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 {}

View file

@ -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<string, string>,
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();
});
});
});

View file

@ -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());
};
}

View file

@ -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');

9
src/health.controller.ts Normal file
View file

@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return { status: 'ok' };
}
}

View file

@ -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})`);
}

View file

@ -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 ?? '',

View file

@ -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(/,/);
});

View file

@ -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: {