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 b9ba288b..14873b71 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -1,5 +1,8 @@ import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import { getScaledDimensions } from '../../schema' +import { sceneRegistry } from '../scene-registry/scene-registry' +import useScene from '../../store/use-scene' +import { Box3, Matrix4, Vector3, type Object3D } from 'three' import { SpatialGrid } from './spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid' @@ -51,6 +54,155 @@ function getItemFootprint( ] } +type ItemLocalBounds = { + min: [number, number, number] + max: [number, number, number] +} + +type ItemParentAabb = { + minX: number + maxX: number + minY: number + maxY: number + minZ: number + maxZ: number +} + +function getFallbackItemLocalBounds(item: ItemNode): ItemLocalBounds { + const [width, height, depth] = getScaledDimensions(item) + const minZ = item.asset.attachTo === 'wall-side' ? -depth : -depth / 2 + const maxZ = item.asset.attachTo === 'wall-side' ? 0 : depth / 2 + return { + min: [-width / 2, 0, minZ], + max: [width / 2, height, maxZ], + } +} + +function getItemLocalBoundsFromObject(object: Object3D | null): ItemLocalBounds | null { + if (!object) return 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 registeredNodeObjects = new Set(sceneRegistry.nodes.values()) + + const expandBounds = (child: Object3D) => { + if (child !== object && registeredNodeObjects.has(child)) return + + 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 getItemLocalBounds(item: ItemNode): ItemLocalBounds { + return getItemLocalBoundsFromObject(sceneRegistry.nodes.get(item.id) ?? null) ?? getFallbackItemLocalBounds(item) +} + +function getItemParentAabb(item: ItemNode): ItemParentAabb { + const object = sceneRegistry.nodes.get(item.id) + const bounds = getItemLocalBounds(item) + + if (!object) { + return { + minX: bounds.min[0] + item.position[0], + maxX: bounds.max[0] + item.position[0], + minY: bounds.min[1] + item.position[1], + maxY: bounds.max[1] + item.position[1], + minZ: bounds.min[2] + item.position[2], + maxZ: bounds.max[2] + item.position[2], + } + } + + object.updateMatrix() + 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]), + ] + + let minX = Number.POSITIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + + for (const corner of corners) { + corner.applyMatrix4(object.matrix) + minX = Math.min(minX, corner.x) + minY = Math.min(minY, corner.y) + minZ = Math.min(minZ, corner.z) + maxX = Math.max(maxX, corner.x) + maxY = Math.max(maxY, corner.y) + maxZ = Math.max(maxZ, corner.z) + } + + return { minX, maxX, minY, maxY, minZ, maxZ } +} + +function intervalsOverlap(minA: number, maxA: number, minB: number, maxB: number, epsilon = 1e-4) { + return minA < maxB - epsilon && maxA > minB + epsilon +} + +function resolveNodeLevelId(node: AnyNode, nodes: Record): string { + if (node.type === 'level') return node.id + + let current: AnyNode | undefined = node + while (current) { + if (current.type === 'level') return current.id + current = current.parentId ? nodes[current.parentId] : undefined + } + + return 'default' +} + /** * Test if two line segments (a1->a2) and (b1->b2) intersect. */ @@ -481,8 +633,39 @@ export class SpatialGridManager { rotation: [number, number, number], ignoreIds?: string[], ) { - const grid = this.getFloorGrid(levelId) - return grid.canPlace(position, dimensions, rotation, ignoreIds) + const nodes = useScene.getState().nodes + const ignoreSet = new Set(ignoreIds ?? []) + const [width, , depth] = dimensions + const yRot = rotation[1] + const cos = Math.abs(Math.cos(yRot)) + const sin = Math.abs(Math.sin(yRot)) + const rotatedW = width * cos + depth * sin + const rotatedD = width * sin + depth * cos + const draftBounds = { + minX: position[0] - rotatedW / 2, + maxX: position[0] + rotatedW / 2, + minZ: position[2] - rotatedD / 2, + maxZ: position[2] + rotatedD / 2, + } + + const conflicts: string[] = [] + for (const node of Object.values(nodes)) { + if (node.type !== 'item') continue + const item = node as ItemNode + if (item.asset.attachTo) continue + if (ignoreSet.has(item.id)) continue + if (resolveNodeLevelId(item, nodes) !== levelId) continue + + const bounds = getItemParentAabb(item) + if ( + intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && + intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ) + ) { + conflicts.push(item.id) + } + } + + return { valid: conflicts.length === 0, conflictIds: conflicts } } /** @@ -514,7 +697,7 @@ export class SpatialGridManager { // Convert local X position to parametric t (0-1) const tCenter = localX / wallLength const [itemWidth, itemHeight] = dimensions - return this.getWallGrid(levelId).canPlaceOnWall( + const baseResult = this.getWallGrid(levelId).canPlaceOnWall( wallId, wallLength, wallHeight, @@ -526,6 +709,44 @@ export class SpatialGridManager { side, ignoreIds, ) + + if (!baseResult.valid) return baseResult + + const nodes = useScene.getState().nodes + const ignoreSet = new Set(ignoreIds ?? []) + const draftBounds = { + minX: localX - itemWidth / 2, + maxX: localX + itemWidth / 2, + minY: baseResult.adjustedY, + maxY: baseResult.adjustedY + itemHeight, + } + + const conflicts: string[] = [] + for (const node of Object.values(nodes)) { + if (node.type !== 'item') continue + const item = node as ItemNode + if (!(item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side')) continue + if (ignoreSet.has(item.id)) continue + if (item.parentId !== wallId) continue + + if (attachType === 'wall-side' && item.asset.attachTo === 'wall-side' && side && item.side) { + if (side !== item.side) continue + } + + const bounds = getItemParentAabb(item) + if ( + intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && + intervalsOverlap(draftBounds.minY, draftBounds.maxY, bounds.minY, bounds.maxY) + ) { + conflicts.push(item.id) + } + } + + return { + ...baseResult, + valid: conflicts.length === 0, + conflictIds: conflicts, + } } getWallForItem(levelId: string, itemId: string): string | undefined { @@ -692,8 +913,39 @@ export class SpatialGridManager { } } - // Check for overlaps with other ceiling items - return this.getCeilingGrid(ceilingId).canPlace(position, dimensions, rotation, ignoreIds) + const nodes = useScene.getState().nodes + const ignoreSet = new Set(ignoreIds ?? []) + const [width, , depth] = dimensions + const yRot = rotation[1] + const cos = Math.abs(Math.cos(yRot)) + const sin = Math.abs(Math.sin(yRot)) + const rotatedW = width * cos + depth * sin + const rotatedD = width * sin + depth * cos + const draftBounds = { + minX: position[0] - rotatedW / 2, + maxX: position[0] + rotatedW / 2, + minZ: position[2] - rotatedD / 2, + maxZ: position[2] + rotatedD / 2, + } + + const conflicts: string[] = [] + for (const node of Object.values(nodes)) { + if (node.type !== 'item') continue + const item = node as ItemNode + if (item.asset.attachTo !== 'ceiling') continue + if (ignoreSet.has(item.id)) continue + if (item.parentId !== ceilingId) continue + + const bounds = getItemParentAabb(item) + if ( + intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && + intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ) + ) { + conflicts.push(item.id) + } + } + + return { valid: conflicts.length === 0, conflictIds: conflicts } } clearLevel(levelId: string) { diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index ccca22ca..0232f08c 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -449,6 +449,7 @@ type FloorplanItemEntry = { item: ItemNode points: string polygon: Point2D[] + usesRealMesh: boolean } type FloorplanStairSegmentEntry = { @@ -6165,10 +6166,12 @@ export function FloorplanPanel() { item: entry.item, points: formatPolygonPoints(entry.polygon), polygon: entry.polygon, + usesRealMesh: entry.usesRealMesh, }, ] }) }, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision]) + const hasPendingItemMeshFootprints = floorplanItemEntries.some((entry) => !entry.usesRealMesh) const floorplanStairEntries = useMemo( () => floorplanStairs.flatMap((stair) => { @@ -8044,6 +8047,20 @@ export function FloorplanPanel() { } }, [isItemPlacementPreviewActive]) + useEffect(() => { + if (!hasPendingItemMeshFootprints) { + return + } + + const frameId = window.requestAnimationFrame(() => { + setMovingFloorplanNodeRevision((current) => current + 1) + }) + + return () => { + window.cancelAnimationFrame(frameId) + } + }, [hasPendingItemMeshFootprints]) + useEffect(() => { if (!(movingNode?.type === 'door' || movingNode?.type === 'window')) { return diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index e1fb7442..d0ef2203 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -20,11 +20,14 @@ import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' import { BoxGeometry, + Box3, EdgesGeometry, Euler, type Group, type LineSegments, + Matrix4, type Mesh, + type Object3D, PlaneGeometry, Quaternion, Vector3, @@ -46,6 +49,108 @@ import type { DraftNodeHandle } from './use-draft-node' const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1] +type PreviewBounds = { + min: [number, number, number] + max: [number, number, number] + dimensions: [number, number, number] + center: [number, number, number] +} + +function getPreviewBoundsFromObject(object: Object3D | null): PreviewBounds | null { + if (!object) return 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() + const hasBounds = { current: false } + const registeredNodeObjects = new Set(sceneRegistry.nodes.values()) + + const expandBounds = (child: Object3D) => { + if (child !== object && registeredNodeObjects.has(child)) { + return + } + + 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 (Number.isFinite(scratchBounds.min.x) && Number.isFinite(scratchBounds.max.x)) { + if (!hasBounds.current) { + localBounds.copy(scratchBounds) + hasBounds.current = true + } else { + localBounds.union(scratchBounds) + } + } + } + } + + for (const grandchild of child.children) { + expandBounds(grandchild) + } + } + + for (const child of object.children) { + expandBounds(child) + } + + if (!hasBounds.current) return null + + const size = new Vector3() + const center = new Vector3() + localBounds.getSize(size) + localBounds.getCenter(center) + + if (size.x <= 0 || size.y <= 0 || size.z <= 0) { + return null + } + + return { + min: [localBounds.min.x, localBounds.min.y, localBounds.min.z], + max: [localBounds.max.x, localBounds.max.y, localBounds.max.z], + dimensions: [size.x, size.y, size.z], + center: [center.x, center.y, center.z], + } +} + +function getFallbackPreviewBounds( + item: import('@pascal-app/core').ItemNode | null, + asset: AssetInput, + attachTo: AssetInput['attachTo'], +): PreviewBounds { + const dims = item ? getScaledDimensions(item) : (asset.dimensions ?? DEFAULT_DIMENSIONS) + return { + min: [ + -dims[0] / 2, + 0, + attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2, + ], + max: [ + dims[0] / 2, + dims[1], + attachTo === 'wall-side' ? 0 : dims[2] / 2, + ], + dimensions: dims, + center: [0, dims[1] / 2, attachTo === 'wall-side' ? -dims[2] / 2 : 0], + } +} + // Shared materials for placement cursor - we just change colors, not swap materials // Note: EdgesGeometry doesn't work with dashed lines, so using solid lines const edgeMaterial = new LineBasicNodeMaterial({ @@ -89,6 +194,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }, ) const shiftFreeRef = useRef(false) + const previewBoundsSignatureRef = useRef(null) + const meshPreviewAppliedRef = useRef(false) // Store config callbacks in refs to avoid re-running effect when they change const configRef = useRef(config) @@ -97,9 +204,33 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery() const { asset, draftNode } = config + const updatePreviewGeometry = (bounds: PreviewBounds) => { + const [width, height, depth] = bounds.dimensions + const [centerX, centerY, centerZ] = bounds.center + const signature = `${width.toFixed(4)}:${height.toFixed(4)}:${depth.toFixed(4)}:${centerX.toFixed(4)}:${centerY.toFixed(4)}:${centerZ.toFixed(4)}` + + if (previewBoundsSignatureRef.current === signature) return + previewBoundsSignatureRef.current = signature + + const nextBoxGeometry = new BoxGeometry(width, height, depth) + nextBoxGeometry.translate(centerX, centerY, centerZ) + const nextEdgesGeometry = new EdgesGeometry(nextBoxGeometry) + + const nextBasePlaneGeometry = new PlaneGeometry(width, depth) + nextBasePlaneGeometry.rotateX(-Math.PI / 2) + nextBasePlaneGeometry.translate(centerX, 0.01, centerZ) + + edgesRef.current.geometry.dispose() + edgesRef.current.geometry = nextEdgesGeometry + basePlaneRef.current.geometry.dispose() + basePlaneRef.current.geometry = nextBasePlaneGeometry + nextBoxGeometry.dispose() + } + useEffect(() => { if (!asset) return useScene.temporal.getState().pause() + meshPreviewAppliedRef.current = false const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } @@ -825,12 +956,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Bounding box geometry ---- const draft = draftNode.current - const dims = draft ? getScaledDimensions(draft) : (asset.dimensions ?? DEFAULT_DIMENSIONS) - const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2]) - const wallSideZOffset = asset.attachTo === 'wall-side' ? -dims[2] / 2 : 0 - boxGeometry.translate(0, dims[1] / 2, wallSideZOffset) - const edgesGeometry = new EdgesGeometry(boxGeometry) - edgesRef.current.geometry = edgesGeometry + const fallbackBounds = getFallbackPreviewBounds(draft, asset, asset.attachTo) + updatePreviewGeometry( + draft + ? (getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ?? + fallbackBounds) + : fallbackBounds, + ) // ---- Undo protection ---- // Undo replaces the entire `nodes` object with a previous snapshot, which doesn't @@ -874,6 +1006,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return () => { tearingDown = true + meshPreviewAppliedRef.current = false unsubDraftWatch() // Clear live transform for any remaining draft if (draftNode.current) { @@ -920,6 +1053,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const mesh = sceneRegistry.nodes.get(draftNode.current.id) if (!mesh) return + if (!meshPreviewAppliedRef.current) { + const previewBounds = getPreviewBoundsFromObject(mesh) + if (previewBounds) { + updatePreviewGeometry(previewBounds) + meshPreviewAppliedRef.current = true + } + } + // Hide wall/ceiling-attached items when between surfaces (only cursor visible) if (asset.attachTo && placementState.current.surface === 'floor') { mesh.visible = false diff --git a/packages/editor/src/lib/floorplan/items.ts b/packages/editor/src/lib/floorplan/items.ts index d909aa05..90e98ba9 100644 --- a/packages/editor/src/lib/floorplan/items.ts +++ b/packages/editor/src/lib/floorplan/items.ts @@ -1,12 +1,14 @@ import { - getScaledDimensions, type AnyNode, type AnyNodeId, type ItemNode, type LevelNode, + sceneRegistry, useLiveTransforms, } from '@pascal-app/core' -import { getRotatedRectanglePolygon, rotatePlanVector } from './geometry' +import type { Object3D } from 'three' +import { Box3, Matrix4, Vector3 } from 'three' +import { rotatePlanVector } from './geometry' import type { FloorplanItemEntry, FloorplanNodeTransform, LevelDescendantMap } from './types' export function collectLevelDescendants( @@ -136,9 +138,253 @@ export function buildFloorplanItemEntry( return null } - const [width, , depth] = getScaledDimensions(item) + const object = sceneRegistry.nodes.get(item.id) + const realMeshPolygon = object + ? getRealMeshFloorplanPolygon(transform, object) + : getCachedMeshFloorplanPolygon(item, transform) + if (!realMeshPolygon) { + return null + } + return { item, - polygon: getRotatedRectanglePolygon(transform.position, width, depth, transform.rotation), + polygon: realMeshPolygon, + usesRealMesh: realMeshPolygon !== null, } } + +type Point = { + x: number + y: number +} + +function getCachedLocalMeshPolygon(item: ItemNode): Point[] | null { + const metadata = + typeof item.metadata === 'object' && item.metadata !== null && !Array.isArray(item.metadata) + ? (item.metadata as Record) + : null + const rawPolygon = metadata?.floorplanLocalPolygon + if (!Array.isArray(rawPolygon)) { + return null + } + + const polygon = rawPolygon.flatMap((point) => { + if (!Array.isArray(point) || point.length < 2) { + return [] + } + const x = point[0] + const y = point[1] + return typeof x === 'number' && typeof y === 'number' ? [{ x, y }] : [] + }) + + return polygon.length >= 3 ? polygon : null +} + +function getCachedMeshFloorplanPolygon(item: ItemNode, transform: FloorplanNodeTransform) { + const localPolygon = getCachedLocalMeshPolygon(item) + if (!localPolygon) { + return null + } + + return localPolygon.map((corner) => { + const [offsetX, offsetY] = rotatePlanVector(corner.x, corner.y, transform.rotation) + return { + x: transform.position.x + offsetX, + y: transform.position.y + offsetY, + } + }) +} + +function getRealMeshFloorplanPolygon(transform: FloorplanNodeTransform, object: Object3D) { + const localPolygon = getLocalMeshFloorplanPolygon(object) + if (localPolygon.length === 0) { + return null + } + + return localPolygon.map((corner) => { + const [offsetX, offsetY] = rotatePlanVector(corner.x, corner.y, transform.rotation) + return { + x: transform.position.x + offsetX, + y: transform.position.y + offsetY, + } + }) +} + +function getLocalMeshFloorplanPolygon(object: Object3D): Point[] { + object.updateWorldMatrix(true, true) + + const inverseRootMatrix = new Matrix4().copy(object.matrixWorld).invert() + const localMatrix = new Matrix4() + const scratchBounds = new Box3() + const scratchPosition = new Vector3() + const registeredNodeObjects = new Set(sceneRegistry.nodes.values()) + const footprintPoints: Point[] = [] + + const collectPoints = (child: Object3D) => { + if (child !== object && registeredNodeObjects.has(child)) { + return + } + + const mesh = child as { + isMesh?: boolean + name?: string + geometry?: { + boundingBox: Box3 | null + computeBoundingBox?: () => void + attributes?: { + position?: { + count: number + getX: (index: number) => number + getY: (index: number) => number + getZ: (index: number) => number + } + } + } + matrixWorld: Matrix4 + } + + if (mesh.isMesh && mesh.name !== 'cutout' && mesh.geometry) { + if (!mesh.geometry.boundingBox && mesh.geometry.computeBoundingBox) { + mesh.geometry.computeBoundingBox() + } + + localMatrix.copy(inverseRootMatrix).multiply(mesh.matrixWorld) + + const vertexPositions = mesh.geometry.attributes?.position + if (vertexPositions && vertexPositions.count > 0) { + for (let index = 0; index < vertexPositions.count; index += 1) { + scratchPosition + .set( + vertexPositions.getX(index), + vertexPositions.getY(index), + vertexPositions.getZ(index), + ) + .applyMatrix4(localMatrix) + + if (Number.isFinite(scratchPosition.x) && Number.isFinite(scratchPosition.z)) { + footprintPoints.push({ x: scratchPosition.x, y: scratchPosition.z }) + } + } + } else if (mesh.geometry.boundingBox) { + scratchBounds.copy(mesh.geometry.boundingBox) + scratchBounds.applyMatrix4(localMatrix) + if (Number.isFinite(scratchBounds.min.x) && Number.isFinite(scratchBounds.max.x)) { + footprintPoints.push( + { x: scratchBounds.min.x, y: scratchBounds.min.z }, + { x: scratchBounds.max.x, y: scratchBounds.min.z }, + { x: scratchBounds.max.x, y: scratchBounds.max.z }, + { x: scratchBounds.min.x, y: scratchBounds.max.z }, + ) + } + } + } + + for (const grandchild of child.children) { + collectPoints(grandchild) + } + } + + for (const child of object.children) { + collectPoints(child) + } + + return getMinimumAreaBoundingRect(footprintPoints) ?? [] +} + +function getMinimumAreaBoundingRect(points: Point[]) { + if (points.length === 0) { + return null + } + + const hull = getConvexHull(points) + if (hull.length === 0) { + return null + } + + if (hull.length === 1) { + const point = hull[0]! + return [point, point, point, point] + } + + if (hull.length === 2) { + const [start, end] = hull + return [start!, end!, end!, start!] + } + + let bestArea = Number.POSITIVE_INFINITY + let bestRect: Point[] | null = null + + for (let index = 0; index < hull.length; index += 1) { + const start = hull[index]! + const end = hull[(index + 1) % hull.length]! + const angle = Math.atan2(end.y - start.y, end.x - start.x) + const cos = Math.cos(-angle) + const sin = Math.sin(-angle) + + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const point of hull) { + const rx = point.x * cos - point.y * sin + const ry = point.x * sin + point.y * cos + minX = Math.min(minX, rx) + maxX = Math.max(maxX, rx) + minY = Math.min(minY, ry) + maxY = Math.max(maxY, ry) + } + + const area = (maxX - minX) * (maxY - minY) + if (area >= bestArea) { + continue + } + + bestRect = [ + { x: minX, y: minY }, + { x: maxX, y: minY }, + { x: maxX, y: maxY }, + { x: minX, y: maxY }, + ].map((point) => ({ + x: point.x * Math.cos(angle) - point.y * Math.sin(angle), + y: point.x * Math.sin(angle) + point.y * Math.cos(angle), + })) + bestArea = area + } + + return bestRect +} + +function getConvexHull(points: Point[]) { + const uniquePoints = Array.from( + new Map(points.map((point) => [`${point.x.toFixed(6)}:${point.y.toFixed(6)}`, point])).values(), + ).sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) + + if (uniquePoints.length <= 1) { + return uniquePoints + } + + const cross = (origin: Point, a: Point, b: Point) => + (a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x) + + const lower: Point[] = [] + for (const point of uniquePoints) { + while (lower.length >= 2 && cross(lower[lower.length - 2]!, lower[lower.length - 1]!, point) <= 0) { + lower.pop() + } + lower.push(point) + } + + const upper: Point[] = [] + for (let index = uniquePoints.length - 1; index >= 0; index -= 1) { + const point = uniquePoints[index]! + while (upper.length >= 2 && cross(upper[upper.length - 2]!, upper[upper.length - 1]!, point) <= 0) { + upper.pop() + } + upper.push(point) + } + + lower.pop() + upper.pop() + return [...lower, ...upper] +} diff --git a/packages/editor/src/lib/floorplan/types.ts b/packages/editor/src/lib/floorplan/types.ts index b9dce428..0a3012ab 100644 --- a/packages/editor/src/lib/floorplan/types.ts +++ b/packages/editor/src/lib/floorplan/types.ts @@ -13,6 +13,7 @@ export type FloorplanLineSegment = { export type FloorplanItemEntry = { item: ItemNode polygon: Point2D[] + usesRealMesh: boolean } export type FloorplanStairSegmentEntry = { diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index 5366d3d5..ce39dbf4 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -15,8 +15,8 @@ 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 } from 'three' -import { MathUtils } from 'three' +import type { AnimationAction, Group, Material, Mesh, Object3D } from 'three' +import { Box3, MathUtils, Matrix4, Vector3 } from 'three' import { positionLocal, smoothstep, time } from 'three/tsl' import { MeshStandardNodeMaterial } from 'three/webgpu' import { useNodeEvents } from '../../../hooks/use-node-events' @@ -89,6 +89,167 @@ 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 +} + +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 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!) @@ -107,6 +268,39 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId) }, [node.parentId]) + useEffect(() => { + const cloneRoot = ref.current + if (!cloneRoot) return + + const polygon = getLocalMeshFloorplanPolygon(cloneRoot) + if (polygon.length < 3) return + + const nextPolygon = polygon.map(({ x, y }) => [x, y] as [number, number]) + const metadata = + typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata) + ? (node.metadata as Record) + : {} + const currentPolygon = metadata.floorplanLocalPolygon + const unchanged = + Array.isArray(currentPolygon) && + currentPolygon.length === nextPolygon.length && + currentPolygon.every( + (point, index) => + Array.isArray(point) && + point[0] === nextPolygon[index]?.[0] && + point[1] === nextPolygon[index]?.[1], + ) + + if (unchanged) return + + useScene.getState().updateNode(node.id, { + metadata: { + ...metadata, + floorplanLocalPolygon: nextPolygon, + }, + }) + }, [node.id, node.metadata, scene]) + useEffect(() => { const interactive = interactiveRef.current if (!interactive) return