Add MovementService (axis-lock + grid-snap) to core (Phase 1, 4/6)
Pure constraint math built on the registry's `MovableConfig`: - `resolveMovable(node)` — reads `def.capabilities.movable`, runs the optional `override(ctx)` callback (returning null falls back to the base config). Returns null when the kind isn't movable. - `applyAxisLock(current, target, axes)` — projects 3D motion onto the allowed axes; locked components fall back to current. - `moveToward(node, current, target, options?)` — top-level helper combining axis lock + (optional) grid snap. Returns null when the node is not movable. - `movePlanToward(node, currentY, current, target, options?)` — X/Z-plane convenience for floor/plan-view placement. - `isMovable(node)` — predicate for tools/UI gating. Tests cover override callback, null-override fallback, axis lock permutations, grid-snap on/off, and the 2D plan convenience. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
bc0c73d449
commit
386df62b43
@@ -9,6 +9,14 @@ export {
|
||||
pickHost,
|
||||
type Vec3,
|
||||
} from './hosting'
|
||||
export {
|
||||
type AxisLock,
|
||||
applyAxisLock,
|
||||
isMovable,
|
||||
movePlanToward,
|
||||
moveToward,
|
||||
resolveMovable,
|
||||
} from './movement'
|
||||
export {
|
||||
DEFAULT_ANGLE_STEP,
|
||||
DEFAULT_GRID_STEP,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { z } from 'zod'
|
||||
import { nodeRegistry, registerNode } from '../registry/registry'
|
||||
import type { AnyNodeDefinition, Capabilities, MovableConfig } from '../registry/types'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
import { applyAxisLock, isMovable, movePlanToward, moveToward, resolveMovable } from './movement'
|
||||
|
||||
const id = (s: string) => s as AnyNodeId
|
||||
|
||||
function makeDef(kind: string, capabilities: Capabilities = {}): AnyNodeDefinition {
|
||||
return {
|
||||
kind,
|
||||
schemaVersion: 1,
|
||||
schema: z.object({ type: z.literal(kind) }) as any,
|
||||
category: 'utility',
|
||||
defaults: () => ({}) as any,
|
||||
capabilities,
|
||||
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
|
||||
}
|
||||
}
|
||||
|
||||
function makeNode(kind: string, idStr: string): AnyNode {
|
||||
return {
|
||||
id: id(idStr),
|
||||
type: kind,
|
||||
parentId: null,
|
||||
visible: true,
|
||||
} as unknown as AnyNode
|
||||
}
|
||||
|
||||
describe('resolveMovable', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
})
|
||||
|
||||
test('returns null when no def is registered', () => {
|
||||
expect(resolveMovable(makeNode('mystery', 'm'))).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null when def declares no movable capability', () => {
|
||||
registerNode(makeDef('static'))
|
||||
expect(resolveMovable(makeNode('static', 's'))).toBeNull()
|
||||
})
|
||||
|
||||
test('returns the declared config', () => {
|
||||
registerNode(makeDef('column', { movable: { axes: ['x', 'z'], gridSnap: true } }))
|
||||
const config = resolveMovable(makeNode('column', 'c'))
|
||||
expect(config?.axes).toEqual(['x', 'z'])
|
||||
expect(config?.gridSnap).toBe(true)
|
||||
})
|
||||
|
||||
test('runs override callback when present', () => {
|
||||
let overrideRan = false
|
||||
const config: MovableConfig = {
|
||||
axes: ['x'],
|
||||
override: () => {
|
||||
overrideRan = true
|
||||
return { axes: ['y'] }
|
||||
},
|
||||
}
|
||||
registerNode(makeDef('weird', { movable: config }))
|
||||
const resolved = resolveMovable(makeNode('weird', 'w'))
|
||||
expect(overrideRan).toBe(true)
|
||||
expect(resolved?.axes).toEqual(['y'])
|
||||
})
|
||||
|
||||
test('override returning null falls back to base config', () => {
|
||||
registerNode(
|
||||
makeDef('column', {
|
||||
movable: { axes: ['x', 'z'], override: () => null },
|
||||
}),
|
||||
)
|
||||
const resolved = resolveMovable(makeNode('column', 'c'))
|
||||
expect(resolved?.axes).toEqual(['x', 'z'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyAxisLock', () => {
|
||||
test('passes through unlocked axes only', () => {
|
||||
expect(applyAxisLock([1, 2, 3], [10, 20, 30], ['x'])).toEqual([10, 2, 3])
|
||||
expect(applyAxisLock([1, 2, 3], [10, 20, 30], ['x', 'z'])).toEqual([10, 2, 30])
|
||||
expect(applyAxisLock([1, 2, 3], [10, 20, 30], ['x', 'y', 'z'])).toEqual([10, 20, 30])
|
||||
})
|
||||
|
||||
test('empty lock returns current unchanged', () => {
|
||||
expect(applyAxisLock([1, 2, 3], [10, 20, 30], [])).toEqual([1, 2, 3])
|
||||
})
|
||||
})
|
||||
|
||||
describe('moveToward', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
})
|
||||
|
||||
test('returns null when node is not movable', () => {
|
||||
registerNode(makeDef('static'))
|
||||
expect(moveToward(makeNode('static', 's'), [0, 0, 0], [1, 1, 1])).toBeNull()
|
||||
})
|
||||
|
||||
test('axis-locks and returns the constrained target', () => {
|
||||
registerNode(makeDef('column', { movable: { axes: ['x', 'z'] } }))
|
||||
expect(moveToward(makeNode('column', 'c'), [0, 0.5, 0], [1, 99, 2])).toEqual([1, 0.5, 2])
|
||||
})
|
||||
|
||||
test('applies grid snap when capability declares gridSnap: true', () => {
|
||||
registerNode(makeDef('column', { movable: { axes: ['x', 'z'], gridSnap: true } }))
|
||||
const result = moveToward(makeNode('column', 'c'), [0, 0, 0], [0.3, 0, 0.6], {
|
||||
gridStep: 0.25,
|
||||
})
|
||||
expect(result).toEqual([0.25, 0, 0.5])
|
||||
})
|
||||
|
||||
test('grid snap can be overridden at call site', () => {
|
||||
registerNode(makeDef('column', { movable: { axes: ['x', 'z'], gridSnap: true } }))
|
||||
// Caller explicitly disables grid snap for this call
|
||||
const result = moveToward(makeNode('column', 'c'), [0, 0, 0], [0.3, 0, 0.6], {
|
||||
gridSnap: false,
|
||||
})
|
||||
expect(result).toEqual([0.3, 0, 0.6])
|
||||
})
|
||||
})
|
||||
|
||||
describe('movePlanToward', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
})
|
||||
|
||||
test('returns 2D point with X/Z constrained, Y dropped', () => {
|
||||
registerNode(makeDef('column', { movable: { axes: ['x', 'z'], gridSnap: true } }))
|
||||
const result = movePlanToward(
|
||||
makeNode('column', 'c'),
|
||||
0.5, // currentY
|
||||
[0, 0],
|
||||
[0.3, 0.6],
|
||||
{ gridStep: 0.25 },
|
||||
)
|
||||
expect(result).toEqual([0.25, 0.5])
|
||||
})
|
||||
|
||||
test('returns null when node is not movable', () => {
|
||||
registerNode(makeDef('static'))
|
||||
expect(movePlanToward(makeNode('static', 's'), 0, [0, 0], [1, 1])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMovable', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
})
|
||||
|
||||
test('true when movable.axes has entries', () => {
|
||||
registerNode(makeDef('column', { movable: { axes: ['x'] } }))
|
||||
expect(isMovable(makeNode('column', 'c'))).toBe(true)
|
||||
})
|
||||
|
||||
test('false when no movable capability', () => {
|
||||
registerNode(makeDef('static'))
|
||||
expect(isMovable(makeNode('static', 's'))).toBe(false)
|
||||
})
|
||||
|
||||
test('false when movable.axes is empty', () => {
|
||||
registerNode(makeDef('locked', { movable: { axes: [] } }))
|
||||
expect(isMovable(makeNode('locked', 'l'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { nodeRegistry } from '../registry/registry'
|
||||
import type { MovableConfig } from '../registry/types'
|
||||
import type { AnyNode } from '../schema/types'
|
||||
import { snapVec3ToGrid, type Vec3 } from './snap'
|
||||
|
||||
/**
|
||||
* Pure movement constraint helpers. Given a node and a target position, apply
|
||||
* the constraints declared in `def.capabilities.movable` (axis lock, grid
|
||||
* snap, override callback) and return the constrained target.
|
||||
*
|
||||
* No scene access, no React, no Three.js — caller passes the node, this
|
||||
* returns the math result.
|
||||
*/
|
||||
|
||||
export type AxisLock = ReadonlyArray<'x' | 'y' | 'z'>
|
||||
|
||||
/**
|
||||
* Returns the MovableConfig effective for `node` after running its `override`
|
||||
* callback if declared. Returns `null` if the node's def doesn't declare
|
||||
* `movable` (i.e. the node is not movable).
|
||||
*/
|
||||
export function resolveMovable(node: AnyNode): MovableConfig | null {
|
||||
const def = nodeRegistry.get(node.type)
|
||||
const base = def?.capabilities.movable
|
||||
if (!base) return null
|
||||
if (base.override) {
|
||||
const overridden = base.override({ node })
|
||||
return overridden ?? base
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects a target X/Y/Z onto the axes a node is allowed to move on. Components
|
||||
* outside the lock fall back to the node's current values, so caller-supplied
|
||||
* positions can come from any 3D source without breaking axis-locked motion.
|
||||
*/
|
||||
export function applyAxisLock(current: Vec3, target: Vec3, axes: AxisLock): Vec3 {
|
||||
return [
|
||||
axes.includes('x') ? target[0] : current[0],
|
||||
axes.includes('y') ? target[1] : current[1],
|
||||
axes.includes('z') ? target[2] : current[2],
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level helper: takes a node and a desired position, returns the position
|
||||
* filtered through the node's movable capability (axis lock + optional grid
|
||||
* snap). Returns `null` when the node is not movable.
|
||||
*/
|
||||
export function moveToward(
|
||||
node: AnyNode,
|
||||
current: Vec3,
|
||||
target: Vec3,
|
||||
options: { gridStep?: number; gridSnap?: boolean } = {},
|
||||
): Vec3 | null {
|
||||
const config = resolveMovable(node)
|
||||
if (!config) return null
|
||||
|
||||
let next = applyAxisLock(current, target, config.axes)
|
||||
|
||||
const wantsGridSnap = options.gridSnap ?? config.gridSnap
|
||||
if (wantsGridSnap) {
|
||||
next = snapVec3ToGrid(next, options.gridStep)
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 2D convenience: same as moveToward but for plan-view (X/Z) operations like
|
||||
* floor placement. Returns a tuple in the X/Z plane so callers don't have to
|
||||
* pack/unpack the dropped Y.
|
||||
*/
|
||||
export function movePlanToward(
|
||||
node: AnyNode,
|
||||
currentY: number,
|
||||
current: readonly [number, number],
|
||||
target: readonly [number, number],
|
||||
options: { gridStep?: number; gridSnap?: boolean } = {},
|
||||
): readonly [number, number] | null {
|
||||
const result = moveToward(
|
||||
node,
|
||||
[current[0], currentY, current[1]],
|
||||
[target[0], currentY, target[1]],
|
||||
options,
|
||||
)
|
||||
if (!result) return null
|
||||
return [result[0], result[2]]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a node's def declares it as movable on any axis.
|
||||
* Quick predicate for tools/UI that gate on movability.
|
||||
*/
|
||||
export function isMovable(node: AnyNode): boolean {
|
||||
const config = resolveMovable(node)
|
||||
return config != null && config.axes.length > 0
|
||||
}
|
||||
Reference in New Issue
Block a user