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,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()
|