Add duplicate options to level menus

This commit is contained in:
sudhir
2026-04-28 11:09:51 +05:30
parent 5607671d25
commit 025a9b5d28
8 changed files with 404 additions and 72 deletions
@@ -309,8 +309,11 @@ export const ceilingStrategy = {
const rotY = ctx.draftItem?.rotation?.[1] ?? 0 const rotY = ctx.draftItem?.rotation?.[1] ?? 0
const swapDims = Math.abs(Math.sin(rotY)) > 0.9 const swapDims = Math.abs(Math.sin(rotY)) > 0.9
const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX) // Ceiling items are stored in ceiling-local coordinates, so snapping must
const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ) // use the ceiling hit's local position rather than world position.
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
return { return {
stateUpdate: { surface: 'ceiling', ceilingId: event.node.id }, stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
@@ -320,7 +323,7 @@ export const ceilingStrategy = {
}, },
cursorRotationY: 0, cursorRotationY: 0,
gridPosition: [x, -itemHeight, z], gridPosition: [x, -itemHeight, z],
cursorPosition: [x, event.position[1] - itemHeight, z], cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
stopPropagation: true, stopPropagation: true,
} }
}, },
@@ -338,12 +341,13 @@ export const ceilingStrategy = {
const rotY = ctx.draftItem.rotation?.[1] ?? 0 const rotY = ctx.draftItem.rotation?.[1] ?? 0
const swapDims = Math.abs(Math.sin(rotY)) > 0.9 const swapDims = Math.abs(Math.sin(rotY)) > 0.9
const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX) const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ) const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
const worldSnapped = event.object.localToWorld(new Vector3(x, -itemHeight, z))
return { return {
gridPosition: [x, -itemHeight, z], gridPosition: [x, -itemHeight, z],
cursorPosition: [x, event.position[1] - itemHeight, z], cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
cursorRotationY: 0, cursorRotationY: 0,
nodeUpdate: null, nodeUpdate: null,
stopPropagation: true, stopPropagation: true,
@@ -614,7 +614,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return return
} }
lastRawPos.current.set(event.position[0], event.position[1], event.position[2]) lastRawPos.current.set(
event.localPosition[0],
event.localPosition[1],
event.localPosition[2],
)
const result = ceilingStrategy.move(getContext(), event) const result = ceilingStrategy.move(getContext(), event)
if (!result) return if (!result) return
@@ -8,11 +8,16 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { MoreVertical, Plus, Trash2 } from 'lucide-react' import { Copy, MoreVertical, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import {
buildLevelDuplicateCreateOps,
type LevelDuplicatePreset,
} from '../../lib/level-duplication'
import { deleteLevelWithFallbackSelection } from '../../lib/level-selection' import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import { LevelDuplicateDialog } from './level-duplicate-dialog'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -92,13 +97,16 @@ function LevelRow({
level, level,
isSelected, isSelected,
onSelect, onSelect,
onDuplicate,
onRequestDelete, onRequestDelete,
}: { }: {
level: LevelNode level: LevelNode
isSelected: boolean isSelected: boolean
onSelect: () => void onSelect: () => void
onDuplicate: (preset?: LevelDuplicatePreset) => void
onRequestDelete: () => void onRequestDelete: () => void
}) { }) {
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
return ( return (
@@ -142,7 +150,29 @@ function LevelRow({
<MoreVertical className="h-3 w-3" /> <MoreVertical className="h-3 w-3" />
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="start" className="w-36 p-1" side="right" sideOffset={8}> <PopoverContent align="start" className="w-44 p-1" side="right" sideOffset={8}>
<button
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
onDuplicate()
}}
type="button"
>
<Copy className="h-3 w-3" />
Duplicate level
</button>
<button
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
setDuplicateDialogOpen(true)
}}
type="button"
>
<Copy className="h-3 w-3" />
Duplicate with options...
</button>
<button <button
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-red-400" className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-red-400"
onClick={(e) => { onClick={(e) => {
@@ -158,6 +188,15 @@ function LevelRow({
</Popover> </Popover>
</div> </div>
)} )}
<LevelDuplicateDialog
level={level}
onConfirm={(preset) => {
onDuplicate(preset)
setDuplicateDialogOpen(false)
}}
onOpenChange={setDuplicateDialogOpen}
open={duplicateDialogOpen}
/>
</div> </div>
) )
} }
@@ -169,6 +208,7 @@ export function FloatingLevelSelector() {
const levelId = useViewer((s) => s.selection.levelId) const levelId = useViewer((s) => s.selection.levelId)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const createNode = useScene((s) => s.createNode) const createNode = useScene((s) => s.createNode)
const createNodes = useScene((s) => s.createNodes)
const updateNodes = useScene((s) => s.updateNodes) const updateNodes = useScene((s) => s.updateNodes)
const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null) const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
@@ -251,6 +291,22 @@ export function FloatingLevelSelector() {
setDeletingLevel(null) setDeletingLevel(null)
}, [deletingLevel]) }, [deletingLevel])
const handleDuplicateLevel = useCallback(
(level: LevelNode, preset: LevelDuplicatePreset = 'everything') => {
const { createOps, newLevelId } = buildLevelDuplicateCreateOps({
nodes: useScene.getState().nodes,
level,
levels,
preset,
})
createNodes(createOps)
setSelection({ buildingId: resolvedBuildingId ?? undefined, levelId: newLevelId })
},
[createNodes, levels, resolvedBuildingId, setSelection],
)
if (levels.length === 0) return null if (levels.length === 0) return null
const reversedLevels = [...levels].reverse() const reversedLevels = [...levels].reverse()
@@ -294,6 +350,7 @@ export function FloatingLevelSelector() {
<LevelRow <LevelRow
isSelected={isSelected} isSelected={isSelected}
level={level} level={level}
onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
onRequestDelete={() => setDeletingLevel(level)} onRequestDelete={() => setDeletingLevel(level)}
onSelect={() => onSelect={() =>
setSelection( setSelection(
@@ -9,6 +9,7 @@ import { SlabHelper } from './slab-helper'
import { WallHelper } from './wall-helper' import { WallHelper } from './wall-helper'
export function HelperManager() { export function HelperManager() {
const mode = useEditor((s) => s.mode)
const tool = useEditor((s) => s.tool) const tool = useEditor((s) => s.tool)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
@@ -17,6 +18,10 @@ export function HelperManager() {
return <ItemHelper showEsc /> return <ItemHelper showEsc />
} }
if (mode === 'material-paint') {
return null
}
// Show appropriate helper based on current tool // Show appropriate helper based on current tool
switch (tool) { switch (tool) {
case 'wall': case 'wall':
@@ -0,0 +1,115 @@
'use client'
import type { LevelNode } from '@pascal-app/core'
import { useEffect, useState } from 'react'
import { cn } from '../../lib/utils'
import type { LevelDuplicatePreset } from '../../lib/level-duplication'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './primitives/dialog'
const DUPLICATE_PRESETS: Array<{
id: LevelDuplicatePreset
label: string
description: string
}> = [
{
id: 'everything',
label: 'Everything',
description: 'Structure, materials, furniture, and references.',
},
{
id: 'structure',
label: 'Structure only',
description: 'Walls, slabs, roofs, stairs, windows, and doors without finishes.',
},
{
id: 'structure-materials',
label: 'Structure + materials',
description: 'Structure with the current material and finish assignments.',
},
{
id: 'structure-furniture',
label: 'Structure + furniture',
description: 'Structure, finishes, and placed items, without guide references.',
},
]
function getLevelLabel(level: LevelNode | null) {
if (!level) return 'this level'
return level.name || `Level ${level.level}`
}
export function LevelDuplicateDialog({
open,
level,
onConfirm,
onOpenChange,
}: {
open: boolean
level: LevelNode | null
onConfirm: (preset: LevelDuplicatePreset) => void
onOpenChange: (open: boolean) => void
}) {
const [preset, setPreset] = useState<LevelDuplicatePreset>('everything')
useEffect(() => {
if (open) {
setPreset('everything')
}
}, [open])
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Duplicate Level</DialogTitle>
<DialogDescription>
Choose what to copy from {getLevelLabel(level)}.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
{DUPLICATE_PRESETS.map((option) => (
<button
className={cn(
'cursor-pointer rounded-xl border px-3 py-3 text-left transition-colors',
preset === option.id
? 'border-primary bg-primary/10 text-foreground'
: 'border-border bg-background hover:bg-accent/40',
)}
key={option.id}
onClick={() => setPreset(option.id)}
type="button"
>
<div className="font-medium text-sm">{option.label}</div>
<div className="mt-1 text-muted-foreground text-xs">{option.description}</div>
</button>
))}
</div>
<DialogFooter>
<button
className="cursor-pointer rounded-md px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent"
onClick={() => onOpenChange(false)}
type="button"
>
Cancel
</button>
<button
className="cursor-pointer rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-opacity hover:opacity-90"
onClick={() => onConfirm(preset)}
type="button"
>
Duplicate
</button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,6 +1,7 @@
'use client' 'use client'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { SliderControl } from '../controls/slider-control'
import { Input } from '../primitives/input' import { Input } from '../primitives/input'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
@@ -77,68 +78,42 @@ export function PaintPanel() {
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-1">
<div className="flex items-center justify-between"> <label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]"> Surface
Roughness
</label> </label>
<span className="font-mono text-muted-foreground text-xs"> <div className="space-y-1 rounded-lg border border-border/50 bg-background/40 p-2">
{currentProps.roughness.toFixed(2)} <SliderControl
</span> label="Roughness"
</div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1} max={1}
min={0} min={0}
onChange={(e) => updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) })} onChange={(roughness) => updateCustomMaterial({ roughness })}
precision={2}
step={0.01} step={0.01}
type="range"
value={currentProps.roughness} value={currentProps.roughness}
/> />
</div> <SliderControl
label="Metalness"
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Metalness
</label>
<span className="font-mono text-muted-foreground text-xs">
{currentProps.metalness.toFixed(2)}
</span>
</div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1} max={1}
min={0} min={0}
onChange={(e) => updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) })} onChange={(metalness) => updateCustomMaterial({ metalness })}
precision={2}
step={0.01} step={0.01}
type="range"
value={currentProps.metalness} value={currentProps.metalness}
/> />
</div> <SliderControl
label="Opacity"
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Opacity
</label>
<span className="font-mono text-muted-foreground text-xs">
{currentProps.opacity.toFixed(2)}
</span>
</div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1} max={1}
min={0} min={0}
onChange={(e) => { onChange={(opacity) =>
const opacity = Number.parseFloat(e.target.value)
updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent) updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
}} }
precision={2}
step={0.01} step={0.01}
type="range"
value={currentProps.opacity} value={currentProps.opacity}
/> />
</div> </div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]"> <label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
@@ -3,9 +3,9 @@ import {
type AnyNodeId, type AnyNodeId,
type BuildingNode, type BuildingNode,
emitter, emitter,
type GuideNode, GuideNode,
LevelNode, LevelNode,
type ScanNode, ScanNode,
type SiteNode, type SiteNode,
useScene, useScene,
type ZoneNode, type ZoneNode,
@@ -14,6 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
import { import {
Camera, Camera,
ChevronDown, ChevronDown,
Copy,
Loader2, Loader2,
MoreHorizontal, MoreHorizontal,
Pencil, Pencil,
@@ -32,9 +33,14 @@ import {
PopoverTrigger, PopoverTrigger,
} from './../../../../../components/ui/primitives/popover' } from './../../../../../components/ui/primitives/popover'
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection' import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
import {
buildLevelDuplicateCreateOps,
type LevelDuplicatePreset,
} from './../../../../../lib/level-duplication'
import { cn } from './../../../../../lib/utils' import { cn } from './../../../../../lib/utils'
import useEditor from './../../../../../store/use-editor' import useEditor from './../../../../../store/use-editor'
import { useUploadStore } from '../../../../../store/use-upload' import { useUploadStore } from '../../../../../store/use-upload'
import { LevelDuplicateDialog } from '../../../level-duplicate-dialog'
import { InlineRenameInput } from './inline-rename-input' import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, TreeNode } from './tree-node' import { focusTreeNode, TreeNode } from './tree-node'
import { TreeNodeDragProvider } from './tree-node-drag' import { TreeNodeDragProvider } from './tree-node-drag'
@@ -558,6 +564,7 @@ const LevelReferences = memo(function LevelReferences({
const LevelItem = memo(function LevelItem({ const LevelItem = memo(function LevelItem({
level, level,
levels,
selectedLevelId, selectedLevelId,
setSelection, setSelection,
updateNode, updateNode,
@@ -567,6 +574,7 @@ const LevelItem = memo(function LevelItem({
onDeleteAsset, onDeleteAsset,
}: { }: {
level: LevelNode level: LevelNode
levels: LevelNode[]
selectedLevelId: string | null selectedLevelId: string | null
setSelection: (selection: any) => void setSelection: (selection: any) => void
updateNode: (id: AnyNodeId, updates: Partial<AnyNode>) => void updateNode: (id: AnyNodeId, updates: Partial<AnyNode>) => void
@@ -576,7 +584,9 @@ const LevelItem = memo(function LevelItem({
onDeleteAsset?: (projectId: string, url: string) => void onDeleteAsset?: (projectId: string, url: string) => void
}) { }) {
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false) const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const createNodes = useScene((s) => s.createNodes)
const itemRef = useRef<HTMLDivElement>(null) const itemRef = useRef<HTMLDivElement>(null)
const isSelected = selectedLevelId === level.id const isSelected = selectedLevelId === level.id
const canDeleteLevel = level.level !== 0 const canDeleteLevel = level.level !== 0
@@ -600,6 +610,19 @@ const LevelItem = memo(function LevelItem({
focusTreeNode(level.id) focusTreeNode(level.id)
} }
const handleDuplicateLevel = (preset: LevelDuplicatePreset = 'everything') => {
const { createOps, newLevelId } = buildLevelDuplicateCreateOps({
nodes: useScene.getState().nodes,
level,
levels,
preset,
})
createNodes(createOps)
setSelection({ levelId: newLevelId })
setDuplicateDialogOpen(false)
}
return ( return (
<div className="relative flex flex-col"> <div className="relative flex flex-col">
<div <div
@@ -750,7 +773,23 @@ const LevelItem = memo(function LevelItem({
<MoreHorizontal className="h-3.5 w-3.5" /> <MoreHorizontal className="h-3.5 w-3.5" />
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="start" className="w-40 p-1" side="right"> <PopoverContent align="start" className="w-48 p-1" side="right">
<button
className="flex w-full cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-left text-sm transition-colors hover:bg-accent"
onClick={() => handleDuplicateLevel()}
title="Duplicate level"
>
<Copy className="h-3.5 w-3.5" />
Duplicate
</button>
<button
className="flex w-full cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-left text-sm transition-colors hover:bg-accent"
onClick={() => setDuplicateDialogOpen(true)}
title="Duplicate level with options"
>
<Copy className="h-3.5 w-3.5" />
Duplicate with options...
</button>
<button <button
className="flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-sm transition-colors enabled:cursor-pointer enabled:hover:bg-accent enabled:hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50" className="flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-sm transition-colors enabled:cursor-pointer enabled:hover:bg-accent enabled:hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!canDeleteLevel} disabled={!canDeleteLevel}
@@ -782,6 +821,12 @@ const LevelItem = memo(function LevelItem({
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
<LevelDuplicateDialog
level={level}
onConfirm={handleDuplicateLevel}
onOpenChange={setDuplicateDialogOpen}
open={duplicateDialogOpen}
/>
</div> </div>
) )
}) })
@@ -859,6 +904,7 @@ const LevelsSection = memo(function LevelsSection({
isLast={index === levels.length - 1} isLast={index === levels.length - 1}
key={level.id} key={level.id}
level={level} level={level}
levels={levels}
onDeleteAsset={onDeleteAsset} onDeleteAsset={onDeleteAsset}
onUploadAsset={onUploadAsset} onUploadAsset={onUploadAsset}
projectId={projectId} projectId={projectId}
@@ -0,0 +1,126 @@
import type { AnyNode, AnyNodeId, LevelNode } from '@pascal-app/core'
import { cloneLevelSubtree } from '@pascal-app/core'
export type LevelDuplicatePreset =
| 'everything'
| 'structure'
| 'structure-materials'
| 'structure-furniture'
const REFERENCE_NODE_TYPES = new Set<AnyNode['type']>(['scan', 'guide'])
const STRUCTURAL_NODE_TYPES = new Set<AnyNode['type']>([
'level',
'wall',
'fence',
'zone',
'slab',
'ceiling',
'roof',
'roof-segment',
'stair',
'stair-segment',
'window',
'door',
])
function shouldKeepNode(node: AnyNode, preset: LevelDuplicatePreset) {
if (preset === 'everything') return true
if (preset === 'structure-furniture') return !REFERENCE_NODE_TYPES.has(node.type)
if (preset === 'structure' || preset === 'structure-materials') {
return STRUCTURAL_NODE_TYPES.has(node.type)
}
return true
}
function stripMaterials(node: AnyNode): AnyNode {
const next = { ...node } as Record<string, unknown>
switch (node.type) {
case 'wall':
delete next.material
delete next.materialPreset
delete next.interiorMaterial
delete next.interiorMaterialPreset
delete next.exteriorMaterial
delete next.exteriorMaterialPreset
break
case 'slab':
case 'ceiling':
case 'fence':
case 'roof-segment':
case 'stair-segment':
case 'window':
case 'door':
delete next.material
delete next.materialPreset
break
case 'roof':
delete next.material
delete next.materialPreset
delete next.topMaterial
delete next.topMaterialPreset
delete next.edgeMaterial
delete next.edgeMaterialPreset
delete next.wallMaterial
delete next.wallMaterialPreset
break
case 'stair':
delete next.material
delete next.materialPreset
delete next.railingMaterial
delete next.railingMaterialPreset
delete next.treadMaterial
delete next.treadMaterialPreset
delete next.sideMaterial
delete next.sideMaterialPreset
break
}
return next as AnyNode
}
export function buildLevelDuplicateCreateOps({
nodes,
level,
levels,
preset,
}: {
nodes: Record<AnyNodeId, AnyNode>
level: LevelNode
levels: LevelNode[]
preset: LevelDuplicatePreset
}) {
const { clonedNodes, newLevelId } = cloneLevelSubtree(nodes, level.id)
const nextLevelNumber = Math.max(...levels.map((entry) => entry.level), -1) + 1
const filteredNodes = clonedNodes
.filter((node) => shouldKeepNode(node, preset))
.map((node) => (preset === 'structure' ? stripMaterials(node) : node))
const keptIds = new Set(filteredNodes.map((node) => node.id))
const cleanedNodes = filteredNodes.map((node) => {
if (!('children' in node) || !Array.isArray(node.children)) {
return node
}
return {
...node,
children: node.children.filter((childId) => keptIds.has(childId as AnyNodeId)),
} as AnyNode
})
return {
createOps: cleanedNodes.map((node) => ({
node:
node.id === newLevelId
? ({
...node,
level: nextLevelNumber,
} as AnyNode)
: node,
parentId: node.parentId as AnyNodeId | undefined,
})),
newLevelId,
}
}