// CPU-side hatch fill data. The kernel triangulates the boundary and the GPU // evaluates the pattern inside that mesh. use std::sync::Arc; use kernel::geom2d::{ inside_spans, Curve as KernelCurve, Line as KernelLine, Tolerance, XLine as KernelXLine, }; pub const MAX_HATCH_BOUNDARY_VERTS: usize = 1024; /// How near two crossings along a pattern line must be to count as one. /// /// The boundary reaching here is already a chord approximation of the drawn /// curves, so this only has to be finer than that sampling and coarser than /// the rounding in it. const CLIP_TOLERANCE_LINEAR: f64 = 1.0e-6; /// One line family from a PAT-format hatch pattern. /// /// Format mirrors the standard PAT line format: /// `angle_deg, x0, y0, dx, dy [, dash1, dash2, ...]` /// /// The perpendicular spacing between adjacent parallel lines is: /// `| -dx * sin(angle) + dy * cos(angle) |` #[derive(Clone, Debug)] pub struct PatFamily { /// Line direction in degrees. pub angle_deg: f32, /// Origin of the first line in this family. pub x0: f32, pub y0: f32, /// Step vector to the next parallel line. pub dx: f32, pub dy: f32, /// Dash/gap sequence: positive = dash length, negative = gap length. /// Empty = solid (no dash pattern). pub dashes: Vec, } /// The standard gradient fill shapes. Spherical / Hemispherical run from the /// boundary centre outward; the others run along `angle_deg`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum GradientKind { Linear, Cylinder, Spherical, Hemispherical, Curved, } impl GradientKind { /// User-facing gradient definitions in their standard persisted order. /// Inverted definitions are real named patterns rather than a separate /// property, so Properties and the draw command share this single list. pub const CHOICES: [(GradientKind, bool); 9] = [ (GradientKind::Linear, false), (GradientKind::Cylinder, false), (GradientKind::Cylinder, true), (GradientKind::Spherical, false), (GradientKind::Hemispherical, false), (GradientKind::Curved, false), (GradientKind::Spherical, true), (GradientKind::Hemispherical, true), (GradientKind::Curved, true), ]; /// Radial fills shade from the boundary centre outward. pub fn radial(self) -> bool { matches!(self, GradientKind::Spherical | GradientKind::Hemispherical) } pub fn choice_label(self, inverted: bool) -> &'static str { match (self, inverted) { (GradientKind::Linear, _) => "Linear", (GradientKind::Cylinder, false) => "Cylindrical", (GradientKind::Cylinder, true) => "Inverted cylindrical", (GradientKind::Spherical, false) => "Spherical", (GradientKind::Spherical, true) => "Inverted spherical", (GradientKind::Hemispherical, false) => "Hemispherical", (GradientKind::Hemispherical, true) => "Inverted hemispherical", (GradientKind::Curved, false) => "Curved", (GradientKind::Curved, true) => "Inverted curved", } } pub fn from_choice_label(label: &str) -> Option<(Self, bool)> { Self::CHOICES .iter() .copied() .find(|(kind, inverted)| kind.choice_label(*inverted).eq_ignore_ascii_case(label)) } /// Parse the DXF gradient name (`LINEAR`, `INVCYLINDER`, …) into the kind /// plus its inverted flag. Unknown names read as Linear. pub fn from_name(name: &str) -> (Self, bool) { let n = name.trim().to_ascii_uppercase(); let invert = n.starts_with("INV"); let base = n.trim_start_matches("INV"); let kind = if base.contains("CYL") { GradientKind::Cylinder } else if base.contains("HEMI") { GradientKind::Hemispherical } else if base.contains("SPHER") { GradientKind::Spherical } else if base.contains("CURV") { GradientKind::Curved } else { GradientKind::Linear }; (kind, invert) } /// The DXF gradient name. Linear has no INV variant in the standard set — /// an inverted linear is persisted by swapping the colour stops instead. pub fn dxf_name(self, invert: bool) -> &'static str { match (self, invert) { (GradientKind::Linear, _) => "LINEAR", (GradientKind::Cylinder, false) => "CYLINDER", (GradientKind::Cylinder, true) => "INVCYLINDER", (GradientKind::Spherical, false) => "SPHERICAL", (GradientKind::Spherical, true) => "INVSPHERICAL", (GradientKind::Hemispherical, false) => "HEMISPHERICAL", (GradientKind::Hemispherical, true) => "INVHEMISPHERICAL", (GradientKind::Curved, false) => "CURVED", (GradientKind::Curved, true) => "INVCURVED", } } /// Shape selector for the GPU shader (`grad_kind` low bits). pub fn shader_kind(self) -> u32 { match self { GradientKind::Linear => 0, GradientKind::Cylinder => 1, GradientKind::Spherical => 2, GradientKind::Hemispherical => 3, GradientKind::Curved => 4, } } } pub(crate) fn plot_style_fill_pattern(style: u8) -> Option { let family = |angle_deg, dx, dy, dashes: &[f32]| PatFamily { angle_deg, x0: 0.0, y0: 0.0, dx, dy, dashes: dashes.to_vec(), }; let pattern = match style { 64 => HatchPattern::Solid, 65 => HatchPattern::Pattern(vec![ family(0.0, 0.0, 2.0, &[2.0, -2.0]), family(90.0, 2.0, 0.0, &[2.0, -2.0]), ]), 66 => HatchPattern::Pattern(vec![ family(0.0, 0.0, 2.0, &[]), family(90.0, 2.0, 0.0, &[]), ]), 67 => HatchPattern::Pattern(vec![ family(45.0, 0.0, 2.0, &[]), family(135.0, 0.0, 2.0, &[]), ]), 68 => HatchPattern::Pattern(vec![family(0.0, 0.0, 2.0, &[])]), 69 => HatchPattern::Pattern(vec![family(135.0, 0.0, 2.0, &[])]), 70 => HatchPattern::Pattern(vec![family(45.0, 0.0, 2.0, &[])]), 71 => HatchPattern::Pattern(vec![family(0.0, 0.0, 2.0, &[0.0, -2.0])]), 72 => HatchPattern::Pattern(vec![family(90.0, 2.0, 0.0, &[])]), _ => return None, }; Some(pattern) } /// Hatch fill pattern. #[derive(Clone, Debug)] pub enum HatchPattern { /// Opaque solid fill. Solid, /// One or more line families (PAT format). Pattern(Vec), /// Two-stop gradient from `color` to `color2`, shaped by `kind`; `invert` /// swaps the two stops. Gradient { angle_deg: f32, color2: [f32; 4], kind: GradientKind, invert: bool, /// 0 = centred, 1 = shifted towards the upper-left light source. shift: f32, }, } /// GPU-side separator between disconnected boundary sub-loops. A finite /// magnitude sentinel instead of NaN: shaders detect it with a plain /// `abs(x) < 1e29` compare, which every driver evaluates identically, /// unlike NaN self-comparison (#386, #416). Boundary verts are local /// (anchor-relative), so real coordinates never approach it. pub const GPU_BOUNDARY_SEP: f32 = 1.0e30; #[derive(Clone, Copy, Debug)] pub struct FillPlane { pub origin: [f64; 3], pub x_axis: [f64; 3], pub y_axis: [f64; 3], } /// A hatched region defined by a closed polygon boundary. #[derive(Clone, Debug)] pub struct HatchModel { /// Recorded hatch origin in its fill plane, independent of intrinsic PAT line bases. pub pattern_origin: Option<[f64; 2]>, pub render_instance: Option, /// World XY anchor (in the same offset-relative coordinate space as /// the rest of the scene — `world_offset` already subtracted, but /// kept at f64 precision). Boundary vertices are stored as f32 /// offsets from this anchor so that: /// 1) hit-test / paper_canvas can still read small-magnitude f32 /// coords without precision loss from the f64 → f32 cast that /// would otherwise happen at large drawing extents (UTM, etc.). /// 2) the GPU pipeline can pre-shift the quad in hatch-local /// space (so the fragment shader's `xz` varying stays small) /// and add `world_origin` back inside the view_proj multiply. /// Reconstruct WCS-relative coords as `(world_origin.x + v.x as f64, /// world_origin.y + v.y as f64)`. pub world_origin: [f64; 2], /// World-XY coordinates of the boundary polygon vertices, stored as /// f32 offsets from `world_origin`. NaN-NaN sentinels separate /// disconnected paths and must be preserved un-shifted by consumers. /// GPU uploads rewrite them to [`GPU_BOUNDARY_SEP`] — some drivers /// (Intel Mesa) fold the shader-side `x == x` NaN test to `true` /// under fast math, which turned separators into real vertices and /// bled fills outside their boundary (#386, #416). pub boundary: Arc>, /// Exact absolute-WCS boundary in f64, set only by the draw commands so a /// typed boundary vertex is persisted without the f32 quantization the /// render-side `boundary` would incur (issue #311). `None` for hatches /// rebuilt from a DXF entity — `add_hatch` then reconstructs the persisted /// vertices from `boundary` + `world_origin` instead. pub boundary_wcs: Option>>, /// Optional 3-D placement for planar fills. pub fill_plane: Option, pub fill_plane_boundary: Option>>, /// Per-ring DXF role, aligned with the NaN-separated boundary paths. pub boundary_exterior: Option>>, /// Source entity handles for each boundary ring. pub boundary_sources: Option>>>, /// Exact boundary paths retained for persistence and editing. pub boundary_paths: Option>>, /// Island handling used by the persisted hatch entity. pub style: codec::entities::HatchStyleType, /// Fill pattern. pub pattern: HatchPattern, /// Catalog name for this pattern (e.g. "ANSI31", "SOLID", "LINEAR"). /// Stored so `add_hatch()` can write the correct name to the DXF entity. pub name: String, /// RGBA color in [0,1]. pub color: [f32; 4], /// Effective indexed color used by plot style tables; 0 means RGB/default. pub aci: u8, /// Effective display lineweight used by pattern strokes. PDF export converts /// this back to a physical thickness unless a plot style overrides it. pub line_weight_px: f32, /// Pattern rotation offset in radians (from DXF `pattern_angle`). /// Applied on top of each family's base angle at render time. pub angle_offset: f32, /// Pattern scale multiplier (from DXF `pattern_scale`). pub scale: f32, /// Normalized draw-order depth in (0,1); higher draws on top. Fed to the /// hatch pipeline as a small clip-z bias so this fill orders correctly /// against other entity types. 0.0 for transient/preview hatches. pub draw_depth: f32, } impl HatchModel { /// Apply an entity's resolved opacity to every colour stop. Gradient stop /// colours come from the HATCH payload, but transparency remains a common /// entity property and must affect both ends equally. pub(crate) fn set_alpha(&mut self, alpha: f32) { self.color[3] = alpha; if let HatchPattern::Gradient { color2, .. } = &mut self.pattern { color2[3] = alpha; } } } impl HatchModel { pub(crate) fn gradient_frame( &self, angle_deg: f32, shift: f32, ) -> Option { let boundary: Vec<[f64; 2]> = self .boundary .iter() .filter(|point| point[0].is_finite() && point[1].is_finite()) .map(|point| [point[0] as f64, point[1] as f64]) .collect(); kernel::geom2d::gradient_frame( &boundary, (angle_deg as f64).to_radians(), shift as f64, Tolerance::default(), ) } /// Indexed local-space fill mesh with even-odd loop containment. pub(crate) fn fill_mesh(&self) -> (Vec<[f32; 2]>, Vec) { let mut rings = Vec::new(); let mut ring = Vec::new(); for &[x, y] in self.boundary.iter() { if x.is_finite() && y.is_finite() { ring.push([x as f64, y as f64]); } else if ring.len() >= 3 { rings.push(std::mem::take(&mut ring)); } else { ring.clear(); } } if ring.len() >= 3 { rings.push(ring); } let (points, triangles) = kernel::geom2d::triangulate_rings(&rings); let vertices = points .into_iter() .map(|[x, y]| [x as f32, y as f32]) .collect(); let mut indices = Vec::with_capacity(triangles.len() * 3); for triangle in triangles { for index in triangle { let Ok(index) = u32::try_from(index) else { return (Vec::new(), Vec::new()); }; indices.push(index); } } (vertices, indices) } /// 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 absolute WCS in f64, i.e. /// `world_origin + boundary[i]` resolved. Solid / gradient hatches return an /// empty vec — callers fall back to their solid-fill path. /// /// The whole rasterisation is f64 because boundaries and family origins are /// resolved in absolute WCS. Narrowing them first at UTM magnitudes would /// quantise both the generated lines and the `edge − line` intersections. pub fn pattern_segments(&self) -> Vec<[[f64; 2]; 2]> { self.pattern_segments_with_dot_length(None) } /// Plot variant of [`Self::pattern_segments`] that preserves PAT /// zero-length dash entries as coincident endpoints. The PDF exporter can /// then paint a round point; turning them into short segments here makes /// point-only patterns such as AR-CONC visibly look like tiny lines. pub(crate) fn pattern_segments_for_plot(&self) -> Vec<[[f64; 2]; 2]> { self.pattern_segments_with_dot_length(Some(0.0)) } fn pattern_segments_with_dot_length(&self, plot_dot_length: Option) -> Vec<[[f64; 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]; let oy = self.world_origin[1]; // ── 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<([f64; 2], [f64; 2])> = Vec::new(); let mut sub_start: Option<[f64; 2]> = None; let mut prev: Option<[f64; 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 as f64 + ox, by as f64 + 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(); } // The boundary as kernel curves, built once for every pattern line to // be clipped against. let clip_tolerance = Tolerance::new(CLIP_TOLERANCE_LINEAR); let boundary: Vec = edges .iter() .map(|(a, b)| KernelCurve::Line(KernelLine { start: *a, end: *b })) .collect(); // ── AABB of the boundary in world coords. let mut min_x = f64::INFINITY; let mut max_x = f64::NEG_INFINITY; let mut min_y = f64::INFINITY; let mut max_y = f64::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) as f64; let angle_offset = self.angle_offset as f64; let mut segments: Vec<[[f64; 2]; 2]> = Vec::new(); // Hard cap to keep pathological patterns / huge boundaries bounded. // `k` is i64: at UTM with a fine spacing the index legitimately reaches // ~1e7 and an out-of-range `as i32` saturates silently, collapsing the // range to a bogus run of lines from i32::MIN. const MAX_LINES_PER_FAMILY: i64 = 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 as f64).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 (fdx, fdy) = (family.dx as f64, family.dy as f64); let step_x = (fdx * cos_a - fdy * sin_a) * scale; let step_y = (fdx * sin_a + fdy * 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 (fx0, fy0) = (family.x0 as f64, family.y0 as f64); let origin = [ ox + (fx0 * cos_off - fy0 * sin_off) * scale, oy + (fx0 * sin_off + fy0 * cos_off) * scale, ]; let mut p_min = f64::INFINITY; let mut p_max = f64::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 i64 - 1; let mut k_hi = (p_max / step_perp).ceil() as i64 + 1; if k_lo > k_hi { std::mem::swap(&mut k_lo, &mut k_hi); } // Cap the line COUNT (span), not the absolute index. A hatch far // from the pattern origin (0,0) — e.g. a fine-spaced fill at large // drawing coordinates — has large-magnitude k at both ends but a // small span. Clamping the absolute index to ±MAX_LINES_PER_FAMILY // would invert the range (k_lo > k_hi) and emit nothing, silently // dropping the whole fill. if k_hi.saturating_sub(k_lo) > MAX_LINES_PER_FAMILY { k_hi = k_lo.saturating_add(MAX_LINES_PER_FAMILY); } let period: f64 = family.dashes.iter().map(|d| d.abs() as f64).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 f64; let lx = origin[0] + kf * step_x; let ly = origin[1] + kf * step_y; // The stretches of this pattern line that fall inside the // boundary. The direction is a unit vector, so the kernel's // parameter is distance along the line — the same `t` the // dash walk below steps in. // // Each span is tested for containment rather than taken from // alternating crossings. A line that grazes a corner crosses // twice in one place, and an alternating walk inverts from // there on: the fill lands in the holes and the solid comes // out empty. let spans = inside_spans( &boundary, &KernelCurve::XLine(KernelXLine { base: [lx, ly], direction: [cos_a, sin_a], }), clip_tolerance, ); for span in spans { let t0 = span[0]; let t1 = span[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 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 round-capped point. let n = family.dashes.len(); let dot_len = plot_dot_length.unwrap_or_else(|| (period * 0.06).max(1e-3)); let phase = t0.rem_euclid(period); // 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; 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] as f64; 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], ]); } } else if d == 0.0 && seg_t >= t0 - 1e-6 && seg_t <= t1 + 1e-6 { if plot_dot_length == Some(0.0) { let point = [lx + seg_t * cos_a, ly + seg_t * sin_a]; segments.push([point, point]); } else { 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], ]); } } } seg_t += dl; idx = (idx + 1) % n; iters += 1; if segments.len() >= MAX_SEGMENTS_TOTAL { return segments; } } } } } } segments } }