From 3f9c29d6dfea83a38dd9a7692b70371380dfc920 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 9 Jun 2026 23:15:45 +0300 Subject: [PATCH] fix(hatch): render dot patterns (zero-length dashes) A zero-length dash entry is a dot (e.g. the DOTS pattern), but every render path treated only positive entries as marks and skipped it, so dot patterns drew nothing. - GPU shaders: light exactly the one pixel containing each dot's lattice centre. The dot grid rotates with the pattern angle while the lit pixel stays a steady single pixel, so dots no longer flicker at oblique angles or zoom (an earlier coverage/round test still aliased). - PDF export (pattern_segments): walk the dash sequence with absolute phase and emit a short mark for each dot. Co-Authored-By: Claude Opus 4.8 --- src/scene/hatch_model.rs | 63 +++++++++++++++++++--------------- src/shaders/hatch.wgsl | 26 +++++++++++--- src/shaders/hatch_batched.wgsl | 28 ++++++++++++--- 3 files changed, 79 insertions(+), 38 deletions(-) diff --git a/src/scene/hatch_model.rs b/src/scene/hatch_model.rs index 666cc9ac1..7f5a3a45b 100644 --- a/src/scene/hatch_model.rs +++ b/src/scene/hatch_model.rs @@ -265,40 +265,47 @@ impl HatchModel { let p1 = [lx + t1 * cos_a, ly + t1 * sin_a]; segments.push([p0, p1]); } else { - // Walk the dash sequence along this clipped span. - // Negative entries are gaps, positive are dashes. - // Skip ahead by `t0 mod period` so dash phase aligns - // across consecutive line spans (matches the GPU - // shader which evaluates dash on absolute t). + // Walk the dash sequence along this clipped span with + // absolute phase (so the pattern aligns across spans, + // matching the GPU shader). Positive entries are + // dashes, negative are gaps, and a zero-length entry is + // a dot — rendered as a short mark so dot patterns + // (e.g. DOTS) are visible instead of drawing nothing. + let n = family.dashes.len(); + let dot_len = (period * 0.06).max(1e-3); let phase = t0.rem_euclid(period); - let mut t = t0; - let mut cursor = phase; + // Start at the period boundary at or before t0; the + // span clip below drops anything before t0. + let mut seg_t = t0 - phase; let mut idx = 0usize; - // Fast-forward `idx` and the in-element offset by - // the phase amount. - let mut consumed = 0.0_f32; - loop { + let max_iters = (((t1 - t0) / period).ceil() as usize + 2) * n + 8; + let mut iters = 0usize; + while seg_t < t1 && iters < max_iters { let d = family.dashes[idx]; - let d_world = d.abs() * scale; - if consumed + d_world > cursor { - let remaining = consumed + d_world - cursor; - let t_end = (t + remaining).min(t1); - if d > 0.0 && t_end > t { - let p0 = [lx + t * cos_a, ly + t * sin_a]; - let p1 = [lx + t_end * cos_a, ly + t_end * sin_a]; - segments.push([p0, p1]); + let dl = d.abs() * scale; + if d > 0.0 { + let a = seg_t.max(t0); + let b = (seg_t + dl).min(t1); + if b > a { + segments.push([ + [lx + a * cos_a, ly + a * sin_a], + [lx + b * cos_a, ly + b * sin_a], + ]); } - t = t_end; - if t >= t1 - 1e-6 { - break; + } else if d == 0.0 && seg_t >= t0 - 1e-6 && seg_t <= t1 + 1e-6 { + // Short centered mark for the dot. + let a = (seg_t - dot_len * 0.5).max(t0); + let b = (seg_t + dot_len * 0.5).min(t1); + if b > a { + segments.push([ + [lx + a * cos_a, ly + a * sin_a], + [lx + b * cos_a, ly + b * sin_a], + ]); } - cursor = 0.0; - consumed = 0.0; - idx = (idx + 1) % family.dashes.len(); - } else { - consumed += d_world; - idx = (idx + 1) % family.dashes.len(); } + seg_t += dl; + idx = (idx + 1) % n; + iters += 1; if segments.len() >= MAX_SEGMENTS_TOTAL { return segments; } diff --git a/src/shaders/hatch.wgsl b/src/shaders/hatch.wgsl index 6bee8df29..5f8752e91 100644 --- a/src/shaders/hatch.wgsl +++ b/src/shaders/hatch.wgsl @@ -165,13 +165,21 @@ fn check_family( let perp = -px * sin_a + pz * cos_a; let k = round(perp / perp_step); - let d = abs(perp - k * perp_step); + let dperp = perp - k * perp_step; + let d = abs(dperp); let half_px = length(vec2(dpdx(perp), dpdy(perp))) * 0.5; - if d > half_px { return false; } + // World units per screen pixel on each axis — used to light exactly the + // one pixel that contains a dot's centre (pixel-snapped, so a dot stays a + // steady single pixel at any pattern angle instead of flickering). + let wpx = length(vec2(dpdx(xz.x), dpdy(xz.x))); + let wpy = length(vec2(dpdx(xz.y), dpdy(xz.y))); + + // A fragment within ~1px of a line may be a dot; further out is empty fill. + if d > half_px * 2.0 { return false; } // Solid line family — no dash check needed. - if fam.n_dashes == 0u { return true; } + if fam.n_dashes == 0u { return d <= half_px; } // Dash pattern test: position along line k. let along_step = fam.along_step * scale; @@ -185,10 +193,18 @@ fn check_family( let idx = fam.dash_off + j; let sv = f.dash_values[idx / 4u][idx % 4u] * scale; // scale dash lengths if sv > 0.0 { - if t_mod >= pos && t_mod < pos + sv { return true; } + if d <= half_px && t_mod >= pos && t_mod < pos + sv { return true; } pos = pos + sv; - } else { + } else if sv < 0.0 { pos = pos - sv; + } else { + // Dot: signed distance to its lattice centre, rotated back to world + // and snapped to the pixel grid — the grid rotates with the + // pattern but the lit pixel stays a steady single pixel. + let dtv = (t - pos) - round((t - pos) / period) * period; + let owx = -dtv * cos_a + dperp * sin_a; + let owy = -dtv * sin_a - dperp * cos_a; + if abs(owx / wpx) <= 0.5 && abs(owy / wpy) <= 0.5 { return true; } } } return false; diff --git a/src/shaders/hatch_batched.wgsl b/src/shaders/hatch_batched.wgsl index f53ce6982..2e4912630 100644 --- a/src/shaders/hatch_batched.wgsl +++ b/src/shaders/hatch_batched.wgsl @@ -190,11 +190,20 @@ fn check_family( let perp = -px * sin_a + pz * cos_a; let k = round(perp / perp_step); - let d = abs(perp - k * perp_step); + let dperp = perp - k * perp_step; + let d = abs(dperp); let half_px = length(vec2(dpdx(perp), dpdy(perp))) * 0.5; - if d > half_px { return false; } - if fam.n_dashes == 0u { return true; } + // World units per screen pixel on each axis — used to light exactly the + // one pixel that contains a dot's centre (pixel-snapped, so the dot stays + // a steady single pixel at any pattern angle instead of flickering). + let wpx = length(vec2(dpdx(xz.x), dpdy(xz.x))); + let wpy = length(vec2(dpdx(xz.y), dpdy(xz.y))); + + // A fragment within ~1px of a line may be a dot; everything further out is + // empty fill. (A dot's pixel sits on a line, so its perp offset is < 1px.) + if d > half_px * 2.0 { return false; } + if fam.n_dashes == 0u { return d <= half_px; } let along_step = fam.along_step * scale; let period = fam.period * scale; @@ -206,10 +215,19 @@ fn check_family( for (var j = 0u; j < fam.n_dashes; j++) { let sv = dashes[fam.dash_offset + j] * scale; if sv > 0.0 { - if t_mod >= pos && t_mod < pos + sv { return true; } + if d <= half_px && t_mod >= pos && t_mod < pos + sv { return true; } pos = pos + sv; - } else { + } else if sv < 0.0 { pos = pos - sv; + } else { + // Dot: signed distance to its lattice centre (along the line and + // across lines), rotated back to world, then snapped to the pixel + // grid. The dot grid rotates with the pattern; the lit pixel does + // not, so it never thins/flickers. + let dtv = (t - pos) - round((t - pos) / period) * period; + let owx = -dtv * cos_a + dperp * sin_a; + let owy = -dtv * sin_a - dperp * cos_a; + if abs(owx / wpx) <= 0.5 && abs(owy / wpy) <= 0.5 { return true; } } } return false;