Fix composite node duplication placement (#542)
Route composite duplication through the fresh-subtree placement lifecycle, preserve descendant geometry and IDs, clear stale selection, and guarantee failed drafts restore history tracking.
This commit is contained in:
@@ -24,6 +24,7 @@ import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extensi
|
|||||||
import {
|
import {
|
||||||
createFreshPlacementSubtree,
|
createFreshPlacementSubtree,
|
||||||
duplicatesAsFreshSubtree,
|
duplicatesAsFreshSubtree,
|
||||||
|
prepareFreshPlacementRootDuplicate,
|
||||||
} from '../../lib/fresh-planar-placement'
|
} from '../../lib/fresh-planar-placement'
|
||||||
import { curveReshapeScope } from '../../lib/interaction/scope'
|
import { curveReshapeScope } from '../../lib/interaction/scope'
|
||||||
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
|
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
|
||||||
@@ -116,8 +117,8 @@ function collectQuickActionNodes(
|
|||||||
* - Add hole (slab + ceiling only): inserts a small default-square
|
* - Add hole (slab + ceiling only): inserts a small default-square
|
||||||
* hole at the polygon centroid via `updateNode`. Mirrors the legacy
|
* hole at the polygon centroid via `updateNode`. Mirrors the legacy
|
||||||
* `handleAddHole` in `floating-action-menu.tsx`.
|
* `handleAddHole` in `floating-action-menu.tsx`.
|
||||||
* - Duplicate: deep-clones the node, marks it new, sets it as the
|
* - Duplicate: creates a fresh subtree when the kind opts in, otherwise a
|
||||||
* movingNode (placement cursor) — same UX pattern as 3D duplicate.
|
* root-only copy, then hands that real draft to the placement cursor.
|
||||||
* - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
|
* - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
|
||||||
* `relations.cascadeDelete` if declared on the def.
|
* `relations.cascadeDelete` if declared on the def.
|
||||||
*
|
*
|
||||||
@@ -320,34 +321,30 @@ export function FloorplanRegistryActionMenu() {
|
|||||||
if (!node.parentId) return
|
if (!node.parentId) return
|
||||||
sfxEmitter.emit('sfx:item-pick')
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
if (duplicatesAsFreshSubtree(node as AnyNode)) {
|
let draftId: AnyNodeId | null = null
|
||||||
const draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
|
try {
|
||||||
const draft = draftId ? useScene.getState().nodes[draftId] : null
|
if (duplicatesAsFreshSubtree(node as AnyNode)) {
|
||||||
if (draft) {
|
draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
|
||||||
|
const draft = draftId ? useScene.getState().nodes[draftId] : null
|
||||||
|
if (!draft) return
|
||||||
setMovingNode(draft as never)
|
setMovingNode(draft as never)
|
||||||
setMovingNodeOrigin('2d')
|
} else {
|
||||||
useScene.temporal.getState().resume()
|
const cloned = prepareFreshPlacementRootDuplicate(node as AnyNode)
|
||||||
return
|
const parsed = def.schema.parse(cloned) as AnyNode
|
||||||
|
draftId = parsed.id as AnyNodeId
|
||||||
|
useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
|
||||||
|
setMovingNode(parsed as never)
|
||||||
}
|
}
|
||||||
|
setMovingNodeOrigin('2d')
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [] })
|
||||||
|
} catch (error) {
|
||||||
|
if (draftId && useScene.getState().nodes[draftId]) {
|
||||||
|
useScene.getState().deleteNode(draftId)
|
||||||
|
}
|
||||||
|
console.error('Failed to duplicate node', error)
|
||||||
|
} finally {
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
return
|
|
||||||
}
|
}
|
||||||
const cloned = structuredClone(node) as AnyNode & { id?: AnyNodeId }
|
|
||||||
delete (cloned as { id?: AnyNodeId }).id
|
|
||||||
const prevMeta =
|
|
||||||
cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
|
|
||||||
? (cloned.metadata as Record<string, unknown>)
|
|
||||||
: {}
|
|
||||||
// Mark fresh + hand to the placement cursor so the copy follows the
|
|
||||||
// pointer and only lands on the next click — same gesture for every
|
|
||||||
// kind. Polyline runs (duct / pipe / lineset) ride the same path:
|
|
||||||
// `FloorplanRegistryMoveOverlay` translates their whole `path`, so they
|
|
||||||
// no longer need the old "offset + drop already-placed" special case.
|
|
||||||
cloned.metadata = { ...prevMeta, isNew: true }
|
|
||||||
const parsed = def.schema.parse(cloned) as AnyNode
|
|
||||||
useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
|
|
||||||
setMovingNode(parsed as never)
|
|
||||||
useScene.temporal.getState().resume()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
runAsSingleSceneHistoryStep,
|
runAsSingleSceneHistoryStep,
|
||||||
type SlabNode,
|
type SlabNode,
|
||||||
SpawnNode,
|
SpawnNode,
|
||||||
StairNode,
|
|
||||||
StairSegmentNode,
|
StairSegmentNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
summarizeSystemFor,
|
summarizeSystemFor,
|
||||||
@@ -47,6 +46,7 @@ import { resolveMoveActionNode } from '../../lib/direct-manipulation'
|
|||||||
import {
|
import {
|
||||||
createFreshPlacementSubtree,
|
createFreshPlacementSubtree,
|
||||||
duplicatesAsFreshSubtree,
|
duplicatesAsFreshSubtree,
|
||||||
|
prepareFreshPlacementRootDuplicate,
|
||||||
} from '../../lib/fresh-planar-placement'
|
} from '../../lib/fresh-planar-placement'
|
||||||
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
|
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
|
||||||
import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
|
import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
|
||||||
@@ -54,7 +54,6 @@ import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
|
|||||||
import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
|
import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
|
||||||
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
||||||
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import { duplicateStairSubtree } from '../../lib/stair-duplication'
|
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
import useInteractionScope, {
|
import useInteractionScope, {
|
||||||
@@ -530,20 +529,26 @@ export function FloatingActionMenu() {
|
|||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
if (duplicatesAsFreshSubtree(node as AnyNode)) {
|
if (duplicatesAsFreshSubtree(node as AnyNode)) {
|
||||||
const draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
|
let draftId: AnyNodeId | null = null
|
||||||
const draft = draftId ? useScene.getState().nodes[draftId] : null
|
try {
|
||||||
if (draft) {
|
draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
|
||||||
setMovingNode(draft as any)
|
const draft = draftId ? useScene.getState().nodes[draftId] : null
|
||||||
setSelection({ selectedIds: [] })
|
if (draft) {
|
||||||
return
|
setMovingNode(draft as any)
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (draftId && useScene.getState().nodes[draftId]) {
|
||||||
|
useScene.getState().deleteNode(draftId)
|
||||||
|
}
|
||||||
|
console.error('Failed to duplicate node subtree', error)
|
||||||
}
|
}
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let duplicateInfo = structuredClone(node) as any
|
const duplicateInfo = prepareFreshPlacementRootDuplicate(node as AnyNode) as any
|
||||||
delete duplicateInfo.id
|
|
||||||
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
|
|
||||||
|
|
||||||
let duplicate: AnyNode | null = null
|
let duplicate: AnyNode | null = null
|
||||||
try {
|
try {
|
||||||
@@ -566,11 +571,6 @@ export function FloatingActionMenu() {
|
|||||||
} else if (node.type === 'roof-segment') {
|
} else if (node.type === 'roof-segment') {
|
||||||
duplicateInfo.id = generateId('rseg')
|
duplicateInfo.id = generateId('rseg')
|
||||||
duplicate = RoofSegmentNode.parse(duplicateInfo)
|
duplicate = RoofSegmentNode.parse(duplicateInfo)
|
||||||
} else if (node.type === 'stair') {
|
|
||||||
duplicateInfo.children = []
|
|
||||||
duplicateInfo.metadata = { ...duplicateInfo.metadata }
|
|
||||||
delete duplicateInfo.metadata?.isNew
|
|
||||||
duplicate = StairNode.parse(duplicateInfo)
|
|
||||||
} else if (node.type === 'stair-segment') {
|
} else if (node.type === 'stair-segment') {
|
||||||
duplicate = StairSegmentNode.parse(duplicateInfo)
|
duplicate = StairSegmentNode.parse(duplicateInfo)
|
||||||
} else if (node.type === 'spawn') {
|
} else if (node.type === 'spawn') {
|
||||||
@@ -608,11 +608,7 @@ export function FloatingActionMenu() {
|
|||||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||||
} else if (duplicate.type === 'fence') {
|
} else if (duplicate.type === 'fence') {
|
||||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||||
} else if (
|
} else if (duplicate.type === 'roof-segment' || duplicate.type === 'stair-segment') {
|
||||||
duplicate.type === 'roof-segment' ||
|
|
||||||
duplicate.type === 'stair' ||
|
|
||||||
duplicate.type === 'stair-segment'
|
|
||||||
) {
|
|
||||||
// Add small offset to make it visible
|
// Add small offset to make it visible
|
||||||
if ('position' in duplicate) {
|
if ('position' in duplicate) {
|
||||||
duplicate.position = [
|
duplicate.position = [
|
||||||
@@ -621,13 +617,7 @@ export function FloatingActionMenu() {
|
|||||||
duplicate.position[2] + 1,
|
duplicate.position[2] + 1,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
if (node.type === 'stair' && duplicate.type === 'stair') {
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||||
duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
|
|
||||||
} else {
|
|
||||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duplicate children for stair nodes
|
|
||||||
} else if (
|
} else if (
|
||||||
duplicate.type === 'item' ||
|
duplicate.type === 'item' ||
|
||||||
duplicate.type === 'chimney' ||
|
duplicate.type === 'chimney' ||
|
||||||
@@ -696,12 +686,8 @@ export function FloatingActionMenu() {
|
|||||||
nodeRegistry.has(duplicate.type)
|
nodeRegistry.has(duplicate.type)
|
||||||
) {
|
) {
|
||||||
setMovingNode(duplicate as any)
|
setMovingNode(duplicate as any)
|
||||||
} else if (duplicate.type === 'stair') {
|
|
||||||
setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
|
|
||||||
}
|
|
||||||
if (duplicate.type !== 'stair') {
|
|
||||||
setSelection({ selectedIds: [] })
|
|
||||||
}
|
}
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[node, setMovingNode, setSelection],
|
[node, setMovingNode, setSelection],
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ import {
|
|||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { commitFreshPlacementSubtree, createFreshPlacementSubtree } from './fresh-planar-placement'
|
import {
|
||||||
|
commitFreshPlacementSubtree,
|
||||||
|
createFreshPlacementSubtree,
|
||||||
|
duplicatesAsFreshSubtree,
|
||||||
|
prepareFreshPlacementRootDuplicate,
|
||||||
|
} from './fresh-planar-placement'
|
||||||
|
|
||||||
type RafFn = (cb: (time: number) => void) => number
|
type RafFn = (cb: (time: number) => void) => number
|
||||||
;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
|
;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
|
||||||
@@ -212,6 +217,33 @@ describe('commitFreshPlacementSubtree', () => {
|
|||||||
expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([])
|
expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('uses the subtree contract for childless variants and never aliases root-only children', () => {
|
||||||
|
registerCabinetClonePrepTestKind()
|
||||||
|
const childlessCabinet = {
|
||||||
|
...shelf(),
|
||||||
|
type: 'cabinet',
|
||||||
|
children: [],
|
||||||
|
metadata: { isTransient: true, label: 'source' },
|
||||||
|
} as AnyNode
|
||||||
|
expect(duplicatesAsFreshSubtree(childlessCabinet)).toBe(true)
|
||||||
|
|
||||||
|
const source = {
|
||||||
|
...shelf(),
|
||||||
|
children: ['item_original' as AnyNodeId],
|
||||||
|
metadata: { isTransient: true, label: 'source' },
|
||||||
|
} as AnyNode
|
||||||
|
const duplicate = prepareFreshPlacementRootDuplicate(source) as AnyNode & {
|
||||||
|
children: AnyNodeId[]
|
||||||
|
id?: AnyNodeId
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(duplicate.id).toBeUndefined()
|
||||||
|
expect(duplicate.children).toEqual([])
|
||||||
|
expect(duplicate.metadata).toEqual({ isNew: true, label: 'source' })
|
||||||
|
expect((source as AnyNode & { children: AnyNodeId[] }).children).toEqual(['item_original'])
|
||||||
|
})
|
||||||
|
|
||||||
test('commits a duplicated cabinet draft without deleting the original modules', () => {
|
test('commits a duplicated cabinet draft without deleting the original modules', () => {
|
||||||
seedCabinetRun()
|
seedCabinetRun()
|
||||||
useScene.temporal.getState().clear()
|
useScene.temporal.getState().clear()
|
||||||
|
|||||||
@@ -27,10 +27,27 @@ function duplicableConfigFor(node: AnyNode): DuplicableConfig | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function duplicatesAsFreshSubtree(node: AnyNode): boolean {
|
export function duplicatesAsFreshSubtree(node: AnyNode): boolean {
|
||||||
const children = (node as { children?: unknown }).children
|
return duplicableConfigFor(node)?.subtree === true
|
||||||
return (
|
}
|
||||||
duplicableConfigFor(node)?.subtree === true && Array.isArray(children) && children.length > 0
|
|
||||||
)
|
/**
|
||||||
|
* Prepares a non-subtree duplicate without retaining ownership of the
|
||||||
|
* original node's children. Subtree-capable kinds take the path above and
|
||||||
|
* receive fresh descendant IDs; every other kind duplicates only its root.
|
||||||
|
*/
|
||||||
|
export function prepareFreshPlacementRootDuplicate(node: AnyNode): AnyNode {
|
||||||
|
const duplicate = structuredClone(node) as unknown as Record<string, unknown> & {
|
||||||
|
id?: AnyNodeId
|
||||||
|
children?: unknown
|
||||||
|
metadata?: unknown
|
||||||
|
}
|
||||||
|
delete duplicate.id
|
||||||
|
if (Array.isArray(duplicate.children)) duplicate.children = []
|
||||||
|
duplicate.metadata = {
|
||||||
|
...getPlacementMetadataRecord(stripPlacementMetadataFlags(duplicate.metadata)),
|
||||||
|
isNew: true,
|
||||||
|
}
|
||||||
|
return duplicate as unknown as AnyNode
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||||
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
LevelNode,
|
||||||
|
StairNode,
|
||||||
|
StairSegmentNode,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope'
|
||||||
|
import { duplicateStairSubtree } from './stair-duplication'
|
||||||
|
|
||||||
|
const LEVEL_ID = 'level_stair-duplicate' as AnyNodeId
|
||||||
|
const STAIR_ID = 'stair_original' as AnyNodeId
|
||||||
|
const SEGMENT_ID = 'sseg_original' as AnyNodeId
|
||||||
|
|
||||||
|
function seedStraightStair() {
|
||||||
|
const level = LevelNode.parse({
|
||||||
|
id: LEVEL_ID,
|
||||||
|
type: 'level',
|
||||||
|
children: [STAIR_ID],
|
||||||
|
})
|
||||||
|
const stair = StairNode.parse({
|
||||||
|
id: STAIR_ID,
|
||||||
|
type: 'stair',
|
||||||
|
parentId: LEVEL_ID,
|
||||||
|
children: [SEGMENT_ID],
|
||||||
|
position: [2, 0, 3],
|
||||||
|
})
|
||||||
|
const segment = StairSegmentNode.parse({
|
||||||
|
id: SEGMENT_ID,
|
||||||
|
type: 'stair-segment',
|
||||||
|
parentId: STAIR_ID,
|
||||||
|
})
|
||||||
|
useScene.setState({
|
||||||
|
nodes: {
|
||||||
|
[LEVEL_ID]: level as AnyNode,
|
||||||
|
[STAIR_ID]: stair as AnyNode,
|
||||||
|
[SEGMENT_ID]: segment as AnyNode,
|
||||||
|
},
|
||||||
|
rootNodeIds: [LEVEL_ID],
|
||||||
|
collections: {},
|
||||||
|
dirtyNodes: new Set(),
|
||||||
|
} as never)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('duplicateStairSubtree', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useInteractionScope.getState().end()
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [STAIR_ID] })
|
||||||
|
useScene.temporal.getState().clear()
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
seedStraightStair()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useInteractionScope.getState().end()
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('moves the exact fresh scene subtree and clears the original selection', () => {
|
||||||
|
const result = duplicateStairSubtree(STAIR_ID, { mode: 'move' })
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
const draft = nodes[result.stair.id as AnyNodeId]
|
||||||
|
|
||||||
|
expect(result.stair.id).not.toBe(STAIR_ID)
|
||||||
|
expect(draft).toBe(result.stair)
|
||||||
|
expect(getMovingNode()?.id).toBe(result.stair.id)
|
||||||
|
expect(useViewer.getState().selection.selectedIds).toEqual([])
|
||||||
|
expect((result.stair.metadata as Record<string, unknown>)?.isNew).toBe(true)
|
||||||
|
expect(result.stair.position).toEqual([3, 0, 4])
|
||||||
|
expect(result.segmentIds).toHaveLength(1)
|
||||||
|
expect(result.segmentIds[0]).not.toBe(SEGMENT_ID)
|
||||||
|
expect(nodes[result.segmentIds[0] as AnyNodeId]?.parentId).toBe(result.stair.id)
|
||||||
|
expect(nodes[STAIR_ID]).toBeDefined()
|
||||||
|
expect(nodes[SEGMENT_ID]).toBeDefined()
|
||||||
|
expect(useScene.temporal.getState().isTracking).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps childless curved stairs on the same real draft path', () => {
|
||||||
|
const curved = StairNode.parse({
|
||||||
|
...(useScene.getState().nodes[STAIR_ID] as AnyNode),
|
||||||
|
children: [],
|
||||||
|
stairType: 'curved',
|
||||||
|
})
|
||||||
|
useScene.setState((state) => ({
|
||||||
|
nodes: {
|
||||||
|
...state.nodes,
|
||||||
|
[LEVEL_ID]: { ...state.nodes[LEVEL_ID], children: [STAIR_ID] } as AnyNode,
|
||||||
|
[STAIR_ID]: curved as AnyNode,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = duplicateStairSubtree(STAIR_ID, { mode: 'move', offset: [0, 0, 0] })
|
||||||
|
|
||||||
|
expect(result.segmentIds).toEqual([])
|
||||||
|
expect(useScene.getState().nodes[result.stair.id as AnyNodeId]).toBe(result.stair)
|
||||||
|
expect(getMovingNode()?.id).toBe(result.stair.id)
|
||||||
|
expect((result.stair.metadata as Record<string, unknown>)?.isNew).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
generateId,
|
|
||||||
type StairNode,
|
type StairNode,
|
||||||
StairNode as StairNodeSchema,
|
|
||||||
type StairSegmentNode,
|
type StairSegmentNode,
|
||||||
StairSegmentNode as StairSegmentNodeSchema,
|
|
||||||
sceneRegistry,
|
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import useEditor from '../store/use-editor'
|
import useEditor from '../store/use-editor'
|
||||||
|
import { commitFreshPlacementSubtree, createFreshPlacementSubtree } from './fresh-planar-placement'
|
||||||
|
|
||||||
type DuplicateStairOptions = {
|
type DuplicateStairOptions = {
|
||||||
mode?: 'select' | 'move'
|
mode?: 'select' | 'move'
|
||||||
@@ -22,47 +20,19 @@ type DuplicateStairResult = {
|
|||||||
segmentIds: StairSegmentNode['id'][]
|
segmentIds: StairSegmentNode['id'][]
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOVE_REGISTRY_RETRY_LIMIT = 12
|
/**
|
||||||
|
* Duplicates a stair through the shared fresh-subtree placement path.
|
||||||
function stripDuplicateFlags(metadata: unknown) {
|
*
|
||||||
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
|
* The draft passed to the move tool is the exact node stored in the scene,
|
||||||
return metadata
|
* so its geometry, descendants, selection identity, and eventual commit all
|
||||||
}
|
* use one fresh ID graph.
|
||||||
|
*/
|
||||||
const nextMeta = { ...(metadata as Record<string, unknown>) }
|
|
||||||
delete nextMeta.isNew
|
|
||||||
delete nextMeta.isTransient
|
|
||||||
return nextMeta
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveStairWhenRegistered(stairId: StairNode['id'], attempt = 0) {
|
|
||||||
const latestStair = useScene.getState().nodes[stairId as AnyNodeId]
|
|
||||||
if (latestStair?.type !== 'stair') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sceneRegistry.nodes.has(stairId)) {
|
|
||||||
useEditor.getState().setMovingNode(latestStair)
|
|
||||||
useViewer.getState().setSelection({ selectedIds: [] })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempt >= MOVE_REGISTRY_RETRY_LIMIT) {
|
|
||||||
console.warn(`Duplicated stair "${stairId}" did not register before move mode started`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame(() => moveStairWhenRegistered(stairId, attempt + 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function duplicateStairSubtree(
|
export function duplicateStairSubtree(
|
||||||
sourceStairId: AnyNodeId,
|
sourceStairId: AnyNodeId,
|
||||||
options: DuplicateStairOptions = {},
|
options: DuplicateStairOptions = {},
|
||||||
): DuplicateStairResult {
|
): DuplicateStairResult {
|
||||||
const { mode = 'move', offset = [1, 0, 1], parentId: explicitParentId } = options
|
const { mode = 'move', offset = [1, 0, 1], parentId: explicitParentId } = options
|
||||||
|
const sourceStair = useScene.getState().nodes[sourceStairId]
|
||||||
const scene = useScene.getState()
|
|
||||||
const sourceStair = scene.nodes[sourceStairId]
|
|
||||||
|
|
||||||
if (sourceStair?.type !== 'stair') {
|
if (sourceStair?.type !== 'stair') {
|
||||||
throw new Error(`Node "${sourceStairId}" is not a stair`)
|
throw new Error(`Node "${sourceStairId}" is not a stair`)
|
||||||
@@ -73,54 +43,56 @@ export function duplicateStairSubtree(
|
|||||||
throw new Error(`Stair "${sourceStairId}" is missing a parent level`)
|
throw new Error(`Stair "${sourceStairId}" is missing a parent level`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stairClone = StairNodeSchema.parse({
|
const temporal = useScene.temporal.getState()
|
||||||
...structuredClone(sourceStair),
|
const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
|
||||||
id: generateId('stair'),
|
if (wasTracking) temporal.pause()
|
||||||
parentId,
|
|
||||||
children: [],
|
|
||||||
position: [
|
|
||||||
sourceStair.position[0] + offset[0],
|
|
||||||
sourceStair.position[1] + offset[1],
|
|
||||||
sourceStair.position[2] + offset[2],
|
|
||||||
] as StairNode['position'],
|
|
||||||
metadata: stripDuplicateFlags(sourceStair.metadata),
|
|
||||||
})
|
|
||||||
|
|
||||||
const segmentClones: StairSegmentNode[] = []
|
let draftId: AnyNodeId | null = null
|
||||||
for (const childId of sourceStair.children ?? []) {
|
try {
|
||||||
const childNode = scene.nodes[childId as AnyNodeId]
|
draftId = createFreshPlacementSubtree(sourceStairId, {
|
||||||
if (childNode?.type !== 'stair-segment') {
|
parentId,
|
||||||
continue
|
position: [
|
||||||
|
sourceStair.position[0] + offset[0],
|
||||||
|
sourceStair.position[1] + offset[1],
|
||||||
|
sourceStair.position[2] + offset[2],
|
||||||
|
],
|
||||||
|
} as Partial<AnyNode>)
|
||||||
|
const draft = draftId ? useScene.getState().nodes[draftId] : null
|
||||||
|
if (draft?.type !== 'stair') {
|
||||||
|
throw new Error(`Duplicated stair "${sourceStairId}" was not created`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const childClone = StairSegmentNodeSchema.parse({
|
if (mode === 'select') {
|
||||||
...structuredClone(childNode),
|
const committedId = commitFreshPlacementSubtree(draft.id as AnyNodeId, {})
|
||||||
id: generateId('sseg'),
|
const committed = committedId ? useScene.getState().nodes[committedId] : null
|
||||||
parentId: stairClone.id,
|
if (committed?.type !== 'stair') {
|
||||||
metadata: stripDuplicateFlags(childNode.metadata),
|
throw new Error(`Duplicated stair "${sourceStairId}" could not be committed`)
|
||||||
})
|
}
|
||||||
segmentClones.push(childClone)
|
if (wasTracking) temporal.resume()
|
||||||
}
|
useViewer.getState().setSelection({ selectedIds: [committed.id] })
|
||||||
|
return {
|
||||||
|
stair: committed,
|
||||||
|
segmentIds: stairSegmentIds(committed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
scene.createNodes([
|
useViewer.getState().setSelection({ selectedIds: [] })
|
||||||
{ node: stairClone, parentId },
|
useEditor.getState().setMovingNode(draft)
|
||||||
...segmentClones.map((segment) => ({ node: segment, parentId: stairClone.id as AnyNodeId })),
|
return {
|
||||||
])
|
stair: draft,
|
||||||
|
segmentIds: stairSegmentIds(draft),
|
||||||
const createdStair = useScene.getState().nodes[stairClone.id as AnyNodeId]
|
}
|
||||||
if (createdStair?.type !== 'stair') {
|
} catch (error) {
|
||||||
throw new Error(`Duplicated stair "${stairClone.id}" was not created`)
|
if (draftId && useScene.getState().nodes[draftId]) {
|
||||||
}
|
useScene.getState().deleteNode(draftId)
|
||||||
|
}
|
||||||
if (mode === 'select') {
|
if (wasTracking) temporal.resume()
|
||||||
useViewer.getState().setSelection({ selectedIds: [createdStair.id] })
|
throw error
|
||||||
} else {
|
|
||||||
useViewer.getState().setSelection({ selectedIds: [createdStair.id] })
|
|
||||||
requestAnimationFrame(() => moveStairWhenRegistered(createdStair.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
stair: createdStair,
|
|
||||||
segmentIds: segmentClones.map((segment) => segment.id),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stairSegmentIds(stair: StairNode): StairSegmentNode['id'][] {
|
||||||
|
return (stair.children ?? []).filter((childId): childId is StairSegmentNode['id'] => {
|
||||||
|
return useScene.getState().nodes[childId as AnyNodeId]?.type === 'stair-segment'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -468,7 +468,7 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
|
|||||||
const aabb = stairFootprintAABB(node as StairNodeType, nodes)
|
const aabb = stairFootprintAABB(node as StairNodeType, nodes)
|
||||||
return aabb ? { shape: 'aabb', ...aabb } : null
|
return aabb ? { shape: 'aabb', ...aabb } : null
|
||||||
},
|
},
|
||||||
duplicable: true,
|
duplicable: { subtree: true },
|
||||||
deletable: true,
|
deletable: true,
|
||||||
floorPlaced: {
|
floorPlaced: {
|
||||||
footprints: (node, ctx) =>
|
footprints: (node, ctx) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user