From 8aa179e9cb7b94fc4e45a048c23c20eb62ff4037 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 17 Jun 2026 14:01:37 -0400 Subject: [PATCH] feat(paint-slots): per-part paint for windows + doors, chrome/brass, world-scale UVs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/core/src/material-library.ts | 80 ++++- packages/core/src/registry/types.ts | 10 +- .../components/editor/selection-manager.tsx | 32 +- packages/nodes/src/door/paint.ts | 21 +- packages/nodes/src/door/slots.ts | 20 +- packages/nodes/src/shared/slot-paint.ts | 27 +- packages/nodes/src/window/paint.ts | 21 +- packages/nodes/src/window/slots.ts | 4 +- packages/viewer/src/lib/box-uv.ts | 40 +++ .../viewer/src/systems/door/door-system.tsx | 293 +++++++++++++----- .../src/systems/window/window-system.tsx | 156 ++++++++-- 11 files changed, 544 insertions(+), 160 deletions(-) create mode 100644 packages/viewer/src/lib/box-uv.ts diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index 88b7068b..eedf63e4 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -3442,7 +3442,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, - side: 2, + // FrontSide — DoubleSide on a NodeMaterial poisons the WebGPU MRT scene + // pass (window/door glass relies on this). It's the only glass we use. + side: 0, opacity: 0.3, lightMapIntensity: 1, }, @@ -3953,7 +3955,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ mapProperties: { color: '#ffffff', roughness: 0.4, - metalness: 1, + metalness: 0.6, repeatX: 1, repeatY: 1, rotation: 0, @@ -3992,7 +3994,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ mapProperties: { color: '#ffffff', roughness: 0.3, - metalness: 1, + metalness: 0.6, repeatX: 1, repeatY: 1, rotation: 0, @@ -4033,7 +4035,77 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ mapProperties: { color: '#ffffff', roughness: 0.45, - metalness: 1, + metalness: 0.6, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + // Parameter-only metal (no texture maps) — a worked example of a non-PBR + // catalog finish driven purely by three.js material settings. + id: 'metal-brass', + label: 'Brass', + category: 'metal', + surfaces: ['furniture', 'wall'], + description: 'Polished brass (flat metal, no maps)', + previewColor: '#b08d57', + preset: { + maps: {}, + mapProperties: { + color: '#b08d57', + roughness: 0.35, + metalness: 0.9, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + // Parameter-only chrome (no texture maps) — moderate metalness so it reads + // as bright metal under direct/ambient light without needing an env map. + id: 'metal-chrome', + label: 'Chrome', + category: 'metal', + surfaces: ['furniture', 'wall'], + description: 'Polished chrome (flat metal, no maps)', + previewColor: '#c8ccce', + preset: { + maps: {}, + mapProperties: { + color: '#c8ccce', + roughness: 0.2, + metalness: 0.6, repeatX: 1, repeatY: 1, rotation: 0, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index bbeca31a..826f1332 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1,5 +1,5 @@ import type { ComponentType } from 'react' -import type { BufferGeometry, Object3D } from 'three' +import type { BufferGeometry, Object3D, Ray } from 'three' import type { ZodObject, z } from 'zod' import type { MaterialSchema } from '../schema/material' import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material' @@ -1212,6 +1212,14 @@ export type PaintResolveArgs = { hitObjectName?: string /** Optional: the three.js object that received the pointer hit. Items read userData.slotId off it. */ hitObject?: Object3D + /** + * Optional: the pointer's world ray, so a kind can re-raycast its OWN subtree + * to pick the precise sub-mesh under the cursor — independent of what the + * shared scene raycast hit first. Door/window use this: their opening proxy + * (a proud invisible cutout) wins the scene raycast over the wall in front of + * the recessed door body, then they re-raycast their parts to find the slot. + */ + ray?: Ray } export type PaintPatchArgs = { diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index a7b28095..e6cc48c1 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -871,14 +871,6 @@ export const SelectionManager = () => { const activePaintMaterial = resolveActivePaintMaterial() const node = event.node - // TEMP paint debug - if (node.type === 'window' || node.type === 'door') { - // biome-ignore lint/suspicious/noConsole: temporary paint diagnostics - console.log('[paint-debug] event arrived', node.type, { - inLevel: isNodeInCurrentLevel(node), - }) - } - if (!isNodeInCurrentLevel(node)) return null // The eraser clears a surface back to its default by painting with an @@ -906,18 +898,6 @@ export const SelectionManager = () => { // roof / stair / single-surface arms below stay until they // migrate too. const paintCap = nodeRegistry.get(node.type)?.capabilities?.paint - // TEMP paint debug - if (node.type === 'window' || node.type === 'door') { - const ho = getEventObject(event) - // biome-ignore lint/suspicious/noConsole: temporary paint diagnostics - console.log('[paint-debug] getPaintInteraction', node.type, { - hasPaintCap: !!paintCap, - hitObjName: ho?.name, - hitSlotId: (ho?.userData as { slotId?: string } | undefined)?.slotId, - eventObjName: event.nativeEvent.object?.name, - paintEnabled, - }) - } if (paintCap) { const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex) const role = paintCap.resolveRole({ @@ -927,11 +907,8 @@ export const SelectionManager = () => { localPosition: event.localPosition as readonly [number, number, number] | undefined, hitObjectName: event.nativeEvent.object?.name, hitObject: getEventObject(event), + ray: event.nativeEvent.ray, }) - if (node.type === 'window' || node.type === 'door') { - // biome-ignore lint/suspicious/noConsole: temporary paint diagnostics - console.log('[paint-debug] resolved role', node.type, role) - } const compatible = role !== null && paintEnabled return { key: `${node.type}:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`, @@ -1209,6 +1186,11 @@ export const SelectionManager = () => { for (const type of subscribedKinds) { emitter.on(`${type}:enter` as any, onEnter as any) + // Re-evaluate on move so the hover preview tracks the cursor across a + // kind's sub-parts (door/window panel↔frame↔glass↔hardware, wall + // interior↔exterior) — not just on the initial enter. onEnter is + // idempotent (no-ops when the resolved part is unchanged). + emitter.on(`${type}:move` as any, onEnter as any) emitter.on(`${type}:leave` as any, onLeave as any) emitter.on(`${type}:click` as any, onClick as any) } @@ -1216,6 +1198,7 @@ export const SelectionManager = () => { return () => { for (const type of subscribedKinds) { emitter.off(`${type}:enter` as any, onEnter as any) + emitter.off(`${type}:move` as any, onEnter as any) emitter.off(`${type}:leave` as any, onLeave as any) emitter.off(`${type}:click` as any, onClick as any) } @@ -1576,6 +1559,7 @@ export const SelectionManager = () => { localPosition: event.localPosition as readonly [number, number, number] | undefined, hitObjectName: event.nativeEvent.object?.name, hitObject: getEventObject(event), + ray: event.nativeEvent.ray, }) if (role) { setSelectedMaterialTargetForNode(nodeToSelect, role as MaterialTargetRole) diff --git a/packages/nodes/src/door/paint.ts b/packages/nodes/src/door/paint.ts index b1d50f6a..e236a5e3 100644 --- a/packages/nodes/src/door/paint.ts +++ b/packages/nodes/src/door/paint.ts @@ -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, }) diff --git a/packages/nodes/src/door/slots.ts b/packages/nodes/src/door/slots.ts index 89de6c69..2e221879 100644 --- a/packages/nodes/src/door/slots.ts +++ b/packages/nodes/src/door/slots.ts @@ -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 }, ] } diff --git a/packages/nodes/src/shared/slot-paint.ts b/packages/nodes/src/shared/slot-paint.ts index eb594958..c2d24066 100644 --- a/packages/nodes/src/shared/slot-paint.ts +++ b/packages/nodes/src/shared/slot-paint.ts @@ -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 diff --git a/packages/nodes/src/window/paint.ts b/packages/nodes/src/window/paint.ts index 471374eb..ca5120d2 100644 --- a/packages/nodes/src/window/paint.ts +++ b/packages/nodes/src/window/paint.ts @@ -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, }) diff --git a/packages/nodes/src/window/slots.ts b/packages/nodes/src/window/slots.ts index ad7829b0..4067d4d4 100644 --- a/packages/nodes/src/window/slots.ts +++ b/packages/nodes/src/window/slots.ts @@ -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[] { diff --git a/packages/viewer/src/lib/box-uv.ts b/packages/viewer/src/lib/box-uv.ts new file mode 100644 index 00000000..fe73d272 --- /dev/null +++ b/packages/viewer/src/lib/box-uv.ts @@ -0,0 +1,40 @@ +import type { BoxGeometry } from 'three' + +/** + * Rewrite a default `BoxGeometry`'s UVs to world scale — 1 UV unit = 1 metre — + * so tiled finishes (with `repeat` in tiles-per-metre) render at a consistent + * real-world scale instead of stretching to fit each face. Matches the + * world-scale UV convention used by the procedural slab/wall geometry. + * + * three.js builds box faces in the fixed order [+X, -X, +Y, -Y, +Z, -Z], four + * verts each, with UVs spanning 0→1 across the face. Each face's two in-plane + * dimensions differ, so we scale U/V per face by that face's size in metres. + */ +export function applyWorldScaleBoxUVs( + geometry: BoxGeometry, + w: number, + h: number, + d: number, +): void { + const uv = geometry.getAttribute('uv') + if (!uv || uv.count < 24) return // non-default segmentation — leave as-is + + // [uScaleMetres, vScaleMetres] per face, in three's face order. + const faceScale: Array<[number, number]> = [ + [d, h], // +X + [d, h], // -X + [w, d], // +Y + [w, d], // -Y + [w, h], // +Z + [w, h], // -Z + ] + + for (let face = 0; face < 6; face += 1) { + const [us, vs] = faceScale[face]! + for (let v = 0; v < 4; v += 1) { + const i = face * 4 + v + uv.setXY(i, uv.getX(i) * us, uv.getY(i) * vs) + } + } + uv.needsUpdate = true +} diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index 257798e3..3342f4fd 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -15,8 +15,10 @@ import { import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' import * as THREE from 'three' +import { applyWorldScaleBoxUVs } from '../../lib/box-uv' import { type ColorPreset, + createDefaultMaterial, createSurfaceRoleMaterial, glassMaterial as defaultGlassMaterial, baseMaterial as getBaseMaterial, @@ -27,12 +29,24 @@ import useViewer from '../../store/use-viewer' // Invisible material for root mesh — used as selection hitbox only const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) -// Disables a mesh's own raycast so its children become the hit targets. -const noopHitboxRaycast: THREE.Mesh['raycast'] = () => {} const defaultRevealMaterial = new THREE.MeshBasicMaterial({ color: '#7f766c' }) +// Door hardware (handle / hinges / closer / panic bar) renders a catalog metal +// finish by default (chrome), separate from the door body. The flat material is +// only a fallback if the catalog ref ever fails to resolve. +const HARDWARE_DEFAULT_REF = 'library:metal-chrome' +// Door body defaults to a catalog colour (generic approach). Glass keeps the +// built-in FrontSide glass material — the catalog `preset-glass` is DoubleSide, +// which poisons the WebGPU MRT scene pass. +const PANEL_DEFAULT_REF = 'library:preset-softwhite' +const FRAME_DEFAULT_REF = 'library:preset-softwhite' +const GLASS_DEFAULT_REF = 'library:preset-glass' +const defaultHardwareMaterial = createDefaultMaterial('#3a3a3a', 0.4) let baseMaterial = getBaseMaterial() +let frameMaterial: THREE.Material = getBaseMaterial() let revealMaterial: THREE.Material = defaultRevealMaterial let glassMaterial: THREE.Material = defaultGlassMaterial +let hardwareMaterial: THREE.Material = defaultHardwareMaterial +let currentDoorSlot: string | undefined // Per-frame viewer state, captured so the per-node mesh builder (which runs // outside React) can resolve each door's slot materials. let currentShading: RenderShading = 'rendered' @@ -73,8 +87,10 @@ export const DoorSystem = () => { const joineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset) baseMaterial = textures ? getBaseMaterial(shading) : joineryMaterial + frameMaterial = textures ? getBaseMaterial(shading) : joineryMaterial revealMaterial = textures ? defaultRevealMaterial : joineryMaterial glassMaterial = textures ? defaultGlassMaterial : joineryMaterial + hardwareMaterial = textures ? defaultHardwareMaterial : joineryMaterial useEffect(() => { const materialRevision = `${shading}:${textures ? 'textures' : 'solid'}:${colorPreset}` @@ -104,8 +120,10 @@ export const DoorSystem = () => { if (dirtyNodes.size === 0) return const frameJoineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset) baseMaterial = textures ? getBaseMaterial(shading) : frameJoineryMaterial + frameMaterial = textures ? getBaseMaterial(shading) : frameJoineryMaterial revealMaterial = textures ? defaultRevealMaterial : frameJoineryMaterial glassMaterial = textures ? defaultGlassMaterial : frameJoineryMaterial + hardwareMaterial = textures ? defaultHardwareMaterial : frameJoineryMaterial currentShading = shading currentTextures = textures currentColorPreset = colorPreset @@ -163,12 +181,8 @@ export const DoorSystem = () => { return null } -// A door exposes two slots: `panel` (the door body — frame casing + leaf, all -// built with `baseMaterial`) and `glass`. The reveal (opening depth) is left on -// its own material and isn't painted. Tag each mesh by which material it got. function tagDoorSlot(mesh: THREE.Mesh): THREE.Mesh { - if (mesh.material === glassMaterial) mesh.userData.slotId = 'glass' - else if (mesh.material === baseMaterial) mesh.userData.slotId = 'panel' + mesh.userData.slotId = currentDoorSlot return mesh } @@ -181,15 +195,38 @@ function nodeReferencesSceneMaterial(node: { slots?: Record }): return false } -function doorSlotDefault(slotId: 'panel' | 'glass'): THREE.Material { +type DoorMaterialSlotId = 'panel' | 'frame' | 'glass' | 'hardware' + +function doorSlotDefault(slotId: DoorMaterialSlotId): THREE.Material { if (!currentTextures) return createSurfaceRoleMaterial('joinery', currentColorPreset) - return slotId === 'glass' ? defaultGlassMaterial : getBaseMaterial(currentShading) + if (slotId === 'glass') { + return ( + resolveMaterialRef(GLASS_DEFAULT_REF, currentSceneMaterials, currentShading) ?? + defaultGlassMaterial + ) + } + if (slotId === 'hardware') { + return ( + resolveMaterialRef(HARDWARE_DEFAULT_REF, currentSceneMaterials, currentShading) ?? + defaultHardwareMaterial + ) + } + if (slotId === 'frame') { + return ( + resolveMaterialRef(FRAME_DEFAULT_REF, currentSceneMaterials, currentShading) ?? + getBaseMaterial(currentShading) + ) + } + return ( + resolveMaterialRef(PANEL_DEFAULT_REF, currentSceneMaterials, currentShading) ?? + getBaseMaterial(currentShading) + ) } // Resolve a door's slot to a material: the `node.slots` override (colored mode -// only) → the body/glass default. Textures-off ignores overrides — the +// only) → the body/glass/hardware default. Textures-off ignores overrides — the // monochrome escape hatch. -function resolveDoorSlotMaterial(node: DoorNode, slotId: 'panel' | 'glass'): THREE.Material { +function resolveDoorSlotMaterial(node: DoorNode, slotId: DoorMaterialSlotId): THREE.Material { const fallback = doorSlotDefault(slotId) if (!currentTextures) return fallback const ref = node.slots?.[slotId] @@ -207,7 +244,9 @@ function addBox( y: number, z: number, ) { - const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) + const geometry = new THREE.BoxGeometry(w, h, d) + applyWorldScaleBoxUVs(geometry, w, h, d) + const m = new THREE.Mesh(geometry, material) m.position.set(x, y, z) tagDoorSlot(m) parent.add(m) @@ -224,7 +263,9 @@ function addRotatedBox( z: number, rotationY: number, ) { - const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) + const geometry = new THREE.BoxGeometry(w, h, d) + applyWorldScaleBoxUVs(geometry, w, h, d) + const m = new THREE.Mesh(geometry, material) m.position.set(x, y, z) m.rotation.y = rotationY tagDoorSlot(m) @@ -242,7 +283,9 @@ function addBoxWithRotation( z: number, rotation: [number, number, number], ) { - const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) + const geometry = new THREE.BoxGeometry(w, h, d) + applyWorldScaleBoxUVs(geometry, w, h, d) + const m = new THREE.Mesh(geometry, material) m.position.set(x, y, z) m.rotation.set(rotation[0], rotation[1], rotation[2]) tagDoorSlot(m) @@ -825,6 +868,7 @@ function addLeafSegmentContent({ const cpX = contentPadding[0] const cpY = contentPadding[1] if (renderPerimeterFrame && shouldRenderFrame && cpY > 0) { + currentDoorSlot = 'panel' addLeafBox( baseMaterial, leafWidth, @@ -846,6 +890,7 @@ function addLeafSegmentContent({ } if (renderPerimeterFrame && shouldRenderFrame && cpX > 0) { const innerH = leafHeight - 2 * cpY + currentDoorSlot = 'panel' addLeafBox( baseMaterial, cpX, @@ -925,6 +970,7 @@ function addLeafSegmentContent({ if (seg.type !== 'empty') { cx = leafCenterX - contentW / 2 + currentDoorSlot = 'panel' for (let c = 0; c < numCols - 1; c++) { cx += colWidths[c]! const dividerLeft = cx @@ -962,6 +1008,7 @@ function addLeafSegmentContent({ const colX = colXCenters[c]! if (seg.type === 'glass') { + currentDoorSlot = 'glass' const glassDepth = Math.max(0.004, leafDepth * 0.15) const segmentLeft = colX - colW / 2 const segmentRight = colX + colW / 2 @@ -982,6 +1029,7 @@ function addLeafSegmentContent({ addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) } } else if (seg.type === 'panel') { + currentDoorSlot = 'panel' const segmentLeft = colX - colW / 2 const segmentRight = colX + colW / 2 const outerPanelShape = @@ -1119,6 +1167,7 @@ function addDoorLeaf( const usesShapedLeafFrame = openingShape === 'rounded' || openingShape === 'arch' if (usesShapedLeafFrame && hasLeafContent) { + currentDoorSlot = 'panel' if (openingShape === 'rounded') { const roundedLeafShape = roundedBoundary ? createRoundedClippedLeafFrameShape( @@ -1209,6 +1258,7 @@ function addDoorLeaf( }) if (hasLeafContent && handle) { + currentDoorSlot = 'hardware' const handleY = handleHeight - doorHeight / 2 const faceZ = leafDepth / 2 const handleX = @@ -1216,20 +1266,21 @@ function addDoorLeaf( ? leafCenterX + leafWidth / 2 - 0.045 : leafCenterX - leafWidth / 2 + 0.045 - addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005) - addLeafBox(baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025) + addLeafBox(hardwareMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005) + addLeafBox(hardwareMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025) if (handleBothSides) { - addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, -faceZ - 0.005) - addLeafBox(baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, -faceZ - 0.025) + addLeafBox(hardwareMaterial, 0.028, 0.14, 0.01, handleX, handleY, -faceZ - 0.005) + addLeafBox(hardwareMaterial, 0.022, 0.1, 0.035, handleX, handleY, -faceZ - 0.025) } } if (hasLeafContent && doorCloser) { + currentDoorSlot = 'hardware' const closerY = leafCenterY + leafHeight / 2 - 0.04 - addLeafBox(baseMaterial, 0.28, 0.055, 0.055, leafCenterX, closerY, leafDepth / 2 + 0.03) + addLeafBox(hardwareMaterial, 0.28, 0.055, 0.055, leafCenterX, closerY, leafDepth / 2 + 0.03) addLeafBox( - baseMaterial, + hardwareMaterial, 0.14, 0.015, 0.015, @@ -1240,18 +1291,37 @@ function addDoorLeaf( } if (hasLeafContent && panicBar) { + currentDoorSlot = 'hardware' const barY = panicBarHeight - doorHeight / 2 - addLeafBox(baseMaterial, leafWidth * 0.72, 0.04, 0.055, leafCenterX, barY, leafDepth / 2 + 0.03) + addLeafBox( + hardwareMaterial, + leafWidth * 0.72, + 0.04, + 0.055, + leafCenterX, + barY, + leafDepth / 2 + 0.03, + ) } if (hasLeafContent) { + currentDoorSlot = 'hardware' const hingeMarkerX = hingeSide === 'right' ? hingeX - 0.012 : hingeX + 0.012 const hingeH = 0.1 const hingeW = 0.024 const hingeD = leafDepth + 0.016 - addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafBottom + 0.25, 0) - addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, (leafBottom + leafTop) / 2, 0) - addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0) + addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafBottom + 0.25, 0) + addBox( + mesh, + hardwareMaterial, + hingeW, + hingeH, + hingeD, + hingeMarkerX, + (leafBottom + leafTop) / 2, + 0, + ) + addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0) } } @@ -1290,9 +1360,10 @@ function addFoldingDoor( const panelLength = insideWidth / panelCount const foldAngle = Math.PI * 0.44 * foldAmount + currentDoorSlot = 'hardware' addBox( mesh, - baseMaterial, + hardwareMaterial, insideWidth, Math.min(frameThickness * 0.5, 0.025), Math.max(frameDepth * 0.45, 0.035), @@ -1312,6 +1383,7 @@ function addFoldingDoor( }) } + currentDoorSlot = undefined for (let index = 0; index < panelCount; index++) { const start = vertices[index]! const end = vertices[index + 1]! @@ -1359,6 +1431,7 @@ function addFoldingDoor( keepFrameWhenEmpty: true, }) + currentDoorSlot = undefined for (const point of [start, end]) { addBox( mesh, @@ -1375,9 +1448,10 @@ function addFoldingDoor( const handlePoint = vertices[vertices.length - 1]! const handleY = handleHeight - doorHeight / 2 + currentDoorSlot = 'hardware' addBox( mesh, - baseMaterial, + hardwareMaterial, 0.035, 0.16, leafDepth + 0.035, @@ -1387,7 +1461,7 @@ function addFoldingDoor( ) addBox( mesh, - baseMaterial, + hardwareMaterial, 0.035, 0.16, leafDepth + 0.035, @@ -1436,9 +1510,10 @@ function addPocketDoor( const handleY = handleHeight - doorHeight / 2 const handleX = leafCenterX - slideSign * (leafWidth / 2 - 0.055) + currentDoorSlot = 'hardware' addBox( mesh, - baseMaterial, + hardwareMaterial, insideWidth * 2, Math.min(frameThickness * 0.45, 0.024), Math.max(frameDepth * 0.38, 0.03), @@ -1446,6 +1521,7 @@ function addPocketDoor( topY - 0.018, 0, ) + currentDoorSlot = undefined addBox( mesh, revealMaterial, @@ -1487,8 +1563,27 @@ function addPocketDoor( segments, contentPadding, }) - addBox(mesh, baseMaterial, 0.03, 0.18, leafDepth + 0.03, handleX, handleY, leafDepth / 2 + 0.02) - addBox(mesh, baseMaterial, 0.03, 0.18, leafDepth + 0.03, handleX, handleY, -leafDepth / 2 - 0.02) + currentDoorSlot = 'hardware' + addBox( + mesh, + hardwareMaterial, + 0.03, + 0.18, + leafDepth + 0.03, + handleX, + handleY, + leafDepth / 2 + 0.02, + ) + addBox( + mesh, + hardwareMaterial, + 0.03, + 0.18, + leafDepth + 0.03, + handleX, + handleY, + -leafDepth / 2 - 0.02, + ) } function addBarnDoor( @@ -1533,9 +1628,10 @@ function addBarnDoor( const handleX = leafCenterX - slideSign * (leafWidth / 2 - 0.075) const wheelY = trackY - 0.075 - addBox(mesh, revealMaterial, railLength, 0.035, 0.035, railCenterX, trackY, faceZ + 0.01) - addBox(mesh, revealMaterial, 0.05, 0.13, 0.035, -insideWidth / 2, trackY - 0.02, faceZ + 0.01) - addBox(mesh, revealMaterial, 0.05, 0.13, 0.035, insideWidth / 2, trackY - 0.02, faceZ + 0.01) + currentDoorSlot = 'hardware' + addBox(mesh, hardwareMaterial, railLength, 0.035, 0.035, railCenterX, trackY, faceZ + 0.01) + addBox(mesh, hardwareMaterial, 0.05, 0.13, 0.035, -insideWidth / 2, trackY - 0.02, faceZ + 0.01) + addBox(mesh, hardwareMaterial, 0.05, 0.13, 0.035, insideWidth / 2, trackY - 0.02, faceZ + 0.01) const addBarnLeafBox = ( material: THREE.Material, @@ -1559,6 +1655,7 @@ function addBarnDoor( keepFrameWhenEmpty: true, }) + currentDoorSlot = undefined addRotatedBox( mesh, revealMaterial, @@ -1582,11 +1679,12 @@ function addBarnDoor( 0.52, ) + currentDoorSlot = 'hardware' for (const offset of [-leafWidth * 0.28, leafWidth * 0.28]) { - addBox(mesh, revealMaterial, 0.085, 0.085, 0.035, leafCenterX + offset, wheelY, faceZ + 0.022) + addBox(mesh, hardwareMaterial, 0.085, 0.085, 0.035, leafCenterX + offset, wheelY, faceZ + 0.022) addBox( mesh, - revealMaterial, + hardwareMaterial, 0.026, 0.16, 0.026, @@ -1596,9 +1694,10 @@ function addBarnDoor( ) } + currentDoorSlot = 'hardware' addBox( mesh, - baseMaterial, + hardwareMaterial, 0.032, 0.22, leafDepth + 0.034, @@ -1608,7 +1707,7 @@ function addBarnDoor( ) addBox( mesh, - baseMaterial, + hardwareMaterial, 0.032, 0.22, leafDepth + 0.034, @@ -1663,10 +1762,20 @@ function addSlidingDoor( const handleY = handleHeight - doorHeight / 2 const handleX = activeX + activeSign * (panelWidth / 2 - 0.06) - addBox(mesh, revealMaterial, insideWidth, 0.024, Math.max(frameDepth * 0.32, 0.026), 0, railY, 0) + currentDoorSlot = 'hardware' addBox( mesh, - revealMaterial, + hardwareMaterial, + insideWidth, + 0.024, + Math.max(frameDepth * 0.32, 0.026), + 0, + railY, + 0, + ) + addBox( + mesh, + hardwareMaterial, insideWidth, 0.018, Math.max(frameDepth * 0.28, 0.022), @@ -1717,8 +1826,27 @@ function addSlidingDoor( contentPadding, keepFrameWhenEmpty: true, }) - addBox(mesh, baseMaterial, 0.032, 0.24, 0.016, handleX, handleY, frontZ + leafDepth / 2 + 0.01) - addBox(mesh, baseMaterial, 0.032, 0.24, 0.016, handleX, handleY, frontZ - leafDepth / 2 - 0.01) + currentDoorSlot = 'hardware' + addBox( + mesh, + hardwareMaterial, + 0.032, + 0.24, + 0.016, + handleX, + handleY, + frontZ + leafDepth / 2 + 0.01, + ) + addBox( + mesh, + hardwareMaterial, + 0.032, + 0.24, + 0.016, + handleX, + handleY, + frontZ - leafDepth / 2 - 0.01, + ) } function addGarageSectionalDoor( @@ -1755,9 +1883,10 @@ function addGarageSectionalDoor( const railY = leafCenterY + leafHeight / 2 - 0.04 const railZ = -travelDepth / 2 + currentDoorSlot = 'hardware' addBox( mesh, - revealMaterial, + hardwareMaterial, 0.035, Math.max(0.04, frameThickness * 0.75), travelDepth, @@ -1767,7 +1896,7 @@ function addGarageSectionalDoor( ) addBox( mesh, - revealMaterial, + hardwareMaterial, 0.035, Math.max(0.04, frameThickness * 0.75), travelDepth, @@ -1798,6 +1927,7 @@ function addGarageSectionalDoor( const trimDepth = 0.01 const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006 const addSectionalTrim = (localY: number) => { + currentDoorSlot = undefined addBoxWithRotation( mesh, revealMaterial, @@ -1811,6 +1941,7 @@ function addGarageSectionalDoor( ) } + currentDoorSlot = 'panel' addBoxWithRotation( mesh, baseMaterial, @@ -1826,7 +1957,8 @@ function addGarageSectionalDoor( addSectionalTrim(-revealOffset) } - addBox(mesh, revealMaterial, insideWidth, 0.032, Math.max(frameDepth * 0.36, 0.03), 0, railY, 0) + currentDoorSlot = 'hardware' + addBox(mesh, hardwareMaterial, insideWidth, 0.032, Math.max(frameDepth * 0.36, 0.03), 0, railY, 0) } function addGarageRollupDoor( @@ -1859,9 +1991,10 @@ function addGarageRollupDoor( const drumY = topY + drumMaxRadius * 0.12 const drumZ = -frameDepth / 2 - drumMaxRadius * 0.72 + currentDoorSlot = 'hardware' addBox( mesh, - revealMaterial, + hardwareMaterial, 0.032, leafHeight, Math.max(frameDepth * 0.48, 0.035), @@ -1871,7 +2004,7 @@ function addGarageRollupDoor( ) addBox( mesh, - revealMaterial, + hardwareMaterial, 0.032, leafHeight, Math.max(frameDepth * 0.48, 0.035), @@ -1881,8 +2014,10 @@ function addGarageRollupDoor( ) if (visibleHeight > 0.01) { + currentDoorSlot = 'panel' addBox(mesh, baseMaterial, insideWidth, visibleHeight, leafDepth, 0, curtainCenterY, 0) + currentDoorSlot = undefined for (let index = 0; index < visibleSlatCount; index++) { const y = topY - Math.min(visibleHeight, index * slatHeight) addBox(mesh, revealMaterial, insideWidth - 0.08, 0.01, 0.012, 0, y, leafDepth / 2 + 0.012) @@ -1900,17 +2035,20 @@ function addGarageRollupDoor( ) } + currentDoorSlot = 'panel' const drum = new THREE.Mesh( new THREE.CylinderGeometry(drumMaxRadius, drumMaxRadius, insideWidth + frameThickness, 36), baseMaterial, ) drum.position.set(0, drumY, drumZ) drum.rotation.z = Math.PI / 2 + tagDoorSlot(drum) mesh.add(drum) + currentDoorSlot = 'hardware' addBox( mesh, - revealMaterial, + hardwareMaterial, insideWidth + frameThickness, 0.026, Math.max(frameDepth * 0.52, 0.04), @@ -1949,9 +2087,10 @@ function addGarageTiltupDoor( const railY = hingeY - frameThickness * 0.35 const railZ = -railLength / 2 + currentDoorSlot = 'hardware' addBox( mesh, - revealMaterial, + hardwareMaterial, 0.03, Math.max(frameThickness * 0.7, 0.035), railLength, @@ -1961,7 +2100,7 @@ function addGarageTiltupDoor( ) addBox( mesh, - revealMaterial, + hardwareMaterial, 0.03, Math.max(frameThickness * 0.7, 0.035), railLength, @@ -1970,6 +2109,7 @@ function addGarageTiltupDoor( railZ, ) + currentDoorSlot = 'panel' addBoxWithRotation( mesh, baseMaterial, @@ -1987,6 +2127,7 @@ function addGarageTiltupDoor( const trimDepth = 0.012 const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006 const addTiltupTrim = (localX: number, localY: number, trimWidth: number, trimHeight: number) => { + currentDoorSlot = undefined addBoxWithRotation( mesh, revealMaterial, @@ -2005,7 +2146,17 @@ function addGarageTiltupDoor( addTiltupTrim(-insetWidth / 2, 0, 0.018, insetHeight) addTiltupTrim(insetWidth / 2, 0, 0.018, insetHeight) - addBox(mesh, revealMaterial, insideWidth, 0.026, Math.max(frameDepth * 0.4, 0.035), 0, hingeY, 0) + currentDoorSlot = 'hardware' + addBox( + mesh, + hardwareMaterial, + insideWidth, + 0.026, + Math.max(frameDepth * 0.4, 0.035), + 0, + hingeY, + 0, + ) } function getEffectiveOpeningShape(node: DoorNode): DoorNode['openingShape'] { @@ -2019,15 +2170,12 @@ function getEffectiveOpeningShape(node: DoorNode): DoorNode['openingShape'] { function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { const node = normalizeDoorNodeForRender(rawNode) + currentDoorSlot = undefined // Root mesh is an invisible hitbox; all visuals live in child meshes mesh.geometry.dispose() mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth) mesh.material = hitboxMaterial - // Default (selectable) hitbox raycast — restored each build; the visual path - // below disables it so the tagged panel/glass children are the hit targets - // (otherwise the full-depth invisible box intercepts every paint/hover ray). - mesh.raycast = THREE.Mesh.prototype.raycast // Sync transform from node (React may lag behind the system by a frame during drag) mesh.position.set(node.position[0], node.position[1], node.position[2]) @@ -2040,11 +2188,13 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { mesh.remove(child) } - // Point the builder-facing body/glass materials at this door's slot overrides - // for the duration of its build (recomputed per node, so the next door resets - // cleanly without a restore). Reveal keeps its own material. + // Point the builder-facing materials at this door's slot overrides for the + // duration of its build (recomputed per node, so the next door resets cleanly + // without a restore). Reveal keeps its own material. baseMaterial = resolveDoorSlotMaterial(node, 'panel') + frameMaterial = resolveDoorSlotMaterial(node, 'frame') glassMaterial = resolveDoorSlotMaterial(node, 'glass') + hardwareMaterial = resolveDoorSlotMaterial(node, 'hardware') const { width, @@ -2083,9 +2233,6 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { return } - // Visuals exist: let the tagged children receive paint/hover/selection rays. - mesh.raycast = noopHitboxRaycast - const insideWidth = width - 2 * frameThickness const leafH = height - frameThickness // only top frame const leafDepth = 0.04 @@ -2093,6 +2240,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { const swingDirectionSign = swingDirection === 'inward' ? 1 : -1 // ── Frame members ── + currentDoorSlot = 'frame' if (openingShape === 'arch') { const frameBottom = -height / 2 const frameTop = height / 2 @@ -2106,7 +2254,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { addBox( mesh, - baseMaterial, + frameMaterial, frameThickness, postHeight, frameDepth, @@ -2116,7 +2264,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { ) addBox( mesh, - baseMaterial, + frameMaterial, frameThickness, postHeight, frameDepth, @@ -2126,7 +2274,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { ) addShape( mesh, - baseMaterial, + frameMaterial, useShallowHeadBar ? createArchHeadBarShape(width, frameHeadBottomY, frameSpringY, frameTop) : createArchBandShape( @@ -2142,7 +2290,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { } else if (openingShape === 'rounded') { addShape( mesh, - baseMaterial, + frameMaterial, createRoundedDoorFrameShape( width, height, @@ -2155,7 +2303,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { // Left post — full height addBox( mesh, - baseMaterial, + frameMaterial, frameThickness, height, frameDepth, @@ -2166,7 +2314,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { // Right post — full height addBox( mesh, - baseMaterial, + frameMaterial, frameThickness, height, frameDepth, @@ -2177,7 +2325,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { // Head (top bar) — full width addBox( mesh, - baseMaterial, + frameMaterial, width, frameThickness, frameDepth, @@ -2189,9 +2337,10 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { // ── Threshold (inside the frame) ── if (threshold) { + currentDoorSlot = 'frame' addBox( mesh, - baseMaterial, + frameMaterial, insideWidth, thresholdHeight, frameDepth, @@ -2412,10 +2561,10 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) { if (!cutout) { cutout = new THREE.Mesh() cutout.name = 'cutout' - // The cutout is a 1m-deep CSG helper for the wall hole — never interactive. - // three.js raycasts invisible meshes, so without this its front face (0.5m - // proud of the door) intercepts every paint/hover ray. - cutout.raycast = noopHitboxRaycast + // The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it + // wins the scene raycast over the wall in front of the recessed door body — + // making it the selection AND paint hit target for the whole opening. The + // paint capability then re-raycasts the door's parts to find the slot. mesh.add(cutout) } cutout.geometry.dispose() diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index a45f4f12..57c93892 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -12,6 +12,7 @@ import { import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' import * as THREE from 'three' +import { applyWorldScaleBoxUVs } from '../../lib/box-uv' import { type ColorPreset, createSurfaceRoleMaterial, @@ -24,10 +25,9 @@ import useViewer from '../../store/use-viewer' // Invisible material for root mesh — used as selection hitbox only const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) -// Disables a mesh's own raycast so its children become the hit targets. -const noopHitboxRaycast: THREE.Mesh['raycast'] = () => {} let baseMaterial = getBaseMaterial() let glassMaterial: THREE.Material = defaultGlassMaterial +let currentWindowSlot: string | undefined // Per-frame viewer state, captured so the per-node mesh builder (which runs // outside React) can resolve each window's slot materials. let currentShading: RenderShading = 'rendered' @@ -158,12 +158,8 @@ export const WindowSystem = () => { return null } -// A window exposes two slots: `frame` (every joinery member) and `glass`. The -// builders pass `baseMaterial` / `glassMaterial`, so tag each mesh by which one -// it got — that's what `(nodeId, slotId)` paint resolves against. function tagWindowSlot(mesh: THREE.Mesh): THREE.Mesh { - if (mesh.material === glassMaterial) mesh.userData.slotId = 'glass' - else if (mesh.material === baseMaterial) mesh.userData.slotId = 'frame' + mesh.userData.slotId = currentWindowSlot return mesh } @@ -176,15 +172,25 @@ function nodeReferencesSceneMaterial(node: { slots?: Record }): return false } +// Window frame/glass default to catalog finishes (generic approach). `preset-glass` +// is now FrontSide (it was the only glass we use), so it's safe for the WebGPU +// MRT scene pass. +const FRAME_DEFAULT_REF = 'library:preset-softwhite' +const GLASS_DEFAULT_REF = 'library:preset-glass' + function windowSlotDefault(slotId: 'frame' | 'glass'): THREE.Material { if (slotId === 'glass') { - return currentTextures - ? defaultGlassMaterial - : createSurfaceRoleMaterial('glazing', currentColorPreset) + if (!currentTextures) return createSurfaceRoleMaterial('glazing', currentColorPreset) + return ( + resolveMaterialRef(GLASS_DEFAULT_REF, currentSceneMaterials, currentShading) ?? + defaultGlassMaterial + ) } - return currentTextures - ? getBaseMaterial(currentShading) - : createSurfaceRoleMaterial('joinery', currentColorPreset) + if (!currentTextures) return createSurfaceRoleMaterial('joinery', currentColorPreset) + return ( + resolveMaterialRef(FRAME_DEFAULT_REF, currentSceneMaterials, currentShading) ?? + getBaseMaterial(currentShading) + ) } // Resolve a window's slot to a material: the `node.slots` override (colored mode @@ -208,7 +214,9 @@ function addBox( y: number, z: number, ) { - const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) + const geometry = new THREE.BoxGeometry(w, h, d) + applyWorldScaleBoxUVs(geometry, w, h, d) + const m = new THREE.Mesh(geometry, material) m.position.set(x, y, z) tagWindowSlot(m) parent.add(m) @@ -565,6 +573,7 @@ function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = innerTop - innerBottom const innerRadii = insetCornerRadii(outerRadii, inset, innerW, innerH) + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -574,6 +583,7 @@ function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (innerW > 0.01 && innerH > 0.01) { const glassDepth = Math.max(0.004, frameDepth * 0.08) + currentWindowSlot = 'glass' addShape( mesh, glassMaterial, @@ -591,6 +601,7 @@ function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH) let x = innerLeft + currentWindowSlot = 'frame' for (let c = 0; c < numCols - 1; c++) { x += colWidths[c]! const x1 = x @@ -611,6 +622,7 @@ function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } let y = innerTop + currentWindowSlot = 'frame' for (let r = 0; r < numRows - 1; r++) { y -= rowHeights[r]! const yTop = y @@ -637,6 +649,7 @@ function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -678,10 +691,12 @@ function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerArchHeight = getClampedArchHeight(innerW, innerH, archHeight - inset) const innerSpringY = innerTop - innerArchHeight + currentWindowSlot = 'frame' addShape(mesh, baseMaterial, createArchedFrameShape(width, height, archHeight, inset), frameDepth) if (innerW > 0.01 && innerH > 0.01) { const glassDepth = Math.max(0.004, frameDepth * 0.08) + currentWindowSlot = 'glass' addShape( mesh, glassMaterial, @@ -700,6 +715,7 @@ function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerHalfWidth = innerW / 2 let x = innerLeft + currentWindowSlot = 'frame' for (let c = 0; c < numCols - 1; c++) { x += colWidths[c]! const x1 = x @@ -720,6 +736,7 @@ function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } let y = innerTop + currentWindowSlot = 'frame' for (let r = 0; r < numRows - 1; r++) { y -= rowHeights[r]! const yTop = y @@ -747,6 +764,7 @@ function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -786,6 +804,7 @@ function addSlidingWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -848,6 +867,7 @@ function addSlidingWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { mesh.add(activePanel) // Twin tracks signal the sliding operation without adding editor-only state. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -869,10 +889,12 @@ function addSlidingWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { 0, ) + currentWindowSlot = 'glass' addBox(activePanel, glassMaterial, panelWidth, panelH, glassDepth, 0, 0, 0) addBox(mesh, glassMaterial, panelWidth, panelH, glassDepth, rightPanelX, 0, rightZ) // The right sash stays fixed. The left sash is the active panel that slides across it. + currentWindowSlot = 'frame' addBox( activePanel, baseMaterial, @@ -918,6 +940,7 @@ function addSlidingWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -954,6 +977,7 @@ function addRectCasementSash( sash.rotation.y = rotationY parent.add(sash) + currentWindowSlot = 'frame' addBox( sash, baseMaterial, @@ -994,6 +1018,7 @@ function addRectCasementSash( 0, 0, ) + currentWindowSlot = 'glass' addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08) } @@ -1006,6 +1031,7 @@ function addFrenchCasementHingeMarkers( ) { const markerW = Math.max(frameThickness * 0.38, 0.018) const markerH = innerH * 0.24 + currentWindowSlot = 'frame' for (const pivotX of [-innerW / 2, innerW / 2]) { addBox( mesh, @@ -1184,6 +1210,7 @@ function addShapedFrenchCasementSash( const outerArchHeight = getClampedArchHeight(node.width, node.height, node.archHeight) const sashArchHeight = getClampedArchHeight(fullW, leafH, outerArchHeight - frameThickness) const sashSpringY = node.height / 2 - outerArchHeight + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -1200,6 +1227,7 @@ function addShapedFrenchCasementSash( ) const glassInset = Math.min(sashFrameThickness, leafW / 2 - 0.005, leafH / 2 - 0.005) if (glassInset > 0.001) { + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -1225,6 +1253,7 @@ function addShapedFrenchCasementSash( fullW, leafH, ) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -1233,6 +1262,7 @@ function addShapedFrenchCasementSash( ) const glassInset = Math.min(sashFrameThickness, leafW / 2 - 0.005, leafH / 2 - 0.005) if (glassInset > 0.001) { + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -1249,6 +1279,7 @@ function addFrenchCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Fixed outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1321,6 +1352,7 @@ function addFrenchCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1340,6 +1372,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness if (node.openingShape === 'arch') { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -1352,6 +1385,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { frameDepth, ) } else { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -1403,6 +1437,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1443,6 +1478,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { innerH, (node.archHeight ?? innerW / 2) - frameThickness, ) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -1453,6 +1489,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -1469,6 +1506,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } else { const outerRadii = getWindowRoundedRadii(node, innerW, innerH) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -1479,6 +1517,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -1495,6 +1534,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1520,6 +1560,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1550,6 +1591,7 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Fixed outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1610,6 +1652,7 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { sash.rotation.y = hingeSign * openAngle mesh.add(sash) + currentWindowSlot = 'frame' addBox( sash, baseMaterial, @@ -1650,9 +1693,11 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { 0, 0, ) + currentWindowSlot = 'glass' addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08) // Small hinge markers make the pivot side legible when the sash is closed. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1678,6 +1723,7 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1703,6 +1749,7 @@ function addAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Fixed outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1762,6 +1809,7 @@ function addAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { sash.rotation.x = -openAngle mesh.add(sash) + currentWindowSlot = 'frame' addBox( sash, baseMaterial, @@ -1802,9 +1850,11 @@ function addAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { sashCenterY, 0, ) + currentWindowSlot = 'glass' addBox(sash, glassMaterial, glassW, glassH, glassDepth, 0, sashCenterY, sashDepth * 0.08) // Compact hinge rail, visible even when the sash is closed. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1820,6 +1870,7 @@ function addAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1839,6 +1890,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness if (node.openingShape === 'arch') { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -1851,6 +1903,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { frameDepth, ) } else { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -1888,6 +1941,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { innerH, (node.archHeight ?? innerW / 2) - frameThickness, ) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -1898,6 +1952,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -1914,6 +1969,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } else { const outerRadii = getWindowRoundedRadii(node, innerW, innerH) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -1924,6 +1980,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -1940,6 +1997,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1955,6 +2013,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1980,6 +2039,7 @@ function addHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Fixed outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2037,6 +2097,7 @@ function addHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { sash.rotation.x = -openAngle mesh.add(sash) + currentWindowSlot = 'frame' addBox( sash, baseMaterial, @@ -2068,9 +2129,11 @@ function addHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { innerH / 2, 0, ) + currentWindowSlot = 'glass' addBox(sash, glassMaterial, glassW, glassH, glassDepth, 0, innerH / 2, sashDepth * 0.08) // Compact bottom hinge rail, visible even when the sash is closed. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2086,6 +2149,7 @@ function addHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2105,6 +2169,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness if (node.openingShape === 'arch') { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -2117,6 +2182,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { frameDepth, ) } else { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -2153,6 +2219,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { innerH, (node.archHeight ?? innerW / 2) - frameThickness, ) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -2163,6 +2230,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -2179,6 +2247,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } else { const outerRadii = getWindowRoundedRadii(node, innerW, innerH) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -2189,6 +2258,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -2205,6 +2275,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2220,6 +2291,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2243,6 +2315,7 @@ function addHungSash( glassW: number, glassH: number, ) { + currentWindowSlot = 'frame' addBox( parent, baseMaterial, @@ -2283,6 +2356,7 @@ function addHungSash( 0, 0, ) + currentWindowSlot = 'glass' addBox(parent, glassMaterial, glassW, glassH, glassDepth, 0, 0, 0) } @@ -2293,6 +2367,7 @@ function addSingleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Fixed outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2357,6 +2432,7 @@ function addSingleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { mesh.add(activeSash) // Side tracks show the lower sash is the moving element. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2403,6 +2479,7 @@ function addSingleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { ) // Meeting rails: top sash fixed, bottom sash moves upward over it. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2428,6 +2505,7 @@ function addSingleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2448,6 +2526,7 @@ function addDoubleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Fixed outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2516,6 +2595,7 @@ function addDoubleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { mesh.add(bottomSash) // Side tracks show both sashes move vertically. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2559,6 +2639,7 @@ function addDoubleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { ) // Opposing meeting rails: top sash descends while bottom sash rises. + currentWindowSlot = 'frame' addBox( topSash, baseMaterial, @@ -2584,6 +2665,7 @@ function addDoubleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2602,6 +2684,7 @@ function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2662,6 +2745,7 @@ function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const addBayPanel = (parent: THREE.Object3D, panelW: number) => { const glassW = Math.max(panelW - 2 * sashFrameThickness, 0.01) const glassH = Math.max(innerH - 2 * sashFrameThickness, 0.01) + currentWindowSlot = 'frame' addBox( parent, baseMaterial, @@ -2702,10 +2786,12 @@ function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { 0, 0, ) + currentWindowSlot = 'glass' addBox(parent, glassMaterial, glassW, glassH, glassDepth, 0, 0, panelDepth * 0.08) } const addBayCap = (centerY: number) => { + currentWindowSlot = 'frame' const halfThickness = frameThickness / 2 const vertices: number[] = [] const indices: number[] = [] @@ -2760,7 +2846,7 @@ function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)) geometry.setIndex(indices) geometry.computeVertexNormals() - mesh.add(new THREE.Mesh(geometry, baseMaterial)) + mesh.add(tagWindowSlot(new THREE.Mesh(geometry, baseMaterial))) } const center = new THREE.Group() @@ -2787,6 +2873,7 @@ function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2805,6 +2892,7 @@ function addBowWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2931,15 +3019,19 @@ function addBowWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } const addCurvedMesh = (material: THREE.Material, geometry: THREE.BufferGeometry) => { - mesh.add(new THREE.Mesh(geometry, material)) + mesh.add(tagWindowSlot(new THREE.Mesh(geometry, material))) } + currentWindowSlot = 'frame' addCurvedMesh(baseMaterial, createCurvedVerticalBand(glassTop, innerH / 2)) addCurvedMesh(baseMaterial, createCurvedVerticalBand(-innerH / 2, glassBottom)) + currentWindowSlot = 'glass' addCurvedMesh(glassMaterial, createCurvedVerticalBand(glassBottom, glassTop, frameDepth * 0.04)) + currentWindowSlot = 'frame' addCurvedMesh(baseMaterial, createCurvedCap(slabYTop, frameThickness)) addCurvedMesh(baseMaterial, createCurvedCap(slabYBottom, frameThickness)) + currentWindowSlot = 'frame' for (let index = 0; index <= mullionCount; index += 1) { const x = -halfSpan + (innerW * index) / mullionCount addBox(mesh, baseMaterial, sashFrameThickness, innerH, frameDepth * 0.72, x, 0, arcZAt(x)) @@ -2949,6 +3041,7 @@ function addBowWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2973,6 +3066,7 @@ function addLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -3027,6 +3121,7 @@ function addLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { slats.name = LOUVERED_WINDOW_SLATS_NAME mesh.add(slats) + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -3048,6 +3143,7 @@ function addLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { 0, ) + currentWindowSlot = 'glass' for (let index = 0; index < slatCount; index += 1) { const y = innerH / 2 - slatGap * (index + 0.5) const slat = new THREE.Group() @@ -3070,6 +3166,7 @@ function addLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -3097,6 +3194,7 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = innerTop - innerBottom if (node.openingShape === 'arch') { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -3109,6 +3207,7 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { frameDepth, ) } else { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -3159,6 +3258,7 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { })() const addVerticalRail = (x: number) => { + currentWindowSlot = 'frame' const railX1 = x const railX2 = x + (x < 0 ? railThickness : -railThickness) const sampleX = x < 0 ? Math.max(railX1, railX2) : Math.min(railX1, railX2) @@ -3192,6 +3292,7 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { addVerticalRail(innerLeft) addVerticalRail(innerRight) + currentWindowSlot = 'glass' for (let index = 0; index < slatCount; index += 1) { const y = innerTop - slatGap * (index + 0.5) const topBounds = getBoundsAtY(Math.min(y + slatHeight / 2, innerTop)) @@ -3212,6 +3313,7 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -3226,14 +3328,12 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { + currentWindowSlot = undefined + // Root mesh is an invisible hitbox; all visuals live in child meshes mesh.geometry.dispose() mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth) mesh.material = hitboxMaterial - // Default (selectable) hitbox raycast — restored each build; the visual path - // below disables it so the tagged frame/glass children are the hit targets - // (otherwise the full-depth invisible box intercepts every paint/hover ray). - mesh.raycast = THREE.Mesh.prototype.raycast // Sync transform from node (React may lag behind the system by a frame during drag) mesh.position.set(node.position[0], node.position[1], node.position[2]) @@ -3274,9 +3374,6 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { return } - // Visuals exist: let the tagged children receive paint/hover/selection rays. - mesh.raycast = noopHitboxRaycast - if (windowType === 'sliding') { addSlidingWindowVisuals(node, mesh) syncWindowCutout(node, mesh) @@ -3348,6 +3445,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { // ── Frame members ── // Top / bottom — full width + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -3422,6 +3520,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { // Column dividers — full inner height cx = -innerW / 2 + currentWindowSlot = 'frame' for (let c = 0; c < numCols - 1; c++) { cx += colWidths[c]! addBox( @@ -3439,6 +3538,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { // Row dividers — per column width, so they don't overlap column dividers (top to bottom) cy = innerH / 2 + currentWindowSlot = 'frame' for (let r = 0; r < numRows - 1; r++) { cy -= rowHeights[r]! const divY = cy - rowDividerThickness / 2 @@ -3459,6 +3559,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { // Glass panes const glassDepth = Math.max(0.004, frameDepth * 0.08) + currentWindowSlot = 'glass' for (let c = 0; c < numCols; c++) { for (let r = 0; r < numRows; r++) { addBox( @@ -3479,6 +3580,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { const sillW = width + sillDepth * 0.4 // slightly wider than frame // Protrudes from the front face of the frame (+Z) const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -3500,10 +3602,10 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) { if (!cutout) { cutout = new THREE.Mesh() cutout.name = 'cutout' - // The cutout is a 1m-deep CSG helper for the wall hole — never interactive. - // three.js raycasts invisible meshes, so without this its front face (0.5m - // proud of the glass) intercepts every paint/hover ray. - cutout.raycast = noopHitboxRaycast + // The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it + // wins the scene raycast over the wall in front of the recessed window — + // making it the selection AND paint hit target for the whole opening. The + // paint capability then re-raycasts the window's parts to find the slot. mesh.add(cutout) } cutout.geometry.dispose()