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
+76 -4
View File
@@ -3442,7 +3442,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
bumpScale: 1, bumpScale: 1,
emissiveColor: '#000000', emissiveColor: '#000000',
aoMapIntensity: 1, 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, opacity: 0.3,
lightMapIntensity: 1, lightMapIntensity: 1,
}, },
@@ -3953,7 +3955,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
mapProperties: { mapProperties: {
color: '#ffffff', color: '#ffffff',
roughness: 0.4, roughness: 0.4,
metalness: 1, metalness: 0.6,
repeatX: 1, repeatX: 1,
repeatY: 1, repeatY: 1,
rotation: 0, rotation: 0,
@@ -3992,7 +3994,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
mapProperties: { mapProperties: {
color: '#ffffff', color: '#ffffff',
roughness: 0.3, roughness: 0.3,
metalness: 1, metalness: 0.6,
repeatX: 1, repeatX: 1,
repeatY: 1, repeatY: 1,
rotation: 0, rotation: 0,
@@ -4033,7 +4035,77 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
mapProperties: { mapProperties: {
color: '#ffffff', color: '#ffffff',
roughness: 0.45, 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, repeatX: 1,
repeatY: 1, repeatY: 1,
rotation: 0, rotation: 0,
+9 -1
View File
@@ -1,5 +1,5 @@
import type { ComponentType } from 'react' 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 { ZodObject, z } from 'zod'
import type { MaterialSchema } from '../schema/material' import type { MaterialSchema } from '../schema/material'
import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material' import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material'
@@ -1212,6 +1212,14 @@ export type PaintResolveArgs = {
hitObjectName?: string hitObjectName?: string
/** Optional: the three.js object that received the pointer hit. Items read userData.slotId off it. */ /** Optional: the three.js object that received the pointer hit. Items read userData.slotId off it. */
hitObject?: Object3D 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 = { export type PaintPatchArgs = {
@@ -871,14 +871,6 @@ export const SelectionManager = () => {
const activePaintMaterial = resolveActivePaintMaterial() const activePaintMaterial = resolveActivePaintMaterial()
const node = event.node 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 if (!isNodeInCurrentLevel(node)) return null
// The eraser clears a surface back to its default by painting with an // 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 // roof / stair / single-surface arms below stay until they
// migrate too. // migrate too.
const paintCap = nodeRegistry.get(node.type)?.capabilities?.paint 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) { if (paintCap) {
const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex) const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
const role = paintCap.resolveRole({ const role = paintCap.resolveRole({
@@ -927,11 +907,8 @@ export const SelectionManager = () => {
localPosition: event.localPosition as readonly [number, number, number] | undefined, localPosition: event.localPosition as readonly [number, number, number] | undefined,
hitObjectName: event.nativeEvent.object?.name, hitObjectName: event.nativeEvent.object?.name,
hitObject: getEventObject(event), 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 const compatible = role !== null && paintEnabled
return { return {
key: `${node.type}:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`, key: `${node.type}:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
@@ -1209,6 +1186,11 @@ export const SelectionManager = () => {
for (const type of subscribedKinds) { for (const type of subscribedKinds) {
emitter.on(`${type}:enter` as any, onEnter as any) 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}:leave` as any, onLeave as any)
emitter.on(`${type}:click` as any, onClick as any) emitter.on(`${type}:click` as any, onClick as any)
} }
@@ -1216,6 +1198,7 @@ export const SelectionManager = () => {
return () => { return () => {
for (const type of subscribedKinds) { for (const type of subscribedKinds) {
emitter.off(`${type}:enter` as any, onEnter as any) 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}:leave` as any, onLeave as any)
emitter.off(`${type}:click` as any, onClick 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, localPosition: event.localPosition as readonly [number, number, number] | undefined,
hitObjectName: event.nativeEvent.object?.name, hitObjectName: event.nativeEvent.object?.name,
hitObject: getEventObject(event), hitObject: getEventObject(event),
ray: event.nativeEvent.ray,
}) })
if (role) { if (role) {
setSelectedMaterialTargetForNode(nodeToSelect, role as MaterialTargetRole) setSelectedMaterialTargetForNode(nodeToSelect, role as MaterialTargetRole)
+10 -11
View File
@@ -1,17 +1,16 @@
import type { PaintResolveArgs } from '@pascal-app/core' import {
import { createSlotPaintCapability, previewSlotByUserData } from '../shared/slot-paint' createSlotPaintCapability,
previewSlotByUserData,
resolveSlotByReRaycast,
} from '../shared/slot-paint'
/** /**
* Door paint on the unified slot model. The door's viewer system tags each built * Door paint on the unified slot model. The door's opening proxy (a proud,
* mesh with `userData.slotId` (`panel` / `glass`), so the role resolves straight * invisible cutout) wins the shared scene raycast over the wall in front of the
* from the pointer hit; commit writes `node.slots[slotId]`. * 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({ export const doorPaint = createSlotPaintCapability({
resolveRole: resolveDoorRole, resolveRole: resolveSlotByReRaycast,
applyPreview: previewSlotByUserData, applyPreview: previewSlotByUserData,
}) })
+13 -7
View File
@@ -1,19 +1,25 @@
import type { SlotDeclaration } from '@pascal-app/core' 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 // Picker swatches. Rendering falls back to the live body/glass/hardware defaults
// already track shading + theme), so these are just the indicator colours. // (which already track shading + theme), so these are just the indicator colours.
const PANEL_DEFAULT = '#f2f0ed' const PANEL_DEFAULT = 'library:preset-softwhite'
const GLASS_DEFAULT = '#87ceeb' 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 + * A door exposes four paintable slots: `panel` (leaf faces), `frame`, `glass`,
* leaf) and `glass`. The opening reveal keeps its own material. * and `hardware` (handle / hinges / closer / panic bar). The opening reveal
* keeps its own material.
*/ */
export function doorSlots(): SlotDeclaration[] { export function doorSlots(): SlotDeclaration[] {
return [ return [
{ slotId: 'panel', label: 'Panel', default: PANEL_DEFAULT }, { slotId: 'panel', label: 'Panel', default: PANEL_DEFAULT },
{ slotId: 'frame', label: 'Frame', default: FRAME_DEFAULT },
{ slotId: 'glass', label: 'Glass', default: GLASS_DEFAULT }, { slotId: 'glass', label: 'Glass', default: GLASS_DEFAULT },
{ slotId: 'hardware', label: 'Hardware', default: HARDWARE_DEFAULT },
] ]
} }
+26 -1
View File
@@ -9,11 +9,12 @@ import {
parseMaterialRef, parseMaterialRef,
type SceneMaterial, type SceneMaterial,
type SceneMaterialId, type SceneMaterialId,
sceneRegistry,
toSceneMaterialRef, toSceneMaterialRef,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { createMaterial, createMaterialFromPresetRef, useViewer } from '@pascal-app/viewer' 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 * 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 = { export type SlotPaintConfig = {
/** Resolve the slot id for a pointer hit (`null` = not paintable here). */ /** Resolve the slot id for a pointer hit (`null` = not paintable here). */
resolveRole: (args: PaintResolveArgs) => string | null resolveRole: (args: PaintResolveArgs) => string | null
+10 -11
View File
@@ -1,17 +1,16 @@
import type { PaintResolveArgs } from '@pascal-app/core' import {
import { createSlotPaintCapability, previewSlotByUserData } from '../shared/slot-paint' createSlotPaintCapability,
previewSlotByUserData,
resolveSlotByReRaycast,
} from '../shared/slot-paint'
/** /**
* Window paint on the unified slot model. The window's viewer system tags each * Window paint on the unified slot model. The window's opening proxy (a proud,
* built mesh with `userData.slotId` (`frame` / `glass`), so the role resolves * invisible cutout) wins the shared scene raycast over the wall in front of the
* straight from the pointer hit; commit writes `node.slots[slotId]`. * 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({ export const windowPaint = createSlotPaintCapability({
resolveRole: resolveWindowRole, resolveRole: resolveSlotByReRaycast,
applyPreview: previewSlotByUserData, applyPreview: previewSlotByUserData,
}) })
+2 -2
View File
@@ -4,8 +4,8 @@ export type WindowSlotId = 'frame' | 'glass'
// Picker swatches. Rendering falls back to the live frame/glass defaults (which // Picker swatches. Rendering falls back to the live frame/glass defaults (which
// already track shading + theme), so these are just the indicator colours. // already track shading + theme), so these are just the indicator colours.
const FRAME_DEFAULT = '#f2f0ed' const FRAME_DEFAULT = 'library:preset-softwhite'
const GLASS_DEFAULT = '#87ceeb' const GLASS_DEFAULT = 'library:preset-glass'
/** A window exposes two paintable slots: the joinery frame and the glass. */ /** A window exposes two paintable slots: the joinery frame and the glass. */
export function windowSlots(): SlotDeclaration[] { export function windowSlots(): SlotDeclaration[] {
+40
View File
@@ -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
}
+221 -72
View File
@@ -15,8 +15,10 @@ import {
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { applyWorldScaleBoxUVs } from '../../lib/box-uv'
import { import {
type ColorPreset, type ColorPreset,
createDefaultMaterial,
createSurfaceRoleMaterial, createSurfaceRoleMaterial,
glassMaterial as defaultGlassMaterial, glassMaterial as defaultGlassMaterial,
baseMaterial as getBaseMaterial, baseMaterial as getBaseMaterial,
@@ -27,12 +29,24 @@ import useViewer from '../../store/use-viewer'
// Invisible material for root mesh — used as selection hitbox only // Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) 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' }) 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 baseMaterial = getBaseMaterial()
let frameMaterial: THREE.Material = getBaseMaterial()
let revealMaterial: THREE.Material = defaultRevealMaterial let revealMaterial: THREE.Material = defaultRevealMaterial
let glassMaterial: THREE.Material = defaultGlassMaterial 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 // Per-frame viewer state, captured so the per-node mesh builder (which runs
// outside React) can resolve each door's slot materials. // outside React) can resolve each door's slot materials.
let currentShading: RenderShading = 'rendered' let currentShading: RenderShading = 'rendered'
@@ -73,8 +87,10 @@ export const DoorSystem = () => {
const joineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset) const joineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset)
baseMaterial = textures ? getBaseMaterial(shading) : joineryMaterial baseMaterial = textures ? getBaseMaterial(shading) : joineryMaterial
frameMaterial = textures ? getBaseMaterial(shading) : joineryMaterial
revealMaterial = textures ? defaultRevealMaterial : joineryMaterial revealMaterial = textures ? defaultRevealMaterial : joineryMaterial
glassMaterial = textures ? defaultGlassMaterial : joineryMaterial glassMaterial = textures ? defaultGlassMaterial : joineryMaterial
hardwareMaterial = textures ? defaultHardwareMaterial : joineryMaterial
useEffect(() => { useEffect(() => {
const materialRevision = `${shading}:${textures ? 'textures' : 'solid'}:${colorPreset}` const materialRevision = `${shading}:${textures ? 'textures' : 'solid'}:${colorPreset}`
@@ -104,8 +120,10 @@ export const DoorSystem = () => {
if (dirtyNodes.size === 0) return if (dirtyNodes.size === 0) return
const frameJoineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset) const frameJoineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset)
baseMaterial = textures ? getBaseMaterial(shading) : frameJoineryMaterial baseMaterial = textures ? getBaseMaterial(shading) : frameJoineryMaterial
frameMaterial = textures ? getBaseMaterial(shading) : frameJoineryMaterial
revealMaterial = textures ? defaultRevealMaterial : frameJoineryMaterial revealMaterial = textures ? defaultRevealMaterial : frameJoineryMaterial
glassMaterial = textures ? defaultGlassMaterial : frameJoineryMaterial glassMaterial = textures ? defaultGlassMaterial : frameJoineryMaterial
hardwareMaterial = textures ? defaultHardwareMaterial : frameJoineryMaterial
currentShading = shading currentShading = shading
currentTextures = textures currentTextures = textures
currentColorPreset = colorPreset currentColorPreset = colorPreset
@@ -163,12 +181,8 @@ export const DoorSystem = () => {
return null 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 { function tagDoorSlot(mesh: THREE.Mesh): THREE.Mesh {
if (mesh.material === glassMaterial) mesh.userData.slotId = 'glass' mesh.userData.slotId = currentDoorSlot
else if (mesh.material === baseMaterial) mesh.userData.slotId = 'panel'
return mesh return mesh
} }
@@ -181,15 +195,38 @@ function nodeReferencesSceneMaterial(node: { slots?: Record<string, string> }):
return false 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) 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 // 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. // monochrome escape hatch.
function resolveDoorSlotMaterial(node: DoorNode, slotId: 'panel' | 'glass'): THREE.Material { function resolveDoorSlotMaterial(node: DoorNode, slotId: DoorMaterialSlotId): THREE.Material {
const fallback = doorSlotDefault(slotId) const fallback = doorSlotDefault(slotId)
if (!currentTextures) return fallback if (!currentTextures) return fallback
const ref = node.slots?.[slotId] const ref = node.slots?.[slotId]
@@ -207,7 +244,9 @@ function addBox(
y: number, y: number,
z: 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) m.position.set(x, y, z)
tagDoorSlot(m) tagDoorSlot(m)
parent.add(m) parent.add(m)
@@ -224,7 +263,9 @@ function addRotatedBox(
z: number, z: number,
rotationY: 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.position.set(x, y, z)
m.rotation.y = rotationY m.rotation.y = rotationY
tagDoorSlot(m) tagDoorSlot(m)
@@ -242,7 +283,9 @@ function addBoxWithRotation(
z: number, z: number,
rotation: [number, number, 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.position.set(x, y, z)
m.rotation.set(rotation[0], rotation[1], rotation[2]) m.rotation.set(rotation[0], rotation[1], rotation[2])
tagDoorSlot(m) tagDoorSlot(m)
@@ -825,6 +868,7 @@ function addLeafSegmentContent({
const cpX = contentPadding[0] const cpX = contentPadding[0]
const cpY = contentPadding[1] const cpY = contentPadding[1]
if (renderPerimeterFrame && shouldRenderFrame && cpY > 0) { if (renderPerimeterFrame && shouldRenderFrame && cpY > 0) {
currentDoorSlot = 'panel'
addLeafBox( addLeafBox(
baseMaterial, baseMaterial,
leafWidth, leafWidth,
@@ -846,6 +890,7 @@ function addLeafSegmentContent({
} }
if (renderPerimeterFrame && shouldRenderFrame && cpX > 0) { if (renderPerimeterFrame && shouldRenderFrame && cpX > 0) {
const innerH = leafHeight - 2 * cpY const innerH = leafHeight - 2 * cpY
currentDoorSlot = 'panel'
addLeafBox( addLeafBox(
baseMaterial, baseMaterial,
cpX, cpX,
@@ -925,6 +970,7 @@ function addLeafSegmentContent({
if (seg.type !== 'empty') { if (seg.type !== 'empty') {
cx = leafCenterX - contentW / 2 cx = leafCenterX - contentW / 2
currentDoorSlot = 'panel'
for (let c = 0; c < numCols - 1; c++) { for (let c = 0; c < numCols - 1; c++) {
cx += colWidths[c]! cx += colWidths[c]!
const dividerLeft = cx const dividerLeft = cx
@@ -962,6 +1008,7 @@ function addLeafSegmentContent({
const colX = colXCenters[c]! const colX = colXCenters[c]!
if (seg.type === 'glass') { if (seg.type === 'glass') {
currentDoorSlot = 'glass'
const glassDepth = Math.max(0.004, leafDepth * 0.15) const glassDepth = Math.max(0.004, leafDepth * 0.15)
const segmentLeft = colX - colW / 2 const segmentLeft = colX - colW / 2
const segmentRight = colX + colW / 2 const segmentRight = colX + colW / 2
@@ -982,6 +1029,7 @@ function addLeafSegmentContent({
addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0)
} }
} else if (seg.type === 'panel') { } else if (seg.type === 'panel') {
currentDoorSlot = 'panel'
const segmentLeft = colX - colW / 2 const segmentLeft = colX - colW / 2
const segmentRight = colX + colW / 2 const segmentRight = colX + colW / 2
const outerPanelShape = const outerPanelShape =
@@ -1119,6 +1167,7 @@ function addDoorLeaf(
const usesShapedLeafFrame = openingShape === 'rounded' || openingShape === 'arch' const usesShapedLeafFrame = openingShape === 'rounded' || openingShape === 'arch'
if (usesShapedLeafFrame && hasLeafContent) { if (usesShapedLeafFrame && hasLeafContent) {
currentDoorSlot = 'panel'
if (openingShape === 'rounded') { if (openingShape === 'rounded') {
const roundedLeafShape = roundedBoundary const roundedLeafShape = roundedBoundary
? createRoundedClippedLeafFrameShape( ? createRoundedClippedLeafFrameShape(
@@ -1209,6 +1258,7 @@ function addDoorLeaf(
}) })
if (hasLeafContent && handle) { if (hasLeafContent && handle) {
currentDoorSlot = 'hardware'
const handleY = handleHeight - doorHeight / 2 const handleY = handleHeight - doorHeight / 2
const faceZ = leafDepth / 2 const faceZ = leafDepth / 2
const handleX = const handleX =
@@ -1216,20 +1266,21 @@ function addDoorLeaf(
? leafCenterX + leafWidth / 2 - 0.045 ? leafCenterX + leafWidth / 2 - 0.045
: leafCenterX - leafWidth / 2 + 0.045 : leafCenterX - leafWidth / 2 + 0.045
addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005) addLeafBox(hardwareMaterial, 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.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025)
if (handleBothSides) { if (handleBothSides) {
addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, -faceZ - 0.005) addLeafBox(hardwareMaterial, 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.022, 0.1, 0.035, handleX, handleY, -faceZ - 0.025)
} }
} }
if (hasLeafContent && doorCloser) { if (hasLeafContent && doorCloser) {
currentDoorSlot = 'hardware'
const closerY = leafCenterY + leafHeight / 2 - 0.04 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( addLeafBox(
baseMaterial, hardwareMaterial,
0.14, 0.14,
0.015, 0.015,
0.015, 0.015,
@@ -1240,18 +1291,37 @@ function addDoorLeaf(
} }
if (hasLeafContent && panicBar) { if (hasLeafContent && panicBar) {
currentDoorSlot = 'hardware'
const barY = panicBarHeight - doorHeight / 2 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) { if (hasLeafContent) {
currentDoorSlot = 'hardware'
const hingeMarkerX = hingeSide === 'right' ? hingeX - 0.012 : hingeX + 0.012 const hingeMarkerX = hingeSide === 'right' ? hingeX - 0.012 : hingeX + 0.012
const hingeH = 0.1 const hingeH = 0.1
const hingeW = 0.024 const hingeW = 0.024
const hingeD = leafDepth + 0.016 const hingeD = leafDepth + 0.016
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafBottom + 0.25, 0) addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafBottom + 0.25, 0)
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, (leafBottom + leafTop) / 2, 0) addBox(
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0) 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 panelLength = insideWidth / panelCount
const foldAngle = Math.PI * 0.44 * foldAmount const foldAngle = Math.PI * 0.44 * foldAmount
currentDoorSlot = 'hardware'
addBox( addBox(
mesh, mesh,
baseMaterial, hardwareMaterial,
insideWidth, insideWidth,
Math.min(frameThickness * 0.5, 0.025), Math.min(frameThickness * 0.5, 0.025),
Math.max(frameDepth * 0.45, 0.035), Math.max(frameDepth * 0.45, 0.035),
@@ -1312,6 +1383,7 @@ function addFoldingDoor(
}) })
} }
currentDoorSlot = undefined
for (let index = 0; index < panelCount; index++) { for (let index = 0; index < panelCount; index++) {
const start = vertices[index]! const start = vertices[index]!
const end = vertices[index + 1]! const end = vertices[index + 1]!
@@ -1359,6 +1431,7 @@ function addFoldingDoor(
keepFrameWhenEmpty: true, keepFrameWhenEmpty: true,
}) })
currentDoorSlot = undefined
for (const point of [start, end]) { for (const point of [start, end]) {
addBox( addBox(
mesh, mesh,
@@ -1375,9 +1448,10 @@ function addFoldingDoor(
const handlePoint = vertices[vertices.length - 1]! const handlePoint = vertices[vertices.length - 1]!
const handleY = handleHeight - doorHeight / 2 const handleY = handleHeight - doorHeight / 2
currentDoorSlot = 'hardware'
addBox( addBox(
mesh, mesh,
baseMaterial, hardwareMaterial,
0.035, 0.035,
0.16, 0.16,
leafDepth + 0.035, leafDepth + 0.035,
@@ -1387,7 +1461,7 @@ function addFoldingDoor(
) )
addBox( addBox(
mesh, mesh,
baseMaterial, hardwareMaterial,
0.035, 0.035,
0.16, 0.16,
leafDepth + 0.035, leafDepth + 0.035,
@@ -1436,9 +1510,10 @@ function addPocketDoor(
const handleY = handleHeight - doorHeight / 2 const handleY = handleHeight - doorHeight / 2
const handleX = leafCenterX - slideSign * (leafWidth / 2 - 0.055) const handleX = leafCenterX - slideSign * (leafWidth / 2 - 0.055)
currentDoorSlot = 'hardware'
addBox( addBox(
mesh, mesh,
baseMaterial, hardwareMaterial,
insideWidth * 2, insideWidth * 2,
Math.min(frameThickness * 0.45, 0.024), Math.min(frameThickness * 0.45, 0.024),
Math.max(frameDepth * 0.38, 0.03), Math.max(frameDepth * 0.38, 0.03),
@@ -1446,6 +1521,7 @@ function addPocketDoor(
topY - 0.018, topY - 0.018,
0, 0,
) )
currentDoorSlot = undefined
addBox( addBox(
mesh, mesh,
revealMaterial, revealMaterial,
@@ -1487,8 +1563,27 @@ function addPocketDoor(
segments, segments,
contentPadding, contentPadding,
}) })
addBox(mesh, baseMaterial, 0.03, 0.18, leafDepth + 0.03, handleX, handleY, leafDepth / 2 + 0.02) currentDoorSlot = 'hardware'
addBox(mesh, baseMaterial, 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,
)
addBox(
mesh,
hardwareMaterial,
0.03,
0.18,
leafDepth + 0.03,
handleX,
handleY,
-leafDepth / 2 - 0.02,
)
} }
function addBarnDoor( function addBarnDoor(
@@ -1533,9 +1628,10 @@ function addBarnDoor(
const handleX = leafCenterX - slideSign * (leafWidth / 2 - 0.075) const handleX = leafCenterX - slideSign * (leafWidth / 2 - 0.075)
const wheelY = trackY - 0.075 const wheelY = trackY - 0.075
addBox(mesh, revealMaterial, railLength, 0.035, 0.035, railCenterX, trackY, faceZ + 0.01) currentDoorSlot = 'hardware'
addBox(mesh, revealMaterial, 0.05, 0.13, 0.035, -insideWidth / 2, trackY - 0.02, faceZ + 0.01) addBox(mesh, hardwareMaterial, 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, 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 = ( const addBarnLeafBox = (
material: THREE.Material, material: THREE.Material,
@@ -1559,6 +1655,7 @@ function addBarnDoor(
keepFrameWhenEmpty: true, keepFrameWhenEmpty: true,
}) })
currentDoorSlot = undefined
addRotatedBox( addRotatedBox(
mesh, mesh,
revealMaterial, revealMaterial,
@@ -1582,11 +1679,12 @@ function addBarnDoor(
0.52, 0.52,
) )
currentDoorSlot = 'hardware'
for (const offset of [-leafWidth * 0.28, leafWidth * 0.28]) { 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( addBox(
mesh, mesh,
revealMaterial, hardwareMaterial,
0.026, 0.026,
0.16, 0.16,
0.026, 0.026,
@@ -1596,9 +1694,10 @@ function addBarnDoor(
) )
} }
currentDoorSlot = 'hardware'
addBox( addBox(
mesh, mesh,
baseMaterial, hardwareMaterial,
0.032, 0.032,
0.22, 0.22,
leafDepth + 0.034, leafDepth + 0.034,
@@ -1608,7 +1707,7 @@ function addBarnDoor(
) )
addBox( addBox(
mesh, mesh,
baseMaterial, hardwareMaterial,
0.032, 0.032,
0.22, 0.22,
leafDepth + 0.034, leafDepth + 0.034,
@@ -1663,10 +1762,20 @@ function addSlidingDoor(
const handleY = handleHeight - doorHeight / 2 const handleY = handleHeight - doorHeight / 2
const handleX = activeX + activeSign * (panelWidth / 2 - 0.06) 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( addBox(
mesh, mesh,
revealMaterial, hardwareMaterial,
insideWidth,
0.024,
Math.max(frameDepth * 0.32, 0.026),
0,
railY,
0,
)
addBox(
mesh,
hardwareMaterial,
insideWidth, insideWidth,
0.018, 0.018,
Math.max(frameDepth * 0.28, 0.022), Math.max(frameDepth * 0.28, 0.022),
@@ -1717,8 +1826,27 @@ function addSlidingDoor(
contentPadding, contentPadding,
keepFrameWhenEmpty: true, keepFrameWhenEmpty: true,
}) })
addBox(mesh, baseMaterial, 0.032, 0.24, 0.016, handleX, handleY, frontZ + leafDepth / 2 + 0.01) currentDoorSlot = 'hardware'
addBox(mesh, baseMaterial, 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,
)
addBox(
mesh,
hardwareMaterial,
0.032,
0.24,
0.016,
handleX,
handleY,
frontZ - leafDepth / 2 - 0.01,
)
} }
function addGarageSectionalDoor( function addGarageSectionalDoor(
@@ -1755,9 +1883,10 @@ function addGarageSectionalDoor(
const railY = leafCenterY + leafHeight / 2 - 0.04 const railY = leafCenterY + leafHeight / 2 - 0.04
const railZ = -travelDepth / 2 const railZ = -travelDepth / 2
currentDoorSlot = 'hardware'
addBox( addBox(
mesh, mesh,
revealMaterial, hardwareMaterial,
0.035, 0.035,
Math.max(0.04, frameThickness * 0.75), Math.max(0.04, frameThickness * 0.75),
travelDepth, travelDepth,
@@ -1767,7 +1896,7 @@ function addGarageSectionalDoor(
) )
addBox( addBox(
mesh, mesh,
revealMaterial, hardwareMaterial,
0.035, 0.035,
Math.max(0.04, frameThickness * 0.75), Math.max(0.04, frameThickness * 0.75),
travelDepth, travelDepth,
@@ -1798,6 +1927,7 @@ function addGarageSectionalDoor(
const trimDepth = 0.01 const trimDepth = 0.01
const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006 const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006
const addSectionalTrim = (localY: number) => { const addSectionalTrim = (localY: number) => {
currentDoorSlot = undefined
addBoxWithRotation( addBoxWithRotation(
mesh, mesh,
revealMaterial, revealMaterial,
@@ -1811,6 +1941,7 @@ function addGarageSectionalDoor(
) )
} }
currentDoorSlot = 'panel'
addBoxWithRotation( addBoxWithRotation(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -1826,7 +1957,8 @@ function addGarageSectionalDoor(
addSectionalTrim(-revealOffset) 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( function addGarageRollupDoor(
@@ -1859,9 +1991,10 @@ function addGarageRollupDoor(
const drumY = topY + drumMaxRadius * 0.12 const drumY = topY + drumMaxRadius * 0.12
const drumZ = -frameDepth / 2 - drumMaxRadius * 0.72 const drumZ = -frameDepth / 2 - drumMaxRadius * 0.72
currentDoorSlot = 'hardware'
addBox( addBox(
mesh, mesh,
revealMaterial, hardwareMaterial,
0.032, 0.032,
leafHeight, leafHeight,
Math.max(frameDepth * 0.48, 0.035), Math.max(frameDepth * 0.48, 0.035),
@@ -1871,7 +2004,7 @@ function addGarageRollupDoor(
) )
addBox( addBox(
mesh, mesh,
revealMaterial, hardwareMaterial,
0.032, 0.032,
leafHeight, leafHeight,
Math.max(frameDepth * 0.48, 0.035), Math.max(frameDepth * 0.48, 0.035),
@@ -1881,8 +2014,10 @@ function addGarageRollupDoor(
) )
if (visibleHeight > 0.01) { if (visibleHeight > 0.01) {
currentDoorSlot = 'panel'
addBox(mesh, baseMaterial, insideWidth, visibleHeight, leafDepth, 0, curtainCenterY, 0) addBox(mesh, baseMaterial, insideWidth, visibleHeight, leafDepth, 0, curtainCenterY, 0)
currentDoorSlot = undefined
for (let index = 0; index < visibleSlatCount; index++) { for (let index = 0; index < visibleSlatCount; index++) {
const y = topY - Math.min(visibleHeight, index * slatHeight) const y = topY - Math.min(visibleHeight, index * slatHeight)
addBox(mesh, revealMaterial, insideWidth - 0.08, 0.01, 0.012, 0, y, leafDepth / 2 + 0.012) 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( const drum = new THREE.Mesh(
new THREE.CylinderGeometry(drumMaxRadius, drumMaxRadius, insideWidth + frameThickness, 36), new THREE.CylinderGeometry(drumMaxRadius, drumMaxRadius, insideWidth + frameThickness, 36),
baseMaterial, baseMaterial,
) )
drum.position.set(0, drumY, drumZ) drum.position.set(0, drumY, drumZ)
drum.rotation.z = Math.PI / 2 drum.rotation.z = Math.PI / 2
tagDoorSlot(drum)
mesh.add(drum) mesh.add(drum)
currentDoorSlot = 'hardware'
addBox( addBox(
mesh, mesh,
revealMaterial, hardwareMaterial,
insideWidth + frameThickness, insideWidth + frameThickness,
0.026, 0.026,
Math.max(frameDepth * 0.52, 0.04), Math.max(frameDepth * 0.52, 0.04),
@@ -1949,9 +2087,10 @@ function addGarageTiltupDoor(
const railY = hingeY - frameThickness * 0.35 const railY = hingeY - frameThickness * 0.35
const railZ = -railLength / 2 const railZ = -railLength / 2
currentDoorSlot = 'hardware'
addBox( addBox(
mesh, mesh,
revealMaterial, hardwareMaterial,
0.03, 0.03,
Math.max(frameThickness * 0.7, 0.035), Math.max(frameThickness * 0.7, 0.035),
railLength, railLength,
@@ -1961,7 +2100,7 @@ function addGarageTiltupDoor(
) )
addBox( addBox(
mesh, mesh,
revealMaterial, hardwareMaterial,
0.03, 0.03,
Math.max(frameThickness * 0.7, 0.035), Math.max(frameThickness * 0.7, 0.035),
railLength, railLength,
@@ -1970,6 +2109,7 @@ function addGarageTiltupDoor(
railZ, railZ,
) )
currentDoorSlot = 'panel'
addBoxWithRotation( addBoxWithRotation(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -1987,6 +2127,7 @@ function addGarageTiltupDoor(
const trimDepth = 0.012 const trimDepth = 0.012
const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006 const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006
const addTiltupTrim = (localX: number, localY: number, trimWidth: number, trimHeight: number) => { const addTiltupTrim = (localX: number, localY: number, trimWidth: number, trimHeight: number) => {
currentDoorSlot = undefined
addBoxWithRotation( addBoxWithRotation(
mesh, mesh,
revealMaterial, revealMaterial,
@@ -2005,7 +2146,17 @@ function addGarageTiltupDoor(
addTiltupTrim(-insetWidth / 2, 0, 0.018, insetHeight) addTiltupTrim(-insetWidth / 2, 0, 0.018, insetHeight)
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'] { function getEffectiveOpeningShape(node: DoorNode): DoorNode['openingShape'] {
@@ -2019,15 +2170,12 @@ function getEffectiveOpeningShape(node: DoorNode): DoorNode['openingShape'] {
function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
const node = normalizeDoorNodeForRender(rawNode) const node = normalizeDoorNodeForRender(rawNode)
currentDoorSlot = undefined
// Root mesh is an invisible hitbox; all visuals live in child meshes // Root mesh is an invisible hitbox; all visuals live in child meshes
mesh.geometry.dispose() mesh.geometry.dispose()
mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth) mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth)
mesh.material = hitboxMaterial 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) // 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]) 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) mesh.remove(child)
} }
// Point the builder-facing body/glass materials at this door's slot overrides // Point the builder-facing materials at this door's slot overrides for the
// for the duration of its build (recomputed per node, so the next door resets // duration of its build (recomputed per node, so the next door resets cleanly
// cleanly without a restore). Reveal keeps its own material. // without a restore). Reveal keeps its own material.
baseMaterial = resolveDoorSlotMaterial(node, 'panel') baseMaterial = resolveDoorSlotMaterial(node, 'panel')
frameMaterial = resolveDoorSlotMaterial(node, 'frame')
glassMaterial = resolveDoorSlotMaterial(node, 'glass') glassMaterial = resolveDoorSlotMaterial(node, 'glass')
hardwareMaterial = resolveDoorSlotMaterial(node, 'hardware')
const { const {
width, width,
@@ -2083,9 +2233,6 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
return return
} }
// Visuals exist: let the tagged children receive paint/hover/selection rays.
mesh.raycast = noopHitboxRaycast
const insideWidth = width - 2 * frameThickness const insideWidth = width - 2 * frameThickness
const leafH = height - frameThickness // only top frame const leafH = height - frameThickness // only top frame
const leafDepth = 0.04 const leafDepth = 0.04
@@ -2093,6 +2240,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
const swingDirectionSign = swingDirection === 'inward' ? 1 : -1 const swingDirectionSign = swingDirection === 'inward' ? 1 : -1
// ── Frame members ── // ── Frame members ──
currentDoorSlot = 'frame'
if (openingShape === 'arch') { if (openingShape === 'arch') {
const frameBottom = -height / 2 const frameBottom = -height / 2
const frameTop = height / 2 const frameTop = height / 2
@@ -2106,7 +2254,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
addBox( addBox(
mesh, mesh,
baseMaterial, frameMaterial,
frameThickness, frameThickness,
postHeight, postHeight,
frameDepth, frameDepth,
@@ -2116,7 +2264,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
) )
addBox( addBox(
mesh, mesh,
baseMaterial, frameMaterial,
frameThickness, frameThickness,
postHeight, postHeight,
frameDepth, frameDepth,
@@ -2126,7 +2274,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
) )
addShape( addShape(
mesh, mesh,
baseMaterial, frameMaterial,
useShallowHeadBar useShallowHeadBar
? createArchHeadBarShape(width, frameHeadBottomY, frameSpringY, frameTop) ? createArchHeadBarShape(width, frameHeadBottomY, frameSpringY, frameTop)
: createArchBandShape( : createArchBandShape(
@@ -2142,7 +2290,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
} else if (openingShape === 'rounded') { } else if (openingShape === 'rounded') {
addShape( addShape(
mesh, mesh,
baseMaterial, frameMaterial,
createRoundedDoorFrameShape( createRoundedDoorFrameShape(
width, width,
height, height,
@@ -2155,7 +2303,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
// Left post — full height // Left post — full height
addBox( addBox(
mesh, mesh,
baseMaterial, frameMaterial,
frameThickness, frameThickness,
height, height,
frameDepth, frameDepth,
@@ -2166,7 +2314,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
// Right post — full height // Right post — full height
addBox( addBox(
mesh, mesh,
baseMaterial, frameMaterial,
frameThickness, frameThickness,
height, height,
frameDepth, frameDepth,
@@ -2177,7 +2325,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
// Head (top bar) — full width // Head (top bar) — full width
addBox( addBox(
mesh, mesh,
baseMaterial, frameMaterial,
width, width,
frameThickness, frameThickness,
frameDepth, frameDepth,
@@ -2189,9 +2337,10 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
// ── Threshold (inside the frame) ── // ── Threshold (inside the frame) ──
if (threshold) { if (threshold) {
currentDoorSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, frameMaterial,
insideWidth, insideWidth,
thresholdHeight, thresholdHeight,
frameDepth, frameDepth,
@@ -2412,10 +2561,10 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
if (!cutout) { if (!cutout) {
cutout = new THREE.Mesh() cutout = new THREE.Mesh()
cutout.name = 'cutout' cutout.name = 'cutout'
// The cutout is a 1m-deep CSG helper for the wall hole — never interactive. // The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it
// three.js raycasts invisible meshes, so without this its front face (0.5m // wins the scene raycast over the wall in front of the recessed door body —
// proud of the door) intercepts every paint/hover ray. // making it the selection AND paint hit target for the whole opening. The
cutout.raycast = noopHitboxRaycast // paint capability then re-raycasts the door's parts to find the slot.
mesh.add(cutout) mesh.add(cutout)
} }
cutout.geometry.dispose() cutout.geometry.dispose()
@@ -12,6 +12,7 @@ import {
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { applyWorldScaleBoxUVs } from '../../lib/box-uv'
import { import {
type ColorPreset, type ColorPreset,
createSurfaceRoleMaterial, createSurfaceRoleMaterial,
@@ -24,10 +25,9 @@ import useViewer from '../../store/use-viewer'
// Invisible material for root mesh — used as selection hitbox only // Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) 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 baseMaterial = getBaseMaterial()
let glassMaterial: THREE.Material = defaultGlassMaterial let glassMaterial: THREE.Material = defaultGlassMaterial
let currentWindowSlot: string | undefined
// Per-frame viewer state, captured so the per-node mesh builder (which runs // Per-frame viewer state, captured so the per-node mesh builder (which runs
// outside React) can resolve each window's slot materials. // outside React) can resolve each window's slot materials.
let currentShading: RenderShading = 'rendered' let currentShading: RenderShading = 'rendered'
@@ -158,12 +158,8 @@ export const WindowSystem = () => {
return null 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 { function tagWindowSlot(mesh: THREE.Mesh): THREE.Mesh {
if (mesh.material === glassMaterial) mesh.userData.slotId = 'glass' mesh.userData.slotId = currentWindowSlot
else if (mesh.material === baseMaterial) mesh.userData.slotId = 'frame'
return mesh return mesh
} }
@@ -176,15 +172,25 @@ function nodeReferencesSceneMaterial(node: { slots?: Record<string, string> }):
return false 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 { function windowSlotDefault(slotId: 'frame' | 'glass'): THREE.Material {
if (slotId === 'glass') { if (slotId === 'glass') {
return currentTextures if (!currentTextures) return createSurfaceRoleMaterial('glazing', currentColorPreset)
? defaultGlassMaterial return (
: createSurfaceRoleMaterial('glazing', currentColorPreset) resolveMaterialRef(GLASS_DEFAULT_REF, currentSceneMaterials, currentShading) ??
defaultGlassMaterial
)
} }
return currentTextures if (!currentTextures) return createSurfaceRoleMaterial('joinery', currentColorPreset)
? getBaseMaterial(currentShading) return (
: createSurfaceRoleMaterial('joinery', currentColorPreset) resolveMaterialRef(FRAME_DEFAULT_REF, currentSceneMaterials, currentShading) ??
getBaseMaterial(currentShading)
)
} }
// Resolve a window's slot to a material: the `node.slots` override (colored mode // Resolve a window's slot to a material: the `node.slots` override (colored mode
@@ -208,7 +214,9 @@ function addBox(
y: number, y: number,
z: 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) m.position.set(x, y, z)
tagWindowSlot(m) tagWindowSlot(m)
parent.add(m) parent.add(m)
@@ -565,6 +573,7 @@ function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const innerH = innerTop - innerBottom const innerH = innerTop - innerBottom
const innerRadii = insetCornerRadii(outerRadii, inset, innerW, innerH) const innerRadii = insetCornerRadii(outerRadii, inset, innerW, innerH)
currentWindowSlot = 'frame'
addShape( addShape(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -574,6 +583,7 @@ function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (innerW > 0.01 && innerH > 0.01) { if (innerW > 0.01 && innerH > 0.01) {
const glassDepth = Math.max(0.004, frameDepth * 0.08) const glassDepth = Math.max(0.004, frameDepth * 0.08)
currentWindowSlot = 'glass'
addShape( addShape(
mesh, mesh,
glassMaterial, glassMaterial,
@@ -591,6 +601,7 @@ function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH) const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH)
let x = innerLeft let x = innerLeft
currentWindowSlot = 'frame'
for (let c = 0; c < numCols - 1; c++) { for (let c = 0; c < numCols - 1; c++) {
x += colWidths[c]! x += colWidths[c]!
const x1 = x const x1 = x
@@ -611,6 +622,7 @@ function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
} }
let y = innerTop let y = innerTop
currentWindowSlot = 'frame'
for (let r = 0; r < numRows - 1; r++) { for (let r = 0; r < numRows - 1; r++) {
y -= rowHeights[r]! y -= rowHeights[r]!
const yTop = y const yTop = y
@@ -637,6 +649,7 @@ function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (sill) { if (sill) {
const sillW = width + sillDepth * 0.4 const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2 const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -678,10 +691,12 @@ function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const innerArchHeight = getClampedArchHeight(innerW, innerH, archHeight - inset) const innerArchHeight = getClampedArchHeight(innerW, innerH, archHeight - inset)
const innerSpringY = innerTop - innerArchHeight const innerSpringY = innerTop - innerArchHeight
currentWindowSlot = 'frame'
addShape(mesh, baseMaterial, createArchedFrameShape(width, height, archHeight, inset), frameDepth) addShape(mesh, baseMaterial, createArchedFrameShape(width, height, archHeight, inset), frameDepth)
if (innerW > 0.01 && innerH > 0.01) { if (innerW > 0.01 && innerH > 0.01) {
const glassDepth = Math.max(0.004, frameDepth * 0.08) const glassDepth = Math.max(0.004, frameDepth * 0.08)
currentWindowSlot = 'glass'
addShape( addShape(
mesh, mesh,
glassMaterial, glassMaterial,
@@ -700,6 +715,7 @@ function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const innerHalfWidth = innerW / 2 const innerHalfWidth = innerW / 2
let x = innerLeft let x = innerLeft
currentWindowSlot = 'frame'
for (let c = 0; c < numCols - 1; c++) { for (let c = 0; c < numCols - 1; c++) {
x += colWidths[c]! x += colWidths[c]!
const x1 = x const x1 = x
@@ -720,6 +736,7 @@ function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
} }
let y = innerTop let y = innerTop
currentWindowSlot = 'frame'
for (let r = 0; r < numRows - 1; r++) { for (let r = 0; r < numRows - 1; r++) {
y -= rowHeights[r]! y -= rowHeights[r]!
const yTop = y const yTop = y
@@ -747,6 +764,7 @@ function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (sill) { if (sill) {
const sillW = width + sillDepth * 0.4 const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2 const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -786,6 +804,7 @@ function addSlidingWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const innerH = height - 2 * frameThickness const innerH = height - 2 * frameThickness
// Outer frame. // Outer frame.
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -848,6 +867,7 @@ function addSlidingWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
mesh.add(activePanel) mesh.add(activePanel)
// Twin tracks signal the sliding operation without adding editor-only state. // Twin tracks signal the sliding operation without adding editor-only state.
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -869,10 +889,12 @@ function addSlidingWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
0, 0,
) )
currentWindowSlot = 'glass'
addBox(activePanel, glassMaterial, panelWidth, panelH, glassDepth, 0, 0, 0) addBox(activePanel, glassMaterial, panelWidth, panelH, glassDepth, 0, 0, 0)
addBox(mesh, glassMaterial, panelWidth, panelH, glassDepth, rightPanelX, 0, rightZ) 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. // The right sash stays fixed. The left sash is the active panel that slides across it.
currentWindowSlot = 'frame'
addBox( addBox(
activePanel, activePanel,
baseMaterial, baseMaterial,
@@ -918,6 +940,7 @@ function addSlidingWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (sill) { if (sill) {
const sillW = width + sillDepth * 0.4 const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2 const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -954,6 +977,7 @@ function addRectCasementSash(
sash.rotation.y = rotationY sash.rotation.y = rotationY
parent.add(sash) parent.add(sash)
currentWindowSlot = 'frame'
addBox( addBox(
sash, sash,
baseMaterial, baseMaterial,
@@ -994,6 +1018,7 @@ function addRectCasementSash(
0, 0,
0, 0,
) )
currentWindowSlot = 'glass'
addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08) 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 markerW = Math.max(frameThickness * 0.38, 0.018)
const markerH = innerH * 0.24 const markerH = innerH * 0.24
currentWindowSlot = 'frame'
for (const pivotX of [-innerW / 2, innerW / 2]) { for (const pivotX of [-innerW / 2, innerW / 2]) {
addBox( addBox(
mesh, mesh,
@@ -1184,6 +1210,7 @@ function addShapedFrenchCasementSash(
const outerArchHeight = getClampedArchHeight(node.width, node.height, node.archHeight) const outerArchHeight = getClampedArchHeight(node.width, node.height, node.archHeight)
const sashArchHeight = getClampedArchHeight(fullW, leafH, outerArchHeight - frameThickness) const sashArchHeight = getClampedArchHeight(fullW, leafH, outerArchHeight - frameThickness)
const sashSpringY = node.height / 2 - outerArchHeight const sashSpringY = node.height / 2 - outerArchHeight
currentWindowSlot = 'frame'
addShape( addShape(
sashVisual, sashVisual,
baseMaterial, baseMaterial,
@@ -1200,6 +1227,7 @@ function addShapedFrenchCasementSash(
) )
const glassInset = Math.min(sashFrameThickness, leafW / 2 - 0.005, leafH / 2 - 0.005) const glassInset = Math.min(sashFrameThickness, leafW / 2 - 0.005, leafH / 2 - 0.005)
if (glassInset > 0.001) { if (glassInset > 0.001) {
currentWindowSlot = 'glass'
addShape( addShape(
sashVisual, sashVisual,
glassMaterial, glassMaterial,
@@ -1225,6 +1253,7 @@ function addShapedFrenchCasementSash(
fullW, fullW,
leafH, leafH,
) )
currentWindowSlot = 'frame'
addShape( addShape(
sashVisual, sashVisual,
baseMaterial, baseMaterial,
@@ -1233,6 +1262,7 @@ function addShapedFrenchCasementSash(
) )
const glassInset = Math.min(sashFrameThickness, leafW / 2 - 0.005, leafH / 2 - 0.005) const glassInset = Math.min(sashFrameThickness, leafW / 2 - 0.005, leafH / 2 - 0.005)
if (glassInset > 0.001) { if (glassInset > 0.001) {
currentWindowSlot = 'glass'
addShape( addShape(
sashVisual, sashVisual,
glassMaterial, glassMaterial,
@@ -1249,6 +1279,7 @@ function addFrenchCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const innerH = height - 2 * frameThickness const innerH = height - 2 * frameThickness
// Fixed outer frame. // Fixed outer frame.
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -1321,6 +1352,7 @@ function addFrenchCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (sill) { if (sill) {
const sillW = width + sillDepth * 0.4 const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2 const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -1340,6 +1372,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const innerH = height - 2 * frameThickness const innerH = height - 2 * frameThickness
if (node.openingShape === 'arch') { if (node.openingShape === 'arch') {
currentWindowSlot = 'frame'
addShape( addShape(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -1352,6 +1385,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
frameDepth, frameDepth,
) )
} else { } else {
currentWindowSlot = 'frame'
addShape( addShape(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -1403,6 +1437,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (sill) { if (sill) {
const sillW = width + sillDepth * 0.4 const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2 const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -1443,6 +1478,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
innerH, innerH,
(node.archHeight ?? innerW / 2) - frameThickness, (node.archHeight ?? innerW / 2) - frameThickness,
) )
currentWindowSlot = 'frame'
addShape( addShape(
sashVisual, sashVisual,
baseMaterial, baseMaterial,
@@ -1453,6 +1489,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (glassInset > 0.001) { if (glassInset > 0.001) {
const glassW = innerW - 2 * glassInset const glassW = innerW - 2 * glassInset
const glassH = innerH - 2 * glassInset const glassH = innerH - 2 * glassInset
currentWindowSlot = 'glass'
addShape( addShape(
sashVisual, sashVisual,
glassMaterial, glassMaterial,
@@ -1469,6 +1506,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
} }
} else { } else {
const outerRadii = getWindowRoundedRadii(node, innerW, innerH) const outerRadii = getWindowRoundedRadii(node, innerW, innerH)
currentWindowSlot = 'frame'
addShape( addShape(
sashVisual, sashVisual,
baseMaterial, baseMaterial,
@@ -1479,6 +1517,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (glassInset > 0.001) { if (glassInset > 0.001) {
const glassW = innerW - 2 * glassInset const glassW = innerW - 2 * glassInset
const glassH = innerH - 2 * glassInset const glassH = innerH - 2 * glassInset
currentWindowSlot = 'glass'
addShape( addShape(
sashVisual, sashVisual,
glassMaterial, glassMaterial,
@@ -1495,6 +1534,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
} }
} }
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -1520,6 +1560,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (sill) { if (sill) {
const sillW = width + sillDepth * 0.4 const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2 const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -1550,6 +1591,7 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const innerH = height - 2 * frameThickness const innerH = height - 2 * frameThickness
// Fixed outer frame. // Fixed outer frame.
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -1610,6 +1652,7 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
sash.rotation.y = hingeSign * openAngle sash.rotation.y = hingeSign * openAngle
mesh.add(sash) mesh.add(sash)
currentWindowSlot = 'frame'
addBox( addBox(
sash, sash,
baseMaterial, baseMaterial,
@@ -1650,9 +1693,11 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
0, 0,
0, 0,
) )
currentWindowSlot = 'glass'
addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08) addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08)
// Small hinge markers make the pivot side legible when the sash is closed. // Small hinge markers make the pivot side legible when the sash is closed.
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -1678,6 +1723,7 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (sill) { if (sill) {
const sillW = width + sillDepth * 0.4 const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2 const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -1703,6 +1749,7 @@ function addAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const innerH = height - 2 * frameThickness const innerH = height - 2 * frameThickness
// Fixed outer frame. // Fixed outer frame.
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -1762,6 +1809,7 @@ function addAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
sash.rotation.x = -openAngle sash.rotation.x = -openAngle
mesh.add(sash) mesh.add(sash)
currentWindowSlot = 'frame'
addBox( addBox(
sash, sash,
baseMaterial, baseMaterial,
@@ -1802,9 +1850,11 @@ function addAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
sashCenterY, sashCenterY,
0, 0,
) )
currentWindowSlot = 'glass'
addBox(sash, glassMaterial, glassW, glassH, glassDepth, 0, sashCenterY, sashDepth * 0.08) addBox(sash, glassMaterial, glassW, glassH, glassDepth, 0, sashCenterY, sashDepth * 0.08)
// Compact hinge rail, visible even when the sash is closed. // Compact hinge rail, visible even when the sash is closed.
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -1820,6 +1870,7 @@ function addAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (sill) { if (sill) {
const sillW = width + sillDepth * 0.4 const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2 const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -1839,6 +1890,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const innerH = height - 2 * frameThickness const innerH = height - 2 * frameThickness
if (node.openingShape === 'arch') { if (node.openingShape === 'arch') {
currentWindowSlot = 'frame'
addShape( addShape(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -1851,6 +1903,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
frameDepth, frameDepth,
) )
} else { } else {
currentWindowSlot = 'frame'
addShape( addShape(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -1888,6 +1941,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
innerH, innerH,
(node.archHeight ?? innerW / 2) - frameThickness, (node.archHeight ?? innerW / 2) - frameThickness,
) )
currentWindowSlot = 'frame'
addShape( addShape(
sashVisual, sashVisual,
baseMaterial, baseMaterial,
@@ -1898,6 +1952,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (glassInset > 0.001) { if (glassInset > 0.001) {
const glassW = innerW - 2 * glassInset const glassW = innerW - 2 * glassInset
const glassH = innerH - 2 * glassInset const glassH = innerH - 2 * glassInset
currentWindowSlot = 'glass'
addShape( addShape(
sashVisual, sashVisual,
glassMaterial, glassMaterial,
@@ -1914,6 +1969,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
} }
} else { } else {
const outerRadii = getWindowRoundedRadii(node, innerW, innerH) const outerRadii = getWindowRoundedRadii(node, innerW, innerH)
currentWindowSlot = 'frame'
addShape( addShape(
sashVisual, sashVisual,
baseMaterial, baseMaterial,
@@ -1924,6 +1980,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (glassInset > 0.001) { if (glassInset > 0.001) {
const glassW = innerW - 2 * glassInset const glassW = innerW - 2 * glassInset
const glassH = innerH - 2 * glassInset const glassH = innerH - 2 * glassInset
currentWindowSlot = 'glass'
addShape( addShape(
sashVisual, sashVisual,
glassMaterial, glassMaterial,
@@ -1940,6 +1997,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
} }
} }
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -1955,6 +2013,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (sill) { if (sill) {
const sillW = width + sillDepth * 0.4 const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2 const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -1980,6 +2039,7 @@ function addHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const innerH = height - 2 * frameThickness const innerH = height - 2 * frameThickness
// Fixed outer frame. // Fixed outer frame.
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -2037,6 +2097,7 @@ function addHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
sash.rotation.x = -openAngle sash.rotation.x = -openAngle
mesh.add(sash) mesh.add(sash)
currentWindowSlot = 'frame'
addBox( addBox(
sash, sash,
baseMaterial, baseMaterial,
@@ -2068,9 +2129,11 @@ function addHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
innerH / 2, innerH / 2,
0, 0,
) )
currentWindowSlot = 'glass'
addBox(sash, glassMaterial, glassW, glassH, glassDepth, 0, innerH / 2, sashDepth * 0.08) addBox(sash, glassMaterial, glassW, glassH, glassDepth, 0, innerH / 2, sashDepth * 0.08)
// Compact bottom hinge rail, visible even when the sash is closed. // Compact bottom hinge rail, visible even when the sash is closed.
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -2086,6 +2149,7 @@ function addHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (sill) { if (sill) {
const sillW = width + sillDepth * 0.4 const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2 const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -2105,6 +2169,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const innerH = height - 2 * frameThickness const innerH = height - 2 * frameThickness
if (node.openingShape === 'arch') { if (node.openingShape === 'arch') {
currentWindowSlot = 'frame'
addShape( addShape(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -2117,6 +2182,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
frameDepth, frameDepth,
) )
} else { } else {
currentWindowSlot = 'frame'
addShape( addShape(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -2153,6 +2219,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
innerH, innerH,
(node.archHeight ?? innerW / 2) - frameThickness, (node.archHeight ?? innerW / 2) - frameThickness,
) )
currentWindowSlot = 'frame'
addShape( addShape(
sashVisual, sashVisual,
baseMaterial, baseMaterial,
@@ -2163,6 +2230,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (glassInset > 0.001) { if (glassInset > 0.001) {
const glassW = innerW - 2 * glassInset const glassW = innerW - 2 * glassInset
const glassH = innerH - 2 * glassInset const glassH = innerH - 2 * glassInset
currentWindowSlot = 'glass'
addShape( addShape(
sashVisual, sashVisual,
glassMaterial, glassMaterial,
@@ -2179,6 +2247,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
} }
} else { } else {
const outerRadii = getWindowRoundedRadii(node, innerW, innerH) const outerRadii = getWindowRoundedRadii(node, innerW, innerH)
currentWindowSlot = 'frame'
addShape( addShape(
sashVisual, sashVisual,
baseMaterial, baseMaterial,
@@ -2189,6 +2258,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (glassInset > 0.001) { if (glassInset > 0.001) {
const glassW = innerW - 2 * glassInset const glassW = innerW - 2 * glassInset
const glassH = innerH - 2 * glassInset const glassH = innerH - 2 * glassInset
currentWindowSlot = 'glass'
addShape( addShape(
sashVisual, sashVisual,
glassMaterial, glassMaterial,
@@ -2205,6 +2275,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
} }
} }
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -2220,6 +2291,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (sill) { if (sill) {
const sillW = width + sillDepth * 0.4 const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2 const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -2243,6 +2315,7 @@ function addHungSash(
glassW: number, glassW: number,
glassH: number, glassH: number,
) { ) {
currentWindowSlot = 'frame'
addBox( addBox(
parent, parent,
baseMaterial, baseMaterial,
@@ -2283,6 +2356,7 @@ function addHungSash(
0, 0,
0, 0,
) )
currentWindowSlot = 'glass'
addBox(parent, glassMaterial, glassW, glassH, glassDepth, 0, 0, 0) 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 const innerH = height - 2 * frameThickness
// Fixed outer frame. // Fixed outer frame.
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -2357,6 +2432,7 @@ function addSingleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
mesh.add(activeSash) mesh.add(activeSash)
// Side tracks show the lower sash is the moving element. // Side tracks show the lower sash is the moving element.
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -2403,6 +2479,7 @@ function addSingleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
) )
// Meeting rails: top sash fixed, bottom sash moves upward over it. // Meeting rails: top sash fixed, bottom sash moves upward over it.
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -2428,6 +2505,7 @@ function addSingleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (sill) { if (sill) {
const sillW = width + sillDepth * 0.4 const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2 const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -2448,6 +2526,7 @@ function addDoubleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const innerH = height - 2 * frameThickness const innerH = height - 2 * frameThickness
// Fixed outer frame. // Fixed outer frame.
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -2516,6 +2595,7 @@ function addDoubleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
mesh.add(bottomSash) mesh.add(bottomSash)
// Side tracks show both sashes move vertically. // Side tracks show both sashes move vertically.
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -2559,6 +2639,7 @@ function addDoubleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
) )
// Opposing meeting rails: top sash descends while bottom sash rises. // Opposing meeting rails: top sash descends while bottom sash rises.
currentWindowSlot = 'frame'
addBox( addBox(
topSash, topSash,
baseMaterial, baseMaterial,
@@ -2584,6 +2665,7 @@ function addDoubleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (sill) { if (sill) {
const sillW = width + sillDepth * 0.4 const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2 const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -2602,6 +2684,7 @@ function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const innerW = width - 2 * frameThickness const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness const innerH = height - 2 * frameThickness
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -2662,6 +2745,7 @@ function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const addBayPanel = (parent: THREE.Object3D, panelW: number) => { const addBayPanel = (parent: THREE.Object3D, panelW: number) => {
const glassW = Math.max(panelW - 2 * sashFrameThickness, 0.01) const glassW = Math.max(panelW - 2 * sashFrameThickness, 0.01)
const glassH = Math.max(innerH - 2 * sashFrameThickness, 0.01) const glassH = Math.max(innerH - 2 * sashFrameThickness, 0.01)
currentWindowSlot = 'frame'
addBox( addBox(
parent, parent,
baseMaterial, baseMaterial,
@@ -2702,10 +2786,12 @@ function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
0, 0,
0, 0,
) )
currentWindowSlot = 'glass'
addBox(parent, glassMaterial, glassW, glassH, glassDepth, 0, 0, panelDepth * 0.08) addBox(parent, glassMaterial, glassW, glassH, glassDepth, 0, 0, panelDepth * 0.08)
} }
const addBayCap = (centerY: number) => { const addBayCap = (centerY: number) => {
currentWindowSlot = 'frame'
const halfThickness = frameThickness / 2 const halfThickness = frameThickness / 2
const vertices: number[] = [] const vertices: number[] = []
const indices: number[] = [] const indices: number[] = []
@@ -2760,7 +2846,7 @@ function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)) geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))
geometry.setIndex(indices) geometry.setIndex(indices)
geometry.computeVertexNormals() geometry.computeVertexNormals()
mesh.add(new THREE.Mesh(geometry, baseMaterial)) mesh.add(tagWindowSlot(new THREE.Mesh(geometry, baseMaterial)))
} }
const center = new THREE.Group() const center = new THREE.Group()
@@ -2787,6 +2873,7 @@ function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (sill) { if (sill) {
const sillW = width + sillDepth * 0.4 const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2 const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -2805,6 +2892,7 @@ function addBowWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const innerW = width - 2 * frameThickness const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness const innerH = height - 2 * frameThickness
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -2931,15 +3019,19 @@ function addBowWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
} }
const addCurvedMesh = (material: THREE.Material, geometry: THREE.BufferGeometry) => { 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(glassTop, innerH / 2))
addCurvedMesh(baseMaterial, createCurvedVerticalBand(-innerH / 2, glassBottom)) addCurvedMesh(baseMaterial, createCurvedVerticalBand(-innerH / 2, glassBottom))
currentWindowSlot = 'glass'
addCurvedMesh(glassMaterial, createCurvedVerticalBand(glassBottom, glassTop, frameDepth * 0.04)) addCurvedMesh(glassMaterial, createCurvedVerticalBand(glassBottom, glassTop, frameDepth * 0.04))
currentWindowSlot = 'frame'
addCurvedMesh(baseMaterial, createCurvedCap(slabYTop, frameThickness)) addCurvedMesh(baseMaterial, createCurvedCap(slabYTop, frameThickness))
addCurvedMesh(baseMaterial, createCurvedCap(slabYBottom, frameThickness)) addCurvedMesh(baseMaterial, createCurvedCap(slabYBottom, frameThickness))
currentWindowSlot = 'frame'
for (let index = 0; index <= mullionCount; index += 1) { for (let index = 0; index <= mullionCount; index += 1) {
const x = -halfSpan + (innerW * index) / mullionCount const x = -halfSpan + (innerW * index) / mullionCount
addBox(mesh, baseMaterial, sashFrameThickness, innerH, frameDepth * 0.72, x, 0, arcZAt(x)) 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) { if (sill) {
const sillW = width + sillDepth * 0.4 const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2 const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -2973,6 +3066,7 @@ function addLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const innerW = width - 2 * frameThickness const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness const innerH = height - 2 * frameThickness
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -3027,6 +3121,7 @@ function addLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
slats.name = LOUVERED_WINDOW_SLATS_NAME slats.name = LOUVERED_WINDOW_SLATS_NAME
mesh.add(slats) mesh.add(slats)
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -3048,6 +3143,7 @@ function addLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
0, 0,
) )
currentWindowSlot = 'glass'
for (let index = 0; index < slatCount; index += 1) { for (let index = 0; index < slatCount; index += 1) {
const y = innerH / 2 - slatGap * (index + 0.5) const y = innerH / 2 - slatGap * (index + 0.5)
const slat = new THREE.Group() const slat = new THREE.Group()
@@ -3070,6 +3166,7 @@ function addLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (sill) { if (sill) {
const sillW = width + sillDepth * 0.4 const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2 const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -3097,6 +3194,7 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
const innerH = innerTop - innerBottom const innerH = innerTop - innerBottom
if (node.openingShape === 'arch') { if (node.openingShape === 'arch') {
currentWindowSlot = 'frame'
addShape( addShape(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -3109,6 +3207,7 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
frameDepth, frameDepth,
) )
} else { } else {
currentWindowSlot = 'frame'
addShape( addShape(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -3159,6 +3258,7 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
})() })()
const addVerticalRail = (x: number) => { const addVerticalRail = (x: number) => {
currentWindowSlot = 'frame'
const railX1 = x const railX1 = x
const railX2 = x + (x < 0 ? railThickness : -railThickness) const railX2 = x + (x < 0 ? railThickness : -railThickness)
const sampleX = x < 0 ? Math.max(railX1, railX2) : Math.min(railX1, railX2) 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(innerLeft)
addVerticalRail(innerRight) addVerticalRail(innerRight)
currentWindowSlot = 'glass'
for (let index = 0; index < slatCount; index += 1) { for (let index = 0; index < slatCount; index += 1) {
const y = innerTop - slatGap * (index + 0.5) const y = innerTop - slatGap * (index + 0.5)
const topBounds = getBoundsAtY(Math.min(y + slatHeight / 2, innerTop)) const topBounds = getBoundsAtY(Math.min(y + slatHeight / 2, innerTop))
@@ -3212,6 +3313,7 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
if (sill) { if (sill) {
const sillW = width + sillDepth * 0.4 const sillW = width + sillDepth * 0.4
const sillZ = frameDepth / 2 + sillDepth / 2 const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -3226,14 +3328,12 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) {
} }
function updateWindowMesh(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 // Root mesh is an invisible hitbox; all visuals live in child meshes
mesh.geometry.dispose() mesh.geometry.dispose()
mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth) mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth)
mesh.material = hitboxMaterial 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) // 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]) mesh.position.set(node.position[0], node.position[1], node.position[2])
@@ -3274,9 +3374,6 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
return return
} }
// Visuals exist: let the tagged children receive paint/hover/selection rays.
mesh.raycast = noopHitboxRaycast
if (windowType === 'sliding') { if (windowType === 'sliding') {
addSlidingWindowVisuals(node, mesh) addSlidingWindowVisuals(node, mesh)
syncWindowCutout(node, mesh) syncWindowCutout(node, mesh)
@@ -3348,6 +3445,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
// ── Frame members ── // ── Frame members ──
// Top / bottom — full width // Top / bottom — full width
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -3422,6 +3520,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
// Column dividers — full inner height // Column dividers — full inner height
cx = -innerW / 2 cx = -innerW / 2
currentWindowSlot = 'frame'
for (let c = 0; c < numCols - 1; c++) { for (let c = 0; c < numCols - 1; c++) {
cx += colWidths[c]! cx += colWidths[c]!
addBox( 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) // Row dividers — per column width, so they don't overlap column dividers (top to bottom)
cy = innerH / 2 cy = innerH / 2
currentWindowSlot = 'frame'
for (let r = 0; r < numRows - 1; r++) { for (let r = 0; r < numRows - 1; r++) {
cy -= rowHeights[r]! cy -= rowHeights[r]!
const divY = cy - rowDividerThickness / 2 const divY = cy - rowDividerThickness / 2
@@ -3459,6 +3559,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
// Glass panes // Glass panes
const glassDepth = Math.max(0.004, frameDepth * 0.08) const glassDepth = Math.max(0.004, frameDepth * 0.08)
currentWindowSlot = 'glass'
for (let c = 0; c < numCols; c++) { for (let c = 0; c < numCols; c++) {
for (let r = 0; r < numRows; r++) { for (let r = 0; r < numRows; r++) {
addBox( addBox(
@@ -3479,6 +3580,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
const sillW = width + sillDepth * 0.4 // slightly wider than frame const sillW = width + sillDepth * 0.4 // slightly wider than frame
// Protrudes from the front face of the frame (+Z) // Protrudes from the front face of the frame (+Z)
const sillZ = frameDepth / 2 + sillDepth / 2 const sillZ = frameDepth / 2 + sillDepth / 2
currentWindowSlot = 'frame'
addBox( addBox(
mesh, mesh,
baseMaterial, baseMaterial,
@@ -3500,10 +3602,10 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
if (!cutout) { if (!cutout) {
cutout = new THREE.Mesh() cutout = new THREE.Mesh()
cutout.name = 'cutout' cutout.name = 'cutout'
// The cutout is a 1m-deep CSG helper for the wall hole — never interactive. // The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it
// three.js raycasts invisible meshes, so without this its front face (0.5m // wins the scene raycast over the wall in front of the recessed window —
// proud of the glass) intercepts every paint/hover ray. // making it the selection AND paint hit target for the whole opening. The
cutout.raycast = noopHitboxRaycast // paint capability then re-raycasts the window's parts to find the slot.
mesh.add(cutout) mesh.add(cutout)
} }
cutout.geometry.dispose() cutout.geometry.dispose()