From 967a905e3bf93543b0c0467e7fbf7b430f74a141 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 17 Jun 2026 07:51:37 -0400 Subject: [PATCH] feat(paint-slots): unified slot defaults + paint for slab, ceiling, wall (phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings slab, ceiling, and wall onto the unified slot contract the shelf established, so each declares its paintable slots with a declarative default and (slab/ceiling) is painted through the registry capabilities.paint dispatch. - Shared helper packages/nodes/src/shared/slot-paint.ts: a node.slots-based PaintCapability factory (commit/resolve/effective generic; preview injected). Distinct from surface-paint.ts, which writes the legacy inline node.material. - slab: schema slots; def.geometry resolves node.slots.surface -> legacy material -> declared default, tags the mesh userData.slotId; slabPaint + capabilities.slots. Retires DEFAULT_SLAB_MATERIAL in the slab path. - ceiling: schema slots; material builders extracted to ceiling/materials.ts (shared by renderer + paint preview, built BackSide so the hover preview is visible from below); renderer resolves the slot; ceilingPaint + slots. - wall: WALL_SLOT_DEFAULT in core; the viewer's getMaterialsForWall renders an unpainted face with its declared default instead of the themed wall role; capabilities.slots (interior/exterior). wallPaint's inline interior/exterior fields are unchanged (node.slots migration is a later step). - selection-manager + material-paint: drop slab/ceiling from the legacy single-surface arms (now registry-driven). Behavior change (intended, matches the shelf precedent + the phase-5 plan): colored-mode UNPAINTED slab/ceiling/wall surfaces now render their fixed slot default (#e5e5e5 / #f5f5dc / #ffffff) instead of the theme role colour. The textures-off (monochrome) role collapse is unchanged — the escape hatch. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/schema/index.ts | 1 + packages/core/src/schema/nodes/ceiling.ts | 4 + packages/core/src/schema/nodes/slab.ts | 4 + packages/core/src/schema/nodes/wall.ts | 10 + .../components/editor/selection-manager.tsx | 8 +- packages/editor/src/lib/material-paint.ts | 2 - packages/nodes/src/ceiling/definition.ts | 6 + packages/nodes/src/ceiling/materials.ts | 80 +++++++ packages/nodes/src/ceiling/paint.ts | 42 ++++ packages/nodes/src/ceiling/renderer.tsx | 88 +++----- packages/nodes/src/ceiling/slots.ts | 11 + packages/nodes/src/shared/slot-paint.ts | 211 ++++++++++++++++++ packages/nodes/src/slab/definition.ts | 6 + packages/nodes/src/slab/geometry.ts | 60 +++-- packages/nodes/src/slab/paint.ts | 19 ++ packages/nodes/src/slab/slots.ts | 11 + packages/nodes/src/wall/definition.ts | 6 + packages/nodes/src/wall/slots.ts | 17 ++ .../viewer/src/systems/wall/wall-materials.ts | 22 +- 19 files changed, 520 insertions(+), 88 deletions(-) create mode 100644 packages/nodes/src/ceiling/materials.ts create mode 100644 packages/nodes/src/ceiling/paint.ts create mode 100644 packages/nodes/src/ceiling/slots.ts create mode 100644 packages/nodes/src/shared/slot-paint.ts create mode 100644 packages/nodes/src/slab/paint.ts create mode 100644 packages/nodes/src/slab/slots.ts create mode 100644 packages/nodes/src/wall/slots.ts diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index f0baa8db..ca8fc184 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -154,6 +154,7 @@ export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall' export { getEffectiveWallSurfaceMaterial, getWallSurfaceMaterialSignature, + WALL_SLOT_DEFAULT, WallNode, } from './nodes/wall' export { WindowNode, WindowType } from './nodes/window' diff --git a/packages/core/src/schema/nodes/ceiling.ts b/packages/core/src/schema/nodes/ceiling.ts index 724bb0fd..b94aaaa2 100644 --- a/packages/core/src/schema/nodes/ceiling.ts +++ b/packages/core/src/schema/nodes/ceiling.ts @@ -11,6 +11,10 @@ export const CeilingNode = BaseNode.extend({ children: z.array(ItemNode.shape.id).default([]), material: MaterialSchema.optional(), materialPreset: z.string().optional(), + // Per-slot material overrides on the unified slot model, mirroring + // `ShelfNode.slots`. Key = slot id (`surface`), value = a `MaterialRef` + // (`library:` / `scene:`). Absent = the declared slot default. + slots: z.record(z.string(), z.string()).optional(), polygon: z.array(z.tuple([z.number(), z.number()])), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), holeMetadata: z.array(SurfaceHoleMetadata).default([]), diff --git a/packages/core/src/schema/nodes/slab.ts b/packages/core/src/schema/nodes/slab.ts index 5232eaaf..fe3c47c1 100644 --- a/packages/core/src/schema/nodes/slab.ts +++ b/packages/core/src/schema/nodes/slab.ts @@ -9,6 +9,10 @@ export const SlabNode = BaseNode.extend({ type: nodeType('slab'), material: MaterialSchema.optional(), materialPreset: z.string().optional(), + // Per-slot material overrides on the unified slot model, mirroring + // `ShelfNode.slots`. Key = slot id (`surface`), value = a `MaterialRef` + // (`library:` / `scene:`). Absent = the declared slot default. + slots: z.record(z.string(), z.string()).optional(), polygon: z.array(z.tuple([z.number(), z.number()])), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), holeMetadata: z.array(SurfaceHoleMetadata).default([]), diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index ebce121c..c356ae60 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -46,6 +46,16 @@ export type WallNode = z.infer export type WallSurfaceSide = 'interior' | 'exterior' +// Declared default appearance for an unpainted wall face in colored mode — +// visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the +// slot declaration (nodes) and the material resolver (viewer) share one value. +// May be a `#rrggbb` colour or a `library:` ref. Textures-off still +// collapses to the themed wall role (the escape hatch). +export const WALL_SLOT_DEFAULT: Record = { + interior: '#ffffff', + exterior: '#ffffff', +} + export type WallSurfaceMaterialSpec = { material?: z.infer materialPreset?: string diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index b1a3e50a..965131f6 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -1059,13 +1059,7 @@ export const SelectionManager = () => { // before any of the legacy roof / stair / single-surface arms // below run. - if ( - node.type === 'fence' || - node.type === 'column' || - node.type === 'slab' || - node.type === 'ceiling' || - node.type === 'shelf' - ) { + if (node.type === 'fence' || node.type === 'column' || node.type === 'shelf') { const compatible = paintEnabled return { diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index bd672f15..7f86c05e 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -382,8 +382,6 @@ export function resolveActivePaintMaterialFromSelection(params: { if ( (selectedNode.type === 'fence' || selectedNode.type === 'column' || - selectedNode.type === 'slab' || - selectedNode.type === 'ceiling' || selectedNode.type === 'shelf') && selectedMaterialTarget.role === 'surface' ) { diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index a59a7034..118c0aba 100644 --- a/packages/nodes/src/ceiling/definition.ts +++ b/packages/nodes/src/ceiling/definition.ts @@ -10,8 +10,10 @@ import { ceilingMoveVertexAffordance, } from './floorplan-affordances' import { ceilingFloorplanMoveTarget } from './floorplan-move' +import { ceilingPaint } from './paint' import { ceilingParametrics } from './parametrics' import { CeilingNode } from './schema' +import { ceilingSlots } from './slots' const HEIGHT_HANDLE_OFFSET = 0.22 const MIN_CEILING_HEIGHT = 0.5 @@ -102,6 +104,10 @@ export const ceilingDefinition: NodeDefinition = { }, duplicable: true, deletable: true, + // Unified slot model: one paintable underside surface with a declared + // default, painted through the registry `capabilities.paint` dispatch. + slots: () => ceilingSlots(), + paint: ceilingPaint, }, relations: { diff --git a/packages/nodes/src/ceiling/materials.ts b/packages/nodes/src/ceiling/materials.ts new file mode 100644 index 00000000..a6e4e590 --- /dev/null +++ b/packages/nodes/src/ceiling/materials.ts @@ -0,0 +1,80 @@ +import { + getMaterialPresetByRef, + parseMaterialRef, + resolveMaterial, + type SceneMaterial, + type SceneMaterialId, +} from '@pascal-app/core' +import { float, mix, positionWorld, smoothstep } from 'three/tsl' +import { BackSide, FrontSide, MeshBasicNodeMaterial } from 'three/webgpu' + +/** + * Ceiling material builders, shared by the renderer (mesh appearance) and the + * paint capability (hover preview). A ceiling is a flat tinted surface: the + * underside (`bottom`, seen from inside the room, `BackSide`) is opaque, while + * the `top` carries a transparent TSL grid overlay used while placing / + * selecting ceiling-hosted items. Both derive from a single colour, so slot + * painting resolves a colour and rebuilds these — it never applies a PBR map. + */ + +const gridScale = 5 +const gridX = positionWorld.x.mul(gridScale).fract() +const gridY = positionWorld.z.mul(gridScale).fract() +const lineWidth = 0.05 +const lineX = smoothstep(lineWidth, 0, gridX).add(smoothstep(1.0 - lineWidth, 1.0, gridX)) +const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.0, gridY)) +const gridPattern = lineX.max(lineY) +const gridOpacity = mix(float(0.2), float(0.6), gridPattern) + +export type CeilingMaterials = { + topMaterial: MeshBasicNodeMaterial + bottomMaterial: MeshBasicNodeMaterial +} + +function createCeilingMaterials(color = '#999999'): CeilingMaterials { + const topMaterial = new MeshBasicNodeMaterial({ + color, + transparent: true, + depthWrite: false, + side: FrontSide, + }) + topMaterial.opacityNode = gridOpacity + + const bottomMaterial = new MeshBasicNodeMaterial({ + color, + transparent: true, + side: BackSide, + }) + + return { topMaterial, bottomMaterial } +} + +const ceilingMaterialCache = new Map() + +export function getCeilingMaterials(color = '#999999'): CeilingMaterials { + const cached = ceilingMaterialCache.get(color) + if (cached) return cached + const materials = createCeilingMaterials(color) + ceilingMaterialCache.set(color, materials) + return materials +} + +/** + * Resolve a slot `MaterialRef` to a flat colour for the ceiling surface. + * `library:` refs use the catalog preset's base colour; `scene:` refs use the + * stored material's colour. Returns null for a dangling / unparseable ref so + * the caller falls back to its default. + */ +export function ceilingColorFromRef( + ref: string | undefined, + sceneMaterials: Record | undefined, +): string | null { + const parsed = parseMaterialRef(ref) + if (!parsed) return null + if (parsed.kind === 'library') { + return getMaterialPresetByRef(ref)?.mapProperties.color ?? null + } + const sceneMaterial = sceneMaterials?.[parsed.id as SceneMaterialId] + if (!sceneMaterial) return null + return resolveMaterial(sceneMaterial.material).color ?? null +} diff --git a/packages/nodes/src/ceiling/paint.ts b/packages/nodes/src/ceiling/paint.ts new file mode 100644 index 00000000..3efce699 --- /dev/null +++ b/packages/nodes/src/ceiling/paint.ts @@ -0,0 +1,42 @@ +import { + type AnyNode, + type CeilingNode, + getMaterialPresetByRef, + resolveMaterial, +} from '@pascal-app/core' +import type { Mesh } from 'three' +import { createSlotPaintCapability } from '../shared/slot-paint' +import { getCeilingMaterials } from './materials' + +/** + * Ceiling paint on the unified slot model. A ceiling has one paintable surface, + * so every hit resolves to `surface`; commit writes `node.slots.surface`. The + * preview swaps the registered underside mesh to the ceiling's own flat-tinted + * material (built `BackSide`, the way it renders), so the hover preview matches + * the committed result — a generic PBR preview would be invisible from below. + */ +export const ceilingPaint = createSlotPaintCapability({ + resolveRole: () => 'surface', + applyPreview: ({ material, materialPreset, root }) => { + const color = materialPreset + ? (getMaterialPresetByRef(materialPreset)?.mapProperties.color ?? null) + : material + ? (resolveMaterial(material).color ?? null) + : null + if (!color) return () => {} + const mesh = root as Mesh + if (!mesh.isMesh) return null + const previous = mesh.material + mesh.material = getCeilingMaterials(color).bottomMaterial + return () => { + mesh.material = previous + } + }, + legacyEffective: (node: AnyNode) => { + const ceiling = node as CeilingNode + if (ceiling.materialPreset || ceiling.material) { + return { material: ceiling.material, materialPreset: ceiling.materialPreset } + } + return null + }, +}) diff --git a/packages/nodes/src/ceiling/renderer.tsx b/packages/nodes/src/ceiling/renderer.tsx index 263c4fd8..9f49086c 100644 --- a/packages/nodes/src/ceiling/renderer.tsx +++ b/packages/nodes/src/ceiling/renderer.tsx @@ -15,53 +15,15 @@ import { useViewer, } from '@pascal-app/viewer' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' -import { float, mix, positionWorld, smoothstep } from 'three/tsl' -import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' +import { BackSide, type Mesh } from 'three/webgpu' import { createPlaceholderGeometry } from '../shared/placeholder-geometry' +import { ceilingColorFromRef, getCeilingMaterials } from './materials' +import { CEILING_SLOT_DEFAULT_COLOR } from './slots' function createEmptyGeometry() { return createPlaceholderGeometry() } -const gridScale = 5 -const gridX = positionWorld.x.mul(gridScale).fract() -const gridY = positionWorld.z.mul(gridScale).fract() -const lineWidth = 0.05 -const lineX = smoothstep(lineWidth, 0, gridX).add(smoothstep(1.0 - lineWidth, 1.0, gridX)) -const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.0, gridY)) -const gridPattern = lineX.max(lineY) -const gridOpacity = mix(float(0.2), float(0.6), gridPattern) - -function createCeilingMaterials(color = '#999999') { - const topMaterial = new MeshBasicNodeMaterial({ - color, - transparent: true, - depthWrite: false, - side: FrontSide, - }) - topMaterial.opacityNode = gridOpacity - - const bottomMaterial = new MeshBasicNodeMaterial({ - color, - transparent: true, - side: BackSide, - }) - - return { topMaterial, bottomMaterial } -} - -const ceilingMaterialCache = new Map>() - -function getCeilingMaterials(color = '#999999') { - const cacheKey = color - const cached = ceilingMaterialCache.get(cacheKey) - if (cached) return cached - - const materials = createCeilingMaterials(color) - ceilingMaterialCache.set(cacheKey, materials) - return materials -} - export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const ref = useRef(null!) const placeholderGeometry = useMemo(createEmptyGeometry, []) @@ -80,6 +42,9 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const textures = useViewer((s) => s.textures) const colorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + // Subscribe to the scene-material library so editing a `scene:` material the + // ceiling slot references re-tints it live. + const sceneMaterials = useScene((s) => s.materials) const liveTransform = useLiveTransforms((s) => s.get(node.id)) const ceilingY = (node.height ?? 2.5) - 0.01 + (liveTransform?.position[1] ?? 0) const position: [number, number, number] = [ @@ -97,18 +62,15 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { ) const materials = useMemo(() => { - // Untextured ceilings (and everything in textures-off mode) take the themed - // 'ceiling' role colour; only an explicit preset/material keeps a texture. - const hasExplicit = Boolean(node.materialPreset || node.material) - if (!textures || !hasExplicit) { - // Bottom (seen from inside the room, looking up) stays opaque so the - // ceiling reads as a solid surface. Top uses the transparent - // grid-pattern material so the ceiling stays see-through whenever - // the editor reveals the `ceiling-grid` overlay (placing a - // ceiling-hosted item, or selecting one of its children — e.g. - // after committing a placement). Without this the top mesh shipped - // an opaque surface-role material, so a top-down camera lost view - // of everything under the ceiling once the overlay turned on. + // Textures-off mode takes the themed 'ceiling' role colour — the guaranteed + // escape hatch, independent of any slot override. The bottom (seen from + // inside the room, looking up) stays opaque so the ceiling reads as a solid + // surface; the top keeps the transparent grid material so a top-down camera + // can see through the ceiling whenever the `ceiling-grid` overlay is + // revealed (placing a ceiling-hosted item, or selecting one of its + // children). Without that the top mesh would ship an opaque surface-role + // material and a top-down camera would lose everything under the ceiling. + if (!textures) { const ceilingColor = resolveSurfaceColor('ceiling', colorPreset, sceneTheme) return { topMaterial: getCeilingMaterials(ceilingColor).topMaterial, @@ -116,14 +78,26 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { } } - const preset = getMaterialPresetByRef(node.materialPreset) - const props = preset?.mapProperties ?? resolveMaterial(node.material) - const color = props.color || '#999999' - return getCeilingMaterials(color) + // Unified slot override — shared scene material or catalog `library:` finish + // (resolved to its base colour; a ceiling renders flat-tinted, not mapped). + const slotColor = ceilingColorFromRef(node.slots?.surface, sceneMaterials) + if (slotColor) return getCeilingMaterials(slotColor) + + // Legacy inline material / preset (scenes painted before the slot model). + if (node.materialPreset || node.material) { + const preset = getMaterialPresetByRef(node.materialPreset) + const props = preset?.mapProperties ?? resolveMaterial(node.material) + return getCeilingMaterials(props.color || '#999999') + } + + // Declared slot default. + return getCeilingMaterials(CEILING_SLOT_DEFAULT_COLOR) }, [ textures, colorPreset, sceneTheme, + sceneMaterials, + node.slots, node.materialPreset, node.material, node.material?.preset, diff --git a/packages/nodes/src/ceiling/slots.ts b/packages/nodes/src/ceiling/slots.ts new file mode 100644 index 00000000..7ca5e286 --- /dev/null +++ b/packages/nodes/src/ceiling/slots.ts @@ -0,0 +1,11 @@ +import type { SlotDeclaration } from '@pascal-app/core' + +export type CeilingSlotId = 'surface' + +// Visual parity with the retired DEFAULT_CEILING_MATERIAL (warm beige). +export const CEILING_SLOT_DEFAULT_COLOR = '#f5f5dc' + +/** A ceiling exposes a single paintable underside surface. */ +export function ceilingSlots(): SlotDeclaration[] { + return [{ slotId: 'surface', label: 'Surface', default: CEILING_SLOT_DEFAULT_COLOR }] +} diff --git a/packages/nodes/src/shared/slot-paint.ts b/packages/nodes/src/shared/slot-paint.ts new file mode 100644 index 00000000..fa206d6f --- /dev/null +++ b/packages/nodes/src/shared/slot-paint.ts @@ -0,0 +1,211 @@ +import { + type AnyNode, + type AnyNodeId, + generateSceneMaterialId, + type MaterialSchema, + type PaintCapability, + type PaintPreviewArgs, + type PaintResolveArgs, + parseMaterialRef, + type SceneMaterial, + type SceneMaterialId, + toSceneMaterialRef, + useScene, +} from '@pascal-app/core' +import { createMaterial, createMaterialFromPresetRef, useViewer } from '@pascal-app/viewer' +import type { Material, Mesh, Object3D } from 'three' + +/** + * Shared paint capability for procedural kinds on the unified slot model + * (`node.slots: Record` + the shared scene-material + * palette) — the same data shape items derive from their GLB and the shelf + * declares via `capabilities.slots`. Distinct from `surface-paint.ts`, which + * writes the legacy inline `node.material` copy the plan is retiring. + * + * The commit / resolve / effective-material logic is identical across kinds; + * only the slot-resolution from a pointer hit and the mesh preview differ, so + * those are injected per kind. + */ + +type SlotsNode = AnyNode & { slots?: Record } + +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 findMatchingSceneMaterial( + materials: Record, + material: MaterialSchema, +): SceneMaterial | null { + for (const sceneMaterial of Object.values(materials)) { + if (deepEqual(sceneMaterial.material, material)) return sceneMaterial + } + return null +} + +function commitSlotPaint( + node: SlotsNode, + 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 SlotsNode | 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) { + // 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. + const sceneMaterial = newSceneMaterial + useScene.setState((s) => { + if (s.readOnly) return s + const node2 = s.nodes[nodeId] as SlotsNode | undefined + if (!node2) return s + return { + materials: { ...s.materials, [sceneMaterial.id as SceneMaterialId]: sceneMaterial }, + nodes: { + ...s.nodes, + [nodeId]: { ...node2, slots: nextSlots } as AnyNode, + }, + } + }) + useScene.getState().markDirty(nodeId) + return + } + + state.updateNode(nodeId, { slots: nextSlots } as Partial) +} + +/** Preview material for a slot paint — mirrors the commit's resolution. */ +export function buildSlotPreviewMaterial( + 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 +} + +/** + * Preview for kinds whose meshes are produced by `def.geometry` and tagged + * with `userData.slotId` (+ `__fromGeometry`). Swaps every builder mesh whose + * slot matches `role`, leaving hosted-child meshes (which can carry a colliding + * `userData.slotId` from their own GLB) untouched. + */ +export function previewGeometrySlot(args: PaintPreviewArgs): (() => void) | null { + const { role, root, material, materialPreset } = args + const preview = buildSlotPreviewMaterial(material, materialPreset) + if (!preview) return () => {} + + const restores: Array<() => void> = [] + ;(root as Object3D).traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + const userData = mesh.userData as { slotId?: string | null; __fromGeometry?: boolean } + if (userData.__fromGeometry !== true) return + if (userData.slotId !== role) return + const previous = mesh.material + mesh.material = preview + 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 type SlotPaintConfig = { + /** Resolve the slot id for a pointer hit (`null` = not paintable here). */ + resolveRole: (args: PaintResolveArgs) => string | null + /** Apply a preview to the registered mesh subtree for `role`. */ + applyPreview: (args: PaintPreviewArgs) => (() => void) | null + /** + * Optional legacy fallback for the picker's current-value indicator — read + * when no `node.slots[role]` ref exists yet (e.g. a scene painted before the + * kind moved onto the slot model still carries inline `material`/`preset`). + */ + legacyEffective?: ( + node: AnyNode, + role: string, + ) => { material: MaterialSchema | undefined; materialPreset: string | undefined } | null +} + +export function createSlotPaintCapability(config: SlotPaintConfig): PaintCapability { + return { + resolveRole: config.resolveRole, + buildPatch: ({ node, role, materialPreset }) => { + const slots = { ...((node as SlotsNode).slots ?? {}) } + if (materialPreset) slots[role] = materialPreset + else delete slots[role] + return { slots } as Partial + }, + commit: ({ node, role, material, materialPreset }) => + commitSlotPaint(node as SlotsNode, role, material, materialPreset), + applyPreview: config.applyPreview, + getEffectiveMaterial: ({ node, role }) => { + const ref = (node as SlotsNode).slots?.[role] + const parsed = parseMaterialRef(ref) + if (parsed) { + if (parsed.kind === 'library') return { material: undefined, materialPreset: ref } + const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId] + if (sceneMaterial) return { material: sceneMaterial.material, materialPreset: undefined } + } + return config.legacyEffective?.(node, role) ?? null + }, + } +} diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index 4b63d070..fc0ab43c 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -12,8 +12,10 @@ import { } from './floorplan-affordances' import { slabFloorplanMoveTarget } from './floorplan-move' import { buildSlabGeometry } from './geometry' +import { slabPaint } from './paint' import { slabParametrics } from './parametrics' import { SlabNode } from './schema' +import { slabSlots } from './slots' const HEIGHT_HANDLE_OFFSET = 0.22 const MIN_SLAB_ELEVATION = 0.02 @@ -155,6 +157,10 @@ export const slabDefinition: NodeDefinition = { }, duplicable: true, deletable: true, + // Unified slot model: one paintable floor surface with a declared default, + // painted through the registry `capabilities.paint` dispatch like the shelf. + slots: () => slabSlots(), + paint: slabPaint, }, relations: { diff --git a/packages/nodes/src/slab/geometry.ts b/packages/nodes/src/slab/geometry.ts index dd1a6808..b15c1726 100644 --- a/packages/nodes/src/slab/geometry.ts +++ b/packages/nodes/src/slab/geometry.ts @@ -1,25 +1,27 @@ -import { getMaterialPresetByRef, type SlabNode } from '@pascal-app/core' +import { type GeometryContext, getMaterialPresetByRef, type SlabNode } from '@pascal-app/core' import { applyMaterialPresetToMaterials, type ColorPreset, createDefaultMaterial, createMaterial, createSurfaceRoleMaterial, - DEFAULT_SLAB_MATERIAL, generateSlabGeometry, type RenderShading, + resolveMaterialRef, } from '@pascal-app/viewer' import { FrontSide, Group, type Material, Mesh, type Texture } from 'three' +import { SLAB_SLOT_DEFAULT_COLOR } from './slots' /** * Stage B builder for slab. Reuses `generateSlabGeometry` (pure * triangulation + hole CSG from viewer) and the same material cache * pattern the legacy slab renderer used. * - * Materials are cached by `{material, materialPreset}` signature so - * slabs sharing settings share the GPU resource. Cached entry mutation - * (preset apply) is preserved — async texture loads still update the - * rendered material after re-mount. + * Materials follow the unified slot model: the single `surface` slot resolves + * `node.slots.surface` (a shared scene material or `library:` finish) → the + * legacy inline `node.material` / `materialPreset` (pre-slot-model scenes) → + * the declared slot default colour. Textures-off collapses to the themed + * `floor` role — the guaranteed monochrome escape hatch. */ type SlabMaterial = Material & { alphaMap?: Texture | null @@ -35,19 +37,39 @@ function getSlabMaterial( shading: RenderShading, textures: boolean, colorPreset: ColorPreset, - sceneTheme?: string, + sceneTheme: string | undefined, + sceneMaterials: GeometryContext['materials'], ): Material { - // Untextured slabs (and everything in textures-off mode) take the themed - // 'floor' role colour. createSurfaceRoleMaterial returns a shared cached - // material, so it is returned as-is without the mutation below. - // FrontSide — DoubleSide on the role material's NodeMaterial poisons the - // MRT scene pass (see `materials.ts` line 77 / glazing fix 9400f1c5). - // Slab side faces still render correctly because `generateSlabGeometry` - // produces outward-facing normals on the top, bottom, and perimeter. - if (!textures || (!node.materialPreset && !node.material)) { + // Textures-off mode takes the themed 'floor' role colour — the guaranteed + // escape hatch, independent of any slot override. createSurfaceRoleMaterial + // returns a shared cached material. FrontSide — DoubleSide on the role + // material's NodeMaterial poisons the MRT scene pass (see `materials.ts` + // line 77 / glazing fix 9400f1c5). Slab side faces still render correctly + // because `generateSlabGeometry` produces outward-facing normals. + if (!textures) { return createSurfaceRoleMaterial('floor', colorPreset, FrontSide, sceneTheme) } + // Unified slot override — shared scene material or catalog `library:` finish. + const slotRef = node.slots?.surface + if (slotRef) { + const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading) + if (resolved) return resolved + } + + // Legacy inline material / preset, for scenes painted before the slot model. + if (node.materialPreset || node.material) { + return getLegacySlabMaterial(node, shading) + } + + // Declared slot default (visual parity with the retired DEFAULT_SLAB_MATERIAL). + return createDefaultMaterial(SLAB_SLOT_DEFAULT_COLOR, 0.8, shading) +} + +function getLegacySlabMaterial(node: SlabNode, shading: RenderShading): Material { + // Cached by `{material, materialPreset}` signature so slabs sharing settings + // share the GPU resource; cached entry mutation (preset apply) is preserved + // so async texture loads still update the rendered material after re-mount. const cacheKey = JSON.stringify({ shading, material: node.material ?? null, @@ -61,7 +83,7 @@ function getSlabMaterial( ? createDefaultMaterial('#ffffff', 0.5, shading) : node.material ? createMaterial(node.material, shading).clone() - : DEFAULT_SLAB_MATERIAL(shading).clone() + : createDefaultMaterial(SLAB_SLOT_DEFAULT_COLOR, 0.8, shading) if (preset) { applyMaterialPresetToMaterials(material, preset) @@ -84,7 +106,7 @@ function getSlabMaterial( export function buildSlabGeometry( node: SlabNode, - _ctx?: unknown, + ctx?: GeometryContext, shading: RenderShading = 'rendered', textures = true, colorPreset: ColorPreset = 'clay', @@ -92,10 +114,12 @@ export function buildSlabGeometry( ): Group { const group = new Group() const geometry = generateSlabGeometry(node) - const material = getSlabMaterial(node, shading, textures, colorPreset, sceneTheme) + const material = getSlabMaterial(node, shading, textures, colorPreset, sceneTheme, ctx?.materials) const mesh = new Mesh(geometry, material) mesh.castShadow = true mesh.receiveShadow = true + // Tag the surface so the unified slot paint can resolve the hit and preview. + mesh.userData.slotId = 'surface' const elevation = node.elevation ?? 0.05 if (elevation < 0) mesh.position.y = elevation group.add(mesh) diff --git a/packages/nodes/src/slab/paint.ts b/packages/nodes/src/slab/paint.ts new file mode 100644 index 00000000..a5387849 --- /dev/null +++ b/packages/nodes/src/slab/paint.ts @@ -0,0 +1,19 @@ +import type { AnyNode, SlabNode } from '@pascal-app/core' +import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint' + +/** + * Slab paint on the unified slot model. A slab has one paintable surface, so + * every face resolves to the `surface` slot; commit writes `node.slots.surface` + * (a shared scene-material or `library:` ref) like the shelf. + */ +export const slabPaint = createSlotPaintCapability({ + resolveRole: () => 'surface', + applyPreview: previewGeometrySlot, + legacyEffective: (node: AnyNode) => { + const slab = node as SlabNode + if (slab.materialPreset || slab.material) { + return { material: slab.material, materialPreset: slab.materialPreset } + } + return null + }, +}) diff --git a/packages/nodes/src/slab/slots.ts b/packages/nodes/src/slab/slots.ts new file mode 100644 index 00000000..febe029b --- /dev/null +++ b/packages/nodes/src/slab/slots.ts @@ -0,0 +1,11 @@ +import type { SlotDeclaration } from '@pascal-app/core' + +export type SlabSlotId = 'surface' + +// Visual parity with the retired DEFAULT_SLAB_MATERIAL (light grey). +export const SLAB_SLOT_DEFAULT_COLOR = '#e5e5e5' + +/** A slab exposes a single paintable floor surface. */ +export function slabSlots(): SlotDeclaration[] { + return [{ slotId: 'surface', label: 'Surface', default: SLAB_SLOT_DEFAULT_COLOR }] +} diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 0e0face2..d200c436 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -6,6 +6,7 @@ import { wallFloorplanSiblingOverrides } from './floorplan-overrides' import { wallPaint } from './paint' import { wallParametrics } from './parametrics' import { WallNode } from './schema' +import { wallSlots } from './slots' /** * Wall — the Phase 3 stress test of the registry-driven node model. @@ -56,6 +57,11 @@ export const wallDefinition: NodeDefinition = { // preview through this entry rather than carrying a kind-name // arm. paint: wallPaint, + // Declared paintable slots (interior / exterior) with their default + // appearance — the same `{ slotId, label, default }` contract every other + // paintable kind exposes. Paint still writes the legacy inline fields via + // `wallPaint`; migrating those into `node.slots` is a later step. + slots: () => wallSlots(), }, relations: { diff --git a/packages/nodes/src/wall/slots.ts b/packages/nodes/src/wall/slots.ts new file mode 100644 index 00000000..d6bded90 --- /dev/null +++ b/packages/nodes/src/wall/slots.ts @@ -0,0 +1,17 @@ +import { type SlotDeclaration, WALL_SLOT_DEFAULT } from '@pascal-app/core' + +/** + * A wall exposes two paintable faces — interior + exterior. Painting still + * writes the legacy `interiorMaterial*` / `exteriorMaterial*` fields via + * `wallPaint` (the inline model isn't migrated to `node.slots` yet); this + * declaration surfaces the slot list + declared defaults for the picker and + * keeps walls on the same `{ slotId, label, default }` contract as every other + * paintable kind. The defaults come from core so the viewer's material + * resolver renders the identical value. + */ +export function wallSlots(): SlotDeclaration[] { + return [ + { slotId: 'interior', label: 'Interior', default: WALL_SLOT_DEFAULT.interior }, + { slotId: 'exterior', label: 'Exterior', default: WALL_SLOT_DEFAULT.exterior }, + ] +} diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts index 3c807dc8..a3769071 100644 --- a/packages/viewer/src/systems/wall/wall-materials.ts +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -2,7 +2,9 @@ import { getEffectiveWallSurfaceMaterial, getMaterialPresetByRef, getWallSurfaceMaterialSignature, + parseMaterialRef, resolveMaterial, + WALL_SLOT_DEFAULT, type WallNode, type WallSurfaceMaterialSpec, } from '@pascal-app/core' @@ -12,6 +14,7 @@ import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu' import { baseMaterial, type ColorPreset, + createDefaultMaterial, createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, @@ -88,6 +91,15 @@ function hasExplicitMaterial(spec: WallSurfaceMaterialSpec): boolean { return Boolean(spec.materialPreset || spec.material) } +// Resolve a wall face's declared default — a catalog `library:` finish or a +// flat colour — to a renderable material. +function resolveWallSlotDefault(slotDefault: string, shading: RenderShading): Material { + if (parseMaterialRef(slotDefault)?.kind === 'library') { + return createMaterialFromPresetRef(slotDefault, shading) ?? baseMaterial(shading) + } + return createDefaultMaterial(slotDefault, 0.9, shading) +} + function getSurfaceColor(spec: WallSurfaceMaterialSpec, fallback = DEFAULT_WALL_COLOR): string { const preset = getMaterialPresetByRef(spec.materialPreset) if (preset?.mapProperties?.color) { @@ -216,17 +228,19 @@ export function getMaterialsForWall( const exteriorSpec = getEffectiveWallSurfaceMaterial(wallNode, 'exterior') const wallRoleMaterial = createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme) - // Untextured surfaces take the themed wall role colour even with textures on; - // only surfaces with an explicit preset/material keep their texture. + // Colored mode: an unpainted face takes its declared slot default (parity + // with the retired DEFAULT_WALL_MATERIAL); only an explicit preset/material + // keeps a texture. Textures-off collapses every face to the themed wall role + // (the guaranteed escape hatch). The edge/cap slot (index 0) stays role-based. const visible: WallMaterialArray = textures ? [ wallRoleMaterial, hasExplicitMaterial(interiorSpec) ? getSurfaceVisibleMaterial(interiorSpec, shading) - : wallRoleMaterial, + : resolveWallSlotDefault(WALL_SLOT_DEFAULT.interior, shading), hasExplicitMaterial(exteriorSpec) ? getSurfaceVisibleMaterial(exteriorSpec, shading) - : wallRoleMaterial, + : resolveWallSlotDefault(WALL_SLOT_DEFAULT.exterior, shading), ] : [wallRoleMaterial, wallRoleMaterial, wallRoleMaterial]