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:
co-authored by
Claude Opus 4.7
parent
56e022a093
commit
924567293a
@@ -51,6 +51,7 @@ export const MaterialTarget = z.enum([
|
||||
'ceiling',
|
||||
'door',
|
||||
'window',
|
||||
'shelf',
|
||||
])
|
||||
export type MaterialTarget = z.infer<typeof MaterialTarget>
|
||||
|
||||
|
||||
@@ -1,33 +1,91 @@
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { MaterialSchema } from '../material'
|
||||
import { ItemNode } from './item'
|
||||
|
||||
/**
|
||||
* Parametric shelf — a free-standing or table-top horizontal surface.
|
||||
* Parametric shelf — a configurable furniture unit with one or more
|
||||
* horizontal boards that host other items.
|
||||
*
|
||||
* v1: free-standing only. Wall-mount via a `mount` discriminator lands in
|
||||
* Phase 5 alongside item migration. Until then, position is the world
|
||||
* (or level-local) position of the shelf's center; rotation is yaw only.
|
||||
* Four styles share the same dimensional schema:
|
||||
*
|
||||
* Schema lives in core because `AnyNode` (also in core) needs to reference
|
||||
* it via the hand-maintained discriminated union. Phase 6 derives `AnyNode`
|
||||
* from `nodeRegistry.schemas()` and this file moves entirely into
|
||||
* `@pascal-app/nodes/shelf/`.
|
||||
* - `wall-shelf` — open boards held by end brackets. `rows > 1` stacks
|
||||
* evenly-spaced boards. Brackets style: `minimal | industrial | hidden`.
|
||||
* The v1 archetype.
|
||||
* - `bookshelf` — full-height cabinet: side panels + multiple shelf
|
||||
* boards. `columns > 1` adds vertical dividers between sections.
|
||||
* `withBack` toggles a back panel. `withSides` toggles the side
|
||||
* panels (`false` = open silhouette held by cross-brace posts).
|
||||
* - `open-rack` — industrial wire-rack style: four corner posts, no
|
||||
* side panels, slim boards. `withBack` adds an X-brace.
|
||||
* - `cubby` — grid of pigeonhole cubicles: `rows × columns` cells
|
||||
* formed by full back + sides + inner dividers. Each cubicle hosts
|
||||
* items on its own bottom surface.
|
||||
*
|
||||
* `height` is the distance from floor to the underside of the topmost
|
||||
* board (legacy v1 semantic, preserved so v1 scenes load with identical
|
||||
* top-board placement). For `rows > 1`, boards are evenly spaced from
|
||||
* `height / rows` up to `height`. For `cubby`, the height divides into
|
||||
* `rows` equal-height cubicles.
|
||||
*
|
||||
* Items host on each row's top surface via `capabilities.surfaces.custom`.
|
||||
*/
|
||||
export const ShelfNode = BaseNode.extend({
|
||||
id: objectId('shelf'),
|
||||
type: nodeType('shelf'),
|
||||
// Hosted items live here — without this field `createNode(item, shelf)`
|
||||
// would write `item.parentId = shelf.id` but skip the children-list
|
||||
// update, so the shelf renderer wouldn't pick the item up and React
|
||||
// would never mount it (the item would exist in `useScene.nodes` but
|
||||
// not be rendered, making the commit look like "the item went
|
||||
// somewhere else"). The action's parent-update branch needs the field
|
||||
// present at parse-time so the children array is always defined.
|
||||
children: z.array(ItemNode.shape.id).default([]),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
|
||||
// Dimensions (meters)
|
||||
// Dimensions (meters). Schema-level defaults intentionally reproduce
|
||||
// the v1 wall-shelf so existing v1 scenes that omit the v2-introduced
|
||||
// fields (style / rows / columns / with*) load with their original
|
||||
// visual unchanged. The user-facing "place a fresh shelf" defaults
|
||||
// (cubby 3x2 @ 1m × 0.5m × 1.8m) live on `shelfDefinition.defaults()`
|
||||
// and are applied by the placement tool, NOT here.
|
||||
width: z.number().min(0.3).max(3.0).default(1.2),
|
||||
depth: z.number().min(0.1).max(1.0).default(0.3),
|
||||
/** Board thickness — shared by top boards, sides, back, dividers. */
|
||||
thickness: z.number().min(0.01).max(0.1).default(0.04),
|
||||
/** Distance from the floor to the bottom of the shelf top board. */
|
||||
/**
|
||||
* Distance from floor to the underside of the topmost board. For
|
||||
* `rows > 1`, intermediate boards are evenly spaced from `height/rows`
|
||||
* up to `height`.
|
||||
*/
|
||||
height: z.number().min(0.05).max(2.5).default(0.9),
|
||||
|
||||
// Style + topology — v2 additions, default to v1 visual (single-board
|
||||
// wall shelf) so v1 scenes are forward-compatible without migration.
|
||||
style: z.enum(['wall-shelf', 'bookshelf', 'open-rack', 'cubby']).default('wall-shelf'),
|
||||
rows: z.number().int().min(1).max(8).default(1),
|
||||
columns: z.number().int().min(1).max(6).default(1),
|
||||
withBack: z.boolean().default(false),
|
||||
withSides: z.boolean().default(true),
|
||||
/**
|
||||
* Renders a horizontal board at floor level — closes the bottom row of
|
||||
* a cubby (or the base of a bookshelf) so items can host on a real
|
||||
* surface rather than the open floor. No-op for `wall-shelf` /
|
||||
* `open-rack` where the structure has no enclosed bottom cell.
|
||||
*/
|
||||
withBottom: z.boolean().default(false),
|
||||
|
||||
bracketStyle: z.enum(['minimal', 'industrial', 'hidden']).default('minimal'),
|
||||
color: z.string().default('#a07050'),
|
||||
|
||||
// Paintable surface — same shape walls / slabs / stairs use. The default
|
||||
// is unset (renders as the off-white `DEFAULT_SHELF_MATERIAL`); paint
|
||||
// mode writes the chosen catalog material here. Keeping the same field
|
||||
// names (`material` / `materialPreset`) lets the existing
|
||||
// `buildSurfaceMaterialPatch` helpers in `material-paint.ts` work
|
||||
// unchanged once `'shelf'` is added to `MaterialTarget`.
|
||||
material: MaterialSchema.optional(),
|
||||
materialPreset: z.string().optional(),
|
||||
})
|
||||
|
||||
export type ShelfNode = z.infer<typeof ShelfNode>
|
||||
|
||||
@@ -253,16 +253,23 @@ export const createNodesAction = (
|
||||
|
||||
nextNodes[newNode.id] = newNode
|
||||
|
||||
// 2. Update the Parent's children list
|
||||
// 2. Update the Parent's children list. We append to ANY container
|
||||
// parent (kind has `children` in its schema) — if the field is
|
||||
// present but undefined (e.g. an old saved scene from before the
|
||||
// kind gained children), we initialise to `[]` first so the
|
||||
// reparenting goes through. Without this, hosting items on an
|
||||
// old shelf (v1, before `children` was added) silently no-ops:
|
||||
// the item is reparented to the shelf but the shelf's children
|
||||
// array is never updated, so `ParametricNodeRenderer` doesn't
|
||||
// mount it and the item "disappears".
|
||||
if (effectiveParentId && nextNodes[effectiveParentId]) {
|
||||
const parent = nextNodes[effectiveParentId]
|
||||
|
||||
// Type Guard: Check if the parent node is a container that supports children
|
||||
if ('children' in parent && Array.isArray(parent.children)) {
|
||||
if ('children' in parent) {
|
||||
const existing = (parent as { children?: unknown }).children
|
||||
const children = Array.isArray(existing) ? (existing as AnyNodeId[]) : []
|
||||
nextNodes[effectiveParentId] = {
|
||||
...parent,
|
||||
// Use Set to prevent duplicate IDs if createNode is called twice
|
||||
children: Array.from(new Set([...parent.children, newNode.id])) as any, // We don't verify child types here
|
||||
children: Array.from(new Set([...children, newNode.id])) as any,
|
||||
}
|
||||
}
|
||||
} else if (!effectiveParentId) {
|
||||
@@ -442,20 +449,31 @@ export const updateNodesAction = (
|
||||
const oldParentId = currentNode.parentId as AnyNodeId | null
|
||||
if (oldParentId && nextNodes[oldParentId]) {
|
||||
const oldParent = nextNodes[oldParentId] as AnyContainerNode
|
||||
const oldChildren = Array.isArray((oldParent as { children?: unknown }).children)
|
||||
? (oldParent as { children: AnyNodeId[] }).children
|
||||
: []
|
||||
nextNodes[oldParent.id] = {
|
||||
...oldParent,
|
||||
children: oldParent.children.filter((childId) => childId !== id),
|
||||
children: oldChildren.filter((childId) => childId !== id),
|
||||
} as AnyNode
|
||||
parentsToUpdate.add(oldParent.id)
|
||||
}
|
||||
|
||||
// 2. Add to new parent
|
||||
// 2. Add to new parent. Defensive against parents that don't yet
|
||||
// carry a `children` array — older saved scenes can predate the
|
||||
// schema field on a particular kind (shelf v1 → v2 added one),
|
||||
// and a spread of `undefined` here throws and aborts the entire
|
||||
// `set` callback. Initialising to `[]` matches what the schema's
|
||||
// default would have produced.
|
||||
const newParentId = data.parentId as AnyNodeId | null
|
||||
if (newParentId && nextNodes[newParentId]) {
|
||||
const newParent = nextNodes[newParentId] as AnyContainerNode
|
||||
const newChildren = Array.isArray((newParent as { children?: unknown }).children)
|
||||
? (newParent as { children: AnyNodeId[] }).children
|
||||
: []
|
||||
nextNodes[newParent.id] = {
|
||||
...newParent,
|
||||
children: Array.from(new Set([...newParent.children, id])),
|
||||
children: Array.from(new Set([...newChildren, id])),
|
||||
} as AnyNode
|
||||
parentsToUpdate.add(newParent.id)
|
||||
}
|
||||
|
||||
@@ -342,6 +342,16 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
|
||||
patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id])
|
||||
}
|
||||
|
||||
// Shelf v2: hosting was added in this migration cycle. Older shelves
|
||||
// (saved before the schema gained `children`) need the field
|
||||
// initialised so `createNode(item, shelfId)` finds an array to
|
||||
// append the child id to — without this the host item ends up
|
||||
// orphaned (parented in scene state but not in the shelf's
|
||||
// children list, so the renderer doesn't mount it).
|
||||
if (node.type === 'shelf' && !Array.isArray(node.children)) {
|
||||
patchedNodes[id] = { ...node, children: [] }
|
||||
}
|
||||
|
||||
if (node.type === 'roof') {
|
||||
patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user