diff --git a/crates/ui-egui/src/context.rs b/crates/ui-egui/src/context.rs index cf3827d..e7344fc 100644 --- a/crates/ui-egui/src/context.rs +++ b/crates/ui-egui/src/context.rs @@ -130,7 +130,7 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { let selected = (!app.pick.selection.is_empty()).then(|| app.pick.selection.summary()); // While a job runs (this form's mesher, a solve) OK waits for it. let running = app.busy().map(str::to_string); - let relax_note = app.relax_note(); + let relax_note = app.relax_note().or_else(|| app.node_move_note()); let units = app.session.model.units(); let session = &app.session; // The set Use selection will make on OK is offered with the others. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 47f3e3e..520c8c0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -40,6 +40,7 @@ pub mod gpu; pub mod icons; pub mod menu; pub mod navigation; +pub mod node_move; pub mod palette; pub mod privacy; pub mod reply; @@ -372,6 +373,10 @@ pub struct ModelApp { preview: std::cell::RefCell, merge_preview: std::cell::RefCell, relax_preview: std::cell::RefCell, + /// The open Move Nodes form's move, worked out for the view ([`node_move`]). + node_move: std::cell::RefCell, + /// The Move Nodes drag in progress. + pub node_drag: Option, /// Forms opened so far (numbers each [`ParamsDialog::opening`]). forms_opened: u64, pub viewport: Viewport, @@ -520,6 +525,8 @@ impl ModelApp { preview: Default::default(), merge_preview: Default::default(), relax_preview: Default::default(), + node_move: Default::default(), + node_drag: None, forms_opened: 0, viewport: Viewport::default(), icons: icons::IconCache::default(), @@ -1607,7 +1614,8 @@ impl ModelApp { let cut = self.partition_preview(); let merge = self.merge_preview(); let relax_key = self.dialog.as_ref().filter(|d| d.command == "mesh.node.relax").map(|d| d.text.clone()); - let selected = hash(&|h| (scene, self.pick.generation, &set, &cut, &*merge, &relax_key).hash(h)); + let move_key = self.dialog.as_ref().filter(|d| d.command == node_move::COMMAND).map(|d| d.text.clone()); + let selected = hash(&|h| (scene, self.pick.generation, &set, &cut, &*merge, &relax_key, &move_key).hash(h)); let key = hash(&|h| (selected, self.pick.hover_generation).hash(h)); if self.pick.overlay.0 == Some(key) { return (key, self.pick.overlay.1.clone()); @@ -1647,6 +1655,10 @@ impl ModelApp { if relax_key.is_some() { self.relax_overlay(&mut s, &t); } + // The Move Nodes preview: the ghost, the moved elements and the sphere. + if move_key.is_some() { + self.node_move_overlay(&mut s, &t); + } self.pick.selected_overlay = (Some(selected), Arc::new(s)); } let mut s = (*self.pick.selected_overlay.1).clone(); @@ -2091,6 +2103,10 @@ impl ModelApp { if ctx.memory(|m| m.focused().is_some()) { return; } + // Escape cancels a Move Nodes drag first. + if self.node_drag.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + self.node_move_cancel(); + } // Escape clears the viewport's selection first (a second one cancels an open form). if !self.pick.selection.is_empty() && self.ui.menu.is_none() @@ -2563,6 +2579,53 @@ mod tests { assert_ne!(app.session.model.node(n).unwrap().xyz, [2.7, 2.6, 2.5]); } + /// Move Nodes: the form previews the move (the region's quality before and now, the ghost and + /// the coloured elements drawn), Alt + scroll sizes the radius, a drag's release runs the + /// command in one undo step and keeps the form for the next drag, Esc cancels a drag. + #[test] + fn move_nodes_previews_and_runs_on_release() { + let mut app = ModelApp::new(Session::default()); + let c = [[0., 0., 0.], [4., 0., 0.], [4., 4., 0.], [0., 4., 0.], [0., 0., 4.], [4., 0., 4.], [4., 4., 4.], [0., 4., 4.]]; + app.run("mesh.block", json!({"body": "A", "kind": "hex8", "corners": c, "divisions": [4, 4, 4]})).unwrap(); + let n = *app.session.model.nodes().iter().find(|(_, p)| p.xyz == [2.0, 2.0, 2.0]).unwrap().0; + assert!(!app.moving_nodes() && app.node_move_preview().is_none()); + app.start(node_move::COMMAND).unwrap(); + assert!(app.moving_nodes()); + if let Some(d) = app.dialog.as_mut() { + d.text = json!({"nodes": [n], "by": [0.4, 0.2, 0.0], "on_geometry": true, "guard": "no_inversion"}).to_string(); + } + assert!(app.node_move_scale_radius(1.0), "a radius to start from"); + assert!(app.node_move_scale_radius(5.0), "Alt + scroll grows it"); + let note = app.node_move_note().unwrap(); + assert!(note.starts_with("Preview:") && note.contains("worse"), "{note}"); + let p = app.node_move_preview().unwrap(); + let m = p.moved.as_ref().unwrap(); + assert!(m.positions.contains_key(&n) && m.moved > 1 && p.sphere.is_some()); + let (_, overlay) = app.overlay(); + assert!( + !overlay.fills.is_empty() && !overlay.lines.is_empty() && !overlay.on_top.is_empty(), + "sphere, ghost and moved edges, the grabbed node" + ); + // Esc during a drag: nothing moves, the form stays. + let start = app.session.model.clone(); + app.node_drag = Some(node_move::Drag { start: Vec3::new(2.0, 2.0, 2.0), normal: Vec3::Z }); + app.node_move_cancel(); + assert!(app.node_drag.is_none() && app.moving_nodes() && app.session.model == start); + assert!(app.dialog.as_ref().unwrap().text.contains("\"by\":[0.0,0.0,0.0]")); + // A drag's release runs the move. + if let Some(d) = app.dialog.as_mut() { + let mut p: Value = serde_json::from_str(&d.text).unwrap(); + p["by"] = json!([0.4, 0.2, 0.0]); + d.text = p.to_string(); + } + app.node_drag = Some(node_move::Drag { start: Vec3::new(2.0, 2.0, 2.0), normal: Vec3::Z }); + app.node_move_release().unwrap(); + assert_eq!(app.session.model.node(n).unwrap().xyz, [2.4, 2.2, 2.0]); + assert!(app.moving_nodes() && app.node_drag.is_none(), "the form stays open for the next drag"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.model, start); + } + /// Relax Nodes by face (#471): Use selection puts the picked design faces in the form in /// place of a sphere, clicks then pick faces, and OK relaxes them. #[test] diff --git a/crates/ui-egui/src/node_move.rs b/crates/ui-egui/src/node_move.rs new file mode 100644 index 0000000..0065643 --- /dev/null +++ b/crates/ui-egui/src/node_move.rs @@ -0,0 +1,307 @@ +//! The Move Nodes tool (`mesh.node.move`): with its form open, a drag on the mesh grabs the node +//! under the pointer (or the nodes the form lists, when the press is on one of them) and moves it +//! in the screen plane; the nodes round it follow as the form says (radius, Alt + scroll over +//! the view; falloff and strength, or harmonic). While dragging, the view draws the original +//! element edges as a ghost under the moved ones, those coloured by how each element's scaled +//! Jacobian changed (the error colour worse, the ok colour better), and the sphere of influence; +//! the form shows the region's numbers before and now. Release runs the command (one undo +//! step) and keeps the form open for the next drag; Esc during a drag cancels it. +//! +//! The tool only previews: what is drawn comes from [`fieldcraft_engine::cmd::NodeMovePrepared`], +//! the command's own move worked out without doing it. The part that does not depend on the +//! displacement (who follows, the design's faces) is worked out once per form and model +//! revision, so a drag frame only moves the region's nodes and measures its elements; a region +//! of more than [`PREVIEW_NODES`] nodes is not previewed (the move applies on release). + +use std::sync::Arc; + +use fieldcraft_engine::cmd::{NodeMovePrepared, NodeMoved}; +use fieldcraft_render::{Scene, SceneLine, Vec3}; +use serde_json::{Value, json}; + +use crate::theme::{self, Tokens}; +use crate::{ModelApp, scene}; + +/// The command the tool runs. +pub const COMMAND: &str = "mesh.node.move"; + +/// Most nodes a live preview moves: a larger region is moved on release only. +pub const PREVIEW_NODES: usize = 20_000; + +/// A drag of the Move Nodes tool in progress. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Drag { + /// The point under the press, on the plane the drag moves in. + pub start: Vec3, + /// That plane's normal: the view direction when the drag started. + pub normal: Vec3, +} + +/// What the open form's move would do, and what it was worked out for. +#[derive(Default)] +pub struct Cache { + /// The move up to its displacement, by model revision and form text without `by` and `to`. + prepared: Option<((u64, String), Result, String>)>, + /// The move itself, by model revision and form text. + preview: Option<((u64, String), Option>)>, +} + +/// The open form's move, without doing it. +pub struct Preview { + pub sphere: Option<([f64; 3], f64)>, + /// Nodes that take a share of the move. + pub nodes: usize, + /// The move; `None` when the region is too large to preview or the form is not complete. + pub moved: Option, + /// Why there is no move to show. + pub note: Option, +} + +/// Where the ray `origin + t·dir` meets the plane through `point` with normal `normal`; `None` +/// when it runs along the plane. +pub fn on_plane(origin: Vec3, dir: Vec3, point: Vec3, normal: Vec3) -> Option { + let denom = dir.dot(normal); + if denom.abs() < 1e-12 { + return None; + } + let t = (point - origin).dot(normal) / denom; + let p = origin + dir * t; + p.is_finite().then_some(p) +} + +/// The form text without the displacement: the key of the prepared move. +fn without_displacement(params: &Value) -> String { + let mut p = params.clone(); + if let Some(o) = p.as_object_mut() { + o.remove("by"); + o.remove("to"); + } + p.to_string() +} + +impl ModelApp { + /// Whether the Move Nodes form is open. + pub fn moving_nodes(&self) -> bool { + self.dialog.as_ref().is_some_and(|d| d.command == COMMAND) + } + + fn move_params(&self) -> Option { + let d = self.dialog.as_ref().filter(|d| d.command == COMMAND)?; + serde_json::from_str::(&d.text).ok().filter(Value::is_object) + } + + fn set_move_params(&mut self, params: Value) { + if let Some(d) = self.dialog.as_mut().filter(|d| d.command == COMMAND) { + d.text = params.to_string(); + d.error = None; + d.refused = None; + } + } + + /// The open form's move up to its displacement (cached by form and revision). + fn move_prepared(&self, params: &Value) -> Result, String> { + let key = (self.revision, without_displacement(params)); + let mut cache = self.node_move.borrow_mut(); + if let Some((k, p)) = &cache.prepared + && *k == key + { + return p.clone(); + } + let p = fieldcraft_engine::cmd::node_move_prepare(&self.session, params).map(Arc::new).map_err(|e| e.to_string()); + cache.prepared = Some((key, p.clone())); + p + } + + /// What the open Move Nodes form would do, worked out once per form text and model revision. + pub fn node_move_preview(&self) -> Option> { + let d = self.dialog.as_ref().filter(|d| d.command == COMMAND)?; + let key = (self.revision, d.text.clone()); + if let Some((k, p)) = &self.node_move.borrow().preview + && *k == key + { + return p.clone(); + } + let params = serde_json::from_str::(&d.text).ok(); + let preview = params.and_then(|params| { + let prep = self.move_prepared(¶ms).ok()?; + let mut out = Preview { sphere: prep.sphere, nodes: prep.nodes(), moved: None, note: None }; + if prep.nodes() > PREVIEW_NODES { + out.note = Some(format!("{} nodes follow: too many for a live preview; the move applies on release", prep.nodes())); + } else { + match prep.displacement(¶ms).and_then(|d| prep.preview(&self.session, d)) { + Ok(m) => out.moved = Some(m), + Err(e) => out.note = Some(e.to_string()), + } + } + Some(Arc::new(out)) + }); + self.node_move.borrow_mut().preview = Some((key, preview.clone())); + preview + } + + /// The open Move Nodes form's preview in a line: the region's quality before and now. + pub fn node_move_note(&self) -> Option { + let p = self.node_move_preview()?; + let Some(m) = &p.moved else { return p.note.clone() }; + let mut line = format!( + "Preview: {} nodes move (largest {:.3}); scaled Jacobian min {:.3} to {:.3}, mean {:.3} to {:.3}; edge ratio max {:.2} to {:.2}; {} worse, {} better", + m.moved, + m.max_displacement, + m.before.min_quality, + m.after.min_quality, + m.mean_before, + m.mean_after, + m.before.max_edge_ratio, + m.after.max_edge_ratio, + m.worse, + m.better + ); + if m.scale < 1.0 { + line.push_str(&format!("; scaled to {:.0}% so that no element inverts", m.scale * 100.0)); + } + Some(line) + } + + /// Start a drag at window point `at` on the mesh: grab the listed nodes when the node under + /// the press is one of them, else that node. Whether a drag started. + pub fn node_move_grab(&mut self, at: egui::Pos2) -> bool { + let Some(mut params) = self.move_params() else { return false }; + let (Some(rect), Some(hit)) = (self.viewport.rect, self.surface_point(at)) else { return false }; + let Some(node) = fieldcraft_engine::cmd::node_move_nearest(&self.session, [hit.x, hit.y, hit.z]) else { return false }; + let listed: Vec = + params.get("nodes").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + if !listed.contains(&u64::from(node)) { + params["nodes"] = json!([node]); + } + if let Some(o) = params.as_object_mut() { + for key in ["center", "faces", "to"] { + o.remove(key); + } + } + params["by"] = json!([0.0, 0.0, 0.0]); + let (o, d) = self.cam.ray(f64::from(at.x - rect.left()), f64::from(at.y - rect.top()), f64::from(rect.width()), f64::from(rect.height())); + let normal = self.cam.back(); + let Some(start) = on_plane(o, d, hit, normal) else { return false }; + self.set_move_params(params); + self.node_drag = Some(Drag { start, normal }); + true + } + + /// The pointer of the drag is at window point `at`: the move follows it in the screen plane. + pub fn node_move_drag(&mut self, at: egui::Pos2) { + let (Some(drag), Some(rect), Some(mut params)) = (self.node_drag, self.viewport.rect, self.move_params()) else { return }; + let (o, d) = self.cam.ray(f64::from(at.x - rect.left()), f64::from(at.y - rect.top()), f64::from(rect.width()), f64::from(rect.height())); + let Some(p) = on_plane(o, d, drag.start, drag.normal) else { return }; + let by = p - drag.start; + params["by"] = json!([by.x, by.y, by.z]); + self.set_move_params(params); + } + + /// The drag ends: run the move (one undo step) and keep the form for the next one. + pub fn node_move_release(&mut self) -> Result<(), String> { + if self.node_drag.take().is_none() { + return Ok(()); + } + let Some(mut params) = self.move_params() else { return Ok(()) }; + let by = params.get("by").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).map(|x| x * x).sum::()).unwrap_or(0.0); + if by > 0.0 { + self.run(COMMAND, params.clone())?; + } + params["by"] = json!([0.0, 0.0, 0.0]); + self.set_move_params(params); + Ok(()) + } + + /// Esc during a drag: nothing moves. + pub fn node_move_cancel(&mut self) { + if self.node_drag.take().is_some() + && let Some(mut params) = self.move_params() + { + params["by"] = json!([0.0, 0.0, 0.0]); + self.set_move_params(params); + } + } + + /// Scale the open Move Nodes form's radius by `factor` (Alt + scroll over the view); a form + /// without one starts at a twentieth of the mesh's size. + pub fn node_move_scale_radius(&mut self, factor: f64) -> bool { + let Some(mut params) = self.move_params() else { return false }; + let radius = match params.get("radius").and_then(Value::as_f64).filter(|r| *r > 0.0 && r.is_finite()) { + Some(r) => r * factor, + None => { + let mut bounds = fieldcraft_render::Aabb3::EMPTY; + for n in self.session.model.nodes().values() { + bounds.add(Vec3::from(n.xyz)); + } + bounds.diagonal() / 20.0 + } + }; + if !(radius > 0.0 && radius.is_finite()) { + return false; + } + params["radius"] = json!(radius); + self.set_move_params(params); + true + } + + /// The open Move Nodes form drawn over the scene: the sphere of influence, the original edges + /// of the elements it changes as a ghost, and their moved edges coloured by how their scaled + /// Jacobian changed. + pub(crate) fn node_move_overlay(&self, s: &mut Scene, t: &Tokens) { + let Some(p) = self.node_move_preview() else { return }; + if let Some((c, r)) = p.sphere { + scene::sphere(s, Vec3::from(c), r, theme::rgb(t.pick_hover)); + } + let Some(m) = &p.moved else { return }; + let model = &self.session.model; + let ghost = theme::rgb(t.text_dim); + let (worse, better, grabbed) = (theme::rgb(t.error), theme::rgb(t.ok), theme::rgb(t.pick_selected)); + let moved = |n: u32| m.positions.get(&n).copied().or_else(|| model.node(n).map(|x| x.xyz)).map(Vec3::from); + let original = |n: u32| model.node(n).map(|x| Vec3::from(x.xyz)); + for c in m.elements.iter().filter(|c| c.worse() || c.better()) { + let Some(e) = model.element(c.label) else { continue }; + let color = if c.worse() { worse } else { better }; + for edge in e.kind.edges() { + let (Some(&a), Some(&b)) = (edge.first().and_then(|&i| e.nodes.get(i)), edge.get(1).and_then(|&i| e.nodes.get(i))) else { continue }; + if let (Some(pa), Some(pb)) = (original(a), original(b)) { + s.lines.push(SceneLine { a: pa, b: pb, color: ghost, width: 1.5 }); + } + if let (Some(pa), Some(pb)) = (moved(a), moved(b)) { + s.lines.push(SceneLine { a: pa, b: pb, color, width: 3.0 }); + } + } + } + // The grabbed nodes where they go, over everything. + if let Some(params) = self.move_params() { + for n in params.get("nodes").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64) { + if let Some(x) = u32::try_from(n).ok().and_then(moved) { + s.on_top.push(SceneLine { a: x, b: x, color: grabbed, width: 7.0 }); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A ray meets the drag plane where it should; one along the plane does not. + #[test] + fn the_drag_plane_meets_the_pointer_ray() { + let p = on_plane(Vec3::new(1.0, 2.0, 10.0), Vec3::new(0.0, 0.0, -1.0), Vec3::new(0.0, 0.0, 3.0), Vec3::Z).unwrap(); + assert!(p.dist(Vec3::new(1.0, 2.0, 3.0)) < 1e-12); + let slanted = on_plane(Vec3::new(0.0, 0.0, 1.0), Vec3::new(1.0, 0.0, -1.0), Vec3::new(5.0, 5.0, 0.0), Vec3::Z).unwrap(); + assert!(slanted.dist(Vec3::new(1.0, 0.0, 0.0)) < 1e-12); + assert!(on_plane(Vec3::new(0.0, 0.0, 1.0), Vec3::X, Vec3::new(0.0, 0.0, 0.0), Vec3::Z).is_none()); + } + + #[test] + fn the_prepared_key_leaves_out_the_displacement() { + let a = json!({"nodes": [3], "by": [1.0, 0.0, 0.0], "radius": 2.0}); + let b = json!({"nodes": [3], "by": [0.0, 5.0, 0.0], "radius": 2.0}); + let c = json!({"nodes": [3], "to": [0.0, 5.0, 0.0], "radius": 3.0}); + assert_eq!(without_displacement(&a), without_displacement(&b)); + assert_ne!(without_displacement(&a), without_displacement(&c)); + } +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 4c6332e..d37ed7a 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -40,6 +40,7 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui) { app.apply_reframe(); let response = ui.allocate_rect(rect, Sense::click_and_drag()); let boxing = app.pick.box_mode && app.pick.filter.boxes(); + node_drag(app, ui, &response, boxing); navigate(app, ui, &response, rect, boxing); if boxing && app.viewport.drag.is_none() { box_drag(app, ui, &response); @@ -172,6 +173,33 @@ fn box_drag(app: &mut ModelApp, ui: &egui::Ui, response: &egui::Response) { } } +/// The Move Nodes tool: with its form open, a plain left drag that starts on the mesh moves the +/// node under the press in the screen plane ([`crate::node_move`]); release runs the move. +fn node_drag(app: &mut ModelApp, ui: &egui::Ui, response: &egui::Response, boxing: bool) { + if response.drag_started_by(egui::PointerButton::Primary) + && app.moving_nodes() + && !boxing + && mode(ui.input(|i| i.modifiers)) == Mode::Replace + && let Some(at) = ui.input(|i| i.pointer.press_origin()).or(response.interact_pointer_pos()) + { + app.node_move_grab(at); + } + if app.node_drag.is_none() { + return; + } + // The release is handled on the next frame even when no more input comes. + ui.ctx().request_repaint(); + if response.dragged_by(egui::PointerButton::Primary) + && let Some(at) = response.interact_pointer_pos() + { + app.node_move_drag(at); + } + if response.drag_stopped_by(egui::PointerButton::Primary) || !response.dragged() { + // A failure shows in the status bar. + let _ = app.node_move_release(); + } +} + /// What a click would pick under the pointer, worked out again when the pointer, the camera, the /// scene or the filter change. fn hover(app: &mut ModelApp, response: &egui::Response) { @@ -351,7 +379,8 @@ fn navigate(app: &mut ModelApp, ui: &egui::Ui, response: &egui::Response, rect: (egui::PointerButton::Middle, NavButton::Middle), (egui::PointerButton::Secondary, NavButton::Right), ] { - if response.drag_started_by(pressed) { + // A Move Nodes drag is no navigation. + if response.drag_started_by(pressed) && app.node_drag.is_none() { let at = press.or(response.interact_pointer_pos()); let preset = navigation::preset(&app.ui.nav_preset); // The pick is needed only for an orbit's pivot and an unbound Ctrl + left drag. @@ -389,9 +418,10 @@ fn navigate(app: &mut ModelApp, ui: &egui::Ui, response: &egui::Response, rect: let options = ui.ctx().options(|o| o.input_options); let (scroll, pinch, at) = ui.input(|i| (wheel_points(i, &options), i.zoom_delta(), i.pointer.hover_pos())); let factor = (-f64::from(scroll) * ZOOM_PER_POINT * zoom_sign(app)).exp() / f64::from(pinch).max(1e-3); - // Alt + scroll sizes the Relax Nodes sphere instead of zooming. - let sized = - app.relaxing() && ui.input(|i| i.modifiers.alt) && scroll != 0.0 && app.relax_scale_radius((f64::from(scroll) * ZOOM_PER_POINT).exp()); + // Alt + scroll sizes the Relax Nodes sphere, or the Move Nodes radius, instead of zooming. + let alt = ui.input(|i| i.modifiers.alt) && scroll != 0.0; + let step = (f64::from(scroll) * ZOOM_PER_POINT).exp(); + let sized = alt && ((app.relaxing() && app.relax_scale_radius(step)) || (app.moving_nodes() && app.node_move_scale_radius(step))); if !sized && (factor - 1.0).abs() > 1e-9 { let anchor = at.and_then(|p| { app.surface_point(p).or_else(|| app.cam.target_plane_point(f64::from(p.x - rect.left()), f64::from(p.y - rect.top()), w, h))