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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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 }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
const color = props.color || '#999999'
|
||||
return getCeilingMaterials(color)
|
||||
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,
|
||||
|
||||
@@ -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 }]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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 },
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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,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: {
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user