diff --git a/apps/editor/public/icons/shelf.png b/apps/editor/public/icons/shelf.png new file mode 100644 index 00000000..84845573 Binary files /dev/null and b/apps/editor/public/icons/shelf.png differ diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts index e2e76ec5..5e5f71e3 100644 --- a/packages/core/src/schema/material.ts +++ b/packages/core/src/schema/material.ts @@ -51,6 +51,7 @@ export const MaterialTarget = z.enum([ 'ceiling', 'door', 'window', + 'shelf', ]) export type MaterialTarget = z.infer diff --git a/packages/core/src/schema/nodes/shelf.ts b/packages/core/src/schema/nodes/shelf.ts index c5d19e27..58d7af5e 100644 --- a/packages/core/src/schema/nodes/shelf.ts +++ b/packages/core/src/schema/nodes/shelf.ts @@ -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 diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 401385bb..2c5768a3 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -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) } diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 48615bbf..39f28c00 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -342,6 +342,16 @@ function migrateNodes(nodes: Record): Record { 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]) } diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx index 6196d0ce..d639fe2f 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx @@ -1,12 +1,13 @@ 'use client' -import { type ShelfNode, useScene } from '@pascal-app/core' +import { type AnyNodeId, type ShelfNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Layers } from 'lucide-react' -import { memo, useCallback, useState } from 'react' +import Image from 'next/image' +import { memo, useCallback, useEffect, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import useEditor from './../../../../../store/use-editor' import { InlineRenameInput } from './inline-rename-input' -import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' +import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node' import { TreeNodeActions } from './tree-node-actions' interface ShelfTreeNodeProps { @@ -16,10 +17,11 @@ interface ShelfTreeNodeProps { } /** - * Sidebar tree entry for shelf. Mirrors spawn-tree-node's shape so the - * existing tree-node-wrapper / selection / hover / rename plumbing all work - * unchanged. Phase 4 derives this row generically from - * `definition.presentation` — until then, one file per kind. + * Sidebar tree entry for shelf. Mirrors `item-tree-node`'s shape so the + * shelf's hosted items list as collapsible children — same pattern items + * use for their nested items. The shelf has its own `children: ItemNode[`id`]` + * field on the schema; items reparent into it via `def.surfaces` + the + * placement coordinator's shelf strategy. */ export const ShelfTreeNode = memo(function ShelfTreeNode({ nodeId, @@ -27,12 +29,37 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({ isLast, }: ShelfTreeNodeProps) { const [isEditing, setIsEditing] = useState(false) + const [expanded, setExpanded] = useState(true) const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false) + const children = useScene( + useShallow((s) => (s.nodes[nodeId] as ShelfNode | undefined)?.children ?? []), + ) const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) const isHovered = useViewer((state) => state.hoveredId === nodeId) const setSelection = useViewer((state) => state.setSelection) const setHoveredId = useViewer((state) => state.setHoveredId) + // Expand when a descendant is selected — same imperative subscription + // the item tree-node uses, so we don't re-render when unrelated + // selection-state ticks. + useEffect(() => { + return useViewer.subscribe((state) => { + const { selectedIds } = state.selection + if (selectedIds.length === 0) return + const nodes = useScene.getState().nodes + for (const id of selectedIds) { + let current = nodes[id as AnyNodeId] + while (current?.parentId) { + if (current.parentId === nodeId) { + setExpanded(true) + return + } + current = nodes[current.parentId as AnyNodeId] + } + } + }) + }, [nodeId]) + const handleClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation() @@ -49,13 +76,24 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({ [nodeId, setSelection], ) + const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId]) + const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId]) + const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId]) + const handleToggle = useCallback(() => setExpanded((prev) => !prev), []) + const handleStartEditing = useCallback(() => setIsEditing(true), []) + const handleStopEditing = useCallback(() => setIsEditing(false), []) + + const hasChildren = children.length > 0 + return ( } depth={depth} - expanded={false} - hasChildren={false} - icon={} + expanded={expanded} + hasChildren={hasChildren} + icon={ + + } isHovered={isHovered} isLast={isLast} isSelected={isSelected} @@ -65,16 +103,26 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({ defaultName="Shelf" isEditing={isEditing} nodeId={nodeId} - onStartEditing={() => setIsEditing(true)} - onStopEditing={() => setIsEditing(false)} + onStartEditing={handleStartEditing} + onStopEditing={handleStopEditing} /> } nodeId={nodeId} onClick={handleClick} - onDoubleClick={() => focusTreeNode(nodeId)} - onMouseEnter={() => setHoveredId(nodeId)} - onMouseLeave={() => setHoveredId(null)} - onToggle={() => {}} - /> + onDoubleClick={handleDoubleClick} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + onToggle={handleToggle} + > + {hasChildren && + children.map((childId, index) => ( + + ))} + ) }) diff --git a/packages/nodes/src/shelf/__tests__/geometry.test.ts b/packages/nodes/src/shelf/__tests__/geometry.test.ts index aa8732a1..1383f674 100644 --- a/packages/nodes/src/shelf/__tests__/geometry.test.ts +++ b/packages/nodes/src/shelf/__tests__/geometry.test.ts @@ -1,14 +1,14 @@ import { describe, expect, test } from 'bun:test' import type { Mesh } from 'three' -import { buildShelfGeometry } from '../geometry' +import { buildShelfGeometry, shelfRowSurfaceYs } from '../geometry' import { ShelfNode } from '../schema' -describe('buildShelfGeometry', () => { - test('returns a Group with named meshes for top + brackets (minimal style)', () => { - const node = ShelfNode.parse({ bracketStyle: 'minimal' }) +describe('buildShelfGeometry — wall-shelf', () => { + test('returns a Group with one board + two brackets (default v1 shape)', () => { + const node = ShelfNode.parse({}) const group = buildShelfGeometry(node) const names = group.children.map((c) => c.name) - expect(names).toContain('shelf-top') + expect(names).toContain('shelf-board-0') expect(names).toContain('shelf-bracket-left') expect(names).toContain('shelf-bracket-right') expect(group.children.length).toBe(3) @@ -18,32 +18,29 @@ describe('buildShelfGeometry', () => { const node = ShelfNode.parse({ bracketStyle: 'hidden' }) const group = buildShelfGeometry(node) expect(group.children.length).toBe(1) - expect(group.children[0]!.name).toBe('shelf-top') + expect(group.children[0]!.name).toBe('shelf-board-0') }) - test('top board y-center matches height + thickness/2', () => { + test('top board y-center matches height + thickness/2 (v1 semantic preserved)', () => { const node = ShelfNode.parse({ height: 1.0, thickness: 0.05 }) const group = buildShelfGeometry(node) - const top = group.children.find((c) => c.name === 'shelf-top') as Mesh | undefined + const top = group.children.find((c) => c.name === 'shelf-board-0') as Mesh | undefined expect(top).toBeDefined() expect(top!.position.y).toBeCloseTo(1.0 + 0.025) }) - test('brackets are inset from the shelf ends and run from the floor to the top', () => { - const node = ShelfNode.parse({ width: 1.5, height: 0.8 }) + test('rows > 1 produces multiple boards evenly spaced from height/rows to height', () => { + const node = ShelfNode.parse({ rows: 3, height: 1.8, thickness: 0.04 }) const group = buildShelfGeometry(node) - const left = group.children.find((c) => c.name === 'shelf-bracket-left') as Mesh | undefined - const right = group.children.find((c) => c.name === 'shelf-bracket-right') as Mesh | undefined - expect(left).toBeDefined() - expect(right).toBeDefined() - // Left bracket sits at negative X, right at positive X. - expect(left!.position.x).toBeLessThan(0) - expect(right!.position.x).toBeGreaterThan(0) - // Brackets rise from floor (y = bracketHeight/2 ≈ 0.4 for height 0.8). - expect(left!.position.y).toBeCloseTo(0.4) + const boards = group.children.filter((c) => c.name.startsWith('shelf-board-')) as Mesh[] + expect(boards.length).toBe(3) + const ys = boards.map((b) => b.position.y).sort((a, b) => a - b) + expect(ys[0]).toBeCloseTo(0.6 + 0.02) + expect(ys[1]).toBeCloseTo(1.2 + 0.02) + expect(ys[2]).toBeCloseTo(1.8 + 0.02) }) - test('industrial bracket style produces thicker bracket boxes', () => { + test('industrial bracket style produces wider bracket boxes', () => { const minimal = buildShelfGeometry(ShelfNode.parse({ bracketStyle: 'minimal', depth: 0.4 })) const industrial = buildShelfGeometry( ShelfNode.parse({ bracketStyle: 'industrial', depth: 0.4 }), @@ -52,24 +49,155 @@ describe('buildShelfGeometry', () => { const industrialBracket = industrial.children.find( (c) => c.name === 'shelf-bracket-left', ) as Mesh - // industrial bracket box should have a wider X (bracketWidth) than minimal const minimalParams = (minimalBracket.geometry as any).parameters const industrialParams = (industrialBracket.geometry as any).parameters expect(industrialParams.width).toBeGreaterThan(minimalParams.width) }) +}) - test('top board material is built from node.color (not the default)', () => { - const defaultColor = ( - buildShelfGeometry(ShelfNode.parse({})).children.find((c) => c.name === 'shelf-top') as Mesh +describe('buildShelfGeometry — bookshelf', () => { + test('emits side panels + multiple boards', () => { + const node = ShelfNode.parse({ style: 'bookshelf', rows: 4, height: 1.8 }) + const group = buildShelfGeometry(node) + const names = group.children.map((c) => c.name) + expect(names).toContain('shelf-side-left') + expect(names).toContain('shelf-side-right') + expect(names.filter((n) => n.startsWith('shelf-board-')).length).toBe(4) + }) + + test('withBack adds a back panel', () => { + const without = buildShelfGeometry(ShelfNode.parse({ style: 'bookshelf', withBack: false })) + const withBack = buildShelfGeometry(ShelfNode.parse({ style: 'bookshelf', withBack: true })) + expect(without.children.find((c) => c.name === 'shelf-back')).toBeUndefined() + expect(withBack.children.find((c) => c.name === 'shelf-back')).toBeDefined() + }) + + test('columns > 1 adds vertical dividers', () => { + const node = ShelfNode.parse({ style: 'bookshelf', columns: 3 }) + const group = buildShelfGeometry(node) + const dividers = group.children.filter((c) => c.name.startsWith('shelf-divider-col-')) + expect(dividers.length).toBe(2) + }) + + test('withSides=false replaces side panels with corner posts', () => { + const node = ShelfNode.parse({ style: 'bookshelf', withSides: false }) + const group = buildShelfGeometry(node) + const names = group.children.map((c) => c.name) + expect(names).not.toContain('shelf-side-left') + expect(names.filter((n) => n.startsWith('shelf-post-')).length).toBe(4) + }) +}) + +describe('buildShelfGeometry — open-rack', () => { + test('always emits four corner posts', () => { + const node = ShelfNode.parse({ style: 'open-rack' }) + const group = buildShelfGeometry(node) + const posts = group.children.filter((c) => c.name.startsWith('shelf-post-')) + expect(posts.length).toBe(4) + }) + + test('withBack adds horizontal cross-braces top + bottom', () => { + const node = ShelfNode.parse({ style: 'open-rack', withBack: true }) + const group = buildShelfGeometry(node) + const braces = group.children.filter((c) => c.name.startsWith('shelf-brace-h-')) + expect(braces.length).toBe(2) + }) +}) + +describe('buildShelfGeometry — cubby', () => { + test('grid of cubbies emits sides + back + boards + dividers', () => { + const node = ShelfNode.parse({ style: 'cubby', rows: 3, columns: 3 }) + const group = buildShelfGeometry(node) + const names = group.children.map((c) => c.name) + expect(names).toContain('shelf-side-left') + expect(names).toContain('shelf-side-right') + expect(names).toContain('shelf-back') + // Boards: rows = 3 → 3 horizontal boards. + expect(names.filter((n) => n.startsWith('shelf-board-')).length).toBe(3) + // Dividers: (columns − 1) per row → 2 × 3 = 6. + expect(names.filter((n) => /^shelf-divider-\d+-\d+$/.test(n)).length).toBe(6) + }) + + test('withBottom adds a floor board at y = thickness/2', () => { + const node = ShelfNode.parse({ style: 'cubby', withBottom: true, thickness: 0.05 }) + const group = buildShelfGeometry(node) + const bottom = group.children.find((c) => c.name === 'shelf-board-bottom') as Mesh | undefined + expect(bottom).toBeDefined() + expect(bottom!.position.y).toBeCloseTo(0.025) + }) + + test('withBottom=false omits the floor board', () => { + const node = ShelfNode.parse({ style: 'cubby', withBottom: false }) + const group = buildShelfGeometry(node) + expect(group.children.find((c) => c.name === 'shelf-board-bottom')).toBeUndefined() + }) +}) + +describe('shelfRowSurfaceYs — withBottom', () => { + test('prepends y = thickness when cubby has withBottom on', () => { + const node = ShelfNode.parse({ + style: 'cubby', + withBottom: true, + rows: 3, + height: 1.8, + thickness: 0.05, + }) + const ys = shelfRowSurfaceYs(node) + expect(ys.length).toBe(4) + expect(ys[0]).toBeCloseTo(0.05) // top of bottom board + }) + + test('ignores withBottom for wall-shelf', () => { + const node = ShelfNode.parse({ style: 'wall-shelf', withBottom: true }) + const ys = shelfRowSurfaceYs(node) + expect(ys.length).toBe(1) + }) +}) + +describe('shelfRowSurfaceYs', () => { + test('returns one Y per row, all at board top', () => { + const node = ShelfNode.parse({ rows: 3, height: 1.8, thickness: 0.04 }) + const ys = shelfRowSurfaceYs(node) + expect(ys.length).toBe(3) + // Y values are sorted ascending and represent top-of-board. + expect(ys[0]).toBeCloseTo(0.6 + 0.04) + expect(ys[1]).toBeCloseTo(1.2 + 0.04) + expect(ys[2]).toBeCloseTo(1.8 + 0.04) + }) + + test('rows=1 returns single Y at v1 top-of-board (height + thickness)', () => { + const node = ShelfNode.parse({ height: 0.9, thickness: 0.04 }) + const ys = shelfRowSurfaceYs(node) + expect(ys.length).toBe(1) + expect(ys[0]).toBeCloseTo(0.94) + }) +}) + +describe('material application', () => { + test('default shelf material is the canonical white shared with walls / stairs', () => { + const board = buildShelfGeometry(ShelfNode.parse({})).children.find( + (c) => c.name === 'shelf-board-0', + ) as Mesh + const material = board.material as { color: { getHexString(): string } } + // DEFAULT_SHELF_MATERIAL is '#ffffff' — same as DEFAULT_WALL_MATERIAL / + // DEFAULT_STAIR_MATERIAL so an unpainted shelf reads as the same + // "default white" surface the rest of the structural kinds use. + expect(material.color.getHexString().toLowerCase()).toBe('ffffff') + }) + + test('user-set material is applied (not the default)', () => { + const defaultBoard = ( + buildShelfGeometry(ShelfNode.parse({})).children.find( + (c) => c.name === 'shelf-board-0', + ) as Mesh ).material as { color: { getHexString(): string } } - const custom = ( - buildShelfGeometry(ShelfNode.parse({ color: '#112233' })).children.find( - (c) => c.name === 'shelf-top', - ) as Mesh + const customBoard = ( + buildShelfGeometry( + ShelfNode.parse({ + material: { properties: { color: '#112233' } }, + }), + ).children.find((c) => c.name === 'shelf-board-0') as Mesh ).material as { color: { getHexString(): string } } - // Three.js applies color space conversion (sRGB → linear) for materials. - // The materials should differ — that's the property we care about, not the - // exact channel values. - expect(custom.color.getHexString()).not.toBe(defaultColor.color.getHexString()) + expect(customBoard.color.getHexString()).not.toBe(defaultBoard.color.getHexString()) }) }) diff --git a/packages/nodes/src/shelf/__tests__/schema.test.ts b/packages/nodes/src/shelf/__tests__/schema.test.ts index 473eb197..6f2cdddc 100644 --- a/packages/nodes/src/shelf/__tests__/schema.test.ts +++ b/packages/nodes/src/shelf/__tests__/schema.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test' import { ShelfNode } from '../schema' describe('ShelfNode schema', () => { - test('parses with all defaults applied', () => { + test('parses with v2 defaults applied (v1 wall-shelf visual preserved)', () => { const parsed = ShelfNode.parse({}) expect(parsed.type).toBe('shelf') expect(parsed.id).toMatch(/^shelf_/) @@ -10,8 +10,37 @@ describe('ShelfNode schema', () => { expect(parsed.depth).toBe(0.3) expect(parsed.thickness).toBe(0.04) expect(parsed.height).toBe(0.9) + expect(parsed.style).toBe('wall-shelf') + expect(parsed.rows).toBe(1) + expect(parsed.columns).toBe(1) + expect(parsed.withBack).toBe(false) + expect(parsed.withSides).toBe(true) + expect(parsed.withBottom).toBe(false) expect(parsed.bracketStyle).toBe('minimal') - expect(parsed.color).toBe('#a07050') + // material / materialPreset default to undefined — the geometry + // builder uses `DEFAULT_SHELF_MATERIAL` when both are unset. + expect(parsed.material).toBeUndefined() + expect(parsed.materialPreset).toBeUndefined() + }) + + test('v1-shaped input parses cleanly (forward compatibility)', () => { + // v1 scenes carried { width, depth, thickness, height, bracketStyle, + // color }. v2 dropped `color` in favour of `material` + `materialPreset`; + // unknown keys on a Zod object pass through and are stripped by + // `.parse`. New v2 fields fall back to defaults that reproduce v1 + // visuals (style=wall-shelf, rows=1) so saved scenes load unchanged. + const parsed = ShelfNode.parse({ + width: 1.5, + depth: 0.35, + thickness: 0.05, + height: 1.2, + bracketStyle: 'industrial', + color: '#553322', + }) + expect(parsed.style).toBe('wall-shelf') + expect(parsed.rows).toBe(1) + expect(parsed.bracketStyle).toBe('industrial') + expect((parsed as { color?: string }).color).toBeUndefined() }) test('accepts user-supplied dimensions within bounds', () => { @@ -21,9 +50,24 @@ describe('ShelfNode schema', () => { thickness: 0.06, height: 1.4, bracketStyle: 'industrial', + style: 'bookshelf', + rows: 4, + columns: 2, }) expect(parsed.width).toBe(2.0) - expect(parsed.bracketStyle).toBe('industrial') + expect(parsed.style).toBe('bookshelf') + expect(parsed.rows).toBe(4) + expect(parsed.columns).toBe(2) + }) + + test('rejects unknown style', () => { + expect(() => ShelfNode.parse({ style: 'mystery' })).toThrow() + }) + + test('rejects rows above 8 and below 1', () => { + expect(() => ShelfNode.parse({ rows: 0 })).toThrow() + expect(() => ShelfNode.parse({ rows: 9 })).toThrow() + expect(() => ShelfNode.parse({ rows: 1.5 })).toThrow() }) test('rejects width below min', () => { diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts index e384ea2a..9bfeb230 100644 --- a/packages/nodes/src/shelf/definition.ts +++ b/packages/nodes/src/shelf/definition.ts @@ -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 = { kind: 'shelf', - schemaVersion: 1, + schemaVersion: 2, schema: ShelfNode, category: 'furnish', @@ -15,14 +16,23 @@ export const shelfDefinition: NodeDefinition = { 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 = { 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 = { // mount and rebuild on dirty; the 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 = { 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.', }, } diff --git a/packages/nodes/src/shelf/floorplan-move.ts b/packages/nodes/src/shelf/floorplan-move.ts new file mode 100644 index 00000000..ee43750d --- /dev/null +++ b/packages/nodes/src/shelf/floorplan-move.ts @@ -0,0 +1,101 @@ +import { + type AnyNodeId, + type FloorplanMoveTarget, + type FloorplanMoveTargetSession, + type ShelfNode, + sceneRegistry, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { snapPointToGrid, triggerSFX, type WallPlanPoint } from '@pascal-app/editor' +import type * as THREE from 'three' + +/** + * 2D floor-plan move handler for shelf — behaves like items in the + * floor-plan move flow: + * + * - Each pointermove writes the absolute world-plan target position + * to `useLiveTransforms` (so the 2D layer's `effectiveNode` override + * re-renders the SVG at the new position) AND mutates the + * registered mesh's `position` directly (so the 3D view mirrors the + * drag in real time). + * - On commit, `canCommit` writes the final position to `scene` as a + * single tracked update — the dispatcher's snapshot-diff captures + * it as one undoable step. + * - On any non-commit unmount (escape, abnormal teardown) the + * dispatcher clears `useLiveTransforms` for affectedIds, so the 3D + * visual snaps back to the reverted scene state. + * + * Unlike `slab` / `ceiling`, this writes the **absolute** position (the + * shelf carries its location in `node.position`, not in polygon + * vertices). The 2D layer's override branch for `shelf` mirrors `item`'s + * world-plan handling. + */ +const GRID_STEP = 0.5 + +export const shelfFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { + const shelfId = node.id as AnyNodeId + const originalPosition: [number, number, number] = [...node.position] as [number, number, number] + const originalRotationY = node.rotation[1] ?? 0 + let lastPosition: [number, number, number] = originalPosition + let lastSnapKey: string | null = null + + const session: FloorplanMoveTargetSession = { + affectedIds: [shelfId], + apply({ planPoint, modifiers }) { + const snapped: WallPlanPoint = modifiers.shiftKey + ? ([planPoint[0], planPoint[1]] as WallPlanPoint) + : snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP) + const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]] + lastPosition = next + + // Grid-snap SFX on cell crossings — matches the 3D `MoveSlabTool` + // and the placement coordinators. Item / slab / wall flows fire + // the same cue, so the shelf following along is the expected UX. + const snapKey = `${snapped[0]},${snapped[1]}` + if (snapKey !== lastSnapKey) { + triggerSFX('sfx:grid-snap') + lastSnapKey = snapKey + } + // Live preview — same shape items use. `useLiveTransforms.position` + // holds world-plan coords (level-local); the 2D `FloorplanRegistryLayer` + // override for `shelf` reads this and re-renders the SVG entry. + useLiveTransforms.getState().set(shelfId, { + position: next, + rotation: originalRotationY, + }) + // Mirror to the 3D mesh so split-view follows the cursor without + // touching scene state per tick (no CSG, no React re-render of + // geometry — same imperative live-drag pattern as the 3D + // `MoveRegistryNodeTool`). + const mesh = sceneRegistry.nodes.get(shelfId) as THREE.Object3D | undefined + if (mesh) mesh.position.set(next[0], next[1], next[2]) + }, + canCommit() { + const live = useScene.getState().nodes[shelfId] as ShelfNode | undefined + if (!live || live.type !== 'shelf') return false + if (lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2]) { + return false + } + // Side-effect commit — write final position. The dispatcher's + // snapshot-diff right after `canCommit` returns picks this up as + // the single tracked change for undo. `useLiveTransforms` is + // cleared in the dispatcher's commit path (and in our + // abnormal-unmount cleanup) so the 3D view reconciles to the + // committed scene position on the next render. + useScene.getState().updateNodes([ + { + id: shelfId, + data: { position: lastPosition }, + }, + ]) + // The shelf's geometry doesn't depend on `position` (it's the + // group's transform, not the build inputs), but we mark dirty so + // any sibling-aware system that does watch position re-runs. + useScene.getState().markDirty(shelfId) + useLiveTransforms.getState().clear(shelfId) + return true + }, + } + return session +} diff --git a/packages/nodes/src/shelf/floorplan.ts b/packages/nodes/src/shelf/floorplan.ts index a9dc9f33..b146afc8 100644 --- a/packages/nodes/src/shelf/floorplan.ts +++ b/packages/nodes/src/shelf/floorplan.ts @@ -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, } } diff --git a/packages/nodes/src/shelf/geometry.ts b/packages/nodes/src/shelf/geometry.ts index 417ef4cf..ec94e699 100644 --- a/packages/nodes/src/shelf/geometry.ts +++ b/packages/nodes/src/shelf/geometry.ts @@ -1,65 +1,331 @@ -import { BoxGeometry, type BufferGeometry, Color, Group, Mesh, MeshStandardMaterial } from 'three' +import { getMaterialPresetByRef } from '@pascal-app/core' +import { + applyMaterialPresetToMaterials, + createMaterial, + DEFAULT_SHELF_MATERIAL, +} from '@pascal-app/viewer' +import { BoxGeometry, FrontSide, Group, Mesh, MeshStandardMaterial } from 'three' import type { ShelfNode } from './schema' /** * Pure shelf geometry builder. Takes a `ShelfNode` and returns a `Group` - * containing the top board + bracket meshes — no React, no scene access. + * with named child meshes — `shelf-board-`, `shelf-side-`, + * `shelf-back`, `shelf-divider--`, `shelf-bracket-`, + * `shelf-post-`, `shelf-brace-` — so other systems can + * address them by name if needed. * - * Two reasons this is its own pure function (not inlined into the renderer): + * The function is pure: no React, no scene access, no `useScene`. Every + * piece of geometry is determined by `node` alone. This lets the parity + * test in `__tests__/geometry.test.ts` compare BufferGeometry vertex / + * index arrays directly, and lets AI-generated nodes follow the same + * shape with no editor-specific knowledge. * - * 1. **Geometry parity testing.** Phase 4's pixel-diff test compares the - * BufferGeometry vertex/index arrays returned by this function against - * a snapshot — pure functions are trivial to test, JSX is not. - * 2. **AI-authored nodes.** This is the file an AI is most likely to - * generate. Pure, deterministic, takes typed input, returns Three.js - * primitives. No React or registry knowledge required. + * Materials: the kind exposes a single paintable surface via + * `node.material` / `node.materialPreset` — same shape walls / slabs / + * stairs use. When neither is set, every mesh shares the + * `DEFAULT_SHELF_MATERIAL` (off-white). When the user paints, the + * library preset's properties land on a cloned material here. The cache + * key includes the preset / material signature so paint changes + * invalidate without stomping unrelated shelves. + * + * Style dispatch lives at the top of the function; each style helper + * mutates the same `group`. */ +const shelfMaterialCache = new Map() + +function getShelfMaterial(node: ShelfNode): MeshStandardMaterial { + const cacheKey = JSON.stringify({ + material: node.material ?? null, + materialPreset: node.materialPreset ?? null, + }) + const cached = shelfMaterialCache.get(cacheKey) + if (cached) return cached + + const preset = getMaterialPresetByRef(node.materialPreset) + const material = preset + ? new MeshStandardMaterial() + : node.material + ? createMaterial(node.material).clone() + : DEFAULT_SHELF_MATERIAL.clone() + + if (preset) { + applyMaterialPresetToMaterials(material, preset) + } + + material.side = FrontSide + material.depthWrite = true + material.needsUpdate = true + + shelfMaterialCache.set(cacheKey, material) + return material +} + export function buildShelfGeometry(node: ShelfNode): Group { const group = new Group() group.name = 'shelf-geometry' - const material = new MeshStandardMaterial({ - color: new Color(node.color), - roughness: 0.65, - metalness: 0.05, - }) + const material = getShelfMaterial(node) - // Top board, centered at (0, height + thickness/2, 0) - const topBoardGeometry: BufferGeometry = new BoxGeometry(node.width, node.thickness, node.depth) - const topBoard = new Mesh(topBoardGeometry, material) - topBoard.name = 'shelf-top' - topBoard.position.set(0, node.height + node.thickness / 2, 0) - group.add(topBoard) - - // Brackets — two below the top, near each end. Style varies the look. - for (const sign of [-1, 1] as const) { - const bracket = buildBracket(node, sign, material) - if (bracket) { - bracket.name = `shelf-bracket-${sign === -1 ? 'left' : 'right'}` - group.add(bracket) - } + switch (node.style) { + case 'wall-shelf': + buildWallShelf(group, node, material) + break + case 'bookshelf': + buildBookshelf(group, node, material) + break + case 'open-rack': + buildOpenRack(group, node, material) + break + case 'cubby': + buildCubby(group, node, material) + break } return group } -function buildBracket(node: ShelfNode, sign: -1 | 1, material: MeshStandardMaterial): Mesh | null { - // 'hidden' style: skip visible brackets entirely. - if (node.bracketStyle === 'hidden') return null +// ─── Style helpers ─────────────────────────────────────────────────── + +/** + * Wall-shelf: open boards held by end brackets. `rows > 1` stacks + * evenly-spaced boards from `height/rows` up to `height`. Brackets + * span from floor to the topmost board. + */ +function buildWallShelf(group: Group, node: ShelfNode, material: MeshStandardMaterial) { + for (const y of boardCenterYs(node)) { + const board = new Mesh(new BoxGeometry(node.width, node.thickness, node.depth), material) + board.name = `shelf-board-${boardRowIndex(node, y)}` + board.position.set(0, y, 0) + group.add(board) + } + + if (node.bracketStyle === 'hidden') return const inset = Math.min(0.12, node.width / 6) - const x = sign * (node.width / 2 - inset) - // Bracket height: from floor (0) up to the underside of the top board. const bracketHeight = Math.max(0.01, node.height) - const bracketWidth = node.bracketStyle === 'industrial' ? Math.max(0.04, node.depth * 0.2) : Math.max(0.02, node.depth * 0.12) const bracketDepth = node.bracketStyle === 'industrial' ? node.depth * 0.95 : node.depth * 0.7 - const geometry = new BoxGeometry(bracketWidth, bracketHeight, bracketDepth) - const mesh = new Mesh(geometry, material) - mesh.position.set(x, bracketHeight / 2, 0) - return mesh + for (const sign of [-1, 1] as const) { + const bracket = new Mesh(new BoxGeometry(bracketWidth, bracketHeight, bracketDepth), material) + bracket.name = `shelf-bracket-${sign === -1 ? 'left' : 'right'}` + bracket.position.set(sign * (node.width / 2 - inset), bracketHeight / 2, 0) + group.add(bracket) + } +} + +/** + * Bookshelf: full-height cabinet with side panels, multiple shelf boards, + * optional back, and inner vertical dividers if `columns > 1`. When + * `withSides === false`, side panels become slim corner posts (a rack + * silhouette). + */ +function buildBookshelf(group: Group, node: ShelfNode, material: MeshStandardMaterial) { + const unitHeight = node.height + node.thickness + const innerWidth = node.withSides ? node.width - 2 * node.thickness : node.width + + // Top + bottom + intermediate boards + for (const y of boardCenterYs(node)) { + const board = new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), material) + board.name = `shelf-board-${boardRowIndex(node, y)}` + board.position.set(0, y, 0) + group.add(board) + } + + if (node.withBottom) { + const bottom = new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), material) + bottom.name = 'shelf-board-bottom' + bottom.position.set(0, node.thickness / 2, 0) + group.add(bottom) + } + + // Side panels (or corner posts) — span the full unit height. + if (node.withSides) { + for (const sign of [-1, 1] as const) { + const side = new Mesh(new BoxGeometry(node.thickness, unitHeight, node.depth), material) + side.name = `shelf-side-${sign === -1 ? 'left' : 'right'}` + side.position.set(sign * (node.width / 2 - node.thickness / 2), unitHeight / 2, 0) + group.add(side) + } + } else { + addCornerPosts(group, node, material, unitHeight, 'rack') + } + + if (node.withBack) { + const back = new Mesh(new BoxGeometry(innerWidth, unitHeight, node.thickness), material) + back.name = 'shelf-back' + back.position.set(0, unitHeight / 2, -(node.depth / 2 - node.thickness / 2)) + group.add(back) + } + + // Vertical dividers between columns + if (node.columns > 1) { + const colStep = innerWidth / node.columns + for (let c = 1; c < node.columns; c++) { + const x = -innerWidth / 2 + c * colStep + const divider = new Mesh(new BoxGeometry(node.thickness, unitHeight, node.depth), material) + divider.name = `shelf-divider-col-${c}` + divider.position.set(x, unitHeight / 2, 0) + group.add(divider) + } + } +} + +/** + * Open-rack: four corner posts + horizontal boards. `withBack` adds an + * X-brace on the back face for stability. `withSides` / `bracketStyle` + * are ignored (the rack defines its own posts). + */ +function buildOpenRack(group: Group, node: ShelfNode, material: MeshStandardMaterial) { + const unitHeight = node.height + node.thickness + const innerWidth = node.width + const boardThickness = Math.max(0.02, node.thickness * 0.8) + + for (const y of boardCenterYs(node)) { + const board = new Mesh(new BoxGeometry(innerWidth, boardThickness, node.depth), material) + board.name = `shelf-board-${boardRowIndex(node, y)}` + board.position.set(0, y, 0) + group.add(board) + } + + addCornerPosts(group, node, material, unitHeight, 'rack') + + if (node.withBack) { + const braceThickness = Math.max(0.015, node.thickness * 0.6) + for (const y of [boardThickness, unitHeight - boardThickness] as const) { + const brace = new Mesh( + new BoxGeometry(node.width - braceThickness * 2, braceThickness, braceThickness), + material, + ) + brace.name = `shelf-brace-h-${y < unitHeight / 2 ? 'bottom' : 'top'}` + brace.position.set(0, y, -(node.depth / 2 - braceThickness / 2)) + group.add(brace) + } + } +} + +/** + * Cubby: closed grid of pigeonholes. Always has sides + back + horizontal + * boards + vertical dividers. `withBack` / `withSides` are forced on + * because the cubby shape requires them. + */ +function buildCubby(group: Group, node: ShelfNode, material: MeshStandardMaterial) { + const unitHeight = node.height + node.thickness + const innerWidth = node.width - 2 * node.thickness + + for (const y of boardCenterYs(node)) { + const board = new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), material) + board.name = `shelf-board-${boardRowIndex(node, y)}` + board.position.set(0, y, 0) + group.add(board) + } + + if (node.withBottom) { + const bottom = new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), material) + bottom.name = 'shelf-board-bottom' + bottom.position.set(0, node.thickness / 2, 0) + group.add(bottom) + } + + for (const sign of [-1, 1] as const) { + const side = new Mesh(new BoxGeometry(node.thickness, unitHeight, node.depth), material) + side.name = `shelf-side-${sign === -1 ? 'left' : 'right'}` + side.position.set(sign * (node.width / 2 - node.thickness / 2), unitHeight / 2, 0) + group.add(side) + } + + const back = new Mesh(new BoxGeometry(innerWidth, unitHeight, node.thickness), material) + back.name = 'shelf-back' + back.position.set(0, unitHeight / 2, -(node.depth / 2 - node.thickness / 2)) + group.add(back) + + if (node.columns > 1) { + const colStep = innerWidth / node.columns + const rowStep = node.height / node.rows + for (let r = 0; r < node.rows; r++) { + const cellBottomY = node.thickness + r * rowStep + const cellTopY = node.thickness + (r + 1) * rowStep + const dividerHeight = cellTopY - cellBottomY - node.thickness + if (dividerHeight <= 0) continue + for (let c = 1; c < node.columns; c++) { + const x = -innerWidth / 2 + c * colStep + const divider = new Mesh( + new BoxGeometry(node.thickness, dividerHeight, node.depth), + material, + ) + divider.name = `shelf-divider-${r}-${c}` + divider.position.set(x, cellBottomY + dividerHeight / 2, 0) + group.add(divider) + } + } + } +} + +// ─── Shared helpers ────────────────────────────────────────────────── + +/** + * Y positions of every shelf board's vertical center, in floor-to-top + * order. The topmost board's center is at `height + thickness/2`; lower + * boards are evenly spaced from `height/rows` to `height` (matching the + * legacy v1 wall-shelf where the only board is at `height + thickness/2`). + */ +function boardCenterYs(node: ShelfNode): number[] { + const ys: number[] = [] + const step = node.height / node.rows + for (let r = 1; r <= node.rows; r++) { + ys.push(r * step + node.thickness / 2) + } + return ys +} + +/** Convert a Y position back to its row index (0 = bottom row). */ +function boardRowIndex(node: ShelfNode, y: number): number { + const step = node.height / node.rows + return Math.round((y - node.thickness / 2) / step) - 1 +} + +/** + * Place four corner posts at `(±width/2 ∓ inset, height/2, ±depth/2 ∓ inset)`. + * Used by `open-rack` and the no-sides variant of `bookshelf`. + */ +function addCornerPosts( + group: Group, + node: ShelfNode, + material: MeshStandardMaterial, + unitHeight: number, + postStyle: 'rack' | 'leg', +) { + const postThickness = + postStyle === 'rack' ? Math.max(0.025, node.thickness * 1.5) : Math.max(0.02, node.thickness) + const inset = postThickness / 2 + for (const xSign of [-1, 1] as const) { + for (const zSign of [-1, 1] as const) { + const post = new Mesh(new BoxGeometry(postThickness, unitHeight, postThickness), material) + post.name = `shelf-post-${xSign === -1 ? 'l' : 'r'}${zSign === -1 ? 'b' : 'f'}` + post.position.set( + xSign * (node.width / 2 - inset), + unitHeight / 2, + zSign * (node.depth / 2 - inset), + ) + group.add(post) + } + } +} + +/** + * Y of the top surface of each shelf row (top of the board). Used by + * `capabilities.surfaces.custom` so items host at the right Y on + * whichever row the cursor targets. When `withBottom` is on (cubby / + * bookshelf only — wall-shelf and open-rack ignore the toggle), the + * top of the bottom board is exposed as an additional surface so items + * can host in the lowest cell. + */ +export function shelfRowSurfaceYs(node: ShelfNode): number[] { + const ys = boardCenterYs(node).map((y) => y + node.thickness / 2) + const bottomApplies = node.style === 'cubby' || node.style === 'bookshelf' + if (node.withBottom && bottomApplies) ys.unshift(node.thickness) + return ys } diff --git a/packages/nodes/src/shelf/parametrics.ts b/packages/nodes/src/shelf/parametrics.ts index 5a8fe6d9..cf19d743 100644 --- a/packages/nodes/src/shelf/parametrics.ts +++ b/packages/nodes/src/shelf/parametrics.ts @@ -2,12 +2,71 @@ import type { ParametricDescriptor } from '@pascal-app/core' import type { ShelfNode } from './schema' /** - * Inspector descriptor for the parametric shelf. Drives both the auto-derived - * inspector UI (Phase 4) and the AI/MCP `create_shelf` / `update_shelf` tools - * with bounded JSON-schema parameters (also Phase 4). + * Inspector descriptor for the parametric shelf. Drives both the + * auto-derived inspector UI and the AI/MCP `create_shelf` / + * `update_shelf` tools with bounded JSON-schema parameters. + * + * Fields are grouped by intent: Style first (what kind of shelf), then + * Topology (rows / columns / back / sides / bottom + wall-shelf bracket + * style), then Dimensions. Surface material is paint-tray driven (same + * flow as walls / slabs / stairs) and intentionally not surfaced here. */ export const shelfParametrics: ParametricDescriptor = { groups: [ + { + label: 'Style', + fields: [ + { + key: 'style', + kind: 'enum', + options: ['wall-shelf', 'bookshelf', 'open-rack', 'cubby'], + }, + ], + }, + { + label: 'Topology', + fields: [ + { key: 'rows', kind: 'number', min: 1, max: 8, step: 1 }, + // Columns only meaningful for kinds with vertical dividers. + { + key: 'columns', + kind: 'number', + min: 1, + max: 6, + step: 1, + visibleIf: (n) => n.style === 'bookshelf' || n.style === 'cubby', + }, + // Sides toggle only applies to bookshelf (cubby always on, the + // others use their own post structure). + { + key: 'withSides', + kind: 'boolean', + visibleIf: (n) => n.style === 'bookshelf', + }, + // Back toggle only applies to bookshelf and open-rack (cubby + // always has a back, wall-shelf has no back). + { + key: 'withBack', + kind: 'boolean', + visibleIf: (n) => n.style === 'bookshelf' || n.style === 'open-rack', + }, + // Bottom toggle only applies to bookshelf and cubby — closes + // the lowest cell with a floor board so items can host there. + { + key: 'withBottom', + kind: 'boolean', + visibleIf: (n) => n.style === 'bookshelf' || n.style === 'cubby', + }, + // Bracket style only matters for wall-shelf — the other styles + // structure themselves through sides / posts / dividers. + { + key: 'bracketStyle', + kind: 'enum', + options: ['minimal', 'industrial', 'hidden'], + visibleIf: (n) => n.style === 'wall-shelf', + }, + ], + }, { label: 'Dimensions', fields: [ @@ -17,12 +76,5 @@ export const shelfParametrics: ParametricDescriptor = { { key: 'height', kind: 'number', unit: 'm', min: 0.05, max: 2.5, step: 0.05 }, ], }, - { - label: 'Style', - fields: [ - { key: 'bracketStyle', kind: 'enum', options: ['minimal', 'industrial', 'hidden'] }, - { key: 'color', kind: 'color' }, - ], - }, ], } diff --git a/packages/nodes/src/shelf/preview.tsx b/packages/nodes/src/shelf/preview.tsx index 14d76ed1..0f5214d3 100644 --- a/packages/nodes/src/shelf/preview.tsx +++ b/packages/nodes/src/shelf/preview.tsx @@ -1,50 +1,85 @@ 'use client' -import { useMemo } from 'react' -import { Color } from 'three' +import { useEffect, useMemo } from 'react' +import type { MeshStandardMaterial } from 'three' +import { buildShelfGeometry } from './geometry' import type { ShelfNode } from './schema' /** - * Translucent preview of a shelf. Used by: - * - The placement tool's cursor (ShelfTool) — at the cursor position - * - The move tool (MoveRegistryNodeTool) — at the drag target position + * Translucent preview of a shelf — used by the placement tool's cursor + * and the registry mover. Defers to `buildShelfGeometry` so the preview + * shape stays in lockstep with whatever the actual shelf will render, + * then walks the result, **clones** each mesh's material, and mutates + * the clone for a translucent ghost. * - * Renders the same primitives as the actual ShelfRenderer, but with - * `transparent: true, opacity: 0.5` so the user can see what they're - * placing/moving without it being a hard solid. + * Cloning is non-negotiable: `getShelfMaterial` caches the default + * `MeshStandardMaterial` instance in a module-scoped map keyed on + * `material` / `materialPreset`, so every unpainted shelf in the scene + * shares the same material. Mutating `mat.transparent = true` here + * would leak into every committed shelf and render them all see-through. + * + * Building the full geometry tree per-frame would be wasteful, so we + * memoize the group + dispose the per-mesh material clones on unmount. + * Geometry is intentionally NOT disposed — `buildShelfGeometry` creates + * fresh BufferGeometry per call, but if a future revision returns + * cached geometry, disposing here would corrupt later renders. Keep the + * cleanup focused on what the preview itself created (the clones). + * + * **Raycast is disabled** on every preview mesh: the cursor follows the + * shelf, so without this the preview itself would intercept the cursor + * ray, `grid:move` would stop firing as soon as the preview entered the + * cursor cone, and the placement tool would lose track of the cursor's + * grid position. Disabling raycast lets the ray pass through the ghost + * to the grid plane below. */ const ShelfPreview = ({ node }: { node: ShelfNode }) => { - const color = useMemo(() => new Color(node.color), [node.color]) - const topY = node.height + node.thickness / 2 + const built = useMemo(() => buildShelfGeometry(node), [node]) - const inset = Math.min(0.12, node.width / 6) - const bracketHeight = Math.max(0.01, node.height) - const bracketWidth = - node.bracketStyle === 'industrial' - ? Math.max(0.04, node.depth * 0.2) - : Math.max(0.02, node.depth * 0.12) - const bracketDepth = node.bracketStyle === 'industrial' ? node.depth * 0.95 : node.depth * 0.7 + useEffect(() => { + const cloned: MeshStandardMaterial[] = [] + built.traverse((obj) => { + // Skip pointer events: see component-level note above. + ;(obj as unknown as { raycast: () => void }).raycast = () => {} - return ( - - - - - - {node.bracketStyle !== 'hidden' && ( - <> - - - - - - - - - - )} - - ) + // `Mesh.material` is typed as `Material | Material[]` upstream; + // every shelf board carries a `MeshStandardMaterial` from + // `getShelfMaterial`. Access through a structural cast keeps the + // assignment well-typed without depending on the Mesh union. + const mesh = obj as { + material?: MeshStandardMaterial | MeshStandardMaterial[] + } + if (!mesh.material) return + + const cloneAndSwap = (mat: MeshStandardMaterial): MeshStandardMaterial => { + const c = mat.clone() + c.transparent = true + c.opacity = 0.5 + c.depthWrite = false + cloned.push(c) + return c + } + + if (Array.isArray(mesh.material)) { + mesh.material = mesh.material.map(cloneAndSwap) + } else { + mesh.material = cloneAndSwap(mesh.material) + } + }) + + return () => { + // Dispose only the clones we made — never the shared cached + // material returned by `getShelfMaterial`, which other shelves in + // the scene still reference. Geometry is left alone for the same + // reason; the builder may move to a cached strategy in future. + for (const c of cloned) c.dispose() + built.traverse((obj) => { + const mesh = obj as { geometry?: { dispose: () => void } } + mesh.geometry?.dispose() + }) + } + }, [built]) + + return } export default ShelfPreview diff --git a/packages/nodes/src/shelf/tool.tsx b/packages/nodes/src/shelf/tool.tsx index f65998ee..af0ea78a 100644 --- a/packages/nodes/src/shelf/tool.tsx +++ b/packages/nodes/src/shelf/tool.tsx @@ -1,8 +1,11 @@ 'use client' import { + type AnyNode, + type EventSuffix, emitter, type GridEvent, + type NodeEvent, ShelfNode, sceneRegistry, snapPointToGrid, @@ -12,23 +15,56 @@ import { triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import { type Group, Vector3 } from 'three' +import { shelfDefinition } from './definition' import ShelfPreview from './preview' const worldVector = new Vector3() const GRID_STEP = 0.5 /** - * Convert a click into the shelf's commit position (level-local). The shelf - * node's `position` field is stored relative to its level parent, so we - * project the click point into the level's local frame before storing. - * - * Cursor display uses event.localPosition (building-local) — see onGridMove. + * Click-trigger kinds: when the user clicks ANY of these during shelf + * placement, we commit at the latest cursor position. R3F's pointer + * raycaster dispatches to the closest intersected mesh, so a click on + * a wall / slab / item / etc. would otherwise never reach `grid:click` + * — the placement would silently drop. Listening for each kind's click + * (and committing at the snapshot of the last `grid:move` cursor) + * mirrors the fix in `MoveRegistryNodeTool`. */ -function getLevelLocalPosition(levelId: string, event: GridEvent): [number, number, number] { +const CLICK_TRIGGER_KINDS = [ + 'shelf', + 'item', + 'slab', + 'ceiling', + 'wall', + 'fence', + 'column', + 'roof', + 'roof-segment', + 'stair', + 'stair-segment', +] as const + +type ClickTriggerEvent = GridEvent | NodeEvent + +/** + * Convert the latest cursor world hit into level-local coords for the + * commit `position`. The cursor's local position from `event.localPosition` + * (building-local) needs to come back through the level's world transform + * so the shelf is stored in its parent's frame. + */ +function getLevelLocalPosition( + levelId: string, + event: GridEvent | NodeEvent, +): [number, number, number] { const levelObject = sceneRegistry.nodes.get(levelId) if (!levelObject) { - const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP) - return [sx, event.localPosition[1], sz] + const local = (event as GridEvent).localPosition + if (local) { + const [sx, sz] = snapPointToGrid([local[0], local[2]], GRID_STEP) + return [sx, local[1] ?? 0, sz] + } + const [sx, sz] = snapPointToGrid([event.position[0], event.position[2]], GRID_STEP) + return [sx, event.position[1], sz] } worldVector.set(event.position[0], event.position[1], event.position[2]) levelObject.updateWorldMatrix(true, false) @@ -42,21 +78,39 @@ const ShelfTool = () => { const cursorRef = useRef(null) const previousSnapRef = useRef<[number, number] | null>(null) - // Default-shaped shelf for the placement preview. Same shape the move tool - // uses (both reach for `shelfDefinition.preview`) so placement and move - // look identical. + // Default-shaped shelf for the placement preview. Pulls from + // `shelfDefinition.defaults()` so the preview matches what the commit + // will actually create (a 1m × 0.5m × 1.8m cubby 3x2 with closed back + // + bottom). The schema-level defaults are deliberately the v1 + // wall-shelf — those exist so v1 scenes loading under v2 keep their + // original visual; the placement default is a separate, user-facing + // choice that lives on the definition. const previewNode = useMemo( - () => ShelfNode.parse({ name: 'Shelf', position: [0, 0, 0], rotation: [0, 0, 0] }), + () => + ShelfNode.parse({ + ...shelfDefinition.defaults(), + name: 'Shelf', + position: [0, 0, 0], + rotation: [0, 0, 0], + }), [], ) useEffect(() => { if (!activeLevelId) return previousSnapRef.current = null + /** + * Snapped cursor position from the latest `grid:move`. Used as the + * commit position for ANY click variant (grid or node), so clicks + * on vertical surfaces (other shelves, walls, etc.) still commit + * where the user was visually targeting. + */ + const lastCursorRef: { current: [number, number, number] | null } = { current: null } const onGridMove = (event: GridEvent) => { const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP) cursorRef.current?.position.set(sx, event.localPosition[1], sz) + lastCursorRef.current = [sx, event.localPosition[1], sz] const prev = previousSnapRef.current if (!prev || prev[0] !== sx || prev[1] !== sz) { @@ -65,9 +119,14 @@ const ShelfTool = () => { } } - const onGridClick = (event: GridEvent) => { - const position = getLevelLocalPosition(activeLevelId, event) + const commitAtCursor = (event: ClickTriggerEvent) => { + // Prefer the latest `grid:move` cursor snapshot; fall back to + // projecting the click event into level-local coords if no + // grid:move has fired yet (e.g. cursor entered via a node hit + // first). Both paths apply the same grid snap. + const position = lastCursorRef.current ?? getLevelLocalPosition(activeLevelId, event) const shelf = ShelfNode.parse({ + ...shelfDefinition.defaults(), name: 'Shelf', position, rotation: [0, 0, 0], @@ -75,22 +134,39 @@ const ShelfTool = () => { useScene.getState().createNode(shelf, activeLevelId) useViewer.getState().setSelection({ selectedIds: [shelf.id] }) triggerSFX('sfx:structure-build') + + const native = (event as { nativeEvent?: unknown }).nativeEvent + if ( + native && + typeof (native as { stopPropagation?: () => void }).stopPropagation === 'function' + ) { + ;(native as { stopPropagation: () => void }).stopPropagation() + } + const direct = (event as { stopPropagation?: () => void }).stopPropagation + if (typeof direct === 'function') direct.call(event) } emitter.on('grid:move', onGridMove) - emitter.on('grid:click', onGridClick) + emitter.on('grid:click', commitAtCursor) + type SuffixedKey = `${K}:${EventSuffix}` + type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]> + for (const kind of CLICK_TRIGGER_KINDS) { + const key = `${kind}:click` as ClickKey + emitter.on(key, commitAtCursor as never) + } return () => { emitter.off('grid:move', onGridMove) - emitter.off('grid:click', onGridClick) + emitter.off('grid:click', commitAtCursor) + for (const kind of CLICK_TRIGGER_KINDS) { + const key = `${kind}:click` as ClickKey + emitter.off(key, commitAtCursor as never) + } } }, [activeLevelId]) if (!activeLevelId) return null - // Cursor preview: defers to the shared ShelfPreview component used by the - // move tool too. Position is updated imperatively via the ref; no React - // state, no re-render cycles. return ( diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 26d099fb..9dc95bb7 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -322,6 +322,7 @@ export const DEFAULT_WINDOW_MATERIAL = new THREE.MeshStandardMaterial({ }) export const DEFAULT_CEILING_MATERIAL = createDefaultMaterial('#f5f5dc', 0.95) export const DEFAULT_ROOF_MATERIAL = createDefaultMaterial('#808080', 0.85) +export const DEFAULT_SHELF_MATERIAL = createDefaultMaterial('#ffffff', 0.9) export const DEFAULT_STAIR_MATERIAL = createDefaultMaterial('#ffffff', 0.9) export function disposeMaterial(material: THREE.Material): void {