Files
editor/packages/nodes/src/cupola/renderer.tsx
T
Sudhir YadavandGitHub 5551500d98 feat: HVAC ductwork + DWV plumbing systems (#402)
Adds two new MEP node families (HVAC ductwork, DWV plumbing) built on a shared port-connectivity model. Co-authored by @sudhir9297.
2026-06-16 15:30:39 -04:00

111 lines
3.7 KiB
TypeScript

'use client'
import {
type AnyNodeId,
type CupolaNode,
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 { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
import { buildCupolaGeometry } from './geometry'
const defaultMaterial = new THREE.MeshStandardMaterial({
color: 0xff_ff_ff,
roughness: 0.7,
metalness: 0.2,
})
/**
* Cupola renderer. Same transform stack as the box vent — the cupola is
* parented to a roof-segment, so this reads the segment directly and
* reproduces the segment-local transform (segment position → rotation →
* cupola position → slope tilt → cupola yaw → mesh). No animation.
*/
const CupolaRenderer = ({ node: storeNode }: { node: CupolaNode }) => {
const ref = useRef<THREE.Group>(null!)
useRegistry(storeNode.id, 'cupola', ref)
const handlers = useNodeEvents(storeNode, 'cupola')
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<CupolaNode> | undefined,
)
const node: CupolaNode = overrides ? ({ ...storeNode, ...overrides } as CupolaNode) : storeNode
const segment = useScene((state) =>
node.roofSegmentId
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
: undefined,
)
// 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(
() => buildCupolaGeometry(node),
[node.width, node.depth, node.height, node.roofStyle, node.finial],
)
useEffect(() => () => geometry.dispose(), [geometry])
const surfaceQuat = useMemo(() => {
if (!segment) return new THREE.Quaternion()
const normal = getAnalyticalNormal(node.position[0] ?? 0, node.position[2] ?? 0, segment)
return surfaceQuatFromNormal(normal, new THREE.Quaternion())
}, [segment, node.position[0], node.position[2]])
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])
const yAxis = useMemo(() => new THREE.Vector3(0, 1, 0), [])
const composedQuat = useMemo(() => {
const yawQuat = new THREE.Quaternion().setFromAxisAngle(yAxis, node.rotation ?? 0)
return new THREE.Quaternion().copy(surfaceQuat).multiply(yawQuat)
}, [surfaceQuat, node.rotation, yAxis])
if (!segment) return null
const segPos = segment.position ?? [0, 0, 0]
const segRotY = segment.rotation ?? 0
return (
<group position={segPos} rotation-y={segRotY}>
<group
position={[node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0]}
quaternion={composedQuat}
ref={ref}
visible={node.visible}
>
<mesh
castShadow
geometry={geometry}
material={material}
name="cupola-surface"
receiveShadow
{...handlers}
/>
</group>
</group>
)
}
export default CupolaRenderer