883 lines
27 KiB
TypeScript
883 lines
27 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
type AnyNodeId,
|
|
type RoofSegmentNode,
|
|
SKYLIGHT_TYPE_PRESETS,
|
|
type SkylightNode,
|
|
type SkylightOpeningSide,
|
|
useInteractive,
|
|
useLiveNodeOverrides,
|
|
useRegistry,
|
|
useScene,
|
|
} from '@pascal-app/core'
|
|
import {
|
|
type ColorPreset,
|
|
createMaterial,
|
|
createMaterialFromPresetRef,
|
|
createSurfaceRoleMaterial,
|
|
getRoofOuterSurfaceFrameAtPoint,
|
|
useNodeEvents,
|
|
useViewer,
|
|
} from '@pascal-app/viewer'
|
|
import { useEffect, useMemo, useRef } from 'react'
|
|
import * as THREE from 'three'
|
|
import { surfaceQuatFromNormal } from '../shared/roof-surface'
|
|
import { TrimClippedMesh, useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip'
|
|
import { buildFrameGeometry } from './frame-csg'
|
|
import { buildLanternGlassGeometry, clamp01, paneSize } from './geometry'
|
|
|
|
const yAxis = new THREE.Vector3(0, 1, 0)
|
|
|
|
const defaultFrameMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0xff_ff_ff,
|
|
roughness: 0.3,
|
|
metalness: 0.5,
|
|
})
|
|
|
|
// MeshBasicMaterial: only requires position (slot 0). Safe with DoubleSide
|
|
// because Basic doesn't write to the additional MRT targets that
|
|
// MeshStandardMaterial/Physical do, so the WebGPU "writeMask not zero"
|
|
// error doesn't fire. Also avoids the "vertex buffer slot 1 not set" error
|
|
// that MeshLambertNodeMaterial triggers when inline <boxGeometry> JSX
|
|
// recreates geometry on resize — the node-material pipeline expects normals
|
|
// in slot 1, but the new geometry instance isn't fully bound yet at draw time.
|
|
// MeshBasicMaterial at 30% opacity gives visually identical glass without
|
|
// those constraints.
|
|
const defaultGlassMaterial = new THREE.MeshBasicMaterial({
|
|
color: 0x87_ce_eb,
|
|
transparent: true,
|
|
opacity: 0.3,
|
|
side: THREE.DoubleSide,
|
|
depthWrite: false,
|
|
})
|
|
|
|
function FrameBar({
|
|
end,
|
|
material,
|
|
radius,
|
|
start,
|
|
}: {
|
|
end: [number, number, number]
|
|
material: THREE.Material | THREE.Material[]
|
|
radius: number
|
|
start: [number, number, number]
|
|
}) {
|
|
const transform = useMemo(() => {
|
|
const startPoint = new THREE.Vector3(...start)
|
|
const endPoint = new THREE.Vector3(...end)
|
|
const direction = endPoint.clone().sub(startPoint)
|
|
const length = direction.length()
|
|
const midpoint = startPoint.clone().add(endPoint).multiplyScalar(0.5)
|
|
const quaternion = new THREE.Quaternion()
|
|
if (length > 1e-6) {
|
|
quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction.normalize())
|
|
}
|
|
return { length, midpoint, quaternion }
|
|
}, [start, end])
|
|
|
|
if (transform.length <= 1e-6) return null
|
|
|
|
return (
|
|
<mesh
|
|
castShadow
|
|
material={material}
|
|
name="skylight-surface"
|
|
position={transform.midpoint}
|
|
quaternion={transform.quaternion}
|
|
receiveShadow
|
|
>
|
|
<cylinderGeometry args={[radius, radius, transform.length, 8]} />
|
|
</mesh>
|
|
)
|
|
}
|
|
|
|
function GlassPane({
|
|
glassThickness,
|
|
material,
|
|
name = 'skylight-glass',
|
|
paneDepth,
|
|
position = [0, 0, 0],
|
|
rotation,
|
|
width,
|
|
segment,
|
|
parentToSegment,
|
|
}: {
|
|
glassThickness: number
|
|
material: THREE.Material | THREE.Material[]
|
|
name?: string
|
|
paneDepth: number
|
|
position?: [number, number, number]
|
|
rotation?: [number, number, number]
|
|
width: number
|
|
// Trim-clip context: when provided, the glass pane is sliced by the host
|
|
// segment's trim (so the glazing matches the roof cutaway). Absent in
|
|
// contexts with no host segment frame.
|
|
segment?: RoofSegmentNode
|
|
parentToSegment?: THREE.Matrix4
|
|
}) {
|
|
const geometry = useMemo(
|
|
() => new THREE.BoxGeometry(paneSize(width), paneSize(glassThickness), paneSize(paneDepth)),
|
|
[width, glassThickness, paneDepth],
|
|
)
|
|
useEffect(() => () => geometry.dispose(), [geometry])
|
|
|
|
if (segment && parentToSegment) {
|
|
return (
|
|
<TrimClippedMesh
|
|
geometry={geometry}
|
|
material={material}
|
|
name={name}
|
|
parentToSegment={parentToSegment}
|
|
position={position}
|
|
receiveShadow
|
|
rotation={rotation}
|
|
segment={segment}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<mesh
|
|
geometry={geometry}
|
|
material={material}
|
|
name={name}
|
|
position={position}
|
|
receiveShadow
|
|
rotation={rotation}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function PaneFrame({
|
|
depth,
|
|
railHeight,
|
|
railWidth,
|
|
material,
|
|
position = [0, 0, 0],
|
|
width,
|
|
}: {
|
|
depth: number
|
|
railHeight: number
|
|
railWidth: number
|
|
material: THREE.Material | THREE.Material[]
|
|
position?: [number, number, number]
|
|
width: number
|
|
}) {
|
|
const halfW = width / 2
|
|
const halfD = depth / 2
|
|
const y = railHeight / 2
|
|
|
|
return (
|
|
<group position={position}>
|
|
<mesh
|
|
castShadow
|
|
material={material}
|
|
name="skylight-surface"
|
|
position={[0, y, halfD]}
|
|
receiveShadow
|
|
>
|
|
<boxGeometry args={[paneSize(width + railWidth), railHeight, railWidth]} />
|
|
</mesh>
|
|
<mesh
|
|
castShadow
|
|
material={material}
|
|
name="skylight-surface"
|
|
position={[0, y, -halfD]}
|
|
receiveShadow
|
|
>
|
|
<boxGeometry args={[paneSize(width + railWidth), railHeight, railWidth]} />
|
|
</mesh>
|
|
<mesh
|
|
castShadow
|
|
material={material}
|
|
name="skylight-surface"
|
|
position={[-halfW, y, 0]}
|
|
receiveShadow
|
|
>
|
|
<boxGeometry args={[railWidth, railHeight, paneSize(depth + railWidth)]} />
|
|
</mesh>
|
|
<mesh
|
|
castShadow
|
|
material={material}
|
|
name="skylight-surface"
|
|
position={[halfW, y, 0]}
|
|
receiveShadow
|
|
>
|
|
<boxGeometry args={[railWidth, railHeight, paneSize(depth + railWidth)]} />
|
|
</mesh>
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function LanternGlass({
|
|
curbHeight,
|
|
frameMaterial,
|
|
glassMaterial,
|
|
node,
|
|
segment,
|
|
parentToSegment,
|
|
}: {
|
|
curbHeight: number
|
|
frameMaterial: THREE.Material | THREE.Material[]
|
|
glassMaterial: THREE.Material | THREE.Material[]
|
|
node: SkylightNode
|
|
segment?: RoofSegmentNode
|
|
parentToSegment?: THREE.Matrix4
|
|
}) {
|
|
const preset = SKYLIGHT_TYPE_PRESETS.lantern
|
|
const width = node.width - 0.01
|
|
const depth = node.height - 0.01
|
|
const height = Math.max(0.05, node.lanternHeight ?? preset.lanternHeight)
|
|
const topScale = clamp01(node.lanternTopScale ?? preset.lanternTopScale)
|
|
const baseHalfW = paneSize(width) / 2
|
|
const baseHalfD = paneSize(depth) / 2
|
|
const topHalfW = baseHalfW * topScale
|
|
const topHalfD = baseHalfD * topScale
|
|
const frameRadius = Math.max(0.008, node.frameThickness * 0.16)
|
|
const baseCorners: [number, number, number][] = [
|
|
[-baseHalfW, 0, baseHalfD],
|
|
[baseHalfW, 0, baseHalfD],
|
|
[baseHalfW, 0, -baseHalfD],
|
|
[-baseHalfW, 0, -baseHalfD],
|
|
]
|
|
const topCorners: [number, number, number][] =
|
|
topScale <= 1e-4
|
|
? [
|
|
[0, height, 0],
|
|
[0, height, 0],
|
|
[0, height, 0],
|
|
[0, height, 0],
|
|
]
|
|
: [
|
|
[-topHalfW, height, topHalfD],
|
|
[topHalfW, height, topHalfD],
|
|
[topHalfW, height, -topHalfD],
|
|
[-topHalfW, height, -topHalfD],
|
|
]
|
|
const geometry = useMemo(
|
|
() => buildLanternGlassGeometry(width, depth, height, topScale),
|
|
[depth, height, topScale, width],
|
|
)
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
geometry.dispose()
|
|
}
|
|
}, [geometry])
|
|
|
|
const glassParentToSegment =
|
|
segment && parentToSegment
|
|
? new THREE.Matrix4()
|
|
.copy(parentToSegment)
|
|
.multiply(new THREE.Matrix4().makeTranslation(0, curbHeight, 0))
|
|
: undefined
|
|
|
|
return (
|
|
<group position={[0, curbHeight, 0]}>
|
|
{segment && glassParentToSegment ? (
|
|
<TrimClippedMesh
|
|
geometry={geometry}
|
|
material={glassMaterial}
|
|
name="skylight-glass"
|
|
parentToSegment={glassParentToSegment}
|
|
receiveShadow
|
|
segment={segment}
|
|
/>
|
|
) : (
|
|
<mesh geometry={geometry} material={glassMaterial} name="skylight-glass" receiveShadow />
|
|
)}
|
|
{baseCorners.map((corner, index) => (
|
|
<FrameBar
|
|
end={baseCorners[(index + 1) % baseCorners.length] ?? corner}
|
|
key={`lantern-base-${index}`}
|
|
material={frameMaterial}
|
|
radius={frameRadius}
|
|
start={corner}
|
|
/>
|
|
))}
|
|
{baseCorners.map((corner, index) => (
|
|
<FrameBar
|
|
end={topCorners[index] ?? corner}
|
|
key={`lantern-hip-${index}`}
|
|
material={frameMaterial}
|
|
radius={frameRadius}
|
|
start={corner}
|
|
/>
|
|
))}
|
|
{topScale > 1e-4 &&
|
|
topCorners.map((corner, index) => (
|
|
<FrameBar
|
|
end={topCorners[(index + 1) % topCorners.length] ?? corner}
|
|
key={`lantern-top-${index}`}
|
|
material={frameMaterial}
|
|
radius={frameRadius}
|
|
start={corner}
|
|
/>
|
|
))}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function getHingedPaneTransform(
|
|
side: SkylightOpeningSide,
|
|
width: number,
|
|
depth: number,
|
|
openingAngle: number,
|
|
): {
|
|
hingePosition: [number, number, number]
|
|
panePosition: [number, number, number]
|
|
rotation: [number, number, number]
|
|
} {
|
|
if (side === 'bottom') {
|
|
return {
|
|
hingePosition: [0, 0, -depth / 2],
|
|
panePosition: [0, 0, depth / 2],
|
|
rotation: [-openingAngle, 0, 0],
|
|
}
|
|
}
|
|
if (side === 'left') {
|
|
return {
|
|
hingePosition: [-width / 2, 0, 0],
|
|
panePosition: [width / 2, 0, 0],
|
|
rotation: [0, 0, openingAngle],
|
|
}
|
|
}
|
|
if (side === 'right') {
|
|
return {
|
|
hingePosition: [width / 2, 0, 0],
|
|
panePosition: [-width / 2, 0, 0],
|
|
rotation: [0, 0, -openingAngle],
|
|
}
|
|
}
|
|
return {
|
|
hingePosition: [0, 0, depth / 2],
|
|
panePosition: [0, 0, -depth / 2],
|
|
rotation: [openingAngle, 0, 0],
|
|
}
|
|
}
|
|
|
|
function ElectricMotorHousing({
|
|
curbHeight,
|
|
frameMaterial,
|
|
glassThickness,
|
|
node,
|
|
side,
|
|
}: {
|
|
curbHeight: number
|
|
frameMaterial: THREE.Material | THREE.Material[]
|
|
glassThickness: number
|
|
node: SkylightNode
|
|
side: SkylightOpeningSide
|
|
}) {
|
|
const size = Math.max(
|
|
0.03,
|
|
node.motorHousingSize ?? SKYLIGHT_TYPE_PRESETS.opening.motorHousingSize,
|
|
)
|
|
const y = curbHeight + glassThickness + size / 2
|
|
const isHorizontalHinge = side === 'top' || side === 'bottom'
|
|
return (
|
|
<mesh
|
|
castShadow
|
|
material={frameMaterial}
|
|
name="skylight-surface"
|
|
position={[
|
|
side === 'left' ? -node.width / 2 : side === 'right' ? node.width / 2 : 0,
|
|
y,
|
|
side === 'top' ? node.height / 2 : side === 'bottom' ? -node.height / 2 : 0,
|
|
]}
|
|
receiveShadow
|
|
>
|
|
<boxGeometry
|
|
args={
|
|
isHorizontalHinge
|
|
? [paneSize(node.width), size, size]
|
|
: [size, size, paneSize(node.height)]
|
|
}
|
|
/>
|
|
</mesh>
|
|
)
|
|
}
|
|
|
|
function HingedGlass({
|
|
curbHeight,
|
|
frameMaterial,
|
|
glassMaterial,
|
|
glassThickness,
|
|
hasMotorHousing,
|
|
node,
|
|
openAmount,
|
|
segment,
|
|
parentToSegment,
|
|
}: {
|
|
curbHeight: number
|
|
frameMaterial: THREE.Material | THREE.Material[]
|
|
glassMaterial: THREE.Material | THREE.Material[]
|
|
glassThickness: number
|
|
hasMotorHousing: boolean
|
|
node: SkylightNode
|
|
openAmount: number
|
|
segment?: RoofSegmentNode
|
|
parentToSegment?: THREE.Matrix4
|
|
}) {
|
|
const preset = SKYLIGHT_TYPE_PRESETS.opening
|
|
const side = node.openingSide ?? preset.openingSide
|
|
const openingAngle = Math.max(0, node.openingAngle ?? preset.openingAngle) * clamp01(openAmount)
|
|
const width = node.width - 0.01
|
|
const depth = node.height - 0.01
|
|
const transform = getHingedPaneTransform(side, width, depth, openingAngle)
|
|
const frameRadius = Math.max(0.006, node.frameThickness * 0.13)
|
|
const sashRailWidth = Math.max(0.018, node.frameThickness * 0.42)
|
|
const sashRailHeight = Math.max(glassThickness * 1.4, node.frameThickness * 0.2)
|
|
const showSupport = side === 'top' && openingAngle > 0.04
|
|
const supportX = width / 2 + node.frameThickness * 0.35
|
|
const supportStartZ = -depth / 2 + Math.min(0.12, depth * 0.12)
|
|
const supportTravel = depth * 0.78
|
|
const supportEndY = curbHeight + glassThickness + Math.sin(openingAngle) * supportTravel
|
|
const supportEndZ = depth / 2 - Math.cos(openingAngle) * supportTravel
|
|
|
|
// The hinge group offsets + rotates the pane; compose that onto the
|
|
// skylight→segment matrix so the glass clips in segment-local space.
|
|
const hingeToSegment =
|
|
segment && parentToSegment
|
|
? new THREE.Matrix4()
|
|
.copy(parentToSegment)
|
|
.multiply(
|
|
new THREE.Matrix4().compose(
|
|
new THREE.Vector3(
|
|
transform.hingePosition[0],
|
|
curbHeight + glassThickness / 2,
|
|
transform.hingePosition[2],
|
|
),
|
|
new THREE.Quaternion().setFromEuler(
|
|
new THREE.Euler(
|
|
transform.rotation[0],
|
|
transform.rotation[1],
|
|
transform.rotation[2],
|
|
),
|
|
),
|
|
new THREE.Vector3(1, 1, 1),
|
|
),
|
|
)
|
|
: undefined
|
|
|
|
return (
|
|
<>
|
|
<group
|
|
position={[
|
|
transform.hingePosition[0],
|
|
curbHeight + glassThickness / 2,
|
|
transform.hingePosition[2],
|
|
]}
|
|
rotation={transform.rotation}
|
|
>
|
|
<GlassPane
|
|
glassThickness={glassThickness}
|
|
material={glassMaterial}
|
|
paneDepth={depth}
|
|
parentToSegment={hingeToSegment}
|
|
position={transform.panePosition}
|
|
segment={segment}
|
|
width={width}
|
|
/>
|
|
<PaneFrame
|
|
depth={depth}
|
|
material={frameMaterial}
|
|
position={transform.panePosition}
|
|
railHeight={sashRailHeight}
|
|
railWidth={sashRailWidth}
|
|
width={width}
|
|
/>
|
|
</group>
|
|
{showSupport && (
|
|
<>
|
|
<FrameBar
|
|
end={[-supportX, supportEndY, supportEndZ]}
|
|
material={frameMaterial}
|
|
radius={frameRadius * 0.72}
|
|
start={[-supportX, curbHeight + 0.018, supportStartZ]}
|
|
/>
|
|
<FrameBar
|
|
end={[supportX, supportEndY, supportEndZ]}
|
|
material={frameMaterial}
|
|
radius={frameRadius * 0.72}
|
|
start={[supportX, curbHeight + 0.018, supportStartZ]}
|
|
/>
|
|
</>
|
|
)}
|
|
{hasMotorHousing && (
|
|
<ElectricMotorHousing
|
|
curbHeight={curbHeight}
|
|
frameMaterial={frameMaterial}
|
|
glassThickness={glassThickness}
|
|
node={node}
|
|
side={side}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function SlidingGlass({
|
|
curbHeight,
|
|
frameMaterial,
|
|
glassMaterial,
|
|
glassThickness,
|
|
node,
|
|
openAmount,
|
|
segment,
|
|
parentToSegment,
|
|
}: {
|
|
curbHeight: number
|
|
frameMaterial: THREE.Material | THREE.Material[]
|
|
glassMaterial: THREE.Material | THREE.Material[]
|
|
glassThickness: number
|
|
node: SkylightNode
|
|
openAmount: number
|
|
segment?: RoofSegmentNode
|
|
parentToSegment?: THREE.Matrix4
|
|
}) {
|
|
const preset = SKYLIGHT_TYPE_PRESETS.sliding
|
|
const slideDirection = node.slideDirection ?? preset.slideDirection
|
|
const slideFraction = clamp01(openAmount)
|
|
const trackWidth = Math.max(0.02, node.trackWidth ?? preset.trackWidth)
|
|
const y = curbHeight + glassThickness / 2
|
|
const railY = curbHeight + glassThickness + trackWidth / 2
|
|
const sashRailWidth = Math.max(0.016, node.frameThickness * 0.36)
|
|
const sashRailHeight = Math.max(glassThickness * 1.25, node.frameThickness * 0.18)
|
|
|
|
if (slideDirection === 'x') {
|
|
const paneWidth = (node.width - trackWidth) / 2
|
|
const fixedX = -node.width / 4
|
|
const movingX = node.width / 4 - slideFraction * paneWidth
|
|
const fixedPanePosition: [number, number, number] = [fixedX, y, 0]
|
|
const movingPanePosition: [number, number, number] = [movingX, y + glassThickness + 0.003, 0]
|
|
return (
|
|
<>
|
|
<GlassPane
|
|
glassThickness={glassThickness}
|
|
material={glassMaterial}
|
|
paneDepth={node.height - 0.01}
|
|
parentToSegment={parentToSegment}
|
|
position={fixedPanePosition}
|
|
segment={segment}
|
|
width={paneWidth}
|
|
/>
|
|
<PaneFrame
|
|
depth={node.height - 0.01}
|
|
material={frameMaterial}
|
|
position={fixedPanePosition}
|
|
railHeight={sashRailHeight}
|
|
railWidth={sashRailWidth}
|
|
width={paneWidth}
|
|
/>
|
|
<GlassPane
|
|
glassThickness={glassThickness}
|
|
material={glassMaterial}
|
|
paneDepth={node.height - 0.01}
|
|
parentToSegment={parentToSegment}
|
|
position={movingPanePosition}
|
|
segment={segment}
|
|
width={paneWidth}
|
|
/>
|
|
<PaneFrame
|
|
depth={node.height - 0.01}
|
|
material={frameMaterial}
|
|
position={movingPanePosition}
|
|
railHeight={sashRailHeight}
|
|
railWidth={sashRailWidth}
|
|
width={paneWidth}
|
|
/>
|
|
<mesh
|
|
material={frameMaterial}
|
|
name="skylight-surface"
|
|
position={[0, railY, node.height / 2]}
|
|
receiveShadow
|
|
>
|
|
<boxGeometry args={[paneSize(node.width + trackWidth * 2), trackWidth, trackWidth]} />
|
|
</mesh>
|
|
<mesh
|
|
material={frameMaterial}
|
|
name="skylight-surface"
|
|
position={[0, railY, -node.height / 2]}
|
|
receiveShadow
|
|
>
|
|
<boxGeometry args={[paneSize(node.width + trackWidth * 2), trackWidth, trackWidth]} />
|
|
</mesh>
|
|
</>
|
|
)
|
|
}
|
|
|
|
const paneDepth = (node.height - trackWidth) / 2
|
|
const fixedZ = -node.height / 4
|
|
const movingZ = node.height / 4 - slideFraction * paneDepth
|
|
const fixedPanePosition: [number, number, number] = [0, y, fixedZ]
|
|
const movingPanePosition: [number, number, number] = [0, y + glassThickness + 0.003, movingZ]
|
|
return (
|
|
<>
|
|
<GlassPane
|
|
glassThickness={glassThickness}
|
|
material={glassMaterial}
|
|
paneDepth={paneDepth}
|
|
parentToSegment={parentToSegment}
|
|
position={fixedPanePosition}
|
|
segment={segment}
|
|
width={node.width - 0.01}
|
|
/>
|
|
<PaneFrame
|
|
depth={paneDepth}
|
|
material={frameMaterial}
|
|
position={fixedPanePosition}
|
|
railHeight={sashRailHeight}
|
|
railWidth={sashRailWidth}
|
|
width={node.width - 0.01}
|
|
/>
|
|
<GlassPane
|
|
glassThickness={glassThickness}
|
|
material={glassMaterial}
|
|
paneDepth={paneDepth}
|
|
parentToSegment={parentToSegment}
|
|
position={movingPanePosition}
|
|
segment={segment}
|
|
width={node.width - 0.01}
|
|
/>
|
|
<PaneFrame
|
|
depth={paneDepth}
|
|
material={frameMaterial}
|
|
position={movingPanePosition}
|
|
railHeight={sashRailHeight}
|
|
railWidth={sashRailWidth}
|
|
width={node.width - 0.01}
|
|
/>
|
|
<mesh
|
|
material={frameMaterial}
|
|
name="skylight-surface"
|
|
position={[node.width / 2, railY, 0]}
|
|
receiveShadow
|
|
>
|
|
<boxGeometry args={[trackWidth, trackWidth, paneSize(node.height + trackWidth * 2)]} />
|
|
</mesh>
|
|
<mesh
|
|
material={frameMaterial}
|
|
name="skylight-surface"
|
|
position={[-node.width / 2, railY, 0]}
|
|
receiveShadow
|
|
>
|
|
<boxGeometry args={[trackWidth, trackWidth, paneSize(node.height + trackWidth * 2)]} />
|
|
</mesh>
|
|
</>
|
|
)
|
|
}
|
|
|
|
const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => {
|
|
const ref = useRef<THREE.Group>(null!)
|
|
useRegistry(storeNode.id, 'skylight', ref)
|
|
const handlers = useNodeEvents(storeNode, 'skylight')
|
|
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 liveOverrides = useLiveNodeOverrides((state) => state.get(storeNode.id))
|
|
const node = useMemo(
|
|
() => (liveOverrides ? ({ ...storeNode, ...liveOverrides } as SkylightNode) : storeNode),
|
|
[storeNode, liveOverrides],
|
|
)
|
|
|
|
const segment = useScene((state) =>
|
|
node.roofSegmentId
|
|
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
|
|
: undefined,
|
|
)
|
|
|
|
const frameGeo = useMemo(() => {
|
|
return buildFrameGeometry({
|
|
curb: node.curb,
|
|
curbHeight: node.curbHeight,
|
|
frameDepth: node.frameDepth,
|
|
frameThickness: node.frameThickness,
|
|
height: node.height,
|
|
width: node.width,
|
|
})
|
|
}, [node.width, node.height, node.frameThickness, node.frameDepth, node.curb, node.curbHeight])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
frameGeo?.dispose()
|
|
}
|
|
}, [frameGeo])
|
|
|
|
const frameMaterial = useMemo(() => {
|
|
// Untextured frame (and everything in textures-off mode) takes the
|
|
// themed 'joinery' role colour; explicit paint shows when textures on.
|
|
if (!textures || (!node.material && !node.materialPreset)) {
|
|
return createSurfaceRoleMaterial('joinery', colorPreset, undefined, sceneTheme)
|
|
}
|
|
if (node.material) return createMaterial(node.material, shading)
|
|
return createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultFrameMaterial
|
|
}, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset])
|
|
|
|
const activeType = node.skylightType ?? 'flat'
|
|
const typePreset = SKYLIGHT_TYPE_PRESETS[activeType]
|
|
const glassThickness = Math.max(0.002, node.glassThickness ?? typePreset.glassThickness)
|
|
const runtimeOpenAmount = useInteractive(
|
|
(state) => state.skylights[storeNode.id as AnyNodeId]?.operationState,
|
|
)
|
|
const openAmount = runtimeOpenAmount ?? node.operationState ?? typePreset.operationState
|
|
|
|
const glassMaterial = useMemo(() => {
|
|
// Untextured glass (and textures-off mode) takes the themed 'glazing'
|
|
// role material from the shared cache, so it must not be mutated.
|
|
if (!textures || (!node.glassMaterial && !node.glassMaterialPreset)) {
|
|
return createSurfaceRoleMaterial('glazing', colorPreset, undefined, sceneTheme)
|
|
}
|
|
const mat = node.glassMaterial
|
|
? createMaterial(node.glassMaterial, shading)
|
|
: (createMaterialFromPresetRef(node.glassMaterialPreset, shading) ??
|
|
defaultGlassMaterial.clone())
|
|
if (mat && typeof mat === 'object') {
|
|
if (mat instanceof THREE.MeshPhysicalMaterial) {
|
|
mat.thickness = glassThickness
|
|
}
|
|
}
|
|
return mat
|
|
}, [
|
|
textures,
|
|
colorPreset,
|
|
sceneTheme,
|
|
shading,
|
|
glassThickness,
|
|
node.glassMaterial,
|
|
node.glassMaterialPreset,
|
|
])
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes.
|
|
const surfaceFrame = useMemo(() => {
|
|
if (!segment) return { point: new THREE.Vector3(), normal: new THREE.Vector3(0, 1, 0) }
|
|
return getRoofOuterSurfaceFrameAtPoint(segment, node.position[0] ?? 0, node.position[2] ?? 0)
|
|
}, [segment, node.position[0], node.position[2], node.rotation, liveOverrides, storeNode.id])
|
|
|
|
const surfaceQuat = useMemo(
|
|
() => surfaceQuatFromNormal(surfaceFrame.normal, new THREE.Quaternion()),
|
|
[surfaceFrame.normal],
|
|
)
|
|
|
|
// Compose the surface tilt with the skylight's own yaw so the
|
|
// registered ref group below carries the complete "skylight pose in
|
|
// segment frame" as a single local position+quaternion. Registry
|
|
// handles (`portal: 'grandparent'`) read this Object3D's *local*
|
|
// pose, so splitting the tilt and the yaw across nested groups would
|
|
// leave the registered group with just the yaw and put the handles
|
|
// at the wrong spot.
|
|
const composedQuat = useMemo(() => {
|
|
const q = new THREE.Quaternion().copy(surfaceQuat)
|
|
q.multiply(new THREE.Quaternion().setFromAxisAngle(yAxis, node.rotation ?? 0))
|
|
return q
|
|
}, [surfaceQuat, node.rotation])
|
|
|
|
const hasCurb = node.curb ?? false
|
|
const curbH = hasCurb ? Math.max(0, node.curbHeight ?? 0.1) : 0
|
|
|
|
// Map skylight-local geometry into the host segment's local frame (where the
|
|
// trim cut prisms live) — the same pose the inner registered group is mounted
|
|
// with (position [x, surfaceY, z] + surfaceQuat·yaw). Only the structural
|
|
// frame is clipped; the thin glass panes live inside animated sub-components
|
|
// (sliding / hinged) with their own internal transforms and ride with the
|
|
// frame. Computed before the early return so the hook order stays stable.
|
|
const localToSegment = useMemo(
|
|
() =>
|
|
new THREE.Matrix4().compose(
|
|
new THREE.Vector3(node.position[0] ?? 0, surfaceFrame.point.y, node.position[2] ?? 0),
|
|
composedQuat,
|
|
new THREE.Vector3(1, 1, 1),
|
|
),
|
|
[node.position[0], node.position[2], surfaceFrame.point.y, composedQuat],
|
|
)
|
|
const clippedFrame = useSegmentTrimClippedGeometry(frameGeo, segment, localToSegment)
|
|
|
|
if (!segment || !frameGeo) return null
|
|
|
|
const surfaceY = surfaceFrame.point.y
|
|
|
|
return (
|
|
<group
|
|
position={segment.position}
|
|
rotation-y={segment.rotation}
|
|
visible={node.visible}
|
|
{...handlers}
|
|
>
|
|
{/*
|
|
Single registered transform group carries the full skylight pose
|
|
in segment frame: translation = (skylight.x, surfaceY, skylight.z),
|
|
quaternion = surfaceQuat · Y(node.rotation). Used to be three
|
|
nested groups; collapsed because registry handles read this
|
|
Object3D's *local* position/quaternion (grandparent portal mode),
|
|
and a split tree would only expose the bottom group's local pose
|
|
(the yaw) — handles would land at the segment origin on the roof
|
|
base instead of on the skylight.
|
|
*/}
|
|
<group
|
|
position={[node.position[0] ?? 0, surfaceY, node.position[2] ?? 0]}
|
|
quaternion={composedQuat}
|
|
ref={ref}
|
|
>
|
|
<mesh
|
|
castShadow
|
|
geometry={clippedFrame ?? frameGeo}
|
|
material={frameMaterial}
|
|
name="skylight-surface"
|
|
receiveShadow
|
|
/>
|
|
{activeType === 'lantern' && (
|
|
<LanternGlass
|
|
curbHeight={curbH}
|
|
frameMaterial={frameMaterial}
|
|
glassMaterial={glassMaterial}
|
|
node={node}
|
|
parentToSegment={localToSegment}
|
|
segment={segment}
|
|
/>
|
|
)}
|
|
{activeType === 'sliding' && (
|
|
<SlidingGlass
|
|
curbHeight={curbH}
|
|
frameMaterial={frameMaterial}
|
|
glassMaterial={glassMaterial}
|
|
glassThickness={glassThickness}
|
|
node={node}
|
|
openAmount={openAmount}
|
|
parentToSegment={localToSegment}
|
|
segment={segment}
|
|
/>
|
|
)}
|
|
{activeType === 'opening' && (
|
|
<HingedGlass
|
|
curbHeight={curbH}
|
|
frameMaterial={frameMaterial}
|
|
glassMaterial={glassMaterial}
|
|
glassThickness={glassThickness}
|
|
hasMotorHousing={node.motorHousing ?? false}
|
|
node={node}
|
|
openAmount={openAmount}
|
|
parentToSegment={localToSegment}
|
|
segment={segment}
|
|
/>
|
|
)}
|
|
{(activeType === 'flat' || activeType === 'walk-on') && (
|
|
<GlassPane
|
|
glassThickness={glassThickness}
|
|
material={glassMaterial}
|
|
paneDepth={node.height + 0.004}
|
|
parentToSegment={localToSegment}
|
|
position={[0, curbH + glassThickness / 2, 0]}
|
|
segment={segment}
|
|
width={node.width + 0.004}
|
|
/>
|
|
)}
|
|
</group>
|
|
</group>
|
|
)
|
|
}
|
|
|
|
export default SkylightRenderer
|