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

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-15 14:40:00 -04:00
1 parent 75f9e5ae6e
commit 94fdbf0fca
19 files changed
+1038 -9

No files matched your search

+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() {