fix(scene): hatch boundary + pattern precision for large-WCS drawings

Two precision problems were producing visibly wrong hatches on UTM-scale
(and similarly-offset) drawings even after world_offset was correct:

1. **Boundary tessellation** — `hatch_model_from_dxf`'s polyline-bulge
   arc tessellation ran the whole arc-center computation in f32. With
   vertices at WCS magnitude ~1e5 the f32 ULP is ~1 cm, which propagated
   into the sampled arc points and showed up as visibly wavy / jagged
   hatch boundaries on edges that crossed the long axis of the drawing.
   Re-run the full bulge → arc math in f64; only cast at the to_xy
   call that feeds the (also f64) boundary buffer.

2. **Pattern phase** — the per-fragment `perp / perp_step` modulo blew
   precision when `xz` was at offset-rel WCS magnitude and perp_step
   was sub-cm. Move the precision-critical bits out of f32 by keeping
   each hatch's anchor in f64 on the model side: `HatchModel` now
   carries a `world_origin: [f64; 2]` and the `boundary: [f32; 2]`
   vertices are stored as small offsets from it. The GPU vertex shader
   adds `origin` back inside the view_proj multiply for clip position
   (sub-pixel-lossy at large hatches but invisible); the `xz` varying
   that the fragment shader uses for in_polygon + pattern math stays
   in hatch-local space at full f32 precision.

   For pattern alignment across adjacent hatches with the same family,
   the GPU pipeline snaps the anchor to the family's perp/along grid
   in f64 before sending it down. Snap uses the same QCAD-PAT
   convention (`fam.dy = perp_step`, `fam.dx = along_step`) the
   shader's check_family uses — an earlier attempt used the world-frame
   `(-dx*sin + dy*cos)` formula, which produced a meaningless grid and
   no alignment.

   Trade-off: pattern phase is "global modulo per-family-grid" rather
   than tied to WCS (0,0). Same hatches still align with each other;
   the absolute phase relative to the WCS origin may differ from the
   pre-fix behaviour on small drawings.

Consumers that read `model.boundary` directly (hit_test, paper_canvas,
add_hatch round-trip) reconstruct WCS via `boundary[i] + world_origin`.
The hatch wgsl bind-group entry for binding 0 was bumped to
`VERTEX | FRAGMENT` because the vertex shader now reads `h.origin`.

The fragment-shader hatch LOD substitute + sub-pixel skip from e7675be
and the scissor plumbing from fbbac42 still work — those touched
different fields of the same struct.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-16 08:41:23 +03:00
1 parent dc80968023
commit 172c538159
8 files changed
+238 -63

No files matched your search

+3
View File
@@ -119,6 +119,7 @@ impl HatchCommand {
color: [0.75, 0.75, 0.75, 0.85],
angle_offset: 0.0,
scale: 1.0,
world_origin: [0.0; 2],
vp_scissor: None,
}
}
@@ -255,6 +256,7 @@ impl GradientCommand {
color: [0.30, 0.60, 0.95, 0.80],
angle_offset: 0.0,
scale: 1.0,
world_origin: [0.0; 2],
vp_scissor: None,
}
}
@@ -404,6 +406,7 @@ impl CadCommand for BoundaryCommand {
color: [0.45, 0.45, 0.45, 0.60],
angle_offset: 0.0,
scale: 1.0,
world_origin: [0.0; 2],
vp_scissor: None,
};
return CmdResult::CommitHatch(model);
+16 -2
View File
@@ -43,8 +43,22 @@ pub enum HatchPattern {
/// A hatched region defined by a closed polygon boundary.
#[derive(Clone, Debug)]
pub struct HatchModel {
/// World XY coordinates of the boundary polygon vertices.
/// Arc so HatchModel::clone() is a pointer bump rather than a full Vec copy.
/// 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.
pub boundary: Arc<Vec<[f32; 2]>>,
/// Fill pattern.
pub pattern: HatchPattern,
+12 -3
View File
@@ -277,10 +277,15 @@ pub fn click_hit_hatch(
bounds: Rectangle,
) -> Option<Handle> {
for (&handle, hatch) in hatches {
// boundary verts are stored as small f32 offsets from
// `world_origin` (f64). Reconstruct offset-rel WCS before
// projecting to screen.
let ox = hatch.world_origin[0] as f32;
let oy = hatch.world_origin[1] as f32;
let screen: Vec<Point> = hatch
.boundary
.iter()
.map(|&[x, y]| world_to_screen(Vec3::new(x, y, 0.0), view_proj, bounds))
.map(|&[x, y]| world_to_screen(Vec3::new(x + ox, y + oy, 0.0), view_proj, bounds))
.collect();
if screen.len() >= 3 && point_in_polygon(cursor, &screen) {
return Some(handle);
@@ -315,10 +320,12 @@ pub fn box_hit_hatch(
if hatch.boundary.is_empty() {
return None;
}
let ox = hatch.world_origin[0] as f32;
let oy = hatch.world_origin[1] as f32;
let screen: Vec<Point> = hatch
.boundary
.iter()
.map(|&[x, y]| world_to_screen(Vec3::new(x, y, 0.0), view_proj, bounds))
.map(|&[x, y]| world_to_screen(Vec3::new(x + ox, y + oy, 0.0), view_proj, bounds))
.collect();
let hit = if crossing {
screen.iter().any(|&sp| inside(sp))
@@ -352,10 +359,12 @@ pub fn poly_hit_hatch(
if hatch.boundary.is_empty() {
return None;
}
let ox = hatch.world_origin[0] as f32;
let oy = hatch.world_origin[1] as f32;
let screen: Vec<Point> = hatch
.boundary
.iter()
.map(|&[x, y]| world_to_screen(Vec3::new(x, y, 0.0), view_proj, bounds))
.map(|&[x, y]| world_to_screen(Vec3::new(x + ox, y + oy, 0.0), view_proj, bounds))
.collect();
let hit = if crossing {
screen.iter().any(|&sp| point_in_polygon(sp, poly))
+74 -34
View File
@@ -2307,6 +2307,7 @@ impl Scene {
color,
angle_offset: 0.0,
scale: 1.0,
world_origin: [0.0; 2],
vp_scissor: None,
});
}
@@ -2360,6 +2361,7 @@ impl Scene {
color: fill_color,
angle_offset: 0.0,
scale: 1.0,
world_origin: [0.0; 2],
vp_scissor: None,
});
}
@@ -2418,21 +2420,26 @@ impl Scene {
) -> Option<HatchModel> {
let [ox, oy, _oz] = world_offset;
let normal = (dxf.normal.x, dxf.normal.y, dxf.normal.z);
let to_xy = |x: f64, y: f64| -> [f32; 2] {
// Build the boundary in f64 first so the precision-preserving
// origin computation below sees full WCS precision. We only cast
// to f32 once at the end, after subtracting the AABB centre, so
// the stored offsets are small-magnitude with high f32 precision
// even on large UTM-scale drawings.
let to_xy = |x: f64, y: f64| -> [f64; 2] {
let (wx, wy, _) =
crate::scene::transform::ocs_point_to_wcs((x, y, dxf.elevation), normal);
[(wx - ox) as f32, (wy - oy) as f32]
[wx - ox, wy - oy]
};
if dxf.paths.is_empty() {
return None;
}
let mut boundary: Vec<[f32; 2]> = Vec::new();
let mut boundary: Vec<[f64; 2]> = Vec::new();
for path in &dxf.paths {
let before_path = boundary.len();
if !boundary.is_empty() {
boundary.push([f32::NAN, f32::NAN]);
boundary.push([f64::NAN, f64::NAN]);
}
let path_start = boundary.len();
@@ -2456,54 +2463,50 @@ impl Scene {
if bulge.abs() < 1e-9 {
boundary.push(to_xy(v0.x, v0.y));
} else {
let p0 = [v0.x as f32, v0.y as f32];
let p1 = [v1.x as f32, v1.y as f32];
let theta = 4.0 * (bulge as f32).atan();
let dx = p1[0] - p0[0];
let dy = p1[1] - p0[1];
// Run the whole bulge → arc tessellation in
// f64. The old f32 path lost ~1 cm of
// precision at UTM-scale WCS coordinates
// (1e5 magnitude × 1 ULP), which showed up
// as visibly wavy hatch-boundary arcs.
let theta = 4.0 * bulge.atan();
let dx = v1.x - v0.x;
let dy = v1.y - v0.y;
let d = (dx * dx + dy * dy).sqrt();
if d < 1e-9 {
if d < 1e-12 {
boundary.push(to_xy(v0.x, v0.y));
continue;
}
let r = (d * 0.5) / (theta * 0.5).sin().abs();
let mx = (p0[0] + p1[0]) * 0.5;
let my = (p0[1] + p1[1]) * 0.5;
let mx = (v0.x + v1.x) * 0.5;
let my = (v0.y + v1.y) * 0.5;
let px = -dy / d;
let py = dx / d;
let sign = if bulge > 0.0 { 1.0_f32 } else { -1.0_f32 };
let sign = if bulge > 0.0 { 1.0_f64 } else { -1.0_f64 };
let center_offset = r * (theta * 0.5).cos();
let cx = mx + sign * px * center_offset;
let cy = my + sign * py * center_offset;
let a0 = (p0[1] - cy).atan2(p0[0] - cx);
let a1 = (p1[1] - cy).atan2(p1[0] - cx);
let a0 = (v0.y - cy).atan2(v0.x - cx);
let a1 = (v1.y - cy).atan2(v1.x - cx);
let mut sweep = a1 - a0;
const TAU: f64 = std::f64::consts::TAU;
if bulge > 0.0 {
if sweep <= 0.0 {
sweep += std::f32::consts::TAU;
sweep += TAU;
}
} else if sweep >= 0.0 {
sweep -= std::f32::consts::TAU;
sweep -= TAU;
}
if sweep.abs() < 1e-7 {
sweep = if bulge > 0.0 {
std::f32::consts::TAU
} else {
-std::f32::consts::TAU
};
if sweep.abs() < 1e-9 {
sweep = if bulge > 0.0 { TAU } else { -TAU };
}
let r64 = r as f64;
let segs = tessellate::arc_segments(
r64,
sweep.abs() as f64,
tessellate::fill_chord_tol(r64),
r,
sweep.abs(),
tessellate::fill_chord_tol(r),
);
for j in 0..segs {
let a = a0 + sweep * (j as f32 / segs as f32);
boundary.push(to_xy(
(cx + r * a.cos()) as f64,
(cy + r * a.sin()) as f64,
));
let a = a0 + sweep * (j as f64 / segs as f64);
boundary.push(to_xy(cx + r * a.cos(), cy + r * a.sin()));
}
}
}
@@ -2656,13 +2659,47 @@ impl Scene {
} else {
dxf.pattern.name.clone()
};
// Precision-preserving cast f64 → f32: pick an `world_origin`
// anchor (boundary AABB centre in f64) and store every vertex
// as a small f32 offset from it. NaN separators are preserved
// so the in_polygon ray-cast still sees the path breaks.
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
let mut max_x = f64::NEG_INFINITY;
let mut max_y = f64::NEG_INFINITY;
for &[x, y] in &boundary {
if x.is_finite() && y.is_finite() {
if x < min_x { min_x = x; }
if y < min_y { min_y = y; }
if x > max_x { max_x = x; }
if y > max_y { max_y = y; }
}
}
let world_origin = if min_x.is_finite() && min_y.is_finite() {
[(min_x + max_x) * 0.5, (min_y + max_y) * 0.5]
} else {
[0.0, 0.0]
};
let boundary_f32: Vec<[f32; 2]> = boundary
.iter()
.map(|&[x, y]| {
if x.is_finite() && y.is_finite() {
[(x - world_origin[0]) as f32, (y - world_origin[1]) as f32]
} else {
[f32::NAN, f32::NAN]
}
})
.collect();
Some(HatchModel {
boundary: std::sync::Arc::new(boundary),
boundary: std::sync::Arc::new(boundary_f32),
pattern,
name,
color,
angle_offset: dxf.pattern_angle as f32,
scale: dxf.pattern_scale as f32,
world_origin,
vp_scissor: None,
})
}
@@ -2812,6 +2849,7 @@ impl Scene {
color,
angle_offset: 0.0,
scale: 1.0,
world_origin: [0.0; 2],
vp_scissor: None,
}
}
@@ -2822,10 +2860,12 @@ impl Scene {
model.pattern,
crate::scene::hatch_model::HatchPattern::Solid
);
let [wx, wy] = model.world_origin;
let verts: Vec<Vector2> = model
.boundary
.iter()
.map(|&[x, y]| Vector2::new(x as f64, y as f64))
.filter(|v| v[0].is_finite() && v[1].is_finite())
.map(|&[x, y]| Vector2::new(x as f64 + wx, y as f64 + wy))
.collect();
let edge = PolylineEdge::new(verts, true);
let mut path = BoundaryPath::external();
+5 -2
View File
@@ -171,11 +171,14 @@ fn draw_hatch(frame: &mut canvas::Frame, hatch: &HatchModel, to_px: &impl Fn(f32
let [r, g, b, a] = hatch.color;
let color = Color { r, g, b, a };
// Reconstruct offset-rel WCS from stored small offsets + f64 anchor.
let ox = hatch.world_origin[0] as f32;
let oy = hatch.world_origin[1] as f32;
let path = canvas::Path::new(|builder| {
let first = to_px(hatch.boundary[0][0], hatch.boundary[0][1]);
let first = to_px(hatch.boundary[0][0] + ox, hatch.boundary[0][1] + oy);
builder.move_to(first);
for &[x, y] in &hatch.boundary[1..] {
builder.line_to(to_px(x, y));
builder.line_to(to_px(x + ox, y + oy));
}
builder.close();
});
+93 -17
View File
@@ -39,7 +39,7 @@ impl HatchVertex {
// ── Uniform structs ───────────────────────────────────────────────────────
/// Per-hatch parameters (binding 0). Must be 64 bytes.
/// Per-hatch parameters (binding 0). Must be 80 bytes (16-byte aligned).
///
/// `mode` encoding:
/// 0 → Pattern (families in FamilyBatchData)
@@ -48,17 +48,24 @@ impl HatchVertex {
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct HatchUniformData {
pub color: [f32; 4], // 0: primary RGBA / gradient start
pub color2: [f32; 4], // 16: gradient end color
pub mode: u32, // 32: 0=pattern, 1=solid, 2=gradient
pub vertex_count: u32, // 36: boundary vertex count
pub angle_offset: f32, // 40: pattern rotation (radians)
pub scale: f32, // 44: pattern scale multiplier
pub grad_cos: f32, // 48: gradient direction cos
pub grad_sin: f32, // 52: gradient direction sin
pub grad_min: f32, // 56: gradient proj_min
pub grad_range: f32, // 60: gradient proj_range
} // total 64 bytes
pub color: [f32; 4], // 0: primary RGBA / gradient start
pub color2: [f32; 4], // 16: gradient end color
pub mode: u32, // 32: 0=pattern, 1=solid, 2=gradient
pub vertex_count: u32, // 36: boundary vertex count
pub angle_offset: f32, // 40: pattern rotation (radians)
pub scale: f32, // 44: pattern scale multiplier
pub grad_cos: f32, // 48: gradient direction cos
pub grad_sin: f32, // 52: gradient direction sin
pub grad_min: f32, // 56: gradient proj_min
pub grad_range: f32, // 60: gradient proj_range
pub origin: [f32; 2], // 64: hatch-local origin (boundary AABB centre).
// // The pattern fragment shader subtracts this
// // from xz before the perp/perp_step quotient
// // so f32 doesn't catastrophically cancel when
// // world coords are large and pattern spacing
// // is small (e.g. UTM drawing + sub-mm hatch).
pub _pad: [f32; 2], // 72: 16-byte alignment
} // total 80 bytes
/// Boundary polygon (binding 1). Matches WGSL `Boundary`.
#[repr(C)]
@@ -153,7 +160,68 @@ impl HatchGpu {
};
let diag = ((max_x - min_x).powi(2) + (max_y - min_y).powi(2)).sqrt();
let pad = (diag * 0.8 + max_spacing * 2.0 * model.scale).max(1.0);
let (x0, x1, y0, y1) = (min_x - pad, max_x + pad, min_y - pad, max_y + pad);
// ── Pattern phase anchor.
//
// Boundary verts are already stored as small f32 offsets from
// `model.world_origin` (an f64 anchor near the AABB centre), so
// the in_polygon ray-cast and the pattern math both run at full
// f32 precision in the fragment shader. The remaining question
// is *which* offset-rel point we hand to the vertex shader as
// `h.origin` (so the quad lands at the correct WCS position
// after `view_proj * (v.pos + h.origin)`).
//
// For pattern alignment across adjacent hatches with the same
// family, snap `h.origin` to the family's perp/along grid in
// f64. Without the snap, each hatch's pattern phase is per-hatch
// and stripes don't continue across hatch boundaries (you see
// pattern misalignment along a road etc).
let mut origin = model.world_origin;
if let HatchPattern::Pattern(families) = &model.pattern {
if let Some(fam) = families.first() {
// Must mirror the shader's pattern math exactly. QCAD PAT
// convention (matching build_family_batch): `dy` is the
// perpendicular spacing and `dx` is the along-line phase
// shift, both in family-local space. Multiplied by
// `model.scale` in the fragment shader. Combined angle
// (family + pattern_angle) rotates the perp/along axes.
let angle = (fam.angle_deg.to_radians() + model.angle_offset) as f64;
let cos_a = angle.cos();
let sin_a = angle.sin();
let scale = model.scale as f64;
let perp_step = fam.dy as f64 * scale;
let along_step = fam.dx as f64 * scale;
let o_perp = -origin[0] * sin_a + origin[1] * cos_a;
let o_along = origin[0] * cos_a + origin[1] * sin_a;
let snapped_perp = if perp_step.abs() > 1e-12 {
(o_perp / perp_step).round() * perp_step
} else {
o_perp
};
let snapped_along = if along_step.abs() > 1e-12 {
(o_along / along_step).round() * along_step
} else {
o_along
};
origin = [
-snapped_perp * sin_a + snapped_along * cos_a,
snapped_perp * cos_a + snapped_along * sin_a,
];
}
}
// Boundary buffer + quad now live in "snap-shifted" hatch-local
// coords: subtract any drift between model.world_origin and the
// snapped origin so the in_polygon ray-cast in fragment shader
// (which reads the boundary verts straight) lines up with v.xz.
let drift = [
(model.world_origin[0] - origin[0]) as f32,
(model.world_origin[1] - origin[1]) as f32,
];
let (x0, x1, y0, y1) = (
min_x + drift[0] - pad,
max_x + drift[0] + pad,
min_y + drift[1] - pad,
max_y + drift[1] + pad,
);
let quad = [
HatchVertex {
@@ -187,13 +255,13 @@ impl HatchGpu {
usage: wgpu::BufferUsages::VERTEX,
});
// ── Gradient: projection range ───────────────────────────────────
// ── Gradient: projection range (in snapped-local space) ──────────
let (grad_min, grad_range) = if mode == 2 {
let projs: Vec<f32> = model
.boundary
.iter()
.filter(|v| v[0].is_finite() && v[1].is_finite())
.map(|&[x, y]| x * grad_cos + y * grad_sin)
.map(|&[x, y]| (x + drift[0]) * grad_cos + (y + drift[1]) * grad_sin)
.collect();
if projs.is_empty() {
(0.0, 1.0)
@@ -219,9 +287,11 @@ impl HatchGpu {
grad_sin,
grad_min,
grad_range,
origin: [origin[0] as f32, origin[1] as f32],
_pad: [0.0; 2],
};
// ── BoundaryData ─────────────────────────────────────────────────
// ── BoundaryData (in snapped-local space) ────────────────────────
let mut boundary_data = BoundaryData {
verts: [[0.0; 4]; MAX_HATCH_BOUNDARY_VERTS],
};
@@ -231,7 +301,13 @@ impl HatchGpu {
.take(MAX_HATCH_BOUNDARY_VERTS)
.enumerate()
{
boundary_data.verts[i] = [x, y, 0.0, 0.0];
// Preserve NaN separators — they signal path breaks to the
// in_polygon ray-cast, must not be shifted into finite numbers.
if x.is_finite() && y.is_finite() {
boundary_data.verts[i] = [x + drift[0], y + drift[1], 0.0, 0.0];
} else {
boundary_data.verts[i] = [f32::NAN, f32::NAN, 0.0, 0.0];
}
}
// ── FamilyBatchData ───────────────────────────────────────────────
+13 -3
View File
@@ -220,9 +220,13 @@ impl Pipeline {
});
// ── Hatch pipeline ─────────────────────────────────────────────────
let hatch_entry = |binding: u32| wgpu::BindGroupLayoutEntry {
// binding 0 (HatchUniforms) is read by the vertex shader too — it
// pulls `origin` to undo the CPU-side hatch-local pre-shift when
// computing clip position. bindings 1 (Boundary) and 2
// (FamilyBatch) stay fragment-only.
let hatch_entry = |binding: u32, vis: wgpu::ShaderStages| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
visibility: vis,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
@@ -230,9 +234,15 @@ impl Pipeline {
},
count: None,
};
let frag = wgpu::ShaderStages::FRAGMENT;
let vert_frag = wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT;
let hatch_bgl1 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("hatch.bgl1"),
entries: &[hatch_entry(0), hatch_entry(1), hatch_entry(2)],
entries: &[
hatch_entry(0, vert_frag),
hatch_entry(1, frag),
hatch_entry(2, frag),
],
});
let hatch_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+22 -2
View File
@@ -39,6 +39,12 @@ struct HatchUniforms {
grad_sin: f32, // 52: gradient direction sin
grad_min: f32, // 56: gradient proj_min
grad_range: f32, // 60: gradient proj_range
origin: vec2<f32>, // 64: hatch-local pattern origin (boundary
// // centre). Pattern math runs against
// // `xz - origin` so the f32 modulo doesn't
// // catastrophically cancel when world coords
// // are large and pattern spacing is small.
_pad: vec2<f32>, // 72: 16-byte alignment
}
@group(1) @binding(0) var<uniform> h: HatchUniforms;
@@ -81,7 +87,16 @@ struct VOut {
@vertex fn vs_main(v: VIn) -> VOut {
var o: VOut;
o.clip = u.view_proj * vec4<f32>(v.pos, 1.0);
// v.pos comes in pre-shifted to hatch-local space (CPU subtracted
// h.origin from the quad). Add origin back inside the view_proj
// multiply to land at the correct world-space clip position. The
// f32+f32 addition can lose sub-mm precision at very far hatches
// but the result feeds view_proj for screen NDC where sub-pixel
// jitter is invisible. The local-space `xz` we export to the
// fragment shader stays at full f32 precision so the in_polygon /
// pattern math doesn't catastrophically cancel.
let world = vec3<f32>(v.pos.x + h.origin.x, v.pos.y + h.origin.y, v.pos.z);
o.clip = u.view_proj * vec4<f32>(world, 1.0);
o.xz = vec2<f32>(v.pos.x, v.pos.y);
return o;
}
@@ -213,7 +228,12 @@ fn check_family(
}
}
// 4. Pattern: evaluate each line family.
// 4. Pattern: v.xz is already in hatch-local space (CPU pre-shifted
// quad + BoundaryData by h.origin). The PAT family origin
// (typically (0,0)) is treated as relative to h.origin, so
// pattern phase is per-hatch rather than world-aligned — that
// trade-off is the price of f32 precision at large drawing
// extents.
let cos_off = cos(h.angle_offset);
let sin_off = sin(h.angle_offset);
for (var i = 0u; i < f.n_families; i++) {