Enable drag after duplicating roofs and stairs
This commit is contained in:
@@ -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()
|
||||
|
||||
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,17 +336,13 @@ export const MoveRoofTool: React.FC<{
|
||||
// Clear ephemeral live transform
|
||||
useLiveTransforms.getState().clear(movingNode.id)
|
||||
|
||||
if (!wasCommitted) {
|
||||
if (isNew) {
|
||||
useScene.getState().deleteNode(movingNode.id)
|
||||
} else {
|
||||
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)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user