2026-03-22 14:56:27 +03:00
|
|
|
|
// HatchModel — CPU-side hatch fill data; rendered entirely on the GPU.
|
2026-05-06 22:42:22 +03:00
|
|
|
|
use std::sync::Arc;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
//
|
2026-05-10 20:29:39 +03:00
|
|
|
|
// The boundary is a closed polygon in world XY coordinates.
|
2026-03-22 14:56:27 +03:00
|
|
|
|
// The GPU fragment shader performs point-in-polygon and hatch-line tests so
|
|
|
|
|
|
// no line geometry is ever tessellated on the CPU.
|
|
|
|
|
|
|
2026-05-10 20:29:39 +03:00
|
|
|
|
pub const MAX_HATCH_BOUNDARY_VERTS: usize = 1024;
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
/// 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<f32>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Hatch fill pattern.
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
|
pub enum HatchPattern {
|
|
|
|
|
|
/// Opaque solid fill.
|
|
|
|
|
|
Solid,
|
|
|
|
|
|
/// One or more line families (PAT format).
|
|
|
|
|
|
Pattern(Vec<PatFamily>),
|
|
|
|
|
|
/// Linear gradient from `color` to `color2` along `angle_deg`.
|
|
|
|
|
|
Gradient { angle_deg: f32, color2: [f32; 4] },
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A hatched region defined by a closed polygon boundary.
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
|
pub struct HatchModel {
|
2026-05-16 08:41:23 +03:00
|
|
|
|
/// 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.
|
2026-05-06 22:42:22 +03:00
|
|
|
|
pub boundary: Arc<Vec<[f32; 2]>>,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
/// 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],
|
|
|
|
|
|
/// 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,
|
2026-05-15 23:13:16 +03:00
|
|
|
|
/// Optional world-space XY rect [x0, y0, x1, y1] for paper-space
|
|
|
|
|
|
/// viewport clipping. When `Some`, the pipeline translates it into
|
|
|
|
|
|
/// a per-frame pixel scissor and clips this hatch's draw call to it,
|
|
|
|
|
|
/// preventing viewport-projected content from bleeding past the
|
|
|
|
|
|
/// viewport frame. Mirrors `WireModel.vp_scissor`.
|
|
|
|
|
|
pub vp_scissor: Option<[f32; 4]>,
|
feat(draworder): cross-type draw order + context menu
DRAWORDER now affects rendering immediately (bump geometry epoch so the
sort cache rebuilds) and front/back anchor to the block's full effective
sort range instead of only existing table entries, so bring-to-front
actually lands on top. The render fallback sorts table-absent entities by
their own handle.
Draw order now applies across entity types, not just within a type. A
per-entity draw-order rank (SortEntitiesTable override or own handle,
ranked per block) is encoded as a small signed clip-z bias in the wire,
hatch, image, and 3DFACE pipelines. The shared LessEqual depth buffer then
orders fills, lines, rasters and 2D solids against each other regardless of
pass order — without touching model coordinates. 3D meshes keep real
geometric depth; wipeouts stay always-on-top.
Adds a Draw Order submenu to the viewport context menu (Bring to Front,
Send to Back, Bring Above/Send Under Object). The above/under items run an
interactive reference-object pick, then reorder the captured selection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:29:01 +03:00
|
|
|
|
/// 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,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
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>
2026-05-22 06:52:20 +03:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|