feat: integrate SimpleCADAPI 2.0.2 CAD workflows

This commit is contained in:
Jerry
2026-08-03 11:17:05 +08:00
parent b5738e9109
commit c3a0f269b7
481 changed files with 110229 additions and 12826 deletions
File diff suppressed because one or more lines are too long
@@ -0,0 +1,65 @@
"""Shared-corpus replay and characterization helpers for Python tests."""
from __future__ import annotations
import base64
from collections import Counter
from copy import deepcopy
from typing import Any, Mapping
from simplecadapi.scene import with_scene_revision
def decode_base64(value: str) -> bytes:
return base64.b64decode(value, validate=True)
def corpus_blobs(
corpus: Mapping[str, Any], case: Mapping[str, Any]
) -> dict[str, bytes]:
pool = {
uri: decode_base64(payload) for uri, payload in corpus["blobs"].items()
}
blobs = {uri: pool[uri] for uri in case["blob_uris"]}
blobs.update(
{
uri: decode_base64(payload)
for uri, payload in case.get("blob_mutations", {}).items()
}
)
return blobs
def apply_scene_field_case(
scene: Mapping[str, Any], case: Mapping[str, Any]
) -> dict[str, Any]:
result = deepcopy(dict(scene))
for mutation in case["mutations"]:
current: Any = result
path = mutation["path"]
for part in path[:-1]:
current = current[part]
if mutation["operation"] == "delete":
del current[path[-1]]
else:
current[path[-1]] = deepcopy(mutation.get("value"))
return with_scene_revision(result) if case["recompute_revision"] else result
def scene_shape_facts(scene: Mapping[str, Any]) -> dict[str, Any]:
nodes = scene["nodes"]
occurrence_counts = Counter(node["definition_id"] for node in nodes)
return {
"definition_count": len(scene["definitions"]),
"definition_occurrence_counts": dict(sorted(occurrence_counts.items())),
"edge_asset_count": len(scene["edge_assets"]),
"entity_asset_count": len(scene["entity_assets"]),
"geometry_asset_count": len(scene["geometry_assets"]),
"maximum_depth": max(
len(node["source"].get("component_path", [])) for node in nodes
),
"node_count": len(nodes),
"root_node_ids": [
node["node_id"] for node in nodes if node["parent_node_id"] is None
],
}
+9 -4
View File
@@ -285,9 +285,14 @@ class TestBooleanOperations(unittest.TestCase):
with self.assertRaises(scad.SimpleCADError) as ctx:
scad.union_rsolid(solids)
message = str(ctx.exception)
self.assertIn("union_rsolid", message)
self.assertIn("单个Solid结果", message)
error = ctx.exception
self.assertEqual(error.operation, "union_rsolid")
self.assertEqual(
error.guidance.what_happened, "Failed to compute the boolean union."
)
self.assertTrue(
any("exactly one solid" in cause for cause in error.guidance.possible_causes)
)
def test_union_touching_boxes_cleans_splitter_faces(self):
"""Test union of face-touching boxes follows CadQuery-style clean behavior."""
@@ -642,7 +647,7 @@ class TestComplexExamples(unittest.TestCase):
tooth = scad.extrude_rsolid(tooth_profile, (0, 0, 1), 1.2)
# 合并一个齿到基础上
gear = scad.union_rsolid([gear_base, tooth])
gear = scad.union_rsolid([gear_base, tooth], glue=False)
# 验证
self.assertIsInstance(gear, scad.Solid)
+41 -1
View File
@@ -101,6 +101,10 @@ class TestAutoDocsGenPathResolution(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp_dir:
package_root = Path(tmp_dir) / "src/simplecadapi"
package_root.mkdir(parents=True, exist_ok=True)
backend_root = package_root / "translator/freecad_translator"
backend_root.mkdir(parents=True)
(backend_root / "api.py").write_text("", encoding="utf-8")
(backend_root / "translator.py").write_text("", encoding="utf-8")
resolved = auto_docs_gen._default_source_files(package_root)
@@ -108,11 +112,12 @@ class TestAutoDocsGenPathResolution(unittest.TestCase):
self.assertIn("serializer.py", resolved_names)
self.assertIn("graph.py", resolved_names)
self.assertIn("expr.py", resolved_names)
self.assertIn("tolerance.py", resolved_names)
self.assertIn("sketch.py", resolved_names)
self.assertIn("math.py", resolved_names)
self.assertIn("translator/freecad_translator/api.py", resolved_names)
self.assertIn(
"translator/freecad_translator/script_translator.py",
"translator/freecad_translator/translator.py",
resolved_names,
)
@@ -295,6 +300,41 @@ def fit_cubic_bspline_control_points(sample_points, *, tolerance=1e-3):
readme,
)
def test_generate_markdown_includes_physical_units_category(self):
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
init_file = tmp_path / "__init__.py"
init_file.write_text(
"__all__ = ['Dimension', 'convert_value']\n", encoding="utf-8"
)
source_file = tmp_path / "units.py"
source_file.write_text(
'''
class Dimension:
"""Physical dimension."""
def convert_value(value, from_unit, to_unit):
"""Convert compatible units."""
return value
'''.strip()
+ "\n",
encoding="utf-8",
)
output_dir = tmp_path / "docs/api"
generator = auto_docs_gen.APIDocumentGenerator(
source_files=[source_file], output_dirs=[output_dir], quiet=True
)
generator.extract_apis()
generator.generate_markdown_docs()
readme = (output_dir / "README.md").read_text(encoding="utf-8")
self.assertIn("## Physical Units", readme)
self.assertIn("[Dimension](Dimension.md)", readme)
self.assertIn("[convert_value](convert_value.md)", readme)
def test_generate_stdlib_markdown_uses_stdlib_index_and_import_surface(self):
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
@@ -0,0 +1,108 @@
"""Structural checks for the example model/session contract."""
import ast
import importlib.util
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[1]
EXAMPLES = ROOT / "examples"
def _model_files() -> tuple[Path, ...]:
return (
EXAMPLES / "04_dimension_tolerance_chain.py",
EXAMPLES / "08_constrained_sketch.py",
EXAMPLES / "09_naca0016_blade_freecad.py",
EXAMPLES / "10_part_assembly.py",
EXAMPLES / "16_compact_two_stage_planetary_reducer" / "main.py",
EXAMPLES / "20_integrated_bldc_joint_actuator" / "main.py",
)
def _source_files() -> tuple[Path, ...]:
return tuple(
path
for path in EXAMPLES.rglob("*.py")
if "out" not in path.relative_to(EXAMPLES).parts
)
def _is_decorator(node: ast.expr, name: str) -> bool:
if isinstance(node, ast.Call):
node = node.func
return (
isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name)
and node.value.id == "scad"
and node.attr == name
) or (isinstance(node, ast.Name) and node.id == name)
class TestExampleModelContract(unittest.TestCase):
def test_example_10_output_dir_is_anchored_to_the_example_file(self):
path = EXAMPLES / "10_part_assembly.py"
spec = importlib.util.spec_from_file_location("example_10_path_contract", path)
self.assertIsNotNone(spec)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
self.assertTrue(module.OUT_DIR.is_absolute())
self.assertEqual(
module.OUT_DIR,
EXAMPLES / "out" / "hydraulic_rod_assembly",
)
def test_each_current_model_entry_has_one_explicit_result(self):
for path in _model_files():
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
model_functions = [
node
for node in tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and any(_is_decorator(decorator, "model") for decorator in node.decorator_list)
]
self.assertEqual(len(model_functions), 1, path)
self.assertFalse(
isinstance(model_functions[0], ast.AsyncFunctionDef),
f"async model entry is unsupported: {path}",
)
capture_calls = [
node
for node in ast.walk(tree)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "scad"
and node.func.attr == "capture_result"
]
self.assertEqual(len(capture_calls), 1, path)
def test_examples_do_not_own_manual_sessions_or_serialization(self):
for path in _source_files():
source = path.read_text(encoding="utf-8")
self.assertNotIn("GraphSession(", source, path)
self.assertNotIn("export_model_json(", source, path)
self.assertNotIn("export_session_json(", source, path)
def test_make_builders_require_the_active_session(self):
for path in _source_files():
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
if not (node.name.startswith("make_") or node.name.startswith("_make_")):
continue
decorators = node.decorator_list
has_session_contract = any(
_is_decorator(decorator, "requires_session")
or _is_decorator(decorator, "model")
for decorator in decorators
)
self.assertTrue(has_session_contract, f"{path}:{node.lineno}:{node.name}")
if __name__ == "__main__":
unittest.main()
+670 -57
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import hashlib
import json
import importlib
import math
@@ -18,6 +19,7 @@ import xml.etree.ElementTree as ET
import simplecadapi as scad
from simplecadapi import ql
from simplecadapi.graph import GraphSession
from simplecadapi.kernel.ocp_properties import bounding_box
from simplecadapi.topology import OperationGraph
from simplecadapi.translator import freecad_translator
@@ -202,14 +204,266 @@ class TestFreeCADTranslator(unittest.TestCase):
)
self.assertIn("import FreeCAD as App", script)
self.assertIn("Sketcher::SketchObject", script)
self.assertIn("Part::Extrusion", script)
self.assertIn("Part::Box", script)
self.assertIn("_register_graph_folded_alias", script)
self.assertNotIn("doc.addObject('App::Link'", script)
self.assertIn("SimpleCADNodeId", script)
self.assertIn("EXPRESSION_GRAPH_META", script)
self.assertIn("# Step", script)
def test_translate_model_json_emits_tag_selection_link_with_binding_metadata(self):
with GraphSession() as session:
box = scad.make_box_rsolid(width=2.0, height=3.0, depth=4.0)
scad.apply_tag_rselection(
scope=box,
targets=[box],
tag="role.semantic_view",
)
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
self.assertIn("# Step", script)
self.assertIn("apply_tag_rselection", script)
self.assertIn("_register_tag_selection_node", script)
self.assertIn("SimpleCADTagBinding", script)
def test_translate_tagged_cylinder_preserves_topology_identity_bindings(self):
with GraphSession() as session:
scad.make_cylinder_rsolid(
radius=2.0,
height=5.0,
tag_prefix="shaft",
)
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
self.assertIn("Part::Cylinder", script)
self.assertIn("_register_tag_selection_node", script)
self.assertIn("shaft.face.start", script)
self.assertIn("topology_name", script)
def test_translate_tagged_box_preserves_topology_identity_bindings(self):
with GraphSession() as session:
scad.make_box_rsolid(
width=2.0,
height=3.0,
depth=4.0,
tag_prefix="housing",
top_face_tag="role.lid_mount",
)
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
self.assertIn("Part::Box", script)
self.assertIn("_register_tag_selection_node", script)
self.assertIn("housing.face.top", script)
self.assertIn("role.lid_mount", script)
self.assertIn("topology_name", script)
self.assertIn("operation_output_role", script)
def test_translate_tagged_cone_preserves_topology_identity_bindings(self):
with GraphSession() as session:
scad.make_cone_rsolid(
bottom_radius=2.0,
height=4.0,
top_radius=1.0,
tag_prefix="adapter",
end_face_tag="role.outlet",
)
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
self.assertIn("Part::Cone", script)
self.assertIn("_register_tag_selection_node", script)
self.assertIn("adapter.face.end", script)
self.assertIn("adapter.edge.seam", script)
self.assertIn("role.outlet", script)
self.assertIn("topology_name", script)
self.assertIn("operation_output_role", script)
def test_translate_constrained_sketch_preserves_topology_tag_contract(self):
with GraphSession() as session:
sketch = scad.make_sketch_rsketch(name="rect")
for point_id, x, y in (
("p0", 0.0, 0.0),
("p1", 2.0, 0.0),
("p2", 2.0, 1.0),
("p3", 0.0, 1.0),
):
sketch = scad.add_point_rsketch(sketch, point_id, x, y)
for entity_id, start, end in (
("bottom", "p0", "p1"),
("right", "p1", "p2"),
("top", "p2", "p3"),
("left", "p3", "p0"),
):
sketch = scad.add_line_rsketch(sketch, entity_id, start, end)
scad.make_face_from_sketch_rface(sketch)
model_json = scad.export_model_json(session)
script = freecad_translator.translate_model_json_to_freecad_script(
model_json
)
self.assertIn("SimpleCADSketchPromotion", script)
self.assertIn("sketch_entity.bottom", script)
self.assertIn("topology_name", script)
self.assertIn("profile_id", script)
def test_translate_model_json_tag_branches_are_distinct_fcstd_links(self):
with GraphSession() as session:
box = scad.make_box_rsolid(width=2.0, height=3.0, depth=4.0)
scad.apply_tag_rselection(
scope=box,
targets=[box],
tag="role.left_branch",
)
scad.apply_tag_rselection(
scope=box,
targets=[box],
tag="role.right_branch",
)
model_json = scad.export_model_json(session)
expected_bindings = sorted(
json.loads(model_json)["semantic_bindings"],
key=lambda binding: binding["tag"],
)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
links = sorted(
[
obj
for obj in doc.Objects
if getattr(obj, 'SimpleCADOp', '') == 'apply_tag_rselection'
],
key=lambda obj: json.loads(obj.SimpleCADTagBinding)['tag'],
)
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'names': [obj.Name for obj in links],
'type_ids': [obj.TypeId for obj in links],
'linked_names': [obj.LinkedObject.Name for obj in links],
'volumes': [round(float(obj.Shape.Volume), 6) for obj in links],
'bindings': [json.loads(obj.SimpleCADTagBinding) for obj in links],
'node_ids': [obj.SimpleCADNodeId for obj in links],
}, fh)
"""
result = self._inspect_fcstd_json(model_json, probe)
self.assertEqual(result["type_ids"], ["App::Link", "App::Link"])
self.assertEqual(len(set(result["names"])), 2)
self.assertEqual(len(set(result["linked_names"])), 1)
self.assertTrue(set(result["names"]).isdisjoint(result["linked_names"]))
self.assertEqual(result["volumes"], [24.0, 24.0])
self.assertEqual(result["bindings"], expected_bindings)
self.assertEqual(len(set(result["node_ids"])), 2)
def test_translate_model_json_tagged_parts_preserve_nested_placements(self):
with GraphSession() as session:
lower_body = scad.make_box_rsolid(
width=2.0,
height=4.0,
depth=1.0,
bottom_face_center=(0.0, 0.0, 0.0),
)
upper_body = scad.make_box_rsolid(
width=2.0,
height=4.0,
depth=1.0,
bottom_face_center=(0.0, 0.0, 10.0),
)
lower_body = scad.apply_tag(shape=lower_body, tag="role.structure")
upper_body = scad.apply_tag(shape=upper_body, tag="role.structure")
upper_body = scad.apply_tag(shape=upper_body, tag="role.upper")
lower = scad.make_part_rpart(part_id="lower", body=lower_body)
upper = scad.make_part_rpart(part_id="upper", body=upper_body)
child = scad.make_assembly_rassembly(assembly_id="child")
child = scad.add_component_rassembly(
assembly=child,
item=lower,
component_id="lower",
placement=scad.identity_placement_rplacement(),
)
child = scad.add_component_rassembly(
assembly=child,
item=upper,
component_id="upper",
placement=scad.identity_placement_rplacement(),
)
root = scad.make_assembly_rassembly(assembly_id="root")
root = scad.add_component_rassembly(
assembly=root,
item=child,
component_id="child",
placement=scad.make_placement_rplacement(
origin=(20.0, 30.0, 5.0),
x_axis=(0.0, 1.0, 0.0),
y_axis=(-1.0, 0.0, 0.0),
),
)
scad.make_compound_from_assembly_rcompound(assembly=root)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
tag_links = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'apply_tag_rselection']
upper_part = next(obj for obj in doc.Objects if getattr(obj, 'SimpleCADPartId', '') == 'upper')
upper_body = next(obj for obj in upper_part.Group if hasattr(obj, 'SimpleCADSourceBodyNodeId'))
child_link = next(obj for obj in doc.Objects if getattr(obj, 'SimpleCADComponentId', '') == 'child')
compound = next(obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_compound_from_assembly_rcompound')
def bbox(shape):
bounds = shape.BoundBox
return [
round(float(value), 3)
for value in (
bounds.XMin,
bounds.YMin,
bounds.ZMin,
bounds.XMax,
bounds.YMax,
bounds.ZMax,
)
]
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'tag_link_transforms': [bool(obj.LinkTransform) for obj in tag_links],
'upper_body_bbox': bbox(upper_body.Shape),
'child_bbox': bbox(child_link.Shape),
'child_solids': len(child_link.Shape.Solids),
'child_volume': round(float(child_link.Shape.Volume), 3),
'compound_bbox': bbox(compound.Shape),
'compound_solids': len(compound.Shape.Solids),
'compound_volume': round(float(compound.Shape.Volume), 3),
}, fh)
"""
result = self._inspect_fcstd_json(scad.export_model_json(session=session), probe)
self.assertTrue(result["tag_link_transforms"])
self.assertTrue(all(result["tag_link_transforms"]))
self.assertEqual(result["upper_body_bbox"], [-1.0, -2.0, 10.0, 1.0, 2.0, 11.0])
self.assertEqual(result["child_bbox"], [18.0, 29.0, 5.0, 22.0, 31.0, 16.0])
self.assertEqual(result["child_solids"], 2)
self.assertEqual(result["child_volume"], 16.0)
self.assertEqual(result["compound_bbox"], result["child_bbox"])
self.assertEqual(result["compound_solids"], 2)
self.assertEqual(result["compound_volume"], 16.0)
def test_translate_model_json_preserves_part_assembly_semantics_in_script(self):
with GraphSession() as session:
body = scad.make_box_rsolid(2.0, 3.0, 1.0)
@@ -1002,6 +1256,10 @@ subassembly_links = [obj for obj in doc.Objects if obj.TypeId == 'Assembly::Asse
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'assembly_ids': sorted(obj.SimpleCADAssemblyId for obj in assembly_objs),
'assembly_visibility': {
obj.SimpleCADAssemblyId: bool(obj.Visibility)
for obj in assembly_objs
},
'subassembly_component_ids': [obj.SimpleCADComponentId for obj in subassembly_links],
'subassembly_linked_type_ids': [obj.LinkedObject.TypeId for obj in subassembly_links],
'subassembly_x': [round(obj.Placement.Base.x, 3) for obj in subassembly_links],
@@ -1012,6 +1270,10 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh:
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["assembly_ids"], ["child", "root"])
self.assertEqual(
result["assembly_visibility"],
{"child": False, "root": True},
)
self.assertEqual(result["subassembly_component_ids"], ["child_1"])
self.assertEqual(result["subassembly_linked_type_ids"], ["Assembly::AssemblyObject"])
self.assertEqual(result["subassembly_x"], [4.0])
@@ -1080,18 +1342,50 @@ links = [
if obj.TypeId == 'Assembly::AssemblyLink'
and getattr(obj, 'SimpleCADComponentId', '') == 'child_1'
]
joints = [
obj for obj in doc.Objects
if hasattr(obj, 'SimpleCADConstraint')
and json.loads(obj.SimpleCADConstraint).get('constraint_id') == 'mount_output'
]
all_joints = [obj for obj in doc.Objects if hasattr(obj, 'SimpleCADConstraint')]
grounds = [obj for obj in doc.Objects if hasattr(obj, 'SimpleCADGroundedComponent')]
assembly_by_id = {
str(obj.SimpleCADAssemblyId): obj
for obj in doc.Objects
if obj.TypeId == 'Assembly::AssemblyObject'
and hasattr(obj, 'SimpleCADAssemblyId')
}
def constraint_payload(obj):
return json.loads(obj.SimpleCADConstraint)
def reference_name(reference):
if not reference:
return ''
target = reference[0]
return str(getattr(target, 'SimpleCADComponentId', '') or getattr(target, 'Name', ''))
child_assembly = assembly_by_id['child']
child_joint_group = next(
obj for obj in list(getattr(child_assembly, 'OutList', []) or [])
if getattr(obj, 'TypeId', '') == 'Assembly::JointGroup'
)
child_joints = [
obj for obj in list(getattr(child_joint_group, 'Group', []) or [])
if hasattr(obj, 'SimpleCADConstraint')
]
pivot = next(obj for obj in child_joints if constraint_payload(obj).get('constraint_id') == 'pivot')
mount = next(obj for obj in all_joints if constraint_payload(obj).get('constraint_id') == 'mount_output')
child_joint_ids = {constraint_payload(obj).get('constraint_id') for obj in child_joints}
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'count': len(links),
'rigid': [bool(obj.Rigid) for obj in links],
'output_reference_type': str(joints[0].Reference2[0].TypeId),
'output_reference_type': str(mount.Reference2[0].TypeId),
'child_joint_ids': [constraint_payload(obj).get('constraint_id') for obj in child_joints],
'child_joint_statuses': [str(getattr(obj, 'SimpleCADConstraintTranslationStatus', '')) for obj in child_joints],
'pivot_reference_names': [reference_name(pivot.Reference1), reference_name(pivot.Reference2)],
'pivot_status': str(getattr(pivot, 'SimpleCADConstraintTranslationStatus', '')),
'root_joint_ids': [
constraint_payload(obj).get('constraint_id')
for obj in all_joints
if constraint_payload(obj).get('constraint_id') not in child_joint_ids
],
'joint_visibility': [bool(obj.Visibility) for obj in all_joints],
'ground_visibility': [bool(obj.Visibility) for obj in grounds],
}, fh)
@@ -1101,6 +1395,11 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh:
self.assertEqual(result["count"], 1)
self.assertEqual(result["rigid"], [False])
self.assertEqual(result["output_reference_type"], "App::Link")
self.assertEqual(result["child_joint_ids"], ["pivot"])
self.assertEqual(result["child_joint_statuses"], ["native_equivalent"])
self.assertEqual(result["pivot_reference_names"], ["base", "arm"])
self.assertEqual(result["pivot_status"], "native_equivalent")
self.assertEqual(result["root_joint_ids"], ["mount_output"])
self.assertTrue(result["joint_visibility"])
self.assertTrue(all(visible is False for visible in result["joint_visibility"]))
self.assertTrue(result["ground_visibility"])
@@ -1416,6 +1715,139 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh:
self.assertIn("var_fold_ty", normalized_expr_map["Placement.Base.y"])
self.assertIn("var_fold_tz", normalized_expr_map["Placement.Base.z"])
def test_expression_circle_normal_uses_extrusion_profile_fcstd(self):
normal_y = scad.var("circle_normal_y", 1.0)
with GraphSession() as session:
profile = scad.make_circle_rface(
(0.0, 0.0, 0.0),
1.0,
normal=(0.0, normal_y, 0.0),
)
scad.extrude_rsolid(profile, (0.0, 1.0, 0.0), 2.0)
payload = scad.export_model_json(session)
script = freecad_translator.translate_model_json_to_freecad_script(payload)
self.assertNotIn("doc.addObject('Part::Cylinder'", script)
self.assertIn("doc.addObject('Sketcher::SketchObject'", script)
self.assertIn("doc.addObject('Part::Extrusion'", script)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
extrusions = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_extrude_rsolid']
cylinders = [obj for obj in doc.Objects if obj.TypeId == 'Part::Cylinder']
extrusion = extrusions[0]
shape = extrusion.Shape
sketches = [obj for obj in doc.Objects if obj.TypeId == 'Sketcher::SketchObject']
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'extrusion_count': len(extrusions),
'cylinder_count': len(cylinders),
'solid_count': 0 if shape.isNull() else len(shape.Solids),
'volume': 0.0 if shape.isNull() else float(shape.Volume),
'expr_support': [getattr(obj, 'SimpleCADExprSupport', '') for obj in sketches],
'expr_limitations': [getattr(obj, 'SimpleCADExprLimitation', '') for obj in sketches],
}, fh)
"""
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["extrusion_count"], 1)
self.assertEqual(result["cylinder_count"], 0)
self.assertEqual(result["solid_count"], 1)
self.assertAlmostEqual(result["volume"], 2.0 * 3.141592653589793, places=5)
self.assertIn("limited", result["expr_support"])
self.assertTrue(
any("orientation" in value.lower() for value in result["expr_limitations"])
)
def test_oblique_circle_extrusion_preserves_source_geometry_fcstd(self):
with GraphSession() as session:
profile = scad.make_circle_rface((0.0, 0.0, 0.0), 1.0)
source = scad.extrude_rsolid(profile, (1.0, 0.0, 1.0), 2.0)
payload = scad.export_model_json(session)
script = freecad_translator.translate_model_json_to_freecad_script(payload)
self.assertNotIn("doc.addObject('Part::Cylinder'", script)
self.assertIn("doc.addObject('Part::Extrusion'", script)
source_box = bounding_box(source.wrapped)
source_bounds = [
source_box.xmin,
source_box.ymin,
source_box.zmin,
source_box.xmax,
source_box.ymax,
source_box.zmax,
]
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
extrusion = next(obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_extrude_rsolid')
shape = extrusion.Shape
box = shape.BoundBox
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'type_id': extrusion.TypeId,
'volume': float(shape.Volume),
'bbox': [
float(box.XMin), float(box.YMin), float(box.ZMin),
float(box.XMax), float(box.YMax), float(box.ZMax),
],
}, fh)
"""
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["type_id"], "Part::Extrusion")
self.assertAlmostEqual(result["volume"], source.get_volume(), places=5)
for actual, expected in zip(result["bbox"], source_bounds):
self.assertAlmostEqual(actual, expected, places=5)
def test_transform_links_compose_non_identity_source_placement_fcstd(self):
with GraphSession() as session:
source = scad.make_box_rsolid(
1.0,
1.0,
1.0,
bottom_face_center=(2.0, 0.0, 0.0),
)
scad.translate_shape(source, (3.0, 0.0, 0.0))
scad.translate_shape(source, (0.0, 3.0, 0.0))
payload = scad.export_model_json(session)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
links = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_translate_rshape']
def bbox(shape):
box = shape.BoundBox
return [
float(box.XMin), float(box.YMin), float(box.ZMin),
float(box.XMax), float(box.YMax), float(box.ZMax),
]
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'link_transforms': [bool(link.LinkTransform) for link in links],
'bounds': [bbox(link.Shape) for link in links],
}, fh)
"""
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["link_transforms"], [True, True])
self.assertEqual(
result["bounds"],
[
[4.5, -0.5, 0.0, 5.5, 0.5, 1.0],
[1.5, 2.5, 0.0, 2.5, 3.5, 1.0],
],
)
def test_translate_model_json_keeps_link_when_translate_input_is_shared(self):
with GraphSession() as session:
profile = scad.make_circle_rface((0.0, 0.0, 0.0), 1.0)
@@ -1456,8 +1888,9 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh:
scad.export_model_json(session)
)
self.assertIn("Sketcher::SketchObject", script)
self.assertIn("Part::Extrusion", script)
self.assertIn("Part::Box", script)
self.assertIn("RESULT_NODE_IDS", script)
self.assertNotIn("evaluated_graph", script)
def test_translate_model_json_requires_graph(self):
payload = {
@@ -1497,6 +1930,93 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh:
"=<<SimpleCADExpressions>>.var_r * <<SimpleCADExpressions>>.const_", script
)
def test_translate_model_json_preserves_dimension_tolerances(self):
width = scad.var("width", 10.0, tolerance=(-0.1, 0.2))
with GraphSession() as session:
scad.make_box_rsolid(width, 2.0, 1.0)
session.require_tolerance(width * 2.0, (-0.2, 0.4), name="overall")
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
self.assertIn("TOLERANCE_GRAPH =", script)
self.assertIn("simplecad_tolerance_graph", script)
self.assertIn("overall", script)
self.assertIn("expr_sheet.set('E1', \"-0.1\")", script)
self.assertIn("expr_sheet.set('F1', \"0.2\")", script)
def test_translate_model_json_preserves_units_and_uses_canonical_values(self):
width = scad.var(
"width", 1.0, unit="in", tolerance=0.1, tolerance_unit="mm"
)
angle = scad.var("angle", math.pi / 2.0, unit="rad", tolerance=0.5)
with GraphSession() as session:
scad.make_box_rsolid(width, 2.0, 1.0)
scad.rotate_shape(
scad.make_box_rsolid(1.0, 1.0, 1.0),
angle,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, 0.0),
)
session.require_tolerance(width, 0.004, tolerance_unit="in")
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
self.assertIn('expr_sheet.set("B1", "25.4")', script)
self.assertIn("expr_sheet.set('G1', \"in\")", script)
self.assertIn("expr_sheet.set('H1', \"mm\")", script)
self.assertIn("expr_sheet.set('I1', \"Length\")", script)
self.assertIn("expr_sheet.set('G2', \"rad\")", script)
self.assertIn("expr_sheet.set('H2', \"rad\")", script)
self.assertIn("expr_sheet.set('I2', \"Angle\")", script)
self.assertIn("'tolerance_unit': 'in'", script)
self.assertIn("'target_dimension': {'angle': 0, 'length': 1}", script)
def test_translate_model_json_disambiguates_same_name_variable_aliases(self):
first = scad.var("width", 10.0)
second = scad.var("width", 20.0)
expression = first + second
with GraphSession() as session:
scad.make_box_rsolid(expression, 2.0, 1.0)
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
suffix = hashlib.sha256(second.expr_id.encode("utf-8")).hexdigest()[:8]
second_alias = f"var_width_{suffix}"
self.assertIn('setAlias("B1", "var_width")', script)
self.assertIn(f'setAlias("B2", "{second_alias}")', script)
self.assertIn(f"<<SimpleCADExpressions>>.{second_alias}", script)
def test_translate_model_json_uses_ascii_bounded_unique_aliases(self):
variables = [
scad.var("宽度" + "x" * 80, float(index + 1))
for index in range(3)
]
with GraphSession() as session:
scad.make_box_rsolid(
variables[0] + variables[1] + variables[2], 2.0, 1.0
)
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
aliases = []
for line in script.splitlines():
marker = "expr_sheet.setAlias("
if marker not in line:
continue
arguments = line.split("(", 1)[1].rsplit(")", 1)[0]
aliases.append(json.loads("[" + arguments + "]")[1])
self.assertEqual(len(aliases), len(set(aliases)))
self.assertTrue(all(alias.isascii() for alias in aliases))
self.assertTrue(all(len(alias) <= 64 for alias in aliases))
def test_translate_model_json_uses_semantic_spreadsheet_aliases_and_formulas(self):
x = scad.var("hub_radius", 6.5, comment="Hub outer radius")
expr = x + 2.0
@@ -1660,6 +2180,38 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh:
self.assertIn(".Angle = float(", script)
self.assertIn("'Angle'", script)
def test_wire_revolve_keeps_native_source_dependency_fcstd(self):
with GraphSession() as session:
profile = scad.make_rectangle_rwire(
1.0,
1.0,
center=(1.5, 0.0, 0.0),
normal=(0.0, 1.0, 0.0),
)
scad.revolve_rsolid(profile)
payload = scad.export_model_json(session)
script = freecad_translator.translate_model_json_to_freecad_script(payload)
self.assertNotIn("make_revolve_rsolid_profile", script)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
revolution = next(obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_revolve_rsolid')
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'source_op': getattr(revolution.Source, 'SimpleCADOp', ''),
'solid_count': len(revolution.Shape.Solids),
'volume': float(revolution.Shape.Volume),
}, fh)
"""
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["source_op"], "make_wire_from_edges_rwire")
self.assertEqual(result["solid_count"], 1)
self.assertGreater(result["volume"], 0.0)
def test_translate_model_json_uses_single_graph_sweep_helper(self):
with GraphSession() as session:
profile = scad.make_circle_rface((0.0, 0.0, 0.0), 0.5)
@@ -1680,6 +2232,72 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh:
self.assertIn(".Frenet = bool(", script)
self.assertIn("'Frenet'", script)
def test_translate_model_json_emulates_twisted_sweep_with_solid_loft(self):
with GraphSession() as session:
profile = scad.make_rectangle_rface(
width=2.0,
height=1.0,
center=(0.0, 0.0, 0.0),
)
scad.twisted_sweep_rsolid(
profile=profile,
distance=4.0,
twist_angle=45.0,
)
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
self.assertIn("_twisted_sweep_loft_shape", script)
self.assertIn("Part.makeLoft(sections, True, False, False, 5)", script)
self.assertIn("SimpleCADTranslationSupport", script)
self.assertIn("'emulated'", script)
self.assertIn("approximates the continuous SimpleCAD sweep", script)
def test_translate_model_json_twisted_sweep_emulation_fcstd_valid(self):
with GraphSession() as session:
profile = scad.make_rectangle_rface(
width=2.0,
height=1.0,
center=(0.0, 0.0, 0.0),
)
scad.twisted_sweep_rsolid(
profile=profile,
distance=4.0,
twist_angle=45.0,
)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
objects = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_twisted_sweep_rsolid']
obj = objects[-1]
shape = obj.Shape
note = doc.getObject('simplecad_translation_limitations')
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'count': len(objects),
'support': getattr(obj, 'SimpleCADTranslationSupport', ''),
'limitation': getattr(obj, 'SimpleCADTranslationLimitation', ''),
'valid': shape.isValid(),
'solid_count': len(shape.Solids),
'volume': float(shape.Volume),
'note_payload': getattr(note, 'Payload', '') if note is not None else '',
}, fh)
"""
result = self._inspect_fcstd_json(scad.export_model_json(session), probe)
self.assertEqual(result["count"], 1)
self.assertEqual(result["support"], "emulated")
self.assertIn("smooth solid loft", result["limitation"])
self.assertTrue(result["valid"])
self.assertEqual(result["solid_count"], 1)
self.assertGreater(result["volume"], 0.0)
self.assertIn("make_twisted_sweep_rsolid", result["note_payload"])
def test_translate_model_json_materializes_ql_selected_face_profile_for_sweep(self):
with GraphSession() as session:
base = scad.make_circle_rface((0.0, 0.0, 0.0), 0.25)
@@ -1887,9 +2505,9 @@ import FreeCAD as App
import Part
doc = App.openDocument(FCSTD_PATH)
target = max(
(obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'Sketcher::SketchObject'),
key=lambda obj: len(list(getattr(obj, 'Geometry', []))),
target = next(
obj for obj in doc.Objects
if getattr(obj, 'SimpleCADOp', '') == 'make_wire_from_edges_rwire'
)
shape = target.Shape
wire = shape.Wires[0]
@@ -2102,12 +2720,17 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh:
def test_translate_model_json_binds_feature_properties_to_freecad_expressions(self):
with GraphSession() as session:
helix = scad.make_helix_rwire(1.0, 3.0, 2.0)
face = scad.make_circle_rface((0.0, 0.0, 0.0), 1.0)
scad.extrude_rsolid(face, (0.0, 0.0, 1.0), 2.0)
extrude_face = scad.make_rectangle_rface(width=2.0, height=1.0)
scad.extrude_rsolid(
profile=extrude_face,
direction=(0.0, 0.0, 1.0),
distance=2.0,
)
rev_wire = scad.make_rectangle_rwire(1.0, 2.0, center=(2.0, 0.0, 0.0))
rev_face = scad.make_face_from_wire_rface(rev_wire)
scad.revolve_rsolid(rev_face, axis=(0.0, 0.0, 1.0), angle=180.0)
scad.sweep_rsolid(face, helix, is_frenet=True)
sweep_face = scad.make_circle_rface((0.0, 0.0, 0.0), 1.0)
scad.sweep_rsolid(sweep_face, helix, is_frenet=True)
box = scad.make_box_rsolid(2.0, 2.0, 2.0)
scad.shell_rsolid(box, [box.get_faces(0)], 0.25)
@@ -2828,35 +3451,6 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh:
payload_obj["spline_expr"]["reason"],
)
def test_single_gear_model_has_single_leaf_after_parametric_build(self):
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir)
subprocess.run(
[
"uv",
"run",
"python",
"examples/06_parametric_gear_model.py",
"--output-dir",
str(output_dir),
],
check=True,
text=True,
capture_output=True,
)
payload = json.loads(
(output_dir / "parametric_gear.model.json").read_text(
encoding="utf-8"
)
)
self.assertEqual(len(payload["leaf_ids"]), 1)
var_names = [
node.get("name")
for node in payload["expression_graph"]["nodes"]
if node.get("kind") == "var"
]
self.assertNotIn("pitch_radius", var_names)
def test_naca0016_blade_example_translates_bspline_sections_to_fcstd(self):
freecad_cmd = self._discover_freecadcmd()
if not freecad_cmd:
@@ -2892,25 +3486,34 @@ import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
sketches = [obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'Sketcher::SketchObject']
sections = [
obj for obj in doc.Objects
if getattr(obj, 'SimpleCADOp', '') == 'make_wire_from_edges_rwire'
]
bspline_counts = []
placements = []
for sketch in sketches:
bspline_counts.append(sum(1 for geom in getattr(sketch, 'Geometry', []) if type(geom).__name__ == 'BSplineCurve'))
for section in sections:
bspline_counts.append(
sum(1 for edge in section.Shape.Edges if type(edge.Curve).__name__ == 'BSplineCurve')
)
placements.append({
'z': float(sketch.Placement.Base.z),
'angle': float(sketch.Placement.Rotation.Angle),
'axis': [float(sketch.Placement.Rotation.Axis.x), float(sketch.Placement.Rotation.Axis.y), float(sketch.Placement.Rotation.Axis.z)],
'z': float(section.Placement.Base.z),
'angle': float(section.Placement.Rotation.Angle),
'axis': [float(section.Placement.Rotation.Axis.x), float(section.Placement.Rotation.Axis.y), float(section.Placement.Rotation.Axis.z)],
})
lofts = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_loft_rsolid']
links = [obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'App::Link']
transform_links = [
obj for obj in doc.Objects
if getattr(obj, 'TypeId', '') == 'App::Link'
and getattr(obj, 'SimpleCADOp', '') in {'make_translate_rshape', 'make_rotate_rshape'}
]
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'sketch_count': len(sketches),
'section_count': len(sections),
'bspline_counts': bspline_counts,
'total_bspline_geometry': sum(bspline_counts),
'placements': placements,
'link_count': len(links),
'transform_link_count': len(transform_links),
'loft_count': len(lofts),
'loft_solid_count': 0 if not lofts else len(lofts[-1].Shape.Solids),
'loft_volume': 0.0 if not lofts else float(lofts[-1].Shape.Volume),
@@ -2930,10 +3533,10 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh:
node for node in payload["graph"]["nodes"] if node.get("op") == "make_spline_redge"
]
self.assertEqual(len(bspline_nodes), 6)
self.assertEqual(result["sketch_count"], 6)
self.assertEqual(result["section_count"], 6)
self.assertEqual(result["total_bspline_geometry"], 6)
self.assertTrue(all(count == 1 for count in result["bspline_counts"]))
self.assertEqual(result["link_count"], 0)
self.assertEqual(result["transform_link_count"], 0)
self.assertEqual(
[round(item["z"], 3) for item in result["placements"]],
[0.0, 0.8, 1.6, 2.4, 3.2, 4.0],
@@ -2952,6 +3555,7 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh:
op="make_line_redge",
node_id="e1",
params={"start": [0.0, 0.0, 0.0], "end": [1.0, 0.0, 0.0]},
param_exprs={"end": [{"expr_id": "var_x"}, None, None]},
)
e2 = graph.add_node(
op="make_line_redge",
@@ -2974,7 +3578,16 @@ with open(OUT_PATH, 'w', encoding='utf-8') as fh:
"canonical_contract": {"contract_version": "2.0"},
"graph": graph.to_dict(),
"leaf_ids": [wire.node_id],
"expression_graph": {"nodes": []},
"expression_graph": {
"nodes": [
{
"expr_id": "var_x",
"kind": "var",
"name": "x",
"default": 1.0,
}
]
},
"frame_graph": {"nodes": []},
"geometry_registry": [],
"semantic_entity_registry": [],
@@ -0,0 +1,241 @@
"""FreeCAD runtime tests for geometric signature topology matching."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import tempfile
import unittest
from simplecadapi.translator.freecad_translator.runtime import (
assemble_runtime_source,
)
class TestFreeCADTranslatorGSM(unittest.TestCase):
def _discover_freecadcmd(self) -> str | None:
return (
shutil.which("FreeCADCmd")
or shutil.which("freecadcmd")
or (
"/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd"
if os.path.exists(
"/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd"
)
else None
)
)
def _run_runtime_probe(self, probe: str) -> dict:
freecad_cmd = self._discover_freecadcmd()
if not freecad_cmd:
self.skipTest("freecadcmd not available")
with tempfile.TemporaryDirectory() as tmp_dir:
script_path = os.path.join(tmp_dir, "probe.py")
result_path = os.path.join(tmp_dir, "result.json")
script = "\n".join(
[
"import json",
"import math",
"import FreeCAD as App",
"import Part",
"doc = App.newDocument('SimpleCADGSMProbe')",
"GRAPH_NODES = {}",
"GRAPH_OUTPUTS = {}",
"GRAPH_METADATA = {}",
"GRAPH_SELECTIONS = {}",
"GRAPH_SPINE_OBJECTS = {}",
"GRAPH_LIMITATIONS = {}",
"GRAPH_TRANSLATION_LIMITATIONS = {}",
"PRODUCT_VALUES = {}",
"ASSEMBLY_PROJECTION_INPUTS = {}",
"GUI_VISIBILITY_BY_NAME = {}",
"GUI_EXPANDED_BY_NAME = {}",
"GUI_SHAPE_COLOR_BY_NAME = {}",
"GUI_MATERIAL_OVERRIDE_BY_NAME = {}",
"MATERIAL_OBJECTS_BY_ID = {}",
"SIMPLECAD_JOINT_OBJECTS = {}",
"SKETCH_REGISTRY = []",
"OP_EXPRESSION_BINDINGS = {}",
"OP_EXPRESSION_LIMITATIONS = {}",
"EXPRESSION_GRAPH = {}",
f"RESULT_PATH = {json.dumps(result_path)}",
assemble_runtime_source(),
probe,
]
)
with open(script_path, "w", encoding="utf-8") as handle:
handle.write(script)
completed = subprocess.run(
[freecad_cmd, script_path],
check=False,
text=True,
capture_output=True,
)
self.assertEqual(
completed.returncode,
0,
msg=f"stdout={completed.stdout!r}\nstderr={completed.stderr!r}",
)
with open(result_path, "r", encoding="utf-8") as handle:
return json.load(handle)
def test_split_line_selector_resolves_unique_fragment_chain(self):
result = self._run_runtime_probe(
"""
shape = Part.makeCompound([
Part.makeLine(App.Vector(0, 0, 0), App.Vector(1, 0, 0)),
Part.makeLine(App.Vector(1, 0, 0), App.Vector(2, 0, 0)),
])
selector = {
'kind': 'edge',
'geom_type': 'LINE',
'start': [0, 0, 0],
'end': [2, 0, 0],
'center': [1, 0, 0],
'length': 2,
'bbox': {'min': [0, 0, 0], 'max': [2, 0, 0]},
}
indices = _selection_indices_for_selector(shape, selector, context='split test')
with open(RESULT_PATH, 'w', encoding='utf-8') as handle:
json.dump({'indices': indices}, handle)
"""
)
self.assertEqual(result["indices"], [0, 1])
def test_two_source_selectors_resolve_one_merged_edge(self):
result = self._run_runtime_probe(
"""
shape = Part.makeCompound([
Part.makeLine(App.Vector(0, 0, 0), App.Vector(2, 0, 0)),
])
selectors = [
{
'kind': 'edge', 'geom_type': 'LINE',
'start': [0, 0, 0], 'end': [1, 0, 0],
'center': [0.5, 0, 0], 'length': 1,
'bbox': {'min': [0, 0, 0], 'max': [1, 0, 0]},
},
{
'kind': 'edge', 'geom_type': 'LINE',
'start': [1, 0, 0], 'end': [2, 0, 0],
'center': [1.5, 0, 0], 'length': 1,
'bbox': {'min': [1, 0, 0], 'max': [2, 0, 0]},
},
]
indices = _selection_indices_for_selectors(shape, selectors, context='merge test')
with open(RESULT_PATH, 'w', encoding='utf-8') as handle:
json.dump({'indices': indices}, handle)
"""
)
self.assertEqual(result["indices"], [0])
def test_identical_candidates_are_rejected_as_ambiguous(self):
result = self._run_runtime_probe(
"""
shape = Part.makeCompound([
Part.makeLine(App.Vector(0, 0, 0), App.Vector(2, 0, 0)),
Part.makeLine(App.Vector(0, 0, 0), App.Vector(2, 0, 0)),
])
selector = {
'kind': 'edge', 'geom_type': 'LINE',
'start': [0, 0, 0], 'end': [2, 0, 0],
'center': [1, 0, 0], 'length': 2,
'bbox': {'min': [0, 0, 0], 'max': [2, 0, 0]},
}
try:
_selection_indices_for_selector(shape, selector, context='ambiguity test')
except RuntimeError as error:
message = str(error)
else:
message = ''
with open(RESULT_PATH, 'w', encoding='utf-8') as handle:
json.dump({'message': message}, handle)
"""
)
self.assertIn("ambiguous", result["message"].lower())
def test_second_candidate_inside_match_threshold_is_ambiguous(self):
result = self._run_runtime_probe(
"""
shape = Part.makeCompound([
Part.makeLine(App.Vector(0, 0, 0), App.Vector(2, 0, 0)),
Part.makeLine(App.Vector(0, 0.000005, 0), App.Vector(2, 0.000005, 0)),
])
selector = {
'kind': 'edge', 'geom_type': 'LINE',
'start': [0, 0, 0], 'end': [2, 0, 0],
'center': [1, 0, 0], 'length': 2,
'bbox': {'min': [0, 0, 0], 'max': [2, 0, 0]},
}
scores = [
_geo_selector_score(candidate, selector, index)
for index, candidate in enumerate(shape.Edges)
]
try:
_selection_indices_for_selector(shape, selector, context='near ambiguity test')
except RuntimeError as error:
message = str(error)
else:
message = ''
with open(RESULT_PATH, 'w', encoding='utf-8') as handle:
json.dump({'message': message, 'scores': scores}, handle)
"""
)
self.assertLessEqual(result["scores"][1], 1e-4)
self.assertIn("ambiguous", result["message"].lower())
def test_split_full_circle_selector_resolves_unique_closed_chain(self):
result = self._run_runtime_probe(
"""
shape = Part.makeCompound([
Part.Arc(App.Vector(1, 0, 0), App.Vector(0, 1, 0), App.Vector(-1, 0, 0)).toShape(),
Part.Arc(App.Vector(-1, 0, 0), App.Vector(0, -1, 0), App.Vector(1, 0, 0)).toShape(),
])
selector = {
'kind': 'edge', 'geom_type': 'CIRCLE',
'start': [1, 0, 0], 'end': [1, 0, 0],
'center': [0, 0, 0], 'length': 2 * math.pi,
'bbox': {'min': [-1, -1, 0], 'max': [1, 1, 0]},
}
indices = _selection_indices_for_selector(shape, selector, context='circle split test')
with open(RESULT_PATH, 'w', encoding='utf-8') as handle:
json.dump({'indices': indices}, handle)
"""
)
self.assertEqual(result["indices"], [0, 1])
def test_three_source_selectors_resolve_one_merged_edge(self):
result = self._run_runtime_probe(
"""
shape = Part.makeCompound([
Part.makeLine(App.Vector(0, 0, 0), App.Vector(3, 0, 0)),
])
selectors = [
{
'kind': 'edge', 'geom_type': 'LINE',
'start': [index, 0, 0], 'end': [index + 1, 0, 0],
'center': [index + 0.5, 0, 0], 'length': 1,
'bbox': {'min': [index, 0, 0], 'max': [index + 1, 0, 0]},
}
for index in range(3)
]
indices = _selection_indices_for_selectors(shape, selectors, context='three-way merge test')
with open(RESULT_PATH, 'w', encoding='utf-8') as handle:
json.dump({'indices': indices}, handle)
"""
)
self.assertEqual(result["indices"], [0])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,264 @@
"""Host-independent tests for the Fusion 360 translator backend."""
from __future__ import annotations
import ast
import copy
import importlib
import math
import sys
import unittest
import simplecadapi as scad
from simplecadapi import GraphSession
from simplecadapi.translator.errors import TranslationRequestError
def _rectangle_extrusion_model() -> str:
with GraphSession() as session:
profile = scad.make_rectangle_rface(width=2.0, height=1.0)
scad.extrude_rsolid(
profile=profile,
direction=(0.0, 0.0, 1.0),
distance=3.0,
)
return scad.export_model_json(session=session)
class TestFusion360Translator(unittest.TestCase):
def test_backend_imports_without_target_runtime_modules(self):
before = set(sys.modules)
fusion = importlib.import_module("simplecadapi.translator.fusion360_translator")
self.assertNotIn("adsk.core", set(sys.modules) - before)
self.assertNotIn("adsk.fusion", set(sys.modules) - before)
self.assertEqual(fusion.CAPABILITIES.backend_id, "fusion360")
def test_supported_graph_emits_deterministic_compilable_script(self):
model_json = _rectangle_extrusion_model()
from simplecadapi.translator.fusion360_translator import Fusion360Translator
translator = Fusion360Translator(document_name="ContractFusion")
first = translator.translate_model_json(model_json)
second = translator.translate_model_json(model_json)
self.assertEqual(first.content, second.content)
compile(first.content, "<fusion360-script>", "exec")
self.assertFalse(first.metadata["target_runtime_validated"])
self.assertTrue(translator.capabilities.targets[0].requires_external_runtime)
def test_payload_uses_canonical_periodic_curve_frames(self):
with GraphSession() as session:
scad.make_angle_arc_rwire(
(0.0, 0.0, 0.0),
2.0,
0.0,
math.pi / 2.0,
normal=(1.0, 0.0, 0.0),
)
scad.make_helix_rwire(
1.0,
3.0,
2.0,
dir=(1.0, 0.0, 0.0),
)
from simplecadapi.translator.fusion360_translator import Fusion360Translator
script = (
Fusion360Translator()
.translate_model_json(scad.export_model_json(session))
.content
)
module = ast.parse(script)
model_payload = next(
ast.literal_eval(node.value)
for node in module.body
if isinstance(node, ast.Assign)
and any(
isinstance(target, ast.Name) and target.id == "MODEL_PAYLOAD"
for target in node.targets
)
)
curve_nodes = {
node["op"]: node
for node in model_payload["graph"]["nodes"]
if node["op"] in {"make_angle_arc_redge", "make_helix_redge"}
}
for node in curve_nodes.values():
self.assertEqual(node["params"]["_kernel_x_axis"], [-0.0, 0.0, 1.0])
self.assertEqual(node["params"]["_kernel_y_axis"], [0.0, -1.0, 0.0])
self.assertIn("Circle3D.createByCenter", script)
self.assertIn("SweepOrientationTypes.ParallelOrientationType", script)
def test_fallback_scripts_are_deterministic_and_result_scoped(self):
with GraphSession() as session:
profile = scad.make_rectangle_rface(
1.0,
2.0,
center=(2.0, 0.0, 0.0),
normal=(0.0, 1.0, 0.0),
)
scad.revolve_rsolid(profile, axis=(0.0, 1.0, 0.0))
scad.make_box_rsolid(width=4.0, height=5.0, depth=6.0)
model_json = scad.export_model_json(session)
payload = scad.import_model_json(model_json)
graph = payload["graph"]
result_id = next(
node.node_id
for node in graph.topological_order()
if node.op == "make_revolve_rsolid"
)
box_id = next(
node.node_id
for node in graph.topological_order()
if node.op == "make_box_rsolid"
)
from simplecadapi.translator.fusion360_translator import Fusion360Translator
translator = Fusion360Translator(
result_node_ids=[result_id], source_kernel_fallback=True
)
first = translator.translate_model_json(model_json).content
second = translator.translate_model_json(model_json).content
self.assertEqual(first, second)
module = ast.parse(first)
fallback_steps = next(
ast.literal_eval(node.value)
for node in module.body
if isinstance(node, ast.Assign)
and any(
isinstance(target, ast.Name) and target.id == "SOURCE_KERNEL_STEPS"
for target in node.targets
)
)
self.assertIn(result_id, fallback_steps)
self.assertNotIn(box_id, fallback_steps)
def test_implicit_results_prefer_active_assembly_state(self):
from simplecadapi.topology import OperationGraph
from simplecadapi.translator.fusion360_translator import Fusion360Translator
graph = OperationGraph("assembly_states")
locked = graph.add_node(
"make_compound_from_assembly_rcompound",
{"assembly_id": "fixture_locked", "component_count": 1},
node_id="locked_result",
)
operating = graph.add_node(
"make_compound_from_assembly_rcompound",
{"assembly_id": "fixture_operating", "component_count": 1},
node_id="operating_result",
)
payload = {
"schema_version": "2.0",
"graph": graph,
"leaf_ids": [locked.node_id, operating.node_id],
}
script = Fusion360Translator().translate_model_payload_to_script(
payload, graph=graph
)
self.assertIn("ACTIVE_RESULT_STATE = 'operating'", script)
self.assertIn("RESULT_NODE_IDS = ['operating_result']", script)
def test_fallback_accepts_legacy_metadata_only_selector_nodes(self):
with GraphSession() as session:
profile = scad.make_rectangle_rface(3.0, 3.0)
solid = scad.extrude_rsolid(profile, (0.0, 0.0, 1.0), 3.0)
scad.fillet_rsolid(solid, [solid.get_edges(0)], 0.2)
payload = scad.import_model_json(scad.export_model_json(session))
graph_data = copy.deepcopy(payload["graph"].to_dict())
selector_ids = {
node["node_id"]
for node in graph_data["nodes"]
if node["op"] == "make_select_redge"
}
for node in graph_data["nodes"]:
if node["node_id"] in selector_ids:
node["inputs"] = []
else:
node["inputs"] = [
input_ref
for input_ref in node.get("inputs", [])
if input_ref not in selector_ids
]
graph_data["edges"] = [
edge
for edge in graph_data.get("edges", [])
if edge[0] not in selector_ids and edge[1] not in selector_ids
]
from simplecadapi.topology import OperationGraph
from simplecadapi.translator.fusion360_translator import Fusion360Translator
legacy_graph = OperationGraph.from_dict(graph_data)
result_id = next(
node.node_id
for node in legacy_graph.nodes
if node.op == "make_fillet_rsolid"
)
legacy_payload = {
"schema_version": "2.0",
"graph": legacy_graph,
"leaf_ids": [result_id],
}
script = Fusion360Translator(
source_kernel_fallback=True
).translate_model_payload_to_script(legacy_payload, graph=legacy_graph)
module = ast.parse(script)
fallback_steps = next(
ast.literal_eval(node.value)
for node in module.body
if isinstance(node, ast.Assign)
and any(
isinstance(target, ast.Name) and target.id == "SOURCE_KERNEL_STEPS"
for target in node.targets
)
)
self.assertIn(result_id, fallback_steps)
def test_explicit_result_only_executes_its_dependency_closure(self):
with GraphSession() as session:
profile = scad.make_rectangle_rface(width=2.0, height=1.0)
scad.extrude_rsolid(profile, (0.0, 0.0, 1.0), 3.0)
scad.make_box_rsolid(width=1.0, height=2.0, depth=3.0)
model_json = scad.export_model_json(session=session)
payload = scad.import_model_json(model_json)
graph = payload["graph"]
extrusion_id = next(
node.node_id
for node in graph.topological_order()
if node.op == "make_extrude_rsolid"
)
from simplecadapi.translator.fusion360_translator import Fusion360Translator
script = (
Fusion360Translator(result_node_ids=[extrusion_id])
.translate_model_json(model_json)
.content
)
self.assertIn(f"RESULT_NODE_IDS = ['{extrusion_id}']", script)
self.assertIn("active_node_ids = self._result_dependency_ids", script)
self.assertIn("not in active_node_ids", script)
def test_unsupported_result_operation_is_rejected_before_host_execution(self):
with GraphSession() as session:
scad.make_box_rsolid(width=1.0, height=2.0, depth=3.0)
from simplecadapi.translator.fusion360_translator import Fusion360Translator
with self.assertRaises(TranslationRequestError):
Fusion360Translator().translate_model_json(scad.export_model_json(session))
if __name__ == "__main__":
unittest.main()
+289
View File
@@ -0,0 +1,289 @@
import json
from dataclasses import dataclass
from pathlib import Path
from tempfile import TemporaryDirectory
import unittest
import simplecadapi as scad
from simplecadapi.scene import (
parse_canonical_json,
preflight_zip_bytes,
validate_scene_package,
)
from simplecadapi.topology import TopoKind, TopoRef
class TestGraphModelApi(unittest.TestCase):
def test_model_export_dir_writes_one_self_contained_scene_package(self):
with TemporaryDirectory() as directory:
@scad.model(graph_id="auto_export", export_dir=directory)
def build_model():
body = scad.make_box_rsolid(width=1.0, height=2.0, depth=3.0)
scad.capture_result(value=body)
return body
result = build_model()
self.assertEqual(set(result.artifact_paths), {"scene"})
self.assertTrue(
all(Path(path).is_file() for path in result.artifact_paths.values())
)
self.assertEqual(
sorted(Path(directory).iterdir()),
[result.artifact_paths["scene"]],
)
def test_model_export_dir_writes_assembly_artifacts_with_model_provenance(self):
with TemporaryDirectory() as directory:
@scad.model(graph_id="assembly_export", export_dir=directory)
def build_model():
body = scad.make_box_rsolid(width=1.0, height=2.0, depth=3.0)
part = scad.make_part_rpart(part_id="body", body=body)
assembly = scad.make_assembly_rassembly(assembly_id="root")
assembly = scad.add_component_rassembly(
assembly=assembly,
item=part,
component_id="body",
placement=scad.identity_placement_rplacement(),
)
scad.capture_result(value=assembly)
return assembly
result = build_model()
self.assertEqual(set(result.artifact_paths), {"scene"})
self.assertTrue(
all(Path(path).is_file() for path in result.artifact_paths.values())
)
archive = preflight_zip_bytes(
result.artifact_paths["scene"].read_bytes()
)
manifest = parse_canonical_json(archive.members["scene.json"])
blobs = {
name: payload
for name, payload in archive.members.items()
if name != "scene.json"
}
report = validate_scene_package(manifest, blobs)
self.assertTrue(report.valid, report.issues)
self.assertEqual(manifest["source"]["kind"], "model")
self.assertIn("model/model.json", archive.members)
self.assertTrue(manifest["compile_options"]["embed_source"])
self.assertEqual(
manifest["source"]["embedded_artifact_uri"],
"model/model.json",
)
source_files = manifest["source"]["source_files"]
self.assertEqual(
[record["path"] for record in source_files],
["test/test_graph_model_api.py"],
)
self.assertEqual(
archive.members[source_files[0]["uri"]],
Path(__file__).read_bytes(),
)
self.assertFalse(
any(
name.lower().endswith((".step", ".stl", ".fcstd"))
for name in archive.members
)
)
self.assertTrue(
manifest["definitions"]
and all(
item["source"]["kind"] == "product_model"
for item in manifest["definitions"]
)
)
def test_model_source_embedding_is_explicit(self):
@scad.model(graph_id="embedded_source")
def build_model():
body = scad.make_box_rsolid(width=1.0, height=2.0, depth=3.0)
scad.capture_result(value=body)
return body
result = build_model()
package = scad.compile_scene(
scene_id="embedded_source",
roots=(scad.SceneRoot(root_id="main", value=result.value),),
source=result,
options=scad.SceneCompileOptions(embed_source=True),
)
self.assertEqual(
package.blobs["model/model.json"],
result.model_json.encode("utf-8"),
)
self.assertTrue(package.manifest["compile_options"]["embed_source"])
self.assertEqual(
package.manifest["source"]["embedded_artifact_uri"],
"model/model.json",
)
source_files = package.manifest["source"]["source_files"]
self.assertEqual(
[record["path"] for record in source_files],
["test/test_graph_model_api.py"],
)
self.assertEqual(
package.blobs[source_files[0]["uri"]],
Path(__file__).read_bytes(),
)
def test_model_owns_one_session_and_replays_explicit_result(self):
@scad.model(graph_id="decorator_model")
def build_model():
scad.make_box_rsolid(width=0.5, height=0.5, depth=0.5)
return scad.make_box_rsolid(width=1.0, height=2.0, depth=3.0)
result = build_model()
self.assertIsInstance(result, scad.ModelResult)
self.assertEqual(result.session.graph.graph_id, "decorator_model")
self.assertEqual(len(result.result_node_ids), 1)
self.assertEqual(result.session.graph.node_count, 2)
self.assertEqual(len(result.replay()), 1)
payload = json.loads(result.model_json)
self.assertEqual(payload["leaf_ids"], list(result.result_node_ids))
def test_requires_session_reuses_active_session(self):
@scad.requires_session
def build_box():
return scad.make_box_rsolid(width=1.0, height=1.0, depth=1.0)
with self.assertRaisesRegex(RuntimeError, "requires an active GraphSession"):
build_box()
with scad.GraphSession(graph_id="shared") as session:
box = build_box()
self.assertEqual(box.get_metadata("graph")["graph_id"], "shared")
self.assertEqual(session.graph.node_count, 1)
def test_model_rejects_nested_model_sessions(self):
@scad.model
def child_model():
return scad.make_box_rsolid(width=1.0, height=1.0, depth=1.0)
@scad.model
def parent_model():
return child_model()
with self.assertRaisesRegex(RuntimeError, "cannot be nested"):
parent_model()
def test_capture_result_can_exclude_unrelated_leaf(self):
@scad.model
def build_model():
debug = scad.make_box_rsolid(width=0.25, height=0.25, depth=0.25)
final = scad.make_box_rsolid(width=2.0, height=2.0, depth=2.0)
scad.capture_result(value=final)
return debug, final
result = build_model()
payload = json.loads(result.model_json)
nodes = {node["node_id"]: node for node in payload["graph"]["nodes"]}
self.assertEqual(payload["leaf_ids"], list(result.result_node_ids))
self.assertEqual(len(payload["leaf_ids"]), 1)
self.assertEqual(nodes[payload["leaf_ids"][0]]["params"]["width"], 2.0)
def test_cross_session_shape_input_is_rejected_at_operation_boundary(self):
with scad.GraphSession(graph_id="source"):
foreign = scad.make_box_rsolid(width=1.0, height=1.0, depth=1.0)
with scad.GraphSession(graph_id="target"):
with self.assertRaisesRegex(ValueError, "source.*target"):
scad.translate_shape(shape=foreign, vector=(1.0, 0.0, 0.0))
def test_cross_session_child_assembly_is_rejected(self):
with scad.GraphSession(graph_id="child_graph"):
child = scad.make_assembly_rassembly(assembly_id="child")
with scad.GraphSession(graph_id="parent_graph"):
parent = scad.make_assembly_rassembly(assembly_id="parent")
placement = scad.identity_placement_rplacement()
with self.assertRaisesRegex(ValueError, "child_graph.*parent_graph"):
scad.add_component_rassembly(
assembly=parent,
item=child,
component_id="child_1",
placement=placement,
)
def test_unrecorded_child_assembly_is_rejected(self):
child = scad.make_assembly_rassembly(assembly_id="unrecorded_child")
with scad.GraphSession(graph_id="parent_graph"):
parent = scad.make_assembly_rassembly(assembly_id="parent")
placement = scad.identity_placement_rplacement()
with self.assertRaisesRegex(ValueError, "unrecorded Assembly"):
scad.add_component_rassembly(
assembly=parent,
item=child,
component_id="child_1",
placement=placement,
)
def test_capture_result_walks_dataclass_values_and_is_atomic_on_failure(self):
@dataclass
class ResultValue:
body: scad.Solid
@scad.model(graph_id="dataclass_result")
def build_model():
body = scad.make_box_rsolid(width=1.0, height=2.0, depth=3.0)
scad.capture_result(value=ResultValue(body=body))
return ResultValue(body=body)
result = build_model()
self.assertEqual(len(result.result_node_ids), 1)
self.assertEqual(len(result.replay()), 1)
with scad.GraphSession(graph_id="atomic_capture") as session:
body = scad.make_box_rsolid(width=1.0, height=1.0, depth=1.0)
with scad.GraphSession(graph_id="foreign_capture"):
foreign = scad.make_box_rsolid(
width=2.0, height=2.0, depth=2.0
)
with self.assertRaisesRegex(ValueError, "foreign_capture.*atomic_capture"):
session.capture_result(value=(body, foreign))
self.assertFalse(session.has_explicit_results)
self.assertEqual(session.result_node_ids, ())
with scad.GraphSession(graph_id="topology_capture") as session:
body = scad.make_box_rsolid(width=1.0, height=1.0, depth=1.0)
node = body._get_runtime("graph.node")
body._set_runtime(
"topo.ref",
TopoRef(
graph_id="foreign_topology",
node_id=node.node_id,
output_slot=0,
kind=TopoKind.SOLID,
topo_id="solid_0",
),
)
with self.assertRaisesRegex(
ValueError, "foreign_topology.*topology_capture"
):
session.capture_result(value=body)
self.assertFalse(session.has_explicit_results)
self.assertEqual(session.result_node_ids, ())
def test_model_and_requires_session_reject_async_functions(self):
async def async_model():
return scad.make_box_rsolid(width=1.0, height=1.0, depth=1.0)
async def async_builder():
return scad.make_box_rsolid(width=1.0, height=1.0, depth=1.0)
with self.assertRaisesRegex(TypeError, "@model does not support async"):
scad.model(async_model)
with self.assertRaisesRegex(
TypeError, "@requires_session does not support async"
):
scad.requires_session(async_builder)
if __name__ == "__main__":
unittest.main()
+55 -4
View File
@@ -15,24 +15,75 @@ class TestQLSugar(unittest.TestCase):
def test_op_predicate(self):
pred = Q.op("cut", "generated")
obj = type("Obj", (), {"_tags": {"op.cut.generated"}})()
obj = type(
"Obj",
(),
{"_metadata": {"track": {"op": "make_cut_rsolid", "event": "generated"}}},
)()
self.assertTrue(pred(obj))
def test_op_predicate_wildcard(self):
pred = Q.op("cut")
obj = type("Obj", (), {"_tags": {"op.cut.modified"}})()
obj = type(
"Obj",
(),
{"_metadata": {"track": {"op": "make_cut_rsolid", "events": ["modified"]}}},
)()
self.assertTrue(pred(obj))
def test_op_predicate_matches_generic_operation_alias(self):
obj = type(
"Obj",
(),
{
"_metadata": {
"track": {
"op": "make_translate_rsolid",
"operation": "translate",
"event": "modified",
}
}
},
)()
self.assertTrue(Q.op("translate", "modified")(obj))
self.assertTrue(Q.op("make_translate_rsolid", "modified")(obj))
def test_origin_predicate(self):
pred = Q.origin("tool")
obj = type("Obj", (), {"_tags": {"origin.tool"}})()
obj = type(
"Obj", (), {"_metadata": {"track": {"origin_roles": ["body", "tool"]}}}
)()
self.assertTrue(pred(obj))
def test_origin_predicate_no_match(self):
pred = Q.origin("tool")
obj = type("Obj", (), {"_tags": {"origin.body"}})()
obj = type("Obj", (), {"_metadata": {"track": {"origin_role": "body"}}})()
self.assertFalse(pred(obj))
def test_op_and_origin_do_not_fall_back_to_flat_tags(self):
obj = type(
"Obj",
(),
{"_metadata": {}, "_tags": {"op.cut.generated", "origin.tool"}},
)()
self.assertFalse(Q.op("cut", "generated")(obj))
self.assertFalse(Q.origin("tool")(obj))
def test_typed_tracking_predicates_roundtrip(self):
operation = Q.operation_event("cut", "modified")
origin = Q.origin_role("tool")
self.assertEqual(
Q.SerializablePredicate.from_dict(operation.to_dict()).to_dict(),
operation.to_dict(),
)
self.assertEqual(
Q.SerializablePredicate.from_dict(origin.to_dict()).to_dict(),
origin.to_dict(),
)
def test_role_predicate(self):
pred = Q.role("section")
obj = type("Obj", (), {"_tags": {"role.section.face"}})()
@@ -0,0 +1,150 @@
from __future__ import annotations
import math
from pathlib import Path
import pytest
import simplecadapi as scad
from simplecadapi.inverse_engineer import brep
from simplecadapi.inverse_engineer.brep.cli import main as brep_cli_main
def _box():
return scad.make_box_rsolid(width=4.0, height=3.0, depth=2.0)
def _two_arc_cylinder():
first = scad.make_angle_arc_redge(
center=(0.0, 0.0, 0.0),
radius=1.0,
start_angle=0.0,
end_angle=math.pi,
normal=(0.0, 0.0, 1.0),
)
second = scad.make_angle_arc_redge(
center=(0.0, 0.0, 0.0),
radius=1.0,
start_angle=math.pi,
end_angle=2.0 * math.pi,
normal=(0.0, 0.0, 1.0),
)
wire = scad.make_wire_from_edges_rwire(edges=[first, second])
face = scad.make_face_from_wire_rface(wire=wire, normal=(0.0, 0.0, 1.0))
return scad.extrude_rsolid(profile=face, direction=(0.0, 0.0, 1.0), distance=2.0)
def test_inspect_shape_reports_unique_and_occurrence_counts():
report = brep.inspect_shape(_box().wrapped)
assert report.valid is True
assert report.counts["unique_faces"] == 6
assert report.counts["unique_edges"] == 12
assert report.counts["unique_vertices"] == 8
assert report.counts["edge_occurrences"] == 24
assert report.surface_type_counts == {"Plane": 6}
assert report.edge_type_counts == {"Line": 12}
assert report.volume == pytest.approx(24.0)
def test_inspect_bspline_edge_includes_reconstruction_parameters():
wire = scad.make_spline_rwire(
control_points=[
(0.0, 0.0, 0.0),
(1.0, 1.0, 0.0),
(2.0, 1.0, 0.0),
(3.0, 0.0, 0.0),
],
degree=3,
)
report = brep.inspect_shape(wire.wrapped)
spline = report.edges[0]
assert spline["type"] == "BSplineCurve"
assert spline["degree"] == 3
assert spline["poles"] == 4
assert len(spline["control_points"]) == 4
assert len(spline["knot_values"]) == spline["knots"]
assert len(spline["multiplicities"]) == spline["knots"]
def test_compare_same_shape_passes_geometry_and_topology():
shape = _box().wrapped
comparison = brep.compare_shapes(shape, shape)
assert comparison.same_geometric_point_set is True
assert comparison.geometry_labelled_incidence_graph_isomorphic is True
assert comparison.hard_gate_passed is True
assert comparison.target_minus_candidate_volume == 0.0
assert comparison.candidate_minus_target_volume == 0.0
def test_compare_closed_shape_is_invariant_to_boundary_orientation():
shape = _box().wrapped
comparison = brep.compare_shapes(shape, shape.Reversed())
assert comparison.same_geometric_point_set is True
assert comparison.geometry_labelled_incidence_graph_isomorphic is True
assert comparison.hard_gate_passed is True
assert comparison.target_minus_candidate_volume == 0.0
assert comparison.candidate_minus_target_volume == 0.0
def test_compare_detects_same_geometry_with_different_topology():
full_circle = scad.make_cylinder_rsolid(radius=1.0, height=2.0)
two_arcs = _two_arc_cylinder()
comparison = brep.compare_shapes(full_circle.wrapped, two_arcs.wrapped)
assert comparison.same_geometric_point_set is True
assert comparison.geometry_labelled_incidence_graph_isomorphic is False
assert comparison.hard_gate_passed is False
def test_center_slice_specs_follow_shape_bounds():
specs = brep.center_slice_specs(
minimum=(-2.0, 4.0, 10.0),
maximum=(6.0, 8.0, 14.0),
)
assert [(spec.plane, spec.value) for spec in specs] == [
("yz", 2.0),
("xz", 6.0),
("xy", 12.0),
]
def test_compare_shape_slices_has_zero_xor_for_same_shape():
shape = _box().wrapped
comparison = brep.compare_shape_slices(
shape,
shape,
slices=(brep.SliceSpec("xy", 1.0), brep.SliceSpec("xz", 1.5)),
samples=(11, 9),
)
assert comparison.total_samples == 198
assert comparison.xor_samples == 0
assert comparison.sampled_slices_identical is True
def test_step_round_trip_and_cli(tmp_path: Path):
step = tmp_path / "box.step"
report_path = tmp_path / "box-report.json"
comparison_path = tmp_path / "comparison.json"
scad.export_step(shapes=_box(), filename=str(step))
report = brep.inspect_step(step)
comparison = brep.compare_steps(step, step)
assert report.counts["unique_faces"] == 6
assert comparison.hard_gate_passed is True
assert brep_cli_main(["inspect", str(step), "--output", str(report_path)]) == 0
assert (
brep_cli_main(
["compare", str(step), str(step), "--output", str(comparison_path)]
)
== 0
)
assert report_path.exists()
assert comparison_path.exists()
+33
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import unittest
from copy import deepcopy
import simplecadapi as scad
from simplecadapi.graph import GraphSession
@@ -23,6 +24,38 @@ class TestModelJson(unittest.TestCase):
self.assertGreaterEqual(payload["graph"].node_count, 1)
self.assertGreaterEqual(payload["expression_graph"].node_count, 1)
def test_model_json_tag_binding_registry_roundtrip_and_tamper_rejection(self):
selector = (
scad.ql.faces()
.order_by(scad.ql.key("geom.center.z"), desc=True)
.take(1)
.exactly(1)
)
with 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))
node = next(
item
for item in raw["graph"]["nodes"]
if item["op"] == "apply_tag_rselection"
)
binding = node["params"]["tag_binding"]
self.assertEqual(raw["semantic_bindings"], [binding])
self.assertEqual(
raw["canonical_contract"]["semantic_op_set"],
["apply_tag_rselection"],
)
parsed = scad.import_model_json(json.dumps(raw))
self.assertEqual(parsed["semantic_bindings"], [binding])
damaged = deepcopy(raw)
damaged["semantic_bindings"][0]["tag"] = "role.tampered"
with self.assertRaisesRegex(ValueError, "semantic_bindings do not match"):
scad.import_model_json(json.dumps(damaged))
def test_model_json_declares_canonical_contract_and_graph_roles(self):
with GraphSession() as session:
scad.make_box_rsolid(1.0, 2.0, 3.0)
@@ -0,0 +1,869 @@
"""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()
@@ -61,7 +61,7 @@ class TestOriginalTransformApiIntegration(unittest.TestCase):
faces = moved.get_faces()
self.assertGreater(
len(
Q.select(faces).where(Q.op("make_translate_rshape", "preserved")).all()
Q.select(faces).where(Q.op("make_translate_rshape", "modified")).all()
),
0,
)
@@ -74,20 +74,27 @@ class TestOriginalTransformApiIntegration(unittest.TestCase):
self.assertEqual(moved.get_metadata("track")["op"], "make_rotate_rshape")
faces = moved.get_faces()
self.assertGreater(
len(Q.select(faces).where(Q.op("make_rotate_rshape", "preserved")).all()),
len(Q.select(faces).where(Q.op("make_rotate_rshape", "modified")).all()),
0,
)
class TestOriginalFeatureApiIntegration(unittest.TestCase):
def test_extrude_rsolid_auto_applies_semantic_tags(self):
def test_extrude_rsolid_exposes_kernel_proven_output_roles(self):
profile = scad.make_rectangle_rface(2.0, 1.0)
extruded = scad.extrude_rsolid(profile, (0, 0, 1), 2.0)
self.assertEqual(extruded.get_metadata("track")["op"], "make_extrude_rsolid")
faces = extruded.get_faces()
tagged = Q.select(faces).where(Q.op("make_extrude_rsolid")).all()
self.assertGreater(len(tagged), 0)
self.assertEqual(
len(Q.select(faces).where(Q.output_role("extrusion.start")).all()), 1
)
self.assertEqual(
len(Q.select(faces).where(Q.output_role("extrusion.end")).all()), 1
)
self.assertEqual(
len(Q.select(faces).where(Q.output_role("extrusion.side")).all()), 4
)
def test_fillet_rsolid_auto_applies_semantic_tags(self):
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
+146
View File
@@ -514,6 +514,152 @@ def test_constraint_graph_model_json_and_replay_roundtrip():
assert solved.get_component("slider").placement.origin == (0.0, 0.0, 4.0)
def test_boolean_named_face_connector_resolves_after_replay():
with scad.GraphSession() as session:
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,
end_face_tag="cap.face.end",
)
body = scad.make_cylinder_rsolid(
radius=1.5,
height=4.0,
bottom_face_center=(-1.0, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
)
bridge = scad.make_box_rsolid(
width=1.0,
height=1.0,
depth=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(
radius=0.25,
height=4.0,
bottom_face_center=(-1.0, 1.0, 0.0),
axis=(1.0, 0.0, 0.0),
),
)
named_face = (
ql.faces()
.where(ql.tag("cap.face.end"))
.exactly(1)
.resolve(result)[0]
)
connector = scad.make_face_connector_rconnector("mount", named_face)
part = scad.add_connector_rpart(
scad.make_part_rpart("boolean_named_part", result),
connector,
)
session.capture_result(value=part)
replayed = scad.replay_model_json(scad.export_model_json(session))[0]
assert isinstance(replayed, scad.Part)
replayed_face = (
ql.faces()
.where(ql.tag("cap.face.end"))
.exactly(1)
.resolve(replayed.body)[0]
)
assert replayed.get_connector("mount").placement.origin == pytest.approx(
tuple(replayed_face.get_center())
)
package = scad.compile_scene(
scene_id="boolean-named-connector",
roots=(scad.SceneRoot(root_id="main", value=replayed),),
)
snapshot = package.manifest["connectors"][0]
asset = next(
item
for item in package.manifest["entity_assets"]
if item["entity_asset_id"] == snapshot["target"]["entity_asset_id"]
)
entity_document = json.loads(package.blobs[asset["uri"]])
target = next(
item
for item in entity_document["entities"]
if item["entity_id"] == snapshot["target"]["entity_id"]
)
assert "cap.face.end" in target["evaluated_tags"]
def test_nested_constraint_graph_replay_preserves_child_assembly_constraints():
with scad.GraphSession() as session:
part = _part_with_placement_connector("nested_replay_part")
child = scad.make_assembly_rassembly(assembly_id="nested_replay_child")
child = scad.add_component_rassembly(
assembly=child,
item=part,
component_id="base",
placement=scad.identity_placement_rplacement(),
)
child = scad.add_component_rassembly(
assembly=child,
item=part,
component_id="arm",
placement=scad.identity_placement_rplacement(),
)
child = scad.ground_component_rassembly(
assembly=child,
component_id="base",
)
child = scad.add_revolute_constraint_rassembly(
assembly=child,
constraint_id="pivot",
connector_a=scad.make_connector_ref_rconnectorref(
component_id="base",
connector_id="axis",
),
connector_b=scad.make_connector_ref_rconnectorref(
component_id="arm",
connector_id="axis",
),
drive_angle_degrees=30.0,
)
child = scad.solve_assembly_constraints_rassembly(assembly=child)
root = scad.make_assembly_rassembly(assembly_id="nested_replay_root")
root = scad.add_component_rassembly(
assembly=root,
item=child,
component_id="child",
placement=scad.make_placement_rplacement(origin=(10.0, 20.0, 30.0)),
)
payload = json.loads(scad.export_model_json(session))
ops = [node["op"] for node in payload["graph"]["nodes"]]
assert ops.count("make_revolute_constraint_rassembly") == 1
assert ops.count("make_solve_assembly_constraints_rassembly") == 1
assert ops.count("make_add_component_rassembly") == 3
replayed = scad.replay_model_json(json.dumps(payload))
assert len(replayed) == 1
replayed_root = replayed[0]
assert isinstance(replayed_root, scad.Assembly)
replayed_child = replayed_root.get_component("child").item
assert isinstance(replayed_child, scad.Assembly)
assert replayed_child.assembly_id == "nested_replay_child"
assert replayed_child.component_ids() == ("base", "arm")
assert replayed_child.constraint_ids() == ("pivot",)
assert replayed_child.grounded_component_ids == ("base",)
assert replayed_child.get_constraint("pivot").drive_angle_degrees == 30.0
assert replayed_child.get_component("arm").placement.x_axis == (
math.cos(math.radians(30.0)),
math.sin(math.radians(30.0)),
0.0,
)
assert replayed_root.get_component("child").placement.origin == (10.0, 20.0, 30.0)
def test_graph_session_rejects_duplicate_product_ids():
with pytest.raises(Exception, match="duplicate part"):
with scad.GraphSession():
@@ -60,3 +60,41 @@ print(json.dumps({
self.assertTrue(payload["has_apply_tag"])
self.assertTrue(payload["has_list_tags"])
self.assertFalse(payload["has_set_tag"])
def test_canonical_tagging_exports(self):
import simplecadapi as scad
from simplecadapi import operations, tagging
expected = {
"apply_tag_rselection": operations.apply_tag_rselection,
"explain_tag": operations.explain_tag,
"LineageDerivation": tagging.LineageDerivation,
"LineagePolicy": tagging.LineagePolicy,
"TagAttachment": tagging.TagAttachment,
"TagBinding": tagging.TagBinding,
"TagBindingScope": tagging.TagBindingScope,
"TagCertainty": tagging.TagCertainty,
"TagEvidence": tagging.TagEvidence,
"TagEvidenceKind": tagging.TagEvidenceKind,
"TagLifecycle": tagging.TagLifecycle,
"TagLineageWitness": tagging.TagLineageWitness,
"TagProducer": tagging.TagProducer,
"TagProducerKind": tagging.TagProducerKind,
"TagPropagation": tagging.TagPropagation,
"TagScope": tagging.TagScope,
"TagTarget": tagging.TagTarget,
"TagTargetKind": tagging.TagTargetKind,
"TopologyPropagation": tagging.TopologyPropagation,
}
for name, implementation in expected.items():
with self.subTest(name=name):
self.assertIn(name, scad.__all__)
self.assertIs(getattr(scad, name), implementation)
def test_tracking_policy_is_public(self):
import simplecadapi as scad
from simplecadapi.tracking import TrackingPolicy
self.assertIn("TrackingPolicy", scad.__all__)
self.assertIs(scad.TrackingPolicy, TrackingPolicy)
+82
View File
@@ -2,6 +2,7 @@ import unittest
import simplecadapi as scad
from simplecadapi import ql as Q
from simplecadapi import tagging
class TestQLTagPredicates(unittest.TestCase):
@@ -23,6 +24,30 @@ class TestQLTagPredicates(unittest.TestCase):
self.assertTrue(Q.tag("face.top")(top_face))
self.assertTrue(Q.tag("face.*")(top_face))
def test_tag_scope_serializes_and_roundtrips(self):
predicate = Q.tag("role.mounting_surface", scope="inherited")
payload = predicate.to_dict()
restored = Q.SerializablePredicate.from_dict(payload)
self.assertEqual(payload["kind"], "tag")
self.assertEqual(payload["data"]["scope"], "inherited")
self.assertEqual(restored.to_dict(), payload)
def test_tag_scope_evaluates_explicit_downward_inheritance(self):
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
box._apply_user_tag("role.body", topology_propagation="downward")
face = box.get_faces(0)
self.assertFalse(Q.tag("role.body", scope="local")(face))
self.assertTrue(Q.tag("role.body", scope="inherited")(face))
self.assertTrue(Q.tag("role.body", scope="effective")(face))
def test_lineage_capability_error_is_not_swallowed(self):
vertex = scad.make_point_rvertex(0.0, 0.0, 0.0)
with self.assertRaises(tagging.UnsupportedQueryCapabilityError):
Q.tag("role.datum", scope="lineage")(vertex)
class TestQLMetadataPredicates(unittest.TestCase):
def test_meta_eq_and_compare(self):
@@ -229,6 +254,63 @@ class TestSerializableGeometrySelectors(unittest.TestCase):
edges = selector.resolve(result)
self.assertGreater(len(edges), 0)
def test_incident_face_query_selects_one_named_edge(self):
profile = scad.make_rectangle_rface(
4.0,
2.0,
tag_prefix="profile",
edge_tags=["bottom", "right", "top", "left"],
)
solid = scad.extrude_rsolid(
profile, (0, 0, 1), 3.0, tag_prefix="boss"
)
selector = (
Q.faces()
.where(Q.tag("boss.face.start"))
.boundary("edge")
.incident_to(
Q.faces().where(Q.tag("boss.face.start")),
Q.faces().where(Q.tag("boss.face.side.right")),
distinct=True,
)
.incident_face_count(exactly=2)
.exactly(1)
)
edges = selector.resolve(solid)
self.assertEqual(len(edges), 1)
restored = Q.selector_from_dict(selector.to_dict())
self.assertEqual(len(restored.resolve(solid)), 1)
def test_shared_boundary_intersection_uses_topology_identity(self):
solid = scad.make_cylinder_rsolid(2.0, 3.0, tag_prefix="shaft")
start = Q.faces().where(Q.tag("shaft.face.start"))
side = Q.faces().where(Q.tag("shaft.face.side"))
shared = start.shared_boundary(side).exactly(1)
self.assertEqual(len(shared.resolve(solid)), 1)
self.assertEqual(
len(Q.selector_from_dict(shared.to_dict()).resolve(solid)), 1
)
first = scad.make_cylinder_rsolid(2.0, 3.0, tag_prefix="first")
second = scad.make_cylinder_rsolid(2.0, 3.0, tag_prefix="second")
self.assertEqual(
Q.solids()
.where(Q.tag("first.solid"))
.shared_boundary(Q.solids().where(Q.tag("first.solid")))
.resolve(first),
first.get_edges(),
)
self.assertEqual(
len(
Q.solids()
.where(Q.tag("first.solid"))
.shared_boundary(Q.solids().where(Q.tag("second.solid")))
.resolve([first, second])
),
0,
)
def test_selector_exactly_enforces_cardinality(self):
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
selector = (
@@ -96,9 +96,12 @@ class TestRearchitecture20ExpressionContracts(unittest.TestCase):
self.assertIsInstance(expr, scad.Expr)
self.assertAlmostEqual(float(expr), 16.0)
def test_expression_system_is_unitless_phase_one(self):
def test_expression_system_preserves_legacy_unitless_variables(self):
r = scad.var("r", 10.0)
self.assertFalse(hasattr(r, "unit"))
self.assertIsNone(r.unit)
self.assertIsNone(r.tolerance_unit)
self.assertIsNone(scad.infer_dimension(r))
self.assertEqual(r.evaluate(), 10.0)
def test_expression_values_can_flow_into_public_modeling_apis(self):
r = scad.var("r", 10.0)
@@ -3,8 +3,10 @@
from __future__ import annotations
import unittest
from unittest import mock
import simplecadapi as scad
from simplecadapi import operations
from simplecadapi.graph import GraphSession
@@ -92,6 +94,143 @@ class TestRearchitecture20CoreOps(unittest.TestCase):
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))
@@ -127,6 +266,93 @@ class TestRearchitecture20CoreOps(unittest.TestCase):
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:
+534
View File
@@ -0,0 +1,534 @@
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import pytest
import simplecadapi as scad
from simplecadapi import _mesh
from simplecadapi.scene import (
export_scene,
parse_canonical_json,
preflight_zip_bytes,
validate_scene_manifest,
validate_scene_package,
with_scene_revision,
)
def _manual_source() -> scad.SceneSource:
return scad.SceneSource(kind="manual", source_id="main")
def _mutable_package(package, tmp_path, name):
archive_path = tmp_path / f"{name}.scene.zip"
export_scene(package=package, path=archive_path)
archive = preflight_zip_bytes(archive_path.read_bytes())
manifest = parse_canonical_json(archive.members["scene.json"])
blobs = {
uri: payload
for uri, payload in archive.members.items()
if uri != "scene.json"
}
return manifest, blobs
def _replace_embedded_model(manifest, blobs, model):
payload = json.dumps(model, sort_keys=True, separators=(",", ":")).encode(
"utf-8"
)
blobs["model/model.json"] = payload
manifest["source"]["artifact_hash"] = (
"sha256:" + hashlib.sha256(payload).hexdigest()
)
manifest["source"]["embedded_artifact_byte_length"] = len(payload)
return with_scene_revision(manifest)
@pytest.fixture
def embedded_source_package():
@scad.model(graph_id="source-boundaries")
def build_model():
return scad.make_box_rsolid(width=2.0, height=3.0, depth=4.0)
result = build_model()
return scad.compile_scene(
scene_id="source-boundaries",
roots=(scad.SceneRoot(root_id="main", value=result.value),),
source=result,
options=scad.SceneCompileOptions(embed_source=True),
)
def test_compile_scene_is_deterministic_and_self_validating():
solid = scad.make_box_rsolid(width=10.0, height=20.0, depth=30.0)
roots = (scad.SceneRoot(root_id="main", value=solid),)
first = scad.compile_scene(scene_id="box", roots=roots, source=_manual_source())
second = scad.compile_scene(scene_id="box", roots=roots, source=_manual_source())
assert first.manifest == second.manifest
assert dict(first.blobs) == dict(second.blobs)
assert validate_scene_package(first.manifest, first.blobs).valid
assert first.manifest["revision"].startswith("sha256:")
def test_embedded_python_source_is_integrity_checked(tmp_path):
@scad.model(graph_id="source-integrity")
def build_model():
return scad.make_box_rsolid(width=2.0, height=3.0, depth=4.0)
result = build_model()
package = scad.compile_scene(
scene_id="source-integrity",
roots=(scad.SceneRoot(root_id="main", value=result.value),),
source=result,
options=scad.SceneCompileOptions(embed_source=True),
)
source_record = package.manifest["source"]["source_files"][0]
assert source_record["path"] == "test/test_scene_compiler.py"
assert package.blobs[source_record["uri"]] == Path(__file__).read_bytes()
assert validate_scene_package(package.manifest, package.blobs).valid
tampered = dict(package.blobs)
tampered[source_record["uri"]] += b"\n# tampered\n"
report = validate_scene_package(package.manifest, tampered)
assert not report.valid
assert {issue.code for issue in report.issues} >= {
"blob_hash_mismatch",
"blob_length_mismatch",
}
archive_path = tmp_path / "source-integrity.scene.zip"
export_scene(package=package, path=archive_path)
archive = preflight_zip_bytes(archive_path.read_bytes())
malformed_manifest = parse_canonical_json(archive.members["scene.json"])
malformed_manifest["source"]["source_files"][0]["uri"] = "sources/wrong.py"
malformed_manifest = with_scene_revision(malformed_manifest)
report = validate_scene_package(malformed_manifest, package.blobs)
assert not report.valid
assert "source_matrix_invalid" in {issue.code for issue in report.issues}
def test_model_scene_rejects_root_from_different_graph_with_same_graph_id():
@scad.model(graph_id="shared-provenance-id")
def first_model():
return scad.make_box_rsolid(width=1.0, height=2.0, depth=3.0)
@scad.model(graph_id="shared-provenance-id")
def second_model():
return scad.make_box_rsolid(width=4.0, height=5.0, depth=6.0)
first = first_model()
second = second_model()
with pytest.raises(ValueError, match="not owned by the source model graph"):
scad.compile_scene(
scene_id="cross-graph-root",
roots=(scad.SceneRoot(root_id="main", value=first.value),),
source=second,
)
def test_model_scene_rejects_stale_model_result_snapshot():
@scad.model(graph_id="stale-model-snapshot")
def build_model():
return scad.make_box_rsolid(width=1.0, height=2.0, depth=3.0)
result = build_model()
result.session.graph.add_node("late_mutation", node_id="late_mutation")
with pytest.raises(ValueError, match="no longer matches its model JSON snapshot"):
scad.compile_scene(
scene_id="stale-model-snapshot",
roots=(scad.SceneRoot(root_id="main", value=result.value),),
source=result,
)
def test_manifest_rejects_unsafe_embedded_python_path(
embedded_source_package, tmp_path
):
manifest, _blobs = _mutable_package(
embedded_source_package, tmp_path, "unsafe-source-path"
)
source_file = manifest["source"]["source_files"][0]
source_file["path"] = "test/../test_scene_compiler.py"
source_file["uri"] = "sources/test/../test_scene_compiler.py"
report = validate_scene_manifest(with_scene_revision(manifest))
assert any(
issue.code == "source_matrix_invalid"
and issue.path == "/source/source_files/0/path"
for issue in report.issues
)
def test_package_rejects_malformed_operation_source_mapping(
embedded_source_package, tmp_path
):
manifest, blobs = _mutable_package(
embedded_source_package, tmp_path, "malformed-operation-source"
)
model = json.loads(blobs["model/model.json"])
node = next(node for node in model["graph"]["nodes"] if "source" in node)
node["source"].pop("callsite_id")
manifest = _replace_embedded_model(manifest, blobs, model)
report = validate_scene_package(manifest, blobs)
assert any(
issue.code == "source_matrix_invalid"
and issue.path.endswith("/graph/nodes/0/source")
for issue in report.issues
)
def test_package_rejects_invalid_utf8_python_source(
embedded_source_package, tmp_path
):
manifest, blobs = _mutable_package(
embedded_source_package, tmp_path, "invalid-source-utf8"
)
source_file = manifest["source"]["source_files"][0]
payload = b"\xff"
blobs[source_file["uri"]] = payload
source_file["byte_length"] = len(payload)
source_file["content_hash"] = "sha256:" + hashlib.sha256(payload).hexdigest()
report = validate_scene_package(with_scene_revision(manifest), blobs)
assert any(
issue.code == "invalid_utf8" and issue.path == f"/{source_file['uri']}"
for issue in report.issues
)
def test_sphere_entity_uses_kernel_axis_directions():
solid = scad.make_sphere_rsolid(radius=2.5, center=(1.0, 2.0, 3.0))
package = scad.compile_scene(
scene_id="sphere",
roots=(scad.SceneRoot(root_id="main", value=solid),),
source=_manual_source(),
)
entity_uri = package.manifest["entity_assets"][0]["uri"]
entity_document = parse_canonical_json(package.blobs[entity_uri])
sphere = next(
entity
for entity in entity_document["entities"]
if entity["kind"] == "face"
)
assert sphere["geometry"] == {
"type": "sphere",
"center": [1.0, 2.0, 3.0],
"axis": [0.0, 0.0, 1.0],
"x_direction": [1.0, 0.0, 0.0],
"radius": 2.5,
}
def test_repeated_part_occurrences_reuse_definition_and_assets():
part = scad.make_part_rpart(
part_id="block",
body=scad.make_box_rsolid(width=4.0, height=5.0, depth=6.0),
)
assembly = scad.make_assembly_rassembly(assembly_id="root")
assembly = scad.add_component_rassembly(
assembly=assembly,
item=part,
component_id="first",
placement=scad.identity_placement_rplacement(),
)
assembly = scad.add_component_rassembly(
assembly=assembly,
item=part,
component_id="second",
placement=scad.make_placement_rplacement(origin=(10.0, 0.0, 0.0)),
)
package = scad.compile_scene(
scene_id="repeated",
roots=(scad.SceneRoot(root_id="main", value=assembly),),
source=_manual_source(),
)
definitions = package.manifest["definitions"]
nodes = package.manifest["nodes"]
part_definitions = [item for item in definitions if item["kind"] == "part"]
assert len(part_definitions) == 1
assert [item["node_id"] for item in nodes] == [
"instance/main",
"instance/main/first",
"instance/main/second",
]
assert len(package.manifest["geometry_assets"]) == 1
assert len(package.manifest["edge_assets"]) == 1
assert len(package.manifest["entity_assets"]) == 1
def test_same_part_id_with_different_body_geometry_is_rejected():
first = scad.make_part_rpart(
part_id="shared",
body=scad.make_box_rsolid(width=4.0, height=5.0, depth=6.0),
)
second = scad.make_part_rpart(
part_id="shared",
body=scad.make_box_rsolid(width=7.0, height=5.0, depth=6.0),
)
assembly = scad.make_assembly_rassembly(assembly_id="root")
assembly = scad.add_component_rassembly(
assembly=assembly,
item=first,
component_id="first",
placement=scad.identity_placement_rplacement(),
)
assembly = scad.add_component_rassembly(
assembly=assembly,
item=second,
component_id="second",
placement=scad.identity_placement_rplacement(),
)
with pytest.raises(ValueError, match="conflicting Part body geometry"):
scad.compile_scene(
scene_id="conflict",
roots=(scad.SceneRoot(root_id="main", value=assembly),),
source=_manual_source(),
)
def test_manual_face_connector_is_exported_with_entity_target():
solid = scad.make_box_rsolid(width=4.0, height=5.0, depth=6.0)
connector = scad.make_face_connector_rconnector(
connector_id="mount",
face=solid.get_faces()[0],
)
part = scad.make_part_rpart(part_id="block", body=solid)
part = scad.add_connector_rpart(part=part, connector=connector)
package = scad.compile_scene(
scene_id="connector",
roots=(scad.SceneRoot(root_id="main", value=part),),
source=_manual_source(),
)
snapshot = package.manifest["connectors"][0]
assert snapshot["anchor_kind"] == "geometry"
assert snapshot["target"]["entity_id"].startswith("entity/face/")
assert snapshot["target"]["entity_asset_id"] == package.manifest["definitions"][0]["entity_asset_id"]
assert snapshot["source"] == {"kind": "manual", "source_id": "main"}
def test_render_and_collision_use_the_same_default_mesh_object():
solid = scad.make_box_rsolid(width=10.0, height=20.0, depth=30.0)
cached = _mesh.cached_mesh(solid)
assert cached is not None
render = scad.build_render_mesh(
solid,
face_entity_ids=[f"entity/face/{index}" for index in range(len(solid.get_faces()))],
linear_tolerance=0.35,
angular_tolerance=0.22,
)
assert _mesh.cached_mesh(solid) is cached
assert len(render.indices) == cached.triangle_count * 3
def test_edge_mesh_uses_angular_tolerance_for_curved_edges():
solid = scad.make_cylinder_rsolid(radius=2.0, height=5.0)
edge_ids = [f"edge-{index}" for index, _edge in enumerate(solid.get_edges())]
default = scad.build_edge_mesh(
solid,
edge_entity_ids=edge_ids,
linear_tolerance=0.35,
angular_tolerance=0.22,
)
finer = scad.build_edge_mesh(
solid,
edge_entity_ids=edge_ids,
linear_tolerance=0.35,
angular_tolerance=0.1,
)
default_counts = sorted(len(block.segments) for block in default.blocks)
finer_counts = sorted(len(block.segments) for block in finer.blocks)
assert default_counts == [1, 29, 29]
assert finer_counts == [1, 63, 63]
def test_edge_mesh_default_angular_tolerance_preserves_public_call_shape():
solid = scad.make_cylinder_rsolid(radius=2.0, height=5.0)
edge_ids = [f"edge-{index}" for index, _edge in enumerate(solid.get_edges())]
mesh = scad.build_edge_mesh(
solid,
edge_entity_ids=edge_ids,
linear_tolerance=0.35,
)
assert sorted(len(block.segments) for block in mesh.blocks) == [1, 29, 29]
def test_compiler_declares_angular_edge_render_profile():
package = scad.compile_scene(
scene_id="edge-profile",
roots=(
scad.SceneRoot(
root_id="main",
value=scad.make_cylinder_rsolid(radius=2.0, height=5.0),
),
),
source=_manual_source(),
)
assert package.manifest["generator"]["profile"] == "scene-1.0-ocp-glb-2"
assert package.manifest["edge_assets"][0]["tessellation"] == {
"linear_tolerance": 0.35
}
assert validate_scene_package(package.manifest, package.blobs).valid
def test_validator_keeps_profile_1_scene_packages_readable(tmp_path):
package = scad.compile_scene(
scene_id="legacy-profile",
roots=(
scad.SceneRoot(
root_id="main",
value=scad.make_box_rsolid(width=2.0, height=3.0, depth=4.0),
),
),
source=_manual_source(),
)
path = tmp_path / "legacy-profile.scene.zip"
export_scene(package=package, path=path)
archive = preflight_zip_bytes(path.read_bytes())
manifest = parse_canonical_json(archive.members["scene.json"])
manifest["generator"]["profile"] = "scene-1.0-ocp-glb-1"
manifest = with_scene_revision(manifest)
assert validate_scene_package(manifest, package.blobs).valid
def test_export_scene_is_canonical_and_round_trips_through_archive_preflight(tmp_path):
package = scad.compile_scene(
scene_id="archive",
roots=(scad.SceneRoot(root_id="main", value=scad.make_box_rsolid(width=2.0, height=3.0, depth=4.0)),),
source=_manual_source(),
)
first_path = tmp_path / "first.scene.zip"
second_path = tmp_path / "second.scene.zip"
export_scene(package=package, path=first_path)
export_scene(package=package, path=second_path)
first_bytes = first_path.read_bytes()
assert first_bytes == second_path.read_bytes()
archive = preflight_zip_bytes(first_bytes)
manifest = parse_canonical_json(archive.members["scene.json"])
blobs = {name: payload for name, payload in archive.members.items() if name != "scene.json"}
report = validate_scene_package(manifest, blobs)
assert report.valid, report.issues
def test_forwarded_connector_is_finalized_after_its_source_connector():
part = scad.make_part_rpart(
part_id="inner",
body=scad.make_box_rsolid(width=2.0, height=2.0, depth=2.0),
)
part = scad.add_connector_rpart(
part=part,
connector=scad.make_placement_connector_rconnector(
connector_id="axis",
placement=scad.make_placement_rplacement(origin=(1.0, 0.0, 0.0)),
),
)
child = scad.make_assembly_rassembly(assembly_id="child")
child = scad.add_component_rassembly(
assembly=child,
item=part,
component_id="inner",
placement=scad.make_placement_rplacement(origin=(5.0, 0.0, 0.0)),
)
child = scad.forward_connector_rassembly(
assembly=child,
connector_id="public",
source_component_id="inner",
source_connector_id="axis",
)
root = scad.make_assembly_rassembly(assembly_id="root")
root = scad.add_component_rassembly(
assembly=root,
item=child,
component_id="child",
placement=scad.identity_placement_rplacement(),
)
package = scad.compile_scene(
scene_id="forwarded",
roots=(scad.SceneRoot(root_id="main", value=root),),
source=_manual_source(),
)
forwarded = next(item for item in package.manifest["connectors"] if item["connector_id"] == "public")
source = next(item for item in package.manifest["connectors"] if item["connector_id"] == "axis")
assert forwarded["forwarded_from"]["source_connector_snapshot_id"] == source["connector_snapshot_id"]
assert validate_scene_package(package.manifest, package.blobs).valid
def test_model_connector_sources_use_product_attachment_operations():
@scad.model(graph_id="connector_provenance")
def build_model():
body = scad.make_box_rsolid(width=2.0, height=2.0, depth=2.0)
part = scad.make_part_rpart(part_id="block", body=body)
part = scad.add_connector_rpart(
part=part,
connector=scad.make_placement_connector_rconnector(
connector_id="axis",
placement=scad.identity_placement_rplacement(),
),
)
assembly = scad.make_assembly_rassembly(assembly_id="root")
assembly = scad.add_component_rassembly(
assembly=assembly,
item=part,
component_id="block",
placement=scad.identity_placement_rplacement(),
)
assembly = scad.forward_connector_rassembly(
assembly=assembly,
connector_id="public_axis",
source_component_id="block",
source_connector_id="axis",
)
assembly = scad.place_component_rassembly(
assembly=assembly,
component_id="block",
placement=scad.make_placement_rplacement(origin=(1.0, 0.0, 0.0)),
)
scad.capture_result(value=assembly)
return assembly
result = build_model()
package = scad.compile_scene(
scene_id="connector-provenance",
roots=(scad.SceneRoot(root_id="main", value=result.value),),
source=result,
)
graph_nodes = {
node.node_id: node for node in result.session.graph.nodes
}
sources = {
connector["connector_id"]: connector["source"]
for connector in package.manifest["connectors"]
}
assert graph_nodes[sources["axis"]["node_id"]].op == "make_add_connector_rpart"
assert (
graph_nodes[sources["public_axis"]["node_id"]].op
== "make_forward_connector_rassembly"
)
assert sources["axis"]["node_id"] != sources["public_axis"]["node_id"]
assert validate_scene_package(package.manifest, package.blobs).valid
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,345 @@
"""Phase A characterization of the current Model 2.0 and OCP boundaries."""
from __future__ import annotations
import json
import pytest
from OCP.BRep import BRep_Tool
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.BRepTools import BRepTools
from OCP.TopAbs import TopAbs_FORWARD, TopAbs_REVERSED
from OCP.TopLoc import TopLoc_Location
import simplecadapi as scad
from simplecadapi.core import Compound, Edge, Face
from simplecadapi.kernel.ocp_export import make_compound_always
from simplecadapi.operations import _make_geo_selector
from simplecadapi.scene import (
canonical_json_bytes,
load_contract_artifact,
profile_cross,
profile_f32_bits,
profile_normalize,
)
from simplecadapi.serializer import (
_candidate_shapes_for_geo_selection,
_geo_selector_score,
_resolve_shape_from_geo_selector,
)
CHARACTERIZED_RENDER_PROFILE_CASES = {
"bounded_canonicalization_failure",
"closed_edge",
"degenerate_edge",
"duplicate_geometry_with_different_metadata",
"duplicate_geometry_with_different_provenance",
"negative_zero",
"normal_fallback",
"reversed_kernel_traversal",
"shared_face_rejection",
"symmetric_entity",
}
def _evaluated_profile() -> dict[str, object]:
return json.loads(
load_contract_artifact(
"profiles/ocp-evaluated-properties-1.profile.json"
)
)
def _render_profile() -> dict[str, object]:
return json.loads(
load_contract_artifact("profiles/scene-1.0-ocp-glb-2.profile.json")
)
def _fallback_normal(face: Face) -> tuple[float, float, float]:
BRepTools.Clean_s(face.wrapped, False)
mesher = BRepMesh_IncrementalMesh(face.wrapped, 0.35, False, 0.22, False)
mesher.Perform()
location = TopLoc_Location()
triangulation = BRep_Tool.Triangulation_s(face.wrapped, location, 0)
assert triangulation is not None
assert triangulation.HasNormals() is False
indices = list(triangulation.Triangle(1).Get())
if face.wrapped.Orientation() == TopAbs_REVERSED:
indices = [indices[0], indices[2], indices[1]]
points = []
transform = location.Transformation()
for index in indices:
point = triangulation.Node(index).Transformed(transform)
points.append((point.X() / 1000, point.Z() / 1000, -point.Y() / 1000))
first = tuple(points[1][index] - points[0][index] for index in range(3))
second = tuple(points[2][index] - points[0][index] for index in range(3))
return profile_normalize(profile_cross(first, second))
def test_all_render_profile_characterization_cases_have_targeted_evidence():
profile = _render_profile()
assert set(profile["rules"]["required_characterization_cases"]) == (
CHARACTERIZED_RENDER_PROFILE_CASES
)
def test_model_schema_2_topology_witnesses_and_roles_survive_clean_replay():
with scad.GraphSession(graph_id="scene-characterization") as session:
profile = scad.make_rectangle_rface(width=5, height=3)
scad.extrude_rsolid(
profile=profile,
direction=(0, 0, 1),
distance=2,
start_face_tag="role.start",
end_face_tag="role.end",
)
model_json = scad.export_model_json(session=session)
payload = json.loads(model_json)
assert payload["schema_version"] == "2.0"
assert payload["graph"]["schema_version"] == "2.0"
assert {
key: payload["graph"]["capabilities"][key]
for key in (
"topology_delta_entries",
"durable_topology_parent_refs",
"operation_output_roles",
)
} == {
"topology_delta_entries": True,
"durable_topology_parent_refs": True,
"operation_output_roles": True,
}
assert len(payload["topology_delta_log"]) == 1
witness = payload["topology_delta_log"][0]
delta = witness["delta"]
assert sorted({entry["role"] for entry in delta["roles"]}) == [
"extrusion.end",
"extrusion.side",
"extrusion.start",
]
assert all(
entry["ref"]["graph_id"] == "scene-characterization"
and entry["ref"]["node_id"] == witness["node_id"]
for entry in delta["entries"]
)
replayed = scad.replay_model_json(json_str=model_json)
assert len(replayed) == 1
solid = replayed[0]
assert {
role: sum(scad.ql.output_role(role)(face) for face in solid.get_faces())
for role in ("extrusion.start", "extrusion.end", "extrusion.side")
} == {
"extrusion.start": 1,
"extrusion.end": 1,
"extrusion.side": 4,
}
def test_profile_forces_reversed_edges_forward_before_selector_evaluation():
profile = _evaluated_profile()
preparation = profile["rules"]["shape_preparation"]
assert preparation["edge_orientation_call"] == "edge.Oriented(TopAbs_FORWARD)"
assert preparation["edge_orientation_order"] == "force_forward_before_location_bake"
edge = scad.make_line_redge(start=(0, 0, 0), end=(3, 4, 0))
reversed_edge = Edge(edge.wrapped.Oriented(TopAbs_REVERSED))
forward_edge = Edge(reversed_edge.wrapped.Oriented(TopAbs_FORWARD))
assert edge.wrapped.Orientation() == TopAbs_FORWARD
assert reversed_edge.wrapped.Orientation() == TopAbs_REVERSED
assert forward_edge.wrapped.Orientation() == TopAbs_FORWARD
assert _make_geo_selector(forward_edge) == _make_geo_selector(edge)
assert _geo_selector_score(
forward_edge, _make_geo_selector(edge)
) == pytest.approx(0.0)
def test_reversed_kernel_traversal_preserves_normalized_evaluated_properties():
first = scad.make_box_rsolid(
width=1,
height=2,
depth=3,
bottom_face_center=(-5, 0, 0),
)
second = scad.make_box_rsolid(
width=2,
height=3,
depth=4,
bottom_face_center=(5, 0, 0),
)
forward_root = Compound(
make_compound_always([first.wrapped, second.wrapped])
)
reversed_root = Compound(
make_compound_always([second.wrapped, first.wrapped])
)
forward_solids = forward_root.get_solids()
reversed_solids = reversed_root.get_solids()
assert len(forward_solids) == len(reversed_solids) == 2
assert forward_solids[0].wrapped.IsSame(first.wrapped)
assert forward_solids[1].wrapped.IsSame(second.wrapped)
assert reversed_solids[0].wrapped.IsSame(second.wrapped)
assert reversed_solids[1].wrapped.IsSame(first.wrapped)
def evaluated_property_records(root: Compound) -> list[bytes]:
records = []
for solid in root.get_solids():
selector = _make_geo_selector(solid)
records.append(
canonical_json_bytes(
{
"bounds": selector["bbox"],
"kind": selector["kind"],
"volume": selector["volume"],
}
)
)
return records
forward_records = evaluated_property_records(forward_root)
reversed_records = evaluated_property_records(reversed_root)
assert forward_records != reversed_records
assert tuple(sorted(forward_records)) == tuple(sorted(reversed_records))
def test_symmetric_entities_are_selector_ambiguous_even_if_legacy_resolver_picks_one():
profile = _evaluated_profile()
selector_rules = profile["rules"]["geo_exact_selector"]
threshold = selector_rules["threshold"]
assert selector_rules["multiple_match_status"] == "selector_ambiguous"
first = scad.make_box_rsolid(width=2, height=2, depth=2)
second = scad.make_box_rsolid(width=2, height=2, depth=2)
compound = Compound(make_compound_always([first.wrapped, second.wrapped]))
selector = _make_geo_selector(compound.get_edges()[0])
candidates = _candidate_shapes_for_geo_selection(compound, "edge")
passing = [
candidate
for candidate in candidates
if _geo_selector_score(candidate, selector) <= threshold
]
assert len(passing) == 2
legacy_result = _resolve_shape_from_geo_selector(compound, selector)
assert any(legacy_result.same_topology(candidate) for candidate in passing)
def test_serialized_closed_edge_connector_replays_but_has_no_current_frame():
profile = _evaluated_profile()
assert (
profile["rules"]["connector_frame"]["edge_undefined"]
== "missing_endpoint_or_coincident_endpoints"
)
with scad.GraphSession(graph_id="closed-edge-characterization") as session:
edge = scad.make_circle_redge(center=(0, 0, 0), radius=2)
scad.make_edge_connector_rconnector(
connector_id="closed_edge",
edge=edge,
)
replayed = scad.replay_model_json(
json_str=scad.export_model_json(session=session)
)
assert len(replayed) == 1
connector = replayed[0]
selector = connector.geometry_ref.geo_selector
assert selector["start"] == selector["end"]
with pytest.raises(ValueError, match="direction must be a non-zero vector"):
_ = connector.placement
def test_profile_canonicalizes_negative_zero_before_render_keys():
profile = _render_profile()
assert profile["rules"]["numeric"]["negative_zero"] == (
"canonicalize_to_positive_zero"
)
assert profile_f32_bits(-0.0) == profile_f32_bits(0.0) == 0
def test_short_valid_ocp_edge_collapses_after_gltf_float32_conversion():
profile = _render_profile()
assert profile["rules"]["canonical_blocks"]["edge_empty_policy"] == (
"retain_degenerate_entity_without_render_block"
)
edge = scad.make_line_redge(
start=(1000, 0, 0),
end=(1000.00001, 0, 0),
)
start = edge.get_start_vertex().get_coordinates()
end = edge.get_end_vertex().get_coordinates()
assert edge.get_length() > 0
assert tuple(profile_f32_bits(component / 1000) for component in start) == tuple(
profile_f32_bits(component / 1000) for component in end
)
def test_missing_kernel_normals_use_oriented_triangle_fallback():
profile = _render_profile()
assert profile["rules"]["normal"]["fallback"] == (
"oriented_triangle_cross_product_after_coordinate_conversion"
)
face = scad.make_rectangle_rface(width=2, height=3)
reversed_face = Face(face.wrapped.Oriented(TopAbs_REVERSED))
assert _fallback_normal(face) == pytest.approx((0, 1, 0))
assert _fallback_normal(reversed_face) == pytest.approx((0, -1, 0))
def test_duplicate_geometry_metadata_changes_selector_bytes_but_not_score():
first = scad.make_line_redge(start=(0, 0, 0), end=(1, 0, 0))
second = scad.make_line_redge(start=(0, 0, 0), end=(1, 0, 0))
first.set_metadata("geo", {"label": "first"})
second.set_metadata("geo", {"label": "second"})
first_selector = _make_geo_selector(first)
second_selector = _make_geo_selector(second)
assert first_selector["metadata_geo"] != second_selector["metadata_geo"]
assert _geo_selector_score(first, second_selector) == pytest.approx(0.0)
assert _geo_selector_score(second, first_selector) == pytest.approx(0.0)
def test_duplicate_geometry_provenance_is_distinct_but_not_selector_scored():
with scad.GraphSession(graph_id="duplicate-provenance"):
first = scad.make_line_redge(start=(0, 0, 0), end=(1, 0, 0))
second = scad.make_line_redge(start=(0, 0, 0), end=(1, 0, 0))
first_node = first._get_runtime("graph.node")
second_node = second._get_runtime("graph.node")
assert first_node.node_id != second_node.node_id
assert _make_geo_selector(first) == _make_geo_selector(second)
assert _geo_selector_score(first, _make_geo_selector(second)) == pytest.approx(0.0)
def test_shared_shape_compound_violates_disjoint_solid_ownership_target():
profile = _evaluated_profile()
topology = profile["rules"]["topology"]
assert topology["accepted_root"] == (
"single_manifold_solid_or_compound_of_disjoint_manifold_solids"
)
assert "shared_face_between_solids" in topology["rejected_roots"]
solid = scad.make_box_rsolid(width=2, height=2, depth=2)
compound = Compound(make_compound_always([solid.wrapped, solid.wrapped]))
solids = compound.get_solids()
assert len(solids) == 2
assert solids[0].wrapped.IsSame(solids[1].wrapped)
assert len(compound.get_faces()) == 12
assert len({face.topo_id for face in compound.get_faces()}) == 6
def test_symmetric_graph_canonicalization_has_a_hard_failure_budget():
profile = _evaluated_profile()
labeling = profile["rules"]["canonical_labeling"]
assert labeling["maximum_states"] == 1_000_000
assert labeling["budget_error"] == "entity_canonicalization_budget_exceeded"
assert labeling["exact_candidate_ties"] == (
"arbitrary_order_but_evaluate_every_branch"
)
@@ -0,0 +1,238 @@
"""Opt-in Phase A hierarchy characterization for selected large examples."""
from __future__ import annotations
import json
import os
from pathlib import Path
import subprocess
import sys
import threading
import time
import pytest
ROOT = Path(__file__).resolve().parents[1]
RUN_SLOW_EXAMPLES = os.environ.get("SIMPLECADAPI_RUN_SLOW_SCENE_EXAMPLES") == "1"
EXAMPLE_CASES = (
(
"10_part_assembly.py",
"build_hydraulic_rod_assembly",
3,
3,
2,
),
(
"16_compact_two_stage_planetary_reducer/main.py",
"_build_compact_two_stage_planetary_reducer",
116,
17,
15,
),
(
"20_integrated_bldc_joint_actuator/main.py",
"build_integrated_bldc_joint_actuator",
186,
43,
34,
),
)
_PROBE = r"""
import importlib.util
import json
from pathlib import Path
import sys
import simplecadapi as scad
from simplecadapi.product import Assembly
path = Path(sys.argv[1]).resolve()
builder_name = sys.argv[2]
print(
f"SCENE_PHASE_A_START={path.name}:{builder_name}",
flush=True,
)
sys.path.insert(0, str(path.parent))
spec = importlib.util.spec_from_file_location("phase_a_example_probe", path)
if spec is None or spec.loader is None:
raise RuntimeError(f"could not load example: {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
result = getattr(module, builder_name)()
assembly = result.value[0]
if not isinstance(assembly, Assembly):
raise TypeError(f"{builder_name} did not return an Assembly first")
nodes = []
def walk(item):
nodes.append(item)
if isinstance(item, Assembly):
for component in item.components:
walk(component.item)
walk(assembly)
definitions = {
("assembly", item.assembly_id)
if isinstance(item, Assembly)
else ("part", item.part_id)
for item in nodes
}
part_definitions = {
item.part_id
for item in nodes
if not isinstance(item, Assembly)
}
face_naming = {}
if path.name == "10_part_assembly.py":
naming_contract = {
"outer_sleeve": ("sleeve.", "sleeve.gland.face.mount"),
"piston_rod": ("rod.", "rod.piston.land.left.face.rear"),
}
for part_id, (prefix, connector_face_tag) in naming_contract.items():
part = next(
item
for item in nodes
if not isinstance(item, Assembly) and item.part_id == part_id
)
faces = part.body.get_faces()
face_naming[part_id] = {
"face_count": len(faces),
"unnamed_indices": [
index
for index, face in enumerate(faces)
if not any(
tag.startswith(prefix)
for tag in scad.list_tags(face, scope="local")
)
],
"connector_face_count": len(
scad.select_faces_by_tag(
part.body,
connector_face_tag,
scope="local",
)
),
}
print("SCENE_PHASE_A_FACTS=" + json.dumps({
"expected_mesh_count": len(part_definitions),
"face_naming": face_naming,
"product_node_count": len(nodes),
"unique_definition_count": len(definitions),
}, sort_keys=True), flush=True)
"""
@pytest.mark.skipif(
not RUN_SLOW_EXAMPLES,
reason="set SIMPLECADAPI_RUN_SLOW_SCENE_EXAMPLES=1 to run large examples",
)
@pytest.mark.parametrize(
(
"relative_path",
"builder_name",
"expected_nodes",
"expected_definitions",
"expected_meshes",
),
EXAMPLE_CASES,
ids=("example_10", "example_16", "example_20"),
)
def test_allowlisted_example_product_hierarchy_in_fresh_process(
relative_path: str,
builder_name: str,
expected_nodes: int,
expected_definitions: int,
expected_meshes: int,
):
path = ROOT / "examples" / relative_path
environment = dict(os.environ)
environment["PYTHONHASHSEED"] = "0"
label = f"{relative_path}:{builder_name}"
print(
f"\n[scene-example] starting {label}; "
f"expected nodes={expected_nodes}, definitions={expected_definitions}, "
f"meshes={expected_meshes}",
flush=True,
)
started = time.monotonic()
process = subprocess.Popen(
[sys.executable, "-c", _PROBE, str(path), builder_name],
cwd=ROOT,
env=environment,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
assert process.stdout is not None
output: list[str] = []
def relay_output() -> None:
assert process.stdout is not None
for line in process.stdout:
output.append(line)
print(f"[{relative_path}] {line}", end="", flush=True)
relay = threading.Thread(target=relay_output, daemon=True)
relay.start()
try:
next_heartbeat = 15.0
while process.poll() is None:
elapsed = time.monotonic() - started
if elapsed >= 600:
process.kill()
process.wait()
pytest.fail(f"{label} timed out after {elapsed:.1f}s")
if elapsed >= next_heartbeat:
print(
f"[scene-example] still building {label}; elapsed={elapsed:.1f}s",
flush=True,
)
next_heartbeat += 15.0
time.sleep(0.5)
except BaseException:
if process.poll() is None:
process.kill()
process.wait()
raise
finally:
relay.join(timeout=5)
elapsed = time.monotonic() - started
stdout = "".join(output)
print(
f"[scene-example] finished {label}; "
f"returncode={process.returncode}, elapsed={elapsed:.1f}s",
flush=True,
)
assert process.returncode == 0, stdout
fact_lines = [
line.removeprefix("SCENE_PHASE_A_FACTS=")
for line in stdout.splitlines()
if line.startswith("SCENE_PHASE_A_FACTS=")
]
assert len(fact_lines) == 1, stdout
assert json.loads(fact_lines[0]) == {
"expected_mesh_count": expected_meshes,
"face_naming": (
{
"outer_sleeve": {
"connector_face_count": 1,
"face_count": 31,
"unnamed_indices": [],
},
"piston_rod": {
"connector_face_count": 1,
"face_count": 24,
"unnamed_indices": [],
},
}
if relative_path == "10_part_assembly.py"
else {}
),
"product_node_count": expected_nodes,
"unique_definition_count": expected_definitions,
}
+294
View File
@@ -163,6 +163,7 @@ class TestCoverageMatrix(unittest.TestCase):
"revolve_rsolid",
"loft_rsolid",
"sweep_rsolid",
"twisted_sweep_rsolid",
"helical_sweep_rsolid",
"union_rsolid",
"cut_rsolid",
@@ -302,6 +303,7 @@ class TestCoverageMatrix(unittest.TestCase):
"make_revolve_rsolid",
"make_loft_rsolid",
"make_sweep_rsolid",
"make_twisted_sweep_rsolid",
"make_translate_rshape",
"make_rotate_rshape",
"make_mirror_rshape",
@@ -352,6 +354,31 @@ class TestCoverageMatrix(unittest.TestCase):
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:
@@ -369,6 +396,222 @@ class TestReplay(unittest.TestCase):
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(
@@ -846,6 +1089,38 @@ class TestReplay(unittest.TestCase):
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)
@@ -1022,6 +1297,25 @@ class TestReplay(unittest.TestCase):
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)
+103
View File
@@ -6,6 +6,7 @@ import json
import unittest
import simplecadapi as scad
from simplecadapi import ql as Q
class TestSketchApi(unittest.TestCase):
@@ -92,6 +93,108 @@ class TestSketchApi(unittest.TestCase):
self.assertAlmostEqual(face.get_area(), 3.141592653589793 * 2.25, places=5)
self.assertIn("sketch_entity.outer", scad.list_tags(face.get_edges(0)))
def test_constrained_sketch_promotion_has_topology_identity_tags(self):
sketch = self._make_constrained_rectangle()
face = scad.make_face_from_sketch_rface(
sketch, require_fully_constrained=True
)
self.assertEqual(
len(Q.faces().where(Q.tag("sketch.rect.profile.bottom")).resolve(face)),
1,
)
for entity_id in ("bottom", "right", "top", "left"):
edges = Q.edges().where(
Q.tag(f"sketch.rect.entity.{entity_id}")
).resolve(face)
self.assertEqual(len(edges), 1)
evidence = scad.explain_tag(
edges[0], f"sketch.rect.entity.{entity_id}", scope="local"
)[0]["binding"]["evidence"]
self.assertEqual(evidence["evidence_method"], "SketchPromotionMap")
self.assertEqual(
evidence["sketch_promotion"]["entity_id"], entity_id
)
self.assertEqual(evidence["topology_name"]["kind"], "edge")
def test_constrained_sketch_wire_promotion_has_topology_identity_tags(self):
sketch = self._make_constrained_rectangle()
wire = scad.make_wire_from_sketch_rwire(
sketch, require_fully_constrained=True
)
self.assertEqual(
len(Q.wires().where(Q.tag("sketch.rect.profile.bottom")).resolve(wire)),
1,
)
for entity_id in ("bottom", "right", "top", "left"):
edges = Q.edges().where(
Q.tag(f"sketch.rect.entity.{entity_id}")
).resolve(wire)
self.assertEqual(len(edges), 1)
evidence = scad.explain_tag(
edges[0], f"sketch.rect.entity.{entity_id}", scope="local"
)[0]["binding"]["evidence"]
self.assertEqual(evidence["evidence_method"], "SketchPromotionMap")
self.assertEqual(
evidence["sketch_promotion"]["entity_id"], entity_id
)
self.assertEqual(evidence["topology_name"]["kind"], "edge")
def test_constrained_sketch_topology_tags_project_and_replay(self):
with scad.GraphSession() as session:
sketch = self._make_constrained_rectangle()
profile = scad.make_face_from_sketch_rface(sketch)
body = scad.extrude_rsolid(
profile, (0, 0, 1), 2.0, tag_prefix="body"
)
expected_tags = {
"body.face.side.bottom",
"body.face.side.right",
"body.face.side.top",
"body.face.side.left",
}
self.assertEqual(
{
tag
for face in body.get_faces()
for tag in scad.list_tags(face, scope="local")
if tag.startswith("body.face.side.")
},
expected_tags,
)
payload = json.loads(scad.export_model_json(session))
promotion = next(
node
for node in payload["graph"]["nodes"]
if node["op"] == "make_face_from_sketch_rface"
)
self.assertEqual(
promotion["params"]["promotion_map"]["topology_name"]["kind"],
"face",
)
self.assertEqual(
[
edge["topology_name"]["local_name"]
for edge in promotion["params"]["promotion_map"]["edges"]
],
["bottom", "right", "top", "left"],
)
replayed = scad.replay_model_json(json.dumps(payload))
rebuilt = next(shape for shape in replayed if isinstance(shape, scad.Solid))
self.assertEqual(
{
tag
for face in rebuilt.get_faces()
for tag in scad.list_tags(face, scope="local")
if tag.startswith("body.face.side.")
},
expected_tags,
)
def test_underconstrained_and_conflicting_sketches_report_diagnostics(self):
sketch = scad.make_sketch_rsketch("open")
sketch = scad.add_point_rsketch(sketch, "p0", 0.0, 0.0)
+3
View File
@@ -181,6 +181,9 @@ class TestSkillPackPathResolution(unittest.TestCase):
content = packager._build_skill_markdown()
self.assertIn("GraphSession", content)
self.assertIn("ModelResult", content)
self.assertIn("@model", content)
self.assertIn("capture_result", content)
self.assertIn("export_model_json", content)
self.assertIn("replay_model_json", content)
self.assertIn("Use the graph/model JSON workflow", content)
@@ -0,0 +1,97 @@
"""Host-independent tests for the SolidWorks translator backend."""
from __future__ import annotations
import importlib
import sys
import unittest
import simplecadapi as scad
from simplecadapi import GraphSession
from simplecadapi.translator.errors import TranslationRequestError
def _rectangle_extrusion_model() -> str:
with GraphSession() as session:
profile = scad.make_rectangle_rface(width=2.0, height=1.0)
scad.extrude_rsolid(
profile=profile,
direction=(0.0, 0.0, 1.0),
distance=3.0,
)
return scad.export_model_json(session=session)
class TestSolidWorksTranslator(unittest.TestCase):
def test_backend_imports_without_target_runtime_modules(self):
before = set(sys.modules)
solidworks = importlib.import_module(
"simplecadapi.translator.solidworks_translator"
)
self.assertNotIn("pythoncom", set(sys.modules) - before)
self.assertNotIn("win32com.client", set(sys.modules) - before)
self.assertEqual(solidworks.CAPABILITIES.backend_id, "solidworks")
def test_supported_graph_emits_deterministic_compilable_script(self):
model_json = _rectangle_extrusion_model()
from simplecadapi.translator.solidworks_translator import SolidWorksTranslator
translator = SolidWorksTranslator(document_name="ContractSolidWorks")
first = translator.translate_model_json(model_json)
second = translator.translate_model_json(model_json)
self.assertEqual(first.content, second.content)
compile(first.content, "<solidworks-script>", "exec")
self.assertFalse(first.metadata["target_runtime_validated"])
self.assertTrue(translator.capabilities.targets[0].requires_external_runtime)
def test_fallback_scripts_are_deterministic(self):
with GraphSession() as session:
profile = scad.make_rectangle_rface(3.0, 3.0)
solid = scad.extrude_rsolid(profile, (0.0, 0.0, 1.0), 3.0)
scad.fillet_rsolid(solid, [solid.get_edges(0)], 0.2)
model_json = scad.export_model_json(session)
from simplecadapi.translator.solidworks_translator import SolidWorksTranslator
translator = SolidWorksTranslator(source_kernel_fallback=True)
first = translator.translate_model_json(model_json).content
second = translator.translate_model_json(model_json).content
self.assertEqual(first, second)
compile(first, "<solidworks-fallback-script>", "exec")
def test_script_owns_com_and_only_its_created_document(self):
from simplecadapi.translator.solidworks_translator import SolidWorksTranslator
script = (
SolidWorksTranslator(visible=True)
.translate_model_json(_rectangle_extrusion_model())
.content
)
main_offset = script.index("def main():")
coinit_offset = script.index("pythoncom.CoInitialize()", main_offset)
runtime_offset = script.index(
"runtime = SimpleCADSolidWorksRuntime", main_offset
)
self.assertLess(coinit_offset, runtime_offset)
self.assertEqual(script.count("pythoncom.CoInitialize()"), 1)
self.assertIn("self.sw.CloseDoc(str(_maybe_call(self.model.GetTitle)))", script)
self.assertIn("if not self.visible:", script)
self.assertIn("self.sw.ExitApp()", script)
def test_unsupported_result_operation_is_rejected_before_host_execution(self):
with GraphSession() as session:
scad.make_box_rsolid(width=1.0, height=2.0, depth=3.0)
from simplecadapi.translator.solidworks_translator import SolidWorksTranslator
with self.assertRaises(TranslationRequestError):
SolidWorksTranslator().translate_model_json(scad.export_model_json(session))
if __name__ == "__main__":
unittest.main()
+116
View File
@@ -0,0 +1,116 @@
"""Regression tests for operation source provenance."""
import json
import simplecadapi as scad
from simplecadapi.serializer import export_graph_json
from simplecadapi.source_mapping import canonical_source_payload
from simplecadapi.topology import OperationGraph
def test_source_mapping_records_call_span_and_assignment_target():
with scad.GraphSession(graph_id="source_mapping") as session:
width = 10.0
body = scad.make_box_rsolid(
width=width,
height=2.0,
depth=3.0,
)
node = session.graph.nodes[0]
assert node.source is not None
assert node.source["path"].endswith("test_source_mapping.py")
assert node.source["path_kind"] == "project_relative"
assert node.source["line"] <= node.source["end_line"]
assert node.source["call_text"].startswith("scad.make_box_rsolid")
assert node.source["assignment_targets"] == ["body"]
assert "input_bindings" not in node.source
def test_source_mapping_disambiguates_same_line_calls_and_round_trips():
with scad.GraphSession(graph_id="source_mapping_same_line") as session:
first = scad.make_box_rsolid(1.0, 1.0, 1.0); second = scad.make_box_rsolid(2.0, 2.0, 2.0)
nodes = session.graph.topological_order()
assert len(nodes) == 2
assert nodes[0].source is not None
assert nodes[1].source is not None
assert nodes[0].source["assignment_targets"] == ["first"]
assert nodes[1].source["assignment_targets"] == ["second"]
assert nodes[0].source["column"] < nodes[1].source["column"]
payload = json.loads(export_graph_json(session.graph))
assert payload["capabilities"]["source_mapping"] is True
assert "local_path" not in payload["nodes"][0]["source"]
restored = OperationGraph.from_dict(payload)
assert restored.nodes[0].source == canonical_source_payload(nodes[0].source)
assert restored.nodes[1].source == canonical_source_payload(nodes[1].source)
def test_source_mapping_does_not_claim_nested_call_as_assignment_output():
with scad.GraphSession(graph_id="source_mapping_nested") as session:
body = scad.make_box_rsolid(1.0, 1.0, 1.0)
moved = scad.translate_shape(
shape=body,
vector=(1.0, 0.0, 0.0),
)
translate_node = session.graph.topological_order()[1]
assert translate_node.source is not None
assert translate_node.source["assignment_targets"] == ["moved"]
def test_source_mapping_records_complex_targets_and_return():
class Holder:
pass
holder = Holder()
with scad.GraphSession(graph_id="source_mapping_targets") as session:
holder.body = scad.make_box_rsolid(1.0, 1.0, 1.0)
items = [None]
index = 0
items[index] = scad.make_box_rsolid(2.0, 2.0, 2.0)
first, second = session.graph.topological_order()
assert first.source is not None
assert first.source["assignment_targets"] == ["holder.body"]
assert second.source is not None
assert second.source["assignment_targets"] == ["items[index]"]
def test_source_mapping_records_return_and_requires_session_builder():
@scad.requires_session
def build_box():
return scad.make_box_rsolid(1.0, 1.0, 1.0)
with scad.GraphSession(graph_id="source_mapping_return") as session:
result = build_box()
node = session.graph.nodes[0]
assert result.get_metadata("graph")["node_id"] == node.node_id
assert node.source is not None
assert node.source["call_text"] == "scad.make_box_rsolid(1.0, 1.0, 1.0)"
assert node.source["assignment_targets"] == []
def test_macro_nodes_share_the_user_callsite_and_assignment_target():
with scad.GraphSession(graph_id="source_mapping_macro") as session:
profile = scad.make_rectangle_rface(width=2.0, height=1.0)
nodes = session.graph.topological_order()
assert len(nodes) > 1
assert profile.get_metadata("graph")["node_id"] == nodes[-1].node_id
assert all(node.source is not None for node in nodes)
callsite_ids = {node.source["callsite_id"] for node in nodes if node.source}
call_texts = {node.source["call_text"] for node in nodes if node.source}
targets = {
tuple(node.source["assignment_targets"])
for node in nodes
if node.source
}
assert len(callsite_ids) == 1
assert call_texts == {
"scad.make_rectangle_rface(width=2.0, height=1.0)"
}
assert targets == {("profile",)}
+6
View File
@@ -151,6 +151,12 @@ class TestBallBearingAssembly(unittest.TestCase):
self.assertIn("make_revolute_constraint_rassembly", ops)
self.assertEqual(ops.count("make_forward_connector_rassembly"), 2)
self.assertIn("make_compound_from_assembly_rcompound", ops)
stdlib_nodes = [
node
for node in payload["graph"]["nodes"]
if node["op"] != "make_compound_from_assembly_rcompound"
]
self.assertTrue(all("topo_delta" not in node for node in stdlib_nodes))
def test_inferred_ball_count_is_recorded(self):
bearing = scad.std.bearing.make_ball_bearing_rassembly(
+106
View File
@@ -0,0 +1,106 @@
"""Tests for roller-chain standard parts."""
import json
import math
import unittest
import simplecadapi as scad
class TestRollerChainSprocket(unittest.TestCase):
def test_iso_606_12b_candidate_geometry(self):
sprocket = scad.std.chain.make_roller_chain_sprocket_rsolid(
n_teeth=18,
chain_pitch=19.05,
roller_diameter=12.07,
sprocket_thickness=11.1,
bore_radius=10.2,
)
self.assertGreater(sprocket.get_volume(), 0.0)
meta = sprocket.get_metadata("std.chain.roller_sprocket")
self.assertEqual(meta["n_teeth"], 18)
self.assertAlmostEqual(
meta["pitch_radius"],
19.05 / (2.0 * math.sin(math.pi / 18)),
)
self.assertLess(meta["root_radius"], meta["pitch_radius"])
self.assertGreater(meta["outside_radius"], meta["pitch_radius"])
print(
"roller_chain_sprocket: "
f"volume={sprocket.get_volume():.3f} "
f"pitch_radius={meta['pitch_radius']:.6f}"
)
def test_roller_seats_are_recorded_as_strict_cuts(self):
with scad.GraphSession() as session:
sprocket = scad.std.chain.make_roller_chain_sprocket_rsolid(
n_teeth=18,
chain_pitch=19.05,
roller_diameter=12.07,
sprocket_thickness=11.1,
bore_radius=10.2,
)
model_json = scad.export_model_json(session=session)
payload = json.loads(model_json)
cut_nodes = [
node
for node in payload["graph"]["nodes"]
if node["op"] == "make_cut_rsolid"
]
self.assertEqual(len(cut_nodes), 1)
self.assertEqual(cut_nodes[0]["params"]["tool_count"], 19)
self.assertFalse(cut_nodes[0]["params"]["skip_non_intersecting"])
self.assertEqual(cut_nodes[0]["params"]["tracking_policy"], "graph")
self.assertTrue(
all("topo_delta" not in node for node in payload["graph"]["nodes"])
)
replayed = scad.replay_model_json(json_str=model_json, strict=True)
self.assertEqual(len(replayed), 1)
self.assertAlmostEqual(
replayed[0].get_volume(), sprocket.get_volume(), places=5
)
def test_invalid_sprocket_parameters(self):
invalid_kwargs = (
{
"n_teeth": 5,
"chain_pitch": 19.05,
"roller_diameter": 12.07,
"sprocket_thickness": 11.1,
},
{
"n_teeth": 18,
"chain_pitch": 0.0,
"roller_diameter": 12.07,
"sprocket_thickness": 11.1,
},
{
"n_teeth": 18,
"chain_pitch": 19.05,
"roller_diameter": 19.05,
"sprocket_thickness": 11.1,
},
{
"n_teeth": 18,
"chain_pitch": 19.05,
"roller_diameter": 12.07,
"sprocket_thickness": 0.0,
},
{
"n_teeth": 18,
"chain_pitch": 19.05,
"roller_diameter": 12.07,
"sprocket_thickness": 11.1,
"bore_radius": 60.0,
},
)
for kwargs in invalid_kwargs:
with self.subTest(kwargs=kwargs), self.assertRaises(ValueError):
scad.std.chain.make_roller_chain_sprocket_rsolid(**kwargs)
if __name__ == "__main__":
unittest.main()
+359
View File
@@ -0,0 +1,359 @@
"""Tests for parameterized bolt and nut standard parts."""
import json
import unittest
import simplecadapi as scad
class TestFastenerSurface(unittest.TestCase):
def test_preferred_nested_std_export_surface(self):
bolt = scad.std.fastener.make_bolt_rsolid(diameter=8.0, length=24.0)
nut = scad.std.fastener.make_nut_rsolid(
diameter=8.0,
width=13.0,
height=6.5,
)
self.assertIsInstance(bolt, scad.Solid)
self.assertIsInstance(nut, scad.Solid)
def test_public_factories_follow_make_rtype_naming(self):
factory_names = [
name
for name in scad.std.fastener.__all__
if callable(getattr(scad.std.fastener, name, None))
]
self.assertEqual(factory_names, ["make_bolt_rsolid", "make_nut_rsolid"])
for name in factory_names:
self.assertTrue(name.startswith("make_"), name)
self.assertIn("_r", name, name)
class TestBoltSolid(unittest.TestCase):
def test_supported_head_and_drive_styles(self):
styles = (
("hex", "none"),
("square", "slot"),
("cylindrical", "hex_socket"),
("button", "cross"),
("countersunk", "slot"),
)
for head_style, drive_style in styles:
with self.subTest(head_style=head_style, drive_style=drive_style):
bolt = scad.std.fastener.make_bolt_rsolid(
diameter=8.0,
length=24.0,
head_style=head_style,
thread_style="partial",
drive_style=drive_style,
)
meta = bolt.get_metadata("std.fastener.bolt")
self.assertGreater(bolt.get_volume(), 0.0)
self.assertEqual(meta["head_style"], head_style)
self.assertEqual(meta["drive_style"], drive_style)
self.assertEqual(meta["thread_start"], 24.0 - meta["thread_length"])
def test_thread_styles_record_expected_interval(self):
none = scad.std.fastener.make_bolt_rsolid(
diameter=8.0,
length=24.0,
thread_style="none",
)
full = scad.std.fastener.make_bolt_rsolid(
diameter=8.0,
length=24.0,
thread_style="full",
)
partial = scad.std.fastener.make_bolt_rsolid(
diameter=8.0,
length=24.0,
thread_style="partial",
thread_length=15.0,
)
none_meta = none.get_metadata("std.fastener.bolt")
full_meta = full.get_metadata("std.fastener.bolt")
partial_meta = partial.get_metadata("std.fastener.bolt")
self.assertEqual(none_meta["thread_length"], 0.0)
self.assertEqual(full_meta["thread_start"], 0.0)
self.assertEqual(full_meta["thread_length"], 24.0)
self.assertEqual(partial_meta["thread_start"], 9.0)
self.assertEqual(partial_meta["thread_length"], 15.0)
def test_metric_coarse_series_and_basic_thread_dimensions(self):
bolt = scad.std.fastener.make_bolt_rsolid(
diameter=10.0,
length=40.0,
)
meta = bolt.get_metadata("std.fastener.bolt")
self.assertEqual(meta["thread_pitch"], 1.5)
self.assertEqual(meta["thread_pitch_source"], "metric_coarse")
self.assertAlmostEqual(meta["pitch_diameter"], 10.0 - 0.6495 * 1.5, places=5)
self.assertAlmostEqual(
meta["basic_minor_diameter"],
10.0 - 1.0825 * 1.5,
places=5,
)
self.assertEqual(meta["thread_style"], "partial")
self.assertEqual(meta["thread_start"], 14.0)
self.assertEqual(meta["thread_length"], 26.0)
self.assertAlmostEqual(meta["head_width"], 16.0)
self.assertAlmostEqual(meta["head_height"], 6.4)
self.assertAlmostEqual(meta["head_across_corners"], 16.0 / 0.8660254038)
self.assertAlmostEqual(meta["underhead_fillet_radius"], 0.6)
self.assertEqual(meta["recommended_mating_hole_chamfer_min"], 0.6)
def test_explicit_thread_pitch_is_required_outside_coarse_series(self):
with self.assertRaises(ValueError):
scad.std.fastener.make_bolt_rsolid(diameter=9.0, length=24.0)
bolt = scad.std.fastener.make_bolt_rsolid(
diameter=9.0,
length=24.0,
thread_pitch=1.25,
)
self.assertEqual(
bolt.get_metadata("std.fastener.bolt")["thread_pitch_source"],
"explicit",
)
def test_auto_uses_full_thread_when_standard_partial_length_exceeds_length(self):
bolt = scad.std.fastener.make_bolt_rsolid(
diameter=4.0,
length=13.0,
thread_detail="cosmetic",
)
meta = bolt.get_metadata("std.fastener.bolt")
self.assertEqual(meta["requested_thread_style"], "auto")
self.assertEqual(meta["thread_style"], "full")
self.assertEqual(meta["thread_length"], 13.0)
def test_modeled_external_thread_strict_replay(self):
with scad.GraphSession() as session:
bolt = scad.std.fastener.make_bolt_rsolid(
diameter=8.0,
length=24.0,
head_style="button",
thread_style="partial",
thread_detail="modeled",
thread_form="v",
thread_pitch=1.25,
thread_depth=0.65,
thread_length=18.0,
drive_style="cross",
)
model_json = scad.export_model_json(session=session)
payload = json.loads(model_json)
ops = [node["op"] for node in payload["graph"]["nodes"]]
self.assertIn("make_helix_redge", ops)
self.assertIn("make_sweep_rsolid", ops)
self.assertIn("make_cut_rsolid", ops)
self.assertTrue(
all("topo_delta" not in node for node in payload["graph"]["nodes"])
)
replayed = scad.replay_model_json(json_str=model_json, strict=True)
self.assertEqual(len(replayed), 1)
self.assertAlmostEqual(replayed[0].get_volume(), bolt.get_volume(), places=5)
print(
"modeled_bolt: "
f"volume={bolt.get_volume():.3f} faces={len(bolt.get_faces())}"
)
def test_external_thread_stabilization_records_only_selected_phase(self):
with scad.GraphSession() as session:
bolt = scad.std.fastener.make_bolt_rsolid(
diameter=6.0,
length=18.0,
thread_style="full",
thread_detail="modeled",
thread_form="v",
thread_pitch=1.0,
thread_depth=0.45,
)
model_json = scad.export_model_json(session=session)
meta = bolt.get_metadata("std.fastener.bolt")
payload = json.loads(model_json)
ops = [node["op"] for node in payload["graph"]["nodes"]]
self.assertNotEqual(meta["thread_phase_degrees"], 0.0)
self.assertEqual(ops.count("make_rotate_rshape"), 1)
replayed = scad.replay_model_json(json_str=model_json, strict=True)
self.assertAlmostEqual(replayed[0].get_volume(), bolt.get_volume(), places=5)
def test_invalid_bolt_parameters(self):
invalid_kwargs = (
{"diameter": 0.0, "length": 24.0},
{"diameter": 8.0, "length": 24.0, "head_style": "wing"},
{"diameter": 8.0, "length": 24.0, "head_width": 7.0},
{
"diameter": 8.0,
"length": 24.0,
"thread_style": "partial",
"thread_length": 24.0,
},
{
"diameter": 8.0,
"length": 24.0,
"thread_style": "full",
"thread_length": 12.0,
},
{
"diameter": 8.0,
"length": 24.0,
"drive_style": "none",
"drive_size": 4.0,
},
)
for kwargs in invalid_kwargs:
with self.subTest(kwargs=kwargs), self.assertRaises(ValueError):
scad.std.fastener.make_bolt_rsolid(**kwargs)
class TestNutSolid(unittest.TestCase):
def test_supported_nut_and_hole_styles(self):
for nut_style in ("hex", "square", "round", "knurled"):
for hole_style in ("through", "blind"):
with self.subTest(nut_style=nut_style, hole_style=hole_style):
kwargs = {"hole_depth": 5.0} if hole_style == "blind" else {}
nut = scad.std.fastener.make_nut_rsolid(
diameter=8.0,
width=13.0,
height=6.5,
nut_style=nut_style,
hole_style=hole_style,
**kwargs,
)
meta = nut.get_metadata("std.fastener.nut")
self.assertGreater(nut.get_volume(), 0.0)
self.assertEqual(meta["nut_style"], nut_style)
self.assertEqual(meta["hole_style"], hole_style)
def test_blind_hole_retains_more_material_than_through_hole(self):
through = scad.std.fastener.make_nut_rsolid(
diameter=8.0,
width=13.0,
height=7.0,
hole_style="through",
)
blind = scad.std.fastener.make_nut_rsolid(
diameter=8.0,
width=13.0,
height=7.0,
hole_style="blind",
hole_depth=5.0,
)
self.assertGreater(blind.get_volume(), through.get_volume())
def test_modeled_internal_thread_strict_replay(self):
cosmetic = scad.std.fastener.make_nut_rsolid(
diameter=8.0,
width=13.0,
height=7.0,
nut_style="knurled",
hole_style="blind",
hole_depth=5.5,
thread_detail="cosmetic",
knurl_count=18,
)
with scad.GraphSession() as session:
modeled = scad.std.fastener.make_nut_rsolid(
diameter=8.0,
width=13.0,
height=7.0,
nut_style="knurled",
hole_style="blind",
hole_depth=5.5,
thread_detail="modeled",
thread_form="trapezoidal",
thread_pitch=1.25,
thread_depth=0.65,
knurl_count=18,
)
model_json = scad.export_model_json(session=session)
self.assertGreater(modeled.get_volume(), cosmetic.get_volume())
payload = json.loads(model_json)
ops = [node["op"] for node in payload["graph"]["nodes"]]
self.assertIn("make_helix_redge", ops)
self.assertIn("make_sweep_rsolid", ops)
self.assertIn("make_union_rsolid", ops)
self.assertTrue(
all("topo_delta" not in node for node in payload["graph"]["nodes"])
)
replayed = scad.replay_model_json(json_str=model_json, strict=True)
self.assertEqual(len(replayed), 1)
self.assertAlmostEqual(replayed[0].get_volume(), modeled.get_volume(), places=5)
print(
"modeled_nut: "
f"volume={modeled.get_volume():.3f} faces={len(modeled.get_faces())}"
)
def test_internal_thread_stabilization_records_only_selected_phase(self):
with scad.GraphSession() as session:
nut = scad.std.fastener.make_nut_rsolid(
diameter=6.0,
width=10.0,
height=5.0,
thread_detail="modeled",
thread_form="trapezoidal",
thread_pitch=1.0,
thread_depth=0.45,
)
model_json = scad.export_model_json(session=session)
meta = nut.get_metadata("std.fastener.nut")
payload = json.loads(model_json)
ops = [node["op"] for node in payload["graph"]["nodes"]]
self.assertNotEqual(meta["thread_phase_degrees"], 0.0)
self.assertEqual(ops.count("make_rotate_rshape"), 1)
replayed = scad.replay_model_json(json_str=model_json, strict=True)
self.assertAlmostEqual(replayed[0].get_volume(), nut.get_volume(), places=5)
def test_invalid_nut_parameters(self):
invalid_kwargs = (
{"diameter": 8.0, "width": 8.2, "height": 6.5},
{
"diameter": 8.0,
"width": 13.0,
"height": 6.5,
"nut_style": "castle",
},
{
"diameter": 8.0,
"width": 13.0,
"height": 6.5,
"hole_style": "through",
"hole_depth": 5.0,
},
{
"diameter": 8.0,
"width": 13.0,
"height": 6.5,
"hole_style": "blind",
"hole_depth": 6.5,
},
{
"diameter": 8.0,
"width": 13.0,
"height": 6.5,
"nut_style": "knurled",
"knurl_count": 6,
},
)
for kwargs in invalid_kwargs:
with self.subTest(kwargs=kwargs), self.assertRaises(ValueError):
scad.std.fastener.make_nut_rsolid(**kwargs)
if __name__ == "__main__":
unittest.main()
+364 -56
View File
@@ -3,6 +3,7 @@
import json
import math
import unittest
from unittest import mock
import simplecadapi as scad
@@ -12,21 +13,26 @@ def _loft_nodes_for(factory):
factory()
payload = json.loads(scad.export_model_json(session=session))
return [node for node in payload["graph"]["nodes"] if node["op"] == "make_loft_rsolid"]
return [
node for node in payload["graph"]["nodes"] if node["op"] == "make_loft_rsolid"
]
class TestStdGearSurface(unittest.TestCase):
def test_preferred_nested_std_export_surface(self):
self.assertFalse(hasattr(scad, "std" + "_gear"))
solid = scad.std.gear.make_spur_gear_rsolid(
n_teeth=8, module=1.0, gear_height=2.0,
n_teeth=8,
module=1.0,
gear_height=2.0,
)
self.assertGreater(solid.get_volume(), 0.0)
def test_public_gear_factories_follow_make_rtype_naming(self):
factory_names = [
name for name in scad.std.gear.__all__
name
for name in scad.std.gear.__all__
if callable(getattr(scad.std.gear, name, None))
]
@@ -42,24 +48,37 @@ class TestSpurGear(unittest.TestCase):
def test_basic_spur_gear(self):
solid = scad.std.gear.make_spur_gear_rsolid(
n_teeth=10, module=2.0, pressure_angle=20.0, gear_height=5.0,
n_teeth=10,
module=2.0,
pressure_angle=20.0,
gear_height=5.0,
)
self.assertGreater(solid.get_volume(), 0.0)
def test_default_pressure_angle(self):
solid = scad.std.gear.make_spur_gear_rsolid(
n_teeth=20, module=1.5, gear_height=4.0,
n_teeth=20,
module=1.5,
gear_height=4.0,
)
self.assertGreater(solid.get_volume(), 0.0)
def test_more_teeth_larger_volume(self):
small = scad.std.gear.make_spur_gear_rsolid(n_teeth=10, module=2.0, gear_height=5.0)
large = scad.std.gear.make_spur_gear_rsolid(n_teeth=24, module=2.0, gear_height=5.0)
small = scad.std.gear.make_spur_gear_rsolid(
n_teeth=10, module=2.0, gear_height=5.0
)
large = scad.std.gear.make_spur_gear_rsolid(
n_teeth=24, module=2.0, gear_height=5.0
)
self.assertGreater(large.get_volume(), small.get_volume())
def test_height_scales_volume(self):
short = scad.std.gear.make_spur_gear_rsolid(n_teeth=12, module=2.0, gear_height=4.0)
tall = scad.std.gear.make_spur_gear_rsolid(n_teeth=12, module=2.0, gear_height=8.0)
short = scad.std.gear.make_spur_gear_rsolid(
n_teeth=12, module=2.0, gear_height=4.0
)
tall = scad.std.gear.make_spur_gear_rsolid(
n_teeth=12, module=2.0, gear_height=8.0
)
self.assertAlmostEqual(tall.get_volume(), 2.0 * short.get_volume(), places=0)
def test_invalid_params(self):
@@ -72,12 +91,16 @@ class TestSpurGear(unittest.TestCase):
with self.assertRaises(Exception):
scad.std.gear.make_spur_gear_rsolid(n_teeth=10, module=2.0, backlash=-0.1)
with self.assertRaises(Exception):
scad.std.gear.make_spur_gear_rsolid(n_teeth=10, module=2.0, addendum_factor=0.0)
scad.std.gear.make_spur_gear_rsolid(
n_teeth=10, module=2.0, addendum_factor=0.0
)
def test_tip_radius_bounds(self):
n_teeth = 20
module = 2.0
solid = scad.std.gear.make_spur_gear_rsolid(n_teeth=n_teeth, module=module, gear_height=4.0)
solid = scad.std.gear.make_spur_gear_rsolid(
n_teeth=n_teeth, module=module, gear_height=4.0
)
expected_tip = module * n_teeth / 2.0 + module
max_r = 0.0
for face in solid.get_faces():
@@ -126,7 +149,9 @@ class TestSpurGear(unittest.TestCase):
pressure_angle=math.radians(20.0),
return_sketch=True,
)
fix_constraints = [constraint for constraint in sketch.constraints if constraint.kind == "fix"]
fix_constraints = [
constraint for constraint in sketch.constraints if constraint.kind == "fix"
]
self.assertEqual(len(fix_constraints), 1)
self.assertEqual(fix_constraints[0].targets[0]["entity_id"], "center")
@@ -137,10 +162,16 @@ class TestSpurGear(unittest.TestCase):
pressure_angle = math.radians(20.0)
backlash = 0.12
no_backlash = scad.std.gear._compute_tooth_geometry(
n_teeth, module, pressure_angle, backlash=0.0,
n_teeth,
module,
pressure_angle,
backlash=0.0,
)
with_backlash = scad.std.gear._compute_tooth_geometry(
n_teeth, module, pressure_angle, backlash=backlash,
n_teeth,
module,
pressure_angle,
backlash=backlash,
)
no_backlash_width = no_backlash["right_start"] - no_backlash["left_start"]
@@ -187,9 +218,150 @@ class TestSpurGear(unittest.TestCase):
finally:
scad.std.gear.fit_cubic_bspline_control_points = original
self.assertGreater(len(calls), 0)
self.assertEqual(len(calls), 1)
self.assertTrue(all(call[1]["tolerance"] == 1e-4 for call in calls))
def test_external_gear_reuses_rotated_canonical_involute(self):
n_teeth = 12
_face, sketch = scad.std.gear._build_gear_profile_face(
n_teeth=n_teeth,
module=1.5,
pressure_angle=math.radians(20.0),
return_sketch=True,
)
tooth_angle = 2.0 * math.pi / n_teeth
first = sketch.entities["bspline_left_0"].data["control_points"]
second = sketch.entities["bspline_left_1"].data["control_points"]
for source, actual in zip(first, second):
expected = scad.std.gear._rotate_xy(tuple(source), tooth_angle)
self.assertAlmostEqual(actual[0], expected[0], places=10)
self.assertAlmostEqual(actual[1], expected[1], places=10)
def test_external_gear_builds_only_two_canonical_root_fillets(self):
original = scad.std.gear._root_fillet_control_points
calls = []
def wrapped(*args, **kwargs):
calls.append((args, kwargs))
return original(*args, **kwargs)
scad.std.gear._root_fillet_control_points = wrapped
try:
scad.std.gear._build_gear_profile_face(
n_teeth=18,
module=1.5,
pressure_angle=math.radians(20.0),
)
finally:
scad.std.gear._root_fillet_control_points = original
self.assertEqual(len(calls), 2)
def test_external_gear_builds_sketch_with_bounded_clone_count(self):
from simplecadapi.sketch import Sketch
original = Sketch.clone
calls = []
def wrapped(sketch, *args, **kwargs):
calls.append(sketch)
return original(sketch, *args, **kwargs)
with mock.patch.object(Sketch, "clone", wrapped):
scad.std.gear._build_gear_profile_face(
n_teeth=18,
module=1.5,
pressure_angle=math.radians(20.0),
)
self.assertLessEqual(len(calls), 2)
def test_external_gear_linear_sketch_edits_strict_replay(self):
with scad.GraphSession() as session:
original = scad.std.gear.make_spur_gear_rsolid(
n_teeth=12,
module=2.0,
gear_height=4.0,
)
replayed = scad.replay_model_json(
json_str=scad.export_model_json(session=session), strict=True
)[0]
self.assertAlmostEqual(replayed.get_volume(), original.get_volume(), places=6)
class TestStraightBevelGear(unittest.TestCase):
def setUp(self):
scad.GraphSession()
def test_basic_straight_bevel_gear(self):
solid = scad.std.gear.make_straight_bevel_gear_rsolid(
n_teeth=18,
module=4.0,
pitch_angle=45.0,
pressure_angle=20.0,
face_width=18.0,
)
self.assertGreater(solid.get_volume(), 0.0)
meta = solid.get_metadata("std.gear.straight_bevel")
self.assertEqual(meta["n_teeth"], 18)
self.assertEqual(meta["module"], 4.0)
self.assertEqual(meta["pitch_angle"], 45.0)
self.assertAlmostEqual(meta["outer_pitch_radius"], 36.0)
self.assertAlmostEqual(
meta["outer_cone_distance"],
36.0 / math.sin(math.radians(45.0)),
)
print(
"straight_bevel_gear: "
f"volume={solid.get_volume():.3f} "
f"inner_scale={meta['inner_scale']:.6f}"
)
def test_straight_bevel_gear_uses_ruled_similar_sections(self):
loft_nodes = _loft_nodes_for(
lambda: scad.std.gear.make_straight_bevel_gear_rsolid(
n_teeth=18,
module=4.0,
pitch_angle=45.0,
face_width=18.0,
)
)
self.assertEqual(len(loft_nodes), 1)
self.assertEqual(loft_nodes[0]["params"]["profile_count"], 2)
self.assertTrue(loft_nodes[0]["params"]["ruled"])
def test_straight_bevel_gear_strict_replay(self):
with scad.GraphSession() as session:
original = scad.std.gear.make_straight_bevel_gear_rsolid(
n_teeth=18,
module=4.0,
pitch_angle=45.0,
face_width=18.0,
)
model_json = scad.export_model_json(session=session)
replayed = scad.replay_model_json(json_str=model_json, strict=True)
self.assertEqual(len(replayed), 1)
self.assertAlmostEqual(
replayed[0].get_volume(), original.get_volume(), places=5
)
def test_invalid_straight_bevel_gear_params(self):
invalid_kwargs = (
{"n_teeth": 2, "module": 4.0},
{"n_teeth": 18, "module": 0.0},
{"n_teeth": 18, "module": 4.0, "pitch_angle": 0.0},
{"n_teeth": 18, "module": 4.0, "pitch_angle": 90.0},
{"n_teeth": 18, "module": 4.0, "face_width": 60.0},
)
for kwargs in invalid_kwargs:
with self.subTest(kwargs=kwargs), self.assertRaises(ValueError):
scad.std.gear.make_straight_bevel_gear_rsolid(**kwargs)
class TestHelicalGear(unittest.TestCase):
def setUp(self):
@@ -197,14 +369,22 @@ class TestHelicalGear(unittest.TestCase):
def test_basic_helical_gear(self):
solid = scad.std.gear.make_helical_gear_rsolid(
n_teeth=12, module=2.0, helix_angle=25.0, gear_height=8.0,
n_teeth=12,
module=2.0,
helix_angle=25.0,
gear_height=8.0,
)
self.assertGreater(solid.get_volume(), 0.0)
def test_zero_helix_falls_back_to_spur(self):
spur = scad.std.gear.make_spur_gear_rsolid(n_teeth=12, module=2.0, gear_height=6.0)
spur = scad.std.gear.make_spur_gear_rsolid(
n_teeth=12, module=2.0, gear_height=6.0
)
helical = scad.std.gear.make_helical_gear_rsolid(
n_teeth=12, module=2.0, gear_height=6.0, helix_angle=0.0,
n_teeth=12,
module=2.0,
gear_height=6.0,
helix_angle=0.0,
)
self.assertAlmostEqual(spur.get_volume(), helical.get_volume(), places=0)
@@ -212,19 +392,33 @@ class TestHelicalGear(unittest.TestCase):
with self.assertRaises(Exception):
scad.std.gear.make_helical_gear_rsolid(n_teeth=2, module=2.0)
def test_helical_gear_uses_small_step_ruled_loft(self):
loft_nodes = _loft_nodes_for(
lambda: scad.std.gear.make_helical_gear_rsolid(
def test_helical_gear_uses_one_continuous_twisted_sweep(self):
with scad.GraphSession() as session:
solid = scad.std.gear.make_helical_gear_rsolid(
n_teeth=12,
module=2.0,
helix_angle=25.0,
gear_height=8.0,
)
)
self.assertEqual(len(loft_nodes), 1)
self.assertEqual(loft_nodes[0]["params"]["profile_count"], 7)
self.assertTrue(loft_nodes[0]["params"]["ruled"])
payload = json.loads(scad.export_model_json(session=session))
twisted_nodes = [
node
for node in payload["graph"]["nodes"]
if node["op"] == "make_twisted_sweep_rsolid"
]
self.assertEqual(len(twisted_nodes), 1)
self.assertNotIn("topo_delta", twisted_nodes[0])
self.assertFalse(
any(node["op"] == "make_loft_rsolid" for node in payload["graph"]["nodes"])
)
self.assertEqual(len(solid.get_faces()), 74)
replayed = scad.replay_model_json(
json_str=json.dumps(payload), strict=True
)[0]
self.assertEqual(len(replayed.get_faces()), 74)
self.assertAlmostEqual(replayed.get_volume(), solid.get_volume(), places=6)
class TestHerringboneGear(unittest.TestCase):
@@ -233,30 +427,84 @@ class TestHerringboneGear(unittest.TestCase):
def test_basic_herringbone_gear(self):
solid = scad.std.gear.make_herringbone_gear_rsolid(
n_teeth=12, module=2.0, helix_angle=25.0, gear_height=10.0,
n_teeth=12,
module=2.0,
helix_angle=25.0,
gear_height=10.0,
)
self.assertGreater(solid.get_volume(), 0.0)
def test_zero_helix_falls_back_to_spur(self):
spur = scad.std.gear.make_spur_gear_rsolid(n_teeth=12, module=2.0, gear_height=8.0)
spur = scad.std.gear.make_spur_gear_rsolid(
n_teeth=12, module=2.0, gear_height=8.0
)
herringbone = scad.std.gear.make_herringbone_gear_rsolid(
n_teeth=12, module=2.0, gear_height=8.0, helix_angle=0.0,
n_teeth=12,
module=2.0,
gear_height=8.0,
helix_angle=0.0,
)
self.assertAlmostEqual(spur.get_volume(), herringbone.get_volume(), places=0)
def test_herringbone_gear_uses_small_step_ruled_loft(self):
loft_nodes = _loft_nodes_for(
lambda: scad.std.gear.make_herringbone_gear_rsolid(
def test_herringbone_gear_unions_two_replayable_twisted_halves(self):
with scad.GraphSession() as session:
solid = scad.std.gear.make_herringbone_gear_rsolid(
n_teeth=12,
module=2.0,
helix_angle=25.0,
gear_height=10.0,
)
)
self.assertEqual(len(loft_nodes), 1)
self.assertEqual(loft_nodes[0]["params"]["profile_count"], 9)
self.assertTrue(loft_nodes[0]["params"]["ruled"])
payload = json.loads(scad.export_model_json(session=session))
operations = [node["op"] for node in payload["graph"]["nodes"]]
self.assertEqual(operations.count("make_twisted_sweep_rsolid"), 2)
self.assertEqual(operations.count("make_union_rsolid"), 1)
self.assertNotIn("make_loft_rsolid", operations)
self.assertTrue(
all(
"topo_delta" not in node
for node in payload["graph"]["nodes"]
if node["op"] == "make_twisted_sweep_rsolid"
)
)
self.assertEqual(len(solid.get_faces()), 146)
replayed = scad.replay_model_json(
json_str=json.dumps(payload), strict=True
)[0]
self.assertEqual(len(replayed.get_faces()), 146)
self.assertAlmostEqual(replayed.get_volume(), solid.get_volume(), places=6)
def test_stdlib_graph_tracking_scope_does_not_leak(self):
with scad.GraphSession() as session:
scad.std.gear.make_herringbone_gear_rsolid(
n_teeth=8,
module=1.0,
helix_angle=20.0,
gear_height=4.0,
)
profile = scad.make_rectangle_rface(
width=1.0,
height=1.0,
center=(0.0, 0.0, 0.0),
)
scad.twisted_sweep_rsolid(
profile=profile,
distance=2.0,
twist_angle=15.0,
)
payload = json.loads(scad.export_model_json(session=session))
twisted_nodes = [
node
for node in payload["graph"]["nodes"]
if node["op"] == "make_twisted_sweep_rsolid"
]
self.assertEqual(len(twisted_nodes), 3)
self.assertEqual(
sum("topo_delta" in node for node in twisted_nodes),
1,
)
class TestSpurRingGear(unittest.TestCase):
@@ -265,7 +513,10 @@ class TestSpurRingGear(unittest.TestCase):
def test_basic_ring_gear(self):
solid = scad.std.gear.make_spur_ring_gear_rsolid(
n_teeth=20, module=2.0, gear_height=5.0, rim_thickness=4.0,
n_teeth=20,
module=2.0,
gear_height=5.0,
rim_thickness=4.0,
)
self.assertGreater(solid.get_volume(), 0.0)
@@ -274,7 +525,10 @@ class TestSpurRingGear(unittest.TestCase):
module = 2.0
rim_thickness = 4.0
ring = scad.std.gear.make_spur_ring_gear_rsolid(
n_teeth=n_teeth, module=module, gear_height=5.0, rim_thickness=rim_thickness,
n_teeth=n_teeth,
module=module,
gear_height=5.0,
rim_thickness=rim_thickness,
)
pitch_radius = module * n_teeth / 2.0
outer_r = pitch_radius + 1.25 * module + rim_thickness
@@ -302,13 +556,18 @@ class TestSpurRingGear(unittest.TestCase):
pitch_radius = module * n_teeth / 2.0
base_radius = pitch_radius * math.cos(pressure_angle)
self.assertAlmostEqual(min(vertex_radii), pitch_radius - module, places=5)
self.assertAlmostEqual(max(vertex_radii), pitch_radius + 1.25 * module, places=5)
self.assertAlmostEqual(
max(vertex_radii), pitch_radius + 1.25 * module, places=5
)
self.assertGreater(min(vertex_radii), base_radius + 0.5 * module)
def test_spur_ring_gear_uses_direct_multi_loop_face_not_2d_cut(self):
with scad.GraphSession() as session:
scad.std.gear.make_spur_ring_gear_rsolid(
n_teeth=20, module=2.0, gear_height=5.0, rim_thickness=4.0,
n_teeth=20,
module=2.0,
gear_height=5.0,
rim_thickness=4.0,
)
payload = json.loads(scad.export_model_json(session))
@@ -337,7 +596,9 @@ class TestSpurRingGear(unittest.TestCase):
pressure_angle=math.radians(20.0),
return_sketch=True,
)
fix_constraints = [constraint for constraint in sketch.constraints if constraint.kind == "fix"]
fix_constraints = [
constraint for constraint in sketch.constraints if constraint.kind == "fix"
]
self.assertEqual(len(fix_constraints), 1)
self.assertEqual(fix_constraints[0].targets[0]["entity_id"], "center")
@@ -348,10 +609,16 @@ class TestSpurRingGear(unittest.TestCase):
pressure_angle = math.radians(20.0)
backlash = 0.12
no_backlash = scad.std.gear._compute_internal_tooth_geometry(
n_teeth, module, pressure_angle, backlash=0.0,
n_teeth,
module,
pressure_angle,
backlash=0.0,
)
with_backlash = scad.std.gear._compute_internal_tooth_geometry(
n_teeth, module, pressure_angle, backlash=backlash,
n_teeth,
module,
pressure_angle,
backlash=backlash,
)
no_backlash_space = no_backlash["tooth_angle"] - (
@@ -387,9 +654,13 @@ class TestSpurRingGear(unittest.TestCase):
with self.assertRaises(Exception):
scad.std.gear.make_spur_ring_gear_rsolid(n_teeth=2, module=2.0)
with self.assertRaises(Exception):
scad.std.gear.make_spur_ring_gear_rsolid(n_teeth=10, module=2.0, rim_thickness=0.0)
scad.std.gear.make_spur_ring_gear_rsolid(
n_teeth=10, module=2.0, rim_thickness=0.0
)
with self.assertRaises(Exception):
scad.std.gear.make_spur_ring_gear_rsolid(n_teeth=10, module=2.0, backlash=-0.1)
scad.std.gear.make_spur_ring_gear_rsolid(
n_teeth=10, module=2.0, backlash=-0.1
)
class TestHelicalRingGear(unittest.TestCase):
@@ -398,7 +669,10 @@ class TestHelicalRingGear(unittest.TestCase):
def test_basic_helical_ring(self):
solid = scad.std.gear.make_helical_ring_gear_rsolid(
n_teeth=20, module=2.0, helix_angle=20.0, gear_height=8.0,
n_teeth=20,
module=2.0,
helix_angle=20.0,
gear_height=8.0,
)
self.assertGreater(solid.get_volume(), 0.0)
@@ -423,23 +697,41 @@ class TestHerringboneRingGear(unittest.TestCase):
def test_basic_herringbone_ring(self):
solid = scad.std.gear.make_herringbone_ring_gear_rsolid(
n_teeth=20, module=2.0, helix_angle=20.0, gear_height=10.0,
n_teeth=20,
module=2.0,
helix_angle=20.0,
gear_height=10.0,
)
self.assertGreater(solid.get_volume(), 0.0)
def test_herringbone_ring_uses_small_step_ruled_inner_loft(self):
loft_nodes = _loft_nodes_for(
lambda: scad.std.gear.make_herringbone_ring_gear_rsolid(
with scad.GraphSession() as session:
scad.std.gear.make_herringbone_ring_gear_rsolid(
n_teeth=20,
module=2.0,
helix_angle=20.0,
gear_height=10.0,
)
)
payload = json.loads(scad.export_model_json(session=session))
loft_nodes = [
node
for node in payload["graph"]["nodes"]
if node["op"] == "make_loft_rsolid"
]
cut_nodes = [
node
for node in payload["graph"]["nodes"]
if node["op"] == "make_cut_rsolid"
]
self.assertEqual(len(loft_nodes), 1)
self.assertEqual(loft_nodes[0]["params"]["profile_count"], 9)
self.assertTrue(loft_nodes[0]["params"]["ruled"])
self.assertEqual(loft_nodes[0]["params"]["tracking_policy"], "graph")
self.assertEqual(len(cut_nodes), 1)
self.assertEqual(cut_nodes[0]["params"]["tracking_policy"], "graph")
self.assertNotIn("topo_delta", cut_nodes[0])
class TestCycloidalDisc(unittest.TestCase):
@@ -580,12 +872,18 @@ class TestSpurRack(unittest.TestCase):
scad.GraphSession()
def test_basic_rack(self):
solid = scad.std.gear.make_spur_rack_rsolid(module=2.0, n_teeth=8, rack_height=5.0)
solid = scad.std.gear.make_spur_rack_rsolid(
module=2.0, n_teeth=8, rack_height=5.0
)
self.assertGreater(solid.get_volume(), 0.0)
def test_more_teeth_larger_volume(self):
short = scad.std.gear.make_spur_rack_rsolid(module=2.0, n_teeth=5, rack_height=5.0)
long = scad.std.gear.make_spur_rack_rsolid(module=2.0, n_teeth=10, rack_height=5.0)
short = scad.std.gear.make_spur_rack_rsolid(
module=2.0, n_teeth=5, rack_height=5.0
)
long = scad.std.gear.make_spur_rack_rsolid(
module=2.0, n_teeth=10, rack_height=5.0
)
self.assertGreater(long.get_volume(), short.get_volume())
def test_invalid_params(self):
@@ -609,7 +907,10 @@ class TestHelicalRack(unittest.TestCase):
def test_basic_helical_rack(self):
solid = scad.std.gear.make_helical_rack_rsolid(
module=2.0, n_teeth=8, helix_angle=25.0, rack_height=8.0,
module=2.0,
n_teeth=8,
helix_angle=25.0,
rack_height=8.0,
)
self.assertGreater(solid.get_volume(), 0.0)
@@ -620,7 +921,10 @@ class TestHerringboneRack(unittest.TestCase):
def test_basic_herringbone_rack(self):
solid = scad.std.gear.make_herringbone_rack_rsolid(
module=2.0, n_teeth=8, helix_angle=30.0, rack_height=10.0,
module=2.0,
n_teeth=8,
helix_angle=30.0,
rack_height=10.0,
)
self.assertGreater(solid.get_volume(), 0.0)
@@ -634,7 +938,9 @@ class Test2DFaceBoolean(unittest.TestCase):
inner = scad.make_circle_rface(center=(0, 0, 0), radius=4.0)
ring = scad.make_2d_cut_rface(outer, inner)
self.assertAlmostEqual(
ring.get_area(), math.pi * (100 - 16), places=1,
ring.get_area(),
math.pi * (100 - 16),
places=1,
)
self.assertEqual(len(ring.get_inner_wires()), 1)
@@ -643,7 +949,9 @@ class Test2DFaceBoolean(unittest.TestCase):
inner = scad.make_circle_rwire(center=(0, 0, 0), radius=4.0)
ring = scad.make_face_from_wires_rface(outer, [inner])
self.assertAlmostEqual(
ring.get_area(), math.pi * (100 - 16), places=1,
ring.get_area(),
math.pi * (100 - 16),
places=1,
)
self.assertEqual(len(ring.get_inner_wires()), 1)
+265 -16
View File
@@ -1,7 +1,8 @@
import unittest
from unittest import mock
import simplecadapi as scad
from simplecadapi import tagging
from simplecadapi import operations, tagging
class TestTaggingRefactor(unittest.TestCase):
@@ -11,25 +12,215 @@ class TestTaggingRefactor(unittest.TestCase):
self.assertFalse(tagging.is_normalized_tag("Face.Top"))
self.assertFalse(tagging.is_normalized_tag("size: 2x3x4"))
def test_tag_policy_propagation(self):
policy = tagging.DEFAULT_TAG_POLICY
self.assertTrue(policy.should_propagate("role.mounting_surface"))
self.assertTrue(policy.should_propagate("anchor.datum.primary"))
self.assertTrue(policy.should_propagate("group.fasteners"))
self.assertFalse(policy.should_propagate("feature.extrude.start_face"))
self.assertFalse(policy.should_propagate("state.debug"))
self.assertFalse(policy.should_propagate("face.top"))
self.assertFalse(policy.should_propagate("legacy.top"))
def test_apply_tag_propagates_role(self):
def test_apply_tag_does_not_infer_propagation_from_prefix(self):
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
scad.apply_tag(box, "role.mounting_surface")
faces = box.get_faces()
self.assertTrue(any("role.mounting_surface" in scad.list_tags(face) for face in faces))
self.assertIn("role.mounting_surface", box._list_tags("local"))
self.assertFalse(
any(
"role.mounting_surface" in face._list_tags("effective")
for face in box.get_faces()
)
)
edges = box.get_edges()
self.assertTrue(any("role.mounting_surface" in scad.list_tags(edge) for edge in edges))
def test_explicit_downward_propagation_is_computed_not_copied(self):
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
binding = box._apply_user_tag(
"role.mounting_surface", topology_propagation="downward"
)
face = box.get_faces(0)
edge = face.get_edges(0)
self.assertEqual(
face._list_tags("local"), ["face.box.back", "face.left"]
)
self.assertNotIn("role.mounting_surface", face._list_tags("local"))
self.assertIn("role.mounting_surface", face._list_tags("inherited"))
self.assertIn("role.mounting_surface", edge._list_tags("inherited"))
self.assertNotIn(binding, face._tag_bindings)
self.assertIn("role.mounting_surface", face._tags)
def test_effective_excludes_lineage(self):
source = scad.make_point_rvertex(0.0, 0.0, 0.0)
result = scad.make_point_rvertex(1.0, 0.0, 0.0)
binding = source._apply_user_tag("role.datum")
result._add_tag_lineage(
binding,
derivation="continuation",
source_topo_id=source.topo_id,
evidence=tagging.TagEvidence(
"topology_change", {"change_ids": ["change_1"]}
),
)
result._set_runtime("semantic.lineage.coverage", "complete")
self.assertNotIn("role.datum", result._list_tags("effective"))
self.assertEqual(result._list_tags("lineage"), ["role.datum"])
def test_lineage_requires_complete_history(self):
vertex = scad.make_point_rvertex(0.0, 0.0, 0.0)
with self.assertRaises(tagging.UnsupportedQueryCapabilityError):
vertex._list_tags("lineage")
vertex._set_runtime("semantic.lineage.coverage", "partial")
with self.assertRaises(tagging.UnsupportedQueryCapabilityError):
vertex._list_tags("lineage")
def test_default_lineage_policy_rejects_boundary(self):
source = scad.make_point_rvertex(0.0, 0.0, 0.0)
result = scad.make_point_rvertex(1.0, 0.0, 0.0)
binding = source._apply_user_tag("role.datum")
result._add_tag_lineage(
binding,
derivation="boundary",
source_topo_id=source.topo_id,
evidence=tagging.TagEvidence("topology_change"),
)
result._set_runtime("semantic.lineage.coverage", "complete")
self.assertEqual(result._list_tags("lineage"), [])
def test_lineage_continues_across_multiple_proven_hops(self):
source = scad.make_point_rvertex(0.0, 0.0, 0.0)
scad.apply_tag(source, "role.datum")
first = scad.translate_shape(source, (1.0, 0.0, 0.0))
second = scad.translate_shape(first, (1.0, 0.0, 0.0))
self.assertNotIn("role.datum", scad.list_tags(second, "effective"))
self.assertEqual(scad.list_tags(second, "lineage"), ["role.datum"])
def test_disallowed_lineage_does_not_seed_later_continuation(self):
source = scad.make_point_rvertex(0.0, 0.0, 0.0)
intermediate = scad.make_point_rvertex(1.0, 0.0, 0.0)
binding = source._apply_user_tag("role.datum")
intermediate._add_tag_lineage(
binding,
derivation="boundary",
source_topo_id=source.topo_id,
evidence=tagging.TagEvidence("topology_change"),
)
intermediate._set_runtime("semantic.lineage.coverage", "complete")
result = scad.translate_shape(intermediate, (1.0, 0.0, 0.0))
self.assertEqual(scad.list_tags(result, "lineage"), [])
def test_lineage_explanation_filters_disallowed_witnesses(self):
source = scad.make_point_rvertex(0.0, 0.0, 0.0)
result = scad.make_point_rvertex(1.0, 0.0, 0.0)
binding = source._apply_user_tag("role.datum")
result._add_tag_lineage(
binding,
derivation="boundary",
source_topo_id=source.topo_id,
evidence=tagging.TagEvidence("topology_change"),
)
result._set_runtime("semantic.lineage.coverage", "complete")
self.assertEqual(scad.explain_tag(result, "role.datum", "lineage"), [])
def test_lineage_witness_roundtrip_preserves_source_binding(self):
binding = tagging.user_tag_binding("role.datum", node_id="node_source")
witness = tagging.TagLineageWitness(
binding=binding,
derivation="fragment",
source_topo_id="face_source",
target_topo_id="face_target",
evidence=tagging.TagEvidence(
"topology_change", {"change_ids": ["change_1"]}
),
)
self.assertEqual(
tagging.TagLineageWitness.from_dict(witness.to_dict()), witness
)
def test_binding_schema_roundtrip_is_strict(self):
binding = tagging.TagBinding(
binding_id="tag_binding_mounting_surface",
tag="role.mounting_surface",
producer=tagging.TagProducer("user_operation", node_id="node_tag"),
scope=tagging.TagBindingScope("node_body", 0),
target=tagging.TagTarget(
"selection_query",
query_hash="sha256:query",
binding_hash="sha256:binding",
),
propagation=tagging.TagPropagation(
topology="local", lineage="continuation_fragment"
),
evidence=tagging.TagEvidence(
"query_execution",
{
"execution_hash": "sha256:execution",
"selected_refs": [],
},
),
)
payload = binding.to_dict()
self.assertEqual(payload["schema_version"], "1.0")
self.assertEqual(tagging.TagBinding.from_dict(payload), binding)
malformed = dict(payload)
malformed["unknown"] = True
with self.assertRaises(tagging.TagValidationError):
tagging.TagBinding.from_dict(malformed)
missing_scope = dict(payload)
missing_scope.pop("scope")
with self.assertRaises(tagging.TagValidationError):
tagging.TagBinding.from_dict(missing_scope)
def test_same_token_dedupes_but_explanation_preserves_producers(self):
vertex = scad.make_point_rvertex(0.0, 0.0, 0.0)
user_binding = tagging.user_tag_binding(
"role.datum", node_id="node_user"
)
auto_binding = tagging.TagBinding(
tag="role.datum",
producer=tagging.TagProducer(
"auto_rule",
rule_id="simplecad.test.datum",
rule_version="1.0",
),
evidence=tagging.TagEvidence("geometry_classification"),
certainty="proven",
lifecycle="recompute",
)
vertex._add_tag_binding(user_binding)
vertex._add_tag_binding(auto_binding)
self.assertEqual(vertex._list_tags("local"), ["role.datum"])
explanations = vertex._explain_tag("role.datum", "local")
self.assertEqual(len(explanations), 2)
self.assertEqual(
{item["producer"]["kind"] for item in explanations},
{"user_operation", "auto_rule"},
)
self.assertEqual(vertex._remove_tag("role.datum"), 1)
self.assertEqual(vertex._list_tags("local"), ["role.datum"])
self.assertEqual(
vertex._explain_tag("role.datum", "local")[0]["producer"]["kind"],
"auto_rule",
)
def test_internal_and_legacy_bindings_are_not_user_assertions(self):
vertex = scad.make_point_rvertex(0.0, 0.0, 0.0)
vertex._add_tag("internal.marker")
internal = vertex._explain_tag("internal.marker", "local")[0]
self.assertEqual(internal["producer"]["kind"], "auto_rule")
vertex._tags = {"legacy.marker"}
legacy = vertex._explain_tag("legacy.marker", "effective")[0]
self.assertEqual(legacy["producer"]["kind"], "legacy_import")
self.assertEqual(legacy["attachment"], "effective_legacy")
with self.assertRaises(tagging.UnsupportedQueryCapabilityError):
vertex._list_tags("local")
def test_tagging_public_surface_is_functional_and_sorted(self):
vertex = scad.make_point_rvertex(0.0, 0.0, 0.0)
@@ -42,6 +233,64 @@ class TestTaggingRefactor(unittest.TestCase):
self.assertFalse(hasattr(vertex, member_name))
self.assertFalse(hasattr(scad, "set_tag"))
def test_apply_tag_mutates_without_cloning_semantic_topology(self):
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
with mock.patch.object(
operations,
"clone_semantic_shape_view",
wraps=operations.clone_semantic_shape_view,
) as clone:
tagged = scad.apply_tag(box, "role.structure")
self.assertIs(tagged, box)
self.assertEqual(clone.call_count, 0)
self.assertIn("role.structure", scad.list_tags(box, scope="local"))
def test_apply_tag_rselection_keeps_independent_clone(self):
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
with mock.patch.object(
operations,
"clone_semantic_shape_view",
wraps=operations.clone_semantic_shape_view,
) as clone:
tagged = scad.apply_tag_rselection(
box,
scad.ql.solids().exactly(1),
"role.structure",
)
self.assertIsNot(tagged, box)
self.assertEqual(clone.call_count, 1)
self.assertNotIn("role.structure", scad.list_tags(box, scope="local"))
self.assertIn("role.structure", scad.list_tags(tagged, scope="local"))
def test_apply_tag_rselection_and_explain_tag_public_surface(self):
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
source_face = box.get_faces(0)
tagged = scad.apply_tag_rselection(
box,
[source_face],
"role.mounting_surface",
topology_propagation=scad.TopologyPropagation.LOCAL,
lineage_policy=scad.LineagePolicy.CONTINUATION_FRAGMENT,
)
tagged_face = next(
face for face in tagged.get_faces() if face.topo_id == source_face.topo_id
)
self.assertIn(
"role.mounting_surface",
scad.list_tags(tagged_face, scad.TagScope.LOCAL),
)
explanation = scad.explain_tag(
tagged_face, "role.mounting_surface", scad.TagScope.LOCAL
)
self.assertEqual(len(explanation), 1)
self.assertEqual(explanation[0]["producer"]["kind"], "user_operation")
def test_anchor_resolution_candidate_priority(self):
candidates = tagging.resolve_anchor_tag_candidates("mounting_surface")
+722
View File
@@ -0,0 +1,722 @@
"""Complete coverage for expression-driven dimension tolerance chains."""
from __future__ import annotations
import json
import math
import unittest
import simplecadapi as scad
class TestDimensionToleranceDeclaration(unittest.TestCase):
def test_symmetric_and_asymmetric_tolerances(self):
symmetric = scad.var("width", 10.0, tolerance=0.2)
asymmetric = scad.var("shaft", 8.0, tolerance=(-0.05, 0.0))
self.assertEqual(symmetric.tolerance.lower_deviation, -0.2)
self.assertEqual(symmetric.tolerance.upper_deviation, 0.2)
self.assertEqual(asymmetric.tolerance.lower_deviation, -0.05)
self.assertEqual(asymmetric.tolerance.upper_deviation, 0.0)
def test_dimension_tolerance_roundtrip(self):
tolerance = scad.DimensionTolerance(-0.1, 0.3)
self.assertEqual(
scad.DimensionTolerance.from_dict(tolerance.to_dict()), tolerance
)
self.assertAlmostEqual(tolerance.width, 0.4)
def test_invalid_variable_and_tolerance_values_are_rejected(self):
invalid_calls = [
lambda: scad.var("x", True),
lambda: scad.var("x", math.inf),
lambda: scad.var("x", 1.0, tolerance=True),
lambda: scad.var("x", 1.0, tolerance=-0.1),
lambda: scad.var("x", 1.0, tolerance=(0.1, 0.2)),
lambda: scad.var("x", 1.0, tolerance=(-0.2, -0.1)),
lambda: scad.var("x", 1.0, tolerance=(-0.1, 0.1, 0.2)),
lambda: scad.DimensionTolerance(float("nan"), 0.1),
]
for call in invalid_calls:
with self.subTest(call=call), self.assertRaises((TypeError, ValueError)):
call()
def test_expression_graph_preserves_variable_tolerances_and_forward_refs(self):
width = scad.var("width", 10.0, tolerance=(-0.1, 0.2))
graph = scad.ExpressionGraph()
expression = width * 2.0
graph.register(expression)
payload = graph.to_dict()
payload["nodes"].reverse()
rebuilt = scad.ExpressionGraph.from_dict(payload)
rebuilt_width = rebuilt.get(width.expr_id)
self.assertIsInstance(rebuilt_width, scad.Var)
self.assertEqual(rebuilt_width.tolerance, width.tolerance)
self.assertAlmostEqual(rebuilt.get(expression.expr_id).evaluate(), 20.0)
def test_var_positional_expr_id_remains_compatible(self):
variable = scad.Var("width", 10.0, "plate width", "var_width")
self.assertEqual(variable.expr_id, "var_width")
self.assertIsNone(variable.tolerance)
def test_malformed_expression_graphs_are_rejected(self):
valid_var = {
"expr_id": "var_x",
"kind": "var",
"name": "x",
"default": 1.0,
"tolerance": {"lower_deviation": -0.1, "upper_deviation": 0.1},
}
payloads = [
{"nodes": [valid_var, dict(valid_var)]},
{
"nodes": [
{
"expr_id": "expr_a",
"kind": "expr",
"op": "neg",
"args": ["missing"],
}
]
},
{
"nodes": [
{
"expr_id": "expr_a",
"kind": "expr",
"op": "neg",
"args": ["expr_b"],
},
{
"expr_id": "expr_b",
"kind": "expr",
"op": "neg",
"args": ["expr_a"],
},
]
},
{
"nodes": [
valid_var,
{
"expr_id": "expr_bad",
"kind": "expr",
"op": "add",
"args": ["var_x"],
},
]
},
]
for payload in payloads:
with self.subTest(payload=payload), self.assertRaises(ValueError):
scad.ExpressionGraph.from_dict(payload)
def test_expression_registration_is_atomic_for_conflicting_ids(self):
left = scad.Var(
"left",
1.0,
expr_id="var_duplicate",
tolerance=scad.DimensionTolerance.symmetric(0.1),
)
right = scad.Var(
"right",
2.0,
expr_id="var_duplicate",
tolerance=scad.DimensionTolerance.symmetric(0.1),
)
expression = scad.Expr("add", (left, right))
graph = scad.ExpressionGraph()
with self.assertRaisesRegex(ValueError, "already registered"):
graph.register(expression)
self.assertEqual(graph.node_count, 0)
def test_expression_registration_rejects_cycles_without_mutation(self):
expression = scad.Expr("neg", (scad.const(1.0),), expr_id="expr_cycle")
object.__setattr__(expression, "args", (expression,))
graph = scad.ExpressionGraph()
with self.assertRaisesRegex(ValueError, "cycle"):
graph.register(expression)
self.assertEqual(graph.node_count, 0)
def test_expression_arguments_are_normalized_to_an_immutable_tuple(self):
arguments = [scad.const(1.0)]
expression = scad.Expr("neg", arguments)
arguments[0] = expression
self.assertIsInstance(expression.args, tuple)
self.assertIsNot(expression.args[0], expression)
def test_signed_zero_nodes_with_the_same_id_are_distinct(self):
positive = scad.Const(0.0, expr_id="const_zero")
negative = scad.Const(-0.0, expr_id="const_zero")
graph = scad.ExpressionGraph()
graph.register(positive)
with self.assertRaisesRegex(ValueError, "different node"):
graph.register(negative)
def test_expression_payload_missing_required_fields_is_rejected_cleanly(self):
payloads = [
{"nodes": [{"expr_id": "const_x", "kind": "const"}]},
{
"nodes": [
{"expr_id": "var_x", "kind": "var", "default": 1.0}
]
},
{
"nodes": [
{"expr_id": "var_x", "kind": "var", "name": "x"}
]
},
{
"nodes": [
{"expr_id": "expr_x", "kind": "expr", "args": []}
]
},
]
for payload in payloads:
with self.subTest(payload=payload), self.assertRaises(ValueError):
scad.ExpressionGraph.from_dict(payload)
def test_huge_numeric_inputs_are_rejected_as_non_finite(self):
with self.assertRaisesRegex(ValueError, "finite"):
scad.const(10**10000)
class TestWorstCaseTolerancePropagation(unittest.TestCase):
def test_addition_and_subtraction_preserve_asymmetric_deviations(self):
a = scad.var("a", 10.0, tolerance=(-0.1, 0.2))
b = scad.var("b", 5.0, tolerance=(-0.3, 0.4))
result = scad.analyze_tolerance(a - b)
self.assertAlmostEqual(result.nominal, 5.0)
self.assertAlmostEqual(result.lower_deviation, -0.5)
self.assertAlmostEqual(result.upper_deviation, 0.5)
self.assertEqual(len(result.contributions), 2)
def test_linear_scaling_and_repeated_variable_dependency_are_exact(self):
x = scad.var("x", 4.0, tolerance=(-0.2, 0.3))
scaled = scad.analyze_tolerance(-2.0 * x + 1.0)
cancelled = scad.analyze_tolerance(x - x)
self.assertAlmostEqual(scaled.lower_deviation, -0.6)
self.assertAlmostEqual(scaled.upper_deviation, 0.4)
self.assertAlmostEqual(cancelled.lower_deviation, 0.0)
self.assertAlmostEqual(cancelled.upper_deviation, 0.0)
def test_multiplication_handles_all_interval_corner_signs(self):
a = scad.var("a", -1.0, tolerance=(-1.0, 0.5))
b = scad.var("b", 3.0, tolerance=(-1.0, 1.0))
result = scad.analyze_tolerance(a * b)
self.assertAlmostEqual(result.nominal, -3.0)
self.assertAlmostEqual(result.lower_bound, -8.0)
self.assertAlmostEqual(result.upper_bound, -1.0)
def test_division_rejects_denominator_interval_containing_zero(self):
numerator = scad.var("numerator", 2.0, tolerance=0.1)
denominator = scad.var("denominator", 1.0, tolerance=1.0)
with self.assertRaisesRegex(
scad.ToleranceAnalysisError, "denominator.*contains zero"
):
scad.analyze_tolerance(numerator / denominator)
def test_integer_and_fractional_power_intervals(self):
signed = scad.var("signed", 0.0, tolerance=2.0)
positive = scad.var("positive", 4.0, tolerance=(-3.0, 5.0))
squared = scad.analyze_tolerance(signed**2)
rooted = scad.analyze_tolerance(scad.sqrt(positive))
self.assertLessEqual(squared.lower_bound, 0.0)
self.assertGreaterEqual(squared.upper_bound, 4.0)
self.assertAlmostEqual(squared.upper_bound, 4.0)
self.assertLessEqual(rooted.lower_bound, 1.0)
self.assertGreaterEqual(rooted.upper_bound, 3.0)
self.assertAlmostEqual(rooted.lower_bound, 1.0)
self.assertAlmostEqual(rooted.upper_bound, 3.0)
def test_power_domain_errors_are_explicit(self):
negative = scad.var("negative", -2.0, tolerance=0.1)
crosses_zero = scad.var("crosses_zero", 0.0, tolerance=0.1)
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "negative base"):
scad.analyze_tolerance(negative**0.5)
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "zero"):
scad.analyze_tolerance(crosses_zero ** -1)
def test_invalid_constant_subexpressions_use_analysis_errors(self):
expression = scad.sqrt(-1.0)
with self.assertRaisesRegex(
scad.ToleranceAnalysisError, "declared tolerance interval"
):
scad.analyze_tolerance(expression)
def test_trigonometric_extrema_and_discontinuities(self):
angle = scad.var("angle", 0.0, tolerance=(-math.pi, math.pi))
tangent_angle = scad.var(
"tangent_angle", math.pi / 2.0, tolerance=0.1
)
sine = scad.analyze_tolerance(scad.sin(angle))
cosine = scad.analyze_tolerance(scad.cos(angle))
self.assertEqual((sine.lower_bound, sine.upper_bound), (-1.0, 1.0))
self.assertEqual((cosine.lower_bound, cosine.upper_bound), (-1.0, 1.0))
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "discontinuity"):
scad.analyze_tolerance(scad.tan(tangent_angle))
def test_inverse_function_domains_are_checked_over_full_interval(self):
below_sqrt = scad.var("below_sqrt", 0.1, tolerance=0.2)
outside_unit = scad.var("outside_unit", 0.9, tolerance=0.2)
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "below zero"):
scad.analyze_tolerance(scad.sqrt(below_sqrt))
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "outside"):
scad.analyze_tolerance(scad.asin(outside_unit))
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "outside"):
scad.analyze_tolerance(scad.acos(outside_unit))
def test_atan_and_atan2_propagation(self):
y = scad.var("y", 2.0, tolerance=0.1)
x = scad.var("x", 3.0, tolerance=0.2)
origin_y = scad.var("origin_y", 0.0, tolerance=0.1)
origin_x = scad.var("origin_x", 0.0, tolerance=0.1)
atan_result = scad.analyze_tolerance(scad.atan(y))
atan2_result = scad.analyze_tolerance(scad.atan2(y, x))
self.assertLess(atan_result.lower_bound, atan_result.upper_bound)
self.assertLess(atan2_result.lower_bound, atan2_result.upper_bound)
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "undefined origin"):
scad.analyze_tolerance(scad.atan2(origin_y, origin_x))
def test_atan2_negative_x_branch_cut_is_not_underestimated(self):
y = scad.var("y", 0.0, tolerance=0.1)
x = scad.var("x", -2.0, tolerance=0.1)
expression = scad.atan2(y, x)
result = scad.analyze_tolerance(expression)
self.assertEqual(result.lower_bound, -math.pi)
self.assertEqual(result.upper_bound, math.pi)
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "branch cut"):
scad.analyze_tolerance(expression, method="rss")
def test_atan2_signed_zero_branch_cut_is_rejected_for_rss(self):
y = scad.var("y", -0.0, tolerance=(0.0, 0.1))
x = scad.var("x", -2.0, tolerance=0.0)
expression = scad.atan2(y, x)
worst_case = scad.analyze_tolerance(expression)
self.assertEqual(worst_case.lower_bound, -math.pi)
self.assertEqual(worst_case.upper_bound, math.pi)
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "branch cut"):
scad.analyze_tolerance(expression, method="rss")
def test_abs_crossing_zero_and_negation(self):
value = scad.var("value", 0.25, tolerance=0.5)
absolute = scad.analyze_tolerance(abs(value))
negated = scad.analyze_tolerance(-value)
self.assertEqual(absolute.lower_bound, 0.0)
self.assertAlmostEqual(absolute.upper_bound, 0.75)
self.assertAlmostEqual(negated.lower_deviation, -0.5)
self.assertAlmostEqual(negated.upper_deviation, 0.5)
def test_nonlinear_contributions_are_reported_in_target_units(self):
value = scad.var("value", 2.0, tolerance=0.1)
result = scad.analyze_tolerance(value**2)
self.assertIsNone(result.contributions[0].sensitivity)
self.assertAlmostEqual(result.contributions[0].lower_deviation, -0.39)
self.assertAlmostEqual(result.contributions[0].upper_deviation, 0.41)
def test_large_nominal_value_does_not_erase_small_declared_deviations(self):
value = scad.var("large", 1e16, tolerance=0.1)
result = scad.analyze_tolerance(value)
self.assertEqual(result.lower_deviation, -0.1)
self.assertEqual(result.upper_deviation, 0.1)
self.assertLess(result.lower_bound, result.nominal)
self.assertGreater(result.upper_bound, result.nominal)
def test_underflowed_nonlinear_range_remains_conservative(self):
value = scad.var("tiny", 0.0, tolerance=1e-200)
result = scad.analyze_tolerance(value**2)
check = scad.check_tolerance(value**2, 0.0)
self.assertEqual(result.lower_deviation, 0.0)
self.assertGreater(result.upper_deviation, 0.0)
self.assertFalse(check.passed)
def test_large_atan_input_keeps_nonzero_worst_case_range(self):
value = scad.var("large", 1e160, tolerance=1e100)
result = scad.analyze_tolerance(scad.atan(value))
self.assertLess(result.lower_deviation, 0.0)
self.assertGreater(result.upper_deviation, 0.0)
def test_zero_tolerance_large_periodic_input_remains_a_point(self):
angle = scad.var("angle", 1e16, tolerance=0.0)
tangent = scad.analyze_tolerance(scad.tan(angle))
sine = scad.analyze_tolerance(scad.sin(angle))
self.assertEqual(tangent.lower_deviation, 0.0)
self.assertEqual(tangent.upper_deviation, 0.0)
self.assertEqual(sine.lower_deviation, 0.0)
self.assertEqual(sine.upper_deviation, 0.0)
def test_exact_zero_absolute_value_is_not_spuriously_widened(self):
value = scad.var("zero", 0.0, tolerance=0.0)
result = scad.analyze_tolerance(abs(value))
self.assertEqual(result.lower_deviation, 0.0)
self.assertEqual(result.upper_deviation, 0.0)
class TestRssTolerancePropagation(unittest.TestCase):
def test_independent_sources_are_combined_by_root_sum_square(self):
a = scad.var("a", 10.0, tolerance=0.3)
b = scad.var("b", 4.0, tolerance=0.4)
result = scad.analyze_tolerance(a + b, method="rss")
self.assertAlmostEqual(result.lower_deviation, -0.5)
self.assertAlmostEqual(result.upper_deviation, 0.5)
def test_repeated_source_is_not_treated_as_independent(self):
x = scad.var("x", 2.0, tolerance=0.2)
cancelled = scad.analyze_tolerance(x - x, method="rss")
doubled = scad.analyze_tolerance(x + x, method="rss")
self.assertAlmostEqual(cancelled.lower_deviation, 0.0)
self.assertAlmostEqual(cancelled.upper_deviation, 0.0)
self.assertAlmostEqual(doubled.lower_deviation, -0.4)
self.assertAlmostEqual(doubled.upper_deviation, 0.4)
def test_nonlinear_sensitivity_is_analytic(self):
x = scad.var("x", 2.0, tolerance=0.1)
squared = scad.analyze_tolerance(x**2, method="rss")
sine = scad.analyze_tolerance(scad.sin(x), method="rss")
self.assertAlmostEqual(squared.lower_deviation, -0.4)
self.assertAlmostEqual(squared.upper_deviation, 0.4)
self.assertAlmostEqual(
sine.upper_deviation, abs(math.cos(2.0)) * 0.1
)
def test_rss_rejects_non_differentiable_and_invalid_intervals(self):
at_zero = scad.var("at_zero", 0.0, tolerance=0.1)
denominator = scad.var("denominator", 1.0, tolerance=1.0)
numerator = scad.var("numerator", 2.0, tolerance=0.1)
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "abs"):
scad.analyze_tolerance(abs(at_zero), method="rss")
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "denominator"):
scad.analyze_tolerance(numerator / denominator, method="rss")
def test_all_unary_and_binary_derivative_paths_are_supported(self):
a = scad.var("a", 0.5, tolerance=0.01)
b = scad.var("b", 2.0, tolerance=0.02)
expressions = [
a + b,
a - b,
a * b,
a / b,
b**a,
-a,
abs(a),
scad.sin(a),
scad.cos(a),
scad.tan(a),
scad.sqrt(b),
scad.acos(a),
scad.asin(a),
scad.atan(a),
scad.atan2(a, b),
]
for expression in expressions:
with self.subTest(op=getattr(expression, "op", None)):
result = scad.analyze_tolerance(expression, method="rss")
self.assertTrue(math.isfinite(result.lower_bound))
self.assertTrue(math.isfinite(result.upper_bound))
def test_constant_boundary_functions_have_zero_rss_tolerance(self):
expressions = [scad.sqrt(0.0), scad.acos(1.0), scad.asin(-1.0)]
for expression in expressions:
with self.subTest(op=expression.op):
result = scad.analyze_tolerance(expression, method="rss")
self.assertEqual(result.lower_deviation, 0.0)
self.assertEqual(result.upper_deviation, 0.0)
def test_rss_uses_overflow_safe_root_sum_square(self):
a = scad.var("a", 0.0, tolerance=1e200)
b = scad.var("b", 0.0, tolerance=1e200)
result = scad.analyze_tolerance(a + b, method="rss")
expected = math.hypot(1e200, 1e200)
self.assertTrue(math.isfinite(result.upper_deviation))
self.assertLessEqual(result.lower_deviation, -expected)
self.assertGreaterEqual(result.upper_deviation, expected)
self.assertAlmostEqual(result.upper_deviation / expected, 1.0)
def test_rss_atan2_derivative_is_stable_for_large_coordinates(self):
y = scad.var("y", 1e200, tolerance=1e190)
x = scad.var("x", 1e200, tolerance=1e190)
result = scad.analyze_tolerance(scad.atan2(y, x), method="rss")
self.assertTrue(math.isfinite(result.upper_deviation))
self.assertGreater(result.upper_deviation, 0.0)
def test_rss_atan_derivative_does_not_underflow_before_contribution(self):
value = scad.var("large", 1e160, tolerance=1e100)
result = scad.analyze_tolerance(scad.atan(value), method="rss")
self.assertGreaterEqual(result.upper_deviation, 1e-220)
def test_ill_conditioned_affine_coefficients_preserve_source_effect(self):
value = scad.var("value", 1.0, tolerance=0.1)
expression = (1e16 * value + value) - 1e16 * value
worst_case = scad.analyze_tolerance(expression)
rss = scad.analyze_tolerance(expression, method="rss")
self.assertLessEqual(worst_case.lower_deviation, -0.1)
self.assertGreaterEqual(worst_case.upper_deviation, 0.1)
self.assertLessEqual(rss.lower_deviation, -0.1)
self.assertGreaterEqual(rss.upper_deviation, 0.1)
class TestToleranceRequirementsAndPersistence(unittest.TestCase):
def test_check_tolerance_returns_margins_without_raising(self):
a = scad.var("a", 10.0, tolerance=0.2)
b = scad.var("b", 5.0, tolerance=0.1)
passing = scad.check_tolerance(a - b, 0.3, name="clearance")
failing = scad.check_tolerance(a - b, 0.2, name="clearance")
self.assertTrue(passing.passed)
self.assertAlmostEqual(passing.lower_margin, 0.0)
self.assertAlmostEqual(passing.upper_margin, 0.0)
self.assertFalse(failing.passed)
def test_requirement_comparison_does_not_scale_epsilon_by_nominal(self):
value = scad.var("large", 1e16, tolerance=0.1)
check = scad.check_tolerance(value, 0.0)
self.assertFalse(check.passed)
self.assertLess(check.lower_margin, 0.0)
self.assertLess(check.upper_margin, 0.0)
def test_analysis_rejects_conflicting_expression_ids_before_caching(self):
left = scad.Var(
"left",
1.0,
expr_id="var_duplicate",
tolerance=scad.DimensionTolerance.symmetric(0.1),
)
right = scad.Var(
"right",
2.0,
expr_id="var_duplicate",
tolerance=scad.DimensionTolerance.symmetric(0.2),
)
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "multiple"):
scad.analyze_tolerance(scad.Expr("add", (left, right)))
def test_every_variable_requires_an_explicit_source_tolerance(self):
declared = scad.var("declared", 2.0, tolerance=0.1)
missing = scad.var("missing", 1.0)
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "missing"):
scad.analyze_tolerance(declared + missing)
def test_tolerance_graph_validates_all_requirements(self):
a = scad.var("a", 10.0, tolerance=0.2)
b = scad.var("b", 5.0, tolerance=0.1)
expression_graph = scad.ExpressionGraph()
tolerance_graph = scad.ToleranceGraph(expression_graph)
tolerance_graph.require(a + b, 0.3, name="overall")
tolerance_graph.require(a - b, 0.2, name="clearance")
report = tolerance_graph.validate()
self.assertFalse(report.passed)
self.assertEqual(report.checks[0].requirement.name, "overall")
with self.assertRaises(scad.ToleranceValidationError) as context:
tolerance_graph.validate(raise_on_failure=True)
self.assertIsNotNone(context.exception.report)
def test_tolerance_graph_roundtrip_and_dangling_refs(self):
width = scad.var("width", 10.0, tolerance=0.1)
expression_graph = scad.ExpressionGraph()
tolerance_graph = scad.ToleranceGraph(expression_graph)
requirement = tolerance_graph.require(
width * 2.0, 0.2, name="overall_width", requirement_id="req_width"
)
expression_payload = expression_graph.to_dict()
rebuilt_expression_graph = scad.ExpressionGraph.from_dict(expression_payload)
rebuilt = scad.ToleranceGraph.from_dict(
tolerance_graph.to_dict(), rebuilt_expression_graph
)
self.assertEqual(rebuilt.requirement_count, 1)
self.assertEqual(rebuilt.requirements[0], requirement)
broken = tolerance_graph.to_dict()
broken["requirements"][0]["target_expr_id"] = "missing"
with self.assertRaisesRegex(ValueError, "Unknown tolerance target"):
scad.ToleranceGraph.from_dict(broken, rebuilt_expression_graph)
def test_tolerance_graph_rejects_malformed_requirement_types(self):
width = scad.var("width", 10.0, tolerance=0.1)
expression_graph = scad.ExpressionGraph()
expression_graph.register(width)
base = {
"requirements": [
{
"requirement_id": "req_width",
"target_expr_id": width.expr_id,
"tolerance": {
"lower_deviation": -0.1,
"upper_deviation": 0.1,
},
"method": "worst_case",
"name": "width",
}
]
}
for field, invalid in (
("requirement_id", 1),
("target_expr_id", 1),
("method", []),
("name", 1),
("name", ""),
):
payload = json.loads(json.dumps(base))
payload["requirements"][0][field] = invalid
with self.subTest(field=field), self.assertRaises((TypeError, ValueError)):
scad.ToleranceGraph.from_dict(payload, expression_graph)
def test_duplicate_requirement_ids_are_rejected(self):
width = scad.var("width", 10.0, tolerance=0.1)
graph = scad.ToleranceGraph(scad.ExpressionGraph())
graph.require(width, 0.1, requirement_id="same")
with self.assertRaisesRegex(ValueError, "Duplicate"):
graph.require(width, 0.1, requirement_id="same")
def test_explicit_invalid_requirement_ids_and_names_are_rejected(self):
width = scad.var("width", 10.0, tolerance=0.1)
graph = scad.ToleranceGraph(scad.ExpressionGraph())
for requirement_id in ("", 0, []):
with self.subTest(requirement_id=requirement_id), self.assertRaises(
(TypeError, ValueError)
):
graph.require(width, 0.1, requirement_id=requirement_id)
with self.assertRaises(ValueError):
graph.require(width, 0.1, name="")
def test_session_and_model_json_include_tolerance_graph(self):
width = scad.var("width", 10.0, tolerance=(-0.1, 0.2))
with scad.GraphSession() as session:
session.require_tolerance(width * 2.0, (-0.2, 0.4), name="overall")
session_payload = scad.import_session_json(scad.export_session_json(session))
model_payload = scad.import_model_json(scad.export_model_json(session))
self.assertEqual(session_payload["tolerance_graph"].requirement_count, 1)
self.assertTrue(session_payload["tolerance_graph"].validate().passed)
self.assertEqual(model_payload["tolerance_graph"].requirement_count, 1)
def test_legacy_session_and_model_payloads_default_to_empty_tolerance_graph(self):
with scad.GraphSession() as session:
pass
raw_session = json.loads(scad.export_session_json(session))
raw_model = json.loads(scad.export_model_json(session))
raw_session.pop("tolerance_graph")
raw_model.pop("tolerance_graph")
imported_session = scad.import_session_json(json.dumps(raw_session))
imported_model = scad.import_model_json(json.dumps(raw_model))
self.assertEqual(imported_session["tolerance_graph"].requirement_count, 0)
self.assertEqual(imported_model["tolerance_graph"].requirement_count, 0)
def test_failed_requirement_blocks_session_and_model_export(self):
width = scad.var("width", 10.0, tolerance=0.2)
with scad.GraphSession() as session:
session.require_tolerance(width, 0.1, name="width")
with self.assertRaises(scad.ToleranceValidationError):
scad.export_session_json(session)
with self.assertRaises(scad.SimpleCADError):
scad.export_model_json(session)
def test_failed_requirement_in_hand_edited_model_blocks_replay(self):
width = scad.var("width", 10.0, tolerance=0.2)
with scad.GraphSession() as session:
scad.make_box_rsolid(width, 1.0, 1.0)
session.require_tolerance(width, 0.2, name="width")
payload = json.loads(scad.export_model_json(session))
payload["tolerance_graph"]["requirements"][0]["tolerance"] = {
"lower_deviation": -0.1,
"upper_deviation": 0.1,
}
with self.assertRaises(scad.SimpleCADError):
scad.replay_model_json(json.dumps(payload))
def test_analysis_and_report_payloads_are_json_serializable(self):
width = scad.var("width", 10.0, tolerance=0.1)
check = scad.check_tolerance(width * 2.0, 0.2, name="overall")
serialized = json.dumps(check.to_dict())
self.assertIn('"passed": true', serialized)
self.assertIn('"variable_name": "width"', serialized)
def test_unknown_method_is_rejected(self):
width = scad.var("width", 10.0, tolerance=0.1)
with self.assertRaisesRegex(ValueError, "Unsupported"):
scad.analyze_tolerance(width, method="monte_carlo")
if __name__ == "__main__":
unittest.main()
+190 -12
View File
@@ -43,13 +43,16 @@ class TestTrackedCut(unittest.TestCase):
# either modified or preserved; some should survive
self.assertGreater(len(preserved_face_refs), 0)
def test_tracked_cut_has_generated_faces(self):
def test_tracked_cut_has_proven_changed_faces(self):
result = tracked_cut(self.body, self.tool)
generated_face_refs = [
r for r in result.delta.generated if r.kind == TopoKind.FACE
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"
]
# The cylindrical hole creates new faces
self.assertGreater(len(generated_face_refs), 0)
self.assertGreater(len(changed_faces), 0)
def test_tracked_cut_volume_decreased(self):
result = tracked_cut(self.body, self.tool)
@@ -68,14 +71,15 @@ class TestTrackedCut(unittest.TestCase):
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_generated = [
r
for r in result.delta.generated
if r.kind == TopoKind.FACE
and result.delta_entries.get(r.topo_id, {}).get("origin_role") == "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}
]
# At least the cylindrical face of the hole should be tool-origin
self.assertGreater(len(tool_generated), 0)
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)
@@ -108,6 +112,180 @@ class TestTrackedUnion(unittest.TestCase):
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):
@@ -7,9 +7,12 @@ from simplecadapi.topology import (
TopoEvent,
TopoRef,
TopoEntry,
TopoRoleEntry,
TopoDelta,
OperationNode,
OperationGraph,
topo_delta_from_dict,
topo_delta_to_dict,
)
@@ -112,6 +115,54 @@ class TestTopoDelta(unittest.TestCase):
self.assertEqual(delta.deleted, ())
self.assertEqual(delta.section_edges, ())
def test_entries_are_canonical_and_preserve_output_and_source_kind(self):
source_ref = TopoRef("g1", "n1", 0, TopoKind.EDGE, "e_source")
output_ref = TopoRef("g1", "n2", 0, TopoKind.FACE, "f_output")
entry = TopoEntry(
ref=output_ref,
event=TopoEvent.GENERATED,
origin_role="profile",
parent_refs=(source_ref,),
metadata={
"derivation": "boundary",
"coverage": "complete",
"status": "proven",
"evidence_kind": "kernel_history",
"source_kind": "EDGE",
},
)
delta = TopoDelta(generated=(output_ref,), entries=(entry,))
self.assertEqual(delta.entries, (entry,))
self.assertEqual(entry.ref.kind, TopoKind.FACE)
self.assertEqual(entry.parent_refs[0].kind, TopoKind.EDGE)
self.assertEqual(entry.metadata["source_kind"], "EDGE")
self.assertEqual(
topo_delta_from_dict(topo_delta_to_dict(delta)).entries,
delta.entries,
)
def test_output_roles_roundtrip_independently_from_change_events(self):
source_ref = TopoRef("g1", "n1", 0, TopoKind.EDGE, "e_source")
output_ref = TopoRef("g1", "n2", 0, TopoKind.FACE, "f_output")
role = TopoRoleEntry(
ref=output_ref,
role="Extrusion.Side",
origin_role="profile",
parent_refs=(source_ref,),
metadata={
"coverage": "complete",
"status": "proven",
"evidence_method": "Generated",
},
)
delta = TopoDelta(roles=(role,))
restored = topo_delta_from_dict(topo_delta_to_dict(delta))
self.assertEqual(role.role, "extrusion.side")
self.assertEqual(restored.roles, (role,))
self.assertEqual(restored.entries, ())
class TestOperationNode(unittest.TestCase):
def test_creation(self):
+6 -6
View File
@@ -30,11 +30,11 @@ class TestTrackedTranslate(unittest.TestCase):
result.shape.get_volume(), self.box.get_volume(), places=6
)
def test_translate_all_faces_preserved(self):
def test_translate_all_faces_modified(self):
result = tracked_translate(self.box, (5, 0, 0))
preserved = [r for r in result.delta.preserved if r.kind == TopoKind.FACE]
modified = [r for r in result.delta.modified if r.kind == TopoKind.FACE]
original_faces = len(self.box.get_faces())
self.assertEqual(len(preserved), original_faces)
self.assertEqual(len(modified), original_faces)
def test_translate_no_generated(self):
result = tracked_translate(self.box, (5, 0, 0))
@@ -61,11 +61,11 @@ class TestTrackedRotate(unittest.TestCase):
result.shape.get_volume(), self.box.get_volume(), places=4
)
def test_rotate_all_faces_preserved(self):
def test_rotate_all_faces_modified(self):
result = tracked_rotate(self.box, 45.0, (0, 0, 1))
preserved = [r for r in result.delta.preserved if r.kind == TopoKind.FACE]
modified = [r for r in result.delta.modified if r.kind == TopoKind.FACE]
original_faces = len(self.box.get_faces())
self.assertEqual(len(preserved), original_faces)
self.assertEqual(len(modified), original_faces)
def test_rotate_no_generated(self):
result = tracked_rotate(self.box, 45.0, (0, 0, 1))
+105 -44
View File
@@ -1,11 +1,19 @@
"""Tests for auto-tagging based on TopoDelta."""
"""Tests for evidence-gated semantic projections from TopoDelta."""
import unittest
import simplecadapi as scad
from simplecadapi.topology import TopoKind, TopoEvent
from simplecadapi import ql as Q
from simplecadapi.topology import TopoDelta
from simplecadapi.tracking import tracked_cut, tracked_union, tracked_extrude
from simplecadapi.autotag import apply_tracking_tags, apply_tracking_tags_to_delta
from simplecadapi.autotag import apply_tracking_tags_to_delta
def proven_event(op: str, event: str):
return Q.and_(
Q.operation_event(op, event),
Q.meta("track.status", "==", "proven"),
)
class TestAutoTagCut(unittest.TestCase):
@@ -15,96 +23,135 @@ class TestAutoTagCut(unittest.TestCase):
2.0, 15.0, bottom_face_center=(3, 3, -2.5)
)
def test_cut_result_faces_get_operation_tags(self):
def test_cut_result_faces_get_typed_operation_evidence(self):
result = tracked_cut(self.body, self.tool)
tagged_solid = apply_tracking_tags_to_delta(
result.solid, result.delta, result.delta_entries, op="cut"
)
faces = tagged_solid.get_faces()
# At least some faces should have operation event tags
has_op_tag = any(
"op.cut.modified" in scad.list_tags(f) or "op.cut.generated" in scad.list_tags(f) for f in faces
has_change = any(
proven_event("cut", "modified")(face)
or proven_event("cut", "generated")(face)
for face in faces
)
self.assertTrue(has_op_tag)
self.assertTrue(has_change)
def test_cut_preserved_faces_tagged(self):
def test_cut_preserved_faces_have_typed_evidence(self):
result = tracked_cut(self.body, self.tool)
tagged_solid = apply_tracking_tags_to_delta(
result.solid, result.delta, result.delta_entries, op="cut"
)
faces = tagged_solid.get_faces()
preserved = [f for f in faces if "op.cut.preserved" in scad.list_tags(f)]
preserved = [face for face in faces if proven_event("cut", "preserved")(face)]
self.assertGreater(len(preserved), 0)
def test_cut_all_faces_tagged(self):
def test_cut_all_faces_have_tracking_envelopes_not_flat_tracking_tags(self):
result = tracked_cut(self.body, self.tool)
tagged_solid = apply_tracking_tags_to_delta(
result.solid, result.delta, result.delta_entries, op="cut"
)
faces = tagged_solid.get_faces()
# All result faces should have some operation event tag
all_tagged = all(
"op.cut.modified" in scad.list_tags(f)
or "op.cut.preserved" in scad.list_tags(f)
or "op.cut.generated" in scad.list_tags(f)
for f in faces
)
self.assertTrue(all_tagged)
for face in faces:
track = face.get_metadata("track")
self.assertEqual(track["schema_version"], "1.0")
self.assertEqual(track["kind"], "topology_change")
self.assertEqual(track["operation"], "cut")
self.assertEqual(track["topo_kind"], "FACE")
self.assertIn(track["coverage"], {"complete", "partial"})
self.assertIn(track["status"], {"proven", "unknown"})
self.assertFalse(
any(
tag.startswith(("op.", "origin."))
for tag in scad.list_tags(face)
)
)
def test_cut_origin_role_tags(self):
def test_cut_origin_roles_are_typed_evidence(self):
result = tracked_cut(self.body, self.tool)
tagged_solid = apply_tracking_tags_to_delta(
result.solid, result.delta, result.delta_entries, op="cut"
)
faces = tagged_solid.get_faces()
has_body = any("origin.body" in scad.list_tags(f) for f in faces)
has_tool = any("origin.tool" in scad.list_tags(f) for f in faces)
proven = Q.meta("track.status", "==", "proven")
has_body = any(Q.origin_role("body")(face) and proven(face) for face in faces)
has_tool = any(Q.origin_role("tool")(face) and proven(face) for face in faces)
self.assertTrue(has_body)
self.assertTrue(has_tool)
def test_unmatched_faces_remain_partial_and_unknown(self):
solid = scad.make_box_rsolid(1.0, 1.0, 1.0)
tagged = apply_tracking_tags_to_delta(solid, TopoDelta(), op="cut")
for face in tagged.get_faces():
track = face.get_metadata("track")
self.assertEqual(track["coverage"], "partial")
self.assertEqual(track["status"], "unknown")
self.assertEqual(track["events"], [])
self.assertEqual(track["origin_roles"], [])
self.assertEqual(track["witnesses"], [])
self.assertFalse(Q.operation_event("cut")(face))
class TestAutoTagUnion(unittest.TestCase):
def setUp(self):
self.body = scad.make_box_rsolid(10, 10, 10)
self.tool = scad.make_cylinder_rsolid(4.0, 10.0, bottom_face_center=(3, 3, 0))
def test_union_section_faces_tagged(self):
result = tracked_union(self.body, self.tool)
def test_union_faces_have_typed_operation_evidence(self):
result = tracked_union(self.body, self.tool, glue=False)
tagged_solid = apply_tracking_tags_to_delta(
result.solid, result.delta, result.delta_entries, op="union"
)
faces = tagged_solid.get_faces()
# Union creates modified faces at the intersection
has_union_tag = any(
"op.union.modified" in scad.list_tags(f) or "op.union.preserved" in scad.list_tags(f)
for f in faces
has_union_evidence = any(
proven_event("union", "modified")(face)
or proven_event("union", "preserved")(face)
for face in faces
)
self.assertTrue(has_union_evidence)
section_tracks = [
edge.get_metadata("track")
for edge in tagged_solid.get_edges()
if edge.get_metadata("track").get("section")
]
self.assertTrue(section_tracks)
self.assertTrue(
all(
{"body", "tool"}.issubset(track["origin_roles"])
and len(track["witnesses"]) >= 2
for track in section_tracks
)
)
self.assertTrue(has_union_tag)
class TestAutoTagExtrude(unittest.TestCase):
def setUp(self):
self.profile = scad.make_rectangle_rface(5.0, 3.0)
def test_extrude_faces_tagged(self):
def test_extrude_roles_are_kernel_proven_without_geometry_guesses(self):
result = tracked_extrude(self.profile, (0, 0, 1), 10.0)
tagged_solid = apply_tracking_tags_to_delta(
result.shape, result.delta, result.delta_entries, op="extrude"
)
faces = tagged_solid.get_faces()
# Extrude produces new faces; any operation tag is sufficient
has_tag = any(
"op.extrude.generated" in scad.list_tags(f) or "op.extrude.modified" in scad.list_tags(f)
for f in faces
)
self.assertTrue(has_tag)
self.assertTrue(faces)
roles = [
role
for face in faces
for role in face.get_metadata("track")["result_roles"]
]
self.assertEqual(roles.count("extrusion.start"), 1)
self.assertEqual(roles.count("extrusion.end"), 1)
self.assertEqual(roles.count("extrusion.side"), 4)
self.assertTrue(all(Q.output_role(role)(face) for face in faces for role in face.get_metadata("track")["result_roles"]))
class TestAutoTagPreservesExisting(unittest.TestCase):
def test_existing_tags_carried_to_result(self):
"""Tags from the original body should be carried to preserved/modified faces."""
def test_user_semantics_are_available_only_through_proven_lineage(self):
body = scad.make_box_rsolid(10, 10, 10)
body.auto_tag_faces("box")
for face in body.get_faces():
scad.apply_tag(face, "role.source_face")
tool = scad.make_cylinder_rsolid(2.0, 15.0, bottom_face_center=(3, 3, -2.5))
result = tracked_cut(body, tool)
tagged_solid = apply_tracking_tags_to_delta(
@@ -114,12 +161,26 @@ class TestAutoTagPreservesExisting(unittest.TestCase):
op="cut",
source_solid=body,
)
faces = tagged_solid.get_faces()
# Preserved and modified faces from body should carry body's tags
has_original = any(
"face.top" in scad.list_tags(f) or "face.bottom" in scad.list_tags(f) for f in faces
body_descendants = [
face
for face in tagged_solid.get_faces()
if Q.origin_role("body")(face)
and face.get_metadata("track")["status"] == "proven"
]
self.assertTrue(body_descendants)
self.assertTrue(
any(
"role.source_face" in scad.list_tags(face, scope="lineage")
for face in body_descendants
)
)
self.assertTrue(
all(
"role.source_face" not in scad.list_tags(face, scope="effective")
for face in body_descendants
)
)
self.assertTrue(has_original)
if __name__ == "__main__":
@@ -0,0 +1,110 @@
"""Contract tests shared by translator backends."""
from __future__ import annotations
import importlib
from pathlib import Path
import unittest
import simplecadapi as scad
from simplecadapi.graph import GraphSession
from simplecadapi.serializer import CANONICAL_OP_SET
from simplecadapi.translator.base import BaseTranslator
from simplecadapi.translator.freecad_translator import FreeCADTranslator
from simplecadapi.translator.freecad_translator.emitters.registry import (
EMITTER_METHOD_BY_OP,
)
from simplecadapi.translator.freecad_translator.runtime import (
assemble_runtime_source,
)
from simplecadapi.translator.types import SupportLevel
class TestTranslatorBackendContract(unittest.TestCase):
def test_backend_packages_have_required_modules(self):
translator_root = (
Path(__file__).resolve().parents[1]
/ "src"
/ "simplecadapi"
/ "translator"
)
backend_dirs = sorted(translator_root.glob("*_translator"))
self.assertTrue(backend_dirs)
for backend_dir in backend_dirs:
for filename in (
"__init__.py",
"api.py",
"translator.py",
"capabilities.py",
):
self.assertTrue(
(backend_dir / filename).is_file(),
f"{backend_dir.name} is missing required {filename}",
)
def test_backend_public_exports_and_capabilities_are_valid(self):
from simplecadapi import translator
for backend_package_name in translator.__all__:
backend = importlib.import_module(
f"simplecadapi.translator.{backend_package_name}"
)
for exported_name in backend.__all__:
self.assertTrue(hasattr(backend, exported_name))
capabilities = backend.CAPABILITIES
expected_backend_name = backend_package_name.removesuffix("_translator")
self.assertEqual(capabilities.backend_id, expected_backend_name)
self.assertEqual(
set(capabilities.operations), set(CANONICAL_OP_SET)
)
for op, capability in capabilities.operations.items():
if capability.level is SupportLevel.UNSUPPORTED:
self.assertTrue(capability.reason, op)
translator_class = getattr(
backend, f"{capabilities.display_name}Translator"
)
self.assertTrue(issubclass(translator_class, BaseTranslator))
def test_freecad_emitter_registry_matches_declared_support(self):
from simplecadapi.translator.freecad_translator import CAPABILITIES
self.assertIs(
CAPABILITIES.operations["apply_tag_rselection"].level,
SupportLevel.METADATA_ONLY,
)
self.assertIs(
CAPABILITIES.operations["make_twisted_sweep_rsolid"].level,
SupportLevel.EMULATED,
)
supported_ops = {
op
for op, capability in CAPABILITIES.operations.items()
if capability.level is not SupportLevel.UNSUPPORTED
}
self.assertEqual(set(EMITTER_METHOD_BY_OP), supported_ops)
def test_freecad_runtime_fragments_form_valid_python(self):
runtime_source = assemble_runtime_source()
self.assertTrue(runtime_source)
compile(runtime_source, "<freecad-runtime>", "exec")
def test_freecad_translator_is_reusable_and_emits_valid_python(self):
with GraphSession() as session:
scad.make_box_rsolid(1.0, 2.0, 3.0)
model_json = scad.export_model_json(session)
translator = FreeCADTranslator(document_name="ContractTest")
first = translator.translate_model_json_to_script(model_json)
second = translator.translate_model_json_to_script(model_json)
self.assertEqual(first, second)
self.assertIn('DOC_NAME = "ContractTest"', first)
compile(first, "<generated-freecad-script>", "exec")
if __name__ == "__main__":
unittest.main()
+534
View File
@@ -0,0 +1,534 @@
"""Complete coverage for physical units and dimensional expression inference."""
from __future__ import annotations
import json
import math
import unittest
import simplecadapi as scad
from simplecadapi.units import _infer, unit_to_payload
class TestDimensionsAndUnits(unittest.TestCase):
def test_named_dimensions_and_dimension_algebra(self):
self.assertEqual(scad.DIMENSIONLESS.name, "Dimensionless")
self.assertEqual(scad.LENGTH.name, "Length")
self.assertEqual(scad.AREA.name, "Area")
self.assertEqual(scad.VOLUME.name, "Volume")
self.assertEqual(scad.ANGLE.name, "Angle")
self.assertEqual(scad.LENGTH.multiply(scad.LENGTH), scad.AREA)
self.assertEqual(scad.VOLUME.divide(scad.LENGTH), scad.AREA)
self.assertEqual(scad.LENGTH.power(3), scad.VOLUME)
self.assertEqual(scad.AREA.square_root(), scad.LENGTH)
self.assertEqual(scad.Dimension(length=-1, angle=2).symbol, "L^-1 A^2")
self.assertTrue(scad.DIMENSIONLESS.is_dimensionless)
self.assertTrue(scad.LENGTH.is_design_dimension)
self.assertTrue(scad.ANGLE.is_design_dimension)
self.assertFalse(scad.AREA.is_design_dimension)
def test_dimension_serialization_and_validation(self):
self.assertEqual(
scad.Dimension.from_dict(scad.VOLUME.to_dict()), scad.VOLUME
)
invalid = [
None,
{},
{"length": 1},
{"length": True, "angle": 0},
{"length": 1, "angle": 0.5},
]
for payload in invalid:
with self.subTest(payload=payload), self.assertRaises(
(TypeError, ValueError)
):
scad.Dimension.from_dict(payload)
with self.assertRaises(TypeError):
scad.LENGTH.power(0.5)
with self.assertRaises(scad.UnitValidationError):
scad.LENGTH.square_root()
def test_every_registered_unit_converts_to_canonical_units(self):
conversions = [
(scad.MM, 1.0),
(scad.CM, 10.0),
(scad.M, 1000.0),
(scad.INCH, 25.4),
(scad.FOOT, 304.8),
(scad.DEGREE, 1.0),
(scad.RADIAN, 180.0 / math.pi),
(scad.ONE, 1.0),
(scad.PERCENT, 0.01),
(scad.SQUARE_MM, 1.0),
(scad.SQUARE_CM, 100.0),
(scad.SQUARE_M, 1_000_000.0),
(scad.SQUARE_INCH, 25.4**2),
(scad.SQUARE_FOOT, 304.8**2),
(scad.CUBIC_MM, 1.0),
(scad.CUBIC_CM, 1000.0),
(scad.CUBIC_M, 1_000_000_000.0),
(scad.CUBIC_INCH, 25.4**3),
(scad.CUBIC_FOOT, 304.8**3),
]
for unit, expected in conversions:
with self.subTest(unit=unit.symbol):
self.assertAlmostEqual(unit.to_canonical(1.0), expected)
self.assertAlmostEqual(unit.from_canonical(expected), 1.0)
self.assertIs(scad.get_unit(unit), unit)
self.assertEqual(scad.get_unit(unit.symbol), unit)
self.assertEqual(
scad.canonical_unit_for_dimension(unit.dimension).dimension,
unit.dimension,
)
def test_unit_aliases_and_conversion(self):
aliases = {
"millimeters": scad.MM,
"centimeter": scad.CM,
"meters": scad.M,
"inches": scad.INCH,
"feet": scad.FOOT,
"degrees": scad.DEGREE,
"radians": scad.RADIAN,
"percent": scad.PERCENT,
"square feet": scad.SQUARE_FOOT,
"cubic inches": scad.CUBIC_INCH,
"ml": scad.CUBIC_CM,
}
for alias, unit in aliases.items():
with self.subTest(alias=alias):
self.assertEqual(scad.get_unit(alias), unit)
self.assertAlmostEqual(scad.convert_value(1.0, "in", "mm"), 25.4)
self.assertAlmostEqual(scad.convert_value(180.0, "deg", "rad"), math.pi)
self.assertAlmostEqual(scad.convert_value(1.0, "ft^2", "in^2"), 144.0)
with self.assertRaises(scad.UnitValidationError):
scad.convert_value(1.0, "mm", "deg")
with self.assertRaisesRegex(ValueError, "Unknown unit"):
scad.get_unit("parsec")
def test_unit_validation_and_custom_unit_roundtrip(self):
invalid_calls = [
lambda: scad.Unit("", scad.LENGTH, 1.0),
lambda: scad.Unit("bad", "Length", 1.0),
lambda: scad.Unit("bad", scad.LENGTH, True),
lambda: scad.Unit("bad", scad.LENGTH, 0.0),
lambda: scad.Unit("bad", scad.LENGTH, math.inf),
lambda: scad.MM.to_canonical(math.inf),
lambda: scad.MM.to_canonical(True),
lambda: scad.M.to_canonical(1e308),
lambda: scad.Unit("tiny", scad.LENGTH, 5e-324).to_canonical(0.5),
lambda: scad.Unit("tiny", scad.LENGTH, 5e-324).from_canonical(1.0),
lambda: scad.Unit("huge", scad.LENGTH, 1e308).from_canonical(5e-324),
lambda: scad.MM.to_canonical(10**10000),
lambda: scad.get_unit(None),
lambda: scad.get_unit(""),
lambda: scad.Unit.from_dict(None),
lambda: unit_to_payload(None),
]
for call in invalid_calls:
with self.subTest(call=call), self.assertRaises((TypeError, ValueError)):
call()
thou = scad.Unit("thou", scad.LENGTH, 0.0254)
width = scad.var(
"width", 1000.0, unit=thou, tolerance=2.0, tolerance_unit=thou
)
graph = scad.ExpressionGraph()
graph.register(width)
payload = graph.to_dict()
self.assertIsInstance(payload["nodes"][0]["unit"], dict)
rebuilt = scad.ExpressionGraph.from_dict(payload).get(width.expr_id)
self.assertIsInstance(rebuilt, scad.Var)
self.assertEqual(rebuilt.unit, thou)
self.assertEqual(rebuilt.tolerance_unit, thou)
self.assertAlmostEqual(rebuilt.evaluate(), 25.4)
self.assertAlmostEqual(rebuilt.canonical_tolerance.upper_deviation, 0.0508)
def test_unknown_compound_dimension_uses_a_canonical_scale_one_unit(self):
dimension = scad.Dimension(length=-1, angle=2)
unit = scad.canonical_unit_for_dimension(dimension)
self.assertEqual(unit.symbol, "L^-1 A^2")
self.assertEqual(unit.dimension, dimension)
self.assertEqual(unit.scale_to_canonical, 1.0)
def test_malformed_custom_unit_payloads_are_rejected(self):
base = {
"nodes": [
{
"expr_id": "var_x",
"kind": "var",
"name": "x",
"default": 1.0,
"unit": {
"symbol": "custom",
"dimension": {"length": 1, "angle": 0},
"scale_to_canonical": 2.0,
},
}
]
}
for field in ("symbol", "dimension", "scale_to_canonical"):
payload = json.loads(json.dumps(base))
del payload["nodes"][0]["unit"][field]
with self.subTest(field=field), self.assertRaises(ValueError):
scad.ExpressionGraph.from_dict(payload)
class TestUnitAwareVariables(unittest.TestCase):
def test_nominal_and_tolerance_units_are_canonicalized_independently(self):
width = scad.var(
"width",
1.0,
unit="in",
tolerance=(-0.1, 0.2),
tolerance_unit="mm",
)
inherited = scad.var("inherited", 1.0, unit="in", tolerance=0.01)
self.assertEqual(width.default, 1.0)
self.assertEqual(width.tolerance, scad.DimensionTolerance(-0.1, 0.2))
self.assertEqual(width.unit, scad.INCH)
self.assertEqual(width.tolerance_unit, scad.MM)
self.assertAlmostEqual(width.canonical_default, 25.4)
self.assertEqual(
width.canonical_tolerance, scad.DimensionTolerance(-0.1, 0.2)
)
self.assertEqual(inherited.tolerance_unit, scad.INCH)
self.assertAlmostEqual(
inherited.canonical_tolerance.upper_deviation, 0.254
)
def test_bindings_use_the_variable_declaration_unit(self):
width = scad.var("width", 1.0, unit="in")
self.assertAlmostEqual(width.evaluate({"width": 2.0}), 50.8)
self.assertAlmostEqual((width + 1.0).evaluate({"width": 2.0}), 51.8)
def test_invalid_variable_unit_combinations_are_rejected(self):
invalid_calls = [
lambda: scad.var("x", 1.0, tolerance=0.1, tolerance_unit="mm"),
lambda: scad.var("x", 1.0, unit="mm", tolerance_unit="mm"),
lambda: scad.var(
"x", 1.0, unit="mm", tolerance=0.1, tolerance_unit="deg"
),
lambda: scad.var("x", 1.0, unit="unknown"),
lambda: scad.var("x", 1e308, unit="m"),
lambda: scad.var("x", 1.0, unit="mm", tolerance=1e308, tolerance_unit="m"),
]
for call in invalid_calls:
with self.subTest(call=call), self.assertRaises((TypeError, ValueError)):
call()
def test_expression_graph_roundtrip_preserves_registered_units(self):
angle = scad.var(
"angle", math.pi / 2.0, unit="rad", tolerance=0.5, tolerance_unit="deg"
)
expression = scad.sin(angle)
graph = scad.ExpressionGraph()
graph.register(expression)
payload = graph.to_dict()
rebuilt = scad.ExpressionGraph.from_dict(payload)
rebuilt_angle = rebuilt.get(angle.expr_id)
self.assertEqual(rebuilt_angle.unit, scad.RADIAN)
self.assertEqual(rebuilt_angle.tolerance_unit, scad.DEGREE)
self.assertAlmostEqual(rebuilt.get(expression.expr_id).evaluate(), 1.0)
def test_geometry_parameters_use_canonical_cad_values(self):
width = scad.var("width", 1.0, unit="in")
with scad.GraphSession() as session:
scad.make_box_rsolid(width, 2.0, 3.0)
node = session.graph.topological_order()[0]
self.assertAlmostEqual(node.params["width"], 25.4)
self.assertEqual(node.param_exprs["width"]["expr_id"], width.expr_id)
class TestDimensionInference(unittest.TestCase):
def setUp(self):
self.length = scad.var("length", 3.0, unit="mm")
self.other_length = scad.var("other_length", 4.0, unit="cm")
self.angle = scad.var("angle", 90.0, unit="deg")
self.scalar = scad.var("scalar", 50.0, unit="%")
def test_addition_subtraction_and_contextual_constants(self):
self.assertEqual(scad.infer_dimension(self.length + self.other_length), scad.LENGTH)
self.assertEqual(scad.infer_dimension(self.length - 1.0), scad.LENGTH)
self.assertEqual(scad.infer_dimension(1.0 + self.angle), scad.ANGLE)
self.assertAlmostEqual((self.length + self.other_length).evaluate(), 43.0)
def test_multiplication_division_and_powers(self):
area = self.length * self.other_length
volume = area * self.length
ratio = self.length / self.other_length
inverse = 1.0 / self.length
self.assertEqual(scad.infer_dimension(area), scad.AREA)
self.assertEqual(scad.infer_dimension(volume), scad.VOLUME)
self.assertEqual(scad.infer_dimension(ratio), scad.DIMENSIONLESS)
self.assertEqual(scad.infer_dimension(inverse), scad.Dimension(length=-1))
self.assertEqual(scad.infer_dimension(self.length**3), scad.VOLUME)
self.assertEqual(scad.infer_dimension(self.length**0), scad.DIMENSIONLESS)
self.assertEqual(
scad.infer_dimension(self.scalar ** scad.var("power", 2.0, unit="1")),
scad.DIMENSIONLESS,
)
def test_square_root_requires_even_dimension_exponents(self):
diagonal = scad.sqrt(self.length**2 + self.other_length**2)
self.assertEqual(scad.infer_dimension(diagonal), scad.LENGTH)
self.assertAlmostEqual(diagonal.evaluate(), math.sqrt(1609.0))
self.assertEqual(scad.infer_dimension(scad.sqrt(self.scalar)), scad.DIMENSIONLESS)
with self.assertRaises(scad.UnitValidationError):
scad.infer_dimension(scad.sqrt(self.length))
with self.assertRaises(scad.UnitValidationError):
scad.infer_dimension(self.length**0.5)
def test_unary_and_trigonometric_dimensions(self):
self.assertEqual(scad.infer_dimension(-self.length), scad.LENGTH)
self.assertEqual(scad.infer_dimension(abs(self.length)), scad.LENGTH)
for expression in (
scad.sin(self.angle),
scad.cos(self.angle),
scad.tan(self.angle),
):
self.assertEqual(scad.infer_dimension(expression), scad.DIMENSIONLESS)
for expression in (
scad.asin(self.scalar),
scad.acos(self.scalar),
scad.atan(self.scalar),
scad.atan2(self.length, self.other_length),
):
self.assertEqual(scad.infer_dimension(expression), scad.ANGLE)
def test_invalid_physical_expressions_are_rejected(self):
legacy = scad.var("legacy", 1.0)
invalid = [
self.length + self.angle,
self.length + self.scalar,
self.length + legacy,
self.length * legacy,
self.length ** self.scalar,
self.length ** self.angle,
self.length ** legacy,
self.length**0.25,
scad.sin(self.length),
scad.asin(self.length),
scad.atan2(self.length, self.angle),
]
for expression in invalid:
with self.subTest(op=expression.op), self.assertRaises(
scad.UnitValidationError
):
scad.infer_dimension(expression)
with self.assertRaises(scad.UnitValidationError):
(self.length + self.angle).evaluate()
def test_inference_cache_does_not_hide_conflicting_duplicate_ids(self):
length = scad.Var("length", 1.0, expr_id="same", unit=scad.MM)
angle = scad.Var("angle", 1.0, expr_id="same", unit=scad.DEGREE)
expression = scad.Expr("add", (length, angle))
with self.assertRaises(scad.UnitValidationError):
scad.infer_dimension(expression)
with self.assertRaisesRegex(ValueError, "different node"):
scad.ExpressionGraph().register(expression)
def test_inference_rejects_mutated_cycles_ops_and_node_types(self):
cycle = scad.Expr("neg", (scad.const(1.0),), expr_id="cycle")
object.__setattr__(cycle, "args", (cycle,))
with self.assertRaisesRegex(scad.UnitValidationError, "cycle"):
scad.infer_dimension(cycle)
unsupported = scad.Expr("neg", (scad.const(1.0),))
object.__setattr__(unsupported, "op", "unsupported")
with self.assertRaisesRegex(scad.UnitValidationError, "Unsupported"):
scad.infer_dimension(unsupported)
with self.assertRaisesRegex(TypeError, "Unsupported expression node"):
_infer(object(), {}, set())
def test_legacy_expressions_keep_radian_math_and_unknown_dimension(self):
value = scad.var("legacy", 0.5)
expression = value ** scad.var("legacy_power", 2.0)
self.assertIsNone(scad.infer_dimension(expression))
self.assertFalse(scad.expression_uses_units(expression))
self.assertAlmostEqual(scad.sin(value).evaluate(), math.sin(0.5))
self.assertAlmostEqual(scad.asin(value).evaluate(), math.asin(0.5))
self.assertIsNone(scad.infer_dimension(scad.sin(value)))
self.assertIsNone(scad.infer_dimension(value + value))
self.assertIsNone(scad.infer_dimension(scad.atan2(value, value)))
class TestUnitAwareMathAndTolerance(unittest.TestCase):
def test_trigonometric_evaluation_uses_degrees_canonically(self):
degrees = scad.var("degrees", 90.0, unit="deg")
radians = scad.var("radians", math.pi / 2.0, unit="rad")
ratio = scad.var("ratio", 50.0, unit="%")
y = scad.var("y", 1.0, unit="in")
x = scad.var("x", 25.4, unit="mm")
self.assertAlmostEqual(scad.sin(degrees).evaluate(), 1.0)
self.assertAlmostEqual(scad.sin(radians).evaluate(), 1.0)
self.assertAlmostEqual(scad.asin(ratio).evaluate(), 30.0)
self.assertAlmostEqual(scad.atan2(y, x).evaluate(), 45.0)
def test_diagonal_tolerance_chain_reports_length_in_mm(self):
width = scad.var(
"width", 3.0, unit="cm", tolerance=0.1, tolerance_unit="mm"
)
height = scad.var("height", 40.0, unit="mm", tolerance=0.2)
diagonal = scad.sqrt(width**2 + height**2)
worst_case = scad.analyze_tolerance(diagonal)
rss = scad.analyze_tolerance(diagonal, method="rss")
self.assertEqual(worst_case.dimension, scad.LENGTH)
self.assertEqual(worst_case.unit, scad.MM)
self.assertAlmostEqual(worst_case.nominal, 50.0)
self.assertLess(worst_case.lower_deviation, 0.0)
self.assertGreater(worst_case.upper_deviation, 0.0)
expected_rss = math.hypot(0.6 * 0.1, 0.8 * 0.2)
self.assertAlmostEqual(rss.upper_deviation, expected_rss, places=12)
self.assertTrue(all(item.source_unit == scad.MM for item in rss.contributions))
def test_angle_tolerance_propagation_converts_derivatives(self):
angle = scad.var("angle", 30.0, unit="deg", tolerance=1.0)
result = scad.analyze_tolerance(scad.sin(angle), method="rss")
expected = math.cos(math.radians(30.0)) * math.pi / 180.0
self.assertEqual(result.dimension, scad.DIMENSIONLESS)
self.assertEqual(result.unit, scad.ONE)
self.assertAlmostEqual(result.nominal, 0.5)
self.assertAlmostEqual(result.upper_deviation, expected, places=12)
def test_inverse_trig_and_atan2_tolerances_report_degrees(self):
ratio = scad.var("ratio", 50.0, unit="%", tolerance=1.0)
y = scad.var("y", 10.0, unit="mm", tolerance=0.1)
x = scad.var("x", 10.0, unit="mm", tolerance=0.1)
asin_result = scad.analyze_tolerance(scad.asin(ratio), method="rss")
atan2_result = scad.analyze_tolerance(scad.atan2(y, x), method="rss")
self.assertEqual(asin_result.unit, scad.DEGREE)
self.assertAlmostEqual(asin_result.nominal, 30.0)
self.assertEqual(atan2_result.unit, scad.DEGREE)
self.assertAlmostEqual(atan2_result.nominal, 45.0)
def test_area_and_volume_analysis_are_inferred_but_not_requirements(self):
width = scad.var("width", 2.0, unit="mm", tolerance=0.1)
area = width**2
volume = width**3
self.assertEqual(scad.analyze_tolerance(area).dimension, scad.AREA)
self.assertEqual(scad.analyze_tolerance(volume).dimension, scad.VOLUME)
with self.assertRaisesRegex(ValueError, "Length or Angle"):
scad.check_tolerance(area, 0.5, tolerance_unit="mm^2")
class TestUnitAwareRequirementsAndPersistence(unittest.TestCase):
def test_requirement_tolerance_units_are_converted_before_comparison(self):
width = scad.var(
"width", 1.0, unit="in", tolerance=0.1, tolerance_unit="mm"
)
passing = scad.check_tolerance(width, 0.004, tolerance_unit="in")
failing = scad.check_tolerance(width, 0.003, tolerance_unit="in")
self.assertTrue(passing.passed)
self.assertFalse(failing.passed)
self.assertEqual(passing.requirement.tolerance_unit, scad.INCH)
self.assertAlmostEqual(
passing.requirement.canonical_tolerance.upper_deviation, 0.1016
)
def test_requirement_unit_validation(self):
length = scad.var("length", 10.0, unit="mm", tolerance=0.1)
legacy = scad.var("legacy", 10.0, tolerance=0.1)
scalar = scad.var("scalar", 1.0, unit="1", tolerance=0.1)
with self.assertRaisesRegex(ValueError, "incompatible"):
scad.check_tolerance(length, 1.0, tolerance_unit="deg")
with self.assertRaisesRegex(ValueError, "unit-aware"):
scad.check_tolerance(legacy, 1.0, tolerance_unit="mm")
with self.assertRaisesRegex(ValueError, "Length or Angle"):
scad.check_tolerance(scalar, 0.1)
def test_session_model_and_tolerance_graph_roundtrip_units(self):
width = scad.var(
"width", 1.0, unit="in", tolerance=0.1, tolerance_unit="mm"
)
with scad.GraphSession() as session:
scad.make_box_rsolid(width, 2.0, 3.0)
requirement = session.require_tolerance(
width, 0.004, tolerance_unit="in", name="width"
)
raw = json.loads(scad.export_model_json(session))
self.assertEqual(raw["expression_graph"]["nodes"][0]["unit"], "in")
self.assertEqual(
raw["expression_graph"]["nodes"][0]["tolerance_unit"], "mm"
)
self.assertEqual(
raw["tolerance_graph"]["requirements"][0]["tolerance_unit"], "in"
)
self.assertEqual(
raw["tolerance_graph"]["requirements"][0]["target_dimension"],
scad.LENGTH.to_dict(),
)
imported = scad.import_model_json(json.dumps(raw))
rebuilt = imported["tolerance_graph"].requirements[0]
self.assertEqual(rebuilt, requirement)
self.assertTrue(imported["tolerance_graph"].validate().passed)
self.assertEqual(len(scad.replay_model_json(json.dumps(raw))), 1)
def test_import_rejects_tampered_requirement_units_and_dimensions(self):
width = scad.var("width", 10.0, unit="mm", tolerance=0.1)
expressions = scad.ExpressionGraph()
tolerances = scad.ToleranceGraph(expressions)
tolerances.require(width, 0.1, tolerance_unit="mm", requirement_id="req")
expression_payload = expressions.to_dict()
wrong_unit = tolerances.to_dict()
wrong_unit["requirements"][0]["tolerance_unit"] = "deg"
with self.assertRaisesRegex(ValueError, "incompatible"):
scad.ToleranceGraph.from_dict(
wrong_unit, scad.ExpressionGraph.from_dict(expression_payload)
)
wrong_dimension = tolerances.to_dict()
wrong_dimension["requirements"][0]["target_dimension"] = scad.ANGLE.to_dict()
with self.assertRaisesRegex(ValueError, "does not match"):
scad.ToleranceGraph.from_dict(
wrong_dimension, scad.ExpressionGraph.from_dict(expression_payload)
)
missing_dimension = tolerances.to_dict()
missing_dimension["requirements"][0]["target_dimension"] = None
with self.assertRaisesRegex(ValueError, "does not match"):
scad.ToleranceGraph.from_dict(
missing_dimension, scad.ExpressionGraph.from_dict(expression_payload)
)
def test_legacy_payloads_and_requirements_remain_compatible(self):
width = scad.var("width", 10.0, tolerance=0.1)
graph = scad.ExpressionGraph()
tolerance_graph = scad.ToleranceGraph(graph)
tolerance_graph.require(width, 0.1, requirement_id="legacy")
payload = tolerance_graph.to_dict()
payload["requirements"][0].pop("tolerance_unit")
payload["requirements"][0].pop("target_dimension")
rebuilt = scad.ToleranceGraph.from_dict(payload, graph)
requirement = rebuilt.requirements[0]
self.assertIsNone(requirement.tolerance_unit)
self.assertIsNone(requirement.target_dimension)
self.assertTrue(rebuilt.validate().passed)
if __name__ == "__main__":
unittest.main()