feat(editor): precise item dimensions — static bounding boxes, placement-math, remove item-mesh-metadata system
- Replace runtime mesh-based bounding-box computation with static dimension-based polygons for item footprints - Add snapUpToGridStep() and getGridAlignedDimensions() to placement-math for grid-cell-aligned placement wireframes - Add expandBoundsToGrid() to use-placement-coordinator for consistent wireframe snapping - Add currentCursorRotationY to PlacementContext; preserve world orientation across item-surface transitions - Fix item detach from surface: use worldToBuildingLocal() instead of event.localPosition to avoid coordinate-space jump - Subscribe to useLiveTransforms in FloorplanPanel during placement so R/T keyboard rotation refreshes the 2D overlay immediately - Fix FloorplanItemImage rotation (+180° to account for top-down camera capture orientation) - Simplify spatial-grid-manager: single dimension-based getItemLocalBounds(), removes runtime mesh-metadata path - Remove item-mesh-metadata system (compute-item-mesh-metadata, item-mesh-metadata-system, sync-request)
This commit is contained in:
@@ -66,7 +66,7 @@ type ItemParentAabb = {
|
||||
maxZ: number
|
||||
}
|
||||
|
||||
function getFallbackItemLocalBounds(item: ItemNode): ItemLocalBounds {
|
||||
function getItemLocalBounds(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
|
||||
@@ -76,41 +76,6 @@ function getFallbackItemLocalBounds(item: ItemNode): ItemLocalBounds {
|
||||
}
|
||||
}
|
||||
|
||||
function getItemLocalBounds(item: ItemNode): ItemLocalBounds {
|
||||
const metadata =
|
||||
typeof item.metadata === 'object' && item.metadata !== null && !Array.isArray(item.metadata)
|
||||
? (item.metadata as Record<string, unknown>)
|
||||
: null
|
||||
const rawBounds =
|
||||
typeof metadata?.meshLocalBounds === 'object' &&
|
||||
metadata.meshLocalBounds !== null &&
|
||||
!Array.isArray(metadata.meshLocalBounds)
|
||||
? (metadata.meshLocalBounds as Record<string, unknown>)
|
||||
: null
|
||||
const min = rawBounds?.min
|
||||
const max = rawBounds?.max
|
||||
|
||||
if (
|
||||
Array.isArray(min) &&
|
||||
min.length >= 3 &&
|
||||
Array.isArray(max) &&
|
||||
max.length >= 3 &&
|
||||
typeof min[0] === 'number' &&
|
||||
typeof min[1] === 'number' &&
|
||||
typeof min[2] === 'number' &&
|
||||
typeof max[0] === 'number' &&
|
||||
typeof max[1] === 'number' &&
|
||||
typeof max[2] === 'number'
|
||||
) {
|
||||
return {
|
||||
min: [min[0], min[1], min[2]],
|
||||
max: [max[0], max[1], max[2]],
|
||||
}
|
||||
}
|
||||
|
||||
return getFallbackItemLocalBounds(item)
|
||||
}
|
||||
|
||||
function getItemParentAabb(item: ItemNode): ItemParentAabb {
|
||||
const bounds = getItemLocalBounds(item)
|
||||
const corners: Array<[number, number, number]> = [
|
||||
|
||||
@@ -5181,7 +5181,7 @@ function FloorplanItemImage({
|
||||
}) {
|
||||
const resolvedUrl = useResolvedAssetUrl(url)
|
||||
if (!resolvedUrl) return null
|
||||
const rotationDeg = (-rotation * 180) / Math.PI
|
||||
const rotationDeg = (-rotation * 180) / Math.PI + 180
|
||||
return (
|
||||
<g
|
||||
pointerEvents="none"
|
||||
@@ -9708,6 +9708,21 @@ export function FloorplanPanel() {
|
||||
}
|
||||
}, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
|
||||
|
||||
// Subscribe to the live-transforms store so rotation/position changes that
|
||||
// *don't* go through pointer events still refresh the floorplan — e.g. R/T
|
||||
// keyboard rotation during placement updates `useLiveTransforms` but emits
|
||||
// no grid:move, so without this the floorplan was stale until the user
|
||||
// moved the cursor.
|
||||
useEffect(() => {
|
||||
if (!isItemPlacementPreviewActive) return
|
||||
const unsubscribe = useLiveTransforms.subscribe((state, prev) => {
|
||||
if (state.transforms !== prev.transforms) {
|
||||
scheduleMovingFloorplanNodeRefresh()
|
||||
}
|
||||
})
|
||||
return unsubscribe
|
||||
}, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasPendingItemMeshFootprints) {
|
||||
return
|
||||
|
||||
@@ -584,9 +584,9 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
||||
return (
|
||||
<>
|
||||
{!isFirstPersonMode && <SelectionManager />}
|
||||
{!isVersionPreviewMode && !isFirstPersonMode && <BoxSelectTool />}
|
||||
{!isVersionPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
|
||||
{!isVersionPreviewMode && !isFirstPersonMode && <FloatingBuildingActionMenu />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <BoxSelectTool />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingActionMenu />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingBuildingActionMenu />}
|
||||
{!isFirstPersonMode && <WallMeasurementLabel />}
|
||||
<ExportManager />
|
||||
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||
@@ -594,10 +594,10 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
||||
<CeilingSelectionAffordanceSystem />
|
||||
<RoofEditSystem />
|
||||
<StairEditSystem />
|
||||
{!isLoading && !isFirstPersonMode && (
|
||||
{!(isLoading || isFirstPersonMode) && (
|
||||
<Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />
|
||||
)}
|
||||
{!(isLoading || isVersionPreviewMode) && !isFirstPersonMode && <ToolManager />}
|
||||
{!(isLoading || isVersionPreviewMode || isFirstPersonMode) && <ToolManager />}
|
||||
{isFirstPersonMode && <FirstPersonControls />}
|
||||
<CustomCameraControls />
|
||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||
@@ -1161,7 +1161,7 @@ export default function Editor({
|
||||
/>
|
||||
{/* First-person overlay — rendered on top of normal layout */}
|
||||
{isFirstPersonMode && (
|
||||
<div className="fixed inset-0 z-50 pointer-events-none">
|
||||
<div className="pointer-events-none fixed inset-0 z-50">
|
||||
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isObject } from '@pascal-app/core'
|
||||
import { type AssetInput, isObject } from '@pascal-app/core'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
|
||||
function getGridSnapStep(): number {
|
||||
@@ -27,6 +27,35 @@ export function snapToHalf(value: number, step = getGridSnapStep()): number {
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
/**
|
||||
* Round a value up to the next multiple of `step`, with a minimum of `step`.
|
||||
*/
|
||||
export function snapUpToGridStep(value: number, step = getGridSnapStep()): number {
|
||||
return Math.max(step, Math.ceil(value / step) * step)
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand an item's scaled dimensions up to the active grid step on the axes
|
||||
* the placement grid covers. Used for the placement wireframe, snap math, and
|
||||
* collision against the draft so a small item visually reserves a full grid
|
||||
* cell.
|
||||
*
|
||||
* - Floor / ceiling / item-surface: X + Z (footprint) expand; Y stays exact.
|
||||
* - Wall / wall-side: X (along wall) + Y (height) expand; Z (depth) stays exact
|
||||
* so wall-thickness offsets aren't disturbed.
|
||||
*/
|
||||
export function getGridAlignedDimensions(
|
||||
scaledDims: [number, number, number],
|
||||
attachTo: AssetInput['attachTo'] | null | undefined,
|
||||
step = getGridSnapStep(),
|
||||
): [number, number, number] {
|
||||
const [w, h, d] = scaledDims
|
||||
if (attachTo === 'wall' || attachTo === 'wall-side') {
|
||||
return [snapUpToGridStep(w, step), snapUpToGridStep(h, step), d]
|
||||
}
|
||||
return [snapUpToGridStep(w, step), h, snapUpToGridStep(d, step)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cursor rotation in WORLD space from wall normal and orientation.
|
||||
*/
|
||||
|
||||
@@ -10,10 +10,11 @@ import type {
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { getScaledDimensions, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { Vector3 } from 'three'
|
||||
import { Euler, Quaternion, Vector3 } from 'three'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
getGridAlignedDimensions,
|
||||
getSideFromNormal,
|
||||
isValidWallSideFace,
|
||||
snapToGrid,
|
||||
@@ -43,9 +44,10 @@ export const floorStrategy = {
|
||||
move(ctx: PlacementContext, event: GridEvent): PlacementResult | null {
|
||||
if (ctx.state.surface !== 'floor') return null
|
||||
|
||||
const dims = ctx.draftItem
|
||||
const rawDims = ctx.draftItem
|
||||
? getScaledDimensions(ctx.draftItem)
|
||||
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const dims = getGridAlignedDimensions(rawDims, ctx.asset.attachTo)
|
||||
const [dimX, , dimZ] = dims
|
||||
const rotY = ctx.draftItem?.rotation?.[1] ?? 0
|
||||
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
||||
@@ -80,7 +82,7 @@ export const floorStrategy = {
|
||||
const valid = validators.canPlaceOnFloor(
|
||||
ctx.levelId,
|
||||
pos,
|
||||
getScaledDimensions(ctx.draftItem),
|
||||
getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
|
||||
ctx.draftItem.rotation,
|
||||
[ctx.draftItem.id],
|
||||
).valid
|
||||
@@ -133,14 +135,15 @@ export const wallStrategy = {
|
||||
const z = snapToHalf(event.localPosition[2])
|
||||
|
||||
// Get auto-adjusted Y position from validator
|
||||
const rawDims = ctx.draftItem
|
||||
? getScaledDimensions(ctx.draftItem)
|
||||
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const validation = validators.canPlaceOnWall(
|
||||
ctx.levelId,
|
||||
event.node.id,
|
||||
x,
|
||||
y,
|
||||
ctx.draftItem
|
||||
? getScaledDimensions(ctx.draftItem)
|
||||
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS),
|
||||
getGridAlignedDimensions(rawDims, attachTo),
|
||||
attachTo,
|
||||
side,
|
||||
[],
|
||||
@@ -195,7 +198,7 @@ export const wallStrategy = {
|
||||
event.node.id,
|
||||
snappedX,
|
||||
snappedY,
|
||||
getScaledDimensions(ctx.draftItem),
|
||||
getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
|
||||
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
|
||||
side,
|
||||
[ctx.draftItem.id],
|
||||
@@ -239,7 +242,7 @@ export const wallStrategy = {
|
||||
ctx.state.wallId as WallNode['id'],
|
||||
ctx.gridPosition.x,
|
||||
ctx.gridPosition.y,
|
||||
getScaledDimensions(ctx.draftItem),
|
||||
getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
|
||||
ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
|
||||
ctx.draftItem.side,
|
||||
[ctx.draftItem.id],
|
||||
@@ -301,11 +304,12 @@ export const ceilingStrategy = {
|
||||
const ceilingLevelId = resolveLevelId(event.node, nodes)
|
||||
if (ctx.levelId !== ceilingLevelId) return null
|
||||
|
||||
const dims = ctx.draftItem
|
||||
const rawDims = ctx.draftItem
|
||||
? getScaledDimensions(ctx.draftItem)
|
||||
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const dims = getGridAlignedDimensions(rawDims, ctx.asset.attachTo)
|
||||
const [dimX, , dimZ] = dims
|
||||
const itemHeight = dims[1]
|
||||
const itemHeight = rawDims[1]
|
||||
const rotY = ctx.draftItem?.rotation?.[1] ?? 0
|
||||
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
||||
|
||||
@@ -335,9 +339,10 @@ export const ceilingStrategy = {
|
||||
if (ctx.state.surface !== 'ceiling') return null
|
||||
if (!ctx.draftItem) return null
|
||||
|
||||
const dims = getScaledDimensions(ctx.draftItem)
|
||||
const rawDims = getScaledDimensions(ctx.draftItem)
|
||||
const dims = getGridAlignedDimensions(rawDims, ctx.draftItem.asset.attachTo)
|
||||
const [dimX, , dimZ] = dims
|
||||
const itemHeight = dims[1]
|
||||
const itemHeight = rawDims[1]
|
||||
const rotY = ctx.draftItem.rotation?.[1] ?? 0
|
||||
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
||||
|
||||
@@ -375,7 +380,7 @@ export const ceilingStrategy = {
|
||||
const valid = validators.canPlaceOnCeiling(
|
||||
ctx.state.ceilingId as CeilingNode['id'],
|
||||
pos,
|
||||
getScaledDimensions(ctx.draftItem),
|
||||
getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
|
||||
ctx.draftItem.rotation,
|
||||
[ctx.draftItem.id],
|
||||
).valid
|
||||
@@ -453,8 +458,21 @@ export const itemSurfaceStrategy = {
|
||||
|
||||
return {
|
||||
stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
|
||||
nodeUpdate: { position: [x, y, z], parentId: surfaceItem.id },
|
||||
cursorRotationY: 0,
|
||||
nodeUpdate: {
|
||||
position: [x, y, z],
|
||||
parentId: surfaceItem.id,
|
||||
rotation: [
|
||||
(ctx.draftItem?.rotation ?? [0, 0, 0])[0],
|
||||
(() => {
|
||||
const surfaceQuat = new Quaternion()
|
||||
surfaceMesh.getWorldQuaternion(surfaceQuat)
|
||||
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
|
||||
return ctx.currentCursorRotationY - surfaceWorldY
|
||||
})(),
|
||||
(ctx.draftItem?.rotation ?? [0, 0, 0])[2],
|
||||
] as [number, number, number],
|
||||
},
|
||||
cursorRotationY: ctx.currentCursorRotationY,
|
||||
gridPosition: [x, y, z],
|
||||
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
||||
stopPropagation: true,
|
||||
@@ -488,7 +506,7 @@ export const itemSurfaceStrategy = {
|
||||
return {
|
||||
gridPosition: [x, y, z],
|
||||
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
||||
cursorRotationY: 0,
|
||||
cursorRotationY: ctx.currentCursorRotationY,
|
||||
nodeUpdate: { position: [x, y, z] },
|
||||
stopPropagation: true,
|
||||
dirtyNodeId: null,
|
||||
@@ -532,12 +550,14 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
||||
|
||||
const attachTo = ctx.draftItem.asset.attachTo
|
||||
|
||||
const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo)
|
||||
|
||||
if (attachTo === 'ceiling') {
|
||||
if (ctx.state.surface !== 'ceiling' || !ctx.state.ceilingId) return false
|
||||
return validators.canPlaceOnCeiling(
|
||||
ctx.state.ceilingId as CeilingNode['id'],
|
||||
[ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||||
getScaledDimensions(ctx.draftItem),
|
||||
alignedDims,
|
||||
ctx.draftItem.rotation,
|
||||
[ctx.draftItem.id],
|
||||
).valid
|
||||
@@ -550,7 +570,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
||||
ctx.state.wallId as WallNode['id'],
|
||||
ctx.gridPosition.x,
|
||||
ctx.gridPosition.y,
|
||||
getScaledDimensions(ctx.draftItem),
|
||||
alignedDims,
|
||||
attachTo,
|
||||
ctx.draftItem.side,
|
||||
[ctx.draftItem.id],
|
||||
@@ -561,7 +581,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
||||
return validators.canPlaceOnFloor(
|
||||
ctx.levelId,
|
||||
[ctx.gridPosition.x, 0, ctx.gridPosition.z],
|
||||
getScaledDimensions(ctx.draftItem),
|
||||
alignedDims,
|
||||
ctx.draftItem.rotation,
|
||||
[ctx.draftItem.id],
|
||||
).valid
|
||||
|
||||
@@ -38,6 +38,13 @@ export interface PlacementContext {
|
||||
draftItem: ItemNode | null
|
||||
gridPosition: Vector3
|
||||
state: PlacementState
|
||||
/**
|
||||
* Current world Y rotation of the placement cursor — the user's intended
|
||||
* orientation, preserved across surface transitions. Strategies that
|
||||
* re-parent the draft (e.g. floor → item-surface) read this to compute the
|
||||
* matching parent-local rotation so the world orientation doesn't jump.
|
||||
*/
|
||||
currentCursorRotationY: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -39,7 +39,8 @@ import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { snapToGrid } from './placement-math'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { getGridAlignedDimensions, snapToGrid, snapUpToGridStep } from './placement-math'
|
||||
import {
|
||||
ceilingStrategy,
|
||||
checkCanPlace,
|
||||
@@ -70,6 +71,54 @@ type PreviewBounds = {
|
||||
center: [number, number, number]
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand `bounds` outward so each axis is rounded up to the active grid step.
|
||||
* The wireframe stays centered on the original bounds centre on each axis we
|
||||
* expand, so an off-centre mesh bbox stays off-centre. Wall-side items keep
|
||||
* `max.z = 0` (flush with the wall plane); the bottom (`min.y`) is preserved
|
||||
* so the box still sits on the floor / attachment plane.
|
||||
*
|
||||
* Floor / ceiling / item-surface: X and Z expand; Y stays exact.
|
||||
* Wall / wall-side: X and Y expand; Z stays exact.
|
||||
*/
|
||||
function expandBoundsToGrid(
|
||||
bounds: PreviewBounds,
|
||||
attachTo: AssetInput['attachTo'] | null | undefined,
|
||||
step: number,
|
||||
): PreviewBounds {
|
||||
const [w, h, d] = bounds.dimensions
|
||||
const [cx, , cz] = bounds.center
|
||||
const onWall = attachTo === 'wall' || attachTo === 'wall-side'
|
||||
const expandedW = snapUpToGridStep(w, step)
|
||||
const expandedH = onWall ? snapUpToGridStep(h, step) : h
|
||||
const expandedD = onWall ? d : snapUpToGridStep(d, step)
|
||||
|
||||
const minX = cx - expandedW / 2
|
||||
const maxX = cx + expandedW / 2
|
||||
const minY = bounds.min[1]
|
||||
const maxY = minY + expandedH
|
||||
|
||||
let minZ: number
|
||||
let maxZ: number
|
||||
let newCz: number
|
||||
if (attachTo === 'wall-side') {
|
||||
maxZ = 0
|
||||
minZ = -expandedD
|
||||
newCz = -expandedD / 2
|
||||
} else {
|
||||
minZ = cz - expandedD / 2
|
||||
maxZ = cz + expandedD / 2
|
||||
newCz = cz
|
||||
}
|
||||
|
||||
return {
|
||||
min: [minX, minY, minZ],
|
||||
max: [maxX, maxY, maxZ],
|
||||
dimensions: [expandedW, expandedH, expandedD],
|
||||
center: [cx, (minY + maxY) / 2, newCz],
|
||||
}
|
||||
}
|
||||
|
||||
function getPreviewBoundsFromObject(object: Object3D | null): PreviewBounds | null {
|
||||
if (!object) return null
|
||||
|
||||
@@ -233,7 +282,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
|
||||
const { asset, draftNode } = config
|
||||
const unit = useViewer((state) => state.unit)
|
||||
|
||||
const gridSnapStep = useEditor((s) => s.gridSnapStep)
|
||||
const updatePreviewGeometry = (bounds: PreviewBounds) => {
|
||||
const [width, height, depth] = bounds.dimensions
|
||||
const [centerX, centerY, centerZ] = bounds.center
|
||||
@@ -373,6 +422,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
draftItem: draftNode.current,
|
||||
gridPosition: gridPosition.current,
|
||||
state: { ...placementState.current },
|
||||
currentCursorRotationY: cursorGroupRef.current.rotation.y,
|
||||
})
|
||||
|
||||
const getActiveValidators = () =>
|
||||
@@ -791,9 +841,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
event.stopPropagation()
|
||||
|
||||
// Transition back to floor using building-local position
|
||||
const wx = Math.round(event.localPosition[0] * 2) / 2
|
||||
const wz = Math.round(event.localPosition[2] * 2) / 2
|
||||
// `event.localPosition` from useNodeEvents is in the LEAVING item's
|
||||
// local space (the sofa/table the draft is detaching from), not
|
||||
// building-local. Convert from world via worldToBuildingLocal instead,
|
||||
// otherwise the wireframe jumps to a surface-local-coordinate ghost
|
||||
// position until the next mouse move.
|
||||
const buildingLocalLeave = worldToBuildingLocal(
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
const wx = Math.round(buildingLocalLeave.x * 2) / 2
|
||||
const wz = Math.round(buildingLocalLeave.z * 2) / 2
|
||||
const floorPos: [number, number, number] = [wx, 0, wz]
|
||||
|
||||
Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
|
||||
@@ -1083,11 +1142,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
// ---- Bounding box geometry ----
|
||||
|
||||
const draft = draftNode.current
|
||||
const fallbackBounds = getFallbackPreviewBounds(draft, asset, asset.attachTo)
|
||||
const fallbackBounds = expandBoundsToGrid(
|
||||
getFallbackPreviewBounds(draft, asset, asset.attachTo),
|
||||
asset.attachTo,
|
||||
gridSnapStep,
|
||||
)
|
||||
updatePreviewGeometry(
|
||||
draft
|
||||
? (getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ??
|
||||
fallbackBounds)
|
||||
? (expandBoundsToGrid(
|
||||
getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ?? getFallbackPreviewBounds(draft, asset, asset.attachTo),
|
||||
asset.attachTo,
|
||||
gridSnapStep,
|
||||
))
|
||||
: fallbackBounds,
|
||||
)
|
||||
updateDimensionGuides(fallbackBounds)
|
||||
@@ -1163,7 +1229,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
}
|
||||
}, [asset, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode])
|
||||
|
||||
// Reparent floor draft to the new level when the user switches levels mid-placement.
|
||||
// Refresh wireframe when the grid step changes mid-placement so the green/red
|
||||
// box snaps to the new cell size right away.
|
||||
useEffect(() => {
|
||||
if (!asset) return
|
||||
const draft = draftNode.current
|
||||
const fallbackBounds = expandBoundsToGrid(
|
||||
getFallbackPreviewBounds(draft, asset, asset.attachTo),
|
||||
asset.attachTo,
|
||||
gridSnapStep,
|
||||
)
|
||||
const meshBounds = draft
|
||||
? getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null)
|
||||
: null
|
||||
updatePreviewGeometry(
|
||||
meshBounds ? expandBoundsToGrid(meshBounds, asset.attachTo, gridSnapStep) : fallbackBounds,
|
||||
)
|
||||
updateDimensionGuides(fallbackBounds)
|
||||
}, [gridSnapStep, asset, draftNode])
|
||||
// Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
|
||||
const viewerLevelId = useViewer((s) => s.selection.levelId)
|
||||
useEffect(() => {
|
||||
@@ -1190,7 +1273,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
if (!meshPreviewAppliedRef.current) {
|
||||
const previewBounds = getPreviewBoundsFromObject(mesh)
|
||||
if (previewBounds) {
|
||||
updatePreviewGeometry(previewBounds)
|
||||
updatePreviewGeometry(
|
||||
expandBoundsToGrid(previewBounds, asset.attachTo, useEditor.getState().gridSnapStep),
|
||||
)
|
||||
meshPreviewAppliedRef.current = true
|
||||
}
|
||||
}
|
||||
@@ -1217,7 +1302,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
||||
levelId,
|
||||
[gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
||||
getScaledDimensions(draftNode.current),
|
||||
getGridAlignedDimensions(
|
||||
getScaledDimensions(draftNode.current),
|
||||
draftNode.current.asset.attachTo,
|
||||
),
|
||||
draftNode.current.rotation,
|
||||
)
|
||||
mesh.position.y = slabElevation
|
||||
@@ -1226,18 +1314,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
})
|
||||
|
||||
const initialDraft = draftNode.current
|
||||
const dims = initialDraft
|
||||
const initialAttachTo = config.asset?.attachTo
|
||||
const rawDims = initialDraft
|
||||
? getScaledDimensions(initialDraft)
|
||||
: (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep)
|
||||
const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
||||
const wallSideZOffset = config.asset?.attachTo === 'wall-side' ? -dims[2] / 2 : 0
|
||||
const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0
|
||||
initialBoxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
|
||||
|
||||
// Base plane geometry (colored rectangle on the ground)
|
||||
const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2])
|
||||
basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal
|
||||
basePlaneGeometry.translate(0, 0.01, wallSideZOffset) // Slightly above ground to avoid z-fighting
|
||||
const initialDimensionBounds = getFallbackPreviewBounds(initialDraft, config.asset!, config.asset?.attachTo)
|
||||
const initialDimensionBounds = expandBoundsToGrid(
|
||||
getFallbackPreviewBounds(initialDraft, config.asset!, initialAttachTo),
|
||||
initialAttachTo,
|
||||
gridSnapStep,
|
||||
)
|
||||
const widthLabel = formatMeasurement(initialDimensionBounds.dimensions[0], unit)
|
||||
const depthLabel = formatMeasurement(initialDimensionBounds.dimensions[2], unit)
|
||||
const heightLabel = formatMeasurement(initialDimensionBounds.dimensions[1], unit)
|
||||
|
||||
@@ -4,11 +4,8 @@ import {
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
type LevelNode,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
} from '@pascal-app/core'
|
||||
import type { Object3D } from 'three'
|
||||
import { Box3, Matrix4, Vector3 } from 'three'
|
||||
import { getRotatedRectanglePolygon, rotatePlanVector } from './geometry'
|
||||
import type { FloorplanItemEntry, FloorplanNodeTransform, LevelDescendantMap } from './types'
|
||||
|
||||
@@ -139,34 +136,20 @@ export function buildFloorplanItemEntry(
|
||||
return null
|
||||
}
|
||||
|
||||
// Polygon is derived purely from `dimensions` — the same source of truth the
|
||||
// editor uses for placement / collision. Previously we ran a per-frame
|
||||
// convex-hull / minimum-area-rect pass over the loaded mesh's vertices to
|
||||
// produce a tighter polygon, but that's expensive and disagrees with what
|
||||
// the user sees on the 3D side (their dimensions are intentionally the
|
||||
// bounding box, sometimes hand-tuned).
|
||||
const dimensionPolygon = getItemDimensionPolygon(item, transform)
|
||||
const [width, , depth] = getScaledDimensions(item)
|
||||
if (shouldUseDimensionFloorplanFootprint(item)) {
|
||||
return {
|
||||
dimensionPolygon,
|
||||
item,
|
||||
polygon: dimensionPolygon,
|
||||
usesRealMesh: true,
|
||||
center: transform.position,
|
||||
rotation: transform.rotation,
|
||||
width,
|
||||
depth,
|
||||
}
|
||||
}
|
||||
|
||||
const object = sceneRegistry.nodes.get(item.id)
|
||||
const realMeshPolygon = object
|
||||
? getRealMeshFloorplanPolygon(transform, object)
|
||||
: getCachedMeshFloorplanPolygon(item, transform)
|
||||
if (!realMeshPolygon) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
dimensionPolygon,
|
||||
item,
|
||||
polygon: realMeshPolygon,
|
||||
usesRealMesh: realMeshPolygon !== null,
|
||||
polygon: dimensionPolygon,
|
||||
usesRealMesh: false,
|
||||
center: transform.position,
|
||||
rotation: transform.rotation,
|
||||
width,
|
||||
@@ -179,29 +162,6 @@ type Point = {
|
||||
y: number
|
||||
}
|
||||
|
||||
const DIMENSION_FOOTPRINT_ASSET_IDS = new Set(['tree', 'fir-tree', 'palm', 'bush'])
|
||||
const DIMENSION_FOOTPRINT_TAGS = new Set([
|
||||
'botanical',
|
||||
'foliage',
|
||||
'greenery',
|
||||
'plant',
|
||||
'tree',
|
||||
'vegetation',
|
||||
])
|
||||
|
||||
function shouldUseDimensionFloorplanFootprint(item: ItemNode) {
|
||||
const asset = item.asset
|
||||
if (asset.category !== 'outdoor') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (DIMENSION_FOOTPRINT_ASSET_IDS.has(asset.id)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return asset.tags?.some((tag) => DIMENSION_FOOTPRINT_TAGS.has(tag.toLowerCase())) ?? false
|
||||
}
|
||||
|
||||
function getItemDimensionPolygon(item: ItemNode, transform: FloorplanNodeTransform): Point[] {
|
||||
const [width, , depth] = getScaledDimensions(item)
|
||||
const centerLocalZ = item.asset.attachTo === 'wall-side' ? -depth / 2 : 0
|
||||
@@ -217,240 +177,3 @@ function getItemDimensionPolygon(item: ItemNode, transform: FloorplanNodeTransfo
|
||||
transform.rotation,
|
||||
)
|
||||
}
|
||||
|
||||
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?.meshLocalPlanPolygon
|
||||
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]
|
||||
}
|
||||
|
||||
@@ -21,10 +21,6 @@ import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { resolveCdnUrl } from '../../../lib/asset-url'
|
||||
import { baseMaterial, glassMaterial } from '../../../lib/materials'
|
||||
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'
|
||||
|
||||
@@ -110,19 +106,6 @@ 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
|
||||
|
||||
setItemMeshMetadataSourceRoot(node.id, cloneRoot)
|
||||
requestItemMeshMetadataSync(node.id)
|
||||
return () => {
|
||||
setItemMeshMetadataSourceRoot(node.id, null)
|
||||
}
|
||||
}, [node.id, node.metadata, scene])
|
||||
|
||||
useEffect(() => {
|
||||
const interactive = interactiveRef.current
|
||||
if (!interactive) return
|
||||
|
||||
@@ -11,7 +11,6 @@ import { FenceSystem } from '../../systems/fence/fence-system'
|
||||
import { GuideSystem } from '../../systems/guide/guide-system'
|
||||
import { ItemSystem } from '../../systems/item/item-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 { RoofSystem } from '../../systems/roof/roof-system'
|
||||
import { ScanSystem } from '../../systems/scan/scan-system'
|
||||
@@ -239,7 +238,6 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
{/* <DebugRenderer /> */}
|
||||
|
||||
<ItemLightSystem />
|
||||
<ItemMeshMetadataSystem />
|
||||
{selectionManager === 'default' && <SelectionManager />}
|
||||
{perf && <PerfMonitor />}
|
||||
{children}
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
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]
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
'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<string, unknown>,
|
||||
): boolean {
|
||||
const currentPolygon = metadata.meshLocalPlanPolygon
|
||||
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<string, AnyNode | undefined>) {
|
||||
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<string, unknown>)
|
||||
: {}
|
||||
|
||||
if (isMetadataUnchanged(nextPolygon, nextBounds, metadata)) return
|
||||
|
||||
useScene.getState().updateNode(itemId as AnyNodeId, {
|
||||
metadata: {
|
||||
...metadata,
|
||||
...(nextPolygon ? { meshLocalPlanPolygon: nextPolygon } : {}),
|
||||
...(nextBounds ? { meshLocalBounds: nextBounds } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes `meshLocalPlanPolygon` / `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
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { Object3D } from 'three'
|
||||
|
||||
const pendingIds = new Set<string>()
|
||||
/** Preferred root for footprint math (Clone root). Falls back to sceneRegistry item root. */
|
||||
const sourceRoots = new Map<string, Object3D>()
|
||||
|
||||
/** 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
|
||||
}
|
||||
Reference in New Issue
Block a user