diff --git a/CLAUDE.md b/CLAUDE.md
index 5f5fec6..633f41f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -88,10 +88,27 @@ E57 volontairement hors perimetre.
fournir qu'UN SEUL nom de fichier a `-SAVE_CLOUDS FILE`.
- Le nuage "comble" est un nuage-grille (un point par cellule non vide, ex: 39971 points pour une
grille 201x201 avec quelques cellules hors de l'enveloppe convexe) — PAS le nuage original avec des
- points ajoutes. On le garde uniquement pour le calcul de volume (`raw/L{n}_filled.bin`) ; le nuage
- BRUT (non comble) reste utilise pour l'export, les cartes de hauteur et le chainage de decimation,
- afin de montrer les vrais trous de scan et ne pas faire chainer l'interpolation d'un niveau vers
- le suivant (qui composerait l'erreur).
+ points ajoutes. On le garde uniquement pour le calcul de volume et la carte de comparaison
+ (`raw/L{n}_filled.xyz`, en **ASCII** — pas `.bin` : `-VOLUME` lit l'ASCII nativement, et ca evite
+ un aller-retour BIN inutile puisqu'on a de toute facon besoin d'un export ASCII pour la carte de
+ comparaison Python). Le nuage BRUT (non comble, en `.bin`) reste utilise pour l'assemblage final et
+ le chainage de decimation, afin de ne pas faire chainer l'interpolation d'un niveau vers le suivant
+ (qui composerait l'erreur).
+ - **Toutes les strategies `-EMPTY_FILL` de CloudCompare sont exposees dans l'UI** (mirroir de la
+ boite de dialogue "Compute Volume" du GUI), pas seulement INTERP : `LEAVE_EMPTY` (aucun flag),
+ `MIN_H`/`MAX_H` (hauteur min/max des cellules voisines), `CUSTOM_H` (necessite `-CUSTOM_HEIGHT
+ Les densites des deux nuages sont d'abord appariees (le plus dense est decime pour matcher l'autre), puis les deux sont decimes ensemble a chaque etape. Trop grand = comble aussi les creux du contour de l'amas (a eviter). Trop petit = les trous de scan restent vides. % abs = valeur / niveau 0 · % relatif = ecart vs niveau 0 (+ ou -)${new Date(r.createdAt).toLocaleString('fr-FR')}
${r.summary ? `${r.summary.levelsOk}/${r.summary.levelsTotal} niveaux` : '-'}
${r.summary && r.summary.volumeDeltaPct !== null ? fmt(r.summary.volumeDeltaPct, 2) + ' %' : '-'}
+
+
+
`).join('');
app.innerHTML = `
@@ -81,6 +91,7 @@ async function renderList() {
${rows}
@@ -88,6 +99,16 @@ async function renderList() {
`;
}
+async function deleteRun(id) {
+ if (!confirm('Supprimer definitivement ce run (nuages, images, rapports) ?')) return;
+ try {
+ await api(`${API}/${id}`, { method: 'DELETE' });
+ renderList();
+ } catch (err) {
+ alert(`Echec de la suppression : ${err.message}`);
+ }
+}
+
// ---------------- Nouveau run ----------------
function renderNew() {
app.innerHTML = `
@@ -128,11 +149,11 @@ function renderNew() {
ID Fichier Statut
Cree le Niveaux OK Ecart volume (L0 -> dernier)
+
Options avancees (optionnel)
- Parametres CloudCompare (mirroir de la boite "Compute Volume")
+
${fmt(abs, 1)}% abs · ${fmtPct(rel, 1)}`;
+}
+
+function levelRow(l, l0) {
const warnCls = l.status === 'error' ? 'bg-red-950/40' : l.warn ? 'bg-amber-950/30' : '';
const statusTxt = l.status === 'ok' ? '' : `${l.level}
${stepCell(l)}
${l.gridStep !== null ? fmt(l.gridStep, 5) + ' m' : '-'}
- ${l.maxEdgeLength !== null ? fmt(l.maxEdgeLength, 5) + ' m' : '-'}
${pointsCell(l)}
- ${l.status === 'ok' ? fmt(l.volume, 4) + ' m3' : '-'}
- ${l.status === 'ok' ? fmt(l.surface, 3) + ' m2' : '-'}
- ${l.status === 'ok' ? fmt(l.matchingCellsPct, 1) + ' %' : '-'}
- ${statusBadge(l.status)} ${l.warn && l.status === 'ok' ? 'a verifier' : ''}${statusTxt}
+ ${l.status === 'ok' ? metricCell(l.volume, l0 && l0.volume, 'm3', 4) : '-'}
+ ${l.status === 'ok' ? metricCell(l.addedVolume, l0 && l0.addedVolume, 'm3', 4) : '-'}
+ ${l.status === 'ok' ? metricCell(l.removedVolume, l0 && l0.removedVolume, 'm3', 4) : '-'}
+ ${l.status === 'ok' ? metricCell(l.surface, l0 && l0.surface, 'm2', 3) : '-'}
+ ${statusBadge(l.status)}${statusTxt}
`;
}
function paintDetail(run) {
const r = run.report;
const isCompare = r && r.mode === 'cloud_compare';
+ const l0 = r ? r.levels.find((l) => l.level === 0 && l.status === 'ok') : null;
const levelsTable = r ? `
- ${r.levels.map((l) => levelRow(l)).join('')}
+ ${r.levels.map((l) => levelRow(l, l0)).join('')}
+ Niveau Pas spatial Grille volume
- Max edge (remplissage) Points
- Volume Surface
- Matching cells Statut
+ Points
+ Volume Volume ajoute Volume retire
+ Surface Statut
Niveau ${level}${c.role !== 'unique' ? ' - ' + roleLabel(c.role) : ''}
+Niveau ${l.level}
${escapeHtml(run.log || '')}
diff --git a/python/render_images.py b/python/render_images.py
index 35fb4c1..4070450 100644
--- a/python/render_images.py
+++ b/python/render_images.py
@@ -1,15 +1,17 @@
#!/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.
+ - "diffmap" : construit UNE carte de comparaison (grille 2.5D, degrade bleu->rouge façon
+ CloudCompare) entre le nuage du dessus (ceil) et soit un plan Z constant, soit le
+ nuage du dessous (ground). Le paquet apt CloudCompare (Debian) plante sur l'export
+ GeoTIFF (assertion GDAL manquante) - on reconstruit donc la grille nous-memes a
+ partir d'exports ASCII, plutot que de dependre du rasterizer CloudCompare.
+ - "summary" : genere les graphiques de synthese (volume/points 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 diffmap --ceil top.xyz --ground bottom.xyz --gridstep 0.05 --output diff.png --title "Niveau 0"
+ python3 render_images.py diffmap --ceil cloud.xyz --const-height 100.0 --gridstep 0.05 --output diff.png --title "Niveau 0"
python3 render_images.py summary --report report.json --outdir images/
"""
import argparse
@@ -25,37 +27,64 @@ import numpy as np
from scipy.stats import binned_statistic_2d
-def cmd_heightmap(args: argparse.Namespace) -> int:
+def _load_xyz(path: str) -> np.ndarray:
+ pts = np.loadtxt(path, dtype=np.float64, usecols=(0, 1, 2))
+ if pts.ndim == 1:
+ pts = pts.reshape(1, -1)
+ return pts
+
+
+def _grid_edges(xmin: float, xmax: float, ymin: float, ymax: float, step: float):
+ nx = max(2, int(np.ceil((xmax - xmin) / step)) + 1) if xmax > xmin else 2
+ ny = max(2, int(np.ceil((ymax - ymin) / step)) + 1) if ymax > ymin else 2
+ nx, ny = min(nx, 2000), min(ny, 2000) # borne pour eviter une image demesuree
+ return np.linspace(xmin, xmax, nx), np.linspace(ymin, ymax, ny)
+
+
+def cmd_diffmap(args: argparse.Namespace) -> int:
try:
- pts = np.loadtxt(args.input, dtype=np.float64, usecols=(0, 1, 2))
+ ceil_pts = _load_xyz(args.ceil)
+ ground_pts = _load_xyz(args.ground) if args.ground else None
except Exception as exc:
print(json.dumps({"error": f"lecture xyz impossible: {exc}"}))
return 1
- if pts.ndim == 1:
- pts = pts.reshape(1, -1)
+ if args.ground is None and args.const_height is None:
+ print(json.dumps({"error": "il faut soit --ground, soit --const-height"}))
+ return 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)
+ xmin = float(np.min(ceil_pts[:, 0]))
+ xmax = float(np.max(ceil_pts[:, 0]))
+ ymin = float(np.min(ceil_pts[:, 1]))
+ ymax = float(np.max(ceil_pts[:, 1]))
+ if ground_pts is not None:
+ xmin = min(xmin, float(np.min(ground_pts[:, 0])))
+ xmax = max(xmax, float(np.max(ground_pts[:, 0])))
+ ymin = min(ymin, float(np.min(ground_pts[:, 1])))
+ ymax = max(ymax, float(np.max(ground_pts[:, 1])))
- 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():
+ fig, ax = plt.subplots(figsize=(6.4, 5.2), dpi=130)
+ if xmax <= xmin or ymax <= ymin:
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)
+ xedges, yedges = _grid_edges(xmin, xmax, ymin, ymax, args.gridstep)
+ ceil_stat, _, _, _ = binned_statistic_2d(ceil_pts[:, 0], ceil_pts[:, 1], ceil_pts[:, 2], statistic="mean", bins=[xedges, yedges])
+ if ground_pts is not None:
+ ground_stat, _, _, _ = binned_statistic_2d(ground_pts[:, 0], ground_pts[:, 1], ground_pts[:, 2], statistic="mean", bins=[xedges, yedges])
+ diff = (ceil_stat - ground_stat).T
+ else:
+ diff = (ceil_stat - args.const_height).T
+
+ if not np.isfinite(diff).any():
+ ax.text(0.5, 0.5, "Aucune donnee exploitable", ha="center", va="center")
+ else:
+ cmap = plt.get_cmap("jet").copy()
+ cmap.set_bad("#e5e7eb")
+ im = ax.imshow(diff, cmap=cmap, origin="lower", extent=[xmin, xmax, ymin, ymax], aspect="auto")
+ cbar = fig.colorbar(im, ax=ax, shrink=0.85)
+ cbar.set_label("Ecart de hauteur (m)")
+
+ ax.set_title(args.title or Path(args.ceil).stem)
ax.set_xlabel("X (m)")
ax.set_ylabel("Y (m)")
fig.tight_layout()
@@ -79,8 +108,9 @@ def cmd_summary(args: argparse.Namespace) -> int:
xs = [lvl["level"] for lvl in levels]
volumes = [lvl["volume"] for lvl in levels]
- matching = [lvl["matchingCellsPct"] for lvl in levels]
+ surfaces = [lvl["surface"] for lvl in levels]
v0 = volumes[0] if volumes else None
+ s0 = surfaces[0] if surfaces else None
# En mode cloud_compare chaque niveau a 2 nuages (top/bottom) ; en mode const_height, 1 seul.
point_series = {}
@@ -117,22 +147,21 @@ def cmd_summary(args: argparse.Namespace) -> int:
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)
+ # --- Ecart relatif volume + surface vs niveau 0 ---
+ fig, ax = 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")
+ delta_v = [100.0 * (v - v0) / v0 for v in volumes]
+ ax.plot(xs, delta_v, marker="o", color="#dc2626", label="Volume")
+ if s0:
+ delta_s = [100.0 * (s - s0) / s0 for s in surfaces]
+ ax.plot(xs, delta_s, marker="s", linestyle="--", color="#0ea5e9", label="Surface")
+ ax.set_xlabel("Niveau de decimation")
+ ax.set_ylabel("Ecart vs niveau 0 (%)")
+ ax.set_title("Robustesse : ecart de volume et de surface vs niveau 0")
+ ax.set_xticks(xs)
+ ax.grid(alpha=0.3)
+ ax.axhline(0, color="#94a3b8", linewidth=1)
+ ax.legend()
fig.tight_layout()
fig.savefig(outdir / "robustness_vs_level.png")
plt.close(fig)
@@ -145,12 +174,14 @@ 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_diffmap = sub.add_parser("diffmap")
+ p_diffmap.add_argument("--ceil", required=True)
+ p_diffmap.add_argument("--ground", default=None)
+ p_diffmap.add_argument("--const-height", dest="const_height", default=None, type=float)
+ p_diffmap.add_argument("--gridstep", required=True, type=float)
+ p_diffmap.add_argument("--output", required=True)
+ p_diffmap.add_argument("--title", default=None)
+ p_diffmap.set_defaults(func=cmd_diffmap)
p_summary = sub.add_parser("summary")
p_summary.add_argument("--report", required=True)
diff --git a/src/config.ts b/src/config.ts
index e23b210..16e9fa4 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -25,6 +25,12 @@ export const config = {
// du contour de l'amas (qui doivent rester "vides") tout en bouchant les petits trous de scan.
// Voir CLAUDE.md : -VOLUME n'a PAS d'option de remplissage native, on passe par -RASTERIZE.
maxEdgeLengthMultiplier: 3,
+ // Defauts des parametres CloudCompare exposes dans l'UI (mirroir de la boite de dialogue
+ // "Compute Volume" du GUI) - tous overridables par run, voir RunParamsInput.
+ emptyCellStrategyDefault: 'interpolate' as const,
+ projectionTypeDefault: 'avg' as const,
+ vertDirDefault: 2 as const,
+ krigingKnnDefault: 8,
// 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,
diff --git a/src/pipeline/pipeline.service.ts b/src/pipeline/pipeline.service.ts
index 7528cc3..4c3e4a9 100644
--- a/src/pipeline/pipeline.service.ts
+++ b/src/pipeline/pipeline.service.ts
@@ -4,7 +4,7 @@ import * as path from 'path';
import { config } from '../config';
import { CcRunnerService } from '../cloudcompare/cc-runner.service';
import { PyHelperService, CloudStats } from '../pyhelper/pyhelper.service';
-import { CloudRole, LevelCloudInfo, LevelResult, RunParamsInput, RunReport } from './types';
+import { CloudRole, EmptyCellStrategy, LevelCloudInfo, LevelResult, ProjectionType, RunParamsInput, RunReport, VertDir } from './types';
export function sanitizeBaseName(filename: string): string {
const base = filename.replace(/\.[^./\\]+$/, '');
@@ -26,18 +26,36 @@ interface CloudPrepResult {
info: LevelCloudInfo;
/** Chemin absolu du nuage brut (non rempli), utilise pour chainer la decimation suivante */
rawBinAbs: string | null;
- /** Chemin absolu du nuage avec trous combles (INTERP), utilise pour -VOLUME */
- filledBinAbs: string | null;
+ /** Chemin absolu (ASCII XYZ) du nuage avec trous combles, utilise pour -VOLUME et la carte de comparaison */
+ filledXyzAbs: string | null;
+}
+
+/** Convertit une strategie de remplissage en arguments CLI CloudCompare (-EMPTY_FILL ...). */
+function emptyFillArgs(strategy: EmptyCellStrategy, customHeightValue: number | undefined, krigingKnn: number, maxEdgeLength: number): string[] {
+ switch (strategy) {
+ case 'leave_empty':
+ return [];
+ case 'min_height':
+ return ['-EMPTY_FILL', 'MIN_H'];
+ case 'max_height':
+ return ['-EMPTY_FILL', 'MAX_H'];
+ case 'custom_height':
+ return ['-EMPTY_FILL', 'CUSTOM_H', '-CUSTOM_HEIGHT', String(customHeightValue ?? 0)];
+ case 'interpolate':
+ return ['-EMPTY_FILL', 'INTERP', '-MAX_EDGE_LENGTH', String(maxEdgeLength)];
+ case 'kriging':
+ return ['-EMPTY_FILL', 'KRIGING', '-KRIGING_KNN', String(krigingKnn)];
+ }
}
/**
* Orchestration complete d'un run. Deux modes :
- * - const_height : 1 nuage, compare a un plan Z constant (comme avant).
+ * - const_height : 1 nuage, compare a un plan Z constant.
* - cloud_compare : 2 nuages (surface du haut vs limite du bas), densites appariees au niveau 0
* puis decimation synchronisee x2 sur 5 etapes, compares l'un a l'autre a chaque niveau.
- * Dans les deux cas, les trous de scan sont combles (interpolation Delaunay, bornee par
- * max-edge-length) avant chaque calcul de volume - voir CLAUDE.md, -VOLUME n'a pas cette option
- * nativement, on passe par -RASTERIZE en amont.
+ * Dans les deux cas, les trous de scan sont combles (strategie configurable, mirroir de la boite de
+ * dialogue "Compute Volume" de CloudCompare) avant chaque calcul de volume - voir CLAUDE.md,
+ * -VOLUME n'a pas ces options nativement, on passe par -RASTERIZE en amont.
*/
@Injectable()
export class PipelineService {
@@ -63,6 +81,12 @@ export class PipelineService {
await fs.mkdir(rawDir, { recursive: true });
const isCompare = params.mode === 'cloud_compare';
+ const gridStepMultiplier = params.gridStepMultiplierOverride ?? config.gridStepMultiplier;
+ const emptyCellStrategy: EmptyCellStrategy = params.emptyCellStrategy ?? config.emptyCellStrategyDefault;
+ const projectionType: ProjectionType = params.projectionType ?? config.projectionTypeDefault;
+ const vertDir: VertDir = params.vertDir ?? config.vertDirDefault;
+ const krigingKnn = params.krigingKnn ?? config.krigingKnnDefault;
+ const customHeightValue = params.customHeightValue;
// ---------- 0) Normalisation du/des format(s) d'entree ----------
const primaryNorm = await this.normalizeInput(inputs.primaryPath, workDir, 'primary', onProgress);
@@ -126,12 +150,12 @@ export class PipelineService {
// Niveau 0 (const_height) = nuage complet, jamais decime : spatialStep=null.
// Niveau 0 (cloud_compare) = pas apparie = initialStep (factor^0) ; niveaux suivants x2.
const spatialStep = !isCompare && i === 0 ? null : initialStep * Math.pow(config.decimationFactor, i);
- const gridStep = (spatialStep ?? initialStep) * config.gridStepMultiplier;
+ const gridStep = (spatialStep ?? initialStep) * gridStepMultiplier;
const maxEdgeLength = params.maxEdgeLengthOverride ?? gridStep * config.maxEdgeLengthMultiplier;
onProgress(
`Niveau ${i} : ${spatialStep === null ? 'nuage complet' : `decimation (pas=${spatialStep.toFixed(5)}m)`}, ` +
- `grille=${gridStep.toFixed(5)}m, remplissage trous (max edge=${maxEdgeLength.toFixed(5)}m)...`,
+ `grille=${gridStep.toFixed(5)}m, remplissage trous (${emptyCellStrategy}${emptyCellStrategy === 'interpolate' ? `, max edge=${maxEdgeLength.toFixed(5)}m` : ''})...`,
);
try {
@@ -146,11 +170,15 @@ export class PipelineService {
sourcePath,
isFirstConversion,
levelsDir,
- imagesDir,
rawDir,
workDir,
gridStep,
maxEdgeLength,
+ emptyCellStrategy,
+ customHeightValue,
+ krigingKnn,
+ projectionType,
+ vertDir,
refPointCount: refPointCount[role] ?? 0,
});
cloudResults.push(res);
@@ -170,6 +198,7 @@ export class PipelineService {
matchingCellsPct: null,
groundNonMatchingPct: null,
ceilNonMatchingPct: null,
+ diffMapImage: null,
warn: true,
status: anyBad.status,
message: anyBad.message,
@@ -185,7 +214,17 @@ export class PipelineService {
let volRes;
if (!isCompare) {
volRes = await this.cc.run(
- ['-O', this.relFrom(workDir, cloudResults[0].filledBinAbs!), '-VOLUME', '-GRID_STEP', String(gridStep), '-CONST_HEIGHT', String(zref)],
+ [
+ '-O',
+ this.relFrom(workDir, cloudResults[0].filledXyzAbs!),
+ '-VOLUME',
+ '-GRID_STEP',
+ String(gridStep),
+ '-CONST_HEIGHT',
+ String(zref),
+ '-VERT_DIR',
+ String(vertDir),
+ ],
workDir,
{ autoSave: true },
);
@@ -193,12 +232,14 @@ export class PipelineService {
volRes = await this.cc.run(
[
'-O',
- this.relFrom(workDir, cloudResults[0].filledBinAbs!), // top = ceil
+ this.relFrom(workDir, cloudResults[0].filledXyzAbs!), // top = ceil
'-O',
- this.relFrom(workDir, cloudResults[1].filledBinAbs!), // bottom = ground
+ this.relFrom(workDir, cloudResults[1].filledXyzAbs!), // bottom = ground
'-VOLUME',
'-GRID_STEP',
String(gridStep),
+ '-VERT_DIR',
+ String(vertDir),
],
workDir,
{ autoSave: true },
@@ -211,13 +252,29 @@ export class PipelineService {
const vol = await this.cc.parseVolumeReportFile(reportFile);
await fs.rename(reportFile, path.join(rawDir, `L${i}_volume_report.txt`));
- // AUTO_SAVE ON sauvegarde aussi une grille "*_HEIGHT_DIFFERENCE_*.bin" qu'on ne veut pas garder
+ // AUTO_SAVE ON sauvegarde aussi une grille "*_HEIGHT_DIFFERENCE_*.bin" qu'on ne veut pas
+ // garder (les nuages combles sont maintenant en ASCII, donc tout .bin frais ici est ce fichier)
const strayGrid = await this.cc.findLatestFile(workDir, { suffix: '.bin', after: beforeVolume });
- const filledPaths = new Set(cloudResults.map((r) => r.filledBinAbs));
- if (strayGrid && !filledPaths.has(strayGrid)) {
+ if (strayGrid) {
await fs.unlink(strayGrid).catch(() => undefined);
}
+ // ---------- Carte de comparaison (bleu->rouge, une seule image par niveau) ----------
+ let diffMapImage: string | null = null;
+ try {
+ const pngName = `L${i}_diffmap.png`;
+ const pngAbs = path.join(imagesDir, pngName);
+ const title = `Niveau ${i}` + (spatialStep === null ? ' (complet)' : ` (pas=${spatialStep.toFixed(4)}m)`);
+ if (!isCompare) {
+ await this.py.renderDiffmap(cloudResults[0].filledXyzAbs!, null, zref, gridStep, pngAbs, title);
+ } else {
+ await this.py.renderDiffmap(cloudResults[0].filledXyzAbs!, cloudResults[1].filledXyzAbs!, null, gridStep, pngAbs, title);
+ }
+ diffMapImage = path.posix.join('images', pngName);
+ } catch (err: any) {
+ this.logger.warn(`Carte de comparaison niveau ${i} non generee: ${err.message}`);
+ }
+
const warn = vol.matchingCellsPct < config.matchingCellsWarnThreshold;
levels.push({
@@ -232,6 +289,7 @@ export class PipelineService {
matchingCellsPct: vol.matchingCellsPct,
groundNonMatchingPct: vol.groundNonMatchingPct,
ceilNonMatchingPct: vol.ceilNonMatchingPct,
+ diffMapImage,
warn,
status: 'ok',
computeTimeMs: Date.now() - beforeVolume,
@@ -253,7 +311,7 @@ export class PipelineService {
level: i,
gridStep,
maxEdgeLength,
- clouds: rolesForThisRun.map((role) => ({ role, spatialStep, pointCount: 0, pointRatio: 0, binFile: null, imageFile: null })),
+ clouds: rolesForThisRun.map((role) => ({ role, spatialStep, pointCount: 0, pointRatio: 0, binFile: null })),
volume: null,
surface: null,
addedVolume: null,
@@ -261,6 +319,7 @@ export class PipelineService {
matchingCellsPct: null,
groundNonMatchingPct: null,
ceilNonMatchingPct: null,
+ diffMapImage: null,
warn: true,
status: 'error',
message: err.message,
@@ -304,9 +363,15 @@ export class PipelineService {
zrefSource,
initialStep,
initialStepSource: params.initialStepOverride !== undefined ? 'override' : 'auto',
- gridStepMultiplier: config.gridStepMultiplier,
+ gridStepMultiplier,
+ gridStepMultiplierSource: params.gridStepMultiplierOverride !== undefined ? 'override' : 'auto',
maxEdgeLengthMultiplier: config.maxEdgeLengthMultiplier,
maxEdgeLengthSource,
+ emptyCellStrategy,
+ customHeightValue: customHeightValue ?? null,
+ krigingKnn: emptyCellStrategy === 'kriging' ? krigingKnn : null,
+ projectionType,
+ vertDir,
factor: config.decimationFactor,
steps: config.decimationSteps,
densityMatch,
@@ -376,6 +441,7 @@ export class PipelineService {
matchingCellsPct: null,
groundNonMatchingPct: null,
ceilNonMatchingPct: null,
+ diffMapImage: null,
warn: true,
status: 'skipped',
message: reason,
@@ -392,10 +458,11 @@ export class PipelineService {
}
/**
- * Prepare le nuage d'UN role a UN niveau : decimation (ou conversion initiale), export de la
- * carte de hauteur, puis remplissage des trous (rasterize + interpolation Delaunay bornee) pour
- * produire la version utilisee par -VOLUME. Le nuage BRUT (non rempli) est conserve pour
- * chainer la decimation du niveau suivant et pour l'assemblage final.
+ * Prepare le nuage d'UN role a UN niveau : decimation (ou conversion initiale) puis remplissage
+ * des trous (rasterize + strategie configurable, mirroir de la boite de dialogue CloudCompare)
+ * pour produire la version ASCII utilisee par -VOLUME et la carte de comparaison. Le nuage BRUT
+ * (non rempli, en .bin) est conserve pour chainer la decimation du niveau suivant et pour
+ * l'assemblage final.
*/
private async prepareCloudAtLevel(opts: {
level: number;
@@ -404,11 +471,15 @@ export class PipelineService {
sourcePath: string;
isFirstConversion: boolean;
levelsDir: string;
- imagesDir: string;
rawDir: string;
workDir: string;
gridStep: number;
maxEdgeLength: number;
+ emptyCellStrategy: EmptyCellStrategy;
+ customHeightValue: number | undefined;
+ krigingKnn: number;
+ projectionType: ProjectionType;
+ vertDir: VertDir;
refPointCount: number;
}): Promise