From 386df62b43915c789f6a05cd36b3e3200e0ba157 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 14 May 2026 12:29:00 -0400 Subject: [PATCH] Add MovementService (axis-lock + grid-snap) to core (Phase 1, 4/6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/core/src/services/index.ts | 8 + packages/core/src/services/movement.test.ts | 165 ++++++++++++++++++++ packages/core/src/services/movement.ts | 99 ++++++++++++ 3 files changed, 272 insertions(+) create mode 100644 packages/core/src/services/movement.test.ts create mode 100644 packages/core/src/services/movement.ts diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 670abe9b..74966e1f 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -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, diff --git a/packages/core/src/services/movement.test.ts b/packages/core/src/services/movement.test.ts new file mode 100644 index 00000000..53cf4cf3 --- /dev/null +++ b/packages/core/src/services/movement.test.ts @@ -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) + }) +}) diff --git a/packages/core/src/services/movement.ts b/packages/core/src/services/movement.ts new file mode 100644 index 00000000..3e8e38a2 --- /dev/null +++ b/packages/core/src/services/movement.ts @@ -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 +}