graphic design tool
git clone https://git.lucas.co/cce-designer.git
src/viewer_state.rs (23.4K)
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 /// What a source may need to know beyond its own node.
48 ///
49 /// A curve's points are world positions and need nothing. A shape on an
50 /// image is written in the image's unit from the image's corner, so where it
51 /// stands in the scene depends on a node further up its chain and on what a
52 /// world unit is — neither of which `write` can look up, holding the node
53 /// mutably as it does. The framework gathers both before it calls.
54 #[derive(Clone, Copy, Debug, Default)]
55 pub struct HandleCtx {
56 /// The page the node draws on, when it draws on one.
57 pub page: Option<crate::page::PageFrame>,
58 /// One world unit in millimetres.
59 pub world_unit_mm: f32,
60 }
61
62 pub trait HandleSource {
63 /// Shown in the HUD, so it says what mode the viewport is in.
64 fn name(&self) -> &'static str;
65
66 /// Whether this source can edit a node of that type. Checked on every
67 /// resolution, not just on entry: a project reload can put anything at an
68 /// old id, and the tool must drop out rather than write nonsense.
69 fn accepts(&self, node_type: &str) -> bool;
70
71 /// The node's handles, in world space.
72 fn read(&self, node: &FsNode, ctx: &HandleCtx) -> Vec<Vec3>;
73
74 /// Write handles back into the node's parameters. The framework runs the
75 /// resync afterwards.
76 fn write(&self, node: &mut FsNode, handles: &[Vec3], ctx: &HandleCtx);
77
78 /// Handle `moved` was dragged to `to`: what the whole set is now.
79 ///
80 /// Moving the one handle is the default, and is all a source of
81 /// independent handles wants. A source whose handles hang off one
82 /// another carries them here — a shape's corner goes with its centre —
83 /// so that `write` is still handed a whole set that means one thing,
84 /// whether it came from a drag or from an undo snapshot.
85 fn drag(&self, handles: &mut Vec<Vec3>, moved: usize, to: Vec3, _ctx: &HandleCtx) {
86 handles[moved] = to;
87 }
88
89 /// The plane the handles live in, as a point on it and its normal, for
90 /// a source that has one. A drag then follows the cursor's ray to that
91 /// plane, where without one it follows it to the camera-facing plane at
92 /// the depth the handle was grabbed — which leaves a flat thing's plane
93 /// as soon as the view is not square to it.
94 fn plane(&self, _ctx: &HandleCtx) -> Option<(Vec3, Vec3)> {
95 None
96 }
97
98 /// A closed outline to draw with the handles, in world space: what is
99 /// being edited, where the handles alone do not show it.
100 fn outline(&self, _node: &FsNode, _ctx: &HandleCtx) -> Vec<Vec3> {
101 Vec::new()
102 }
103
104 /// Whether the handles are joined in order by a faint line — a curve's
105 /// control cage. False for handles that are not a sequence.
106 fn cage(&self) -> bool {
107 true
108 }
109
110 /// Whether a press on empty space appends a handle and a right press
111 /// deletes one. False for a source with a fixed set — a soft transform has
112 /// exactly a centre and a translation, and a third handle would mean
113 /// nothing.
114 fn extensible(&self) -> bool;
115
116 /// The key hints for the HUD, without the ones the framework owns
117 /// (snapping, Escape) — those are appended.
118 fn hints(&self) -> &'static str;
119
120 /// What to write beside handle `i`. Indices by default, which is right
121 /// for an ordered list; a source whose handles mean different things names
122 /// them instead, because "1" and "2" on a centre and a tip is a worse
123 /// label than none.
124 fn handle_label(&self, i: usize) -> String {
125 (i + 1).to_string()
126 }
127 }
128
129 /// An in-flight drag.
130 #[derive(Clone, Copy)]
131 pub struct Drag {
132 pub handle: usize,
133 /// NDC depth captured at grab time; motion unprojects onto this plane.
134 pub ndc_z: f32,
135 }
136
137 /// The active viewer state.
138 pub struct ViewerTool {
139 /// The edited node's id — not its slot, so renames and graph edits do not
140 /// detach the tool.
141 pub node_id: String,
142 pub source: Box<dyn HandleSource>,
143 /// The last-clicked handle — the Delete target.
144 pub selected: Option<usize>,
145 pub drag: Option<Drag>,
146 /// Handle snapshots, one per gesture.
147 pub history: History<Vec<Vec3>>,
148 /// World-space increment a dragged handle rounds to, or `None` for free
149 /// movement. Lives on the tool rather than in settings because it is a
150 /// property of the editing session, and it survives retargeting so turning
151 /// it on does not have to be repeated per node.
152 pub snap: Option<f32>,
153 }
154
155 /// The increment snapping rounds to when it is switched on.
156 ///
157 /// A tenth of a world unit: fine enough to place a point deliberately, coarse
158 /// enough that two snapped points actually coincide. The world unit is a
159 /// DECLARATION here (see the Guides node), so this is a tenth of whatever the
160 /// project says a unit is rather than a tenth of a millimetre.
161 pub const SNAP_INCREMENT: f32 = 0.1;
162
163 impl ViewerTool {
164 pub fn new(node_id: String, source: Box<dyn HandleSource>) -> Self {
165 ViewerTool { node_id, source, selected: None, drag: None, history: History::new(), snap: None }
166 }
167
168 /// The HUD line: what mode this is, what the keys do, and whether snapping
169 /// is on. The snap state is on the HUD because it silently changes what a
170 /// drag does, and a mode you cannot see is a mode you forget you are in.
171 pub fn hud(&self) -> String {
172 format!(
173 "{} — {} · Snap {} · Esc exits",
174 self.source.name(),
175 self.source.hints(),
176 if self.snap.is_some() { "on" } else { "off" },
177 )
178 }
179 }
180
181 /// Round a world position to `increment` on every axis.
182 fn snapped(p: Vec3, increment: Option<f32>) -> Vec3 {
183 match increment {
184 Some(i) if i > 0.0 => Vec3::new(
185 (p.x / i).round() * i,
186 (p.y / i).round() * i,
187 (p.z / i).round() * i,
188 ),
189 _ => p,
190 }
191 }
192
193 /// World → (screen x, screen y, ndc z) through the cached scene mvp.
194 pub fn project_point(mvp: &Mat4, view: (f32, f32, f32, f32), p: Vec3) -> Option<(f32, f32, f32)> {
195 let (vx, vy, vw, vh) = view;
196 let clip = *mvp * Vec4::new(p.x, p.y, p.z, 1.0);
197 if clip.w <= 0.0 {
198 return None;
199 }
200 let ndc = clip / clip.w;
201 Some((vx + (ndc.x * 0.5 + 0.5) * vw, vy + (0.5 - ndc.y * 0.5) * vh, ndc.z))
202 }
203
204 /// (screen x, screen y, ndc z) → world through the inverse of the cached mvp.
205 pub fn unproject_point(
206 mvp: &Mat4,
207 view: (f32, f32, f32, f32),
208 sx: f32,
209 sy: f32,
210 ndc_z: f32,
211 ) -> Option<Vec3> {
212 let (vx, vy, vw, vh) = view;
213 if vw <= 0.0 || vh <= 0.0 {
214 return None;
215 }
216 let inv = mvp.inverse();
217 if !inv.is_finite() {
218 return None;
219 }
220 let ndc_x = ((sx - vx) / vw - 0.5) * 2.0;
221 let ndc_y = (0.5 - (sy - vy) / vh) * 2.0;
222 let world = inv * Vec4::new(ndc_x, ndc_y, ndc_z, 1.0);
223 if world.w.abs() < 1e-6 {
224 return None;
225 }
226 let world = world / world.w;
227 if !world.is_finite() {
228 return None;
229 }
230 Some(Vec3::new(world.x, world.y, world.z))
231 }
232
233 pub fn find_node_by_id<'a>(root: &'a FsNode, id: &str) -> Option<&'a FsNode> {
234 if root.id == id {
235 return Some(root);
236 }
237 root.children.iter().find_map(|c| find_node_by_id(c, id))
238 }
239
240 pub fn find_node_by_id_mut<'a>(root: &'a mut FsNode, id: &str) -> Option<&'a mut FsNode> {
241 if root.id == id {
242 return Some(root);
243 }
244 root.children.iter_mut().find_map(|c| find_node_by_id_mut(c, id))
245 }
246
247 /// The viewer state a node type can enter, if any.
248 ///
249 /// One place that maps node types to tools, so the node context menu, the
250 /// command and any future entry point agree about what is editable.
251 pub fn source_for(node_type: &str) -> Option<Box<dyn HandleSource>> {
252 let sources: [Box<dyn HandleSource>; 4] = [
253 Box::new(crate::curve_tool::CurveHandles),
254 Box::new(crate::soft_transform_tool::SoftTransformHandles),
255 Box::new(crate::image_handles::ShapeHandles),
256 Box::new(crate::image_handles::TextHandles),
257 ];
258 sources.into_iter().find(|s| s.accepts(node_type))
259 }
260
261 impl State {
262 /// Enter/exit the viewer state for the node in `slot` of the current
263 /// directory. A different editable node retargets the tool.
264 pub(crate) fn toggle_viewer_state(&mut self, slot: usize) {
265 let Some(node) = self.current_dir().children.get(slot) else { return };
266 let Some(source) = source_for(&node.node_type) else { return };
267 let id = node.id.clone();
268 if self.viewer_tool.as_ref().map(|t| t.node_id == id).unwrap_or(false) {
269 self.viewer_tool = None;
270 } else {
271 self.viewer_tool = Some(ViewerTool::new(id, source));
272 }
273 }
274
275 /// Turn snapping on or off for the active state. Returns false when no
276 /// state is active, so the command falls through to mean nothing rather
277 /// than reporting success.
278 pub(crate) fn toggle_viewer_snap(&mut self) -> bool {
279 let Some(tool) = self.viewer_tool.as_mut() else { return false };
280 tool.snap = match tool.snap {
281 Some(_) => None,
282 None => Some(SNAP_INCREMENT),
283 };
284 let on = tool.snap.is_some();
285 self.update_status_text(if on { "Snapping on" } else { "Snapping off" });
286 true
287 }
288
289 /// What the source of the node's handles may need to know: gathered
290 /// here, once, ahead of every read and write.
291 fn viewer_handle_ctx(&self, node: &FsNode) -> HandleCtx {
292 let page = crate::page::is_page_node(&node.node_type)
293 .then(|| crate::page::resolve_frame(&self.fs_root, node))
294 .flatten();
295 HandleCtx { page, world_unit_mm: self.world_unit_mm() }
296 }
297
298 /// The edited node's handles, or None if the node is gone or is no longer
299 /// a type this source accepts.
300 fn viewer_handles_of(&self, node_id: &str) -> Option<Vec<Vec3>> {
301 let tool = self.viewer_tool.as_ref()?;
302 let node = find_node_by_id(&self.fs_root, node_id)?;
303 if !tool.source.accepts(&node.node_type) {
304 return None;
305 }
306 Some(tool.source.read(node, &self.viewer_handle_ctx(node)))
307 }
308
309 /// The active tool's outline, projected: screen points of a closed
310 /// loop, empty for a source that draws none.
311 pub(crate) fn viewer_tool_outline(&self) -> Vec<(f32, f32)> {
312 let Some(tool) = &self.viewer_tool else { return Vec::new() };
313 let Some(mvp) = self.last_scene_mvp else { return Vec::new() };
314 let Some(node) = find_node_by_id(&self.fs_root, &tool.node_id) else { return Vec::new() };
315 if !tool.source.accepts(&node.node_type) {
316 return Vec::new();
317 }
318 let loop_ = tool.source.outline(node, &self.viewer_handle_ctx(node));
319 let projected: Vec<(f32, f32)> = loop_
320 .iter()
321 .filter_map(|p| project_point(&mvp, self.last_scene_view_rect, *p))
322 .map(|(x, y, _)| (x, y))
323 .collect();
324 // A corner behind the camera has no place on screen, and a loop
325 // missing one is another shape.
326 if projected.len() == loop_.len() { projected } else { Vec::new() }
327 }
328
329 /// Where a drag puts the grabbed handle: on the source's plane under
330 /// the cursor when it has one, else on the camera-facing plane at the
331 /// depth the handle was grabbed.
332 fn viewer_drag_target(&self, node_id: &str, ndc_z: f32) -> Option<Vec3> {
333 let mvp = self.last_scene_mvp?;
334 let view = self.last_scene_view_rect;
335 let (cx, cy) = (self.cursor_x, self.cursor_y);
336 let plane = self.viewer_tool.as_ref().and_then(|tool| {
337 let node = find_node_by_id(&self.fs_root, node_id)?;
338 tool.source.plane(&self.viewer_handle_ctx(node))
339 });
340 if let Some((point, normal)) = plane {
341 let near = unproject_point(&mvp, view, cx, cy, 0.0)?;
342 let far = unproject_point(&mvp, view, cx, cy, 1.0)?;
343 let ray = far - near;
344 let along = ray.dot(normal);
345 // A plane seen edge-on is met nowhere the cursor can name.
346 if along.abs() > 1e-6 * ray.length().max(1e-6) {
347 let t = (point - near).dot(normal) / along;
348 return Some(near + ray * t);
349 }
350 }
351 unproject_point(&mvp, view, cx, cy, ndc_z)
352 }
353
354 /// Write handles back and run the same resync sequence as SetParam.
355 fn set_viewer_handles(&mut self, node_id: &str, handles: &[Vec3]) {
356 let ctx = match find_node_by_id(&self.fs_root, node_id) {
357 Some(node) => self.viewer_handle_ctx(node),
358 None => return,
359 };
360 let Some(tool) = self.viewer_tool.take() else { return };
361 if let Some(node) = find_node_by_id_mut(&mut self.fs_root, node_id) {
362 tool.source.write(node, handles, &ctx);
363 }
364 self.viewer_tool = Some(tool);
365 self.sync_nodes();
366 self.rebuild_scene_geometry();
367 self.sync_parameters_pane();
368 }
369
370 /// The active tool's handles as (index, screen x, screen y, ndc z).
371 /// Empty when no tool is active, the node is gone, or no scene mvp has
372 /// been cached yet (a frame before the first scene staging).
373 pub(crate) fn viewer_tool_handles(&self) -> Vec<(usize, f32, f32, f32)> {
374 let Some(tool) = &self.viewer_tool else { return Vec::new() };
375 let Some(mvp) = self.last_scene_mvp else { return Vec::new() };
376 let Some(pts) = self.viewer_handles_of(&tool.node_id) else { return Vec::new() };
377 pts.iter()
378 .enumerate()
379 .filter_map(|(i, p)| {
380 project_point(&mvp, self.last_scene_view_rect, *p).map(|(sx, sy, z)| (i, sx, sy, z))
381 })
382 .collect()
383 }
384
385 /// The handle under the cursor, nearest first.
386 fn viewer_handle_at_cursor(&self) -> Option<(usize, f32)> {
387 let (cx, cy) = (self.cursor_x, self.cursor_y);
388 self.viewer_tool_handles()
389 .iter()
390 .map(|(i, sx, sy, z)| (*i, ((sx - cx).powi(2) + (sy - cy).powi(2)).sqrt(), *z))
391 .filter(|(_, d, _)| *d <= HANDLE_HIT_RADIUS)
392 .min_by(|a, b| a.1.total_cmp(&b.1))
393 .map(|(i, _, z)| (i, z))
394 }
395
396 /// Left press in the viewport while a state is active: grab the handle
397 /// under the cursor, or — for an extensible source — append a new handle
398 /// there and start dragging it. Returns false, letting the press fall
399 /// through, only when the edited node no longer exists.
400 pub(crate) fn viewer_tool_press(&mut self) -> bool {
401 let Some(tool) = &self.viewer_tool else { return false };
402 let node_id = tool.node_id.clone();
403 let Some(mut pts) = self.viewer_handles_of(&node_id) else {
404 self.viewer_tool = None;
405 return false;
406 };
407 if let Some((idx, ndc_z)) = self.viewer_handle_at_cursor() {
408 let tool = self.viewer_tool.as_mut().expect("checked above");
409 tool.selected = Some(idx);
410 tool.history.begin_gesture(pts);
411 tool.drag = Some(Drag { handle: idx, ndc_z });
412 return true;
413 }
414 if !self.viewer_tool.as_ref().is_some_and(|t| t.source.extensible()) {
415 // A fixed source consumes the press anyway: the state owns the
416 // viewport while it is active, and falling through would open the
417 // context menu on every miss.
418 return true;
419 }
420 // Empty space: add a handle. Depth comes from the last one (or the
421 // world origin) so the new handle lands in the plane already in use.
422 let Some(mvp) = self.last_scene_mvp else { return true };
423 let view = self.last_scene_view_rect;
424 let ndc_z = pts
425 .last()
426 .and_then(|p| project_point(&mvp, view, *p))
427 .map(|(_, _, z)| z)
428 .or_else(|| project_point(&mvp, view, Vec3::ZERO).map(|(_, _, z)| z));
429 let Some(ndc_z) = ndc_z else { return true };
430 let Some(world) = unproject_point(&mvp, view, self.cursor_x, self.cursor_y, ndc_z) else {
431 return true;
432 };
433 let snap = self.viewer_tool.as_ref().and_then(|t| t.snap);
434 let before = pts.clone();
435 pts.push(snapped(world, snap));
436 let idx = pts.len() - 1;
437 self.set_viewer_handles(&node_id, &pts);
438 if let Some(tool) = self.viewer_tool.as_mut() {
439 // The add is the recorded step; the drag that follows is part of
440 // the same gesture, so no gesture is opened for it.
441 tool.history.record(before);
442 tool.selected = Some(idx);
443 tool.drag = Some(Drag { handle: idx, ndc_z });
444 }
445 true
446 }
447
448 /// Pointer motion during a grab: the handle tracks the cursor on the
449 /// camera-facing plane at its grab depth.
450 pub(crate) fn viewer_tool_drag_motion(&mut self) -> bool {
451 let Some(drag) = self.viewer_tool.as_ref().and_then(|t| t.drag) else { return false };
452 if self.last_scene_mvp.is_none() {
453 return false;
454 }
455 let node_id = self.viewer_tool.as_ref().expect("drag implies tool").node_id.clone();
456 let Some(mut pts) = self.viewer_handles_of(&node_id) else {
457 self.viewer_tool = None;
458 return false;
459 };
460 if drag.handle >= pts.len() {
461 return false;
462 }
463 let Some(world) = self.viewer_drag_target(&node_id, drag.ndc_z) else {
464 return false;
465 };
466 let snap = self.viewer_tool.as_ref().and_then(|t| t.snap);
467 let ctx = match find_node_by_id(&self.fs_root, &node_id) {
468 Some(node) => self.viewer_handle_ctx(node),
469 None => return false,
470 };
471 if let Some(tool) = self.viewer_tool.as_ref() {
472 tool.source.drag(&mut pts, drag.handle, snapped(world, snap), &ctx);
473 }
474 if let Some(tool) = self.viewer_tool.as_mut() {
475 tool.history.commit_gesture();
476 }
477 self.set_viewer_handles(&node_id, &pts);
478 true
479 }
480
481 /// Button release: end any in-flight grab.
482 pub(crate) fn viewer_tool_release(&mut self) -> bool {
483 match self.viewer_tool.as_mut() {
484 Some(tool) if tool.drag.is_some() => {
485 tool.drag = None;
486 tool.history.cancel_gesture();
487 true
488 }
489 _ => false,
490 }
491 }
492
493 /// Right press: delete the handle under the cursor. Consumes only on a hit
494 /// on an extensible source — otherwise the press falls through to the
495 /// viewport context menu.
496 pub(crate) fn viewer_tool_delete_at_cursor(&mut self) -> bool {
497 if !self.viewer_tool.as_ref().is_some_and(|t| t.source.extensible()) {
498 return false;
499 }
500 let Some((idx, _)) = self.viewer_handle_at_cursor() else { return false };
501 self.viewer_tool_delete_handle(idx)
502 }
503
504 /// Delete/Backspace: remove the selected handle, if any.
505 pub(crate) fn viewer_tool_delete_selected(&mut self) -> bool {
506 if !self.viewer_tool.as_ref().is_some_and(|t| t.source.extensible()) {
507 return false;
508 }
509 let Some(idx) = self.viewer_tool.as_ref().and_then(|t| t.selected) else { return false };
510 self.viewer_tool_delete_handle(idx)
511 }
512
513 fn viewer_tool_delete_handle(&mut self, idx: usize) -> bool {
514 let Some(tool) = &self.viewer_tool else { return false };
515 let node_id = tool.node_id.clone();
516 let Some(mut pts) = self.viewer_handles_of(&node_id) else {
517 self.viewer_tool = None;
518 return false;
519 };
520 if idx >= pts.len() {
521 return false;
522 }
523 let before = pts.clone();
524 pts.remove(idx);
525 self.set_viewer_handles(&node_id, &pts);
526 if let Some(tool) = self.viewer_tool.as_mut() {
527 tool.history.record(before);
528 tool.drag = None;
529 // Keep a neighbour selected so repeated Delete walks the handles.
530 tool.selected = if pts.is_empty() { None } else { Some(idx.min(pts.len() - 1)) };
531 }
532 true
533 }
534
535 /// Undo: return the handles to how they were before the last recorded
536 /// gesture. Consumes only when a state is active and has history.
537 pub(crate) fn viewer_tool_undo(&mut self) -> bool {
538 self.viewer_tool_step(true)
539 }
540
541 /// Redo: reapply the last undone gesture.
542 pub(crate) fn viewer_tool_redo(&mut self) -> bool {
543 self.viewer_tool_step(false)
544 }
545
546 fn viewer_tool_step(&mut self, undo: bool) -> bool {
547 let Some(tool) = &self.viewer_tool else { return false };
548 let node_id = tool.node_id.clone();
549 let Some(current) = self.viewer_handles_of(&node_id) else {
550 self.viewer_tool = None;
551 return false;
552 };
553 let tool = self.viewer_tool.as_mut().expect("checked above");
554 let stepped = if undo { tool.history.undo(current) } else { tool.history.redo(current) };
555 let Some(target) = stepped else { return false };
556 // A step mid-drag abandons the drag: the grabbed index may not exist
557 // in the restored list, and the pointer no longer means anything to it.
558 tool.drag = None;
559 tool.selected = match tool.selected {
560 Some(i) if !target.is_empty() => Some(i.min(target.len() - 1)),
561 _ => None,
562 };
563 self.set_viewer_handles(&node_id, &target);
564 true
565 }
566 }