Fix wall move junction ownership
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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<AnyNode> }
|
||||
type NodeDeleteOp = AnyNodeId
|
||||
type WallAttachmentUpdate = { id: AnyNodeId; data: Partial<AnyNode> }
|
||||
type WallMergePlan = {
|
||||
primaryWallId: AnyNodeId
|
||||
@@ -230,7 +233,7 @@ function buildWallMergePlans(
|
||||
export const createNodesAction = (
|
||||
set: (fn: (state: SceneState) => Partial<SceneState>) => 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<SceneState>) => 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<AnyNodeId>()
|
||||
const parentsToMarkDirty = new Set<AnyNodeId>()
|
||||
|
||||
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<AnyNodeId>()
|
||||
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<SceneState>) => void,
|
||||
get: () => SceneState,
|
||||
|
||||
@@ -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<AnyNode> }[]
|
||||
delete?: AnyNodeId[]
|
||||
}) => void
|
||||
|
||||
updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void
|
||||
updateNodes: (updates: { id: AnyNodeId; data: Partial<AnyNode> }[]) => void
|
||||
@@ -579,6 +584,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
|
||||
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 }]),
|
||||
|
||||
@@ -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<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>> = {
|
||||
wall: TWall
|
||||
originalPoint: WallPlanPoint
|
||||
movedEndpoint: WallMoveEndpoint
|
||||
}
|
||||
|
||||
export type WallMoveLinkedWallTargetPlan<
|
||||
TWall extends Pick<WallNode, 'id' | 'start' | 'end'>,
|
||||
> = {
|
||||
wall: TWall
|
||||
originalPoint: WallPlanPoint
|
||||
targetPoint: WallPlanPoint
|
||||
}
|
||||
|
||||
export type WallMoveJunctionPlan<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>> = {
|
||||
linkedWallsToMove: TWall[]
|
||||
linkedWallTargetPlans: Array<WallMoveLinkedWallTargetPlan<TWall>>
|
||||
bridgePlans: Array<WallMoveBridgePlan<TWall>>
|
||||
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<WallNode, 'start' | 'end'>, point: WallPlanPoint) {
|
||||
return pointsEqual(wall.start, point) || pointsEqual(wall.end, point)
|
||||
}
|
||||
|
||||
function otherWallEndpoint(wall: Pick<WallNode, 'start' | 'end'>, point: WallPlanPoint) {
|
||||
return pointsEqual(wall.start, point) ? wall.end : wall.start
|
||||
}
|
||||
|
||||
type MoveWallRelation = 'same-direction' | 'opposite-direction' | 'off-axis' | 'stationary'
|
||||
type RelatedWallEntry<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>> = {
|
||||
wall: TWall
|
||||
relation: MoveWallRelation
|
||||
}
|
||||
|
||||
function wallLengthFromPoint(wall: Pick<WallNode, 'start' | 'end'>, point: WallPlanPoint) {
|
||||
const freeEndpoint = otherWallEndpoint(wall, point)
|
||||
return Math.hypot(freeEndpoint[0] - point[0], freeEndpoint[1] - point[1])
|
||||
}
|
||||
|
||||
function getMoveWallRelation(
|
||||
wall: Pick<WallNode, 'start' | 'end'>,
|
||||
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<TWall extends Pick<WallNode, 'id' | 'start' | 'end'>>(
|
||||
linkedWalls: TWall[],
|
||||
originalStart: WallPlanPoint,
|
||||
originalEnd: WallPlanPoint,
|
||||
nextStart: WallPlanPoint,
|
||||
nextEnd: WallPlanPoint,
|
||||
): WallMoveJunctionPlan<TWall> {
|
||||
const linkedWallsToMove = new Map<TWall['id'], TWall>()
|
||||
const linkedWallTargetPlans = new Map<TWall['id'], WallMoveLinkedWallTargetPlan<TWall>>()
|
||||
const bridgePlans = new Map<string, WallMoveBridgePlan<TWall>>()
|
||||
const wallsToDelete = new Map<TWall['id'], TWall>()
|
||||
|
||||
const addStandardEndpointPlan = (
|
||||
endpoint: WallMoveEndpoint,
|
||||
point: WallPlanPoint,
|
||||
nextPoint: WallPlanPoint,
|
||||
relatedWalls: Array<RelatedWallEntry<TWall>>,
|
||||
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()),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user