From 93f1a031374b9a56a0e97ea5baf77bff8c742c9c Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:45:36 -0400 Subject: [PATCH] render: symbols for boundary conditions, loads and interactions, and scene labels glyphs::Glyphs picks where a model's setup symbols go (resolving its sets once, thinning them on a grid, capping each definition and sampling large ones with a note) and draws them into a Scene at the nodes' current positions: cones for constrained translations (and rotations where nodes rotate), arrows for forces, double-headed arrows for moments, arrows normal to pressure faces, one arrow for gravity and body forces, a curved arrow for centrifugal loads, crosses with names for reference points, lines for couplings, hatched squares for rigid walls. Colours come from the caller by role, muted over results. Scene gains labels, drawn by the CPU rasterizer with a small bitmap font of its own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --- crates/render/src/display.rs | 9 +- crates/render/src/font.rs | 86 ++++ crates/render/src/glyphs.rs | 951 +++++++++++++++++++++++++++++++++++ crates/render/src/lib.rs | 5 +- crates/render/src/raster.rs | 84 +++- crates/render/src/vec.rs | 7 + 6 files changed, 1137 insertions(+), 5 deletions(-) create mode 100644 crates/render/src/font.rs create mode 100644 crates/render/src/glyphs.rs diff --git a/crates/render/src/display.rs b/crates/render/src/display.rs index ca14fe8..ac0fad4 100644 --- a/crates/render/src/display.rs +++ b/crates/render/src/display.rs @@ -103,7 +103,7 @@ fn face_key(e: &[u32], face: &[usize]) -> Option<[u32; 4]> { } /// Newell's normal of a polygon (robust for non-planar quads); `None` when degenerate. -fn polygon_normal(p: &[Vec3]) -> Option { +pub(crate) fn polygon_normal(p: &[Vec3]) -> Option { let mut n = Vec3::ZERO; for (i, a) in p.iter().enumerate() { let b = p.get((i + 1) % p.len()).copied().unwrap_or(*a); @@ -112,7 +112,7 @@ fn polygon_normal(p: &[Vec3]) -> Option { n.normalized() } -fn centre(p: &[Vec3]) -> Vec3 { +pub(crate) fn centre(p: &[Vec3]) -> Vec3 { p.iter().fold(Vec3::ZERO, |s, q| s + *q) * (1.0 / p.len().max(1) as f64) } @@ -243,6 +243,11 @@ impl DisplayMesh { } } + /// Where node `label` is drawn, if it is (nodes are kept in label order). + pub fn node_position(&self, label: u32) -> Option { + self.nodes.binary_search_by_key(&label, |n| n.label).ok().and_then(|i| self.nodes.get(i)).map(|n| n.p) + } + /// The node nearest to where a ray (`origin`, `dir`) first hits an element face ahead of /// its origin: the hit face's closest corner, and the hit point. Line elements and geometry /// are not hit. diff --git a/crates/render/src/font.rs b/crates/render/src/font.rs new file mode 100644 index 0000000..195bac2 --- /dev/null +++ b/crates/render/src/font.rs @@ -0,0 +1,86 @@ +//! A small bitmap font for labels in headless pictures (the CPU rasterizer has no text +//! otherwise): capitals, digits and a few marks on a 5 × 7 grid, drawn for FieldCraft. Lower case +//! is drawn in capitals; anything else is a question mark. + +/// Glyph width and height in font pixels, and the advance between glyphs. +pub const W: usize = 5; +pub const H: usize = 7; +pub const ADVANCE: usize = 6; + +/// The rows of a character, top first, the leftmost pixel in bit 4. +pub fn rows(c: char) -> [u8; H] { + match c.to_ascii_uppercase() { + 'A' => [0x0e, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11], + 'B' => [0x1e, 0x11, 0x11, 0x1e, 0x11, 0x11, 0x1e], + 'C' => [0x0e, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0e], + 'D' => [0x1e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1e], + 'E' => [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x1f], + 'F' => [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x10], + 'G' => [0x0e, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0f], + 'H' => [0x11, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11], + 'I' => [0x0e, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e], + 'J' => [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0c], + 'K' => [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11], + 'L' => [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1f], + 'M' => [0x11, 0x1b, 0x15, 0x15, 0x11, 0x11, 0x11], + 'N' => [0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11], + 'O' => [0x0e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e], + 'P' => [0x1e, 0x11, 0x11, 0x1e, 0x10, 0x10, 0x10], + 'Q' => [0x0e, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0d], + 'R' => [0x1e, 0x11, 0x11, 0x1e, 0x14, 0x12, 0x11], + 'S' => [0x0f, 0x10, 0x10, 0x0e, 0x01, 0x01, 0x1e], + 'T' => [0x1f, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], + 'U' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e], + 'V' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x0a, 0x04], + 'W' => [0x11, 0x11, 0x11, 0x15, 0x15, 0x15, 0x0a], + 'X' => [0x11, 0x11, 0x0a, 0x04, 0x0a, 0x11, 0x11], + 'Y' => [0x11, 0x11, 0x0a, 0x04, 0x04, 0x04, 0x04], + 'Z' => [0x1f, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1f], + '0' => [0x0e, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0e], + '1' => [0x04, 0x0c, 0x04, 0x04, 0x04, 0x04, 0x0e], + '2' => [0x0e, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1f], + '3' => [0x1f, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0e], + '4' => [0x02, 0x06, 0x0a, 0x12, 0x1f, 0x02, 0x02], + '5' => [0x1f, 0x10, 0x1e, 0x01, 0x01, 0x11, 0x0e], + '6' => [0x06, 0x08, 0x10, 0x1e, 0x11, 0x11, 0x0e], + '7' => [0x1f, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08], + '8' => [0x0e, 0x11, 0x11, 0x0e, 0x11, 0x11, 0x0e], + '9' => [0x0e, 0x11, 0x11, 0x0f, 0x01, 0x02, 0x0c], + ' ' => [0; H], + '-' => [0x00, 0x00, 0x00, 0x1f, 0x00, 0x00, 0x00], + '_' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f], + '.' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x0c], + ',' => [0x00, 0x00, 0x00, 0x00, 0x0c, 0x04, 0x08], + ':' => [0x00, 0x0c, 0x0c, 0x00, 0x0c, 0x0c, 0x00], + '(' => [0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02], + ')' => [0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08], + '/' => [0x01, 0x01, 0x02, 0x04, 0x08, 0x10, 0x10], + '+' => [0x00, 0x04, 0x04, 0x1f, 0x04, 0x04, 0x00], + '#' => [0x0a, 0x0a, 0x1f, 0x0a, 0x1f, 0x0a, 0x0a], + _ => [0x0e, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04], + } +} + +/// Whether font pixel (`x`, `y`) of `c` is set. +pub fn on(c: char, x: usize, y: usize) -> bool { + x < W && rows(c).get(y).is_some_and(|r| r & (0x10 >> x) != 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn characters_are_distinct_and_lower_case_draws_capitals() { + let set: Vec = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.,:()/+#".chars().collect(); + for (i, a) in set.iter().enumerate() { + assert!(rows(*a).iter().all(|r| *r < 0x20), "{a}"); + for b in set.iter().skip(i + 1) { + assert_ne!(rows(*a), rows(*b), "{a} and {b}"); + } + } + assert_eq!(rows('r'), rows('R')); + assert_eq!(rows('é'), rows('?')); + assert!(on('L', 0, 6) && !on('L', 4, 0) && !on('L', 9, 0) && !on('L', 0, 9)); + } +} diff --git a/crates/render/src/glyphs.rs b/crates/render/src/glyphs.rs new file mode 100644 index 0000000..85cbebb --- /dev/null +++ b/crates/render/src/glyphs.rs @@ -0,0 +1,951 @@ +//! 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. +//! +//! - **Boundary conditions:** a cone per constrained translation, tip on the node, pointing along +//! the axis; a second, smaller cone behind it for a constrained rotation (only where the node +//! can rotate: shell and beam nodes, reference points). Thermal conditions are dots. +//! - **Forces:** straight arrows ending on their nodes, along the force. **Moments:** arrows with +//! two heads, starting at the node. +//! - **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; **rigid walls:** a hatched square. +//! +//! Sizes follow the model: one symbol unit is 2.5 % of its bounding box diagonal. +//! Arrow shafts and outlines are lines of a fixed width in pixels, so they stay visible at any +//! zoom. Symbols closer than about a symbol apart are thinned on a grid, and each definition +//! draws at most [`MAX_PER_DEFINITION`] symbols ([`MAX_SYMBOLS`] in all): a definition with more +//! places than that is drawn with a sample and listed in [`Glyphs::notes`], so the viewer can +//! say so. +//! +//! [`Glyphs::from_model`] does the work that depends on the model only (resolving sets, picking +//! the symbols' nodes); [`Glyphs::draw`] 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, ElementFace, Load, Model, Procedure, ResolvedSet, Target}; + +use crate::display::{centre, polygon_normal}; +use crate::{Aabb3, Rgb, Scene, SceneLabel, SceneLine, ScenePoint, SceneTri, Vec3}; + +/// One symbol unit as a fraction of the model's bounding box diagonal. +const UNIT_FRACTION: f64 = 0.025; +/// 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 around a cone. +const CONE_SEGMENTS: usize = 10; +/// Segments of a centrifugal load's curved arrow. +const ARC_SEGMENTS: usize = 24; + +/// Arrow shaft, outline and coupling line widths, in output pixels. +const SHAFT_WIDTH: f64 = 2.5; +const OUTLINE_WIDTH: f64 = 1.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 role. +#[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, + /// Temperatures, heat fluxes, convection and heat sources. + pub thermal: Rgb, + /// Reference point names. + pub label: 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). + 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), + thermal: mix(self.thermal), + label: mix(self.label), + } + } + + /// 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 and rigid walls. + pub interactions: bool, +} + +impl GlyphShow { + pub const ALL: GlyphShow = GlyphShow { loads: true, interactions: true }; +} + +/// 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, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum FaceLoad { + /// A pressure: onto the face (positive) or away from it. + Pressure { onto: bool }, + /// A traction along a unit direction. + Traction(Vec3), +} + +#[derive(Clone, Debug, PartialEq)] +enum Mark { + /// Constrained translations (bits 0..3) and rotations (bits 3..6). + Constraint { + at: Anchor, + dofs: u8, + }, + /// A force (ending on the node) or moment (starting there), `len` units long. + Arrow { + at: Anchor, + dir: Vec3, + len: f64, + moment: bool, + }, + FaceArrow { + face: Face, + load: FaceLoad, + }, + Thermal { + at: Anchor, + }, + /// A fixed arrow (gravity, body force, initial velocity) from `tail` along `dir`. + Free { + tail: Vec3, + dir: Vec3, + len: f64, + }, + /// 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`, `half` units across, hatched on the side away + /// from `normal`. + Wall { + point: Vec3, + normal: Vec3, + half: 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 { + marks: Vec, + /// One symbol unit, in model length. + unit: f64, + /// Definitions drawn with a sample of their symbols: `Clamp: 400 of 12000 nodes`. + pub notes: Vec, +} + +/// 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: &[(T, Vec3)], 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::new(); + 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>, + unit: f64, + bounds: Aabb3, + marks: Vec, + notes: Vec, + /// 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) + } + + /// 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()) + } + + fn room(&self) -> usize { + MAX_SYMBOLS.saturating_sub(self.marks.len()).min(MAX_PER_DEFINITION) + } + + /// Note a definition with more places than one definition draws symbols, drawn with a + /// sample (small ones are only thinned where symbols would overlap). + fn note(&mut self, name: &str, drawn: usize, total: usize, what: &str) { + if drawn < total && total > MAX_PER_DEFINITION { + self.notes.push(format!("{name}: {drawn} of {total} {what}")); + } + } + + /// The anchors of a target, thinned on a grid of `cell` units. + fn anchors(&mut self, name: &str, target: &Target, cell: f64) -> Vec { + match target { + Target::ReferencePoint(r) => { + self.model.reference_point(r).map(|p| Anchor::Point { label: p.label, xyz: Vec3::from(p.xyz) }).into_iter().collect() + } + Target::Set(s) => { + let (at, total) = self.node_points(s); + let kept = thin(&at, cell * self.unit, self.room()); + self.note(name, kept.len(), total, "nodes"); + kept.into_iter().map(Anchor::Node).collect() + } + } + } + + /// Dots at a thermal condition's nodes. + fn thermal(&mut self, name: &str, target: &Target) { + for at in self.anchors(name, target, 1.5) { + self.marks.push(Mark::Thermal { at }); + } + } + + /// The loaded faces of a set (its element faces, else the edges of its 2D elements), thinned. + fn faces(&mut self, name: &str, set: &str) -> Vec { + let model = self.model; + let Some(r) = self.set(set) else { return Vec::new() }; + 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))); + } + } + let total = if found.is_empty() { + // 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))); + } + } + r.edges.len() + } else { + r.faces.len() + }; + let kept = thin(&found, 1.6 * self.unit, self.room()); + self.note(name, kept.len(), total, "faces"); + kept + } + + /// 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, dir: Vec3) { + let Some(dir) = dir.normalized() else { return }; + let len = 5.0; + let b = self.bounds; + // Over the model's middle, in front of it, each one beside the last. + let at = Vec3::new(b.center().x, b.min.y, b.max.z) + Vec3::new(2.5 * self.free as f64, -4.0, 2.0) * self.unit; + self.free += 1; + // The arrow's middle sits at the spot, whichever way it points. + let tail = at - dir * (0.5 * len * self.unit); + self.marks.push(Mark::Free { tail, dir, len }); + } + + fn boundary_condition(&mut self, name: &str, bc: &BoundaryCondition) { + if bc.is_thermal() { + return self.thermal(name, bc.target()); + } + let dofs = bc.dofs().bits(); + if dofs == 0 { + return; + } + let anchors = self.anchors(name, bc.target(), 1.5); + 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); + for at in anchors { + let rotates = match at { + Anchor::Node(n) => rotating.contains(&n), + Anchor::Point { .. } => true, + }; + let dofs = if rotates { dofs } else { dofs & 0b000_111 }; + if dofs != 0 { + self.marks.push(Mark::Constraint { at, dofs }); + } + } + } + + fn load(&mut self, name: &str, load: &Load) { + match load { + Load::Force { target, vector, .. } | Load::Moment { target, vector, .. } => { + let Some(dir) = Vec3::from(*vector).normalized() else { return }; + let moment = matches!(load, Load::Moment { .. }); + let anchors = self.anchors(name, target, 2.0); + let len = if anchors.len() > 1 { 3.0 } else { 5.0 }; + for at in anchors { + self.marks.push(Mark::Arrow { at, dir, len, moment }); + } + } + Load::Pressure { set, magnitude } => { + if !magnitude.is_finite() || *magnitude == 0.0 { + return; + } + let onto = *magnitude > 0.0; + for face in self.faces(name, set) { + self.marks.push(Mark::FaceArrow { face, load: FaceLoad::Pressure { onto } }); + } + } + Load::Traction { set, vector } => { + let Some(dir) = Vec3::from(*vector).normalized() else { return }; + for face in self.faces(name, set) { + self.marks.push(Mark::FaceArrow { face, load: FaceLoad::Traction(dir) }); + } + } + Load::Gravity { acceleration, .. } => self.free_arrow(Vec3::from(*acceleration)), + Load::BodyForce { vector, .. } => self.free_arrow(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); + let half_length = (0.5 * self.bounds.diagonal()).max(4.0 * self.unit); + self.marks.push(Mark::Spin { centre, axis, half_length }); + } + Load::Temperature { target, .. } => self.thermal(name, target), + Load::SurfaceFlux { set, .. } | Load::Convection { set, .. } | Load::HeatSource { set, .. } => { + self.thermal(name, &Target::Set(set.clone())) + } + } + } + + fn interactions(&mut self) { + let model = self.model; + for (name, p) in model.reference_points() { + let xyz = Vec3::from(p.xyz); + if xyz.is_finite() && self.marks.len() < MAX_SYMBOLS { + self.marks.push(Mark::ReferencePoint { at: Anchor::Point { label: p.label, xyz }, name: name.clone() }); + } + } + for c in model.couplings().values() { + let Some(p) = model.reference_point(&c.reference_point) else { continue }; + 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(self.unit); + let room = self.room().min(MAX_COUPLING_LINES); + for to in thin(&at, cell, room) { + self.marks.push(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 }; + 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() || self.marks.len() >= MAX_SYMBOLS { + continue; + } + // Behind the set, ending a unit short of it, 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(); + let len = 5.0; + let tail = b.center() - dir * (reach + (1.0 + len) * self.unit); + self.marks.push(Mark::Free { tail, dir, len }); + } + 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() || self.marks.len() >= MAX_SYMBOLS { + continue; + } + // Behind the set's nodes and wide enough to back them. + let b = self.set_bounds(&w.set); + let (point, half) = if b.is_empty() { + (point, 4.0) + } else { + let c = b.center(); + (c - normal * normal.dot(c - point), (0.5 * b.diagonal() / self.unit + 1.0).clamp(2.0, 40.0)) + }; + self.marks.push(Mark::Wall { point, normal, half }); + } + } + } +} + +impl Glyphs { + /// The symbols of `model`'s boundary conditions, loads, 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, unit, bounds, marks: Vec::new(), notes: 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.interactions(); + b.marks.truncate(MAX_SYMBOLS); + Glyphs { marks: b.marks, unit, notes: b.notes } + } + + #[cfg(test)] + fn len(&self) -> usize { + self.marks.len() + } + + /// Draw the symbols into `scene`, 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). Returns the box around what was + /// drawn, for framing and clipping. + pub fn draw(&self, pos: impl Fn(u32) -> Option, show: GlyphShow, colors: &GlyphColors, scene: &mut Scene) -> Aabb3 { + 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 = self.unit; + let mut d = Draw { scene, bounds: Aabb3::EMPTY }; + for m in &self.marks { + if !(if m.interaction() { show.interactions } else { show.loads }) { + continue; + } + match m { + Mark::Constraint { at: a, dofs } => { + let Some(p) = at(*a) else { continue }; + for (i, axis) in [Vec3::X, Vec3::Y, Vec3::Z].into_iter().enumerate() { + let (t, r) = (dofs & (1 << i) != 0, dofs & (1 << (i + 3)) != 0); + if t { + d.cone(p, axis, u, 0.4 * u, colors.constraint); + } + if r { + // Behind the translation's cone (or where it would be). + d.cone(p - axis * (1.05 * u), axis, 0.7 * u, 0.3 * u, colors.constraint); + } + } + } + Mark::Arrow { at: a, dir, len, moment } => { + let Some(p) = at(*a) else { continue }; + if *moment { + d.arrow(p, *dir, len * u, u, true, colors.load); + } else { + d.arrow(p - *dir * (len * u), *dir, len * u, u, false, colors.load); + } + } + Mark::FaceArrow { face, load } => { + 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()) { + continue; + } + 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 = 2.2 * u; + match load { + FaceLoad::Pressure { onto: true } => d.arrow(c + outward * len, -outward, len, 0.7 * u, false, colors.load), + FaceLoad::Pressure { onto: false } => d.arrow(c, outward, len, 0.7 * u, false, colors.load), + FaceLoad::Traction(dir) => d.arrow(c - *dir * len, *dir, len, 0.7 * u, false, colors.load), + } + } + Mark::Thermal { at: a } => { + if let Some(p) = at(*a) { + d.point(p, colors.thermal, THERMAL_SIZE); + } + } + Mark::Free { tail, dir, len } => d.arrow(*tail, *dir, len * u, 1.2 * u, false, colors.load), + Mark::Spin { centre: c, axis, half_length } => { + d.line(*c - *axis * *half_length, *c + *axis * *half_length, colors.load, COUPLING_WIDTH); + d.spin(*c + *axis * (half_length * 0.8), *axis, 2.0 * u, u, colors.load); + } + Mark::ReferencePoint { at: a, name } => { + let Some(p) = at(*a) else { continue }; + for axis in [Vec3::X, Vec3::Y, Vec3::Z] { + d.line(p - axis * (0.9 * u), p + axis * (0.9 * u), colors.interaction, OUTLINE_WIDTH); + } + d.point(p, colors.interaction, POINT_SIZE); + d.scene.labels.push(SceneLabel { p, text: name.clone(), color: colors.label }); + } + Mark::CouplingLine { from, to } => { + if let (Some(a), Some(b)) = (at(*from), pos(*to)) { + d.line(a, b, colors.interaction, COUPLING_WIDTH); + } + } + Mark::Wall { point, normal, half } => d.wall(*point, *normal, half * u, u, colors.constraint), + } + } + d.bounds + } +} + +/// Symbol primitives into a scene. +struct Draw<'s> { + scene: &'s mut Scene, + bounds: Aabb3, +} + +impl Draw<'_> { + fn point(&mut self, p: Vec3, color: Rgb, size: f64) { + self.bounds.add(p); + self.scene.points.push(ScenePoint { p, color, size }); + } + + fn line(&mut self, a: Vec3, b: Vec3, color: Rgb, width: f64) { + if a.is_finite() && b.is_finite() { + self.bounds.add(a); + self.bounds.add(b); + self.scene.lines.push(SceneLine { a, b, color, width }); + } + } + + fn tri(&mut self, p: [Vec3; 3], color: Rgb) { + if let Some(normal) = (p[1] - p[0]).cross(p[2] - p[0]).normalized() { + for q in p { + self.bounds.add(q); + } + self.scene.tris.push(SceneTri { p, normal, color }); + } + } + + /// A solid cone with its tip at `tip`, pointing along unit `axis`. + fn cone(&mut self, tip: Vec3, axis: Vec3, len: f64, radius: f64, color: Rgb) { + let base = tip - axis * len; + let (u, v) = perpendiculars(axis); + let ring: [Vec3; CONE_SEGMENTS] = std::array::from_fn(|k| { + let a = std::f64::consts::TAU * k as f64 / CONE_SEGMENTS as f64; + base + (u * a.cos() + v * a.sin()) * radius + }); + for (k, a) in ring.iter().enumerate() { + let b = ring.get((k + 1) % CONE_SEGMENTS).copied().unwrap_or(*a); + self.tri([tip, *a, b], color); + self.tri([base, b, *a], color); + } + } + + /// An arrow from `tail` along unit `dir`, `len` long, its head `head` long; a moment's has + /// two heads. + fn arrow(&mut self, tail: Vec3, dir: Vec3, len: f64, head: f64, moment: bool, color: Rgb) { + let tip = tail + dir * len; + let heads = if moment { 2.0 } else { 1.0 }; + self.line(tail, tip - dir * (head * heads * 0.9), color, SHAFT_WIDTH); + self.cone(tip, dir, head, 0.38 * head, color); + if moment { + self.cone(tip - dir * (0.75 * head), dir, head, 0.38 * head, color); + } + } + + /// A curved arrow three quarters of the way around `axis` at `centre`, `radius` across. + fn spin(&mut self, centre: Vec3, axis: Vec3, radius: f64, head: f64, color: Rgb) { + 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), color, SHAFT_WIDTH); + } + // The head along the tangent at the arc's end (turning right-handed about the axis). + let tangent = (u * -end.sin() + v * end.cos()).normalized().unwrap_or(u); + self.cone(at(end) + tangent * (0.6 * head), tangent, head, 0.38 * head, color); + } + + /// 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, color: Rgb) { + 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() { + let b = corners.get((k + 1) % 4).copied().unwrap_or(*a); + self.line(*a, b, color, SHAFT_WIDTH); + } + // 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); + 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), color, COUPLING_WIDTH); + } + } + } +} + +#[cfg(test)] +mod tests { + use fieldcraft_model::{Coupling, CouplingKind, Dofs, ElementKind, ReferencePoint, Set}; + + use super::*; + + const COLORS: GlyphColors = + GlyphColors { constraint: Rgb(0, 0, 255), load: Rgb(255, 0, 0), interaction: Rgb(0, 255, 0), thermal: Rgb(255, 128, 0), label: Rgb(0, 0, 0) }; + + /// 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 drawn(g: &Glyphs, m: &Model, show: GlyphShow) -> Scene { + let mut s = Scene::default(); + g.draw(|l| m.node(l).map(|n| Vec3::from(n.xyz)), show, &COLORS, &mut s); + s + } + + #[test] + fn constraints_draw_a_cone_per_translation_and_rotations_only_where_nodes_rotate() { + 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.unit - m_diag(&m) * UNIT_FRACTION).abs() < 1e-12); + // Solid nodes have no rotations: three cones on each of the four nodes, 2 × 10 triangles + // a cone. + let s = drawn(&g, &m, GlyphShow::ALL); + assert_eq!(s.tris.len(), 4 * 3 * 2 * CONE_SEGMENTS); + assert!(s.tris.iter().all(|t| t.color == COLORS.constraint)); + // A cone's tip is on its node; the cones reach a symbol unit below the root. + assert!(s.tris.iter().any(|t| t.p[0] == Vec3::ZERO)); + let mut again = Scene::default(); + let b = g.draw(|l| m.node(l).map(|n| Vec3::from(n.xyz)), GlyphShow::ALL, &COLORS, &mut again); + assert!((b.min.x + g.unit).abs() < 1e-9 && b.max.x > 0.3 * g.unit && b.max.x <= 0.4 * g.unit + 1e-12, "{b:?}"); + // Hidden with Loads & BCs. + let s = drawn(&g, &m, GlyphShow { loads: false, interactions: true }); + assert!(s.tris.is_empty() && s.lines.is_empty()); + // A beam node can rotate: six cones. + 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( + "Pin", + BoundaryCondition::Displacement { target: Target::Set("end".into()), values: [Some(0.0), None, None, Some(0.0), None, None] }, + ) + .unwrap(); + let s = drawn(&Glyphs::from_model(&m), &m, GlyphShow::ALL); + assert_eq!(s.tris.len(), 2 * 2 * CONE_SEGMENTS, "one translation and one rotation"); + } + + 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() + } + + #[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); + // One shaft per tip node, coming down onto it. + assert_eq!(s.lines.len(), 4); + for l in &s.lines { + assert!(l.a.z > l.b.z && l.a.x == 4.0 && l.color == COLORS.load, "{l:?}"); + } + // 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![fieldcraft_model::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(), 1); + assert!(l.a.x > 4.0 && l.b.x > 4.0 && l.b.x < l.a.x, "{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:?}"); + } + + #[test] + fn reference_points_couplings_gravity_and_moments() { + 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( + "Twist", + Load::Moment { target: Target::ReferencePoint("RP-1".into()), vector: [1.0, 0.0, 0.0], distribution: Default::default() }, + ) + .unwrap(); + m.add_load("g", Load::Gravity { acceleration: [0.0, 0.0, -9810.0], set: None }).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"); + // The moment's two heads and gravity's one: three cones. + assert_eq!(s.tris.len(), 3 * 2 * CONE_SEGMENTS); + // The moment starts at the point. + assert!(s.lines.iter().any(|l| l.a == Vec3::new(6.0, 0.5, 0.5) && l.b.x > 6.0 && l.color == COLORS.load)); + // Interactions hidden: no point, cross or lines to the nodes; the loads stay. + let s = drawn(&g, &m, GlyphShow { loads: true, interactions: false }); + assert!(s.labels.is_empty() && s.points.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)), GlyphShow::ALL, &COLORS, &mut s); + assert!(s.lines.iter().filter(|l| l.width == COUPLING_WIDTH).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, InitialVelocity, Procedure, 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] }], + ..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 == SHAFT_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(), 1); + assert!(shaft.b.x > 4.0 && shaft.b.x < shaft.a.x, "{shaft:?}"); + } + + #[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!(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 and + /// vectors 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); + assert!(g.len() <= 2 * MAX_PER_DEFINITION, "{}", g.len()); + assert!(g.notes.iter().any(|n| n.starts_with("Clamp: ") && n.ends_with(" of 250000 nodes")), "{:?}", g.notes); + let s = drawn(&g, &m, GlyphShow::ALL); + assert!(s.tris.len() <= MAX_PER_DEFINITION * (3 + 1) * 2 * CONE_SEGMENTS); + // Positions that are not finite are left out. + let mut s = Scene::default(); + g.draw(|_| Some(Vec3::new(f64::NAN, 0.0, 0.0)), GlyphShow::ALL, &COLORS, &mut s); + assert!(s.tris.is_empty() && s.lines.is_empty()); + // An empty model, and one with a reference point only. + assert_eq!(Glyphs::from_model(&Model::default()).len(), 0); + 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, GlyphShow::ALL, &COLORS, &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, 1.0, 1000).len(), 10); + assert_eq!(thin(&items, 1e-9, 7).len(), 7); + assert_eq!(thin(&items, 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()); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4afee39..ed56dce 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -8,12 +8,15 @@ mod camera; pub mod contour; mod display; +mod font; +pub mod glyphs; mod raster; mod vec; pub use camera::{Camera, Mat4, Orientation, StandardView}; pub use display::{DisplayEdge, DisplayLine, DisplayMesh, DisplayNode, DisplayTri, SurfaceTri}; pub use raster::{ - Canvas, MAX_DIM, MAX_PIXELS, MAX_PRIMITIVES, RenderError, Rgb, Scene, SceneLine, ScenePoint, SceneTri, fit_size, png, render, render_png, shade, + Canvas, LABEL_OFFSET, MAX_DIM, MAX_PIXELS, MAX_PRIMITIVES, RenderError, Rgb, Scene, SceneLabel, SceneLine, ScenePoint, SceneTri, fit_size, png, + render, render_png, shade, }; pub use vec::{Aabb3, Vec3}; diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index ac274d2..8dc6125 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -5,7 +5,7 @@ //! Adapted from SolidCraft's `crates/render/src/raster.rs` (MIT OR Apache-2.0), see //! ATTRIBUTION.md. -use crate::{Camera, Vec3}; +use crate::{Camera, Vec3, font}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Rgb(pub u8, pub u8, pub u8); @@ -41,11 +41,21 @@ pub struct ScenePoint { pub size: f64, } +/// Text beside a point (a reference point's name), drawn over everything else. The GPU viewport +/// draws labels with the interface's font; the CPU rasterizer with [`crate::font`]. +#[derive(Clone, Debug, PartialEq)] +pub struct SceneLabel { + pub p: Vec3, + pub text: String, + pub color: Rgb, +} + #[derive(Clone, Debug, Default, PartialEq)] pub struct Scene { pub tris: Vec, pub lines: Vec, pub points: Vec, + pub labels: Vec, /// Background gradient top → bottom. pub background: Option<(Rgb, Rgb)>, /// Scene radius around the camera target, for the clipping planes. @@ -131,6 +141,12 @@ pub fn fit_size(w: usize, h: usize) -> (usize, usize) { } /// Largest half-width of a line or point, in supersampled pixels. const MAX_MARK: f64 = 64.0; +/// Most labels drawn, and most characters drawn of one. +const MAX_LABELS: usize = 200; +const MAX_LABEL_CHARS: usize = 48; +/// Output pixels per font pixel, and a label's offset from its point (output pixels). +const LABEL_SCALE: usize = 2; +pub const LABEL_OFFSET: f64 = 8.0; /// Most samples drawn along one line segment (a segment from far off screen is bounded). const MAX_LINE_STEPS: usize = 20_000; @@ -166,7 +182,7 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Result MAX_PRIMITIVES { return Err(RenderError::SceneTooLarge { primitives }); } @@ -238,9 +254,49 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Result sw || y0 > sh || x0 < -sw || y0 < -sh { + return; + } + let luma = 0.3 * col[0] + 0.6 * col[1] + 0.1 * col[2]; + let halo = if luma > 0.5 { [0.0; 3] } else { [1.0; 3] }; + let (x0, y0) = (x0.floor() as i64, y0.floor() as i64); + let chars: Vec = text.chars().take(MAX_LABEL_CHARS).collect(); + // The halo, spread a supersampled pixel pair around each font pixel, then the text. + for (grow, color, a) in [(SS as i64, halo, 0.7), (0, col, 1.0)] { + for (i, ch) in chars.iter().enumerate() { + for fy in 0..font::H { + for fx in (0..font::W).filter(|&fx| font::on(*ch, fx, fy)) { + let (bx, by) = (x0 + ((i * font::ADVANCE + fx) * px) as i64, y0 + (fy * px) as i64); + for y in by - grow..by + px as i64 + grow { + for x in bx - grow..bx + px as i64 + grow { + if x >= 0 && y >= 0 && (x as usize) < c.w && (y as usize) < c.h { + c.put(x as usize, y as usize, color, a); + } + } + } + } + } + } + } +} + /// A depth-tested disc (`round`) or square of diameter `size` at screen point `p` (x, y, depth). fn dot(c: &mut Canvas, p: [f64; 3], size: f64, col: [f64; 3], round: bool) { // Widths are capped so a hostile width cannot make the loop below unbounded. @@ -373,6 +429,30 @@ mod tests { let _ = render(&wild, &cam, 32, 32); } + #[test] + fn labels_draw_over_everything_beside_their_point() { + let mut scene = red_triangle(); + let mut cam = Camera { distance: 50.0, ..Default::default() }; + cam.set_view(StandardView::Front); + let plain = render(&scene, &cam, 64, 64).unwrap(); + scene.labels.push(SceneLabel { p: Vec3::new(0.0, 0.0, 0.0), text: "RP".into(), color: Rgb(0, 0, 0) }); + let c = render(&scene, &cam, 64, 64).unwrap(); + // Up and right of the centre, some pixels turned dark; left of it nothing changed. + let dark = |c: &Canvas, x0: usize, x1: usize| { + (x0..x1).flat_map(|x| (0..32).map(move |y| (x, y))).filter(|&(x, y)| c.rgba[(y * 64 + x) * 4] < 40).count() + }; + assert!(dark(&c, 32, 64) > 10 && dark(&plain, 32, 64) == 0, "{}", dark(&c, 32, 64)); + assert_eq!(dark(&c, 0, 32), 0); + // Labels far off screen, empty or very long, or at a non-finite point, are bounded. + scene.labels = vec![ + SceneLabel { p: Vec3::new(1e9, 0.0, 0.0), text: "far".into(), color: Rgb(0, 0, 0) }, + SceneLabel { p: Vec3::new(f64::NAN, 0.0, 0.0), text: "nan".into(), color: Rgb(0, 0, 0) }, + SceneLabel { p: Vec3::ZERO, text: String::new(), color: Rgb(255, 255, 255) }, + SceneLabel { p: Vec3::ZERO, text: "x".repeat(100_000), color: Rgb(255, 255, 255) }, + ]; + assert!(render(&scene, &cam, 64, 64).is_ok()); + } + #[test] fn huge_scenes_are_refused() { let line = SceneLine { a: Vec3::ZERO, b: Vec3::X, color: Rgb(0, 0, 0), width: 1.0 }; diff --git a/crates/render/src/vec.rs b/crates/render/src/vec.rs index 3ccb16e..9fcc079 100644 --- a/crates/render/src/vec.rs +++ b/crates/render/src/vec.rs @@ -108,6 +108,13 @@ impl Aabb3 { self.min = Vec3::new(self.min.x.min(p.x), self.min.y.min(p.y), self.min.z.min(p.z)); self.max = Vec3::new(self.max.x.max(p.x), self.max.y.max(p.y), self.max.z.max(p.z)); } + /// Grow to hold `o` too (an empty box adds nothing). + pub fn union(&mut self, o: Aabb3) { + if !o.is_empty() { + self.add(o.min); + self.add(o.max); + } + } pub fn center(&self) -> Vec3 { (self.min + self.max) * 0.5 }