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
+1
-1
@@ -38,7 +38,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
|
||||
| M9 Post-processing and deck readers | Results readers for public formats (Nastran OP2, LS-DYNA d3plot, Abaqus `.fil`); XY and path plots, cuts, display groups, free-body forces; deck readers that keep unknown keywords; mesh convergence studies | planned |
|
||||
| M10 Explicit dynamics | Shared element and material kernels with the implicit solver (`fem`); central-difference solver with lumped mass, stable time step, one-point hex8 with hourglass control and nodal-pressure tet4, bulk viscosity, energy accounting (X1 core: done — `fieldcraft-explicit`, `explicit_dynamic` steps with initial velocities, prescribed motion and planar rigid walls, `solve.explicit`/`solve.history`, small-strain linear elastic; bar wave, rigid-wall impact, energy balance and cantilever-period tests); large-deformation materials (X2: done — updated Lagrangian elements, Jaumann/Hughes–Winget and Green–Naghdi/Flanagan–Taylor rates, J2 bilinear/table plasticity, Johnson–Cook strength with heating and damage, erosion; materials gain `plastic`, `damage`, `erosion` with `model.material.plasticity`/`damage`; Dienes shear, rigid rotation, J2 uniaxial and Taylor-bar tests); contact (X3: done — `interaction.contact.pair`/`general`/`find`, segment-to-segment penalty contact both ways with polygon clipping (passes the contact patch test on non-matching meshes), pairs, general (all exterior faces) and self contact, bucket-grid search, deterministic on any thread count, a penalty from the masses and the stable increment that never shortens it, Coulomb friction with exponential decay by a return map, friction on rigid walls, contact energy and friction dissipation in the balance, initial penetrations adjusted or refused; Abaqus `*CONTACT PAIR`/`*CONTACT`/`*FRICTION` and LS-DYNA automatic contacts in the decks; verified by colliding bars, sliding blocks, the patch test and Hertz's sphere impact); hyperelastic materials (neo-Hookean, Mooney–Rivlin, Yeoh) on the shared material point with average nodal pressure tets, and opt-in mass scaling with the added mass reported: done; runs on hex and tet meshes | started |
|
||||
| M11 Equations of state and shells | Polynomial, ideal-gas, Mie–Grüneisen, JWL with programmed burn, tabulated; energy coupling; shock verification (Rankine–Hugoniot, Sod, Noh, Sedov); Belytschko–Lin–Tsay shells with self-contact on quad meshes | planned |
|
||||
| M12 CFD: lattice Boltzmann | D3Q19/D3Q27 on grids voxelised from the face-tagged surface; TRT, regularised and cumulant collision; interpolated walls, inlets and outlets; LES (Smagorinsky, WALE) with wall models; thermal LBM and conjugate heat transfer with the structure's thermal solver; non-Newtonian viscosity (power-law, Carreau–Yasuda, Cross, Herschel–Bulkley, Bingham) and temperature-dependent viscosity; porous media (Brinkman, Darcy–Forchheimer); multiphase and free surface (M13); validation by the lid-driven cavity, backward-facing step, cylinder wake (Strouhal) and turbulent channel; octree refinement shared with the hex mesher; wgpu backend (deterministic per device, checked against the CPU) (CPU core: done — `fieldcraft-lbm` with D3Q19 TRT and Guo forcing, halfway bounce-back and moving walls, slip and periodic faces, Zou–He velocity and pressure faces, exact voxelisation of obstacle bodies, momentum-exchange forces per face; fluid materials, fluid domains and fluid steps in the model; `model.fluid_domain.*`, `solve.fluid`; verified by Poiseuille channel and pipe, Couette, Taylor–Green decay, sphere-array Stokes drag and mass conservation; Schäfer–Turek 2D-1 as a benchmark) | started |
|
||||
| M12 CFD: lattice Boltzmann | D3Q19/D3Q27 on grids voxelised from the face-tagged surface; TRT, regularised and cumulant collision; interpolated walls, inlets and outlets; LES (Smagorinsky, WALE) with wall models; thermal LBM and conjugate heat transfer with the structure's thermal solver; non-Newtonian viscosity (power-law, Carreau–Yasuda, Cross, Herschel–Bulkley, Bingham) and temperature-dependent viscosity; porous media (Brinkman, Darcy–Forchheimer); multiphase and free surface (M13); validation by the lid-driven cavity, backward-facing step, cylinder wake (Strouhal) and turbulent channel; octree refinement shared with the hex mesher; wgpu backend (deterministic per device, checked against the CPU) (CPU core: done — `fieldcraft-lbm` with D3Q19 TRT and Guo forcing, halfway bounce-back and moving walls, slip and periodic faces, Zou–He velocity and pressure faces, exact voxelisation of obstacle bodies, momentum-exchange forces per face; fluid materials, fluid domains and fluid steps in the model; `model.fluid_domain.*`, `solve.fluid`; verified by Poiseuille channel and pipe, Couette, Taylor–Green decay, sphere-array Stokes drag and mass conservation; Schäfer–Turek 2D-1 as a benchmark; results in the viewport and headless pictures: velocity or pressure on a slice, the obstacles coloured by the flow beside them and streamlines seeded upstream, `results.slice`, `results.surface`, `results.streamlines`: done) | started |
|
||||
| M13 Fluid–structure interaction | Immersed-boundary coupling of the lattice Boltzmann fluid with the explicit or implicit structure; interface force transfer; sub-cycling; Aitken then interface quasi-Newton coupling; free-surface flow | planned |
|
||||
| M14 Blast and impact fluids | EOS-driven fluids in the explicit solver: ALE multi-material or SPH, with fluid–structure contact | planned |
|
||||
| M15 Nonlinear implicit core | Newton–Raphson with line search, automatic increments and cutbacks, convergence on force, displacement and energy; geometric nonlinearity (total and updated Lagrangian, large rotations for shells and beams, follower pressure); every `fem` material behind one interface with its consistent tangent (plasticity, damage and thermal strain from the explicit solver reused implicitly); arc-length (Riks) for snap-through; linear buckling (eigen) with preload stress stiffening, and imperfections from buckling modes; nonlinear stabilisation; restart. Verified by the elastica, Lee's frame, the snap-through arch, the hinged cylindrical shell and plastic collapse loads (solids core: done — `nonlinear_static` steps with automatic increments and cutbacks, Newton–Raphson with the consistent tangent, force, displacement and energy convergence and an optional line search; a material point interface (Kirchhoff stress and spatial tangent from F, with history) with St Venant–Kirchhoff and compressible neo-Hookean; total Lagrangian tet4, tet10, wedge6, hex8, hex20 (full and reduced), hybrid hex8/hex20/tet10 by the three-field mixed form, plane strain and axisymmetric elements; follower pressure with its symmetrised load stiffness; Cauchy stress and logarithmic strain, increment history and frames, `solve.nonlinear`; Abaqus and LS-DYNA decks; verified by the patch and objectivity tests, uniaxial stretch, the elastica and tube inflation by a follower pressure (finite-strain Cook's membrane pending its transcribed reference; J2 plasticity on the interface: Simo's exponential return map at finite strain and the radial return at small strain, both with consistent tangents and the return shared with explicit dynamics, bilinear isotropic and kinematic (small strain), tabulated and Johnson–Cook (rate-independent) hardening, hybrid elements included, load paths for cyclic loading, a tangent predictor for path-dependent materials, verified by uniaxial tension to large strain, Hill's thick cylinder (plastic zone and collapse pressure), a cantilever's collapse and the Bauschinger effect, with quadratic Newton convergence; Mooney–Rivlin and Yeoh options (decoupled, hybrid elements), verified in Treloar's three modes and by a stretched tube's inflation; thermal strain by the multiplicative split at finite strain and the additive one at small strain, with temperatures from temperature loads or the step's own conduction, verified by free and constrained expansion and a heated bar held past yield. a supernodal symmetric indefinite LDLᵀ factorization (bounded Bunch–Kaufman within supernodes, inertia) on which Newton continues through indefinite tangents, so nearly incompressible hybrid elements (ν = 0.4999) converge under prescribed displacement; linear buckling steps (linear preload, initial-stress stiffness of solids and 2D elements, block Lanczos in the K inner product, the shifted buckling transformation on LDLᵀ with an inertia count, `solve.buckle`, Abaqus `*BUCKLE`), verified by Euler columns and Bryan's plate; imperfections from buckling modes; arc-length (Riks–Crisfield, cylindrical or spherical) with limit and bifurcation points from the tangent's inertia, verified by the snap-through of a shallow arch. Next: Lee's frame and the hinged cylindrical shell (with shells), the follower pressure's full tangent (LU), incompatible modes (EAS), kinematic hardening at finite strain, shells and beams) | started |
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -67,8 +67,10 @@ Reynolds number and rises as it falls); the lattice's mass changes by less than
|
||||
|
||||

|
||||
|
||||
The picture shows the bracket only: the viewport does not draw flow results yet (velocity
|
||||
slices and streamlines are planned in M12), so the results are numbers for now.
|
||||
The picture shows the bracket as the sample opens. After Solve the viewport draws the flow:
|
||||
velocity magnitude on a slice through the middle of the box (`results.slice`; pressure with
|
||||
`field: "pressure"`), the bracket coloured by the flow beside it (`results.surface`) and
|
||||
streamlines seeded upstream of it, coloured by speed (`results.streamlines`).
|
||||
|
||||
## Heated bracket (`heated-bracket`)
|
||||
|
||||
|
||||
Reference in new issue
Block a user