Viewer render modes: Solid/Rendered + textures + surface-role clay + scene themes + edges (#332)

* viewer: add Phase 1 render-modes foundation (shading/textures/colorPreset state, defaultRender prop, SSGI gating)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* viewer: Phase 2 render-modes material-class switch (Lambert in solid, Standard in rendered)

Shading-aware material factories (cached per class), reactive selection in
renderers via the useViewer(shading) pattern, and dirty-rebuild on toggle for
geometry/door systems. Rendered mode output unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* viewer: Phase 3a render-modes surface roles + clay palette foundation

Adds surfaceRole token to core NodeDefinition, per-kind default roles,
ColorPreset palettes + resolveSurfaceColor/createSurfaceRoleMaterial (glazing
stays translucent), and the textures-off recolor path for def.geometry kinds
(slab/fence/shelf via GeometrySystem.applyDefaultSurfaceRole) + wall. Renderer-
based kinds (roof/window/stair/item/column/door/ceiling/elevator) wired in 3b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* viewer: Phase 3b render-modes textures-off recoloring for renderer/system kinds

Wires clay role coloring (textures=off) for roof/roof-segment, window, stair/
stair-segment, door, item, column, ceiling, elevator via createSurfaceRoleMaterial,
reactive on textures/colorPreset. Per-surface roles: roof top+edge=roof /
underside=ceiling; window frame=joinery / glass=glazing; stair+door+elevator=
joinery; ceiling=ceiling; column=wall; item=furnishing. textures=on unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* editor: Phase 4a render-modes UI — Solid/Rendered toggle + per-context persistence

Per-context shading via renderContext discriminator + shadingByContext (persisted);
<Viewer> renderContext prop seeds per-context on mount. Solid/Rendered toggle in the
editor action bar + standalone toolbar + command palette. Editor mounts default to
renderContext=editor / shading=solid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* viewer: fix window-system glassMaterial type to allow clay glazing reassignment

The let was inferred as MeshLambertNodeMaterial from the imported glass constant,
so reassigning createSurfaceRoleMaterial('glazing') (returns THREE.Material) failed
under tsc --build. Widen the annotation to THREE.Material. Surfaced by the build
(check-types had replayed a stale turbo cache).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* viewer: Phase 5 lighting — add theme-driven hemisphere light, trim fill directionals 3->2

Adds a sky/ground hemisphere fill (theme-lerped) and drops the second fill
directional; the hemisphere covers the shadow-side fill it provided, at one
fewer per-fragment directional term (shared by Solid + Rendered). Ambient lowered
since the hemisphere now carries soft fill. Intensities are a starting point —
tune visually on the gpu-perf overlay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* viewer: tune Solid lighting for more form — stronger hemisphere sky/ground contrast, lower ambient

Darker hemisphere ground (#d8d6cf -> #aaa49a) + higher hemisphere intensity and
lower ambient so directional shading reads as form and undersides ground without
AO. Keeps Solid free of any post-processing pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: biome format render-mode files (lefthook pre-commit)

Formatting-only — import wrapping, dep-array wrapping, single-line ternaries —
across Phase 2-4a files that weren't biome-clean. No logic changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* viewer: scene-theme system — named environment themes (studio/paper/sunset/night/...)

New SceneTheme registry (lib/scene-themes.ts) drives lights, background, and tone
mapping; lights.tsx refactored data-driven (N directionals + hemisphere + ambient).
sceneTheme state (persisted) + cycle-button picker in editor bar + standalone
toolbar, importing the registry from the viewer barrel (single source). Default
'studio' reproduces the prior look exactly; app light/dark 'theme' untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* editor: Phase 7 — popover-dropdown pickers for render mode + scene theme (editor bar)

Replace the shading + scene-theme cycle buttons in viewer-overlay.tsx with
DropdownMenu pickers: render mode shows 2 rows (Solid/Rendered) with one-line
detail; scene theme lists all themes with a derived color-swatch strip + active
check. Imports the registry from the viewer barrel (single source).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* editor: Phase 7 — dropdown pickers in standalone toolbar + export DropdownMenu from barrel

Replicate the render-mode + scene-theme dropdown pickers (with swatch strip +
active check) to apps/editor's compact toolbar, matching viewer-overlay.tsx.
Export DropdownMenu* from the @pascal-app/editor barrel for the standalone app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* viewer+editor: Phase 8 — crisp geometry edge overlay (off/soft/strong/sketchy)

EdgeOverlaySystem draws EdgesGeometry LineSegments over node-backed building
meshes (scoped via sceneRegistry, skips zone-layer/hitbox/overlay meshes),
rebuilt on geometry-uuid or mode change, line color follows scene-theme
background luminance; sketchy = static TSL vertex jitter. New 'edges' state
(persisted, default off) + Edges dropdown in editor bar + standalone toolbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* viewer+editor: edge overlay — thick lines via Line2, drop sketchy mode

Switch EdgeOverlaySystem from LineBasicNodeMaterial (1px hardware cap) to
LineSegments2 + Line2NodeMaterial so edges have real screen-space width
(soft 1.5px / strong 3px); resolution tracks viewport. EdgeMode is now
off/soft/strong (sketchy removed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* viewer: fix edge overlay crash — webgpu Line2NodeMaterial has no settable resolution

material.resolution is undefined under WebGPU (the node material reads the
viewport internally); optional-chain the .set() call so it no-ops there instead
of throwing. Thickness still applies via linewidth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* viewer: edge overlay — weld vertices to fix CSG spiderweb edges + lighter strong

Position-only mergeVertices before EdgesGeometry so coplanar triangles from
CSG-cut walls (doors/windows) share vertices and their interior edges are
suppressed — only opening outlines + silhouettes remain. Strong linewidth
3 -> 2px (was too heavy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* viewer: edge overlay — crease-only extractor to fix CSG spiderweb

Replace EdgesGeometry (which always draws unpaired boundary/T-junction edges)
with buildCreaseEdges: weld positions, keep only edges shared by exactly two
faces whose dihedral exceeds the threshold, drop everything unpaired. CSG-cut
walls/slabs are watertight so real corners + opening outlines survive while the
interior triangulation fans (coplanar or T-junction) are removed. Open meshes
(bare ground plane, billboard leaves) shed their boundary clutter too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* viewer: crease edges — coarser weld (0.1mm->1mm) to recover CSG/extrude seam edges

The cap<->side-wall top edge of ExtrudeGeometry walls drifts past 0.1mm after
CSG, so it stayed unpaired and was dropped. Weld at ~1mm to pair it into a real
crease while staying far below feature size (wall thickness, openings).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* viewer: replace geometry edge overlay with screen-space ink (SketchUp look)

Port the prototype's screen-space ink into the post-processing pipeline: depth +
normal Sobel reading the scene-pass MRT. Crease term (normalized normals,
center-vs-neighbour) + distance-independent depth-step term (raw Laplacian /
(1-d)² with a noise gate so flat ground stays clean). Topology-agnostic, so it
finally handles CSG-cut walls/openings without the spiderweb or missing-edge
problems of EdgesGeometry. Driven by the existing edges off/soft/strong mode;
MRT now builds when SSGI OR ink is on; ink colour tracks scene-theme luminance.

Removes EdgeOverlaySystem + crease-edges (geometry approach).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* viewer: soft/strong ink modes + keep editor overlays out of the ink

Two adjustments to the screen-space ink pass:

1. Soft vs strong now visibly differ. The edge masks saturate, so the old
   `intensity` gain did nothing once a line was detected. Replace it with a
   sample radius (line thickness) + opacity: soft = 1px / 50%, strong = 2px /
   100%. `inkedEdges` takes `radius` + `opacity` instead of `intensity`.

2. Editor overlays (gizmos, move handles, tool previews, grid) no longer get
   inked. The scene pass that feeds the depth/normal MRT now renders only
   SCENE_LAYER; overlays render in a dedicated pass on OVERLAY_LAYER and are
   composited on top after the ink + outlines, so they read as crisp UI and
   never get inked or AO'd.

   New OVERLAY_LAYER constant in viewer; editor's EDITOR_LAYER re-exports it so
   the two stay in lockstep. Also moves WallMoveSideHandles (the wall/fence move
   arrows) onto EDITOR_LAYER — it was the one overlay still on SCENE_LAYER.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* viewer: render the grid in the scene pass so geometry occludes it

The depth-gate fix couldn't help the grid: its material is depthWrite:false,
so it never wrote overlay-pass depth and the "didn't write depth -> keep on
top" term forced it on top — hence the floor grid bleeding through walls and
objects.

A full-floor plane can only be occluded correctly by living in the same depth
context as the scene, so move the grid onto its own GRID_LAYER which the scene
pass renders (alongside SCENE_LAYER). It's flat and depth-non-writing, so the
screen-space ink still ignores it; gizmos/handles stay on OVERLAY_LAYER. The
grid camera layer is enabled in custom-camera-controls and disabled on the
thumbnail camera so thumbnails stay grid-free, matching EDITOR_LAYER.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* viewer: strong ink — match soft's 1px thickness, differ by darkness only

Strong at radius 2 read too thick. Soft's 1px line is the nice one, so use it
for both modes and let strong distinguish itself purely by being fully solid
(opacity 1) vs soft's lighter 50%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* viewer: shadow frustum follows the view + a shadows on/off setting

The directional light's ortho shadow camera only covers ±50 around the light
target, which was pinned at the origin — so zones far from origin received no
shadows no matter where the camera moved. Recentre each shadow-casting light
(position + target together, preserving direction) on the view focus every
frame: the orbit-controls target when available, else the camera's ground
projection. The shadow area now tracks wherever the user looks.

Also add a persisted `shadows` toggle (default on) to the viewer store and a
"Shadows" switch in the editor settings panel — the dedicated shadows control
the render-modes plan deferred. Lights gate castShadow on it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* editor: shadows toggle in the standalone toolbar + a touch more shadow strength

The shadows switch I added only lived in the cloud settings panel's Visibility
section, which is hidden in the local/standalone editor (no projectId). Add a
ShadowsToggle button next to the grid toggle in the standalone toolbar so it's
reachable there, matching how Show Grid is exposed in both places.

Also push shadow strength partway toward the aesthetic prototype (which runs
near-black, no blur): bump the bright-key shadow-intensity cap 0.4 -> 0.55 and
tighten shadow-radius 2 -> 1.5. Still softer than aesthetic by design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* nodes: site ground receives shadows (lit material instead of unlit Basic)

The site ground fill used MeshBasicMaterial — unlit, so it could never show
the directional shadow, and shadows visibly truncated at the slab edge. Swap it
for a lit MeshLambertNodeMaterial with receiveShadow on the mesh; the geometry
is the site polygon (slab footprints punched out), so shadows now extend across
the whole site and stop at its boundary, which is the desired bound.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* themes: per-theme clay palettes + 2x2 swatch in the theme pickers

Each scene theme now carries a clayTints map (wall/floor/ceiling/roof/glazing)
giving it a per-surface-role palette — e.g. Mediterranean's blue roof + warm
walls. The theme pickers (standalone toolbar + community overlay) now render the
aesthetic-style 2x2 swatch of those role tints over the theme background instead
of the old 3-colour strip.

Data + UI only; wiring the tints into the textures-off surface materials is a
separate change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* editor: slimmer theme switcher + cloud-sun icon

Shrink the scene-theme toolbar button (w-[8.5rem] -> w-28) so it stops reserving
space for "Mediterranean"; the label truncates when it overflows. Swap the
palette icon for cloud-sun (atmosphere/lighting, distinct from the app light/dark
Sun-Moon toggle) in both the standalone toolbar and the community overlay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* editor: theme switcher icon -> swatch-book

Swap the scene-theme icon from cloud-sun to swatch-book in both the standalone
toolbar and the community overlay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* themes: colour untextured building surfaces by the active scene theme

Untextured walls/roof/slab/ceiling now take the active theme's per-role colour
(theme.clayTints[role], falling back to the colour preset) in BOTH textures
modes. The textures toggle only governs surfaces that actually have an explicit
material/preset — those still show their texture when textures are on. This is
what makes e.g. Mediterranean read as a blue roof + warm walls instead of the
old hardcoded white/grey defaults.

- materials.ts: resolveSurfaceColor / createSurfaceRoleMaterial take an optional
  sceneThemeId (theme tint ?? preset palette); theme folded into the cache key.
- wall-materials, roof-materials, slab/geometry, ceiling/renderer: the untextured
  fallback now resolves to the themed role colour instead of white/grey, in both
  modes; theme threaded into each builder + material cache key.
- wall-cutout: now reads textures/colorPreset/sceneTheme and re-applies wall
  materials when any change (previously it ignored textures/colorPreset entirely).
- geometry-system: threads sceneTheme into the generic surface-role path + rebuild
  effect. Renderers/preview call sites thread sceneTheme through.

Doors/windows/stairs/columns/items still use their existing defaults — a
follow-up pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(slab): recessed (negative-elevation) slabs extrude downward again

The registry geometry builder created the slab mesh at Y=0 without applying the
negative-elevation offset, so recessed slabs rendered above the floor plane
(pool geometry is built locally with its floor cap at Y=0 and walls rising to
Y=|elevation|, so the mesh must be shifted down by `elevation` to recess). The
runtime slab-system already did this; the static builder path didn't. Mirror it:
shift mesh.position.y by elevation when negative. Positive elevation unchanged.

Unrelated to render modes — bundled into this branch's PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(roof): legacy roofs render again — migration wrote invalid wallHeight 0

Legacy roof nodes (old format, no `children`) were migrated with a hardcoded
`wallHeight: 0`. With zero wall height the eave height (`wallHeight - autoDrop`)
went negative in getRoofSegmentBrushes, producing geometrically invalid brushes;
three-bvh-csg then spammed "TriangleClipper: Coplanar clip not handled" every
frame and emitted NaN positions, so the merged roof geometry failed
computeBoundingSphere and never rendered.

- core/use-scene migration: wallHeight 0 -> 0.5 (the RoofSegmentNode schema
  default), so migrated segments have a valid wall height.
- roof-system: clamp eave height to >= 0.01 so an intentional wallHeight 0 can
  never yield a negative eave, and guard updateMergedRoofGeometry so a CSG result
  with NaN positions is discarded (keep the last good mesh, warn once per roof)
  instead of poisoning the buffer + spamming the console.

Unrelated to render modes — bundled into this branch's PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(column): columns cast and receive shadows

Column meshes (box, beam, cylinder, sphere, torus) rendered without castShadow/
receiveShadow, so columns neither dropped a shadow nor caught one — unlike walls,
slabs and roofs. Set both on all column shape meshes.

Unrelated to render modes — bundled into this branch's PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* viewer: default edges to soft

Editor defaults are now solid shading / studio theme / soft edges / shadows on.
Shading (solid, via EDITOR_DEFAULT_RENDER), theme (studio) and shadows (on) were
already the defaults; edges was 'off' — make 'soft' the default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* themes: fold light/dark into the scene theme (remove the separate toggle)

The viewer had two overlapping appearance controls: a light/dark `theme` toggle
AND scene themes (which already drive the 3D background + lights). They
conflicted — e.g. Night/Twilight are dark themes, but the light/dark toggle was
an independent axis still tinting the 2D scene chrome.

Unify on the scene theme: add an explicit `appearance: 'light' | 'dark'` to each
SceneTheme (twilight/night = dark, the rest = light) and drive everything the
old toggle drove off it — canvas backdrop, grid line colours, measurement-label/
cursor/site-edge contrast, the site ground fill, the ground occluder, and the
mobile viewer bg. The editor UI chrome is unaffected (always dark via a fixed
body class).

Removes the `theme`/`setTheme` store state (+ persistence) and every light/dark
toggle UI: the standalone toolbar Sun/Moon button, the community overlay theme
switch, the command-palette command, and the ifc-converter preview toolbar
button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* themes: per-theme ground colour + add the "Verdant" nature theme

- Add a `ground` colour to every SceneTheme and drive the site ground fill +
  the infinite ground-occluder off it (instead of the binary
  isDark ? #1f2433 : #fafafa). Dark themes now get a lit mid-tone ground
  (twilight #4a4566, night #2b3247) so the ground reads as ground rather than
  going near-black.
- Add a new green/nature scene theme "Verdant": soft green sky + lit, with a
  green roof clay tint and mossy ground.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* wiki: document the surface-colour / theme system

The colour-per-node/renderer/system model from the render-modes work was
undocumented. Add wiki/architecture/materials-and-themes.md covering surface
roles, colour presets, the textures axis, scene themes (appearance / ground /
clay tints), and the "untextured surfaces are theme-coloured in both modes"
invariant + where each kind wires it.

Also fix two pages that the same work made stale:
- node-definitions: geometry builders receive (shading, textures, colorPreset,
  sceneTheme); document the `surfaceRole` token + applyDefaultSurfaceRole.
- layers: OVERLAY_LAYER (1, viewer) with EDITOR_LAYER now its alias, the new
  GRID_LAYER (3, rendered in the scene pass for depth occlusion), and the
  overlay pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* nodes: wire #330 roof-accessory kinds into the surface-role/theme colours

PR #330's new kinds (chimney, dormer, skylight, solar-panel, ridge-vent,
box-vent) use custom renderers, so the generic textures-off recolour path never
reached them — they fell back to hardcoded colours. Wire each renderer into the
render-modes system: read shading/textures/colorPreset/sceneTheme and resolve
untextured surfaces via createSurfaceRoleMaterial (and force the role colour when
textures are off), matching column/ceiling.

Roles: chimney body→wall / cap→roof; dormer wall→wall, roof→roof, glass→glazing,
frame→joinery; skylight glass→glazing / frame→joinery; ridge-vent + box-vent→roof;
solar-panel frame→roof (the dark product-specific cell face is left as-is). Each
definition also gets its dominant `surfaceRole` token.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(roof): legacy-roof migration must use a non-zero wallHeight

The render-modes/#330 merge left the legacy roof→roof-segment migration writing
`wallHeight: 0`. With #330's pitch model that builds a flat, zero-volume wall
CSG brush, which three-bvh-csg can't clip ("Coplanar clip not handled") and
yields NaN positions — so the migrated old roof never renders. Use the schema
default 0.5 (what new roofs use), giving a valid wall. The eave clamp + merged-
geometry NaN guard added earlier stay as defense-in-depth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(roof): guard slope frame against missing/NaN pitch (no more NaN geometry)

getSegmentSlopeFrame used `pitch <= 0` to detect flat/zero-pitch, but an
undefined or NaN pitch (a segment from an older migration that set `roofHeight`
instead of `pitch`, or stale persisted data) slips past that check and computes
Math.tan(NaN) → NaN tanTheta/activeRh → NaN segment geometry → the merged-roof
CSG spews "Coplanar clip not handled" and NaN positions, so the roof never
renders. Use `!(pitch > 0)` so any non-positive/non-finite pitch resolves to the
flat frame. Self-heals bad data regardless of how the segment was produced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(roof): migration guarantees a positive pitch for every roof-segment

Segments saved with neither a valid pitch nor a roofHeight (older/partial
saves, e.g. landing home-graph) fell through the legacy roofHeight->pitch
branch, leaving pitch undefined. The slope-frame guard then resolved them
to a flat frame, so the roof rendered as a slab instead of pitched. Branch
2b now normalises any segment lacking a valid pitch: derive from roofHeight
when present, else fall back to the schema default (40deg). The migration
result is cast (not zod-parsed), so this is the only place the default lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-22 17:33:00 -04:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 87384cfbab
commit bb5ce68254
140 changed files with 4036 additions and 1686 deletions
@@ -2,11 +2,11 @@ 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 theme = useViewer((state) => state.theme)
const bgColor = theme === 'dark' ? '#1f2433' : '#fafafa'
const bgColor = useViewer((state) => getSceneTheme(state.sceneTheme).ground)
const nodes = useScene((state) => state.nodes)
+63 -33
View File
@@ -2,10 +2,12 @@
import { StairOpeningSystem } from '@pascal-app/core'
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef } from 'react'
import { useEffect } from 'react'
import * as THREE from 'three/webgpu'
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
import useViewer from '../../store/use-viewer'
import type { ColorPreset, RenderShading } from '../../lib/materials'
import { getSceneTheme } from '../../lib/scene-themes'
import useViewer, { type RenderContext } from '../../store/use-viewer'
import { FloorElevationSystem } from '../../systems/floor-elevation/floor-elevation-system'
import { GeometrySystem } from '../../systems/geometry/geometry-system'
import { ErrorBoundary } from '../error-boundary'
@@ -19,33 +21,6 @@ import { SceneBvh } from './scene-bvh'
import { SelectionManager } from './selection-manager'
import { ViewerCamera } from './viewer-camera'
function AnimatedBackground({ isDark }: { isDark: boolean }) {
const targetColor = useMemo(() => new THREE.Color(), [])
const initialized = useRef(false)
useFrame(({ scene }, delta) => {
const dt = Math.min(delta, 0.1) * 4
const targetHex = isDark ? '#1f2433' : '#ffffff'
if (!(scene.background && scene.background instanceof THREE.Color)) {
scene.background = new THREE.Color(targetHex)
initialized.current = true
return
}
if (!initialized.current) {
scene.background.set(targetHex)
initialized.current = true
return
}
targetColor.set(targetHex)
scene.background.lerp(targetColor, dt)
})
return null
}
declare module '@react-three/fiber' {
interface ThreeElements extends ThreeToJSXElements<typeof THREE> {}
}
@@ -130,12 +105,31 @@ function GPUDeviceWatcher() {
return null
}
function ToneMappingExposure() {
const sceneTheme = useViewer((state) => state.sceneTheme)
const gl = useThree((state) => state.gl)
const invalidate = useThree((state) => state.invalidate)
useEffect(() => {
gl.toneMappingExposure = getSceneTheme(sceneTheme).toneMappingExposure
invalidate()
}, [gl, invalidate, sceneTheme])
return null
}
interface ViewerProps {
children?: React.ReactNode
hoverStyles?: HoverStyles
selectionManager?: 'default' | 'custom'
perf?: boolean
useBvh?: boolean
renderContext?: RenderContext
defaultRender?: {
shading?: RenderShading
textures?: boolean
colorPreset?: ColorPreset
}
}
const Viewer: React.FC<ViewerProps> = ({
@@ -144,8 +138,42 @@ const Viewer: React.FC<ViewerProps> = ({
selectionManager = 'default',
perf = false,
useBvh = true,
renderContext = 'editor',
defaultRender,
}) => {
const theme = useViewer((state) => state.theme)
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
useEffect(() => {
const ctx = renderContext
useViewer.getState().setRenderContext(ctx)
const { shading, shadingByContext, setShading } = useViewer.getState()
setShading(shadingByContext[ctx] ?? defaultRender?.shading ?? shading)
if (!defaultRender || typeof window === 'undefined') return
let persistedState: Record<string, unknown> = {}
const rawPreferences = window.localStorage.getItem('viewer-preferences')
if (rawPreferences) {
try {
const parsed = JSON.parse(rawPreferences)
if (
parsed &&
typeof parsed === 'object' &&
parsed.state &&
typeof parsed.state === 'object'
) {
persistedState = parsed.state as Record<string, unknown>
}
} catch {}
}
if (defaultRender.textures !== undefined && !('textures' in persistedState)) {
useViewer.getState().setTextures(defaultRender.textures)
}
if (defaultRender.colorPreset && !('colorPreset' in persistedState)) {
useViewer.getState().setColorPreset(defaultRender.colorPreset)
}
}, [])
// Coarse-pointer devices (phones/tablets) get a tighter DPR ceiling to keep
// fragment-shader cost down — saves another ~30% over 1.5x on high-DPI mobile.
// Desktops (fine pointer) keep the original 1.5 cap.
@@ -154,7 +182,7 @@ const Viewer: React.FC<ViewerProps> = ({
return (
<Canvas
camera={{ position: [50, 50, 50], fov: 50 }}
className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
className={`transition-colors duration-700 ${isDark ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
dpr={[1, maxDpr]}
frameloop="never"
gl={
@@ -166,7 +194,9 @@ const Viewer: React.FC<ViewerProps> = ({
try {
const renderer = new THREE.WebGPURenderer(props as any)
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 0.9
renderer.toneMappingExposure = getSceneTheme(
useViewer.getState().sceneTheme,
).toneMappingExposure
await renderer.init()
return renderer
} catch (err) {
@@ -191,9 +221,9 @@ const Viewer: React.FC<ViewerProps> = ({
}}
>
<FrameLimiter fps={50} />
{/* <AnimatedBackground isDark={theme === 'dark'} /> */}
<ViewerCamera />
<GPUDeviceWatcher />
<ToneMappingExposure />
<ErrorBoundary fallback={null} scope="viewer-scene">
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
+123 -81
View File
@@ -1,7 +1,13 @@
import { useFrame } from '@react-three/fiber'
import { useMemo, useRef } from 'react'
import type { AmbientLight, DirectionalLight, OrthographicCamera } from 'three/webgpu'
import type {
AmbientLight,
DirectionalLight,
HemisphereLight,
OrthographicCamera,
} from 'three/webgpu'
import * as THREE from 'three/webgpu'
import { getSceneTheme } from '../../lib/scene-themes'
import useViewer from '../../store/use-viewer'
// Diagnostic toggle: `?disable=shadows` skips the shadow-map render pass
@@ -15,136 +21,172 @@ const SHADOWS_DISABLED =
.map((s) => s.trim()),
).has('shadows')
export function Lights() {
const theme = useViewer((state) => state.theme)
const isDark = theme === 'dark'
// 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
const light1Ref = useRef<DirectionalLight>(null)
export function Lights() {
const sceneTheme = useViewer((state) => state.sceneTheme)
const theme = getSceneTheme(sceneTheme)
const shadows = useViewer((state) => state.shadows)
const lightRefs = useRef<Array<DirectionalLight | null>>([])
const shadowCamera = useRef<OrthographicCamera>(null)
const shadowCameraSize = 50 // The "area" around the camera to shadow
const light2Ref = useRef<DirectionalLight>(null)
const light3Ref = useRef<DirectionalLight>(null)
// Where the shadow frustum is centred each frame. The directional light's
// ortho shadow camera only covers ±shadowCameraSize around the light target,
// so it has to track the view or anything far from origin gets no shadows.
const shadowFocus = useRef(new THREE.Vector3())
const hemiRef = useRef<HemisphereLight>(null)
const ambientRef = useRef<AmbientLight>(null)
const initialized = useRef(false)
const lightTargets = useRef<THREE.Color[]>([])
const targets = useMemo(
() => ({
l1Color: new THREE.Color(),
l2Color: new THREE.Color(),
l3Color: new THREE.Color(),
hemiSky: new THREE.Color(),
hemiGround: new THREE.Color(),
ambColor: new THREE.Color(),
}),
[],
)
useFrame((_, delta) => {
useFrame((state, delta) => {
// clamp delta to avoid huge jumps on tab switch
const dt = Math.min(delta, 0.1) * 4
if (!initialized.current) {
if (light1Ref.current) {
light1Ref.current.intensity = isDark ? 0.8 : 4
light1Ref.current.color.set(isDark ? '#e0e5ff' : '#ffffff')
// Recentre each shadow-casting light's frustum on what the viewer is looking
// at (orbit target if available, else the camera's ground projection), moving
// the light and its target together so the light DIRECTION is preserved and
// only the shadow camera slides. Without this the frustum stays at the origin
// and zones far from it never receive shadows.
if (shadows) {
const focus = shadowFocus.current
const controls = state.controls as { getTarget?: (out: THREE.Vector3) => void } | null
if (controls?.getTarget) {
controls.getTarget(focus)
} else {
focus.set(state.camera.position.x, 0, state.camera.position.z)
}
for (let index = 0; index < theme.lights.length; index++) {
const config = theme.lights[index]
const light = lightRefs.current[index]
if (!(config?.castShadow && light)) continue
const [ox, oy, oz] = config.position
light.position.set(focus.x + ox, focus.y + oy, focus.z + oz)
light.target.position.copy(focus)
light.target.updateMatrixWorld()
}
}
if (light1Ref.current.shadow) light1Ref.current.shadow.intensity = isDark ? 0.8 : 0.4
if (!initialized.current) {
for (let index = 0; index < theme.lights.length; index++) {
const config = theme.lights[index]
const light = lightRefs.current[index]
if (!(config && light)) continue
light.intensity = config.intensity
light.color.set(config.color)
if (config.castShadow && light.shadow) {
light.shadow.intensity = config.intensity <= 1 ? config.intensity : MAX_SHADOW_INTENSITY
}
}
if (light2Ref.current) {
light2Ref.current.intensity = isDark ? 0.2 : 0.75
light2Ref.current.color.set(isDark ? '#8090ff' : '#ffffff')
}
if (light3Ref.current) {
light3Ref.current.intensity = isDark ? 0.3 : 1
light3Ref.current.color.set(isDark ? '#a0b0ff' : '#ffffff')
if (hemiRef.current && theme.hemi) {
hemiRef.current.intensity = theme.hemi.intensity
hemiRef.current.color.set(theme.hemi.sky)
hemiRef.current.groundColor.set(theme.hemi.ground)
}
if (ambientRef.current) {
ambientRef.current.intensity = isDark ? 0.15 : 0.5
ambientRef.current.color.set(isDark ? '#a0b0ff' : '#ffffff')
ambientRef.current.intensity = theme.ambient.intensity
ambientRef.current.color.set(theme.ambient.color)
}
initialized.current = true
return
}
if (light1Ref.current) {
light1Ref.current.intensity = THREE.MathUtils.lerp(
light1Ref.current.intensity,
isDark ? 0.8 : 4,
dt,
)
targets.l1Color.set(isDark ? '#e0e5ff' : '#ffffff')
light1Ref.current.color.lerp(targets.l1Color, dt)
for (let index = 0; index < theme.lights.length; index++) {
const config = theme.lights[index]
const light = lightRefs.current[index]
if (!(config && light)) continue
if (light1Ref.current.shadow) {
if (light1Ref.current.shadow.intensity !== undefined) {
light1Ref.current.shadow.intensity = THREE.MathUtils.lerp(
light1Ref.current.shadow.intensity,
isDark ? 0.8 : 0.4,
light.intensity = THREE.MathUtils.lerp(light.intensity, config.intensity, dt)
let target = lightTargets.current[index]
if (!target) {
target = new THREE.Color()
lightTargets.current[index] = target
}
target.set(config.color)
light.color.lerp(target, dt)
if (config.castShadow && light.shadow) {
if (light.shadow.intensity !== undefined) {
light.shadow.intensity = THREE.MathUtils.lerp(
light.shadow.intensity,
config.intensity <= 1 ? config.intensity : MAX_SHADOW_INTENSITY,
dt,
)
}
}
}
if (light2Ref.current) {
light2Ref.current.intensity = THREE.MathUtils.lerp(
light2Ref.current.intensity,
isDark ? 0.2 : 0.75,
if (hemiRef.current && theme.hemi) {
hemiRef.current.intensity = THREE.MathUtils.lerp(
hemiRef.current.intensity,
theme.hemi.intensity,
dt,
)
targets.l2Color.set(isDark ? '#8090ff' : '#ffffff')
light2Ref.current.color.lerp(targets.l2Color, dt)
}
if (light3Ref.current) {
light3Ref.current.intensity = THREE.MathUtils.lerp(
light3Ref.current.intensity,
isDark ? 0.3 : 1,
dt,
)
targets.l3Color.set(isDark ? '#a0b0ff' : '#ffffff')
light3Ref.current.color.lerp(targets.l3Color, dt)
targets.hemiSky.set(theme.hemi.sky)
hemiRef.current.color.lerp(targets.hemiSky, dt)
targets.hemiGround.set(theme.hemi.ground)
hemiRef.current.groundColor.lerp(targets.hemiGround, dt)
}
if (ambientRef.current) {
ambientRef.current.intensity = THREE.MathUtils.lerp(
ambientRef.current.intensity,
isDark ? 0.15 : 0.5,
theme.ambient.intensity,
dt,
)
targets.ambColor.set(isDark ? '#a0b0ff' : '#ffffff')
targets.ambColor.set(theme.ambient.color)
ambientRef.current.color.lerp(targets.ambColor, dt)
}
})
return (
<>
<directionalLight
castShadow={!SHADOWS_DISABLED}
position={[10, 10, 10]}
ref={light1Ref}
shadow-bias={-0.002}
shadow-mapSize={[1024, 1024]}
shadow-normalBias={0.3}
shadow-radius={2}
>
{SHADOWS_DISABLED ? null : (
<orthographicCamera
attach="shadow-camera"
bottom={-shadowCameraSize}
far={100}
left={-shadowCameraSize}
near={1}
ref={shadowCamera}
right={shadowCameraSize}
top={shadowCameraSize}
/>
)}
</directionalLight>
{theme.lights.map((light, index) => (
<directionalLight
castShadow={Boolean(light.castShadow) && !SHADOWS_DISABLED && shadows}
key={`${index}-${light.position.join(',')}`}
position={light.position}
ref={(ref) => {
lightRefs.current[index] = ref
}}
shadow-bias={-0.002}
shadow-mapSize={[1024, 1024]}
shadow-normalBias={0.3}
shadow-radius={1.5}
>
{light.castShadow && !SHADOWS_DISABLED && shadows ? (
<orthographicCamera
attach="shadow-camera"
bottom={-shadowCameraSize}
far={100}
left={-shadowCameraSize}
near={1}
ref={shadowCamera}
right={shadowCameraSize}
top={shadowCameraSize}
/>
) : null}
</directionalLight>
))}
<directionalLight position={[-10, 10, -10]} ref={light2Ref} />
<directionalLight position={[-10, 10, 10]} ref={light3Ref} />
{theme.hemi ? <hemisphereLight ref={hemiRef} /> : null}
<ambientLight ref={ambientRef} />
</>
@@ -21,9 +21,12 @@ import {
vec4,
} from 'three/tsl'
import { RenderPipeline, type WebGPURenderer } from 'three/webgpu'
import { edgeColorFor } from '../../lib/edge-style'
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
import { SCENE_LAYER, ZONE_LAYER } from '../../lib/layers'
import { inkedEdges } from '../../lib/ink-edges'
import { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from '../../lib/layers'
import { mergedOutline } from '../../lib/merged-outline-node'
import { getSceneTheme } from '../../lib/scene-themes'
import useViewer from '../../store/use-viewer'
// SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion
@@ -82,9 +85,6 @@ const PERF_POST_FX_DISABLED =
const MAX_PIPELINE_RETRIES = 3
const RETRY_DELAY_MS = 500
const DARK_BG = '#1f2433'
const LIGHT_BG = '#ffffff'
export type HoverStyle = {
visibleColor: number
hiddenColor: number
@@ -135,18 +135,41 @@ const PostProcessingPasses = ({
const skippedZeroSizeRef = useRef(false)
// Background color uniform — updated every frame via lerp, read by the TSL pipeline.
// Initialised from the current theme so there's no flash on first render.
const initBg = useViewer.getState().theme === 'dark' ? DARK_BG : LIGHT_BG
// Initialised from the current scene theme so there's no flash on first render.
const initBg = getSceneTheme(useViewer.getState().sceneTheme).background
const bgUniform = useRef(uniform(new Color(initBg)))
const bgCurrent = useRef(new Color(initBg))
const bgTarget = useRef(new Color())
// Ink-line colour follows the scene-theme background luminance (dark lines on
// light scenes, light on dark), refreshed each frame like the background.
const inkColorUniform = useRef(uniform(new Color(edgeColorFor(initBg))))
const zoneLayers = useMemo(() => {
const l = new Layers()
l.enable(ZONE_LAYER)
l.disable(SCENE_LAYER)
return l
}, [])
// Scene pass renders the main geometry layer plus the grid. The default camera
// mask also has the overlay layer enabled (custom controls enable it for
// picking), so without this the gizmos/handles/tool previews land in the
// depth+normal MRT and get inked / AO'd as if they were geometry. The grid is
// kept in here (not the overlay pass) so scene geometry depth-occludes it; it's
// a flat, depth-non-writing plane so the ink never picks it up.
const sceneOnlyLayers = useMemo(() => {
const l = new Layers()
l.set(SCENE_LAYER)
l.enable(GRID_LAYER)
return l
}, [])
// Editor overlays render in their own pass, composited on top after the ink
// and outlines so they read as crisp UI rather than scene geometry.
const overlayLayers = useMemo(() => {
const l = new Layers()
l.set(OVERLAY_LAYER)
return l
}, [])
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
const hoverVisibleColor = useMemo(() => uniform(new Color(DEFAULT_HOVER_STYLE.visibleColor)), [])
const hoverHiddenColor = useMemo(() => uniform(new Color(DEFAULT_HOVER_STYLE.hiddenColor)), [])
@@ -155,6 +178,8 @@ const PostProcessingPasses = ({
// Subscribe to projectId so the pipeline rebuilds on project switch
const projectId = useViewer((s) => s.projectId)
const shading = useViewer((s) => s.shading)
const edges = useViewer((s) => s.edges)
const lastProjectIdRef = useRef(projectId)
// Bump this to force a pipeline rebuild (used by retry logic)
@@ -235,9 +260,33 @@ const PostProcessingPasses = ({
}
const perfDisable = readPerfDisableFlags()
const ssgiEnabled = SSGI_PARAMS.enabled && !perfDisable.ao
const ssgiEnabled = shading === 'rendered' && SSGI_PARAMS.enabled && !perfDisable.ao
const denoiseEnabled = ssgiEnabled && !perfDisable.denoise
const outlineEnabled = !perfDisable.outline
const inkEnabled = edges !== 'off'
// The depth+normal MRT feeds both SSGI and the screen-space ink pass.
const needsNormalMRT = ssgiEnabled || inkEnabled
// Soft = thin (1px sample radius) + faint (50% opacity); strong = thick
// (2px, ~2× wider detected band) + solid (100%). The edge masks saturate,
// so radius+opacity are what actually separate the two modes — gain wouldn't.
// Same 1px line thickness for both (soft's thickness is the nice one);
// strong reads heavier purely by being fully solid vs soft's lighter 50%.
const inkRadius = 1
const inkOpacity = edges === 'strong' ? 1 : 0.5
console.log('[viewer/post-processing] Building pipeline', {
version: pipelineVersion,
ssgi: ssgiEnabled,
denoise: denoiseEnabled,
outline: outlineEnabled,
perfDisable,
hoverHighlightMode,
projectId,
shading,
rendererCtor: (renderer as any).constructor?.name,
width,
height,
})
hasPipelineErrorRef.current = false
@@ -266,8 +315,15 @@ const PostProcessingPasses = ({
try {
const scenePass = pass(scene, camera)
scenePass.setLayers(sceneOnlyLayers)
const zonePass = pass(scene, camera)
zonePass.setLayers(zoneLayers)
// Editor overlays (gizmos, move handles, tool previews, grid) on their own
// layer, kept out of the depth/normal MRT above so the ink + SSGI ignore
// them, then composited on top of the final image below.
const overlayPass = pass(scene, camera)
overlayPass.setLayers(overlayLayers)
const overlayColor = overlayPass.getTextureNode('output')
const scenePassColor = scenePass.getTextureNode('output')
@@ -280,8 +336,12 @@ const PostProcessingPasses = ({
let sceneColor = scenePassColor as unknown as ReturnType<typeof vec4>
if (ssgiEnabled) {
// MRT only needed for SSGI (diffuse for GI, normal for SSGI sampling)
// Depth + normal MRT — shared by SSGI (diffuse/normal) and the ink pass
// (depth/normal). Built whenever either is active.
let scenePassDepth: any = null
let scenePassNormal: any = null
let sceneNormal: any = null
if (needsNormalMRT) {
scenePass.setMRT(
mrt({
output,
@@ -289,19 +349,18 @@ const PostProcessingPasses = ({
normal: directionToColor(normalView),
}),
)
const scenePassDiffuse = scenePass.getTextureNode('diffuseColor')
const scenePassDepth = scenePass.getTextureNode('depth')
const scenePassNormal = scenePass.getTextureNode('normal')
// Optimize texture bandwidth
const diffuseTexture = scenePass.getTexture('diffuseColor')
diffuseTexture.type = UnsignedByteType
scenePassDepth = scenePass.getTextureNode('depth')
scenePassNormal = scenePass.getTextureNode('normal')
const normalTexture = scenePass.getTexture('normal')
normalTexture.type = UnsignedByteType
// Extract normal from color-encoded texture (SSGI consumes the node form)
sceneNormal = sample((uv) => colorToDirection(scenePassNormal.sample(uv)))
}
// Extract normal from color-encoded texture
const sceneNormal = sample((uv) => colorToDirection(scenePassNormal.sample(uv)))
if (ssgiEnabled) {
const scenePassDiffuse = scenePass.getTextureNode('diffuseColor')
const diffuseTexture = scenePass.getTexture('diffuseColor')
diffuseTexture.type = UnsignedByteType
const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, camera as any)
giPass.sliceCount.value = SSGI_PARAMS.sliceCount
@@ -341,6 +400,23 @@ const PostProcessingPasses = ({
)
}
// Screen-space ink outline (SketchUp look) — depth/normal edge detection
// over the composited scene. Topology-agnostic, so it handles CSG-cut
// walls cleanly. Applied before the selection outline + background mix.
if (inkEnabled) {
sceneColor = vec4(
inkedEdges({
sceneRgb: sceneColor.rgb,
depthTex: scenePassDepth,
normalTex: scenePassNormal,
inkColor: inkColorUniform.current,
radius: inkRadius,
opacity: inkOpacity,
}),
sceneColor.a,
)
}
// Single merged outline node: one shared depth pass for both selected + hovered groups.
const outliner = useViewer.getState().outliner
let compositeWithOutlines = sceneColor
@@ -377,10 +453,11 @@ const PostProcessingPasses = ({
)
}
const finalOutput = vec4(
mix(bgUniform.current, compositeWithOutlines.rgb, contentAlpha),
float(1),
)
const composited = mix(bgUniform.current, 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)
const finalOutput = vec4(withOverlay, float(1))
const renderPipeline = new RenderPipeline(renderer as unknown as WebGPURenderer)
renderPipeline.outputNode = finalOutput
@@ -416,13 +493,17 @@ const PostProcessingPasses = ({
hoverPulseMix,
hoverStrength,
hoverVisibleColor,
edges,
pipelineVersion,
projectId,
renderer,
scene,
shading,
size.height,
size.width,
zoneLayers,
sceneOnlyLayers,
overlayLayers,
])
useFrame((_, delta) => {
@@ -430,10 +511,12 @@ const PostProcessingPasses = ({
return
}
// Animate background colour toward the current theme target (same lerp as AnimatedBackground)
bgTarget.current.set(useViewer.getState().theme === 'dark' ? DARK_BG : LIGHT_BG)
// Animate background colour toward the current scene theme target (same lerp as AnimatedBackground)
bgTarget.current.set(getSceneTheme(useViewer.getState().sceneTheme).background)
bgCurrent.current.lerp(bgTarget.current, Math.min(delta, 0.1) * 4)
bgUniform.current.value.copy(bgCurrent.current)
// Ink colour follows the (lerping) background luminance — snaps dark↔light.
inkColorUniform.current.value.set(edgeColorFor(`#${bgCurrent.current.getHexString()}`))
const outliner = useViewer.getState().outliner
sanitizeOutlineObjects(outliner.selectedObjects)
+33 -16
View File
@@ -3,6 +3,7 @@
// Public so registry-driven kinds can compose children without reaching
// into viewer's internal paths.
export type { SurfaceRole } from '@pascal-app/core'
export { ErrorBoundary } from './components/error-boundary'
// Stage A wrap-exports for the rest of the kinds — `@pascal-app/nodes`
// registers each via `def.renderer` (and `def.system` when present)
@@ -22,14 +23,32 @@ 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 { SCENE_LAYER, ZONE_LAYER } from './lib/layers'
// CSG primitives — used by chimney's roof-trim and other kinds whose
// geometry subtracts pieces against their host. Lives in viewer
// because three-bvh-csg / three-mesh-bvh are viewer-only deps.
export {
ADDITION,
Brush,
computeGeometryBoundsTree,
csgEvaluator,
csgGeometry,
csgMaterials,
prepareBrushForCSG,
SUBTRACTION,
} from './lib/csg-utils'
export type { EdgeMode } from './lib/edge-style'
export { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from './lib/layers'
export {
applyMaterialPresetToMaterials,
BLUEPRINT_PALETTE,
baseMaterial,
CLAY_PALETTE,
type ColorPreset,
clearMaterialCache,
createDefaultMaterial,
createMaterial,
createMaterialFromPresetRef,
createSurfaceRoleMaterial,
DEFAULT_CEILING_MATERIAL,
DEFAULT_DOOR_MATERIAL,
DEFAULT_ROOF_MATERIAL,
@@ -40,9 +59,20 @@ export {
DEFAULT_WINDOW_MATERIAL,
disposeMaterial,
glassMaterial,
MONO_PALETTE,
PRESET_PALETTES,
type RenderShading,
resolveSurfaceColor,
WHITE_PALETTE,
} from './lib/materials'
export { mergedOutline } from './lib/merged-outline-node'
export { unionPolygons } from './lib/polygon-union'
export {
getSceneTheme,
SCENE_THEME_IDS,
SCENE_THEMES,
type SceneTheme,
} from './lib/scene-themes'
export { useItemLightPool } from './store/use-item-light-pool'
export { default as useViewer } from './store/use-viewer'
export { CeilingSystem } from './systems/ceiling/ceiling-system'
@@ -73,19 +103,6 @@ export { ItemSystem } from './systems/item/item-system'
export { ItemLightSystem } from './systems/item-light/item-light-system'
export { LevelSystem } from './systems/level/level-system'
export { snapLevelsToTruePositions } from './systems/level/level-utils'
// CSG primitives — used by chimney's roof-trim and other kinds whose
// geometry subtracts pieces against their host. Lives in viewer
// because three-bvh-csg / three-mesh-bvh are viewer-only deps.
export {
ADDITION,
Brush,
csgEvaluator,
csgGeometry,
csgMaterials,
computeGeometryBoundsTree,
prepareBrushForCSG,
SUBTRACTION,
} from './lib/csg-utils'
export { getRoofMaterialArray } from './systems/roof/roof-materials'
// Generic roof-segment primitives. Kinds that compose CSG against
// the roof shell (chimney's self-trim, dormer's virtual-segment cut)
@@ -95,10 +112,10 @@ export {
getRoofOuterSurfaceFrameAtPoint,
getRoofSegmentBrushes,
mapRoofGroupMaterialIndex,
remapRoofShellFaces,
ROOF_MATERIAL_SLOT_COUNT,
roofCsgDummyMats,
RoofSystem,
remapRoofShellFaces,
roofCsgDummyMats,
type SurfaceFrame,
} from './systems/roof/roof-system'
export { ScanSystem } from './systems/scan/scan-system'
+5 -3
View File
@@ -1,5 +1,5 @@
import * as THREE from 'three'
import { Brush, Evaluator } from 'three-bvh-csg'
import type * as THREE from 'three'
import { type Brush, Evaluator } from 'three-bvh-csg'
import { computeBoundsTree } from 'three-mesh-bvh'
/**
@@ -27,7 +27,9 @@ csgEvaluator.attributes = ['position', 'normal', 'uv']
export function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) {
;(geometry as unknown as { computeBoundsTree: typeof computeBoundsTree }).computeBoundsTree =
computeBoundsTree
;(geometry as unknown as { computeBoundsTree: (opts: { maxLeafSize: number }) => void }).computeBoundsTree({ maxLeafSize: 10 })
;(
geometry as unknown as { computeBoundsTree: (opts: { maxLeafSize: number }) => void }
).computeBoundsTree({ maxLeafSize: 10 })
}
export function prepareBrushForCSG(brush: Brush) {
+15
View File
@@ -0,0 +1,15 @@
// Edge overlay = a screen-space ink pass (see `ink-edges.ts`), driven by this
// 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 {
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'
}
+79
View File
@@ -0,0 +1,79 @@
import {
abs,
colorToDirection,
float,
max,
min,
mix,
screenSize,
screenUV,
smoothstep,
vec2,
} from 'three/tsl'
// Screen-space ink outline (SketchUp / Moebius look). Reads the scene-pass
// depth + normal MRT and inks two signals:
//
// 1. Crease — center normal vs each neighbour (`1 - dot`): 0 on a flat
// surface, ~1 at a 90° corner. Catches wall↔roof, wall↔wall and window-
// reveal creases. Normals are re-normalized because the 8-bit normal MRT
// decodes to non-unit vectors, which otherwise crushes the signal.
// 2. Depth — raw-depth Laplacian (screen-linear across planes → ~0 on flat
// ground, no banding) normalized by (1 - depth)² so it becomes ≈
// worldStep / near, i.e. DISTANCE-INDEPENDENT (a window reveal reads the
// same zoomed in or out). A raw-Laplacian gate rejects flat-plane
// quantization noise so the far ground/roof never inks.
//
// Topology-agnostic: works on CSG triangle soup, organic GLBs, anything — it
// only sees the rendered buffers. `intensity` scales the final mask; `inkColor`
// should track the background luminance (dark lines on light scenes).
export function inkedEdges({
depthTex,
normalTex,
inkColor,
radius,
opacity,
sceneRgb,
}: {
depthTex: any
normalTex: any
inkColor: any
// 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.
radius: number
opacity: number
sceneRgb: any
}) {
const px = vec2(1, 1).div(screenSize).mul(radius)
const uvN = screenUV
const dC = depthTex.sample(uvN).r
const dR = depthTex.sample(uvN.add(vec2(px.x, 0))).r
const dL = depthTex.sample(uvN.sub(vec2(px.x, 0))).r
const dU = depthTex.sample(uvN.add(vec2(0, px.y))).r
const dD = depthTex.sample(uvN.sub(vec2(0, px.y))).r
const depthLap = abs(dR.add(dL).add(dU).add(dD).sub(dC.mul(4)))
const invDepth = float(1).sub(dC)
const depthMetric = depthLap.div(invDepth.mul(invDepth).add(float(0.00002)))
const noiseGate = smoothstep(float(0.00002), float(0.00006), depthLap)
// ≈ metres of step / near (near≈0.1): ~5cm starts a line, ~25cm solid.
const depthEdge = smoothstep(float(0.5), float(2.5), depthMetric).mul(noiseGate)
const nC = colorToDirection(normalTex.sample(uvN)).normalize()
const nR = colorToDirection(normalTex.sample(uvN.add(vec2(px.x, 0)))).normalize()
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()
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))),
)
const normalEdge = smoothstep(float(0.01), float(0.05), nDiff)
// 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))
return (mix as any)(sceneRgb, inkColor, edgeMask)
}
+20
View File
@@ -1,5 +1,25 @@
/** Default Three.js layer for main scene geometry. */
export const SCENE_LAYER = 0
/**
* Layer for editor-only overlays (gizmos, move handles, tool previews, grid).
* The post-processing pipeline excludes this layer from the depth/normal MRT
* scene pass so the screen-space ink and SSGI never treat overlays as geometry,
* then composites it back on top via a dedicated overlay pass.
*
* Editor's `EDITOR_LAYER` (packages/editor) re-exports this — they MUST match.
*/
export const OVERLAY_LAYER = 1
/** Layer used for zone rendering (floor fills and wall borders). */
export const ZONE_LAYER = 2
/**
* Layer for the editor ground grid. Rendered *inside* the scene pass (so scene
* geometry depth-occludes it instead of it bleeding through walls/objects) — it
* is a flat, depth-non-writing plane, so the screen-space ink never picks it up.
* Kept off OVERLAY_LAYER because overlays composite on top with no scene-depth
* test, which is exactly what we don't want for a full-floor plane. Excluded
* from thumbnails like the other editor-only layers.
*/
export const GRID_LAYER = 3
+283 -66
View File
@@ -5,16 +5,74 @@ import {
type MaterialProperties,
type MaterialSchema,
resolveMaterial,
type SurfaceRole,
} from '@pascal-app/core'
import * as THREE from 'three'
import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu'
import { resolveCdnUrl } from './asset-url'
export const baseMaterial = new MeshStandardNodeMaterial({
color: '#f2f0ed',
roughness: 0.5,
metalness: 0.0,
})
import { resolveCdnUrl } from './asset-url'
import { getSceneTheme } from './scene-themes'
export type RenderShading = 'solid' | 'rendered'
export type ColorPreset = 'clay' | 'white' | 'mono' | 'blueprint'
export const CLAY_PALETTE: Record<SurfaceRole, string> = {
wall: '#dcd6c7',
floor: '#cfc8b6',
ceiling: '#e4ded0',
roof: '#b8ad96',
joinery: '#c4bba6',
glazing: '#c8d4dc',
furnishing: '#d2ccbe',
}
export const WHITE_PALETTE: Record<SurfaceRole, string> = {
wall: '#f4f3ef',
floor: '#ece9e2',
ceiling: '#fbfaf6',
roof: '#dedbd2',
joinery: '#e8e5dc',
glazing: '#dbe8ee',
furnishing: '#efede7',
}
export const MONO_PALETTE: Record<SurfaceRole, string> = {
wall: '#c8c8c8',
floor: '#b8b8b8',
ceiling: '#d8d8d8',
roof: '#9a9a9a',
joinery: '#adadad',
glazing: '#c2cbd0',
furnishing: '#c0c0c0',
}
export const BLUEPRINT_PALETTE: Record<SurfaceRole, string> = {
wall: '#90a9c7',
floor: '#7f98ba',
ceiling: '#aec0d8',
roof: '#5f789b',
joinery: '#6f86a8',
glazing: '#b6d7ea',
furnishing: '#8ba2bf',
}
export const PRESET_PALETTES: Record<ColorPreset, Record<SurfaceRole, string>> = {
clay: CLAY_PALETTE,
white: WHITE_PALETTE,
mono: MONO_PALETTE,
blueprint: BLUEPRINT_PALETTE,
}
export function resolveSurfaceColor(
role: SurfaceRole,
preset: ColorPreset,
sceneThemeId?: string,
): string {
// The active scene theme may tint individual roles (e.g. Mediterranean's blue
// roof); fall back to the chosen colour preset's palette when it doesn't.
const tints = sceneThemeId ? getSceneTheme(sceneThemeId).clayTints : undefined
return tints?.[role] ?? PRESET_PALETTES[preset][role]
}
// DoubleSide on any NodeMaterial inside the MRT scenePass (SSGI's output /
// diffuseColor / normal targets) causes WebGPU to create a render pipeline
@@ -34,7 +92,9 @@ const sideMap: Record<MaterialProperties['side'], THREE.Side> = {
double: THREE.DoubleSide,
}
const materialCache = new Map<string, THREE.MeshStandardMaterial>()
const materialCache = new Map<string, THREE.Material>()
const defaultMaterialCache = new Map<string, THREE.Material>()
const surfaceRoleMaterialCache = new Map<string, THREE.Material>()
const textureCache = new Map<string, THREE.Texture>()
const textureLoadPromises = new Map<string, Promise<THREE.Texture | null>>()
const textureLoader = new THREE.TextureLoader()
@@ -44,7 +104,24 @@ const wrapMap = {
MirroredRepeat: THREE.MirroredRepeatWrapping,
} as const
type StandardMaterial = THREE.MeshStandardMaterial | THREE.MeshPhysicalMaterial
type CommonMaterial = THREE.Material & {
color: THREE.Color
map?: THREE.Texture | null
emissive?: THREE.Color
emissiveIntensity?: number
opacity: number
transparent: boolean
side: THREE.Side
needsUpdate: boolean
}
type StandardMaterial =
| THREE.MeshStandardMaterial
| THREE.MeshPhysicalMaterial
| MeshStandardNodeMaterial
type TextureMaterial = CommonMaterial & Partial<Record<TextureSlot, THREE.Texture | null>>
type TextureSlot =
| 'map'
| 'normalMap'
@@ -79,8 +156,8 @@ function getTextureChannel(slot?: TextureSlot): number {
return 0
}
function getCacheKey(props: MaterialProperties): string {
return `${props.color}-${props.roughness}-${props.metalness}-${props.opacity}-${props.transparent}-${props.side}`
function getCacheKey(props: MaterialProperties, shading: RenderShading): string {
return `${shading}-${props.color}-${props.roughness}-${props.metalness}-${props.opacity}-${props.transparent}-${props.side}`
}
function getTextureKey(material?: MaterialSchema): string {
@@ -115,10 +192,16 @@ function getTexture(material?: MaterialSchema): THREE.Texture | undefined {
function isStandardMaterial(material: THREE.Material): material is StandardMaterial {
return (
material instanceof THREE.MeshStandardMaterial || material instanceof THREE.MeshPhysicalMaterial
material instanceof THREE.MeshStandardMaterial ||
material instanceof THREE.MeshPhysicalMaterial ||
material instanceof MeshStandardNodeMaterial
)
}
function isCommonMaterial(material: THREE.Material): material is CommonMaterial {
return 'color' in material && material.color instanceof THREE.Color
}
function applyTextureProperties(
texture: THREE.Texture,
props: MaterialMapProperties,
@@ -181,14 +264,14 @@ function createAssignedTexture(
return applyTextureProperties(texture, props, slot)
}
function applyTexturePropertiesToMaterial(
material: StandardMaterial,
props: MaterialMapProperties,
) {
for (const slot of TEXTURE_SLOTS) {
const texture = material[slot]
function applyTexturePropertiesToMaterial(material: CommonMaterial, props: MaterialMapProperties) {
const slots = isStandardMaterial(material) ? TEXTURE_SLOTS : (['map'] as const)
const textureMaterial = material as TextureMaterial
for (const slot of slots) {
const texture = textureMaterial[slot as TextureSlot]
if (!texture) continue
applyTextureProperties(texture, props, slot)
applyTextureProperties(texture, props, slot as TextureSlot)
}
}
@@ -225,53 +308,62 @@ async function loadPresetTexture(
}
function queueTextureAssignment(
material: StandardMaterial,
material: CommonMaterial,
slot: TextureSlot,
path: string | undefined,
props: MaterialMapProperties,
) {
const textureMaterial = material as TextureMaterial
if (!path) {
material[slot] = null
textureMaterial[slot] = null
return
}
const resolvedPath = resolveCdnUrl(path) ?? path
const cacheKey = getPresetTextureCacheKey(resolvedPath, props, slot)
if (material[slot]?.userData.pascalTextureCacheKey === cacheKey) {
applyTextureProperties(material[slot], props, slot)
if (textureMaterial[slot]?.userData.pascalTextureCacheKey === cacheKey) {
applyTextureProperties(textureMaterial[slot], props, slot)
return
}
const cached = textureCache.get(cacheKey)
if (cached) {
material[slot] = createAssignedTexture(cached, props, slot)
textureMaterial[slot] = createAssignedTexture(cached, props, slot)
material.needsUpdate = true
return
}
material[slot] = null
textureMaterial[slot] = null
loadPresetTexture(path, props, slot).then((texture) => {
if (!texture) return
material[slot] = createAssignedTexture(texture, props, slot)
textureMaterial[slot] = createAssignedTexture(texture, props, slot)
material.needsUpdate = true
})
}
function applyMaterialMapProperties(
material: StandardMaterial,
material: CommonMaterial,
mapProperties: MaterialMapProperties,
) {
material.color.set(mapProperties.color)
material.roughness = mapProperties.roughness
material.metalness = mapProperties.metalness
material.emissiveIntensity = mapProperties.emissiveIntensity
material.emissive.set(mapProperties.emissiveColor)
material.displacementScale = mapProperties.displacementScale
material.bumpScale = mapProperties.bumpScale
material.aoMapIntensity = mapProperties.aoMapIntensity
material.lightMapIntensity = mapProperties.lightMapIntensity
if (isStandardMaterial(material)) {
material.roughness = mapProperties.roughness
material.metalness = mapProperties.metalness
material.displacementScale = mapProperties.displacementScale
material.bumpScale = mapProperties.bumpScale
material.aoMapIntensity = mapProperties.aoMapIntensity
material.lightMapIntensity = mapProperties.lightMapIntensity
material.normalScale.set(mapProperties.normalScaleX, mapProperties.normalScaleY)
}
if (material.emissive) {
material.emissive.set(mapProperties.emissiveColor)
}
if ('emissiveIntensity' in material) {
material.emissiveIntensity = mapProperties.emissiveIntensity
}
material.transparent = mapProperties.transparent
material.opacity = mapProperties.opacity
material.side =
@@ -280,15 +372,19 @@ function applyMaterialMapProperties(
: mapProperties.side === 1
? THREE.BackSide
: THREE.DoubleSide
material.normalScale.set(mapProperties.normalScaleX, mapProperties.normalScaleY)
applyTexturePropertiesToMaterial(material, mapProperties)
material.needsUpdate = true
}
function applyMaterialPresetTextures(material: StandardMaterial, preset: MaterialPresetPayload) {
function applyMaterialPresetTextures(material: CommonMaterial, preset: MaterialPresetPayload) {
const { maps, mapProperties } = preset
queueTextureAssignment(material, 'map', maps.albedoMap, mapProperties)
if (!isStandardMaterial(material)) {
material.needsUpdate = true
return
}
queueTextureAssignment(material, 'normalMap', maps.normalMap, mapProperties)
queueTextureAssignment(material, 'roughnessMap', maps.roughnessMap, mapProperties)
queueTextureAssignment(material, 'metalnessMap', maps.metalnessMap, mapProperties)
@@ -308,7 +404,7 @@ export function applyMaterialPresetToMaterials(
if (!preset) return
const materials = (Array.isArray(materialInput) ? materialInput : [materialInput]).filter(
isStandardMaterial,
isCommonMaterial,
)
if (materials.length === 0) return
@@ -321,14 +417,16 @@ export function applyMaterialPresetToMaterials(
export function createMaterialFromPreset(
preset: MaterialPresetPayload,
): THREE.MeshStandardMaterial {
const cacheKey = JSON.stringify(preset)
shading: RenderShading = 'rendered',
): THREE.Material {
const cacheKey = `${shading}-${JSON.stringify(preset)}`
if (materialCache.has(cacheKey)) {
return materialCache.get(cacheKey)!
}
const material = new THREE.MeshStandardMaterial()
const material =
shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial()
applyMaterialPresetToMaterials(material, preset)
materialCache.set(cacheKey, material)
return material
@@ -336,25 +434,33 @@ export function createMaterialFromPreset(
export function createMaterialFromPresetRef(
materialPreset?: string,
): THREE.MeshStandardMaterial | null {
shading: RenderShading = 'rendered',
): THREE.Material | null {
const preset = getMaterialPresetByRef(materialPreset)
if (!preset) return null
return createMaterialFromPreset(preset)
return createMaterialFromPreset(preset, shading)
}
export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMaterial {
export function createMaterial(
material?: MaterialSchema,
shading: RenderShading = 'rendered',
): THREE.Material {
const props = resolveMaterial(material)
const cacheKey = `${getCacheKey(props)}-${getTextureKey(material)}`
const cacheKey = `${getCacheKey(props, shading)}-${getTextureKey(material)}`
if (materialCache.has(cacheKey)) {
return materialCache.get(cacheKey)!
}
const map = getTexture(material)
const materialParams: THREE.MeshStandardMaterialParameters = {
const materialParams: {
color: string
map?: THREE.Texture
opacity: number
side: THREE.Side
transparent: boolean
} = {
color: props.color,
roughness: props.roughness,
metalness: props.metalness,
opacity: props.opacity,
transparent: props.transparent,
side: sideMap[props.side],
@@ -362,7 +468,14 @@ export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMat
if (map) materialParams.map = map
const threeMaterial = new THREE.MeshStandardMaterial(materialParams)
const threeMaterial =
shading === 'solid'
? new MeshLambertNodeMaterial(materialParams)
: new MeshStandardNodeMaterial({
...materialParams,
roughness: props.roughness,
metalness: props.metalness,
})
materialCache.set(cacheKey, threeMaterial)
return threeMaterial
@@ -371,30 +484,124 @@ export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMat
export function createDefaultMaterial(
color = '#ffffff',
roughness = 0.9,
): THREE.MeshStandardMaterial {
return new THREE.MeshStandardMaterial({
shading: RenderShading = 'rendered',
side: THREE.Side = THREE.FrontSide,
): THREE.Material {
if (shading === 'solid') {
return new MeshLambertNodeMaterial({
color,
side,
})
}
return new MeshStandardNodeMaterial({
color,
roughness,
metalness: 0,
side: THREE.FrontSide,
side,
})
}
export const DEFAULT_WALL_MATERIAL = createDefaultMaterial('#ffffff', 0.9)
export const DEFAULT_SLAB_MATERIAL = createDefaultMaterial('#e5e5e5', 0.8)
export const DEFAULT_DOOR_MATERIAL = createDefaultMaterial('#8b4513', 0.7)
export const DEFAULT_WINDOW_MATERIAL = new THREE.MeshStandardMaterial({
color: '#87ceeb',
roughness: 0.1,
metalness: 0.1,
opacity: 0.3,
transparent: true,
side: THREE.FrontSide,
})
export const DEFAULT_CEILING_MATERIAL = createDefaultMaterial('#f5f5dc', 0.95)
export const DEFAULT_ROOF_MATERIAL = createDefaultMaterial('#808080', 0.85)
export const DEFAULT_SHELF_MATERIAL = createDefaultMaterial('#ffffff', 0.9)
export const DEFAULT_STAIR_MATERIAL = createDefaultMaterial('#ffffff', 0.9)
function cachedDefaultMaterial(
key: string,
color: string,
roughness: number,
shading: RenderShading,
side: THREE.Side = THREE.FrontSide,
): THREE.Material {
const cacheKey = `${key}-${shading}`
const cached = defaultMaterialCache.get(cacheKey)
if (cached) return cached
const material = createDefaultMaterial(color, roughness, shading, side)
defaultMaterialCache.set(cacheKey, material)
return material
}
export function createSurfaceRoleMaterial(
role: SurfaceRole,
preset: ColorPreset,
side: THREE.Side = THREE.FrontSide,
sceneThemeId?: string,
): THREE.Material {
const resolvedSide = role === 'glazing' ? THREE.DoubleSide : side
const cacheKey = `${role}-${preset}-${resolvedSide}-${sceneThemeId ?? 'base'}`
const cached = surfaceRoleMaterialCache.get(cacheKey)
if (cached) return cached
const material =
role === 'glazing'
? new MeshLambertNodeMaterial({
color: resolveSurfaceColor(role, preset, sceneThemeId),
depthWrite: false,
opacity: 0.25,
side: resolvedSide,
transparent: true,
})
: new MeshLambertNodeMaterial({
color: resolveSurfaceColor(role, preset, sceneThemeId),
side: resolvedSide,
})
material.userData.__pascalCachedMaterial = true
surfaceRoleMaterialCache.set(cacheKey, material)
return material
}
export function baseMaterial(shading: RenderShading = 'rendered'): THREE.Material {
return cachedDefaultMaterial('base', '#f2f0ed', 0.5, shading)
}
export function DEFAULT_WALL_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material {
return cachedDefaultMaterial('wall', '#ffffff', 0.9, shading)
}
export function DEFAULT_SLAB_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material {
return cachedDefaultMaterial('slab', '#e5e5e5', 0.8, shading)
}
export function DEFAULT_DOOR_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material {
return cachedDefaultMaterial('door', '#8b4513', 0.7, shading)
}
export function DEFAULT_WINDOW_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material {
const cacheKey = `window-${shading}`
const cached = defaultMaterialCache.get(cacheKey)
if (cached) return cached
const params = {
color: '#87ceeb',
opacity: 0.3,
transparent: true,
side: THREE.DoubleSide,
}
const material =
shading === 'solid'
? new MeshLambertNodeMaterial(params)
: new MeshStandardNodeMaterial({
...params,
roughness: 0.1,
metalness: 0.1,
})
defaultMaterialCache.set(cacheKey, material)
return material
}
export function DEFAULT_CEILING_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material {
return cachedDefaultMaterial('ceiling', '#f5f5dc', 0.95, shading)
}
export function DEFAULT_ROOF_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material {
return cachedDefaultMaterial('roof', '#808080', 0.85, shading)
}
export function DEFAULT_SHELF_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material {
return cachedDefaultMaterial('shelf', '#ffffff', 0.9, shading)
}
export function DEFAULT_STAIR_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material {
return cachedDefaultMaterial('stair', '#ffffff', 0.9, shading)
}
export function disposeMaterial(material: THREE.Material): void {
material.dispose()
@@ -406,6 +613,16 @@ export function clearMaterialCache(): void {
}
materialCache.clear()
for (const material of defaultMaterialCache.values()) {
material.dispose()
}
defaultMaterialCache.clear()
for (const material of surfaceRoleMaterialCache.values()) {
material.dispose()
}
surfaceRoleMaterialCache.clear()
for (const texture of textureCache.values()) {
texture.dispose()
}
+222
View File
@@ -0,0 +1,222 @@
import type { SurfaceRole } from '@pascal-app/core'
export type SceneTheme = {
id: string
name: string
// Drives the 2D scene chrome that used to follow the removed light/dark toggle:
// canvas backdrop, grid line colours, measurement-label/cursor contrast, and
// 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
// separate from `background` so dark themes can have a lit ground that reads
// as ground rather than going near-black.
ground: string
ambient: { color: string; intensity: number }
hemi?: { sky: string; ground: string; intensity: number }
lights: Array<{
position: [number, number, number]
color: string
intensity: number
castShadow?: boolean
}>
toneMappingExposure: number
clayTints?: Partial<Record<SurfaceRole, string>>
}
export const SCENE_THEMES: SceneTheme[] = [
{
id: 'studio',
name: 'Studio',
appearance: 'light',
background: '#ffffff',
ground: '#f4f4f2',
ambient: { color: '#ffffff', intensity: 0.15 },
hemi: { sky: '#ffffff', ground: '#aaa49a', intensity: 0.6 },
lights: [
{ position: [10, 10, 10], color: '#ffffff', intensity: 4, castShadow: true },
{ position: [-10, 10, -10], color: '#ffffff', intensity: 0.75 },
],
toneMappingExposure: 0.9,
clayTints: {
wall: '#e9e5db',
floor: '#d8d2c4',
ceiling: '#f1ede4',
roof: '#c4bba6',
glazing: '#cdd8df',
},
},
{
id: 'paper',
name: 'Paper',
appearance: 'light',
background: '#ede9df',
ground: '#e7e1d3',
ambient: { color: '#fff9eb', intensity: 0.55 },
hemi: { sky: '#fff5d9', ground: '#c2b89c', intensity: 0.35 },
lights: [
{ position: [16, 22, 12], color: '#fff1c8', intensity: 2.6, castShadow: true },
{ position: [-14, 10, -6], color: '#dde5ff', intensity: 0.35 },
],
toneMappingExposure: 1,
clayTints: {
wall: '#efe9da',
floor: '#ddd4bf',
ceiling: '#f5efe0',
roof: '#b9b09a',
glazing: '#cdd5d8',
},
},
{
id: 'sunset',
name: 'Sunset',
appearance: 'light',
background: '#f6e8d4',
ground: '#ecd9bf',
ambient: { color: '#ffd9a8', intensity: 0.45 },
hemi: { sky: '#ffd9a8', ground: '#5b4634', intensity: 0.4 },
lights: [
{ position: [22, 8, 8], color: '#ffb070', intensity: 3.4, castShadow: true },
{ position: [-14, 16, -10], color: '#a4b8ff', intensity: 0.4 },
],
toneMappingExposure: 1,
clayTints: {
wall: '#f3e3cf',
floor: '#e2cdab',
ceiling: '#f6e7d2',
roof: '#a6764f',
glazing: '#e7c9a8',
},
},
{
id: 'overcast',
name: 'Overcast',
appearance: 'light',
background: '#e6e7e6',
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,
clayTints: {
wall: '#dedfdc',
floor: '#cdcec9',
ceiling: '#e8e9e6',
roof: '#a3a49e',
glazing: '#c6cdd0',
},
},
{
id: 'blueprint',
name: 'Blueprint',
appearance: 'light',
background: '#dde6ef',
ground: '#c9d6e6',
ambient: { color: '#cfdcec', intensity: 0.7 },
hemi: { sky: '#dfeaf6', ground: '#5b6b80', intensity: 0.55 },
lights: [
{ position: [16, 24, 12], color: '#e6efff', intensity: 1.8, castShadow: true },
{ position: [-12, 10, -8], color: '#9fb6d8', intensity: 0.4 },
],
toneMappingExposure: 0.95,
clayTints: {
wall: '#9fb6d2',
floor: '#8ba2c2',
ceiling: '#aec0d8',
roof: '#5f789b',
glazing: '#b6d7ea',
},
},
{
id: 'mediterranean',
name: 'Mediterranean',
appearance: 'light',
background: '#bdd6e8',
ground: '#ddd2bb',
ambient: { color: '#d6e6f3', intensity: 0.5 },
hemi: { sky: '#a8c8e2', ground: '#d8c9a4', intensity: 0.6 },
lights: [
{ position: [18, 20, 12], color: '#fff4d4', intensity: 3.6, castShadow: true },
{ position: [-12, 8, -8], color: '#8fb3d8', intensity: 0.7 },
],
toneMappingExposure: 0.9,
clayTints: {
wall: '#f6f1e6',
floor: '#e0d6c2',
ceiling: '#f3ede0',
roof: '#3e6585',
glazing: '#bcd3e2',
},
},
{
id: 'twilight',
name: 'Twilight',
appearance: 'dark',
background: '#3a3550',
ground: '#4a4566',
ambient: { color: '#a89cc8', intensity: 0.35 },
hemi: { sky: '#d8a8c0', ground: '#1c1830', intensity: 0.5 },
lights: [
{ position: [-14, 22, -10], color: '#a4b6e8', intensity: 1.4, castShadow: true },
{ position: [14, 6, 8], color: '#ffb070', intensity: 0.9 },
],
toneMappingExposure: 1.1,
clayTints: {
wall: '#c5b9cf',
floor: '#ad9fbb',
ceiling: '#d2c6dc',
roof: '#5b4f74',
glazing: '#c3b6d4',
},
},
{
id: 'night',
name: 'Night',
appearance: 'dark',
background: '#1f2433',
ground: '#2b3247',
ambient: { color: '#a0b0ff', intensity: 0.07 },
hemi: { sky: '#3a4666', ground: '#0e111c', intensity: 0.4 },
lights: [
{ position: [10, 10, 10], color: '#e0e5ff', intensity: 0.8, castShadow: true },
{ position: [-10, 10, -10], color: '#8090ff', intensity: 0.2 },
],
toneMappingExposure: 0.9,
clayTints: {
wall: '#aab3c6',
floor: '#98a1b5',
ceiling: '#b7bfd0',
roof: '#5b6680',
glazing: '#aebbd0',
},
},
{
id: 'verdant',
name: 'Verdant',
appearance: 'light',
background: '#d6e4d2',
ground: '#c7d6b4',
ambient: { color: '#e3efdd', intensity: 0.5 },
hemi: { sky: '#cfe6cf', ground: '#8ea06f', intensity: 0.65 },
lights: [
{ position: [16, 22, 12], color: '#fff6d8', intensity: 3, castShadow: true },
{ position: [-12, 10, -8], color: '#bfe0c2', intensity: 0.5 },
],
toneMappingExposure: 0.95,
clayTints: {
wall: '#eef0e6',
floor: '#d8ddc6',
ceiling: '#f1f3ea',
roof: '#6f8a5a',
glazing: '#c4dcd0',
},
},
]
export const SCENE_THEME_IDS = SCENE_THEMES.map((theme) => theme.id)
const SCENE_THEME_BY_ID = new Map(SCENE_THEMES.map((theme) => [theme.id, theme]))
export function getSceneTheme(id: string): SceneTheme {
return SCENE_THEME_BY_ID.get(id) ?? SCENE_THEMES[0]!
}
+56 -5
View File
@@ -5,6 +5,10 @@ import type { Object3D } from 'three'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { EdgeMode } from '../lib/edge-style'
import type { ColorPreset, RenderShading } from '../lib/materials'
export type RenderContext = 'editor' | 'viewer'
type SelectionPath = {
buildingId: BuildingNode['id'] | null
@@ -30,8 +34,27 @@ type ViewerState = {
cameraMode: 'perspective' | 'orthographic'
setCameraMode: (mode: 'perspective' | 'orthographic') => void
theme: 'light' | 'dark'
setTheme: (theme: 'light' | 'dark') => void
sceneTheme: string
setSceneTheme: (id: string) => void
renderContext: RenderContext
setRenderContext: (context: RenderContext) => void
shading: RenderShading
shadingByContext: Partial<Record<RenderContext, RenderShading>>
setShading: (shading: RenderShading) => void
textures: boolean
setTextures: (textures: boolean) => void
colorPreset: ColorPreset
setColorPreset: (preset: ColorPreset) => void
edges: EdgeMode
setEdges: (edges: EdgeMode) => void
shadows: boolean
setShadows: (shadows: boolean) => void
unit: 'metric' | 'imperial'
setUnit: (unit: 'metric' | 'imperial') => void
@@ -93,8 +116,31 @@ const useViewer = create<ViewerState>()(
cameraMode: 'perspective',
setCameraMode: (mode) => set({ cameraMode: mode }),
theme: 'light',
setTheme: (theme) => set({ theme }),
sceneTheme: 'studio',
setSceneTheme: (id) => set({ sceneTheme: id }),
renderContext: 'editor',
setRenderContext: (context) => set({ renderContext: context }),
shading: 'rendered',
shadingByContext: {},
setShading: (shading) =>
set((state) => ({
shading,
shadingByContext: { ...state.shadingByContext, [state.renderContext]: shading },
})),
textures: true,
setTextures: (textures) => set({ textures }),
colorPreset: 'clay',
setColorPreset: (preset) => set({ colorPreset: preset }),
edges: 'soft',
setEdges: (edges) => set({ edges }),
shadows: true,
setShadows: (shadows) => set({ shadows }),
unit: 'metric',
setUnit: (unit) => set({ unit }),
@@ -208,7 +254,12 @@ const useViewer = create<ViewerState>()(
name: 'viewer-preferences',
partialize: (state) => ({
cameraMode: state.cameraMode,
theme: state.theme,
sceneTheme: state.sceneTheme,
shadingByContext: state.shadingByContext,
textures: state.textures,
colorPreset: state.colorPreset,
edges: state.edges,
shadows: state.shadows,
unit: state.unit,
levelMode: state.levelMode,
wallMode: state.wallMode,
@@ -8,19 +8,49 @@ import {
useScene,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { useEffect } from 'react'
import * as THREE from 'three'
import { baseMaterial, glassMaterial } from '../../lib/materials'
import {
createSurfaceRoleMaterial,
glassMaterial as defaultGlassMaterial,
baseMaterial as getBaseMaterial,
} from '../../lib/materials'
import useViewer from '../../store/use-viewer'
// Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
const revealMaterial = new THREE.MeshBasicMaterial({ color: '#7f766c' })
const defaultRevealMaterial = new THREE.MeshBasicMaterial({ color: '#7f766c' })
let baseMaterial = getBaseMaterial()
let revealMaterial: THREE.Material = defaultRevealMaterial
let glassMaterial: THREE.Material = defaultGlassMaterial
export const DoorSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty)
const shading = useViewer((state) => state.shading)
const textures = useViewer((state) => state.textures)
const colorPreset = useViewer((state) => state.colorPreset)
const joineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset)
baseMaterial = textures ? getBaseMaterial(shading) : joineryMaterial
revealMaterial = textures ? defaultRevealMaterial : joineryMaterial
glassMaterial = textures ? defaultGlassMaterial : joineryMaterial
useEffect(() => {
const nodes = useScene.getState().nodes
for (const node of Object.values(nodes)) {
if (node?.type === 'door') {
useScene.getState().dirtyNodes.add(node.id as AnyNodeId)
}
}
}, [shading, textures, colorPreset])
useFrame(() => {
if (dirtyNodes.size === 0) return
const frameJoineryMaterial = createSurfaceRoleMaterial('joinery', colorPreset)
baseMaterial = textures ? getBaseMaterial(shading) : frameJoineryMaterial
revealMaterial = textures ? defaultRevealMaterial : frameJoineryMaterial
glassMaterial = textures ? defaultGlassMaterial : frameJoineryMaterial
const nodes = useScene.getState().nodes
@@ -5,11 +5,19 @@ import {
type AnyNodeId,
type GeometryContext,
nodeRegistry,
type SurfaceRole,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import type { Group, Mesh } from 'three'
import { useEffect } from 'react'
import { FrontSide, type Group, type Material, type Mesh } from 'three'
import {
type ColorPreset,
createSurfaceRoleMaterial,
type RenderShading,
} from '../../lib/materials'
import useViewer from '../../store/use-viewer'
/**
* Generic geometry system.
@@ -45,6 +53,20 @@ import type { Group, Mesh } from 'three'
export const GeometrySystem = () => {
const dirtyNodes = useScene((s) => s.dirtyNodes)
const clearDirty = useScene((s) => s.clearDirty)
const shading = useViewer((s) => s.shading)
const textures = useViewer((s) => s.textures)
const colorPreset = useViewer((s) => s.colorPreset)
const sceneTheme = useViewer((s) => s.sceneTheme)
useEffect(() => {
const nodes = useScene.getState().nodes
for (const node of Object.values(nodes)) {
const def = nodeRegistry.get(node.type)
if (def?.geometry) {
useScene.getState().markDirty(node.id as AnyNodeId)
}
}
}, [shading, textures, colorPreset, sceneTheme])
useFrame(() => {
if (dirtyNodes.size === 0) return
@@ -123,10 +145,20 @@ export const GeometrySystem = () => {
// The builder is typed against the kind's specific node — at the
// generic system level we lose that refinement, so the cast lands
// here. Builders are responsible for trusting their schema.
const built = (builder as (n: AnyNode, c: GeometryContext) => { children: unknown[] })(
node,
ctx,
) as unknown as Group
const built = (
builder as (
n: AnyNode,
c: GeometryContext,
shading: RenderShading,
textures: boolean,
colorPreset: ColorPreset,
sceneTheme: string,
) => { children: unknown[] }
)(node, ctx, shading, textures, colorPreset, sceneTheme) as unknown as Group
if (!textures && def.surfaceRole) {
applyDefaultSurfaceRole(built, def.surfaceRole, colorPreset, sceneTheme)
}
disposeChildren(group)
for (const child of [...built.children]) {
@@ -215,10 +247,12 @@ function disposeChildren(group: Group) {
const m = (mesh as { material: unknown }).material
if (Array.isArray(m)) {
for (const mat of m) {
if (isCachedMaterial(mat)) continue
if (mat && typeof (mat as { dispose?: () => void }).dispose === 'function') {
;(mat as { dispose: () => void }).dispose()
}
}
} else if (isCachedMaterial(m)) {
} else if (m && typeof (m as { dispose?: () => void }).dispose === 'function') {
;(m as { dispose: () => void }).dispose()
}
@@ -226,4 +260,56 @@ function disposeChildren(group: Group) {
}
}
function applyDefaultSurfaceRole(
root: Group,
defaultRole: SurfaceRole,
colorPreset: ColorPreset,
sceneTheme?: string,
) {
root.traverse((child) => {
const mesh = child as Partial<Mesh> & {
material?: Material | Material[]
userData: Record<string, unknown>
}
if (!('material' in mesh) || !mesh.material) return
const role = getMeshSurfaceRole(mesh.userData.surfaceRole, defaultRole)
mesh.userData.surfaceRole = role
mesh.material = createSurfaceRoleMaterial(
role,
colorPreset,
getMaterialSide(mesh.material),
sceneTheme,
)
})
}
function getMeshSurfaceRole(value: unknown, fallback: SurfaceRole): SurfaceRole {
return typeof value === 'string' && isSurfaceRole(value) ? value : fallback
}
function isSurfaceRole(value: string): value is SurfaceRole {
return (
value === 'wall' ||
value === 'floor' ||
value === 'ceiling' ||
value === 'roof' ||
value === 'joinery' ||
value === 'glazing' ||
value === 'furnishing'
)
}
function getMaterialSide(material: Material | Material[]): Material['side'] {
const source = Array.isArray(material) ? material[0] : material
return source?.side ?? FrontSide
}
function isCachedMaterial(value: unknown): boolean {
return Boolean(
(value as { userData?: { __pascalCachedMaterial?: boolean } } | null)?.userData
?.__pascalCachedMaterial,
)
}
export default GeometrySystem
@@ -1,6 +1,16 @@
import { getEffectiveRoofSurfaceMaterial, type RoofNode, type RoofSegmentNode } from '@pascal-app/core'
import * as THREE from 'three'
import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials'
import {
getEffectiveRoofSurfaceMaterial,
type RoofNode,
type RoofSegmentNode,
} from '@pascal-app/core'
import type * as THREE from 'three'
import {
type ColorPreset,
createMaterial,
createMaterialFromPresetRef,
createSurfaceRoleMaterial,
type RenderShading,
} from '../../lib/materials'
export type RoofMaterialArray = [THREE.Material, THREE.Material, THREE.Material, THREE.Material]
@@ -18,24 +28,35 @@ function getSurfaceMaterialSignature(
function createResolvedMaterial(
material: RoofNode['material'] | RoofSegmentNode['material'] | undefined,
materialPreset: string | undefined,
shading: RenderShading,
): THREE.Material | null {
if (materialPreset) {
return createMaterialFromPresetRef(materialPreset)
return createMaterialFromPresetRef(materialPreset, shading)
}
if (material) {
return createMaterial(material)
return createMaterial(material, shading)
}
return null
}
export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null {
export function getRoofMaterialArray(
node: RoofNode,
shading: RenderShading = 'rendered',
textures = true,
colorPreset: ColorPreset = 'clay',
sceneTheme?: string,
): RoofMaterialArray | null {
const top = getEffectiveRoofSurfaceMaterial(node, 'top')
const edge = getEffectiveRoofSurfaceMaterial(node, 'edge')
const wall = getEffectiveRoofSurfaceMaterial(node, 'wall')
const cacheKey = JSON.stringify({
shading,
textures,
colorPreset,
sceneTheme,
top: getSurfaceMaterialSignature(top),
edge: getSurfaceMaterialSignature(edge),
wall: getSurfaceMaterialSignature(wall),
@@ -44,19 +65,37 @@ export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null {
const cached = roofMaterialArrayCache.get(cacheKey)
if (cached) return cached
const topMaterial = createResolvedMaterial(top.material, top.materialPreset)
const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset)
const wallMaterial = createResolvedMaterial(wall.material, wall.materialPreset)
// Themed role colours: roof top/edge use the 'roof' role, the soffit/underside
// uses 'ceiling'. These also fill any untextured slot so an untextured roof is
// theme-coloured regardless of the textures toggle (no more white default).
const roofMaterial = createSurfaceRoleMaterial('roof', colorPreset, undefined, sceneTheme)
const ceilingMaterial = createSurfaceRoleMaterial('ceiling', colorPreset, undefined, sceneTheme)
const roleArray: RoofMaterialArray = [
roofMaterial,
ceilingMaterial,
ceilingMaterial,
roofMaterial,
]
if (!textures) {
roofMaterialArrayCache.set(cacheKey, roleArray)
return roleArray
}
const topMaterial = createResolvedMaterial(top.material, top.materialPreset, shading)
const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset, shading)
const wallMaterial = createResolvedMaterial(wall.material, wall.materialPreset, shading)
if (!(topMaterial || edgeMaterial || wallMaterial)) {
return null
roofMaterialArrayCache.set(cacheKey, roleArray)
return roleArray
}
const materialArray: RoofMaterialArray = [
edgeMaterial ?? wallMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
topMaterial ?? wallMaterial ?? edgeMaterial ?? new THREE.MeshStandardMaterial(),
edgeMaterial ?? wallMaterial ?? topMaterial ?? roofMaterial,
wallMaterial ?? edgeMaterial ?? topMaterial ?? ceilingMaterial,
wallMaterial ?? edgeMaterial ?? topMaterial ?? ceilingMaterial,
topMaterial ?? wallMaterial ?? edgeMaterial ?? roofMaterial,
]
roofMaterialArrayCache.set(cacheKey, materialArray)
@@ -63,6 +63,7 @@ const _surfaceFaceNormal = new THREE.Vector3()
// Pending merged-roof updates carried across frames (for throttling)
const pendingRoofUpdates = new Set<AnyNodeId>()
const warnedMergedRoofNaNIds = new Set<AnyNodeId>()
const MAX_ROOFS_PER_FRAME = 1
const MAX_SEGMENTS_PER_FRAME = 3
@@ -79,6 +80,7 @@ export const RoofSystem = () => {
// Clear stale pending updates when the scene is unloaded
if (rootNodeIds.length === 0) {
pendingRoofUpdates.clear()
warnedMergedRoofNaNIds.clear()
return
}
@@ -103,9 +105,7 @@ export const RoofSystem = () => {
const def = nodeRegistry.get(node.type)
if (def?.capabilities?.roofAccessory) {
const segId = (node as { roofSegmentId?: string }).roofSegmentId
const seg = segId
? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined)
: undefined
const seg = segId ? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined) : undefined
if (seg?.parentId) {
pendingRoofUpdates.add(seg.parentId as AnyNodeId)
}
@@ -367,6 +367,22 @@ function updateMergedRoofGeometry(
const combined = csgEvaluator.evaluate(shinDeck, finalWallTrimmed, ADDITION)
const resultGeo = csgGeometry(combined)
if (geometryHasNaNPositions(resultGeo)) {
if (!warnedMergedRoofNaNIds.has(roofNode.id)) {
console.warn('[RoofSystem] Skipping merged roof geometry with NaN positions', roofNode.id)
warnedMergedRoofNaNIds.add(roofNode.id)
}
resultGeo.dispose()
finalShinTrimmed.geometry.dispose()
finalDeckTrimmed.geometry.dispose()
finalWallTrimmed.geometry.dispose()
shinDeck.geometry.dispose()
totalShinSlab.geometry.dispose()
totalDeckSlab.geometry.dispose()
totalWall.geometry.dispose()
totalInner.geometry.dispose()
return
}
const resultMaterials = csgMaterials(combined)
@@ -401,6 +417,17 @@ function updateMergedRoofGeometry(
}
}
function geometryHasNaNPositions(geometry: THREE.BufferGeometry) {
const position = geometry.getAttribute('position')
if (!position) return false
for (let i = 0; i < position.array.length; i++) {
if (Number.isNaN(position.array[i])) return true
}
return false
}
/**
* Four dummy materials used as identity placeholders during CSG. Shared
* across every input brush so three-bvh-csg can preserve reference
@@ -448,8 +475,10 @@ export function mapRoofGroupMaterialIndex(
// clones the dummyMats refs) makes every group collapse to slot 0
// (Wall) — which is the "shape is there but the wrong colour"
// symptom roofs show after deselect / refresh.
return ((groupMaterialIndex % ROOF_MATERIAL_SLOT_COUNT) + ROOF_MATERIAL_SLOT_COUNT) %
return (
((groupMaterialIndex % ROOF_MATERIAL_SLOT_COUNT) + ROOF_MATERIAL_SLOT_COUNT) %
ROOF_MATERIAL_SLOT_COUNT
)
}
function normalizeRoofMaterialIndex(materialIndex: number | undefined): number {
@@ -502,7 +531,7 @@ export function getRoofSegmentBrushes(
const dV = Math.max(0.01, depth + 2 * wExt)
const autoDrop = wExt * tanTheta
const whV = wallHeight - autoDrop + vOffset
const whV = Math.max(0.01, wallHeight - autoDrop + vOffset)
let rhV = activeRh
if (activeRh > 0) {
@@ -1416,4 +1445,3 @@ export function getRoofOuterSurfaceFrameAtPoint(
return { point: bestPoint, normal: bestNormal }
}
@@ -5,9 +5,12 @@ import {
} from '@pascal-app/core'
import type * as THREE from 'three'
import {
type ColorPreset,
createMaterial,
createMaterialFromPresetRef,
createSurfaceRoleMaterial,
DEFAULT_STAIR_MATERIAL,
type RenderShading,
} from '../../lib/materials'
export type StairBodyMaterials = [THREE.Material, THREE.Material]
@@ -27,22 +30,37 @@ function getSurfaceMaterialSignature(
function createResolvedMaterial(
material: StairNode['material'] | StairSegmentNode['material'] | undefined,
materialPreset: string | undefined,
shading: RenderShading,
textures: boolean,
colorPreset: ColorPreset,
): THREE.Material {
if (!textures) {
return createSurfaceRoleMaterial('joinery', colorPreset)
}
if (materialPreset) {
return createMaterialFromPresetRef(materialPreset) ?? DEFAULT_STAIR_MATERIAL
return createMaterialFromPresetRef(materialPreset, shading) ?? DEFAULT_STAIR_MATERIAL(shading)
}
if (material) {
return createMaterial(material)
return createMaterial(material, shading)
}
return DEFAULT_STAIR_MATERIAL
return DEFAULT_STAIR_MATERIAL(shading)
}
export function getStairBodyMaterials(stair: StairNode): StairBodyMaterials {
export function getStairBodyMaterials(
stair: StairNode,
shading: RenderShading = 'rendered',
textures = true,
colorPreset: ColorPreset = 'clay',
): StairBodyMaterials {
const tread = getEffectiveStairSurfaceMaterial(stair, 'tread')
const side = getEffectiveStairSurfaceMaterial(stair, 'side')
const cacheKey = JSON.stringify({
shading,
textures,
colorPreset,
tread: getSurfaceMaterialSignature(tread),
side: getSurfaceMaterialSignature(side),
})
@@ -51,21 +69,37 @@ export function getStairBodyMaterials(stair: StairNode): StairBodyMaterials {
if (cached) return cached
const materials: StairBodyMaterials = [
createResolvedMaterial(tread.material, tread.materialPreset),
createResolvedMaterial(side.material, side.materialPreset),
createResolvedMaterial(tread.material, tread.materialPreset, shading, textures, colorPreset),
createResolvedMaterial(side.material, side.materialPreset, shading, textures, colorPreset),
]
stairBodyMaterialCache.set(cacheKey, materials)
return materials
}
export function getStairRailingMaterial(stair: StairNode): THREE.Material {
export function getStairRailingMaterial(
stair: StairNode,
shading: RenderShading = 'rendered',
textures = true,
colorPreset: ColorPreset = 'clay',
): THREE.Material {
const railing = getEffectiveStairSurfaceMaterial(stair, 'railing')
const cacheKey = getSurfaceMaterialSignature(railing)
const cacheKey = JSON.stringify({
shading,
textures,
colorPreset,
railing: getSurfaceMaterialSignature(railing),
})
const cached = stairRailingMaterialCache.get(cacheKey)
if (cached) return cached
const material = createResolvedMaterial(railing.material, railing.materialPreset)
const material = createResolvedMaterial(
railing.material,
railing.materialPreset,
shading,
textures,
colorPreset,
)
stairRailingMaterialCache.set(cacheKey, material)
return material
}
@@ -73,15 +107,29 @@ export function getStairRailingMaterial(stair: StairNode): THREE.Material {
export function getStraightStairSegmentBodyMaterials(
segment: StairSegmentNode,
parentNode?: StairNode,
shading: RenderShading = 'rendered',
textures = true,
colorPreset: ColorPreset = 'clay',
): StairBodyMaterials {
if (segment.material !== undefined || typeof segment.materialPreset === 'string') {
const override = createResolvedMaterial(segment.material, segment.materialPreset)
const override = createResolvedMaterial(
segment.material,
segment.materialPreset,
shading,
textures,
colorPreset,
)
return [override, override]
}
if (parentNode) {
return getStairBodyMaterials(parentNode)
return getStairBodyMaterials(parentNode, shading, textures, colorPreset)
}
return [DEFAULT_STAIR_MATERIAL, DEFAULT_STAIR_MATERIAL]
if (!textures) {
const material = createSurfaceRoleMaterial('joinery', colorPreset)
return [material, material]
}
return [DEFAULT_STAIR_MATERIAL(shading), DEFAULT_STAIR_MATERIAL(shading)]
}
@@ -41,11 +41,19 @@ export const WallCutout = () => {
const lastCameraTarget = useRef(new Vector3())
const lastUpdateTime = useRef(0)
const lastWallMode = useRef<string>(useViewer.getState().wallMode)
const lastShading = useRef(useViewer.getState().shading)
const lastNumberOfWalls = useRef(0)
const lastHighlightKey = useRef('')
const lastTextures = useRef(useViewer.getState().textures)
const lastColorPreset = useRef(useViewer.getState().colorPreset)
const lastSceneTheme = useRef(useViewer.getState().sceneTheme)
useFrame(({ camera, clock }) => {
const wallMode = useViewer.getState().wallMode
const shading = useViewer.getState().shading
const textures = useViewer.getState().textures
const colorPreset = useViewer.getState().colorPreset
const sceneTheme = useViewer.getState().sceneTheme
const selectedIds = useViewer.getState().selection.selectedIds
const previewSelectedIds = useViewer.getState().previewSelectedIds
const hoveredId = useViewer.getState().hoveredId
@@ -74,6 +82,10 @@ export const WallCutout = () => {
if (
((distanceMoved > 0.5 || directionChanged > 0.3) && timeSinceUpdate > 0.1) ||
lastWallMode.current !== wallMode ||
lastShading.current !== shading ||
lastTextures.current !== textures ||
lastColorPreset.current !== colorPreset ||
lastSceneTheme.current !== sceneTheme ||
sceneRegistry.byType.wall!.size !== lastNumberOfWalls.current ||
lastHighlightKey.current !== highlightKey
) {
@@ -92,7 +104,7 @@ export const WallCutout = () => {
const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u)
const isDeleteHighlighted = deleteHoveredWallId === wallId
const isSelectionHighlighted = !isDeleteHighlighted && highlightedWallIds.has(wallId)
const materials = getMaterialsForWall(wallNode)
const materials = getMaterialsForWall(wallNode, shading, textures, colorPreset, sceneTheme)
if (hideWall) {
;(wallMesh as Mesh).material = isDeleteHighlighted
@@ -109,6 +121,10 @@ export const WallCutout = () => {
}
})
lastWallMode.current = wallMode
lastShading.current = shading
lastTextures.current = textures
lastColorPreset.current = colorPreset
lastSceneTheme.current = sceneTheme
lastNumberOfWalls.current = sceneRegistry.byType.wall!.size
lastHighlightKey.current = highlightKey
}
@@ -123,7 +139,13 @@ export const WallCutout = () => {
if (!wallMesh) return
const wallNode = useScene.getState().nodes[wallId as AnyNodeId] as WallNode | undefined
if (!wallNode || wallNode.type !== 'wall') return
const mats = getMaterialsForWall(wallNode)
const mats = getMaterialsForWall(
wallNode,
useViewer.getState().shading,
useViewer.getState().textures,
useViewer.getState().colorPreset,
useViewer.getState().sceneTheme,
)
const current = wallMesh.material as Material | Material[]
snapshot.set(wallMesh, current)
if (current === mats.highlightedVisible || current === mats.deleteVisible) {
@@ -8,8 +8,16 @@ import {
} from '@pascal-app/core'
import { Color, type Material } from 'three'
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
import { MeshStandardNodeMaterial } from 'three/webgpu'
import { baseMaterial, createMaterial, createMaterialFromPresetRef } from '../../lib/materials'
import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu'
import {
baseMaterial,
type ColorPreset,
createMaterial,
createMaterialFromPresetRef,
createSurfaceRoleMaterial,
type RenderShading,
resolveSurfaceColor,
} from '../../lib/materials'
const DEFAULT_WALL_COLOR = '#f2f0ed'
@@ -61,16 +69,23 @@ const dotPattern = Fn(() => {
return dots.mul(yFade)
})
function getSurfaceVisibleMaterial(spec: WallSurfaceMaterialSpec): Material {
function getSurfaceVisibleMaterial(
spec: WallSurfaceMaterialSpec,
shading: RenderShading,
): Material {
if (spec.materialPreset) {
return createMaterialFromPresetRef(spec.materialPreset) ?? baseMaterial
return createMaterialFromPresetRef(spec.materialPreset, shading) ?? baseMaterial(shading)
}
if (spec.material) {
return createMaterial(spec.material)
return createMaterial(spec.material, shading)
}
return baseMaterial
return baseMaterial(shading)
}
function hasExplicitMaterial(spec: WallSurfaceMaterialSpec): boolean {
return Boolean(spec.materialPreset || spec.material)
}
function getSurfaceColor(spec: WallSurfaceMaterialSpec, fallback = DEFAULT_WALL_COLOR): string {
@@ -119,14 +134,24 @@ function createHighlightedWallMaterial(material: Material, kind: WallHighlightKi
return highlightedMaterial
}
function createInvisibleWallMaterial(color: string): MeshStandardNodeMaterial {
return new MeshStandardNodeMaterial({
transparent: true,
opacityNode: mix(float(0.0), float(0.24), dotPattern()),
color,
depthWrite: false,
emissive: color,
})
function createInvisibleWallMaterial(color: string, shading: RenderShading): Material {
const material =
shading === 'solid'
? new MeshLambertNodeMaterial({
transparent: true,
color,
depthWrite: false,
emissive: color,
})
: new MeshStandardNodeMaterial({
transparent: true,
color,
depthWrite: false,
emissive: color,
})
material.opacityNode = mix(float(0.0), float(0.24), dotPattern())
return material
}
function mapWallMaterialArray(
@@ -148,8 +173,9 @@ function disposeOwnedMaterials(materials: WallMaterialArray[]) {
})
}
export function getWallMaterialHash(wallNode: WallNode): string {
export function getWallMaterialHash(wallNode: WallNode, shading: RenderShading): string {
return JSON.stringify({
shading,
interior: getWallSurfaceMaterialSignature(
getEffectiveWallSurfaceMaterial(wallNode, 'interior'),
),
@@ -159,9 +185,17 @@ export function getWallMaterialHash(wallNode: WallNode): string {
})
}
export function getMaterialsForWall(wallNode: WallNode): WallMaterials {
const cacheKey = wallNode.id
const materialHash = getWallMaterialHash(wallNode)
export function getMaterialsForWall(
wallNode: WallNode,
shading: RenderShading = 'rendered',
textures = true,
colorPreset: ColorPreset = 'clay',
sceneTheme?: string,
): WallMaterials {
const cacheKey = `${wallNode.id}-${shading}-${textures}-${colorPreset}-${sceneTheme ?? 'base'}`
const materialHash = textures
? getWallMaterialHash(wallNode, shading)
: JSON.stringify({ textures, colorPreset, sceneTheme })
const existing = wallMaterialCache.get(cacheKey)
if (existing && existing.materialHash === materialHash) {
@@ -180,17 +214,33 @@ export function getMaterialsForWall(wallNode: WallNode): WallMaterials {
const interiorSpec = getEffectiveWallSurfaceMaterial(wallNode, 'interior')
const exteriorSpec = getEffectiveWallSurfaceMaterial(wallNode, 'exterior')
const wallRoleMaterial = createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme)
const visible: WallMaterialArray = [
baseMaterial,
getSurfaceVisibleMaterial(interiorSpec),
getSurfaceVisibleMaterial(exteriorSpec),
]
// Untextured surfaces take the themed wall role colour even with textures on;
// only surfaces with an explicit preset/material keep their texture.
const visible: WallMaterialArray = textures
? [
wallRoleMaterial,
hasExplicitMaterial(interiorSpec)
? getSurfaceVisibleMaterial(interiorSpec, shading)
: wallRoleMaterial,
hasExplicitMaterial(exteriorSpec)
? getSurfaceVisibleMaterial(exteriorSpec, shading)
: wallRoleMaterial,
]
: [wallRoleMaterial, wallRoleMaterial, wallRoleMaterial]
const wallRoleColor = resolveSurfaceColor('wall', colorPreset, sceneTheme)
const invisible: WallMaterialArray = [
createInvisibleWallMaterial(DEFAULT_WALL_COLOR),
createInvisibleWallMaterial(getSurfaceColor(interiorSpec, DEFAULT_WALL_COLOR)),
createInvisibleWallMaterial(getSurfaceColor(exteriorSpec, DEFAULT_WALL_COLOR)),
createInvisibleWallMaterial(wallRoleColor, textures ? shading : 'solid'),
createInvisibleWallMaterial(
textures ? getSurfaceColor(interiorSpec, wallRoleColor) : wallRoleColor,
textures ? shading : 'solid',
),
createInvisibleWallMaterial(
textures ? getSurfaceColor(exteriorSpec, wallRoleColor) : wallRoleColor,
textures ? shading : 'solid',
),
]
const highlightedVisible = mapWallMaterialArray(visible, (material) =>
@@ -220,6 +270,12 @@ export function getMaterialsForWall(wallNode: WallNode): WallMaterials {
return result
}
export function getVisibleWallMaterials(wallNode: WallNode): WallMaterialArray {
return getMaterialsForWall(wallNode).visible
export function getVisibleWallMaterials(
wallNode: WallNode,
shading: RenderShading = 'rendered',
textures = true,
colorPreset: ColorPreset = 'clay',
sceneTheme?: string,
): WallMaterialArray {
return getMaterialsForWall(wallNode, shading, textures, colorPreset, sceneTheme).visible
}
@@ -6,11 +6,19 @@ import {
type WindowNode,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { useEffect } from 'react'
import * as THREE from 'three'
import { baseMaterial, glassMaterial } from '../../lib/materials'
import {
createSurfaceRoleMaterial,
glassMaterial as defaultGlassMaterial,
baseMaterial as getBaseMaterial,
} from '../../lib/materials'
import useViewer from '../../store/use-viewer'
// Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
let baseMaterial = getBaseMaterial()
let glassMaterial: THREE.Material = defaultGlassMaterial
export const CASEMENT_WINDOW_SASH_NAME = 'casement-window-sash'
export const FRENCH_CASEMENT_LEFT_SASH_NAME = 'french-casement-left-sash'
export const FRENCH_CASEMENT_RIGHT_SASH_NAME = 'french-casement-right-sash'
@@ -25,9 +33,34 @@ export const HOPPER_WINDOW_SASH_NAME = 'hopper-window-sash'
export const WindowSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty)
const shading = useViewer((state) => state.shading)
const textures = useViewer((state) => state.textures)
const colorPreset = useViewer((state) => state.colorPreset)
baseMaterial = textures
? getBaseMaterial(shading)
: createSurfaceRoleMaterial('joinery', colorPreset)
glassMaterial = textures
? defaultGlassMaterial
: createSurfaceRoleMaterial('glazing', colorPreset)
useEffect(() => {
const nodes = useScene.getState().nodes
for (const node of Object.values(nodes)) {
if (node?.type === 'window') {
useScene.getState().dirtyNodes.add(node.id as AnyNodeId)
}
}
}, [shading, textures, colorPreset])
useFrame(() => {
if (dirtyNodes.size === 0) return
baseMaterial = textures
? getBaseMaterial(shading)
: createSurfaceRoleMaterial('joinery', colorPreset)
glassMaterial = textures
? defaultGlassMaterial
: createSurfaceRoleMaterial('glazing', colorPreset)
const nodes = useScene.getState().nodes