From 1002a0a980e1ecc1a576af61af2a3d8d35f41f35 Mon Sep 17 00:00:00 2001 From: Pascal Date: Mon, 4 May 2026 19:35:41 +0000 Subject: [PATCH] =?UTF-8?q?feat(editor):=20precise=20item=20dimensions=20?= =?UTF-8?q?=E2=80=94=20static=20bounding=20boxes,=20placement-math,=20remo?= =?UTF-8?q?ve=20item-mesh-metadata=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace runtime mesh-based bounding-box computation with static dimension-based polygons for item footprints - Add snapUpToGridStep() and getGridAlignedDimensions() to placement-math for grid-cell-aligned placement wireframes - Add expandBoundsToGrid() to use-placement-coordinator for consistent wireframe snapping - Add currentCursorRotationY to PlacementContext; preserve world orientation across item-surface transitions - Fix item detach from surface: use worldToBuildingLocal() instead of event.localPosition to avoid coordinate-space jump - Subscribe to useLiveTransforms in FloorplanPanel during placement so R/T keyboard rotation refreshes the 2D overlay immediately - Fix FloorplanItemImage rotation (+180° to account for top-down camera capture orientation) - Simplify spatial-grid-manager: single dimension-based getItemLocalBounds(), removes runtime mesh-metadata path - Remove item-mesh-metadata system (compute-item-mesh-metadata, item-mesh-metadata-system, sync-request) --- .../spatial-grid/spatial-grid-manager.ts | 37 +-- .../src/components/editor/floorplan-panel.tsx | 17 +- .../editor/src/components/editor/index.tsx | 12 +- .../components/tools/item/placement-math.ts | 31 +- .../tools/item/placement-strategies.ts | 58 ++-- .../components/tools/item/placement-types.ts | 7 + .../tools/item/use-placement-coordinator.tsx | 122 +++++++- packages/editor/src/lib/floorplan/items.ts | 293 +----------------- .../renderers/item/item-renderer.tsx | 17 - .../viewer/src/components/viewer/index.tsx | 2 - .../compute-item-mesh-metadata.ts | 226 -------------- .../item-mesh-metadata-system.tsx | 101 ------ .../item-mesh-metadata/sync-request.ts | 29 -- 13 files changed, 215 insertions(+), 737 deletions(-) delete mode 100644 packages/viewer/src/systems/item-mesh-metadata/compute-item-mesh-metadata.ts delete mode 100644 packages/viewer/src/systems/item-mesh-metadata/item-mesh-metadata-system.tsx delete mode 100644 packages/viewer/src/systems/item-mesh-metadata/sync-request.ts 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 4b8ea127d..39bdf4b8b 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -66,7 +66,7 @@ type ItemParentAabb = { maxZ: number } -function getFallbackItemLocalBounds(item: ItemNode): ItemLocalBounds { +function getItemLocalBounds(item: ItemNode): ItemLocalBounds { const [width, height, depth] = getScaledDimensions(item) const minZ = item.asset.attachTo === 'wall-side' ? -depth : -depth / 2 const maxZ = item.asset.attachTo === 'wall-side' ? 0 : depth / 2 @@ -76,41 +76,6 @@ function getFallbackItemLocalBounds(item: ItemNode): ItemLocalBounds { } } -function getItemLocalBounds(item: ItemNode): ItemLocalBounds { - 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 bounds = getItemLocalBounds(item) const corners: Array<[number, number, number]> = [ diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index bf3b5f9cd..55a32e7d5 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -5181,7 +5181,7 @@ function FloorplanItemImage({ }) { const resolvedUrl = useResolvedAssetUrl(url) if (!resolvedUrl) return null - const rotationDeg = (-rotation * 180) / Math.PI + const rotationDeg = (-rotation * 180) / Math.PI + 180 return ( { + if (!isItemPlacementPreviewActive) return + const unsubscribe = useLiveTransforms.subscribe((state, prev) => { + if (state.transforms !== prev.transforms) { + scheduleMovingFloorplanNodeRefresh() + } + }) + return unsubscribe + }, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh]) + useEffect(() => { if (!hasPendingItemMeshFootprints) { return diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 893b63ec3..f9abb7ac9 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -584,9 +584,9 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ return ( <> {!isFirstPersonMode && } - {!isVersionPreviewMode && !isFirstPersonMode && } - {!isVersionPreviewMode && !isFirstPersonMode && } - {!isVersionPreviewMode && !isFirstPersonMode && } + {!(isVersionPreviewMode || isFirstPersonMode) && } + {!(isVersionPreviewMode || isFirstPersonMode) && } + {!(isVersionPreviewMode || isFirstPersonMode) && } {!isFirstPersonMode && } {isFirstPersonMode ? : } @@ -594,10 +594,10 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ - {!isLoading && !isFirstPersonMode && ( + {!(isLoading || isFirstPersonMode) && ( )} - {!(isLoading || isVersionPreviewMode) && !isFirstPersonMode && } + {!(isLoading || isVersionPreviewMode || isFirstPersonMode) && } {isFirstPersonMode && } @@ -1161,7 +1161,7 @@ export default function Editor({ /> {/* First-person overlay — rendered on top of normal layout */} {isFirstPersonMode && ( -
+
useEditor.getState().setFirstPersonMode(false)} />
)} diff --git a/packages/editor/src/components/tools/item/placement-math.ts b/packages/editor/src/components/tools/item/placement-math.ts index 3f6295fa2..e4b7d3b19 100644 --- a/packages/editor/src/components/tools/item/placement-math.ts +++ b/packages/editor/src/components/tools/item/placement-math.ts @@ -1,4 +1,4 @@ -import { isObject } from '@pascal-app/core' +import { type AssetInput, isObject } from '@pascal-app/core' import useEditor from '../../../store/use-editor' function getGridSnapStep(): number { @@ -27,6 +27,35 @@ export function snapToHalf(value: number, step = getGridSnapStep()): number { return Math.round(value / step) * step } +/** + * Round a value up to the next multiple of `step`, with a minimum of `step`. + */ +export function snapUpToGridStep(value: number, step = getGridSnapStep()): number { + return Math.max(step, Math.ceil(value / step) * step) +} + +/** + * Expand an item's scaled dimensions up to the active grid step on the axes + * the placement grid covers. Used for the placement wireframe, snap math, and + * collision against the draft so a small item visually reserves a full grid + * cell. + * + * - Floor / ceiling / item-surface: X + Z (footprint) expand; Y stays exact. + * - Wall / wall-side: X (along wall) + Y (height) expand; Z (depth) stays exact + * so wall-thickness offsets aren't disturbed. + */ +export function getGridAlignedDimensions( + scaledDims: [number, number, number], + attachTo: AssetInput['attachTo'] | null | undefined, + step = getGridSnapStep(), +): [number, number, number] { + const [w, h, d] = scaledDims + if (attachTo === 'wall' || attachTo === 'wall-side') { + return [snapUpToGridStep(w, step), snapUpToGridStep(h, step), d] + } + return [snapUpToGridStep(w, step), h, snapUpToGridStep(d, step)] +} + /** * Calculate cursor rotation in WORLD space from wall normal and orientation. */ diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index f20f8745e..05abcb5fc 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -10,10 +10,11 @@ import type { WallNode, } from '@pascal-app/core' import { getScaledDimensions, sceneRegistry, useScene } from '@pascal-app/core' -import { Vector3 } from 'three' +import { Euler, Quaternion, Vector3 } from 'three' import { calculateCursorRotation, calculateItemRotation, + getGridAlignedDimensions, getSideFromNormal, isValidWallSideFace, snapToGrid, @@ -43,9 +44,10 @@ export const floorStrategy = { move(ctx: PlacementContext, event: GridEvent): PlacementResult | null { if (ctx.state.surface !== 'floor') return null - const dims = ctx.draftItem + const rawDims = ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS) + const dims = getGridAlignedDimensions(rawDims, ctx.asset.attachTo) const [dimX, , dimZ] = dims const rotY = ctx.draftItem?.rotation?.[1] ?? 0 const swapDims = Math.abs(Math.sin(rotY)) > 0.9 @@ -80,7 +82,7 @@ export const floorStrategy = { const valid = validators.canPlaceOnFloor( ctx.levelId, pos, - getScaledDimensions(ctx.draftItem), + getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo), ctx.draftItem.rotation, [ctx.draftItem.id], ).valid @@ -133,14 +135,15 @@ export const wallStrategy = { const z = snapToHalf(event.localPosition[2]) // Get auto-adjusted Y position from validator + const rawDims = ctx.draftItem + ? getScaledDimensions(ctx.draftItem) + : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS) const validation = validators.canPlaceOnWall( ctx.levelId, event.node.id, x, y, - ctx.draftItem - ? getScaledDimensions(ctx.draftItem) - : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS), + getGridAlignedDimensions(rawDims, attachTo), attachTo, side, [], @@ -195,7 +198,7 @@ export const wallStrategy = { event.node.id, snappedX, snappedY, - getScaledDimensions(ctx.draftItem), + getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo), ctx.draftItem.asset.attachTo as 'wall' | 'wall-side', side, [ctx.draftItem.id], @@ -239,7 +242,7 @@ export const wallStrategy = { ctx.state.wallId as WallNode['id'], ctx.gridPosition.x, ctx.gridPosition.y, - getScaledDimensions(ctx.draftItem), + getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo), ctx.draftItem.asset.attachTo as 'wall' | 'wall-side', ctx.draftItem.side, [ctx.draftItem.id], @@ -301,11 +304,12 @@ export const ceilingStrategy = { const ceilingLevelId = resolveLevelId(event.node, nodes) if (ctx.levelId !== ceilingLevelId) return null - const dims = ctx.draftItem + const rawDims = ctx.draftItem ? getScaledDimensions(ctx.draftItem) : (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS) + const dims = getGridAlignedDimensions(rawDims, ctx.asset.attachTo) const [dimX, , dimZ] = dims - const itemHeight = dims[1] + const itemHeight = rawDims[1] const rotY = ctx.draftItem?.rotation?.[1] ?? 0 const swapDims = Math.abs(Math.sin(rotY)) > 0.9 @@ -335,9 +339,10 @@ export const ceilingStrategy = { if (ctx.state.surface !== 'ceiling') return null if (!ctx.draftItem) return null - const dims = getScaledDimensions(ctx.draftItem) + const rawDims = getScaledDimensions(ctx.draftItem) + const dims = getGridAlignedDimensions(rawDims, ctx.draftItem.asset.attachTo) const [dimX, , dimZ] = dims - const itemHeight = dims[1] + const itemHeight = rawDims[1] const rotY = ctx.draftItem.rotation?.[1] ?? 0 const swapDims = Math.abs(Math.sin(rotY)) > 0.9 @@ -375,7 +380,7 @@ export const ceilingStrategy = { const valid = validators.canPlaceOnCeiling( ctx.state.ceilingId as CeilingNode['id'], pos, - getScaledDimensions(ctx.draftItem), + getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo), ctx.draftItem.rotation, [ctx.draftItem.id], ).valid @@ -453,8 +458,21 @@ export const itemSurfaceStrategy = { return { stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id }, - nodeUpdate: { position: [x, y, z], parentId: surfaceItem.id }, - cursorRotationY: 0, + nodeUpdate: { + position: [x, y, z], + parentId: surfaceItem.id, + rotation: [ + (ctx.draftItem?.rotation ?? [0, 0, 0])[0], + (() => { + const surfaceQuat = new Quaternion() + surfaceMesh.getWorldQuaternion(surfaceQuat) + const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y + return ctx.currentCursorRotationY - surfaceWorldY + })(), + (ctx.draftItem?.rotation ?? [0, 0, 0])[2], + ] as [number, number, number], + }, + cursorRotationY: ctx.currentCursorRotationY, gridPosition: [x, y, z], cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], stopPropagation: true, @@ -488,7 +506,7 @@ export const itemSurfaceStrategy = { return { gridPosition: [x, y, z], cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], - cursorRotationY: 0, + cursorRotationY: ctx.currentCursorRotationY, nodeUpdate: { position: [x, y, z] }, stopPropagation: true, dirtyNodeId: null, @@ -532,12 +550,14 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato const attachTo = ctx.draftItem.asset.attachTo + const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo) + if (attachTo === 'ceiling') { if (ctx.state.surface !== 'ceiling' || !ctx.state.ceilingId) return false return validators.canPlaceOnCeiling( ctx.state.ceilingId as CeilingNode['id'], [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], - getScaledDimensions(ctx.draftItem), + alignedDims, ctx.draftItem.rotation, [ctx.draftItem.id], ).valid @@ -550,7 +570,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato ctx.state.wallId as WallNode['id'], ctx.gridPosition.x, ctx.gridPosition.y, - getScaledDimensions(ctx.draftItem), + alignedDims, attachTo, ctx.draftItem.side, [ctx.draftItem.id], @@ -561,7 +581,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return validators.canPlaceOnFloor( ctx.levelId, [ctx.gridPosition.x, 0, ctx.gridPosition.z], - getScaledDimensions(ctx.draftItem), + alignedDims, ctx.draftItem.rotation, [ctx.draftItem.id], ).valid diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 04bfe3a80..538286580 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -38,6 +38,13 @@ export interface PlacementContext { draftItem: ItemNode | null gridPosition: Vector3 state: PlacementState + /** + * Current world Y rotation of the placement cursor — the user's intended + * orientation, preserved across surface transitions. Strategies that + * re-parent the draft (e.g. floor → item-surface) read this to compute the + * matching parent-local rotation so the world orientation doesn't jump. + */ + currentCursorRotationY: number } // ============================================================================ 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 0e2b5d217..3e9a8059b 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -39,7 +39,8 @@ import { distance, smoothstep, uv, vec2 } from 'three/tsl' import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' -import { snapToGrid } from './placement-math' +import useEditor from '../../../store/use-editor' +import { getGridAlignedDimensions, snapToGrid, snapUpToGridStep } from './placement-math' import { ceilingStrategy, checkCanPlace, @@ -70,6 +71,54 @@ type PreviewBounds = { center: [number, number, number] } +/** + * Expand `bounds` outward so each axis is rounded up to the active grid step. + * The wireframe stays centered on the original bounds centre on each axis we + * expand, so an off-centre mesh bbox stays off-centre. Wall-side items keep + * `max.z = 0` (flush with the wall plane); the bottom (`min.y`) is preserved + * so the box still sits on the floor / attachment plane. + * + * Floor / ceiling / item-surface: X and Z expand; Y stays exact. + * Wall / wall-side: X and Y expand; Z stays exact. + */ +function expandBoundsToGrid( + bounds: PreviewBounds, + attachTo: AssetInput['attachTo'] | null | undefined, + step: number, +): PreviewBounds { + const [w, h, d] = bounds.dimensions + const [cx, , cz] = bounds.center + const onWall = attachTo === 'wall' || attachTo === 'wall-side' + const expandedW = snapUpToGridStep(w, step) + const expandedH = onWall ? snapUpToGridStep(h, step) : h + const expandedD = onWall ? d : snapUpToGridStep(d, step) + + const minX = cx - expandedW / 2 + const maxX = cx + expandedW / 2 + const minY = bounds.min[1] + const maxY = minY + expandedH + + let minZ: number + let maxZ: number + let newCz: number + if (attachTo === 'wall-side') { + maxZ = 0 + minZ = -expandedD + newCz = -expandedD / 2 + } else { + minZ = cz - expandedD / 2 + maxZ = cz + expandedD / 2 + newCz = cz + } + + return { + min: [minX, minY, minZ], + max: [maxX, maxY, maxZ], + dimensions: [expandedW, expandedH, expandedD], + center: [cx, (minY + maxY) / 2, newCz], + } +} + function getPreviewBoundsFromObject(object: Object3D | null): PreviewBounds | null { if (!object) return null @@ -233,7 +282,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery() const { asset, draftNode } = config const unit = useViewer((state) => state.unit) - + const gridSnapStep = useEditor((s) => s.gridSnapStep) const updatePreviewGeometry = (bounds: PreviewBounds) => { const [width, height, depth] = bounds.dimensions const [centerX, centerY, centerZ] = bounds.center @@ -373,6 +422,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draftItem: draftNode.current, gridPosition: gridPosition.current, state: { ...placementState.current }, + currentCursorRotationY: cursorGroupRef.current.rotation.y, }) const getActiveValidators = () => @@ -791,9 +841,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea event.stopPropagation() - // Transition back to floor using building-local position - const wx = Math.round(event.localPosition[0] * 2) / 2 - const wz = Math.round(event.localPosition[2] * 2) / 2 + // `event.localPosition` from useNodeEvents is in the LEAVING item's + // local space (the sofa/table the draft is detaching from), not + // 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 }) @@ -1083,11 +1142,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Bounding box geometry ---- const draft = draftNode.current - const fallbackBounds = getFallbackPreviewBounds(draft, asset, asset.attachTo) + const fallbackBounds = expandBoundsToGrid( + getFallbackPreviewBounds(draft, asset, asset.attachTo), + asset.attachTo, + gridSnapStep, + ) updatePreviewGeometry( draft - ? (getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ?? - fallbackBounds) + ? (expandBoundsToGrid( + getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ?? getFallbackPreviewBounds(draft, asset, asset.attachTo), + asset.attachTo, + gridSnapStep, + )) : fallbackBounds, ) updateDimensionGuides(fallbackBounds) @@ -1163,7 +1229,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } }, [asset, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode]) - // Reparent floor draft to the new level when the user switches levels mid-placement. + // Refresh wireframe when the grid step changes mid-placement so the green/red + // box snaps to the new cell size right away. + useEffect(() => { + if (!asset) return + const draft = draftNode.current + const fallbackBounds = expandBoundsToGrid( + getFallbackPreviewBounds(draft, asset, asset.attachTo), + asset.attachTo, + gridSnapStep, + ) + const meshBounds = draft + ? getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) + : null + updatePreviewGeometry( + meshBounds ? expandBoundsToGrid(meshBounds, asset.attachTo, gridSnapStep) : fallbackBounds, + ) + updateDimensionGuides(fallbackBounds) + }, [gridSnapStep, asset, draftNode]) // Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent). const viewerLevelId = useViewer((s) => s.selection.levelId) useEffect(() => { @@ -1190,7 +1273,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!meshPreviewAppliedRef.current) { const previewBounds = getPreviewBoundsFromObject(mesh) if (previewBounds) { - updatePreviewGeometry(previewBounds) + updatePreviewGeometry( + expandBoundsToGrid(previewBounds, asset.attachTo, useEditor.getState().gridSnapStep), + ) meshPreviewAppliedRef.current = true } } @@ -1217,7 +1302,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const slabElevation = spatialGridManager.getSlabElevationForItem( levelId, [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], - getScaledDimensions(draftNode.current), + getGridAlignedDimensions( + getScaledDimensions(draftNode.current), + draftNode.current.asset.attachTo, + ), draftNode.current.rotation, ) mesh.position.y = slabElevation @@ -1226,18 +1314,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea }) const initialDraft = draftNode.current - const dims = initialDraft + const initialAttachTo = config.asset?.attachTo + const rawDims = initialDraft ? 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 = config.asset?.attachTo === 'wall-side' ? -dims[2] / 2 : 0 + 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 = getFallbackPreviewBounds(initialDraft, config.asset!, config.asset?.attachTo) + 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) diff --git a/packages/editor/src/lib/floorplan/items.ts b/packages/editor/src/lib/floorplan/items.ts index 40d290a54..bb978ff71 100644 --- a/packages/editor/src/lib/floorplan/items.ts +++ b/packages/editor/src/lib/floorplan/items.ts @@ -4,11 +4,8 @@ import { getScaledDimensions, type ItemNode, type LevelNode, - sceneRegistry, useLiveTransforms, } from '@pascal-app/core' -import type { Object3D } from 'three' -import { Box3, Matrix4, Vector3 } from 'three' import { getRotatedRectanglePolygon, rotatePlanVector } from './geometry' import type { FloorplanItemEntry, FloorplanNodeTransform, LevelDescendantMap } from './types' @@ -139,34 +136,20 @@ export function buildFloorplanItemEntry( return null } + // Polygon is derived purely from `dimensions` — the same source of truth the + // editor uses for placement / collision. Previously we ran a per-frame + // convex-hull / minimum-area-rect pass over the loaded mesh's vertices to + // produce a tighter polygon, but that's expensive and disagrees with what + // the user sees on the 3D side (their dimensions are intentionally the + // bounding box, sometimes hand-tuned). const dimensionPolygon = getItemDimensionPolygon(item, transform) const [width, , depth] = getScaledDimensions(item) - if (shouldUseDimensionFloorplanFootprint(item)) { - return { - dimensionPolygon, - item, - polygon: dimensionPolygon, - usesRealMesh: true, - center: transform.position, - rotation: transform.rotation, - width, - depth, - } - } - - const object = sceneRegistry.nodes.get(item.id) - const realMeshPolygon = object - ? getRealMeshFloorplanPolygon(transform, object) - : getCachedMeshFloorplanPolygon(item, transform) - if (!realMeshPolygon) { - return null - } return { dimensionPolygon, item, - polygon: realMeshPolygon, - usesRealMesh: realMeshPolygon !== null, + polygon: dimensionPolygon, + usesRealMesh: false, center: transform.position, rotation: transform.rotation, width, @@ -179,29 +162,6 @@ type Point = { y: number } -const DIMENSION_FOOTPRINT_ASSET_IDS = new Set(['tree', 'fir-tree', 'palm', 'bush']) -const DIMENSION_FOOTPRINT_TAGS = new Set([ - 'botanical', - 'foliage', - 'greenery', - 'plant', - 'tree', - 'vegetation', -]) - -function shouldUseDimensionFloorplanFootprint(item: ItemNode) { - const asset = item.asset - if (asset.category !== 'outdoor') { - return false - } - - if (DIMENSION_FOOTPRINT_ASSET_IDS.has(asset.id)) { - return true - } - - return asset.tags?.some((tag) => DIMENSION_FOOTPRINT_TAGS.has(tag.toLowerCase())) ?? false -} - function getItemDimensionPolygon(item: ItemNode, transform: FloorplanNodeTransform): Point[] { const [width, , depth] = getScaledDimensions(item) const centerLocalZ = item.asset.attachTo === 'wall-side' ? -depth / 2 : 0 @@ -217,240 +177,3 @@ function getItemDimensionPolygon(item: ItemNode, transform: FloorplanNodeTransfo transform.rotation, ) } - -function getCachedLocalMeshPolygon(item: ItemNode): Point[] | null { - const metadata = - typeof item.metadata === 'object' && item.metadata !== null && !Array.isArray(item.metadata) - ? (item.metadata as Record) - : null - const rawPolygon = metadata?.meshLocalPlanPolygon - if (!Array.isArray(rawPolygon)) { - return null - } - - const polygon = rawPolygon.flatMap((point) => { - if (!Array.isArray(point) || point.length < 2) { - return [] - } - const x = point[0] - const y = point[1] - return typeof x === 'number' && typeof y === 'number' ? [{ x, y }] : [] - }) - - return polygon.length >= 3 ? polygon : null -} - -function getCachedMeshFloorplanPolygon(item: ItemNode, transform: FloorplanNodeTransform) { - const localPolygon = getCachedLocalMeshPolygon(item) - if (!localPolygon) { - return null - } - - return localPolygon.map((corner) => { - const [offsetX, offsetY] = rotatePlanVector(corner.x, corner.y, transform.rotation) - return { - x: transform.position.x + offsetX, - y: transform.position.y + offsetY, - } - }) -} - -function getRealMeshFloorplanPolygon(transform: FloorplanNodeTransform, object: Object3D) { - const localPolygon = getLocalMeshFloorplanPolygon(object) - if (localPolygon.length === 0) { - return null - } - - return localPolygon.map((corner) => { - const [offsetX, offsetY] = rotatePlanVector(corner.x, corner.y, transform.rotation) - return { - x: transform.position.x + offsetX, - y: transform.position.y + offsetY, - } - }) -} - -function getLocalMeshFloorplanPolygon(object: Object3D): Point[] { - object.updateWorldMatrix(true, true) - - const inverseRootMatrix = new Matrix4().copy(object.matrixWorld).invert() - const localMatrix = new Matrix4() - const scratchBounds = new Box3() - const scratchPosition = new Vector3() - const registeredNodeObjects = new Set(sceneRegistry.nodes.values()) - const footprintPoints: Point[] = [] - - const collectPoints = (child: Object3D) => { - if (child !== object && registeredNodeObjects.has(child)) { - return - } - - const mesh = child as { - isMesh?: boolean - name?: string - geometry?: { - boundingBox: Box3 | null - computeBoundingBox?: () => void - attributes?: { - position?: { - count: number - getX: (index: number) => number - getY: (index: number) => number - getZ: (index: number) => number - } - } - } - matrixWorld: Matrix4 - } - - if (mesh.isMesh && mesh.name !== 'cutout' && mesh.geometry) { - if (!mesh.geometry.boundingBox && mesh.geometry.computeBoundingBox) { - mesh.geometry.computeBoundingBox() - } - - localMatrix.copy(inverseRootMatrix).multiply(mesh.matrixWorld) - - const vertexPositions = mesh.geometry.attributes?.position - if (vertexPositions && vertexPositions.count > 0) { - for (let index = 0; index < vertexPositions.count; index += 1) { - scratchPosition - .set( - vertexPositions.getX(index), - vertexPositions.getY(index), - vertexPositions.getZ(index), - ) - .applyMatrix4(localMatrix) - - if (Number.isFinite(scratchPosition.x) && Number.isFinite(scratchPosition.z)) { - footprintPoints.push({ x: scratchPosition.x, y: scratchPosition.z }) - } - } - } else if (mesh.geometry.boundingBox) { - scratchBounds.copy(mesh.geometry.boundingBox) - scratchBounds.applyMatrix4(localMatrix) - if (Number.isFinite(scratchBounds.min.x) && Number.isFinite(scratchBounds.max.x)) { - footprintPoints.push( - { x: scratchBounds.min.x, y: scratchBounds.min.z }, - { x: scratchBounds.max.x, y: scratchBounds.min.z }, - { x: scratchBounds.max.x, y: scratchBounds.max.z }, - { x: scratchBounds.min.x, y: scratchBounds.max.z }, - ) - } - } - } - - for (const grandchild of child.children) { - collectPoints(grandchild) - } - } - - for (const child of object.children) { - collectPoints(child) - } - - return getMinimumAreaBoundingRect(footprintPoints) ?? [] -} - -function getMinimumAreaBoundingRect(points: Point[]) { - if (points.length === 0) { - return null - } - - const hull = getConvexHull(points) - if (hull.length === 0) { - return null - } - - if (hull.length === 1) { - const point = hull[0]! - return [point, point, point, point] - } - - if (hull.length === 2) { - const [start, end] = hull - return [start!, end!, end!, start!] - } - - let bestArea = Number.POSITIVE_INFINITY - let bestRect: Point[] | null = null - - for (let index = 0; index < hull.length; index += 1) { - const start = hull[index]! - const end = hull[(index + 1) % hull.length]! - const angle = Math.atan2(end.y - start.y, end.x - start.x) - const cos = Math.cos(-angle) - const sin = Math.sin(-angle) - - let minX = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - - for (const point of hull) { - const rx = point.x * cos - point.y * sin - const ry = point.x * sin + point.y * cos - minX = Math.min(minX, rx) - maxX = Math.max(maxX, rx) - minY = Math.min(minY, ry) - maxY = Math.max(maxY, ry) - } - - const area = (maxX - minX) * (maxY - minY) - if (area >= bestArea) { - continue - } - - bestRect = [ - { x: minX, y: minY }, - { x: maxX, y: minY }, - { x: maxX, y: maxY }, - { x: minX, y: maxY }, - ].map((point) => ({ - x: point.x * Math.cos(angle) - point.y * Math.sin(angle), - y: point.x * Math.sin(angle) + point.y * Math.cos(angle), - })) - bestArea = area - } - - return bestRect -} - -function getConvexHull(points: Point[]) { - const uniquePoints = Array.from( - new Map(points.map((point) => [`${point.x.toFixed(6)}:${point.y.toFixed(6)}`, point])).values(), - ).sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) - - if (uniquePoints.length <= 1) { - return uniquePoints - } - - const cross = (origin: Point, a: Point, b: Point) => - (a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x) - - const lower: Point[] = [] - for (const point of uniquePoints) { - while ( - lower.length >= 2 && - cross(lower[lower.length - 2]!, lower[lower.length - 1]!, point) <= 0 - ) { - lower.pop() - } - lower.push(point) - } - - const upper: Point[] = [] - for (let index = uniquePoints.length - 1; index >= 0; index -= 1) { - const point = uniquePoints[index]! - while ( - upper.length >= 2 && - cross(upper[upper.length - 2]!, upper[upper.length - 1]!, point) <= 0 - ) { - upper.pop() - } - upper.push(point) - } - - lower.pop() - upper.pop() - return [...lower, ...upper] -} diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index 86b44d23c..3931e0dac 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -21,10 +21,6 @@ import { useNodeEvents } from '../../../hooks/use-node-events' import { resolveCdnUrl } from '../../../lib/asset-url' import { baseMaterial, glassMaterial } from '../../../lib/materials' import { useItemLightPool } from '../../../store/use-item-light-pool' -import { - requestItemMeshMetadataSync, - setItemMeshMetadataSourceRoot, -} from '../../../systems/item-mesh-metadata/sync-request' import { ErrorBoundary } from '../../error-boundary' import { NodeRenderer } from '../node-renderer' @@ -110,19 +106,6 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId) }, [node.parentId]) - // Re-sync when GLTF `scene` or external `metadata` edits should invalidate cached footprint/bounds. - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — asset load and metadata drive mesh-metadata sync - useEffect(() => { - const cloneRoot = ref.current - if (!cloneRoot) return - - setItemMeshMetadataSourceRoot(node.id, cloneRoot) - requestItemMeshMetadataSync(node.id) - return () => { - setItemMeshMetadataSourceRoot(node.id, null) - } - }, [node.id, node.metadata, scene]) - useEffect(() => { const interactive = interactiveRef.current if (!interactive) return diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 13f478b5c..192e3c93a 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -11,7 +11,6 @@ import { FenceSystem } from '../../systems/fence/fence-system' import { GuideSystem } from '../../systems/guide/guide-system' import { ItemSystem } from '../../systems/item/item-system' import { ItemLightSystem } from '../../systems/item-light/item-light-system' -import { ItemMeshMetadataSystem } from '../../systems/item-mesh-metadata/item-mesh-metadata-system' import { LevelSystem } from '../../systems/level/level-system' import { RoofSystem } from '../../systems/roof/roof-system' import { ScanSystem } from '../../systems/scan/scan-system' @@ -239,7 +238,6 @@ const Viewer: React.FC = ({ {/* */} - {selectionManager === 'default' && } {perf && } {children} diff --git a/packages/viewer/src/systems/item-mesh-metadata/compute-item-mesh-metadata.ts b/packages/viewer/src/systems/item-mesh-metadata/compute-item-mesh-metadata.ts deleted file mode 100644 index 035857f0b..000000000 --- a/packages/viewer/src/systems/item-mesh-metadata/compute-item-mesh-metadata.ts +++ /dev/null @@ -1,226 +0,0 @@ -import type { Object3D } from 'three' -import { Box3, Matrix4, Vector3 } from 'three' - -type Point = { x: number; y: number } - -export type MeshLocalBounds = { - min: [number, number, number] - max: [number, number, number] -} - -/** Plan footprint in the item root's horizontal (x, z) plane — stored as floorplan polygon. */ -export function computePlanFootprintPolygonLocal(object: Object3D): Point[] { - object.updateWorldMatrix(true, true) - - const inverseRootMatrix = new Matrix4().copy(object.matrixWorld).invert() - const localMatrix = new Matrix4() - const scratchBounds = new Box3() - const scratchPosition = new Vector3() - const footprintPoints: Point[] = [] - - const collectPoints = (child: Object3D) => { - const mesh = child as Object3D & { - isMesh?: boolean - name?: string - geometry?: { - boundingBox: Box3 | null - computeBoundingBox?: () => void - attributes?: { - position?: { - count: number - getX: (index: number) => number - getY: (index: number) => number - getZ: (index: number) => number - } - } - } - matrixWorld: Matrix4 - } - - if (mesh.isMesh && mesh.name !== 'cutout' && mesh.geometry) { - if (!mesh.geometry.boundingBox && mesh.geometry.computeBoundingBox) { - mesh.geometry.computeBoundingBox() - } - - localMatrix.copy(inverseRootMatrix).multiply(mesh.matrixWorld) - - const vertexPositions = mesh.geometry.attributes?.position - if (vertexPositions && vertexPositions.count > 0) { - for (let index = 0; index < vertexPositions.count; index += 1) { - scratchPosition - .set( - vertexPositions.getX(index), - vertexPositions.getY(index), - vertexPositions.getZ(index), - ) - .applyMatrix4(localMatrix) - - if (Number.isFinite(scratchPosition.x) && Number.isFinite(scratchPosition.z)) { - footprintPoints.push({ x: scratchPosition.x, y: scratchPosition.z }) - } - } - } else if (mesh.geometry.boundingBox) { - scratchBounds.copy(mesh.geometry.boundingBox) - scratchBounds.applyMatrix4(localMatrix) - if (Number.isFinite(scratchBounds.min.x) && Number.isFinite(scratchBounds.max.x)) { - footprintPoints.push( - { x: scratchBounds.min.x, y: scratchBounds.min.z }, - { x: scratchBounds.max.x, y: scratchBounds.min.z }, - { x: scratchBounds.max.x, y: scratchBounds.max.z }, - { x: scratchBounds.min.x, y: scratchBounds.max.z }, - ) - } - } - } - - for (const grandchild of child.children) { - collectPoints(grandchild) - } - } - - for (const child of object.children) { - collectPoints(child) - } - - return getMinimumAreaBoundingRect(footprintPoints) ?? [] -} - -export function computeMeshLocalBoundsFromObject(object: Object3D): MeshLocalBounds | 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 expandBounds = (child: Object3D) => { - 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 - - const hull = getConvexHull(points) - if (hull.length < 3) return hull - - let bestArea = Number.POSITIVE_INFINITY - let bestRect: Point[] | null = null - - for (let index = 0; index < hull.length; index += 1) { - const nextIndex = (index + 1) % hull.length - const current = hull[index]! - const next = hull[nextIndex]! - const angle = Math.atan2(next.y - current.y, next.x - current.x) - const cos = Math.cos(-angle) - const sin = Math.sin(-angle) - - let minX = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - - for (const point of hull) { - const rx = point.x * cos - point.y * sin - const ry = point.x * sin + point.y * cos - minX = Math.min(minX, rx) - maxX = Math.max(maxX, rx) - minY = Math.min(minY, ry) - maxY = Math.max(maxY, ry) - } - - const area = (maxX - minX) * (maxY - minY) - if (area >= bestArea) continue - bestArea = area - - const unrotate = (x: number, y: number): Point => ({ - x: x * Math.cos(angle) - y * Math.sin(angle), - y: x * Math.sin(angle) + y * Math.cos(angle), - }) - - bestRect = [ - unrotate(minX, minY), - unrotate(maxX, minY), - unrotate(maxX, maxY), - unrotate(minX, maxY), - ] - } - - return bestRect -} - -function getConvexHull(points: Point[]) { - if (points.length <= 1) return points - - const sorted = [...points].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) - const cross = (o: Point, a: Point, b: Point) => - (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x) - - const lower: Point[] = [] - for (const point of sorted) { - while ( - lower.length >= 2 && - cross(lower[lower.length - 2]!, lower[lower.length - 1]!, point) <= 0 - ) { - lower.pop() - } - lower.push(point) - } - - const upper: Point[] = [] - for (let index = sorted.length - 1; index >= 0; index -= 1) { - const point = sorted[index]! - while ( - upper.length >= 2 && - cross(upper[upper.length - 2]!, upper[upper.length - 1]!, point) <= 0 - ) { - upper.pop() - } - upper.push(point) - } - - lower.pop() - upper.pop() - return [...lower, ...upper] -} diff --git a/packages/viewer/src/systems/item-mesh-metadata/item-mesh-metadata-system.tsx b/packages/viewer/src/systems/item-mesh-metadata/item-mesh-metadata-system.tsx deleted file mode 100644 index 58581c802..000000000 --- a/packages/viewer/src/systems/item-mesh-metadata/item-mesh-metadata-system.tsx +++ /dev/null @@ -1,101 +0,0 @@ -'use client' - -import { type AnyNode, type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core' -import { useFrame } from '@react-three/fiber' -import type { Object3D } from 'three' -import { - computeMeshLocalBoundsFromObject, - computePlanFootprintPolygonLocal, -} from './compute-item-mesh-metadata' -import { drainItemMeshMetadataSyncRequests, getItemMeshMetadataSourceRoot } from './sync-request' - -function isMetadataUnchanged( - nextPolygon: [number, number][] | null, - nextBounds: { min: [number, number, number]; max: [number, number, number] } | null, - metadata: Record, -): boolean { - const currentPolygon = metadata.meshLocalPlanPolygon - const currentBounds = - typeof metadata.meshLocalBounds === 'object' && - metadata.meshLocalBounds !== null && - !Array.isArray(metadata.meshLocalBounds) - ? (metadata.meshLocalBounds as { min?: unknown; max?: unknown }) - : null - - const polygonUnchanged = - (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], - )) - - const boundsUnchanged = - (nextBounds === null && (currentBounds === undefined || currentBounds === null)) || - (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]) - - return polygonUnchanged && boundsUnchanged -} - -function trySyncItemMeshMetadata(itemId: string, nodes: Record) { - const node = nodes[itemId] - if (!node || node.type !== 'item') return - const root = - getItemMeshMetadataSourceRoot(itemId) ?? - (sceneRegistry.nodes.get(itemId) as Object3D | undefined) - if (!root) return - - const polygon = computePlanFootprintPolygonLocal(root) - const bounds = computeMeshLocalBoundsFromObject(root) - if (polygon.length < 3 && !bounds) return - - 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) - : {} - - if (isMetadataUnchanged(nextPolygon, nextBounds, metadata)) return - - useScene.getState().updateNode(itemId as AnyNodeId, { - metadata: { - ...metadata, - ...(nextPolygon ? { meshLocalPlanPolygon: nextPolygon } : {}), - ...(nextBounds ? { meshLocalBounds: nextBounds } : {}), - }, - }) -} - -/** - * Writes `meshLocalPlanPolygon` / `meshLocalBounds` from loaded item meshes. - * ModelRenderer requests sync via `requestItemMeshMetadataSync` when GLTF is ready. - */ -export function ItemMeshMetadataSystem() { - useFrame(() => { - const ids = drainItemMeshMetadataSyncRequests() - if (ids.length === 0) return - - const nodes = useScene.getState().nodes - for (const id of ids) { - trySyncItemMeshMetadata(id, nodes) - } - }) - - return null -} diff --git a/packages/viewer/src/systems/item-mesh-metadata/sync-request.ts b/packages/viewer/src/systems/item-mesh-metadata/sync-request.ts deleted file mode 100644 index 7ec7c33ce..000000000 --- a/packages/viewer/src/systems/item-mesh-metadata/sync-request.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Object3D } from 'three' - -const pendingIds = new Set() -/** Preferred root for footprint math (Clone root). Falls back to sceneRegistry item root. */ -const sourceRoots = new Map() - -/** Called when an item's loaded GLTF (or metadata driving footprint) may need re-syncing. */ -export function requestItemMeshMetadataSync(itemId: string) { - pendingIds.add(itemId) -} - -export function setItemMeshMetadataSourceRoot(itemId: string, root: Object3D | null) { - if (root) { - sourceRoots.set(itemId, root) - } else { - sourceRoots.delete(itemId) - } -} - -export function getItemMeshMetadataSourceRoot(itemId: string): Object3D | undefined { - return sourceRoots.get(itemId) -} - -export function drainItemMeshMetadataSyncRequests(): string[] { - if (pendingIds.size === 0) return [] - const ids = [...pendingIds] - pendingIds.clear() - return ids -}