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,798 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ trench_scene_generator_v3.py
5
+ Surface generator with:
6
+ * Polyline trench (L/U/...)
7
+ * Sloped walls (bottom width = max(width - 2*slope*depth, epsilon))
8
+ * Ground surface: consistent ground plane z = z0 + sx*x + sy*y
9
+ * Pipes/boxes/spheres correctly oriented and clamped inside trench
10
+ * Optional vertex-normal noise
11
+ * Multi-angle previews: top / side / oblique
12
+ CLI:
13
+ python trench_scene_generator_v3.py --spec scene.json --out ./out --preview
14
+ Outputs:
15
+ - trench_scene.obj, metrics.json
16
+ - preview_top.png, preview_side.png, preview_oblique.png (if --preview)
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import io
21
+ import os, json, math, argparse, re
22
+ from dataclasses import dataclass, field, asdict
23
+ from pathlib import Path
24
+ from typing import Dict, List, Tuple, Optional, Any
25
+ import numpy as np
26
+
27
+ from .render_colors import color_for_group, opacity_for_group
28
+
29
+ try:
30
+ import matplotlib.pyplot as plt
31
+ from mpl_toolkits.mplot3d.art3d import Poly3DCollection
32
+ except Exception:
33
+ plt = None
34
+ Poly3DCollection = None
35
+
36
+ # ---------------- Geometry helpers ----------------
37
+
38
+ def _normalize(v: np.ndarray) -> np.ndarray:
39
+ n = np.linalg.norm(v)
40
+ if n == 0: return v
41
+ return v / n
42
+
43
+ def _rotate_cw(v: np.ndarray) -> np.ndarray:
44
+ return np.array([v[1], -v[0]], dtype=float)
45
+
46
+ def _rotate_ccw(v: np.ndarray) -> np.ndarray:
47
+ return np.array([-v[1], v[0]], dtype=float)
48
+
49
+ def _line_intersection_2d(p: np.ndarray, d: np.ndarray, q: np.ndarray, e: np.ndarray):
50
+ M = np.array([d, -e], float).T
51
+ det = np.linalg.det(M)
52
+ if abs(det) < 1e-12: return None
53
+ t, s = np.linalg.solve(M, (q - p))
54
+ return p + t * d
55
+
56
+ def _polyline_lengths(path: List[Tuple[float,float]]):
57
+ P = np.array(path, float)
58
+ segs = P[1:] - P[:-1]
59
+ lens = np.linalg.norm(segs, axis=1)
60
+ cum = np.concatenate([[0.0], np.cumsum(lens)])
61
+ return cum, float(cum[-1])
62
+
63
+ def _sample_polyline_at_s(path: List[Tuple[float,float]], s: float):
64
+ P = np.array(path, float)
65
+ cum, total = _polyline_lengths(path)
66
+ if total == 0: return P[0], np.array([1.0, 0.0])
67
+ s_abs = s * total
68
+ i = np.searchsorted(cum, s_abs, side="right") - 1
69
+ i = int(np.clip(i, 0, len(P)-2))
70
+ seg = P[i+1] - P[i]; L = np.linalg.norm(seg)
71
+ if L == 0:
72
+ t = np.array([1.0, 0.0]); pos = P[i]
73
+ else:
74
+ t = seg / L; u = (s_abs - cum[i]) / L; pos = (1-u)*P[i] + u*P[i+1]
75
+ return pos, t
76
+
77
+ def _offset_polyline(path: List[Tuple[float,float]], offset: float):
78
+ P = np.array(path, float); n = len(P)
79
+ if n < 2: raise ValueError("Polyline needs at least 2 points")
80
+ tangents = []; normals = []
81
+ for i in range(n-1):
82
+ t = _normalize(P[i+1]-P[i])
83
+ if np.linalg.norm(t) < 1e-12: t = np.array([1.0, 0.0])
84
+ tangents.append(t); normals.append(_rotate_ccw(t))
85
+ left_pts = [P[0] + offset * normals[0]]
86
+ right_pts = [P[0] - offset * normals[0]]
87
+ for k in range(1, n-1):
88
+ t_prev, n_prev = tangents[k-1], normals[k-1]
89
+ t_next, n_next = tangents[k], normals[k]
90
+ L1_p = P[k] + offset * n_prev; L1_d = t_prev
91
+ L2_p = P[k] + offset * n_next; L2_d = t_next
92
+ R1_p = P[k] - offset * n_prev; R1_d = t_prev
93
+ R2_p = P[k] - offset * n_next; R2_d = t_next
94
+ L = _line_intersection_2d(L1_p, L1_d, L2_p, L2_d)
95
+ R = _line_intersection_2d(R1_p, R1_d, R2_p, R2_d)
96
+ if L is None: L = 0.5*(L1_p + L2_p)
97
+ if R is None: R = 0.5*(R1_p + R2_p)
98
+ left_pts.append(L); right_pts.append(R)
99
+ left_pts.append(P[-1] + offset * normals[-1])
100
+ right_pts.append(P[-1] - offset * normals[-1])
101
+ return left_pts, right_pts
102
+
103
+ def _polygon_area_2d(poly_xy: np.ndarray) -> float:
104
+ x = poly_xy[:,0]; y = poly_xy[:,1]
105
+ return 0.5 * float(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1)))
106
+
107
+ def _ensure_ccw(poly_xy: np.ndarray) -> np.ndarray:
108
+ return poly_xy if _polygon_area_2d(poly_xy) > 0 else poly_xy[::-1].copy()
109
+
110
+ def _cross2d(a: np.ndarray, b: np.ndarray) -> float:
111
+ return float(a[0]*b[1] - a[1]*b[0])
112
+
113
+ def _ear_clipping_triangulation(poly_xy: np.ndarray) -> np.ndarray:
114
+ def is_convex(a, b, c): return _cross2d(b - a, c - b) > 0
115
+ def point_in_tri(p, a, b, c):
116
+ v0=c-a; v1=b-a; v2=p-a
117
+ den=v0[0]*v1[1]-v1[0]*v0[1]
118
+ if abs(den)<1e-15: return False
119
+ u=(v2[0]*v1[1]-v1[0]*v2[1])/den
120
+ v=(v0[0]*v2[1]-v2[0]*v0[1])/den
121
+ return (u>=-1e-12) and (v>=-1e-12) and (u+v<=1+1e-12)
122
+ V = list(range(len(poly_xy))); tris=[]; it=0
123
+ while len(V)>3 and it<10000:
124
+ ear=False; m=len(V)
125
+ for vi in range(m):
126
+ i0=V[(vi-1)%m]; i1=V[vi]; i2=V[(vi+1)%m]
127
+ a,b,c = poly_xy[i0], poly_xy[i1], poly_xy[i2]
128
+ if not is_convex(a,b,c): continue
129
+ inside=False
130
+ for j in range(m):
131
+ if j in [(vi-1)%m,vi,(vi+1)%m]: continue
132
+ pj = poly_xy[V[j]]
133
+ if point_in_tri(pj,a,b,c): inside=True; break
134
+ if inside: continue
135
+ tris.append([i0,i1,i2]); del V[vi]; ear=True; break
136
+ if not ear:
137
+ V2=V.copy()
138
+ for k in range(1,len(V2)-1): tris.append([V2[0],V2[k],V2[k+1]])
139
+ V=[V2[0],V2[-1],V2[-2]]
140
+ it+=1
141
+ tris.append([V[0],V[1],V[2]])
142
+ return np.array(tris,int)
143
+
144
+ # ---------------- Mesh IO & metrics ----------------
145
+
146
+ def write_obj_with_groups(path: str, groups: Dict[str, Tuple[np.ndarray, np.ndarray]]):
147
+ lines=[]; offset=1
148
+ for g,(V,F) in groups.items():
149
+ lines.append(f"g {g}")
150
+ for v in V: lines.append(f"v {v[0]:.9g} {v[1]:.9g} {v[2]:.9g}")
151
+ for tri in F:
152
+ a,b,c = tri + offset
153
+ lines.append(f"f {a} {b} {c}")
154
+ offset += V.shape[0]
155
+ with open(path,"w") as f: f.write("\n".join(lines))
156
+
157
+ def parse_obj_groups(path: str):
158
+ verts=[]; faces_by_group={}; current="default"
159
+ with open(path,"r") as f:
160
+ for line in f:
161
+ if not line.strip(): continue
162
+ if line.startswith("v "):
163
+ _,x,y,z = line.strip().split()
164
+ verts.append([float(x),float(y),float(z)])
165
+ elif line.startswith("g "):
166
+ current = line.strip().split(maxsplit=1)[1]
167
+ faces_by_group.setdefault(current, [])
168
+ elif line.startswith("f "):
169
+ parts = line.strip().split()
170
+ idxs = [int(p.split('/')[0])-1 for p in parts[1:4]]
171
+ faces_by_group.setdefault(current, []).append(idxs)
172
+ V=np.array(verts,float)
173
+ faces_by_group = {k:(np.array(v,int) if len(v) else np.zeros((0,3),int)) for k,v in faces_by_group.items()}
174
+ return V, faces_by_group
175
+
176
+ def triangle_areas(V,F):
177
+ p0=V[F[:,0]]; p1=V[F[:,1]]; p2=V[F[:,2]]
178
+ return 0.5*np.linalg.norm(np.cross(p1-p0,p2-p0),axis=1)
179
+
180
+ def surface_area(V,F): return float(triangle_areas(V,F).sum())
181
+
182
+ def surface_area_by_group(obj_path: str):
183
+ V, fbg = parse_obj_groups(obj_path)
184
+ return {g: float(surface_area(V,F)) for g,F in fbg.items()}
185
+
186
+ def signed_volume_of_closed_surface(V,F):
187
+ p0=V[F[:,0]]; p1=V[F[:,1]]; p2=V[F[:,2]]
188
+ vol = np.einsum('ij,ij->i', p0, np.cross(p1,p2))
189
+ return float(vol.sum()/6.0)
190
+
191
+ def volume_by_groups_as_closed(obj_path: str, names):
192
+ V, fbg = parse_obj_groups(obj_path)
193
+ Fs=[fbg[n] for n in names if n in fbg]
194
+ if not Fs: return 0.0
195
+ F=np.vstack(Fs)
196
+ return signed_volume_of_closed_surface(V,F)
197
+
198
+ def flux_volume_from_closed_groups(obj_path: str, names):
199
+ V, fbg = parse_obj_groups(obj_path)
200
+ F_all = np.vstack([fbg[n] for n in names if n in fbg])
201
+ p0=V[F_all[:,0]]; p1=V[F_all[:,1]]; p2=V[F_all[:,2]]
202
+ cent=(p0+p1+p2)/3.0; Fvec=cent/3.0; nvec=np.cross(p1-p0,p2-p0)
203
+ return float(((Fvec*nvec).sum(axis=1)/2.0).sum())
204
+
205
+
206
+ def _combine_groups(groups: Dict[str, Tuple[np.ndarray, np.ndarray]], names: List[str]) -> Tuple[np.ndarray, np.ndarray]:
207
+ vertices: List[np.ndarray] = []
208
+ faces: List[np.ndarray] = []
209
+ offset = 0
210
+ for name in names:
211
+ entry = groups.get(name)
212
+ if entry is None:
213
+ continue
214
+ V, F = entry
215
+ if V.size == 0 or F.size == 0:
216
+ continue
217
+ vertices.append(V)
218
+ faces.append(F + offset)
219
+ offset += V.shape[0]
220
+ if not vertices:
221
+ return np.zeros((0, 3), float), np.zeros((0, 3), int)
222
+ return np.vstack(vertices), np.vstack(faces)
223
+
224
+
225
+ def _compute_surface_metrics(
226
+ groups: Dict[str, Tuple[np.ndarray, np.ndarray]],
227
+ extra: Dict[str, Any],
228
+ spec: SceneSpec,
229
+ ) -> Dict[str, Any]:
230
+ areas = {
231
+ name: float(surface_area(V, F))
232
+ for name, (V, F) in groups.items()
233
+ }
234
+ closed_names = ["trench_walls", "trench_bottom", "trench_cap_for_volume"]
235
+ V_closed, F_closed = _combine_groups(groups, closed_names)
236
+ if F_closed.size == 0:
237
+ vol_surface = 0.0
238
+ vol_flux = 0.0
239
+ else:
240
+ vol_surface = signed_volume_of_closed_surface(V_closed, F_closed)
241
+ p0 = V_closed[F_closed[:, 0]]
242
+ p1 = V_closed[F_closed[:, 1]]
243
+ p2 = V_closed[F_closed[:, 2]]
244
+ cent = (p0 + p1 + p2) / 3.0
245
+ Fvec = cent / 3.0
246
+ nvec = np.cross(p1 - p0, p2 - p0)
247
+ vol_flux = float(((Fvec * nvec).sum(axis=1) / 2.0).sum())
248
+
249
+ metrics = {
250
+ "surface_area_by_group": areas,
251
+ "closed_surface_sets": {"trench_closed_groups": closed_names},
252
+ "volumes": {
253
+ "trench_from_surface": vol_surface,
254
+ "trench_flux_integral_div1": vol_flux,
255
+ },
256
+ "footprint_area_top": float(areas.get("trench_cap_for_volume", 0.0)),
257
+ "footprint_area_bottom": float(areas.get("trench_bottom", 0.0)),
258
+ "width_top": float(extra.get("width_top", spec.width)),
259
+ "width_bottom": float(extra.get("width_bottom", spec.width)),
260
+ "noise": asdict(spec.noise) if spec.noise else None,
261
+ }
262
+ return metrics
263
+
264
+
265
+ def _render_surface_previews(groups: Dict[str, Tuple[np.ndarray, np.ndarray]]) -> Dict[str, bytes]:
266
+ if plt is None or not groups:
267
+ return {}
268
+ all_vertices = [V for (V, F) in groups.values() if V.size > 0]
269
+ if not all_vertices:
270
+ return {}
271
+ stack = np.vstack(all_vertices)
272
+ mins, maxs = stack.min(axis=0), stack.max(axis=0)
273
+ previews: Dict[str, bytes] = {}
274
+ viewset = [("top", (90, 0)), ("side", (0, 0)), ("oblique", (22, -60))]
275
+ for name, (elev, azim) in viewset:
276
+ fig = plt.figure(figsize=(8, 7))
277
+ ax = fig.add_subplot(111, projection="3d")
278
+ for group_name, (V, F) in groups.items():
279
+ if F.shape[0] == 0:
280
+ continue
281
+ tris = [V[idx] for idx in F]
282
+ if not tris:
283
+ continue
284
+ pc = Poly3DCollection(tris, linewidths=0.1)
285
+ color = color_for_group(group_name)
286
+ alpha = opacity_for_group(group_name)
287
+ pc.set_facecolor(color)
288
+ pc.set_edgecolor(color)
289
+ pc.set_alpha(alpha)
290
+ ax.add_collection3d(pc)
291
+ ax.set_xlim(mins[0], maxs[0])
292
+ ax.set_ylim(mins[1], maxs[1])
293
+ ax.set_zlim(mins[2], maxs[2])
294
+ ax.set_xlabel("X")
295
+ ax.set_ylabel("Y")
296
+ ax.set_zlabel("Z")
297
+ ax.view_init(elev=elev, azim=azim)
298
+ buf = io.BytesIO()
299
+ fig.savefig(buf, format="png", dpi=150, bbox_inches="tight")
300
+ plt.close(fig)
301
+ previews[name] = buf.getvalue()
302
+ return previews
303
+
304
+ # ---------------- Scene & primitives ----------------
305
+
306
+ @dataclass
307
+ class PipeSpec:
308
+ radius: float
309
+ length: float
310
+ angle_deg: float
311
+ s_center: float = 0.5
312
+ z: Optional[float] = None
313
+ offset_u: float = 0.0
314
+ n_theta: int = 96
315
+ n_along: int = 48
316
+ clearance_scale: float = 1.0
317
+
318
+ @dataclass
319
+ class BoxSpec:
320
+ along: float
321
+ across: float
322
+ height: float
323
+ s: float = 0.5
324
+ offset_u: float = 0.0
325
+ z: Optional[float] = None
326
+
327
+ @dataclass
328
+ class SphereSpec:
329
+ radius: float
330
+ s: float = 0.7
331
+ offset_u: float = 0.0
332
+ z: Optional[float] = None
333
+
334
+ @dataclass
335
+ class NoiseSpec:
336
+ enable: bool = False
337
+ amplitude: float = 0.02
338
+ corr_length: float = 0.5
339
+ octaves: int = 2
340
+ gain: float = 0.5
341
+ seed: int = 42
342
+ apply_to: Tuple[str,...] = ("trench_walls","trench_bottom")
343
+
344
+ @dataclass
345
+ class GroundSpec:
346
+ z0: float = 0.0
347
+ slope: Tuple[float,float] = (0.0, 0.0) # (dz/dx, dz/dy)
348
+ size_margin: float = 3.0
349
+
350
+ @dataclass
351
+ class SceneSpec:
352
+ path_xy: List[Tuple[float,float]]
353
+ width: float
354
+ depth: float
355
+ wall_slope: float = 0.0 # m horizontal per m depth (each side)
356
+ ground_margin: float = 0.0 # legacy; used if ground.size_margin==0
357
+ pipes: List[PipeSpec] = field(default_factory=list)
358
+ boxes: List[BoxSpec] = field(default_factory=list)
359
+ spheres: List[SphereSpec] = field(default_factory=list)
360
+ noise: NoiseSpec = field(default_factory=NoiseSpec)
361
+ ground: GroundSpec = field(default_factory=GroundSpec)
362
+
363
+
364
+ @dataclass(frozen=True)
365
+ class SurfaceMeshFiles:
366
+ obj_path: Path
367
+ metrics_path: Path
368
+ preview_paths: Tuple[Path, ...]
369
+
370
+
371
+ @dataclass
372
+ class SurfaceMeshResult:
373
+ spec: SceneSpec
374
+ groups: Dict[str, Tuple[np.ndarray, np.ndarray]]
375
+ object_counts: Dict[str, int]
376
+ metrics: Dict[str, Any]
377
+ previews: Dict[str, bytes]
378
+
379
+ def persist(self, out_dir: str | Path, *, include_previews: bool = False) -> SurfaceMeshFiles:
380
+ out_path = Path(out_dir)
381
+ out_path.mkdir(parents=True, exist_ok=True)
382
+ obj_path = out_path / "trench_scene.obj"
383
+ write_obj_with_groups(obj_path.as_posix(), self.groups)
384
+ metrics_path = out_path / "metrics.json"
385
+ with metrics_path.open("w") as fh:
386
+ json.dump(self.metrics, fh, indent=2)
387
+ preview_paths: List[Path] = []
388
+ if include_previews and self.previews:
389
+ for name, data in self.previews.items():
390
+ target = out_path / f"preview_{name}.png"
391
+ target.write_bytes(data)
392
+ preview_paths.append(target)
393
+ return SurfaceMeshFiles(obj_path=obj_path, metrics_path=metrics_path, preview_paths=tuple(preview_paths))
394
+
395
+ def _ground_fn(g: GroundSpec):
396
+ sx, sy = g.slope
397
+ def fn(x, y): return g.z0 + sx*float(x) + sy*float(y)
398
+ return fn
399
+
400
+ def _frame_from_axis(axis_dir: np.ndarray) -> np.ndarray:
401
+ v=_normalize(axis_dir)
402
+ helper=np.array([0.0,0.0,1.0],float)
403
+ if abs(np.dot(helper,v))>0.99: helper=np.array([1.0,0.0,0.0],float)
404
+ u=_normalize(np.cross(helper,v)); w=np.cross(v,u)
405
+ return np.column_stack([u,v,w])
406
+
407
+ def make_cylinder(center: np.ndarray, axis_dir: np.ndarray, radius: float, length: float,
408
+ n_theta: int=64, n_along: int=32, with_caps: bool=True):
409
+ n_theta=max(8,int(n_theta)); n_along=max(1,int(n_along))
410
+ thetas=np.linspace(0,2*np.pi,n_theta+1); ys=np.linspace(-length/2.0,length/2.0,n_along+1)
411
+ Vloc=[]
412
+ for j in range(n_along+1):
413
+ y=ys[j]
414
+ for i in range(n_theta+1):
415
+ th=thetas[i]; x=radius*np.cos(th); z=radius*np.sin(th)
416
+ Vloc.append([x,y,z])
417
+ Vloc=np.array(Vloc,float)
418
+ def idx(i,j): return j*(n_theta+1)+i
419
+ F=[]
420
+ for j in range(n_along):
421
+ for i in range(n_theta):
422
+ v00=idx(i,j); v10=idx(i+1,j); v01=idx(i,j+1); v11=idx(i+1,j+1)
423
+ F.append([v00,v01,v11]); F.append([v00,v11,v10])
424
+ F=np.array(F,int)
425
+ caps={}
426
+ if with_caps:
427
+ ring=np.array([[radius*np.cos(t),-length/2.0,radius*np.sin(t)] for t in thetas[:-1]],float)
428
+ Vn=np.vstack([np.array([[0.0,-length/2.0,0.0]],float), ring])
429
+ Fn=np.array([[0,1+(i+1)%len(ring),1+i] for i in range(len(ring))],int)
430
+ ring=np.array([[radius*np.cos(t),+length/2.0,radius*np.sin(t)] for t in thetas[:-1]],float)
431
+ Vp=np.vstack([np.array([[0.0,+length/2.0,0.0]],float), ring])
432
+ Fp=np.array([[0,1+i,1+(i+1)%len(ring)] for i in range(len(ring))],int)
433
+ caps['pipe_cap_neg']=(Vn,Fn); caps['pipe_cap_pos']=(Vp,Fp)
434
+ M=_frame_from_axis(axis_dir)
435
+ def xform(V): return (center + V @ M.T).astype(float)
436
+ out={"pipe_side": (xform(Vloc), F)}
437
+ if with_caps:
438
+ Vn,Fn=caps['pipe_cap_neg']; Vp,Fp=caps['pipe_cap_pos']
439
+ out['pipe_cap_neg']=(xform(Vn),Fn); out['pipe_cap_pos']=(xform(Vp),Fp)
440
+ return out
441
+
442
+ def make_box(center: np.ndarray, frame_cols: np.ndarray, dims: Tuple[float,float,float]):
443
+ a,b,h=dims; u=frame_cols[:,0]; v=frame_cols[:,1]; w=frame_cols[:,2]
444
+ corners=[]
445
+ for sx in [-0.5,0.5]:
446
+ for sy in [-0.5,0.5]:
447
+ for sz in [-0.5,0.5]:
448
+ corners.append(center + sx*a*u + sy*b*v + sz*h*w)
449
+ corners=np.array(corners,float)
450
+ def vid(sx,sy,sz):
451
+ ix=0 if sx<0 else 1; iy=0 if sy<0 else 1; iz=0 if sz<0 else 1
452
+ return ix*4 + iy*2 + iz
453
+ quads=[
454
+ [vid( 0.5,-0.5,-0.5), vid( 0.5, 0.5,-0.5), vid( 0.5, 0.5, 0.5), vid( 0.5,-0.5, 0.5)],
455
+ [vid(-0.5, 0.5,-0.5), vid( 0.5, 0.5,-0.5), vid( 0.5, 0.5, 0.5), vid(-0.5, 0.5, 0.5)],
456
+ [vid(-0.5,-0.5, 0.5), vid( 0.5,-0.5, 0.5), vid( 0.5, 0.5, 0.5), vid(-0.5, 0.5, 0.5)],
457
+ [vid(-0.5,-0.5,-0.5), vid( 0.5,-0.5,-0.5), vid( 0.5, 0.5,-0.5), vid(-0.5, 0.5,-0.5)],
458
+ [vid(-0.5,-0.5,-0.5), vid(-0.5, 0.5,-0.5), vid(-0.5, 0.5, 0.5), vid(-0.5,-0.5, 0.5)],
459
+ [vid(-0.5,-0.5,-0.5), vid( 0.5,-0.5,-0.5), vid( 0.5,-0.5, 0.5), vid(-0.5,-0.5, 0.5)],
460
+ ]
461
+ faces=[]
462
+ for q in quads: faces.append([q[0],q[1],q[2]]); faces.append([q[0],q[2],q[3]])
463
+ return corners, np.array(faces,int)
464
+
465
+ def make_sphere(center: np.ndarray, radius: float, n_theta: int=48, n_phi: int=24):
466
+ n_theta=max(8,int(n_theta)); n_phi=max(4,int(n_phi))
467
+ thetas=np.linspace(0,2*np.pi,n_theta+1); phis=np.linspace(0,np.pi,n_phi+1)
468
+ V=[]
469
+ for j in range(n_phi+1):
470
+ phi=phis[j]
471
+ for i in range(n_theta+1):
472
+ th=thetas[i]
473
+ x=radius*np.sin(phi)*np.cos(th); y=radius*np.sin(phi)*np.sin(th); z=radius*np.cos(phi)
474
+ V.append([center[0]+x, center[1]+y, center[2]+z])
475
+ V=np.array(V,float)
476
+ def idx(i,j): return j*(n_theta+1)+i
477
+ F=[]
478
+ for j in range(n_phi):
479
+ for i in range(n_theta):
480
+ v00=idx(i,j); v10=idx(i+1,j); v01=idx(i,j+1); v11=idx(i+1,j+1)
481
+ F.append([v00,v01,v11]); F.append([v00,v11,v10])
482
+ return V, np.array(F,int)
483
+
484
+ # --------------- Sloped trench surfaces with ground ---------------
485
+
486
+ def _ring_from_LR(L: List[np.ndarray], R: List[np.ndarray]) -> np.ndarray:
487
+ return np.array(L + list(R[::-1]), float)
488
+
489
+ def make_trench_from_path_sloped(path_xy: List[Tuple[float,float]], width_top: float, depth: float, wall_slope: float, ground) -> Tuple[Dict,str,str,dict]:
490
+ # Build top and bottom rings by offsetting centerline
491
+ half_top = width_top/2.0
492
+ shrink = max(0.0, wall_slope * depth)
493
+ half_bot = max(1e-3, half_top - shrink)
494
+ L_top, R_top = _offset_polyline(path_xy, half_top)
495
+ L_bot, R_bot = _offset_polyline(path_xy, half_bot)
496
+ poly_top = _ensure_ccw(_ring_from_LR(L_top, R_top))
497
+ poly_bot = _ensure_ccw(_ring_from_LR(L_bot, R_bot))
498
+
499
+ gfun = _ground_fn(ground)
500
+ # Top and bottom rings lie on the ground plane and ground-depth respectively
501
+ z_top = np.array([gfun(x,y) for x,y in poly_top]); z_bot = np.array([gfun(x,y) - depth for x,y in poly_bot])
502
+ tris_top = _ear_clipping_triangulation(poly_top)
503
+ tris_bot = _ear_clipping_triangulation(poly_bot)
504
+ V_cap = np.column_stack([poly_top, z_top])
505
+ V_bottom = np.column_stack([poly_bot, z_bot])
506
+ F_cap = tris_top
507
+ F_bottom = tris_bot[:, ::-1] # outward
508
+
509
+ # Walls: connect corresponding indices
510
+ N = len(poly_top); assert N == len(poly_bot)
511
+ walls_V = []; walls_F = []
512
+ for i in range(N):
513
+ j=(i+1)%N
514
+ A_top = np.array([poly_top[i,0], poly_top[i,1], z_top[i]])
515
+ B_top = np.array([poly_top[j,0], poly_top[j,1], z_top[j]])
516
+ A_bot = np.array([poly_bot[i,0], poly_bot[i,1], z_bot[i]])
517
+ B_bot = np.array([poly_bot[j,0], poly_bot[j,1], z_bot[j]])
518
+ base=len(walls_V)
519
+ walls_V.extend([A_top, B_top, B_bot, A_bot])
520
+ walls_F.extend([[base, base+1, base+2], [base, base+2, base+3]])
521
+ V_walls = np.array(walls_V,float); F_walls = np.array(walls_F,int)
522
+
523
+ groups = {
524
+ "trench_bottom": (V_bottom, F_bottom),
525
+ "trench_cap_for_volume": (V_cap, F_cap),
526
+ "trench_walls": (V_walls, F_walls)
527
+ }
528
+ extra = {
529
+ "width_top": width_top,
530
+ "width_bottom": 2.0*half_bot,
531
+ "area_top": abs(_polygon_area_2d(poly_top)),
532
+ "area_bottom": abs(_polygon_area_2d(poly_bot))
533
+ }
534
+ return groups, poly_top, poly_bot, extra
535
+
536
+ def make_ground_surface_plane(path_xy: List[Tuple[float,float]], width_top: float, ground) -> Dict[str,Tuple[np.ndarray,np.ndarray]]:
537
+ # single rectangular plane covering the trench projection + margin
538
+ half_top = width_top/2.0
539
+ L, R = _offset_polyline(path_xy, half_top)
540
+ ring = _ensure_ccw(_ring_from_LR(L, R))
541
+ minx, miny = ring.min(axis=0); maxx, maxy = ring.max(axis=0)
542
+ m = float(max(1.0, ground.size_margin))
543
+ gfun = _ground_fn(ground)
544
+ corners_xy = np.array([[minx-m,miny-m],[maxx+m,miny-m],[maxx+m,maxy+m],[minx-m,maxy+m]], float)
545
+ Vg = np.array([[x,y,gfun(x,y)] for (x,y) in corners_xy], float)
546
+ Fg = np.array([[0,1,2],[0,2,3]], int) if _polygon_area_2d(corners_xy)>0 else np.array([[0,2,1],[0,3,2]], int)
547
+ return {"ground_surface": (Vg, Fg)}
548
+
549
+ def _half_width_at_depth(half_top: float, slope: float, top_z: float, z: float) -> float:
550
+ return max(1e-6, half_top - slope * (top_z - z))
551
+
552
+ # ---------------- Noise ----------------
553
+
554
+ def vertex_normals(V: np.ndarray, F: np.ndarray) -> np.ndarray:
555
+ n=np.zeros_like(V)
556
+ p0=V[F[:,0]]; p1=V[F[:,1]]; p2=V[F[:,2]]
557
+ fn=np.cross(p1-p0,p2-p0)
558
+ for i in range(3): np.add.at(n, F[:,i], fn)
559
+ norms=np.linalg.norm(n,axis=1); norms[norms==0]=1.0
560
+ return n / norms[:,None]
561
+
562
+ def smooth_noise_field(points: np.ndarray, seed: int, corr_length: float, octaves: int=2, gain: float=0.5) -> np.ndarray:
563
+ rng=np.random.default_rng(seed); K=7
564
+ val=np.zeros(points.shape[0],float)
565
+ base_k=2.0*np.pi/max(corr_length,1e-6)
566
+ for o in range(octaves):
567
+ kscale=(2**o)*base_k; amp=(gain**o)
568
+ ks=rng.normal(size=(K,3)); ks=ks/np.linalg.norm(ks,axis=1)[:,None]*kscale
569
+ phase=rng.uniform(0,2*np.pi,size=(K,))
570
+ proj=points@ks.T
571
+ val += amp * np.sum(np.cos(proj + phase), axis=1) / K
572
+ return val
573
+
574
+ def apply_vertex_noise(groups: Dict[str, Tuple[np.ndarray, np.ndarray]], patterns: List[str],
575
+ amplitude: float, seed: int, corr_length: float, octaves:int=2, gain:float=0.5):
576
+ import fnmatch
577
+ out={}
578
+ for name,(V,F) in groups.items():
579
+ if any(fnmatch.fnmatch(name, pat) for pat in patterns):
580
+ nrm=vertex_normals(V,F)
581
+ field=smooth_noise_field(V, seed, corr_length, octaves, gain)
582
+ Vn=V + (amplitude*field)[:,None]*nrm
583
+ out[name]=(Vn, F.copy())
584
+ else:
585
+ out[name]=(V.copy(), F.copy())
586
+ return out
587
+
588
+ # --------------- Scene builder ---------------
589
+
590
+ def _build_surface_groups(
591
+ spec: SceneSpec,
592
+ ) -> Tuple[Dict[str, Tuple[np.ndarray, np.ndarray]], Dict[str, int], Dict[str, Any]]:
593
+ groups: Dict[str, Tuple[np.ndarray, np.ndarray]] = {}
594
+
595
+ trench_groups, _, _, extra = make_trench_from_path_sloped(
596
+ spec.path_xy, spec.width, spec.depth, spec.wall_slope, spec.ground
597
+ )
598
+ groups.update(trench_groups)
599
+
600
+ if spec.ground and spec.ground.size_margin > 0:
601
+ groups.update(make_ground_surface_plane(spec.path_xy, spec.width, spec.ground))
602
+ else:
603
+ L, R = _offset_polyline(spec.path_xy, spec.width / 2.0)
604
+ gfun = _ground_fn(spec.ground)
605
+
606
+ def tri_quad_ccw(v0, v1, v2, v3):
607
+ poly = np.array([v0, v1, v2, v3], float)
608
+ if _polygon_area_2d(poly[:, :2]) < 0:
609
+ poly = poly[::-1]
610
+ return np.array([[0, 1, 2], [0, 2, 3]], int), poly
611
+
612
+ V_left: List[List[float]] = []
613
+ F_left: List[List[int]] = []
614
+ for i in range(len(L) - 1):
615
+ v0 = [L[i][0], L[i][1], gfun(*L[i])]
616
+ v1 = [L[i + 1][0], L[i + 1][1], gfun(*L[i + 1])]
617
+ v2 = [spec.path_xy[i + 1][0], spec.path_xy[i + 1][1], gfun(*spec.path_xy[i + 1])]
618
+ v3 = [spec.path_xy[i][0], spec.path_xy[i][1], gfun(*spec.path_xy[i])]
619
+ tris, poly = tri_quad_ccw(v0, v1, v2, v3)
620
+ base = len(V_left)
621
+ V_left += poly.tolist()
622
+ F_left += (tris + base).tolist()
623
+ if V_left:
624
+ groups["ground_left_strip"] = (np.array(V_left, float), np.array(F_left, int))
625
+
626
+ V_right: List[List[float]] = []
627
+ F_right: List[List[int]] = []
628
+ for i in range(len(R) - 1):
629
+ v0 = [spec.path_xy[i][0], spec.path_xy[i][1], gfun(*spec.path_xy[i])]
630
+ v1 = [spec.path_xy[i + 1][0], spec.path_xy[i + 1][1], gfun(*spec.path_xy[i + 1])]
631
+ v2 = [R[i + 1][0], R[i + 1][1], gfun(*R[i + 1])]
632
+ v3 = [R[i][0], R[i][1], gfun(*R[i])]
633
+ tris, poly = tri_quad_ccw(v0, v1, v2, v3)
634
+ base = len(V_right)
635
+ V_right += poly.tolist()
636
+ F_right += (tris + base).tolist()
637
+ if V_right:
638
+ groups["ground_right_strip"] = (np.array(V_right, float), np.array(F_right, int))
639
+
640
+ half_top = spec.width * 0.5
641
+ gfun = _ground_fn(spec.ground)
642
+ clearance = 0.02
643
+
644
+ for idx, p in enumerate(spec.pipes):
645
+ pos_xy, tangent = _sample_polyline_at_s(spec.path_xy, p.s_center)
646
+ angle = math.radians(p.angle_deg)
647
+ t_rot = np.array(
648
+ [
649
+ math.cos(angle) * tangent[0] - math.sin(angle) * tangent[1],
650
+ math.sin(angle) * tangent[0] + math.cos(angle) * tangent[1],
651
+ ],
652
+ float,
653
+ )
654
+ axis_dir = np.array([t_rot[0], t_rot[1], 0.0], float)
655
+ left_normal = _rotate_ccw(tangent)
656
+ top_z = gfun(pos_xy[0], pos_xy[1])
657
+ req_u = float(p.offset_u)
658
+ req_z = float(p.z if p.z is not None else (top_z - spec.depth * 0.5))
659
+ z_min = top_z - spec.depth + (p.radius + clearance)
660
+ z_max = top_z - (p.radius + clearance)
661
+ zc = float(np.clip(req_z, z_min, z_max))
662
+ half_w = _half_width_at_depth(half_top, spec.wall_slope, top_z, zc)
663
+ umax = max(0.0, half_w - (p.radius + clearance))
664
+ u = float(np.clip(req_u, -umax, umax))
665
+ ctr_xy = pos_xy + u * left_normal
666
+ center = np.array([ctr_xy[0], ctr_xy[1], zc], float)
667
+ cyl = make_cylinder(center, axis_dir, p.radius, p.length, p.n_theta, p.n_along, with_caps=True)
668
+ for key, (V, F) in cyl.items():
669
+ groups[f"pipe{idx}_{key}"] = (V, F)
670
+
671
+ for j, b in enumerate(spec.boxes):
672
+ pos_xy, tangent = _sample_polyline_at_s(spec.path_xy, b.s)
673
+ left_normal = _rotate_ccw(tangent)
674
+ top_z = gfun(pos_xy[0], pos_xy[1])
675
+ req_u = float(b.offset_u)
676
+ req_z = float(b.z if b.z is not None else (top_z - spec.depth + b.height * 0.5))
677
+ z_min = top_z - spec.depth + (b.height * 0.5 + clearance)
678
+ z_max = top_z - (b.height * 0.5 + clearance)
679
+ zc = float(np.clip(req_z, z_min, z_max))
680
+ half_w = _half_width_at_depth(half_top, spec.wall_slope, top_z, zc)
681
+ umax = max(0.0, half_w - (b.across * 0.5 + clearance))
682
+ u = float(np.clip(req_u, -umax, umax))
683
+ ctr_xy = pos_xy + u * left_normal
684
+ center = np.array([ctr_xy[0], ctr_xy[1], zc], float)
685
+ frame_cols = np.column_stack(
686
+ [
687
+ np.array([tangent[0], tangent[1], 0.0]),
688
+ np.array([left_normal[0], left_normal[1], 0.0]),
689
+ np.array([0.0, 0.0, 1.0]),
690
+ ]
691
+ )
692
+ Vb, Fb = make_box(center, frame_cols, (b.along, b.across, b.height))
693
+ groups[f"box{j}"] = (Vb, Fb)
694
+
695
+ for k, s in enumerate(spec.spheres):
696
+ pos_xy, tangent = _sample_polyline_at_s(spec.path_xy, s.s)
697
+ left_normal = _rotate_ccw(tangent)
698
+ top_z = gfun(pos_xy[0], pos_xy[1])
699
+ req_u = float(s.offset_u)
700
+ req_z = float(s.z if s.z is not None else (top_z - spec.depth + s.radius))
701
+ z_min = top_z - spec.depth + (s.radius + clearance)
702
+ z_max = top_z - (s.radius + clearance)
703
+ zc = float(np.clip(req_z, z_min, z_max))
704
+ half_w = _half_width_at_depth(half_top, spec.wall_slope, top_z, zc)
705
+ umax = max(0.0, half_w - (s.radius + clearance))
706
+ u = float(np.clip(req_u, -umax, umax))
707
+ ctr_xy = pos_xy + u * left_normal
708
+ center = np.array([ctr_xy[0], ctr_xy[1], zc], float)
709
+ Vs, Fs = make_sphere(center, s.radius, n_theta=64, n_phi=32)
710
+ groups[f"sphere{k}"] = (Vs, Fs)
711
+
712
+ if spec.noise and spec.noise.enable:
713
+ groups = apply_vertex_noise(
714
+ groups,
715
+ list(spec.noise.apply_to),
716
+ amplitude=spec.noise.amplitude,
717
+ seed=spec.noise.seed,
718
+ corr_length=spec.noise.corr_length,
719
+ octaves=spec.noise.octaves,
720
+ gain=spec.noise.gain,
721
+ )
722
+
723
+ object_counts = {
724
+ "pipes": len(spec.pipes),
725
+ "boxes": len(spec.boxes),
726
+ "spheres": len(spec.spheres),
727
+ }
728
+ return groups, object_counts, extra
729
+
730
+
731
+ def generate_surface_mesh(spec: SceneSpec, *, make_preview: bool = False) -> SurfaceMeshResult:
732
+ groups, object_counts, extra = _build_surface_groups(spec)
733
+ metrics = _compute_surface_metrics(groups, extra, spec)
734
+ previews = _render_surface_previews(groups) if make_preview else {}
735
+ return SurfaceMeshResult(
736
+ spec=spec,
737
+ groups=groups,
738
+ object_counts=object_counts,
739
+ metrics=metrics,
740
+ previews=previews,
741
+ )
742
+
743
+
744
+ def build_scene(spec: SceneSpec, out_dir: str, make_preview=False):
745
+ result = generate_surface_mesh(spec, make_preview=make_preview)
746
+ files = result.persist(out_dir, include_previews=make_preview)
747
+ return {
748
+ "obj_path": files.obj_path.as_posix(),
749
+ "metrics": result.metrics,
750
+ "previews": [p.as_posix() for p in files.preview_paths],
751
+ "object_counts": result.object_counts,
752
+ "surface_result": result,
753
+ }
754
+
755
+ # ---------------- CLI ----------------
756
+
757
+ def scene_spec_from_dict(cfg: Dict[str, Any]) -> SceneSpec:
758
+ pipes=[PipeSpec(**p) for p in cfg.get("pipes", [])]
759
+ boxes=[BoxSpec(**b) for b in cfg.get("boxes", [])]
760
+ spheres=[SphereSpec(**s) for s in cfg.get("spheres", [])]
761
+ noise_cfg = cfg.get("noise", {})
762
+ noise = NoiseSpec(**noise_cfg) if noise_cfg else NoiseSpec(enable=False)
763
+ ground_cfg = cfg.get("ground", {})
764
+ ground = GroundSpec(**ground_cfg) if ground_cfg else GroundSpec()
765
+ return SceneSpec(path_xy=[tuple(map(float, p)) for p in cfg["path_xy"]],
766
+ width=float(cfg["width"]), depth=float(cfg["depth"]),
767
+ wall_slope=float(cfg.get("wall_slope", 0.0)),
768
+ ground_margin=float(cfg.get("ground_margin", 0.0)),
769
+ pipes=pipes, boxes=boxes, spheres=spheres, noise=noise, ground=ground)
770
+
771
+ def load_scene_spec_from_json(path: str) -> SceneSpec:
772
+ with open(path,"r") as f: cfg=json.load(f)
773
+ return scene_spec_from_dict(cfg)
774
+
775
+ def main():
776
+ ap=argparse.ArgumentParser(description="Synthetic trench scene (surface, sloped walls, grounded)")
777
+ ap.add_argument("--spec", required=True)
778
+ ap.add_argument("--out", required=True)
779
+ ap.add_argument("--preview", action="store_true")
780
+ args=ap.parse_args()
781
+ spec=load_scene_spec_from_json(args.spec)
782
+ out=build_scene(spec, args.out, make_preview=args.preview)
783
+ response = {
784
+ "obj_path": out["obj_path"],
785
+ "metrics_path": os.path.join(args.out, "metrics.json"),
786
+ "objects": out["object_counts"],
787
+ "previews": out["previews"],
788
+ "preview_count": len(out["previews"]),
789
+ "footprint_top": out["metrics"]["footprint_area_top"],
790
+ "footprint_bottom": out["metrics"]["footprint_area_bottom"],
791
+ "trench_from_surface": out["metrics"]["volumes"]["trench_from_surface"]
792
+ }
793
+ if args.preview and plt is None:
794
+ response["preview_note"] = "matplotlib_unavailable"
795
+ print(json.dumps(response, indent=2))
796
+
797
+ if __name__ == "__main__":
798
+ main()