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, type CeilingNode,
DoorNode, DoorNode,
FenceNode, FenceNode,
generateId,
ItemNode, ItemNode,
RoofNode,
RoofSegmentNode, RoofSegmentNode,
type SlabNode, type SlabNode,
StairNode, StairNode,
@@ -23,6 +23,8 @@ import { useFrame } from '@react-three/fiber'
import { Move } from 'lucide-react' import { Move } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
import { duplicateStairSubtree } from '../../lib/stair-duplication'
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 { NodeActionMenu } from './node-action-menu' import { NodeActionMenu } from './node-action-menu'
@@ -234,6 +236,16 @@ export function FloatingActionMenu() {
e.stopPropagation() e.stopPropagation()
if (!node?.parentId) return if (!node?.parentId) return
sfxEmitter.emit('sfx:item-pick') 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() useScene.temporal.getState().pause()
let duplicateInfo = structuredClone(node) as any let duplicateInfo = structuredClone(node) as any
@@ -254,10 +266,8 @@ export function FloatingActionMenu() {
duplicate = FenceNode.parse(duplicateInfo) duplicate = FenceNode.parse(duplicateInfo)
duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1] duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1]
duplicate.end = [duplicate.end[0] + 1, duplicate.end[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') { } else if (node.type === 'roof-segment') {
duplicateInfo.id = generateId('rseg')
duplicate = RoofSegmentNode.parse(duplicateInfo) duplicate = RoofSegmentNode.parse(duplicateInfo)
} else if (node.type === 'stair') { } else if (node.type === 'stair') {
duplicateInfo.children = [] duplicateInfo.children = []
@@ -286,7 +296,6 @@ export function FloatingActionMenu() {
} 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' ||
duplicate.type === 'roof-segment' || duplicate.type === 'roof-segment' ||
duplicate.type === 'stair' || duplicate.type === 'stair' ||
duplicate.type === 'stair-segment' duplicate.type === 'stair-segment'
@@ -300,54 +309,11 @@ export function FloatingActionMenu() {
] ]
} }
if (node.type === 'stair' && duplicate.type === 'stair') { if (node.type === 'stair' && duplicate.type === 'stair') {
const nodesState = useScene.getState().nodes duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
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)
} else { } else {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) 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 // Duplicate children for stair nodes
} }
if ( if (
@@ -356,7 +322,6 @@ export function FloatingActionMenu() {
duplicate.type === 'fence' || duplicate.type === 'fence' ||
duplicate.type === 'window' || duplicate.type === 'window' ||
duplicate.type === 'door' || duplicate.type === 'door' ||
duplicate.type === 'roof' ||
duplicate.type === 'roof-segment' || duplicate.type === 'roof-segment' ||
duplicate.type === 'stair-segment' duplicate.type === 'stair-segment'
) { ) {
@@ -364,7 +329,7 @@ export function FloatingActionMenu() {
} else if (duplicate.type === 'stair') { } else if (duplicate.type === 'stair') {
setSelection({ selectedIds: [duplicate.id as AnyNodeId] }) setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
} }
if (duplicate.type !== 'stair') { if (duplicate.type !== 'stair' && duplicate.type !== 'roof') {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
} }
} }
@@ -80,6 +80,8 @@ import {
type FloorplanNodeTransform as SharedFloorplanNodeTransform, type FloorplanNodeTransform as SharedFloorplanNodeTransform,
rotatePlanVector as rotateSharedPlanVector, rotatePlanVector as rotateSharedPlanVector,
} from '../../lib/floorplan' } from '../../lib/floorplan'
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
import { duplicateStairSubtree } from '../../lib/stair-duplication'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
@@ -10118,60 +10120,15 @@ export function FloorplanPanel() {
) )
const duplicateSelectedStair = useCallback(() => { const duplicateSelectedStair = useCallback(() => {
const stair = selectedStairEntry?.stair const stair = selectedStairEntry?.stair
if (!stair?.parentId) { if (!stair) {
return return
} }
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
useScene.temporal.getState().pause() 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 { try {
const duplicate = StairNodeSchema.parse(cloned) duplicateStairSubtree(stair.id as AnyNodeId, { mode: 'move' })
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] })
} catch (error) { } catch (error) {
console.error('Failed to duplicate stair', error) console.error('Failed to duplicate stair', error)
} }
@@ -10216,6 +10173,27 @@ export function FloorplanPanel() {
}, },
[selectedRoofEntry, setMovingNode, setSelection], [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( const handleSelectedRoofDelete = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => { (event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation() event.stopPropagation()
@@ -11081,7 +11059,7 @@ export function FloorplanPanel() {
site, site,
]) ])
const hasDuplicatableFloorplanSelection = Boolean( const hasDuplicatableFloorplanSelection = Boolean(
selectedItemEntry || selectedOpeningEntry || selectedStairEntry, selectedItemEntry || selectedOpeningEntry || selectedStairEntry || selectedRoofEntry,
) )
const handleDuplicateFloorplanSelection = useCallback(() => { const handleDuplicateFloorplanSelection = useCallback(() => {
if (selectedOpeningEntry) { if (selectedOpeningEntry) {
@@ -11094,13 +11072,19 @@ export function FloorplanPanel() {
} }
if (selectedStairEntry) { if (selectedStairEntry) {
duplicateSelectedStair() duplicateSelectedStair()
return
}
if (selectedRoofEntry) {
duplicateSelectedRoof()
} }
}, [ }, [
duplicateSelectedItem, duplicateSelectedItem,
duplicateSelectedOpening, duplicateSelectedOpening,
duplicateSelectedRoof,
duplicateSelectedStair, duplicateSelectedStair,
selectedItemEntry, selectedItemEntry,
selectedOpeningEntry, selectedOpeningEntry,
selectedRoofEntry,
selectedStairEntry, selectedStairEntry,
]) ])
const activeDraftAnchorPoint = const activeDraftAnchorPoint =
@@ -11174,6 +11158,7 @@ export function FloorplanPanel() {
roof={{ roof={{
position: selectedRoofActionMenuPosition, position: selectedRoofActionMenuPosition,
onDelete: handleSelectedRoofDelete, onDelete: handleSelectedRoofDelete,
onDuplicate: handleSelectedRoofDuplicate,
onMove: handleSelectedRoofMove, onMove: handleSelectedRoofMove,
}} }}
slab={{ slab={{
@@ -16,6 +16,7 @@ import {
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'
import * as THREE from 'three' import * as THREE from 'three'
import { clearRoofDuplicateMetadata } from '../../../lib/roof-duplication'
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 { snapFenceDraftPoint } from '../fence/fence-drafting' 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 // resetting the mesh position (it resets on dirty) and from triggering
// expensive merged-mesh CSG rebuilds on every frame. // expensive merged-mesh CSG rebuilds on every frame.
let wasCommitted = false let wasCommitted = false
let wasCancelled = false
// Track pending rotation — no store updates during drag // Track pending rotation — no store updates during drag
let pendingRotation: number = movingNode.rotation as number 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. // Resume temporal and apply the final state as a single undoable step.
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
useScene.getState().updateNode(movingNode.id, { if (isNew && movingNode.type === 'roof') {
position: [localX, movingNode.position[1], localZ], clearRoofDuplicateMetadata(movingNode.id as AnyNodeId, {
rotation: pendingRotation, position: [localX, movingNode.position[1], localZ],
metadata: committedMeta, rotation: pendingRotation,
}) metadata: committedMeta,
})
} else {
useScene.getState().updateNode(movingNode.id, {
position: [localX, movingNode.position[1], localZ],
rotation: pendingRotation,
metadata: committedMeta,
})
}
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
@@ -267,6 +277,7 @@ export const MoveRoofTool: React.FC<{
} }
const onCancel = () => { const onCancel = () => {
wasCancelled = true
useLiveTransforms.getState().clear(movingNode.id) useLiveTransforms.getState().clear(movingNode.id)
if (isNew) { if (isNew) {
useScene.getState().deleteNode(movingNode.id) useScene.getState().deleteNode(movingNode.id)
@@ -325,16 +336,12 @@ export const MoveRoofTool: React.FC<{
// Clear ephemeral live transform // Clear ephemeral live transform
useLiveTransforms.getState().clear(movingNode.id) useLiveTransforms.getState().clear(movingNode.id)
if (!wasCommitted) { if (!(wasCommitted || wasCancelled || isNew)) {
if (isNew) { useScene.getState().updateNode(movingNode.id, {
useScene.getState().deleteNode(movingNode.id) position: original.position,
} else { rotation: original.rotation,
useScene.getState().updateNode(movingNode.id, { metadata: original.metadata,
position: original.position, })
rotation: original.rotation,
metadata: original.metadata,
})
}
} }
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
@@ -7,7 +7,6 @@ import {
type MaterialSchema, type MaterialSchema,
type RoofNode, type RoofNode,
type RoofSurfaceMaterialRole, type RoofSurfaceMaterialRole,
RoofNode as RoofNodeSchema,
type RoofSegmentNode, type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema, RoofSegmentNode as RoofSegmentNodeSchema,
useScene, useScene,
@@ -17,6 +16,7 @@ import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { duplicateRoofSubtree } from '../../../lib/roof-duplication'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker' import { MaterialPicker } from '../controls/material-picker'
@@ -131,44 +131,15 @@ export function RoofPanel() {
) )
const handleDuplicate = useCallback(() => { const handleDuplicate = useCallback(() => {
if (!node?.parentId) return if (!node) return
sfxEmitter.emit('sfx:item-pick') 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 { try {
const duplicate = RoofNodeSchema.parse(duplicateInfo) duplicateRoofSubtree(node.id as AnyNodeId, { mode: 'move' })
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)
} catch (e) { } catch (e) {
console.error('Failed to duplicate roof', e) console.error('Failed to duplicate roof', e)
} }
}, [node, setSelection, setMovingNode]) }, [node])
const handleMove = useCallback(() => { const handleMove = useCallback(() => {
if (node) { if (node) {
@@ -12,7 +12,6 @@ import {
type StairSlabOpeningMode, type StairSlabOpeningMode,
type StairTopLandingMode, type StairTopLandingMode,
type StairType, type StairType,
StairNode as StairNodeSchema,
type StairSegmentNode, type StairSegmentNode,
StairSegmentNode as StairSegmentNodeSchema, StairSegmentNode as StairSegmentNodeSchema,
useScene, useScene,
@@ -20,6 +19,7 @@ import {
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react' import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { duplicateStairSubtree } from '../../../lib/stair-duplication'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
@@ -88,7 +88,6 @@ export function StairPanel() {
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode) const createNode = useScene((s) => s.createNode)
const createNodes = useScene((s) => s.createNodes)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget) const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
@@ -209,46 +208,15 @@ export function StairPanel() {
) )
const handleDuplicate = useCallback(() => { const handleDuplicate = useCallback(() => {
if (!node?.parentId) return if (!node) return
sfxEmitter.emit('sfx:item-pick') 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 { try {
const duplicate = StairNodeSchema.parse(duplicateInfo) duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
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']] })
} catch (e) { } catch (e) {
console.error('Failed to duplicate stair', e) console.error('Failed to duplicate stair', e)
} }
}, [createNodes, node, setSelection]) }, [node])
const handleMove = useCallback(() => { const handleMove = useCallback(() => {
if (node) { 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),
}
}