diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index f707bfaab..943ea7948 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -290,9 +290,11 @@ fn emit_hatch(ops: &mut Vec, 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, 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], diff --git a/src/scene/hatch_model.rs b/src/scene/hatch_model.rs index aba122f40..53572965d 100644 --- a/src/scene/hatch_model.rs +++ b/src/scene/hatch_model.rs @@ -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::() * 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 = 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 + } +} diff --git a/src/scene/paper_canvas.rs b/src/scene/paper_canvas.rs index 8b98f4b7c..9232d6ea8 100644 --- a/src/scene/paper_canvas.rs +++ b/src/scene/paper_canvas.rs @@ -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.