Includes a regression test for the "no-op bend" case that reproduces
the user-reported 'fence bend cancels fence creation' bug: when
action.commit returns false (draft === original), no pastState push
happens during the drag's commit path. The very next Ctrl-Z falls
through to whatever was on the stack before activation — typically
the fence creation itself.
The actual dance with real changes (delta !=0) works correctly — the
test exercising the full createDragSession flow with action.commit
returning true pins one undo step covering only the drag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous dispose() was documented as "equivalent to cancel()" and
fired onCancel. That breaks React StrictMode's mount → cleanup → mount
cycle for useDragAction consumers: the first cleanup's onCancel resets
the parent state machine (e.g. setCurvingFence(null)), which unmounts
the component before the second mount runs. Net result: the tool blinks
in and out instantly.
dispose() now restores scene state + resumes history but skips onCancel.
Explicit cancel() still fires onCancel (Esc / external aborts). New
test locks this in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure orchestrator in core, thin React wrapper in editor:
- `core/services/drag-session.ts` — `createDragSession(action, scene,
options)` returns an imperative session with `start / move / commit
/ cancel / dispose / isActive / getDraft`. Pauses history on start,
resumes on terminate. Per-move runs preview → snap → apply, then
cascades dirty marks via the relations resolver (deduped across
ticks). Re-entry guard, idempotent dispose, fires onCommit/onCancel
callbacks. All tested in bun:test — no React needed.
- `editor/src/hooks/use-drag-action.ts` — wraps the session with the
editor's grid-event emitter and an Esc-to-cancel keyboard listener.
Builds a `SceneApi` once via `createSceneApi(useScene)` at module
init. The hook itself is small enough to read top-to-bottom; all
behavior lives in the session.
Tests (13 cases) cover the hard parts: history pause/resume bracket,
explicit cancel restoring all touched nodes, dispose mid-drag, commit
returning false short-circuiting to cancel, snap callback wired in,
re-entry rejected, deduped dirty-mark across multiple move ticks,
hosts cascade from the registry firing in apply.
No callers yet — Phase 2 column and shelf tools are the first
consumers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Pure math, no React, no scene access. Three primitives plus a facade:
- `snapScalar(value, step)` / `snapPointToGrid(point, step)` /
`snapVec3ToGrid(point, step)` — regular grid snapping. Default
step 0.25m matches the editor's wall tool.
- `snapPointToAngle(from, cursor, angleStep, gridStep?)` — locks a
cursor to the nearest angle multiple from a fixed point, preserves
distance, optionally re-grids the projected point. Default angle
step π/12 (15°).
- `snapAngleToList(angle, list, tolerance)` — snaps a free angle to
the nearest entry in a fixed list (e.g. 0/45/90/135) within a
tolerance; returns the original angle otherwise. Handles wrap.
- `snapServices` facade — `grid.*` + `angle.*` namespaces. Stable
contract that `DragAction.snap` callbacks receive. Phase 3 ports
the existing `snapWallDraftPoint` family from
`editor/.../wall-drafting.ts` under a `wall.*` namespace.
17 unit tests cover the math + the facade pass-through. No existing
callers re-wired yet — Phase 2 column/shelf tools are the first
consumers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First service in `core/src/services/`: pure, no React or R3F, takes
SceneApi + node data, returns results.
Exports:
- `canAttach(childId, hostId, scene)` — validates host attachment.
Rejects self-host, cycles (host's ancestor chain contains child),
chains past MAX_HOST_DEPTH (6), and host kinds outside the child
def's `capabilities.hostable.parents` allowlist. Returns a typed
AttachError discriminated union so callers can render specific
messages.
- `getSurface(host)` / `getTopSurfaceHeight(host)` — reads
`def.capabilities.surfaces` from the registry; resolves
function-valued heights with the node.
- `clampYToHostTop(host, y)` — convenience for placement code.
- `pickHost({ point, candidates, placedKind, hitTest? })` — given
spatially pre-filtered candidates, returns the first hostable.
The runtime is responsible for spatial filtering; this function
stays pure.
MAX_HOST_DEPTH = 6: the explore earlier found today's editor has no
cap on item-on-item nesting. Cap is bounded by hostable depth, not
total tree depth (sites/buildings/levels don't count).
17 tests cover all rejection paths + happy paths + function-valued
surface heights.
Re-exported from `@pascal-app/core` via a new `services/` barrel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>