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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-05-22 06:52:20 +03:00
commit 30f7ad0918
3 changed files with 285 additions and 24 deletions

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],