From e08bc30e2a33453f049fdb7816a5ff669cf18f27 Mon Sep 17 00:00:00 2001 From: asmith26 Date: Sat, 22 Nov 2025 16:24:41 +0000 Subject: [PATCH 1/4] Add measure support --- packages/base/src/3dview/mainview.tsx | 83 ++++++++++-- packages/base/src/3dview/measurement.tsx | 159 +++++++++++++++++++++++ ui-tests/tests/measure.spec.ts | 54 ++++++++ 3 files changed, 285 insertions(+), 11 deletions(-) create mode 100644 packages/base/src/3dview/measurement.tsx create mode 100644 ui-tests/tests/measure.spec.ts diff --git a/packages/base/src/3dview/mainview.tsx b/packages/base/src/3dview/mainview.tsx index 25c35be2..d97ae475 100644 --- a/packages/base/src/3dview/mainview.tsx +++ b/packages/base/src/3dview/mainview.tsx @@ -22,6 +22,7 @@ import { TransformControls } from 'three/examples/jsm/controls/TransformControls import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'; import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'; import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'; +import { CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; import { FloatingAnnotation } from '../annotation'; import { getCSSVariableColor, throttle } from '../tools'; @@ -55,6 +56,7 @@ import { SPLITVIEW_BACKGROUND_COLOR_CSS } from './helpers'; import { MainViewModel } from './mainviewmodel'; +import { Measurement } from './measurement'; import { Spinner } from './spinner'; interface IProps { viewModel: MainViewModel; @@ -81,6 +83,7 @@ interface IStates { rotationSnapValue: number; translationSnapValue: number; transformMode: string | undefined; + selectionBox: THREE.Box3 | null; } interface ILineIntersection extends THREE.Intersection { @@ -131,7 +134,8 @@ export class MainView extends React.Component { explodedViewFactor: 0, rotationSnapValue: 10, translationSnapValue: 1, - transformMode: 'translate' + transformMode: 'translate', + selectionBox: null }; this._model.settingsChanged.connect(this._handleSettingsChange, this); @@ -164,17 +168,45 @@ export class MainView extends React.Component { 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 - ); - } - if (oldState.translationSnapValue !== this.state.translationSnapValue) { - this._transformControls.translationSnap = this.state.translationSnapValue; + componentDidUpdate(oldProps: IProps, oldState: IStates): void { + // Resize the canvas to fit the display area + this.resizeCanvasToDisplaySize(); + + // Update transform controls rotation snap if the value has changed + if (oldState.rotationSnapValue !== this.state.rotationSnapValue) { + this._transformControls.rotationSnap = THREE.MathUtils.degToRad( + this.state.rotationSnapValue + ); + } + + // Update transform controls translation snap if the value has changed + if (oldState.translationSnapValue !== this.state.translationSnapValue) { + this._transformControls.translationSnap = this.state.translationSnapValue; + } + + // Handle measurement display based on the selection box + if (oldState.selectionBox !== this.state.selectionBox) { + // If there is a new selection box, create and display measurements + if (this.state.selectionBox) { + // Clear any existing measurement visuals + if (this._measurementGroup) { + this._measurementGroup.clear(); + this._scene.remove(this._measurementGroup); + } + // Create a new measurement object for the selection box + const measurement = new Measurement({ box: this.state.selectionBox }); + this._measurementGroup = measurement.group; + this._scene.add(this._measurementGroup); + } else { + // If the selection box is removed, clear the measurement visuals + if (this._measurementGroup) { + this._measurementGroup.clear(); + this._scene.remove(this._measurementGroup); + this._measurementGroup = null; + } + } + } } - } componentWillUnmount(): void { window.cancelAnimationFrame(this._requestID); @@ -317,6 +349,15 @@ export class MainView extends React.Component { this._renderer.setSize(500, 500, false); this._divRef.current.appendChild(this._renderer.domElement); // mount using React ref + // Initialize the CSS2DRenderer for displaying labels + this._labelRenderer = new CSS2DRenderer(); + this._labelRenderer.setSize(500, 500); // Set initial size + this._labelRenderer.domElement.style.position = 'absolute'; + this._labelRenderer.domElement.style.top = '0px'; + // Disable pointer events so the 3D view can be controlled from behind the labels + this._labelRenderer.domElement.style.pointerEvents = 'none'; + this._divRef.current.appendChild(this._labelRenderer.domElement); + this._syncPointer = throttle( (position: THREE.Vector3 | undefined, parent: string | undefined) => { if (position && parent) { @@ -656,6 +697,7 @@ export class MainView extends React.Component { this._renderer.render(this._scene, this._camera); + this._labelRenderer.render(this._scene, this._camera); // Render the 2D labels on top of the 3D scene this._viewHelper.render(this._renderer); this.updateCameraRotation(); }; @@ -667,6 +709,10 @@ export class MainView extends React.Component { this._divRef.current.clientHeight, false ); + this._labelRenderer.setSize( + this._divRef.current.clientWidth, + this._divRef.current.clientHeight + ); if (this._camera instanceof THREE.PerspectiveCamera) { this._camera.aspect = this._divRef.current.clientWidth / this._divRef.current.clientHeight; @@ -1358,6 +1404,19 @@ export class MainView extends React.Component { } this._updateTransformControls(selectedNames); + + // Calculate bounding box for selected items + if (this._selectedMeshes.length > 0) { + const combinedBox = new THREE.Box3(); + for (const mesh of this._selectedMeshes) { + // We need to account for the object's transformation + const box = new THREE.Box3().setFromObject(mesh); + combinedBox.union(box); + } + this.setState({ selectionBox: combinedBox }); + } else { + this.setState({ selectionBox: null }); + } } /* @@ -2203,6 +2262,7 @@ export class MainView extends React.Component { private _edgeMaterials: any[] = []; private _currentSelection: { [key: string]: ISelection } | null = null; + private _measurementGroup: THREE.Group | null = null; private _scene: THREE.Scene; // Threejs scene private _ambientLight: THREE.AmbientLight; @@ -2210,6 +2270,7 @@ export class MainView extends React.Component { private _cameraLight: THREE.PointLight; private _raycaster = new THREE.Raycaster(); private _renderer: THREE.WebGLRenderer; // Threejs render + private _labelRenderer: CSS2DRenderer; private _requestID: any = null; // ID of window.requestAnimationFrame private _geometry: THREE.BufferGeometry; // Threejs BufferGeometry private _refLength: number | null = null; // Length of bounding box of current object diff --git a/packages/base/src/3dview/measurement.tsx b/packages/base/src/3dview/measurement.tsx new file mode 100644 index 00000000..4d722991 --- /dev/null +++ b/packages/base/src/3dview/measurement.tsx @@ -0,0 +1,159 @@ +/** + * This file defines a React component for rendering measurements of a 3D object. + * It uses `three.js` to create dimension lines and labels for a given bounding box. + */ +import * as React from 'react'; +import * as THREE from 'three'; +import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; + +/** + * Props for the Measurement component. + */ +interface IMeasurementProps { + /** + * The bounding box to measure. + */ + box: THREE.Box3; +} + +/** + * A React component that displays the dimensions of a THREE.Box3. + * It creates visual annotations (lines and labels) for the X, Y, and Z dimensions. + */ +export class Measurement extends React.Component { + private _group: THREE.Group; + + /** + * Constructor for the Measurement component. + * @param props The component props. + */ + constructor(props: IMeasurementProps) { + super(props); + this._group = new THREE.Group(); + this.createAnnotations(); + } + + /** + * Called when the component updates. + * If the bounding box has changed, it clears the old annotations and creates new ones. + * @param prevProps The previous component props. + */ + componentDidUpdate(prevProps: IMeasurementProps) { + if (this.props.box !== prevProps.box) { + this.clearAnnotations(); + this.createAnnotations(); + } + } + + /** + * Called when the component is about to be unmounted. + * It clears any existing annotations. + */ + componentWillUnmount() { + this.clearAnnotations(); + } + + /** + * Removes all annotations from the scene. + */ + clearAnnotations() { + this._group.clear(); + } + + /** + * Creates the dimension lines and labels for the bounding box. + */ + createAnnotations() { + const { box } = this.props; + if (!box) { + return; + } + + const size = new THREE.Vector3(); + box.getSize(size); + + const min = box.min; + const max = box.max; + + // Create dimension lines for X, Y, and Z axes + this.createDimensionLine( + new THREE.Vector3(min.x, min.y, min.z), + new THREE.Vector3(max.x, min.y, min.z), + 'X', + size.x + ); + this.createDimensionLine( + new THREE.Vector3(max.x, min.y, min.z), + new THREE.Vector3(max.x, max.y, min.z), + 'Y', + size.y + ); + this.createDimensionLine( + new THREE.Vector3(max.x, max.y, min.z), + new THREE.Vector3(max.x, max.y, max.z), + 'Z', + size.z + ); + } + + /** + * Creates a single dimension line with a label. + * @param start The start point of the line. + * @param end The end point of the line. + * @param axis The axis name ('X', 'Y', or 'Z'). + * @param value The length of the dimension. + */ + createDimensionLine( + start: THREE.Vector3, + end: THREE.Vector3, + axis: string, + value: number + ) { + // Create the dashed line + const material = new THREE.LineDashedMaterial({ + color: 0x000000, + linewidth: 1, + scale: 1, + dashSize: 0.1, + gapSize: 0.1 + }); + const geometry = new THREE.BufferGeometry().setFromPoints([start, end]); + const line = new THREE.Line(geometry, material); + line.computeLineDistances(); + this._group.add(line); + + // Create the label + const labelDiv = document.createElement('div'); + labelDiv.className = 'measurement-label'; + labelDiv.textContent = `${axis}: ${value.toFixed(2)}`; + labelDiv.style.color = 'black'; + labelDiv.style.fontSize = '12px'; + labelDiv.style.backgroundColor = 'rgba(255, 255, 255, 0.7)'; + labelDiv.style.padding = '2px 5px'; + labelDiv.style.borderRadius = '3px'; + + const label = new CSS2DObject(labelDiv); + + // Position the label at the midpoint of the line + const midPoint = new THREE.Vector3().addVectors(start, end).multiplyScalar(0.5); + label.position.copy(midPoint); + + this._group.add(label); + } + + /** + * This component does not render any DOM elements itself. + * The measurements are rendered in the 3D scene. + */ + render(): null { + return null; + } + + /** + * Getter for the THREE.Group containing the measurement annotations. + * This group can be added to a THREE.Scene to be rendered. + */ + public get group(): THREE.Group { + return this._group; + } +} diff --git a/ui-tests/tests/measure.spec.ts b/ui-tests/tests/measure.spec.ts new file mode 100644 index 00000000..3dbb8f32 --- /dev/null +++ b/ui-tests/tests/measure.spec.ts @@ -0,0 +1,54 @@ +import { expect, test, galata } from '@jupyterlab/galata'; +import path from 'path'; + +test.use({ autoGoto: false }); + +test.describe('Measure Tools', () => { + test.beforeEach(async ({ page, request }) => { + page.setViewportSize({ width: 1920, height: 1080 }); + const content = galata.newContentsHelper(request); + await content.deleteDirectory('/examples'); + await content.uploadDirectory( + path.resolve(__dirname, '../../examples'), + '/examples' + ); + }); + + test('Should display measurement on object selection', async ({ + page + }) => { + test.setTimeout(120000); + 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' }); + + // Select 'box2' from the tree + await page + .locator('[data-test-id="react-tree-root"]') + .getByText('box2') + .click(); + + // Check if the measurement labels are displayed + const xLabel = page.locator('.measurement-label', { hasText: /X: \d+\.\d{2}/ }); + const yLabel = page.locator('.measurement-label', { hasText: /Y: \d+\.\d{2}/ }); + const zLabel = page.locator('.measurement-label', { hasText: /Z: \d+\.\d{2}/ }); + + await expect(xLabel).toBeVisible(); + await expect(yLabel).toBeVisible(); + await expect(zLabel).toBeVisible(); + + // Deselect the object by clicking on the canvas + await page.locator('canvas').click({ + position: { x: 10, y: 10 } + }); + + // Check if the measurement labels are hidden + await expect(xLabel).toBeHidden(); + await expect(yLabel).toBeHidden(); + await expect(zLabel).toBeHidden(); + }); +}); From 87aa35b119452f03b01792d70f5e7e1eec3a3714 Mon Sep 17 00:00:00 2001 From: asmith26 Date: Sat, 22 Nov 2025 22:27:04 +0000 Subject: [PATCH 2/4] Measure -> Measurement --- ui-tests/tests/{measure.spec.ts => measurement.spec.ts} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename ui-tests/tests/{measure.spec.ts => measurement.spec.ts} (97%) diff --git a/ui-tests/tests/measure.spec.ts b/ui-tests/tests/measurement.spec.ts similarity index 97% rename from ui-tests/tests/measure.spec.ts rename to ui-tests/tests/measurement.spec.ts index 3dbb8f32..67717966 100644 --- a/ui-tests/tests/measure.spec.ts +++ b/ui-tests/tests/measurement.spec.ts @@ -3,7 +3,7 @@ import path from 'path'; test.use({ autoGoto: false }); -test.describe('Measure Tools', () => { +test.describe('Measurement test', () => { test.beforeEach(async ({ page, request }) => { page.setViewportSize({ width: 1920, height: 1080 }); const content = galata.newContentsHelper(request); From f08452fa1d0cc152c2648b3363cc0c3f2c823646 Mon Sep 17 00:00:00 2001 From: asmith26 Date: Sat, 22 Nov 2025 23:41:03 +0000 Subject: [PATCH 3/4] Add comment --- packages/base/src/3dview/mainview.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/base/src/3dview/mainview.tsx b/packages/base/src/3dview/mainview.tsx index d97ae475..7ca5e041 100644 --- a/packages/base/src/3dview/mainview.tsx +++ b/packages/base/src/3dview/mainview.tsx @@ -709,10 +709,13 @@ export class MainView extends React.Component { this._divRef.current.clientHeight, false ); + + // Update the size of the label renderer to match the container div. this._labelRenderer.setSize( this._divRef.current.clientWidth, this._divRef.current.clientHeight ); + if (this._camera instanceof THREE.PerspectiveCamera) { this._camera.aspect = this._divRef.current.clientWidth / this._divRef.current.clientHeight; From 9ed266874ece7da05f262ef56dd80ad8228d0f83 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 23 Nov 2025 00:08:48 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- packages/base/src/3dview/mainview.tsx | 72 ++++++++++++------------ packages/base/src/3dview/measurement.tsx | 4 +- ui-tests/tests/measurement.spec.ts | 16 ++++-- 3 files changed, 49 insertions(+), 43 deletions(-) diff --git a/packages/base/src/3dview/mainview.tsx b/packages/base/src/3dview/mainview.tsx index 7ca5e041..55751182 100644 --- a/packages/base/src/3dview/mainview.tsx +++ b/packages/base/src/3dview/mainview.tsx @@ -168,45 +168,45 @@ export class MainView extends React.Component { document.addEventListener('keydown', this._keyDownHandler); } - componentDidUpdate(oldProps: IProps, oldState: IStates): void { - // Resize the canvas to fit the display area - this.resizeCanvasToDisplaySize(); - - // Update transform controls rotation snap if the value has changed - if (oldState.rotationSnapValue !== this.state.rotationSnapValue) { - this._transformControls.rotationSnap = THREE.MathUtils.degToRad( - this.state.rotationSnapValue - ); - } + componentDidUpdate(oldProps: IProps, oldState: IStates): void { + // Resize the canvas to fit the display area + this.resizeCanvasToDisplaySize(); - // Update transform controls translation snap if the value has changed - if (oldState.translationSnapValue !== this.state.translationSnapValue) { - this._transformControls.translationSnap = this.state.translationSnapValue; - } + // Update transform controls rotation snap if the value has changed + if (oldState.rotationSnapValue !== this.state.rotationSnapValue) { + this._transformControls.rotationSnap = THREE.MathUtils.degToRad( + this.state.rotationSnapValue + ); + } - // Handle measurement display based on the selection box - if (oldState.selectionBox !== this.state.selectionBox) { - // If there is a new selection box, create and display measurements - if (this.state.selectionBox) { - // Clear any existing measurement visuals - if (this._measurementGroup) { - this._measurementGroup.clear(); - this._scene.remove(this._measurementGroup); - } - // Create a new measurement object for the selection box - const measurement = new Measurement({ box: this.state.selectionBox }); - this._measurementGroup = measurement.group; - this._scene.add(this._measurementGroup); - } else { - // If the selection box is removed, clear the measurement visuals - if (this._measurementGroup) { - this._measurementGroup.clear(); - this._scene.remove(this._measurementGroup); - this._measurementGroup = null; - } + // Update transform controls translation snap if the value has changed + if (oldState.translationSnapValue !== this.state.translationSnapValue) { + this._transformControls.translationSnap = this.state.translationSnapValue; + } + + // Handle measurement display based on the selection box + if (oldState.selectionBox !== this.state.selectionBox) { + // If there is a new selection box, create and display measurements + if (this.state.selectionBox) { + // Clear any existing measurement visuals + if (this._measurementGroup) { + this._measurementGroup.clear(); + this._scene.remove(this._measurementGroup); + } + // Create a new measurement object for the selection box + const measurement = new Measurement({ box: this.state.selectionBox }); + this._measurementGroup = measurement.group; + this._scene.add(this._measurementGroup); + } else { + // If the selection box is removed, clear the measurement visuals + if (this._measurementGroup) { + this._measurementGroup.clear(); + this._scene.remove(this._measurementGroup); + this._measurementGroup = null; } } } + } componentWillUnmount(): void { window.cancelAnimationFrame(this._requestID); @@ -697,7 +697,7 @@ export class MainView extends React.Component { this._renderer.render(this._scene, this._camera); - this._labelRenderer.render(this._scene, this._camera); // Render the 2D labels on top of the 3D scene + this._labelRenderer.render(this._scene, this._camera); // Render the 2D labels on top of the 3D scene this._viewHelper.render(this._renderer); this.updateCameraRotation(); }; @@ -715,7 +715,7 @@ export class MainView extends React.Component { this._divRef.current.clientWidth, this._divRef.current.clientHeight ); - + if (this._camera instanceof THREE.PerspectiveCamera) { this._camera.aspect = this._divRef.current.clientWidth / this._divRef.current.clientHeight; diff --git a/packages/base/src/3dview/measurement.tsx b/packages/base/src/3dview/measurement.tsx index 4d722991..eb41e86f 100644 --- a/packages/base/src/3dview/measurement.tsx +++ b/packages/base/src/3dview/measurement.tsx @@ -135,7 +135,9 @@ export class Measurement extends React.Component { const label = new CSS2DObject(labelDiv); // Position the label at the midpoint of the line - const midPoint = new THREE.Vector3().addVectors(start, end).multiplyScalar(0.5); + const midPoint = new THREE.Vector3() + .addVectors(start, end) + .multiplyScalar(0.5); label.position.copy(midPoint); this._group.add(label); diff --git a/ui-tests/tests/measurement.spec.ts b/ui-tests/tests/measurement.spec.ts index 67717966..9c31f99b 100644 --- a/ui-tests/tests/measurement.spec.ts +++ b/ui-tests/tests/measurement.spec.ts @@ -14,9 +14,7 @@ test.describe('Measurement test', () => { ); }); - test('Should display measurement on object selection', async ({ - page - }) => { + test('Should display measurement on object selection', async ({ page }) => { test.setTimeout(120000); await page.goto(); @@ -33,9 +31,15 @@ test.describe('Measurement test', () => { .click(); // Check if the measurement labels are displayed - const xLabel = page.locator('.measurement-label', { hasText: /X: \d+\.\d{2}/ }); - const yLabel = page.locator('.measurement-label', { hasText: /Y: \d+\.\d{2}/ }); - const zLabel = page.locator('.measurement-label', { hasText: /Z: \d+\.\d{2}/ }); + const xLabel = page.locator('.measurement-label', { + hasText: /X: \d+\.\d{2}/ + }); + const yLabel = page.locator('.measurement-label', { + hasText: /Y: \d+\.\d{2}/ + }); + const zLabel = page.locator('.measurement-label', { + hasText: /Z: \d+\.\d{2}/ + }); await expect(xLabel).toBeVisible(); await expect(yLabel).toBeVisible();