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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-09 23:15:45 +03:00
1 parent 6b4272521c
commit 3f9c29d6df
3 files changed
+79 -38

No files matched your search

+35 -28
View File
@@ -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;
}
+21 -5
View File
@@ -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<f32>(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<f32>(dpdx(xz.x), dpdy(xz.x)));
let wpy = length(vec2<f32>(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;
+23 -5
View File
@@ -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<f32>(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<f32>(dpdx(xz.x), dpdy(xz.x)));
let wpy = length(vec2<f32>(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;