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,508 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ gmsh_sloped_trench_mesher.py
5
+ Ground-aware volumetric mesher with sloped walls and conformal pipes.
6
+ Usage:
7
+ python gmsh_sloped_trench_mesher.py --spec scene.json --out ./vol --lc 0.3
8
+ """
9
+ import json, math, os, sys, argparse
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Callable, Dict, List, Optional, Tuple
13
+ import numpy as np
14
+ import gmsh # pip install gmsh
15
+
16
+ PIPE_CLEARANCE_BASE = 0.05 # baseline minimum (metres) between pipe surfaces and trench walls
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class VolumeElementBlock:
21
+ gmsh_type: int
22
+ element_tags: np.ndarray
23
+ node_tags: np.ndarray
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class PhysicalGroupInfo:
28
+ dimension: int
29
+ tag: int
30
+ name: str
31
+ entity_tags: Tuple[int, ...]
32
+ element_tags: Dict[int, np.ndarray]
33
+
34
+
35
+ @dataclass
36
+ class VolumeMeshResult:
37
+ node_tags: np.ndarray
38
+ nodes: np.ndarray
39
+ element_blocks: List[VolumeElementBlock]
40
+ physical_groups: List[PhysicalGroupInfo]
41
+ pipe_clearances: List[Dict[str, object]]
42
+ persisted_path: Optional[Path]
43
+ mesh_characteristic_length: Optional[float]
44
+
45
+ def _normalize(v):
46
+ n = np.linalg.norm(v);
47
+ return v if n==0 else v/n
48
+
49
+ def _rotate_ccw(v): return np.array([-v[1], v[0]], float)
50
+
51
+ def _line_intersection_2d(p, d, q, e):
52
+ M = np.array([d, -e], float).T; det = np.linalg.det(M)
53
+ if abs(det) < 1e-12: return None
54
+ t, s = np.linalg.solve(M, (q - p)); return p + t * d
55
+
56
+ def _offset_polyline(path, offset):
57
+ P = np.array(path, float); n = len(P)
58
+ tangents=[]; normals=[]
59
+ for i in range(n-1):
60
+ t = _normalize(P[i+1]-P[i]);
61
+ if np.linalg.norm(t) < 1e-12: t = np.array([1.0, 0.0])
62
+ tangents.append(t); normals.append(np.array([-t[1], t[0]], float))
63
+ L=[P[0] + offset*normals[0]]; R=[P[0] - offset*normals[0]]
64
+ for k in range(1,n-1):
65
+ t_prev, n_prev = tangents[k-1], normals[k-1]
66
+ t_next, n_next = tangents[k], normals[k]
67
+ L1_p = P[k] + offset * n_prev; L1_d = t_prev
68
+ L2_p = P[k] + offset * n_next; L2_d = t_next
69
+ R1_p = P[k] - offset * n_prev; R1_d = t_prev
70
+ R2_p = P[k] - offset * n_next; R2_d = t_next
71
+ Lp = _line_intersection_2d(L1_p, L1_d, L2_p, L2_d)
72
+ Rp = _line_intersection_2d(R1_p, R1_d, R2_p, R2_d)
73
+ if Lp is None: Lp = 0.5*(L1_p+L2_p)
74
+ if Rp is None: Rp = 0.5*(R1_p+R2_p)
75
+ L.append(Lp); R.append(Rp)
76
+ L.append(P[-1] + offset*normals[-1])
77
+ R.append(P[-1] - offset*normals[-1])
78
+ return L, R
79
+
80
+ def _ring_from_LR(L, R): return np.array(L + list(R[::-1]), float)
81
+
82
+ def _add_closed_wire_xyz(points_xyz):
83
+ pt = [gmsh.model.occ.addPoint(float(x), float(y), float(z)) for x,y,z in points_xyz]
84
+ lines = []
85
+ for i in range(len(pt)):
86
+ a = pt[i]; b = pt[(i+1) % len(pt)]
87
+ lines.append(gmsh.model.occ.addLine(a, b))
88
+ loop = gmsh.model.occ.addCurveLoop(lines)
89
+ return loop
90
+
91
+ def _sample_polyline_at_s(path, s):
92
+ P = np.array(path, float)
93
+ segs = P[1:] - P[:-1]; lens = np.linalg.norm(segs, axis=1)
94
+ cum = np.concatenate([[0.0], np.cumsum(lens)]); total = float(cum[-1])
95
+ if total == 0: return P[0], np.array([1.0, 0.0])
96
+ s_abs = s * total; i = int(np.searchsorted(cum, s_abs, side="right") - 1); i = max(0, min(i, len(P)-2))
97
+ seg = P[i+1] - P[i]; L = np.linalg.norm(seg)
98
+ if L == 0: return P[i], np.array([1.0, 0.0])
99
+ u = (s_abs - cum[i]) / L; pos = (1-u)*P[i] + u*P[i+1]; t = seg / L;
100
+ return pos, t
101
+
102
+ def _polyline_lengths(path):
103
+ P = np.array(path, float)
104
+ if len(P) < 2:
105
+ return np.array([0.0]), 0.0
106
+ segs = P[1:] - P[:-1]
107
+ lens = np.linalg.norm(segs, axis=1)
108
+ cum = np.concatenate([[0.0], np.cumsum(lens)])
109
+ return cum, float(cum[-1])
110
+
111
+ def _line_segment_intersection_param(p, direction, a, b):
112
+ seg = np.array(b, float) - np.array(a, float)
113
+ M = np.array([direction, -seg]).T
114
+ det = np.linalg.det(M)
115
+ if abs(det) < 1e-12:
116
+ return None
117
+ t, u = np.linalg.solve(M, np.array(a, float) - np.array(p, float))
118
+ if -1e-9 <= u <= 1 + 1e-9:
119
+ return float(t)
120
+ return None
121
+
122
+ def _max_extent_in_polygon(center, direction, poly_xy, clearance):
123
+ direction = _normalize(np.array(direction, float))
124
+ pos_intersections = []
125
+ neg_intersections = []
126
+ m = len(poly_xy)
127
+ for i in range(m):
128
+ a = poly_xy[i]
129
+ b = poly_xy[(i + 1) % m]
130
+ t = _line_segment_intersection_param(center, direction, a, b)
131
+ if t is None:
132
+ continue
133
+ if t > 0:
134
+ pos_intersections.append(t)
135
+ elif t < 0:
136
+ neg_intersections.append(t)
137
+ if not pos_intersections or not neg_intersections:
138
+ return math.inf
139
+ positive_min = min(pos_intersections)
140
+ negative_max = max(neg_intersections)
141
+ return max(0.0, min(positive_min, -negative_max) - clearance)
142
+
143
+ def _pipe_clearance(radius: float, wall_slope: float) -> float:
144
+ """Adaptive clearance: scale guard band with pipe radius and wall slope."""
145
+ radius_term = radius * 0.35
146
+ slope_term = abs(float(wall_slope)) * 0.02 * radius
147
+ return max(PIPE_CLEARANCE_BASE, radius_term + slope_term)
148
+
149
+ def generate_trench_volume(
150
+ cfg,
151
+ *,
152
+ lc=0.3,
153
+ persist_path: Optional[str | Path] = None,
154
+ finalize: bool = True,
155
+ debug_callback: Optional[Callable[[Dict[str, object]], None]] = None,
156
+ debug_export: Optional[str] = None,
157
+ ):
158
+ gmsh.initialize()
159
+ persist_path_obj = Path(persist_path) if persist_path is not None else None
160
+ try:
161
+ gmsh.model.add("trench_volume")
162
+
163
+ path_xy = [tuple(map(float, p)) for p in cfg["path_xy"]]
164
+ width_top = float(cfg["width"])
165
+ depth = float(cfg["depth"])
166
+ slope = float(cfg.get("wall_slope", 0.0))
167
+ ground_cfg = cfg.get("ground", {})
168
+ z0 = float(ground_cfg.get("z0", 0.0))
169
+ sx, sy = tuple(ground_cfg.get("slope", (0.0, 0.0)))
170
+
171
+ def g(x, y): return z0 + sx*float(x) + sy*float(y)
172
+
173
+ half_top = width_top / 2.0
174
+ half_bot = max(1e-3, half_top - slope * depth)
175
+
176
+ Ltop, Rtop = _offset_polyline(path_xy, half_top)
177
+ Lbot, Rbot = _offset_polyline(path_xy, half_bot)
178
+ ring_top = _ring_from_LR(Ltop, Rtop)
179
+ ring_bot = _ring_from_LR(Lbot, Rbot)
180
+
181
+ ring_top_xyz = [(x, y, g(x, y)) for (x, y) in ring_top]
182
+ ring_bot_xyz = [(x, y, g(x, y) - depth) for (x, y) in ring_bot]
183
+
184
+ top_loop = _add_closed_wire_xyz(ring_top_xyz)
185
+ bot_loop = _add_closed_wire_xyz(ring_bot_xyz)
186
+
187
+ outDimTags = []
188
+ try:
189
+ gmsh.model.occ.addThruSections(
190
+ [top_loop, bot_loop], makeSolid=True, makeRuled=True, outDimTags=outDimTags
191
+ )
192
+ except TypeError: # gmsh >= 4.14 removed outDimTags kwarg
193
+ outDimTags = gmsh.model.occ.addThruSections(
194
+ [top_loop, bot_loop], makeSolid=True, makeRuled=True
195
+ )
196
+ if outDimTags is None:
197
+ outDimTags = []
198
+ gmsh.model.occ.healShapes()
199
+ gmsh.model.occ.removeAllDuplicates()
200
+ gmsh.model.occ.synchronize()
201
+
202
+ vols = [tag for (dim, tag) in outDimTags if dim == 3]
203
+ assert len(vols) >= 1, "Loft did not create a volume"
204
+ trench_vol = vols[0]
205
+
206
+ pipe_cfgs = cfg.get("pipes", [])
207
+ cum_lengths, total_length = _polyline_lengths(path_xy)
208
+ pipe_dimtags = []
209
+ clearance_records: List[Dict[str, object]] = []
210
+ for i, p in enumerate(pipe_cfgs):
211
+ radius = float(p["radius"])
212
+ orig_length = float(p["length"])
213
+ angle = math.radians(float(p["angle_deg"]))
214
+ s_center = float(p.get("s_center", 0.5))
215
+ zc = float(p.get("z", -depth * 0.5))
216
+ offset_u = float(p.get("offset_u", 0.0))
217
+ clearance_scale = float(p.get("clearance_scale", 1.0))
218
+ if not math.isfinite(clearance_scale) or clearance_scale <= 0:
219
+ raise ValueError(f"clearance_scale for pipe[{i}] must be > 0")
220
+ pos_xy, tangent = _sample_polyline_at_s(path_xy, s_center)
221
+ axis_xy = _normalize(np.array(
222
+ [
223
+ math.cos(angle) * tangent[0] - math.sin(angle) * tangent[1],
224
+ math.sin(angle) * tangent[0] + math.cos(angle) * tangent[1],
225
+ ],
226
+ float,
227
+ ))
228
+ left_n = _rotate_ccw(tangent)
229
+ # clamp inside
230
+ top_here = g(pos_xy[0], pos_xy[1])
231
+ clearance = _pipe_clearance(radius, slope) * clearance_scale
232
+ warn_threshold = 0.5 * clearance
233
+ z_min = top_here - depth + (radius + clearance)
234
+ z_max = top_here - (radius + clearance)
235
+ zc = max(z_min, min(zc, z_max))
236
+ half_w = max(1e-6, half_top - slope * (top_here - zc))
237
+ umax = max(0.0, half_w - (radius + clearance))
238
+ offset_u = max(-umax, min(offset_u, umax))
239
+ ctr_xy = pos_xy + offset_u * left_n
240
+ # clamp length to stay within trench extents
241
+ t_component = float(np.dot(axis_xy, tangent))
242
+ n_component = float(np.dot(axis_xy, left_n))
243
+ s_abs = s_center * total_length
244
+ dist_start = s_abs
245
+ dist_end = total_length - s_abs
246
+ path_margin = radius + clearance
247
+ allow_path = max(0.0, min(dist_start, dist_end) - path_margin)
248
+ half_len_allow_path = (
249
+ allow_path / max(abs(t_component), 1e-6) if allow_path > 0 else math.inf
250
+ )
251
+ lateral_allow = max(0.0, half_w - (radius + clearance))
252
+ half_len_allow_lat = (
253
+ lateral_allow / max(abs(n_component), 1e-6) if lateral_allow > 0 else math.inf
254
+ )
255
+ poly_extent = _max_extent_in_polygon(ctr_xy, axis_xy, ring_top, radius + clearance)
256
+ half_len_allow_poly = poly_extent
257
+ half_len_cap = min(half_len_allow_path, half_len_allow_lat, half_len_allow_poly)
258
+ length = orig_length
259
+ if math.isfinite(half_len_cap):
260
+ max_length = max(0.0, 2.0 * half_len_cap)
261
+ if max_length > 0.0:
262
+ length = min(length, max_length)
263
+ length = max(length, radius * 0.5)
264
+
265
+ half_length = 0.5 * length
266
+ axis_margin_poly = (
267
+ (half_len_allow_poly - half_length) if math.isfinite(half_len_allow_poly) else math.inf
268
+ )
269
+ axis_margin_lat = (
270
+ (half_len_allow_lat - half_length) if math.isfinite(half_len_allow_lat) else math.inf
271
+ )
272
+ axis_margin_path = (
273
+ (half_len_allow_path - half_length) if math.isfinite(half_len_allow_path) else math.inf
274
+ )
275
+ lateral_gap = half_w - abs(offset_u) - radius
276
+ lateral_margin = lateral_gap - clearance
277
+ min_margin_val = min(axis_margin_poly, axis_margin_lat, axis_margin_path, lateral_margin)
278
+
279
+ def _finite(val: float) -> Optional[float]:
280
+ return float(val) if math.isfinite(val) else None
281
+
282
+ stored_min_margin = _finite(min_margin_val)
283
+
284
+ clearance_records.append(
285
+ {
286
+ "pipe_index": i,
287
+ "radius": radius,
288
+ "length": length,
289
+ "s_center": s_center,
290
+ "axis_margin_poly": _finite(axis_margin_poly),
291
+ "axis_margin_lat": _finite(axis_margin_lat),
292
+ "axis_margin_path": _finite(axis_margin_path),
293
+ "lateral_gap": float(lateral_gap),
294
+ "lateral_margin": float(lateral_margin),
295
+ "min_margin": stored_min_margin,
296
+ "clearance": float(clearance),
297
+ "warn_threshold": float(warn_threshold),
298
+ "clearance_scale": float(clearance_scale),
299
+ "center_xy": (float(ctr_xy[0]), float(ctr_xy[1])),
300
+ "offset_u": float(offset_u),
301
+ }
302
+ )
303
+
304
+ # axis in XY, constant Z
305
+ dx, dy, dz = (axis_xy[0] * length, axis_xy[1] * length, 0.0)
306
+ base_x = float(ctr_xy[0] - 0.5 * dx)
307
+ base_y = float(ctr_xy[1] - 0.5 * dy)
308
+ base_z = float(zc - 0.5 * dz)
309
+ cyl = gmsh.model.occ.addCylinder(base_x, base_y, base_z, dx, dy, dz, radius)
310
+ pipe_dimtags.append((3, cyl))
311
+
312
+ critical_clearance = [
313
+ rec for rec in clearance_records if rec["min_margin"] is not None and rec["min_margin"] < -1e-6
314
+ ]
315
+ near_violation = [
316
+ rec
317
+ for rec in clearance_records
318
+ if rec["min_margin"] is not None and rec["min_margin"] < rec["warn_threshold"]
319
+ ]
320
+ if critical_clearance or near_violation:
321
+ def _fmt(rec: Dict[str, object]) -> str:
322
+ idx = rec["pipe_index"]
323
+ min_margin_val = rec["min_margin"]
324
+ lat_gap = rec["lateral_gap"]
325
+ axis_poly = rec["axis_margin_poly"]
326
+ axis_path = rec["axis_margin_path"]
327
+
328
+ def _fmt_opt(val: Optional[float]) -> str:
329
+ if val is None:
330
+ return "inf"
331
+ return f"{val:.4f}"
332
+
333
+ return (
334
+ f"pipe[{idx}] min_margin={_fmt_opt(min_margin_val)}m "
335
+ f"(lateral_gap={lat_gap:.4f}m, axis_poly_margin={_fmt_opt(axis_poly)}m, "
336
+ f"axis_path_margin={_fmt_opt(axis_path)}m, clearance_scale={rec['clearance_scale']:.2f})"
337
+ )
338
+
339
+ if critical_clearance:
340
+ details = "\n".join(_fmt(rec) for rec in critical_clearance)
341
+ raise ValueError(
342
+ "Pipe clearance fell below the required guard band; adjust scenario geometry:\n" + details
343
+ )
344
+ else:
345
+ for rec in near_violation:
346
+ print(
347
+ "[trenchfoot] clearance warning: "
348
+ + _fmt(rec)
349
+ + f" (< {rec['warn_threshold']:.3f}m headroom)",
350
+ file=sys.stderr,
351
+ )
352
+
353
+ gmsh.model.occ.synchronize()
354
+ if pipe_dimtags:
355
+ outDT, outMap = gmsh.model.occ.fragment(
356
+ [(3, trench_vol)], pipe_dimtags, removeObject=True, removeTool=True
357
+ )
358
+ gmsh.model.occ.synchronize()
359
+ gmsh.model.occ.removeAllDuplicates()
360
+ gmsh.model.occ.synchronize()
361
+ available_vols = {tag for (dim, tag) in gmsh.model.getEntities(dim=3)}
362
+ if isinstance(outMap, dict):
363
+ trench_new = outMap.get((3, trench_vol), [])
364
+ pipes_new_lists = [outMap.get(dimtag, []) for dimtag in pipe_dimtags]
365
+ else: # gmsh >= 4.14 returns list of lists
366
+ trench_new = outMap[0] if len(outMap) >= 1 else []
367
+ pipes_new_lists = outMap[1:1 + len(pipe_dimtags)]
368
+ while len(pipes_new_lists) < len(pipe_dimtags):
369
+ pipes_new_lists.append([])
370
+ pipe_volume_tags = {t for lst in pipes_new_lists for (d, t) in lst if d == 3}
371
+ trench_tags = [t for (d, t) in trench_new if d == 3 and t not in pipe_volume_tags]
372
+ if not trench_tags:
373
+ trench_tags = [t for (d, t) in outDT if d == 3 and t not in pipe_volume_tags]
374
+ trench_tags = [t for t in trench_tags if t in available_vols]
375
+ if trench_tags:
376
+ gmsh.model.addPhysicalGroup(3, trench_tags, tag=1, name="TrenchAir")
377
+ for i, lst in enumerate(pipes_new_lists):
378
+ vol_tags = [t for (d, t) in lst if d == 3 and t in available_vols]
379
+ if vol_tags:
380
+ gmsh.model.addPhysicalGroup(3, vol_tags, tag=100 + i, name=f"Pipe{i}")
381
+ else:
382
+ available_vols = {tag for (dim, tag) in gmsh.model.getEntities(dim=3)}
383
+ trench_tags = [t for t in available_vols] or ([trench_vol] if trench_vol in available_vols else [])
384
+ if trench_tags:
385
+ gmsh.model.addPhysicalGroup(3, trench_tags, tag=1, name="TrenchAir")
386
+
387
+ if debug_export:
388
+ os.makedirs(debug_export, exist_ok=True)
389
+ gmsh.write(os.path.join(debug_export, "geom_pre_mesh.brep"))
390
+
391
+ if debug_callback is not None:
392
+ ctx: Dict[str, object] = {
393
+ "volumes": gmsh.model.getEntities(dim=3),
394
+ "surfaces": gmsh.model.getEntities(dim=2),
395
+ "physical_groups": gmsh.model.getPhysicalGroups(),
396
+ "out_msh": persist_path_obj.as_posix() if persist_path_obj else None,
397
+ "pipe_clearances": clearance_records,
398
+ }
399
+ try:
400
+ debug_callback(ctx)
401
+ except Exception as exc: # pragma: no cover - debug aid only
402
+ print(f"[trenchfoot] debug_callback failed: {exc}")
403
+
404
+ if lc is not None:
405
+ gmsh.option.setNumber("Mesh.CharacteristicLengthMin", lc)
406
+ gmsh.option.setNumber("Mesh.CharacteristicLengthMax", lc)
407
+ gmsh.model.mesh.generate(3)
408
+
409
+ persisted = None
410
+ if persist_path_obj is not None:
411
+ persist_path_obj.parent.mkdir(parents=True, exist_ok=True)
412
+ gmsh.write(persist_path_obj.as_posix())
413
+ persisted = persist_path_obj
414
+
415
+ node_tags, node_coords, _ = gmsh.model.mesh.getNodes()
416
+ node_tags_arr = np.array(node_tags, dtype=int)
417
+ node_coords_arr = np.array(node_coords, dtype=float)
418
+ nodes = node_coords_arr.reshape(-1, 3) if node_coords_arr.size else np.zeros((0, 3), float)
419
+
420
+ elem_types, elem_tags, elem_node_tags = gmsh.model.mesh.getElements()
421
+ element_blocks: List[VolumeElementBlock] = []
422
+ for etype, tags, node_conn in zip(elem_types, elem_tags, elem_node_tags):
423
+ tags_arr = np.array(tags, dtype=int)
424
+ node_conn_arr = np.array(node_conn, dtype=int)
425
+ if tags_arr.size:
426
+ if node_conn_arr.size:
427
+ nodes_per_elem = node_conn_arr.size // tags_arr.size
428
+ node_conn_arr = node_conn_arr.reshape(tags_arr.size, nodes_per_elem)
429
+ else:
430
+ node_conn_arr = np.empty((tags_arr.size, 0), dtype=int)
431
+ else:
432
+ node_conn_arr = np.empty((0, 0), dtype=int)
433
+ element_blocks.append(
434
+ VolumeElementBlock(
435
+ gmsh_type=int(etype),
436
+ element_tags=tags_arr,
437
+ node_tags=node_conn_arr,
438
+ )
439
+ )
440
+
441
+ physical_groups: List[PhysicalGroupInfo] = []
442
+ for dim, tag in gmsh.model.getPhysicalGroups():
443
+ name = gmsh.model.getPhysicalName(dim, tag) or f"dim{dim}_tag{tag}"
444
+ entities = tuple(gmsh.model.getEntitiesForPhysicalGroup(dim, tag))
445
+ pg_types, pg_elem_tags = gmsh.model.mesh.getElementsForPhysicalGroup(dim, tag)
446
+ elements_map: Dict[int, np.ndarray] = {
447
+ int(t): np.array(tags, dtype=int)
448
+ for t, tags in zip(pg_types, pg_elem_tags)
449
+ }
450
+ physical_groups.append(
451
+ PhysicalGroupInfo(
452
+ dimension=dim,
453
+ tag=tag,
454
+ name=name,
455
+ entity_tags=entities,
456
+ element_tags=elements_map,
457
+ )
458
+ )
459
+
460
+ return VolumeMeshResult(
461
+ node_tags=node_tags_arr,
462
+ nodes=nodes,
463
+ element_blocks=element_blocks,
464
+ physical_groups=physical_groups,
465
+ pipe_clearances=clearance_records,
466
+ persisted_path=persisted,
467
+ mesh_characteristic_length=float(lc) if lc is not None else None,
468
+ )
469
+ finally:
470
+ if finalize:
471
+ gmsh.finalize()
472
+
473
+
474
+ def build_trench_volume_from_spec(
475
+ cfg,
476
+ lc=0.3,
477
+ out_msh="mesh.msh",
478
+ *,
479
+ finalize: bool = True,
480
+ debug_callback: Optional[Callable[[Dict[str, object]], None]] = None,
481
+ debug_export: Optional[str] = None,
482
+ ):
483
+ result = generate_trench_volume(
484
+ cfg,
485
+ lc=lc,
486
+ persist_path=out_msh,
487
+ finalize=finalize,
488
+ debug_callback=debug_callback,
489
+ debug_export=debug_export,
490
+ )
491
+ if result.persisted_path is None:
492
+ return out_msh
493
+ return result.persisted_path.as_posix()
494
+
495
+
496
+ def main():
497
+ ap = argparse.ArgumentParser(description="Volumetric sloped-trench mesher (ground-aware)")
498
+ ap.add_argument("--spec", required=True)
499
+ ap.add_argument("--out", required=True)
500
+ ap.add_argument("--lc", type=float, default=0.3, help="Target mesh size")
501
+ args = ap.parse_args()
502
+ with open(args.spec, "r") as f: cfg = json.load(f)
503
+ os.makedirs(args.out, exist_ok=True)
504
+ msh = build_trench_volume_from_spec(cfg, lc=args.lc, out_msh=os.path.join(args.out, "trench_volume.msh"))
505
+ print(json.dumps({"msh": msh}, indent=2))
506
+
507
+ if __name__ == "__main__":
508
+ main()
@@ -0,0 +1,126 @@
1
+ """
2
+ Generate Plotly-based interactive HTML visualisations for trench meshes.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import os
8
+ import sys
9
+ import webbrowser
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from .render_colors import color_for_group, opacity_for_group
14
+ from .trench_scene_generator_v3 import parse_obj_groups
15
+
16
+ try: # Optional dependency
17
+ import plotly.graph_objects as go
18
+ except Exception: # pragma: no cover - handled at runtime
19
+ go = None
20
+
21
+
22
+ def _ensure_plotly_available() -> None:
23
+ if go is None:
24
+ raise RuntimeError(
25
+ "plotly is required for this command. Install with 'pip install trenchfoot[viz]'"
26
+ )
27
+
28
+
29
+ def _mesh_traces_from_obj(obj_path: Path) -> list:
30
+ import numpy as np
31
+
32
+ V, faces_by_group = parse_obj_groups(str(obj_path))
33
+ traces = []
34
+ for name, faces in faces_by_group.items():
35
+ if faces.size == 0:
36
+ continue
37
+ faces = np.asarray(faces, dtype=int)
38
+ trace = go.Mesh3d(
39
+ x=V[:, 0],
40
+ y=V[:, 1],
41
+ z=V[:, 2],
42
+ i=faces[:, 0],
43
+ j=faces[:, 1],
44
+ k=faces[:, 2],
45
+ color=color_for_group(name),
46
+ opacity=opacity_for_group(name),
47
+ name=name,
48
+ flatshading=True,
49
+ lighting=dict(ambient=0.5, diffuse=0.7, specular=0.1),
50
+ showscale=False,
51
+ )
52
+ traces.append(trace)
53
+ return traces
54
+
55
+
56
+ def _figure_for_mesh(path: Path) -> "go.Figure":
57
+ suffix = path.suffix.lower()
58
+ if suffix == ".obj":
59
+ traces = _mesh_traces_from_obj(path)
60
+ else:
61
+ raise RuntimeError(f"Unsupported mesh format '{suffix}'. Only OBJ is currently supported.")
62
+
63
+ if not traces:
64
+ raise RuntimeError("No faces found in mesh; nothing to render.")
65
+
66
+ fig = go.Figure(data=traces)
67
+ fig.update_layout(
68
+ scene=dict(
69
+ aspectmode="data",
70
+ xaxis=dict(title="X", backgroundcolor="rgb(245,245,245)", showgrid=False),
71
+ yaxis=dict(title="Y", backgroundcolor="rgb(245,245,245)", showgrid=False),
72
+ zaxis=dict(title="Z", backgroundcolor="rgb(245,245,245)", showgrid=False),
73
+ ),
74
+ margin=dict(l=0, r=0, t=60, b=0),
75
+ )
76
+ return fig
77
+
78
+
79
+ def main(argv: Optional[list[str]] = None) -> None:
80
+ parser = argparse.ArgumentParser(description="Generate a Plotly HTML visualisation for a trench mesh (OBJ).")
81
+ parser.add_argument("input", type=Path, help="Path to trench_scene.obj (or other supported mesh).")
82
+ parser.add_argument(
83
+ "--out",
84
+ dest="out_path",
85
+ type=Path,
86
+ help="Destination HTML file (defaults to <input>.plot.html).",
87
+ )
88
+ parser.add_argument(
89
+ "--title",
90
+ dest="title",
91
+ type=str,
92
+ help="Optional figure title (defaults to input file name).",
93
+ )
94
+ parser.add_argument(
95
+ "--open",
96
+ dest="open_browser",
97
+ action="store_true",
98
+ help="Open the resulting HTML in your default browser after writing.",
99
+ )
100
+ args = parser.parse_args(argv)
101
+
102
+ _ensure_plotly_available()
103
+
104
+ mesh_path = args.input.expanduser().resolve()
105
+ if not mesh_path.exists():
106
+ parser.error(f"Mesh not found: {mesh_path}")
107
+
108
+ fig = _figure_for_mesh(mesh_path)
109
+ if args.title:
110
+ fig.update_layout(title=args.title)
111
+ else:
112
+ fig.update_layout(title=mesh_path.name)
113
+
114
+ out_path = args.out_path or mesh_path.with_suffix(".plot.html")
115
+ out_path = out_path.expanduser().resolve()
116
+ out_path.parent.mkdir(parents=True, exist_ok=True)
117
+
118
+ fig.write_html(str(out_path), auto_open=False, include_plotlyjs="cdn")
119
+ print(f"Wrote Plotly visualisation to {out_path}")
120
+
121
+ if args.open_browser:
122
+ webbrowser.open(out_path.as_uri())
123
+
124
+
125
+ if __name__ == "__main__": # pragma: no cover
126
+ main()
@@ -0,0 +1,59 @@
1
+ import re
2
+ from typing import Optional
3
+
4
+ _TRENCH_COLORS = {
5
+ "trench_walls": "#b87333", # copper
6
+ "trench_bottom": "#d2a679",
7
+ "trench_cap_for_volume": "#e6cfa4",
8
+ "ground_surface": "#b0b0b0",
9
+ }
10
+
11
+ _PIPE_COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b"]
12
+ _BOX_COLORS = ["#17becf", "#bcbd22"]
13
+ _SPHERE_COLORS = ["#e377c2", "#7f7f7f"]
14
+
15
+ _OBJECT_ALPHA = 0.9
16
+ _SURFACE_ALPHA = 0.55
17
+ _DEFAULT_COLOR = "#c7c7c7"
18
+
19
+
20
+ def _match_index(name: str, prefix: str) -> Optional[int]:
21
+ match = re.search(rf"{prefix}(\d+)", name, re.IGNORECASE)
22
+ if match:
23
+ try:
24
+ return int(match.group(1))
25
+ except ValueError:
26
+ return None
27
+ return None
28
+
29
+
30
+ def is_object_group(name: str) -> bool:
31
+ lower = name.lower()
32
+ return lower.startswith("pipe") or lower.startswith("box") or lower.startswith("sphere")
33
+
34
+
35
+ def color_for_group(name: str) -> str:
36
+ lower = name.lower()
37
+ for key, color in _TRENCH_COLORS.items():
38
+ if lower.startswith(key):
39
+ return color
40
+ if lower.startswith("ground"):
41
+ return _TRENCH_COLORS["ground_surface"]
42
+
43
+ idx = _match_index(name, "pipe")
44
+ if idx is not None:
45
+ return _PIPE_COLORS[idx % len(_PIPE_COLORS)]
46
+
47
+ idx = _match_index(name, "box")
48
+ if idx is not None:
49
+ return _BOX_COLORS[idx % len(_BOX_COLORS)]
50
+
51
+ idx = _match_index(name, "sphere")
52
+ if idx is not None:
53
+ return _SPHERE_COLORS[idx % len(_SPHERE_COLORS)]
54
+
55
+ return _DEFAULT_COLOR
56
+
57
+
58
+ def opacity_for_group(name: str) -> float:
59
+ return _OBJECT_ALPHA if is_object_group(name) else _SURFACE_ALPHA