870 lines
32 KiB
Python
870 lines
32 KiB
Python
"""Kernel-backed feature output roles, tagging, projection, and replay."""
|
|
|
|
import json
|
|
import unittest
|
|
from copy import deepcopy
|
|
|
|
import simplecadapi as scad
|
|
from simplecadapi import ql as Q
|
|
|
|
|
|
def _role_count(shape, role):
|
|
candidates = shape.get_edges() if role == "shell.wall" else shape.get_faces()
|
|
return sum(Q.output_role(role)(item) for item in candidates)
|
|
|
|
|
|
class TestOperationOutputRoles(unittest.TestCase):
|
|
def test_feature_role_matrix_is_kernel_backed(self):
|
|
profile = scad.make_rectangle_rface(2.0, 1.0)
|
|
extruded = scad.extrude_rsolid(profile, (0, 0, 1), 2.0)
|
|
self.assertEqual(_role_count(extruded, "extrusion.start"), 1)
|
|
self.assertEqual(_role_count(extruded, "extrusion.end"), 1)
|
|
self.assertEqual(_role_count(extruded, "extrusion.side"), 4)
|
|
|
|
revolve_profile = scad.make_rectangle_rface(2.0, 1.0, center=(3, 0, 0))
|
|
revolved = scad.revolve_rsolid(
|
|
revolve_profile, (0, 1, 0), 180.0, (0, 0, 0)
|
|
)
|
|
self.assertEqual(_role_count(revolved, "revolution.start"), 1)
|
|
self.assertEqual(_role_count(revolved, "revolution.end"), 1)
|
|
self.assertEqual(_role_count(revolved, "revolution.side"), 4)
|
|
|
|
first = scad.make_rectangle_rwire(2.0, 2.0)
|
|
last = scad.make_rectangle_rwire(1.0, 1.0, center=(0, 0, 2))
|
|
lofted = scad.loft_rsolid([first, last])
|
|
self.assertEqual(_role_count(lofted, "loft.start"), 1)
|
|
self.assertEqual(_role_count(lofted, "loft.end"), 1)
|
|
self.assertEqual(_role_count(lofted, "loft.side"), 4)
|
|
|
|
sweep_profile = scad.make_rectangle_rface(1.0, 1.0)
|
|
path = scad.make_segment_rwire((0, 0, 0), (0, 0, 3))
|
|
swept = scad.sweep_rsolid(sweep_profile, path)
|
|
self.assertEqual(_role_count(swept, "sweep.start"), 1)
|
|
self.assertEqual(_role_count(swept, "sweep.end"), 1)
|
|
self.assertEqual(_role_count(swept, "sweep.side"), 4)
|
|
|
|
twisted = scad.twisted_sweep_rsolid(
|
|
profile=sweep_profile,
|
|
distance=3.0,
|
|
twist_angle=45.0,
|
|
)
|
|
self.assertEqual(_role_count(twisted, "twisted_sweep.start"), 1)
|
|
self.assertEqual(_role_count(twisted, "twisted_sweep.end"), 1)
|
|
self.assertEqual(_role_count(twisted, "twisted_sweep.side"), 4)
|
|
|
|
def test_revolve_and_sweep_topology_tags_require_exact_side_correspondence(self):
|
|
revolve_profile = scad.make_rectangle_rface(
|
|
2.0,
|
|
1.0,
|
|
center=(3, 0, 0),
|
|
edge_tags=("a", "b", "c", "d"),
|
|
)
|
|
revolved = scad.revolve_rsolid(
|
|
revolve_profile,
|
|
(0, 1, 0),
|
|
180.0,
|
|
(0, 0, 0),
|
|
tag_prefix="revolved",
|
|
)
|
|
self.assertEqual(
|
|
{
|
|
tag
|
|
for face in revolved.get_faces()
|
|
for tag in scad.list_tags(face, scope="local")
|
|
if tag.startswith("revolved.face.side.")
|
|
},
|
|
{
|
|
"revolved.face.side.a",
|
|
"revolved.face.side.b",
|
|
"revolved.face.side.c",
|
|
"revolved.face.side.d",
|
|
},
|
|
)
|
|
|
|
profile = scad.make_rectangle_rface(
|
|
1.0, 1.0, edge_tags=("a", "b", "c", "d")
|
|
)
|
|
path = scad.make_segment_rwire((0, 0, 0), (0, 0, 3))
|
|
swept = scad.sweep_rsolid(profile, path, tag_prefix="swept")
|
|
self.assertEqual(
|
|
{
|
|
tag
|
|
for face in swept.get_faces()
|
|
for tag in scad.list_tags(face, scope="local")
|
|
if tag.startswith("swept.face.side.")
|
|
},
|
|
{
|
|
"swept.face.side.a",
|
|
"swept.face.side.b",
|
|
"swept.face.side.c",
|
|
"swept.face.side.d",
|
|
},
|
|
)
|
|
|
|
def test_loft_does_not_guess_topology_tags_for_merged_side_faces(self):
|
|
first = scad.make_rectangle_rwire(
|
|
2.0,
|
|
2.0,
|
|
edge_tags=("first_a", "first_b", "first_c", "first_d"),
|
|
)
|
|
last = scad.make_rectangle_rwire(
|
|
1.0,
|
|
1.0,
|
|
center=(0, 0, 2),
|
|
edge_tags=("last_a", "last_b", "last_c", "last_d"),
|
|
)
|
|
lofted = scad.loft_rsolid([first, last], tag_prefix="lofted")
|
|
|
|
side_tags = {
|
|
tag
|
|
for face in lofted.get_faces()
|
|
for tag in scad.list_tags(face, scope="local")
|
|
if tag == "lofted.face.side" or tag.startswith("lofted.face.side.")
|
|
}
|
|
self.assertEqual(side_tags, {"lofted.face.side"})
|
|
|
|
def test_native_cylinder_has_kernel_face_and_edge_roles(self):
|
|
cylinder = scad.make_cylinder_rsolid(2.0, 5.0, tag_prefix="shaft")
|
|
for role in ("cylinder.start", "cylinder.end", "cylinder.side"):
|
|
self.assertEqual(
|
|
sum(Q.output_role(role)(face) for face in cylinder.get_faces()), 1
|
|
)
|
|
for role in (
|
|
"cylinder.start_boundary",
|
|
"cylinder.end_boundary",
|
|
"cylinder.seam",
|
|
):
|
|
self.assertEqual(
|
|
sum(Q.output_role(role)(edge) for edge in cylinder.get_edges()), 1
|
|
)
|
|
self.assertEqual(
|
|
len(Q.faces().where(Q.tag("shaft.face.start")).resolve(cylinder)), 1
|
|
)
|
|
self.assertEqual(
|
|
len(Q.edges().where(Q.tag("shaft.face.start")).resolve(cylinder)), 1
|
|
)
|
|
|
|
def test_native_box_has_kernel_face_roles_and_exact_topology_tags(self):
|
|
box = scad.make_box_rsolid(2.0, 3.0, 4.0, tag_prefix="housing")
|
|
for role in (
|
|
"box.bottom",
|
|
"box.top",
|
|
"box.front",
|
|
"box.back",
|
|
"box.left",
|
|
"box.right",
|
|
):
|
|
self.assertEqual(
|
|
sum(Q.output_role(role)(face) for face in box.get_faces()), 1
|
|
)
|
|
|
|
top = Q.faces().where(Q.tag("housing.face.top"))
|
|
front = Q.faces().where(Q.tag("housing.face.front"))
|
|
self.assertEqual(len(top.resolve(box)), 1)
|
|
self.assertEqual(len(front.resolve(box)), 1)
|
|
self.assertEqual(
|
|
len(Q.edges().incident_to(top, front, distinct=True).resolve(box)), 1
|
|
)
|
|
|
|
def test_native_box_roles_and_tags_replay(self):
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(
|
|
2.0,
|
|
3.0,
|
|
4.0,
|
|
tag_prefix="housing",
|
|
bottom_face_tag="anchor.base",
|
|
right_face_tag="role.mounting_surface",
|
|
result_tag="part.housing",
|
|
)
|
|
|
|
payload = json.loads(scad.export_model_json(session))
|
|
primitive = next(
|
|
node
|
|
for node in payload["graph"]["nodes"]
|
|
if node["op"] == "make_box_rsolid"
|
|
)
|
|
self.assertNotIn("name", primitive["params"])
|
|
self.assertEqual(len(primitive["topo_delta"]["roles"]), 6)
|
|
|
|
replayed = scad.replay_model_json(json.dumps(payload))[0]
|
|
for shape in (box, replayed):
|
|
self.assertEqual(
|
|
len(Q.faces().where(Q.tag("housing.face.bottom")).resolve(shape)),
|
|
1,
|
|
)
|
|
self.assertEqual(
|
|
len(Q.faces().where(Q.tag("anchor.base")).resolve(shape)), 1
|
|
)
|
|
self.assertEqual(
|
|
len(
|
|
Q.faces()
|
|
.where(Q.tag("role.mounting_surface"))
|
|
.resolve(shape)
|
|
),
|
|
1,
|
|
)
|
|
self.assertIn("part.housing", scad.list_tags(shape, scope="local"))
|
|
self.assertEqual(
|
|
sum(
|
|
Q.output_role("box.right")(face)
|
|
for face in shape.get_faces()
|
|
),
|
|
1,
|
|
)
|
|
|
|
def test_generic_role_tag_mapping_is_not_a_public_entrypoint(self):
|
|
with self.assertRaises(TypeError):
|
|
scad.make_box_rsolid(
|
|
2.0,
|
|
3.0,
|
|
4.0,
|
|
**{"output_tags": {"box.top_front_edge": "role.edge"}},
|
|
)
|
|
|
|
def test_removed_topology_name_keywords_are_not_public_entrypoints(self):
|
|
with self.assertRaises(TypeError):
|
|
scad.make_box_rsolid(2.0, 3.0, 4.0, **{"name": "housing"})
|
|
with self.assertRaises(TypeError):
|
|
scad.make_rectangle_rface(
|
|
2.0,
|
|
1.0,
|
|
**{"edge_names": ("bottom", "right", "top", "left")},
|
|
)
|
|
with self.assertRaises(TypeError):
|
|
scad.make_circle_rface(
|
|
(0.0, 0.0, 0.0),
|
|
1.0,
|
|
**{"edge_name": "outer"},
|
|
)
|
|
|
|
def test_native_cone_and_frustum_have_kernel_roles(self):
|
|
cone = scad.make_cone_rsolid(2.0, 4.0, tag_prefix="tip")
|
|
frustum = scad.make_cone_rsolid(
|
|
2.0, 4.0, top_radius=1.0, tag_prefix="adapter"
|
|
)
|
|
|
|
for shape, end_count in ((cone, 0), (frustum, 1)):
|
|
self.assertEqual(_role_count(shape, "cone.start"), 1)
|
|
self.assertEqual(_role_count(shape, "cone.end"), end_count)
|
|
self.assertEqual(_role_count(shape, "cone.side"), 1)
|
|
for role in (
|
|
"cone.start_boundary",
|
|
"cone.end_boundary",
|
|
"cone.seam",
|
|
):
|
|
self.assertEqual(
|
|
sum(Q.output_role(role)(edge) for edge in shape.get_edges()), 1
|
|
)
|
|
|
|
self.assertEqual(
|
|
len(Q.faces().where(Q.tag("tip.face.start")).resolve(cone)), 1
|
|
)
|
|
self.assertEqual(
|
|
len(Q.faces().where(Q.tag("tip.face.end")).resolve(cone)), 0
|
|
)
|
|
self.assertEqual(
|
|
len(Q.edges().where(Q.tag("tip.edge.end")).resolve(cone)), 1
|
|
)
|
|
self.assertEqual(
|
|
len(Q.faces().where(Q.tag("adapter.face.end")).resolve(frustum)), 1
|
|
)
|
|
|
|
def test_native_cone_roles_and_tags_replay(self):
|
|
with scad.GraphSession() as session:
|
|
cone = scad.make_cone_rsolid(
|
|
2.0,
|
|
4.0,
|
|
top_radius=1.0,
|
|
tag_prefix="adapter",
|
|
start_face_tag="anchor.base",
|
|
end_face_tag="role.outlet",
|
|
seam_edge_tag="role.seam",
|
|
result_tag="part.adapter",
|
|
)
|
|
|
|
payload = json.loads(scad.export_model_json(session))
|
|
primitive = next(
|
|
node
|
|
for node in payload["graph"]["nodes"]
|
|
if node["op"] == "make_cone_rsolid"
|
|
)
|
|
self.assertNotIn("name", primitive["params"])
|
|
self.assertEqual(len(primitive["topo_delta"]["roles"]), 6)
|
|
|
|
replayed = scad.replay_model_json(json.dumps(payload))[0]
|
|
for shape in (cone, replayed):
|
|
self.assertEqual(
|
|
len(Q.faces().where(Q.tag("adapter.face.end")).resolve(shape)), 1
|
|
)
|
|
self.assertEqual(
|
|
len(Q.faces().where(Q.tag("role.outlet")).resolve(shape)), 1
|
|
)
|
|
self.assertEqual(
|
|
len(Q.edges().where(Q.tag("role.seam")).resolve(shape)), 1
|
|
)
|
|
self.assertIn("part.adapter", scad.list_tags(shape, scope="local"))
|
|
self.assertEqual(
|
|
sum(
|
|
Q.output_role("cone.end_boundary")(edge)
|
|
for edge in shape.get_edges()
|
|
),
|
|
1,
|
|
)
|
|
|
|
def test_pointed_cone_rejects_absent_end_face_tag(self):
|
|
with self.assertRaisesRegex(
|
|
scad.SimpleCADError, "requires exactly one kernel-proven result, got 0"
|
|
):
|
|
scad.make_cone_rsolid(2.0, 4.0, end_face_tag="role.outlet")
|
|
|
|
def test_native_cylinder_roles_and_tags_replay(self):
|
|
with scad.GraphSession() as session:
|
|
cylinder = scad.make_cylinder_rsolid(
|
|
2.0,
|
|
5.0,
|
|
tag_prefix="shaft",
|
|
start_face_tag="role.base",
|
|
seam_edge_tag="role.seam",
|
|
result_tag="part.shaft",
|
|
)
|
|
|
|
payload = json.loads(scad.export_model_json(session))
|
|
primitive = next(
|
|
node
|
|
for node in payload["graph"]["nodes"]
|
|
if node["op"] == "make_cylinder_rsolid"
|
|
)
|
|
self.assertNotIn("name", primitive["params"])
|
|
self.assertTrue(
|
|
any(
|
|
node["params"]["tag_binding"]["evidence"].get("topology_name")
|
|
for node in payload["graph"]["nodes"]
|
|
if node["op"] == "apply_tag_rselection"
|
|
)
|
|
)
|
|
|
|
replayed = scad.replay_model_json(json.dumps(payload))[0]
|
|
for shape in (cylinder, replayed):
|
|
self.assertEqual(
|
|
len(Q.faces().where(Q.tag("shaft.face.start")).resolve(shape)), 1
|
|
)
|
|
self.assertEqual(
|
|
len(Q.faces().where(Q.tag("role.base")).resolve(shape)), 1
|
|
)
|
|
self.assertEqual(
|
|
len(Q.edges().where(Q.tag("role.seam")).resolve(shape)), 1
|
|
)
|
|
self.assertEqual(
|
|
len(Q.edges().where(Q.tag("shaft.edge.seam")).resolve(shape)), 1
|
|
)
|
|
self.assertEqual(
|
|
sum(
|
|
Q.output_role("cylinder.seam")(edge)
|
|
for edge in shape.get_edges()
|
|
),
|
|
1,
|
|
)
|
|
|
|
def test_detail_feature_role_matrix_is_kernel_backed(self):
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
edge = box.get_edges(0)
|
|
filleted = scad.fillet_rsolid(box, [edge], 0.2)
|
|
chamfered = scad.chamfer_rsolid(box, [edge], 0.2)
|
|
self.assertGreaterEqual(_role_count(filleted, "fillet.patch"), 1)
|
|
self.assertGreaterEqual(_role_count(chamfered, "chamfer.patch"), 1)
|
|
|
|
shelled = scad.shell_rsolid(box, [box.get_faces(0)], 0.2)
|
|
self.assertEqual(_role_count(shelled, "shell.offset_face"), 5)
|
|
self.assertEqual(_role_count(shelled, "shell.closing_descendant"), 1)
|
|
self.assertEqual(_role_count(shelled, "shell.wall"), 4)
|
|
|
|
def test_full_revolve_has_no_cap_roles_and_rejects_cap_tag(self):
|
|
profile = scad.make_rectangle_rface(2.0, 1.0, center=(3, 0, 0))
|
|
revolved = scad.revolve_rsolid(
|
|
profile, (0, 1, 0), 360.0, (0, 0, 0)
|
|
)
|
|
self.assertEqual(_role_count(revolved, "revolution.start"), 0)
|
|
self.assertEqual(_role_count(revolved, "revolution.end"), 0)
|
|
|
|
with self.assertRaisesRegex(
|
|
scad.SimpleCADError, "requires exactly one kernel-proven result, got 0"
|
|
):
|
|
scad.revolve_rsolid(
|
|
profile,
|
|
(0, 1, 0),
|
|
360.0,
|
|
(0, 0, 0),
|
|
start_face_tag="role.start",
|
|
)
|
|
|
|
def test_unavailable_shell_role_fails_instead_of_guessing(self):
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
with self.assertRaisesRegex(
|
|
scad.SimpleCADError, "shell.body_face.*kernel-proven"
|
|
):
|
|
scad.shell_rsolid(
|
|
box,
|
|
[box.get_faces(0)],
|
|
0.2,
|
|
body_faces_tag="group.body",
|
|
)
|
|
|
|
def test_sweep_rejects_profiles_with_inner_wires(self):
|
|
outer = scad.make_rectangle_rwire(3.0, 3.0)
|
|
inner = scad.make_rectangle_rwire(1.0, 1.0, center=(1.0, 1.0, 0.0))
|
|
profile = scad.make_face_from_wires_rface(outer, [inner])
|
|
path = scad.make_segment_rwire((0, 0, 0), (0, 0, 3))
|
|
|
|
with self.assertRaisesRegex(scad.SimpleCADError, "inner wires are unsupported"):
|
|
scad.sweep_rsolid(profile, path)
|
|
|
|
|
|
class TestOperationRoleTags(unittest.TestCase):
|
|
@staticmethod
|
|
def _binding_nodes(payload):
|
|
return [
|
|
node
|
|
for node in payload["graph"]["nodes"]
|
|
if node["op"] == "apply_tag_rselection"
|
|
]
|
|
|
|
def test_named_role_tags_lower_to_role_aware_semantic_nodes(self):
|
|
with scad.GraphSession() as session:
|
|
profile = scad.make_rectangle_rface(5.0, 3.0)
|
|
result = scad.extrude_rsolid(
|
|
profile,
|
|
(0, 0, 1),
|
|
2.0,
|
|
start_face_tag="anchor.base",
|
|
end_face_tag="role.mounting_surface",
|
|
side_faces_tag="group.outer_walls",
|
|
result_tag="part.body",
|
|
)
|
|
|
|
self.assertEqual(
|
|
len(scad.select_faces_by_tag(result, "anchor.base", scope="local")), 1
|
|
)
|
|
self.assertEqual(
|
|
len(
|
|
scad.select_faces_by_tag(
|
|
result, "role.mounting_surface", scope="local"
|
|
)
|
|
),
|
|
1,
|
|
)
|
|
self.assertEqual(
|
|
len(
|
|
scad.select_faces_by_tag(
|
|
result, "group.outer_walls", scope="local"
|
|
)
|
|
),
|
|
4,
|
|
)
|
|
self.assertIn("part.body", scad.list_tags(result, scope="local"))
|
|
|
|
payload = json.loads(scad.export_model_json(session))
|
|
feature = next(
|
|
node
|
|
for node in payload["graph"]["nodes"]
|
|
if node["op"] == "make_extrude_rsolid"
|
|
)
|
|
self.assertNotIn("result_tag", feature["params"])
|
|
|
|
binding_nodes = self._binding_nodes(payload)
|
|
self.assertEqual(len(binding_nodes), 4)
|
|
role_evidence = [
|
|
node["params"]["tag_binding"]["evidence"].get(
|
|
"operation_output_role"
|
|
)
|
|
for node in binding_nodes
|
|
]
|
|
self.assertEqual(
|
|
[item["role"] for item in role_evidence if item is not None],
|
|
["extrusion.start", "extrusion.end", "extrusion.side"],
|
|
)
|
|
self.assertTrue(
|
|
all(
|
|
node["params"]["tag_binding"]["producer"]["kind"]
|
|
== "user_operation"
|
|
for node in binding_nodes
|
|
)
|
|
)
|
|
expected_binding_ids = {
|
|
node["params"]["tag_binding"]["binding_id"] for node in binding_nodes
|
|
}
|
|
|
|
replayed = scad.replay_model_json(json.dumps(payload))[0]
|
|
self.assertEqual(
|
|
len(
|
|
scad.select_faces_by_tag(
|
|
replayed, "group.outer_walls", scope="local"
|
|
)
|
|
),
|
|
4,
|
|
)
|
|
actual_binding_ids = {
|
|
explanation["binding_id"]
|
|
for shape, tag in [
|
|
(replayed, "part.body"),
|
|
*[
|
|
(face, tag)
|
|
for tag in (
|
|
"anchor.base",
|
|
"role.mounting_surface",
|
|
"group.outer_walls",
|
|
)
|
|
for face in scad.select_faces_by_tag(replayed, tag, scope="local")
|
|
],
|
|
]
|
|
for explanation in scad.explain_tag(shape, tag, scope="local")
|
|
}
|
|
self.assertEqual(actual_binding_ids, expected_binding_ids)
|
|
|
|
def test_shell_tags_face_and_edge_roles_and_replays(self):
|
|
with scad.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,
|
|
offset_faces_tag="group.offset",
|
|
closing_faces_tag="group.closing",
|
|
wall_edges_tag="group.wall",
|
|
)
|
|
|
|
self.assertEqual(
|
|
len(scad.select_faces_by_tag(result, "group.offset", scope="local")), 5
|
|
)
|
|
self.assertEqual(
|
|
len(scad.select_faces_by_tag(result, "group.closing", scope="local")),
|
|
1,
|
|
)
|
|
self.assertEqual(
|
|
len(Q.edges().where(Q.tag("group.wall", scope="local")).resolve(result)),
|
|
4,
|
|
)
|
|
|
|
replayed = scad.replay_model_json(scad.export_model_json(session))[0]
|
|
self.assertEqual(
|
|
len(Q.edges().where(Q.tag("group.wall", scope="local")).resolve(replayed)),
|
|
4,
|
|
)
|
|
|
|
def test_remaining_feature_output_tag_surfaces_replay(self):
|
|
cases = []
|
|
|
|
with scad.GraphSession() as session:
|
|
profile = scad.make_rectangle_rface(2.0, 1.0, center=(3, 0, 0))
|
|
result = scad.revolve_rsolid(
|
|
profile,
|
|
(0, 1, 0),
|
|
180.0,
|
|
(0, 0, 0),
|
|
start_face_tag="test.revolve.start",
|
|
end_face_tag="test.revolve.end",
|
|
side_faces_tag="test.revolve.side",
|
|
)
|
|
cases.append(
|
|
(
|
|
"revolve",
|
|
result,
|
|
scad.export_model_json(session),
|
|
{
|
|
"test.revolve.start": 1,
|
|
"test.revolve.end": 1,
|
|
"test.revolve.side": 4,
|
|
},
|
|
)
|
|
)
|
|
|
|
with scad.GraphSession() as session:
|
|
profile = scad.make_rectangle_rface(1.0, 1.0)
|
|
result = scad.twisted_sweep_rsolid(
|
|
profile=profile,
|
|
distance=3.0,
|
|
twist_angle=30.0,
|
|
start_face_tag="test.twisted.start",
|
|
end_face_tag="test.twisted.end",
|
|
side_faces_tag="test.twisted.side",
|
|
)
|
|
cases.append(
|
|
(
|
|
"twisted_sweep",
|
|
result,
|
|
scad.export_model_json(session),
|
|
{
|
|
"test.twisted.start": 1,
|
|
"test.twisted.end": 1,
|
|
"test.twisted.side": 4,
|
|
},
|
|
)
|
|
)
|
|
|
|
with scad.GraphSession() as session:
|
|
first = scad.make_rectangle_rwire(2.0, 2.0)
|
|
last = scad.make_rectangle_rwire(1.0, 1.0, center=(0, 0, 2))
|
|
result = scad.loft_rsolid(
|
|
[first, last],
|
|
start_face_tag="test.loft.start",
|
|
end_face_tag="test.loft.end",
|
|
side_faces_tag="test.loft.side",
|
|
)
|
|
cases.append(
|
|
(
|
|
"loft",
|
|
result,
|
|
scad.export_model_json(session),
|
|
{
|
|
"test.loft.start": 1,
|
|
"test.loft.end": 1,
|
|
"test.loft.side": 4,
|
|
},
|
|
)
|
|
)
|
|
|
|
with scad.GraphSession() as session:
|
|
profile = scad.make_rectangle_rface(1.0, 1.0)
|
|
path = scad.make_segment_rwire((0, 0, 0), (0, 0, 3))
|
|
result = scad.sweep_rsolid(
|
|
profile,
|
|
path,
|
|
start_face_tag="test.sweep.start",
|
|
end_face_tag="test.sweep.end",
|
|
side_faces_tag="test.sweep.side",
|
|
)
|
|
cases.append(
|
|
(
|
|
"sweep",
|
|
result,
|
|
scad.export_model_json(session),
|
|
{
|
|
"test.sweep.start": 1,
|
|
"test.sweep.end": 1,
|
|
"test.sweep.side": 4,
|
|
},
|
|
)
|
|
)
|
|
|
|
for operation, feature, payload, expected in cases:
|
|
replayed = scad.replay_model_json(payload)[0]
|
|
for tag, count in expected.items():
|
|
with self.subTest(operation=operation, tag=tag):
|
|
authored = scad.select_faces_by_tag(feature, tag, scope="local")
|
|
rebuilt = scad.select_faces_by_tag(replayed, tag, scope="local")
|
|
self.assertEqual(len(authored), count)
|
|
self.assertEqual(len(rebuilt), count)
|
|
self.assertEqual(
|
|
{
|
|
item["binding_id"]
|
|
for face in authored
|
|
for item in scad.explain_tag(face, tag, scope="local")
|
|
},
|
|
{
|
|
item["binding_id"]
|
|
for face in rebuilt
|
|
for item in scad.explain_tag(face, tag, scope="local")
|
|
},
|
|
)
|
|
|
|
def test_fillet_and_chamfer_patch_tags_replay(self):
|
|
for operation in ("fillet", "chamfer"):
|
|
with self.subTest(operation=operation):
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
edge = box.get_edges(0)
|
|
if operation == "fillet":
|
|
result = scad.fillet_rsolid(
|
|
box,
|
|
[edge],
|
|
0.2,
|
|
generated_faces_tag="test.fillet.patch",
|
|
)
|
|
tag = "test.fillet.patch"
|
|
else:
|
|
result = scad.chamfer_rsolid(
|
|
box,
|
|
[edge],
|
|
0.2,
|
|
generated_faces_tag="test.chamfer.patch",
|
|
)
|
|
tag = "test.chamfer.patch"
|
|
|
|
authored = scad.select_faces_by_tag(result, tag, scope="local")
|
|
replayed = scad.replay_model_json(scad.export_model_json(session))[0]
|
|
rebuilt = scad.select_faces_by_tag(replayed, tag, scope="local")
|
|
self.assertGreaterEqual(len(authored), 1)
|
|
self.assertEqual(len(rebuilt), len(authored))
|
|
self.assertEqual(
|
|
{
|
|
item["binding_id"]
|
|
for face in authored
|
|
for item in scad.explain_tag(face, tag, scope="local")
|
|
},
|
|
{
|
|
item["binding_id"]
|
|
for face in rebuilt
|
|
for item in scad.explain_tag(face, tag, scope="local")
|
|
},
|
|
)
|
|
|
|
def test_generic_role_tag_mapping_is_not_a_public_entrypoint(self):
|
|
profile = scad.make_rectangle_rface(2.0, 1.0)
|
|
with self.assertRaises(TypeError):
|
|
scad.extrude_rsolid(
|
|
profile,
|
|
(0, 0, 1),
|
|
1.0,
|
|
**{"output_tags": {"extrusion.end": "role.end"}},
|
|
)
|
|
with self.assertRaisesRegex(scad.SimpleCADError, "is not normalized"):
|
|
scad.extrude_rsolid(
|
|
profile,
|
|
(0, 0, 1),
|
|
1.0,
|
|
end_face_tag="Not Normalized",
|
|
)
|
|
|
|
def test_topology_and_role_tags_share_surface_with_distinct_evidence(self):
|
|
with scad.GraphSession():
|
|
box = scad.make_box_rsolid(
|
|
2.0,
|
|
3.0,
|
|
4.0,
|
|
tag_prefix="housing",
|
|
top_face_tag="role.cover",
|
|
result_tag="part.housing",
|
|
)
|
|
|
|
top = Q.faces().where(Q.tag("housing.face.top")).exactly(1).resolve(box)[0]
|
|
topology_binding = scad.explain_tag(
|
|
top, "housing.face.top", scope="local"
|
|
)[0]["binding"]
|
|
role_binding = scad.explain_tag(
|
|
top, "role.cover", scope="local"
|
|
)[0]["binding"]
|
|
result_binding = scad.explain_tag(
|
|
box, "part.housing", scope="local"
|
|
)[0]["binding"]
|
|
|
|
self.assertIn("topology_name", topology_binding["evidence"])
|
|
self.assertNotIn("operation_output_role", topology_binding["evidence"])
|
|
self.assertIn("operation_output_role", role_binding["evidence"])
|
|
self.assertNotIn("topology_name", role_binding["evidence"])
|
|
self.assertIn("operation_result", result_binding["evidence"])
|
|
self.assertNotIn("topology_name", result_binding["evidence"])
|
|
|
|
def test_profile_edge_binding_projects_with_exact_source_identity(self):
|
|
with scad.GraphSession() as session:
|
|
profile = scad.make_rectangle_rface(5.0, 3.0)
|
|
source_edge = profile.get_edges(0)
|
|
profile = scad.apply_tag_rselection(
|
|
profile, [source_edge], "role.source_edge"
|
|
)
|
|
tagged_source = scad.select_edges_by_tag(
|
|
profile, "role.source_edge", scope="local"
|
|
)[0]
|
|
source_explanation = scad.explain_tag(
|
|
tagged_source, "role.source_edge", scope="local"
|
|
)[0]
|
|
source_binding_id = source_explanation["binding_id"]
|
|
source_topo_id = tagged_source.topo_id
|
|
result = scad.extrude_rsolid(profile, (0, 0, 1), 2.0)
|
|
|
|
projected = (
|
|
Q.faces()
|
|
.where(Q.source_binding(source_binding_id))
|
|
.exactly(1)
|
|
.resolve(result)
|
|
)
|
|
self.assertTrue(Q.source_topology(source_topo_id)(projected[0]))
|
|
projected_explanation = scad.explain_tag(
|
|
projected[0], "role.source_edge", scope="local"
|
|
)[0]
|
|
self.assertEqual(
|
|
projected_explanation["binding"]["evidence"]["source_binding_id"],
|
|
source_binding_id,
|
|
)
|
|
|
|
replayed = next(
|
|
shape
|
|
for shape in scad.replay_model_json(scad.export_model_json(session))
|
|
if isinstance(shape, scad.Solid)
|
|
)
|
|
replayed_projected = (
|
|
Q.faces()
|
|
.where(Q.source_binding(source_binding_id))
|
|
.exactly(1)
|
|
.resolve(replayed)
|
|
)
|
|
self.assertTrue(Q.source_topology(source_topo_id)(replayed_projected[0]))
|
|
|
|
def test_replay_rejects_role_binding_and_topology_role_tampering(self):
|
|
with scad.GraphSession() as session:
|
|
profile = scad.make_rectangle_rface(5.0, 3.0)
|
|
scad.extrude_rsolid(
|
|
profile, (0, 0, 1), 2.0, start_face_tag="anchor.base"
|
|
)
|
|
|
|
raw = json.loads(scad.export_model_json(session))
|
|
binding_node = self._binding_nodes(raw)[0]
|
|
|
|
damaged = deepcopy(raw)
|
|
damaged_binding_node = next(
|
|
node
|
|
for node in damaged["graph"]["nodes"]
|
|
if node["node_id"] == binding_node["node_id"]
|
|
)
|
|
binding = damaged_binding_node["params"]["tag_binding"]
|
|
binding["evidence"]["operation_output_role"]["role"] = "extrusion.end"
|
|
damaged["semantic_bindings"] = [binding]
|
|
with self.assertRaisesRegex(
|
|
scad.SimpleCADError, "binding targets do not match"
|
|
):
|
|
scad.replay_model_json(json.dumps(damaged))
|
|
|
|
damaged = deepcopy(raw)
|
|
feature = next(
|
|
node
|
|
for node in damaged["graph"]["nodes"]
|
|
if node["op"] == "make_extrude_rsolid"
|
|
)
|
|
feature["topo_delta"]["roles"].pop()
|
|
with self.assertRaisesRegex(scad.SimpleCADError, "output-role evidence drifted"):
|
|
scad.replay_model_json(json.dumps(damaged))
|
|
|
|
|
|
class TestOperationRoleQL(unittest.TestCase):
|
|
def test_role_and_source_predicates_roundtrip(self):
|
|
predicates = (
|
|
Q.output_role("Extrusion.End"),
|
|
Q.source_binding("tag_binding_source"),
|
|
Q.source_topology("edge_0"),
|
|
)
|
|
for predicate in predicates:
|
|
with self.subTest(kind=predicate.kind):
|
|
self.assertEqual(
|
|
Q.SerializablePredicate.from_dict(predicate.to_dict()).to_dict(),
|
|
predicate.to_dict(),
|
|
)
|
|
|
|
def test_predicate_deserialization_rejects_invalid_payloads(self):
|
|
invalid = (
|
|
{"kind": "unknown", "data": {}, "children": []},
|
|
{"kind": "output_role", "data": {}, "children": []},
|
|
{
|
|
"kind": "source_binding",
|
|
"data": {"source_binding_id": "x", "extra": True},
|
|
"children": [],
|
|
},
|
|
{"kind": "not", "data": {}, "children": []},
|
|
)
|
|
for payload in invalid:
|
|
with self.subTest(payload=payload):
|
|
with self.assertRaises(ValueError):
|
|
Q.SerializablePredicate.from_dict(payload)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|