* 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>
189 lines
6.3 KiB
TypeScript
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
|