Enable drag after duplicating roofs and stairs
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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