diff --git a/packages/base/src/3dview/mainview.tsx b/packages/base/src/3dview/mainview.tsx index 25c35be2..55751182 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); @@ -165,15 +169,43 @@ export class MainView extends React.Component { } 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 { @@ -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,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; @@ -1358,6 +1407,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 +2265,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 +2273,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..eb41e86f --- /dev/null +++ b/packages/base/src/3dview/measurement.tsx @@ -0,0 +1,161 @@ +/** + * 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/measurement.spec.ts b/ui-tests/tests/measurement.spec.ts new file mode 100644 index 00000000..9c31f99b --- /dev/null +++ b/ui-tests/tests/measurement.spec.ts @@ -0,0 +1,58 @@ +import { expect, test, galata } from '@jupyterlab/galata'; +import path from 'path'; + +test.use({ autoGoto: false }); + +test.describe('Measurement test', () => { + 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(); + }); +});