Skip to content

Commit a558e0c

Browse files
authored
Add "Show Dimensions" overlay for selected splat bound (playcanvas#907)
1 parent 835a9c2 commit a558e0c

15 files changed

Lines changed: 270 additions & 1 deletion

File tree

src/editor.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ const registerEditorEvents = (events: Events, editHistory: EditHistory, scene: S
7575

7676
[
7777
'camera.mode', 'camera.overlay', 'camera.splatSize', 'view.outlineSelection',
78-
'view.centersUseGaussianColor', 'view.bands', 'camera.bound', 'camera.showPoses',
78+
'view.centersUseGaussianColor', 'view.bands', 'camera.bound', 'camera.boundDimensions', 'camera.showPoses',
7979
'selection.changed', 'tool.coordSpace'
8080
].forEach((eventName) => {
8181
events.on(eventName, () => {
@@ -156,6 +156,29 @@ const registerEditorEvents = (events: Events, editHistory: EditHistory, scene: S
156156
setBoundVisible(!events.invoke('camera.bound'));
157157
});
158158

159+
// camera.boundDimensions
160+
161+
let boundDimensions = scene.config.show.boundDimensions;
162+
163+
const setBoundDimensionsVisible = (visible: boolean) => {
164+
if (visible !== boundDimensions) {
165+
boundDimensions = visible;
166+
events.fire('camera.boundDimensions', boundDimensions);
167+
}
168+
};
169+
170+
events.function('camera.boundDimensions', () => {
171+
return boundDimensions;
172+
});
173+
174+
events.on('camera.setBoundDimensions', (value: boolean) => {
175+
setBoundDimensionsVisible(value);
176+
});
177+
178+
events.on('camera.toggleBoundDimensions', () => {
179+
setBoundDimensionsVisible(!events.invoke('camera.boundDimensions'));
180+
});
181+
159182
// camera.showPoses
160183

161184
let showPoses = scene.config.show.cameraPoses;
@@ -756,6 +779,7 @@ const registerEditorEvents = (events: Events, editHistory: EditHistory, scene: S
756779
outlineSelection: events.invoke('view.outlineSelection'),
757780
showGrid: events.invoke('grid.visible'),
758781
showBound: events.invoke('camera.bound'),
782+
showBoundDimensions: events.invoke('camera.boundDimensions'),
759783
showCameraPoses: events.invoke('camera.showPoses'),
760784
flySpeed: events.invoke('camera.flySpeed')
761785
};
@@ -771,6 +795,7 @@ const registerEditorEvents = (events: Events, editHistory: EditHistory, scene: S
771795
events.fire('view.setOutlineSelection', docView.outlineSelection);
772796
events.fire('grid.setVisible', docView.showGrid);
773797
events.fire('camera.setBound', docView.showBound);
798+
events.fire('camera.setBoundDimensions', docView.showBoundDimensions ?? false);
774799
events.fire('camera.setShowPoses', docView.showCameraPoses ?? false);
775800
events.fire('camera.setFlySpeed', docView.flySpeed);
776801
});

src/main.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { SphereSelection } from './tools/sphere-selection';
3232
import { ToolManager } from './tools/tool-manager';
3333
import { registerTrackManagerEvents } from './track-manager';
3434
import { registerTransformHandlerEvents } from './transform-handler';
35+
import { BoundDimensionsOverlay } from './ui/bound-dimensions-overlay';
3536
import { EditorUI } from './ui/editor';
3637
import { localizeInit } from './ui/localization';
3738

@@ -241,6 +242,8 @@ const main = async () => {
241242
toolManager.register('scale', new ScaleTool(events, scene));
242243
toolManager.register('measure', new MeasureTool(events, scene, editorUI.toolsContainer.dom, editorUI.canvasContainer));
243244

245+
const boundDimensionsOverlay = new BoundDimensionsOverlay(events, scene, editorUI.canvasContainer);
246+
244247
editorUI.toolsContainer.dom.appendChild(maskCanvas);
245248

246249
window.scene = scene;

src/scene-config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const sceneConfig = {
2222
show: {
2323
grid: true,
2424
bound: true,
25+
boundDimensions: false,
2526
cameraPoses: false,
2627
shBands: 3
2728
},

src/ui/bound-dimensions-overlay.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { Container } from '@playcanvas/pcui';
2+
import { Vec3 } from 'playcanvas';
3+
4+
import { Events } from '../events';
5+
import { Scene } from '../scene';
6+
import { Splat } from '../splat';
7+
8+
const corners = Array.from({ length: 8 }, () => new Vec3());
9+
const screenCorners = Array.from({ length: 8 }, () => new Vec3());
10+
const cornerInFront = new Array<boolean>(8);
11+
const screenBoundCenter = new Vec3();
12+
const worldBoundCenter = new Vec3();
13+
const tmpVec = new Vec3();
14+
15+
// indices into the 8-corner array, ordered as (sx, sy, sz) where each s is 0 or 1
16+
// corner index = sx*4 + sy*2 + sz
17+
const cornerIndex = (sx: number, sy: number, sz: number) => sx * 4 + sy * 2 + sz;
18+
19+
// for each axis, the 4 pairs of corner indices that form the parallel edges along that axis
20+
const axisEdges: number[][][] = [
21+
// X edges: vary sx from 0->1, hold sy, sz constant
22+
[
23+
[cornerIndex(0, 0, 0), cornerIndex(1, 0, 0)],
24+
[cornerIndex(0, 0, 1), cornerIndex(1, 0, 1)],
25+
[cornerIndex(0, 1, 0), cornerIndex(1, 1, 0)],
26+
[cornerIndex(0, 1, 1), cornerIndex(1, 1, 1)]
27+
],
28+
// Y edges
29+
[
30+
[cornerIndex(0, 0, 0), cornerIndex(0, 1, 0)],
31+
[cornerIndex(0, 0, 1), cornerIndex(0, 1, 1)],
32+
[cornerIndex(1, 0, 0), cornerIndex(1, 1, 0)],
33+
[cornerIndex(1, 0, 1), cornerIndex(1, 1, 1)]
34+
],
35+
// Z edges
36+
[
37+
[cornerIndex(0, 0, 0), cornerIndex(0, 0, 1)],
38+
[cornerIndex(0, 1, 0), cornerIndex(0, 1, 1)],
39+
[cornerIndex(1, 0, 0), cornerIndex(1, 0, 1)],
40+
[cornerIndex(1, 1, 0), cornerIndex(1, 1, 1)]
41+
]
42+
];
43+
44+
class BoundDimensionsOverlay {
45+
constructor(events: Events, scene: Scene, canvasContainer: Container) {
46+
const ns = 'http://www.w3.org/2000/svg';
47+
const svg = document.createElementNS(ns, 'svg');
48+
svg.classList.add('tool-svg', 'bound-dimensions-svg', 'hidden');
49+
svg.id = 'bound-dimensions-svg';
50+
canvasContainer.dom.appendChild(svg);
51+
52+
const labels: SVGTextElement[] = [];
53+
for (let i = 0; i < 3; i++) {
54+
const text = document.createElementNS(ns, 'text') as SVGTextElement;
55+
text.classList.add(['bound-dim-x', 'bound-dim-y', 'bound-dim-z'][i]);
56+
text.setAttribute('text-anchor', 'middle');
57+
text.setAttribute('dominant-baseline', 'middle');
58+
svg.appendChild(text);
59+
labels.push(text);
60+
}
61+
62+
events.on('prerender', () => {
63+
const selection = events.invoke('selection') as Splat;
64+
65+
if (!selection ||
66+
!selection.visible ||
67+
!events.invoke('camera.boundDimensions')) {
68+
svg.classList.add('hidden');
69+
return;
70+
}
71+
72+
svg.classList.remove('hidden');
73+
74+
const width = canvasContainer.dom.clientWidth;
75+
const height = canvasContainer.dom.clientHeight;
76+
const camera = scene.camera;
77+
const transform = selection.entity.getWorldTransform();
78+
const bound = selection.localBound;
79+
const { center, halfExtents } = bound;
80+
81+
// compute 8 world-space corners
82+
for (let i = 0; i < 8; i++) {
83+
const sx = (i >> 2) & 1;
84+
const sy = (i >> 1) & 1;
85+
const sz = i & 1;
86+
const local = corners[i];
87+
local.set(
88+
center.x + (sx ? 1 : -1) * halfExtents.x,
89+
center.y + (sy ? 1 : -1) * halfExtents.y,
90+
center.z + (sz ? 1 : -1) * halfExtents.z
91+
);
92+
transform.transformPoint(local, local);
93+
}
94+
95+
// determine which corners are in front of the camera (for behind-camera culling)
96+
const cameraPos = camera.mainCamera.getPosition();
97+
const cameraFwd = camera.mainCamera.forward;
98+
for (let i = 0; i < 8; i++) {
99+
tmpVec.sub2(corners[i], cameraPos);
100+
cornerInFront[i] = tmpVec.dot(cameraFwd) > 0;
101+
}
102+
103+
// project all corners to screen
104+
for (let i = 0; i < 8; i++) {
105+
camera.worldToScreen(corners[i], screenCorners[i]);
106+
}
107+
108+
// project bound center to screen (used to choose the outer-most edge)
109+
transform.transformPoint(center, worldBoundCenter);
110+
camera.worldToScreen(worldBoundCenter, screenBoundCenter);
111+
const scx = screenBoundCenter.x * width;
112+
const scy = screenBoundCenter.y * height;
113+
114+
for (let axis = 0; axis < 3; axis++) {
115+
const edges = axisEdges[axis];
116+
let bestEdge = -1;
117+
let bestScore = -Infinity;
118+
119+
// pick the parallel edge on the outer silhouette: farthest screen-space distance
120+
// from the projected box centroid. Ties are common in orthographic projection
121+
// (opposite edges are exactly equidistant), so require a meaningful difference
122+
// before swapping the chosen edge to avoid frame-to-frame flicker.
123+
for (let e = 0; e < edges.length; e++) {
124+
const [a, b] = edges[e];
125+
if (!cornerInFront[a] || !cornerInFront[b]) continue;
126+
const mxe = (screenCorners[a].x + screenCorners[b].x) * 0.5 * width;
127+
const mye = (screenCorners[a].y + screenCorners[b].y) * 0.5 * height;
128+
const dxe = mxe - scx;
129+
const dye = mye - scy;
130+
const score = dxe * dxe + dye * dye;
131+
if (score > bestScore + 1) {
132+
bestScore = score;
133+
bestEdge = e;
134+
}
135+
}
136+
137+
const text = labels[axis];
138+
139+
if (bestEdge < 0) {
140+
// no parallel edge has both endpoints in front of the camera
141+
text.setAttribute('visibility', 'hidden');
142+
continue;
143+
}
144+
text.setAttribute('visibility', 'visible');
145+
146+
const [a, b] = edges[bestEdge];
147+
const sa = screenCorners[a];
148+
const sb = screenCorners[b];
149+
150+
// world-space edge length
151+
const length = corners[a].distance(corners[b]);
152+
153+
// screen-space endpoints in pixels
154+
const x0 = sa.x * width;
155+
const y0 = sa.y * height;
156+
const x1 = sb.x * width;
157+
const y1 = sb.y * height;
158+
159+
const mx = (x0 + x1) * 0.5;
160+
const my = (y0 + y1) * 0.5;
161+
162+
let theta = Math.atan2(y1 - y0, x1 - x0);
163+
// flip 180° to keep text upright
164+
if (Math.cos(theta) < 0) {
165+
theta += Math.PI;
166+
}
167+
168+
// perpendicular offset so the label sits outside the box
169+
const perpX = -Math.sin(theta);
170+
const perpY = Math.cos(theta);
171+
const toCenterX = scx - mx;
172+
const toCenterY = scy - my;
173+
const dot = perpX * toCenterX + perpY * toCenterY;
174+
const sign = dot > 0 ? -1 : 1;
175+
const offsetPx = 10;
176+
const ox = perpX * offsetPx * sign;
177+
const oy = perpY * offsetPx * sign;
178+
179+
const thetaDeg = theta * 180 / Math.PI;
180+
text.setAttribute('transform', `translate(${(mx + ox).toFixed(1)}, ${(my + oy).toFixed(1)}) rotate(${thetaDeg.toFixed(1)})`);
181+
text.textContent = length.toFixed(2);
182+
}
183+
});
184+
}
185+
}
186+
187+
export { BoundDimensionsOverlay };

src/ui/scss/tool.scss

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,19 @@
4545
}
4646
}
4747

48+
&#bound-dimensions-svg {
49+
pointer-events: none;
50+
51+
> text {
52+
font-family: monospace;
53+
font-size: 11px;
54+
fill: white;
55+
paint-order: stroke;
56+
stroke: rgba(0, 0, 0, 0.7);
57+
stroke-width: 3px;
58+
}
59+
}
60+
4861
&#measure-tool-svg {
4962
>#measure-line-bottom {
5063
stroke: black;

src/ui/view-panel.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,26 @@ class ViewPanel extends Container {
301301
showBoundRow.append(showBoundLabel);
302302
showBoundRow.append(showBoundToggle);
303303

304+
// show dimensions
305+
306+
const showBoundDimensionsRow = new Container({
307+
class: 'view-panel-row'
308+
});
309+
310+
const showBoundDimensionsLabel = new Label({
311+
text: localize('panel.view-options.show-bound-dimensions'),
312+
class: 'view-panel-row-label'
313+
});
314+
315+
const showBoundDimensionsToggle = new BooleanInput({
316+
type: 'toggle',
317+
class: 'view-panel-row-toggle',
318+
value: false
319+
});
320+
321+
showBoundDimensionsRow.append(showBoundDimensionsLabel);
322+
showBoundDimensionsRow.append(showBoundDimensionsToggle);
323+
304324
// show camera poses
305325

306326
const showCameraPosesRow = new Container({
@@ -332,6 +352,7 @@ class ViewPanel extends Container {
332352
this.append(outlineSelectionRow);
333353
this.append(showGridRow);
334354
this.append(showBoundRow);
355+
this.append(showBoundDimensionsRow);
335356
this.append(showCameraPosesRow);
336357

337358
// handle panel visibility
@@ -432,6 +453,16 @@ class ViewPanel extends Container {
432453
events.fire('camera.setBound', showBoundToggle.value);
433454
});
434455

456+
// show dimensions
457+
458+
events.on('camera.boundDimensions', (visible: boolean) => {
459+
showBoundDimensionsToggle.value = visible;
460+
});
461+
462+
showBoundDimensionsToggle.on('change', () => {
463+
events.fire('camera.setBoundDimensions', showBoundDimensionsToggle.value);
464+
});
465+
435466
// show camera poses
436467

437468
events.on('camera.showPoses', (visible: boolean) => {

static/locales/de.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"panel.view-options.outline-selection": "Umriss Selektion",
7272
"panel.view-options.show-grid": "Raster anzeigen",
7373
"panel.view-options.show-bound": "Objektbox anzeigen",
74+
"panel.view-options.show-bound-dimensions": "Abmessungen anzeigen",
7475
"panel.view-options.show-camera-poses": "Kameras anzeigen",
7576
"panel.view-options.fly-speed": "Kamera Geschwindigkeit",
7677
"panel.view-options.tonemapping": "Tonemapping",

static/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"panel.view-options.outline-selection": "Outline Selection",
7272
"panel.view-options.show-grid": "Show Grid",
7373
"panel.view-options.show-bound": "Show Bound",
74+
"panel.view-options.show-bound-dimensions": "Show Dimensions",
7475
"panel.view-options.show-camera-poses": "Show Cameras",
7576
"panel.view-options.fly-speed": "Fly Speed",
7677
"panel.view-options.tonemapping": "Tonemapping",

static/locales/es.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"panel.view-options.outline-selection": "Contorno de selección",
7272
"panel.view-options.show-grid": "Mostrar cuadrícula",
7373
"panel.view-options.show-bound": "Mostrar límites",
74+
"panel.view-options.show-bound-dimensions": "Mostrar dimensiones",
7475
"panel.view-options.show-camera-poses": "Mostrar cámaras",
7576
"panel.view-options.fly-speed": "Velocidad de vuelo",
7677
"panel.view-options.tonemapping": "Mapeo de tonos",

static/locales/fr.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"panel.view-options.outline-selection": "Contour de la sélection",
7272
"panel.view-options.show-grid": "Afficher la grille",
7373
"panel.view-options.show-bound": "Afficher limites",
74+
"panel.view-options.show-bound-dimensions": "Afficher dimensions",
7475
"panel.view-options.show-camera-poses": "Afficher caméras",
7576
"panel.view-options.fly-speed": "Vitesse de vol",
7677
"panel.view-options.tonemapping": "Mappage tonal",

0 commit comments

Comments
 (0)