trenchfoot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of trenchfoot might be problematic. Click here for more details.

Files changed (69) hide show
  1. trenchfoot/Dockerfile +5 -0
  2. trenchfoot/README.md +125 -0
  3. trenchfoot/__init__.py +67 -0
  4. trenchfoot/generate_scenarios.py +667 -0
  5. trenchfoot/gmsh_sloped_trench_mesher.py +508 -0
  6. trenchfoot/plot_mesh.py +126 -0
  7. trenchfoot/render_colors.py +59 -0
  8. trenchfoot/scenarios/S01_straight_vwalls/meshes/trench_scene_culled.obj +46 -0
  9. trenchfoot/scenarios/S01_straight_vwalls/metrics.json +35 -0
  10. trenchfoot/scenarios/S01_straight_vwalls/point_clouds/culled/resolution0p050.pth +0 -0
  11. trenchfoot/scenarios/S01_straight_vwalls/point_clouds/full/resolution0p050.pth +0 -0
  12. trenchfoot/scenarios/S01_straight_vwalls/preview.png +0 -0
  13. trenchfoot/scenarios/S01_straight_vwalls/preview_oblique.png +0 -0
  14. trenchfoot/scenarios/S01_straight_vwalls/preview_side.png +0 -0
  15. trenchfoot/scenarios/S01_straight_vwalls/preview_top.png +0 -0
  16. trenchfoot/scenarios/S01_straight_vwalls/scene.json +30 -0
  17. trenchfoot/scenarios/S01_straight_vwalls/trench_scene.obj +46 -0
  18. trenchfoot/scenarios/S01_straight_vwalls/volumetric/trench_volume.msh +1017 -0
  19. trenchfoot/scenarios/S02_straight_slope_pipe/meshes/trench_scene_culled.obj +60 -0
  20. trenchfoot/scenarios/S02_straight_slope_pipe/metrics.json +38 -0
  21. trenchfoot/scenarios/S02_straight_slope_pipe/point_clouds/culled/resolution0p050.pth +0 -0
  22. trenchfoot/scenarios/S02_straight_slope_pipe/point_clouds/full/resolution0p050.pth +0 -0
  23. trenchfoot/scenarios/S02_straight_slope_pipe/preview.png +0 -0
  24. trenchfoot/scenarios/S02_straight_slope_pipe/preview_oblique.png +0 -0
  25. trenchfoot/scenarios/S02_straight_slope_pipe/preview_side.png +0 -0
  26. trenchfoot/scenarios/S02_straight_slope_pipe/preview_top.png +0 -0
  27. trenchfoot/scenarios/S02_straight_slope_pipe/scene.json +39 -0
  28. trenchfoot/scenarios/S02_straight_slope_pipe/trench_scene.obj +14404 -0
  29. trenchfoot/scenarios/S02_straight_slope_pipe/volumetric/trench_volume.msh +2389 -0
  30. trenchfoot/scenarios/S03_L_slope_two_pipes_box/meshes/trench_scene_culled.obj +87 -0
  31. trenchfoot/scenarios/S03_L_slope_two_pipes_box/metrics.json +42 -0
  32. trenchfoot/scenarios/S03_L_slope_two_pipes_box/point_clouds/culled/resolution0p050.pth +0 -0
  33. trenchfoot/scenarios/S03_L_slope_two_pipes_box/point_clouds/full/resolution0p050.pth +0 -0
  34. trenchfoot/scenarios/S03_L_slope_two_pipes_box/preview.png +0 -0
  35. trenchfoot/scenarios/S03_L_slope_two_pipes_box/preview_oblique.png +0 -0
  36. trenchfoot/scenarios/S03_L_slope_two_pipes_box/preview_side.png +0 -0
  37. trenchfoot/scenarios/S03_L_slope_two_pipes_box/preview_top.png +0 -0
  38. trenchfoot/scenarios/S03_L_slope_two_pipes_box/scene.json +68 -0
  39. trenchfoot/scenarios/S03_L_slope_two_pipes_box/trench_scene.obj +28803 -0
  40. trenchfoot/scenarios/S03_L_slope_two_pipes_box/volumetric/trench_volume.msh +4463 -0
  41. trenchfoot/scenarios/S04_U_slope_multi_noise/meshes/trench_scene_culled.obj +150 -0
  42. trenchfoot/scenarios/S04_U_slope_multi_noise/metrics.json +46 -0
  43. trenchfoot/scenarios/S04_U_slope_multi_noise/preview.png +0 -0
  44. trenchfoot/scenarios/S04_U_slope_multi_noise/preview_oblique.png +0 -0
  45. trenchfoot/scenarios/S04_U_slope_multi_noise/preview_side.png +0 -0
  46. trenchfoot/scenarios/S04_U_slope_multi_noise/preview_top.png +0 -0
  47. trenchfoot/scenarios/S04_U_slope_multi_noise/scene.json +79 -0
  48. trenchfoot/scenarios/S04_U_slope_multi_noise/trench_scene.obj +49402 -0
  49. trenchfoot/scenarios/S04_U_slope_multi_noise/volumetric/trench_volume.msh +7610 -0
  50. trenchfoot/scenarios/S05_wide_slope_pair/metrics.json +42 -0
  51. trenchfoot/scenarios/S05_wide_slope_pair/preview_oblique.png +0 -0
  52. trenchfoot/scenarios/S05_wide_slope_pair/preview_side.png +0 -0
  53. trenchfoot/scenarios/S05_wide_slope_pair/preview_top.png +0 -0
  54. trenchfoot/scenarios/S05_wide_slope_pair/scene.json +71 -0
  55. trenchfoot/scenarios/S05_wide_slope_pair/trench_scene.obj +28803 -0
  56. trenchfoot/scenarios/S06_bumpy_wide_loop/metrics.json +43 -0
  57. trenchfoot/scenarios/S06_bumpy_wide_loop/preview_oblique.png +0 -0
  58. trenchfoot/scenarios/S06_bumpy_wide_loop/preview_side.png +0 -0
  59. trenchfoot/scenarios/S06_bumpy_wide_loop/preview_top.png +0 -0
  60. trenchfoot/scenarios/S06_bumpy_wide_loop/scene.json +82 -0
  61. trenchfoot/scenarios/S06_bumpy_wide_loop/trench_scene.obj +35084 -0
  62. trenchfoot/scenarios/SUMMARY.json +159 -0
  63. trenchfoot/scene_spec_example.json +74 -0
  64. trenchfoot/trench_scene_generator_v3.py +798 -0
  65. trenchfoot-0.1.0.dist-info/METADATA +104 -0
  66. trenchfoot-0.1.0.dist-info/RECORD +69 -0
  67. trenchfoot-0.1.0.dist-info/WHEEL +4 -0
  68. trenchfoot-0.1.0.dist-info/entry_points.txt +3 -0
  69. trenchfoot-0.1.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,667 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ generate_scenarios.py
5
+
6
+ Produce the bundled trench scenarios from simple → complex using the surface generator,
7
+ and optionally run the volumetric mesher (via Gmsh) when available.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import shutil
15
+ import sys
16
+ import tempfile
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any, Dict, Iterable, List, Optional, Sequence
20
+
21
+ _ROOT = Path(__file__).resolve().parents[1]
22
+
23
+
24
+ def _ensure_repo_on_path() -> None:
25
+ if str(_ROOT) not in sys.path:
26
+ sys.path.insert(0, str(_ROOT))
27
+
28
+ try:
29
+ from .trench_scene_generator_v3 import (
30
+ SceneSpec,
31
+ build_scene,
32
+ scene_spec_from_dict,
33
+ )
34
+ except ImportError: # pragma: no cover - direct script invocation
35
+ _ensure_repo_on_path()
36
+ from trenchfoot.trench_scene_generator_v3 import ( # type: ignore
37
+ SceneSpec,
38
+ build_scene,
39
+ scene_spec_from_dict,
40
+ )
41
+
42
+ _gmsh_mesher = None
43
+ _gmsh_import_error: Optional[Exception] = None
44
+
45
+
46
+ def _load_gmsh_mesher() -> None:
47
+ global _gmsh_mesher, _gmsh_import_error
48
+ if _gmsh_mesher is not None or _gmsh_import_error is not None:
49
+ return
50
+ try:
51
+ from . import gmsh_sloped_trench_mesher as _mesher # type: ignore
52
+ except Exception:
53
+ _ensure_repo_on_path()
54
+ try:
55
+ import trenchfoot.gmsh_sloped_trench_mesher as _mesher # type: ignore
56
+ except Exception as exc:
57
+ _gmsh_import_error = exc
58
+ return
59
+ _gmsh_mesher = _mesher
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ScenarioDefinition:
64
+ """Name + spec dictionary for generating a scenario."""
65
+
66
+ name: str
67
+ spec: Dict[str, Any]
68
+
69
+
70
+ @dataclass
71
+ class ScenarioSummary:
72
+ """Outputs captured for a single generated scenario."""
73
+
74
+ name: str
75
+ directory: Path
76
+ spec_path: Path
77
+ surface_obj: Path
78
+ metrics_path: Path
79
+ preview_paths: List[Path]
80
+ preview_count: int
81
+ object_counts: Dict[str, int]
82
+ footprint_top: float
83
+ footprint_bottom: float
84
+ trench_from_surface: float
85
+ volumetric_path: Optional[Path]
86
+ volumetric_lc: Optional[float]
87
+ volumetric_error: Optional[str]
88
+ pipe_clearances: List[Dict[str, Any]] = field(default_factory=list)
89
+
90
+ def to_dict(self) -> Dict[str, Any]:
91
+ return {
92
+ "name": self.name,
93
+ "directory": str(self.directory),
94
+ "spec_path": str(self.spec_path),
95
+ "surface_obj": str(self.surface_obj),
96
+ "metrics_path": str(self.metrics_path),
97
+ "preview_paths": [str(p) for p in self.preview_paths],
98
+ "preview_count": self.preview_count,
99
+ "object_counts": dict(self.object_counts),
100
+ "footprint_top": self.footprint_top,
101
+ "footprint_bottom": self.footprint_bottom,
102
+ "trench_from_surface": self.trench_from_surface,
103
+ "volumetric_path": str(self.volumetric_path) if self.volumetric_path else None,
104
+ "volumetric_lc": self.volumetric_lc,
105
+ "volumetric_error": self.volumetric_error,
106
+ "pipe_clearances": self.pipe_clearances,
107
+ }
108
+
109
+
110
+ @dataclass
111
+ class RunReport:
112
+ """Aggregate report describing a generate_scenarios run."""
113
+
114
+ out_root: Path
115
+ preview_enabled: bool
116
+ volumetric_requested: bool
117
+ volumetric_available: bool
118
+ mesh_characteristic_length: float
119
+ scenarios: List[ScenarioSummary]
120
+
121
+ def to_dict(self) -> Dict[str, Any]:
122
+ return {
123
+ "out_root": str(self.out_root),
124
+ "preview_enabled": self.preview_enabled,
125
+ "volumetric_requested": self.volumetric_requested,
126
+ "volumetric_available": self.volumetric_available,
127
+ "mesh_characteristic_length": self.mesh_characteristic_length,
128
+ "scenarios": [sc.to_dict() for sc in self.scenarios],
129
+ }
130
+
131
+
132
+ def gmsh_available() -> bool:
133
+ """Return True if the gmsh-based mesher can be imported."""
134
+ _load_gmsh_mesher()
135
+ return _gmsh_mesher is not None
136
+
137
+
138
+ def default_scenarios() -> List[ScenarioDefinition]:
139
+ """Built-in scenario presets."""
140
+ return [
141
+ ScenarioDefinition(
142
+ "S01_straight_vwalls",
143
+ {
144
+ "path_xy": [[0, 0], [5, 0]],
145
+ "width": 1.0,
146
+ "depth": 1.0,
147
+ "wall_slope": 0.0,
148
+ "ground_margin": 0.5,
149
+ "ground": {"z0": 0.0, "slope": [0.0, 0.0], "size_margin": 3.0},
150
+ "pipes": [],
151
+ "boxes": [],
152
+ "spheres": [],
153
+ "noise": {"enable": False},
154
+ },
155
+ ),
156
+ ScenarioDefinition(
157
+ "S02_straight_slope_pipe",
158
+ {
159
+ "path_xy": [[0, 0], [6, 0]],
160
+ "width": 1.2,
161
+ "depth": 1.5,
162
+ "wall_slope": 0.2,
163
+ "ground_margin": 0.5,
164
+ "ground": {"z0": 0.0, "slope": [0.0, 0.0], "size_margin": 3.0},
165
+ "pipes": [
166
+ {
167
+ "radius": 0.15,
168
+ "length": 7.0,
169
+ "angle_deg": 0,
170
+ "s_center": 0.5,
171
+ "z": -0.7,
172
+ "offset_u": 0.0,
173
+ }
174
+ ],
175
+ "boxes": [],
176
+ "spheres": [],
177
+ "noise": {"enable": False},
178
+ },
179
+ ),
180
+ ScenarioDefinition(
181
+ "S03_L_slope_two_pipes_box",
182
+ {
183
+ "path_xy": [[0, 0], [6, 0], [6, 4]],
184
+ "width": 1.2,
185
+ "depth": 1.8,
186
+ "wall_slope": 0.15,
187
+ "ground_margin": 1.0,
188
+ "ground": {"z0": 0.0, "slope": [0.0, 0.0], "size_margin": 4.0},
189
+ "pipes": [
190
+ {
191
+ "radius": 0.15,
192
+ "length": 8.0,
193
+ "angle_deg": 0,
194
+ "s_center": 0.35,
195
+ "z": -1.0,
196
+ "offset_u": 0.0,
197
+ },
198
+ {
199
+ "radius": 0.10,
200
+ "length": 5.0,
201
+ "angle_deg": 90,
202
+ "s_center": 0.75,
203
+ "z": -0.9,
204
+ "offset_u": 0.2,
205
+ },
206
+ ],
207
+ "boxes": [
208
+ {
209
+ "along": 0.8,
210
+ "across": 0.5,
211
+ "height": 0.4,
212
+ "s": 0.55,
213
+ "offset_u": 0.0,
214
+ }
215
+ ],
216
+ "spheres": [],
217
+ "noise": {
218
+ "enable": True,
219
+ "amplitude": 0.01,
220
+ "corr_length": 0.5,
221
+ "octaves": 2,
222
+ "gain": 0.5,
223
+ "seed": 7,
224
+ "apply_to": ["trench_walls", "trench_bottom"],
225
+ },
226
+ },
227
+ ),
228
+ ScenarioDefinition(
229
+ "S04_U_slope_multi_noise",
230
+ {
231
+ "path_xy": [[0, 0], [6, 0], [6, 4], [0, 4]],
232
+ "width": 1.4,
233
+ "depth": 2.0,
234
+ "wall_slope": 0.25,
235
+ "ground_margin": 1.2,
236
+ "ground": {"z0": 0.0, "slope": [0.0, 0.0], "size_margin": 5.0},
237
+ "pipes": [
238
+ {
239
+ "radius": 0.18,
240
+ "length": 9.0,
241
+ "angle_deg": 0,
242
+ "s_center": 0.25,
243
+ "z": -1.0,
244
+ "offset_u": 0.0,
245
+ },
246
+ {
247
+ "radius": 0.10,
248
+ "length": 5.0,
249
+ "angle_deg": 45,
250
+ "s_center": 0.55,
251
+ "z": -1.1,
252
+ "offset_u": -0.2,
253
+ },
254
+ {
255
+ "radius": 0.08,
256
+ "length": 3.5,
257
+ "angle_deg": -60,
258
+ "s_center": 0.75,
259
+ "z": -1.3,
260
+ "offset_u": 0.25,
261
+ },
262
+ ],
263
+ "boxes": [],
264
+ "spheres": [{"radius": 0.3, "s": 0.85, "offset_u": -0.2}],
265
+ "noise": {
266
+ "enable": True,
267
+ "amplitude": 0.02,
268
+ "corr_length": 0.6,
269
+ "octaves": 2,
270
+ "gain": 0.6,
271
+ "seed": 13,
272
+ "apply_to": ["trench_walls", "trench_bottom", "pipe*_pipe_side"],
273
+ },
274
+ },
275
+ ),
276
+ ScenarioDefinition(
277
+ "S05_wide_slope_pair",
278
+ {
279
+ "path_xy": [[0, 0], [9, 0], [9, 3]],
280
+ "width": 2.4,
281
+ "depth": 1.2,
282
+ "wall_slope": 0.08,
283
+ "ground_margin": 1.5,
284
+ "ground": {"z0": 0.0, "slope": [0.02, -0.015], "size_margin": 6.0},
285
+ "pipes": [
286
+ {
287
+ "radius": 0.2,
288
+ "length": 5.5,
289
+ "angle_deg": 10,
290
+ "s_center": 0.35,
291
+ "z": -0.7,
292
+ "offset_u": 0.3,
293
+ "clearance_scale": 0.9,
294
+ },
295
+ {
296
+ "radius": 0.16,
297
+ "length": 4.2,
298
+ "angle_deg": -15,
299
+ "s_center": 0.7,
300
+ "z": -0.8,
301
+ "offset_u": -0.4,
302
+ "clearance_scale": 1.1,
303
+ },
304
+ ],
305
+ "boxes": [
306
+ {
307
+ "along": 1.2,
308
+ "across": 0.9,
309
+ "height": 0.5,
310
+ "s": 0.55,
311
+ "offset_u": -0.25,
312
+ "z": -0.6,
313
+ }
314
+ ],
315
+ "spheres": [],
316
+ "noise": {
317
+ "enable": True,
318
+ "amplitude": 0.015,
319
+ "corr_length": 0.8,
320
+ "octaves": 3,
321
+ "gain": 0.45,
322
+ "seed": 17,
323
+ "apply_to": ["trench_walls", "trench_bottom"],
324
+ },
325
+ },
326
+ ),
327
+ ScenarioDefinition(
328
+ "S06_bumpy_wide_loop",
329
+ {
330
+ "path_xy": [[0, 0], [4, -1], [8, 0], [8, 5], [2, 5], [-1, 2]],
331
+ "width": 2.6,
332
+ "depth": 1.4,
333
+ "wall_slope": 0.12,
334
+ "ground_margin": 2.0,
335
+ "ground": {"z0": 0.2, "slope": [0.015, 0.03], "size_margin": 7.0},
336
+ "pipes": [
337
+ {
338
+ "radius": 0.18,
339
+ "length": 6.0,
340
+ "angle_deg": 35,
341
+ "s_center": 0.3,
342
+ "z": -0.9,
343
+ "offset_u": 0.35,
344
+ "clearance_scale": 1.0,
345
+ },
346
+ {
347
+ "radius": 0.14,
348
+ "length": 4.8,
349
+ "angle_deg": -40,
350
+ "s_center": 0.6,
351
+ "z": -0.95,
352
+ "offset_u": -0.45,
353
+ "clearance_scale": 0.85,
354
+ },
355
+ ],
356
+ "boxes": [],
357
+ "spheres": [
358
+ {"radius": 0.35, "s": 0.82, "offset_u": 0.3, "z": -0.65}
359
+ ],
360
+ "noise": {
361
+ "enable": True,
362
+ "amplitude": 0.035,
363
+ "corr_length": 0.5,
364
+ "octaves": 4,
365
+ "gain": 0.55,
366
+ "seed": 29,
367
+ "apply_to": ["trench_walls", "trench_bottom", "pipe*_pipe_side"],
368
+ },
369
+ },
370
+ ),
371
+ ]
372
+
373
+
374
+ def _ensure_spec_written(path: Path, spec: Dict[str, Any]) -> None:
375
+ if not path.parent.exists():
376
+ path.parent.mkdir(parents=True, exist_ok=True)
377
+ with path.open("w") as fh:
378
+ json.dump(spec, fh, indent=2)
379
+
380
+
381
+ def _build_surface(spec: SceneSpec, out_dir: Path, make_preview: bool) -> Dict[str, Any]:
382
+ return build_scene(spec, str(out_dir), make_preview=make_preview)
383
+
384
+
385
+ def _build_volume(spec: Dict[str, Any], out_dir: Path, lc: float) -> tuple[Optional[Path], Optional[str], List[Dict[str, Any]]]:
386
+ _load_gmsh_mesher()
387
+ if _gmsh_mesher is None:
388
+ reason = str(_gmsh_import_error) if _gmsh_import_error else "gmsh_not_available"
389
+ return None, reason, []
390
+ out_dir.mkdir(parents=True, exist_ok=True)
391
+ msh_path = out_dir / "trench_volume.msh"
392
+ clearance_data: List[Dict[str, Any]] = []
393
+
394
+ try:
395
+ if hasattr(_gmsh_mesher, "generate_trench_volume"):
396
+ result = _gmsh_mesher.generate_trench_volume(
397
+ spec,
398
+ lc=lc,
399
+ persist_path=str(msh_path),
400
+ )
401
+ clearance_data.extend(
402
+ [dict(entry) for entry in result.pipe_clearances]
403
+ if isinstance(result.pipe_clearances, list)
404
+ else []
405
+ )
406
+ persisted = result.persisted_path if result.persisted_path is not None else Path(str(msh_path))
407
+ else:
408
+ _gmsh_mesher.build_trench_volume_from_spec(
409
+ spec, lc=lc, out_msh=str(msh_path)
410
+ )
411
+ persisted = Path(str(msh_path))
412
+ except Exception as exc: # pragma: no cover - gmsh failure
413
+ return None, str(exc), clearance_data
414
+
415
+ final_path = Path(persisted)
416
+ return final_path, None, clearance_data
417
+
418
+
419
+ def generate_scenarios(
420
+ out_root: Path | str,
421
+ scenarios: Optional[Sequence[ScenarioDefinition]] = None,
422
+ *,
423
+ make_preview: bool = True,
424
+ make_volumes: bool = True,
425
+ mesh_characteristic_length: float = 0.3,
426
+ write_summary_json: bool = True,
427
+ ) -> RunReport:
428
+ """
429
+ Generate trench scenarios, producing surface meshes (+previews) and optional volumetric meshes.
430
+ Returns a RunReport describing the run.
431
+ """
432
+ out_root = Path(out_root)
433
+ out_root.mkdir(parents=True, exist_ok=True)
434
+
435
+ scenario_defs = list(scenarios) if scenarios is not None else default_scenarios()
436
+ results: List[ScenarioSummary] = []
437
+ gmsh_ok = gmsh_available() if make_volumes else False
438
+
439
+ for definition in scenario_defs:
440
+ scen_dir = out_root / definition.name
441
+ scen_dir.mkdir(parents=True, exist_ok=True)
442
+
443
+ spec_path = scen_dir / "scene.json"
444
+ _ensure_spec_written(spec_path, definition.spec)
445
+
446
+ scene_spec = scene_spec_from_dict(definition.spec)
447
+ surface_out = _build_surface(scene_spec, scen_dir, make_preview=make_preview)
448
+
449
+ volumetric_path: Optional[Path] = None
450
+ volumetric_error: Optional[str] = None
451
+ pipe_clearances: List[Dict[str, Any]] = []
452
+ if gmsh_ok:
453
+ vol_dir = scen_dir / "volumetric"
454
+ volumetric_path, volumetric_error, pipe_clearances = _build_volume(
455
+ definition.spec, vol_dir, mesh_characteristic_length
456
+ )
457
+ if volumetric_error:
458
+ print(
459
+ f"[volumetric] {definition.name} failed: {volumetric_error}",
460
+ file=sys.stderr,
461
+ )
462
+
463
+ summary = ScenarioSummary(
464
+ name=definition.name,
465
+ directory=scen_dir,
466
+ spec_path=spec_path,
467
+ surface_obj=Path(surface_out["obj_path"]),
468
+ metrics_path=scen_dir / "metrics.json",
469
+ preview_paths=[Path(p) for p in surface_out["previews"]],
470
+ preview_count=len(surface_out["previews"]),
471
+ object_counts=dict(surface_out["object_counts"]),
472
+ footprint_top=float(surface_out["metrics"]["footprint_area_top"]),
473
+ footprint_bottom=float(surface_out["metrics"]["footprint_area_bottom"]),
474
+ trench_from_surface=float(surface_out["metrics"]["volumes"]["trench_from_surface"]),
475
+ volumetric_path=volumetric_path,
476
+ volumetric_lc=mesh_characteristic_length if volumetric_path else None,
477
+ volumetric_error=volumetric_error,
478
+ pipe_clearances=pipe_clearances,
479
+ )
480
+ results.append(summary)
481
+
482
+ report = RunReport(
483
+ out_root=out_root,
484
+ preview_enabled=make_preview,
485
+ volumetric_requested=make_volumes,
486
+ volumetric_available=gmsh_ok,
487
+ mesh_characteristic_length=mesh_characteristic_length,
488
+ scenarios=results,
489
+ )
490
+
491
+ if write_summary_json:
492
+ summary_path = out_root / "SUMMARY.json"
493
+ with summary_path.open("w") as fh:
494
+ json.dump(report.to_dict(), fh, indent=2)
495
+
496
+ return report
497
+
498
+
499
+ def _relative_path(path: Path, base: Path) -> str:
500
+ path_abs = path.resolve()
501
+ base_abs = base.resolve()
502
+ rel = os.path.relpath(path_abs, start=base_abs)
503
+ return Path(rel).as_posix()
504
+
505
+
506
+ def _preview_mapping(paths: List[Path]) -> Dict[str, Optional[Path]]:
507
+ lookup = {"top": None, "side": None, "oblique": None}
508
+ for p in paths:
509
+ name = p.name
510
+ if "preview_top" in name:
511
+ lookup["top"] = p
512
+ elif "preview_side" in name:
513
+ lookup["side"] = p
514
+ elif "preview_oblique" in name:
515
+ lookup["oblique"] = p
516
+ return lookup
517
+
518
+
519
+ def build_gallery_markdown(report: RunReport, base: Path = _ROOT) -> str:
520
+ lines = ["| Scenario | Top | Side | Oblique |", "| --- | --- | --- | --- |"]
521
+ for scenario in report.scenarios:
522
+ previews = _preview_mapping(scenario.preview_paths)
523
+
524
+ def cell(which: str) -> str:
525
+ path = previews[which]
526
+ if path is None:
527
+ return "_(missing)_"
528
+ rel = _relative_path(path, base)
529
+ return f"![{scenario.name} {which}]({rel})"
530
+
531
+ lines.append(
532
+ f"| {scenario.name} | {cell('top')} | {cell('side')} | {cell('oblique')} |"
533
+ )
534
+ return "\n".join(lines) + "\n"
535
+
536
+
537
+ def write_gallery(markdown_path: Path, report: RunReport, base: Optional[Path] = None) -> None:
538
+ markdown_path.parent.mkdir(parents=True, exist_ok=True)
539
+ actual_base = base if base is not None else markdown_path.parent
540
+ markdown_path.write_text(build_gallery_markdown(report, base=actual_base))
541
+
542
+
543
+ def _format_table(report: RunReport) -> str:
544
+ header = f"{'Scenario':<28} {'Surface OBJ':<40} {'Previews':<9} {'Volumetric':<12}"
545
+ rows = [header, "-" * len(header)]
546
+ for scenario in report.scenarios:
547
+ preview_label = f"{scenario.preview_count}"
548
+ if report.preview_enabled and scenario.preview_count == 0:
549
+ preview_label = "0 (matplotlib?)"
550
+ vol_label = "disabled"
551
+ if report.volumetric_requested:
552
+ if scenario.volumetric_path:
553
+ vol_label = "ok"
554
+ elif scenario.volumetric_error:
555
+ vol_label = "fail"
556
+ elif report.volumetric_available:
557
+ vol_label = "skipped"
558
+ else:
559
+ vol_label = "skipped"
560
+ rows.append(
561
+ f"{scenario.name:<28} "
562
+ f"{scenario.surface_obj.name:<40} "
563
+ f"{preview_label:<9} "
564
+ f"{vol_label:<12}"
565
+ )
566
+ return "\n".join(rows)
567
+
568
+
569
+ def main(argv: Optional[Sequence[str]] = None) -> None:
570
+ env_out_root = os.environ.get("TRENCHFOOT_SCENARIO_OUT_ROOT")
571
+ default_out_root = (
572
+ Path(env_out_root).expanduser()
573
+ if env_out_root
574
+ else Path(__file__).resolve().parent / "scenarios"
575
+ )
576
+ parser = argparse.ArgumentParser(description="Generate the bundled trench scenarios.")
577
+ parser.add_argument(
578
+ "--out",
579
+ dest="out_root",
580
+ default=default_out_root,
581
+ type=Path,
582
+ help=(
583
+ "Destination directory for scenario outputs "
584
+ "(default: packages/trenchfoot/scenarios or $TRENCHFOOT_SCENARIO_OUT_ROOT)"
585
+ ),
586
+ )
587
+ parser.add_argument(
588
+ "--preview",
589
+ dest="make_preview",
590
+ action="store_true",
591
+ help="Enable preview rendering.",
592
+ )
593
+ parser.add_argument(
594
+ "--no-preview",
595
+ dest="make_preview",
596
+ action="store_false",
597
+ help="Disable preview rendering.",
598
+ )
599
+ parser.add_argument(
600
+ "--volumetric",
601
+ dest="make_volumes",
602
+ action="store_true",
603
+ help="Enable volumetric meshing when gmsh is available.",
604
+ )
605
+ parser.add_argument(
606
+ "--skip-volumetric",
607
+ dest="make_volumes",
608
+ action="store_false",
609
+ help="Skip volumetric meshing even if gmsh is installed.",
610
+ )
611
+ parser.add_argument(
612
+ "--lc",
613
+ dest="lc",
614
+ type=float,
615
+ default=0.3,
616
+ help="Characteristic mesh length passed to gmsh (default: 0.3).",
617
+ )
618
+ parser.add_argument(
619
+ "--gallery",
620
+ dest="gallery",
621
+ type=Path,
622
+ help="Write a Markdown gallery of scenario previews to this path.",
623
+ )
624
+ parser.add_argument(
625
+ "--scratch",
626
+ dest="scratch",
627
+ action="store_true",
628
+ help="Generate scenarios in a temporary directory to keep the repository tree clean.",
629
+ )
630
+ parser.add_argument(
631
+ "--include-prebuilt",
632
+ dest="include_prebuilt",
633
+ action="store_true",
634
+ help="Copy the shipped scenario assets into the output directory before regeneration.",
635
+ )
636
+ parser.set_defaults(make_preview=True, make_volumes=True)
637
+ args = parser.parse_args(argv)
638
+
639
+ if args.scratch:
640
+ tmp_dir = Path(tempfile.mkdtemp(prefix="trenchfoot_scenarios_"))
641
+ print(f"[trenchfoot] Using scratch directory: {tmp_dir}", file=sys.stderr)
642
+ args.out_root = tmp_dir
643
+
644
+ if args.include_prebuilt:
645
+ source = (_ROOT / "scenarios")
646
+ if source.exists():
647
+ shutil.copytree(source, args.out_root, dirs_exist_ok=True)
648
+
649
+ report = generate_scenarios(
650
+ out_root=args.out_root,
651
+ make_preview=args.make_preview,
652
+ make_volumes=args.make_volumes,
653
+ mesh_characteristic_length=args.lc,
654
+ write_summary_json=True,
655
+ )
656
+
657
+ print(_format_table(report))
658
+ print()
659
+ print(json.dumps(report.to_dict(), indent=2))
660
+
661
+ if args.gallery:
662
+ write_gallery(args.gallery, report)
663
+ print(f"[trenchfoot] Wrote gallery to {args.gallery}", file=sys.stderr)
664
+
665
+
666
+ if __name__ == "__main__":
667
+ main()