From 30b9e3ccb2fcad96d6a7bb5669070342244e47b6 Mon Sep 17 00:00:00 2001 From: wass08 Date: Tue, 10 Mar 2026 18:08:27 +0100 Subject: [PATCH 1/5] move zones to separate layers todo: fix hardcoded 2 and zone floor --- .../editor/custom-camera-controls.tsx | 1 + .../renderers/zone/zone-renderer.tsx | 4 +- .../viewer/src/components/viewer/index.tsx | 2 +- .../src/components/viewer/post-processing.tsx | 61 +++++++++++++++++-- 4 files changed, 60 insertions(+), 8 deletions(-) diff --git a/apps/editor/components/editor/custom-camera-controls.tsx b/apps/editor/components/editor/custom-camera-controls.tsx index 2c3314cd..ac16b411 100644 --- a/apps/editor/components/editor/custom-camera-controls.tsx +++ b/apps/editor/components/editor/custom-camera-controls.tsx @@ -26,6 +26,7 @@ export const CustomCameraControls = () => { useEffect(() => { camera.layers.enable(EDITOR_LAYER) raycaster.layers.enable(EDITOR_LAYER) + raycaster.layers.enable(2) }, [camera, raycaster]) useEffect(() => { diff --git a/packages/viewer/src/components/renderers/zone/zone-renderer.tsx b/packages/viewer/src/components/renderers/zone/zone-renderer.tsx index 8719d848..4bebc85b 100644 --- a/packages/viewer/src/components/renderers/zone/zone-renderer.tsx +++ b/packages/viewer/src/components/renderers/zone/zone-renderer.tsx @@ -28,7 +28,7 @@ const createWallGradientMaterial = (zoneColor: string) => { colorNode: baseColor, opacityNode: finalOpacity, side: DoubleSide, - depthWrite: false, + depthWrite: true, depthTest: false, userData: { uOpacity: opacity, @@ -244,7 +244,7 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => { {/* Wall borders with gradient */} - + ) } diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index ebbae641..a0a81d58 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -84,7 +84,7 @@ const Viewer: React.FC = ({ children, selectionManager = 'default' }} camera={{ position: [50, 50, 50], fov: 50 }} > - + {/* */} diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 42a335eb..5bb2d711 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -1,6 +1,6 @@ import { useFrame, useThree } from '@react-three/fiber' import { useEffect, useRef, useState } from 'react' -import { Color, UnsignedByteType } from 'three' +import { Color, Layers, UnsignedByteType } from 'three' import { outline } from 'three/addons/tsl/display/OutlineNode.js' import { ssgi } from 'three/addons/tsl/display/SSGINode.js' import { traa } from 'three/addons/tsl/display/TRAANode.js' @@ -9,6 +9,8 @@ import { colorToDirection, diffuseColor, directionToColor, + float, + mix, mrt, normalView, oscSine, @@ -40,12 +42,24 @@ export const SSGI_PARAMS = { useTemporalFiltering: true, } +const DARK_BG = '#1f2433' +const LIGHT_BG = '#ffffff' + const PostProcessingPasses = () => { const { gl: renderer, scene, camera } = useThree() const renderPipelineRef = useRef(null) const hasPipelineErrorRef = useRef(false) const [isInitialized, setIsInitialized] = useState(false) + const theme = useViewer((s) => s.theme) + + // 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 = theme === 'dark' ? DARK_BG : LIGHT_BG + const bgUniform = useRef(uniform(new Color(initBg))) + const bgCurrent = useRef(new Color(initBg)) + const bgTarget = useRef(new Color()) + useEffect(() => { let mounted = true @@ -111,6 +125,18 @@ const PostProcessingPasses = () => { return colorToDirection(scenePassNormal.sample(uv)) }) + // camera.layers.disable(0) + // camera.layers.disable(1) + // camera.layers.enable(2) // Enable layer 2 for zone rendering + const zonePass = pass(scene, camera) + // camera.layers.enable(0) // Disable layer 2 for main scene rendering + // camera.layers.enable(1) // Disable layer 2 for main scene rendering + // camera.layers.disable(2) // Disable layer 2 for main scene rendering + + const layers: Layers = new Layers() + layers.enable(2) // Enable layer 2 for zone rendering + layers.disable(0) + zonePass.setLayers(layers) // SSGI Pass (cast to PerspectiveCamera for SSGI) const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, camera as any) @@ -130,10 +156,17 @@ const PostProcessingPasses = () => { const gi = giPass.rgb const ao = giPass.a + // Background detection via alpha: renderer clears with alpha=0 (setClearAlpha(0) in useFrame), + // so background pixels have scenePassColor.a=0 while geometry pixels have output.a=1. + // WebGPU only applies clearColorValue to MRT attachment 0 (output), so scenePassColor.a + // is the reliable geometry mask — no normals, no flicker. + const hasGeometry = scenePassColor.a + const contentAlpha = hasGeometry.max(zonePass.a) + // Composite: scene * AO + diffuse * GI const compositePass = vec4( - add(scenePassColor.rgb.mul(ao), scenePassDiffuse.rgb.mul(gi)), - scenePassColor.a, + add(scenePassColor.rgb.mul(ao), add(zonePass.rgb, scenePassDiffuse.rgb.mul(gi))), + contentAlpha, ) function generateSelectedOutlinePass() { @@ -194,7 +227,17 @@ const PostProcessingPasses = () => { : vec4(add(scenePassColor.rgb, selectedOutlinePass.add(hoverOutlinePass)), scenePassColor.a) // TRAA (Temporal Reprojection Anti-Aliasing) - applied AFTER combining everything - const finalOutput = traa(compositeWithOutlines, scenePassDepth, scenePassVelocity, camera) + const traaOutput = traa(compositeWithOutlines, scenePassDepth, scenePassVelocity, camera) + + // For zone-over-background pixels, scenePassDepth=1.0 (no scene geometry) causes TRAA + // to output black. Use hasGeometry to blend: geometry pixels use traaRgb, all others + // (zones over background, pure background) use compositePass.rgb directly. + const traaRgb = (traaOutput as any).rgb + const colorSource = mix(compositePass.rgb, traaRgb, hasGeometry) + const finalOutput = vec4( + mix(bgUniform.current, colorSource, contentAlpha), + float(1), + ) const renderPipeline = new RenderPipeline(renderer as unknown as WebGPURenderer) renderPipeline.outputNode = finalOutput @@ -219,12 +262,20 @@ const PostProcessingPasses = () => { } }, [renderer, scene, camera, isInitialized]) - useFrame(() => { + useFrame((_, delta) => { + // Animate background colour toward the current theme target (same lerp as AnimatedBackground) + bgTarget.current.set(theme === 'dark' ? DARK_BG : LIGHT_BG) + bgCurrent.current.lerp(bgTarget.current, Math.min(delta, 0.1) * 4) + bgUniform.current.value.copy(bgCurrent.current) + if (hasPipelineErrorRef.current || !renderPipelineRef.current) { return } try { + // Clear alpha=0 so background pixels in the output MRT attachment (index 0) get a=0, + // making scenePassColor.a a reliable geometry mask (geometry pixels write a=1 via output node). + ;(renderer as any).setClearAlpha(0) renderPipelineRef.current.render() } catch (error) { hasPipelineErrorRef.current = true From 918ec46a93c3edb0677c871188e84b30422ed480 Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 11 Mar 2026 07:27:52 +0100 Subject: [PATCH 2/5] bring back ground occluder --- .../renderers/zone/zone-renderer.tsx | 1 + .../src/components/viewer/ground-occluder.tsx | 18 +++++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/viewer/src/components/renderers/zone/zone-renderer.tsx b/packages/viewer/src/components/renderers/zone/zone-renderer.tsx index 4bebc85b..a0556c32 100644 --- a/packages/viewer/src/components/renderers/zone/zone-renderer.tsx +++ b/packages/viewer/src/components/renderers/zone/zone-renderer.tsx @@ -239,6 +239,7 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => { rotation={[-Math.PI / 2, 0, 0]} material={floorMaterial} name="floor" + layers={2} > diff --git a/packages/viewer/src/components/viewer/ground-occluder.tsx b/packages/viewer/src/components/viewer/ground-occluder.tsx index 41a54b67..4df8726b 100644 --- a/packages/viewer/src/components/viewer/ground-occluder.tsx +++ b/packages/viewer/src/components/viewer/ground-occluder.tsx @@ -1,13 +1,13 @@ import { useScene } from '@pascal-app/core' +import polygonClipping from 'polygon-clipping' import { useMemo } from 'react' import * as THREE from 'three' import useViewer from '../../store/use-viewer' -import polygonClipping from 'polygon-clipping' export const GroundOccluder = () => { const theme = useViewer((state) => state.theme) const bgColor = theme === 'dark' ? '#1f2433' : '#fafafa' - + const nodes = useScene((state) => state.nodes) const shape = useMemo(() => { @@ -22,17 +22,17 @@ export const GroundOccluder = () => { // Collect all polygons for slabs and zones const polygons: [number, number][][] = [] - + Object.values(nodes).forEach((node) => { - if ((node.type === 'slab' || node.type === 'zone') && node.polygon && node.polygon.length >= 3) { + if (node.type === 'slab' && node.polygon && node.polygon.length >= 3) { polygons.push(node.polygon as [number, number][]) } }) if (polygons.length > 0) { // Format for polygon-clipping: [[[x, y], [x, y], ...]] - const multiPolygons = polygons.map(pts => { - const ring = pts.map(p => [p[0], -p[1]] as [number, number]) // Negate Y (which was Z) + const multiPolygons = polygons.map((pts) => { + const ring = pts.map((p) => [p[0], -p[1]] as [number, number]) // Negate Y (which was Z) return [ring] }) @@ -45,7 +45,7 @@ export const GroundOccluder = () => { if (geom.length > 0) { const ring = geom[0]! const hole = new THREE.Path() - + if (ring.length > 0) { hole.moveTo(ring[0]![0], ring[0]![1]) for (let i = 1; i < ring.length; i++) { @@ -64,8 +64,8 @@ export const GroundOccluder = () => { return ( - Date: Wed, 11 Mar 2026 07:35:24 +0100 Subject: [PATCH 3/5] clean zone handling code --- .../renderers/zone/zone-renderer.tsx | 5 ++-- .../src/components/viewer/post-processing.tsx | 30 ++++++++----------- packages/viewer/src/index.ts | 1 + packages/viewer/src/lib/layers.ts | 5 ++++ 4 files changed, 22 insertions(+), 19 deletions(-) create mode 100644 packages/viewer/src/lib/layers.ts diff --git a/packages/viewer/src/components/renderers/zone/zone-renderer.tsx b/packages/viewer/src/components/renderers/zone/zone-renderer.tsx index a0556c32..abb14a9b 100644 --- a/packages/viewer/src/components/renderers/zone/zone-renderer.tsx +++ b/packages/viewer/src/components/renderers/zone/zone-renderer.tsx @@ -5,6 +5,7 @@ import { BufferGeometry, Color, DoubleSide, Float32BufferAttribute, type Group, import { color, float, uniform, uv } from 'three/tsl' import { MeshBasicNodeMaterial } from 'three/webgpu' import { useNodeEvents } from '../../../hooks/use-node-events' +import { ZONE_LAYER } from '../../../lib/layers' const Y_OFFSET = 0.01 const WALL_HEIGHT = 2.3 @@ -239,13 +240,13 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => { rotation={[-Math.PI / 2, 0, 0]} material={floorMaterial} name="floor" - layers={2} + layers={ZONE_LAYER} > {/* Wall borders with gradient */} - + ) } diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 5bb2d711..9539ed57 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -1,5 +1,5 @@ import { useFrame, useThree } from '@react-three/fiber' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Color, Layers, UnsignedByteType } from 'three' import { outline } from 'three/addons/tsl/display/OutlineNode.js' import { ssgi } from 'three/addons/tsl/display/SSGINode.js' @@ -25,6 +25,7 @@ import { import { RenderPipeline, type WebGPURenderer } from 'three/webgpu' import useViewer from '../../store/use-viewer' +import { SCENE_LAYER, ZONE_LAYER } from '../../lib/layers' // SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion export const SSGI_PARAMS = { @@ -51,15 +52,20 @@ const PostProcessingPasses = () => { const hasPipelineErrorRef = useRef(false) const [isInitialized, setIsInitialized] = useState(false) - const theme = useViewer((s) => s.theme) - // 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 = theme === 'dark' ? DARK_BG : LIGHT_BG + const initBg = useViewer.getState().theme === 'dark' ? DARK_BG : LIGHT_BG const bgUniform = useRef(uniform(new Color(initBg))) const bgCurrent = useRef(new Color(initBg)) const bgTarget = useRef(new Color()) + const zoneLayers = useMemo(() => { + const l = new Layers() + l.enable(ZONE_LAYER) + l.disable(SCENE_LAYER) + return l + }, []) + useEffect(() => { let mounted = true @@ -125,18 +131,8 @@ const PostProcessingPasses = () => { return colorToDirection(scenePassNormal.sample(uv)) }) - // camera.layers.disable(0) - // camera.layers.disable(1) - // camera.layers.enable(2) // Enable layer 2 for zone rendering const zonePass = pass(scene, camera) - // camera.layers.enable(0) // Disable layer 2 for main scene rendering - // camera.layers.enable(1) // Disable layer 2 for main scene rendering - // camera.layers.disable(2) // Disable layer 2 for main scene rendering - - const layers: Layers = new Layers() - layers.enable(2) // Enable layer 2 for zone rendering - layers.disable(0) - zonePass.setLayers(layers) + zonePass.setLayers(zoneLayers) // SSGI Pass (cast to PerspectiveCamera for SSGI) const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, camera as any) @@ -260,11 +256,11 @@ const PostProcessingPasses = () => { } renderPipelineRef.current = null } - }, [renderer, scene, camera, isInitialized]) + }, [renderer, scene, camera, isInitialized, zoneLayers]) useFrame((_, delta) => { // Animate background colour toward the current theme target (same lerp as AnimatedBackground) - bgTarget.current.set(theme === 'dark' ? DARK_BG : LIGHT_BG) + bgTarget.current.set(useViewer.getState().theme === 'dark' ? DARK_BG : LIGHT_BG) bgCurrent.current.lerp(bgTarget.current, Math.min(delta, 0.1) * 4) bgUniform.current.value.copy(bgCurrent.current) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index d97a508c..e7e4f714 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -1,5 +1,6 @@ export { default as Viewer } from './components/viewer' export { default as useViewer } from './store/use-viewer' export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' +export { SCENE_LAYER, ZONE_LAYER } from './lib/layers' export { InteractiveSystem } from './systems/interactive/interactive-system' export { snapLevelsToTruePositions } from './systems/level/level-utils' \ No newline at end of file diff --git a/packages/viewer/src/lib/layers.ts b/packages/viewer/src/lib/layers.ts new file mode 100644 index 00000000..b7aa789d --- /dev/null +++ b/packages/viewer/src/lib/layers.ts @@ -0,0 +1,5 @@ +/** Default Three.js layer for main scene geometry. */ +export const SCENE_LAYER = 0 + +/** Layer used for zone rendering (floor fills and wall borders). */ +export const ZONE_LAYER = 2 From 32406e5a20a5ee5cd627630a277c4d0b3ed9b124 Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 11 Mar 2026 07:39:45 +0100 Subject: [PATCH 4/5] enhance hover zone --- .../components/systems/zone/zone-label-editor-system.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/editor/components/systems/zone/zone-label-editor-system.tsx b/apps/editor/components/systems/zone/zone-label-editor-system.tsx index 33881f0e..ccca379a 100644 --- a/apps/editor/components/systems/zone/zone-label-editor-system.tsx +++ b/apps/editor/components/systems/zone/zone-label-editor-system.tsx @@ -29,13 +29,11 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) { const el = document.getElementById(`${zoneId}-label`) if (!el) return setLabelEl(el) - el.style.pointerEvents = 'auto' const textEl = el.children[0] as HTMLElement | undefined if (textEl) textEl.style.display = 'none' return () => { - el.style.pointerEvents = '' if (textEl) textEl.style.display = '' } }, [zoneId]) @@ -79,6 +77,7 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) { fontSize: 14, fontFamily: 'sans-serif', userSelect: 'none', + pointerEvents: 'auto', display: 'inline-flex', alignItems: 'center', gap: 4, From 8d02e5191bfc32d1d2c736c2cdfbc372aa85c795 Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 11 Mar 2026 07:45:53 +0100 Subject: [PATCH 5/5] rule for layers --- .claude/rules/layers.md | 1 + .cursor/rules/creating-rules.mdc | 3 +- .cursor/rules/layers.mdc | 57 ++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 120000 .claude/rules/layers.md create mode 100644 .cursor/rules/layers.mdc diff --git a/.claude/rules/layers.md b/.claude/rules/layers.md new file mode 120000 index 00000000..de51970d --- /dev/null +++ b/.claude/rules/layers.md @@ -0,0 +1 @@ +../../.cursor/rules/layers.mdc \ No newline at end of file diff --git a/.cursor/rules/creating-rules.mdc b/.cursor/rules/creating-rules.mdc index 4ac11580..1c3639e6 100644 --- a/.cursor/rules/creating-rules.mdc +++ b/.cursor/rules/creating-rules.mdc @@ -1,6 +1,6 @@ --- description: How to create and maintain project rules -globs: +globs: .cursor/rules/** alwaysApply: false --- @@ -76,3 +76,4 @@ Concrete guidance with examples. | `events` | Typed event bus — emitting and listening to node and grid events | | `node-schemas` | Zod schema pattern for node types, createNode, updateNode | | `spatial-queries` | Placement validation (canPlaceOnFloor/Wall/Ceiling) for tools | +| `layers` | Three.js layer constants, ownership, and rendering separation | diff --git a/.cursor/rules/layers.mdc b/.cursor/rules/layers.mdc new file mode 100644 index 00000000..74b82407 --- /dev/null +++ b/.cursor/rules/layers.mdc @@ -0,0 +1,57 @@ +--- +description: Three.js layer conventions — which layer each object type lives on and why +globs: packages/viewer/**,apps/editor/** +alwaysApply: false +--- + +# Three.js Layers + +Three.js `Layers` control which objects each camera and render pass sees. We use them to separate scene geometry, editor helpers, and zone overlays into distinct rendering buckets without duplicating scene structure. + +## Layer Map + +| Constant | Value | Package | Purpose | +|---|---|---|---| +| `SCENE_LAYER` | `0` | `@pascal-app/viewer` | Default Three.js layer — all regular scene geometry | +| `EDITOR_LAYER` | `1` | `apps/editor` | Editor-only helpers: grid, tool previews, cursor meshes, snap guides | +| `ZONE_LAYER` | `2` | `@pascal-app/viewer` | Zone floor fills and wall borders — composited in a separate post-processing pass | + +Import the constants from their owning packages: + +```ts +// In viewer code +import { SCENE_LAYER, ZONE_LAYER } from '@pascal-app/viewer' + +// In editor code +import { EDITOR_LAYER } from '@/lib/constants' +``` + +## Why Separate Zones onto Layer 2 + +Zones use semi-transparent, `depthTest: false` materials that must be composited *on top of* the scene without being fed into SSGI or TRAA. The post-processing pipeline in `post-processing.tsx` renders a dedicated `zonePass` with a `Layers` mask that enables only `ZONE_LAYER` (and disables `SCENE_LAYER`), then blends its output into the final composite manually: + +```ts +const zoneLayers = useMemo(() => { + const l = new Layers() + l.enable(ZONE_LAYER) + l.disable(SCENE_LAYER) + return l +}, []) + +zonePass.setLayers(zoneLayers) +``` + +This keeps zones out of the SSGI depth/normal buffers (which would produce incorrect AO on transparent surfaces) while still letting them appear correctly over the scene. + +## Why Separate Editor Helpers onto Layer 1 + +The editor camera enables `EDITOR_LAYER` so tools and helpers are visible during editing. The thumbnail generator disables `EDITOR_LAYER` so exports show clean geometry without snap lines or cursor spheres. + +## Rules + +- **Never hardcode layer numbers.** Always use the named constants. +- **`SCENE_LAYER` and `ZONE_LAYER` belong in `@pascal-app/viewer`** — they are renderer concerns, not editor concerns. +- **`EDITOR_LAYER` belongs in `apps/editor`** — the viewer must never import it; editor behaviour is injected via props/children. +- **Zone meshes must set `layers={ZONE_LAYER}`** so they are picked up by `zonePass` and excluded from `scenePass` depth buffers. +- **Editor helper meshes must set `layers={EDITOR_LAYER}`** so they are invisible to the thumbnail camera and the viewer's render passes. +- **Do not add new layers without updating this rule** and the post-processing pipeline accordingly.