218 lines
8.4 KiB
TypeScript

'use client'
import {
type AnyNodeId,
type DownspoutNode,
type GutterNode,
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 * as THREE from 'three'
import { computeEaveY } from '../gutter/eave-snap'
import { resolveGutterOutletById } from '../gutter/outlet-lookup'
import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip'
import { buildDownspoutGeometry } from './geometry'
import { computeDownspoutRouting } from './routing'
const defaultMaterial = new THREE.MeshStandardMaterial({
color: 0xff_ff_ff,
roughness: 0.7,
metalness: 0.25,
})
/**
* Downspout renderer. Mount chain mirrors the gutter's, then nests
* one level deeper into the outlet position in gutter-mesh-local:
*
* segment.position → segment.rotation (Y)
* → [gutter.position[0], computeEaveY(segment), gutter.position[2]]
* → gutter.rotation (Y)
* → [outlet.x, outlet.y, outlet.z]
* → mesh (pipe descends from Y = 0)
*
* Pulling the gutter's eave Y from `computeEaveY(effectiveSegment)`
* means the downspout follows wallHeight / overhang / pitch changes
* live, on the same frame as the gutter. The gutter and segment also
* subscribe to `useLiveNodeOverrides` so drag-in-flight changes flow
* through too.
*/
const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => {
const ref = useRef<THREE.Group>(null!)
useRegistry(storeNode.id, 'downspout', ref)
const handlers = useNodeEvents(storeNode, 'downspout')
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)
const overrides = useLiveNodeOverrides(
(s) => s.get(storeNode.id as AnyNodeId) as Partial<DownspoutNode> | undefined,
)
const node: DownspoutNode = overrides
? ({ ...storeNode, ...overrides } as DownspoutNode)
: storeNode
// Host gutter — both scene + live overrides so drag-in-flight gutter
// moves (length / position) reposition the downspout immediately.
const gutter = useScene((s) =>
node.gutterId ? (s.nodes[node.gutterId as AnyNodeId] as GutterNode | undefined) : undefined,
)
const gutterOverrides = useLiveNodeOverrides((s) =>
node.gutterId
? (s.get(node.gutterId as AnyNodeId) as Partial<GutterNode> | undefined)
: undefined,
)
const effectiveGutter: GutterNode | undefined = gutter
? gutterOverrides
? ({ ...gutter, ...gutterOverrides } as GutterNode)
: gutter
: undefined
// Segment of the host gutter (the downspout's own scene-graph parent
// is the same segment — same as roof accessories — so the chain
// segment → gutter-mesh-local is what we need to reach the outlet).
const segment = useScene((s) =>
effectiveGutter?.roofSegmentId
? (s.nodes[effectiveGutter.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
: undefined,
)
const segmentOverrides = useLiveNodeOverrides((s) =>
effectiveGutter?.roofSegmentId
? (s.get(effectiveGutter.roofSegmentId as AnyNodeId) as Partial<RoofSegmentNode> | undefined)
: undefined,
)
const effectiveSegment: RoofSegmentNode | undefined = segment
? segmentOverrides
? ({ ...segment, ...segmentOverrides } as RoofSegmentNode)
: segment
: undefined
// Routing back to the wall — memoised on the gutter/segment values
// that actually move the jog or the collar bore, so the pipe geometry
// only rebuilds when one of those changes (not on every override-merge
// render). Resolves to null when the gutter has no outlet.
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const routing = useMemo(
() =>
effectiveGutter && effectiveSegment
? computeDownspoutRouting(effectiveGutter, effectiveSegment, node.outletId)
: null,
[
effectiveGutter?.profile,
effectiveGutter?.size,
// The outlets array — its referenced entry's diameter / offset
// drives the collar bore + nesting.
effectiveGutter ? JSON.stringify(effectiveGutter.outlets) : undefined,
effectiveSegment?.overhang,
node.outletId,
],
)
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
const geometry = useMemo(
() => buildDownspoutGeometry(node, routing),
[
node.length,
node.diameter,
node.standoff,
node.shape,
node.strapStyle,
node.strapSpacing,
node.terminal,
routing,
],
)
useEffect(() => () => geometry.dispose(), [geometry])
const material = useMemo(() => {
if (!textures || (!node.material && !node.materialPreset)) {
return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme)
}
return node.material
? createMaterial(node.material, shading)
: (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial)
}, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset])
// Map downspout-local geometry into the host segment's local frame (where the
// trim cut prisms live). Recompose the same outlet pose the inner mesh group
// is mounted with (gutter offset + rotation → outlet → eave Y). Computed
// before the early returns so the hook order stays stable.
const localToSegment = useMemo(() => {
if (!effectiveGutter || !effectiveSegment) return new THREE.Matrix4()
const outlet = resolveGutterOutletById(effectiveGutter, node.outletId)
if (!outlet) return new THREE.Matrix4()
const liveEaveY = computeEaveY(effectiveSegment)
const gutterRotY = effectiveGutter.rotation ?? 0
const gutterX = effectiveGutter.position[0] ?? 0
const gutterZ = effectiveGutter.position[2] ?? 0
const cos = Math.cos(gutterRotY)
const sin = Math.sin(gutterRotY)
const outletSegX = gutterX + (outlet.x * cos + outlet.z * sin)
const outletSegZ = gutterZ + (-outlet.x * sin + outlet.z * cos)
const outletSegY = liveEaveY + outlet.y
return new THREE.Matrix4().compose(
new THREE.Vector3(outletSegX, outletSegY, outletSegZ),
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), gutterRotY),
new THREE.Vector3(1, 1, 1),
)
}, [effectiveGutter, effectiveSegment, node.outletId])
const clippedGeometry = useSegmentTrimClippedGeometry(geometry, effectiveSegment, localToSegment)
if (!effectiveGutter || !effectiveSegment) return null
const outlet = resolveGutterOutletById(effectiveGutter, node.outletId)
if (!outlet) return null
const segPos = effectiveSegment.position ?? [0, 0, 0]
const segRotY = effectiveSegment.rotation ?? 0
const liveEaveY = computeEaveY(effectiveSegment)
const gutterRotY = effectiveGutter.rotation ?? 0
// Bake the gutter's position + Y-rotation into the registered ref so it
// sits as a DIRECT child of the segment-transform group — its local
// pose is the outlet's full segment-local placement. `NodeArrowHandles`
// copies the registered object's LOCAL transform into the segment's
// object (it assumes a flat node → scene-parent chain); with the old
// nested segment → gutter → outlet groups it only saw the innermost
// `[outlet.x …]` offset and the handles landed at the roof centre.
const gutterX = effectiveGutter.position[0] ?? 0
const gutterZ = effectiveGutter.position[2] ?? 0
const cos = Math.cos(gutterRotY)
const sin = Math.sin(gutterRotY)
const outletSegX = gutterX + (outlet.x * cos + outlet.z * sin)
const outletSegZ = gutterZ + (-outlet.x * sin + outlet.z * cos)
const outletSegY = liveEaveY + outlet.y
return (
<group position={segPos} rotation-y={segRotY}>
<group
position={[outletSegX, outletSegY, outletSegZ]}
ref={ref}
rotation-y={gutterRotY}
visible={node.visible}
>
<mesh
castShadow
geometry={clippedGeometry ?? geometry}
material={material}
name="downspout-surface"
receiveShadow
{...handlers}
/>
</group>
</group>
)
}
export default DownspoutRenderer