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} />
}