diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 477485728..a3b4bac22 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1243,8 +1243,8 @@ pub(super) fn on_open_file(&mut self) -> Task { wipeouts.as_slice(), eff_w, eff_h, - draw_ox as f32, - draw_oy as f32, + draw_ox, + draw_oy, rotation_deg, 1.0, None, @@ -1313,8 +1313,8 @@ pub(super) fn on_open_file(&mut self) -> Task { // scale (rotation_deg == 0 adds no further CTM translation). // window_to_sheet's (ox, oy) is the sheet-mm target for the window's // min corner, so: scale * (x0 + offset_x) = ox => offset_x = ox/scale - x0. - let offset_x = ((ox / scale) - x0) as f32; - let offset_y = ((oy / scale) - y0) as f32; + let offset_x = (ox / scale) - x0; + let offset_y = (oy / scale) - y0; // Clip rect in pre-scale space (build_pdf's CTM applies `scale` to it, // same as the wires) so the final sheet-mm rect lands at (ox, oy). let clip = Some(((ox / scale) as f32, (oy / scale) as f32, win_w as f32, win_h as f32)); @@ -1359,8 +1359,10 @@ pub(super) fn on_open_file(&mut self) -> Task { Vec, f64, f64, - f32, - f32, + // Draw offsets stay f64: they cancel an absolute world coordinate that + // an f32 cannot hold at UTM scale. + f64, + f64, i32, ) { let i = self.active_tab; @@ -1411,8 +1413,8 @@ pub(super) fn on_open_file(&mut self) -> Task { wipeouts, eff_w, eff_h, - draw_ox as f32, - draw_oy as f32, + draw_ox, + draw_oy, rotation_deg, ) } @@ -2076,8 +2078,10 @@ pub(super) fn on_open_file(&mut self) -> Task { Vec, f64, f64, - f32, - f32, + // Offsets stay f64 (they cancel a UTM-scale world coordinate); the plot + // scale is a small ratio and stays f32. + f64, + f64, f32, Option<(f32, f32, f32, f32)>, )> { @@ -2113,8 +2117,8 @@ pub(super) fn on_open_file(&mut self) -> Task { .collect(); let hatches: Vec<_> = scene.paper_canvas_hatches().as_ref().clone(); let wipeouts: Vec<_> = scene.paper_canvas_wipeouts().as_ref().clone(); - let offset_x = ((ox / scale) - x0) as f32; - let offset_y = ((oy / scale) - y0) as f32; + let offset_x = (ox / scale) - x0; + let offset_y = (oy / scale) - y0; let clip = Some(( (ox / scale) as f32, (oy / scale) as f32, diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index a71acae5f..0f9797a49 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -32,8 +32,8 @@ pub fn export_pdf( _wipeouts: &[HatchModel], _paper_w: f64, _paper_h: f64, - _offset_x: f32, - _offset_y: f32, + _offset_x: f64, + _offset_y: f64, _rotation_deg: i32, _scale: f32, _clip: Option<(f32, f32, f32, f32)>, @@ -72,8 +72,8 @@ pub fn export_pdf( wipeouts: &[HatchModel], paper_w: f64, paper_h: f64, - offset_x: f32, - offset_y: f32, + offset_x: f64, + offset_y: f64, rotation_deg: i32, scale: f32, clip: Option<(f32, f32, f32, f32)>, @@ -119,8 +119,11 @@ fn build_pdf( wipeouts: &[HatchModel], paper_w: f32, paper_h: f32, - ox: f32, - oy: f32, + // Absolute-world offsets, kept in f64: at UTM the drawing sits at ~5e5/4.5e6 + // where an f32 has ~0.03 m / ~0.5 m of resolution, so an f32 offset is itself + // already quantised before it can cancel the coordinate it is meant to cancel. + ox: f64, + oy: f64, rotation_deg: i32, scale: f32, clip: Option<(f32, f32, f32, f32)>, @@ -306,15 +309,24 @@ fn build_pdf( last_dash = Some(dash_arr); } - // Emit segments (NaN = pen-up). + // Emit segments (NaN = pen-up). Points are the "high" half of a + // double-single pair; fold in the `points_low` residual and cancel the + // offset in f64 before narrowing. Dropping the residual (or narrowing + // first) snaps a UTM drawing onto the f32 grid — ~3 cm across, ~50 cm + // along northing — which is exactly the distortion the plot showed while + // low-coordinate drawings came out clean. The result is a sheet-mm value + // in single digits, so f32 is lossless from here. let mut segment: Vec = Vec::new(); - for &[x, y, _z] in &wire.points { + for (pi, &[x, y, _z]) in wire.points.iter().enumerate() { if x.is_nan() || y.is_nan() { flush_line(&mut ops, &segment); segment.clear(); } else { + let lo = wire.points_low.get(pi).copied().unwrap_or([0.0; 3]); + let wx = (x as f64 + lo[0] as f64 + ox) as f32; + let wy = (y as f64 + lo[1] as f64 + oy) as f32; segment.push(LinePoint { - p: Point::new(Mm(x + ox), Mm(y + oy)), + p: Point::new(Mm(wx), Mm(wy)), bezier: false, }); } @@ -384,7 +396,7 @@ fn flush_line(ops: &mut Vec, pts: &[LinePoint]) { /// Mirrors `scene::paper_canvas::draw_hatch`: solid → fill, pattern → outline, /// gradient → solid fill of the averaged colour. #[cfg(not(target_arch = "wasm32"))] -fn emit_hatch(ops: &mut Vec, hatch: &HatchModel, ox: f32, oy: f32) { +fn emit_hatch(ops: &mut Vec, hatch: &HatchModel, ox: f64, oy: f64) { if hatch.boundary.is_empty() { return; } @@ -411,8 +423,11 @@ fn emit_hatch(ops: &mut Vec, hatch: &HatchModel, ox: f32, oy: f32) { b = 0.50; } } - let world_ox = hatch.world_origin[0] as f32; - let world_oy = hatch.world_origin[1] as f32; + // `boundary` holds f32 offsets from the f64 `world_origin`, so resolve the + // pair in f64 and only narrow once the offset has cancelled — casting + // `world_origin` to f32 first re-introduces the ~0.5 m UTM quantisation the + // boundary-relative encoding exists to avoid. + let (world_ox, world_oy) = (hatch.world_origin[0], hatch.world_origin[1]); // Split the boundary into rings on every NaN-NaN separator. let mut rings: Vec = Vec::new(); @@ -426,8 +441,10 @@ fn emit_hatch(ops: &mut Vec, hatch: &HatchModel, ox: f32, oy: f32) { } continue; } + let px = (bx as f64 + world_ox + ox) as f32; + let py = (by as f64 + world_oy + oy) as f32; current.push(LinePoint { - p: Point::new(Mm(bx + world_ox + ox), Mm(by + world_oy + oy)), + p: Point::new(Mm(px), Mm(py)), bezier: false, }); } @@ -475,15 +492,19 @@ fn emit_hatch(ops: &mut Vec, hatch: &HatchModel, ox: f32, oy: f32) { }), }); for [a, b_pt] in segments { + // `pattern_segments` returns absolute world f64; cancel the offset + // before narrowing, as everywhere else in this file. + let (ax, ay) = ((a[0] + ox) as f32, (a[1] + oy) as f32); + let (bx, by) = ((b_pt[0] + ox) as f32, (b_pt[1] + oy) as f32); ops.push(Op::DrawLine { line: Line { points: vec![ LinePoint { - p: Point::new(Mm(a[0] + ox), Mm(a[1] + oy)), + p: Point::new(Mm(ax), Mm(ay)), bezier: false, }, LinePoint { - p: Point::new(Mm(b_pt[0] + ox), Mm(b_pt[1] + oy)), + p: Point::new(Mm(bx), Mm(by)), bezier: false, }, ], @@ -517,9 +538,16 @@ fn emit_hatch(ops: &mut Vec, hatch: &HatchModel, ox: f32, oy: f32) { // ── Text (SDF glyph quads → vector strokes / fills) ──────────────────────── /// Absolute world XY of a glyph vertex (double-single high + low parts folded). +/// +/// The fold must happen in f64: the pair exists because the absolute coordinate +/// does not fit an f32, so `pos + pos_low` evaluated in f32 rounds straight back +/// to `pos` and throws away the residual it was carrying. #[cfg(not(target_arch = "wasm32"))] -fn glyph_world_xy(v: &crate::scene::pipeline::text_gpu::TextVertex) -> [f32; 2] { - [v.pos[0] + v.pos_low[0], v.pos[1] + v.pos_low[1]] +fn glyph_world_xy(v: &crate::scene::pipeline::text_gpu::TextVertex) -> [f64; 2] { + [ + v.pos[0] as f64 + v.pos_low[0] as f64, + v.pos[1] as f64 + v.pos_low[1] as f64, + ] } /// Adapt a text colour to the white sheet, mirroring the wire/hatch passes: @@ -550,8 +578,8 @@ fn adapt_text_color([r, g, b]: [f32; 3]) -> [f32; 3] { fn emit_text( ops: &mut Vec, wires: &[WireModel], - ox: f32, - oy: f32, + ox: f64, + oy: f64, scale: f32, plot_style: Option<&PlotStyleTable>, ) { @@ -621,18 +649,22 @@ fn emit_text( // `tl` carries uv = (uv_min.x, uv_min.y) — the atlas tile key. let key = sdf_atlas::uv_key([quad[5].uv[0], quad[5].uv[1]]); - let point = |wx: f32, wy: f32| Point::new(Mm(wx + ox), Mm(wy + oy)); + // Cancel the offset in f64, then narrow: the sheet-mm result is a + // small number even when the world coordinate is UTM-scale. + let point = |wx: f64, wy: f64| Point::new(Mm((wx + ox) as f32), Mm((wy + oy) as f32)); if let Some(ge) = table.get(&key) { // Affine basis of the quad: plane_min → bl, +x → br, +y → tl. + // The glyph-space maths is small and stays f32; only the lift into + // world coordinates needs f64. let (pmin, pmax) = (ge.plane_min, ge.plane_max); let (sx, sy) = (pmax[0] - pmin[0], pmax[1] - pmin[1]); if sx.abs() < 1e-9 || sy.abs() < 1e-9 { continue; } let map = |p: [f32; 2]| -> Point { - let u = (p[0] - pmin[0]) / sx; - let v = (p[1] - pmin[1]) / sy; + let u = ((p[0] - pmin[0]) / sx) as f64; + let v = ((p[1] - pmin[1]) / sy) as f64; let wx = bl[0] + u * (br[0] - bl[0]) + v * (tl[0] - bl[0]); let wy = bl[1] + u * (br[1] - bl[1]) + v * (tl[1] - bl[1]); point(wx, wy) diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index cb9956805..1baa17908 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -41,8 +41,8 @@ pub async fn print_wires( _wipeouts: Vec, _paper_w: f64, _paper_h: f64, - _offset_x: f32, - _offset_y: f32, + _offset_x: f64, + _offset_y: f64, _rotation_deg: i32, _plot_style: Option, ) -> Result { @@ -62,8 +62,8 @@ pub async fn print_wires_with( _wipeouts: Vec, _paper_w: f64, _paper_h: f64, - _offset_x: f32, - _offset_y: f32, + _offset_x: f64, + _offset_y: f64, _rotation_deg: i32, _plot_style: Option, _opts: PrintOptions, @@ -92,8 +92,8 @@ pub async fn print_wires( wipeouts: Vec, paper_w: f64, paper_h: f64, - offset_x: f32, - offset_y: f32, + offset_x: f64, + offset_y: f64, rotation_deg: i32, plot_style: Option, ) -> Result { @@ -155,8 +155,8 @@ pub async fn print_wires_with( wipeouts: Vec, paper_w: f64, paper_h: f64, - offset_x: f32, - offset_y: f32, + offset_x: f64, + offset_y: f64, rotation_deg: i32, plot_style: Option, opts: PrintOptions, diff --git a/src/scene/model/hatch_model.rs b/src/scene/model/hatch_model.rs index 4ea8b0b72..9978c6b36 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -96,26 +96,30 @@ impl HatchModel { /// `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]> { + /// Coordinate frame: each emitted segment is absolute WCS in f64, i.e. + /// `world_origin + boundary[i]` resolved. Solid / gradient hatches return an + /// empty vec — callers fall back to their solid-fill path. + /// + /// The whole rasterisation is f64 because the pattern is anchored at world + /// (0, 0): a hatch at UTM sits ~5e5 away from that anchor, so the line index + /// `k` runs to ~1e7 and `origin + k · step` in f32 quantises the family lines + /// into visible garbage, as does the `edge − line` cancellation below. + pub fn pattern_segments(&self) -> Vec<[[f64; 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; + let ox = self.world_origin[0]; + let oy = self.world_origin[1]; // ── 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; + let mut edges: Vec<([f64; 2], [f64; 2])> = Vec::new(); + let mut sub_start: Option<[f64; 2]> = None; + let mut prev: Option<[f64; 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) { @@ -127,7 +131,7 @@ impl HatchModel { prev = None; continue; } - let pt = [bx + ox, by + oy]; + let pt = [bx as f64 + ox, by as f64 + oy]; match (sub_start, prev) { (None, _) => { sub_start = Some(pt); @@ -150,10 +154,10 @@ impl HatchModel { } // ── 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; + let mut min_x = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut min_y = f64::INFINITY; + let mut max_y = f64::NEG_INFINITY; for &(a, b) in &edges { for [x, y] in [a, b] { min_x = min_x.min(x); @@ -163,25 +167,29 @@ impl HatchModel { } } - let scale = self.scale.max(1e-6); - let angle_offset = self.angle_offset; - let mut segments: Vec<[[f32; 2]; 2]> = Vec::new(); + let scale = self.scale.max(1e-6) as f64; + let angle_offset = self.angle_offset as f64; + let mut segments: Vec<[[f64; 2]; 2]> = Vec::new(); // Hard cap to keep pathological patterns / huge boundaries bounded. - const MAX_LINES_PER_FAMILY: i32 = 4096; + // `k` is i64: at UTM with a fine spacing the index legitimately reaches + // ~1e7 and an out-of-range `as i32` saturates silently, collapsing the + // range to a bogus run of lines from i32::MIN. + const MAX_LINES_PER_FAMILY: i64 = 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 angle = (family.angle_deg as f64).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 (fdx, fdy) = (family.dx as f64, family.dy as f64); + let step_x = (fdx * cos_a - fdy * sin_a) * scale; + let step_y = (fdx * sin_a + fdy * cos_a) * scale; let perp_x = -sin_a; let perp_y = cos_a; let step_perp = step_x * perp_x + step_y * perp_y; @@ -195,12 +203,13 @@ impl HatchModel { // 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 (fx0, fy0) = (family.x0 as f64, family.y0 as f64); let origin = [ - (family.x0 * cos_off - family.y0 * sin_off) * scale, - (family.x0 * sin_off + family.y0 * cos_off) * scale, + (fx0 * cos_off - fy0 * sin_off) * scale, + (fx0 * sin_off + fy0 * cos_off) * scale, ]; - let mut p_min = f32::INFINITY; - let mut p_max = f32::NEG_INFINITY; + let mut p_min = f64::INFINITY; + let mut p_max = f64::NEG_INFINITY; for &[cx, cy] in &[ [min_x, min_y], [max_x, min_y], @@ -211,8 +220,8 @@ impl HatchModel { 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; + let mut k_lo = (p_min / step_perp).floor() as i64 - 1; + let mut k_hi = (p_max / step_perp).ceil() as i64 + 1; if k_lo > k_hi { std::mem::swap(&mut k_lo, &mut k_hi); } @@ -226,21 +235,21 @@ impl HatchModel { k_hi = k_lo.saturating_add(MAX_LINES_PER_FAMILY); } - let period: f32 = family.dashes.iter().map(|d| d.abs()).sum::() * scale; + let period: f64 = family.dashes.iter().map(|d| d.abs() as f64).sum::() * 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 kf = k as f64; 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 = Vec::with_capacity(8); + let mut ts: Vec = Vec::with_capacity(8); for &(a, b) in &edges { let ex = b[0] - a[0]; let ey = b[1] - a[1]; @@ -285,7 +294,7 @@ impl HatchModel { // a dot — rendered as a short mark so dot patterns // (e.g. DOTS) are visible instead of drawing nothing. let n = family.dashes.len(); - let dot_len = (period * 0.06).max(1e-3); + let dot_len: f64 = (period * 0.06).max(1e-3); let phase = t0.rem_euclid(period); // Start at the period boundary at or before t0; the // span clip below drops anything before t0. @@ -294,7 +303,7 @@ impl HatchModel { let max_iters = (((t1 - t0) / period).ceil() as usize + 2) * n + 8; let mut iters = 0usize; while seg_t < t1 && iters < max_iters { - let d = family.dashes[idx]; + let d = family.dashes[idx] as f64; let dl = d.abs() * scale; if d > 0.0 { let a = seg_t.max(t0);