feat(hatch): rasterise PAT pattern lines for paper canvas + PDF

Both the on-screen paper canvas and the PDF / print pipeline only knew how
to draw the boundary outline of pattern hatches (ANSI31, ANSI37, dotted
brick, etc.) — the actual hatch lines were a GPU-only feature, so any
paper-space view or PDF export showed a hollow polygon where there should
be ruled fill.

Add a CPU rasteriser as `HatchModel::pattern_segments()`:

- builds an edge list from the boundary, splitting at NaN sentinels and
  closing each sub-path so even-odd inside tests handle islands / holes;
- for every PAT family computes the world-space step vector
  (`(dx,dy)` rotated into the family's frame, scaled by `model.scale`)
  and the inclusive k-range bounded by the boundary AABB plus a 4096-
  line safety cap;
- intersects each line with every boundary edge, sorts the hit
  parameters, walks them in pairs (even-odd) and emits one segment per
  in/out interval. Dash patterns are walked along each clipped span with
  phase preserved via `t mod period`, matching the GPU shader's
  absolute-t dash evaluation;
- pattern origin (`family.x0 / y0`) is rotated by `angle_offset` and
  scaled the same way as `hatch_batched.wgsl`, so PAT patterns with
  non-zero origins (brick / offset herringbone) line up with the GPU
  render.

`paper_canvas::draw_hatch` and `pdf_export::emit_hatch` now route
pattern hatches through this rasteriser; solid / gradient hatches keep
their existing fast paths. The previous outline-only behaviour was a
visible regression on every layout containing a ruled fill.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-22 06:52:20 +03:00
1 parent c2f3844cb6
commit 30f7ad0918
3 files changed
+285 -24

No files matched your search

+40 -15
View File
@@ -290,9 +290,11 @@ fn emit_hatch(ops: &mut Vec<Op>, hatch: &HatchModel, ox: f32, oy: f32) {
let (paint_mode, fill_color) = match &hatch.pattern {
HatchPattern::Solid => (PaintMode::Fill, [r, g, b]),
HatchPattern::Pattern(_) => {
// Stroke the outline only — pattern lines themselves are
// procedural and not synthesised on the CPU.
(PaintMode::Stroke, [r, g, b])
// Pattern fills are emitted as raster line segments below; the
// outline polygon path itself is skipped because pattern
// hatches in real DXF do not draw their boundary as part of
// the fill.
(PaintMode::Clip, [r, g, b]) // sentinel — handled below
}
HatchPattern::Gradient { color2, .. } => {
// PDF gradients are stored in resource dictionaries; for the
@@ -306,21 +308,44 @@ fn emit_hatch(ops: &mut Vec<Op>, hatch: &HatchModel, ox: f32, oy: f32) {
}
};
match paint_mode {
PaintMode::Fill | PaintMode::FillStroke => {
ops.push(Op::SetFillColor {
col: Color::Rgb(Rgb {
r: fill_color[0],
g: fill_color[1],
b: fill_color[2],
icc_profile: None,
}),
// Pattern hatches: rasterise the family lines clipped to the boundary
// and emit each as a stroked line. Skips the polygon outline entirely.
if matches!(hatch.pattern, HatchPattern::Pattern(_)) {
let segments = hatch.pattern_segments();
if segments.is_empty() {
return;
}
ops.push(Op::SetOutlineColor {
col: Color::Rgb(Rgb {
r,
g,
b,
icc_profile: None,
}),
});
for [a, b_pt] in segments {
ops.push(Op::DrawLine {
line: Line {
points: vec![
LinePoint {
p: Point::new(Mm(a[0] + ox), Mm(a[1] + oy)),
bezier: false,
},
LinePoint {
p: Point::new(Mm(b_pt[0] + ox), Mm(b_pt[1] + oy)),
bezier: false,
},
],
is_closed: false,
},
});
}
_ => {}
return;
}
if matches!(paint_mode, PaintMode::Stroke | PaintMode::FillStroke) {
ops.push(Op::SetOutlineColor {
// Solid / gradient: filled polygon path.
if matches!(paint_mode, PaintMode::Fill | PaintMode::FillStroke) {
ops.push(Op::SetFillColor {
col: Color::Rgb(Rgb {
r: fill_color[0],
g: fill_color[1],
+227
View File
@@ -79,3 +79,230 @@ pub struct HatchModel {
/// viewport frame. Mirrors `WireModel.vp_scissor`.
pub vp_scissor: Option<[f32; 4]>,
}
impl HatchModel {
/// CPU-side rasteriser for `HatchPattern::Pattern` — produces the line
/// segments inside the boundary so non-GPU consumers (PDF export,
/// `paper_canvas`, print preview) can draw the actual pattern instead
/// of just the outline.
///
/// Coordinate frame: each emitted segment is in the same
/// boundary-relative WCS that callers already use, i.e. `world_origin +
/// boundary[i]`. Solid / gradient hatches return an empty vec —
/// callers fall back to their solid-fill path.
pub fn pattern_segments(&self) -> Vec<[[f32; 2]; 2]> {
let HatchPattern::Pattern(families) = &self.pattern else {
return Vec::new();
};
if self.boundary.is_empty() || families.is_empty() {
return Vec::new();
}
let ox = self.world_origin[0] as f32;
let oy = self.world_origin[1] as f32;
// ── Build edge list from boundary, splitting on NaN sentinels.
// Each sub-path is closed (last → first edge) so even-odd
// inside-tests work for islands / holes.
let mut edges: Vec<([f32; 2], [f32; 2])> = Vec::new();
let mut sub_start: Option<[f32; 2]> = None;
let mut prev: Option<[f32; 2]> = None;
for &[bx, by] in self.boundary.iter() {
if bx.is_nan() || by.is_nan() {
if let (Some(s), Some(p)) = (sub_start, prev) {
if (s[0] - p[0]).abs() > 1e-6 || (s[1] - p[1]).abs() > 1e-6 {
edges.push((p, s));
}
}
sub_start = None;
prev = None;
continue;
}
let pt = [bx + ox, by + oy];
match (sub_start, prev) {
(None, _) => {
sub_start = Some(pt);
prev = Some(pt);
}
(Some(_), Some(p)) => {
edges.push((p, pt));
prev = Some(pt);
}
_ => {}
}
}
if let (Some(s), Some(p)) = (sub_start, prev) {
if (s[0] - p[0]).abs() > 1e-6 || (s[1] - p[1]).abs() > 1e-6 {
edges.push((p, s));
}
}
if edges.is_empty() {
return Vec::new();
}
// ── AABB of the boundary in world coords.
let mut min_x = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY;
let mut min_y = f32::INFINITY;
let mut max_y = f32::NEG_INFINITY;
for &(a, b) in &edges {
for [x, y] in [a, b] {
min_x = min_x.min(x);
max_x = max_x.max(x);
min_y = min_y.min(y);
max_y = max_y.max(y);
}
}
let scale = self.scale.max(1e-6);
let angle_offset = self.angle_offset;
let mut segments: Vec<[[f32; 2]; 2]> = Vec::new();
// Hard cap to keep pathological patterns / huge boundaries bounded.
const MAX_LINES_PER_FAMILY: i32 = 4096;
const MAX_SEGMENTS_TOTAL: usize = 200_000;
let cos_off = angle_offset.cos();
let sin_off = angle_offset.sin();
for family in families {
let angle = family.angle_deg.to_radians() + angle_offset;
let cos_a = angle.cos();
let sin_a = angle.sin();
// PAT local frame: dx = along-line phase, dy = perpendicular
// spacing. Lines step in world by k · (dx, dy)_local rotated
// into the family's frame.
let step_x = (family.dx * cos_a - family.dy * sin_a) * scale;
let step_y = (family.dx * sin_a + family.dy * cos_a) * scale;
let perp_x = -sin_a;
let perp_y = cos_a;
let step_perp = step_x * perp_x + step_y * perp_y;
if step_perp.abs() < 1e-6 {
continue; // degenerate spacing
}
// k range: project AABB corners onto perp direction relative
// to the family's origin and divide by signed perp step. The
// pattern origin is rotated by `angle_offset` and scaled —
// same convention as the GPU shader, so PAT patterns whose
// `x0/y0` are non-zero (e.g. brick offsets) line up with the
// on-screen render.
let origin = [
(family.x0 * cos_off - family.y0 * sin_off) * scale,
(family.x0 * sin_off + family.y0 * cos_off) * scale,
];
let mut p_min = f32::INFINITY;
let mut p_max = f32::NEG_INFINITY;
for &[cx, cy] in &[
[min_x, min_y],
[max_x, min_y],
[min_x, max_y],
[max_x, max_y],
] {
let p = (cx - origin[0]) * perp_x + (cy - origin[1]) * perp_y;
p_min = p_min.min(p);
p_max = p_max.max(p);
}
let mut k_lo = (p_min / step_perp).floor() as i32 - 1;
let mut k_hi = (p_max / step_perp).ceil() as i32 + 1;
if k_lo > k_hi {
std::mem::swap(&mut k_lo, &mut k_hi);
}
k_lo = k_lo.max(-MAX_LINES_PER_FAMILY);
k_hi = k_hi.min(MAX_LINES_PER_FAMILY);
let period: f32 = family.dashes.iter().map(|d| d.abs()).sum::<f32>() * scale;
let has_dashes = !family.dashes.is_empty() && period > 1e-6;
for k in k_lo..=k_hi {
if segments.len() >= MAX_SEGMENTS_TOTAL {
return segments;
}
let kf = k as f32;
let lx = origin[0] + kf * step_x;
let ly = origin[1] + kf * step_y;
// Intersect line P(t) = L + t·(cos_a, sin_a) against each
// boundary edge; collect t-values where the edge actually
// crosses (s ∈ [0,1]).
let mut ts: Vec<f32> = Vec::with_capacity(8);
for &(a, b) in &edges {
let ex = b[0] - a[0];
let ey = b[1] - a[1];
let det = ex * sin_a - ey * cos_a; // = sin_a·ex − cos_a·ey
if det.abs() < 1e-9 {
continue;
}
let rx = a[0] - lx;
let ry = a[1] - ly;
let t = (ex * ry - ey * rx) / det;
let s = (cos_a * ry - sin_a * rx) / det;
if s >= 0.0 && s <= 1.0 {
ts.push(t);
}
}
if ts.len() < 2 {
continue;
}
ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
// De-duplicate near-coincident hits (line clipping a vertex).
ts.dedup_by(|a, b| (*a - *b).abs() < 1e-5);
if ts.len() < 2 {
continue;
}
// Even-odd: emit segments between consecutive pairs.
for pair in ts.chunks_exact(2) {
let t0 = pair[0];
let t1 = pair[1];
if t1 - t0 < 1e-6 {
continue;
}
if !has_dashes {
let p0 = [lx + t0 * cos_a, ly + t0 * sin_a];
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).
let phase = t0.rem_euclid(period);
let mut t = t0;
let mut cursor = 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 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]);
}
t = t_end;
if t >= t1 - 1e-6 {
break;
}
cursor = 0.0;
consumed = 0.0;
idx = (idx + 1) % family.dashes.len();
} else {
consumed += d_world;
idx = (idx + 1) % family.dashes.len();
}
if segments.len() >= MAX_SEGMENTS_TOTAL {
return segments;
}
}
}
}
}
}
segments
}
}
+18 -9
View File
@@ -188,15 +188,24 @@ fn draw_hatch(frame: &mut canvas::Frame, hatch: &HatchModel, to_px: &impl Fn(f32
frame.fill(&path, color);
}
HatchPattern::Pattern(_) => {
// Pattern hatches: draw just the boundary outline for now.
frame.stroke(
&path,
canvas::Stroke {
style: canvas::Style::Solid(color),
width: 1.0,
..Default::default()
},
);
// Rasterise the PAT line families clipped to the boundary so
// the paper canvas shows the actual hatch lines (ANSI31, etc.)
// instead of just the outline. Matches the GPU shader output
// for model-space hatches.
for [a, b] in hatch.pattern_segments() {
let seg = canvas::Path::new(|builder| {
builder.move_to(to_px(a[0], a[1]));
builder.line_to(to_px(b[0], b[1]));
});
frame.stroke(
&seg,
canvas::Stroke {
style: canvas::Style::Solid(color),
width: 1.0,
..Default::default()
},
);
}
}
HatchPattern::Gradient { color2, .. } => {
// Gradient: average the two colours as a solid fill.