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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user