From 2a3b0c26264942e8f86860b063fdd914b8336cae Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:32:27 +0200 Subject: [PATCH] A wide polyline thinner than a pixel keeps every side on screen (fixes #1434) (#1453) --- crates/render/src/lib.rs | 34 ++++++++++++++++- crates/render/src/tests.rs | 75 ++++++++++++++++++++++++++++++++++++++ crates/render/src/wide.rs | 15 +++++--- 3 files changed, 116 insertions(+), 8 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 67e3ed423..b952fe18e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -343,6 +343,18 @@ impl Builder<'_> { } self.note_pen(color); let (color, lw) = self.styled(color, lw); + self.push_polyline(ctx, color, lw, pts); + } + /// A one-pixel line in `color` as the plot style prints it, without the style's lineweight + /// or pen: the centre line drawn on screen over a thin wide polyline. + fn hairline(&mut self, ctx: &Ctx, color: Ink, pts: &[Vec2]) { + if pts.len() < 2 { + return; + } + let (color, _) = self.styled(color, 0.0); + self.push_polyline(ctx, color, 0.0, pts); + } + fn push_polyline(&mut self, ctx: &Ctx, color: Ink, lw: f32, pts: &[Vec2]) { let start = self.list.verts.len() as u32; for p in pts { let q = ctx.xf.apply(*p); @@ -1124,12 +1136,24 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { EntityKind::MLine(m) => mline::draw(b, ctx, e, m, 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 { + // The GPU canvas has no MSAA: a band narrower than a pixel lights only where it + // covers pixel centres, so whole sides vanish at some zooms (#1434). On screen the + // centre line is drawn too, as a hairline (wide bands are drawn without lineweight), + // so a thin band still shows as a one-pixel line. Always, not below a threshold: + // the list serves a whole band of zooms. Plots and exports draw the band alone, and + // a transparent band gets none (its alpha would add up along the centre). + let hairline = !b.plotting && b.opts.min_dash > 0.0 && b.transparency == 0; match < { // Dashed: each dash a filled piece of the band; dots a line across it. Some(lt) => { let min = b.opts.min_dash / ctx.xf.scale_factor().max(1e-12); - let (tris, ticks) = wide::dashed(p, tol, lt, ltscale, min); + let (tris, ticks, centres) = wide::dashed(p, tol, lt, ltscale, min); b.tris(ctx, rgb, &tris); + if hairline { + for c in ¢res { + b.hairline(ctx, rgb, c); + } + } for t in &ticks { match t.as_slice() { [q] => b.point(ctx, rgb, *q), @@ -1137,7 +1161,13 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { } } } - None => b.tris(ctx, rgb, &fill::wide_polyline(p, tol)), + None => { + b.tris(ctx, rgb, &fill::wide_polyline(p, tol)); + if hairline { + let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; + b.hairline(ctx, rgb, &pl.tessellate(tol)); + } + } } } else { let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ee22abb3c..fc4b99156 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -924,3 +924,78 @@ fn cancelled_build_stops_early() { assert!(!crate::hatch::pattern_lines_until(&drawing, &h, &loops, 0.0, None, None).is_empty()); assert!(crate::hatch::pattern_lines_until(&drawing, &h, &loops, 0.0, None, set.as_deref()).is_empty()); } + +/// #1434: a closed polyline with a global width narrower than a pixel loses whole sides at +/// some zooms, while the same polyline with width 0 always shows every side. +#[test] +fn thin_wide_polyline_keeps_every_side_at_every_zoom() { + let square = |const_width: f64| { + let mut d = Drawing::new_imperial(); + let vertices = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)].map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).to_vec(); + let p = cadcraft_doc::LwPolyline { vertices, closed: true, const_width, elevation: 0.0, plinegen: false }; + d.add(&Space::Model, Common::default(), EntityKind::LwPolyline(p)).unwrap(); + d + }; + // Side midpoints and the direction across each side. + let sides = [("bottom", Vec2::new(5.0, 0.0)), ("right", Vec2::new(10.0, 5.0)), ("top", Vec2::new(5.0, 10.0)), ("left", Vec2::new(0.0, 5.0))]; + let mut missing = Vec::new(); + for const_width in [0.0, 0.01, 0.05] { + let d = square(const_width); + for step in 0..40 { + let scale = 20.0 + f64::from(step) * 0.37; + let px = 1.0 / scale; + let l = build(&d, &Space::Model, &screen(px, None)); + let v = View { center: Vec2::new(5.17, 4.62), scale, width: 400, height: 400 }; + // No anti-aliasing: pixel-centre coverage, as the GPU canvas rasterises (no MSAA). + let pm = render(&l, &v, &RasterOptions { background: Rgb(0, 0, 0), antialias: false, ..Default::default() }).unwrap(); + for (name, mid) in sides { + let (sx, sy) = v.to_screen(mid); + let lit = (-2i32..=2) + .flat_map(|dy| (-2i32..=2).map(move |dx| (dx, dy))) + .any(|(dx, dy)| pm.pixel((sx as i32 + dx) as u32, (sy as i32 + dy) as u32).is_some_and(|c| c.red() > 0)); + if !lit { + missing.push(format!("width {const_width} scale {scale:.2} ({:.2} px wide): {name} side missing", const_width * scale)); + } + } + } + } + assert!(missing.is_empty(), "{} misses:\n{}", missing.len(), missing.join("\n")); +} + +/// #1434: on screen a wide polyline also draws its centre line (a hairline in its colour, solid and +/// dashed alike); plots and exports (`min_dash` 0) draw the band alone. +#[test] +fn wide_polyline_centre_line_is_drawn_on_screen_only() { + for lt in ["Continuous", "DASHED"] { + let drawing = |transparency: u8| { + let mut d = Drawing::new_imperial(); + d.linetypes.extend(cadcraft_doc::library::standard_linetypes().into_iter().filter(|l| l.name == "DASHED")); + let vertices = [(0.0, 0.0), (3.0, 0.0), (3.0, 3.0)].map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).to_vec(); + let p = cadcraft_doc::LwPolyline { vertices, closed: false, const_width: 0.2, elevation: 0.0, plinegen: false }; + let common = Common { + color: Color::Index(1), + linetype: lt.into(), + lineweight: cadcraft_doc::Lineweight::Mm100(50), + transparency: cadcraft_doc::Transparency::Percent(transparency), + ..Default::default() + }; + d.add(&Space::Model, common, EntityKind::LwPolyline(p)).unwrap(); + d + }; + let d = drawing(0); + let opts = Options { lineweights: true, transparency: true, ..screen(0.01, None) }; + let lines = |l: &DisplayList| l.prims.iter().filter(|p| p.kind == Kind::Polyline).count(); + let on_screen = build(&d, &Space::Model, &opts); + assert!(on_screen.prims.iter().any(|p| p.kind == Kind::Tris), "{lt}: the band"); + let hair: Vec<&DPrim> = on_screen.prims.iter().filter(|p| p.kind == Kind::Polyline).collect(); + assert!(!hair.is_empty(), "{lt}: the centre line on screen"); + assert!(hair.iter().all(|p| p.color == Rgb(255, 0, 0) && p.lw == 0.0), "{lt}: hairline in the polyline's colour"); + assert!(on_screen.points(hair[0]).iter().all(|q| q.y.abs() < 1e-9 || (q.x - 3.0).abs() < 1e-9), "{lt}: on the centre line"); + assert_eq!(lines(&build(&d, &Space::Model, &Options { min_dash: 0.0, ..opts.clone() })), 0, "{lt}: exports draw the band alone"); + assert_eq!(lines(&build_plot(&d, &Space::Model, &opts)), 0, "{lt}: plots draw the band alone"); + // Its alpha would add up along the centre: a transparent band gets no hairline. + let see_through = build(&drawing(50), &Space::Model, &opts); + assert!(see_through.prims.iter().any(|p| p.kind == Kind::Tris && p.transparency == 50), "{lt}: the transparent band"); + assert_eq!(lines(&see_through), 0, "{lt}: a transparent band gets no hairline"); + } +} diff --git a/crates/render/src/wide.rs b/crates/render/src/wide.rs index c4c69caa5..d0d874d3f 100644 --- a/crates/render/src/wide.rs +++ b/crates/render/src/wide.rs @@ -66,10 +66,11 @@ fn edges(x: &Sample) -> (Vec2, Vec2) { (x.p + nrm, x.p - nrm) } -/// Triangles for the dashes of a wide polyline drawn with `lt` at `scale`, plus a line across the -/// band for each dot. With PLINEGEN the pattern runs along the whole polyline; otherwise it -/// restarts at every vertex. A pattern shorter than `min_pattern` fills the whole band. -pub fn dashed(p: &LwPolyline, tol: f64, lt: &Linetype, scale: f64, min_pattern: f64) -> (Vec, Vec>) { +/// Triangles for the dashes of a wide polyline drawn with `lt` at `scale`, a line across the band +/// for each dot, and the centre line of each dash. With PLINEGEN the pattern runs along the whole +/// polyline; otherwise it restarts at every vertex. A pattern shorter than `min_pattern` fills +/// the whole band. +pub fn dashed(p: &LwPolyline, tol: f64, lt: &Linetype, scale: f64, min_pattern: f64) -> (Vec, Vec>, Vec>) { let mut runs = segments(p, tol); if p.plinegen && runs.len() > 1 { let mut whole: Vec = Vec::new(); @@ -81,6 +82,7 @@ pub fn dashed(p: &LwPolyline, tol: f64, lt: &Linetype, scale: f64, min_pattern: } let mut tris = Vec::new(); let mut ticks = Vec::new(); + let mut centres = Vec::new(); for run in &runs { let len = run.last().map(|x| x.s).unwrap_or(0.0); let dashes = linetype::ranges(len, lt, scale, min_pattern).unwrap_or_else(|| vec![(0.0, len)]); @@ -102,10 +104,11 @@ pub fn dashed(p: &LwPolyline, tol: f64, lt: &Linetype, scale: f64, min_pattern: tris.extend_from_slice(&[a0, a1, b1, a0, b1, b0]); } } + centres.push(pts.iter().map(|x| x.p).collect()); if tris.len() > 6_000_000 { - return (tris, ticks); + return (tris, ticks, centres); } } } - (tris, ticks) + (tris, ticks, centres) }