Fix floorplan preview update loop
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
|
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
|
||||||
import { getScaledDimensions } from '../../schema'
|
import { getScaledDimensions } from '../../schema'
|
||||||
import useScene from '../../store/use-scene'
|
import useScene from '../../store/use-scene'
|
||||||
import { Vector3 } from 'three'
|
|
||||||
import { SpatialGrid } from './spatial-grid'
|
import { SpatialGrid } from './spatial-grid'
|
||||||
import { WallSpatialGrid } from './wall-spatial-grid'
|
import { WallSpatialGrid } from './wall-spatial-grid'
|
||||||
|
|
||||||
@@ -114,15 +113,15 @@ function getItemLocalBounds(item: ItemNode): ItemLocalBounds {
|
|||||||
|
|
||||||
function getItemParentAabb(item: ItemNode): ItemParentAabb {
|
function getItemParentAabb(item: ItemNode): ItemParentAabb {
|
||||||
const bounds = getItemLocalBounds(item)
|
const bounds = getItemLocalBounds(item)
|
||||||
const corners = [
|
const corners: Array<[number, number, number]> = [
|
||||||
new Vector3(bounds.min[0], bounds.min[1], bounds.min[2]),
|
[bounds.min[0], bounds.min[1], bounds.min[2]],
|
||||||
new Vector3(bounds.min[0], bounds.min[1], bounds.max[2]),
|
[bounds.min[0], bounds.min[1], bounds.max[2]],
|
||||||
new Vector3(bounds.min[0], bounds.max[1], bounds.min[2]),
|
[bounds.min[0], bounds.max[1], bounds.min[2]],
|
||||||
new Vector3(bounds.min[0], bounds.max[1], bounds.max[2]),
|
[bounds.min[0], bounds.max[1], bounds.max[2]],
|
||||||
new Vector3(bounds.max[0], bounds.min[1], bounds.min[2]),
|
[bounds.max[0], bounds.min[1], bounds.min[2]],
|
||||||
new Vector3(bounds.max[0], bounds.min[1], bounds.max[2]),
|
[bounds.max[0], bounds.min[1], bounds.max[2]],
|
||||||
new Vector3(bounds.max[0], bounds.max[1], bounds.min[2]),
|
[bounds.max[0], bounds.max[1], bounds.min[2]],
|
||||||
new Vector3(bounds.max[0], bounds.max[1], bounds.max[2]),
|
[bounds.max[0], bounds.max[1], bounds.max[2]],
|
||||||
]
|
]
|
||||||
const yRot = item.rotation[1] ?? 0
|
const yRot = item.rotation[1] ?? 0
|
||||||
const cos = Math.cos(yRot)
|
const cos = Math.cos(yRot)
|
||||||
@@ -135,11 +134,11 @@ function getItemParentAabb(item: ItemNode): ItemParentAabb {
|
|||||||
let maxY = Number.NEGATIVE_INFINITY
|
let maxY = Number.NEGATIVE_INFINITY
|
||||||
let maxZ = Number.NEGATIVE_INFINITY
|
let maxZ = Number.NEGATIVE_INFINITY
|
||||||
|
|
||||||
for (const corner of corners) {
|
for (const [cx, cy, cz] of corners) {
|
||||||
const rotatedX = corner.x * cos + corner.z * sin
|
const rotatedX = cx * cos + cz * sin
|
||||||
const rotatedZ = -corner.x * sin + corner.z * cos
|
const rotatedZ = -cx * sin + cz * cos
|
||||||
const worldX = rotatedX + item.position[0]
|
const worldX = rotatedX + item.position[0]
|
||||||
const worldY = corner.y + item.position[1]
|
const worldY = cy + item.position[1]
|
||||||
const worldZ = rotatedZ + item.position[2]
|
const worldZ = rotatedZ + item.position[2]
|
||||||
minX = Math.min(minX, worldX)
|
minX = Math.min(minX, worldX)
|
||||||
minY = Math.min(minY, worldY)
|
minY = Math.min(minY, worldY)
|
||||||
|
|||||||
@@ -5361,6 +5361,7 @@ export function FloorplanPanel() {
|
|||||||
const [shiftPressed, setShiftPressed] = useState(false)
|
const [shiftPressed, setShiftPressed] = useState(false)
|
||||||
const [rotationModifierPressed, setRotationModifierPressed] = useState(false)
|
const [rotationModifierPressed, setRotationModifierPressed] = useState(false)
|
||||||
const [movingFloorplanNodeRevision, setMovingFloorplanNodeRevision] = useState(0)
|
const [movingFloorplanNodeRevision, setMovingFloorplanNodeRevision] = useState(0)
|
||||||
|
const movingFloorplanNodeRefreshFrameRef = useRef<number | null>(null)
|
||||||
const [stairBuildPreviewPoint, setStairBuildPreviewPoint] = useState<WallPlanPoint | null>(null)
|
const [stairBuildPreviewPoint, setStairBuildPreviewPoint] = useState<WallPlanPoint | null>(null)
|
||||||
const [stairBuildPreviewRotation, setStairBuildPreviewRotation] = useState(0)
|
const [stairBuildPreviewRotation, setStairBuildPreviewRotation] = useState(0)
|
||||||
const [isPanning, setIsPanning] = useState(false)
|
const [isPanning, setIsPanning] = useState(false)
|
||||||
@@ -5388,6 +5389,27 @@ export function FloorplanPanel() {
|
|||||||
setIsMacPlatform(navigator.platform.toUpperCase().includes('MAC'))
|
setIsMacPlatform(navigator.platform.toUpperCase().includes('MAC'))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const scheduleMovingFloorplanNodeRefresh = useCallback(() => {
|
||||||
|
if (movingFloorplanNodeRefreshFrameRef.current !== null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
movingFloorplanNodeRefreshFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
|
movingFloorplanNodeRefreshFrameRef.current = null
|
||||||
|
setMovingFloorplanNodeRevision((current) => current + 1)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (movingFloorplanNodeRefreshFrameRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(movingFloorplanNodeRefreshFrameRef.current)
|
||||||
|
movingFloorplanNodeRefreshFrameRef.current = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
const sitePolygonEntry = useMemo(() => {
|
const sitePolygonEntry = useMemo(() => {
|
||||||
const polygonPoints = site?.polygon?.points
|
const polygonPoints = site?.polygon?.points
|
||||||
if (!(site && polygonPoints)) {
|
if (!(site && polygonPoints)) {
|
||||||
@@ -6913,10 +6935,35 @@ export function FloorplanPanel() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasUserAdjustedViewportRef.current) {
|
// While the cursor drives live geometry (items, drafts, moves), `fittedViewport` changes every
|
||||||
|
// pointermove. Syncing `viewport` here would call setState in a tight loop (max update depth).
|
||||||
|
const transientFloorplanFit =
|
||||||
|
cursorPoint != null ||
|
||||||
|
movingNode != null ||
|
||||||
|
movingFenceEndpoint != null ||
|
||||||
|
curvingWall != null ||
|
||||||
|
curvingFence != null ||
|
||||||
|
slabVertexDragState != null ||
|
||||||
|
siteVertexDragState != null ||
|
||||||
|
zoneVertexDragState != null ||
|
||||||
|
isPolygonDraftBuildActive
|
||||||
|
|
||||||
|
if (!hasUserAdjustedViewportRef.current && !transientFloorplanFit) {
|
||||||
setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport))
|
setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport))
|
||||||
}
|
}
|
||||||
}, [fittedViewport, levelId])
|
}, [
|
||||||
|
curvingFence,
|
||||||
|
curvingWall,
|
||||||
|
cursorPoint,
|
||||||
|
fittedViewport,
|
||||||
|
isPolygonDraftBuildActive,
|
||||||
|
levelId,
|
||||||
|
movingFenceEndpoint,
|
||||||
|
movingNode,
|
||||||
|
siteVertexDragState,
|
||||||
|
slabVertexDragState,
|
||||||
|
zoneVertexDragState,
|
||||||
|
])
|
||||||
|
|
||||||
const viewBox = useMemo(() => {
|
const viewBox = useMemo(() => {
|
||||||
const currentViewport = viewport ?? fittedViewport
|
const currentViewport = viewport ?? fittedViewport
|
||||||
@@ -7661,7 +7708,7 @@ export function FloorplanPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const refreshFloorplanItemPreview = () => {
|
const refreshFloorplanItemPreview = () => {
|
||||||
setMovingFloorplanNodeRevision((current) => current + 1)
|
scheduleMovingFloorplanNodeRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
emitter.on('grid:move', refreshFloorplanItemPreview)
|
emitter.on('grid:move', refreshFloorplanItemPreview)
|
||||||
@@ -7687,21 +7734,15 @@ export function FloorplanPanel() {
|
|||||||
emitter.off('item:move', refreshFloorplanItemPreview as any)
|
emitter.off('item:move', refreshFloorplanItemPreview as any)
|
||||||
emitter.off('item:leave', refreshFloorplanItemPreview as any)
|
emitter.off('item:leave', refreshFloorplanItemPreview as any)
|
||||||
}
|
}
|
||||||
}, [isItemPlacementPreviewActive])
|
}, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasPendingItemMeshFootprints) {
|
if (!hasPendingItemMeshFootprints) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const frameId = window.requestAnimationFrame(() => {
|
scheduleMovingFloorplanNodeRefresh()
|
||||||
setMovingFloorplanNodeRevision((current) => current + 1)
|
}, [hasPendingItemMeshFootprints, scheduleMovingFloorplanNodeRefresh])
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.cancelAnimationFrame(frameId)
|
|
||||||
}
|
|
||||||
}, [hasPendingItemMeshFootprints])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!(movingNode?.type === 'door' || movingNode?.type === 'window')) {
|
if (!(movingNode?.type === 'door' || movingNode?.type === 'window')) {
|
||||||
@@ -7710,7 +7751,7 @@ export function FloorplanPanel() {
|
|||||||
|
|
||||||
const movingOpeningId = movingNode.id
|
const movingOpeningId = movingNode.id
|
||||||
const refreshOpeningPreview = () => {
|
const refreshOpeningPreview = () => {
|
||||||
setMovingFloorplanNodeRevision((current) => current + 1)
|
scheduleMovingFloorplanNodeRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshOpeningPreview()
|
refreshOpeningPreview()
|
||||||
@@ -7725,7 +7766,7 @@ export function FloorplanPanel() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
}, [movingNode])
|
}, [movingNode, scheduleMovingFloorplanNodeRefresh])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (movingNode?.type !== 'fence') {
|
if (movingNode?.type !== 'fence') {
|
||||||
@@ -7753,7 +7794,7 @@ export function FloorplanPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const refreshFencePreview = () => {
|
const refreshFencePreview = () => {
|
||||||
setMovingFloorplanNodeRevision((current) => current + 1)
|
scheduleMovingFloorplanNodeRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshFencePreview()
|
refreshFencePreview()
|
||||||
@@ -7768,7 +7809,7 @@ export function FloorplanPanel() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
}, [fences, movingNode])
|
}, [fences, movingNode, scheduleMovingFloorplanNodeRefresh])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!(movingNode?.type === 'roof' || movingNode?.type === 'roof-segment')) {
|
if (!(movingNode?.type === 'roof' || movingNode?.type === 'roof-segment')) {
|
||||||
@@ -7777,7 +7818,7 @@ export function FloorplanPanel() {
|
|||||||
|
|
||||||
const movingRoofNodeId = movingNode.id
|
const movingRoofNodeId = movingNode.id
|
||||||
const refreshRoofPreview = () => {
|
const refreshRoofPreview = () => {
|
||||||
setMovingFloorplanNodeRevision((current) => current + 1)
|
scheduleMovingFloorplanNodeRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshRoofPreview()
|
refreshRoofPreview()
|
||||||
@@ -7792,7 +7833,7 @@ export function FloorplanPanel() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return unsubscribe
|
return unsubscribe
|
||||||
}, [movingNode])
|
}, [movingNode, scheduleMovingFloorplanNodeRefresh])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -15,13 +15,17 @@ import { Clone } from '@react-three/drei/core/Clone'
|
|||||||
import { useGLTF } from '@react-three/drei/core/Gltf'
|
import { useGLTF } from '@react-three/drei/core/Gltf'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { Suspense, useEffect, useMemo, useRef } from 'react'
|
import { Suspense, useEffect, useMemo, useRef } from 'react'
|
||||||
import type { AnimationAction, Group, Material, Mesh, Object3D } from 'three'
|
import type { AnimationAction, Group, Material, Mesh } from 'three'
|
||||||
import { Box3, MathUtils, Matrix4, Vector3 } from 'three'
|
import { MathUtils } from 'three'
|
||||||
import { positionLocal, smoothstep, time } from 'three/tsl'
|
import { positionLocal, smoothstep, time } from 'three/tsl'
|
||||||
import { MeshStandardNodeMaterial } from 'three/webgpu'
|
import { MeshStandardNodeMaterial } from 'three/webgpu'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import { resolveCdnUrl } from '../../../lib/asset-url'
|
import { resolveCdnUrl } from '../../../lib/asset-url'
|
||||||
import { useItemLightPool } from '../../../store/use-item-light-pool'
|
import { useItemLightPool } from '../../../store/use-item-light-pool'
|
||||||
|
import {
|
||||||
|
requestItemMeshMetadataSync,
|
||||||
|
setItemMeshMetadataSourceRoot,
|
||||||
|
} from '../../../systems/item-mesh-metadata/sync-request'
|
||||||
import { ErrorBoundary } from '../../error-boundary'
|
import { ErrorBoundary } from '../../error-boundary'
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
|
|
||||||
@@ -89,225 +93,6 @@ const multiplyScales = (
|
|||||||
b: [number, number, number],
|
b: [number, number, number],
|
||||||
): [number, number, number] => [a[0] * b[0], a[1] * b[1], a[2] * b[2]]
|
): [number, number, number] => [a[0] * b[0], a[1] * b[1], a[2] * b[2]]
|
||||||
|
|
||||||
type Point = {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type LocalBounds = {
|
|
||||||
min: [number, number, number]
|
|
||||||
max: [number, number, number]
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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) ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalMeshBounds(object: Object3D): LocalBounds | 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]
|
|
||||||
}
|
|
||||||
|
|
||||||
const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
||||||
const { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '')
|
const { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '')
|
||||||
const ref = useRef<Group>(null!)
|
const ref = useRef<Group>(null!)
|
||||||
@@ -326,60 +111,17 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
|||||||
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
||||||
}, [node.parentId])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const cloneRoot = ref.current
|
const cloneRoot = ref.current
|
||||||
if (!cloneRoot) return
|
if (!cloneRoot) return
|
||||||
|
|
||||||
const polygon = getLocalMeshFloorplanPolygon(cloneRoot)
|
setItemMeshMetadataSourceRoot(node.id, cloneRoot)
|
||||||
const bounds = getLocalMeshBounds(cloneRoot)
|
requestItemMeshMetadataSync(node.id)
|
||||||
if (polygon.length < 3 && !bounds) return
|
return () => {
|
||||||
|
setItemMeshMetadataSourceRoot(node.id, null)
|
||||||
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>)
|
|
||||||
: {}
|
|
||||||
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 =
|
|
||||||
((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)) ||
|
|
||||||
(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,
|
|
||||||
...(nextPolygon ? { floorplanLocalPolygon: nextPolygon } : {}),
|
|
||||||
...(nextBounds ? { meshLocalBounds: nextBounds } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}, [node.id, node.metadata, scene])
|
}, [node.id, node.metadata, scene])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import * as THREE from 'three/webgpu'
|
|||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
import { GuideSystem } from '../../systems/guide/guide-system'
|
import { GuideSystem } from '../../systems/guide/guide-system'
|
||||||
import { ItemLightSystem } from '../../systems/item-light/item-light-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 { LevelSystem } from '../../systems/level/level-system'
|
||||||
import { ScanSystem } from '../../systems/scan/scan-system'
|
import { ScanSystem } from '../../systems/scan/scan-system'
|
||||||
import { WallCutout } from '../../systems/wall/wall-cutout'
|
import { WallCutout } from '../../systems/wall/wall-cutout'
|
||||||
@@ -172,6 +173,7 @@ const Viewer: React.FC<ViewerProps> = ({
|
|||||||
<GPUDeviceWatcher />
|
<GPUDeviceWatcher />
|
||||||
|
|
||||||
<ItemLightSystem />
|
<ItemLightSystem />
|
||||||
|
<ItemMeshMetadataSystem />
|
||||||
{selectionManager === 'default' && <SelectionManager />}
|
{selectionManager === 'default' && <SelectionManager />}
|
||||||
{perf && <PerfMonitor />}
|
{perf && <PerfMonitor />}
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
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]
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
'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.floorplanLocalPolygon
|
||||||
|
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 ? { floorplanLocalPolygon: nextPolygon } : {}),
|
||||||
|
...(nextBounds ? { meshLocalBounds: nextBounds } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes `floorplanLocalPolygon` / `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
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user