diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index cb6733d..ad661a6 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -452,6 +452,10 @@ pub struct Hatch { pub origin: Vec2, #[serde(default)] pub background: Option, + /// The pattern's line families as a file defined them, for a pattern that isn't in the + /// standard library (unit scale and angle, like the library's; empty otherwise). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pattern_lines: Vec, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/doc/src/library.rs b/crates/doc/src/library.rs index 0a45527..c2f63a5 100644 --- a/crates/doc/src/library.rs +++ b/crates/doc/src/library.rs @@ -75,7 +75,7 @@ fn scaled(mut lt: Linetype, k: f64) -> Linetype { } /// A hatch pattern line family: angle (degrees), origin, offset (shift along, spacing), dashes. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct PatternLine { pub angle: f64, pub origin: (f64, f64), diff --git a/crates/engine/src/cmd/hatch.rs b/crates/engine/src/cmd/hatch.rs index 0f466a7..4c49df2 100644 --- a/crates/engine/src/cmd/hatch.rs +++ b/crates/engine/src/cmd/hatch.rs @@ -200,6 +200,7 @@ impl HatchSettings { gradient: self.gradient.clone(), origin: Vec2::ZERO, background: None, + pattern_lines: Vec::new(), }) } } diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 982365b..772b4dc 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -146,6 +146,7 @@ pub fn bracket() -> Drawing { gradient: None, origin: Vec2::ZERO, background: None, + pattern_lines: Vec::new(), }) }; add(on("Hatch"), hatch(Vec2::new(sx, c.y + 0.875), Vec2::new(sx + 0.5, y0 + h))); diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 5c446d6..37249ee 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -611,12 +611,17 @@ fn hatch(tags: &[Tag], t: &T) -> Option { } loops.push(HatchLoop { vertices: verts, outer }); } + 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) }; Some(EntityKind::Hatch(Hatch { pattern: pattern.to_ascii_uppercase(), - solid: solid || pattern.eq_ignore_ascii_case("SOLID"), + solid, loops, - scale: t.fd(41, 1.0), - angle: t.fd(52, 0.0).to_radians(), + scale, + angle, associative: t.i(71).unwrap_or(0) != 0, style: t.i(75).unwrap_or(0) as u8, // The first 30 is the elevation point's z (boundary points are 2D). @@ -624,9 +629,63 @@ fn hatch(tags: &[Tag], t: &T) -> Option { gradient: gradient(tags), origin, background, + pattern_lines, })) } +/// Upper bounds on a HATCH's pattern definition lines and on the dashes of one line. +const MAX_PATTERN_LINES: usize = 1024; +const MAX_PATTERN_DASHES: usize = 256; + +/// The pattern definition lines of a HATCH (groups 78, then per line 53 angle, 43/44 base +/// point, 45/46 offset, 79 dash count, 49 dash lengths). A file holds them as drawn: scaled, +/// rotated with the hatch and the offset as a world vector. They are kept the library's way +/// (unit scale, no hatch rotation, base point relative to the hatch origin, offset as shift +/// along the line and spacing), which the writer and the renderer turn back. Lines with +/// non-finite values are skipped. +fn pattern_definition(tags: &[Tag], scale: f64, angle: f64, origin: Vec2) -> Vec { + let Some(at) = tags.iter().position(|x| x.code == 78) else { return Vec::new() }; + let scale = if scale.is_finite() && scale > 1e-9 { scale } else { 1.0 }; + let angle = if angle.is_finite() { angle } else { 0.0 }; + // Raw lines: angle (degrees), base, offset, dashes. + let mut raw: Vec<(f64, Vec2, Vec2, Vec)> = Vec::new(); + for x in tags.iter().skip(at + 1) { + let cur = raw.last_mut(); + match (x.code, cur) { + (53, _) => { + if raw.len() >= MAX_PATTERN_LINES { + break; + } + raw.push((x.f64(), Vec2::ZERO, Vec2::ZERO, Vec::new())); + } + (43, Some(l)) => l.1.x = x.f64(), + (44, Some(l)) => l.1.y = x.f64(), + (45, Some(l)) => l.2.x = x.f64(), + (46, Some(l)) => l.2.y = x.f64(), + (79, Some(_)) => {} + (49, Some(l)) => { + if l.3.len() < MAX_PATTERN_DASHES { + l.3.push(x.f64()); + } + } + _ => break, + } + } + raw.into_iter() + .filter(|(a, b, o, d)| a.is_finite() && b.is_finite() && o.is_finite() && d.iter().all(|v| v.is_finite())) + .map(|(a, base, offset, dashes)| { + let local = (base - origin).rotate(-angle) * (1.0 / scale); + let delta = offset.rotate(-a.to_radians()) * (1.0 / scale); + cadcraft_doc::library::PatternLine { + angle: a - angle.to_degrees(), + origin: (local.x, local.y), + delta: (delta.x, delta.y), + dashes: dashes.into_iter().map(|d| d / scale).collect(), + } + }) + .collect() +} + /// Hatch origin and background colour from `CADCRAFT` xdata: `1000 HATCH`, `1011` origin, /// `1000` background colour name (empty for none). Defaults when absent. fn hatch_xdata(tags: &[Tag]) -> (Vec2, Option) { diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 165287b..e6ee22c 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -787,7 +787,8 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx w.f(41, h.scale); w.i(77, 0); let pat = cadcraft_doc::library::pattern(&h.pattern); - let lines = pat.map(|p| p.lines).unwrap_or_default(); + // 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()); w.i(78, lines.len() as i64); for pl in lines { let ang = pl.angle.to_radians() + h.angle; diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 20cd30e..c651673 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -113,6 +113,7 @@ fn sample() -> Drawing { gradient: None, origin: Vec2::ZERO, background: None, + pattern_lines: Vec::new(), }), ) .unwrap(); @@ -1477,6 +1478,7 @@ fn gradient_sample() -> (Drawing, Vec>) { gradient: g.clone(), origin: Vec2::ZERO, background: None, + pattern_lines: Vec::new(), }; d.add(&Space::Model, Common::default(), EntityKind::Hatch(h)).unwrap(); } diff --git a/crates/io/tests/entity_props_roundtrip.rs b/crates/io/tests/entity_props_roundtrip.rs index 98108b3..dda8894 100644 --- a/crates/io/tests/entity_props_roundtrip.rs +++ b/crates/io/tests/entity_props_roundtrip.rs @@ -83,6 +83,7 @@ fn hatch(pattern: &str, gradient: Option) -> Hatch { gradient, origin: Vec2::new(1.0, 2.0), background: Some(Color::Index(2)), + pattern_lines: Vec::new(), } } diff --git a/crates/io/tests/hatch_pattern_def.rs b/crates/io/tests/hatch_pattern_def.rs new file mode 100644 index 0000000..c4108c6 --- /dev/null +++ b/crates/io/tests/hatch_pattern_def.rs @@ -0,0 +1,130 @@ +//! HATCH pattern definition lines (groups 78, 53, 43–46, 79, 49) of a pattern the library +//! doesn't define survive a DXF read and save (issue #290). + +use cadcraft_doc::library::PatternLine; +use cadcraft_doc::*; +use cadcraft_geom::{PolyVertex, Vec2}; +use cadcraft_io::{read_dxf, write_dxf}; + +fn square() -> HatchLoop { + let v = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)].into_iter().map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).collect(); + HatchLoop { vertices: v, outer: true } +} + +fn hatch(pattern: &str, scale: f64, angle: f64, origin: Vec2, lines: Vec) -> Hatch { + Hatch { + pattern: pattern.into(), + solid: false, + loops: vec![square()], + scale, + angle, + associative: false, + style: 0, + elevation: 0.0, + gradient: None, + origin, + background: None, + pattern_lines: lines, + } +} + +fn custom_lines() -> Vec { + vec![ + PatternLine { angle: 0.0, origin: (0.0, 0.0), delta: (0.5, 1.0), dashes: vec![0.75, -0.25, 0.0, -0.25] }, + PatternLine { angle: 90.0, origin: (0.25, 0.0), delta: (0.0, 1.0), dashes: vec![] }, + ] +} + +fn save(h: Hatch) -> String { + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), EntityKind::Hatch(h)).expect("add"); + write_dxf(&d) +} + +fn first_hatch(text: &str) -> Hatch { + let d = read_dxf(text.as_bytes()).expect("read"); + d.model.iter().find_map(|e| if let EntityKind::Hatch(h) = &e.kind { Some(h.clone()) } else { None }).expect("hatch") +} + +/// (group code, value) pairs of a DXF text. +fn pairs(text: &str) -> Vec<(i32, String)> { + let lines: Vec<&str> = text.lines().collect(); + lines.chunks(2).filter_map(|c| Some((c.first()?.trim().parse().ok()?, c.get(1)?.trim().to_string()))).collect() +} + +/// Index of the HATCH's 78 group. +fn pattern_data(p: &[(i32, String)]) -> usize { + let hatch = p.iter().position(|(c, v)| *c == 0 && v == "HATCH").expect("HATCH"); + p.iter().skip(hatch).position(|(c, _)| *c == 78).map(|k| hatch + k).expect("78") +} + +fn join(p: &[(i32, String)]) -> String { + p.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect() +} + +fn near(a: &[PatternLine], b: &[PatternLine]) -> bool { + let close = |x: f64, y: f64| (x - y).abs() < 1e-9; + a.len() == b.len() + && a.iter().zip(b).all(|(p, q)| { + close(p.angle, q.angle) + && close(p.origin.0, q.origin.0) + && close(p.origin.1, q.origin.1) + && close(p.delta.0, q.delta.0) + && close(p.delta.1, q.delta.1) + && p.dashes.len() == q.dashes.len() + && p.dashes.iter().zip(&q.dashes).all(|(x, y)| close(*x, *y)) + }) +} + +#[test] +fn custom_pattern_lines_roundtrip_scaled_and_rotated() { + let angle = 30f64.to_radians(); + let text = save(hatch("MYPAT", 2.0, angle, Vec2::new(1.0, 2.0), custom_lines())); + // In the file the lines are as drawn: rotated with the hatch, scaled, offset as a world vector. + let p = pairs(&text); + let at = pattern_data(&p); + assert_eq!(p[at].1, "2"); + let num = |k: usize| p[at + k].1.parse::().expect("number"); + assert!((num(1) - 30.0).abs() < 1e-9, "53 angle"); + let off = Vec2::new(0.5, 1.0).rotate(angle) * 2.0; + assert!((num(2) - 1.0).abs() < 1e-9 && (num(3) - 2.0).abs() < 1e-9, "43/44 base point at the origin"); + assert!((num(4) - off.x).abs() < 1e-9 && (num(5) - off.y).abs() < 1e-9, "45/46 offset"); + assert_eq!(p[at + 6], (79, "4".into())); + assert!((num(7) - 1.5).abs() < 1e-9, "49 dash scaled"); + // Read back to the same definition, and saved again unchanged. + let h = first_hatch(&text); + assert!(near(&h.pattern_lines, &custom_lines()), "{:?}", h.pattern_lines); + assert!(near(&first_hatch(&save(h)).pattern_lines, &custom_lines())); +} + +#[test] +fn library_patterns_keep_no_definition() { + let h = first_hatch(&save(hatch("ANSI31", 1.0, 0.0, Vec2::ZERO, vec![]))); + assert!(h.pattern_lines.is_empty()); +} + +#[test] +fn hostile_pattern_definitions_are_capped() { + let text = save(hatch("MYPAT", 1.0, 0.0, Vec2::ZERO, custom_lines())); + let p = pairs(&text); + let at = pattern_data(&p); + let end = p.iter().skip(at).position(|(c, _)| *c == 98).map(|k| at + k).expect("98"); + let mut hostile = vec![(78, "2000000000".to_string())]; + // A line with a NaN offset, a line with a million dashes, then far too many lines. + hostile.extend([(53, "0"), (43, "0"), (44, "0"), (45, "nan"), (46, "1"), (79, "1"), (49, "1")].map(|(c, v)| (c, v.to_string()))); + hostile.extend([(53, "0"), (43, "0"), (44, "0"), (45, "0"), (46, "1"), (79, "1000000")].map(|(c, v)| (c, v.to_string()))); + hostile.extend((0..1_000).map(|_| (49, "0.5".to_string()))); + for _ in 0..3_000 { + hostile.extend([(53, "45"), (43, "0"), (44, "0"), (45, "0"), (46, "1"), (79, "0")].map(|(c, v)| (c, v.to_string()))); + } + let mut q = p[..at].to_vec(); + q.extend(hostile); + q.extend_from_slice(&p[end..]); + let h = first_hatch(&join(&q)); + assert!(h.pattern_lines.len() <= 1024 && h.pattern_lines.len() > 100, "{}", h.pattern_lines.len()); + assert!(h.pattern_lines.iter().all(|l| l.delta.0.is_finite() && l.dashes.len() <= 256)); + // Drawing the capped definition stays bounded. + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), EntityKind::Hatch(h)).expect("add"); + let _ = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()); +} diff --git a/crates/render/src/hatch.rs b/crates/render/src/hatch.rs index 4978199..08917cc 100644 --- a/crates/render/src/hatch.rs +++ b/crates/render/src/hatch.rs @@ -1,26 +1,232 @@ -//! Pattern hatch line generation: each pattern line family is swept across the boundary -//! (even-odd clipped), then dashed. +//! Hatch drawing: island detection picks the boundary loops that take part, then a solid, +//! gradient or background fill (even-odd triangles) and the pattern lines (each pattern line +//! family swept across the boundary, even-odd clipped, then dashed). -use cadcraft_doc::Hatch; -use cadcraft_doc::library::{HatchPattern, pattern}; -use cadcraft_geom::{Bounds2, Polyline, Vec2}; +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_geom::{Bounds2, Polyline, Vec2, point_in_polygon}; -/// Hatch line runs in world coordinates (single-point runs are dots). -pub fn pattern_lines(h: &Hatch, tol: f64) -> Vec> { - let pat = pattern(&h.pattern).unwrap_or_else(|| pattern("ANSI31").unwrap_or(HatchPattern { name: "ANSI31", description: "", lines: vec![] })); +/// Draw a hatch entity: `rgb` is its resolved colour, `tol` the chord tolerance. +pub(super) fn draw(b: &mut Builder, ctx: &Ctx, e: &Entity, h: &Hatch, rgb: Ink, lw: f32, tol: f64) { + let loops = fill_loops(h, tol); + // Gradient and background colours given ByLayer follow the hatch's own layer. + let layer = if e.common.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { e.common.layer.as_str() }; + let layer_color = ctx.layer_color(layer).unwrap_or(Color::Index(7)); + if h.solid || h.pattern.eq_ignore_ascii_case("SOLID") || h.gradient.is_some() { + if b.opts.fill { + let tris = fill::triangulate_evenodd(&loops); + match &h.gradient { + Some(g) => gradient(b, ctx, g, layer_color, &tris), + None => b.tris(ctx, rgb, &tris), + } + } + return; + } + if let Some(bg) = h.background + && b.opts.fill + { + b.tris(ctx, ink(bg, layer_color, ctx.block_color), &fill::triangulate_evenodd(&loops)); + } + for seg in pattern_lines(h, &loops) { + if seg.len() == 1 { + if let Some(p) = seg.first() { + b.point(ctx, rgb, *p); + } + } else { + b.polyline(ctx, rgb, lw, &seg); + } + } +} + +/// Upper bound on loop count × vertex count for island detection (the nesting test compares +/// every loop with every other); past it all loops are kept (odd parity). +const MAX_ISLAND_WORK: usize = 20_000_000; + +/// The tessellated boundary loops that take part in the fill under the hatch's island +/// detection style (DXF group 75), filled with odd parity: 0 normal keeps every loop (islands +/// within islands alternate), 1 outer keeps the outermost loops and their first islands, 2 +/// ignore keeps only the outermost loops. Nesting depth = how many other loops enclose a loop. +pub(crate) fn fill_loops(h: &Hatch, tol: f64) -> Vec> { let loops: Vec> = h.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate(tol)).collect(); + let max_depth = match h.style { + 1 => 1, + 2 => 0, + _ => return loops, + }; + let verts: usize = loops.iter().map(Vec::len).sum(); + if loops.len().saturating_mul(verts) > MAX_ISLAND_WORK { + return loops; + } + let depth = |i: usize, l: &[Vec2]| { + let Some(p) = probe(l) else { return 0 }; + loops.iter().enumerate().filter(|(j, o)| *j != i && point_in_polygon(o, p)).count() + }; + loops.iter().enumerate().filter(|(i, l)| depth(*i, l) <= max_depth).map(|(_, l)| l.clone()).collect() +} + +/// A point of a loop to test its nesting with: the middle of its first non-degenerate edge +/// (vertices may touch an enclosing loop; edge midpoints rarely do). +fn probe(l: &[Vec2]) -> Option { + let n = l.len(); + (0..n).find_map(|i| { + let (a, b) = (*l.get(i)?, *l.get((i + 1) % n)?); + (a.dist(b) > 1e-12).then(|| (a + b) * 0.5).filter(|m| m.is_finite()) + }) +} + +/// Colour steps a gradient is drawn with (one display-list primitive each). +const GRADIENT_LEVELS: usize = 64; +/// Gradient triangles are split until no edge is longer than the larger extent over this. +const GRADIENT_CELLS: f64 = 64.0; +/// Upper bound on the triangles of one gradient fill. +const MAX_GRADIENT_TRIS: usize = 200_000; + +/// A two-colour gradient over the fill triangles `tris`: a base fill in the middle colour (it +/// hides seams between steps), then the triangles split into small pieces, each drawn in the +/// colour step of the gradient value at its centroid. +fn gradient(b: &mut Builder, ctx: &Ctx, g: &Gradient, layer: Color, tris: &[Vec2]) { + let c1 = ink(g.color1, layer, ctx.block_color); + let c2 = ink(g.color2, layer, ctx.block_color); + b.tris(ctx, mix(c1, c2, 0.5), tris); + let Some(field) = Field::new(g, tris) else { return }; + let mut steps: Vec> = vec![Vec::new(); GRADIENT_LEVELS]; + let max2 = field.cell * field.cell; + let mut stack: Vec<[Vec2; 3]> = tris.as_chunks::<3>().0.to_vec(); + let mut drawn = 0usize; + while let Some([a, bb, c]) = stack.pop() { + // Split the longest edge while the budget allows. + let edges = [(a, bb, c), (bb, c, a), (c, a, bb)]; + let (p, q, r) = edges.into_iter().max_by(|x, y| (x.0 - x.1).len2().total_cmp(&(y.0 - y.1).len2())).unwrap_or((a, bb, c)); + if (p - q).len2() > max2 && drawn + stack.len() + 2 <= MAX_GRADIENT_TRIS { + let m = (p + q) * 0.5; + stack.push([p, m, r]); + stack.push([m, q, r]); + continue; + } + let t = field.value((a + bb + c) / 3.0); + let k = (t * (GRADIENT_LEVELS - 1) as f64).round() as usize; + if let Some(s) = steps.get_mut(k.min(GRADIENT_LEVELS - 1)) { + s.extend_from_slice(&[a, bb, c]); + } + drawn += 1; + } + for (k, s) in steps.iter().enumerate() { + b.tris(ctx, mix(c1, c2, k as f64 / (GRADIENT_LEVELS - 1) as f64), s); + } +} + +/// `a` blended towards `b` by `t`; colour 7 only when both ends are. +fn mix(a: Ink, b: Ink, t: f64) -> Ink { + let l = |x: u8, y: u8| (f64::from(x) + (f64::from(y) - f64::from(x)) * t).round().clamp(0.0, 255.0) as u8; + Ink { rgb: Rgb(l(a.rgb.0, b.rgb.0), l(a.rgb.1, b.rgb.1), l(a.rgb.2, b.rgb.2)), aci7: a.aci7 && b.aci7 } +} + +/// Gradient shapes by name (the `INV` forms swap the two colours). +#[derive(Clone, Copy, Debug, PartialEq)] +enum Shape { + /// Colour 1 to colour 2 along the gradient direction. + Linear, + /// Colour 1 at both sides, colour 2 along the middle. + Cylinder, + /// Like linear but bending quickly towards colour 2. + Curved, + /// Colour 2 at the centre, colour 1 from the farthest side outwards. + Spherical, + /// Like spherical, centred on the bottom edge (in the gradient direction's frame). + Hemispherical, +} + +/// The gradient value (0 = colour 1, 1 = colour 2) over the fill, in a frame whose x axis is +/// the gradient direction. +struct Field { + shape: Shape, + inverted: bool, + dir: Vec2, + /// Fill bounds in the gradient frame. + bb: Bounds2, + /// Centre in the gradient frame (moved up and back when the gradient isn't centred). + centre: Vec2, + /// Radius at which a radial gradient reaches colour 1: the centre's distance to the farthest + /// side of `bb` (the corners beyond it are colour 1). + reach: f64, + /// Largest edge length of the pieces drawn. + cell: f64, +} + +impl Field { + fn new(g: &Gradient, tris: &[Vec2]) -> Option { + let name = g.name.trim().to_ascii_uppercase(); + let (inverted, base) = match name.strip_prefix("INV") { + Some(rest) => (true, rest), + None => (false, name.as_str()), + }; + let shape = match base { + "CYLINDER" => Shape::Cylinder, + "CURVED" => Shape::Curved, + "SPHERICAL" => Shape::Spherical, + "HEMISPHERICAL" => Shape::Hemispherical, + _ => Shape::Linear, + }; + let angle = if g.angle.is_finite() { g.angle } else { 0.0 }; + let dir = Vec2::new(angle.cos(), angle.sin()); + let bb = Bounds2::from_points(tris.iter().filter(|p| p.is_finite()).map(|p| Vec2::new(p.dot(dir), p.dot(dir.perp())))); + let size = bb.width().max(bb.height()); + if bb.is_empty() || !(size.is_finite() && size > 1e-12) { + return None; + } + let (fx, fy) = if g.centered { (0.5, 0.5) } else { (0.3, 0.7) }; + let mut centre = Vec2::new(bb.min.x + bb.width() * fx, bb.min.y + bb.height() * fy); + if shape == Shape::Hemispherical { + centre.y = bb.min.y; + } + let reach = [centre.x - bb.min.x, bb.max.x - centre.x, centre.y - bb.min.y, bb.max.y - centre.y].into_iter().fold(0.0, f64::max).max(1e-12); + Some(Field { shape, inverted, dir, bb, centre, reach, cell: size / GRADIENT_CELLS }) + } + + /// The value at world point `p`, in 0..=1. + fn value(&self, p: Vec2) -> f64 { + let q = Vec2::new(p.dot(self.dir), p.dot(self.dir.perp())); + // Position along the direction, 0..1, with the centre mapped to 0.5. + let along = || { + let (lo, c, hi) = (self.bb.min.x, self.centre.x, self.bb.max.x); + if q.x < c { 0.5 * (q.x - lo) / (c - lo).max(1e-12) } else { 0.5 + 0.5 * (q.x - c) / (hi - c).max(1e-12) } + }; + let t = match self.shape { + Shape::Linear => along(), + Shape::Cylinder => 1.0 - (along() - 0.5).abs() * 2.0, + Shape::Curved => 1.0 - (1.0 - along().clamp(0.0, 1.0)).powi(2), + Shape::Spherical | Shape::Hemispherical => 1.0 - q.dist(self.centre) / self.reach, + }; + let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; + if self.inverted { 1.0 - t } else { t } + } +} + +/// Upper bound on the line families drawn from a pattern definition read from a file. +const MAX_PATTERN_LINES: usize = 1024; + +/// 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(), + }; let scale = if h.scale.is_finite() && h.scale > 1e-9 { h.scale } else { 1.0 }; let mut out = Vec::new(); - for fam in &pat.lines { + for fam in &lines { let ang = fam.angle.to_radians() + h.angle; let origin = h.origin + Vec2::new(fam.origin.0, fam.origin.1).rotate(h.angle) * scale; let delta = Vec2::new(fam.delta.0, fam.delta.1).rotate(ang) * scale; let spacing = (Vec2::new(fam.delta.0, fam.delta.1).y * scale).abs(); - if spacing < 1e-9 { + if !spacing.is_finite() || spacing < 1e-9 { continue; } let dashes: Vec = fam.dashes.iter().map(|d| d * scale).collect(); - family(&loops, origin, ang, delta, spacing, &dashes, &mut out); + family(loops, origin, ang, delta, spacing, &dashes, &mut out); if out.len() > 500_000 { break; } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a6f323d..768bb54 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -529,30 +529,7 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) { dimension(b, ctx, e, dm, lw); } } - EntityKind::Hatch(h) => { - if h.solid || h.pattern.eq_ignore_ascii_case("SOLID") || h.gradient.is_some() { - if b.opts.fill { - let loops: Vec> = - h.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate(tol)).collect(); - let tris = fill::triangulate_evenodd(&loops); - let c = match &h.gradient { - Some(g) => ink(g.color1, Color::Index(7), ctx.block_color), - None => rgb, - }; - b.tris(ctx, c, &tris); - } - } else { - for seg in hatch::pattern_lines(h, tol) { - if seg.len() == 1 { - if let Some(p) = seg.first() { - b.point(ctx, rgb, *p); - } - } else { - b.polyline(ctx, rgb, lw, &seg); - } - } - } - } + EntityKind::Hatch(h) => hatch::draw(b, ctx, e, h, rgb, lw, tol), EntityKind::LwPolyline(p) if p.const_width > 0.0 || p.vertices.iter().any(|v| v.start_width > 0.0 || v.end_width > 0.0) => { if b.opts.fill && ctx.d.header.i64("FILLMODE", 1) != 0 { match < { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index b5e5fda..9c72acd 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -86,6 +86,7 @@ fn hatch_pattern_and_solid() { gradient: None, origin: Vec2::ZERO, background: None, + pattern_lines: Vec::new(), }; d.add(&Space::Model, Common::default(), EntityKind::Hatch(h.clone())).unwrap(); let l = build(&d, &Space::Model, &Options::default()); diff --git a/crates/render/tests/hatch_fill.rs b/crates/render/tests/hatch_fill.rs new file mode 100644 index 0000000..538f1c1 --- /dev/null +++ b/crates/render/tests/hatch_fill.rs @@ -0,0 +1,166 @@ +//! Hatch drawing: island detection styles, background colour, two-colour gradients and +//! pattern definitions kept from a file (issues #289, #290). + +use cadcraft_color::{Color, Rgb, aci_rgb}; +use cadcraft_doc::library::PatternLine; +use cadcraft_doc::{Common, Drawing, EntityKind, Gradient, Hatch, HatchLoop, Layer, Space}; +use cadcraft_geom::{PolyVertex, Vec2}; +use cadcraft_render::{DisplayList, Kind, Options, build}; + +fn square(lo: f64, hi: f64) -> HatchLoop { + let v = [(lo, lo), (hi, lo), (hi, hi), (lo, hi)].into_iter().map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).collect(); + HatchLoop { vertices: v, outer: lo == 0.0 } +} + +fn hatch(pattern: &str, loops: Vec) -> Hatch { + Hatch { + pattern: pattern.into(), + solid: pattern == "SOLID", + loops, + scale: 1.0, + angle: 0.0, + associative: false, + style: 0, + elevation: 0.0, + gradient: None, + origin: Vec2::ZERO, + background: None, + pattern_lines: Vec::new(), + } +} + +/// Square 0..10 with the island 2..8 and the island-in-an-island 4..6. +fn nested(pattern: &str, style: u8) -> Hatch { + Hatch { style, ..hatch(pattern, vec![square(0.0, 10.0), square(2.0, 8.0), square(4.0, 6.0)]) } +} + +fn draw(h: Hatch, layer: &str) -> DisplayList { + let mut d = Drawing::new_imperial(); + d.layers.push(Layer { color: Color::Index(1), ..Layer::new("RED") }); + let common = Common { layer: layer.into(), ..Common::default() }; + d.add(&Space::Model, common, EntityKind::Hatch(h)).expect("add"); + build(&d, &Space::Model, &Options::default()) +} + +fn tri_area(l: &DisplayList, color: Option) -> f64 { + l.prims + .iter() + .filter(|p| p.kind == Kind::Tris && color.is_none_or(|c| p.color == c)) + .flat_map(|p| l.points(p).chunks(3)) + .map(|t| ((t[1] - t[0]).cross(t[2] - t[0]) / 2.0).abs()) + .sum() +} + +/// Mid points of the pattern line runs. +fn line_mids(l: &DisplayList) -> Vec { + l.prims.iter().filter(|p| p.kind == Kind::Polyline).filter_map(|p| Some(l.points(p).first()?.mid(*l.points(p).last()?))).collect() +} + +#[test] +fn island_detection_styles() { + // Normal: odd parity (100 - 36 + 4); outer: the innermost island stays empty; ignore: all. + for (style, area) in [(0, 68.0), (1, 64.0), (2, 100.0)] { + let a = tri_area(&draw(nested("SOLID", style), "0"), None); + assert!((a - area).abs() < 1e-6, "style {style}: {a}"); + } + let inside = |p: &Vec2, lo: f64, hi: f64| p.x > lo && p.x < hi && p.y > lo && p.y < hi; + let island = |p: &Vec2| inside(p, 2.0, 8.0) && !inside(p, 4.0, 6.0); + let normal = line_mids(&draw(nested("ANSI31", 0), "0")); + assert!(!normal.iter().any(island) && normal.iter().any(|p| inside(p, 4.0, 6.0))); + let outer = line_mids(&draw(nested("ANSI31", 1), "0")); + assert!(!outer.is_empty() && !outer.iter().any(|p| inside(p, 2.0, 8.0)), "outer hatches only the outer band"); + let ignore = line_mids(&draw(nested("ANSI31", 2), "0")); + assert!(ignore.iter().any(island) && ignore.iter().any(|p| inside(p, 4.0, 6.0))); +} + +#[test] +fn pattern_background_is_filled_under_the_lines() { + let plain = draw(nested("ANSI31", 0), "0"); + assert!(plain.prims.iter().all(|p| p.kind != Kind::Tris)); + let l = draw(Hatch { background: Some(Color::Index(3)), ..nested("ANSI31", 0) }, "0"); + assert!((tri_area(&l, Some(aci_rgb(3))) - 68.0).abs() < 1e-6); + // The fill comes first so the pattern lines draw over it. + assert_eq!(l.prims.first().map(|p| p.kind), Some(Kind::Tris)); + assert!(l.prims.iter().any(|p| p.kind == Kind::Polyline)); +} + +fn gradient(name: &str, angle: f64, color1: Color) -> Hatch { + let g = Gradient { name: name.into(), color1, color2: Color::Index(3), angle, centered: true }; + Hatch { gradient: Some(g), ..hatch("SOLID", vec![square(0.0, 10.0)]) } +} + +/// Mean position of the triangles drawn in `c` (the base fill has the middle colour). +fn centre_of(l: &DisplayList, c: Rgb) -> Option { + let pts: Vec = l.prims.iter().filter(|p| p.kind == Kind::Tris && p.color == c).flat_map(|p| l.points(p).iter().copied()).collect(); + (!pts.is_empty()).then(|| pts.iter().fold(Vec2::ZERO, |a, p| a + *p) * (1.0 / pts.len() as f64)) +} + +#[test] +fn linear_gradient_blends_from_the_layer_colour_to_colour_2() { + let (red, green) = (aci_rgb(1), aci_rgb(3)); + let l = draw(gradient("LINEAR", 0.0, Color::ByLayer), "RED"); + assert!(!l.prims.iter().any(|p| p.color == Rgb(255, 255, 255)), "ByLayer colour 1 is the layer's red, not white"); + let (a, b) = (centre_of(&l, red).expect("red end"), centre_of(&l, green).expect("green end")); + assert!(a.x < 1.0 && b.x > 9.0, "{a:?} {b:?}"); + assert!(l.prims.iter().filter(|p| p.kind == Kind::Tris).count() > 20, "blended in steps"); + // Base fill plus the steps cover the square twice. + assert!((tri_area(&l, None) - 200.0).abs() < 1e-6); + // 90°: the blend runs bottom to top. + let l = draw(gradient("LINEAR", std::f64::consts::FRAC_PI_2, Color::ByLayer), "RED"); + let (a, b) = (centre_of(&l, red).expect("red end"), centre_of(&l, green).expect("green end")); + assert!(a.y < 1.0 && b.y > 9.0, "{a:?} {b:?}"); +} + +/// Mean position of the gradient step closest in colour to `c` (the first fill is the base). +fn nearest_step(l: &DisplayList, c: Rgb) -> Vec2 { + let d = |x: Rgb| (i32::from(x.0) - i32::from(c.0)).abs() + (i32::from(x.1) - i32::from(c.1)).abs() + (i32::from(x.2) - i32::from(c.2)).abs(); + let best = l.prims.iter().skip(1).filter(|p| p.kind == Kind::Tris).min_by_key(|p| d(p.color)).expect("steps"); + centre_of(l, best.color).expect("step") +} + +#[test] +fn radial_and_inverted_gradients() { + let (blue, green) = (aci_rgb(5), aci_rgb(3)); + let c = Vec2::new(5.0, 5.0); + let l = draw(gradient("SPHERICAL", 0.0, Color::Index(5)), "0"); + assert!(nearest_step(&l, green).dist(c) < 0.5); + let corners: Vec = l.prims.iter().filter(|p| p.kind == Kind::Tris && p.color == blue).flat_map(|p| l.points(p).iter().copied()).collect(); + assert!(!corners.is_empty() && corners.iter().all(|p| p.dist(c) > 4.5), "colour 1 only in the corners: {corners:?}"); + let l = draw(gradient("INVSPHERICAL", 0.0, Color::Index(5)), "0"); + assert!(nearest_step(&l, blue).dist(c) < 0.5); + let l = draw(gradient("CYLINDER", 0.0, Color::Index(5)), "0"); + assert!((nearest_step(&l, green).x - 5.0).abs() < 0.5); + let l = draw(gradient("HEMISPHERICAL", 0.0, Color::Index(5)), "0"); + assert!((nearest_step(&l, green) - Vec2::new(5.0, 0.0)).len() < 0.5); + // Shifted: the highlight moves off the centre. + let mut h = gradient("SPHERICAL", 0.0, Color::Index(5)); + if let Some(g) = h.gradient.as_mut() { + g.centered = false; + } + assert!(nearest_step(&draw(h, "0"), green).dist(c) > 1.0); +} + +#[test] +fn hostile_gradients_stay_bounded() { + let mut h = gradient("NOSUCHGRADIENT", f64::NAN, Color::ByBlock); + h.loops.push(square(-1e300, 1e300)); + let l = draw(h, "0"); + assert!(l.prims.len() <= 65); + let l = draw(Hatch { style: 200, ..gradient("CURVED", 1e308, Color::Index(5)) }, "0"); + assert!(l.prims.len() <= 65 && l.tris.len() <= 3 * 200_000 + 6); +} + +#[test] +fn pattern_lines_from_a_file_are_drawn_for_unknown_names() { + // A grid: horizontal and vertical lines one unit apart. + let fam = |angle| PatternLine { angle, origin: (0.0, 0.0), delta: (0.0, 1.0), dashes: vec![] }; + let h = Hatch { pattern_lines: vec![fam(0.0), fam(90.0)], ..hatch("MYGRID", vec![square(0.5, 9.5)]) }; + let l = draw(h, "0"); + let runs: Vec<&[Vec2]> = l.prims.iter().filter(|p| p.kind == Kind::Polyline).map(|p| l.points(p)).collect(); + assert_eq!(runs.len(), 18); + assert!(runs.iter().all(|r| { r.len() == 2 && ((r[0].x - r[1].x).abs() < 1e-9 || (r[0].y - r[1].y).abs() < 1e-9) })); + // Without a definition an unknown name still falls back to ANSI31 (diagonal lines). + let l = draw(hatch("MYGRID", vec![square(0.5, 9.5)]), "0"); + let p = l.prims.iter().find(|p| p.kind == Kind::Polyline).map(|p| l.points(p)).expect("lines"); + assert!(((p[1] - p[0]).angle().to_degrees().rem_euclid(180.0) - 45.0).abs() < 1e-6); +}