//! The parametric primitives. //! //! Every shape is built from the base's own measurements, so the same recipe //! produces a mark tuned to whichever face it is cut into. That is what "same //! design, not byte-identical" means in practice: `▲` in Quasi Mono and in a //! future Quasi Body will not share an outline, and both read as the mark. //! //! All seven marks are straight-edged, so every contour is a polygon of //! on-curve points. Nothing here emits a curve, and the day a recipe needs one //! it gets a quadratic rather than a cubic, because that is what `glyf` stores. use kurbo::BezPath; use crate::base::BaseParams; use crate::manifest::{Anchor, Dim, Direction, Shape}; /// A drawn mark, before it becomes a glyph. pub struct Drawing { pub contours: Vec>, } impl Drawing { /// A `BezPath` with every contour closed and wound the way `glyf` fills. /// /// TrueType fills non-zero with y up, so an outer contour runs clockwise, /// which is a negative shoelace area. Winding is enforced here rather than /// asked of each recipe: a mark that comes out inside-out is a bug nobody /// sees until a renderer drops it. pub fn to_bezpath(&self) -> BezPath { let mut path = BezPath::new(); for contour in &self.contours { let mut points = contour.clone(); if signed_area(&points) > 0.0 { points.reverse(); } let Some(&(x, y)) = points.first() else { continue; }; path.move_to((x, y)); for &(x, y) in &points[1..] { path.line_to((x, y)); } path.close_path(); } path } } /// The base's horizontal stroke weight, which is the set's one weight signal. fn stroke_of(params: &BaseParams) -> f64 { f64::from(params.stroke) } fn signed_area(points: &[(f64, f64)]) -> f64 { let n = points.len(); let mut area = 0.0; for i in 0..n { let (x0, y0) = points[i]; let (x1, y1) = points[(i + 1) % n]; area += x0 * y1 - x1 * y0; } area / 2.0 } pub fn draw(shape: &Shape, params: &BaseParams) -> Drawing { match shape { // The generated tier draws itself: it is sized off the cell rather than // the band, so it shares this module's output type and none of its // arithmetic. Shape::Cell(cell) => crate::cells::draw(*cell, params), Shape::Triangle { direction, span, depth, anchor, } => triangle(params, *direction, *span, *depth, *anchor), Shape::Arrow { direction, both_ends, length, head_span, head_depth, stroke, } => arrow( params, *direction, *both_ends, *length, *head_span, *head_depth, *stroke, ), Shape::Cross { width, height, stroke, } => cross(params, *width, *height, *stroke), Shape::OpenBox { width, height, bottom, stroke, } => open_box(params, *width, *height, *bottom, *stroke), Shape::ReturnArrow { top, shaft, head_span, head_depth, stroke, } => return_arrow(params, *top, *shaft, *head_span, *head_depth, *stroke), } } fn anchor_y(params: &BaseParams, anchor: Anchor) -> f64 { match anchor { Anchor::BandCenter => params.band_center_y(), Anchor::XHeight => params.x_height_center_y(), } } /// A solid triangle, sized off the band and centred on the cell. /// /// `span` is measured across the base edge and `depth` from that edge to the /// apex, both regardless of which way the mark points, so a left-pointing and /// an up-pointing triangle of the same numbers are the same triangle rotated. /// A shaft with a solid head at one or both ends. /// /// Built along an axis and then mapped onto the cell, so `↑` and `→` are one /// recipe rather than four, and the head is the same drawing as the sort caret /// it sits beside in a status line. #[allow(clippy::too_many_arguments)] fn arrow( params: &BaseParams, direction: Direction, both_ends: bool, length: Dim, head_span: Dim, head_depth: f64, stroke: f64, ) -> Drawing { let vertical = matches!(direction, Direction::Up | Direction::Down); // Along the arrow, and across it: a vertical arrow's length comes off the // band's height and its head's span off the band's width, and the other way // round for a horizontal one. let (along, across) = if vertical { (params.band_height(), params.band_width()) } else { (params.band_width(), params.band_height()) }; let len = length.resolve(along, stroke_of(params)); let span = head_span.resolve(across, stroke_of(params)); let depth = span * head_depth; let half_shaft = stroke_of(params) * stroke / 2.0; let cx = params.center_x(); let cy = params.band_center_y(); // In arrow space: `u` runs along the arrow towards its head, `v` across. let place = |u: f64, v: f64| -> (f64, f64) { match direction { Direction::Up => (cx + v, cy + u), Direction::Down => (cx + v, cy - u), Direction::Right => (cx + u, cy + v), Direction::Left => (cx - u, cy + v), } }; let half = len / 2.0; // The shaft stops inside the head rather than at its base, so the two read // as one mark at a terminal's size instead of a bar touching a triangle. let overlap = depth / 2.0; let tail = if both_ends { -half + depth - overlap } else { -half }; let mut contours = vec![vec![ place(tail, -half_shaft), place(half - depth + overlap, -half_shaft), place(half - depth + overlap, half_shaft), place(tail, half_shaft), ]]; contours.push(vec![ place(half - depth, -span / 2.0), place(half, 0.0), place(half - depth, span / 2.0), ]); if both_ends { contours.push(vec![ place(-half + depth, -span / 2.0), place(-half, 0.0), place(-half + depth, span / 2.0), ]); } Drawing { contours } } fn triangle( params: &BaseParams, direction: Direction, span: Dim, depth: f64, anchor: Anchor, ) -> Drawing { let cx = params.center_x(); let cy = anchor_y(params, anchor); let (span_px, depth_px) = match direction { // A horizontal mark's span runs up the cell, so it comes off the band's // height; a vertical mark's runs across, off the band's width. Direction::Up | Direction::Down => { let s = span.resolve(params.band_width(), stroke_of(params)); (s, s * depth) } Direction::Left | Direction::Right => { let s = span.resolve(params.band_height(), stroke_of(params)); (s, s * depth) } }; let half_span = span_px / 2.0; let half_depth = depth_px / 2.0; let points = match direction { Direction::Up => vec![ (cx - half_span, cy - half_depth), (cx + half_span, cy - half_depth), (cx, cy + half_depth), ], Direction::Down => vec![ (cx - half_span, cy + half_depth), (cx + half_span, cy + half_depth), (cx, cy - half_depth), ], Direction::Right => vec![ (cx - half_depth, cy - half_span), (cx - half_depth, cy + half_span), (cx + half_depth, cy), ], Direction::Left => vec![ (cx + half_depth, cy - half_span), (cx + half_depth, cy + half_span), (cx - half_depth, cy), ], }; Drawing { contours: vec![points], } } /// Two crossed strokes as one contour: the twelve-point X. /// /// Drawn as a single outline rather than two overlapping bars so the fill is /// correct under any fill rule and the join at the centre is a real join. fn cross(params: &BaseParams, width: Dim, height: Dim, stroke: f64) -> Drawing { let cx = params.center_x(); let cy = params.band_center_y(); let base_stroke = stroke_of(params); let half_w = width.resolve(params.band_width(), base_stroke) / 2.0; let half_h = height.resolve(params.band_height(), base_stroke) / 2.0; let weight = base_stroke * stroke; // The arms only sit at 45 degrees when the extents are square, so both // offsets are derived from the diagonal rather than assumed equal. `gap_y` // is where the arms' inner edges meet above and below the centre; `gap_x` // is the same meeting left and right. The arm ends are cut across the // corner, which puts the same two offsets at each end. let diagonal = half_w.hypot(half_h); let gap_y = weight / 2.0 * diagonal / half_w; let gap_x = weight / 2.0 * diagonal / half_h; let points = vec![ (cx - half_w, cy + half_h - gap_y), (cx - half_w + gap_x, cy + half_h), (cx, cy + gap_y), (cx + half_w - gap_x, cy + half_h), (cx + half_w, cy + half_h - gap_y), (cx + gap_x, cy), (cx + half_w, cy - half_h + gap_y), (cx + half_w - gap_x, cy - half_h), (cx, cy - gap_y), (cx - half_w + gap_x, cy - half_h), (cx - half_w, cy - half_h + gap_y), (cx - gap_x, cy), ]; Drawing { contours: vec![points], } } /// `U+2423`, a box open at the top: two risers and a floor, one contour. fn open_box(params: &BaseParams, width: Dim, height: Dim, bottom: f64, stroke: f64) -> Drawing { let cx = params.center_x(); let base_stroke = stroke_of(params); let half_w = width.resolve(params.band_width(), base_stroke) / 2.0; let h = height.resolve(params.band_height(), base_stroke); let y0 = params.band_height() * bottom; let y1 = y0 + h; // The risers take the vertical stroke weight and the floor the horizontal // one, which is what the base does with every other box it draws. let riser = f64::from(params.stem) * stroke; let floor = f64::from(params.stroke) * stroke; let points = vec![ (cx - half_w, y1), (cx - half_w + riser, y1), (cx - half_w + riser, y0 + floor), (cx + half_w - riser, y0 + floor), (cx + half_w - riser, y1), (cx + half_w, y1), (cx + half_w, y0), (cx - half_w, y0), ]; Drawing { contours: vec![points], } } /// `U+23CE`: a left-pointing arrow along the bottom with a riser at its right /// end, drawn as one contour so the elbow is a join rather than an overlap. fn return_arrow( params: &BaseParams, top: f64, shaft: f64, head_span: Dim, head_depth: Dim, stroke: f64, ) -> Drawing { let x0 = f64::from(params.band_x0); let x1 = f64::from(params.band_x1); let y_floor = f64::from(params.band_y0); let band_h = params.band_height(); let weight = f64::from(params.stroke) * stroke; let riser_weight = f64::from(params.stem) * stroke; let half = weight / 2.0; let shaft_y = y_floor + band_h * shaft; let cap_y = y_floor + band_h * top; // The head is solid geometry, so it comes off the band and holds still // across weights. Sizing it in multiples of the stroke instead would grow // it by two thirds into Bold and push its back past the riser. let head_half = head_span.resolve(band_h, f64::from(params.stroke)) / 2.0; let head_x = x0 + head_depth.resolve(x1 - x0, f64::from(params.stroke)); let points = vec![ // The tip, then up the head's back and into the shaft. (x0, shaft_y), (head_x, shaft_y + head_half), (head_x, shaft_y + half), // Along the shaft's top edge to the riser, then up it. (x1 - riser_weight, shaft_y + half), (x1 - riser_weight, cap_y), (x1, cap_y), // Down the riser's right edge and back along the shaft's underside. (x1, shaft_y - half), (head_x, shaft_y - half), (head_x, shaft_y - head_half), ]; Drawing { contours: vec![points], } } #[cfg(test)] mod tests { use super::*; use crate::manifest::{Manifest, WeightResponse}; /// Atkinson Hyperlegible Mono at `wght` 200, its own default instance from /// the pinned file (`quasi-type params`). /// /// These were Plex Mono Regular and Bold until the base moved. The two ends /// of one axis are a wider span than two static cuts were — 200 to 800 /// against 400 to 700 — which is why the coefficients they check had to be /// refitted rather than carried over. fn light() -> BaseParams { BaseParams { upem: 1000, advance: 632, cap_height: 668, x_height: 496, stem: 54, stroke: 55, band_x0: 68, band_x1: 564, band_y0: 0, band_y1: 496, ascent: 984, descent: -316, } } /// The far end of the axis, `wght` 800. The cell holds and the strokes /// nearly triple, which is the measurement the set's weight rule rests on: /// the base's stem runs 54 units to 158 where Plex's ran 70 to 126. fn heavy() -> BaseParams { BaseParams { stem: 158, stroke: 139, // The band widens with the weight; its height does not. band_x0: 58, band_x1: 574, ..light() } } fn bounds(drawing: &Drawing) -> (f64, f64, f64, f64) { let points = drawing.contours.concat(); let xs: Vec = points.iter().map(|p| p.0).collect(); let ys: Vec = points.iter().map(|p| p.1).collect(); ( xs.iter().copied().fold(f64::MAX, f64::min), ys.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max), ys.iter().copied().fold(f64::MIN, f64::max), ) } /// How much of its own bounding box a mark inks in. /// /// Contours are summed rather than unioned, so a shaft running into its own /// head counts twice. That is the same reading the built face is measured /// with, and both are comparing a mark against itself at another weight. fn fill_of(drawing: &Drawing) -> f64 { let ink: f64 = drawing.contours.iter().map(|c| signed_area(c).abs()).sum(); let (x0, y0, x1, y1) = bounds(drawing); ink / ((x1 - x0) * (y1 - y0)) } fn shape(name: &str) -> Shape { let manifest = Manifest::parse(crate::HOUSE_SET).unwrap(); let mut glyphs = manifest.glyphs; let index = glyphs .iter() .position(|g| g.name == name) .unwrap_or_else(|| panic!("no glyph {name}")); glyphs.swap_remove(index).shape } /// Two heads on one shaft have to leave a shaft between them. /// /// At the single-ended arrows' proportions they do not: two heads take 82% /// of the length, meet in the middle, and the mark reads as a bowtie. That /// is why `↕` carries its own smaller head rather than the set's, and this /// is the measurement behind it rather than the eye that caught it. #[test] fn an_arrow_with_two_heads_still_has_a_shaft() { let params = light(); let drawing = draw(&shape("uni2195"), ¶ms); let heads: Vec<&Vec<(f64, f64)>> = drawing.contours.iter().filter(|c| c.len() == 3).collect(); assert_eq!(heads.len(), 2, "`↕` has a head at each end"); let top = heads .iter() .map(|c| c.iter().map(|p| p.1).fold(f64::MIN, f64::max)) .fold(f64::MIN, f64::max); let bottom = heads .iter() .map(|c| c.iter().map(|p| p.1).fold(f64::MAX, f64::min)) .fold(f64::MAX, f64::min); let head_depth = heads .iter() .map(|c| { let ys: Vec = c.iter().map(|p| p.1).collect(); ys.iter().copied().fold(f64::MIN, f64::max) - ys.iter().copied().fold(f64::MAX, f64::min) }) .fold(f64::MIN, f64::max); let shaft = (top - bottom) - head_depth * 2.0; assert!( shaft > (top - bottom) * 0.25, "the heads leave {shaft:.0} units of shaft in {:.0} of arrow", top - bottom ); } #[test] fn every_mark_fits_inside_the_cell() { let manifest = Manifest::parse(crate::HOUSE_SET).unwrap(); for params in [light(), heavy()] { // Band-relative marks only. Cell furniture is sized against the cell // instead and obeys different rules, which `crate::cells` asserts. for glyph in manifest .glyphs .iter() .filter(|glyph| !glyph.shape.is_cell_furniture()) { let drawing = draw(&glyph.shape, ¶ms); let (x0, _, x1, _) = bounds(&drawing); assert!( x0 >= 0.0 && x1 <= f64::from(params.advance), "{} runs outside the cell: x[{x0}, {x1}] in {}", glyph.name, params.advance ); } } } #[test] fn every_mark_sits_above_the_descender() { let manifest = Manifest::parse(crate::HOUSE_SET).unwrap(); let params = light(); // Band-relative marks only. Cell furniture is sized against the cell // instead and obeys different rules, which `crate::cells` asserts. for glyph in manifest .glyphs .iter() .filter(|glyph| !glyph.shape.is_cell_furniture()) { let (_, y0, _, y1) = bounds(&draw(&glyph.shape, ¶ms)); assert!( y0 > -350.0, "{} dips below the base's descender", glyph.name ); assert!( y1 <= f64::from(params.cap_height) + 60.0, "{} rides above cap height", glyph.name ); } } #[test] fn marks_are_centred_on_the_cell_not_the_band() { let params = light(); for name in ["uni25B2", "uni25BC", "uni25B8", "uni25C2", "uni2718"] { let (x0, _, x1, _) = bounds(&draw(&shape(name), ¶ms)); let centre = f64::midpoint(x0, x1); assert!( (centre - params.center_x()).abs() < 0.51, "{name} centres at {centre}, not {}", params.center_x() ); } } #[test] fn every_mark_answers_a_heavier_base() { let manifest = Manifest::parse(crate::HOUSE_SET).unwrap(); // Band-relative marks only. Cell furniture is sized against the cell // instead and obeys different rules, which `crate::cells` asserts. for glyph in manifest .glyphs .iter() .filter(|glyph| !glyph.shape.is_cell_furniture()) { let light = draw(&glyph.shape, &light()).to_bezpath(); let bold = draw(&glyph.shape, &heavy()).to_bezpath(); assert_ne!( light.to_svg(), bold.to_svg(), "{} is drawn identically at both weights, so it will read light \ inside the Bold face", glyph.name ); } } /// The discriminator is fill, not extent. /// /// It was extent until the base moved, and that read the two responses apart /// only because Plex's two static cuts were 400 and 700 apart. Over a whole /// 200-800 axis a stroked mark's own band term carries it 1.15x wider on its /// own, so every mark grows and the test said nothing. What the two /// responses actually mean is how much of its own box a mark inks: a solid /// mark scales, so it inks the same share of a larger box, and a stroked one /// thickens inside a box that barely moves. #[test] fn a_solid_mark_scales_and_a_stroked_one_thickens() { let manifest = Manifest::parse(crate::HOUSE_SET).unwrap(); // Band-relative marks only. Cell furniture is sized against the cell // instead and obeys different rules, which `crate::cells` asserts. for glyph in manifest .glyphs .iter() .filter(|glyph| !glyph.shape.is_cell_furniture()) { let thin = fill_of(&draw(&glyph.shape, &light())); let thick = fill_of(&draw(&glyph.shape, &heavy())); match glyph.shape.weight_response() { WeightResponse::Grows => { let light_bounds = bounds(&draw(&glyph.shape, &light())); let heavy_bounds = bounds(&draw(&glyph.shape, &heavy())); assert!( (thick - thin).abs() < 0.01, "{} inks {thin:.3} of its box at the light end and {thick:.3} at the \ heavy one. A solid mark scales, so its fill is the shape's own constant.", glyph.name ); assert!( (heavy_bounds.2 - heavy_bounds.0) > (light_bounds.2 - light_bounds.0) + 0.5, "{} has no stroke to thicken, so it has to grow", glyph.name ); } // Only the generated cell fills hold, and the manifest's own // marks are never one. WeightResponse::Holds => unreachable!("{} is an authored mark", glyph.name), WeightResponse::Thickens => assert!( thick > thin * 1.3, "{} inks {thin:.3} of its box at the light end and {thick:.3} at the heavy \ one, so its stroke is not following the base's", glyph.name ), } } } /// An open box has to stay open, which is the rule its recalibration holds. /// /// The counter is what makes the mark read as a box rather than as a blob, /// and it is the base's stroke that eats it: Atkinson's nearly triples /// across the axis where Plex's grew 1.8x between two static cuts. So this /// is checked at the heavy end, where the aperture is scarce, and it is /// checked as a share of the box rather than in units, so it refits. #[test] fn the_space_render_keeps_its_counter_open() { let Shape::OpenBox { width, height, stroke, .. } = shape("uni2423") else { panic!("uni2423 is not an open box"); }; for params in [light(), heavy()] { let bar = f64::from(params.stroke) * stroke; let box_width = width.resolve(params.band_width(), f64::from(params.stroke)); let box_height = height.resolve(params.band_height(), f64::from(params.stroke)); // Two risers across, and one bar up: the box is open at the top. let across = (box_width - 2.0 * bar) / box_width; let up = (box_height - bar) / box_height; assert!( across > 0.45 && up > 0.45, "the space render's counter is {:.0}% of its width and {:.0}% of its height at \ stroke {}, which closes up into a blob rather than reading as a box", across * 100.0, up * 100.0, params.stroke ); } } /// A head that does not clear its own shaft is not an arrowhead. /// /// The bound is the shaft's stroke: the head has to stand at least half a /// stroke proud on each side at every weight. Bold is where this bites, /// since the shaft thickens by 69% and a band-sized head does not. #[test] fn the_return_arrows_head_clears_its_shaft() { let Shape::ReturnArrow { head_span, stroke, .. } = shape("uni23CE") else { panic!("uni23CE is not a return arrow"); }; for params in [light(), heavy()] { let band = params.band_height(); let weight = f64::from(params.stroke) * stroke; let head = head_span.resolve(band, f64::from(params.stroke)); let proud = (head - weight) / 2.0; assert!( proud >= weight * 0.5, "the head stands {proud:.0} proud of a {weight:.0} shaft" ); } } #[test] fn the_carets_are_reflections_of_each_other() { let params = light(); let up = bounds(&draw(&shape("uni25B2"), ¶ms)); let down = bounds(&draw(&shape("uni25BC"), ¶ms)); assert!((up.0 - down.0).abs() < 0.01 && (up.2 - down.2).abs() < 0.01); let cy = params.band_center_y(); assert!( ((up.1 - cy) + (down.3 - cy)).abs() < 0.01, "▲ and ▼ are not mirrored about the band" ); } #[test] fn every_contour_comes_out_wound_for_glyf() { let manifest = Manifest::parse(crate::HOUSE_SET).unwrap(); let params = light(); for glyph in &manifest.glyphs { let drawing = draw(&glyph.shape, ¶ms); for contour in &drawing.contours { let mut points = contour.clone(); if signed_area(&points) > 0.0 { points.reverse(); } assert!( signed_area(&points) < 0.0, "{} has a degenerate contour", glyph.name ); } } } /// The X is calibrated against the base's own cross rather than by eye. /// /// Atkinson Hyperlegible Mono's `×` (U+00D7) fills 30.0% of its bounding /// box at `wght` 200 and 56.9% at 800 by flattening its outline. U+2718 is /// the HEAVY ballot X, so it has to sit above that and not far above it. /// The first attempt used a flat 1.5x the base stroke and put 213 units of /// ink across a 380-unit mark, which is the failure this bounds — and the /// coefficient is refitted per base rather than carried over, since Plex's /// numbers were 38.2% and 53.4% and its bar was heavier against its own /// cross than Atkinson's is. #[test] fn the_cross_is_heavier_than_the_bases_own_and_no_heavier() { for (params, base_fill) in [(light(), 0.300), (heavy(), 0.569)] { let drawing = draw(&shape("uni2718"), ¶ms); let ink = signed_area(&drawing.contours[0]).abs(); let (x0, y0, x1, y1) = bounds(&drawing); let fill = ink / ((x1 - x0) * (y1 - y0)); assert!( fill > base_fill, "the X fills {:.1}%, lighter than the base's own cross at {:.1}%", fill * 100.0, base_fill * 100.0 ); assert!( fill < base_fill + 0.04, "the X fills {:.1}%, well past the base's {:.1}%", fill * 100.0, base_fill * 100.0 ); } } }