feat(editor): mode-driven shelf/column/spawn placement + cross-kind floor collision
Migrate the remaining floor-placed kinds onto the unified snapping/modifier
model and generalize floor collision so any solid floor kind blocks any other.
- shelf/column/spawn declare `snapProfile: 'item'` → contextual snapping chip,
Shift=cycle, Ctrl=grid step during placement; their tools read the active
mode (grid/lines/off) instead of legacy Shift/Alt bypass; spawn fresh
placement now respects alignment ("lines") like its move.
- Resize/radial handles claim the handle-drag scope (new RESIZE_HANDLE_DRAG_LABEL)
so the HUD shows no select-mode shortcuts mid-resize.
- Column move migrated to the generic MoveRegistryNodeTool (declare `movable`,
drop the bespoke move-tool) — gains mode-driven snapping, alignment, R/T,
slab lift, grid SFX, and the collision box for free. 2D move still routes
through `floorplanMoveTarget`.
- Cross-kind floor collision: new declarative `FloorPlacedConfig.collides`
(item/shelf/column opt in; spawn/MEP/stair stay off). `canPlaceOnFloor` now
treats every colliding floor kind as an obstacle (was item-only), reading the
declarative footprint; the generic move tool's red/green placement box gates
on `collides`. Column footprint uses the visible `columnFootprintHalf` extent
so the box/slab-lift/collision track the real (round/square) column size.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
04f1b0d59e
commit
8a57105eec
@@ -1,8 +1,10 @@
|
||||
import { nodeRegistry } from '../../registry'
|
||||
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
|
||||
import { getScaledDimensions, isLowProfileItemSurface } from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
import { isCurvedWall, sampleWallCenterline } from '../../systems/wall/wall-curve'
|
||||
import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint'
|
||||
import { getFloorPlacedFootprints } from './floor-placed-elevation'
|
||||
import { SpatialGrid } from './spatial-grid'
|
||||
import { WallSpatialGrid } from './wall-spatial-grid'
|
||||
|
||||
@@ -54,6 +56,29 @@ function getItemFootprint(
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Axis-aligned XZ extent of a footprint at `position`, rotated by `yRot`. The
|
||||
* rotated width/depth is the same conservative bound the floor-placement draft
|
||||
* uses, so a draft and an existing node are compared with identical math.
|
||||
*/
|
||||
function footprintBoundsXZ(
|
||||
position: [number, number, number],
|
||||
dimensions: [number, number, number],
|
||||
yRot: number,
|
||||
): { minX: number; maxX: number; minZ: number; maxZ: number } {
|
||||
const [width, , depth] = dimensions
|
||||
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
|
||||
return {
|
||||
minX: position[0] - rotatedW / 2,
|
||||
maxX: position[0] + rotatedW / 2,
|
||||
minZ: position[2] - rotatedD / 2,
|
||||
maxZ: position[2] + rotatedD / 2,
|
||||
}
|
||||
}
|
||||
|
||||
type ItemLocalBounds = {
|
||||
min: [number, number, number]
|
||||
max: [number, number, number]
|
||||
@@ -647,34 +672,38 @@ export class SpatialGridManager {
|
||||
) {
|
||||
const nodes = useScene.getState().nodes
|
||||
const ignoreSet = new Set(ignoreIds ?? [])
|
||||
const [width, , depth] = dimensions
|
||||
const yRot = rotation[1]
|
||||
const cos = Math.abs(Math.cos(yRot))
|
||||
const sin = Math.abs(Math.sin(yRot))
|
||||
const rotatedW = width * cos + depth * sin
|
||||
const rotatedD = width * sin + depth * cos
|
||||
const draftBounds = {
|
||||
minX: position[0] - rotatedW / 2,
|
||||
maxX: position[0] + rotatedW / 2,
|
||||
minZ: position[2] - rotatedD / 2,
|
||||
maxZ: position[2] + rotatedD / 2,
|
||||
}
|
||||
const draftBounds = footprintBoundsXZ(position, dimensions, rotation[1])
|
||||
|
||||
// A floor placement conflicts with any other COLLIDING floor-resting node,
|
||||
// not just items — every kind whose `floorPlaced.collides` is set (item /
|
||||
// shelf / column) contributes its footprint(s) as an obstacle. Each
|
||||
// candidate's XZ extent is read from the same declarative footprint the
|
||||
// elevation + sync paths use, so adding a colliding kind needs no change here.
|
||||
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 (isLowProfileItemSurface(item)) continue
|
||||
if (ignoreSet.has(item.id)) continue
|
||||
if (resolveNodeLevelId(item, nodes) !== levelId) continue
|
||||
if (ignoreSet.has(node.id)) continue
|
||||
const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
|
||||
if (!floorPlaced?.collides) continue
|
||||
if (floorPlaced.applies && !floorPlaced.applies(node)) continue
|
||||
// Low-profile item surfaces (rugs, mats) are stack-on targets, not
|
||||
// obstacles — keep the long-standing item-only exemption.
|
||||
if (node.type === 'item' && isLowProfileItemSurface(node as ItemNode)) continue
|
||||
if (resolveNodeLevelId(node, 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)
|
||||
for (const footprint of getFloorPlacedFootprints(floorPlaced, node, { nodes })) {
|
||||
const fpRotation = Array.isArray(footprint.rotation) ? (footprint.rotation[1] ?? 0) : 0
|
||||
const bounds = footprintBoundsXZ(
|
||||
footprint.position ?? (node as { position: [number, number, number] }).position,
|
||||
footprint.dimensions,
|
||||
fpRotation,
|
||||
)
|
||||
if (
|
||||
intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) &&
|
||||
intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ)
|
||||
) {
|
||||
conflicts.push(node.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1575,6 +1575,15 @@ export type FloorPlacedConfig = {
|
||||
footprint?: FloorPlacedFootprintResolver
|
||||
footprints?: FloorPlacedFootprintsResolver
|
||||
applies?: (node: AnyNode) => boolean
|
||||
/**
|
||||
* Opt this kind into floor-placement collision: its footprint blocks other
|
||||
* placements (it's an obstacle in `canPlaceOnFloor`) AND its own
|
||||
* placement/move refuses to overlap another colliding footprint (red ghost,
|
||||
* Alt to force). Solid furniture-like kinds (item / shelf / column) set this;
|
||||
* markers and port-mated kinds (spawn / MEP / stair) leave it off so they
|
||||
* neither block nor get blocked. Default off.
|
||||
*/
|
||||
collides?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user