diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index ca1ab1b9..602739e2 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -132,7 +132,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/wood/floor_plank_1/floor_plank-diffuse.webp', aoMap: '/material/wood/floor_plank_1/floor_plank-ao.webp', - metalnessMap: '/material/wood/floor_plank_1/floor_plank-specular.webp', normalMap: '/material/wood/floor_plank_1/floor_plank-normal.webp', }, mapProperties: { @@ -169,7 +168,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ preset: { maps: { albedoMap: '/material/wood/hungarian_parquet_10/Hungarian Parquet_10_baseColor.webp', - metalnessMap: '/material/wood/hungarian_parquet_10/Hungarian Parquet_10_specularLevel.webp', normalMap: '/material/wood/hungarian_parquet_10/Hungarian Parquet_10_normal.webp', roughnessMap: '/material/wood/hungarian_parquet_10/Hungarian Parquet_10_roughness.webp', }, @@ -207,7 +205,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ preset: { maps: { albedoMap: '/material/wood/hungarian_parquet_2/Hungarian Parquet_2_baseColor.webp', - metalnessMap: '/material/wood/hungarian_parquet_2/Hungarian Parquet_2_specularLevel.webp', normalMap: '/material/wood/hungarian_parquet_2/Hungarian Parquet_2_normal.webp', roughnessMap: '/material/wood/hungarian_parquet_2/Hungarian Parquet_2_roughness.webp', }, @@ -246,8 +243,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ preset: { maps: { albedoMap: '/material/wood/square_parquet_21/Square Pattern Parquet_21_baseColor.webp', - metalnessMap: - '/material/wood/square_parquet_21/Square Pattern Parquet_21_specularLevel.webp', normalMap: '/material/wood/square_parquet_21/Square Pattern Parquet_21_normal.webp', roughnessMap: '/material/wood/square_parquet_21/Square Pattern Parquet_21_roughness.webp', }, @@ -286,8 +281,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ preset: { maps: { albedoMap: '/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_baseColor.webp', - metalnessMap: - '/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_specularLevel.webp', normalMap: '/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_normal.webp', roughnessMap: '/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_roughness.webp', @@ -327,7 +320,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/wood/wood_fine/wood_fine_1-diffuse.webp', aoMap: '/material/wood/wood_fine/wood_fine_1-ao.webp', - metalnessMap: '/material/wood/wood_fine/wood_fine_1-specular.webp', normalMap: '/material/wood/wood_fine/wood_fine_1-normal.webp', }, mapProperties: { @@ -365,7 +357,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/wood/wood_fine_11/wood_fine_11-diffuse.webp', aoMap: '/material/wood/wood_fine_11/wood_fine_11-ao.webp', - metalnessMap: '/material/wood/wood_fine_11/wood_fine_11-specular.webp', normalMap: '/material/wood/wood_fine_11/wood_fine_11-normal.webp', }, mapProperties: { @@ -403,7 +394,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/wood/wood_fine_13/wood_fine_13-diffuse.webp', aoMap: '/material/wood/wood_fine_13/wood_fine_13-ao.webp', - metalnessMap: '/material/wood/wood_fine_13/wood_fine_13-specular.webp', normalMap: '/material/wood/wood_fine_13/wood_fine_13-normal.webp', }, mapProperties: { @@ -441,7 +431,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/wood/wood_fine_2/wood_fine_2-diffuse.webp', aoMap: '/material/wood/wood_fine_2/wood_fine_2-ao.webp', - metalnessMap: '/material/wood/wood_fine_2/wood_fine_2-specular.webp', normalMap: '/material/wood/wood_fine_2/wood_fine_2-normal.webp', }, mapProperties: { @@ -479,7 +468,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/wood/wood_fine_22/wood_fine_22-diffuse.webp', aoMap: '/material/wood/wood_fine_22/wood_fine_22-ao.webp', - metalnessMap: '/material/wood/wood_fine_22/wood_fine_22-specular.webp', normalMap: '/material/wood/wood_fine_22/wood_fine_22-normal.webp', }, mapProperties: { @@ -517,7 +505,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/wood/wood_fine_24/wood_fine_24-diffuse.webp', aoMap: '/material/wood/wood_fine_24/wood_fine_24-ao.webp', - metalnessMap: '/material/wood/wood_fine_24/wood_fine_24-specular.webp', normalMap: '/material/wood/wood_fine_24/wood_fine_24-normal.webp', }, mapProperties: { @@ -593,7 +580,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ preset: { maps: { albedoMap: '/material/wood/wooden_parquet_11/Classic Parquet_11_baseColor.webp', - metalnessMap: '/material/wood/wooden_parquet_11/Classic Parquet_11_specularLevel.webp', normalMap: '/material/wood/wooden_parquet_11/Classic Parquet_11_normal.webp', roughnessMap: '/material/wood/wooden_parquet_11/Classic Parquet_11_roughness.webp', }, @@ -1018,7 +1004,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/flooring/garage_panel/garage_panel_diffuse.jpg', aoMap: '/material/flooring/garage_panel/garage_panel_ao.jpg', - metalnessMap: '/material/flooring/garage_panel/garage_panel_specular.jpg', normalMap: '/material/flooring/garage_panel/garage_panel_normal.jpg', }, mapProperties: { @@ -1056,7 +1041,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/flooring/green_labradorite/green_labradorite_diffuse.jpg', aoMap: '/material/flooring/green_labradorite/green_labradorite_ao.jpg', - metalnessMap: '/material/flooring/green_labradorite/green_labradorite_specular.jpg', normalMap: '/material/flooring/green_labradorite/green_labradorite_normal.jpg', }, mapProperties: { @@ -1133,7 +1117,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/flooring/pool_tiles/pool_tiles_diffuse.jpg', aoMap: '/material/flooring/pool_tiles/pool_tiles_ao.jpg', - metalnessMap: '/material/flooring/pool_tiles/pool_tiles_specular.jpg', normalMap: '/material/flooring/pool_tiles/pool_tiles_normal.jpg', }, mapProperties: { @@ -1171,7 +1154,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/flooring/tiles_checker/tiles_checker_diffuse.jpg', aoMap: '/material/flooring/tiles_checker/tiles_checker_ao.jpg', - metalnessMap: '/material/flooring/tiles_checker/tiles_checker_specular.jpg', normalMap: '/material/flooring/tiles_checker/tiles_checker_normal.jpg', }, mapProperties: { @@ -1209,7 +1191,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/flooring/tiles_grid/tiles_grid_diffuse.jpg', aoMap: '/material/flooring/tiles_grid/tiles_grid_ao.jpg', - metalnessMap: '/material/flooring/tiles_grid/tiles_grid_specular.jpg', normalMap: '/material/flooring/tiles_grid/tiles_grid_normal.jpg', }, mapProperties: { @@ -1247,7 +1228,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/flooring/stone_wall/stone_wall_diffuse.webp', aoMap: '/material/flooring/stone_wall/stone_wall_ao.webp', - metalnessMap: '/material/flooring/stone_wall/stone_wall_specular.webp', normalMap: '/material/flooring/stone_wall/stone_wall_normal.webp', }, mapProperties: { @@ -1285,7 +1265,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic-diffuse.webp', aoMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic-ao.webp', - metalnessMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic-specular.webp', normalMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic-normal.webp', }, mapProperties: { @@ -1477,7 +1456,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/flooring/green_glass_quartzite/green_glass_quartzite_diffuse.jpg', aoMap: '/material/flooring/green_glass_quartzite/green_glass_quartzite_ao.jpg', - metalnessMap: '/material/flooring/green_glass_quartzite/green_glass_quartzite_specular.jpg', normalMap: '/material/flooring/green_glass_quartzite/green_glass_quartzite_normal.jpg', }, mapProperties: { @@ -1594,7 +1572,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/flooring/statuaretto/statuaretto_diffuse.jpg', aoMap: '/material/flooring/statuaretto/statuaretto_ao.jpg', - metalnessMap: '/material/flooring/statuaretto/statuaretto_specular.jpg', normalMap: '/material/flooring/statuaretto/statuaretto_normal.jpg', }, mapProperties: { @@ -1710,7 +1687,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic-diffuse.webp', aoMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic-ao.webp', - metalnessMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic-specular.webp', normalMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic-normal.webp', }, mapProperties: { @@ -1948,11 +1924,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ label: 'White', category: 'colors', description: 'Clean painted finish', - previewColor: '#ffffff', + // Real white paint reflects ~80%; a literal #ffffff albedo kills GI/shadow + // contrast, so "white" is clamped to ≈0.83 linear. + previewColor: '#e9e9e9', preset: { maps: {}, mapProperties: { - color: '#ffffff', + color: '#e9e9e9', roughness: 0.9, metalness: 0, repeatX: 1, @@ -1980,11 +1958,11 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ label: 'Soft White', category: 'colors', description: 'Warm off-white painted finish', - previewColor: '#f2eee6', + previewColor: '#ebe7df', preset: { maps: {}, mapProperties: { - color: '#f2eee6', + color: '#ebe7df', roughness: 0.9, metalness: 0, repeatX: 1, diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts index 59c85cd0..5a07a966 100644 --- a/packages/core/src/schema/material.ts +++ b/packages/core/src/schema/material.ts @@ -115,7 +115,7 @@ export type MaterialPresetPayload = z.infer export const DEFAULT_MATERIALS: Record = { white: { - color: '#ffffff', + color: '#e9e9e9', roughness: 0.9, metalness: 0, opacity: 1, @@ -163,7 +163,7 @@ export const DEFAULT_MATERIALS: Record = { side: 'front', }, plaster: { - color: '#f5f5dc', + color: '#ebebd3', roughness: 0.95, metalness: 0, opacity: 1, @@ -179,7 +179,7 @@ export const DEFAULT_MATERIALS: Record = { side: 'front', }, marble: { - color: '#fafafa', + color: '#ebebeb', roughness: 0.2, metalness: 0.1, opacity: 1, @@ -187,7 +187,7 @@ export const DEFAULT_MATERIALS: Record = { side: 'front', }, custom: { - color: '#ffffff', + color: '#e9e9e9', roughness: 0.5, metalness: 0, opacity: 1, diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 3fdec0ed..67de8a06 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -391,6 +391,20 @@ export const CustomCameraControls = () => { raycaster.layers.enable(ZONE_LAYER) }, [camera, raycaster]) + useEffect(() => { + // Dev-only: deterministic camera poses for screenshot/automation tooling. + // A getter, not a snapshot — drei recreates the impl when the default + // camera changes, so a captured instance goes stale. + if (process.env.NODE_ENV !== 'development') return + const w = window as typeof window & { + __pascalCameraControls?: (() => CameraControlsImpl | null) | null + } + w.__pascalCameraControls = () => controls.current + return () => { + w.__pascalCameraControls = null + } + }, []) + useEffect(() => { if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return let targetY = 0 diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index 72c840a8..159885f8 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -1,7 +1,16 @@ 'use client' import { emitter, sceneRegistry } from '@pascal-app/core' -import { GRID_LAYER, SSGI_PARAMS, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer' +import { + backdropGradient, + deepSkyColor, + GRID_LAYER, + getSceneTheme, + horizonHazeColor, + SSGI_PARAMS, + snapLevelsToTruePositions, + useViewer, +} from '@pascal-app/viewer' import type { CameraControls } from '@react-three/drei' import { useThree } from '@react-three/fiber' import { useCallback, useEffect, useRef } from 'react' @@ -16,11 +25,15 @@ import { diffuseColor, directionToColor, float, + mix, mrt, normalView, output, pass, sample, + screenUV, + smoothstep, + uniform, vec4, } from 'three/tsl' import { RenderPipeline, RenderTarget, type WebGPURenderer } from 'three/webgpu' @@ -54,6 +67,18 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro const pipelineRef = useRef(null) const renderTargetRef = useRef(null) + // Backdrop compositing for scene snapshots (studio renders, project + // thumbnails): theme background + sky gradient, same world-ray math as the + // viewport backdrop in viewer's post-processing. Uniform-driven so the one + // cached pipeline serves both opaque and transparent (preset/item) captures. + const bgColorUniform = useRef(uniform(new THREE.Color('#ffffff'))) + const bgSkyUniform = useRef(uniform(new THREE.Color('#ffffff'))) + const bgSkyDeepUniform = useRef(uniform(new THREE.Color('#ffffff'))) + const bgHazeUniform = useRef(uniform(new THREE.Color('#ffffff'))) + const bgProjInvUniform = useRef(uniform(new THREE.Matrix4())) + const bgCamWorldUniform = useRef(uniform(new THREE.Matrix4())) + const bgMixUniform = useRef(uniform(1)) + useEffect(() => { onThumbnailCaptureRef.current = onThumbnailCapture }, [onThumbnailCapture]) @@ -112,8 +137,41 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro denoisePass.index.value = 0 denoisePass.radius.value = 4 - const ao = (denoisePass as any).r - const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a) + // Same far-field AO fade as the viewport pipeline — without it the + // horizon picks up a visible AO line in captures. + const aoFarFade = smoothstep( + float(0.9994), + float(0.9998), + scenePassDepth.sample(screenUV).r, + ) + const ao = mix((denoisePass as any).r, float(1), aoFarFade) + const sceneRgb = scenePassColor.rgb.mul(ao) + + // Per-pixel world ray from the capture camera → sky gradient above the + // horizon (dir.y = 0), flat background below — mirrors the viewport + // backdrop. bgMix 0 bypasses it and keeps the capture transparent. + const ndc = vec4( + screenUV.x.mul(2).sub(1), + float(1).sub(screenUV.y).mul(2).sub(1), + 1, + 1, + ) as any + const viewRay = (bgProjInvUniform.current as any).mul(ndc) + const worldDir = (bgCamWorldUniform.current as any) + .mul(vec4(viewRay.xyz, 0)) + .xyz.normalize() + const bgGradient = backdropGradient({ + dirY: worldDir.y, + background: bgColorUniform.current, + haze: bgHazeUniform.current, + sky: bgSkyUniform.current, + skyDeep: bgSkyDeepUniform.current, + }) + const alpha = scenePassColor.a + const finalOutput = vec4( + mix(sceneRgb, mix(bgGradient, sceneRgb, alpha), bgMixUniform.current), + mix(alpha, float(1), bgMixUniform.current), + ) // FXAA requires a texture node as input; convertToTexture renders finalOutput // into an intermediate RT so FXAA can sample it with neighbour UV offsets. @@ -152,6 +210,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro captureMode?: 'standard' | 'viewport' | 'area', cropRegion?: { x: number; y: number; width: number; height: number }, standardSize?: { w: number; h: number }, + transparent = false, ) => { const standardW = standardSize?.w ?? THUMBNAIL_WIDTH const standardH = standardSize?.h ?? THUMBNAIL_HEIGHT @@ -176,6 +235,21 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro const { width, height } = gl.domElement thumbnailCamera.aspect = width / height thumbnailCamera.updateProjectionMatrix() + // The capture camera never joins the scene graph, so its matrixWorld + // is only refreshed by the render itself — too late for the backdrop + // uniforms below. + thumbnailCamera.updateMatrixWorld() + + const theme = getSceneTheme(useViewer.getState().sceneTheme) + bgColorUniform.current.value.set(theme.background) + bgSkyUniform.current.value.set(theme.backgroundSky ?? theme.background) + bgSkyDeepUniform.current.value.set(deepSkyColor(theme.backgroundSky ?? theme.background)) + bgHazeUniform.current.value.set( + horizonHazeColor(theme.backgroundSky ?? theme.background, theme.appearance), + ) + bgProjInvUniform.current.value.copy(thumbnailCamera.projectionMatrixInverse) + bgCamWorldUniform.current.value.copy(thumbnailCamera.matrixWorld) + bgMixUniform.current.value = transparent ? 0 : 1 // Capture camera data for snapshot storage const pos = mainCamera.position @@ -473,10 +547,9 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro cropRegion?: { x: number; y: number; width: number; height: number } standardSize?: { w: number; h: number } snapLevels?: boolean - // `transparent` is informational here — the render pipeline already - // captures with alpha (see `setClearAlpha(0)` above) — the flag is - // forwarded so future tweaks (suppressing the ground occluder, theme - // background bits) can branch on it without touching the emitter. + // Preset/item captures keep the alpha channel (their thumbnails compose + // onto arbitrary palette backgrounds); scene snapshots — studio renders + // and project thumbnails — composite the theme backdrop + sky. transparent?: boolean }) => { await generate( @@ -484,6 +557,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro event.captureMode, event.cropRegion, event.standardSize, + event.transparent === true, ) } diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index 514c744a..21b30048 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -9,7 +9,10 @@ import { useScene, } from '@pascal-app/core' import { + backdropGradient, + deepSkyColor, getSceneTheme, + horizonHazeColor, NodeRenderer, unionPolygons, useNodeEvents, @@ -18,16 +21,22 @@ import { import { useEffect, useMemo, useRef } from 'react' import { BufferGeometry, + CircleGeometry, Float32BufferAttribute, type Group, Path, Shape, ShapeGeometry, } from 'three' +import { cameraPosition, color, float, mix, positionWorld, smoothstep, vec2 } from 'three/tsl' import { MeshLambertNodeMaterial } from 'three/webgpu' 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 @@ -63,11 +72,38 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { 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. @@ -79,6 +115,61 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { 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]) + + const horizonGeometry = useMemo(() => { + if (!fadeBounds) return null + return new CircleGeometry(Math.max(fadeBounds.radius * 8, 400), 64) + }, [fadeBounds]) + useEffect(() => () => horizonGeometry?.dispose(), [horizonGeometry]) + // Cache slab polygon references to keep the selector stable across unrelated store updates const slabPolygonsCache = useRef<[number, number][][]>([]) const slabPolygons = useScene((state: S) => { @@ -177,6 +268,18 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { /> )} + {/* Infinite-ground presentation disc fading into the sky at the horizon */} + {horizonGeometry && horizonMaterial && fadeBounds && ( + + )} + {/* Simple boundary line */} {/* @ts-ignore */} diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index 72ffa64b..7f64f622 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -12,6 +12,7 @@ import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three- import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2' import { ZONE_LAYER } from '../../lib/layers' import { createSurfaceRoleMaterial } from '../../lib/materials' +import { applyShadowOnly, clearShadowOnly } from '../../lib/shadow-only' import useViewer from '../../store/use-viewer' import { GlbInteractive, type GlbInteractiveItem } from './glb-interactive' import { GlbReferenceNodes } from './glb-reference-nodes' @@ -592,14 +593,25 @@ export function GlbScene({ if (levels.length === 0) return const { levelMode, selection, walkthroughMode } = useViewer.getState() const selectedLevel = selection.levelId + const selectedIdx = selectedLevel ? levels.findIndex((l) => l.id === selectedLevel) : -1 levels.forEach(({ id, node, baseY }, index) => { const exploded = !walkthroughMode && levelMode === 'exploded' const targetY = baseY + (exploded ? index * EXPLODED_GAP : 0) // Snap (not lerp) in walkthrough so the first-person collider, built from // these world positions, matches the stacked building immediately. node.position.y = walkthroughMode ? targetY : lerp(node.position.y, targetY, delta * 12) - node.visible = - walkthroughMode || levelMode !== 'solo' || !selectedLevel || id === selectedLevel + // Solo: hidden levels above the soloed one keep casting shadows + // (shadow-caster-only); below-levels can't block the sun, so plain-hide. + const hidden = + !walkthroughMode && levelMode === 'solo' && Boolean(selectedLevel) && id !== selectedLevel + const castsWhileHidden = hidden && selectedIdx >= 0 && index > selectedIdx + if (castsWhileHidden) { + applyShadowOnly(node) + node.visible = true + } else { + clearShadowOnly(node) + node.visible = !hidden + } }) }, 5) @@ -819,7 +831,12 @@ export function GlbScene({ // focused level actually has rooms. Focusing a zone-less floor keeps the // building intact (otherwise its roof would just vanish with nothing to show). const revealing = !walk && selection.levelId != null && levelsWithZones.has(selection.levelId) - for (const mesh of occluderOwnMeshes) mesh.visible = !revealing + // Shadow-caster-only: hidden roof/ceiling meshes keep casting sun shadows + // so interiors show window light patches instead of uniform sun flood. + for (const mesh of occluderOwnMeshes) { + if (revealing) applyShadowOnly(mesh) + else clearShadowOnly(mesh) + } for (const { id, levelId, meshes, uniforms } of zoneFills.current) { const show = @@ -1031,7 +1048,7 @@ export function GlbScene({ outliner.hoveredObjects.length = 0 document.body.style.cursor = 'auto' // Restore ceilings/roof — the GLB scene is cached by drei and may be reused. - for (const mesh of occluderOwnMeshes) mesh.visible = true + for (const mesh of occluderOwnMeshes) clearShadowOnly(mesh) }, [occluderOwnMeshes], ) diff --git a/packages/viewer/src/components/viewer/ground-occluder.tsx b/packages/viewer/src/components/viewer/ground-occluder.tsx deleted file mode 100644 index b939fb16..00000000 --- a/packages/viewer/src/components/viewer/ground-occluder.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { type LevelNode, useScene } from '@pascal-app/core' -import { useMemo } from 'react' -import * as THREE from 'three' -import { unionPolygons } from '../../lib/polygon-union' -import { getSceneTheme } from '../../lib/scene-themes' -import useViewer from '../../store/use-viewer' - -export const GroundOccluder = () => { - const bgColor = useViewer((state) => getSceneTheme(state.sceneTheme).ground) - - const nodes = useScene((state) => state.nodes) - - const shape = useMemo(() => { - const s = new THREE.Shape() - const size = 1000 - // Create outer infinite plane - s.moveTo(-size, -size) - s.lineTo(size, -size) - s.lineTo(size, size) - s.lineTo(-size, size) - s.closePath() - - const levelIndexById = new Map() - let lowestLevelIndex = Number.POSITIVE_INFINITY - - Object.values(nodes).forEach((node) => { - if (node.type !== 'level') { - return - } - - levelIndexById.set(node.id, node.level) - lowestLevelIndex = Math.min(lowestLevelIndex, node.level) - }) - - // Only the lowest level should punch through the ground plane. - // Upper-level slabs should still cast shadows, but they should not - // reveal their footprint on the level-zero ground material. - const polygons: [number, number][][] = [] - - Object.values(nodes).forEach((node) => { - if ( - !( - node.type === 'slab' && - node.visible && - node.polygon.length >= 3 && - // Only recessed slabs should punch through the ground plane. - (node.elevation ?? 0.05) < 0 - ) - ) { - return - } - - if (Number.isFinite(lowestLevelIndex)) { - const parentLevelIndex = node.parentId - ? levelIndexById.get(node.parentId as LevelNode['id']) - : undefined - - if (parentLevelIndex !== lowestLevelIndex) { - return - } - } - - polygons.push(node.polygon as [number, number][]) - }) - - if (polygons.length > 0) { - for (const ring of unionPolygons(polygons.map((pts) => pts.map((p) => [p[0], -p[1]])))) { - if (ring.length < 3) continue - const hole = new THREE.Path() - - hole.moveTo(ring[0]![0], ring[0]![1]) - for (let i = 1; i < ring.length; i++) { - hole.lineTo(ring[i]![0], ring[i]![1]) - } - hole.closePath() - s.holes.push(hole) - } - } - - return s - }, [nodes]) - - return ( - - - - - ) -} diff --git a/packages/viewer/src/components/viewer/lights.tsx b/packages/viewer/src/components/viewer/lights.tsx index 27dbf6c4..33daa7f1 100644 --- a/packages/viewer/src/components/viewer/lights.tsx +++ b/packages/viewer/src/components/viewer/lights.tsx @@ -8,6 +8,7 @@ import type { OrthographicCamera, } from 'three/webgpu' import * as THREE from 'three/webgpu' +import { SHADOW_ONLY_LAYER } from '../../lib/layers' import { getSceneTheme } from '../../lib/scene-themes' import useViewer from '../../store/use-viewer' @@ -23,9 +24,11 @@ const SHADOWS_DISABLED = ).has('shadows') // Shadow darkness for the bright key lights (themes drive most lights past -// intensity 1). The aesthetic prototype runs these near-black (≈1.0); this is a -// deliberate middle ground — present, but not the heavy contact shadow there. -const MAX_SHADOW_INTENSITY = 0.55 +// intensity 1). Runs high so shadowed areas actually lose the sun's +// contribution — the ambient/hemisphere/IBL stack provides the fill. The old +// 0.55 clamp leaked 45% of the key light into shadow and flattened interiors; +// 0.9 read too heavy in review. +const MAX_SHADOW_INTENSITY = 0.75 // 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 @@ -151,6 +154,9 @@ export function Lights() { // the below. const cam = light.shadow?.camera as THREE.OrthographicCamera | undefined if (cam) { + // Shadow-caster-only geometry (hidden roofs/levels in cutaway views) + // is visible to the shadow pass alone — see lib/shadow-only.ts. + cam.layers.enable(SHADOW_ONLY_LAYER) cam.left = -size cam.right = size cam.top = size @@ -248,7 +254,7 @@ export function Lights() { shadow-bias={-0.002} shadow-mapSize={[1024, 1024]} shadow-normalBias={0.3} - shadow-radius={1.5} + shadow-radius={2} > {light.castShadow && !SHADOWS_DISABLED && shadows ? ( { const l = new Layers() @@ -435,6 +470,17 @@ const PostProcessingPasses = ({ ao = giTexture.a } + // AO is a near/mid-field cue like the ink: fade it out with raw depth + // (same ≈150→350 m window as ink-edges' distanceFade) so the horizon + // disc and the geometry↔sky depth cliff never grow an AO band — that + // band read as a visible line along the horizon. + const aoFarFade = smoothstep( + float(0.9994), + float(0.9998), + scenePassDepth.sample(screenUV).r, + ) + ao = mix(ao, float(1), aoFarFade) + // Composite: scene * AO + diffuse * GI sceneColor = vec4( add(scenePassColor.rgb.mul(ao), add(zonePass.rgb, scenePassDiffuse.rgb.mul(gi))), @@ -453,12 +499,26 @@ const PostProcessingPasses = ({ normalTex: scenePassNormal, inkColor: inkColorUniform.current, radius: inkRadius, - opacity: inkOpacity, + opacity: float(inkOpacity).mul(inkOpacityScaleUniform.current), }), sceneColor.a, ) } + // Scene-referred grade (contrast around mid-gray + saturation) before the + // pipeline's output tone mapping. Kept out of solid/schematic shading so + // the flat presets stay exact. The same transform is applied to the + // backdrop below so geometry that fades to the background colour (the + // horizon disc) matches it exactly. + const gradeRgb = (rgb: any) => + saturation( + rgb.div(0.18).pow(vec3(GRADE_PARAMS.contrast)).mul(0.18), + GRADE_PARAMS.saturation, + ) + if (shading === 'rendered') { + sceneColor = vec4(gradeRgb(sceneColor.rgb), sceneColor.a) + } + // Single merged outline node: one shared depth pass for both selected + hovered groups. const outliner = useViewer.getState().outliner let compositeWithOutlines = sceneColor @@ -501,7 +561,29 @@ const PostProcessingPasses = ({ ) } - const composited = mix(bgUniform.current, compositeWithOutlines.rgb, contentAlpha) + // Backdrop: world-space view ray per pixel → background / horizon haze / + // sky gradient (shared formula in lib/backdrop.ts). The horizon disc + // dissolves into the same formula, so backdrop and ground meet + // seamlessly exactly where the disc vanishes. + const ndc = vec4( + screenUV.x.mul(2).sub(1), + float(1).sub(screenUV.y).mul(2).sub(1), + 1, + 1, + ) as any + const viewRay = (camProjInvUniform.current as any).mul(ndc) + const worldDir = (camWorldUniform.current as any).mul(vec4(viewRay.xyz, 0)).xyz.normalize() + let bgGradient = backdropGradient({ + dirY: worldDir.y, + background: bgUniform.current, + haze: bgHazeUniform.current, + sky: bgSkyUniform.current, + skyDeep: bgSkyDeepUniform.current, + }) + if (shading === 'rendered') { + bgGradient = gradeRgb(bgGradient) + } + const composited = mix(bgGradient, compositeWithOutlines.rgb, contentAlpha) // Editor overlays painted on top by their own alpha — they never get inked, // AO'd, or outlined, and always read crisp regardless of scene depth. const withOverlay = mix(composited, overlayColor.rgb, overlayColor.a) @@ -596,11 +678,27 @@ const PostProcessingPasses = ({ } // Animate background colour toward the current scene theme target (same lerp as AnimatedBackground) - bgTarget.current.set(getSceneTheme(useViewer.getState().sceneTheme).background) + const bgTheme = getSceneTheme(useViewer.getState().sceneTheme) + bgTarget.current.set(bgTheme.background) bgCurrent.current.lerp(bgTarget.current, Math.min(delta, 0.1) * 4) bgUniform.current.value.copy(bgCurrent.current) + bgSkyTarget.current.set(bgTheme.backgroundSky ?? bgTheme.background) + bgSkyCurrent.current.lerp(bgSkyTarget.current, Math.min(delta, 0.1) * 4) + bgSkyUniform.current.value.copy(bgSkyCurrent.current) + bgHazeTarget.current.set( + horizonHazeColor(bgTheme.backgroundSky ?? bgTheme.background, bgTheme.appearance), + ) + bgHazeCurrent.current.lerp(bgHazeTarget.current, Math.min(delta, 0.1) * 4) + bgHazeUniform.current.value.copy(bgHazeCurrent.current) + bgSkyDeepTarget.current.set(deepSkyColor(bgTheme.backgroundSky ?? bgTheme.background)) + bgSkyDeepCurrent.current.lerp(bgSkyDeepTarget.current, Math.min(delta, 0.1) * 4) + bgSkyDeepUniform.current.value.copy(bgSkyDeepCurrent.current) + camProjInvUniform.current.value.copy(camera.projectionMatrixInverse) + camWorldUniform.current.value.copy(camera.matrixWorld) // Ink colour follows the (lerping) background luminance — snaps dark↔light. - inkColorUniform.current.value.set(edgeColorFor(`#${bgCurrent.current.getHexString()}`)) + const bgHex = `#${bgCurrent.current.getHexString()}` + inkColorUniform.current.value.set(edgeColorFor(bgHex)) + inkOpacityScaleUniform.current.value = edgeOpacityScaleFor(bgHex) const outliner = useViewer.getState().outliner sanitizeOutlineObjects(outliner.selectedObjects) diff --git a/packages/viewer/src/components/viewer/scene-environment.tsx b/packages/viewer/src/components/viewer/scene-environment.tsx index c17df444..23c332d4 100644 --- a/packages/viewer/src/components/viewer/scene-environment.tsx +++ b/packages/viewer/src/components/viewer/scene-environment.tsx @@ -1,27 +1,86 @@ 'use client' -import { Environment } from '@react-three/drei' -import { Suspense } from 'react' +import { useThree } from '@react-three/fiber' +import { useEffect, useMemo } from 'react' +import * as THREE from 'three/webgpu' +import { getSceneTheme } from '../../lib/scene-themes' +import useViewer from '../../store/use-viewer' /** - * Scene IBL — drei's prefiltered environment map, exported as an opt-in - * *child* rather than baked into the Viewer component, so embed / - * thumbnail surfaces that don't want the HDRI fetch simply don't mount it. - * This is what gives PBR metals their reflections and lifts the lighting on - * vertical surfaces (walls), which flat directional + hemisphere lights can't - * do alone. Intensity is dialled below the preset default so it complements - * the scene lights rather than washing them out. Only visible in `rendered` - * shading. - * - * The HDR is self-hosted (drei's `preset="sunset"` resolves to the same - * `venice_sunset_1k.hdr` on raw.githack.com, which intermittently fails). - * Every app that mounts this — like `/audios/sfx` — must ship the file in its - * own `public/hdri/`. + * Scene IBL — a small procedural gradient sky (cool zenith → warm horizon → + * dim ground bounce) used purely as the environment light source. The visible + * backdrop stays the flat theme background (composited in post-processing); + * this texture is never shown. Replaces the old `venice_sunset_1k.hdr` fetch: + * no network dependency, and the vertical color split means upward-facing + * surfaces read cooler than vertical ones instead of everything getting the + * same directionless warm wash. Exported as an opt-in child so + * embed / thumbnail surfaces that don't want IBL simply don't mount it. + * Only affects `rendered` shading (Lambert ignores env maps). */ + +// Linear-space gradient stops. +const ZENITH = [0.4, 0.56, 0.78] as const +const HORIZON = [0.95, 0.84, 0.66] as const +const GROUND = [0.38, 0.35, 0.3] as const + +const ENV_INTENSITY = 0.6 +// The gradient sky is a daylight source; dark themes only want a whisper of it. +const ENV_INTENSITY_DARK = 0.2 +const WIDTH = 64 +const HEIGHT = 32 + +function buildGradientSky(): THREE.DataTexture { + const data = new Float32Array(WIDTH * HEIGHT * 4) + for (let y = 0; y < HEIGHT; y++) { + // Row 0 = v0 = nadir, top row = zenith (equirect v spans -90°..+90°). + const lat = ((y + 0.5) / HEIGHT) * 2 - 1 + let r: number + let g: number + let b: number + if (lat <= 0) { + // Below the horizon: flat ground bounce, slightly darker toward nadir. + const k = 1 + lat * 0.35 + r = GROUND[0] * k + g = GROUND[1] * k + b = GROUND[2] * k + } else { + // pow < 1 widens the warm horizon band. + const t = lat ** 0.65 + r = HORIZON[0] + (ZENITH[0] - HORIZON[0]) * t + g = HORIZON[1] + (ZENITH[1] - HORIZON[1]) * t + b = HORIZON[2] + (ZENITH[2] - HORIZON[2]) * t + } + for (let x = 0; x < WIDTH; x++) { + const i = (y * WIDTH + x) * 4 + data[i] = r + data[i + 1] = g + data[i + 2] = b + data[i + 3] = 1 + } + } + const texture = new THREE.DataTexture(data, WIDTH, HEIGHT, THREE.RGBAFormat, THREE.FloatType) + texture.mapping = THREE.EquirectangularReflectionMapping + texture.colorSpace = THREE.LinearSRGBColorSpace + texture.needsUpdate = true + return texture +} + export function SceneEnvironment() { - return ( - - - - ) + const scene = useThree((state) => state.scene) + const texture = useMemo(buildGradientSky, []) + const appearance = useViewer((state) => getSceneTheme(state.sceneTheme).appearance) + + useEffect(() => { + const prevEnvironment = scene.environment + const prevIntensity = scene.environmentIntensity + scene.environment = texture + scene.environmentIntensity = appearance === 'dark' ? ENV_INTENSITY_DARK : ENV_INTENSITY + return () => { + scene.environment = prevEnvironment + scene.environmentIntensity = prevIntensity + texture.dispose() + } + }, [scene, texture, appearance]) + + return null } diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 2dd8780e..bff46a0d 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -46,6 +46,7 @@ export { useAssetUrl } from './hooks/use-asset-url' export { useGLTFKTX2 } from './hooks/use-gltf-ktx2' export { useNodeEvents } from './hooks/use-node-events' export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' +export { backdropGradient, deepSkyColor, horizonHazeColor } from './lib/backdrop' export { applyWorldScaleBoxUVs } from './lib/box-uv' // CSG primitives — used by chimney's roof-trim and other kinds whose // geometry subtracts pieces against their host. Lives in viewer diff --git a/packages/viewer/src/lib/backdrop.ts b/packages/viewer/src/lib/backdrop.ts new file mode 100644 index 00000000..bb172193 --- /dev/null +++ b/packages/viewer/src/lib/backdrop.ts @@ -0,0 +1,108 @@ +import { abs, exp, mix, smoothstep } from 'three/tsl' + +/** + * Shared backdrop gradient: flat background looking down, a warm haze band + * hugging the horizon, the theme sky arriving just above it and deepening + * toward the zenith. One formula serves the post-processing backdrop and the + * thumbnail pipeline (per-pixel view ray) AND the site horizon disc's + * far-field dissolve (per-fragment view direction) — the disc converges to + * exactly this colour, so ground and backdrop meet without a seam from any + * camera pose. + * + * `dirY` is the world-space view direction's Y component (horizon = 0). + * Colour inputs are TSL nodes (uniforms or literals). + */ +export function backdropGradient({ + dirY, + background, + haze, + sky, + skyDeep, +}: { + dirY: any + background: any + haze: any + sky: any + skyDeep: any +}): any { + // The pale sky arrives fast (full by ≈8° elevation) so eye-level frames + // actually show it, then deepens toward the zenith for a real gradient — + // a single pale stop reads as a white void with blue "hiding" up top. + let base = (mix as any)(background, sky, smoothstep(-0.02, 0.14, dirY)) + base = (mix as any)(base, skyDeep, smoothstep(0.1, 0.55, dirY)) + // Haze as an exponential glow hugging the horizon tightly: C¹-smooth on + // both sides, so it brightens the junction without ever drawing an edge. + const hazeWeight = exp(abs(dirY).mul(-11)).mul(0.8) + return (mix as any)(base, haze, hazeWeight) +} + +// Warm sun tint the haze pulls toward — aerial perspective is sky-coloured +// light plus a little sun scatter, not white fog. +const HAZE_SUN_TINT = [255, 244, 222] as const + +/** + * Atmospheric haze at the horizon: the theme's *sky* colour pulled toward a + * warm sun tint — skyish and sunish at once, so the band reads as part of + * the sky rather than a white stripe. Dark themes keep it faint (a low glow + * over the night zenith). + */ +export function horizonHazeColor(sky: string, appearance: 'light' | 'dark'): string { + const amount = appearance === 'dark' ? 0.25 : 0.5 + const hex = sky.replace('#', '') + let out = '#' + for (let i = 0; i < 3; i++) { + const c = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) + out += Math.round(c + (HAZE_SUN_TINT[i]! - c) * amount) + .toString(16) + .padStart(2, '0') + } + return out +} + +/** + * Zenith colour derived from the theme's sky: saturated and darkened in HSL + * so the hue stays the theme's own (blue studio, lavender sunset, near-black + * night) while the top of the frame gets real colour depth. + */ +export function deepSkyColor(sky: string): string { + const hex = sky.replace('#', '') + const r = Number.parseInt(hex.slice(0, 2), 16) / 255 + const g = Number.parseInt(hex.slice(2, 4), 16) / 255 + const b = Number.parseInt(hex.slice(4, 6), 16) / 255 + + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const l = (max + min) / 2 + const d = max - min + let h = 0 + let s = 0 + if (d > 0) { + s = d / (1 - Math.abs(2 * l - 1)) + if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6 + else if (max === g) h = ((b - r) / d + 2) / 6 + else h = ((r - g) / d + 4) / 6 + } + + const s2 = Math.min(1, s * 1.5 + 0.05) + const l2 = l * 0.72 + + const c = (1 - Math.abs(2 * l2 - 1)) * s2 + const x = c * (1 - Math.abs(((h * 6) % 2) - 1)) + const m = l2 - c / 2 + const sector = Math.floor(h * 6) % 6 + const rgb = [ + [c, x, 0], + [x, c, 0], + [0, c, x], + [0, x, c], + [x, 0, c], + [c, 0, x], + ][sector]! + let out = '#' + for (const channel of rgb) { + out += Math.round((channel + m) * 255) + .toString(16) + .padStart(2, '0') + } + return out +} diff --git a/packages/viewer/src/lib/edge-style.ts b/packages/viewer/src/lib/edge-style.ts index e0226ece..db9f7b9d 100644 --- a/packages/viewer/src/lib/edge-style.ts +++ b/packages/viewer/src/lib/edge-style.ts @@ -2,14 +2,35 @@ // mode. `off`/`soft`/`strong` map to ink intensity in the post-processing pass. export type EdgeMode = 'off' | 'soft' | 'strong' -// Ink line colour follows background luminance — light backgrounds get -// near-black lines, dark backgrounds get near-white. Same rule Mapbox uses for -// label outlines, so edges stay legible across every scene theme. -export function edgeColorFor(background: string): string { +function lumaOf(background: string): number { const hex = background.replace('#', '') const r = Number.parseInt(hex.slice(0, 2), 16) / 255 const g = Number.parseInt(hex.slice(2, 4), 16) / 255 const b = Number.parseInt(hex.slice(4, 6), 16) / 255 - const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b - return luma > 0.5 ? '#1a1d24' : '#dde2eb' + return 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +// Ink line colour follows background luminance — light backgrounds get +// near-black lines (the rule Mapbox uses for label outlines). Dark backgrounds +// get a tint *derived from the background* rather than a near-white constant: +// full-white lines glow harshly against night/twilight scenes, while lifting +// the background's own hue toward white stays legible but reads native to the +// theme. +export function edgeColorFor(background: string): string { + if (lumaOf(background) > 0.5) return '#1a1d24' + const hex = background.replace('#', '') + let out = '#' + for (let i = 0; i < 3; i++) { + const c = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) / 255 + out += Math.round((c + (1 - c) * 0.42) * 255) + .toString(16) + .padStart(2, '0') + } + return out +} + +// Dark scenes additionally run the ink slightly transparent — even a muted +// tint at full alpha reads as glowing wireframe against a low-luma backdrop. +export function edgeOpacityScaleFor(background: string): number { + return lumaOf(background) > 0.5 ? 1 : 0.7 } diff --git a/packages/viewer/src/lib/ink-edges.ts b/packages/viewer/src/lib/ink-edges.ts index 19ea36fb..3b928b51 100644 --- a/packages/viewer/src/lib/ink-edges.ts +++ b/packages/viewer/src/lib/ink-edges.ts @@ -41,8 +41,9 @@ export function inkedEdges({ // Line thickness in px (the detected band is ~2×radius) and final line // darkness — these are what distinguish soft (thin/faint) from strong // (thick/solid); the edge masks themselves saturate, so a gain wouldn't. + // Opacity may be a TSL node (theme-driven scaling) or a plain number. radius: number - opacity: number + opacity: any sceneRgb: any }) { const px = vec2(1, 1).div(screenSize).mul(radius) @@ -66,6 +67,12 @@ export function inkedEdges({ const nL = colorToDirection(normalTex.sample(uvN.sub(vec2(px.x, 0)))).normalize() const nU = colorToDirection(normalTex.sample(uvN.add(vec2(0, px.y)))).normalize() const nD = colorToDirection(normalTex.sample(uvN.sub(vec2(0, px.y)))).normalize() + // Ink is a near/mid-field affordance: fade it out with raw depth so the + // horizon (the infinite ground disc vanishing against the backdrop) and + // other far-field depth cliffs never draw a line across the sky junction. + // ~full ink below ≈150 m, none beyond ≈350 m (perspective near 0.1/far 1000). + const distanceFade = float(1).sub(smoothstep(float(0.9994), float(0.9998), dC)) + const nDiff = max( max(float(1).sub(nC.dot(nR)), float(1).sub(nC.dot(nL))), max(float(1).sub(nC.dot(nU)), float(1).sub(nC.dot(nD))), @@ -74,6 +81,6 @@ export function inkedEdges({ // TSL's typed overloads are finicky across versions; the runtime is proven in // the aesthetic sandbox, so cast at the mask/mix boundary. - const edgeMask: any = min(max(depthEdge, normalEdge).mul(opacity), float(1)) + const edgeMask: any = min(max(depthEdge, normalEdge).mul(opacity).mul(distanceFade), float(1)) return (mix as any)(sceneRgb, inkColor, edgeMask) } diff --git a/packages/viewer/src/lib/layers.ts b/packages/viewer/src/lib/layers.ts index b169fa03..a0603be0 100644 --- a/packages/viewer/src/lib/layers.ts +++ b/packages/viewer/src/lib/layers.ts @@ -23,3 +23,14 @@ export const ZONE_LAYER = 2 * from thumbnails like the other editor-only layers. */ export const GRID_LAYER = 3 + +/** + * Layer for geometry hidden from the color passes but still rendered into the + * shadow map ("shadow-caster-only"). Used when cutaway/solo views hide roofs + * or non-selected levels: the sun keeps casting their shadows, so interiors + * get window-shaped light patches instead of flooding with uniform sun. + * No camera or pass enables this layer — only each shadow-casting light's + * shadow camera does (see `lights.tsx`). Applied per-object (layers don't + * cascade) via `applyShadowOnly` / `clearShadowOnly` in `lib/shadow-only.ts`. + */ +export const SHADOW_ONLY_LAYER = 4 diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 160dce8a..d1e0f4a0 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -11,6 +11,7 @@ import { type SurfaceRole, } from '@pascal-app/core' import * as THREE from 'three' +import { float, mix, positionViewDirection, transformedNormalView } from 'three/tsl' import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu' import { resolveCdnUrl } from './asset-url' @@ -30,14 +31,16 @@ export const CLAY_PALETTE: Record = { furnishing: '#d2ccbe', } +// Albedos are clamped to ≈0.83 linear (max channel #eb): real white paint +// reflects ~80%, and pure-white albedo kills GI/shadow contrast. export const WHITE_PALETTE: Record = { - wall: '#f4f3ef', - floor: '#ece9e2', - ceiling: '#fbfaf6', + wall: '#ebeae6', + floor: '#e7e4dd', + ceiling: '#ebeae6', roof: '#dedbd2', - joinery: '#e8e5dc', + joinery: '#e5e2d9', glazing: '#dbe8ee', - furnishing: '#efede7', + furnishing: '#e9e7e1', } export const MONO_PALETTE: Record = { @@ -394,6 +397,32 @@ function applyMaterialMapProperties( material.needsUpdate = true } +// Glass-like transparency threshold: any standard material authored as +// `transparent` with opacity below this gets the fresnel treatment. +const GLASS_OPACITY_THRESHOLD = 0.6 + +/** + * Fresnel-driven opacity for glass: nearly the authored opacity head-on, + * increasingly opaque (showing the environment reflection) at grazing angles. + * This is what makes glass read as a surface instead of a flat blue tint. + */ +function applyGlassFresnel(material: MeshStandardNodeMaterial) { + const facing = transformedNormalView.dot(positionViewDirection).clamp(0, 1) + const fresnel = facing.oneMinus().pow(3) + material.opacityNode = mix(float(material.opacity), float(0.92), fresnel) + material.envMapIntensity = 1.4 +} + +function maybeApplyGlassFresnel(material: THREE.Material) { + if ( + material instanceof MeshStandardNodeMaterial && + material.transparent && + material.opacity < GLASS_OPACITY_THRESHOLD + ) { + applyGlassFresnel(material) + } +} + function applyMaterialPresetTextures(material: CommonMaterial, preset: MaterialPresetPayload) { const { maps, mapProperties } = preset @@ -446,6 +475,7 @@ export function createMaterialFromPreset( const material = shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial() applyMaterialPresetToMaterials(material, preset) + maybeApplyGlassFresnel(material) material.userData.__pascalCachedMaterial = true materialCache.set(cacheKey, material) return material @@ -496,6 +526,7 @@ export function createMaterial( metalness: props.metalness, }) + maybeApplyGlassFresnel(threeMaterial) threeMaterial.userData.__pascalCachedMaterial = true materialCache.set(cacheKey, threeMaterial) return threeMaterial @@ -618,11 +649,11 @@ export function createSurfaceRoleMaterial( } export function baseMaterial(shading: RenderShading = 'rendered'): THREE.Material { - return cachedDefaultMaterial('base', '#f2f0ed', 0.5, shading) + return cachedDefaultMaterial('base', '#e9e7e3', 0.5, shading) } export function DEFAULT_WALL_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material { - return cachedDefaultMaterial('wall', '#ffffff', 0.9, shading) + return cachedDefaultMaterial('wall', '#e9e6e0', 0.9, shading) } export function DEFAULT_SLAB_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material { @@ -657,12 +688,13 @@ export function DEFAULT_WINDOW_MATERIAL(shading: RenderShading = 'rendered'): TH roughness: 0.1, metalness: 0.1, }) + maybeApplyGlassFresnel(material) defaultMaterialCache.set(cacheKey, material) return material } export function DEFAULT_CEILING_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material { - return cachedDefaultMaterial('ceiling', '#f5f5dc', 0.95, shading) + return cachedDefaultMaterial('ceiling', '#ebebd3', 0.95, shading) } export function DEFAULT_ROOF_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material { @@ -670,11 +702,11 @@ export function DEFAULT_ROOF_MATERIAL(shading: RenderShading = 'rendered'): THRE } export function DEFAULT_SHELF_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material { - return cachedDefaultMaterial('shelf', '#ffffff', 0.9, shading) + return cachedDefaultMaterial('shelf', '#e9e6e0', 0.9, shading) } export function DEFAULT_STAIR_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material { - return cachedDefaultMaterial('stair', '#ffffff', 0.9, shading) + return cachedDefaultMaterial('stair', '#e9e6e0', 0.9, shading) } export function disposeMaterial(material: THREE.Material): void { diff --git a/packages/viewer/src/lib/scene-themes.ts b/packages/viewer/src/lib/scene-themes.ts index f544c738..c6398c8f 100644 --- a/packages/viewer/src/lib/scene-themes.ts +++ b/packages/viewer/src/lib/scene-themes.ts @@ -8,7 +8,11 @@ export type SceneTheme = { // the site ground fill. The 3D background + lights come from the fields below. appearance: 'light' | 'dark' background: string - // Colour of the site ground fill + infinite ground-occluder plane. Kept + // Optional zenith colour for the backdrop: the post pipeline renders a + // vertical screen-space gradient from this (top) to `background` (horizon). + // Omitted → flat `background`, as before. + backgroundSky?: string + // Colour of the site ground fill. Kept // separate from `background` so dark themes can have a lit ground that reads // as ground rather than going near-black. ground: string @@ -29,13 +33,14 @@ export const SCENE_THEMES: SceneTheme[] = [ id: 'studio', name: 'Studio', appearance: 'light', - background: '#ffffff', - ground: '#f4f4f2', + background: '#fbfbfa', + backgroundSky: '#b6cfe7', + ground: '#e9e7e2', ambient: { color: '#ffffff', intensity: 0.15 }, - hemi: { sky: '#ffffff', ground: '#aaa49a', intensity: 0.6 }, + hemi: { sky: '#ffffff', ground: '#aaa49a', intensity: 0.45 }, lights: [ { position: [10, 10, 10], color: '#ffffff', intensity: 4, castShadow: true }, - { position: [-10, 10, -10], color: '#ffffff', intensity: 0.75 }, + { position: [-10, 10, -10], color: '#ffffff', intensity: 0.6 }, ], toneMappingExposure: 0.9, clayTints: { @@ -51,6 +56,7 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Paper', appearance: 'light', background: '#ede9df', + backgroundSky: '#c0d2e4', ground: '#e7e1d3', ambient: { color: '#fff9eb', intensity: 0.55 }, hemi: { sky: '#fff5d9', ground: '#c2b89c', intensity: 0.35 }, @@ -72,6 +78,7 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Sunset', appearance: 'light', background: '#f6e8d4', + backgroundSky: '#b5bede', ground: '#ecd9bf', ambient: { color: '#ffd9a8', intensity: 0.45 }, hemi: { sky: '#ffd9a8', ground: '#5b4634', intensity: 0.4 }, @@ -93,11 +100,12 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Overcast', appearance: 'light', background: '#e6e7e6', + backgroundSky: '#c3ccd6', ground: '#dadcd9', ambient: { color: '#eef0ef', intensity: 1.1 }, hemi: { sky: '#f4f5f3', ground: '#bcbfbb', intensity: 0.9 }, lights: [{ position: [12, 28, 10], color: '#f4f5f3', intensity: 0.8, castShadow: true }], - toneMappingExposure: 0.95, + toneMappingExposure: 0.9, clayTints: { wall: '#dedfdc', floor: '#cdcec9', @@ -111,6 +119,7 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Blueprint', appearance: 'light', background: '#dde6ef', + backgroundSky: '#a5c4e2', ground: '#c9d6e6', ambient: { color: '#cfdcec', intensity: 0.7 }, hemi: { sky: '#dfeaf6', ground: '#5b6b80', intensity: 0.55 }, @@ -118,7 +127,7 @@ export const SCENE_THEMES: SceneTheme[] = [ { position: [16, 24, 12], color: '#e6efff', intensity: 1.8, castShadow: true }, { position: [-12, 10, -8], color: '#9fb6d8', intensity: 0.4 }, ], - toneMappingExposure: 0.95, + toneMappingExposure: 0.9, clayTints: { wall: '#9fb6d2', floor: '#8ba2c2', @@ -132,6 +141,7 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Mediterranean', appearance: 'light', background: '#bdd6e8', + backgroundSky: '#8ab4d6', ground: '#ddd2bb', ambient: { color: '#d6e6f3', intensity: 0.5 }, hemi: { sky: '#a8c8e2', ground: '#d8c9a4', intensity: 0.6 }, @@ -153,14 +163,15 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Twilight', appearance: 'dark', background: '#3a3550', - ground: '#4a4566', - ambient: { color: '#a89cc8', intensity: 0.35 }, - hemi: { sky: '#d8a8c0', ground: '#1c1830', intensity: 0.5 }, + backgroundSky: '#272338', + ground: '#67618a', + ambient: { color: '#a89cc8', intensity: 0.55 }, + hemi: { sky: '#d8a8c0', ground: '#3a3450', intensity: 0.7 }, lights: [ { position: [-14, 22, -10], color: '#a4b6e8', intensity: 1.4, castShadow: true }, { position: [14, 6, 8], color: '#ffb070', intensity: 0.9 }, ], - toneMappingExposure: 1.1, + toneMappingExposure: 0.9, clayTints: { wall: '#c5b9cf', floor: '#ad9fbb', @@ -174,12 +185,13 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Night', appearance: 'dark', background: '#1f2433', - ground: '#2b3247', - ambient: { color: '#a0b0ff', intensity: 0.07 }, - hemi: { sky: '#3a4666', ground: '#0e111c', intensity: 0.4 }, + backgroundSky: '#12161f', + ground: '#4a5470', + ambient: { color: '#a0b0ff', intensity: 0.25 }, + hemi: { sky: '#3a4666', ground: '#232a3d', intensity: 0.55 }, lights: [ - { position: [10, 10, 10], color: '#e0e5ff', intensity: 0.8, castShadow: true }, - { position: [-10, 10, -10], color: '#8090ff', intensity: 0.2 }, + { position: [10, 10, 10], color: '#e0e5ff', intensity: 1.2, castShadow: true }, + { position: [-10, 10, -10], color: '#8090ff', intensity: 0.3 }, ], toneMappingExposure: 0.9, clayTints: { @@ -195,6 +207,7 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Verdant', appearance: 'light', background: '#d6e4d2', + backgroundSky: '#aecde0', ground: '#c7d6b4', ambient: { color: '#e3efdd', intensity: 0.5 }, hemi: { sky: '#cfe6cf', ground: '#8ea06f', intensity: 0.65 }, @@ -202,7 +215,7 @@ export const SCENE_THEMES: SceneTheme[] = [ { position: [16, 22, 12], color: '#fff6d8', intensity: 3, castShadow: true }, { position: [-12, 10, -8], color: '#bfe0c2', intensity: 0.5 }, ], - toneMappingExposure: 0.95, + toneMappingExposure: 0.9, clayTints: { wall: '#eef0e6', floor: '#d8ddc6', diff --git a/packages/viewer/src/lib/shadow-only.ts b/packages/viewer/src/lib/shadow-only.ts new file mode 100644 index 00000000..c63c10ab --- /dev/null +++ b/packages/viewer/src/lib/shadow-only.ts @@ -0,0 +1,42 @@ +import type { Object3D } from 'three' +import { SCENE_LAYER, SHADOW_ONLY_LAYER } from './layers' + +/** + * Shadow-caster-only hiding: removes an object (and its descendants) from the + * color passes while keeping it in the shadow map, so a hidden roof or level + * still shadows the interior and sun enters through windows correctly. + * + * Uses layer masks instead of `visible = false` for two reasons: `visible` + * cascades (and, critically, prunes the object from the shadow pass too), + * while layers are tested per-object against the rendering camera — the main + * camera never enables {@link SHADOW_ONLY_LAYER}, but every shadow-casting + * light's shadow camera does (see `lights.tsx`). + * + * The original `layers.mask` is stashed under a private Symbol so + * {@link clearShadowOnly} restores the exact prior state. Both calls are + * idempotent and cheap to reapply. + */ + +const ORIGINAL_LAYERS = Symbol('pascal:shadow-only:original-layers') + +type ShadowOnlyCarrier = Object3D & { [ORIGINAL_LAYERS]?: number } + +export function applyShadowOnly(root: Object3D): void { + root.traverse((obj) => { + const carrier = obj as ShadowOnlyCarrier + if (carrier[ORIGINAL_LAYERS] === undefined) { + carrier[ORIGINAL_LAYERS] = obj.layers.mask + } + obj.layers.disable(SCENE_LAYER) + obj.layers.enable(SHADOW_ONLY_LAYER) + }) +} + +export function clearShadowOnly(root: Object3D): void { + root.traverse((obj) => { + const carrier = obj as ShadowOnlyCarrier + if (carrier[ORIGINAL_LAYERS] === undefined) return + obj.layers.mask = carrier[ORIGINAL_LAYERS] + delete carrier[ORIGINAL_LAYERS] + }) +} diff --git a/packages/viewer/src/systems/level/level-system.tsx b/packages/viewer/src/systems/level/level-system.tsx index 63877873..b32b976c 100644 --- a/packages/viewer/src/systems/level/level-system.tsx +++ b/packages/viewer/src/systems/level/level-system.tsx @@ -1,10 +1,18 @@ import { getLevelHeight, type LevelNode, sceneRegistry, useScene } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' +import type { Object3D } from 'three' import { lerp } from 'three/src/math/MathUtils.js' +import { applyShadowOnly, clearShadowOnly } from '../../lib/shadow-only' import useViewer from '../../store/use-viewer' const EXPLODED_GAP = 5 +// Levels currently in shadow-caster-only mode (solo hides them from the color +// passes but keeps their sun shadows). Tracked so we can restore layer masks +// exactly once on transition; apply re-runs every frame so meshes rebuilt +// while hidden (theme/texture changes) get re-hidden. +const shadowOnlyLevels = new WeakSet() + export const LevelSystem = () => { useFrame((_, delta) => { const nodes = useScene.getState().nodes @@ -29,6 +37,9 @@ export const LevelSystem = () => { entries.sort((a, b) => a.index - b.index) // Walk sorted levels, accumulating base Y offsets + const selectedIndex = selectedLevel + ? entries.find((e) => e.levelId === selectedLevel)?.index + : undefined let cumulativeY = 0 for (const { levelId, index, obj } of entries) { const level = nodes[levelId as LevelNode['id']] @@ -37,7 +48,23 @@ export const LevelSystem = () => { const targetY = baseY + explodedExtra obj.position.y = lerp(obj.position.y, targetY, delta * 12) // Smoothly animate to new Y position - obj.visible = levelMode !== 'solo' || level?.id === selectedLevel || !selectedLevel + + // Solo: hidden levels ABOVE the soloed one stay in the shadow map + // (shadow-caster-only) so the sun still shadows the soloed floor through + // them; levels below can't block the sun, so they plain-hide. + const hidden = levelMode === 'solo' && Boolean(selectedLevel) && level?.id !== selectedLevel + const castsWhileHidden = hidden && selectedIndex !== undefined && index > selectedIndex + if (castsWhileHidden) { + applyShadowOnly(obj) + shadowOnlyLevels.add(obj) + obj.visible = true + } else { + if (shadowOnlyLevels.has(obj)) { + clearShadowOnly(obj) + shadowOnlyLevels.delete(obj) + } + obj.visible = !hidden + } cumulativeY += getLevelHeight( levelId, diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx index 61b28063..8ee82304 100644 --- a/packages/viewer/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -74,8 +74,21 @@ function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) { // For negative elevation, shift the mesh down so the top face sits at Y=elevation // rather than at Y=0. Positive elevation stays at Y=0 (slab sits at floor level). + // A deterministic sub-3mm per-node lift breaks the coplanarity of slabs + // duplicated at the exact same position — identical depths z-fight, and no + // camera near/far tuning can separate them. Render-only: node data, + // snapping and measurements are untouched. const elevation = node.elevation ?? 0.05 - mesh.position.y = elevation < 0 ? elevation : 0 + mesh.position.y = (elevation < 0 ? elevation : 0) + coplanarityEpsilon(node.id) +} + +// Stable id hash → 0..2.7 mm in 0.3 mm steps. +function coplanarityEpsilon(id: string): number { + let hash = 0 + for (let i = 0; i < id.length; i++) { + hash = (hash * 31 + id.charCodeAt(i)) | 0 + } + return (Math.abs(hash) % 10) * 0.0003 } /** diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts index 03f081f1..4df61351 100644 --- a/packages/viewer/src/systems/wall/wall-materials.ts +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -31,7 +31,7 @@ import { type SceneMaterials = Record | undefined -const DEFAULT_WALL_COLOR = '#f2f0ed' +const DEFAULT_WALL_COLOR = '#e9e7e3' const WALL_HIGHLIGHT_PROFILES = { delete: { diff --git a/wiki/architecture/layers.md b/wiki/architecture/layers.md index 8943526c..c77ba157 100644 --- a/wiki/architecture/layers.md +++ b/wiki/architecture/layers.md @@ -14,6 +14,7 @@ Three.js `Layers` control which objects each camera and render pass sees. We use | `OVERLAY_LAYER` | `1` | `@pascal-app/viewer` | Editor overlays: gizmos, move handles, tool previews, cursor meshes, snap guides. Composited on top in its own pass. | | `ZONE_LAYER` | `2` | `@pascal-app/viewer` | Zone floor fills and wall borders — composited in a separate post-processing pass | | `GRID_LAYER` | `3` | `@pascal-app/viewer` | The editor ground grid — rendered *in* the scene pass for correct depth occlusion | +| `SHADOW_ONLY_LAYER` | `4` | `@pascal-app/viewer` | Shadow-caster-only geometry: hidden roofs/levels in cutaway/solo views. No color pass or camera enables it — only the sun's shadow camera (`lights.tsx`), so the geometry keeps shadowing interiors. Applied per-object via `lib/shadow-only.ts` (`applyShadowOnly`/`clearShadowOnly`). | `apps/editor` exposes `EDITOR_LAYER` for editor-helper meshes; it **re-exports** `OVERLAY_LAYER` (`EDITOR_LAYER === OVERLAY_LAYER`) so the editor stays decoupled from the viewer's pass numbering while landing on the same layer.