Enable drag after duplicating roofs and stairs

This commit is contained in:
sudhir
2026-04-27 13:32:31 +05:30
parent 3ae8d6d0fa
commit d00ba42b9f
7 changed files with 419 additions and 183 deletions
@@ -6,8 +6,8 @@ import {
type CeilingNode,
DoorNode,
FenceNode,
generateId,
ItemNode,
RoofNode,
RoofSegmentNode,
type SlabNode,
StairNode,
@@ -23,6 +23,8 @@ import { useFrame } from '@react-three/fiber'
import { Move } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
import { duplicateStairSubtree } from '../../lib/stair-duplication'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { NodeActionMenu } from './node-action-menu'
@@ -234,6 +236,16 @@ export function FloatingActionMenu() {
e.stopPropagation()
if (!node?.parentId) return
sfxEmitter.emit('sfx:item-pick')
if (node.type === 'roof') {
try {
duplicateRoofSubtree(node.id as AnyNodeId, { mode: 'move' })
} catch (error) {
console.error('Failed to duplicate roof', error)
}
return
}
useScene.temporal.getState().pause()
let duplicateInfo = structuredClone(node) as any
@@ -254,10 +266,8 @@ export function FloatingActionMenu() {
duplicate = FenceNode.parse(duplicateInfo)
duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1]
duplicate.end = [duplicate.end[0] + 1, duplicate.end[1] + 1]
} else if (node.type === 'roof') {
duplicateInfo.children = []
duplicate = RoofNode.parse(duplicateInfo)
} else if (node.type === 'roof-segment') {
duplicateInfo.id = generateId('rseg')
duplicate = RoofSegmentNode.parse(duplicateInfo)
} else if (node.type === 'stair') {
duplicateInfo.children = []
@@ -286,7 +296,6 @@ export function FloatingActionMenu() {
} else if (duplicate.type === 'fence') {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
} else if (
duplicate.type === 'roof' ||
duplicate.type === 'roof-segment' ||
duplicate.type === 'stair' ||
duplicate.type === 'stair-segment'
@@ -300,54 +309,11 @@ export function FloatingActionMenu() {
]
}
if (node.type === 'stair' && duplicate.type === 'stair') {
const nodesState = useScene.getState().nodes
const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
{ node: duplicate, parentId: duplicate.parentId as AnyNodeId },
]
for (const childId of node.children ?? []) {
const childNode = nodesState[childId]
if (childNode?.type !== 'stair-segment') {
continue
}
let childDuplicateInfo = structuredClone(childNode) as any
delete childDuplicateInfo.id
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata }
delete childDuplicateInfo.metadata?.isNew
try {
const childDuplicate = StairSegmentNode.parse(childDuplicateInfo)
createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
} catch (e) {
console.error('Failed to duplicate stair segment', e)
}
}
useScene.getState().createNodes(createOps)
duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
} else {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
}
// Duplicate children for roof nodes
if (node.type === 'roof' && node.children) {
const nodesState = useScene.getState().nodes
for (const childId of node.children) {
const childNode = nodesState[childId]
if (childNode && childNode.type === 'roof-segment') {
let childDuplicateInfo = structuredClone(childNode) as any
delete childDuplicateInfo.id
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
try {
const childDuplicate = RoofSegmentNode.parse(childDuplicateInfo)
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
} catch (e) {
console.error('Failed to duplicate roof segment', e)
}
}
}
}
// Duplicate children for stair nodes
}
if (
@@ -356,7 +322,6 @@ export function FloatingActionMenu() {
duplicate.type === 'fence' ||
duplicate.type === 'window' ||
duplicate.type === 'door' ||
duplicate.type === 'roof' ||
duplicate.type === 'roof-segment' ||
duplicate.type === 'stair-segment'
) {
@@ -364,7 +329,7 @@ export function FloatingActionMenu() {
} else if (duplicate.type === 'stair') {
setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
}
if (duplicate.type !== 'stair') {
if (duplicate.type !== 'stair' && duplicate.type !== 'roof') {
setSelection({ selectedIds: [] })
}
}
@@ -80,6 +80,8 @@ import {
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
rotatePlanVector as rotateSharedPlanVector,
} from '../../lib/floorplan'
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
import { duplicateStairSubtree } from '../../lib/stair-duplication'
import { sfxEmitter } from '../../lib/sfx-bus'
import { cn } from '../../lib/utils'
import useEditor from '../../store/use-editor'
@@ -10118,60 +10120,15 @@ export function FloorplanPanel() {
)
const duplicateSelectedStair = useCallback(() => {
const stair = selectedStairEntry?.stair
if (!stair?.parentId) {
if (!stair) {
return
}
sfxEmitter.emit('sfx:item-pick')
useScene.temporal.getState().pause()
const cloned = structuredClone(stair) as Record<string, unknown>
delete cloned.id
cloned.metadata = {
...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),
}
delete (cloned.metadata as Record<string, unknown>).isNew
const nextPosition =
Array.isArray(cloned.position) && cloned.position.length >= 3
? [
Number(cloned.position[0]) + 1,
Number(cloned.position[1]),
Number(cloned.position[2]) + 1,
]
: [stair.position[0] + 1, stair.position[1], stair.position[2] + 1]
cloned.position = nextPosition
try {
const duplicate = StairNodeSchema.parse(cloned)
const nodesState = useScene.getState().nodes
const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
{ node: duplicate, parentId: stair.parentId as AnyNodeId },
]
for (const childId of stair.children ?? []) {
const childNode = nodesState[childId]
if (childNode?.type !== 'stair-segment') {
continue
}
const childClone = structuredClone(childNode) as Record<string, unknown>
delete childClone.id
childClone.metadata = {
...(typeof childClone.metadata === 'object' && childClone.metadata !== null
? childClone.metadata
: {}),
}
delete (childClone.metadata as Record<string, unknown>).isNew
const childDuplicate = StairSegmentNodeSchema.parse(childClone)
createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
}
useScene.getState().createNodes(createOps)
setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
duplicateStairSubtree(stair.id as AnyNodeId, { mode: 'move' })
} catch (error) {
console.error('Failed to duplicate stair', error)
}
@@ -10216,6 +10173,27 @@ export function FloorplanPanel() {
},
[selectedRoofEntry, setMovingNode, setSelection],
)
const duplicateSelectedRoof = useCallback(() => {
const roof = selectedRoofEntry?.roof
if (!roof) {
return
}
sfxEmitter.emit('sfx:item-pick')
try {
duplicateRoofSubtree(roof.id as AnyNodeId, { mode: 'move' })
} catch (error) {
console.error('Failed to duplicate roof', error)
}
}, [selectedRoofEntry])
const handleSelectedRoofDuplicate = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
duplicateSelectedRoof()
},
[duplicateSelectedRoof],
)
const handleSelectedRoofDelete = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
@@ -11081,7 +11059,7 @@ export function FloorplanPanel() {
site,
])
const hasDuplicatableFloorplanSelection = Boolean(
selectedItemEntry || selectedOpeningEntry || selectedStairEntry,
selectedItemEntry || selectedOpeningEntry || selectedStairEntry || selectedRoofEntry,
)
const handleDuplicateFloorplanSelection = useCallback(() => {
if (selectedOpeningEntry) {
@@ -11094,13 +11072,19 @@ export function FloorplanPanel() {
}
if (selectedStairEntry) {
duplicateSelectedStair()
return
}
if (selectedRoofEntry) {
duplicateSelectedRoof()
}
}, [
duplicateSelectedItem,
duplicateSelectedOpening,
duplicateSelectedRoof,
duplicateSelectedStair,
selectedItemEntry,
selectedOpeningEntry,
selectedRoofEntry,
selectedStairEntry,
])
const activeDraftAnchorPoint =
@@ -11174,6 +11158,7 @@ export function FloorplanPanel() {
roof={{
position: selectedRoofActionMenuPosition,
onDelete: handleSelectedRoofDelete,
onDuplicate: handleSelectedRoofDuplicate,
onMove: handleSelectedRoofMove,
}}
slab={{
@@ -16,6 +16,7 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
import { clearRoofDuplicateMetadata } from '../../../lib/roof-duplication'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { snapFenceDraftPoint } from '../fence/fence-drafting'
@@ -100,6 +101,7 @@ export const MoveRoofTool: React.FC<{
// resetting the mesh position (it resets on dirty) and from triggering
// expensive merged-mesh CSG rebuilds on every frame.
let wasCommitted = false
let wasCancelled = false
// Track pending rotation — no store updates during drag
let pendingRotation: number = movingNode.rotation as number
@@ -251,11 +253,19 @@ export const MoveRoofTool: React.FC<{
// Resume temporal and apply the final state as a single undoable step.
useScene.temporal.getState().resume()
useScene.getState().updateNode(movingNode.id, {
position: [localX, movingNode.position[1], localZ],
rotation: pendingRotation,
metadata: committedMeta,
})
if (isNew && movingNode.type === 'roof') {
clearRoofDuplicateMetadata(movingNode.id as AnyNodeId, {
position: [localX, movingNode.position[1], localZ],
rotation: pendingRotation,
metadata: committedMeta,
})
} else {
useScene.getState().updateNode(movingNode.id, {
position: [localX, movingNode.position[1], localZ],
rotation: pendingRotation,
metadata: committedMeta,
})
}
useScene.temporal.getState().pause()
@@ -267,6 +277,7 @@ export const MoveRoofTool: React.FC<{
}
const onCancel = () => {
wasCancelled = true
useLiveTransforms.getState().clear(movingNode.id)
if (isNew) {
useScene.getState().deleteNode(movingNode.id)
@@ -325,16 +336,12 @@ export const MoveRoofTool: React.FC<{
// Clear ephemeral live transform
useLiveTransforms.getState().clear(movingNode.id)
if (!wasCommitted) {
if (isNew) {
useScene.getState().deleteNode(movingNode.id)
} else {
useScene.getState().updateNode(movingNode.id, {
position: original.position,
rotation: original.rotation,
metadata: original.metadata,
})
}
if (!(wasCommitted || wasCancelled || isNew)) {
useScene.getState().updateNode(movingNode.id, {
position: original.position,
rotation: original.rotation,
metadata: original.metadata,
})
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
@@ -7,7 +7,6 @@ import {
type MaterialSchema,
type RoofNode,
type RoofSurfaceMaterialRole,
RoofNode as RoofNodeSchema,
type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema,
useScene,
@@ -17,6 +16,7 @@ import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { duplicateRoofSubtree } from '../../../lib/roof-duplication'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
@@ -131,44 +131,15 @@ export function RoofPanel() {
)
const handleDuplicate = useCallback(() => {
if (!node?.parentId) return
if (!node) return
sfxEmitter.emit('sfx:item-pick')
let duplicateInfo = structuredClone(node) as any
delete duplicateInfo.id
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
// Offset slightly so it's visible
duplicateInfo.position = [
duplicateInfo.position[0] + 1,
duplicateInfo.position[1],
duplicateInfo.position[2] + 1,
]
try {
const duplicate = RoofNodeSchema.parse(duplicateInfo)
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
// Also duplicate all child segments
const nodesState = useScene.getState().nodes
const children = node.children || []
for (const childId of children) {
const childNode = nodesState[childId]
if (childNode && childNode.type === 'roof-segment') {
let childDuplicateInfo = structuredClone(childNode) as any
delete childDuplicateInfo.id
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
const childDuplicate = RoofSegmentNodeSchema.parse(childDuplicateInfo)
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
}
}
setSelection({ selectedIds: [] })
setMovingNode(duplicate)
duplicateRoofSubtree(node.id as AnyNodeId, { mode: 'move' })
} catch (e) {
console.error('Failed to duplicate roof', e)
}
}, [node, setSelection, setMovingNode])
}, [node])
const handleMove = useCallback(() => {
if (node) {
@@ -12,7 +12,6 @@ import {
type StairSlabOpeningMode,
type StairTopLandingMode,
type StairType,
StairNode as StairNodeSchema,
type StairSegmentNode,
StairSegmentNode as StairSegmentNodeSchema,
useScene,
@@ -20,6 +19,7 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { duplicateStairSubtree } from '../../../lib/stair-duplication'
import { useShallow } from 'zustand/react/shallow'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
@@ -88,7 +88,6 @@ export function StairPanel() {
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode)
const createNodes = useScene((s) => s.createNodes)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
@@ -209,46 +208,15 @@ export function StairPanel() {
)
const handleDuplicate = useCallback(() => {
if (!node?.parentId) return
if (!node) return
sfxEmitter.emit('sfx:item-pick')
let duplicateInfo = structuredClone(node) as any
delete duplicateInfo.id
duplicateInfo.metadata = { ...duplicateInfo.metadata }
duplicateInfo.children = []
duplicateInfo.position = [
duplicateInfo.position[0] + 1,
duplicateInfo.position[1],
duplicateInfo.position[2] + 1,
]
try {
const duplicate = StairNodeSchema.parse(duplicateInfo)
const nodesState = useScene.getState().nodes
const children = node.children || []
const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
{ node: duplicate, parentId: duplicate.parentId as AnyNodeId },
]
for (const childId of children) {
const childNode = nodesState[childId]
if (childNode && childNode.type === 'stair-segment') {
let childDuplicateInfo = structuredClone(childNode) as any
delete childDuplicateInfo.id
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata }
const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo)
createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
}
}
createNodes(createOps)
setSelection({ selectedIds: [duplicate.id as AnyNode['id']] })
duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
} catch (e) {
console.error('Failed to duplicate stair', e)
}
}, [createNodes, node, setSelection])
}, [node])
const handleMove = useCallback(() => {
if (node) {
+214
View File
@@ -0,0 +1,214 @@
'use client'
import {
type AnyNodeId,
generateId,
type RoofNode,
RoofNode as RoofNodeSchema,
type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import useEditor from '../store/use-editor'
type DuplicateRoofMode = 'select' | 'move'
type DuplicateRoofOptions = {
mode?: DuplicateRoofMode
offset?: [number, number, number]
parentId?: AnyNodeId
}
type DuplicateRoofResult = {
roof: RoofNode
segmentIds: RoofSegmentNode['id'][]
}
const MOVE_REGISTRY_RETRY_LIMIT = 12
function stripDuplicateFlags(metadata: unknown) {
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
return metadata
}
const nextMeta = { ...(metadata as Record<string, unknown>) }
delete nextMeta.isNew
delete nextMeta.isTransient
return nextMeta
}
function buildDuplicateMetadata(metadata: unknown) {
const cleaned = stripDuplicateFlags(metadata)
if (typeof cleaned !== 'object' || cleaned === null || Array.isArray(cleaned)) {
return { isNew: true }
}
return {
...cleaned,
isNew: true,
}
}
function moveRoofWhenRegistered(roofId: RoofNode['id'], attempt = 0) {
const latestRoof = useScene.getState().nodes[roofId as AnyNodeId]
if (latestRoof?.type !== 'roof') {
return
}
if (sceneRegistry.nodes.has(roofId)) {
useEditor.getState().setMovingNode(latestRoof)
useViewer.getState().setSelection({ selectedIds: [] })
return
}
if (attempt >= MOVE_REGISTRY_RETRY_LIMIT) {
console.warn(`Duplicated roof "${roofId}" did not register before move mode started`)
return
}
requestAnimationFrame(() => moveRoofWhenRegistered(roofId, attempt + 1))
}
export function duplicateRoofSubtree(
sourceRoofId: AnyNodeId,
options: DuplicateRoofOptions = {},
): DuplicateRoofResult {
const { mode = 'move', offset = [1, 0, 1], parentId: explicitParentId } = options
const scene = useScene.getState()
const sourceRoof = scene.nodes[sourceRoofId]
if (!sourceRoof || sourceRoof.type !== 'roof') {
throw new Error(`Node "${sourceRoofId}" is not a roof`)
}
const parentId = explicitParentId ?? (sourceRoof.parentId as AnyNodeId | null)
if (!parentId) {
throw new Error(`Roof "${sourceRoofId}" is missing a parent level`)
}
const roofClone = RoofNodeSchema.parse({
...structuredClone(sourceRoof),
id: generateId('roof'),
parentId,
children: [],
position: [
sourceRoof.position[0] + offset[0],
sourceRoof.position[1] + offset[1],
sourceRoof.position[2] + offset[2],
] as RoofNode['position'],
metadata: buildDuplicateMetadata(sourceRoof.metadata),
})
const segmentClones: RoofSegmentNode[] = []
for (const childId of sourceRoof.children ?? []) {
const childNode = scene.nodes[childId as AnyNodeId]
if (!childNode || childNode.type !== 'roof-segment') {
continue
}
const childClone = RoofSegmentNodeSchema.parse({
...structuredClone(childNode),
id: generateId('rseg'),
parentId: roofClone.id,
metadata: buildDuplicateMetadata(childNode.metadata),
})
segmentClones.push(childClone)
}
scene.createNodes([
{ node: roofClone, parentId },
...segmentClones.map((segment) => ({ node: segment, parentId: roofClone.id as AnyNodeId })),
])
const nextScene = useScene.getState()
const createdRoof = nextScene.nodes[roofClone.id as AnyNodeId]
if (!createdRoof || createdRoof.type !== 'roof') {
throw new Error(`Duplicated roof "${roofClone.id}" was not created`)
}
const createdParent = nextScene.nodes[parentId]
const parentChildIds =
createdParent && 'children' in createdParent && Array.isArray(createdParent.children)
? (createdParent.children as AnyNodeId[])
: null
if (!createdParent || !parentChildIds?.includes(createdRoof.id as AnyNodeId)) {
throw new Error(`Duplicated roof "${createdRoof.id}" was not linked to parent "${parentId}"`)
}
const segmentIds = segmentClones.map((segment) => segment.id)
const createdChildIds = (createdRoof.children ?? []) as AnyNodeId[]
const missingSegmentId = segmentIds.find(
(segmentId) => !createdChildIds.includes(segmentId as AnyNodeId),
)
if (missingSegmentId) {
throw new Error(
`Duplicated roof "${createdRoof.id}" is missing cloned segment "${missingSegmentId}"`,
)
}
const invalidSegment = segmentIds.find((segmentId) => {
const segment = nextScene.nodes[segmentId as AnyNodeId]
return !segment || segment.type !== 'roof-segment' || segment.parentId !== createdRoof.id
})
if (invalidSegment) {
throw new Error(
`Duplicated roof segment "${invalidSegment}" was not linked to roof "${createdRoof.id}"`,
)
}
const setSelection = useViewer.getState().setSelection
if (mode === 'select') {
setSelection({ selectedIds: [createdRoof.id] })
} else {
setSelection({ selectedIds: [createdRoof.id] })
requestAnimationFrame(() => moveRoofWhenRegistered(createdRoof.id))
}
return {
roof: createdRoof,
segmentIds,
}
}
export function clearRoofDuplicateMetadata(
roofId: AnyNodeId,
updates: Partial<Pick<RoofNode, 'position' | 'rotation' | 'metadata'>> = {},
) {
const scene = useScene.getState()
const roofNode = scene.nodes[roofId]
if (!roofNode || roofNode.type !== 'roof') {
return
}
const nodeUpdates: { id: AnyNodeId; data: Record<string, unknown> }[] = [
{
id: roofId,
data: {
...updates,
metadata:
updates.metadata !== undefined
? stripDuplicateFlags(updates.metadata)
: stripDuplicateFlags(roofNode.metadata),
},
},
]
for (const childId of roofNode.children ?? []) {
const childNode = scene.nodes[childId as AnyNodeId]
if (!childNode || childNode.type !== 'roof-segment') {
continue
}
nodeUpdates.push({
id: childNode.id as AnyNodeId,
data: {
metadata: stripDuplicateFlags(childNode.metadata),
},
})
}
scene.updateNodes(nodeUpdates as { id: AnyNodeId; data: Partial<RoofNode | RoofSegmentNode> }[])
}
@@ -0,0 +1,126 @@
import {
generateId,
type AnyNodeId,
sceneRegistry,
type StairNode,
StairNode as StairNodeSchema,
type StairSegmentNode,
StairSegmentNode as StairSegmentNodeSchema,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import useEditor from '../store/use-editor'
type DuplicateStairOptions = {
mode?: 'select' | 'move'
offset?: [number, number, number]
parentId?: AnyNodeId
}
type DuplicateStairResult = {
stair: StairNode
segmentIds: StairSegmentNode['id'][]
}
const MOVE_REGISTRY_RETRY_LIMIT = 12
function stripDuplicateFlags(metadata: unknown) {
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
return metadata
}
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 || 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(
sourceStairId: AnyNodeId,
options: DuplicateStairOptions = {},
): DuplicateStairResult {
const { mode = 'move', offset = [1, 0, 1], parentId: explicitParentId } = options
const scene = useScene.getState()
const sourceStair = scene.nodes[sourceStairId]
if (!sourceStair || sourceStair.type !== 'stair') {
throw new Error(`Node "${sourceStairId}" is not a stair`)
}
const parentId = explicitParentId ?? (sourceStair.parentId as AnyNodeId | null)
if (!parentId) {
throw new Error(`Stair "${sourceStairId}" is missing a parent level`)
}
const stairClone = StairNodeSchema.parse({
...structuredClone(sourceStair),
id: generateId('stair'),
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[] = []
for (const childId of sourceStair.children ?? []) {
const childNode = scene.nodes[childId as AnyNodeId]
if (!childNode || childNode.type !== 'stair-segment') {
continue
}
const childClone = StairSegmentNodeSchema.parse({
...structuredClone(childNode),
id: generateId('sseg'),
parentId: stairClone.id,
metadata: stripDuplicateFlags(childNode.metadata),
})
segmentClones.push(childClone)
}
scene.createNodes([
{ node: stairClone, parentId },
...segmentClones.map((segment) => ({ node: segment, parentId: stairClone.id as AnyNodeId })),
])
const createdStair = useScene.getState().nodes[stairClone.id as AnyNodeId]
if (!createdStair || createdStair.type !== 'stair') {
throw new Error(`Duplicated stair "${stairClone.id}" was not created`)
}
if (mode === 'select') {
useViewer.getState().setSelection({ selectedIds: [createdStair.id] })
} else {
useViewer.getState().setSelection({ selectedIds: [createdStair.id] })
requestAnimationFrame(() => moveStairWhenRegistered(createdStair.id))
}
return {
stair: createdStair,
segmentIds: segmentClones.map((segment) => segment.id),
}
}