diff --git a/jupytercad_freecad/freecad/jcad_converter.py b/jupytercad_freecad/freecad/jcad_converter.py new file mode 100644 index 0000000..3028100 --- /dev/null +++ b/jupytercad_freecad/freecad/jcad_converter.py @@ -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) diff --git a/jupytercad_freecad/freecad/loader.py b/jupytercad_freecad/freecad/loader.py index b184237..d5bd4bb 100644 --- a/jupytercad_freecad/freecad/loader.py +++ b/jupytercad_freecad/freecad/loader.py @@ -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): @@ -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( @@ -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) @@ -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: diff --git a/jupytercad_freecad/freecad/props/geometry/geom_circle.py b/jupytercad_freecad/freecad/props/geometry/geom_circle.py index 13ab151..26268d2 100644 --- a/jupytercad_freecad/freecad/props/geometry/geom_circle.py +++ b/jupytercad_freecad/freecad/props/geometry/geom_circle.py @@ -7,7 +7,7 @@ with redirect_stdout_stderr(): try: import freecad as fc - import Part + import Part as Part except ImportError: fc = None diff --git a/jupytercad_freecad/freecad/props/geometry/geom_linesegment.py b/jupytercad_freecad/freecad/props/geometry/geom_linesegment.py index e1940c5..c854b65 100644 --- a/jupytercad_freecad/freecad/props/geometry/geom_linesegment.py +++ b/jupytercad_freecad/freecad/props/geometry/geom_linesegment.py @@ -7,7 +7,7 @@ with redirect_stdout_stderr(): try: import freecad as fc - import Part + import Part as Part except ImportError: fc = None diff --git a/jupytercad_freecad/freecad/props/property_partshape.py b/jupytercad_freecad/freecad/props/property_partshape.py index d49e315..f16c4fa 100644 --- a/jupytercad_freecad/freecad/props/property_partshape.py +++ b/jupytercad_freecad/freecad/props/property_partshape.py @@ -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 diff --git a/jupytercad_freecad/handlers.py b/jupytercad_freecad/handlers.py index df3aa4e..2748b6a 100644 --- a/jupytercad_freecad/handlers.py +++ b/jupytercad_freecad/handlers.py @@ -1,9 +1,16 @@ import json +import base64 +from pathlib import Path +import os +import shutil from jupyter_server.base.handlers import APIHandler -from jupyter_server.utils import url_path_join +from jupyter_server.utils import url_path_join, ApiPath, to_os_path import tornado +from jupytercad_freecad.freecad.loader import FCStd +from jupytercad_freecad.freecad.jcad_converter import export_jcad_to_fcstd + class BackendCheckHandler(APIHandler): @tornado.web.authenticated @@ -23,10 +30,123 @@ def post(self): self.finish(json.dumps({"installed": False})) +class JCadExportHandler(APIHandler): + @tornado.web.authenticated + def post(self): + body = self.get_json_body() + + # Works with both prefixed and unprefixed paths + path = body.get("path", "") + parts = path.split(":", 1) + + if len(parts) == 2: + file_name = parts[1] + else: + file_name = parts[0] # fallback: treat whole thing as path + + export_name = body["newName"] + + root_dir = Path(self.contents_manager.root_dir).resolve() + file_path = Path(to_os_path(ApiPath(file_name), str(root_dir))) + + try: + with open(file_path, "rb") as fobj: + base64_content = base64.b64encode(fobj.read()).decode("utf-8") + except Exception as e: + self.log.error(f"Error reading file {file_path}: {e}") + self.set_status(500) + self.finish(json.dumps({"error": f"Failed to read file: {str(e)}"})) + return + + try: + fcstd = FCStd() + fcstd.load(base64_content=base64_content) + except Exception as e: + self.log.error(f"Error loading FCStd file: {e}") + self.set_status(500) + self.finish(json.dumps({"error": f"Failed to load FCStd file: {str(e)}"})) + return + + jcad = dict( + schemaVersion="1.0", + objects=fcstd._objects, + metadata=fcstd._metadata, + options=fcstd._guidata, + outputs={}, + ) + + export_path = file_path.parent / export_name + try: + with open(export_path, "w") as fobj: + fobj.write(json.dumps(jcad, indent=2)) + except Exception as e: + self.log.error(f"Error writing JCAD file: {e}") + self.set_status(500) + self.finish(json.dumps({"error": f"Failed to write JCAD file: {str(e)}"})) + return + + self.finish(json.dumps({"done": True, "exportedPath": str(export_path)})) + + +class FCStdExportHandler(APIHandler): + @tornado.web.authenticated + def post(self): + body = self.get_json_body() + + # same path‐splitting logic as JCadExportHandler + path = body.get("path", "") + parts = path.split(":", 1) + file_name = parts[1] if len(parts) == 2 else parts[0] + + new_name = body["newName"] + root_dir = Path(self.contents_manager.root_dir).resolve() + in_path = Path(to_os_path(ApiPath(file_name), str(root_dir))) + out_path = in_path.parent / new_name + + # load JCAD JSON from disk + try: + jcad_dict = json.loads(in_path.read_text()) + except Exception as e: + self.log.error(f"Error reading JCAD file {in_path}: {e}") + self.set_status(500) + return self.finish(json.dumps({"error": str(e)})) + + # convert to FreeCAD document + try: + doc = export_jcad_to_fcstd(jcad_dict) + + temp_fcstd_path = doc.FileName + shutil.copy2(temp_fcstd_path, str(out_path)) + + # Clean up the temporary file + if os.path.exists(temp_fcstd_path): + os.unlink(temp_fcstd_path) + + except Exception as e: + self.log.error(f"Error converting to FCStd: {e}") + self.set_status(500) + return self.finish(json.dumps({"error": str(e)})) + + self.finish(json.dumps({"done": True, "exportedPath": str(out_path)})) + + def setup_handlers(web_app): host_pattern = ".*$" base_url = web_app.settings["base_url"] - route_pattern = url_path_join(base_url, "jupytercad_freecad", "backend-check") - handlers = [(route_pattern, BackendCheckHandler)] + + handlers = [ + ( + url_path_join(base_url, "jupytercad_freecad", "backend-check"), + BackendCheckHandler, + ), + ( + url_path_join(base_url, "jupytercad_freecad", "export-jcad"), + JCadExportHandler, + ), + ( + url_path_join(base_url, "jupytercad_freecad", "export-fcstd"), + FCStdExportHandler, + ), + ] web_app.add_handlers(host_pattern, handlers) diff --git a/src/plugins.ts b/src/plugins.ts index 5ad7e2c..1547fb9 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -1,7 +1,5 @@ -import { - ICollaborativeDrive, - SharedDocumentFactory -} from '@jupyter/collaborative-drive'; +// plugins.ts +import { ICollaborativeDrive } from '@jupyter/collaborative-drive'; import { IAnnotationModel, IJCadWorkerRegistry, @@ -17,8 +15,12 @@ import { import { IThemeManager, showErrorMessage, + InputDialog, + showDialog, WidgetTracker } from '@jupyterlab/apputils'; +import { IMainMenu } from '@jupyterlab/mainmenu'; +import { PathExt } from '@jupyterlab/coreutils'; import { LabIcon } from '@jupyterlab/ui-components'; import { JupyterCadWidgetFactory } from '@jupytercad/jupytercad-core'; @@ -32,98 +34,18 @@ import { JupyterCadFCModelFactory } from './modelfactory'; import freecadIconSvg from '../style/freecad.svg'; const freecadIcon = new LabIcon({ - name: 'jupytercad:stp', + name: 'jupytercad:freecad', svgstr: freecadIconSvg }); const FACTORY = 'Jupytercad Freecad Factory'; - -const activate = async ( - app: JupyterFrontEnd, - tracker: WidgetTracker, - themeManager: IThemeManager, - annotationModel: IAnnotationModel, - drive: ICollaborativeDrive, - workerRegistry: IJCadWorkerRegistry, - externalCommandRegistry: IJCadExternalCommandRegistry -): Promise => { - const fcCheck = await requestAPI<{ installed: boolean }>( - 'jupytercad_freecad/backend-check', - { - method: 'POST', - body: JSON.stringify({ - backend: 'FreeCAD' - }) - } - ); - const { installed } = fcCheck; - const backendCheck = () => { - if (!installed) { - showErrorMessage( - 'FreeCAD is not installed', - 'FreeCAD is required to open FCStd files' - ); - } - return installed; - }; - const widgetFactory = new JupyterCadWidgetFactory({ - name: FACTORY, - modelName: 'jupytercad-fcmodel', - fileTypes: ['FCStd'], - defaultFor: ['FCStd'], - tracker, - commands: app.commands, - workerRegistry, - externalCommandRegistry, - backendCheck - }); - - // Registering the widget factory - app.docRegistry.addWidgetFactory(widgetFactory); - - // Creating and registering the model factory for our custom DocumentModel - const modelFactory = new JupyterCadFCModelFactory({ annotationModel }); - app.docRegistry.addModelFactory(modelFactory); - // register the filetype - app.docRegistry.addFileType({ - name: 'FCStd', - displayName: 'FCStd', - mimeTypes: ['application/octet-stream'], - extensions: ['.FCStd', 'fcstd'], - fileFormat: 'base64', - contentType: 'FCStd', - icon: freecadIcon - }); - - const FCStdSharedModelFactory: SharedDocumentFactory = () => { - return new JupyterCadDoc(); - }; - drive.sharedModelFactory.registerDocumentFactory( - 'FCStd', - FCStdSharedModelFactory - ); - - widgetFactory.widgetCreated.connect((sender, widget) => { - widget.title.icon = freecadIcon; - // Notify the instance tracker if restore data needs to update. - widget.context.pathChanged.connect(() => { - tracker.save(widget); - }); - themeManager.themeChanged.connect((_, changes) => - widget.context.model.themeChanged.emit(changes) - ); - - tracker.add(widget); - app.shell.activateById('jupytercad::leftControlPanel'); - app.shell.activateById('jupytercad::rightControlPanel'); - }); - console.log('jupytercad:fcplugin is activated!'); -}; +const EXPORT_FCSTD_CMD = 'jupytercad:export-fcstd'; export const fcplugin: JupyterFrontEndPlugin = { id: 'jupytercad:fcplugin', requires: [ IJupyterCadDocTracker, + IMainMenu, IThemeManager, IAnnotationToken, ICollaborativeDrive, @@ -131,5 +53,125 @@ export const fcplugin: JupyterFrontEndPlugin = { IJCadExternalCommandRegistryToken ], autoStart: true, - activate + activate: async ( + app: JupyterFrontEnd, + tracker: WidgetTracker, + mainMenu: IMainMenu, + themeManager: IThemeManager, + annotationModel: IAnnotationModel, + drive: ICollaborativeDrive, + workerRegistry: IJCadWorkerRegistry, + externalCommandRegistry: IJCadExternalCommandRegistry + ) => { + const { installed } = await requestAPI<{ installed: boolean }>( + 'jupytercad_freecad/backend-check', + { + method: 'POST', + body: JSON.stringify({ backend: 'FreeCAD' }) + } + ); + const backendCheck = () => { + if (!installed) { + showErrorMessage( + 'FreeCAD is not installed', + 'FreeCAD is required to open or export FCStd files' + ); + } + return installed; + }; + + const widgetFactory = new JupyterCadWidgetFactory({ + name: FACTORY, + modelName: 'jupytercad-fcmodel', + fileTypes: ['FCStd'], + defaultFor: ['FCStd'], + tracker, + commands: app.commands, + workerRegistry, + externalCommandRegistry, + backendCheck + }); + app.docRegistry.addWidgetFactory(widgetFactory); + + const modelFactory = new JupyterCadFCModelFactory({ annotationModel }); + app.docRegistry.addModelFactory(modelFactory); + + app.docRegistry.addFileType({ + name: 'FCStd', + displayName: 'FCStd', + mimeTypes: ['application/octet-stream'], + extensions: ['.FCStd', '.fcstd'], + fileFormat: 'base64', + contentType: 'FCStd', + icon: freecadIcon + }); + + drive.sharedModelFactory.registerDocumentFactory( + 'FCStd', + (): JupyterCadDoc => new JupyterCadDoc() + ); + + widgetFactory.widgetCreated.connect((_, widget) => { + widget.title.icon = freecadIcon; + widget.context.pathChanged.connect(() => tracker.save(widget)); + themeManager.themeChanged.connect((_, changes) => + widget.context.model.themeChanged.emit(changes) + ); + app.shell.activateById('jupytercad::leftControlPanel'); + app.shell.activateById('jupytercad::rightControlPanel'); + tracker.add(widget); + }); + + console.log('jupytercad:fcplugin is activated!'); + + app.commands.addCommand(EXPORT_FCSTD_CMD, { + label: 'Export to .FCStd', + iconClass: 'fa fa-file-export', + isEnabled: () => { + const w = tracker.currentWidget; + return !!w && w.context.path.toLowerCase().endsWith('.jcad'); + }, + execute: async () => { + const w = tracker.currentWidget; + if (!w) { + return; + } + const defaultName = PathExt.basename(w.context.path).replace( + /\.[^.]+$/, + '.FCStd' + ); + const result = await InputDialog.getText({ + title: 'Export to .FCStd', + placeholder: 'Output file name', + text: defaultName + }); + if (!result.value) { + return; + } + try { + const resp = await requestAPI<{ path?: string; done?: boolean }>( + 'jupytercad_freecad/export-fcstd', + { + method: 'POST', + body: JSON.stringify({ + path: w.context.path, + newName: result.value + }) + } + ); + const outPath = resp.path ?? result.value; + await showDialog({ + title: 'Export successful', + body: `Wrote file to: ${outPath}` + }); + } catch (e: any) { + showErrorMessage('Export Error', e.message || String(e)); + } + } + }); + + mainMenu.fileMenu.addGroup([{ command: EXPORT_FCSTD_CMD }], /* rank */ 100); + } }; + +export default [fcplugin];