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
+27 -6
View File
@@ -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,