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
+48 -22
View File
@@ -2,20 +2,19 @@ import type { FloorplanGeometry } from '@pascal-app/core'
import type { ShelfNode } from './schema'
/**
* 2D floor-plan representation of a shelf. The top board (the largest
* visible surface from above) projects to a rectangle of `width × depth`
* centered on `(position.x, position.z)`, rotated by the shelf's Y angle.
* 2D floor-plan representation of a shelf. The unit's outer footprint
* projects to a rectangle of `width × depth` centered on the shelf's
* position, rotated by its Y angle. For `bookshelf` / `cubby` with
* columns > 1, vertical column dividers project as thin lines so the
* grid is legible from above.
*
* Brackets are intentionally omitted — they're hidden under the top
* board from a top-down view, and adding them as separate rects clutters
* the plan without conveying useful information at typical zoom levels.
* Brackets / posts / individual boards are intentionally omitted — they
* stack vertically under the topmost board from a top-down view and
* adding them clutters the plan without conveying useful information.
*
* Coordinates are level-local meters; the floor-plan panel applies the
* world→SVG transform via its viewBox. Rotation is radians (three.js
* convention); the renderer converts to SVG degrees.
*
* Pairs with `buildShelfGeometry(node)` — the 3D builder. Same shape,
* different output projection.
*/
export function buildShelfFloorplan(node: ShelfNode): FloorplanGeometry {
const [px, , pz] = node.position
@@ -23,21 +22,48 @@ export function buildShelfFloorplan(node: ShelfNode): FloorplanGeometry {
const halfW = node.width / 2
const halfD = node.depth / 2
// Floor-plan fill: a single neutral fill regardless of `material`.
// 2D doesn't render the actual paint material — surfaces in plan view
// read as outline + tone, not photoreal texture. Using a fixed light
// gray keeps the plan visually consistent with the other furniture
// kinds (item / column / etc.) which also render as neutral fills.
const children: FloorplanGeometry[] = [
{
kind: 'rect',
x: -halfW,
y: -halfD,
width: node.width,
height: node.depth,
fill: '#d6d3d1',
stroke: '#1f2937',
strokeWidth: 0.015,
opacity: 0.9,
},
]
// Show column dividers for grid-style shelves so the cubby / bookshelf
// grid is visible from above.
if ((node.style === 'bookshelf' || node.style === 'cubby') && node.columns > 1) {
const innerWidth = node.width - 2 * node.thickness
const colStep = innerWidth / node.columns
for (let c = 1; c < node.columns; c++) {
const x = -innerWidth / 2 + c * colStep
children.push({
kind: 'line',
x1: x,
y1: -halfD + node.thickness,
x2: x,
y2: halfD - node.thickness,
stroke: '#1f2937',
strokeWidth: 0.012,
opacity: 0.7,
})
}
}
return {
kind: 'group',
transform: { translate: [px, pz], rotate: ry },
children: [
{
kind: 'rect',
x: -halfW,
y: -halfD,
width: node.width,
height: node.depth,
fill: node.color,
stroke: '#1f2937',
strokeWidth: 0.015,
opacity: 0.9,
},
],
children,
}
}