362 lines
13 KiB
Python
362 lines
13 KiB
Python
"""Tests for BRep tracking: boolean operation history capture via OCC Modified/Generated/IsDeleted."""
|
|
|
|
import unittest
|
|
|
|
import simplecadapi as scad
|
|
from simplecadapi.topology import (
|
|
TopoKind,
|
|
TopoEvent,
|
|
TopoRef,
|
|
TopoDelta,
|
|
OperationNode,
|
|
OperationGraph,
|
|
)
|
|
from simplecadapi.tracking import (
|
|
tracked_cut,
|
|
tracked_union,
|
|
tracked_intersect,
|
|
TrackedBooleanResult,
|
|
)
|
|
|
|
|
|
class TestTrackedCut(unittest.TestCase):
|
|
"""Test cut with full face-level history."""
|
|
|
|
def setUp(self):
|
|
self.body = scad.make_box_rsolid(10, 10, 10)
|
|
self.tool = scad.make_cylinder_rsolid(
|
|
2.0, 15.0, bottom_face_center=(3, 3, -2.5)
|
|
)
|
|
|
|
def test_tracked_cut_returns_solid_and_delta(self):
|
|
result = tracked_cut(self.body, self.tool)
|
|
self.assertIsInstance(result, TrackedBooleanResult)
|
|
self.assertIsNotNone(result.solid)
|
|
self.assertIsInstance(result.delta, TopoDelta)
|
|
|
|
def test_tracked_cut_has_preserved_faces(self):
|
|
result = tracked_cut(self.body, self.tool)
|
|
preserved_face_refs = [
|
|
r for r in result.delta.preserved if r.kind == TopoKind.FACE
|
|
]
|
|
# After a cylinder cut through a box, most original faces should be
|
|
# either modified or preserved; some should survive
|
|
self.assertGreater(len(preserved_face_refs), 0)
|
|
|
|
def test_tracked_cut_has_proven_changed_faces(self):
|
|
result = tracked_cut(self.body, self.tool)
|
|
changed_faces = [
|
|
entry
|
|
for entry in result.delta.entries
|
|
if entry.ref.kind == TopoKind.FACE
|
|
and entry.event in {TopoEvent.MODIFIED, TopoEvent.GENERATED}
|
|
and entry.metadata["status"] == "proven"
|
|
]
|
|
self.assertGreater(len(changed_faces), 0)
|
|
|
|
def test_tracked_cut_volume_decreased(self):
|
|
result = tracked_cut(self.body, self.tool)
|
|
original_vol = self.body.get_volume()
|
|
result_vol = result.solid.get_volume()
|
|
self.assertLess(result_vol, original_vol)
|
|
|
|
def test_tracked_cut_total_faces_increased(self):
|
|
"""A cylinder cut through a box adds the cylindrical hole face."""
|
|
result = tracked_cut(self.body, self.tool)
|
|
original_faces = len(self.body.get_faces())
|
|
result_faces = len(result.solid.get_faces())
|
|
# Cylinder through box: at least 1 new face (cylindrical hole)
|
|
self.assertGreaterEqual(result_faces, original_faces)
|
|
|
|
def test_tracked_cut_tool_with_tool_face_labels(self):
|
|
"""Faces from the tool should be labeled with origin_role='tool'."""
|
|
result = tracked_cut(self.body, self.tool)
|
|
tool_outputs = [
|
|
entry
|
|
for entry in result.delta.entries
|
|
if entry.ref.kind == TopoKind.FACE
|
|
and entry.origin_role == "tool"
|
|
and entry.event in {TopoEvent.MODIFIED, TopoEvent.GENERATED}
|
|
]
|
|
self.assertGreater(len(tool_outputs), 0)
|
|
self.assertTrue(all(entry.parent_refs for entry in tool_outputs))
|
|
|
|
def test_tracked_cut_preserves_volume_accuracy(self):
|
|
result = tracked_cut(self.body, self.tool)
|
|
# Volume should be original minus roughly the cylinder volume
|
|
expected_min = self.body.get_volume() - 3.14159 * 2.0**2 * 15.0
|
|
self.assertGreater(result.solid.get_volume(), 0)
|
|
|
|
|
|
class TestTrackedUnion(unittest.TestCase):
|
|
def setUp(self):
|
|
self.body = scad.make_box_rsolid(10, 10, 10)
|
|
# Use a cylinder to ensure curved intersection edges
|
|
self.tool = scad.make_cylinder_rsolid(4.0, 10.0, bottom_face_center=(3, 3, 0))
|
|
|
|
def test_tracked_union_returns_solid_and_delta(self):
|
|
result = tracked_union(self.body, self.tool)
|
|
self.assertIsInstance(result, TrackedBooleanResult)
|
|
self.assertIsNotNone(result.solid)
|
|
|
|
def test_tracked_union_volume_increased(self):
|
|
result = tracked_union(self.body, self.tool)
|
|
original_vol = self.body.get_volume()
|
|
result_vol = result.solid.get_volume()
|
|
# Union of two overlapping boxes should be less than sum but more than either
|
|
self.assertGreater(result_vol, original_vol)
|
|
|
|
def test_tracked_union_has_section_edges(self):
|
|
# OCC may suppress SectionEdges under glue mode; disabling glue should expose them.
|
|
result = tracked_union(self.body, self.tool, glue=False)
|
|
section_edges = result.delta.section_edges
|
|
self.assertGreater(len(section_edges), 0)
|
|
|
|
for section_ref in section_edges:
|
|
entries = [
|
|
entry
|
|
for entry in result.delta.entries
|
|
if entry.ref == section_ref
|
|
]
|
|
self.assertGreaterEqual(len(entries), 2)
|
|
self.assertEqual(
|
|
{entry.origin_role for entry in entries}, {"body", "tool"}
|
|
)
|
|
self.assertTrue(all(entry.parent_refs for entry in entries))
|
|
|
|
def test_nary_union_tracks_every_input_through_clean_history(self):
|
|
solids = [
|
|
scad.make_box_rsolid(
|
|
1.0,
|
|
1.0,
|
|
1.0,
|
|
bottom_face_center=(float(index), 0.0, 0.0),
|
|
)
|
|
for index in range(3)
|
|
]
|
|
|
|
result = scad.union_rsolid(*solids, glue=False)
|
|
tracks = [face.get_metadata("track") for face in result.get_faces()]
|
|
|
|
self.assertEqual(len(result.get_faces()), 6)
|
|
self.assertTrue(all(track["status"] == "proven" for track in tracks))
|
|
self.assertEqual(
|
|
{role for track in tracks for role in track["origin_roles"]},
|
|
{"body", "tool", "tool_2"},
|
|
)
|
|
self.assertTrue(result.get_metadata("track")["has_delta"])
|
|
|
|
def test_nary_union_keeps_modified_face_lineage_after_clean(self):
|
|
barrel = scad.make_cylinder_rsolid(
|
|
16.0,
|
|
120.0,
|
|
bottom_face_center=(-60.0, 0.0, 0.0),
|
|
axis=(1.0, 0.0, 0.0),
|
|
)
|
|
flange = scad.make_cylinder_rsolid(
|
|
22.0,
|
|
12.0,
|
|
bottom_face_center=(50.0, 0.0, 0.0),
|
|
axis=(1.0, 0.0, 0.0),
|
|
)
|
|
nose = scad.make_cylinder_rsolid(
|
|
13.0,
|
|
10.0,
|
|
bottom_face_center=(58.0, 0.0, 0.0),
|
|
axis=(1.0, 0.0, 0.0),
|
|
)
|
|
source_face = max(flange.get_faces(), key=lambda face: face.get_center().x)
|
|
scad.apply_tag(source_face, "cap.face.end")
|
|
|
|
result = scad.union_rsolid(barrel, flange, nose, glue=False)
|
|
descendants = [
|
|
face
|
|
for face in result.get_faces()
|
|
if "cap.face.end" in scad.list_tags(face, scope="lineage")
|
|
]
|
|
|
|
self.assertEqual(len(descendants), 1)
|
|
self.assertAlmostEqual(descendants[0].get_center().x, 62.0, places=6)
|
|
self.assertAlmostEqual(descendants[0].get_area(), 989.6016859, places=5)
|
|
self.assertEqual(descendants[0].get_metadata("track")["event"], "modified")
|
|
self.assertNotIn("cap.face.end", scad.list_tags(descendants[0]))
|
|
|
|
def test_nary_union_respects_face_binding_lineage_policy(self):
|
|
barrel = scad.make_cylinder_rsolid(
|
|
16.0,
|
|
120.0,
|
|
bottom_face_center=(-60.0, 0.0, 0.0),
|
|
axis=(1.0, 0.0, 0.0),
|
|
)
|
|
flange = scad.make_cylinder_rsolid(
|
|
22.0,
|
|
12.0,
|
|
bottom_face_center=(50.0, 0.0, 0.0),
|
|
axis=(1.0, 0.0, 0.0),
|
|
)
|
|
nose = scad.make_cylinder_rsolid(
|
|
13.0,
|
|
10.0,
|
|
bottom_face_center=(58.0, 0.0, 0.0),
|
|
axis=(1.0, 0.0, 0.0),
|
|
)
|
|
source_face = max(flange.get_faces(), key=lambda face: face.get_center().x)
|
|
flange = scad.apply_tag_rselection(
|
|
flange,
|
|
[source_face],
|
|
"cap.face.end",
|
|
lineage_policy=scad.LineagePolicy.NONE,
|
|
)
|
|
|
|
result = scad.union_rsolid(barrel, flange, nose, glue=False)
|
|
|
|
self.assertFalse(
|
|
any("cap.face.end" in scad.list_tags(face) for face in result.get_faces())
|
|
)
|
|
|
|
def test_nary_union_face_binding_stays_directly_queryable_after_cut(self):
|
|
flange = scad.extrude_rsolid(
|
|
scad.make_circle_rface(
|
|
center=(0.0, 0.0, 0.0),
|
|
radius=2.0,
|
|
normal=(1.0, 0.0, 0.0),
|
|
),
|
|
direction=(1.0, 0.0, 0.0),
|
|
distance=2.0,
|
|
start_face_tag="cap.face.start",
|
|
end_face_tag="cap.face.end",
|
|
side_faces_tag="cap.face.side",
|
|
)
|
|
source_end = scad.select_faces_by_tag(flange, "cap.face.end")[0]
|
|
source_explanation = scad.explain_tag(
|
|
source_end, "cap.face.end", scope="local"
|
|
)[0]
|
|
body = scad.make_cylinder_rsolid(
|
|
1.5,
|
|
4.0,
|
|
bottom_face_center=(-1.0, 0.0, 0.0),
|
|
axis=(1.0, 0.0, 0.0),
|
|
)
|
|
bridge = scad.make_box_rsolid(
|
|
1.0,
|
|
1.0,
|
|
1.0,
|
|
bottom_face_center=(-0.5, -0.5, -0.5),
|
|
)
|
|
fused = scad.union_rsolid(body, flange, bridge, glue=False)
|
|
result = scad.cut_rsolid(
|
|
fused,
|
|
scad.make_cylinder_rsolid(
|
|
0.25,
|
|
4.0,
|
|
bottom_face_center=(-1.0, 1.0, 0.0),
|
|
axis=(1.0, 0.0, 0.0),
|
|
),
|
|
)
|
|
|
|
end_faces = scad.select_faces_by_tag(result, "cap.face.end")
|
|
|
|
self.assertEqual(len(end_faces), 1)
|
|
projected = scad.explain_tag(
|
|
end_faces[0], "cap.face.end", scope="local"
|
|
)[0]["binding"]["evidence"]
|
|
self.assertEqual(
|
|
projected["source_binding_id"], source_explanation["binding_id"]
|
|
)
|
|
self.assertEqual(projected["source_topo_id"], source_end.topo_id)
|
|
|
|
def test_nary_union_tracks_faces_without_cleaning(self):
|
|
solids = [
|
|
scad.make_box_rsolid(
|
|
1.0,
|
|
1.0,
|
|
1.0,
|
|
bottom_face_center=(float(index), 0.0, 0.0),
|
|
)
|
|
for index in range(3)
|
|
]
|
|
|
|
result = scad.union_rsolid(*solids, clean=False, glue=False)
|
|
|
|
self.assertEqual(len(result.get_faces()), 14)
|
|
self.assertTrue(
|
|
all(
|
|
face.get_metadata("track")["status"] == "proven"
|
|
for face in result.get_faces()
|
|
)
|
|
)
|
|
|
|
|
|
class TestTrackedIntersect(unittest.TestCase):
|
|
def setUp(self):
|
|
self.body = scad.make_box_rsolid(10, 10, 10)
|
|
self.tool = scad.make_cylinder_rsolid(6.0, 10.0, bottom_face_center=(3, 3, 0))
|
|
|
|
def test_tracked_intersect_returns_solid_and_delta(self):
|
|
result = tracked_intersect(self.body, self.tool)
|
|
self.assertIsInstance(result, TrackedBooleanResult)
|
|
self.assertIsNotNone(result.solid)
|
|
|
|
def test_tracked_intersect_volume_less_than_both(self):
|
|
result = tracked_intersect(self.body, self.tool)
|
|
self.assertLess(result.solid.get_volume(), self.body.get_volume())
|
|
self.assertLess(result.solid.get_volume(), self.tool.get_volume())
|
|
|
|
|
|
class TestDeltaEntries(unittest.TestCase):
|
|
"""Test that delta_entries provides origin_role info."""
|
|
|
|
def setUp(self):
|
|
self.body = scad.make_box_rsolid(10, 10, 10)
|
|
self.tool = scad.make_cylinder_rsolid(
|
|
3.0, 15.0, bottom_face_center=(3, 3, -2.5)
|
|
)
|
|
|
|
def test_body_faces_labeled(self):
|
|
result = tracked_cut(self.body, self.tool)
|
|
body_preserved = [
|
|
r
|
|
for r in result.delta.preserved
|
|
if r.kind == TopoKind.FACE
|
|
and result.delta_entries.get(r.topo_id, {}).get("origin_role") == "body"
|
|
]
|
|
self.assertGreater(len(body_preserved), 0)
|
|
|
|
def test_modified_faces_labeled(self):
|
|
result = tracked_cut(self.body, self.tool)
|
|
body_modified = [
|
|
r
|
|
for r in result.delta.modified
|
|
if r.kind == TopoKind.FACE
|
|
and result.delta_entries.get(r.topo_id, {}).get("origin_role") == "body"
|
|
]
|
|
# Some faces from body should be modified
|
|
self.assertGreater(len(body_modified), 0)
|
|
|
|
|
|
class TestSolidMapping(unittest.TestCase):
|
|
"""Test that the result solid is a valid SimpleCADAPI Solid."""
|
|
|
|
def setUp(self):
|
|
self.body = scad.make_box_rsolid(10, 10, 10)
|
|
self.tool = scad.make_cylinder_rsolid(
|
|
2.0, 15.0, bottom_face_center=(3, 3, -2.5)
|
|
)
|
|
|
|
def test_result_is_solid(self):
|
|
result = tracked_cut(self.body, self.tool)
|
|
self.assertIsInstance(result.solid, scad.Solid)
|
|
|
|
def test_result_has_faces(self):
|
|
result = tracked_cut(self.body, self.tool)
|
|
faces = result.solid.get_faces()
|
|
self.assertGreater(len(faces), 0)
|
|
|
|
def test_result_has_valid_volume(self):
|
|
result = tracked_cut(self.body, self.tool)
|
|
self.assertGreater(result.solid.get_volume(), 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|