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 9e02263c..4b8ea127 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,7 +1,6 @@ import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import { getScaledDimensions } from '../../schema' import useScene from '../../store/use-scene' -import { Vector3 } from 'three' import { SpatialGrid } from './spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid' @@ -114,15 +113,15 @@ function getItemLocalBounds(item: ItemNode): ItemLocalBounds { function getItemParentAabb(item: ItemNode): ItemParentAabb { const bounds = getItemLocalBounds(item) - const corners = [ - new Vector3(bounds.min[0], bounds.min[1], bounds.min[2]), - new Vector3(bounds.min[0], bounds.min[1], bounds.max[2]), - new Vector3(bounds.min[0], bounds.max[1], bounds.min[2]), - new Vector3(bounds.min[0], bounds.max[1], bounds.max[2]), - new Vector3(bounds.max[0], bounds.min[1], bounds.min[2]), - new Vector3(bounds.max[0], bounds.min[1], bounds.max[2]), - new Vector3(bounds.max[0], bounds.max[1], bounds.min[2]), - new Vector3(bounds.max[0], bounds.max[1], bounds.max[2]), + const corners: Array<[number, number, number]> = [ + [bounds.min[0], bounds.min[1], bounds.min[2]], + [bounds.min[0], bounds.min[1], bounds.max[2]], + [bounds.min[0], bounds.max[1], bounds.min[2]], + [bounds.min[0], bounds.max[1], bounds.max[2]], + [bounds.max[0], bounds.min[1], bounds.min[2]], + [bounds.max[0], bounds.min[1], bounds.max[2]], + [bounds.max[0], bounds.max[1], bounds.min[2]], + [bounds.max[0], bounds.max[1], bounds.max[2]], ] const yRot = item.rotation[1] ?? 0 const cos = Math.cos(yRot) @@ -135,11 +134,11 @@ function getItemParentAabb(item: ItemNode): ItemParentAabb { let maxY = Number.NEGATIVE_INFINITY let maxZ = Number.NEGATIVE_INFINITY - for (const corner of corners) { - const rotatedX = corner.x * cos + corner.z * sin - const rotatedZ = -corner.x * sin + corner.z * cos + for (const [cx, cy, cz] of corners) { + const rotatedX = cx * cos + cz * sin + const rotatedZ = -cx * sin + cz * cos 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] minX = Math.min(minX, worldX) minY = Math.min(minY, worldY) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 363ed87e..c897e01a 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -5361,6 +5361,7 @@ export function FloorplanPanel() { const [shiftPressed, setShiftPressed] = useState(false) const [rotationModifierPressed, setRotationModifierPressed] = useState(false) const [movingFloorplanNodeRevision, setMovingFloorplanNodeRevision] = useState(0) + const movingFloorplanNodeRefreshFrameRef = useRef(null) const [stairBuildPreviewPoint, setStairBuildPreviewPoint] = useState(null) const [stairBuildPreviewRotation, setStairBuildPreviewRotation] = useState(0) const [isPanning, setIsPanning] = useState(false) @@ -5388,6 +5389,27 @@ export function FloorplanPanel() { 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 polygonPoints = site?.polygon?.points if (!(site && polygonPoints)) { @@ -6913,10 +6935,35 @@ export function FloorplanPanel() { 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)) } - }, [fittedViewport, levelId]) + }, [ + curvingFence, + curvingWall, + cursorPoint, + fittedViewport, + isPolygonDraftBuildActive, + levelId, + movingFenceEndpoint, + movingNode, + siteVertexDragState, + slabVertexDragState, + zoneVertexDragState, + ]) const viewBox = useMemo(() => { const currentViewport = viewport ?? fittedViewport @@ -7661,7 +7708,7 @@ export function FloorplanPanel() { } const refreshFloorplanItemPreview = () => { - setMovingFloorplanNodeRevision((current) => current + 1) + scheduleMovingFloorplanNodeRefresh() } emitter.on('grid:move', refreshFloorplanItemPreview) @@ -7687,21 +7734,15 @@ export function FloorplanPanel() { emitter.off('item:move', refreshFloorplanItemPreview as any) emitter.off('item:leave', refreshFloorplanItemPreview as any) } - }, [isItemPlacementPreviewActive]) + }, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh]) useEffect(() => { if (!hasPendingItemMeshFootprints) { return } - const frameId = window.requestAnimationFrame(() => { - setMovingFloorplanNodeRevision((current) => current + 1) - }) - - return () => { - window.cancelAnimationFrame(frameId) - } - }, [hasPendingItemMeshFootprints]) + scheduleMovingFloorplanNodeRefresh() + }, [hasPendingItemMeshFootprints, scheduleMovingFloorplanNodeRefresh]) useEffect(() => { if (!(movingNode?.type === 'door' || movingNode?.type === 'window')) { @@ -7710,7 +7751,7 @@ export function FloorplanPanel() { const movingOpeningId = movingNode.id const refreshOpeningPreview = () => { - setMovingFloorplanNodeRevision((current) => current + 1) + scheduleMovingFloorplanNodeRefresh() } refreshOpeningPreview() @@ -7725,7 +7766,7 @@ export function FloorplanPanel() { }) return unsubscribe - }, [movingNode]) + }, [movingNode, scheduleMovingFloorplanNodeRefresh]) useEffect(() => { if (movingNode?.type !== 'fence') { @@ -7753,7 +7794,7 @@ export function FloorplanPanel() { } const refreshFencePreview = () => { - setMovingFloorplanNodeRevision((current) => current + 1) + scheduleMovingFloorplanNodeRefresh() } refreshFencePreview() @@ -7768,7 +7809,7 @@ export function FloorplanPanel() { }) return unsubscribe - }, [fences, movingNode]) + }, [fences, movingNode, scheduleMovingFloorplanNodeRefresh]) useEffect(() => { if (!(movingNode?.type === 'roof' || movingNode?.type === 'roof-segment')) { @@ -7777,7 +7818,7 @@ export function FloorplanPanel() { const movingRoofNodeId = movingNode.id const refreshRoofPreview = () => { - setMovingFloorplanNodeRevision((current) => current + 1) + scheduleMovingFloorplanNodeRefresh() } refreshRoofPreview() @@ -7792,7 +7833,7 @@ export function FloorplanPanel() { }) return unsubscribe - }, [movingNode]) + }, [movingNode, scheduleMovingFloorplanNodeRefresh]) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index a271dd38..5bcfbd2d 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -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(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) - : {} - 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(() => { diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 6ee40f54..4cd9f4b3 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -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 = ({ + {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 new file mode 100644 index 00000000..035857f0 --- /dev/null +++ b/packages/viewer/src/systems/item-mesh-metadata/compute-item-mesh-metadata.ts @@ -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] +} 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 new file mode 100644 index 00000000..da121c69 --- /dev/null +++ b/packages/viewer/src/systems/item-mesh-metadata/item-mesh-metadata-system.tsx @@ -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, +): 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) { + 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 ? { 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 +} diff --git a/packages/viewer/src/systems/item-mesh-metadata/sync-request.ts b/packages/viewer/src/systems/item-mesh-metadata/sync-request.ts new file mode 100644 index 00000000..7ec7c33c --- /dev/null +++ b/packages/viewer/src/systems/item-mesh-metadata/sync-request.ts @@ -0,0 +1,29 @@ +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 +}