diff --git a/packages/base/src/commands.ts b/packages/base/src/commands.ts index e468afe2..a213bcea 100644 --- a/packages/base/src/commands.ts +++ b/packages/base/src/commands.ts @@ -22,11 +22,14 @@ import { explodedViewIcon, extrusionIcon, intersectionIcon, + requestAPI, sphereIcon, torusIcon, unionIcon } from './tools'; import { JupyterCadPanel, JupyterCadWidget } from './widget'; +import { DocumentRegistry } from '@jupyterlab/docregistry'; +import { PathExt } from '@jupyterlab/coreutils'; export function newName(type: string, model: IJupyterCadModel): string { const sharedModel = model.sharedModel; @@ -404,6 +407,43 @@ const CAMERA_FORM = { } }; +const EXPORT_FORM = { + title: 'Export to .jcad', + schema: { + type: 'object', + required: ['Name'], + additionalProperties: false, + properties: { + Name: { + title: 'File name', + description: 'The exported file name', + type: 'string' + } + } + }, + default: (context: DocumentRegistry.IContext) => { + return { + Name: PathExt.basename(context.path).replace( + PathExt.extname(context.path), + '.jcad' + ) + }; + }, + syncData: (context: DocumentRegistry.IContext) => { + return (props: IDict) => { + const { Name } = props; + console.log(`export to ${Name}`); + requestAPI<{ done: boolean }>('jupytercad/export', { + method: 'POST', + body: JSON.stringify({ + path: context.path, + newName: Name + }) + }); + }; + } +}; + /** * Add the FreeCAD commands to the application's command registry. */ @@ -645,6 +685,33 @@ export function addCommands( await dialog.launch(); } }); + + commands.addCommand(CommandIDs.exportJcad, { + label: trans.__('Export to .jcad'), + isEnabled: () => { + return tracker.currentWidget + ? tracker.currentWidget.context.model.sharedModel.exportable + : false; + }, + iconClass: 'fa fa-file-export', + execute: async () => { + const current = tracker.currentWidget; + + if (!current) { + return; + } + + const dialog = new FormDialog({ + context: current.context, + title: EXPORT_FORM.title, + schema: EXPORT_FORM.schema, + sourceData: EXPORT_FORM.default(tracker.currentWidget?.context), + syncData: EXPORT_FORM.syncData(tracker.currentWidget?.context), + cancelButton: true + }); + await dialog.launch(); + } + }); } /** @@ -670,6 +737,8 @@ export namespace CommandIDs { export const updateAxes = 'jupytercad:updateAxes'; export const updateExplodedView = 'jupytercad:updateExplodedView'; export const updateCameraSettings = 'jupytercad:updateCameraSettings'; + + export const exportJcad = 'jupytercad:exportJcad'; } namespace Private { diff --git a/packages/schema/src/interfaces.ts b/packages/schema/src/interfaces.ts index ad66e999..b78fc98a 100644 --- a/packages/schema/src/interfaces.ts +++ b/packages/schema/src/interfaces.ts @@ -80,6 +80,7 @@ export interface IJupyterCadDoc extends YDocument { metadata: JSONObject; outputs: JSONObject; readonly editable: boolean; + readonly exportable: boolean; objectExists(name: string): boolean; getObjectByName(name: string): IJCadObject | undefined; diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 3f5bca03..9e6000f6 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -446,6 +446,7 @@ export class JupyterCadDoc } editable = true; + exportable = false; private _getObjectAsYMapByName(name: string): Y.Map | undefined { for (const obj of this._objects) { @@ -547,6 +548,7 @@ export class JupyterCadStepDoc extends JupyterCadDoc { } editable = false; + exportable = true; private _sourceObserver = (events: Y.YEvent[]): void => { const changes: Array<{ diff --git a/python/jupytercad_core/jupyter-config/server-config/jupytercad_core.json b/python/jupytercad_core/jupyter-config/server-config/jupytercad_core.json new file mode 100644 index 00000000..bd6dedce --- /dev/null +++ b/python/jupytercad_core/jupyter-config/server-config/jupytercad_core.json @@ -0,0 +1,7 @@ +{ + "ServerApp": { + "jpserver_extensions": { + "jupytercad_core": true + } + } +} diff --git a/python/jupytercad_core/jupytercad_core/__init__.py b/python/jupytercad_core/jupytercad_core/__init__.py index 65ae1f0c..291737ed 100644 --- a/python/jupytercad_core/jupytercad_core/__init__.py +++ b/python/jupytercad_core/jupytercad_core/__init__.py @@ -9,6 +9,20 @@ warnings.warn("Importing 'jupytercad_core' outside a proper installation.") __version__ = "dev" +from .handlers import setup_handlers + def _jupyter_labextension_paths(): return [{"src": "labextension", "dest": "@jupytercad/jupytercad-core"}] + + +def _load_jupyter_server_extension(server_app): + """Registers the API handler to receive HTTP requests from the frontend extension. + + Parameters + ---------- + server_app: jupyterlab.labapp.LabApp + JupyterLab application instance + """ + setup_handlers(server_app.web_app) + server_app.log.info("Registered jupytercad server extension") diff --git a/python/jupytercad_core/jupytercad_core/handlers.py b/python/jupytercad_core/jupytercad_core/handlers.py new file mode 100644 index 00000000..3a0f80e8 --- /dev/null +++ b/python/jupytercad_core/jupytercad_core/handlers.py @@ -0,0 +1,52 @@ +import json +from pathlib import Path + +from jupyter_server.base.handlers import APIHandler +from jupyter_server.utils import url_path_join, ApiPath, to_os_path +import tornado + + +class JCadExportHandler(APIHandler): + @tornado.web.authenticated + def post(self): + body = self.get_json_body() + + # Get filename removing the drive prefix + file_name = body["path"].split(":")[1] + export_name = body["newName"] + + root_dir = Path(self.contents_manager.root_dir).resolve() + file_name = Path(to_os_path(ApiPath(file_name), str(root_dir))) + + with open(file_name, "r") as fobj: + file_content = fobj.read() + + jcad = dict( + objects=[ + dict( + name=Path(export_name).stem, + visible=True, + shape="Part::Any", + parameters=dict( + Content=file_content, Type=str(Path(Path(file_name).suffix[1:])) + ), + ) + ], + metadata={}, + options={}, + outputs={}, + ) + + with open(Path(file_name).parents[0] / export_name, "w") as fobj: + fobj.write(json.dumps(jcad, indent=2)) + + self.finish(json.dumps({"done": True})) + + +def setup_handlers(web_app): + host_pattern = ".*$" + + base_url = web_app.settings["base_url"] + route_pattern = url_path_join(base_url, "jupytercad", "export") + handlers = [(route_pattern, JCadExportHandler)] + web_app.add_handlers(host_pattern, handlers) diff --git a/python/jupytercad_core/pyproject.toml b/python/jupytercad_core/pyproject.toml index c8cae945..22df36e0 100644 --- a/python/jupytercad_core/pyproject.toml +++ b/python/jupytercad_core/pyproject.toml @@ -47,6 +47,7 @@ exclude = [".github", "binder"] [tool.hatch.build.targets.wheel.shared-data] "install.json" = "share/jupyter/labextensions/@jupytercad/jupytercad-core/install.json" "jupytercad_core/labextension" = "share/jupyter/labextensions/@jupytercad/jupytercad-core" +"jupyter-config/server-config" = "etc/jupyter/jupyter_server_config.d" [tool.hatch.build.hooks.version] path = "jupytercad_core/_version.py" diff --git a/python/jupytercad_lab/src/index.ts b/python/jupytercad_lab/src/index.ts index 6e397999..6d477400 100644 --- a/python/jupytercad_lab/src/index.ts +++ b/python/jupytercad_lab/src/index.ts @@ -104,6 +104,9 @@ const controlPanel: JupyterFrontEndPlugin = { * Populates the application menus for the notebook. */ function populateMenus(mainMenu: IMainMenu, isEnabled: () => boolean): void { + mainMenu.fileMenu.addItem({ + command: CommandIDs.exportJcad + }); // Add undo/redo hooks to the edit menu. mainMenu.editMenu.undoers.redo.add({ id: CommandIDs.redo, diff --git a/scripts/dev-install.py b/scripts/dev-install.py index 3bebdb6a..0f8434ee 100644 --- a/scripts/dev-install.py +++ b/scripts/dev-install.py @@ -23,6 +23,10 @@ def install_dev(): execute(f"pip uninstall {py_package} -y") execute("jlpm clean:all", cwd=root_path / "python" / py_package) execute(f"pip install -e {python_package_prefix}/{py_package}") + + if py_package == "jupytercad_core": + execute("jupyter server extension enable jupytercad_core") + if py_package != "jupytercad_app": execute( f"jupyter labextension develop {python_package_prefix}/{py_package} --overwrite" diff --git a/ui-tests/tests/ui.spec.ts b/ui-tests/tests/ui.spec.ts index 88b8df1d..587dd7d7 100644 --- a/ui-tests/tests/ui.spec.ts +++ b/ui-tests/tests/ui.spec.ts @@ -24,7 +24,7 @@ test.describe('UI Test', () => { }); }); - test.describe('File rendering test', () => { + test.describe('File operations', () => { test.beforeAll(async ({ request }) => { const content = galata.newContentsHelper(request); await content.deleteDirectory('/examples'); @@ -76,6 +76,51 @@ test.describe('UI Test', () => { } }); } + + test(`Should be able to do export .STEP to .jcad`, async ({ page }) => { + await page.goto(); + + const fileName = '3M_CONNECTOR.STEP'; + const fullPath = `examples/${fileName}`; + await page.notebook.openByPath(fullPath); + await page.notebook.activate(fullPath); + + await page.waitForTimeout(3000); + + // Export to jcad + await page.getByRole('menuitem', { name: 'File' }).click(); + await page.getByText('Export to .jcad').click(); + + const accept = await page.locator('div.jp-Dialog-buttonLabel', { + hasText: 'Submit' + }); + accept.click(); + + await page.waitForTimeout(1000); + + // Refresh file browser + const filebrowserId = 'filebrowser'; + await page.sidebar.openTab(filebrowserId); + expect(await page.sidebar.isTabOpen(filebrowserId)).toBeTruthy(); + await page.filebrowser.openDirectory('examples'); + await page.filebrowser.refresh(); + + // Open new jcad file + const newFileName = '3M_CONNECTOR.jcad'; + const newFullPath = `examples/${newFileName}`; + + await page.notebook.openByPath(newFullPath); + await page.notebook.activate(newFullPath); + + await page.waitForTimeout(1000); + + const main = await page.$('#jp-main-split-panel'); + if (main) { + expect(await main.screenshot()).toMatchSnapshot({ + name: `JCAD-export-${fileName}.png` + }); + } + }); }); test.describe('File operator test', () => { diff --git a/ui-tests/tests/ui.spec.ts-snapshots/JCAD-export-3M-CONNECTOR-STEP-linux.png b/ui-tests/tests/ui.spec.ts-snapshots/JCAD-export-3M-CONNECTOR-STEP-linux.png new file mode 100644 index 00000000..f96069a2 Binary files /dev/null and b/ui-tests/tests/ui.spec.ts-snapshots/JCAD-export-3M-CONNECTOR-STEP-linux.png differ