Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e08bc30
Add measure support
asmith26 Nov 22, 2025
87aa35b
Measure -> Measurement
asmith26 Nov 22, 2025
1a5b1c6
Merge branch 'jupytercad:main' into measures_support
asmith26 Nov 22, 2025
f08452f
Add comment
asmith26 Nov 22, 2025
7c23ef9
Add toggle measurement button
asmith26 Nov 23, 2025
1570e31
Increase size of ruler image
asmith26 Nov 24, 2025
e14281f
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 24, 2025
79cd9fc
Merge branch 'jupytercad:main' into measures_support_ruler
asmith26 Nov 24, 2025
bc5f431
Fix test
asmith26 Nov 24, 2025
dcac414
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 24, 2025
35fa2ee
Update failing snapshots
asmith26 Nov 25, 2025
e8fcd72
Update snapshots
asmith26 Nov 25, 2025
3194256
Update snapshots
asmith26 Nov 25, 2025
58269ed
Update snapshots
asmith26 Nov 25, 2025
73ed175
React -> Class
asmith26 Nov 25, 2025
cae7af3
Remove redundant code, add comment
asmith26 Nov 25, 2025
a4e7535
Fix measurements not refreshing on delete/move
asmith26 Nov 26, 2025
4abf570
Fix: align bounding box to the object only when measuring a single ob…
asmith26 Nov 26, 2025
5707339
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 26, 2025
d2cd181
Update comment
asmith26 Nov 26, 2025
56fdfca
Hide 0 axes
asmith26 Nov 26, 2025
9329bbe
Improved dashed line
asmith26 Nov 26, 2025
c4377ea
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 26, 2025
6e6f135
Update snapshot
asmith26 Nov 26, 2025
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
98 changes: 97 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;
measurement: boolean;
}

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',
measurement: false
};

this._model.settingsChanged.connect(this._handleSettingsChange, this);
Expand Down Expand Up @@ -165,15 +169,25 @@ 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 when the measurement tool is toggled.
if (oldState.measurement !== this.state.measurement) {
this._refreshMeasurement();
}
}

componentWillUnmount(): void {
Expand Down Expand Up @@ -317,6 +331,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 @@ -481,6 +504,15 @@ export class MainView extends React.Component<IProps, IStates> {
this._transformControls.addEventListener('dragging-changed', event => {
this._controls.enabled = !event.value;
});
this._transformControls.addEventListener(
'change',
throttle(() => {
if (this.state.measurement) {
this._refreshMeasurement();
// Refresh measurement annotations when the transformed object changes.
}
}, 100)
);
// Update the currently transformed object in the shared model once finished moving
this._transformControls.addEventListener('mouseUp', async () => {
const updatedObject = this._selectedMeshes[0];
Expand Down Expand Up @@ -656,6 +688,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 +700,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 @@ -1020,6 +1060,7 @@ export class MainView extends React.Component<IProps, IStates> {
});

this._updateTransformControls(selectedNames);
this._refreshMeasurement();

// Update the reflength.
this._updateRefLength(this._refLength === null);
Expand Down Expand Up @@ -1358,8 +1399,53 @@ export class MainView extends React.Component<IProps, IStates> {
}

this._updateTransformControls(selectedNames);

// Refresh measurement annotations when the selection changes.
this._refreshMeasurement();
}

private _refreshMeasurement = (): void => {
// Clear existing measurement annotations if any.
if (this._measurementGroup) {
this._measurementGroup.clear();
this._scene.remove(this._measurementGroup);
this._measurementGroup = null;
}

// If measurement tool is enabled and there are selected meshes, create new measurement annotations.
if (this.state.measurement && this._selectedMeshes.length > 0) {
if (this._selectedMeshes.length === 1) {
// For a single selected object, create an oriented measurement that aligns with the object's rotation.
const mesh = this._selectedMeshes[0];
const meshGroup = mesh.parent as THREE.Group;

if (!mesh.geometry.boundingBox) {
mesh.geometry.computeBoundingBox();
}
const localBox = mesh.geometry.boundingBox!.clone();

// Pass the local bounding box, position, and quaternion to the Measurement constructor.
const measurement = new Measurement(
localBox,
meshGroup.position,
meshGroup.quaternion
);
this._measurementGroup = measurement.group;
this._scene.add(this._measurementGroup);
} else {
// For multiple selected objects, create a single axis-aligned bounding box that encloses all of them.
const combinedBox = new THREE.Box3();
for (const mesh of this._selectedMeshes) {
const box = new THREE.Box3().setFromObject(mesh.parent!);
combinedBox.union(box);
}
const measurement = new Measurement(combinedBox);
this._measurementGroup = measurement.group;
this._scene.add(this._measurementGroup);
}
}
};

/*
* Attach the transform controls to the current selection, or detach it
*/
Expand Down Expand Up @@ -1674,6 +1760,14 @@ export class MainView extends React.Component<IProps, IStates> {
);
}
}
if (change.key === 'measurement') {
// Update the measurement state when the measurement tool is toggled.
const measurementEnabled = change.newValue as boolean | undefined;

if (measurementEnabled !== undefined) {
this.setState(old => ({ ...old, measurement: measurementEnabled }));
}
}
}

get explodedViewEnabled(): boolean {
Expand Down Expand Up @@ -2203,13 +2297,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
167 changes: 167 additions & 0 deletions packages/base/src/3dview/measurement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* This file defines a class for rendering measurements of a 3D object.
* It uses `three.js` to create dimension lines and labels for a given bounding box.
*/
import * as THREE from 'three';
import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';

/**
* A class that displays the dimensions of a THREE.Box3.
* It creates visual annotations (lines and labels) for the X, Y, and Z dimensions.
* The measurement can be axis-aligned or oriented by providing a position and quaternion.
*/
export class Measurement {
private _group: THREE.Group;
private _box: THREE.Box3;
private _quaternion?: THREE.Quaternion;
private _position?: THREE.Vector3;

/**
* Constructor for the Measurement class.
* @param box The bounding box to measure.
* @param position Optional position to apply to the measurement group for oriented measurements.
* @param quaternion Optional quaternion to apply to the measurement group for oriented measurements.
*/
constructor(
box: THREE.Box3,
position?: THREE.Vector3,
quaternion?: THREE.Quaternion
) {
this._box = box;
this._position = position;
this._quaternion = quaternion;
this._group = new THREE.Group();
this.createAnnotations();
}

/**
* Removes all annotations from the scene.
*/
clearAnnotations() {
this._group.clear();
}

/**
* Creates the dimension lines and labels for the bounding box.
*/
createAnnotations() {
if (!this._box) {
return;
}

const size = new THREE.Vector3();
this._box.getSize(size);

const min = this._box.min;
const max = this._box.max;

// Create dimension lines only for dimensions with a size greater than a small epsilon.
// This is useful for hiding zero-dimension measurements for 2D objects like edges.
if (size.x > 1e-6) {
this.createDimensionLine(
new THREE.Vector3(min.x, min.y, min.z),
new THREE.Vector3(max.x, min.y, min.z),
'X',
size.x
);
}
if (size.y > 1e-6) {
this.createDimensionLine(
new THREE.Vector3(max.x, min.y, min.z),
new THREE.Vector3(max.x, max.y, min.z),
'Y',
size.y
);
}
if (size.z > 1e-6) {
this.createDimensionLine(
new THREE.Vector3(max.x, max.y, min.z),
new THREE.Vector3(max.x, max.y, max.z),
'Z',
size.z
);
}

// The annotations are created for an axis-aligned box at the origin, so transform
// the group to match the object's actual position and orientation (if provided).
if (this._quaternion) {
this._group.quaternion.copy(this._quaternion);
}
if (this._position) {
this._group.position.copy(this._position);
}
}

/**
* 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,
depthTest: false, // Render lines on top of other objects for better visibility
depthWrite: false,
transparent: true
});
const geometry = new THREE.BufferGeometry().setFromPoints([start, end]);
// Create a thin halo (solid) line behind the dashed line to improve
// contrast when measurements pass over objects.
const haloMat = new THREE.LineBasicMaterial({
color: 0xffffff,
linewidth: 4,
depthTest: false,
depthWrite: false,
transparent: true,
opacity: 0.85
});
const halo = new THREE.Line(geometry.clone(), haloMat);
halo.renderOrder = 0; // Ensure halo renders just before the dashed line
this._group.add(halo);

const line = new THREE.Line(geometry, material);
line.computeLineDistances();
line.renderOrder = 1; // Ensure dashed line renders on top of the halo and other objects
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);
}

/**
* 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;
}
}
Loading
Loading