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
+4
View File
@@ -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:<id>` / `scene:<id>`).
// 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]),
+4
View File
@@ -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:<id>` / `scene:<id>`). 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]),