feat: integrate SimpleCADAPI 2.0.2 CAD workflows
This commit is contained in:
+19872
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
|
||||
],
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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",)}
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user