Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extensi
import {
createFreshPlacementSubtree,
duplicatesAsFreshSubtree,
prepareFreshPlacementRootDuplicate,
} from '../../lib/fresh-planar-placement'
import { curveReshapeScope } from '../../lib/interaction/scope'
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
Expand Down Expand Up @@ -116,8 +117,8 @@ function collectQuickActionNodes(
* - Add hole (slab + ceiling only): inserts a small default-square
* hole at the polygon centroid via `updateNode`. Mirrors the legacy
* `handleAddHole` in `floating-action-menu.tsx`.
* - Duplicate: deep-clones the node, marks it new, sets it as the
* movingNode (placement cursor) — same UX pattern as 3D duplicate.
* - Duplicate: creates a fresh subtree when the kind opts in, otherwise a
* root-only copy, then hands that real draft to the placement cursor.
* - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
* `relations.cascadeDelete` if declared on the def.
*
Expand Down Expand Up @@ -320,34 +321,30 @@ export function FloorplanRegistryActionMenu() {
if (!node.parentId) return
sfxEmitter.emit('sfx:item-pick')
useScene.temporal.getState().pause()
if (duplicatesAsFreshSubtree(node as AnyNode)) {
const draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
const draft = draftId ? useScene.getState().nodes[draftId] : null
if (draft) {
let draftId: AnyNodeId | null = null
try {
if (duplicatesAsFreshSubtree(node as AnyNode)) {
draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
const draft = draftId ? useScene.getState().nodes[draftId] : null
if (!draft) return
setMovingNode(draft as never)
setMovingNodeOrigin('2d')
useScene.temporal.getState().resume()
return
} else {
const cloned = prepareFreshPlacementRootDuplicate(node as AnyNode)
const parsed = def.schema.parse(cloned) as AnyNode
draftId = parsed.id as AnyNodeId
useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
setMovingNode(parsed as never)
}
setMovingNodeOrigin('2d')
useViewer.getState().setSelection({ selectedIds: [] })
} catch (error) {
if (draftId && useScene.getState().nodes[draftId]) {
useScene.getState().deleteNode(draftId)
}
console.error('Failed to duplicate node', error)
} finally {
useScene.temporal.getState().resume()
return
}
const cloned = structuredClone(node) as AnyNode & { id?: AnyNodeId }
delete (cloned as { id?: AnyNodeId }).id
const prevMeta =
cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
? (cloned.metadata as Record<string, unknown>)
: {}
// Mark fresh + hand to the placement cursor so the copy follows the
// pointer and only lands on the next click — same gesture for every
// kind. Polyline runs (duct / pipe / lineset) ride the same path:
// `FloorplanRegistryMoveOverlay` translates their whole `path`, so they
// no longer need the old "offset + drop already-placed" special case.
cloned.metadata = { ...prevMeta, isNew: true }
const parsed = def.schema.parse(cloned) as AnyNode
useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
setMovingNode(parsed as never)
useScene.temporal.getState().resume()
}

const handleDelete = () => {
Expand Down
52 changes: 19 additions & 33 deletions packages/editor/src/components/editor/floating-action-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import {
runAsSingleSceneHistoryStep,
type SlabNode,
SpawnNode,
StairNode,
StairSegmentNode,
sceneRegistry,
summarizeSystemFor,
Expand All @@ -47,14 +46,14 @@ import { resolveMoveActionNode } from '../../lib/direct-manipulation'
import {
createFreshPlacementSubtree,
duplicatesAsFreshSubtree,
prepareFreshPlacementRootDuplicate,
} from '../../lib/fresh-planar-placement'
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
import { duplicateStairSubtree } from '../../lib/stair-duplication'
import { cn } from '../../lib/utils'
import useEditor from '../../store/use-editor'
import useInteractionScope, {
Expand Down Expand Up @@ -530,20 +529,26 @@ export function FloatingActionMenu() {
useScene.temporal.getState().pause()

if (duplicatesAsFreshSubtree(node as AnyNode)) {
const draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
const draft = draftId ? useScene.getState().nodes[draftId] : null
if (draft) {
setMovingNode(draft as any)
setSelection({ selectedIds: [] })
return
let draftId: AnyNodeId | null = null
try {
draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
const draft = draftId ? useScene.getState().nodes[draftId] : null
if (draft) {
setMovingNode(draft as any)
setSelection({ selectedIds: [] })
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stair duplicate drops placement offset

Medium Severity

3D duplicate now routes stairs through createFreshPlacementSubtree with no position patch, but the removed menu path used duplicateStairSubtree, which defaulted to a [1, 0, 1] offset. The draft is created at the source stair’s position, so it overlaps the original until the user moves it, unlike other positioned duplicates that get a small nudge.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f1a14da. Configure here.

}
} catch (error) {
if (draftId && useScene.getState().nodes[draftId]) {
useScene.getState().deleteNode(draftId)
}
console.error('Failed to duplicate node subtree', error)
}
useScene.temporal.getState().resume()
return
}

let duplicateInfo = structuredClone(node) as any
delete duplicateInfo.id
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
const duplicateInfo = prepareFreshPlacementRootDuplicate(node as AnyNode) as any

let duplicate: AnyNode | null = null
try {
Expand All @@ -566,11 +571,6 @@ export function FloatingActionMenu() {
} else if (node.type === 'roof-segment') {
duplicateInfo.id = generateId('rseg')
duplicate = RoofSegmentNode.parse(duplicateInfo)
} else if (node.type === 'stair') {
duplicateInfo.children = []
duplicateInfo.metadata = { ...duplicateInfo.metadata }
delete duplicateInfo.metadata?.isNew
duplicate = StairNode.parse(duplicateInfo)
} else if (node.type === 'stair-segment') {
duplicate = StairSegmentNode.parse(duplicateInfo)
} else if (node.type === 'spawn') {
Expand Down Expand Up @@ -608,11 +608,7 @@ export function FloatingActionMenu() {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
} else if (duplicate.type === 'fence') {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
} else if (
duplicate.type === 'roof-segment' ||
duplicate.type === 'stair' ||
duplicate.type === 'stair-segment'
) {
} else if (duplicate.type === 'roof-segment' || duplicate.type === 'stair-segment') {
// Add small offset to make it visible
if ('position' in duplicate) {
duplicate.position = [
Expand All @@ -621,13 +617,7 @@ export function FloatingActionMenu() {
duplicate.position[2] + 1,
]
}
if (node.type === 'stair' && duplicate.type === 'stair') {
duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
} else {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
}

// Duplicate children for stair nodes
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
} else if (
duplicate.type === 'item' ||
duplicate.type === 'chimney' ||
Expand Down Expand Up @@ -696,12 +686,8 @@ export function FloatingActionMenu() {
nodeRegistry.has(duplicate.type)
) {
setMovingNode(duplicate as any)
} else if (duplicate.type === 'stair') {
setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
}
if (duplicate.type !== 'stair') {
setSelection({ selectedIds: [] })
}
setSelection({ selectedIds: [] })
}
},
[node, setMovingNode, setSelection],
Expand Down
34 changes: 33 additions & 1 deletion packages/editor/src/lib/fresh-planar-placement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import {
useScene,
} from '@pascal-app/core'
import { z } from 'zod'
import { commitFreshPlacementSubtree, createFreshPlacementSubtree } from './fresh-planar-placement'
import {
commitFreshPlacementSubtree,
createFreshPlacementSubtree,
duplicatesAsFreshSubtree,
prepareFreshPlacementRootDuplicate,
} from './fresh-planar-placement'

type RafFn = (cb: (time: number) => void) => number
;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
Expand Down Expand Up @@ -212,6 +217,33 @@ describe('commitFreshPlacementSubtree', () => {
expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([])
})

test('uses the subtree contract for childless variants and never aliases root-only children', () => {
registerCabinetClonePrepTestKind()
const childlessCabinet = {
...shelf(),
type: 'cabinet',
children: [],
metadata: { isTransient: true, label: 'source' },
} as AnyNode
expect(duplicatesAsFreshSubtree(childlessCabinet)).toBe(true)

const source = {
...shelf(),
children: ['item_original' as AnyNodeId],
metadata: { isTransient: true, label: 'source' },
} as AnyNode
const duplicate = prepareFreshPlacementRootDuplicate(source) as AnyNode & {
children: AnyNodeId[]
id?: AnyNodeId
metadata?: Record<string, unknown>
}

expect(duplicate.id).toBeUndefined()
expect(duplicate.children).toEqual([])
expect(duplicate.metadata).toEqual({ isNew: true, label: 'source' })
expect((source as AnyNode & { children: AnyNodeId[] }).children).toEqual(['item_original'])
})

test('commits a duplicated cabinet draft without deleting the original modules', () => {
seedCabinetRun()
useScene.temporal.getState().clear()
Expand Down
25 changes: 21 additions & 4 deletions packages/editor/src/lib/fresh-planar-placement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,27 @@ function duplicableConfigFor(node: AnyNode): DuplicableConfig | null {
}

export function duplicatesAsFreshSubtree(node: AnyNode): boolean {
const children = (node as { children?: unknown }).children
return (
duplicableConfigFor(node)?.subtree === true && Array.isArray(children) && children.length > 0
)
return duplicableConfigFor(node)?.subtree === true
}

/**
* Prepares a non-subtree duplicate without retaining ownership of the
* original node's children. Subtree-capable kinds take the path above and
* receive fresh descendant IDs; every other kind duplicates only its root.
*/
export function prepareFreshPlacementRootDuplicate(node: AnyNode): AnyNode {
const duplicate = structuredClone(node) as unknown as Record<string, unknown> & {
id?: AnyNodeId
children?: unknown
metadata?: unknown
}
delete duplicate.id
if (Array.isArray(duplicate.children)) duplicate.children = []
duplicate.metadata = {
...getPlacementMetadataRecord(stripPlacementMetadataFlags(duplicate.metadata)),
isNew: true,
}
return duplicate as unknown as AnyNode
}

/**
Expand Down
103 changes: 103 additions & 0 deletions packages/editor/src/lib/stair-duplication.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeId,
LevelNode,
StairNode,
StairSegmentNode,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import useInteractionScope, { getMovingNode } from '../store/use-interaction-scope'
import { duplicateStairSubtree } from './stair-duplication'

const LEVEL_ID = 'level_stair-duplicate' as AnyNodeId
const STAIR_ID = 'stair_original' as AnyNodeId
const SEGMENT_ID = 'sseg_original' as AnyNodeId

function seedStraightStair() {
const level = LevelNode.parse({
id: LEVEL_ID,
type: 'level',
children: [STAIR_ID],
})
const stair = StairNode.parse({
id: STAIR_ID,
type: 'stair',
parentId: LEVEL_ID,
children: [SEGMENT_ID],
position: [2, 0, 3],
})
const segment = StairSegmentNode.parse({
id: SEGMENT_ID,
type: 'stair-segment',
parentId: STAIR_ID,
})
useScene.setState({
nodes: {
[LEVEL_ID]: level as AnyNode,
[STAIR_ID]: stair as AnyNode,
[SEGMENT_ID]: segment as AnyNode,
},
rootNodeIds: [LEVEL_ID],
collections: {},
dirtyNodes: new Set(),
} as never)
}

describe('duplicateStairSubtree', () => {
beforeEach(() => {
useInteractionScope.getState().end()
useViewer.getState().setSelection({ selectedIds: [STAIR_ID] })
useScene.temporal.getState().clear()
useScene.temporal.getState().resume()
seedStraightStair()
})

afterEach(() => {
useInteractionScope.getState().end()
useScene.temporal.getState().resume()
useViewer.getState().setSelection({ selectedIds: [] })
})

test('moves the exact fresh scene subtree and clears the original selection', () => {
const result = duplicateStairSubtree(STAIR_ID, { mode: 'move' })
const nodes = useScene.getState().nodes
const draft = nodes[result.stair.id as AnyNodeId]

expect(result.stair.id).not.toBe(STAIR_ID)
expect(draft).toBe(result.stair)
expect(getMovingNode()?.id).toBe(result.stair.id)
expect(useViewer.getState().selection.selectedIds).toEqual([])
expect((result.stair.metadata as Record<string, unknown>)?.isNew).toBe(true)
expect(result.stair.position).toEqual([3, 0, 4])
expect(result.segmentIds).toHaveLength(1)
expect(result.segmentIds[0]).not.toBe(SEGMENT_ID)
expect(nodes[result.segmentIds[0] as AnyNodeId]?.parentId).toBe(result.stair.id)
expect(nodes[STAIR_ID]).toBeDefined()
expect(nodes[SEGMENT_ID]).toBeDefined()
expect(useScene.temporal.getState().isTracking).toBe(false)
})

test('keeps childless curved stairs on the same real draft path', () => {
const curved = StairNode.parse({
...(useScene.getState().nodes[STAIR_ID] as AnyNode),
children: [],
stairType: 'curved',
})
useScene.setState((state) => ({
nodes: {
...state.nodes,
[LEVEL_ID]: { ...state.nodes[LEVEL_ID], children: [STAIR_ID] } as AnyNode,
[STAIR_ID]: curved as AnyNode,
},
}))

const result = duplicateStairSubtree(STAIR_ID, { mode: 'move', offset: [0, 0, 0] })

expect(result.segmentIds).toEqual([])
expect(useScene.getState().nodes[result.stair.id as AnyNodeId]).toBe(result.stair)
expect(getMovingNode()?.id).toBe(result.stair.id)
expect((result.stair.metadata as Record<string, unknown>)?.isNew).toBe(true)
})
})
Loading
Loading