'use client' import { type AnyNodeId, type SiteNode, useLiveNodeOverrides, useRegistry, useScene, } from '@pascal-app/core' import { backdropGradient, deepSkyColor, getSceneTheme, horizonHazeColor, NodeRenderer, unionPolygons, useNodeEvents, useViewer, } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape, ShapeGeometry, } from 'three' import { cameraPosition, color, float, mix, positionWorld, smoothstep, vec2 } from 'three/tsl' import { MeshLambertNodeMaterial } from 'three/webgpu' import { getRecessedSlabGroundHoles } from './recessed-slab-ground-holes' const Y_OFFSET = 0.01 // The horizon disc is presentation-only — clicks must fall through to the // real site polygon / grid, so its raycast is a no-op. const noopRaycast = () => {} /** * Creates simple line geometry for site boundary * Single horizontal line at ground level */ const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeometry => { const geometry = new BufferGeometry() if (points.length < 2) return geometry const positions: number[] = [] const uvs: number[] = [] // Create a simple line loop at ground level points.forEach(([x, z], index) => { positions.push(x ?? 0, Y_OFFSET, z ?? 0) uvs.push(points.length > 1 ? index / (points.length - 1) : 0, 0) }) // Close the loop positions.push(points[0]?.[0] ?? 0, Y_OFFSET, points[0]?.[1] ?? 0) uvs.push(1, 0) geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)) return geometry } type S = ReturnType function polygonsMatch( a: Array>, b: Array>, ): boolean { return ( a.length === b.length && a.every( (polygon, polygonIndex) => polygon.length === b[polygonIndex]?.length && polygon.every( (point, pointIndex) => point[0] === b[polygonIndex]?.[pointIndex]?.[0] && point[1] === b[polygonIndex]?.[pointIndex]?.[1], ), ) ) } function addSlabHoles( shape: Shape, slabPolygons: Array>, originX = 0, originZ = 0, ) { const localPolygons = slabPolygons.map((polygon) => polygon.map(([x, z]): [number, number] => [x - originX, -(z - originZ)]), ) for (const ring of unionPolygons(localPolygons)) { if (ring.length < 3) continue const hole = new Path() hole.moveTo(ring[0]![0], ring[0]![1]) for (let index = 1; index < ring.length; index += 1) { hole.lineTo(ring[index]![0], ring[index]![1]) } hole.closePath() shape.holes.push(hole) } } export const SiteRenderer = ({ node }: { node: SiteNode }) => { const ref = useRef(null!) useRegistry(node.id, 'site', ref) const bgColor = useViewer((state) => getSceneTheme(state.sceneTheme).ground) const backgroundColor = useViewer((state) => getSceneTheme(state.sceneTheme).background) const skyColor = useViewer((state) => { const theme = getSceneTheme(state.sceneTheme) return theme.backgroundSky ?? theme.background }) const appearance = useViewer((state) => getSceneTheme(state.sceneTheme).appearance) const maxLightIntensity = useViewer((state) => Math.max(1, ...getSceneTheme(state.sceneTheme).lights.map((light) => light.intensity)), ) const livePolygon = useLiveNodeOverrides( (state) => (state.overrides.get(node.id)?.polygon as SiteNode['polygon'] | undefined) ?? null, ) const polygonPoints = livePolygon?.points ?? node.polygon?.points // Centroid + radius of the lot polygon, for the presentation fade below. const fadeBounds = useMemo(() => { if (!polygonPoints || polygonPoints.length < 3) return null let cx = 0 let cz = 0 for (const [x, z] of polygonPoints) { cx += x ?? 0 cz += z ?? 0 } cx /= polygonPoints.length cz /= polygonPoints.length let radius = 0 for (const [x, z] of polygonPoints) { radius = Math.max(radius, Math.hypot((x ?? 0) - cx, (z ?? 0) - cz)) } return { cx, cz, radius } }, [polygonPoints]) // Lit (not Basic) so the site ground receives the directional shadow — Basic // is unlit, which is why shadows used to stop dead at the slab edge. polygonOffset // keeps it tucked behind the grid/slab as before. const groundMaterial = useMemo(() => { const material = new MeshLambertNodeMaterial({ color: bgColor }) material.polygonOffset = true material.polygonOffsetFactor = 1 material.polygonOffsetUnits = 1 return material }, [bgColor]) // Presentation horizon: a large ground disc under the lot, in the same // theme ground colour, fading radially into the theme background so the // scene sits on an "infinite" plane that dissolves into the sky instead of // a hard-edged plate floating on the backdrop. Never pickable. const horizonMaterial = useMemo(() => { if (!fadeBounds) return null const material = new MeshLambertNodeMaterial({ color: bgColor }) const center = vec2(fadeBounds.cx, fadeBounds.cz) const dist = positionWorld.xz.sub(center).length() const fade = smoothstep(float(fadeBounds.radius * 1.05), float(fadeBounds.radius * 5), dist) // Contact vignette: a soft darkening that hugs the lot so the parcel // reads as sitting on the ground instead of floating on an even field. // The linear cut competes with the tone mapper's shoulder — bright themes // (studio's key light runs at intensity 4) compress a fixed 15% to almost // nothing — so the strength scales with the theme's strongest light. const vignetteStrength = Math.min(0.45, 0.13 * maxLightIntensity) const halo = float(1) .sub(smoothstep(float(fadeBounds.radius * 0.95), float(fadeBounds.radius * 2.6), dist)) .mul(vignetteStrength) const haloFactor = float(1).sub(halo) material.colorNode = mix(color(bgColor), color('#000000'), fade).mul(haloFactor) // Dissolve, not tint: the albedo (lighting response, incl. shadows) fades // to black while an emissive term fades up to the backdrop gradient — the // exact formula the post pipeline composites (viewer lib/backdrop.ts), // evaluated with this fragment's view direction, so the far end is // literally the backdrop (incl. the horizon haze) from any camera pose. const viewDirY = positionWorld.sub(cameraPosition).normalize().y const backdrop = backdropGradient({ dirY: viewDirY, background: color(backgroundColor), haze: color(horizonHazeColor(skyColor, appearance)), sky: color(skyColor), skyDeep: color(deepSkyColor(skyColor)), }) // The halo also scales the in-band emissive: the dissolve starts at 1.05R, // so without it the (bright) backdrop dilutes the vignette exactly where // it should read. halo is 0 past 2.6R while the dissolve completes at 5R, // so the far field stays the pure backdrop — the seam guarantee holds. ;(material as unknown as { emissiveNode: unknown }).emissiveNode = mix( color('#000000'), backdrop, fade, ).mul(haloFactor) material.polygonOffset = true material.polygonOffsetFactor = 2 material.polygonOffsetUnits = 2 return material }, [bgColor, backgroundColor, skyColor, appearance, maxLightIntensity, fadeBounds]) // Cache computed polygons to keep the selector stable across unrelated store updates. const slabPolygonsCache = useRef<[number, number][][]>([]) const slabPolygons = useScene((state: S) => { const next = getRecessedSlabGroundHoles(state.nodes) const prev = slabPolygonsCache.current if (polygonsMatch(next, prev)) return prev slabPolygonsCache.current = next return next }) // Ground shape: site polygon with slab footprints punched as holes const groundShape = useMemo(() => { if (!polygonPoints || polygonPoints.length < 3) return null const pts = polygonPoints const shape = new Shape() shape.moveTo(pts[0]![0], -pts[0]![1]) for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1]) shape.closePath() addSlabHoles(shape, slabPolygons) return shape }, [polygonPoints, slabPolygons]) const horizonGeometry = useMemo(() => { if (!fadeBounds) return null const radius = Math.max(fadeBounds.radius * 8, 400) const shape = new Shape() const segments = 64 shape.moveTo(radius, 0) for (let index = 1; index <= segments; index += 1) { const angle = (index / segments) * Math.PI * 2 shape.lineTo(Math.cos(angle) * radius, Math.sin(angle) * radius) } shape.closePath() addSlabHoles(shape, slabPolygons, fadeBounds.cx, fadeBounds.cz) return new ShapeGeometry(shape) }, [fadeBounds, slabPolygons]) useEffect(() => () => horizonGeometry?.dispose(), [horizonGeometry]) // Create boundary line geometry const lineGeometry = useMemo(() => { if (!polygonPoints || polygonPoints.length < 2) return null return createBoundaryLineGeometry(polygonPoints) }, [polygonPoints]) useEffect(() => () => lineGeometry?.dispose(), [lineGeometry]) const groundGeometry = useMemo(() => { if (!groundShape) return null return new ShapeGeometry(groundShape) }, [groundShape]) useEffect(() => () => groundGeometry?.dispose(), [groundGeometry]) const handlers = useNodeEvents(node, 'site') if (!(node && lineGeometry)) { return null } return ( {/* Render children (buildings and items) */} {(node.children ?? []).map((childId) => ( ))} {/* Ground fill: site polygon with slab holes, occludes below-grade geometry */} {groundGeometry && ( )} {/* Infinite-ground presentation disc fading into the sky at the horizon */} {horizonGeometry && horizonMaterial && fadeBounds && ( )} {/* Simple boundary line */} {/* @ts-ignore */} ) } export default SiteRenderer