render, ui: prescribed motion and thermal conditions get their own symbols and tree icons (#354, #355)

A non-zero prescribed displacement is an arrow leaving the nodes along the
motion (its held degrees of freedom stay supports); a prescribed temperature
is a thermometer, convection a wave off the faces, a heat flux an arrow onto
them. In the tree, thermal conditions carry the heat icon and a moving
boundary condition a motion arrow.

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 08:55:25 -04:00
1 parent 27af5e19b7
commit b89efb55c1
2 files changed
+152 -13

No files matched your search

+104 -11
View File
@@ -244,7 +244,18 @@ enum Shape {
Constraint,
Force(Vec3),
Moment(Vec3),
/// Heat generated in a set's elements: a dot at each node.
Thermal,
/// A prescribed temperature (a boundary condition or a temperature field): a stem off the
/// part ending in a bulb, a thermometer.
Temperature,
/// A prescribed motion along a unit direction: an arrow leaving the node from a short bar,
/// telling it apart from a force (an arrow onto the node) and a support (triangles).
Prescribed(Vec3),
/// Convection to the surroundings: a wavy line off the face.
Convection,
/// A heat flux into the face: an arrow onto it.
HeatFlux,
/// A pressure: onto the face (positive) or away from it.
Pressure {
onto: bool,
@@ -474,10 +485,11 @@ impl Builder<'_> {
}
}
/// Dots at a thermal condition's nodes.
fn thermal(&mut self, owner: usize, target: &Target) {
/// A thermal condition's symbol (`shape`, at nodes) at its target's nodes.
fn thermal(&mut self, owner: usize, target: &Target, shape: Shape) {
let (spots, total) = self.spots(target);
self.group(owner, Shape::Thermal, spots, Vec::new(), total, 1.5);
let cell = if shape == Shape::Temperature { 2.4 } else { 1.5 };
self.group(owner, shape, spots, Vec::new(), total, cell);
}
/// The faces of a set (its element faces, else the edges of its 2D elements) with their
@@ -543,9 +555,22 @@ impl Builder<'_> {
fn boundary_condition(&mut self, name: &str, bc: &BoundaryCondition) {
let owner = self.owner(Definition::BoundaryCondition, name);
if bc.is_thermal() {
return self.thermal(owner, bc.target());
return self.thermal(owner, bc.target(), Shape::Temperature);
}
let mut dofs = bc.dofs().bits();
// A prescribed non-zero translation moves the nodes: an arrow along the motion; the
// degrees of freedom it drives are no supports.
if let BoundaryCondition::Displacement { values, .. } = bc {
let moved = |i: usize| values.get(i).copied().flatten().filter(|v| v.is_finite() && *v != 0.0);
let motion = Vec3::new(moved(0).unwrap_or(0.0), moved(1).unwrap_or(0.0), moved(2).unwrap_or(0.0));
for (i, _) in values.iter().enumerate().filter(|(i, _)| moved(*i).is_some()) {
dofs &= !(1u8 << i);
}
if let Some(dir) = motion.normalized() {
let (spots, total) = self.spots(bc.target());
self.group(owner, Shape::Prescribed(dir), spots, Vec::new(), total, 2.8);
}
}
// A symmetry plane shows its normal only: the rotations it holds follow from it.
if matches!(bc, BoundaryCondition::Symmetry { .. }) {
dofs &= 0b000_111;
@@ -606,10 +631,17 @@ impl Builder<'_> {
let centre = point + axis * axis.dot(c - point);
self.marks.push((owner, Mark::Spin { centre, axis, half_length: 0.5 * self.bounds.diagonal() }));
}
Load::Temperature { target, .. } => self.thermal(owner, target),
Load::SurfaceFlux { set, .. } | Load::Convection { set, .. } | Load::HeatSource { set, .. } => {
self.thermal(owner, &Target::Set(set.clone()))
Load::Temperature { target, .. } => self.thermal(owner, target, Shape::Temperature),
Load::Convection { set, .. } | Load::SurfaceFlux { set, .. } => {
let shape = if matches!(load, Load::Convection { .. }) { Shape::Convection } else { Shape::HeatFlux };
let (faces, total) = self.faces(set);
if faces.is_empty() {
self.thermal(owner, &Target::Set(set.clone()), Shape::Thermal);
} else {
self.group(owner, shape, Vec::new(), faces, total, 2.2);
}
}
Load::HeatSource { set, .. } => self.thermal(owner, &Target::Set(set.clone()), Shape::Thermal),
}
}
@@ -761,8 +793,8 @@ impl Glyphs {
continue;
}
let color = match g.shape {
Shape::Constraint => colors.constraint,
Shape::Thermal => colors.thermal,
Shape::Constraint | Shape::Prescribed(_) => colors.constraint,
Shape::Thermal | Shape::Temperature | Shape::Convection | Shape::HeatFlux => colors.thermal,
Shape::Surface => colors.contact,
_ => colors.load,
};
@@ -1087,7 +1119,29 @@ impl Draw<'_> {
self.curl(p, dir, radius, head);
}
Shape::Thermal => self.point(p, THERMAL_SIZE),
Shape::Pressure { .. } | Shape::Traction(_) | Shape::Surface => {}
Shape::Temperature => {
// Off the part along its faces' outward direction (up, away from faces).
let dir = s.normal.unwrap_or(Vec3::Z);
let (stem, bulb) = if spread { (1.2 * u, 0.32 * u) } else { (1.8 * u, 0.45 * u) };
self.line(p, p + dir * stem);
let (e1, _) = perpendiculars(dir);
let c = p + dir * (stem + bulb);
for k in 0..ARC_SEGMENTS {
let at = |k: usize| {
let a = std::f64::consts::TAU * k as f64 / ARC_SEGMENTS as f64;
c + (dir * a.cos() + e1 * a.sin()) * bulb
};
self.line(at(k), at(k + 1));
}
self.point(p, THERMAL_SIZE * 0.7);
}
Shape::Prescribed(dir) => {
let (len, head) = if spread { (2.2 * u, 0.7 * u) } else { (3.5 * u, 0.9 * u) };
let (e1, _) = perpendiculars(dir);
self.line(p - e1 * (0.45 * u), p + e1 * (0.45 * u));
self.arrow(p, dir, len, head);
}
Shape::Pressure { .. } | Shape::Traction(_) | Shape::Surface | Shape::Convection | Shape::HeatFlux => {}
}
}
@@ -1108,6 +1162,21 @@ impl Draw<'_> {
Shape::Pressure { onto: true } => self.arrow(c + outward * len, -outward, len, head),
Shape::Pressure { onto: false } => self.arrow(c, outward, len, head),
Shape::Traction(dir) => self.arrow(c - dir * len, dir, len, head),
Shape::HeatFlux => self.arrow(c + outward * len, -outward, len, head),
Shape::Convection => {
// A wave leaving the face.
let (e1, _) = perpendiculars(outward);
let steps = 6;
let at = |k: usize| {
let along = len * k as f64 / steps as f64;
let side = if k == 0 { 0.0 } else { (k as f64 * std::f64::consts::FRAC_PI_2).sin() * 0.25 * u };
c + outward * along + e1 * side
};
for k in 0..steps {
self.line(at(k), at(k + 1));
}
self.head(at(steps), outward, head);
}
Shape::Surface => {
// A small square on the face, lifted off it a little.
let (e1, e2) = perpendiculars(outward);
@@ -1117,7 +1186,7 @@ impl Draw<'_> {
self.line(*a, ring.get((k + 1) % 4).copied().unwrap_or(*a));
}
}
Shape::Constraint | Shape::Force(_) | Shape::Moment(_) | Shape::Thermal => {}
Shape::Constraint | Shape::Force(_) | Shape::Moment(_) | Shape::Thermal | Shape::Temperature | Shape::Prescribed(_) => {}
}
}
@@ -1244,6 +1313,30 @@ mod tests {
assert_eq!(near(&bases(&s, Vec3::ZERO), -Vec3::X * u), 2);
}
/// A prescribed motion is drawn as an arrow leaving each node along it (a bar, a shaft and a
/// head), its held degrees of freedom still as supports (#354); a prescribed
/// temperature is a thermometer, never a support (#355).
#[test]
fn prescribed_motion_and_temperature_have_their_own_symbols() {
let u = 0.1;
let mut m = bar(4);
let values = [Some(0.0), None, Some(-2.5), None, None, None];
m.add_boundary_condition("Press", BoundaryCondition::Displacement { target: Target::Set("root".into()), values }).unwrap();
let (s, _) = drawn_at(&Glyphs::from_model(&m), &m, &view(GlyphShow::ALL, u));
// Per node: one support triangle (x held at 0, six lines), and the motion's bar, shaft and
// four-line head pointing down from the node.
assert_eq!(s.lines.len(), 4 * (6 + 1 + 1 + 4), "{}", s.lines.len());
let down = s.lines.iter().filter(|l| l.a.dist(Vec3::ZERO) < 1e-9 && l.b.dist(Vec3::new(0.0, 0.0, -2.2 * u)) < 1e-9).count();
assert_eq!(down, 1, "a shaft leaves the node along the motion");
assert!(s.lines.iter().all(|l| l.color == COLORS.constraint));
let mut m = bar(4);
m.add_boundary_condition("Heater", BoundaryCondition::Temperature { target: Target::Set("root".into()), value: 120.0 }).unwrap();
let (s, _) = drawn_at(&Glyphs::from_model(&m), &m, &view(GlyphShow::ALL, u));
assert!(s.lines.iter().all(|l| l.color == COLORS.thermal) && s.lines.len() >= 4 * (1 + ARC_SEGMENTS), "{}", s.lines.len());
assert_eq!(s.points.len(), 4);
}
#[test]
fn rotations_are_double_marks_and_symmetry_shows_its_normal_only() {
let u = 0.5;
+48 -2
View File
@@ -423,6 +423,8 @@ struct Row<'a> {
/// Some(visible) for rows with an eye.
eye: Option<bool>,
icon: Option<&'static str>,
/// A motion arrow over the icon's corner: a boundary condition that moves the part.
moves: bool,
/// A colour swatch instead of an icon (element kinds).
swatch: Option<Color32>,
label: &'a str,
@@ -499,7 +501,17 @@ fn draw_row(app: &mut ModelApp, ui: &mut egui::Ui, id: egui::Id, row: &Row) -> R
x += 20.0;
}
if let Some(icon) = row.icon {
icons::paint(app, ui, icon, Rect::from_min_size(pos2(x, cy - 8.0), Vec2::splat(16.0)), Look::of(!row.dim));
let ir = Rect::from_min_size(pos2(x, cy - 8.0), Vec2::splat(16.0));
icons::paint(app, ui, icon, ir, Look::of(!row.dim));
if row.moves {
// An arrow out of the icon's lower right corner, on a disc of the row's background.
let c = ir.right_bottom() - vec2(3.0, 3.0);
ui.painter().circle_filled(c, 4.5, if row.selected { t.accent_soft } else { t.panel });
let s = Stroke::new(1.4, t.accent);
ui.painter().line_segment([c + vec2(-3.0, -3.0), c + vec2(2.5, 2.5)], s);
ui.painter().line_segment([c + vec2(2.5, 2.5), c + vec2(-0.5, 2.5)], s);
ui.painter().line_segment([c + vec2(2.5, 2.5), c + vec2(2.5, -0.5)], s);
}
x += 21.0;
} else if let Some(c) = row.swatch {
ui.painter().rect_filled(Rect::from_center_size(pos2(x + 6.0, cy), Vec2::splat(10.0)), 2.0, c);
@@ -651,6 +663,25 @@ enum Act {
Menu(Target, Action),
}
/// A definition's icon in the tree, by what it is: thermal boundary conditions and loads carry
/// the heat icon (a temperature is no support), and a boundary condition that moves the part
/// (a non-zero prescribed displacement) its kind's icon with a motion arrow (`true`).
fn item_icon(m: &Model, kind: Def, name: &str) -> (&'static str, bool) {
const HEAT: &str = "model.step.heat";
match kind {
Def::BoundaryCondition => match m.boundary_condition(name) {
Some(bc) if bc.is_thermal() => (HEAT, false),
Some(bc) => (kind.icon(), bc.drives()),
None => (kind.icon(), false),
},
Def::Load => match m.loads().get(name) {
Some(l) if l.is_thermal() || matches!(l, fieldcraft_model::Load::Temperature { .. }) => (HEAT, false),
_ => (kind.icon(), false),
},
_ => (kind.icon(), false),
}
}
/// A seed in a few words: `8 exact`, `2.5 min, bias 4 end`.
fn seed_note(s: &fieldcraft_model::Seed) -> String {
let what = match s.divisions {
@@ -936,7 +967,8 @@ fn rows(app: &mut ModelApp, ui: &mut egui::Ui, acts: &mut Vec<Act>) {
if open(app, key) {
for (kind, name) in items {
let id = egui::Id::new(("fc_row_def", kind.stem(), &name));
let row = Row { depth: 2, icon: Some(kind.icon()), label: &name, ..Default::default() };
let (icon, moves) = item_icon(&app.session.model, kind, &name);
let row = Row { depth: 2, icon: Some(icon), moves, label: &name, ..Default::default() };
item_row(app, ui, id, row, Target::Def(kind, name.clone()), acts);
}
}
@@ -1055,6 +1087,20 @@ mod tests {
assert_eq!(v[2], json!({"node": "sets", "state": "done", "tip": "Done", "add": []}));
}
/// A prescribed displacement and a temperature read apart from a support in the tree (#354,
/// #355).
#[test]
fn driving_and_thermal_conditions_have_their_own_icons() {
let mut s = Session::default();
s.run_script(&fieldcraft_engine::sample::rubber_seal()).unwrap();
assert_eq!(item_icon(&s.model, Def::BoundaryCondition, "Press"), ("model.boundary_condition.create", true));
assert_eq!(item_icon(&s.model, Def::BoundaryCondition, "Base"), ("model.boundary_condition.create", false));
let mut s = Session::default();
s.execute("file.sample", &json!({"name": "heated-bracket"})).unwrap();
assert_eq!(item_icon(&s.model, Def::BoundaryCondition, "Heater"), ("model.step.heat", false));
assert_eq!(item_icon(&s.model, Def::Load, "Air").0, "model.step.heat");
}
#[test]
fn definitions_read_back_as_their_parameters() {
let mut app = ModelApp::new(Session::default());