cad-editor/src/shaders/hatch.wgsl

245 lines
9.3 KiB
WebGPU Shading Language
Raw Normal View History

2026-03-22 14:56:27 +03:00
// Hatch shader — GPU-driven hatch fill over an arbitrary polygon boundary.
//
// Vertex shader : projects a bounding-box quad over the hatch region.
// Fragment shader: three stages —
// 1. Point-in-polygon (ray casting against boundary vertices in world XY).
// 2. Pattern test:
// mode 0 — N line families (PAT format), each with optional dash pattern.
// mode 1 — solid fill.
// mode 2 — linear gradient.
//
// GPU limits: MAX_FAMILIES = 16, MAX_DASHES = 128.
// ── Group 0: frame uniforms (shared) ──────────────────────────────────────
struct Uniforms {
view_proj: mat4x4<f32>,
camera_pos: vec4<f32>,
viewport_size: vec2<f32>,
world_per_pixel: f32,
_pad: f32,
2026-03-22 14:56:27 +03:00
}
@group(0) @binding(0) var<uniform> u: Uniforms;
// ── Group 1: per-hatch data ────────────────────────────────────────────────
//
// mode encoding:
// 0 → Pattern (evaluate FamilyBatch)
// 1 → Solid (return h.color immediately)
// 2 → Gradient (mix h.color → h.color2 along grad_cos/grad_sin)
struct HatchUniforms {
color: vec4<f32>, // 0: primary RGBA
color2: vec4<f32>, // 16: gradient end color
mode: u32, // 32: 0=pattern, 1=solid, 2=gradient
vcount: u32, // 36: boundary vertex count
angle_offset: f32, // 40: pattern rotation (radians, added to each family)
scale: f32, // 44: pattern scale multiplier
grad_cos: f32, // 48: gradient direction cos
grad_sin: f32, // 52: gradient direction sin
grad_min: f32, // 56: gradient proj_min
grad_range: f32, // 60: gradient proj_range
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 <noreply@anthropic.com>
2026-05-16 08:41:23 +03:00
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
2026-03-22 14:56:27 +03:00
}
@group(1) @binding(0) var<uniform> h: HatchUniforms;
struct Boundary {
verts: array<vec4<f32>, 1024>,
2026-03-22 14:56:27 +03:00
}
@group(1) @binding(1) var<uniform> b: Boundary;
// One line family (48 bytes, matches LineFamilyGpu in hatch_gpu.rs).
struct LineFamily {
cos_a: f32, // base cos(angle)
sin_a: f32, // base sin(angle)
x0: f32, // family origin x
y0: f32, // family origin y
dx: f32, // step vector x
dy: f32, // step vector y
perp_step: f32, // -dx*sin_a + dy*cos_a (perpendicular spacing)
along_step: f32, // dx*cos_a + dy*sin_a (phase shift per step)
line_width: f32, // half-width of each line (|perp_step| × 0.08)
period: f32, // sum(|dashes|) — 0 means solid
n_dashes: u32, // number of dash entries
dash_off: u32, // start index in dash_values
}
struct FamilyBatch {
families: array<LineFamily, 16>,
dash_values: array<vec4<f32>, 32>, // 128 f32s packed as 32×vec4 (uniform stride=16)
n_families: u32,
_pad0: u32, _pad1: u32, _pad2: u32,
}
@group(1) @binding(2) var<uniform> f: FamilyBatch;
// ── Vertex shader ──────────────────────────────────────────────────────────
struct VIn { @location(0) pos: vec3<f32> }
struct VOut {
@builtin(position) clip: vec4<f32>,
@location(0) xz: vec2<f32>,
}
@vertex fn vs_main(v: VIn) -> VOut {
var o: VOut;
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 <noreply@anthropic.com>
2026-05-16 08:41:23 +03:00
// 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);
2026-03-22 14:56:27 +03:00
o.xz = vec2<f32>(v.pos.x, v.pos.y);
return o;
}
// ── Point-in-polygon (ray casting) ────────────────────────────────────────
fn valid_vertex(p: vec2<f32>) -> bool {
return p.x == p.x && p.y == p.y;
}
fn edge_crosses(p: vec2<f32>, a: vec2<f32>, c: vec2<f32>) -> bool {
if (a.y > p.y) != (c.y > p.y) {
let x_int = (c.x - a.x) * (p.y - a.y) / (c.y - a.y) + a.x;
return p.x < x_int;
}
return false;
}
2026-03-22 14:56:27 +03:00
fn in_polygon(p: vec2<f32>) -> bool {
var inside = false;
let n = h.vcount;
var prev = vec2<f32>(0.0, 0.0);
var have_prev = false;
2026-03-22 14:56:27 +03:00
for (var i = 0u; i < n; i++) {
let vi = b.verts[i].xy;
if !valid_vertex(vi) {
have_prev = false;
continue;
}
if have_prev && edge_crosses(p, prev, vi) {
inside = !inside;
2026-03-22 14:56:27 +03:00
}
prev = vi;
have_prev = true;
2026-03-22 14:56:27 +03:00
}
return inside;
}
// ── Per-family hatch test ─────────────────────────────────────────────────
//
// Returns true if world point `xz` falls on a hatch line of `fam`.
// `cos_off` / `sin_off` are cos/sin of `h.angle_offset` (precomputed once).
// `scale` is `h.scale`.
fn check_family(
xz: vec2<f32>,
fam: LineFamily,
cos_off: f32,
sin_off: f32,
scale: f32,
) -> bool {
// Rotate family direction by angle_offset.
let cos_a = fam.cos_a * cos_off - fam.sin_a * sin_off;
let sin_a = fam.sin_a * cos_off + fam.cos_a * sin_off;
// Rotate and scale the family origin.
let ox = (fam.x0 * cos_off - fam.y0 * sin_off) * scale;
let oz = (fam.x0 * sin_off + fam.y0 * cos_off) * scale;
let px = xz.x - ox;
let pz = xz.y - oz;
// Perpendicular distance from the nearest parallel line.
let perp_step = fam.perp_step * scale;
let line_w = abs(fam.line_width * scale);
let perp = -px * sin_a + pz * cos_a;
let k = round(perp / perp_step);
let d = abs(perp - k * perp_step);
let half_px = length(vec2<f32>(dpdx(perp), dpdy(perp))) * 0.5;
if d > half_px { return false; }
// Solid line family — no dash check needed.
if fam.n_dashes == 0u { return true; }
// Dash pattern test: position along line k.
let along_step = fam.along_step * scale;
let period = fam.period * scale;
let along = px * cos_a + pz * sin_a;
let t = along - k * along_step;
let t_mod = ((t % period) + period) % period;
var pos = 0.0;
for (var j = 0u; j < fam.n_dashes; j++) {
let idx = fam.dash_off + j;
let sv = f.dash_values[idx / 4u][idx % 4u] * scale; // scale dash lengths
if sv > 0.0 {
if t_mod >= pos && t_mod < pos + sv { return true; }
pos = pos + sv;
} else {
pos = pos - sv;
}
}
return false;
}
// ── Fragment shader ────────────────────────────────────────────────────────
@fragment fn fs_main(v: VOut) -> @location(0) vec4<f32> {
// 1. Boundary test.
if !in_polygon(v.xz) { discard; }
// 2. Mode dispatch.
if h.mode == 1u {
// Solid fill.
return h.color;
} else if h.mode == 2u {
// Linear gradient.
let proj = v.xz.x * h.grad_cos + v.xz.y * h.grad_sin;
let t = clamp((proj - h.grad_min) / h.grad_range, 0.0, 1.0);
return mix(h.color, h.color2, t);
}
// 3. Pattern: LOD substitution — when the densest family's spacing
// projects to less than 2 px, individual lines blur into a solid
// fill and the per-family loop just wastes ALU. Return solid color
// instead. (Phase 3.3 hatch LOD.)
if u.world_per_pixel > 0.0 && f.n_families > 0u {
var min_spacing_world: f32 = 1.0e30;
for (var i = 0u; i < f.n_families; i++) {
let s = abs(f.families[i].perp_step) * h.scale;
if s > 0.0 && s < min_spacing_world {
min_spacing_world = s;
}
}
if min_spacing_world / u.world_per_pixel < 2.0 {
return h.color;
}
}
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 <noreply@anthropic.com>
2026-05-16 08:41:23 +03:00
// 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.
2026-03-22 14:56:27 +03:00
let cos_off = cos(h.angle_offset);
let sin_off = sin(h.angle_offset);
for (var i = 0u; i < f.n_families; i++) {
if check_family(v.xz, f.families[i], cos_off, sin_off, h.scale) {
return h.color;
}
}
discard;
}