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:
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
// ── 3–7. 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)
|
||||
Vendored
+3
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user