Files
editor/packages/nodes/src/dormer/renderer.tsx
T
c3199e7540 editor: green/red placement box for shelf + tool sync for presets (#366)
* feat(editor): shelf placement validity box + sync tool for positioned presets

Give the generic move tool a green/red footprint box for shelf placement,
matching the GLB item cursor, and drop the vertical-arrow CursorSphere for
shelves. Box colour comes from canPlaceOnFloor; an invalid (red) drop is
refused unless Shift forces it, and R/T play the rotate sfx. Re-sync the box
transform on node change so a re-armed clone isn't left at the previous
rotation/position.

Extract the box wireframe geometry helpers into a shared
placement-box-geometry module reused by the item coordinator and the new
declarative PlacementBox component. Export the Tool type so host apps can set
the active tool for a positioned preset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: apply biome formatting + ignore generated next-env.d.ts

Format files that predate the current biome config (lineWidth 100, single
quotes, semicolons as-needed) so they stop showing as dirty on every checkout.
Formatting only — no behavior change.

Also exclude **/next-env.d.ts from biome: Next regenerates it (double quotes +
semicolon) on every build, so biome kept reformatting it into a perpetual dirty
diff. Ignoring it lets Next own the file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:52:57 -04:00

189 lines
6.3 KiB
TypeScript

'use client'
import {
type AnyNodeId,
type DormerNode,
getEffectiveDormerSurfaceMaterial,
type RoofSegmentNode,
useLiveNodeOverrides,
useRegistry,
useScene,
} from '@pascal-app/core'
import {
type ColorPreset,
createMaterial,
createMaterialFromPresetRef,
createSurfaceRoleMaterial,
useNodeEvents,
useViewer,
} from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import type * as THREE from 'three'
import {
buildDormerFallbackGeometry,
DORMER_GABLE_MATERIAL_INDEX,
generateDormerGeometry,
} from './csg-geometry'
import DormerWindowAssembly from './window-assembly'
const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
const ref = useRef<THREE.Group>(null!)
useRegistry(storeNode.id, 'dormer', ref)
const handlers = useNodeEvents(storeNode, 'dormer')
// Live overrides so slider drag updates the dormer without committing
// to the store. While any override is live we render the cheap
// fallback silhouette — running the full CSG on every pointer move is
// far too expensive (multiple boolean ops + ground subtract +
// 32-segment arch curves). Commit clears the override and the real
// CSG mesh kicks back in.
const liveOverrides = useLiveNodeOverrides((state) => state.get(storeNode.id as AnyNodeId))
const isLiveDrag = !!liveOverrides && Object.keys(liveOverrides).length > 0
const node = useMemo(
() => (liveOverrides ? ({ ...storeNode, ...liveOverrides } as DormerNode) : storeNode),
[storeNode, liveOverrides],
)
const segment = useScene((state) =>
node.roofSegmentId
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
: undefined,
)
const shading = useViewer((s) => s.shading)
const textures = useViewer((s) => s.textures)
const colorPreset: ColorPreset = useViewer((s) => s.colorPreset)
const sceneTheme = useViewer((s) => s.sceneTheme)
// Geometry material slots: 0=Wall, 1=Deck/side, 2=Interior, 3=Roof
// shingle, 4=Gable wall. Walls take the 'wall' role, the deck side and
// shingle take 'roof'. When textures are off, every slot snaps to its
// role colour regardless of explicit paint (the render-modes invariant).
const material = useMemo(() => {
const wallRole = () => createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme)
const roofRole = () => createSurfaceRoleMaterial('roof', colorPreset, undefined, sceneTheme)
const top = getEffectiveDormerSurfaceMaterial(node, 'top')
const side = getEffectiveDormerSurfaceMaterial(node, 'side')
const wall = getEffectiveDormerSurfaceMaterial(node, 'wall')
const resolve = (
spec: { material?: DormerNode['material']; materialPreset?: string },
role: () => THREE.Material,
) => {
if (!textures) return role()
if (spec.materialPreset)
return createMaterialFromPresetRef(spec.materialPreset, shading) ?? role()
if (spec.material) return createMaterial(spec.material, shading)
return role()
}
const w = resolve(wall, wallRole)
const s = resolve(side, roofRole)
const t = resolve(top, roofRole)
return [w, s, w, t, w] as THREE.Material[]
}, [
textures,
colorPreset,
sceneTheme,
shading,
node.material,
node.materialPreset,
node.topMaterial,
node.topMaterialPreset,
node.sideMaterial,
node.sideMaterialPreset,
node.wallMaterial,
node.wallMaterialPreset,
])
// The window frame bars / sill take the 'joinery' role when untextured;
// otherwise the deck-side material (slot 1) drives the frame look.
const frameSideMat = useMemo(() => {
if (!textures) return createSurfaceRoleMaterial('joinery', colorPreset, undefined, sceneTheme)
return material[1]!
}, [textures, colorPreset, sceneTheme, material])
// Dormer window glass has no per-node material — it always takes the
// themed 'glazing' role (semi-transparent) in both texture modes.
const glassMat = useMemo(
() => createSurfaceRoleMaterial('glazing', colorPreset, undefined, sceneTheme),
[colorPreset, sceneTheme],
)
const geometry = useMemo(() => {
if (!segment) return null
if (isLiveDrag) return buildDormerFallbackGeometry(node)
return generateDormerGeometry(node, segment)
}, [
isLiveDrag,
segment,
node.id,
node.roofType,
node.width,
node.depth,
node.height,
node.roofHeight,
node.wallSkirtHeight,
node.position[0],
node.position[1],
node.position[2],
node.rotation,
node.windowWidth,
node.windowHeight,
node.windowOffsetX,
node.windowOffsetY,
node.windowShape,
node.windowArchHeight,
node.windowCornerRadii[0],
node.windowCornerRadii[1],
node.windowCornerRadii[2],
node.windowCornerRadii[3],
])
useEffect(() => () => geometry?.dispose(), [geometry])
if (!(segment && geometry)) return null
// Dormers are mounted inside `RoofRenderer`'s `roof-elements` group
// (at the roof origin — NOT inside the host segment's transform), so
// we apply the segment's own position + rotation here. Mirrors how
// chimney / skylight render. The CSG geometry is built in
// dormer-mesh-local with `dormer.position` + `dormer.rotation`
// already accounted for by `segToMesh`, so we layer them as group
// transforms here too.
//
// The registered ref sits on the inner group that applies the
// dormer's own position + rotation so the registered Object3D's
// local frame is *dormer-local* — that's what `NodeArrowHandles`
// reads to place its chevrons. Mirrors chimney's structure.
return (
<group
position={segment.position}
rotation-y={segment.rotation ?? 0}
visible={node.visible}
{...handlers}
>
<group
position={[node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0]}
ref={ref}
rotation-y={node.rotation ?? 0}
>
<mesh castShadow geometry={geometry} material={material} name="dormer-body" receiveShadow />
<DormerWindowAssembly
frameMaterial={frameSideMat}
glassMaterial={glassMat}
node={node}
segment={segment}
/>
</group>
</group>
)
}
// Re-export so consumers (e.g. tests) can reach the gable slot index
// without importing from `@pascal-app/viewer` directly.
export { DORMER_GABLE_MATERIAL_INDEX }
export default DormerRenderer