git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

src/viewer_state.rs (18.3K)

  1 //! The viewer-state framework: interactive viewport tools, generalized out of
  2 //! the curve tool.
  3 //!
  4 //! A viewer state is a mode the viewport is in, bound to one node, in which
  5 //! the pointer edits that node directly instead of orbiting the camera. The
  6 //! curve tool was the first, and everything in it except "what a handle IS"
  7 //! turned out to be the same for any such tool:
  8 //!
  9 //! - **projection** — world positions to screen through the raster scene's
 10 //!   cached mvp, the same path the meta Point Numbers overlay rides;
 11 //! - **hit-testing** — the nearest handle within a radius of the cursor;
 12 //! - **the drag model** — capture the grabbed handle's NDC depth, then
 13 //!   unproject the cursor onto that plane, so orbiting between edits never
 14 //!   makes a drag jump;
 15 //! - **per-gesture undo** — a whole drag is one step, recorded on the first
 16 //!   motion after a grab so a click that never moves records nothing;
 17 //! - **binding by node ID, not slot** — renames and graph edits do not detach
 18 //!   the tool, and a node that disappears makes every handler resolve nothing
 19 //!   so the state drops out lazily;
 20 //! - **write-back** — the same resync sequence `McpAction::SetParam` runs, so
 21 //!   the params pane, the spreadsheet and the scene all follow an edit live.
 22 //!
 23 //! What differs per tool is [`HandleSource`]: which node types it accepts,
 24 //! where the handles are, how to write them back, and whether the pointer may
 25 //! add and remove them. Two implementations ship, deliberately different in
 26 //! shape — a curve's open-ended list of control points, and a soft transform's
 27 //! fixed pair where one handle's position is DERIVED from two parameters. An
 28 //! abstraction with a single implementation has not been shown to be one.
 29 //!
 30 //! The two things the framework adds over what the curve tool had are snapping
 31 //! and a HUD, both of which every tool wants and neither of which a tool
 32 //! should implement itself.
 33 
 34 use crate::app::{FsNode, State};
 35 use cce_ui::history::History;
 36 use glam::{Mat4, Vec3, Vec4};
 37 
 38 /// How close (logical px) a press must land to a projected handle to grab it.
 39 pub const HANDLE_HIT_RADIUS: f32 = 10.0;
 40 
 41 /// What a viewer state edits.
 42 ///
 43 /// Handles are WORLD positions, always. A source whose parameters are not
 44 /// world positions — a soft transform's translation is an offset — converts in
 45 /// [`read`](Self::read) and [`write`](Self::write), so the framework never has
 46 /// to know the difference and the drag maths stays one implementation.
 47 pub trait HandleSource {
 48     /// Shown in the HUD, so it says what mode the viewport is in.
 49     fn name(&self) -> &'static str;
 50 
 51     /// Whether this source can edit a node of that type. Checked on every
 52     /// resolution, not just on entry: a project reload can put anything at an
 53     /// old id, and the tool must drop out rather than write nonsense.
 54     fn accepts(&self, node_type: &str) -> bool;
 55 
 56     /// The node's handles, in world space.
 57     fn read(&self, node: &FsNode) -> Vec<Vec3>;
 58 
 59     /// Write handles back into the node's parameters. The framework runs the
 60     /// resync afterwards.
 61     fn write(&self, node: &mut FsNode, handles: &[Vec3]);
 62 
 63     /// Whether a press on empty space appends a handle and a right press
 64     /// deletes one. False for a source with a fixed set — a soft transform has
 65     /// exactly a centre and a translation, and a third handle would mean
 66     /// nothing.
 67     fn extensible(&self) -> bool;
 68 
 69     /// The key hints for the HUD, without the ones the framework owns
 70     /// (snapping, Escape) — those are appended.
 71     fn hints(&self) -> &'static str;
 72 
 73     /// What to write beside handle `i`. Indices by default, which is right
 74     /// for an ordered list; a source whose handles mean different things names
 75     /// them instead, because "1" and "2" on a centre and a tip is a worse
 76     /// label than none.
 77     fn handle_label(&self, i: usize) -> String {
 78         (i + 1).to_string()
 79     }
 80 }
 81 
 82 /// An in-flight drag.
 83 #[derive(Clone, Copy)]
 84 pub struct Drag {
 85     pub handle: usize,
 86     /// NDC depth captured at grab time; motion unprojects onto this plane.
 87     pub ndc_z: f32,
 88 }
 89 
 90 /// The active viewer state.
 91 pub struct ViewerTool {
 92     /// The edited node's id — not its slot, so renames and graph edits do not
 93     /// detach the tool.
 94     pub node_id: String,
 95     pub source: Box<dyn HandleSource>,
 96     /// The last-clicked handle — the Delete target.
 97     pub selected: Option<usize>,
 98     pub drag: Option<Drag>,
 99     /// Handle snapshots, one per gesture.
100     pub history: History<Vec<Vec3>>,
101     /// World-space increment a dragged handle rounds to, or `None` for free
102     /// movement. Lives on the tool rather than in settings because it is a
103     /// property of the editing session, and it survives retargeting so turning
104     /// it on does not have to be repeated per node.
105     pub snap: Option<f32>,
106 }
107 
108 /// The increment snapping rounds to when it is switched on.
109 ///
110 /// A tenth of a world unit: fine enough to place a point deliberately, coarse
111 /// enough that two snapped points actually coincide. The world unit is a
112 /// DECLARATION here (see the Guides node), so this is a tenth of whatever the
113 /// project says a unit is rather than a tenth of a millimetre.
114 pub const SNAP_INCREMENT: f32 = 0.1;
115 
116 impl ViewerTool {
117     pub fn new(node_id: String, source: Box<dyn HandleSource>) -> Self {
118         ViewerTool { node_id, source, selected: None, drag: None, history: History::new(), snap: None }
119     }
120 
121     /// The HUD line: what mode this is, what the keys do, and whether snapping
122     /// is on. The snap state is on the HUD because it silently changes what a
123     /// drag does, and a mode you cannot see is a mode you forget you are in.
124     pub fn hud(&self) -> String {
125         format!(
126             "{} — {}  ·  Snap {}  ·  Esc exits",
127             self.source.name(),
128             self.source.hints(),
129             if self.snap.is_some() { "on" } else { "off" },
130         )
131     }
132 }
133 
134 /// Round a world position to `increment` on every axis.
135 fn snapped(p: Vec3, increment: Option<f32>) -> Vec3 {
136     match increment {
137         Some(i) if i > 0.0 => Vec3::new(
138             (p.x / i).round() * i,
139             (p.y / i).round() * i,
140             (p.z / i).round() * i,
141         ),
142         _ => p,
143     }
144 }
145 
146 /// World → (screen x, screen y, ndc z) through the cached scene mvp.
147 pub fn project_point(mvp: &Mat4, view: (f32, f32, f32, f32), p: Vec3) -> Option<(f32, f32, f32)> {
148     let (vx, vy, vw, vh) = view;
149     let clip = *mvp * Vec4::new(p.x, p.y, p.z, 1.0);
150     if clip.w <= 0.0 {
151         return None;
152     }
153     let ndc = clip / clip.w;
154     Some((vx + (ndc.x * 0.5 + 0.5) * vw, vy + (0.5 - ndc.y * 0.5) * vh, ndc.z))
155 }
156 
157 /// (screen x, screen y, ndc z) → world through the inverse of the cached mvp.
158 pub fn unproject_point(
159     mvp: &Mat4,
160     view: (f32, f32, f32, f32),
161     sx: f32,
162     sy: f32,
163     ndc_z: f32,
164 ) -> Option<Vec3> {
165     let (vx, vy, vw, vh) = view;
166     if vw <= 0.0 || vh <= 0.0 {
167         return None;
168     }
169     let inv = mvp.inverse();
170     if !inv.is_finite() {
171         return None;
172     }
173     let ndc_x = ((sx - vx) / vw - 0.5) * 2.0;
174     let ndc_y = (0.5 - (sy - vy) / vh) * 2.0;
175     let world = inv * Vec4::new(ndc_x, ndc_y, ndc_z, 1.0);
176     if world.w.abs() < 1e-6 {
177         return None;
178     }
179     let world = world / world.w;
180     if !world.is_finite() {
181         return None;
182     }
183     Some(Vec3::new(world.x, world.y, world.z))
184 }
185 
186 pub fn find_node_by_id<'a>(root: &'a FsNode, id: &str) -> Option<&'a FsNode> {
187     if root.id == id {
188         return Some(root);
189     }
190     root.children.iter().find_map(|c| find_node_by_id(c, id))
191 }
192 
193 pub fn find_node_by_id_mut<'a>(root: &'a mut FsNode, id: &str) -> Option<&'a mut FsNode> {
194     if root.id == id {
195         return Some(root);
196     }
197     root.children.iter_mut().find_map(|c| find_node_by_id_mut(c, id))
198 }
199 
200 /// The viewer state a node type can enter, if any.
201 ///
202 /// One place that maps node types to tools, so the node context menu, the
203 /// command and any future entry point agree about what is editable.
204 pub fn source_for(node_type: &str) -> Option<Box<dyn HandleSource>> {
205     let sources: [Box<dyn HandleSource>; 2] = [
206         Box::new(crate::curve_tool::CurveHandles),
207         Box::new(crate::soft_transform_tool::SoftTransformHandles),
208     ];
209     sources.into_iter().find(|s| s.accepts(node_type))
210 }
211 
212 impl State {
213     /// Enter/exit the viewer state for the node in `slot` of the current
214     /// directory. A different editable node retargets the tool.
215     pub(crate) fn toggle_viewer_state(&mut self, slot: usize) {
216         let Some(node) = self.current_dir().children.get(slot) else { return };
217         let Some(source) = source_for(&node.node_type) else { return };
218         let id = node.id.clone();
219         if self.viewer_tool.as_ref().map(|t| t.node_id == id).unwrap_or(false) {
220             self.viewer_tool = None;
221         } else {
222             self.viewer_tool = Some(ViewerTool::new(id, source));
223         }
224     }
225 
226     /// Turn snapping on or off for the active state. Returns false when no
227     /// state is active, so the command falls through to mean nothing rather
228     /// than reporting success.
229     pub(crate) fn toggle_viewer_snap(&mut self) -> bool {
230         let Some(tool) = self.viewer_tool.as_mut() else { return false };
231         tool.snap = match tool.snap {
232             Some(_) => None,
233             None => Some(SNAP_INCREMENT),
234         };
235         let on = tool.snap.is_some();
236         self.update_status_text(if on { "Snapping on" } else { "Snapping off" });
237         true
238     }
239 
240     /// The edited node's handles, or None if the node is gone or is no longer
241     /// a type this source accepts.
242     fn viewer_handles_of(&self, node_id: &str) -> Option<Vec<Vec3>> {
243         let tool = self.viewer_tool.as_ref()?;
244         let node = find_node_by_id(&self.fs_root, node_id)?;
245         if !tool.source.accepts(&node.node_type) {
246             return None;
247         }
248         Some(tool.source.read(node))
249     }
250 
251     /// Write handles back and run the same resync sequence as SetParam.
252     fn set_viewer_handles(&mut self, node_id: &str, handles: &[Vec3]) {
253         let Some(tool) = self.viewer_tool.take() else { return };
254         if let Some(node) = find_node_by_id_mut(&mut self.fs_root, node_id) {
255             tool.source.write(node, handles);
256         }
257         self.viewer_tool = Some(tool);
258         self.sync_nodes();
259         self.rebuild_scene_geometry();
260         self.sync_parameters_pane();
261     }
262 
263     /// The active tool's handles as (index, screen x, screen y, ndc z).
264     /// Empty when no tool is active, the node is gone, or no scene mvp has
265     /// been cached yet (a frame before the first scene staging).
266     pub(crate) fn viewer_tool_handles(&self) -> Vec<(usize, f32, f32, f32)> {
267         let Some(tool) = &self.viewer_tool else { return Vec::new() };
268         let Some(mvp) = self.last_scene_mvp else { return Vec::new() };
269         let Some(pts) = self.viewer_handles_of(&tool.node_id) else { return Vec::new() };
270         pts.iter()
271             .enumerate()
272             .filter_map(|(i, p)| {
273                 project_point(&mvp, self.last_scene_view_rect, *p).map(|(sx, sy, z)| (i, sx, sy, z))
274             })
275             .collect()
276     }
277 
278     /// The handle under the cursor, nearest first.
279     fn viewer_handle_at_cursor(&self) -> Option<(usize, f32)> {
280         let (cx, cy) = (self.cursor_x, self.cursor_y);
281         self.viewer_tool_handles()
282             .iter()
283             .map(|(i, sx, sy, z)| (*i, ((sx - cx).powi(2) + (sy - cy).powi(2)).sqrt(), *z))
284             .filter(|(_, d, _)| *d <= HANDLE_HIT_RADIUS)
285             .min_by(|a, b| a.1.total_cmp(&b.1))
286             .map(|(i, _, z)| (i, z))
287     }
288 
289     /// Left press in the viewport while a state is active: grab the handle
290     /// under the cursor, or — for an extensible source — append a new handle
291     /// there and start dragging it. Returns false, letting the press fall
292     /// through, only when the edited node no longer exists.
293     pub(crate) fn viewer_tool_press(&mut self) -> bool {
294         let Some(tool) = &self.viewer_tool else { return false };
295         let node_id = tool.node_id.clone();
296         let Some(mut pts) = self.viewer_handles_of(&node_id) else {
297             self.viewer_tool = None;
298             return false;
299         };
300         if let Some((idx, ndc_z)) = self.viewer_handle_at_cursor() {
301             let tool = self.viewer_tool.as_mut().expect("checked above");
302             tool.selected = Some(idx);
303             tool.history.begin_gesture(pts);
304             tool.drag = Some(Drag { handle: idx, ndc_z });
305             return true;
306         }
307         if !self.viewer_tool.as_ref().is_some_and(|t| t.source.extensible()) {
308             // A fixed source consumes the press anyway: the state owns the
309             // viewport while it is active, and falling through would open the
310             // context menu on every miss.
311             return true;
312         }
313         // Empty space: add a handle. Depth comes from the last one (or the
314         // world origin) so the new handle lands in the plane already in use.
315         let Some(mvp) = self.last_scene_mvp else { return true };
316         let view = self.last_scene_view_rect;
317         let ndc_z = pts
318             .last()
319             .and_then(|p| project_point(&mvp, view, *p))
320             .map(|(_, _, z)| z)
321             .or_else(|| project_point(&mvp, view, Vec3::ZERO).map(|(_, _, z)| z));
322         let Some(ndc_z) = ndc_z else { return true };
323         let Some(world) = unproject_point(&mvp, view, self.cursor_x, self.cursor_y, ndc_z) else {
324             return true;
325         };
326         let snap = self.viewer_tool.as_ref().and_then(|t| t.snap);
327         let before = pts.clone();
328         pts.push(snapped(world, snap));
329         let idx = pts.len() - 1;
330         self.set_viewer_handles(&node_id, &pts);
331         if let Some(tool) = self.viewer_tool.as_mut() {
332             // The add is the recorded step; the drag that follows is part of
333             // the same gesture, so no gesture is opened for it.
334             tool.history.record(before);
335             tool.selected = Some(idx);
336             tool.drag = Some(Drag { handle: idx, ndc_z });
337         }
338         true
339     }
340 
341     /// Pointer motion during a grab: the handle tracks the cursor on the
342     /// camera-facing plane at its grab depth.
343     pub(crate) fn viewer_tool_drag_motion(&mut self) -> bool {
344         let Some(drag) = self.viewer_tool.as_ref().and_then(|t| t.drag) else { return false };
345         let Some(mvp) = self.last_scene_mvp else { return false };
346         let node_id = self.viewer_tool.as_ref().expect("drag implies tool").node_id.clone();
347         let Some(mut pts) = self.viewer_handles_of(&node_id) else {
348             self.viewer_tool = None;
349             return false;
350         };
351         if drag.handle >= pts.len() {
352             return false;
353         }
354         let Some(world) = unproject_point(
355             &mvp,
356             self.last_scene_view_rect,
357             self.cursor_x,
358             self.cursor_y,
359             drag.ndc_z,
360         ) else {
361             return false;
362         };
363         let snap = self.viewer_tool.as_ref().and_then(|t| t.snap);
364         pts[drag.handle] = snapped(world, snap);
365         if let Some(tool) = self.viewer_tool.as_mut() {
366             tool.history.commit_gesture();
367         }
368         self.set_viewer_handles(&node_id, &pts);
369         true
370     }
371 
372     /// Button release: end any in-flight grab.
373     pub(crate) fn viewer_tool_release(&mut self) -> bool {
374         match self.viewer_tool.as_mut() {
375             Some(tool) if tool.drag.is_some() => {
376                 tool.drag = None;
377                 tool.history.cancel_gesture();
378                 true
379             }
380             _ => false,
381         }
382     }
383 
384     /// Right press: delete the handle under the cursor. Consumes only on a hit
385     /// on an extensible source — otherwise the press falls through to the
386     /// viewport context menu.
387     pub(crate) fn viewer_tool_delete_at_cursor(&mut self) -> bool {
388         if !self.viewer_tool.as_ref().is_some_and(|t| t.source.extensible()) {
389             return false;
390         }
391         let Some((idx, _)) = self.viewer_handle_at_cursor() else { return false };
392         self.viewer_tool_delete_handle(idx)
393     }
394 
395     /// Delete/Backspace: remove the selected handle, if any.
396     pub(crate) fn viewer_tool_delete_selected(&mut self) -> bool {
397         if !self.viewer_tool.as_ref().is_some_and(|t| t.source.extensible()) {
398             return false;
399         }
400         let Some(idx) = self.viewer_tool.as_ref().and_then(|t| t.selected) else { return false };
401         self.viewer_tool_delete_handle(idx)
402     }
403 
404     fn viewer_tool_delete_handle(&mut self, idx: usize) -> bool {
405         let Some(tool) = &self.viewer_tool else { return false };
406         let node_id = tool.node_id.clone();
407         let Some(mut pts) = self.viewer_handles_of(&node_id) else {
408             self.viewer_tool = None;
409             return false;
410         };
411         if idx >= pts.len() {
412             return false;
413         }
414         let before = pts.clone();
415         pts.remove(idx);
416         self.set_viewer_handles(&node_id, &pts);
417         if let Some(tool) = self.viewer_tool.as_mut() {
418             tool.history.record(before);
419             tool.drag = None;
420             // Keep a neighbour selected so repeated Delete walks the handles.
421             tool.selected = if pts.is_empty() { None } else { Some(idx.min(pts.len() - 1)) };
422         }
423         true
424     }
425 
426     /// Undo: return the handles to how they were before the last recorded
427     /// gesture. Consumes only when a state is active and has history.
428     pub(crate) fn viewer_tool_undo(&mut self) -> bool {
429         self.viewer_tool_step(true)
430     }
431 
432     /// Redo: reapply the last undone gesture.
433     pub(crate) fn viewer_tool_redo(&mut self) -> bool {
434         self.viewer_tool_step(false)
435     }
436 
437     fn viewer_tool_step(&mut self, undo: bool) -> bool {
438         let Some(tool) = &self.viewer_tool else { return false };
439         let node_id = tool.node_id.clone();
440         let Some(current) = self.viewer_handles_of(&node_id) else {
441             self.viewer_tool = None;
442             return false;
443         };
444         let tool = self.viewer_tool.as_mut().expect("checked above");
445         let stepped = if undo { tool.history.undo(current) } else { tool.history.redo(current) };
446         let Some(target) = stepped else { return false };
447         // A step mid-drag abandons the drag: the grabbed index may not exist
448         // in the restored list, and the pointer no longer means anything to it.
449         tool.drag = None;
450         tool.selected = match tool.selected {
451             Some(i) if !target.is_empty() => Some(i.min(target.len() - 1)),
452             _ => None,
453         };
454         self.set_viewer_handles(&node_id, &target);
455         true
456     }
457 }