results: clamp the contour legend's range; values above it in an over colour (#191)

results.contour takes range {min?, max?} (or "auto"), kept until the field changes: a
singular peak no longer squeezes the field into the bottom bands. Values above a clamped
maximum go to an extra band drawn in the over colour, split exactly at the clamp; the legend
shows that band up to the true maximum and says it is clamped. The results panel has Legend
max/min (Auto or a value), the MCP show_results tool a range argument, and headless pictures
follow the clamp too.

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 11:07:46 -04:00
1 parent 441f03d093
commit 5112855b83
10 files changed
+227 -26

No files matched your search

+9
View File
@@ -1670,6 +1670,15 @@ pub const CONTOUR: &[Field] = &[
.req()
.def("\"von_mises\""),
int("bands", "Bands", 2, 24).help("Number of colour bands.").def("12"),
group(
"range",
"Legend range",
&[
num("min", "Min", D::None).help("Values below it take the lowest colour.").hint("the field's minimum"),
num("max", "Max", D::None).help("Values above it are drawn in the over colour.").hint("the field's maximum"),
],
)
.help("Clamp the legend, e.g. below a singular peak; empty follows the field."),
];
pub const MODES: &[Field] = &[SOLVED, int("mode", "Mode", 1, 1_000_000).help("The mode shape shown.").def("1")];
pub const ANIMATE: &[Field] = &[flag("on", "Animate").help("Play the deformation or the frames.").def("true")];
+66 -1
View File
@@ -23,7 +23,7 @@ pub const DEFORMED: CommandSpec = CommandSpec {
pub const CONTOUR: CommandSpec = CommandSpec {
id: "results.contour",
label: "Contours",
params: r#"{"step": "Load", "field": "von_mises", "bands": 12} — contours of a nodal field: displacement, displacement_x/y/z, von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed or stick, 2 slip), contact_shear, contact_slip (friction; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands"#,
params: r#"{"step": "Load", "field": "von_mises", "bands": 12, "range"?: {"min"?: 0, "max"?: 80} | "auto"} — contours of a nodal field: displacement, displacement_x/y/z, von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed or stick, 2 slip), contact_shear, contact_slip (friction; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands; `range` clamps the legend (a bound left out, null or "auto" follows the field; values above `max` are drawn in the over colour), kept until the field changes"#,
form: Some(super::forms::CONTOUR),
kind: CommandKind::Session,
requires: &[Requirement::Result],
@@ -196,14 +196,52 @@ fn contour(s: &mut Session, p: &Value) -> Result<Value> {
{
return Err(EngineError::Params(format!("{} is not available for step `{step}` (mode shapes have no stress scale)", f.label())));
}
let range = match p.get("range") {
None => None,
Some(r) => Some(legend_range(r)?),
};
let v = view_of(s, &step)?;
if v.contour != field {
(v.range_min, v.range_max) = (None, None);
}
v.contour = field;
if let Some(b) = bands {
v.bands = b;
}
if let Some((lo, hi)) = range {
(v.range_min, v.range_max) = (lo, hi);
}
Ok(s.results_report())
}
/// `results.contour`'s `range`: `"auto"` or null (follow the field), or `{min?, max?}`, each a
/// finite number, null or `"auto"`; `min` below `max`.
fn legend_range(r: &Value) -> Result<(Option<f64>, Option<f64>)> {
let bound = |k: &str| -> Result<Option<f64>> {
match r.get(k) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(a)) if a == "auto" => Ok(None),
Some(v) => {
v.as_f64().filter(|x| x.is_finite()).map(Some).ok_or_else(|| EngineError::Params(format!("`range.{k}` must be a number or \"auto\"")))
}
}
};
match r {
Value::Null => Ok((None, None)),
Value::String(a) if a == "auto" => Ok((None, None)),
Value::Object(_) => {
let (lo, hi) = (bound("min")?, bound("max")?);
if let (Some(a), Some(b)) = (lo, hi)
&& a >= b
{
return Err(EngineError::Params(format!("`range.min` ({a}) must be below `range.max` ({b})")));
}
Ok((lo, hi))
}
_ => Err(EngineError::Params("`range` must be {\"min\"?, \"max\"?} or \"auto\"".into())),
}
}
fn modes(s: &mut Session, p: &Value) -> Result<Value> {
let step = step_for(s, p, Want::Frequency)?;
let count = match s.latest_result(&step).map(|(r, _)| r.kind()) {
@@ -344,6 +382,29 @@ mod tests {
s
}
/// `range` clamps the legend (#191): kept with the field, cleared by "auto" or another
/// field, reported in the reply; the true range is still there.
#[test]
fn the_legend_range_can_be_clamped() {
let mut s = solved();
let r = s.execute("results.contour", &json!({"field": "von_mises", "range": {"max": 10}})).unwrap();
let top = r["contour"]["range"]["max"]["value"].as_f64().unwrap();
assert!(top > 10.0, "{r}");
assert_eq!(r["contour"]["legend"], json!({"min": null, "max": 10.0}), "{r}");
let v = s.results_view.clone().unwrap();
let b = v.bands(0.0, top);
assert!(b.over && b.max == 10.0 && b.band(top) == Some(b.count));
s.execute("results.contour", &json!({"field": "von_mises", "bands": 6})).unwrap();
assert_eq!(s.results_view.as_ref().unwrap().range_max, Some(10.0), "kept with the field");
s.execute("results.contour", &json!({"field": "von_mises", "range": {"max": "auto", "min": 2}})).unwrap();
assert_eq!(s.results_view.as_ref().map(|v| (v.range_min, v.range_max)), Some((Some(2.0), None)));
s.execute("results.contour", &json!({"field": "displacement"})).unwrap();
assert_eq!(s.results_view.as_ref().map(|v| (v.range_min, v.range_max)), Some((None, None)), "another field follows its own range");
s.execute("results.contour", &json!({"field": "displacement", "range": {"max": 1e-3}})).unwrap();
let r = s.execute("results.contour", &json!({"field": "displacement", "range": "auto"})).unwrap();
assert!(r["contour"].get("legend").is_none(), "{r}");
}
#[test]
fn the_display_follows_the_commands_and_is_not_an_edit() {
let mut s = solved();
@@ -450,6 +511,10 @@ mod tests {
json!({"field": 3, "bands": 1e300}),
json!({"field": "von_mises", "bands": 0}),
json!({"field": "von_mises", "bands": 25}),
json!({"field": "von_mises", "range": {"max": "high"}}),
json!({"field": "von_mises", "range": {"min": 5, "max": 5}}),
json!({"field": "von_mises", "range": {"min": 1e308, "max": -1e308}}),
json!({"field": "von_mises", "range": 7}),
json!({"scale": -1}),
json!({"scale": -0.0001}),
json!({"scale": 1e300}),
+16
View File
@@ -372,6 +372,12 @@ pub struct ResultsView {
/// Contours of this field; `None` draws the deformed shape in element colours.
pub contour: Option<Field>,
pub bands: u32,
/// The legend's range, clamped below the field's smallest or above its largest value (each
/// `None` follows the field): a singular peak no longer squeezes the field into one band.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub range_min: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub range_max: Option<f64>,
/// The mode shown, from 1 (frequency steps).
pub mode: usize,
/// The field frame shown (explicit steps); `None` is the last.
@@ -404,6 +410,8 @@ impl ResultsView {
scale: true_scale.then_some(1.0),
contour: Some(contour),
bands: DEFAULT_BANDS,
range_min: None,
range_max: None,
mode: 1,
frame: None,
animate: false,
@@ -413,6 +421,11 @@ impl ResultsView {
pub fn pick(&self) -> Pick {
Pick { mode: self.mode, frame: self.frame }
}
/// The contour bands for a field ranging from `min` to `max`, clamped to the view's range.
pub fn bands(&self, min: f64, max: f64) -> fieldcraft_render::contour::Bands {
fieldcraft_render::contour::Bands::clamped(min, max, self.range_min, self.range_max, self.bands)
}
}
/// The automatic deformation scale draws the largest displacement this fraction of the model's
@@ -650,6 +663,9 @@ impl Session {
let ends =
range(r.nodes(), &values).map(|((ln, lv), (hn, hv))| json!({"min": {"node": ln, "value": lv}, "max": {"node": hn, "value": hv}}));
out["contour"] = json!({"field": f, "label": f.label(), "unit": f.unit(r, self.model.units()), "range": ends});
if v.range_min.is_some() || v.range_max.is_some() {
out["contour"]["legend"] = json!({"min": v.range_min, "max": v.range_max});
}
}
match r.kind() {
Kind::Frequency(fr) => {
+9 -3
View File
@@ -12,7 +12,7 @@
//! once the camera is known.
use fieldcraft_model::ElementKind;
use fieldcraft_render::contour::{Bands, band_color, split_triangle};
use fieldcraft_render::contour::{bands_color, split_triangle};
use fieldcraft_render::glyphs::{self, GlyphColors, GlyphShow, GlyphView, Glyphs};
use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Orientation, Rgb, Scene, SceneLine, SceneTri, Vec3};
use serde_json::Value;
@@ -39,10 +39,15 @@ pub struct Palette {
pub no_result: Rgb,
/// The contour colour scale, low to high.
pub contour: [Rgb; 5],
/// Values above a clamped legend's maximum.
pub contour_over: Rgb,
/// The analysis setup's symbols.
pub glyphs: GlyphColors,
}
/// Values above a clamped legend's maximum (both themes): a deep purple apart from the scale's red.
pub const CONTOUR_OVER: Rgb = Rgb(0x6e, 0x2c, 0x80);
impl Palette {
/// The desktop app's light theme.
pub const LIGHT: Palette = Palette {
@@ -67,6 +72,7 @@ impl Palette {
],
no_result: Rgb(0x9a, 0xa0, 0xaa),
contour: [Rgb(0x2b, 0x4f, 0xd8), Rgb(0x2f, 0xb8, 0xe0), Rgb(0x3c, 0xc8, 0x64), Rgb(0xf0, 0xd2, 0x3c), Rgb(0xe0, 0x40, 0x2e)],
contour_over: CONTOUR_OVER,
glyphs: GlyphColors {
constraint: Rgb(0x00, 0x8a, 0xa8),
load: Rgb(0xd0, 0x24, 0x24),
@@ -210,12 +216,12 @@ fn picture(s: &Session, p: &Palette) -> Picture {
});
if let Some((field, min, max, values)) = contour {
shown.contour = Some((field.id().to_string(), min, max));
let bands = Bands::new(min.1, max.1, view.bands);
let bands = view.bands(min.1, max.1);
let at = |label: u32| nodes.binary_search(&label).ok().and_then(|i| values.get(i).copied()).unwrap_or(f64::NAN);
scene.tris.clear();
for tri in &drawn.tris {
split_triangle(tri.p, tri.nodes.map(at), &bands, &mut |piece, band| {
let color = band.map_or(p.no_result, |k| band_color(&p.contour, k, bands.count));
let color = band.map_or(p.no_result, |k| bands_color(&p.contour, p.contour_over, &bands, k));
for k in 1..piece.len().saturating_sub(1) {
if let (Some(&a), Some(&b), Some(&c)) = (piece.first(), piece.get(k), piece.get(k + 1)) {
scene.tris.push(SceneTri { p: [a, b, c], normal: tri.normal, color });
+5 -4
View File
@@ -229,7 +229,7 @@ pub fn tool_definitions() -> Vec<Value> {
"Show results",
"Choose what the results display shows and read it back: the step, the deformed shape (`deformed`, `scale`: \
a factor, 0 for the undeformed shape, or \"auto\"), contours of a nodal field (`field`: displacement, displacement_x/y/z, von_mises, \
max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz, or none; `bands` 2–24), a frequency step's mode or an \
max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz, or none; `bands` 2–24; `range` {min?, max?} or \"auto\" clamps the legend, values above max in the over colour), a frequency step's mode or an \
explicit step's frame. The reply has the contour's range: its smallest and largest value and their nodes \
(field displacement gives the largest displacement). Only `step`: show that step.",
obj(
@@ -237,6 +237,7 @@ pub fn tool_definitions() -> Vec<Value> {
"step": string("Solved step (default: the one shown)"),
"field": string("Contour field, or \"none\""),
"bands": integer("Contour bands, 2 to 24"),
"range": {"type": ["object", "string"], "description": "Legend range {\"min\"?, \"max\"?} (each a number or \"auto\"), or \"auto\"; goes with `field`"},
"deformed": {"type": "boolean", "description": "Draw the deformed shape"},
"scale": {"type": ["number", "string"], "description": "Deformation scale factor (0: undeformed) or \"auto\""},
"mode": integer("Mode of a frequency step, from 1"),
@@ -539,9 +540,9 @@ fn show_results(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<Value, St
last = Some(b.execute("results.deformed", with_step(p))?);
}
if a.get("field").is_some_and(|v| !v.is_null()) {
last = Some(b.execute("results.contour", with_step(pick(a, &["field", "bands"])))?);
} else if a.get("bands").is_some_and(|v| !v.is_null()) {
return Err("show_results: `bands` goes with `field`".into());
last = Some(b.execute("results.contour", with_step(pick(a, &["field", "bands", "range"])))?);
} else if a.get("bands").is_some_and(|v| !v.is_null()) || a.get("range").is_some_and(|v| !v.is_null()) {
return Err("show_results: `bands` and `range` go with `field`".into());
}
match last {
Some(v) => Ok(v),
+58 -5
View File
@@ -13,6 +13,9 @@ pub struct Bands {
pub min: f64,
pub max: f64,
pub count: u32,
/// Values above `max` go to an extra band, [`Bands::count`] (drawn in the "over" colour), not
/// to the top band: the range was clamped below the field's maximum.
pub over: bool,
}
impl Bands {
@@ -20,9 +23,25 @@ impl Bands {
pub fn new(min: f64, max: f64, count: u32) -> Bands {
let count = count.max(1);
if !(min.is_finite() && max.is_finite()) {
return Bands { min: 0.0, max: 0.0, count };
return Bands { min: 0.0, max: 0.0, count, over: false };
}
Bands { min: min.min(max), max: max.max(min), count }
Bands { min: min.min(max), max: max.max(min), count, over: false }
}
/// The bands of a field ranging over `[min, max]` drawn over `[lo, hi]` where given (a
/// clamped legend): values below `lo` go to the bottom band, values above `hi` to the "over"
/// band. A clamp that leaves no range (lo ≥ hi) is ignored.
pub fn clamped(min: f64, max: f64, lo: Option<f64>, hi: Option<f64>, count: u32) -> Bands {
let (a, b) = (lo.filter(|v| v.is_finite()).unwrap_or(min), hi.filter(|v| v.is_finite()).unwrap_or(max));
if !(a < b) {
return Bands::new(min, max, count);
}
Bands { over: max > b, ..Bands::new(a, b, count) }
}
/// Whether band `k` is the "over" band (values above a clamped maximum).
pub fn is_over(&self, k: u32) -> bool {
self.over && k >= self.count
}
/// The band a value lies in (values outside the range go to the end bands); `None` for a
@@ -31,6 +50,9 @@ impl Bands {
if v.is_nan() {
return None;
}
if self.over && v > self.max {
return Some(self.count);
}
let span = self.max - self.min;
if !(span > 0.0) {
return Some(self.count / 2);
@@ -60,9 +82,10 @@ pub fn split_triangle(p: [Vec3; 3], v: [f64; 3], bands: &Bands, out: &mut impl F
}
let poly: Vec<(Vec3, f64)> = p.into_iter().zip(v).collect();
for k in lo..=hi {
// The end bands also hold the values beyond the range.
let below = if k == 0 { f64::NEG_INFINITY } else { bands.level(k) };
let above = if k + 1 == bands.count { f64::INFINITY } else { bands.level(k + 1) };
// The end bands also hold the values beyond the range, but for the "over" band above a
// clamped maximum.
let below = if k == 0 { f64::NEG_INFINITY } else { bands.level(k.min(bands.count)) };
let above = if k >= bands.count || (k + 1 == bands.count && !bands.over) { f64::INFINITY } else { bands.level(k + 1) };
let piece = clip(&clip(&poly, below, true), above, false);
if piece.len() >= 3 {
let pts: Vec<Vec3> = piece.iter().map(|(q, _)| *q).collect();
@@ -94,6 +117,12 @@ fn clip(poly: &[(Vec3, f64)], level: f64, keep_above: bool) -> Vec<(Vec3, f64)>
/// The colour of band `k` of `count`: the colour scale `stops` (low to high) sampled at the
/// band's middle.
/// The colour of band `k` of `bands`: [`band_color`], or `over` for the band above a clamped
/// maximum.
pub fn bands_color(stops: &[Rgb], over: Rgb, bands: &Bands, k: u32) -> Rgb {
if bands.is_over(k) { over } else { band_color(stops, k, bands.count) }
}
pub fn band_color(stops: &[Rgb], k: u32, count: u32) -> Rgb {
let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return Rgb(128, 128, 128) };
if stops.len() == 1 {
@@ -162,6 +191,30 @@ mod tests {
assert_eq!(n, 2);
}
/// A clamped range puts the values above it in the "over" band, split exactly at the
/// clamp; one below it in the bottom band; a clamp leaving no range is ignored.
#[test]
fn a_clamped_range_has_an_over_band() {
let b = Bands::clamped(0.0, 100.0, None, Some(10.0), 5);
assert_eq!((b.min, b.max, b.over), (0.0, 10.0, true));
assert_eq!((b.band(9.9), b.band(10.0), b.band(10.1), b.band(1e9)), (Some(4), Some(4), Some(5), Some(5)));
assert!(b.is_over(5) && !b.is_over(4));
let p = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(20.0, 0.0, 0.0), Vec3::new(0.0, 20.0, 0.0)];
let mut pieces = Vec::new();
split_triangle(p, [0.0, 20.0, 0.0], &b, &mut |q, k| pieces.push((q.to_vec(), k.unwrap())));
assert_eq!(pieces.iter().map(|(_, k)| *k).collect::<Vec<_>>(), vec![0, 1, 2, 3, 4, 5]);
let (over, _) = pieces.last().unwrap();
assert!(over.iter().all(|c| c.x >= 10.0 - 1e-9), "{over:?}");
assert!((pieces.iter().map(|(q, _)| area(q)).sum::<f64>() - 200.0).abs() < 1e-9);
let lo = Bands::clamped(0.0, 100.0, Some(50.0), None, 5);
assert_eq!((lo.min, lo.max, lo.over, lo.band(3.0)), (50.0, 100.0, false, Some(0)));
assert_eq!(Bands::clamped(0.0, 100.0, Some(50.0), Some(40.0), 5), Bands::new(0.0, 100.0, 5));
assert!(!Bands::clamped(0.0, 5.0, None, Some(10.0), 5).over, "a clamp above the maximum clamps nothing");
let stops = [Rgb(0, 0, 255), Rgb(255, 0, 0)];
assert_eq!(bands_color(&stops, Rgb(1, 2, 3), &b, 5), Rgb(1, 2, 3));
assert_eq!(bands_color(&stops, Rgb(1, 2, 3), &b, 4), band_color(&stops, 4, 5));
}
#[test]
fn band_colours_run_through_the_stops() {
let stops = [Rgb(0, 0, 255), Rgb(0, 255, 0), Rgb(255, 0, 0)];
+4 -3
View File
@@ -10,7 +10,7 @@ use std::sync::Arc;
use fieldcraft_engine::results::{Field, Kind, Pick, ResultsView, auto_scale, displacements, field_values, range};
use fieldcraft_engine::{Session, StepResult};
use fieldcraft_render::contour::{Bands, band_color, split_triangle};
use fieldcraft_render::contour::{Bands, bands_color, split_triangle};
use fieldcraft_render::{DisplayMesh, Scene, SceneTri, Vec3};
use crate::Style;
@@ -167,7 +167,7 @@ pub fn prepare(session: &Session, base: &DisplayMesh, pick: Pick) -> Option<Prep
}
}
let (min, max) = range(result.nodes(), &values)?;
Some(Arc::new(Contour { field, bands: Bands::new(min.1, max.1, view.bands), min, max, values }))
Some(Arc::new(Contour { field, bands: view.bands(min.1, max.1), min, max, values }))
});
Some(Prepared { scale: scale(view, result, pick, base), contour })
}
@@ -208,7 +208,7 @@ pub fn scene(plot: &Plot, style: Style, show: crate::scene::Show, t: &Tokens) ->
s.tris.clear();
for tri in &plot.display.tris {
split_triangle(tri.p, tri.nodes.map(|l| c.at(nodes, l)), &c.bands, &mut |piece, band| {
let color = band.map_or(rgb(t.no_result), |k| band_color(&stops, k, c.bands.count));
let color = band.map_or(rgb(t.no_result), |k| bands_color(&stops, rgb(t.contour_over), &c.bands, k));
for k in 1..piece.len().saturating_sub(1) {
if let (Some(&a), Some(&b), Some(&cc)) = (piece.first(), piece.get(k), piece.get(k + 1)) {
s.tris.push(SceneTri { p: [a, b, cc], normal: tri.normal, color });
@@ -295,6 +295,7 @@ pub(crate) mod tests {
use serde_json::json;
use fieldcraft_engine::results::{AUTO_FRACTION, magnitude};
use fieldcraft_render::contour::band_color;
use super::*;
+56 -9
View File
@@ -124,13 +124,17 @@ fn legend_layout(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) -
}
lines.push((step, t.overlay_text));
lines.push((deformation(plot), t.text_dim));
if let Some(l) = plot.contour.as_ref().filter(|_| plot.view.range_min.is_some() || plot.view.range_max.is_some()) {
let scale = l.max.1.abs().max(l.min.1.abs());
lines.push((format!("Legend clamped to {} … {}", number_in(l.bands.min, scale), number_in(l.bands.max, scale)), t.warm));
}
if plot.stale {
lines.push(("Stale: the model changed since this step was solved".into(), t.warm));
}
let origin = rect.left_top() + vec2(12.0, 12.0);
let font = FontId::proportional(12.0);
let line_h = LEGEND_LINE;
let bands = plot.contour.as_ref().map_or(0, |l| l.bands.count) as f32;
let bands = plot.contour.as_ref().map_or(0, |l| swatches(&l.bands)) as f32;
// As wide as its longest line (and the extremes' lines under the swatches).
let extremes = extreme_lines(plot).into_iter().flatten().map(|(text, _)| text);
let widest = lines
@@ -146,6 +150,11 @@ fn legend_layout(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) -
Legend { frame: Rect::from_min_size(origin, vec2(width, height)), title, lines }
}
/// The legend's swatches: the bands, and the "over" band above a clamped maximum.
fn swatches(b: &fieldcraft_render::contour::Bands) -> u32 {
b.count + u32::from(b.over)
}
/// The legend, top left under the view buttons. Returns the Max and Min lines' rects with their
/// nodes.
fn legend(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) -> Vec<(Rect, u32)> {
@@ -165,24 +174,28 @@ fn legend(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) -> Vec<(
let stops = t.contour_stops();
// Round-off among the levels (a range from -1e-15 to 3) reads as 0.
let scale = l.max.1.abs().max(l.min.1.abs());
// High values on top, each band's lower boundary written beside its swatch's bottom edge.
for k in (0..l.bands.count).rev() {
let row = (l.bands.count - 1 - k) as f32;
// High values on top, each band's lower boundary written beside its swatch's bottom edge;
// over a clamped maximum, the "over" band runs up to the field's true maximum.
let rows = swatches(&l.bands);
let over = crate::theme::rgb(t.contour_over);
for k in (0..rows).rev() {
let row = (rows - 1 - k) as f32;
let r = Rect::from_min_size(pos2(origin.x + 10.0, y + row * SWATCH.y), SWATCH);
let c = fieldcraft_render::contour::band_color(&stops, k, l.bands.count);
let c = fieldcraft_render::contour::bands_color(&stops, over, &l.bands, k);
painter.rect_filled(r, 0.0, Color32::from_rgb(c.0, c.1, c.2));
if k + 1 == l.bands.count {
painter.text(pos2(r.right() + 6.0, r.top()), Align2::LEFT_CENTER, number_in(l.bands.level(k + 1), scale), font.clone(), t.overlay_text);
if k + 1 == rows {
let top = if l.bands.is_over(k) { l.max.1 } else { l.bands.level(k + 1) };
painter.text(pos2(r.right() + 6.0, r.top()), Align2::LEFT_CENTER, number_in(top, scale), font.clone(), t.overlay_text);
}
painter.text(pos2(r.right() + 6.0, r.bottom()), Align2::LEFT_CENTER, number_in(l.bands.level(k), scale), font.clone(), t.overlay_text);
}
painter.rect_stroke(
Rect::from_min_size(pos2(origin.x + 10.0, y), vec2(SWATCH.x, SWATCH.y * l.bands.count as f32)),
Rect::from_min_size(pos2(origin.x + 10.0, y), vec2(SWATCH.x, SWATCH.y * rows as f32)),
0.0,
Stroke::new(1.0, t.border),
egui::StrokeKind::Outside,
);
y += SWATCH.y * l.bands.count as f32 + 10.0;
y += SWATCH.y * rows as f32 + 10.0;
let mut out = Vec::new();
for (i, (text, node)) in extremes.into_iter().enumerate() {
let r = painter.text(pos2(origin.x + 10.0, y + line_h * i as f32), Align2::LEFT_TOP, text, font.clone(), t.text_dim);
@@ -220,6 +233,15 @@ fn markers(app: &ModelApp, plot: &Plot, painter: &egui::Painter, rect: Rect, t:
}
}
/// `results.contour`'s parameters that change one end of the legend's range (`key` `min` or
/// `max`), keeping the field and the other end.
fn legend_params(plot: &Plot, key: &str, value: Value) -> Value {
let field = plot.view.contour.map_or("none", Field::id);
let mut range = json!({"min": plot.view.range_min, "max": plot.view.range_max});
range[key] = value;
json!({"step": plot.view.step, "field": field, "range": range})
}
/// The probed node: a marker on it and its values beside it, inside the viewport.
fn probe(app: &ModelApp, plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) {
let Some(p) = app.probe.as_ref().filter(|p| p.step == plot.view.step && p.mode == plot.pick.mode && p.frame == plot.pick.frame) else { return };
@@ -258,6 +280,31 @@ pub fn controls(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens)
}
});
ui.end_row();
// The legend's range: each end follows the field (Auto) or is clamped, e.g. below a
// singular peak (#191).
if let Some(l) = &plot.contour {
for (label, key, set, value) in [("Legend max", "max", plot.view.range_max, l.max.1), ("Legend min", "min", plot.view.range_min, l.min.1)]
{
ui.label(label);
ui.horizontal(|ui| {
let mut auto = set.is_none();
let tip = format!("Follow the field's {}; untick to clamp the legend", if key == "max" { "maximum" } else { "minimum" });
if ui.checkbox(&mut auto, "Auto").on_hover_text(tip).changed() {
run.push(("results.contour", legend_params(plot, key, if auto { json!("auto") } else { json!(value) })));
}
let mut v = set.unwrap_or(value);
let speed = (l.max.1 - l.min.1).abs().max(1e-9) * 0.005;
let drag = egui::DragValue::new(&mut v)
.speed(speed)
.custom_formatter(|v, _| number(v))
.custom_parser(|s| s.replace(',', "").trim().parse::<f64>().ok());
if ui.add_enabled(set.is_some(), drag).changed() {
run.push(("results.contour", legend_params(plot, key, json!(v))));
}
});
ui.end_row();
}
}
ui.label("Deformed");
ui.horizontal(|ui| {
let mut deformed = plot.view.deformed;
+3
View File
@@ -73,6 +73,8 @@ pub struct Tokens {
pub contour: [Color32; 5],
/// Faces with no result at a node.
pub no_result: Color32,
/// Values above a clamped legend's maximum (the same in both themes).
pub contour_over: Color32,
// ---- analysis setup symbols ----
/// Boundary conditions and rigid walls.
pub glyph_constraint: Color32,
@@ -159,6 +161,7 @@ impl Tokens {
],
contour: CONTOUR,
no_result: Color32::from_rgb(0x9a, 0xa0, 0xaa),
contour_over: Color32::from_rgb(0x6e, 0x2c, 0x80),
glyph_constraint: Color32::from_rgb(0x00, 0x8a, 0xa8),
glyph_load: Color32::from_rgb(0xd0, 0x24, 0x24),
glyph_interaction: Color32::from_rgb(0x9a, 0x30, 0xc0),
+1 -1
View File
@@ -101,7 +101,7 @@ software on the computer.
| `write_deck` | `format` (abaqus, nastran, lsdyna), `path`, `steps?` | `deck.write`. |
| `mesh` | `method`, `params?` | Runs the mesher (`tet`: `mesh.tet.generate`; `hex`, `hex_sweep`, `hex_mapped`, `hex_blocks`: the structured hex meshers; `cells`: `mesh.cells`, a partitioned part cell by cell; `mirror`: `mesh.mirror`, a meshed half reflected across a plane; `block`: `mesh.block`; `planar`: `mesh.planar`) and replies with its result, `model.info` and `mesh.quality`. |
| `solve` | `analysis` (static, frequency, explicit, fluid; heat as it lands), `step?`, `options?` | `solve.<analysis>` with the step and the solver's caps and settings. |
| `show_results` | `step?`, `field?`, `bands?`, `deformed?`, `scale?`, `mode?`, `frame?`, `time?` | The `results.*` commands its arguments ask for (`deformed: false` draws the undeformed mesh; `scale` is a factor, 0 for the undeformed shape so a sequence from 0 swings the deformation out, or `"auto"`); the reply has the contour range (smallest and largest value and their nodes: `field: "displacement"` gives the largest displacement), modes or frames. |
| `show_results` | `step?`, `field?`, `bands?`, `range?`, `deformed?`, `scale?`, `mode?`, `frame?`, `time?` | The `results.*` commands its arguments ask for (`deformed: false` draws the undeformed mesh; `scale` is a factor, 0 for the undeformed shape so a sequence from 0 swings the deformation out, or `"auto"`; `range: {min?, max?}` clamps the legend, values above `max` drawn in the over colour); the reply has the contour range (smallest and largest value and their nodes: `field: "displacement"` gives the largest displacement), modes or frames. |
| `probe` | `step?`, `nodes?`, `at?`, `elements?`, `points?`, `forces?`, `mode?`, `frame?` | `solve.probe`: stored results at nodes, at the nodes nearest to points `at` (structural steps), elements (explicit) or points (fluid). |
| `reactions` | `step?`, `sets?`, `about?` | `results.reactions`: reaction force and moment per node set and in total. |
| `screenshot` | `view?`, `camera?`, `source?` (window, model), `width?`, `height?`, `path?` | A PNG image content block and what it shows. Headless: a CPU render framing the model, the geometry, the analysis setup's symbols (boundary conditions, loads, reference points, couplings; `sampled_symbols` lists definitions drawn with a sample) and the results display (deformed shape, contour bands). Bridged: the whole app window drawn offscreen (`ui.capture`, the default) or a model render with the app's camera (`ui.render`). `view` turns the camera to a standard view first; `camera` `{yaw, pitch, distance?}` to any orientation, in degrees (yaw 0 looks along +Y, −45 is the iso view's; pitch is the elevation, clamped to ±90) and model units (`ui.camera`; headless, the picture is framed unless `distance` is given). Give one of the two; the orientation is kept for the next picture, so a sequence of `camera` angles orbits smoothly. |