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:
@@ -10,6 +10,11 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./clone-scene-graph": {
|
||||
"types": "./dist/utils/clone-scene-graph.d.ts",
|
||||
"import": "./dist/utils/clone-scene-graph.js",
|
||||
"default": "./dist/utils/clone-scene-graph.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useLayoutEffect } from 'react'
|
||||
import type * as THREE from 'three'
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Store
|
||||
|
||||
export type {
|
||||
BuildingEvent,
|
||||
CameraControlEvent,
|
||||
@@ -20,9 +18,7 @@ export type {
|
||||
WindowEvent,
|
||||
ZoneEvent,
|
||||
} from './events/bus'
|
||||
// Events
|
||||
export { emitter, eventSuffixes } from './events/bus'
|
||||
// Hooks
|
||||
export {
|
||||
sceneRegistry,
|
||||
useRegistry,
|
||||
@@ -33,24 +29,22 @@ export {
|
||||
resolveLevelId,
|
||||
} from './hooks/spatial-grid/spatial-grid-sync'
|
||||
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
|
||||
// Asset storage
|
||||
export { loadAssetUrl, saveAsset } from './lib/asset-storage'
|
||||
// Space detection
|
||||
export {
|
||||
detectSpacesForLevel,
|
||||
initSpaceDetectionSync,
|
||||
type Space,
|
||||
wallTouchesOthers,
|
||||
} from './lib/space-detection'
|
||||
// Schema
|
||||
export { baseMaterial, glassMaterial } from './materials'
|
||||
export * from './schema'
|
||||
export {
|
||||
type ControlValue,
|
||||
type ItemInteractiveState,
|
||||
useInteractive,
|
||||
} from './store/use-interactive'
|
||||
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
|
||||
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
||||
// Systems
|
||||
export { CeilingSystem } from './systems/ceiling/ceiling-system'
|
||||
export { DoorSystem } from './systems/door/door-system'
|
||||
export { ItemSystem } from './systems/item/item-system'
|
||||
@@ -71,5 +65,6 @@ export {
|
||||
} from './systems/wall/wall-mitering'
|
||||
export { WallSystem } from './systems/wall/wall-system'
|
||||
export { WindowSystem } from './systems/window/window-system'
|
||||
export type { SceneGraph } from './utils/clone-scene-graph'
|
||||
export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
|
||||
export { isObject } from './utils/types'
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
||||
|
||||
/**
|
||||
* Shared base material for structural elements: walls, frames, slabs, roof.
|
||||
*/
|
||||
export const baseMaterial = new MeshStandardNodeMaterial({
|
||||
color: '#f2f0ed',
|
||||
roughness: 0.5,
|
||||
metalness: 0,
|
||||
})
|
||||
|
||||
/**
|
||||
* Shared glass material for windows, glazed door panels, and glass items.
|
||||
*/
|
||||
export const glassMaterial = new MeshStandardNodeMaterial({
|
||||
name: 'glass',
|
||||
color: 'lightblue',
|
||||
roughness: 0.05,
|
||||
metalness: 0.1,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
side: DoubleSide,
|
||||
depthWrite: false,
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
// Base
|
||||
export { BaseNode, generateId, nodeType, objectId } from './base'
|
||||
export { BaseNode, generateId, Material, nodeType, objectId } from './base'
|
||||
// Camera
|
||||
export { CameraSchema } from './camera'
|
||||
// Collections
|
||||
|
||||
@@ -64,7 +64,6 @@ export const updateNodesAction = (
|
||||
) => {
|
||||
if (get().readOnly) return
|
||||
const parentsToUpdate = new Set<AnyNodeId>()
|
||||
const idsToMarkDirty = new Set<AnyNodeId>()
|
||||
|
||||
set((state) => {
|
||||
const nextNodes = { ...state.nodes }
|
||||
@@ -105,26 +104,19 @@ export const updateNodesAction = (
|
||||
return { nodes: nextNodes }
|
||||
})
|
||||
|
||||
// Collect all IDs that need to be marked dirty
|
||||
// Batch dirty-marking into a single RAF to avoid redundant callbacks during rapid updates
|
||||
for (const u of updates) {
|
||||
idsToMarkDirty.add(u.id)
|
||||
pendingUpdates.add(u.id)
|
||||
}
|
||||
for (const pId of parentsToUpdate) {
|
||||
idsToMarkDirty.add(pId)
|
||||
pendingUpdates.add(pId)
|
||||
}
|
||||
|
||||
// Add to pending updates set
|
||||
for (const id of idsToMarkDirty) {
|
||||
pendingUpdates.add(id)
|
||||
}
|
||||
|
||||
// Cancel any pending RAF and schedule a new one
|
||||
if (pendingRafId !== null) {
|
||||
cancelAnimationFrame(pendingRafId)
|
||||
}
|
||||
|
||||
pendingRafId = requestAnimationFrame(() => {
|
||||
// Mark all pending updates as dirty
|
||||
pendingUpdates.forEach((id) => {
|
||||
get().markDirty(id)
|
||||
})
|
||||
@@ -146,32 +138,26 @@ export const deleteNodesAction = (
|
||||
const nextCollections = { ...state.collections }
|
||||
let nextRootIds = [...state.rootNodeIds]
|
||||
|
||||
// Collect all IDs to delete (including descendants) in a first pass
|
||||
// This avoids issues with recursive calls during state mutation
|
||||
const allIdsToDelete = new Set<AnyNodeId>()
|
||||
const collectDescendants = (id: AnyNodeId) => {
|
||||
// Collect all ids to delete (the requested ids + all their descendants) before
|
||||
// mutating anything, so the recursive walk reads consistent state.
|
||||
const allIds = new Set<AnyNodeId>()
|
||||
const collect = (id: AnyNodeId) => {
|
||||
if (allIds.has(id)) return
|
||||
allIds.add(id)
|
||||
const node = nextNodes[id]
|
||||
if (!node) return
|
||||
allIdsToDelete.add(id)
|
||||
if ('children' in node && node.children) {
|
||||
for (const childId of node.children as AnyNodeId[]) {
|
||||
collectDescendants(childId)
|
||||
}
|
||||
if (node && 'children' in node) {
|
||||
for (const cid of node.children as AnyNodeId[]) collect(cid)
|
||||
}
|
||||
}
|
||||
for (const id of ids) collect(id)
|
||||
|
||||
for (const id of ids) {
|
||||
collectDescendants(id)
|
||||
}
|
||||
|
||||
// Now process all nodes for deletion
|
||||
for (const id of allIdsToDelete) {
|
||||
for (const id of allIds) {
|
||||
const node = nextNodes[id]
|
||||
if (!node) continue
|
||||
|
||||
// 1. Remove reference from Parent
|
||||
// 1. Remove reference from parent — only if the parent itself is NOT also being deleted
|
||||
const parentId = node.parentId as AnyNodeId | null
|
||||
if (parentId && nextNodes[parentId]) {
|
||||
if (parentId && nextNodes[parentId] && !allIds.has(parentId)) {
|
||||
const parent = nextNodes[parentId] as AnyContainerNode
|
||||
if (parent.children) {
|
||||
nextNodes[parent.id] = {
|
||||
@@ -182,7 +168,7 @@ export const deleteNodesAction = (
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove from Root list
|
||||
// 2. Remove from root list
|
||||
nextRootIds = nextRootIds.filter((rid) => rid !== id)
|
||||
|
||||
// 3. Remove from any collections it belongs to
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Ephemeral live transform state for nodes being actively dragged/moved.
|
||||
// This decouples 2D (floorplan) and 3D (viewer) so neither needs to peek
|
||||
// into the other's scene graph during drag operations.
|
||||
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type LiveTransform = {
|
||||
position: [number, number, number]
|
||||
rotation: number // Y-axis rotation (plan-view rotation)
|
||||
}
|
||||
|
||||
type LiveTransformState = {
|
||||
transforms: Map<string, LiveTransform>
|
||||
set(nodeId: string, transform: LiveTransform): void
|
||||
get(nodeId: string): LiveTransform | undefined
|
||||
clear(nodeId: string): void
|
||||
clearAll(): void
|
||||
}
|
||||
|
||||
const useLiveTransforms = create<LiveTransformState>((set, get) => ({
|
||||
transforms: new Map(),
|
||||
set: (nodeId, transform) =>
|
||||
set((state) => {
|
||||
const next = new Map(state.transforms)
|
||||
next.set(nodeId, transform)
|
||||
return { transforms: next }
|
||||
}),
|
||||
get: (nodeId) => get().transforms.get(nodeId),
|
||||
clear: (nodeId) =>
|
||||
set((state) => {
|
||||
const next = new Map(state.transforms)
|
||||
next.delete(nodeId)
|
||||
return { transforms: next }
|
||||
}),
|
||||
clearAll: () => set({ transforms: new Map() }),
|
||||
}))
|
||||
|
||||
export default useLiveTransforms
|
||||
@@ -123,11 +123,6 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
setReadOnly: (readOnly: boolean) => set({ readOnly }),
|
||||
|
||||
unloadScene: () => {
|
||||
// Clear temporal tracking to prevent memory leaks from stale node references
|
||||
prevPastLength = 0
|
||||
prevFutureLength = 0
|
||||
prevNodesSnapshot = null
|
||||
|
||||
set({
|
||||
nodes: {},
|
||||
rootNodeIds: [],
|
||||
@@ -145,14 +140,29 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
// Apply backward compatibility migrations
|
||||
const patchedNodes = migrateNodes(nodes)
|
||||
|
||||
// Remove orphans: nodes whose parentId points to a non-existent node
|
||||
const cleanedNodes = { ...patchedNodes }
|
||||
for (const node of Object.values(cleanedNodes)) {
|
||||
if (node.parentId && !cleanedNodes[node.parentId]) {
|
||||
console.warn(
|
||||
'[Scene] Removing orphan node',
|
||||
node.id,
|
||||
'(parentId',
|
||||
node.parentId,
|
||||
'not found)',
|
||||
)
|
||||
delete cleanedNodes[node.id]
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
nodes: patchedNodes,
|
||||
nodes: cleanedNodes,
|
||||
rootNodeIds,
|
||||
dirtyNodes: new Set<AnyNodeId>(),
|
||||
collections: {},
|
||||
})
|
||||
// Mark all nodes as dirty to trigger re-validation
|
||||
Object.values(patchedNodes).forEach((node) => {
|
||||
Object.values(cleanedNodes).forEach((node) => {
|
||||
get().markDirty(node.id)
|
||||
})
|
||||
},
|
||||
@@ -292,7 +302,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
if (!col) return state
|
||||
const nextCollections = {
|
||||
...state.collections,
|
||||
[id]: { ...col, nodeIds: col.nodeIds.filter((n: AnyNodeId) => n !== nodeId) },
|
||||
[id]: { ...col, nodeIds: col.nodeIds.filter((n) => n !== nodeId) },
|
||||
}
|
||||
const node = state.nodes[nodeId]
|
||||
if (!(node && 'collectionIds' in node)) return { collections: nextCollections }
|
||||
@@ -324,21 +334,13 @@ let prevPastLength = 0
|
||||
let prevFutureLength = 0
|
||||
let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
|
||||
|
||||
/**
|
||||
* Clears temporal history tracking variables to prevent memory leaks.
|
||||
* Should be called when unloading a scene to release node references.
|
||||
*/
|
||||
export function clearTemporalTracking() {
|
||||
export function clearSceneHistory() {
|
||||
useScene.temporal.getState().clear()
|
||||
prevPastLength = 0
|
||||
prevFutureLength = 0
|
||||
prevNodesSnapshot = null
|
||||
}
|
||||
|
||||
export function clearSceneHistory() {
|
||||
useScene.temporal.getState().clear()
|
||||
clearTemporalTracking()
|
||||
}
|
||||
|
||||
// Subscribe to the temporal store (Undo/Redo events)
|
||||
useScene.temporal.subscribe((state) => {
|
||||
const currentPastLength = state.pastStates.length
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -21,8 +21,8 @@ function extractIdPrefix(id: string): string {
|
||||
* parent-child relationships and other internal references.
|
||||
*
|
||||
* This is useful for:
|
||||
* - Duplicating a project (host app creates a new project record, then loads the cloned scene)
|
||||
* - Copying nodes between different projects
|
||||
* - Duplicating a subset of a scene within the same project
|
||||
* - Multi-scene in-memory scenarios
|
||||
*/
|
||||
export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||
@@ -42,7 +42,6 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||
|
||||
for (const [oldId, node] of Object.entries(nodes)) {
|
||||
const newId = idMap.get(oldId)! as AnyNodeId
|
||||
// structuredClone to avoid shared references between original and clone
|
||||
const clonedNode = structuredClone({ ...node, id: newId }) as AnyNode
|
||||
|
||||
// Remap parentId
|
||||
@@ -50,10 +49,23 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||
clonedNode.parentId = (idMap.get(clonedNode.parentId) ?? null) as AnyNodeId | null
|
||||
}
|
||||
|
||||
// Remap children array (walls, levels, buildings, sites, items can have children)
|
||||
// Remap children array (buildings, levels, walls, items, etc.)
|
||||
// Children can be either string IDs or embedded node objects (with an `id` property).
|
||||
// Normalize both forms to remapped string IDs.
|
||||
if ('children' in clonedNode && Array.isArray(clonedNode.children)) {
|
||||
;(clonedNode as Record<string, unknown>).children = (clonedNode.children as string[])
|
||||
.map((childId) => idMap.get(childId))
|
||||
;(clonedNode as Record<string, unknown>).children = (clonedNode.children as unknown[])
|
||||
.map((child) => {
|
||||
if (typeof child === 'string') return idMap.get(child)
|
||||
if (
|
||||
child &&
|
||||
typeof child === 'object' &&
|
||||
'id' in child &&
|
||||
typeof (child as any).id === 'string'
|
||||
) {
|
||||
return idMap.get((child as any).id)
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
.filter((id): id is string => id !== undefined)
|
||||
}
|
||||
|
||||
@@ -78,7 +90,6 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||
clonedCollections = {} as Record<CollectionId, Collection>
|
||||
const collectionIdMap = new Map<string, CollectionId>()
|
||||
|
||||
// Generate new collection IDs
|
||||
for (const collectionId of Object.keys(collections)) {
|
||||
collectionIdMap.set(collectionId, generateId('collection'))
|
||||
}
|
||||
@@ -166,6 +177,9 @@ export function cloneLevelSubtree(
|
||||
const newLevelId = idMap.get(levelId)! as AnyNodeId
|
||||
|
||||
// Clone each node with remapped references.
|
||||
// Use JSON roundtrip instead of structuredClone because live runtime nodes may
|
||||
// carry non-serializable properties (Three.js Object3D refs, functions, etc.)
|
||||
// that structuredClone would throw on.
|
||||
const clonedNodes: AnyNode[] = []
|
||||
for (const oldId of subtreeIds) {
|
||||
const node = nodes[oldId]
|
||||
@@ -178,6 +192,7 @@ export function cloneLevelSubtree(
|
||||
;(cloned as Record<string, unknown>).id = newId
|
||||
|
||||
// Remap parentId — but only for descendants, not the level node itself
|
||||
// (the level's parentId points to the building, which is outside the subtree)
|
||||
if (oldId !== levelId && cloned.parentId && typeof cloned.parentId === 'string') {
|
||||
cloned.parentId = (idMap.get(cloned.parentId) ?? cloned.parentId) as AnyNodeId | null
|
||||
}
|
||||
@@ -219,6 +234,7 @@ export function cloneLevelSubtree(
|
||||
export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||
const { nodes, rootNodeIds, collections } = sceneGraph
|
||||
|
||||
// First, identify scan and guide node IDs to exclude (user-uploaded imagery)
|
||||
const excludedNodeIds = new Set<string>()
|
||||
for (const [nodeId, node] of Object.entries(nodes)) {
|
||||
if (node.type === 'scan' || node.type === 'guide') {
|
||||
@@ -226,12 +242,15 @@ export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||
}
|
||||
}
|
||||
|
||||
// Build a filtered scene graph without scan nodes
|
||||
const filteredNodes = {} as Record<AnyNodeId, AnyNode>
|
||||
for (const [nodeId, node] of Object.entries(nodes)) {
|
||||
if (excludedNodeIds.has(nodeId)) continue
|
||||
|
||||
const clonedNode = structuredClone(node) as AnyNode
|
||||
|
||||
// Remove scan children from any parent that references them.
|
||||
// Children can be string IDs or embedded node objects.
|
||||
if ('children' in clonedNode && Array.isArray(clonedNode.children)) {
|
||||
;(clonedNode as Record<string, unknown>).children = (clonedNode.children as unknown[]).filter(
|
||||
(child) => {
|
||||
@@ -251,6 +270,7 @@ export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||
|
||||
const filteredRootNodeIds = rootNodeIds.filter((id) => !excludedNodeIds.has(id))
|
||||
|
||||
// Filter collections to remove references to scan nodes
|
||||
let filteredCollections: Record<CollectionId, Collection> | undefined
|
||||
if (collections) {
|
||||
filteredCollections = {} as Record<CollectionId, Collection>
|
||||
@@ -269,6 +289,7 @@ export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||
}
|
||||
}
|
||||
|
||||
// Now clone the filtered graph with new IDs
|
||||
return cloneSceneGraph({
|
||||
nodes: filteredNodes,
|
||||
rootNodeIds: filteredRootNodeIds,
|
||||
|
||||
Reference in New Issue
Block a user