//! 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, } /// 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 { 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 { 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, 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, 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, }, } 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, 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(items: &[T]) -> impl Iterator { 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 { let planar: BTreeSet = 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> { 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 { 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(items: impl IntoIterator, cell: f64, cap: usize) -> Vec { 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 = 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, /// The nodes that can rotate, worked out when a constraint needs them. rotating: Option>, bounds: Aabb3, owners: Vec<(Definition, String)>, groups: Vec, 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, 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, usize) { match target { Target::ReferencePoint(r) => { let spots: Vec = 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 = 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::>>() 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> { 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 = 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::>() { 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 = 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, view: &GlyphView, scene: &mut Scene) -> Drawn { let at = |a: Anchor| -> Option { 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 = 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, 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 { 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::>(); 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::([], 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| 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:?}"); } }