forked from 1b-agent/fieldcraft-upstream
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
711e51074c
commit
ee09ff2fca
4 files changed
+406
-6
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(¶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<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));
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user