425 lines
16 KiB
Python
425 lines
16 KiB
Python
"""Focused 2.0 operation-level tests for remaining core operations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
import simplecadapi as scad
|
|
from simplecadapi import operations
|
|
from simplecadapi.graph import GraphSession
|
|
|
|
|
|
class TestRearchitecture20CoreOps(unittest.TestCase):
|
|
def test_cylinder_accepts_expression_parameters(self):
|
|
r = scad.var("r", 1.5)
|
|
h = scad.var("h", 4.0)
|
|
solid = scad.make_cylinder_rsolid(r, h, bottom_face_center=(0, 0, 0))
|
|
self.assertIsInstance(solid, scad.Solid)
|
|
self.assertGreater(solid.get_volume(), 0.0)
|
|
|
|
def test_revolve_accepts_expression_angle(self):
|
|
angle = scad.var("angle", 180.0)
|
|
face = scad.make_rectangle_rface(1.0, 2.0, center=(1.0, 0.0, 0.0))
|
|
solid = scad.revolve_rsolid(face, axis=(0, 1, 0), angle=angle, origin=(0, 0, 0))
|
|
self.assertIsInstance(solid, scad.Solid)
|
|
|
|
def test_revolve_produces_topology_delta_at_runtime(self):
|
|
face = scad.make_rectangle_rface(1.0, 2.0, center=(1.0, 0.0, 0.0))
|
|
with GraphSession() as session:
|
|
solid = scad.revolve_rsolid(
|
|
face, axis=(0, 1, 0), angle=180.0, origin=(0, 0, 0)
|
|
)
|
|
|
|
self.assertIsInstance(solid, scad.Solid)
|
|
leaf = session.graph.leaf_nodes()[0]
|
|
self.assertEqual(leaf.op, "make_revolve_rsolid")
|
|
self.assertIsNotNone(leaf.topo_delta)
|
|
self.assertGreaterEqual(
|
|
len(leaf.topo_delta.modified)
|
|
+ len(leaf.topo_delta.generated)
|
|
+ len(leaf.topo_delta.deleted),
|
|
1,
|
|
)
|
|
|
|
def test_revolve_topology_delta_survives_graph_json_roundtrip(self):
|
|
face = scad.make_rectangle_rface(1.0, 2.0, center=(1.0, 0.0, 0.0))
|
|
with GraphSession() as session:
|
|
scad.revolve_rsolid(face, axis=(0, 1, 0), angle=180.0, origin=(0, 0, 0))
|
|
|
|
restored = scad.import_graph_json(scad.export_graph_json(session.graph))
|
|
leaf = restored.leaf_nodes()[0]
|
|
|
|
self.assertEqual(leaf.op, "make_revolve_rsolid")
|
|
self.assertIsNotNone(leaf.topo_delta)
|
|
self.assertGreaterEqual(
|
|
len(leaf.topo_delta.modified)
|
|
+ len(leaf.topo_delta.generated)
|
|
+ len(leaf.topo_delta.deleted),
|
|
1,
|
|
)
|
|
|
|
def test_loft_produces_topology_delta_at_runtime(self):
|
|
a = scad.make_rectangle_rwire(2.0, 2.0, center=(0.0, 0.0, 0.0))
|
|
b = scad.make_rectangle_rwire(1.0, 1.0, center=(0.0, 0.0, 2.0))
|
|
with GraphSession() as session:
|
|
solid = scad.loft_rsolid([a, b])
|
|
|
|
self.assertIsInstance(solid, scad.Solid)
|
|
leaf = session.graph.leaf_nodes()[0]
|
|
self.assertEqual(leaf.op, "make_loft_rsolid")
|
|
self.assertIsNotNone(leaf.topo_delta)
|
|
self.assertGreaterEqual(
|
|
len(leaf.topo_delta.modified)
|
|
+ len(leaf.topo_delta.generated)
|
|
+ len(leaf.topo_delta.deleted),
|
|
1,
|
|
)
|
|
|
|
def test_loft_topology_delta_survives_graph_json_roundtrip(self):
|
|
a = scad.make_rectangle_rwire(2.0, 2.0, center=(0.0, 0.0, 0.0))
|
|
b = scad.make_rectangle_rwire(1.0, 1.0, center=(0.0, 0.0, 2.0))
|
|
with GraphSession() as session:
|
|
scad.loft_rsolid([a, b])
|
|
|
|
restored = scad.import_graph_json(scad.export_graph_json(session.graph))
|
|
leaf = restored.leaf_nodes()[0]
|
|
|
|
self.assertEqual(leaf.op, "make_loft_rsolid")
|
|
self.assertIsNotNone(leaf.topo_delta)
|
|
self.assertGreaterEqual(
|
|
len(leaf.topo_delta.modified)
|
|
+ len(leaf.topo_delta.generated)
|
|
+ len(leaf.topo_delta.deleted),
|
|
1,
|
|
)
|
|
|
|
def test_loft_graph_tracking_records_replayable_node_without_topology_delta(self):
|
|
with GraphSession() as session:
|
|
a = scad.make_rectangle_rwire(2.0, 2.0, center=(0.0, 0.0, 0.0))
|
|
b = scad.make_rectangle_rwire(1.0, 1.0, center=(0.0, 0.0, 2.0))
|
|
solid = scad.loft_rsolid(
|
|
[a, b], tracking_policy=scad.TrackingPolicy.GRAPH
|
|
)
|
|
session.capture_result(value=solid)
|
|
|
|
loft = next(
|
|
node for node in session.graph.nodes if node.op == "make_loft_rsolid"
|
|
)
|
|
self.assertIsNone(loft.topo_delta)
|
|
self.assertEqual(loft.params["tracking_policy"], "graph")
|
|
self.assertEqual(len(loft.inputs), 2)
|
|
|
|
replayed = scad.replay_model_json(scad.export_model_json(session))[0]
|
|
self.assertIsInstance(replayed, scad.Solid)
|
|
self.assertAlmostEqual(replayed.get_volume(), solid.get_volume(), places=6)
|
|
|
|
def test_loft_graph_tracking_rejects_topology_role_tags(self):
|
|
a = scad.make_rectangle_rwire(2.0, 2.0, center=(0.0, 0.0, 0.0))
|
|
b = scad.make_rectangle_rwire(1.0, 1.0, center=(0.0, 0.0, 2.0))
|
|
|
|
with self.assertRaisesRegex(
|
|
scad.SimpleCADError, "GRAPH tracking does not provide loft face-role evidence"
|
|
):
|
|
scad.loft_rsolid(
|
|
[a, b],
|
|
tracking_policy=scad.TrackingPolicy.GRAPH,
|
|
side_faces_tag="face.loft.side",
|
|
)
|
|
|
|
def test_union_graph_tracking_records_replayable_node_without_topology_delta(self):
|
|
with GraphSession() as session:
|
|
a = scad.make_box_rsolid(2.0, 2.0, 2.0)
|
|
b = scad.make_box_rsolid(
|
|
2.0,
|
|
2.0,
|
|
2.0,
|
|
bottom_face_center=(1.0, 0.0, 0.0),
|
|
)
|
|
solid = scad.union_rsolid(
|
|
a,
|
|
b,
|
|
glue=False,
|
|
tracking_policy=scad.TrackingPolicy.GRAPH,
|
|
)
|
|
session.capture_result(value=solid)
|
|
|
|
union = next(
|
|
node for node in session.graph.nodes if node.op == "make_union_rsolid"
|
|
)
|
|
self.assertIsNone(union.topo_delta)
|
|
self.assertIsNotNone(union.semantic_delta)
|
|
self.assertEqual(union.params["tracking_policy"], "graph")
|
|
self.assertEqual(len(union.inputs), 2)
|
|
self.assertNotIn("has_delta", solid.get_metadata("track"))
|
|
|
|
replayed = scad.replay_model_json(scad.export_model_json(session))[0]
|
|
self.assertIsInstance(replayed, scad.Solid)
|
|
self.assertAlmostEqual(replayed.get_volume(), solid.get_volume(), places=6)
|
|
|
|
def test_cut_graph_tracking_records_replayable_node_without_topology_delta(self):
|
|
with GraphSession() as session:
|
|
body = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
tool = scad.make_cylinder_rsolid(
|
|
0.75,
|
|
6.0,
|
|
bottom_face_center=(0.0, 0.0, -1.0),
|
|
)
|
|
solid = scad.cut_rsolid(
|
|
body,
|
|
tool,
|
|
skip_non_intersecting=False,
|
|
tracking_policy=scad.TrackingPolicy.GRAPH,
|
|
)
|
|
session.capture_result(value=solid)
|
|
|
|
cut = next(
|
|
node for node in session.graph.nodes if node.op == "make_cut_rsolid"
|
|
)
|
|
self.assertIsNone(cut.topo_delta)
|
|
self.assertIsNotNone(cut.semantic_delta)
|
|
self.assertEqual(cut.params["tracking_policy"], "graph")
|
|
self.assertEqual(len(cut.inputs), 2)
|
|
self.assertNotIn("has_delta", solid.get_metadata("track"))
|
|
self.assertIn("solid.boolean.cut", scad.list_tags(solid, scope="local"))
|
|
|
|
replayed = scad.replay_model_json(scad.export_model_json(session))[0]
|
|
self.assertIsInstance(replayed, scad.Solid)
|
|
self.assertAlmostEqual(replayed.get_volume(), solid.get_volume(), places=6)
|
|
|
|
def test_graph_booleans_do_not_call_topology_history_helpers(self):
|
|
body = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
cutter = scad.make_cylinder_rsolid(
|
|
0.75,
|
|
6.0,
|
|
bottom_face_center=(0.0, 0.0, -1.0),
|
|
)
|
|
rib = scad.make_box_rsolid(
|
|
2.0,
|
|
2.0,
|
|
2.0,
|
|
bottom_face_center=(1.5, 0.0, 0.0),
|
|
)
|
|
|
|
with mock.patch.object(
|
|
operations,
|
|
"tracked_cut",
|
|
side_effect=AssertionError("GRAPH cut queried topology history"),
|
|
):
|
|
cut = scad.cut_rsolid(
|
|
body,
|
|
cutter,
|
|
tracking_policy=scad.TrackingPolicy.GRAPH,
|
|
)
|
|
|
|
with mock.patch.object(
|
|
operations,
|
|
"fuse_shapes_with_history",
|
|
side_effect=AssertionError("GRAPH union requested topology history"),
|
|
), mock.patch.object(
|
|
operations,
|
|
"track_union_history",
|
|
side_effect=AssertionError("GRAPH union queried topology history"),
|
|
):
|
|
fused = scad.union_rsolid(
|
|
body,
|
|
rib,
|
|
glue=False,
|
|
tracking_policy=scad.TrackingPolicy.GRAPH,
|
|
)
|
|
|
|
self.assertLess(cut.get_volume(), body.get_volume())
|
|
self.assertGreater(fused.get_volume(), body.get_volume())
|
|
|
|
def test_sweep_produces_topology_delta_at_runtime(self):
|
|
profile = scad.make_circle_rface((0.0, 0.0, 0.0), 0.5)
|
|
path = scad.make_segment_rwire((0.0, 0.0, 0.0), (0.0, 0.0, 3.0))
|
|
with GraphSession() as session:
|
|
solid = scad.sweep_rsolid(profile, path)
|
|
|
|
self.assertIsInstance(solid, scad.Solid)
|
|
leaf = session.graph.leaf_nodes()[0]
|
|
self.assertEqual(leaf.op, "make_sweep_rsolid")
|
|
self.assertIsNotNone(leaf.topo_delta)
|
|
self.assertGreaterEqual(
|
|
len(leaf.topo_delta.modified)
|
|
+ len(leaf.topo_delta.generated)
|
|
+ len(leaf.topo_delta.deleted),
|
|
1,
|
|
)
|
|
|
|
def test_sweep_topology_delta_survives_graph_json_roundtrip(self):
|
|
profile = scad.make_circle_rface((0.0, 0.0, 0.0), 0.5)
|
|
path = scad.make_segment_rwire((0.0, 0.0, 0.0), (0.0, 0.0, 3.0))
|
|
with GraphSession() as session:
|
|
scad.sweep_rsolid(profile, path)
|
|
|
|
restored = scad.import_graph_json(scad.export_graph_json(session.graph))
|
|
leaf = restored.leaf_nodes()[0]
|
|
|
|
self.assertEqual(leaf.op, "make_sweep_rsolid")
|
|
self.assertIsNotNone(leaf.topo_delta)
|
|
self.assertGreaterEqual(
|
|
len(leaf.topo_delta.modified)
|
|
+ len(leaf.topo_delta.generated)
|
|
+ len(leaf.topo_delta.deleted),
|
|
1,
|
|
)
|
|
|
|
def test_twisted_sweep_produces_replayable_topology_delta(self):
|
|
with GraphSession() as session:
|
|
profile = scad.make_rectangle_rface(width=2.0, height=1.0)
|
|
solid = scad.twisted_sweep_rsolid(
|
|
profile=profile,
|
|
distance=5.0,
|
|
twist_angle=-45.0,
|
|
)
|
|
session.capture_result(value=solid)
|
|
|
|
node = next(
|
|
item
|
|
for item in session.graph.nodes
|
|
if item.op == "make_twisted_sweep_rsolid"
|
|
)
|
|
self.assertIsNotNone(node.topo_delta)
|
|
self.assertEqual(len(solid.get_faces()), 6)
|
|
|
|
replayed = scad.replay_model_json(
|
|
scad.export_model_json(session), strict=True
|
|
)[0]
|
|
self.assertEqual(len(replayed.get_faces()), 6)
|
|
self.assertAlmostEqual(replayed.get_volume(), solid.get_volume(), places=8)
|
|
|
|
def test_twisted_sweep_supports_expression_parameters_and_arbitrary_axis(self):
|
|
distance = scad.var("twisted_distance", 5.0)
|
|
angle = scad.var("twisted_angle", 45.0)
|
|
with GraphSession() as session:
|
|
profile = scad.make_rectangle_rface(
|
|
width=2.0,
|
|
height=1.0,
|
|
normal=(1.0, 1.0, 1.0),
|
|
)
|
|
solid = scad.twisted_sweep_rsolid(
|
|
profile=profile,
|
|
distance=distance,
|
|
twist_angle=angle,
|
|
axis=(1.0, 1.0, 1.0),
|
|
)
|
|
session.capture_result(value=solid)
|
|
|
|
node = next(
|
|
item
|
|
for item in session.graph.nodes
|
|
if item.op == "make_twisted_sweep_rsolid"
|
|
)
|
|
self.assertEqual(set(node.param_exprs), {"distance", "twist_angle"})
|
|
self.assertEqual(len(solid.get_faces()), 6)
|
|
|
|
replayed = scad.replay_model_json(
|
|
scad.export_model_json(session), strict=True
|
|
)[0]
|
|
self.assertAlmostEqual(replayed.get_volume(), solid.get_volume(), places=8)
|
|
|
|
def test_twisted_sweep_rejects_inner_wires_and_invalid_axis(self):
|
|
outer = scad.make_circle_rface(center=(0.0, 0.0, 0.0), radius=2.0)
|
|
inner = scad.make_circle_rface(center=(0.0, 0.0, 0.0), radius=1.0)
|
|
ring = scad.make_2d_cut_rface(body=outer, tool=inner)
|
|
|
|
with self.assertRaises(scad.SimpleCADError):
|
|
scad.twisted_sweep_rsolid(
|
|
profile=ring,
|
|
distance=2.0,
|
|
twist_angle=20.0,
|
|
)
|
|
with self.assertRaises(scad.SimpleCADError):
|
|
scad.twisted_sweep_rsolid(
|
|
profile=outer,
|
|
distance=2.0,
|
|
twist_angle=20.0,
|
|
axis=(0.0, 0.0, 0.0),
|
|
)
|
|
with self.assertRaises(scad.SimpleCADError):
|
|
scad.twisted_sweep_rsolid(
|
|
profile=outer,
|
|
distance=2.0,
|
|
twist_angle=20.0,
|
|
axis=(1.0, 0.0, 0.0),
|
|
)
|
|
offset = scad.translate_shape(outer, vector=(0.0, 0.0, 1.0))
|
|
with self.assertRaises(scad.SimpleCADError):
|
|
scad.twisted_sweep_rsolid(
|
|
profile=offset,
|
|
distance=2.0,
|
|
twist_angle=20.0,
|
|
)
|
|
|
|
def test_fillet_accepts_expression_radius_and_records_param_expr(self):
|
|
radius = scad.var("fillet_r", 0.2)
|
|
with GraphSession() as session:
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
result = scad.fillet_rsolid(box, [box.get_edges(i) for i in range(4)], radius)
|
|
|
|
self.assertIsInstance(result, scad.Solid)
|
|
leaf = session.graph.leaf_nodes()[0]
|
|
self.assertEqual(leaf.op, "make_fillet_rsolid")
|
|
self.assertIn("radius", leaf.param_exprs)
|
|
|
|
def test_chamfer_accepts_expression_distance_and_records_param_expr(self):
|
|
distance = scad.var("chamfer_d", 0.2)
|
|
with GraphSession() as session:
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
result = scad.chamfer_rsolid(box, [box.get_edges(i) for i in range(4)], distance)
|
|
|
|
self.assertIsInstance(result, scad.Solid)
|
|
leaf = session.graph.leaf_nodes()[0]
|
|
self.assertEqual(leaf.op, "make_chamfer_rsolid")
|
|
self.assertIn("distance", leaf.param_exprs)
|
|
|
|
def test_shell_accepts_expression_thickness_and_records_param_expr(self):
|
|
thickness = scad.var("shell_t", 0.2)
|
|
with GraphSession() as session:
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
result = scad.shell_rsolid(box, [box.get_faces(0)], thickness)
|
|
|
|
self.assertIsInstance(result, scad.Solid)
|
|
leaf = session.graph.leaf_nodes()[0]
|
|
self.assertEqual(leaf.op, "make_shell_rsolid")
|
|
self.assertIn("thickness", leaf.param_exprs)
|
|
|
|
def test_shell_produces_topology_delta_at_runtime(self):
|
|
with GraphSession() as session:
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
result = scad.shell_rsolid(box, [box.get_faces(0)], 0.2)
|
|
|
|
self.assertIsInstance(result, scad.Solid)
|
|
leaf = session.graph.leaf_nodes()[0]
|
|
self.assertEqual(leaf.op, "make_shell_rsolid")
|
|
self.assertIsNotNone(leaf.topo_delta)
|
|
self.assertGreaterEqual(
|
|
len(leaf.topo_delta.modified)
|
|
+ len(leaf.topo_delta.generated)
|
|
+ len(leaf.topo_delta.deleted),
|
|
1,
|
|
)
|
|
|
|
def test_shell_topology_delta_survives_graph_json_roundtrip(self):
|
|
with GraphSession() as session:
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
scad.shell_rsolid(box, [box.get_faces(0)], 0.2)
|
|
|
|
restored = scad.import_graph_json(scad.export_graph_json(session.graph))
|
|
leaf = restored.leaf_nodes()[0]
|
|
|
|
self.assertEqual(leaf.op, "make_shell_rsolid")
|
|
self.assertIsNotNone(leaf.topo_delta)
|
|
self.assertGreaterEqual(
|
|
len(leaf.topo_delta.modified)
|
|
+ len(leaf.topo_delta.generated)
|
|
+ len(leaf.topo_delta.deleted),
|
|
1,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|