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

+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]