Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion packages/base/src/3dview/mainview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -81,6 +83,7 @@ interface IStates {
rotationSnapValue: number;
translationSnapValue: number;
transformMode: string | undefined;
selectionBox: THREE.Box3 | null;
}

interface ILineIntersection extends THREE.Intersection {
Expand Down Expand Up @@ -131,7 +134,8 @@ export class MainView extends React.Component<IProps, IStates> {
explodedViewFactor: 0,
rotationSnapValue: 10,
translationSnapValue: 1,
transformMode: 'translate'
transformMode: 'translate',
selectionBox: null
};

this._model.settingsChanged.connect(this._handleSettingsChange, this);
Expand Down Expand Up @@ -165,15 +169,43 @@ export class MainView extends React.Component<IProps, IStates> {
}

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 {
Expand Down Expand Up @@ -317,6 +349,15 @@ export class MainView extends React.Component<IProps, IStates> {
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) {
Expand Down Expand Up @@ -656,6 +697,7 @@ export class MainView extends React.Component<IProps, IStates> {

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();
};
Expand All @@ -667,6 +709,13 @@ export class MainView extends React.Component<IProps, IStates> {
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;
Expand Down Expand Up @@ -1358,6 +1407,19 @@ export class MainView extends React.Component<IProps, IStates> {
}

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 });
}
}

/*
Expand Down Expand Up @@ -2203,13 +2265,15 @@ export class MainView extends React.Component<IProps, IStates> {
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;
private _camera: THREE.PerspectiveCamera | THREE.OrthographicCamera; // Threejs camera
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
Expand Down
161 changes: 161 additions & 0 deletions packages/base/src/3dview/measurement.tsx
Original file line number Diff line number Diff line change
@@ -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<IMeasurementProps> {
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;
}
}
58 changes: 58 additions & 0 deletions ui-tests/tests/measurement.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading