graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat(viewer-state): a framework, proven by a second tool that is not a curve
The curve tool was the first viewer state, and everything in it except
"what a handle IS" turned out to be the same for any such tool:
projecting world positions to screen handles through the cached scene
mvp, hit-testing the cursor, capturing a grabbed handle's NDC depth and
unprojecting onto that plane so orbiting between edits never makes a drag
jump, per-gesture undo, binding by node ID so renames do not detach the
tool and a vanished node drops it lazily, and write-back through the
SetParam resync sequence. src/viewer_state.rs owns all of it, plus the
two things this phase asked for that the curve tool did not have:
snapping and a HUD, which every tool wants and none should implement.
What differs per tool is the HandleSource trait. Two implementations ship
because an abstraction with a single implementation has not been shown to
be one, and they are deliberately different in shape:
- curve — an open-ended list of world positions, stored as world
positions, which the pointer may extend and trim;
- soft transform — a FIXED pair whose second handle is Centre +
Translation. A translation is not a place, so that handle is a DERIVED
position converted both ways by the source, and the framework's drag
maths never learns that one of its two world points is not a place.
That conversion is the whole reason the trait exists.
The design question it forced is worth recording. `write` is handed a
full set of handles with no word about which one moved, because it has to
mean the same thing when the set came from an undo snapshot as when it
came from a drag. So the soft transform's handles read as a vector with a
base and a tip, and dragging either end changes the offset between them.
The alternative — keep the translation fixed when the centre moves —
would be right for the drag and would quietly discard half of every
restored snapshot.
`source_for` is the one map from node type to tool, so the node context
menu, the new `edit_handles` command and anything later cannot disagree
about what is editable: adding a source makes it appear in the menu
without the menu being touched. The entry is "Edit Handles" now rather
than "Edit Points" — only a curve's handles are points. Snapping is a
command (`toggle_snap`), which is the command registry paying for itself
one round after it landed.
The HUD draws one line above the scale readout rather than at the top of
the viewport: the viewport is full-bleed and the pane plates float over
its top edge, so a mode line there lands under the collapsed stubs and
their titles read through it. It says which state is active and whether
snapping is on, because a mode you cannot see is a mode you forget you
are in.
The curve tool's existing tests pass unchanged through the framework,
which is the migration's proof; the new ones cover the derived handle,
its undo, snapping, and that a fixed source refuses to add or delete.
Co-Authored-By: Claude Opus 5 <[email protected]>
CLAUDE.md | 69 ++++---
shapeshifter.md | 32 +++-
src/app.rs | 66 ++++---
src/application.rs | 4 +-
src/command.rs | 4 +
src/curve_tool.rs | 397 ++++-----------------------------------
src/main.rs | 244 ++++++++++++++++++++----
src/render.rs | 31 ++-
src/shortcut.rs | 4 +
src/soft_transform_tool.rs | 100 ++++++++++
src/viewer_state.rs | 457 +++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 957 insertions(+), 451 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index b6ae5c3..5692567 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -169,32 +169,57 @@ gone from cce-ui with the wgpu path).
from output nodes; OpenCL failures are collected, not fatal.
- `src/viewport_3d.rs` — app-owned `Viewport3D` widget (camera orbit/zoom, inertial
scroll, `rt_mode` flag switching the pane to the `cce_ui::vk` compute path tracer).
-- `src/curve_tool.rs` — the curve viewer state (Houdini-style viewport point
- editing for the native `curve` node; "Edit Points" in the node context menu).
- The pattern for any future viewer state: project world positions to handles
+- `src/viewer_state.rs` — the **viewer-state framework**: interactive viewport
+ tools, generalized out of the curve tool. A viewer state is a mode the
+ viewport is in, bound to one node, in which the pointer edits that node
+ instead of orbiting the camera. The framework owns everything that turned out
+ to be the same for any such tool: projection of world positions to handles
through `State::last_scene_mvp` + `last_scene_view_rect` (both LOGICAL px,
- the rect is divided by scale where it is cached — same path as the meta
- Point Numbers overlay), hit-test against `cursor_x/y`, drag by unprojecting
- the cursor at the grabbed point's captured NDC depth, and write edits back
- through the SetParam resync sequence (`sync_nodes` + `rebuild_scene_geometry`
- + `sync_parameters_pane`). Input hooks live in `handle_event`: presses
- intercept in the MouseInput arm ahead of the viewport context menu (gated on
- `cursor_in_viewport() && !in_network_pane`, so the network plate keeps its
- clicks where they overlap), motion at the top of CursorMoved, Escape ahead of
- connection-cancel. The tool binds the node by ID and deactivates lazily when
- the id no longer resolves to a curve. Undo/redo is a
- `cce_ui::history::History` of point-list snapshots on the tool, one per
- gesture (a drag opens a gesture its first motion commits, so a no-move
- click leaves nothing). The chords are the toolkit's (`undo`/`redo` in
- input.kdl, routed by the runner to `Application::undo`/`redo` in
- `application.rs` after the focused text box declines); Edit ▸ Undo/Redo
- reach the same code through `Action::Undo`/`Redo`. There is no
- project-wide history yet; `execute_action` is where one would be consulted
- after the tool declines.
+ the rect divided by scale where it is cached — the same path as the meta
+ Point Numbers overlay), hit-testing against `cursor_x/y`, dragging by
+ unprojecting the cursor at the grabbed handle's captured NDC depth, snapping,
+ the HUD, per-gesture undo (`cce_ui::history::History` of handle snapshots on
+ the tool, so it lives exactly as long as the state does), binding by node ID
+ rather than slot so renames don't detach it and a vanished node drops the
+ state lazily, and write-back through the SetParam resync sequence
+ (`sync_nodes` + `rebuild_scene_geometry` + `sync_parameters_pane`).
+ Input hooks live in `handle_event`: presses intercept in the MouseInput arm
+ ahead of the viewport context menu (gated on `cursor_in_viewport() &&
+ !in_network_pane`, so the network plate keeps its clicks where they overlap),
+ motion at the top of CursorMoved, Escape ahead of connection-cancel.
+
+ What differs per tool is the `HandleSource` trait: which node types it
+ accepts, where the handles are, how to write them back, whether the pointer
+ may add and remove them, and what to label them. `source_for` is the one map
+ from node type to tool, so the node context menu's Edit Handles entry, the
+ `edit_handles` command and any future entry point cannot disagree about what
+ is editable — adding a source makes it appear in the menu without touching
+ the menu.
+
+ Two implementations ship, deliberately different in shape, because an
+ abstraction with a single implementation has not been shown to be one:
+ `src/curve_tool.rs` (an open-ended list of world positions in the `curve`
+ node's Points parameter, extensible) and `src/soft_transform_tool.rs` (a
+ FIXED pair where the second handle is `Centre + Translation` — a derived
+ position that has to be converted both ways, which is exactly what the trait
+ exists to contain). The soft transform's two handles read as a vector with a
+ base and a tip, and dragging either end changes the offset between them; a
+ rule like "keep the translation when the centre moves" would be right for the
+ drag and would quietly discard half of every restored undo snapshot, since
+ `write` is handed a full set of handles with no word about which moved.
+
+ The HUD draws one line ABOVE the scale readout, sharing its left margin — not
+ at the top, because the viewport is full-bleed and the pane plates float over
+ its top edge, so a mode line there lands under the collapsed stubs. It exists
+ because a viewer state changes what every click does and snapping silently
+ changes what a drag does.
- `src/project.rs` — save/load. A project is a **directory containing `state.json`**
(`Project { name, root: FsNode, view_state }`); `default_project.json` in the crate
root is special-cased as a single file and doubles as the detached-window sync channel.
-- `src/shortcut.rs` — `Shortcut::parse("Ctrl+Shift+g")` → `Action` mapping.
+- `src/shortcut.rs` — `Shortcut::parse("Ctrl+Shift+g")` and chord → COMMAND ID
+ matching (see the command registry above; a chord names a row in
+ `src/command.rs`, not an `Action`). `Shortcut`'s equality is hand-written
+ rather than derived, so it agrees with `matches` about case.
The `zcce_inspector_v1` integration (window-position tracking + widget-state
streaming to cce-test-interface) was dropped in the engine migration; the HTTP API
diff --git a/shapeshifter.md b/shapeshifter.md
index f6a5f6e..2f10fd4 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -359,9 +359,37 @@ Touches: `geometry.rs`, `nodes/*.json`.
> missing was not a file but a set of NAMES to put in it, and conflict
> reporting; both are in.
>
+> **The viewer-state framework landed.** `src/viewer_state.rs` owns everything
+> the curve tool had that was not about curves: projection, hit-testing, the
+> drag model, per-gesture undo, binding by node id, write-back — plus the two
+> things this phase asked for that it did not have, snapping and a HUD.
+>
+> What differs per tool is the `HandleSource` trait, and two implementations
+> ship because an abstraction with one implementation has not been shown to be
+> one. The curve is an open-ended list of world positions stored as world
+> positions. The soft transform is a FIXED pair whose second handle is
+> `Centre + Translation` — a derived position, converted both ways by the
+> source, so the framework's drag maths never learns that one of its two world
+> points is not a place. That conversion is the whole reason the trait exists.
+>
+> The one design question it forced: `write` is handed a full set of handles
+> with no word about which moved, because it has to mean the same thing when
+> the set came from an undo snapshot as when it came from a drag. So the soft
+> transform's handles read as a vector with a base and a tip, and dragging
+> either end changes the offset between them. The alternative — keep the
+> translation fixed when the centre moves — would be right for the drag and
+> would quietly discard half of every restored snapshot.
+>
+> `source_for` is the one map from node type to tool, so the context menu entry,
+> the `edit_handles` command and anything later cannot disagree about what is
+> editable: a new source appears in the menu without the menu being touched.
+> The entry is now "Edit Handles", not "Edit Points" — only a curve's are
+> points. Snapping is a command (`toggle_snap`), which is the previous round's
+> registry paying for itself.
+>
> Still outstanding for this phase: keyboard graph navigation and auto-layout
-> in the network pane, and the viewer-state framework generalized out of
-> `curve_tool.rs`.
+> in the network pane. The keycam navigator the proposal names as a viewer
+> state is not written yet, but the framework it would sit on is.
Independent of all the geometry work, and the place where the app gets to be
better rather than equal. A **command palette** on the HC Panel's model — fuzzy
diff --git a/src/app.rs b/src/app.rs
index b0c038b..0a1d7e0 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1355,7 +1355,7 @@ pub struct State {
pub last_scene_mvp: Option<Mat4>,
pub last_scene_view_rect: (f32, f32, f32, f32),
/// The active curve viewer state (viewport point editing), if any.
- pub curve_tool: Option<crate::curve_tool::CurveTool>,
+ pub viewer_tool: Option<crate::viewer_state::ViewerTool>,
pub last_viewport_rt_mode: bool,
/// Sphere-geometry cache for the path tracer (a copy of the last
/// `rebuild_scene_geometry` output, so entering RT mode never re-runs
@@ -2985,16 +2985,13 @@ impl State {
let dir = self.current_dir();
let Some(node) = dir.children.get(slot) else { return };
let enterable = node.is_enterable();
- // None: not a curve; Some(bool): a curve, editing or not.
- let curve_editing = node
- .node_type
- .eq_ignore_ascii_case("curve")
- .then(|| {
- self.curve_tool
- .as_ref()
- .map(|t| t.node_id == node.id)
- .unwrap_or(false)
- });
+ // None: no viewer state for this node type; Some(bool): editable,
+ // and whether it is being edited right now. The types that have a
+ // state are whatever `source_for` accepts, so a new HandleSource
+ // appears in this menu without touching it.
+ let curve_editing = crate::viewer_state::source_for(&node.node_type).map(|_| {
+ self.viewer_tool.as_ref().map(|t| t.node_id == node.id).unwrap_or(false)
+ });
(
matches!(node.node_type.as_str(), "utility" | "session" | "meta"),
node.geometry_visible,
@@ -3020,7 +3017,9 @@ impl State {
actions.push(NodeMenuAction::ToggleGeometry);
}
if let Some(editing) = curve_editing {
- options.push(if editing { "Stop Editing Points" } else { "Edit Points" }.to_string());
+ // "Handles", not "Points": a soft transform's are a centre and a
+ // tip, and only a curve's are points.
+ options.push(if editing { "Stop Editing Handles" } else { "Edit Handles" }.to_string());
actions.push(NodeMenuAction::EditCurve);
}
if deletable {
@@ -3317,7 +3316,7 @@ impl State {
let _ = self.apply_action(McpAction::ToggleGeometry { slot }, &mut redraw);
}
NodeMenuAction::EditCurve => {
- self.toggle_curve_tool(slot);
+ self.toggle_viewer_state(slot);
}
NodeMenuAction::Delete => {
self.delete_node(slot);
@@ -4203,7 +4202,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
pick_cache: None,
last_scene_mvp: None,
last_scene_view_rect: (0.0, 0.0, 0.0, 0.0),
- curve_tool: None,
+ viewer_tool: None,
last_viewport_rt_mode: false,
rt_sphere_verts: Vec::new(),
rt_geometry_version: 0,
@@ -5121,6 +5120,25 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
let mut settings_changed = false;
match action {
Action::CommandPalette => self.open_command_palette(),
+ Action::ToggleViewerState => {
+ // The selected node, the same one the context menu's entry
+ // would act on — so the command and the menu cannot disagree
+ // about what "this node" means.
+ match self.graph().selected_node() {
+ Some(slot) => {
+ self.toggle_viewer_state(slot);
+ if self.viewer_tool.is_none() {
+ self.update_status_text("Left the viewer state.");
+ }
+ }
+ None => self.update_status_text("Select a node to edit its handles."),
+ }
+ }
+ Action::ToggleSnap => {
+ if !self.toggle_viewer_snap() {
+ self.update_status_text("Snapping applies inside a viewer state.");
+ }
+ }
// Undo/Redo reach whichever editing state owns a history. The
// chords arrive through `Application::undo` / `redo` (the
// toolkit routes them, after the focused text box's turn); the
@@ -5128,10 +5146,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
// the only history so far; a project-wide one would be consulted
// here after the tool declines.
Action::Undo => {
- self.curve_tool_undo();
+ self.viewer_tool_undo();
}
Action::Redo => {
- self.curve_tool_redo();
+ self.viewer_tool_redo();
}
Action::ToggleGrid => {
let val = !self.viewport().show_grid;
@@ -5579,7 +5597,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
// An in-flight curve-tool grab eats motion ahead of every
// other drag: the grabbed control point tracks the cursor.
- if self.curve_tool_drag_motion() {
+ if self.viewer_tool_drag_motion() {
return true;
}
@@ -6011,16 +6029,16 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
// left grabs or adds a control point, right on a
// handle deletes it (right elsewhere still opens the
// viewport menu below).
- if self.curve_tool.is_some()
+ if self.viewer_tool.is_some()
&& self.cursor_in_viewport()
&& !in_circle_network_pane
{
if *button == MouseButton::Left {
- if self.curve_tool_press() {
+ if self.viewer_tool_press() {
return true;
}
} else if *button == MouseButton::Right
- && self.curve_tool_delete_at_cursor()
+ && self.viewer_tool_delete_at_cursor()
{
return true;
}
@@ -6250,7 +6268,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
self.sync_pane_focus();
}
ElementState::Released => {
- if self.curve_tool_release() {
+ if self.viewer_tool_release() {
changed = true;
}
if let Some(drag) = self.app_drag.take() {
@@ -6588,8 +6606,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
// The curve viewer state exits on Escape, ahead of
// connection-cancel — leaving point-edit mode is the
// more immediate "get me out" while it is active.
- if self.curve_tool.is_some() {
- self.curve_tool = None;
+ if self.viewer_tool.is_some() {
+ self.viewer_tool = None;
return true;
}
self.graph_mut().cancel_connecting();
@@ -6603,7 +6621,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
if event.state == ElementState::Pressed
&& (event.logical_key == Key::Named(NamedKey::Delete)
|| event.logical_key == Key::Named(NamedKey::Backspace))
- && self.curve_tool_delete_selected()
+ && self.viewer_tool_delete_selected()
{
return true;
}
diff --git a/src/application.rs b/src/application.rs
index 5f504d8..216074b 100644
--- a/src/application.rs
+++ b/src/application.rs
@@ -326,7 +326,7 @@ impl Application for State {
/// The toolkit's undo/redo routing lands here once no focused text box
/// wanted the chord. Only the curve viewer state has a history today.
fn undo(&mut self, needs_rebuild: &mut bool) -> bool {
- let taken = self.curve_tool_undo();
+ let taken = self.viewer_tool_undo();
if taken {
*needs_rebuild = true;
}
@@ -334,7 +334,7 @@ impl Application for State {
}
fn redo(&mut self, needs_rebuild: &mut bool) -> bool {
- let taken = self.curve_tool_redo();
+ let taken = self.viewer_tool_redo();
if taken {
*needs_rebuild = true;
}
diff --git a/src/command.rs b/src/command.rs
index e1546c2..050055f 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -122,6 +122,10 @@ pub const COMMANDS: &[Command] = &[
Command { id: "toggle_circular_pane", label: "Circular Pane", context: Context::Network, run: Run::Key(Action::ToggleCircularPane), default_chord: Some("Ctrl+d") },
Command { id: "detach_circular_window", label: "Detach Circular Window", context: Context::Network, run: Run::Key(Action::DetachCircularWindow), default_chord: None },
+ // --- Viewer states ---
+ Command { id: "edit_handles", label: "Edit Handles", context: Context::Viewport, run: Run::Key(Action::ToggleViewerState), default_chord: Some("Ctrl+h") },
+ Command { id: "toggle_snap", label: "Toggle Snapping", context: Context::Viewport, run: Run::Key(Action::ToggleSnap), default_chord: Some("Ctrl+b") },
+
// --- Viewport ---
Command { id: "toggle_grid", label: "Show Grid", context: Context::Viewport, run: Run::Key(Action::ToggleGrid), default_chord: Some("Ctrl+g") },
Command { id: "toggle_cube", label: "Show Cube", context: Context::Viewport, run: Run::Key(Action::ToggleCube), default_chord: Some("Ctrl+e") },
diff --git a/src/curve_tool.rs b/src/curve_tool.rs
index 2d8cbfd..bf39cc7 100644
--- a/src/curve_tool.rs
+++ b/src/curve_tool.rs
@@ -1,379 +1,64 @@
-//! The curve viewer state: an interactive viewport tool for the native
-//! `curve` node, entered from the node's context menu ("Edit Points").
+//! The curve viewer state: the `curve` node's control points as draggable
+//! handles, entered from the node's context menu ("Edit Handles").
//!
-//! While active, the node's control points draw as screen-space handles
-//! projected through the raster scene's cached mvp — the same path the meta
-//! Point Numbers overlay rides — and the pointer edits them in place:
+//! This is now one [`HandleSource`] on the framework in
+//! [`crate::viewer_state`], which owns everything that used to live here:
+//! projection, hit-testing, the drag model, per-gesture undo, snapping, the
+//! HUD, and write-back through the SetParam resync sequence. What is left is
+//! the part that is actually about curves — an open-ended list of world
+//! positions in the "Points" parameter, which the pointer may extend and trim.
//!
-//! - **left press on a handle** grabs it; dragging moves the point on the
-//! camera-facing plane at its own depth (the grab keeps the point's NDC z,
-//! so orbiting between edits never makes a drag jump);
-//! - **left press on empty viewport space** appends a new point there, at the
-//! depth of the last control point, and immediately drags it;
-//! - **right press on a handle** deletes that point; Delete/Backspace deletes
-//! the selected (last-clicked) one;
-//! - **undo / redo** (the toolkit chords, routed through `Application::undo`
-//! / `redo` once no focused text box wants them) step one gesture at a
-//! time: a whole drag is one step (recorded on the first motion after a
-//! grab, so a click that never moves records nothing), an add-and-drag is
-//! one step, a delete is one step. The history is a
-//! `cce_ui::history::History` of point-list snapshots on the tool itself —
-//! it lives exactly as long as the state does. Edits that arrive from
-//! outside the tool (the params pane, `curve_set_points` over MCP) are not
-//! recorded, but an undo still restores the list as it was before the last
-//! tool gesture, whatever happened since;
-//! - **Escape** exits the state.
+//! The behaviour that survives the move, because it is the framework's now:
//!
-//! Edits write the node's "Points" param through the same resync sequence as
-//! `McpAction::SetParam`, so the params pane, spreadsheet, and scene all
-//! follow live. The tool holds the node's *id*, not its slot: renames and
-//! graph edits don't detach it, and if the node disappears (deleted, project
-//! reloaded) every handler resolves nothing and the tool drops out lazily.
-
-use crate::app::{FsNode, State};
+//! - **left press on a handle** grabs it; dragging moves the point on the
+//! camera-facing plane at its own depth, so orbiting between edits never
+//! makes a drag jump;
+//! - **left press on empty space** appends a point there, at the depth of the
+//! last one, and immediately drags it;
+//! - **right press on a handle** deletes it; Delete/Backspace deletes the
+//! selected one;
+//! - **undo / redo** step one gesture at a time — a whole drag is one step,
+//! an add-and-drag is one step, a delete is one step;
+//! - **Escape** exits.
+
+use crate::app::FsNode;
use crate::geometry::{format_curve_points, node_param_str, parse_curve_points};
-use cce_ui::history::History;
-use glam::{Mat4, Vec3, Vec4};
+use crate::viewer_state::HandleSource;
+use glam::Vec3;
-/// How close (logical px) a press must land to a projected handle to grab it.
-pub const HANDLE_HIT_RADIUS: f32 = 10.0;
+/// Re-exported for the call sites that predate the framework. The projection
+/// helpers are the framework's; this module is only the curve's handles.
+pub use crate::viewer_state::{
+ find_node_by_id, find_node_by_id_mut, project_point, unproject_point, HANDLE_HIT_RADIUS,
+};
-pub struct CurveTool {
- /// Id of the curve node being edited.
- pub node_id: String,
- /// The last-clicked control point — the Delete target.
- pub selected: Option<usize>,
- /// An in-flight drag, if a press grabbed (or just added) a handle.
- pub drag: Option<CurveDrag>,
- /// Point-list snapshots, one per gesture. A grab opens a gesture that
- /// the first motion commits, so a click that never moves records nothing.
- pub history: History<Vec<Vec3>>,
-}
+pub struct CurveHandles;
-impl CurveTool {
- pub fn new(node_id: String) -> Self {
- CurveTool { node_id, selected: None, drag: None, history: History::new() }
+impl HandleSource for CurveHandles {
+ fn name(&self) -> &'static str {
+ "Curve Points"
}
-}
-
-#[derive(Clone, Copy)]
-pub struct CurveDrag {
- pub point: usize,
- /// NDC depth captured at grab time; motion unprojects onto this plane.
- pub ndc_z: f32,
-}
-/// World → (screen x, screen y, ndc z) through the cached scene mvp.
-pub fn project_point(mvp: &Mat4, view: (f32, f32, f32, f32), p: Vec3) -> Option<(f32, f32, f32)> {
- let (vx, vy, vw, vh) = view;
- let clip = *mvp * Vec4::new(p.x, p.y, p.z, 1.0);
- if clip.w <= 0.0 {
- return None;
+ fn accepts(&self, node_type: &str) -> bool {
+ node_type.eq_ignore_ascii_case("curve")
}
- let ndc = clip / clip.w;
- Some((
- vx + (ndc.x * 0.5 + 0.5) * vw,
- vy + (0.5 - ndc.y * 0.5) * vh,
- ndc.z,
- ))
-}
-/// (screen x, screen y, ndc z) → world through the inverse of the cached mvp.
-pub fn unproject_point(
- mvp: &Mat4,
- view: (f32, f32, f32, f32),
- sx: f32,
- sy: f32,
- ndc_z: f32,
-) -> Option<Vec3> {
- let (vx, vy, vw, vh) = view;
- if vw <= 0.0 || vh <= 0.0 {
- return None;
- }
- let inv = mvp.inverse();
- if !inv.is_finite() {
- return None;
- }
- let ndc_x = ((sx - vx) / vw - 0.5) * 2.0;
- let ndc_y = (0.5 - (sy - vy) / vh) * 2.0;
- let world = inv * Vec4::new(ndc_x, ndc_y, ndc_z, 1.0);
- if world.w.abs() < 1e-6 {
- return None;
- }
- let world = world / world.w;
- if !world.is_finite() {
- return None;
+ fn read(&self, node: &FsNode) -> Vec<Vec3> {
+ parse_curve_points(&node_param_str(node, "Points", ""))
}
- Some(Vec3::new(world.x, world.y, world.z))
-}
-pub fn find_node_by_id<'a>(root: &'a FsNode, id: &str) -> Option<&'a FsNode> {
- if root.id == id {
- return Some(root);
- }
- root.children.iter().find_map(|c| find_node_by_id(c, id))
-}
-
-pub fn find_node_by_id_mut<'a>(root: &'a mut FsNode, id: &str) -> Option<&'a mut FsNode> {
- if root.id == id {
- return Some(root);
- }
- root.children
- .iter_mut()
- .find_map(|c| find_node_by_id_mut(c, id))
-}
-
-impl State {
- /// Enter/exit the curve viewer state for the node in `slot` of the
- /// current directory. A different curve node retargets the tool.
- pub(crate) fn toggle_curve_tool(&mut self, slot: usize) {
- let Some(node) = self.current_dir().children.get(slot) else {
- return;
- };
- if !node.node_type.eq_ignore_ascii_case("curve") {
- return;
- }
- let id = node.id.clone();
- if self.curve_tool.as_ref().map(|t| t.node_id == id).unwrap_or(false) {
- self.curve_tool = None;
- } else {
- self.curve_tool = Some(CurveTool::new(id));
- }
- }
-
- /// The edited node's control points, or None if the node is gone (or is
- /// no longer a curve — a project reload can put anything at an old id).
- fn curve_node_points(&self, node_id: &str) -> Option<Vec<Vec3>> {
- let node = find_node_by_id(&self.fs_root, node_id)?;
- if !node.node_type.eq_ignore_ascii_case("curve") {
- return None;
- }
- Some(parse_curve_points(&node_param_str(node, "Points", "")))
- }
-
- /// Write the points back and run the same resync sequence as SetParam,
- /// so the scene, spreadsheet, and params pane all follow the edit.
- fn set_curve_points(&mut self, node_id: &str, pts: &[Vec3]) {
- let formatted = format_curve_points(pts);
- let Some(node) = find_node_by_id_mut(&mut self.fs_root, node_id) else {
- return;
- };
+ fn write(&self, node: &mut FsNode, handles: &[Vec3]) {
+ let formatted = format_curve_points(handles);
if let Some(p) = node.params.iter_mut().find(|p| p.name == "Points") {
p.default = formatted;
- } else {
- return;
}
- self.sync_nodes();
- self.rebuild_scene_geometry();
- self.sync_parameters_pane();
- }
-
- /// The active tool's handles as (point index, screen x, screen y, ndc z).
- /// Empty when the tool is off, the node is gone, or no scene mvp has been
- /// cached yet (a frame before the first scene staging).
- pub(crate) fn curve_tool_handles(&self) -> Vec<(usize, f32, f32, f32)> {
- let Some(tool) = &self.curve_tool else {
- return Vec::new();
- };
- let Some(mvp) = self.last_scene_mvp else {
- return Vec::new();
- };
- let Some(pts) = self.curve_node_points(&tool.node_id) else {
- return Vec::new();
- };
- pts.iter()
- .enumerate()
- .filter_map(|(i, p)| {
- project_point(&mvp, self.last_scene_view_rect, *p).map(|(sx, sy, z)| (i, sx, sy, z))
- })
- .collect()
}
- /// The handle under the cursor, nearest first.
- fn curve_tool_handle_at_cursor(&self) -> Option<(usize, f32)> {
- let (cx, cy) = (self.cursor_x, self.cursor_y);
- self.curve_tool_handles()
- .iter()
- .map(|(i, sx, sy, z)| (*i, ((sx - cx).powi(2) + (sy - cy).powi(2)).sqrt(), *z))
- .filter(|(_, d, _)| *d <= HANDLE_HIT_RADIUS)
- .min_by(|a, b| a.1.total_cmp(&b.1))
- .map(|(i, _, z)| (i, z))
- }
-
- /// Left press in the viewport while the tool is active: grab the handle
- /// under the cursor, or append a new point at the cursor (at the last
- /// point's depth) and start dragging it. Returns false — letting the
- /// press fall through — only when the edited node no longer exists.
- pub(crate) fn curve_tool_press(&mut self) -> bool {
- let Some(tool) = &self.curve_tool else {
- return false;
- };
- let node_id = tool.node_id.clone();
- let Some(mut pts) = self.curve_node_points(&node_id) else {
- self.curve_tool = None;
- return false;
- };
- if let Some((idx, ndc_z)) = self.curve_tool_handle_at_cursor() {
- let tool = self.curve_tool.as_mut().expect("checked above");
- tool.selected = Some(idx);
- tool.history.begin_gesture(pts);
- tool.drag = Some(CurveDrag { point: idx, ndc_z });
- return true;
- }
- // Empty space: add a point. Depth comes from the last control point
- // (or the world origin for an empty curve) so the new point lands in
- // the plane the user is already working in.
- let Some(mvp) = self.last_scene_mvp else {
- return true; // tool consumes viewport presses even pre-staging
- };
- let view = self.last_scene_view_rect;
- let ndc_z = pts
- .last()
- .and_then(|p| project_point(&mvp, view, *p))
- .map(|(_, _, z)| z)
- .or_else(|| project_point(&mvp, view, Vec3::ZERO).map(|(_, _, z)| z));
- let Some(ndc_z) = ndc_z else {
- return true;
- };
- let Some(world) = unproject_point(&mvp, view, self.cursor_x, self.cursor_y, ndc_z) else {
- return true;
- };
- let before = pts.clone();
- pts.push(world);
- let idx = pts.len() - 1;
- self.set_curve_points(&node_id, &pts);
- if let Some(tool) = self.curve_tool.as_mut() {
- // The add is the recorded step; the drag that follows it is
- // part of the same gesture, so no gesture is opened for it.
- tool.history.record(before);
- tool.selected = Some(idx);
- tool.drag = Some(CurveDrag { point: idx, ndc_z });
- }
- true
- }
-
- /// Pointer motion during a grab: the point tracks the cursor on the
- /// camera-facing plane at its grab depth.
- pub(crate) fn curve_tool_drag_motion(&mut self) -> bool {
- let Some(drag) = self.curve_tool.as_ref().and_then(|t| t.drag) else {
- return false;
- };
- let Some(mvp) = self.last_scene_mvp else {
- return false;
- };
- let node_id = self.curve_tool.as_ref().expect("drag implies tool").node_id.clone();
- let Some(mut pts) = self.curve_node_points(&node_id) else {
- self.curve_tool = None;
- return false;
- };
- if drag.point >= pts.len() {
- return false;
- }
- let Some(world) =
- unproject_point(&mvp, self.last_scene_view_rect, self.cursor_x, self.cursor_y, drag.ndc_z)
- else {
- return false;
- };
- pts[drag.point] = world;
- if let Some(tool) = self.curve_tool.as_mut() {
- tool.history.commit_gesture();
- }
- self.set_curve_points(&node_id, &pts);
- true
- }
-
- /// Button release: end any in-flight grab.
- pub(crate) fn curve_tool_release(&mut self) -> bool {
- match self.curve_tool.as_mut() {
- Some(tool) if tool.drag.is_some() => {
- tool.drag = None;
- tool.history.cancel_gesture();
- true
- }
- _ => false,
- }
- }
-
- /// Right press while the tool is active: delete the handle under the
- /// cursor. Consumes only on a hit — otherwise the press falls through to
- /// the viewport context menu.
- pub(crate) fn curve_tool_delete_at_cursor(&mut self) -> bool {
- let Some((idx, _)) = self.curve_tool_handle_at_cursor() else {
- return false;
- };
- self.curve_tool_delete_point(idx)
- }
-
- /// Delete/Backspace: remove the selected control point, if any.
- pub(crate) fn curve_tool_delete_selected(&mut self) -> bool {
- let Some(idx) = self.curve_tool.as_ref().and_then(|t| t.selected) else {
- return false;
- };
- self.curve_tool_delete_point(idx)
- }
-
- fn curve_tool_delete_point(&mut self, idx: usize) -> bool {
- let Some(tool) = &self.curve_tool else {
- return false;
- };
- let node_id = tool.node_id.clone();
- let Some(mut pts) = self.curve_node_points(&node_id) else {
- self.curve_tool = None;
- return false;
- };
- if idx >= pts.len() {
- return false;
- }
- let before = pts.clone();
- pts.remove(idx);
- self.set_curve_points(&node_id, &pts);
- if let Some(tool) = self.curve_tool.as_mut() {
- tool.history.record(before);
- tool.drag = None;
- // Keep a neighbor selected so repeated Delete walks the curve.
- tool.selected = if pts.is_empty() {
- None
- } else {
- Some(idx.min(pts.len() - 1))
- };
- }
+ fn extensible(&self) -> bool {
true
}
- /// Undo: return the points to how they were before the last recorded
- /// gesture. Consumes only when the tool is active and has history.
- pub(crate) fn curve_tool_undo(&mut self) -> bool {
- self.curve_tool_step(true)
- }
-
- /// Redo: reapply the last undone gesture.
- pub(crate) fn curve_tool_redo(&mut self) -> bool {
- self.curve_tool_step(false)
- }
-
- fn curve_tool_step(&mut self, undo: bool) -> bool {
- let Some(tool) = &self.curve_tool else {
- return false;
- };
- let node_id = tool.node_id.clone();
- let Some(current) = self.curve_node_points(&node_id) else {
- self.curve_tool = None;
- return false;
- };
- let tool = self.curve_tool.as_mut().expect("checked above");
- let stepped = if undo { tool.history.undo(current) } else { tool.history.redo(current) };
- let Some(target) = stepped else {
- return false;
- };
- // A step mid-drag abandons the drag: the grabbed index may not
- // exist in the restored list, and the pointer no longer means
- // anything to it.
- tool.drag = None;
- tool.selected = match tool.selected {
- Some(i) if !target.is_empty() => Some(i.min(target.len() - 1)),
- _ => None,
- };
- self.set_curve_points(&node_id, &target);
- true
+ fn hints(&self) -> &'static str {
+ "drag to move, click to add, right-click or Del to remove"
}
}
diff --git a/src/main.rs b/src/main.rs
index bd081c3..c05f87a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,6 +2,8 @@
pub mod app;
pub mod application;
pub mod curve_tool;
+pub mod soft_transform_tool;
+pub mod viewer_state;
pub mod detail;
pub mod export;
pub mod export_cli;
@@ -1688,8 +1690,8 @@ mod tests {
"template instances must not share ids"
);
- state.toggle_curve_tool(slot);
- assert!(state.curve_tool.is_some());
+ state.toggle_viewer_state(slot);
+ assert!(state.viewer_tool.is_some());
state.last_scene_mvp = Some(Mat4::IDENTITY);
state.last_scene_view_rect = (0.0, 0.0, 100.0, 100.0);
@@ -1707,11 +1709,11 @@ mod tests {
// Grab the first point and drag it to the pane center → (0, 0, z).
state.cursor_x = sx(default_first.x);
state.cursor_y = sy(default_first.y);
- assert!(state.curve_tool_press(), "press on a handle must grab");
+ assert!(state.viewer_tool_press(), "press on a handle must grab");
state.cursor_x = 50.0;
state.cursor_y = 50.0;
- assert!(state.curve_tool_drag_motion());
- assert!(state.curve_tool_release());
+ assert!(state.viewer_tool_drag_motion());
+ assert!(state.viewer_tool_release());
let pts = points_of(&state, slot);
assert!(pts[0].length() < 1e-4, "dragged point should sit at the origin, got {:?}", pts[0]);
// The other curve is untouched.
@@ -1721,24 +1723,24 @@ mod tests {
// depth — z=0 here) and immediately drags it.
state.cursor_x = 90.0;
state.cursor_y = 90.0;
- assert!(state.curve_tool_press());
+ assert!(state.viewer_tool_press());
let pts = points_of(&state, slot);
assert_eq!(pts.len(), 5);
assert!((pts[4] - Vec3::new(0.8, -0.8, 0.0)).length() < 1e-4, "added at {:?}", pts[4]);
- assert!(state.curve_tool_release());
+ assert!(state.viewer_tool_release());
// Delete the (selected) new point, then right-press-delete the one
// parked at the pane center.
- assert!(state.curve_tool_delete_selected());
+ assert!(state.viewer_tool_delete_selected());
assert_eq!(points_of(&state, slot).len(), 4);
state.cursor_x = 50.0;
state.cursor_y = 50.0;
- assert!(state.curve_tool_delete_at_cursor());
+ assert!(state.viewer_tool_delete_at_cursor());
assert_eq!(points_of(&state, slot).len(), 3);
// Toggling on the same node exits the state.
- state.toggle_curve_tool(slot);
- assert!(state.curve_tool.is_none());
+ state.toggle_viewer_state(slot);
+ assert!(state.viewer_tool.is_none());
}
/// Undo/redo in the curve viewer state: one entry per gesture (a drag
@@ -1757,7 +1759,7 @@ mod tests {
)
.expect("add curve node");
let slot = state.current_dir().children.len() - 1;
- state.toggle_curve_tool(slot);
+ state.toggle_viewer_state(slot);
state.last_scene_mvp = Some(Mat4::IDENTITY);
state.last_scene_view_rect = (0.0, 0.0, 100.0, 100.0);
let sx = |x: f32| 50.0 + x * 50.0;
@@ -1770,30 +1772,30 @@ mod tests {
))
};
let history = |state: &State| {
- let t = state.curve_tool.as_ref().expect("tool active");
+ let t = state.viewer_tool.as_ref().expect("tool active");
(t.history.undo_len(), t.history.redo_len())
};
let initial = points_of(&state);
- assert!(!state.curve_tool_undo(), "nothing to undo yet");
- assert!(!state.curve_tool_redo(), "nothing to redo yet");
+ assert!(!state.viewer_tool_undo(), "nothing to undo yet");
+ assert!(!state.viewer_tool_redo(), "nothing to redo yet");
// Grab and release without moving: no history.
state.cursor_x = sx(initial[0].x);
state.cursor_y = sy(initial[0].y);
- assert!(state.curve_tool_press());
- assert!(state.curve_tool_release());
+ assert!(state.viewer_tool_press());
+ assert!(state.viewer_tool_release());
assert_eq!(history(&state), (0, 0));
// Gesture 1: drag the first point to the pane center, over several
// motion events — still one entry.
- assert!(state.curve_tool_press());
+ assert!(state.viewer_tool_press());
for (x, y) in [(55.0, 55.0), (52.0, 52.0), (50.0, 50.0)] {
state.cursor_x = x;
state.cursor_y = y;
- assert!(state.curve_tool_drag_motion());
+ assert!(state.viewer_tool_drag_motion());
}
- assert!(state.curve_tool_release());
+ assert!(state.viewer_tool_release());
let after_drag = points_of(&state);
assert!(after_drag[0].length() < 1e-4);
assert_eq!(history(&state), (1, 0));
@@ -1801,62 +1803,62 @@ mod tests {
// Gesture 2: add a point (press on empty space + drag + release).
state.cursor_x = 90.0;
state.cursor_y = 90.0;
- assert!(state.curve_tool_press());
+ assert!(state.viewer_tool_press());
state.cursor_x = 85.0;
state.cursor_y = 85.0;
- assert!(state.curve_tool_drag_motion());
- assert!(state.curve_tool_release());
+ assert!(state.viewer_tool_drag_motion());
+ assert!(state.viewer_tool_release());
let after_add = points_of(&state);
assert_eq!(after_add.len(), initial.len() + 1);
assert_eq!(history(&state), (2, 0));
// Gesture 3: delete the selected (new) point.
- assert!(state.curve_tool_delete_selected());
+ assert!(state.viewer_tool_delete_selected());
let after_delete = points_of(&state);
assert_eq!(after_delete.len(), initial.len());
assert_eq!(history(&state), (3, 0));
// Undo walks back through all three.
- assert!(state.curve_tool_undo());
+ assert!(state.viewer_tool_undo());
assert_eq!(points_of(&state), after_add);
- assert!(state.curve_tool_undo());
+ assert!(state.viewer_tool_undo());
assert_eq!(points_of(&state), after_drag);
- assert!(state.curve_tool_undo());
+ assert!(state.viewer_tool_undo());
assert_eq!(points_of(&state), initial);
assert_eq!(history(&state), (0, 3));
- assert!(!state.curve_tool_undo(), "history exhausted");
+ assert!(!state.viewer_tool_undo(), "history exhausted");
// Redo walks forward again.
- assert!(state.curve_tool_redo());
+ assert!(state.viewer_tool_redo());
assert_eq!(points_of(&state), after_drag);
- assert!(state.curve_tool_redo());
+ assert!(state.viewer_tool_redo());
assert_eq!(points_of(&state), after_add);
assert_eq!(history(&state), (2, 1));
// A new gesture after an undo forks: the redo branch is gone.
state.cursor_x = 50.0;
state.cursor_y = 50.0;
- assert!(state.curve_tool_delete_at_cursor());
+ assert!(state.viewer_tool_delete_at_cursor());
assert_eq!(history(&state), (3, 0));
- assert!(!state.curve_tool_redo());
+ assert!(!state.viewer_tool_redo());
// Undo mid-drag abandons the drag and clamps the selection.
let pts = points_of(&state);
state.cursor_x = sx(pts[pts.len() - 1].x);
state.cursor_y = sy(pts[pts.len() - 1].y);
- assert!(state.curve_tool_press());
+ assert!(state.viewer_tool_press());
state.cursor_x += 5.0;
- assert!(state.curve_tool_drag_motion());
- assert!(state.curve_tool_undo());
- let tool = state.curve_tool.as_ref().unwrap();
+ assert!(state.viewer_tool_drag_motion());
+ assert!(state.viewer_tool_undo());
+ let tool = state.viewer_tool.as_ref().unwrap();
assert!(tool.drag.is_none());
assert!(tool.selected.map(|i| i < points_of(&state).len()).unwrap_or(true));
- assert!(!state.curve_tool_drag_motion(), "no drag survives an undo");
+ assert!(!state.viewer_tool_drag_motion(), "no drag survives an undo");
// Leaving the state drops its history.
- state.toggle_curve_tool(slot);
- assert!(state.curve_tool.is_none());
- assert!(!state.curve_tool_undo());
+ state.toggle_viewer_state(slot);
+ assert!(state.viewer_tool.is_none());
+ assert!(!state.viewer_tool_undo());
}
/// The Extrude template: a subnet (input -> opencl -> output) whose kernel
@@ -4100,6 +4102,168 @@ mod tests {
assert!(from_palette_row("Not A Command").is_none());
}
+ /// The soft-transform viewer state: two fixed handles, one of which is a
+ /// DERIVED position, driven through the same framework as the curve.
+ ///
+ /// This is the test that says the framework is one — the curve tests above
+ /// exercise an open-ended list of stored world positions, and this is a
+ /// fixed pair where the second handle is `Centre + Translation` and has to
+ /// be converted both ways.
+ #[test]
+ fn test_the_soft_transform_state_drags_a_derived_handle() {
+ use crate::geometry::node_param_str;
+ let mut state = State::new(false);
+ let mut redraw = false;
+ state
+ .apply_action(
+ McpAction::AddNode {
+ template_name: "Soft Transform".to_string(),
+ name: None,
+ x: 0.0,
+ y: 0.0,
+ },
+ &mut redraw,
+ )
+ .expect("add soft transform node");
+ let slot = state.current_dir().children.len() - 1;
+ assert_eq!(state.current_dir().children[slot].node_type, "soft_transform");
+
+ state.toggle_viewer_state(slot);
+ assert!(state.viewer_tool.is_some(), "soft_transform must enter a viewer state");
+
+ state.last_scene_mvp = Some(Mat4::IDENTITY);
+ state.last_scene_view_rect = (0.0, 0.0, 100.0, 100.0);
+ let sx = |x: f32| 50.0 + x * 50.0;
+ let sy = |y: f32| 50.0 - y * 50.0;
+ let param = |state: &State, name: &str| {
+ node_param_str(&state.current_dir().children[slot], name, "")
+ };
+
+ // Two handles: the centre, and the tip at centre + translation. The
+ // template ships centre (0,0,0) and translation (0,0.2,0).
+ let handles = state.viewer_tool_handles();
+ assert_eq!(handles.len(), 2, "a soft transform has exactly two handles");
+ assert!((handles[0].1 - sx(0.0)).abs() < 1e-3 && (handles[0].2 - sy(0.0)).abs() < 1e-3);
+ assert!(
+ (handles[1].2 - sy(0.2)).abs() < 1e-3,
+ "the tip is not drawn at centre + translation: {:?}",
+ handles[1]
+ );
+
+ // Drag the TIP to (0.4, 0, 0): the translation becomes that offset,
+ // and the centre does not move.
+ state.cursor_x = handles[1].1;
+ state.cursor_y = handles[1].2;
+ assert!(state.viewer_tool_press(), "press on the tip must grab");
+ state.cursor_x = sx(0.4);
+ state.cursor_y = sy(0.0);
+ assert!(state.viewer_tool_drag_motion());
+ assert!(state.viewer_tool_release());
+ assert_eq!(param(&state, "Center"), "0.00:0.00:0.00", "the centre moved");
+ assert_eq!(param(&state, "Translation"), "0.40:0.00:0.00");
+
+ // Fixed handles: a press on empty space adds nothing, and Delete
+ // removes nothing — a third handle would mean nothing.
+ state.cursor_x = sx(-0.9);
+ state.cursor_y = sy(-0.9);
+ assert!(state.viewer_tool_press(), "the state still owns the viewport press");
+ assert!(state.viewer_tool_release() || true);
+ assert_eq!(state.viewer_tool_handles().len(), 2, "empty-space press added a handle");
+ assert!(!state.viewer_tool_delete_selected(), "a fixed source must not delete");
+
+ // Dragging the BASE keeps the tip where it is, so the translation
+ // shortens to match — the documented two-handled-gizmo behaviour.
+ let handles = state.viewer_tool_handles();
+ state.cursor_x = handles[0].1;
+ state.cursor_y = handles[0].2;
+ assert!(state.viewer_tool_press());
+ state.cursor_x = sx(0.1);
+ state.cursor_y = sy(0.0);
+ assert!(state.viewer_tool_drag_motion());
+ assert!(state.viewer_tool_release());
+ assert_eq!(param(&state, "Center"), "0.10:0.00:0.00");
+ assert_eq!(param(&state, "Translation"), "0.30:0.00:0.00", "the tip should not have moved");
+
+ // And undo restores BOTH parameters, which is the case a "keep the
+ // translation when the centre moves" rule would have broken.
+ assert!(state.viewer_tool_undo());
+ assert_eq!(param(&state, "Center"), "0.00:0.00:0.00");
+ assert_eq!(param(&state, "Translation"), "0.40:0.00:0.00");
+ }
+
+ /// Snapping rounds a dragged handle to a world increment, and only when it
+ /// is on.
+ #[test]
+ fn test_snapping_rounds_a_dragged_handle() {
+ use crate::viewer_state::SNAP_INCREMENT;
+ let mut state = State::new(false);
+ let mut redraw = false;
+ state
+ .apply_action(
+ McpAction::AddNode { template_name: "Curve".to_string(), name: None, x: 0.0, y: 0.0 },
+ &mut redraw,
+ )
+ .expect("add curve node");
+ let slot = state.current_dir().children.len() - 1;
+ state.toggle_viewer_state(slot);
+ state.last_scene_mvp = Some(Mat4::IDENTITY);
+ state.last_scene_view_rect = (0.0, 0.0, 100.0, 100.0);
+ let points = |state: &State| {
+ crate::geometry::parse_curve_points(&crate::geometry::node_param_str(
+ &state.current_dir().children[slot],
+ "Points",
+ "",
+ ))
+ };
+
+ // Off by default: a drag lands exactly where the cursor is.
+ let first = points(&state)[0];
+ state.cursor_x = 50.0 + first.x * 50.0;
+ state.cursor_y = 50.0 - first.y * 50.0;
+ assert!(state.viewer_tool_press());
+ state.cursor_x = 50.0 + 0.37 * 50.0;
+ state.cursor_y = 50.0;
+ assert!(state.viewer_tool_drag_motion());
+ assert!(state.viewer_tool_release());
+ assert!((points(&state)[0].x - 0.37).abs() < 1e-3, "{:?}", points(&state)[0]);
+
+ // On: the same drag rounds to the increment.
+ assert!(state.toggle_viewer_snap());
+ assert_eq!(state.viewer_tool.as_ref().unwrap().snap, Some(SNAP_INCREMENT));
+ let first = points(&state)[0];
+ state.cursor_x = 50.0 + first.x * 50.0;
+ state.cursor_y = 50.0 - first.y * 50.0;
+ assert!(state.viewer_tool_press());
+ state.cursor_x = 50.0 + 0.37 * 50.0;
+ state.cursor_y = 50.0;
+ assert!(state.viewer_tool_drag_motion());
+ assert!(state.viewer_tool_release());
+ let p = points(&state)[0];
+ assert!((p.x - 0.4).abs() < 1e-3, "snapped x should be 0.4, got {p:?}");
+
+ // And off again, from the same command.
+ assert!(state.toggle_viewer_snap());
+ assert_eq!(state.viewer_tool.as_ref().unwrap().snap, None);
+
+ // The HUD says which it is, because a mode you cannot see is a mode
+ // you forget you are in.
+ let hud = state.viewer_tool.as_ref().unwrap().hud();
+ assert!(hud.contains("Curve Points") && hud.contains("Snap off"), "{hud}");
+ }
+
+ /// Only node types with a source enter a viewer state, and each gets its
+ /// own.
+ #[test]
+ fn test_only_editable_node_types_enter_a_viewer_state() {
+ use crate::viewer_state::source_for;
+ assert_eq!(source_for("curve").map(|s| s.name()), Some("Curve Points"));
+ assert_eq!(source_for("soft_transform").map(|s| s.name()), Some("Soft Transform"));
+ assert!(source_for("sphere").is_none());
+ assert!(source_for("boolean").is_none());
+ // Types are matched case-insensitively, like every other node lookup.
+ assert!(source_for("Curve").is_some());
+ }
+
/// A page's raster is its physical size times its resolution — the
/// property that makes DPI a page parameter rather than an export one.
#[test]
diff --git a/src/render.rs b/src/render.rs
index a3f49e6..1ed092c 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -178,7 +178,7 @@ impl State {
self.append_frame_text(&mut pc);
self.append_meta_point_numbers(&mut pc);
self.append_scale_readout(&mut pc);
- self.append_curve_tool_overlay(&mut pc);
+ self.append_viewer_state_overlay(&mut pc);
self.append_popovers(&mut pc);
self.append_dock_drag_overlay(&mut pc);
self.append_plate_corners(&mut pc);
@@ -907,12 +907,12 @@ impl State {
/// through the cached scene mvp (like the point numbers above), drawn as
/// a ringed dot with its index, the control cage as faint segments
/// between them. Selected point draws larger and brighter.
- fn append_curve_tool_overlay(&self, pc: &mut PaintCtx) {
- let Some(tool) = &self.curve_tool else { return };
+ fn append_viewer_state_overlay(&self, pc: &mut PaintCtx) {
+ let Some(tool) = &self.viewer_tool else { return };
if !self.show_viewport {
return;
}
- let handles = self.curve_tool_handles();
+ let handles = self.viewer_tool_handles();
let (vx, vy, vw, vh) = self.last_scene_view_rect;
if vw <= 0.0 || vh <= 0.0 {
return;
@@ -934,9 +934,30 @@ impl State {
[1.0, 0.78, 0.20, 1.0]
};
pc.circle(*sx, *sy, r, col);
- pc.text((i + 1).to_string(), sx + 8.0, sy - 6.0, 10.0, [0xff, 0xe6, 0xa0]);
+ pc.text(tool.source.handle_label(*i), sx + 8.0, sy - 6.0, 10.0, [0xff, 0xe6, 0xa0]);
}
});
+
+ // The HUD sits one line ABOVE the scale readout, sharing its left
+ // margin. Not at the top: the viewport is full-bleed and the pane
+ // plates float over its top edge, so a mode line there lands under the
+ // collapsed stubs and their titles read through it. Not at the very
+ // bottom either — that row belongs to the scale readout, and two
+ // sentences on one line read as one garbled sentence.
+ //
+ // It exists because a viewer state changes what every click does and
+ // snapping silently changes what a drag does. A mode you cannot see is
+ // a mode you forget you are in, and the first symptom is a click that
+ // does something surprising.
+ let hud = tool.hud();
+ let size = 11.0;
+ let pad = 5.0;
+ let y = vy + vh - 16.0 - (size + pad * 2.0) - 4.0;
+ let width = (hud.chars().count() as f32 * size * 0.52 + pad * 2.0).min(vw - 16.0);
+ pc.clip(rect(vx, vy, vw, vh), |pc| {
+ pc.quad(rect(vx + 8.0 - pad, y, width, size + pad * 2.0), [0.0, 0.0, 0.0, 0.55]);
+ pc.text(hud, vx + 8.0, y + pad, size, [0xff, 0xe6, 0xa0]);
+ });
}
/// Compose the 2D page the displayed level holds, if it holds one, and
diff --git a/src/shortcut.rs b/src/shortcut.rs
index 72c58b4..a44003b 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -25,6 +25,10 @@ pub enum Action {
/// Open the command palette — a command like any other, so it is
/// rebindable and lists itself.
CommandPalette,
+ /// Snap dragged handles in the active viewer state to a world increment.
+ ToggleSnap,
+ /// Enter or leave the selected node's viewer state.
+ ToggleViewerState,
}
#[derive(Debug, Clone)]
diff --git a/src/soft_transform_tool.rs b/src/soft_transform_tool.rs
new file mode 100644
index 0000000..149eccb
--- /dev/null
+++ b/src/soft_transform_tool.rs
@@ -0,0 +1,100 @@
+//! The soft-transform viewer state: place the falloff centre and the offset by
+//! dragging, instead of typing six numbers.
+//!
+//! The second [`HandleSource`], and deliberately a different shape from the
+//! curve's — an abstraction with one implementation has not been shown to be
+//! one. Where a curve is an open-ended list of world positions stored as world
+//! positions, a soft transform has exactly TWO handles and only one of them is
+//! a position:
+//!
+//! - **Centre** — where the falloff is centred, a world position, stored as
+//! one.
+//! - **Tip** — drawn at `Centre + Translation`, because a translation is not a
+//! place: it is how far things move.
+//!
+//! So the pair reads as a vector with a base and a tip, and dragging EITHER
+//! end changes the offset between them — moving the centre keeps the tip where
+//! it is and shortens or lengthens the translation to match. That is the
+//! behaviour a two-handled gizmo has everywhere, and it is also the only one
+//! this trait can express honestly: `write` is handed a full set of handles
+//! with no word about which moved, and it has to mean the same thing when the
+//! set came from an undo snapshot as when it came from a drag. A rule like
+//! "keep the translation when the centre moves" would be right for the drag
+//! and would quietly discard half of every restored snapshot.
+//!
+//! The conversion between stored parameters and world handles is exactly what
+//! [`HandleSource`] exists to contain. The framework's drag maths only ever
+//! sees two world positions, and needs no idea that one of them is derived —
+//! which is the property that makes it a framework rather than the curve tool
+//! with the names changed.
+//!
+//! Handles are fixed, so the source is not extensible: a press on empty space
+//! grabs nothing and a third handle would mean nothing.
+
+use crate::app::FsNode;
+use crate::geometry::node_param_str;
+use crate::viewer_state::HandleSource;
+use glam::Vec3;
+
+pub struct SoftTransformHandles;
+
+/// Read a `x:y:z` (or whitespace/comma separated) triple parameter.
+///
+/// Soft Transform stores Translation and Center as `text`, not `float3`, so
+/// the separator it was saved with depends on which pane wrote it last —
+/// accept any of them rather than silently reading a zero.
+fn triple(node: &FsNode, name: &str) -> Vec3 {
+ let raw = node_param_str(node, name, "");
+ let n: Vec<f32> = raw
+ .split(|c: char| c == ':' || c == ',' || c.is_whitespace())
+ .filter(|t| !t.is_empty())
+ .filter_map(|t| t.parse::<f32>().ok())
+ .collect();
+ if n.len() == 3 && n.iter().all(|v| v.is_finite()) {
+ Vec3::new(n[0], n[1], n[2])
+ } else {
+ Vec3::ZERO
+ }
+}
+
+fn set_triple(node: &mut FsNode, name: &str, v: Vec3) {
+ // Written back in the same `x:y:z` form the templates ship, so a value the
+ // tool wrote and a value the pane wrote are indistinguishable.
+ let formatted = format!("{:.2}:{:.2}:{:.2}", v.x, v.y, v.z);
+ if let Some(p) = node.params.iter_mut().find(|p| p.name == name) {
+ p.default = formatted;
+ }
+}
+
+impl HandleSource for SoftTransformHandles {
+ fn name(&self) -> &'static str {
+ "Soft Transform"
+ }
+
+ fn accepts(&self, node_type: &str) -> bool {
+ node_type.eq_ignore_ascii_case("soft_transform")
+ }
+
+ fn read(&self, node: &FsNode) -> Vec<Vec3> {
+ let centre = triple(node, "Center");
+ vec![centre, centre + triple(node, "Translation")]
+ }
+
+ fn write(&self, node: &mut FsNode, handles: &[Vec3]) {
+ let [centre, offset] = handles else { return };
+ set_triple(node, "Center", *centre);
+ set_triple(node, "Translation", *offset - *centre);
+ }
+
+ fn extensible(&self) -> bool {
+ false
+ }
+
+ fn hints(&self) -> &'static str {
+ "drag the base to place the falloff, the tip to set the offset"
+ }
+
+ fn handle_label(&self, i: usize) -> String {
+ if i == 0 { "centre".into() } else { "tip".into() }
+ }
+}
diff --git a/src/viewer_state.rs b/src/viewer_state.rs
new file mode 100644
index 0000000..74e6444
--- /dev/null
+++ b/src/viewer_state.rs
@@ -0,0 +1,457 @@
+//! The viewer-state framework: interactive viewport tools, generalized out of
+//! the curve tool.
+//!
+//! A viewer state is a mode the viewport is in, bound to one node, in which
+//! the pointer edits that node directly instead of orbiting the camera. The
+//! curve tool was the first, and everything in it except "what a handle IS"
+//! turned out to be the same for any such tool:
+//!
+//! - **projection** — world positions to screen through the raster scene's
+//! cached mvp, the same path the meta Point Numbers overlay rides;
+//! - **hit-testing** — the nearest handle within a radius of the cursor;
+//! - **the drag model** — capture the grabbed handle's NDC depth, then
+//! unproject the cursor onto that plane, so orbiting between edits never
+//! makes a drag jump;
+//! - **per-gesture undo** — a whole drag is one step, recorded on the first
+//! motion after a grab so a click that never moves records nothing;
+//! - **binding by node ID, not slot** — renames and graph edits do not detach
+//! the tool, and a node that disappears makes every handler resolve nothing
+//! so the state drops out lazily;
+//! - **write-back** — the same resync sequence `McpAction::SetParam` runs, so
+//! the params pane, the spreadsheet and the scene all follow an edit live.
+//!
+//! What differs per tool is [`HandleSource`]: which node types it accepts,
+//! where the handles are, how to write them back, and whether the pointer may
+//! add and remove them. Two implementations ship, deliberately different in
+//! shape — a curve's open-ended list of control points, and a soft transform's
+//! fixed pair where one handle's position is DERIVED from two parameters. An
+//! abstraction with a single implementation has not been shown to be one.
+//!
+//! The two things the framework adds over what the curve tool had are snapping
+//! and a HUD, both of which every tool wants and neither of which a tool
+//! should implement itself.
+
+use crate::app::{FsNode, State};
+use cce_ui::history::History;
+use glam::{Mat4, Vec3, Vec4};
+
+/// How close (logical px) a press must land to a projected handle to grab it.
+pub const HANDLE_HIT_RADIUS: f32 = 10.0;
+
+/// What a viewer state edits.
+///
+/// Handles are WORLD positions, always. A source whose parameters are not
+/// world positions — a soft transform's translation is an offset — converts in
+/// [`read`](Self::read) and [`write`](Self::write), so the framework never has
+/// to know the difference and the drag maths stays one implementation.
+pub trait HandleSource {
+ /// Shown in the HUD, so it says what mode the viewport is in.
+ fn name(&self) -> &'static str;
+
+ /// Whether this source can edit a node of that type. Checked on every
+ /// resolution, not just on entry: a project reload can put anything at an
+ /// old id, and the tool must drop out rather than write nonsense.
+ fn accepts(&self, node_type: &str) -> bool;
+
+ /// The node's handles, in world space.
+ fn read(&self, node: &FsNode) -> Vec<Vec3>;
+
+ /// Write handles back into the node's parameters. The framework runs the
+ /// resync afterwards.
+ fn write(&self, node: &mut FsNode, handles: &[Vec3]);
+
+ /// Whether a press on empty space appends a handle and a right press
+ /// deletes one. False for a source with a fixed set — a soft transform has
+ /// exactly a centre and a translation, and a third handle would mean
+ /// nothing.
+ fn extensible(&self) -> bool;
+
+ /// The key hints for the HUD, without the ones the framework owns
+ /// (snapping, Escape) — those are appended.
+ fn hints(&self) -> &'static str;
+
+ /// What to write beside handle `i`. Indices by default, which is right
+ /// for an ordered list; a source whose handles mean different things names
+ /// them instead, because "1" and "2" on a centre and a tip is a worse
+ /// label than none.
+ fn handle_label(&self, i: usize) -> String {
+ (i + 1).to_string()
+ }
+}
+
+/// An in-flight drag.
+#[derive(Clone, Copy)]
+pub struct Drag {
+ pub handle: usize,
+ /// NDC depth captured at grab time; motion unprojects onto this plane.
+ pub ndc_z: f32,
+}
+
+/// The active viewer state.
+pub struct ViewerTool {
+ /// The edited node's id — not its slot, so renames and graph edits do not
+ /// detach the tool.
+ pub node_id: String,
+ pub source: Box<dyn HandleSource>,
+ /// The last-clicked handle — the Delete target.
+ pub selected: Option<usize>,
+ pub drag: Option<Drag>,
+ /// Handle snapshots, one per gesture.
+ pub history: History<Vec<Vec3>>,
+ /// World-space increment a dragged handle rounds to, or `None` for free
+ /// movement. Lives on the tool rather than in settings because it is a
+ /// property of the editing session, and it survives retargeting so turning
+ /// it on does not have to be repeated per node.
+ pub snap: Option<f32>,
+}
+
+/// The increment snapping rounds to when it is switched on.
+///
+/// A tenth of a world unit: fine enough to place a point deliberately, coarse
+/// enough that two snapped points actually coincide. The world unit is a
+/// DECLARATION here (see the Guides node), so this is a tenth of whatever the
+/// project says a unit is rather than a tenth of a millimetre.
+pub const SNAP_INCREMENT: f32 = 0.1;
+
+impl ViewerTool {
+ pub fn new(node_id: String, source: Box<dyn HandleSource>) -> Self {
+ ViewerTool { node_id, source, selected: None, drag: None, history: History::new(), snap: None }
+ }
+
+ /// The HUD line: what mode this is, what the keys do, and whether snapping
+ /// is on. The snap state is on the HUD because it silently changes what a
+ /// drag does, and a mode you cannot see is a mode you forget you are in.
+ pub fn hud(&self) -> String {
+ format!(
+ "{} — {} · Snap {} · Esc exits",
+ self.source.name(),
+ self.source.hints(),
+ if self.snap.is_some() { "on" } else { "off" },
+ )
+ }
+}
+
+/// Round a world position to `increment` on every axis.
+fn snapped(p: Vec3, increment: Option<f32>) -> Vec3 {
+ match increment {
+ Some(i) if i > 0.0 => Vec3::new(
+ (p.x / i).round() * i,
+ (p.y / i).round() * i,
+ (p.z / i).round() * i,
+ ),
+ _ => p,
+ }
+}
+
+/// World → (screen x, screen y, ndc z) through the cached scene mvp.
+pub fn project_point(mvp: &Mat4, view: (f32, f32, f32, f32), p: Vec3) -> Option<(f32, f32, f32)> {
+ let (vx, vy, vw, vh) = view;
+ let clip = *mvp * Vec4::new(p.x, p.y, p.z, 1.0);
+ if clip.w <= 0.0 {
+ return None;
+ }
+ let ndc = clip / clip.w;
+ Some((vx + (ndc.x * 0.5 + 0.5) * vw, vy + (0.5 - ndc.y * 0.5) * vh, ndc.z))
+}
+
+/// (screen x, screen y, ndc z) → world through the inverse of the cached mvp.
+pub fn unproject_point(
+ mvp: &Mat4,
+ view: (f32, f32, f32, f32),
+ sx: f32,
+ sy: f32,
+ ndc_z: f32,
+) -> Option<Vec3> {
+ let (vx, vy, vw, vh) = view;
+ if vw <= 0.0 || vh <= 0.0 {
+ return None;
+ }
+ let inv = mvp.inverse();
+ if !inv.is_finite() {
+ return None;
+ }
+ let ndc_x = ((sx - vx) / vw - 0.5) * 2.0;
+ let ndc_y = (0.5 - (sy - vy) / vh) * 2.0;
+ let world = inv * Vec4::new(ndc_x, ndc_y, ndc_z, 1.0);
+ if world.w.abs() < 1e-6 {
+ return None;
+ }
+ let world = world / world.w;
+ if !world.is_finite() {
+ return None;
+ }
+ Some(Vec3::new(world.x, world.y, world.z))
+}
+
+pub fn find_node_by_id<'a>(root: &'a FsNode, id: &str) -> Option<&'a FsNode> {
+ if root.id == id {
+ return Some(root);
+ }
+ root.children.iter().find_map(|c| find_node_by_id(c, id))
+}
+
+pub fn find_node_by_id_mut<'a>(root: &'a mut FsNode, id: &str) -> Option<&'a mut FsNode> {
+ if root.id == id {
+ return Some(root);
+ }
+ root.children.iter_mut().find_map(|c| find_node_by_id_mut(c, id))
+}
+
+/// The viewer state a node type can enter, if any.
+///
+/// One place that maps node types to tools, so the node context menu, the
+/// command and any future entry point agree about what is editable.
+pub fn source_for(node_type: &str) -> Option<Box<dyn HandleSource>> {
+ let sources: [Box<dyn HandleSource>; 2] = [
+ Box::new(crate::curve_tool::CurveHandles),
+ Box::new(crate::soft_transform_tool::SoftTransformHandles),
+ ];
+ sources.into_iter().find(|s| s.accepts(node_type))
+}
+
+impl State {
+ /// Enter/exit the viewer state for the node in `slot` of the current
+ /// directory. A different editable node retargets the tool.
+ pub(crate) fn toggle_viewer_state(&mut self, slot: usize) {
+ let Some(node) = self.current_dir().children.get(slot) else { return };
+ let Some(source) = source_for(&node.node_type) else { return };
+ let id = node.id.clone();
+ if self.viewer_tool.as_ref().map(|t| t.node_id == id).unwrap_or(false) {
+ self.viewer_tool = None;
+ } else {
+ self.viewer_tool = Some(ViewerTool::new(id, source));
+ }
+ }
+
+ /// Turn snapping on or off for the active state. Returns false when no
+ /// state is active, so the command falls through to mean nothing rather
+ /// than reporting success.
+ pub(crate) fn toggle_viewer_snap(&mut self) -> bool {
+ let Some(tool) = self.viewer_tool.as_mut() else { return false };
+ tool.snap = match tool.snap {
+ Some(_) => None,
+ None => Some(SNAP_INCREMENT),
+ };
+ let on = tool.snap.is_some();
+ self.update_status_text(if on { "Snapping on" } else { "Snapping off" });
+ true
+ }
+
+ /// The edited node's handles, or None if the node is gone or is no longer
+ /// a type this source accepts.
+ fn viewer_handles_of(&self, node_id: &str) -> Option<Vec<Vec3>> {
+ let tool = self.viewer_tool.as_ref()?;
+ let node = find_node_by_id(&self.fs_root, node_id)?;
+ if !tool.source.accepts(&node.node_type) {
+ return None;
+ }
+ Some(tool.source.read(node))
+ }
+
+ /// Write handles back and run the same resync sequence as SetParam.
+ fn set_viewer_handles(&mut self, node_id: &str, handles: &[Vec3]) {
+ let Some(tool) = self.viewer_tool.take() else { return };
+ if let Some(node) = find_node_by_id_mut(&mut self.fs_root, node_id) {
+ tool.source.write(node, handles);
+ }
+ self.viewer_tool = Some(tool);
+ self.sync_nodes();
+ self.rebuild_scene_geometry();
+ self.sync_parameters_pane();
+ }
+
+ /// The active tool's handles as (index, screen x, screen y, ndc z).
+ /// Empty when no tool is active, the node is gone, or no scene mvp has
+ /// been cached yet (a frame before the first scene staging).
+ pub(crate) fn viewer_tool_handles(&self) -> Vec<(usize, f32, f32, f32)> {
+ let Some(tool) = &self.viewer_tool else { return Vec::new() };
+ let Some(mvp) = self.last_scene_mvp else { return Vec::new() };
+ let Some(pts) = self.viewer_handles_of(&tool.node_id) else { return Vec::new() };
+ pts.iter()
+ .enumerate()
+ .filter_map(|(i, p)| {
+ project_point(&mvp, self.last_scene_view_rect, *p).map(|(sx, sy, z)| (i, sx, sy, z))
+ })
+ .collect()
+ }
+
+ /// The handle under the cursor, nearest first.
+ fn viewer_handle_at_cursor(&self) -> Option<(usize, f32)> {
+ let (cx, cy) = (self.cursor_x, self.cursor_y);
+ self.viewer_tool_handles()
+ .iter()
+ .map(|(i, sx, sy, z)| (*i, ((sx - cx).powi(2) + (sy - cy).powi(2)).sqrt(), *z))
+ .filter(|(_, d, _)| *d <= HANDLE_HIT_RADIUS)
+ .min_by(|a, b| a.1.total_cmp(&b.1))
+ .map(|(i, _, z)| (i, z))
+ }
+
+ /// Left press in the viewport while a state is active: grab the handle
+ /// under the cursor, or — for an extensible source — append a new handle
+ /// there and start dragging it. Returns false, letting the press fall
+ /// through, only when the edited node no longer exists.
+ pub(crate) fn viewer_tool_press(&mut self) -> bool {
+ let Some(tool) = &self.viewer_tool else { return false };
+ let node_id = tool.node_id.clone();
+ let Some(mut pts) = self.viewer_handles_of(&node_id) else {
+ self.viewer_tool = None;
+ return false;
+ };
+ if let Some((idx, ndc_z)) = self.viewer_handle_at_cursor() {
+ let tool = self.viewer_tool.as_mut().expect("checked above");
+ tool.selected = Some(idx);
+ tool.history.begin_gesture(pts);
+ tool.drag = Some(Drag { handle: idx, ndc_z });
+ return true;
+ }
+ if !self.viewer_tool.as_ref().is_some_and(|t| t.source.extensible()) {
+ // A fixed source consumes the press anyway: the state owns the
+ // viewport while it is active, and falling through would open the
+ // context menu on every miss.
+ return true;
+ }
+ // Empty space: add a handle. Depth comes from the last one (or the
+ // world origin) so the new handle lands in the plane already in use.
+ let Some(mvp) = self.last_scene_mvp else { return true };
+ let view = self.last_scene_view_rect;
+ let ndc_z = pts
+ .last()
+ .and_then(|p| project_point(&mvp, view, *p))
+ .map(|(_, _, z)| z)
+ .or_else(|| project_point(&mvp, view, Vec3::ZERO).map(|(_, _, z)| z));
+ let Some(ndc_z) = ndc_z else { return true };
+ let Some(world) = unproject_point(&mvp, view, self.cursor_x, self.cursor_y, ndc_z) else {
+ return true;
+ };
+ let snap = self.viewer_tool.as_ref().and_then(|t| t.snap);
+ let before = pts.clone();
+ pts.push(snapped(world, snap));
+ let idx = pts.len() - 1;
+ self.set_viewer_handles(&node_id, &pts);
+ if let Some(tool) = self.viewer_tool.as_mut() {
+ // The add is the recorded step; the drag that follows is part of
+ // the same gesture, so no gesture is opened for it.
+ tool.history.record(before);
+ tool.selected = Some(idx);
+ tool.drag = Some(Drag { handle: idx, ndc_z });
+ }
+ true
+ }
+
+ /// Pointer motion during a grab: the handle tracks the cursor on the
+ /// camera-facing plane at its grab depth.
+ pub(crate) fn viewer_tool_drag_motion(&mut self) -> bool {
+ let Some(drag) = self.viewer_tool.as_ref().and_then(|t| t.drag) else { return false };
+ let Some(mvp) = self.last_scene_mvp else { return false };
+ let node_id = self.viewer_tool.as_ref().expect("drag implies tool").node_id.clone();
+ let Some(mut pts) = self.viewer_handles_of(&node_id) else {
+ self.viewer_tool = None;
+ return false;
+ };
+ if drag.handle >= pts.len() {
+ return false;
+ }
+ let Some(world) = unproject_point(
+ &mvp,
+ self.last_scene_view_rect,
+ self.cursor_x,
+ self.cursor_y,
+ drag.ndc_z,
+ ) else {
+ return false;
+ };
+ let snap = self.viewer_tool.as_ref().and_then(|t| t.snap);
+ pts[drag.handle] = snapped(world, snap);
+ if let Some(tool) = self.viewer_tool.as_mut() {
+ tool.history.commit_gesture();
+ }
+ self.set_viewer_handles(&node_id, &pts);
+ true
+ }
+
+ /// Button release: end any in-flight grab.
+ pub(crate) fn viewer_tool_release(&mut self) -> bool {
+ match self.viewer_tool.as_mut() {
+ Some(tool) if tool.drag.is_some() => {
+ tool.drag = None;
+ tool.history.cancel_gesture();
+ true
+ }
+ _ => false,
+ }
+ }
+
+ /// Right press: delete the handle under the cursor. Consumes only on a hit
+ /// on an extensible source — otherwise the press falls through to the
+ /// viewport context menu.
+ pub(crate) fn viewer_tool_delete_at_cursor(&mut self) -> bool {
+ if !self.viewer_tool.as_ref().is_some_and(|t| t.source.extensible()) {
+ return false;
+ }
+ let Some((idx, _)) = self.viewer_handle_at_cursor() else { return false };
+ self.viewer_tool_delete_handle(idx)
+ }
+
+ /// Delete/Backspace: remove the selected handle, if any.
+ pub(crate) fn viewer_tool_delete_selected(&mut self) -> bool {
+ if !self.viewer_tool.as_ref().is_some_and(|t| t.source.extensible()) {
+ return false;
+ }
+ let Some(idx) = self.viewer_tool.as_ref().and_then(|t| t.selected) else { return false };
+ self.viewer_tool_delete_handle(idx)
+ }
+
+ fn viewer_tool_delete_handle(&mut self, idx: usize) -> bool {
+ let Some(tool) = &self.viewer_tool else { return false };
+ let node_id = tool.node_id.clone();
+ let Some(mut pts) = self.viewer_handles_of(&node_id) else {
+ self.viewer_tool = None;
+ return false;
+ };
+ if idx >= pts.len() {
+ return false;
+ }
+ let before = pts.clone();
+ pts.remove(idx);
+ self.set_viewer_handles(&node_id, &pts);
+ if let Some(tool) = self.viewer_tool.as_mut() {
+ tool.history.record(before);
+ tool.drag = None;
+ // Keep a neighbour selected so repeated Delete walks the handles.
+ tool.selected = if pts.is_empty() { None } else { Some(idx.min(pts.len() - 1)) };
+ }
+ true
+ }
+
+ /// Undo: return the handles to how they were before the last recorded
+ /// gesture. Consumes only when a state is active and has history.
+ pub(crate) fn viewer_tool_undo(&mut self) -> bool {
+ self.viewer_tool_step(true)
+ }
+
+ /// Redo: reapply the last undone gesture.
+ pub(crate) fn viewer_tool_redo(&mut self) -> bool {
+ self.viewer_tool_step(false)
+ }
+
+ fn viewer_tool_step(&mut self, undo: bool) -> bool {
+ let Some(tool) = &self.viewer_tool else { return false };
+ let node_id = tool.node_id.clone();
+ let Some(current) = self.viewer_handles_of(&node_id) else {
+ self.viewer_tool = None;
+ return false;
+ };
+ let tool = self.viewer_tool.as_mut().expect("checked above");
+ let stepped = if undo { tool.history.undo(current) } else { tool.history.redo(current) };
+ let Some(target) = stepped else { return false };
+ // A step mid-drag abandons the drag: the grabbed index may not exist
+ // in the restored list, and the pointer no longer means anything to it.
+ tool.drag = None;
+ tool.selected = match tool.selected {
+ Some(i) if !target.is_empty() => Some(i.min(target.len() - 1)),
+ _ => None,
+ };
+ self.set_viewer_handles(&node_id, &target);
+ true
+ }
+}