Use mesh bounds for item placement and floorplan
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
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 { 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 { SpatialGrid } from './spatial-grid'
|
||||||
import { WallSpatialGrid } from './wall-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, AnyNode>): 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.
|
* Test if two line segments (a1->a2) and (b1->b2) intersect.
|
||||||
*/
|
*/
|
||||||
@@ -481,8 +633,39 @@ export class SpatialGridManager {
|
|||||||
rotation: [number, number, number],
|
rotation: [number, number, number],
|
||||||
ignoreIds?: string[],
|
ignoreIds?: string[],
|
||||||
) {
|
) {
|
||||||
const grid = this.getFloorGrid(levelId)
|
const nodes = useScene.getState().nodes
|
||||||
return grid.canPlace(position, dimensions, rotation, ignoreIds)
|
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)
|
// Convert local X position to parametric t (0-1)
|
||||||
const tCenter = localX / wallLength
|
const tCenter = localX / wallLength
|
||||||
const [itemWidth, itemHeight] = dimensions
|
const [itemWidth, itemHeight] = dimensions
|
||||||
return this.getWallGrid(levelId).canPlaceOnWall(
|
const baseResult = this.getWallGrid(levelId).canPlaceOnWall(
|
||||||
wallId,
|
wallId,
|
||||||
wallLength,
|
wallLength,
|
||||||
wallHeight,
|
wallHeight,
|
||||||
@@ -526,6 +709,44 @@ export class SpatialGridManager {
|
|||||||
side,
|
side,
|
||||||
ignoreIds,
|
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 {
|
getWallForItem(levelId: string, itemId: string): string | undefined {
|
||||||
@@ -692,8 +913,39 @@ export class SpatialGridManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for overlaps with other ceiling items
|
const nodes = useScene.getState().nodes
|
||||||
return this.getCeilingGrid(ceilingId).canPlace(position, dimensions, rotation, ignoreIds)
|
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) {
|
clearLevel(levelId: string) {
|
||||||
|
|||||||
@@ -449,6 +449,7 @@ type FloorplanItemEntry = {
|
|||||||
item: ItemNode
|
item: ItemNode
|
||||||
points: string
|
points: string
|
||||||
polygon: Point2D[]
|
polygon: Point2D[]
|
||||||
|
usesRealMesh: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type FloorplanStairSegmentEntry = {
|
type FloorplanStairSegmentEntry = {
|
||||||
@@ -6165,10 +6166,12 @@ export function FloorplanPanel() {
|
|||||||
item: entry.item,
|
item: entry.item,
|
||||||
points: formatPolygonPoints(entry.polygon),
|
points: formatPolygonPoints(entry.polygon),
|
||||||
polygon: entry.polygon,
|
polygon: entry.polygon,
|
||||||
|
usesRealMesh: entry.usesRealMesh,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
}, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision])
|
}, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision])
|
||||||
|
const hasPendingItemMeshFootprints = floorplanItemEntries.some((entry) => !entry.usesRealMesh)
|
||||||
const floorplanStairEntries = useMemo(
|
const floorplanStairEntries = useMemo(
|
||||||
() =>
|
() =>
|
||||||
floorplanStairs.flatMap((stair) => {
|
floorplanStairs.flatMap((stair) => {
|
||||||
@@ -8044,6 +8047,20 @@ export function FloorplanPanel() {
|
|||||||
}
|
}
|
||||||
}, [isItemPlacementPreviewActive])
|
}, [isItemPlacementPreviewActive])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasPendingItemMeshFootprints) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameId = window.requestAnimationFrame(() => {
|
||||||
|
setMovingFloorplanNodeRevision((current) => current + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frameId)
|
||||||
|
}
|
||||||
|
}, [hasPendingItemMeshFootprints])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!(movingNode?.type === 'door' || movingNode?.type === 'window')) {
|
if (!(movingNode?.type === 'door' || movingNode?.type === 'window')) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ import { useFrame } from '@react-three/fiber'
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
BoxGeometry,
|
BoxGeometry,
|
||||||
|
Box3,
|
||||||
EdgesGeometry,
|
EdgesGeometry,
|
||||||
Euler,
|
Euler,
|
||||||
type Group,
|
type Group,
|
||||||
type LineSegments,
|
type LineSegments,
|
||||||
|
Matrix4,
|
||||||
type Mesh,
|
type Mesh,
|
||||||
|
type Object3D,
|
||||||
PlaneGeometry,
|
PlaneGeometry,
|
||||||
Quaternion,
|
Quaternion,
|
||||||
Vector3,
|
Vector3,
|
||||||
@@ -46,6 +49,108 @@ import type { DraftNodeHandle } from './use-draft-node'
|
|||||||
|
|
||||||
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
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
|
// Shared materials for placement cursor - we just change colors, not swap materials
|
||||||
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
|
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
|
||||||
const edgeMaterial = new LineBasicNodeMaterial({
|
const edgeMaterial = new LineBasicNodeMaterial({
|
||||||
@@ -89,6 +194,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
|
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
|
||||||
)
|
)
|
||||||
const shiftFreeRef = useRef(false)
|
const shiftFreeRef = useRef(false)
|
||||||
|
const previewBoundsSignatureRef = useRef<string | null>(null)
|
||||||
|
const meshPreviewAppliedRef = useRef(false)
|
||||||
|
|
||||||
// Store config callbacks in refs to avoid re-running effect when they change
|
// Store config callbacks in refs to avoid re-running effect when they change
|
||||||
const configRef = useRef(config)
|
const configRef = useRef(config)
|
||||||
@@ -97,9 +204,33 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
|
const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
|
||||||
const { asset, draftNode } = config
|
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(() => {
|
useEffect(() => {
|
||||||
if (!asset) return
|
if (!asset) return
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
meshPreviewAppliedRef.current = false
|
||||||
|
|
||||||
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
|
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
|
||||||
|
|
||||||
@@ -825,12 +956,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
// ---- Bounding box geometry ----
|
// ---- Bounding box geometry ----
|
||||||
|
|
||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
const dims = draft ? getScaledDimensions(draft) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
|
const fallbackBounds = getFallbackPreviewBounds(draft, asset, asset.attachTo)
|
||||||
const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
updatePreviewGeometry(
|
||||||
const wallSideZOffset = asset.attachTo === 'wall-side' ? -dims[2] / 2 : 0
|
draft
|
||||||
boxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
|
? (getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ??
|
||||||
const edgesGeometry = new EdgesGeometry(boxGeometry)
|
fallbackBounds)
|
||||||
edgesRef.current.geometry = edgesGeometry
|
: fallbackBounds,
|
||||||
|
)
|
||||||
|
|
||||||
// ---- Undo protection ----
|
// ---- Undo protection ----
|
||||||
// Undo replaces the entire `nodes` object with a previous snapshot, which doesn't
|
// Undo replaces the entire `nodes` object with a previous snapshot, which doesn't
|
||||||
@@ -874,6 +1006,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
tearingDown = true
|
tearingDown = true
|
||||||
|
meshPreviewAppliedRef.current = false
|
||||||
unsubDraftWatch()
|
unsubDraftWatch()
|
||||||
// Clear live transform for any remaining draft
|
// Clear live transform for any remaining draft
|
||||||
if (draftNode.current) {
|
if (draftNode.current) {
|
||||||
@@ -920,6 +1053,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
||||||
if (!mesh) return
|
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)
|
// Hide wall/ceiling-attached items when between surfaces (only cursor visible)
|
||||||
if (asset.attachTo && placementState.current.surface === 'floor') {
|
if (asset.attachTo && placementState.current.surface === 'floor') {
|
||||||
mesh.visible = false
|
mesh.visible = false
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
getScaledDimensions,
|
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
type LevelNode,
|
type LevelNode,
|
||||||
|
sceneRegistry,
|
||||||
useLiveTransforms,
|
useLiveTransforms,
|
||||||
} from '@pascal-app/core'
|
} 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'
|
import type { FloorplanItemEntry, FloorplanNodeTransform, LevelDescendantMap } from './types'
|
||||||
|
|
||||||
export function collectLevelDescendants(
|
export function collectLevelDescendants(
|
||||||
@@ -136,9 +138,253 @@ export function buildFloorplanItemEntry(
|
|||||||
return null
|
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 {
|
return {
|
||||||
item,
|
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<string, unknown>)
|
||||||
|
: 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]
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type FloorplanLineSegment = {
|
|||||||
export type FloorplanItemEntry = {
|
export type FloorplanItemEntry = {
|
||||||
item: ItemNode
|
item: ItemNode
|
||||||
polygon: Point2D[]
|
polygon: Point2D[]
|
||||||
|
usesRealMesh: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FloorplanStairSegmentEntry = {
|
export type FloorplanStairSegmentEntry = {
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ 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 } from 'three'
|
import type { AnimationAction, Group, Material, Mesh, Object3D } from 'three'
|
||||||
import { MathUtils } from 'three'
|
import { Box3, MathUtils, Matrix4, Vector3 } 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'
|
||||||
@@ -89,6 +89,167 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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!)
|
||||||
@@ -107,6 +268,39 @@ 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])
|
||||||
|
|
||||||
|
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<string, unknown>)
|
||||||
|
: {}
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const interactive = interactiveRef.current
|
const interactive = interactiveRef.current
|
||||||
if (!interactive) return
|
if (!interactive) return
|
||||||
|
|||||||
Reference in New Issue
Block a user