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 39bdf4b8..b9708753 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,5 +1,5 @@ import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' -import { getScaledDimensions } from '../../schema' +import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' import useScene from '../../store/use-scene' import { SpatialGrid } from './spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid' @@ -582,6 +582,7 @@ export class SpatialGridManager { if (node.type !== 'item') continue const item = node as ItemNode if (item.asset.attachTo) continue + if (isLowProfileItemSurface(item)) continue if (ignoreSet.has(item.id)) continue if (resolveNodeLevelId(item, nodes) !== levelId) continue diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index ed182320..4b708fb9 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -55,7 +55,12 @@ export type { TemperatureControl, ToggleControl, } from './nodes/item' -export { getScaledDimensions, ItemNode } from './nodes/item' +export { + getScaledDimensions, + ItemNode, + isLowProfileItemSurface, + LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT, +} from './nodes/item' export { LevelNode } from './nodes/level' export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof' export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof' diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index 160e724c..5fb0f15e 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -135,6 +135,20 @@ export const ItemNode = BaseNode.extend({ export type ItemNode = z.infer +export const LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT = 0.1 + +/** + * Low, floor-resting items like rugs and parking mats can receive items visually, + * but should not become item parents or block normal floor placement. + */ +export function isLowProfileItemSurface(item: ItemNode): boolean { + if (item.asset.attachTo) return false + const surfaceHeight = item.asset.surface + ? item.asset.surface.height * item.scale[1] + : getScaledDimensions(item)[1] + return surfaceHeight <= LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT +} + /** * Returns the effective world-space dimensions of an item after applying its scale. * Use this everywhere item.asset.dimensions is used for spatial calculations. diff --git a/packages/editor/src/components/editor/wall-measurement-label.tsx b/packages/editor/src/components/editor/wall-measurement-label.tsx index 60b041ce..3b24257f 100755 --- a/packages/editor/src/components/editor/wall-measurement-label.tsx +++ b/packages/editor/src/components/editor/wall-measurement-label.tsx @@ -4,7 +4,6 @@ import { type AnyNodeId, calculateLevelMiters, DEFAULT_WALL_HEIGHT, - getScaledDimensions, getWallCurveLength, getWallMiterBoundaryPoints, getWallPlanFootprint, @@ -428,30 +427,6 @@ function buildMeasurementGuide( } } -type HeightGuide = { - start: Vec3 - end: Vec3 - labelPosition: Vec3 -} - -function buildItemHeightGuide(item: ItemNode): { guide: HeightGuide; height: number } | null { - const [width, height, depth] = getScaledDimensions(item) - - if (!Number.isFinite(height) || height < 0.01) return null - - const x = Number.isFinite(width) ? width / 2 + 0.18 : 0.18 - const z = Number.isFinite(depth) ? depth / 2 + 0.18 : 0.18 - - return { - height, - guide: { - start: [x, 0, z], - end: [x, height, z], - labelPosition: [x, height / 2, z], - }, - } -} - function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color: string }) { const segment = useMemo(() => { const startVector = new THREE.Vector3(...start) @@ -535,7 +510,7 @@ function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode }) return } - return + return null } function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { @@ -600,27 +575,3 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { ) } - -function ItemHeightMeasurementAnnotation({ item }: { item: ItemNode }) { - const theme = useViewer((state) => state.theme) - const unit = useViewer((state) => state.unit) - const isNight = theme === 'dark' - const color = isNight ? '#ffffff' : '#111111' - const shadowColor = isNight ? '#111111' : '#ffffff' - - const measurement = useMemo(() => buildItemHeightGuide(item), [item]) - - if (!measurement) return null - - return ( - - - - - ) -} diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 05abcb5f..20781ec1 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -10,7 +10,7 @@ import type { WallNode, } from '@pascal-app/core' import { getScaledDimensions, sceneRegistry, useScene } from '@pascal-app/core' -import { Euler, Quaternion, Vector3 } from 'three' +import { Euler, Matrix3, Quaternion, Vector3 } from 'three' import { calculateCursorRotation, calculateItemRotation, @@ -31,6 +31,56 @@ import type { } from './placement-types' const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1] +const LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT = 0.1 +const UPWARD_SURFACE_NORMAL_MIN_Y = 0.75 +const AUTO_SURFACE_MIN_LOCAL_Y = 0.1 + +function isLowProfileItemSurface(item: ItemNode): boolean { + if (item.asset.attachTo) return false + const surfaceHeight = item.asset.surface + ? item.asset.surface.height * item.scale[1] + : getScaledDimensions(item)[1] + return surfaceHeight <= LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT +} + +function getWorldNormalY(event: ItemEvent): number | null { + if (!event.normal) return null + + const normal = new Vector3(event.normal[0], event.normal[1], event.normal[2]) + normal.applyNormalMatrix(new Matrix3().getNormalMatrix(event.object.matrixWorld)).normalize() + return normal.y +} + +function isUpwardItemSurfaceHit(event: ItemEvent): boolean { + const normalY = getWorldNormalY(event) + return normalY !== null && normalY >= UPWARD_SURFACE_NORMAL_MIN_Y +} + +function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) { + if (isLowProfileItemSurface(surfaceItem)) return null + if (!isUpwardItemSurfaceHit(event)) return null + + if (surfaceItem.asset.surface) { + return surfaceItem.asset.surface.height * surfaceItem.scale[1] + } + + if (localPos.y < AUTO_SURFACE_MIN_LOCAL_Y) return null + return localPos.y +} + +function isDescendantOfItem( + candidate: ItemNode, + ancestor: ItemNode, + nodes: Record, +): boolean { + let parentId = candidate.parentId + while (parentId) { + if (parentId === ancestor.id) return true + const parent = nodes[parentId as AnyNodeId] + parentId = parent?.parentId ?? null + } + return false +} // ============================================================================ // FLOOR STRATEGY @@ -434,8 +484,11 @@ export const itemSurfaceStrategy = { const surfaceItem = event.node as ItemNode // Don't surface-place on the draft itself if (surfaceItem.id === ctx.draftItem?.id) return null - // Surface item must declare a surface - if (!surfaceItem.asset.surface) return null + if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId === surfaceItem.id) { + return null + } + const nodes = useScene.getState().nodes + if (ctx.draftItem && isDescendantOfItem(surfaceItem, ctx.draftItem, nodes)) return null // Size check: our footprint must fit on surface item's footprint const ourDims = ctx.draftItem @@ -449,10 +502,12 @@ export const itemSurfaceStrategy = { const worldPos = new Vector3(event.position[0], event.position[1], event.position[2]) const localPos = surfaceMesh.worldToLocal(worldPos) + const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos) + if (surfaceHeight === null) return null const x = snapToGrid(localPos.x, ourDims[0]) const z = snapToGrid(localPos.z, ourDims[2]) - const y = surfaceItem.asset.surface.height * surfaceItem.scale[1] + const y = surfaceHeight const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z)) @@ -485,10 +540,11 @@ export const itemSurfaceStrategy = { move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null { if (ctx.state.surface !== 'item-surface') return null if (!(ctx.state.surfaceItemId && ctx.draftItem)) return null + if (event.node.id !== ctx.state.surfaceItemId) return null const nodes = useScene.getState().nodes const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined - if (!surfaceItem?.asset.surface) return null + if (!surfaceItem) return null const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId) if (!surfaceMesh) return null @@ -496,10 +552,12 @@ export const itemSurfaceStrategy = { const ourDims = getScaledDimensions(ctx.draftItem) const worldPos = new Vector3(event.position[0], event.position[1], event.position[2]) const localPos = surfaceMesh.worldToLocal(worldPos) + const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos) + if (surfaceHeight === null) return null const x = snapToGrid(localPos.x, ourDims[0]) const z = snapToGrid(localPos.z, ourDims[2]) - const y = surfaceItem.asset.surface.height * surfaceItem.scale[1] + const y = surfaceHeight const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z)) @@ -519,6 +577,7 @@ export const itemSurfaceStrategy = { click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null { if (ctx.state.surface !== 'item-surface') return null if (!(ctx.draftItem && ctx.state.surfaceItemId)) return null + if (_event.node.id !== ctx.state.surfaceItemId) return null return { nodeUpdate: { diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 3e9a8059..14758e5c 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -17,13 +17,11 @@ import { } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' -import { createPortal, useFrame } from '@react-three/fiber' -import { useEffect, useRef, useState } from 'react' +import { useFrame } from '@react-three/fiber' +import { useEffect, useMemo, useRef, useState } from 'react' import { - BoxGeometry, Box3, BufferGeometry, - EdgesGeometry, Euler, Float32BufferAttribute, type Group, @@ -199,21 +197,121 @@ function getFallbackPreviewBounds( ): PreviewBounds { const dims = item ? getScaledDimensions(item) : (asset.dimensions ?? DEFAULT_DIMENSIONS) return { - min: [ - -dims[0] / 2, - 0, - attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2, - ], - max: [ - dims[0] / 2, - dims[1], - attachTo === 'wall-side' ? 0 : dims[2] / 2, - ], + min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2], + max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? 0 : dims[2] / 2], dimensions: dims, center: [0, dims[1] / 2, attachTo === 'wall-side' ? -dims[2] / 2 : 0], } } +function createLineGeometry(points: number[] = [0, 0, 0, 0, 0, 0]): BufferGeometry { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(points, 3)) + return geometry +} + +function getBoxEdgePoints(bounds: PreviewBounds): number[] { + const [width, height, depth] = bounds.dimensions + const [centerX, centerY, centerZ] = bounds.center + const minX = centerX - width / 2 + const maxX = centerX + width / 2 + const minY = centerY - height / 2 + const maxY = centerY + height / 2 + const minZ = centerZ - depth / 2 + const maxZ = centerZ + depth / 2 + + return [ + minX, + minY, + minZ, + maxX, + minY, + minZ, + maxX, + minY, + minZ, + maxX, + minY, + maxZ, + maxX, + minY, + maxZ, + minX, + minY, + maxZ, + minX, + minY, + maxZ, + minX, + minY, + minZ, + + minX, + maxY, + minZ, + maxX, + maxY, + minZ, + maxX, + maxY, + minZ, + maxX, + maxY, + maxZ, + maxX, + maxY, + maxZ, + minX, + maxY, + maxZ, + minX, + maxY, + maxZ, + minX, + maxY, + minZ, + + minX, + minY, + minZ, + minX, + maxY, + minZ, + maxX, + minY, + minZ, + maxX, + maxY, + minZ, + maxX, + minY, + maxZ, + maxX, + maxY, + maxZ, + minX, + minY, + maxZ, + minX, + maxY, + maxZ, + ] +} + +function updateLineGeometry(ref: React.RefObject, points: number[]) { + const geometry = ref.current?.geometry + if (!geometry) return + + const attribute = geometry.getAttribute('position') as Float32BufferAttribute | undefined + if (!attribute || attribute.array.length !== points.length) { + geometry.setAttribute('position', new Float32BufferAttribute(points, 3)) + } else { + attribute.set(points) + attribute.needsUpdate = true + } + geometry.computeBoundingSphere() +} + // Shared materials for placement cursor - we just change colors, not swap materials // Note: EdgesGeometry doesn't work with dashed lines, so using solid lines const edgeMaterial = new LineBasicNodeMaterial({ @@ -269,11 +367,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) const meshPreviewAppliedRef = useRef(false) - const dimensionBoundsRef = useRef(null) - const [measurementTargetState, setMeasurementTargetState] = useState<{ - id: string - object: Object3D - } | null>(null) + const [dimensionBounds, setDimensionBounds] = useState(null) // Store config callbacks in refs to avoid re-running effect when they change const configRef = useRef(config) @@ -291,23 +385,33 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (previewBoundsSignatureRef.current === signature) return previewBoundsSignatureRef.current = signature - const nextBoxGeometry = new BoxGeometry(width, height, depth) - nextBoxGeometry.translate(centerX, centerY, centerZ) - const nextEdgesGeometry = new EdgesGeometry(nextBoxGeometry) - const nextBasePlaneGeometry = new PlaneGeometry(width, depth) nextBasePlaneGeometry.rotateX(-Math.PI / 2) nextBasePlaneGeometry.translate(centerX, 0.01, centerZ) - edgesRef.current.geometry.dispose() - edgesRef.current.geometry = nextEdgesGeometry - basePlaneRef.current.geometry.dispose() + updateLineGeometry(edgesRef, getBoxEdgePoints(bounds)) + + const oldBasePlaneGeometry = basePlaneRef.current.geometry basePlaneRef.current.geometry = nextBasePlaneGeometry - nextBoxGeometry.dispose() + oldBasePlaneGeometry.dispose() } const updateDimensionGuides = (bounds: PreviewBounds) => { - dimensionBoundsRef.current = bounds + setDimensionBounds((current) => { + if ( + current && + current.dimensions[0] === bounds.dimensions[0] && + current.dimensions[1] === bounds.dimensions[1] && + current.dimensions[2] === bounds.dimensions[2] && + current.center[0] === bounds.center[0] && + current.center[1] === bounds.center[1] && + current.center[2] === bounds.center[2] + ) { + return current + } + return bounds + }) + const [width, , depth] = bounds.dimensions const [centerX, , centerZ] = bounds.center const minX = centerX - width / 2 @@ -388,10 +492,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea ] 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 + updateLineGeometry(ref, points) } applyPoints(measurementWidthRef, widthPoints) @@ -767,6 +868,32 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Item Surface Handlers ---- + const detachItemSurfaceToFloor = (event: ItemEvent) => { + const buildingLocalPoint = worldToBuildingLocal( + event.position[0], + event.position[1], + event.position[2], + ) + const wx = Math.round(buildingLocalPoint.x * 2) / 2 + const wz = Math.round(buildingLocalPoint.z * 2) / 2 + const floorPos: [number, number, number] = [wx, 0, wz] + + Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null }) + gridPosition.current.set(wx, 0, wz) + cursorGroupRef.current.position.set(wx, 0, wz) + + const draft = draftNode.current + if (draft) { + draft.position = floorPos + useScene.getState().updateNode(draft.id, { + parentId: useViewer.getState().selection.levelId as string, + position: floorPos, + }) + } + + revalidate() + } + const onItemEnter = (event: ItemEvent) => { if (event.node.id === draftNode.current?.id) return const result = itemSurfaceStrategy.enter(getContext(), event) @@ -800,6 +927,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } + if (ctx.state.surface === 'item-surface' && event.node.id !== ctx.state.surfaceItemId) { + const enterResult = itemSurfaceStrategy.enter( + { ...ctx, state: { ...ctx.state, surface: 'floor', surfaceItemId: null } }, + event, + ) + + event.stopPropagation() + if (enterResult) { + applyTransition(enterResult) + if (draftNode.current && enterResult.nodeUpdate.parentId) { + useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate) + } + } else { + detachItemSurfaceToFloor(event) + } + return + } + if (!draftNode.current) { const enterResult = itemSurfaceStrategy.enter(getContext(), event) if (!enterResult) return @@ -846,30 +991,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // building-local. Convert from world via worldToBuildingLocal instead, // otherwise the wireframe jumps to a surface-local-coordinate ghost // position until the next mouse move. - const buildingLocalLeave = worldToBuildingLocal( - event.position[0], - event.position[1], - event.position[2], - ) - const wx = Math.round(buildingLocalLeave.x * 2) / 2 - const wz = Math.round(buildingLocalLeave.z * 2) / 2 - const floorPos: [number, number, number] = [wx, 0, wz] - - Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null }) - gridPosition.current.set(wx, 0, wz) - cursorGroupRef.current.position.x = wx - cursorGroupRef.current.position.z = wz - - const draft = draftNode.current - if (draft) { - draft.position = floorPos - useScene.getState().updateNode(draft.id, { - parentId: useViewer.getState().selection.levelId as string, - position: floorPos, - }) - } - - revalidate() + detachItemSurfaceToFloor(event) } const onItemClick = (event: ItemEvent) => { @@ -927,11 +1049,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } - lastRawPos.current.set( - event.localPosition[0], - event.localPosition[1], - event.localPosition[2], - ) + lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2]) const result = ceilingStrategy.move(getContext(), event) if (!result) return @@ -1147,16 +1265,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea asset.attachTo, gridSnapStep, ) - updatePreviewGeometry( - draft - ? (expandBoundsToGrid( - getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ?? getFallbackPreviewBounds(draft, asset, asset.attachTo), - asset.attachTo, - gridSnapStep, - )) - : fallbackBounds, - ) - updateDimensionGuides(fallbackBounds) + const previewBounds = draft + ? expandBoundsToGrid( + getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ?? + getFallbackPreviewBounds(draft, asset, asset.attachTo), + asset.attachTo, + gridSnapStep, + ) + : fallbackBounds + updatePreviewGeometry(previewBounds) + updateDimensionGuides(previewBounds) // ---- Undo protection ---- // Undo replaces the entire `nodes` object with a previous snapshot, which doesn't @@ -1242,10 +1360,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const meshBounds = draft ? getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) : null - updatePreviewGeometry( - meshBounds ? expandBoundsToGrid(meshBounds, asset.attachTo, gridSnapStep) : fallbackBounds, - ) - updateDimensionGuides(fallbackBounds) + const previewBounds = meshBounds + ? expandBoundsToGrid(meshBounds, asset.attachTo, gridSnapStep) + : fallbackBounds + updatePreviewGeometry(previewBounds) + updateDimensionGuides(previewBounds) }, [gridSnapStep, asset, draftNode]) // Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent). const viewerLevelId = useViewer((s) => s.selection.levelId) @@ -1263,19 +1382,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!draftNode.current) return const mesh = sceneRegistry.nodes.get(draftNode.current.id) if (!mesh) return - if ( - measurementTargetState?.id !== draftNode.current.id || - measurementTargetState.object !== mesh - ) { - setMeasurementTargetState({ id: draftNode.current.id, object: mesh }) - } - if (!meshPreviewAppliedRef.current) { const previewBounds = getPreviewBoundsFromObject(mesh) if (previewBounds) { - updatePreviewGeometry( - expandBoundsToGrid(previewBounds, asset.attachTo, useEditor.getState().gridSnapStep), - ) + const expandedBounds = expandBoundsToGrid( + previewBounds, + asset.attachTo, + useEditor.getState().gridSnapStep, + ) + updatePreviewGeometry(expandedBounds) + updateDimensionGuides(expandedBounds) meshPreviewAppliedRef.current = true } } @@ -1319,68 +1435,75 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea ? getScaledDimensions(initialDraft) : (config.asset?.dimensions ?? DEFAULT_DIMENSIONS) const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep) - const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2]) const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0 - initialBoxGeometry.translate(0, dims[1] / 2, wallSideZOffset) - - // Base plane geometry (colored rectangle on the ground) - 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 = expandBoundsToGrid( getFallbackPreviewBounds(initialDraft, config.asset!, initialAttachTo), initialAttachTo, gridSnapStep, ) - const widthLabel = formatMeasurement(initialDimensionBounds.dimensions[0], unit) - const depthLabel = formatMeasurement(initialDimensionBounds.dimensions[2], unit) - const heightLabel = formatMeasurement(initialDimensionBounds.dimensions[1], unit) + const initialEdgeGeometry = useMemo( + () => createLineGeometry(getBoxEdgePoints(initialDimensionBounds)), + [ + initialDimensionBounds.center[0], + initialDimensionBounds.center[1], + initialDimensionBounds.center[2], + initialDimensionBounds.dimensions[0], + initialDimensionBounds.dimensions[1], + initialDimensionBounds.dimensions[2], + ], + ) + const basePlaneGeometry = useMemo(() => { + const geometry = new PlaneGeometry(dims[0], dims[2]) + geometry.rotateX(-Math.PI / 2) + geometry.translate(0, 0.01, wallSideZOffset) + return geometry + }, [dims[0], dims[2], wallSideZOffset]) + const initialWidthGuideGeometry = useMemo(() => createLineGeometry(), []) + const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), []) + const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), []) + const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds + const widthLabel = formatMeasurement(currentDimensionBounds.dimensions[0], unit) + const depthLabel = formatMeasurement(currentDimensionBounds.dimensions[2], unit) + const heightLabel = formatMeasurement(currentDimensionBounds.dimensions[1], unit) const widthLabelPosition: [number, number, number] = [ - initialDimensionBounds.center[0], + currentDimensionBounds.center[0], 0.04, - initialDimensionBounds.center[2] + initialDimensionBounds.dimensions[2] / 2 + 0.24, + currentDimensionBounds.center[2] + currentDimensionBounds.dimensions[2] / 2 + 0.24, ] const depthLabelPosition: [number, number, number] = [ - initialDimensionBounds.center[0] + initialDimensionBounds.dimensions[0] / 2 + 0.24, + currentDimensionBounds.center[0] + currentDimensionBounds.dimensions[0] / 2 + 0.24, 0.04, - initialDimensionBounds.center[2], + currentDimensionBounds.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, + currentDimensionBounds.center[0] - currentDimensionBounds.dimensions[0] / 2 - 0.24, + currentDimensionBounds.dimensions[1] / 2, + currentDimensionBounds.center[2] - currentDimensionBounds.dimensions[2] / 2, ] - const measurementTarget = - draftNode.current && measurementTargetState?.id === draftNode.current.id - ? measurementTargetState.object - : null const measurementContent = ( <> - - + /> - - + /> - - + />
- - - - {measurementTarget ? createPortal(measurementContent, measurementTarget) : measurementContent} + + {measurementContent}