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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
737c4e9d1d
commit
9f1627e923
@@ -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<typeof DoorNode> = {
|
||||
// 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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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 },
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof WindowNode> = {
|
||||
// `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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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 },
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user