From 05f462f294567a1bb1336f0d08ac249254fa82c5 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Thu, 17 Sep 2026 13:13:49 +0100 Subject: [PATCH] fix: honor GRIDUNIT X/Y spacing and LIMITS option in grid display The viewport grid ignored the configured spacing and forced square grids: compute_grid_step hardcoded a 1.0 base, a single shared step drove both line families (including the tangent derivative used for gap measurement), and the display always clipped to LIMITS despite the beyond-Limits option. Grid spacing now scales per-axis from GRIDUNIT via compute_grid_steps (adaptive 5x growth, fixed passthrough), GridParams carries step_x/step_y, snap tolerances follow the visible steps, and the LIMITS clip is skipped when beyond-Limits is set. Also fixes a per-axis overflow fallback that returned the X base for Y. Spacing state (X/Y, adaptive, beyond-Limits) is stored on the app and persisted to UserSettings at defaults; dialog wiring follows separately. --- benches/performance_benchmarks.rs | 14 +- src/app/mod.rs | 11 ++ src/app/settings.rs | 19 +++ src/app/update/file.rs | 8 ++ src/app/update/viewport.rs | 25 +++- src/app/view/mod.rs | 25 +++- src/ui/overlay.rs | 217 ++++++++++++++++++++++-------- 7 files changed, 246 insertions(+), 73 deletions(-) diff --git a/benches/performance_benchmarks.rs b/benches/performance_benchmarks.rs index 4ae2dcf9c..4cfd9f3cd 100644 --- a/benches/performance_benchmarks.rs +++ b/benches/performance_benchmarks.rs @@ -1185,10 +1185,10 @@ fn bench_ui_grid_geometry(runner: &mut BenchmarkRunner) { if runner.should_run("ui_grid_geometry_uncached") { for _ in 0..10 { let _ = black_box(grid_segments( - view_rot1, eye1, bounds1, step1, origin1, axes1, limits1, + view_rot1, eye1, bounds1, step1, step1, origin1, axes1, limits1, )); let _ = black_box(grid_segments( - view_rot2, eye2, bounds2, step2, origin2, axes2, limits2, + view_rot2, eye2, bounds2, step2, step2, origin2, axes2, limits2, )); } @@ -1199,8 +1199,8 @@ fn bench_ui_grid_geometry(runner: &mut BenchmarkRunner) { for _ in 0..runs { let t0 = Instant::now(); for _ in 0..n { - let g1 = grid_segments(view_rot1, eye1, bounds1, step1, origin1, axes1, limits1); - let g2 = grid_segments(view_rot2, eye2, bounds2, step2, origin2, axes2, limits2); + let g1 = grid_segments(view_rot1, eye1, bounds1, step1, step1, origin1, axes1, limits1); + let g2 = grid_segments(view_rot2, eye2, bounds2, step2, step2, origin2, axes2, limits2); black_box(g1); black_box(g2); } @@ -1225,7 +1225,8 @@ fn bench_ui_grid_geometry(runner: &mut BenchmarkRunner) { view_rot: view_rot1, eye: eye1, bounds: bounds1, - step: step1, + step_x: step1, + step_y: step1, origin: origin1, axes: axes1, limits: limits1, @@ -1234,7 +1235,8 @@ fn bench_ui_grid_geometry(runner: &mut BenchmarkRunner) { view_rot: view_rot2, eye: eye2, bounds: bounds2, - step: step2, + step_x: step2, + step_y: step2, origin: origin2, axes: axes2, limits: limits2, diff --git a/src/app/mod.rs b/src/app/mod.rs index fab5a9ecb..5d4c8cdbc 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -542,6 +542,13 @@ pub(super) struct OpenCADStudio { snap_angle_deg: f32, /// Show grid lines in the viewport (F7). show_grid: bool, + /// GRIDUNIT X/Y display spacing backing the DSettings grid-resize inputs. + pub grid_spacing_x: f32, + pub grid_spacing_y: f32, + /// Adaptive grid: scale GRIDUNIT up by 5x steps to stay readable. + pub grid_adaptive: bool, + /// Display the grid beyond LIMITS (infinite) instead of clipping to them. + pub grid_beyond_limits: bool, /// Dynamic input overlay (F12): show coordinate tooltip near cursor. dyn_input: bool, /// Currently visible page in the application Options dialog. @@ -3806,6 +3813,10 @@ impl OpenCADStudio { iso_plane: settings::IsoPlane::Left, snap_angle_deg: 0.0, show_grid: false, + grid_spacing_x: 10.0, + grid_spacing_y: 10.0, + grid_adaptive: true, + grid_beyond_limits: true, dyn_input: true, options_tab: crate::ui::window::options::OptionsTab::General, spacemouse: { diff --git a/src/app/settings.rs b/src/app/settings.rs index 0c1670994..856e7d2ab 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -459,6 +459,17 @@ pub struct UserSettings { pub snap_spacing_x: f32, #[serde(default = "default_snap_spacing")] pub snap_spacing_y: f32, + /// GRIDUNIT X/Y display spacing (grid resizing). Falls back to 10. + #[serde(default = "default_snap_spacing")] + pub grid_spacing_x: f32, + #[serde(default = "default_snap_spacing")] + pub grid_spacing_y: f32, + /// Adaptive grid scaling (default on). + #[serde(default = "default_true")] + pub grid_adaptive: bool, + /// Display grid beyond LIMITS (default on, matches dialog). + #[serde(default = "default_true")] + pub grid_beyond_limits: bool, /// Most-recently-inserted block names, most recent first, capped to 20. /// Used to rank INSERT suggestions without touching the drawing file. #[serde(default)] @@ -485,6 +496,10 @@ fn default_snap_spacing() -> f32 { 10.0 } +fn default_true() -> bool { + true +} + /// Clamp a snap spacing to the positive range the dialog accepts. pub fn sanitize_snap_spacing(v: f32) -> f32 { if v.is_finite() && v > 0.0 && v <= 1e9 { @@ -599,6 +614,10 @@ impl Default for UserSettings { commandline_fade_ms: 3000, snap_spacing_x: 10.0, snap_spacing_y: 10.0, + grid_spacing_x: 10.0, + grid_spacing_y: 10.0, + grid_adaptive: true, + grid_beyond_limits: true, block_mru: Vec::new(), block_freq: std::collections::HashMap::new(), } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index c4edecfa0..3ec81c78f 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -531,6 +531,10 @@ impl OpenCADStudio { ), snap_spacing_x: self.snapper.snap_spacing_x, snap_spacing_y: self.snapper.snap_spacing_y, + grid_spacing_x: self.grid_spacing_x, + grid_spacing_y: self.grid_spacing_y, + grid_adaptive: self.grid_adaptive, + grid_beyond_limits: self.grid_beyond_limits, block_mru: self.block_mru.clone(), block_freq: self.block_freq.clone(), } @@ -635,6 +639,10 @@ impl OpenCADStudio { crate::app::settings::sanitize_snap_spacing(s.snap_spacing_x); self.snapper.snap_spacing_y = crate::app::settings::sanitize_snap_spacing(s.snap_spacing_y); + self.grid_spacing_x = crate::app::settings::sanitize_snap_spacing(s.grid_spacing_x); + self.grid_spacing_y = crate::app::settings::sanitize_snap_spacing(s.grid_spacing_y); + self.grid_adaptive = s.grid_adaptive; + self.grid_beyond_limits = s.grid_beyond_limits; // Block usage: clone but cap to sane sizes (MRU 20, freq map 200) self.block_mru = s.block_mru.iter().take(20).cloned().collect(); self.block_freq = s diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 8478a94b9..a5477488b 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1924,18 +1924,25 @@ impl OpenCADStudio { // content), so snap / hit-test / preview run exactly like the // main model view — no paper projection, tracks pan/zoom/twist. let cursor_world = self.cursor_model_point(i, &edit_cam, p, bounds); - let (view_rot, eye, grid_spacing) = match &edit_cam { + let (gx, gy, adaptive) = (self.grid_spacing_x, self.grid_spacing_y, self.grid_adaptive); + let visible_step = |distance: f32, fov_y: f32| { + let (sx, sy) = crate::ui::overlay::compute_grid_steps( + gx, gy, distance, fov_y, bounds, adaptive, + ); + sx.max(sy) + }; + let (view_rot, eye, grid_spacing) = match &edit_cam { Some(cam) => ( cam.view_proj_rte(bounds), cam.eye(), - crate::ui::overlay::compute_grid_step(cam.distance, cam.fov_y, bounds), + visible_step(cam.distance, cam.fov_y), ), None => { let cam = self.tabs[i].scene.camera.borrow(); ( cam.view_proj_rte(bounds), cam.eye(), - crate::ui::overlay::compute_grid_step(cam.distance, cam.fov_y, bounds), + visible_step(cam.distance, cam.fov_y), ) } }; @@ -2788,18 +2795,26 @@ impl OpenCADStudio { // Object/grid snap, same path as an entity grip or command drag: the // dragged UCS point sticks to endpoints/midpoints/grid under the cursor, // and the snap marker is published via `snap_result`. + let (dgx, dgy, dadaptive) = + (self.grid_spacing_x, self.grid_spacing_y, self.grid_adaptive); + let dvisible_step = |distance: f32, fov_y: f32| { + let (sx, sy) = crate::ui::overlay::compute_grid_steps( + dgx, dgy, distance, fov_y, bounds, dadaptive, + ); + sx.max(sy) + }; let (view_rot, eye, grid_spacing) = match &edit_cam { Some(cam) => ( cam.view_proj_rte(bounds), cam.eye(), - crate::ui::overlay::compute_grid_step(cam.distance, cam.fov_y, bounds), + dvisible_step(cam.distance, cam.fov_y), ), None => { let cam = self.tabs[i].scene.camera.borrow(); ( cam.view_proj_rte(bounds), cam.eye(), - crate::ui::overlay::compute_grid_step(cam.distance, cam.fov_y, bounds), + dvisible_step(cam.distance, cam.fov_y), ) } }; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 06cbe15d2..238afa81f 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -443,18 +443,31 @@ impl OpenCADStudio { ); axes = (ux.as_vec3(), uy.as_vec3(), uz.as_vec3()); } + // BUG FIX: the display step used to ignore GRIDUNIT entirely + // (hardcoded 1.0 base). It now resizes from the DSettings + // grid spacing, and honors "Display grid beyond Limits". + let (step_x, step_y) = crate::ui::overlay::compute_grid_steps( + self.grid_spacing_x, + self.grid_spacing_y, + cam.distance, + cam.fov_y, + bounds, + self.grid_adaptive, + ); + let limits = if self.grid_beyond_limits { + None + } else { + tab.scene.grid_limits_for_viewport(handle) + }; crate::ui::overlay::GridParams { view_rot: cam.view_proj_rte(bounds), eye: cam.eye(), bounds, - step: crate::ui::overlay::compute_grid_step( - cam.distance, - cam.fov_y, - bounds, - ), + step_x, + step_y, origin, axes, - limits: tab.scene.grid_limits_for_viewport(handle), + limits, } }) .collect(); diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 00c642206..db5a1354d 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -490,9 +490,11 @@ pub struct GridParams { /// Camera eye in absolute world f64 — subtracted from each grid point. pub eye: glam::DVec3, pub bounds: iced::Rectangle, - /// Adaptive world-space spacing derived from camera zoom at its pivot. + /// World-space X spacing (GRIDUNIT X, after adaptive scaling). /// Rotation does not affect this value, so orbiting cannot rescale the grid. - pub step: f32, + pub step_x: f32, + /// World-space Y spacing (GRIDUNIT Y, after adaptive scaling). + pub step_y: f32, /// Grid origin in absolute world f64 and the active UCS axis directions. /// The grid always lies on the active UCS XY plane. Plain WCS passes /// `(ZERO, X, Y, Z)`. @@ -633,19 +635,61 @@ fn clip_seg(p0: Point, p1: Point, bounds: iced::Rectangle) -> Option<(Point, Poi } pub fn compute_grid_step(distance: f32, fov_y: f32, bounds: iced::Rectangle) -> f32 { + compute_grid_steps(1.0, 1.0, distance, fov_y, bounds, true).0 +} + +/// Adaptive world-space grid spacing for non-uniform GRIDUNIT X/Y. +/// +/// BUG FIX: the old `compute_grid_step` hardcoded a 1.0 base, so the +/// Drafting Settings grid spacing had no effect on the display. The base now +/// comes from the user's GRIDUNIT setting. With `adaptive == true`, each axis +/// is scaled up by powers of 5 until neighbouring lines are at least +/// `MIN_GRID_PX` apart; with `adaptive == false` the raw base is returned so +/// the grid resizes exactly as typed. +pub fn compute_grid_steps( + base_x: f32, + base_y: f32, + distance: f32, + fov_y: f32, + bounds: iced::Rectangle, + adaptive: bool, +) -> (f32, f32) { + let sanitize = |b: f32| { + if b.is_finite() && b > 0.0 && b <= 1e9 { + b + } else { + 1.0 + } + }; + let bx = sanitize(base_x); + let by = sanitize(base_y); + if !adaptive { + return (bx, by); + } let half_height = distance * (fov_y * 0.5).tan(); if !half_height.is_finite() || half_height <= 1e-9 || bounds.height <= 0.0 { - return 1.0; + return (bx, by); } let px_per_unit = bounds.height / (2.0 * half_height); - let mut s = 1.0_f32; - while s * px_per_unit < MIN_GRID_PX { - s *= 5.0; - if s > 1e9 { - return 1.0; - } + if !px_per_unit.is_finite() || px_per_unit <= 0.0 { + return (bx, by); } - s + let adapt = |mut s: f32, base: f32| { + // Grow-only: shrinking below the user's base would silently change the + // requested GRIDUNIT when zoomed in. Guard the loop so a degenerate + // px_per_unit can never spin forever. The overflow fallback returns + // this axis's own base (returning the other axis's base mixed X/Y). + let mut guard = 0; + while s * px_per_unit < MIN_GRID_PX && guard < 32 { + s *= 5.0; + guard += 1; + if !s.is_finite() || s > 1e9 { + return base; + } + } + s + }; + (adapt(bx, bx), adapt(by, by)) } /// Parameters for the screen-space UCS icon drawn in the viewport corner. @@ -739,7 +783,8 @@ impl canvas::Program for GridCanvas { g.view_rot, g.eye, gb, - g.step, + g.step_x, + g.step_y, g.origin, g.axes, g.limits, @@ -2031,7 +2076,8 @@ fn draw_grid( view_rot: Mat4, eye: glam::DVec3, bounds: iced::Rectangle, - step: f32, + step_x: f32, + step_y: f32, grid_origin: glam::DVec3, grid_axes: (Vec3, Vec3, Vec3), limits: Option<(glam::DVec2, glam::DVec2)>, @@ -2060,7 +2106,8 @@ fn draw_grid( style: canvas::Style::Solid(gc), ..Default::default() }; - let geometry = grid_segments(view_rot, eye, bounds, step, grid_origin, grid_axes, limits); + let geometry = + grid_segments(view_rot, eye, bounds, step_x, step_y, grid_origin, grid_axes, limits); if !geometry.segments.is_empty() { let path = canvas::Path::new(|builder| { for (p0, p1) in &geometry.segments { @@ -2072,7 +2119,7 @@ fn draw_grid( } if geometry.axis_extent > 0.0 { let (gx, gy, gz) = grid_axes; - let extent = (geometry.axis_extent + step) * 1.5; + let extent = (geometry.axis_extent + step_x.max(step_y)) * 1.5; draw_axes(frame, view_rot, eye, bounds, extent.max(10.0), grid_origin, (gx, gy, gz), style.bg_luminance); } } @@ -2090,7 +2137,8 @@ pub fn grid_segments( view_rot: Mat4, eye: glam::DVec3, bounds: iced::Rectangle, - step: f32, + step_x: f32, + step_y: f32, grid_origin: glam::DVec3, grid_axes: (Vec3, Vec3, Vec3), limits: Option<(glam::DVec2, glam::DVec2)>, @@ -2162,7 +2210,7 @@ pub fn grid_segments( // family at a point on the grid. Measuring the perpendicular component, // rather than point-to-point distance, remains correct for a skewed // perspective grid. - let grid_gaps = |world: glam::DVec3, step: f32| -> Option<(f32, f32)> { + let grid_gaps = |world: glam::DVec3, sx: f32, sy: f32| -> Option<(f32, f32)> { let p = project(world)?; let projected_deltas = |axis: Vec3, amount: f32| { [amount, -amount].map(|signed_step| { @@ -2170,14 +2218,16 @@ pub fn grid_segments( .map(|next| glam::Vec2::new(next.x - p.x, next.y - p.y)) }) }; - let neighbours1 = projected_deltas(axis1, step); - let neighbours2 = projected_deltas(axis2, step); + let neighbours1 = projected_deltas(axis1, sx); + let neighbours2 = projected_deltas(axis2, sy); // A full grid step is needed to measure adjacent-line distance, but it // is too large for the line's local tangent near the eye. A small // derivative keeps the tangent measurable without crossing the eye. - let tangent_step = (step * 0.01).max(1e-4); - let tangents1 = projected_deltas(axis1, tangent_step); - let tangents2 = projected_deltas(axis2, tangent_step); + // BUG FIX: the tangent used a single shared step for both families, so + // a non-square GRIDUNIT (sx != sy) measured the wrong local direction. + // Each family now uses its own derivative. + let tangents1 = projected_deltas(axis1, (sx * 0.01).max(1e-4)); + let tangents2 = projected_deltas(axis2, (sy * 0.01).max(1e-4)); // At the near side of a perspective plane a large +step neighbour may // cross behind the eye while the -step neighbour remains perfectly @@ -2227,10 +2277,12 @@ pub fn grid_segments( // Step follows camera zoom only. The previous visible-sample calculation // changed depth while orbiting and made the grid jump 1 → 5 → 25. - if !step.is_finite() || step <= 0.0 { + // BUG FIX: a single shared step forced square grids; X and Y are now + // validated independently so GRIDUNIT X/Y resize each family. + if !step_x.is_finite() || step_x <= 0.0 || !step_y.is_finite() || step_y <= 0.0 { return GridGeometry::empty(); } - let s = step; + let (sx, sy) = (step_x, step_y); // Trace a family-specific visible region around the viewport perimeter. // When a boundary ray points through the horizon, binary-search back toward @@ -2242,7 +2294,7 @@ pub fn grid_segments( | -> Vec { let visible_at = |screen: glam::Vec2| -> Option { let world = unproject(screen.x, screen.y)?; - let gaps = grid_gaps(world, s)?; + let gaps = grid_gaps(world, sx, sy)?; let gap = if family == 0 { gaps.0 } else { gaps.1 }; (gap >= MIN_HORIZON_GRID_PX).then_some(world) }; @@ -2284,7 +2336,7 @@ pub fn grid_segments( let best_anchor = |family: usize| -> Option<(glam::Vec2, glam::DVec3, f32)> { let mut best = None; for (screen, world) in &samples { - let Some(gaps) = grid_gaps(*world, s) else { + let Some(gaps) = grid_gaps(*world, sx, sy) else { continue; }; let gap = if family == 0 { gaps.0 } else { gaps.1 }; @@ -2306,13 +2358,13 @@ pub fn grid_segments( } (min <= max).then_some((min, max)) }; - let line_range = |min: f32, max: f32, anchor: f32| -> (i32, i32) { - let mut start = (min / s).floor() as i32; - let mut end = (max / s).ceil() as i32; + let line_range = |min: f32, max: f32, anchor: f32, step: f32| -> (i32, i32) { + let mut start = (min / step).floor() as i32; + let mut end = (max / step).ceil() as i32; // The pixel-gap cut-off naturally bounds this by viewport resolution. Keep // malformed projection data from creating an unbounded CPU loop. let limit = ((bounds.width + bounds.height).ceil() as i32 + 64).max(128); - let center = (anchor / s).round() as i32; + let center = (anchor / step).round() as i32; start = start.max(center.saturating_sub(limit)); end = end.min(center.saturating_add(limit)); (start, end) @@ -2413,7 +2465,7 @@ pub fn grid_segments( let Some(world) = unproject(screen.x, screen.y) else { return false; }; - let Some(gaps) = grid_gaps(world, s) else { + let Some(gaps) = grid_gaps(world, sx, sy) else { return false; }; let gap = if family == 0 { gaps.0 } else { gaps.1 }; @@ -2529,14 +2581,13 @@ pub fn grid_segments( let (min1, max1) = coordinate_range(axis1); let (min2, max2) = coordinate_range(axis2); - let mut segments = Vec::new(); if let Some((_, anchor_world, gap)) = best_anchor(0) { if gap >= MIN_HORIZON_GRID_PX { let anchor = (anchor_world - grid_origin).as_vec3().dot(axis1); - let (start, end) = line_range(min1, max1, anchor); + let (start, end) = line_range(min1, max1, anchor, sx); for index in start..=end { - if let Some(segment) = clip_world_line(0, index as f32 * s) { - segments.push(segment); + if let Some(segment) = clip_world_line(0, index as f32 * sx) { + all_segments.push(segment); } } } @@ -2544,15 +2595,14 @@ pub fn grid_segments( if let Some((_, anchor_world, gap)) = best_anchor(1) { if gap >= MIN_HORIZON_GRID_PX { let anchor = (anchor_world - grid_origin).as_vec3().dot(axis2); - let (start, end) = line_range(min2, max2, anchor); + let (start, end) = line_range(min2, max2, anchor, sy); for index in start..=end { - if let Some(segment) = clip_world_line(1, index as f32 * s) { - segments.push(segment); + if let Some(segment) = clip_world_line(1, index as f32 * sy) { + all_segments.push(segment); } } } } - all_segments.extend(segments); // LIMITS bounds the grid, not the UCS axes. Size the axes from the // visible grid plane so X/Y/Z still span the viewport even when the @@ -2577,15 +2627,13 @@ pub fn grid_segments( (axis_range(&hits, axis1), axis_range(&hits, axis2)) { let anchor1 = (anchor_world - grid_origin).as_vec3().dot(axis1); - let (start, end) = line_range(min1, max1, anchor1); - let mut segments = Vec::with_capacity((end - start + 1).max(0) as usize); + let (start, end) = line_range(min1, max1, anchor1, sx); for i in start..=end { - let value = i as f32 * s; + let value = i as f32 * sx; if let Some((p0, p1)) = project_line(0, value) { - segments.extend(trim_line(0, p0, p1)); + all_segments.extend(trim_line(0, p0, p1)); } } - all_segments.extend(segments); axis_extent = axis_extent.max(min1.abs().max(max1.abs()).max(min2.abs()).max(max2.abs())); } @@ -2600,15 +2648,13 @@ pub fn grid_segments( (axis_range(&hits, axis1), axis_range(&hits, axis2)) { let anchor2 = (anchor_world - grid_origin).as_vec3().dot(axis2); - let (start, end) = line_range(min2, max2, anchor2); - let mut segments = Vec::with_capacity((end - start + 1).max(0) as usize); + let (start, end) = line_range(min2, max2, anchor2, sy); for i in start..=end { - let value = i as f32 * s; + let value = i as f32 * sy; if let Some((p0, p1)) = project_line(1, value) { - segments.extend(trim_line(1, p0, p1)); + all_segments.extend(trim_line(1, p0, p1)); } } - all_segments.extend(segments); axis_extent = axis_extent.max(min1.abs().max(max1.abs()).max(min2.abs()).max(max2.abs())); } @@ -3692,7 +3738,8 @@ mod grid_key_tests { width: 1280.0, height: 720.0, }, - step: 80.0, + step_x: 80.0, + step_y: 80.0, origin: glam::DVec3::new(0.0, 0.0, 0.0), axes: (Vec3::X, Vec3::Y, Vec3::Z), limits: None, @@ -3742,13 +3789,22 @@ mod grid_key_tests { "eye change must invalidate" ); - // step: zoom in + // step_x: zoom in let mut p = baseline_params(); - p.step = 40.0; + p.step_x = 40.0; assert_ne!( GridKey::from_grids(&[p], baseline_bounds, GridStyle::default()), baseline_key, - "step change must invalidate" + "step_x change must invalidate" + ); + + // step_y: non-square grid resize + let mut p = baseline_params(); + p.step_y = 40.0; + assert_ne!( + GridKey::from_grids(&[p], baseline_bounds, GridStyle::default()), + baseline_key, + "step_y change must invalidate" ); // origin: translate the UCS origin off-zero @@ -3822,7 +3878,7 @@ mod grid_key_tests { let baseline = GridKey::from_grids(&both, bounds, GridStyle::default()); let mut pane2_changed = pane2; - pane2_changed.step = 160.0; + pane2_changed.step_x = 160.0; let dirty = vec![pane1, pane2_changed]; assert_ne!( GridKey::from_grids(&dirty, bounds, GridStyle::default()), @@ -3855,13 +3911,62 @@ mod grid_key_tests { fn should_reuse_changed() { let grids_a = vec![baseline_params()]; let mut pane2 = baseline_params(); - pane2.step = 160.0; + pane2.step_x = 160.0; let grids_b = vec![pane2]; let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 }; let old = GridKey::from_grids(&grids_a, bounds, GridStyle::default()); let new = GridKey::from_grids(&grids_b, bounds, GridStyle::default()); assert!(!should_reuse(Some(&old), &new)); } + + /// Adaptive steps grow from the GRIDUNIT base (the old hardcoded 1.0 base + /// ignored the DSettings grid spacing). Fixed mode returns the base verbatim. + #[test] + fn grid_steps_follow_base_and_adaptive_flag() { + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1280.0, height: 720.0 }; + // Far zoom: 10-unit base must scale up to stay readable. + let (sx, sy) = compute_grid_steps(10.0, 10.0, 5000.0, 0.6, bounds, true); + assert!(sx >= 10.0 && sy >= 10.0); + assert_eq!((sx, sy), (sx.max(10.0), sy.max(10.0))); + // Non-uniform bases stay non-uniform. + let (nx, ny) = compute_grid_steps(10.0, 2.0, 5000.0, 0.6, bounds, true); + assert!(nx >= 10.0 && ny >= 2.0); + // Fixed mode returns exactly what was typed. + assert_eq!(compute_grid_steps(7.5, 2.5, 5000.0, 0.6, bounds, false), (7.5, 2.5)); + // Degenerate input sanitizes to 1.0 instead of emptying the grid. + assert_eq!(compute_grid_steps(0.0, -3.0, 5000.0, 0.6, bounds, false), (1.0, 1.0)); + } + + /// Overflow fallback keeps each axis on its own base: an extreme zoom-out + /// that pushes the 5x growth past 1e9 must return (bx, by), never (bx, bx). + #[test] + fn grid_steps_overflow_falls_back_per_axis() { + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1280.0, height: 720.0 }; + assert_eq!( + compute_grid_steps(10.0, 2.0, 1e15, 0.6, bounds, true), + (10.0, 2.0) + ); + } + + /// Non-uniform steps still produce grid geometry on both families. + #[test] + fn grid_segments_support_non_square_spacing() { + // Top-down orthographic-ish view over the origin: deterministic lines. + let view_rot = glam::camera::rh::proj::directx::orthographic( + -400.0, 400.0, -300.0, 300.0, 0.1, 2000.0, + ) * glam::camera::rh::view::look_at_mat4( + Vec3::new(0.0, 0.0, 500.0), + Vec3::ZERO, + Vec3::Y, + ); + let eye = glam::DVec3::new(0.0, 0.0, 500.0); + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 800.0, height: 600.0 }; + let g = grid_segments( + view_rot, eye, bounds, 10.0, 2.0, + glam::DVec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None, + ); + assert!(!g.segments.is_empty(), "grid lines expected"); + } } #[cfg(test)] @@ -3886,7 +3991,6 @@ mod grid_canvas_state_tests { let view_rot = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05); let eye = glam::DVec3::new(4.0, 3.5, 9.0); let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1280.0, height: 720.0 }; - let step = 80.0_f32; let grid_origin = glam::DVec3::new(0.0, 0.0, 0.0); let grid_axes = (Vec3::X, Vec3::Y, Vec3::Z); let limits: Option<(glam::DVec2, glam::DVec2)> = None; @@ -3895,7 +3999,8 @@ mod grid_canvas_state_tests { view_rot, eye, bounds, - step, + step_x: 80.0, + step_y: 80.0, origin: grid_origin, axes: grid_axes, limits,