From e4f42e0ee58093b9b6eede2836872300719b0b01 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 27 Apr 2026 10:49:20 +0530 Subject: [PATCH] Use bbox anchors for item dimension measurements --- .../spatial-grid/spatial-grid-manager.ts | 129 ++++------- .../floorplan-measurements-layer.tsx | 42 ++-- .../src/components/editor/floorplan-panel.tsx | 138 ++++++++++- .../tools/item/use-placement-coordinator.tsx | 217 ++++++++++++++++++ packages/editor/src/lib/floorplan/items.ts | 22 +- packages/editor/src/lib/floorplan/types.ts | 1 + .../renderers/item/item-renderer.tsx | 103 ++++++++- 7 files changed, 534 insertions(+), 118 deletions(-) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index 14873b71..9e02263c 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,8 +1,7 @@ import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import { getScaledDimensions } from '../../schema' -import { sceneRegistry } from '../scene-registry/scene-registry' import useScene from '../../store/use-scene' -import { Box3, Matrix4, Vector3, type Object3D } from 'three' +import { Vector3 } from 'three' import { SpatialGrid } from './spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid' @@ -78,84 +77,43 @@ function getFallbackItemLocalBounds(item: ItemNode): ItemLocalBounds { } } -function getItemLocalBoundsFromObject(object: Object3D | null): ItemLocalBounds | null { - if (!object) return null - - object.updateWorldMatrix(true, true) - - const inverseRootMatrix = new Matrix4().copy(object.matrixWorld).invert() - const localMatrix = new Matrix4() - const localBounds = new Box3() - const scratchBounds = new Box3() - let hasBounds = false - const registeredNodeObjects = new Set(sceneRegistry.nodes.values()) - - const expandBounds = (child: Object3D) => { - if (child !== object && registeredNodeObjects.has(child)) return - - const mesh = child as Object3D & { - isMesh?: boolean - name?: string - geometry?: { - boundingBox: Box3 | null - computeBoundingBox?: () => void - } - } - - if (mesh.isMesh && mesh.name !== 'cutout' && mesh.geometry) { - if (!mesh.geometry.boundingBox && mesh.geometry.computeBoundingBox) { - mesh.geometry.computeBoundingBox() - } - - if (mesh.geometry.boundingBox) { - localMatrix.copy(inverseRootMatrix).multiply(mesh.matrixWorld) - scratchBounds.copy(mesh.geometry.boundingBox).applyMatrix4(localMatrix) - if (!hasBounds) { - localBounds.copy(scratchBounds) - hasBounds = true - } else { - localBounds.union(scratchBounds) - } - } - } - - for (const grandchild of child.children) { - expandBounds(grandchild) - } - } - - for (const child of object.children) { - expandBounds(child) - } - - if (!hasBounds) return null - - return { - min: [localBounds.min.x, localBounds.min.y, localBounds.min.z], - max: [localBounds.max.x, localBounds.max.y, localBounds.max.z], - } -} - function getItemLocalBounds(item: ItemNode): ItemLocalBounds { - return getItemLocalBoundsFromObject(sceneRegistry.nodes.get(item.id) ?? null) ?? getFallbackItemLocalBounds(item) + const metadata = + typeof item.metadata === 'object' && item.metadata !== null && !Array.isArray(item.metadata) + ? (item.metadata as Record) + : null + const rawBounds = + typeof metadata?.meshLocalBounds === 'object' && + metadata.meshLocalBounds !== null && + !Array.isArray(metadata.meshLocalBounds) + ? (metadata.meshLocalBounds as Record) + : null + const min = rawBounds?.min + const max = rawBounds?.max + + if ( + Array.isArray(min) && + min.length >= 3 && + Array.isArray(max) && + max.length >= 3 && + typeof min[0] === 'number' && + typeof min[1] === 'number' && + typeof min[2] === 'number' && + typeof max[0] === 'number' && + typeof max[1] === 'number' && + typeof max[2] === 'number' + ) { + return { + min: [min[0], min[1], min[2]], + max: [max[0], max[1], max[2]], + } + } + + return getFallbackItemLocalBounds(item) } function getItemParentAabb(item: ItemNode): ItemParentAabb { - const object = sceneRegistry.nodes.get(item.id) const bounds = getItemLocalBounds(item) - - if (!object) { - return { - minX: bounds.min[0] + item.position[0], - maxX: bounds.max[0] + item.position[0], - minY: bounds.min[1] + item.position[1], - maxY: bounds.max[1] + item.position[1], - minZ: bounds.min[2] + item.position[2], - maxZ: bounds.max[2] + item.position[2], - } - } - - object.updateMatrix() const corners = [ new Vector3(bounds.min[0], bounds.min[1], bounds.min[2]), new Vector3(bounds.min[0], bounds.min[1], bounds.max[2]), @@ -166,6 +124,9 @@ function getItemParentAabb(item: ItemNode): ItemParentAabb { new Vector3(bounds.max[0], bounds.max[1], bounds.min[2]), new Vector3(bounds.max[0], bounds.max[1], bounds.max[2]), ] + const yRot = item.rotation[1] ?? 0 + const cos = Math.cos(yRot) + const sin = Math.sin(yRot) let minX = Number.POSITIVE_INFINITY let minY = Number.POSITIVE_INFINITY @@ -175,13 +136,17 @@ function getItemParentAabb(item: ItemNode): ItemParentAabb { let maxZ = Number.NEGATIVE_INFINITY for (const corner of corners) { - corner.applyMatrix4(object.matrix) - minX = Math.min(minX, corner.x) - minY = Math.min(minY, corner.y) - minZ = Math.min(minZ, corner.z) - maxX = Math.max(maxX, corner.x) - maxY = Math.max(maxY, corner.y) - maxZ = Math.max(maxZ, corner.z) + const rotatedX = corner.x * cos + corner.z * sin + const rotatedZ = -corner.x * sin + corner.z * cos + const worldX = rotatedX + item.position[0] + const worldY = corner.y + item.position[1] + const worldZ = rotatedZ + item.position[2] + minX = Math.min(minX, worldX) + minY = Math.min(minY, worldY) + minZ = Math.min(minZ, worldZ) + maxX = Math.max(maxX, worldX) + maxY = Math.max(maxY, worldY) + maxZ = Math.max(maxZ, worldZ) } return { minX, maxX, minY, maxY, minZ, maxZ } diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx index 12bc6f53..8c5663ba 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx @@ -10,6 +10,7 @@ const FLOORPLAN_MEASUREMENT_EXTENSION_DASH = '0.08 0.12' const FLOORPLAN_MEASUREMENT_END_TICK = 0.18 export type LinearMeasurementOverlay = { + dashedExtensions?: boolean id: string dimensionLineEnd: { x1: number; y1: number; x2: number; y2: number } dimensionLineStart: { x1: number; y1: number; x2: number; y2: number } @@ -22,6 +23,7 @@ export type LinearMeasurementOverlay = { extensionStroke?: string isSelected?: boolean labelFill?: string + showTicks?: boolean stroke?: string } @@ -125,7 +127,7 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay {measurements.map((measurement) => ( - - + {measurement.showTicks !== false ? ( + <> + + + + ) : null} ) + : null + + if (itemMetadata?.isTransient !== true) { + return [] + } + + const polygon = itemEntry.polygon + if (polygon.length < 4) { + return [] + } + + const centroid = polygonCentroid(polygon) + const configuredWidth = formatMeasurement(itemEntry.item.scale[0] * itemEntry.item.asset.dimensions[0], unit) + const configuredDepth = formatMeasurement(itemEntry.item.scale[2] * itemEntry.item.asset.dimensions[2], unit) + const buildSideOverlay = (id: string, start: Point2D, end: Point2D) => { + const edgeVector = { + x: end.x - start.x, + y: end.y - start.y, + } + const tangent = normalizePlanVector(edgeVector) + if (!tangent) { + return null + } + + let outwardNormal: Point2D = { + x: -tangent.y, + y: tangent.x, + } + const midpoint = { + x: (start.x + end.x) / 2, + y: (start.y + end.y) / 2, + } + const centroidVector = { + x: midpoint.x - centroid.x, + y: midpoint.y - centroid.y, + } + + if (dotPlanVectors(outwardNormal, centroidVector) < 0) { + outwardNormal = { + x: -outwardNormal.x, + y: -outwardNormal.y, + } + } + + const overlay = getLinearMeasurementOverlay( + id, + start, + end, + id.includes(':width') ? configuredWidth : configuredDepth, + { + extensionOvershoot: 0, + offsetDistance: FLOORPLAN_ITEM_DIMENSION_OFFSET, + offsetVector: outwardNormal, + }, + ) + + return overlay + ? { + dashedExtensions: false, + ...overlay, + isSelected: true, + showTicks: false, + } + : null + } + + const widthCandidates = [ + polygon[0] && polygon[1] + ? buildSideOverlay(`${itemEntry.item.id}:width-a`, polygon[0], polygon[1]) + : null, + polygon[2] && polygon[3] + ? buildSideOverlay(`${itemEntry.item.id}:width-b`, polygon[3], polygon[2]) + : null, + ].filter((overlay): overlay is LinearMeasurementOverlay => overlay !== null) + + const depthCandidates = [ + polygon[1] && polygon[2] + ? buildSideOverlay(`${itemEntry.item.id}:depth-a`, polygon[1], polygon[2]) + : null, + polygon[0] && polygon[3] + ? buildSideOverlay(`${itemEntry.item.id}:depth-b`, polygon[0], polygon[3]) + : null, + ].filter((overlay): overlay is LinearMeasurementOverlay => overlay !== null) + + const widthOverlay = + widthCandidates.length > 0 + ? widthCandidates.reduce((best, current) => (current.labelY > best.labelY ? current : best)) + : null + const depthOverlay = + depthCandidates.length > 0 + ? depthCandidates.reduce((best, current) => (current.labelX < best.labelX ? current : best)) + : null + + return [widthOverlay, depthOverlay].filter( + (overlay): overlay is LinearMeasurementOverlay => overlay !== null, + ) +} + function getOpeningFootprint(wall: WallNode, node: WindowNode | DoorNode): Point2D[] { const [x1, z1] = wall.start const [x2, z2] = wall.end @@ -6163,6 +6273,7 @@ export function FloorplanPanel() { return [ { + dimensionPolygon: entry.dimensionPolygon, item: entry.item, points: formatPolygonPoints(entry.polygon), polygon: entry.polygon, @@ -6665,6 +6776,15 @@ export function FloorplanPanel() { isFenceEndpointMoveActive || isFloorItemBuildActive || isFloorItemMoveActive + const itemPlacementDimensionMeasurements = useMemo(() => { + if (!isItemPlacementPreviewActive) { + return [] as LinearMeasurementOverlay[] + } + + return floorplanItemEntries.flatMap((itemEntry) => + getItemDimensionMeasurementOverlays(itemEntry, unit), + ) + }, [floorplanItemEntries, isItemPlacementPreviewActive, unit]) const floorplanPreviewStairSegment = useMemo( () => StairSegmentNodeSchema.parse({ @@ -11876,6 +11996,12 @@ export function FloorplanPanel() { selectedIdSet={selectedIdSet} /> + + (null!) const edgesRef = useRef(null!) + const measurementWidthRef = useRef(null!) + const measurementDepthRef = useRef(null!) + const measurementHeightRef = useRef(null!) const basePlaneRef = useRef(null!) const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) @@ -196,6 +220,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) const meshPreviewAppliedRef = useRef(false) + const dimensionBoundsRef = useRef(null) // Store config callbacks in refs to avoid re-running effect when they change const configRef = useRef(config) @@ -203,6 +228,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery() const { asset, draftNode } = config + const unit = useViewer((state) => state.unit) const updatePreviewGeometry = (bounds: PreviewBounds) => { const [width, height, depth] = bounds.dimensions @@ -227,6 +253,99 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea nextBoxGeometry.dispose() } + const updateDimensionGuides = (bounds: PreviewBounds) => { + dimensionBoundsRef.current = bounds + const [width, , depth] = bounds.dimensions + const [centerX, , centerZ] = bounds.center + const minX = centerX - width / 2 + const maxX = centerX + width / 2 + const minZ = centerZ - depth / 2 + const maxZ = centerZ + depth / 2 + const guideOffset = 0.18 + const tick = 0.08 + const y = 0.02 + + const widthPoints = [ + minX, + y, + maxZ + guideOffset, + maxX, + y, + maxZ + guideOffset, + + minX, + y, + maxZ + guideOffset - tick, + minX, + y, + maxZ + guideOffset + tick, + + maxX, + y, + maxZ + guideOffset - tick, + maxX, + y, + maxZ + guideOffset + tick, + ] + + const depthPoints = [ + maxX + guideOffset, + y, + minZ, + maxX + guideOffset, + y, + maxZ, + + maxX + guideOffset - tick, + y, + minZ, + maxX + guideOffset + tick, + y, + minZ, + + maxX + guideOffset - tick, + y, + maxZ, + maxX + guideOffset + tick, + y, + maxZ, + ] + + const heightPoints = [ + minX - guideOffset, + 0, + minZ, + minX - guideOffset, + bounds.dimensions[1], + minZ, + + minX - guideOffset - tick, + 0, + minZ, + minX - guideOffset + tick, + 0, + minZ, + + minX - guideOffset - tick, + bounds.dimensions[1], + minZ, + minX - guideOffset + tick, + bounds.dimensions[1], + minZ, + ] + + const applyPoints = (ref: React.RefObject, points: number[]) => { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(points, 3)) + ref.current!.geometry.dispose() + ref.current!.geometry = geometry + } + + applyPoints(measurementWidthRef, widthPoints) + applyPoints(measurementDepthRef, depthPoints) + applyPoints(measurementHeightRef, heightPoints) + } + useEffect(() => { if (!asset) return useScene.temporal.getState().pause() @@ -963,6 +1082,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea fallbackBounds) : fallbackBounds, ) + updateDimensionGuides(fallbackBounds) // ---- Undo protection ---- // Undo replaces the entire `nodes` object with a previous snapshot, which doesn't @@ -1107,12 +1227,109 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2]) basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal basePlaneGeometry.translate(0, 0.01, wallSideZOffset) // Slightly above ground to avoid z-fighting + const initialDimensionBounds = getFallbackPreviewBounds(initialDraft, config.asset!, config.asset?.attachTo) + const widthLabel = formatMeasurement(initialDimensionBounds.dimensions[0], unit) + const depthLabel = formatMeasurement(initialDimensionBounds.dimensions[2], unit) + const heightLabel = formatMeasurement(initialDimensionBounds.dimensions[1], unit) + const widthLabelPosition: [number, number, number] = [ + initialDimensionBounds.center[0], + 0.04, + initialDimensionBounds.center[2] + initialDimensionBounds.dimensions[2] / 2 + 0.24, + ] + const depthLabelPosition: [number, number, number] = [ + initialDimensionBounds.center[0] + initialDimensionBounds.dimensions[0] / 2 + 0.24, + 0.04, + initialDimensionBounds.center[2], + ] + const heightLabelPosition: [number, number, number] = [ + initialDimensionBounds.center[0] - initialDimensionBounds.dimensions[0] / 2 - 0.24, + initialDimensionBounds.dimensions[1] / 2, + initialDimensionBounds.center[2] - initialDimensionBounds.dimensions[2] / 2, + ] return ( + + + + + + + + + + +
+ {widthLabel} +
+ + +
+ {depthLabel} +
+ + +
+ {heightLabel} +
+ { + const mesh = child as Object3D & { + isMesh?: boolean + name?: string + geometry?: { + boundingBox: Box3 | null + computeBoundingBox?: () => void + } + } + + if (mesh.isMesh && mesh.name !== 'cutout' && mesh.geometry) { + if (!mesh.geometry.boundingBox && mesh.geometry.computeBoundingBox) { + mesh.geometry.computeBoundingBox() + } + + if (mesh.geometry.boundingBox) { + localMatrix.copy(inverseRootMatrix).multiply(mesh.matrixWorld) + scratchBounds.copy(mesh.geometry.boundingBox).applyMatrix4(localMatrix) + if (!hasBounds) { + localBounds.copy(scratchBounds) + hasBounds = true + } else { + localBounds.union(scratchBounds) + } + } + } + + for (const grandchild of child.children) { + expandBounds(grandchild) + } + } + + for (const child of object.children) { + expandBounds(child) + } + + if (!hasBounds) return null + + return { + min: [localBounds.min.x, localBounds.min.y, localBounds.min.z], + max: [localBounds.max.x, localBounds.max.y, localBounds.max.z], + } +} + function getMinimumAreaBoundingRect(points: Point[]) { if (points.length === 0) return null if (points.length < 3) return points @@ -273,30 +331,53 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { if (!cloneRoot) return const polygon = getLocalMeshFloorplanPolygon(cloneRoot) - if (polygon.length < 3) return + const bounds = getLocalMeshBounds(cloneRoot) + if (polygon.length < 3 && !bounds) return - const nextPolygon = polygon.map(({ x, y }) => [x, y] as [number, number]) + const nextPolygon = polygon.length >= 3 ? polygon.map(({ x, y }) => [x, y] as [number, number]) : null + const nextBounds = bounds ? { min: bounds.min, max: bounds.max } : null const metadata = typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata) ? (node.metadata as Record) : {} const currentPolygon = metadata.floorplanLocalPolygon + const currentBounds = + typeof metadata.meshLocalBounds === 'object' && + metadata.meshLocalBounds !== null && + !Array.isArray(metadata.meshLocalBounds) + ? (metadata.meshLocalBounds as { min?: unknown; max?: unknown }) + : null const unchanged = - Array.isArray(currentPolygon) && - currentPolygon.length === nextPolygon.length && - currentPolygon.every( - (point, index) => - Array.isArray(point) && - point[0] === nextPolygon[index]?.[0] && - point[1] === nextPolygon[index]?.[1], - ) + ((nextPolygon === null && + (currentPolygon === undefined || currentPolygon === null || currentPolygon === false)) || + (Array.isArray(currentPolygon) && + nextPolygon !== null && + currentPolygon.length === nextPolygon.length && + currentPolygon.every( + (point, index) => + Array.isArray(point) && + point[0] === nextPolygon[index]?.[0] && + point[1] === nextPolygon[index]?.[1], + ))) && + ((nextBounds === null && + (currentBounds === undefined || currentBounds === null || currentBounds === false)) || + (nextBounds !== null && + Array.isArray(currentBounds?.min) && + Array.isArray(currentBounds?.max) && + currentBounds.min[0] === nextBounds.min[0] && + currentBounds.min[1] === nextBounds.min[1] && + currentBounds.min[2] === nextBounds.min[2] && + currentBounds.max[0] === nextBounds.max[0] && + currentBounds.max[1] === nextBounds.max[1] && + currentBounds.max[2] === nextBounds.max[2])) if (unchanged) return useScene.getState().updateNode(node.id, { metadata: { ...metadata, - floorplanLocalPolygon: nextPolygon, + ...(nextPolygon ? { floorplanLocalPolygon: nextPolygon } : {}), + ...(nextBounds ? { meshLocalBounds: nextBounds } : {}), }, }) }, [node.id, node.metadata, scene])