Files
editor/packages/nodes/src/chimney/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

288 lines
10 KiB
TypeScript

'use client'
import {
type AnyNodeId,
type ChimneyNode,
type RoofSegmentNode,
useLiveNodeOverrides,
useRegistry,
useScene,
} from '@pascal-app/core'
import {
type ColorPreset,
createMaterial,
createMaterialFromPresetRef,
createSurfaceRoleMaterial,
getRoofSegmentBrushes,
useNodeEvents,
useViewer,
} from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { buildChimneyGeometry } from './geometry'
import { carveChimneyHoles } from './holes'
import { trimChimneyBodyAgainstRoof } from './roof-trim'
/**
* Chimney renderer. Reads the parent roof-segment so the body height
* is derived from `segment.wallHeight + roofHeight + node.heightAboveRidge`.
*
* **Option C scope**: chimney is rendered as solid geometry that
* intersects the roof at the deck line. The decorative CSG-driven
* features (cap flue holes, body cavity, panels, bands) are not
* rendered in this port — they remain as no-op fields in the schema
* until the roof-segment Stage B migration introduces a `roofCutout`
* capability the parent can read.
*/
const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => {
const ref = useRef<THREE.Group>(null!)
useRegistry(storeNode.id, 'chimney', ref)
const handlers = useNodeEvents(storeNode, 'chimney')
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)
// Merge in-flight slider drags from `useLiveNodeOverrides` so the mesh
// updates while the user is still holding the slider. On release the
// panel commits to the store and clears the override.
const overrides = useLiveNodeOverrides(
(state) => state.get(storeNode.id as AnyNodeId) as Partial<ChimneyNode> | undefined,
)
const node = useMemo<ChimneyNode>(
() => (overrides ? { ...storeNode, ...overrides } : storeNode),
[storeNode, overrides],
)
const segment = useScene((state) =>
node.roofSegmentId
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
: undefined,
)
// Geometry + carved CSG depend on the chimney's full schema and the
// host segment's shape. Both come in as memoised references — `node`
// only re-references when the store node or a live-override actually
// changes, `segment` only when the segment's own data changes — so a
// two-entry dep array is equivalent to enumerating every field, and
// adding a new schema field doesn't risk stale geometry from a
// forgotten dep.
const geo = useMemo(() => {
if (!segment) return null
const raw = buildChimneyGeometry(node, segment)
// Carve the smoke shaft (body cavity), cap holes, and hollow flue
// bores. Matches the v1 roof-system visual.
const carved = carveChimneyHoles(raw.body, raw.cap, raw.flues, node, segment)
return { ...raw, body: carved.body, cap: carved.cap, flues: carved.flues }
}, [node, segment])
// Segment brushes for the body trim. Building these is non-trivial
// (4 CSG-ready Brush instances per segment). `segment` comes from a
// `useScene` selector, so it only re-identifies when the segment's own
// data changes — depend on it directly (as the `geo` memo above does)
// and the brushes rebuild exactly when the host roof reshapes, incl.
// the gambrel / mansard / dutch-hip width-ratio fields that
// `getRoofSegmentBrushes` reads. A chimney slider drag changes `node`,
// not `segment`, so the cache still survives the drag. Enumerating
// individual fields here previously omitted those ratios and left the
// trim CSG-ing against a stale roof outline.
const segmentBrushes = useMemo(() => (segment ? getRoofSegmentBrushes(segment) : null), [segment])
useEffect(
() => () => {
if (segmentBrushes) {
segmentBrushes.deckSlab.geometry.dispose()
segmentBrushes.shinSlab.geometry.dispose()
segmentBrushes.wallBrush.geometry.dispose()
segmentBrushes.innerBrush.geometry.dispose()
}
},
[segmentBrushes],
)
// CSG-trim the body against the parent roof segment so the portion
// passing through the wall and shingles is hidden. Returns the
// original body geometry on any CSG failure (logged via console.error).
const trimmedBody = useMemo(() => {
if (!geo || !segment || !segmentBrushes) return null
return trimChimneyBodyAgainstRoof(geo.body, segment, node, segmentBrushes)
}, [geo, segment, node, segmentBrushes])
useEffect(
() => () => {
if (geo) {
// The body may have been replaced by the trimmed version —
// `trimChimneyBodyAgainstRoof` disposes the original on
// success. Dispose `trimmedBody` if present, else the
// original body.
;(trimmedBody ?? geo.body).dispose()
geo.cap?.dispose()
geo.flues?.dispose()
geo.cricket?.dispose()
geo.bands?.dispose()
}
},
[geo, trimmedBody],
)
// Per-instance fallback materials. Were previously module-scoped
// singletons shared across every chimney — a paint-mode or debug
// system that mutates `surfaceMaterial` would have flipped the look
// of every unpainted chimney on the scene. Owning them here also
// lets us dispose them on unmount.
const fallbackBodyMaterial = useMemo(
() =>
new THREE.MeshStandardMaterial({
color: 0xb8_88_72,
roughness: 0.85,
metalness: 0,
}),
[],
)
const fallbackTopMaterial = useMemo(
() =>
new THREE.MeshStandardMaterial({
color: 0xa0_a0_a0,
roughness: 0.75,
metalness: 0,
}),
[],
)
useEffect(
() => () => {
fallbackBodyMaterial.dispose()
fallbackTopMaterial.dispose()
},
[fallbackBodyMaterial, fallbackTopMaterial],
)
const surfaceMaterial = useMemo(() => {
// Untextured chimney body (and everything in textures-off mode) takes
// the themed 'wall' role colour; only an explicit preset/material keeps
// its texture when textures are on.
if (!textures || (!node.material && !node.materialPreset)) {
return createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme)
}
if (node.material) return createMaterial(node.material, shading)
return createMaterialFromPresetRef(node.materialPreset, shading) ?? fallbackBodyMaterial
}, [
textures,
colorPreset,
sceneTheme,
shading,
node.material,
node.materialPreset,
fallbackBodyMaterial,
])
const capSurfaceMaterial = useMemo(() => {
// Cap/crown is the chimney's roof-facing surface → 'roof' role when
// untextured (or textures off). Otherwise resolve the explicit cap
// material, then fall back to the body material.
if (
!textures ||
(!node.topMaterial && !node.topMaterialPreset && !node.material && !node.materialPreset)
) {
return createSurfaceRoleMaterial('roof', colorPreset, undefined, sceneTheme)
}
if (node.topMaterial) return createMaterial(node.topMaterial, shading)
const preset = createMaterialFromPresetRef(node.topMaterialPreset, shading)
if (preset) return preset
if (node.material) return createMaterial(node.material, shading)
return createMaterialFromPresetRef(node.materialPreset, shading) ?? fallbackTopMaterial
}, [
textures,
colorPreset,
sceneTheme,
shading,
node.topMaterial,
node.topMaterialPreset,
node.material,
node.materialPreset,
fallbackTopMaterial,
])
// Two-material array: index 0 = body/surface, index 1 = top. The
// geometry buffers are partitioned in `holes.ts:partitionTopFaceGroups`
// so the very top face of body/cap/flues lands in group 1 and picks up
// the top material — matching the v1 roof-system visual.
// Must be declared above the early-return below: hooks can't be
// called conditionally without changing the hook-call order between
// renders.
const surfaceArray = useMemo(
() => [surfaceMaterial, capSurfaceMaterial],
[surfaceMaterial, capSurfaceMaterial],
)
if (!segment || !geo) return null
// Chimneys are mounted inside `RoofRenderer`'s `roof-elements` group,
// which sits at the ROOF's origin — not inside the host segment's
// transform. Apply the segment's pose on the outer group, then nest a
// ref'd inner group at the chimney's segment-local position +
// rotation so the registered Object3D's local frame is *chimney-local*
// — that's what `NodeArrowHandles` reads to place its arrows.
// Mirrors skylight's renderer; geometry comes from `geometry.ts` in
// chimney-local frame (no transform baking) and lands in the right
// world spot via the two-group stack below.
return (
<group
position={segment.position}
rotation-y={segment.rotation}
visible={node.visible}
{...handlers}
>
<group
position={[node.position[0] ?? 0, 0, node.position[2] ?? 0]}
ref={ref}
rotation-y={node.rotation ?? 0}
>
<mesh
castShadow
geometry={trimmedBody ?? geo.body}
material={surfaceArray}
name="chimney-body"
receiveShadow
/>
{geo.cap && (
<mesh
castShadow
geometry={geo.cap}
material={surfaceArray}
name="chimney-cap"
receiveShadow
/>
)}
{geo.flues && (
<mesh
castShadow
geometry={geo.flues}
material={surfaceArray}
name="chimney-flues"
receiveShadow
/>
)}
{geo.cricket && (
<mesh
castShadow
geometry={geo.cricket}
material={surfaceMaterial}
name="chimney-cricket"
receiveShadow
/>
)}
{geo.bands && (
<mesh
castShadow
geometry={geo.bands}
material={surfaceMaterial}
name="chimney-bands"
receiveShadow
/>
)}
</group>
</group>
)
}
export default ChimneyRenderer