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
+1 -19
View File
@@ -1,28 +1,10 @@
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import { baseMaterial, glassMaterial } from '../../materials'
import type { AnyNodeId, DoorNode } from '../../schema'
import useScene from '../../store/use-scene'
const baseMaterial = new MeshStandardNodeMaterial({
name: 'door-base',
color: '#f2f0ed',
roughness: 0.5,
metalness: 0,
})
const glassMaterial = new MeshStandardNodeMaterial({
name: 'door-glass',
color: 'lightblue',
roughness: 0.05,
metalness: 0.1,
transparent: true,
opacity: 0.35,
side: DoubleSide,
depthWrite: false,
})
// Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
@@ -10,8 +10,15 @@ import useScene from '../../store/use-scene'
const csgEvaluator = new Evaluator()
csgEvaluator.useGroups = true
;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash
csgEvaluator.attributes = ['position', 'normal']
function prepareBrushForCSG(brush: Brush) {
brush.geometry.computeBoundsTree = computeBoundsTree
brush.geometry.computeBoundsTree({ maxLeafSize: 10 })
brush.updateMatrixWorld()
}
// Pooled objects to avoid per-frame allocation in updateMergedRoofGeometry
const _matrix = new THREE.Matrix4()
const _position = new THREE.Vector3()
@@ -78,6 +85,8 @@ export const RoofSystem = () => {
mesh.rotation.y = node.rotation
}
clearDirty(id as AnyNodeId)
} else {
clearDirty(id as AnyNodeId)
}
// Queue the parent roof for a merged geometry update
if (node.parentId) {
@@ -179,6 +188,7 @@ function updateMergedRoofGeometry(
const next: Brush = csgEvaluator.evaluate(totalShinSlab, brushes.shinSlab, ADDITION) as Brush
totalShinSlab.geometry.dispose()
brushes.shinSlab.geometry.dispose()
prepareBrushForCSG(next)
totalShinSlab = next
} else {
totalShinSlab = brushes.shinSlab
@@ -188,6 +198,7 @@ function updateMergedRoofGeometry(
const next: Brush = csgEvaluator.evaluate(totalDeckSlab, brushes.deckSlab, ADDITION) as Brush
totalDeckSlab.geometry.dispose()
brushes.deckSlab.geometry.dispose()
prepareBrushForCSG(next)
totalDeckSlab = next
} else {
totalDeckSlab = brushes.deckSlab
@@ -197,6 +208,7 @@ function updateMergedRoofGeometry(
const next: Brush = csgEvaluator.evaluate(totalWall, brushes.wallBrush, ADDITION) as Brush
totalWall.geometry.dispose()
brushes.wallBrush.geometry.dispose()
prepareBrushForCSG(next)
totalWall = next
} else {
totalWall = brushes.wallBrush
@@ -206,6 +218,7 @@ function updateMergedRoofGeometry(
const next: Brush = csgEvaluator.evaluate(totalInner, brushes.innerBrush, ADDITION) as Brush
totalInner.geometry.dispose()
brushes.innerBrush.geometry.dispose()
prepareBrushForCSG(next)
totalInner = next
} else {
totalInner = brushes.innerBrush
@@ -505,6 +518,10 @@ export function getRoofSegmentBrushes(
const toBrush = (geo: THREE.BufferGeometry): Brush | null => {
if (!geo?.attributes.position || geo.attributes.position.count === 0) return null
if (!geo.index) return null
// Strip zero-count groups — three-bvh-csg crashes with groupIndices[i] undefined
// when a group exists but covers no triangles (can happen after mergeVertices)
geo.groups = geo.groups.filter((g) => g.count > 0)
if (geo.groups.length === 0) return null
geo.computeBoundsTree = computeBoundsTree
geo.computeBoundsTree({ maxLeafSize: 10 })
const brush = new Brush(geo, dummyMats)
+79 -34
View File
@@ -42,6 +42,11 @@ function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) {
mesh.geometry.dispose()
mesh.geometry = newGeo
// For negative elevation, shift the mesh down so the top face sits at Y=elevation
// rather than at Y=0. Positive elevation stays at Y=0 (slab sits at floor level).
const elevation = node.elevation ?? 0.05
mesh.position.y = elevation < 0 ? elevation : 0
}
/** Half of default wall thickness — used to extend slab geometry under walls */
@@ -102,54 +107,94 @@ function outsetPolygon(polygon: Array<[number, number]>, amount: number): Array<
* Generates extruded slab geometry from polygon
*/
export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const elevation = slabNode.elevation ?? 0.05
return elevation < 0 ? generatePoolGeometry(slabNode) : generatePositiveSlabGeometry(slabNode)
}
/**
* Standard slab: flat extrusion upward from Y=0 by elevation thickness.
*/
function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const polygon = outsetPolygon(slabNode.polygon, SLAB_OUTSET)
const elevation = slabNode.elevation ?? 0.05
if (polygon.length < 3) {
return new THREE.BufferGeometry()
}
if (polygon.length < 3) return new THREE.BufferGeometry()
// Create shape from polygon
// Shape is in X-Y plane, we'll rotate to X-Z plane after extrusion
const shape = new THREE.Shape()
const firstPt = polygon[0]!
// Negate Y (which becomes Z) to get correct orientation after rotation
shape.moveTo(firstPt[0], -firstPt[1])
for (let i = 1; i < polygon.length; i++) {
const pt = polygon[i]!
shape.lineTo(pt[0], -pt[1])
}
shape.moveTo(polygon[0]![0], -polygon[0]![1])
for (let i = 1; i < polygon.length; i++) shape.lineTo(polygon[i]![0], -polygon[i]![1])
shape.closePath()
// Add holes to the shape
const holes = slabNode.holes || []
for (const holePolygon of holes) {
for (const holePolygon of slabNode.holes ?? []) {
if (holePolygon.length < 3) continue
const holePath = new THREE.Path()
const holeFirstPt = holePolygon[0]!
holePath.moveTo(holeFirstPt[0], -holeFirstPt[1])
for (let i = 1; i < holePolygon.length; i++) {
const pt = holePolygon[i]!
holePath.lineTo(pt[0], -pt[1])
}
holePath.moveTo(holePolygon[0]![0], -holePolygon[0]![1])
for (let i = 1; i < holePolygon.length; i++)
holePath.lineTo(holePolygon[i]![0], -holePolygon[i]![1])
holePath.closePath()
shape.holes.push(holePath)
}
// Extrude the shape by elevation
const geometry = new THREE.ExtrudeGeometry(shape, {
depth: elevation,
bevelEnabled: false,
})
// Rotate so extrusion direction (Z) becomes height direction (Y)
const geometry = new THREE.ExtrudeGeometry(shape, { depth: elevation, bevelEnabled: false })
geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals()
return geometry
}
/**
* Pool / recessed slab: floor cap at Y=0 (local) + inner walls up to Y=|elevation|.
* No top cap — the opening at ground level is handled by the ground occluder hole.
* mesh.position.y must be set to elevation so the floor sits at the correct world Y.
*
* Geometry is built directly in 3D (Y-up) to avoid rotation confusion:
* - floor in XZ plane at Y=0, normals pointing +Y (visible when looking down into pool)
* - walls from Y=0 to Y=depth, inward-facing normals (visible from inside pool)
*/
function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const polygon = outsetPolygon(slabNode.polygon, SLAB_OUTSET)
const depth = Math.abs(slabNode.elevation ?? 0.05)
if (polygon.length < 3) return new THREE.BufferGeometry()
const positions: number[] = []
const indices: number[] = []
const n = polygon.length
// --- Floor at Y=0 ---
for (const [x, z] of polygon) positions.push(x!, 0, z!)
const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!))
const holesPts2d = (slabNode.holes ?? []).map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
for (const hole of slabNode.holes ?? []) {
for (const [x, z] of hole) positions.push(x!, 0, z!)
}
const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d)
for (const tri of floorTris) {
// Reversed winding → normals point +Y (upward) in XZ plane
indices.push(tri[0]!, tri[2]!, tri[1]!)
}
// --- Inner walls (no top cap at Y=depth) ---
// Standard winding on a CCW polygon in XZ gives inward-facing normals.
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const [x0, z0] = polygon[i]!
const [x1, z1] = polygon[j]!
const vBase = positions.length / 3
positions.push(x0!, 0, z0!) // v0 — floor level
positions.push(x1!, 0, z1!) // v1 — floor level
positions.push(x1!, depth, z1!) // v2 — ground level
positions.push(x0!, depth, z0!) // v3 — ground level
indices.push(vBase, vBase + 1, vBase + 2)
indices.push(vBase, vBase + 2, vBase + 3)
}
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geo.setIndex(indices)
geo.computeVertexNormals()
return geo
}
@@ -22,6 +22,7 @@ const csgEvaluator = new Evaluator()
// WALL SYSTEM
// ============================================================================
let useFrameNb = 0
export const WallSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty)
@@ -34,6 +35,7 @@ export const WallSystem = () => {
// Collect dirty walls and their levels
const dirtyWallsByLevel = new Map<string, Set<string>>()
useFrameNb += 1
dirtyNodes.forEach((id) => {
const node = nodes[id]
if (!node || node.type !== 'wall') return
@@ -1,28 +1,10 @@
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import { baseMaterial, glassMaterial } from '../../materials'
import type { AnyNodeId, WindowNode } from '../../schema'
import useScene from '../../store/use-scene'
const glassMaterial = new MeshStandardNodeMaterial({
name: 'glass',
color: 'lightblue',
roughness: 0.05,
metalness: 0.1,
transparent: true,
opacity: 0.3,
side: DoubleSide,
depthWrite: false,
})
const frameMaterial = new MeshStandardNodeMaterial({
name: 'window-frame',
color: '#e8e8e8',
roughness: 0.6,
metalness: 0,
})
// Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
@@ -108,7 +90,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
// Top / bottom — full width
addBox(
mesh,
frameMaterial,
baseMaterial,
width,
frameThickness,
frameDepth,
@@ -118,7 +100,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
)
addBox(
mesh,
frameMaterial,
baseMaterial,
width,
frameThickness,
frameDepth,
@@ -129,7 +111,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
// Left / right — inner height to avoid corner overlap
addBox(
mesh,
frameMaterial,
baseMaterial,
frameThickness,
innerH,
frameDepth,
@@ -139,7 +121,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
)
addBox(
mesh,
frameMaterial,
baseMaterial,
frameThickness,
innerH,
frameDepth,
@@ -184,7 +166,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
cx += colWidths[c]!
addBox(
mesh,
frameMaterial,
baseMaterial,
columnDividerThickness,
innerH,
frameDepth,
@@ -203,7 +185,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
for (let c = 0; c < numCols; c++) {
addBox(
mesh,
frameMaterial,
baseMaterial,
colWidths[c]!,
rowDividerThickness,
frameDepth,
@@ -239,7 +221,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
const sillZ = frameDepth / 2 + sillDepth / 2
addBox(
mesh,
frameMaterial,
baseMaterial,
sillW,
sillThickness,
sillDepth,