fix(editor): improve guided manipulation and snap affordances

This commit is contained in:
Aymeric Rabot
2026-06-11 23:59:15 -04:00
committed by GitHub
parent aab48e053f
commit 5411f5abc8
89 changed files with 2830 additions and 518 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa
| [node-definitions](node-definitions.md) | Three-checkbox composition model for registry-driven kinds (`geometry` / `renderer` / `system`) |
| [materials-and-themes](materials-and-themes.md) | Surface colour: surface roles, colour presets, the textures axis, and scene themes (appearance / ground / clay tints) |
| [plugin-authoring](plugin-authoring.md) | Public contract for external plugins — `Plugin` shape, `setPluginDiscovery`, lifecycle, what's in and out of v1 |
| [tools](tools.md) | Editor tools structure in `apps/editor` |
| [tools](tools.md) | Editor tools structure, manipulation constraints, and Shift bypass defaults |
| [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic |
| [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner |
| [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` |
+22
View File
@@ -184,6 +184,28 @@ If the system also handles cascades, animations, or material updates, keep `def.
- **Dispose on rebuild.** The generic system disposes the previous children's geometry + material before swapping. Custom systems that imperatively add children must dispose what they replace, or accept the GPU-memory cost.
- **`def.renderer` overrides the generic renderer.** Once you set it, you own the mount — `<ParametricNodeRenderer>` is not invoked. The generic geometry system still runs for the kind if `def.geometry` is set, so a custom renderer can register an empty group and let the system fill it.
## `toolHints`
`toolHints?: ToolHint[]` is the registry-owned source for the floating helper shown while
a registered placement or draw tool is active.
```ts
type ToolHint = {
key: string
label: string
}
```
Keep labels short and action-oriented. Prefer the default guided-building language:
snapping, angle increments, guides, and validation are active unless the user holds Shift
during the gesture. A `Shift` hint should describe the bypass in user terms, such as
`Free angle`, `Free place`, or `Bypass guided constraints`.
`HelperManager` renders `def.toolHints` through `RegisteredToolHelper`, and active Shift
state can update the row to show that guided constraints are currently bypassed. Select
mode is not owned by a node definition, so its helper is derived separately from
selection state, selected-node move/rotate capabilities, and held modifiers.
## Pitfalls
### `<GeometrySystem>` must not mutate `group.position` / `group.rotation`
+15
View File
@@ -73,6 +73,21 @@ phase: 'furnish' → selectable: furniture items only
Clicking a node of a different phase auto-switches the phase. Double-click drills into a context level.
In Select mode, 3D and 2D canvas selection share the same modifier vocabulary:
- `Ctrl/Meta + click` toggles the clicked object in `selectedIds`.
- `Shift + click` also toggles the clicked canvas object so users can multi-select from
either viewport. The scene graph keeps file-browser semantics: `Shift + click` selects
the visible range between the last selected row and the clicked row.
- `Ctrl/Meta + left-drag` on a selected movable object starts direct move from the canvas.
- `Ctrl/Meta + right-drag` on a selected rotatable object starts direct rotation from the
canvas. Rotation snaps to the default angle increment unless Shift is held during the
drag.
The floating helper in `packages/editor/src/components/ui/helpers/helper-manager.tsx`
mirrors these rules from current selection state and held modifiers. Keep that helper and
the shortcut dialog in sync when changing selection gestures.
---
## Rules
+21
View File
@@ -66,6 +66,27 @@ 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.
- **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.