Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions jupytercad_freecad/freecad/jcad_converter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import re
import tempfile

import freecad as fc
import OfflineRenderingUtils # type: ignore[import]

from .loader import _hex_to_rgb
from .props.base_prop import BaseProp
from . import props as Props


def sanitize_object_name(name: str) -> str:
"""Convert object names to FreeCAD-compatible format."""
s = name.replace(" ", "_").replace("-", "_")
return re.sub(r"[^\w_]", "_", s)


def build_prop_handlers():
return {
Cls.name(): Cls
for Cls in Props.__dict__.values()
if isinstance(Cls, type) and issubclass(Cls, BaseProp)
}


def update_references(jcad_dict, name_mapping):
"""Walk all parameters and rewrite any string/list references using name_mapping."""
for obj in jcad_dict.get("objects", []):
params = obj.get("parameters", {})
for key, val in params.items():
if isinstance(val, str) and val in name_mapping:
params[key] = name_mapping[val]
elif isinstance(val, list):
params[key] = [
name_mapping.get(item, item) if isinstance(item, str) else item
for item in val
]


def create_body_and_coordinates(doc, body_obj, fc_objects):
"""Create a PartDesign::Body so that FreeCAD auto-creates Origin & axes."""
body_name = body_obj["name"]
body_fc = doc.addObject("PartDesign::Body", body_name)
fc_objects[body_name] = body_fc

# Once the Body is created, FreeCAD auto-adds an Origin with child axes & planes.
if not body_fc.Origin:
return body_fc

fc_objects["Origin"] = body_fc.Origin
for child in body_fc.Origin.Group:
role = getattr(child, "Role", "")
if role in {"X_Axis", "Y_Axis", "Z_Axis", "XY_Plane", "XZ_Plane", "YZ_Plane"}:
fc_objects[role] = child

return body_fc


def apply_object_properties(fc_obj, jcad_obj, prop_handlers, doc):
"""Run through all non‐color parameters and use the Prop handlers to set them."""
params = jcad_obj.get("parameters", {})
for prop, jval in params.items():
if prop == "Color" or not hasattr(fc_obj, prop):
continue
prop_type = fc_obj.getTypeIdOfProperty(prop)
Handler = prop_handlers.get(prop_type)
if not Handler:
continue
try:
new_val = Handler.jcad_to_fc(
jval,
jcad_object=jcad_obj,
fc_prop=getattr(fc_obj, prop),
fc_object=fc_obj,
fc_file=doc,
)
if new_val is not None:
setattr(fc_obj, prop, new_val)
except Exception as e:
print(f"Error setting {prop} on {fc_obj.Name}: {e}")


def export_jcad_to_fcstd(jcad_dict: dict) -> "fc.Document":
doc = fc.app.newDocument("__jcad_export__")
doc.Meta = jcad_dict.get("metadata", {})

prop_handlers = build_prop_handlers()
coordinate_names = {
"Origin",
"X_Axis",
"Y_Axis",
"Z_Axis",
"XY_Plane",
"XZ_Plane",
"YZ_Plane",
}

# 1) Sanitize JCAD names in place and build a mapping
name_mapping = {}
for obj in jcad_dict.get("objects", []):
original = obj["name"]
sanitized = sanitize_object_name(original)
name_mapping[original] = sanitized
obj["name"] = sanitized

update_references(jcad_dict, name_mapping)

fc_objects = {}
guidata = {}

# 2) Separate PartDesign::Body entries from others
body_objs = [
o for o in jcad_dict.get("objects", []) if o["shape"] == "PartDesign::Body"
]
other_objs = [
o
for o in jcad_dict.get("objects", [])
if o not in body_objs and o["name"] not in coordinate_names
]

# Helper: determine RGB tuple and visibility flag
def _color_and_visibility(jcad_obj):
opts = jcad_dict.get("options", {}).get(jcad_obj["name"], {})
hexcol = jcad_obj.get("parameters", {}).get("Color") or opts.get(
"color", "#808080"
)
rgb = _hex_to_rgb(hexcol)
visible = opts.get("visible")
if visible is None:
visible = jcad_obj.get("visible", True)
return rgb, visible

# 3) Create all PartDesign::Body objects
for body_obj in body_objs:
body_fc = create_body_and_coordinates(doc, body_obj, fc_objects)
apply_object_properties(body_fc, body_obj, prop_handlers, doc)

rgb, visible = _color_and_visibility(body_obj)
guidata[body_obj["name"]] = {
"ShapeColor": {"type": "App::PropertyColor", "value": rgb},
"Visibility": {"type": "App::PropertyBool", "value": visible},
}

# Coordinate children inherit the same color but remain hidden
for coord in coordinate_names:
if coord in fc_objects:
guidata[coord] = {
"ShapeColor": {"type": "App::PropertyColor", "value": rgb},
"Visibility": {"type": "App::PropertyBool", "value": False},
}

# 4) Create all other objects
for obj in other_objs:
fc_obj = doc.addObject(obj["shape"], obj["name"])
fc_objects[obj["name"]] = fc_obj
apply_object_properties(fc_obj, obj, prop_handlers, doc)

rgb, visible = _color_and_visibility(obj)
default_camera = (
"OrthographicCamera {\n"
" viewportMapping ADJUST_CAMERA\n"
" position 5.0 0.0 10.0\n"
" orientation 0.7 0.2 0.4 1.0\n"
" nearDistance 0.2\n"
" farDistance 20.0\n"
" aspectRatio 1.0\n"
" focalDistance 8.0\n"
" height 16.0\n"
"}"
)
guidata[obj["name"]] = {
"ShapeColor": {"type": "App::PropertyColor", "value": rgb},
"Visibility": {"type": "App::PropertyBool", "value": visible},
}
guidata["GuiCameraSettings"] = default_camera

# 5) Recompute so FreeCAD generates any missing children
doc.recompute()

# 7) Save with guidata so FreeCAD writes a full GuiDocument.xml
with tempfile.NamedTemporaryFile(delete=False, suffix=".FCStd") as tmp:
path = tmp.name
OfflineRenderingUtils.save(doc, filename=path, guidata=guidata)

return fc.app.openDocument(path)
9 changes: 6 additions & 3 deletions jupytercad_freecad/freecad/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ def _rgb_to_hex(rgb):
def _hex_to_rgb(hex_color):
"""Convert hex color string to an RGB tuple"""
hex_color = hex_color.lstrip("#")
return tuple(int(hex_color[i : i + 2], 16) / 255.0 for i in (0, 2, 4))
result = tuple(int(hex_color[i : i + 2], 16) / 255.0 for i in (0, 2, 4))
return result


def _guidata_to_options(guidata):
Expand Down Expand Up @@ -78,7 +79,6 @@ def _options_to_guidata(options):
if "color" in data:
rgb_value = _hex_to_rgb(data["color"])
obj_data["ShapeColor"] = dict(type="App::PropertyColor", value=rgb_value)

# Handle visibility property from JupyterCad to FreeCAD's Visibility
if "visible" in data:
obj_data["Visibility"] = dict(
Expand Down Expand Up @@ -189,6 +189,10 @@ def save(self, objects: List, options: Dict, metadata: Dict) -> None:
fc_obj = fc_file.getObject(py_obj["name"])

for prop, jcad_prop_value in py_obj["parameters"].items():
# Skip Color property as it's handled separately through guidata
if prop == "Color":
continue

if hasattr(fc_obj, prop):
try:
prop_type = fc_obj.getTypeIdOfProperty(prop)
Expand Down Expand Up @@ -217,7 +221,6 @@ def save(self, objects: List, options: Dict, metadata: Dict) -> None:
new_hex_color = py_obj["parameters"]["Color"]
else:
new_hex_color = "#808080" # Default to gray if no color is provided

if obj_name in self._guidata:
self._guidata[obj_name]["color"] = new_hex_color
else:
Expand Down
2 changes: 1 addition & 1 deletion jupytercad_freecad/freecad/props/geometry/geom_circle.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
with redirect_stdout_stderr():
try:
import freecad as fc
import Part
import Part as Part
except ImportError:
fc = None

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
with redirect_stdout_stderr():
try:
import freecad as fc
import Part
import Part as Part
except ImportError:
fc = None

Expand Down
29 changes: 27 additions & 2 deletions jupytercad_freecad/freecad/props/property_partshape.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,30 @@ def fc_to_jcad(prop_value: Any, **kwargs) -> Any:

@staticmethod
def jcad_to_fc(prop_value: str, **kwargs) -> Any:
"""PropertyPartShape is readonly"""
return None
try:
import freecad as fc
import Part as Part
except ImportError:
print("Error: FreeCAD or Part module could not be imported in jcad_to_fc.")
return None

if not prop_value:
print("Warning: jcad_to_fc received empty prop_value for PartShape.")
return None

try:
shape = Part.Shape()
shape.importBrepFromString(prop_value)

if shape.isNull():
print(
f"Warning: Reconstructed shape is Null after importBrepFromString. Input (first 100 chars): {prop_value[:100]}..."
)
return None

return shape
except Exception as e:
print(
f"Failed to rebuild BRep shape with importBrepFromString: {e}. Input (first 100 chars): {prop_value[:100]}..."
)
return None
Loading
Loading