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) {
- | Niveau | Pas spatial | Points |
+ Niveau | Pas spatial | Grille volume | Points |
% points vs L0 | Volume | Surface |
Matching cells | Statut |
@@ -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: {