trenchfoot 0.1.0__py3-none-any.whl → 0.2.2__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/generate_scenarios.py +104 -27
- trenchfoot/gmsh_sloped_trench_mesher.py +133 -28
- trenchfoot/scenarios/S01_straight_vwalls/metrics.json +4 -4
- 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 +2 -2
- trenchfoot/scenarios/S01_straight_vwalls/trench_scene.obj +30 -27
- trenchfoot/scenarios/S01_straight_vwalls/volumetric/trench_volume.msh +540 -802
- trenchfoot/scenarios/S02_straight_slope_pipe/metrics.json +10 -10
- 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 +3 -3
- trenchfoot/scenarios/S02_straight_slope_pipe/trench_scene.obj +4977 -4974
- trenchfoot/scenarios/S02_straight_slope_pipe/volumetric/trench_volume.msh +1694 -1969
- trenchfoot/scenarios/S03_L_slope_two_pipes_box/metrics.json +14 -14
- 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 +4 -4
- trenchfoot/scenarios/S03_L_slope_two_pipes_box/trench_scene.obj +10547 -10540
- trenchfoot/scenarios/S03_L_slope_two_pipes_box/volumetric/trench_volume.msh +3101 -3767
- trenchfoot/scenarios/S04_U_slope_multi_noise/metrics.json +17 -17
- 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 +8 -7
- trenchfoot/scenarios/S04_U_slope_multi_noise/trench_scene.obj +17999 -17988
- trenchfoot/scenarios/S04_U_slope_multi_noise/volumetric/trench_volume.msh +4991 -6506
- trenchfoot/scenarios/S05_wide_slope_pair/metrics.json +14 -14
- 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 +6 -6
- trenchfoot/scenarios/S05_wide_slope_pair/trench_scene.obj +10547 -10540
- trenchfoot/scenarios/S05_wide_slope_pair/volumetric/trench_volume.msh +5081 -0
- trenchfoot/scenarios/S06_bumpy_wide_loop/metrics.json +16 -16
- 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 +6 -6
- trenchfoot/scenarios/S06_bumpy_wide_loop/trench_scene.obj +12812 -12793
- trenchfoot/scenarios/S06_bumpy_wide_loop/volumetric/trench_volume.msh +10402 -0
- trenchfoot/scenarios/S07_circular_well/metrics.json +48 -0
- trenchfoot/scenarios/S07_circular_well/preview_oblique.png +0 -0
- trenchfoot/scenarios/S07_circular_well/preview_side.png +0 -0
- trenchfoot/scenarios/S07_circular_well/preview_top.png +0 -0
- trenchfoot/scenarios/S07_circular_well/scene.json +203 -0
- trenchfoot/scenarios/S07_circular_well/trench_scene.obj +64401 -0
- trenchfoot/scenarios/S07_circular_well/volumetric/trench_volume.msh +22370 -0
- trenchfoot/scenarios/SUMMARY.json +343 -32
- trenchfoot/trench_scene_generator_v3.py +323 -46
- trenchfoot-0.2.2.dist-info/METADATA +111 -0
- {trenchfoot-0.1.0.dist-info → trenchfoot-0.2.2.dist-info}/RECORD +58 -49
- {trenchfoot-0.1.0.dist-info → trenchfoot-0.2.2.dist-info}/WHEEL +1 -1
- trenchfoot-0.1.0.dist-info/METADATA +0 -104
- {trenchfoot-0.1.0.dist-info → trenchfoot-0.2.2.dist-info}/entry_points.txt +0 -0
- {trenchfoot-0.1.0.dist-info → trenchfoot-0.2.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -33,6 +33,9 @@ except Exception:
|
|
|
33
33
|
plt = None
|
|
34
34
|
Poly3DCollection = None
|
|
35
35
|
|
|
36
|
+
# Groups kept for internal metrics but excluded from OBJ export and previews
|
|
37
|
+
_INTERNAL_GROUPS = frozenset({"trench_cap_for_volume"})
|
|
38
|
+
|
|
36
39
|
# ---------------- Geometry helpers ----------------
|
|
37
40
|
|
|
38
41
|
def _normalize(v: np.ndarray) -> np.ndarray:
|
|
@@ -74,6 +77,19 @@ def _sample_polyline_at_s(path: List[Tuple[float,float]], s: float):
|
|
|
74
77
|
t = seg / L; u = (s_abs - cum[i]) / L; pos = (1-u)*P[i] + u*P[i+1]
|
|
75
78
|
return pos, t
|
|
76
79
|
|
|
80
|
+
def _is_path_closed(path: List[Tuple[float,float]], threshold: float = 0.01) -> bool:
|
|
81
|
+
"""Detect if a path is explicitly closed (first and last points nearly identical).
|
|
82
|
+
|
|
83
|
+
Returns True only if the first and last points are within threshold distance.
|
|
84
|
+
Use a small threshold (default 0.01) to only catch truly closed paths where
|
|
85
|
+
the endpoint is repeated.
|
|
86
|
+
"""
|
|
87
|
+
if len(path) < 3:
|
|
88
|
+
return False
|
|
89
|
+
P = np.array(path, float)
|
|
90
|
+
first_last_dist = np.linalg.norm(P[0] - P[-1])
|
|
91
|
+
return first_last_dist < threshold
|
|
92
|
+
|
|
77
93
|
def _offset_polyline(path: List[Tuple[float,float]], offset: float):
|
|
78
94
|
P = np.array(path, float); n = len(P)
|
|
79
95
|
if n < 2: raise ValueError("Polyline needs at least 2 points")
|
|
@@ -100,6 +116,43 @@ def _offset_polyline(path: List[Tuple[float,float]], offset: float):
|
|
|
100
116
|
right_pts.append(P[-1] - offset * normals[-1])
|
|
101
117
|
return left_pts, right_pts
|
|
102
118
|
|
|
119
|
+
def _offset_closed_polyline(path: List[Tuple[float,float]], offset: float) -> List[np.ndarray]:
|
|
120
|
+
"""Offset a closed polyline, returning a single closed ring.
|
|
121
|
+
|
|
122
|
+
Unlike _offset_polyline which returns left/right sides for open paths,
|
|
123
|
+
this returns a single continuous closed ring for paths where first ≈ last point.
|
|
124
|
+
"""
|
|
125
|
+
P = np.array(path, float)
|
|
126
|
+
n = len(P)
|
|
127
|
+
if n < 3:
|
|
128
|
+
raise ValueError("Closed polyline needs at least 3 points")
|
|
129
|
+
|
|
130
|
+
# Compute tangents treating path as closed loop
|
|
131
|
+
tangents = []
|
|
132
|
+
normals = []
|
|
133
|
+
for i in range(n):
|
|
134
|
+
t = _normalize(P[(i+1) % n] - P[i])
|
|
135
|
+
if np.linalg.norm(t) < 1e-12:
|
|
136
|
+
t = np.array([1.0, 0.0])
|
|
137
|
+
tangents.append(t)
|
|
138
|
+
normals.append(_rotate_ccw(t))
|
|
139
|
+
|
|
140
|
+
# Compute offset points with proper miter at each vertex
|
|
141
|
+
offset_pts = []
|
|
142
|
+
for k in range(n):
|
|
143
|
+
t_prev, n_prev = tangents[(k-1) % n], normals[(k-1) % n]
|
|
144
|
+
t_next, n_next = tangents[k], normals[k]
|
|
145
|
+
L1_p = P[k] + offset * n_prev
|
|
146
|
+
L1_d = t_prev
|
|
147
|
+
L2_p = P[k] + offset * n_next
|
|
148
|
+
L2_d = t_next
|
|
149
|
+
pt = _line_intersection_2d(L1_p, L1_d, L2_p, L2_d)
|
|
150
|
+
if pt is None:
|
|
151
|
+
pt = 0.5 * (L1_p + L2_p)
|
|
152
|
+
offset_pts.append(pt)
|
|
153
|
+
|
|
154
|
+
return offset_pts
|
|
155
|
+
|
|
103
156
|
def _polygon_area_2d(poly_xy: np.ndarray) -> float:
|
|
104
157
|
x = poly_xy[:,0]; y = poly_xy[:,1]
|
|
105
158
|
return 0.5 * float(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1)))
|
|
@@ -262,9 +315,14 @@ def _compute_surface_metrics(
|
|
|
262
315
|
return metrics
|
|
263
316
|
|
|
264
317
|
|
|
265
|
-
def _render_surface_previews(
|
|
318
|
+
def _render_surface_previews(
|
|
319
|
+
groups: Dict[str, Tuple[np.ndarray, np.ndarray]],
|
|
320
|
+
exclude_groups: Optional[frozenset] = None,
|
|
321
|
+
) -> Dict[str, bytes]:
|
|
266
322
|
if plt is None or not groups:
|
|
267
323
|
return {}
|
|
324
|
+
if exclude_groups:
|
|
325
|
+
groups = {k: v for k, v in groups.items() if k not in exclude_groups}
|
|
268
326
|
all_vertices = [V for (V, F) in groups.values() if V.size > 0]
|
|
269
327
|
if not all_vertices:
|
|
270
328
|
return {}
|
|
@@ -380,7 +438,9 @@ class SurfaceMeshResult:
|
|
|
380
438
|
out_path = Path(out_dir)
|
|
381
439
|
out_path.mkdir(parents=True, exist_ok=True)
|
|
382
440
|
obj_path = out_path / "trench_scene.obj"
|
|
383
|
-
|
|
441
|
+
# Exclude internal groups (like trench_cap_for_volume) from OBJ export
|
|
442
|
+
export_groups = {k: v for k, v in self.groups.items() if k not in _INTERNAL_GROUPS}
|
|
443
|
+
write_obj_with_groups(obj_path.as_posix(), export_groups)
|
|
384
444
|
metrics_path = out_path / "metrics.json"
|
|
385
445
|
with metrics_path.open("w") as fh:
|
|
386
446
|
json.dump(self.metrics, fh, indent=2)
|
|
@@ -491,60 +551,276 @@ def make_trench_from_path_sloped(path_xy: List[Tuple[float,float]], width_top: f
|
|
|
491
551
|
half_top = width_top/2.0
|
|
492
552
|
shrink = max(0.0, wall_slope * depth)
|
|
493
553
|
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
554
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
555
|
+
is_closed = _is_path_closed(path_xy)
|
|
556
|
+
|
|
557
|
+
if is_closed:
|
|
558
|
+
# For closed paths (like circles), create outer/inner rings
|
|
559
|
+
# Outer ring: centerline offset outward (positive)
|
|
560
|
+
# Inner ring: centerline offset inward (negative)
|
|
561
|
+
outer_top = np.array(_offset_closed_polyline(path_xy, half_top), float)
|
|
562
|
+
inner_top = np.array(_offset_closed_polyline(path_xy, -half_top), float)
|
|
563
|
+
outer_bot = np.array(_offset_closed_polyline(path_xy, half_bot), float)
|
|
564
|
+
inner_bot = np.array(_offset_closed_polyline(path_xy, -half_bot), float)
|
|
565
|
+
|
|
566
|
+
# Ensure CCW orientation (outer should be CCW, inner CW for proper normals)
|
|
567
|
+
outer_top = _ensure_ccw(outer_top)
|
|
568
|
+
outer_bot = _ensure_ccw(outer_bot)
|
|
569
|
+
# Inner rings should go opposite direction
|
|
570
|
+
if _polygon_area_2d(inner_top) > 0:
|
|
571
|
+
inner_top = inner_top[::-1].copy()
|
|
572
|
+
if _polygon_area_2d(inner_bot) > 0:
|
|
573
|
+
inner_bot = inner_bot[::-1].copy()
|
|
574
|
+
|
|
575
|
+
gfun = _ground_fn(ground)
|
|
576
|
+
|
|
577
|
+
# For closed trenches, we need outer wall, inner wall, and bottom (no cap for annular trench)
|
|
578
|
+
# Actually, for annular trench, the "bottom" is an annulus and the "cap" is also an annulus
|
|
579
|
+
z_outer_top = np.array([gfun(x,y) for x,y in outer_top])
|
|
580
|
+
z_inner_top = np.array([gfun(x,y) for x,y in inner_top])
|
|
581
|
+
z_outer_bot = np.array([gfun(x,y) - depth for x,y in outer_bot])
|
|
582
|
+
z_inner_bot = np.array([gfun(x,y) - depth for x,y in inner_bot])
|
|
583
|
+
|
|
584
|
+
# Triangulate annular cap and bottom
|
|
585
|
+
cap_verts, cap_faces = _triangulate_annulus(outer_top, inner_top[::-1]) # reverse inner for CCW
|
|
586
|
+
V_cap = np.column_stack([cap_verts, np.concatenate([z_outer_top, z_inner_top[::-1]])])
|
|
587
|
+
F_cap = cap_faces
|
|
588
|
+
|
|
589
|
+
bot_verts, bot_faces = _triangulate_annulus(outer_bot, inner_bot[::-1])
|
|
590
|
+
V_bottom = np.column_stack([bot_verts, np.concatenate([z_outer_bot, z_inner_bot[::-1]])])
|
|
591
|
+
F_bottom = bot_faces[:, ::-1] # flip for outward normals
|
|
592
|
+
|
|
593
|
+
# Outer wall: connects outer_top to outer_bot (facing outward from trench)
|
|
594
|
+
n_outer = len(outer_top)
|
|
595
|
+
walls_V = []
|
|
596
|
+
walls_F = []
|
|
597
|
+
for i in range(n_outer):
|
|
598
|
+
j = (i + 1) % n_outer
|
|
599
|
+
A_top = np.array([outer_top[i,0], outer_top[i,1], z_outer_top[i]])
|
|
600
|
+
B_top = np.array([outer_top[j,0], outer_top[j,1], z_outer_top[j]])
|
|
601
|
+
A_bot = np.array([outer_bot[i,0], outer_bot[i,1], z_outer_bot[i]])
|
|
602
|
+
B_bot = np.array([outer_bot[j,0], outer_bot[j,1], z_outer_bot[j]])
|
|
603
|
+
base = len(walls_V)
|
|
604
|
+
walls_V.extend([A_top, B_top, B_bot, A_bot])
|
|
605
|
+
# Winding for outward-facing (away from center)
|
|
606
|
+
walls_F.extend([[base, base+1, base+2], [base, base+2, base+3]])
|
|
607
|
+
|
|
608
|
+
# Inner wall: connects inner_top to inner_bot (facing inward toward center)
|
|
609
|
+
n_inner = len(inner_top)
|
|
610
|
+
for i in range(n_inner):
|
|
611
|
+
j = (i + 1) % n_inner
|
|
612
|
+
A_top = np.array([inner_top[i,0], inner_top[i,1], z_inner_top[i]])
|
|
613
|
+
B_top = np.array([inner_top[j,0], inner_top[j,1], z_inner_top[j]])
|
|
614
|
+
A_bot = np.array([inner_bot[i,0], inner_bot[i,1], z_inner_bot[i]])
|
|
615
|
+
B_bot = np.array([inner_bot[j,0], inner_bot[j,1], z_inner_bot[j]])
|
|
616
|
+
base = len(walls_V)
|
|
617
|
+
walls_V.extend([A_top, B_top, B_bot, A_bot])
|
|
618
|
+
# Winding for inward-facing (toward center) - reverse winding
|
|
619
|
+
walls_F.extend([[base, base+2, base+1], [base, base+3, base+2]])
|
|
620
|
+
|
|
621
|
+
V_walls = np.array(walls_V, float)
|
|
622
|
+
F_walls = np.array(walls_F, int)
|
|
623
|
+
|
|
624
|
+
# For closed path, poly_top is the outer ring (used for ground plane hole)
|
|
625
|
+
poly_top = outer_top
|
|
626
|
+
poly_bot = outer_bot
|
|
627
|
+
|
|
628
|
+
extra = {
|
|
629
|
+
"width_top": width_top,
|
|
630
|
+
"width_bottom": 2.0*half_bot,
|
|
631
|
+
"area_top": abs(_polygon_area_2d(outer_top)) - abs(_polygon_area_2d(inner_top)),
|
|
632
|
+
"area_bottom": abs(_polygon_area_2d(outer_bot)) - abs(_polygon_area_2d(inner_bot)),
|
|
633
|
+
"is_closed_path": True
|
|
634
|
+
}
|
|
635
|
+
else:
|
|
636
|
+
# Original logic for open paths
|
|
637
|
+
L_top, R_top = _offset_polyline(path_xy, half_top)
|
|
638
|
+
L_bot, R_bot = _offset_polyline(path_xy, half_bot)
|
|
639
|
+
poly_top = _ensure_ccw(_ring_from_LR(L_top, R_top))
|
|
640
|
+
poly_bot = _ensure_ccw(_ring_from_LR(L_bot, R_bot))
|
|
641
|
+
|
|
642
|
+
gfun = _ground_fn(ground)
|
|
643
|
+
# Top and bottom rings lie on the ground plane and ground-depth respectively
|
|
644
|
+
z_top = np.array([gfun(x,y) for x,y in poly_top])
|
|
645
|
+
z_bot = np.array([gfun(x,y) - depth for x,y in poly_bot])
|
|
646
|
+
tris_top = _ear_clipping_triangulation(poly_top)
|
|
647
|
+
tris_bot = _ear_clipping_triangulation(poly_bot)
|
|
648
|
+
V_cap = np.column_stack([poly_top, z_top])
|
|
649
|
+
V_bottom = np.column_stack([poly_bot, z_bot])
|
|
650
|
+
F_cap = tris_top
|
|
651
|
+
F_bottom = tris_bot[:, ::-1] # outward
|
|
652
|
+
|
|
653
|
+
# Walls: connect corresponding indices
|
|
654
|
+
N = len(poly_top)
|
|
655
|
+
assert N == len(poly_bot)
|
|
656
|
+
walls_V = []
|
|
657
|
+
walls_F = []
|
|
658
|
+
for i in range(N):
|
|
659
|
+
j = (i+1) % N
|
|
660
|
+
A_top = np.array([poly_top[i,0], poly_top[i,1], z_top[i]])
|
|
661
|
+
B_top = np.array([poly_top[j,0], poly_top[j,1], z_top[j]])
|
|
662
|
+
A_bot = np.array([poly_bot[i,0], poly_bot[i,1], z_bot[i]])
|
|
663
|
+
B_bot = np.array([poly_bot[j,0], poly_bot[j,1], z_bot[j]])
|
|
664
|
+
base = len(walls_V)
|
|
665
|
+
walls_V.extend([A_top, B_top, B_bot, A_bot])
|
|
666
|
+
walls_F.extend([[base, base+1, base+2], [base, base+2, base+3]])
|
|
667
|
+
V_walls = np.array(walls_V, float)
|
|
668
|
+
F_walls = np.array(walls_F, int)
|
|
669
|
+
|
|
670
|
+
extra = {
|
|
671
|
+
"width_top": width_top,
|
|
672
|
+
"width_bottom": 2.0*half_bot,
|
|
673
|
+
"area_top": abs(_polygon_area_2d(poly_top)),
|
|
674
|
+
"area_bottom": abs(_polygon_area_2d(poly_bot)),
|
|
675
|
+
"is_closed_path": False
|
|
676
|
+
}
|
|
522
677
|
|
|
523
678
|
groups = {
|
|
524
679
|
"trench_bottom": (V_bottom, F_bottom),
|
|
525
680
|
"trench_cap_for_volume": (V_cap, F_cap),
|
|
526
681
|
"trench_walls": (V_walls, F_walls)
|
|
527
682
|
}
|
|
528
|
-
|
|
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
|
-
}
|
|
683
|
+
|
|
534
684
|
return groups, poly_top, poly_bot, extra
|
|
535
685
|
|
|
686
|
+
def _triangulate_annulus(outer: np.ndarray, inner: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
|
687
|
+
"""Triangulate the annular region between outer and inner polygons.
|
|
688
|
+
|
|
689
|
+
Creates triangles that fill ONLY the region between the two polygons,
|
|
690
|
+
leaving the inner polygon area as an open hole.
|
|
691
|
+
|
|
692
|
+
Both polygons should be CCW oriented. Returns (vertices, faces) where
|
|
693
|
+
vertices is the concatenation of outer and inner, and faces index into it.
|
|
694
|
+
"""
|
|
695
|
+
n_outer = len(outer)
|
|
696
|
+
n_inner = len(inner)
|
|
697
|
+
|
|
698
|
+
# Vertices: outer first, then inner
|
|
699
|
+
verts = np.vstack([outer, inner])
|
|
700
|
+
|
|
701
|
+
# Create triangles by "zipping" around the two polygons
|
|
702
|
+
# This works well when both polygons have similar vertex counts
|
|
703
|
+
# For different counts, we need to handle the ratio
|
|
704
|
+
|
|
705
|
+
tris = []
|
|
706
|
+
|
|
707
|
+
# Use a marching approach: for each outer edge, connect to nearest inner vertices
|
|
708
|
+
# and vice versa. This creates a proper triangulated annulus.
|
|
709
|
+
|
|
710
|
+
# Simple approach: interpolate around both polygons simultaneously
|
|
711
|
+
# treating them as having a common parameter t in [0, 1]
|
|
712
|
+
|
|
713
|
+
i_outer = 0 # current outer vertex index
|
|
714
|
+
i_inner = 0 # current inner vertex index
|
|
715
|
+
t_outer = 0.0 # parameter position on outer polygon
|
|
716
|
+
t_inner = 0.0 # parameter position on inner polygon
|
|
717
|
+
|
|
718
|
+
outer_step = 1.0 / n_outer
|
|
719
|
+
inner_step = 1.0 / n_inner
|
|
720
|
+
|
|
721
|
+
# March around creating triangles
|
|
722
|
+
while i_outer < n_outer or i_inner < n_inner:
|
|
723
|
+
# Current vertices
|
|
724
|
+
o_curr = i_outer % n_outer
|
|
725
|
+
o_next = (i_outer + 1) % n_outer
|
|
726
|
+
i_curr = i_inner % n_inner
|
|
727
|
+
i_next = (i_inner + 1) % n_inner
|
|
728
|
+
|
|
729
|
+
# Indices in combined vertex array
|
|
730
|
+
vo_curr = o_curr
|
|
731
|
+
vo_next = o_next
|
|
732
|
+
vi_curr = n_outer + i_curr
|
|
733
|
+
vi_next = n_outer + i_next
|
|
734
|
+
|
|
735
|
+
if i_outer >= n_outer:
|
|
736
|
+
# Finished outer, just advance inner
|
|
737
|
+
tris.append([vo_curr, vi_next, vi_curr])
|
|
738
|
+
i_inner += 1
|
|
739
|
+
t_inner += inner_step
|
|
740
|
+
elif i_inner >= n_inner:
|
|
741
|
+
# Finished inner, just advance outer
|
|
742
|
+
tris.append([vo_curr, vo_next, vi_curr])
|
|
743
|
+
i_outer += 1
|
|
744
|
+
t_outer += outer_step
|
|
745
|
+
elif t_outer + outer_step <= t_inner + inner_step:
|
|
746
|
+
# Advance outer - create triangle: o_curr, o_next, i_curr
|
|
747
|
+
tris.append([vo_curr, vo_next, vi_curr])
|
|
748
|
+
i_outer += 1
|
|
749
|
+
t_outer += outer_step
|
|
750
|
+
else:
|
|
751
|
+
# Advance inner - create triangle: o_curr, i_next, i_curr
|
|
752
|
+
tris.append([vo_curr, vi_next, vi_curr])
|
|
753
|
+
i_inner += 1
|
|
754
|
+
t_inner += inner_step
|
|
755
|
+
|
|
756
|
+
return verts, np.array(tris, dtype=int)
|
|
757
|
+
|
|
758
|
+
|
|
536
759
|
def make_ground_surface_plane(path_xy: List[Tuple[float,float]], width_top: float, ground) -> Dict[str,Tuple[np.ndarray,np.ndarray]]:
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
760
|
+
"""Create ground surface as an offset polygon around the trench opening.
|
|
761
|
+
|
|
762
|
+
The ground surface forms an annulus (ring) around the trench, leaving
|
|
763
|
+
the trench opening as an open hole. This creates a natural shape that
|
|
764
|
+
hugs L-shaped, U-shaped, and curved trenches.
|
|
765
|
+
|
|
766
|
+
For closed paths (like circles), also creates a center island inside
|
|
767
|
+
the inner edge of the annular trench.
|
|
768
|
+
"""
|
|
769
|
+
half_top = width_top / 2.0
|
|
770
|
+
m = float(max(0.5, ground.size_margin))
|
|
543
771
|
gfun = _ground_fn(ground)
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
772
|
+
is_closed = _is_path_closed(path_xy)
|
|
773
|
+
|
|
774
|
+
if is_closed:
|
|
775
|
+
# For closed paths, we have:
|
|
776
|
+
# - Outer ground: annulus from ground_outer to trench_outer
|
|
777
|
+
# - Center island: filled polygon inside trench_inner
|
|
778
|
+
|
|
779
|
+
# Trench boundaries
|
|
780
|
+
trench_outer = np.array(_offset_closed_polyline(path_xy, half_top), float)
|
|
781
|
+
trench_inner = np.array(_offset_closed_polyline(path_xy, -half_top), float)
|
|
782
|
+
|
|
783
|
+
# Ground outer boundary
|
|
784
|
+
ground_outer = np.array(_offset_closed_polyline(path_xy, half_top + m), float)
|
|
785
|
+
|
|
786
|
+
# Ensure proper orientations
|
|
787
|
+
trench_outer = _ensure_ccw(trench_outer)
|
|
788
|
+
trench_inner = _ensure_ccw(trench_inner)
|
|
789
|
+
ground_outer = _ensure_ccw(ground_outer)
|
|
790
|
+
|
|
791
|
+
# Outer ground annulus
|
|
792
|
+
outer_combined_xy, outer_tris = _triangulate_annulus(ground_outer, trench_outer)
|
|
793
|
+
outer_Vg = np.array([[x, y, gfun(x, y)] for (x, y) in outer_combined_xy], float)
|
|
794
|
+
|
|
795
|
+
# Center island (simple filled polygon)
|
|
796
|
+
center_tris = _ear_clipping_triangulation(trench_inner)
|
|
797
|
+
center_Vg = np.array([[x, y, gfun(x, y)] for (x, y) in trench_inner], float)
|
|
798
|
+
|
|
799
|
+
# Combine into single ground surface
|
|
800
|
+
n_outer_verts = len(outer_Vg)
|
|
801
|
+
center_tris_offset = center_tris + n_outer_verts
|
|
802
|
+
|
|
803
|
+
Vg = np.vstack([outer_Vg, center_Vg])
|
|
804
|
+
tris = np.vstack([outer_tris, center_tris_offset])
|
|
805
|
+
|
|
806
|
+
return {"ground_surface": (Vg, tris)}
|
|
807
|
+
else:
|
|
808
|
+
# Original logic for open paths
|
|
809
|
+
# Inner boundary: the trench opening (same as trench top ring)
|
|
810
|
+
L_inner, R_inner = _offset_polyline(path_xy, half_top)
|
|
811
|
+
inner_ring = _ensure_ccw(_ring_from_LR(L_inner, R_inner))
|
|
812
|
+
|
|
813
|
+
# Outer boundary: offset by additional margin
|
|
814
|
+
L_outer, R_outer = _offset_polyline(path_xy, half_top + m)
|
|
815
|
+
outer_ring = _ensure_ccw(_ring_from_LR(L_outer, R_outer))
|
|
816
|
+
|
|
817
|
+
# Triangulate the annular region (leaves hole open)
|
|
818
|
+
combined_xy, tris = _triangulate_annulus(outer_ring, inner_ring)
|
|
819
|
+
|
|
820
|
+
# Apply ground elevation to get 3D vertices
|
|
821
|
+
Vg = np.array([[x, y, gfun(x, y)] for (x, y) in combined_xy], float)
|
|
822
|
+
|
|
823
|
+
return {"ground_surface": (Vg, tris)}
|
|
548
824
|
|
|
549
825
|
def _half_width_at_depth(half_top: float, slope: float, top_z: float, z: float) -> float:
|
|
550
826
|
return max(1e-6, half_top - slope * (top_z - z))
|
|
@@ -731,7 +1007,8 @@ def _build_surface_groups(
|
|
|
731
1007
|
def generate_surface_mesh(spec: SceneSpec, *, make_preview: bool = False) -> SurfaceMeshResult:
|
|
732
1008
|
groups, object_counts, extra = _build_surface_groups(spec)
|
|
733
1009
|
metrics = _compute_surface_metrics(groups, extra, spec)
|
|
734
|
-
|
|
1010
|
+
# Exclude internal groups (like cap) from previews to show open-topped trenches
|
|
1011
|
+
previews = _render_surface_previews(groups, exclude_groups=_INTERNAL_GROUPS) if make_preview else {}
|
|
735
1012
|
return SurfaceMeshResult(
|
|
736
1013
|
spec=spec,
|
|
737
1014
|
groups=groups,
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trenchfoot
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Synthetic trench scenario generator bundle (surfaces + volumetrics).
|
|
5
|
+
Author: Liam Moore
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: numpy>=1.26
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8.3; extra == 'dev'
|
|
11
|
+
Provides-Extra: mesher
|
|
12
|
+
Requires-Dist: gmsh>=4.11; extra == 'mesher'
|
|
13
|
+
Provides-Extra: preview
|
|
14
|
+
Requires-Dist: matplotlib>=3.8; extra == 'preview'
|
|
15
|
+
Provides-Extra: viz
|
|
16
|
+
Requires-Dist: plotly>=5.24; extra == 'viz'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# Trenchfoot
|
|
20
|
+
|
|
21
|
+
Surface and volumetric trench mesh generator with shipped presets, Plotly previews, and a lightweight Python SDK.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install trenchfoot
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Want volumetrics or visualisations? Install extras as needed:
|
|
30
|
+
- `pip install "trenchfoot[mesher]"` for gmsh-powered volume meshes.
|
|
31
|
+
- `pip install "trenchfoot[preview]"` for matplotlib snapshot renders.
|
|
32
|
+
- `pip install "trenchfoot[viz]"` for Plotly HTML viewers.
|
|
33
|
+
|
|
34
|
+
## Scenario Gallery
|
|
35
|
+
|
|
36
|
+
Color key: trench surfaces use warm soil tones; embedded geometry is colour-coded per group.
|
|
37
|
+
|
|
38
|
+
| Scenario | Top | Side | Oblique |
|
|
39
|
+
| --- | --- | --- | --- |
|
|
40
|
+
| S01_straight_vwalls |  |  |  |
|
|
41
|
+
| S02_straight_slope_pipe |  |  |  |
|
|
42
|
+
| S03_L_slope_two_pipes_box |  |  |  |
|
|
43
|
+
| S04_U_slope_multi_noise |  |  |  |
|
|
44
|
+
| S05_wide_slope_pair |  |  |  |
|
|
45
|
+
| S06_bumpy_wide_loop |  |  |  |
|
|
46
|
+
| S07_circular_well |  |  |  |
|
|
47
|
+
|
|
48
|
+
### S07 circular well preset
|
|
49
|
+
|
|
50
|
+
A deep cylindrical well with criss-crossing pipes at different elevations:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"path_xy": "<<32-vertex circle approximation, radius=1.5>>",
|
|
55
|
+
"width": 2.0,
|
|
56
|
+
"depth": 2.5,
|
|
57
|
+
"wall_slope": 0.05,
|
|
58
|
+
"ground": {"z0": 0.0, "slope": [0.0, 0.0], "size_margin": 2.0},
|
|
59
|
+
"pipes": [
|
|
60
|
+
{"radius": 0.20, "length": 4.0, "angle_deg": 0, "s_center": 0.25, "z": -0.5},
|
|
61
|
+
{"radius": 0.15, "length": 3.5, "angle_deg": 45, "s_center": 0.5, "z": -1.2},
|
|
62
|
+
{"radius": 0.10, "length": 3.0, "angle_deg": -60, "s_center": 0.75, "z": -1.8},
|
|
63
|
+
{"radius": 0.12, "length": 3.2, "angle_deg": 90, "s_center": 0.0, "z": -2.2}
|
|
64
|
+
],
|
|
65
|
+
"spheres": [{"radius": 0.25, "s": 0.4, "z": -1.5}],
|
|
66
|
+
"noise": {"enable": true, "amplitude": 0.02, "corr_length": 0.4, "octaves": 2, "gain": 0.5}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## CLI quick start
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
trenchfoot-generate --help
|
|
74
|
+
trenchfoot-generate --preview --skip-volumetric --gallery docs/scenario_gallery.md
|
|
75
|
+
trenchfoot-plot packages/trenchfoot/scenarios/S05_wide_slope_pair/trench_scene.obj --open
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Set `TRENCHFOOT_SCENARIO_OUT_ROOT=/tmp/trench-previews` (or another writable path) to keep generated assets out of your checkout.
|
|
79
|
+
|
|
80
|
+
## Python API
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from trenchfoot import scene_spec_from_dict, generate_surface_mesh, generate_trench_volume, gmsh_available
|
|
84
|
+
|
|
85
|
+
spec_dict = {
|
|
86
|
+
"path_xy": [[0.0, 0.0], [5.0, 0.0]],
|
|
87
|
+
"width": 1.0,
|
|
88
|
+
"depth": 1.2,
|
|
89
|
+
"pipes": [{"radius": 0.1, "length": 1.8, "angle_deg": 0.0, "s_center": 0.5}],
|
|
90
|
+
"boxes": [],
|
|
91
|
+
"spheres": [],
|
|
92
|
+
"noise": {"enable": False},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
scene = scene_spec_from_dict(spec_dict)
|
|
96
|
+
surface = generate_surface_mesh(scene, make_preview=True)
|
|
97
|
+
surface.persist("./surface")
|
|
98
|
+
|
|
99
|
+
if gmsh_available():
|
|
100
|
+
volume = generate_trench_volume(spec_dict, lc=0.4, persist_path="./volume/trench_volume.msh")
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`SurfaceMeshResult` keeps per-group faces, metrics, and optional preview PNG bytes; call `.persist(...)` when you need files. `VolumeMeshResult` exposes node coordinates, elements, and physical groups while still letting you stay in memory.
|
|
104
|
+
|
|
105
|
+
## Testing
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pytest -rs
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The suite exercises each preset (surface + volumetric), the gallery helpers, and the SDK smoke paths.
|