From 76e009ab100ca8def6525fcbbfd2c36e282975ef Mon Sep 17 00:00:00 2001 From: kaltenbrunnermarkus <153593707+kaltenbrunnermarkus@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:35:49 +0000 Subject: [PATCH] Viewport: blend grid levels while zooming instead of popping (#9570) (#9608) * claim: #9570 * Cross-fade the viewport grid between levels while zooming (#9570) grid_step switches the spacing in a step (1, 2, 5 times a power of ten), so the whole grid swapped at once as the view zoomed through a switch and popped over and over while scrolling. Near a switch the drawn grid now draws the outgoing and incoming levels together, weighted by a smoothstep over about 19% of zoom either side, each level's lines on their own places on the plane so a fading level never shifts. A line both levels share is drawn once; its colour and width blend between minor and major. grid_step itself is unchanged, so sketch snapping, dash lengths and the tools keep their spacing; the dominant drawn level is always grid_step's. The viewport and the headless picture share grid_lines, so they still draw the same grid. Co-Authored-By: Claude Opus 5.5 * Fade out the fine grid before a non-nested coarser level appears (#9570) The levels go 1, 2, 5 times a power of ten. From 1 to 2 and from 5 to 10 every coarser line is also a finer one, but 5 is not a multiple of 2, so cross-fading the 2 and 5 levels drew lines at 0, 2, 4, 5, 6, 8, 10 mm at once, which looked restless. At that switch the finer level's own lines now fade out over the first half of the cross-fade and the coarser level's own lines fade in over the second, so the two are never drawn together. The lines both levels share (every 10 mm, the finer level's major lines) stay at full strength throughout, so the grid never goes blank. The cross-fade spans the same zoom range as before, and the nested switches cross-fade as they did. grid_levels keeps its shares (adding up to 1, grid_step's level the stronger one); grid_lines draws a line only one level has as strongly as that level's own lines. The headless and viewport tests now check half strength at the nested 5 to 10 switch and no 2 or 5 mm line right at the 2 to 5 switch; a new test zooms through 2 to 5 in four decades. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/view.rs | 221 ++++++++++++++++++++++-- crates/engine/src/view_palette_tests.rs | 127 ++++++++++++++ crates/ui-egui/src/viewport.rs | 81 +++++++-- 3 files changed, 400 insertions(+), 29 deletions(-) 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