diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 85d4e1274e..a9cb64d6d9 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -223,6 +223,183 @@ pub fn grid_step(half_height: f64) -> (f64, f64) { (minor, minor * 5.0) } +/// How wide the cross-fade between two grid levels is, in powers of ten of the view's height +/// (#9570): about 19% of zoom either side of the switch. The switches are at least +/// `log10(2)` apart, so two cross-fades never overlap. +const GRID_LEVEL_BLEND: f64 = 0.15; + +/// One level of the drawn grid: its spacing and how strongly it is drawn. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GridLevel { + pub minor: f64, + pub major: f64, + /// 0..1, the level's share of the cross-fade; the levels drawn at one zoom add up to 1. The + /// lines only this level has may be drawn weaker than its share ([`grid_lines`]). + pub weight: f32, +} + +/// Where the view is in a cross-fade between two grid levels (#9570). +#[derive(Clone, Copy, Debug)] +struct GridBlend { + /// The finer and the coarser level's minor steps. + lo: f64, + hi: f64, + /// 0..1 through the cross-fade; the switch of [`grid_step`] is at 0.5. + s: f64, + /// Whether the coarser step is a whole multiple of the finer one (1 to 2, 5 to 10), so that + /// every coarser line is also a finer one. From 2 to 5 it is not. + nested: bool, +} + +impl GridBlend { + /// The coarser level's share of the cross-fade: a smoothstep through it. + fn share_hi(&self) -> f32 { + smoothstep(self.s) as f32 + } + + /// How strongly the lines only one of the two levels has are drawn (finer, coarser). + /// + /// Nested levels cross-fade as their shares do. Levels that do not nest (2 and 5: lines at + /// 0, 2, 4, 5, 6, 8, 10 mm) look restless drawn together, so the finer level's own lines fade + /// out over the first half of the cross-fade and the coarser level's own lines fade in over + /// the second: the two are never seen at once. The lines both share (every 10 mm, the finer + /// level's major lines) stay at full strength throughout, so the grid never goes blank. + fn own(&self) -> (f32, f32) { + if self.nested { + let hi = self.share_hi(); + (1.0 - hi, hi) + } else { + (smoothstep(1.0 - 2.0 * self.s) as f32, smoothstep(2.0 * self.s - 1.0) as f32) + } + } +} + +fn smoothstep(x: f64) -> f64 { + let x = x.clamp(0.0, 1.0); + x * x * (3.0 - 2.0 * x) +} + +/// The cross-fade the view of the given half height is in, if any (#9570). +fn grid_blend(half_height: f64) -> Option { + let (minor, _) = grid_step(half_height); + let raw = half_height / 10.0; + // grid_step's floor: no finer level is ever drawn, so there is nothing to fade to. + if !raw.is_finite() || !minor.is_finite() || raw <= 1e-3 || minor <= 0.0 { + return None; + } + let p = 10f64.powf((minor.log10() + 1e-9).floor()); + let mantissa = (minor / p).round(); + // The neighbouring steps of the 1, 2, 5 sequence. + let (finer, coarser) = if mantissa < 1.5 { + (minor * 0.5, minor * 2.0) + } else if mantissa < 3.5 { + (minor * 0.5, minor * 2.5) + } else { + (minor * 0.4, minor * 2.0) + }; + let half = GRID_LEVEL_BLEND * 0.5; + let l = raw.log10(); + // Zooming out, the switch into the coarser level comes at `log10(coarser)`; zooming in, + // the switch into this level was at `log10(minor)`, below which the finer level returns. + let (lo, hi) = if coarser.log10() - l < half { + (minor, coarser) + } else if l - minor.log10() < half && finer >= 1e-3 * (1.0 - 1e-9) { + (finer, minor) + } else { + return None; + }; + let s = ((l - hi.log10()) / GRID_LEVEL_BLEND + 0.5).clamp(0.0, 1.0); + // The switch from 2 to 5 is the only one whose coarser step is not a whole multiple of the + // finer one. It takes the same zoom range as the others, so the grid is cross-fading at the + // same zooms as before; its two halves are each as long as half a nested cross-fade. + let nested = ((hi / lo) - (hi / lo).round()).abs() < 1e-6; + Some(GridBlend { lo, hi, s, nested }) +} + +/// The grid levels drawn for a view of the given half height (#9570). +/// +/// [`grid_step`] switches the spacing in a step (1, 2, 5 times a power of ten), so the whole grid +/// used to swap at once as the view zoomed through a switch, popping over and over while +/// scrolling. Near a switch the drawn grid now cross-fades: the outgoing and incoming levels are +/// both drawn, weighted by how far the zoom is through the switch (from 2 to 5 one after the +/// other, see [`grid_lines`]). Away from a switch only the level [`grid_step`] gives is drawn, at +/// full strength, so the grid looks as it did. The level with the larger weight is always +/// [`grid_step`]'s, which sketch snapping, dash lengths and the tools keep using unchanged: only +/// the drawing fades. +pub fn grid_levels(half_height: f64) -> Vec { + let Some(b) = grid_blend(half_height) else { + let (minor, major) = grid_step(half_height); + return vec![GridLevel { minor, major, weight: 1.0 }]; + }; + let w_hi = b.share_hi(); + [GridLevel { minor: b.lo, major: b.lo * 5.0, weight: 1.0 - w_hi }, GridLevel { minor: b.hi, major: b.hi * 5.0, weight: w_hi }] + .into_iter() + .filter(|g| g.weight > 0.0) + .collect() +} + +/// One line of the drawn grid (#9570), across one of the grid's axes. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GridLineAt { + /// Where the line crosses the axis, measured from the grid's centre. + pub offset: f64, + /// How strongly it is drawn (0..1), before the fade with the distance ([`GridFade`]). + pub weight: f32, + /// How much of that weight is a major line (0..1): the line's colour and width blend from + /// the minor to the major ones by it. + pub major: f32, +} + +/// The lines of the grid across one axis, for a view of the given half height: every level of +/// [`grid_levels`], at the grid's own places on the plane (multiples of its step from the plane's +/// origin), so that a level fading in lies where it will stay once it is the only one. `at` is +/// the grid's centre on that axis, measured from the plane's origin; the lines reach `reach` +/// either side of it, at most `cap` steps of each level. A line two levels share is drawn once, +/// with their weights added; a line only one level has is drawn as strongly as that level's own +/// lines, which from 2 to 5 fade one after the other, never both at once. +pub fn grid_lines(half_height: f64, at: f64, reach: f64, cap: i64) -> Vec { + if !at.is_finite() { + return Vec::new(); + } + // (minor step, share of the cross-fade, strength of its own lines) per level. + let levels: Vec<(f64, f32, f32)> = match grid_blend(half_height) { + Some(b) => { + let (share_hi, (own_lo, own_hi)) = (b.share_hi(), b.own()); + vec![(b.lo, 1.0 - share_hi, own_lo), (b.hi, share_hi, own_hi)] + } + None => vec![(grid_step(half_height).0, 1.0, 1.0)], + }; + // (line, own strength, shared by two levels) + let mut out: Vec<(GridLineAt, f32, bool)> = Vec::new(); + for &(step, share, own) in &levels { + let k0 = (at / step).round(); + if !k0.is_finite() { + continue; + } + let n = ((reach / step) as i64).clamp(1, cap.max(1)); + for i in -n..=n { + let k = (k0 as i64).saturating_add(i); + let major = if k.rem_euclid(5) == 0 { 1.0 } else { 0.0 }; + out.push((GridLineAt { offset: k as f64 * step - at, weight: share, major }, own, false)); + } + } + out.sort_by(|a, b| a.0.offset.total_cmp(&b.0.offset)); + let tol = levels.iter().map(|l| l.0).fold(f64::INFINITY, f64::min) * 1e-6; + let mut merged: Vec<(GridLineAt, f32, bool)> = Vec::with_capacity(out.len()); + for (line, own, _) in out { + match merged.last_mut() { + Some((last, _, shared)) if (line.offset - last.offset).abs() <= tol => { + let weight = last.weight + line.weight; + last.major = if weight > 0.0 { (last.major * last.weight + line.major * line.weight) / weight } else { last.major }; + last.weight = weight.min(1.0); + *shared = true; + } + _ => merged.push((line, own, false)), + } + } + merged.into_iter().map(|(line, own, shared)| if shared { line } else { GridLineAt { weight: own, ..line } }).filter(|l| l.weight > 0.0).collect() +} + /// How the grid fades out with the distance from the middle of the view (#4214). /// /// The grid used to stop at a fixed distance, so its edge showed as a cut across the picture. It @@ -603,7 +780,7 @@ fn build_scene( sc.radius = (b.diagonal() * 0.5).max(cam.half_height()).max(10.0) + b.center().dist(cam.target); // Grid on the ground plane (XY with Z up, XZ with Y up), drawn under the model as in the viewport. if guides.grid { - let (minor, major) = grid_step(cam.half_height()); + let major = grid_step(cam.half_height()).1; // Headless pictures keep the grid on the ground, the viewport's default; an open sketch // has its own plane's grid (world coordinates, no camera frame). let (c, u, v, slant, w): (Vec3, Vec3, Vec3, f64, Box Vec3>) = if let Some(pl) = open_sketch_plane(s) { @@ -624,23 +801,31 @@ fn build_scene( let ext = grid_ext(cam.half_height(), major, slant); let far_reach = grid_far_reach(cam, cam.clip(sc.radius).1, w(c), w(u.cross(v))); let fade = GridFade::new(cam.half_height(), slant, ext, far_reach); - let n = ((fade.end / minor) as i64).clamp(1, 400); - for i in -n..=n { - let t = i as f64 * minor; - let major_line = (t / major).fract().abs() < 1e-9; - let col = if major_line { p.grid_major } else { p.grid }; - // The viewport's widths (pixels). - let width = if major_line { 1.0 } else { 0.7 }; - // Two lines through the point `c + u * t`: one along v, one along u. - for (along, across) in [(v, u), (u, v)] { - for (s0, s1, alpha) in fade.pieces(t) { - sc.grid.push(SceneLine { - points: vec![w(c + across * t + along * s0), w(c + across * t + along * s1)], - color: col, - alpha, - width, - on_top: false, - }); + // The grid's centre on each axis, from the plane's origin (the world's, or the sketch + // plane's): a level fading in near a switch of the spacing lies on its own places (#9570). + let origin = open_sketch_plane(s).map_or(Vec3::ZERO, |pl| pl.origin); + let (at_u, at_v) = ((c - origin).dot(u), (c - origin).dot(v)); + // Two lines through the point `c + u * t`: one along v, one along u. + for (along, across, at) in [(v, u, at_u), (u, v, at_v)] { + for line in grid_lines(cam.half_height(), at, fade.end, 400) { + let t = line.offset; + // The viewport's widths (pixels). The headless picture keeps the palette's two + // grid colours: a line two levels share with different kinds is drawn as both, + // each as strong as its share. + let width = 0.7 + 0.3 * f64::from(line.major); + for (col, share) in [(p.grid_major, line.major), (p.grid, 1.0 - line.major)] { + if share <= 0.0 { + continue; + } + for (s0, s1, alpha) in fade.pieces(t) { + sc.grid.push(SceneLine { + points: vec![w(c + across * t + along * s0), w(c + across * t + along * s1)], + color: col, + alpha: alpha * line.weight * share, + width, + on_top: false, + }); + } } } } diff --git a/crates/engine/src/view_palette_tests.rs b/crates/engine/src/view_palette_tests.rs index 4b991e3254..9407bab939 100644 --- a/crates/engine/src/view_palette_tests.rs +++ b/crates/engine/src/view_palette_tests.rs @@ -75,6 +75,133 @@ fn the_grid_reaches_further_as_its_plane_is_seen_at_a_slant() { assert!(grid_ext(40.0, major, 0.0).is_finite()); } +/// #9570: zooming through a switch of the grid's spacing used to swap the whole grid at once, so +/// scrolling made it pop over and over. The drawn grid now cross-fades: zoomed in small steps +/// through a switch, no line's strength jumps from one step to the next, while away from a +/// switch only [`grid_step`]'s level is drawn, at full strength, as before. +#[test] +fn the_grid_cross_fades_between_levels_as_the_view_zooms() { + // Every 1, 2, 5 switch, zoomed by 1% a step from a half height of 8 to 1200. + let strengths = |hh: f64| { + let mut m = std::collections::BTreeMap::new(); + // The grid's centre off the origin, as when the view has panned: each level's lines + // stay on the plane's own places. + let at = 37.0; + for l in grid_lines(hh, at, 60.0 * hh, 4000) { + let world = l.offset + at; + // Weight and major share, keyed by the line's place on the plane (to 1e-6 mm). + m.insert((world * 1e6).round() as i64, (l.weight, l.major)); + } + m + }; + let mut hh = 8.0; + let mut prev = strengths(hh); + let mut faded = 0; + while hh < 1200.0 { + let next_hh = hh * 1.01; + let next = strengths(next_hh); + // Only lines both steps draw: compare within the smaller reach, short of its ends, where + // each level stops at a whole number of its steps. + let reach = ((60.0 * hh - hh / 4.0) * 1e6) as i64; + for key in prev.keys().chain(next.keys()).filter(|k| (**k - 37_000_000).abs() < reach) { + let (a, ma) = prev.get(key).copied().unwrap_or((0.0, 0.0)); + let (b, mb) = next.get(key).copied().unwrap_or((0.0, 0.0)); + assert!((a - b).abs() < 0.1, "the line at {} mm jumps from {a} to {b} zooming from {hh} to {next_hh}", *key as f64 * 1e-6); + if a > 0.05 && b > 0.05 { + assert!((ma - mb).abs() < 0.1, "the line at {} mm turns from major {ma} to {mb} zooming from {hh} to {next_hh}", *key as f64 * 1e-6); + } + } + let levels = grid_levels(next_hh); + let total: f32 = levels.iter().map(|g| g.weight).sum(); + assert!((total - 1.0).abs() < 1e-5, "the levels at {next_hh} add up to {total}"); + // The strongest level is the one snapping and the tools use. + let strongest = levels.iter().fold(levels[0], |a, g| if g.weight > a.weight { *g } else { a }); + assert_eq!((strongest.minor, strongest.major), grid_step(next_hh), "at {next_hh} the drawn grid's main level is grid_step's"); + faded += usize::from(levels.len() == 2); + prev = next; + hh = next_hh; + } + assert!(faded > 0, "some steps cross-fade"); + // Away from a switch (raw step 3 lies between the 2 and 5 switches), one level at full + // strength, as the grid always was. + assert_eq!(grid_levels(30.0), vec![GridLevel { minor: 2.0, major: 10.0, weight: 1.0 }]); + // grid_step's floor and hostile heights draw one level. + for hh in [0.0, 1e-9, -5.0, f64::NAN, f64::INFINITY] { + assert_eq!(grid_levels(hh).len(), 1, "{hh}"); + let _ = grid_lines(hh, f64::NAN, 10.0, 10); + let _ = grid_lines(hh, 1e300, 1e300, 10); + } +} + +/// #9570: the headless picture's grid cross-fades as the viewport's does: right at a nested +/// switch of the spacing (5 to 10), both levels are drawn, the finer one's own lines at half +/// strength, in the palette's grid colours only. Right at the switch from 2 to 5, which do not +/// nest, neither level's own lines are drawn, only the 10 mm lines both share. +#[test] +fn the_headless_grid_draws_both_levels_at_a_switch() { + let s = Session::default(); + let mut cam = home_camera(&s); + cam.pitch = std::f64::consts::FRAC_PI_2; + cam.target = Vec3::ZERO; + let strength = |hh: f64, x: f64| { + let mut cam = cam; + cam.distance *= hh / cam.half_height(); + assert!((cam.half_height() - hh).abs() < 1e-6); + let scene = scene(&s, &cam); + assert!(scene.grid.iter().all(|l| [Palette::DEFAULT.grid, Palette::DEFAULT.grid_major].contains(&l.color))); + // The strongest piece of the line in each grid colour, added: a line two levels share + // with different kinds is drawn in both. + let on = |l: &&SceneLine| (l.points[0].x - x).abs() < 1e-6 && (l.points[1].x - x).abs() < 1e-6; + [Palette::DEFAULT.grid, Palette::DEFAULT.grid_major] + .iter() + .map(|c| scene.grid.iter().filter(on).filter(|l| l.color == *c).map(|l| l.alpha).fold(0.0f32, f32::max)) + .sum::() + }; + // A half height where grid_step switches from 5 to 10: the 5 mm line (only the finer level) + // is drawn at half strength, the 10 mm line (both) at full. + let a = strength(100.0, 5.0); + assert!(a > 0.3 && a < 0.7, "the line at 5 mm is drawn at {a}, half strength while the levels cross-fade"); + assert!(strength(100.0, 10.0) > 0.9); + // A half height where grid_step switches from 2 to 5. + for x in [2.0, 5.0] { + let a = strength(50.0, x); + assert!(a < 1e-6, "the line at {x} mm is drawn at {a} while 2 and 5 swap"); + } + assert!(strength(50.0, 10.0) > 0.9); +} + +/// #9570: the levels 2 and 5 do not nest, and drawn together they gave lines at 0, 2, 4, 5, 6, +/// 8, 10 mm, which looked restless. Zooming through that switch the finer level's own lines are +/// gone before the coarser level's own lines appear, while the lines both share (every 10 mm) +/// stay at full strength, so the grid never goes blank. Checked in every decade drawn. +#[test] +fn the_grid_never_draws_two_levels_that_do_not_nest_at_once() { + for scale in [0.1, 1.0, 10.0, 100.0] { + // grid_step switches from 2 to 5 (times `scale`) at a half height of 50 times `scale`. + let (mut hh, end) = (30.0 * scale, 80.0 * scale); + let (mut saw_fine, mut saw_coarse) = (false, false); + while hh < end { + let lines = grid_lines(hh, 0.0, 60.0 * scale, 4000); + let at = |x: f64| lines.iter().filter(|l| (l.offset - x * scale).abs() < 1e-6 * scale).map(|l| l.weight).sum::(); + // 2, 4, 6, 8: only the finer level; 5, 15: only the coarser. + let fine = [2.0, 4.0, 6.0, 8.0].iter().map(|x| at(*x)).fold(0.0f32, f32::max); + let coarse = [5.0, 15.0].iter().map(|x| at(*x)).fold(0.0f32, f32::max); + assert!(fine == 0.0 || coarse == 0.0, "at {hh} the 2 and 5 levels are both drawn ({fine}, {coarse})"); + saw_fine |= fine > 0.0 && fine < 1.0; + saw_coarse |= coarse > 0.0 && coarse < 1.0; + for x in [0.0, 10.0, 20.0] { + assert!(at(x) > 0.999, "at {hh} the line at {x} mm both levels share is drawn at {}", at(x)); + } + hh *= 1.005; + } + assert!(saw_fine && saw_coarse, "both levels fade rather than swap (scale {scale})"); + } + // The nested switches still cross-fade both levels together: from 1 to 2 (half height 20). + let lines = grid_lines(20.0, 0.0, 20.0, 4000); + let at = |x: f64| lines.iter().filter(|l| (l.offset - x).abs() < 1e-6).map(|l| l.weight).sum::(); + assert!(at(1.0) > 0.3 && at(1.0) < 0.7 && at(2.0) > 0.999, "{} {}", at(1.0), at(2.0)); +} + /// No colour here is one of [`colors`] or an appearance below, and each field has its own. const MARK: Palette = Palette { background: (Rgb(1, 2, 3), Rgb(4, 5, 6)), diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index a18cd5ab53..26b6b8c9e1 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -8,7 +8,7 @@ use solidcraft_engine::Sel; use solidcraft_engine::geom::{Plane, Vec2, Vec3}; use solidcraft_engine::render::{Camera, Mat4, Rgb}; use solidcraft_engine::sketch::CurveKind; -use solidcraft_engine::view::{GridFade, GridPlane, colors, grid_ext, grid_plane, grid_step, sketch_lines_with_sampler}; +use solidcraft_engine::view::{GridFade, GridPlane, colors, grid_ext, grid_lines, grid_plane, grid_step, sketch_lines_with_sampler}; use std::hash::{Hash, Hasher}; use crate::gpu::{Frame, GpuScene, GpuTarget, SceneSlot, ViewportCallback}; @@ -1694,7 +1694,7 @@ fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene { // An open sketch has its grid whatever the Grid switch says, unless the sketch's own Sketch // Grid option (Sketch panel) is off (#9147); outside a sketch that option is never off. if (sketch_plane.is_some() || app.ui.show_grid) && !active_view(app).hide_grid { - let (minor, major) = grid_step(app.cam.half_height()); + let major = grid_step(app.cam.half_height()).1; // An open sketch has its grid on its own plane, and so does the plane Create Sketch // would take; otherwise the ground plane: XY with Z up, XZ with Y up (laid out in the // camera's Z-up frame), or the upright origin plane facing the view when the ground is @@ -1743,17 +1743,22 @@ fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene { // now drawn in pieces, each as faint as the fade makes it there, and the grid is gone // before its own edge, which is therefore never seen. let fade = GridFade::new(app.cam.half_height(), slant, ext, far_reach); - let n = ((fade.end / minor) as i64).clamp(1, 300); let (grid, grid_major) = (c4(tk.grid), c4(tk.grid_major)); - for i in -n..=n { - let t = i as f64 * minor; - let maj = (t / major).round() * major == t; - let col = if maj { grid_major } else { grid }; - let wd = if maj { 1.0 } else { 0.7 }; - // Two lines through the point `c + u * t`: one along v, one along u. - for (along, across) in [(v, u), (u, v)] { + // Near a switch of the spacing the outgoing and incoming levels cross-fade (#9570), each + // line on its own place on the plane: the grid's centre on each axis, from the plane's + // origin (the sketch plane's, or the camera frame's). + let origin = sketch_plane.as_ref().map_or(Vec3::ZERO, |pl| pl.origin); + let (at_u, at_v) = ((c - origin).dot(u), (c - origin).dot(v)); + // Two lines through the point `c + u * t`: one along v, one along u. + for (along, across, at) in [(v, u, at_u), (u, v, at_v)] { + for line in grid_lines(app.cam.half_height(), at, fade.end, 300) { + let t = line.offset; + // A line two levels share with different kinds blends between the minor and + // the major look. + let col = grid_blend(grid, grid_major, line.major); + let wd = 0.7 + 0.3 * line.major; for (s0, s1, alpha) in fade.pieces(t) { - let col = sc.color(grid_colour(col, alpha)); + let col = sc.color(grid_colour(col, alpha * line.weight)); sc.under_line(w3(c + across * t + along * s0), w3(c + across * t + along * s1), col, wd); } } @@ -6129,6 +6134,13 @@ fn points_2d(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { } /// A grid line's colour, as faint as the fade makes it. +/// The grid's minor colour blended towards its major one by `major` (0..1): a line two grid +/// levels share while they cross-fade, minor in one and major in the other (#9570). +fn grid_blend(minor: [u8; 4], major: [u8; 4], share: f32) -> [u8; 4] { + let t = if share.is_finite() { f64::from(share.clamp(0.0, 1.0)) } else { 0.0 }; + std::array::from_fn(|i| (f64::from(minor[i]) + (f64::from(major[i]) - f64::from(minor[i])) * t).round().clamp(0.0, 255.0) as u8) +} + fn grid_colour(c: [u8; 4], alpha: f32) -> [u8; 4] { [c[0], c[1], c[2], (f64::from(c[3]) * f64::from(alpha)).round().clamp(0.0, 255.0) as u8] } @@ -11959,6 +11971,53 @@ mod grid_plane_tests { assert!(farthest < cut, "the grid fades out at {farthest}, short of the cut at {cut}"); } + /// #9570: zooming through a switch of the grid's spacing swapped the whole grid at once. At + /// a nested switch the window now draws both levels at half strength, each on its own + /// places; at the switch from 2 to 5, which do not nest, neither level's own lines; and a + /// line two levels share with different kinds takes a colour between the two. + #[test] + fn the_grid_draws_both_levels_at_a_switch_of_its_spacing() { + let rect = Rect::from_min_size(Pos2::ZERO, egui::vec2(800.0, 600.0)); + // The strongest alpha a line across X at `x` is drawn with, at a half height of `hh`. + let alpha_at = |hh: f64, x: f32| { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.ui.show_grid = true; + app.ui.perspective = false; + app.cam.pitch = std::f64::consts::FRAC_PI_2; + app.cam.yaw = 0.0; + app.cam.target = Vec3::ZERO; + app.cam.distance = 1.0; + app.cam.distance = hh / app.cam.half_height(); + let scene = build_sketch_scene(&app, &projection(&app, rect)); + scene + .under + .as_chunks::<32>() + .0 + .iter() + .filter(|l| { + let f = |at: usize| f32::from_le_bytes(l[at..at + 4].try_into().unwrap()); + (f(0) - x).abs() < 1e-4 && (f(12) - x).abs() < 1e-4 + }) + .filter_map(|l| scene.palette.get(crate::gpu::PaletteIndex(u32::from_le_bytes(l[24..28].try_into().unwrap()))).map(|c| c[3])) + .max() + .unwrap_or(0) + }; + let full = u16::from(c4(Tokens::get().grid)[3]); + // A half height where the spacing switches from 5 to 10 mm (nested): the 5 mm lines + // cross-fade at about half strength. + let a = u16::from(alpha_at(100.0, 5.0)); + assert!(a > full / 4 && a < full * 3 / 4, "the line at 5 mm is drawn at {a} of {full}: both levels cross-fade"); + // From 2 to 5 mm (not nested) the finer level's own lines are gone before the coarser + // level's own lines appear: right at the switch neither is drawn, the shared 10 mm are. + for x in [2.0, 5.0] { + assert_eq!(alpha_at(50.0, x), 0, "the line at {x} mm is drawn while 2 and 5 swap"); + } + assert!(alpha_at(50.0, 10.0) > 0); + assert_eq!(grid_blend([0, 0, 0, 0], [200, 100, 50, 255], 0.5), [100, 50, 25, 128]); + assert_eq!(grid_blend([1, 2, 3, 4], [9, 9, 9, 9], 0.0), [1, 2, 3, 4]); + assert_eq!(grid_blend([1, 2, 3, 4], [9, 9, 9, 9], f32::NAN), [1, 2, 3, 4]); + } + /// #4214: the picture and the window draw the same grid. On a sketch's plane the headless /// render's grid used to reach further than the viewport's (the picture stretched the grid /// with the slant, the window did not), so a picture saved from the command line and the