trenchfoot 0.2.7__tar.gz → 0.3.0__tar.gz

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 (89) hide show
  1. trenchfoot-0.3.0/.pypi_token.env +1 -0
  2. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/CHANGELOG.md +19 -0
  3. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/PKG-INFO +1 -1
  4. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/trench_scene_generator_v3.py +125 -4
  5. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/pyproject.toml +1 -1
  6. trenchfoot-0.3.0/tests/test_sdf_readiness.py +385 -0
  7. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/uv.lock +1 -1
  8. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/.env +0 -0
  9. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/.github/workflows/ci.yml +0 -0
  10. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/.github_token.env +0 -0
  11. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/CLAUDE.md +0 -0
  12. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/LICENSE +0 -0
  13. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/README.md +0 -0
  14. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/STATUS.md +0 -0
  15. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/docs/scenario_gallery.md +0 -0
  16. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/__init__.py +0 -0
  17. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/Dockerfile +0 -0
  18. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/README.md +0 -0
  19. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/__init__.py +0 -0
  20. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/generate_scenarios.py +0 -0
  21. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/gmsh_sloped_trench_mesher.py +0 -0
  22. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/plot_mesh.py +0 -0
  23. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/render_colors.py +0 -0
  24. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S01_straight_vwalls/meshes/trench_scene_culled.obj +0 -0
  25. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S01_straight_vwalls/metrics.json +0 -0
  26. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S01_straight_vwalls/point_clouds/culled/resolution0p050.pth +0 -0
  27. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S01_straight_vwalls/point_clouds/full/resolution0p050.pth +0 -0
  28. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S01_straight_vwalls/preview.png +0 -0
  29. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S01_straight_vwalls/preview_oblique.png +0 -0
  30. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S01_straight_vwalls/preview_side.png +0 -0
  31. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S01_straight_vwalls/preview_top.png +0 -0
  32. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S01_straight_vwalls/scene.json +0 -0
  33. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S01_straight_vwalls/trench_scene.obj +0 -0
  34. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S01_straight_vwalls/volumetric/trench_volume.msh +0 -0
  35. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S02_straight_slope_pipe/meshes/trench_scene_culled.obj +0 -0
  36. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S02_straight_slope_pipe/metrics.json +0 -0
  37. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S02_straight_slope_pipe/point_clouds/culled/resolution0p050.pth +0 -0
  38. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S02_straight_slope_pipe/point_clouds/full/resolution0p050.pth +0 -0
  39. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S02_straight_slope_pipe/preview.png +0 -0
  40. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S02_straight_slope_pipe/preview_oblique.png +0 -0
  41. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S02_straight_slope_pipe/preview_side.png +0 -0
  42. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S02_straight_slope_pipe/preview_top.png +0 -0
  43. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S02_straight_slope_pipe/scene.json +0 -0
  44. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S02_straight_slope_pipe/trench_scene.obj +0 -0
  45. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S02_straight_slope_pipe/volumetric/trench_volume.msh +0 -0
  46. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S03_L_slope_two_pipes_box/meshes/trench_scene_culled.obj +0 -0
  47. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S03_L_slope_two_pipes_box/metrics.json +0 -0
  48. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S03_L_slope_two_pipes_box/point_clouds/culled/resolution0p050.pth +0 -0
  49. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S03_L_slope_two_pipes_box/point_clouds/full/resolution0p050.pth +0 -0
  50. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S03_L_slope_two_pipes_box/preview.png +0 -0
  51. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S03_L_slope_two_pipes_box/preview_oblique.png +0 -0
  52. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S03_L_slope_two_pipes_box/preview_side.png +0 -0
  53. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S03_L_slope_two_pipes_box/preview_top.png +0 -0
  54. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S03_L_slope_two_pipes_box/scene.json +0 -0
  55. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S03_L_slope_two_pipes_box/trench_scene.obj +0 -0
  56. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S03_L_slope_two_pipes_box/volumetric/trench_volume.msh +0 -0
  57. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S04_U_slope_multi_noise/meshes/trench_scene_culled.obj +0 -0
  58. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S04_U_slope_multi_noise/metrics.json +0 -0
  59. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S04_U_slope_multi_noise/preview.png +0 -0
  60. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S04_U_slope_multi_noise/preview_oblique.png +0 -0
  61. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S04_U_slope_multi_noise/preview_side.png +0 -0
  62. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S04_U_slope_multi_noise/preview_top.png +0 -0
  63. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S04_U_slope_multi_noise/scene.json +0 -0
  64. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S04_U_slope_multi_noise/trench_scene.obj +0 -0
  65. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S04_U_slope_multi_noise/volumetric/trench_volume.msh +0 -0
  66. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S05_wide_slope_pair/metrics.json +0 -0
  67. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S05_wide_slope_pair/preview_oblique.png +0 -0
  68. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S05_wide_slope_pair/preview_side.png +0 -0
  69. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S05_wide_slope_pair/preview_top.png +0 -0
  70. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S05_wide_slope_pair/scene.json +0 -0
  71. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S05_wide_slope_pair/trench_scene.obj +0 -0
  72. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S05_wide_slope_pair/volumetric/trench_volume.msh +0 -0
  73. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S06_bumpy_wide_loop/metrics.json +0 -0
  74. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S06_bumpy_wide_loop/preview_oblique.png +0 -0
  75. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S06_bumpy_wide_loop/preview_side.png +0 -0
  76. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S06_bumpy_wide_loop/preview_top.png +0 -0
  77. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S06_bumpy_wide_loop/scene.json +0 -0
  78. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S06_bumpy_wide_loop/trench_scene.obj +0 -0
  79. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S06_bumpy_wide_loop/volumetric/trench_volume.msh +0 -0
  80. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S07_circular_well/metrics.json +0 -0
  81. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S07_circular_well/preview_oblique.png +0 -0
  82. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S07_circular_well/preview_side.png +0 -0
  83. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S07_circular_well/preview_top.png +0 -0
  84. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S07_circular_well/scene.json +0 -0
  85. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S07_circular_well/trench_scene.obj +0 -0
  86. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/S07_circular_well/volumetric/trench_volume.msh +0 -0
  87. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scenarios/SUMMARY.json +0 -0
  88. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/packages/trenchfoot/scene_spec_example.json +0 -0
  89. {trenchfoot-0.2.7 → trenchfoot-0.3.0}/tests/test_trenchfoot_generation.py +0 -0
@@ -0,0 +1 @@
1
+ PYPI_API_TOKEN="pypi-AgEIcHlwaS5vcmcCJDU5NWZhMThiLTcwZDAtNGQ1NS04NzY5LTUzOWFjNzUyMzBhOQACKlszLCJiYTQ1Nzc2Ni01MDg0LTQ4ZmYtOGU5Yi05MjY4NjM3MTNiY2EiXQAABiCDzIFj_CDx2Hr1RoMANFCByB8X_u1VLbmPLNpe-oOFow"
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] - 2026-01-07
4
+
5
+ ### Added
6
+ - **SDF metadata export**: Each scenario now exports `sdf_metadata.json` alongside the OBJ mesh, containing:
7
+ - Normal convention ("into_void")
8
+ - Geometry type ("open_trench")
9
+ - Trench opening polygon with z-level
10
+ - Surface group annotations (floor, walls, ground)
11
+ - Embedded object counts
12
+ - **SDF readiness tests**: 21 new tests in `tests/test_sdf_readiness.py` validating:
13
+ - Floor normals point up (+z)
14
+ - Wall normals point inward toward trench centerline
15
+ - Ground surface normals point up (+z)
16
+ - Metadata consistency with mesh geometry
17
+
18
+ ### Fixed
19
+ - **Normal orientation consistency**: All floor and ground surfaces now have upward-pointing normals (+z). Previously, some faces (particularly on S07 circular well ground surface) had incorrect winding order causing downward normals, which broke SDF sign computation in downstream consumers.
20
+ - **`_ensure_upward_normals()` post-processing**: Added helper function that flips face winding order for any horizontal faces with downward normals, applied to all ground surface generation paths.
21
+
3
22
  ## [0.2.7] - 2025-12-24
4
23
 
5
24
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trenchfoot
3
- Version: 0.2.7
3
+ Version: 0.3.0
4
4
  Summary: Synthetic trench scenario generator bundle (surfaces + volumetrics).
5
5
  Author: Liam Moore
6
6
  License-File: LICENSE
@@ -425,6 +425,7 @@ class SurfaceMeshFiles:
425
425
  obj_path: Path
426
426
  metrics_path: Path
427
427
  preview_paths: Tuple[Path, ...]
428
+ sdf_metadata_path: Optional[Path] = None
428
429
 
429
430
 
430
431
  @dataclass
@@ -435,7 +436,73 @@ class SurfaceMeshResult:
435
436
  metrics: Dict[str, Any]
436
437
  previews: Dict[str, bytes]
437
438
 
438
- def persist(self, out_dir: str | Path, *, include_previews: bool = False) -> SurfaceMeshFiles:
439
+ def _build_sdf_metadata(self) -> Dict[str, Any]:
440
+ """Build SDF metadata for downstream consumers.
441
+
442
+ This metadata enables generic mesh-to-SDF pipelines to correctly
443
+ interpret the mesh geometry without trenchfoot-specific heuristics.
444
+ """
445
+ # Extract trench opening polygon from the trench cap geometry
446
+ trench_opening_vertices = None
447
+ if "trench_cap_for_volume" in self.groups:
448
+ V_cap, F_cap = self.groups["trench_cap_for_volume"]
449
+ if V_cap.size > 0:
450
+ # Get unique vertices at z ≈ ground level (the top cap boundary)
451
+ # These form the trench opening polygon
452
+ z_level = float(np.median(V_cap[:, 2]))
453
+ xy_coords = V_cap[:, :2]
454
+ # Use convex hull to get ordered boundary vertices
455
+ try:
456
+ from scipy.spatial import ConvexHull
457
+ hull = ConvexHull(xy_coords)
458
+ boundary_indices = hull.vertices
459
+ trench_opening_vertices = xy_coords[boundary_indices].tolist()
460
+ except ImportError:
461
+ # Fallback: just use unique xy coords (unordered)
462
+ trench_opening_vertices = xy_coords.tolist()
463
+
464
+ # Determine geometry type
465
+ is_closed = _is_path_closed(self.spec.path_xy)
466
+ geometry_type = "closed_well" if is_closed else "open_trench"
467
+
468
+ # Build surface group info
469
+ surface_groups = {}
470
+ for name in self.groups:
471
+ if name in _INTERNAL_GROUPS:
472
+ continue
473
+ if "bottom" in name:
474
+ surface_groups[name] = {"normal_direction": "up", "surface_type": "floor"}
475
+ elif "wall" in name:
476
+ surface_groups[name] = {"normal_direction": "inward", "surface_type": "wall"}
477
+ elif "ground" in name:
478
+ surface_groups[name] = {"normal_direction": "up", "surface_type": "ground"}
479
+ elif "pipe" in name:
480
+ surface_groups[name] = {"normal_direction": "outward", "surface_type": "embedded_object"}
481
+ elif "box" in name or "sphere" in name:
482
+ surface_groups[name] = {"normal_direction": "outward", "surface_type": "embedded_object"}
483
+ else:
484
+ surface_groups[name] = {"normal_direction": "unknown", "surface_type": "other"}
485
+
486
+ return {
487
+ "sdf_metadata": {
488
+ "version": "1.0",
489
+ "normal_convention": "into_void",
490
+ "geometry_type": geometry_type,
491
+ "trench_opening": {
492
+ "type": "polygon",
493
+ "vertices_xy": trench_opening_vertices,
494
+ "z_level": self.spec.ground.z0 if self.spec.ground else 0.0,
495
+ },
496
+ "surface_groups": surface_groups,
497
+ "embedded_objects": {
498
+ "pipes": self.object_counts.get("pipes", 0),
499
+ "boxes": self.object_counts.get("boxes", 0),
500
+ "spheres": self.object_counts.get("spheres", 0),
501
+ },
502
+ }
503
+ }
504
+
505
+ def persist(self, out_dir: str | Path, *, include_previews: bool = False, include_sdf_metadata: bool = True) -> SurfaceMeshFiles:
439
506
  out_path = Path(out_dir)
440
507
  out_path.mkdir(parents=True, exist_ok=True)
441
508
  obj_path = out_path / "trench_scene.obj"
@@ -445,13 +512,27 @@ class SurfaceMeshResult:
445
512
  metrics_path = out_path / "metrics.json"
446
513
  with metrics_path.open("w") as fh:
447
514
  json.dump(self.metrics, fh, indent=2)
515
+
516
+ # Export SDF metadata
517
+ sdf_metadata_path = None
518
+ if include_sdf_metadata:
519
+ sdf_metadata = self._build_sdf_metadata()
520
+ sdf_metadata_path = out_path / "sdf_metadata.json"
521
+ with sdf_metadata_path.open("w") as fh:
522
+ json.dump(sdf_metadata, fh, indent=2)
523
+
448
524
  preview_paths: List[Path] = []
449
525
  if include_previews and self.previews:
450
526
  for name, data in self.previews.items():
451
527
  target = out_path / f"preview_{name}.png"
452
528
  target.write_bytes(data)
453
529
  preview_paths.append(target)
454
- return SurfaceMeshFiles(obj_path=obj_path, metrics_path=metrics_path, preview_paths=tuple(preview_paths))
530
+ return SurfaceMeshFiles(
531
+ obj_path=obj_path,
532
+ metrics_path=metrics_path,
533
+ preview_paths=tuple(preview_paths),
534
+ sdf_metadata_path=sdf_metadata_path,
535
+ )
455
536
 
456
537
  def _ground_fn(g: GroundSpec):
457
538
  sx, sy = g.slope
@@ -620,7 +701,8 @@ def make_trench_from_path_sloped(path_xy: List[Tuple[float,float]], width_top: f
620
701
 
621
702
  bot_verts, bot_faces = _triangulate_annulus(outer_bot, inner_bot[::-1])
622
703
  V_bottom = np.column_stack([bot_verts, np.concatenate([z_outer_bot, z_inner_bot[::-1]])])
623
- F_bottom = bot_faces[:, ::-1] # flip for outward normals
704
+ # Floor normals point UP (+z) into the trench void for correct SDF sign
705
+ F_bottom = _ensure_upward_normals(V_bottom, bot_faces)
624
706
 
625
707
  # Outer wall: connects outer_top to outer_bot (facing outward from trench)
626
708
  n_outer = len(outer_top)
@@ -680,7 +762,8 @@ def make_trench_from_path_sloped(path_xy: List[Tuple[float,float]], width_top: f
680
762
  V_cap = np.column_stack([poly_top, z_top])
681
763
  V_bottom = np.column_stack([poly_bot, z_bot])
682
764
  F_cap = tris_top
683
- F_bottom = tris_bot[:, ::-1] # outward
765
+ # Floor normals point UP (+z) into the trench void for correct SDF sign
766
+ F_bottom = _ensure_upward_normals(V_bottom, tris_bot)
684
767
 
685
768
  # Walls: connect corresponding indices
686
769
  N = len(poly_top)
@@ -715,6 +798,38 @@ def make_trench_from_path_sloped(path_xy: List[Tuple[float,float]], width_top: f
715
798
 
716
799
  return groups, poly_top, poly_bot, extra
717
800
 
801
+ def _ensure_upward_normals(V: np.ndarray, F: np.ndarray) -> np.ndarray:
802
+ """Ensure all faces have upward-pointing normals (+z).
803
+
804
+ For horizontal surfaces like trench floors, normals should point UP
805
+ into the void for correct SDF computation. This function flips any
806
+ faces with downward-pointing normals.
807
+
808
+ Parameters
809
+ ----------
810
+ V : np.ndarray
811
+ Vertices (n, 3)
812
+ F : np.ndarray
813
+ Faces (m, 3) - indices into V
814
+
815
+ Returns
816
+ -------
817
+ np.ndarray
818
+ Faces with consistent upward normals (may have winding flipped)
819
+ """
820
+ F_out = F.copy()
821
+ p0 = V[F[:, 0]]
822
+ p1 = V[F[:, 1]]
823
+ p2 = V[F[:, 2]]
824
+ normals = np.cross(p1 - p0, p2 - p0)
825
+
826
+ # Flip faces with negative z-component normals
827
+ down_mask = normals[:, 2] < 0
828
+ F_out[down_mask] = F_out[down_mask, ::-1]
829
+
830
+ return F_out
831
+
832
+
718
833
  def _triangulate_annulus(outer: np.ndarray, inner: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
719
834
  """Triangulate the annular region between outer and inner polygons.
720
835
 
@@ -822,6 +937,9 @@ def make_ground_surface_plane(path_xy: List[Tuple[float,float]], width_top: floa
822
937
  combined_xy, tris = _triangulate_annulus(ground_outer, trench_outer)
823
938
  Vg = np.array([[x, y, gfun(x, y)] for (x, y) in combined_xy], float)
824
939
 
940
+ # Ground normals should point UP (+z) into the air
941
+ tris = _ensure_upward_normals(Vg, tris)
942
+
825
943
  return {"ground_surface": (Vg, tris)}
826
944
  else:
827
945
  # Open paths: ground forms annulus with extensions past trench endpoints.
@@ -844,6 +962,9 @@ def make_ground_surface_plane(path_xy: List[Tuple[float,float]], width_top: floa
844
962
  # Apply ground elevation to get 3D vertices
845
963
  Vg = np.array([[x, y, gfun(x, y)] for (x, y) in combined_xy], float)
846
964
 
965
+ # Ground normals should point UP (+z) into the air
966
+ tris = _ensure_upward_normals(Vg, tris)
967
+
847
968
  return {"ground_surface": (Vg, tris)}
848
969
 
849
970
  def _half_width_at_depth(half_top: float, slope: float, top_z: float, z: float) -> float:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "trenchfoot"
7
- version = "0.2.7"
7
+ version = "0.3.0"
8
8
  description = "Synthetic trench scenario generator bundle (surfaces + volumetrics)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -0,0 +1,385 @@
1
+ # ABOUTME: Tests for SDF readiness: normal orientation and metadata export.
2
+ # ABOUTME: Ensures meshes are suitable for signed distance field computation.
3
+ """
4
+ Tests verifying that generated meshes are ready for SDF computation.
5
+
6
+ These tests validate:
7
+ 1. Normal orientation follows "into void" convention
8
+ 2. SDF metadata is correctly exported
9
+ 3. Trench opening polygon matches actual geometry
10
+ """
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+ import pytest
17
+
18
+ ROOT = Path(__file__).resolve().parents[1]
19
+ if str(ROOT) not in sys.path:
20
+ sys.path.insert(0, str(ROOT))
21
+ PKG_ROOT = ROOT / "packages"
22
+ if str(PKG_ROOT) not in sys.path:
23
+ sys.path.insert(0, str(PKG_ROOT))
24
+ VENVDIR = ROOT / ".venv"
25
+ if VENVDIR.exists():
26
+ for candidate in (VENVDIR / "lib").glob("python*/site-packages"):
27
+ if str(candidate) not in sys.path:
28
+ sys.path.insert(0, str(candidate))
29
+ break
30
+
31
+ from trenchfoot.generate_scenarios import (
32
+ ScenarioDefinition,
33
+ default_scenarios,
34
+ generate_scenarios,
35
+ )
36
+ from trenchfoot.trench_scene_generator_v3 import (
37
+ scene_spec_from_dict,
38
+ generate_surface_mesh,
39
+ )
40
+
41
+
42
+ def _compute_face_normals(V: np.ndarray, F: np.ndarray) -> np.ndarray:
43
+ """Compute per-face normals for a triangle mesh."""
44
+ p0 = V[F[:, 0]]
45
+ p1 = V[F[:, 1]]
46
+ p2 = V[F[:, 2]]
47
+ normals = np.cross(p1 - p0, p2 - p0)
48
+ # Normalize
49
+ lengths = np.linalg.norm(normals, axis=1, keepdims=True)
50
+ lengths = np.maximum(lengths, 1e-10) # Avoid division by zero
51
+ return normals / lengths
52
+
53
+
54
+ def _minimal_spec_dict() -> dict:
55
+ """Minimal trench spec for testing."""
56
+ return {
57
+ "path_xy": [[0.0, 0.0], [3.0, 0.0]],
58
+ "width": 1.0,
59
+ "depth": 1.2,
60
+ "wall_slope": 0.1,
61
+ "ground": {"z0": 0.0, "slope": [0.0, 0.0], "size_margin": 2.0},
62
+ "pipes": [],
63
+ "boxes": [],
64
+ "spheres": [],
65
+ "noise": {"enable": False},
66
+ }
67
+
68
+
69
+ class TestFloorNormalOrientation:
70
+ """Tests for trench floor normal orientation."""
71
+
72
+ def test_floor_normals_point_up_minimal(self):
73
+ """Floor normals should point UP (+z) for correct SDF sign."""
74
+ spec = scene_spec_from_dict(_minimal_spec_dict())
75
+ result = generate_surface_mesh(spec, make_preview=False)
76
+
77
+ V, F = result.groups["trench_bottom"]
78
+ normals = _compute_face_normals(V, F)
79
+
80
+ # All floor normals should have positive z component
81
+ z_components = normals[:, 2]
82
+ down_count = np.sum(z_components < 0)
83
+
84
+ assert down_count == 0, (
85
+ f"Floor has {down_count}/{len(F)} faces with downward normals. "
86
+ f"Floor normals should all point UP (+z) into the void."
87
+ )
88
+
89
+ # Check they're strongly upward (nz > 0.5 for mostly-horizontal faces)
90
+ weak_up_count = np.sum(z_components < 0.5)
91
+ assert weak_up_count == 0, (
92
+ f"Floor has {weak_up_count}/{len(F)} faces with weak upward normals (nz < 0.5). "
93
+ f"Floor should be mostly horizontal."
94
+ )
95
+
96
+ @pytest.mark.parametrize("scenario_name", [
97
+ "S01_straight_vwalls",
98
+ "S02_straight_sloped",
99
+ "S03_angled_corner",
100
+ "S04_curved_bend",
101
+ "S05_pipe_cluster",
102
+ "S06_complex_layout",
103
+ "S07_circular_well",
104
+ ])
105
+ def test_floor_normals_point_up_all_scenarios(self, scenario_name):
106
+ """Floor normals should point UP (+z) for all standard scenarios."""
107
+ scenarios = default_scenarios()
108
+ scenario = next((s for s in scenarios if s.name == scenario_name), None)
109
+ if scenario is None:
110
+ pytest.skip(f"{scenario_name} not available")
111
+
112
+ spec = scene_spec_from_dict(scenario.spec)
113
+ result = generate_surface_mesh(spec, make_preview=False)
114
+
115
+ V, F = result.groups["trench_bottom"]
116
+ normals = _compute_face_normals(V, F)
117
+
118
+ z_components = normals[:, 2]
119
+ down_count = np.sum(z_components < 0)
120
+
121
+ assert down_count == 0, (
122
+ f"{scenario_name}: Floor has {down_count}/{len(F)} faces with downward normals. "
123
+ f"All floor normals should point UP (+z)."
124
+ )
125
+
126
+
127
+ class TestWallNormalOrientation:
128
+ """Tests for trench wall normal orientation."""
129
+
130
+ def test_wall_normals_point_into_void(self):
131
+ """Wall normals should point INTO the trench void (toward centerline)."""
132
+ spec = scene_spec_from_dict(_minimal_spec_dict())
133
+ result = generate_surface_mesh(spec, make_preview=False)
134
+
135
+ V, F = result.groups["trench_walls"]
136
+ normals = _compute_face_normals(V, F)
137
+
138
+ # For a straight trench along x-axis (y=0), walls are on either side.
139
+ # The trench centerline is at y=0.
140
+ # Left wall (y < 0) should have normals pointing +y (into trench)
141
+ # Right wall (y > 0) should have normals pointing -y (into trench)
142
+
143
+ # Compute face centroids
144
+ centroids = (V[F[:, 0]] + V[F[:, 1]] + V[F[:, 2]]) / 3
145
+
146
+ # Check each face: direction from centroid to centerline should align with normal
147
+ errors = 0
148
+ for i, (centroid, normal) in enumerate(zip(centroids, normals)):
149
+ # Direction toward centerline (y=0)
150
+ toward_center_y = -np.sign(centroid[1]) if abs(centroid[1]) > 0.01 else 0
151
+
152
+ # For wall faces, the y-component of normal should match direction toward center
153
+ if abs(normal[1]) > 0.3: # Face has significant y-normal component
154
+ if np.sign(normal[1]) != toward_center_y:
155
+ errors += 1
156
+
157
+ assert errors == 0, (
158
+ f"Wall has {errors}/{len(F)} faces with normals pointing away from trench void. "
159
+ f"Wall normals should point inward toward the trench centerline."
160
+ )
161
+
162
+ @pytest.mark.parametrize("scenario_name", [
163
+ "S01_straight_vwalls",
164
+ "S02_straight_sloped",
165
+ "S03_angled_corner",
166
+ ])
167
+ def test_wall_normals_mostly_horizontal(self, scenario_name):
168
+ """Wall normals should be mostly horizontal (small z component)."""
169
+ scenarios = default_scenarios()
170
+ scenario = next((s for s in scenarios if s.name == scenario_name), None)
171
+ if scenario is None:
172
+ pytest.skip(f"{scenario_name} not available")
173
+
174
+ spec = scene_spec_from_dict(scenario.spec)
175
+ result = generate_surface_mesh(spec, make_preview=False)
176
+
177
+ V, F = result.groups["trench_walls"]
178
+ normals = _compute_face_normals(V, F)
179
+
180
+ # Wall faces should have mostly horizontal normals
181
+ # Allow some z component for sloped walls but should be < 0.7
182
+ z_components = np.abs(normals[:, 2])
183
+ vertical_wall_ratio = np.mean(z_components < 0.7)
184
+
185
+ assert vertical_wall_ratio > 0.8, (
186
+ f"{scenario_name}: Only {vertical_wall_ratio*100:.1f}% of wall faces have |nz| < 0.7. "
187
+ f"Wall faces should be mostly vertical (horizontal normals)."
188
+ )
189
+
190
+
191
+ class TestGroundNormalOrientation:
192
+ """Tests for ground surface normal orientation."""
193
+
194
+ def test_ground_normals_point_up(self):
195
+ """Ground surface normals should point UP (+z)."""
196
+ spec = scene_spec_from_dict(_minimal_spec_dict())
197
+ result = generate_surface_mesh(spec, make_preview=False)
198
+
199
+ V, F = result.groups["ground_surface"]
200
+ normals = _compute_face_normals(V, F)
201
+
202
+ z_components = normals[:, 2]
203
+ down_count = np.sum(z_components < 0)
204
+
205
+ assert down_count == 0, (
206
+ f"Ground has {down_count}/{len(F)} faces with downward normals. "
207
+ f"Ground normals should all point UP (+z)."
208
+ )
209
+
210
+
211
+ class TestSDFMetadata:
212
+ """Tests for SDF metadata export."""
213
+
214
+ def test_metadata_exported(self, tmp_path):
215
+ """SDF metadata JSON should be exported alongside mesh."""
216
+ spec = scene_spec_from_dict(_minimal_spec_dict())
217
+ result = generate_surface_mesh(spec, make_preview=False)
218
+ files = result.persist(tmp_path)
219
+
220
+ assert files.sdf_metadata_path is not None, "sdf_metadata_path should be set"
221
+ assert files.sdf_metadata_path.exists(), "sdf_metadata.json should exist"
222
+
223
+ def test_metadata_has_required_fields(self, tmp_path):
224
+ """SDF metadata should contain all required fields."""
225
+ spec = scene_spec_from_dict(_minimal_spec_dict())
226
+ result = generate_surface_mesh(spec, make_preview=False)
227
+ files = result.persist(tmp_path)
228
+
229
+ with files.sdf_metadata_path.open() as f:
230
+ data = json.load(f)
231
+
232
+ assert "sdf_metadata" in data, "Root key 'sdf_metadata' missing"
233
+ meta = data["sdf_metadata"]
234
+
235
+ required_fields = ["version", "normal_convention", "geometry_type", "trench_opening"]
236
+ for field in required_fields:
237
+ assert field in meta, f"Required field '{field}' missing from metadata"
238
+
239
+ assert meta["version"] == "1.0"
240
+ assert meta["normal_convention"] == "into_void"
241
+ assert meta["geometry_type"] == "open_trench"
242
+
243
+ def test_metadata_trench_opening_structure(self, tmp_path):
244
+ """Trench opening should have proper polygon structure."""
245
+ spec = scene_spec_from_dict(_minimal_spec_dict())
246
+ result = generate_surface_mesh(spec, make_preview=False)
247
+ files = result.persist(tmp_path)
248
+
249
+ with files.sdf_metadata_path.open() as f:
250
+ data = json.load(f)
251
+
252
+ opening = data["sdf_metadata"]["trench_opening"]
253
+ assert opening["type"] == "polygon"
254
+ assert "vertices_xy" in opening
255
+ assert "z_level" in opening
256
+
257
+ vertices = opening["vertices_xy"]
258
+ assert len(vertices) >= 4, "Trench opening should have at least 4 vertices"
259
+
260
+ # Each vertex should be [x, y]
261
+ for v in vertices:
262
+ assert len(v) == 2, f"Vertex should be [x, y], got {v}"
263
+
264
+ def test_metadata_polygon_bounds_match_trench(self, tmp_path):
265
+ """Trench opening polygon should match actual trench geometry bounds."""
266
+ spec_dict = _minimal_spec_dict()
267
+ spec = scene_spec_from_dict(spec_dict)
268
+ result = generate_surface_mesh(spec, make_preview=False)
269
+ files = result.persist(tmp_path)
270
+
271
+ with files.sdf_metadata_path.open() as f:
272
+ data = json.load(f)
273
+
274
+ opening = data["sdf_metadata"]["trench_opening"]
275
+ vertices = np.array(opening["vertices_xy"])
276
+
277
+ # The trench path goes from [0,0] to [3,0] with width 1.0
278
+ # The metadata uses the base half-width (0.5), which represents the
279
+ # footprint of the trench floor, not the sloped width at z=0.
280
+ expected_half_width = 0.5
281
+
282
+ # Check x range covers path (0 to 3)
283
+ x_min, x_max = vertices[:, 0].min(), vertices[:, 0].max()
284
+ assert x_min <= 0.1, f"Opening x_min ({x_min}) should be near 0"
285
+ assert x_max >= 2.9, f"Opening x_max ({x_max}) should be near 3"
286
+
287
+ # Check y range matches half-width
288
+ y_min, y_max = vertices[:, 1].min(), vertices[:, 1].max()
289
+ assert abs(y_min + expected_half_width) < 0.1, (
290
+ f"Opening y_min ({y_min}) should be near {-expected_half_width}"
291
+ )
292
+ assert abs(y_max - expected_half_width) < 0.1, (
293
+ f"Opening y_max ({y_max}) should be near {expected_half_width}"
294
+ )
295
+
296
+ def test_metadata_surface_groups_present(self, tmp_path):
297
+ """Surface groups should list all mesh groups with properties."""
298
+ spec = scene_spec_from_dict(_minimal_spec_dict())
299
+ result = generate_surface_mesh(spec, make_preview=False)
300
+ files = result.persist(tmp_path)
301
+
302
+ with files.sdf_metadata_path.open() as f:
303
+ data = json.load(f)
304
+
305
+ groups = data["sdf_metadata"]["surface_groups"]
306
+
307
+ expected_groups = ["trench_bottom", "trench_walls", "ground_surface"]
308
+ for group in expected_groups:
309
+ assert group in groups, f"Expected surface group '{group}' in metadata"
310
+ assert "normal_direction" in groups[group]
311
+ assert "surface_type" in groups[group]
312
+
313
+ # Verify correct normal conventions documented
314
+ assert groups["trench_bottom"]["normal_direction"] == "up"
315
+ assert groups["trench_walls"]["normal_direction"] == "inward"
316
+ assert groups["ground_surface"]["normal_direction"] == "up"
317
+
318
+ def test_metadata_embedded_objects_count(self, tmp_path):
319
+ """Embedded objects count should match spec."""
320
+ spec_dict = _minimal_spec_dict()
321
+ # Note: PipeSpec uses s_center, SphereSpec uses s
322
+ spec_dict["pipes"] = [
323
+ {"radius": 0.1, "length": 1.0, "angle_deg": 0, "s_center": 0.5, "z": -0.6, "offset_u": 0},
324
+ {"radius": 0.05, "length": 0.8, "angle_deg": 90, "s_center": 0.3, "z": -0.4, "offset_u": 0},
325
+ ]
326
+ spec_dict["spheres"] = [{"radius": 0.15, "s": 0.7, "z": -0.5}]
327
+
328
+ spec = scene_spec_from_dict(spec_dict)
329
+ result = generate_surface_mesh(spec, make_preview=False)
330
+ files = result.persist(tmp_path)
331
+
332
+ with files.sdf_metadata_path.open() as f:
333
+ data = json.load(f)
334
+
335
+ embedded = data["sdf_metadata"]["embedded_objects"]
336
+ assert embedded["pipes"] == 2
337
+ assert embedded["spheres"] == 1
338
+ assert embedded["boxes"] == 0
339
+
340
+
341
+ class TestCircularWellNormals:
342
+ """Specific tests for circular well (S07) normal orientation."""
343
+
344
+ def test_s07_floor_normals_all_upward(self):
345
+ """S07 circular well floor should have 100% upward normals."""
346
+ scenarios = default_scenarios()
347
+ s07 = next((s for s in scenarios if s.name == "S07_circular_well"), None)
348
+ if s07 is None:
349
+ pytest.skip("S07_circular_well not available")
350
+
351
+ spec = scene_spec_from_dict(s07.spec)
352
+ result = generate_surface_mesh(spec, make_preview=False)
353
+
354
+ V, F = result.groups["trench_bottom"]
355
+ normals = _compute_face_normals(V, F)
356
+
357
+ z_components = normals[:, 2]
358
+ down_count = np.sum(z_components < 0)
359
+ up_pct = 100 * (1 - down_count / len(F))
360
+
361
+ assert down_count == 0, (
362
+ f"S07 circular well has {down_count}/{len(F)} floor faces with downward normals "
363
+ f"({up_pct:.1f}% upward). Should be 100% upward."
364
+ )
365
+
366
+ def test_s07_annulus_ground_normals_upward(self):
367
+ """S07 ground annulus should have upward normals."""
368
+ scenarios = default_scenarios()
369
+ s07 = next((s for s in scenarios if s.name == "S07_circular_well"), None)
370
+ if s07 is None:
371
+ pytest.skip("S07_circular_well not available")
372
+
373
+ spec = scene_spec_from_dict(s07.spec)
374
+ result = generate_surface_mesh(spec, make_preview=False)
375
+
376
+ V, F = result.groups["ground_surface"]
377
+ normals = _compute_face_normals(V, F)
378
+
379
+ z_components = normals[:, 2]
380
+ down_count = np.sum(z_components < 0)
381
+
382
+ assert down_count == 0, (
383
+ f"S07 ground annulus has {down_count}/{len(F)} faces with downward normals. "
384
+ f"Ground should all point UP."
385
+ )
@@ -501,7 +501,7 @@ wheels = [
501
501
 
502
502
  [[package]]
503
503
  name = "trenchfoot"
504
- version = "0.2.7"
504
+ version = "0.3.0"
505
505
  source = { editable = "." }
506
506
  dependencies = [
507
507
  { name = "numpy" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes