docs(arch): codify the unified snapping/modifier convention + enforce in review

The snapping model (Shift = cycle mode, Alt = force/free, mode-driven reads via
isGridSnapActive/isMagneticSnapActive/isAngleSnapActive, snapProfile-declared
context) lived only in code and the plan; tools.md still preached the legacy
"Shift = bypass snapping". Close the drift so the architecture review refuses
tool changes that revert to the old pattern:

- tools.md: replace the held-Shift-bypass manipulation policy with the unified
  mode-driven model + the single snap read path.
- interaction-scope.md: new "Snapping mode & modifiers" section (contexts, read
  path, modifiers, the chip-needs-a-scope rule) + a Rules bullet + the
  known-legacy MEP movers (migrate-on-touch) incl. the dual-path constraint
  (a bespoke mover must not open a `moving` scope — it re-mounts the generic
  mover via useMovingNode).
- review-architecture skill: add interaction-scope.md to the reads and a new
  "F. Interaction scope, snapping & modifiers" checklist — new shiftKey-bypass,
  ungated grid step, missing snapProfile, a new useEditor interaction flag, or a
  bespoke mover opening a moving scope are blockers; touching the legacy MEP
  movers forces migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-26 09:28:14 -04:00
co-authored by Claude Opus 4.8
parent 1de1923e47
commit f0206dc6eb
3 changed files with 69 additions and 21 deletions
+33
View File
@@ -115,6 +115,38 @@ The policy is binary (`IDLE_POLICY` vs `ACTIVE_POLICY`) keyed on `isActive`.
---
## Snapping mode & modifiers (the unified model)
Snapping is a persistent, **per-context**, always-visible mode — not a held-Shift bypass.
The active scope selects the *context*; the context's current mode selects the *behaviour*.
There is no per-kind snapping switch.
- **Contexts** (`lib/snapping-mode.ts`, `SNAP_PROFILES`): `wall` (grid/lines/angles/off, default grid),
`item` (lines/grid/off, default lines), `polygon` (grid/lines/off, default grid). A kind opts in by
declaring `NodeDefinition.snapProfile` (`'item' | 'structural'`); `snapContextOf(scope × profile)` maps
it — `structural` while **setting direction** (drafting / endpoint drag) → `wall` (angle-bearing),
`structural` otherwise (translate / curve) → `polygon` (no angle), `item``item`. No profile → no chip.
- **Single read path.** Tools read `isGridSnapActive()` / `isMagneticSnapActive()` / `isAngleSnapActive()`
(`store/use-editor`); the grid step is `useEditor.getState().gridSnapStep` gated on `isGridSnapActive()`.
These resolve the mode from the scope via `getActiveSnapContext()``snappingModeByContext[context]`.
- **Modifiers.** Shift (tap) cycles the mode for the active context; Ctrl (tap) cycles the grid step;
Alt (hold) is force / free (raw cursor + commit past invalid; for MEP runs, the vertical-riser carve-out).
Shift is **not** a snap bypass and Alt is **not** a toggle.
- **The chip is the scope's.** The contextual HUD shows the active context's mode and is the only place the
mode is cycled — so a tool that wants its chip must run inside a scope whose `snapContextOf` resolves
(a build tool, `drafting`, `placing`/`moving`, or `reshaping`).
**Known-legacy (migrate on touch).** A few bespoke movers predate this model and still read
`event.shiftKey` as a snap bypass with hardcoded steps: the MEP move/endpoint tools
(`packages/nodes/src/{duct-segment,pipe-segment,liquid-line,lineset,duct-fitting}/{move-tool,selection}.tsx`),
tracked in `plans/editor-placement-interaction-overhaul.md`. A PR that **touches** one must migrate it to
the model above, not extend the legacy path. Note: opening a `moving` scope from a bespoke mover is **not**
the migration — `useMovingNode()` reads the scope, so `tool-manager` re-mounts the generic
`MoveRegistryNodeTool` alongside it (the dual-path FPS/teleport bug). Resolve the mode without a global
`moving`/`reshaping` scope; see the plan's dual-path note.
---
## Migration status (strangler fig)
The scope is the target source of truth, but the legacy `useEditor` flags still
@@ -140,3 +172,4 @@ independent flag clear can't stomp an unrelated scope) to keep the scope in sync
- **`update` cannot change `kind`.** Switching interactions is a `begin`, not a patch.
- **Hot-set and overlay policy are pure derivations of the scope** (and, for the hot-set, the candidate metadata). Don't branch overlay/picking behaviour on legacy flags — branch on the scope.
- **Don't add new `useEditor` interaction flags.** New interactions go through the scope.
- **Snapping is mode-driven.** Read snap state through `isGridSnapActive` / `isMagneticSnapActive` / `isAngleSnapActive` (gate any grid step on the first); never bypass snapping via `event.shiftKey` / `modifiers.shiftKey`, and never hardcode an ungated grid step. Snappable kinds declare `snapProfile`. Shift cycles the mode; Alt is force/free.
+24 -21
View File
@@ -68,27 +68,30 @@ export function MyTool() {
- The offset must be cleared on tool unmount, cancel, *and* commit — both `mesh.position.set(0, 0, 0)` and `useLiveTransforms.clear(id)`.
- The tool must not generate or mutate geometry in this path — only transform writes. Geometry generation still belongs in a core system.
- **No business logic in tools** — delegate geometry/constraint rules to core systems.
- **Guided manipulation is the default.** Placement, move, rotate, resize, endpoint drag,
and handle drag should behave as guided building mode: they help the user build quickly
with fewer mistakes through grid/object snapping, canonical angle increments,
alignment guides, and distance feedback. Holding Shift is the standard live bypass for
those constraints: while Shift is held, tools should commit the raw pointer/angle
proposal instead of applying sticky snap or angle corrections. Passive measurement
guides may remain visible only when they do not alter the proposal. If an interaction
cannot use Shift because of an established shortcut or topology rule, document the
opt-out in its manipulation policy and explain the replacement behavior.
- **Constraints and guides can be decoupled.** When a stronger constraint owns the
proposal, such as a wall segment's 15° angle lock, the tool may still publish passive
dashed alignment/proximity guides as long as it does not apply the guide snap delta.
Use this for chained wall segments: users keep the fast constrained draft, but still see
proximity feedback for later points. Shift remains the hard bypass for both correction
and guide feedback.
- **Help must mirror manipulation policy.** The shortcut dialog and floating helper panel
are part of the interaction contract. Static shortcut docs should describe guided
building as the default and Shift as the live bypass. Floating help should be contextual
when enough state exists: Select mode can derive direct move, direct rotate,
multi-select, and Shift-bypass tips from the selected nodes and active modifiers; active
tools can highlight the Shift bypass row while the modifier is held.
- **Snapping is mode-driven, not a held-Shift bypass.** Placement, move, rotate, resize,
endpoint drag, and handle drag are guided building — grid/object snapping, canonical angle
increments, alignment guides, distance feedback — but the active behaviour is an explicit,
always-visible, **per-context** mode (the contextual HUD chip), not a hidden held key:
- **Shift (tap)** cycles the snapping mode for the active context (`wall` grid/lines/angles/off ·
`item` lines/grid/off · `polygon` grid/lines/off — one persisted mode per context).
- **Alt (hold)** is force / free: commit the raw cursor past snap *and* past an invalid /
colliding drop. It is the only momentary "bypass" key (plus the vertical-riser carve-out for MEP runs).
- **Ctrl (tap)** cycles the grid step.
- Read snapping through the single path — `isGridSnapActive()` / `isMagneticSnapActive()` /
`isAngleSnapActive()` (`store/use-editor`), which resolve the active mode from the interaction
scope via `getActiveSnapContext()`. **Never** read `event.shiftKey` / `event.nativeEvent.shiftKey` /
`modifiers.shiftKey` to bypass snapping, and never apply a grid step that isn't gated on
`isGridSnapActive()` (`const step = isGridSnapActive() ? gridSnapStep : 0`). A snappable kind declares
`NodeDefinition.snapProfile` (`'item' | 'structural'`) so its context, mode-set, and chip fall out
with no per-kind switch. See [interaction-scope](interaction-scope.md) § "Snapping mode & modifiers"
and `lib/snapping-mode.ts`.
- **Constraints and guides can be decoupled.** When a stronger constraint owns the proposal —
a wall segment's 45° lock while in `angles` mode — the tool may still publish passive dashed
alignment/proximity guides as long as it does not apply the guide snap delta. Use this for chained
wall segments: users keep the fast constrained draft but still see proximity feedback for later points.
- **Help mirrors the model.** The shortcut dialog and the contextual HUD are part of the interaction
contract: they describe the always-visible mode chip + `Alt` = force, **not** a hidden Shift bypass.
The HUD is driven by the active interaction scope, so it shows only the current context's controls.
- **Preview geometry is local** — transient meshes shown while a tool is active live in the tool component, not in the scene store.
- **Clean up on unmount** — remove any pending/incomplete nodes *and* any live transforms/mesh offsets when the tool unmounts.
- **Tools must not import from `@pascal-app/viewer`** — use the scene store and core hooks only. `sceneRegistry` is exported from `@pascal-app/core` and is the allowed door into the Three.js graph for the narrow purposes above.