registry: generic FloorElevationSystem driven by a floorPlaced capability

Symptom: a shelf placed on a level with a raised slab underneath
visually clipped through it — `ItemSystem` lifted items onto slabs,
but shelves (and other floor-placed registry kinds) had no equivalent
path.

Fix: lift the slab-elevation logic out of `ItemSystem` into a generic
`<FloorElevationSystem>` keyed off a new `capabilities.floorPlaced`
config. Any kind that opts in declares a `footprint(node)` (dimensions
+ rotation used to query overlapping slabs) and an optional `applies`
predicate (skips items whose `asset.attachTo` is wall / ceiling).

The new system runs at frame priority 1 so its `mesh.position.y`
override lands before `ItemSystem` / `GeometrySystem` (priority 2)
clear the dirty mark. The spatial-grid sync's `markNodesOverlappingSlab`
also dropped its hardcoded `item` branch in favour of an iteration over
every registered kind that declares `floorPlaced` — so any new
floor-placed kind picks up slab-driven re-elevation automatically.

Tagged kinds:
 - `item`  — `footprint = getScaledDimensions`, `applies = !asset.attachTo`
 - `shelf` — `footprint = (w, h, d)`
 - `column` — `footprint = (w, h, d)`
 - `spawn` — `footprint = (0.6, 1.8, 0.6)` (marker)

`ItemSystem` retains only the wall-side z-offset block (`mesh.position.z =
wallThickness / 2`). The elevation block + its `getScaledDimensions` /
`resolveLevelId` / `spatialGridManager` imports moved to the generic
system.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-19 19:44:21 -04:00
co-authored by Claude Opus 4.7
parent 78e3ed13d8
commit 9a481f2511
10 changed files with 170 additions and 43 deletions
@@ -205,6 +205,11 @@ const Viewer: React.FC<ViewerProps> = ({
<SceneRenderer />
)}
{/* Generic slab-elevation lift for any kind that declares
`capabilities.floorPlaced`. Runs at frame priority 1 so it
lands its mesh.position.y override before the priority-2
systems below clear the dirty mark. */}
<FloorElevationSystem />
{/* Generic geometry rebuild loop for any registered kind that
ships `def.geometry`. Reads dirtyNodes, calls the kind's pure
builder, swaps the registered group's children. See
+5
View File
@@ -59,6 +59,11 @@ export { ElevatorInteractionSystem } from './systems/elevator/elevator-interacti
// registry-driven fence definition's `def.system`. Removed in Phase 6
// alongside the legacy fence mount point.
export { FenceSystem, generateFenceGeometry } from './systems/fence/fence-system'
// Generic floor-elevation system. Lifts the rendered mesh of any kind
// whose definition declares `capabilities.floorPlaced` by the slab
// elevation under its footprint. Replaces the per-kind elevation block
// that used to live inside `ItemSystem`.
export { FloorElevationSystem } from './systems/floor-elevation/floor-elevation-system'
export { GuideSystem } from './systems/guide/guide-system'
export { InteractiveSystem } from './systems/interactive/interactive-system'
// Item systems for the registry-driven item definition. ItemSystem
@@ -0,0 +1,75 @@
import {
type AnyNode,
type AnyNodeId,
nodeRegistry,
resolveLevelId,
sceneRegistry,
spatialGridManager,
useScene,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import type * as THREE from 'three'
/**
* Generic floor-elevation system.
*
* Walks `dirtyNodes` and, for any kind that declares
* `capabilities.floorPlaced`, lifts the registered mesh's Y by whatever
* slab the footprint overlaps. Items / shelves / etc. that sit directly
* on a level pick this up automatically — no per-kind elevation logic.
*
* Skips nodes whose parent is not a level (items hosted on shelves /
* tables inherit Y from the parent group), and respects
* `floorPlaced.applies` so items with `asset.attachTo` (wall / ceiling
* mounted) are left alone.
*
* Runs at priority 1 — before the priority-2 systems (`GeometrySystem`,
* `ItemSystem`) so the dirty mark survives long enough for those to do
* their own work. Doesn't clear dirty; the per-kind system (or the
* generic geometry rebuild) is responsible for that.
*/
export const FloorElevationSystem = () => {
const dirtyNodes = useScene((s) => s.dirtyNodes)
useFrame(() => {
if (dirtyNodes.size === 0) return
const nodes = useScene.getState().nodes
dirtyNodes.forEach((id) => {
const node = nodes[id]
if (!node) return
const def = nodeRegistry.get(node.type)
const floorPlaced = def?.capabilities?.floorPlaced
if (!floorPlaced) return
if (floorPlaced.applies && !floorPlaced.applies(node as AnyNode)) return
// Only nodes parented directly to a level get the lift. Children of
// walls / ceilings / other items inherit Y from the parent group.
const parentId = node.parentId as AnyNodeId | null
const parent = parentId ? nodes[parentId] : null
if (parent && parent.type !== 'level') return
const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined
if (!mesh) return
const position = (node as { position?: [number, number, number] }).position
if (!position) return
const levelId = resolveLevelId(node, nodes)
if (!levelId) return
const { dimensions, rotation } = floorPlaced.footprint(node as AnyNode)
const slabElevation = spatialGridManager.getSlabElevationForItem(
levelId,
position,
dimensions,
rotation,
)
mesh.position.y = slabElevation + position[1]
})
}, 1)
return null
}
@@ -1,10 +1,7 @@
import {
type AnyNodeId,
getScaledDimensions,
type ItemNode,
resolveLevelId,
sceneRegistry,
spatialGridManager,
useScene,
type WallNode,
} from '@pascal-app/core'
@@ -15,6 +12,13 @@ import type * as THREE from 'three'
// ITEM SYSTEM
// ============================================================================
/**
* Per-frame wall-side offset for items mounted to wall faces. The slab-
* elevation lift for floor items lives in the generic
* `<FloorElevationSystem>` and runs at priority 1 — it has already
* landed `mesh.position.y` by the time this system clears the dirty
* mark at priority 2.
*/
export const ItemSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty)
@@ -39,20 +43,6 @@ export const ItemSystem = () => {
const side = item.side === 'front' ? 1 : -1
mesh.position.z = (wallThickness / 2) * side
}
} else if (!item.asset.attachTo) {
// If parented to another item (surface placement), R3F handles positioning via the hierarchy
const parentNode = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined
if (parentNode?.type !== 'item') {
// Floor item: elevate by slab height (using full footprint overlap)
const levelId = resolveLevelId(item, nodes)
const slabElevation = spatialGridManager.getSlabElevationForItem(
levelId,
item.position,
getScaledDimensions(item),
item.rotation,
)
mesh.position.y = slabElevation + item.position[1]
}
}
clearDirty(id as AnyNodeId)