Files
OneCAD-Simulation/crates/render/src/glyphs.rs
T

1801 lines
86 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Symbols for a model's analysis setup, drawn over the model by the desktop viewport and the
//! headless renderer alike, so both show the same thing. Every symbol is a wireframe: thin lines
//! and dots, no shaded faces, so it never hides the mesh or a contour plot under it.
//!
//! - **Boundary conditions:** a wire triangle per constrained translation, tip on the node,
//! pointing along that axis from outside the part (where the set's faces tell which side that
//! is); a constrained rotation adds a second, smaller triangle behind the first (the double
//! mark), only where the node can rotate (shell and beam nodes, reference points). Fixed shows
//! all three axes; a symmetry condition only the axis normal to its plane. Thermal conditions
//! are dots.
//! - **Forces:** straight arrows with an open head, ending on their nodes. **Moments:** an axis
//! line through the node and a curved arrow three quarters of the way around it, turning
//! right-handed about the moment vector.
//! - **Pressures:** arrows normal to the loaded faces (onto them for a positive pressure, away for
//! a suction); **tractions:** arrows onto the faces along the traction. 2D models get them on
//! the loaded edges, in the plane.
//! - **Gravity, body forces and centrifugal loads:** one arrow beside the model (a centrifugal
//! load: its axis and a curved arrow around it). **Initial velocities:** one arrow behind the
//! moving set, along its motion.
//! - **Reference points:** a three-axis cross with the point's name; **couplings:** thin lines
//! from the point to some of the coupled nodes; **contact and tie surfaces:** small squares on
//! their faces; **rigid walls:** a hatched square.
//!
//! **Size on screen.** Symbols keep a constant size on screen whatever the zoom: [`Glyphs::draw`]
//! takes the symbol unit (a support triangle's length) in model length, which the caller works
//! out from the camera with [`symbol_unit`] (a size in pixels, [`DEFAULT_SIZE`] unless the user
//! chose another, times the model length a pixel spans). Line widths and dots are fixed in pixels
//! too. Loads are drawn in the same unit as supports, so the two stay in proportion.
//!
//! **Density.** Where a definition covers many nodes or faces, its symbols are thinned on a grid
//! a few symbol units across, so they are spread evenly at a density that follows the screen,
//! not the mesh; each definition draws at most [`MAX_PER_DEFINITION`] symbols ([`MAX_SYMBOLS`] in
//! all), and one with more places than that is listed in [`Drawn::notes`], so the viewer can say
//! so.
//!
//! **Depth.** Symbols are lines, depth-tested against the model with the small bias toward the
//! eye that keeps lines on the face they lie on: they show on their own surface and hide behind
//! other parts of the model. A concentrated load (at a reference point or a single node) and a
//! reference point's cross are [`Scene::on_top`], visible from any side.
//!
//! **Colour** is per kind ([`GlyphColors`], from the theme's tokens); the symbols of the
//! definition selected in the browser ([`GlyphView::highlight`]) are drawn in the highlight
//! colour, wider.
//!
//! [`Glyphs::from_model`] does the work that depends on the model only (resolving sets, the
//! places symbols may go); [`Glyphs::draw`] chooses among them for the symbol unit and places
//! them at the nodes' current positions (a results plot's deformed shape) in the given colours.
use std::collections::{BTreeMap, BTreeSet, HashSet};
use fieldcraft_model::{BoundaryCondition, ContactSurfaces, ElementFace, Load, Model, Procedure, ResolvedSet, Target};
use crate::display::{centre, polygon_normal};
use crate::{Aabb3, Rgb, Scene, SceneFill, SceneLabel, SceneLine, ScenePoint, SceneTri, Vec3};
/// The symbol unit drawn when the caller gives none: a fraction of the model's bounding box
/// diagonal.
const UNIT_FRACTION: f64 = 0.025;
/// A symbol unit's default size on screen, in pixels (points on the desktop).
pub const DEFAULT_SIZE: f64 = 14.0;
/// The symbol sizes one may choose, in pixels.
pub const SIZE_RANGE: (f64, f64) = (6.0, 48.0);
/// Most symbols one definition draws.
pub const MAX_PER_DEFINITION: usize = 400;
/// Most symbols drawn in all.
pub const MAX_SYMBOLS: usize = 6000;
/// Most lines one coupling draws to its nodes.
const MAX_COUPLING_LINES: usize = 24;
/// How far symbols over a results plot fade toward the background ([`GlyphColors::over_results`]).
pub const MUTED: f64 = 0.55;
/// Most nodes or faces of one set looked at when choosing where symbols go (a larger set is
/// strided down to this first).
const MAX_SCAN: usize = 200_000;
/// Segments of a curved arrow.
const ARC_SEGMENTS: usize = 18;
/// Steps a symbol unit is rounded to per doubling ([`quantise_unit`]).
const UNIT_STEPS: f64 = 4.0;
/// Symbol line width, and the selected definition's, in output pixels.
const LINE_WIDTH: f64 = 1.5;
const HIGHLIGHT_WIDTH: f64 = 2.5;
/// Datum outline, datum axis and coupling line widths, in output pixels.
const OUTLINE_WIDTH: f64 = 1.5;
const SHAFT_WIDTH: f64 = 2.5;
const COUPLING_WIDTH: f64 = 1.0;
/// Reference point and thermal dot sizes, in output pixels.
const POINT_SIZE: f64 = 8.0;
const THERMAL_SIZE: f64 = 6.0;
/// The colours symbols are drawn in, by kind.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GlyphColors {
/// Boundary conditions and rigid walls.
pub constraint: Rgb,
/// Forces, moments, pressures, body loads and initial velocities.
pub load: Rgb,
/// Reference points and couplings.
pub interaction: Rgb,
/// Contact and tie surfaces.
pub contact: Rgb,
/// Temperatures, heat fluxes, convection and heat sources.
pub thermal: Rgb,
/// Reference point names.
pub label: Rgb,
/// Datum planes and axes.
pub datum: Rgb,
/// The symbols of the definition selected in the browser.
pub highlight: Rgb,
}
impl GlyphColors {
/// The colours moved a fraction `k` (0..=1) of the way to `toward`: symbols that stay out
/// of the way (over a results plot). The highlight stays as it is.
pub fn muted(self, toward: Rgb, k: f64) -> GlyphColors {
let k = if k.is_finite() { k.clamp(0.0, 1.0) } else { 0.0 };
let mix = |c: Rgb| {
let m = |a: u8, b: u8| (f64::from(a) + (f64::from(b) - f64::from(a)) * k).round().clamp(0.0, 255.0) as u8;
Rgb(m(c.0, toward.0), m(c.1, toward.1), m(c.2, toward.2))
};
GlyphColors {
constraint: mix(self.constraint),
load: mix(self.load),
interaction: mix(self.interaction),
contact: mix(self.contact),
thermal: mix(self.thermal),
label: mix(self.label),
datum: mix(self.datum),
highlight: self.highlight,
}
}
/// The colours over a results plot drawn on `background`: [`MUTED`] of the way to it.
pub fn over_results(self, background: Rgb) -> GlyphColors {
self.muted(background, MUTED)
}
}
/// Which groups of symbols are drawn (the browser's eye toggles).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct GlyphShow {
/// Boundary conditions, loads and initial velocities.
pub loads: bool,
/// Reference points, couplings, contact, ties and rigid walls.
pub interactions: bool,
}
impl GlyphShow {
pub const ALL: GlyphShow = GlyphShow { loads: true, interactions: true };
/// Whether a symbol of the Interactions group (`interaction`), or else of Loads & BCs, is shown.
fn allows(self, interaction: bool) -> bool {
if interaction { self.interactions } else { self.loads }
}
}
/// The kind of definition a symbol draws (with its name, what the browser selects).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Definition {
BoundaryCondition,
Load,
ReferencePoint,
Coupling,
Contact,
Tie,
/// A step's initial velocities and rigid walls.
Step,
}
/// How [`Glyphs::draw`] draws: what is shown, in which colours, at which size, and which
/// definition is highlighted.
#[derive(Clone, Copy, Debug)]
pub struct GlyphView<'a> {
pub show: GlyphShow,
pub colors: &'a GlyphColors,
/// One symbol unit (a support triangle's length) in model length, from [`symbol_unit`];
/// when it is not a positive finite number, the model's own unit
/// ([`Glyphs::model_unit`]).
pub unit: f64,
/// The definition selected in the browser.
pub highlight: Option<(Definition, &'a str)>,
}
/// What [`Glyphs::draw`] drew.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Drawn {
/// The box around the symbols, for framing and clipping.
pub bounds: Aabb3,
/// Definitions drawn with a sample of their symbols: `Clamp: 400 of 12000 nodes`.
pub notes: Vec<String>,
}
/// The symbol unit for symbols `size` pixels across where one pixel spans `length_per_pixel`
/// of model length ([`crate::Camera::length_per_pixel`]); the size is held to [`SIZE_RANGE`]
/// ([`DEFAULT_SIZE`] when it is not a number). `None` when there is no such unit.
pub fn symbol_unit(length_per_pixel: f64, size: f64) -> Option<f64> {
let size = if size.is_finite() { size.clamp(SIZE_RANGE.0, SIZE_RANGE.1) } else { DEFAULT_SIZE };
let unit = length_per_pixel * size;
(unit.is_finite() && unit > 0.0).then_some(unit)
}
/// The symbol unit for symbols `size_pt` points across in a view `height_px` pixels high at
/// `pixels_per_point` (1 for a headless image, the display's scale for the desktop), seen with
/// `cam`: the one helper the desktop, `ui.render` and headless renders share, so a render at
/// device resolution matches the window. A scale that is not a positive number counts as 1.
pub fn unit_for(cam: &crate::Camera, height_px: f64, size_pt: f64, pixels_per_point: f64) -> Option<f64> {
let ppp = if pixels_per_point.is_finite() && pixels_per_point > 0.0 { pixels_per_point } else { 1.0 };
symbol_unit(cam.length_per_pixel(height_px)? * ppp, size_pt)
}
/// A symbol unit rounded to a quarter of a doubling (within 9 %), and the step it is: a viewer
/// rebuilds its symbols only when zooming changes the step.
pub fn quantise_unit(unit: f64) -> Option<(i32, f64)> {
if !(unit.is_finite() && unit > 0.0) {
return None;
}
let step = (unit.log2() * UNIT_STEPS).round().clamp(-4000.0, 4000.0) as i32;
Some((step, (f64::from(step) / UNIT_STEPS).exp2()))
}
/// Where a symbol sits: a mesh node, or a reference point (its own node label when a result
/// moves it, else its coordinates).
#[derive(Clone, Copy, Debug, PartialEq)]
enum Anchor {
Node(u32),
Point { label: u32, xyz: Vec3 },
}
/// The corners of a loaded face (or the ends of a loaded edge), with the outward direction it
/// had on the undeformed model (the solver's convention: a face's right-hand normal, outward
/// for solids; an in-plane edge's normal to the right of its direction).
#[derive(Clone, Copy, Debug, PartialEq)]
struct Face {
corners: [u32; 4],
count: usize,
outward: Vec3,
}
/// A place a node symbol may go: its anchor, where it is on the undeformed model (for
/// thinning), the outward direction of the set's faces there (when it has faces) and, for a
/// constraint, the degrees of freedom held there.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Spot {
at: Anchor,
p: Vec3,
normal: Option<Vec3>,
dofs: u8,
}
/// What a group of symbols draws at each of its places.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Shape {
/// The constrained translations (bits 0..3) and rotations (bits 3..6) of each spot.
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,
},
/// A traction along a unit direction.
Traction(Vec3),
/// A contact or tie surface.
Surface,
}
/// The symbols of one definition at many places, thinned when drawn.
#[derive(Clone, Debug, PartialEq)]
struct Group {
owner: usize,
shape: Shape,
/// Node places (or face places, for face shapes), all of them up to [`MAX_SCAN`].
nodes: Vec<Spot>,
faces: Vec<(Face, Vec3)>,
/// How many places the definition has.
total: usize,
/// The thinning grid's cell, in symbol units.
cell: f64,
/// A load at one place, or anything at a reference point: concentrated, drawn over the part
/// (#78).
on_top: bool,
}
/// A symbol drawn once.
#[derive(Clone, Debug, PartialEq)]
enum Mark {
/// A fixed arrow over the model (gravity, body forces): the `slot`th beside `base`.
Free {
base: Vec3,
slot: usize,
dir: Vec3,
},
/// An initial velocity: an arrow along `dir` ending short of a set centred at `centre`
/// reaching `reach` toward it.
Velocity {
centre: Vec3,
reach: f64,
dir: Vec3,
},
/// A centrifugal load: its axis through `centre` and a curved arrow around it.
Spin {
centre: Vec3,
axis: Vec3,
half_length: f64,
},
ReferencePoint {
at: Anchor,
name: String,
},
CouplingLine {
from: Anchor,
to: u32,
},
/// A rigid wall: a square through `point` normal to `normal`, backing a set of half
/// diagonal `reach` (none: a small square), hatched on the side away from `normal`.
Wall {
point: Vec3,
normal: Vec3,
reach: Option<f64>,
},
}
impl Mark {
/// Whether the symbol belongs to the Interactions group (else Loads & BCs).
fn interaction(&self) -> bool {
matches!(self, Mark::ReferencePoint { .. } | Mark::CouplingLine { .. } | Mark::Wall { .. })
}
}
/// The symbols of a model's analysis setup, ready to draw.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Glyphs {
/// The definitions drawn: (kind, name).
owners: Vec<(Definition, String)>,
groups: Vec<Group>,
marks: Vec<(usize, Mark)>,
/// The model's own symbol unit ([`UNIT_FRACTION`] of its diagonal).
unit: f64,
}
/// Every element of a long list, strided down to about [`MAX_SCAN`] of them.
fn scan<T>(items: &[T]) -> impl Iterator<Item = &T> {
items.iter().step_by(items.len().div_ceil(MAX_SCAN).max(1))
}
/// The nodes that can rotate: shell and beam nodes (not those of 2D sections: plane stress,
/// plane strain, axisymmetric), as the model check counts them.
fn rotating_nodes(model: &Model) -> BTreeSet<u32> {
let planar: BTreeSet<u32> = model
.sections()
.values()
.filter(|s| s.kind.extent().is_some())
.filter_map(|s| model.resolve_set_partial(&s.set))
.flat_map(|r| r.elements)
.collect();
model.rotational_nodes(|l| planar.contains(&l))
}
/// The corner node labels of an element face, in the face's order.
fn face_corners(model: &Model, f: ElementFace) -> Option<Vec<u32>> {
let kind = model.element(f.element)?.kind;
let local = kind.faces().get(usize::from(f.face).checked_sub(1)?)?;
let corners = local.iter().filter(|&&i| i < kind.corner_count()).count();
let mut labels = model.face_nodes(f)?;
labels.truncate(corners);
Some(labels)
}
/// A node's coordinates, when finite.
fn xyz(model: &Model, label: u32) -> Option<Vec3> {
model.node(label).map(|n| Vec3::from(n.xyz)).filter(|p| p.is_finite())
}
/// Two unit vectors perpendicular to `a` and to each other.
fn perpendiculars(a: Vec3) -> (Vec3, Vec3) {
let helper = if a.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
let u = a.cross(helper).normalized().unwrap_or(Vec3::Y);
let v = a.cross(u).normalized().unwrap_or(Vec3::Z);
(u, v)
}
/// Thin `items` at their positions to one per grid cell of side `cell` (the first in each),
/// then stride them down to `cap`.
fn thin<T: Copy>(items: impl IntoIterator<Item = (T, Vec3)>, cell: f64, cap: usize) -> Vec<T> {
let cell = if cell.is_finite() && cell > 0.0 { cell } else { 1.0 };
let key = |v: f64| (v / cell).floor().clamp(-1e15, 1e15) as i64;
let mut seen = HashSet::with_capacity(cap.saturating_mul(4).min(MAX_SCAN));
let mut kept: Vec<T> = Vec::new();
for (item, p) in items {
if p.is_finite() && seen.insert((key(p.x), key(p.y), key(p.z))) {
kept.push(item);
}
}
if kept.len() > cap {
let stride = kept.len().div_ceil(cap.max(1));
kept = kept.into_iter().step_by(stride).take(cap).collect();
}
kept
}
/// What [`Glyphs::from_model`] reads, with the sets resolved once each.
struct Builder<'m> {
model: &'m Model,
sets: BTreeMap<String, ResolvedSet>,
/// The nodes that can rotate, worked out when a constraint needs them.
rotating: Option<BTreeSet<u32>>,
bounds: Aabb3,
owners: Vec<(Definition, String)>,
groups: Vec<Group>,
marks: Vec<(usize, Mark)>,
/// Free arrows placed so far (each beside the last).
free: usize,
}
impl Builder<'_> {
fn set(&mut self, name: &str) -> Option<&ResolvedSet> {
if !self.sets.contains_key(name) {
let r = self.model.resolve_set_partial(name)?;
self.sets.insert(name.to_string(), r);
}
self.sets.get(name)
}
fn owner(&mut self, def: Definition, name: &str) -> usize {
self.owners.push((def, name.to_string()));
self.owners.len() - 1
}
fn group(&mut self, owner: usize, shape: Shape, nodes: Vec<Spot>, faces: Vec<(Face, Vec3)>, total: usize, cell: f64) {
if !(nodes.is_empty() && faces.is_empty()) {
let concentrated =
matches!(shape, Shape::Force(_) | Shape::Moment(_)) || matches!(nodes.first(), Some(Spot { at: Anchor::Point { .. }, .. }));
let on_top = total == 1 && faces.is_empty() && concentrated;
self.groups.push(Group { owner, shape, nodes, faces, total, cell, on_top });
}
}
/// A set's nodes (a sample of a huge one) with their coordinates, and how many it has.
fn node_points(&mut self, set: &str) -> (Vec<(u32, Vec3)>, usize) {
let model = self.model;
let Some(r) = self.set(set) else { return (Vec::new(), 0) };
(scan(&r.nodes).filter_map(|&n| Some((n, xyz(model, n)?))).collect(), r.nodes.len())
}
/// The places of a target (a sample of a huge set) and how many it has; a set's nodes carry
/// the outward direction of its faces there, when it has faces.
fn spots(&mut self, target: &Target) -> (Vec<Spot>, usize) {
match target {
Target::ReferencePoint(r) => {
let spots: Vec<Spot> = self
.model
.reference_point(r)
.map(|p| {
let xyz = Vec3::from(p.xyz);
Spot { at: Anchor::Point { label: p.label, xyz }, p: xyz, normal: None, dofs: 0 }
})
.into_iter()
.collect();
let n = spots.len();
(spots, n)
}
Target::Set(s) => {
let model = self.model;
let (at, total) = self.node_points(s);
let mut normals: BTreeMap<u32, Vec3> = BTreeMap::new();
if let Some(r) = self.set(s) {
for &f in scan(&r.faces) {
let Some(labels) = face_corners(model, f) else { continue };
let Some(p) = labels.iter().map(|&n| xyz(model, n)).collect::<Option<Vec<_>>>() else { continue };
let Some(n) = polygon_normal(&p) else { continue };
for l in labels {
let sum = normals.entry(l).or_insert(Vec3::ZERO);
*sum = *sum + n;
}
}
}
let spots = at
.into_iter()
.map(|(n, p)| Spot { at: Anchor::Node(n), p, normal: normals.get(&n).and_then(|v| v.normalized()), dofs: 0 })
.collect();
(spots, total)
}
}
}
/// 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);
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
/// centres, and how many it has.
fn faces(&mut self, set: &str) -> (Vec<(Face, Vec3)>, usize) {
let model = self.model;
let Some(r) = self.set(set) else { return (Vec::new(), 0) };
let face = |labels: &[u32], outward: Vec3| {
let mut corners = [0u32; 4];
for (slot, l) in corners.iter_mut().zip(labels) {
*slot = *l;
}
Face { corners, count: labels.len().min(4), outward }
};
let corners_at = |labels: &[u32]| -> Option<Vec<Vec3>> { labels.iter().map(|&n| xyz(model, n)).collect() };
let mut found: Vec<(Face, Vec3)> = Vec::new();
for &f in scan(&r.faces) {
let Some(labels) = face_corners(model, f) else { continue };
let Some(p) = corners_at(&labels) else { continue };
if let Some(n) = polygon_normal(&p) {
found.push((face(&labels, n), centre(&p)));
}
}
if !found.is_empty() {
return (found, r.faces.len());
}
// A 2D model's loaded edges (in the XY plane).
for &g in scan(&r.edges) {
if model.element(g.element).is_none_or(|e| e.kind.dimension() != 2) {
continue;
}
let Some(labels) = model.edge_nodes(g) else { continue };
let labels = labels.get(..2).unwrap_or(&[]);
let Some(p) = corners_at(labels) else { continue };
let (Some(a), Some(b)) = (p.first(), p.get(1)) else { continue };
let t = *b - *a;
if let Some(n) = Vec3::new(t.y, -t.x, 0.0).normalized() {
found.push((face(labels, n), centre(&p)));
}
}
(found, r.edges.len())
}
/// The bounding box of a set's nodes.
fn set_bounds(&mut self, set: &str) -> Aabb3 {
let mut b = Aabb3::EMPTY;
for (_, p) in self.node_points(set).0 {
b.add(p);
}
b
}
/// A fixed arrow over the model (gravity, body forces).
fn free_arrow(&mut self, owner: usize, dir: Vec3) {
let Some(dir) = dir.normalized() else { return };
let b = self.bounds;
// Over the model's middle, in front of it, each one beside the last.
let base = Vec3::new(b.center().x, b.min.y, b.max.z);
self.marks.push((owner, Mark::Free { base, slot: self.free, dir }));
self.free += 1;
}
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(), 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;
}
if dofs == 0 {
return;
}
let (spots, total) = self.spots(bc.target());
if dofs & 0b111_000 != 0 && self.rotating.is_none() {
self.rotating = Some(rotating_nodes(self.model));
}
let none = BTreeSet::new();
let rotating = self.rotating.as_ref().unwrap_or(&none);
let spots = spots
.into_iter()
.filter_map(|s| {
let rotates = match s.at {
Anchor::Node(n) => rotating.contains(&n),
Anchor::Point { .. } => true,
};
let dofs = if rotates { dofs } else { dofs & 0b000_111 };
(dofs != 0).then_some(Spot { dofs, ..s })
})
.collect();
self.group(owner, Shape::Constraint, spots, Vec::new(), total, 2.4);
}
fn load(&mut self, name: &str, load: &Load) {
let owner = self.owner(Definition::Load, name);
match load {
Load::Force { target, vector, .. } | Load::Moment { target, vector, .. } => {
let Some(dir) = Vec3::from(*vector).normalized() else { return };
let (shape, cell) = if matches!(load, Load::Moment { .. }) { (Shape::Moment(dir), 3.5) } else { (Shape::Force(dir), 2.8) };
let (spots, total) = self.spots(target);
self.group(owner, shape, spots, Vec::new(), total, cell);
}
Load::Pressure { set, magnitude } => {
if !magnitude.is_finite() || *magnitude == 0.0 {
return;
}
let (faces, total) = self.faces(set);
self.group(owner, Shape::Pressure { onto: *magnitude > 0.0 }, Vec::new(), faces, total, 1.8);
}
Load::Traction { set, vector } => {
let Some(dir) = Vec3::from(*vector).normalized() else { return };
let (faces, total) = self.faces(set);
self.group(owner, Shape::Traction(dir), Vec::new(), faces, total, 1.8);
}
Load::Gravity { acceleration, .. } => self.free_arrow(owner, Vec3::from(*acceleration)),
Load::BodyForce { vector, .. } => self.free_arrow(owner, Vec3::from(*vector)),
Load::Centrifugal { point, axis, .. } => {
let (Some(axis), point) = (Vec3::from(*axis).normalized(), Vec3::from(*point)) else { return };
if !point.is_finite() {
return;
}
// The axis through the model's middle, as long as the model.
let c = self.bounds.center();
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, 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),
}
}
/// Squares on the faces of contact and tie surfaces.
fn surfaces(&mut self) {
let model = self.model;
let contacts = model.contacts().iter().map(|(name, c)| {
let sets: Vec<String> = match &c.surfaces {
ContactSurfaces::Pair { main, secondary } => vec![main.clone(), secondary.clone()],
ContactSurfaces::General { surface } => surface.iter().cloned().collect(),
};
(Definition::Contact, name, sets)
});
let ties = model.ties().iter().map(|(name, t)| (Definition::Tie, name, vec![t.main.clone(), t.secondary.clone()]));
for (def, name, sets) in contacts.chain(ties).collect::<Vec<_>>() {
let owner = self.owner(def, name);
for set in sets {
let (faces, total) = self.faces(&set);
self.group(owner, Shape::Surface, Vec::new(), faces, total, 2.5);
}
}
}
fn interactions(&mut self, unit: f64) {
let model = self.model;
for (name, p) in model.reference_points() {
let xyz = Vec3::from(p.xyz);
if xyz.is_finite() {
let owner = self.owner(Definition::ReferencePoint, name);
self.marks.push((owner, Mark::ReferencePoint { at: Anchor::Point { label: p.label, xyz }, name: name.clone() }));
}
}
for (name, c) in model.couplings() {
let Some(p) = model.reference_point(&c.reference_point) else { continue };
let owner = self.owner(Definition::Coupling, name);
let from = Anchor::Point { label: p.label, xyz: Vec3::from(p.xyz) };
let (at, _) = self.node_points(&c.set);
// Spread over the set: a grid of about four cells across its extent.
let mut b = Aabb3::EMPTY;
for (_, q) in &at {
b.add(*q);
}
let cell = (b.diagonal() / 4.0).max(unit);
for to in thin(at, cell, MAX_COUPLING_LINES) {
self.marks.push((owner, Mark::CouplingLine { from, to }));
}
}
}
fn steps(&mut self) {
let model = self.model;
for step in model.steps() {
let Procedure::ExplicitDynamic(e) = &step.procedure else { continue };
let owner = self.owner(Definition::Step, &step.name);
for v in &e.initial_velocities {
let Some(dir) = Vec3::from(v.velocity).normalized() else { continue };
let b = self.set_bounds(&v.set);
if b.is_empty() {
continue;
}
// Behind the set, so it clears what the set moves toward (a rigid wall).
let half = (b.max - b.min) * 0.5;
let reach = (dir.x * half.x).abs() + (dir.y * half.y).abs() + (dir.z * half.z).abs();
self.marks.push((owner, Mark::Velocity { centre: b.center(), reach, dir }));
}
for w in &e.rigid_walls {
let (Some(normal), point) = (Vec3::from(w.normal).normalized(), Vec3::from(w.point)) else { continue };
if !point.is_finite() {
continue;
}
// Behind the set's nodes and wide enough to back them.
let b = self.set_bounds(&w.set);
let (point, reach) = if b.is_empty() {
(point, None)
} else {
let c = b.center();
(c - normal * normal.dot(c - point), Some(0.5 * b.diagonal()))
};
self.marks.push((owner, Mark::Wall { point, normal, reach }));
}
}
}
}
impl Glyphs {
/// The symbols of `model`'s boundary conditions, loads, contact and tie surfaces, reference
/// points, couplings and explicit steps' initial velocities and rigid walls.
pub fn from_model(model: &Model) -> Glyphs {
let mut bounds = Aabb3::EMPTY;
for n in model.nodes().values() {
bounds.add(Vec3::from(n.xyz));
}
for p in model.reference_points().values() {
bounds.add(Vec3::from(p.xyz));
}
let d = bounds.diagonal();
let unit = if d.is_finite() && d > 0.0 { d * UNIT_FRACTION } else { 1.0 };
let mut b =
Builder { model, sets: BTreeMap::new(), rotating: None, bounds, owners: Vec::new(), groups: Vec::new(), marks: Vec::new(), free: 0 };
for (name, bc) in model.boundary_conditions() {
b.boundary_condition(name, bc);
}
for (name, load) in model.loads() {
b.load(name, load);
}
b.steps();
b.surfaces();
b.interactions(unit);
b.marks.truncate(MAX_SYMBOLS);
Glyphs { owners: b.owners, groups: b.groups, marks: b.marks, unit }
}
/// [`Glyphs::from_model`] without the definitions `hidden` names (the view's hidden items).
pub fn from_model_hiding(model: &Model, hidden: &dyn Fn(Definition, &str) -> bool) -> Glyphs {
let mut g = Glyphs::from_model(model);
let gone: Vec<bool> = g.owners.iter().map(|(d, n)| hidden(*d, n)).collect();
let kept = |owner: usize| !gone.get(owner).copied().unwrap_or(false);
g.groups.retain(|x| kept(x.owner));
g.marks.retain(|(owner, _)| kept(*owner));
g
}
/// The model's own symbol unit (a fortieth of its bounding box diagonal): the size symbols
/// are drawn at when no camera gives one.
pub fn model_unit(&self) -> f64 {
self.unit
}
/// Draw the symbols into `scene` as `view` says, at the nodes' positions `pos(label)` (the
/// undeformed model, or a results plot's deformed shape); a symbol at a node `pos` does not
/// know is left out (a reference point falls back to its own coordinates).
pub fn draw(&self, pos: impl Fn(u32) -> Option<Vec3>, view: &GlyphView, scene: &mut Scene) -> Drawn {
let at = |a: Anchor| -> Option<Vec3> {
match a {
Anchor::Node(n) => pos(n),
Anchor::Point { label, xyz } => Some(pos(label).unwrap_or(xyz)),
}
.filter(|p| p.is_finite())
};
let u = if view.unit.is_finite() && view.unit > 0.0 { view.unit } else { self.unit };
let colors = view.colors;
let mut d = Draw { scene, bounds: Aabb3::EMPTY, color: colors.load, width: LINE_WIDTH, on_top: false };
let mut notes = Vec::new();
let mut drawn = 0usize;
// Whether an owner is the highlighted definition.
let selected = |owner: usize| view.highlight.is_some_and(|(def, name)| self.owners.get(owner).is_some_and(|(d, n)| *d == def && n == name));
for g in &self.groups {
if !view.show.allows(g.shape == Shape::Surface) {
continue;
}
let color = match g.shape {
Shape::Constraint | Shape::Prescribed(_) => colors.constraint,
Shape::Thermal | Shape::Temperature | Shape::Convection | Shape::HeatFlux => colors.thermal,
Shape::Surface => colors.contact,
_ => colors.load,
};
d.style(color, selected(g.owner), colors.highlight);
let room = MAX_SYMBOLS.saturating_sub(drawn).min(MAX_PER_DEFINITION);
let cell = g.cell * u;
let kept = if g.faces.is_empty() {
let spots = thin(g.nodes.iter().map(|s| (*s, s.p)), cell, room);
d.on_top = g.on_top;
for s in &spots {
let Some(p) = at(s.at) else { continue };
d.node_symbol(g.shape, p, s, u, g.total > 1);
}
d.on_top = false;
spots.len()
} else {
let faces = thin(g.faces.iter().copied(), cell, room);
for f in &faces {
d.face_symbol(g.shape, f, &pos, u);
}
faces.len()
};
drawn += kept;
if kept < g.total && g.total > MAX_PER_DEFINITION {
let name = self.owners.get(g.owner).map_or("?", |(_, n)| n.as_str());
let what = if g.faces.is_empty() { "nodes" } else { "faces" };
notes.push(format!("{name}: {kept} of {} {what}", g.total));
}
}
for (owner, m) in &self.marks {
if !view.show.allows(m.interaction()) || drawn >= MAX_SYMBOLS {
continue;
}
drawn += 1;
let color = match m {
Mark::Wall { .. } => colors.constraint,
Mark::ReferencePoint { .. } | Mark::CouplingLine { .. } => colors.interaction,
_ => colors.load,
};
let picked = selected(*owner);
d.style(color, picked, colors.highlight);
match m {
Mark::Free { base, slot, dir } => {
let middle = *base + Vec3::new(2.5 * *slot as f64, -4.0, 2.0) * u;
// The arrow's middle sits at the spot, whichever way it points.
d.arrow(middle - *dir * (2.5 * u), *dir, 5.0 * u, 1.2 * u);
}
Mark::Velocity { centre, reach, dir } => d.arrow(*centre - *dir * (reach + 6.0 * u), *dir, 5.0 * u, 1.2 * u),
Mark::Spin { centre: c, axis, half_length } => {
let half = half_length.max(4.0 * u);
d.line(*c - *axis * half, *c + *axis * half);
d.curl(*c + *axis * (half * 0.8), *axis, 2.0 * u, u);
}
Mark::ReferencePoint { at: a, name } => {
let Some(p) = at(*a) else { continue };
d.on_top = true;
for axis in [Vec3::X, Vec3::Y, Vec3::Z] {
d.line(p - axis * (0.9 * u), p + axis * (0.9 * u));
}
// A dot: a zero-length line drawn round.
let width = d.width;
d.width = POINT_SIZE;
d.line(p, p);
(d.width, d.on_top) = (width, false);
let label = if picked { colors.highlight } else { colors.label };
d.scene.labels.push(SceneLabel { p, text: name.clone(), color: label });
}
Mark::CouplingLine { from, to } => {
if let (Some(a), Some(b)) = (at(*from), pos(*to)) {
if !picked {
d.width = COUPLING_WIDTH;
}
d.line(a, b);
}
}
Mark::Wall { point, normal, reach } => {
let half = reach.map_or(4.0 * u, |r| (r + u).clamp(2.0 * u, 40.0 * u));
d.wall(*point, *normal, half, u, picked);
}
}
}
Drawn { bounds: d.bounds, notes }
}
}
/// The margin around a datum plane's rectangle and beyond a datum axis's ends, as a fraction
/// of the model's extent drawn.
const DATUM_MARGIN: f64 = 0.05;
/// A datum point's cross: each arm's length, as a fraction of the bounding box diagonal.
const DATUM_POINT: f64 = 0.02;
/// A datum plane's fill: how opaque its translucent rectangle is.
pub const DATUM_FILL_ALPHA: f64 = 0.12;
/// Corner `k` (0..8, one bit per axis) of box `b`.
fn box_corner(b: &Aabb3, k: usize) -> Vec3 {
let pick = |bit: usize, lo: f64, hi: f64| if k & bit == 0 { lo } else { hi };
Vec3::new(pick(1, b.min.x, b.max.x), pick(2, b.min.y, b.max.y), pick(4, b.min.z, b.max.z))
}
/// The rectangle datum plane (`p`, unit normal `n`) is drawn as, in its own axes `(u, v)`
/// ([`perpendiculars`]): the model's extent within the plane (where the plane cuts `bounds`;
/// the box's shadow on the plane when it misses it) with a [`DATUM_MARGIN`] around it. Returns
/// the rectangle's centre on the plane and its half sizes along `u` and `v`.
fn plane_rect(bounds: &Aabb3, p: Vec3, n: Vec3, u: Vec3, v: Vec3) -> (Vec3, f64, f64) {
if bounds.is_empty() || !bounds.diagonal().is_finite() {
return (p, 1.0, 1.0);
}
let (lo, hi) = (bounds.min, bounds.max);
let corner =
|k: usize| Vec3::new(if k & 1 == 0 { lo.x } else { hi.x }, if k & 2 == 0 { lo.y } else { hi.y }, if k & 4 == 0 { lo.z } else { hi.z });
let height = |q: Vec3| (q - p).dot(n);
// The box's twelve edges join corners differing in one coordinate bit.
let mut cut: Vec<Vec3> = Vec::with_capacity(12);
for a in 0..8usize {
for bit in [1usize, 2, 4].into_iter().filter(|bit| a & bit == 0) {
let b = a | bit;
let (qa, qb) = (corner(a), corner(b));
let (ha, hb) = (height(qa), height(qb));
if (ha <= 0.0 && hb >= 0.0) || (ha >= 0.0 && hb <= 0.0) {
let t = if (ha - hb).abs() > 0.0 { ha / (ha - hb) } else { 0.0 };
cut.push(qa + (qb - qa) * t);
}
}
}
if cut.is_empty() {
cut = (0..8).map(|k| corner(k) - n * height(corner(k))).collect();
}
let (mut u0, mut u1, mut v0, mut v1) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
for q in &cut {
let (a, b) = ((*q - p).dot(u), (*q - p).dot(v));
(u0, u1, v0, v1) = (u0.min(a), u1.max(a), v0.min(b), v1.max(b));
}
let size = (u1 - u0).max(v1 - v0);
let margin = if size > 0.0 { DATUM_MARGIN * size } else { DATUM_MARGIN * bounds.diagonal().max(1e-9) };
let centre = p + u * ((u0 + u1) * 0.5) + v * ((v0 + v1) * 0.5);
(centre, (u1 - u0) * 0.5 + margin, (v1 - v0) * 0.5 + margin)
}
/// The ends datum axis (`p`, unit direction `a`) is drawn between: the model's extent along it
/// (`bounds`' corners projected on it) with a [`DATUM_MARGIN`] beyond each end.
fn axis_ends(bounds: &Aabb3, p: Vec3, a: Vec3) -> (Vec3, Vec3) {
if bounds.is_empty() || !bounds.diagonal().is_finite() {
return (p - a, p + a);
}
let (mut t0, mut t1) = (f64::INFINITY, f64::NEG_INFINITY);
for k in 0..8usize {
let t = (box_corner(bounds, k) - p).dot(a);
(t0, t1) = (t0.min(t), t1.max(t));
}
let span = t1 - t0;
let margin = if span > 0.0 { DATUM_MARGIN * span } else { DATUM_MARGIN * bounds.diagonal().max(1e-9) };
(p + a * (t0 - margin), p + a * (t1 + margin))
}
/// Draw the model's datum planes (an outlined rectangle with a light translucent fill: the model's extent
/// within the plane, `bounds`, plus a margin), axes (a line along the model's extent) and points
/// (a small cross), each with its name; returns the box around them. Only the datums `shown`
/// takes are drawn, and `highlight` (the one selected in the browser) whether shown or not, in
/// the highlight colour.
pub fn datums(
model: &Model,
bounds: &Aabb3,
shown: &dyn Fn(&str) -> bool,
highlight: Option<&str>,
colors: &GlyphColors,
scene: &mut Scene,
) -> Aabb3 {
let mut d = Draw { scene, bounds: Aabb3::EMPTY, color: colors.datum, width: OUTLINE_WIDTH, on_top: false };
let diagonal = if bounds.is_empty() { 1.0 } else { bounds.diagonal().max(1e-9) };
// A suppressed datum is out of the model: drawn only while selected in the browser.
let drawn = |n: &String| (shown(n) && !model.datum_suppressed(n)) || highlight == Some(n.as_str());
for (name, datum) in model.datums().iter().filter(|(n, _)| drawn(n)) {
let picked = highlight == Some(name.as_str());
let color = if picked { colors.highlight } else { colors.datum };
let outline = if picked { HIGHLIGHT_WIDTH } else { OUTLINE_WIDTH };
// The one selected in the browser shows through the part.
(d.color, d.on_top) = (color, picked);
match *datum {
fieldcraft_model::Datum::Plane { point, normal } => {
let (p, n) = (Vec3::from(point), Vec3::from(normal));
let (u, v) = perpendiculars(n);
let (c, hu, hv) = plane_rect(bounds, p, n, u, v);
let corner = |a: f64, b: f64| c + u * (a * hu) + v * (b * hv);
let ring = [corner(-1.0, -1.0), corner(1.0, -1.0), corner(1.0, 1.0), corner(-1.0, 1.0)];
d.width = outline;
for (k, a) in ring.iter().enumerate() {
let b = ring.get((k + 1) % 4).copied().unwrap_or(*a);
d.line(*a, b);
}
let [a, b, c2, e] = ring;
for p in [[a, b, c2], [a, c2, e]] {
d.scene.fills.push(SceneFill { tri: SceneTri { p, normal: n, color }, alpha: DATUM_FILL_ALPHA });
}
d.scene.labels.push(SceneLabel { p: corner(1.0, 1.0), text: name.clone(), color });
}
fieldcraft_model::Datum::Axis { point, direction } => {
let (a, b) = axis_ends(bounds, Vec3::from(point), Vec3::from(direction));
d.width = if picked { SHAFT_WIDTH + 1.0 } else { SHAFT_WIDTH };
d.line(a, b);
d.scene.labels.push(SceneLabel { p: b, text: name.clone(), color });
}
fieldcraft_model::Datum::Point { point } => {
let p = Vec3::from(point);
let arm = DATUM_POINT * diagonal;
d.width = outline;
for axis in [Vec3::X, Vec3::Y, Vec3::Z] {
d.line(p - axis * arm, p + axis * arm);
}
d.scene.labels.push(SceneLabel { p: p + (Vec3::X + Vec3::Y) * arm, text: name.clone(), color });
}
}
}
d.bounds
}
/// Symbol primitives into a scene, in the current colour and width, depth-tested or on top.
struct Draw<'s> {
scene: &'s mut Scene,
bounds: Aabb3,
color: Rgb,
width: f64,
on_top: bool,
}
impl Draw<'_> {
/// Draw in `color` at the line width, or in `highlight`, wider, when `selected`.
fn style(&mut self, color: Rgb, selected: bool, highlight: Rgb) {
(self.color, self.width) = if selected { (highlight, HIGHLIGHT_WIDTH) } else { (color, LINE_WIDTH) };
}
fn point(&mut self, p: Vec3, size: f64) {
self.bounds.add(p);
self.scene.points.push(ScenePoint { p, color: self.color, size });
}
fn line(&mut self, a: Vec3, b: Vec3) {
if a.is_finite() && b.is_finite() {
self.bounds.add(a);
self.bounds.add(b);
let l = SceneLine { a, b, color: self.color, width: self.width };
if self.on_top { self.scene.on_top.push(l) } else { self.scene.lines.push(l) }
}
}
/// Two crossed wire triangles with the tip at `tip`, pointing along unit `dir`, `len` long and
/// `half` across at the base, in the planes of `dir` with `e1` and with `e2`: a triangle seen
/// from any side.
fn triangle(&mut self, tip: Vec3, dir: Vec3, len: f64, half: f64, e1: Vec3, e2: Vec3) {
let base = tip - dir * len;
for e in [e1, e2] {
let (a, b) = (base + e * half, base - e * half);
self.line(tip, a);
self.line(tip, b);
self.line(a, b);
}
}
/// An open arrowhead: four lines back from `tip` against unit `dir`, `len` long.
fn head(&mut self, tip: Vec3, dir: Vec3, len: f64) {
let (e1, e2) = perpendiculars(dir);
let back = tip - dir * len;
for e in [e1, -e1, e2, -e2] {
self.line(tip, back + e * (0.4 * len));
}
}
/// An arrow from `tail` along unit `dir`, `len` long, its open head `head` long.
fn arrow(&mut self, tail: Vec3, dir: Vec3, len: f64, head: f64) {
let tip = tail + dir * len;
self.line(tail, tip);
self.head(tip, dir, head);
}
/// A curved arrow three quarters of the way around unit `axis` at `centre`, `radius` across,
/// turning right-handed about the axis, its head `head` long and tangent at its end.
fn curl(&mut self, centre: Vec3, axis: Vec3, radius: f64, head: f64) {
let (u, v) = perpendiculars(axis);
let at = |a: f64| centre + (u * a.cos() + v * a.sin()) * radius;
let end = 1.5 * std::f64::consts::PI;
for k in 0..ARC_SEGMENTS {
let (a, b) = (end * k as f64 / ARC_SEGMENTS as f64, end * (k + 1) as f64 / ARC_SEGMENTS as f64);
self.line(at(a), at(b));
}
// u × v = axis, so increasing angle turns right-handed about it.
let tangent = (u * -end.sin() + v * end.cos()).normalized().unwrap_or(u);
self.head(at(end), tangent, head);
}
/// The symbol of a node group's shape at `p` (`spread`: one of many, drawn smaller).
fn node_symbol(&mut self, shape: Shape, p: Vec3, s: &Spot, u: f64, spread: bool) {
match shape {
Shape::Constraint => {
let axes = [Vec3::X, Vec3::Y, Vec3::Z];
for (i, axis) in axes.into_iter().enumerate() {
let (t, r) = (s.dofs & (1 << i) != 0, s.dofs & (1 << (i + 3)) != 0);
if !(t || r) {
continue;
}
// From outside the part: pointing against the faces' outward direction
// (along the axis where it lies in the faces).
let dir = match s.normal {
Some(n) if n.dot(axis) > 0.1 => -axis,
_ => axis,
};
let (e1, e2) = (axes[(i + 1) % 3], axes[(i + 2) % 3]);
if t {
self.triangle(p, dir, u, 0.45 * u, e1, e2);
}
if r {
// The double mark: a smaller triangle behind the translation's.
self.triangle(p - dir * u, dir, 0.8 * u, 0.36 * u, e1, e2);
}
}
}
Shape::Force(dir) => {
let (len, head) = if spread { (2.5 * u, 0.8 * u) } else { (4.0 * u, u) };
self.arrow(p - dir * len, dir, len, head);
}
Shape::Moment(dir) => {
let (radius, head) = if spread { (0.8 * u, 0.6 * u) } else { (1.3 * u, 0.8 * u) };
self.line(p - dir * (1.6 * radius), p + dir * (1.6 * radius));
self.curl(p, dir, radius, head);
}
Shape::Thermal => self.point(p, THERMAL_SIZE),
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 => {}
}
}
/// The symbol of a face group's shape on `face` (at its corners' positions `pos`).
fn face_symbol(&mut self, shape: Shape, face: &Face, pos: &impl Fn(u32) -> Option<Vec3>, u: f64) {
let mut p = [Vec3::ZERO; 4];
let corners = face.corners.iter().take(face.count).map(|&n| pos(n));
if face.count == 0 || corners.zip(p.iter_mut()).any(|(q, slot)| q.map(|q| *slot = q).is_none()) {
return;
}
let p = p.get(..face.count).unwrap_or(&[]);
let c = centre(p);
// The face's own normal now (it may be deformed), on the side it pointed.
let outward = if face.count >= 3 { polygon_normal(p).map(|n| if n.dot(face.outward) < 0.0 { -n } else { n }) } else { None };
let outward = outward.unwrap_or(face.outward);
let (len, head) = (2.2 * u, 0.7 * u);
match shape {
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);
let at = |a: f64, b: f64| c + outward * (0.1 * u) + (e1 * a + e2 * b) * (0.35 * u);
let ring = [at(-1.0, -1.0), at(1.0, -1.0), at(1.0, 1.0), at(-1.0, 1.0)];
for (k, a) in ring.iter().enumerate() {
self.line(*a, ring.get((k + 1) % 4).copied().unwrap_or(*a));
}
}
Shape::Constraint | Shape::Force(_) | Shape::Moment(_) | Shape::Thermal | Shape::Temperature | Shape::Prescribed(_) => {}
}
}
/// A rigid wall: a square through `point` normal to `normal`, `half` across, with hatching
/// on its back.
fn wall(&mut self, point: Vec3, normal: Vec3, half: f64, unit: f64, selected: bool) {
let (u, v) = perpendiculars(normal);
let c = |a: f64, b: f64| point + u * (a * half) + v * (b * half);
let corners = [c(-1.0, -1.0), c(1.0, -1.0), c(1.0, 1.0), c(-1.0, 1.0)];
for (k, a) in corners.iter().enumerate() {
self.line(*a, corners.get((k + 1) % 4).copied().unwrap_or(*a));
}
// Slanted ticks behind the square, on a grid about two symbol units apart.
let ticks = ((half / unit).floor() as usize).clamp(2, 20);
let slant = (u + v).normalized().unwrap_or(u);
let width = self.width;
self.width = if selected { width } else { COUPLING_WIDTH };
for i in 0..=ticks {
for j in 0..=ticks {
let at = c(-1.0 + 2.0 * i as f64 / ticks as f64, -1.0 + 2.0 * j as f64 / ticks as f64);
self.line(at, at - normal * (0.8 * unit) + slant * (0.4 * unit));
}
}
self.width = width;
}
}
#[cfg(test)]
mod tests {
use fieldcraft_model::{Coupling, CouplingKind, Dofs, ElementKind, ReferencePoint, Set};
use super::*;
use crate::{Camera, StandardView};
const COLORS: GlyphColors = GlyphColors {
constraint: Rgb(0, 0, 255),
load: Rgb(255, 0, 0),
interaction: Rgb(0, 255, 0),
contact: Rgb(200, 200, 0),
thermal: Rgb(255, 128, 0),
label: Rgb(0, 0, 0),
datum: Rgb(0, 128, 128),
highlight: Rgb(255, 0, 255),
};
/// A row of `n` unit hex8 cells along x.
fn bar(n: u32) -> Model {
let mut m = Model::default();
let id = |i: u32, j: u32, k: u32| 1 + i * 4 + j * 2 + k;
for i in 0..=n {
for j in 0..2 {
for k in 0..2 {
m.set_node(id(i, j, k), [i as f64, j as f64, k as f64]).unwrap();
}
}
}
for i in 0..n {
let nodes = vec![id(i, 0, 0), id(i + 1, 0, 0), id(i + 1, 1, 0), id(i, 1, 0), id(i, 0, 1), id(i + 1, 0, 1), id(i + 1, 1, 1), id(i, 1, 1)];
m.set_element(i + 1, ElementKind::Hex8, nodes).unwrap();
}
m.add_set("root", Set { nodes: vec![1, 2, 3, 4], ..Set::default() }).unwrap();
m.add_set("tip", Set { nodes: (0..4).map(|k| 4 * n + 1 + k).collect(), ..Set::default() }).unwrap();
m
}
fn view(show: GlyphShow, unit: f64) -> GlyphView<'static> {
GlyphView { show, colors: &COLORS, unit, highlight: None }
}
fn drawn_at(g: &Glyphs, m: &Model, v: &GlyphView) -> (Scene, Drawn) {
let mut s = Scene::default();
let d = g.draw(|l| m.node(l).map(|n| Vec3::from(n.xyz)), v, &mut s);
(s, d)
}
fn drawn(g: &Glyphs, m: &Model, show: GlyphShow) -> Scene {
drawn_at(g, m, &view(show, g.model_unit())).0
}
/// The midpoints of the triangles' bases drawn with their tip at `tip` (the lines that do not
/// touch it and whose ends are each joined to it).
fn bases(s: &Scene, tip: Vec3) -> Vec<Vec3> {
let joined = |p: Vec3| s.lines.iter().any(|l| (l.a == tip && l.b.dist(p) < 1e-9) || (l.b == tip && l.a.dist(p) < 1e-9));
s.lines.iter().filter(|l| l.a != tip && l.b != tip && joined(l.a) && joined(l.b)).map(|l| (l.a + l.b) * 0.5).collect()
}
fn near(v: &[Vec3], p: Vec3) -> usize {
v.iter().filter(|q| q.dist(p) < 1e-9).count()
}
#[test]
fn constraints_draw_a_wire_triangle_per_held_degree_of_freedom() {
let u = 0.1;
let mut m = bar(4);
m.add_boundary_condition("Clamp", BoundaryCondition::Fixed { target: Target::Set("root".into()) }).unwrap();
let g = Glyphs::from_model(&m);
assert!((g.model_unit() - m_diag(&m) * UNIT_FRACTION).abs() < 1e-12);
// Solid nodes have no rotations: three crossed triangles (six lines each) on each of the
// four nodes, wireframe only, in the constraint colour.
let (s, _) = drawn_at(&g, &m, &view(GlyphShow::ALL, u));
assert!(s.tris.is_empty() && s.on_top.is_empty());
assert_eq!(s.lines.len(), 4 * 3 * 6);
// A hidden definition draws nothing; hiding another leaves it.
let (hidden, _) =
drawn_at(&Glyphs::from_model_hiding(&m, &|d, n| d == Definition::BoundaryCondition && n == "Clamp"), &m, &view(GlyphShow::ALL, u));
assert!(hidden.lines.is_empty());
let (other, _) = drawn_at(&Glyphs::from_model_hiding(&m, &|d, _| d == Definition::Load), &m, &view(GlyphShow::ALL, u));
assert_eq!(other.lines.len(), s.lines.len());
assert!(s.lines.iter().all(|l| l.color == COLORS.constraint && l.width == LINE_WIDTH));
// Tip on the node, each triangle along its axis (two crossed bases per axis).
let b = bases(&s, Vec3::ZERO);
for axis in [Vec3::X, Vec3::Y, Vec3::Z] {
assert_eq!(near(&b, -axis * u), 2, "{axis:?}: {b:?}");
}
// Hidden with Loads & BCs.
let s = drawn(&g, &m, GlyphShow { loads: false, interactions: true });
assert!(s.lines.is_empty());
// One held translation: one triangle a node, along x.
let mut m = bar(4);
let values = [Some(0.0), None, None, None, None, None];
m.add_boundary_condition("Slide", BoundaryCondition::Displacement { target: Target::Set("root".into()), values }).unwrap();
let (s, _) = drawn_at(&Glyphs::from_model(&m), &m, &view(GlyphShow::ALL, u));
assert_eq!(s.lines.len(), 4 * 6);
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;
let beam = |bc: BoundaryCondition| {
let mut m = Model::default();
m.set_node(1, [0.0; 3]).unwrap();
m.set_node(2, [10.0, 0.0, 0.0]).unwrap();
m.set_element(1, ElementKind::Beam2, vec![1, 2]).unwrap();
m.add_set("end", Set { nodes: vec![1], ..Set::default() }).unwrap();
m.add_boundary_condition("BC", bc).unwrap();
let (s, _) = drawn_at(&Glyphs::from_model(&m), &m, &view(GlyphShow::ALL, u));
s
};
let end = || Target::Set("end".into());
// Encastre on a beam node: three translations and three rotations.
let s = beam(BoundaryCondition::Fixed { target: end() });
assert_eq!(s.lines.len(), 6 * 6);
// The rotation's triangle sits behind the translation's: its tip at the first's base.
assert_eq!(near(&bases(&s, -Vec3::X * u), -Vec3::X * (1.8 * u)), 2);
// A symmetry plane normal to y holds u2, ur1 and ur3, and shows u2 alone.
let s = beam(BoundaryCondition::Symmetry { target: end(), plane: fieldcraft_model::Symmetry::Y });
assert_eq!(s.lines.len(), 6);
assert_eq!(near(&bases(&s, Vec3::ZERO), -Vec3::Y * u), 2);
// A rotation alone keeps its place behind the node, apart from it.
let s = beam(BoundaryCondition::Displacement { target: end(), values: [None, None, None, Some(0.0), None, None] });
assert_eq!(s.lines.len(), 6);
assert!(s.lines.iter().all(|l| l.a.x <= -u + 1e-9 && l.b.x <= -u + 1e-9));
}
/// Supports on a face come at it from outside the part.
#[test]
fn supports_on_faces_point_in_from_outside() {
let u = 0.2;
let mut m = bar(4);
// The end face at x = 4, outward +x.
m.add_set("end", Set { faces: vec![ElementFace { element: 4, face: 4 }], ..Set::default() }).unwrap();
m.add_boundary_condition(
"Hold",
BoundaryCondition::Displacement { target: Target::Set("end".into()), values: [Some(0.0), None, None, None, None, None] },
)
.unwrap();
let (s, _) = drawn_at(&Glyphs::from_model(&m), &m, &view(GlyphShow::ALL, u));
assert_eq!(s.lines.len(), 4 * 6);
assert!(s.lines.iter().all(|l| l.a.x >= 4.0 - 1e-9 && l.b.x >= 4.0 - 1e-9), "every triangle outside x = 4");
}
/// The same symbol measures the same number of pixels on screen at any zoom, orthographic or
/// perspective.
#[test]
fn symbols_keep_their_size_on_screen_at_any_zoom() {
let mut m = bar(4);
m.add_set("one", Set { nodes: vec![1], ..Set::default() }).unwrap();
m.add_boundary_condition(
"Down",
BoundaryCondition::Displacement { target: Target::Set("one".into()), values: [None, None, Some(0.0), None, None, None] },
)
.unwrap();
let g = Glyphs::from_model(&m);
let (w, h) = (800.0, 600.0);
for fov in [0.0, 0.6] {
for distance in [3.0, 30.0, 3000.0] {
let mut cam = Camera { distance, fov, target: Vec3::ZERO, ..Default::default() };
cam.set_view(StandardView::Front);
cam.target = Vec3::ZERO;
let unit = symbol_unit(cam.length_per_pixel(h).unwrap(), DEFAULT_SIZE).unwrap();
let (s, _) = drawn_at(&g, &m, &view(GlyphShow::ALL, unit));
let base = bases(&s, Vec3::ZERO);
let at = |p: Vec3| cam.to_screen(p, w, h, 10.0 * distance).unwrap();
let (a, b) = (at(Vec3::ZERO), at(base[0]));
let px = ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt();
assert!((px - DEFAULT_SIZE).abs() < 0.05 * DEFAULT_SIZE, "fov {fov} distance {distance}: {px} px");
}
}
// The size setting is held to its range; no camera gives no unit.
assert_eq!(symbol_unit(1.0, 1e9), Some(SIZE_RANGE.1));
assert_eq!(symbol_unit(1.0, f64::NAN), Some(DEFAULT_SIZE));
assert_eq!(symbol_unit(f64::INFINITY, 10.0), None);
// A view at twice the pixels per point has the same unit as at one (#285).
let cam = crate::Camera::default();
assert_eq!(unit_for(&cam, 1600.0, DEFAULT_SIZE, 2.0), unit_for(&cam, 800.0, DEFAULT_SIZE, 1.0));
assert_eq!(unit_for(&cam, 800.0, DEFAULT_SIZE, f64::NAN), unit_for(&cam, 800.0, DEFAULT_SIZE, 1.0));
assert_eq!(unit_for(&cam, 0.0, DEFAULT_SIZE, 1.0), None);
assert_eq!(Camera::default().length_per_pixel(0.0), None);
// Units round to a quarter of a doubling.
let (k, q) = quantise_unit(1.1).unwrap();
assert!(k == 1 && (q - 2f64.powf(0.25)).abs() < 1e-12, "{k} {q}");
assert_eq!(quantise_unit(1.0).map(|x| x.0), Some(0));
assert!(quantise_unit(-1.0).is_none() && quantise_unit(f64::NAN).is_none());
assert!(quantise_unit(f64::MAX).is_some_and(|(_, q)| q.is_finite()));
}
/// Symbols spread over a set at a density that follows the screen, never more than the cap.
#[test]
fn sampling_follows_the_screen_density_and_is_bounded() {
let mut m = Model::default();
let n = 101u32;
for i in 0..n {
for j in 0..n {
m.set_node(1 + i * n + j, [i as f64, j as f64, 0.0]).unwrap();
}
}
m.add_set("all", Set { nodes: (1..=n * n).collect(), ..Set::default() }).unwrap();
m.add_boundary_condition(
"Clamp",
BoundaryCondition::Displacement { target: Target::Set("all".into()), values: [None, None, Some(0.0), None, None, None] },
)
.unwrap();
let g = Glyphs::from_model(&m);
let count = |u: f64| {
let (s, d) = drawn_at(&g, &m, &view(GlyphShow::ALL, u));
(s.lines.len() / 6, d.notes)
};
// Zoomed in, the cap; zoomed out, one per grid cell of 2.4 units.
let (close, notes) = count(0.01);
assert!(close <= MAX_PER_DEFINITION && close > MAX_PER_DEFINITION * 9 / 10, "{close}");
assert_eq!(notes, vec![format!("Clamp: {close} of {} nodes", n * n)]);
for u in [2.0f64, 5.0, 20.0] {
let cells = (100.0 / (2.4 * u)).floor() + 1.0;
let (k, _) = count(u);
assert!(k as f64 <= cells * cells && k > 0, "unit {u}: {k}");
}
assert!(count(20.0).0 < count(5.0).0 && count(5.0).0 < count(2.0).0);
}
#[test]
fn colours_come_from_the_view_and_the_selected_definition_is_highlighted() {
let mut m = bar(4);
m.add_boundary_condition("Clamp", BoundaryCondition::Fixed { target: Target::Set("root".into()) }).unwrap();
m.add_load("Push", Load::Force { target: Target::Set("tip".into()), vector: [0.0, 0.0, -10.0], distribution: Default::default() }).unwrap();
let g = Glyphs::from_model(&m);
let v = GlyphView { highlight: Some((Definition::Load, "Push")), ..view(GlyphShow::ALL, 0.1) };
let (s, _) = drawn_at(&g, &m, &v);
let of = |c: Rgb| s.lines.iter().filter(|l| l.color == c).collect::<Vec<_>>();
assert_eq!(of(COLORS.constraint).len(), 4 * 3 * 6);
assert_eq!(of(COLORS.highlight).len(), 4 * 5, "the push's arrows: a shaft and four head lines");
assert!(of(COLORS.highlight).iter().all(|l| l.width == HIGHLIGHT_WIDTH));
assert!(of(COLORS.load).is_empty() && s.tris.is_empty());
// Another definition of the same name and another kind is not highlighted.
let v = GlyphView { highlight: Some((Definition::BoundaryCondition, "Push")), ..view(GlyphShow::ALL, 0.1) };
assert!(drawn_at(&g, &m, &v).0.lines.iter().all(|l| l.color != COLORS.highlight));
}
#[test]
fn forces_end_on_their_nodes_and_pressures_point_onto_their_faces() {
let mut m = bar(4);
m.add_load("Push", Load::Force { target: Target::Set("tip".into()), vector: [0.0, 0.0, -10.0], distribution: Default::default() }).unwrap();
let g = Glyphs::from_model(&m);
let s = drawn(&g, &m, GlyphShow::ALL);
// A shaft per tip node coming down onto it, and an open head; set loads are depth-tested.
let shafts: Vec<&SceneLine> = s.lines.iter().filter(|l| l.a.x == l.b.x && l.a.y == l.b.y).collect();
assert_eq!(shafts.len(), 4);
for l in shafts {
assert!(l.a.z > l.b.z && l.a.x == 4.0 && l.color == COLORS.load, "{l:?}");
}
assert_eq!(s.lines.len(), 4 * 5);
assert!(s.on_top.is_empty());
// A pressure on the end face (x = 4): one arrow onto it, from outside (x > 4).
let mut m = bar(4);
m.add_set("end", Set { faces: vec![ElementFace { element: 4, face: 4 }], ..Set::default() }).unwrap();
m.add_load("Press", Load::Pressure { set: "end".into(), magnitude: 2.0 }).unwrap();
let s = drawn(&Glyphs::from_model(&m), &m, GlyphShow::ALL);
let l = s.lines.first().unwrap();
assert_eq!(s.lines.len(), 5);
assert!(l.a.x > 4.0 && (l.b.x - 4.0).abs() < 1e-9, "{l:?}");
// A suction points away.
m.replace_load("Press", Load::Pressure { set: "end".into(), magnitude: -2.0 }).unwrap();
let s = drawn(&Glyphs::from_model(&m), &m, GlyphShow::ALL);
let l = s.lines.first().unwrap();
assert!((l.a.x - 4.0).abs() < 1e-9 && l.b.x > l.a.x, "{l:?}");
}
/// A load at a reference point or a single node is drawn over the part; its support-sized
/// arrow is four symbol units long.
#[test]
fn concentrated_loads_draw_on_top() {
let mut m = bar(4);
// Inside the bar: the part would hide it.
m.add_reference_point("Mid", ReferencePoint { xyz: [2.0, 0.5, 0.5], label: 0 }).unwrap();
m.add_load("F", Load::Force { target: Target::ReferencePoint("Mid".into()), vector: [0.0, 0.0, -1.0], distribution: Default::default() })
.unwrap();
m.add_set("one", Set { nodes: vec![5], ..Set::default() }).unwrap();
m.add_load("G", Load::Force { target: Target::Set("one".into()), vector: [1.0, 0.0, 0.0], distribution: Default::default() }).unwrap();
let g = Glyphs::from_model(&m);
let (s, _) = drawn_at(&g, &m, &view(GlyphShow { loads: true, interactions: false }, 0.1));
assert!(s.lines.is_empty());
assert_eq!(s.on_top.len(), 2 * 5);
let shaft = s.on_top.iter().find(|l| l.b == Vec3::new(2.0, 0.5, 0.5)).unwrap();
assert!((shaft.a.z - (0.5 + 0.4)).abs() < 1e-9, "{shaft:?}");
// The reference point's cross and dot too.
let (s, _) = drawn_at(&g, &m, &view(GlyphShow { loads: false, interactions: true }, 0.1));
assert_eq!(s.on_top.len(), 3 + 1);
assert!(s.lines.is_empty());
}
#[test]
fn moments_curl_right_handed_about_their_vector() {
let mut m = bar(4);
let c = Vec3::new(6.0, 0.5, 0.5);
m.add_reference_point("RP", ReferencePoint { xyz: [c.x, c.y, c.z], label: 0 }).unwrap();
m.add_load("Twist", Load::Moment { target: Target::ReferencePoint("RP".into()), vector: [3.0, 0.0, 0.0], distribution: Default::default() })
.unwrap();
let u = 0.2;
let (s, _) = drawn_at(&Glyphs::from_model(&m), &m, &view(GlyphShow { loads: true, interactions: false }, u));
let lines = &s.on_top;
assert_eq!(lines.len(), 1 + ARC_SEGMENTS + 4);
// The axis line through the point along x.
let axis = lines.first().unwrap();
assert!((axis.a + axis.b) * 0.5 == c && axis.a.y == c.y && axis.a.z == c.z && axis.a.x < axis.b.x, "{axis:?}");
// The arc lies in the plane x = 6, a radius from the point.
let radius = 1.3 * u;
let arc = lines.get(1..1 + ARC_SEGMENTS).unwrap();
for l in arc {
for p in [l.a, l.b] {
assert!((p.x - c.x).abs() < 1e-12 && (p.dist(c) - radius).abs() < 1e-9, "{p:?}");
}
}
// The head at the arc's end points along the right-handed tangent, x × r.
let end = arc.last().unwrap().b;
let head = lines.get(1 + ARC_SEGMENTS..).unwrap();
assert!(head.iter().all(|l| l.a == end));
let back = head.iter().fold(Vec3::ZERO, |s, l| s + l.b) * 0.25;
let pointing = (end - back).normalized().unwrap();
let want = Vec3::X.cross(end - c).normalized().unwrap();
assert!(pointing.dist(want) < 1e-9, "{pointing:?} vs {want:?}");
}
#[test]
fn reference_points_couplings_contact_and_gravity() {
let mut m = bar(4);
m.add_reference_point("RP-1", ReferencePoint { xyz: [6.0, 0.5, 0.5], label: 0 }).unwrap();
m.add_coupling("Tie", Coupling { reference_point: "RP-1".into(), set: "tip".into(), kind: CouplingKind::Kinematic, dofs: Dofs::all() })
.unwrap();
m.add_load("g", Load::Gravity { acceleration: [0.0, 0.0, -9810.0], set: None }).unwrap();
m.add_set("end", Set { faces: vec![ElementFace { element: 4, face: 4 }], ..Set::default() }).unwrap();
m.add_set("start", Set { faces: vec![ElementFace { element: 1, face: 6 }], ..Set::default() }).unwrap();
m.add_contact("Touch", fieldcraft_model::Contact::new(ContactSurfaces::Pair { main: "end".into(), secondary: "start".into() })).unwrap();
let g = Glyphs::from_model(&m);
let s = drawn(&g, &m, GlyphShow::ALL);
assert_eq!(s.labels, vec![SceneLabel { p: Vec3::new(6.0, 0.5, 0.5), text: "RP-1".into(), color: COLORS.label }]);
assert_eq!(s.lines.iter().filter(|l| l.color == COLORS.interaction && l.width == COUPLING_WIDTH).count(), 4, "a line to each tip node");
// A square on each contact face.
assert_eq!(s.lines.iter().filter(|l| l.color == COLORS.contact).count(), 2 * 4);
// Gravity: one arrow.
assert_eq!(s.lines.iter().filter(|l| l.color == COLORS.load).count(), 5);
// Interactions hidden: no point, cross, coupling or contact; the loads stay.
let s = drawn(&g, &m, GlyphShow { loads: true, interactions: false });
assert!(s.labels.is_empty() && s.on_top.is_empty() && s.lines.iter().all(|l| l.color == COLORS.load));
// A results plot moves the symbols with the nodes; the reference point keeps its place
// when the plot does not know it.
let mut s = Scene::default();
g.draw(|l| m.node(l).map(|n| Vec3::from(n.xyz) + Vec3::new(0.0, 0.0, 1.0)), &view(GlyphShow::ALL, g.model_unit()), &mut s);
assert!(
s.lines
.iter()
.filter(|l| l.width == COUPLING_WIDTH && l.color == COLORS.interaction)
.all(|l| l.a == Vec3::new(6.0, 0.5, 0.5) && l.b.z >= 1.0)
);
}
#[test]
fn rigid_walls_back_their_set_and_initial_velocities_leave_it() {
use fieldcraft_model::{ExplicitDynamic, Friction, InitialVelocity, RigidWall, Step};
let mut m = bar(4);
m.add_set("all", Set { nodes: m.nodes().keys().copied().collect(), ..Set::default() }).unwrap();
let e = ExplicitDynamic {
initial_velocities: vec![InitialVelocity { set: "all".into(), velocity: [-10.0, 0.0, 0.0] }],
rigid_walls: vec![RigidWall { set: "root".into(), point: [0.0, 0.0, 0.0], normal: [1.0, 0.0, 0.0], friction: Friction::NONE }],
..ExplicitDynamic::new(1.0)
};
m.add_step(Step::new("Impact", Procedure::ExplicitDynamic(e)), None).unwrap();
let g = Glyphs::from_model(&m);
let s = drawn(&g, &m, GlyphShow { loads: false, interactions: true });
// The wall: in the plane x = 0, centred behind the root face, hatched toward -x.
let outline: Vec<&SceneLine> = s.lines.iter().filter(|l| l.width == LINE_WIDTH).collect();
assert_eq!(outline.len(), 4);
assert!(outline.iter().all(|l| l.a.x.abs() < 1e-9 && l.b.x.abs() < 1e-9));
let mid = outline.iter().fold(Vec3::ZERO, |a, l| a + l.a) * 0.25;
assert!(mid.dist(Vec3::new(0.0, 0.5, 0.5)) < 1e-9, "{mid:?}");
assert!(s.lines.iter().filter(|l| l.width == COUPLING_WIDTH).all(|l| l.b.x < 0.0));
// The velocity: one arrow behind the bar's +x end, pointing along -x toward it.
let s = drawn(&g, &m, GlyphShow { loads: true, interactions: false });
let shaft = s.lines.first().unwrap();
assert_eq!(s.lines.len(), 5);
assert!(shaft.b.x > 4.0 && shaft.b.x < shaft.a.x, "{shaft:?}");
// Selecting the step highlights both.
let v = GlyphView { highlight: Some((Definition::Step, "Impact")), ..view(GlyphShow::ALL, g.model_unit()) };
assert!(drawn_at(&g, &m, &v).0.lines.iter().all(|l| l.color == COLORS.highlight));
}
#[test]
fn colours_mute_toward_the_background() {
let m = COLORS.muted(Rgb(255, 255, 255), 0.5);
assert_eq!(m.constraint, Rgb(128, 128, 255));
assert_eq!(m.highlight, COLORS.highlight);
assert_eq!(COLORS.muted(Rgb(1, 2, 3), f64::NAN), COLORS);
}
/// A set of a quarter million nodes draws a capped sample and says so; hostile coordinates,
/// vectors and units draw nothing rather than fail.
#[test]
fn huge_and_hostile_setups_are_sampled_and_bounded() {
let mut m = Model::default();
let n = 500u32;
for i in 0..n {
for j in 0..n {
m.set_node(1 + i * n + j, [i as f64, j as f64, 0.0]).unwrap();
}
}
m.add_set("all", Set { nodes: (1..=n * n).collect(), ..Set::default() }).unwrap();
m.add_boundary_condition("Clamp", BoundaryCondition::Fixed { target: Target::Set("all".into()) }).unwrap();
m.add_load("Push", Load::Force { target: Target::Set("all".into()), vector: [0.0, 0.0, -1.0], distribution: Default::default() }).unwrap();
let g = Glyphs::from_model(&m);
let (s, d) = drawn_at(&g, &m, &view(GlyphShow::ALL, 1e-3));
assert!(d.notes.iter().any(|n| n.starts_with("Clamp: ") && n.ends_with(" of 250000 nodes")), "{:?}", d.notes);
assert!(s.lines.len() <= MAX_PER_DEFINITION * (3 * 6 + 5));
// Positions that are not finite are left out; a unit that is not a length is the model's.
let mut s = Scene::default();
g.draw(|_| Some(Vec3::new(f64::NAN, 0.0, 0.0)), &view(GlyphShow::ALL, f64::NAN), &mut s);
assert!(s.tris.is_empty() && s.lines.is_empty());
for unit in [f64::INFINITY, -1.0, 0.0, 1e300, 1e-300] {
let _ = drawn_at(&g, &m, &view(GlyphShow::ALL, unit));
}
// An empty model, and one with a reference point only.
assert!(Glyphs::from_model(&Model::default()).groups.is_empty());
let mut m = Model::default();
m.add_reference_point("RP", ReferencePoint { xyz: [1e9, -1e9, 0.0], label: 0 }).unwrap();
let g = Glyphs::from_model(&m);
let mut s = Scene::default();
g.draw(|_| None, &view(GlyphShow::ALL, 1.0), &mut s);
assert_eq!(s.labels.len(), 1);
}
#[test]
fn grid_thinning_keeps_one_per_cell_and_respects_the_cap() {
let items: Vec<(u32, Vec3)> = (0..100).map(|i| (i, Vec3::new(f64::from(i) * 0.1, 0.0, 0.0))).collect();
assert_eq!(thin(items.iter().copied(), 1.0, 1000).len(), 10);
assert_eq!(thin(items.iter().copied(), 1e-9, 7).len(), 7);
assert_eq!(thin(items.iter().copied(), f64::NAN, 1000).len(), 10);
assert!(thin::<u32>([], 1.0, 10).is_empty());
assert!(thin([(1u32, Vec3::new(f64::INFINITY, 0.0, 0.0))], 1.0, 10).is_empty());
}
fn m_diag(m: &Model) -> f64 {
let mut b = Aabb3::EMPTY;
for n in m.nodes().values() {
b.add(Vec3::from(n.xyz));
}
b.diagonal()
}
fn cube(lo: f64, hi: f64) -> Aabb3 {
let mut b = Aabb3::EMPTY;
b.add(Vec3::new(lo, lo, lo));
b.add(Vec3::new(hi, hi, hi));
b
}
#[test]
fn datums_draw_at_the_model_with_their_names() {
let mut m = Model::default();
m.add_datum("Mid", fieldcraft_model::Datum::Plane { point: [5.0, 0.0, 0.0], normal: [1.0, 0.0, 0.0] }).unwrap();
m.add_datum("Ax", fieldcraft_model::Datum::Axis { point: [0.0; 3], direction: [0.0, 0.0, 1.0] }).unwrap();
m.add_datum("Pt", fieldcraft_model::Datum::Point { point: [1.0, 2.0, 3.0] }).unwrap();
let bounds = cube(0.0, 10.0);
let mut scene = Scene::default();
let drawn = datums(&m, &bounds, &|_| true, None, &COLORS, &mut scene);
// A rectangle (4 lines, filled by two translucent triangles), the axis line and the
// point's three arms, each crossing at the point.
assert_eq!(scene.lines.len(), 8);
assert_eq!(scene.fills.len(), 2);
assert!(scene.fills.iter().all(|f| f.alpha == DATUM_FILL_ALPHA && f.tri.normal == Vec3::X));
let p = Vec3::new(1.0, 2.0, 3.0);
assert!(scene.lines.iter().rev().take(3).all(|l| ((l.a + l.b) * 0.5).dist(p) < 1e-12));
let mut names: Vec<&str> = scene.labels.iter().map(|l| l.text.as_str()).collect();
names.sort_unstable();
assert_eq!(names, ["Ax", "Mid", "Pt"]);
// The axis spans the model along z with a 5 % margin: z from -0.5 to 10.5.
let axis = scene.lines.iter().find(|l| l.width == SHAFT_WIDTH).unwrap();
assert!((axis.a.z + 0.5).abs() < 1e-9 && (axis.b.z - 10.5).abs() < 1e-9, "{axis:?}");
// Everything drawn stays within the model's box and its margin.
let mut reach = bounds;
reach.add(Vec3::new(-0.5, -0.5, -0.5));
reach.add(Vec3::new(10.5, 10.5, 10.5));
assert!(drawn.min.x >= reach.min.x - 1e-9 && drawn.max.z <= reach.max.z + 1e-9, "{drawn:?}");
// A datum left out draws nothing; the highlighted one draws anyway, on top.
let mut only = Scene::default();
datums(&m, &bounds, &|n| n == "Ax", None, &COLORS, &mut only);
assert_eq!((only.lines.len(), only.labels.len()), (1, 1));
let mut picked = Scene::default();
datums(&m, &bounds, &|_| false, Some("Mid"), &COLORS, &mut picked);
assert_eq!((picked.lines.len(), picked.on_top.len(), picked.fills.len()), (0, 4, 2));
assert!(picked.on_top.iter().all(|l| l.color == COLORS.highlight));
}
/// A datum plane at any offset or angle is drawn as the model's extent within it plus the
/// margin: every corner lies on the plane and within the model's box grown by the margin.
#[test]
fn datum_planes_fit_the_model_at_any_offset_and_angle() {
let bounds = cube(-20.0, 20.0);
let size = 40.0;
let normals = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 1.0, 0.0], [1.0, 2.0, 3.0], [0.3, -0.2, 0.9]];
for nr in normals {
let n = Vec3::from(nr).normalized().unwrap();
for offset in [-19.0, -7.5, 0.0, 3.0, 15.0] {
let p = n * offset;
let mut m = Model::default();
m.add_datum("D", fieldcraft_model::Datum::Plane { point: [p.x, p.y, p.z], normal: [n.x, n.y, n.z] }).unwrap();
let mut scene = Scene::default();
datums(&m, &bounds, &|_| true, None, &COLORS, &mut scene);
// Every line lies on the plane.
assert!(scene.lines.iter().all(|l| ((l.a - p).dot(n)).abs() < 1e-9 && ((l.b - p).dot(n)).abs() < 1e-9), "{nr:?} at {offset}");
// The outline is the tightest rectangle in the plane's axes around the points
// where the plane cuts the box's edges, plus the margin on each side.
let (u, v) = perpendiculars(n);
let mut cut = Vec::new();
for a in 0..8usize {
for bit in [1usize, 2, 4] {
if a & bit == 0 {
let at = |k: usize| Vec3::new([-20.0, 20.0][k & 1], [-20.0, 20.0][(k >> 1) & 1], [-20.0, 20.0][(k >> 2) & 1]);
let (qa, qb) = (at(a), at(a | bit));
let (ha, hb) = ((qa - p).dot(n), (qb - p).dot(n));
if ha * hb <= 0.0 && ha != hb {
cut.push(qa + (qb - qa) * (ha / (ha - hb)));
}
}
}
}
let range = |pts: &mut dyn Iterator<Item = f64>| pts.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| (a.min(x), b.max(x)));
let (cu, cv) = (range(&mut cut.iter().map(|q| (*q - p).dot(u))), range(&mut cut.iter().map(|q| (*q - p).dot(v))));
let margin = DATUM_MARGIN * (cu.1 - cu.0).max(cv.1 - cv.0);
let (ru, rv) = (
range(&mut scene.lines.iter().take(4).map(|l| (l.a - p).dot(u))),
range(&mut scene.lines.iter().take(4).map(|l| (l.a - p).dot(v))),
);
let close = |a: (f64, f64), b: (f64, f64)| (a.0 - (b.0 - margin)).abs() < 1e-9 && (a.1 - (b.1 + margin)).abs() < 1e-9;
assert!(close(ru, cu) && close(rv, cv), "{nr:?} at {offset}: {ru:?} {rv:?} around {cu:?} {cv:?}");
// Never larger than the model's section can be, plus the margins.
assert!((ru.1 - ru.0).hypot(rv.1 - rv.0) <= (1.0 + 2.0 * DATUM_MARGIN) * 2f64.sqrt() * bounds.diagonal() + 1e-9);
if nr.iter().filter(|x| **x != 0.0).count() == 1 {
// An axis-aligned plane: the cube's face plus 5 % on each side.
assert!((ru.1 - ru.0 - size * 1.1).abs() < 1e-9 && (rv.1 - rv.0 - size * 1.1).abs() < 1e-9);
}
}
}
// A plane missing the model is drawn as the model's shadow on it.
let mut m = Model::default();
m.add_datum("Far", fieldcraft_model::Datum::Plane { point: [0.0, 0.0, 100.0], normal: [0.0, 0.0, 1.0] }).unwrap();
let mut scene = Scene::default();
let drawn = datums(&m, &bounds, &|_| true, None, &COLORS, &mut scene);
assert!((drawn.max.x - 22.0).abs() < 1e-9 && (drawn.min.y + 22.0).abs() < 1e-9, "{drawn:?}");
}
}