Merge pull request #417 from pascalorg/feat/paint-slots-node-defaults

feat(paint-slots): unified slot defaults + paint for slab, ceiling, wall (phase 5)
This commit is contained in:
Wassim SAMAD
2026-06-17 14:49:51 -04:00
committed by GitHub
42 changed files with 1373 additions and 147 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

+114 -4
View File
@@ -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,
+9 -1
View File
@@ -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 = {
+1
View File
@@ -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'
@@ -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:<id>` / `scene:<id>`). 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([]),
+4
View File
@@ -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:<id>` / `scene:<id>`).
// 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]),
+4
View File
@@ -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:<id>` / `scene:<id>`). 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([]),
+10
View File
@@ -46,6 +46,16 @@ export type WallNode = z.infer<typeof WallNode>
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:<id>` ref. Textures-off still
// collapses to the themed wall role (the escape hatch).
export const WALL_SLOT_DEFAULT: Record<WallSurfaceSide, string> = {
interior: 'library:concrete-plate',
exterior: 'library:concrete-plate',
}
export type WallSurfaceMaterialSpec = {
material?: z.infer<typeof MaterialSchema>
materialPreset?: string
+4
View File
@@ -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:<id>` / `scene:<id>`). 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]),
@@ -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 (
<>
<SceneEnvironment />
{!(isFirstPersonMode || isStudioMode) && <SelectionManager />}
{!noEditing && <BoxSelectTool />}
{!noEditing && <NodeArrowHandles />}
@@ -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)
@@ -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'
) {
+6
View File
@@ -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<typeof CeilingNode> = {
},
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: {
+80
View File
@@ -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<string, CeilingMaterials>()
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<SceneMaterialId, SceneMaterial> | 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
}
+42
View File
@@ -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
},
})
+31 -57
View File
@@ -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<string, ReturnType<typeof createCeilingMaterials>>()
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<Mesh>(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,
+12
View File
@@ -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 }]
}
+6
View File
@@ -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<typeof DoorNode> = {
// 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,
+16
View File
@@ -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,
})
+25
View File
@@ -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 },
]
}
+264
View File
@@ -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<slotId, MaterialRef>` + 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<string, string> }
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<string, unknown>
const bRecord = b as Record<string, unknown>
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<SceneMaterialId, SceneMaterial>,
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<AnyNode>)
}
/** 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<AnyNode>
},
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
},
}
}
+6
View File
@@ -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<typeof SlabNode> = {
},
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: {
+43 -18
View File
@@ -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)
+19
View File
@@ -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
},
})
+13
View File
@@ -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:<id>` 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 }]
}
+6
View File
@@ -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<typeof WallNode> = {
// 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: {
+17
View File
@@ -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 },
]
}
+6
View File
@@ -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<typeof WindowNode> = {
// `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,
+16
View File
@@ -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,
})
+16
View File
@@ -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 },
]
}
@@ -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,
@@ -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
* <Viewer> *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 (
<Suspense fallback={null}>
<Environment preset="sunset" environmentIntensity={0.6} />
</Suspense>
)
}
+2
View File
@@ -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'
+40
View File
@@ -0,0 +1,40 @@
import type { BoxGeometry } from 'three'
/**
* Rewrite a default `BoxGeometry`'s UVs to world scale — 1 UV unit = 1 metre —
* so tiled finishes (with `repeat` in tiles-per-metre) render at a consistent
* real-world scale instead of stretching to fit each face. Matches the
* world-scale UV convention used by the procedural slab/wall geometry.
*
* three.js builds box faces in the fixed order [+X, -X, +Y, -Y, +Z, -Z], four
* verts each, with UVs spanning 0→1 across the face. Each face's two in-plane
* dimensions differ, so we scale U/V per face by that face's size in metres.
*/
export function applyWorldScaleBoxUVs(
geometry: BoxGeometry,
w: number,
h: number,
d: number,
): void {
const uv = geometry.getAttribute('uv')
if (!uv || uv.count < 24) return // non-default segmentation — leave as-is
// [uScaleMetres, vScaleMetres] per face, in three's face order.
const faceScale: Array<[number, number]> = [
[d, h], // +X
[d, h], // -X
[w, d], // +Y
[w, d], // -Y
[w, h], // +Z
[w, h], // -Z
]
for (let face = 0; face < 6; face += 1) {
const [us, vs] = faceScale[face]!
for (let v = 0; v < 4; v += 1) {
const i = face * 4 + v
uv.setXY(i, uv.getX(i) * us, uv.getY(i) * vs)
}
}
uv.needsUpdate = true
}
+20
View File
@@ -516,6 +516,26 @@ export function resolveMaterialRef(
return createMaterial(sceneMaterial.material, shading)
}
/**
* Resolve a node kind's declared slot default — either a catalog `library:<id>`
* 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,
+281 -47
View File
@@ -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<SceneMaterialId, SceneMaterial> | 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<string | null>(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 <GeometrySystem>).
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<string, string> }): 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()
@@ -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]
@@ -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<SceneMaterialId, SceneMaterial> | 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<string | null>(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 <GeometrySystem>, 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<string, string> }): 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()