From 172c538159241a961834c0321983025e63a1af11 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 16 May 2026 08:41:23 +0300 Subject: [PATCH] fix(scene): hatch boundary + pattern precision for large-WCS drawings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/modules/home/draw/hatch.rs | 3 + src/scene/hatch_model.rs | 18 +++++- src/scene/hit_test.rs | 15 ++++- src/scene/mod.rs | 108 +++++++++++++++++++++---------- src/scene/paper_canvas.rs | 7 +- src/scene/pipeline/hatch_gpu.rs | 110 +++++++++++++++++++++++++++----- src/scene/pipeline/mod.rs | 16 ++++- src/shaders/hatch.wgsl | 24 ++++++- 8 files changed, 238 insertions(+), 63 deletions(-) diff --git a/src/modules/home/draw/hatch.rs b/src/modules/home/draw/hatch.rs index 0e861bafe..8dced9ed1 100644 --- a/src/modules/home/draw/hatch.rs +++ b/src/modules/home/draw/hatch.rs @@ -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); diff --git a/src/scene/hatch_model.rs b/src/scene/hatch_model.rs index 16ea149ca..aba122f40 100644 --- a/src/scene/hatch_model.rs +++ b/src/scene/hatch_model.rs @@ -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>, /// Fill pattern. pub pattern: HatchPattern, diff --git a/src/scene/hit_test.rs b/src/scene/hit_test.rs index e88909152..64f0212f2 100644 --- a/src/scene/hit_test.rs +++ b/src/scene/hit_test.rs @@ -277,10 +277,15 @@ pub fn click_hit_hatch( bounds: Rectangle, ) -> Option { 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 = 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 = 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 = 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)) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index f50d1785b..38f3ca657 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -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 { 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 = 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(); diff --git a/src/scene/paper_canvas.rs b/src/scene/paper_canvas.rs index 3ce56106b..8b98f4b7c 100644 --- a/src/scene/paper_canvas.rs +++ b/src/scene/paper_canvas.rs @@ -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(); }); diff --git a/src/scene/pipeline/hatch_gpu.rs b/src/scene/pipeline/hatch_gpu.rs index e9bc8cb56..a41a8c3c4 100644 --- a/src/scene/pipeline/hatch_gpu.rs +++ b/src/scene/pipeline/hatch_gpu.rs @@ -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 = 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 ─────────────────────────────────────────────── diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 746d88725..e199be2fb 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -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 { diff --git a/src/shaders/hatch.wgsl b/src/shaders/hatch.wgsl index 8c2fdd4f8..bec6b0765 100644 --- a/src/shaders/hatch.wgsl +++ b/src/shaders/hatch.wgsl @@ -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, // 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, // 72: 16-byte alignment } @group(1) @binding(0) var h: HatchUniforms; @@ -81,7 +87,16 @@ struct VOut { @vertex fn vs_main(v: VIn) -> VOut { var o: VOut; - o.clip = u.view_proj * vec4(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(v.pos.x + h.origin.x, v.pos.y + h.origin.y, v.pos.z); + o.clip = u.view_proj * vec4(world, 1.0); o.xz = vec2(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++) {