trenchfoot 0.4.0__py3-none-any.whl → 0.4.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/scenarios/S01_straight_vwalls/sdf_metadata.json +23 -4
- trenchfoot/scenarios/S02_straight_slope_pipe/sdf_metadata.json +35 -4
- trenchfoot/scenarios/S03_L_slope_two_pipes_box/sdf_metadata.json +52 -5
- trenchfoot/scenarios/S04_U_slope_multi_noise/sdf_metadata.json +66 -7
- trenchfoot/scenarios/S05_wide_slope_pair/sdf_metadata.json +52 -5
- trenchfoot/scenarios/S06_bumpy_wide_loop/sdf_metadata.json +63 -36
- trenchfoot/scenarios/S07_circular_well/sdf_metadata.json +136 -201
- trenchfoot/trench_scene_generator_v3.py +755 -51
- {trenchfoot-0.4.0.dist-info → trenchfoot-0.4.2.dist-info}/METADATA +1 -1
- {trenchfoot-0.4.0.dist-info → trenchfoot-0.4.2.dist-info}/RECORD +14 -14
- /trenchfoot/scenarios/S03_L_slope_two_pipes_box/point_clouds/full/{resolution0p050.pth → resolutio} +0 -0
- {trenchfoot-0.4.0.dist-info → trenchfoot-0.4.2.dist-info}/WHEEL +0 -0
- {trenchfoot-0.4.0.dist-info → trenchfoot-0.4.2.dist-info}/entry_points.txt +0 -0
- {trenchfoot-0.4.0.dist-info → trenchfoot-0.4.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -18,7 +18,7 @@ Outputs:
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
20
|
import io
|
|
21
|
-
import os, json, math, argparse
|
|
21
|
+
import os, json, math, argparse
|
|
22
22
|
from dataclasses import dataclass, field, asdict
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
from typing import Dict, List, Tuple, Optional, Any
|
|
@@ -198,6 +198,74 @@ def _ear_clipping_triangulation(poly_xy: np.ndarray) -> np.ndarray:
|
|
|
198
198
|
tris.append([V[0],V[1],V[2]])
|
|
199
199
|
return np.array(tris,int)
|
|
200
200
|
|
|
201
|
+
|
|
202
|
+
def _extract_boundary_polygon(V: np.ndarray, F: np.ndarray) -> Optional[np.ndarray]:
|
|
203
|
+
"""Extract ordered boundary polygon from a triangulated mesh.
|
|
204
|
+
|
|
205
|
+
Finds boundary edges (edges appearing in only one face) and chains
|
|
206
|
+
them together to form an ordered polygon. This correctly handles
|
|
207
|
+
non-convex shapes like L-shaped or U-shaped boundaries.
|
|
208
|
+
|
|
209
|
+
Parameters
|
|
210
|
+
----------
|
|
211
|
+
V : np.ndarray
|
|
212
|
+
Vertices (n, 3) or (n, 2)
|
|
213
|
+
F : np.ndarray
|
|
214
|
+
Faces (m, 3) - triangle indices
|
|
215
|
+
|
|
216
|
+
Returns
|
|
217
|
+
-------
|
|
218
|
+
np.ndarray or None
|
|
219
|
+
Ordered boundary vertices XY coordinates (k, 2), or None if
|
|
220
|
+
no boundary edges found (closed mesh).
|
|
221
|
+
"""
|
|
222
|
+
if F.size == 0:
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
# Count edge occurrences - boundary edges appear exactly once
|
|
226
|
+
edge_count: Dict[Tuple[int, int], int] = {}
|
|
227
|
+
for face in F:
|
|
228
|
+
for i in range(3):
|
|
229
|
+
v0, v1 = int(face[i]), int(face[(i + 1) % 3])
|
|
230
|
+
edge = (min(v0, v1), max(v0, v1))
|
|
231
|
+
edge_count[edge] = edge_count.get(edge, 0) + 1
|
|
232
|
+
|
|
233
|
+
# Boundary edges appear exactly once
|
|
234
|
+
boundary_edges = [edge for edge, count in edge_count.items() if count == 1]
|
|
235
|
+
|
|
236
|
+
if not boundary_edges:
|
|
237
|
+
return None # No boundary (closed mesh)
|
|
238
|
+
|
|
239
|
+
# Build adjacency from boundary edges
|
|
240
|
+
adj: Dict[int, List[int]] = {}
|
|
241
|
+
for v0, v1 in boundary_edges:
|
|
242
|
+
adj.setdefault(v0, []).append(v1)
|
|
243
|
+
adj.setdefault(v1, []).append(v0)
|
|
244
|
+
|
|
245
|
+
# Chain the boundary edges starting from any vertex
|
|
246
|
+
start = boundary_edges[0][0]
|
|
247
|
+
polygon = [start]
|
|
248
|
+
prev = None
|
|
249
|
+
curr = start
|
|
250
|
+
|
|
251
|
+
max_iter = len(boundary_edges) + 1
|
|
252
|
+
for _ in range(max_iter):
|
|
253
|
+
neighbors = adj.get(curr, [])
|
|
254
|
+
# Pick the neighbor that isn't the previous vertex
|
|
255
|
+
next_candidates = [n for n in neighbors if n != prev]
|
|
256
|
+
if not next_candidates:
|
|
257
|
+
break
|
|
258
|
+
next_v = next_candidates[0]
|
|
259
|
+
if next_v == start:
|
|
260
|
+
break # Completed the loop
|
|
261
|
+
polygon.append(next_v)
|
|
262
|
+
prev = curr
|
|
263
|
+
curr = next_v
|
|
264
|
+
|
|
265
|
+
# Extract XY coordinates
|
|
266
|
+
return V[polygon, :2]
|
|
267
|
+
|
|
268
|
+
|
|
201
269
|
# ---------------- Mesh IO & metrics ----------------
|
|
202
270
|
|
|
203
271
|
def write_obj_with_groups(path: str, groups: Dict[str, Tuple[np.ndarray, np.ndarray]]):
|
|
@@ -451,19 +519,15 @@ class SurfaceMeshResult:
|
|
|
451
519
|
if "trench_cap_for_volume" in self.groups:
|
|
452
520
|
V_cap, F_cap = self.groups["trench_cap_for_volume"]
|
|
453
521
|
if V_cap.size > 0:
|
|
454
|
-
#
|
|
455
|
-
#
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
trench_opening_vertices = xy_coords[boundary_indices].tolist()
|
|
464
|
-
except ImportError:
|
|
465
|
-
# Fallback: just use unique xy coords (unordered)
|
|
466
|
-
trench_opening_vertices = xy_coords.tolist()
|
|
522
|
+
# Extract boundary polygon by finding boundary edges (edges in only one face)
|
|
523
|
+
# and chaining them together. This correctly handles non-convex shapes
|
|
524
|
+
# like L-shaped or U-shaped trenches.
|
|
525
|
+
boundary_xy = _extract_boundary_polygon(V_cap, F_cap)
|
|
526
|
+
if boundary_xy is not None:
|
|
527
|
+
trench_opening_vertices = boundary_xy.tolist()
|
|
528
|
+
else:
|
|
529
|
+
# Fallback: just use all vertices (unordered)
|
|
530
|
+
trench_opening_vertices = V_cap[:, :2].tolist()
|
|
467
531
|
|
|
468
532
|
# Determine geometry type
|
|
469
533
|
is_closed = _is_path_closed(self.spec.path_xy)
|
|
@@ -489,7 +553,7 @@ class SurfaceMeshResult:
|
|
|
489
553
|
|
|
490
554
|
return {
|
|
491
555
|
"sdf_metadata": {
|
|
492
|
-
"version": "
|
|
556
|
+
"version": "2.0",
|
|
493
557
|
"normal_convention": "into_void",
|
|
494
558
|
"geometry_type": geometry_type,
|
|
495
559
|
"trench_opening": {
|
|
@@ -550,41 +614,202 @@ def _frame_from_axis(axis_dir: np.ndarray) -> np.ndarray:
|
|
|
550
614
|
u=_normalize(np.cross(helper,v)); w=np.cross(v,u)
|
|
551
615
|
return np.column_stack([u,v,w])
|
|
552
616
|
|
|
553
|
-
def make_cylinder(
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
617
|
+
def make_cylinder(
|
|
618
|
+
center: np.ndarray,
|
|
619
|
+
axis_dir: np.ndarray,
|
|
620
|
+
radius: float,
|
|
621
|
+
length: float,
|
|
622
|
+
n_theta: int = 64,
|
|
623
|
+
n_along: int = 32,
|
|
624
|
+
with_caps: bool = True,
|
|
625
|
+
neg_extent: Optional[float] = None,
|
|
626
|
+
pos_extent: Optional[float] = None,
|
|
627
|
+
cap_plane_neg: Optional[Tuple[np.ndarray, np.ndarray]] = None,
|
|
628
|
+
cap_plane_pos: Optional[Tuple[np.ndarray, np.ndarray]] = None,
|
|
629
|
+
) -> Dict[str, Tuple[np.ndarray, np.ndarray]]:
|
|
630
|
+
"""Generate a cylinder mesh with optional truncation and angled caps.
|
|
631
|
+
|
|
632
|
+
Parameters
|
|
633
|
+
----------
|
|
634
|
+
center : np.ndarray
|
|
635
|
+
Center point of the cylinder (3D).
|
|
636
|
+
axis_dir : np.ndarray
|
|
637
|
+
Unit vector along the cylinder axis (3D).
|
|
638
|
+
radius : float
|
|
639
|
+
Cylinder radius.
|
|
640
|
+
length : float
|
|
641
|
+
Total cylinder length (used if neg/pos_extent not specified).
|
|
642
|
+
n_theta : int
|
|
643
|
+
Number of angular divisions.
|
|
644
|
+
n_along : int
|
|
645
|
+
Number of divisions along the axis.
|
|
646
|
+
with_caps : bool
|
|
647
|
+
Whether to generate end caps.
|
|
648
|
+
neg_extent : float, optional
|
|
649
|
+
Distance from center to negative end. If None, uses -length/2.
|
|
650
|
+
pos_extent : float, optional
|
|
651
|
+
Distance from center to positive end. If None, uses +length/2.
|
|
652
|
+
cap_plane_neg : tuple, optional
|
|
653
|
+
(normal, point) defining the plane for negative cap. If provided,
|
|
654
|
+
generates an elliptical cap on this plane instead of a flat circular cap.
|
|
655
|
+
cap_plane_pos : tuple, optional
|
|
656
|
+
(normal, point) for positive cap.
|
|
657
|
+
|
|
658
|
+
Returns
|
|
659
|
+
-------
|
|
660
|
+
dict
|
|
661
|
+
Dictionary with 'pipe_side' and optionally 'pipe_cap_neg', 'pipe_cap_pos'.
|
|
662
|
+
"""
|
|
663
|
+
n_theta = max(8, int(n_theta))
|
|
664
|
+
n_along = max(1, int(n_along))
|
|
665
|
+
|
|
666
|
+
# Use extents if provided, otherwise symmetric from length
|
|
667
|
+
y_neg = neg_extent if neg_extent is not None else -length / 2.0
|
|
668
|
+
y_pos = pos_extent if pos_extent is not None else length / 2.0
|
|
669
|
+
|
|
670
|
+
# Build coordinate frame
|
|
671
|
+
M = _frame_from_axis(axis_dir)
|
|
672
|
+
|
|
673
|
+
def xform(V: np.ndarray) -> np.ndarray:
|
|
674
|
+
return (center + V @ M.T).astype(float)
|
|
675
|
+
|
|
676
|
+
# Generate cylinder side surface
|
|
677
|
+
thetas = np.linspace(0, 2 * np.pi, n_theta + 1)
|
|
678
|
+
ys = np.linspace(y_neg, y_pos, n_along + 1)
|
|
679
|
+
Vloc = []
|
|
680
|
+
for j in range(n_along + 1):
|
|
681
|
+
y = ys[j]
|
|
682
|
+
for i in range(n_theta + 1):
|
|
683
|
+
th = thetas[i]
|
|
684
|
+
x = radius * np.cos(th)
|
|
685
|
+
z = radius * np.sin(th)
|
|
686
|
+
Vloc.append([x, y, z])
|
|
687
|
+
Vloc = np.array(Vloc, float)
|
|
688
|
+
|
|
689
|
+
def idx(i: int, j: int) -> int:
|
|
690
|
+
return j * (n_theta + 1) + i
|
|
691
|
+
|
|
692
|
+
F = []
|
|
566
693
|
for j in range(n_along):
|
|
567
694
|
for i in range(n_theta):
|
|
568
|
-
v00
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
Fp=np.array([[0,1+i,1+(i+1)%len(ring)] for i in range(len(ring))],int)
|
|
579
|
-
caps['pipe_cap_neg']=(Vn,Fn); caps['pipe_cap_pos']=(Vp,Fp)
|
|
580
|
-
M=_frame_from_axis(axis_dir)
|
|
581
|
-
def xform(V): return (center + V @ M.T).astype(float)
|
|
582
|
-
out={"pipe_side": (xform(Vloc), F)}
|
|
695
|
+
v00 = idx(i, j)
|
|
696
|
+
v10 = idx(i + 1, j)
|
|
697
|
+
v01 = idx(i, j + 1)
|
|
698
|
+
v11 = idx(i + 1, j + 1)
|
|
699
|
+
F.append([v00, v01, v11])
|
|
700
|
+
F.append([v00, v11, v10])
|
|
701
|
+
F = np.array(F, int)
|
|
702
|
+
|
|
703
|
+
out: Dict[str, Tuple[np.ndarray, np.ndarray]] = {"pipe_side": (xform(Vloc), F)}
|
|
704
|
+
|
|
583
705
|
if with_caps:
|
|
584
|
-
|
|
585
|
-
|
|
706
|
+
# Generate caps (either flat circular or angled elliptical)
|
|
707
|
+
Vn, Fn = _make_cylinder_cap(
|
|
708
|
+
radius, y_neg, thetas[:-1], M, center, axis_dir,
|
|
709
|
+
cap_plane_neg, is_negative=True
|
|
710
|
+
)
|
|
711
|
+
Vp, Fp = _make_cylinder_cap(
|
|
712
|
+
radius, y_pos, thetas[:-1], M, center, axis_dir,
|
|
713
|
+
cap_plane_pos, is_negative=False
|
|
714
|
+
)
|
|
715
|
+
out['pipe_cap_neg'] = (Vn, Fn)
|
|
716
|
+
out['pipe_cap_pos'] = (Vp, Fp)
|
|
717
|
+
|
|
586
718
|
return out
|
|
587
719
|
|
|
720
|
+
|
|
721
|
+
def _make_cylinder_cap(
|
|
722
|
+
radius: float,
|
|
723
|
+
y_extent: float,
|
|
724
|
+
thetas: np.ndarray,
|
|
725
|
+
M: np.ndarray,
|
|
726
|
+
center: np.ndarray,
|
|
727
|
+
axis_dir: np.ndarray,
|
|
728
|
+
cap_plane: Optional[Tuple[np.ndarray, np.ndarray]],
|
|
729
|
+
is_negative: bool,
|
|
730
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
731
|
+
"""Generate a cylinder end cap, either flat or angled.
|
|
732
|
+
|
|
733
|
+
For angled caps, we compute where each point on the cylinder rim
|
|
734
|
+
intersects the cap plane, creating an elliptical cap.
|
|
735
|
+
"""
|
|
736
|
+
n_theta = len(thetas)
|
|
737
|
+
|
|
738
|
+
if cap_plane is None:
|
|
739
|
+
# Flat circular cap perpendicular to axis
|
|
740
|
+
ring = np.array([
|
|
741
|
+
[radius * np.cos(t), y_extent, radius * np.sin(t)]
|
|
742
|
+
for t in thetas
|
|
743
|
+
], float)
|
|
744
|
+
cap_center = np.array([[0.0, y_extent, 0.0]], float)
|
|
745
|
+
V = np.vstack([cap_center, ring])
|
|
746
|
+
|
|
747
|
+
# Apply transformation to world coordinates
|
|
748
|
+
V_world = center + V @ M.T
|
|
749
|
+
|
|
750
|
+
# Generate fan triangles
|
|
751
|
+
if is_negative:
|
|
752
|
+
# Negative cap: winding for outward normal (toward -axis)
|
|
753
|
+
F = np.array([[0, 1 + (i + 1) % n_theta, 1 + i] for i in range(n_theta)], int)
|
|
754
|
+
else:
|
|
755
|
+
# Positive cap: winding for outward normal (toward +axis)
|
|
756
|
+
F = np.array([[0, 1 + i, 1 + (i + 1) % n_theta] for i in range(n_theta)], int)
|
|
757
|
+
|
|
758
|
+
return V_world.astype(float), F
|
|
759
|
+
else:
|
|
760
|
+
# Angled cap: intersect cylinder rim with plane
|
|
761
|
+
plane_normal, plane_point = cap_plane
|
|
762
|
+
plane_normal = _normalize(plane_normal)
|
|
763
|
+
|
|
764
|
+
# Points on the cylinder rim at y_extent (in local coords before xform)
|
|
765
|
+
ring_local = np.array([
|
|
766
|
+
[radius * np.cos(t), y_extent, radius * np.sin(t)]
|
|
767
|
+
for t in thetas
|
|
768
|
+
], float)
|
|
769
|
+
|
|
770
|
+
# Transform ring to world coordinates
|
|
771
|
+
ring_world = center + ring_local @ M.T
|
|
772
|
+
|
|
773
|
+
# For each ring point, project along axis onto the cap plane
|
|
774
|
+
# Line: P = ring_point + t * axis_dir
|
|
775
|
+
# Plane: dot(P - plane_point, plane_normal) = 0
|
|
776
|
+
# => t = dot(plane_point - ring_point, plane_normal) / dot(axis_dir, plane_normal)
|
|
777
|
+
denom = np.dot(axis_dir, plane_normal)
|
|
778
|
+
if abs(denom) < 1e-10:
|
|
779
|
+
# Axis is parallel to plane - fall back to flat cap
|
|
780
|
+
V_world = np.vstack([
|
|
781
|
+
center + np.array([0.0, y_extent, 0.0]) @ M.T,
|
|
782
|
+
ring_world
|
|
783
|
+
])
|
|
784
|
+
if is_negative:
|
|
785
|
+
F = np.array([[0, 1 + (i + 1) % n_theta, 1 + i] for i in range(n_theta)], int)
|
|
786
|
+
else:
|
|
787
|
+
F = np.array([[0, 1 + i, 1 + (i + 1) % n_theta] for i in range(n_theta)], int)
|
|
788
|
+
return V_world.astype(float), F
|
|
789
|
+
|
|
790
|
+
# Project ring points onto plane
|
|
791
|
+
cap_verts = []
|
|
792
|
+
for ring_pt in ring_world:
|
|
793
|
+
t = np.dot(plane_point - ring_pt, plane_normal) / denom
|
|
794
|
+
cap_pt = ring_pt + t * axis_dir
|
|
795
|
+
cap_verts.append(cap_pt)
|
|
796
|
+
cap_verts = np.array(cap_verts, float)
|
|
797
|
+
|
|
798
|
+
# Cap center: project axis center onto plane
|
|
799
|
+
axis_pt = center + y_extent * axis_dir
|
|
800
|
+
t_center = np.dot(plane_point - axis_pt, plane_normal) / denom
|
|
801
|
+
cap_center = axis_pt + t_center * axis_dir
|
|
802
|
+
|
|
803
|
+
V_world = np.vstack([cap_center.reshape(1, 3), cap_verts])
|
|
804
|
+
|
|
805
|
+
# Generate fan triangles
|
|
806
|
+
if is_negative:
|
|
807
|
+
F = np.array([[0, 1 + (i + 1) % n_theta, 1 + i] for i in range(n_theta)], int)
|
|
808
|
+
else:
|
|
809
|
+
F = np.array([[0, 1 + i, 1 + (i + 1) % n_theta] for i in range(n_theta)], int)
|
|
810
|
+
|
|
811
|
+
return V_world.astype(float), F
|
|
812
|
+
|
|
588
813
|
def make_box(center: np.ndarray, frame_cols: np.ndarray, dims: Tuple[float,float,float]):
|
|
589
814
|
a,b,h=dims; u=frame_cols[:,0]; v=frame_cols[:,1]; w=frame_cols[:,2]
|
|
590
815
|
corners=[]
|
|
@@ -1018,6 +1243,433 @@ def make_ground_surface_plane(path_xy: List[Tuple[float,float]], width_top: floa
|
|
|
1018
1243
|
def _half_width_at_depth(half_top: float, slope: float, top_z: float, z: float) -> float:
|
|
1019
1244
|
return max(1e-6, half_top - slope * (top_z - z))
|
|
1020
1245
|
|
|
1246
|
+
|
|
1247
|
+
# ---------------- Object Truncation Helpers ----------------
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
@dataclass
|
|
1251
|
+
class TrenchLocalFrame:
|
|
1252
|
+
"""Local coordinate frame at a point along the trench."""
|
|
1253
|
+
centerline_xy: np.ndarray # 2D position on centerline
|
|
1254
|
+
tangent: np.ndarray # 2D unit tangent along path
|
|
1255
|
+
left_normal: np.ndarray # 2D unit normal pointing left
|
|
1256
|
+
top_z: float # Ground elevation at this point
|
|
1257
|
+
half_width_top: float # Half-width at ground level
|
|
1258
|
+
depth: float # Trench depth
|
|
1259
|
+
wall_slope: float # Slope of walls
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def _find_trench_frame_at_xy(
|
|
1263
|
+
x: float, y: float,
|
|
1264
|
+
path_xy: List[Tuple[float, float]],
|
|
1265
|
+
half_top: float,
|
|
1266
|
+
depth: float,
|
|
1267
|
+
wall_slope: float,
|
|
1268
|
+
ground: GroundSpec,
|
|
1269
|
+
) -> Tuple[TrenchLocalFrame, float]:
|
|
1270
|
+
"""Find the trench local frame for a given XY position.
|
|
1271
|
+
|
|
1272
|
+
Returns the local coordinate frame and the local 'u' offset (signed
|
|
1273
|
+
distance from centerline in left_normal direction).
|
|
1274
|
+
"""
|
|
1275
|
+
_, total = _polyline_lengths(path_xy)
|
|
1276
|
+
|
|
1277
|
+
# Find closest point on path by sampling
|
|
1278
|
+
best_dist_sq = float("inf")
|
|
1279
|
+
best_s = 0.0
|
|
1280
|
+
n_samples = max(200, int(total * 50)) # ~50 samples per unit length for precision
|
|
1281
|
+
for s_val in np.linspace(0, 1, n_samples):
|
|
1282
|
+
pos, _ = _sample_polyline_at_s(path_xy, s_val)
|
|
1283
|
+
dist_sq = (pos[0] - x) ** 2 + (pos[1] - y) ** 2
|
|
1284
|
+
if dist_sq < best_dist_sq:
|
|
1285
|
+
best_dist_sq = dist_sq
|
|
1286
|
+
best_s = s_val
|
|
1287
|
+
|
|
1288
|
+
pos, tangent = _sample_polyline_at_s(path_xy, best_s)
|
|
1289
|
+
left_normal = _rotate_ccw(tangent)
|
|
1290
|
+
gfun = _ground_fn(ground)
|
|
1291
|
+
top_z = gfun(pos[0], pos[1])
|
|
1292
|
+
|
|
1293
|
+
# Compute local u offset
|
|
1294
|
+
offset_vec = np.array([x - pos[0], y - pos[1]])
|
|
1295
|
+
local_u = float(np.dot(offset_vec, left_normal))
|
|
1296
|
+
|
|
1297
|
+
frame = TrenchLocalFrame(
|
|
1298
|
+
centerline_xy=pos,
|
|
1299
|
+
tangent=tangent,
|
|
1300
|
+
left_normal=left_normal,
|
|
1301
|
+
top_z=top_z,
|
|
1302
|
+
half_width_top=half_top,
|
|
1303
|
+
depth=depth,
|
|
1304
|
+
wall_slope=wall_slope,
|
|
1305
|
+
)
|
|
1306
|
+
return frame, local_u
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def _point_inside_trench(
|
|
1310
|
+
x: float, y: float, z: float,
|
|
1311
|
+
path_xy: List[Tuple[float, float]],
|
|
1312
|
+
half_top: float,
|
|
1313
|
+
depth: float,
|
|
1314
|
+
wall_slope: float,
|
|
1315
|
+
ground: GroundSpec,
|
|
1316
|
+
) -> bool:
|
|
1317
|
+
"""Check if a 3D point is inside the trench void."""
|
|
1318
|
+
frame, local_u = _find_trench_frame_at_xy(x, y, path_xy, half_top, depth, wall_slope, ground)
|
|
1319
|
+
|
|
1320
|
+
# Check vertical bounds
|
|
1321
|
+
if z > frame.top_z:
|
|
1322
|
+
return False # Above ground
|
|
1323
|
+
if z < frame.top_z - depth:
|
|
1324
|
+
return False # Below floor
|
|
1325
|
+
|
|
1326
|
+
# Check horizontal bounds (accounting for wall slope)
|
|
1327
|
+
half_w = _half_width_at_depth(half_top, wall_slope, frame.top_z, z)
|
|
1328
|
+
return abs(local_u) <= half_w
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
@dataclass
|
|
1332
|
+
class TruncationResult:
|
|
1333
|
+
"""Result of computing pipe truncation."""
|
|
1334
|
+
neg_extent: float # Distance from center to negative end
|
|
1335
|
+
pos_extent: float # Distance from center to positive end
|
|
1336
|
+
neg_cap_plane: Optional[Tuple[np.ndarray, np.ndarray]] # (normal, point) or None
|
|
1337
|
+
pos_cap_plane: Optional[Tuple[np.ndarray, np.ndarray]] # (normal, point) or None
|
|
1338
|
+
was_truncated: bool # True if any truncation occurred
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
def _compute_pipe_truncation(
|
|
1342
|
+
center: np.ndarray,
|
|
1343
|
+
axis_dir: np.ndarray,
|
|
1344
|
+
radius: float,
|
|
1345
|
+
half_length: float,
|
|
1346
|
+
path_xy: List[Tuple[float, float]],
|
|
1347
|
+
half_top: float,
|
|
1348
|
+
wall_slope: float,
|
|
1349
|
+
ground: GroundSpec,
|
|
1350
|
+
depth: float,
|
|
1351
|
+
) -> TruncationResult:
|
|
1352
|
+
"""Compute where a pipe axis exits the trench void.
|
|
1353
|
+
|
|
1354
|
+
Samples points along the pipe axis and finds where the pipe surface
|
|
1355
|
+
(considering radius) would exit the trench. Returns truncated extents
|
|
1356
|
+
and the wall/floor planes at each truncation point.
|
|
1357
|
+
|
|
1358
|
+
The truncation includes a safety margin to account for cap projection.
|
|
1359
|
+
When the pipe is truncated at an angle, the cap vertices project beyond
|
|
1360
|
+
the truncation point. The cap_margin ensures the entire cap stays inside.
|
|
1361
|
+
"""
|
|
1362
|
+
# Cap safety margin: accounts for cap projection when pipe is at an angle
|
|
1363
|
+
# to the trench wall. The margin is proportional to radius with a minimum.
|
|
1364
|
+
cap_margin = max(0.02, 0.4 * radius)
|
|
1365
|
+
|
|
1366
|
+
def pipe_surface_inside(t: float) -> bool:
|
|
1367
|
+
"""Check if pipe surface at axis position t is inside trench.
|
|
1368
|
+
|
|
1369
|
+
Includes cap_margin to ensure angled caps stay inside the boundary.
|
|
1370
|
+
"""
|
|
1371
|
+
point = center + t * axis_dir
|
|
1372
|
+
x, y, z = point
|
|
1373
|
+
|
|
1374
|
+
# Get local trench frame
|
|
1375
|
+
frame, local_u = _find_trench_frame_at_xy(
|
|
1376
|
+
x, y, path_xy, half_top, depth, wall_slope, ground
|
|
1377
|
+
)
|
|
1378
|
+
|
|
1379
|
+
# Effective radius includes cap margin for conservative truncation
|
|
1380
|
+
effective_radius = radius + cap_margin
|
|
1381
|
+
|
|
1382
|
+
# Check floor clearance: pipe bottom must be above floor
|
|
1383
|
+
floor_z = frame.top_z - depth
|
|
1384
|
+
if z - effective_radius < floor_z:
|
|
1385
|
+
return False
|
|
1386
|
+
|
|
1387
|
+
# Check ceiling clearance: pipe top must be below ground
|
|
1388
|
+
if z + effective_radius > frame.top_z:
|
|
1389
|
+
return False
|
|
1390
|
+
|
|
1391
|
+
# Check wall clearance: pipe surface must not penetrate walls
|
|
1392
|
+
half_w = _half_width_at_depth(half_top, wall_slope, frame.top_z, z)
|
|
1393
|
+
if abs(local_u) + effective_radius > half_w:
|
|
1394
|
+
return False
|
|
1395
|
+
|
|
1396
|
+
return True
|
|
1397
|
+
|
|
1398
|
+
def binary_search_boundary(t_inside: float, t_outside: float, tol: float = 0.001) -> float:
|
|
1399
|
+
"""Binary search to find boundary between inside and outside."""
|
|
1400
|
+
for _ in range(50): # Max iterations
|
|
1401
|
+
if abs(t_outside - t_inside) < tol:
|
|
1402
|
+
break
|
|
1403
|
+
t_mid = (t_inside + t_outside) / 2
|
|
1404
|
+
if pipe_surface_inside(t_mid):
|
|
1405
|
+
t_inside = t_mid
|
|
1406
|
+
else:
|
|
1407
|
+
t_outside = t_mid
|
|
1408
|
+
return t_inside
|
|
1409
|
+
|
|
1410
|
+
# Find negative extent
|
|
1411
|
+
neg_extent = -half_length
|
|
1412
|
+
was_neg_truncated = False
|
|
1413
|
+
if pipe_surface_inside(0):
|
|
1414
|
+
# Search from center toward negative end
|
|
1415
|
+
step = 0.02 # 2cm steps
|
|
1416
|
+
t = 0
|
|
1417
|
+
while t > -half_length:
|
|
1418
|
+
t -= step
|
|
1419
|
+
if not pipe_surface_inside(t):
|
|
1420
|
+
# Found exit, binary search for exact boundary
|
|
1421
|
+
neg_extent = binary_search_boundary(t + step, t)
|
|
1422
|
+
was_neg_truncated = True
|
|
1423
|
+
break
|
|
1424
|
+
if not was_neg_truncated:
|
|
1425
|
+
# Never exited, use full length
|
|
1426
|
+
neg_extent = -half_length
|
|
1427
|
+
|
|
1428
|
+
# Find positive extent
|
|
1429
|
+
pos_extent = half_length
|
|
1430
|
+
was_pos_truncated = False
|
|
1431
|
+
if pipe_surface_inside(0):
|
|
1432
|
+
step = 0.02
|
|
1433
|
+
t = 0
|
|
1434
|
+
while t < half_length:
|
|
1435
|
+
t += step
|
|
1436
|
+
if not pipe_surface_inside(t):
|
|
1437
|
+
pos_extent = binary_search_boundary(t - step, t)
|
|
1438
|
+
was_pos_truncated = True
|
|
1439
|
+
break
|
|
1440
|
+
if not was_pos_truncated:
|
|
1441
|
+
pos_extent = half_length
|
|
1442
|
+
|
|
1443
|
+
# Compute cap planes at truncation points
|
|
1444
|
+
neg_cap_plane = None
|
|
1445
|
+
pos_cap_plane = None
|
|
1446
|
+
|
|
1447
|
+
if was_neg_truncated:
|
|
1448
|
+
neg_cap_plane = _compute_cap_plane_at_truncation(
|
|
1449
|
+
center + neg_extent * axis_dir, radius,
|
|
1450
|
+
path_xy, half_top, wall_slope, ground, depth
|
|
1451
|
+
)
|
|
1452
|
+
|
|
1453
|
+
if was_pos_truncated:
|
|
1454
|
+
pos_cap_plane = _compute_cap_plane_at_truncation(
|
|
1455
|
+
center + pos_extent * axis_dir, radius,
|
|
1456
|
+
path_xy, half_top, wall_slope, ground, depth
|
|
1457
|
+
)
|
|
1458
|
+
|
|
1459
|
+
was_truncated = was_neg_truncated or was_pos_truncated
|
|
1460
|
+
return TruncationResult(
|
|
1461
|
+
neg_extent=neg_extent,
|
|
1462
|
+
pos_extent=pos_extent,
|
|
1463
|
+
neg_cap_plane=neg_cap_plane,
|
|
1464
|
+
pos_cap_plane=pos_cap_plane,
|
|
1465
|
+
was_truncated=was_truncated,
|
|
1466
|
+
)
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
def _compute_cap_plane_at_truncation(
|
|
1470
|
+
point: np.ndarray,
|
|
1471
|
+
radius: float,
|
|
1472
|
+
path_xy: List[Tuple[float, float]],
|
|
1473
|
+
half_top: float,
|
|
1474
|
+
wall_slope: float,
|
|
1475
|
+
ground: GroundSpec,
|
|
1476
|
+
depth: float,
|
|
1477
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
1478
|
+
"""Compute the plane where the pipe intersects the trench boundary.
|
|
1479
|
+
|
|
1480
|
+
Determines which boundary (left wall, right wall, floor) the pipe exits
|
|
1481
|
+
through and returns (normal, point) for that plane.
|
|
1482
|
+
"""
|
|
1483
|
+
x, y, z = point
|
|
1484
|
+
frame, local_u = _find_trench_frame_at_xy(
|
|
1485
|
+
x, y, path_xy, half_top, depth, wall_slope, ground
|
|
1486
|
+
)
|
|
1487
|
+
|
|
1488
|
+
floor_z = frame.top_z - depth
|
|
1489
|
+
half_w = _half_width_at_depth(half_top, wall_slope, frame.top_z, z)
|
|
1490
|
+
|
|
1491
|
+
# Determine which boundary was hit
|
|
1492
|
+
dist_to_floor = z - radius - floor_z
|
|
1493
|
+
dist_to_ceiling = frame.top_z - (z + radius)
|
|
1494
|
+
dist_to_left_wall = half_w - (local_u + radius)
|
|
1495
|
+
dist_to_right_wall = half_w - (-local_u + radius)
|
|
1496
|
+
|
|
1497
|
+
# For sloped walls, the wall normal has a horizontal and vertical component
|
|
1498
|
+
# Wall slope means the wall goes inward as we go down
|
|
1499
|
+
# Wall normal = (left_normal_2d_x, left_normal_2d_y, slope) normalized
|
|
1500
|
+
if wall_slope > 0:
|
|
1501
|
+
wall_normal_3d = np.array([
|
|
1502
|
+
frame.left_normal[0],
|
|
1503
|
+
frame.left_normal[1],
|
|
1504
|
+
wall_slope
|
|
1505
|
+
], float)
|
|
1506
|
+
wall_normal_3d = wall_normal_3d / np.linalg.norm(wall_normal_3d)
|
|
1507
|
+
else:
|
|
1508
|
+
wall_normal_3d = np.array([frame.left_normal[0], frame.left_normal[1], 0.0], float)
|
|
1509
|
+
|
|
1510
|
+
# Find which boundary is closest (most violated)
|
|
1511
|
+
min_dist = min(dist_to_floor, dist_to_ceiling, dist_to_left_wall, dist_to_right_wall)
|
|
1512
|
+
|
|
1513
|
+
if min_dist == dist_to_floor or dist_to_floor < 0:
|
|
1514
|
+
# Hit floor - horizontal plane at floor level
|
|
1515
|
+
plane_normal = np.array([0.0, 0.0, 1.0], float)
|
|
1516
|
+
plane_point = np.array([x, y, floor_z], float)
|
|
1517
|
+
elif min_dist == dist_to_ceiling or dist_to_ceiling < 0:
|
|
1518
|
+
# Hit ceiling (ground) - horizontal plane at ground level
|
|
1519
|
+
plane_normal = np.array([0.0, 0.0, -1.0], float)
|
|
1520
|
+
plane_point = np.array([x, y, frame.top_z], float)
|
|
1521
|
+
elif min_dist == dist_to_left_wall or dist_to_left_wall < 0:
|
|
1522
|
+
# Hit left wall
|
|
1523
|
+
plane_normal = -wall_normal_3d # Points inward (into trench)
|
|
1524
|
+
wall_x = frame.centerline_xy[0] + half_w * frame.left_normal[0]
|
|
1525
|
+
wall_y = frame.centerline_xy[1] + half_w * frame.left_normal[1]
|
|
1526
|
+
plane_point = np.array([wall_x, wall_y, z], float)
|
|
1527
|
+
else:
|
|
1528
|
+
# Hit right wall
|
|
1529
|
+
plane_normal = wall_normal_3d # Flip for right wall
|
|
1530
|
+
plane_normal[0] = -plane_normal[0]
|
|
1531
|
+
plane_normal[1] = -plane_normal[1]
|
|
1532
|
+
wall_x = frame.centerline_xy[0] - half_w * frame.left_normal[0]
|
|
1533
|
+
wall_y = frame.centerline_xy[1] - half_w * frame.left_normal[1]
|
|
1534
|
+
plane_point = np.array([wall_x, wall_y, z], float)
|
|
1535
|
+
|
|
1536
|
+
return plane_normal, plane_point
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
def _compute_box_fit(
|
|
1540
|
+
center: np.ndarray,
|
|
1541
|
+
along: float,
|
|
1542
|
+
across: float,
|
|
1543
|
+
height: float,
|
|
1544
|
+
path_xy: List[Tuple[float, float]],
|
|
1545
|
+
half_top: float,
|
|
1546
|
+
wall_slope: float,
|
|
1547
|
+
ground: GroundSpec,
|
|
1548
|
+
depth: float,
|
|
1549
|
+
clearance: float = 0.02,
|
|
1550
|
+
) -> Tuple[float, float, float]:
|
|
1551
|
+
"""Compute shrunk box dimensions to fit within trench.
|
|
1552
|
+
|
|
1553
|
+
Returns (along, across, height) that fit within the trench at the given
|
|
1554
|
+
center position.
|
|
1555
|
+
"""
|
|
1556
|
+
x, y, z = center
|
|
1557
|
+
frame, local_u = _find_trench_frame_at_xy(
|
|
1558
|
+
x, y, path_xy, half_top, depth, wall_slope, ground
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
floor_z = frame.top_z - depth
|
|
1562
|
+
|
|
1563
|
+
# Max height: from floor to ground, minus clearance
|
|
1564
|
+
max_height = (frame.top_z - floor_z) - 2 * clearance
|
|
1565
|
+
fit_height = min(height, max_height)
|
|
1566
|
+
|
|
1567
|
+
# Recompute z to center the box if height was shrunk
|
|
1568
|
+
if fit_height < height:
|
|
1569
|
+
# Center box vertically in trench
|
|
1570
|
+
z_new = floor_z + clearance + fit_height / 2
|
|
1571
|
+
else:
|
|
1572
|
+
z_new = z
|
|
1573
|
+
|
|
1574
|
+
# At the box center depth, what's the half-width?
|
|
1575
|
+
half_w = _half_width_at_depth(half_top, wall_slope, frame.top_z, z_new)
|
|
1576
|
+
|
|
1577
|
+
# Max across: must fit within walls accounting for offset from centerline
|
|
1578
|
+
# The box extends ±across/2 from center, so:
|
|
1579
|
+
# local_u + across/2 <= half_w - clearance
|
|
1580
|
+
# local_u - across/2 >= -(half_w - clearance)
|
|
1581
|
+
# => across/2 <= min(half_w - clearance - local_u, half_w - clearance + local_u)
|
|
1582
|
+
max_across = 2 * (half_w - clearance - abs(local_u))
|
|
1583
|
+
fit_across = max(0.01, min(across, max_across))
|
|
1584
|
+
|
|
1585
|
+
# Along dimension: no shrinking needed for typical cases
|
|
1586
|
+
# (pipes along trench axis can be arbitrarily long within path bounds)
|
|
1587
|
+
fit_along = along
|
|
1588
|
+
|
|
1589
|
+
return fit_along, fit_across, fit_height
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
def _compute_sphere_fit(
|
|
1593
|
+
center: np.ndarray,
|
|
1594
|
+
radius: float,
|
|
1595
|
+
path_xy: List[Tuple[float, float]],
|
|
1596
|
+
half_top: float,
|
|
1597
|
+
wall_slope: float,
|
|
1598
|
+
ground: GroundSpec,
|
|
1599
|
+
depth: float,
|
|
1600
|
+
clearance: float = 0.02,
|
|
1601
|
+
) -> float:
|
|
1602
|
+
"""Compute shrunk sphere radius to fit within trench.
|
|
1603
|
+
|
|
1604
|
+
Returns the maximum radius that fits within the trench at the given center.
|
|
1605
|
+
"""
|
|
1606
|
+
x, y, z = center
|
|
1607
|
+
frame, local_u = _find_trench_frame_at_xy(
|
|
1608
|
+
x, y, path_xy, half_top, depth, wall_slope, ground
|
|
1609
|
+
)
|
|
1610
|
+
|
|
1611
|
+
floor_z = frame.top_z - depth
|
|
1612
|
+
|
|
1613
|
+
# Distance to floor
|
|
1614
|
+
dist_floor = z - floor_z - clearance
|
|
1615
|
+
|
|
1616
|
+
# Distance to ground
|
|
1617
|
+
dist_ground = frame.top_z - z - clearance
|
|
1618
|
+
|
|
1619
|
+
# Distance to nearest wall (accounting for offset)
|
|
1620
|
+
half_w = _half_width_at_depth(half_top, wall_slope, frame.top_z, z)
|
|
1621
|
+
dist_wall = half_w - abs(local_u) - clearance
|
|
1622
|
+
|
|
1623
|
+
# Maximum radius is minimum of all distances
|
|
1624
|
+
max_radius = max(0.01, min(dist_floor, dist_ground, dist_wall))
|
|
1625
|
+
|
|
1626
|
+
return min(radius, max_radius)
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def _clip_vertices_to_trench(
|
|
1630
|
+
V: np.ndarray,
|
|
1631
|
+
path_xy: List[Tuple[float, float]],
|
|
1632
|
+
half_top: float,
|
|
1633
|
+
wall_slope: float,
|
|
1634
|
+
ground: GroundSpec,
|
|
1635
|
+
depth: float,
|
|
1636
|
+
) -> np.ndarray:
|
|
1637
|
+
"""Clip vertices to stay inside the trench boundary.
|
|
1638
|
+
|
|
1639
|
+
Any vertex outside the trench is projected back to the nearest boundary.
|
|
1640
|
+
This handles edge cases where cap projection pushes vertices outside.
|
|
1641
|
+
"""
|
|
1642
|
+
V_clipped = V.copy()
|
|
1643
|
+
|
|
1644
|
+
for i, vert in enumerate(V):
|
|
1645
|
+
x, y, z = vert
|
|
1646
|
+
frame, local_u = _find_trench_frame_at_xy(
|
|
1647
|
+
x, y, path_xy, half_top, depth, wall_slope, ground
|
|
1648
|
+
)
|
|
1649
|
+
|
|
1650
|
+
floor_z = frame.top_z - depth
|
|
1651
|
+
half_w = _half_width_at_depth(half_top, wall_slope, frame.top_z, z)
|
|
1652
|
+
|
|
1653
|
+
# Clip z to floor/ceiling
|
|
1654
|
+
z_clipped = np.clip(z, floor_z, frame.top_z)
|
|
1655
|
+
|
|
1656
|
+
# Clip u to walls (need to update xy)
|
|
1657
|
+
if abs(local_u) > half_w:
|
|
1658
|
+
# Project point back to wall
|
|
1659
|
+
u_clipped = np.sign(local_u) * half_w
|
|
1660
|
+
# Adjust xy: move toward centerline
|
|
1661
|
+
delta_u = u_clipped - local_u
|
|
1662
|
+
x_clipped = x + delta_u * frame.left_normal[0]
|
|
1663
|
+
y_clipped = y + delta_u * frame.left_normal[1]
|
|
1664
|
+
else:
|
|
1665
|
+
x_clipped = x
|
|
1666
|
+
y_clipped = y
|
|
1667
|
+
|
|
1668
|
+
V_clipped[i] = [x_clipped, y_clipped, z_clipped]
|
|
1669
|
+
|
|
1670
|
+
return V_clipped
|
|
1671
|
+
|
|
1672
|
+
|
|
1021
1673
|
# ---------------- Noise ----------------
|
|
1022
1674
|
|
|
1023
1675
|
def vertex_normals(V: np.ndarray, F: np.ndarray) -> np.ndarray:
|
|
@@ -1125,16 +1777,40 @@ def _build_surface_groups(
|
|
|
1125
1777
|
top_z = gfun(pos_xy[0], pos_xy[1])
|
|
1126
1778
|
req_u = float(p.offset_u)
|
|
1127
1779
|
req_z = float(p.z if p.z is not None else (top_z - spec.depth * 0.5))
|
|
1128
|
-
|
|
1129
|
-
|
|
1780
|
+
|
|
1781
|
+
# Cap margin accounts for cap projection when pipe is at angle to wall
|
|
1782
|
+
cap_margin = max(0.02, 0.4 * p.radius)
|
|
1783
|
+
effective_radius = p.radius + cap_margin
|
|
1784
|
+
|
|
1785
|
+
z_min = top_z - spec.depth + (effective_radius + clearance)
|
|
1786
|
+
z_max = top_z - (effective_radius + clearance)
|
|
1130
1787
|
zc = float(np.clip(req_z, z_min, z_max))
|
|
1131
1788
|
half_w = _half_width_at_depth(half_top, spec.wall_slope, top_z, zc)
|
|
1132
|
-
umax = max(0.0, half_w - (
|
|
1789
|
+
umax = max(0.0, half_w - (effective_radius + clearance))
|
|
1133
1790
|
u = float(np.clip(req_u, -umax, umax))
|
|
1134
1791
|
ctr_xy = pos_xy + u * left_normal
|
|
1135
1792
|
center = np.array([ctr_xy[0], ctr_xy[1], zc], float)
|
|
1136
|
-
|
|
1793
|
+
|
|
1794
|
+
# Compute pipe truncation at trench boundaries
|
|
1795
|
+
trunc = _compute_pipe_truncation(
|
|
1796
|
+
center, axis_dir, p.radius, p.length / 2.0,
|
|
1797
|
+
spec.path_xy, half_top, spec.wall_slope, spec.ground, spec.depth
|
|
1798
|
+
)
|
|
1799
|
+
|
|
1800
|
+
cyl = make_cylinder(
|
|
1801
|
+
center, axis_dir, p.radius, p.length,
|
|
1802
|
+
p.n_theta, p.n_along, with_caps=True,
|
|
1803
|
+
neg_extent=trunc.neg_extent,
|
|
1804
|
+
pos_extent=trunc.pos_extent,
|
|
1805
|
+
cap_plane_neg=trunc.neg_cap_plane,
|
|
1806
|
+
cap_plane_pos=trunc.pos_cap_plane,
|
|
1807
|
+
)
|
|
1137
1808
|
for key, (V, F) in cyl.items():
|
|
1809
|
+
# Clip cap vertices to trench boundary to handle projection overshoot
|
|
1810
|
+
if "cap" in key:
|
|
1811
|
+
V = _clip_vertices_to_trench(
|
|
1812
|
+
V, spec.path_xy, half_top, spec.wall_slope, spec.ground, spec.depth
|
|
1813
|
+
)
|
|
1138
1814
|
groups[f"pipe{idx}_{key}"] = (V, F)
|
|
1139
1815
|
|
|
1140
1816
|
for j, b in enumerate(spec.boxes):
|
|
@@ -1151,6 +1827,19 @@ def _build_surface_groups(
|
|
|
1151
1827
|
u = float(np.clip(req_u, -umax, umax))
|
|
1152
1828
|
ctr_xy = pos_xy + u * left_normal
|
|
1153
1829
|
center = np.array([ctr_xy[0], ctr_xy[1], zc], float)
|
|
1830
|
+
|
|
1831
|
+
# Compute shrunk box dimensions to fit within trench
|
|
1832
|
+
fit_along, fit_across, fit_height = _compute_box_fit(
|
|
1833
|
+
center, b.along, b.across, b.height,
|
|
1834
|
+
spec.path_xy, half_top, spec.wall_slope, spec.ground, spec.depth, clearance
|
|
1835
|
+
)
|
|
1836
|
+
|
|
1837
|
+
# Re-center if height was shrunk
|
|
1838
|
+
if fit_height < b.height:
|
|
1839
|
+
floor_z = top_z - spec.depth
|
|
1840
|
+
zc = floor_z + clearance + fit_height / 2
|
|
1841
|
+
center = np.array([ctr_xy[0], ctr_xy[1], zc], float)
|
|
1842
|
+
|
|
1154
1843
|
frame_cols = np.column_stack(
|
|
1155
1844
|
[
|
|
1156
1845
|
np.array([tangent[0], tangent[1], 0.0]),
|
|
@@ -1158,7 +1847,7 @@ def _build_surface_groups(
|
|
|
1158
1847
|
np.array([0.0, 0.0, 1.0]),
|
|
1159
1848
|
]
|
|
1160
1849
|
)
|
|
1161
|
-
Vb, Fb = make_box(center, frame_cols, (
|
|
1850
|
+
Vb, Fb = make_box(center, frame_cols, (fit_along, fit_across, fit_height))
|
|
1162
1851
|
groups[f"box{j}"] = (Vb, Fb)
|
|
1163
1852
|
|
|
1164
1853
|
for k, s in enumerate(spec.spheres):
|
|
@@ -1175,7 +1864,22 @@ def _build_surface_groups(
|
|
|
1175
1864
|
u = float(np.clip(req_u, -umax, umax))
|
|
1176
1865
|
ctr_xy = pos_xy + u * left_normal
|
|
1177
1866
|
center = np.array([ctr_xy[0], ctr_xy[1], zc], float)
|
|
1178
|
-
|
|
1867
|
+
|
|
1868
|
+
# Compute shrunk sphere radius to fit within trench
|
|
1869
|
+
fit_radius = _compute_sphere_fit(
|
|
1870
|
+
center, s.radius,
|
|
1871
|
+
spec.path_xy, half_top, spec.wall_slope, spec.ground, spec.depth, clearance
|
|
1872
|
+
)
|
|
1873
|
+
|
|
1874
|
+
# Re-center if radius was shrunk significantly
|
|
1875
|
+
if fit_radius < s.radius:
|
|
1876
|
+
floor_z = top_z - spec.depth
|
|
1877
|
+
# Center the smaller sphere optimally
|
|
1878
|
+
zc = floor_z + clearance + fit_radius
|
|
1879
|
+
zc = min(zc, top_z - clearance - fit_radius) # Also respect ceiling
|
|
1880
|
+
center = np.array([ctr_xy[0], ctr_xy[1], zc], float)
|
|
1881
|
+
|
|
1882
|
+
Vs, Fs = make_sphere(center, fit_radius, n_theta=64, n_phi=32)
|
|
1179
1883
|
groups[f"sphere{k}"] = (Vs, Fs)
|
|
1180
1884
|
|
|
1181
1885
|
if spec.noise and spec.noise.enable:
|