From be4151147456cc5f36643908b03035b10352386f Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 11 May 2026 23:46:57 +0530 Subject: [PATCH] Fix wall move junction ownership --- packages/core/src/index.ts | 9 + .../core/src/store/actions/node-actions.ts | 142 +++++++- packages/core/src/store/use-scene.ts | 6 + packages/core/src/systems/wall/wall-move.ts | 227 +++++++++++++ .../components/editor/selection-manager.tsx | 12 +- .../components/tools/wall/move-wall-tool.tsx | 321 ++++++++++++++---- .../renderers/item/item-renderer.tsx | 3 + .../renderers/wall/wall-renderer.tsx | 45 ++- packages/viewer/src/lib/materials.ts | 10 +- 9 files changed, 694 insertions(+), 81 deletions(-) create mode 100644 packages/core/src/systems/wall/wall-move.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d3875421..e147e18c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -107,6 +107,15 @@ export { type WallMiterBoundaryPoints, type WallMiterData, } from './systems/wall/wall-mitering' +export { + constrainWallMoveDeltaToAxis, + getPerpendicularWallMoveAxis, + planWallMoveJunctions, + type WallMoveBridgePlan, + type WallMoveAxis, + type WallMoveJunctionPlan, + type WallPlanPoint, +} from './systems/wall/wall-move' export type { SceneGraph } from './utils/clone-scene-graph' export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph' export { isObject } from './utils/types' diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index d9e50b79..b65c03d0 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -9,6 +9,9 @@ import type { CollectionId } from '../../schema/collections' import type { SceneState } from '../use-scene' type AnyContainerNode = AnyNode & { children: string[] } +type NodeCreateOp = { node: AnyNode; parentId?: AnyNodeId } +type NodeUpdateOp = { id: AnyNodeId; data: Partial } +type NodeDeleteOp = AnyNodeId type WallAttachmentUpdate = { id: AnyNodeId; data: Partial } type WallMergePlan = { primaryWallId: AnyNodeId @@ -230,7 +233,7 @@ function buildWallMergePlans( export const createNodesAction = ( set: (fn: (state: SceneState) => Partial) => void, get: () => SceneState, - ops: { node: AnyNode; parentId?: AnyNodeId }[], + ops: NodeCreateOp[], ) => { if (get().readOnly) return set((state) => { @@ -278,6 +281,143 @@ export const createNodesAction = ( }) } +export const applyNodeChangesAction = ( + set: (fn: (state: SceneState) => Partial) => void, + get: () => SceneState, + changes: { create?: NodeCreateOp[]; update?: NodeUpdateOp[]; delete?: NodeDeleteOp[] }, +) => { + if (get().readOnly) return + + const createOps = changes.create ?? [] + const updateOps = changes.update ?? [] + const deleteOps = changes.delete ?? [] + const nodesToMarkDirty = new Set() + const parentsToMarkDirty = new Set() + + set((state) => { + const nextNodes = { ...state.nodes } + const nextCollections = { ...state.collections } + const nextRootIds = [...state.rootNodeIds] + let resolvedRootIds = nextRootIds + + for (const { id, data } of updateOps) { + const currentNode = nextNodes[id] + if (!currentNode) continue + + if (data.parentId !== undefined && data.parentId !== currentNode.parentId) { + const oldParentId = currentNode.parentId as AnyNodeId | null + if (oldParentId && nextNodes[oldParentId]) { + const oldParent = nextNodes[oldParentId] as AnyContainerNode + nextNodes[oldParent.id] = { + ...oldParent, + children: oldParent.children.filter((childId) => childId !== id), + } as AnyNode + parentsToMarkDirty.add(oldParent.id) + } + + const newParentId = data.parentId as AnyNodeId | null + if (newParentId && nextNodes[newParentId]) { + const newParent = nextNodes[newParentId] as AnyContainerNode + nextNodes[newParent.id] = { + ...newParent, + children: Array.from(new Set([...newParent.children, id])), + } as AnyNode + parentsToMarkDirty.add(newParent.id) + } + } + + nextNodes[id] = { ...currentNode, ...data } as AnyNode + nodesToMarkDirty.add(id) + } + + for (const { node, parentId } of createOps) { + const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null + const newNode = { + ...node, + parentId: effectiveParentId, + } as AnyNode + + nextNodes[newNode.id as AnyNodeId] = newNode + nodesToMarkDirty.add(newNode.id as AnyNodeId) + + if (effectiveParentId && nextNodes[effectiveParentId]) { + const parent = nextNodes[effectiveParentId] + if ('children' in parent && Array.isArray(parent.children)) { + nextNodes[effectiveParentId] = { + ...parent, + children: Array.from(new Set([...parent.children, newNode.id])) as any, + } + parentsToMarkDirty.add(effectiveParentId) + } + } else if (!effectiveParentId && !nextRootIds.includes(newNode.id as AnyNodeId)) { + nextRootIds.push(newNode.id as AnyNodeId) + } + } + + const allIdsToDelete = new Set() + const collectDelete = (id: AnyNodeId) => { + if (allIdsToDelete.has(id)) return + allIdsToDelete.add(id) + const node = nextNodes[id] + if (node && 'children' in node && Array.isArray(node.children)) { + for (const childId of node.children) { + collectDelete(childId as AnyNodeId) + } + } + } + + for (const id of deleteOps) { + collectDelete(id) + } + + for (const id of allIdsToDelete) { + const node = nextNodes[id] + if (!node) continue + + const parentId = node.parentId as AnyNodeId | null + if (parentId && nextNodes[parentId] && !allIdsToDelete.has(parentId)) { + const parent = nextNodes[parentId] as AnyContainerNode + if (parent.children) { + nextNodes[parent.id] = { + ...parent, + children: parent.children.filter((childId) => childId !== id), + } as AnyNode + parentsToMarkDirty.add(parent.id) + } + } + + resolvedRootIds = resolvedRootIds.filter((rootId) => rootId !== id) + + if ('collectionIds' in node && node.collectionIds) { + for (const collectionId of node.collectionIds as CollectionId[]) { + const collection = nextCollections[collectionId] + if (collection) { + nextCollections[collectionId] = { + ...collection, + nodeIds: collection.nodeIds.filter((nodeId) => nodeId !== id), + } + } + } + } + + delete nextNodes[id] + } + + return { nodes: nextNodes, rootNodeIds: resolvedRootIds, collections: nextCollections } + }) + + nodesToMarkDirty.forEach((id) => get().markDirty(id)) + parentsToMarkDirty.forEach((id) => { + get().markDirty(id) + const parent = get().nodes[id] + if (parent && 'children' in parent && Array.isArray(parent.children)) { + for (const childId of parent.children) { + get().markDirty(childId as AnyNodeId) + } + } + }) +} + export const updateNodesAction = ( set: (fn: (state: SceneState) => Partial) => void, get: () => SceneState, diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 51e1caa9..dece8061 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -438,6 +438,11 @@ export type SceneState = { createNode: (node: AnyNode, parentId?: AnyNodeId) => void createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void + applyNodeChanges: (changes: { + create?: { node: AnyNode; parentId?: AnyNodeId }[] + update?: { id: AnyNodeId; data: Partial }[] + delete?: AnyNodeId[] + }) => void updateNode: (id: AnyNodeId, data: Partial) => void updateNodes: (updates: { id: AnyNodeId; data: Partial }[]) => void @@ -579,6 +584,7 @@ const useScene: UseSceneStore = create()( createNodes: (ops) => nodeActions.createNodesAction(set, get, ops), createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]), + applyNodeChanges: (changes) => nodeActions.applyNodeChangesAction(set, get, changes), updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates), updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]), diff --git a/packages/core/src/systems/wall/wall-move.ts b/packages/core/src/systems/wall/wall-move.ts new file mode 100644 index 00000000..e9b6595c --- /dev/null +++ b/packages/core/src/systems/wall/wall-move.ts @@ -0,0 +1,227 @@ +import type { WallNode } from '../../schema' + +const AXIS_EPSILON = 1e-6 + +export type WallPlanPoint = [number, number] +export type WallMoveAxis = 'x' | 'z' +export type WallMoveEndpoint = 'start' | 'end' + +export type WallMoveBridgePlan> = { + wall: TWall + originalPoint: WallPlanPoint + movedEndpoint: WallMoveEndpoint +} + +export type WallMoveLinkedWallTargetPlan< + TWall extends Pick, +> = { + wall: TWall + originalPoint: WallPlanPoint + targetPoint: WallPlanPoint +} + +export type WallMoveJunctionPlan> = { + linkedWallsToMove: TWall[] + linkedWallTargetPlans: Array> + bridgePlans: Array> + wallsToDelete: TWall[] +} + +export function getPerpendicularWallMoveAxis( + start: WallPlanPoint, + end: WallPlanPoint, +): WallMoveAxis | null { + const wallDeltaX = Math.abs(end[0] - start[0]) + const wallDeltaZ = Math.abs(end[1] - start[1]) + + if (wallDeltaX < AXIS_EPSILON && wallDeltaZ < AXIS_EPSILON) return null + + return wallDeltaX >= wallDeltaZ ? 'z' : 'x' +} + +export function constrainWallMoveDeltaToAxis( + deltaX: number, + deltaZ: number, + axis: WallMoveAxis | null, +): WallPlanPoint { + if (axis === 'x') return [deltaX, 0] + if (axis === 'z') return [0, deltaZ] + return [deltaX, deltaZ] +} + +function pointsEqual(a: WallPlanPoint, b: WallPlanPoint) { + return Math.abs(a[0] - b[0]) <= AXIS_EPSILON && Math.abs(a[1] - b[1]) <= AXIS_EPSILON +} + +function wallTouchesPoint(wall: Pick, point: WallPlanPoint) { + return pointsEqual(wall.start, point) || pointsEqual(wall.end, point) +} + +function otherWallEndpoint(wall: Pick, point: WallPlanPoint) { + return pointsEqual(wall.start, point) ? wall.end : wall.start +} + +type MoveWallRelation = 'same-direction' | 'opposite-direction' | 'off-axis' | 'stationary' +type RelatedWallEntry> = { + wall: TWall + relation: MoveWallRelation +} + +function wallLengthFromPoint(wall: Pick, point: WallPlanPoint) { + const freeEndpoint = otherWallEndpoint(wall, point) + return Math.hypot(freeEndpoint[0] - point[0], freeEndpoint[1] - point[1]) +} + +function getMoveWallRelation( + wall: Pick, + sharedPoint: WallPlanPoint, + nextPoint: WallPlanPoint, +): MoveWallRelation { + const moveX = nextPoint[0] - sharedPoint[0] + const moveZ = nextPoint[1] - sharedPoint[1] + const moveLength = Math.hypot(moveX, moveZ) + + if (moveLength < AXIS_EPSILON) return 'stationary' + + const freeEndpoint = otherWallEndpoint(wall, sharedPoint) + const wallX = freeEndpoint[0] - sharedPoint[0] + const wallZ = freeEndpoint[1] - sharedPoint[1] + const wallLength = Math.hypot(wallX, wallZ) + + if (wallLength < AXIS_EPSILON) return 'stationary' + + const normalizedCross = Math.abs(moveX * wallZ - moveZ * wallX) / (moveLength * wallLength) + if (normalizedCross > 1e-4) return 'off-axis' + + const normalizedDot = (moveX * wallX + moveZ * wallZ) / (moveLength * wallLength) + return normalizedDot >= 0 ? 'same-direction' : 'opposite-direction' +} + +export function planWallMoveJunctions>( + linkedWalls: TWall[], + originalStart: WallPlanPoint, + originalEnd: WallPlanPoint, + nextStart: WallPlanPoint, + nextEnd: WallPlanPoint, +): WallMoveJunctionPlan { + const linkedWallsToMove = new Map() + const linkedWallTargetPlans = new Map>() + const bridgePlans = new Map>() + const wallsToDelete = new Map() + + const addStandardEndpointPlan = ( + endpoint: WallMoveEndpoint, + point: WallPlanPoint, + nextPoint: WallPlanPoint, + relatedWalls: Array>, + keySuffix = '', + useTargetPlans = false, + ) => { + const hasSideBranch = relatedWalls.some((entry) => entry.relation === 'off-axis') + const hasOppositeBridge = relatedWalls.some( + (entry) => entry.relation === 'opposite-direction' && hasSideBranch, + ) + + for (const { wall, relation } of relatedWalls) { + if ( + relation === 'stationary' || + relation === 'same-direction' || + (relation === 'opposite-direction' && !hasSideBranch) + ) { + if (useTargetPlans) { + linkedWallTargetPlans.set(wall.id, { + wall, + originalPoint: point, + targetPoint: nextPoint, + }) + } else { + linkedWallsToMove.set(wall.id, wall) + } + continue + } + + if (relation === 'off-axis' && hasOppositeBridge) { + continue + } + + bridgePlans.set(`${wall.id}:${endpoint}${keySuffix}`, { + wall, + originalPoint: point, + movedEndpoint: endpoint, + }) + } + } + + const addEndpointPlan = ( + endpoint: WallMoveEndpoint, + point: WallPlanPoint, + nextPoint: WallPlanPoint, + ) => { + const moveLength = Math.hypot(nextPoint[0] - point[0], nextPoint[1] - point[1]) + const linkedAtEndpoint = linkedWalls + .filter((wall) => wallTouchesPoint(wall, point)) + .map((wall) => ({ + wall, + relation: getMoveWallRelation(wall, point, nextPoint), + })) + const consumedSameDirectionWall = linkedAtEndpoint + .filter((entry) => entry.relation === 'same-direction') + .map((entry) => ({ + ...entry, + distance: wallLengthFromPoint(entry.wall, point), + })) + .filter((entry) => moveLength + AXIS_EPSILON >= entry.distance) + .sort((a, b) => a.distance - b.distance)[0] + + if (consumedSameDirectionWall) { + const pivotPoint = [...otherWallEndpoint(consumedSameDirectionWall.wall, point)] as WallPlanPoint + const bridgeSource = linkedAtEndpoint.find((entry) => entry.relation === 'opposite-direction') + + wallsToDelete.set(consumedSameDirectionWall.wall.id, consumedSameDirectionWall.wall) + linkedWallTargetPlans.set(consumedSameDirectionWall.wall.id, { + wall: consumedSameDirectionWall.wall, + originalPoint: point, + targetPoint: pivotPoint, + }) + + if (bridgeSource) { + linkedWallTargetPlans.set(bridgeSource.wall.id, { + wall: bridgeSource.wall, + originalPoint: point, + targetPoint: pivotPoint, + }) + + bridgePlans.set(`${bridgeSource.wall.id}:${endpoint}:through`, { + wall: bridgeSource.wall, + originalPoint: pivotPoint, + movedEndpoint: endpoint, + }) + return + } + + const linkedAtPivot = linkedWalls + .filter( + (wall) => wall.id !== consumedSameDirectionWall.wall.id && wallTouchesPoint(wall, pivotPoint), + ) + .map((wall) => ({ + wall, + relation: getMoveWallRelation(wall, pivotPoint, nextPoint), + })) + + addStandardEndpointPlan(endpoint, pivotPoint, nextPoint, linkedAtPivot, ':through-pivot', true) + return + } + + addStandardEndpointPlan(endpoint, point, nextPoint, linkedAtEndpoint) + } + + addEndpointPlan('start', originalStart, nextStart) + addEndpointPlan('end', originalEnd, nextEnd) + + return { + linkedWallsToMove: Array.from(linkedWallsToMove.values()), + linkedWallTargetPlans: Array.from(linkedWallTargetPlans.values()), + bridgePlans: Array.from(bridgePlans.values()), + wallsToDelete: Array.from(wallsToDelete.values()), + } +} diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 71f278cf..af365545 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -1636,6 +1636,7 @@ const EditorOutlinerSync = () => { const previewSelectedIds = useViewer((s) => s.previewSelectedIds) const hoveredId = useViewer((s) => s.hoveredId) const outliner = useViewer((s) => s.outliner) + const nodes = useScene((s) => s.nodes) useEffect(() => { let idsToHighlight: string[] = [] @@ -1672,16 +1673,21 @@ const EditorOutlinerSync = () => { // 2. Sync with the imperative outliner arrays (mutate in place to keep references) outliner.selectedObjects.length = 0 for (const id of idsToHighlight) { + if (!nodes[id as AnyNodeId]) continue const obj = sceneRegistry.nodes.get(id) if (obj?.parent) outliner.selectedObjects.push(obj) } outliner.hoveredObjects.length = 0 if (hoveredId) { - const obj = sceneRegistry.nodes.get(hoveredId) - if (obj?.parent) outliner.hoveredObjects.push(obj) + if (!nodes[hoveredId as AnyNodeId]) { + useViewer.setState({ hoveredId: null }) + } else { + const obj = sceneRegistry.nodes.get(hoveredId) + if (obj?.parent) outliner.hoveredObjects.push(obj) + } } - }, [phase, previewSelectedIds, selection, hoveredId, outliner]) + }, [phase, previewSelectedIds, selection, hoveredId, outliner, nodes]) return null } diff --git a/packages/editor/src/components/tools/wall/move-wall-tool.tsx b/packages/editor/src/components/tools/wall/move-wall-tool.tsx index cd82aa34..cd9b5ea1 100644 --- a/packages/editor/src/components/tools/wall/move-wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/move-wall-tool.tsx @@ -2,12 +2,19 @@ import { type AnyNodeId, + constrainWallMoveDeltaToAxis, emitter, + getPerpendicularWallMoveAxis, type GridEvent, pauseSceneHistory, + planWallMoveJunctions, resumeSceneHistory, useScene, + type WallMoveBridgePlan, + type WallMoveAxis, + type WallMoveJunctionPlan, type WallNode, + WallNode as WallSchema, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' @@ -15,7 +22,7 @@ import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' -import { getWallGridStep, snapScalarToGrid } from './wall-drafting' +import { getWallGridStep, isWallLongEnough, snapScalarToGrid } from './wall-drafting' function rotateVector([x, z]: [number, number], angle: number): [number, number] { const cos = Math.cos(angle) @@ -27,6 +34,10 @@ function samePoint(a: [number, number], b: [number, number]) { return a[0] === b[0] && a[1] === b[1] } +function pointKey(point: [number, number]) { + return `${point[0]}:${point[1]}` +} + function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'] { if (!meta || typeof meta !== 'object' || Array.isArray(meta)) { return meta @@ -37,11 +48,7 @@ function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata' return nextMeta as WallNode['metadata'] } -type LinkedWallSnapshot = { - id: WallNode['id'] - start: [number, number] - end: [number, number] -} +type LinkedWallSnapshot = WallNode function getLinkedWallSnapshots(args: { wallId: WallNode['id'] @@ -51,30 +58,45 @@ function getLinkedWallSnapshots(args: { }) { const { wallId, wallParentId, originalStart, originalEnd } = args const { nodes } = useScene.getState() + const walls = Object.values(nodes).filter( + (node): node is WallNode => + node?.type === 'wall' && node.id !== wallId && (node.parentId ?? null) === wallParentId, + ) + const directlyLinkedWalls = walls.filter( + (wall) => + samePoint(wall.start, originalStart) || + samePoint(wall.start, originalEnd) || + samePoint(wall.end, originalStart) || + samePoint(wall.end, originalEnd), + ) + const contextPoints = new Set([pointKey(originalStart), pointKey(originalEnd)]) + + for (const wall of directlyLinkedWalls) { + contextPoints.add(pointKey(wall.start)) + contextPoints.add(pointKey(wall.end)) + } + const snapshots: LinkedWallSnapshot[] = [] + const seenWallIds = new Set() - for (const node of Object.values(nodes)) { - if (!(node?.type === 'wall' && node.id !== wallId)) { - continue - } - - if ((node.parentId ?? null) !== wallParentId) { - continue - } - + for (const node of walls) { if ( - !samePoint(node.start, originalStart) && - !samePoint(node.start, originalEnd) && - !samePoint(node.end, originalStart) && - !samePoint(node.end, originalEnd) + !contextPoints.has(pointKey(node.start)) && + !contextPoints.has(pointKey(node.end)) ) { continue } + if (seenWallIds.has(node.id)) { + continue + } + seenWallIds.add(node.id) + snapshots.push({ - id: node.id, + ...node, start: [...node.start] as [number, number], end: [...node.end] as [number, number], + children: [...(node.children ?? [])], }) } @@ -82,25 +104,139 @@ function getLinkedWallSnapshots(args: { } function getLinkedWallUpdates( - linkedWalls: LinkedWallSnapshot[], + linkedWalls: Array<{ + wall: LinkedWallSnapshot + matchPoint?: [number, number] + targetPoint?: [number, number] + }>, originalStart: [number, number], originalEnd: [number, number], nextStart: [number, number], nextEnd: [number, number], ) { - return linkedWalls.map((wall) => ({ - id: wall.id, - start: samePoint(wall.start, originalStart) - ? nextStart - : samePoint(wall.start, originalEnd) - ? nextEnd - : wall.start, - end: samePoint(wall.end, originalStart) - ? nextStart - : samePoint(wall.end, originalEnd) - ? nextEnd - : wall.end, - })) + return linkedWalls.map(({ wall, matchPoint, targetPoint }) => { + if (matchPoint && targetPoint) { + return { + id: wall.id, + start: samePoint(wall.start, matchPoint) ? targetPoint : wall.start, + end: samePoint(wall.end, matchPoint) ? targetPoint : wall.end, + } + } + + const targetStart = targetPoint ?? nextStart + const targetEnd = targetPoint ?? nextEnd + + return { + id: wall.id, + start: samePoint(wall.start, originalStart) + ? targetStart + : samePoint(wall.start, originalEnd) + ? targetEnd + : wall.start, + end: samePoint(wall.end, originalStart) + ? targetStart + : samePoint(wall.end, originalEnd) + ? targetEnd + : wall.end, + } + }) +} + +function getPlannedLinkedWallUpdates( + plan: WallMoveJunctionPlan, + originalStart: [number, number], + originalEnd: [number, number], + nextStart: [number, number], + nextEnd: [number, number], +) { + const movePlans = new Map< + WallNode['id'], + { wall: LinkedWallSnapshot; matchPoint?: [number, number]; targetPoint?: [number, number] } + >() + + for (const wall of plan.linkedWallsToMove) { + movePlans.set(wall.id, { wall }) + } + + for (const targetPlan of plan.linkedWallTargetPlans) { + movePlans.set(targetPlan.wall.id, { + wall: targetPlan.wall, + matchPoint: targetPlan.originalPoint, + targetPoint: targetPlan.targetPoint, + }) + } + + return getLinkedWallUpdates( + Array.from(movePlans.values()), + originalStart, + originalEnd, + nextStart, + nextEnd, + ) +} + +function wallSegmentExists(walls: WallNode[], start: [number, number], end: [number, number]) { + return walls.some( + (wall) => + (samePoint(wall.start, start) && samePoint(wall.end, end)) || + (samePoint(wall.start, end) && samePoint(wall.end, start)), + ) +} + +function getWallsAfterUpdates( + nodes: ReturnType['nodes'], + updates: Array<{ id: AnyNodeId; data: Partial }>, +) { + const updateById = new Map(updates.map((update) => [update.id, update.data])) + + return Object.values(nodes) + .filter((node): node is WallNode => node?.type === 'wall') + .map((wall) => { + const update = updateById.get(wall.id as AnyNodeId) + return update ? ({ ...wall, ...update } as WallNode) : wall + }) +} + +function buildBridgeWallCreates(args: { + bridgePlans: Array> + nextStart: [number, number] + nextEnd: [number, number] + existingWalls: WallNode[] + wallCount: number +}): Array<{ node: WallNode; parentId?: AnyNodeId }> { + const { bridgePlans, nextStart, nextEnd, existingWalls, wallCount } = args + const wallsForDuplicateCheck = [...existingWalls] + const creates: Array<{ node: WallNode; parentId?: AnyNodeId }> = [] + + for (const plan of bridgePlans) { + const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd + + if (!isWallLongEnough(plan.originalPoint, nextPoint)) { + continue + } + + if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) { + continue + } + + const { id: _id, parentId: _parentId, children: _children, ...sourceWall } = plan.wall + const bridgeWall = WallSchema.parse({ + ...sourceWall, + name: `Wall ${wallCount + creates.length + 1}`, + start: plan.originalPoint, + end: nextPoint, + children: [], + metadata: stripWallIsNewMetadata(plan.wall.metadata), + }) + + creates.push({ + node: bridgeWall, + parentId: (plan.wall.parentId ?? undefined) as AnyNodeId | undefined, + }) + wallsForDuplicateCheck.push(bridgeWall) + } + + return creates } export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { @@ -121,7 +257,10 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { (node.end[0] - node.start[0]) / 2, (node.end[1] - node.start[1]) / 2, ]) - const linkedOriginalsRef = useRef( + const moveAxisRef = useRef( + getPerpendicularWallMoveAxis(node.start, node.end), + ) + const linkedOriginalsRef = useRef( isNew ? [] : getLinkedWallSnapshots({ @@ -178,6 +317,31 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { return { start: nextStart, end: nextEnd } } + const getMovePlan = (nextStart: [number, number], nextEnd: [number, number]) => + planWallMoveJunctions( + linkedOriginalsRef.current, + originalStart, + originalEnd, + nextStart, + nextEnd, + ) + + const getLinkedPreviewUpdates = (nextStart: [number, number], nextEnd: [number, number]) => { + const plan = getMovePlan(nextStart, nextEnd) + const movedUpdates = getPlannedLinkedWallUpdates( + plan, + originalStart, + originalEnd, + nextStart, + nextEnd, + ) + const movedById = new Map(movedUpdates.map((entry) => [entry.id, entry])) + + return linkedOriginalsRef.current.map( + (wall) => movedById.get(wall.id) ?? { id: wall.id, start: wall.start, end: wall.end }, + ) + } + const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => { previewRef.current = { start: nextStart, end: nextEnd } const centerX = (nextStart[0] + nextEnd[0]) / 2 @@ -185,13 +349,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { setCursorLocalPos([centerX, 0, centerZ]) applyNodePreview([ { id: nodeId, start: nextStart, end: nextEnd }, - ...getLinkedWallUpdates( - linkedOriginalsRef.current, - originalStart, - originalEnd, - nextStart, - nextEnd, - ), + ...getLinkedPreviewUpdates(nextStart, nextEnd), ]) } @@ -209,19 +367,24 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const localX = shiftPressedRef.current ? rawX : snapScalarToGrid(rawX, snapStep) const localZ = shiftPressedRef.current ? rawZ : snapScalarToGrid(rawZ, snapStep) - if ( - previousGridPosRef.current && - (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) - ) { - sfxEmitter.emit('sfx:grid-snap') - } - previousGridPosRef.current = [localX, localZ] - const anchor = dragAnchorRef.current ?? [localX, localZ] dragAnchorRef.current = anchor - const deltaX = localX - anchor[0] - const deltaZ = localZ - anchor[1] + const [deltaX, deltaZ] = constrainWallMoveDeltaToAxis( + localX - anchor[0], + localZ - anchor[1], + moveAxisRef.current, + ) + const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ] + + if ( + previousGridPosRef.current && + (constrainedGridPos[0] !== previousGridPosRef.current[0] || + constrainedGridPos[1] !== previousGridPosRef.current[1]) + ) { + sfxEmitter.emit('sfx:grid-snap') + } + previousGridPosRef.current = constrainedGridPos const nextCenter: [number, number] = [originalCenter[0] + deltaX, originalCenter[1] + deltaZ] const nextWall = buildWallFromCenter(nextCenter) @@ -246,6 +409,22 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { ]) resumeSceneHistory(useScene) + const commitPlan = getMovePlan(preview.start, preview.end) + const linkedWallUpdates = getPlannedLinkedWallUpdates( + commitPlan, + originalStart, + originalEnd, + preview.start, + preview.end, + ) + const collapsedLinkedWallIds = new Set( + [ + ...linkedWallUpdates + .filter((entry) => !isWallLongEnough(entry.start, entry.end)) + .map((entry) => entry.id as AnyNodeId), + ...commitPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId), + ], + ) const commitUpdates = [ { @@ -258,21 +437,30 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { } : { start: preview.start, end: preview.end }, }, - ...getLinkedWallUpdates( - linkedOriginalsRef.current, - originalStart, - originalEnd, - preview.start, - preview.end, - ).map((entry) => ({ - id: entry.id as AnyNodeId, - data: { start: entry.start, end: entry.end }, - })), + ...linkedWallUpdates + .filter((entry) => !collapsedLinkedWallIds.has(entry.id as AnyNodeId)) + .map((entry) => ({ + id: entry.id as AnyNodeId, + data: { start: entry.start, end: entry.end }, + })), ] - useScene.getState().updateNodes(commitUpdates) - for (const { id } of commitUpdates) { - useScene.getState().markDirty(id) - } + const sceneState = useScene.getState() + const existingWalls = getWallsAfterUpdates(sceneState.nodes, commitUpdates).filter( + (wall) => !collapsedLinkedWallIds.has(wall.id as AnyNodeId), + ) + const bridgeCreates = buildBridgeWallCreates({ + bridgePlans: commitPlan.bridgePlans, + nextStart: preview.start, + nextEnd: preview.end, + existingWalls, + wallCount: Object.values(sceneState.nodes).filter((entry) => entry?.type === 'wall') + .length, + }) + sceneState.applyNodeChanges({ + update: commitUpdates, + create: bridgeCreates, + delete: Array.from(collapsedLinkedWallIds), + }) pauseSceneHistory(useScene) @@ -311,6 +499,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { (preview.start[1] + preview.end[1]) / 2, ] const nextWall = buildWallFromCenter(currentCenter) + moveAxisRef.current = getPerpendicularWallMoveAxis(nextWall.start, nextWall.end) applyPreview(nextWall.start, nextWall.end) } diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index 3931e0da..8ac11b81 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -156,9 +156,12 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { const lightEffects = interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? [] + // useGLTF caches scenes, and Clone shares child geometry/material references. + // Undo can unmount one item while another clone of the same asset still needs them. return ( <> { const ref = useRef(null!) + const placeholderGeometry = useMemo(createEmptyWallGeometry, []) + const collisionPlaceholderGeometry = useMemo(() => { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute([], 3)) + return geometry + }, []) useRegistry(node.id, 'wall', ref) @@ -14,15 +29,31 @@ export const WallRenderer = ({ node }: { node: WallNode }) => { useScene.getState().markDirty(node.id) }, [node.id]) + useEffect(() => { + return () => { + placeholderGeometry.dispose() + collisionPlaceholderGeometry.dispose() + } + }, [collisionPlaceholderGeometry, placeholderGeometry]) + const handlers = useNodeEvents(node, 'wall') const material = getVisibleWallMaterials(node) return ( - - - - - + + {node.children.map((childId) => ( diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index b390e436..4595a6fd 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -271,16 +271,18 @@ export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMat } const map = getTexture(material) - - const threeMaterial = new THREE.MeshStandardMaterial({ + const materialParams: THREE.MeshStandardMaterialParameters = { color: props.color, roughness: props.roughness, metalness: props.metalness, opacity: props.opacity, transparent: props.transparent, side: sideMap[props.side], - map, - }) + } + + if (map) materialParams.map = map + + const threeMaterial = new THREE.MeshStandardMaterial(materialParams) materialCache.set(cacheKey, threeMaterial) return threeMaterial