Add duplicate options to level menus
This commit is contained in:
@@ -309,8 +309,11 @@ export const ceilingStrategy = {
|
||||
const rotY = ctx.draftItem?.rotation?.[1] ?? 0
|
||||
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
||||
|
||||
const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
|
||||
const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
|
||||
// Ceiling items are stored in ceiling-local coordinates, so snapping must
|
||||
// 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 {
|
||||
stateUpdate: { surface: 'ceiling', ceilingId: event.node.id },
|
||||
@@ -320,7 +323,7 @@ export const ceilingStrategy = {
|
||||
},
|
||||
cursorRotationY: 0,
|
||||
gridPosition: [x, -itemHeight, z],
|
||||
cursorPosition: [x, event.position[1] - itemHeight, z],
|
||||
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
||||
stopPropagation: true,
|
||||
}
|
||||
},
|
||||
@@ -338,12 +341,13 @@ export const ceilingStrategy = {
|
||||
const rotY = ctx.draftItem.rotation?.[1] ?? 0
|
||||
const swapDims = Math.abs(Math.sin(rotY)) > 0.9
|
||||
|
||||
const x = snapToGrid(event.position[0], swapDims ? dimZ : dimX)
|
||||
const z = snapToGrid(event.position[2], swapDims ? dimX : dimZ)
|
||||
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 {
|
||||
gridPosition: [x, -itemHeight, z],
|
||||
cursorPosition: [x, event.position[1] - itemHeight, z],
|
||||
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
||||
cursorRotationY: 0,
|
||||
nodeUpdate: null,
|
||||
stopPropagation: true,
|
||||
|
||||
@@ -614,7 +614,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
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)
|
||||
if (!result) return
|
||||
|
||||
|
||||
@@ -8,11 +8,16 @@ import {
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
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 { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
buildLevelDuplicateCreateOps,
|
||||
type LevelDuplicatePreset,
|
||||
} from '../../lib/level-duplication'
|
||||
import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { LevelDuplicateDialog } from './level-duplicate-dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -92,13 +97,16 @@ function LevelRow({
|
||||
level,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDuplicate,
|
||||
onRequestDelete,
|
||||
}: {
|
||||
level: LevelNode
|
||||
isSelected: boolean
|
||||
onSelect: () => void
|
||||
onDuplicate: (preset?: LevelDuplicatePreset) => void
|
||||
onRequestDelete: () => void
|
||||
}) {
|
||||
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -142,7 +150,29 @@ function LevelRow({
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</button>
|
||||
</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
|
||||
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) => {
|
||||
@@ -158,6 +188,15 @@ function LevelRow({
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
<LevelDuplicateDialog
|
||||
level={level}
|
||||
onConfirm={(preset) => {
|
||||
onDuplicate(preset)
|
||||
setDuplicateDialogOpen(false)
|
||||
}}
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
open={duplicateDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -169,6 +208,7 @@ export function FloatingLevelSelector() {
|
||||
const levelId = useViewer((s) => s.selection.levelId)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const createNode = useScene((s) => s.createNode)
|
||||
const createNodes = useScene((s) => s.createNodes)
|
||||
const updateNodes = useScene((s) => s.updateNodes)
|
||||
|
||||
const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
|
||||
@@ -251,6 +291,22 @@ export function FloatingLevelSelector() {
|
||||
setDeletingLevel(null)
|
||||
}, [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
|
||||
|
||||
const reversedLevels = [...levels].reverse()
|
||||
@@ -294,6 +350,7 @@ export function FloatingLevelSelector() {
|
||||
<LevelRow
|
||||
isSelected={isSelected}
|
||||
level={level}
|
||||
onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
|
||||
onRequestDelete={() => setDeletingLevel(level)}
|
||||
onSelect={() =>
|
||||
setSelection(
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SlabHelper } from './slab-helper'
|
||||
import { WallHelper } from './wall-helper'
|
||||
|
||||
export function HelperManager() {
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const tool = useEditor((s) => s.tool)
|
||||
const movingNode = useEditor((state) => state.movingNode)
|
||||
|
||||
@@ -17,6 +18,10 @@ export function HelperManager() {
|
||||
return <ItemHelper showEsc />
|
||||
}
|
||||
|
||||
if (mode === 'material-paint') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Show appropriate helper based on current tool
|
||||
switch (tool) {
|
||||
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'
|
||||
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { Input } from '../primitives/input'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
@@ -77,67 +78,41 @@ export function PaintPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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]">
|
||||
Roughness
|
||||
</label>
|
||||
<span className="font-mono text-muted-foreground text-xs">
|
||||
{currentProps.roughness.toFixed(2)}
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
|
||||
Surface
|
||||
</label>
|
||||
<div className="space-y-1 rounded-lg border border-border/50 bg-background/40 p-2">
|
||||
<SliderControl
|
||||
label="Roughness"
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={(roughness) => updateCustomMaterial({ roughness })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={currentProps.roughness}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Metalness"
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={(metalness) => updateCustomMaterial({ metalness })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={currentProps.metalness}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Opacity"
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={(opacity) =>
|
||||
updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
|
||||
}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={currentProps.opacity}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={(e) => updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) })}
|
||||
step={0.01}
|
||||
type="range"
|
||||
value={currentProps.roughness}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
min={0}
|
||||
onChange={(e) => updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) })}
|
||||
step={0.01}
|
||||
type="range"
|
||||
value={currentProps.metalness}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
min={0}
|
||||
onChange={(e) => {
|
||||
const opacity = Number.parseFloat(e.target.value)
|
||||
updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
|
||||
}}
|
||||
step={0.01}
|
||||
type="range"
|
||||
value={currentProps.opacity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -3,9 +3,9 @@ import {
|
||||
type AnyNodeId,
|
||||
type BuildingNode,
|
||||
emitter,
|
||||
type GuideNode,
|
||||
GuideNode,
|
||||
LevelNode,
|
||||
type ScanNode,
|
||||
ScanNode,
|
||||
type SiteNode,
|
||||
useScene,
|
||||
type ZoneNode,
|
||||
@@ -14,6 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import {
|
||||
Camera,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
@@ -32,9 +33,14 @@ import {
|
||||
PopoverTrigger,
|
||||
} from './../../../../../components/ui/primitives/popover'
|
||||
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
|
||||
import {
|
||||
buildLevelDuplicateCreateOps,
|
||||
type LevelDuplicatePreset,
|
||||
} from './../../../../../lib/level-duplication'
|
||||
import { cn } from './../../../../../lib/utils'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { useUploadStore } from '../../../../../store/use-upload'
|
||||
import { LevelDuplicateDialog } from '../../../level-duplicate-dialog'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, TreeNode } from './tree-node'
|
||||
import { TreeNodeDragProvider } from './tree-node-drag'
|
||||
@@ -558,6 +564,7 @@ const LevelReferences = memo(function LevelReferences({
|
||||
|
||||
const LevelItem = memo(function LevelItem({
|
||||
level,
|
||||
levels,
|
||||
selectedLevelId,
|
||||
setSelection,
|
||||
updateNode,
|
||||
@@ -567,6 +574,7 @@ const LevelItem = memo(function LevelItem({
|
||||
onDeleteAsset,
|
||||
}: {
|
||||
level: LevelNode
|
||||
levels: LevelNode[]
|
||||
selectedLevelId: string | null
|
||||
setSelection: (selection: any) => void
|
||||
updateNode: (id: AnyNodeId, updates: Partial<AnyNode>) => void
|
||||
@@ -576,7 +584,9 @@ const LevelItem = memo(function LevelItem({
|
||||
onDeleteAsset?: (projectId: string, url: string) => void
|
||||
}) {
|
||||
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
|
||||
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const createNodes = useScene((s) => s.createNodes)
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
const isSelected = selectedLevelId === level.id
|
||||
const canDeleteLevel = level.level !== 0
|
||||
@@ -600,6 +610,19 @@ const LevelItem = memo(function LevelItem({
|
||||
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 (
|
||||
<div className="relative flex flex-col">
|
||||
<div
|
||||
@@ -750,7 +773,23 @@ const LevelItem = memo(function LevelItem({
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</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
|
||||
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}
|
||||
@@ -782,6 +821,12 @@ const LevelItem = memo(function LevelItem({
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<LevelDuplicateDialog
|
||||
level={level}
|
||||
onConfirm={handleDuplicateLevel}
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
open={duplicateDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -859,6 +904,7 @@ const LevelsSection = memo(function LevelsSection({
|
||||
isLast={index === levels.length - 1}
|
||||
key={level.id}
|
||||
level={level}
|
||||
levels={levels}
|
||||
onDeleteAsset={onDeleteAsset}
|
||||
onUploadAsset={onUploadAsset}
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user