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.

Files changed (59) hide show
  1. trenchfoot/generate_scenarios.py +104 -27
  2. trenchfoot/gmsh_sloped_trench_mesher.py +133 -28
  3. trenchfoot/scenarios/S01_straight_vwalls/metrics.json +4 -4
  4. trenchfoot/scenarios/S01_straight_vwalls/preview_oblique.png +0 -0
  5. trenchfoot/scenarios/S01_straight_vwalls/preview_side.png +0 -0
  6. trenchfoot/scenarios/S01_straight_vwalls/preview_top.png +0 -0
  7. trenchfoot/scenarios/S01_straight_vwalls/scene.json +2 -2
  8. trenchfoot/scenarios/S01_straight_vwalls/trench_scene.obj +30 -27
  9. trenchfoot/scenarios/S01_straight_vwalls/volumetric/trench_volume.msh +540 -802
  10. trenchfoot/scenarios/S02_straight_slope_pipe/metrics.json +10 -10
  11. trenchfoot/scenarios/S02_straight_slope_pipe/preview_oblique.png +0 -0
  12. trenchfoot/scenarios/S02_straight_slope_pipe/preview_side.png +0 -0
  13. trenchfoot/scenarios/S02_straight_slope_pipe/preview_top.png +0 -0
  14. trenchfoot/scenarios/S02_straight_slope_pipe/scene.json +3 -3
  15. trenchfoot/scenarios/S02_straight_slope_pipe/trench_scene.obj +4977 -4974
  16. trenchfoot/scenarios/S02_straight_slope_pipe/volumetric/trench_volume.msh +1694 -1969
  17. trenchfoot/scenarios/S03_L_slope_two_pipes_box/metrics.json +14 -14
  18. trenchfoot/scenarios/S03_L_slope_two_pipes_box/preview_oblique.png +0 -0
  19. trenchfoot/scenarios/S03_L_slope_two_pipes_box/preview_side.png +0 -0
  20. trenchfoot/scenarios/S03_L_slope_two_pipes_box/preview_top.png +0 -0
  21. trenchfoot/scenarios/S03_L_slope_two_pipes_box/scene.json +4 -4
  22. trenchfoot/scenarios/S03_L_slope_two_pipes_box/trench_scene.obj +10547 -10540
  23. trenchfoot/scenarios/S03_L_slope_two_pipes_box/volumetric/trench_volume.msh +3101 -3767
  24. trenchfoot/scenarios/S04_U_slope_multi_noise/metrics.json +17 -17
  25. trenchfoot/scenarios/S04_U_slope_multi_noise/preview_oblique.png +0 -0
  26. trenchfoot/scenarios/S04_U_slope_multi_noise/preview_side.png +0 -0
  27. trenchfoot/scenarios/S04_U_slope_multi_noise/preview_top.png +0 -0
  28. trenchfoot/scenarios/S04_U_slope_multi_noise/scene.json +8 -7
  29. trenchfoot/scenarios/S04_U_slope_multi_noise/trench_scene.obj +17999 -17988
  30. trenchfoot/scenarios/S04_U_slope_multi_noise/volumetric/trench_volume.msh +4991 -6506
  31. trenchfoot/scenarios/S05_wide_slope_pair/metrics.json +14 -14
  32. trenchfoot/scenarios/S05_wide_slope_pair/preview_oblique.png +0 -0
  33. trenchfoot/scenarios/S05_wide_slope_pair/preview_side.png +0 -0
  34. trenchfoot/scenarios/S05_wide_slope_pair/preview_top.png +0 -0
  35. trenchfoot/scenarios/S05_wide_slope_pair/scene.json +6 -6
  36. trenchfoot/scenarios/S05_wide_slope_pair/trench_scene.obj +10547 -10540
  37. trenchfoot/scenarios/S05_wide_slope_pair/volumetric/trench_volume.msh +5081 -0
  38. trenchfoot/scenarios/S06_bumpy_wide_loop/metrics.json +16 -16
  39. trenchfoot/scenarios/S06_bumpy_wide_loop/preview_oblique.png +0 -0
  40. trenchfoot/scenarios/S06_bumpy_wide_loop/preview_side.png +0 -0
  41. trenchfoot/scenarios/S06_bumpy_wide_loop/preview_top.png +0 -0
  42. trenchfoot/scenarios/S06_bumpy_wide_loop/scene.json +6 -6
  43. trenchfoot/scenarios/S06_bumpy_wide_loop/trench_scene.obj +12812 -12793
  44. trenchfoot/scenarios/S06_bumpy_wide_loop/volumetric/trench_volume.msh +10402 -0
  45. trenchfoot/scenarios/S07_circular_well/metrics.json +48 -0
  46. trenchfoot/scenarios/S07_circular_well/preview_oblique.png +0 -0
  47. trenchfoot/scenarios/S07_circular_well/preview_side.png +0 -0
  48. trenchfoot/scenarios/S07_circular_well/preview_top.png +0 -0
  49. trenchfoot/scenarios/S07_circular_well/scene.json +203 -0
  50. trenchfoot/scenarios/S07_circular_well/trench_scene.obj +64401 -0
  51. trenchfoot/scenarios/S07_circular_well/volumetric/trench_volume.msh +22370 -0
  52. trenchfoot/scenarios/SUMMARY.json +343 -32
  53. trenchfoot/trench_scene_generator_v3.py +323 -46
  54. trenchfoot-0.2.2.dist-info/METADATA +111 -0
  55. {trenchfoot-0.1.0.dist-info → trenchfoot-0.2.2.dist-info}/RECORD +58 -49
  56. {trenchfoot-0.1.0.dist-info → trenchfoot-0.2.2.dist-info}/WHEEL +1 -1
  57. trenchfoot-0.1.0.dist-info/METADATA +0 -104
  58. {trenchfoot-0.1.0.dist-info → trenchfoot-0.2.2.dist-info}/entry_points.txt +0 -0
  59. {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(groups: Dict[str, Tuple[np.ndarray, np.ndarray]]) -> Dict[str, bytes]:
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
- write_obj_with_groups(obj_path.as_posix(), self.groups)
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
- 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)
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
- 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
- }
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
- # 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))
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
- 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)}
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
- previews = _render_surface_previews(groups) if make_preview else {}
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 | ![S01 top](packages/trenchfoot/scenarios/S01_straight_vwalls/preview_top.png) | ![S01 side](packages/trenchfoot/scenarios/S01_straight_vwalls/preview_side.png) | ![S01 oblique](packages/trenchfoot/scenarios/S01_straight_vwalls/preview_oblique.png) |
41
+ | S02_straight_slope_pipe | ![S02 top](packages/trenchfoot/scenarios/S02_straight_slope_pipe/preview_top.png) | ![S02 side](packages/trenchfoot/scenarios/S02_straight_slope_pipe/preview_side.png) | ![S02 oblique](packages/trenchfoot/scenarios/S02_straight_slope_pipe/preview_oblique.png) |
42
+ | S03_L_slope_two_pipes_box | ![S03 top](packages/trenchfoot/scenarios/S03_L_slope_two_pipes_box/preview_top.png) | ![S03 side](packages/trenchfoot/scenarios/S03_L_slope_two_pipes_box/preview_side.png) | ![S03 oblique](packages/trenchfoot/scenarios/S03_L_slope_two_pipes_box/preview_oblique.png) |
43
+ | S04_U_slope_multi_noise | ![S04 top](packages/trenchfoot/scenarios/S04_U_slope_multi_noise/preview_top.png) | ![S04 side](packages/trenchfoot/scenarios/S04_U_slope_multi_noise/preview_side.png) | ![S04 oblique](packages/trenchfoot/scenarios/S04_U_slope_multi_noise/preview_oblique.png) |
44
+ | S05_wide_slope_pair | ![S05 top](packages/trenchfoot/scenarios/S05_wide_slope_pair/preview_top.png) | ![S05 side](packages/trenchfoot/scenarios/S05_wide_slope_pair/preview_side.png) | ![S05 oblique](packages/trenchfoot/scenarios/S05_wide_slope_pair/preview_oblique.png) |
45
+ | S06_bumpy_wide_loop | ![S06 top](packages/trenchfoot/scenarios/S06_bumpy_wide_loop/preview_top.png) | ![S06 side](packages/trenchfoot/scenarios/S06_bumpy_wide_loop/preview_side.png) | ![S06 oblique](packages/trenchfoot/scenarios/S06_bumpy_wide_loop/preview_oblique.png) |
46
+ | S07_circular_well | ![S07 top](packages/trenchfoot/scenarios/S07_circular_well/preview_top.png) | ![S07 side](packages/trenchfoot/scenarios/S07_circular_well/preview_side.png) | ![S07 oblique](packages/trenchfoot/scenarios/S07_circular_well/preview_oblique.png) |
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.