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 <[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 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 });