diff --git a/crates/engine/src/cmd/hatch/edit.rs b/crates/engine/src/cmd/hatch/edit.rs index 93fff84..a41783f 100644 --- a/crates/engine/src/cmd/hatch/edit.rs +++ b/crates/engine/src/cmd/hatch/edit.rs @@ -148,8 +148,12 @@ fn properties(h: &mut Hatch, p: &Value) { if !h.solid { // A named pattern replaces the gradient fill; the renderer draws the gradient first when it is set. h.gradient = None; + // A library pattern is drawn from the library; a loaded .pat pattern brings its lines; + // any other name is the hatch's own and keeps its definition. if cadcraft_doc::library::pattern(&h.pattern).is_some() { h.pattern_lines.clear(); + } else if let Some(user) = super::patfile::user_pattern(&h.pattern) { + h.pattern_lines = user.lines; } } } @@ -186,6 +190,7 @@ pub(super) fn run_hatchedit(s: &mut Session, p: &Value) -> Result { if let Some(up) = str_param(p, "pattern").map(|v| v.trim().to_ascii_uppercase()).filter(|v| !v.is_empty()) && up != "SOLID" && cadcraft_doc::library::pattern(&up).is_none() + && super::patfile::user_pattern(&up).is_none() { let d = s.doc()?; let own = hs.iter().all(|h| matches!(d.entity(*h).map(|e| &e.kind), Some(EntityKind::Hatch(x)) if x.pattern == up)); @@ -380,7 +385,7 @@ impl Interactive for HatchEditM { (Ask::Pattern, _) => { if let Some(t) = text.filter(|t| !t.is_empty()) { let up = t.to_ascii_uppercase(); - if cadcraft_doc::library::pattern(&up).is_none() { + if cadcraft_doc::library::pattern(&up).is_none() && super::patfile::user_pattern(&up).is_none() { s.echo(format!("Unknown pattern {up}.")); return Ok(Step::Continue); } diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 8889b6d..77da4d7 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -170,6 +170,9 @@ pub fn pdf(d: &Drawing, space: &Space, o: &PdfOptions) -> Result> { } else { Map { from: Vec2::ZERO, s: 1.0, to: Vec2::ZERO, k } }; + // Plot style ends, joins and fill patterns at the plot scale (a layout's are already drawn). + let mut list = list; + cadcraft_render::pen::apply(&mut list, map.s * unit_mm); let media = sheet.size * k; let clip_pt = if fit || matches!(space, Space::Model) { Bounds2::new(sheet.printable.min * k, sheet.printable.max * k) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c268ff2..c21129b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -15,6 +15,7 @@ mod linetype; mod linetype_text; mod mline; pub mod paper; +pub mod pen; mod plotstyle; mod point; pub mod raster; @@ -25,6 +26,7 @@ mod wide; use cadcraft_color::{Color, Rgb}; use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, Lineweight, Prim, Space}; use cadcraft_geom::{Bounds2, Mat3, Polyline, Vec2}; +use pen::Pen; pub use dim::{ArrowGeom, Arrowhead, DimGeometry, DimText, LineRole, arrowhead, dimension_geometry, dimension_geometry_with, format_linear_value}; pub use fill::triangulate_evenodd; @@ -77,6 +79,9 @@ pub struct DisplayList { pub sheet: Option, /// Block contents expanded while building (see [`MAX_BLOCK_EXPANSION`]). pub expanded: usize, + /// Plot style end, join and fill style per primitive (parallel to `prims`; missing entries + /// are "use object"), turned into geometry by [`pen::apply`]. + pub pens: Vec, } impl DPrim { @@ -213,10 +218,21 @@ impl Builder<'_> { None => (color, lw), } } + /// Record the end, join and fill style `color` prints with for the primitive pushed next. + fn note_pen(&mut self, color: Ink) { + let Some(s) = self.styles else { return }; + let pen = s.pen(color, self.named); + if !pen.is_object() { + let i = self.list.prims.len(); + self.list.pens.resize(i, Pen::default()); + self.list.pens.push(pen); + } + } fn polyline(&mut self, ctx: &Ctx, color: Ink, lw: f32, pts: &[Vec2]) { if pts.len() < 2 { return; } + self.note_pen(color); let (color, lw) = self.styled(color, lw); let start = self.list.verts.len() as u32; for p in pts { @@ -230,6 +246,7 @@ impl Builder<'_> { if tris.len() < 3 { return; } + self.note_pen(color); let (color, _) = self.styled(color, 0.0); let start = self.list.tris.len() as u32; for p in tris { @@ -338,6 +355,10 @@ fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> Di entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); } } + // A layout is drawn in paper units, so its pens become geometry now. + if let Some(sheet) = b.list.sheet { + pen::apply(&mut b.list, sheet.unit_mm); + } b.list } @@ -411,6 +432,8 @@ fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewpor } entity(&mut sub, &Ctx { lt_factor, ..top_ctx(d, xf, VIEWPORT_CONTENT, &vp.frozen_layers, &vp.layer_colors) }, me); } + // The contents are in paper units now: draw their pens before clipping. + pen::apply(&mut sub.list, paper::paper_unit_mm(d)); b.list.expanded = b.list.expanded.saturating_add(sub.list.expanded); match &poly { Some(p) => append_clipped(&mut b.list, &sub.list, &Clip::Poly(p)), diff --git a/crates/render/src/pen.rs b/crates/render/src/pen.rs new file mode 100644 index 0000000..b639d0b --- /dev/null +++ b/crates/render/src/pen.rs @@ -0,0 +1,375 @@ +//! Plot style line ends, line joins and fill patterns ([`Pen`]). +//! +//! While a display list is built with a plot style table, every line and filled area records the +//! end, join and fill style of the style it prints with ([`crate::DisplayList::pens`]). [`apply`] then +//! turns them into plain geometry once the size on paper is known, so every writer (PDF, SVG, +//! PNG, the screen) draws them without knowing about plot styles: +//! +//! - a line wider than [`WIDE_MM`] on paper whose style sets an end or join style becomes the +//! filled outline of the stroke, with butt, square, round or diamond ends and miter, bevel, +//! round or diamond joins. "Use object" ends and joins stay round, as lines are drawn anyway; +//! - a filled area whose style sets a fill pattern is filled with that pattern instead. +//! +//! The patterns are CADCraft's own definitions, measured in millimetres on paper and anchored to +//! the paper origin. Layouts apply them while building (paper units are known); a model-space +//! plot applies them once its plot scale is known. + +use cadcraft_doc::{EndStyle, FillStyle, JoinStyle}; +use cadcraft_geom::Vec2; + +use crate::{DisplayList, Kind}; + +/// End, join and fill style a primitive prints with (all "use object" when no plot style table +/// is applied). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Pen { + pub end: EndStyle, + pub join: JoinStyle, + pub fill: FillStyle, +} + +impl Pen { + /// Pen of a plot style. + pub fn of(s: &cadcraft_doc::PlotStyle) -> Pen { + Pen { end: s.end, join: s.join, fill: s.fill } + } + /// Whether [`apply`] has nothing to do for this pen. + pub fn is_object(&self) -> bool { + *self == Pen::default() + } + /// End or join styles that change how a wide line is drawn. + fn shapes_lines(&self) -> bool { + self.end != EndStyle::Object || self.join != JoinStyle::Object + } + /// A fill pattern that replaces a solid fill. + fn patterned(&self) -> bool { + !matches!(self.fill, FillStyle::Object | FillStyle::Solid) + } +} + +/// Lines up to this width on paper (mm) are drawn as plain strokes: their ends and joins are too +/// small to see. +pub const WIDE_MM: f32 = 0.25; + +/// Repeat distance of the fill patterns on paper (mm). +pub const PATTERN_MM: f64 = 1.0; + +/// Pattern cells visited per display list; past it the remaining areas stay solid (huge areas at +/// a tiny plot scale would otherwise produce unbounded geometry). +pub const MAX_PATTERN_CELLS: usize = 2_000_000; + +/// Miters longer than this many half widths are drawn bevelled (as PDF's default miter limit). +const MITER_LIMIT: f64 = 10.0; + +/// Segments of a half circle (round ends and joins). +const ROUND_STEPS: usize = 12; + +/// Turn the pens of `list` into geometry for output where one drawing unit is `mm_per_unit` +/// millimetres on paper. Lines with end or join styles become filled outlines and patterned +/// fills become the pattern's pieces. Each primitive stays one primitive (same order, handle and +/// colour) and the pens are used up, so applying twice changes nothing. +pub fn apply(list: &mut DisplayList, mm_per_unit: f64) { + let pens = std::mem::take(&mut list.pens); + if !(mm_per_unit.is_finite() && mm_per_unit > 0.0) { + return; + } + let mut budget = MAX_PATTERN_CELLS; + for (i, pen) in pens.iter().enumerate() { + let Some(p) = list.prims.get(i).copied() else { break }; + let out = match p.kind { + Kind::Polyline if pen.shapes_lines() && p.lw > WIDE_MM => { + let hw = f64::from(p.lw) / 2.0 / mm_per_unit; + Some(stroke(list.points(&p), hw, pen.end, pen.join)) + } + Kind::Tris if pen.patterned() => pattern(list.points(&p), pen.fill, PATTERN_MM / mm_per_unit, &mut budget), + _ => None, + }; + let Some(tris) = out else { continue }; + let (Ok(start), Ok(len)) = (u32::try_from(list.tris.len()), u32::try_from(tris.len())) else { continue }; + if start.checked_add(len).is_none() { + continue; + } + let Some(prim) = list.prims.get_mut(i) else { break }; + prim.kind = Kind::Tris; + prim.start = start; + prim.len = len; + prim.lw = 0.0; + list.tris.extend_from_slice(&tris); + } +} + +/// Push triangle `a b c` counter-clockwise (so overlapping pieces fill as a union under the +/// non-zero rule); degenerate ones are dropped. +fn tri(out: &mut Vec, a: Vec2, b: Vec2, c: Vec2) { + let area = (b - a).cross(c - a); + if !area.is_finite() || area.abs() <= 1e-300 { + return; + } + if area > 0.0 { + out.extend_from_slice(&[a, b, c]); + } else { + out.extend_from_slice(&[a, c, b]); + } +} + +/// Fan of the arc around `c` from `from` to `to` (radius `r`), turning the short way. +fn round_fan(out: &mut Vec, c: Vec2, r: f64, from: Vec2, to: Vec2) { + let a0 = from.y.atan2(from.x); + let mut sweep = to.y.atan2(to.x) - a0; + while sweep > std::f64::consts::PI { + sweep -= std::f64::consts::TAU; + } + while sweep < -std::f64::consts::PI { + sweep += std::f64::consts::TAU; + } + let steps = ((sweep.abs() / std::f64::consts::PI * ROUND_STEPS as f64).ceil() as usize).clamp(1, ROUND_STEPS * 2); + let mut prev = c + Vec2::from_angle(a0) * r; + for k in 1..=steps { + let q = c + Vec2::from_angle(a0 + sweep * k as f64 / steps as f64) * r; + tri(out, c, prev, q); + prev = q; + } +} + +/// Triangles of the outline of a line through `pts`, `hw` half width, with `end` caps and `join` +/// corners ("use object" = round). A polyline whose last point is its first is closed: joins all +/// round, no ends. +pub fn stroke(pts: &[Vec2], hw: f64, end: EndStyle, join: JoinStyle) -> Vec { + let mut out = Vec::new(); + if !(hw.is_finite() && hw > 0.0) { + return out; + } + let eps = hw * 1e-9; + let mut v: Vec = Vec::with_capacity(pts.len()); + for p in pts.iter().filter(|p| p.is_finite()) { + if v.last().is_none_or(|l| !l.near(*p, eps)) { + v.push(*p); + } + } + let closed = v.len() > 2 && v.first().zip(v.last()).is_some_and(|(a, b)| a.near(*b, eps)); + if closed { + v.pop(); + } + let n = v.len(); + let Some(first) = v.first().copied() else { return out }; + if n == 1 { + // A dot: both ends' caps back to back. + cap(&mut out, first, Vec2::X, hw, end); + cap(&mut out, first, -Vec2::X, hw, end); + return out; + } + let segs = if closed { n } else { n - 1 }; + for i in 0..segs { + let (Some(a), Some(b)) = (v.get(i).copied(), v.get((i + 1) % n).copied()) else { continue }; + let nrm = (b - a).normalized().perp() * hw; + tri(&mut out, a + nrm, a - nrm, b - nrm); + tri(&mut out, a + nrm, b - nrm, b + nrm); + } + let corners = if closed { 0..n } else { 1..n - 1 }; + for i in corners { + let (Some(p), Some(c), Some(q)) = (v.get((i + n - 1) % n).copied(), v.get(i).copied(), v.get((i + 1) % n).copied()) else { + continue; + }; + corner(&mut out, c, (c - p).normalized(), (q - c).normalized(), hw, join); + } + if !closed && let (Some(a), Some(a1), Some(b0), Some(b)) = (v.first(), v.get(1), v.get(n - 2), v.last()) { + cap(&mut out, *a, (*a - *a1).normalized(), hw, end); + cap(&mut out, *b, (*b - *b0).normalized(), hw, end); + } + out +} + +/// The cap at end point `p` of a line leaving it in unit direction `out_dir`. +fn cap(out: &mut Vec, p: Vec2, out_dir: Vec2, hw: f64, end: EndStyle) { + let n = out_dir.perp() * hw; + match end { + EndStyle::Butt => {} + EndStyle::Square => { + let f = out_dir * hw; + tri(out, p + n, p - n, p - n + f); + tri(out, p + n, p - n + f, p + n + f); + } + EndStyle::Diamond => tri(out, p + n, p - n, p + out_dir * hw), + EndStyle::Round | EndStyle::Object => round_fan(out, p, hw, n, -n), + } +} + +/// The corner at `c` between a segment arriving in direction `d0` and one leaving in `d1`. +fn corner(out: &mut Vec, c: Vec2, d0: Vec2, d1: Vec2, hw: f64, join: JoinStyle) { + let turn = d0.cross(d1); + if turn.abs() <= 1e-12 && d0.dot(d1) > 0.0 { + return; // straight on + } + // The outer side of the turn: right of a left turn, left of a right turn. + let side = if turn > 0.0 { -1.0 } else { 1.0 }; + let n0 = d0.perp() * (hw * side); + let n1 = d1.perp() * (hw * side); + let (o0, o1) = (c + n0, c + n1); + let bis = (n0 + n1).normalized(); + // Distance from the corner to the miter tip, in half widths. + let cos_half = bis.dot(n0) / hw; + let miter = if cos_half > 1e-9 { 1.0 / cos_half } else { f64::INFINITY }; + match join { + JoinStyle::Bevel => tri(out, c, o0, o1), + JoinStyle::Miter if miter <= MITER_LIMIT => { + let tip = c + bis * (hw * miter); + tri(out, c, o0, tip); + tri(out, c, tip, o1); + } + JoinStyle::Miter => tri(out, c, o0, o1), + JoinStyle::Diamond => { + // A point one half width out along the bisector, like a diamond end. + tri(out, c, o0, o1); + tri(out, o0, c + bis * hw, o1); + } + JoinStyle::Round | JoinStyle::Object => round_fan(out, c, hw, n0, n1), + } +} + +/// A family of pattern cells: bands across `a` (`width` wide every `pitch`), crossed with bands +/// across `b` when given; `checker` keeps every other cell of the grid. +struct Grid { + a: Vec2, + b: Option, + pitch: f64, + width: f64, + checker: bool, +} + +/// The cell families of a fill pattern with repeat distance `p` (drawing units). +fn grids(fill: FillStyle, p: f64) -> Vec { + let g = |a: Vec2, b: Option, pitch: f64, width: f64| Grid { a, b, pitch, width, checker: false }; + let r = std::f64::consts::FRAC_1_SQRT_2; + match fill { + FillStyle::Object | FillStyle::Solid => Vec::new(), + FillStyle::HorizontalBars => vec![g(Vec2::Y, None, p, p / 3.0)], + FillStyle::VerticalBars => vec![g(Vec2::X, None, p, p / 3.0)], + // Bands across (1, -1) run like `/`, across (1, 1) like `\`. + FillStyle::SlantRight => vec![g(Vec2::new(r, -r), None, p, p / 3.0)], + FillStyle::SlantLeft => vec![g(Vec2::new(r, r), None, p, p / 3.0)], + FillStyle::Crosshatch => vec![g(Vec2::Y, None, p, p / 5.0), g(Vec2::X, None, p, p / 5.0)], + FillStyle::SquareDots => vec![g(Vec2::X, Some(Vec2::Y), p / 2.0, p / 5.0)], + FillStyle::Checkerboard => vec![Grid { checker: true, ..g(Vec2::X, Some(Vec2::Y), p / 2.0, p / 2.0) }], + FillStyle::Diamonds => vec![g(Vec2::new(r, r), Some(Vec2::new(r, -r)), p * 0.7, p * 0.35)], + } +} + +/// Keep the part of convex polygon `poly` where `dot(n, p) >= c`. +fn clip_half(poly: &[Vec2], n: Vec2, c: f64) -> Vec { + let mut out = Vec::with_capacity(poly.len() + 1); + let Some(mut prev) = poly.last().copied() else { return out }; + let mut dp = n.dot(prev) - c; + for &q in poly { + let dq = n.dot(q) - c; + if (dp >= 0.0) != (dq >= 0.0) { + let t = dp / (dp - dq); + out.push(prev + (q - prev) * t); + } + if dq >= 0.0 { + out.push(q); + } + prev = q; + dp = dq; + } + out +} + +/// Range of band indices across `axis` that a triangle can touch. +fn band_range(t: &[Vec2; 3], axis: Vec2, pitch: f64) -> Option<(f64, f64)> { + let (lo, hi) = t.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(axis.dot(*p)), hi.max(axis.dot(*p)))); + let (a, b) = ((lo / pitch).floor() - 1.0, (hi / pitch).floor()); + (a.is_finite() && b.is_finite()).then_some((a, b)) +} + +/// The pieces of the triangles `tris` covered by `fill`'s pattern at repeat distance `pitch`, or +/// `None` (draw solid) when the pattern would take more than the remaining `budget` of cells. +pub fn pattern(tris: &[Vec2], fill: FillStyle, pitch: f64, budget: &mut usize) -> Option> { + if !(pitch.is_finite() && pitch > 0.0) { + return None; + } + let families = grids(fill, pitch); + let mut out = Vec::new(); + for t in tris.as_chunks::<3>().0 { + if t.iter().any(|p| !p.is_finite()) { + continue; + } + let [a, b, c] = *t; + let base: Vec = if (b - a).cross(c - a) >= 0.0 { vec![a, b, c] } else { vec![a, c, b] }; + for g in &families { + let (i0, i1) = band_range(t, g.a, g.pitch)?; + let (j0, j1) = match g.b { + Some(axis) => band_range(t, axis, g.pitch)?, + None => (0.0, 0.0), + }; + let cells = (i1 - i0 + 1.0) * (j1 - j0 + 1.0); + if !(cells.is_finite() && cells <= *budget as f64) { + return None; + } + *budget = budget.saturating_sub(cells as usize); + // Counted loops: at huge coordinates `i + 1.0` may not move. + for ki in 0..=(i1 - i0) as usize { + let i = i0 + ki as f64; + let s = i * g.pitch; + let band = clip_half(&clip_half(&base, g.a, s), -g.a, -(s + g.width)); + if band.len() < 3 { + continue; + } + let Some(axis) = g.b else { + fan(&mut out, &band); + continue; + }; + for kj in 0..=(j1 - j0) as usize { + let j = j0 + kj as f64; + if g.checker && !(ki + kj + (i0 + j0).rem_euclid(2.0) as usize).is_multiple_of(2) { + continue; + } + let s = j * g.pitch; + fan(&mut out, &clip_half(&clip_half(&band, axis, s), -axis, -(s + g.width))); + } + } + } + } + Some(out) +} + +/// Triangle fan of a convex polygon. +fn fan(out: &mut Vec, poly: &[Vec2]) { + let Some(first) = poly.first().copied() else { return }; + for w in poly.get(1..).unwrap_or(&[]).windows(2) { + if let [b, c] = w { + tri(out, first, *b, *c); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn area(tris: &[Vec2]) -> f64 { + tris.as_chunks::<3>().0.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a) / 2.0).sum() + } + + #[test] + fn caps_change_the_outline_length() { + let line = [Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0)]; + let butt = area(&stroke(&line, 1.0, EndStyle::Butt, JoinStyle::Miter)); + let square = area(&stroke(&line, 1.0, EndStyle::Square, JoinStyle::Miter)); + let diamond = area(&stroke(&line, 1.0, EndStyle::Diamond, JoinStyle::Miter)); + let round = area(&stroke(&line, 1.0, EndStyle::Round, JoinStyle::Miter)); + assert!((butt - 20.0).abs() < 1e-9, "{butt}"); + assert!((square - 24.0).abs() < 1e-9, "{square}"); + assert!((diamond - 22.0).abs() < 1e-9, "{diamond}"); + assert!(round > 23.0 && round < 20.0 + std::f64::consts::PI, "{round}"); + } + + #[test] + fn pattern_is_bounded() { + let big = [Vec2::new(0.0, 0.0), Vec2::new(1e9, 0.0), Vec2::new(0.0, 1e9)]; + let mut budget = 1000; + assert!(pattern(&big, FillStyle::Checkerboard, 1.0, &mut budget).is_none()); + let mut budget = MAX_PATTERN_CELLS; + assert!(pattern(&big, FillStyle::Checkerboard, f64::NAN, &mut budget).is_none()); + assert!(stroke(&[Vec2::new(f64::NAN, 0.0)], 1.0, EndStyle::Round, JoinStyle::Round).is_empty()); + } +} diff --git a/crates/render/src/plotstyle.rs b/crates/render/src/plotstyle.rs index ef87f99..6a2ed3c 100644 --- a/crates/render/src/plotstyle.rs +++ b/crates/render/src/plotstyle.rs @@ -7,12 +7,13 @@ //! style; layer 0 inside a block takes the reference's layer) or `ByBlock` (the block //! reference's); names the table lacks print as `Normal`. The //! style replaces or keeps the colour, turns it grey and screens it (toward the white paper), -//! and replaces the linetype and lineweight. Colour 7 prints black, as on paper. +//! and replaces the linetype and lineweight. Colour 7 prints black, as on paper. Its end, join +//! and fill styles travel with each primitive ([`crate::Pen`]). use cadcraft_color::{Rgb, nearest_aci}; use cadcraft_doc::{Common, Drawing, Linetype, PlotStyle, PlotStyleTable}; -use crate::{Ctx, Ink}; +use crate::{Ctx, Ink, Pen}; /// A table ready for the builder: styles plus their resolved linetype overrides. pub(crate) struct Styler { @@ -97,6 +98,11 @@ impl Styler { (Ink { rgb: s.apply_color(base), aci7: false, ..ink }, lw) } + /// The end, join and fill style of the style that applies to `ink`. + pub(crate) fn pen(&self, ink: Ink, named: Option) -> Pen { + self.style(ink, named).map(Pen::of).unwrap_or_default() + } + /// The linetype override of the style that applies to `ink`: `None` keeps the object's, /// `Some(None)` draws continuous. pub(crate) fn linetype(&self, ink: Ink, named: Option) -> Option> { diff --git a/crates/render/tests/plot_line_styles.rs b/crates/render/tests/plot_line_styles.rs new file mode 100644 index 0000000..8cad7e3 --- /dev/null +++ b/crates/render/tests/plot_line_styles.rs @@ -0,0 +1,179 @@ +//! Plot style line ends, joins and fill patterns (issue #564): wide lines become outlines with +//! the style's ends and joins, solid fills become the style's pattern. + +use std::sync::Arc; + +use cadcraft_color::Color; +use cadcraft_doc::{ + Common, Drawing, EndStyle, EntityKind, FillStyle, JoinStyle, Line, LwPolyline, PlotStyle, PlotStyleTable, Solid, Space, Viewport, +}; +use cadcraft_geom::{PolyVertex, Vec2, Vec3}; +use cadcraft_render::{DisplayList, Kind, Options, VIEWPORT_CONTENT, build, build_plot, pen}; + +/// A colour-dependent table whose colour 1 prints 1.00 mm wide with `style`'s end/join/fill. +fn table(end: EndStyle, join: JoinStyle, fill: FillStyle) -> Options { + let t = PlotStyleTable::color_dependent("pens.ctb", "", |i| { + (i == 1).then(|| PlotStyle { lineweight: Some(100), end, join, fill, ..PlotStyle::default() }) + }); + Options { plot_style_table: Some(Arc::new(t)), lineweights: true, ..Default::default() } +} + +fn red() -> Common { + Common { color: Color::Index(1), ..Default::default() } +} + +fn tri_area(l: &DisplayList) -> f64 { + l.prims + .iter() + .filter(|p| p.kind == Kind::Tris) + .flat_map(|p| l.points(p).as_chunks::<3>().0.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a) / 2.0)) + .sum() +} + +/// A 10-unit line plotted at 1 mm per unit. +fn line_plot(end: EndStyle, join: JoinStyle) -> DisplayList { + let mut d = Drawing::new_metric(); + d.add(&Space::Model, red(), EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(10.0, 0.0, 0.0) })).unwrap(); + let mut l = build_plot(&d, &Space::Model, &table(end, join, FillStyle::Object)); + pen::apply(&mut l, 1.0); + l +} + +#[test] +fn line_ends_shape_wide_lines() { + let object = line_plot(EndStyle::Object, JoinStyle::Object); + assert_eq!(object.prims[0].kind, Kind::Polyline, "use-object ends keep the stroke"); + assert_eq!(object.prims[0].lw, 1.0); + let butt = line_plot(EndStyle::Butt, JoinStyle::Object); + assert_eq!(butt.prims.len(), 1); + assert_eq!(butt.prims[0].kind, Kind::Tris); + assert!((tri_area(&butt) - 10.0).abs() < 1e-9, "butt: {}", tri_area(&butt)); + let square = line_plot(EndStyle::Square, JoinStyle::Object); + assert!((tri_area(&square) - 11.0).abs() < 1e-9, "square: {}", tri_area(&square)); + let diamond = line_plot(EndStyle::Diamond, JoinStyle::Object); + assert!((tri_area(&diamond) - 10.5).abs() < 1e-9, "diamond: {}", tri_area(&diamond)); + let round = line_plot(EndStyle::Round, JoinStyle::Object); + assert!(tri_area(&round) > 10.7 && tri_area(&round) < 10.0 + std::f64::consts::PI / 4.0); + // Applying again changes nothing; pens are spent. + let mut again = butt.clone(); + pen::apply(&mut again, 1.0); + assert_eq!(again.prims, butt.prims); + assert!(butt.pens.is_empty()); +} + +#[test] +fn joins_shape_the_corner() { + // An L: (0,0) → (10,0) → (10,10), 1 mm wide, butt ends. + let corner = |join| { + let mut d = Drawing::new_metric(); + let v = |x, y| PolyVertex::new(Vec2::new(x, y)); + let pl = + LwPolyline { vertices: vec![v(0.0, 0.0), v(10.0, 0.0), v(10.0, 10.0)], closed: false, const_width: 0.0, elevation: 0.0, plinegen: false }; + d.add(&Space::Model, red(), EntityKind::LwPolyline(pl)).unwrap(); + let mut l = build_plot(&d, &Space::Model, &table(EndStyle::Butt, join, FillStyle::Object)); + pen::apply(&mut l, 1.0); + // The union of the outline: rasterise coarsely by sampling the outer corner region. + let inside = |p: Vec2| { + l.prims.iter().any(|pr| { + l.points(pr).as_chunks::<3>().0.iter().any(|[a, b, c]| { + let s = |u: Vec2, w: Vec2| (w - u).cross(p - u) >= -1e-12; + s(*a, *b) && s(*b, *c) && s(*c, *a) + }) + }) + }; + (inside(Vec2::new(10.45, -0.45)), inside(Vec2::new(10.3, -0.3)), inside(Vec2::new(10.2, -0.2))) + }; + assert_eq!(corner(JoinStyle::Miter), (true, true, true), "miter fills the outer corner"); + assert_eq!(corner(JoinStyle::Bevel), (false, false, true), "bevel cuts it"); + assert_eq!(corner(JoinStyle::Round), (false, true, true), "round rounds it"); + assert_eq!(corner(JoinStyle::Diamond), (false, true, true), "diamond points into it"); +} + +#[test] +fn fill_patterns_replace_solid_fills() { + let mut d = Drawing::new_metric(); + let c = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 0.0), Vec3::new(0.0, 10.0, 0.0), Vec3::new(10.0, 10.0, 0.0)]; + d.add(&Space::Model, red(), EntityKind::Solid(Solid { corners: c })).unwrap(); + let area = |fill| { + let mut l = build_plot(&d, &Space::Model, &table(EndStyle::Object, JoinStyle::Object, fill)); + assert_eq!(l.prims.len(), 1); + pen::apply(&mut l, 1.0); + assert_eq!(l.prims.len(), 1, "one primitive per object"); + tri_area(&l) + }; + assert!((area(FillStyle::Solid) - 100.0).abs() < 1e-9); + assert!((area(FillStyle::Object) - 100.0).abs() < 1e-9); + for (fill, expect) in [ + (FillStyle::Checkerboard, 50.0), + (FillStyle::HorizontalBars, 100.0 / 3.0), + (FillStyle::VerticalBars, 100.0 / 3.0), + (FillStyle::SquareDots, 100.0 * 0.16), + ] { + let a = area(fill); + assert!((a - expect).abs() < 1.5, "{fill:?}: {a} (expected about {expect})"); + } + for fill in [FillStyle::Crosshatch, FillStyle::Diamonds, FillStyle::SlantLeft, FillStyle::SlantRight] { + let a = area(fill); + assert!(a > 15.0 && a < 60.0, "{fill:?}: {a}"); + } +} + +#[test] +fn layouts_draw_pens_while_building() { + let space = Space::Paper("Layout1".into()); + let mut d = Drawing::new_metric(); + d.add(&space, red(), EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(10.0, 0.0, 0.0) })).unwrap(); + let opts = table(EndStyle::Square, JoinStyle::Object, FillStyle::Object); + let l = build(&d, &space, &opts); + assert_eq!(l.prims[0].kind, Kind::Tris); + assert!(l.pens.is_empty(), "pens are used up"); + assert!((tri_area(&l) - 11.0).abs() < 1e-9); + // Thin lines stay strokes. + let thin = PlotStyleTable::color_dependent("thin.ctb", "", |i| { + (i == 1).then(|| PlotStyle { lineweight: Some(18), end: EndStyle::Square, ..PlotStyle::default() }) + }); + let l = build(&d, &space, &Options { plot_style_table: Some(Arc::new(thin)), lineweights: true, ..Default::default() }); + assert_eq!(l.prims[0].kind, Kind::Polyline); +} + +#[test] +fn hostile_scales_and_huge_fills_do_not_hang() { + let mut d = Drawing::new_metric(); + let c = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(1e12, 0.0, 0.0), Vec3::new(0.0, 1e12, 0.0), Vec3::new(1e12, 1e12, 0.0)]; + d.add(&Space::Model, red(), EntityKind::Solid(Solid { corners: c })).unwrap(); + d.add(&Space::Model, red(), EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(1e300, 0.0, 0.0) })).unwrap(); + let l = build_plot(&d, &Space::Model, &table(EndStyle::Diamond, JoinStyle::Diamond, FillStyle::Checkerboard)); + for mm in [1.0, 1e-300, 1e300, 0.0, -1.0, f64::NAN, f64::INFINITY] { + let mut x = l.clone(); + pen::apply(&mut x, mm); + assert_eq!(x.prims.len(), l.prims.len()); + } +} + +#[test] +fn viewport_contents_get_their_pens() { + // A 2:1 viewport onto a red model-space line: 1 mm wide on paper, square ends. + let mut d = Drawing::new_metric(); + d.add(&Space::Model, red(), EntityKind::Line(Line { a: Vec3::new(-10.0, 0.0, 0.0), b: Vec3::new(10.0, 0.0, 0.0) })).unwrap(); + let vp = Viewport { + center: Vec3::new(50.0, 50.0, 0.0), + width: 80.0, + height: 60.0, + view_center: Vec2::ZERO, + view_height: 30.0, + id: 2, + locked: false, + frozen_layers: Vec::new(), + layer_colors: Vec::new(), + clip: None, + on: true, + }; + let space = Space::Paper("Layout1".into()); + d.add(&space, Common::default(), EntityKind::Viewport(vp)).unwrap(); + let l = build_plot(&d, &space, &table(EndStyle::Square, JoinStyle::Object, FillStyle::Object)); + let content: Vec<_> = l.prims.iter().filter(|p| p.handle == VIEWPORT_CONTENT).collect(); + assert_eq!(content.len(), 1); + assert_eq!(content[0].kind, Kind::Tris); + // 40 mm long on paper plus two 0.5 mm square ends, 1 mm wide. + assert!((tri_area(&l) - 41.0).abs() < 1e-6, "{}", tri_area(&l)); +} diff --git a/docs/plot-styles.md b/docs/plot-styles.md index 8adb90a..55fc300 100644 --- a/docs/plot-styles.md +++ b/docs/plot-styles.md @@ -22,7 +22,32 @@ A style can set these: | `lineweight` | hundredths of a millimetre, 0–211; used when lineweights are plotted | absent / `null` | | `end`, `join`, `fill` | line end, line join and area fill style | `object` | -Colour 7 prints black. `end`, `join` and `fill` are stored but not applied yet. +Colour 7 prints black. + +### Line ends, joins and fill patterns + +- **`end` and `join`** shape lines wider than 0.25 mm on paper (from lineweights). The line + is drawn as its filled outline: `butt` ends at the end point, `square` reaches half the + width past it, `round` adds a half circle and `diamond` a point. `miter` joins meet in a + point (bevelled past 10 half widths), `bevel` cuts the corner, `round` rounds it and + `diamond` points out one half width. `object` is round, as lines are drawn anyway. +- **`fill`** replaces the solid fill of filled areas: 2D solids, traces, wide polylines, + solid hatches and filled text. `solid` and `object` keep it solid. The patterns are + CADCraft's own definitions, measured on paper and anchored to the sheet's origin. They + repeat every 1 mm: + +| Fill | Pattern | +|---|---| +| `checkerboard` | 0.5 mm squares, every other one filled | +| `crosshatch` | horizontal and vertical bars, 0.2 mm wide | +| `diamonds` | 0.35 mm squares turned 45°, every 0.7 mm | +| `horizontalBars`, `verticalBars` | bars a third of the repeat wide | +| `slantLeft`, `slantRight` | `\` and `/` bars a third of the repeat wide | +| `squareDots` | 0.2 mm squares every 0.5 mm | + +Wipeouts are never patterned. A plot that would need more than two million pattern cells keeps +the remaining areas solid. Layouts draw these while the display list is built. A model-space +plot draws them at its plot scale. ### Built-in tables