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 frome7675beand the scissor plumbing fromfbbac42still work — those touched different fields of the same struct. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
dc80968023
commit
172c538159
8 changed files with 238 additions and 63 deletions
|
|
@ -119,6 +119,7 @@ impl HatchCommand {
|
||||||
color: [0.75, 0.75, 0.75, 0.85],
|
color: [0.75, 0.75, 0.75, 0.85],
|
||||||
angle_offset: 0.0,
|
angle_offset: 0.0,
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
|
world_origin: [0.0; 2],
|
||||||
vp_scissor: None,
|
vp_scissor: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -255,6 +256,7 @@ impl GradientCommand {
|
||||||
color: [0.30, 0.60, 0.95, 0.80],
|
color: [0.30, 0.60, 0.95, 0.80],
|
||||||
angle_offset: 0.0,
|
angle_offset: 0.0,
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
|
world_origin: [0.0; 2],
|
||||||
vp_scissor: None,
|
vp_scissor: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -404,6 +406,7 @@ impl CadCommand for BoundaryCommand {
|
||||||
color: [0.45, 0.45, 0.45, 0.60],
|
color: [0.45, 0.45, 0.45, 0.60],
|
||||||
angle_offset: 0.0,
|
angle_offset: 0.0,
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
|
world_origin: [0.0; 2],
|
||||||
vp_scissor: None,
|
vp_scissor: None,
|
||||||
};
|
};
|
||||||
return CmdResult::CommitHatch(model);
|
return CmdResult::CommitHatch(model);
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,22 @@ pub enum HatchPattern {
|
||||||
/// A hatched region defined by a closed polygon boundary.
|
/// A hatched region defined by a closed polygon boundary.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct HatchModel {
|
pub struct HatchModel {
|
||||||
/// World XY coordinates of the boundary polygon vertices.
|
/// World XY anchor (in the same offset-relative coordinate space as
|
||||||
/// Arc so HatchModel::clone() is a pointer bump rather than a full Vec copy.
|
/// 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]>>,
|
pub boundary: Arc<Vec<[f32; 2]>>,
|
||||||
/// Fill pattern.
|
/// Fill pattern.
|
||||||
pub pattern: HatchPattern,
|
pub pattern: HatchPattern,
|
||||||
|
|
|
||||||
|
|
@ -277,10 +277,15 @@ pub fn click_hit_hatch(
|
||||||
bounds: Rectangle,
|
bounds: Rectangle,
|
||||||
) -> Option<Handle> {
|
) -> Option<Handle> {
|
||||||
for (&handle, hatch) in hatches {
|
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
|
let screen: Vec<Point> = hatch
|
||||||
.boundary
|
.boundary
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
if screen.len() >= 3 && point_in_polygon(cursor, &screen) {
|
if screen.len() >= 3 && point_in_polygon(cursor, &screen) {
|
||||||
return Some(handle);
|
return Some(handle);
|
||||||
|
|
@ -315,10 +320,12 @@ pub fn box_hit_hatch(
|
||||||
if hatch.boundary.is_empty() {
|
if hatch.boundary.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
let ox = hatch.world_origin[0] as f32;
|
||||||
|
let oy = hatch.world_origin[1] as f32;
|
||||||
let screen: Vec<Point> = hatch
|
let screen: Vec<Point> = hatch
|
||||||
.boundary
|
.boundary
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
let hit = if crossing {
|
let hit = if crossing {
|
||||||
screen.iter().any(|&sp| inside(sp))
|
screen.iter().any(|&sp| inside(sp))
|
||||||
|
|
@ -352,10 +359,12 @@ pub fn poly_hit_hatch(
|
||||||
if hatch.boundary.is_empty() {
|
if hatch.boundary.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
let ox = hatch.world_origin[0] as f32;
|
||||||
|
let oy = hatch.world_origin[1] as f32;
|
||||||
let screen: Vec<Point> = hatch
|
let screen: Vec<Point> = hatch
|
||||||
.boundary
|
.boundary
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
let hit = if crossing {
|
let hit = if crossing {
|
||||||
screen.iter().any(|&sp| point_in_polygon(sp, poly))
|
screen.iter().any(|&sp| point_in_polygon(sp, poly))
|
||||||
|
|
|
||||||
108
src/scene/mod.rs
108
src/scene/mod.rs
|
|
@ -2307,6 +2307,7 @@ impl Scene {
|
||||||
color,
|
color,
|
||||||
angle_offset: 0.0,
|
angle_offset: 0.0,
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
|
world_origin: [0.0; 2],
|
||||||
vp_scissor: None,
|
vp_scissor: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -2360,6 +2361,7 @@ impl Scene {
|
||||||
color: fill_color,
|
color: fill_color,
|
||||||
angle_offset: 0.0,
|
angle_offset: 0.0,
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
|
world_origin: [0.0; 2],
|
||||||
vp_scissor: None,
|
vp_scissor: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -2418,21 +2420,26 @@ impl Scene {
|
||||||
) -> Option<HatchModel> {
|
) -> Option<HatchModel> {
|
||||||
let [ox, oy, _oz] = world_offset;
|
let [ox, oy, _oz] = world_offset;
|
||||||
let normal = (dxf.normal.x, dxf.normal.y, dxf.normal.z);
|
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, _) =
|
let (wx, wy, _) =
|
||||||
crate::scene::transform::ocs_point_to_wcs((x, y, dxf.elevation), normal);
|
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() {
|
if dxf.paths.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut boundary: Vec<[f32; 2]> = Vec::new();
|
let mut boundary: Vec<[f64; 2]> = Vec::new();
|
||||||
|
|
||||||
for path in &dxf.paths {
|
for path in &dxf.paths {
|
||||||
let before_path = boundary.len();
|
let before_path = boundary.len();
|
||||||
if !boundary.is_empty() {
|
if !boundary.is_empty() {
|
||||||
boundary.push([f32::NAN, f32::NAN]);
|
boundary.push([f64::NAN, f64::NAN]);
|
||||||
}
|
}
|
||||||
let path_start = boundary.len();
|
let path_start = boundary.len();
|
||||||
|
|
||||||
|
|
@ -2456,54 +2463,50 @@ impl Scene {
|
||||||
if bulge.abs() < 1e-9 {
|
if bulge.abs() < 1e-9 {
|
||||||
boundary.push(to_xy(v0.x, v0.y));
|
boundary.push(to_xy(v0.x, v0.y));
|
||||||
} else {
|
} else {
|
||||||
let p0 = [v0.x as f32, v0.y as f32];
|
// Run the whole bulge → arc tessellation in
|
||||||
let p1 = [v1.x as f32, v1.y as f32];
|
// f64. The old f32 path lost ~1 cm of
|
||||||
let theta = 4.0 * (bulge as f32).atan();
|
// precision at UTM-scale WCS coordinates
|
||||||
let dx = p1[0] - p0[0];
|
// (1e5 magnitude × 1 ULP), which showed up
|
||||||
let dy = p1[1] - p0[1];
|
// 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();
|
let d = (dx * dx + dy * dy).sqrt();
|
||||||
if d < 1e-9 {
|
if d < 1e-12 {
|
||||||
boundary.push(to_xy(v0.x, v0.y));
|
boundary.push(to_xy(v0.x, v0.y));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let r = (d * 0.5) / (theta * 0.5).sin().abs();
|
let r = (d * 0.5) / (theta * 0.5).sin().abs();
|
||||||
let mx = (p0[0] + p1[0]) * 0.5;
|
let mx = (v0.x + v1.x) * 0.5;
|
||||||
let my = (p0[1] + p1[1]) * 0.5;
|
let my = (v0.y + v1.y) * 0.5;
|
||||||
let px = -dy / d;
|
let px = -dy / d;
|
||||||
let py = dx / 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 center_offset = r * (theta * 0.5).cos();
|
||||||
let cx = mx + sign * px * center_offset;
|
let cx = mx + sign * px * center_offset;
|
||||||
let cy = my + sign * py * center_offset;
|
let cy = my + sign * py * center_offset;
|
||||||
let a0 = (p0[1] - cy).atan2(p0[0] - cx);
|
let a0 = (v0.y - cy).atan2(v0.x - cx);
|
||||||
let a1 = (p1[1] - cy).atan2(p1[0] - cx);
|
let a1 = (v1.y - cy).atan2(v1.x - cx);
|
||||||
let mut sweep = a1 - a0;
|
let mut sweep = a1 - a0;
|
||||||
|
const TAU: f64 = std::f64::consts::TAU;
|
||||||
if bulge > 0.0 {
|
if bulge > 0.0 {
|
||||||
if sweep <= 0.0 {
|
if sweep <= 0.0 {
|
||||||
sweep += std::f32::consts::TAU;
|
sweep += TAU;
|
||||||
}
|
}
|
||||||
} else if sweep >= 0.0 {
|
} else if sweep >= 0.0 {
|
||||||
sweep -= std::f32::consts::TAU;
|
sweep -= TAU;
|
||||||
}
|
}
|
||||||
if sweep.abs() < 1e-7 {
|
if sweep.abs() < 1e-9 {
|
||||||
sweep = if bulge > 0.0 {
|
sweep = if bulge > 0.0 { TAU } else { -TAU };
|
||||||
std::f32::consts::TAU
|
|
||||||
} else {
|
|
||||||
-std::f32::consts::TAU
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
let r64 = r as f64;
|
|
||||||
let segs = tessellate::arc_segments(
|
let segs = tessellate::arc_segments(
|
||||||
r64,
|
r,
|
||||||
sweep.abs() as f64,
|
sweep.abs(),
|
||||||
tessellate::fill_chord_tol(r64),
|
tessellate::fill_chord_tol(r),
|
||||||
);
|
);
|
||||||
for j in 0..segs {
|
for j in 0..segs {
|
||||||
let a = a0 + sweep * (j as f32 / segs as f32);
|
let a = a0 + sweep * (j as f64 / segs as f64);
|
||||||
boundary.push(to_xy(
|
boundary.push(to_xy(cx + r * a.cos(), cy + r * a.sin()));
|
||||||
(cx + r * a.cos()) as f64,
|
|
||||||
(cy + r * a.sin()) as f64,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2656,13 +2659,47 @@ impl Scene {
|
||||||
} else {
|
} else {
|
||||||
dxf.pattern.name.clone()
|
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 {
|
Some(HatchModel {
|
||||||
boundary: std::sync::Arc::new(boundary),
|
boundary: std::sync::Arc::new(boundary_f32),
|
||||||
pattern,
|
pattern,
|
||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
angle_offset: dxf.pattern_angle as f32,
|
angle_offset: dxf.pattern_angle as f32,
|
||||||
scale: dxf.pattern_scale as f32,
|
scale: dxf.pattern_scale as f32,
|
||||||
|
world_origin,
|
||||||
vp_scissor: None,
|
vp_scissor: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -2812,6 +2849,7 @@ impl Scene {
|
||||||
color,
|
color,
|
||||||
angle_offset: 0.0,
|
angle_offset: 0.0,
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
|
world_origin: [0.0; 2],
|
||||||
vp_scissor: None,
|
vp_scissor: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2822,10 +2860,12 @@ impl Scene {
|
||||||
model.pattern,
|
model.pattern,
|
||||||
crate::scene::hatch_model::HatchPattern::Solid
|
crate::scene::hatch_model::HatchPattern::Solid
|
||||||
);
|
);
|
||||||
|
let [wx, wy] = model.world_origin;
|
||||||
let verts: Vec<Vector2> = model
|
let verts: Vec<Vector2> = model
|
||||||
.boundary
|
.boundary
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
let edge = PolylineEdge::new(verts, true);
|
let edge = PolylineEdge::new(verts, true);
|
||||||
let mut path = BoundaryPath::external();
|
let mut path = BoundaryPath::external();
|
||||||
|
|
|
||||||
|
|
@ -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 [r, g, b, a] = hatch.color;
|
||||||
let color = Color { r, g, b, a };
|
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 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);
|
builder.move_to(first);
|
||||||
for &[x, y] in &hatch.boundary[1..] {
|
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();
|
builder.close();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ impl HatchVertex {
|
||||||
|
|
||||||
// ── Uniform structs ───────────────────────────────────────────────────────
|
// ── 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:
|
/// `mode` encoding:
|
||||||
/// 0 → Pattern (families in FamilyBatchData)
|
/// 0 → Pattern (families in FamilyBatchData)
|
||||||
|
|
@ -48,17 +48,24 @@ impl HatchVertex {
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
pub struct HatchUniformData {
|
pub struct HatchUniformData {
|
||||||
pub color: [f32; 4], // 0: primary RGBA / gradient start
|
pub color: [f32; 4], // 0: primary RGBA / gradient start
|
||||||
pub color2: [f32; 4], // 16: gradient end color
|
pub color2: [f32; 4], // 16: gradient end color
|
||||||
pub mode: u32, // 32: 0=pattern, 1=solid, 2=gradient
|
pub mode: u32, // 32: 0=pattern, 1=solid, 2=gradient
|
||||||
pub vertex_count: u32, // 36: boundary vertex count
|
pub vertex_count: u32, // 36: boundary vertex count
|
||||||
pub angle_offset: f32, // 40: pattern rotation (radians)
|
pub angle_offset: f32, // 40: pattern rotation (radians)
|
||||||
pub scale: f32, // 44: pattern scale multiplier
|
pub scale: f32, // 44: pattern scale multiplier
|
||||||
pub grad_cos: f32, // 48: gradient direction cos
|
pub grad_cos: f32, // 48: gradient direction cos
|
||||||
pub grad_sin: f32, // 52: gradient direction sin
|
pub grad_sin: f32, // 52: gradient direction sin
|
||||||
pub grad_min: f32, // 56: gradient proj_min
|
pub grad_min: f32, // 56: gradient proj_min
|
||||||
pub grad_range: f32, // 60: gradient proj_range
|
pub grad_range: f32, // 60: gradient proj_range
|
||||||
} // total 64 bytes
|
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`.
|
/// Boundary polygon (binding 1). Matches WGSL `Boundary`.
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
|
|
@ -153,7 +160,68 @@ impl HatchGpu {
|
||||||
};
|
};
|
||||||
let diag = ((max_x - min_x).powi(2) + (max_y - min_y).powi(2)).sqrt();
|
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 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 = [
|
let quad = [
|
||||||
HatchVertex {
|
HatchVertex {
|
||||||
|
|
@ -187,13 +255,13 @@ impl HatchGpu {
|
||||||
usage: wgpu::BufferUsages::VERTEX,
|
usage: wgpu::BufferUsages::VERTEX,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Gradient: projection range ───────────────────────────────────
|
// ── Gradient: projection range (in snapped-local space) ──────────
|
||||||
let (grad_min, grad_range) = if mode == 2 {
|
let (grad_min, grad_range) = if mode == 2 {
|
||||||
let projs: Vec<f32> = model
|
let projs: Vec<f32> = model
|
||||||
.boundary
|
.boundary
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|v| v[0].is_finite() && v[1].is_finite())
|
.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();
|
.collect();
|
||||||
if projs.is_empty() {
|
if projs.is_empty() {
|
||||||
(0.0, 1.0)
|
(0.0, 1.0)
|
||||||
|
|
@ -219,9 +287,11 @@ impl HatchGpu {
|
||||||
grad_sin,
|
grad_sin,
|
||||||
grad_min,
|
grad_min,
|
||||||
grad_range,
|
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 {
|
let mut boundary_data = BoundaryData {
|
||||||
verts: [[0.0; 4]; MAX_HATCH_BOUNDARY_VERTS],
|
verts: [[0.0; 4]; MAX_HATCH_BOUNDARY_VERTS],
|
||||||
};
|
};
|
||||||
|
|
@ -231,7 +301,13 @@ impl HatchGpu {
|
||||||
.take(MAX_HATCH_BOUNDARY_VERTS)
|
.take(MAX_HATCH_BOUNDARY_VERTS)
|
||||||
.enumerate()
|
.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 ───────────────────────────────────────────────
|
// ── FamilyBatchData ───────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -220,9 +220,13 @@ impl Pipeline {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Hatch 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,
|
binding,
|
||||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
visibility: vis,
|
||||||
ty: wgpu::BindingType::Buffer {
|
ty: wgpu::BindingType::Buffer {
|
||||||
ty: wgpu::BufferBindingType::Uniform,
|
ty: wgpu::BufferBindingType::Uniform,
|
||||||
has_dynamic_offset: false,
|
has_dynamic_offset: false,
|
||||||
|
|
@ -230,9 +234,15 @@ impl Pipeline {
|
||||||
},
|
},
|
||||||
count: None,
|
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 {
|
let hatch_bgl1 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
label: Some("hatch.bgl1"),
|
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 {
|
let hatch_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,12 @@ struct HatchUniforms {
|
||||||
grad_sin: f32, // 52: gradient direction sin
|
grad_sin: f32, // 52: gradient direction sin
|
||||||
grad_min: f32, // 56: gradient proj_min
|
grad_min: f32, // 56: gradient proj_min
|
||||||
grad_range: f32, // 60: gradient proj_range
|
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;
|
@group(1) @binding(0) var<uniform> h: HatchUniforms;
|
||||||
|
|
||||||
|
|
@ -81,7 +87,16 @@ struct VOut {
|
||||||
|
|
||||||
@vertex fn vs_main(v: VIn) -> VOut {
|
@vertex fn vs_main(v: VIn) -> VOut {
|
||||||
var o: 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);
|
o.xz = vec2<f32>(v.pos.x, v.pos.y);
|
||||||
return o;
|
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 cos_off = cos(h.angle_offset);
|
||||||
let sin_off = sin(h.angle_offset);
|
let sin_off = sin(h.angle_offset);
|
||||||
for (var i = 0u; i < f.n_families; i++) {
|
for (var i = 0u; i < f.n_families; i++) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue