forked from 1b-agent/fieldcraft-upstream
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:
1 parent
75f9e5ae6e
commit
94fdbf0fca
19 files changed
+1038
-9
No files matched your search
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user