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
+26 -1
View File
@@ -9,11 +9,12 @@ import {
parseMaterialRef,
type SceneMaterial,
type SceneMaterialId,
sceneRegistry,
toSceneMaterialRef,
useScene,
} from '@pascal-app/core'
import { createMaterial, createMaterialFromPresetRef, useViewer } from '@pascal-app/viewer'
import type { Material, Mesh, Object3D } from 'three'
import { type Material, type Mesh, type Object3D, Raycaster } from 'three'
/**
* Shared paint capability for procedural kinds on the unified slot model
@@ -197,6 +198,30 @@ export function previewSlotByUserData(args: PaintPreviewArgs): (() => void) | nu
}
}
// Reused across calls — set from the pointer ray each time.
const subtreeRaycaster = new Raycaster()
/**
* Resolve the slot for a kind whose paint hit lands on a proud opening proxy
* (door/window: a 1m-deep invisible cutout that wins the scene raycast over the
* wall in front of the recessed body) rather than the part itself. Re-raycasts
* the kind's OWN registered subtree (ignoring everything else) and returns the
* first tagged sub-mesh under the cursor; falls back to the direct hit's slot
* (e.g. a proud part the scene raycast hit directly).
*/
export function resolveSlotByReRaycast(args: PaintResolveArgs): string | null {
const direct = (args.hitObject?.userData as { slotId?: string } | undefined)?.slotId
if (typeof direct === 'string') return direct
const root = sceneRegistry.nodes.get(args.node.id as AnyNodeId)
if (!root || !args.ray) return null
subtreeRaycaster.ray.copy(args.ray)
for (const hit of subtreeRaycaster.intersectObject(root, true)) {
const slot = (hit.object.userData as { slotId?: string }).slotId
if (typeof slot === 'string') return slot
}
return null
}
export type SlotPaintConfig = {
/** Resolve the slot id for a pointer hit (`null` = not paintable here). */
resolveRole: (args: PaintResolveArgs) => string | null