//! The 3D viewport: navigation, GPU (or CPU fallback) rendering, picking, overlays, the axis //! view and the navigation bar. use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2}; use serde_json::json; use solidcraft_engine::Sel; use solidcraft_engine::geom::{Vec2, Vec3}; use solidcraft_engine::render::{Camera, Mat4, Rgb}; use solidcraft_engine::sketch::CurveKind; use solidcraft_engine::view::{colors, grid_step, sketch_lines_with_sampler}; use std::hash::{Hash, Hasher}; use crate::gpu::{GpuScene, GpuTarget, SceneSlot, ViewportCallback}; use crate::selection::{BoxSel, origin_quads, origin_size, ray_quad}; use crate::theme::Tokens; use crate::{SolveApp, icons}; #[cfg(test)] #[path = "viewport_appearance_tests.rs"] mod appearance_tests; #[cfg(test)] #[path = "viewport_plane_target_tests.rs"] mod plane_target_tests; #[path = "navigation_cube.rs"] mod navigation_cube; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NavMode { Orbit, Pan, Zoom, } #[derive(Default)] pub struct ViewportState { /// Per-body boxes of one world state, held (not just its address, which a later state can /// reuse once the old one is freed, so stale boxes would come back). pick_bounds: std::sync::Mutex>, /// The last click in the view was the middle button's (for the middle double-click). middle_clicked: bool, /// The last pick (its key and hits): reused while nothing it depends on changed. last_pick: Option<(u64, Vec)>, pub rect: Option, pub(crate) layout: Option, pub(crate) palette_rect: Option, pub(crate) dialog_rect: Option, pub gpu: Option, slot: SceneSlot, key: u64, sketch_slot: SceneSlot, sketch_key: u64, physical_scale: f32, pub(crate) display: std::sync::Mutex, hl_slot: SceneSlot, hl_key: u64, cpu: Option<(u64, egui::TextureHandle)>, /// Nothing will look at the frame (the headless scenario harness): skip the software render. pub no_pixels: bool, pub nav: Option, /// The preset's navigation for the left-button drag under way (Alias: Shift+Alt), taken /// when the button went down, so the drag neither selects nor moves sketch geometry. pub primary_nav: Option, /// What a click would pick now (after the active command's filter). pub hover: Option, pub mouse: Option, /// Box selection being dragged. pub boxsel: Option, /// Timeline item under the cursor: its bodies or resulting faces are highlighted. pub hover_feature: Option, /// Bodies whose browser row is under the cursor: highlighted the same way. pub hover_bodies: Vec, /// The model point an orbit turns around (Preferences: orbit around the cursor). pub orbit_pivot: Option, /// Continuous orbit: the view turns about the vertical until the next click or key. pub spin: bool, /// After immediate zoom, ignore egui's outstanding smoothing until the gesture ends. wheel_zoom_raw_until_idle: bool, /// While trackpad scrolling runs, ignore egui's smoothing tail for zoom so /// two-finger input is not replayed as zoom after it pans. trackpad_until_idle: bool, /// Inside a bracketed trackpad scroll (`Start` without its `End` yet): /// point moves pan, bare moves are a mouse. trackpad_gesture: bool, /// A primary press on the axis gizmo: the pointer position used by its last orbit step. pub axis_drag: Option, /// Unsnapped camera while Shift-orbit previews a standard or diagonal view. pub orbit_free: Option, /// Spring toward the chosen orientation, including after the drag ends. pub(crate) orbit_snap: Option, pub build_ms: f64, /// The right-click menu, open at this screen position. pub context_menu: Option, /// Select Other: its list, a press being held, and click cycling. pub select_other: crate::select_other::State, /// A sketch point or curve being dragged (its id, the undo depth when the drag began: the /// whole drag is one undo step, and for a curve where it was last under the cursor). pub point_drag: Option<(String, usize, Option)>, /// Checkpoint and source for a cancellable whole-text drag. pub(crate) text_drag: Option<(u64, String)>, } /// Something under the cursor. #[derive(Clone, Debug, PartialEq)] pub enum Hit { SketchPoint { sketch: u64, id: String, at: Vec2, }, /// A sketch curve; `straight` for lines (usable as an axis). SketchCurve { sketch: u64, id: String, straight: bool, }, Vertex { body: String, point: Vec3, }, Edge { body: String, index: usize, mid: Vec3, }, Profile { sketch: u64, index: usize, }, Face { body: String, index: usize, point: Vec3, }, /// An origin plane (XY, XZ, YZ) or a construction plane (by name). Plane { name: String, point: Vec3, }, /// An origin axis (X, Y, Z). Axis { name: String, }, } impl ViewportState { pub fn pixels_per_point(&self) -> f32 { if self.physical_scale > 0.0 { self.physical_scale } else { 1.0 } } #[cfg(test)] pub(crate) fn set_pixels_per_point(&mut self, scale: f32) { self.physical_scale = scale; } } fn display_proj(app: &SolveApp) -> Proj { projection(app, app.viewport.rect.unwrap_or_else(|| Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)))) } /// World ↔ screen mapping for the current frame. #[derive(Clone, Copy)] pub struct Proj { pub cam: Camera, pub vp: Mat4, pub rect: Rect, } impl Proj { pub fn to_screen(&self, p: Vec3) -> Option { let c = self.vp.apply(p); if c[3] <= 1e-9 { return None; } let (x, y) = (c[0] / c[3], c[1] / c[3]); Some(pos2( self.rect.left() + ((x + 1.0) * 0.5 * self.rect.width() as f64) as f32, self.rect.top() + ((1.0 - y) * 0.5 * self.rect.height() as f64) as f32, )) } pub fn ray(&self, p: Pos2) -> (Vec3, Vec3) { self.cam.ray((p.x - self.rect.left()) as f64, (p.y - self.rect.top()) as f64, self.rect.width() as f64, self.rect.height() as f64) } } /// A projected 3D face (convex): an unfeathered triangle fan, outlined one edge at a time (no /// joins between the edges). Unlike `Shape::convex_polygon`, it stays inside its outline when /// the face turns edge-on. pub(crate) fn projected_polygon(pts: &[Pos2], fill: Color32, stroke: Stroke) -> Vec { let mut mesh = egui::Mesh::default(); for (i, p) in (0u32..).zip(pts) { mesh.colored_vertex(*p, fill); if i >= 2 { mesh.add_triangle(0, i - 1, i); } } let mut out = vec![Shape::mesh(mesh)]; out.extend(pts.iter().zip(pts.iter().skip(1).chain(pts.first())).map(|(a, b)| Shape::line_segment([*a, *b], stroke))); out } /// View directions all round, a few degrees apart, none exactly along an axis. #[cfg(test)] pub(crate) fn orbit_directions() -> impl Iterator { (0..90).flat_map(|i| { (0..45).map(move |j| { let (yaw, pitch) = (f64::from(i) * 4.0f64.to_radians() + 0.003, f64::from(j) * 4.0f64.to_radians() - 1.5); Vec3::new(pitch.cos() * yaw.cos(), pitch.cos() * yaw.sin(), pitch.sin()) }) }) } /// Every vertex egui draws for `shapes`. #[cfg(test)] pub(crate) fn drawn_points(shapes: Vec) -> Vec { let mut tess = egui::epaint::Tessellator::new(1.0, egui::epaint::TessellationOptions::default(), [1, 1], vec![]); let mut mesh = egui::Mesh::default(); for shape in shapes { tess.tessellate_shape(shape, &mut mesh); } mesh.vertices.iter().map(|v| v.pos).collect() } fn rgba(c: Rgb) -> [u8; 4] { [c.0, c.1, c.2, 255] } pub fn scene_radius(app: &SolveApp) -> f64 { let mut b = model_bounds(app); if !app.preview.bounds.is_empty() { b = b.union(&app.preview.bounds); } let r = if b.is_empty() { 50.0 } else { b.diagonal() * 0.5 + b.center().dist(app.cam.target) }; r.max(app.cam.half_height() * 2.0).max(10.0) } pub fn projection(app: &SolveApp, rect: Rect) -> Proj { let mut cam = app.cam; cam.fov = if app.ui.perspective { 0.6 } else { 0.0 }; let aspect = rect.width() as f64 / rect.height().max(1.0) as f64; Proj { cam, vp: cam.view_proj(aspect, scene_radius(app)), rect } } /// Sketches drawn in the viewport: the active one, and finished ones not yet used by a feature. fn visible_sketches(app: &SolveApp) -> Vec { let st = app.session.world_state(); st.sketches.iter().filter(|s| solidcraft_engine::view::sketch_drawn(&app.session, s.feature, app.ui.show_sketches)).map(|s| s.feature).collect() } /// What Home, Fit and the orbit pivot frame: the bodies and sketches shown now, where they are /// now (see [`solidcraft_engine::view::visible_bounds`]). pub fn view_bounds(app: &SolveApp) -> solidcraft_engine::geom::Aabb3 { solidcraft_engine::view::visible_bounds(&app.session, app.ui.show_sketches) } fn scene_key(app: &SolveApp) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); app.session.revision.hash(&mut h); (std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h); app.ui.show_grid.hash(&mut h); app.ui.visual_style.hash(&mut h); app.ui.ground_shadow.hash(&mut h); // The section plane (its cut outlines are part of the model scene). section_plane(app).map(|(o, n)| [o.x, o.y, o.z, n.x, n.y, n.z].map(f64::to_bits)).hash(&mut h); app.cam.up.hash(&mut h); (app.cam.pitch > 0.0).hash(&mut h); app.ui.show_sketches.hash(&mut h); app.session.visibility.hidden_bodies.hash(&mut h); app.session.visibility.hidden_occurrences.hash(&mut h); app.session.visibility.hidden_sketches.hash(&mut h); app.session.visibility.shown_sketches.hash(&mut h); app.ui.hidden_profiles.hash(&mut h); app.session.active_component.hash(&mut h); app.session.active_occurrence_path.hash(&mut h); app.preview.replaced.hash(&mut h); app.tree.redefine.hash(&mut h); crate::theme::is_dark().hash(&mut h); app.session.active_sketch.hash(&mut h); h.finish() } fn sketch_scene_key(app: &SolveApp, proj: &Proj) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); scene_key(app).hash(&mut h); (std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h); crate::sketch_display::projection_key(proj, app.viewport.pixels_per_point()).hash(&mut h); h.finish() } #[cfg(test)] pub(crate) fn display_scene_keys(app: &SolveApp, proj: &Proj) -> (u64, u64) { (scene_key(app), sketch_scene_key(app, proj)) } /// A sketch curve colour from the engine, in the current theme. fn sketch_color(t: &Tokens, c: Rgb) -> [u8; 4] { if c == colors::SKETCH { c4(t.sketch) } else if c == colors::SKETCH_DONE || c == colors::SKETCH_FIXED { c4(t.sketch_fixed) } else if c == colors::SKETCH_LOCKED { c4(t.sketch_locked) } else { rgba(c) } } fn build_scene(app: &SolveApp) -> GpuScene { let tk = Tokens::get(); let mut sc = GpuScene::default(); let s = &app.session; // Bodies where their occurrences place them. let st = s.world_state(); let active_bodies = s.active_occurrence_path.as_ref().and_then(|p| s.occurrence_bodies(p, true).ok()); for b in &st.bodies { if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) { continue; } // With a component active, bodies outside it are drawn faded. let faded = if let Some(names) = &active_bodies { !names.contains(&b.name) } else { s.active_component != 0 && !s.doc.component_within(b.component, s.active_component) }; let base = body_color(&s.doc, b).unwrap_or(tk.body); let col = if faded { c4(base.gamma_multiply(0.35)) } else { c4(base) }; let m = b.mesh(); let (shaded, edges) = match app.ui.visual_style { 1 => (true, false), 2 => (false, true), _ => (true, true), }; let edge_col = if shaded { c4(tk.body_edge) } else { c4(tk.text) }; // Shaded with hidden edges: edges behind the model show faintly. let hidden_edges = app.ui.visual_style == 3; let hidden_col = c4(tk.text.gamma_multiply(0.35)); // Appearances: the body's opacity, and faces with looks of their own. let opacity = s.doc.model_body_opacity(b); let face_looks: std::collections::HashMap = if shaded { s.doc.face_colors(b).into_iter().collect() } else { Default::default() }; // Opaque bodies are drawn from their display plans on the GPU where the device evaluates // them; faded and see-through ones (sorted and blended per triangle) are built here. let opaque = !faded && opacity >= 0.999 && face_looks.values().all(|l| l.opacity >= 0.999); let planned = (shaded && opaque && app.viewport.gpu.is_some_and(|g| g.tess)) .then(|| b.display_plan()) .flatten() .filter(|plan| plan.vertex_count() <= solidcraft_gpu::MAX_BATCH); let on_gpu = planned.is_some(); if let Some(plan) = planned { let face_colors = (0..b.body.face_count()) .map(|f| match face_looks.get(&f) { Some(l) => c4(Color32::from_rgb(l.color[0], l.color[1], l.color[2])), None => col, }) .collect(); sc.planned.push(crate::gpu::PlannedBody { plan, face_colors }); } for (ti, t) in m.triangles.iter().enumerate().filter(|_| shaded && !on_gpu) { let (mut col, a) = match m.tri_face.get(ti).and_then(|f| face_looks.get(&(*f as usize))) { Some(l) => { let c = Color32::from_rgb(l.color[0], l.color[1], l.color[2]); (if faded { c4(c.gamma_multiply(0.35)) } else { c4(c) }, l.opacity) } None => (col, opacity), }; let glass = !faded && a < 0.999; if glass { col[3] = (a.clamp(0.05, 1.0) * 255.0).round() as u8; } for k in t { let i = *k as usize; if let (Some(p), Some(n)) = (m.positions.get(i), m.normals.get(i)) { if faded { sc.trans_tri(p.to_f32(), n.to_f32(), col); } else if glass { sc.glass_tri(p.to_f32(), n.to_f32(), col); } else { sc.tri(p.to_f32(), n.to_f32(), col); } } } } for (ei, e) in m.edges.iter().enumerate().filter(|_| edges) { if !m.edge_pickable(ei) || (shaded && !m.shaded_edge_visible(ei)) { continue; } for w in e.windows(2) { sc.line(w[0].to_f32(), w[1].to_f32(), edge_col, 1.3, false); if hidden_edges { sc.hidden_line(w[0].to_f32(), w[1].to_f32(), hidden_col, 1.0); } } } } if app.ui.ground_shadow && app.ui.visual_style != 2 && app.cam.pitch > 0.0 { ground_shadow(&mut sc, app, &st); } // Section: the outline of each body where the plane cuts it. if let Some((o, n)) = section_plane(app) { let col = c4(tk.text.gamma_multiply(0.9)); for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name)) { for (a, c) in solidcraft_engine::view::section_segments(&b.mesh(), o, n) { sc.line(a.to_f32(), c.to_f32(), col, 1.8, false); } } } sc } /// A soft dark ellipse on the ground under the shown bodies (darkest in the middle). fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene { let tk = Tokens::get(); let mut sc = GpuScene::default(); 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 (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). let tf = app.cam.to_frame(app.cam.target); let c = Vec3::new((tf.x / major).round() * major, (tf.y / major).round() * major, 0.0); 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 + Vec3::new(t, -ext, 0.0)), w3(c + Vec3::new(t, ext, 0.0)), col, w); sc.under_line(w3(c + Vec3::new(-ext, t, 0.0)), w3(c + Vec3::new(ext, t, 0.0)), col, w); } } for sid in visible_sketches(app) { let Some(ss) = st.sketch(sid) else { continue }; let active = s.active_sketch == Some(sid); // Closed profiles are shaded (lifted off a face they may lie on). let lift = ss.plane.normal() * 0.01; let profiles = if app.ui.hidden_profiles.contains(&sid) || ss.sketch.view.hide_profile { &[][..] } else { &ss.profiles[..] }; for (index, _) in profiles.iter().enumerate() { let display = crate::sketch_display::profile_region(app, sid, index, &ss.plane, proj); for tri in display.as_ref().as_ref().map_or_else(Vec::new, |region| crate::sketch_display::fill_triangles(region, &mut fill_budget)) { for q in tri { sc.trans_tri((ss.plane.to_world(q) + lift).to_f32(), [0.0; 3], c4(tk.profile_fill)); } } } // Construction curves are dashed, centerlines dash-dot (lengths follow the grid). let unit = grid_step(app.cam.half_height()).0; for (pts, col, cons) in sketch_lines_with_sampler(&ss.sketch, &ss.plane, active, &ss.report.curve_determined, |_, index, plane| { crate::sketch_display::curve_sample(app, sid, index, plane, proj).parts.clone() }) { let segs = sketch_segments( &pts, if !cons { 0 } else if col == colors::SKETCH_CENTERLINE { 2 } else { 1 }, unit, ); for (a, b) in segs { sc.line(a.to_f32(), b.to_f32(), sketch_color(&tk, col), if cons { 1.2 } else { 2.0 }, active); } } } let guide_unit = grid_step(app.cam.half_height()).0; for points in crate::sketch_attachment_ui::display_guide_lines(app, proj) { for (a, b) in dashes(&points, &[guide_unit * 0.25, guide_unit * 0.15]) { sc.line(a.to_f32(), b.to_f32(), c4(tk.warning), 1.5, true); } } sc } fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solidcraft_engine::doc::ModelState) { // The bodies' box in the camera's Z-up frame (the ground is its XY plane). let mut b = solidcraft_engine::geom::Aabb3::EMPTY; for x in st.bodies.iter().filter(|x| app.session.body_visible(&x.name)) { let bb = x.mesh().bounds(); if bb.is_empty() { continue; } for i in 0..8 { let p = Vec3::new( if i & 1 == 0 { bb.min.x } else { bb.max.x }, if i & 2 == 0 { bb.min.y } else { bb.max.y }, if i & 4 == 0 { bb.min.z } else { bb.max.z }, ); b.add(app.cam.to_frame(p)); } } if b.is_empty() { return; } let size = b.size(); let c = b.center(); let z = b.min.z - 1e-3 * (1.0 + b.diagonal()); let (rx, ry) = (size.x * 0.62 + size.z * 0.15, size.y * 0.62 + size.z * 0.15); if !(rx > 0.0 && ry > 0.0) { return; } let dark = if crate::theme::is_dark() { 150 } else { 80 }; // Rings from the middle out, fading to nothing. let rings = [(0.0, dark), (0.55, dark * 3 / 4), (0.85, dark / 3), (1.0, 0)]; let n = 48; let at = |r: f64, k: usize| { let a = k as f64 / n as f64 * std::f64::consts::TAU; app.cam.from_frame(Vec3::new(c.x + rx * r * a.cos(), c.y + ry * r * a.sin(), z)).to_f32() }; for w in rings.windows(2) { let ((r0, a0), (r1, a1)) = (w[0], w[1]); let (c0, c1) = ([0, 0, 0, a0 as u8], [0, 0, 0, a1 as u8]); for k in 0..n { let (p00, p01, p10, p11) = (at(r0, k), at(r0, k + 1), at(r1, k), at(r1, k + 1)); sc.trans_tri(p00, [0.0; 3], c0); sc.trans_tri(p10, [0.0; 3], c1); sc.trans_tri(p11, [0.0; 3], c1); if r0 > 0.0 { sc.trans_tri(p00, [0.0; 3], c0); sc.trans_tri(p11, [0.0; 3], c1); sc.trans_tri(p01, [0.0; 3], c0); } } } } /// Sketch line types share the same world-space dash phase in the scene and overlays. pub(crate) fn sketch_segments(pts: &[Vec3], mode: u8, unit: f64) -> Vec<(Vec3, Vec3)> { match mode { 1 => dashes(pts, &[0.3 * unit, 0.2 * unit]), 2 => dashes(pts, &[0.6 * unit, 0.15 * unit, 0.05 * unit, 0.15 * unit]), _ => pts.windows(2).map(|w| (w[0], w[1])).collect(), } } /// The drawn pieces of a polyline in a dash pattern (on, off, on, off… lengths). pub(crate) fn dashes(pts: &[Vec3], pattern: &[f64]) -> Vec<(Vec3, Vec3)> { let mut out = Vec::new(); if pattern.is_empty() || pattern.iter().any(|l| !(l.is_finite() && *l > 0.0)) { return pts.windows(2).map(|w| (w[0], w[1])).collect(); } let (mut k, mut left) = (0usize, pattern[0]); for w in pts.windows(2) { let (mut a, b) = (w[0], w[1]); let mut len = (b - a).len(); // Cap the pieces per segment so a huge zoom-out can't explode the scene. let mut guard = 0; while len > 1e-12 && guard < 10_000 { guard += 1; let step = left.min(len); let q = a + (b - a) * (step / len); if k % 2 == 0 { out.push((a, q)); } left -= step; len -= step; a = q; if left <= 1e-12 { k = (k + 1) % pattern.len(); left = pattern.get(k).copied().unwrap_or(1.0); } } } out } /// Colour as straight (unpremultiplied) sRGBA bytes for the GPU. fn c4(c: Color32) -> [u8; 4] { c.to_srgba_unmultiplied() } /// Origin planes and axes currently shown, sized for the view: (name, normal, quad). pub fn origin_planes(app: &SolveApp) -> Vec<(&'static str, Vec3, [Vec3; 4])> { if !app.origin_visible() { return Vec::new(); } // While the Origin is hidden, Create Sketch may bring the planes back (empty design): then // every plane; otherwise only those not hidden one by one. let creating = !app.ui.show_origin && app.dialog.as_ref().is_some_and(|d| matches!(d.kind, crate::dialogs::Kind::Sketch)); origin_quads(origin_size(app.cam.half_height())) .into_iter() .filter(|(n, _, _)| creating || !app.session.visibility.hidden_origin.iter().any(|h| h == n)) .collect() } /// A point on origin plane `name`'s square that a click at its screen point picks: the middle, /// or else the middle of a quarter the other planes don't cover from this view (in an iso view /// the XZ square hides the middle of the XY one). The middle when no candidate is picked. pub fn origin_plane_target(app: &SolveApp, proj: &Proj, name: &str) -> Option { let (_, _, q) = origin_planes(app).into_iter().find(|(n, _, _)| *n == name)?; let mid = (q[0] + q[1] + q[2] + q[3]) / 4.0; let picked = |w: &Vec3| proj.to_screen(*w).is_some_and(|p| matches!(pick(app, proj, p).first(), Some(Hit::Plane { name: n, .. }) if n == name)); Some(std::iter::once(mid).chain(q.iter().map(|c| (mid + *c) / 2.0)).find(picked).unwrap_or(mid)) } pub fn origin_axes(app: &SolveApp) -> Vec<(&'static str, Vec3)> { if !app.origin_visible() { return Vec::new(); } [("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)] .into_iter() .filter(|(n, _)| !app.session.visibility.hidden_origin.iter().any(|h| h == n)) .collect() } /// Construction axes shown, as segments: across the model where the axis passes it (a little /// past it either side), at least the origin axes' length around the axis's origin: (name, /// from, to). pub fn construction_axis_segments(app: &SolveApp) -> Vec<(String, Vec3, Vec3)> { let h = origin_size(app.cam.half_height()) * AXIS_LEN; let bb = view_bounds(app); solidcraft_engine::view::construction_axes(&app.session) .into_iter() .filter(|(_, n, _, _)| !app.session.visibility.hidden_origin.contains(n)) .map(|(_, name, o, d)| { let (mut lo, mut hi) = (-h, h); if !bb.is_empty() { for i in 0..8 { let c = Vec3::new( if i & 1 == 0 { bb.min.x } else { bb.max.x }, if i & 2 == 0 { bb.min.y } else { bb.max.y }, if i & 4 == 0 { bb.min.z } else { bb.max.z }, ); let t = (c - o).dot(d); lo = lo.min(t - h * 0.3); hi = hi.max(t + h * 0.3); } } (name, o + d * lo, o + d * hi) }) .collect() } /// Construction points shown: (name, position). pub fn construction_points(app: &SolveApp) -> Vec<(String, Vec3)> { solidcraft_engine::view::construction_points(&app.session) .into_iter() .filter(|(_, n, _)| !app.session.visibility.hidden_origin.contains(n)) .map(|(_, n, p)| (n, p)) .collect() } /// Construction planes drawn as squares around their origin: (name, normal, quad). pub fn construction_quads(app: &SolveApp) -> Vec<(String, Vec3, [Vec3; 4])> { let h = origin_size(app.cam.half_height()) * 1.2; solidcraft_engine::view::construction_planes(&app.session) .into_iter() .filter(|(_, n, _)| !app.session.visibility.hidden_origin.contains(n)) .map(|(_, name, pl)| { let (o, x, y) = (pl.origin, pl.x * h, pl.y * h); (name, pl.normal(), [o - x - y, o + x - y, o + x + y, o - x + y]) }) .collect() } /// Pick what is under `pos`: sketch points, sketch curves, vertices, edges and axes first, then /// the surfaces along the ray (profiles, faces, planes) nearest first. /// Bounding boxes of the shown model's bodies (cached until the model changes). fn body_bounds( app: &SolveApp, st: &std::sync::Arc, ) -> std::sync::Arc> { if let Ok(mut g) = app.viewport.pick_bounds.lock() { if let Some((k, b)) = g.as_ref() && std::sync::Arc::ptr_eq(k, st) { return b.clone(); } let b = std::sync::Arc::new(st.bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect::>()); *g = Some((st.clone(), b.clone())); return b; } std::sync::Arc::new(Vec::new()) } type BodyBounds = (std::sync::Arc, std::sync::Arc>); /// The model's extent: bodies (from the cached boxes) and sketches. pub fn model_bounds(app: &SolveApp) -> solidcraft_engine::geom::Aabb3 { let st = app.session.world_state(); let mut b = body_bounds(app, &st).iter().fold(solidcraft_engine::geom::Aabb3::EMPTY, |a, (_, x)| a.union(x)); for ss in &st.sketches { if let Some((lo, hi)) = ss.sketch.bounds() { for p in [lo, hi] { b.add(ss.plane.to_world(p)); } } } for point in crate::sketch_attachment_ui::guide_lines(app).into_iter().flatten() { b.add(point); } b } /// Where a ray enters a box (None: it misses). fn ray_box(o: Vec3, d: Vec3, b: &solidcraft_engine::geom::Aabb3, pad: f64) -> Option { let (mut t0, mut t1) = (f64::NEG_INFINITY, f64::INFINITY); for (oo, dd, lo, hi) in [(o.x, d.x, b.min.x, b.max.x), (o.y, d.y, b.min.y, b.max.y), (o.z, d.z, b.min.z, b.max.z)] { let (lo, hi) = (lo - pad, hi + pad); if dd.abs() < 1e-15 { if oo < lo || oo > hi { return None; } } else { let (a, c) = ((lo - oo) / dd, (hi - oo) / dd); t0 = t0.max(a.min(c)); t1 = t1.min(a.max(c)); } } (t0 <= t1).then_some(t0) } /// A box's outline on screen, grown by `px` (None: behind the eye). fn screen_box(proj: &Proj, b: &solidcraft_engine::geom::Aabb3, px: f32) -> Option { let mut r = Rect::NOTHING; for i in 0..8 { let p = Vec3::new( if i & 1 == 0 { b.min.x } else { b.max.x }, if i & 2 == 0 { b.min.y } else { b.max.y }, if i & 4 == 0 { b.min.z } else { b.max.z }, ); r.extend_with(proj.to_screen(p)?); } Some(r.expand(px)) } /// What is under `pos`, reusing the last answer while the cursor, the camera and the model are /// unchanged. fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec { let mut h = std::collections::hash_map::DefaultHasher::new(); pos.x.to_bits().hash(&mut h); pos.y.to_bits().hash(&mut h); for v in [app.cam.yaw, app.cam.pitch, app.cam.roll, app.cam.distance, app.cam.target.x, app.cam.target.y, app.cam.target.z] { v.to_bits().hash(&mut h); } app.session.revision.hash(&mut h); (std::sync::Arc::as_ptr(&app.session.world_state()) as usize).hash(&mut h); app.session.active_sketch.hash(&mut h); crate::sketch3d::drawing(app).hash(&mut h); app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).hash(&mut h); app.session.visibility.hidden_bodies.hash(&mut h); app.session.visibility.hidden_occurrences.hash(&mut h); app.ui.perspective.hash(&mut h); (proj.rect.width().to_bits(), proj.rect.height().to_bits()).hash(&mut h); crate::sketch_display::projection_key(proj, app.viewport.pixels_per_point()).hash(&mut h); let key = h.finish(); if let Some((k, hits)) = &app.viewport.last_pick && *k == key { return hits.clone(); } // Hover feedback mirrors what a 3D bridge click will snap to; clicks still go through `pick`. let hits = pick_with_sketch_points(app, proj, pos, crate::sketch3d::drawing(app)); app.viewport.last_pick = Some((key, hits.clone())); hits } /// What is under `pos`. Sketch points come from the active sketch only (from every shown sketch /// while a dialog input takes points, while idle, or while projecting), so sketch tools never get /// another sketch's local point and id. The 3D bridge snap asks for cross-sketch points explicitly with [`pick_3d`]. pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { pick_with_sketch_points(app, proj, pos, false) } /// Like [`pick`], but also returns points of every visible sketch: only for the 3D drawing snap, /// which converts them to world space through their own sketch's plane. pub fn pick_3d(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { pick_with_sketch_points(app, proj, pos, true) } /// Everything under `pos` (Select Other): like [`pick`], but every sketch point, sketch curve, /// vertex, edge, axis and profile near the cursor, and the nearest face of every body along the /// ray, instead of the nearest of each kind. Each kind is nearest first. pub fn pick_all(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { pick_hits(app, proj, pos, false, true) } fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool) -> Vec { pick_hits(app, proj, pos, other_sketch_points, false) } /// The same thing picked twice (one row per target in [`pick_all`]). pub fn same_target(a: &Hit, b: &Hit) -> bool { match (a, b) { (Hit::SketchPoint { sketch: s, id: i, .. }, Hit::SketchPoint { sketch: t, id: j, .. }) | (Hit::SketchCurve { sketch: s, id: i, .. }, Hit::SketchCurve { sketch: t, id: j, .. }) => s == t && i == j, (Hit::Vertex { body: a, point: p }, Hit::Vertex { body: b, point: q }) => a == b && p.dist(*q) < 1e-9, (Hit::Edge { body: a, index: i, .. }, Hit::Edge { body: b, index: j, .. }) | (Hit::Face { body: a, index: i, .. }, Hit::Face { body: b, index: j, .. }) => a == b && i == j, (Hit::Profile { sketch: s, index: i }, Hit::Profile { sketch: t, index: j }) => s == t && i == j, (Hit::Plane { name: a, .. }, Hit::Plane { name: b, .. }) | (Hit::Axis { name: a }, Hit::Axis { name: b }) => a == b, _ => false, } } /// The candidates of one kind: the nearest only, or with `all` every target (nearest first). struct Near { all: bool, v: Vec<(T, Hit)>, } impl Near { fn new(all: bool) -> Self { Near { all, v: Vec::new() } } /// Whether a candidate at `d` would be kept. fn wants(&self, d: T) -> bool { self.all || self.v.first().is_none_or(|(b, _)| d < *b) } fn offer(&mut self, d: T, h: Hit) { if !self.all { if self.wants(d) { self.v = vec![(d, h)]; } return; } match self.v.iter_mut().find(|(_, x)| same_target(x, &h)) { Some(e) if d < e.0 => *e = (d, h), Some(_) => {} None => self.v.push((d, h)), } } fn sorted(mut self) -> Vec<(T, Hit)> { self.v.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); self.v } fn hits(self) -> impl Iterator { self.sorted().into_iter().map(|x| x.1) } } fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool, all: bool) -> Vec { let s = &app.session; let st = s.world_state(); let bounds = body_bounds(app, &st); let bound = |name: &str| bounds.iter().find(|(n, _)| n == name).map(|(_, b)| *b); let mut hits = Vec::new(); let (o, d) = proj.ray(pos); // Sketch points of the active sketch (of every shown sketch while a command takes points, // while idle or projecting, and while 3D drawing snaps to points on other visible sketches). let wants_points = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| i.accept & crate::selection::POINTS != 0); let external_points = wants_points || other_sketch_points || (app.dialog.is_none() && app.tool.as_ref().is_none_or(|tool| tool.cmd == "sketch.project")); let point_sketches: Vec = if external_points { visible_sketches(app) } else { s.active_sketch.into_iter().collect() }; // A face place (the Single hole's) snaps only to points their sketches show, so a nearer // point that isn't shown (a letter's outline) neither takes the pick nor hides a shown one. let snapping = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| crate::dialogs::snaps_to_points(i.accept)); let mut best = Near::new(all); for ss in point_sketches.iter().filter_map(|sid| st.sketch(*sid)) { let sid = ss.feature; let shown = snapping.then(|| crate::dialogs::SnapPoints::of(&ss.sketch)); let visible = solidcraft_engine::view::SketchPointVisibility::new(&ss.sketch); for p in &ss.sketch.points { if !visible.takes(p) || shown.as_ref().is_some_and(|f| !f.takes(p)) { continue; } if let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) { let dd = sp.distance(pos) - if s.active_sketch == Some(sid) { 2.0 } else { 0.0 }; if dd < 8.0 && best.wants(dd) { best.offer(dd, Hit::SketchPoint { sketch: sid, id: p.id.clone(), at: p.pos }); } } } } hits.extend(best.hits()); // Sketch curves (the active sketch wins ties). let mut bestc = Near::new(all); for sid in visible_sketches(app) { let Some(ss) = st.sketch(sid) else { continue }; let bonus = if s.active_sketch == Some(sid) { 2.0 } else { 0.0 }; for (i, c) in ss.sketch.curves.iter().enumerate() { if !solidcraft_engine::view::sketch_curve_visible(&ss.sketch, i) { continue; } let straight = matches!(c.kind, CurveKind::Line { .. }); for part in &crate::sketch_display::curve_sample(app, sid, i, &ss.plane, proj).parts { let pts: Vec = part.iter().filter_map(|q| proj.to_screen(*q)).collect(); for w in pts.windows(2) { let dd = seg_dist(pos, w[0], w[1]) - bonus; if dd < 6.0 && bestc.wants(dd) { bestc.offer(dd, Hit::SketchCurve { sketch: sid, id: c.id.clone(), straight }); } } } } // 3D sketch curves (paths for sweeps and pipes). for w in &ss.sketch.wires { let pts: Vec = w.pts.iter().filter_map(|q| proj.to_screen(*q)).collect(); for seg in pts.windows(2) { let dd = seg_dist(pos, seg[0], seg[1]) - bonus; if dd < 6.0 && bestc.wants(dd) { bestc.offer(dd, Hit::SketchCurve { sketch: sid, id: w.id.clone(), straight: false }); } } } } hits.extend(bestc.hits()); // Nearest face along the ray (hides what is behind it); with `all`, every body's nearest. let mut bestf: Option<(f64, Hit)> = None; let mut faces = Near::new(all); for b in &st.bodies { if !app.session.body_visible(&b.name) { continue; } // Bodies whose box the ray misses (or enters behind the best hit so far) are skipped. if let Some(bb) = bound(&b.name) { let pad = bb.diagonal() * 1e-6 + 1e-9; match ray_box(o, d, &bb, pad) { None => continue, Some(t0) if !all && bestf.as_ref().is_some_and(|(bt, _)| t0 > *bt) => continue, _ => {} } } let m = b.mesh(); if let Some((t, ti)) = m.raycast(o, d) { let fi = m.tri_face.get(ti).copied().unwrap_or(0) as usize; let hit = Hit::Face { body: b.name.clone(), index: fi, point: o + d * t }; if bestf.as_ref().is_none_or(|(bt, _)| t < *bt) { bestf = Some((t, hit.clone())); } if all { faces.offer(t, hit); } } } let slack = scene_radius(app) * 0.02; let visible = |p: Vec3| bestf.as_ref().is_none_or(|(t, _)| (p - o).dot(d) <= *t + slack); // Vertices (edge end points) and edges. let mut bestv = Near::new(all); let mut beste = Near::new(all); for b in &st.bodies { if !app.session.body_visible(&b.name) { continue; } // Only bodies whose outline on screen is near the cursor can have an edge under it. if let Some(bb) = bound(&b.name) && screen_box(proj, &bb, 8.0).is_some_and(|r| !r.contains(pos)) { continue; } let m = b.mesh(); for (ei, e) in m.edges.iter().enumerate() { if !m.edge_pickable(ei) { continue; } for v in [e.first(), e.last()].into_iter().flatten() { if let Some(sp) = proj.to_screen(*v) { let dd = sp.distance(pos); if dd < 7.0 && visible(*v) && bestv.wants(dd) { bestv.offer(dd, Hit::Vertex { body: b.name.clone(), point: *v }); } } } let pts: Vec = e.iter().filter_map(|q| proj.to_screen(*q)).collect(); for (k, w) in pts.windows(2).enumerate() { let dd = seg_dist(pos, w[0], w[1]); if dd < 6.0 && beste.wants(dd) { let mid = polyline_mid(e); // Visible where the cursor is (not just at the middle). let at = e.get(k).copied().unwrap_or(mid); if visible(at) || visible(mid) { beste.offer(dd, Hit::Edge { body: b.name.clone(), index: ei, mid }); } } } } } hits.extend(bestv.hits()); hits.extend(beste.hits()); // Origin axes. let size = origin_size(app.cam.half_height()); let mut besta = Near::new(all); for (name, dir) in origin_axes(app) { if let (Some(a), Some(b)) = (proj.to_screen(Vec3::ZERO), proj.to_screen(dir * (size * AXIS_LEN))) { let dd = seg_dist(pos, a, b); if dd < 5.0 && besta.wants(dd) { besta.offer(dd, Hit::Axis { name: name.into() }); } } } // Construction axes, picked like the origin axes. for (name, a, b) in construction_axis_segments(app) { if let (Some(a), Some(b)) = (proj.to_screen(a), proj.to_screen(b)) { let dd = seg_dist(pos, a, b); if dd < 5.0 && besta.wants(dd) { besta.offer(dd, Hit::Axis { name }); } } } hits.extend(besta.hits()); // Surfaces by depth: profiles (slightly preferred over a face they lie on), faces, planes. let mut surf: Vec<(f64, Hit)> = Vec::new(); let mut bestp: Option<(f64, f64, Hit)> = None; let mut profiles_all: Vec<(f64, f64, Hit)> = Vec::new(); for sid in visible_sketches(app) { let Some(ss) = st.sketch(sid) else { continue }; let n = ss.plane.normal(); let den = n.dot(d); if den.abs() < 1e-12 { continue; } let t = n.dot(ss.plane.origin - o) / den; if t < 0.0 { continue; } let lp = ss.plane.to_local(o + d * t); // Show Profile off: the sketch being edited offers no profiles. let profiles = if ss.sketch.view.hide_profile && s.active_sketch == Some(sid) { &[][..] } else { &ss.profiles[..] }; for (pi, p) in profiles.iter().enumerate() { if !p.region.contains(lp) { continue; } if all { profiles_all.push((t, p.area, Hit::Profile { sketch: sid, index: pi })); } else if bestp.as_ref().is_none_or(|(_, a, _)| p.area < *a) { bestp = Some((t, p.area, Hit::Profile { sketch: sid, index: pi })); } } } // Smallest profile first. profiles_all.sort_by(|a, b| a.1.total_cmp(&b.1)); for (t, _, h) in bestp.into_iter().chain(profiles_all) { surf.push((t - slack, h)); } if all { surf.extend(faces.sorted()); } else if let Some(f) = bestf { surf.push(f); } // Planes give way to a face or profile at the same depth (a floor on the XZ plane). for (name, _, q) in origin_planes(app) { if let Some(t) = ray_quad(o, d, &q) { surf.push((t + slack, Hit::Plane { name: name.into(), point: o + d * t })); } } for (name, _, q) in construction_quads(app) { if let Some(t) = ray_quad(o, d, &q) { surf.push((t + slack, Hit::Plane { name, point: o + d * t })); } } surf.sort_by(|a, b| a.0.total_cmp(&b.0)); hits.extend(surf.into_iter().map(|x| x.1)); hits } /// The section cut shown now: the open Section Analysis dialog's plane, else the session's. /// The active sketch's display options (defaults when not sketching). pub fn active_view(app: &SolveApp) -> solidcraft_engine::sketch::SketchView { let st = app.session.world_state(); app.session.active_sketch.and_then(|s| st.sketch(s)).map(|ss| ss.sketch.view.clone()).unwrap_or_default() } pub fn section_plane(app: &SolveApp) -> Option<(Vec3, Vec3)> { if let Some(d) = app.dialog.as_ref().filter(|d| matches!(d.kind, crate::dialogs::Kind::Section { .. })) { let cmds = crate::dialogs::apply_commands(app, d).ok()?; let (_, p) = cmds.into_iter().next()?; let mut scratch = app.session.scratch(); scratch.execute("inspect.section", &p).ok()?; return scratch.section; } // Slice (Sketch Palette): cut the model at the plane of the sketch being edited. if app.session.section.is_none() { let st = app.session.world_state(); if let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)).filter(|ss| ss.sketch.view.slice) { return Some((ss.plane.origin, ss.plane.normal())); } } app.session.section } /// Visible geometry belonging to a timeline feature. Body creators retain their whole-body /// feedback; modifiers use persistent face names, which survive later edits and placement. /// Do not expand these faces into surface patches: neighbouring faces can belong to another /// feature even when they share a surface. fn feature_geometry(app: &SolveApp, id: u64) -> Vec { if !app.session.model.result(id).is_some_and(|r| !r.skipped && r.error.is_none()) { return Vec::new(); } let st = app.session.world_state(); let prefix = format!("F{id}:"); let mut out = Vec::new(); for b in &st.bodies { if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) { continue; } if b.feature == id { out.push(Sel::Body { name: b.name.clone() }); continue; } let names = solidcraft_engine::doc::naming::face_names(b); let m = b.mesh(); for (index, _) in names.iter().enumerate().filter(|(_, n)| n.starts_with(&prefix)) { if let Some(point) = m.triangles.iter().zip(&m.tri_face).find_map(|(t, f)| { if *f as usize != index { return None; } m.tri(t).map(|[a, b, c]| (a + b + c) / 3.0) }) { out.push(Sel::Face { body: b.name.clone(), index, point }); } } } out } fn displayed_selection(app: &SolveApp, item: &Sel) -> Vec { match item { Sel::Feature { id } => feature_geometry(app, *id), Sel::Occurrence { path } => app .session .occurrence_bodies(path, true) .unwrap_or_default() .into_iter() .filter(|name| app.session.body_visible(name)) .map(|name| Sel::Body { name }) .collect(), _ => grouped_selection(&app.session, item), } } /// The box around what is selected (bodies, faces, edges, vertices, sketch curves and the /// bodies of selected features), where it is shown. pub fn selection_bounds(app: &SolveApp) -> Option { let st = app.session.world_state(); let mut b = solidcraft_engine::geom::Aabb3::EMPTY; for s in app.session.selection.iter().flat_map(|item| displayed_selection(app, item)) { match &s { Sel::Body { name } => { if let Some(x) = st.body(name) { b = b.union(&x.mesh().bounds()); } } Sel::Face { body, index, .. } => { if let Some(x) = st.body(body) { let m = x.mesh(); for (t, f) in m.triangles.iter().zip(&m.tri_face) { if *f as usize == *index { for p in m.tri(t).into_iter().flatten() { b.add(p); } } } } } Sel::Edge { body, index, .. } => { if let Some(e) = st.body(body).and_then(|x| x.mesh().edges.get(*index).cloned()) { for p in e { b.add(p); } } } Sel::Vertex { point, .. } => b.add(*point), Sel::ExternalSketchPoint { sketch, id } => { if let Some(ss) = st.sketch(*sketch) && let Some(p) = ss.sketch.resolve_point(id).and_then(|i| ss.sketch.point(i)) { b.add(ss.plane.to_world(p)); } } Sel::ExternalSketchCurve { sketch, id } => { if let Some(ss) = st.sketch(*sketch) && let Some(i) = ss.sketch.curve_index(id) { for p in ss.sketch.polyline(i) { b.add(ss.plane.to_world(p)); } } } Sel::SketchCurve { id } => { for ss in &st.sketches { if let Some(i) = ss.sketch.curve_index(id) { for q in ss.sketch.polyline(i) { b.add(ss.plane.to_world(q)); } } } } _ => {} } } (!b.is_empty()).then_some(b) } /// The section as the shader's clip plane (normal, d), zero when there is none. /// The surface analysis for the shader (see `gpu::ViewportCallback::analysis`). fn analysis_uniform(app: &SolveApp) -> [f32; 8] { use solidcraft_engine::SurfaceAnalysis as A; match app.session.analysis { Some(A::Zebra { stripes }) => [1.0, stripes as f32, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0], Some(A::Draft { pull, angle }) => [2.0, angle as f32, 0.0, 0.0, pull.x as f32, pull.y as f32, pull.z as f32, 0.0], Some(A::Curvature { radius }) => [3.0, radius as f32, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0], Some(A::Environment) => [4.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0], Some(A::Access { dir }) => { let (scale, size, eps) = crate::access::map(app).map(|m| (m.scale, m.size as f32, 1.5 / m.scale.max(1e-9))).unwrap_or((0.0, 0.0, 0.0)); [5.0, eps, scale, size, dir.x as f32, dir.y as f32, dir.z as f32, 0.0] } None => [0.0; 8], } } fn section_clip(app: &SolveApp) -> [f32; 4] { match section_plane(app) { Some((o, n)) => [n.x as f32, n.y as f32, n.z as f32, n.dot(o) as f32], None => [0.0; 4], } } /// Space kept free at the top right of the viewport for the axis view (docked dialogs start below). pub(crate) fn axis_view_size(style: crate::prefs::NavigatorStyle) -> f32 { match style { crate::prefs::NavigatorStyle::Axes => 116.0, crate::prefs::NavigatorStyle::Cube => navigation_cube::SIZE, } } // Keep command panels anchored independently of the compact cube's display size. pub const AXIS_VIEW_CLEARANCE: f32 = 168.0; /// Axis length as a multiple of the origin plane size (solid part 1, dashed beyond). const AXIS_LEN: f64 = 1.8; /// The hit as a plain selection (no command filter). pub fn hit_sel(h: &Hit) -> Option { Some(match h { Hit::SketchPoint { id, .. } => Sel::SketchPoint { id: id.clone() }, Hit::SketchCurve { id, .. } => Sel::SketchCurve { id: id.clone() }, Hit::Vertex { body, point } => Sel::Vertex { body: body.clone(), point: *point }, Hit::Edge { body, index, mid } => Sel::Edge { body: body.clone(), index: *index, point: *mid }, Hit::Profile { sketch, index } => Sel::Profile { sketch: *sketch, index: *index }, Hit::Face { body, index, point } => Sel::Face { body: body.clone(), index: *index, point: *point }, Hit::Plane { name, .. } => Sel::Plane { name: name.clone() }, Hit::Axis { name } => Sel::Axis { name: name.clone() }, }) } /// What a click would pick: the first hit the open dialog's active input accepts, or with no /// dialog the first hit (sketch entities only belong to the active sketch). pub fn candidate(app: &SolveApp, hits: &[Hit]) -> Option<(Hit, Sel)> { if let Some(d) = app.dialog.as_ref().filter(|d| d.wants_picks()) { let snap = d.face_snap(&app.session, hits); return hits.iter().find_map(|h| d.candidate_on(&app.session, h, snap.as_ref()).map(|s| (h.clone(), s))); } hits.iter().filter(|h| pick_allowed(app, h)).find_map(|h| hit_candidate(app, h)) } /// Every hit a click could pick, in order, each with its selection (Select Other). pub fn candidates(app: &SolveApp, hits: &[Hit]) -> Vec<(Hit, Sel)> { if let Some(d) = app.dialog.as_ref().filter(|d| d.wants_picks()) { // The face under the cursor is looked up once for every sketch point a face place may // snap to, before Select Other caps its rows. let snap = d.face_snap(&app.session, hits); return hits.iter().filter_map(|h| d.candidate_on(&app.session, h, snap.as_ref()).map(|s| (h.clone(), s))).collect(); } hits.iter().filter(|h| pick_allowed(app, h)).filter_map(|h| hit_candidate(app, h)).collect() } /// What the selection filter lets a click pick with no dialog. fn pick_allowed(app: &SolveApp, h: &Hit) -> bool { let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k); match h { Hit::SketchCurve { .. } => !off("sketch") && !off("sketch_curves"), Hit::SketchPoint { .. } => !off("sketch") && !off("sketch_points"), Hit::Profile { .. } => !off("sketch"), Hit::Vertex { .. } => !off("vertices") && !app.ui.pick_bodies, Hit::Edge { .. } => !off("edges") && !app.ui.pick_bodies, Hit::Face { .. } => !off("faces") || app.ui.pick_bodies, Hit::Plane { .. } | Hit::Axis { .. } => true, } } /// The selection a click on `h` makes with no dialog (sketch entities only belong to the active sketch). fn hit_candidate(app: &SolveApp, h: &Hit) -> Option<(Hit, Sel)> { match h { Hit::SketchCurve { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => { Some((h.clone(), Sel::ExternalSketchCurve { sketch: *sketch, id: id.clone() })) } Hit::SketchPoint { sketch, id, .. } if app.session.active_sketch != Some(*sketch) => { Some((h.clone(), Sel::ExternalSketchPoint { sketch: *sketch, id: id.clone() })) } Hit::Face { body, .. } if app.ui.pick_bodies => Some((h.clone(), Sel::Body { name: body.clone() })), _ => hit_sel(h).map(|s| (h.clone(), s)), } } /// A pick for the open dialog's active input (a click, or a Select Other row). pub(crate) fn dialog_pick(app: &mut SolveApp, hit: &Hit, sel: Sel) { let Some(mut d) = app.dialog.take() else { return }; d.pick(&app.session, sel); if let crate::dialogs::Kind::PathPlane { sketch, .. } = &mut d.kind { *sketch = match hit { Hit::SketchCurve { sketch, .. } => Some(*sketch), _ => None, }; d.extra.remove("path"); } app.dialog = Some(d); } /// The edge a selection refers to now: by index if it still passes through the stored point, /// otherwise the edge nearest the point. fn edge_of(m: &solidcraft_engine::geom::Mesh, index: usize, point: Vec3) -> Option<&Vec> { let tol = m.bounds().diagonal().max(1.0) * 1e-6; let near = |e: &Vec| e.windows(2).map(|w| point.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min); if let Some(e) = m.edges.get(index) && near(e) < tol { return Some(e); } m.edges.iter().min_by(|a, b| near(a).total_cmp(&near(b))).filter(|e| near(e) < tol * 1e3) } fn face_tris(sc: &mut GpuScene, m: &solidcraft_engine::geom::Mesh, face: usize, col: [u8; 4], lit: bool) { for (t, f) in m.triangles.iter().zip(&m.tri_face) { if *f as usize != face { continue; } for k in t { let i = *k as usize; if let (Some(p), Some(n)) = (m.positions.get(i), m.normals.get(i)) { sc.tri(p.to_f32(), if lit { n.to_f32() } else { [0.0; 3] }, col); } } } } /// A highlighted edge: a halo and a core, and where the model hides it, the core dimmed (so a /// pick on the far side still shows). fn edge_lines(sc: &mut GpuScene, e: &[Vec3], core: Color32, halo: Color32, w: f32) { for (col, width) in [(halo, w + 3.0), (core, w)] { for p in e.windows(2) { sc.line(p[0].to_f32(), p[1].to_f32(), c4(col), width, false); } } for p in e.windows(2) { sc.hidden_line(p[0].to_f32(), p[1].to_f32(), c4(core.gamma_multiply(0.45)), (w * 0.7).max(1.0)); } } /// The outline of a highlighted face where the model hides it (dimmed). fn hidden_face_outline(sc: &mut GpuScene, m: &solidcraft_engine::geom::Mesh, face: usize, col: Color32) { for (ei, e) in m.edges.iter().enumerate() { let on = m.edge_faces.get(ei).is_some_and(|f| f.iter().any(|x| *x as usize == face)); if on && m.edge_pickable(ei) { for p in e.windows(2) { sc.hidden_line(p[0].to_f32(), p[1].to_f32(), c4(col.gamma_multiply(0.45)), 1.4); } } } } pub(crate) fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index: usize, fill: Color32, edge: Color32) { let st = app.session.world_state(); let Some(ss) = st.sketch(sketch) else { return }; let Some(p) = ss.profiles.get(index) else { return }; let plane = match crate::dialogs::emboss_profile_plane(app, sketch) { Ok(Some(plane)) => plane, Ok(None) => ss.plane, Err(_) => return, }; // Lift toward the eye so a profile on a face shows over it. let lift = app.cam.back() * (app.cam.half_height() * 0.002); let w = |q: solidcraft_engine::geom::Vec2| (plane.to_world(q) + lift).to_f32(); let col = c4(fill); // In the translucent pass (after the grid and edges) so it covers them. let proj = display_proj(app); let display = crate::sketch_display::profile_region(app, sketch, index, &plane, &proj); for [a, b, c] in display.as_ref().as_ref().map_or_else(Vec::new, |region| crate::sketch_display::fill_triangles(region, &mut 65_536)) { sc.trans_tri(w(a), [0.0; 3], col); sc.trans_tri(w(b), [0.0; 3], col); sc.trans_tri(w(c), [0.0; 3], col); } for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) { for part in crate::sketch_display::boundary_parts(lp, &plane, &proj, app.viewport.pixels_per_point()) { for pair in part.windows(2) { sc.line((pair[0] + lift).to_f32(), (pair[1] + lift).to_f32(), c4(edge), 2.0, false); } } } } fn quad(sc: &mut GpuScene, q: &[Vec3; 4], fill: Color32, edge: Color32) { let col = c4(fill); for i in [0, 1, 2, 0, 2, 3] { if let Some(p) = q.get(i) { sc.trans_tri(p.to_f32(), [0.0; 3], col); } } for i in 0..4 { sc.line(q[i].to_f32(), q[(i + 1) % 4].to_f32(), c4(edge), 1.2, false); } } fn highlight_key(app: &SolveApp) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); app.session.revision.hash(&mut h); serde_json::to_string(&app.highlighted()).unwrap_or_default().hash(&mut h); format!("{:?}", app.viewport.hover).hash(&mut h); app.viewport.hover_feature.hash(&mut h); app.viewport.hover_bodies.hash(&mut h); app.ui.pick_bodies.hash(&mut h); crate::theme::is_dark().hash(&mut h); app.preview.replaced.hash(&mut h); app.origin_visible().hash(&mut h); app.session.visibility.hidden_origin.hash(&mut h); app.session.visibility.hidden_bodies.hash(&mut h); app.session.visibility.hidden_occurrences.hash(&mut h); app.cam.half_height().to_bits().hash(&mut h); app.cam.back().x.to_bits().hash(&mut h); crate::sketch_display::projection_key(&display_proj(app), app.viewport.pixels_per_point()).hash(&mut h); h.finish() } /// The highlight scene: origin widget, construction planes, selection and hover. fn build_highlight(app: &SolveApp) -> GpuScene { let t = Tokens::get(); let mut sc = GpuScene::default(); let st = app.session.world_state(); let mut sel = Vec::new(); for item in app.highlighted() { for piece in displayed_selection(app, &item) { if !sel.iter().any(|x| crate::selection::same_item(x, &piece)) { sel.push(piece); } } } let hover = app.viewport.hover.as_ref(); let size = origin_size(app.cam.half_height()); // Origin planes and construction planes. let plane_state = |name: &str| { let selected = sel.iter().any(|x| matches!(x, Sel::Plane { name: n } if n == name)); let hovered = matches!(hover, Some(Hit::Plane { name: n, .. }) if n == name); (selected, hovered) }; let mut planes: Vec<(String, [Vec3; 4], Color32)> = origin_planes(app).into_iter().map(|(n, _, q)| (n.to_string(), q, t.origin_plane)).collect(); planes.extend(construction_quads(app).into_iter().map(|(n, _, q)| (n, q, t.construction_plane))); for (name, q, base) in &planes { let (selected, hovered) = plane_state(name); let fill = if hovered { t.origin_plane_hover } else if selected { Color32::from_rgba_unmultiplied(t.sel_face.r(), t.sel_face.g(), t.sel_face.b(), 150) } else { *base }; quad(&mut sc, q, fill, if selected { t.sel_edge } else { t.origin_plane_edge }); } // Origin axes: solid near the origin, dashed beyond. for (name, dir) in origin_axes(app) { let col = match name { "X" => t.axis_x, "Y" => t.axis_y, _ => t.axis_z, }; let on = sel.iter().any(|x| matches!(x, Sel::Axis { name: n } if n == name)) || matches!(hover, Some(Hit::Axis { name: n }) if n == name); let w = if on { 4.0 } else { 2.0 }; sc.line([0.0; 3], (dir * size).to_f32(), c4(col), w, false); let dash = size * 0.08; let mut a = size + dash; while a < size * AXIS_LEN { sc.line((dir * a).to_f32(), (dir * (a + dash)).to_f32(), c4(col), w * 0.75, false); a += dash * 2.0; } } // Construction axes (dashed, the length of the origin axes either side) and points (a // small star), in the construction colour. for (name, a, b) in construction_axis_segments(app) { let on = sel.iter().any(|x| matches!(x, Sel::Axis { name: n } if *n == name)) || matches!(hover, Some(Hit::Axis { name: n }) if *n == name); let col = c4(if on { t.sel_edge } else { t.construction_geom }); let w = if on { 3.0 } else { 1.6 }; let len = a.dist(b); let dash = size * 0.08; let mut s = 0.0; while s < len { let e = (s + dash).min(len); sc.line(a.lerp(b, s / len).to_f32(), a.lerp(b, e / len).to_f32(), col, w, false); s += dash * 1.6; } } for (_, p) in construction_points(app) { let r = size * 0.04; for d in [Vec3::X, Vec3::Y, Vec3::Z] { sc.line((p - d * r).to_f32(), (p + d * r).to_f32(), c4(t.construction_geom), 2.0, true); } } // Selected things. for x in &sel { match x { Sel::Face { body, index, .. } => { // A live preview stands in for the body: its old faces would fight with it. if let Some(b) = st.body(body).filter(|_| app.session.body_visible(body) && !app.preview.replaced.contains(body)) { face_tris(&mut sc, &b.mesh(), *index, c4(t.sel_face), false); hidden_face_outline(&mut sc, &b.mesh(), *index, t.sel_edge); } } Sel::Body { name } => { if let Some(b) = st.body(name) && app.session.body_visible(name) { let m = b.mesh(); for f in 0..b.body.face_count() { face_tris(&mut sc, &m, f, c4(t.body_selected), true); } } } Sel::Edge { body, index, point } => { if let Some(b) = st.body(body) && app.session.body_visible(body) && let Some(e) = edge_of(&b.mesh(), *index, *point) { edge_lines(&mut sc, e, t.sel_edge, t.sel_edge_rim, 3.0); } } Sel::Profile { sketch, index } => profile_fill(&mut sc, app, *sketch, *index, t.sel_profile, t.sel_profile_edge), Sel::ExternalSketchCurve { sketch, id } => { if let Some(ss) = st.sketch(*sketch) && let Some(ci) = ss.sketch.curve_index(id) { for pts in &crate::sketch_display::curve_sample(app, *sketch, ci, &ss.plane, &display_proj(app)).parts { let mode = ss.sketch.curves.get(ci).map_or(0, |c| if c.centerline { 2 } else { u8::from(c.construction) }); for (a, b) in sketch_segments(pts, mode, grid_step(app.cam.half_height()).0) { sc.line(a.to_f32(), b.to_f32(), c4(t.sel_edge), 3.5, true); } } } } // A 3D sketch curve, in whichever sketch has it. Sel::SketchCurve { id } if st.sketches.iter().any(|ss| ss.sketch.wire_index(id).is_some()) => { for ss in st.sketches.iter() { if let Some(w) = ss.sketch.wire_index(id).and_then(|i| ss.sketch.wires.get(i)) { for q in w.pts.windows(2) { sc.line(q[0].to_f32(), q[1].to_f32(), c4(t.sel_edge), 3.5, true); } } } } Sel::SketchCurve { id } => { if let Some(ss) = app.session.active_sketch.and_then(|sid| st.sketch(sid)) && let Some(ci) = ss.sketch.curve_index(id) { for pts in &crate::sketch_display::curve_sample(app, ss.feature, ci, &ss.plane, &display_proj(app)).parts { let mode = ss.sketch.curves.get(ci).map_or(0, |c| if c.centerline { 2 } else { u8::from(c.construction) }); for (a, b) in sketch_segments(pts, mode, grid_step(app.cam.half_height()).0) { sc.line(a.to_f32(), b.to_f32(), c4(t.sel_edge), 3.5, true); } } } } _ => {} } } // Timeline modifiers light only their resulting faces; browser rows light whole bodies. let mut hovered = app.viewport.hover_feature.map(|id| feature_geometry(app, id)).unwrap_or_default(); hovered.extend(app.viewport.hover_bodies.iter().map(|name| Sel::Body { name: name.clone() })); for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name) && !app.preview.replaced.contains(&b.name)) { let whole = hovered.iter().any(|x| matches!(x, Sel::Body { name } if name == &b.name)); let faces: Vec<_> = if whole { (0..b.body.face_count()).collect() } else { hovered .iter() .filter_map(|x| match x { Sel::Face { body, index, .. } if body == &b.name => Some(*index), _ => None, }) .collect() }; if faces.is_empty() { continue; } let k = t.hover_face_lift; let c = body_rgb(app, &b.name); let m = b.mesh(); for &f in &faces { if !sel.iter().any(|x| { matches!(x, Sel::Face { body, index, .. } if body == &b.name && *index == f) || matches!(x, Sel::Body { name } if name == &b.name) }) { face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); } } for (ei, e) in m.edges.iter().enumerate() { if m.edge_pickable(ei) && (whole || m.edge_faces.get(ei).is_some_and(|fs| fs.iter().any(|f| faces.contains(&(*f as usize))))) { edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 1.5); } } } // Hover (pre-highlight). match hover { // Body selection: the whole body lights up. Some(Hit::Face { body, .. }) if app.ui.pick_bodies && app.dialog.is_none() && app.tool.is_none() => { if let Some(b) = st.body(body) { let k = t.hover_face_lift; let c = body_rgb(app, body); let m = b.mesh(); for f in 0..b.body.face_count() { face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); } } } Some(Hit::Face { body, index, .. }) if !app.preview.replaced.contains(body) && !sel.iter().any(|x| matches!(x, Sel::Face { body: b, index: i, .. } if b == body && i == index)) => { if let Some(b) = st.body(body) { let k = t.hover_face_lift; let c = body_rgb(app, body); let m = b.mesh(); let patch = m.surface_patch(*index); for &f in &patch { face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); } for (e, _) in m.edge_faces.iter().enumerate().filter(|(_, fs)| fs.iter().any(|f| patch.contains(&(*f as usize)))) { if let Some(p) = m.edges.get(e) && m.edge_pickable(e) { edge_lines(&mut sc, p, t.hover_edge, t.hover_edge_halo, 1.5); } } } } Some(Hit::Edge { body, index, mid }) => { if let Some(b) = st.body(body) { let m = b.mesh(); for i in m.circular_rim(*index) { let selected = sel.iter().any(|x| matches!(x, Sel::Edge { body: b, index: j, .. } if b == body && *j == i)); if !selected && let Some(e) = if i == *index { edge_of(&m, i, *mid) } else { m.edges.get(i) } { edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 2.0); } } } } Some(Hit::Profile { sketch, index }) if !sel.iter().any(|x| matches!(x, Sel::Profile { sketch: s2, index: i2 } if s2 == sketch && i2 == index)) => { profile_fill(&mut sc, app, *sketch, *index, t.hover_profile, t.hover_profile_edge); } _ => {} } sc } /// A body's display colour (appearance, material or imported colour), if it has one. fn body_color(doc: &solidcraft_engine::doc::Document, b: &solidcraft_engine::doc::ModelBody) -> Option { let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8; doc.body_color(b).map(|[r, g, b]| Color32::from_rgb(to8(r), to8(g), to8(b))) } /// A body's display colour (else the theme's) as RGB. fn body_rgb(app: &SolveApp, name: &str) -> (u8, u8, u8) { let st = app.session.world_state(); let c = st.body(name).and_then(|b| body_color(&app.session.doc, b)).unwrap_or(Tokens::get().body); (c.r(), c.g(), c.b()) } pub(crate) fn polyline_mid(pts: &[Vec3]) -> Vec3 { let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum(); let mut acc = 0.0; for w in pts.windows(2) { let l = w[0].dist(w[1]); if acc + l >= len * 0.5 && l > 0.0 { return w[0].lerp(w[1], (len * 0.5 - acc) / l); } acc += l; } pts.first().copied().unwrap_or_default() } fn seg_dist(p: Pos2, a: Pos2, b: Pos2) -> f32 { let ab = b - a; let l2 = ab.length_sq(); let t = if l2 > 0.0 { ((p - a).dot(ab) / l2).clamp(0.0, 1.0) } else { 0.0 }; p.distance(a + ab * t) } /// Point on the active sketch plane under the cursor (sketch coordinates), snapped to sketch /// points within a few pixels and otherwise to a fine grid. Returns the snapped point and the /// id of the sketch point it snapped to. pub fn sketch_point_at(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Vec2, Option)> { let sid = app.session.active_sketch?; let st = app.session.world_state(); let ss = st.sketch(sid)?; for h in pick(app, proj, pos) { if let Hit::SketchPoint { sketch, id, at } = h && sketch == sid { return Some((at, Some(id))); } } let (o, d) = proj.ray(pos); let w = ss.plane.intersect_ray(o, d)?; let lp = ss.plane.to_local(w); if crate::drag_snap::suppressed() { return Some((lp, None)); } let (minor, _) = grid_step(app.cam.half_height()); let step = minor / 10.0; if let Some(p) = crate::sketch_tools::refine_snap(app, proj, &ss.sketch, &ss.plane, lp) { return Some(p); } // With Snap off (Sketch Palette) the point is where the cursor is. if ss.sketch.view.no_snap { return Some((lp, None)); } Some((Vec2::new((lp.x / step).round() * step, (lp.y / step).round() * step), None)) } pub fn delete_selection(app: &mut SolveApp) { crate::delete::delete_selection(app); } /// Raw wheel/trackpad input for one frame, split by source. /// /// Mouse wheels report lines or pages; trackpads report points. Trackpad /// scrolls arrive bracketed (`Start` … `Move` … `End`, momentum included) /// while mouse wheels send bare `Move` events, so phases tell them apart. struct RawWheel { /// Vertical mouse-wheel input in points (zooms). zoom: f32, /// Trackpad point input, routed like the wheel (zooms when panning is off). point_zoom: f32, /// Bracketed two-finger scroll in points (pans when panning is on). pan: egui::Vec2, /// Bare horizontal input in points (thumb wheel: always pans sideways). mouse_pan: egui::Vec2, } /// Route wheel points to the vertical axis, matching egui's scroll modifiers. fn route_wheel_vertical(points: egui::Vec2, modifiers: egui::Modifiers, options: egui::InputOptions) -> f32 { let horizontal = modifiers.matches_any(options.horizontal_scroll_modifier); let vertical = modifiers.matches_any(options.vertical_scroll_modifier); if horizontal && !vertical { 0.0 } else if vertical && !horizontal { points.x + points.y } else { points.y } } /// Match egui's wheel units and modifier routing, without its temporal smoothing. /// /// `gesture` tracks an ongoing bracketed trackpad scroll (`Start` … `End`): /// moves inside one pan, bare `Move` input is a mouse (vertical zooms, /// horizontal pans). Only point input brackets; line/page input always zooms. fn immediate_wheel(input: &egui::InputState, options: egui::InputOptions, page_height: f32, gesture: &mut bool) -> RawWheel { let mut out = RawWheel { zoom: 0.0, point_zoom: 0.0, pan: egui::Vec2::ZERO, mouse_pan: egui::Vec2::ZERO }; for event in &input.events { let egui::Event::MouseWheel { unit, delta, modifiers, phase } = event else { continue }; if modifiers.matches_any(options.zoom_modifier) { continue; } let point = matches!(unit, egui::MouseWheelUnit::Point); match phase { egui::TouchPhase::Start => { if point { *gesture = true; } continue; } egui::TouchPhase::End | egui::TouchPhase::Cancel => { if point { *gesture = false; } continue; } egui::TouchPhase::Move => {} } let points = match unit { egui::MouseWheelUnit::Point => *delta, egui::MouseWheelUnit::Line => *delta * options.line_scroll_speed, egui::MouseWheelUnit::Page => *delta * page_height, }; if point && *gesture { out.pan += points; // Legacy path only (ignored while panning is on). out.point_zoom += route_wheel_vertical(points, *modifiers, options); } else if point { // Bare moves are a mouse: vertical zooms, horizontal pans sideways. // A wheel needs the opposite sign of fingers for the same motion. out.zoom += route_wheel_vertical(points, *modifiers, options); out.mouse_pan += egui::vec2(-points.x, 0.0); } else { out.zoom += route_wheel_vertical(points, *modifiers, options); } } out } /// Combined pinch-to-zoom factor for one frame (1 = no pinch input). fn pinch_factor(input: &egui::InputState) -> f32 { input .events .iter() .filter_map(|event| { let egui::Event::Zoom(f) = event else { return None }; (*f).is_finite().then_some(*f).filter(|f| *f > 0.0) }) .fold(1.0, |acc, f| acc * f) } /// Zoom anchor: the model point under the cursor, else the target's depth. /// Preferences › Navigation › Zoom to the cursor (off: toward the view's centre). fn zoom_anchor(app: &mut SolveApp, rect: egui::Rect, hover: Option) -> Option { hover.filter(|_| app.preferences.zoom_to_cursor).map(|p| { let proj = projection(app, rect); // Toward the model's surface under the cursor, else the target's depth. if let Some(q) = pick_cached(app, &proj, p).iter().find_map(|h| if let Hit::Face { point, .. } = h { Some(*point) } else { None }) { return q; } let (o, d) = proj.ray(p); let n = app.cam.back(); let den = d.dot(n); if den.abs() > 1e-9 { o + d * ((app.cam.target - o).dot(n) / den) } else { app.cam.target } }) } pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); let rect = ui.max_rect(); app.viewport.rect = Some(rect); refresh_layout(app, ui.ctx().pixels_per_point()); let resp = ui.interact(rect, ui.id().with("viewport"), Sense::click_and_drag()); let (hover, scroll, mods, middle, secondary_down, primary_down, primary_pressed, primary_released, delta) = ui.input(|i| { ( i.pointer.hover_pos(), i.smooth_scroll_delta.y, i.modifiers, i.pointer.middle_down(), i.pointer.secondary_down(), i.pointer.primary_down(), i.pointer.primary_pressed(), i.pointer.primary_released(), i.pointer.delta(), ) }); // The right button belongs to the marking menu while it is open. let secondary_down = secondary_down && app.viewport.context_menu.is_none(); // The axis view handles its own clicks; the model underneath must not see them. let axes = axis_rect(app, rect); // Floating windows and menus own input over the model, including wheel navigation. egui's // hit test also counts the model as under the pointer a few points past its own edge and // inside a window's or a list's edge (#678), so the pointer must be in the viewport too, // with no window or menu above it. let inside = resp.contains_pointer() && ui.rect_contains_pointer(rect) && hover.is_some_and(|p| !axes.contains(p)); app.viewport.mouse = hover.filter(|_| inside); // Ctrl or Alt held: drawing and dragging don't snap. crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command); middle_double_click(app, ui, inside); // ---- navigation ---- let trackpad_pan = app.preferences.trackpad_pan; if !app.preferences.smooth_wheel_zoom { app.viewport.wheel_zoom_raw_until_idle = true; } else if !ui.input(|i| i.is_scrolling()) { app.viewport.wheel_zoom_raw_until_idle = false; } let options = ui.ctx().options(|o| o.input_options); let page_height = ui.ctx().viewport_rect().height(); let raw = ui.input(|i| immediate_wheel(i, options, page_height, &mut app.viewport.trackpad_gesture)); if trackpad_pan && raw.pan != egui::Vec2::ZERO { // Swallow egui's smoothing tail while the trackpad runs (a wheel-only // tail ends the moment trackpad input starts; mixed input is rare). app.viewport.trackpad_until_idle = true; } else if !ui.input(|i| i.is_scrolling()) { app.viewport.trackpad_until_idle = false; // Self-healing: with no scroll activity no gesture can be mid-flight // (its End was lost); a stuck flag would pan future mouse input. app.viewport.trackpad_gesture = false; } let pan = if trackpad_pan { raw.pan } else { egui::Vec2::ZERO } + raw.mouse_pan; let scroll = if app.viewport.wheel_zoom_raw_until_idle || (trackpad_pan && app.viewport.trackpad_until_idle) { raw.zoom + if trackpad_pan { 0.0 } else { raw.point_zoom } } else { scroll }; // Pinch-to-zoom (trackpad): direct manipulation, never smoothed. let pinch = ui.input(pinch_factor); let h = rect.height().max(1.0) as f64; if inside && pinch != 1.0 { app.viewport.orbit_snap = None; app.viewport.orbit_free = None; // Spread zooms in: the camera distance shrinks by the pinch factor. let f = f64::from(pinch).powf(-crate::prefs::zoom_sign(app)); let anchor = zoom_anchor(app, rect, hover); app.cancel_view_animation(); app.cam.zoom_at(f, anchor); } if inside && scroll != 0.0 { app.viewport.orbit_snap = None; app.viewport.orbit_free = None; let f = (-scroll as f64 * 0.0015 * crate::prefs::zoom_sign(app)).exp(); let anchor = zoom_anchor(app, rect, hover); app.cancel_view_animation(); app.cam.zoom_at(f, anchor); } // Two-finger scroll pans the model with the fingers. if inside && pan != egui::Vec2::ZERO { app.viewport.orbit_snap = None; app.viewport.orbit_free = None; app.cancel_view_animation(); app.cam.pan(f64::from(pan.x), f64::from(pan.y), h); } // A left press with the preset's keys (Alias: Shift+Alt) navigates until the button is let // go (kept through the release frame, so the end of the drag doesn't select either). if primary_pressed && inside { let empty_space = |app: &SolveApp| { app.viewport.context_menu.is_none() && hover.is_some_and(|p| { let proj = projection(app, rect); pick(app, &proj, p).is_empty() && crate::dim_view::hit(p).is_none() && crate::sketch_tools::glyph_at(p).is_none() }) }; let openscad_orbit = app.preferences.nav == crate::prefs::OPENSCAD_NAV && app.tool.is_none() && app.dialog.is_none() && !mods.shift && empty_space(app); let preset = if app.preferences.nav != crate::prefs::OPENSCAD_NAV || openscad_orbit { crate::prefs::nav_mode(app, crate::prefs::NavButton::Left, mods) } else { None }; // Ctrl+left drag from empty space orbits in every preset (Cmd on macOS). A Ctrl+click // still selects, and Ctrl on geometry still drags it without snapping. let ctrl_orbit = (mods.ctrl || mods.command) && !mods.shift && !mods.alt && empty_space(app); app.viewport.primary_nav = preset.or(ctrl_orbit.then_some(NavMode::Orbit)); } else if !primary_down && !primary_released { app.viewport.primary_nav = None; } // The fixed navigation mode (navigation bar) or the preset's left-button binding. let primary_nav = app.viewport.nav.or(app.viewport.primary_nav); let button = if middle { Some(crate::prefs::NavButton::Middle) } else if secondary_down { Some(crate::prefs::NavButton::Right) } else { None }; let dragging = resp.dragged() || (inside && button.is_some()); let mode = if dragging && app.viewport.axis_drag.is_none() { match button { Some(b) => crate::prefs::nav_mode(app, b, mods), None if primary_down => primary_nav, None => None, } } else { None }; if mode == Some(NavMode::Orbit) { let press = ui.input(|i| i.pointer.press_origin()); app.cancel_view_animation(); // Shift can be required to enter orbit (Fusion/Inventor middle drag, Alias Shift+Alt), // rather than an additional request for snapping. Only an extra modifier snaps. let without_shift = egui::Modifiers { shift: false, ..mods }; let snap = app.preferences.nav != crate::prefs::OPENSCAD_NAV && mods.shift && match button { Some(b) => crate::prefs::nav_mode(app, b, without_shift) == Some(NavMode::Orbit), None => app.viewport.nav.is_some(), }; orbit_drag(app, rect, f64::from(delta.x), f64::from(delta.y), press, snap, true); } else { if matches!(mode, Some(NavMode::Pan | NavMode::Zoom)) { app.viewport.orbit_snap = None; } if app.viewport.axis_drag.is_none() { app.viewport.orbit_free = None; } if delta != egui::Vec2::ZERO { if mode.is_some() { app.cancel_view_animation(); } match mode { Some(NavMode::Pan) => app.cam.pan(f64::from(delta.x), f64::from(delta.y), h), Some(NavMode::Zoom) => app.cam.zoom_at((f64::from(delta.y) * 0.01).exp(), None), _ => {} } } } if !dragging { app.viewport.orbit_pivot = None; } if app.viewport.spin { // A click on the navigation bar (its own button) is not a stop. let bar = app .viewport .layout .as_ref() .map(|l| l.navigation) .unwrap_or_else(|| Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(320.0, 30.0))); let (pressed, key, dt) = ui.input(|i| { ( i.pointer.any_pressed() && inside && !i.pointer.press_origin().is_some_and(|p| bar.contains(p)), i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. })), i.stable_dt, ) }); if pressed || key || dragging { app.viewport.spin = false; } else { app.cam.orbit(f64::from(dt.min(0.1)) * 30.0, 0.0); ui.ctx().request_repaint(); } } if !app.cam.is_valid() { app.viewport.orbit_free = None; app.viewport.orbit_snap = None; app.cam = Camera::default(); app.fit_view(); } step_orbit_snap(app, rect, ui.input(|i| f64::from(i.stable_dt))); if app.viewport.orbit_snap.is_some() { ui.ctx().request_repaint(); } app.viewport.physical_scale = ui.ctx().pixels_per_point(); let proj = projection(app, rect); if let Ok(mut cache) = app.viewport.display.lock() { cache.prepare(app.session.world_state(), &proj, app.viewport.pixels_per_point()); } // Suppress hover for the whole navigation gesture, including frames between pointer // events. Testing delta made the highlight reappear whenever a held drag paused. // Resolve it before rendering so the GPU scene and screen overlays use the same hit. let navigating = scroll != 0.0 || pinch != 1.0 || pan != egui::Vec2::ZERO || middle || secondary_down || (primary_nav.is_some() && primary_down) || mode.is_some() || app.viewport.axis_drag.is_some() || app.viewport.spin || app.viewport.orbit_snap.is_some() || app.cam_anim.is_some(); let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none() && !navigating).map(|p| pick_cached(app, &proj, p)).unwrap_or_default(); // The Fillet and Chamfer window resolves its own corners: the nearest sketch point to a // corner drawn from the origin is the fixed origin, which ends no two curves. let corner = hover.filter(|_| inside && !navigating).and_then(|p| crate::sketch_modify_ui::corner_hit(app, &proj, p)); let cand = if app.tool.is_some() { hits.first().cloned().map(|h| (h, None)) } else if let Some((hit, sel)) = corner { Some((hit, Some(sel))) } else { candidate(app, &hits).map(|(h, s)| (h, Some(s))) }; app.viewport.hover = cand.as_ref().map(|c| c.0.clone()); // Select Other: a press held still opens the list of everything under it; while the list is // open its row under the pointer is what lights up. let held = crate::select_other::track_hold(app, ui.ctx(), &proj, inside, primary_pressed, primary_down, hover, primary_nav.is_some() || mode.is_some()); crate::select_other::hover_override(app); let painter = ui.painter_at(rect); // Background gradient. let (top, bot) = (t.viewport_top, t.viewport_bottom); let mut mesh = egui::Mesh::default(); mesh.colored_vertex(rect.left_top(), top); mesh.colored_vertex(rect.right_top(), top); mesh.colored_vertex(rect.right_bottom(), bot); mesh.colored_vertex(rect.left_bottom(), bot); mesh.add_triangle(0, 1, 2); mesh.add_triangle(0, 2, 3); painter.add(Shape::mesh(mesh)); // ---- model ---- let key = scene_key(app); let t0 = crate::now_ms(); if let Some(_g) = app.viewport.gpu { if key != app.viewport.key { let sc = build_scene(app); if let Ok(mut slot) = app.viewport.slot.lock() { *slot = Some(sc); } app.viewport.key = key; app.viewport.build_ms = crate::now_ms() - t0; } let sk = sketch_scene_key(app, &proj); if sk != app.viewport.sketch_key { let scene = build_sketch_scene(app, &proj); if let Ok(mut slot) = app.viewport.sketch_slot.lock() { *slot = Some(scene); } app.viewport.sketch_key = sk; } let hk = highlight_key(app); if hk != app.viewport.hl_key { let hl = build_highlight(app); if let Ok(mut slot) = app.viewport.hl_slot.lock() { *slot = Some(hl); } app.viewport.hl_key = hk; } let ppp = ui.ctx().pixels_per_point(); let cb = ViewportCallback { key: app.viewport.key, slot: app.viewport.slot.clone(), sketch_key: app.viewport.sketch_key, sketch_slot: app.viewport.sketch_slot.clone(), hl_key: app.viewport.hl_key, hl_slot: app.viewport.hl_slot.clone(), pv_key: app.preview.key, pv_slot: app.preview.slot.clone(), clip: section_clip(app), cap: t.section_cap.to_normalized_gamma_f32(), view_proj: proj.vp.to_f32(), back: proj.cam.back().to_f32(), size_px: [rect.width() * ppp, rect.height() * ppp], images: crate::ref_images::gpu_images(app), analysis: analysis_uniform(app), access: crate::access::map(app), outline: matches!(app.ui.visual_style, 0 | 3), eye: { let e = proj.cam.eye().to_f32(); [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }] }, }; painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb)); } else { cpu_render(app, ui.ctx(), &painter, rect, &proj); } // ---- interaction ---- let add = mods.shift || mods.command || mods.ctrl; crate::sketch_text::set_one_letter(mods.shift); if let Some(p) = hover.filter(|_| inside) { if app.tool.is_some() { crate::tools::on_hover(app, &proj, p); } if resp.clicked() && !held { if app.tool.is_some() { crate::tools::on_click(app, &proj, p); } else if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) { if let Some((hit, Some(sel))) = cand { dialog_pick(app, &hit, sel); } else if let Some(mut dialog) = app.dialog.take() { let state = app.session.world_state(); if let Some(sketch) = app.session.active_sketch.and_then(|id| state.sketch(id)) { let (origin, direction) = proj.ray(p); if let Some(point) = sketch.plane.intersect_ray(origin, direction) { crate::sketch_modify_ui::pick_position(&mut dialog, &app.session, point); } } app.dialog = Some(dialog); } } else { // Dimensions and constraint glyphs sit on top of the geometry. let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k); let sketching = app.session.active_sketch.is_some(); // A dimension's text wins; its lines only where no sketch point or curve is (a // diameter's line runs through the centre point). let on_geometry = matches!(cand.as_ref().map(|c| &c.0), Some(Hit::SketchPoint { .. } | Hit::SketchCurve { .. })); let on_dim = crate::dim_view::hit_text(p) .or_else(|| if on_geometry { None } else { crate::dim_view::hit(p) }) .filter(|_| sketching && !off("dimensions")); let on_glyph = crate::sketch_tools::glyph_at(p).filter(|_| sketching && !off("constraints")); if crate::sketch_text_handles::over(app, &proj, p) { // A text's resize or rotation handle: the text stays selected. } else if !crate::sketch3d::click(app, &proj, p) { match on_dim.or(on_glyph) { Some(id) => select(app, Some(Sel::SketchConstraint { id }), add), // Clicking again on the same spot selects the next thing under it (not // the second click of a double-click, which has its own actions). None if !add && !resp.double_clicked() && crate::select_other::cycle_click(app, &proj, p) => {} None => { let sel = cand.and_then(|c| c.1); select(app, sel.clone(), add); crate::select_other::note_click(app, p, if add { None } else { sel }); } } } } } // Double-click a dimension: edit its value in place. let dim_edit = resp.double_clicked() && !held && app.tool.is_none() && app.dialog.is_none() && crate::dim_view::double_click(app, p); // Double-click a text (an outline or inside a letter; the first click selected it): edit it. if resp.double_clicked() && !held && !dim_edit && app.tool.is_none() && app.dialog.is_none() && let Some(entity) = crate::sketch_text::selected_entity(app) { crate::sketch_text::open(app, &entity); } // Double-click a sketch curve: select the chain joined to it end to end. if resp.double_clicked() && !held && !dim_edit && app.tool.is_none() && app.dialog.is_none() && let Some(Hit::SketchCurve { sketch, id, .. }) = app.viewport.hover.clone() && app.session.active_sketch == Some(sketch) && let Ok(v) = app.session.execute("sketch.chain", &json!({ "curve": id })) { let items: Vec = v["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str()).map(|id| Sel::SketchCurve { id: id.into() }).collect(); let _ = app.run("select.set", json!({ "items": items, "add": add })); } // Double-click an edge: select the edges that continue it smoothly (the loop). if resp.double_clicked() && !held && !dim_edit && app.tool.is_none() && app.dialog.is_none() && let Some(Hit::Edge { body, index, .. }) = app.viewport.hover.clone() && let Some(b) = app.session.world_state().body(&body).cloned() { let m = b.mesh(); let items: Vec = m .tangent_chain(index, 2f64.to_radians()) .into_iter() .filter_map(|i| m.edges.get(i).map(|e| Sel::Edge { body: body.clone(), index: i, point: polyline_mid(e) })) .collect(); let _ = app.run("select.set", json!({ "items": items, "add": add })); } if resp.secondary_clicked() && delta == egui::Vec2::ZERO { if app.tool.is_some() { crate::tools::finish(app); } else if app.dialog.is_none() { crate::context_menu::open(app, p); } } } // Dragging a sketch point of the active sketch moves it; the solver keeps the constraints. if app.tool.is_none() && app.dialog.is_none() && primary_nav.is_none() && mode.is_none() && app.session.active_sketch.is_some() && !held { let on_plane = |app: &SolveApp, p: Pos2| { let ss = app.session.active_sketch.and_then(|sid| app.session.world_state().sketch(sid).cloned())?; let (o, d) = proj.ray(p); ss.plane.intersect_ray(o, d).map(|w| ss.plane.to_local(w)) }; // What a drag starts on: the 3D triad, then a dimension's text, then sketch geometry // (before the dimension's lines, which end on the geometry they measure), as for clicks. let grabbed = resp .drag_started_by(egui::PointerButton::Primary) .then(|| ui.input(|i| i.pointer.press_origin())) .flatten() .filter(|a| !crate::sketch_text_handles::drag_start(app, &proj, *a) && !crate::sketch3d::drag_start(app, &proj, *a)) .map(|a| (a, pick(app, &proj, a).into_iter().next())); if let Some((a, hit)) = &grabbed && !crate::sketch_text_handles::dragging(app) && let Some(q) = on_plane(app, *a) { let on_geometry = matches!(hit, Some(Hit::SketchPoint { .. } | Hit::SketchCurve { .. })); crate::dim_view::drag_start(app, *a, q, on_geometry); } if crate::sketch3d::dragging() && (resp.dragged_by(egui::PointerButton::Primary) || resp.drag_stopped()) && let Some(p) = hover { crate::sketch3d::drag_to(app, &proj, p); } if crate::sketch_text_handles::dragging(app) && (resp.dragged_by(egui::PointerButton::Primary) || resp.drag_stopped()) && let Some(p) = hover { crate::sketch_text_handles::drag_to(app, &proj, p, mods.shift); } if crate::dim_view::dragging() && (resp.dragged_by(egui::PointerButton::Primary) || resp.drag_stopped()) && let Some(q) = hover.and_then(|p| on_plane(app, p)) { crate::dim_view::drag_to(app, q); } // A point or a curve of the sketch being edited follows a drag that starts on it. if let Some((a, hit)) = grabbed && !crate::dim_view::dragging() && !crate::sketch3d::dragging() && !crate::sketch_text_handles::dragging(app) { let active = app.session.active_sketch; match hit { Some(Hit::SketchPoint { sketch, id, .. }) if Some(sketch) == active => { app.viewport.point_drag = Some((id, app.session.undo.len(), None)) } Some(Hit::SketchCurve { sketch, id, .. }) if Some(sketch) == active => { app.viewport.point_drag = on_plane(app, a).map(|q| (id, app.session.undo.len(), Some(q))); } _ => {} } if let Some((id, _, _)) = &app.viewport.point_drag { let link = app.session.active_sketch.and_then(|sid| app.session.doc.sketch(sid).ok()).and_then(|sk| { let link = sk .curve_index(id) .and_then(|i| sk.curves.get(i)) .and_then(|c| c.link.as_deref()) .or_else(|| sk.resolve_point(id).and_then(|i| sk.points.get(i)).and_then(|p| p.link.as_deref()))?; sk.link(link).filter(|l| l.kind == solidcraft_engine::sketch::LinkKind::Text).map(|l| l.id.clone()) }); if let Some(link) = link && let Some(q) = on_plane(app, a) { if let Some((_, _, grab)) = &mut app.viewport.point_drag { *grab = Some(q); } if let Ok(result) = app.session.execute("edit.checkpoint", &json!({})) && let Some(checkpoint) = result["checkpoint"].as_u64() { app.viewport.text_drag = Some((checkpoint, link)); } } } } if let Some((id, depth, grab)) = app.viewport.point_drag.clone() && resp.dragged_by(egui::PointerButton::Primary) && let Some(mut q) = hover.and_then(|p| on_plane(app, p)) { let from = grab.unwrap_or(q); // Snap the dragged point (or the line's ends) to other geometry, like drawing. let snap = { let st = app.session.world_state(); app.session.active_sketch.and_then(|s| st.sketch(s)).and_then(|ss| { let sk = &ss.sketch; let (own, exclude) = crate::drag_snap::moving_points(sk, &id); if own.iter().any(|&p| sk.point_locked(p)) { return crate::drag_snap::find(sk, &[], &[], 0.0); } let a = proj.to_screen(ss.plane.to_world(q))?; let b = proj.to_screen(ss.plane.to_world(q + Vec2::X))?; let mm_px = 1.0 / (a.distance(b) as f64).max(1e-9); let d = q - from; let moving: Vec<(usize, Vec2)> = own.iter().filter_map(|&p| if grab.is_some() { sk.point(p).map(|x| (p, x + d)) } else { Some((p, q)) }).collect(); crate::drag_snap::find(sk, &moving, &exclude, mm_px) }) }; if let Some(s) = snap { q += s.offset; } // Each frame re-solves the sketch only (`live`); the model follows when the drag ends. let at = match app.session.execute("sketch.drag", &json!({"entity": id, "from": [from.x, from.y], "to": [q.x, q.y], "live": true})) { Ok(r) => { app.session.undo.truncate(depth + 1); r.get("at").and_then(solidcraft_engine::params::vec2).unwrap_or(q) } Err(_) => from, }; // The grab stays on the geometry: what it could not follow this frame it catches up // with on the next one instead of lagging the cursor for the rest of the drag. if grab.is_some() { app.viewport.point_drag = Some((id, depth, Some(at))); } } if resp.drag_stopped() { crate::sketch_text_handles::drag_end(app); if let Some((_, depth, _)) = app.viewport.point_drag.clone() { // Letting go on a point, midpoint or curve adds the matching constraint, then the // whole model follows the drag, all in the drag's undo step. let _ = app.session.execute("sketch.drag_end", &json!({ "constraint": crate::drag_snap::release() })); app.session.undo.truncate(depth + 1); } crate::drag_snap::clear(); app.viewport.point_drag = None; crate::sketch_text_move::end_drag(app); crate::dim_view::drag_end(); crate::sketch3d::drag_end(); } } // A sketch drag whose release never came (focus lost, a tool or dialog took over) ends here, // so the model does not stay behind its sketch until the next command. Not while CAD input // is held back (an issue report's capture): a release then belongs to the report, and the // drag continues afterwards. if app.viewport.point_drag.is_some() && !crate::issue_report::cad_input_blocked() && !ui.input(|i| i.pointer.primary_down()) { app.viewport.point_drag = None; crate::drag_snap::clear(); let _ = app.session.execute("sketch.drag_end", &json!({})); crate::sketch_text_move::end_drag(app); } if crate::sketch_text_handles::dragging(app) && !crate::issue_report::cad_input_blocked() && !ui.input(|i| i.pointer.primary_down()) { crate::sketch_text_handles::drag_end(app); } // Box selection: a primary drag on the model when no navigation mode is on. if app.tool.is_none() && !held && primary_nav.is_none() && mode.is_none() && app.viewport.point_drag.is_none() && !crate::dim_view::dragging() && !crate::sketch3d::dragging() && !crate::sketch_text_handles::dragging(app) { let origin = ui.input(|i| i.pointer.press_origin()); if resp.dragged_by(egui::PointerButton::Primary) && let (Some(a), Some(b)) = (origin, hover) && rect.contains(a) && !axes.contains(a) { app.viewport.boxsel = Some(BoxSel::from_drag(a, b)); } if resp.drag_stopped() && let Some(bx) = app.viewport.boxsel.take() { box_select(app, &proj, bx, add); } } else { app.viewport.boxsel = None; } crate::preview::paint_sketches(app, &painter, &proj); crate::ref_images::show(app, ui, &painter, &proj); crate::dialogs_assembly::origins(app, &painter, &proj); overlays(app, &painter, &proj); crate::dim_view::show(app, ui, &painter, &proj); crate::sketch_text_handles::show(app, ui, &painter, &proj, hover); crate::sketch3d::show(app, &painter, &proj); crate::inference::show(app, &painter, &proj); crate::drag_snap::show(app, &painter, &proj); hover_highlight(app, &painter, &proj); points_2d(app, &painter, &proj); if let Some((plane, sketch)) = crate::sketch_attachment_ui::lost_guide(app) && let Some((low, high)) = sketch.bounds() && let Some(at) = proj.to_screen(plane.to_world(solidcraft_engine::geom::Vec2::new((low.x + high.x) * 0.5, high.y))) { painter.text(at - vec2(0.0, 12.0), Align2::CENTER_BOTTOM, "Saved sketch plane — attachment lost", FontId::proportional(12.0), t.warning); } if let Some(bx) = app.viewport.boxsel { let col = if bx.crossing { t.box_crossing } else { t.box_window }; painter.rect_filled(bx.rect, 0.0, col.gamma_multiply(0.12)); if bx.crossing { let c = [bx.rect.left_top(), bx.rect.right_top(), bx.rect.right_bottom(), bx.rect.left_bottom()]; for i in 0..4 { painter.add(Shape::dashed_line(&[c[i], c[(i + 1) % 4]], Stroke::new(1.2, col), 6.0, 4.0)); } } else { painter.rect_stroke(bx.rect, 0.0, Stroke::new(1.2, col), egui::StrokeKind::Inside); } } if let Some(tl) = app.tool.as_ref() { crate::tools::preview(app, tl, &painter, &proj); } crate::canvas::show(app, ui, &painter, &proj); crate::sketch_tools::show(app, ui, &painter, &proj); crate::sketch_dims::show(app, ui, &proj); crate::select_other::show(app, ui.ctx()); axis_view(app, ui, rect); nav_bar(app, ui, rect); // Status chip. if let Some((msg, at, err)) = app.status.clone() { if crate::now_ms() - at < 6000.0 { let pos = pos2(rect.left() + 12.0, rect.bottom() - 46.0); let galley = painter.layout_no_wrap(msg, FontId::proportional(12.5), if err { t.error } else { t.text }); let r = Rect::from_min_size(pos, galley.size() + vec2(16.0, 8.0)); painter.rect_filled(r, 4.0, t.overlay); painter.galley(pos + vec2(8.0, 4.0), galley, t.text); ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); } else { app.status = None; } } } /// A click without a command: select the candidate (replacing the selection, or toggling it /// in with Shift/Ctrl); a click on nothing clears. pub(crate) fn select(app: &mut SolveApp, sel: Option, add: bool) { match sel { Some(x) => { let items = picked_selection(&app.session, &x); if add && items.iter().all(|x| app.session.selection.iter().any(|y| crate::selection::same_item(x, y))) { let rest: Vec = app.session.selection.iter().filter(|y| !items.iter().any(|x| crate::selection::same_item(x, y))).cloned().collect(); let _ = app.run("select.set", json!({ "items": rest })); } else { let _ = app.run("select.set", json!({"items": items, "add": add})); } } None if !add => { let _ = app.run("select.clear", json!({})); } None => {} } } /// What a click on `sel` picks: its pieces ([`grouped_selection`]), and for a letter of a sketch /// text the whole text, unless Shift is held (Shift picks the one letter). pub(crate) fn picked_selection(s: &solidcraft_engine::Session, sel: &Sel) -> Vec { if let Sel::Profile { sketch, index } = sel && !crate::sketch_text::one_letter() && let Some(letters) = crate::sketch_text::text_profiles(s, *sketch, *index) { return letters.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect(); } grouped_selection(s, sel) } /// Expand a visible rim or surface into the kernel pieces a command needs. The original /// face pick keeps its point (used by tools such as Hole and construction geometry). pub(crate) fn grouped_selection(s: &solidcraft_engine::Session, sel: &Sel) -> Vec { let st = s.world_state(); match sel { Sel::Edge { body, index, .. } => { if let Some(b) = st.body(body) { let m = b.mesh(); return m .circular_rim(*index) .into_iter() .filter_map(|i| m.edges.get(i).map(|e| Sel::Edge { body: body.clone(), index: i, point: polyline_mid(e) })) .collect(); } } Sel::Face { body, index, .. } => { if let Some(b) = st.body(body) { let m = b.mesh(); let mut patch = m.surface_patch(*index); patch.sort_by_key(|i| usize::from(i != index)); return patch .into_iter() .filter_map(|i| { if i == *index { return Some(sel.clone()); } m.triangles.iter().zip(&m.tri_face).find_map(|(t, f)| { if *f as usize != i { return None; } let [a, b, c] = m.tri(t)?; Some(Sel::Face { body: body.clone(), index: i, point: (a + b + c) / 3.0 }) }) }) .collect(); } } _ => {} } vec![sel.clone()] } /// Measure keeps one entry per visible item, rather than using its two slots for kernel halves. pub(crate) fn measurement_selection(s: &solidcraft_engine::Session, sel: &Sel) -> Sel { let pieces = grouped_selection(s, sel); let mut representative = sel.clone(); match &mut representative { Sel::Edge { index, .. } => { if let Some(Sel::Edge { index: i, .. }) = pieces.first() { *index = *i; } } Sel::Face { index, .. } => { if let Some(i) = pieces .iter() .filter_map(|p| match p { Sel::Face { index, .. } => Some(*index), _ => None, }) .min() { *index = i; } } _ => {} } representative } /// What a box selects: sketch entities while sketching, otherwise what the open dialog's /// active input takes (edges, faces or bodies), otherwise bodies. fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { use crate::selection::{BODIES, EDGES, FACES, PROFILES}; let st = app.session.world_state(); let mut out: Vec = Vec::new(); let to2 = |pts: &[Vec3]| -> Vec { pts.iter().filter_map(|p| proj.to_screen(*p)).collect() }; let sketch_dialog = app.dialog.as_ref().is_some_and(|d| matches!(d.kind, crate::dialogs::Kind::SketchModify(_))); let accepts = app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept); if (app.dialog.is_none() || sketch_dialog) && let Some(ss) = app.session.active_sketch.and_then(|sid| st.sketch(sid)) { // What the selection filter lets through (window: wholly inside; crossing: touching). let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k); for (i, c) in ss.sketch.curves.iter().enumerate().filter(|_| !off("sketch") && !off("sketch_curves")) { if accepts .is_some_and(|a| a & crate::selection::CURVES == 0 && (a & crate::selection::AXES == 0 || !matches!(c.kind, CurveKind::Line { .. }))) { continue; } if !solidcraft_engine::view::sketch_curve_visible(&ss.sketch, i) { continue; } let sampled = if bx.crossing { crate::sketch_display::curve_sample(app, ss.feature, i, &ss.plane, proj) } else { std::sync::Arc::new(crate::sketch_display::curve_unclipped(&ss.sketch, i, &ss.plane, proj, app.viewport.pixels_per_point())) }; let parts: Vec> = sampled.parts.iter().map(|part| to2(part)).collect(); let takes = if bx.crossing { parts.iter().any(|part| bx.takes(part)) } else { sampled.quality == solidcraft_engine::render::SamplingQuality::Complete && !parts.is_empty() && parts.iter().all(|part| bx.takes(part)) }; if takes { out.push(Sel::SketchCurve { id: c.id.clone() }); } } let visible = solidcraft_engine::view::SketchPointVisibility::new(&ss.sketch); for (i, p) in ss.sketch.points.iter().enumerate().filter(|_| !off("sketch") && !off("sketch_points")) { if accepts.is_none_or(|a| a & crate::selection::SKETCH_POINTS != 0) && i != 0 && visible.takes(p) && bx.takes(&to2(&[ss.plane.to_world(p.pos)])) { out.push(Sel::SketchPoint { id: p.id.clone() }); } } if !sketch_dialog && !off("dimensions") { for (id, r) in crate::dim_view::text_rects() { if bx.takes(&[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()]) { out.push(Sel::SketchConstraint { id }); } } } if !sketch_dialog && !off("constraints") { for (id, c) in crate::sketch_tools::glyph_positions() { if bx.takes(&[c]) { out.push(Sel::SketchConstraint { id }); } } } } else { let accept = app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).unwrap_or(BODIES); for b in &st.bodies { if !app.session.body_visible(&b.name) { continue; } let m = b.mesh(); if accept & EDGES != 0 { for (ei, e) in m.edges.iter().enumerate() { let rim = m.circular_rim(ei); let takes = if bx.crossing { bx.takes(&to2(e)) } else { rim.iter().all(|&i| m.edges.get(i).is_some_and(|p| bx.takes(&to2(p)))) }; if m.edge_pickable(ei) && takes { out.push(Sel::Edge { body: b.name.clone(), index: ei, point: polyline_mid(e) }); } } } else if accept & FACES != 0 { for f in 0..b.body.face_count() { let mut pts: Vec = Vec::new(); for e in m.face_edges(u32::try_from(f).unwrap_or(u32::MAX)) { pts.extend(m.edges.get(e).into_iter().flatten().copied()); } let point = m .triangles .iter() .zip(&m.tri_face) .find(|(_, tf)| **tf as usize == f) .and_then(|(t, _)| m.tri(t)) .map(|[a, bb, c]| (a + bb + c) / 3.0); let takes = bx.takes(&to2(&pts)) && (bx.crossing || m.surface_patch(f).iter().all(|&face| { m.face_edges(u32::try_from(face).unwrap_or(u32::MAX)) .iter() .all(|&e| m.edges.get(e).is_some_and(|p| bx.takes(&to2(p)))) })); if let Some(point) = point && takes { out.push(Sel::Face { body: b.name.clone(), index: f, point }); } } } else if accept & BODIES != 0 { let crossing_hit = m.edges.iter().any(|e| bx.takes(&to2(e))); let all_in = m.edges.iter().all(|e| bx.takes(&to2(e))); if (bx.crossing && crossing_hit) || (!bx.crossing && all_in && !m.edges.is_empty()) { out.push(Sel::Body { name: b.name.clone() }); } } else if accept & PROFILES != 0 { for sid in visible_sketches(app) { let Some(ss) = st.sketch(sid) else { continue }; for (pi, p) in ss.profiles.iter().enumerate() { let parts = crate::sketch_display::boundary_parts(&p.region.outer, &ss.plane, proj, app.viewport.pixels_per_point()); let takes = if bx.crossing { parts.iter().any(|part| bx.takes(&to2(part))) } else { crate::sketch_display::profile_region(app, sid, pi, &ss.plane, proj).as_ref().as_ref().is_some_and(|display| { let points: Vec<_> = display.outer.segs.iter().map(|segment| ss.plane.to_world(segment.start())).collect(); bx.takes(&to2(&points)) }) }; if takes { out.push(Sel::Profile { sketch: sid, index: pi }); } } } break; } } } let mut grouped = Vec::new(); for item in out { for piece in grouped_selection(&app.session, &item) { if !grouped.iter().any(|x| crate::selection::same_item(x, &piece)) { grouped.push(piece); } } } let out = grouped; if let Some(mut d) = app.dialog.take() { d.take_box(&app.session, out, add); app.dialog = Some(d); return; } let items = serde_json::to_value(&out).unwrap_or_default(); let _ = app.run("select.set", json!({"items": items, "add": add})); } /// Screen-space dots: the origin point, and hovered or selected body vertices. fn points_2d(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { let t = Tokens::get(); if app.origin_visible() && !app.session.visibility.hidden_origin.iter().any(|h| h == "O") && let Some(o) = proj.to_screen(Vec3::ZERO) { painter.circle(o, 4.5, t.origin_point, Stroke::new(1.0, Color32::from_gray(110))); } for x in app.highlighted() { if let Sel::Vertex { point, .. } = x && let Some(p) = proj.to_screen(point) { painter.circle(p, 4.0, t.sel_vertex, Stroke::new(1.5, t.sel_edge)); } } if let Some(Hit::Vertex { point, .. }) = &app.viewport.hover && let Some(p) = proj.to_screen(*point) { painter.circle(p, 4.5, t.hover_edge_halo, Stroke::new(1.5, t.hover_edge)); } } /// Hover feedback for the active sketch's points and curves (drawn on top of everything). fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { let col = Tokens::get().hover_profile_edge; let st = app.session.world_state(); match &app.viewport.hover { Some(Hit::SketchCurve { sketch, id, .. }) => { if let Some(ss) = st.sketch(*sketch) && let Some(ci) = ss.sketch.curve_index(id) { for part in &crate::sketch_display::curve_sample(app, *sketch, ci, &ss.plane, proj).parts { let mode = ss.sketch.curves.get(ci).map_or(0, |c| if c.centerline { 2 } else { u8::from(c.construction) }); crate::tools::paint_sketch_path(part, mode, painter, proj, Stroke::new(3.0, col)); } } } Some(Hit::SketchPoint { sketch, at, .. }) => { if let Some(ss) = st.sketch(*sketch) && let Some(p) = proj.to_screen(ss.plane.to_world(*at)) { painter.circle_stroke(p, 6.0, Stroke::new(2.0, col)); } } _ => {} } } /// Sketch points and the status of the active sketch (constraint glyphs: `sketch_tools`). fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { let t = Tokens::get(); let st = app.session.world_state(); for item in &app.session.selection { if let Sel::ExternalSketchPoint { sketch, id } = item && let Some(ss) = st.sketch(*sketch) && let Some(point) = ss.sketch.resolve_point(id).and_then(|i| ss.sketch.point(i)) && let Some(pos) = proj.to_screen(ss.plane.to_world(point)) { painter.circle_stroke(pos, 6.0, Stroke::new(2.0, t.sel_edge)); } } let Some(sid) = app.session.active_sketch else { return }; let Some(ss) = st.sketch(sid) else { return }; let sk = &ss.sketch; let locked = sk.locked_points(); let virtual_corners: Vec = sk.virtual_corners().into_iter().map(|(i, _)| i).collect(); let visible = solidcraft_engine::view::SketchPointVisibility::new(sk); for (i, p) in sk.points.iter().enumerate() { if !visible.takes(p) { continue; } let hidden = (sk.view.hide_points && i != 0) || (sk.view.hide_projected && p.link.is_some()); let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)).filter(|_| !hidden) else { continue }; let det = ss.report.point_determined.get(i).copied().unwrap_or(false); let c = if p.link.is_none() && locked.get(i).copied().unwrap_or(false) { t.sketch_locked } else if det { t.sketch_point } else { t.sketch_point_free }; if i == 0 { painter.circle(sp, 3.5, Color32::from_rgb(240, 200, 60), Stroke::new(1.0, t.body_edge)); } else if virtual_corners.binary_search(&i).is_ok() { // A virtual sharp corner: an open square, it is not on any drawn curve. painter.rect_stroke(Rect::from_center_size(sp, vec2(6.0, 6.0)), 0.0, Stroke::new(1.2, c), egui::StrokeKind::Middle); } else { painter.rect_filled(Rect::from_center_size(sp, vec2(5.0, 5.0)), 0.0, c); } } // Sketch status. if let Some(r) = app.viewport.rect { let msg = if !ss.report.ok() { format!("{} — constraints conflict", ss.name) } else if ss.report.dof == 0 { format!("{} — fully constrained", ss.name) } else { format!("{} — {} degrees of freedom", ss.name, ss.report.dof) }; let c = if ss.report.ok() { t.sketch_accent } else { t.error }; painter.text(pos2(r.left() + 12.0, r.top() + 12.0), Align2::LEFT_TOP, msg, FontId::proportional(13.0), c); } } fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, rect: Rect, proj: &Proj) { if app.viewport.no_pixels { return; } let mut h = std::collections::hash_map::DefaultHasher::new(); sketch_scene_key(app, proj).hash(&mut h); for v in [proj.cam.yaw, proj.cam.pitch, proj.cam.roll, proj.cam.distance, proj.cam.target.x, proj.cam.target.y, proj.cam.target.z] { v.to_bits().hash(&mut h); } (rect.width() as u32, rect.height() as u32).hash(&mut h); let key = h.finish(); if app.viewport.cpu.as_ref().is_none_or(|(k, _)| *k != key) { let mut budget = solidcraft_engine::render::SamplingBudget::new(131_072, 1_048_576); let scene = solidcraft_engine::view::scene_with_section_and_sketch_sampler(&app.session, &proj.cam, section_plane(app), |sk, index, plane| { crate::sketch_display::curve_with_budget(sk, index, plane, proj, app.viewport.pixels_per_point(), &mut budget).parts }); let w = (rect.width() as usize).clamp(16, 4096); let hh = (rect.height() as usize).clamp(16, 4096); let c = solidcraft_engine::render::render(&scene, &proj.cam, w, hh); let img = egui::ColorImage::from_rgba_unmultiplied([c.w, c.h], &c.rgba); let tex = ctx.load_texture("sc_cpu_viewport", img, egui::TextureOptions::LINEAR); app.viewport.cpu = Some((key, tex)); } if let Some((_, tex)) = &app.viewport.cpu { painter.image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } } pub(crate) fn refresh_layout(app: &mut SolveApp, scale: f32) { if let Some(rect) = app.viewport.rect { let docks: Vec<_> = [app.viewport.palette_rect.filter(|_| app.session.active_sketch.is_some()), app.viewport.dialog_rect.filter(|_| app.dialog.is_some())] .into_iter() .flatten() .collect(); app.viewport.layout = Some(crate::viewport_layout::Layout::new(rect, &docks, scale, app.preferences.navigator_style)); } } /// Orientation controls share the layout reservation used by model fitting. fn axis_center(app: &SolveApp, rect: Rect) -> Pos2 { axis_rect(app, rect).center() } fn axis_rect(app: &SolveApp, rect: Rect) -> Rect { app.viewport.layout.as_ref().filter(|l| l.viewport == rect).map(|l| l.axis_view).unwrap_or_else(|| { let size = axis_view_size(app.preferences.navigator_style); Rect::from_min_size(pos2(rect.right() - size, rect.top()), vec2(size, size)) }) } const AXIS_LENGTH: f64 = 38.0; use crate::icons::navigation::AXIS_RADIUS; /// Six signed world axes, sorted back to front so overlapping tips have one owner. fn axis_tips(cam: &Camera, c: Pos2) -> Vec<(&'static str, Vec3, Pos2, f64)> { let (r, u, b) = cam.basis(); let mut tips: Vec<_> = [("x", Vec3::X), ("-x", -Vec3::X), ("y", Vec3::Y), ("-y", -Vec3::Y), ("z", Vec3::Z), ("-z", -Vec3::Z)] .into_iter() .map(|(name, d)| (name, d, c + vec2((d.dot(r) * AXIS_LENGTH) as f32, -(d.dot(u) * AXIS_LENGTH) as f32), d.dot(b))) .collect(); tips.sort_by(|a, b| a.3.total_cmp(&b.3)); tips } fn axis_hit(tips: &[(&str, Vec3, Pos2, f64)], p: Pos2) -> Option { tips.iter().enumerate().rev().find(|(_, (_, _, at, _))| at.distance(p) <= AXIS_RADIUS).map(|(i, _)| i) } /// Six principal views, twelve edge diagonals and eight isometric corner directions. fn snap_directions() -> impl Iterator { [-1.0, 0.0, 1.0].into_iter().flat_map(|x| { [-1.0, 0.0, 1.0].into_iter().flat_map(move |y| [-1.0, 0.0, 1.0].into_iter().filter_map(move |z| Vec3::new(x, y, z).normalized())) }) } /// Snap to the nearest standard or diagonal direction in the user's up-axis frame. fn snapped_axis_camera(cam: Camera) -> Camera { let b = cam.to_frame(cam.back()); let dir = snap_directions().max_by(|a, c| a.dot(b).total_cmp(&c.dot(b))).unwrap_or(Vec3::Z); let mut to = cam.looking_from(cam.from_frame(dir)); if dir.x == 0.0 && dir.y == 0.0 { to.yaw = nearest_quarter_turn(cam.yaw); } to } pub(crate) struct OrbitSnap { target: Camera, yaw_velocity: f64, pitch_velocity: f64, pivot: Option<(Vec3, Pos2)>, } /// Pointer motion accumulates independently of the spring preview, so many small deltas /// still cross snap boundaries. Keep a small angular margin to avoid boundary flicker. fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press: Option, snap: bool, around_cursor: bool) { let displayed = app.cam; if let Some(free) = app.viewport.orbit_free.take() { app.cam = free; } if around_cursor { crate::prefs::orbit(app, rect, dx, dy, press); } else { app.cam.orbit(dx, dy); } let free = app.cam; app.viewport.orbit_free = Some(free); if snap || !around_cursor || app.viewport.orbit_snap.is_some() { let mut target = if snap { snapped_axis_camera(free) } else { free }; if snap && let Some(previous) = app.viewport.orbit_snap.as_ref() { let b = free.back(); if previous.target.back().dot(b) + 0.025 >= target.back().dot(b) { target.yaw = previous.target.yaw; target.pitch = previous.target.pitch; } } let pivot = app.viewport.orbit_pivot.filter(|_| around_cursor).and_then(|p| { app.cam = displayed; projection(app, rect).to_screen(p).map(|at| (p, at)) }); let spring = app.viewport.orbit_snap.get_or_insert(OrbitSnap { target, yaw_velocity: 0.0, pitch_velocity: 0.0, pivot }); spring.target = target; spring.pivot = pivot; app.cam = displayed; } } /// A lightly damped spring: mostly settled in about a third of a second, with a small /// elastic finish. Bounded substeps keep the behavior consistent across frame rates. fn step_orbit_snap(app: &mut SolveApp, rect: Rect, dt: f64) { let Some(mut spring) = app.viewport.orbit_snap.take() else { return }; let dt = if dt.is_finite() { dt.clamp(0.0, 0.05) } else { 1.0 / 60.0 }; let steps = (dt * 240.0).ceil().max(1.0) as usize; let h = dt / steps as f64; let omega = 16.0; let damping = 0.82; let yaw_error = |yaw: f64| (spring.target.yaw - yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; for _ in 0..steps.min(12) { spring.yaw_velocity += (omega * omega * yaw_error(app.cam.yaw) - 2.0 * damping * omega * spring.yaw_velocity) * h; spring.pitch_velocity += (omega * omega * (spring.target.pitch - app.cam.pitch) - 2.0 * damping * omega * spring.pitch_velocity) * h; app.cam.yaw += spring.yaw_velocity * h; app.cam.pitch = (app.cam.pitch + spring.pitch_velocity * h).clamp(-std::f64::consts::FRAC_PI_2, std::f64::consts::FRAC_PI_2); } app.cam.target = spring.target.target; let done = yaw_error(app.cam.yaw).abs() < 1e-5 && (spring.target.pitch - app.cam.pitch).abs() < 1e-5 && spring.yaw_velocity.abs() < 1e-4 && spring.pitch_velocity.abs() < 1e-4; if done { app.cam = spring.target; } if let Some((pivot, at)) = spring.pivot && let Some(after) = projection(app, rect).to_screen(pivot) { app.cam.pan(f64::from(at.x - after.x), f64::from(at.y - after.y), f64::from(rect.height().max(1.0))); } if !done { app.viewport.orbit_snap = Some(spring); } } /// Dragging anywhere on the axis gizmo orbits about the view centre. A stationary press /// on a tip chooses its view. Floating windows own their input over the gizmo. fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool { let (pressed, down, decided, origin, pos, shift) = ui.input(|i| { let p = &i.pointer; (p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos(), i.modifiers.shift) }); if pressed && ui.rect_contains_pointer(rect) { app.viewport.axis_drag = origin.filter(|p| match app.preferences.navigator_style { crate::prefs::NavigatorStyle::Axes => axis_rect(app, rect).contains(*p), crate::prefs::NavigatorStyle::Cube => { navigation_cube::drag_origin(&app.cam, axis_center(app, rect), *p, axis_rect(app, rect).width() / navigation_cube::DESIGN_SIZE) } }); if app.viewport.axis_drag.is_some() { app.viewport.orbit_free = None; } } if !down && app.viewport.axis_drag.take().is_some() { app.viewport.orbit_free = None; } let Some(last) = app.viewport.axis_drag.filter(|_| decided) else { return false }; ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); if let Some(p) = pos { app.cancel_view_animation(); orbit_drag(app, rect, f64::from(p.x - last.x), f64::from(p.y - last.y), None, shift, false); app.viewport.axis_drag = Some(p); ui.ctx().request_repaint(); } true } fn axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { match app.preferences.navigator_style { crate::prefs::NavigatorStyle::Axes => signed_axis_view(app, ui, rect), crate::prefs::NavigatorStyle::Cube => navigation_cube::show(app, ui, rect), } } fn signed_axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { if !axis_rect(app, rect).is_positive() { app.viewport.axis_drag = None; crate::scenario::publish_count("axis_view_highlights", 0.0); return; } crate::scenario::publish_rect("axis_view:outer", axis_rect(app, rect)); let t = Tokens::get(); let c = axis_center(app, rect); let orbiting = axis_view_orbit(app, ui, rect); let tips = axis_tips(&app.cam, c); let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect)); let hovered = if orbiting { None } else { hover.and_then(|p| axis_hit(&tips, p)) }; let painter = ui.painter_at(rect); if orbiting || hover.is_some_and(|p| axis_rect(app, rect).contains(p)) { painter.circle_filled(c, 55.0, t.overlay); } for (i, (name, d, at, depth)) in tips.iter().enumerate() { crate::icons::navigation::axis_tip(&painter, c, name, d, *at, *depth, hovered == Some(i), t); crate::scenario::publish_handle(&format!("axis_view:{name}"), *at); } crate::scenario::publish_count("axis_view_highlights", f64::from(u8::from(hovered.is_some()))); if let Some(i) = hovered && let Some((name, d, _, _)) = tips.get(i) { crate::scenario::publish_handle(&format!("axis_view_hover:{name}"), hover.unwrap_or(c)); ui.interact(Rect::from_center_size(hover.unwrap_or(c), vec2(2.0, 2.0)), ui.id().with("axis_view_tip"), Sense::hover()) .on_hover_text(format!("View from {}. Click again to reverse. Drag to orbit; hold Shift to snap.", name.to_uppercase())); ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); if ui.input(|i| i.pointer.primary_clicked()) { let frame = app.cam.to_frame(*d); let dir = if app.cam.back().dot(*d) > 0.9999 { -*d } else { *d }; let mut to = app.cam.looking_from(dir); if frame.z.abs() > 0.5 { to.yaw = nearest_quarter_turn(app.cam.yaw); } if app.preferences.look_at_anim { app.cancel_view_animation(); app.viewport.orbit_free = None; app.viewport.orbit_snap = Some(OrbitSnap { target: to, yaw_velocity: 0.0, pitch_velocity: 0.0, pivot: None }); ui.ctx().request_repaint(); } else { app.animate_to(to); } } } let home = Rect::from_center_size(pos2(c.x - 44.0, c.y - 44.0), vec2(18.0, 18.0)); crate::scenario::publish_rect("axis_view:home", home); crate::scenario::publish_handle("axis_view:home", home.center()); let hr = ui.interact(home, ui.id().with("axis_view_home"), Sense::click()); icons::paint(&painter, home, "home", t.icon, if hr.hovered() { t.accent_soft } else { t.overlay }, t.accent); if hr.on_hover_text(crate::i18n::tr_label("Home view")).clicked() { app.animate_view("home"); } } /// A middle-button double-click goes Home (or fits, or nothing: Preferences › Navigation). Only /// clicks count: a press that moves (a pan or orbit drag) or is held is not one, and the two /// clicks must both be the middle button's, close together in time and place. fn middle_double_click(app: &mut SolveApp, ui: &egui::Ui, inside: bool) { use egui::PointerButton::{Extra1, Extra2, Middle, Primary, Secondary}; let (double, releases, middle, other, dragging) = ui.input(|i| { ( i.pointer.button_double_clicked(Middle), i.events.iter().filter(|e| matches!(e, egui::Event::PointerButton { button: Middle, pressed: false, .. })).count(), i.pointer.button_clicked(Middle), [Primary, Secondary, Extra1, Extra2].into_iter().any(|b| i.pointer.button_clicked(b)), i.pointer.is_decidedly_dragging(), ) }); // egui pairs clicks of any buttons; the first click must have been the middle one too (or // both arrive in this frame). let go = double && inside && !other && (app.viewport.middle_clicked || releases >= 2); app.viewport.middle_clicked = if other || dragging || go { false } else { middle || app.viewport.middle_clicked }; if !go { return; } match crate::prefs::MiddleDoubleClick::of(app) { crate::prefs::MiddleDoubleClick::Home => app.animate_view("home"), crate::prefs::MiddleDoubleClick::Fit => app.animate_fit_all(), crate::prefs::MiddleDoubleClick::Off => {} } } /// At a halfway tie, prefer the square heading nearer the standard plan view. fn nearest_quarter_turn(yaw: f64) -> f64 { if !yaw.is_finite() { return 0.0; } let q = std::f64::consts::FRAC_PI_2; let lo = (yaw / q).floor() * q; let hi = lo + q; let (dlo, dhi) = (yaw - lo, hi - yaw); if (dlo - dhi).abs() < 1e-6 { let off_front = |a: f64| ((a + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI).abs(); if off_front(lo) <= off_front(hi) { lo } else { hi } } else if dlo < dhi { lo } else { hi } } /// Navigation bar (bottom centre). /// Turn the view to face the selected planar face or plane, else the active sketch. pub fn look_at_selection(app: &mut SolveApp) { let target = app.session.selection.iter().find_map(|s| match s { Sel::Face { body, index, point } => crate::dialogs::planar_face(&app.session, body, *index).map(|(_, n)| (*point, n)), Sel::Plane { name } => solidcraft_engine::geom::Plane::named(name).map(|p| (p.origin, p.normal())), _ => None, }); match target { Some((p, n)) => { let mut to = app.cam.looking_from(n); to.target = p; app.animate_to(to); } None => crate::dialogs::look_at_sketch(app), } } fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { if app.viewport.layout.as_ref().is_some_and(|l| !l.navigation.is_positive()) { return; } let t = Tokens::get(); let items: [(&str, &str); 10] = [ ( "orbit", if app.preferences.nav == crate::prefs::OPENSCAD_NAV { "Orbit (left drag on empty space; Shift+left drag box-selects)" } else { "Orbit (right drag; hold Shift to snap)" }, ), ("spin", "Continuous orbit (stops at the next click or key)"), ("pan", "Pan (drag; or middle drag)"), ("zoom", "Zoom (drag; or wheel)"), ("fit", "Fit (F6)"), ("lookat", "Look At the selected face or plane (or the active sketch)"), ("home", "Home view"), ("perspective", "Perspective / orthographic"), ("style", "Visual style: shaded with edges, shaded, wireframe, shaded with hidden edges"), ("settings", "View settings: grid, ground shadow, agent cursor"), ]; let menu_id = ui.id().with("nav_view_menu"); let mut menu_open = ui.data(|d| d.get_temp::(menu_id)).unwrap_or(false); let w = items.len() as f32 * 30.0 + 10.0; let bar = app .viewport .layout .as_ref() .map(|l| l.navigation) .unwrap_or_else(|| Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(w, 30.0))); crate::scenario::publish_rect("nav:outer", bar); let active = ui.rect_contains_pointer(bar) || ui.rect_contains_pointer(axis_rect(app, rect)) || app.viewport.axis_drag.is_some() || app.viewport.nav.is_some() || app.viewport.spin || menu_open; let mut painter = ui.painter_at(rect); painter.set_opacity(if active || app.preferences.navigator_style == crate::prefs::NavigatorStyle::Axes { 1.0 } else { 0.45 }); painter.rect(bar, 6.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); let columns = ((bar.width() - 10.0) / 30.0).round().clamp(1.0, 10.0) as usize; for (i, (icon, tip)) in items.iter().enumerate() { let br = Rect::from_min_size( pos2(bar.left() + 5.0 + (i % columns) as f32 * 30.0, bar.top() + 2.0 + (i / columns) as f32 * 30.0), vec2(26.0, 26.0), ); let resp = ui.interact(br, ui.id().with(("nav", *icon)), Sense::click()); let active = match *icon { "orbit" => app.viewport.nav == Some(NavMode::Orbit), "pan" => app.viewport.nav == Some(NavMode::Pan), "zoom" => app.viewport.nav == Some(NavMode::Zoom), "perspective" => app.ui.perspective, "settings" => menu_open, "spin" => app.viewport.spin, _ => false, }; if active || resp.hovered() { painter.rect_filled(br, 4.0, if active { t.accent_soft } else { t.hover }); } icons::paint(&painter, br.shrink(4.0), icon, t.icon, t.icon_fill, t.accent); if resp.on_hover_text(*tip).clicked() { let toggle = |m: NavMode, cur: Option| if cur == Some(m) { None } else { Some(m) }; match *icon { "orbit" => app.viewport.nav = toggle(NavMode::Orbit, app.viewport.nav), "pan" => app.viewport.nav = toggle(NavMode::Pan, app.viewport.nav), "zoom" => app.viewport.nav = toggle(NavMode::Zoom, app.viewport.nav), "fit" => app.animate_view("fit"), "spin" => { app.cancel_view_animation(); app.viewport.spin = !app.viewport.spin; } "home" => app.animate_view("home"), "lookat" => look_at_selection(app), "style" => app.ui.visual_style = (app.ui.visual_style + 1) % 4, "perspective" => app.ui.perspective = !app.ui.perspective, _ => menu_open = !menu_open, } } if *icon == "settings" { crate::scenario::publish_handle("nav:view", br.center()); if menu_open { menu_open = view_menu(app, ui, br); } } } ui.data_mut(|d| d.insert_temp(menu_id, menu_open)); } /// The View settings above the navigation bar's last button: grid, ground shadow and the agent /// cursor (#35). False once it should close (a click outside it). fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool { let area = crate::frame::area(egui::Id::new("sc_view_menu")) .order(egui::Order::Foreground) .pivot(Align2::RIGHT_BOTTOM) .fixed_pos(button.right_top() - vec2(0.0, 6.0)); let r = area.show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_width(220.0); ui.label(egui::RichText::new(crate::i18n::tr_label("VIEW")).strong().size(11.5)); ui.checkbox(&mut app.ui.show_grid, crate::i18n::tr_label("Grid")); ui.checkbox(&mut app.ui.ground_shadow, crate::i18n::tr_label("Ground shadow")); ui.separator(); let s = &mut app.ui.agent_cursor; let show = ui.checkbox(&mut s.show, crate::i18n::tr_label("Show Agent Cursor")); crate::scenario::publish_handle("view:agent_cursor", show.rect.center()); ui.add_enabled_ui(s.show, |ui| { ui.horizontal(|ui| { ui.label(crate::i18n::tr_label("Speed")); crate::agent_cursor::speed_slider(ui, &mut s.speed); }); ui.checkbox(&mut s.follow_camera, crate::i18n::tr_label("Follow camera (Look At sketches)")); }); }); }); let clicked_out = ui.input(|i| i.pointer.any_pressed()) && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !r.response.rect.contains(p) && !button.contains(p)); !clicked_out } #[cfg(test)] mod tests { use super::*; /// Bodies drawn from their display plans, evaluated on the GPU, look like the same bodies /// built into triangles on the CPU: the sample design rendered both ways through the /// viewport's real pipelines, offscreen, on this machine's GPU (skipped without one). #[cfg(not(target_arch = "wasm32"))] #[test] fn bodies_evaluated_on_the_gpu_look_like_bodies_built_on_the_cpu() { let mut s = solidcraft_engine::Session::default(); s.run_script(&solidcraft_engine::sample::script()).unwrap(); let mut app = SolveApp::new(s, crate::Services::default()); app.viewport.no_pixels = true; app.fit_view(); const SIZE: u32 = 256; let rect = Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32)); let mut render = |tess: bool| { app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess }); let scene = build_scene(&app); let proj = projection(&app, rect); let e = proj.cam.eye().to_f32(); let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }]; let planned = scene.planned.len(); (planned, scene.tris.len(), crate::gpu::render_offscreen(scene, proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, tess)) }; let (planned, gpu_tris, gpu) = render(true); let (none, cpu_tris, cpu) = render(false); let (Some(gpu), Some(cpu)) = (gpu, cpu) else { eprintln!("skipping: no GPU adapter"); return; }; assert!(planned > 0 && none == 0, "{planned} bodies planned"); assert!(gpu_tris < cpu_tris, "the planned bodies' triangles are not built on the CPU ({gpu_tris} vs {cpu_tris} bytes)"); let px = |img: &[u8], i: usize| [img[4 * i], img[4 * i + 1], img[4 * i + 2]]; let n = (SIZE * SIZE) as usize; let drawn = (0..n).filter(|&i| px(&cpu, i) != [255, 255, 255]).count(); assert!(drawn > n / 20, "the model covers the view: {drawn} pixels"); let differ = (0..n).filter(|&i| px(&gpu, i).iter().zip(px(&cpu, i)).any(|(a, b)| a.abs_diff(b) > 24)).count(); assert!(differ * 100 < drawn, "{differ} of {drawn} model pixels differ"); } #[test] fn construction_drawing_and_hover_overlays_preserve_visible_gaps() { fn length(shape: &Shape) -> f32 { match shape { Shape::LineSegment { points, .. } => points[0].distance(points[1]), Shape::Path(path) => path.points.windows(2).map(|w| w[0].distance(w[1])).sum(), Shape::Vec(shapes) => shapes.iter().map(length).sum(), _ => 0.0, } } let mut h = crate::scenario::Harness::new(); h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); h.call("ui.view", json!({"view":"top"})); h.settle(); let proj = display_proj(&h.app); let a = Vec2::ZERO; let b = Vec2::new(20.0, 8.0); let whole = proj.to_screen(Vec3::new(b.x, b.y, 0.0)).unwrap().distance(proj.to_screen(Vec3::ZERO).unwrap()); let mut draw = crate::tools::Tool::for_command("sketch.line").unwrap(); draw.pts.push((a, None)); draw.hover = Some((b, None)); for mode in [0, 1, 2] { crate::sketch_palette::set_mode(&mut h.app, mode); let ctx = egui::Context::default(); let output = ctx.run_ui(egui::RawInput::default(), |ui| { let painter = ui.ctx().layer_painter(egui::LayerId::background()); crate::tools::preview(&h.app, &draw, &painter, &proj); }); let preview: f32 = output.shapes.iter().map(|s| length(&s.shape)).sum(); let r = h.app.run("sketch.line", json!({"points":[[0,0],[20,8]]})).unwrap(); let id = r["curves"][0].as_str().unwrap().to_string(); h.app.viewport.hover = Some(Hit::SketchCurve { sketch: h.app.session.active_sketch.unwrap(), id, straight: true }); let output = ctx.run_ui(egui::RawInput::default(), |ui| { hover_highlight(&h.app, &ui.ctx().layer_painter(egui::LayerId::background()), &proj); }); let hover: f32 = output.shapes.iter().map(|s| length(&s.shape)).sum(); assert!((preview - hover).abs() < 0.01, "mode {mode}: preview {preview}, hover {hover}"); if mode == 0 { assert!((preview - whole).abs() < 0.01); } else { assert!(preview > whole * 0.25 && preview < whole * 0.85, "mode {mode} covers {preview} of {whole}"); } h.app.run("edit.undo", json!({})).unwrap(); } crate::sketch_palette::set_mode(&mut h.app, 0); } #[test] fn cpu_viewport_uses_section_preview_and_sketch_slice() { fn pixels(h: &mut crate::scenario::Harness, cam: Camera) -> Vec { let rect = Rect::from_min_size(Pos2::ZERO, vec2(320.0, 240.0)); let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect }; h.app.viewport.no_pixels = false; // Force a texture upload so this test reads the actual CPU rasterizer result. h.app.viewport.cpu = None; let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| { cpu_render(&mut h.app, ui.ctx(), ui.painter(), rect, &proj); }); let id = h.app.viewport.cpu.as_ref().unwrap().1.id(); let upload = out.textures_delta.set.get(&id).unwrap().iter().find(|delta| delta.pos.is_none()).unwrap(); let egui::ImageData::Color(image) = &upload.image; assert_eq!(image.size, [320, 240]); let pixels = image.pixels.clone(); out.textures_delta.clear(); pixels } let mut h = crate::scenario::Harness::new(); h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap(); let cam = solidcraft_engine::view::home_camera(&h.app.session); let whole = pixels(&mut h, cam); let before = h.app.session.doc.clone(); let undo = h.app.session.undo.len(); h.app.session.selection = vec![Sel::Plane { name: "XZ".into() }]; h.app.start("inspect.section"); let Some(crate::dialogs::Kind::Section { offset, .. }) = h.app.dialog.as_mut().map(|d| &mut d.kind) else { panic!("section dialog") }; *offset = "15 mm".into(); assert!(h.app.session.section.is_none()); let preview = pixels(&mut h, cam); assert!(whole.iter().zip(&preview).filter(|(a, b)| a != b).count() > 100, "CPU viewport ignored uncommitted section preview"); assert!(std::sync::Arc::ptr_eq(&before, &h.app.session.doc)); assert_eq!(h.app.session.undo.len(), undo); h.app.run("inspect.section", json!({"plane":"XZ","offset":15})).unwrap(); h.app.dialog = None; assert_eq!(preview, pixels(&mut h, cam), "preview and committed section must rasterize identically"); h.app.session.section = None; h.app.run("sketch.create", json!({"plane":{"origin":[0,0,10],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap(); let whole = pixels(&mut h, cam); h.app.run("sketch.options", json!({"slice":true})).unwrap(); assert!(h.app.session.section.is_none()); let sliced = pixels(&mut h, cam); assert!(whole.iter().zip(&sliced).filter(|(a, b)| a != b).count() > 100, "CPU viewport ignored active Sketch Slice"); h.app.run("sketch.options", json!({"slice":false})).unwrap(); assert_eq!(whole, pixels(&mut h, cam), "disabling Slice restores the whole-body pixels"); } #[test] fn timeline_modifiers_highlight_on_hover_and_click() { for command in ["solid.fillet", "solid.chamfer"] { let mut h = crate::scenario::Harness::new(); h.app.ui.show_origin = false; let base = h.app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap()["feature"].as_u64().unwrap(); let id = h.app.run(command, json!({"edges":[[0,0,10]],"radius":2,"distance":2,"name":"Blend"})).unwrap()["feature"].as_u64().unwrap(); h.app.run("solid.box", json!({"length":5,"width":5,"height":5,"corner":[40,0,0]})).unwrap(); h.frames(2); let geometry = feature_geometry(&h.app, id); assert_eq!(geometry.len(), 1, "{command}: {geometry:?}"); let Sel::Face { body, index, .. } = &geometry[0] else { panic!("a modifier must highlight its face") }; let st = h.app.session.world_state(); let m = st.body(body).unwrap().mesh(); let triangles = m.tri_face.iter().filter(|f| **f as usize == *index).count(); assert!(triangles > 0 && triangles < m.triangles.len()); let p = crate::scenario::handle_at("timeline:Blend").unwrap(); h.step(&json!({"move":[p.x,p.y]})).unwrap(); assert_eq!(h.app.viewport.hover_feature, Some(id)); assert_eq!(build_highlight(&h.app).tris.len(), triangles * 3 * crate::gpu::TRI_SIZE); assert!(!build_highlight(&h.app).lines.is_empty()); h.step(&json!({"click":[p.x,p.y]})).unwrap(); assert_eq!(h.app.session.selection, vec![Sel::Feature { id }]); // Moving away removes hover but leaves the selected blend highlighted. h.step(&json!({"move":[5,5]})).unwrap(); assert_eq!(h.app.viewport.hover_feature, None); assert_eq!(build_highlight(&h.app).tris.len(), triangles * 3 * crate::gpu::TRI_SIZE); let bounds = selection_bounds(&h.app).unwrap(); assert!(bounds.max.x <= 2.01 && bounds.max.y <= 2.01 && bounds.max.z > 19.99, "{bounds:?}"); // Creator features retain whole-body feedback, while hidden bodies stay hidden. assert_eq!(feature_geometry(&h.app, base), vec![Sel::Body { name: body.clone() }]); h.app.session.visibility.hidden_bodies.push(body.clone()); assert!(feature_geometry(&h.app, id).is_empty()); assert!(selection_bounds(&h.app).is_none()); assert!(build_highlight(&h.app).tris.is_empty()); } } #[test] fn timeline_modifier_highlight_follows_edits_and_rollback() { let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap(); let id = app.run("solid.fillet", json!({"edges":[[0,0,10]],"radius":2})).unwrap()["feature"].as_u64().unwrap(); // A later chamfer changes the same body without consuming the fillet. app.run("solid.chamfer", json!({"edges":[[20,20,10]],"distance":1})).unwrap(); app.run("solid.move", json!({"bodies":["Body1"],"translate":[30,0,0]})).unwrap(); app.run("body.rename", json!({"body":"Body1","name":"Moved"})).unwrap(); app.session.selection = vec![Sel::Feature { id }]; let bounds = selection_bounds(&app).unwrap(); assert!(bounds.min.x >= 29.99 && bounds.max.x <= 32.01, "{bounds:?}"); assert!(feature_geometry(&app, id).iter().all(|x| matches!(x, Sel::Face { body, .. } if body == "Moved"))); app.run("timeline.roll_to", json!({"position":1})).unwrap(); assert!(feature_geometry(&app, id).is_empty()); app.run("timeline.roll_to", json!({})).unwrap(); assert!(!feature_geometry(&app, id).is_empty()); app.run("timeline.suppress", json!({"feature":id})).unwrap(); assert!(feature_geometry(&app, id).is_empty()); } #[test] fn timeline_modifier_highlight_follows_component_instances() { let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); app.run("component.create", json!({"name":"Part"})).unwrap(); app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap(); let id = app.run("solid.fillet", json!({"edges":[[0,0,10]],"radius":2})).unwrap()["feature"].as_u64().unwrap(); app.run("occurrence.move", json!({"occurrence":"Part:1","translate":[30,0,0]})).unwrap(); app.run("occurrence.copy", json!({"component":"Part","translate":[0,40,0]})).unwrap(); let geometry = feature_geometry(&app, id); assert_eq!(geometry.len(), 2, "both occurrences retain the fillet's face: {geometry:?}"); app.session.selection = vec![Sel::Feature { id }]; let bounds = selection_bounds(&app).unwrap(); assert!(bounds.min.x >= 29.99 && bounds.max.x <= 32.01 && bounds.max.y >= 41.99, "{bounds:?}"); } fn hover_frame(app: &mut SolveApp, ctx: &egui::Context, events: Vec) { let selected = app.highlighted(); let mut output = ctx.run_ui( egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }, |ui| { show(app, ui); }, ); // This headless frame checks interactions without applying textures to a renderer. output.textures_delta.clear(); // Click selection is applied after rendering; hover itself must already be current. if app.viewport.gpu.is_some() && selected == app.highlighted() { assert_eq!(app.viewport.hl_key, highlight_key(app), "GPU highlight must use this frame's hover"); } } fn hover_app(gpu: bool) -> SolveApp { let mut s = solidcraft_engine::Session::default(); s.execute("sketch.create", &json!({"plane": "XY"})).unwrap(); s.execute("sketch.point", &json!({"point": [0, 0]})).unwrap(); let mut app = SolveApp::new(s, crate::Services::default()); app.viewport.no_pixels = true; if gpu { // The callback is queued by egui; no device is needed to inspect scene updates. app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false }); } app } #[test] fn ctrl_left_drag_from_empty_space_orbits_in_every_preset() { for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) { for action in ["orbit", "tool", "point", "click"] { let ctx = egui::Context::default(); let mut app = hover_app(false); app.preferences.nav = preset.into(); app.ui.show_origin = false; hover_frame(&mut app, &ctx, vec![]); let point = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap(); let (start, end) = match action { "point" => (point, point + vec2(30.0, 20.0)), "click" => (point, point), _ => (point - vec2(60.0, 60.0), point - vec2(20.0, 30.0)), }; if action == "tool" { app.start("sketch.line"); } let before = app.cam; let undo = app.session.undo.len(); let modifiers = egui::Modifiers { ctrl: true, command: !cfg!(target_os = "macos"), ..Default::default() }; let frame = |app: &mut SolveApp, mut events: Vec| { events.insert(0, egui::Event::ModifiersChanged(modifiers)); hover_frame(app, &ctx, events); }; frame( &mut app, vec![ egui::Event::PointerMoved(start), egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers }, ], ); frame(&mut app, vec![egui::Event::PointerMoved(end)]); frame(&mut app, vec![]); match action { "orbit" | "tool" => { assert_ne!(app.cam, before, "{preset}: Ctrl+left drag on empty space orbits ({action})"); assert!(app.viewport.boxsel.is_none(), "{preset}: no box selection while orbiting"); } "point" => { assert_eq!(app.cam, before, "{preset}: Ctrl+drag on sketch geometry is not an orbit"); assert!(app.viewport.point_drag.is_some(), "{preset}: Ctrl+drag still drags the point"); } _ => assert_eq!(app.cam, before, "{preset}: a Ctrl+click does not orbit"), } frame(&mut app, vec![egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers }]); frame(&mut app, vec![]); match action { "click" => assert!(!app.session.selection.is_empty(), "{preset}: Ctrl+click still selects"), "tool" => assert_eq!(app.session.undo.len(), undo, "{preset}: the orbit drag adds no geometry"), "orbit" => assert!(app.session.selection.is_empty(), "{preset}: the end of the orbit selects nothing"), _ => {} } } } } #[test] fn openscad_left_drag_preserves_editing_and_selection() { for action in ["point", "box", "tool"] { let ctx = egui::Context::default(); let mut app = hover_app(false); app.preferences.nav = crate::prefs::OPENSCAD_NAV.into(); app.ui.show_origin = false; hover_frame(&mut app, &ctx, vec![]); let point = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap(); let (start, end) = if action == "point" { (point, point + vec2(30.0, 20.0)) } else { (point - vec2(40.0, 40.0), point + vec2(40.0, 40.0)) }; if action == "tool" { app.start("sketch.line"); assert!(app.tool.is_some()); } let before = app.cam; let undo = app.session.undo.len(); let modifiers = egui::Modifiers { shift: action == "box", ..Default::default() }; let frame = |app: &mut SolveApp, mut events: Vec| { events.insert(0, egui::Event::ModifiersChanged(modifiers)); let mut out = ctx.run_ui( egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }, |ui| show(app, ui), ); out.textures_delta.clear(); }; frame( &mut app, vec![ egui::Event::PointerMoved(start), egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers }, ], ); frame(&mut app, vec![egui::Event::PointerMoved(end)]); frame(&mut app, vec![]); assert_eq!(app.cam, before, "{action} drag must not orbit"); if action == "point" { assert!(app.viewport.point_drag.is_some()); assert_eq!(app.session.undo.len(), undo + 1); } else if action == "box" { assert!(app.viewport.boxsel.is_some()); } else { assert!(app.tool.is_some()); assert!(app.viewport.primary_nav.is_none()); } frame(&mut app, vec![egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers }]); if action == "box" { assert!(!app.session.selection.is_empty()); } } } #[test] fn openscad_empty_space_drags_navigate_without_editing_or_box_selecting() { for (button, shift) in [ (egui::PointerButton::Primary, false), (egui::PointerButton::Secondary, false), (egui::PointerButton::Middle, false), (egui::PointerButton::Secondary, true), (egui::PointerButton::Middle, true), ] { let ctx = egui::Context::default(); let mut app = hover_app(false); app.preferences.nav = crate::prefs::OPENSCAD_NAV.into(); hover_frame(&mut app, &ctx, vec![]); let point = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap(); let p = if button == egui::PointerButton::Primary { point + vec2(80.0, 80.0) } else { point }; let before = app.cam; let undo = app.session.undo.len(); let modifiers = egui::Modifiers { shift, ..Default::default() }; let frame = |app: &mut SolveApp, mut events: Vec| { events.insert(0, egui::Event::ModifiersChanged(modifiers)); let _ = ctx.run_ui( egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }, |ui| show(app, ui), ); }; frame(&mut app, vec![egui::Event::PointerMoved(p), egui::Event::PointerButton { pos: p, button, pressed: true, modifiers }]); let end = p + vec2(30.0, 20.0); frame(&mut app, vec![egui::Event::PointerMoved(end)]); frame(&mut app, vec![]); assert!(app.viewport.hover.is_none()); assert!(app.viewport.boxsel.is_none() && app.viewport.point_drag.is_none()); frame(&mut app, vec![egui::Event::PointerButton { pos: end, button, pressed: false, modifiers }]); assert_eq!(app.session.undo.len(), undo); assert!(app.session.selection.is_empty()); assert!(app.viewport.context_menu.is_none()); if button == egui::PointerButton::Primary { assert_ne!(app.cam.yaw, before.yaw); assert_eq!(app.cam.target, before.target); assert_eq!(app.cam.distance, before.distance); } else if shift { assert_ne!(app.cam.distance, before.distance); assert_eq!(app.cam.target, before.target); assert_eq!(app.cam.yaw, before.yaw); } else { assert_ne!(app.cam.target, before.target); assert_eq!(app.cam.distance, before.distance); assert_eq!(app.cam.yaw, before.yaw); } } } #[test] fn hover_stays_off_between_navigation_events_and_returns_on_release() { for gpu in [false, true] { for button in [egui::PointerButton::Middle, egui::PointerButton::Secondary, egui::PointerButton::Primary] { let ctx = egui::Context::default(); let mut app = hover_app(gpu); if button == egui::PointerButton::Primary { app.viewport.nav = Some(NavMode::Orbit); } hover_frame(&mut app, &ctx, vec![]); let p = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap(); hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]); assert!(app.viewport.hover.is_some()); hover_frame(&mut app, &ctx, vec![egui::Event::PointerButton { pos: p, button, pressed: true, modifiers: Default::default() }]); assert!(app.viewport.hover.is_none(), "hover on navigation press"); for offset in [1.0, 2.0, 3.0] { hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p + vec2(offset, 0.0))]); assert!(app.viewport.hover.is_none(), "hover while moving"); for _ in 0..3 { hover_frame(&mut app, &ctx, vec![]); assert!(app.viewport.hover.is_none(), "hover between movement events"); } } hover_frame(&mut app, &ctx, vec![egui::Event::PointerButton { pos: p, button, pressed: false, modifiers: Default::default() }]); assert!(app.viewport.hover.is_some(), "hover must return on release"); } } } #[test] fn hover_stays_off_during_spin_and_animated_views() { let ctx = egui::Context::default(); let mut app = hover_app(true); hover_frame(&mut app, &ctx, vec![]); let p = projection(&app, app.viewport.rect.unwrap()).to_screen(Vec3::ZERO).unwrap(); hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]); assert!(app.viewport.hover.is_some()); app.viewport.spin = true; for _ in 0..3 { hover_frame(&mut app, &ctx, vec![]); assert!(app.viewport.hover.is_none()); } app.viewport.spin = false; hover_frame(&mut app, &ctx, vec![]); assert!(app.viewport.hover.is_some()); app.cam_anim = Some(solidcraft_engine::render::CameraAnim::new(app.cam, app.cam, 0.0)); for _ in 0..3 { hover_frame(&mut app, &ctx, vec![]); assert!(app.viewport.hover.is_none()); } app.cam_anim = None; hover_frame(&mut app, &ctx, vec![]); assert!(app.viewport.hover.is_some()); } fn cylinder_app() -> SolveApp { let mut s = solidcraft_engine::Session::default(); s.execute("solid.cylinder", &json!({"radius": 10, "height": 20})).unwrap(); let mut app = SolveApp::new(s, crate::Services::default()); app.ui.show_origin = false; app } #[test] fn smooth_wall_display_preserves_wireframe_and_edge_selection() { let mut s = solidcraft_engine::Session::default(); let sketch = s.execute("sketch.create", &json!({"plane":"XY"})).unwrap()["sketch"].as_u64().unwrap(); s.execute("sketch.ellipse", &json!({"center":[0,0],"major":[25,0],"minor_radius":5})).unwrap(); let inner = s.execute("sketch.offset", &json!({"curves":["e1"],"distance":-1,"chain":false})).unwrap()["curves"][0].as_str().unwrap().to_string(); let outer = s.execute("sketch.offset", &json!({"curves":[inner],"distance":-0.5,"chain":false})).unwrap()["curves"][0].as_str().unwrap().to_string(); s.execute( "solid.extrude", &json!({"sketch":sketch,"distance":3,"profiles":{"type":"whole_boundaries","profiles":[{"outer":[outer],"holes":[[inner]]}]}}), ) .unwrap(); let mut app = SolveApp::new(s, crate::Services::default()); app.ui.ground_shadow = false; let st = app.session.world_state(); let b = st.bodies.first().unwrap(); let mesh = b.mesh(); assert_eq!(mesh.edges.len(), 1536); assert_eq!(mesh.smooth_edges.iter().filter(|f| **f).count(), 512); let smooth = mesh.smooth_edges.iter().position(|f| *f).unwrap(); let expected = mesh.edges.iter().enumerate().filter(|(i, _)| mesh.shaded_edge_visible(*i)).map(|(_, e)| e.len() - 1).sum::(); let shaded = build_scene(&app); assert_eq!(shaded.lines.len() / crate::gpu::LINE_SIZE, expected); assert!(!shaded.tris.is_empty()); app.ui.visual_style = 2; let wire = build_scene(&app); let expected_wire = mesh.edges.iter().enumerate().filter(|(i, _)| !mesh.seams.get(*i).copied().unwrap_or(false)).map(|(_, e)| e.len() - 1).sum::(); assert_eq!(wire.lines.len() / crate::gpu::LINE_SIZE, expected_wire); assert!(expected_wire > expected); assert!(wire.tris.is_empty()); app.ui.visual_style = 0; app.session.selection = vec![Sel::Edge { body: b.name.clone(), index: smooth, point: polyline_mid(&mesh.edges[smooth]) }]; assert!(!build_highlight(&app).lines.is_empty()); let mut without_display_mask = (*mesh).clone(); without_display_mask.smooth_edges.clear(); assert_eq!(mesh.surface_patch(mesh.edge_faces[smooth][0] as usize), without_display_mask.surface_patch(mesh.edge_faces[smooth][0] as usize)); let cam = solidcraft_engine::view::home_camera(&app.session); let cpu = solidcraft_engine::view::scene(&app.session, &cam); let actual_edges = cpu.lines.iter().filter(|line| line.color == solidcraft_engine::view::colors::EDGE).count(); assert_eq!(actual_edges, mesh.edges.iter().enumerate().filter(|(i, _)| mesh.shaded_edge_visible(*i)).count()); if let Ok(directory) = solidcraft_engine::geom::env::var("SMOOTH_SCREENSHOTS").ok_or(()) { std::fs::create_dir_all(&directory).unwrap(); for (name, old_edges, wireframe) in [("after", false, false), ("before", true, false), ("wireframe", true, true)] { let mut scene = solidcraft_engine::view::scene(&app.session, &cam); scene.lines.retain(|line| line.color == solidcraft_engine::view::colors::EDGE); if old_edges { scene.lines.clear(); for (i, edge) in mesh.edges.iter().enumerate() { if mesh.edge_pickable(i) { scene.lines.push(solidcraft_engine::render::SceneLine { points: edge.clone(), color: solidcraft_engine::view::colors::EDGE, width: 1.2, on_top: false, }); } } } if wireframe { scene.meshes.clear(); } let png = solidcraft_engine::render::render_png(&scene, &cam, 1200, 800).unwrap(); std::fs::write(std::path::Path::new(&directory).join(format!("smooth-ring-{name}.png")), png).unwrap(); } } } #[test] fn certified_small_crease_overrides_old_seam_fit_for_picking_and_patches() { let mut s = solidcraft_engine::Session::default(); let sketch = s.execute("sketch.create", &json!({"plane":"XY"})).unwrap()["sketch"].as_u64().unwrap(); let step = std::f64::consts::TAU / 8.; let k = 4. / 3. * (step / 4.).tan() * 10.; for i in 0..8 { let a = i as f64 * step; let b = (i + 1) as f64 * step; let p0 = [10. * a.cos(), 10. * a.sin()]; let p3 = if i == 7 { [10., 0.] } else { [10. * b.cos(), 10. * b.sin()] }; let mut p1 = [p0[0] - k * a.sin(), p0[1] + k * a.cos()]; let mut p2 = [p3[0] + k * b.sin(), p3[1] - k * b.cos()]; if i == 0 { p1[0] += k * 1e-6; p2[0] += k * 1e-6 * b.cos(); p2[1] += k * 1e-6 * b.sin(); } s.execute("sketch.spline.control_point", &json!({"points":[p0,p1,p2,p3]})).unwrap(); } s.execute("solid.extrude", &json!({"sketch":sketch,"distance":3})).unwrap(); let mut app = SolveApp::new(s, crate::Services::default()); let st = app.session.world_state(); let body = st.bodies.first().unwrap(); let mesh = body.mesh(); let crease = mesh .edges .iter() .position(|line| { line.first().zip(line.last()).is_some_and(|(a, b)| { (a.x - 10.).abs() < 1e-8 && a.y.abs() < 1e-8 && (b.x - 10.).abs() < 1e-8 && b.y.abs() < 1e-8 && (a.z - b.z).abs() > 2.9 }) }) .unwrap(); assert!(mesh.seams[crease], "the fixture must exercise the coarse seam false positive"); assert!(mesh.sharp_edges[crease]); assert!(mesh.shaded_edge_visible(crease)); assert!(mesh.edge_pickable(crease)); assert!(mesh.edge_faces[crease].iter().any(|f| mesh.surface_patch(*f as usize).len() == 1)); let mid = polyline_mid(&mesh.edges[crease]); app.cam = app.cam.looking_from(Vec3::new(30., 0., 1.5)); app.cam.target = Vec3::new(0., 0., 1.5); for perspective in [false, true] { app.ui.perspective = perspective; let proj = projection(&app, Rect::from_min_size(pos2(0., 0.), vec2(800., 600.))); let hits = pick(&app, &proj, proj.to_screen(mid).unwrap()); assert!(hits.iter().any(|hit| matches!(hit,Hit::Edge{index,..} if *index==crease)), "{hits:?}"); } } #[test] fn circular_items_measure_as_whole_rims_and_surfaces() { let mut app = cylinder_app(); let st = app.session.world_state(); let b = &st.bodies[0]; let m = b.mesh(); let edges: Vec = m .edges .iter() .enumerate() .filter(|(_, p)| p.len() > 3 && p.iter().all(|q| (q.z - 20.0).abs() < 1e-6)) .map(|(index, p)| Sel::Edge { body: b.name.clone(), index, point: polyline_mid(p) }) .collect(); let result = app.session.measure_items(&edges); assert_eq!(result["items"].as_array().unwrap().len(), 1); assert!((result["items"][0]["length_mm"].as_f64().unwrap() - 20.0 * std::f64::consts::PI).abs() < 0.2, "{result}"); app.session.selection = edges; let d = crate::dialogs::Dialog::for_command(&app, "inspect.measure").unwrap(); assert_eq!(d.items().len(), 1); let faces = m.edge_faces.iter().zip(&m.seams).find(|(_, seam)| **seam).unwrap().0; let wall = Sel::Face { body: b.name.clone(), index: faces[0] as usize, point: Vec3::new(10.0, 0.0, 10.0) }; let wall_pieces = grouped_selection(&app.session, &wall); let result = app.session.measure_items(&wall_pieces); assert_eq!(result["items"].as_array().unwrap().len(), 1); assert!((result["items"][0]["area_mm2"].as_f64().unwrap() - 400.0 * std::f64::consts::PI).abs() < 10.0, "{result}"); let mut d = crate::dialogs::Dialog::for_command(&app, "inspect.measure").unwrap(); d.pick(&app.session, wall.clone()); assert_eq!(d.items().len(), 2, "a rim and a wall are two measurement targets"); d.pick(&app.session, Sel::Face { body: b.name.clone(), index: faces[1] as usize, point: Vec3::new(-10.0, 0.0, 10.0) }); assert_eq!(d.items().len(), 1, "the other half toggles the same wall"); } #[test] fn circular_rim_hover_click_and_dialog_pick_include_both_halves() { let mut app = cylinder_app(); let st = app.session.world_state(); let b = &st.bodies[0]; let m = b.mesh(); let top = m.edges.iter().position(|e| e.len() > 3 && e.iter().all(|p| (p.z - 20.0).abs() < 1e-6)).unwrap(); let rim = m.circular_rim(top); assert_eq!(rim.len(), 2); let line_count: usize = rim.iter().map(|&i| m.edges[i].len() - 1).sum(); for &i in &rim { let mid = polyline_mid(&m.edges[i]); app.viewport.hover = Some(Hit::Edge { body: b.name.clone(), index: i, mid }); assert_eq!(build_highlight(&app).lines.len(), 2 * line_count * crate::gpu::LINE_SIZE); let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap(); select(&mut app, Some(item.clone()), false); assert_eq!(app.session.selection.len(), 2); // Selecting both pieces must not draw each highlight twice. assert_eq!(build_highlight(&app).lines.len(), 2 * line_count * crate::gpu::LINE_SIZE); // Ctrl/Shift clicking either half deselects the entire rim. select(&mut app, Some(item.clone()), true); assert!(app.session.selection.is_empty()); let mut d = crate::dialogs::Dialog::for_command(&app, "solid.chamfer").unwrap(); if let crate::dialogs::Kind::Fillet { chain, .. } = &mut d.kind { *chain = false; } d.pick(&app.session, item.clone()); assert_eq!(d.items().len(), 2); d.pick(&app.session, item); assert!(d.items().is_empty()); } // Screen-space picking works from either side of the rim as the model turns. for &i in &rim { let mid = polyline_mid(&m.edges[i]); app.cam = app.cam.looking_from(Vec3::new(mid.x, mid.y, 20.0)); app.cam.target = Vec3::new(0.0, 0.0, 10.0); for perspective in [false, true] { app.ui.perspective = perspective; let proj = projection(&app, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))); let hits = pick(&app, &proj, proj.to_screen(mid).unwrap()); let (_, sel) = candidate(&app, &hits).unwrap(); assert!(matches!(sel, Sel::Edge { .. }), "{hits:?}"); assert_eq!(grouped_selection(&app.session, &sel).len(), 2); } } } #[test] fn curved_face_hover_selection_and_dialogs_cross_the_seam() { let mut app = cylinder_app(); let st = app.session.world_state(); let b = &st.bodies[0]; let m = b.mesh(); let faces = m.edge_faces.iter().zip(&m.seams).find(|(_, seam)| **seam).unwrap().0; assert_eq!(faces.len(), 2); let triangle_count = m.tri_face.iter().filter(|f| faces.contains(f)).count(); for &f in faces { let index = f as usize; let t = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf == f).unwrap().0; let [a, c, d] = m.tri(t).unwrap(); let point = (a + c + d) / 3.0; app.viewport.hover = Some(Hit::Face { body: b.name.clone(), index, point }); assert_eq!(build_highlight(&app).tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE); let item = hit_sel(app.viewport.hover.as_ref().unwrap()).unwrap(); select(&mut app, Some(item.clone()), false); assert_eq!(app.session.selection.len(), 2); assert_eq!(build_highlight(&app).tris.len(), triangle_count * 3 * crate::gpu::TRI_SIZE); assert_eq!(app.session.selection.first(), Some(&item), "keep the original tool placement point first"); let mut dialog = crate::dialogs::Dialog::for_command(&app, "solid.chamfer").unwrap(); assert_eq!(dialog.items().len(), 2); dialog.pick(&app.session, item.clone()); assert!(dialog.items().is_empty()); dialog.pick(&app.session, item); assert_eq!(dialog.items().len(), 2); // A point elsewhere on the opposite half still toggles the whole surface off. let other = faces.iter().find(|&&x| x != f).unwrap(); select(&mut app, Some(Sel::Face { body: b.name.clone(), index: *other as usize, point: m.positions[0] }), true); assert!(app.session.selection.is_empty()); } // Planar caps and box corners stay separate. let cap = (0..b.body.face_count()).find(|&i| m.surface_patch(i).len() == 1).unwrap(); assert_eq!(m.surface_patch(cap), vec![cap]); app.session.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap(); let st = app.session.world_state(); let box_body = st.bodies.last().unwrap(); for i in 0..box_body.body.face_count() { assert_eq!(box_body.mesh().surface_patch(i), vec![i]); } } #[test] fn immediate_wheel_zoom_applies_normalized_input_once() { let mut h = crate::scenario::Harness::new(); h.app.preferences.smooth_wheel_zoom = false; h.app.preferences.trackpad_pan = false; h.app.preferences.zoom_to_cursor = false; h.call("ui.move", json!({"x":800,"y":500})); let before = h.app.cam; let line = h.ctx.options(|o| o.input_options.line_scroll_speed); for (unit, amount, points) in [(egui::MouseWheelUnit::Point, 120.0, 120.0), (egui::MouseWheelUnit::Line, 1.0, line), (egui::MouseWheelUnit::Page, 0.1, 100.0)] { let height = h.app.cam.half_height(); h.app.synthetic.push(egui::Event::MouseWheel { unit, delta: vec2(0.0, amount), modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move, }); h.frame(); let expected = height * (-f64::from(points) * 0.0015).exp(); assert!((h.app.cam.half_height() - expected).abs() < 1e-6); let after = h.app.cam; h.frames(30); assert_eq!(h.app.cam, after, "idle frames must not replay wheel input"); } assert_eq!(h.app.cam.target, before.target); h.app.preferences.zoom_reverse = true; let height = h.app.cam.half_height(); h.call("ui.scroll", json!({"x":800,"y":500,"delta":120})); assert!((h.app.cam.half_height() - height * 0.18_f64.exp()).abs() < 1e-6); } #[test] fn wheel_zoom_setting_changes_do_not_replay_smoothing() { let mut h = crate::scenario::Harness::new(); h.app.preferences.zoom_to_cursor = false; h.app.preferences.trackpad_pan = false; h.call("ui.move", json!({"x":800,"y":500})); h.app.synthetic.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, 120.0), modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move, }); h.frame(); assert!(h.ctx.input(|i| i.is_scrolling())); h.app.preferences.smooth_wheel_zoom = false; let stopped = h.app.cam; h.frame(); assert_eq!(h.app.cam, stopped); h.app.preferences.smooth_wheel_zoom = true; h.frames(90); assert_eq!(h.app.cam, stopped, "re-enabling must not replay the old smoothing tail"); assert!(!h.app.viewport.wheel_zoom_raw_until_idle); h.app.preferences.smooth_wheel_zoom = false; for _ in 0..3 { h.app.synthetic.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, 120.0), modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move, }); h.frame(); } assert!((h.app.cam.half_height() - stopped.half_height() * (-0.54_f64).exp()).abs() < 1e-6); let after = h.app.cam; h.frames(90); assert_eq!(h.app.cam, after); } #[test] fn trackpad_two_finger_scroll_pans_without_zooming() { let mut h = crate::scenario::Harness::new(); assert!(h.app.preferences.trackpad_pan, "two-finger pan is the default"); h.app.preferences.zoom_to_cursor = false; h.call("ui.move", json!({"x":800,"y":500})); let gesture = |phase| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(60.0, -40.0), modifiers: egui::Modifiers::NONE, phase }; // A bracketed trackpad gesture pans (measured macOS Start…End bracketing). let before = h.app.cam; h.app.synthetic.push(gesture(egui::TouchPhase::Start)); h.frame(); h.app.synthetic.push(gesture(egui::TouchPhase::Move)); h.frame(); assert_eq!(h.app.cam.half_height(), before.half_height(), "trackpad scroll must not zoom"); assert_ne!(h.app.cam.target, before.target, "trackpad scroll must pan"); h.app.synthetic.push(gesture(egui::TouchPhase::End)); h.frame(); let after = h.app.cam; h.frames(30); assert_eq!(h.app.cam, after, "pan must not drift once input stops"); // Bare moves are a mouse: vertical zooms… h.app.preferences.smooth_wheel_zoom = false; h.app.synthetic.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, 120.0), modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move, }); h.frame(); assert!((h.app.cam.half_height() - after.half_height() * (-0.18_f64).exp()).abs() < 1e-6); // …and horizontal pans without zooming (thumb wheel). let zoomed = h.app.cam; h.app.synthetic.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(40.0, 0.0), modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move, }); h.frame(); assert_eq!(h.app.cam.half_height(), zoomed.half_height()); assert_ne!(h.app.cam.target, zoomed.target); // Opting out restores the legacy wheel behaviour for all point input. h.app.preferences.trackpad_pan = false; h.app.synthetic.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, 120.0), modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move, }); h.frame(); assert!((h.app.cam.half_height() - zoomed.half_height() * (-0.18_f64).exp()).abs() < 1e-6); // Legacy mode also zooms bracketed input instead of panning it. let legacy = h.app.cam.half_height(); let legacy_target = h.app.cam.target; for phase in [egui::TouchPhase::Start, egui::TouchPhase::Move, egui::TouchPhase::End] { h.app.synthetic.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, 120.0), modifiers: egui::Modifiers::NONE, phase, }); h.frame(); } assert!((h.app.cam.half_height() - legacy * (-0.18_f64).exp()).abs() < 1e-6); assert_eq!(h.app.cam.target, legacy_target); // The thumb wheel pans sideways even with panning off: it is a mouse. let h0 = h.app.cam.half_height(); h.app.synthetic.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(40.0, 0.0), modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move, }); h.frame(); assert_eq!(h.app.cam.half_height(), h0); assert_ne!(h.app.cam.target, legacy_target); } #[test] fn pinch_gesture_zooms_and_ignores_noise() { let mut h = crate::scenario::Harness::new(); h.app.preferences.smooth_wheel_zoom = false; h.app.preferences.zoom_to_cursor = false; h.call("ui.move", json!({"x":800,"y":500})); let height = h.app.cam.half_height(); let target = h.app.cam.target; h.app.synthetic.push(egui::Event::Zoom(1.2)); h.frame(); let zoomed = h.app.cam.half_height(); assert!(((zoomed - height / 1.2) / (height / 1.2)).abs() < 1e-6, "spread must zoom in"); assert_eq!(h.app.cam.target, target, "centre zoom keeps the target"); h.app.preferences.zoom_reverse = true; let height = h.app.cam.half_height(); h.app.synthetic.push(egui::Event::Zoom(1.2)); h.frame(); let flipped = h.app.cam.half_height(); assert!(((flipped - height * 1.2) / (height * 1.2)).abs() < 1e-6, "reverse flips the pinch direction"); h.app.synthetic.push(egui::Event::Zoom(f32::NAN)); h.app.synthetic.push(egui::Event::Zoom(0.0)); h.app.synthetic.push(egui::Event::Zoom(1.0)); let stopped = h.app.cam; h.frame(); assert_eq!(h.app.cam, stopped, "noise must leave the camera still"); } #[test] fn trackpad_gestures_over_dialogs_do_not_move_the_view() { let ctx = egui::Context::default(); let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); assert!(app.preferences.trackpad_pan); app.viewport.no_pixels = true; crate::help::run(&mut app, &ctx, "shortcuts"); let mut time = 0.0; let pan = |phase| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(40.0, -20.0), modifiers: egui::Modifiers::NONE, phase }; let mut frame = |app: &mut SolveApp, pointer: Option, events: Vec| { time += 1.0 / 60.0; let mut input = events; if let Some(p) = pointer { input.insert(0, egui::Event::PointerMoved(p)); } let mut output = ctx.run_ui( egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 800.0))), time: Some(time), events: input, ..Default::default() }, |ui| { egui::CentralPanel::default().show(ui, |ui| show(app, ui)); crate::prefs::show(app, ui.ctx()); }, ); output.textures_delta.clear(); }; for _ in 0..4 { frame(&mut app, None, Vec::new()); } let window = ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap(); let over = window.center(); let height = app.cam.half_height(); let target = app.cam.target; // A full gesture plus pinch over the dialog stays in the popup. for events in [vec![pan(egui::TouchPhase::Start)], vec![pan(egui::TouchPhase::Move), egui::Event::Zoom(1.2)], vec![pan(egui::TouchPhase::End)]] { frame(&mut app, Some(over), events); assert_eq!(app.cam.half_height(), height, "trackpad input over a dialog must stay in the popup"); assert_eq!(app.cam.target, target); } let exposed = pos2(40.0, 700.0); assert!(!window.contains(exposed)); frame(&mut app, Some(exposed), Vec::new()); frame(&mut app, Some(exposed), vec![pan(egui::TouchPhase::Start)]); frame(&mut app, Some(exposed), vec![pan(egui::TouchPhase::Move)]); assert_eq!(app.cam.half_height(), height); assert_ne!(app.cam.target, target, "the exposed viewport still pans"); frame(&mut app, Some(exposed), vec![egui::Event::Zoom(1.2)]); assert_ne!(app.cam.half_height(), height, "the exposed viewport still pinch-zooms"); frame(&mut app, Some(exposed), vec![pan(egui::TouchPhase::End)]); } #[test] fn shortcuts_window_wheel_does_not_zoom_the_viewport() { for smooth in [true, false] { let ctx = egui::Context::default(); let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); app.preferences.smooth_wheel_zoom = smooth; app.preferences.trackpad_pan = false; app.viewport.no_pixels = true; crate::help::run(&mut app, &ctx, "shortcuts"); let mut time = 0.0; let mut frame = |app: &mut SolveApp, pointer: Option, wheel: f32| { time += 1.0 / 60.0; let mut events = Vec::new(); if let Some(p) = pointer { events.push(egui::Event::PointerMoved(p)); } if wheel != 0.0 { events.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, wheel), modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move, }); } let mut output = ctx.run_ui( egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 800.0))), time: Some(time), events, ..Default::default() }, |ui| { egui::CentralPanel::default().show(ui, |ui| show(app, ui)); crate::prefs::show(app, ui.ctx()); }, ); output.textures_delta.clear(); }; // Let egui establish the floating window's size and hit-test widgets. for _ in 0..4 { frame(&mut app, None, 0.0); } let window = ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap(); let list = window.center() + vec2(140.0, 0.0); let height = app.cam.half_height(); let target = app.cam.target; frame(&mut app, Some(list), 0.0); for n in 0..12 { frame(&mut app, Some(list), if n == 0 { -120.0 } else { 0.0 }); assert_eq!(app.cam.half_height(), height, "wheel input over shortcuts must stay in the popup"); assert_eq!(app.cam.target, target); } // The window's non-scrolling sidebar must also shield the model. frame(&mut app, Some(window.left_center() + vec2(30.0, 0.0)), -120.0); assert_eq!(app.cam.half_height(), height); // An exposed part of the viewport still accepts wheel navigation with the popup open. let exposed = pos2(40.0, 700.0); assert!(!window.contains(exposed)); frame(&mut app, Some(exposed), 0.0); frame(&mut app, Some(exposed), -120.0); assert_ne!(app.cam.half_height(), height); // Closing the popup returns its former area to the viewport. app.prefs_window.open = false; for _ in 0..12 { frame(&mut app, Some(list), 0.0); } let height = app.cam.half_height(); frame(&mut app, Some(list), -120.0); assert_ne!(app.cam.half_height(), height); } } #[test] fn wheel_by_a_dialog_list_or_panel_edge_leaves_the_model_alone() { // #678: scrolling the Physical Material list, a drop-down that hangs over the model, // zoomed the model too. egui's hit test counts the model as under the pointer within a // few points of a window's, a list's or the viewport's own edge, so near those edges // the same wheel turn reached both. use crate::scenario::{Harness, handle_at}; // One wheel turn at `p`, then frames until egui's smoothing is spent: did the camera move? let wheel = |h: &mut Harness, p: Pos2, delta: f32| { let before = h.app.cam; h.call("ui.scroll", json!({"x": p.x, "y": p.y, "delta": delta})); h.frames(20); h.app.cam != before }; // A trackpad pinch, then a two-finger scroll (which pans the model): did the camera move? let gestures = |h: &mut Harness, p: Pos2| { let before = h.app.cam; h.call("ui.move", json!({"x": p.x, "y": p.y})); h.app.synthetic.push(egui::Event::Zoom(1.2)); for phase in [egui::TouchPhase::Start, egui::TouchPhase::Move, egui::TouchPhase::End] { let unit = egui::MouseWheelUnit::Point; h.app.synthetic.push(egui::Event::MouseWheel { unit, delta: vec2(30.0, -20.0), modifiers: egui::Modifiers::NONE, phase }); } h.settle(); h.frames(20); h.app.cam != before }; for smooth in [true, false] { let mut h = Harness::new(); h.app.preferences.smooth_wheel_zoom = smooth; h.app.preferences.zoom_to_cursor = false; h.call("engine.execute", json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}})); h.call("ui.start", json!({"command": "material.assign"})); let field = handle_at("dialog:Material").expect("the dialog's material field"); let field = json!({"x": field.x, "y": field.y}); h.call("ui.move", field.clone()); h.call("ui.click", field.clone()); let item = "dialog:Material:Steel"; let first = handle_at(item).expect("the open material list"); let list = h .ctx .memory(|m| { let popups = m.areas().visible_layer_ids().into_iter().filter(|l| l.order == egui::Order::Foreground); popups.filter_map(|l| m.area_rect(l.id)).find(|r| r.contains(first)) }) .expect("the list's popup"); let dialog = h.app.viewport.dialog_rect.expect("the dialog"); let vp = h.app.viewport.rect.expect("the viewport"); assert!(!dialog.contains(first) && vp.contains(first), "the list must hang over the model: {first:?} {dialog:?}"); // The wheel scrolls the list and only the list, in its middle and by its edges. assert!(!wheel(&mut h, pos2(list.center().x, first.y), -60.0), "smooth {smooth}: the wheel over the list zoomed the model"); let moved = handle_at(item).expect("the list stays open"); assert!(moved.y < first.y - 1.0, "the wheel must scroll the list: {first:?} -> {moved:?}"); for p in [pos2(list.left() + 2.0, list.center().y), pos2(list.right() - 2.0, list.center().y), pos2(list.center().x, list.bottom() - 2.0)] { assert!(!wheel(&mut h, p, 60.0), "smooth {smooth}: the wheel at {p:?} by the list's edge ({list:?}) zoomed the model"); assert!(!gestures(&mut h, p), "a pinch or two-finger scroll at {p:?} by the list's edge ({list:?}) moved the model"); assert!(h.app.viewport.mouse.is_none(), "the model must not see the pointer at {p:?} over the list"); } // With the list closed: by the dialog's edges, and over the browser and the timeline // next to the model. h.call("ui.move", field.clone()); h.call("ui.click", field); assert!(handle_at(item).is_none(), "the list must close"); let (mid, x) = (vp.center().y, vp.left() + vp.width() * 0.25); for p in [ pos2(dialog.left() + 2.0, dialog.center().y), pos2(dialog.center().x, dialog.bottom() - 2.0), pos2(vp.left() - 2.0, mid), pos2(x, vp.bottom() + 2.0), ] { assert!(!wheel(&mut h, p, 60.0), "smooth {smooth}: the wheel at {p:?} off the model ({vp:?}, dialog {dialog:?}) zoomed it"); assert!(h.app.viewport.mouse.is_none(), "the model must not see the pointer at {p:?}"); } // The model itself still zooms right next to them. for p in [pos2(dialog.left() - 2.0, dialog.center().y), pos2(vp.left() + 2.0, mid), pos2(x, vp.bottom() - 2.0)] { assert!(wheel(&mut h, p, 60.0), "smooth {smooth}: the wheel at {p:?} on the model ({vp:?}) must zoom it"); assert_eq!(h.app.viewport.mouse, Some(p)); assert!(gestures(&mut h, p), "a pinch and a two-finger scroll at {p:?} on the model must move it"); } } } #[test] fn floating_dialog_blocks_viewport_hover_and_clicks() { let mut h = crate::scenario::Harness::new(); h.call("engine.execute", json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}})); h.call("ui.view", json!({"view": "fit"})); h.app.ui.pick_bodies = true; let p = h.app.viewport.rect.unwrap().center(); h.call("ui.move", json!({"x": p.x, "y": p.y})); assert!(h.app.viewport.hover.is_some(), "the test point must hover over the model"); h.call("ui.click", json!({"x": p.x, "y": p.y})); assert!(!h.app.session.selection.is_empty(), "the uncovered model must be selectable"); h.app.session.selection.clear(); h.call("ui.prefs", json!({"open": "Navigation"})); let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap(); assert!(window.contains(p), "the dialog must cover the model at the test point"); h.call("ui.move", json!({"x": p.x, "y": p.y})); assert!(h.app.viewport.hover.is_none(), "the dialog must block model highlights"); assert!(h.app.viewport.mouse.is_none()); h.call("ui.click", json!({"x": p.x, "y": p.y})); assert!(h.app.session.selection.is_empty(), "clicks in the dialog must not select the model"); h.call("ui.prefs", json!({"button": "cancel"})); h.call("ui.move", json!({"x": p.x, "y": p.y})); assert!(h.app.viewport.hover.is_some(), "closing the dialog must restore model highlights"); h.call("ui.click", json!({"x": p.x, "y": p.y})); assert!(!h.app.session.selection.is_empty()); } #[test] fn floating_dialog_blocks_axis_view_highlights_and_clicks() { let mut h = crate::scenario::Harness::new(); h.call("ui.resize", json!({"width": 800, "height": 700})); let p = crate::scenario::handle_at("axis_view:z").unwrap(); h.call("ui.move", json!({"x": p.x, "y": p.y})); assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(1.0)); h.call("ui.prefs", json!({"open": "Navigation"})); let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap(); assert!(window.contains(p), "the floating dialog must cover the axis target"); let camera = serde_json::to_value(h.app.cam).unwrap(); h.call("ui.move", json!({"x": p.x, "y": p.y})); assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(0.0)); h.call("ui.click", json!({"x": p.x, "y": p.y})); assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog clicks must not turn the axis view"); h.call("ui.drag", json!({"x0": p.x, "y0": p.y, "x1": p.x + 20.0, "y1": p.y, "steps": 8})); assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog drags must not orbit the axis gizmo"); assert!(h.app.viewport.axis_drag.is_none()); h.call("ui.prefs", json!({"button": "cancel"})); h.call("ui.move", json!({"x": p.x, "y": p.y})); assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(1.0)); h.call("ui.click", json!({"x": p.x, "y": p.y})); assert_ne!(serde_json::to_value(h.app.cam).unwrap(), camera); } #[test] fn view_axis_drag_owns_navigation_and_keeps_the_view_centre() { for nav in [None, Some(NavMode::Orbit), Some(NavMode::Pan)] { let mut h = crate::scenario::Harness::new(); h.call("ui.view", json!({"view": "front"})); h.app.viewport.nav = nav; let before = h.app.cam; let p = crate::scenario::handle_at("axis_view:-y").unwrap(); h.call("ui.drag", json!({"x0": p.x, "y0": p.y, "x1": p.x + 48.0, "y1": p.y, "steps": 8})); assert!((h.app.cam.yaw - 48.0 * 0.008).abs() < 1e-6, "{nav:?}: axis drag must orbit exactly once"); assert_eq!(h.app.cam.target, before.target, "{nav:?}: axis drag must not pan"); assert_eq!(h.app.cam.distance, before.distance); assert_eq!(h.app.cam.pitch, before.pitch); assert!(h.app.viewport.axis_drag.is_none(), "release clears the axis gesture"); assert!(h.app.viewport.boxsel.is_none()); } } #[test] fn section_outlines_follow_the_cut() { let mut s = solidcraft_engine::Session::default(); s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); let st = s.model.state(); let m = st.bodies[0].mesh(); let (o, n) = (Vec3::new(0.0, 15.0, 0.0), Vec3::new(0.0, -1.0, 0.0)); let segs = solidcraft_engine::view::section_segments(&m, o, n); assert!(segs.len() >= 4, "{segs:?}"); // On the plane (a hair to the kept side) and around the 40 x 20 cut. let mut len = 0.0; for (a, b) in &segs { assert!((a.y - 15.0).abs() < 0.01 && (b.y - 15.0).abs() < 0.01); len += a.dist(*b); } assert!((len - 120.0).abs() < 0.5, "outline length {len}"); // A plane that misses the body cuts nothing. assert!(solidcraft_engine::view::section_segments(&m, Vec3::new(0.0, 99.0, 0.0), n).is_empty()); } #[test] fn selection_shows_where_the_model_hides_it() { let mut s = solidcraft_engine::Session::default(); s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); let mut app = SolveApp::new(s, crate::Services::default()); assert!(build_highlight(&app).hidden.is_empty()); // The back face: its outline is drawn (dimmed) where the box hides it. app.session.selection = vec![Sel::Face { body: "Body1".into(), index: 3, point: Vec3::new(20.0, 30.0, 10.0) }]; assert!(!build_highlight(&app).hidden.is_empty()); } #[test] fn axis_view_top_bottom_choose_nearest_square_heading() { for (from, to) in [ (0.0, 0.0), (30.0, 0.0), (-80.0, -90.0), (100.0, 90.0), (170.0, 180.0), (400.0, 360.0), (-400.0, -360.0), (-45.0, 0.0), (45.0, 0.0), (135.0, 90.0), (-135.0, -90.0), (44.99, 0.0), (45.01, 90.0), (-44.99, 0.0), (-45.01, -90.0), ] { let got = nearest_quarter_turn(f64::to_radians(from)).to_degrees(); assert!((got - to).abs() < 1e-9, "{from}° → {got}°, expected {to}°"); } assert_eq!(nearest_quarter_turn(f64::NAN), 0.0); assert_eq!(nearest_quarter_turn(f64::INFINITY), 0.0); } #[test] fn shift_orbit_accumulates_and_returns_to_free_rotation() { let mut h = crate::scenario::Harness::new(); h.call("ui.view", json!({"view":"front"})); let rect = h.app.viewport.rect.unwrap(); let before = h.app.cam; for _ in 0..100 { orbit_drag(&mut h.app, rect, 2.0, 0.0, None, true, false); step_orbit_snap(&mut h.app, rect, 1.0 / 60.0); } for _ in 0..120 { step_orbit_snap(&mut h.app, rect, 1.0 / 60.0); } assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6); assert_eq!(h.app.cam.target, before.target); assert_eq!(h.app.cam.distance, before.distance); orbit_drag(&mut h.app, rect, 0.0, 0.0, None, false, false); h.settle(); assert!((h.app.cam.yaw - 1.6).abs() < 1e-6); assert!(h.app.viewport.orbit_free.is_none()); } #[test] fn axis_snap_selects_all_directions_in_both_up_frames() { assert_eq!(snap_directions().count(), 26); for up in [solidcraft_engine::render::UpAxis::Z, solidcraft_engine::render::UpAxis::Y] { for d in snap_directions() { let mut cam = Camera { up, ..Camera::default() }; cam = cam.looking_from(cam.from_frame(d + Vec3::new(0.05, 0.03, 0.02))); let to = snapped_axis_camera(cam); assert!(to.back().dist(cam.from_frame(d)) < 1e-6); assert_eq!(to.target, cam.target); assert_eq!(to.distance, cam.distance); assert_eq!(to.fov, cam.fov); } } } #[test] fn camera_resets_cancel_pending_axis_motion() { for reset in 0..3 { let mut h = crate::scenario::Harness::new(); h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); let rect = h.app.viewport.rect.unwrap(); orbit_drag(&mut h.app, rect, 120.0, 40.0, None, true, false); assert!(h.app.viewport.orbit_snap.is_some()); match reset { 0 => h.app.fit_view(), 1 => h.app.set_up_axis(solidcraft_engine::render::UpAxis::Y), _ => crate::documents::new_design(&mut h.app), } let before = h.app.cam; step_orbit_snap(&mut h.app, rect, 1.0 / 60.0); assert_eq!(h.app.cam.yaw, before.yaw); assert_eq!(h.app.cam.pitch, before.pitch); assert_eq!(h.app.cam.target, before.target); assert!(h.app.viewport.orbit_snap.is_none()); assert!(h.app.viewport.orbit_free.is_none()); } } #[test] fn axis_snap_spring_eases_in_and_settles_at_each_frame_rate() { for fps in [30, 60, 120] { let mut h = crate::scenario::Harness::new(); h.call("ui.view", json!({"view":"front"})); let rect = h.app.viewport.rect.unwrap(); orbit_drag(&mut h.app, rect, 120.0, 0.0, None, true, false); assert_eq!(h.app.cam.yaw, 0.0, "choosing a diagonal must not jump the camera"); step_orbit_snap(&mut h.app, rect, 1.0 / f64::from(fps)); assert!(h.app.cam.yaw > 0.0 && h.app.cam.yaw < std::f64::consts::FRAC_PI_4 * 0.25); for _ in 0..fps * 2 { step_orbit_snap(&mut h.app, rect, 1.0 / f64::from(fps)); assert!(h.app.cam.yaw <= std::f64::consts::FRAC_PI_4 + 0.03, "elastic finish stays small"); } assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_4).abs() < 1e-6); assert!(h.app.viewport.orbit_snap.is_none()); } } #[test] fn shift_orbit_keeps_cursor_pivot_and_default_middle_pan() { let mut h = crate::scenario::Harness::new(); h.app.preferences.orbit_cursor = true; h.app.run("solid.box", json!({"length":40,"width":40,"height":40})).unwrap(); h.app.fit_view(); let rect = h.app.viewport.rect.unwrap(); let at = rect.center() + vec2(50.0, -20.0); orbit_drag(&mut h.app, rect, 0.0, 0.0, Some(at), false, true); let pivot = h.app.viewport.orbit_pivot.unwrap(); let before = projection(&h.app, rect).to_screen(pivot).unwrap(); for _ in 0..20 { orbit_drag(&mut h.app, rect, 4.0, 2.0, Some(at), true, true); step_orbit_snap(&mut h.app, rect, 1.0 / 60.0); let after = projection(&h.app, rect).to_screen(pivot).unwrap(); assert!(after.distance(before) < 2.0); } h.app.viewport.orbit_free = None; h.app.viewport.orbit_pivot = None; h.call("ui.view", json!({"view":"front"})); let before = h.app.cam; h.call("ui.drag", json!({"x0":800,"y0":500,"x1":1020,"y1":500,"button":"middle","shift":true,"steps":40})); assert!((h.app.cam.yaw - 1.76).abs() < 1e-6, "before={before:?}, after={:?}", h.app.cam); assert!(h.app.viewport.orbit_snap.is_none()); h.call("ui.view", json!({"view":"front"})); h.call("ui.drag", json!({"x0":800,"y0":500,"x1":850,"y1":520,"button":"middle"})); assert_eq!(h.app.cam.yaw, before.yaw); assert_eq!(h.app.cam.pitch, before.pitch); assert_ne!(h.app.cam.target, before.target); } #[test] fn preset_shift_middle_orbit_is_free_but_shift_right_orbit_snaps() { for preset in ["fusion", "inventor"] { let mut h = crate::scenario::Harness::new(); h.app.preferences.nav = preset.into(); h.call("ui.view", json!({"view":"front"})); let before = h.app.cam; h.call("ui.drag", json!({"x0":800,"y0":500,"x1":850,"y1":520,"button":"middle","shift":true,"steps":20})); let mut expected = before; expected.orbit(50.0, 20.0); assert!((h.app.cam.yaw - expected.yaw).abs() < 1e-6, "{preset}: {:?}", h.app.cam); assert!((h.app.cam.pitch - expected.pitch).abs() < 1e-6, "{preset}: {:?}", h.app.cam); assert_eq!(h.app.cam.target, before.target); assert_eq!(h.app.cam.distance, before.distance); assert!(h.app.viewport.orbit_snap.is_none()); h.call("ui.view", json!({"view":"front"})); h.call("ui.drag", json!({"x0":800,"y0":500,"x1":1020,"y1":500,"button":"right","shift":true,"steps":40})); assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6, "{preset}: {:?}", h.app.cam); } } #[test] fn navigation_cube_repeated_face_click_keeps_view_and_drag_snaps() { let mut h = crate::scenario::Harness::new(); h.call("ui.prefs", json!({"navigator_style":"cube"})); h.run(&json!([ {"call":"ui.view","params":{"view":"home"}}, {"click":{"handle":"axis_view:z"}}, {"expect":{"camera":{"pitch_deg":90,"yaw_deg":0}}}, {"click":{"handle":"axis_view:z"}}, {"expect":{"camera":{"pitch_deg":90,"yaw_deg":0}}}, {"call":"ui.view","params":{"view":"front"}} ])) .unwrap(); let c = axis_center(&h.app, h.app.viewport.rect.unwrap()); h.call("ui.drag", json!({"x0":c.x,"y0":c.y,"x1":c.x+220.0,"y1":c.y,"shift":true,"steps":40})); assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6); assert!(h.app.viewport.axis_drag.is_none()); assert!(h.app.viewport.orbit_free.is_none()); assert!(h.app.viewport.boxsel.is_none()); } #[test] fn construction_axes_span_the_model_and_points_show() { let mut s = solidcraft_engine::Session::default(); s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); s.execute("construct.axis.two_points", &json!({"a": {"vertex": [0, 0, 20]}, "b": {"vertex": [40, 30, 20]}})).unwrap(); s.execute("construct.point.vertex", &json!({"point": {"vertex": [40, 0, 20]}})).unwrap(); let mut app = SolveApp::new(s, crate::Services::default()); let axes = construction_axis_segments(&app); assert_eq!(axes.len(), 1); let (name, a, b) = axes[0].clone(); // The segment runs through both points and past them. let d = (b - a).normalized().unwrap(); for p in [Vec3::new(0.0, 0.0, 20.0), Vec3::new(40.0, 30.0, 20.0)] { let t = (p - a).dot(d); assert!(t > 0.0 && t < a.dist(b) && (a + d * t).dist(p) < 1e-6, "{p:?} not on {a:?}..{b:?}"); } assert_eq!(construction_points(&app).len(), 1); // It is an axis for dialogs (pattern directions, revolve axes), and hiding it hides it. let (o, dir) = crate::dialogs::axis_of(&app, &Sel::Axis { name: name.clone() }).unwrap(); assert!(o.dist(Vec3::new(0.0, 0.0, 20.0)) < 1e-6 && dir.dist(d) < 1e-6); app.session.visibility.hidden_origin.push(name); assert!(construction_axis_segments(&app).is_empty()); } } #[cfg(test)] #[path = "view_home_tests.rs"] mod view_home_tests;