diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index a3ab3da8..6d5d36ed 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -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: [] }) } } diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index a4b1aea2..363ed87e 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -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 - delete cloned.id - cloned.metadata = { - ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}), - } - delete (cloned.metadata as Record).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 - delete childClone.id - childClone.metadata = { - ...(typeof childClone.metadata === 'object' && childClone.metadata !== null - ? childClone.metadata - : {}), - } - delete (childClone.metadata as Record).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) => { + event.stopPropagation() + duplicateSelectedRoof() + }, + [duplicateSelectedRoof], + ) const handleSelectedRoofDelete = useCallback( (event: ReactMouseEvent) => { 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={{ diff --git a/packages/editor/src/components/tools/roof/move-roof-tool.tsx b/packages/editor/src/components/tools/roof/move-roof-tool.tsx index 502ff0aa..1f24c89e 100644 --- a/packages/editor/src/components/tools/roof/move-roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/move-roof-tool.tsx @@ -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) diff --git a/packages/editor/src/components/ui/panels/roof-panel.tsx b/packages/editor/src/components/ui/panels/roof-panel.tsx index 69cdba15..d277b45a 100755 --- a/packages/editor/src/components/ui/panels/roof-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-panel.tsx @@ -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) { diff --git a/packages/editor/src/components/ui/panels/stair-panel.tsx b/packages/editor/src/components/ui/panels/stair-panel.tsx index 8756cc0b..4746f756 100644 --- a/packages/editor/src/components/ui/panels/stair-panel.tsx +++ b/packages/editor/src/components/ui/panels/stair-panel.tsx @@ -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) { diff --git a/packages/editor/src/lib/roof-duplication.ts b/packages/editor/src/lib/roof-duplication.ts new file mode 100644 index 00000000..4ce30462 --- /dev/null +++ b/packages/editor/src/lib/roof-duplication.ts @@ -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) } + 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> = {}, +) { + const scene = useScene.getState() + const roofNode = scene.nodes[roofId] + if (!roofNode || roofNode.type !== 'roof') { + return + } + + const nodeUpdates: { id: AnyNodeId; data: Record }[] = [ + { + 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 }[]) +} diff --git a/packages/editor/src/lib/stair-duplication.ts b/packages/editor/src/lib/stair-duplication.ts new file mode 100644 index 00000000..4d607c7a --- /dev/null +++ b/packages/editor/src/lib/stair-duplication.ts @@ -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) } + 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), + } +}