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>
100 lines
3.1 KiB
TypeScript
100 lines
3.1 KiB
TypeScript
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
|
|
}
|