sync: comprehensive monorepo → editor parity (2D/3D decoupling, UX polish, crash fixes)

Squash merge of 3 commits:
1. useLiveTransforms store for 2D/3D decoupling + floorplan overhaul + Sentry crash fixes
2. Comprehensive 59-file sync bringing editor to full monorepo parity (selection highlights, delete tool, furnish/zone modes, keyboard shortcuts, all panels)
3. Missing files fix (materials.ts, merged-outline-node.ts, type fix)

75 files changed, ~6K additions.
This commit is contained in:
Pascal
2026-04-07 19:21:10 -04:00
committed by GitHub
parent e8ad92592d
commit 0a46a9deb4
77 changed files with 6890 additions and 2062 deletions
@@ -14,7 +14,7 @@ const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.
const gridPattern = lineX.max(lineY)
const gridOpacity = mix(float(0.2), float(0.6), gridPattern)
function createCeilingMaterials(color: string = '#999999') {
function createCeilingMaterials(color = '#999999') {
const topMaterial = new MeshBasicNodeMaterial({
color,
transparent: true,
@@ -1,6 +1,8 @@
import {
type AnimationEffect,
type AnyNodeId,
baseMaterial,
glassMaterial,
type Interactive,
type ItemNode,
type LightEffect,
@@ -16,36 +18,18 @@ import { Suspense, useEffect, useMemo, useRef } from 'react'
import type { AnimationAction, Group, Material, Mesh } from 'three'
import { MathUtils } from 'three'
import { positionLocal, smoothstep, time } from 'three/tsl'
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
import { MeshStandardNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { resolveCdnUrl } from '../../../lib/asset-url'
import { useItemLightPool } from '../../../store/use-item-light-pool'
import { ErrorBoundary } from '../../error-boundary'
import { NodeRenderer } from '../node-renderer'
// Shared materials to avoid creating new instances for every mesh
const defaultMaterial = new MeshStandardNodeMaterial({
color: 0xff_ff_ff,
roughness: 1,
metalness: 0,
})
const glassMaterial = new MeshStandardNodeMaterial({
name: 'glass',
color: 'lightgray',
roughness: 0.8,
metalness: 0,
transparent: true,
opacity: 0.35,
side: DoubleSide,
depthWrite: false,
})
const getMaterialForOriginal = (original: Material): MeshStandardNodeMaterial => {
if (original.name.toLowerCase() === 'glass') {
return glassMaterial
}
return defaultMaterial
return baseMaterial
}
const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
@@ -145,6 +129,18 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
if (Array.isArray(mesh.material)) {
mesh.material = mesh.material.map((mat) => getMaterialForOriginal(mat))
hasGlass = mesh.material.some((mat) => mat.name === 'glass')
// Fix geometry groups that reference materialIndex beyond the material
// array length — this causes three-mesh-bvh to crash with
// "Cannot read properties of undefined (reading 'side')"
const matCount = mesh.material.length
if (mesh.geometry.groups.length > 0) {
for (const group of mesh.geometry.groups) {
if (group.materialIndex !== undefined && group.materialIndex >= matCount) {
group.materialIndex = 0
}
}
}
} else {
mesh.material = getMaterialForOriginal(mesh.material)
hasGlass = mesh.material.name === 'glass'
@@ -1,7 +1,9 @@
import { type SiteNode, useRegistry } from '@pascal-app/core'
import { type SiteNode, type SlabNode, useRegistry, useScene } from '@pascal-app/core'
import polygonClipping from 'polygon-clipping'
import { useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Group, Shape } from 'three'
import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import useViewer from '../../../store/use-viewer'
import { NodeRenderer } from '../node-renderer'
const Y_OFFSET = 0.01
@@ -29,29 +31,76 @@ const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeom
return geometry
}
type S = ReturnType<typeof useScene.getState>
export const SiteRenderer = ({ node }: { node: SiteNode }) => {
const ref = useRef<Group>(null!)
useRegistry(node.id, 'site', ref)
// Create floor shape from polygon points
const floorShape = useMemo(() => {
const theme = useViewer((state) => state.theme)
const bgColor = theme === 'dark' ? '#1f2433' : '#fafafa'
// Cache slab polygon references to keep the selector stable across unrelated store updates
const slabPolygonsCache = useRef<[number, number][][]>([])
const slabPolygons = useScene((state: S) => {
const nodeList = Object.values(state.nodes)
const levelIndexById = new Map<string, number>()
let lowestLevelIndex = Number.POSITIVE_INFINITY
nodeList.forEach((n) => {
if (n.type !== 'level') return
levelIndexById.set(n.id, n.level)
lowestLevelIndex = Math.min(lowestLevelIndex, n.level)
})
const next = nodeList
.filter((n): n is SlabNode => n.type === 'slab' && n.visible && n.polygon.length >= 3)
.filter((n) => {
if (!Number.isFinite(lowestLevelIndex)) return true
const parentLevel = n.parentId ? levelIndexById.get(n.parentId as string) : undefined
return parentLevel === lowestLevelIndex
})
.map((n) => n.polygon as [number, number][])
const prev = slabPolygonsCache.current
if (next.length === prev.length && next.every((p, i) => p === prev[i])) return prev
slabPolygonsCache.current = next
return next
})
// Ground shape: site polygon with slab footprints punched as holes
const groundShape = useMemo(() => {
if (!node?.polygon?.points || node.polygon.points.length < 3) return null
const pts = node.polygon.points
const shape = new Shape()
const firstPt = node.polygon.points[0]!
// Shape is in X-Y plane, we rotate it to X-Z plane
// Negate Y (which becomes Z) to get correct orientation
shape.moveTo(firstPt[0]!, -firstPt[1]!)
for (let i = 1; i < node.polygon.points.length; i++) {
const pt = node.polygon.points[i]!
shape.lineTo(pt[0]!, -pt[1]!)
}
shape.moveTo(pts[0]![0], -pts[0]![1])
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1])
shape.closePath()
if (slabPolygons.length > 0) {
const multiPolygons = slabPolygons.map((p) => [
p.map((pt) => [pt[0], -pt[1]] as [number, number]),
])
const unioned = polygonClipping.union(
multiPolygons[0] as polygonClipping.Polygon,
...(multiPolygons.slice(1) as polygonClipping.Polygon[]),
)
for (const geom of unioned) {
const ring = geom[0]
if (ring && ring.length > 0) {
const hole = new Path()
hole.moveTo(ring[0]![0], ring[0]![1])
for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1])
hole.closePath()
shape.holes.push(hole)
}
}
}
return shape
}, [node?.polygon?.points])
}, [node?.polygon?.points, slabPolygons])
// Create boundary line geometry
const lineGeometry = useMemo(() => {
@@ -61,7 +110,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
const handlers = useNodeEvents(node, 'site')
if (!(node && floorShape && lineGeometry)) {
if (!(node && lineGeometry)) {
return null
}
@@ -75,11 +124,19 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
/>
))}
{/* Transparent floor fill */}
<mesh position={[0, Y_OFFSET - 0.005, 0]} receiveShadow rotation={[-Math.PI / 2, 0, 0]}>
<shapeGeometry args={[floorShape]} />
<shadowMaterial opacity={0.75} transparent />
</mesh>
{/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}
{groundShape && (
<mesh position={[0, -0.05, 0]} receiveShadow rotation={[-Math.PI / 2, 0, 0]}>
<shapeGeometry args={[groundShape]} />
<meshStandardMaterial
color={bgColor}
depthWrite={true}
polygonOffset={true}
polygonOffsetFactor={1}
polygonOffsetUnits={1}
/>
</mesh>
)}
{/* Simple boundary line */}
{/* @ts-ignore */}
@@ -23,8 +23,8 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
receiveShadow
ref={ref}
{...handlers}
visible={node.visible}
material={material}
visible={node.visible}
>
<boxGeometry args={[0, 0, 0]} />
</mesh>
@@ -23,7 +23,7 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
return (
<mesh castShadow receiveShadow ref={ref} visible={node.visible} material={material}>
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
<boxGeometry args={[0, 0, 0]} />
<mesh name="collision-mesh" visible={false} {...handlers}>
<boxGeometry args={[0, 0, 0]} />
@@ -1,9 +1,5 @@
'use client'
// Must run before @react-three/fiber's Canvas instantiates new THREE.Clock().
// See lib/suppress-three-clock-warning.ts for rationale and removal condition.
import '../../lib/suppress-three-clock-warning'
import {
CeilingSystem,
DoorSystem,
@@ -19,7 +15,6 @@ import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@re
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three/webgpu'
import useViewer from '../../store/use-viewer'
import { ExportSystem } from '../../systems/export/export-system'
import { GuideSystem } from '../../systems/guide/guide-system'
import { ItemLightSystem } from '../../systems/item-light/item-light-system'
import { LevelSystem } from '../../systems/level/level-system'
@@ -151,7 +146,6 @@ const Viewer: React.FC<ViewerProps> = ({
<WallSystem />
<WindowSystem />
<ZoneSystem />
<ExportSystem />
<PostProcessing />
{/* <DebugRenderer /> */}
<GPUDeviceWatcher />
@@ -1,7 +1,6 @@
import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, 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'
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
import {
@@ -23,6 +22,7 @@ import {
} from 'three/tsl'
import { RenderPipeline, type WebGPURenderer } from 'three/webgpu'
import { SCENE_LAYER, ZONE_LAYER } from '../../lib/layers'
import { mergedOutline } from '../../lib/merged-outline-node'
import useViewer from '../../store/use-viewer'
// SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion
@@ -67,6 +67,7 @@ const PostProcessingPasses = () => {
l.disable(SCENE_LAYER)
return l
}, [])
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
// Subscribe to projectId so the pipeline rebuilds on project switch
const projectId = useViewer((s) => s.projectId)
@@ -197,60 +198,45 @@ const PostProcessingPasses = () => {
)
}
function generateSelectedOutlinePass() {
const edgeStrength = uniform(3)
const edgeGlow = uniform(0)
const edgeThickness = uniform(1)
const visibleEdgeColor = uniform(new Color(0xff_ff_ff))
const hiddenEdgeColor = uniform(new Color(0xf3_ff_47))
// Single merged outline node: one shared depth pass for both selected + hovered groups.
const outliner = useViewer.getState().outliner
const outlineNode = mergedOutline(scene, camera, {
primaryObjects: outliner.selectedObjects,
secondaryObjects: outliner.hoveredObjects,
primaryEdgeThickness: uniform(1),
secondaryEdgeThickness: uniform(1.5),
})
const outlinePass = outline(scene, camera, {
selectedObjects: useViewer.getState().outliner.selectedObjects,
edgeGlow,
edgeThickness,
})
const { visibleEdge, hiddenEdge } = outlinePass
// Selected: white visible, yellow hidden
const selectedVisibleColor = uniform(new Color(0xff_ff_ff))
const selectedHiddenColor = uniform(new Color(0xf3_ff_47))
const selectedStrength = uniform(3)
const selectedOutline = outlineNode.primaryVisibleEdge
.mul(selectedVisibleColor)
.add(outlineNode.primaryHiddenEdge.mul(selectedHiddenColor))
.mul(selectedStrength)
const outlineColor = visibleEdge
.mul(visibleEdgeColor)
.add(hiddenEdge.mul(hiddenEdgeColor))
.mul(edgeStrength)
return outlineColor
}
function generateHoverOutlinePass() {
const edgeStrength = uniform(5)
const edgeGlow = uniform(0.5)
const edgeThickness = uniform(1.5)
const pulsePeriod = uniform(3)
const visibleEdgeColor = uniform(new Color(0x00_aa_ff))
const hiddenEdgeColor = uniform(new Color(0xf3_ff_47))
const outlinePass = outline(scene, camera, {
selectedObjects: useViewer.getState().outliner.hoveredObjects,
edgeGlow,
edgeThickness,
})
const { visibleEdge, hiddenEdge } = outlinePass
const period = time.div(pulsePeriod).mul(2)
const osc = oscSine(period).mul(0.5).add(0.5) // osc [ 0.5, 1.0 ]
const outlineColor = visibleEdge
.mul(visibleEdgeColor)
.add(hiddenEdge.mul(hiddenEdgeColor))
.mul(edgeStrength)
const outlinePulse = pulsePeriod.greaterThan(0).select(outlineColor.mul(osc), outlineColor)
return outlinePulse
}
const selectedOutlinePass = generateSelectedOutlinePass()
const hoverOutlinePass = generateHoverOutlinePass()
// Hovered: blue visible, yellow hidden, pulsing
const hoverVisibleColor = uniform(
new Color(hoverHighlightMode === 'delete' ? 0xef_44_44 : 0x00_aa_ff),
)
const hoverHiddenColor = uniform(
new Color(hoverHighlightMode === 'delete' ? 0x99_1b_1b : 0xf3_ff_47),
)
const hoverStrength = uniform(hoverHighlightMode === 'delete' ? 6 : 5)
const pulsePeriod = uniform(3)
const osc =
hoverHighlightMode === 'delete'
? float(1)
: oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) // [ 0.5, 1.0 ]
const hoverOutline = outlineNode.secondaryVisibleEdge
.mul(hoverVisibleColor)
.add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor))
.mul(hoverStrength)
.mul(osc)
const compositeWithOutlines = vec4(
add(sceneColor.rgb, selectedOutlinePass.add(hoverOutlinePass)),
add(sceneColor.rgb, selectedOutline.add(hoverOutline)),
sceneColor.a,
)
@@ -280,7 +266,7 @@ const PostProcessingPasses = () => {
}
renderPipelineRef.current = null
}
}, [renderer, scene, camera, isInitialized, zoneLayers])
}, [renderer, scene, camera, hoverHighlightMode, isInitialized, zoneLayers])
useFrame((_, delta) => {
// Animate background colour toward the current theme target (same lerp as AnimatedBackground)
@@ -0,0 +1,136 @@
'use client'
import { PointerLockControls } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react'
import { Vector3 } from 'three'
import useViewer from '../../store/use-viewer'
const MOVE_SPEED = 5
const EYE_HEIGHT = 1.6
const _direction = new Vector3()
const _forward = new Vector3()
const _right = new Vector3()
export const WalkthroughControls = () => {
const controlsRef = useRef<any>(null!)
const walkthroughMode = useViewer((s: any) => s.walkthroughMode)
const keys = useRef({ w: false, a: false, s: false, d: false })
const camera = useThree((s) => s.camera)
// Set initial eye height
useEffect(() => {
if (walkthroughMode) {
camera.position.y = EYE_HEIGHT
}
}, [walkthroughMode, camera])
// Keyboard handlers
useEffect(() => {
if (!walkthroughMode) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
const key = e.key.toLowerCase()
// ESC exits walkthrough mode completely
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
useViewer.getState().setWalkthroughMode(false)
return
}
if (key === 'w' || key === 'arrowup') keys.current.w = true
if (key === 'a' || key === 'arrowleft') keys.current.a = true
if (key === 's' || key === 'arrowdown') keys.current.s = true
if (key === 'd' || key === 'arrowright') keys.current.d = true
}
const onKeyUp = (e: KeyboardEvent) => {
const key = e.key.toLowerCase()
if (key === 'w' || key === 'arrowup') keys.current.w = false
if (key === 'a' || key === 'arrowleft') keys.current.a = false
if (key === 's' || key === 'arrowdown') keys.current.s = false
if (key === 'd' || key === 'arrowright') keys.current.d = false
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
// Reset keys on cleanup
keys.current = { w: false, a: false, s: false, d: false }
}
}, [walkthroughMode])
// Release pointer lock when walkthrough mode is turned off
useEffect(() => {
if (!walkthroughMode && document.pointerLockElement) {
document.exitPointerLock()
}
}, [walkthroughMode])
// Movement loop
useFrame((_, delta) => {
if (!(walkthroughMode && controlsRef.current)) return
_direction.set(0, 0, 0)
// Get camera forward and right vectors (XZ plane only)
camera.getWorldDirection(_forward)
_forward.y = 0
_forward.normalize()
_right.crossVectors(_forward, camera.up).normalize()
if (keys.current.w) _direction.add(_forward)
if (keys.current.s) _direction.sub(_forward)
if (keys.current.d) _direction.add(_right)
if (keys.current.a) _direction.sub(_right)
if (_direction.lengthSq() > 0) {
_direction.normalize().multiplyScalar(MOVE_SPEED * delta)
camera.position.add(_direction)
// Keep eye height constant
camera.position.y = EYE_HEIGHT
}
})
const handleClick = useCallback(() => {
if (walkthroughMode && controlsRef.current) {
// Feature detection: some browsers (Facebook/Instagram in-app, older Safari)
// don't support pointer lock on the canvas element
if (typeof controlsRef.current.lock === 'function') {
try {
controlsRef.current.lock()
} catch {
// Silently ignore — pointer lock unavailable in this browser context
}
}
}
}, [walkthroughMode])
// Click to lock
useEffect(() => {
if (!walkthroughMode) return
const canvas = document.querySelector('canvas')
if (!canvas) return
canvas.addEventListener('click', handleClick)
return () => canvas.removeEventListener('click', handleClick)
}, [walkthroughMode, handleClick])
if (!walkthroughMode) return null
// Skip PointerLockControls on browsers that don't support pointer lock
// (Facebook/Instagram in-app browsers, some iOS WebViews)
if (typeof document !== 'undefined' && !('requestPointerLock' in HTMLElement.prototype)) {
return null
}
return <PointerLockControls ref={controlsRef} />
}
@@ -36,5 +36,4 @@ const useGLTFKTX2 = (path: string): ReturnType<typeof useGLTF> => {
loader.setMeshoptDecoder(MeshoptDecoder)
})
}
export { useGLTFKTX2 }
+2 -1
View File
@@ -1,4 +1,5 @@
export { default as Viewer } from './components/viewer'
export { WalkthroughControls } from './components/viewer/walkthrough-controls'
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
export { SCENE_LAYER, ZONE_LAYER } from './lib/layers'
export {
@@ -13,7 +14,7 @@ export {
DEFAULT_WINDOW_MATERIAL,
disposeMaterial,
} from './lib/materials'
export { mergedOutline } from './lib/merged-outline-node'
export { default as useViewer } from './store/use-viewer'
export { ExportSystem } from './systems/export/export-system'
export { InteractiveSystem } from './systems/interactive/interactive-system'
export { snapLevelsToTruePositions } from './systems/level/level-utils'
@@ -0,0 +1,614 @@
// @ts-nocheck — Three.js TSL/WebGPU internal APIs have incomplete type definitions;
// this file is a fork of OutlineNode and is intentionally exempt from strict TS checking.
/**
* MergedOutlineNode — a fork of Three.js OutlineNode that processes two object
* groups (primary = selected, secondary = hovered) in a single pass, sharing the
* expensive non-selected depth pre-render between both groups.
*
* Cost comparison vs two separate OutlineNode instances:
* Before: depth_A + mask_A + edge_A×6 + depth_B + mask_B + edge_B×6 = 2 depth passes
* After: depth_AB (shared) + mask_A + edge_A×6 + mask_B + edge_B×6 = 1 depth pass
*
* Additional early-outs:
* - Both empty → skip everything (0 passes)
* - Only primary → skip secondary mask/edge/blur
* - Only secondary → skip primary mask/edge/blur
*/
import { DepthTexture, FloatType, type Object3D, RenderTarget, Vector2 } from 'three'
import {
color,
exp,
Fn,
float,
int,
Loop,
min,
mul,
nodeObject,
orthographicDepthToViewZ,
passTexture,
perspectiveDepthToViewZ,
positionView,
reference,
screenUV,
texture,
textureSize,
uniform,
uv,
vec2,
vec3,
vec4,
} from 'three/tsl'
import {
NodeMaterial,
NodeUpdateType,
QuadMesh,
RendererUtils,
SpriteNodeMaterial,
TempNode,
} from 'three/webgpu'
const _quadMesh = new QuadMesh()
const _size = new Vector2()
const _BLUR_X = new Vector2(1.0, 0.0)
const _BLUR_Y = new Vector2(0.0, 1.0)
let _rendererState: any // eslint-disable-line @typescript-eslint/no-explicit-any
// ---------------------------------------------------------------------------
// Helper: render targets for one outline group
// ---------------------------------------------------------------------------
function makeGroupTargets(downSampleRatio: number) {
const maskBuffer = new RenderTarget()
const maskDownSample = new RenderTarget(1, 1, { depthBuffer: false })
const edgeBuffer1 = new RenderTarget(1, 1, { depthBuffer: false })
const edgeBuffer2 = new RenderTarget(1, 1, { depthBuffer: false })
const blurBuffer1 = new RenderTarget(1, 1, { depthBuffer: false })
const blurBuffer2 = new RenderTarget(1, 1, { depthBuffer: false })
const composite = new RenderTarget(1, 1, { depthBuffer: false })
function setSize(w: number, h: number) {
maskBuffer.setSize(w, h)
composite.setSize(w, h)
let rx = Math.round(w / downSampleRatio)
let ry = Math.round(h / downSampleRatio)
maskDownSample.setSize(rx, ry)
edgeBuffer1.setSize(rx, ry)
blurBuffer1.setSize(rx, ry)
rx = Math.round(rx / 2)
ry = Math.round(ry / 2)
edgeBuffer2.setSize(rx, ry)
blurBuffer2.setSize(rx, ry)
}
function dispose() {
maskBuffer.dispose()
maskDownSample.dispose()
edgeBuffer1.dispose()
edgeBuffer2.dispose()
blurBuffer1.dispose()
blurBuffer2.dispose()
composite.dispose()
}
return {
maskBuffer,
maskDownSample,
edgeBuffer1,
edgeBuffer2,
blurBuffer1,
blurBuffer2,
composite,
setSize,
dispose,
}
}
type GroupTargets = ReturnType<typeof makeGroupTargets>
// ---------------------------------------------------------------------------
// MergedOutlineNode
// ---------------------------------------------------------------------------
export class MergedOutlineNode extends TempNode {
static get type() {
return 'MergedOutlineNode'
}
scene: any
camera: any
primaryObjects: Object3D[]
secondaryObjects: Object3D[]
primaryEdgeThicknessNode: any
secondaryEdgeThicknessNode: any
primaryEdgeGlowNode: any
secondaryEdgeGlowNode: any
downSampleRatio: number
updateBeforeType: string
private readonly _depthRT: RenderTarget
private readonly _depthTexUniform: any
private readonly _groupA: GroupTargets
private readonly _groupB: GroupTargets
private readonly _maskTexA: any
private readonly _maskDownTexA: any
private readonly _edge1TexA: any
private readonly _edge2TexA: any
private readonly _blurColorTexA: any
private readonly _maskTexB: any
private readonly _maskDownTexB: any
private readonly _edge1TexB: any
private readonly _edge2TexB: any
private readonly _blurColorTexB: any
private readonly _blurDirectionA: any
private readonly _blurDirectionB: any
private readonly _cameraNear: any
private readonly _cameraFar: any
private readonly _depthMaterial: NodeMaterial
private readonly _depthSpriteMaterial: SpriteNodeMaterial
private readonly _prepareMaskMatA: NodeMaterial
private readonly _prepareMaskSpriteMatA: SpriteNodeMaterial
private readonly _copyMatA: NodeMaterial
private readonly _edgeDetectMatA: NodeMaterial
private readonly _blurMat1A: NodeMaterial
private readonly _blurMat2A: NodeMaterial
private readonly _compositeMatA: NodeMaterial
private readonly _prepareMaskMatB: NodeMaterial
private readonly _prepareMaskSpriteMatB: SpriteNodeMaterial
private readonly _copyMatB: NodeMaterial
private readonly _edgeDetectMatB: NodeMaterial
private readonly _blurMat1B: NodeMaterial
private readonly _blurMat2B: NodeMaterial
private readonly _compositeMatB: NodeMaterial
private readonly _cacheA = new Set<Object3D>()
private readonly _cacheB = new Set<Object3D>()
private readonly _textureNodeA: any
private readonly _textureNodeB: any
constructor(
scene: any,
camera: any,
params: {
primaryObjects?: Object3D[]
secondaryObjects?: Object3D[]
primaryEdgeThickness?: any
secondaryEdgeThickness?: any
primaryEdgeGlow?: any
secondaryEdgeGlow?: any
downSampleRatio?: number
} = {},
) {
super('vec4')
const {
primaryObjects = [],
secondaryObjects = [],
primaryEdgeThickness = float(1),
secondaryEdgeThickness = float(1),
primaryEdgeGlow = float(0),
secondaryEdgeGlow = float(0),
downSampleRatio = 2,
} = params
this.scene = scene
this.camera = camera
this.primaryObjects = primaryObjects
this.secondaryObjects = secondaryObjects
this.primaryEdgeThicknessNode = nodeObject(primaryEdgeThickness)
this.secondaryEdgeThicknessNode = nodeObject(secondaryEdgeThickness)
this.primaryEdgeGlowNode = nodeObject(primaryEdgeGlow)
this.secondaryEdgeGlowNode = nodeObject(secondaryEdgeGlow)
this.downSampleRatio = downSampleRatio
this.updateBeforeType = NodeUpdateType.FRAME
this._depthRT = new RenderTarget()
this._depthRT.depthTexture = new DepthTexture()
this._depthRT.depthTexture.type = FloatType
this._groupA = makeGroupTargets(downSampleRatio)
this._groupB = makeGroupTargets(downSampleRatio)
this._cameraNear = reference('near', 'float', camera)
this._cameraFar = reference('far', 'float', camera)
this._blurDirectionA = uniform(new Vector2())
this._blurDirectionB = uniform(new Vector2())
this._depthTexUniform = texture(this._depthRT.depthTexture)
this._maskTexA = texture(this._groupA.maskBuffer.texture)
this._maskDownTexA = texture(this._groupA.maskDownSample.texture)
this._edge1TexA = texture(this._groupA.edgeBuffer1.texture)
this._edge2TexA = texture(this._groupA.edgeBuffer2.texture)
this._blurColorTexA = texture(this._groupA.edgeBuffer1.texture)
this._maskTexB = texture(this._groupB.maskBuffer.texture)
this._maskDownTexB = texture(this._groupB.maskDownSample.texture)
this._edge1TexB = texture(this._groupB.edgeBuffer1.texture)
this._edge2TexB = texture(this._groupB.edgeBuffer2.texture)
this._blurColorTexB = texture(this._groupB.edgeBuffer1.texture)
this._depthMaterial = new NodeMaterial()
this._depthMaterial.colorNode = color(0, 0, 0)
this._depthMaterial.name = 'MergedOutline.depth'
this._depthSpriteMaterial = new SpriteNodeMaterial()
this._depthSpriteMaterial.colorNode = color(0, 0, 0)
this._depthSpriteMaterial.name = 'MergedOutline.depthSprite'
this._prepareMaskMatA = new NodeMaterial()
this._prepareMaskMatA.name = 'MergedOutline.maskA'
this._prepareMaskSpriteMatA = new SpriteNodeMaterial()
this._prepareMaskSpriteMatA.name = 'MergedOutline.maskSpriteA'
this._copyMatA = new NodeMaterial()
this._copyMatA.name = 'MergedOutline.copyA'
this._edgeDetectMatA = new NodeMaterial()
this._edgeDetectMatA.name = 'MergedOutline.edgeA'
this._blurMat1A = new NodeMaterial()
this._blurMat1A.name = 'MergedOutline.blur1A'
this._blurMat2A = new NodeMaterial()
this._blurMat2A.name = 'MergedOutline.blur2A'
this._compositeMatA = new NodeMaterial()
this._compositeMatA.name = 'MergedOutline.compositeA'
this._prepareMaskMatB = new NodeMaterial()
this._prepareMaskMatB.name = 'MergedOutline.maskB'
this._prepareMaskSpriteMatB = new SpriteNodeMaterial()
this._prepareMaskSpriteMatB.name = 'MergedOutline.maskSpriteB'
this._copyMatB = new NodeMaterial()
this._copyMatB.name = 'MergedOutline.copyB'
this._edgeDetectMatB = new NodeMaterial()
this._edgeDetectMatB.name = 'MergedOutline.edgeB'
this._blurMat1B = new NodeMaterial()
this._blurMat1B.name = 'MergedOutline.blur1B'
this._blurMat2B = new NodeMaterial()
this._blurMat2B.name = 'MergedOutline.blur2B'
this._compositeMatB = new NodeMaterial()
this._compositeMatB.name = 'MergedOutline.compositeB'
// Output: R = visibleEdge, G = hiddenEdge
this._textureNodeA = passTexture(this, this._groupA.composite.texture)
this._textureNodeB = passTexture(this, this._groupB.composite.texture)
}
get primaryVisibleEdge() {
return this._textureNodeA.r
}
get primaryHiddenEdge() {
return this._textureNodeA.g
}
get secondaryVisibleEdge() {
return this._textureNodeB.r
}
get secondaryHiddenEdge() {
return this._textureNodeB.g
}
setSize(width: number, height: number) {
this._depthRT.setSize(width, height)
this._groupA.setSize(width, height)
this._groupB.setSize(width, height)
}
updateBefore(frame: any) {
const hasPrimary = this.primaryObjects.length > 0
const hasSecondary = this.secondaryObjects.length > 0
const { renderer } = frame
const { camera, scene } = this
_rendererState = RendererUtils.resetRendererAndSceneState(renderer, scene, _rendererState)
const size = renderer.getDrawingBufferSize(_size)
this.setSize(size.width, size.height)
// Clear composites for inactive groups so stale outlines don't persist on GPU.
// Must happen inside resetRendererAndSceneState to avoid MSAA state corruption.
if (!hasPrimary) {
renderer.setRenderTarget(this._groupA.composite)
renderer.clearColor()
}
if (!hasSecondary) {
renderer.setRenderTarget(this._groupB.composite)
renderer.clearColor()
}
const hasAny = hasPrimary || hasSecondary
if (!hasAny) {
RendererUtils.restoreRendererAndSceneState(renderer, scene, _rendererState)
return
}
renderer.setClearColor(0xff_ff_ff, 1)
if (hasPrimary) this._buildCache(this.primaryObjects, this._cacheA)
if (hasSecondary) this._buildCache(this.secondaryObjects, this._cacheB)
const savedName = scene.name
// ── 1. Shared depth pass: all objects NOT in either group ─────────────────
renderer.setRenderTarget(this._depthRT)
renderer.setRenderObjectFunction(
(obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => {
const inCache = this._cacheA.has(obj) || this._cacheB.has(obj)
if (!inCache) {
const m = obj.isSprite ? this._depthSpriteMaterial : this._depthMaterial
renderer.renderObject(obj, sc, cam, geo, m, grp, lights, clip)
}
},
)
scene.name = 'MergedOutline [ Depth ]'
renderer.render(scene, camera)
// ── 2a. Primary mask pass ─────────────────────────────────────────────────
if (hasPrimary) {
renderer.setRenderTarget(this._groupA.maskBuffer)
renderer.setRenderObjectFunction(
(obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => {
if (this._cacheA.has(obj)) {
const m = obj.isSprite ? this._prepareMaskSpriteMatA : this._prepareMaskMatA
renderer.renderObject(obj, sc, cam, geo, m, grp, lights, clip)
}
},
)
scene.name = 'MergedOutline [ Mask A ]'
renderer.render(scene, camera)
}
// ── 2b. Secondary mask pass ───────────────────────────────────────────────
if (hasSecondary) {
renderer.setRenderTarget(this._groupB.maskBuffer)
renderer.setRenderObjectFunction(
(obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => {
if (this._cacheB.has(obj)) {
const m = obj.isSprite ? this._prepareMaskSpriteMatB : this._prepareMaskMatB
renderer.renderObject(obj, sc, cam, geo, m, grp, lights, clip)
}
},
)
scene.name = 'MergedOutline [ Mask B ]'
renderer.render(scene, camera)
}
renderer.setRenderObjectFunction(_rendererState.renderObjectFunction)
this._cacheA.clear()
this._cacheB.clear()
scene.name = savedName
// ── 37. Edge detect + blur + composite per active group ──────────────────
if (hasPrimary) this._runEdgePipeline(renderer, 'A')
if (hasSecondary) this._runEdgePipeline(renderer, 'B')
RendererUtils.restoreRendererAndSceneState(renderer, scene, _rendererState)
}
private _runEdgePipeline(renderer: any, group: 'A' | 'B') {
const isA = group === 'A'
const g = isA ? this._groupA : this._groupB
const copyMat = isA ? this._copyMatA : this._copyMatB
const edgeMat = isA ? this._edgeDetectMatA : this._edgeDetectMatB
const blur1 = isA ? this._blurMat1A : this._blurMat1B
const blur2 = isA ? this._blurMat2A : this._blurMat2B
const blurDir = isA ? this._blurDirectionA : this._blurDirectionB
const blurColorTex = isA ? this._blurColorTexA : this._blurColorTexB
const compositeMat = isA ? this._compositeMatA : this._compositeMatB
_quadMesh.material = copyMat
renderer.setRenderTarget(g.maskDownSample)
_quadMesh.render(renderer)
_quadMesh.material = edgeMat
renderer.setRenderTarget(g.edgeBuffer1)
_quadMesh.render(renderer)
blurColorTex.value = g.edgeBuffer1.texture
blurDir.value.copy(_BLUR_X)
_quadMesh.material = blur1
renderer.setRenderTarget(g.blurBuffer1)
_quadMesh.render(renderer)
blurColorTex.value = g.blurBuffer1.texture
blurDir.value.copy(_BLUR_Y)
renderer.setRenderTarget(g.edgeBuffer1)
_quadMesh.render(renderer)
blurColorTex.value = g.edgeBuffer1.texture
blurDir.value.copy(_BLUR_X)
_quadMesh.material = blur2
renderer.setRenderTarget(g.blurBuffer2)
_quadMesh.render(renderer)
blurColorTex.value = g.blurBuffer2.texture
blurDir.value.copy(_BLUR_Y)
renderer.setRenderTarget(g.edgeBuffer2)
_quadMesh.render(renderer)
_quadMesh.material = compositeMat
renderer.setRenderTarget(g.composite)
_quadMesh.render(renderer)
}
setup(_builder: any) {
// ── prepareMask ───────────────────────────────────────────────────────────
const buildPrepareMask = () => {
const depth = this._depthTexUniform.sample(screenUV)
const viewZ = this.camera.isPerspectiveCamera
? perspectiveDepthToViewZ(depth, this._cameraNear, this._cameraFar)
: orthographicDepthToViewZ(depth, this._cameraNear, this._cameraFar)
const depthTest = positionView.z.lessThanEqual(viewZ).select(1, 0)
return vec3(0.0, depthTest, 1.0)
}
const maskColorA = buildPrepareMask()
this._prepareMaskMatA.colorNode = maskColorA
this._prepareMaskMatA.needsUpdate = true
this._prepareMaskSpriteMatA.colorNode = maskColorA
this._prepareMaskSpriteMatA.needsUpdate = true
const maskColorB = buildPrepareMask()
this._prepareMaskMatB.colorNode = maskColorB
this._prepareMaskMatB.needsUpdate = true
this._prepareMaskSpriteMatB.colorNode = maskColorB
this._prepareMaskSpriteMatB.needsUpdate = true
// ── Copy ──────────────────────────────────────────────────────────────────
this._copyMatA.fragmentNode = this._maskTexA
this._copyMatA.needsUpdate = true
this._copyMatB.fragmentNode = this._maskTexB
this._copyMatB.needsUpdate = true
// ── Edge detection ────────────────────────────────────────────────────────
const buildEdgeDetect = (maskDownTex: any) =>
Fn(() => {
const resolution = textureSize(maskDownTex)
const invSize = vec2(1).div(resolution).toVar()
const uvOffset = vec4(1.0, 0.0, 0.0, 1.0).mul(vec4(invSize, invSize))
const uvNode = uv()
const c1 = maskDownTex.sample(uvNode.add(uvOffset.xy)).toVar()
const c2 = maskDownTex.sample(uvNode.sub(uvOffset.xy)).toVar()
const c3 = maskDownTex.sample(uvNode.add(uvOffset.yw)).toVar()
const c4 = maskDownTex.sample(uvNode.sub(uvOffset.yw)).toVar()
const diff1 = mul(c1.r.sub(c2.r), 0.5)
const diff2 = mul(c3.r.sub(c4.r), 0.5)
const d = vec2(diff1, diff2).length()
const a1 = min(c1.g, c2.g)
const a2 = min(c3.g, c4.g)
const visibilityFactor = min(a1, a2)
// R = visible edge, G = hidden edge (matches OutlineNode convention)
const edgeColor = visibilityFactor
.oneMinus()
.greaterThan(0.001)
.select(vec3(1, 0, 0), vec3(0, 1, 0))
return vec4(edgeColor, 1).mul(d)
})()
this._edgeDetectMatA.fragmentNode = buildEdgeDetect(this._maskDownTexA)
this._edgeDetectMatA.needsUpdate = true
this._edgeDetectMatB.fragmentNode = buildEdgeDetect(this._maskDownTexB)
this._edgeDetectMatB.needsUpdate = true
// ── Separable blur ────────────────────────────────────────────────────────
const MAX_RADIUS = 4
const gaussianPdf = Fn(([x, sigma]: any[]) =>
float(0.398_94).mul(exp(float(-0.5).mul(x).mul(x).div(sigma.mul(sigma))).div(sigma)),
)
const buildBlur = (maskDownTex: any, blurColorTex: any, blurDir: any, kernelRadius: any) =>
Fn(() => {
const resolution = textureSize(maskDownTex)
const invSize = vec2(1).div(resolution).toVar()
const uvNode = uv()
const sigma = kernelRadius.div(2).toVar()
const weightSum = gaussianPdf(0, sigma).toVar()
const diffuseSum = blurColorTex.sample(uvNode).mul(weightSum).toVar()
const delta = blurDir.mul(invSize).mul(kernelRadius).div(MAX_RADIUS).toVar()
const uvOffset = delta.toVar()
Loop(
{ start: int(1), end: int(MAX_RADIUS), type: 'int', condition: '<=' },
({ i }: any) => {
const x = kernelRadius.mul(float(i)).div(MAX_RADIUS)
const w = gaussianPdf(x, sigma)
diffuseSum.addAssign(
blurColorTex
.sample(uvNode.add(uvOffset))
.add(blurColorTex.sample(uvNode.sub(uvOffset)))
.mul(w),
)
weightSum.addAssign(w.mul(2))
uvOffset.addAssign(delta)
},
)
return diffuseSum.div(weightSum)
})()
this._blurMat1A.fragmentNode = buildBlur(
this._maskDownTexA,
this._blurColorTexA,
this._blurDirectionA,
this.primaryEdgeThicknessNode,
)
this._blurMat1A.needsUpdate = true
this._blurMat2A.fragmentNode = buildBlur(
this._maskDownTexA,
this._blurColorTexA,
this._blurDirectionA,
float(MAX_RADIUS),
)
this._blurMat2A.needsUpdate = true
this._blurMat1B.fragmentNode = buildBlur(
this._maskDownTexB,
this._blurColorTexB,
this._blurDirectionB,
this.secondaryEdgeThicknessNode,
)
this._blurMat1B.needsUpdate = true
this._blurMat2B.fragmentNode = buildBlur(
this._maskDownTexB,
this._blurColorTexB,
this._blurDirectionB,
float(MAX_RADIUS),
)
this._blurMat2B.needsUpdate = true
// ── Composite ─────────────────────────────────────────────────────────────
const buildComposite = (maskTex: any, edge1Tex: any, edge2Tex: any, edgeGlowNode: any) =>
Fn(() => maskTex.r.mul(edge1Tex.add(edge2Tex.mul(edgeGlowNode))))()
this._compositeMatA.fragmentNode = buildComposite(
this._maskTexA,
this._edge1TexA,
this._edge2TexA,
this.primaryEdgeGlowNode,
)
this._compositeMatA.needsUpdate = true
this._compositeMatB.fragmentNode = buildComposite(
this._maskTexB,
this._edge1TexB,
this._edge2TexB,
this.secondaryEdgeGlowNode,
)
this._compositeMatB.needsUpdate = true
return this._textureNodeA
}
dispose() {
this.primaryObjects.length = 0
this.secondaryObjects.length = 0
this._depthRT.dispose()
this._groupA.dispose()
this._groupB.dispose()
this._depthMaterial.dispose()
this._depthSpriteMaterial.dispose()
this._prepareMaskMatA.dispose()
this._prepareMaskSpriteMatA.dispose()
this._copyMatA.dispose()
this._edgeDetectMatA.dispose()
this._blurMat1A.dispose()
this._blurMat2A.dispose()
this._compositeMatA.dispose()
this._prepareMaskMatB.dispose()
this._prepareMaskSpriteMatB.dispose()
this._copyMatB.dispose()
this._edgeDetectMatB.dispose()
this._blurMat1B.dispose()
this._blurMat2B.dispose()
this._compositeMatB.dispose()
}
private _buildCache(objects: Object3D[], cache: Set<Object3D>) {
for (const obj of objects) {
obj.traverse((child: any) => {
if (child.isMesh || child.isSprite) cache.add(child)
})
}
}
}
export const mergedOutline = (
scene: any,
camera: any,
params?: ConstructorParameters<typeof MergedOutlineNode>[2],
) => new MergedOutlineNode(scene, camera, params)
+3
View File
@@ -78,18 +78,21 @@ interface ThreeJSXElements {
}
declare module 'react' {
// biome-ignore lint/style/noNamespace: Required for JSX module augmentation
namespace JSX {
interface IntrinsicElements extends ThreeJSXElements {}
}
}
declare module 'react/jsx-runtime' {
// biome-ignore lint/style/noNamespace: Required for JSX module augmentation
namespace JSX {
interface IntrinsicElements extends ThreeJSXElements {}
}
}
declare module 'react/jsx-dev-runtime' {
// biome-ignore lint/style/noNamespace: Required for JSX module augmentation
namespace JSX {
interface IntrinsicElements extends ThreeJSXElements {}
}
+6
View File
@@ -71,6 +71,9 @@ type ViewerState = {
debugColors: boolean
setDebugColors: (enabled: boolean) => void
walkthroughMode: boolean
setWalkthroughMode: (mode: boolean) => void
cameraDragging: boolean
setCameraDragging: (dragging: boolean) => void
}
@@ -194,6 +197,9 @@ const useViewer = create<ViewerState>()(
debugColors: false,
setDebugColors: (enabled) => set({ debugColors: enabled }),
walkthroughMode: false,
setWalkthroughMode: (mode) => set({ walkthroughMode: mode }),
cameraDragging: false,
setCameraDragging: (dragging) => set({ cameraDragging: dragging }),
}),
+134 -70
View File
@@ -1,14 +1,35 @@
import { sceneRegistry, useScene, type WallNode } from '@pascal-app/core'
import {
type AnyNodeId,
baseMaterial,
sceneRegistry,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { useRef } from 'react'
import { Color } from 'three'
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu'
import useViewer from '../../store/use-viewer'
const tmpVec = new Vector3()
const u = new Vector3()
const v = new Vector3()
const DEFAULT_WALL_COLOR = '#f2f0ed'
const WALL_HIGHLIGHT_PROFILES = {
delete: {
color: new Color('#dc2626'),
blend: 0.78,
emissiveIntensity: 0.46,
},
selection: {
color: new Color('#818cf8'),
blend: 0.32,
emissiveIntensity: 0.42,
},
} as const
type WallHighlightKind = keyof typeof WALL_HIGHLIGHT_PROFILES
const dotPattern = Fn(() => {
const scale = float(0.1)
@@ -30,23 +51,15 @@ const dotPattern = Fn(() => {
interface WallMaterials {
visible: MeshStandardNodeMaterial
invisible: MeshStandardNodeMaterial
deleteVisible: MeshStandardNodeMaterial
deleteInvisible: MeshStandardNodeMaterial
highlightedVisible: MeshStandardNodeMaterial
highlightedInvisible: MeshStandardNodeMaterial
materialHash: string
}
const wallMaterialCache = new Map<string, WallMaterials>()
function getMaterialHash(wallNode: WallNode): string {
if (!wallNode.material) return 'none'
const mat = wallNode.material
if (mat.preset && mat.preset !== 'custom') {
return `preset-${mat.preset}`
}
if (mat.properties) {
return `props-${mat.properties.color}-${mat.properties.roughness}-${mat.properties.metalness}`
}
return 'default'
}
const presetColors = {
white: '#ffffff',
brick: '#8b4513',
@@ -59,10 +72,43 @@ const presetColors = {
marble: '#f5f5f5',
} as const
function getMaterialHash(wallNode: WallNode): string {
if (!wallNode.material) return 'none'
const mat = wallNode.material
if (mat.preset && mat.preset !== 'custom') {
return `preset-${mat.preset}`
}
if (mat.properties) {
return `props-${mat.properties.color}-${mat.properties.roughness}-${mat.properties.metalness}`
}
return 'default'
}
function getPresetColor(preset: string): string {
return presetColors[preset as keyof typeof presetColors] ?? '#ffffff'
}
function getHighlightedColor(color: string, kind: WallHighlightKind): Color {
const profile = WALL_HIGHLIGHT_PROFILES[kind]
return new Color(color).lerp(profile.color, profile.blend)
}
function createHighlightedWallMaterial(
material: MeshStandardNodeMaterial,
baseColor: string,
kind: WallHighlightKind,
): MeshStandardNodeMaterial {
const highlightedMaterial = material.clone()
const highlightedColor = getHighlightedColor(baseColor, kind)
const profile = WALL_HIGHLIGHT_PROFILES[kind]
highlightedMaterial.color = highlightedColor
highlightedMaterial.emissive = highlightedColor.clone()
highlightedMaterial.emissiveIntensity = profile.emissiveIntensity
return highlightedMaterial
}
function getMaterialsForWall(wallNode: WallNode): WallMaterials {
const cacheKey = wallNode.id
const materialHash = getMaterialHash(wallNode)
@@ -75,20 +121,26 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials {
if (existing) {
existing.visible.dispose()
existing.invisible.dispose()
existing.deleteVisible.dispose()
existing.deleteInvisible.dispose()
existing.highlightedVisible.dispose()
existing.highlightedInvisible.dispose()
}
let userColor = '#ffffff'
let userColor = DEFAULT_WALL_COLOR
if (wallNode.material?.properties?.color) {
userColor = wallNode.material.properties.color
} else if (wallNode.material?.preset && wallNode.material.preset !== 'custom') {
userColor = getPresetColor(wallNode.material.preset)
}
const visibleMat = new MeshStandardNodeMaterial({
color: userColor,
roughness: 1,
metalness: 0,
})
const visibleMat = wallNode.material
? new MeshStandardNodeMaterial({
color: userColor,
roughness: 1,
metalness: 0,
})
: (baseMaterial.clone() as MeshStandardNodeMaterial)
const invisibleMat = new MeshStandardNodeMaterial({
transparent: true,
@@ -98,7 +150,20 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials {
emissive: userColor,
})
const result: WallMaterials = { visible: visibleMat, invisible: invisibleMat, materialHash }
const highlightedVisible = createHighlightedWallMaterial(visibleMat, userColor, 'selection')
const highlightedInvisible = createHighlightedWallMaterial(invisibleMat, userColor, 'selection')
const deleteVisible = createHighlightedWallMaterial(visibleMat, userColor, 'delete')
const deleteInvisible = createHighlightedWallMaterial(invisibleMat, userColor, 'delete')
const result: WallMaterials = {
visible: visibleMat,
invisible: invisibleMat,
deleteVisible,
deleteInvisible,
highlightedVisible,
highlightedInvisible,
materialHash,
}
wallMaterialCache.set(cacheKey, result)
return result
}
@@ -135,78 +200,77 @@ export const WallCutout = () => {
const lastUpdateTime = useRef(0)
const lastWallMode = useRef<string>(useViewer.getState().wallMode)
const lastNumberOfWalls = useRef(0)
const lastWallMaterials = useRef<Map<string, WallMaterials>>(new Map())
const lastHighlightKey = useRef('')
useFrame(({ camera, clock }) => {
const wallMode = useViewer.getState().wallMode
const selectedIds = useViewer.getState().selection.selectedIds
const previewSelectedIds = useViewer.getState().previewSelectedIds
const hoveredId = useViewer.getState().hoveredId
const hoverHighlightMode = useViewer.getState().hoverHighlightMode
const currentTime = clock.elapsedTime
const currentCameraPosition = camera.position
camera.getWorldDirection(tmpVec)
tmpVec.add(currentCameraPosition)
const highlightedWallIds = new Set(
[...selectedIds, ...previewSelectedIds].filter(
(id) => useScene.getState().nodes[id as AnyNodeId]?.type === 'wall',
),
)
const deleteHoveredWallId =
hoverHighlightMode === 'delete' &&
hoveredId &&
useScene.getState().nodes[hoveredId as AnyNodeId]?.type === 'wall'
? hoveredId
: null
const highlightKey = `${Array.from(highlightedWallIds).sort().join('|')}::${deleteHoveredWallId ?? ''}`
const distanceMoved = currentCameraPosition.distanceTo(lastCameraPosition.current)
const directionChanged = tmpVec.distanceTo(lastCameraTarget.current)
const timeSinceUpdate = currentTime - lastUpdateTime.current
const shouldUpdate =
if (
((distanceMoved > 0.5 || directionChanged > 0.3) && timeSinceUpdate > 0.1) ||
lastWallMode.current !== wallMode ||
sceneRegistry.byType.wall.size !== lastNumberOfWalls.current
const walls = sceneRegistry.byType.wall
const currentWallIds = new Set<string>()
walls.forEach((wallId) => {
const wallMesh = sceneRegistry.nodes.get(wallId)
if (!wallMesh) return
const wallNode = useScene.getState().nodes[wallId as WallNode['id']]
if (!wallNode || wallNode.type !== 'wall') return
currentWallIds.add(wallId)
const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u)
if (shouldUpdate) {
const materials = getMaterialsForWall(wallNode)
;(wallMesh as Mesh).material = hideWall ? materials.invisible : materials.visible
} else {
const currentMaterial = (wallMesh as Mesh).material
const materials = wallMaterialCache.get(wallId)
if (
!materials ||
currentMaterial !== (hideWall ? materials.invisible : materials.visible)
) {
const newMaterials = getMaterialsForWall(wallNode)
;(wallMesh as Mesh).material = hideWall ? newMaterials.invisible : newMaterials.visible
}
}
})
if (shouldUpdate) {
sceneRegistry.byType.wall.size !== lastNumberOfWalls.current ||
lastHighlightKey.current !== highlightKey
) {
lastCameraPosition.current.copy(currentCameraPosition)
lastCameraTarget.current.copy(tmpVec)
lastUpdateTime.current = currentTime
camera.getWorldDirection(u)
if (lastWallMode.current !== wallMode) {
wallMaterialCache.clear()
}
const walls = sceneRegistry.byType.wall
walls.forEach((wallId) => {
const wallMesh = sceneRegistry.nodes.get(wallId)
if (!wallMesh) return
const wallNode = useScene.getState().nodes[wallId as WallNode['id']]
if (!wallNode || wallNode.type !== 'wall') return
for (const [wallId, mats] of lastWallMaterials.current) {
if (!currentWallIds.has(wallId)) {
mats.visible.dispose()
mats.invisible.dispose()
wallMaterialCache.delete(wallId)
const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u)
const isDeleteHighlighted = deleteHoveredWallId === wallId
const isSelectionHighlighted = !isDeleteHighlighted && highlightedWallIds.has(wallId)
const materials = getMaterialsForWall(wallNode)
if (hideWall) {
;(wallMesh as Mesh).material = isDeleteHighlighted
? materials.deleteInvisible
: isSelectionHighlighted
? materials.highlightedInvisible
: materials.invisible
} else {
;(wallMesh as Mesh).material = isDeleteHighlighted
? materials.deleteVisible
: isSelectionHighlighted
? materials.highlightedVisible
: wallNode.material
? materials.visible
: baseMaterial
}
}
lastWallMaterials.current.clear()
for (const [wallId, mats] of wallMaterialCache) {
lastWallMaterials.current.set(wallId, mats)
}
})
lastWallMode.current = wallMode
lastNumberOfWalls.current = sceneRegistry.byType.wall.size
lastHighlightKey.current = highlightKey
}
})
return null