From 135c928feaf38613fbd447d0556155a8831b2ff8 Mon Sep 17 00:00:00 2001 From: aldozar Date: Sun, 11 Oct 2026 17:54:18 -0600 Subject: [PATCH] Hatches: draw the pattern definition stored in the file; scale library patterns in metric drawings (#643) Squash of PR #643 by @aldozar. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/library.rs | 83 +++++++++++++++++++++------- crates/engine/src/cmd/props.rs | 2 + crates/io/src/dxf_read.rs | 7 ++- crates/io/src/dxf_write.rs | 9 ++- crates/io/tests/hatch_pattern_def.rs | 22 +++++++- crates/render/src/hatch.rs | 30 ++++++---- 6 files changed, 115 insertions(+), 38 deletions(-) diff --git a/crates/doc/src/library.rs b/crates/doc/src/library.rs index 7941a68..ab714d1 100644 --- a/crates/doc/src/library.rs +++ b/crates/doc/src/library.rs @@ -203,28 +203,73 @@ pub fn standard_patterns() -> Vec { description: "Staircase effect", lines: vec![pl(0.0, (0.0, 0.0), (0.125, 0.125), &[0.125, -0.125]), pl(90.0, (0.125, 0.0), (0.125, 0.125), &[0.125, -0.125])], }, - HatchPattern { - name: "AR-CONC", - description: "Random dot and stone pattern", - lines: vec![ - pl(50.0, (0.0, 0.0), (0.4, 0.25), &[0.15, -0.6]), - pl(355.0, (0.0, 0.0), (0.27, 0.3), &[0.12, -0.5]), - pl(100.0, (0.12, 0.05), (0.35, 0.33), &[0.1, -0.55]), - pl(0.0, (0.07, 0.02), (0.21, 0.17), &[0.0, -0.4]), - ], - }, - HatchPattern { - name: "AR-SAND", - description: "Random dot pattern", - lines: vec![ - pl(37.5, (0.0, 0.0), (0.112, 0.0912), &[0.0, -0.22, 0.0, -0.14]), - pl(7.5, (0.0, 0.0), (0.157, 0.0724), &[0.0, -0.19, 0.0, -0.31]), - pl(-32.5, (0.0, 0.0), (0.091, 0.1312), &[0.0, -0.27, 0.0, -0.11]), - ], - }, + // Drawn at the scale of the pattern CAD programs ship under this name (about 16x + // coarser than first drawn here: AR-CONC hatches came out hundreds of times too dense). + scaled_pattern( + HatchPattern { + name: "AR-CONC", + description: "Random dot and stone pattern", + lines: vec![ + pl(50.0, (0.0, 0.0), (0.4, 0.25), &[0.15, -0.6]), + pl(355.0, (0.0, 0.0), (0.27, 0.3), &[0.12, -0.5]), + pl(100.0, (0.12, 0.05), (0.35, 0.33), &[0.1, -0.55]), + pl(0.0, (0.07, 0.02), (0.21, 0.17), &[0.0, -0.4]), + ], + }, + 16.0, + ), + // Drawn at the scale of the pattern CAD programs ship under this name (about 16x + // coarser than first drawn here: AR-CONC hatches came out hundreds of times too dense). + scaled_pattern( + HatchPattern { + name: "AR-SAND", + description: "Random dot pattern", + lines: vec![ + pl(37.5, (0.0, 0.0), (0.112, 0.0912), &[0.0, -0.22, 0.0, -0.14]), + pl(7.5, (0.0, 0.0), (0.157, 0.0724), &[0.0, -0.19, 0.0, -0.31]), + pl(-32.5, (0.0, 0.0), (0.091, 0.1312), &[0.0, -0.27, 0.0, -0.11]), + ], + }, + 16.0, + ), ] } pub fn pattern(name: &str) -> Option { standard_patterns().into_iter().find(|p| p.name.eq_ignore_ascii_case(name)) } + +/// The library pattern `name` as drawn in `d`. In a metric drawing (`MEASUREMENT` 1) every +/// length is multiplied by 25.4, as CAD programs use ISO-scaled pattern definitions there (as +/// for linetypes, see [`standard_linetypes_for`]): a hatch at scale 1 measures in millimetres +/// what it measures in inches in an imperial drawing. +pub fn pattern_for(d: &Drawing, name: &str) -> Option { + let p = pattern(name)?; + Some(if d.header.i64("MEASUREMENT", 0) == 1 { scaled_pattern(p, 25.4) } else { p }) +} + +/// `p` with every length (origins, offsets, dashes) multiplied by `k`. +fn scaled_pattern(mut p: HatchPattern, k: f64) -> HatchPattern { + for l in &mut p.lines { + l.origin = (l.origin.0 * k, l.origin.1 * k); + l.delta = (l.delta.0 * k, l.delta.1 * k); + for d in &mut l.dashes { + *d *= k; + } + } + p +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn metric_drawings_draw_iso_scaled_patterns() { + let imp = pattern_for(&Drawing::new_imperial(), "ANSI31").map(|p| p.lines[0].delta.1); + let met = pattern_for(&Drawing::new_metric(), "ansi31").map(|p| p.lines[0].delta.1); + assert_eq!(imp, Some(0.125)); + assert!(met.is_some_and(|m| (m - 0.125 * 25.4).abs() < 1e-12), "{met:?}"); + assert!(pattern_for(&Drawing::new_metric(), "NOPE").is_none()); + } +} diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 60bf05c..35502c2 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -374,6 +374,8 @@ fn run_set(s: &mut Session, p: &Value) -> Result { h.pattern_lines = super::hatch::lines_for(v); } h.pattern = v.to_ascii_uppercase(); + // The old pattern's definition lines no longer apply. + h.pattern_lines.clear(); h.solid = h.pattern == "SOLID"; if !h.solid { // A named pattern replaces the gradient fill; the renderer draws the gradient first when it is set. diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 57a4d3f..39c4cbe 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -742,9 +742,10 @@ fn hatch(tags: &[Tag], t: &T) -> Option { } let solid = solid || pattern.eq_ignore_ascii_case("SOLID"); let (scale, angle) = (t.fd(41, 1.0), t.fd(52, 0.0).to_radians()); - // Definition lines matter only for a pattern the library doesn't define. - let pattern_lines = - if solid || cadcraft_doc::library::pattern(&pattern).is_some() { Vec::new() } else { pattern_definition(tags, scale, angle, origin) }; + // Keep the file's definition lines even for a name the library knows: the library's versions + // of some patterns (AR-CONC) only approximate AutoCAD's, and drawing them at the file's scale + // could be hundreds of times denser than the hatch the author saw. + let pattern_lines = if solid { Vec::new() } else { pattern_definition(tags, scale, angle, origin) }; Some(EntityKind::Hatch(Hatch { pattern: pattern.to_ascii_uppercase(), solid, diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 707dceb..24fbaec 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -924,9 +924,12 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx w.f(52, h.angle.to_degrees()); w.f(41, h.scale); w.i(77, 0); - let pat = cadcraft_doc::library::pattern(&h.pattern); - // A pattern the library doesn't define keeps the lines read from its file. - let lines = pat.map(|p| p.lines).unwrap_or_else(|| h.pattern_lines.clone()); + // The lines read from the file, else the library's pattern of that name. + let lines = if h.pattern_lines.is_empty() { + cadcraft_doc::library::pattern_for(d, &h.pattern).map(|p| p.lines).unwrap_or_default() + } else { + h.pattern_lines.clone() + }; w.i(78, lines.len() as i64); for pl in lines { let ang = pl.angle.to_radians() + h.angle; diff --git a/crates/io/tests/hatch_pattern_def.rs b/crates/io/tests/hatch_pattern_def.rs index 036d89a..6d2502c 100644 --- a/crates/io/tests/hatch_pattern_def.rs +++ b/crates/io/tests/hatch_pattern_def.rs @@ -98,9 +98,27 @@ fn custom_pattern_lines_roundtrip_scaled_and_rotated() { } #[test] -fn library_patterns_keep_no_definition() { +fn library_named_patterns_keep_the_files_definition() { + // The library's AR-CONC only approximates AutoCAD's: the file's own lines are kept and saved. + let h = first_hatch(&save(hatch("AR-CONC", 2.0, 0.0, Vec2::ZERO, custom_lines()))); + assert!(near(&h.pattern_lines, &custom_lines()), "{:?}", h.pattern_lines); + // A hatch without lines of its own saves the library's definition, which reads back as lines. let h = first_hatch(&save(hatch("ANSI31", 1.0, 0.0, Vec2::ZERO, vec![]))); - assert!(h.pattern_lines.is_empty()); + assert_eq!(h.pattern_lines.len(), 1); +} + +#[test] +fn file_definition_is_drawn_instead_of_the_librarys() { + let prims = |lines: Vec| { + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), EntityKind::Hatch(hatch("ANSI31", 1.0, 0.0, Vec2::ZERO, lines))).expect("add"); + cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()).prims.len() + }; + // One horizontal family 5 units apart from y = 2.5 across the 10 × 10 square: two lines. + let sparse = vec![PatternLine { angle: 0.0, origin: (0.0, 2.5), delta: (0.0, 5.0), dashes: vec![] }]; + assert_eq!(prims(sparse), 2); + // Without file lines, the library's ANSI31 (0.125 apart) is drawn. + assert!(prims(vec![]) > 100); } #[test] diff --git a/crates/render/src/hatch.rs b/crates/render/src/hatch.rs index 7ffd92e..e9aa54d 100644 --- a/crates/render/src/hatch.rs +++ b/crates/render/src/hatch.rs @@ -4,8 +4,8 @@ use super::{Builder, Ctx, Ink, fill, ink}; use cadcraft_color::{Color, Rgb}; -use cadcraft_doc::library::pattern; -use cadcraft_doc::{Entity, Gradient, Hatch}; +use cadcraft_doc::library::pattern_for; +use cadcraft_doc::{Drawing, Entity, Gradient, Hatch}; use cadcraft_geom::{Bounds2, Polyline, Vec2, point_in_polygon}; /// Draw a hatch entity: `rgb` is its resolved colour, `tol` the chord tolerance. @@ -29,7 +29,7 @@ pub(super) fn draw(b: &mut Builder, ctx: &Ctx, e: &Entity, h: &Hatch, rgb: Ink, { b.tris(ctx, ink(bg, layer_color, ctx.block_color), &fill::triangulate_evenodd(&loops)); } - for seg in pattern_lines(h, &loops) { + for seg in pattern_lines(ctx.d, h, &loops) { if seg.len() == 1 { if let Some(p) = seg.first() { b.point(ctx, rgb, *p); @@ -209,13 +209,18 @@ impl Field { /// Upper bound on the line families drawn from a pattern definition read from a file. const MAX_PATTERN_LINES: usize = 1024; +/// Upper bound on line runs drawn for one hatch (AutoCAD's HPMAXLINES default): a pattern far +/// too dense for its area stops here instead of exhausting memory. +const MAX_HATCH_SEGMENTS: usize = 1_000_000; + /// Hatch line runs in world coordinates (single-point runs are dots) for the fill `loops`. -pub fn pattern_lines(h: &Hatch, loops: &[Vec]) -> Vec> { - // A pattern the library doesn't define draws with the lines its file defined, else as ANSI31. - let lines = match pattern(&h.pattern) { - Some(p) => p.lines, - None if !h.pattern_lines.is_empty() => h.pattern_lines.iter().take(MAX_PATTERN_LINES).cloned().collect(), - None => pattern("ANSI31").map(|p| p.lines).unwrap_or_default(), +pub fn pattern_lines(d: &Drawing, h: &Hatch, loops: &[Vec]) -> Vec> { + // The lines the file defined (as its author's CAD drew them), else the library's pattern of + // that name, else ANSI31. + let lines = if h.pattern_lines.is_empty() { + pattern_for(d, &h.pattern).or_else(|| pattern_for(d, "ANSI31")).map(|p| p.lines).unwrap_or_default() + } else { + h.pattern_lines.iter().take(MAX_PATTERN_LINES).cloned().collect() }; let scale = if h.scale.is_finite() && h.scale > 1e-9 { h.scale } else { 1.0 }; let mut out = Vec::new(); @@ -229,7 +234,7 @@ pub fn pattern_lines(h: &Hatch, loops: &[Vec]) -> Vec> { } let dashes: Vec = fam.dashes.iter().map(|d| d * scale).collect(); family(loops, origin, ang, delta, spacing, &dashes, &mut out); - if out.len() > 500_000 { + if out.len() >= MAX_HATCH_SEGMENTS { break; } } @@ -253,6 +258,9 @@ fn family(loops: &[Vec], origin: Vec2, ang: f64, delta: Vec2, spacing: f64 } let pat_len: f64 = dashes.iter().map(|d| d.abs()).sum(); for k in k0..=k1 { + if out.len() >= MAX_HATCH_SEGMENTS { + return; + } let y = k as f64 * spacing; let mut xs: Vec = Vec::new(); for l in &local { @@ -279,7 +287,7 @@ fn family(loops: &[Vec], origin: Vec2, ang: f64, delta: Vec2, spacing: f64 'outer: while x < *x1 { for d in dashes { guard += 1; - if guard > 100_000 { + if guard > 100_000 || out.len() >= MAX_HATCH_SEGMENTS { break 'outer; } let len = d.abs();