engine, ui: mesh.node.relax and relaxation fields, Relax Nodes tool with sphere and preview (#398)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 14:40:00 -04:00
1 parent 75f9e5ae6e
commit 94fdbf0fca
19 files changed
+1038 -9

No files matched your search

+4
View File
@@ -126,6 +126,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 units = app.session.model.units();
let session = &app.session;
// The set Use selection will make on OK is offered with the others.
@@ -252,6 +253,9 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
} else if d.attempted && !missing.is_empty() {
ui.label(RichText::new(&missing).color(t.error));
}
if let Some(note) = &relax_note {
ui.label(RichText::new(note).small().color(t.text));
}
ui.collapsing(RichText::new("What it takes").size(12.0).color(t.text_dim), |ui| {
ui.label(RichText::new(spec.params).small().color(t.text_dim));
});
+144 -1
View File
@@ -333,6 +333,8 @@ type DatumPreview = (Option<(u64, &'static str, String)>, Option<fieldcraft_mode
/// The nodes the open Merge Nodes form would merge (both of each pair), and the revision and form
/// text they are for.
type MergePreview = (Option<(u64, String)>, Arc<Vec<u32>>);
/// The open Relax Nodes form's preview, by model revision and form text.
type RelaxPreview = (Option<(u64, String)>, Option<Arc<fieldcraft_engine::cmd::RelaxPreview>>);
/// A screenshot asked for over the control channel.
struct Shot {
@@ -355,6 +357,7 @@ pub struct ModelApp {
/// The datum the open form defines, and the revision, command and form text it is for.
preview: std::cell::RefCell<DatumPreview>,
merge_preview: std::cell::RefCell<MergePreview>,
relax_preview: std::cell::RefCell<RelaxPreview>,
/// Forms opened so far (numbers each [`ParamsDialog::opening`]).
forms_opened: u64,
pub viewport: Viewport,
@@ -493,6 +496,7 @@ impl ModelApp {
dialog: None,
preview: Default::default(),
merge_preview: Default::default(),
relax_preview: Default::default(),
forms_opened: 0,
viewport: Viewport::default(),
icons: icons::IconCache::default(),
@@ -1157,6 +1161,81 @@ impl ModelApp {
cache.1.clone()
}
/// What the open Relax Nodes form would do (the nodes' new positions and the region's
/// quality), worked out once per form text and model revision.
pub fn relax_preview(&self) -> Option<Arc<fieldcraft_engine::cmd::RelaxPreview>> {
let d = self.dialog.as_ref().filter(|d| d.command == "mesh.node.relax")?;
let key = (self.revision, d.text.clone());
let mut cache = self.relax_preview.borrow_mut();
if cache.0.as_ref() != Some(&key) {
let preview = serde_json::from_str::<Value>(&d.text).ok().and_then(|p| fieldcraft_engine::cmd::relax_preview(&self.session, &p));
*cache = (Some(key), preview.map(Arc::new));
}
cache.1.clone()
}
/// The open Relax Nodes form's preview in a line: nodes moved and the region's quality.
pub fn relax_note(&self) -> Option<String> {
let p = self.relax_preview()?;
let r = &p.relaxed;
Some(format!(
"Preview: {} nodes move (largest {:.3}); scaled Jacobian min {:.3} → {:.3}, edge ratio max {:.2} → {:.2}, {} → {} invalid",
r.moved,
r.max_displacement,
r.before.min_quality,
r.after.min_quality,
r.before.max_edge_ratio,
r.after.max_edge_ratio,
r.before.invalid,
r.after.invalid
))
}
/// Whether the Relax Nodes form is open.
pub fn relaxing(&self) -> bool {
self.dialog.as_ref().is_some_and(|d| d.command == "mesh.node.relax")
}
/// Put the open Relax Nodes form's sphere at `at` (a click on the mesh); a sphere without a
/// radius yet gets a twentieth of the mesh's size.
pub fn relax_place(&mut self, at: Vec3) -> bool {
let mut bounds = Aabb3::EMPTY;
for n in self.session.model.nodes().values() {
bounds.add(Vec3::from(n.xyz));
}
let size = bounds.diagonal();
let Some(d) = self.dialog.as_mut().filter(|d| d.command == "mesh.node.relax") else { return false };
let mut params = serde_json::from_str::<Value>(&d.text).ok().filter(Value::is_object).unwrap_or_else(|| json!({}));
params["center"] = json!([at.x, at.y, at.z]);
if let Some(o) = params.as_object_mut() {
o.remove("around_element");
o.remove("nodes");
}
if params.get("radius").and_then(Value::as_f64).is_none_or(|r| !(r > 0.0)) && size > 0.0 && size.is_finite() {
params["radius"] = json!(size / 20.0);
}
d.text = params.to_string();
d.touched.insert("center".into());
true
}
/// Scale the open Relax Nodes form's radius by `factor` (Alt + scroll over the view).
pub fn relax_scale_radius(&mut self, factor: f64) -> bool {
let Some(sphere) = self
.dialog
.as_ref()
.filter(|d| d.command == "mesh.node.relax")
.and_then(|d| serde_json::from_str::<Value>(&d.text).ok().and_then(|p| fieldcraft_engine::cmd::relax_sphere(&self.session, &p)))
else {
return false;
};
let Some(d) = self.dialog.as_mut() else { return false };
let mut params = serde_json::from_str::<Value>(&d.text).ok().filter(Value::is_object).unwrap_or_else(|| json!({}));
params["radius"] = json!(sphere.1 * factor);
d.text = params.to_string();
true
}
/// The datum the open datum form defines so far, drawn before OK.
/// Worked out once per form text and model revision (a face centre costs a design query).
fn datum_preview(&self) -> Option<fieldcraft_model::Datum> {
@@ -1403,7 +1482,8 @@ impl ModelApp {
let set = self.selected_set();
let cut = self.partition_preview();
let merge = self.merge_preview();
let selected = hash(&|h| (scene, self.pick.generation, &set, &cut, &*merge).hash(h));
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 key = hash(&|h| (selected, self.pick.hover_generation).hash(h));
if self.pick.overlay.0 == Some(key) {
return (key, self.pick.overlay.1.clone());
@@ -1439,6 +1519,10 @@ impl ModelApp {
let m = self.with_picker(|p| p.items_overlay(&items, theme::rgb(t.pick_hover)));
s.points.extend(m.points);
}
// The Relax Nodes sphere and its preview (#398).
if relax_key.is_some() {
self.relax_overlay(&mut s, &t);
}
self.pick.selected_overlay = (Some(selected), Arc::new(s));
}
let mut s = (*self.pick.selected_overlay.1).clone();
@@ -1454,6 +1538,36 @@ impl ModelApp {
(key, self.pick.overlay.1.clone())
}
/// The open Relax Nodes form drawn over the scene: its sphere of influence, translucent, and
/// the edges of the elements it changes at their relaxed positions.
fn relax_overlay(&self, s: &mut Scene, t: &theme::Tokens) {
let sphere = self
.dialog
.as_ref()
.and_then(|d| serde_json::from_str::<Value>(&d.text).ok())
.and_then(|p| fieldcraft_engine::cmd::relax_sphere(&self.session, &p));
if let Some((c, r)) = sphere {
scene::sphere(s, Vec3::from(c), r, theme::rgb(t.pick_hover));
}
let Some(p) = self.relax_preview() else { return };
let model = &self.session.model;
let at = |n: u32| p.relaxed.positions.get(&n).copied().or_else(|| model.node(n).map(|x| x.xyz)).map(Vec3::from);
let color = theme::rgb(t.pick_selected);
for e in model.elements().values().filter(|e| e.nodes.iter().any(|n| p.relaxed.positions.contains_key(n))) {
for edge in e.kind.edges() {
let ends = (edge.first().and_then(|&i| e.nodes.get(i)), edge.get(1).and_then(|&i| e.nodes.get(i)));
if let (Some(&a), Some(&b)) = ends
&& let (Some(a), Some(b)) = (at(a), at(b))
{
s.lines.push(fieldcraft_render::SceneLine { a, b, color, width: 1.5 });
}
}
}
for x in p.relaxed.positions.values() {
s.points.push(fieldcraft_render::ScenePoint { p: Vec3::from(*x), color, size: 4.0 });
}
}
/// The seed on `edge`, if it has one.
pub fn seed(&self, edge: &fieldcraft_model::GeometryRef) -> Option<&fieldcraft_model::Seed> {
self.session.model.seeds().iter().find(|s| s.edge == *edge)
@@ -2274,6 +2388,35 @@ mod tests {
assert!(app.pick.selection.is_empty());
}
/// Relax Nodes: a click places the sphere, Alt + scroll sizes it, the form previews the moved
/// nodes and the region's quality, and the sphere is drawn translucent (#398).
#[test]
fn relax_nodes_places_its_sphere_and_previews() {
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();
// A spike inside.
let n = *app.session.model.nodes().iter().find(|(_, p)| p.xyz == [2.0, 2.0, 2.0]).unwrap().0;
app.session.model.set_node(n, [2.7, 2.6, 2.5]).unwrap();
app.model_changed();
assert!(!app.relaxing() && app.relax_preview().is_none());
app.start("mesh.node.relax").unwrap();
assert!(app.relaxing());
assert!(app.relax_place(Vec3 { x: 2.0, y: 2.0, z: 2.0 }));
assert!(app.relax_scale_radius(10.0));
let note = app.relax_note().unwrap();
assert!(note.starts_with("Preview:"), "{note}");
let p = app.relax_preview().unwrap();
assert!(p.relaxed.positions.contains_key(&n) && p.relaxed.after.min_quality > p.relaxed.before.min_quality);
let (_, overlay) = app.overlay();
assert!(!overlay.fills.is_empty() && !overlay.lines.is_empty(), "the sphere and the preview are drawn");
// OK runs it as the form says.
let text = app.dialog.as_ref().unwrap().text.clone();
app.dialog = None;
app.run("mesh.node.relax", serde_json::from_str(&text).unwrap()).unwrap();
assert_ne!(app.session.model.node(n).unwrap().xyz, [2.7, 2.6, 2.5]);
}
/// Mesh Quality reads as a sentence in the status bar, not as its JSON report.
#[test]
fn mesh_quality_reports_in_a_line() {
+16
View File
@@ -214,6 +214,8 @@ enum Act {
Pick(fieldcraft_model::GeometryRef),
/// Select elements in the viewport and frame them (Mesh Quality's worst and invalid ones).
Elements(Vec<u32>),
/// Open Relax Nodes round an element (Mesh Quality's worst).
Relax(u32),
}
/// The reply's section of the context panel.
@@ -256,6 +258,10 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
}
Act::Pick(g) => app.select([crate::selection::Item::Geometry(g)], crate::selection::Mode::Replace),
Act::Elements(labels) => app.show_elements(&labels),
Act::Relax(element) => {
app.show_elements(&[element]);
app.open_form("mesh.node.relax", serde_json::json!({"around_element": element}));
}
}
}
}
@@ -413,6 +419,16 @@ fn quality(ui: &mut egui::Ui, t: &Tokens, v: &Value, acts: &mut Vec<Act>) {
}
}
}
if let Some(worst) = v.pointer("/metrics/scaled_jacobian/worst").and_then(Value::as_u64).and_then(|w| u32::try_from(w).ok()) {
ui.add_space(6.0);
if ui
.button("Relax around worst element")
.on_hover_text("Open Relax Nodes with its sphere round the element of smallest scaled Jacobian; preview, then OK")
.clicked()
{
acts.push(Act::Relax(worst));
}
}
if let Some(skipped) = v.get("skipped").and_then(Value::as_object).filter(|s| !s.is_empty()) {
ui.add_space(6.0);
let text: Vec<String> = skipped.iter().map(|(k, n)| format!("{k}: {}", n.as_u64().map_or_else(|| n.to_string(), count))).collect();
+37 -3
View File
@@ -8,7 +8,7 @@
//! their own over the model, a constant size on screen, muted over a results plot.
use fieldcraft_render::glyphs::{Definition, Drawn, GlyphShow, GlyphView, Glyphs};
use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Scene, SceneLabel, SceneLine, ScenePoint, SceneTri, Vec3};
use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Rgb, Scene, SceneFill, SceneLabel, SceneLine, ScenePoint, SceneTri, Vec3};
use serde::{Deserialize, Serialize};
use crate::theme::{Tokens, rgb};
@@ -99,6 +99,40 @@ pub fn datum_preview(s: &mut Scene, datum: fieldcraft_model::Datum, bounds: &Aab
fieldcraft_render::glyphs::datums(&model, bounds, &|_| true, Some(NAME), &t.glyph_colors(), s)
}
/// A sphere drawn translucent (a tool's region of influence, such as Relax Nodes'), with its
/// three great circles as lines.
pub fn sphere(s: &mut Scene, c: Vec3, r: f64, color: Rgb) {
if !(r > 0.0 && r.is_finite()) {
return;
}
const RINGS: usize = 12;
const SEGMENTS: usize = 24;
let at = |i: usize, j: usize| {
let theta = std::f64::consts::PI * i as f64 / RINGS as f64;
let phi = std::f64::consts::TAU * j as f64 / SEGMENTS as f64;
Vec3 { x: theta.sin() * phi.cos(), y: theta.sin() * phi.sin(), z: theta.cos() }
};
for i in 0..RINGS {
for j in 0..SEGMENTS {
let (a, b, d, e) = (at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1));
for tri in [[a, b, d], [a, d, e]] {
let n = (tri[0] + tri[1] + tri[2]).normalized().unwrap_or(tri[0]);
s.fills.push(SceneFill { tri: SceneTri { p: tri.map(|v| c + v * r), normal: n, color }, alpha: 0.18 });
}
}
}
for k in 0..SEGMENTS {
let (p, q) = (std::f64::consts::TAU * k as f64 / SEGMENTS as f64, std::f64::consts::TAU * (k + 1) as f64 / SEGMENTS as f64);
for (u, v) in [
(Vec3 { x: p.cos(), y: p.sin(), z: 0.0 }, Vec3 { x: q.cos(), y: q.sin(), z: 0.0 }),
(Vec3 { x: p.cos(), y: 0.0, z: p.sin() }, Vec3 { x: q.cos(), y: 0.0, z: q.sin() }),
(Vec3 { x: 0.0, y: p.cos(), z: p.sin() }, Vec3 { x: 0.0, y: q.cos(), z: q.sin() }),
] {
s.lines.push(SceneLine { a: c + u * r, b: c + v * r, color, width: 1.0 });
}
}
}
/// The scene for `display` drawn in `style`.
pub fn build(display: &DisplayMesh, style: Style, show: Show, t: &Tokens) -> Scene {
let mut s = Scene { background: Some((rgb(t.viewport_top), rgb(t.viewport_bottom))), ..Default::default() };
@@ -253,7 +287,7 @@ mod tests {
.collect();
// An element edge across the seeded edge is not along it.
d.edges.push(fieldcraft_render::DisplayEdge {
p: [Vec3::ZERO, Vec3::new(0.0, 10.0, 0.0)],
p: [Vec3::ZERO, Vec3 { x: 0.0, y: 10.0, z: 0.0 }],
nodes: [0, 99],
corners: [0, 99],
feature: true,
@@ -290,7 +324,7 @@ mod tests {
let t = Tokens::dark();
let geometry = |s: &Scene| s.lines.iter().filter(|l| l.color == rgb(t.geometry_edge)).map(|l| [l.a, l.b]).collect::<Vec<_>>();
let s = build(&d, Style::ShadedEdges, Show::DEFAULT, &t);
assert_eq!(geometry(&s), vec![[Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0)]]);
assert_eq!(geometry(&s), vec![[Vec3 { x: 0.0, y: 0.0, z: 0.0 }, Vec3 { x: 1.0, y: 0.0, z: 0.0 }]]);
// Without the mesh the design's edges are drawn.
let s = build(&d, Style::ShadedEdges, Show { mesh: false, ..Show::DEFAULT }, &t);
assert_eq!(geometry(&s).len(), 3);
+10 -3
View File
@@ -47,8 +47,12 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui) {
if response.clicked()
&& let Some(at) = response.interact_pointer_pos()
{
let _ = app.select_at(at, mode(ui.input(|i| i.modifiers)));
probe_at(app, at, rect);
// With Relax Nodes open a click on the mesh places its sphere (#398).
let placed = app.relaxing() && app.surface_point(at).is_some_and(|p| app.relax_place(p));
if !placed {
let _ = app.select_at(at, mode(ui.input(|i| i.modifiers)));
probe_at(app, at, rect);
}
}
if !app.capturing {
hover(app, &response);
@@ -383,7 +387,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);
if (factor - 1.0).abs() > 1e-9 {
// 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());
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))
});