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:
co-authored by
Claude Opus 4.8
parent
c3bd065f2f
commit
8aa179e9cb
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
/**
|
||||
* 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]`.
|
||||
* Window paint on the unified slot model. The window's opening proxy (a proud,
|
||||
* invisible cutout) wins the shared scene raycast over the wall in front of the
|
||||
* recessed window, so `resolveSlotByReRaycast` re-raycasts the window's own
|
||||
* subtree to find the part (frame / glass) under the cursor.
|
||||
*/
|
||||
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,
|
||||
resolveRole: resolveSlotByReRaycast,
|
||||
applyPreview: previewSlotByUserData,
|
||||
})
|
||||
|
||||
@@ -4,8 +4,8 @@ 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'
|
||||
const FRAME_DEFAULT = 'library:preset-softwhite'
|
||||
const GLASS_DEFAULT = 'library:preset-glass'
|
||||
|
||||
/** A window exposes two paintable slots: the joinery frame and the glass. */
|
||||
export function windowSlots(): SlotDeclaration[] {
|
||||
|
||||
Reference in New Issue
Block a user