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
+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