feat(viewer): rendering & lighting pass — sun-dominant look, sky backdrop, grounded horizon (#493)

* feat(editor): dev-only window hook for deterministic camera poses

Exposes a getter for the CameraControls impl in development so
screenshot/automation tooling can set exact camera poses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer): rendering pass — sun-dominant lighting, grade, gradient-sky IBL, albedo clamp, SSGI tune

- Shadow intensity clamp 0.55 → 0.9 (sun no longer leaks into shadow),
  2048 shadow maps, PCFSoft filtering
- Scene-referred contrast/saturation grade before ACES output
  (GRADE_PARAMS in post-processing)
- Procedural gradient-sky IBL (cool zenith / warm horizon / ground
  bounce) replaces the venice_sunset HDR fetch; env-only, background
  unchanged
- Near-white albedos clamped to ~0.83 linear (defaults, white palette,
  catalog preset-white/softwhite, schema presets)
- SSGI: 2 slices / 6 steps, radius 1.6, aoIntensity 1.7, giIntensity 2
  (bounce on); studio hemi 0.6→0.45, fill 0.75→0.6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer): shadow-caster-only cutaway, glass fresnel, ground fade, specular-map unwiring

- SHADOW_ONLY_LAYER (4) + lib/shadow-only.ts: hidden roofs/levels in
  level-solo (editor) and dollhouse (GLB viewer) stay in the shadow map,
  so interiors keep sun shadows + window light patches; only the sun's
  shadow camera enables the layer
- Glass: fresnel-driven opacity + envMapIntensity on transparent
  standard materials (catalog glass, scene glass, window default)
- Site ground: radial fade into the theme background at the lot
  boundary (TSL colorNode); dead ground-occluder.tsx removed
- Catalog: 22 bogus *specular*→metalnessMap wirings removed (specular
  level maps are not metalness; they darkened/metallized dielectrics)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer): PCSS contact-hardening sun shadows via LightShadow.filterNode

Custom TSL filter: Vogel-disk blocker search (textureLoad — the sampled
path would inherit the comparison sampler, which WGSL rejects) →
receiver-blocker penumbra estimate → variable-radius rotated PCF.
shadow.radius scales max penumbra (now 4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer): dark-theme recalibration for the new lighting pipeline

Night/twilight: lifted ambient/hemi beds (the 0.9 shadow intensity
crushed them), brightened theme grounds to a lit mid-tone, and dimmed
the daylight gradient-sky IBL to 0.2 for dark appearances.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer): rendering-pass follow-ups — GI denoise, lighter interactive SSGI, sky gradients, scoped solo shadows

- Denoise the SSGI GI bounce (it composited raw — the visible grain) and
  drop giIntensity to 1
- Interactive SSGI back to 1 slice (×6 steps); SSGI_BAKE_PARAMS (2×6)
  for the thumbnail/bake pipeline
- Per-theme backgroundSky: vertical zenith→horizon backdrop gradient in
  the post pipeline; makes the lot-edge ground fade read in every theme
- Level solo: only levels above the soloed floor stay shadow-caster-only;
  below-levels plain-hide (they can't block the sun)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer): kill SSGI grain, real horizon — infinite ground disc fading into the sky

- SSGI back to 2 slices × 6 steps: three's own minimum preset without
  temporal filtering — 1×6 was below the floor and the grain showed on
  flat walls; denoise radius 5 on both AO and GI, aoIntensity 1.5;
  bake preset raised to 3×8 (single-frame renders)
- Site renderer: presentation horizon disc (8× lot radius, min 400 m)
  under the lot in the theme ground colour, fading radially into the
  theme background; lot fill back to plain ground colour (the disc
  carries the fade); never pickable (noop raycast)
- Backdrop sky gradient compressed to the upper half of the screen so
  it meets the disc's far fade at exactly the horizon colour — no seam

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer): laptop-budget SSGI + seamless world-space horizon

- SSGI interactive: 1 slice × 6 steps, GI bounce OFF (denoised AO only)
  — 2 slices was a thermal problem on laptops; the bounce moves to
  SSGI_BAKE_PARAMS (3×8, gi 1) for one-shot renders
- Horizon disc dissolve: albedo fades to black while emissive fades to
  the background colour, so the far end IS the backdrop (no lit-vs-flat
  seam); backdrop gradient graded with the same transform as the scene
- Sky gradient is now world-space: per-pixel view ray reconstructed from
  the scene camera matrices, sky above the true horizon (dir.y 0→0.35),
  pure background below — aligns with the disc at any camera angle
- Studio theme: ground #e9e7e2 / horizon #fbfbfa / sky #dde7ef so the
  fade is actually visible against the white void

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(viewer): kill shadow grain (PCSS opt-in only) and the inked horizon line

- The eye-level ground grain was measured (high-pass σ on a flat patch:
  2.95 baseline → 1.52 shadows-off → 2.49 ao-off): PCSS's per-pixel IGN
  dither was the dominant source, and it can't be fixed within a laptop
  budget without TAA. Interactive shadows revert to the renderer's
  PCFSoft (clean, cheap); PCSS stays wired behind ?enable=pcss for
  experiments and future bake-time use
- The horizon 'line' was the ink pass edge-detecting the ground disc's
  depth silhouette against the backdrop. Ink now fades with raw depth
  (full below ~150 m, gone past ~350 m) — near silhouettes keep their
  SketchUp line, the horizon dissolves cleanly

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer): soft blue skies for light presets, slab coplanarity epsilon

- backgroundSky retuned: studio/paper/blueprint/verdant get a soft blue
  zenith (overcast keeps a bluish gray — it's overcast; mediterranean/
  sunset were already blue; dark themes untouched)
- Slabs duplicated at the exact same position z-fight and no camera
  near/far tuning can separate identical depths; each slab mesh now gets
  a deterministic sub-3mm lift hashed from its node id. Render-only —
  node data, snapping and measurements untouched. (Long-term fix is
  reversedDepthBuffer; parked in plans with the depth-consumer audit.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(viewer): walk back perf-costly rendering-pass pieces, soften dark-theme ink

Review feedback on the rendering pass:
- SSGI back to main's params (1 slice / 4 steps / r1, AO-only) everywhere,
  including thumbnails — SSGI_BAKE_PARAMS removed. Kills the added AO grain
  and the extra per-frame cost; bake workers also stop paying for heavier GI.
- Shadows stay visible via plain knobs only (intensity 0.9, radius 4): the
  custom TSL PCSS filter is gone, shadow map back to 1024, filter back to PCF
  (r184's Vogel-disk PCF respects radius, so edges stay soft).
- Night/twilight ink edges: colour now derived from the theme background
  (lifted toward white) instead of a near-white constant, and dark scenes run
  the ink at 70% alpha — no more glowing wireframe on dark backdrops.
- Snapshots: the `transparent` capture flag is now honored — preset/item
  captures keep their alpha, while studio renders and project thumbnails
  composite the theme background + sky gradient (same world-ray math as the
  viewport backdrop, uniform-driven so the cached pipeline serves both).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer,nodes): ground the scene — contact vignette + horizon haze, shadow tune

Follow-ups from review:
- Shadow intensity 0.9 → 0.75 (read too heavy) and PCF radius 4 → 2: the
  filter's per-pixel dither spreads with radius, which showed as dots across
  wide penumbras. Both are free knobs.
- New shared backdrop formula (viewer lib/backdrop.ts): background below,
  theme-derived haze band hugging the horizon (background lifted toward
  white — faint glow on dark themes), sky above. Used by the post pipeline,
  the thumbnail pipeline, and the site horizon disc, whose far-field
  dissolve now evaluates the same gradient per fragment view direction —
  ground and backdrop converge to identical colours, so no horizon seam
  from any camera pose.
- Contact vignette on the horizon disc: a soft albedo darkening hugging the
  lot (15%, fading out by ~2.6 lot radii) so the parcel sits on the field
  instead of floating on it. Albedo-only — never tints the dissolve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(nodes): make the contact vignette read on bright themes

A fixed 15% albedo cut disappears into the tone mapper's shoulder on themes
with strong key lights (studio runs intensity 4), and the dissolve's bright
emissive diluted what was left. Scale the vignette with the theme's strongest
light (0.13×, clamped at 0.45) and apply the halo to the in-band emissive as
well — it zeroes out by 2.6R while the dissolve completes at 5R, so the far
field stays the pure backdrop and the horizon seam guarantee holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(viewer): no more horizon line, sky gradient reaches the horizon

Two things drew a visible line where the infinite ground met the sky:
- SSGI AO grows a band along the geometry↔sky depth cliff (same disease the
  ink pass had). Fade AO to 1 with raw depth over the ink's ≈150→350 m
  window, in both the viewport and thumbnail pipelines — AO is a near-field
  cue, it has no business shading the horizon.
- The backdrop's flat haze plateau sat between two ramps, which the eye
  amplifies into Mach lines. The gradient is now one smooth background→sky
  ramp crossing the horizon, with the haze applied as an exponential glow
  peaking exactly at dir.y = 0 — C¹-smooth on both sides, edge-free.

The sky ramp also starts at the horizon instead of ~11° up, so the theme's
backgroundSky blue actually reads at eye level instead of hiding at the top
of the frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer): three-stop sky — derived deep zenith, warm horizon haze

The single pale backgroundSky stop read as a white void with blue hiding at
the top of the frame. The backdrop is now three derived stops, inZOI-style:
- pale theme sky arrives fast (full by ≈8° elevation),
- then deepens toward a zenith colour derived per theme in HSL (saturate
  ×1.5, darken ×0.72 — hue stays the theme's own: blue studio, lavender
  sunset, near-black night),
- horizon haze now lifts toward a warm white (#fff4de) instead of pure
  white, giving the junction the slight yellow of sun-scattered atmosphere.

All derived from the existing backgroundSky/background fields — no theme
data changes, and the shared-formula seam guarantee (viewport = captures =
horizon disc) carries over.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(viewer): horizon haze derives from the sky, hugs the horizon tighter

White-based haze read as a tall white stripe between ground and sky. Aerial
perspective is sky-coloured light with a little sun scatter, so the haze now
pulls the theme's backgroundSky toward the warm sun tint (50% light themes,
25% dark) and the glow decay tightens (exp −7→−11, weight 0.9→0.8) — the
merge band is skyish, sunish, and half the height, so blue starts right
above the ground line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-07-13 11:24:58 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent c45808840c
commit 47313263fb
22 changed files with 737 additions and 207 deletions
@@ -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],
)
@@ -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<LevelNode['id'], number>()
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 (
<mesh position-y={-0.05} rotation-x={-Math.PI / 2}>
<shapeGeometry args={[shape]} />
<meshBasicMaterial
color={bgColor}
depthWrite={true}
polygonOffset={true}
polygonOffsetFactor={1}
polygonOffsetUnits={1}
/>
</mesh>
)
}
@@ -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 <orthographicCamera attach="shadow-camera"> 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 ? (
<orthographicCamera
@@ -1,6 +1,6 @@
import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Color, Layers, type Object3D, Scene, UnsignedByteType } from 'three'
import { Color, Layers, Matrix4, type Object3D, Scene, UnsignedByteType } from 'three'
import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
import {
@@ -18,12 +18,17 @@ import {
premultiplyAlpha,
renderOutput,
sample,
saturation,
screenUV,
smoothstep,
time,
uniform,
vec3,
vec4,
} from 'three/tsl'
import { RenderPipeline, type WebGPURenderer } from 'three/webgpu'
import { edgeColorFor } from '../../lib/edge-style'
import { backdropGradient, deepSkyColor, horizonHazeColor } from '../../lib/backdrop'
import { edgeColorFor, edgeOpacityScaleFor } from '../../lib/edge-style'
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
import { inkedEdges } from '../../lib/ink-edges'
import { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from '../../lib/layers'
@@ -31,6 +36,14 @@ import { mergedOutline } from '../../lib/merged-outline-node'
import { getSceneTheme } from '../../lib/scene-themes'
import useViewer from '../../store/use-viewer'
// Scene-referred grade applied before the output tone mapping (AgX). AgX rolls
// highlights off gently but reads flat on its own; a mild mid-gray-pivot
// contrast + saturation lift restores the punch. Rendered shading only.
export const GRADE_PARAMS = {
contrast: 1.05,
saturation: 1.1,
}
// SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion
export const SSGI_PARAMS = {
enabled: true,
@@ -157,14 +170,36 @@ const PostProcessingPasses = ({
// Background color uniform — updated every frame via lerp, read by the TSL pipeline.
// Initialised from the current scene theme so there's no flash on first render.
const initBg = getSceneTheme(useViewer.getState().sceneTheme).background
const initTheme = getSceneTheme(useViewer.getState().sceneTheme)
const initBg = initTheme.background
const bgUniform = useRef(uniform(new Color(initBg)))
const bgCurrent = useRef(new Color(initBg))
const bgTarget = useRef(new Color())
// Zenith colour of the backdrop gradient (falls back to the flat background).
const initSky = initTheme.backgroundSky ?? initBg
const bgSkyUniform = useRef(uniform(new Color(initSky)))
const bgSkyCurrent = useRef(new Color(initSky))
const bgSkyTarget = useRef(new Color())
// Horizon haze band + deep zenith (derived — see lib/backdrop.ts).
const initHaze = horizonHazeColor(initSky, initTheme.appearance)
const bgHazeUniform = useRef(uniform(new Color(initHaze)))
const bgHazeCurrent = useRef(new Color(initHaze))
const bgHazeTarget = useRef(new Color())
const initSkyDeep = deepSkyColor(initSky)
const bgSkyDeepUniform = useRef(uniform(new Color(initSkyDeep)))
const bgSkyDeepCurrent = useRef(new Color(initSkyDeep))
const bgSkyDeepTarget = useRef(new Color())
// Scene-camera matrices for the backdrop: the pipeline's fullscreen quad has
// its own camera, so the sky gradient reconstructs each pixel's world-space
// view ray from these to find the true horizon (dir.y = 0).
const camProjInvUniform = useRef(uniform(new Matrix4()))
const camWorldUniform = useRef(uniform(new Matrix4()))
// Ink-line colour follows the scene-theme background luminance (dark lines on
// light scenes, light on dark), refreshed each frame like the background.
// Dark scenes also scale the ink opacity down (see edge-style.ts).
const inkColorUniform = useRef(uniform(new Color(edgeColorFor(initBg))))
const inkOpacityScaleUniform = useRef(uniform(edgeOpacityScaleFor(initBg)))
const zoneLayers = useMemo(() => {
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)
@@ -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
* <Viewer> *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 <Viewer> 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 (
<Suspense fallback={null}>
<Environment environmentIntensity={0.6} files="/hdri/venice_sunset_1k.hdr" />
</Suspense>
)
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
}
+1
View File
@@ -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
+108
View File
@@ -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
}
+27 -6
View File
@@ -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
}
+9 -2
View File
@@ -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)
}
+11
View File
@@ -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
+42 -10
View File
@@ -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<SurfaceRole, string> = {
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<SurfaceRole, string> = {
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<SurfaceRole, string> = {
@@ -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 {
+30 -17
View File
@@ -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',
+42
View File
@@ -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]
})
}
@@ -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<Object3D>()
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,
@@ -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
}
/**
@@ -31,7 +31,7 @@ import {
type SceneMaterials = Record<SceneMaterialId, SceneMaterial> | undefined
const DEFAULT_WALL_COLOR = '#f2f0ed'
const DEFAULT_WALL_COLOR = '#e9e7e3'
const WALL_HIGHLIGHT_PROFILES = {
delete: {