Use mesh bounds for item placement and floorplan

This commit is contained in:
sudhir
2026-04-27 09:57:05 +05:30
parent 45f0867a74
commit 59779c5bf2
6 changed files with 868 additions and 17 deletions
@@ -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<Group>(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<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(() => {
const interactive = interactiveRef.current
if (!interactive) return