feat(paint-slots): per-part paint for windows + doors, chrome/brass, world-scale UVs

Builds on the explicit per-mesh slot tagging (currentDoorSlot/currentWindowSlot):

- Per-part painting: door = panel/frame/glass/hardware, window = frame/glass,
  each independently paintable. The recessed door/window body sits behind the
  wall, so the proud invisible cutout wins the scene raycast over the wall and
  the shared resolveSlotByReRaycast() re-raycasts the kind's own subtree to pick
  the exact part under the cursor (panel↔frame↔glass↔hardware). Hover tracks the
  cursor via a  re-eval (idempotent, no flicker).
- Door frame is its own slot (separate frameMaterial); hardware = new flat
  'metal-chrome'.
- Library defaults (generic): panel/frame -> library:preset-softwhite, glass ->
  library:preset-glass (flipped preset-glass to FrontSide — DoubleSide poisons
  the WebGPU MRT pass; it's the only glass we use).
- Catalog: add flat (non-PBR) 'metal-chrome' + 'metal-brass'; drop metal
  metalness 1 -> 0.6 so metals are lit by existing lights (no env needed).
- World-scale UVs (1 unit = 1m) on door/window box meshes via shared box-uv.ts,
  so finishes tile at real-world scale instead of stretching.
- PaintResolveArgs gains an optional  for subtree re-raycasting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-17 14:01:37 -04:00
co-authored by Claude Opus 4.8
parent c3bd065f2f
commit 8aa179e9cb
11 changed files with 544 additions and 160 deletions
+10 -11
View File
@@ -1,17 +1,16 @@
import type { PaintResolveArgs } from '@pascal-app/core'
import { createSlotPaintCapability, previewSlotByUserData } from '../shared/slot-paint'
import {
createSlotPaintCapability,
previewSlotByUserData,
resolveSlotByReRaycast,
} 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]`.
* Door paint on the unified slot model. The door's opening proxy (a proud,
* invisible cutout) wins the shared scene raycast over the wall in front of the
* recessed door body, so `resolveSlotByReRaycast` re-raycasts the door's own
* subtree to find the part (panel / frame / glass / hardware) under the cursor.
*/
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,
resolveRole: resolveSlotByReRaycast,
applyPreview: previewSlotByUserData,
})
+13 -7
View File
@@ -1,19 +1,25 @@
import type { SlotDeclaration } from '@pascal-app/core'
export type DoorSlotId = 'panel' | 'glass'
export type DoorSlotId = 'panel' | 'frame' | 'glass' | 'hardware'
// 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'
// Picker swatches. Rendering falls back to the live body/glass/hardware defaults
// (which already track shading + theme), so these are just the indicator colours.
const PANEL_DEFAULT = 'library:preset-softwhite'
const FRAME_DEFAULT = 'library:preset-softwhite'
const GLASS_DEFAULT = 'library:preset-glass'
// Chrome — a flat (non-PBR) catalog metal finish.
const HARDWARE_DEFAULT = 'library:metal-chrome'
/**
* A door exposes two paintable slots: `panel` (the door body — frame casing +
* leaf) and `glass`. The opening reveal keeps its own material.
* A door exposes four paintable slots: `panel` (leaf faces), `frame`, `glass`,
* and `hardware` (handle / hinges / closer / panic bar). The opening reveal
* keeps its own material.
*/
export function doorSlots(): SlotDeclaration[] {
return [
{ slotId: 'panel', label: 'Panel', default: PANEL_DEFAULT },
{ slotId: 'frame', label: 'Frame', default: FRAME_DEFAULT },
{ slotId: 'glass', label: 'Glass', default: GLASS_DEFAULT },
{ slotId: 'hardware', label: 'Hardware', default: HARDWARE_DEFAULT },
]
}