Fix wall move junction ownership
This commit is contained in:
@@ -107,6 +107,15 @@ export {
|
|||||||
type WallMiterBoundaryPoints,
|
type WallMiterBoundaryPoints,
|
||||||
type WallMiterData,
|
type WallMiterData,
|
||||||
} from './systems/wall/wall-mitering'
|
} 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 type { SceneGraph } from './utils/clone-scene-graph'
|
||||||
export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
|
export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
|
||||||
export { isObject } from './utils/types'
|
export { isObject } from './utils/types'
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import type { CollectionId } from '../../schema/collections'
|
|||||||
import type { SceneState } from '../use-scene'
|
import type { SceneState } from '../use-scene'
|
||||||
|
|
||||||
type AnyContainerNode = AnyNode & { children: string[] }
|
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 WallAttachmentUpdate = { id: AnyNodeId; data: Partial<AnyNode> }
|
||||||
type WallMergePlan = {
|
type WallMergePlan = {
|
||||||
primaryWallId: AnyNodeId
|
primaryWallId: AnyNodeId
|
||||||
@@ -230,7 +233,7 @@ function buildWallMergePlans(
|
|||||||
export const createNodesAction = (
|
export const createNodesAction = (
|
||||||
set: (fn: (state: SceneState) => Partial<SceneState>) => void,
|
set: (fn: (state: SceneState) => Partial<SceneState>) => void,
|
||||||
get: () => SceneState,
|
get: () => SceneState,
|
||||||
ops: { node: AnyNode; parentId?: AnyNodeId }[],
|
ops: NodeCreateOp[],
|
||||||
) => {
|
) => {
|
||||||
if (get().readOnly) return
|
if (get().readOnly) return
|
||||||
set((state) => {
|
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 = (
|
export const updateNodesAction = (
|
||||||
set: (fn: (state: SceneState) => Partial<SceneState>) => void,
|
set: (fn: (state: SceneState) => Partial<SceneState>) => void,
|
||||||
get: () => SceneState,
|
get: () => SceneState,
|
||||||
|
|||||||
@@ -438,6 +438,11 @@ export type SceneState = {
|
|||||||
|
|
||||||
createNode: (node: AnyNode, parentId?: AnyNodeId) => void
|
createNode: (node: AnyNode, parentId?: AnyNodeId) => void
|
||||||
createNodes: (ops: { 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
|
updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void
|
||||||
updateNodes: (updates: { 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),
|
createNodes: (ops) => nodeActions.createNodesAction(set, get, ops),
|
||||||
createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]),
|
createNode: (node, parentId) => nodeActions.createNodesAction(set, get, [{ node, parentId }]),
|
||||||
|
applyNodeChanges: (changes) => nodeActions.applyNodeChangesAction(set, get, changes),
|
||||||
|
|
||||||
updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates),
|
updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates),
|
||||||
updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]),
|
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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1636,6 +1636,7 @@ const EditorOutlinerSync = () => {
|
|||||||
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
||||||
const hoveredId = useViewer((s) => s.hoveredId)
|
const hoveredId = useViewer((s) => s.hoveredId)
|
||||||
const outliner = useViewer((s) => s.outliner)
|
const outliner = useViewer((s) => s.outliner)
|
||||||
|
const nodes = useScene((s) => s.nodes)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let idsToHighlight: string[] = []
|
let idsToHighlight: string[] = []
|
||||||
@@ -1672,16 +1673,21 @@ const EditorOutlinerSync = () => {
|
|||||||
// 2. Sync with the imperative outliner arrays (mutate in place to keep references)
|
// 2. Sync with the imperative outliner arrays (mutate in place to keep references)
|
||||||
outliner.selectedObjects.length = 0
|
outliner.selectedObjects.length = 0
|
||||||
for (const id of idsToHighlight) {
|
for (const id of idsToHighlight) {
|
||||||
|
if (!nodes[id as AnyNodeId]) continue
|
||||||
const obj = sceneRegistry.nodes.get(id)
|
const obj = sceneRegistry.nodes.get(id)
|
||||||
if (obj?.parent) outliner.selectedObjects.push(obj)
|
if (obj?.parent) outliner.selectedObjects.push(obj)
|
||||||
}
|
}
|
||||||
|
|
||||||
outliner.hoveredObjects.length = 0
|
outliner.hoveredObjects.length = 0
|
||||||
if (hoveredId) {
|
if (hoveredId) {
|
||||||
|
if (!nodes[hoveredId as AnyNodeId]) {
|
||||||
|
useViewer.setState({ hoveredId: null })
|
||||||
|
} else {
|
||||||
const obj = sceneRegistry.nodes.get(hoveredId)
|
const obj = sceneRegistry.nodes.get(hoveredId)
|
||||||
if (obj?.parent) outliner.hoveredObjects.push(obj)
|
if (obj?.parent) outliner.hoveredObjects.push(obj)
|
||||||
}
|
}
|
||||||
}, [phase, previewSelectedIds, selection, hoveredId, outliner])
|
}
|
||||||
|
}, [phase, previewSelectedIds, selection, hoveredId, outliner, nodes])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,19 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
constrainWallMoveDeltaToAxis,
|
||||||
emitter,
|
emitter,
|
||||||
|
getPerpendicularWallMoveAxis,
|
||||||
type GridEvent,
|
type GridEvent,
|
||||||
pauseSceneHistory,
|
pauseSceneHistory,
|
||||||
|
planWallMoveJunctions,
|
||||||
resumeSceneHistory,
|
resumeSceneHistory,
|
||||||
useScene,
|
useScene,
|
||||||
|
type WallMoveBridgePlan,
|
||||||
|
type WallMoveAxis,
|
||||||
|
type WallMoveJunctionPlan,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
|
WallNode as WallSchema,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
@@ -15,7 +22,7 @@ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
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] {
|
function rotateVector([x, z]: [number, number], angle: number): [number, number] {
|
||||||
const cos = Math.cos(angle)
|
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]
|
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'] {
|
function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'] {
|
||||||
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
|
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
|
||||||
return meta
|
return meta
|
||||||
@@ -37,11 +48,7 @@ function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'
|
|||||||
return nextMeta as WallNode['metadata']
|
return nextMeta as WallNode['metadata']
|
||||||
}
|
}
|
||||||
|
|
||||||
type LinkedWallSnapshot = {
|
type LinkedWallSnapshot = WallNode
|
||||||
id: WallNode['id']
|
|
||||||
start: [number, number]
|
|
||||||
end: [number, number]
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLinkedWallSnapshots(args: {
|
function getLinkedWallSnapshots(args: {
|
||||||
wallId: WallNode['id']
|
wallId: WallNode['id']
|
||||||
@@ -51,30 +58,45 @@ function getLinkedWallSnapshots(args: {
|
|||||||
}) {
|
}) {
|
||||||
const { wallId, wallParentId, originalStart, originalEnd } = args
|
const { wallId, wallParentId, originalStart, originalEnd } = args
|
||||||
const { nodes } = useScene.getState()
|
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 snapshots: LinkedWallSnapshot[] = []
|
||||||
|
const seenWallIds = new Set<WallNode['id']>()
|
||||||
|
|
||||||
for (const node of Object.values(nodes)) {
|
for (const node of walls) {
|
||||||
if (!(node?.type === 'wall' && node.id !== wallId)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((node.parentId ?? null) !== wallParentId) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!samePoint(node.start, originalStart) &&
|
!contextPoints.has(pointKey(node.start)) &&
|
||||||
!samePoint(node.start, originalEnd) &&
|
!contextPoints.has(pointKey(node.end))
|
||||||
!samePoint(node.end, originalStart) &&
|
|
||||||
!samePoint(node.end, originalEnd)
|
|
||||||
) {
|
) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (seenWallIds.has(node.id)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenWallIds.add(node.id)
|
||||||
|
|
||||||
snapshots.push({
|
snapshots.push({
|
||||||
id: node.id,
|
...node,
|
||||||
start: [...node.start] as [number, number],
|
start: [...node.start] as [number, number],
|
||||||
end: [...node.end] as [number, number],
|
end: [...node.end] as [number, number],
|
||||||
|
children: [...(node.children ?? [])],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,25 +104,139 @@ function getLinkedWallSnapshots(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getLinkedWallUpdates(
|
function getLinkedWallUpdates(
|
||||||
linkedWalls: LinkedWallSnapshot[],
|
linkedWalls: Array<{
|
||||||
|
wall: LinkedWallSnapshot
|
||||||
|
matchPoint?: [number, number]
|
||||||
|
targetPoint?: [number, number]
|
||||||
|
}>,
|
||||||
originalStart: [number, number],
|
originalStart: [number, number],
|
||||||
originalEnd: [number, number],
|
originalEnd: [number, number],
|
||||||
nextStart: [number, number],
|
nextStart: [number, number],
|
||||||
nextEnd: [number, number],
|
nextEnd: [number, number],
|
||||||
) {
|
) {
|
||||||
return linkedWalls.map((wall) => ({
|
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,
|
id: wall.id,
|
||||||
start: samePoint(wall.start, originalStart)
|
start: samePoint(wall.start, originalStart)
|
||||||
? nextStart
|
? targetStart
|
||||||
: samePoint(wall.start, originalEnd)
|
: samePoint(wall.start, originalEnd)
|
||||||
? nextEnd
|
? targetEnd
|
||||||
: wall.start,
|
: wall.start,
|
||||||
end: samePoint(wall.end, originalStart)
|
end: samePoint(wall.end, originalStart)
|
||||||
? nextStart
|
? targetStart
|
||||||
: samePoint(wall.end, originalEnd)
|
: samePoint(wall.end, originalEnd)
|
||||||
? nextEnd
|
? targetEnd
|
||||||
: wall.end,
|
: wall.end,
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlannedLinkedWallUpdates(
|
||||||
|
plan: WallMoveJunctionPlan<LinkedWallSnapshot>,
|
||||||
|
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<typeof useScene.getState>['nodes'],
|
||||||
|
updates: Array<{ id: AnyNodeId; data: Partial<WallNode> }>,
|
||||||
|
) {
|
||||||
|
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<WallMoveBridgePlan<LinkedWallSnapshot>>
|
||||||
|
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 }) => {
|
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[0] - node.start[0]) / 2,
|
||||||
(node.end[1] - node.start[1]) / 2,
|
(node.end[1] - node.start[1]) / 2,
|
||||||
])
|
])
|
||||||
const linkedOriginalsRef = useRef(
|
const moveAxisRef = useRef<WallMoveAxis | null>(
|
||||||
|
getPerpendicularWallMoveAxis(node.start, node.end),
|
||||||
|
)
|
||||||
|
const linkedOriginalsRef = useRef<LinkedWallSnapshot[]>(
|
||||||
isNew
|
isNew
|
||||||
? []
|
? []
|
||||||
: getLinkedWallSnapshots({
|
: getLinkedWallSnapshots({
|
||||||
@@ -178,6 +317,31 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
|||||||
return { start: nextStart, end: nextEnd }
|
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]) => {
|
const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
|
||||||
previewRef.current = { start: nextStart, end: nextEnd }
|
previewRef.current = { start: nextStart, end: nextEnd }
|
||||||
const centerX = (nextStart[0] + nextEnd[0]) / 2
|
const centerX = (nextStart[0] + nextEnd[0]) / 2
|
||||||
@@ -185,13 +349,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
|||||||
setCursorLocalPos([centerX, 0, centerZ])
|
setCursorLocalPos([centerX, 0, centerZ])
|
||||||
applyNodePreview([
|
applyNodePreview([
|
||||||
{ id: nodeId, start: nextStart, end: nextEnd },
|
{ id: nodeId, start: nextStart, end: nextEnd },
|
||||||
...getLinkedWallUpdates(
|
...getLinkedPreviewUpdates(nextStart, nextEnd),
|
||||||
linkedOriginalsRef.current,
|
|
||||||
originalStart,
|
|
||||||
originalEnd,
|
|
||||||
nextStart,
|
|
||||||
nextEnd,
|
|
||||||
),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,19 +367,24 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
|||||||
const localX = shiftPressedRef.current ? rawX : snapScalarToGrid(rawX, snapStep)
|
const localX = shiftPressedRef.current ? rawX : snapScalarToGrid(rawX, snapStep)
|
||||||
const localZ = shiftPressedRef.current ? rawZ : snapScalarToGrid(rawZ, 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]
|
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
||||||
dragAnchorRef.current = anchor
|
dragAnchorRef.current = anchor
|
||||||
|
|
||||||
const deltaX = localX - anchor[0]
|
const [deltaX, deltaZ] = constrainWallMoveDeltaToAxis(
|
||||||
const deltaZ = localZ - anchor[1]
|
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 nextCenter: [number, number] = [originalCenter[0] + deltaX, originalCenter[1] + deltaZ]
|
||||||
const nextWall = buildWallFromCenter(nextCenter)
|
const nextWall = buildWallFromCenter(nextCenter)
|
||||||
@@ -246,6 +409,22 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
resumeSceneHistory(useScene)
|
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 = [
|
const commitUpdates = [
|
||||||
{
|
{
|
||||||
@@ -258,21 +437,30 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
|||||||
}
|
}
|
||||||
: { start: preview.start, end: preview.end },
|
: { start: preview.start, end: preview.end },
|
||||||
},
|
},
|
||||||
...getLinkedWallUpdates(
|
...linkedWallUpdates
|
||||||
linkedOriginalsRef.current,
|
.filter((entry) => !collapsedLinkedWallIds.has(entry.id as AnyNodeId))
|
||||||
originalStart,
|
.map((entry) => ({
|
||||||
originalEnd,
|
|
||||||
preview.start,
|
|
||||||
preview.end,
|
|
||||||
).map((entry) => ({
|
|
||||||
id: entry.id as AnyNodeId,
|
id: entry.id as AnyNodeId,
|
||||||
data: { start: entry.start, end: entry.end },
|
data: { start: entry.start, end: entry.end },
|
||||||
})),
|
})),
|
||||||
]
|
]
|
||||||
useScene.getState().updateNodes(commitUpdates)
|
const sceneState = useScene.getState()
|
||||||
for (const { id } of commitUpdates) {
|
const existingWalls = getWallsAfterUpdates(sceneState.nodes, commitUpdates).filter(
|
||||||
useScene.getState().markDirty(id)
|
(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)
|
pauseSceneHistory(useScene)
|
||||||
|
|
||||||
@@ -311,6 +499,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
|||||||
(preview.start[1] + preview.end[1]) / 2,
|
(preview.start[1] + preview.end[1]) / 2,
|
||||||
]
|
]
|
||||||
const nextWall = buildWallFromCenter(currentCenter)
|
const nextWall = buildWallFromCenter(currentCenter)
|
||||||
|
moveAxisRef.current = getPerpendicularWallMoveAxis(nextWall.start, nextWall.end)
|
||||||
applyPreview(nextWall.start, nextWall.end)
|
applyPreview(nextWall.start, nextWall.end)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -156,9 +156,12 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
|||||||
const lightEffects =
|
const lightEffects =
|
||||||
interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? []
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Clone
|
<Clone
|
||||||
|
dispose={null}
|
||||||
object={scene}
|
object={scene}
|
||||||
position={node.asset.offset}
|
position={node.asset.offset}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
|
import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
|
||||||
import { useLayoutEffect, useRef } from 'react'
|
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||||
import type { Mesh } from 'three'
|
import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials'
|
import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials'
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
|
|
||||||
|
function createEmptyWallGeometry() {
|
||||||
|
const geometry = new BufferGeometry()
|
||||||
|
geometry.setAttribute('position', new Float32BufferAttribute([], 3))
|
||||||
|
geometry.addGroup(0, 0, 0)
|
||||||
|
geometry.addGroup(0, 0, 1)
|
||||||
|
geometry.addGroup(0, 0, 2)
|
||||||
|
return geometry
|
||||||
|
}
|
||||||
|
|
||||||
export const WallRenderer = ({ node }: { node: WallNode }) => {
|
export const WallRenderer = ({ node }: { node: WallNode }) => {
|
||||||
const ref = useRef<Mesh>(null!)
|
const ref = useRef<Mesh>(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)
|
useRegistry(node.id, 'wall', ref)
|
||||||
|
|
||||||
@@ -14,15 +29,31 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
|
|||||||
useScene.getState().markDirty(node.id)
|
useScene.getState().markDirty(node.id)
|
||||||
}, [node.id])
|
}, [node.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
placeholderGeometry.dispose()
|
||||||
|
collisionPlaceholderGeometry.dispose()
|
||||||
|
}
|
||||||
|
}, [collisionPlaceholderGeometry, placeholderGeometry])
|
||||||
|
|
||||||
const handlers = useNodeEvents(node, 'wall')
|
const handlers = useNodeEvents(node, 'wall')
|
||||||
const material = getVisibleWallMaterials(node)
|
const material = getVisibleWallMaterials(node)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
|
<mesh
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
castShadow
|
||||||
<mesh name="collision-mesh" visible={false} {...handlers}>
|
geometry={placeholderGeometry}
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
material={material}
|
||||||
</mesh>
|
receiveShadow
|
||||||
|
ref={ref}
|
||||||
|
visible={node.visible}
|
||||||
|
>
|
||||||
|
<mesh
|
||||||
|
geometry={collisionPlaceholderGeometry}
|
||||||
|
name="collision-mesh"
|
||||||
|
visible={false}
|
||||||
|
{...handlers}
|
||||||
|
/>
|
||||||
|
|
||||||
{node.children.map((childId) => (
|
{node.children.map((childId) => (
|
||||||
<NodeRenderer key={`${node.id}:${childId}`} nodeId={childId} />
|
<NodeRenderer key={`${node.id}:${childId}`} nodeId={childId} />
|
||||||
|
|||||||
@@ -271,16 +271,18 @@ export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMat
|
|||||||
}
|
}
|
||||||
|
|
||||||
const map = getTexture(material)
|
const map = getTexture(material)
|
||||||
|
const materialParams: THREE.MeshStandardMaterialParameters = {
|
||||||
const threeMaterial = new THREE.MeshStandardMaterial({
|
|
||||||
color: props.color,
|
color: props.color,
|
||||||
roughness: props.roughness,
|
roughness: props.roughness,
|
||||||
metalness: props.metalness,
|
metalness: props.metalness,
|
||||||
opacity: props.opacity,
|
opacity: props.opacity,
|
||||||
transparent: props.transparent,
|
transparent: props.transparent,
|
||||||
side: sideMap[props.side],
|
side: sideMap[props.side],
|
||||||
map,
|
}
|
||||||
})
|
|
||||||
|
if (map) materialParams.map = map
|
||||||
|
|
||||||
|
const threeMaterial = new THREE.MeshStandardMaterial(materialParams)
|
||||||
|
|
||||||
materialCache.set(cacheKey, threeMaterial)
|
materialCache.set(cacheKey, threeMaterial)
|
||||||
return threeMaterial
|
return threeMaterial
|
||||||
|
|||||||
Reference in New Issue
Block a user