diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 3684244e..1dc0acaa 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -349,6 +349,20 @@ pub fn scene_with_section(s: &Session, cam: &Camera, section: Option<(Vec3, Vec3 scene_with_guides(s, cam, section, Guides::default()) } +/// The plane of the sketch that is open, if any: it always shows a grid, whatever the Grid switch +/// says (outside a sketch the grid is the user's choice). +pub fn open_sketch_plane(s: &Session) -> Option { + let id = s.active_sketch?; + s.world_state().sketch(id).map(|ss| ss.plane) +} + +/// The grid on a sketch plane: its centre (the view target's foot on the plane, snapped to the +/// major step in the plane's own axes) and the plane's x and y directions. +pub fn sketch_grid_frame(plane: &Plane, target: Vec3, major: f64) -> (Vec3, Vec3, Vec3) { + let snap = |d: Vec3| d * (((target - plane.origin).dot(d) / major).round() * major); + (plane.origin + snap(plane.x) + snap(plane.y), plane.x, plane.y) +} + /// What a headless render draws besides the model: the ground grid and the origin's axes. /// Both are on by default; the grid lies on Z 0, so it cuts through a model that straddles it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -434,12 +448,17 @@ fn build_scene( if guides.grid { let (minor, major) = grid_step(cam.half_height()); let ext = (cam.half_height() * 3.0 / major).ceil() * major; - let tf = cam.to_frame(cam.target); - // Headless pictures keep the grid on the ground, the viewport's default. - let (u, v) = (Vec3::X, Vec3::Y); - let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major); - let c = snap(u) + snap(v); - let w = |p: Vec3| cam.from_frame(p); + // 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, w): (Vec3, Vec3, Vec3, Box Vec3>) = if let Some(pl) = open_sketch_plane(s) { + let (c, u, v) = sketch_grid_frame(&pl, cam.target, major); + (c, u, v, Box::new(|p| p)) + } else { + let tf = cam.to_frame(cam.target); + let (u, v) = (Vec3::X, Vec3::Y); + let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major); + (snap(u) + snap(v), u, v, Box::new(|p| cam.from_frame(p))) + }; let n = ((ext / minor) as i64).clamp(1, 400); for i in -n..=n { let t = i as f64 * minor; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 0ed0b48f..8647e0d0 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -208,7 +208,7 @@ pub(crate) fn view_scene(app: &SolveApp, guides: solidcraft_engine::view::Guides /// The grid and the origin's axes as the window shows them (the Grid and Origin switches). pub(crate) fn window_guides(app: &SolveApp) -> solidcraft_engine::view::Guides { - solidcraft_engine::view::Guides { grid: app.ui.show_grid && !crate::viewport::active_view(app).hide_grid, axes: app.origin_visible() } + solidcraft_engine::view::Guides { grid: app.session.active_sketch.is_some() || (app.ui.show_grid && !crate::viewport::active_view(app).hide_grid), axes: app.origin_visible() } } /// A picture of the view as the window shows it, RGBA, drawn on the GPU with the viewport's diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index a3099b9c..5bbd5497 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -733,33 +733,39 @@ fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene { let s = &app.session; let st = s.world_state(); let mut fill_budget = 131_072; - if app.ui.show_grid && !active_view(app).hide_grid { + let sketch_plane = solidcraft_engine::view::open_sketch_plane(s); + if sketch_plane.is_some() || (app.ui.show_grid && !active_view(app).hide_grid) { let (minor, major) = grid_step(app.cam.half_height()); let ext = (app.cam.half_height() * 3.0 / major).ceil() * major; - // 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 seen edge-on. - let tf = app.cam.to_frame(app.cam.target); - // On the ground (XY) unless the Grid follows the view preference is on (#1470); then on - // the plane the last frame drew, so the choice is hysteretic near the thresholds (#1993). - let (u, v) = if app.preferences.grid_follows_view { - app.viewport.grid_plane.lock().map_or((Vec3::X, Vec3::Y), |mut last| { - *last = grid_plane(app.cam.to_frame(app.cam.back()), *last); - last.axes() - }) + // An open sketch always has its grid on its own plane; 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 seen edge-on. + let (c, u, v, w3): (Vec3, Vec3, Vec3, Box [f32; 3]>) = if let Some(pl) = &sketch_plane { + let (c, u, v) = solidcraft_engine::view::sketch_grid_frame(pl, app.cam.target, major); + (c, u, v, Box::new(|p| p.to_f32())) } else { - (Vec3::X, Vec3::Y) + let tf = app.cam.to_frame(app.cam.target); + // On the ground (XY) unless the Grid follows the view preference is on (#1470); then on + // the plane the last frame drew, so the choice is hysteretic near the thresholds (#1993). + let (u, v) = if app.preferences.grid_follows_view { + app.viewport.grid_plane.lock().map_or((Vec3::X, Vec3::Y), |mut last| { + *last = grid_plane(app.cam.to_frame(app.cam.back()), *last); + last.axes() + }) + } else { + (Vec3::X, Vec3::Y) + }; + let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major); + (snap(u) + snap(v), u, v, Box::new(|p| app.cam.from_frame(p).to_f32())) }; - let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major); - let c = snap(u) + snap(v); - let w3 = |p: Vec3| app.cam.from_frame(p).to_f32(); let n = ((ext / minor) as i64).clamp(1, 300); for i in -n..=n { let t = i as f64 * minor; let maj = (t / major).round() * major == t; let col = if maj { c4(tk.grid_major) } else { c4(tk.grid) }; - let w = if maj { 1.0 } else { 0.7 }; - sc.under_line(w3(c + u * t - v * ext), w3(c + u * t + v * ext), col, w); - sc.under_line(w3(c - u * ext + v * t), w3(c + u * ext + v * t), col, w); + let wd = if maj { 1.0 } else { 0.7 }; + sc.under_line(w3(c + u * t - v * ext), w3(c + u * t + v * ext), col, wd); + sc.under_line(w3(c - u * ext + v * t), w3(c + u * ext + v * t), col, wd); } } for sid in visible_sketches(app) { @@ -6188,6 +6194,28 @@ mod grid_plane_tests { assert_eq!(*app.viewport.grid_plane.lock().unwrap(), GridPlane::Ground, "a clear turn off the ground returns"); } + /// An open sketch always has its grid, on its own plane, whatever the Grid switch and the + /// follow-the-view preference say; outside a sketch the switch decides. + #[test] + fn an_open_sketch_always_shows_a_grid_on_its_plane() { + let rect = Rect::from_min_size(Pos2::ZERO, egui::vec2(800.0, 600.0)); + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.ui.show_grid = false; + let grid = |app: &SolveApp| build_sketch_scene(app, &projection(app, rect)).under; + assert!(grid(&app).is_empty(), "no grid outside a sketch with the switch off"); + app.run("sketch.create", json!({"plane": "XZ"})).unwrap(); + for follows in [false, true] { + app.preferences.grid_follows_view = follows; + let under = grid(&app); + assert!(!under.is_empty(), "the open sketch shows a grid (follows the view: {follows})"); + // Each line is 32 bytes: two f32 points, a colour and a width; the XZ plane is Y = 0. + let ys: Vec = under.chunks_exact(32).flat_map(|l| [&l[4..8], &l[16..20]]).map(|b| f32::from_le_bytes(b.try_into().unwrap())).collect(); + assert!(ys.iter().all(|y| y.abs() < 1e-3), "the grid lies on the sketch plane"); + } + app.ui.show_grid = true; + assert!(!grid(&app).is_empty()); + } + /// A sketch is drawn in a parallel view even when the preference asks for perspective; the /// preference comes back when the sketch is finished. #[test]