diff --git a/packages/nodes/src/spawn/__tests__/parity.test.ts b/packages/nodes/src/spawn/__tests__/parity.test.ts index 458b5ccd..05d3531d 100644 --- a/packages/nodes/src/spawn/__tests__/parity.test.ts +++ b/packages/nodes/src/spawn/__tests__/parity.test.ts @@ -31,6 +31,7 @@ describe('spawn definition', () => { expect(spawnDefinition.schemaVersion).toBe(1) expect(spawnDefinition.category).toBe('site') expect(spawnDefinition.schema).toBe(SpawnNode) + expect(typeof spawnDefinition.floorplanMoveTarget).toBe('function') }) test('defaults() returns a value that the schema accepts', () => { @@ -111,6 +112,7 @@ describe('spawn definition', () => { const flat = flattenFloorplan(geometry) expect(flat.some((entry) => entry.kind === 'path' && entry.stroke === '#818cf8')).toBe(true) + expect(flat.some((entry) => entry.kind === 'move-handle')).toBe(true) expect(flat.some((entry) => entry.kind === 'rotate-arrow')).toBe(true) }) diff --git a/packages/nodes/src/spawn/definition.ts b/packages/nodes/src/spawn/definition.ts index e5e7f41a..f76ecdc8 100644 --- a/packages/nodes/src/spawn/definition.ts +++ b/packages/nodes/src/spawn/definition.ts @@ -1,6 +1,7 @@ import type { HandleDescriptor, NodeDefinition, SpawnNode as SpawnNodeType } from '@pascal-app/core' import { buildSpawnFloorplan } from './floorplan' import { spawnRotateAffordance } from './floorplan-affordances' +import { spawnFloorplanMoveTarget } from './floorplan-move' import { spawnParametrics } from './parametrics' import { SpawnNode } from './schema' @@ -92,6 +93,7 @@ export const spawnDefinition: NodeDefinition = { // delete. Legacy spawn click handlers in FloorplanNodeLayer become // dead code once Phase 6 cleanup removes the [] entries path. floorplan: buildSpawnFloorplan, + floorplanMoveTarget: spawnFloorplanMoveTarget, floorplanAffordances: { 'spawn-rotate': spawnRotateAffordance, }, diff --git a/packages/nodes/src/spawn/floorplan-move.ts b/packages/nodes/src/spawn/floorplan-move.ts new file mode 100644 index 00000000..fe6b1794 --- /dev/null +++ b/packages/nodes/src/spawn/floorplan-move.ts @@ -0,0 +1,39 @@ +import { + type AnyNodeId, + type FloorplanMoveTarget, + type FloorplanMoveTargetSession, + type SpawnNode, + snapScalar, + useScene, +} from '@pascal-app/core' +import { getSegmentGridStep } from '@pascal-app/editor' + +export const spawnFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { + const spawnId = node.id as AnyNodeId + const startY = node.position[1] + const originalPosition: [number, number, number] = [...node.position] + let lastPosition: [number, number, number] | null = null + + const session: FloorplanMoveTargetSession = { + affectedIds: [spawnId], + apply({ planPoint, modifiers }) { + const step = getSegmentGridStep() + const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step)) + const next: [number, number, number] = [snap(planPoint[0]), startY, snap(planPoint[1])] + + if (lastPosition && lastPosition[0] === next[0] && lastPosition[2] === next[2]) return + lastPosition = next + useScene.getState().updateNodes([{ id: spawnId, data: { position: next } }]) + }, + canCommit() { + if (!lastPosition) return false + return lastPosition[0] !== originalPosition[0] || lastPosition[2] !== originalPosition[2] + }, + commit() { + if (!lastPosition) return + useScene.getState().updateNodes([{ id: spawnId, data: { position: lastPosition } }]) + }, + } + + return session +} diff --git a/packages/nodes/src/spawn/floorplan.ts b/packages/nodes/src/spawn/floorplan.ts index ac72b1ab..6ccde308 100644 --- a/packages/nodes/src/spawn/floorplan.ts +++ b/packages/nodes/src/spawn/floorplan.ts @@ -113,6 +113,11 @@ export function buildSpawnFloorplan(node: SpawnNode, ctx: GeometryContext): Floo ] if (isSelected) { + children.push({ + kind: 'move-handle', + point: [px, pz], + }) + const cornerLocalX = 0.34 + ROTATE_ARROW_CORNER_OFFSET const cornerLocalZ = 0.34 + ROTATE_ARROW_CORNER_OFFSET const [cornerX, cornerZ] = rotatePlanVector(cornerLocalX, cornerLocalZ, planRotation)