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 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user