Draw hatch island styles, backgrounds, two-colour gradients and file pattern definitions (fixes #289, fixes #290) (#302)
- Island detection style (group 75) picks the loops that take part: outer keeps the outermost loops and their first islands, ignore only the outermost loops. - A pattern hatch's background colour is filled under its pattern lines. - Gradients blend colour 1 to colour 2 (LINEAR, CYLINDER, CURVED, SPHERICAL, HEMISPHERICAL and the INV forms) along their angle, shifted when not centred, drawn as bounded colour steps; ByLayer / ByBlock gradient colours resolve to the hatch's layer / block colour instead of colour 7. - DXF HATCH pattern definition lines (78, 53, 43-46, 79, 49) of a pattern the library doesn't define are kept on the hatch (new `patternLines` field), drawn and written back; hostile counts are capped. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3e4eb7a0e9
commit
d433112f1e
|
|
@ -452,6 +452,10 @@ pub struct Hatch {
|
|||
pub origin: Vec2,
|
||||
#[serde(default)]
|
||||
pub background: Option<Color>,
|
||||
/// 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<crate::library::PatternLine>,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ impl HatchSettings {
|
|||
gradient: self.gradient.clone(),
|
||||
origin: Vec2::ZERO,
|
||||
background: None,
|
||||
pattern_lines: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -611,12 +611,17 @@ fn hatch(tags: &[Tag], t: &T) -> Option<EntityKind> {
|
|||
}
|
||||
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<EntityKind> {
|
|||
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<cadcraft_doc::library::PatternLine> {
|
||||
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<f64>)> = 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<Color>) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Option<Gradient>>) {
|
|||
gradient: g.clone(),
|
||||
origin: Vec2::ZERO,
|
||||
background: None,
|
||||
pattern_lines: Vec::new(),
|
||||
};
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Hatch(h)).unwrap();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ fn hatch(pattern: &str, gradient: Option<Gradient>) -> Hatch {
|
|||
gradient,
|
||||
origin: Vec2::new(1.0, 2.0),
|
||||
background: Some(Color::Index(2)),
|
||||
pattern_lines: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PatternLine>) -> 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<PatternLine> {
|
||||
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::<f64>().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());
|
||||
}
|
||||
|
|
@ -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<Vec<Vec2>> {
|
||||
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<Vec<Vec2>> {
|
||||
let loops: Vec<Vec<Vec2>> = 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<Vec2> {
|
||||
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<Vec2>> = 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<Field> {
|
||||
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<Vec2>]) -> Vec<Vec<Vec2>> {
|
||||
// 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<f64> = 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Vec<Vec2>> =
|
||||
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 < {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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<HatchLoop>) -> 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<Rgb>) -> 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<Vec2> {
|
||||
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<Vec2> {
|
||||
let pts: Vec<Vec2> = 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<Vec2> = 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);
|
||||
}
|
||||
Loading…
Reference in New Issue