diff --git a/packages/viewer/src/components/viewer/lights.tsx b/packages/viewer/src/components/viewer/lights.tsx index 79847356..df4ef80d 100644 --- a/packages/viewer/src/components/viewer/lights.tsx +++ b/packages/viewer/src/components/viewer/lights.tsx @@ -1,3 +1,4 @@ +import { sceneRegistry } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useMemo, useRef } from 'react' import type { @@ -26,15 +27,29 @@ const SHADOWS_DISABLED = // deliberate middle ground — present, but not the heavy contact shadow there. const MAX_SHADOW_INTENSITY = 0.55 -// Shadow frustum framing. The directional light is parked at a fixed distance -// along its (theme-defined) direction from the focus point, and the ortho -// shadow camera's depth is centred on that focus. Theme offsets are only -// ~17–32 units long, so without a fixed distance the focus sits near the front -// of a long frustum and the far end swings around as the focus moves. Keeping -// the light far away and the focus centred keeps the frustum hugging the view. -const SHADOW_DISTANCE = 120 -const SHADOW_NEAR = 20 -const SHADOW_FAR = 220 +// Shadow frustum framing. The frustum is fit to the BUILDING geometry (not the +// camera): we union the bounds of all registered scene nodes, fit a sphere, and +// size the directional light's ortho shadow camera to that sphere plus a margin. +// This keeps shadows anchored to the building and a bit of surrounding ground no +// matter how the user zooms or pans — fixing the previous camera-following +// behaviour that fell apart when zoomed out (frustum too small) or zoomed into +// an empty corner (frustum centred on nothing). +// +// `site` nodes (the ground/site plane, which can be arbitrarily large) are +// excluded so they don't blow the frustum up to cover the whole lot. +const SHADOW_EXCLUDED_TYPES = ['site'] as const +// How often (seconds) to recompute building bounds. Bounds only change while +// editing, so we throttle the (subtree-walking) union instead of doing it every +// frame. +const BOUNDS_REFRESH_INTERVAL = 0.4 +// Extra coverage around the building bounds — the "and a bit nearby" margin so +// shadows don't get clipped right at the walls. Scales with building size. +const SHADOW_MARGIN_SCALE = 1.15 +const SHADOW_MARGIN = 3 +// Gap between the building bounds sphere and the light / near plane. +const SHADOW_BACKOFF = 10 +// Fallback radius when the scene has no building geometry yet (empty scene). +const SHADOW_FALLBACK_RADIUS = 30 export function Lights() { const sceneTheme = useViewer((state) => state.sceneTheme) @@ -43,14 +58,16 @@ export function Lights() { const lightRefs = useRef>([]) const shadowCamera = useRef(null) - const shadowCameraSize = 50 // The "area" around the camera to shadow + // Initial ortho half-size; overridden each refresh to fit the building. + const shadowCameraSize = 50 - // Where the shadow frustum is centred each frame. The directional light's - // ortho shadow camera only covers ±shadowCameraSize around the light target, - // so it has to track the view or anything far from origin gets no shadows. - const shadowFocus = useRef(new THREE.Vector3()) - // Scratch vector for the per-light direction, reused to avoid per-frame allocs. - const shadowDir = useRef(new THREE.Vector3()) + // Building bounds the shadow frustum is fit to, recomputed on an interval. + const shadowFocus = useRef(new THREE.Vector3()) // sphere centre + const shadowRadius = useRef(SHADOW_FALLBACK_RADIUS) // sphere radius + const shadowDir = useRef(new THREE.Vector3()) // scratch: per-light direction + const boundsBox = useRef(new THREE.Box3()) // scratch: union AABB + const boundsSphere = useRef(new THREE.Sphere()) // scratch: fitted sphere + const lastBoundsTime = useRef(-1) // last refresh timestamp (-1 = never) const hemiRef = useRef(null) const ambientRef = useRef(null) @@ -71,33 +88,66 @@ export function Lights() { // clamp delta to avoid huge jumps on tab switch const dt = Math.min(delta, 0.1) * 4 - // Recentre each shadow-casting light's frustum on what the viewer is looking - // at (orbit target if available, else the camera's ground projection), moving - // the light and its target together so the light DIRECTION is preserved and - // only the shadow camera slides. Without this the frustum stays at the origin - // and zones far from it never receive shadows. + // Fit each shadow-casting light's frustum to the BUILDING geometry rather + // than the camera. We refresh the union bounds on an interval (cheap enough, + // and bounds only change while editing), fit a sphere, and size + place the + // ortho shadow camera so the building (plus a margin) is fully covered from + // the light's direction. The light DIRECTION stays exactly as the theme + // specifies; only its position/distance and the frustum extents change. if (shadows) { - const focus = shadowFocus.current - const controls = state.controls as { getTarget?: (out: THREE.Vector3) => void } | null - if (controls?.getTarget) { - controls.getTarget(focus) - } else { - focus.set(state.camera.position.x, 0, state.camera.position.z) + const now = state.clock.elapsedTime + if (now - lastBoundsTime.current >= BOUNDS_REFRESH_INTERVAL) { + lastBoundsTime.current = now + const box = boundsBox.current.makeEmpty() + for (const [id, obj] of sceneRegistry.nodes) { + if (SHADOW_EXCLUDED_TYPES.some((t) => sceneRegistry.byType[t]!.has(id))) continue + box.expandByObject(obj) + } + if (box.isEmpty()) { + // Empty scene: fall back to the origin with a default radius so the + // ground still receives a sensible shadow region. + shadowFocus.current.set(0, 0, 0) + shadowRadius.current = SHADOW_FALLBACK_RADIUS + } else { + box.getBoundingSphere(boundsSphere.current) + shadowFocus.current.copy(boundsSphere.current.center) + shadowRadius.current = boundsSphere.current.radius + } } + + const focus = shadowFocus.current + // Ortho half-extent: the building sphere plus a proportional margin. + const size = shadowRadius.current * SHADOW_MARGIN_SCALE + SHADOW_MARGIN + // Park the light just outside the sphere so the near plane stays positive + // and the whole building fits between near and far along the light axis. + const distance = size + SHADOW_BACKOFF + const near = SHADOW_BACKOFF + const far = distance + size + for (let index = 0; index < theme.lights.length; index++) { const config = theme.lights[index] const light = lightRefs.current[index] if (!(config?.castShadow && light)) continue const [ox, oy, oz] = config.position - // Preserve the theme's light DIRECTION but park the light at a fixed - // distance, so the ortho frustum (depth centred via SHADOW_NEAR/FAR) - // stays centred on the focus instead of dangling far past it. const dir = shadowDir.current.set(ox, oy, oz) if (dir.lengthSq() === 0) dir.set(0, 1, 0) - dir.normalize().multiplyScalar(SHADOW_DISTANCE) + dir.normalize().multiplyScalar(distance) light.position.set(focus.x + dir.x, focus.y + dir.y, focus.z + dir.z) light.target.position.copy(focus) light.target.updateMatrixWorld() + + // Resize the ortho frustum to the fitted bounds. The shadow camera is + // the below. + const cam = light.shadow?.camera as THREE.OrthographicCamera | undefined + if (cam) { + cam.left = -size + cam.right = size + cam.top = size + cam.bottom = -size + cam.near = near + cam.far = far + cam.updateProjectionMatrix() + } } } @@ -193,9 +243,9 @@ export function Lights() {