shelf: v2 — cubby default, withBottom, item hosting, paintable surface

Schema v2 adds style/rows/columns/withBack/withSides/withBottom/bracketStyle
and a `children: ItemNode[]` field for item hosting. Schema-level defaults
preserve the v1 wall-shelf visual so existing scenes load unchanged; the
placement tool spreads `shelfDefinition.defaults()` for fresh shelves
(cubby 3x2 at 1m × 0.5m × 1.8m, thickness 0.05m, back/sides/bottom on).

Four style geometries (wall-shelf / bookshelf / open-rack / cubby) share
the dimensional schema. `shelfRowSurfaceYs` exposes one host surface per
row, plus the bottom-board top when `withBottom` is on for cubby /
bookshelf.

Material is a single paintable surface (same shape walls / slabs / stairs
use); `DEFAULT_SHELF_MATERIAL` aligned with `DEFAULT_WALL_MATERIAL` so
unpainted shelves read as the canonical off-white.

Preview clones each cached material before mutating `transparent / opacity`
on the ghost — without the clone the mutation leaked into the cached
`getShelfMaterial` instance every committed shelf was using, rendering
them all see-through after the first placement preview rendered.

Store hardening: `migrateNodes` patches missing `children: []` on v1
shelves, and `updateNodesAction` reparenting tolerates a missing children
array on the new parent. `MaterialTarget` enum adds `'shelf'` so paint
mode picks up the kind.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-19 15:11:13 -04:00
co-authored by Claude Opus 4.7
parent 56e022a093
commit 924567293a
16 changed files with 1115 additions and 217 deletions
+52 -18
View File
@@ -1,12 +1,13 @@
import type { NodeDefinition } from '@pascal-app/core'
import { buildShelfFloorplan } from './floorplan'
import { buildShelfGeometry } from './geometry'
import { shelfFloorplanMoveTarget } from './floorplan-move'
import { buildShelfGeometry, shelfRowSurfaceYs } from './geometry'
import { shelfParametrics } from './parametrics'
import { ShelfNode } from './schema'
export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
kind: 'shelf',
schemaVersion: 1,
schemaVersion: 2,
schema: ShelfNode,
category: 'furnish',
@@ -15,14 +16,23 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
parentId: null,
visible: true,
metadata: {},
children: [],
position: [0, 0, 0],
rotation: [0, 0, 0],
width: 1.2,
depth: 0.3,
thickness: 0.04,
height: 0.9,
width: 1,
depth: 0.5,
thickness: 0.05,
height: 1.8,
style: 'cubby',
rows: 3,
columns: 2,
withBack: true,
withSides: true,
withBottom: true,
bracketStyle: 'minimal',
color: '#a07050',
// material / materialPreset left undefined — geometry falls back to
// `DEFAULT_SHELF_MATERIAL` (off-white), and paint mode writes the
// chosen catalog material into these fields.
}),
capabilities: {
@@ -31,17 +41,34 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
axes: ['y'],
snapAngles: [0, Math.PI / 4, Math.PI / 2, (3 * Math.PI) / 4, Math.PI],
},
// The whole point of shelf: things can stack on it. Surface height
// resolves from the node so multiple shelves at different heights stack
// correctly (vs a fixed-height table).
// Multi-row hosting: each row's top board exposes a surface so items
// can stack on whichever row the cursor targets. `surfaces.top`
// points at the topmost board (legacy compatibility — code that
// assumes a single surface still works). `surfaces.custom` emits
// one `SurfacePoint` per row centered on (0, rowY, 0) — the
// placement coordinator's shelf strategy picks the closest by
// cursor local-Y and snaps there.
surfaces: {
top: { height: (n) => (n as ShelfNode).height + (n as ShelfNode).thickness },
top: { height: (n) => shelfRowSurfaceYs(n as ShelfNode).at(-1) ?? 0 },
custom: (n) =>
shelfRowSurfaceYs(n as ShelfNode).map((y) => ({
position: [0, y, 0] as const,
normal: [0, 1, 0] as const,
})),
},
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
},
// Items host on shelves the same way they host on slabs / other items —
// declared here so the placement coordinator's shelf strategy can
// confirm parent-kind compatibility before reparenting.
relations: {
hosts: ['item'],
cascadeDelete: 'descendants',
},
parametrics: shelfParametrics,
// Three-checkbox composition: shelf needs only pure builder functions.
@@ -49,10 +76,17 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
// mount and rebuild on dirty; the <FloorplanRegistryLayer> calls
// buildShelfFloorplan for the 2D top-down view. No renderer.tsx, no
// system.tsx, no inline floor-plan SVG — see
// `wiki/architecture/node-definitions.md`. Shelf is the reference port
// proving Phase 4's boilerplate collapse for both 3D and 2D.
// `wiki/architecture/node-definitions.md`.
geometry: buildShelfGeometry,
floorplan: buildShelfFloorplan,
// 2D move handler — Path 1 in `FloorplanRegistryMoveOverlay`. Without
// this the overlay falls through to Path 2 which stomps the SVG
// entry's `transform` attribute (set by the floor-plan layer to
// position the shelf at `node.position`), producing the "ultra slow,
// wrong place" symptom the user observed. Path 1 writes live
// transforms during drag for real-time 3D sync and commits via a
// single tracked `updateNode`.
floorplanMoveTarget: shelfFloorplanMoveTarget,
preview: () => import('./preview'),
tool: () => import('./tool'),
@@ -63,14 +97,14 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
presentation: {
label: 'Shelf',
description: 'A horizontal surface for stacking other items.',
icon: { kind: 'url', src: '/icons/column.png' },
paletteSection: 'structure',
paletteOrder: 50,
description: 'A configurable shelving unit. Items host on each row.',
icon: { kind: 'url', src: '/icons/shelf.png' },
paletteSection: 'furnish',
paletteOrder: 30,
},
mcp: {
description:
'A parametric shelf with adjustable dimensions and bracket style. Stackable on its top surface.',
'A parametric shelving unit. Four styles (wall-shelf / bookshelf / open-rack / cubby) with configurable rows, columns, sides, and back. Items host on each row.',
},
}