forked from 1b-agent/fieldcraft-upstream
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:
1 parent
441f03d093
commit
5112855b83
10 files changed
+227
-26
No files matched your search
@@ -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::*;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user