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:
Wassim SAMAD
2026-06-17 08:54:18 -04:00
co-authored by Claude Opus 4.8
parent 737c4e9d1d
commit 9f1627e923
12 changed files with 266 additions and 1 deletions
+6
View File
@@ -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,
+17
View File
@@ -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,
})
+19
View File
@@ -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 },
]
}