trenchfoot 0.4.1__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.
@@ -18,7 +18,7 @@ Outputs:
18
18
  from __future__ import annotations
19
19
 
20
20
  import io
21
- import os, json, math, argparse, re
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
@@ -614,41 +614,202 @@ def _frame_from_axis(axis_dir: np.ndarray) -> np.ndarray:
614
614
  u=_normalize(np.cross(helper,v)); w=np.cross(v,u)
615
615
  return np.column_stack([u,v,w])
616
616
 
617
- def make_cylinder(center: np.ndarray, axis_dir: np.ndarray, radius: float, length: float,
618
- n_theta: int=64, n_along: int=32, with_caps: bool=True):
619
- n_theta=max(8,int(n_theta)); n_along=max(1,int(n_along))
620
- thetas=np.linspace(0,2*np.pi,n_theta+1); ys=np.linspace(-length/2.0,length/2.0,n_along+1)
621
- Vloc=[]
622
- for j in range(n_along+1):
623
- y=ys[j]
624
- for i in range(n_theta+1):
625
- th=thetas[i]; x=radius*np.cos(th); z=radius*np.sin(th)
626
- Vloc.append([x,y,z])
627
- Vloc=np.array(Vloc,float)
628
- def idx(i,j): return j*(n_theta+1)+i
629
- F=[]
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 = []
630
693
  for j in range(n_along):
631
694
  for i in range(n_theta):
632
- v00=idx(i,j); v10=idx(i+1,j); v01=idx(i,j+1); v11=idx(i+1,j+1)
633
- F.append([v00,v01,v11]); F.append([v00,v11,v10])
634
- F=np.array(F,int)
635
- caps={}
636
- if with_caps:
637
- ring=np.array([[radius*np.cos(t),-length/2.0,radius*np.sin(t)] for t in thetas[:-1]],float)
638
- Vn=np.vstack([np.array([[0.0,-length/2.0,0.0]],float), ring])
639
- Fn=np.array([[0,1+(i+1)%len(ring),1+i] for i in range(len(ring))],int)
640
- ring=np.array([[radius*np.cos(t),+length/2.0,radius*np.sin(t)] for t in thetas[:-1]],float)
641
- Vp=np.vstack([np.array([[0.0,+length/2.0,0.0]],float), ring])
642
- Fp=np.array([[0,1+i,1+(i+1)%len(ring)] for i in range(len(ring))],int)
643
- caps['pipe_cap_neg']=(Vn,Fn); caps['pipe_cap_pos']=(Vp,Fp)
644
- M=_frame_from_axis(axis_dir)
645
- def xform(V): return (center + V @ M.T).astype(float)
646
- 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
+
647
705
  if with_caps:
648
- Vn,Fn=caps['pipe_cap_neg']; Vp,Fp=caps['pipe_cap_pos']
649
- out['pipe_cap_neg']=(xform(Vn),Fn); out['pipe_cap_pos']=(xform(Vp),Fp)
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
+
650
718
  return out
651
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
+
652
813
  def make_box(center: np.ndarray, frame_cols: np.ndarray, dims: Tuple[float,float,float]):
653
814
  a,b,h=dims; u=frame_cols[:,0]; v=frame_cols[:,1]; w=frame_cols[:,2]
654
815
  corners=[]
@@ -1082,6 +1243,433 @@ def make_ground_surface_plane(path_xy: List[Tuple[float,float]], width_top: floa
1082
1243
  def _half_width_at_depth(half_top: float, slope: float, top_z: float, z: float) -> float:
1083
1244
  return max(1e-6, half_top - slope * (top_z - z))
1084
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
+
1085
1673
  # ---------------- Noise ----------------
1086
1674
 
1087
1675
  def vertex_normals(V: np.ndarray, F: np.ndarray) -> np.ndarray:
@@ -1189,16 +1777,40 @@ def _build_surface_groups(
1189
1777
  top_z = gfun(pos_xy[0], pos_xy[1])
1190
1778
  req_u = float(p.offset_u)
1191
1779
  req_z = float(p.z if p.z is not None else (top_z - spec.depth * 0.5))
1192
- z_min = top_z - spec.depth + (p.radius + clearance)
1193
- z_max = top_z - (p.radius + clearance)
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)
1194
1787
  zc = float(np.clip(req_z, z_min, z_max))
1195
1788
  half_w = _half_width_at_depth(half_top, spec.wall_slope, top_z, zc)
1196
- umax = max(0.0, half_w - (p.radius + clearance))
1789
+ umax = max(0.0, half_w - (effective_radius + clearance))
1197
1790
  u = float(np.clip(req_u, -umax, umax))
1198
1791
  ctr_xy = pos_xy + u * left_normal
1199
1792
  center = np.array([ctr_xy[0], ctr_xy[1], zc], float)
1200
- cyl = make_cylinder(center, axis_dir, p.radius, p.length, p.n_theta, p.n_along, with_caps=True)
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
+ )
1201
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
+ )
1202
1814
  groups[f"pipe{idx}_{key}"] = (V, F)
1203
1815
 
1204
1816
  for j, b in enumerate(spec.boxes):
@@ -1215,6 +1827,19 @@ def _build_surface_groups(
1215
1827
  u = float(np.clip(req_u, -umax, umax))
1216
1828
  ctr_xy = pos_xy + u * left_normal
1217
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
+
1218
1843
  frame_cols = np.column_stack(
1219
1844
  [
1220
1845
  np.array([tangent[0], tangent[1], 0.0]),
@@ -1222,7 +1847,7 @@ def _build_surface_groups(
1222
1847
  np.array([0.0, 0.0, 1.0]),
1223
1848
  ]
1224
1849
  )
1225
- Vb, Fb = make_box(center, frame_cols, (b.along, b.across, b.height))
1850
+ Vb, Fb = make_box(center, frame_cols, (fit_along, fit_across, fit_height))
1226
1851
  groups[f"box{j}"] = (Vb, Fb)
1227
1852
 
1228
1853
  for k, s in enumerate(spec.spheres):
@@ -1239,7 +1864,22 @@ def _build_surface_groups(
1239
1864
  u = float(np.clip(req_u, -umax, umax))
1240
1865
  ctr_xy = pos_xy + u * left_normal
1241
1866
  center = np.array([ctr_xy[0], ctr_xy[1], zc], float)
1242
- Vs, Fs = make_sphere(center, s.radius, n_theta=64, n_phi=32)
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)
1243
1883
  groups[f"sphere{k}"] = (Vs, Fs)
1244
1884
 
1245
1885
  if spec.noise and spec.noise.enable:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trenchfoot
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Synthetic trench scenario generator bundle (surfaces + volumetrics).
5
5
  Author: Liam Moore
6
6
  License-File: LICENSE
@@ -6,7 +6,7 @@ trenchfoot/gmsh_sloped_trench_mesher.py,sha256=D7EL6V0wkE6tvDARmm006yZE6KEzCl25O
6
6
  trenchfoot/plot_mesh.py,sha256=26dOlVfaM1WsUfr_sXVqA7axtY9qjY3WCNM7cUBTS7Q,3810
7
7
  trenchfoot/render_colors.py,sha256=CWMre6DYa2EyrCgZsEY0bv313WqEhQdr3CdPF1xI-40,1649
8
8
  trenchfoot/scene_spec_example.json,sha256=UcV25ku422UO0ZZPDrJwrT1zwmjoOIpnBdLuEdh-AZA,1028
9
- trenchfoot/trench_scene_generator_v3.py,sha256=Ww0xlo127m2KumvQIpH2_9aYNLurnxARn3TGJ3qFOJ8,53385
9
+ trenchfoot/trench_scene_generator_v3.py,sha256=ZWashBnNSWyv6Z_hh_zg8nngO4ySIuijgHtlCnqs0rY,75147
10
10
  trenchfoot/scenarios/SUMMARY.json,sha256=uylEzgzIqk5pGBfWVchVFnwwIDGBjNTDY_E23L_iakI,9372
11
11
  trenchfoot/scenarios/S01_straight_vwalls/ground_truth_isosurface.html,sha256=SLPROqEefBB8OgmZSW6DBmJxuSDxVQhWTn9eG3q6QRU,5880178
12
12
  trenchfoot/scenarios/S01_straight_vwalls/metrics.json,sha256=7VDscjZdxNPgNZaPHzRHYBJ1a5amNgJ7XYKCezVJJKQ,691
@@ -45,7 +45,7 @@ trenchfoot/scenarios/S03_L_slope_two_pipes_box/sdf_metadata.json,sha256=6ESVHVGu
45
45
  trenchfoot/scenarios/S03_L_slope_two_pipes_box/trench_scene.obj,sha256=APHNgcc715-lk360CAOpr6zPVcsEDrY4eHEV6z6dU38,655978
46
46
  trenchfoot/scenarios/S03_L_slope_two_pipes_box/meshes/trench_scene_culled.obj,sha256=At4lipowr_3MY1rxmcKa479F_mQ-jJDVQJe0yHTrFSc,1835
47
47
  trenchfoot/scenarios/S03_L_slope_two_pipes_box/point_clouds/culled/resolution0p050.pth,sha256=KqbuoZ1tUPDhlnDgmhd-7WBb92TABsEmOzEsB__KtlA,2456029
48
- trenchfoot/scenarios/S03_L_slope_two_pipes_box/point_clouds/full/resolution0p050.pth,sha256=4fXvfu0TMO9FJaoqYrW21SCGe3eKuSLkSUl9CdE14rw,2622685
48
+ trenchfoot/scenarios/S03_L_slope_two_pipes_box/point_clouds/full/resolutio,sha256=4fXvfu0TMO9FJaoqYrW21SCGe3eKuSLkSUl9CdE14rw,2622685
49
49
  trenchfoot/scenarios/S03_L_slope_two_pipes_box/volumetric/trench_volume.msh,sha256=cPgHKHlzPPtWX8H1yaxAYsEgYba3fr2JjfPvgg2a000,87383
50
50
  trenchfoot/scenarios/S04_U_slope_multi_noise/ground_truth_isosurface.html,sha256=E7sa8dlDyICJBlnL9X2C8IEopEJzDMhHdLbW-WpoPFc,7184880
51
51
  trenchfoot/scenarios/S04_U_slope_multi_noise/metrics.json,sha256=3dm1ciLvFDj66ouuzhPo6-W0fPg0NRdbh2NPpg8tN-0,1293
@@ -85,8 +85,8 @@ trenchfoot/scenarios/S07_circular_well/scene.json,sha256=bvror2YX6aNbsEc25-N7JO3
85
85
  trenchfoot/scenarios/S07_circular_well/sdf_metadata.json,sha256=5_D_rA_CqWgg2uSgVlqWCO4zQg0Gg5NWUnXHtCERBUA,4776
86
86
  trenchfoot/scenarios/S07_circular_well/trench_scene.obj,sha256=leTTT0i5xE-fvFSzHLNf_JBsU0AN3YqadDx4HmNmFhU,1618101
87
87
  trenchfoot/scenarios/S07_circular_well/volumetric/trench_volume.msh,sha256=dqhtd3SFKj5RLT_BcWIIvVGCbAqvOx7RX25-K7NKX10,615212
88
- trenchfoot-0.4.1.dist-info/METADATA,sha256=WxSIrTX1_4PE6LJnxVT3AV4ds39NiuzD4rZYhZ2pdX0,5292
89
- trenchfoot-0.4.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
90
- trenchfoot-0.4.1.dist-info/entry_points.txt,sha256=5TejAGmc4GnNYLn7MhhLtSCNz9240RvzcNaetF4IHfg,119
91
- trenchfoot-0.4.1.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
92
- trenchfoot-0.4.1.dist-info/RECORD,,
88
+ trenchfoot-0.4.2.dist-info/METADATA,sha256=y_KwrW-9rsUL7Csf7x-32FDt69-gtgZ_Yz5nOj-Vths,5292
89
+ trenchfoot-0.4.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
90
+ trenchfoot-0.4.2.dist-info/entry_points.txt,sha256=5TejAGmc4GnNYLn7MhhLtSCNz9240RvzcNaetF4IHfg,119
91
+ trenchfoot-0.4.2.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
92
+ trenchfoot-0.4.2.dist-info/RECORD,,