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.
- trenchfoot/Dockerfile +5 -0
- trenchfoot/README.md +125 -0
- trenchfoot/__init__.py +67 -0
- trenchfoot/generate_scenarios.py +667 -0
- trenchfoot/gmsh_sloped_trench_mesher.py +508 -0
- trenchfoot/plot_mesh.py +126 -0
- trenchfoot/render_colors.py +59 -0
- trenchfoot/scenarios/S01_straight_vwalls/meshes/trench_scene_culled.obj +46 -0
- trenchfoot/scenarios/S01_straight_vwalls/metrics.json +35 -0
- trenchfoot/scenarios/S01_straight_vwalls/point_clouds/culled/resolution0p050.pth +0 -0
- trenchfoot/scenarios/S01_straight_vwalls/point_clouds/full/resolution0p050.pth +0 -0
- trenchfoot/scenarios/S01_straight_vwalls/preview.png +0 -0
- trenchfoot/scenarios/S01_straight_vwalls/preview_oblique.png +0 -0
- trenchfoot/scenarios/S01_straight_vwalls/preview_side.png +0 -0
- trenchfoot/scenarios/S01_straight_vwalls/preview_top.png +0 -0
- trenchfoot/scenarios/S01_straight_vwalls/scene.json +30 -0
- trenchfoot/scenarios/S01_straight_vwalls/trench_scene.obj +46 -0
- trenchfoot/scenarios/S01_straight_vwalls/volumetric/trench_volume.msh +1017 -0
- trenchfoot/scenarios/S02_straight_slope_pipe/meshes/trench_scene_culled.obj +60 -0
- trenchfoot/scenarios/S02_straight_slope_pipe/metrics.json +38 -0
- trenchfoot/scenarios/S02_straight_slope_pipe/point_clouds/culled/resolution0p050.pth +0 -0
- trenchfoot/scenarios/S02_straight_slope_pipe/point_clouds/full/resolution0p050.pth +0 -0
- trenchfoot/scenarios/S02_straight_slope_pipe/preview.png +0 -0
- trenchfoot/scenarios/S02_straight_slope_pipe/preview_oblique.png +0 -0
- trenchfoot/scenarios/S02_straight_slope_pipe/preview_side.png +0 -0
- trenchfoot/scenarios/S02_straight_slope_pipe/preview_top.png +0 -0
- trenchfoot/scenarios/S02_straight_slope_pipe/scene.json +39 -0
- trenchfoot/scenarios/S02_straight_slope_pipe/trench_scene.obj +14404 -0
- trenchfoot/scenarios/S02_straight_slope_pipe/volumetric/trench_volume.msh +2389 -0
- trenchfoot/scenarios/S03_L_slope_two_pipes_box/meshes/trench_scene_culled.obj +87 -0
- trenchfoot/scenarios/S03_L_slope_two_pipes_box/metrics.json +42 -0
- trenchfoot/scenarios/S03_L_slope_two_pipes_box/point_clouds/culled/resolution0p050.pth +0 -0
- trenchfoot/scenarios/S03_L_slope_two_pipes_box/point_clouds/full/resolution0p050.pth +0 -0
- trenchfoot/scenarios/S03_L_slope_two_pipes_box/preview.png +0 -0
- trenchfoot/scenarios/S03_L_slope_two_pipes_box/preview_oblique.png +0 -0
- trenchfoot/scenarios/S03_L_slope_two_pipes_box/preview_side.png +0 -0
- trenchfoot/scenarios/S03_L_slope_two_pipes_box/preview_top.png +0 -0
- trenchfoot/scenarios/S03_L_slope_two_pipes_box/scene.json +68 -0
- trenchfoot/scenarios/S03_L_slope_two_pipes_box/trench_scene.obj +28803 -0
- trenchfoot/scenarios/S03_L_slope_two_pipes_box/volumetric/trench_volume.msh +4463 -0
- trenchfoot/scenarios/S04_U_slope_multi_noise/meshes/trench_scene_culled.obj +150 -0
- trenchfoot/scenarios/S04_U_slope_multi_noise/metrics.json +46 -0
- trenchfoot/scenarios/S04_U_slope_multi_noise/preview.png +0 -0
- trenchfoot/scenarios/S04_U_slope_multi_noise/preview_oblique.png +0 -0
- trenchfoot/scenarios/S04_U_slope_multi_noise/preview_side.png +0 -0
- trenchfoot/scenarios/S04_U_slope_multi_noise/preview_top.png +0 -0
- trenchfoot/scenarios/S04_U_slope_multi_noise/scene.json +79 -0
- trenchfoot/scenarios/S04_U_slope_multi_noise/trench_scene.obj +49402 -0
- trenchfoot/scenarios/S04_U_slope_multi_noise/volumetric/trench_volume.msh +7610 -0
- trenchfoot/scenarios/S05_wide_slope_pair/metrics.json +42 -0
- trenchfoot/scenarios/S05_wide_slope_pair/preview_oblique.png +0 -0
- trenchfoot/scenarios/S05_wide_slope_pair/preview_side.png +0 -0
- trenchfoot/scenarios/S05_wide_slope_pair/preview_top.png +0 -0
- trenchfoot/scenarios/S05_wide_slope_pair/scene.json +71 -0
- trenchfoot/scenarios/S05_wide_slope_pair/trench_scene.obj +28803 -0
- trenchfoot/scenarios/S06_bumpy_wide_loop/metrics.json +43 -0
- trenchfoot/scenarios/S06_bumpy_wide_loop/preview_oblique.png +0 -0
- trenchfoot/scenarios/S06_bumpy_wide_loop/preview_side.png +0 -0
- trenchfoot/scenarios/S06_bumpy_wide_loop/preview_top.png +0 -0
- trenchfoot/scenarios/S06_bumpy_wide_loop/scene.json +82 -0
- trenchfoot/scenarios/S06_bumpy_wide_loop/trench_scene.obj +35084 -0
- trenchfoot/scenarios/SUMMARY.json +159 -0
- trenchfoot/scene_spec_example.json +74 -0
- trenchfoot/trench_scene_generator_v3.py +798 -0
- trenchfoot-0.1.0.dist-info/METADATA +104 -0
- trenchfoot-0.1.0.dist-info/RECORD +69 -0
- trenchfoot-0.1.0.dist-info/WHEEL +4 -0
- trenchfoot-0.1.0.dist-info/entry_points.txt +3 -0
- 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()
|
trenchfoot/plot_mesh.py
ADDED
|
@@ -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
|