diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index d64dbd7c..3829c4b9 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -1,11 +1,5 @@ -import { - type AnyNode, - type AnyNodeId, - getScaledDimensions, - type ItemNode, - type SlabNode, - type WallNode, -} from '../../schema' +import { nodeRegistry } from '../../registry' +import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema' import useScene from '../../store/use-scene' import { itemOverlapsPolygon, @@ -135,31 +129,35 @@ function markNodesOverlappingSlab( const slabLevelId = resolveLevelId(slab, nodes) for (const node of Object.values(nodes)) { - if (node.type === 'item') { - const item = node as ItemNode - // Only floor items are affected by slabs - if (item.asset.attachTo) continue - if (resolveLevelId(node, nodes) !== slabLevelId) continue - if ( - itemOverlapsPolygon( - item.position, - getScaledDimensions(item), - item.rotation, - slab.polygon, - 0.01, - ) - ) { - markDirty(node.id) - } - } else if (node.type === 'wall') { + if (node.type === 'wall') { const wall = node as WallNode if (resolveLevelId(node, nodes) !== slabLevelId) continue if (wallOverlapsPolygon(wall.start, wall.end, slab.polygon)) { markDirty(node.id) } - } else if (node.type === 'stair') { + continue + } + if (node.type === 'stair') { if (resolveLevelId(node, nodes) !== slabLevelId) continue markDirty(node.id) + continue + } + + // Generic floor-placed sweep: any registry kind that opts in via + // `capabilities.floorPlaced` (item / shelf / column / spawn / …) + // re-elevates through `` when a slab below + // changes. We dirty-mark when the kind's footprint overlaps the + // changed slab so the system picks it up next frame. + const def = nodeRegistry.get(node.type) + const floorPlaced = def?.capabilities?.floorPlaced + if (!floorPlaced) continue + if (floorPlaced.applies && !floorPlaced.applies(node)) continue + if (resolveLevelId(node, nodes) !== slabLevelId) continue + const position = (node as { position?: [number, number, number] }).position + if (!position) continue + const { dimensions, rotation } = floorPlaced.footprint(node) + if (itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) { + markDirty(node.id) } } } diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index f7de31c2..515365e3 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -735,6 +735,7 @@ export type Capabilities = { groupable?: boolean selectable?: SelectableConfig interactive?: boolean + floorPlaced?: FloorPlacedConfig } export type CapabilityCtx = { node: AnyNode } @@ -795,6 +796,23 @@ export type SelectableConfig = { override?: (ctx: CapabilityCtx) => SelectableConfig | null } +/** + * Floor-placed kinds rest directly on a level and need their Y lifted by + * any slab the footprint overlaps. The generic `` + * computes `slabElevation + node.position[1]` and writes it onto the + * registered mesh on every dirty mark. `footprint` returns the world-space + * footprint the spatial-grid manager uses to find overlapping slabs; + * `applies` is an optional predicate to skip nodes that share a kind but + * are mounted off-floor (items attached to a wall / ceiling). + */ +export type FloorPlacedConfig = { + footprint: (node: AnyNode) => { + dimensions: [number, number, number] + rotation: [number, number, number] + } + applies?: (node: AnyNode) => boolean +} + // ─── Relations ─────────────────────────────────────────────────────── export type Relations = { diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts index ec9ab2e4..52fc2a0b 100644 --- a/packages/nodes/src/column/definition.ts +++ b/packages/nodes/src/column/definition.ts @@ -34,6 +34,16 @@ export const columnDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, + // Slab elevation lift via the generic ``. + floorPlaced: { + footprint: (node) => { + const column = node as ColumnNodeType + return { + dimensions: [column.width, column.height, column.depth] as [number, number, number], + rotation: column.rotation, + } + }, + }, }, parametrics: columnParametrics, diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index 94e8116c..025986e5 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -67,6 +67,16 @@ export const itemDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, + // Floor items get lifted by slabs underneath via the generic + // ``. Wall- / ceiling-attached items live in + // their parent's local frame and skip the lift via `applies`. + floorPlaced: { + footprint: (node) => { + const item = node as ItemNodeType + return { dimensions: getScaledDimensions(item), rotation: item.rotation } + }, + applies: (node) => !(node as ItemNodeType).asset.attachTo, + }, }, parametrics: itemParametrics, diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts index 9bfeb230..c78a0c09 100644 --- a/packages/nodes/src/shelf/definition.ts +++ b/packages/nodes/src/shelf/definition.ts @@ -59,6 +59,17 @@ export const shelfDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, + // Slab elevation lift via the generic `` — a + // shelf sitting over a raised slab visually rests on top of it. + floorPlaced: { + footprint: (node) => { + const shelf = node as ShelfNode + return { + dimensions: [shelf.width, shelf.height, shelf.depth] as [number, number, number], + rotation: shelf.rotation, + } + }, + }, }, // Items host on shelves the same way they host on slabs / other items — diff --git a/packages/nodes/src/spawn/definition.ts b/packages/nodes/src/spawn/definition.ts index 0a6afcee..45e430ac 100644 --- a/packages/nodes/src/spawn/definition.ts +++ b/packages/nodes/src/spawn/definition.ts @@ -27,6 +27,11 @@ export const spawnDefinition: NodeDefinition = { duplicable: false, // singleton per level deletable: true, selectable: { hitVolume: 'bbox' }, + // Slab elevation lift via the generic ``. The + // spawn marker is a 1.8m-tall figure with a ~0.6m ring footprint. + floorPlaced: { + footprint: () => ({ dimensions: [0.6, 1.8, 0.6], rotation: [0, 0, 0] }), + }, }, parametrics: spawnParametrics, diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index b4a1c1a7..fdcda841 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -205,6 +205,11 @@ const Viewer: React.FC = ({ )} + {/* 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. */} + {/* 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 diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 9ababf43..469451fe 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -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 diff --git a/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx b/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx new file mode 100644 index 00000000..01b52c2b --- /dev/null +++ b/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx @@ -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 +} diff --git a/packages/viewer/src/systems/item/item-system.tsx b/packages/viewer/src/systems/item/item-system.tsx index cd9c135c..8b86454e 100644 --- a/packages/viewer/src/systems/item/item-system.tsx +++ b/packages/viewer/src/systems/item/item-system.tsx @@ -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 + * `` 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)