//! The 3D viewport: navigation, GPU (or CPU fallback) rendering, picking, overlays, the view //! cube and the navigation bar. use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2}; use serde_json::json; use solvecraft_engine::Sel; use solvecraft_engine::geom::{Vec2, Vec3}; use solvecraft_engine::render::{Camera, Mat4, Rgb}; use solvecraft_engine::sketch::CurveKind; use solvecraft_engine::view::{colors, grid_step, sketch_consumed, sketch_lines}; 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}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NavMode { Orbit, Pan, Zoom, } #[derive(Default)] pub struct ViewportState { /// Per-body bounding boxes for fast picking, keyed by the model they were taken from. pick_bounds: std::sync::Mutex>)>>, /// The last pick (its key and hits): reused while nothing it depends on changed. last_pick: Option<(u64, Vec)>, pub rect: Option, pub gpu: Option, slot: SceneSlot, key: u64, 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, /// 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, /// A primary press on the cube: the pointer position used by its last orbit step. pub cube_drag: Option, pub build_ms: f64, /// The right-click menu, open at this screen position. pub context_menu: Option, /// 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)>, } /// 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, }, } /// 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| { app.session.active_sketch == Some(s.feature) || (!app.session.visibility.hidden_sketches.contains(&s.feature) && (app.session.visibility.shown_sketches.contains(&s.feature) || (app.ui.show_sketches && !sketch_consumed(&app.session, s.feature)))) }) .map(|s| s.feature) .collect() } fn scene_key(app: &SolveApp) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); app.session.revision.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); crate::theme::is_dark().hash(&mut h); app.session.active_sketch.hash(&mut h); let (minor, _) = grid_step(app.cam.half_height()); minor.to_bits().hash(&mut h); ((app.cam.target.x / (minor * 5.0)).round() as i64).hash(&mut h); ((app.cam.target.y / (minor * 5.0)).round() as i64).hash(&mut h); h.finish() } /// 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(); 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); } } 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_look(b).map_or(1.0, |l| l.opacity); let face_looks: std::collections::HashMap = if shaded { s.doc.face_colors(b).into_iter().collect() } else { Default::default() }; for (ti, t) in m.triangles.iter().enumerate().filter(|_| shaded) { 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.seams.get(ei).copied().unwrap_or(false) { 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 section_segments(&b.mesh(), o, n) { sc.line(a.to_f32(), c.to_f32(), col, 1.8, false); } } } 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 p in profiles { for tri in p.region.triangulate(0.05) { 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(&ss.sketch, &ss.plane, active, &ss.report.curve_determined) { let segs = if !cons { pts.windows(2).map(|w| (w[0], w[1])).collect() } else if col == colors::SKETCH_CENTERLINE { dashes(&pts, &[0.6 * unit, 0.15 * unit, 0.05 * unit, 0.15 * unit]) } else { dashes(&pts, &[0.3 * unit, 0.2 * 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); } } } sc } /// A soft dark ellipse on the ground under the shown bodies (darkest in the middle). fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solvecraft_engine::doc::ModelState) { // The bodies' box in the camera's Z-up frame (the ground is its XY plane). let mut b = solvecraft_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); } } } } /// Where the plane through `o` with normal `n` cuts a mesh: one segment per crossed triangle, /// nudged a hair to the kept side (so the clip leaves them drawn). pub(crate) fn section_segments(m: &solvecraft_engine::geom::Mesh, o: Vec3, n: Vec3) -> Vec<(Vec3, Vec3)> { let eps = 1e-4 * (1.0 + m.bounds().diagonal()); let mut out = Vec::new(); for t in &m.triangles { let Some(p) = m.tri(t) else { continue }; let d = p.map(|q| n.dot(q - o)); let mut pts = Vec::with_capacity(2); for (i, j) in [(0, 1), (1, 2), (2, 0)] { if (d[i] < 0.0) != (d[j] < 0.0) { let s = d[i] / (d[i] - d[j]); pts.push(p[i] + (p[j] - p[i]) * s - n * eps); } } if let [a, b] = pts[..] { out.push((a, b)); } } out } /// 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() } 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 = solvecraft_engine::view::bounds(&app.session); solvecraft_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)> { solvecraft_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; solvecraft_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> { let key = std::sync::Arc::as_ptr(st) as usize; if let Ok(mut g) = app.viewport.pick_bounds.lock() { if let Some((k, b)) = g.as_ref() && *k == key { return b.clone(); } let b = std::sync::Arc::new(st.bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect::>()); *g = Some((key, b.clone())); return b; } std::sync::Arc::new(Vec::new()) } /// The model's extent: bodies (from the cached boxes) and sketches. pub fn model_bounds(app: &SolveApp) -> solvecraft_engine::geom::Aabb3 { let st = app.session.world_state(); let mut b = body_bounds(app, &st).iter().fold(solvecraft_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)); } } } b } /// Where a ray enters a box (None: it misses). fn ray_box(o: Vec3, d: Vec3, b: &solvecraft_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: &solvecraft_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.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); 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); let key = h.finish(); if let Some((k, hits)) = &app.viewport.last_pick && *k == key { return hits.clone(); } let hits = pick(app, proj, pos); app.viewport.last_pick = Some((key, hits.clone())); hits } pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> 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). let wants_points = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| i.accept & crate::selection::POINTS != 0); let point_sketches: Vec = if wants_points { visible_sketches(app) } else { s.active_sketch.into_iter().collect() }; let mut best: Option<(f32, Hit)> = None; for ss in point_sketches.iter().filter_map(|sid| st.sketch(*sid)) { let sid = ss.feature; for p in &ss.sketch.points { if let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) { let dd = sp.distance(pos); if dd < 8.0 && best.as_ref().is_none_or(|(b, _)| dd < *b) { best = Some((dd, Hit::SketchPoint { sketch: sid, id: p.id.clone(), at: p.pos })); } } } } if let Some((_, h)) = best { hits.push(h); } // Sketch curves (the active sketch wins ties). let mut bestc: Option<(f32, Hit)> = None; 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 c.link.is_some() && ss.sketch.view.hide_projected { continue; } let straight = matches!(c.kind, CurveKind::Line { .. }); for seg in ss.sketch.segs(i) { let pts: Vec = seg.polyline(0.05).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect(); for w in pts.windows(2) { let dd = seg_dist(pos, w[0], w[1]) - bonus; if dd < 6.0 && bestc.as_ref().is_none_or(|(b, _)| dd < *b) { bestc = Some((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.as_ref().is_none_or(|(b, _)| dd < *b) { bestc = Some((dd, Hit::SketchCurve { sketch: sid, id: w.id.clone(), straight: false })); } } } } if let Some((_, h)) = bestc { hits.push(h); } // Nearest face along the ray (hides what is behind it). let mut bestf: Option<(f64, Hit)> = None; 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 bestf.as_ref().is_some_and(|(bt, _)| t0 > *bt) => continue, _ => {} } } let m = b.mesh(); if let Some((t, ti)) = m.raycast(o, d) && bestf.as_ref().is_none_or(|(bt, _)| t < *bt) { let fi = m.tri_face.get(ti).copied().unwrap_or(0) as usize; bestf = Some((t, Hit::Face { body: b.name.clone(), index: fi, point: o + d * t })); } } 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: Option<(f32, Hit)> = None; let mut beste: Option<(f32, Hit)> = None; 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.seams.get(ei).copied().unwrap_or(false) { 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.as_ref().is_none_or(|(bd, _)| dd < *bd) { bestv = Some((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.as_ref().is_none_or(|(bd, _)| dd < *bd) { 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 = Some((dd, Hit::Edge { body: b.name.clone(), index: ei, mid })); } } } } } hits.extend(bestv.map(|x| x.1)); hits.extend(beste.map(|x| x.1)); // Origin axes. let size = origin_size(app.cam.half_height()); let mut besta: Option<(f32, Hit)> = None; 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.as_ref().is_none_or(|(bd, _)| dd < *bd) { besta = Some((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.as_ref().is_none_or(|(bd, _)| dd < *bd) { besta = Some((dd, Hit::Axis { name })); } } } hits.extend(besta.map(|x| x.1)); // 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; 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) && bestp.as_ref().is_none_or(|(_, a, _)| p.area < *a) { bestp = Some((t, p.area, Hit::Profile { sketch: sid, index: pi })); } } } if let Some((t, _, h)) = bestp { surf.push((t - slack, h)); } 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) -> solvecraft_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 = solvecraft_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 = solvecraft_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::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 solvecraft_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 view cube (docked dialogs start below). pub const VIEW_CUBE_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()) { return hits.iter().find_map(|h| d.candidate(&app.session, h).map(|s| (h.clone(), s))); } let off = |k: &str| app.ui.pick_off.iter().any(|x| x == k); hits.iter() .filter(|h| match h { Hit::SketchCurve { sketch, .. } => app.session.active_sketch == Some(*sketch) && !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, }) .find_map(|h| match h { Hit::Face { body, .. } if app.ui.pick_bodies => Some((h.clone(), Sel::Body { name: body.clone() })), _ => hit_sel(h).map(|s| (h.clone(), s)), }) } /// 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: &solvecraft_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: &solvecraft_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: &solvecraft_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.seams.get(ei).copied().unwrap_or(false) { 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); } } } } 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 }; // 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: solvecraft_engine::geom::Vec2| (ss.plane.to_world(q) + lift).to_f32(); let col = c4(fill); // In the translucent pass (after the grid and edges) so it covers them. for [a, b, c] in p.region.triangulate(0.02) { 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) { let poly = lp.polyline(0.02); let n = poly.len(); for i in 0..n { if let (Some(a), Some(b)) = (poly.get(i), poly.get((i + 1) % n)) { sc.line(w(*a), w(*b), 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); 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), // 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 seg in ss.sketch.segs(ci) { let pts = seg.polyline(0.02); for w in pts.windows(2) { sc.line(ss.plane.to_world(w[0]).to_f32(), ss.plane.to_world(w[1]).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.seams.get(ei).copied().unwrap_or(false) && (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.seams.get(e).copied().unwrap_or(false) { 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: &solvecraft_engine::doc::Document, b: &solvecraft_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 { id, at, .. } = h { 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); } pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); let rect = ui.max_rect(); app.viewport.rect = Some(rect); let resp = ui.interact(rect, ui.id().with("viewport"), Sense::click_and_drag()); let (hover, scroll, mods, middle, secondary_down, primary_down, 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.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 view cube handles its own clicks; the model underneath must not see them. let cube = Rect::from_center_size(pos2(rect.right() - 80.0, rect.top() + 80.0), vec2(150.0, 150.0)); // Floating windows and menus own input over the model, including wheel navigation. let inside = resp.contains_pointer() && hover.is_some_and(|p| !cube.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); // ---- navigation ---- let h = rect.height().max(1.0) as f64; if inside && scroll != 0.0 { let f = (-scroll as f64 * 0.0015 * crate::prefs::zoom_sign(app)).exp(); // Preferences › Navigation › Zoom to the cursor (off: toward the view's centre). let anchor = 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 } }); app.cancel_view_animation(); app.cam.zoom_at(f, anchor); } let dragging = resp.dragged() || (inside && (middle || secondary_down)); if dragging && delta != egui::Vec2::ZERO { let (dx, dy) = (delta.x as f64, delta.y as f64); let mode = if middle || secondary_down { crate::prefs::nav_mode(app, middle, secondary_down, mods) } else if primary_down && app.viewport.cube_drag.is_none() { app.viewport.nav } else { None }; if mode.is_some() { app.cancel_view_animation(); } match mode { Some(NavMode::Orbit) => { let press = ui.input(|i| i.pointer.press_origin()); crate::prefs::orbit(app, rect, dx, dy, press); } Some(NavMode::Pan) => app.cam.pan(dx, dy, h), Some(NavMode::Zoom) => app.cam.zoom_at((dy * 0.01).exp(), None), 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 = 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.cam = Camera::default(); app.fit_view(); } let proj = projection(app, rect); // 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 || middle || secondary_down || (app.viewport.nav.is_some() && primary_down) || app.viewport.cube_drag.is_some() || app.viewport.spin || 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(); let cand = if app.tool.is_some() { hits.first().cloned().map(|h| (h, None)) } else { candidate(app, &hits).map(|(h, s)| (h, Some(s))) }; app.viewport.hover = cand.as_ref().map(|c| c.0.clone()); 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 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(), 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; if let Some(p) = hover.filter(|_| inside) { if app.tool.is_some() { crate::tools::on_hover(app, &proj, p); } if resp.clicked() { 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(sel) = cand.and_then(|c| c.1) && let Some(mut d) = app.dialog.take() { d.pick(&app.session, sel); app.dialog = Some(d); } } 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::sketch3d::click(app, &proj, p) { match on_dim.or(on_glyph) { Some(id) => select(app, Some(Sel::SketchConstraint { id }), add), None => select(app, cand.and_then(|c| c.1), add), } } } } // Double-click a dimension: edit its value in place. let dim_edit = resp.double_clicked() && app.tool.is_none() && app.dialog.is_none() && crate::dim_view::double_click(app, p); // Double-click a sketch curve: select the chain joined to it end to end. if resp.double_clicked() && !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() && !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() && app.viewport.nav.is_none() && app.session.active_sketch.is_some() { 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)) }; // The 3D triad, then a dimension's text, follow a drag that starts on them. if resp.drag_started_by(egui::PointerButton::Primary) && let Some(a) = ui.input(|i| i.pointer.press_origin()) && !crate::sketch3d::drag_start(app, &proj, a) && let Some(q) = on_plane(app, a) { crate::dim_view::drag_start(app, a, q); } 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::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 resp.drag_started_by(egui::PointerButton::Primary) && !crate::dim_view::dragging() && !crate::sketch3d::dragging() && let Some(a) = ui.input(|i| i.pointer.press_origin()) { let active = app.session.active_sketch; match pick(app, &proj, a).into_iter().next() { Some(Hit::SketchPoint { id, .. }) => 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, 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; } if app.session.execute("sketch.drag", &json!({"entity": id, "from": [from.x, from.y], "to": [q.x, q.y]})).is_ok() { app.session.undo.truncate(depth + 1); } if grab.is_some() { app.viewport.point_drag = Some((id, depth, Some(q))); } } if resp.drag_stopped() { if let Some((_, depth, _)) = app.viewport.point_drag.clone() { // Let go on a point, midpoint or curve: the matching constraint, in the drag's undo step. crate::drag_snap::release(app); app.session.undo.truncate(depth + 1); } crate::drag_snap::clear(); app.viewport.point_drag = None; crate::dim_view::drag_end(); crate::sketch3d::drag_end(); } } // Box selection: a primary drag on the model when no navigation mode is on. if app.tool.is_none() && app.viewport.nav.is_none() && app.viewport.point_drag.is_none() && !crate::dim_view::dragging() && !crate::sketch3d::dragging() { 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) && !cube.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::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::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(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); view_cube(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. fn select(app: &mut SolveApp, sel: Option, add: bool) { match sel { Some(x) => { let items = grouped_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 => {} } } /// 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: &solvecraft_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: &solvecraft_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() }; if app.dialog.is_none() && 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 c.link.is_some() && ss.sketch.view.hide_projected { continue; } let pts: Vec = ss.sketch.segs(i).iter().flat_map(|sg| sg.polyline(0.05)).map(|q| ss.plane.to_world(q)).collect(); if bx.takes(&to2(&pts)) { out.push(Sel::SketchCurve { id: c.id.clone() }); } } for (i, p) in ss.sketch.points.iter().enumerate().filter(|_| !off("sketch") && !off("sketch_points")) { if i != 0 && solvecraft_engine::view::sketch_point_visible(&ss.sketch, i) && bx.takes(&to2(&[ss.plane.to_world(p.pos)])) { out.push(Sel::SketchPoint { id: p.id.clone() }); } } if !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 !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.seams.get(ei).copied().unwrap_or(false) && 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 pts: Vec = p.region.outer.polyline(0.05).into_iter().map(|q| ss.plane.to_world(q)).collect(); if bx.takes(&to2(&pts)) { 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 seg in ss.sketch.segs(ci) { let pts: Vec = seg.polyline(0.05).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect(); painter.add(Shape::line(pts, 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(); 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(); for (i, p) in sk.points.iter().enumerate() { if !solvecraft_engine::view::sketch_point_visible(sk, i) { 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 { 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(); scene_key(app).hash(&mut h); for v in [proj.cam.yaw, proj.cam.pitch, 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 scene = solvecraft_engine::view::scene(&app.session, &proj.cam); let w = (rect.width() as usize).clamp(16, 4096); let hh = (rect.height() as usize).clamp(16, 4096); let c = solvecraft_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); } } /// Where a point in view-cube units (faces at ±1) is drawn. pub(crate) fn cube_point(app: &SolveApp, rect: Rect, p: Vec3) -> Pos2 { let (r, u, _) = cube_basis(&app.cam); pos2(rect.right() - 80.0 + (p.dot(r) * CUBE_SCALE) as f32, rect.top() + 80.0 - (p.dot(u) * CUBE_SCALE) as f32) } /// Half a view cube face, in pixels. const CUBE_SCALE: f64 = 32.0; /// The view cube (top right): a cube with chamfered corners. Its 14 targets are the 6 faces /// (orthographic views) and the 8 corner facets (iso views); the edges between them are not /// targets. Hover lights the face or facet under the pointer, a click turns the view to it, the /// house goes home. /// The camera's basis in its up-axis frame (Z up): the cube's TOP is the world's up, X, Y or Z. fn cube_basis(cam: &Camera) -> (Vec3, Vec3, Vec3) { let (r, u, b) = cam.basis(); (cam.to_frame(r), cam.to_frame(u), cam.to_frame(b)) } /// Dragging a primary press on the cube orbits about the view centre. A stationary press /// remains a click; a floating window over the cube owns its input. fn view_cube_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, targets: &[CubeTarget], c: Pos2) -> bool { let (pressed, down, decided, origin, pos) = ui.input(|i| { let p = &i.pointer; (p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos()) }); if pressed && ui.rect_contains_pointer(rect) { let shapes = cube_projected(targets, cube_basis(&app.cam), c); app.viewport.cube_drag = origin.filter(|p| cube_hit(&shapes, *p).is_some()); } if !down { app.viewport.cube_drag = None; } let Some(last) = app.viewport.cube_drag.filter(|_| decided) else { return false }; ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); // Include the movement that crossed the drag threshold, so the orbit follows the pointer. if let Some(p) = pos && p != last { app.cancel_view_animation(); app.cam.orbit(f64::from(p.x - last.x), f64::from(p.y - last.y)); app.viewport.cube_drag = Some(p); ui.ctx().request_repaint(); } true } fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { let t = Tokens::get(); let c = pos2(rect.right() - 80.0, rect.top() + 80.0); let targets = cube_targets(); let orbiting = view_cube_orbit(app, ui, rect, &targets, c); let (r, u, b) = cube_basis(&app.cam); let (wr, wu, _) = app.cam.basis(); let painter = ui.painter_at(rect); let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect)); let shapes = cube_projected(&targets, (r, u, b), c); let hovered = if orbiting { None } else { hover.and_then(|h| cube_hit(&shapes, h)) }; // Axis triad from the cube's back corner, drawn first: the cube hides what is behind it. // The corner is in the cube's frame, the axes are the world's. let o = Vec3::new(-1.0, -1.0, -1.0); let at = pos2(c.x + (o.dot(r) * CUBE_SCALE) as f32, c.y - (o.dot(u) * CUBE_SCALE) as f32); for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] { let tip = at + vec2((d.dot(wr) * CUBE_SCALE * 2.6) as f32, -(d.dot(wu) * CUBE_SCALE * 2.6) as f32); painter.line_segment([at, tip], Stroke::new(2.0, Color32::from_rgb(col.0, col.1, col.2))); } let labels: Vec = targets.iter().filter(|tg| !tg.corner).map(|tg| tg.name.to_uppercase()).collect(); let size = cube_label_size(ui.ctx(), &labels); // Back to front: the faces and facets turned toward the camera. for (i, pts) in shapes.iter().rev() { let tg = &targets[*i]; let n = tg.normal(); let shade = (0.75 + 0.25 * n.dot(b)) as f32; let fill = if tg.corner { t.cube_face.gamma_multiply(shade * 0.92) } else { t.cube_face.gamma_multiply(shade) }; painter.extend(projected_polygon(pts, fill.to_opaque(), Stroke::new(1.0, t.cube_edge))); if hovered == Some(*i) { painter.extend(projected_polygon(pts, t.accent_soft, Stroke::new(1.0, t.accent))); } let centre = cube_centroid(pts); crate::scenario::publish_handle(&format!("cube:{}", tg.name), centre); // Fades out as the face turns edge-on. let ink = (n.dot(b) / 0.3).clamp(0.0, 1.0) as f32; if !tg.corner && ink > 0.05 { let color = Color32::from_rgb(40, 44, 52).gamma_multiply(ink); painter.add(cube_label(ui.ctx(), &tg.name.to_uppercase(), size, n, (r, u), c, color)); } } crate::scenario::publish_count("cube_highlights", f64::from(u8::from(hovered.is_some()))); if let Some(i) = hovered { crate::scenario::publish_handle(&format!("cube_hover:{}", targets[i].name), hover.unwrap_or(c)); } // Clicked: the view from the face's or facet's direction (target → eye). let clicked = hovered.filter(|_| ui.input(|i| i.pointer.primary_clicked())).map(|i| targets[i].dir); let home = Rect::from_center_size(pos2(c.x - 52.0, c.y - 48.0), vec2(18.0, 18.0)); let hr = ui.interact(home, ui.id().with("vc_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("Home view").clicked() { app.animate_view("home"); } if let Some(dir) = clicked { let mut to = app.cam.looking_from(app.cam.from_frame(dir)); // Keep the nearest square heading when looking straight down or up. if dir.x == 0.0 && dir.y == 0.0 { to.yaw = nearest_quarter_turn(app.cam.yaw); } app.animate_to(to); } } /// The nearest square heading. At a halfway tie, prefer the heading nearer the standard /// plan view, so clicking TOP or BOTTOM from home still puts the front edge at the bottom. 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 } } /// How far along each edge a view cube corner is cut off (the cube's faces are at ±1). const CUBE_CHAMFER: f64 = 0.38; /// A view cube target: a face or a corner facet, its view direction (target → eye) and outline /// (cube units). #[derive(Clone, Debug)] pub(crate) struct CubeTarget { pub name: String, pub dir: Vec3, pub corner: bool, pub outline: Vec, } impl CubeTarget { fn normal(&self) -> Vec3 { self.dir.normalized().unwrap_or(Vec3::Z) } } /// The 14 targets: 6 octagonal faces and 8 triangular corner facets. pub(crate) fn cube_targets() -> Vec { let k = CUBE_CHAMFER; let mut out = Vec::new(); for (n, name) in [ (Vec3::new(0.0, -1.0, 0.0), "front"), (Vec3::new(0.0, 1.0, 0.0), "back"), (Vec3::new(1.0, 0.0, 0.0), "right"), (Vec3::new(-1.0, 0.0, 0.0), "left"), (Vec3::Z, "top"), (Vec3::new(0.0, 0.0, -1.0), "bottom"), ] { let (e1, e2) = cube_face_axes(n); let uv = [(-1.0 + k, -1.0), (1.0 - k, -1.0), (1.0, -1.0 + k), (1.0, 1.0 - k), (1.0 - k, 1.0), (-1.0 + k, 1.0), (-1.0, 1.0 - k), (-1.0, -1.0 + k)]; out.push(CubeTarget { name: name.into(), dir: n, corner: false, outline: uv.iter().map(|(a, b2)| n + e1 * *a + e2 * *b2).collect() }); } for sz in [1.0, -1.0] { for sy in [-1.0, 1.0] { for sx in [1.0, -1.0] { let corner = Vec3::new(sx, sy, sz); let name = format!( "{}-{}-{}", if sz > 0.0 { "top" } else { "bottom" }, if sy < 0.0 { "front" } else { "back" }, if sx > 0.0 { "right" } else { "left" } ); let outline = vec![corner - Vec3::X * (sx * k), corner - Vec3::Y * (sy * k), corner - Vec3::Z * (sz * k)]; out.push(CubeTarget { name, dir: corner, corner: true, outline }); } } } out } /// Where a point in cube units is drawn, for the camera's right and up `ru` and the cube's /// centre `c`. fn cube_to_screen((r, u): (Vec3, Vec3), c: Pos2, p: Vec3) -> Pos2 { pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32) } /// A face's text directions (right, up): read from outside the cube, and upright in the view /// of the standard face view (TOP and BOTTOM: yaw 0, front edge at the bottom). fn cube_label_axes(n: Vec3) -> (Vec3, Vec3) { if n.z.abs() > 0.5 { (Vec3::X, Vec3::Y * n.z.signum()) } else { (Vec3::Z.cross(n), Vec3::Z) } } /// How much of a face the widest face label spans. const CUBE_LABEL_WIDTH: f32 = 0.9; /// The face labels' font size: one size for all, the widest of `labels` spanning /// `CUBE_LABEL_WIDTH` of a face. fn cube_label_size(ctx: &egui::Context, labels: &[String]) -> f32 { let probe = 10.0; let widest = labels .iter() .map(|s| ctx.fonts_mut(|f| f.layout_no_wrap(s.clone(), FontId::proportional(probe), Color32::WHITE).size().x)) .fold(0.0, f32::max); if widest > 0.0 { probe * CUBE_LABEL_WIDTH * 2.0 * CUBE_SCALE as f32 / widest } else { probe } } /// A face's label printed on the face at `size`: laid out flat, then mapped through the cube's /// projection, so it turns and foreshortens with the face. fn cube_label(ctx: &egui::Context, text: &str, size: f32, n: Vec3, ru: (Vec3, Vec3), c: Pos2, color: Color32) -> Shape { let galley = ctx.fonts_mut(|f| f.layout(text.to_string(), FontId::proportional(size), color, f32::INFINITY)); let options = egui::epaint::TessellationOptions { round_text_to_pixels: false, ..Default::default() }; let mut tess = egui::epaint::Tessellator::new(ctx.pixels_per_point(), options, ctx.fonts(|f| f.font_image_size()), vec![]); let mut mesh = egui::Mesh::default(); let at = (-galley.size() / 2.0).to_pos2(); tess.tessellate_text(&egui::epaint::TextShape::new(at, galley, color), &mut mesh); let (right, up) = cube_label_axes(n); for v in &mut mesh.vertices { let p = n + right * (f64::from(v.pos.x) / CUBE_SCALE) - up * (f64::from(v.pos.y) / CUBE_SCALE); v.pos = cube_to_screen(ru, c, p); } Shape::mesh(mesh) } /// The targets turned toward the camera (basis `rub`), front first, as screen polygons around /// the cube's centre `c`. pub(crate) fn cube_projected(targets: &[CubeTarget], rub: (Vec3, Vec3, Vec3), c: Pos2) -> Vec<(usize, Vec)> { let (r, u, b) = rub; let mut v: Vec<(usize, f64, Vec)> = targets .iter() .enumerate() .filter(|(_, tg)| tg.normal().dot(b) > 1e-3) .map(|(i, tg)| (i, tg.normal().dot(b), tg.outline.iter().map(|p| cube_to_screen((r, u), c, *p)).collect())) .collect(); v.sort_by(|a, b2| b2.1.total_cmp(&a.1)); v.into_iter().map(|(i, _, p)| (i, p)).collect() } /// The target under `p`. The faces and facets tile the cube, so a point on an edge between two /// of them (where neither polygon claims it) goes to the nearer one, within a few pixels. pub(crate) fn cube_hit(shapes: &[(usize, Vec)], p: Pos2) -> Option { if let Some((i, _)) = shapes.iter().find(|(_, pts)| point_in_poly(p, pts)) { return Some(*i); } shapes.iter().map(|(i, pts)| (*i, poly_distance(p, pts))).filter(|(_, d)| *d <= 3.0).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i) } fn poly_distance(p: Pos2, pts: &[Pos2]) -> f32 { (0..pts.len()) .map(|i| { let (a, b) = (pts[i], pts[(i + 1) % pts.len()]); let ab = b - a; let t = ((p - a).dot(ab) / ab.length_sq().max(1e-6)).clamp(0.0, 1.0); p.distance(a + ab * t) }) .fold(f32::INFINITY, f32::min) } fn cube_centroid(pts: &[Pos2]) -> Pos2 { let n = pts.len().max(1) as f32; pos2(pts.iter().map(|p| p.x).sum::() / n, pts.iter().map(|p| p.y).sum::() / n) } /// The in-face axes of a view cube face. fn cube_face_axes(n: Vec3) -> (Vec3, Vec3) { if n.z.abs() > 0.5 { (Vec3::X, Vec3::Y) } else if n.x.abs() > 0.5 { (Vec3::Y, Vec3::Z) } else { (Vec3::X, Vec3::Z) } } fn point_in_poly(p: Pos2, poly: &[Pos2]) -> bool { let n = poly.len(); let mut inside = false; for i in 0..n { let (a, b) = (poly[i], poly[(i + 1) % n]); if (a.y > p.y) != (b.y > p.y) { let x = a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x); if p.x < x { inside = !inside; } } } inside } /// 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 } => solvecraft_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) { let t = Tokens::get(); let items: [(&str, &str); 10] = [ ("orbit", "Orbit (drag; or Shift+middle / right drag)"), ("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 = Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(w, 30.0)); let painter = ui.painter_at(rect); painter.rect(bar, 6.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); for (i, (icon, tip)) in items.iter().enumerate() { let br = Rect::from_min_size(pos2(bar.left() + 5.0 + i as f32 * 30.0, bar.top() + 2.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 = egui::Area::new(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("VIEW").strong().size(11.5)); ui.checkbox(&mut app.ui.show_grid, "Grid"); ui.checkbox(&mut app.ui.ground_shadow, "Ground shadow"); ui.separator(); let s = &mut app.ui.agent_cursor; let show = ui.checkbox(&mut s.show, "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("Speed"); crate::agent_cursor::speed_slider(ui, &mut s.speed); }); ui.checkbox(&mut s.follow_camera, "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::*; #[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(solvecraft_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(solvecraft_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 _ = 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); }, ); // 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 = solvecraft_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 }); } app } #[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(solvecraft_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 = solvecraft_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 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 shortcuts_window_wheel_does_not_zoom_the_viewport() { let ctx = egui::Context::default(); let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default()); 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 _ = 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()); }, ); }; // 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 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_view_cube_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("cube:top").unwrap(); h.call("ui.move", json!({"x": p.x, "y": p.y})); assert_eq!(crate::scenario::count_of("cube_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 cube 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("cube_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 view cube"); 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 cube"); assert!(h.app.viewport.cube_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("cube_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_cube_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("cube:front").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:?}: cube drag must orbit exactly once"); assert_eq!(h.app.cam.target, before.target, "{nav:?}: cube drag must not pan"); assert_eq!(h.app.cam.distance, before.distance); assert_eq!(h.app.cam.pitch, before.pitch); assert!(h.app.viewport.cube_drag.is_none(), "release clears the cube gesture"); assert!(h.app.viewport.boxsel.is_none()); } } #[test] fn section_outlines_follow_the_cut() { let mut s = solvecraft_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 = 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!(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 = solvecraft_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()); } /// A camera basis looking from `dir` (target → eye), as the view cube draws with. fn basis_from(dir: Vec3) -> (Vec3, Vec3, Vec3) { let cam = solvecraft_engine::render::Camera::default().looking_from(dir); cam.basis() } #[test] fn view_cube_has_14_targets_each_hit_where_drawn() { let targets = cube_targets(); assert_eq!(targets.len(), 14); assert_eq!(targets.iter().filter(|t| t.corner).count(), 8); let c = pos2(100.0, 100.0); for (i, tg) in targets.iter().enumerate() { // Seen from a little off its own direction, its middle hits it. let shapes = cube_projected(&targets, basis_from(tg.dir + Vec3::new(0.11, 0.07, 0.05)), c); let pts = &shapes.iter().find(|(j, _)| *j == i).expect("visible").1; assert_eq!(cube_hit(&shapes, cube_centroid(pts)), Some(i), "{}", tg.name); } // From an iso view: 3 faces and the 4 facets turned toward it. let shapes = cube_projected(&targets, basis_from(Vec3::new(1.0, -1.0, 1.0)), c); assert_eq!(shapes.len(), 3 + 4); assert_eq!(cube_hit(&shapes, pos2(400.0, 400.0)), None); } /// From any direction, including faces turned nearly edge-on, everything drawn stays within /// the cube. #[test] fn view_cube_faces_never_spike_past_the_cube() { let targets = cube_targets(); let c = pos2(100.0, 100.0); // The cube's circumradius (corner at √3) plus the stroke and feathering. let limit = (3f64.sqrt() * CUBE_SCALE) as f32 + 3.0; let mut worst = 0.0f32; for dir in orbit_directions() { for (_, pts) in cube_projected(&targets, basis_from(dir), c) { for p in drawn_points(projected_polygon(&pts, Color32::GRAY, Stroke::new(1.0, Color32::BLACK))) { worst = worst.max(p.distance(c)); } } } assert!(worst <= limit, "a vertex {worst} px from the centre (limit {limit})"); } #[test] fn view_cube_labels_read_upright_in_their_own_view() { for tg in cube_targets().iter().filter(|t| !t.corner) { // The standard face view (TOP and BOTTOM with yaw 0). let mut cam = solvecraft_engine::render::Camera::default().looking_from(tg.dir); if tg.dir.z.abs() > 0.5 { cam.yaw = 0.0; } let (r, u, _) = cam.basis(); let (right, up) = cube_label_axes(tg.dir); assert!(right.dot(r) > 0.99 && up.dot(u) > 0.99, "{}: right·r {} up·u {}", tg.name, right.dot(r), up.dot(u)); } } fn face_labels(targets: &[CubeTarget]) -> Vec { targets.iter().filter(|t| !t.corner).map(|t| t.name.to_uppercase()).collect() } #[test] fn view_cube_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); } /// The widest label spans 90% of its face, looking straight at it. #[test] fn view_cube_widest_label_spans_most_of_its_face() { let ctx = egui::Context::default(); let mut span = 0.0f32; let _ = ctx.run_ui(egui::RawInput::default(), |ui| { let size = cube_label_size(ui.ctx(), &face_labels(&cube_targets())); let front = Vec3::new(0.0, -1.0, 0.0); let (r, u, _) = basis_from(front); let pts = drawn_points(vec![cube_label(ui.ctx(), "BOTTOM", size, front, (r, u), pos2(100.0, 100.0), Color32::BLACK)]); let (lo, hi) = pts.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| (lo.min(p.x), hi.max(p.x))); span = (hi - lo) / (2.0 * CUBE_SCALE as f32); }); assert!((span - CUBE_LABEL_WIDTH).abs() < 0.05, "BOTTOM spans {span} of the face"); } /// From any direction, each label is printed within its face. #[test] fn view_cube_labels_stay_on_their_faces() { let ctx = egui::Context::default(); let (mut worst, mut drawn) = (f32::NEG_INFINITY, 0); let _ = ctx.run_ui(egui::RawInput::default(), |ui| { let targets = cube_targets(); let size = cube_label_size(ui.ctx(), &face_labels(&targets)); let c = pos2(100.0, 100.0); for dir in orbit_directions().step_by(7) { let (r, u, b) = basis_from(dir); for (i, pts) in cube_projected(&targets, (r, u, b), c) { let tg = &targets[i]; if tg.corner { continue; } for p in drawn_points(vec![cube_label(ui.ctx(), &tg.name.to_uppercase(), size, tg.normal(), (r, u), c, Color32::BLACK)]) { let outside = if point_in_poly(p, &pts) { 0.0 } else { poly_distance(p, &pts) }; worst = worst.max(outside); drawn += 1; } } } }); assert!(drawn > 10_000, "only {drawn} label vertices drawn"); assert!(worst <= 0.5, "a label vertex {worst} px outside its face"); } #[test] fn view_cube_edges_are_not_targets() { let targets = cube_targets(); let c = pos2(100.0, 100.0); let shapes = cube_projected(&targets, basis_from(Vec3::new(1.0, -1.2, 0.9)), c); let (r, u, _) = basis_from(Vec3::new(1.0, -1.2, 0.9)); let to2 = |p: Vec3| pos2(c.x + (p.dot(r) * CUBE_SCALE) as f32, c.y - (p.dot(u) * CUBE_SCALE) as f32); let name = |i: Option| i.map(|i| targets[i].name.clone()).unwrap_or_default(); // On the top-front edge, its middle and near its ends: a neighbouring face (or facet). for x in [-0.5, 0.0, 0.5] { let hit = name(cube_hit(&shapes, to2(Vec3::new(x, -1.0, 1.0)))); assert!(hit == "top" || hit == "front", "edge point {x}: {hit}"); } let end = name(cube_hit(&shapes, to2(Vec3::new(0.9, -1.0, 1.0)))); assert!(["top", "front", "top-front-right"].contains(&end.as_str()), "{end}"); // Every direction a click turns to is a face's or a corner's: none along an edge. for tg in &targets { let zeros = [tg.dir.x, tg.dir.y, tg.dir.z].iter().filter(|v| v.abs() < 1e-9).count(); assert!(zeros == 2 || zeros == 0, "{}: {:?}", tg.name, tg.dir); } } #[test] fn construction_axes_span_the_model_and_points_show() { let mut s = solvecraft_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()); } }