|
| 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 }; |
0 commit comments