Fix floorplan preview update loop
This commit is contained in:
@@ -15,13 +15,17 @@ import { Clone } from '@react-three/drei/core/Clone'
|
||||
import { useGLTF } from '@react-three/drei/core/Gltf'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { Suspense, useEffect, useMemo, useRef } from 'react'
|
||||
import type { AnimationAction, Group, Material, Mesh, Object3D } from 'three'
|
||||
import { Box3, MathUtils, Matrix4, Vector3 } from 'three'
|
||||
import type { AnimationAction, Group, Material, Mesh } from 'three'
|
||||
import { MathUtils } from 'three'
|
||||
import { positionLocal, smoothstep, time } from 'three/tsl'
|
||||
import { MeshStandardNodeMaterial } from 'three/webgpu'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { resolveCdnUrl } from '../../../lib/asset-url'
|
||||
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'
|
||||
|
||||
@@ -89,225 +93,6 @@ const multiplyScales = (
|
||||
b: [number, number, number],
|
||||
): [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 { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '')
|
||||
const ref = useRef<Group>(null!)
|
||||
@@ -326,60 +111,17 @@ 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
|
||||
|
||||
const polygon = getLocalMeshFloorplanPolygon(cloneRoot)
|
||||
const bounds = getLocalMeshBounds(cloneRoot)
|
||||
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>)
|
||||
: {}
|
||||
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 } : {}),
|
||||
},
|
||||
})
|
||||
setItemMeshMetadataSourceRoot(node.id, cloneRoot)
|
||||
requestItemMeshMetadataSync(node.id)
|
||||
return () => {
|
||||
setItemMeshMetadataSourceRoot(node.id, null)
|
||||
}
|
||||
}, [node.id, node.metadata, scene])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import * as THREE from 'three/webgpu'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
import { GuideSystem } from '../../systems/guide/guide-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 { ScanSystem } from '../../systems/scan/scan-system'
|
||||
import { WallCutout } from '../../systems/wall/wall-cutout'
|
||||
@@ -172,6 +173,7 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
<GPUDeviceWatcher />
|
||||
|
||||
<ItemLightSystem />
|
||||
<ItemMeshMetadataSystem />
|
||||
{selectionManager === 'default' && <SelectionManager />}
|
||||
{perf && <PerfMonitor />}
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user