feat(editor): precise item dimensions — static bounding boxes, placement-math, remove item-mesh-metadata system

- 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)
This commit is contained in:
Pascal
2026-05-04 19:35:41 +00:00
parent fa38b4af76
commit 1002a0a980
13 changed files with 215 additions and 737 deletions
@@ -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
@@ -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<ViewerProps> = ({
{/* <DebugRenderer /> */}
<ItemLightSystem />
<ItemMeshMetadataSystem />
{selectionManager === 'default' && <SelectionManager />}
{perf && <PerfMonitor />}
{children}
@@ -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]
}
@@ -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<string, unknown>,
): 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<string, AnyNode | undefined>) {
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<string, unknown>)
: {}
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
}
@@ -1,29 +0,0 @@
import type { Object3D } from 'three'
const pendingIds = new Set<string>()
/** Preferred root for footprint math (Clone root). Falls back to sceneRegistry item root. */
const sourceRoots = new Map<string, Object3D>()
/** 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
}