Doors and windows could only be moved via the floating action menu — their 3D handle rig declared width/height resize arrows but no move grip, and Ctrl/Meta-drag was a no-op for them. Add a press-drag move cross and make direct-drag work for every bespoke-mover kind. - door/window: add a `tap-action` `move-cross` handle (plane node-normal, portal grandparent, `engageMoveDrag`) mirroring the item wall grip. It routes through the existing per-kind move tool (3D `affordanceTools.move`, 2D `floorplanMoveTarget`) — wall-bound slide + re-host onto another wall — so the grip, the floating Move button, and the 2D plan's move dot share one pipeline. Grab-drag-release commits without a second click. - canDirectMoveNode: gate Ctrl/Meta-drag on `movable || affordanceTools.move` (the 3D-mountable move paths) instead of `movable` only, so doors/windows/ walls/slabs/stairs/… are draggable in 3D as they already are in 2D. Floorplan-only movers (zone) stay excluded — no 3D tool mounts. The floating helper auto-syncs (it reads canDirectMoveNode). - TapActionArrow: honor `plane: 'node-normal'` by tilting the move cross [π/2,0,0] into the wall face — previously ignored, so the item wall grip rendered flat too. Now door/window/wall-item crosses lie in the wall. - use-node-events: split the drag-suppression gate. `inputDragging` still suppresses SELECTION events (the synthesized release-click would re-select), but no longer suppresses SPATIAL events (enter/move/leave) — a surface-following move tool runs with `inputDragging` set and needs wall:move to track the cursor. General consumers that must ignore drags (viewer hover, box-select) already self-gate on `inputDragging`; the editor's select-hover and paint-preview enter handlers now gate on it too. - handle-arrow: make handle hit areas inert while `placementDragMode` is set, so a move grip riding the dragged node can't intercept the ray and starve the move tool's surface raycast. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
103 lines
3.4 KiB
TypeScript
103 lines
3.4 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
import {
|
|
type AnyNode,
|
|
type AnyNodeDefinition,
|
|
DEFAULT_ANGLE_STEP,
|
|
nodeRegistry,
|
|
registerNode,
|
|
} from '@pascal-app/core'
|
|
import { z } from 'zod'
|
|
import {
|
|
canDirectMoveNode,
|
|
resolveDirectRotationDragDelta,
|
|
snapDirectRotationDelta,
|
|
} from './direct-manipulation'
|
|
|
|
function registerTestDefinition(kind: string, overrides: Partial<AnyNodeDefinition>) {
|
|
if (nodeRegistry.has(kind)) return
|
|
registerNode({
|
|
kind,
|
|
schemaVersion: 1,
|
|
schema: z.object({ type: z.literal(kind) }) as never,
|
|
category: 'structure',
|
|
defaults: () => ({ type: kind }) as never,
|
|
capabilities: {},
|
|
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
|
...overrides,
|
|
} as AnyNodeDefinition)
|
|
}
|
|
|
|
describe('snapDirectRotationDelta', () => {
|
|
test('snaps rotation deltas to the default angle increment', () => {
|
|
expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * 0.49, false)).toBe(0)
|
|
expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * 0.51, false)).toBeCloseTo(
|
|
DEFAULT_ANGLE_STEP,
|
|
)
|
|
expect(snapDirectRotationDelta(DEFAULT_ANGLE_STEP * -1.49, false)).toBeCloseTo(
|
|
-DEFAULT_ANGLE_STEP,
|
|
)
|
|
})
|
|
|
|
test('keeps the raw rotation delta while free-rotating', () => {
|
|
const rawDelta = DEFAULT_ANGLE_STEP * 0.42
|
|
expect(snapDirectRotationDelta(rawDelta, true)).toBe(rawDelta)
|
|
})
|
|
})
|
|
|
|
describe('resolveDirectRotationDragDelta', () => {
|
|
test('maps horizontal pointer motion to the direct rotation delta direction', () => {
|
|
const radiansPerPixel = DEFAULT_ANGLE_STEP / 12
|
|
|
|
expect(resolveDirectRotationDragDelta(100, 112, radiansPerPixel, false)).toBeCloseTo(
|
|
-DEFAULT_ANGLE_STEP,
|
|
)
|
|
expect(resolveDirectRotationDragDelta(100, 88, radiansPerPixel, false)).toBeCloseTo(
|
|
DEFAULT_ANGLE_STEP,
|
|
)
|
|
})
|
|
|
|
test('keeps unsnapped drag deltas while free-rotating', () => {
|
|
expect(resolveDirectRotationDragDelta(100, 103, 0.1, true)).toBeCloseTo(-0.3)
|
|
})
|
|
})
|
|
|
|
describe('canDirectMoveNode', () => {
|
|
// Accepts kinds with a 3D-mountable move tool (`movable` or
|
|
// `affordanceTools.move`); floorplan-only movers (zone) are excluded.
|
|
test('rejects floorplan-only move targets (no 3D tool mounts)', () => {
|
|
const kind = 'direct-move-floorplan-only-test'
|
|
registerTestDefinition(kind, { floorplanMoveTarget: {} as never })
|
|
|
|
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
|
|
})
|
|
|
|
test('accepts kinds with a bespoke move tool', () => {
|
|
const kind = 'direct-move-bespoke-tool-test'
|
|
registerTestDefinition(kind, {
|
|
affordanceTools: {
|
|
move: async () => ({ default: () => null }),
|
|
} as never,
|
|
})
|
|
|
|
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
|
|
})
|
|
|
|
test('accepts nodes with the generic movable capability', () => {
|
|
const kind = 'direct-move-movable-test'
|
|
registerTestDefinition(kind, {
|
|
capabilities: {
|
|
movable: { axes: ['x', 'z'], gridSnap: true },
|
|
},
|
|
} as Partial<AnyNodeDefinition>)
|
|
|
|
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
|
|
})
|
|
|
|
test('rejects kinds with no registered move path', () => {
|
|
const kind = 'direct-move-none-test'
|
|
registerTestDefinition(kind, {})
|
|
|
|
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
|
|
})
|
|
})
|