Files
2026-07-22 13:48:46 +08:00

3910 lines
168 KiB
Python

"""Tests for FreeCAD script translation layer."""
from __future__ import annotations
import json
import importlib
import math
import os
import shutil
import subprocess
import tempfile
import zipfile
from pathlib import Path
from unittest import mock
import unittest
import xml.etree.ElementTree as ET
import simplecadapi as scad
from simplecadapi import ql
from simplecadapi.graph import GraphSession
from simplecadapi.topology import OperationGraph
from simplecadapi.translator import freecad_translator
class TestFreeCADTranslator(unittest.TestCase):
def test_freecad_translator_is_only_exported_from_translator_namespace(self):
self.assertTrue(hasattr(scad, "translator"))
self.assertIs(
scad.translator.freecad_translator.translate_model_json_to_fcstd,
freecad_translator.translate_model_json_to_fcstd,
)
self.assertFalse(hasattr(scad, "translate_model_json_to_fcstd"))
self.assertFalse(hasattr(scad, "translate_model_json_to_freecad_script"))
with self.assertRaises(ModuleNotFoundError):
importlib.import_module("simplecadapi.freecad_translator")
def _expr_alias(self, expr_id: str) -> str:
alias = "".join(ch if str(ch).isalnum() else "_" for ch in str(expr_id)).strip(
"_"
)
if not alias:
alias = "expr"
if alias[0].isdigit():
alias = f"expr_{alias}"
return alias[:64]
def _sheet_alias(self, node: dict, row: int) -> str:
expr_id = str(node.get("expr_id", f"expr_{row}"))
kind = str(node.get("kind", "expr"))
if kind == "var":
name = str(node.get("name", "")).strip()
if name:
return self._sanitize_alias(f"var_{name}", prefix="var")
if kind == "const":
return self._sanitize_alias(
f"const_{self._const_value_alias_token(node.get('value'))}_{self._expr_short_suffix(expr_id)}",
prefix="const",
)
op = str(node.get("op", "expr")).strip() or "expr"
return self._sanitize_alias(
f"expr_{op}_{self._expr_short_suffix(expr_id)}", prefix="expr"
)
def _expr_short_suffix(self, expr_id: str) -> str:
raw = str(expr_id).rsplit("_", 1)[-1]
alias = "".join(ch if str(ch).isalnum() else "_" for ch in str(raw)).strip("_")
return alias[:8] if alias else "id"
def _const_value_alias_token(self, value: object) -> str:
try:
number = float(value)
except Exception:
return "value"
alias = f"{number:.6g}".replace("-", "neg_").replace(".", "_")
alias = "".join(ch if str(ch).isalnum() else "_" for ch in str(alias)).strip(
"_"
)
return alias or "value"
def _sanitize_alias(self, raw: str, prefix: str = "expr") -> str:
alias = "".join(ch if str(ch).isalnum() else "_" for ch in str(raw)).strip("_")
if not alias:
alias = prefix
if alias[0].isdigit():
alias = f"{prefix}_{alias}"
return alias[:64]
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 _inspect_fcstd_json(self, payload: str, probe_source: str) -> dict:
freecad_cmd = self._discover_freecadcmd()
if not freecad_cmd:
self.skipTest("freecadcmd not available")
with tempfile.TemporaryDirectory() as tmp_dir:
fcstd_path = os.path.join(tmp_dir, "model.FCStd")
probe_path = os.path.join(tmp_dir, "probe.py")
out_path = os.path.join(tmp_dir, "probe.json")
freecad_translator.translate_model_json_to_fcstd(
payload, fcstd_path, freecad_cmd=freecad_cmd
)
with open(probe_path, "w", encoding="utf-8") as fh:
fh.write(f"FCSTD_PATH = {json.dumps(fcstd_path)}\n")
fh.write(f"OUT_PATH = {json.dumps(out_path)}\n")
fh.write(probe_source)
subprocess.run(
[freecad_cmd, probe_path],
check=True,
text=True,
capture_output=True,
)
with open(out_path, "r", encoding="utf-8") as fh:
return json.load(fh)
def _inspect_fcstd_gui_visibility(self, payload: str) -> dict:
freecad_cmd = self._discover_freecadcmd()
if not freecad_cmd:
self.skipTest("freecadcmd not available")
with tempfile.TemporaryDirectory() as tmp_dir:
fcstd_path = os.path.join(tmp_dir, "model.FCStd")
freecad_translator.translate_model_json_to_fcstd(
payload, fcstd_path, freecad_cmd=freecad_cmd
)
with zipfile.ZipFile(fcstd_path, "r") as archive:
names = set(archive.namelist())
gui_xml = archive.read("GuiDocument.xml")
root = ET.fromstring(gui_xml)
visibility = {}
expanded = {}
shape_colors = {}
override_material = {}
view_provider_data = root.find("ViewProviderData")
if view_provider_data is None:
return {
"entries": names,
"visibility": visibility,
"expanded": expanded,
"shape_colors": shape_colors,
"override_material": override_material,
}
for view_provider in view_provider_data.findall("ViewProvider"):
name = str(view_provider.attrib.get("name", ""))
expanded[name] = view_provider.attrib.get("expanded") == "1"
properties = view_provider.find("Properties")
if properties is None:
continue
for prop in properties.findall("Property"):
prop_name = prop.attrib.get("name")
if prop_name == "Visibility":
bool_value = prop.find("Bool")
visibility[name] = (
bool_value is not None
and bool_value.attrib.get("value", "").lower() == "true"
)
elif prop_name == "OverrideMaterial":
bool_value = prop.find("Bool")
override_material[name] = (
bool_value is not None
and bool_value.attrib.get("value", "").lower() == "true"
)
elif prop_name == "ShapeColor":
color_value = prop.find("PropertyColor")
if color_value is not None:
shape_colors[name] = int(color_value.attrib["value"])
elif prop_name == "ShapeMaterial":
material_value = prop.find("PropertyMaterial")
if material_value is not None:
shape_colors[name] = int(material_value.attrib["diffuseColor"])
return {
"entries": names,
"visibility": visibility,
"expanded": expanded,
"shape_colors": shape_colors,
"override_material": override_material,
}
def _expression_payload(self, payload: str) -> dict:
payload_obj = json.loads(payload)
payload_obj["expression_graph"] = {"nodes": []}
return payload_obj
def test_translate_model_json_emits_freecad_api_script_for_steps(self):
with GraphSession() as session:
box = scad.make_box_rsolid(2.0, 3.0, 4.0)
scad.translate_shape(box, (1.0, 2.0, 3.0))
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
self.assertIn("import FreeCAD as App", script)
self.assertIn("Sketcher::SketchObject", script)
self.assertIn("Part::Extrusion", 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_preserves_part_assembly_semantics_in_script(self):
with GraphSession() as session:
body = scad.make_box_rsolid(2.0, 3.0, 1.0)
material = scad.make_material_rmaterial(
"steel_8_8", density=7.85e-6, density_unit="kg/mm^3"
)
part = scad.assign_material_rpart(scad.make_part_rpart("plate", body), material)
assembly = scad.make_assembly_rassembly("fixture")
assembly = scad.add_component_rassembly(
assembly,
part,
component_id="plate_1",
placement=scad.identity_placement_rplacement(),
)
scad.make_compound_from_assembly_rcompound(assembly)
script = freecad_translator.translate_model_json_to_freecad_script(scad.export_model_json(session))
self.assertIn("PRODUCT_VALUES = {}", script)
self.assertIn("import Assembly", script)
self.assertIn("Assembly::AssemblyObject", script)
self.assertIn("SimpleCADPartId", script)
self.assertIn("SimpleCADMaterial", script)
self.assertIn("SimpleCADAssemblyId", script)
self.assertIn("SimpleCADComponentId", script)
self.assertIn("Part.makeCompound", script)
def test_translate_model_json_assigns_external_material_params_in_script(self):
material = scad.make_material_rmaterial(
"external_aluminum_6061",
name="External 6061 aluminum",
density=2.7e-6,
density_unit="kg/mm^3",
color=(0.2, 0.4, 0.6),
)
with GraphSession() as session:
for part_id in ("external_material_plate_a", "external_material_plate_b"):
body = scad.make_box_rsolid(2.0, 3.0, 1.0)
part = scad.make_part_rpart(part_id, body)
scad.assign_material_rpart(part, material)
payload = json.loads(scad.export_model_json(session))
assign_nodes = [
node
for node in payload["graph"]["nodes"]
if node["op"] == "make_assign_material_rpart"
]
self.assertEqual(len(assign_nodes), 2)
self.assertTrue(all(len(node["inputs"]) == 1 for node in assign_nodes))
expected_material = {
"material_id": "external_aluminum_6061",
"name": "External 6061 aluminum",
"density": 2.7e-6,
"density_unit": "kg/mm^3",
"color": [0.2, 0.4, 0.6],
}
self.assertEqual(
[node["params"]["material"] for node in assign_nodes],
[expected_material, expected_material],
)
replayed = scad.replay_model_json(json.dumps(payload))
self.assertEqual(len(replayed), 2)
self.assertTrue(
all(part.material.to_dict() == material.to_dict() for part in replayed)
)
script = freecad_translator.translate_model_json_to_freecad_script(json.dumps(payload))
self.assertIn("SimpleCADMaterial", script)
self.assertIn("_material = _material_from_assignment_params(", script)
self.assertIn("App::MaterialObjectPython", script)
self.assertIn("SimpleCADMaterialObject", script)
self.assertIn("GUI_SHAPE_COLOR_BY_NAME", script)
def test_translate_model_json_emits_forwarded_connector_datums_in_script(self):
with GraphSession() as session:
body = scad.make_box_rsolid(1.0, 1.0, 1.0)
part = scad.make_part_rpart("translator_connector_part", body)
axis = scad.make_placement_connector_rconnector(
"axis",
scad.make_placement_rplacement(origin=(2.0, 0.0, 0.0)),
)
part = scad.add_connector_rpart(part, axis)
child = scad.make_assembly_rassembly("translator_connector_child")
child = scad.add_component_rassembly(
child,
part,
component_id="inner",
placement=scad.make_placement_rplacement(origin=(5.0, 0.0, 0.0)),
)
scad.forward_connector_rassembly(
child,
connector_id="public_axis",
source_component_id="inner",
source_connector_id="axis",
)
script = freecad_translator.translate_model_json_to_freecad_script(scad.export_model_json(session))
self.assertIn("make_placement_connector_rconnector", script)
self.assertIn("make_forward_connector_rassembly", script)
self.assertIn("_materialize_product_connector_datums", script)
self.assertIn("PartDesign::CoordinateSystem", script)
self.assertIn("public_axis", script)
def test_translate_model_json_emits_native_constraint_joints_in_script(self):
with GraphSession() as session:
body = scad.make_box_rsolid(1.0, 1.0, 1.0)
top_face = ql.faces().resolve(body)[-1]
connector = scad.make_face_connector_rconnector("axis", top_face)
part = scad.add_connector_rpart(scad.make_part_rpart("block", body), connector)
assembly = scad.make_assembly_rassembly("fixture")
assembly = scad.add_component_rassembly(
assembly,
part,
component_id="base",
placement=scad.identity_placement_rplacement(),
)
assembly = scad.add_component_rassembly(
assembly,
part,
component_id="slider",
placement=scad.identity_placement_rplacement(),
)
assembly = scad.ground_component_rassembly(assembly, "base")
assembly = scad.add_prismatic_constraint_rassembly(
assembly,
"slide",
scad.make_connector_ref_rconnectorref("base", "axis"),
scad.make_connector_ref_rconnectorref("slider", "axis"),
drive_distance=3.0,
)
scad.solve_assembly_constraints_rassembly(assembly)
script = freecad_translator.translate_model_json_to_freecad_script(scad.export_model_json(session))
self.assertIn("import JointObject", script)
self.assertIn("JointObject.Joint", script)
self.assertIn("SimpleCADConstraint", script)
self.assertIn("SimpleCADConstraintTranslationStatus", script)
self.assertIn("'Slider'", script)
def test_translate_model_json_emits_native_coupling_joints_in_script(self):
with GraphSession() as session:
body = scad.make_box_rsolid(1.0, 1.0, 1.0)
top_face = ql.faces().resolve(body)[-1]
x_edge = ql.edges().resolve(body)[8]
axis_connector = scad.make_face_connector_rconnector("axis_z", top_face)
slide_connector = scad.make_edge_connector_rconnector("slide_x", x_edge, flip=True)
part = scad.make_part_rpart("coupler_block", body)
part = scad.add_connector_rpart(part, axis_connector)
part = scad.add_connector_rpart(part, slide_connector)
assembly = scad.make_assembly_rassembly("coupler_fixture")
placements = {
"ground_gear_a": (0.0, 0.0, 0.0),
"gear_a": (0.0, 0.0, 0.0),
"ground_gear_b": (3.0, 0.0, 0.0),
"gear_b": (3.0, 0.0, 0.0),
"ground_pulley_a": (0.0, 10.0, 0.0),
"pulley_a": (0.0, 10.0, 0.0),
"ground_pulley_b": (7.0, 10.0, 0.0),
"pulley_b": (7.0, 10.0, 0.0),
"ground_rack": (0.0, 20.0, 0.0),
"rack": (0.0, 20.0, 0.0),
"ground_pinion": (0.0, 25.0, 0.0),
"pinion": (0.0, 25.0, 0.0),
}
for component_id, origin in placements.items():
assembly = scad.add_component_rassembly(
assembly,
part,
component_id=component_id,
placement=scad.make_placement_rplacement(origin=origin),
)
for component_id in placements:
if component_id.startswith("ground_"):
assembly = scad.ground_component_rassembly(assembly, component_id)
ground_gear_a_ref = scad.make_connector_ref_rconnectorref("ground_gear_a", "axis_z")
ground_gear_b_ref = scad.make_connector_ref_rconnectorref("ground_gear_b", "axis_z")
ground_pulley_a_ref = scad.make_connector_ref_rconnectorref("ground_pulley_a", "axis_z")
ground_pulley_b_ref = scad.make_connector_ref_rconnectorref("ground_pulley_b", "axis_z")
ground_rack_ref = scad.make_connector_ref_rconnectorref("ground_rack", "slide_x")
ground_pinion_ref = scad.make_connector_ref_rconnectorref("ground_pinion", "axis_z")
gear_a_ref = scad.make_connector_ref_rconnectorref("gear_a", "axis_z")
gear_b_ref = scad.make_connector_ref_rconnectorref("gear_b", "axis_z")
pulley_a_ref = scad.make_connector_ref_rconnectorref("pulley_a", "axis_z")
pulley_b_ref = scad.make_connector_ref_rconnectorref("pulley_b", "axis_z")
rack_ref = scad.make_connector_ref_rconnectorref("rack", "slide_x")
pinion_ref = scad.make_connector_ref_rconnectorref("pinion", "axis_z")
assembly = scad.add_revolute_constraint_rassembly(
assembly, "gear_a_axis", ground_gear_a_ref, gear_a_ref, drive_angle_degrees=90.0,
)
assembly = scad.add_revolute_constraint_rassembly(
assembly, "gear_b_axis", ground_gear_b_ref, gear_b_ref,
)
assembly = scad.add_revolute_constraint_rassembly(
assembly, "pulley_a_axis", ground_pulley_a_ref, pulley_a_ref, drive_angle_degrees=90.0,
)
assembly = scad.add_revolute_constraint_rassembly(
assembly, "pulley_b_axis", ground_pulley_b_ref, pulley_b_ref,
)
assembly = scad.add_prismatic_constraint_rassembly(
assembly, "rack_slide", ground_rack_ref, rack_ref,
)
assembly = scad.add_revolute_constraint_rassembly(
assembly, "pinion_axis", ground_pinion_ref, pinion_ref,
)
assembly = scad.add_gear_constraint_rassembly(
assembly,
"gear_mesh",
gear_a_ref,
gear_b_ref,
pitch_radius_a=1.0,
pitch_radius_b=2.0,
)
assembly = scad.add_belt_constraint_rassembly(
assembly,
"belt_loop",
pulley_a_ref,
pulley_b_ref,
pulley_radius_a=3.0,
pulley_radius_b=4.0,
)
assembly = scad.add_rack_pinion_constraint_rassembly(
assembly,
"rack_mesh",
rack_ref,
pinion_ref,
pitch_radius=5.0,
)
scad.make_compound_from_assembly_rcompound(assembly)
script = freecad_translator.translate_model_json_to_freecad_script(scad.export_model_json(session))
self.assertIn("'Gears'", script)
self.assertIn("'Belt'", script)
self.assertIn("'RackPinion'", script)
self.assertIn("'constraint_kind': 'gear'", script)
self.assertIn("'constraint_kind': 'belt'", script)
self.assertIn("'constraint_kind': 'rack_pinion'", script)
self.assertIn("joint.Distance2 = float(constraint_payload.get('pitch_radius_b'))", script)
self.assertIn("joint.Distance2 = float(constraint_payload.get('pulley_radius_b'))", script)
self.assertIn("joint.Distance = float(constraint_payload.get('pitch_radius'))", script)
def test_translate_model_json_coupling_fcstd_contains_native_joint_properties(self):
with GraphSession() as session:
body = scad.make_box_rsolid(1.0, 1.0, 1.0)
top_face = ql.faces().resolve(body)[-1]
x_edge = ql.edges().resolve(body)[8]
axis_connector = scad.make_face_connector_rconnector("axis_z", top_face)
slide_connector = scad.make_edge_connector_rconnector("slide_x", x_edge, flip=True)
part = scad.make_part_rpart("coupler_block", body)
part = scad.add_connector_rpart(part, axis_connector)
part = scad.add_connector_rpart(part, slide_connector)
assembly = scad.make_assembly_rassembly("coupler_fixture")
placements = {
"ground_gear_a": (0.0, 0.0, 0.0),
"gear_a": (0.0, 0.0, 0.0),
"ground_gear_b": (3.0, 0.0, 0.0),
"gear_b": (3.0, 0.0, 0.0),
"ground_pulley_a": (0.0, 10.0, 0.0),
"pulley_a": (0.0, 10.0, 0.0),
"ground_pulley_b": (7.0, 10.0, 0.0),
"pulley_b": (7.0, 10.0, 0.0),
"ground_rack": (0.0, 20.0, 0.0),
"rack": (0.0, 20.0, 0.0),
"ground_pinion": (0.0, 25.0, 0.0),
"pinion": (0.0, 25.0, 0.0),
}
for component_id, origin in placements.items():
assembly = scad.add_component_rassembly(
assembly,
part,
component_id=component_id,
placement=scad.make_placement_rplacement(origin=origin),
)
for component_id in placements:
if component_id.startswith("ground_"):
assembly = scad.ground_component_rassembly(assembly, component_id)
ground_gear_a_ref = scad.make_connector_ref_rconnectorref("ground_gear_a", "axis_z")
ground_gear_b_ref = scad.make_connector_ref_rconnectorref("ground_gear_b", "axis_z")
ground_pulley_a_ref = scad.make_connector_ref_rconnectorref("ground_pulley_a", "axis_z")
ground_pulley_b_ref = scad.make_connector_ref_rconnectorref("ground_pulley_b", "axis_z")
ground_rack_ref = scad.make_connector_ref_rconnectorref("ground_rack", "slide_x")
ground_pinion_ref = scad.make_connector_ref_rconnectorref("ground_pinion", "axis_z")
gear_a_ref = scad.make_connector_ref_rconnectorref("gear_a", "axis_z")
gear_b_ref = scad.make_connector_ref_rconnectorref("gear_b", "axis_z")
pulley_a_ref = scad.make_connector_ref_rconnectorref("pulley_a", "axis_z")
pulley_b_ref = scad.make_connector_ref_rconnectorref("pulley_b", "axis_z")
rack_ref = scad.make_connector_ref_rconnectorref("rack", "slide_x")
pinion_ref = scad.make_connector_ref_rconnectorref("pinion", "axis_z")
assembly = scad.add_revolute_constraint_rassembly(
assembly, "gear_a_axis", ground_gear_a_ref, gear_a_ref, drive_angle_degrees=90.0,
)
assembly = scad.add_revolute_constraint_rassembly(
assembly, "gear_b_axis", ground_gear_b_ref, gear_b_ref,
)
assembly = scad.add_revolute_constraint_rassembly(
assembly, "pulley_a_axis", ground_pulley_a_ref, pulley_a_ref, drive_angle_degrees=90.0,
)
assembly = scad.add_revolute_constraint_rassembly(
assembly, "pulley_b_axis", ground_pulley_b_ref, pulley_b_ref,
)
assembly = scad.add_prismatic_constraint_rassembly(
assembly, "rack_slide", ground_rack_ref, rack_ref,
)
assembly = scad.add_revolute_constraint_rassembly(
assembly, "pinion_axis", ground_pinion_ref, pinion_ref,
)
assembly = scad.add_gear_constraint_rassembly(
assembly,
"gear_mesh",
gear_a_ref,
gear_b_ref,
pitch_radius_a=1.0,
pitch_radius_b=2.0,
)
assembly = scad.add_belt_constraint_rassembly(
assembly,
"belt_loop",
pulley_a_ref,
pulley_b_ref,
pulley_radius_a=3.0,
pulley_radius_b=4.0,
)
assembly = scad.add_rack_pinion_constraint_rassembly(
assembly,
"rack_mesh",
rack_ref,
pinion_ref,
pitch_radius=5.0,
)
scad.make_compound_from_assembly_rcompound(assembly)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
payload = {}
for obj in doc.Objects:
if not hasattr(obj, 'SimpleCADConstraint'):
continue
constraint = json.loads(obj.SimpleCADConstraint)
kind = constraint.get('constraint_kind')
if kind not in {'gear', 'belt', 'rack_pinion'}:
continue
payload[kind] = {
'joint_type': str(getattr(obj, 'JointType', '')),
'distance': round(float(getattr(obj, 'Distance', 0.0)), 6),
'distance2': round(float(getattr(obj, 'Distance2', 0.0)), 6),
'status': obj.SimpleCADConstraintTranslationStatus,
}
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump(payload, fh)
"""
result = self._inspect_fcstd_json(scad.export_model_json(session), probe)
self.assertEqual(result["gear"]["joint_type"], "Gears")
self.assertEqual(result["gear"]["distance"], 1.0)
self.assertEqual(result["gear"]["distance2"], 2.0)
self.assertEqual(result["belt"]["joint_type"], "Belt")
self.assertEqual(result["belt"]["distance"], 3.0)
self.assertEqual(result["belt"]["distance2"], 4.0)
self.assertEqual(result["rack_pinion"]["joint_type"], "RackPinion")
self.assertEqual(result["rack_pinion"]["distance"], 5.0)
self.assertEqual(
{entry["status"] for entry in result.values()},
{"native_equivalent"},
)
def test_translate_model_json_constraint_fcstd_contains_native_joint_metadata(self):
with GraphSession() as session:
body = scad.make_box_rsolid(1.0, 1.0, 1.0)
top_face = ql.faces().resolve(body)[-1]
connector = scad.make_face_connector_rconnector("axis", top_face)
part = scad.add_connector_rpart(scad.make_part_rpart("block", body), connector)
assembly = scad.make_assembly_rassembly("fixture")
assembly = scad.add_component_rassembly(
assembly,
part,
component_id="base",
placement=scad.identity_placement_rplacement(),
)
assembly = scad.add_component_rassembly(
assembly,
part,
component_id="slider",
placement=scad.identity_placement_rplacement(),
)
assembly = scad.ground_component_rassembly(assembly, "base")
assembly = scad.add_prismatic_constraint_rassembly(
assembly,
"slide",
scad.make_connector_ref_rconnectorref("base", "axis"),
scad.make_connector_ref_rconnectorref("slider", "axis"),
drive_distance=3.0,
)
scad.solve_assembly_constraints_rassembly(assembly)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
joints = [obj for obj in doc.Objects if hasattr(obj, 'SimpleCADConstraint')]
links = [obj for obj in doc.Objects if hasattr(obj, 'SimpleCADComponentId')]
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'joint_count': len(joints),
'joint_types': [str(getattr(obj, 'JointType', '')) for obj in joints],
'statuses': [obj.SimpleCADConstraintTranslationStatus for obj in joints],
'constraint_kinds': [json.loads(obj.SimpleCADConstraint)['constraint_kind'] for obj in joints],
'slider_z': {obj.SimpleCADComponentId: round(float(obj.Placement.Base.z), 3) for obj in links},
}, fh)
"""
result = self._inspect_fcstd_json(scad.export_model_json(session), probe)
self.assertEqual(result["joint_count"], 1)
self.assertEqual(result["joint_types"], ["Slider"])
self.assertEqual(result["statuses"], ["native_equivalent"])
self.assertEqual(result["constraint_kinds"], ["prismatic"])
self.assertEqual(result["slider_z"]["slider"], 3.0)
def test_translate_model_json_forwarded_connector_joint_references_components(self):
with GraphSession() as session:
shaft_body = scad.make_cylinder_rsolid(
radius=1.0,
height=4.0,
bottom_face_center=(0.0, 0.0, -2.0),
axis=(0.0, 0.0, 1.0),
)
shaft = scad.make_part_rpart("shaft", shaft_body)
shaft_axis = scad.make_placement_connector_rconnector(
"axis",
scad.make_placement_rplacement(origin=(0.0, 0.0, 0.0)),
)
shaft = scad.add_connector_rpart(shaft, shaft_axis)
inner_body = scad.make_box_rsolid(1.0, 1.0, 1.0)
inner = scad.make_part_rpart("bearing_inner", inner_body)
inner_axis = scad.make_placement_connector_rconnector(
"axis",
scad.make_placement_rplacement(origin=(0.0, 0.0, 0.0)),
)
inner = scad.add_connector_rpart(inner, inner_axis)
bearing = scad.make_assembly_rassembly("bearing_unit")
bearing = scad.add_component_rassembly(
bearing,
inner,
component_id="inner_ring",
placement=scad.identity_placement_rplacement(),
)
bearing = scad.forward_connector_rassembly(
bearing,
connector_id="inner_axis",
source_component_id="inner_ring",
source_connector_id="axis",
)
assembly = scad.make_assembly_rassembly("fixture")
assembly = scad.add_component_rassembly(
assembly,
shaft,
component_id="shaft",
placement=scad.identity_placement_rplacement(),
)
assembly = scad.add_component_rassembly(
assembly,
bearing,
component_id="bearing",
placement=scad.identity_placement_rplacement(),
)
assembly = scad.ground_component_rassembly(assembly, "shaft")
assembly = scad.add_revolute_constraint_rassembly(
assembly,
"shaft_to_bearing_inner",
scad.make_connector_ref_rconnectorref("shaft", "axis"),
scad.make_connector_ref_rconnectorref("bearing", "inner_axis"),
)
scad.solve_assembly_constraints_rassembly(assembly)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
joints = [obj for obj in doc.Objects if hasattr(obj, 'SimpleCADConstraint')]
def ref_payload(ref):
if not ref:
return {'component_id': '', 'type_id': '', 'subs': []}
obj = ref[0]
return {
'component_id': str(getattr(obj, 'SimpleCADComponentId', '')),
'type_id': str(getattr(obj, 'TypeId', '')),
'subs': [str(sub) for sub in list(ref[1])],
}
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
joint = joints[0]
json.dump({
'joint_count': len(joints),
'status': joint.SimpleCADConstraintTranslationStatus,
'kind': json.loads(joint.SimpleCADConstraint)['constraint_kind'],
'ref1': ref_payload(getattr(joint, 'Reference1', None)),
'ref2': ref_payload(getattr(joint, 'Reference2', None)),
'placement1_base': [
round(float(joint.Placement1.Base.x), 6),
round(float(joint.Placement1.Base.y), 6),
round(float(joint.Placement1.Base.z), 6),
],
'placement2_base': [
round(float(joint.Placement2.Base.x), 6),
round(float(joint.Placement2.Base.y), 6),
round(float(joint.Placement2.Base.z), 6),
],
}, fh)
"""
result = self._inspect_fcstd_json(scad.export_model_json(session), probe)
self.assertEqual(result["joint_count"], 1)
self.assertEqual(result["status"], "native_equivalent")
self.assertEqual(result["kind"], "revolute")
self.assertEqual(result["ref1"]["component_id"], "shaft")
self.assertEqual(result["ref2"]["component_id"], "bearing")
self.assertTrue(result["ref1"]["subs"][0].startswith("connector_axis"))
self.assertTrue(result["ref2"]["subs"][0].startswith("connector_inner_axis"))
def test_translate_model_json_fcstd_resolves_complex_face_connector(self):
with GraphSession() as session:
gear = scad.std.gear.make_herringbone_gear_rsolid(
n_teeth=18,
module=1.5,
helix_angle=25.0,
gear_height=8.0,
)
top_face = max(ql.select(gear.get_faces()).all(), key=lambda face: face.get_center().z)
connector = scad.make_face_connector_rconnector("axis", top_face)
part = scad.add_connector_rpart(scad.make_part_rpart("gear", gear), connector)
assembly = scad.make_assembly_rassembly("gear_fixture")
assembly = scad.add_component_rassembly(
assembly,
part,
component_id="base",
placement=scad.identity_placement_rplacement(),
)
assembly = scad.add_component_rassembly(
assembly,
part,
component_id="follower",
placement=scad.make_placement_rplacement(origin=(5.0, 0.0, 0.0)),
)
assembly = scad.ground_component_rassembly(assembly, "base")
assembly = scad.add_fixed_constraint_rassembly(
assembly,
"gear_face_fixed",
scad.make_connector_ref_rconnectorref("base", "axis"),
scad.make_connector_ref_rconnectorref("follower", "axis"),
)
assembly = scad.solve_assembly_constraints_rassembly(assembly)
scad.make_compound_from_assembly_rcompound(assembly)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
joints = [obj for obj in doc.Objects if hasattr(obj, 'SimpleCADConstraint')]
links = [obj for obj in doc.Objects if hasattr(obj, 'SimpleCADComponentId')]
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'joint_count': len(joints),
'statuses': [obj.SimpleCADConstraintTranslationStatus for obj in joints],
'refs': [str(getattr(obj, 'Reference1', '')) + str(getattr(obj, 'Reference2', '')) for obj in joints],
'link_x': {obj.SimpleCADComponentId: round(float(obj.Placement.Base.x), 3) for obj in links},
}, fh)
"""
result = self._inspect_fcstd_json(scad.export_model_json(session), probe)
self.assertEqual(result["joint_count"], 1)
self.assertEqual(result["statuses"], ["native_equivalent"])
self.assertIn("connector_axis", result["refs"][0])
self.assertEqual(result["link_x"], {"base": 0.0, "follower": 0.0})
def test_translate_model_json_part_assembly_fcstd_valid(self):
with GraphSession() as session:
body = scad.make_box_rsolid(2.0, 3.0, 1.0)
material = scad.make_material_rmaterial(
"steel_8_8", density=7.85e-6, density_unit="kg/mm^3"
)
part = scad.assign_material_rpart(scad.make_part_rpart("plate", body), material)
assembly = scad.make_assembly_rassembly("fixture")
assembly = scad.add_component_rassembly(
assembly,
part,
component_id="plate_1",
placement=scad.make_placement_rplacement(origin=(5.0, 0.0, 0.0)),
)
scad.make_compound_from_assembly_rcompound(assembly)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
parts = [obj for obj in doc.Objects if obj.TypeId == 'App::Part' and hasattr(obj, 'SimpleCADPartId')]
assemblies = [obj for obj in doc.Objects if obj.TypeId == 'Assembly::AssemblyObject' and hasattr(obj, 'SimpleCADAssemblyId')]
components = [obj for obj in doc.Objects if obj.TypeId == 'App::Link' and hasattr(obj, 'SimpleCADComponentId')]
compound_objs = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_compound_from_assembly_rcompound']
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'part_ids': [obj.SimpleCADPartId for obj in parts],
'assembly_ids': [obj.SimpleCADAssemblyId for obj in assemblies],
'assembly_type_ids': [obj.TypeId for obj in assemblies],
'component_ids': [obj.SimpleCADComponentId for obj in components],
'component_type_ids': [obj.TypeId for obj in components],
'component_linked_type_ids': [obj.LinkedObject.TypeId for obj in components],
'component_x': [round(obj.Placement.Base.x, 3) for obj in components],
'assembly_visible': [bool(obj.Visibility) for obj in assemblies],
'compound_visible': [bool(obj.Visibility) for obj in compound_objs],
'compound_count': len(compound_objs),
'compound_volume': 0.0 if not compound_objs else float(compound_objs[-1].Shape.Volume),
}, fh)
"""
payload = scad.export_model_json(session)
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["part_ids"], ["plate"])
self.assertEqual(result["assembly_ids"], ["fixture"])
self.assertEqual(result["assembly_type_ids"], ["Assembly::AssemblyObject"])
self.assertEqual(result["component_ids"], ["plate_1"])
self.assertEqual(result["component_type_ids"], ["App::Link"])
self.assertEqual(result["component_linked_type_ids"], ["App::Part"])
self.assertEqual(result["component_x"], [5.0])
self.assertEqual(result["assembly_visible"], [True])
self.assertEqual(result["compound_visible"], [False])
self.assertEqual(result["compound_count"], 1)
self.assertGreater(result["compound_volume"], 0.0)
def test_translate_model_json_fcstd_preserves_editable_materials_and_colors(self):
blue = scad.make_material_rmaterial(
"blue_aluminum",
name="Blue aluminum",
density=2.7e-6,
density_unit="kg/mm^3",
color=(0.2, 0.4, 0.6),
)
uncolored = scad.make_material_rmaterial(
"uncolored_steel",
name="Uncolored steel",
)
with GraphSession() as session:
parts = []
for part_id, x, material in (
("blue_a", 0.0, blue),
("blue_b", 2.0, blue),
("plain", 4.0, uncolored),
):
body = scad.make_box_rsolid(
1.0, 1.0, 1.0, bottom_face_center=(x, 0.0, 0.0)
)
parts.append(
scad.assign_material_rpart(
scad.make_part_rpart(part_id, body), material
)
)
assembly = scad.make_assembly_rassembly("material_fixture")
for part in parts:
assembly = scad.add_component_rassembly(
assembly,
part,
component_id=f"{part.part_id}_1",
placement=scad.identity_placement_rplacement(),
)
scad.make_compound_from_assembly_rcompound(assembly)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
materials = [obj for obj in doc.Objects if obj.TypeId == 'App::MaterialObjectPython' and hasattr(obj, 'SimpleCADMaterialId')]
parts = [obj for obj in doc.Objects if obj.TypeId == 'App::Part' and hasattr(obj, 'SimpleCADPartId')]
bodies = [obj for obj in doc.Objects if obj.TypeId == 'Part::Feature' and hasattr(obj, 'SimpleCADSourceBodyNodeId')]
components = [obj for obj in doc.Objects if obj.TypeId == 'App::Link' and hasattr(obj, 'SimpleCADComponentId')]
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'materials': {
obj.SimpleCADMaterialId: {
'name': obj.SimpleCADMaterialName,
'density': float(obj.SimpleCADDensity) if hasattr(obj, 'SimpleCADDensity') else None,
'density_unit': obj.SimpleCADDensityUnit if hasattr(obj, 'SimpleCADDensityUnit') else None,
'color': list(obj.SimpleCADColor)[:3] if hasattr(obj, 'SimpleCADColor') else None,
'native_map': dict(obj.Material),
}
for obj in materials
},
'part_materials': {obj.SimpleCADPartId: obj.SimpleCADMaterialObject.SimpleCADMaterialId for obj in parts},
'body_materials': {obj.Name: obj.SimpleCADMaterialObject.SimpleCADMaterialId for obj in bodies},
'component_materials': {obj.SimpleCADComponentId: obj.SimpleCADMaterialObject.SimpleCADMaterialId for obj in components},
'body_names': {obj.SimpleCADMaterialId: sorted(candidate.Name for candidate in bodies if candidate.SimpleCADMaterialObject == obj) for obj in materials},
'component_names': {obj.SimpleCADMaterialId: sorted(candidate.Name for candidate in components if candidate.SimpleCADMaterialObject == obj) for obj in materials},
}, fh)
"""
payload = scad.export_model_json(session)
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(set(result["materials"]), {"blue_aluminum", "uncolored_steel"})
self.assertEqual(result["materials"]["blue_aluminum"]["name"], "Blue aluminum")
self.assertAlmostEqual(result["materials"]["blue_aluminum"]["density"], 2.7e-6)
self.assertEqual(
result["materials"]["blue_aluminum"]["density_unit"], "kg/mm^3"
)
self.assertIsNone(result["materials"]["uncolored_steel"]["density"])
self.assertIsNone(result["materials"]["uncolored_steel"]["density_unit"])
self.assertEqual(
[round(value, 3) for value in result["materials"]["blue_aluminum"]["color"]],
[0.2, 0.4, 0.6],
)
self.assertEqual(
result["materials"]["blue_aluminum"]["native_map"]["SimpleCAD.MaterialId"],
"blue_aluminum",
)
self.assertEqual(
result["part_materials"],
{
"blue_a": "blue_aluminum",
"blue_b": "blue_aluminum",
"plain": "uncolored_steel",
},
)
self.assertEqual(set(result["body_materials"].values()), {"blue_aluminum", "uncolored_steel"})
self.assertEqual(
result["component_materials"],
{
"blue_a_1": "blue_aluminum",
"blue_b_1": "blue_aluminum",
"plain_1": "uncolored_steel",
},
)
gui = self._inspect_fcstd_gui_visibility(payload)
expected_blue = int("336699ff", 16)
blue_names = (
result["body_names"]["blue_aluminum"]
+ result["component_names"]["blue_aluminum"]
)
plain_names = (
result["body_names"]["uncolored_steel"]
+ result["component_names"]["uncolored_steel"]
)
self.assertEqual(
{name: gui["shape_colors"][name] for name in blue_names},
{name: expected_blue for name in blue_names},
)
self.assertTrue(
all(gui["override_material"][name] is True for name in result["component_names"]["blue_aluminum"])
)
self.assertTrue(all(name not in gui["shape_colors"] for name in plain_names))
def test_translate_model_json_nested_assembly_uses_native_assembly_link(self):
with GraphSession() as session:
body = scad.make_box_rsolid(1.0, 1.0, 1.0)
part = scad.make_part_rpart("cube", body)
child = scad.make_assembly_rassembly("child")
child = scad.add_component_rassembly(
child,
part,
component_id="cube_1",
placement=scad.identity_placement_rplacement(),
)
root = scad.make_assembly_rassembly("root")
root = scad.add_component_rassembly(
root,
child,
component_id="child_1",
placement=scad.make_placement_rplacement(origin=(4.0, 0.0, 0.0)),
)
scad.make_compound_from_assembly_rcompound(root)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
assembly_objs = [obj for obj in doc.Objects if obj.TypeId == 'Assembly::AssemblyObject' and hasattr(obj, 'SimpleCADAssemblyId')]
subassembly_links = [obj for obj in doc.Objects if obj.TypeId == 'Assembly::AssemblyLink' and hasattr(obj, 'SimpleCADComponentId')]
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'assembly_ids': sorted(obj.SimpleCADAssemblyId 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],
'subassembly_rigid': [bool(obj.Rigid) for obj in subassembly_links],
}, fh)
"""
payload = scad.export_model_json(session)
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["assembly_ids"], ["child", "root"])
self.assertEqual(result["subassembly_component_ids"], ["child_1"])
self.assertEqual(result["subassembly_linked_type_ids"], ["Assembly::AssemblyObject"])
self.assertEqual(result["subassembly_x"], [4.0])
self.assertEqual(result["subassembly_rigid"], [True])
def test_translate_model_json_articulated_subassembly_link_is_flexible(self):
with GraphSession() as session:
body = scad.make_box_rsolid(1.0, 1.0, 1.0)
part = scad.make_part_rpart("cube", body)
connector = scad.make_placement_connector_rconnector(
"axis", scad.identity_placement_rplacement()
)
part = scad.add_connector_rpart(part, connector)
child = scad.make_assembly_rassembly("child")
for component_id in ("base", "arm"):
child = scad.add_component_rassembly(
child,
part,
component_id=component_id,
placement=scad.identity_placement_rplacement(),
)
child = scad.ground_component_rassembly(child, "base")
child = scad.add_revolute_constraint_rassembly(
child,
"pivot",
scad.make_connector_ref_rconnectorref("base", "axis"),
scad.make_connector_ref_rconnectorref("arm", "axis"),
)
child = scad.solve_assembly_constraints_rassembly(child)
child = scad.forward_connector_rassembly(
child,
connector_id="output_axis",
source_component_id="arm",
source_connector_id="axis",
)
root = scad.make_assembly_rassembly("root")
root = scad.add_component_rassembly(
root,
child,
component_id="child_1",
placement=scad.identity_placement_rplacement(),
)
root = scad.add_component_rassembly(
root,
part,
component_id="fixture",
placement=scad.identity_placement_rplacement(),
)
root = scad.ground_component_rassembly(root, "fixture")
root = scad.add_fixed_constraint_rassembly(
root,
"mount_output",
scad.make_connector_ref_rconnectorref("fixture", "axis"),
scad.make_connector_ref_rconnectorref("child_1", "output_axis"),
)
root = scad.solve_assembly_constraints_rassembly(root)
scad.make_compound_from_assembly_rcompound(root)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
links = [
obj for obj in doc.Objects
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')]
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),
'joint_visibility': [bool(obj.Visibility) for obj in all_joints],
'ground_visibility': [bool(obj.Visibility) for obj in grounds],
}, fh)
"""
result = self._inspect_fcstd_json(scad.export_model_json(session), probe)
self.assertEqual(result["count"], 1)
self.assertEqual(result["rigid"], [False])
self.assertEqual(result["output_reference_type"], "App::Link")
self.assertTrue(result["joint_visibility"])
self.assertTrue(all(visible is False for visible in result["joint_visibility"]))
self.assertTrue(result["ground_visibility"])
self.assertTrue(all(visible is False for visible in result["ground_visibility"]))
def test_translate_model_json_places_articulated_subassembly_children_once(self):
with GraphSession() as session:
body = scad.make_box_rsolid(1.0, 1.0, 1.0)
part = scad.make_part_rpart("cube", body)
connector = scad.make_placement_connector_rconnector(
"axis", scad.identity_placement_rplacement()
)
part = scad.add_connector_rpart(part, connector)
child = scad.make_assembly_rassembly("child")
for component_id in ("base", "arm"):
child = scad.add_component_rassembly(
child,
part,
component_id=component_id,
placement=scad.identity_placement_rplacement(),
)
child = scad.ground_component_rassembly(child, "base")
child = scad.add_revolute_constraint_rassembly(
child,
"pivot",
scad.make_connector_ref_rconnectorref("base", "axis"),
scad.make_connector_ref_rconnectorref("arm", "axis"),
)
child = scad.solve_assembly_constraints_rassembly(child)
root = scad.make_assembly_rassembly("root")
root = scad.add_component_rassembly(
root,
child,
component_id="child_1",
placement=scad.make_placement_rplacement(origin=(1.0, 2.0, 3.0)),
)
root = scad.place_component_rassembly(
root,
component_id="child_1",
placement=scad.make_placement_rplacement(origin=(4.0, 5.0, 6.0)),
)
scad.make_compound_from_assembly_rcompound(root)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
links = [
obj for obj in doc.Objects
if obj.TypeId == 'Assembly::AssemblyLink'
and getattr(obj, 'SimpleCADComponentId', '') == 'child_1'
]
children = [
child for child in list(links[0].Group or [])
if getattr(child, 'LinkedObject', None) is not None
and getattr(child.LinkedObject, 'SimpleCADComponentId', '') in {'base', 'arm'}
]
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'outer': [round(float(value), 3) for value in (links[0].Placement.Base.x, links[0].Placement.Base.y, links[0].Placement.Base.z)],
'children': {
child.LinkedObject.SimpleCADComponentId: [
round(float(value), 3)
for value in (child.Placement.Base.x, child.Placement.Base.y, child.Placement.Base.z)
]
for child in children
},
}, fh)
"""
result = self._inspect_fcstd_json(scad.export_model_json(session), probe)
self.assertEqual(result["outer"], [0.0, 0.0, 0.0])
self.assertEqual(
result["children"],
{"base": [4.0, 5.0, 6.0], "arm": [4.0, 5.0, 6.0]},
)
def test_translate_model_json_x_axis_cylinder_fcstd_valid(self):
with GraphSession() as session:
scad.make_cylinder_rsolid(
radius=2.0,
height=10.0,
bottom_face_center=(0.0, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
cylinders = [obj for obj in doc.Objects if obj.TypeId == 'Part::Cylinder']
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'cylinder_count': len(cylinders),
'valid': [bool(obj.Shape.isValid()) for obj in cylinders],
'volumes': [round(float(obj.Shape.Volume), 3) for obj in cylinders],
}, fh)
"""
payload = scad.export_model_json(session)
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["cylinder_count"], 1)
self.assertEqual(result["valid"], [True])
self.assertGreater(result["volumes"][0], 0.0)
def test_translate_model_json_assembly_fcstd_keeps_clean_product_tree(self):
with GraphSession() as session:
body = scad.make_cylinder_rsolid(
radius=2.0,
height=10.0,
bottom_face_center=(0.0, 0.0, 0.0),
axis=(1.0, 0.0, 0.0),
)
part = scad.make_part_rpart("rod", body, name="Rod")
assembly = scad.make_assembly_rassembly("rod_assembly", name="Rod assembly")
assembly = scad.add_component_rassembly(
assembly,
part,
component_id="rod_1",
placement=scad.identity_placement_rplacement(),
)
scad.make_compound_from_assembly_rcompound(assembly)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
loose_sketches = [obj for obj in doc.Objects if obj.TypeId == 'Sketcher::SketchObject' and not getattr(obj, 'InList', [])]
assemblies = [obj for obj in doc.Objects if obj.TypeId == 'Assembly::AssemblyObject' and hasattr(obj, 'SimpleCADAssemblyId')]
links = [obj for obj in doc.Objects if obj.TypeId == 'App::Link' and hasattr(obj, 'SimpleCADComponentId')]
parts = [obj for obj in doc.Objects if obj.TypeId == 'App::Part' and hasattr(obj, 'SimpleCADPartId')]
libraries = [obj for obj in doc.Objects if obj.Name == 'SimpleCADProductLibrary']
construction = [obj for obj in doc.Objects if obj.Name == 'SimpleCADConstruction']
compound_objs = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_compound_from_assembly_rcompound']
origins = [obj for obj in doc.Objects if obj.TypeId == 'App::Origin' or obj.Name.startswith('Origin')]
origin_children = [child for origin in origins for child in list(getattr(origin, 'OutListRecursive', []) or [])]
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'loose_sketch_count': len(loose_sketches),
'assembly_visible': [bool(obj.Visibility) for obj in assemblies],
'compound_visible': [bool(obj.Visibility) for obj in compound_objs],
'link_visible': [bool(obj.Visibility) for obj in links],
'link_group_sizes': [len(getattr(obj, 'Group', []) or []) for obj in links],
'part_group_types': [[child.TypeId for child in getattr(obj, 'Group', []) if child.TypeId != 'App::Origin'] for obj in parts],
'part_group_visible': [[bool(child.Visibility) for child in getattr(obj, 'Group', []) if child.TypeId != 'App::Origin'] for obj in parts],
'library_visible': [bool(obj.Visibility) for obj in libraries],
'construction_visible': [bool(obj.Visibility) for obj in construction],
'origin_count': len(origins),
'origin_visible': [bool(obj.Visibility) for obj in origins],
'origin_child_visible': [bool(obj.Visibility) for obj in origin_children],
}, fh)
"""
payload = scad.export_model_json(session)
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["loose_sketch_count"], 0)
self.assertEqual(result["assembly_visible"], [True])
self.assertEqual(result["compound_visible"], [False])
self.assertEqual(result["link_visible"], [True])
self.assertEqual(result["link_group_sizes"], [1])
self.assertEqual(result["part_group_types"], [["Part::Feature"]])
self.assertEqual(result["part_group_visible"], [[True]])
self.assertEqual(result["library_visible"], [False])
self.assertEqual(result["construction_visible"], [False])
self.assertGreater(result["origin_count"], 0)
self.assertTrue(all(visible is False for visible in result["origin_visible"]))
self.assertTrue(all(visible is False for visible in result["origin_child_visible"]))
gui = self._inspect_fcstd_gui_visibility(payload)
gui_visibility = gui["visibility"]
assembly_names = [
name
for name in gui_visibility
if name.startswith("make_assembly_rassembly_")
]
component_names = [
name
for name in gui_visibility
if name.startswith("make_add_component_rassembly_")
and name.endswith("_component")
]
compound_names = [
name
for name in gui_visibility
if name.startswith("make_compound_from_assembly_rcompound_")
]
self.assertIn("GuiDocument.xml", gui["entries"])
self.assertEqual([gui_visibility[name] for name in assembly_names], [True])
self.assertEqual([gui_visibility[name] for name in component_names], [True])
self.assertEqual(gui_visibility["SimpleCADProductLibrary"], False)
self.assertEqual(gui_visibility["SimpleCADConstruction"], False)
self.assertEqual([gui_visibility[name] for name in compound_names], [False])
self.assertEqual([gui["expanded"][name] for name in assembly_names], [True])
def test_translate_model_json_fcstd_multifuse_bridged_union_and_hides_connectors(self):
with GraphSession() as session:
lower = scad.make_box_rsolid(
width=10.0,
height=10.0,
depth=4.0,
bottom_face_center=(0.0, 0.0, 0.0),
)
upper = scad.make_box_rsolid(
width=10.0,
height=10.0,
depth=4.0,
bottom_face_center=(0.0, 0.0, 20.0),
)
bridge = scad.make_box_rsolid(
width=4.0,
height=4.0,
depth=24.0,
bottom_face_center=(0.0, 0.0, 0.0),
)
body = scad.union_rsolid(lower, upper, bridge, glue=False)
connector = scad.make_placement_connector_rconnector(
connector_id="axis",
placement=scad.make_placement_rplacement(origin=(0.0, 0.0, 0.0)),
)
part = scad.make_part_rpart("bridged_part", body)
part = scad.add_connector_rpart(part, connector)
assembly = scad.make_assembly_rassembly("bridged_assembly")
assembly = scad.add_component_rassembly(
assembly,
part,
component_id="bridged_1",
placement=scad.identity_placement_rplacement(),
)
scad.make_compound_from_assembly_rcompound(assembly)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
parts = [obj for obj in doc.Objects if obj.TypeId == 'App::Part' and getattr(obj, 'SimpleCADPartId', '') == 'bridged_part']
part = parts[0]
children = [child for child in getattr(part, 'Group', []) if child.TypeId != 'App::Origin']
body_children = [child for child in children if getattr(child, 'Label', '') == 'Body']
connector_children = [child for child in children if hasattr(child, 'SimpleCADConnectorId')]
body = body_children[0]
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'child_labels': [str(child.Label) for child in children],
'child_visible': [bool(child.Visibility) for child in children],
'body_solids': len(body.Shape.Solids),
'body_volume': round(float(body.Shape.Volume), 3),
'connector_visible': [bool(child.Visibility) for child in connector_children],
}, fh)
"""
result = self._inspect_fcstd_json(scad.export_model_json(session), probe)
self.assertIn("Body", result["child_labels"])
self.assertEqual(result["body_solids"], 1)
self.assertGreater(result["body_volume"], 0.0)
visible_by_label = dict(zip(result["child_labels"], result["child_visible"]))
self.assertTrue(visible_by_label["Body"])
self.assertEqual(result["connector_visible"], [False])
def test_translate_model_json_folds_single_use_translate_into_extrusion_fcstd(self):
tx = scad.var("fold_tx", 1.0)
ty = scad.var("fold_ty", 2.0)
tz = scad.var("fold_tz", 3.0)
with GraphSession() as session:
profile = scad.make_circle_rface((0.0, 0.0, 0.0), 1.0)
solid = scad.extrude_rsolid(profile, (0.0, 0.0, 1.0), 2.0)
scad.translate_shape(solid, (tx, ty, tz))
payload = scad.export_model_json(session)
script = freecad_translator.translate_model_json_to_freecad_script(payload)
self.assertIn("_register_graph_folded_alias", script)
self.assertNotIn("doc.addObject('App::Link'", 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']
links = [obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'App::Link']
extrusion = extrusions[-1]
folded = json.loads(getattr(extrusion, 'SimpleCADFoldedOps', '[]') or '[]')
exprs = list(getattr(extrusion, 'ExpressionEngine', []))
shape = extrusion.Shape
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'extrusion_count': len(extrusions),
'link_count': len(links),
'placement': [float(extrusion.Placement.Base.x), float(extrusion.Placement.Base.y), float(extrusion.Placement.Base.z)],
'folded_ops': [item.get('op') for item in folded],
'folded_count': len(folded),
'exprs': exprs,
'solid_count': 0 if shape.isNull() else len(shape.Solids),
'volume': 0.0 if shape.isNull() else float(shape.Volume),
}, fh)
"""
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["extrusion_count"], 1)
self.assertEqual(result["link_count"], 0)
self.assertEqual(result["placement"], [1.0, 2.0, 3.0])
self.assertEqual(result["folded_ops"], ["make_translate_rshape"])
self.assertEqual(result["folded_count"], 1)
self.assertEqual(result["solid_count"], 1)
self.assertAlmostEqual(result["volume"], 2.0 * 3.141592653589793, places=5)
expr_map = {prop: expr for prop, expr in result["exprs"]}
normalized_expr_map = {prop.lstrip("."): expr for prop, expr in result["exprs"]}
self.assertIn("Placement.Base.x", normalized_expr_map)
self.assertIn("Placement.Base.y", normalized_expr_map)
self.assertIn("Placement.Base.z", normalized_expr_map)
self.assertIn("var_fold_tx", normalized_expr_map["Placement.Base.x"])
self.assertIn("var_fold_ty", normalized_expr_map["Placement.Base.y"])
self.assertIn("var_fold_tz", normalized_expr_map["Placement.Base.z"])
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)
solid = scad.extrude_rsolid(profile, (0.0, 0.0, 1.0), 2.0)
scad.translate_shape(solid, (1.0, 0.0, 0.0))
scad.translate_shape(solid, (0.0, 1.0, 0.0))
payload = scad.export_model_json(session)
script = freecad_translator.translate_model_json_to_freecad_script(payload)
self.assertIn("doc.addObject('App::Link'", script)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
links = [obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'App::Link']
extrusions = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_extrude_rsolid']
folded_counts = [len(json.loads(getattr(obj, 'SimpleCADFoldedOps', '[]') or '[]')) for obj in extrusions]
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'link_count': len(links),
'link_ops': [getattr(obj, 'SimpleCADOp', '') for obj in links],
'linked_ops': [getattr(getattr(obj, 'LinkedObject', None), 'SimpleCADOp', '') for obj in links],
'folded_counts': folded_counts,
}, fh)
"""
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["link_count"], 2)
self.assertEqual(result["link_ops"], ["make_translate_rshape", "make_translate_rshape"])
self.assertEqual(result["linked_ops"], ["make_extrude_rsolid", "make_extrude_rsolid"])
self.assertEqual(result["folded_counts"], [0])
def test_translate_model_json_uses_single_low_level_graph(self):
with GraphSession() as session:
scad.make_box_rsolid(2.0, 3.0, 4.0)
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
self.assertIn("Sketcher::SketchObject", script)
self.assertIn("Part::Extrusion", script)
def test_translate_model_json_requires_graph(self):
payload = {
"schema_version": "2.0",
"canonical_contract": {"contract_version": "2.0"},
"expression_graph": {"nodes": []},
"frame_graph": {"nodes": []},
"geometry_registry": [],
"semantic_entity_registry": [],
"sketch_profile_registry": [],
"semantic_delta_log": [],
"topology_delta_log": [],
}
with self.assertRaises(ValueError):
freecad_translator.translate_model_json_to_freecad_script(json.dumps(payload))
def test_translate_model_json_emits_expression_formulas_for_ir(self):
r = scad.var("r", 5.0)
with GraphSession() as session:
face = scad.make_circle_rface((0.0, 0.0, 0.0), r)
scad.extrude_rsolid(face, (0.0, 0.0, 1.0), r * 2)
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
self.assertIn("SimpleCADExpressions", script)
self.assertIn("setAlias", script)
self.assertIn("<<SimpleCADExpressions>>", script)
self.assertIn("LengthFwd", script)
self.assertIn("OP_EXPRESSION_BINDINGS", script)
self.assertIn("_apply_op_expression_bindings", script)
self.assertIn("'make_extrude_rsolid'", script)
self.assertIn("var_r", script)
self.assertIn(
"=<<SimpleCADExpressions>>.var_r * <<SimpleCADExpressions>>.const_", script
)
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
with GraphSession() as session:
face = scad.make_circle_rface((0.0, 0.0, 0.0), x)
scad.extrude_rsolid(face, (0.0, 0.0, 1.0), expr)
payload = json.loads(scad.export_model_json(session))
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
ss = doc.getObject('SimpleCADExpressions')
data = {}
for cell in ss.getNonEmptyCells():
data[cell] = {
'alias': ss.getAlias(cell),
'contents': ss.getContents(cell),
'value': ss.get(cell),
}
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump(data, fh)
"""
result = self._inspect_fcstd_json(json.dumps(payload), probe)
self.assertEqual(result["A1"]["contents"].lstrip("'"), "var_hub_radius")
self.assertEqual(result["B1"]["alias"], "var_hub_radius")
self.assertEqual(result["B1"]["contents"], "6.5")
self.assertTrue(result["C1"]["contents"].lstrip("'").startswith("var_"))
self.assertEqual(result["D1"]["contents"].lstrip("'"), "Hub outer radius")
expr_row = next(
row
for row, entry in result.items()
if row.startswith("B") and entry["alias"].startswith("expr_")
)
self.assertIn("var_hub_radius", result[expr_row]["contents"])
self.assertIn("const_", result[expr_row]["contents"])
def test_translate_model_json_resolves_detail_feature_expressions(self):
radius = scad.var("fillet_r", 0.25)
with GraphSession() as session:
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
scad.fillet_rsolid(box, [box.get_edges(i) for i in range(2)], radius)
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
self.assertIn("Part::Fillet", script)
self.assertIn("_resolve_param_value", script)
def test_translate_model_json_resolves_pattern_expressions(self):
graph = OperationGraph(graph_id="graph_pattern")
seed = graph.add_node(
op="make_line_redge",
params={"start": [0.0, 0.0, 0.0], "end": [1.0, 0.0, 0.0]},
)
graph.add_node(
op="make_translate_rshape",
params={
"vector": [2.0, 0.0, 0.0],
},
param_exprs={"vector": [{"expr_id": "var_spacing"}, None, None]},
inputs=[seed],
)
payload = {
"schema_version": "2.0",
"canonical_contract": {"contract_version": "2.0"},
"graph": graph.to_dict(),
"leaf_ids": [leaf.node_id for leaf in graph.leaf_nodes()],
"expression_graph": {
"nodes": [
{
"expr_id": "var_spacing",
"kind": "var",
"name": "spacing",
"default": 2.0,
}
]
},
"frame_graph": {"nodes": []},
"geometry_registry": [],
"semantic_entity_registry": [],
"sketch_profile_registry": [],
"semantic_delta_log": [],
"topology_delta_log": [],
}
script = freecad_translator.translate_model_json_to_freecad_script(json.dumps(payload))
self.assertIn("_resolve_nested_param_value", script)
self.assertIn("_resolve_param_value", script)
def test_translate_model_json_resolves_helix_and_arc_expressions(self):
pitch = scad.var("pitch", 1.0)
radius = scad.var("radius", 2.0)
angle = scad.var("angle", 1.57)
with GraphSession() as session:
scad.make_helix_rwire(pitch, 3.0, radius)
scad.make_angle_arc_rwire((0.0, 0.0, 0.0), radius, 0.0, angle)
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
self.assertIn("Part::Helix", script)
self.assertIn("make_angle_arc_redge", script)
self.assertIn("_apply_op_expression_bindings", script)
self.assertIn("'Pitch'", script)
self.assertIn("'Radius'", script)
def test_translate_model_json_converts_trig_expressions_to_freecad_semantics(self):
theta = scad.var("theta", 0.5)
expr = scad.sin(theta) + scad.acos(theta)
with GraphSession() as session:
face = scad.make_circle_rface((0.0, 0.0, 0.0), 1.0)
scad.extrude_rsolid(face, (0.0, 0.0, 1.0), expr)
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
self.assertIn("sin((<<SimpleCADExpressions>>.", script)
self.assertIn("* 180 / pi)", script)
self.assertIn("=acos(<<SimpleCADExpressions>>.", script)
self.assertIn("* pi / 180", script)
def test_translate_model_json_preserves_helix_center_and_direction(self):
with GraphSession() as session:
scad.make_helix_rwire(
1.0,
3.0,
2.0,
center=(1.0, 2.0, 3.0),
dir=(0.0, 1.0, 0.0),
)
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
self.assertIn("Part::Helix", script)
self.assertIn("Placement = App.Placement", script)
def test_translate_model_json_uses_freecad_revolve_signature(self):
with GraphSession() as session:
profile = scad.make_circle_rface((2.0, 0.0, 0.0), 0.5)
scad.revolve_rsolid(
profile,
axis=(0.0, 1.0, 0.0),
angle=180.0,
origin=(1.0, 0.0, 0.0),
)
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
self.assertIn("Part::Revolution", script)
self.assertIn(".Axis = _vec(", script)
self.assertIn(".Angle = float(", script)
self.assertIn("'Angle'", script)
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)
path = scad.make_helix_rwire(1.0, 3.0, 2.0)
scad.sweep_rsolid(profile, path, is_frenet=True)
payload_obj = self._expression_payload(scad.export_model_json(session))
payload_obj["expression_graph"]["nodes"] = [
{"expr_id": "var_frenet", "kind": "var", "name": "frenet", "default": 1.0}
]
for node in payload_obj["graph"]["nodes"]:
if node["op"] == "make_sweep_rsolid":
node["param_exprs"] = {"is_frenet": {"expr_id": "var_frenet"}}
script = freecad_translator.translate_model_json_to_freecad_script(json.dumps(payload_obj))
self.assertIn("Part::Sweep", script)
self.assertIn(".Spine = _spine_object", script)
self.assertIn(".Frenet = bool(", script)
self.assertIn("'Frenet'", script)
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)
body = scad.extrude_rsolid(base, (0.0, 0.0, 1.0), 1.0)
profile = (
scad.ql.faces()
.where(scad.ql.tag("face.extrusion.end"))
.exactly(1)
.resolve(body)[0]
)
path = scad.make_segment_rwire((0.0, 0.0, 1.0), (0.0, 0.0, 2.0))
scad.sweep_rsolid(profile, path)
payload = scad.export_model_json(session)
payload_obj = json.loads(payload)
select_node = next(
node
for node in payload_obj["graph"]["nodes"]
if node["op"] == "make_select_rface"
)
sweep_node = next(
node
for node in payload_obj["graph"]["nodes"]
if node["op"] == "make_sweep_rsolid"
)
script = freecad_translator.translate_model_json_to_freecad_script(payload)
self.assertEqual(sweep_node["inputs"][0], select_node["node_id"])
self.assertIn("GRAPH_SELECTIONS = {}", script)
self.assertIn("GRAPH_SELECTIONS[node_id] = payload", script)
self.assertIn("GRAPH_SPINE_OBJECTS = {}", script)
self.assertIn("doc.addObject('Part::Feature', f'{str(op)}_{str(node_id)}')", script)
self.assertIn("obj.Shape = selected_shape", script)
self.assertIn(
f"_register_geo_selection_node(node_id={json.dumps(select_node['node_id'])}, op=\"make_select_rface\"",
script,
)
self.assertIn(
f".Sections = [GRAPH_NODES[{json.dumps(select_node['node_id'])}]]",
script,
)
self.assertIn(".Spine = _spine_object", script)
def test_translate_model_json_ql_selected_face_profile_sweep_fcstd_valid(self):
with GraphSession() as session:
base = scad.make_circle_rface((0.0, 0.0, 0.0), 0.25)
body = scad.extrude_rsolid(base, (0.0, 0.0, 1.0), 1.0)
profile = (
scad.ql.faces()
.where(scad.ql.tag("face.extrusion.end"))
.exactly(1)
.resolve(body)[0]
)
path = scad.make_segment_rwire((0.0, 0.0, 1.0), (0.0, 0.0, 2.0))
scad.sweep_rsolid(profile, path)
payload = scad.export_model_json(session)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
select_objs = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_select_rface']
sweep_objs = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_sweep_rsolid']
selected = select_objs[-1]
sweep = sweep_objs[-1]
sweep_shape = sweep.Shape
sweep_null = sweep_shape.isNull()
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'selected_count': len(select_objs),
'sweep_count': len(sweep_objs),
'selected_shape_type': selected.Shape.ShapeType,
'selected_valid': selected.Shape.isValid(),
'sweep_null': sweep_null,
'sweep_valid': False if sweep_null else sweep_shape.isValid(),
'sweep_solid_count': 0 if sweep_null else len(sweep_shape.Solids),
'sweep_volume': 0.0 if sweep_null else float(sweep_shape.Volume),
}, fh)
"""
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["selected_count"], 1)
self.assertEqual(result["sweep_count"], 1)
self.assertEqual(result["selected_shape_type"], "Face")
self.assertTrue(result["selected_valid"])
self.assertFalse(result["sweep_null"])
self.assertTrue(result["sweep_valid"])
self.assertEqual(result["sweep_solid_count"], 1)
self.assertGreater(result["sweep_volume"], 0.0)
def test_translate_model_json_uses_single_result_union_helper(self):
graph = OperationGraph(graph_id="graph_union_single")
a = graph.add_node(
op="make_line_redge",
node_id="edge_a",
params={"start": [0.0, 0.0, 0.0], "end": [1.0, 0.0, 0.0]},
)
b = graph.add_node(
op="make_line_redge",
node_id="edge_b",
params={"start": [0.0, 1.0, 0.0], "end": [1.0, 1.0, 0.0]},
)
graph.add_node(
op="make_union_rsolid",
node_id="union_out",
params={"input_count": 2},
inputs=[a, b],
)
payload = {
"schema_version": "2.0",
"canonical_contract": {"contract_version": "2.0"},
"graph": graph.to_dict(),
"leaf_ids": ["union_out"],
"expression_graph": {"nodes": []},
"frame_graph": {"nodes": []},
"geometry_registry": [],
"semantic_entity_registry": [],
"sketch_profile_registry": [],
"semantic_delta_log": [],
"topology_delta_log": [],
}
script = freecad_translator.translate_model_json_to_freecad_script(json.dumps(payload))
self.assertIn("Part::Fuse", script)
def test_translate_model_json_chains_multi_tool_cut(self):
graph = OperationGraph(graph_id="graph_cut_multi")
base = graph.add_node(
op="make_line_redge",
node_id="base_obj",
params={"start": [0.0, 0.0, 0.0], "end": [1.0, 0.0, 0.0]},
)
tool_a = graph.add_node(
op="make_line_redge",
node_id="tool_a",
params={"start": [0.0, 1.0, 0.0], "end": [1.0, 1.0, 0.0]},
)
tool_b = graph.add_node(
op="make_line_redge",
node_id="tool_b",
params={"start": [0.0, 2.0, 0.0], "end": [1.0, 2.0, 0.0]},
)
tool_c = graph.add_node(
op="make_line_redge",
node_id="tool_c",
params={"start": [0.0, 3.0, 0.0], "end": [1.0, 3.0, 0.0]},
)
graph.add_node(
op="make_cut_rsolid",
node_id="cut_out",
params={"tool_count": 3},
inputs=[base, tool_a, tool_b, tool_c],
)
payload = {
"schema_version": "2.0",
"canonical_contract": {"contract_version": "2.0"},
"graph": graph.to_dict(),
"leaf_ids": ["cut_out"],
"expression_graph": {"nodes": []},
"frame_graph": {"nodes": []},
"geometry_registry": [],
"semantic_entity_registry": [],
"sketch_profile_registry": [],
"semantic_delta_log": [],
"topology_delta_log": [],
}
script = freecad_translator.translate_model_json_to_freecad_script(json.dumps(payload))
self.assertNotIn("Part::MultiFuse", script)
self.assertIn("cut_out_step_1 = doc.addObject('Part::Cut'", script)
self.assertIn("cut_out_step_2 = doc.addObject('Part::Cut'", script)
self.assertIn("cut_out = doc.addObject('Part::Cut'", script)
self.assertIn("cut_out.Base = cut_out_step_2", script)
def test_translate_model_json_mixed_curve_sketch_closes_in_fcstd(self):
with GraphSession() as session:
edges = [
scad.make_line_redge((0.0, 0.0, 0.0), (1.0, 0.0, 0.0)),
scad.make_three_point_arc_redge(
(1.0, 0.0, 0.0),
(1.5, 0.5, 0.0),
(1.0, 1.0, 0.0),
),
scad.make_spline_redge(
control_points=[
(1.0, 1.0, 0.0),
(0.6, 1.25, 0.0),
(0.2, 1.15, 0.0),
(0.0, 1.0, 0.0),
]
),
scad.make_line_redge((0.0, 1.0, 0.0), (0.0, 0.0, 0.0)),
]
wire = scad.make_wire_from_edges_rwire(edges)
face = scad.make_face_from_wire_rface(wire)
scad.extrude_rsolid(face, (0.0, 0.0, 1.0), 2.0)
payload = scad.export_model_json(session)
probe = """
import json
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', []))),
)
shape = target.Shape
wire = shape.Wires[0]
face = Part.Face(wire)
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'shape_type': shape.ShapeType,
'wire_count': len(shape.Wires),
'edge_count': len(shape.Edges),
'wire_closed': wire.isClosed(),
'wire_valid': wire.isValid(),
'face_valid': face.isValid(),
}, fh)
"""
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["shape_type"], "Wire")
self.assertEqual(result["wire_count"], 1)
self.assertEqual(result["edge_count"], 4)
self.assertTrue(result["wire_closed"])
self.assertTrue(result["wire_valid"])
self.assertTrue(result["face_valid"])
def test_translate_model_json_2d_cut_face_extrudes_hole_fcstd_valid(self):
with GraphSession() as session:
outer = scad.make_circle_rface(center=(0.0, 0.0, 0.0), radius=5.0)
inner = scad.make_circle_rface(center=(0.0, 0.0, 0.0), radius=2.0)
ring_face = scad.make_2d_cut_rface(outer, inner)
scad.extrude_rsolid(ring_face, (0.0, 0.0, 1.0), 3.0)
payload = scad.export_model_json(session)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
cut_faces = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_2d_cut_rface']
extrusions = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_extrude_rsolid']
cut_shape = cut_faces[-1].Shape
solid_shape = extrusions[-1].Shape
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'cut_count': len(cut_faces),
'cut_shape_type': cut_shape.ShapeType,
'cut_face_count': len(cut_shape.Faces),
'cut_wire_count': len(cut_shape.Wires),
'solid_count': len(solid_shape.Solids),
'volume': float(solid_shape.Volume),
}, fh)
"""
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["cut_count"], 1)
self.assertEqual(result["cut_shape_type"], "Face")
self.assertEqual(result["cut_face_count"], 1)
self.assertEqual(result["cut_wire_count"], 2)
self.assertEqual(result["solid_count"], 1)
self.assertAlmostEqual(result["volume"], math.pi * (25.0 - 4.0) * 3.0, places=2)
def test_translate_model_json_multi_loop_face_extrudes_hole_fcstd_valid(self):
with GraphSession() as session:
outer = scad.make_circle_rwire(center=(0.0, 0.0, 0.0), radius=5.0)
inner = scad.make_circle_rwire(center=(0.0, 0.0, 0.0), radius=2.0)
ring_face = scad.make_face_from_wires_rface(outer, [inner])
scad.extrude_rsolid(ring_face, (0.0, 0.0, 1.0), 3.0)
payload = scad.export_model_json(session)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
faces = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_face_from_wires_rface']
extrusions = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_extrude_rsolid']
face_shape = faces[-1].Shape
solid_shape = extrusions[-1].Shape
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'face_count': len(faces),
'face_shape_type': face_shape.ShapeType,
'face_wire_count': len(face_shape.Wires),
'solid_count': len(solid_shape.Solids),
'volume': float(solid_shape.Volume),
}, fh)
"""
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["face_count"], 1)
self.assertEqual(result["face_shape_type"], "Face")
self.assertEqual(result["face_wire_count"], 2)
self.assertEqual(result["solid_count"], 1)
self.assertAlmostEqual(result["volume"], math.pi * (25.0 - 4.0) * 3.0, places=2)
def test_translate_model_json_multi_tool_cut_affects_fcstd_result(self):
with GraphSession() as session:
body = scad.make_cylinder_rsolid(10.0, 4.0)
hole = scad.make_cylinder_rsolid(12.0, 0.75)
hole = scad.translate_shape(hole, (2.0, 0.0, -1.0))
hole_b = scad.rotate_shape(hole, 120.0, axis=(0.0, 0.0, 1.0))
hole_c = scad.rotate_shape(hole, 240.0, axis=(0.0, 0.0, 1.0))
scad.cut_rsolid(body, hole, hole_b, hole_c)
payload = scad.export_model_json(session)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
cut_objs = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_cut_rsolid']
final_cut = cut_objs[-1]
shape = final_cut.Shape
tools = doc.getObject('make_cut_rsolid_node_' + final_cut.Name.split('_node_')[-1] + '_tools')
part_cut_objs = [obj for obj in doc.Objects if obj.TypeId == 'Part::Cut']
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'cut_count': len(cut_objs),
'part_cut_count': len(part_cut_objs),
'final_valid': shape.isValid(),
'solid_count': len(shape.Solids),
'volume': float(shape.Volume),
'has_tools_fuse': tools is not None,
'tool_shape_count': len(getattr(tools, 'Shapes', [])) if tools is not None else 0,
}, fh)
"""
result = self._inspect_fcstd_json(payload, probe)
self.assertGreaterEqual(result["cut_count"], 1)
self.assertGreaterEqual(result["part_cut_count"], 3)
self.assertTrue(result["final_valid"])
self.assertEqual(result["solid_count"], 1)
self.assertFalse(result["has_tools_fuse"])
self.assertEqual(result["tool_shape_count"], 0)
def test_translate_model_json_applies_rotated_link_operands_in_boolean_fcstd(self):
with GraphSession() as session:
hub = scad.make_cylinder_rsolid(
radius=1.0,
height=1.0,
bottom_face_center=(0.0, 0.0, 0.0),
axis=(0.0, 0.0, 1.0),
)
arm = scad.make_box_rsolid(
width=4.0,
height=0.4,
depth=1.0,
bottom_face_center=(2.0, 0.0, 0.0),
)
arm_b = scad.rotate_shape(
shape=arm,
angle=120.0,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, 5.0),
)
arm_c = scad.rotate_shape(
shape=arm,
angle=240.0,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, 5.0),
)
scad.union_rsolid([hub, arm, arm_b, arm_c], glue=False)
payload = scad.export_model_json(session)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
union_objs = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_union_rsolid']
final_union = union_objs[-1]
shape = final_union.Shape
bb = shape.BoundBox
materialized = [obj for obj in doc.Objects if hasattr(obj, 'SimpleCADMaterializedFromLink')]
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'solid_count': len(shape.Solids),
'volume': float(shape.Volume),
'bbox': [float(bb.XMin), float(bb.XMax), float(bb.YMin), float(bb.YMax), float(bb.ZMin), float(bb.ZMax)],
'materialized_count': len(materialized),
}, fh)
"""
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["solid_count"], 1)
self.assertLess(result["bbox"][2], -3.0)
self.assertGreater(result["bbox"][3], 3.0)
self.assertAlmostEqual(result["bbox"][4], 0.0, places=6)
self.assertAlmostEqual(result["bbox"][5], 1.0, places=6)
self.assertGreaterEqual(result["materialized_count"], 2)
def test_translate_model_json_emits_native_loft_feature(self):
with GraphSession() as session:
base = scad.make_rectangle_rwire(2.0, 2.0, center=(0.0, 0.0, 0.0))
top = scad.make_rectangle_rwire(1.0, 1.0, center=(0.0, 0.0, 3.0))
scad.loft_rsolid([base, top], ruled=True)
payload_obj = self._expression_payload(scad.export_model_json(session))
payload_obj["expression_graph"]["nodes"] = [
{"expr_id": "var_ruled", "kind": "var", "name": "ruled", "default": 1.0}
]
for node in payload_obj["graph"]["nodes"]:
if node["op"] == "make_loft_rsolid":
node["param_exprs"] = {"ruled": {"expr_id": "var_ruled"}}
script = freecad_translator.translate_model_json_to_freecad_script(json.dumps(payload_obj))
self.assertIn("Part::Loft", script)
self.assertIn(".Sections = [GRAPH_NODES", script)
self.assertIn(".Ruled = bool(", script)
self.assertIn("'Ruled'", script)
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)
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)
box = scad.make_box_rsolid(2.0, 2.0, 2.0)
scad.shell_rsolid(box, [box.get_faces(0)], 0.25)
payload = scad.export_model_json(session)
payload_obj = self._expression_payload(payload)
payload_obj["expression_graph"]["nodes"] = [
{"expr_id": "var_pitch", "kind": "var", "name": "pitch", "default": 1.0},
{"expr_id": "var_radius", "kind": "var", "name": "radius", "default": 2.0},
{"expr_id": "var_angle", "kind": "var", "name": "angle", "default": 180.0},
{"expr_id": "var_frenet", "kind": "var", "name": "frenet", "default": 1.0},
{
"expr_id": "var_thickness",
"kind": "var",
"name": "thickness",
"default": 0.25,
},
]
graph_nodes = payload_obj["graph"]["nodes"]
for node in graph_nodes:
if node["op"] == "make_helix_redge":
node["param_exprs"] = {
"pitch": {"expr_id": "var_pitch"},
"radius": {"expr_id": "var_radius"},
}
elif node["op"] == "make_extrude_rsolid":
node["param_exprs"] = {"distance": {"expr_id": "var_radius"}}
elif node["op"] == "make_revolve_rsolid":
node["param_exprs"] = {"angle": {"expr_id": "var_angle"}}
elif node["op"] == "make_sweep_rsolid":
node["param_exprs"] = {"is_frenet": {"expr_id": "var_frenet"}}
elif node["op"] == "make_shell_rsolid":
node["param_exprs"] = {"thickness": {"expr_id": "var_thickness"}}
expr_aliases = {
node["name"]
if node.get("kind") == "var"
else node.get("op"): self._expr_alias(node["expr_id"])
for node in payload_obj["expression_graph"]["nodes"]
if isinstance(node, dict) and node.get("expr_id")
}
payload = json.dumps(payload_obj)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
targets = {
'Part::Extrusion': ['LengthFwd'],
'Part::Revolution': ['Angle'],
'Part::Helix': ['Pitch', 'Radius'],
'Part::Sweep': ['Frenet'],
'Part::Thickness': ['Value'],
}
result = {}
for obj in doc.Objects:
props = targets.get(getattr(obj, 'TypeId', ''))
if not props:
continue
result[obj.TypeId] = list(getattr(obj, 'ExpressionEngine', []))
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump(result, fh)
"""
result = self._inspect_fcstd_json(payload, probe)
self.assertIn(
["LengthFwd", f"<<SimpleCADExpressions>>.{expr_aliases['radius']}"],
result["Part::Extrusion"],
)
self.assertIn(
["Angle", f"<<SimpleCADExpressions>>.{expr_aliases['angle']}"],
result["Part::Revolution"],
)
self.assertIn(
["Pitch", f"<<SimpleCADExpressions>>.{expr_aliases['pitch']}"],
result["Part::Helix"],
)
self.assertIn(
["Radius", f"<<SimpleCADExpressions>>.{expr_aliases['radius']}"],
result["Part::Helix"],
)
self.assertIn(
["Frenet", f"<<SimpleCADExpressions>>.{expr_aliases['frenet']}"],
result["Part::Sweep"],
)
self.assertIn(
["Value", f"<<SimpleCADExpressions>>.{expr_aliases['thickness']}"],
result["Part::Thickness"],
)
def test_translate_model_json_binds_transform_and_detail_expressions(self):
with GraphSession() as session:
box = scad.make_box_rsolid(2.0, 2.0, 2.0)
scad.translate_shape(box, (1.0, 2.0, 3.0))
scad.rotate_shape(box, 30.0, axis=(0.0, 0.0, 1.0), origin=(1.0, 0.0, 0.0))
scad.mirror_shape(
box, plane_origin=(0.0, 0.0, 0.0), plane_normal=(0.0, 0.0, 1.0)
)
scad.fillet_rsolid(box, [box.get_edges(0)], 0.2)
scad.chamfer_rsolid(box, [box.get_edges(0)], 0.3)
payload_obj = self._expression_payload(scad.export_model_json(session))
payload_obj["expression_graph"]["nodes"] = [
{"expr_id": "var_tx", "kind": "var", "name": "tx", "default": 1.0},
{"expr_id": "var_ty", "kind": "var", "name": "ty", "default": 2.0},
{"expr_id": "var_tz", "kind": "var", "name": "tz", "default": 3.0},
{"expr_id": "var_angle", "kind": "var", "name": "angle", "default": 30.0},
{"expr_id": "var_ox", "kind": "var", "name": "ox", "default": 1.0},
{"expr_id": "var_nz", "kind": "var", "name": "nz", "default": 1.0},
{"expr_id": "var_fillet", "kind": "var", "name": "fillet", "default": 0.2},
{
"expr_id": "var_chamfer",
"kind": "var",
"name": "chamfer",
"default": 0.3,
},
]
for node in payload_obj["graph"]["nodes"]:
if node["op"] == "make_translate_rshape":
node["param_exprs"] = {
"vector": [
{"expr_id": "var_tx"},
{"expr_id": "var_ty"},
{"expr_id": "var_tz"},
]
}
elif node["op"] == "make_rotate_rshape":
node["param_exprs"] = {
"origin": [{"expr_id": "var_ox"}, None, None],
"axis": [None, None, {"expr_id": "var_nz"}],
"angle": {"expr_id": "var_angle"},
}
elif node["op"] == "make_mirror_rshape":
node["param_exprs"] = {
"plane_origin": [{"expr_id": "var_ox"}, None, None],
"plane_normal": [None, None, {"expr_id": "var_nz"}],
}
elif node["op"] == "make_fillet_rsolid":
node["param_exprs"] = {"radius": {"expr_id": "var_fillet"}}
elif node["op"] == "make_chamfer_rsolid":
node["param_exprs"] = {"distance": {"expr_id": "var_chamfer"}}
expr_aliases = {
node["name"]: self._expr_alias(node["expr_id"])
for node in payload_obj["expression_graph"]["nodes"]
}
payload = json.dumps(payload_obj)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
result = {}
for obj in doc.Objects:
if getattr(obj, 'TypeId', '') in {'App::Link', 'Part::Mirroring', 'Part::Fillet', 'Part::Chamfer'}:
result.setdefault(obj.TypeId, []).append(list(getattr(obj, 'ExpressionEngine', [])))
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump(result, fh)
"""
result = self._inspect_fcstd_json(payload, probe)
link_engines = [item for group in result["App::Link"] for item in group]
mirror_engines = [item for group in result["Part::Mirroring"] for item in group]
fillet_engines = [item for group in result["Part::Fillet"] for item in group]
chamfer_engines = [item for group in result["Part::Chamfer"] for item in group]
self.assertIn(
[".Placement.Base.x", f"<<SimpleCADExpressions>>.{expr_aliases['tx']}"],
link_engines,
)
self.assertIn(
[".Placement.Base.y", f"<<SimpleCADExpressions>>.{expr_aliases['ty']}"],
link_engines,
)
self.assertIn(
[".Placement.Base.z", f"<<SimpleCADExpressions>>.{expr_aliases['tz']}"],
link_engines,
)
self.assertIn(
[
".Placement.Rotation.Angle",
f"<<SimpleCADExpressions>>.{expr_aliases['angle']}",
],
link_engines,
)
self.assertIn(
[".Base.x", f"<<SimpleCADExpressions>>.{expr_aliases['ox']}"],
mirror_engines,
)
self.assertIn(
[".Normal.z", f"<<SimpleCADExpressions>>.{expr_aliases['nz']}"],
mirror_engines,
)
self.assertIn(
["Edges[0]", f"<<SimpleCADExpressions>>.{expr_aliases['fillet']}"],
fillet_engines,
)
self.assertIn(
["Edges[0]", f"<<SimpleCADExpressions>>.{expr_aliases['chamfer']}"],
chamfer_engines,
)
def test_translate_model_json_binds_sketch_primitive_expressions(self):
graph = OperationGraph(graph_id="graph_sketch_exprs")
line = graph.add_node(
op="make_line_redge",
node_id="line_expr",
params={"start": [0.0, 0.0, 0.0], "end": [1.0, 0.0, 0.0]},
param_exprs={
"start": [{"expr_id": "var_lsx"}, {"expr_id": "var_lsy"}, None],
"end": [{"expr_id": "var_lex"}, {"expr_id": "var_ley"}, None],
},
)
circle = graph.add_node(
op="make_circle_redge",
node_id="circle_expr",
params={"center": [2.0, 3.0, 0.0], "radius": 4.0},
param_exprs={
"center": [
{"expr_id": "var_cx"},
{"expr_id": "var_cy"},
{"expr_id": "var_cz"},
],
"radius": {"expr_id": "var_cr"},
},
)
wire_line = graph.add_node(
op="make_wire_from_edges_rwire",
node_id="wire_line",
params={"edge_count": 1},
inputs=[line],
)
face_circle = graph.add_node(
op="make_face_from_wire_rface",
node_id="face_circle",
params={"edge_count": 1},
inputs=[
graph.add_node(
op="make_wire_from_edges_rwire",
node_id="wire_circle",
params={"edge_count": 1},
inputs=[circle],
)
],
)
payload = {
"schema_version": "2.0",
"canonical_contract": {"contract_version": "2.0"},
"graph": graph.to_dict(),
"leaf_ids": [wire_line.node_id, face_circle.node_id],
"expression_graph": {
"nodes": [
{
"expr_id": "var_lsx",
"kind": "var",
"name": "lsx",
"default": 0.0,
},
{
"expr_id": "var_lsy",
"kind": "var",
"name": "lsy",
"default": 0.0,
},
{
"expr_id": "var_lex",
"kind": "var",
"name": "lex",
"default": 1.0,
},
{
"expr_id": "var_ley",
"kind": "var",
"name": "ley",
"default": 0.0,
},
{"expr_id": "var_cx", "kind": "var", "name": "cx", "default": 2.0},
{"expr_id": "var_cy", "kind": "var", "name": "cy", "default": 3.0},
{"expr_id": "var_cz", "kind": "var", "name": "cz", "default": 0.0},
{"expr_id": "var_cr", "kind": "var", "name": "cr", "default": 4.0},
]
},
"frame_graph": {"nodes": []},
"geometry_registry": [],
"semantic_entity_registry": [],
"sketch_profile_registry": [],
"semantic_delta_log": [],
"topology_delta_log": [],
}
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
result = {}
for obj in doc.Objects:
if getattr(obj, 'TypeId', '') == 'Sketcher::SketchObject':
result[obj.Name] = list(getattr(obj, 'ExpressionEngine', []))
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump(result, fh)
"""
result = self._inspect_fcstd_json(json.dumps(payload), probe)
all_entries = [entry for entries in result.values() for entry in entries]
self.assertTrue(all_entries)
self.assertIn(
[".Placement.Base.x", "<<SimpleCADExpressions>>.var_lsx"],
all_entries,
)
self.assertIn(
[
"Constraints[0]",
"sqrt(pow(<<SimpleCADExpressions>>.var_lex - <<SimpleCADExpressions>>.var_lsx; 2) + pow(<<SimpleCADExpressions>>.var_ley - <<SimpleCADExpressions>>.var_lsy; 2) + pow(0 - 0; 2))",
],
all_entries,
)
self.assertIn(
[
"Constraints[1]",
"<<SimpleCADExpressions>>.var_lex - <<SimpleCADExpressions>>.var_lsx",
],
all_entries,
)
self.assertIn(
[
"Constraints[2]",
"<<SimpleCADExpressions>>.var_ley - <<SimpleCADExpressions>>.var_lsy",
],
all_entries,
)
self.assertIn(
[
"Geometry[0].EndPoint.x",
"sqrt(pow(<<SimpleCADExpressions>>.var_lex - <<SimpleCADExpressions>>.var_lsx; 2) + pow(<<SimpleCADExpressions>>.var_ley - <<SimpleCADExpressions>>.var_lsy; 2) + pow(0 - 0; 2))",
],
all_entries,
)
self.assertIn(
[".Placement.Base.x", "<<SimpleCADExpressions>>.var_cx"], all_entries
)
self.assertIn(
["Constraints[0]", "2 * <<SimpleCADExpressions>>.var_cr"], all_entries
)
def test_translate_model_json_binds_mixed_sketch_arc_radius_expressions(self):
graph = OperationGraph(graph_id="graph_mixed_arc_expr")
line = graph.add_node(
op="make_line_redge",
node_id="line_expr",
params={"start": [0.0, 0.0, 0.0], "end": [1.0, 0.0, 0.0]},
)
arc = graph.add_node(
op="make_angle_arc_redge",
node_id="arc_expr",
params={
"center": [1.0, 1.0, 0.0],
"radius": 1.0,
"start_angle": -1.5707963267948966,
"end_angle": 0.0,
},
param_exprs={"radius": {"expr_id": "var_r"}},
)
wire = graph.add_node(
op="make_wire_from_edges_rwire",
node_id="wire_expr",
params={"edge_count": 2},
inputs=[line, arc],
)
payload = {
"schema_version": "2.0",
"canonical_contract": {"contract_version": "2.0"},
"graph": graph.to_dict(),
"leaf_ids": [wire.node_id],
"expression_graph": {
"nodes": [
{"expr_id": "var_r", "kind": "var", "name": "r", "default": 1.0}
]
},
"frame_graph": {"nodes": []},
"geometry_registry": [],
"semantic_entity_registry": [],
"sketch_profile_registry": [],
"semantic_delta_log": [],
"topology_delta_log": [],
}
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
result = {}
for obj in doc.Objects:
if getattr(obj, 'TypeId', '') == 'Sketcher::SketchObject':
result[obj.Name] = list(getattr(obj, 'ExpressionEngine', []))
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump(result, fh)
"""
result = self._inspect_fcstd_json(json.dumps(payload), probe)
all_entries = [entry for entries in result.values() for entry in entries]
self.assertIn(
["Geometry[1].Radius", "<<SimpleCADExpressions>>.var_r"], all_entries
)
def test_translate_model_json_binds_mixed_sketch_angle_arc_endpoint_expressions(
self,
):
graph = OperationGraph(graph_id="graph_mixed_angle_arc_expr")
line = graph.add_node(
op="make_line_redge",
node_id="line_expr",
params={"start": [0.0, 0.0, 0.0], "end": [2.0, 0.0, 0.0]},
)
arc = graph.add_node(
op="make_angle_arc_redge",
node_id="arc_expr",
params={
"center": [2.0, 2.0, 0.0],
"radius": 2.0,
"start_angle": -1.5707963267948966,
"end_angle": 0.0,
"normal": [0.0, 0.0, 1.0],
},
param_exprs={
"center": [{"expr_id": "var_cx"}, {"expr_id": "var_cy"}, None],
"radius": {"expr_id": "var_r"},
"start_angle": {"expr_id": "var_a0"},
"end_angle": {"expr_id": "var_a1"},
},
)
wire = graph.add_node(
op="make_wire_from_edges_rwire",
node_id="wire_expr",
params={"edge_count": 2},
inputs=[line, arc],
)
payload = {
"schema_version": "2.0",
"canonical_contract": {"contract_version": "2.0"},
"graph": graph.to_dict(),
"leaf_ids": [wire.node_id],
"expression_graph": {
"nodes": [
{"expr_id": "var_cx", "kind": "var", "name": "cx", "default": 2.0},
{"expr_id": "var_cy", "kind": "var", "name": "cy", "default": 2.0},
{"expr_id": "var_r", "kind": "var", "name": "r", "default": 2.0},
{
"expr_id": "var_a0",
"kind": "var",
"name": "a0",
"default": -1.5707963267948966,
},
{"expr_id": "var_a1", "kind": "var", "name": "a1", "default": 0.0},
]
},
"frame_graph": {"nodes": []},
"geometry_registry": [],
"semantic_entity_registry": [],
"sketch_profile_registry": [],
"semantic_delta_log": [],
"topology_delta_log": [],
}
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
result = {}
for obj in doc.Objects:
if getattr(obj, 'TypeId', '') == 'Sketcher::SketchObject':
result[obj.Name] = list(getattr(obj, 'ExpressionEngine', []))
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump(result, fh)
"""
result = self._inspect_fcstd_json(json.dumps(payload), probe)
expr_map = {prop: expr for entries in result.values() for prop, expr in entries}
self.assertIn("Geometry[1].Center.x", expr_map)
self.assertIn("Geometry[1].Center.y", expr_map)
self.assertIn("Geometry[1].Radius", expr_map)
self.assertIn("Geometry[1].StartPoint.x", expr_map)
self.assertIn("Geometry[1].StartPoint.y", expr_map)
self.assertIn("Geometry[1].EndPoint.x", expr_map)
self.assertIn("Geometry[1].EndPoint.y", expr_map)
self.assertIn("<<SimpleCADExpressions>>.var_r", expr_map["Geometry[1].Radius"])
radius_constraint = next(
expr
for prop, expr in expr_map.items()
if prop.startswith("Constraints[")
and expr == "<<SimpleCADExpressions>>.var_r"
)
angle_constraint = next(
expr
for prop, expr in expr_map.items()
if prop.startswith("Constraints[")
and "<<SimpleCADExpressions>>.var_a1" in expr
and "<<SimpleCADExpressions>>.var_a0" in expr
)
self.assertEqual(radius_constraint, "<<SimpleCADExpressions>>.var_r")
self.assertIn("<<SimpleCADExpressions>>.var_a1", angle_constraint)
self.assertIn("<<SimpleCADExpressions>>.var_a0", angle_constraint)
self.assertIn(
"<<SimpleCADExpressions>>.var_a0", expr_map["Geometry[1].StartPoint.x"]
)
self.assertIn(
"<<SimpleCADExpressions>>.var_a0", expr_map["Geometry[1].StartPoint.y"]
)
self.assertIn(
"<<SimpleCADExpressions>>.var_a1", expr_map["Geometry[1].EndPoint.x"]
)
self.assertIn(
"<<SimpleCADExpressions>>.var_a1", expr_map["Geometry[1].EndPoint.y"]
)
self.assertTrue(
any(
token in expr_map["Geometry[1].StartPoint.x"]
for token in ("sin(", "cos(")
)
)
self.assertTrue(
any(
token in expr_map["Geometry[1].StartPoint.y"]
for token in ("sin(", "cos(")
)
)
self.assertTrue(
any(
token in expr_map["Geometry[1].EndPoint.x"]
for token in ("sin(", "cos(")
)
)
self.assertTrue(
any(
token in expr_map["Geometry[1].EndPoint.y"]
for token in ("sin(", "cos(")
)
)
def test_translate_model_json_exports_single_angle_arc_sketch_with_endpoint_expressions(
self,
):
graph = OperationGraph(graph_id="graph_single_angle_arc_expr")
arc = graph.add_node(
op="make_angle_arc_redge",
node_id="arc_expr",
params={
"center": [0.0, 0.0, 0.0],
"radius": 2.0,
"start_angle": 0.0,
"end_angle": 1.5707963267948966,
"normal": [0.0, 0.0, 1.0],
},
param_exprs={
"center": [{"expr_id": "var_cx"}, {"expr_id": "var_cy"}, None],
"radius": {"expr_id": "var_r"},
"start_angle": {"expr_id": "var_a0"},
"end_angle": {"expr_id": "var_a1"},
},
)
wire = graph.add_node(
op="make_wire_from_edges_rwire",
node_id="wire_expr",
params={"edge_count": 1},
inputs=[arc],
)
payload = {
"schema_version": "2.0",
"canonical_contract": {"contract_version": "2.0"},
"graph": graph.to_dict(),
"leaf_ids": [wire.node_id],
"expression_graph": {
"nodes": [
{"expr_id": "var_cx", "kind": "var", "name": "cx", "default": 0.0},
{"expr_id": "var_cy", "kind": "var", "name": "cy", "default": 0.0},
{"expr_id": "var_r", "kind": "var", "name": "r", "default": 2.0},
{"expr_id": "var_a0", "kind": "var", "name": "a0", "default": 0.0},
{
"expr_id": "var_a1",
"kind": "var",
"name": "a1",
"default": 1.5707963267948966,
},
]
},
"frame_graph": {"nodes": []},
"geometry_registry": [],
"semantic_entity_registry": [],
"sketch_profile_registry": [],
"semantic_delta_log": [],
"topology_delta_log": [],
}
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
sketches = [obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'Sketcher::SketchObject']
target = sketches[0]
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'sketch_count': len(sketches),
'exprs': list(getattr(target, 'ExpressionEngine', [])),
'geom_count': len(list(getattr(target, 'Geometry', []))),
'shape_type': target.Shape.ShapeType,
'edge_count': len(target.Shape.Edges),
}, fh)
"""
result = self._inspect_fcstd_json(json.dumps(payload), probe)
self.assertEqual(result["sketch_count"], 1)
self.assertEqual(result["geom_count"], 1)
self.assertEqual(result["shape_type"], "Wire")
self.assertEqual(result["edge_count"], 1)
expr_map = {prop: expr for prop, expr in result["exprs"]}
self.assertIn("Geometry[0].Center.x", expr_map)
self.assertIn("Geometry[0].Center.y", expr_map)
self.assertIn("Geometry[0].Radius", expr_map)
self.assertIn("Geometry[0].StartPoint.x", expr_map)
self.assertIn("Geometry[0].StartPoint.y", expr_map)
self.assertIn("Geometry[0].EndPoint.x", expr_map)
self.assertIn("Geometry[0].EndPoint.y", expr_map)
self.assertEqual(
expr_map["Geometry[0].Radius"], "<<SimpleCADExpressions>>.var_r"
)
self.assertIn(
"<<SimpleCADExpressions>>.var_a0", expr_map["Geometry[0].StartPoint.x"]
)
self.assertIn(
"<<SimpleCADExpressions>>.var_a0", expr_map["Geometry[0].StartPoint.y"]
)
self.assertIn(
"<<SimpleCADExpressions>>.var_a1", expr_map["Geometry[0].EndPoint.x"]
)
self.assertIn(
"<<SimpleCADExpressions>>.var_a1", expr_map["Geometry[0].EndPoint.y"]
)
def test_translate_model_json_marks_spline_expression_mapping_as_unsupported(
self,
):
graph = OperationGraph(graph_id="graph_spline_expr_limit")
spline = graph.add_node(
op="make_spline_redge",
node_id="spline_expr",
params={
"control_points": [
[0.0, 0.0, 0.0],
[0.6, 1.0, 0.0],
[1.4, 1.0, 0.0],
[2.0, 0.0, 0.0],
],
"degree": 3,
"knots": [0.0, 1.0],
"multiplicities": [4, 4],
"weights": None,
"periodic": False,
},
param_exprs={
"control_points": [
[None, None, None],
[None, {"expr_id": "var_sy"}, None],
[None, {"expr_id": "var_sy"}, None],
[None, None, None],
]
},
)
wire = graph.add_node(
op="make_wire_from_edges_rwire",
node_id="wire_expr",
params={"edge_count": 1},
inputs=[spline],
)
payload = {
"schema_version": "2.0",
"canonical_contract": {"contract_version": "2.0"},
"graph": graph.to_dict(),
"leaf_ids": [wire.node_id],
"expression_graph": {
"nodes": [
{"expr_id": "var_sy", "kind": "var", "name": "sy", "default": 1.0}
]
},
"frame_graph": {"nodes": []},
"geometry_registry": [],
"semantic_entity_registry": [],
"sketch_profile_registry": [],
"semantic_delta_log": [],
"topology_delta_log": [],
}
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
sketch = next(obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'Sketcher::SketchObject')
note = doc.getObject('simplecad_expression_limitations')
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'exprs': list(getattr(sketch, 'ExpressionEngine', [])),
'expr_support': getattr(sketch, 'SimpleCADExprSupport', ''),
'expr_limitation': getattr(sketch, 'SimpleCADExprLimitation', ''),
'note_payload': getattr(note, 'Payload', '') if note is not None else '',
'geom_count': len(list(getattr(sketch, 'Geometry', []))),
'edge_count': len(sketch.Shape.Edges),
}, fh)
"""
result = self._inspect_fcstd_json(json.dumps(payload), probe)
self.assertEqual(result["geom_count"], 1)
self.assertEqual(result["edge_count"], 1)
self.assertEqual(result["exprs"], [])
self.assertEqual(result["expr_support"], "limited")
self.assertIn(
"make_spline_redge",
result["expr_limitation"],
)
self.assertIn(
"no stable equivalent native FreeCAD Sketcher BSpline parameter host",
result["expr_limitation"],
)
payload_obj = json.loads(result["note_payload"])
self.assertIn("spline_expr", payload_obj)
self.assertEqual(payload_obj["spline_expr"]["op"], "make_spline_redge")
self.assertIn(
"no stable equivalent native FreeCAD Sketcher BSpline parameter host",
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:
self.skipTest("freecadcmd not available")
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir)
subprocess.run(
[
"uv",
"run",
"python",
"examples/09_naca0016_blade_freecad.py",
"--output-dir",
str(output_dir),
"--freecad-cmd",
freecad_cmd,
],
check=True,
text=True,
capture_output=True,
)
model_path = output_dir / "naca0016_blade.model.json"
fcstd_path = output_dir / "naca0016_blade.fcstd"
probe_path = output_dir / "probe_blade.py"
out_path = output_dir / "probe_blade.json"
payload = json.loads(model_path.read_text(encoding="utf-8"))
probe_path.write_text(
f"FCSTD_PATH = {json.dumps(str(fcstd_path))}\n"
f"OUT_PATH = {json.dumps(str(out_path))}\n"
"""
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
sketches = [obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'Sketcher::SketchObject']
bspline_counts = []
placements = []
for sketch in sketches:
bspline_counts.append(sum(1 for geom in getattr(sketch, 'Geometry', []) if type(geom).__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)],
})
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']
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'sketch_count': len(sketches),
'bspline_counts': bspline_counts,
'total_bspline_geometry': sum(bspline_counts),
'placements': placements,
'link_count': len(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),
}, fh)
""",
encoding="utf-8",
)
subprocess.run(
[freecad_cmd, str(probe_path)],
check=True,
text=True,
capture_output=True,
)
result = json.loads(out_path.read_text(encoding="utf-8"))
bspline_nodes = [
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["total_bspline_geometry"], 6)
self.assertTrue(all(count == 1 for count in result["bspline_counts"]))
self.assertEqual(result["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],
)
self.assertEqual(
[round(item["angle"], 6) for item in result["placements"]],
[0.0, 0.125664, 0.251327, 0.376991, 0.502655, 0.628319],
)
self.assertEqual(result["loft_count"], 1)
self.assertEqual(result["loft_solid_count"], 1)
self.assertGreater(result["loft_volume"], 0.0)
def test_translate_model_json_adds_coincident_constraints_for_polyline_wire(self):
graph = OperationGraph(graph_id="graph_polyline_constraints")
e1 = graph.add_node(
op="make_line_redge",
node_id="e1",
params={"start": [0.0, 0.0, 0.0], "end": [1.0, 0.0, 0.0]},
)
e2 = graph.add_node(
op="make_line_redge",
node_id="e2",
params={"start": [1.0, 0.0, 0.0], "end": [1.0, 1.0, 0.0]},
)
e3 = graph.add_node(
op="make_line_redge",
node_id="e3",
params={"start": [1.0, 1.0, 0.0], "end": [0.0, 0.0, 0.0]},
)
wire = graph.add_node(
op="make_wire_from_edges_rwire",
node_id="wire",
params={"edge_count": 3},
inputs=[e1, e2, e3],
)
payload = {
"schema_version": "2.0",
"canonical_contract": {"contract_version": "2.0"},
"graph": graph.to_dict(),
"leaf_ids": [wire.node_id],
"expression_graph": {"nodes": []},
"frame_graph": {"nodes": []},
"geometry_registry": [],
"semantic_entity_registry": [],
"sketch_profile_registry": [],
"semantic_delta_log": [],
"topology_delta_log": [],
}
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
sketch = next(obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'Sketcher::SketchObject')
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'constraints': [str(c) for c in sketch.Constraints],
'constraint_count': len(sketch.Constraints),
}, fh)
"""
result = self._inspect_fcstd_json(json.dumps(payload), probe)
self.assertGreaterEqual(result["constraint_count"], 3)
self.assertGreaterEqual(
sum(1 for item in result["constraints"] if "Coincident" in item), 3
)
def _functional_rectangle_sketch_model_json(self) -> str:
width = scad.var("fcstd_sketch_width", 2.0)
height = scad.var("fcstd_sketch_height", 1.0)
thickness = scad.var("fcstd_sketch_thickness", 0.5)
with GraphSession() as session:
sketch = scad.make_sketch_rsketch("fcstd_rect")
sketch = scad.add_point_rsketch(sketch, "p0", 0.0, 0.0)
sketch = scad.add_point_rsketch(sketch, "p1", width, 0.0)
sketch = scad.add_point_rsketch(sketch, "p2", width, height)
sketch = scad.add_point_rsketch(sketch, "p3", 0.0, height)
sketch = scad.add_line_rsketch(sketch, "bottom", "p0", "p1")
sketch = scad.add_line_rsketch(sketch, "right", "p1", "p2")
sketch = scad.add_line_rsketch(sketch, "top", "p2", "p3")
sketch = scad.add_line_rsketch(sketch, "left", "p3", "p0")
sketch = scad.constrain_fix_rsketch(sketch, "p0")
sketch = scad.constrain_horizontal_rsketch(sketch, "bottom")
sketch = scad.constrain_vertical_rsketch(sketch, "right")
sketch = scad.constrain_parallel_rsketch(sketch, "bottom", "top")
sketch = scad.constrain_parallel_rsketch(sketch, "left", "right")
sketch = scad.constrain_perpendicular_rsketch(sketch, "bottom", "right")
sketch = scad.constrain_distance_rsketch(sketch, "p0", "p1", width)
sketch = scad.constrain_distance_rsketch(sketch, "p0", "p3", height)
face = scad.make_face_from_sketch_rface(
sketch,
require_fully_constrained=True,
)
scad.extrude_rsolid(face, (0.0, 0.0, 1.0), thickness)
return scad.export_model_json(session)
def test_translate_model_json_supports_functional_sketch_promotion_script(self):
payload = self._functional_rectangle_sketch_model_json()
script = freecad_translator.translate_model_json_to_freecad_script(payload)
self.assertIn("_make_sketch_promotion_object", script)
self.assertIn("make_face_from_sketch_rface", script)
self.assertIn("make_add_point_rsketch", script)
self.assertIn("make_constrain_distance_rsketch", script)
self.assertIn("SimpleCADSketchSolve", script)
self.assertIn("SimpleCADSketchConstraints", script)
self.assertIn("Part::Extrusion", script)
self.assertNotIn("make_solve_sketch_rsketchresult", script)
def test_translate_model_json_functional_sketch_promotion_fcstd_valid(self):
payload = self._functional_rectangle_sketch_model_json()
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
op_objects = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_face_from_sketch_rface']
sketches = [obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'Sketcher::SketchObject' and getattr(obj, 'SimpleCADOp', '') == 'make_face_from_sketch_rface']
bridge_features = [obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'Part::Feature' and getattr(obj, 'SimpleCADOp', '') == 'make_face_from_sketch_rface']
extrusions = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_extrude_rsolid']
sketch = sketches[-1]
extrusion = extrusions[-1]
solve = json.loads(sketch.SimpleCADSketchSolve)
constraint_status = json.loads(sketch.SimpleCADSketchConstraints)
exprs = list(getattr(sketch, 'ExpressionEngine', []))
shape = extrusion.Shape
base = extrusion.Base
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'op_object_count': len(op_objects),
'sketch_count': len(sketches),
'bridge_feature_count': len(bridge_features),
'extrusion_count': len(extrusions),
'extrusion_base_name': getattr(base, 'Name', ''),
'extrusion_base_type': getattr(base, 'TypeId', ''),
'sketch_name': sketch.Name,
'geom_count': len(list(sketch.Geometry)),
'constraint_count': len(sketch.Constraints),
'mapped_count': len(constraint_status.get('mapped', [])),
'skipped_count': len(constraint_status.get('skipped', [])),
'solve_status': solve.get('status'),
'solve_dof': int(solve.get('dof', -1)),
'exprs': exprs,
'solid_count': 0 if shape.isNull() else len(shape.Solids),
'volume': 0.0 if shape.isNull() else float(shape.Volume),
}, fh)
"""
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["op_object_count"], 1)
self.assertEqual(result["sketch_count"], 1)
self.assertEqual(result["bridge_feature_count"], 0)
self.assertEqual(result["extrusion_count"], 1)
self.assertEqual(result["extrusion_base_name"], result["sketch_name"])
self.assertEqual(result["extrusion_base_type"], "Sketcher::SketchObject")
self.assertEqual(result["geom_count"], 4)
self.assertGreaterEqual(result["constraint_count"], 10)
self.assertEqual(result["mapped_count"], result["constraint_count"])
self.assertGreaterEqual(result["mapped_count"] + result["skipped_count"], 13)
self.assertEqual(result["solve_status"], "solved")
self.assertEqual(result["solve_dof"], 0)
self.assertEqual(result["solid_count"], 1)
self.assertAlmostEqual(result["volume"], 1.0, places=6)
expr_map = {prop: expr for prop, expr in result["exprs"]}
self.assertTrue(
any(
prop.startswith("Constraints[")
and "var_fcstd_sketch_width" in expr
for prop, expr in expr_map.items()
)
)
self.assertTrue(
any(
prop.startswith("Constraints[")
and "var_fcstd_sketch_height" in expr
for prop, expr in expr_map.items()
)
)
def test_translate_model_json_large_sketch_promotion_fcstd_stays_sketcher_object(self):
segment_count = 60
radius = 5.0
with GraphSession() as session:
sketch = scad.make_sketch_rsketch("large_fcstd_profile")
for idx in range(segment_count):
angle = 2.0 * math.pi * idx / segment_count
sketch = scad.add_point_rsketch(
sketch,
f"p{idx}",
radius * math.cos(angle),
radius * math.sin(angle),
)
for idx in range(segment_count):
sketch = scad.add_line_rsketch(
sketch,
f"l{idx}",
f"p{idx}",
f"p{(idx + 1) % segment_count}",
)
face = scad.make_face_from_sketch_rface(sketch)
scad.extrude_rsolid(face, (0.0, 0.0, 1.0), 0.5)
payload = scad.export_model_json(session)
script = freecad_translator.translate_model_json_to_freecad_script(payload)
self.assertNotIn("Large sketch (>50 entities)", script)
self.assertNotIn("materialised as Part::Feature for performance", script)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
sketches = [obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'Sketcher::SketchObject' and getattr(obj, 'SimpleCADOp', '') == 'make_face_from_sketch_rface']
bridge_features = [obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'Part::Feature' and getattr(obj, 'SimpleCADOp', '') == 'make_face_from_sketch_rface']
extrusions = [obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_extrude_rsolid']
sketch = sketches[-1]
extrusion = extrusions[-1]
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'sketch_count': len(sketches),
'bridge_feature_count': len(bridge_features),
'geom_count': len(list(sketch.Geometry)),
'extrusion_base_type': getattr(extrusion.Base, 'TypeId', ''),
'solid_count': 0 if extrusion.Shape.isNull() else len(extrusion.Shape.Solids),
}, fh)
"""
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["sketch_count"], 1)
self.assertEqual(result["bridge_feature_count"], 0)
self.assertEqual(result["geom_count"], segment_count)
self.assertEqual(result["extrusion_base_type"], "Sketcher::SketchObject")
self.assertEqual(result["solid_count"], 1)
def test_translate_model_json_functional_circle_sketch_promotion_fcstd_valid(self):
radius = scad.var("fcstd_circle_radius", 1.5)
thickness = scad.var("fcstd_circle_thickness", 0.5)
with GraphSession() as session:
sketch = scad.make_sketch_rsketch("fcstd_circle")
sketch = scad.add_point_rsketch(sketch, "center", 0.0, 0.0)
sketch = scad.add_circle_rsketch(sketch, "outer", "center", radius)
sketch = scad.constrain_fix_rsketch(sketch, "center")
sketch = scad.constrain_radius_rsketch(sketch, "outer", radius)
face = scad.make_face_from_sketch_rface(
sketch,
require_fully_constrained=True,
)
scad.extrude_rsolid(face, (0.0, 0.0, 1.0), thickness)
payload = scad.export_model_json(session)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
sketch = next(obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'Sketcher::SketchObject' and getattr(obj, 'SimpleCADOp', '') == 'make_face_from_sketch_rface')
extrusion = next(obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_extrude_rsolid')
constraint_status = json.loads(sketch.SimpleCADSketchConstraints)
solve = json.loads(sketch.SimpleCADSketchSolve)
exprs = list(getattr(sketch, 'ExpressionEngine', []))
shape = extrusion.Shape
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'geom_count': len(list(sketch.Geometry)),
'constraint_count': len(sketch.Constraints),
'mapped_count': len(constraint_status.get('mapped', [])),
'skipped_count': len(constraint_status.get('skipped', [])),
'solve_status': solve.get('status'),
'solve_dof': int(solve.get('dof', -1)),
'exprs': exprs,
'solid_count': 0 if shape.isNull() else len(shape.Solids),
'volume': 0.0 if shape.isNull() else float(shape.Volume),
}, fh)
"""
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["geom_count"], 1)
self.assertGreaterEqual(result["constraint_count"], 3)
self.assertEqual(result["mapped_count"], result["constraint_count"])
self.assertEqual(result["skipped_count"], 0)
self.assertEqual(result["solve_status"], "solved")
self.assertEqual(result["solve_dof"], 0)
self.assertEqual(result["solid_count"], 1)
self.assertAlmostEqual(result["volume"], 3.141592653589793 * 1.5 * 1.5 * 0.5, places=5)
expr_map = {prop: expr for prop, expr in result["exprs"]}
self.assertTrue(
any(
prop.startswith("Constraints[")
and "var_fcstd_circle_radius" in expr
for prop, expr in expr_map.items()
)
)
def test_translate_model_json_complex_guided_sketch_constraints_fcstd_valid(self):
with GraphSession() as session:
sketch = scad.make_sketch_rsketch("fcstd_guided_diamond")
sketch = scad.add_point_rsketch(sketch, "center", 10.0, 8.0)
sketch = scad.add_point_rsketch(sketch, "left", 3.0, 8.0)
sketch = scad.add_point_rsketch(sketch, "top", 10.0, 12.0)
sketch = scad.add_point_rsketch(sketch, "right", 17.0, 8.0)
sketch = scad.add_point_rsketch(sketch, "bottom", 10.0, 4.0)
sketch = scad.add_point_rsketch(sketch, "guide_upper_start", 3.0, 13.0)
sketch = scad.add_point_rsketch(sketch, "guide_upper_end", 10.0, 17.0)
sketch = scad.add_point_rsketch(sketch, "guide_lower_start", 17.0, 3.0)
sketch = scad.add_point_rsketch(sketch, "guide_lower_end", 10.0, -1.0)
sketch = scad.add_line_rsketch(sketch, "bottom_left", "left", "bottom")
sketch = scad.add_line_rsketch(sketch, "right_bottom", "bottom", "right")
sketch = scad.add_line_rsketch(sketch, "top_right", "right", "top")
sketch = scad.add_line_rsketch(sketch, "left_top", "top", "left")
sketch = scad.add_line_rsketch(sketch, "guide_upper", "guide_upper_start", "guide_upper_end", construction=True)
sketch = scad.add_line_rsketch(sketch, "guide_lower", "guide_lower_start", "guide_lower_end", construction=True)
sketch = scad.constrain_fix_rsketch(sketch, "center")
sketch = scad.constrain_distance_x_rsketch(sketch, "left", "center", 7.0)
sketch = scad.constrain_distance_y_rsketch(sketch, "left", "center", 0.0)
sketch = scad.constrain_distance_x_rsketch(sketch, "center", "right", 7.0)
sketch = scad.constrain_distance_y_rsketch(sketch, "center", "right", 0.0)
sketch = scad.constrain_distance_x_rsketch(sketch, "center", "top", 0.0)
sketch = scad.constrain_distance_y_rsketch(sketch, "center", "top", 4.0)
sketch = scad.constrain_distance_x_rsketch(sketch, "bottom", "center", 0.0)
sketch = scad.constrain_distance_y_rsketch(sketch, "bottom", "center", 4.0)
sketch = scad.constrain_parallel_rsketch(sketch, "bottom_left", "top_right")
sketch = scad.constrain_parallel_rsketch(sketch, "right_bottom", "left_top")
sketch = scad.constrain_equal_length_rsketch(sketch, "bottom_left", "right_bottom")
sketch = scad.constrain_equal_length_rsketch(sketch, "right_bottom", "top_right")
sketch = scad.constrain_equal_length_rsketch(sketch, "top_right", "left_top")
sketch = scad.constrain_distance_x_rsketch(sketch, "left", "guide_upper_start", 0.0)
sketch = scad.constrain_distance_y_rsketch(sketch, "left", "guide_upper_start", 5.0)
sketch = scad.constrain_distance_x_rsketch(sketch, "top", "guide_upper_end", 0.0)
sketch = scad.constrain_distance_y_rsketch(sketch, "top", "guide_upper_end", 5.0)
sketch = scad.constrain_distance_x_rsketch(sketch, "guide_lower_start", "right", 0.0)
sketch = scad.constrain_distance_y_rsketch(sketch, "guide_lower_start", "right", 5.0)
sketch = scad.constrain_distance_x_rsketch(sketch, "guide_lower_end", "bottom", 0.0)
sketch = scad.constrain_distance_y_rsketch(sketch, "guide_lower_end", "bottom", 5.0)
sketch = scad.constrain_parallel_rsketch(sketch, "guide_upper", "guide_lower")
sketch = scad.constrain_parallel_rsketch(sketch, "guide_upper", "right_bottom")
sketch = scad.constrain_parallel_rsketch(sketch, "guide_lower", "left_top")
sketch = scad.constrain_equal_length_rsketch(sketch, "guide_upper", "right_bottom")
sketch = scad.constrain_equal_length_rsketch(sketch, "guide_lower", "left_top")
face = scad.make_face_from_sketch_rface(
sketch,
require_fully_constrained=True,
)
scad.extrude_rsolid(face, (0.0, 0.0, 1.0), 1.0)
payload = scad.export_model_json(session)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
sketch = next(obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'Sketcher::SketchObject' and getattr(obj, 'SimpleCADOp', '') == 'make_face_from_sketch_rface')
extrusion = next(obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_extrude_rsolid')
constraint_status = json.loads(sketch.SimpleCADSketchConstraints)
solve = json.loads(sketch.SimpleCADSketchSolve)
promotion = json.loads(sketch.SimpleCADSketchPromotion)
shape = extrusion.Shape
construction_count = 0
for idx, _geo in enumerate(sketch.Geometry):
try:
construction_count += 1 if sketch.getConstruction(idx) else 0
except Exception:
pass
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'geom_count': len(list(sketch.Geometry)),
'construction_count': construction_count,
'constraint_count': len(sketch.Constraints),
'mapped_count': len(constraint_status.get('mapped', [])),
'skipped_count': len(constraint_status.get('skipped', [])),
'solve_status': solve.get('status'),
'solve_dof': int(solve.get('dof', -1)),
'promotion_edges': [edge.get('entity_id') for edge in promotion.get('edges', [])],
'solid_count': 0 if shape.isNull() else len(shape.Solids),
'volume': 0.0 if shape.isNull() else float(shape.Volume),
}, fh)
"""
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["geom_count"], 6)
self.assertEqual(result["construction_count"], 2)
self.assertGreaterEqual(result["constraint_count"], 12)
self.assertGreaterEqual(result["mapped_count"], 12)
self.assertGreaterEqual(result["mapped_count"] + result["skipped_count"], 31)
self.assertEqual(result["solve_status"], "solved")
self.assertEqual(result["solve_dof"], 0)
self.assertEqual(
result["promotion_edges"],
["bottom_left", "right_bottom", "top_right", "left_top"],
)
self.assertEqual(result["solid_count"], 1)
self.assertAlmostEqual(result["volume"], 56.0, places=5)
def test_translate_model_json_curve_guided_sketch_constraints_fcstd_valid(self):
with GraphSession() as session:
sketch = scad.make_sketch_rsketch("fcstd_curve_guided")
sketch = scad.add_point_rsketch(sketch, "center", 32.0, 42.0)
sketch = scad.add_point_rsketch(sketch, "rim", 36.0, 42.0)
sketch = scad.add_point_rsketch(sketch, "clearance_center", 32.0, 42.0)
sketch = scad.add_point_rsketch(sketch, "upper_left", 23.0, 46.0)
sketch = scad.add_point_rsketch(sketch, "upper_right", 41.0, 46.0)
sketch = scad.add_point_rsketch(sketch, "lower_left", 23.0, 38.0)
sketch = scad.add_point_rsketch(sketch, "lower_right", 41.0, 38.0)
sketch = scad.add_circle_rsketch(sketch, "relief", "center", 4.0)
sketch = scad.add_circle_rsketch(sketch, "clearance", "clearance_center", 4.0, construction=True)
sketch = scad.add_line_rsketch(sketch, "radius_probe", "center", "rim", construction=True)
sketch = scad.add_line_rsketch(sketch, "upper_rail", "upper_left", "upper_right", construction=True)
sketch = scad.add_line_rsketch(sketch, "lower_rail", "lower_left", "lower_right", construction=True)
sketch = scad.constrain_fix_rsketch(sketch, "center")
sketch = scad.constrain_radius_rsketch(sketch, "relief", 4.0)
sketch = scad.constrain_point_on_rsketch(sketch, "rim", "relief")
sketch = scad.constrain_horizontal_rsketch(sketch, "radius_probe")
sketch = scad.constrain_length_rsketch(sketch, "radius_probe", 4.0)
sketch = scad.constrain_concentric_rsketch(sketch, "relief", "clearance")
sketch = scad.constrain_equal_radius_rsketch(sketch, "relief", "clearance")
sketch = scad.constrain_horizontal_rsketch(sketch, "upper_rail")
sketch = scad.constrain_horizontal_rsketch(sketch, "lower_rail")
sketch = scad.constrain_tangent_rsketch(sketch, "upper_rail", "relief")
sketch = scad.constrain_tangent_rsketch(sketch, "lower_rail", "relief")
sketch = scad.constrain_distance_x_rsketch(sketch, "center", "upper_left", -9.0)
sketch = scad.constrain_distance_x_rsketch(sketch, "center", "upper_right", 9.0)
sketch = scad.constrain_distance_x_rsketch(sketch, "center", "lower_left", -9.0)
sketch = scad.constrain_distance_x_rsketch(sketch, "center", "lower_right", 9.0)
face = scad.make_face_from_sketch_rface(
sketch,
require_fully_constrained=True,
)
scad.extrude_rsolid(face, (0.0, 0.0, 1.0), 1.0)
payload = scad.export_model_json(session)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
sketch = next(obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'Sketcher::SketchObject' and getattr(obj, 'SimpleCADOp', '') == 'make_face_from_sketch_rface')
extrusion = next(obj for obj in doc.Objects if getattr(obj, 'SimpleCADOp', '') == 'make_extrude_rsolid')
constraint_status = json.loads(sketch.SimpleCADSketchConstraints)
solve = json.loads(sketch.SimpleCADSketchSolve)
promotion = json.loads(sketch.SimpleCADSketchPromotion)
shape = extrusion.Shape
geometry_type_names = [geo.__class__.__name__ for geo in sketch.Geometry]
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'geom_count': len(list(sketch.Geometry)),
'circle_count': sum(1 for item in geometry_type_names if item == 'Circle'),
'line_count': sum(1 for item in geometry_type_names if item == 'LineSegment'),
'constraint_count': len(sketch.Constraints),
'mapped_kinds': [item.get('kind') for item in constraint_status.get('mapped', [])],
'skipped_count': len(constraint_status.get('skipped', [])),
'solve_status': solve.get('status'),
'solve_dof': int(solve.get('dof', -1)),
'promotion_edges': [edge.get('entity_id') for edge in promotion.get('edges', [])],
'solid_count': 0 if shape.isNull() else len(shape.Solids),
'volume': 0.0 if shape.isNull() else float(shape.Volume),
}, fh)
"""
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["geom_count"], 5)
self.assertEqual(result["circle_count"], 2)
self.assertEqual(result["line_count"], 3)
self.assertGreaterEqual(result["constraint_count"], 15)
self.assertGreaterEqual(result["mapped_kinds"].count("tangent"), 2)
self.assertIn("point_on", result["mapped_kinds"])
self.assertIn("equal_radius", result["mapped_kinds"])
self.assertIn("concentric", result["mapped_kinds"])
self.assertLessEqual(result["skipped_count"], 1)
self.assertEqual(result["solve_status"], "solved")
self.assertEqual(result["solve_dof"], 0)
self.assertEqual(result["promotion_edges"], ["relief"])
self.assertEqual(result["solid_count"], 1)
self.assertAlmostEqual(result["volume"], 16.0 * 3.141592653589793, places=5)
def test_translate_model_json_records_unsupported_functional_sketch_constraints(self):
with GraphSession() as session:
sketch = scad.make_sketch_rsketch("fcstd_midpoint_record")
sketch = scad.add_point_rsketch(sketch, "p0", 0.0, 0.0)
sketch = scad.add_point_rsketch(sketch, "p1", 2.0, 0.0)
sketch = scad.add_point_rsketch(sketch, "p2", 2.0, 1.0)
sketch = scad.add_point_rsketch(sketch, "p3", 0.0, 1.0)
sketch = scad.add_point_rsketch(sketch, "mid", 1.0, 0.0)
sketch = scad.add_line_rsketch(sketch, "bottom", "p0", "p1")
sketch = scad.add_line_rsketch(sketch, "right", "p1", "p2")
sketch = scad.add_line_rsketch(sketch, "top", "p2", "p3")
sketch = scad.add_line_rsketch(sketch, "left", "p3", "p0")
sketch = scad.constrain_fix_rsketch(sketch, "p0")
sketch = scad.constrain_horizontal_rsketch(sketch, "bottom")
sketch = scad.constrain_vertical_rsketch(sketch, "right")
sketch = scad.constrain_parallel_rsketch(sketch, "bottom", "top")
sketch = scad.constrain_parallel_rsketch(sketch, "left", "right")
sketch = scad.constrain_distance_rsketch(sketch, "p0", "p1", 2.0)
sketch = scad.constrain_distance_rsketch(sketch, "p0", "p3", 1.0)
sketch = scad.constrain_midpoint_rsketch(sketch, "mid", "bottom")
scad.make_face_from_sketch_rface(sketch)
payload = scad.export_model_json(session)
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
sketch = next(obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'Sketcher::SketchObject' and getattr(obj, 'SimpleCADOp', '') == 'make_face_from_sketch_rface')
constraint_status = json.loads(sketch.SimpleCADSketchConstraints)
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'mapped_kinds': [item.get('kind') for item in constraint_status.get('mapped', [])],
'skipped': constraint_status.get('skipped', []),
'shape_edges': len(sketch.Shape.Edges),
}, fh)
"""
result = self._inspect_fcstd_json(payload, probe)
self.assertEqual(result["shape_edges"], 4)
self.assertIn("midpoint", [item.get("kind") for item in result["skipped"]])
self.assertTrue(
any(
"no crash-safe FreeCAD Sketcher mapping" in str(item.get("reason"))
for item in result["skipped"]
)
)
def test_translate_model_json_binds_mixed_sketch_local_line_and_arc_center_expressions(
self,
):
graph = OperationGraph(graph_id="graph_mixed_local_expr")
line = graph.add_node(
op="make_line_redge",
node_id="line_expr",
params={"start": [0.0, 0.0, 0.0], "end": [1.0, 0.0, 0.0]},
param_exprs={"end": [{"expr_id": "var_lx"}, {"expr_id": "var_ly"}, None]},
)
arc = graph.add_node(
op="make_angle_arc_redge",
node_id="arc_expr",
params={
"center": [1.0, 1.0, 0.0],
"radius": 1.0,
"start_angle": -1.5707963267948966,
"end_angle": 0.0,
},
param_exprs={
"center": [{"expr_id": "var_cx"}, {"expr_id": "var_cy"}, None],
"radius": {"expr_id": "var_r"},
},
)
wire = graph.add_node(
op="make_wire_from_edges_rwire",
node_id="wire_expr",
params={"edge_count": 2},
inputs=[line, arc],
)
payload = {
"schema_version": "2.0",
"canonical_contract": {"contract_version": "2.0"},
"graph": graph.to_dict(),
"leaf_ids": [wire.node_id],
"expression_graph": {
"nodes": [
{"expr_id": "var_lx", "kind": "var", "name": "lx", "default": 1.0},
{"expr_id": "var_ly", "kind": "var", "name": "ly", "default": 0.0},
{"expr_id": "var_cx", "kind": "var", "name": "cx", "default": 1.0},
{"expr_id": "var_cy", "kind": "var", "name": "cy", "default": 1.0},
{"expr_id": "var_r", "kind": "var", "name": "r", "default": 1.0},
]
},
"frame_graph": {"nodes": []},
"geometry_registry": [],
"semantic_entity_registry": [],
"sketch_profile_registry": [],
"semantic_delta_log": [],
"topology_delta_log": [],
}
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
result = {}
for obj in doc.Objects:
if getattr(obj, 'TypeId', '') == 'Sketcher::SketchObject':
result[obj.Name] = list(getattr(obj, 'ExpressionEngine', []))
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump(result, fh)
"""
result = self._inspect_fcstd_json(json.dumps(payload), probe)
all_entries = [entry for entries in result.values() for entry in entries]
self.assertIn(
["Geometry[0].EndPoint.x", "<<SimpleCADExpressions>>.var_lx"],
all_entries,
)
self.assertIn(
["Geometry[0].EndPoint.y", "<<SimpleCADExpressions>>.var_ly"],
all_entries,
)
self.assertIn(
["Geometry[1].Center.x", "<<SimpleCADExpressions>>.var_cx"],
all_entries,
)
self.assertIn(
["Geometry[1].Center.y", "<<SimpleCADExpressions>>.var_cy"],
all_entries,
)
self.assertIn(
["Geometry[1].Radius", "<<SimpleCADExpressions>>.var_r"],
all_entries,
)
def test_translate_model_json_binds_mixed_sketch_three_point_arc_expressions(
self,
):
graph = OperationGraph(graph_id="graph_mixed_three_point_expr")
line = graph.add_node(
op="make_line_redge",
node_id="line_expr",
params={"start": [0.0, 0.0, 0.0], "end": [1.0, 0.0, 0.0]},
)
arc = graph.add_node(
op="make_three_point_arc_redge",
node_id="arc_expr",
params={
"start": [1.0, 0.0, 0.0],
"middle": [1.5, 0.5, 0.0],
"end": [1.0, 1.0, 0.0],
},
param_exprs={
"start": [{"expr_id": "var_sx"}, {"expr_id": "var_sy"}, None],
"middle": [{"expr_id": "var_mx"}, {"expr_id": "var_my"}, None],
"end": [{"expr_id": "var_ex"}, {"expr_id": "var_ey"}, None],
},
)
wire = graph.add_node(
op="make_wire_from_edges_rwire",
node_id="wire_expr",
params={"edge_count": 2},
inputs=[line, arc],
)
payload = {
"schema_version": "2.0",
"canonical_contract": {"contract_version": "2.0"},
"graph": graph.to_dict(),
"leaf_ids": [wire.node_id],
"expression_graph": {
"nodes": [
{"expr_id": "var_sx", "kind": "var", "name": "sx", "default": 1.0},
{"expr_id": "var_sy", "kind": "var", "name": "sy", "default": 0.0},
{"expr_id": "var_mx", "kind": "var", "name": "mx", "default": 1.5},
{"expr_id": "var_my", "kind": "var", "name": "my", "default": 0.5},
{"expr_id": "var_ex", "kind": "var", "name": "ex", "default": 1.0},
{"expr_id": "var_ey", "kind": "var", "name": "ey", "default": 1.0},
]
},
"frame_graph": {"nodes": []},
"geometry_registry": [],
"semantic_entity_registry": [],
"sketch_profile_registry": [],
"semantic_delta_log": [],
"topology_delta_log": [],
}
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
result = {}
for obj in doc.Objects:
if getattr(obj, 'TypeId', '') == 'Sketcher::SketchObject':
result[obj.Name] = list(getattr(obj, 'ExpressionEngine', []))
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump(result, fh)
"""
result = self._inspect_fcstd_json(json.dumps(payload), probe)
all_entries = [entry for entries in result.values() for entry in entries]
self.assertIn(
["Geometry[1].StartPoint.x", "<<SimpleCADExpressions>>.var_sx"],
all_entries,
)
self.assertIn(
["Geometry[1].StartPoint.y", "<<SimpleCADExpressions>>.var_sy"],
all_entries,
)
self.assertIn(
["Geometry[1].EndPoint.x", "<<SimpleCADExpressions>>.var_ex"],
all_entries,
)
self.assertIn(
["Geometry[1].EndPoint.y", "<<SimpleCADExpressions>>.var_ey"],
all_entries,
)
center_x = next(
expr for prop, expr in all_entries if prop == "Geometry[1].Center.x"
)
center_y = next(
expr for prop, expr in all_entries if prop == "Geometry[1].Center.y"
)
radius = next(
expr for prop, expr in all_entries if prop == "Geometry[1].Radius"
)
self.assertIn("<<SimpleCADExpressions>>.var_sx", center_x)
self.assertIn("<<SimpleCADExpressions>>.var_mx", center_x)
self.assertIn("<<SimpleCADExpressions>>.var_ex", center_x)
self.assertIn("<<SimpleCADExpressions>>.var_sy", center_y)
self.assertIn("<<SimpleCADExpressions>>.var_my", center_y)
self.assertIn("<<SimpleCADExpressions>>.var_ey", center_y)
self.assertIn("Geometry[1].Center.x", [prop for prop, _ in all_entries])
self.assertIn("Geometry[1].Center.y", [prop for prop, _ in all_entries])
self.assertIn("pow(", radius)
self.assertIn("<<SimpleCADExpressions>>.var_sx", radius)
self.assertIn("<<SimpleCADExpressions>>.var_sy", radius)
self.assertIn("<<SimpleCADExpressions>>.var_mx", radius)
self.assertIn("<<SimpleCADExpressions>>.var_my", radius)
self.assertIn("<<SimpleCADExpressions>>.var_ex", radius)
self.assertIn("<<SimpleCADExpressions>>.var_ey", radius)
def test_translate_model_json_exports_single_three_point_arc_sketch_with_expressions(
self,
):
graph = OperationGraph(graph_id="graph_single_three_point_expr")
arc = graph.add_node(
op="make_three_point_arc_redge",
node_id="arc_expr",
params={
"start": [0.0, 0.0, 0.0],
"middle": [1.0, 1.0, 0.0],
"end": [2.0, 0.0, 0.0],
},
param_exprs={
"start": [{"expr_id": "var_sx"}, {"expr_id": "var_sy"}, None],
"middle": [{"expr_id": "var_mx"}, {"expr_id": "var_my"}, None],
"end": [{"expr_id": "var_ex"}, {"expr_id": "var_ey"}, None],
},
)
wire = graph.add_node(
op="make_wire_from_edges_rwire",
node_id="wire_expr",
params={"edge_count": 1},
inputs=[arc],
)
payload = {
"schema_version": "2.0",
"canonical_contract": {"contract_version": "2.0"},
"graph": graph.to_dict(),
"leaf_ids": [wire.node_id],
"expression_graph": {
"nodes": [
{"expr_id": "var_sx", "kind": "var", "name": "sx", "default": 0.0},
{"expr_id": "var_sy", "kind": "var", "name": "sy", "default": 0.0},
{"expr_id": "var_mx", "kind": "var", "name": "mx", "default": 1.0},
{"expr_id": "var_my", "kind": "var", "name": "my", "default": 1.0},
{"expr_id": "var_ex", "kind": "var", "name": "ex", "default": 2.0},
{"expr_id": "var_ey", "kind": "var", "name": "ey", "default": 0.0},
]
},
"frame_graph": {"nodes": []},
"geometry_registry": [],
"semantic_entity_registry": [],
"sketch_profile_registry": [],
"semantic_delta_log": [],
"topology_delta_log": [],
}
probe = """
import json
import FreeCAD as App
doc = App.openDocument(FCSTD_PATH)
sketches = [obj for obj in doc.Objects if getattr(obj, 'TypeId', '') == 'Sketcher::SketchObject']
target = sketches[0]
with open(OUT_PATH, 'w', encoding='utf-8') as fh:
json.dump({
'sketch_count': len(sketches),
'exprs': list(getattr(target, 'ExpressionEngine', [])),
'geom_count': len(list(getattr(target, 'Geometry', []))),
'shape_type': target.Shape.ShapeType,
'edge_count': len(target.Shape.Edges),
}, fh)
"""
result = self._inspect_fcstd_json(json.dumps(payload), probe)
self.assertEqual(result["sketch_count"], 1)
self.assertEqual(result["geom_count"], 1)
self.assertEqual(result["shape_type"], "Wire")
self.assertEqual(result["edge_count"], 1)
expr_map = {prop: expr for prop, expr in result["exprs"]}
self.assertIn("Geometry[0].StartPoint.x", expr_map)
self.assertIn("Geometry[0].StartPoint.y", expr_map)
self.assertIn("Geometry[0].EndPoint.x", expr_map)
self.assertIn("Geometry[0].EndPoint.y", expr_map)
self.assertIn("Geometry[0].Center.x", expr_map)
self.assertIn("Geometry[0].Center.y", expr_map)
self.assertIn("Geometry[0].Radius", expr_map)
self.assertIn(
"<<SimpleCADExpressions>>.var_sx", expr_map["Geometry[0].StartPoint.x"]
)
self.assertIn(
"<<SimpleCADExpressions>>.var_sy", expr_map["Geometry[0].StartPoint.x"]
)
self.assertIn(
"<<SimpleCADExpressions>>.var_sx", expr_map["Geometry[0].StartPoint.y"]
)
self.assertIn(
"<<SimpleCADExpressions>>.var_sy", expr_map["Geometry[0].StartPoint.y"]
)
self.assertIn(
"<<SimpleCADExpressions>>.var_ex", expr_map["Geometry[0].EndPoint.x"]
)
self.assertIn(
"<<SimpleCADExpressions>>.var_ey", expr_map["Geometry[0].EndPoint.x"]
)
self.assertIn(
"<<SimpleCADExpressions>>.var_ex", expr_map["Geometry[0].EndPoint.y"]
)
self.assertIn(
"<<SimpleCADExpressions>>.var_ey", expr_map["Geometry[0].EndPoint.y"]
)
def test_translate_model_json_uses_selector_index_fallback_for_detail_features(
self,
):
with GraphSession() as session:
box = scad.make_box_rsolid(2.0, 2.0, 2.0)
scad.chamfer_rsolid(box, [box.get_edges(0)], 0.2)
scad.shell_rsolid(box, [box.get_faces(0)], 0.1)
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
self.assertIn("Part::Chamfer", script)
self.assertIn("Part::Thickness", script)
self.assertIn("selected_edge_indices", script)
self.assertIn("selected_face_indices", script)
def test_translate_model_json_does_not_emit_assembly_object_for_plain_geometry(self):
with GraphSession() as session:
scad.make_box_rsolid(1.0, 1.0, 1.0)
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
self.assertNotIn("= _make_native_assembly(", script)
self.assertNotIn("SimpleCADAssemblyId", script)
self.assertNotIn("PART_REGISTRY", script)
self.assertNotIn("CONSTRAINT_REGISTRY", script)
self.assertNotIn("SimpleCAD Constraint", script)
def test_translate_model_json_preserves_pattern_multi_output_structure(self):
with GraphSession() as session:
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
scad.linear_pattern_rsolidlist(box, (1.0, 0.0, 0.0), 3, 2.0)
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
self.assertIn("GRAPH_OUTPUTS", script)
self.assertIn("App::Link", script)
self.assertNotIn("linear_pattern", script)
self.assertIn("RESULT_NODE_IDS", script)
def test_translate_model_json_hides_non_leaf_graph_objects(self):
with GraphSession() as session:
box = scad.make_box_rsolid(2.0, 3.0, 4.0)
scad.translate_shape(box, (1.0, 2.0, 3.0))
script = freecad_translator.translate_model_json_to_freecad_script(
scad.export_model_json(session)
)
self.assertIn("_apply_result_visibility(RESULT_NODE_IDS)", script)
self.assertIn("_set_active_result_object(RESULT_NODE_IDS)", script)
self.assertIn("def _set_visibility", script)
self.assertIn("def _save_fcstd_with_gui_visibility", script)
self.assertIn("def _apply_result_visibility", script)
def test_translate_model_json_rejects_field_surface_ops(self):
graph = OperationGraph(graph_id="graph_field")
graph.add_node(
op="make_field_surface_rsolid",
params={
"bounds": {"min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 1.0]},
"resolution": [8, 8, 8],
"iso": 0.0,
"cap_bounds": True,
"field_serialization_mode": "scalar_field",
"field_tree": {
"op": "box",
"params": {"center": [0.0, 0.0, 0.0], "size": [1.0, 1.0, 1.0]},
"children": [],
},
},
)
payload = {
"schema_version": "2.0",
"canonical_contract": {"contract_version": "2.0"},
"graph": graph.to_dict(),
"leaf_ids": [graph.leaf_nodes()[0].node_id],
"expression_graph": {"nodes": []},
"frame_graph": {"nodes": []},
"geometry_registry": [],
"semantic_entity_registry": [],
"sketch_profile_registry": [],
"semantic_delta_log": [],
"topology_delta_log": [],
}
with self.assertRaises(ValueError):
freecad_translator.translate_model_json_to_freecad_script(json.dumps(payload))
def test_translate_model_json_to_fcstd_invokes_freecadcmd(self):
with GraphSession() as session:
scad.make_box_rsolid(1.0, 1.0, 1.0)
payload = scad.export_model_json(session)
with (
mock.patch(
"shutil.which",
side_effect=lambda name: (
"/usr/bin/FreeCADCmd" if name == "FreeCADCmd" else None
),
),
mock.patch("subprocess.run") as run_mock,
mock.patch("os.path.exists", return_value=True),
mock.patch("os.path.getsize", return_value=1024),
):
run_mock.return_value = mock.Mock(
returncode=0, stdout="/tmp/out.FCStd\n", stderr=""
)
out = freecad_translator.translate_model_json_to_fcstd(payload, "/tmp/out.FCStd")
self.assertEqual(out, "/tmp/out.FCStd")
run_mock.assert_called_once()
def test_translate_model_json_to_fcstd_requires_freecadcmd(self):
with GraphSession() as session:
scad.make_box_rsolid(1.0, 1.0, 1.0)
payload = scad.export_model_json(session)
with (
mock.patch("shutil.which", return_value=None),
mock.patch("os.path.exists", return_value=False),
):
with self.assertRaises(scad.SimpleCADError):
freecad_translator.translate_model_json_to_fcstd(payload, "/tmp/out.FCStd")
def test_translate_model_json_to_fcstd_discovers_macos_bundle_freecadcmd(self):
with GraphSession() as session:
scad.make_box_rsolid(1.0, 1.0, 1.0)
payload = scad.export_model_json(session)
def fake_exists(path: str) -> bool:
return path in {
"/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd",
"/tmp/out.FCStd",
}
with (
mock.patch("shutil.which", return_value=None),
mock.patch("os.path.exists", side_effect=fake_exists),
mock.patch("os.path.getsize", return_value=1024),
mock.patch("subprocess.run") as run_mock,
):
run_mock.return_value = mock.Mock(
returncode=0, stdout="/tmp/out.FCStd\n", stderr=""
)
out = freecad_translator.translate_model_json_to_fcstd(payload, "/tmp/out.FCStd")
self.assertEqual(out, "/tmp/out.FCStd")
args, _kwargs = run_mock.call_args
self.assertEqual(
args[0][0], "/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd"
)
if __name__ == "__main__":
unittest.main()