ui: Move Nodes tool: drag nodes in the view with a ghost and quality colours

With the form open a drag on the mesh grabs the node under the press and
moves it in the screen plane; Alt + scroll sizes the radius. The view draws
the original edges as a ghost under the moved ones, coloured by how each
element's scaled Jacobian changed, and the sphere; the form shows the region's
numbers live. Release runs mesh.node.move (one undo step), Esc cancels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-17 10:35:53 -04:00
1 parent 711e51074c
commit ee09ff2fca
4 files changed
+406 -6

No files matched your search

+1 -1
View File
@@ -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.
+64 -1
View File
@@ -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<DatumPreview>,
merge_preview: std::cell::RefCell<MergePreview>,
relax_preview: std::cell::RefCell<RelaxPreview>,
/// The open Move Nodes form's move, worked out for the view ([`node_move`]).
node_move: std::cell::RefCell<node_move::Cache>,
/// The Move Nodes drag in progress.
pub node_drag: Option<node_move::Drag>,
/// 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]
+307
View File
@@ -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<Arc<NodeMovePrepared>, String>)>,
/// The move itself, by model revision and form text.
preview: Option<((u64, String), Option<Arc<Preview>>)>,
}
/// 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<NodeMoved>,
/// Why there is no move to show.
pub note: Option<String>,
}
/// 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<Vec3> {
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<Value> {
let d = self.dialog.as_ref().filter(|d| d.command == COMMAND)?;
serde_json::from_str::<Value>(&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<Arc<NodeMovePrepared>, 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<Arc<Preview>> {
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::<Value>(&d.text).ok();
let preview = params.and_then(|params| {
let prep = self.move_prepared(&params).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(&params).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<String> {
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<u64> =
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::<f64>()).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));
}
}
+34 -4
View File
@@ -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))