Fix wall move junction ownership

This commit is contained in:
sudhir
2026-05-11 23:46:57 +05:30
parent afadfb2ea8
commit be41511474
9 changed files with 694 additions and 81 deletions
+9
View File
@@ -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'
+141 -1
View File
@@ -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,
+6
View File
@@ -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 }]),
+227
View File
@@ -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} />
+6 -4
View File
@@ -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