forked from 1b-agent/fieldcraft-upstream
ui: show fluid results in the viewport with the part, legend and controls
The window built a deformed copy of the model for every results plot, which drops the geometry surfaces, so after a fluid solve the bracket vanished and nothing else was drawn. A fluid step's plot keeps the part as it is and draws the engine's flow display through it (the same scene as the headless pictures): the field on a slice, the part coloured by the flow beside it and streamlines. The legend shows the field with its bands and the cells of its extremes (Max/Min mark and turn the view to them); the results panel swaps the deformation rows for the slice's normal and position, the obstacles' colours, the streamlines and a frame slider, all running the results.* commands. The status line describes the new commands. Part of #193.
This commit is contained in:
1 parent
f7dcd46b2f
commit
a0c7d6ce9d
6 files changed
+303
-72
No files matched your search
@@ -1387,6 +1387,10 @@ impl ModelApp {
|
||||
let mut bounds = self.display.1.bounds;
|
||||
if let Some(p) = &self.plot.1 {
|
||||
bounds.union(p.display.bounds);
|
||||
// A fluid step's slice and streamlines fill its domain.
|
||||
if let Some(f) = &p.flow {
|
||||
bounds.union(f.bounds());
|
||||
}
|
||||
}
|
||||
// Reference points are places in the model, framed with it (their symbols are not).
|
||||
for p in self.session.model.reference_points().values() {
|
||||
|
||||
@@ -133,6 +133,18 @@ pub fn done(command: &str, label: &str, params: &Value, v: &Value) -> String {
|
||||
Some(f) => format!("Showing {step} frame {f}"),
|
||||
None => format!("Showing {step}, last frame"),
|
||||
}),
|
||||
"results.slice" => Some(match (v["slice"]["axis"].as_str(), v["slice"]["position"].as_f64()) {
|
||||
(Some(axis), Some(at)) => {
|
||||
let field = v.pointer("/contour/label").and_then(Value::as_str).unwrap_or("The flow");
|
||||
format!("{field} on a slice at {axis} = {}", crate::describe::quantity(at))
|
||||
}
|
||||
_ => format!("Slice off on {step}"),
|
||||
}),
|
||||
"results.surface" => Some(if v["surface"] == true { "Obstacles coloured by the flow" } else { "Obstacles in their own colours" }.to_string()),
|
||||
"results.streamlines" => Some(match v.pointer("/traced/streamlines").and_then(Value::as_u64) {
|
||||
Some(n) => format!("Traced {}", plural(n, "streamline")),
|
||||
None => "Streamlines off".to_string(),
|
||||
}),
|
||||
"results.clear" => Some("Back to the model".to_string()),
|
||||
"results.xy" => {
|
||||
let points = v.get("points").and_then(Value::as_u64).unwrap_or(0);
|
||||
|
||||
+101
-20
@@ -4,14 +4,16 @@
|
||||
//! ([`fieldcraft_render::contour`]); animation steps the deformation through a fixed number of
|
||||
//! frames per cycle (an explicit step's animation runs through its field frames, contours
|
||||
//! following). A plot of a stale result (the model changed since the solve) is drawn the
|
||||
//! same way and marked.
|
||||
//! same way and marked. A fluid step draws the part as it is, with the engine's flow display
|
||||
//! ([`fieldcraft_engine::flow`]: a slice, the obstacles coloured and streamlines) through it.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use fieldcraft_engine::flow::FlowPlot;
|
||||
use fieldcraft_engine::results::{Field, Kind, Pick, ResultsView, auto_scale, displacements, field_values, range};
|
||||
use fieldcraft_engine::{Session, StepResult};
|
||||
use fieldcraft_render::contour::{Bands, bands_color, split_triangle};
|
||||
use fieldcraft_render::{DisplayMesh, Scene, SceneTri, Vec3};
|
||||
use fieldcraft_render::contour::{Bands, banded, bands_color};
|
||||
use fieldcraft_render::{DisplayMesh, Scene, Vec3};
|
||||
|
||||
use crate::Style;
|
||||
use crate::theme::{Tokens, rgb};
|
||||
@@ -29,10 +31,10 @@ const ROUND_OFF: f64 = 1e-9;
|
||||
pub struct Contour {
|
||||
pub field: Field,
|
||||
pub bands: Bands,
|
||||
/// The smallest and largest value and their nodes.
|
||||
/// The smallest and largest value and their nodes (a fluid step's: their cells).
|
||||
pub min: (u32, f64),
|
||||
pub max: (u32, f64),
|
||||
/// The value at every node, in the result's node order.
|
||||
/// The value at every node, in the result's node order (empty for a fluid step's cells).
|
||||
pub values: Vec<f64>,
|
||||
}
|
||||
|
||||
@@ -56,6 +58,8 @@ pub struct Plot {
|
||||
/// The elements as drawn (deformed at the current animation frame).
|
||||
pub display: DisplayMesh,
|
||||
pub contour: Option<Arc<Contour>>,
|
||||
/// A fluid step's slice, coloured obstacles and streamlines.
|
||||
pub flow: Option<Arc<FlowPlot>>,
|
||||
/// The mode and field frame drawn.
|
||||
pub pick: Pick,
|
||||
/// The model's unit system, which the numbers shown with the plot are in.
|
||||
@@ -67,6 +71,7 @@ pub struct Plot {
|
||||
pub struct Prepared {
|
||||
pub scale: f64,
|
||||
pub contour: Option<Arc<Contour>>,
|
||||
pub flow: Option<Arc<FlowPlot>>,
|
||||
}
|
||||
|
||||
/// A mode's value as the results show it: its frequency, or a buckling mode's load factor.
|
||||
@@ -95,6 +100,7 @@ impl Plot {
|
||||
(frames.len(), &|k| frames.get(k).map(|f| f.load_factor))
|
||||
}
|
||||
Kind::Heat(h) if h.times.len() > 1 => (h.times.len(), &|k| h.times.get(k).copied()),
|
||||
Kind::Fluid(f) => (f.frames.len(), &|k| f.frames.get(k).map(|f| f.time)),
|
||||
_ => return None,
|
||||
};
|
||||
let k = self.pick.frame.unwrap_or(n.checked_sub(1)?);
|
||||
@@ -105,6 +111,19 @@ impl Plot {
|
||||
pub fn node_position(&self, label: u32) -> Option<Vec3> {
|
||||
self.display.node_position(label)
|
||||
}
|
||||
|
||||
/// Where a contour extreme is: its node's position, or a fluid step's cell centre.
|
||||
pub fn extreme_position(&self, label: u32) -> Option<Vec3> {
|
||||
match self.result.kind() {
|
||||
Kind::Fluid(f) => Some(Vec3::from(fieldcraft_engine::flow::cell_centre(f, usize::try_from(label).ok()?))),
|
||||
_ => self.node_position(label),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether it shows a fluid step (fields per cell, no deformation).
|
||||
pub fn is_fluid(&self) -> bool {
|
||||
matches!(self.result.kind(), Kind::Fluid(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// The result the session's view shows, and whether it is current.
|
||||
@@ -158,6 +177,15 @@ pub fn scale(view: &ResultsView, result: &StepResult, pick: Pick, base: &Display
|
||||
/// animation's deformation multiplier: the deformation scale and the contour values.
|
||||
pub fn prepare(session: &Session, base: &DisplayMesh, pick: Pick) -> Option<Prepared> {
|
||||
let (view, result, _) = shown(session)?;
|
||||
if matches!(result.kind(), Kind::Fluid(_)) {
|
||||
let flow = fieldcraft_engine::flow::plot(view, result, &session.traced).ok();
|
||||
let cell = |(k, v): (usize, f64)| (u32::try_from(k).unwrap_or(u32::MAX), v);
|
||||
let contour = flow
|
||||
.as_ref()
|
||||
.and_then(|p| p.contour.as_ref())
|
||||
.map(|c| Arc::new(Contour { field: c.field, bands: c.bands, min: cell(c.min), max: cell(c.max), values: Vec::new() }));
|
||||
return Some(Prepared { scale: 0.0, contour, flow: flow.map(Arc::new) });
|
||||
}
|
||||
let contour = view.contour.and_then(|field| {
|
||||
let mut values = field_values(&session.model, result, field, pick).ok()?;
|
||||
// The range (and the bands) follow what is drawn: a hidden body's nodes have no value.
|
||||
@@ -169,7 +197,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: view.bands(min.1, max.1), min, max, values }))
|
||||
});
|
||||
Some(Prepared { scale: scale(view, result, pick, base), contour })
|
||||
Some(Prepared { scale: scale(view, result, pick, base), contour, flow: None })
|
||||
}
|
||||
|
||||
/// The plot of the session's results view on the model's display data at animation `frame`,
|
||||
@@ -180,10 +208,15 @@ pub fn plot(session: &Session, base: &DisplayMesh, frame: Option<u64>, prepared:
|
||||
let pick = pick(view, result, frame);
|
||||
let factor = if view.deformed { prepared.scale * swing(result, frame) } else { 0.0 };
|
||||
let u = displacements(result, pick).unwrap_or(&[]);
|
||||
let display = base.displaced(|label| {
|
||||
let i = nodes.binary_search(&label).ok()?;
|
||||
u.get(i).map(|d| Vec3::from(*d) * factor)
|
||||
});
|
||||
// A fluid step does not move the part: it stays drawn as it is, geometry included.
|
||||
let display = if matches!(result.kind(), Kind::Fluid(_)) {
|
||||
base.clone()
|
||||
} else {
|
||||
base.displaced(|label| {
|
||||
let i = nodes.binary_search(&label).ok()?;
|
||||
u.get(i).map(|d| Vec3::from(*d) * factor)
|
||||
})
|
||||
};
|
||||
Some(Plot {
|
||||
view: view.clone(),
|
||||
result: result.clone(),
|
||||
@@ -191,30 +224,32 @@ pub fn plot(session: &Session, base: &DisplayMesh, frame: Option<u64>, prepared:
|
||||
scale: prepared.scale,
|
||||
display,
|
||||
contour: prepared.contour.clone(),
|
||||
flow: prepared.flow.clone(),
|
||||
pick,
|
||||
units: session.model.units(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The scene of a plot: the deformed elements drawn as the model is ([`scene::build`]), with
|
||||
/// their faces split into contour bands when a field is shown. Over a plot, hiding the mesh hides
|
||||
/// the element edges: the result itself never vanishes with them.
|
||||
/// their faces split into contour bands when a field is shown; a fluid step's part as it is with
|
||||
/// the flow drawn through it ([`fieldcraft_engine::flow::draw`]). Over a plot, hiding the mesh
|
||||
/// hides the element edges: the result itself never vanishes with them.
|
||||
pub fn scene(plot: &Plot, style: Style, show: crate::scene::Show, t: &Tokens) -> Scene {
|
||||
let (style, show) = if show.mesh { (style, show) } else { (Style::Shaded, crate::scene::Show { mesh: true, ..show }) };
|
||||
let mut s = crate::scene::build(&plot.display, style, show, t);
|
||||
if let Some(flow) = &plot.flow {
|
||||
let stops = t.contour_stops();
|
||||
let colors = fieldcraft_engine::flow::FlowColors { stops: &stops, over: rgb(t.contour_over), plain: rgb(t.streamline) };
|
||||
fieldcraft_engine::flow::draw(flow, &mut s, &colors);
|
||||
return s;
|
||||
}
|
||||
let Some(c) = plot.contour.as_ref().filter(|_| style != Style::Wireframe && show.mesh) else { return s };
|
||||
let nodes = plot.result.nodes();
|
||||
let stops = t.contour_stops();
|
||||
let color = |band: Option<u32>| band.map_or(rgb(t.no_result), |k| bands_color(&stops, rgb(t.contour_over), &c.bands, k));
|
||||
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| 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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
banded(tri.p, tri.nodes.map(|l| c.at(nodes, l)), tri.normal, &c.bands, &color, &mut s.tris);
|
||||
}
|
||||
s
|
||||
}
|
||||
@@ -376,6 +411,52 @@ pub(crate) mod tests {
|
||||
assert!(hidden.lines.iter().all(|l| l.color != rgb(t.mesh_edge)), "no element edges");
|
||||
}
|
||||
|
||||
/// A 40 × 30 × 10 mm plate in a tank of 5 mm cells, a liquid flowing past it along x, solved.
|
||||
fn plate_in_a_tank() -> Session {
|
||||
use fieldcraft_geometry::sample::{Plate, plate_design};
|
||||
let dir = std::env::temp_dir().join(format!("fieldcraft-ui-flow-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("plate.solidcraft");
|
||||
std::fs::write(&path, plate_design(&Plate::default()).unwrap()).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.execute("geometry.open", &json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
s.run_script(&json!([
|
||||
{"command": "model.material.create", "params": {"name": "Water", "density": 1e-9, "fluid": {"type": "newtonian", "viscosity": 1e-7}}},
|
||||
{"command": "model.fluid_domain.create", "params": {"name": "Tank", "material": "Water", "min": [-20, -20, -20], "max": [60, 50, 30], "spacing": 5, "obstacles": ["Body1"],
|
||||
"faces": {"x_min": {"type": "velocity", "velocity": [10, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0}}}},
|
||||
{"command": "model.step.create", "params": {"name": "Flow", "procedure": {"type": "fluid", "domain": "Tank", "steps": 60, "frames": 2}}},
|
||||
]))
|
||||
.unwrap();
|
||||
s.execute("solve.fluid", &json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// After a fluid solve the part stays drawn and the flow is drawn through it: velocity on a
|
||||
/// slice and on the part, with its legend, and streamlines (#193).
|
||||
#[test]
|
||||
fn a_fluid_step_keeps_the_part_and_draws_the_flow() {
|
||||
let mut s = plate_in_a_tank();
|
||||
let (base, _) = fieldcraft_engine::view::display(&s);
|
||||
assert!(!base.surface_tris.is_empty());
|
||||
let p = plot_at(&s, &base, None).unwrap();
|
||||
assert!(p.is_fluid() && p.flow.is_some());
|
||||
assert_eq!(p.display.surface_tris, base.surface_tris, "the part stays");
|
||||
let legend = p.contour.clone().unwrap();
|
||||
assert!(legend.field == Field::Velocity && legend.max.1 > legend.min.1, "{legend:?}");
|
||||
let at = p.extreme_position(legend.max.0).unwrap();
|
||||
assert!(at.x > -20.0 && at.x < 60.0, "{at:?}");
|
||||
assert_eq!(p.frame().map(|(k, _, n)| (k, n)), Some((2, 3)));
|
||||
let t = Tokens::light();
|
||||
let scene = scene(&p, Style::ShadedEdges, crate::scene::Show::DEFAULT, &t);
|
||||
assert!(scene.tris.len() > base.surface_tris.len(), "the slice is drawn");
|
||||
assert!(!scene.lines.is_empty(), "streamlines and the part's edges are drawn");
|
||||
// Pressure on the slice; the first frame (at rest at the inlet speed everywhere).
|
||||
s.execute("results.slice", &json!({"field": "pressure", "frame": 0})).unwrap();
|
||||
let p = plot_at(&s, &base, None).unwrap();
|
||||
assert_eq!((p.contour.as_ref().map(|c| c.field), p.pick.frame), (Some(Field::Pressure), Some(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modes_swing_and_static_steps_grow_when_animated() {
|
||||
let mut s = solved_cantilever();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! What the viewport shows over a results plot: the legend (field and unit, bands, range,
|
||||
//! deformation scale, a stale-result warning), markers at the contour's largest and smallest
|
||||
//! value, and the probed node; and the results controls of the context panel (right), which run
|
||||
//! the `results.*` commands. Numbers go through one formatter ([`crate::results::number`]) with
|
||||
//! the `results.*` commands (a fluid step's: its slice, coloured obstacles, streamlines and
|
||||
//! frames). Numbers go through one formatter ([`crate::results::number`]) with
|
||||
//! the units of the model's unit system ([`Quantity`]).
|
||||
|
||||
use egui::{Align2, Color32, FontId, Pos2, Rect, RichText, Stroke, pos2, vec2};
|
||||
@@ -53,11 +54,15 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn the view about node `node` of the plot (keeping the zoom) and probe it.
|
||||
/// Turn the view about node `node` of the plot (keeping the zoom) and probe it; a fluid step's
|
||||
/// extreme is a cell, turned about only.
|
||||
fn focus_node(app: &mut ModelApp, plot: &Plot, node: u32) {
|
||||
if let Some(at) = plot.node_position(node).filter(|p| p.is_finite()) {
|
||||
if let Some(at) = plot.extreme_position(node).filter(|p| p.is_finite()) {
|
||||
app.cam.target = at;
|
||||
}
|
||||
if plot.is_fluid() {
|
||||
return;
|
||||
}
|
||||
// A failure (a stale result) shows in the status bar.
|
||||
let _ = app.probe_node(node);
|
||||
}
|
||||
@@ -103,7 +108,11 @@ fn extreme_lines(plot: &Plot) -> Option<[(String, u32); 2]> {
|
||||
let l = plot.contour.as_ref()?;
|
||||
let unit = l.field.unit(&plot.result, plot.units);
|
||||
let scale = l.max.1.abs().max(l.min.1.abs());
|
||||
let line = |what: &str, (node, v): (u32, f64)| (format!("{what} {} at node {node}", with_unit(number_in(v, scale), unit.as_deref())), node);
|
||||
let at = |node: u32| match plot.extreme_position(node).filter(|_| plot.is_fluid()) {
|
||||
Some(p) => format!("at {}", components(&[p.x, p.y, p.z], 0.0)),
|
||||
None => format!("at node {node}"),
|
||||
};
|
||||
let line = |what: &str, (node, v): (u32, f64)| (format!("{what} {} {}", with_unit(number_in(v, scale), unit.as_deref()), at(node)), node);
|
||||
Some([line("Max", l.max), line("Min", l.min)])
|
||||
}
|
||||
|
||||
@@ -125,7 +134,9 @@ 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 !plot.is_fluid() {
|
||||
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));
|
||||
@@ -206,17 +217,32 @@ fn legend(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) -> Vec<(
|
||||
out
|
||||
}
|
||||
|
||||
/// A box of `text` beside the screen point `at`, kept inside `bounds`: right of the point, or left
|
||||
/// of it when it would run off the right edge; held inside top and bottom.
|
||||
fn label_beside(painter: &egui::Painter, at: Pos2, text: String, bounds: Rect, t: &Tokens) {
|
||||
let galley = painter.layout_no_wrap(text, FontId::proportional(12.0), t.overlay_text);
|
||||
let size = galley.size() + 2.0 * LABEL_PAD;
|
||||
/// Where a label box of `size` goes beside the screen point `at`, kept inside `bounds`: right of
|
||||
/// the point, or left of it when it would run off the right edge; held inside top and bottom.
|
||||
/// When it would cover `avoid` (a label already placed), it goes under that box, or over it
|
||||
/// when under runs off the bottom.
|
||||
fn label_box(at: Pos2, size: egui::Vec2, bounds: Rect, avoid: Option<Rect>) -> Rect {
|
||||
let right = at + vec2(LABEL_GAP, -size.y / 2.0);
|
||||
let x = if right.x + size.x > bounds.right() { at.x - LABEL_GAP - size.x } else { right.x };
|
||||
let y = right.y.clamp(bounds.top(), (bounds.bottom() - size.y).max(bounds.top()));
|
||||
let box_rect = Rect::from_min_size(pos2(x.max(bounds.left()), y), size);
|
||||
let x = if right.x + size.x > bounds.right() { at.x - LABEL_GAP - size.x } else { right.x }.max(bounds.left());
|
||||
let inside = |y: f32| y.clamp(bounds.top(), (bounds.bottom() - size.y).max(bounds.top()));
|
||||
let at_y = |y: f32| Rect::from_min_size(pos2(x, inside(y)), size);
|
||||
let beside = at_y(right.y);
|
||||
match avoid.filter(|a| a.intersects(beside)) {
|
||||
None => beside,
|
||||
Some(a) => {
|
||||
let under = at_y(a.bottom() + LABEL_PAD.y);
|
||||
if under.intersects(a) { at_y(a.top() - LABEL_PAD.y - size.y) } else { under }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A box of `text` beside the screen point `at` ([`label_box`]); returns where it went.
|
||||
fn label_beside(painter: &egui::Painter, at: Pos2, text: String, bounds: Rect, avoid: Option<Rect>, t: &Tokens) -> Rect {
|
||||
let galley = painter.layout_no_wrap(text, FontId::proportional(12.0), t.overlay_text);
|
||||
let box_rect = label_box(at, galley.size() + 2.0 * LABEL_PAD, bounds, avoid);
|
||||
painter.rect_filled(box_rect, 4.0, t.overlay);
|
||||
painter.galley(box_rect.left_top() + LABEL_PAD, galley, t.overlay_text);
|
||||
box_rect
|
||||
}
|
||||
|
||||
/// Markers with their values at the contour's largest and smallest value.
|
||||
@@ -225,13 +251,15 @@ fn markers(app: &ModelApp, plot: &Plot, painter: &egui::Painter, rect: Rect, t:
|
||||
let unit = l.field.unit(&plot.result, plot.units);
|
||||
let scale = l.max.1.abs().max(l.min.1.abs());
|
||||
let (high, low) = (t.contour[4], t.contour[0]);
|
||||
// The Min label keeps off the Max label when the two cells are close on screen.
|
||||
let mut placed = None;
|
||||
for (what, (node, v), color) in [("Max", l.max, high), ("Min", l.min, low)] {
|
||||
let Some(at) = plot.node_position(node).and_then(|w| crate::viewport::to_screen(app, w)) else { continue };
|
||||
let Some(at) = plot.extreme_position(node).and_then(|w| crate::viewport::to_screen(app, w)) else { continue };
|
||||
if !rect.contains(at) {
|
||||
continue;
|
||||
}
|
||||
painter.circle(at, 5.0, color, Stroke::new(2.0, t.overlay_text));
|
||||
label_beside(painter, at, format!("{what} {}", with_unit(number_in(v, scale), unit.as_deref())), rect, t);
|
||||
placed = Some(label_beside(painter, at, format!("{what} {}", with_unit(number_in(v, scale), unit.as_deref())), rect, placed, t));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +277,7 @@ fn probe(app: &ModelApp, plot: &Plot, painter: &egui::Painter, rect: Rect, t: &T
|
||||
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 };
|
||||
let Some(at) = plot.node_position(p.node).and_then(|w| crate::viewport::to_screen(app, w)) else { return };
|
||||
painter.circle(at, 5.0, t.accent, Stroke::new(2.0, t.overlay_text));
|
||||
label_beside(painter, at, p.lines.join("\n"), rect, t);
|
||||
label_beside(painter, at, p.lines.join("\n"), rect, None, t);
|
||||
}
|
||||
|
||||
/// The results controls (the context panel's Results section): step, contour field,
|
||||
@@ -308,40 +336,11 @@ pub fn controls(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens)
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
ui.label("Deformed");
|
||||
ui.horizontal(|ui| {
|
||||
let mut deformed = plot.view.deformed;
|
||||
if ui.checkbox(&mut deformed, "").changed() {
|
||||
run.push(("results.deformed", json!({"show": deformed})));
|
||||
}
|
||||
let mut auto = plot.view.scale.is_none();
|
||||
let tip = if true_scale(&plot.result) {
|
||||
"Draw the displacements at true scale (they are large already)"
|
||||
} else {
|
||||
"Scale the largest displacement to a tenth of the model"
|
||||
};
|
||||
if ui.checkbox(&mut auto, "Auto").on_hover_text(tip).changed() {
|
||||
run.push(("results.deformed", json!({"scale": if auto { json!("auto") } else { json!(plot.scale) }})));
|
||||
}
|
||||
// The scale ticking Deformed draws with, also while it is off.
|
||||
let mut scale = plot.scale;
|
||||
let drag = egui::DragValue::new(&mut scale)
|
||||
.speed(plot.scale.abs().max(1e-6) * 0.01)
|
||||
.range(0.0..=1e12)
|
||||
.prefix("× ")
|
||||
.custom_formatter(|v, _| number(v))
|
||||
.custom_parser(|s| s.replace(',', "").trim().parse::<f64>().ok());
|
||||
if ui.add_enabled(plot.view.deformed, drag).changed() {
|
||||
run.push(("results.deformed", json!({"scale": scale})));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Animate");
|
||||
let mut animate = plot.view.animate;
|
||||
if ui.checkbox(&mut animate, "").changed() {
|
||||
run.push(("results.animate", json!({"on": animate})));
|
||||
if plot.is_fluid() {
|
||||
flow_rows(plot, ui, &mut run);
|
||||
} else {
|
||||
deformation_rows(plot, ui, &mut run);
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label("Show");
|
||||
ui.horizontal(|ui| {
|
||||
if plot.contour.is_some() {
|
||||
@@ -354,6 +353,17 @@ pub fn controls(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens)
|
||||
if let Kind::Explicit(e) = plot.result.kind() {
|
||||
ui.separator();
|
||||
explicit(ui, e, plot, t, &mut run);
|
||||
} else if let Some((k, at, n)) = plot.frame().filter(|_| plot.is_fluid()) {
|
||||
// A fluid step: its frames, the slice, the obstacles and the streamlines following.
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Frame");
|
||||
let mut frame = k;
|
||||
if ui.add(egui::Slider::new(&mut frame, 0..=n.saturating_sub(1))).changed() {
|
||||
run.push(("results.frame", json!({"frame": frame})));
|
||||
}
|
||||
});
|
||||
ui.label(RichText::new(format!("t = {}", with_unit(number(at), Quantity::Time.unit(plot.units).as_deref()))).color(t.text_dim));
|
||||
} else if let Some((k, factor, n)) = plot.frame().filter(|_| matches!(plot.result.kind(), Kind::Static(_))) {
|
||||
// A nonlinear static step: its increments, the deformed shape following the slider.
|
||||
ui.separator();
|
||||
@@ -421,6 +431,100 @@ pub fn controls(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens)
|
||||
}
|
||||
}
|
||||
|
||||
/// The deformation rows of the results grid: Deformed (with its scale) and Animate.
|
||||
fn deformation_rows(plot: &Plot, ui: &mut egui::Ui, run: &mut Vec<(&'static str, Value)>) {
|
||||
ui.label("Deformed");
|
||||
ui.horizontal(|ui| {
|
||||
let mut deformed = plot.view.deformed;
|
||||
if ui.checkbox(&mut deformed, "").changed() {
|
||||
run.push(("results.deformed", json!({"show": deformed})));
|
||||
}
|
||||
let mut auto = plot.view.scale.is_none();
|
||||
let tip = if true_scale(&plot.result) {
|
||||
"Draw the displacements at true scale (they are large already)"
|
||||
} else {
|
||||
"Scale the largest displacement to a tenth of the model"
|
||||
};
|
||||
if ui.checkbox(&mut auto, "Auto").on_hover_text(tip).changed() {
|
||||
run.push(("results.deformed", json!({"scale": if auto { json!("auto") } else { json!(plot.scale) }})));
|
||||
}
|
||||
// The scale ticking Deformed draws with, also while it is off.
|
||||
let mut scale = plot.scale;
|
||||
let drag = egui::DragValue::new(&mut scale)
|
||||
.speed(plot.scale.abs().max(1e-6) * 0.01)
|
||||
.range(0.0..=1e12)
|
||||
.prefix("× ")
|
||||
.custom_formatter(|v, _| number(v))
|
||||
.custom_parser(|s| s.replace(',', "").trim().parse::<f64>().ok());
|
||||
if ui.add_enabled(plot.view.deformed, drag).changed() {
|
||||
run.push(("results.deformed", json!({"scale": scale})));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Animate");
|
||||
let mut animate = plot.view.animate;
|
||||
if ui.checkbox(&mut animate, "").changed() {
|
||||
run.push(("results.animate", json!({"on": animate})));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
/// A fluid step's rows of the results grid: the slice (its normal, position), the obstacles'
|
||||
/// colours and the streamlines (their count from the inlet).
|
||||
fn flow_rows(plot: &Plot, ui: &mut egui::Ui, run: &mut Vec<(&'static str, Value)>) {
|
||||
use fieldcraft_engine::flow::{Axis, slice_position};
|
||||
let (Kind::Fluid(f), Some(flow)) = (plot.result.kind(), plot.flow.as_ref()) else { return };
|
||||
let view = &flow.view;
|
||||
ui.label("Slice");
|
||||
ui.horizontal(|ui| {
|
||||
let shown = view.slice.as_ref().map_or("None", |s| s.axis.id());
|
||||
egui::ComboBox::from_id_salt("fc_results_slice").selected_text(format!("Normal {}", shown.to_uppercase())).width(90.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(view.slice.is_none(), "None").clicked() {
|
||||
run.push(("results.slice", json!({"axis": "none"})));
|
||||
}
|
||||
for a in Axis::ALL {
|
||||
if ui.selectable_label(view.slice.as_ref().is_some_and(|s| s.axis == a), format!("Normal {}", a.id().to_uppercase())).clicked() {
|
||||
run.push(("results.slice", json!({"axis": a.id()})));
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(s) = &view.slice {
|
||||
let (lo, hi) = (flow.grid.start(s.axis.index()), flow.grid.end(s.axis.index()));
|
||||
let mut at = slice_position(f, s);
|
||||
let drag = egui::DragValue::new(&mut at)
|
||||
.speed((hi - lo).abs().max(1e-9) * 0.002)
|
||||
.range(lo..=hi)
|
||||
.custom_formatter(|v, _| number(v))
|
||||
.custom_parser(|s| s.replace(',', "").trim().parse::<f64>().ok());
|
||||
if ui.add(drag).on_hover_text("Where the slice crosses its axis").changed() {
|
||||
run.push(("results.slice", json!({"position": at})));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Obstacles");
|
||||
let mut surface = view.surface;
|
||||
if ui.checkbox(&mut surface, "Coloured").on_hover_text("Colour the part by the field in the fluid beside it").changed() {
|
||||
run.push(("results.surface", json!({"show": surface})));
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label("Streamlines");
|
||||
ui.horizontal(|ui| {
|
||||
let mut on = view.streamlines.is_some();
|
||||
if ui.checkbox(&mut on, "").on_hover_text("Streamlines from the inlet, coloured by speed when the legend shows velocity").changed() {
|
||||
run.push(("results.streamlines", json!({"show": on})));
|
||||
}
|
||||
if let Some(sl) = &view.streamlines {
|
||||
let mut count = sl.count;
|
||||
let drag = egui::DragValue::new(&mut count).range(1..=fieldcraft_engine::flow::MAX_STREAMLINES).speed(1.0).suffix(" seeds");
|
||||
if ui.add(drag).on_hover_text("Seeds on the inlet face, upstream of the obstacles").changed() {
|
||||
run.push(("results.streamlines", json!({"count": count})));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
/// The probed node's values, one row each, or how to probe one; the export's parameters when
|
||||
/// its Export CSV… is clicked.
|
||||
fn probed(app: &ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens) -> Option<Value> {
|
||||
@@ -819,3 +923,31 @@ fn reactions_table(ui: &mut egui::Ui, r: &Value, plot: &Plot, t: &Tokens) {
|
||||
ui.label(RichText::new(text).small().color(color));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A label goes right of its point, left when right runs off, inside the top and bottom;
|
||||
/// one that would cover a placed label goes under it, or over it at the bottom edge.
|
||||
#[test]
|
||||
fn labels_keep_inside_the_view_and_off_each_other() {
|
||||
let bounds = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 300.0));
|
||||
let size = vec2(80.0, 20.0);
|
||||
let a = label_box(pos2(100.0, 150.0), size, bounds, None);
|
||||
assert_eq!(a, Rect::from_min_size(pos2(100.0 + LABEL_GAP, 140.0), size));
|
||||
assert_eq!(label_box(pos2(380.0, 150.0), size, bounds, None).min, pos2(380.0 - LABEL_GAP - 80.0, 140.0));
|
||||
assert_eq!(label_box(pos2(100.0, 2.0), size, bounds, None).min.y, 0.0);
|
||||
assert_eq!(label_box(pos2(100.0, 299.0), size, bounds, None).max.y, 300.0);
|
||||
// Far apart: unmoved. Overlapping: under the placed one, apart by the label padding.
|
||||
assert_eq!(label_box(pos2(100.0, 250.0), size, bounds, Some(a)), Rect::from_min_size(pos2(100.0 + LABEL_GAP, 240.0), size));
|
||||
let b = label_box(pos2(104.0, 156.0), size, bounds, Some(a));
|
||||
assert_eq!(b.min, pos2(104.0 + LABEL_GAP, a.max.y + LABEL_PAD.y));
|
||||
assert!(!b.intersects(a));
|
||||
// Placed at the bottom edge: the second goes over it.
|
||||
let low = label_box(pos2(100.0, 299.0), size, bounds, None);
|
||||
let over = label_box(pos2(100.0, 295.0), size, bounds, Some(low));
|
||||
assert_eq!(over.max.y, low.min.y - LABEL_PAD.y);
|
||||
assert!(!over.intersects(low));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user