From 025a9b5d289fc25377082f37bd633224f556a6fb Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 28 Apr 2026 11:09:51 +0530 Subject: [PATCH] Add duplicate options to level menus --- .../tools/item/placement-strategies.ts | 16 ++- .../tools/item/use-placement-coordinator.tsx | 6 +- .../components/ui/floating-level-selector.tsx | 61 ++++++++- .../components/ui/helpers/helper-manager.tsx | 5 + .../components/ui/level-duplicate-dialog.tsx | 115 ++++++++++++++++ .../src/components/ui/panels/paint-panel.tsx | 95 +++++-------- .../ui/sidebar/panels/site-panel/index.tsx | 52 +++++++- packages/editor/src/lib/level-duplication.ts | 126 ++++++++++++++++++ 8 files changed, 404 insertions(+), 72 deletions(-) create mode 100644 packages/editor/src/components/ui/level-duplicate-dialog.tsx create mode 100644 packages/editor/src/lib/level-duplication.ts diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 9d89ddf2..f20f8745 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -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, diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index e1fb7442..64bdcae2 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -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 diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index f83380c2..d1353d45 100755 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -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({ - + + + + ))} + + + + + + + + + ) +} diff --git a/packages/editor/src/components/ui/panels/paint-panel.tsx b/packages/editor/src/components/ui/panels/paint-panel.tsx index ffa8ec9d..dabd70bc 100644 --- a/packages/editor/src/components/ui/panels/paint-panel.tsx +++ b/packages/editor/src/components/ui/panels/paint-panel.tsx @@ -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() { -
-
- - - {currentProps.roughness.toFixed(2)} - +
+ +
+ 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, + } +}