Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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
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;
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 @@ -656,6 +679,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 +691,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,8 +1389,32 @@ 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) {
const combinedBox = new THREE.Box3();
for (const mesh of this._selectedMeshes) {
const box = new THREE.Box3().setFromObject(mesh);
combinedBox.union(box);
}
const measurement = new Measurement({ box: 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 +1729,13 @@ export class MainView extends React.Component<IProps, IStates> {
);
}
}
if (change.key === 'measurement') {
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 +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;
}
}
23 changes: 23 additions & 0 deletions packages/base/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
boxIcon,
chamferIcon,
clippingIcon,
rulerIcon,
coneIcon,
cutIcon,
cylinderIcon,
Expand Down Expand Up @@ -784,8 +785,29 @@ export function addCommands(
}
});

commands.addCommand(CommandIDs.toggleMeasurement, {
label: trans.__('Toggle Measurement'),
isEnabled: () => {
return tracker.currentWidget !== null;
},
isToggled: () => {
const current = tracker.currentWidget?.content;
return current?.measurement ?? false;
},
icon: rulerIcon,
execute: async () => {
const current = tracker.currentWidget?.content;
if (!current) {
return;
}
current.measurement = !current.measurement;
commands.notifyCommandChanged(CommandIDs.toggleMeasurement);
}
});

tracker.currentChanged.connect(() => {
commands.notifyCommandChanged(CommandIDs.updateClipView);
commands.notifyCommandChanged(CommandIDs.toggleMeasurement);
});

commands.addCommand(CommandIDs.splitScreen, {
Expand Down Expand Up @@ -960,6 +982,7 @@ export namespace CommandIDs {
export const updateExplodedView = 'jupytercad:updateExplodedView';
export const updateCameraSettings = 'jupytercad:updateCameraSettings';
export const updateClipView = 'jupytercad:updateClipView';
export const toggleMeasurement = 'jupytercad:toggleMeasurement';

export const splitScreen = 'jupytercad:splitScreen';
export const exportJcad = 'jupytercad:exportJcad';
Expand Down
8 changes: 8 additions & 0 deletions packages/base/src/toolbar/widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,14 @@ export class ToolbarWidget extends ReactiveToolbar {
commands: options.commands
})
);
this.addItem(
'Toggle Measurement',
new CommandToolbarButton({
id: CommandIDs.toggleMeasurement,
label: '',
commands: options.commands
})
);
this.addItem('separator6', new Separator());

this.addItem(
Expand Down
Loading
Loading