278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
type AnyNodeId,
|
|
getEffectiveRoofSurfaceMaterial,
|
|
getEffectiveSegmentSurfaceMaterial,
|
|
type RidgeVentNode,
|
|
type RoofNode,
|
|
type RoofSegmentNode,
|
|
useLiveNodeOverrides,
|
|
useRegistry,
|
|
useScene,
|
|
} from '@pascal-app/core'
|
|
import {
|
|
type ColorPreset,
|
|
createMaterial,
|
|
createMaterialFromPresetRef,
|
|
createSurfaceRoleMaterial,
|
|
getRoofMaterialArray,
|
|
useNodeEvents,
|
|
useViewer,
|
|
} from '@pascal-app/viewer'
|
|
import { useEffect, useMemo, useRef } from 'react'
|
|
import * as THREE from 'three'
|
|
import { RIDGE_LIFT, resolveRidgeSnap } from '../shared/ridge-snap'
|
|
import { getRoofTopSurfaceY } from '../shared/roof-surface'
|
|
import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip'
|
|
import { buildRidgeVentGeometry } from './geometry'
|
|
|
|
function ridgeVentSegmentGeometryKey(segment: RoofSegmentNode | undefined): string {
|
|
if (!segment) return 'none'
|
|
const trim = segment.trim
|
|
return [
|
|
segment.roofType,
|
|
segment.width,
|
|
segment.depth,
|
|
segment.wallHeight,
|
|
segment.pitch,
|
|
segment.wallThickness,
|
|
segment.deckThickness,
|
|
segment.overhang,
|
|
segment.shingleThickness,
|
|
segment.gambrelLowerWidthRatio,
|
|
segment.gambrelLowerHeightRatio,
|
|
segment.mansardSteepWidthRatio,
|
|
segment.mansardSteepHeightRatio,
|
|
segment.dutchHipWidthRatio,
|
|
segment.dutchHipHeightRatio,
|
|
trim.left,
|
|
trim.right,
|
|
trim.front,
|
|
trim.back,
|
|
trim.frontLeft,
|
|
trim.frontRight,
|
|
trim.backLeft,
|
|
trim.backRight,
|
|
trim.frontLeftX,
|
|
trim.frontLeftZ,
|
|
trim.frontRightX,
|
|
trim.frontRightZ,
|
|
trim.backLeftX,
|
|
trim.backLeftZ,
|
|
trim.backRightX,
|
|
trim.backRightZ,
|
|
].join('|')
|
|
}
|
|
|
|
/**
|
|
* Ridge vent renderer. Sits along the ridge of a roof-segment — no
|
|
* slope tilt is needed (the ridge IS the high line of the segment), so
|
|
* the transform stack is simply
|
|
*
|
|
* segment.position → segment.rotation (Y) → vent.position
|
|
* → vent.rotation (Y) → mesh
|
|
*
|
|
* Mirrors the box-vent renderer otherwise (segment lookup via
|
|
* useScene, live-transform follow for parent drags). Style-specific
|
|
* default materials let unpainted ridge vents read as their material
|
|
* family (matte standard / shingled grey / brushed metal) before the
|
|
* user opens the paint tray.
|
|
*/
|
|
const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
|
|
const ref = useRef<THREE.Group>(null!)
|
|
useRegistry(storeNode.id, 'ridge-vent', ref)
|
|
const handlers = useNodeEvents(storeNode, 'ridge-vent')
|
|
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 live drag overrides on top of the store node so handle drags
|
|
// update the mesh in-flight without flushing to zustand on every frame.
|
|
// Same pattern as box-vent / chimney / dormer — the override is set by
|
|
// `NodeArrowHandles`' drag handler and cleared on commit.
|
|
const overrides = useLiveNodeOverrides(
|
|
(s) => s.get(storeNode.id as AnyNodeId) as Partial<RidgeVentNode> | undefined,
|
|
)
|
|
const node: RidgeVentNode = overrides
|
|
? ({ ...storeNode, ...overrides } as RidgeVentNode)
|
|
: storeNode
|
|
const nodePosition = node.position ?? [0, 0, 0]
|
|
|
|
const segmentStore = useScene((state) =>
|
|
node.roofSegmentId
|
|
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
|
|
: undefined,
|
|
)
|
|
// Subscribe to the segment's live overrides too — when the user drags a
|
|
// segment handle (width / depth / wallHeight / pitch / rotation), the
|
|
// dimensions stream through `useLiveNodeOverrides` and don't hit the
|
|
// store until release. Merging them lets the ridge ride the segment in
|
|
// real time instead of snapping into place on commit.
|
|
const segmentOverrides = useLiveNodeOverrides((s) =>
|
|
node.roofSegmentId
|
|
? (s.get(node.roofSegmentId as AnyNodeId) as Partial<RoofSegmentNode> | undefined)
|
|
: undefined,
|
|
)
|
|
const segment: RoofSegmentNode | undefined = segmentStore
|
|
? segmentOverrides
|
|
? ({ ...segmentStore, ...segmentOverrides } as RoofSegmentNode)
|
|
: segmentStore
|
|
: undefined
|
|
const parentRoof = useScene((state) =>
|
|
segmentStore?.parentId
|
|
? (state.nodes[segmentStore.parentId as AnyNodeId] as RoofNode | undefined)
|
|
: undefined,
|
|
)
|
|
const segmentGeometryKey = ridgeVentSegmentGeometryKey(segment)
|
|
const rotationY = node.rotation ?? 0
|
|
const snap = useMemo(
|
|
() =>
|
|
segment && Math.abs(rotationY) < 1e-5
|
|
? resolveRidgeSnap(segment, nodePosition[0] ?? 0, nodePosition[2] ?? 0)
|
|
: null,
|
|
[segment, rotationY, nodePosition[0], nodePosition[2]],
|
|
)
|
|
const ridgeX = snap ? snap.localX : (nodePosition[0] ?? 0)
|
|
const ridgeZ = snap ? snap.localZ : (nodePosition[2] ?? 0)
|
|
const effectiveNode = useMemo<RidgeVentNode>(
|
|
() => ({
|
|
...node,
|
|
position: [ridgeX, nodePosition[1] ?? 0, ridgeZ],
|
|
}),
|
|
[node, ridgeX, ridgeZ, nodePosition[1]],
|
|
)
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: `segmentGeometryKey` captures the segment fields that affect the generated mesh; depending on the whole segment would rebuild on unrelated node changes.
|
|
const geometry = useMemo(
|
|
() => buildRidgeVentGeometry(effectiveNode, segment),
|
|
[
|
|
effectiveNode.length,
|
|
effectiveNode.width,
|
|
effectiveNode.height,
|
|
effectiveNode.style,
|
|
effectiveNode.endCaps,
|
|
effectiveNode.rotation,
|
|
effectiveNode.position[0],
|
|
effectiveNode.position[2],
|
|
segmentGeometryKey,
|
|
],
|
|
)
|
|
|
|
useEffect(() => () => geometry.dispose(), [geometry])
|
|
|
|
// Paint surface: FrontSide everywhere — DoubleSide on the role
|
|
// material's NodeMaterial poisons the MRT scene pass (see `materials.ts`
|
|
// line 77 / glazing fix 9400f1c5). Earlier this path forced DoubleSide
|
|
// so the underside of the thin extruded ridge cap stayed visible from
|
|
// below; that's now a known visual tradeoff — building the cap as a
|
|
// closed solid in `geometry.ts` is the right fix if the underside-view
|
|
// becomes noticeable.
|
|
const material = useMemo(() => {
|
|
const createDefaultTopMaterial = () => {
|
|
const parentSpec = parentRoof ? getEffectiveRoofSurfaceMaterial(parentRoof, 'top') : undefined
|
|
const spec = segment ? getEffectiveSegmentSurfaceMaterial(segment, 'top', parentSpec) : null
|
|
|
|
if (typeof spec?.materialPreset === 'string') {
|
|
const resolved = createMaterialFromPresetRef(spec.materialPreset, shading)
|
|
if (resolved) return resolved
|
|
}
|
|
if (spec?.material !== undefined) {
|
|
return createMaterial(spec.material, shading)
|
|
}
|
|
|
|
const roofMaterials = parentRoof
|
|
? getRoofMaterialArray(parentRoof, shading, textures, colorPreset, sceneTheme)
|
|
: null
|
|
return (
|
|
roofMaterials?.[3] ??
|
|
createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme)
|
|
)
|
|
}
|
|
|
|
if (node.material) {
|
|
return createMaterial(node.material, shading)
|
|
}
|
|
if (node.materialPreset) {
|
|
return createMaterialFromPresetRef(node.materialPreset, shading) ?? createDefaultTopMaterial()
|
|
}
|
|
return createDefaultTopMaterial()
|
|
}, [
|
|
textures,
|
|
colorPreset,
|
|
sceneTheme,
|
|
shading,
|
|
node.material,
|
|
node.materialPreset,
|
|
segment,
|
|
parentRoof,
|
|
])
|
|
|
|
// Map vent-local geometry into the host segment's local frame (where the trim
|
|
// cut prisms live). Recompose the same pose the inner mesh group is mounted
|
|
// with — ridge snap for X/Z, slope-locked base Y + offset, yaw — so the clip
|
|
// matches the rendered placement. Computed before the early return so the
|
|
// hook order stays stable.
|
|
const localToSegment = useMemo(() => {
|
|
if (!segment) return new THREE.Matrix4()
|
|
const baseY = getRoofTopSurfaceY(ridgeX, ridgeZ, segment)
|
|
const yOffset = Math.max(-2, Math.min(2, nodePosition[1] ?? 0))
|
|
const ridgeY = baseY + RIDGE_LIFT + yOffset
|
|
return new THREE.Matrix4().compose(
|
|
new THREE.Vector3(ridgeX, ridgeY, ridgeZ),
|
|
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), rotationY),
|
|
new THREE.Vector3(1, 1, 1),
|
|
)
|
|
}, [segment, ridgeX, ridgeZ, rotationY, nodePosition[1]])
|
|
const clippedGeometry = useSegmentTrimClippedGeometry(geometry, segment, localToSegment)
|
|
|
|
if (!segment) return null
|
|
|
|
// `node.position` is segment-local (placement / move tools resolve the
|
|
// click via `segObj.worldToLocal`), but the renderer mounts in the
|
|
// roof's `roof-elements` group — which only carries the roof's
|
|
// transform, not the segment's. Replicate the segment's roof-local
|
|
// transform here so segment-local coords land at the correct world
|
|
// point on every segment. Without this wrapper, ridge vents placed on
|
|
// a non-origin / rotated segment (e.g. the back slope of a gable, or
|
|
// any face of a hip) appeared on the first segment instead — the
|
|
// "same segment" duplication bug.
|
|
const segPos = segment.position ?? [0, 0, 0]
|
|
const segRotY = segment.rotation ?? 0
|
|
|
|
// Lock the BASE position to the rendered roof skin so the vent always starts
|
|
// on the roof structure; treat `position[1]` and `position[2]` as user-tunable OFFSETS
|
|
// off that base (Y above the surface, Z away from ridge centerline). So
|
|
// after placement the inspector's Y / Z sliders nudge the vent off the
|
|
// locked ridge without losing the slope-tracking base. X is the position
|
|
// along the ridge — the snap re-clamps it to the segment's ridge span.
|
|
const baseY = getRoofTopSurfaceY(ridgeX, ridgeZ, segment)
|
|
// Clamp legacy stored Y (absolute peak height from earlier versions) so the
|
|
// vent doesn't fly off when the field was an absolute Y instead of offset.
|
|
const yOffset = Math.max(-2, Math.min(2, nodePosition[1] ?? 0))
|
|
const ridgeY = baseY + RIDGE_LIFT + yOffset
|
|
|
|
return (
|
|
<group position={segPos} rotation-y={segRotY}>
|
|
<group
|
|
position={[ridgeX, ridgeY, ridgeZ]}
|
|
ref={ref}
|
|
rotation-y={rotationY}
|
|
visible={node.visible}
|
|
>
|
|
<mesh
|
|
castShadow
|
|
geometry={clippedGeometry ?? geometry}
|
|
material={material}
|
|
name="ridge-vent-surface"
|
|
receiveShadow
|
|
{...handlers}
|
|
/>
|
|
</group>
|
|
</group>
|
|
)
|
|
}
|
|
|
|
export default RidgeVentRenderer
|