feat(paint-slots): paintable slots for windows + doors (frame/glass, panel/glass)
Windows and doors build all visuals in their viewer systems from module-global materials, so this threads per-node slot materials + userData.slotId tags through those builders without restructuring them: - window: 'frame' + 'glass' slots. door: 'panel' (body = casing + leaf) + 'glass'; the opening reveal keeps its own material. - Each system captures per-frame viewer state, then updateWindow/DoorMesh points the builder-facing base/glass materials at the node's resolved slot override (recomputed per node, so the next node resets without a restore). Meshes are auto-tagged in the shared addBox/addShape helpers by which material they got. - Textures-off still collapses to the role material (escape hatch); a slot override only applies in colored mode. - Editing a referenced scene material re-dirties the window/door (these systems aren't covered by GeometrySystem's scene-material re-dirty). - New paint capabilities (resolve role from userData.slotId, preview by userData.slotId) + capabilities.slots; window/door dropped from the paint disabled list. Shared previewSlotByUserData helper. Defaults unchanged: unpainted windows/doors render exactly as before (the slot fallback is the existing frame/glass material), so no visual regression. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
737c4e9d1d
commit
9f1627e923
@@ -41,6 +41,10 @@ export const DoorNode = BaseNode.extend({
|
|||||||
id: objectId('door'),
|
id: objectId('door'),
|
||||||
type: nodeType('door'),
|
type: nodeType('door'),
|
||||||
material: MaterialSchema.optional(),
|
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]),
|
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]),
|
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export const WindowNode = BaseNode.extend({
|
|||||||
id: objectId('window'),
|
id: objectId('window'),
|
||||||
type: nodeType('window'),
|
type: nodeType('window'),
|
||||||
material: MaterialSchema.optional(),
|
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]),
|
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]),
|
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
|||||||
@@ -1088,7 +1088,7 @@ export const SelectionManager = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const disabledNodeTypes = ['window', 'door', 'zone']
|
const disabledNodeTypes = ['zone']
|
||||||
if (disabledNodeTypes.includes(node.type)) {
|
if (disabledNodeTypes.includes(node.type)) {
|
||||||
return {
|
return {
|
||||||
key: `${node.type}:${node.id}:unsupported`,
|
key: `${node.type}:${node.id}:unsupported`,
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import { scaleHandleHeight } from './door-math'
|
|||||||
import { buildDoorFloorplan } from './floorplan'
|
import { buildDoorFloorplan } from './floorplan'
|
||||||
import { doorWidthAffordance } from './floorplan-affordances'
|
import { doorWidthAffordance } from './floorplan-affordances'
|
||||||
import { doorFloorplanMoveTarget } from './floorplan-move'
|
import { doorFloorplanMoveTarget } from './floorplan-move'
|
||||||
|
import { doorPaint } from './paint'
|
||||||
import { doorParametrics } from './parametrics'
|
import { doorParametrics } from './parametrics'
|
||||||
import { DoorNode } from './schema'
|
import { DoorNode } from './schema'
|
||||||
|
import { doorSlots } from './slots'
|
||||||
|
|
||||||
const SIDE_HANDLE_OFFSET = 0.24
|
const SIDE_HANDLE_OFFSET = 0.24
|
||||||
const HEIGHT_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
|
// placed. Host apps strip these at preset-save time via
|
||||||
// `getHostRefFields(def)`.
|
// `getHostRefFields(def)`.
|
||||||
hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'],
|
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,
|
parametrics: doorParametrics,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { PaintResolveArgs } from '@pascal-app/core'
|
||||||
|
import { createSlotPaintCapability, previewSlotByUserData } from '../shared/slot-paint'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Door paint on the unified slot model. The door's viewer system tags each built
|
||||||
|
* mesh with `userData.slotId` (`panel` / `glass`), so the role resolves straight
|
||||||
|
* from the pointer hit; commit writes `node.slots[slotId]`.
|
||||||
|
*/
|
||||||
|
function resolveDoorRole(args: PaintResolveArgs): string | null {
|
||||||
|
const slotId = (args.hitObject?.userData as { slotId?: string | null } | undefined)?.slotId
|
||||||
|
return typeof slotId === 'string' ? slotId : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const doorPaint = createSlotPaintCapability({
|
||||||
|
resolveRole: resolveDoorRole,
|
||||||
|
applyPreview: previewSlotByUserData,
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { SlotDeclaration } from '@pascal-app/core'
|
||||||
|
|
||||||
|
export type DoorSlotId = 'panel' | 'glass'
|
||||||
|
|
||||||
|
// Picker swatches. Rendering falls back to the live body/glass defaults (which
|
||||||
|
// already track shading + theme), so these are just the indicator colours.
|
||||||
|
const PANEL_DEFAULT = '#f2f0ed'
|
||||||
|
const GLASS_DEFAULT = '#87ceeb'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A door exposes two paintable slots: `panel` (the door body — frame casing +
|
||||||
|
* leaf) and `glass`. The opening reveal keeps its own material.
|
||||||
|
*/
|
||||||
|
export function doorSlots(): SlotDeclaration[] {
|
||||||
|
return [
|
||||||
|
{ slotId: 'panel', label: 'Panel', default: PANEL_DEFAULT },
|
||||||
|
{ slotId: 'glass', label: 'Glass', default: GLASS_DEFAULT },
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -169,6 +169,34 @@ export function previewGeometrySlot(args: PaintPreviewArgs): (() => void) | null
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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]?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type SlotPaintConfig = {
|
export type SlotPaintConfig = {
|
||||||
/** Resolve the slot id for a pointer hit (`null` = not paintable here). */
|
/** Resolve the slot id for a pointer hit (`null` = not paintable here). */
|
||||||
resolveRole: (args: PaintResolveArgs) => string | null
|
resolveRole: (args: PaintResolveArgs) => string | null
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
|
|||||||
import { buildWindowFloorplan } from './floorplan'
|
import { buildWindowFloorplan } from './floorplan'
|
||||||
import { windowWidthAffordance } from './floorplan-affordances'
|
import { windowWidthAffordance } from './floorplan-affordances'
|
||||||
import { windowFloorplanMoveTarget } from './floorplan-move'
|
import { windowFloorplanMoveTarget } from './floorplan-move'
|
||||||
|
import { windowPaint } from './paint'
|
||||||
import { windowParametrics } from './parametrics'
|
import { windowParametrics } from './parametrics'
|
||||||
import { WindowNode } from './schema'
|
import { WindowNode } from './schema'
|
||||||
|
import { windowSlots } from './slots'
|
||||||
|
|
||||||
const SIDE_HANDLE_OFFSET = 0.24
|
const SIDE_HANDLE_OFFSET = 0.24
|
||||||
const HEIGHT_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
|
// `wallId` / `roofSegmentId` are re-derived from the surface under
|
||||||
// the cursor at preset placement time — see door for the pattern.
|
// the cursor at preset placement time — see door for the pattern.
|
||||||
hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'],
|
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,
|
parametrics: windowParametrics,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { PaintResolveArgs } from '@pascal-app/core'
|
||||||
|
import { createSlotPaintCapability, previewSlotByUserData } from '../shared/slot-paint'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Window paint on the unified slot model. The window's viewer system tags each
|
||||||
|
* built mesh with `userData.slotId` (`frame` / `glass`), so the role resolves
|
||||||
|
* straight from the pointer hit; commit writes `node.slots[slotId]`.
|
||||||
|
*/
|
||||||
|
function resolveWindowRole(args: PaintResolveArgs): string | null {
|
||||||
|
const slotId = (args.hitObject?.userData as { slotId?: string | null } | undefined)?.slotId
|
||||||
|
return typeof slotId === 'string' ? slotId : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const windowPaint = createSlotPaintCapability({
|
||||||
|
resolveRole: resolveWindowRole,
|
||||||
|
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 = '#f2f0ed'
|
||||||
|
const GLASS_DEFAULT = '#87ceeb'
|
||||||
|
|
||||||
|
/** 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 },
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
DoorNode as DoorNodeSchema,
|
DoorNode as DoorNodeSchema,
|
||||||
getDoorRenderOpenAmount,
|
getDoorRenderOpenAmount,
|
||||||
getEffectiveNode,
|
getEffectiveNode,
|
||||||
|
type SceneMaterial,
|
||||||
|
type SceneMaterialId,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useInteractive,
|
useInteractive,
|
||||||
useLiveNodeOverrides,
|
useLiveNodeOverrides,
|
||||||
@@ -14,9 +16,12 @@ import { useFrame } from '@react-three/fiber'
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import {
|
import {
|
||||||
|
type ColorPreset,
|
||||||
createSurfaceRoleMaterial,
|
createSurfaceRoleMaterial,
|
||||||
glassMaterial as defaultGlassMaterial,
|
glassMaterial as defaultGlassMaterial,
|
||||||
baseMaterial as getBaseMaterial,
|
baseMaterial as getBaseMaterial,
|
||||||
|
type RenderShading,
|
||||||
|
resolveMaterialRef,
|
||||||
} from '../../lib/materials'
|
} from '../../lib/materials'
|
||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
|
|
||||||
@@ -26,6 +31,12 @@ const defaultRevealMaterial = new THREE.MeshBasicMaterial({ color: '#7f766c' })
|
|||||||
let baseMaterial = getBaseMaterial()
|
let baseMaterial = getBaseMaterial()
|
||||||
let revealMaterial: THREE.Material = defaultRevealMaterial
|
let revealMaterial: THREE.Material = defaultRevealMaterial
|
||||||
let glassMaterial: THREE.Material = defaultGlassMaterial
|
let glassMaterial: THREE.Material = defaultGlassMaterial
|
||||||
|
// 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 DOOR_RENDER_DEFAULTS = DoorNodeSchema.parse({ id: 'door_render_default' })
|
||||||
const MAX_DOOR_REBUILDS_PER_FRAME = 16
|
const MAX_DOOR_REBUILDS_PER_FRAME = 16
|
||||||
@@ -50,6 +61,7 @@ export const DoorSystem = () => {
|
|||||||
const shading = useViewer((state) => state.shading)
|
const shading = useViewer((state) => state.shading)
|
||||||
const textures = useViewer((state) => state.textures)
|
const textures = useViewer((state) => state.textures)
|
||||||
const colorPreset = useViewer((state) => state.colorPreset)
|
const colorPreset = useViewer((state) => state.colorPreset)
|
||||||
|
const sceneMaterials = useScene((state) => state.materials)
|
||||||
const materialRevisionRef = useRef<string | null>(null)
|
const materialRevisionRef = useRef<string | null>(null)
|
||||||
// Subscribe so an override-only update (no scene write) still re-runs
|
// Subscribe so an override-only update (no scene write) still re-runs
|
||||||
// the component, letting the gate below pick up the latest dirtyNodes
|
// the component, letting the gate below pick up the latest dirtyNodes
|
||||||
@@ -75,12 +87,27 @@ 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(() => {
|
useFrame(() => {
|
||||||
if (dirtyNodes.size === 0) return
|
if (dirtyNodes.size === 0) return
|
||||||
const frameJoineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset)
|
const frameJoineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset)
|
||||||
baseMaterial = textures ? getBaseMaterial(shading) : frameJoineryMaterial
|
baseMaterial = textures ? getBaseMaterial(shading) : frameJoineryMaterial
|
||||||
revealMaterial = textures ? defaultRevealMaterial : frameJoineryMaterial
|
revealMaterial = textures ? defaultRevealMaterial : frameJoineryMaterial
|
||||||
glassMaterial = textures ? defaultGlassMaterial : frameJoineryMaterial
|
glassMaterial = textures ? defaultGlassMaterial : frameJoineryMaterial
|
||||||
|
currentShading = shading
|
||||||
|
currentTextures = textures
|
||||||
|
currentColorPreset = colorPreset
|
||||||
|
currentSceneMaterials = sceneMaterials
|
||||||
|
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
const dirtyDoorIds: AnyNodeId[] = []
|
const dirtyDoorIds: AnyNodeId[] = []
|
||||||
@@ -134,6 +161,40 @@ export const DoorSystem = () => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A door exposes two slots: `panel` (the door body — frame casing + leaf, all
|
||||||
|
// built with `baseMaterial`) and `glass`. The reveal (opening depth) is left on
|
||||||
|
// its own material and isn't painted. Tag each mesh by which material it got.
|
||||||
|
function tagDoorSlot(mesh: THREE.Mesh): THREE.Mesh {
|
||||||
|
if (mesh.material === glassMaterial) mesh.userData.slotId = 'glass'
|
||||||
|
else if (mesh.material === baseMaterial) mesh.userData.slotId = 'panel'
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function doorSlotDefault(slotId: 'panel' | 'glass'): THREE.Material {
|
||||||
|
if (!currentTextures) return createSurfaceRoleMaterial('joinery', currentColorPreset)
|
||||||
|
return slotId === 'glass' ? defaultGlassMaterial : getBaseMaterial(currentShading)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a door's slot to a material: the `node.slots` override (colored mode
|
||||||
|
// only) → the body/glass default. Textures-off ignores overrides — the
|
||||||
|
// monochrome escape hatch.
|
||||||
|
function resolveDoorSlotMaterial(node: DoorNode, slotId: 'panel' | 'glass'): 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(
|
function addBox(
|
||||||
parent: THREE.Object3D,
|
parent: THREE.Object3D,
|
||||||
material: THREE.Material,
|
material: THREE.Material,
|
||||||
@@ -146,6 +207,7 @@ function addBox(
|
|||||||
) {
|
) {
|
||||||
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)
|
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)
|
||||||
m.position.set(x, y, z)
|
m.position.set(x, y, z)
|
||||||
|
tagDoorSlot(m)
|
||||||
parent.add(m)
|
parent.add(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +225,7 @@ function addRotatedBox(
|
|||||||
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)
|
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)
|
||||||
m.position.set(x, y, z)
|
m.position.set(x, y, z)
|
||||||
m.rotation.y = rotationY
|
m.rotation.y = rotationY
|
||||||
|
tagDoorSlot(m)
|
||||||
parent.add(m)
|
parent.add(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +243,7 @@ function addBoxWithRotation(
|
|||||||
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)
|
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)
|
||||||
m.position.set(x, y, z)
|
m.position.set(x, y, z)
|
||||||
m.rotation.set(rotation[0], rotation[1], rotation[2])
|
m.rotation.set(rotation[0], rotation[1], rotation[2])
|
||||||
|
tagDoorSlot(m)
|
||||||
parent.add(m)
|
parent.add(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +260,7 @@ function addShape(
|
|||||||
})
|
})
|
||||||
geometry.translate(0, 0, -depth / 2)
|
geometry.translate(0, 0, -depth / 2)
|
||||||
const mesh = new THREE.Mesh(geometry, material)
|
const mesh = new THREE.Mesh(geometry, material)
|
||||||
|
tagDoorSlot(mesh)
|
||||||
parent.add(mesh)
|
parent.add(mesh)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +280,7 @@ function addShapeAt(
|
|||||||
})
|
})
|
||||||
geometry.translate(x, y, z - depth / 2)
|
geometry.translate(x, y, z - depth / 2)
|
||||||
const mesh = new THREE.Mesh(geometry, material)
|
const mesh = new THREE.Mesh(geometry, material)
|
||||||
|
tagDoorSlot(mesh)
|
||||||
parent.add(mesh)
|
parent.add(mesh)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1968,6 +2034,12 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
|
|||||||
mesh.remove(child)
|
mesh.remove(child)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Point the builder-facing body/glass materials at this door's slot overrides
|
||||||
|
// 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')
|
||||||
|
glassMaterial = resolveDoorSlotMaterial(node, 'glass')
|
||||||
|
|
||||||
const {
|
const {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
getEffectiveNode,
|
getEffectiveNode,
|
||||||
|
type SceneMaterial,
|
||||||
|
type SceneMaterialId,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useInteractive,
|
useInteractive,
|
||||||
useLiveNodeOverrides,
|
useLiveNodeOverrides,
|
||||||
@@ -11,9 +13,12 @@ import { useFrame } from '@react-three/fiber'
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import {
|
import {
|
||||||
|
type ColorPreset,
|
||||||
createSurfaceRoleMaterial,
|
createSurfaceRoleMaterial,
|
||||||
glassMaterial as defaultGlassMaterial,
|
glassMaterial as defaultGlassMaterial,
|
||||||
baseMaterial as getBaseMaterial,
|
baseMaterial as getBaseMaterial,
|
||||||
|
type RenderShading,
|
||||||
|
resolveMaterialRef,
|
||||||
} from '../../lib/materials'
|
} from '../../lib/materials'
|
||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
|
|
||||||
@@ -21,6 +26,12 @@ import useViewer from '../../store/use-viewer'
|
|||||||
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
|
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
|
||||||
let baseMaterial = getBaseMaterial()
|
let baseMaterial = getBaseMaterial()
|
||||||
let glassMaterial: THREE.Material = defaultGlassMaterial
|
let glassMaterial: THREE.Material = defaultGlassMaterial
|
||||||
|
// 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 CASEMENT_WINDOW_SASH_NAME = 'casement-window-sash'
|
||||||
export const FRENCH_CASEMENT_LEFT_SASH_NAME = 'french-casement-left-sash'
|
export const FRENCH_CASEMENT_LEFT_SASH_NAME = 'french-casement-left-sash'
|
||||||
export const FRENCH_CASEMENT_RIGHT_SASH_NAME = 'french-casement-right-sash'
|
export const FRENCH_CASEMENT_RIGHT_SASH_NAME = 'french-casement-right-sash'
|
||||||
@@ -42,6 +53,7 @@ export const WindowSystem = () => {
|
|||||||
const shading = useViewer((state) => state.shading)
|
const shading = useViewer((state) => state.shading)
|
||||||
const textures = useViewer((state) => state.textures)
|
const textures = useViewer((state) => state.textures)
|
||||||
const colorPreset = useViewer((state) => state.colorPreset)
|
const colorPreset = useViewer((state) => state.colorPreset)
|
||||||
|
const sceneMaterials = useScene((state) => state.materials)
|
||||||
const materialRevisionRef = useRef<string | null>(null)
|
const materialRevisionRef = useRef<string | null>(null)
|
||||||
// Subscribe so override-only updates re-run this component. Mirrors
|
// Subscribe so override-only updates re-run this component. Mirrors
|
||||||
// WallSystem + DoorSystem.
|
// WallSystem + DoorSystem.
|
||||||
@@ -67,6 +79,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(() => {
|
useFrame(() => {
|
||||||
if (dirtyNodes.size === 0) return
|
if (dirtyNodes.size === 0) return
|
||||||
baseMaterial = textures
|
baseMaterial = textures
|
||||||
@@ -75,6 +99,10 @@ export const WindowSystem = () => {
|
|||||||
glassMaterial = textures
|
glassMaterial = textures
|
||||||
? defaultGlassMaterial
|
? defaultGlassMaterial
|
||||||
: createSurfaceRoleMaterial('glazing', colorPreset)
|
: createSurfaceRoleMaterial('glazing', colorPreset)
|
||||||
|
currentShading = shading
|
||||||
|
currentTextures = textures
|
||||||
|
currentColorPreset = colorPreset
|
||||||
|
currentSceneMaterials = sceneMaterials
|
||||||
|
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
const dirtyWindowIds: AnyNodeId[] = []
|
const dirtyWindowIds: AnyNodeId[] = []
|
||||||
@@ -128,6 +156,46 @@ export const WindowSystem = () => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A window exposes two slots: `frame` (every joinery member) and `glass`. The
|
||||||
|
// builders pass `baseMaterial` / `glassMaterial`, so tag each mesh by which one
|
||||||
|
// it got — that's what `(nodeId, slotId)` paint resolves against.
|
||||||
|
function tagWindowSlot(mesh: THREE.Mesh): THREE.Mesh {
|
||||||
|
if (mesh.material === glassMaterial) mesh.userData.slotId = 'glass'
|
||||||
|
else if (mesh.material === baseMaterial) mesh.userData.slotId = 'frame'
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowSlotDefault(slotId: 'frame' | 'glass'): THREE.Material {
|
||||||
|
if (slotId === 'glass') {
|
||||||
|
return currentTextures
|
||||||
|
? defaultGlassMaterial
|
||||||
|
: createSurfaceRoleMaterial('glazing', currentColorPreset)
|
||||||
|
}
|
||||||
|
return currentTextures
|
||||||
|
? getBaseMaterial(currentShading)
|
||||||
|
: createSurfaceRoleMaterial('joinery', currentColorPreset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(
|
function addBox(
|
||||||
parent: THREE.Object3D,
|
parent: THREE.Object3D,
|
||||||
material: THREE.Material,
|
material: THREE.Material,
|
||||||
@@ -140,6 +208,7 @@ function addBox(
|
|||||||
) {
|
) {
|
||||||
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)
|
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)
|
||||||
m.position.set(x, y, z)
|
m.position.set(x, y, z)
|
||||||
|
tagWindowSlot(m)
|
||||||
parent.add(m)
|
parent.add(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +226,7 @@ function addShape(
|
|||||||
})
|
})
|
||||||
geometry.translate(0, 0, -depth / 2 + z)
|
geometry.translate(0, 0, -depth / 2 + z)
|
||||||
const mesh = new THREE.Mesh(geometry, material)
|
const mesh = new THREE.Mesh(geometry, material)
|
||||||
|
tagWindowSlot(mesh)
|
||||||
parent.add(mesh)
|
parent.add(mesh)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3170,6 +3240,12 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
|
|||||||
mesh.remove(child)
|
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 {
|
const {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
|||||||
Reference in New Issue
Block a user