diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index edc27149..f3085e7b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,7 +32,7 @@ repos: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.1 + rev: v0.8.6 hooks: - id: ruff args: ['--fix'] @@ -44,7 +44,7 @@ repos: entry: prettier --no-error-on-unmatched-pattern --write --ignore-unknown - repo: https://github.com/pre-commit/mirrors-eslint - rev: v9.16.0 + rev: v9.17.0 hooks: - id: eslint files: \.tsx?$ diff --git a/packages/base/package.json b/packages/base/package.json index 45f753be..b8753cf2 100644 --- a/packages/base/package.json +++ b/packages/base/package.json @@ -35,7 +35,7 @@ "watch": "tsc -w" }, "dependencies": { - "@jupyter/collaborative-drive": "^3.1.0-alpha.0", + "@jupyter/collaborative-drive": "^3.1.0", "@jupyter/ydoc": "^3.0.0", "@jupytercad/occ-worker": "^3.0.0", "@jupytercad/schema": "^3.0.0", diff --git a/packages/base/src/3dview/mainview.tsx b/packages/base/src/3dview/mainview.tsx index 7e672af7..b800e33d 100644 --- a/packages/base/src/3dview/mainview.tsx +++ b/packages/base/src/3dview/mainview.tsx @@ -78,6 +78,8 @@ interface IStates { wireframe: boolean; transform: boolean; clipEnabled: boolean; + rotationSnapValue: number; + transformMode: string | undefined; } interface ILineIntersection extends THREE.Intersection { @@ -122,7 +124,9 @@ export class MainView extends React.Component { firstLoad: true, wireframe: false, transform: false, - clipEnabled: true + clipEnabled: true, + rotationSnapValue: 10, + transformMode: 'translate' }; } @@ -139,10 +143,27 @@ export class MainView extends React.Component { this.lookAtPosition(customEvent.detail.objPosition); } }); + this._transformControls.rotationSnap = THREE.MathUtils.degToRad( + this.state.rotationSnapValue + ); + this._keyDownHandler = (event: KeyboardEvent) => { + if (event.key === 'r') { + const newMode = this._transformControls.mode || 'translate'; + if (this.state.transformMode !== newMode) { + this.setState({ transformMode: newMode }); + } + } + }; + document.addEventListener('keydown', this._keyDownHandler); } componentDidUpdate(oldProps: IProps, oldState: IStates): void { this.resizeCanvasToDisplaySize(); + if (oldState.rotationSnapValue !== this.state.rotationSnapValue) { + this._transformControls.rotationSnap = THREE.MathUtils.degToRad( + this.state.rotationSnapValue + ); + } } componentWillUnmount(): void { @@ -172,6 +193,8 @@ export class MainView extends React.Component { this._mainViewModel.renderSignal.disconnect(this._requestRender, this); this._mainViewModel.workerBusy.disconnect(this._workerBusyHandler, this); this._mainViewModel.dispose(); + + document.removeEventListener('keydown', this._keyDownHandler); } addContextMenu = (): void => { @@ -1265,6 +1288,7 @@ export class MainView extends React.Component { if (material?.linewidth) { material.linewidth = SELECTED_LINEWIDTH; } + selectedMesh.material.wireframe = false; } else { // Highlight non-edges using a bounding box const parentGroup = this._meshGroup?.getObjectByName(selectedMesh.name) @@ -1864,6 +1888,14 @@ export class MainView extends React.Component { }); return screenPosition; } + + private _handleSnapChange = (e: React.ChangeEvent) => { + const value = parseFloat(e.target.value); + if (!isNaN(value) && value > 0) { + this.setState({ rotationSnapValue: value }); + } + }; + render(): JSX.Element { const isTransformOrClipEnabled = this.state.transform || this._clipSettings.enabled; @@ -1925,7 +1957,25 @@ export class MainView extends React.Component { fontSize: '12px' }} > - Press R to switch mode +
Press R to switch mode
+ + {this.state.transformMode === 'rotate' && ( +
+ + +
+ )} )}
{ private _sliderPos = 0; private _slideInit = false; private _sceneL: THREE.Scene | undefined = undefined; + private _keyDownHandler: (event: KeyboardEvent) => void; } diff --git a/packages/base/src/commands.ts b/packages/base/src/commands.ts index e98f8c26..74abc533 100644 --- a/packages/base/src/commands.ts +++ b/packages/base/src/commands.ts @@ -345,13 +345,19 @@ const OPERATORS = { default: (model: IJupyterCadModel) => { const objects = model.getAllObject(); const selected = model.localState?.selected.value || {}; - const sel0 = getSelectedMeshName(selected, 0); - const sel1 = getSelectedMeshName(selected, 1); - const baseName = sel0 || objects[0].name || ''; - const baseModel = model.sharedModel.getObjectByName(baseName); + + const selectedShapes = Object.keys(selected).map(key => key); + + // Fallback to at least two objects if selection is empty + const baseShapes = + selectedShapes.length > 0 + ? selectedShapes + : [objects[0].name || '', objects[1].name || '']; + + const baseModel = model.sharedModel.getObjectByName(baseShapes[0]); return { Name: newName('Union', model), - Shapes: [baseName, sel1 || objects[1].name || ''], + Shapes: baseShapes, Refine: false, Color: baseModel?.parameters?.Color || DEFAULT_MESH_COLOR, Placement: { Position: [0, 0, 0], Axis: [0, 0, 1], Angle: 0 } @@ -1237,7 +1243,11 @@ export function addCommands( counter++; } const jcadModel = current.context.model; - const newObject = { ...clipboard, name: newName }; + const newObject = { + ...clipboard, + name: newName, + visible: true + }; sharedModel.addObject(newObject); jcadModel.syncSelected({ [newObject.name]: { type: 'shape' } }, uuid()); } diff --git a/packages/occ-worker/src/occapi/fuse.ts b/packages/occ-worker/src/occapi/fuse.ts index dc4f6d42..62387b01 100644 --- a/packages/occ-worker/src/occapi/fuse.ts +++ b/packages/occ-worker/src/occapi/fuse.ts @@ -30,13 +30,27 @@ export function _Fuse( } } }); - const operator = new oc.BRepAlgoAPI_Fuse_3( - occShapes[0], - occShapes[1], - new oc.Message_ProgressRange_1() - ); - if (operator.IsDone()) { - return setShapePlacement(operator.Shape(), Placement); + + if (occShapes.length === 0) { + return; + } + + let fusedShape = occShapes[0]; + + for (let i = 1; i < occShapes.length; i++) { + const operator = new oc.BRepAlgoAPI_Fuse_3( + fusedShape, + occShapes[i], + new oc.Message_ProgressRange_1() + ); + + if (operator.IsDone()) { + fusedShape = operator.Shape(); + } else { + console.error(`Fusion failed at index ${i}`); + return; + } } - return; + + return setShapePlacement(fusedShape, Placement); } diff --git a/python/jupytercad/README.md b/python/jupytercad/README.md index 8f4816cb..306e2de2 100644 --- a/python/jupytercad/README.md +++ b/python/jupytercad/README.md @@ -1,6 +1,11 @@ # JupyterCAD - A JupyterLab extension for collaborative 3D geometry modeling. -[![Lite](https://jupyterlite.rtfd.io/en/latest/_static/badge.svg)](https://jupytercad.github.io/JupyterCAD/) +[![lite-badge]][lite] [![docs-badge]][docs] + +[lite-badge]: https://jupyterlite.rtfd.io/en/latest/_static/badge.svg +[lite]: https://jupytercad.github.io/JupyterCAD/ +[docs-badge]: https://readthedocs.org/projects/jupytergis/badge/?version=latest +[docs]: https://jupytercad.readthedocs.io/ JupyterCAD is a JupyterLab extension for 3D geometry modeling with collaborative editing support. It is designed to allow multiple people to work on the same file at the same time, and to facilitate discussion and collaboration around the 3D shapes being created. diff --git a/python/jupytercad_core/pyproject.toml b/python/jupytercad_core/pyproject.toml index e7456c25..035b7971 100644 --- a/python/jupytercad_core/pyproject.toml +++ b/python/jupytercad_core/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ dependencies = [ "jupyter_server>=2.0.6,<3", "jupyter_ydoc>=2,<4", - "jupyter-collaboration>=3.0.0,<4", + "jupyter-collaboration>=3.1.0,<4", "pydantic>=2,<3", ] dynamic = ["version", "description", "authors", "urls", "keywords"] diff --git a/ui-tests/tests/notebook.spec.ts-snapshots/dark-Notebook-ipynb-cell-1-linux.png b/ui-tests/tests/notebook.spec.ts-snapshots/dark-Notebook-ipynb-cell-1-linux.png index 49c7cb29..036baff4 100644 Binary files a/ui-tests/tests/notebook.spec.ts-snapshots/dark-Notebook-ipynb-cell-1-linux.png and b/ui-tests/tests/notebook.spec.ts-snapshots/dark-Notebook-ipynb-cell-1-linux.png differ diff --git a/ui-tests/tests/ui.spec.ts b/ui-tests/tests/ui.spec.ts index 476b60c3..11f2ee02 100644 --- a/ui-tests/tests/ui.spec.ts +++ b/ui-tests/tests/ui.spec.ts @@ -382,4 +382,86 @@ test.describe('UI Test', () => { } }); }); + + test.describe('Suggestion Panel test', () => { + test(`Test Delete Suggestion`, async ({ page }) => { + await page.goto(); + + const fileName = 'test.jcad'; + const fullPath = `examples/${fileName}`; + await page.notebook.openByPath(fullPath); + await page.notebook.activate(fullPath); + await page.locator('div.jpcad-Spinner').waitFor({ state: 'hidden' }); + + // Activate Right Panel + await page.locator('li#tab-key-1-7').click(); + await page.getByTitle('Create new fork').click(); + await page.locator('div.jp-Dialog-buttonLabel[aria-label="Ok"]').click(); + + // Select cone + await page + .locator('[data-test-id="react-tree-root"]') + .getByText('Cone 1') + .click(); + + await page.locator('input#root_Height').click(); + await page.locator('input#root_Height').fill('20'); + + await page + .locator('div.jp-Dialog-buttonLabel', { + hasText: 'Submit' + }) + .click(); + + await page.getByTitle('Delete suggestion').click(); + await page.locator('div.jp-Dialog-buttonLabel[aria-label="Ok"]').click(); + + let main = await page.$('#jp-main-split-panel'); + if (main) { + expect(await main.screenshot()).toMatchSnapshot({ + name: `JCAD-Delete-Suggestion.png` + }); + } + }); + + test(`Test Accept Suggestion`, async ({ page }) => { + await page.goto(); + + const fileName = 'test.jcad'; + const fullPath = `examples/${fileName}`; + await page.notebook.openByPath(fullPath); + await page.notebook.activate(fullPath); + await page.locator('div.jpcad-Spinner').waitFor({ state: 'hidden' }); + + // Activate Right Panel + await page.locator('li#tab-key-1-7').click(); + await page.getByTitle('Create new fork').click(); + await page.locator('div.jp-Dialog-buttonLabel[aria-label="Ok"]').click(); + + // Select cone + await page + .locator('[data-test-id="react-tree-root"]') + .getByText('Cone 1') + .click(); + + await page.locator('input#root_Height').click(); + await page.locator('input#root_Height').fill('20'); + + await page + .locator('div.jp-Dialog-buttonLabel', { + hasText: 'Submit' + }) + .click(); + + await page.getByTitle('Accept suggestion').click(); + await page.locator('div.jp-Dialog-buttonLabel[aria-label="Ok"]').click(); + + let main = await page.$('#jp-main-split-panel'); + if (main) { + expect(await main.screenshot()).toMatchSnapshot({ + name: `JCAD-Accept-Suggestion.png` + }); + } + }); + }); }); diff --git a/ui-tests/tests/ui.spec.ts-snapshots/JCAD-Accept-Suggestion-linux.png b/ui-tests/tests/ui.spec.ts-snapshots/JCAD-Accept-Suggestion-linux.png new file mode 100644 index 00000000..d32f6abd Binary files /dev/null and b/ui-tests/tests/ui.spec.ts-snapshots/JCAD-Accept-Suggestion-linux.png differ diff --git a/ui-tests/tests/ui.spec.ts-snapshots/JCAD-Delete-Suggestion-linux.png b/ui-tests/tests/ui.spec.ts-snapshots/JCAD-Delete-Suggestion-linux.png new file mode 100644 index 00000000..378ea17b Binary files /dev/null and b/ui-tests/tests/ui.spec.ts-snapshots/JCAD-Delete-Suggestion-linux.png differ diff --git a/yarn.lock b/yarn.lock index 7ac58c5b..9dc2be4a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -691,6 +691,18 @@ __metadata: languageName: node linkType: hard +"@jupyter/collaborative-drive@npm:^3.1.0": + version: 3.1.0 + resolution: "@jupyter/collaborative-drive@npm:3.1.0" + dependencies: + "@jupyter/ydoc": ^2.0.0 || ^3.0.0 + "@jupyterlab/services": ^7.2.0 + "@lumino/coreutils": ^2.1.0 + "@lumino/disposable": ^2.1.0 + checksum: ef4288ccc6dc4b353d53c014b2caa87d29e5af64c1649175b6ff75127297953139f79f8774c22ab0e249cc5fcef36dc6503a6c3862e160205bf6a55b368e6ac8 + languageName: node + linkType: hard + "@jupyter/collaborative-drive@npm:^3.1.0-alpha.0, @jupyter/collaborative-drive@npm:^3.1.0-rc.0": version: 3.1.0-rc.0 resolution: "@jupyter/collaborative-drive@npm:3.1.0-rc.0" @@ -767,7 +779,7 @@ __metadata: resolution: "@jupytercad/base@workspace:packages/base" dependencies: "@apidevtools/json-schema-ref-parser": ^9.0.9 - "@jupyter/collaborative-drive": ^3.1.0-alpha.0 + "@jupyter/collaborative-drive": ^3.1.0 "@jupyter/ydoc": ^3.0.0 "@jupytercad/occ-worker": ^3.0.0 "@jupytercad/schema": ^3.0.0