From 9f1627e923d557a75d59ad9875607806a96ce084 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 17 Jun 2026 08:54:18 -0400 Subject: [PATCH] feat(paint-slots): paintable slots for windows + doors (frame/glass, panel/glass) Windows and doors build all visuals in their viewer systems from module-global materials, so this threads per-node slot materials + userData.slotId tags through those builders without restructuring them: - window: 'frame' + 'glass' slots. door: 'panel' (body = casing + leaf) + 'glass'; the opening reveal keeps its own material. - Each system captures per-frame viewer state, then updateWindow/DoorMesh points the builder-facing base/glass materials at the node's resolved slot override (recomputed per node, so the next node resets without a restore). Meshes are auto-tagged in the shared addBox/addShape helpers by which material they got. - Textures-off still collapses to the role material (escape hatch); a slot override only applies in colored mode. - Editing a referenced scene material re-dirties the window/door (these systems aren't covered by GeometrySystem's scene-material re-dirty). - New paint capabilities (resolve role from userData.slotId, preview by userData.slotId) + capabilities.slots; window/door dropped from the paint disabled list. Shared previewSlotByUserData helper. Defaults unchanged: unpainted windows/doors render exactly as before (the slot fallback is the existing frame/glass material), so no visual regression. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/schema/nodes/door.ts | 4 + packages/core/src/schema/nodes/window.ts | 4 + .../components/editor/selection-manager.tsx | 2 +- packages/nodes/src/door/definition.ts | 6 ++ packages/nodes/src/door/paint.ts | 17 +++++ packages/nodes/src/door/slots.ts | 19 +++++ packages/nodes/src/shared/slot-paint.ts | 28 +++++++ packages/nodes/src/window/definition.ts | 6 ++ packages/nodes/src/window/paint.ts | 17 +++++ packages/nodes/src/window/slots.ts | 16 ++++ .../viewer/src/systems/door/door-system.tsx | 72 ++++++++++++++++++ .../src/systems/window/window-system.tsx | 76 +++++++++++++++++++ 12 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 packages/nodes/src/door/paint.ts create mode 100644 packages/nodes/src/door/slots.ts create mode 100644 packages/nodes/src/window/paint.ts create mode 100644 packages/nodes/src/window/slots.ts diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index fd26ad68..26b4170e 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -41,6 +41,10 @@ export const DoorNode = BaseNode.extend({ id: objectId('door'), type: nodeType('door'), material: MaterialSchema.optional(), + // Per-slot material overrides on the unified slot model. Keys: `panel` (the + // door body), `glass`. Value = a `MaterialRef` (`library:` / `scene:`). + // Absent = the body/glass default. Mirrors `ShelfNode.slots`. + slots: z.record(z.string(), z.string()).optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts index c5a1f5f0..08c5ce4e 100644 --- a/packages/core/src/schema/nodes/window.ts +++ b/packages/core/src/schema/nodes/window.ts @@ -21,6 +21,10 @@ export const WindowNode = BaseNode.extend({ id: objectId('window'), type: nodeType('window'), material: MaterialSchema.optional(), + // Per-slot material overrides on the unified slot model. Keys: `frame`, + // `glass`. Value = a `MaterialRef` (`library:` / `scene:`). Absent = + // the frame/glass default. Mirrors `ShelfNode.slots`. + slots: z.record(z.string(), z.string()).optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 965131f6..ffaf5aa8 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -1088,7 +1088,7 @@ export const SelectionManager = () => { } } - const disabledNodeTypes = ['window', 'door', 'zone'] + const disabledNodeTypes = ['zone'] if (disabledNodeTypes.includes(node.type)) { return { key: `${node.type}:${node.id}:unsupported`, diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index f006e263..4846413c 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -12,8 +12,10 @@ import { scaleHandleHeight } from './door-math' import { buildDoorFloorplan } from './floorplan' import { doorWidthAffordance } from './floorplan-affordances' import { doorFloorplanMoveTarget } from './floorplan-move' +import { doorPaint } from './paint' import { doorParametrics } from './parametrics' import { DoorNode } from './schema' +import { doorSlots } from './slots' const SIDE_HANDLE_OFFSET = 0.24 const HEIGHT_HANDLE_OFFSET = 0.24 @@ -174,6 +176,10 @@ export const doorDefinition: NodeDefinition = { // placed. Host apps strip these at preset-save time via // `getHostRefFields(def)`. hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'], + // Panel / glass slots painted through the registry. The door system tags + // each mesh with its `userData.slotId`; paint writes `node.slots`. + slots: () => doorSlots(), + paint: doorPaint, }, parametrics: doorParametrics, diff --git a/packages/nodes/src/door/paint.ts b/packages/nodes/src/door/paint.ts new file mode 100644 index 00000000..b1d50f6a --- /dev/null +++ b/packages/nodes/src/door/paint.ts @@ -0,0 +1,17 @@ +import type { PaintResolveArgs } from '@pascal-app/core' +import { createSlotPaintCapability, previewSlotByUserData } from '../shared/slot-paint' + +/** + * Door paint on the unified slot model. The door's viewer system tags each built + * mesh with `userData.slotId` (`panel` / `glass`), so the role resolves straight + * from the pointer hit; commit writes `node.slots[slotId]`. + */ +function resolveDoorRole(args: PaintResolveArgs): string | null { + const slotId = (args.hitObject?.userData as { slotId?: string | null } | undefined)?.slotId + return typeof slotId === 'string' ? slotId : null +} + +export const doorPaint = createSlotPaintCapability({ + resolveRole: resolveDoorRole, + applyPreview: previewSlotByUserData, +}) diff --git a/packages/nodes/src/door/slots.ts b/packages/nodes/src/door/slots.ts new file mode 100644 index 00000000..89de6c69 --- /dev/null +++ b/packages/nodes/src/door/slots.ts @@ -0,0 +1,19 @@ +import type { SlotDeclaration } from '@pascal-app/core' + +export type DoorSlotId = 'panel' | 'glass' + +// Picker swatches. Rendering falls back to the live body/glass defaults (which +// already track shading + theme), so these are just the indicator colours. +const PANEL_DEFAULT = '#f2f0ed' +const GLASS_DEFAULT = '#87ceeb' + +/** + * A door exposes two paintable slots: `panel` (the door body — frame casing + + * leaf) and `glass`. The opening reveal keeps its own material. + */ +export function doorSlots(): SlotDeclaration[] { + return [ + { slotId: 'panel', label: 'Panel', default: PANEL_DEFAULT }, + { slotId: 'glass', label: 'Glass', default: GLASS_DEFAULT }, + ] +} diff --git a/packages/nodes/src/shared/slot-paint.ts b/packages/nodes/src/shared/slot-paint.ts index fa206d6f..eb594958 100644 --- a/packages/nodes/src/shared/slot-paint.ts +++ b/packages/nodes/src/shared/slot-paint.ts @@ -169,6 +169,34 @@ export function previewGeometrySlot(args: PaintPreviewArgs): (() => void) | null } } +/** + * Preview for kinds whose meshes are built by a viewer system (window, door) + * and tagged with `userData.slotId` — no `__fromGeometry` marker and no hosted + * children to guard against, so it swaps every mesh whose slot matches `role`. + */ +export function previewSlotByUserData(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 + if ((mesh.userData as { slotId?: string | null }).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 diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index 96f8b11c..bdd65963 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -11,8 +11,10 @@ import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' import { buildWindowFloorplan } from './floorplan' import { windowWidthAffordance } from './floorplan-affordances' import { windowFloorplanMoveTarget } from './floorplan-move' +import { windowPaint } from './paint' import { windowParametrics } from './parametrics' import { WindowNode } from './schema' +import { windowSlots } from './slots' const SIDE_HANDLE_OFFSET = 0.24 const HEIGHT_HANDLE_OFFSET = 0.24 @@ -162,6 +164,10 @@ export const windowDefinition: NodeDefinition = { // `wallId` / `roofSegmentId` are re-derived from the surface under // the cursor at preset placement time — see door for the pattern. hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'], + // Frame / glass slots painted through the registry. The window system tags + // each mesh with its `userData.slotId`; paint writes `node.slots`. + slots: () => windowSlots(), + paint: windowPaint, }, parametrics: windowParametrics, diff --git a/packages/nodes/src/window/paint.ts b/packages/nodes/src/window/paint.ts new file mode 100644 index 00000000..471374eb --- /dev/null +++ b/packages/nodes/src/window/paint.ts @@ -0,0 +1,17 @@ +import type { PaintResolveArgs } from '@pascal-app/core' +import { createSlotPaintCapability, previewSlotByUserData } from '../shared/slot-paint' + +/** + * Window paint on the unified slot model. The window's viewer system tags each + * built mesh with `userData.slotId` (`frame` / `glass`), so the role resolves + * straight from the pointer hit; commit writes `node.slots[slotId]`. + */ +function resolveWindowRole(args: PaintResolveArgs): string | null { + const slotId = (args.hitObject?.userData as { slotId?: string | null } | undefined)?.slotId + return typeof slotId === 'string' ? slotId : null +} + +export const windowPaint = createSlotPaintCapability({ + resolveRole: resolveWindowRole, + applyPreview: previewSlotByUserData, +}) diff --git a/packages/nodes/src/window/slots.ts b/packages/nodes/src/window/slots.ts new file mode 100644 index 00000000..ad7829b0 --- /dev/null +++ b/packages/nodes/src/window/slots.ts @@ -0,0 +1,16 @@ +import type { SlotDeclaration } from '@pascal-app/core' + +export type WindowSlotId = 'frame' | 'glass' + +// Picker swatches. Rendering falls back to the live frame/glass defaults (which +// already track shading + theme), so these are just the indicator colours. +const FRAME_DEFAULT = '#f2f0ed' +const GLASS_DEFAULT = '#87ceeb' + +/** A window exposes two paintable slots: the joinery frame and the glass. */ +export function windowSlots(): SlotDeclaration[] { + return [ + { slotId: 'frame', label: 'Frame', default: FRAME_DEFAULT }, + { slotId: 'glass', label: 'Glass', default: GLASS_DEFAULT }, + ] +} diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index 2a93b926..86c9587b 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -5,6 +5,8 @@ import { DoorNode as DoorNodeSchema, getDoorRenderOpenAmount, getEffectiveNode, + type SceneMaterial, + type SceneMaterialId, sceneRegistry, useInteractive, useLiveNodeOverrides, @@ -14,9 +16,12 @@ import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' import * as THREE from 'three' import { + type ColorPreset, createSurfaceRoleMaterial, glassMaterial as defaultGlassMaterial, baseMaterial as getBaseMaterial, + type RenderShading, + resolveMaterialRef, } from '../../lib/materials' import useViewer from '../../store/use-viewer' @@ -26,6 +31,12 @@ const defaultRevealMaterial = new THREE.MeshBasicMaterial({ color: '#7f766c' }) let baseMaterial = getBaseMaterial() let revealMaterial: THREE.Material = defaultRevealMaterial let glassMaterial: THREE.Material = defaultGlassMaterial +// Per-frame viewer state, captured so the per-node mesh builder (which runs +// outside React) can resolve each door's slot materials. +let currentShading: RenderShading = 'rendered' +let currentTextures = true +let currentColorPreset: ColorPreset = 'clay' +let currentSceneMaterials: Record | undefined const DOOR_RENDER_DEFAULTS = DoorNodeSchema.parse({ id: 'door_render_default' }) const MAX_DOOR_REBUILDS_PER_FRAME = 16 @@ -50,6 +61,7 @@ export const DoorSystem = () => { const shading = useViewer((state) => state.shading) const textures = useViewer((state) => state.textures) const colorPreset = useViewer((state) => state.colorPreset) + const sceneMaterials = useScene((state) => state.materials) const materialRevisionRef = useRef(null) // Subscribe so an override-only update (no scene write) still re-runs // the component, letting the gate below pick up the latest dirtyNodes @@ -75,12 +87,27 @@ export const DoorSystem = () => { } }) + // Editing a scene material a door slot references must rebuild that door + // (door meshes are built by this system, not ). + useEffect(() => { + const nodes = useScene.getState().nodes + for (const node of Object.values(nodes)) { + if (node?.type !== 'door') continue + if (!nodeReferencesSceneMaterial(node)) continue + useScene.getState().dirtyNodes.add(node.id as AnyNodeId) + } + }, [sceneMaterials]) + useFrame(() => { if (dirtyNodes.size === 0) return const frameJoineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset) baseMaterial = textures ? getBaseMaterial(shading) : frameJoineryMaterial revealMaterial = textures ? defaultRevealMaterial : frameJoineryMaterial glassMaterial = textures ? defaultGlassMaterial : frameJoineryMaterial + currentShading = shading + currentTextures = textures + currentColorPreset = colorPreset + currentSceneMaterials = sceneMaterials const nodes = useScene.getState().nodes const dirtyDoorIds: AnyNodeId[] = [] @@ -134,6 +161,40 @@ export const DoorSystem = () => { return null } +// A door exposes two slots: `panel` (the door body — frame casing + leaf, all +// built with `baseMaterial`) and `glass`. The reveal (opening depth) is left on +// its own material and isn't painted. Tag each mesh by which material it got. +function tagDoorSlot(mesh: THREE.Mesh): THREE.Mesh { + if (mesh.material === glassMaterial) mesh.userData.slotId = 'glass' + else if (mesh.material === baseMaterial) mesh.userData.slotId = 'panel' + return mesh +} + +function nodeReferencesSceneMaterial(node: { slots?: Record }): boolean { + const slots = node.slots + if (!slots) return false + for (const ref of Object.values(slots)) { + if (typeof ref === 'string' && ref.startsWith('scene:')) return true + } + return false +} + +function doorSlotDefault(slotId: 'panel' | 'glass'): THREE.Material { + if (!currentTextures) return createSurfaceRoleMaterial('joinery', currentColorPreset) + return slotId === 'glass' ? defaultGlassMaterial : getBaseMaterial(currentShading) +} + +// Resolve a door's slot to a material: the `node.slots` override (colored mode +// only) → the body/glass default. Textures-off ignores overrides — the +// monochrome escape hatch. +function resolveDoorSlotMaterial(node: DoorNode, slotId: 'panel' | 'glass'): THREE.Material { + const fallback = doorSlotDefault(slotId) + if (!currentTextures) return fallback + const ref = node.slots?.[slotId] + if (!ref) return fallback + return resolveMaterialRef(ref, currentSceneMaterials, currentShading) ?? fallback +} + function addBox( parent: THREE.Object3D, material: THREE.Material, @@ -146,6 +207,7 @@ function addBox( ) { const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) m.position.set(x, y, z) + tagDoorSlot(m) parent.add(m) } @@ -163,6 +225,7 @@ function addRotatedBox( const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) m.position.set(x, y, z) m.rotation.y = rotationY + tagDoorSlot(m) parent.add(m) } @@ -180,6 +243,7 @@ function addBoxWithRotation( const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) m.position.set(x, y, z) m.rotation.set(rotation[0], rotation[1], rotation[2]) + tagDoorSlot(m) parent.add(m) } @@ -196,6 +260,7 @@ function addShape( }) geometry.translate(0, 0, -depth / 2) const mesh = new THREE.Mesh(geometry, material) + tagDoorSlot(mesh) parent.add(mesh) } @@ -215,6 +280,7 @@ function addShapeAt( }) geometry.translate(x, y, z - depth / 2) const mesh = new THREE.Mesh(geometry, material) + tagDoorSlot(mesh) parent.add(mesh) } @@ -1968,6 +2034,12 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { mesh.remove(child) } + // Point the builder-facing body/glass materials at this door's slot overrides + // for the duration of its build (recomputed per node, so the next door resets + // cleanly without a restore). Reveal keeps its own material. + baseMaterial = resolveDoorSlotMaterial(node, 'panel') + glassMaterial = resolveDoorSlotMaterial(node, 'glass') + const { width, height, diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index 6f953877..e49101f0 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -1,6 +1,8 @@ import { type AnyNodeId, getEffectiveNode, + type SceneMaterial, + type SceneMaterialId, sceneRegistry, useInteractive, useLiveNodeOverrides, @@ -11,9 +13,12 @@ import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' import * as THREE from 'three' import { + type ColorPreset, createSurfaceRoleMaterial, glassMaterial as defaultGlassMaterial, baseMaterial as getBaseMaterial, + type RenderShading, + resolveMaterialRef, } from '../../lib/materials' import useViewer from '../../store/use-viewer' @@ -21,6 +26,12 @@ import useViewer from '../../store/use-viewer' const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) let baseMaterial = getBaseMaterial() let glassMaterial: THREE.Material = defaultGlassMaterial +// Per-frame viewer state, captured so the per-node mesh builder (which runs +// outside React) can resolve each window's slot materials. +let currentShading: RenderShading = 'rendered' +let currentTextures = true +let currentColorPreset: ColorPreset = 'clay' +let currentSceneMaterials: Record | undefined export const CASEMENT_WINDOW_SASH_NAME = 'casement-window-sash' export const FRENCH_CASEMENT_LEFT_SASH_NAME = 'french-casement-left-sash' export const FRENCH_CASEMENT_RIGHT_SASH_NAME = 'french-casement-right-sash' @@ -42,6 +53,7 @@ export const WindowSystem = () => { const shading = useViewer((state) => state.shading) const textures = useViewer((state) => state.textures) const colorPreset = useViewer((state) => state.colorPreset) + const sceneMaterials = useScene((state) => state.materials) const materialRevisionRef = useRef(null) // Subscribe so override-only updates re-run this component. Mirrors // WallSystem + DoorSystem. @@ -67,6 +79,18 @@ export const WindowSystem = () => { } }) + // Editing a scene material a window slot references must rebuild that window + // (window meshes are built by this system, not , so its + // scene-material re-dirty doesn't cover them). + useEffect(() => { + const nodes = useScene.getState().nodes + for (const node of Object.values(nodes)) { + if (node?.type !== 'window') continue + if (!nodeReferencesSceneMaterial(node)) continue + useScene.getState().dirtyNodes.add(node.id as AnyNodeId) + } + }, [sceneMaterials]) + useFrame(() => { if (dirtyNodes.size === 0) return baseMaterial = textures @@ -75,6 +99,10 @@ export const WindowSystem = () => { glassMaterial = textures ? defaultGlassMaterial : createSurfaceRoleMaterial('glazing', colorPreset) + currentShading = shading + currentTextures = textures + currentColorPreset = colorPreset + currentSceneMaterials = sceneMaterials const nodes = useScene.getState().nodes const dirtyWindowIds: AnyNodeId[] = [] @@ -128,6 +156,46 @@ export const WindowSystem = () => { return null } +// A window exposes two slots: `frame` (every joinery member) and `glass`. The +// builders pass `baseMaterial` / `glassMaterial`, so tag each mesh by which one +// it got — that's what `(nodeId, slotId)` paint resolves against. +function tagWindowSlot(mesh: THREE.Mesh): THREE.Mesh { + if (mesh.material === glassMaterial) mesh.userData.slotId = 'glass' + else if (mesh.material === baseMaterial) mesh.userData.slotId = 'frame' + return mesh +} + +function nodeReferencesSceneMaterial(node: { slots?: Record }): boolean { + const slots = node.slots + if (!slots) return false + for (const ref of Object.values(slots)) { + if (typeof ref === 'string' && ref.startsWith('scene:')) return true + } + return false +} + +function windowSlotDefault(slotId: 'frame' | 'glass'): THREE.Material { + if (slotId === 'glass') { + return currentTextures + ? defaultGlassMaterial + : createSurfaceRoleMaterial('glazing', currentColorPreset) + } + return currentTextures + ? getBaseMaterial(currentShading) + : createSurfaceRoleMaterial('joinery', currentColorPreset) +} + +// Resolve a window's slot to a material: the `node.slots` override (colored mode +// only) → the role/base default. Textures-off ignores overrides — the monochrome +// escape hatch. +function resolveWindowSlotMaterial(node: WindowNode, slotId: 'frame' | 'glass'): THREE.Material { + const fallback = windowSlotDefault(slotId) + if (!currentTextures) return fallback + const ref = node.slots?.[slotId] + if (!ref) return fallback + return resolveMaterialRef(ref, currentSceneMaterials, currentShading) ?? fallback +} + function addBox( parent: THREE.Object3D, material: THREE.Material, @@ -140,6 +208,7 @@ function addBox( ) { const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) m.position.set(x, y, z) + tagWindowSlot(m) parent.add(m) } @@ -157,6 +226,7 @@ function addShape( }) geometry.translate(0, 0, -depth / 2 + z) const mesh = new THREE.Mesh(geometry, material) + tagWindowSlot(mesh) parent.add(mesh) } @@ -3170,6 +3240,12 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { mesh.remove(child) } + // Point the builder-facing frame/glass materials at this window's slot + // overrides for the duration of its build (recomputed per node, so the next + // window resets cleanly without a restore). + baseMaterial = resolveWindowSlotMaterial(node, 'frame') + glassMaterial = resolveWindowSlotMaterial(node, 'glass') + const { width, height,