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 }