diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 8dcfc58fd..5eb4939a4 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 b007bf27e..bbeca31ad 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 58d7af5e5..3e9b74b2a 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 bcb97f04d..0ac3f986c 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 c72eabfd5..d616d9425 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 f71a3d470..d5d266c93 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,104 @@ 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 -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) +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) +} + +function stampShelfSlot(mesh: Mesh, slotId: ShelfSlotId): Mesh { + mesh.userData.slotId = slotId + return mesh +} - const shelfMaterial = material as ShelfMaterial - shelfMaterial.side = FrontSide - shelfMaterial.depthWrite = true - shelfMaterial.needsUpdate = true +// 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 + +// 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 } +} - shelfMaterialCache.set(cacheKey, material) - return material +function boardGeometry( + width: number, + thickness: number, + depth: number, + insetWidth = false, +): BoxGeometry { + return new BoxGeometry( + insetWidth ? Math.max(width - 2 * BOARD_INSET, 0.001) : width, + thickness, + Math.max(depth - 2 * BOARD_INSET, 0.001), + ) } 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 +142,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(boardGeometry(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 +164,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 +180,30 @@ 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 + // 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 = new Mesh(new BoxGeometry(innerWidth, node.thickness, node.depth), material) + const board = stampShelfSlot( + new Mesh( + boardGeometry(innerWidth, node.thickness, node.depth, !node.withSides), + 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(boardGeometry(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,30 +212,48 @@ 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 fy = cappedFrameY(unitHeight) + const back = stampShelfSlot( + 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 = new Mesh(new BoxGeometry(node.thickness, unitHeight, node.depth), material) + const divider = stampShelfSlot( + // 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}` - divider.position.set(x, unitHeight / 2, 0) + divider.position.set(x, fy.centerY, 0) group.add(divider) } } @@ -200,26 +264,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(boardGeometry(innerWidth, boardThickness, node.depth, true), 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,49 +303,69 @@ 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(boardGeometry(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(boardGeometry(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 fy = cappedFrameY(unitHeight) + const back = stampShelfSlot( + 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) { 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 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( + // 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}` divider.position.set(x, cellBottomY + dividerHeight / 2, 0) @@ -319,16 +409,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 = new Mesh(new BoxGeometry(postThickness, unitHeight, postThickness), material) + const post = stampShelfSlot( + 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) diff --git a/packages/nodes/src/shelf/paint.ts b/packages/nodes/src/shelf/paint.ts new file mode 100644 index 000000000..e6c8a5642 --- /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 e069a065b..f9fa83209 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 000000000..411eed965 --- /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 3e6ddaf3e..6b6897111 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) {