diff --git a/packages/base/src/3dview/mainviewmodel.ts b/packages/base/src/3dview/mainviewmodel.ts index 4b83d0f7..8e162056 100644 --- a/packages/base/src/3dview/mainviewmodel.ts +++ b/packages/base/src/3dview/mainviewmodel.ts @@ -12,11 +12,13 @@ import { IPostOperatorInput, IPostResult, JCadWorkerSupportedFormat, + IDryRunResponsePayload, MainAction, - WorkerAction + WorkerAction, + IJCadContent } from '@jupytercad/schema'; import { ObservableMap } from '@jupyterlab/observables'; -import { JSONValue } from '@lumino/coreutils'; +import { JSONValue, PromiseDelegate, UUID } from '@lumino/coreutils'; import { IDisposable } from '@lumino/disposable'; import { ISignal, Signal } from '@lumino/signaling'; import { v4 as uuid } from 'uuid'; @@ -74,6 +76,7 @@ export class MainViewModel implements IDisposable { this ); } + initWorker(): void { this._worker = this._workerRegistry.getDefaultWorker(); this._id = this._worker.register({ @@ -87,7 +90,7 @@ export class MainViewModel implements IDisposable { }); } - messageHandler = (msg: IMainMessage): void => { + messageHandler(msg: IMainMessage): void { switch (msg.action) { case MainAction.DISPLAY_SHAPE: { const { result, postResult } = msg.payload; @@ -126,6 +129,10 @@ export class MainViewModel implements IDisposable { break; } + case MainAction.DRY_RUN_RESPONSE: { + this._dryRunResponses[msg.payload.id].resolve(msg.payload); + break; + } case MainAction.INITIALIZED: { if (!this._jcadModel) { return; @@ -140,7 +147,7 @@ export class MainViewModel implements IDisposable { }); } } - }; + } sendRawGeometryToWorker(postResult: IDict): void { Object.values(postResult).forEach(res => { @@ -167,7 +174,38 @@ export class MainViewModel implements IDisposable { }); } - postProcessWorkerHandler = (msg: IMainMessage): void => { + /** + * Send a payload to the worker to test its feasibility. + * + * Return true is the payload is valid, false otherwise. + */ + async dryRun(content: IJCadContent): Promise { + await this._worker.ready; + + const id = UUID.uuid4(); + + this._dryRunResponses[id] = new PromiseDelegate(); + + this._workerBusy.emit(true); + + this._postMessage({ + action: WorkerAction.DRY_RUN, + payload: { + id, + content + } + }); + + const response = await this._dryRunResponses[id].promise; + + delete this._dryRunResponses[id]; + + this._workerBusy.emit(false); + + return response; + } + + postProcessWorkerHandler(msg: IMainMessage): void { switch (msg.action) { case MainAction.DISPLAY_POST: { const postShapes: IDict = {}; @@ -180,27 +218,27 @@ export class MainViewModel implements IDisposable { break; } } - }; + } addAnnotation(value: IAnnotation): void { this._jcadModel.annotationModel?.addAnnotation(uuid(), value); } - private _postMessage = (msg: Omit) => { + private _postMessage(msg: Omit) { if (this._worker) { const newMsg = { ...msg, id: this._id }; this._worker.postMessage(newMsg); } - }; + } - private _saveMeta = (payload: IDisplayShape['payload']['result']) => { + private _saveMeta(payload: IDisplayShape['payload']['result']) { if (!this._jcadModel) { return; } Object.entries(payload).forEach(([objName, data]) => { this._jcadModel.sharedModel.setShapeMeta(objName, data.meta); }); - }; + } private async _onSharedObjectsChanged( _: IJupyterCadDoc, @@ -219,6 +257,7 @@ export class MainViewModel implements IDisposable { } } + private _dryRunResponses: IDict> = {}; private _jcadModel: IJupyterCadModel; private _viewSetting: ObservableMap; private _workerRegistry: IJCadWorkerRegistry; diff --git a/packages/base/src/commands.ts b/packages/base/src/commands.ts index a2a18628..e81a08fa 100644 --- a/packages/base/src/commands.ts +++ b/packages/base/src/commands.ts @@ -1,7 +1,9 @@ import { IDict, + IJCadContent, IJCadFormSchemaRegistry, IJCadObject, + IJCadWorkerRegistry, IJupyterCadDoc, IJupyterCadModel, ISelection, @@ -176,6 +178,50 @@ function getSelectedEdge( } } +export async function executeOperator( + name: string, + objectModel: IJCadObject, + current: JupyterCadWidget, + transaction: (sharedModel: IJupyterCadDoc) => any +) { + const sharedModel = current.context.model.sharedModel; + + if (!sharedModel) { + return; + } + + if (sharedModel.objectExists(objectModel.name)) { + showErrorMessage( + 'The object already exists', + 'There is an existing object with the same name.' + ); + + return; + } + + // Try a dry run with the update content to verify its feasibility + const currentJcadContent = current.context.model.getContent(); + const updatedContent: IJCadContent = { + ...currentJcadContent, + objects: [...currentJcadContent.objects, objectModel] + }; + const dryRunResult = + await current.content.currentViewModel.dryRun(updatedContent); + if (dryRunResult.status === 'error') { + showErrorMessage( + `Failed to create the ${name} operation`, + `The ${name} tool was unable to create the desired shape due to invalid parameter values. The values you entered may not be compatible with the dimensions of your piece.` + ); + + return; + } + + // Everything's good, we can apply the change to the shared model + sharedModel.transact(() => { + transaction(sharedModel); + }); +} + const OPERATORS = { cut: { title: 'Cut parameters', @@ -193,8 +239,8 @@ const OPERATORS = { Placement: { Position: [0, 0, 0], Axis: [0, 0, 1], Angle: 0 } }; }, - syncData: (model: IJupyterCadModel) => { - return (props: IDict) => { + syncData: (current: JupyterCadWidget) => { + return async (props: IDict) => { const { Name, ...parameters } = props; const objectModel: IJCadObject = { shape: 'Part::Cut', @@ -202,22 +248,18 @@ const OPERATORS = { visible: true, name: Name }; - const sharedModel = model.sharedModel; - if (sharedModel) { - sharedModel.transact(() => { + + return executeOperator( + 'Cut', + objectModel, + current, + (sharedModel: IJupyterCadDoc) => { setVisible(sharedModel, parameters['Base'], false); setVisible(sharedModel, parameters['Tool'], false); - if (!sharedModel.objectExists(objectModel.name)) { - sharedModel.addObject(objectModel); - } else { - showErrorMessage( - 'The object already exists', - 'There is an existing object with the same name.' - ); - } - }); - } + sharedModel.addObject(objectModel); + } + ); }; } }, @@ -238,8 +280,8 @@ const OPERATORS = { Placement: { Position: [0, 0, 0], Axis: [0, 0, 1], Angle: 0 } }; }, - syncData: (model: IJupyterCadModel) => { - return (props: IDict) => { + syncData: (current: JupyterCadWidget) => { + return async (props: IDict) => { const { Name, ...parameters } = props; const objectModel: IJCadObject = { shape: 'Part::Extrusion', @@ -247,21 +289,17 @@ const OPERATORS = { visible: true, name: Name }; - const sharedModel = model.sharedModel; - if (sharedModel) { - setVisible(sharedModel, parameters['Base'], false); - sharedModel.transact(() => { - if (!sharedModel.objectExists(objectModel.name)) { - sharedModel.addObject(objectModel); - } else { - showErrorMessage( - 'The object already exists', - 'There is an existing object with the same name.' - ); - } - }); - } + return executeOperator( + 'Extrusion', + objectModel, + current, + (sharedModel: IJupyterCadDoc) => { + setVisible(sharedModel, parameters['Base'], false); + + sharedModel.addObject(objectModel); + } + ); }; } }, @@ -280,8 +318,8 @@ const OPERATORS = { Placement: { Position: [0, 0, 0], Axis: [0, 0, 1], Angle: 0 } }; }, - syncData: (model: IJupyterCadModel) => { - return (props: IDict) => { + syncData: (current: JupyterCadWidget) => { + return async (props: IDict) => { const { Name, ...parameters } = props; const objectModel: IJCadObject = { shape: 'Part::MultiFuse', @@ -289,23 +327,19 @@ const OPERATORS = { visible: true, name: Name }; - const sharedModel = model.sharedModel; - if (sharedModel) { - sharedModel.transact(() => { + + return executeOperator( + 'Fuse', + objectModel, + current, + (sharedModel: IJupyterCadDoc) => { parameters['Shapes'].map((shape: string) => { setVisible(sharedModel, shape, false); }); - if (!sharedModel.objectExists(objectModel.name)) { - sharedModel.addObject(objectModel); - } else { - showErrorMessage( - 'The object already exists', - 'There is an existing object with the same name.' - ); - } - }); - } + sharedModel.addObject(objectModel); + } + ); }; } }, @@ -324,8 +358,8 @@ const OPERATORS = { Placement: { Position: [0, 0, 0], Axis: [0, 0, 1], Angle: 0 } }; }, - syncData: (model: IJupyterCadModel) => { - return (props: IDict) => { + syncData: (current: JupyterCadWidget) => { + return async (props: IDict) => { const { Name, ...parameters } = props; const objectModel: IJCadObject = { shape: 'Part::MultiCommon', @@ -333,23 +367,19 @@ const OPERATORS = { visible: true, name: Name }; - const sharedModel = model.sharedModel; - if (sharedModel) { - sharedModel.transact(() => { + + return executeOperator( + 'Intersection', + objectModel, + current, + (sharedModel: IJupyterCadDoc) => { parameters['Shapes'].map((shape: string) => { setVisible(sharedModel, shape, false); }); - if (!sharedModel.objectExists(objectModel.name)) { - sharedModel.addObject(objectModel); - } else { - showErrorMessage( - 'The object already exists', - 'There is an existing object with the same name.' - ); - } - }); - } + sharedModel.addObject(objectModel); + } + ); }; } }, @@ -367,8 +397,8 @@ const OPERATORS = { Placement: { Position: [0, 0, 0], Axis: [0, 0, 1], Angle: 0 } }; }, - syncData: (model: IJupyterCadModel) => { - return (props: IDict) => { + syncData: (current: JupyterCadWidget) => { + return async (props: IDict) => { const { Name, ...parameters } = props; const objectModel: IJCadObject = { shape: 'Part::Chamfer', @@ -376,21 +406,17 @@ const OPERATORS = { visible: true, name: Name }; - const sharedModel = model.sharedModel; - if (sharedModel) { - sharedModel.transact(() => { + + return executeOperator( + 'Chamfer', + objectModel, + current, + (sharedModel: IJupyterCadDoc) => { setVisible(sharedModel, parameters['Base'], false); - if (!sharedModel.objectExists(objectModel.name)) { - sharedModel.addObject(objectModel); - } else { - showErrorMessage( - 'The object already exists', - 'There is an existing object with the same name.' - ); - } - }); - } + sharedModel.addObject(objectModel); + } + ); }; } }, @@ -408,8 +434,8 @@ const OPERATORS = { Placement: { Position: [0, 0, 0], Axis: [0, 0, 1], Angle: 0 } }; }, - syncData: (model: IJupyterCadModel) => { - return (props: IDict) => { + syncData: (current: JupyterCadWidget) => { + return async (props: IDict) => { const { Name, ...parameters } = props; const objectModel: IJCadObject = { shape: 'Part::Fillet', @@ -417,21 +443,17 @@ const OPERATORS = { visible: true, name: Name }; - const sharedModel = model.sharedModel; - if (sharedModel) { - sharedModel.transact(() => { + + return executeOperator( + 'Fillet', + objectModel, + current, + (sharedModel: IJupyterCadDoc) => { setVisible(sharedModel, parameters['Base'], false); - if (!sharedModel.objectExists(objectModel.name)) { - sharedModel.addObject(objectModel); - } else { - showErrorMessage( - 'The object already exists', - 'There is an existing object with the same name.' - ); - } - }); - } + sharedModel.addObject(objectModel); + } + ); }; } } @@ -617,8 +639,10 @@ export function addCommands( app: JupyterFrontEnd, tracker: WidgetTracker, translator: ITranslator, - formSchemaRegistry: IJCadFormSchemaRegistry + formSchemaRegistry: IJCadFormSchemaRegistry, + workerRegistry: IJCadWorkerRegistry ): void { + workerRegistry.getWorker; const trans = translator.load('jupyterlab'); const { commands } = app; Private.updateFormSchema(formSchemaRegistry); @@ -1083,7 +1107,7 @@ namespace Private { title: op.title, sourceData: op.default(current.context.model), schema: form_schema, - syncData: op.syncData(current.context.model), + syncData: op.syncData(current), cancelButton: true }); await dialog.launch(); diff --git a/packages/base/src/panelview/objectproperties.tsx b/packages/base/src/panelview/objectproperties.tsx index b092182f..10b1b5d3 100644 --- a/packages/base/src/panelview/objectproperties.tsx +++ b/packages/base/src/panelview/objectproperties.tsx @@ -5,9 +5,10 @@ import { IJcadObjectDocChange, IJupyterCadClientState, IJupyterCadDoc, - IJupyterCadModel + IJupyterCadModel, + IJupyterCadTracker } from '@jupytercad/schema'; -import { ReactWidget } from '@jupyterlab/apputils'; +import { ReactWidget, showErrorMessage } from '@jupyterlab/apputils'; import { PanelWithToolbar } from '@jupyterlab/ui-components'; import { Panel } from '@lumino/widgets'; import * as React from 'react'; @@ -20,6 +21,7 @@ import { } from '../tools'; import { IControlPanelModel } from '../types'; import { ObjectPropertiesForm } from './formbuilder'; +import { JupyterCadWidget } from '../widget'; export class ObjectProperties extends PanelWithToolbar { constructor(params: ObjectProperties.IOptions) { @@ -28,6 +30,7 @@ export class ObjectProperties extends PanelWithToolbar { const body = ReactWidget.create( ); @@ -50,6 +53,7 @@ interface IStates { interface IProps { cpModel: IControlPanelModel; + tracker: IJupyterCadTracker; formSchemaRegistry: IJCadFormSchemaRegistry; } @@ -97,7 +101,7 @@ class ObjectPropertiesReact extends React.Component { }); } - syncObjectProperties( + async syncObjectProperties( objectName: string | undefined, properties: { [key: string]: any } ) { @@ -105,10 +109,43 @@ class ObjectPropertiesReact extends React.Component { return; } - const model = this.props.cpModel.jcadModel?.sharedModel; - const obj = model?.getObjectByName(objectName); - if (model && obj) { - model.updateObjectByName(objectName, 'parameters', { + const currentWidget = this.props.tracker + .currentWidget as JupyterCadWidget | null; + if (!currentWidget) { + return; + } + + const model = this.props.cpModel.jcadModel; + if (!model) { + return; + } + + // getContent already returns a deep copy of the content, we can change it safely here + const updatedContent = model.getContent(); + for (const object of updatedContent.objects) { + if (object.name === objectName) { + object.parameters = { + ...object.parameters, + ...properties + }; + } + } + + // Try a dry run + const dryRunResult = + await currentWidget.content.currentViewModel.dryRun(updatedContent); + if (dryRunResult.status === 'error') { + showErrorMessage( + 'Failed to update the shape', + 'The tool was unable to update the desired shape due to invalid parameter values. The values you entered may not be compatible with the dimensions of your piece.' + ); + return; + } + + // Dry run was successful, ready to apply the update now + const obj = model.sharedModel.getObjectByName(objectName); + if (obj) { + model.sharedModel.updateObjectByName(objectName, 'parameters', { ...obj['parameters'], ...properties }); @@ -278,5 +315,6 @@ export namespace ObjectProperties { export interface IOptions extends Panel.IOptions { controlPanelModel: IControlPanelModel; formSchemaRegistry: IJCadFormSchemaRegistry; + tracker: IJupyterCadTracker; } } diff --git a/packages/base/src/panelview/rightpanel.tsx b/packages/base/src/panelview/rightpanel.tsx index 5c658d83..aae1680e 100644 --- a/packages/base/src/panelview/rightpanel.tsx +++ b/packages/base/src/panelview/rightpanel.tsx @@ -1,4 +1,8 @@ -import { IJCadFormSchemaRegistry, JupyterCadDoc } from '@jupytercad/schema'; +import { + IJCadFormSchemaRegistry, + IJupyterCadTracker, + JupyterCadDoc +} from '@jupytercad/schema'; import { SidePanel } from '@jupyterlab/ui-components'; import { IControlPanelModel } from '../types'; @@ -14,7 +18,8 @@ export class RightPanelWidget extends SidePanel { this.header.addWidget(header); const properties = new ObjectProperties({ controlPanelModel: this._model, - formSchemaRegistry: options.formSchemaRegistry + formSchemaRegistry: options.formSchemaRegistry, + tracker: options.tracker }); this.addWidget(properties); @@ -42,6 +47,7 @@ export class RightPanelWidget extends SidePanel { export namespace RightPanelWidget { export interface IOptions { model: IControlPanelModel; + tracker: IJupyterCadTracker; formSchemaRegistry: IJCadFormSchemaRegistry; } export interface IProps { diff --git a/packages/base/src/widget.tsx b/packages/base/src/widget.tsx index 38bdc4fe..b9a9ee9b 100644 --- a/packages/base/src/widget.tsx +++ b/packages/base/src/widget.tsx @@ -36,7 +36,7 @@ export class JupyterCadWidget export class JupyterCadPanel extends ReactWidget { /** - * Construct a `ExamplePanel`. + * Construct a `JupyterCadPanel`. * * @param context - The documents context. */ @@ -73,6 +73,10 @@ export class JupyterCadPanel extends ReactWidget { super.dispose(); } + get currentViewModel(): MainViewModel { + return this._mainViewModel; + } + get axes(): AxeHelper | undefined { return this._view.get('axes') as AxeHelper | undefined; } @@ -112,6 +116,7 @@ export class JupyterCadPanel extends ReactWidget { render(): JSX.Element { return ; } + private _mainViewModel: MainViewModel; private _view: ObservableMap; } diff --git a/packages/occ-worker/src/occapi/operatorCache.ts b/packages/occ-worker/src/occapi/operatorCache.ts index 3063a9e8..f0c81129 100644 --- a/packages/occ-worker/src/occapi/operatorCache.ts +++ b/packages/occ-worker/src/occapi/operatorCache.ts @@ -146,6 +146,7 @@ export function shape_meta_data(shape: OCC.TopoDS_Shape): IShapeMetadata { ] }; } + export function operatorCache( name: Parts | 'ObjectFile', ops: (args: T, content: IJCadContent) => OCC.TopoDS_Shape | undefined @@ -153,9 +154,7 @@ export function operatorCache( return ( args: T, content: IJCadContent - ): - | { occShape: OCC.TopoDS_Shape; metadata?: IShapeMetadata | undefined } - | undefined => { + ): { occShape: OCC.TopoDS_Shape; metadata?: IShapeMetadata | undefined } => { const expandedArgs = expand_operator(name, args, content); const hash = `${hashCode(JSON.stringify(expandedArgs))}`; if (SHAPE_CACHE.has(hash)) { @@ -169,6 +168,10 @@ export function operatorCache( }; SHAPE_CACHE.set(hash, cacheData); return cacheData; + } else { + throw new Error( + `Unknown error while creating ${name}: ${JSON.stringify(content)}` + ); } } }; diff --git a/packages/occ-worker/src/types.ts b/packages/occ-worker/src/types.ts index 3d354329..2278c5e7 100644 --- a/packages/occ-worker/src/types.ts +++ b/packages/occ-worker/src/types.ts @@ -40,7 +40,15 @@ export interface ILoadFile extends IWorkerMessageBase { }; } -export type IWorkerMessage = ILoadFile | IRegister; +export interface IDryRun extends IWorkerMessageBase { + action: WorkerAction.DRY_RUN; + payload: { + id: string; + content: IJCadContent; + }; +} + +export type IWorkerMessage = ILoadFile | IRegister | IDryRun; export interface IOperatorFuncOutput { occShape?: OCC.TopoDS_Shape; diff --git a/packages/occ-worker/src/worker.ts b/packages/occ-worker/src/worker.ts index fb7b6d71..aa240da4 100644 --- a/packages/occ-worker/src/worker.ts +++ b/packages/occ-worker/src/worker.ts @@ -55,5 +55,43 @@ self.onmessage = async (event: MessageEvent): Promise => { ); break; } + case WorkerAction.DRY_RUN: { + try { + WorkerHandler[WorkerAction.LOAD_FILE](message.payload); + } catch (e) { + let msg = ''; + + if (typeof e === 'string') { + msg = e; + } else if (e instanceof Error) { + msg = e.message; + } + + sendToMain( + { + action: MainAction.DRY_RUN_RESPONSE, + payload: { + id: message.payload.id, + status: 'error', + message: msg + } + }, + id + ); + return; + } + + sendToMain( + { + action: MainAction.DRY_RUN_RESPONSE, + payload: { + id: message.payload.id, + status: 'ok' + } + }, + id + ); + break; + } } }; diff --git a/packages/schema/src/interfaces.ts b/packages/schema/src/interfaces.ts index cf642f4e..e7a417c3 100644 --- a/packages/schema/src/interfaces.ts +++ b/packages/schema/src/interfaces.ts @@ -222,6 +222,7 @@ export interface IPostOperatorInput { * Action definitions for worker */ export enum WorkerAction { + DRY_RUN = 'DRY_RUN', LOAD_FILE = 'LOAD_FILE', SAVE_FILE = 'SAVE_FILE', REGISTER = 'REGISTER', @@ -234,7 +235,8 @@ export enum WorkerAction { export enum MainAction { DISPLAY_SHAPE = 'DISPLAY_SHAPE', INITIALIZED = 'INITIALIZED', - DISPLAY_POST = 'DISPLAY_POST' + DISPLAY_POST = 'DISPLAY_POST', + DRY_RUN_RESPONSE = 'DRY_RUN_RESPONSE' } export interface IMainMessageBase { @@ -249,6 +251,18 @@ export interface IDisplayShape extends IMainMessageBase { postResult: IDict; }; } + +export interface IDryRunResponsePayload { + id: string; + status: 'ok' | 'error'; + message?: string; +} + +export interface IDryRunResponse extends IMainMessageBase { + action: MainAction.DRY_RUN_RESPONSE; + payload: IDryRunResponsePayload; +} + export interface IWorkerInitialized extends IMainMessageBase { action: MainAction.INITIALIZED; payload: boolean; @@ -267,7 +281,11 @@ export interface IDisplayPost extends IMainMessageBase { }[]; } -export type IMainMessage = IDisplayShape | IWorkerInitialized | IDisplayPost; +export type IMainMessage = + | IDisplayShape + | IWorkerInitialized + | IDisplayPost + | IDryRunResponse; export interface IWorkerMessageBase { id: string; diff --git a/python/jupytercad_lab/src/index.ts b/python/jupytercad_lab/src/index.ts index 6d477400..4802288f 100644 --- a/python/jupytercad_lab/src/index.ts +++ b/python/jupytercad_lab/src/index.ts @@ -12,6 +12,8 @@ import { IAnnotationToken, IJCadFormSchemaRegistry, IJCadFormSchemaRegistryToken, + IJCadWorkerRegistry, + IJCadWorkerRegistryToken, IJupyterCadDocTracker, IJupyterCadTracker } from '@jupytercad/schema'; @@ -31,12 +33,17 @@ const NAME_SPACE = 'jupytercad'; const plugin: JupyterFrontEndPlugin = { id: 'jupytercad:lab:main-menu', autoStart: true, - requires: [IJupyterCadDocTracker, IJCadFormSchemaRegistryToken], + requires: [ + IJupyterCadDocTracker, + IJCadFormSchemaRegistryToken, + IJCadWorkerRegistryToken + ], optional: [IMainMenu, ITranslator], activate: ( app: JupyterFrontEnd, tracker: WidgetTracker, formSchemaRegistry: IJCadFormSchemaRegistry, + workerRegistry: IJCadWorkerRegistry, mainMenu?: IMainMenu, translator?: ITranslator ): void => { @@ -49,7 +56,7 @@ const plugin: JupyterFrontEndPlugin = { ); }; - addCommands(app, tracker, translator, formSchemaRegistry); + addCommands(app, tracker, translator, formSchemaRegistry, workerRegistry); if (mainMenu) { populateMenus(mainMenu, isEnabled); } @@ -85,6 +92,7 @@ const controlPanel: JupyterFrontEndPlugin = { const rightControlPanel = new RightPanelWidget({ model: controlModel, + tracker, formSchemaRegistry }); rightControlPanel.id = 'jupytercad::rightControlPanel';