+
+
+ updateCustomMaterial({ roughness })}
+ precision={2}
+ step={0.01}
+ value={currentProps.roughness}
+ />
+ updateCustomMaterial({ metalness })}
+ precision={2}
+ step={0.01}
+ value={currentProps.metalness}
+ />
+
+ updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
+ }
+ precision={2}
+ step={0.01}
+ value={currentProps.opacity}
+ />
-
updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) })}
- step={0.01}
- type="range"
- value={currentProps.roughness}
- />
-
-
-
-
- {currentProps.metalness.toFixed(2)}
-
-
-
updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) })}
- step={0.01}
- type="range"
- value={currentProps.metalness}
- />
-
-
-
-
- {currentProps.opacity.toFixed(2)}
-
-
-
{
- const opacity = Number.parseFloat(e.target.value)
- updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
- }}
- step={0.01}
- type="range"
- value={currentProps.opacity}
- />
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx
index b88c6a60..c1eac313 100755
--- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx
+++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx
@@ -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
) => 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(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 (
-
+
+
+
)
})
@@ -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}
diff --git a/packages/editor/src/lib/level-duplication.ts b/packages/editor/src/lib/level-duplication.ts
new file mode 100644
index 00000000..8f0f1040
--- /dev/null
+++ b/packages/editor/src/lib/level-duplication.ts
@@ -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(['scan', 'guide'])
+const STRUCTURAL_NODE_TYPES = new Set([
+ '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
+
+ 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
+ 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,
+ }
+}