diff --git a/apps/editor/public/material/concrete/concrete_plate/concrete_plate_ao_512.ktx2 b/apps/editor/public/material/concrete/concrete_plate/concrete_plate_ao_512.ktx2 new file mode 100644 index 000000000..35e24b319 Binary files /dev/null and b/apps/editor/public/material/concrete/concrete_plate/concrete_plate_ao_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/concrete_plate/concrete_plate_basecolor_512.ktx2 b/apps/editor/public/material/concrete/concrete_plate/concrete_plate_basecolor_512.ktx2 new file mode 100644 index 000000000..7d82bd561 Binary files /dev/null and b/apps/editor/public/material/concrete/concrete_plate/concrete_plate_basecolor_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/concrete_plate/concrete_plate_normal_512.ktx2 b/apps/editor/public/material/concrete/concrete_plate/concrete_plate_normal_512.ktx2 new file mode 100644 index 000000000..4da4bb115 Binary files /dev/null and b/apps/editor/public/material/concrete/concrete_plate/concrete_plate_normal_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/concrete_plate/concrete_plate_roughness_512.ktx2 b/apps/editor/public/material/concrete/concrete_plate/concrete_plate_roughness_512.ktx2 new file mode 100644 index 000000000..8a5044f22 Binary files /dev/null and b/apps/editor/public/material/concrete/concrete_plate/concrete_plate_roughness_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/concrete_plate/concrete_plate_thumb.webp b/apps/editor/public/material/concrete/concrete_plate/concrete_plate_thumb.webp new file mode 100644 index 000000000..495ef6cae Binary files /dev/null and b/apps/editor/public/material/concrete/concrete_plate/concrete_plate_thumb.webp differ diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index d743d03cd..eedf63e4c 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -3442,7 +3442,9 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ bumpScale: 1, emissiveColor: '#000000', aoMapIntensity: 1, - side: 2, + // FrontSide — DoubleSide on a NodeMaterial poisons the WebGPU MRT scene + // pass (window/door glass relies on this). It's the only glass we use. + side: 0, opacity: 0.3, lightMapIntensity: 1, }, @@ -3860,6 +3862,44 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, }, }, + { + id: 'concrete-plate', + label: 'Concrete Plate', + category: 'concrete', + surfaces: ['wall', 'floor'], + description: 'Smooth cast concrete plate', + previewThumbnailUrl: '/material/concrete/concrete_plate/concrete_plate_thumb.webp', + preset: { + maps: { + albedoMap: '/material/concrete/concrete_plate/concrete_plate_basecolor_512.ktx2', + normalMap: '/material/concrete/concrete_plate/concrete_plate_normal_512.ktx2', + roughnessMap: '/material/concrete/concrete_plate/concrete_plate_roughness_512.ktx2', + aoMap: '/material/concrete/concrete_plate/concrete_plate_ao_512.ktx2', + }, + mapProperties: { + color: '#ffffff', + roughness: 0.8, + metalness: 0, + repeatX: 0.5, + repeatY: 0.5, + 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, + }, + }, + }, { id: 'concrete-stucco', label: 'White Stucco', @@ -3915,7 +3955,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ mapProperties: { color: '#ffffff', roughness: 0.4, - metalness: 1, + metalness: 0.6, repeatX: 1, repeatY: 1, rotation: 0, @@ -3954,7 +3994,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ mapProperties: { color: '#ffffff', roughness: 0.3, - metalness: 1, + metalness: 0.6, repeatX: 1, repeatY: 1, rotation: 0, @@ -3995,7 +4035,77 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ mapProperties: { color: '#ffffff', roughness: 0.45, - metalness: 1, + metalness: 0.6, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + // Parameter-only metal (no texture maps) — a worked example of a non-PBR + // catalog finish driven purely by three.js material settings. + id: 'metal-brass', + label: 'Brass', + category: 'metal', + surfaces: ['furniture', 'wall'], + description: 'Polished brass (flat metal, no maps)', + previewColor: '#b08d57', + preset: { + maps: {}, + mapProperties: { + color: '#b08d57', + roughness: 0.35, + metalness: 0.9, + repeatX: 1, + repeatY: 1, + rotation: 0, + wrapS: 'Repeat', + wrapT: 'Repeat', + normalScaleX: 1, + normalScaleY: 1, + emissiveIntensity: 1, + displacementScale: 0, + transparent: false, + flipY: false, + bumpScale: 1, + emissiveColor: '#000000', + aoMapIntensity: 1, + side: 0, + opacity: 1, + lightMapIntensity: 1, + }, + }, + }, + { + // Parameter-only chrome (no texture maps) — moderate metalness so it reads + // as bright metal under direct/ambient light without needing an env map. + id: 'metal-chrome', + label: 'Chrome', + category: 'metal', + surfaces: ['furniture', 'wall'], + description: 'Polished chrome (flat metal, no maps)', + previewColor: '#c8ccce', + preset: { + maps: {}, + mapProperties: { + color: '#c8ccce', + roughness: 0.2, + metalness: 0.6, repeatX: 1, repeatY: 1, rotation: 0, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index bbeca31ad..826f1332e 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1,5 +1,5 @@ import type { ComponentType } from 'react' -import type { BufferGeometry, Object3D } from 'three' +import type { BufferGeometry, Object3D, Ray } from 'three' import type { ZodObject, z } from 'zod' import type { MaterialSchema } from '../schema/material' import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material' @@ -1212,6 +1212,14 @@ export type PaintResolveArgs = { hitObjectName?: string /** Optional: the three.js object that received the pointer hit. Items read userData.slotId off it. */ hitObject?: Object3D + /** + * Optional: the pointer's world ray, so a kind can re-raycast its OWN subtree + * to pick the precise sub-mesh under the cursor — independent of what the + * shared scene raycast hit first. Door/window use this: their opening proxy + * (a proud invisible cutout) wins the scene raycast over the wall in front of + * the recessed door body, then they re-raycast their parts to find the slot. + */ + ray?: Ray } export type PaintPatchArgs = { diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index f0baa8db0..ca8fc1845 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -154,6 +154,7 @@ export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall' export { getEffectiveWallSurfaceMaterial, getWallSurfaceMaterialSignature, + WALL_SLOT_DEFAULT, WallNode, } from './nodes/wall' export { WindowNode, WindowType } from './nodes/window' diff --git a/packages/core/src/schema/nodes/ceiling.ts b/packages/core/src/schema/nodes/ceiling.ts index 724bb0fde..b94aaaa2b 100644 --- a/packages/core/src/schema/nodes/ceiling.ts +++ b/packages/core/src/schema/nodes/ceiling.ts @@ -11,6 +11,10 @@ export const CeilingNode = BaseNode.extend({ children: z.array(ItemNode.shape.id).default([]), material: MaterialSchema.optional(), materialPreset: z.string().optional(), + // Per-slot material overrides on the unified slot model, mirroring + // `ShelfNode.slots`. Key = slot id (`surface`), value = a `MaterialRef` + // (`library:` / `scene:`). Absent = the declared slot default. + slots: z.record(z.string(), z.string()).optional(), polygon: z.array(z.tuple([z.number(), z.number()])), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), holeMetadata: z.array(SurfaceHoleMetadata).default([]), diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index fd26ad687..26b4170e0 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -41,6 +41,10 @@ export const DoorNode = BaseNode.extend({ id: objectId('door'), type: nodeType('door'), material: MaterialSchema.optional(), + // Per-slot material overrides on the unified slot model. Keys: `panel` (the + // door body), `glass`. Value = a `MaterialRef` (`library:` / `scene:`). + // Absent = the body/glass default. Mirrors `ShelfNode.slots`. + slots: z.record(z.string(), z.string()).optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), diff --git a/packages/core/src/schema/nodes/slab.ts b/packages/core/src/schema/nodes/slab.ts index 5232eaaf8..fe3c47c1d 100644 --- a/packages/core/src/schema/nodes/slab.ts +++ b/packages/core/src/schema/nodes/slab.ts @@ -9,6 +9,10 @@ export const SlabNode = BaseNode.extend({ type: nodeType('slab'), material: MaterialSchema.optional(), materialPreset: z.string().optional(), + // Per-slot material overrides on the unified slot model, mirroring + // `ShelfNode.slots`. Key = slot id (`surface`), value = a `MaterialRef` + // (`library:` / `scene:`). Absent = the declared slot default. + slots: z.record(z.string(), z.string()).optional(), polygon: z.array(z.tuple([z.number(), z.number()])), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), holeMetadata: z.array(SurfaceHoleMetadata).default([]), diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index ebce121c2..c1d65e4b8 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -46,6 +46,16 @@ export type WallNode = z.infer export type WallSurfaceSide = 'interior' | 'exterior' +// Declared default appearance for an unpainted wall face in colored mode — +// visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the +// slot declaration (nodes) and the material resolver (viewer) share one value. +// May be a `#rrggbb` colour or a `library:` ref. Textures-off still +// collapses to the themed wall role (the escape hatch). +export const WALL_SLOT_DEFAULT: Record = { + interior: 'library:concrete-plate', + exterior: 'library:concrete-plate', +} + export type WallSurfaceMaterialSpec = { material?: z.infer materialPreset?: string diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts index c5a1f5f01..08c5ce4ef 100644 --- a/packages/core/src/schema/nodes/window.ts +++ b/packages/core/src/schema/nodes/window.ts @@ -21,6 +21,10 @@ export const WindowNode = BaseNode.extend({ id: objectId('window'), type: nodeType('window'), material: MaterialSchema.optional(), + // Per-slot material overrides on the unified slot model. Keys: `frame`, + // `glass`. Value = a `MaterialRef` (`library:` / `scene:`). Absent = + // the frame/glass default. Mirrors `ShelfNode.slots`. + slots: z.record(z.string(), z.string()).optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index c6903080a..1398dec38 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -7,7 +7,13 @@ import { spatialGridManager, useScene, } from '@pascal-app/core' -import { type HoverStyles, InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' +import { + type HoverStyles, + InteractiveSystem, + SceneEnvironment, + useViewer, + Viewer, +} from '@pascal-app/viewer' import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerZoneSystem } from '../../components/viewer-zone-system' @@ -602,6 +608,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ const noEditing = isVersionPreviewMode || isFirstPersonMode || isStudioMode return ( <> + {!(isFirstPersonMode || isStudioMode) && } {!noEditing && } {!noEditing && } diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index b1a3e50ac..e6cc48c11 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -907,6 +907,7 @@ export const SelectionManager = () => { localPosition: event.localPosition as readonly [number, number, number] | undefined, hitObjectName: event.nativeEvent.object?.name, hitObject: getEventObject(event), + ray: event.nativeEvent.ray, }) const compatible = role !== null && paintEnabled return { @@ -1059,13 +1060,7 @@ export const SelectionManager = () => { // before any of the legacy roof / stair / single-surface arms // below run. - if ( - node.type === 'fence' || - node.type === 'column' || - node.type === 'slab' || - node.type === 'ceiling' || - node.type === 'shelf' - ) { + if (node.type === 'fence' || node.type === 'column' || node.type === 'shelf') { const compatible = paintEnabled return { @@ -1094,7 +1089,7 @@ export const SelectionManager = () => { } } - const disabledNodeTypes = ['window', 'door', 'zone'] + const disabledNodeTypes = ['zone'] if (disabledNodeTypes.includes(node.type)) { return { key: `${node.type}:${node.id}:unsupported`, @@ -1191,6 +1186,11 @@ export const SelectionManager = () => { for (const type of subscribedKinds) { emitter.on(`${type}:enter` as any, onEnter as any) + // Re-evaluate on move so the hover preview tracks the cursor across a + // kind's sub-parts (door/window panel↔frame↔glass↔hardware, wall + // interior↔exterior) — not just on the initial enter. onEnter is + // idempotent (no-ops when the resolved part is unchanged). + emitter.on(`${type}:move` as any, onEnter as any) emitter.on(`${type}:leave` as any, onLeave as any) emitter.on(`${type}:click` as any, onClick as any) } @@ -1198,6 +1198,7 @@ export const SelectionManager = () => { return () => { for (const type of subscribedKinds) { emitter.off(`${type}:enter` as any, onEnter as any) + emitter.off(`${type}:move` as any, onEnter as any) emitter.off(`${type}:leave` as any, onLeave as any) emitter.off(`${type}:click` as any, onClick as any) } @@ -1558,6 +1559,7 @@ export const SelectionManager = () => { localPosition: event.localPosition as readonly [number, number, number] | undefined, hitObjectName: event.nativeEvent.object?.name, hitObject: getEventObject(event), + ray: event.nativeEvent.ray, }) if (role) { setSelectedMaterialTargetForNode(nodeToSelect, role as MaterialTargetRole) diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index bd672f153..7f86c05e1 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -382,8 +382,6 @@ export function resolveActivePaintMaterialFromSelection(params: { if ( (selectedNode.type === 'fence' || selectedNode.type === 'column' || - selectedNode.type === 'slab' || - selectedNode.type === 'ceiling' || selectedNode.type === 'shelf') && selectedMaterialTarget.role === 'surface' ) { diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index a59a70349..118c0abae 100644 --- a/packages/nodes/src/ceiling/definition.ts +++ b/packages/nodes/src/ceiling/definition.ts @@ -10,8 +10,10 @@ import { ceilingMoveVertexAffordance, } from './floorplan-affordances' import { ceilingFloorplanMoveTarget } from './floorplan-move' +import { ceilingPaint } from './paint' import { ceilingParametrics } from './parametrics' import { CeilingNode } from './schema' +import { ceilingSlots } from './slots' const HEIGHT_HANDLE_OFFSET = 0.22 const MIN_CEILING_HEIGHT = 0.5 @@ -102,6 +104,10 @@ export const ceilingDefinition: NodeDefinition = { }, duplicable: true, deletable: true, + // Unified slot model: one paintable underside surface with a declared + // default, painted through the registry `capabilities.paint` dispatch. + slots: () => ceilingSlots(), + paint: ceilingPaint, }, relations: { diff --git a/packages/nodes/src/ceiling/materials.ts b/packages/nodes/src/ceiling/materials.ts new file mode 100644 index 000000000..a6e4e590e --- /dev/null +++ b/packages/nodes/src/ceiling/materials.ts @@ -0,0 +1,80 @@ +import { + getMaterialPresetByRef, + parseMaterialRef, + resolveMaterial, + type SceneMaterial, + type SceneMaterialId, +} from '@pascal-app/core' +import { float, mix, positionWorld, smoothstep } from 'three/tsl' +import { BackSide, FrontSide, MeshBasicNodeMaterial } from 'three/webgpu' + +/** + * Ceiling material builders, shared by the renderer (mesh appearance) and the + * paint capability (hover preview). A ceiling is a flat tinted surface: the + * underside (`bottom`, seen from inside the room, `BackSide`) is opaque, while + * the `top` carries a transparent TSL grid overlay used while placing / + * selecting ceiling-hosted items. Both derive from a single colour, so slot + * painting resolves a colour and rebuilds these — it never applies a PBR map. + */ + +const gridScale = 5 +const gridX = positionWorld.x.mul(gridScale).fract() +const gridY = positionWorld.z.mul(gridScale).fract() +const lineWidth = 0.05 +const lineX = smoothstep(lineWidth, 0, gridX).add(smoothstep(1.0 - lineWidth, 1.0, gridX)) +const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.0, gridY)) +const gridPattern = lineX.max(lineY) +const gridOpacity = mix(float(0.2), float(0.6), gridPattern) + +export type CeilingMaterials = { + topMaterial: MeshBasicNodeMaterial + bottomMaterial: MeshBasicNodeMaterial +} + +function createCeilingMaterials(color = '#999999'): CeilingMaterials { + const topMaterial = new MeshBasicNodeMaterial({ + color, + transparent: true, + depthWrite: false, + side: FrontSide, + }) + topMaterial.opacityNode = gridOpacity + + const bottomMaterial = new MeshBasicNodeMaterial({ + color, + transparent: true, + side: BackSide, + }) + + return { topMaterial, bottomMaterial } +} + +const ceilingMaterialCache = new Map() + +export function getCeilingMaterials(color = '#999999'): CeilingMaterials { + const cached = ceilingMaterialCache.get(color) + if (cached) return cached + const materials = createCeilingMaterials(color) + ceilingMaterialCache.set(color, materials) + return materials +} + +/** + * Resolve a slot `MaterialRef` to a flat colour for the ceiling surface. + * `library:` refs use the catalog preset's base colour; `scene:` refs use the + * stored material's colour. Returns null for a dangling / unparseable ref so + * the caller falls back to its default. + */ +export function ceilingColorFromRef( + ref: string | undefined, + sceneMaterials: Record | undefined, +): string | null { + const parsed = parseMaterialRef(ref) + if (!parsed) return null + if (parsed.kind === 'library') { + return getMaterialPresetByRef(ref)?.mapProperties.color ?? null + } + const sceneMaterial = sceneMaterials?.[parsed.id as SceneMaterialId] + if (!sceneMaterial) return null + return resolveMaterial(sceneMaterial.material).color ?? null +} diff --git a/packages/nodes/src/ceiling/paint.ts b/packages/nodes/src/ceiling/paint.ts new file mode 100644 index 000000000..3efce6994 --- /dev/null +++ b/packages/nodes/src/ceiling/paint.ts @@ -0,0 +1,42 @@ +import { + type AnyNode, + type CeilingNode, + getMaterialPresetByRef, + resolveMaterial, +} from '@pascal-app/core' +import type { Mesh } from 'three' +import { createSlotPaintCapability } from '../shared/slot-paint' +import { getCeilingMaterials } from './materials' + +/** + * Ceiling paint on the unified slot model. A ceiling has one paintable surface, + * so every hit resolves to `surface`; commit writes `node.slots.surface`. The + * preview swaps the registered underside mesh to the ceiling's own flat-tinted + * material (built `BackSide`, the way it renders), so the hover preview matches + * the committed result — a generic PBR preview would be invisible from below. + */ +export const ceilingPaint = createSlotPaintCapability({ + resolveRole: () => 'surface', + applyPreview: ({ material, materialPreset, root }) => { + const color = materialPreset + ? (getMaterialPresetByRef(materialPreset)?.mapProperties.color ?? null) + : material + ? (resolveMaterial(material).color ?? null) + : null + if (!color) return () => {} + const mesh = root as Mesh + if (!mesh.isMesh) return null + const previous = mesh.material + mesh.material = getCeilingMaterials(color).bottomMaterial + return () => { + mesh.material = previous + } + }, + legacyEffective: (node: AnyNode) => { + const ceiling = node as CeilingNode + if (ceiling.materialPreset || ceiling.material) { + return { material: ceiling.material, materialPreset: ceiling.materialPreset } + } + return null + }, +}) diff --git a/packages/nodes/src/ceiling/renderer.tsx b/packages/nodes/src/ceiling/renderer.tsx index 263c4fd81..9f49086c8 100644 --- a/packages/nodes/src/ceiling/renderer.tsx +++ b/packages/nodes/src/ceiling/renderer.tsx @@ -15,53 +15,15 @@ import { useViewer, } from '@pascal-app/viewer' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' -import { float, mix, positionWorld, smoothstep } from 'three/tsl' -import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' +import { BackSide, type Mesh } from 'three/webgpu' import { createPlaceholderGeometry } from '../shared/placeholder-geometry' +import { ceilingColorFromRef, getCeilingMaterials } from './materials' +import { CEILING_SLOT_DEFAULT_COLOR } from './slots' function createEmptyGeometry() { return createPlaceholderGeometry() } -const gridScale = 5 -const gridX = positionWorld.x.mul(gridScale).fract() -const gridY = positionWorld.z.mul(gridScale).fract() -const lineWidth = 0.05 -const lineX = smoothstep(lineWidth, 0, gridX).add(smoothstep(1.0 - lineWidth, 1.0, gridX)) -const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.0, gridY)) -const gridPattern = lineX.max(lineY) -const gridOpacity = mix(float(0.2), float(0.6), gridPattern) - -function createCeilingMaterials(color = '#999999') { - const topMaterial = new MeshBasicNodeMaterial({ - color, - transparent: true, - depthWrite: false, - side: FrontSide, - }) - topMaterial.opacityNode = gridOpacity - - const bottomMaterial = new MeshBasicNodeMaterial({ - color, - transparent: true, - side: BackSide, - }) - - return { topMaterial, bottomMaterial } -} - -const ceilingMaterialCache = new Map>() - -function getCeilingMaterials(color = '#999999') { - const cacheKey = color - const cached = ceilingMaterialCache.get(cacheKey) - if (cached) return cached - - const materials = createCeilingMaterials(color) - ceilingMaterialCache.set(cacheKey, materials) - return materials -} - export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const ref = useRef(null!) const placeholderGeometry = useMemo(createEmptyGeometry, []) @@ -80,6 +42,9 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const textures = useViewer((s) => s.textures) const colorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + // Subscribe to the scene-material library so editing a `scene:` material the + // ceiling slot references re-tints it live. + const sceneMaterials = useScene((s) => s.materials) const liveTransform = useLiveTransforms((s) => s.get(node.id)) const ceilingY = (node.height ?? 2.5) - 0.01 + (liveTransform?.position[1] ?? 0) const position: [number, number, number] = [ @@ -97,18 +62,15 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { ) const materials = useMemo(() => { - // Untextured ceilings (and everything in textures-off mode) take the themed - // 'ceiling' role colour; only an explicit preset/material keeps a texture. - const hasExplicit = Boolean(node.materialPreset || node.material) - if (!textures || !hasExplicit) { - // Bottom (seen from inside the room, looking up) stays opaque so the - // ceiling reads as a solid surface. Top uses the transparent - // grid-pattern material so the ceiling stays see-through whenever - // the editor reveals the `ceiling-grid` overlay (placing a - // ceiling-hosted item, or selecting one of its children — e.g. - // after committing a placement). Without this the top mesh shipped - // an opaque surface-role material, so a top-down camera lost view - // of everything under the ceiling once the overlay turned on. + // Textures-off mode takes the themed 'ceiling' role colour — the guaranteed + // escape hatch, independent of any slot override. The bottom (seen from + // inside the room, looking up) stays opaque so the ceiling reads as a solid + // surface; the top keeps the transparent grid material so a top-down camera + // can see through the ceiling whenever the `ceiling-grid` overlay is + // revealed (placing a ceiling-hosted item, or selecting one of its + // children). Without that the top mesh would ship an opaque surface-role + // material and a top-down camera would lose everything under the ceiling. + if (!textures) { const ceilingColor = resolveSurfaceColor('ceiling', colorPreset, sceneTheme) return { topMaterial: getCeilingMaterials(ceilingColor).topMaterial, @@ -116,14 +78,26 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { } } - const preset = getMaterialPresetByRef(node.materialPreset) - const props = preset?.mapProperties ?? resolveMaterial(node.material) - const color = props.color || '#999999' - return getCeilingMaterials(color) + // Unified slot override — shared scene material or catalog `library:` finish + // (resolved to its base colour; a ceiling renders flat-tinted, not mapped). + const slotColor = ceilingColorFromRef(node.slots?.surface, sceneMaterials) + if (slotColor) return getCeilingMaterials(slotColor) + + // Legacy inline material / preset (scenes painted before the slot model). + if (node.materialPreset || node.material) { + const preset = getMaterialPresetByRef(node.materialPreset) + const props = preset?.mapProperties ?? resolveMaterial(node.material) + return getCeilingMaterials(props.color || '#999999') + } + + // Declared slot default. + return getCeilingMaterials(CEILING_SLOT_DEFAULT_COLOR) }, [ textures, colorPreset, sceneTheme, + sceneMaterials, + node.slots, node.materialPreset, node.material, node.material?.preset, diff --git a/packages/nodes/src/ceiling/slots.ts b/packages/nodes/src/ceiling/slots.ts new file mode 100644 index 000000000..7b7c48a99 --- /dev/null +++ b/packages/nodes/src/ceiling/slots.ts @@ -0,0 +1,12 @@ +import type { SlotDeclaration } from '@pascal-app/core' + +export type CeilingSlotId = 'surface' + +// Soft white — the default underside colour for an unpainted ceiling. (A +// ceiling renders flat-tinted, so this is a colour, not a `library:` finish.) +export const CEILING_SLOT_DEFAULT_COLOR = '#f2eee6' + +/** A ceiling exposes a single paintable underside surface. */ +export function ceilingSlots(): SlotDeclaration[] { + return [{ slotId: 'surface', label: 'Surface', default: CEILING_SLOT_DEFAULT_COLOR }] +} diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index f006e2634..4846413c5 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -12,8 +12,10 @@ import { scaleHandleHeight } from './door-math' import { buildDoorFloorplan } from './floorplan' import { doorWidthAffordance } from './floorplan-affordances' import { doorFloorplanMoveTarget } from './floorplan-move' +import { doorPaint } from './paint' import { doorParametrics } from './parametrics' import { DoorNode } from './schema' +import { doorSlots } from './slots' const SIDE_HANDLE_OFFSET = 0.24 const HEIGHT_HANDLE_OFFSET = 0.24 @@ -174,6 +176,10 @@ export const doorDefinition: NodeDefinition = { // placed. Host apps strip these at preset-save time via // `getHostRefFields(def)`. hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'], + // Panel / glass slots painted through the registry. The door system tags + // each mesh with its `userData.slotId`; paint writes `node.slots`. + slots: () => doorSlots(), + paint: doorPaint, }, parametrics: doorParametrics, diff --git a/packages/nodes/src/door/paint.ts b/packages/nodes/src/door/paint.ts new file mode 100644 index 000000000..e236a5e38 --- /dev/null +++ b/packages/nodes/src/door/paint.ts @@ -0,0 +1,16 @@ +import { + createSlotPaintCapability, + previewSlotByUserData, + resolveSlotByReRaycast, +} from '../shared/slot-paint' + +/** + * Door paint on the unified slot model. The door's opening proxy (a proud, + * invisible cutout) wins the shared scene raycast over the wall in front of the + * recessed door body, so `resolveSlotByReRaycast` re-raycasts the door's own + * subtree to find the part (panel / frame / glass / hardware) under the cursor. + */ +export const doorPaint = createSlotPaintCapability({ + resolveRole: resolveSlotByReRaycast, + applyPreview: previewSlotByUserData, +}) diff --git a/packages/nodes/src/door/slots.ts b/packages/nodes/src/door/slots.ts new file mode 100644 index 000000000..2e2218793 --- /dev/null +++ b/packages/nodes/src/door/slots.ts @@ -0,0 +1,25 @@ +import type { SlotDeclaration } from '@pascal-app/core' + +export type DoorSlotId = 'panel' | 'frame' | 'glass' | 'hardware' + +// Picker swatches. Rendering falls back to the live body/glass/hardware defaults +// (which already track shading + theme), so these are just the indicator colours. +const PANEL_DEFAULT = 'library:preset-softwhite' +const FRAME_DEFAULT = 'library:preset-softwhite' +const GLASS_DEFAULT = 'library:preset-glass' +// Chrome — a flat (non-PBR) catalog metal finish. +const HARDWARE_DEFAULT = 'library:metal-chrome' + +/** + * A door exposes four paintable slots: `panel` (leaf faces), `frame`, `glass`, + * and `hardware` (handle / hinges / closer / panic bar). The opening reveal + * keeps its own material. + */ +export function doorSlots(): SlotDeclaration[] { + return [ + { slotId: 'panel', label: 'Panel', default: PANEL_DEFAULT }, + { slotId: 'frame', label: 'Frame', default: FRAME_DEFAULT }, + { slotId: 'glass', label: 'Glass', default: GLASS_DEFAULT }, + { slotId: 'hardware', label: 'Hardware', default: HARDWARE_DEFAULT }, + ] +} diff --git a/packages/nodes/src/shared/slot-paint.ts b/packages/nodes/src/shared/slot-paint.ts new file mode 100644 index 000000000..c2d24066f --- /dev/null +++ b/packages/nodes/src/shared/slot-paint.ts @@ -0,0 +1,264 @@ +import { + type AnyNode, + type AnyNodeId, + generateSceneMaterialId, + type MaterialSchema, + type PaintCapability, + type PaintPreviewArgs, + type PaintResolveArgs, + parseMaterialRef, + type SceneMaterial, + type SceneMaterialId, + sceneRegistry, + toSceneMaterialRef, + useScene, +} from '@pascal-app/core' +import { createMaterial, createMaterialFromPresetRef, useViewer } from '@pascal-app/viewer' +import { type Material, type Mesh, type Object3D, Raycaster } from 'three' + +/** + * Shared paint capability for procedural kinds on the unified slot model + * (`node.slots: Record` + the shared scene-material + * palette) — the same data shape items derive from their GLB and the shelf + * declares via `capabilities.slots`. Distinct from `surface-paint.ts`, which + * writes the legacy inline `node.material` copy the plan is retiring. + * + * The commit / resolve / effective-material logic is identical across kinds; + * only the slot-resolution from a pointer hit and the mesh preview differ, so + * those are injected per kind. + */ + +type SlotsNode = AnyNode & { slots?: Record } + +function deepEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true + if (typeof a !== typeof b) return false + if (a === null || b === null) return false + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false + for (let index = 0; index < a.length; index += 1) { + if (!deepEqual(a[index], b[index])) return false + } + return true + } + if (typeof a === 'object') { + const aRecord = a as Record + const bRecord = b as Record + const aKeys = Object.keys(aRecord) + const bKeys = Object.keys(bRecord) + if (aKeys.length !== bKeys.length) return false + for (const key of aKeys) { + if (!Object.hasOwn(bRecord, key)) return false + if (!deepEqual(aRecord[key], bRecord[key])) return false + } + return true + } + return false +} + +function findMatchingSceneMaterial( + materials: Record, + material: MaterialSchema, +): SceneMaterial | null { + for (const sceneMaterial of Object.values(materials)) { + if (deepEqual(sceneMaterial.material, material)) return sceneMaterial + } + return null +} + +function commitSlotPaint( + node: SlotsNode, + role: string, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): void { + const nodeId = node.id as AnyNodeId + const state = useScene.getState() + const currentNode = (state.nodes[nodeId] as SlotsNode | undefined) ?? node + + let ref: string | undefined + let newSceneMaterial: SceneMaterial | null = null + + if (material === undefined && materialPreset === undefined) { + ref = undefined + } else if (materialPreset) { + ref = materialPreset + } else if (material) { + const existing = findMatchingSceneMaterial(state.materials, material) + if (existing) { + ref = toSceneMaterialRef(existing.id) + } else { + const id = generateSceneMaterialId() + newSceneMaterial = { + id, + name: `Material ${Object.keys(state.materials).length + 1}`, + material, + } + ref = toSceneMaterialRef(id) + } + } else { + return + } + + const nextSlots = { ...(currentNode.slots ?? {}) } + if (ref) nextSlots[role] = ref + else delete nextSlots[role] + + if (newSceneMaterial) { + // Creating the scene material and setting the slot ref are one logical + // edit, so apply both in a single `set` — zundo records one history entry, + // and one undo removes both the ref and its (now orphaned) material. + const sceneMaterial = newSceneMaterial + useScene.setState((s) => { + if (s.readOnly) return s + const node2 = s.nodes[nodeId] as SlotsNode | undefined + if (!node2) return s + return { + materials: { ...s.materials, [sceneMaterial.id as SceneMaterialId]: sceneMaterial }, + nodes: { + ...s.nodes, + [nodeId]: { ...node2, slots: nextSlots } as AnyNode, + }, + } + }) + useScene.getState().markDirty(nodeId) + return + } + + state.updateNode(nodeId, { slots: nextSlots } as Partial) +} + +/** Preview material for a slot paint — mirrors the commit's resolution. */ +export function buildSlotPreviewMaterial( + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Material | null { + const shading = useViewer.getState().shading + if (materialPreset) return createMaterialFromPresetRef(materialPreset, shading) + if (material) return createMaterial(material, shading) + return null +} + +/** + * Preview for kinds whose meshes are produced by `def.geometry` and tagged + * with `userData.slotId` (+ `__fromGeometry`). Swaps every builder mesh whose + * slot matches `role`, leaving hosted-child meshes (which can carry a colliding + * `userData.slotId` from their own GLB) untouched. + */ +export function previewGeometrySlot(args: PaintPreviewArgs): (() => void) | null { + const { role, root, material, materialPreset } = args + const preview = buildSlotPreviewMaterial(material, materialPreset) + if (!preview) return () => {} + + const restores: Array<() => void> = [] + ;(root as Object3D).traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + const userData = mesh.userData as { slotId?: string | null; __fromGeometry?: boolean } + if (userData.__fromGeometry !== true) return + if (userData.slotId !== role) return + const previous = mesh.material + mesh.material = preview + restores.push(() => { + mesh.material = previous + }) + }) + + if (restores.length === 0) return null + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.() + } +} + +/** + * Preview for kinds whose meshes are built by a viewer system (window, door) + * and tagged with `userData.slotId` — no `__fromGeometry` marker and no hosted + * children to guard against, so it swaps every mesh whose slot matches `role`. + */ +export function previewSlotByUserData(args: PaintPreviewArgs): (() => void) | null { + const { role, root, material, materialPreset } = args + const preview = buildSlotPreviewMaterial(material, materialPreset) + if (!preview) return () => {} + + const restores: Array<() => void> = [] + ;(root as Object3D).traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + if ((mesh.userData as { slotId?: string | null }).slotId !== role) return + const previous = mesh.material + mesh.material = preview + restores.push(() => { + mesh.material = previous + }) + }) + + if (restores.length === 0) return null + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.() + } +} + +// Reused across calls — set from the pointer ray each time. +const subtreeRaycaster = new Raycaster() + +/** + * Resolve the slot for a kind whose paint hit lands on a proud opening proxy + * (door/window: a 1m-deep invisible cutout that wins the scene raycast over the + * wall in front of the recessed body) rather than the part itself. Re-raycasts + * the kind's OWN registered subtree (ignoring everything else) and returns the + * first tagged sub-mesh under the cursor; falls back to the direct hit's slot + * (e.g. a proud part the scene raycast hit directly). + */ +export function resolveSlotByReRaycast(args: PaintResolveArgs): string | null { + const direct = (args.hitObject?.userData as { slotId?: string } | undefined)?.slotId + if (typeof direct === 'string') return direct + const root = sceneRegistry.nodes.get(args.node.id as AnyNodeId) + if (!root || !args.ray) return null + subtreeRaycaster.ray.copy(args.ray) + for (const hit of subtreeRaycaster.intersectObject(root, true)) { + const slot = (hit.object.userData as { slotId?: string }).slotId + if (typeof slot === 'string') return slot + } + return null +} + +export type SlotPaintConfig = { + /** Resolve the slot id for a pointer hit (`null` = not paintable here). */ + resolveRole: (args: PaintResolveArgs) => string | null + /** Apply a preview to the registered mesh subtree for `role`. */ + applyPreview: (args: PaintPreviewArgs) => (() => void) | null + /** + * Optional legacy fallback for the picker's current-value indicator — read + * when no `node.slots[role]` ref exists yet (e.g. a scene painted before the + * kind moved onto the slot model still carries inline `material`/`preset`). + */ + legacyEffective?: ( + node: AnyNode, + role: string, + ) => { material: MaterialSchema | undefined; materialPreset: string | undefined } | null +} + +export function createSlotPaintCapability(config: SlotPaintConfig): PaintCapability { + return { + resolveRole: config.resolveRole, + buildPatch: ({ node, role, materialPreset }) => { + const slots = { ...((node as SlotsNode).slots ?? {}) } + if (materialPreset) slots[role] = materialPreset + else delete slots[role] + return { slots } as Partial + }, + commit: ({ node, role, material, materialPreset }) => + commitSlotPaint(node as SlotsNode, role, material, materialPreset), + applyPreview: config.applyPreview, + getEffectiveMaterial: ({ node, role }) => { + const ref = (node as SlotsNode).slots?.[role] + const parsed = parseMaterialRef(ref) + if (parsed) { + if (parsed.kind === 'library') return { material: undefined, materialPreset: ref } + const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId] + if (sceneMaterial) return { material: sceneMaterial.material, materialPreset: undefined } + } + return config.legacyEffective?.(node, role) ?? null + }, + } +} diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index 4b63d0700..fc0ab43c2 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -12,8 +12,10 @@ import { } from './floorplan-affordances' import { slabFloorplanMoveTarget } from './floorplan-move' import { buildSlabGeometry } from './geometry' +import { slabPaint } from './paint' import { slabParametrics } from './parametrics' import { SlabNode } from './schema' +import { slabSlots } from './slots' const HEIGHT_HANDLE_OFFSET = 0.22 const MIN_SLAB_ELEVATION = 0.02 @@ -155,6 +157,10 @@ export const slabDefinition: NodeDefinition = { }, duplicable: true, deletable: true, + // Unified slot model: one paintable floor surface with a declared default, + // painted through the registry `capabilities.paint` dispatch like the shelf. + slots: () => slabSlots(), + paint: slabPaint, }, relations: { diff --git a/packages/nodes/src/slab/geometry.ts b/packages/nodes/src/slab/geometry.ts index dd1a68080..851660ec6 100644 --- a/packages/nodes/src/slab/geometry.ts +++ b/packages/nodes/src/slab/geometry.ts @@ -1,25 +1,28 @@ -import { getMaterialPresetByRef, type SlabNode } from '@pascal-app/core' +import { type GeometryContext, getMaterialPresetByRef, type SlabNode } from '@pascal-app/core' import { applyMaterialPresetToMaterials, type ColorPreset, createDefaultMaterial, createMaterial, createSurfaceRoleMaterial, - DEFAULT_SLAB_MATERIAL, generateSlabGeometry, type RenderShading, + resolveMaterialRef, + resolveSlotDefaultMaterial, } from '@pascal-app/viewer' import { FrontSide, Group, type Material, Mesh, type Texture } from 'three' +import { SLAB_SLOT_DEFAULT } from './slots' /** * Stage B builder for slab. Reuses `generateSlabGeometry` (pure * triangulation + hole CSG from viewer) and the same material cache * pattern the legacy slab renderer used. * - * Materials are cached by `{material, materialPreset}` signature so - * slabs sharing settings share the GPU resource. Cached entry mutation - * (preset apply) is preserved — async texture loads still update the - * rendered material after re-mount. + * Materials follow the unified slot model: the single `surface` slot resolves + * `node.slots.surface` (a shared scene material or `library:` finish) → the + * legacy inline `node.material` / `materialPreset` (pre-slot-model scenes) → + * the declared slot default colour. Textures-off collapses to the themed + * `floor` role — the guaranteed monochrome escape hatch. */ type SlabMaterial = Material & { alphaMap?: Texture | null @@ -35,19 +38,39 @@ function getSlabMaterial( shading: RenderShading, textures: boolean, colorPreset: ColorPreset, - sceneTheme?: string, + sceneTheme: string | undefined, + sceneMaterials: GeometryContext['materials'], ): Material { - // Untextured slabs (and everything in textures-off mode) take the themed - // 'floor' role colour. createSurfaceRoleMaterial returns a shared cached - // material, so it is returned as-is without the mutation below. - // FrontSide — DoubleSide on the role material's NodeMaterial poisons the - // MRT scene pass (see `materials.ts` line 77 / glazing fix 9400f1c5). - // Slab side faces still render correctly because `generateSlabGeometry` - // produces outward-facing normals on the top, bottom, and perimeter. - if (!textures || (!node.materialPreset && !node.material)) { + // Textures-off mode takes the themed 'floor' role colour — the guaranteed + // escape hatch, independent of any slot override. createSurfaceRoleMaterial + // returns a shared cached material. FrontSide — DoubleSide on the role + // material's NodeMaterial poisons the MRT scene pass (see `materials.ts` + // line 77 / glazing fix 9400f1c5). Slab side faces still render correctly + // because `generateSlabGeometry` produces outward-facing normals. + if (!textures) { return createSurfaceRoleMaterial('floor', colorPreset, FrontSide, sceneTheme) } + // Unified slot override — shared scene material or catalog `library:` finish. + const slotRef = node.slots?.surface + if (slotRef) { + const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading) + if (resolved) return resolved + } + + // Legacy inline material / preset, for scenes painted before the slot model. + if (node.materialPreset || node.material) { + return getLegacySlabMaterial(node, shading) + } + + // Declared slot default — a catalog `library:` finish or a flat colour. + return resolveSlotDefaultMaterial(SLAB_SLOT_DEFAULT, shading, 0.8) +} + +function getLegacySlabMaterial(node: SlabNode, shading: RenderShading): Material { + // Cached by `{material, materialPreset}` signature so slabs sharing settings + // share the GPU resource; cached entry mutation (preset apply) is preserved + // so async texture loads still update the rendered material after re-mount. const cacheKey = JSON.stringify({ shading, material: node.material ?? null, @@ -61,7 +84,7 @@ function getSlabMaterial( ? createDefaultMaterial('#ffffff', 0.5, shading) : node.material ? createMaterial(node.material, shading).clone() - : DEFAULT_SLAB_MATERIAL(shading).clone() + : createDefaultMaterial('#e5e5e5', 0.8, shading) if (preset) { applyMaterialPresetToMaterials(material, preset) @@ -84,7 +107,7 @@ function getSlabMaterial( export function buildSlabGeometry( node: SlabNode, - _ctx?: unknown, + ctx?: GeometryContext, shading: RenderShading = 'rendered', textures = true, colorPreset: ColorPreset = 'clay', @@ -92,10 +115,12 @@ export function buildSlabGeometry( ): Group { const group = new Group() const geometry = generateSlabGeometry(node) - const material = getSlabMaterial(node, shading, textures, colorPreset, sceneTheme) + const material = getSlabMaterial(node, shading, textures, colorPreset, sceneTheme, ctx?.materials) const mesh = new Mesh(geometry, material) mesh.castShadow = true mesh.receiveShadow = true + // Tag the surface so the unified slot paint can resolve the hit and preview. + mesh.userData.slotId = 'surface' const elevation = node.elevation ?? 0.05 if (elevation < 0) mesh.position.y = elevation group.add(mesh) diff --git a/packages/nodes/src/slab/paint.ts b/packages/nodes/src/slab/paint.ts new file mode 100644 index 000000000..a5387849a --- /dev/null +++ b/packages/nodes/src/slab/paint.ts @@ -0,0 +1,19 @@ +import type { AnyNode, SlabNode } from '@pascal-app/core' +import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint' + +/** + * Slab paint on the unified slot model. A slab has one paintable surface, so + * every face resolves to the `surface` slot; commit writes `node.slots.surface` + * (a shared scene-material or `library:` ref) like the shelf. + */ +export const slabPaint = createSlotPaintCapability({ + resolveRole: () => 'surface', + applyPreview: previewGeometrySlot, + legacyEffective: (node: AnyNode) => { + const slab = node as SlabNode + if (slab.materialPreset || slab.material) { + return { material: slab.material, materialPreset: slab.materialPreset } + } + return null + }, +}) diff --git a/packages/nodes/src/slab/slots.ts b/packages/nodes/src/slab/slots.ts new file mode 100644 index 000000000..094bdb0cf --- /dev/null +++ b/packages/nodes/src/slab/slots.ts @@ -0,0 +1,13 @@ +import type { SlotDeclaration } from '@pascal-app/core' + +export type SlabSlotId = 'surface' + +// Declared default appearance for an unpainted slab surface in colored mode — +// a catalog `library:` finish or a `#rrggbb` colour. Textures-off collapses +// to the themed floor role (the escape hatch). +export const SLAB_SLOT_DEFAULT = 'library:wood-woodplank48' + +/** A slab exposes a single paintable floor surface. */ +export function slabSlots(): SlotDeclaration[] { + return [{ slotId: 'surface', label: 'Surface', default: SLAB_SLOT_DEFAULT }] +} diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 0e0face2c..d200c4367 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -6,6 +6,7 @@ import { wallFloorplanSiblingOverrides } from './floorplan-overrides' import { wallPaint } from './paint' import { wallParametrics } from './parametrics' import { WallNode } from './schema' +import { wallSlots } from './slots' /** * Wall — the Phase 3 stress test of the registry-driven node model. @@ -56,6 +57,11 @@ export const wallDefinition: NodeDefinition = { // preview through this entry rather than carrying a kind-name // arm. paint: wallPaint, + // Declared paintable slots (interior / exterior) with their default + // appearance — the same `{ slotId, label, default }` contract every other + // paintable kind exposes. Paint still writes the legacy inline fields via + // `wallPaint`; migrating those into `node.slots` is a later step. + slots: () => wallSlots(), }, relations: { diff --git a/packages/nodes/src/wall/slots.ts b/packages/nodes/src/wall/slots.ts new file mode 100644 index 000000000..d6bded909 --- /dev/null +++ b/packages/nodes/src/wall/slots.ts @@ -0,0 +1,17 @@ +import { type SlotDeclaration, WALL_SLOT_DEFAULT } from '@pascal-app/core' + +/** + * A wall exposes two paintable faces — interior + exterior. Painting still + * writes the legacy `interiorMaterial*` / `exteriorMaterial*` fields via + * `wallPaint` (the inline model isn't migrated to `node.slots` yet); this + * declaration surfaces the slot list + declared defaults for the picker and + * keeps walls on the same `{ slotId, label, default }` contract as every other + * paintable kind. The defaults come from core so the viewer's material + * resolver renders the identical value. + */ +export function wallSlots(): SlotDeclaration[] { + return [ + { slotId: 'interior', label: 'Interior', default: WALL_SLOT_DEFAULT.interior }, + { slotId: 'exterior', label: 'Exterior', default: WALL_SLOT_DEFAULT.exterior }, + ] +} diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index 96f8b11c2..bdd659632 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -11,8 +11,10 @@ import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' import { buildWindowFloorplan } from './floorplan' import { windowWidthAffordance } from './floorplan-affordances' import { windowFloorplanMoveTarget } from './floorplan-move' +import { windowPaint } from './paint' import { windowParametrics } from './parametrics' import { WindowNode } from './schema' +import { windowSlots } from './slots' const SIDE_HANDLE_OFFSET = 0.24 const HEIGHT_HANDLE_OFFSET = 0.24 @@ -162,6 +164,10 @@ export const windowDefinition: NodeDefinition = { // `wallId` / `roofSegmentId` are re-derived from the surface under // the cursor at preset placement time — see door for the pattern. hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'], + // Frame / glass slots painted through the registry. The window system tags + // each mesh with its `userData.slotId`; paint writes `node.slots`. + slots: () => windowSlots(), + paint: windowPaint, }, parametrics: windowParametrics, diff --git a/packages/nodes/src/window/paint.ts b/packages/nodes/src/window/paint.ts new file mode 100644 index 000000000..ca5120d28 --- /dev/null +++ b/packages/nodes/src/window/paint.ts @@ -0,0 +1,16 @@ +import { + createSlotPaintCapability, + previewSlotByUserData, + resolveSlotByReRaycast, +} from '../shared/slot-paint' + +/** + * Window paint on the unified slot model. The window's opening proxy (a proud, + * invisible cutout) wins the shared scene raycast over the wall in front of the + * recessed window, so `resolveSlotByReRaycast` re-raycasts the window's own + * subtree to find the part (frame / glass) under the cursor. + */ +export const windowPaint = createSlotPaintCapability({ + resolveRole: resolveSlotByReRaycast, + applyPreview: previewSlotByUserData, +}) diff --git a/packages/nodes/src/window/slots.ts b/packages/nodes/src/window/slots.ts new file mode 100644 index 000000000..4067d4d49 --- /dev/null +++ b/packages/nodes/src/window/slots.ts @@ -0,0 +1,16 @@ +import type { SlotDeclaration } from '@pascal-app/core' + +export type WindowSlotId = 'frame' | 'glass' + +// Picker swatches. Rendering falls back to the live frame/glass defaults (which +// already track shading + theme), so these are just the indicator colours. +const FRAME_DEFAULT = 'library:preset-softwhite' +const GLASS_DEFAULT = 'library:preset-glass' + +/** A window exposes two paintable slots: the joinery frame and the glass. */ +export function windowSlots(): SlotDeclaration[] { + return [ + { slotId: 'frame', label: 'Frame', default: FRAME_DEFAULT }, + { slotId: 'glass', label: 'Glass', default: GLASS_DEFAULT }, + ] +} diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 67645cf5b..301ccec4e 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -280,7 +280,6 @@ const PostProcessingPasses = ({ denoise: denoiseEnabled, outline: outlineEnabled, perfDisable, - hoverHighlightMode, projectId, shading, rendererCtor: (renderer as any).constructor?.name, @@ -487,9 +486,12 @@ const PostProcessingPasses = ({ renderPipelineRef.current = null } }, [ + // NOTE: hoverHighlightMode intentionally excluded — the hover style is + // pushed to uniforms in a separate effect, so a hover must NOT rebuild the + // whole pipeline. The uniform refs below are stable (useMemo), so they + // never trigger a rebuild either. camera, hoverHiddenColor, - hoverHighlightMode, hoverPulseMix, hoverStrength, hoverVisibleColor, diff --git a/packages/viewer/src/components/viewer/scene-environment.tsx b/packages/viewer/src/components/viewer/scene-environment.tsx new file mode 100644 index 000000000..1834c8f3e --- /dev/null +++ b/packages/viewer/src/components/viewer/scene-environment.tsx @@ -0,0 +1,22 @@ +'use client' + +import { Environment } from '@react-three/drei' +import { Suspense } from 'react' + +/** + * Scene IBL — drei's prefiltered environment map, exported as an opt-in + * *child* rather than baked into the Viewer component, so embed / + * thumbnail surfaces that don't want the HDRI fetch simply don't mount it. + * This is what gives PBR metals their reflections and lifts the lighting on + * vertical surfaces (walls), which flat directional + hemisphere lights can't + * do alone. Intensity is dialled below the preset default so it complements + * the scene lights rather than washing them out. Only visible in `rendered` + * shading. + */ +export function SceneEnvironment() { + return ( + + + + ) +} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index f8e55fa33..6782d31cf 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -18,6 +18,7 @@ export { DEFAULT_HOVER_STYLES, SSGI_PARAMS, } from './components/viewer/post-processing' +export { SceneEnvironment } from './components/viewer/scene-environment' export { WalkthroughControls } from './components/viewer/walkthrough-controls' export { useAssetUrl } from './hooks/use-asset-url' export { useGLTFKTX2 } from './hooks/use-gltf-ktx2' @@ -69,6 +70,7 @@ export { PRESET_PALETTES, type RenderShading, resolveMaterialRef, + resolveSlotDefaultMaterial, resolveSurfaceColor, WHITE_PALETTE, } from './lib/materials' diff --git a/packages/viewer/src/lib/box-uv.ts b/packages/viewer/src/lib/box-uv.ts new file mode 100644 index 000000000..fe73d2721 --- /dev/null +++ b/packages/viewer/src/lib/box-uv.ts @@ -0,0 +1,40 @@ +import type { BoxGeometry } from 'three' + +/** + * Rewrite a default `BoxGeometry`'s UVs to world scale — 1 UV unit = 1 metre — + * so tiled finishes (with `repeat` in tiles-per-metre) render at a consistent + * real-world scale instead of stretching to fit each face. Matches the + * world-scale UV convention used by the procedural slab/wall geometry. + * + * three.js builds box faces in the fixed order [+X, -X, +Y, -Y, +Z, -Z], four + * verts each, with UVs spanning 0→1 across the face. Each face's two in-plane + * dimensions differ, so we scale U/V per face by that face's size in metres. + */ +export function applyWorldScaleBoxUVs( + geometry: BoxGeometry, + w: number, + h: number, + d: number, +): void { + const uv = geometry.getAttribute('uv') + if (!uv || uv.count < 24) return // non-default segmentation — leave as-is + + // [uScaleMetres, vScaleMetres] per face, in three's face order. + const faceScale: Array<[number, number]> = [ + [d, h], // +X + [d, h], // -X + [w, d], // +Y + [w, d], // -Y + [w, h], // +Z + [w, h], // -Z + ] + + for (let face = 0; face < 6; face += 1) { + const [us, vs] = faceScale[face]! + for (let v = 0; v < 4; v += 1) { + const i = face * 4 + v + uv.setXY(i, uv.getX(i) * us, uv.getY(i) * vs) + } + } + uv.needsUpdate = true +} diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index fbfdc1acd..677b1d13f 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -516,6 +516,26 @@ export function resolveMaterialRef( return createMaterial(sceneMaterial.material, shading) } +/** + * Resolve a node kind's declared slot default — either a catalog `library:` + * finish or a flat `#rrggbb` colour — to a renderable material. Shared by the + * procedural kinds whose colored-mode unpainted appearance comes from a + * declarative default (slab, wall). + */ +export function resolveSlotDefaultMaterial( + slotDefault: string, + shading: RenderShading = 'rendered', + roughness = 0.9, +): THREE.Material { + if (parseMaterialRef(slotDefault)?.kind === 'library') { + return ( + createMaterialFromPresetRef(slotDefault, shading) ?? + createDefaultMaterial('#ffffff', roughness, shading) + ) + } + return createDefaultMaterial(slotDefault, roughness, shading) +} + export function createDefaultMaterial( color = '#ffffff', roughness = 0.9, diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index 2a93b9265..3342f4fd7 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -5,6 +5,8 @@ import { DoorNode as DoorNodeSchema, getDoorRenderOpenAmount, getEffectiveNode, + type SceneMaterial, + type SceneMaterialId, sceneRegistry, useInteractive, useLiveNodeOverrides, @@ -13,19 +15,44 @@ import { import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' import * as THREE from 'three' +import { applyWorldScaleBoxUVs } from '../../lib/box-uv' import { + type ColorPreset, + createDefaultMaterial, createSurfaceRoleMaterial, glassMaterial as defaultGlassMaterial, baseMaterial as getBaseMaterial, + type RenderShading, + resolveMaterialRef, } from '../../lib/materials' import useViewer from '../../store/use-viewer' // Invisible material for root mesh — used as selection hitbox only const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) const defaultRevealMaterial = new THREE.MeshBasicMaterial({ color: '#7f766c' }) +// Door hardware (handle / hinges / closer / panic bar) renders a catalog metal +// finish by default (chrome), separate from the door body. The flat material is +// only a fallback if the catalog ref ever fails to resolve. +const HARDWARE_DEFAULT_REF = 'library:metal-chrome' +// Door body defaults to a catalog colour (generic approach). Glass keeps the +// built-in FrontSide glass material — the catalog `preset-glass` is DoubleSide, +// which poisons the WebGPU MRT scene pass. +const PANEL_DEFAULT_REF = 'library:preset-softwhite' +const FRAME_DEFAULT_REF = 'library:preset-softwhite' +const GLASS_DEFAULT_REF = 'library:preset-glass' +const defaultHardwareMaterial = createDefaultMaterial('#3a3a3a', 0.4) let baseMaterial = getBaseMaterial() +let frameMaterial: THREE.Material = getBaseMaterial() let revealMaterial: THREE.Material = defaultRevealMaterial let glassMaterial: THREE.Material = defaultGlassMaterial +let hardwareMaterial: THREE.Material = defaultHardwareMaterial +let currentDoorSlot: string | undefined +// Per-frame viewer state, captured so the per-node mesh builder (which runs +// outside React) can resolve each door's slot materials. +let currentShading: RenderShading = 'rendered' +let currentTextures = true +let currentColorPreset: ColorPreset = 'clay' +let currentSceneMaterials: Record | undefined const DOOR_RENDER_DEFAULTS = DoorNodeSchema.parse({ id: 'door_render_default' }) const MAX_DOOR_REBUILDS_PER_FRAME = 16 @@ -50,6 +77,7 @@ export const DoorSystem = () => { const shading = useViewer((state) => state.shading) const textures = useViewer((state) => state.textures) const colorPreset = useViewer((state) => state.colorPreset) + const sceneMaterials = useScene((state) => state.materials) const materialRevisionRef = useRef(null) // Subscribe so an override-only update (no scene write) still re-runs // the component, letting the gate below pick up the latest dirtyNodes @@ -59,8 +87,10 @@ export const DoorSystem = () => { const joineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset) baseMaterial = textures ? getBaseMaterial(shading) : joineryMaterial + frameMaterial = textures ? getBaseMaterial(shading) : joineryMaterial revealMaterial = textures ? defaultRevealMaterial : joineryMaterial glassMaterial = textures ? defaultGlassMaterial : joineryMaterial + hardwareMaterial = textures ? defaultHardwareMaterial : joineryMaterial useEffect(() => { const materialRevision = `${shading}:${textures ? 'textures' : 'solid'}:${colorPreset}` @@ -75,12 +105,29 @@ export const DoorSystem = () => { } }) + // Editing a scene material a door slot references must rebuild that door + // (door meshes are built by this system, not ). + useEffect(() => { + const nodes = useScene.getState().nodes + for (const node of Object.values(nodes)) { + if (node?.type !== 'door') continue + if (!nodeReferencesSceneMaterial(node)) continue + useScene.getState().dirtyNodes.add(node.id as AnyNodeId) + } + }, [sceneMaterials]) + useFrame(() => { if (dirtyNodes.size === 0) return const frameJoineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset) baseMaterial = textures ? getBaseMaterial(shading) : frameJoineryMaterial + frameMaterial = textures ? getBaseMaterial(shading) : frameJoineryMaterial revealMaterial = textures ? defaultRevealMaterial : frameJoineryMaterial glassMaterial = textures ? defaultGlassMaterial : frameJoineryMaterial + hardwareMaterial = textures ? defaultHardwareMaterial : frameJoineryMaterial + currentShading = shading + currentTextures = textures + currentColorPreset = colorPreset + currentSceneMaterials = sceneMaterials const nodes = useScene.getState().nodes const dirtyDoorIds: AnyNodeId[] = [] @@ -134,6 +181,59 @@ export const DoorSystem = () => { return null } +function tagDoorSlot(mesh: THREE.Mesh): THREE.Mesh { + mesh.userData.slotId = currentDoorSlot + return mesh +} + +function nodeReferencesSceneMaterial(node: { slots?: Record }): boolean { + const slots = node.slots + if (!slots) return false + for (const ref of Object.values(slots)) { + if (typeof ref === 'string' && ref.startsWith('scene:')) return true + } + return false +} + +type DoorMaterialSlotId = 'panel' | 'frame' | 'glass' | 'hardware' + +function doorSlotDefault(slotId: DoorMaterialSlotId): THREE.Material { + if (!currentTextures) return createSurfaceRoleMaterial('joinery', currentColorPreset) + if (slotId === 'glass') { + return ( + resolveMaterialRef(GLASS_DEFAULT_REF, currentSceneMaterials, currentShading) ?? + defaultGlassMaterial + ) + } + if (slotId === 'hardware') { + return ( + resolveMaterialRef(HARDWARE_DEFAULT_REF, currentSceneMaterials, currentShading) ?? + defaultHardwareMaterial + ) + } + if (slotId === 'frame') { + return ( + resolveMaterialRef(FRAME_DEFAULT_REF, currentSceneMaterials, currentShading) ?? + getBaseMaterial(currentShading) + ) + } + return ( + resolveMaterialRef(PANEL_DEFAULT_REF, currentSceneMaterials, currentShading) ?? + getBaseMaterial(currentShading) + ) +} + +// Resolve a door's slot to a material: the `node.slots` override (colored mode +// only) → the body/glass/hardware default. Textures-off ignores overrides — the +// monochrome escape hatch. +function resolveDoorSlotMaterial(node: DoorNode, slotId: DoorMaterialSlotId): THREE.Material { + const fallback = doorSlotDefault(slotId) + if (!currentTextures) return fallback + const ref = node.slots?.[slotId] + if (!ref) return fallback + return resolveMaterialRef(ref, currentSceneMaterials, currentShading) ?? fallback +} + function addBox( parent: THREE.Object3D, material: THREE.Material, @@ -144,8 +244,11 @@ function addBox( y: number, z: number, ) { - const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) + const geometry = new THREE.BoxGeometry(w, h, d) + applyWorldScaleBoxUVs(geometry, w, h, d) + const m = new THREE.Mesh(geometry, material) m.position.set(x, y, z) + tagDoorSlot(m) parent.add(m) } @@ -160,9 +263,12 @@ function addRotatedBox( z: number, rotationY: number, ) { - const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) + const geometry = new THREE.BoxGeometry(w, h, d) + applyWorldScaleBoxUVs(geometry, w, h, d) + const m = new THREE.Mesh(geometry, material) m.position.set(x, y, z) m.rotation.y = rotationY + tagDoorSlot(m) parent.add(m) } @@ -177,9 +283,12 @@ function addBoxWithRotation( z: number, rotation: [number, number, number], ) { - const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) + const geometry = new THREE.BoxGeometry(w, h, d) + applyWorldScaleBoxUVs(geometry, w, h, d) + const m = new THREE.Mesh(geometry, material) m.position.set(x, y, z) m.rotation.set(rotation[0], rotation[1], rotation[2]) + tagDoorSlot(m) parent.add(m) } @@ -196,6 +305,7 @@ function addShape( }) geometry.translate(0, 0, -depth / 2) const mesh = new THREE.Mesh(geometry, material) + tagDoorSlot(mesh) parent.add(mesh) } @@ -215,6 +325,7 @@ function addShapeAt( }) geometry.translate(x, y, z - depth / 2) const mesh = new THREE.Mesh(geometry, material) + tagDoorSlot(mesh) parent.add(mesh) } @@ -757,6 +868,7 @@ function addLeafSegmentContent({ const cpX = contentPadding[0] const cpY = contentPadding[1] if (renderPerimeterFrame && shouldRenderFrame && cpY > 0) { + currentDoorSlot = 'panel' addLeafBox( baseMaterial, leafWidth, @@ -778,6 +890,7 @@ function addLeafSegmentContent({ } if (renderPerimeterFrame && shouldRenderFrame && cpX > 0) { const innerH = leafHeight - 2 * cpY + currentDoorSlot = 'panel' addLeafBox( baseMaterial, cpX, @@ -857,6 +970,7 @@ function addLeafSegmentContent({ if (seg.type !== 'empty') { cx = leafCenterX - contentW / 2 + currentDoorSlot = 'panel' for (let c = 0; c < numCols - 1; c++) { cx += colWidths[c]! const dividerLeft = cx @@ -894,6 +1008,7 @@ function addLeafSegmentContent({ const colX = colXCenters[c]! if (seg.type === 'glass') { + currentDoorSlot = 'glass' const glassDepth = Math.max(0.004, leafDepth * 0.15) const segmentLeft = colX - colW / 2 const segmentRight = colX + colW / 2 @@ -914,6 +1029,7 @@ function addLeafSegmentContent({ addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) } } else if (seg.type === 'panel') { + currentDoorSlot = 'panel' const segmentLeft = colX - colW / 2 const segmentRight = colX + colW / 2 const outerPanelShape = @@ -1051,6 +1167,7 @@ function addDoorLeaf( const usesShapedLeafFrame = openingShape === 'rounded' || openingShape === 'arch' if (usesShapedLeafFrame && hasLeafContent) { + currentDoorSlot = 'panel' if (openingShape === 'rounded') { const roundedLeafShape = roundedBoundary ? createRoundedClippedLeafFrameShape( @@ -1141,6 +1258,7 @@ function addDoorLeaf( }) if (hasLeafContent && handle) { + currentDoorSlot = 'hardware' const handleY = handleHeight - doorHeight / 2 const faceZ = leafDepth / 2 const handleX = @@ -1148,20 +1266,21 @@ function addDoorLeaf( ? leafCenterX + leafWidth / 2 - 0.045 : leafCenterX - leafWidth / 2 + 0.045 - addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005) - addLeafBox(baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025) + addLeafBox(hardwareMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005) + addLeafBox(hardwareMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025) if (handleBothSides) { - addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, -faceZ - 0.005) - addLeafBox(baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, -faceZ - 0.025) + addLeafBox(hardwareMaterial, 0.028, 0.14, 0.01, handleX, handleY, -faceZ - 0.005) + addLeafBox(hardwareMaterial, 0.022, 0.1, 0.035, handleX, handleY, -faceZ - 0.025) } } if (hasLeafContent && doorCloser) { + currentDoorSlot = 'hardware' const closerY = leafCenterY + leafHeight / 2 - 0.04 - addLeafBox(baseMaterial, 0.28, 0.055, 0.055, leafCenterX, closerY, leafDepth / 2 + 0.03) + addLeafBox(hardwareMaterial, 0.28, 0.055, 0.055, leafCenterX, closerY, leafDepth / 2 + 0.03) addLeafBox( - baseMaterial, + hardwareMaterial, 0.14, 0.015, 0.015, @@ -1172,18 +1291,37 @@ function addDoorLeaf( } if (hasLeafContent && panicBar) { + currentDoorSlot = 'hardware' const barY = panicBarHeight - doorHeight / 2 - addLeafBox(baseMaterial, leafWidth * 0.72, 0.04, 0.055, leafCenterX, barY, leafDepth / 2 + 0.03) + addLeafBox( + hardwareMaterial, + leafWidth * 0.72, + 0.04, + 0.055, + leafCenterX, + barY, + leafDepth / 2 + 0.03, + ) } if (hasLeafContent) { + currentDoorSlot = 'hardware' const hingeMarkerX = hingeSide === 'right' ? hingeX - 0.012 : hingeX + 0.012 const hingeH = 0.1 const hingeW = 0.024 const hingeD = leafDepth + 0.016 - addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafBottom + 0.25, 0) - addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, (leafBottom + leafTop) / 2, 0) - addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0) + addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafBottom + 0.25, 0) + addBox( + mesh, + hardwareMaterial, + hingeW, + hingeH, + hingeD, + hingeMarkerX, + (leafBottom + leafTop) / 2, + 0, + ) + addBox(mesh, hardwareMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0) } } @@ -1222,9 +1360,10 @@ function addFoldingDoor( const panelLength = insideWidth / panelCount const foldAngle = Math.PI * 0.44 * foldAmount + currentDoorSlot = 'hardware' addBox( mesh, - baseMaterial, + hardwareMaterial, insideWidth, Math.min(frameThickness * 0.5, 0.025), Math.max(frameDepth * 0.45, 0.035), @@ -1244,6 +1383,7 @@ function addFoldingDoor( }) } + currentDoorSlot = undefined for (let index = 0; index < panelCount; index++) { const start = vertices[index]! const end = vertices[index + 1]! @@ -1291,6 +1431,7 @@ function addFoldingDoor( keepFrameWhenEmpty: true, }) + currentDoorSlot = undefined for (const point of [start, end]) { addBox( mesh, @@ -1307,9 +1448,10 @@ function addFoldingDoor( const handlePoint = vertices[vertices.length - 1]! const handleY = handleHeight - doorHeight / 2 + currentDoorSlot = 'hardware' addBox( mesh, - baseMaterial, + hardwareMaterial, 0.035, 0.16, leafDepth + 0.035, @@ -1319,7 +1461,7 @@ function addFoldingDoor( ) addBox( mesh, - baseMaterial, + hardwareMaterial, 0.035, 0.16, leafDepth + 0.035, @@ -1368,9 +1510,10 @@ function addPocketDoor( const handleY = handleHeight - doorHeight / 2 const handleX = leafCenterX - slideSign * (leafWidth / 2 - 0.055) + currentDoorSlot = 'hardware' addBox( mesh, - baseMaterial, + hardwareMaterial, insideWidth * 2, Math.min(frameThickness * 0.45, 0.024), Math.max(frameDepth * 0.38, 0.03), @@ -1378,6 +1521,7 @@ function addPocketDoor( topY - 0.018, 0, ) + currentDoorSlot = undefined addBox( mesh, revealMaterial, @@ -1419,8 +1563,27 @@ function addPocketDoor( segments, contentPadding, }) - addBox(mesh, baseMaterial, 0.03, 0.18, leafDepth + 0.03, handleX, handleY, leafDepth / 2 + 0.02) - addBox(mesh, baseMaterial, 0.03, 0.18, leafDepth + 0.03, handleX, handleY, -leafDepth / 2 - 0.02) + currentDoorSlot = 'hardware' + addBox( + mesh, + hardwareMaterial, + 0.03, + 0.18, + leafDepth + 0.03, + handleX, + handleY, + leafDepth / 2 + 0.02, + ) + addBox( + mesh, + hardwareMaterial, + 0.03, + 0.18, + leafDepth + 0.03, + handleX, + handleY, + -leafDepth / 2 - 0.02, + ) } function addBarnDoor( @@ -1465,9 +1628,10 @@ function addBarnDoor( const handleX = leafCenterX - slideSign * (leafWidth / 2 - 0.075) const wheelY = trackY - 0.075 - addBox(mesh, revealMaterial, railLength, 0.035, 0.035, railCenterX, trackY, faceZ + 0.01) - addBox(mesh, revealMaterial, 0.05, 0.13, 0.035, -insideWidth / 2, trackY - 0.02, faceZ + 0.01) - addBox(mesh, revealMaterial, 0.05, 0.13, 0.035, insideWidth / 2, trackY - 0.02, faceZ + 0.01) + currentDoorSlot = 'hardware' + addBox(mesh, hardwareMaterial, railLength, 0.035, 0.035, railCenterX, trackY, faceZ + 0.01) + addBox(mesh, hardwareMaterial, 0.05, 0.13, 0.035, -insideWidth / 2, trackY - 0.02, faceZ + 0.01) + addBox(mesh, hardwareMaterial, 0.05, 0.13, 0.035, insideWidth / 2, trackY - 0.02, faceZ + 0.01) const addBarnLeafBox = ( material: THREE.Material, @@ -1491,6 +1655,7 @@ function addBarnDoor( keepFrameWhenEmpty: true, }) + currentDoorSlot = undefined addRotatedBox( mesh, revealMaterial, @@ -1514,11 +1679,12 @@ function addBarnDoor( 0.52, ) + currentDoorSlot = 'hardware' for (const offset of [-leafWidth * 0.28, leafWidth * 0.28]) { - addBox(mesh, revealMaterial, 0.085, 0.085, 0.035, leafCenterX + offset, wheelY, faceZ + 0.022) + addBox(mesh, hardwareMaterial, 0.085, 0.085, 0.035, leafCenterX + offset, wheelY, faceZ + 0.022) addBox( mesh, - revealMaterial, + hardwareMaterial, 0.026, 0.16, 0.026, @@ -1528,9 +1694,10 @@ function addBarnDoor( ) } + currentDoorSlot = 'hardware' addBox( mesh, - baseMaterial, + hardwareMaterial, 0.032, 0.22, leafDepth + 0.034, @@ -1540,7 +1707,7 @@ function addBarnDoor( ) addBox( mesh, - baseMaterial, + hardwareMaterial, 0.032, 0.22, leafDepth + 0.034, @@ -1595,10 +1762,20 @@ function addSlidingDoor( const handleY = handleHeight - doorHeight / 2 const handleX = activeX + activeSign * (panelWidth / 2 - 0.06) - addBox(mesh, revealMaterial, insideWidth, 0.024, Math.max(frameDepth * 0.32, 0.026), 0, railY, 0) + currentDoorSlot = 'hardware' addBox( mesh, - revealMaterial, + hardwareMaterial, + insideWidth, + 0.024, + Math.max(frameDepth * 0.32, 0.026), + 0, + railY, + 0, + ) + addBox( + mesh, + hardwareMaterial, insideWidth, 0.018, Math.max(frameDepth * 0.28, 0.022), @@ -1649,8 +1826,27 @@ function addSlidingDoor( contentPadding, keepFrameWhenEmpty: true, }) - addBox(mesh, baseMaterial, 0.032, 0.24, 0.016, handleX, handleY, frontZ + leafDepth / 2 + 0.01) - addBox(mesh, baseMaterial, 0.032, 0.24, 0.016, handleX, handleY, frontZ - leafDepth / 2 - 0.01) + currentDoorSlot = 'hardware' + addBox( + mesh, + hardwareMaterial, + 0.032, + 0.24, + 0.016, + handleX, + handleY, + frontZ + leafDepth / 2 + 0.01, + ) + addBox( + mesh, + hardwareMaterial, + 0.032, + 0.24, + 0.016, + handleX, + handleY, + frontZ - leafDepth / 2 - 0.01, + ) } function addGarageSectionalDoor( @@ -1687,9 +1883,10 @@ function addGarageSectionalDoor( const railY = leafCenterY + leafHeight / 2 - 0.04 const railZ = -travelDepth / 2 + currentDoorSlot = 'hardware' addBox( mesh, - revealMaterial, + hardwareMaterial, 0.035, Math.max(0.04, frameThickness * 0.75), travelDepth, @@ -1699,7 +1896,7 @@ function addGarageSectionalDoor( ) addBox( mesh, - revealMaterial, + hardwareMaterial, 0.035, Math.max(0.04, frameThickness * 0.75), travelDepth, @@ -1730,6 +1927,7 @@ function addGarageSectionalDoor( const trimDepth = 0.01 const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006 const addSectionalTrim = (localY: number) => { + currentDoorSlot = undefined addBoxWithRotation( mesh, revealMaterial, @@ -1743,6 +1941,7 @@ function addGarageSectionalDoor( ) } + currentDoorSlot = 'panel' addBoxWithRotation( mesh, baseMaterial, @@ -1758,7 +1957,8 @@ function addGarageSectionalDoor( addSectionalTrim(-revealOffset) } - addBox(mesh, revealMaterial, insideWidth, 0.032, Math.max(frameDepth * 0.36, 0.03), 0, railY, 0) + currentDoorSlot = 'hardware' + addBox(mesh, hardwareMaterial, insideWidth, 0.032, Math.max(frameDepth * 0.36, 0.03), 0, railY, 0) } function addGarageRollupDoor( @@ -1791,9 +1991,10 @@ function addGarageRollupDoor( const drumY = topY + drumMaxRadius * 0.12 const drumZ = -frameDepth / 2 - drumMaxRadius * 0.72 + currentDoorSlot = 'hardware' addBox( mesh, - revealMaterial, + hardwareMaterial, 0.032, leafHeight, Math.max(frameDepth * 0.48, 0.035), @@ -1803,7 +2004,7 @@ function addGarageRollupDoor( ) addBox( mesh, - revealMaterial, + hardwareMaterial, 0.032, leafHeight, Math.max(frameDepth * 0.48, 0.035), @@ -1813,8 +2014,10 @@ function addGarageRollupDoor( ) if (visibleHeight > 0.01) { + currentDoorSlot = 'panel' addBox(mesh, baseMaterial, insideWidth, visibleHeight, leafDepth, 0, curtainCenterY, 0) + currentDoorSlot = undefined for (let index = 0; index < visibleSlatCount; index++) { const y = topY - Math.min(visibleHeight, index * slatHeight) addBox(mesh, revealMaterial, insideWidth - 0.08, 0.01, 0.012, 0, y, leafDepth / 2 + 0.012) @@ -1832,17 +2035,20 @@ function addGarageRollupDoor( ) } + currentDoorSlot = 'panel' const drum = new THREE.Mesh( new THREE.CylinderGeometry(drumMaxRadius, drumMaxRadius, insideWidth + frameThickness, 36), baseMaterial, ) drum.position.set(0, drumY, drumZ) drum.rotation.z = Math.PI / 2 + tagDoorSlot(drum) mesh.add(drum) + currentDoorSlot = 'hardware' addBox( mesh, - revealMaterial, + hardwareMaterial, insideWidth + frameThickness, 0.026, Math.max(frameDepth * 0.52, 0.04), @@ -1881,9 +2087,10 @@ function addGarageTiltupDoor( const railY = hingeY - frameThickness * 0.35 const railZ = -railLength / 2 + currentDoorSlot = 'hardware' addBox( mesh, - revealMaterial, + hardwareMaterial, 0.03, Math.max(frameThickness * 0.7, 0.035), railLength, @@ -1893,7 +2100,7 @@ function addGarageTiltupDoor( ) addBox( mesh, - revealMaterial, + hardwareMaterial, 0.03, Math.max(frameThickness * 0.7, 0.035), railLength, @@ -1902,6 +2109,7 @@ function addGarageTiltupDoor( railZ, ) + currentDoorSlot = 'panel' addBoxWithRotation( mesh, baseMaterial, @@ -1919,6 +2127,7 @@ function addGarageTiltupDoor( const trimDepth = 0.012 const trimFaceOffset = leafDepth / 2 + trimDepth + 0.006 const addTiltupTrim = (localX: number, localY: number, trimWidth: number, trimHeight: number) => { + currentDoorSlot = undefined addBoxWithRotation( mesh, revealMaterial, @@ -1937,7 +2146,17 @@ function addGarageTiltupDoor( addTiltupTrim(-insetWidth / 2, 0, 0.018, insetHeight) addTiltupTrim(insetWidth / 2, 0, 0.018, insetHeight) - addBox(mesh, revealMaterial, insideWidth, 0.026, Math.max(frameDepth * 0.4, 0.035), 0, hingeY, 0) + currentDoorSlot = 'hardware' + addBox( + mesh, + hardwareMaterial, + insideWidth, + 0.026, + Math.max(frameDepth * 0.4, 0.035), + 0, + hingeY, + 0, + ) } function getEffectiveOpeningShape(node: DoorNode): DoorNode['openingShape'] { @@ -1951,6 +2170,7 @@ function getEffectiveOpeningShape(node: DoorNode): DoorNode['openingShape'] { function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { const node = normalizeDoorNodeForRender(rawNode) + currentDoorSlot = undefined // Root mesh is an invisible hitbox; all visuals live in child meshes mesh.geometry.dispose() @@ -1968,6 +2188,14 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { mesh.remove(child) } + // Point the builder-facing materials at this door's slot overrides for the + // duration of its build (recomputed per node, so the next door resets cleanly + // without a restore). Reveal keeps its own material. + baseMaterial = resolveDoorSlotMaterial(node, 'panel') + frameMaterial = resolveDoorSlotMaterial(node, 'frame') + glassMaterial = resolveDoorSlotMaterial(node, 'glass') + hardwareMaterial = resolveDoorSlotMaterial(node, 'hardware') + const { width, height, @@ -2012,6 +2240,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { const swingDirectionSign = swingDirection === 'inward' ? 1 : -1 // ── Frame members ── + currentDoorSlot = 'frame' if (openingShape === 'arch') { const frameBottom = -height / 2 const frameTop = height / 2 @@ -2025,7 +2254,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { addBox( mesh, - baseMaterial, + frameMaterial, frameThickness, postHeight, frameDepth, @@ -2035,7 +2264,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { ) addBox( mesh, - baseMaterial, + frameMaterial, frameThickness, postHeight, frameDepth, @@ -2045,7 +2274,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { ) addShape( mesh, - baseMaterial, + frameMaterial, useShallowHeadBar ? createArchHeadBarShape(width, frameHeadBottomY, frameSpringY, frameTop) : createArchBandShape( @@ -2061,7 +2290,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { } else if (openingShape === 'rounded') { addShape( mesh, - baseMaterial, + frameMaterial, createRoundedDoorFrameShape( width, height, @@ -2074,7 +2303,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { // Left post — full height addBox( mesh, - baseMaterial, + frameMaterial, frameThickness, height, frameDepth, @@ -2085,7 +2314,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { // Right post — full height addBox( mesh, - baseMaterial, + frameMaterial, frameThickness, height, frameDepth, @@ -2096,7 +2325,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { // Head (top bar) — full width addBox( mesh, - baseMaterial, + frameMaterial, width, frameThickness, frameDepth, @@ -2108,9 +2337,10 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) { // ── Threshold (inside the frame) ── if (threshold) { + currentDoorSlot = 'frame' addBox( mesh, - baseMaterial, + frameMaterial, insideWidth, thresholdHeight, frameDepth, @@ -2331,6 +2561,10 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) { if (!cutout) { cutout = new THREE.Mesh() cutout.name = 'cutout' + // The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it + // wins the scene raycast over the wall in front of the recessed door body — + // making it the selection AND paint hit target for the whole opening. The + // paint capability then re-raycasts the door's parts to find the slot. mesh.add(cutout) } cutout.geometry.dispose() diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts index 3c807dc8b..a37690719 100644 --- a/packages/viewer/src/systems/wall/wall-materials.ts +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -2,7 +2,9 @@ import { getEffectiveWallSurfaceMaterial, getMaterialPresetByRef, getWallSurfaceMaterialSignature, + parseMaterialRef, resolveMaterial, + WALL_SLOT_DEFAULT, type WallNode, type WallSurfaceMaterialSpec, } from '@pascal-app/core' @@ -12,6 +14,7 @@ import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu' import { baseMaterial, type ColorPreset, + createDefaultMaterial, createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, @@ -88,6 +91,15 @@ function hasExplicitMaterial(spec: WallSurfaceMaterialSpec): boolean { return Boolean(spec.materialPreset || spec.material) } +// Resolve a wall face's declared default — a catalog `library:` finish or a +// flat colour — to a renderable material. +function resolveWallSlotDefault(slotDefault: string, shading: RenderShading): Material { + if (parseMaterialRef(slotDefault)?.kind === 'library') { + return createMaterialFromPresetRef(slotDefault, shading) ?? baseMaterial(shading) + } + return createDefaultMaterial(slotDefault, 0.9, shading) +} + function getSurfaceColor(spec: WallSurfaceMaterialSpec, fallback = DEFAULT_WALL_COLOR): string { const preset = getMaterialPresetByRef(spec.materialPreset) if (preset?.mapProperties?.color) { @@ -216,17 +228,19 @@ export function getMaterialsForWall( const exteriorSpec = getEffectiveWallSurfaceMaterial(wallNode, 'exterior') const wallRoleMaterial = createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme) - // Untextured surfaces take the themed wall role colour even with textures on; - // only surfaces with an explicit preset/material keep their texture. + // Colored mode: an unpainted face takes its declared slot default (parity + // with the retired DEFAULT_WALL_MATERIAL); only an explicit preset/material + // keeps a texture. Textures-off collapses every face to the themed wall role + // (the guaranteed escape hatch). The edge/cap slot (index 0) stays role-based. const visible: WallMaterialArray = textures ? [ wallRoleMaterial, hasExplicitMaterial(interiorSpec) ? getSurfaceVisibleMaterial(interiorSpec, shading) - : wallRoleMaterial, + : resolveWallSlotDefault(WALL_SLOT_DEFAULT.interior, shading), hasExplicitMaterial(exteriorSpec) ? getSurfaceVisibleMaterial(exteriorSpec, shading) - : wallRoleMaterial, + : resolveWallSlotDefault(WALL_SLOT_DEFAULT.exterior, shading), ] : [wallRoleMaterial, wallRoleMaterial, wallRoleMaterial] diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index 6f953877e..57c938924 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -1,6 +1,8 @@ import { type AnyNodeId, getEffectiveNode, + type SceneMaterial, + type SceneMaterialId, sceneRegistry, useInteractive, useLiveNodeOverrides, @@ -10,10 +12,14 @@ import { import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' import * as THREE from 'three' +import { applyWorldScaleBoxUVs } from '../../lib/box-uv' import { + type ColorPreset, createSurfaceRoleMaterial, glassMaterial as defaultGlassMaterial, baseMaterial as getBaseMaterial, + type RenderShading, + resolveMaterialRef, } from '../../lib/materials' import useViewer from '../../store/use-viewer' @@ -21,6 +27,13 @@ import useViewer from '../../store/use-viewer' const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) let baseMaterial = getBaseMaterial() let glassMaterial: THREE.Material = defaultGlassMaterial +let currentWindowSlot: string | undefined +// Per-frame viewer state, captured so the per-node mesh builder (which runs +// outside React) can resolve each window's slot materials. +let currentShading: RenderShading = 'rendered' +let currentTextures = true +let currentColorPreset: ColorPreset = 'clay' +let currentSceneMaterials: Record | undefined export const CASEMENT_WINDOW_SASH_NAME = 'casement-window-sash' export const FRENCH_CASEMENT_LEFT_SASH_NAME = 'french-casement-left-sash' export const FRENCH_CASEMENT_RIGHT_SASH_NAME = 'french-casement-right-sash' @@ -42,6 +55,7 @@ export const WindowSystem = () => { const shading = useViewer((state) => state.shading) const textures = useViewer((state) => state.textures) const colorPreset = useViewer((state) => state.colorPreset) + const sceneMaterials = useScene((state) => state.materials) const materialRevisionRef = useRef(null) // Subscribe so override-only updates re-run this component. Mirrors // WallSystem + DoorSystem. @@ -67,6 +81,18 @@ export const WindowSystem = () => { } }) + // Editing a scene material a window slot references must rebuild that window + // (window meshes are built by this system, not , so its + // scene-material re-dirty doesn't cover them). + useEffect(() => { + const nodes = useScene.getState().nodes + for (const node of Object.values(nodes)) { + if (node?.type !== 'window') continue + if (!nodeReferencesSceneMaterial(node)) continue + useScene.getState().dirtyNodes.add(node.id as AnyNodeId) + } + }, [sceneMaterials]) + useFrame(() => { if (dirtyNodes.size === 0) return baseMaterial = textures @@ -75,6 +101,10 @@ export const WindowSystem = () => { glassMaterial = textures ? defaultGlassMaterial : createSurfaceRoleMaterial('glazing', colorPreset) + currentShading = shading + currentTextures = textures + currentColorPreset = colorPreset + currentSceneMaterials = sceneMaterials const nodes = useScene.getState().nodes const dirtyWindowIds: AnyNodeId[] = [] @@ -128,6 +158,52 @@ export const WindowSystem = () => { return null } +function tagWindowSlot(mesh: THREE.Mesh): THREE.Mesh { + mesh.userData.slotId = currentWindowSlot + return mesh +} + +function nodeReferencesSceneMaterial(node: { slots?: Record }): boolean { + const slots = node.slots + if (!slots) return false + for (const ref of Object.values(slots)) { + if (typeof ref === 'string' && ref.startsWith('scene:')) return true + } + return false +} + +// Window frame/glass default to catalog finishes (generic approach). `preset-glass` +// is now FrontSide (it was the only glass we use), so it's safe for the WebGPU +// MRT scene pass. +const FRAME_DEFAULT_REF = 'library:preset-softwhite' +const GLASS_DEFAULT_REF = 'library:preset-glass' + +function windowSlotDefault(slotId: 'frame' | 'glass'): THREE.Material { + if (slotId === 'glass') { + if (!currentTextures) return createSurfaceRoleMaterial('glazing', currentColorPreset) + return ( + resolveMaterialRef(GLASS_DEFAULT_REF, currentSceneMaterials, currentShading) ?? + defaultGlassMaterial + ) + } + if (!currentTextures) return createSurfaceRoleMaterial('joinery', currentColorPreset) + return ( + resolveMaterialRef(FRAME_DEFAULT_REF, currentSceneMaterials, currentShading) ?? + getBaseMaterial(currentShading) + ) +} + +// Resolve a window's slot to a material: the `node.slots` override (colored mode +// only) → the role/base default. Textures-off ignores overrides — the monochrome +// escape hatch. +function resolveWindowSlotMaterial(node: WindowNode, slotId: 'frame' | 'glass'): THREE.Material { + const fallback = windowSlotDefault(slotId) + if (!currentTextures) return fallback + const ref = node.slots?.[slotId] + if (!ref) return fallback + return resolveMaterialRef(ref, currentSceneMaterials, currentShading) ?? fallback +} + function addBox( parent: THREE.Object3D, material: THREE.Material, @@ -138,8 +214,11 @@ function addBox( y: number, z: number, ) { - const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) + const geometry = new THREE.BoxGeometry(w, h, d) + applyWorldScaleBoxUVs(geometry, w, h, d) + const m = new THREE.Mesh(geometry, material) m.position.set(x, y, z) + tagWindowSlot(m) parent.add(m) } @@ -157,6 +236,7 @@ function addShape( }) geometry.translate(0, 0, -depth / 2 + z) const mesh = new THREE.Mesh(geometry, material) + tagWindowSlot(mesh) parent.add(mesh) } @@ -493,6 +573,7 @@ function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = innerTop - innerBottom const innerRadii = insetCornerRadii(outerRadii, inset, innerW, innerH) + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -502,6 +583,7 @@ function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (innerW > 0.01 && innerH > 0.01) { const glassDepth = Math.max(0.004, frameDepth * 0.08) + currentWindowSlot = 'glass' addShape( mesh, glassMaterial, @@ -519,6 +601,7 @@ function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const rowHeights = rowRatios.map((r) => (r / rowSum) * usableH) let x = innerLeft + currentWindowSlot = 'frame' for (let c = 0; c < numCols - 1; c++) { x += colWidths[c]! const x1 = x @@ -539,6 +622,7 @@ function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } let y = innerTop + currentWindowSlot = 'frame' for (let r = 0; r < numRows - 1; r++) { y -= rowHeights[r]! const yTop = y @@ -565,6 +649,7 @@ function addRoundedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -606,10 +691,12 @@ function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerArchHeight = getClampedArchHeight(innerW, innerH, archHeight - inset) const innerSpringY = innerTop - innerArchHeight + currentWindowSlot = 'frame' addShape(mesh, baseMaterial, createArchedFrameShape(width, height, archHeight, inset), frameDepth) if (innerW > 0.01 && innerH > 0.01) { const glassDepth = Math.max(0.004, frameDepth * 0.08) + currentWindowSlot = 'glass' addShape( mesh, glassMaterial, @@ -628,6 +715,7 @@ function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerHalfWidth = innerW / 2 let x = innerLeft + currentWindowSlot = 'frame' for (let c = 0; c < numCols - 1; c++) { x += colWidths[c]! const x1 = x @@ -648,6 +736,7 @@ function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } let y = innerTop + currentWindowSlot = 'frame' for (let r = 0; r < numRows - 1; r++) { y -= rowHeights[r]! const yTop = y @@ -675,6 +764,7 @@ function addArchedWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -714,6 +804,7 @@ function addSlidingWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -776,6 +867,7 @@ function addSlidingWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { mesh.add(activePanel) // Twin tracks signal the sliding operation without adding editor-only state. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -797,10 +889,12 @@ function addSlidingWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { 0, ) + currentWindowSlot = 'glass' addBox(activePanel, glassMaterial, panelWidth, panelH, glassDepth, 0, 0, 0) addBox(mesh, glassMaterial, panelWidth, panelH, glassDepth, rightPanelX, 0, rightZ) // The right sash stays fixed. The left sash is the active panel that slides across it. + currentWindowSlot = 'frame' addBox( activePanel, baseMaterial, @@ -846,6 +940,7 @@ function addSlidingWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -882,6 +977,7 @@ function addRectCasementSash( sash.rotation.y = rotationY parent.add(sash) + currentWindowSlot = 'frame' addBox( sash, baseMaterial, @@ -922,6 +1018,7 @@ function addRectCasementSash( 0, 0, ) + currentWindowSlot = 'glass' addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08) } @@ -934,6 +1031,7 @@ function addFrenchCasementHingeMarkers( ) { const markerW = Math.max(frameThickness * 0.38, 0.018) const markerH = innerH * 0.24 + currentWindowSlot = 'frame' for (const pivotX of [-innerW / 2, innerW / 2]) { addBox( mesh, @@ -1112,6 +1210,7 @@ function addShapedFrenchCasementSash( const outerArchHeight = getClampedArchHeight(node.width, node.height, node.archHeight) const sashArchHeight = getClampedArchHeight(fullW, leafH, outerArchHeight - frameThickness) const sashSpringY = node.height / 2 - outerArchHeight + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -1128,6 +1227,7 @@ function addShapedFrenchCasementSash( ) const glassInset = Math.min(sashFrameThickness, leafW / 2 - 0.005, leafH / 2 - 0.005) if (glassInset > 0.001) { + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -1153,6 +1253,7 @@ function addShapedFrenchCasementSash( fullW, leafH, ) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -1161,6 +1262,7 @@ function addShapedFrenchCasementSash( ) const glassInset = Math.min(sashFrameThickness, leafW / 2 - 0.005, leafH / 2 - 0.005) if (glassInset > 0.001) { + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -1177,6 +1279,7 @@ function addFrenchCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Fixed outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1249,6 +1352,7 @@ function addFrenchCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1268,6 +1372,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness if (node.openingShape === 'arch') { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -1280,6 +1385,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { frameDepth, ) } else { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -1331,6 +1437,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1371,6 +1478,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { innerH, (node.archHeight ?? innerW / 2) - frameThickness, ) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -1381,6 +1489,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -1397,6 +1506,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } else { const outerRadii = getWindowRoundedRadii(node, innerW, innerH) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -1407,6 +1517,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -1423,6 +1534,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1448,6 +1560,7 @@ function addShapedCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1478,6 +1591,7 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Fixed outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1538,6 +1652,7 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { sash.rotation.y = hingeSign * openAngle mesh.add(sash) + currentWindowSlot = 'frame' addBox( sash, baseMaterial, @@ -1578,9 +1693,11 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { 0, 0, ) + currentWindowSlot = 'glass' addBox(sash, glassMaterial, glassW, glassH, glassDepth, sashCenterX, 0, sashDepth * 0.08) // Small hinge markers make the pivot side legible when the sash is closed. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1606,6 +1723,7 @@ function addCasementWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1631,6 +1749,7 @@ function addAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Fixed outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1690,6 +1809,7 @@ function addAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { sash.rotation.x = -openAngle mesh.add(sash) + currentWindowSlot = 'frame' addBox( sash, baseMaterial, @@ -1730,9 +1850,11 @@ function addAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { sashCenterY, 0, ) + currentWindowSlot = 'glass' addBox(sash, glassMaterial, glassW, glassH, glassDepth, 0, sashCenterY, sashDepth * 0.08) // Compact hinge rail, visible even when the sash is closed. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1748,6 +1870,7 @@ function addAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1767,6 +1890,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness if (node.openingShape === 'arch') { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -1779,6 +1903,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { frameDepth, ) } else { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -1816,6 +1941,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { innerH, (node.archHeight ?? innerW / 2) - frameThickness, ) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -1826,6 +1952,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -1842,6 +1969,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } else { const outerRadii = getWindowRoundedRadii(node, innerW, innerH) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -1852,6 +1980,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -1868,6 +1997,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1883,6 +2013,7 @@ function addShapedAwningWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1908,6 +2039,7 @@ function addHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Fixed outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -1965,6 +2097,7 @@ function addHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { sash.rotation.x = -openAngle mesh.add(sash) + currentWindowSlot = 'frame' addBox( sash, baseMaterial, @@ -1996,9 +2129,11 @@ function addHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { innerH / 2, 0, ) + currentWindowSlot = 'glass' addBox(sash, glassMaterial, glassW, glassH, glassDepth, 0, innerH / 2, sashDepth * 0.08) // Compact bottom hinge rail, visible even when the sash is closed. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2014,6 +2149,7 @@ function addHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2033,6 +2169,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness if (node.openingShape === 'arch') { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -2045,6 +2182,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { frameDepth, ) } else { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -2081,6 +2219,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { innerH, (node.archHeight ?? innerW / 2) - frameThickness, ) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -2091,6 +2230,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -2107,6 +2247,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } else { const outerRadii = getWindowRoundedRadii(node, innerW, innerH) + currentWindowSlot = 'frame' addShape( sashVisual, baseMaterial, @@ -2117,6 +2258,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (glassInset > 0.001) { const glassW = innerW - 2 * glassInset const glassH = innerH - 2 * glassInset + currentWindowSlot = 'glass' addShape( sashVisual, glassMaterial, @@ -2133,6 +2275,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } } + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2148,6 +2291,7 @@ function addShapedHopperWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2171,6 +2315,7 @@ function addHungSash( glassW: number, glassH: number, ) { + currentWindowSlot = 'frame' addBox( parent, baseMaterial, @@ -2211,6 +2356,7 @@ function addHungSash( 0, 0, ) + currentWindowSlot = 'glass' addBox(parent, glassMaterial, glassW, glassH, glassDepth, 0, 0, 0) } @@ -2221,6 +2367,7 @@ function addSingleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Fixed outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2285,6 +2432,7 @@ function addSingleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { mesh.add(activeSash) // Side tracks show the lower sash is the moving element. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2331,6 +2479,7 @@ function addSingleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { ) // Meeting rails: top sash fixed, bottom sash moves upward over it. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2356,6 +2505,7 @@ function addSingleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2376,6 +2526,7 @@ function addDoubleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = height - 2 * frameThickness // Fixed outer frame. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2444,6 +2595,7 @@ function addDoubleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { mesh.add(bottomSash) // Side tracks show both sashes move vertically. + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2487,6 +2639,7 @@ function addDoubleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { ) // Opposing meeting rails: top sash descends while bottom sash rises. + currentWindowSlot = 'frame' addBox( topSash, baseMaterial, @@ -2512,6 +2665,7 @@ function addDoubleHungWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2530,6 +2684,7 @@ function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2590,6 +2745,7 @@ function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const addBayPanel = (parent: THREE.Object3D, panelW: number) => { const glassW = Math.max(panelW - 2 * sashFrameThickness, 0.01) const glassH = Math.max(innerH - 2 * sashFrameThickness, 0.01) + currentWindowSlot = 'frame' addBox( parent, baseMaterial, @@ -2630,10 +2786,12 @@ function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { 0, 0, ) + currentWindowSlot = 'glass' addBox(parent, glassMaterial, glassW, glassH, glassDepth, 0, 0, panelDepth * 0.08) } const addBayCap = (centerY: number) => { + currentWindowSlot = 'frame' const halfThickness = frameThickness / 2 const vertices: number[] = [] const indices: number[] = [] @@ -2688,7 +2846,7 @@ function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)) geometry.setIndex(indices) geometry.computeVertexNormals() - mesh.add(new THREE.Mesh(geometry, baseMaterial)) + mesh.add(tagWindowSlot(new THREE.Mesh(geometry, baseMaterial))) } const center = new THREE.Group() @@ -2715,6 +2873,7 @@ function addBayWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2733,6 +2892,7 @@ function addBowWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2859,15 +3019,19 @@ function addBowWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } const addCurvedMesh = (material: THREE.Material, geometry: THREE.BufferGeometry) => { - mesh.add(new THREE.Mesh(geometry, material)) + mesh.add(tagWindowSlot(new THREE.Mesh(geometry, material))) } + currentWindowSlot = 'frame' addCurvedMesh(baseMaterial, createCurvedVerticalBand(glassTop, innerH / 2)) addCurvedMesh(baseMaterial, createCurvedVerticalBand(-innerH / 2, glassBottom)) + currentWindowSlot = 'glass' addCurvedMesh(glassMaterial, createCurvedVerticalBand(glassBottom, glassTop, frameDepth * 0.04)) + currentWindowSlot = 'frame' addCurvedMesh(baseMaterial, createCurvedCap(slabYTop, frameThickness)) addCurvedMesh(baseMaterial, createCurvedCap(slabYBottom, frameThickness)) + currentWindowSlot = 'frame' for (let index = 0; index <= mullionCount; index += 1) { const x = -halfSpan + (innerW * index) / mullionCount addBox(mesh, baseMaterial, sashFrameThickness, innerH, frameDepth * 0.72, x, 0, arcZAt(x)) @@ -2877,6 +3041,7 @@ function addBowWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2901,6 +3066,7 @@ function addLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerW = width - 2 * frameThickness const innerH = height - 2 * frameThickness + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2955,6 +3121,7 @@ function addLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { slats.name = LOUVERED_WINDOW_SLATS_NAME mesh.add(slats) + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -2976,6 +3143,7 @@ function addLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { 0, ) + currentWindowSlot = 'glass' for (let index = 0; index < slatCount; index += 1) { const y = innerH / 2 - slatGap * (index + 0.5) const slat = new THREE.Group() @@ -2998,6 +3166,7 @@ function addLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -3025,6 +3194,7 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { const innerH = innerTop - innerBottom if (node.openingShape === 'arch') { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -3037,6 +3207,7 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { frameDepth, ) } else { + currentWindowSlot = 'frame' addShape( mesh, baseMaterial, @@ -3087,6 +3258,7 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { })() const addVerticalRail = (x: number) => { + currentWindowSlot = 'frame' const railX1 = x const railX2 = x + (x < 0 ? railThickness : -railThickness) const sampleX = x < 0 ? Math.max(railX1, railX2) : Math.min(railX1, railX2) @@ -3120,6 +3292,7 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { addVerticalRail(innerLeft) addVerticalRail(innerRight) + currentWindowSlot = 'glass' for (let index = 0; index < slatCount; index += 1) { const y = innerTop - slatGap * (index + 0.5) const topBounds = getBoundsAtY(Math.min(y + slatHeight / 2, innerTop)) @@ -3140,6 +3313,7 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { if (sill) { const sillW = width + sillDepth * 0.4 const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -3154,6 +3328,8 @@ function addShapedLouveredWindowVisuals(node: WindowNode, mesh: THREE.Mesh) { } function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { + currentWindowSlot = undefined + // Root mesh is an invisible hitbox; all visuals live in child meshes mesh.geometry.dispose() mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth) @@ -3170,6 +3346,12 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { mesh.remove(child) } + // Point the builder-facing frame/glass materials at this window's slot + // overrides for the duration of its build (recomputed per node, so the next + // window resets cleanly without a restore). + baseMaterial = resolveWindowSlotMaterial(node, 'frame') + glassMaterial = resolveWindowSlotMaterial(node, 'glass') + const { width, height, @@ -3263,6 +3445,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { // ── Frame members ── // Top / bottom — full width + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -3337,6 +3520,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { // Column dividers — full inner height cx = -innerW / 2 + currentWindowSlot = 'frame' for (let c = 0; c < numCols - 1; c++) { cx += colWidths[c]! addBox( @@ -3354,6 +3538,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { // Row dividers — per column width, so they don't overlap column dividers (top to bottom) cy = innerH / 2 + currentWindowSlot = 'frame' for (let r = 0; r < numRows - 1; r++) { cy -= rowHeights[r]! const divY = cy - rowDividerThickness / 2 @@ -3374,6 +3559,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { // Glass panes const glassDepth = Math.max(0.004, frameDepth * 0.08) + currentWindowSlot = 'glass' for (let c = 0; c < numCols; c++) { for (let r = 0; r < numRows; r++) { addBox( @@ -3394,6 +3580,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { const sillW = width + sillDepth * 0.4 // slightly wider than frame // Protrudes from the front face of the frame (+Z) const sillZ = frameDepth / 2 + sillDepth / 2 + currentWindowSlot = 'frame' addBox( mesh, baseMaterial, @@ -3415,6 +3602,10 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) { if (!cutout) { cutout = new THREE.Mesh() cutout.name = 'cutout' + // The cutout (a 1m-deep CSG helper, invisible) is proud of the wall, so it + // wins the scene raycast over the wall in front of the recessed window — + // making it the selection AND paint hit target for the whole opening. The + // paint capability then re-raycasts the window's parts to find the slot. mesh.add(cutout) } cutout.geometry.dispose()