From 101341d98d45ee54e9db80bb22a66d962ef1a5a5 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 16 Jun 2026 10:59:59 -0400 Subject: [PATCH 1/6] feat(paint-slots): paintable slots on the procedural shelf (phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proves the unified (nodeId, slotId) slot contract on a procedural generator, beyond items and walls. A shelf now exposes three paintable slots — shelves / frame / back — painted through the same PaintCapability dispatch and the same node.slots: Record shape items use. Foundation (shared, reusable by future procedural kinds): - core: SlotDeclaration type + capabilities.slots(node) registry declaration; GeometryContext gains `materials` so a pure builder can resolve scene: slot refs without importing useScene. - viewer GeometrySystem: threads the scene material library into every builder ctx, and re-dirties (bypassing the geometryKey skip) any geometry node that references a scene material when that material changes — so editing a custom colour propagates to every shelf using it, matching items. Shelf: - schema: slots: Record (mirrors ItemNode). - geometry: per-slot material resolution (slot override -> legacy whole-shelf -> declared default colour); every mesh stamped with userData.slotId; DEFAULT_SHELF_MATERIAL retired (declared default gives identical off-white). - paint.ts: PaintCapability (resolveRole from userData.slotId, scene-material commit for one-off colours, preview restricted to __fromGeometry meshes so hosted items aren't ghosted, getEffectiveMaterial incl. legacy fallback). - definition: paint + slots capabilities; slots folded into geometryKey. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/registry/index.ts | 1 + packages/core/src/registry/types.ts | 33 +++ packages/core/src/schema/nodes/shelf.ts | 7 + packages/nodes/src/shelf/definition.ts | 9 +- packages/nodes/src/shelf/floorplan-move.ts | 2 +- packages/nodes/src/shelf/geometry.ts | 184 +++++++++------ packages/nodes/src/shelf/paint.ts | 218 ++++++++++++++++++ packages/nodes/src/shelf/preview.tsx | 18 +- packages/nodes/src/shelf/slots.ts | 35 +++ .../src/systems/geometry/geometry-system.tsx | 35 ++- 10 files changed, 455 insertions(+), 87 deletions(-) create mode 100644 packages/nodes/src/shelf/paint.ts create mode 100644 packages/nodes/src/shelf/slots.ts diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 8dcfc58f..5eb4939a 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -103,6 +103,7 @@ export type { ScalableConfig, SceneApi, SelectableConfig, + SlotDeclaration, SnapPointKind, SnappableConfig, SnapServicesLike, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index b007bf27..bbeca31a 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -2,6 +2,7 @@ import type { ComponentType } from 'react' import type { BufferGeometry, Object3D } from 'three' import type { ZodObject, z } from 'zod' import type { MaterialSchema } from '../schema/material' +import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material' import type { AnyNode, AnyNodeId } from '../schema/types' import type { HandleList } from './handles' import type { CloneNodesIntoOptions, Subtree } from './subtree' @@ -43,6 +44,15 @@ export type GeometryContext = { * has cheap access to siblings through `ctx.siblings`). */ levelData?: unknown + /** + * The scene's shared material library (`useScene.materials`), passed so a + * pure geometry builder can resolve `scene:` slot refs without importing + * `useScene`. Populated by `` for every `def.geometry` call; + * undefined for `def.floorplan`. `library:` refs resolve against the + * static catalog and need no store, so builders only consult this for + * `scene:` refs. + */ + materials?: Record /** * Optional view state — only populated for `def.floorplan` builders. The * 2D floor-plan layer surfaces selection / hover here so kinds can vary @@ -1023,6 +1033,16 @@ export type Capabilities = { */ ceilingCut?: CeilingCutCapability paint?: PaintCapability + /** + * Declares the kind's paintable slots — the `{ slotId, label, default }` + * contract shared by items (scanned from the GLB) and procedural kinds + * (declared here). Procedural generators tag their emitted geometry with + * `userData.slotId` and resolve each slot's material from + * `node.slots[slotId]` → this declaration's `default` → role colour. The + * declaration is a function of the node because a kind's slot set can depend + * on its parameters (a shelf has a `back` slot only when it has a back). + */ + slots?: (node: AnyNode) => SlotDeclaration[] /** * Kind is placed by clicking on a wall (door, window). When set, the * floor-plan layer lets wall background clicks pass through during @@ -1114,6 +1134,19 @@ export type Capabilities = { * the `selectedMaterialTarget` round-trip, the paint-mode toolbar. * Kinds with no paint behaviour omit `paint`. */ +/** + * One paintable slot a kind exposes. `slotId` is the stable key written into + * `node.slots`; `label` is the human name (sentence case). `default` is the + * slot's fallback appearance when no override is set — either a `MaterialRef` + * (`library:` / `scene:`) or a `#rrggbb` colour. Mirrors the shape + * items derive from their GLB material names. + */ +export type SlotDeclaration = { + slotId: string + label: string + default?: string +} + export type PaintCapability = { /** * Resolve which logical surface the user clicked. Returns `null` diff --git a/packages/core/src/schema/nodes/shelf.ts b/packages/core/src/schema/nodes/shelf.ts index 58d7af5e..3e9b74b2 100644 --- a/packages/core/src/schema/nodes/shelf.ts +++ b/packages/core/src/schema/nodes/shelf.ts @@ -86,6 +86,13 @@ export const ShelfNode = BaseNode.extend({ // unchanged once `'shelf'` is added to `MaterialTarget`. material: MaterialSchema.optional(), materialPreset: z.string().optional(), + + // Per-slot material overrides, mirroring `ItemNode.slots`. Key = slot id + // (`shelves` / `frame` / `back`, see `shelfSlots`), value = a `MaterialRef` + // string (`library:` or `scene:`). Absent slot = fall back to the + // legacy whole-shelf `material` / `materialPreset`, then the registry slot + // default. A dangling ref renders the default (never blocks). + slots: z.record(z.string(), z.string()).optional(), }) export type ShelfNode = z.infer diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts index bcb97f04..0ac3f986 100644 --- a/packages/nodes/src/shelf/definition.ts +++ b/packages/nodes/src/shelf/definition.ts @@ -4,8 +4,10 @@ import { buildShelfFloorplan } from './floorplan' import { shelfResizeAffordance, shelfRotateAffordance } from './floorplan-affordances' import { shelfFloorplanMoveTarget } from './floorplan-move' import { buildShelfGeometry, shelfRowSurfaceYs } from './geometry' +import { shelfPaint } from './paint' import { shelfParametrics } from './parametrics' import { ShelfNode } from './schema' +import { shelfSlots } from './slots' const SIDE_HANDLE_OFFSET = 0.18 const HEIGHT_HANDLE_OFFSET = 0.22 @@ -155,8 +157,8 @@ export const shelfDefinition: NodeDefinition = { withBottom: true, bracketStyle: 'minimal', // material / materialPreset left undefined — geometry falls back to - // `DEFAULT_SHELF_MATERIAL` (off-white), and paint mode writes the - // chosen catalog material into these fields. + // the per-slot off-white default, and slot paint mode writes chosen + // catalog materials into `slots`. }), capabilities: { @@ -183,6 +185,8 @@ export const shelfDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, + paint: shelfPaint, + slots: (n) => shelfSlots(n as ShelfNode), // Slab elevation lift via the generic `` — a // shelf sitting over a raised slab visually rests on top of it. floorPlaced: { @@ -233,6 +237,7 @@ export const shelfDefinition: NodeDefinition = { s.bracketStyle, s.material, s.materialPreset, + JSON.stringify(s.slots ?? null), ]) }, floorplan: buildShelfFloorplan, diff --git a/packages/nodes/src/shelf/floorplan-move.ts b/packages/nodes/src/shelf/floorplan-move.ts index c72eabfd..d616d942 100644 --- a/packages/nodes/src/shelf/floorplan-move.ts +++ b/packages/nodes/src/shelf/floorplan-move.ts @@ -109,7 +109,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget = ({ node, }, canCommit() { const live = useScene.getState().nodes[shelfId] as ShelfNode | undefined - if (!live || live.type !== 'shelf') return false + if (live?.type !== 'shelf') return false return !(lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2]) }, } diff --git a/packages/nodes/src/shelf/geometry.ts b/packages/nodes/src/shelf/geometry.ts index f71a3d47..56dbfa1f 100644 --- a/packages/nodes/src/shelf/geometry.ts +++ b/packages/nodes/src/shelf/geometry.ts @@ -1,14 +1,15 @@ -import { getMaterialPresetByRef } from '@pascal-app/core' +import { type GeometryContext, getMaterialPresetByRef } from '@pascal-app/core' import { applyMaterialPresetToMaterials, createDefaultMaterial, createMaterial, - DEFAULT_SHELF_MATERIAL, type RenderShading, + resolveMaterialRef, } from '@pascal-app/viewer' -import { BoxGeometry, FrontSide, Group, type Material, Mesh } from 'three' +import { BoxGeometry, Group, type Material, Mesh } from 'three' import { sanitizeShelfDimensions } from './dimensions' import type { ShelfNode } from './schema' +import { SHELF_SLOT_DEFAULT_COLOR, type ShelfSlotId } from './slots' /** * Pure shelf geometry builder. Takes a `ShelfNode` and returns a `Group` @@ -23,75 +24,72 @@ import type { ShelfNode } from './schema' * index arrays directly, and lets AI-generated nodes follow the same * shape with no editor-specific knowledge. * - * 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. + * Materials: the kind exposes per-slot paintable surfaces through + * `node.slots`, while `node.material` / `node.materialPreset` remain as + * legacy whole-shelf fallbacks. Every generated mesh is tagged with the + * slot it belongs to so paint mode can target shelves, frame, or back. * * Style dispatch lives at the top of the function; each style helper * mutates the same `group`. */ -type ShelfMaterial = Material & { - depthWrite: boolean +type ShelfSlotMaterials = Record + +function getShelfSlotMaterial( + node: ShelfNode, + slotId: ShelfSlotId, + materials: GeometryContext['materials'], + shading: RenderShading, +): Material { + const ref = node.slots?.[slotId] + if (ref) { + const resolved = resolveMaterialRef(ref, materials, shading) + if (resolved) return resolved + } + // Legacy whole-shelf paint applies to every slot when set (no per-slot override). + if (node.materialPreset) { + const preset = getMaterialPresetByRef(node.materialPreset) + if (preset) { + const base = createDefaultMaterial('#ffffff', 0.5, shading) + applyMaterialPresetToMaterials(base, preset) + return base + } + } + if (node.material) return createMaterial(node.material, shading) + return createDefaultMaterial(SHELF_SLOT_DEFAULT_COLOR, 0.9, shading) } -const shelfMaterialCache = new Map() - -function getShelfMaterial(node: ShelfNode, shading: RenderShading): Material { - const cacheKey = JSON.stringify({ - shading, - 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 - ? createDefaultMaterial('#ffffff', 0.5, shading) - : node.material - ? createMaterial(node.material, shading).clone() - : DEFAULT_SHELF_MATERIAL(shading).clone() - - if (preset) { - applyMaterialPresetToMaterials(material, preset) - } - - const shelfMaterial = material as ShelfMaterial - shelfMaterial.side = FrontSide - shelfMaterial.depthWrite = true - shelfMaterial.needsUpdate = true - - shelfMaterialCache.set(cacheKey, material) - return material +function stampShelfSlot(mesh: Mesh, slotId: ShelfSlotId): Mesh { + mesh.userData.slotId = slotId + return mesh } export function buildShelfGeometry( rawNode: ShelfNode, - _ctx?: unknown, + ctx?: GeometryContext, shading: RenderShading = 'rendered', ): Group { const node = sanitizeShelfDimensions(rawNode) const group = new Group() group.name = 'shelf-geometry' - const material = getShelfMaterial(node, shading) + const materials: ShelfSlotMaterials = { + shelves: getShelfSlotMaterial(node, 'shelves', ctx?.materials, shading), + frame: getShelfSlotMaterial(node, 'frame', ctx?.materials, shading), + back: getShelfSlotMaterial(node, 'back', ctx?.materials, shading), + } switch (node.style) { case 'wall-shelf': - buildWallShelf(group, node, material) + buildWallShelf(group, node, materials) break case 'bookshelf': - buildBookshelf(group, node, material) + buildBookshelf(group, node, materials) break case 'open-rack': - buildOpenRack(group, node, material) + buildOpenRack(group, node, materials) break case 'cubby': - buildCubby(group, node, material) + buildCubby(group, node, materials) break } @@ -112,9 +110,12 @@ export function buildShelfGeometry( * evenly-spaced boards from `height/rows` up to `height`. Brackets * span from floor to the topmost board. */ -function buildWallShelf(group: Group, node: ShelfNode, material: Material) { +function buildWallShelf(group: Group, node: ShelfNode, materials: ShelfSlotMaterials) { for (const y of boardCenterYs(node)) { - const board = new Mesh(new BoxGeometry(node.width, node.thickness, node.depth), material) + const board = stampShelfSlot( + new Mesh(new BoxGeometry(node.width, node.thickness, node.depth), materials.shelves), + 'shelves', + ) board.name = `shelf-board-${boardRowIndex(node, y)}` board.position.set(0, y, 0) group.add(board) @@ -131,7 +132,10 @@ function buildWallShelf(group: Group, node: ShelfNode, material: Material) { const bracketDepth = node.bracketStyle === 'industrial' ? node.depth * 0.95 : node.depth * 0.7 for (const sign of [-1, 1] as const) { - const bracket = new Mesh(new BoxGeometry(bracketWidth, bracketHeight, bracketDepth), material) + const bracket = stampShelfSlot( + new Mesh(new BoxGeometry(bracketWidth, bracketHeight, bracketDepth), materials.frame), + 'frame', + ) bracket.name = `shelf-bracket-${sign === -1 ? 'left' : 'right'}` bracket.position.set(sign * (node.width / 2 - inset), bracketHeight / 2, 0) group.add(bracket) @@ -144,20 +148,26 @@ function buildWallShelf(group: Group, node: ShelfNode, material: Material) { * `withSides === false`, side panels become slim corner posts (a rack * silhouette). */ -function buildBookshelf(group: Group, node: ShelfNode, material: Material) { +function buildBookshelf(group: Group, node: ShelfNode, materials: ShelfSlotMaterials) { 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) + const board = stampShelfSlot( + new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), materials.shelves), + 'shelves', + ) 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) + const bottom = stampShelfSlot( + new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), materials.shelves), + 'shelves', + ) bottom.name = 'shelf-board-bottom' bottom.position.set(0, node.thickness / 2, 0) group.add(bottom) @@ -166,17 +176,23 @@ function buildBookshelf(group: Group, node: ShelfNode, material: Material) { // 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) + const side = stampShelfSlot( + new Mesh(new BoxGeometry(node.thickness, unitHeight, node.depth), materials.frame), + 'frame', + ) 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') + addCornerPosts(group, node, materials.frame, unitHeight, 'rack') } if (node.withBack) { - const back = new Mesh(new BoxGeometry(innerWidth, unitHeight, node.thickness), material) + const back = stampShelfSlot( + new Mesh(new BoxGeometry(innerWidth, unitHeight, node.thickness), materials.back), + 'back', + ) back.name = 'shelf-back' back.position.set(0, unitHeight / 2, -(node.depth / 2 - node.thickness / 2)) group.add(back) @@ -187,7 +203,10 @@ function buildBookshelf(group: Group, node: ShelfNode, material: Material) { 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) + const divider = stampShelfSlot( + new Mesh(new BoxGeometry(node.thickness, unitHeight, node.depth), materials.frame), + 'frame', + ) divider.name = `shelf-divider-col-${c}` divider.position.set(x, unitHeight / 2, 0) group.add(divider) @@ -200,26 +219,32 @@ function buildBookshelf(group: Group, node: ShelfNode, material: Material) { * 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: Material) { +function buildOpenRack(group: Group, node: ShelfNode, materials: ShelfSlotMaterials) { 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) + const board = stampShelfSlot( + new Mesh(new BoxGeometry(innerWidth, boardThickness, node.depth), materials.shelves), + 'shelves', + ) board.name = `shelf-board-${boardRowIndex(node, y)}` board.position.set(0, y, 0) group.add(board) } - addCornerPosts(group, node, material, unitHeight, 'rack') + addCornerPosts(group, node, materials.frame, 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, + const brace = stampShelfSlot( + new Mesh( + new BoxGeometry(node.width - braceThickness * 2, braceThickness, braceThickness), + materials.frame, + ), + 'frame', ) brace.name = `shelf-brace-h-${y < unitHeight / 2 ? 'bottom' : 'top'}` brace.position.set(0, y, -(node.depth / 2 - braceThickness / 2)) @@ -233,32 +258,44 @@ function buildOpenRack(group: Group, node: ShelfNode, material: Material) { * boards + vertical dividers. `withBack` / `withSides` are forced on * because the cubby shape requires them. */ -function buildCubby(group: Group, node: ShelfNode, material: Material) { +function buildCubby(group: Group, node: ShelfNode, materials: ShelfSlotMaterials) { 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) + const board = stampShelfSlot( + new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), materials.shelves), + 'shelves', + ) 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) + const bottom = stampShelfSlot( + new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), materials.shelves), + 'shelves', + ) 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) + const side = stampShelfSlot( + new Mesh(new BoxGeometry(node.thickness, unitHeight, node.depth), materials.frame), + 'frame', + ) 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) + const back = stampShelfSlot( + new Mesh(new BoxGeometry(innerWidth, unitHeight, node.thickness), materials.back), + 'back', + ) back.name = 'shelf-back' back.position.set(0, unitHeight / 2, -(node.depth / 2 - node.thickness / 2)) group.add(back) @@ -273,9 +310,9 @@ function buildCubby(group: Group, node: ShelfNode, material: Material) { 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, + const divider = stampShelfSlot( + new Mesh(new BoxGeometry(node.thickness, dividerHeight, node.depth), materials.frame), + 'frame', ) divider.name = `shelf-divider-${r}-${c}` divider.position.set(x, cellBottomY + dividerHeight / 2, 0) @@ -324,7 +361,10 @@ function addCornerPosts( 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) + const post = stampShelfSlot( + new Mesh(new BoxGeometry(postThickness, unitHeight, postThickness), material), + 'frame', + ) post.name = `shelf-post-${xSign === -1 ? 'l' : 'r'}${zSign === -1 ? 'b' : 'f'}` post.position.set( xSign * (node.width / 2 - inset), diff --git a/packages/nodes/src/shelf/paint.ts b/packages/nodes/src/shelf/paint.ts new file mode 100644 index 00000000..e6c8a564 --- /dev/null +++ b/packages/nodes/src/shelf/paint.ts @@ -0,0 +1,218 @@ +import { + type AnyNode, + type AnyNodeId, + generateSceneMaterialId, + type MaterialSchema, + type PaintCapability, + parseMaterialRef, + type SceneMaterial, + type SceneMaterialId, + type ShelfNode, + toSceneMaterialRef, + useScene, +} from '@pascal-app/core' +import { createMaterial, createMaterialFromPresetRef, useViewer } from '@pascal-app/viewer' +import type { Material, Mesh } from 'three' + +type ShelfSlotUserData = { + slotId?: string | null +} + +function deepEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true + if (typeof a !== typeof b) return false + if (a === null || b === null) return false + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false + for (let index = 0; index < a.length; index += 1) { + if (!deepEqual(a[index], b[index])) return false + } + return true + } + if (typeof a === 'object') { + const aRecord = a as Record + const bRecord = b as Record + const aKeys = Object.keys(aRecord) + const bKeys = Object.keys(bRecord) + if (aKeys.length !== bKeys.length) return false + for (const key of aKeys) { + if (!Object.hasOwn(bRecord, key)) return false + if (!deepEqual(aRecord[key], bRecord[key])) return false + } + return true + } + return false +} + +function resolveShelfSlotId(args: { hitObject?: { userData?: ShelfSlotUserData } }): string | null { + const slotId = args.hitObject?.userData?.slotId + return typeof slotId === 'string' ? slotId : null +} + +function buildShelfSlotsPatch( + node: ShelfNode, + role: string, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Partial { + const slots = { ...(node.slots ?? {}) } + if (material === undefined && materialPreset === undefined) { + delete slots[role] + return { slots } + } + if (materialPreset) { + slots[role] = materialPreset + return { slots } + } + return { slots } +} + +function findMatchingSceneMaterial( + materials: Record, + material: MaterialSchema, +): SceneMaterial | null { + for (const sceneMaterial of Object.values(materials)) { + if (deepEqual(sceneMaterial.material, material)) return sceneMaterial + } + return null +} + +function commitNewSceneMaterialAndSlots( + nodeId: AnyNodeId, + nextSlots: ShelfNode['slots'], + sceneMaterial: SceneMaterial, +): void { + // Creating the scene material and setting the slot ref are one logical + // edit, so apply both in a single `set` — zundo records one history entry, + // and one undo removes both the ref and its (now orphaned) material. + useScene.setState((state) => { + if (state.readOnly) return state + const currentNode = state.nodes[nodeId] + if (currentNode?.type !== 'shelf') return state + return { + materials: { ...state.materials, [sceneMaterial.id as SceneMaterialId]: sceneMaterial }, + nodes: { + ...state.nodes, + [nodeId]: { ...currentNode, slots: nextSlots } as AnyNode, + }, + } + }) + useScene.getState().markDirty(nodeId) +} + +function commitShelfPaint( + node: ShelfNode, + role: string, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): void { + const nodeId = node.id as AnyNodeId + const state = useScene.getState() + const currentNode = (state.nodes[nodeId] as ShelfNode | undefined) ?? node + let ref: string | undefined + let newSceneMaterial: SceneMaterial | null = null + + if (material === undefined && materialPreset === undefined) { + ref = undefined + } else if (materialPreset) { + ref = materialPreset + } else if (material) { + const existing = findMatchingSceneMaterial(state.materials, material) + if (existing) { + ref = toSceneMaterialRef(existing.id) + } else { + const id = generateSceneMaterialId() + newSceneMaterial = { + id, + name: `Material ${Object.keys(state.materials).length + 1}`, + material, + } + ref = toSceneMaterialRef(id) + } + } else { + return + } + + const nextSlots = { ...(currentNode.slots ?? {}) } + if (ref) nextSlots[role] = ref + else delete nextSlots[role] + + if (newSceneMaterial) { + commitNewSceneMaterialAndSlots(nodeId, nextSlots, newSceneMaterial) + return + } + + state.updateNode(nodeId, { slots: nextSlots } as Partial) +} + +function buildPreviewMaterial( + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Material | null { + const shading = useViewer.getState().shading + if (materialPreset) return createMaterialFromPresetRef(materialPreset, shading) + if (material) return createMaterial(material, shading) + return null +} + +function applyShelfPreview( + role: string, + root: import('three').Object3D, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): (() => void) | null { + const previewMaterial = buildPreviewMaterial(material, materialPreset) + if (!previewMaterial) return () => {} + + const restores: Array<() => void> = [] + root.traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + const userData = mesh.userData as ShelfSlotUserData & { __fromGeometry?: boolean } + // Only the shelf's own builder meshes — never hosted item children, whose + // GLB meshes can carry a colliding `userData.slotId` (slot_frame, etc.). + if (userData.__fromGeometry !== true) return + if (userData.slotId !== role) return + + const previous = mesh.material + mesh.material = previewMaterial + restores.push(() => { + mesh.material = previous + }) + }) + + if (restores.length === 0) return null + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) { + restores[index]?.() + } + } +} + +export const shelfPaint: PaintCapability = { + resolveRole: ({ hitObject }) => + resolveShelfSlotId({ hitObject: hitObject as { userData?: ShelfSlotUserData } }), + buildPatch: ({ node, role, material, materialPreset }) => + buildShelfSlotsPatch(node as ShelfNode, role, material, materialPreset) as Partial, + commit: ({ node, role, material, materialPreset }) => + commitShelfPaint(node as ShelfNode, role, material, materialPreset), + applyPreview: ({ role, root, material, materialPreset }) => + applyShelfPreview(role, root, material, materialPreset), + getEffectiveMaterial: ({ node, role }) => { + const shelf = node as ShelfNode + const parsed = parseMaterialRef(shelf.slots?.[role]) + if (parsed) { + if (parsed.kind === 'library') { + return { material: undefined, materialPreset: shelf.slots?.[role] } + } + const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId] + if (sceneMaterial) return { material: sceneMaterial.material, materialPreset: undefined } + } + // No (or dangling) slot ref — surface the legacy whole-shelf paint the + // geometry builder still falls back to, so the picker matches what renders. + if (shelf.materialPreset || shelf.material) { + return { material: shelf.material, materialPreset: shelf.materialPreset } + } + return null + }, +} diff --git a/packages/nodes/src/shelf/preview.tsx b/packages/nodes/src/shelf/preview.tsx index e069a065..f9fa8320 100644 --- a/packages/nodes/src/shelf/preview.tsx +++ b/packages/nodes/src/shelf/preview.tsx @@ -13,11 +13,9 @@ import type { ShelfNode } from './schema' * then walks the result, **clones** each mesh's material, and mutates * the clone for a translucent ghost. * - * Cloning is non-negotiable: `getShelfMaterial` caches the default - * material 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. + * Cloning is non-negotiable: shelf geometry may receive materials from + * shared viewer caches, so mutating `mat.transparent = true` here would + * leak into committed shelves using the same material. * * Building the full geometry tree per-frame would be wasteful, so we * memoize the group + dispose the per-mesh material clones on unmount. @@ -44,9 +42,9 @@ const ShelfPreview = ({ node }: { node: ShelfNode }) => { ;(obj as unknown as { raycast: () => void }).raycast = () => {} // `Mesh.material` is typed as `Material | Material[]` upstream; - // every shelf board carries a material from - // `getShelfMaterial`. Access through a structural cast keeps the - // assignment well-typed without depending on the Mesh union. + // every shelf board carries a material from the geometry builder. + // Access through a structural cast keeps the assignment well-typed + // without depending on the Mesh union. const mesh = obj as { material?: Material | Material[] } @@ -70,8 +68,8 @@ const ShelfPreview = ({ node }: { node: ShelfNode }) => { 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 + // material returned by the builder, which other shelves in the + // scene may 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) => { diff --git a/packages/nodes/src/shelf/slots.ts b/packages/nodes/src/shelf/slots.ts new file mode 100644 index 00000000..411eed96 --- /dev/null +++ b/packages/nodes/src/shelf/slots.ts @@ -0,0 +1,35 @@ +import type { SlotDeclaration } from '@pascal-app/core' +import type { ShelfNode } from './schema' + +export type ShelfSlotId = 'shelves' | 'frame' | 'back' + +// Visual parity with the retired DEFAULT_SHELF_MATERIAL (off-white). +export const SHELF_SLOT_DEFAULT_COLOR = '#ffffff' + +/** Map a builder mesh name to its slot id (null = not a paintable shelf part). */ +export function shelfSlotIdForMeshName(name: string): ShelfSlotId | null { + if (name.startsWith('shelf-board')) return 'shelves' + if (name === 'shelf-back') return 'back' + if ( + name.startsWith('shelf-side') || + name.startsWith('shelf-post') || + name.startsWith('shelf-divider') || + name.startsWith('shelf-bracket') || + name.startsWith('shelf-brace') + ) { + return 'frame' + } + return null +} + +/** Which slots a given shelf actually exposes (depends on style/flags). */ +export function shelfSlots(node: ShelfNode): SlotDeclaration[] { + const slots: SlotDeclaration[] = [ + { slotId: 'shelves', label: 'Shelves', default: SHELF_SLOT_DEFAULT_COLOR }, + ] + const hasFrame = !(node.style === 'wall-shelf' && node.bracketStyle === 'hidden') + if (hasFrame) slots.push({ slotId: 'frame', label: 'Frame', default: SHELF_SLOT_DEFAULT_COLOR }) + const hasBack = node.style === 'cubby' || (node.style === 'bookshelf' && node.withBack) + if (hasBack) slots.push({ slotId: 'back', label: 'Back', default: SHELF_SLOT_DEFAULT_COLOR }) + return slots +} diff --git a/packages/viewer/src/systems/geometry/geometry-system.tsx b/packages/viewer/src/systems/geometry/geometry-system.tsx index 3e6ddaf3..6b689711 100644 --- a/packages/viewer/src/systems/geometry/geometry-system.tsx +++ b/packages/viewer/src/systems/geometry/geometry-system.tsx @@ -58,6 +58,10 @@ export const GeometrySystem = () => { const textures = useViewer((s) => s.textures) const colorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + // The shared scene-material library, threaded into each builder's ctx so + // pure geometry builders can resolve `scene:` slot refs without + // importing `useScene`. + const sceneMaterials = useScene((s) => s.materials) // Per-node cache of the last-built geometry key (for kinds that declare // `def.geometryKey`). Lets us skip a dispose+rebuild when a node is dirty // but its geometry inputs are unchanged — e.g. an item reparenting onto a @@ -74,6 +78,23 @@ export const GeometrySystem = () => { } }, [shading, textures, colorPreset, sceneTheme]) + // Editing a scene material must re-colour every geometry node that + // references it through a `scene:` slot ref. Such a node's + // `geometryKey` is unchanged (only the referenced material's contents + // moved), so clear its cached key to defeat the skip in the rebuild loop, + // then mark it dirty. Scoped to nodes carrying a `scene:` ref so an + // unrelated material edit doesn't churn the whole scene. + useEffect(() => { + const nodes = useScene.getState().nodes + for (const node of Object.values(nodes)) { + const def = nodeRegistry.get(node.type) + if (!def?.geometry) continue + if (!nodeReferencesSceneMaterial(node)) continue + builtGeometryKeyRef.current.delete(node.id) + useScene.getState().markDirty(node.id as AnyNodeId) + } + }, [sceneMaterials]) + useFrame(() => { if (dirtyNodes.size === 0) return const nodes = useScene.getState().nodes @@ -168,7 +189,7 @@ export const GeometrySystem = () => { const parentId = (node.parentId ?? null) as AnyNodeId | null const key: BatchKey = `${node.type}::${parentId ?? ''}` const levelData = levelDataByBatch.get(key) - const ctx = buildGeometryContext(effectiveNode, nodes, levelData) + const ctx = buildGeometryContext(effectiveNode, nodes, levelData, sceneMaterials) // The builder is typed against the kind's specific node — at the // generic system level we lose that refinement, so the cast lands @@ -221,10 +242,20 @@ export const GeometrySystem = () => { return null } +function nodeReferencesSceneMaterial(node: AnyNode): boolean { + const slots = (node as { slots?: Record }).slots + if (!slots) return false + for (const ref of Object.values(slots)) { + if (typeof ref === 'string' && ref.startsWith('scene:')) return true + } + return false +} + function buildGeometryContext( node: AnyNode, nodes: Record, levelData: unknown, + materials: GeometryContext['materials'], ): GeometryContext { const resolve = (id: AnyNodeId): N | undefined => nodes[id] as N | undefined @@ -255,7 +286,7 @@ function buildGeometryContext( } } - return { resolve, children, siblings, parent, levelData } + return { resolve, children, siblings, parent, levelData, materials } } function disposeChildren(group: Group) { From e17c298284cbad75702c4bcfb0840f77124d0170 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 16 Jun 2026 11:10:34 -0400 Subject: [PATCH 2/6] fix(shelf): recess plates 1mm to stop z-fighting with the frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open-rack / no-sides shelves span their boards across the full footprint, so a board's end / front / back faces land exactly on the corner posts' outer faces (and, with a back panel, on its rear face). Coplanar surfaces the depth buffer can't order flicker as z-fighting (visible as seams where plates meet posts). Recess every board 1mm on width + depth via a `boardGeometry` helper so each plate sits just inside the frame — the meshes still overlap (no visible gap), but no two faces are coplanar. Frame parts (posts/sides/back/dividers/brackets) keep their full size as the silhouette. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/nodes/src/shelf/geometry.ts | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/nodes/src/shelf/geometry.ts b/packages/nodes/src/shelf/geometry.ts index 56dbfa1f..f51834a6 100644 --- a/packages/nodes/src/shelf/geometry.ts +++ b/packages/nodes/src/shelf/geometry.ts @@ -63,6 +63,21 @@ function stampShelfSlot(mesh: Mesh, slotId: ShelfSlotId): Mesh { return mesh } +// Plates (boards) span the full footprint, so their end / front / back faces +// land exactly on the frame's (posts / sides / back) outer faces — coplanar +// surfaces the depth buffer can't separate, which flickers as z-fighting. Recess +// every board 1mm on width + depth so it sits just inside the frame: the meshes +// still overlap (no visible gap), but no two faces are coplanar. +const BOARD_INSET = 0.001 + +function boardGeometry(width: number, thickness: number, depth: number): BoxGeometry { + return new BoxGeometry( + Math.max(width - 2 * BOARD_INSET, 0.001), + thickness, + Math.max(depth - 2 * BOARD_INSET, 0.001), + ) +} + export function buildShelfGeometry( rawNode: ShelfNode, ctx?: GeometryContext, @@ -113,7 +128,7 @@ export function buildShelfGeometry( function buildWallShelf(group: Group, node: ShelfNode, materials: ShelfSlotMaterials) { for (const y of boardCenterYs(node)) { const board = stampShelfSlot( - new Mesh(new BoxGeometry(node.width, node.thickness, node.depth), materials.shelves), + new Mesh(boardGeometry(node.width, node.thickness, node.depth), materials.shelves), 'shelves', ) board.name = `shelf-board-${boardRowIndex(node, y)}` @@ -155,7 +170,7 @@ function buildBookshelf(group: Group, node: ShelfNode, materials: ShelfSlotMater // Top + bottom + intermediate boards for (const y of boardCenterYs(node)) { const board = stampShelfSlot( - new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), materials.shelves), + new Mesh(boardGeometry(innerWidth, node.thickness, node.depth), materials.shelves), 'shelves', ) board.name = `shelf-board-${boardRowIndex(node, y)}` @@ -165,7 +180,7 @@ function buildBookshelf(group: Group, node: ShelfNode, materials: ShelfSlotMater if (node.withBottom) { const bottom = stampShelfSlot( - new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), materials.shelves), + new Mesh(boardGeometry(innerWidth, node.thickness, node.depth), materials.shelves), 'shelves', ) bottom.name = 'shelf-board-bottom' @@ -226,7 +241,7 @@ function buildOpenRack(group: Group, node: ShelfNode, materials: ShelfSlotMateri for (const y of boardCenterYs(node)) { const board = stampShelfSlot( - new Mesh(new BoxGeometry(innerWidth, boardThickness, node.depth), materials.shelves), + new Mesh(boardGeometry(innerWidth, boardThickness, node.depth), materials.shelves), 'shelves', ) board.name = `shelf-board-${boardRowIndex(node, y)}` @@ -264,7 +279,7 @@ function buildCubby(group: Group, node: ShelfNode, materials: ShelfSlotMaterials for (const y of boardCenterYs(node)) { const board = stampShelfSlot( - new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), materials.shelves), + new Mesh(boardGeometry(innerWidth, node.thickness, node.depth), materials.shelves), 'shelves', ) board.name = `shelf-board-${boardRowIndex(node, y)}` @@ -274,7 +289,7 @@ function buildCubby(group: Group, node: ShelfNode, materials: ShelfSlotMaterials if (node.withBottom) { const bottom = stampShelfSlot( - new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), materials.shelves), + new Mesh(boardGeometry(innerWidth, node.thickness, node.depth), materials.shelves), 'shelves', ) bottom.name = 'shelf-board-bottom' From b602e04b70ec81601d2717d2e6c69a9607b06d0d Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 16 Jun 2026 12:19:17 -0400 Subject: [PATCH 3/6] fix(shelf): stop top-face z-fighting + avoid plate/side gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the plate-recess fix: - Top surface: full-height frame members that pass UNDER the top board (back panel, bookshelf column dividers, corner posts) had their top face at unitHeight — coplanar with the top board's top — so they z-fought through it (the seam on the top). Drop their top 1mm (cappedFrameY); bottoms stay on the floor so nothing floats. - Plate width: the previous pass recessed every board's width, which opened a 1mm gap where boards ABUT the side panels (cubby / bookshelf-with-sides). Width is now recessed only where boards span OVER posts (open-rack / no-sides bookshelf); abutting boards keep full width and meet the sides flush. Depth stays recessed everywhere (gap-free, fixes the back-panel fight). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/nodes/src/shelf/geometry.ts | 61 ++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/packages/nodes/src/shelf/geometry.ts b/packages/nodes/src/shelf/geometry.ts index f51834a6..12bc208c 100644 --- a/packages/nodes/src/shelf/geometry.ts +++ b/packages/nodes/src/shelf/geometry.ts @@ -63,16 +63,33 @@ function stampShelfSlot(mesh: Mesh, slotId: ShelfSlotId): Mesh { return mesh } -// Plates (boards) span the full footprint, so their end / front / back faces -// land exactly on the frame's (posts / sides / back) outer faces — coplanar -// surfaces the depth buffer can't separate, which flickers as z-fighting. Recess -// every board 1mm on width + depth so it sits just inside the frame: the meshes -// still overlap (no visible gap), but no two faces are coplanar. +// A board's front/back faces land on the frame's outer faces (posts / back panel) +// — coplanar surfaces the depth buffer can't separate, which flickers as z-fighting. +// Recess 1mm so the board sits just inside: the meshes still overlap (no gap), but +// no faces are coplanar. Depth is always recessed (boards reach into the back panel +// / posts). Width is recessed only at call sites where boards span OVER posts +// (open-rack / no-sides bookshelf); boards that ABUT side panels keep full width so +// they meet the sides flush — abutting faces are back-to-back and never fight. const BOARD_INSET = 0.001 -function boardGeometry(width: number, thickness: number, depth: number): BoxGeometry { +// Frame members that pass under the top board (dividers / back / corner posts) reach +// y=unitHeight, coplanar with the top board's top face → z-fighting. Drop their top 1mm so +// the board cleanly caps them; their bottom stays on the floor. +const FRAME_TOP_INSET = 0.001 + +function cappedFrameY(unitHeight: number): { height: number; centerY: number } { + const height = Math.max(unitHeight - FRAME_TOP_INSET, 0.001) + return { height, centerY: height / 2 } +} + +function boardGeometry( + width: number, + thickness: number, + depth: number, + insetWidth = false, +): BoxGeometry { return new BoxGeometry( - Math.max(width - 2 * BOARD_INSET, 0.001), + insetWidth ? Math.max(width - 2 * BOARD_INSET, 0.001) : width, thickness, Math.max(depth - 2 * BOARD_INSET, 0.001), ) @@ -167,10 +184,14 @@ function buildBookshelf(group: Group, node: ShelfNode, materials: ShelfSlotMater const unitHeight = node.height + node.thickness const innerWidth = node.withSides ? node.width - 2 * node.thickness : node.width - // Top + bottom + intermediate boards + // Top + bottom + intermediate boards. No sides => boards span over corner + // posts, so inset their width too; with sides they abut the panels (flush). for (const y of boardCenterYs(node)) { const board = stampShelfSlot( - new Mesh(boardGeometry(innerWidth, node.thickness, node.depth), materials.shelves), + new Mesh( + boardGeometry(innerWidth, node.thickness, node.depth, !node.withSides), + materials.shelves, + ), 'shelves', ) board.name = `shelf-board-${boardRowIndex(node, y)}` @@ -204,26 +225,28 @@ function buildBookshelf(group: Group, node: ShelfNode, materials: ShelfSlotMater } if (node.withBack) { + const fy = cappedFrameY(unitHeight) const back = stampShelfSlot( - new Mesh(new BoxGeometry(innerWidth, unitHeight, node.thickness), materials.back), + new Mesh(new BoxGeometry(innerWidth, fy.height, node.thickness), materials.back), 'back', ) back.name = 'shelf-back' - back.position.set(0, unitHeight / 2, -(node.depth / 2 - node.thickness / 2)) + back.position.set(0, fy.centerY, -(node.depth / 2 - node.thickness / 2)) group.add(back) } // Vertical dividers between columns if (node.columns > 1) { + const fy = cappedFrameY(unitHeight) const colStep = innerWidth / node.columns for (let c = 1; c < node.columns; c++) { const x = -innerWidth / 2 + c * colStep const divider = stampShelfSlot( - new Mesh(new BoxGeometry(node.thickness, unitHeight, node.depth), materials.frame), + new Mesh(new BoxGeometry(node.thickness, fy.height, node.depth), materials.frame), 'frame', ) divider.name = `shelf-divider-col-${c}` - divider.position.set(x, unitHeight / 2, 0) + divider.position.set(x, fy.centerY, 0) group.add(divider) } } @@ -241,7 +264,7 @@ function buildOpenRack(group: Group, node: ShelfNode, materials: ShelfSlotMateri for (const y of boardCenterYs(node)) { const board = stampShelfSlot( - new Mesh(boardGeometry(innerWidth, boardThickness, node.depth), materials.shelves), + new Mesh(boardGeometry(innerWidth, boardThickness, node.depth, true), materials.shelves), 'shelves', ) board.name = `shelf-board-${boardRowIndex(node, y)}` @@ -307,12 +330,13 @@ function buildCubby(group: Group, node: ShelfNode, materials: ShelfSlotMaterials group.add(side) } + const fy = cappedFrameY(unitHeight) const back = stampShelfSlot( - new Mesh(new BoxGeometry(innerWidth, unitHeight, node.thickness), materials.back), + new Mesh(new BoxGeometry(innerWidth, fy.height, node.thickness), materials.back), 'back', ) back.name = 'shelf-back' - back.position.set(0, unitHeight / 2, -(node.depth / 2 - node.thickness / 2)) + back.position.set(0, fy.centerY, -(node.depth / 2 - node.thickness / 2)) group.add(back) if (node.columns > 1) { @@ -371,19 +395,20 @@ function addCornerPosts( unitHeight: number, postStyle: 'rack' | 'leg', ) { + const fy = cappedFrameY(unitHeight) 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 = stampShelfSlot( - new Mesh(new BoxGeometry(postThickness, unitHeight, postThickness), material), + new Mesh(new BoxGeometry(postThickness, fy.height, postThickness), material), 'frame', ) post.name = `shelf-post-${xSign === -1 ? 'l' : 'r'}${zSign === -1 ? 'b' : 'f'}` post.position.set( xSign * (node.width / 2 - inset), - unitHeight / 2, + fy.centerY, zSign * (node.depth / 2 - inset), ) group.add(post) From c717c0c13d59af415e285c840e6c4bb125eb16fe Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 16 Jun 2026 12:43:13 -0400 Subject: [PATCH 4/6] fix(shelf): tuck cubby cell dividers into the boards they meet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cubby per-cell dividers landed exactly flush on the board below and under the board above — coplanar faces that shimmer and read as merged into the shelf. Extend each divider 1mm into both boards (centre unchanged) so it tucks under the top board and onto the bottom board with a solid, seam-free join. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/nodes/src/shelf/geometry.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/nodes/src/shelf/geometry.ts b/packages/nodes/src/shelf/geometry.ts index 12bc208c..a3cc1acc 100644 --- a/packages/nodes/src/shelf/geometry.ts +++ b/packages/nodes/src/shelf/geometry.ts @@ -350,7 +350,13 @@ function buildCubby(group: Group, node: ShelfNode, materials: ShelfSlotMaterials for (let c = 1; c < node.columns; c++) { const x = -innerWidth / 2 + c * colStep const divider = stampShelfSlot( - new Mesh(new BoxGeometry(node.thickness, dividerHeight, node.depth), materials.frame), + // Extend 1mm into the boards above + below (centre unchanged) so the + // divider tucks under the top board and onto the bottom board instead + // of meeting them on a coplanar face (which shimmers / reads merged). + new Mesh( + new BoxGeometry(node.thickness, dividerHeight + 2 * BOARD_INSET, node.depth), + materials.frame, + ), 'frame', ) divider.name = `shelf-divider-${r}-${c}` From c68dfa2095a204ff550d48b1fd3f1b7f871577ae Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 16 Jun 2026 12:57:00 -0400 Subject: [PATCH 5/6] fix(shelf): match cubby divider depth to the boards (no front overflow, no back seam) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cubby column divider was full-depth while the boards are depth-recessed, so it (a) sat proud of the shelf fronts — the earlier Y-tuck then made it poke past the top/bottom boards — and (b) shared the back panel's rear plane, z-fighting down the centre of the back. Give the divider the same depth recess as the boards (via boardGeometry) and drop the Y-tuck: it now sits flush with the shelf fronts and its back tucks inside the back panel. Flush top/bottom is fine — those board faces are back-to-back, not co-facing, so they don't fight. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/nodes/src/shelf/geometry.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/nodes/src/shelf/geometry.ts b/packages/nodes/src/shelf/geometry.ts index a3cc1acc..3e4e8d14 100644 --- a/packages/nodes/src/shelf/geometry.ts +++ b/packages/nodes/src/shelf/geometry.ts @@ -350,13 +350,12 @@ function buildCubby(group: Group, node: ShelfNode, materials: ShelfSlotMaterials for (let c = 1; c < node.columns; c++) { const x = -innerWidth / 2 + c * colStep const divider = stampShelfSlot( - // Extend 1mm into the boards above + below (centre unchanged) so the - // divider tucks under the top board and onto the bottom board instead - // of meeting them on a coplanar face (which shimmers / reads merged). - new Mesh( - new BoxGeometry(node.thickness, dividerHeight + 2 * BOARD_INSET, node.depth), - materials.frame, - ), + // Same depth recess as the boards: the divider sits flush with the + // shelf fronts (not proud) and its back tucks inside the back panel, + // so it neither overflows the boards at the front nor z-fights the + // back panel down the centre. Height is flush (the board faces it + // meets top/bottom are back-to-back, so they don't fight). + new Mesh(boardGeometry(node.thickness, dividerHeight, node.depth), materials.frame), 'frame', ) divider.name = `shelf-divider-${r}-${c}` From e0796151962e3921f6df25130a61dd1bda2f18bb Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 16 Jun 2026 13:05:47 -0400 Subject: [PATCH 6/6] fix(shelf): floor-anchor cubby no-bottom divider + embed bookshelf divider depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cubby without a bottom board: the lowest cell opens onto the floor, but its column divider still started at the (missing) bottom board's top, leaving it floating ~thickness above the floor. Anchor it at y=0 when there's no bottom. - Bookshelf full-height divider: it crosses the continuous shelves, so it can't share their depth plane (proud at the front, coplanar with the back panel — z-fighting down the centre back). Recess its depth to sit fully INSIDE the boards' depth: embedded at each shelf crossing (board occludes it) and tucked inside the back panel. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/nodes/src/shelf/geometry.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/nodes/src/shelf/geometry.ts b/packages/nodes/src/shelf/geometry.ts index 3e4e8d14..d5d266c9 100644 --- a/packages/nodes/src/shelf/geometry.ts +++ b/packages/nodes/src/shelf/geometry.ts @@ -242,7 +242,14 @@ function buildBookshelf(group: Group, node: ShelfNode, materials: ShelfSlotMater for (let c = 1; c < node.columns; c++) { const x = -innerWidth / 2 + c * colStep const divider = stampShelfSlot( - new Mesh(new BoxGeometry(node.thickness, fy.height, node.depth), materials.frame), + // A full-height divider crosses the shelves, so its depth must sit + // INSIDE the boards' (already recessed) depth: embedded at each crossing + // (the board occludes it — no coplanar fight) and tucked inside the back + // panel, rather than proud at the front / coplanar with the back. + new Mesh( + new BoxGeometry(node.thickness, fy.height, node.depth - 4 * BOARD_INSET), + materials.frame, + ), 'frame', ) divider.name = `shelf-divider-col-${c}` @@ -343,7 +350,9 @@ function buildCubby(group: Group, node: ShelfNode, materials: ShelfSlotMaterials 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 + // Without a bottom board the lowest cell opens onto the floor, so its + // divider must reach y=0 rather than rest on a (missing) board top. + const cellBottomY = r === 0 && !node.withBottom ? 0 : node.thickness + r * rowStep const cellTopY = node.thickness + (r + 1) * rowStep const dividerHeight = cellTopY - cellBottomY - node.thickness if (dividerHeight <= 0) continue