1341 lines
54 KiB
Python
1341 lines
54 KiB
Python
"""Tests for Phase 7: serialization and replay."""
|
|
|
|
import json
|
|
import unittest
|
|
from copy import deepcopy
|
|
|
|
import simplecadapi as scad
|
|
from simplecadapi.topology import OperationGraph, TopoDelta
|
|
from simplecadapi.graph import GraphSession, record_operation
|
|
from simplecadapi.serializer import (
|
|
export_graph_json,
|
|
import_graph_json,
|
|
import_model_json,
|
|
replay_graph,
|
|
PUBLIC_API_COVERAGE,
|
|
CANONICAL_CORE_OP_SET,
|
|
)
|
|
|
|
|
|
class TestGraphSerialization(unittest.TestCase):
|
|
def test_roundtrip_empty(self):
|
|
graph = OperationGraph()
|
|
data = graph.to_dict()
|
|
restored = OperationGraph.from_dict(data)
|
|
self.assertEqual(restored.node_count, 0)
|
|
self.assertEqual(restored.graph_id, graph.graph_id)
|
|
|
|
def test_roundtrip_with_nodes(self):
|
|
graph = OperationGraph()
|
|
n1 = graph.add_node("make_line_redge", {"start": (0, 0, 0), "end": (10, 0, 0)})
|
|
n2 = graph.add_node(
|
|
"make_line_redge", {"start": (10, 0, 0), "end": (10, 20, 0)}
|
|
)
|
|
n3 = graph.add_node(
|
|
"make_wire_from_edges_rwire", {"edge_count": 2}, inputs=[n1, n2]
|
|
)
|
|
|
|
data = graph.to_dict()
|
|
restored = OperationGraph.from_dict(data)
|
|
|
|
self.assertEqual(restored.node_count, 3)
|
|
self.assertEqual(restored.edge_count, 2)
|
|
|
|
# Verify node order is preserved
|
|
r_nodes = restored.topological_order()
|
|
r_ops = [n.op for n in r_nodes]
|
|
self.assertIn("make_line_redge", r_ops)
|
|
self.assertIn("make_wire_from_edges_rwire", r_ops)
|
|
|
|
def test_roundtrip_json(self):
|
|
graph = OperationGraph()
|
|
n1 = graph.add_node("make_line_redge", {"start": (0, 0, 0), "end": (10, 0, 0)})
|
|
n2 = graph.add_node(
|
|
"make_wire_from_edges_rwire", {"edge_count": 1}, inputs=[n1]
|
|
)
|
|
|
|
json_str = graph.to_json()
|
|
restored = OperationGraph.from_json(json_str)
|
|
|
|
self.assertEqual(restored.node_count, 2)
|
|
self.assertEqual(restored.graph_id, graph.graph_id)
|
|
|
|
def test_json_is_valid_json(self):
|
|
graph = OperationGraph()
|
|
graph.add_node("make_line_redge", {"start": (0, 0, 0), "end": (10, 20, 0)})
|
|
json_str = graph.to_json()
|
|
parsed = json.loads(json_str)
|
|
self.assertIn("graph_id", parsed)
|
|
self.assertIn("nodes", parsed)
|
|
self.assertIn("edges", parsed)
|
|
|
|
def test_roundtrip_preserves_params(self):
|
|
graph = OperationGraph()
|
|
graph.add_node(
|
|
"make_translate_rshape",
|
|
{"vector": [10.5, 20.0, 30.0], "center": [1, 2, 3]},
|
|
)
|
|
json_str = graph.to_json()
|
|
restored = OperationGraph.from_json(json_str)
|
|
node = restored.nodes[0]
|
|
self.assertEqual(node.params["vector"], [10.5, 20.0, 30.0])
|
|
self.assertEqual(node.params["center"], [1, 2, 3])
|
|
|
|
def test_roundtrip_preserves_tags(self):
|
|
graph = OperationGraph()
|
|
graph.add_node("make_line_redge", {}, tags={"primitive", "profile"})
|
|
json_str = graph.to_json()
|
|
restored = OperationGraph.from_json(json_str)
|
|
self.assertIn("primitive", restored.nodes[0].tags)
|
|
self.assertIn("profile", restored.nodes[0].tags)
|
|
|
|
|
|
class TestExportImport(unittest.TestCase):
|
|
def test_export_graph_json(self):
|
|
with GraphSession() as session:
|
|
record_operation("make_line_redge", {"start": (0, 0, 0), "end": (10, 0, 0)})
|
|
json_str = export_graph_json(session.graph)
|
|
parsed = json.loads(json_str)
|
|
self.assertEqual(len(parsed["nodes"]), 1)
|
|
|
|
def test_export_graph_json_includes_display_payload(self):
|
|
with scad.GraphSession() as session:
|
|
body = scad.make_box_rsolid(10, 10, 10)
|
|
tool = scad.make_cylinder_rsolid(2.0, 15.0, bottom_face_center=(3, 3, -2.5))
|
|
scad.cut_rsolid(body, tool)
|
|
|
|
payload = json.loads(export_graph_json(session.graph))
|
|
leaf = next(
|
|
node for node in payload["nodes"] if node["op"] == "make_cut_rsolid"
|
|
)
|
|
|
|
self.assertIn("display", leaf)
|
|
self.assertEqual(leaf["display"]["category"], "boolean")
|
|
self.assertIn("label", leaf["display"])
|
|
self.assertIn("summary", leaf["display"])
|
|
|
|
def test_export_graph_json_display_payload_includes_selection_counts(self):
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
edges = [box.get_edges(i) for i in range(4)]
|
|
scad.fillet_rsolid(box, edges, 0.2)
|
|
|
|
payload = json.loads(export_graph_json(session.graph))
|
|
fillet_node = next(
|
|
node for node in payload["nodes"] if node["op"] == "make_fillet_rsolid"
|
|
)
|
|
|
|
self.assertIn("display", fillet_node)
|
|
self.assertEqual(fillet_node["display"]["selection_count"], 4)
|
|
|
|
def test_export_graph_json_includes_schema_metadata(self):
|
|
with scad.GraphSession() as session:
|
|
scad.make_box_rsolid(1.0, 1.0, 1.0)
|
|
|
|
payload = json.loads(export_graph_json(session.graph))
|
|
self.assertIn("schema_version", payload)
|
|
self.assertIn("producer_version", payload)
|
|
self.assertIn("capabilities", payload)
|
|
self.assertTrue(payload["capabilities"]["selection_ref_strategies"])
|
|
self.assertTrue(payload["capabilities"]["geo_select_nodes"])
|
|
self.assertTrue(payload["capabilities"]["display_payload"])
|
|
self.assertTrue(payload["capabilities"]["sketch_solve_snapshots"])
|
|
|
|
def test_sdf_field_surface_api_is_not_public(self):
|
|
self.assertFalse(hasattr(scad, "field"))
|
|
self.assertFalse(hasattr(scad, "make_field_surface_rsolid"))
|
|
|
|
|
|
class TestCoverageMatrix(unittest.TestCase):
|
|
def test_public_api_coverage_accounts_for_current_geometry_exports(self):
|
|
expected = {
|
|
"make_box_rsolid",
|
|
"make_cylinder_rsolid",
|
|
"make_sphere_rsolid",
|
|
"make_cone_rsolid",
|
|
"make_circle_rwire",
|
|
"make_rectangle_rface",
|
|
"make_wire_from_edges_rwire",
|
|
"translate_shape",
|
|
"rotate_shape",
|
|
"mirror_shape",
|
|
"extrude_rsolid",
|
|
"revolve_rsolid",
|
|
"loft_rsolid",
|
|
"sweep_rsolid",
|
|
"twisted_sweep_rsolid",
|
|
"helical_sweep_rsolid",
|
|
"union_rsolid",
|
|
"cut_rsolid",
|
|
"intersect_rsolid",
|
|
"fillet_rsolid",
|
|
"chamfer_rsolid",
|
|
"shell_rsolid",
|
|
"linear_pattern_rsolidlist",
|
|
"radial_pattern_rsolidlist",
|
|
"make_material_rmaterial",
|
|
"make_placement_rplacement",
|
|
"identity_placement_rplacement",
|
|
"make_part_rpart",
|
|
"assign_material_rpart",
|
|
"make_assembly_rassembly",
|
|
"add_component_rassembly",
|
|
"place_component_rassembly",
|
|
"make_compound_from_assembly_rcompound",
|
|
"make_face_connector_rconnector",
|
|
"make_edge_connector_rconnector",
|
|
"make_vertex_connector_rconnector",
|
|
"make_placement_connector_rconnector",
|
|
"add_connector_rpart",
|
|
"add_connector_rassembly",
|
|
"forward_connector_rassembly",
|
|
"make_connector_ref_rconnectorref",
|
|
"make_scalar_limit_rscalarlimit",
|
|
"ground_component_rassembly",
|
|
"unground_component_rassembly",
|
|
"add_fixed_constraint_rassembly",
|
|
"add_revolute_constraint_rassembly",
|
|
"add_prismatic_constraint_rassembly",
|
|
"add_gear_constraint_rassembly",
|
|
"add_belt_constraint_rassembly",
|
|
"add_rack_pinion_constraint_rassembly",
|
|
"solve_assembly_constraints_rassembly",
|
|
}
|
|
self.assertTrue(expected.issubset(PUBLIC_API_COVERAGE.keys()))
|
|
|
|
def test_replayable_public_api_coverage_targets_canonical_ops(self):
|
|
canonical = set(CANONICAL_CORE_OP_SET)
|
|
for api_name, coverage in PUBLIC_API_COVERAGE.items():
|
|
if coverage.get("status") != "replayable":
|
|
continue
|
|
with self.subTest(api_name=api_name):
|
|
self.assertIn(coverage.get("op"), canonical)
|
|
|
|
def test_sdf_entries_are_not_part_of_public_api_coverage(self):
|
|
self.assertNotIn("make_field_surface_rsolid", PUBLIC_API_COVERAGE)
|
|
|
|
def test_import_graph_json(self):
|
|
json_str = '{"schema_version": "2.0", "graph_id": "test", "nodes": [{"node_id": "n1", "op": "make_line_redge", "params": {"start": [0, 0, 0], "end": [10, 0, 0]}, "inputs": [], "output_count": 1, "tags": []}], "edges": []}'
|
|
graph = import_graph_json(json_str)
|
|
self.assertEqual(graph.node_count, 1)
|
|
self.assertEqual(graph.nodes[0].op, "make_line_redge")
|
|
|
|
def test_import_graph_json_rejects_unsupported_schema(self):
|
|
json_str = (
|
|
'{"schema_version": "1.0", "graph_id": "test", "nodes": [], "edges": []}'
|
|
)
|
|
with self.assertRaises(ValueError):
|
|
import_graph_json(json_str)
|
|
|
|
def test_canonical_core_op_set_is_exact_contract(self):
|
|
expected = {
|
|
"make_point_rvertex",
|
|
"make_line_redge",
|
|
"make_circle_redge",
|
|
"make_three_point_arc_redge",
|
|
"make_angle_arc_redge",
|
|
"make_spline_redge",
|
|
"make_helix_redge",
|
|
"make_wire_from_edges_rwire",
|
|
"make_face_from_wire_rface",
|
|
"make_face_from_wires_rface",
|
|
"make_sketch_rsketch",
|
|
"make_add_point_rsketch",
|
|
"make_add_line_rsketch",
|
|
"make_add_circle_rsketch",
|
|
"make_add_arc_rsketch",
|
|
"make_add_bspline_rsketch",
|
|
"make_constrain_coincident_rsketch",
|
|
"make_constrain_point_on_rsketch",
|
|
"make_constrain_horizontal_rsketch",
|
|
"make_constrain_vertical_rsketch",
|
|
"make_constrain_parallel_rsketch",
|
|
"make_constrain_perpendicular_rsketch",
|
|
"make_constrain_collinear_rsketch",
|
|
"make_constrain_tangent_rsketch",
|
|
"make_constrain_concentric_rsketch",
|
|
"make_constrain_midpoint_rsketch",
|
|
"make_constrain_symmetric_rsketch",
|
|
"make_constrain_equal_length_rsketch",
|
|
"make_constrain_equal_radius_rsketch",
|
|
"make_constrain_distance_rsketch",
|
|
"make_constrain_distance_x_rsketch",
|
|
"make_constrain_distance_y_rsketch",
|
|
"make_constrain_length_rsketch",
|
|
"make_constrain_angle_rsketch",
|
|
"make_constrain_radius_rsketch",
|
|
"make_constrain_diameter_rsketch",
|
|
"make_constrain_fix_rsketch",
|
|
"make_wire_from_sketch_rwire",
|
|
"make_face_from_sketch_rface",
|
|
"make_box_rsolid",
|
|
"make_cylinder_rsolid",
|
|
"make_cone_rsolid",
|
|
"make_sphere_rsolid",
|
|
"make_material_rmaterial",
|
|
"make_placement_rplacement",
|
|
"make_identity_placement_rplacement",
|
|
"make_part_rpart",
|
|
"make_assign_material_rpart",
|
|
"make_assembly_rassembly",
|
|
"make_add_component_rassembly",
|
|
"make_place_component_rassembly",
|
|
"make_compound_from_assembly_rcompound",
|
|
"make_face_connector_rconnector",
|
|
"make_edge_connector_rconnector",
|
|
"make_vertex_connector_rconnector",
|
|
"make_placement_connector_rconnector",
|
|
"make_add_connector_rpart",
|
|
"make_add_connector_rassembly",
|
|
"make_forward_connector_rassembly",
|
|
"make_connector_ref_rconnectorref",
|
|
"make_scalar_limit_rscalarlimit",
|
|
"make_ground_component_rassembly",
|
|
"make_unground_component_rassembly",
|
|
"make_fixed_constraint_rassembly",
|
|
"make_revolute_constraint_rassembly",
|
|
"make_prismatic_constraint_rassembly",
|
|
"make_gear_constraint_rassembly",
|
|
"make_belt_constraint_rassembly",
|
|
"make_rack_pinion_constraint_rassembly",
|
|
"make_solve_assembly_constraints_rassembly",
|
|
"make_extrude_rsolid",
|
|
"make_revolve_rsolid",
|
|
"make_loft_rsolid",
|
|
"make_sweep_rsolid",
|
|
"make_twisted_sweep_rsolid",
|
|
"make_translate_rshape",
|
|
"make_rotate_rshape",
|
|
"make_mirror_rshape",
|
|
"make_cut_rsolid",
|
|
"make_union_rsolid",
|
|
"make_intersect_rsolid",
|
|
"make_2d_cut_rface",
|
|
"make_2d_union_rface",
|
|
"make_2d_intersect_rface",
|
|
"make_fillet_rsolid",
|
|
"make_chamfer_rsolid",
|
|
"make_shell_rsolid",
|
|
"make_select_rvertex",
|
|
"make_select_redge",
|
|
"make_select_rwire",
|
|
"make_select_rface",
|
|
"make_select_rsolid",
|
|
}
|
|
|
|
self.assertEqual(set(CANONICAL_CORE_OP_SET), expected)
|
|
|
|
def test_import_graph_json_rejects_legacy_op_name(self):
|
|
payload = {
|
|
"schema_version": "2.0",
|
|
"graph_id": "test",
|
|
"nodes": [
|
|
{
|
|
"node_id": "n1",
|
|
"op": "make_line",
|
|
"params": {"start": [0, 0, 0], "end": [1, 0, 0]},
|
|
"inputs": [],
|
|
"output_count": 1,
|
|
"tags": [],
|
|
}
|
|
],
|
|
"edges": [],
|
|
}
|
|
|
|
with self.assertRaises(ValueError):
|
|
import_graph_json(json.dumps(payload))
|
|
|
|
def test_export_graph_json_rejects_legacy_op_name(self):
|
|
graph = OperationGraph()
|
|
graph.add_node("make_line", {"start": (0, 0, 0), "end": (1, 0, 0)})
|
|
|
|
with self.assertRaises(ValueError):
|
|
export_graph_json(graph)
|
|
|
|
|
|
class TestReplay(unittest.TestCase):
|
|
@staticmethod
|
|
def _tag_node(payload, tag):
|
|
return next(
|
|
node
|
|
for node in payload["graph"]["nodes"]
|
|
if node["op"] == "apply_tag_rselection"
|
|
and node["params"]["tag_binding"]["tag"] == tag
|
|
)
|
|
|
|
@staticmethod
|
|
def _topology_ref_map(shape):
|
|
refs = {}
|
|
for entity in shape._topology_cache.entities():
|
|
wrapper = entity.wrappers[0]
|
|
ref = wrapper.get_metadata("topo_ref")
|
|
if isinstance(ref, dict):
|
|
refs[(entity.kind, entity.topo_id)] = (
|
|
ref["graph_id"],
|
|
ref["node_id"],
|
|
ref["output_slot"],
|
|
ref["kind"],
|
|
ref["topo_id"],
|
|
)
|
|
return refs
|
|
|
|
def test_replay_builds_graph(self):
|
|
"""A simple low-level graph can be replayed."""
|
|
with GraphSession() as session:
|
|
n1 = record_operation(
|
|
"make_line_redge", {"start": (0, 0, 0), "end": (1, 0, 0)}
|
|
)
|
|
n2 = record_operation(
|
|
"make_line_redge", {"start": (1, 0, 0), "end": (1, 1, 0)}
|
|
)
|
|
record_operation(
|
|
"make_wire_from_edges_rwire", {"edge_count": 2}, inputs=[n1, n2]
|
|
)
|
|
|
|
results = replay_graph(session.graph)
|
|
self.assertIsNotNone(results)
|
|
self.assertGreater(len(results), 0)
|
|
|
|
def test_terminal_tag_selector_roundtrip_preserves_binding_and_topology_refs(self):
|
|
tag = "role.selector_target"
|
|
selector = (
|
|
scad.ql.faces()
|
|
.order_by(scad.ql.key("geom.center.z"), desc=True)
|
|
.take(1)
|
|
.exactly(1)
|
|
)
|
|
with scad.GraphSession() as session:
|
|
source = scad.make_box_rsolid(2.0, 3.0, 4.0)
|
|
source_refs = self._topology_ref_map(source)
|
|
geometry_node_id = source.get_metadata("graph")["node_id"]
|
|
tagged = scad.apply_tag_rselection(source, selector, tag)
|
|
|
|
self.assertEqual(self._topology_ref_map(tagged), source_refs)
|
|
self.assertEqual(scad.select_faces_by_tag(source, tag, scope="local"), [])
|
|
self.assertEqual(len(scad.select_faces_by_tag(tagged, tag, scope="local")), 1)
|
|
|
|
payload = json.loads(scad.export_model_json(session))
|
|
node = self._tag_node(payload, tag)
|
|
binding = node["params"]["tag_binding"]
|
|
self.assertEqual(payload["leaf_ids"], [node["node_id"]])
|
|
self.assertEqual(node["inputs"], [geometry_node_id])
|
|
self.assertEqual(binding["producer"]["node_id"], node["node_id"])
|
|
self.assertEqual(binding["scope"]["node_id"], geometry_node_id)
|
|
self.assertEqual(binding["target"]["kind"], "selection_query")
|
|
self.assertEqual(binding["evidence"]["selected_count"], 1)
|
|
self.assertEqual(payload["semantic_bindings"], [binding])
|
|
|
|
replayed = scad.replay_model_json(json.dumps(payload))
|
|
self.assertEqual(len(replayed), 1)
|
|
result = replayed[0]
|
|
self.assertEqual(result.get_metadata("graph")["node_id"], node["node_id"])
|
|
self.assertEqual(
|
|
result.get_metadata("topo_ref")["node_id"], geometry_node_id
|
|
)
|
|
selected = scad.select_faces_by_tag(result, tag, scope="local")
|
|
self.assertEqual(len(selected), 1)
|
|
explanation = scad.explain_tag(selected[0], tag, scope="local")
|
|
self.assertEqual(explanation[0]["binding_id"], binding["binding_id"])
|
|
|
|
def test_terminal_tag_explicit_refs_roundtrip(self):
|
|
tag = "role.explicit_target"
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(2.0, 3.0, 4.0)
|
|
top_face = max(box.get_faces(), key=lambda face: face.get_center().z)
|
|
expected_ref = top_face.get_metadata("topo_ref")
|
|
scad.apply_tag_rselection(box, [top_face], tag)
|
|
|
|
payload = json.loads(scad.export_model_json(session))
|
|
node = self._tag_node(payload, tag)
|
|
binding = node["params"]["tag_binding"]
|
|
self.assertEqual(binding["target"]["kind"], "explicit_refs")
|
|
self.assertEqual(
|
|
binding["target"]["refs"][0]["topo_id"], expected_ref["topo_id"]
|
|
)
|
|
self.assertFalse(
|
|
any(
|
|
item["op"].startswith("make_select_")
|
|
for item in payload["graph"]["nodes"]
|
|
)
|
|
)
|
|
|
|
result = scad.replay_model_json(json.dumps(payload))[0]
|
|
selected = scad.select_faces_by_tag(result, tag, scope="local")
|
|
self.assertEqual(len(selected), 1)
|
|
self.assertEqual(
|
|
selected[0].get_metadata("topo_ref")["topo_id"], expected_ref["topo_id"]
|
|
)
|
|
|
|
def test_apply_tag_chain_roundtrip_preserves_nodes_bindings_and_topology_refs(self):
|
|
tags = ("role.alpha", "role.beta", "role.alpha")
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(2.0, 3.0, 4.0)
|
|
geometry_node_id = box.get_metadata("graph")["node_id"]
|
|
source_refs = self._topology_ref_map(box)
|
|
for tag in tags:
|
|
self.assertIs(scad.apply_tag(box, tag), box)
|
|
|
|
self.assertEqual(self._topology_ref_map(box), source_refs)
|
|
payload = json.loads(scad.export_model_json(session))
|
|
tag_nodes = [
|
|
node
|
|
for node in payload["graph"]["nodes"]
|
|
if node["op"] == "apply_tag_rselection"
|
|
]
|
|
self.assertEqual(
|
|
[node["params"]["tag_binding"]["tag"] for node in tag_nodes],
|
|
list(tags),
|
|
)
|
|
|
|
previous_node_id = geometry_node_id
|
|
binding_ids = []
|
|
for node in tag_nodes:
|
|
binding = node["params"]["tag_binding"]
|
|
self.assertEqual(node["inputs"], [previous_node_id])
|
|
self.assertEqual(binding["scope"]["node_id"], previous_node_id)
|
|
self.assertEqual(binding["producer"]["node_id"], node["node_id"])
|
|
binding_ids.append(binding["binding_id"])
|
|
previous_node_id = node["node_id"]
|
|
|
|
self.assertEqual(len(binding_ids), len(set(binding_ids)))
|
|
self.assertEqual(payload["leaf_ids"], [previous_node_id])
|
|
self.assertEqual(box.get_metadata("graph")["node_id"], previous_node_id)
|
|
self.assertEqual(
|
|
[binding["binding_id"] for binding in payload["semantic_bindings"]],
|
|
binding_ids,
|
|
)
|
|
|
|
replayed = scad.replay_model_json(json.dumps(payload))
|
|
self.assertEqual(len(replayed), 1)
|
|
result = replayed[0]
|
|
self.assertTrue(
|
|
{"role.alpha", "role.beta"}.issubset(
|
|
scad.list_tags(result, scope="local")
|
|
)
|
|
)
|
|
self.assertEqual(
|
|
len(scad.explain_tag(result, "role.alpha", scope="local")),
|
|
2,
|
|
)
|
|
self.assertEqual(self._topology_ref_map(result), source_refs)
|
|
|
|
def test_tag_semantic_branches_are_isolated_after_replay(self):
|
|
selector = (
|
|
scad.ql.faces()
|
|
.order_by(scad.ql.key("geom.center.z"), desc=True)
|
|
.take(1)
|
|
.exactly(1)
|
|
)
|
|
with scad.GraphSession() as session:
|
|
source = scad.make_box_rsolid(2.0, 3.0, 4.0)
|
|
left = scad.apply_tag_rselection(source, selector, "role.left_branch")
|
|
right = scad.apply_tag_rselection(source, selector, "role.right_branch")
|
|
|
|
self.assertEqual(
|
|
len(scad.select_faces_by_tag(left, "role.left_branch", scope="local")), 1
|
|
)
|
|
self.assertEqual(
|
|
scad.select_faces_by_tag(left, "role.right_branch", scope="local"), []
|
|
)
|
|
self.assertEqual(
|
|
scad.select_faces_by_tag(right, "role.left_branch", scope="local"), []
|
|
)
|
|
self.assertEqual(
|
|
len(scad.select_faces_by_tag(right, "role.right_branch", scope="local")),
|
|
1,
|
|
)
|
|
|
|
payload = json.loads(scad.export_model_json(session))
|
|
left_node = self._tag_node(payload, "role.left_branch")
|
|
right_node = self._tag_node(payload, "role.right_branch")
|
|
replayed = {
|
|
shape.get_metadata("graph")["node_id"]: shape
|
|
for shape in scad.replay_model_json(json.dumps(payload))
|
|
}
|
|
replayed_left = replayed[left_node["node_id"]]
|
|
replayed_right = replayed[right_node["node_id"]]
|
|
self.assertEqual(
|
|
len(
|
|
scad.select_faces_by_tag(
|
|
replayed_left, "role.left_branch", scope="local"
|
|
)
|
|
),
|
|
1,
|
|
)
|
|
self.assertEqual(
|
|
scad.select_faces_by_tag(
|
|
replayed_left, "role.right_branch", scope="local"
|
|
),
|
|
[],
|
|
)
|
|
self.assertEqual(
|
|
scad.select_faces_by_tag(
|
|
replayed_right, "role.left_branch", scope="local"
|
|
),
|
|
[],
|
|
)
|
|
self.assertEqual(
|
|
len(
|
|
scad.select_faces_by_tag(
|
|
replayed_right, "role.right_branch", scope="local"
|
|
)
|
|
),
|
|
1,
|
|
)
|
|
|
|
def test_strict_tag_replay_rejects_binding_tampering(self):
|
|
selector = (
|
|
scad.ql.faces()
|
|
.order_by(scad.ql.key("geom.center.z"), desc=True)
|
|
.take(1)
|
|
.exactly(1)
|
|
)
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(2.0, 3.0, 4.0)
|
|
scad.apply_tag_rselection(box, selector, "role.target")
|
|
|
|
raw = json.loads(scad.export_model_json(session))
|
|
|
|
damaged = deepcopy(raw)
|
|
node = self._tag_node(damaged, "role.target")
|
|
node["params"]["tag_binding"]["producer"]["node_id"] = node["inputs"][0]
|
|
damaged["semantic_bindings"] = [node["params"]["tag_binding"]]
|
|
with self.assertRaisesRegex(ValueError, "does not match binding producer"):
|
|
scad.replay_model_json(json.dumps(damaged))
|
|
|
|
damaged = deepcopy(raw)
|
|
node = self._tag_node(damaged, "role.target")
|
|
node["params"]["tag_binding"]["target"]["selector"]["order_keys"][0][
|
|
"desc"
|
|
] = False
|
|
damaged["semantic_bindings"] = [node["params"]["tag_binding"]]
|
|
with self.assertRaisesRegex(ValueError, "target evidence drifted"):
|
|
scad.replay_model_json(json.dumps(damaged))
|
|
|
|
def test_replay_preserves_volume(self):
|
|
with GraphSession() as session:
|
|
record_operation(
|
|
"make_extrude_rsolid", {"direction": (0, 0, 1), "distance": 10.0}
|
|
)
|
|
|
|
results = replay_graph(session.graph, strict=False)
|
|
self.assertGreaterEqual(len(results), 0)
|
|
|
|
def test_replay_empty_graph(self):
|
|
graph = OperationGraph()
|
|
results = replay_graph(graph)
|
|
self.assertEqual(results, [])
|
|
|
|
def test_replay_original_api_rectangle_extrude_roundtrip(self):
|
|
with scad.GraphSession() as session:
|
|
profile = scad.make_rectangle_rface(2.0, 1.0)
|
|
solid = scad.extrude_rsolid(profile, (0, 0, 1), 3.0)
|
|
|
|
graph_json = export_graph_json(session.graph)
|
|
restored = import_graph_json(graph_json)
|
|
results = replay_graph(restored)
|
|
|
|
self.assertEqual(len(results), 1)
|
|
self.assertIsInstance(results[0], scad.Solid)
|
|
self.assertAlmostEqual(results[0].get_volume(), solid.get_volume(), places=5)
|
|
|
|
def test_replay_original_api_circle_wire_roundtrip(self):
|
|
with scad.GraphSession() as session:
|
|
wire = scad.make_circle_rwire((0, 0, 0), 2.0)
|
|
|
|
graph_json = export_graph_json(session.graph)
|
|
restored = import_graph_json(graph_json)
|
|
results = replay_graph(restored)
|
|
|
|
self.assertEqual(len(results), 1)
|
|
self.assertIsInstance(results[0], scad.Wire)
|
|
self.assertTrue(results[0].is_closed())
|
|
|
|
def test_replay_original_api_loft_roundtrip(self):
|
|
with scad.GraphSession() as session:
|
|
a = scad.make_rectangle_rwire(2.0, 2.0, center=(0, 0, 0))
|
|
b = scad.make_rectangle_rwire(1.0, 1.0, center=(0, 0, 2.0))
|
|
lofted = scad.loft_rsolid([a, b])
|
|
|
|
graph_json = export_graph_json(session.graph)
|
|
restored = import_graph_json(graph_json)
|
|
results = replay_graph(restored)
|
|
|
|
self.assertEqual(len(results), 1)
|
|
self.assertIsInstance(results[0], scad.Solid)
|
|
self.assertAlmostEqual(results[0].get_volume(), lofted.get_volume(), places=5)
|
|
|
|
def test_replay_forwarded_connector_without_offset_roundtrip(self):
|
|
with scad.GraphSession() as session:
|
|
body = scad.make_box_rsolid(width=1.0, height=1.0, depth=1.0)
|
|
part = scad.make_part_rpart(part_id="forwarded_part", body=body)
|
|
connector = scad.make_placement_connector_rconnector(
|
|
connector_id="axis",
|
|
placement=scad.make_placement_rplacement(origin=(1.0, 2.0, 3.0)),
|
|
)
|
|
part = scad.add_connector_rpart(part=part, connector=connector)
|
|
assembly = scad.make_assembly_rassembly(assembly_id="forwarded_parent")
|
|
assembly = scad.add_component_rassembly(
|
|
assembly=assembly,
|
|
item=part,
|
|
component_id="child",
|
|
placement=scad.identity_placement_rplacement(),
|
|
)
|
|
scad.forward_connector_rassembly(
|
|
assembly=assembly,
|
|
connector_id="public_axis",
|
|
source_component_id="child",
|
|
source_connector_id="axis",
|
|
)
|
|
|
|
replayed = scad.replay_model_json(json_str=scad.export_model_json(session=session))
|
|
assemblies = [item for item in replayed if isinstance(item, scad.Assembly)]
|
|
|
|
self.assertTrue(assemblies)
|
|
self.assertEqual(
|
|
assemblies[-1].get_connector("public_axis").placement.origin,
|
|
(1.0, 2.0, 3.0),
|
|
)
|
|
|
|
def test_replay_wire_face_extrude_edit_chain_roundtrip(self):
|
|
with scad.GraphSession() as session:
|
|
e1 = scad.make_line_redge((0, 0, 0), (1, 0, 0))
|
|
e2 = scad.make_line_redge((1, 0, 0), (1, 1, 0))
|
|
e3 = scad.make_line_redge((1, 1, 0), (0, 1, 0))
|
|
e4 = scad.make_line_redge((0, 1, 0), (0, 0, 0))
|
|
wire = scad.make_wire_from_edges_rwire([e1, e2, e3, e4])
|
|
face = scad.make_face_from_wire_rface(wire)
|
|
solid = scad.extrude_rsolid(face, (0, 0, 1), 2.0)
|
|
|
|
graph_json = export_graph_json(session.graph)
|
|
restored = import_graph_json(graph_json)
|
|
results = replay_graph(restored)
|
|
|
|
self.assertEqual(len(results), 1)
|
|
self.assertIsInstance(results[0], scad.Solid)
|
|
self.assertAlmostEqual(results[0].get_volume(), solid.get_volume(), places=5)
|
|
|
|
def test_replay_multi_loop_face_extrude_roundtrip(self):
|
|
with scad.GraphSession() as session:
|
|
outer = scad.make_circle_rwire(center=(0, 0, 0), radius=5.0)
|
|
inner = scad.make_circle_rwire(center=(0, 0, 0), radius=2.0)
|
|
face = scad.make_face_from_wires_rface(outer, [inner])
|
|
solid = scad.extrude_rsolid(face, (0, 0, 1), 3.0)
|
|
|
|
graph_json = export_graph_json(session.graph)
|
|
restored = import_graph_json(graph_json)
|
|
results = replay_graph(restored)
|
|
|
|
self.assertEqual(len(results), 1)
|
|
self.assertIsInstance(results[0], scad.Solid)
|
|
self.assertAlmostEqual(results[0].get_volume(), solid.get_volume(), places=5)
|
|
|
|
def test_replay_fillet_roundtrip_with_selected_edges(self):
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
edges = [box.get_edges(i) for i in range(4)]
|
|
filleted = scad.fillet_rsolid(box, edges, 0.2)
|
|
|
|
graph_json = export_graph_json(session.graph)
|
|
restored = import_graph_json(graph_json)
|
|
results = replay_graph(restored)
|
|
|
|
self.assertEqual(len(results), 1)
|
|
self.assertIsInstance(results[0], scad.Solid)
|
|
self.assertAlmostEqual(results[0].get_volume(), filleted.get_volume(), places=5)
|
|
|
|
def test_indexed_edge_access_records_geo_select_nodes(self):
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
edge = box.get_edges(0)
|
|
filleted = scad.fillet_rsolid(box, [edge], 0.2)
|
|
|
|
payload = json.loads(export_graph_json(session.graph))
|
|
fillet_node = next(
|
|
node for node in payload["nodes"] if node["op"] == "make_fillet_rsolid"
|
|
)
|
|
selection_node_ids = fillet_node["params"]["selected_edge_node_ids"]
|
|
self.assertEqual(len(selection_node_ids), 1)
|
|
selection_node = next(
|
|
node for node in payload["nodes"] if node["node_id"] == selection_node_ids[0]
|
|
)
|
|
self.assertEqual(selection_node["op"], "make_select_redge")
|
|
self.assertNotIn("source_index", selection_node["params"]["geo_selector"])
|
|
self.assertNotIn("selected_edge_indices", fillet_node["params"])
|
|
|
|
results = replay_graph(import_graph_json(json.dumps(payload)))
|
|
self.assertEqual(len(results), 1)
|
|
self.assertIsInstance(results[0], scad.Solid)
|
|
self.assertAlmostEqual(results[0].get_volume(), filleted.get_volume(), places=5)
|
|
|
|
def test_indexed_edge_getter_records_multiple_geo_select_nodes(self):
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
edges = [box.get_edges(i) for i in range(2)]
|
|
filleted = scad.fillet_rsolid(box, edges, 0.2)
|
|
|
|
payload = json.loads(export_graph_json(session.graph))
|
|
fillet_node = next(
|
|
node for node in payload["nodes"] if node["op"] == "make_fillet_rsolid"
|
|
)
|
|
selection_node_ids = fillet_node["params"]["selected_edge_node_ids"]
|
|
self.assertEqual(len(selection_node_ids), 2)
|
|
selection_nodes = [
|
|
node for node in payload["nodes"] if node["node_id"] in selection_node_ids
|
|
]
|
|
self.assertTrue(
|
|
all(node["op"] == "make_select_redge" for node in selection_nodes)
|
|
)
|
|
|
|
results = replay_graph(import_graph_json(json.dumps(payload)))
|
|
self.assertEqual(len(results), 1)
|
|
self.assertIsInstance(results[0], scad.Solid)
|
|
self.assertAlmostEqual(results[0].get_volume(), filleted.get_volume(), places=5)
|
|
|
|
def test_replay_chamfer_roundtrip_with_selected_edges(self):
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
edges = [box.get_edges(i) for i in range(4)]
|
|
chamfered = scad.chamfer_rsolid(box, edges, 0.2)
|
|
|
|
graph_json = export_graph_json(session.graph)
|
|
restored = import_graph_json(graph_json)
|
|
results = replay_graph(restored)
|
|
|
|
self.assertEqual(len(results), 1)
|
|
self.assertIsInstance(results[0], scad.Solid)
|
|
self.assertAlmostEqual(
|
|
results[0].get_volume(), chamfered.get_volume(), places=5
|
|
)
|
|
|
|
def test_replay_shell_roundtrip_with_selected_faces(self):
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
shelled = scad.shell_rsolid(box, [box.get_faces(0)], 0.2)
|
|
|
|
graph_json = export_graph_json(session.graph)
|
|
restored = import_graph_json(graph_json)
|
|
results = replay_graph(restored)
|
|
|
|
self.assertEqual(len(results), 1)
|
|
self.assertIsInstance(results[0], scad.Solid)
|
|
self.assertAlmostEqual(results[0].get_volume(), shelled.get_volume(), places=5)
|
|
|
|
def test_indexed_face_access_records_geo_select_nodes(self):
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
face = box.get_faces(0)
|
|
shelled = scad.shell_rsolid(box, [face], 0.2)
|
|
|
|
payload = json.loads(export_graph_json(session.graph))
|
|
shell_node = next(
|
|
node for node in payload["nodes"] if node["op"] == "make_shell_rsolid"
|
|
)
|
|
selection_node_ids = shell_node["params"]["selected_face_node_ids"]
|
|
self.assertEqual(len(selection_node_ids), 1)
|
|
selection_node = next(
|
|
node for node in payload["nodes"] if node["node_id"] == selection_node_ids[0]
|
|
)
|
|
self.assertEqual(selection_node["op"], "make_select_rface")
|
|
self.assertNotIn("source_index", selection_node["params"]["geo_selector"])
|
|
self.assertNotIn("selected_face_indices", shell_node["params"])
|
|
|
|
results = replay_graph(import_graph_json(json.dumps(payload)))
|
|
self.assertEqual(len(results), 1)
|
|
self.assertIsInstance(results[0], scad.Solid)
|
|
self.assertAlmostEqual(results[0].get_volume(), shelled.get_volume(), places=5)
|
|
|
|
def test_nested_indexed_edge_access_records_source_face_select_node(self):
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
face = box.get_faces(0)
|
|
edge = face.get_edges(0)
|
|
filleted = scad.fillet_rsolid(box, [edge], 0.2)
|
|
|
|
payload = json.loads(export_graph_json(session.graph))
|
|
select_face_nodes = [
|
|
node for node in payload["nodes"] if node["op"] == "make_select_rface"
|
|
]
|
|
self.assertEqual(len(select_face_nodes), 1)
|
|
|
|
fillet_node = next(
|
|
node for node in payload["nodes"] if node["op"] == "make_fillet_rsolid"
|
|
)
|
|
selection_node_id = fillet_node["params"]["selected_edge_node_ids"][0]
|
|
selection_node = next(
|
|
node for node in payload["nodes"] if node["node_id"] == selection_node_id
|
|
)
|
|
self.assertEqual(selection_node["op"], "make_select_redge")
|
|
self.assertEqual(selection_node["inputs"], [select_face_nodes[0]["node_id"]])
|
|
self.assertNotIn("source_index", selection_node["params"]["geo_selector"])
|
|
|
|
results = replay_graph(import_graph_json(json.dumps(payload)))
|
|
self.assertEqual(len(results), 1)
|
|
self.assertIsInstance(results[0], scad.Solid)
|
|
self.assertAlmostEqual(results[0].get_volume(), filleted.get_volume(), places=5)
|
|
|
|
def test_indexed_child_geometry_getters_record_select_nodes(self):
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
face = box.get_faces(0)
|
|
wire = face.get_wires(0)
|
|
edge = wire.get_edges(0)
|
|
vertex = edge.get_vertices(0)
|
|
|
|
payload = json.loads(export_graph_json(session.graph))
|
|
ops = [node["op"] for node in payload["nodes"]]
|
|
|
|
self.assertIsInstance(face, scad.Face)
|
|
self.assertIsInstance(wire, scad.Wire)
|
|
self.assertIsInstance(edge, scad.Edge)
|
|
self.assertIsInstance(vertex, scad.Vertex)
|
|
self.assertIn("make_select_rface", ops)
|
|
self.assertIn("make_select_rwire", ops)
|
|
self.assertIn("make_select_redge", ops)
|
|
self.assertIn("make_select_rvertex", ops)
|
|
|
|
def test_replay_fillet_roundtrip_with_selector_hint_fallback(self):
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
edges = [box.get_edges(i) for i in range(4)]
|
|
filleted = scad.fillet_rsolid(box, edges, 0.2)
|
|
|
|
payload = json.loads(export_graph_json(session.graph))
|
|
fillet_node = next(
|
|
node for node in payload["nodes"] if node["op"] == "make_fillet_rsolid"
|
|
)
|
|
self.assertGreater(len(fillet_node["params"]["selected_edges"]), 0)
|
|
self.assertIn("selector_hint", fillet_node["params"]["selected_edges"][0])
|
|
|
|
damaged = deepcopy(payload)
|
|
damaged_fillet = next(
|
|
node for node in damaged["nodes"] if node["op"] == "make_fillet_rsolid"
|
|
)
|
|
for ref in damaged_fillet["params"]["selected_edges"]:
|
|
ref["topo_id"] = "missing_edge_ref"
|
|
damaged_fillet["params"]["selected_edge_node_ids"] = []
|
|
damaged_fillet["params"]["selected_edge_indices"] = []
|
|
|
|
restored = import_graph_json(json.dumps(damaged))
|
|
results = replay_graph(restored)
|
|
|
|
self.assertEqual(len(results), 1)
|
|
self.assertIsInstance(results[0], scad.Solid)
|
|
self.assertAlmostEqual(results[0].get_volume(), filleted.get_volume(), places=5)
|
|
|
|
def test_replay_shell_roundtrip_with_selector_hint_fallback(self):
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
shelled = scad.shell_rsolid(box, [box.get_faces(0)], 0.2)
|
|
|
|
payload = json.loads(export_graph_json(session.graph))
|
|
shell_node = next(
|
|
node for node in payload["nodes"] if node["op"] == "make_shell_rsolid"
|
|
)
|
|
self.assertGreater(len(shell_node["params"]["selected_faces"]), 0)
|
|
self.assertIn("selector_hint", shell_node["params"]["selected_faces"][0])
|
|
|
|
damaged = deepcopy(payload)
|
|
damaged_shell = next(
|
|
node for node in damaged["nodes"] if node["op"] == "make_shell_rsolid"
|
|
)
|
|
for ref in damaged_shell["params"]["selected_faces"]:
|
|
ref["topo_id"] = "missing_face_ref"
|
|
damaged_shell["params"]["selected_face_node_ids"] = []
|
|
damaged_shell["params"]["selected_face_indices"] = []
|
|
|
|
restored = import_graph_json(json.dumps(damaged))
|
|
results = replay_graph(restored)
|
|
|
|
self.assertEqual(len(results), 1)
|
|
self.assertIsInstance(results[0], scad.Solid)
|
|
self.assertAlmostEqual(results[0].get_volume(), shelled.get_volume(), places=5)
|
|
|
|
def test_replay_fillet_roundtrip_with_ql_selector(self):
|
|
with scad.GraphSession() as session:
|
|
rod = scad.make_cylinder_rsolid(1.0, 5.0, bottom_face_center=(0, 0, 0))
|
|
selector = (
|
|
scad.ql.edges()
|
|
.where(scad.ql.curve_type("circle"))
|
|
.order_by(scad.ql.center_axis("z"))
|
|
.take(1)
|
|
.exactly(1)
|
|
)
|
|
filleted = scad.fillet_rsolid(rod, selector, 0.15)
|
|
|
|
payload = json.loads(export_graph_json(session.graph))
|
|
fillet_node = next(
|
|
node for node in payload["nodes"] if node["op"] == "make_fillet_rsolid"
|
|
)
|
|
self.assertNotIn("selection_query", fillet_node["params"])
|
|
selection_node_ids = fillet_node["params"]["selected_edge_node_ids"]
|
|
self.assertEqual(len(selection_node_ids), 1)
|
|
selection_node = next(
|
|
node for node in payload["nodes"] if node["node_id"] == selection_node_ids[0]
|
|
)
|
|
self.assertEqual(selection_node["op"], "make_select_redge")
|
|
self.assertEqual(selection_node["params"]["target_kind"], "edge")
|
|
self.assertEqual(selection_node["params"]["geo_selector"]["kind"], "edge")
|
|
self.assertNotIn("tags", selection_node["params"]["geo_selector"])
|
|
|
|
restored = import_graph_json(json.dumps(payload))
|
|
results = replay_graph(restored)
|
|
|
|
self.assertEqual(len(results), 1)
|
|
self.assertIsInstance(results[0], scad.Solid)
|
|
self.assertAlmostEqual(results[0].get_volume(), filleted.get_volume(), places=5)
|
|
|
|
def test_ql_tag_filter_records_geo_select_node_not_tag_selector(self):
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
scad.apply_tag(box.get_edges(0), "role.target_edge")
|
|
selector = scad.ql.edges().where(scad.ql.tag("role.target_edge")).exactly(1)
|
|
filleted = scad.fillet_rsolid(box, selector, 0.2)
|
|
|
|
payload = json.loads(export_graph_json(session.graph))
|
|
fillet_node = next(
|
|
node for node in payload["nodes"] if node["op"] == "make_fillet_rsolid"
|
|
)
|
|
select_node = next(
|
|
node
|
|
for node in payload["nodes"]
|
|
if node["node_id"] == fillet_node["params"]["selected_edge_node_ids"][0]
|
|
)
|
|
|
|
self.assertNotIn("selection_query", fillet_node["params"])
|
|
self.assertNotIn("tags", select_node["params"]["geo_selector"])
|
|
self.assertNotIn("source_index", select_node["params"]["geo_selector"])
|
|
|
|
fillet_node["params"]["selected_edges"] = []
|
|
fillet_node["params"]["selected_edge_indices"] = []
|
|
results = replay_graph(import_graph_json(json.dumps(payload)))
|
|
|
|
self.assertEqual(len(results), 1)
|
|
self.assertIsInstance(results[0], scad.Solid)
|
|
self.assertAlmostEqual(results[0].get_volume(), filleted.get_volume(), places=5)
|
|
|
|
def test_replay_chamfer_roundtrip_with_traversal_selector(self):
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(2.0, 3.0, 4.0)
|
|
selector = (
|
|
scad.ql.faces()
|
|
.where(scad.ql.prop("geom.normal.z", ">", 0.9))
|
|
.order_by(scad.ql.key("geom.center.z"), desc=True)
|
|
.take(1)
|
|
.exactly(1)
|
|
.boundary("wire")
|
|
.where(scad.ql.prop("topo.loop_role", "==", "outer"))
|
|
.take(1)
|
|
.exactly(1)
|
|
.boundary("edge")
|
|
.exactly(4)
|
|
)
|
|
chamfered = scad.chamfer_rsolid(box, selector, 0.15)
|
|
|
|
payload = json.loads(export_graph_json(session.graph))
|
|
chamfer_node = next(
|
|
node for node in payload["nodes"] if node["op"] == "make_chamfer_rsolid"
|
|
)
|
|
self.assertNotIn("selection_query", chamfer_node["params"])
|
|
selection_node_ids = chamfer_node["params"]["selected_edge_node_ids"]
|
|
self.assertEqual(len(selection_node_ids), 4)
|
|
selection_nodes = [
|
|
node for node in payload["nodes"] if node["node_id"] in selection_node_ids
|
|
]
|
|
self.assertTrue(
|
|
all(node["op"] == "make_select_redge" for node in selection_nodes)
|
|
)
|
|
self.assertTrue(
|
|
all(
|
|
node["params"]["geo_selector"]["kind"] == "edge"
|
|
for node in selection_nodes
|
|
)
|
|
)
|
|
self.assertTrue(
|
|
all("tags" not in node["params"]["geo_selector"] for node in selection_nodes)
|
|
)
|
|
|
|
restored = import_graph_json(json.dumps(payload))
|
|
results = replay_graph(restored)
|
|
|
|
self.assertEqual(len(results), 1)
|
|
self.assertIsInstance(results[0], scad.Solid)
|
|
self.assertAlmostEqual(
|
|
results[0].get_volume(), chamfered.get_volume(), places=5
|
|
)
|
|
|
|
def test_replay_mirror_roundtrip(self):
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(2.0, 3.0, 4.0)
|
|
mirrored = scad.mirror_shape(box, (0, 0, 0), (1, 0, 0))
|
|
|
|
restored = import_graph_json(export_graph_json(session.graph))
|
|
results = replay_graph(restored)
|
|
|
|
self.assertEqual(len(results), 1)
|
|
self.assertIsInstance(results[0], scad.Solid)
|
|
self.assertAlmostEqual(results[0].get_volume(), mirrored.get_volume(), places=5)
|
|
|
|
def test_replay_sweep_roundtrip(self):
|
|
with scad.GraphSession() as session:
|
|
profile = scad.make_circle_rface((0, 0, 0), 0.5)
|
|
path = scad.make_segment_rwire((0, 0, 0), (0, 0, 3.0))
|
|
swept = scad.sweep_rsolid(profile, path)
|
|
|
|
restored = import_graph_json(export_graph_json(session.graph))
|
|
results = replay_graph(restored)
|
|
|
|
self.assertEqual(len(results), 1)
|
|
self.assertIsInstance(results[0], scad.Solid)
|
|
self.assertAlmostEqual(results[0].get_volume(), swept.get_volume(), places=5)
|
|
|
|
def test_replay_twisted_sweep_roundtrip(self):
|
|
with scad.GraphSession() as session:
|
|
profile = scad.make_rectangle_rface(width=2.0, height=1.0)
|
|
swept = scad.twisted_sweep_rsolid(
|
|
profile=profile,
|
|
distance=5.0,
|
|
twist_angle=45.0,
|
|
)
|
|
|
|
payload = json.loads(export_graph_json(session.graph))
|
|
node = next(
|
|
item
|
|
for item in payload["nodes"]
|
|
if item["op"] == "make_twisted_sweep_rsolid"
|
|
)
|
|
self.assertEqual(len(node["inputs"]), 1)
|
|
self.assertEqual(
|
|
node["params"],
|
|
{
|
|
"axis": [0.0, 0.0, 1.0],
|
|
"origin": [0.0, 0.0, 0.0],
|
|
"distance": 5.0,
|
|
"twist_angle": 45.0,
|
|
"guide_radius": 1.0,
|
|
},
|
|
)
|
|
|
|
results = replay_graph(import_graph_json(json.dumps(payload)), strict=True)
|
|
self.assertEqual(len(results), 1)
|
|
self.assertEqual(len(results[0].get_faces()), 6)
|
|
self.assertAlmostEqual(results[0].get_volume(), swept.get_volume(), places=8)
|
|
|
|
def test_replay_sweep_records_ql_selected_extrude_end_face_profile(self):
|
|
with scad.GraphSession() as session:
|
|
base = scad.make_circle_rface((0, 0, 0), 0.25)
|
|
body = scad.extrude_rsolid(base, (0, 0, 1), 1.0)
|
|
profile = (
|
|
scad.ql.faces()
|
|
.where(scad.ql.tag("face.extrusion.end"))
|
|
.exactly(1)
|
|
.resolve(body)[0]
|
|
)
|
|
path = scad.make_segment_rwire((0, 0, 1.0), (0, 0, 2.0))
|
|
swept = scad.sweep_rsolid(profile, path)
|
|
|
|
payload = json.loads(export_graph_json(session.graph))
|
|
select_nodes = [
|
|
node for node in payload["nodes"] if node["op"] == "make_select_rface"
|
|
]
|
|
self.assertEqual(len(select_nodes), 1)
|
|
select_node = select_nodes[0]
|
|
self.assertEqual(select_node["params"]["target_kind"], "face")
|
|
self.assertEqual(select_node["params"]["geo_selector"]["kind"], "face")
|
|
self.assertNotIn("source_index", select_node["params"]["geo_selector"])
|
|
self.assertNotIn("tags", select_node["params"]["geo_selector"])
|
|
|
|
sweep_node = next(
|
|
node for node in payload["nodes"] if node["op"] == "make_sweep_rsolid"
|
|
)
|
|
self.assertEqual(sweep_node["inputs"][0], select_node["node_id"])
|
|
|
|
restored = replay_graph(import_graph_json(json.dumps(payload)))
|
|
|
|
self.assertEqual(len(restored), 1)
|
|
self.assertIsInstance(restored[0], scad.Solid)
|
|
self.assertAlmostEqual(restored[0].get_volume(), swept.get_volume(), places=5)
|
|
|
|
def test_replay_helical_sweep_roundtrip(self):
|
|
with scad.GraphSession() as session:
|
|
profile = scad.make_rectangle_rwire(0.4, 0.2)
|
|
swept = scad.helical_sweep_rsolid(
|
|
profile, pitch=1.0, height=2.0, radius=1.0
|
|
)
|
|
|
|
restored = import_graph_json(export_graph_json(session.graph))
|
|
results = replay_graph(restored)
|
|
|
|
self.assertEqual(len(results), 1)
|
|
self.assertIsInstance(results[0], scad.Solid)
|
|
self.assertAlmostEqual(results[0].get_volume(), swept.get_volume(), places=4)
|
|
|
|
def test_replay_linear_pattern_roundtrip(self):
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
|
pattern = scad.linear_pattern_rsolidlist(box, (1, 0, 0), 4, 1.5)
|
|
|
|
restored = import_graph_json(export_graph_json(session.graph))
|
|
results = replay_graph(restored)
|
|
|
|
self.assertEqual(len(results), 4)
|
|
self.assertTrue(all(isinstance(shape, scad.Solid) for shape in results))
|
|
self.assertAlmostEqual(
|
|
sum(s.get_volume() for s in results),
|
|
sum(s.get_volume() for s in pattern),
|
|
places=5,
|
|
)
|
|
|
|
def test_replay_radial_pattern_roundtrip(self):
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
|
pattern = scad.radial_pattern_rsolidlist(
|
|
box, (0, 0, 0), (0, 0, 1), 4, 360.0
|
|
)
|
|
|
|
restored = import_graph_json(export_graph_json(session.graph))
|
|
results = replay_graph(restored)
|
|
|
|
self.assertEqual(len(results), 4)
|
|
self.assertTrue(all(isinstance(shape, scad.Solid) for shape in results))
|
|
self.assertAlmostEqual(
|
|
sum(s.get_volume() for s in results),
|
|
sum(s.get_volume() for s in pattern),
|
|
places=5,
|
|
)
|
|
|
|
def test_replay_missing_required_param_raises_by_default(self):
|
|
graph = OperationGraph()
|
|
graph.add_node("make_point_rvertex", {"x": 1.0, "y": 2.0})
|
|
|
|
with self.assertRaises(ValueError):
|
|
replay_graph(graph)
|
|
|
|
def test_graph_import_missing_input_raises_by_default(self):
|
|
payload = {
|
|
"graph_id": "g_missing_input",
|
|
"nodes": [
|
|
{
|
|
"node_id": "n1",
|
|
"op": "make_translate_rshape",
|
|
"params": {"vector": (1.0, 0.0, 0.0)},
|
|
"inputs": ["missing"],
|
|
"output_count": 1,
|
|
"tags": [],
|
|
}
|
|
],
|
|
"edges": [],
|
|
}
|
|
|
|
with self.assertRaises(ValueError):
|
|
OperationGraph.from_dict(payload)
|
|
|
|
def test_replay_leaf_without_output_raises_by_default(self):
|
|
graph = OperationGraph()
|
|
graph.add_node("make_union_rsolid", {"input_count": 0, "clean": True, "glue": True, "tol": None})
|
|
|
|
with self.assertRaises(ValueError):
|
|
replay_graph(graph)
|
|
|
|
def test_replay_unknown_op_raises_by_default(self):
|
|
graph = OperationGraph()
|
|
graph.add_node("make_unknown_rsolid", {})
|
|
|
|
with self.assertRaises(ValueError):
|
|
replay_graph(graph)
|
|
|
|
def test_replay_cut_uses_recorded_skip_non_intersecting_flag(self):
|
|
with scad.GraphSession() as session:
|
|
body = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
|
tool = scad.make_box_rsolid(
|
|
1.0, 1.0, 1.0, bottom_face_center=(10.0, 10.0, 0.0)
|
|
)
|
|
scad.cut_rsolid(body, tool, skip_non_intersecting=True)
|
|
|
|
payload = json.loads(scad.export_model_json(session))
|
|
cut_node = next(
|
|
node for node in payload["graph"]["nodes"] if node["op"] == "make_cut_rsolid"
|
|
)
|
|
self.assertTrue(cut_node["params"]["skip_non_intersecting"])
|
|
|
|
replayed = scad.replay_model_json(json.dumps(payload))
|
|
self.assertEqual(len(replayed), 1)
|
|
self.assertIsInstance(replayed[0], scad.Solid)
|
|
|
|
def test_replay_cut_defaults_missing_skip_non_intersecting_to_false(self):
|
|
with scad.GraphSession() as session:
|
|
body = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
|
tool = scad.make_box_rsolid(
|
|
1.0, 1.0, 1.0, bottom_face_center=(10.0, 10.0, 0.0)
|
|
)
|
|
scad.cut_rsolid(body, tool, skip_non_intersecting=True)
|
|
|
|
payload = json.loads(scad.export_model_json(session))
|
|
cut_node = next(
|
|
node for node in payload["graph"]["nodes"] if node["op"] == "make_cut_rsolid"
|
|
)
|
|
del cut_node["params"]["skip_non_intersecting"]
|
|
|
|
with self.assertRaises(ValueError):
|
|
scad.replay_model_json(json.dumps(payload))
|
|
|
|
def test_union_replay_preserves_clean_glue_and_tol_params(self):
|
|
with scad.GraphSession() as session:
|
|
a = scad.make_box_rsolid(1.0, 1.0, 1.0, bottom_face_center=(0.0, 0.0, 0.0))
|
|
b = scad.make_box_rsolid(1.0, 1.0, 1.0, bottom_face_center=(1.001, 0.0, 0.0))
|
|
original = scad.union_rsolid(a, b, clean=False, glue=False, tol=1e-3)
|
|
|
|
payload = json.loads(scad.export_model_json(session))
|
|
union_node = next(
|
|
node for node in payload["graph"]["nodes"] if node["op"] == "make_union_rsolid"
|
|
)
|
|
|
|
self.assertFalse(union_node["params"]["clean"])
|
|
self.assertFalse(union_node["params"]["glue"])
|
|
self.assertEqual(union_node["params"]["tol"], 1e-3)
|
|
|
|
replayed = scad.replay_model_json(json.dumps(payload))
|
|
self.assertAlmostEqual(replayed[0].get_volume(), original.get_volume(), places=5)
|
|
|
|
def test_boolean_replay_defaults_missing_tracking_policy_to_full(self):
|
|
with scad.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),
|
|
)
|
|
original = scad.cut_rsolid(body, tool)
|
|
|
|
payload = json.loads(scad.export_model_json(session))
|
|
cut_node = next(
|
|
node for node in payload["graph"]["nodes"] if node["op"] == "make_cut_rsolid"
|
|
)
|
|
self.assertEqual(cut_node["params"].pop("tracking_policy"), "full")
|
|
|
|
replayed = scad.replay_model_json(json.dumps(payload))
|
|
self.assertAlmostEqual(replayed[0].get_volume(), original.get_volume(), places=6)
|
|
|
|
def test_replay_selection_cardinality_mismatch_raises_by_default(self):
|
|
with scad.GraphSession() as session:
|
|
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
|
scad.fillet_rsolid(box, [box.get_edges(0)], 0.2)
|
|
|
|
payload = json.loads(scad.export_model_json(session))
|
|
fillet_node = next(
|
|
node for node in payload["graph"]["nodes"] if node["op"] == "make_fillet_rsolid"
|
|
)
|
|
fillet_node["params"]["selected_edge_node_ids"] = []
|
|
fillet_node["params"]["selected_edges"] = []
|
|
fillet_node["params"]["selected_edge_indices"] = []
|
|
fillet_node["params"]["selection_query"] = scad.ql.edges().take(2).exactly(2).to_dict()
|
|
fillet_node["params"]["edge_count"] = 1
|
|
|
|
restored = import_model_json(json.dumps(payload))["graph"]
|
|
with self.assertRaises(ValueError):
|
|
replay_graph(restored)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|