diff --git a/ROADMAP.md b/ROADMAP.md index 53e9e14e..04c4822c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,10 +7,10 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat | Question | Answer | |---|---| -| Where are we? | **M0 (vertical slice) done.** Sketch with solver, extrude/revolve/fillet/chamfer/cut, parameters that rebuild the timeline, STEP/STL export, desktop app, CLI, oracle harness. | -| Command parity (SOLID + SKETCH toolbar) | **46 / 291 (16%)** — [docs/parity.md](docs/parity.md) | -| Fusion oracle | **2 / 29 parts match** Fusion's measurements (16 need features not built yet) — [docs/oracle.md](docs/oracle.md) | -| Tests | 60 (solver, profiles, kernel booleans/blends/measures, expressions, timeline, file formats, camera, engine end-to-end, hostile-input fuzz over every command) | +| Where are we? | **M0 done; M1/M2 in progress.** Sketch with solver, extrude (taper, two-sided, through all), revolve, fillet/chamfer, holes, patterns, mirror, shell, draft, loft, sweep, split, construction planes, parameters that rebuild the timeline, STEP/STL export, desktop app with Fusion-style selection (hover/selected highlights, origin planes, click-to-pick sketch planes, multi-select, window/crossing box selection, animated view cube), CLI, MCP server, oracle harness. | +| Command parity (SOLID + SKETCH toolbar) | **57 / 291 (20%)** — [docs/parity.md](docs/parity.md) | +| Fusion oracle | **23 / 29 parts match** Fusion's measurements — [docs/oracle.md](docs/oracle.md) | +| Tests | 84 (solver, profiles, kernel booleans/blends/measures, expressions, timeline, file formats, camera, engine end-to-end, hostile-input fuzz over every command) | | Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering — green | | Weighted parity estimate | **≈ 5%** of Fusion's Design workspace by importance (sketch + basic solids are the core, but surfaces, assemblies, sheet metal, CAM, drawings are untouched) | | Time to a useful alpha (M0–M6) | ≈ **150 agent hours** remain | @@ -21,11 +21,11 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat |---|---|---|---| | M0 | Vertical slice | done | — | | M1 | Sketch depth, oracle sketch cases (arcs, taper, two-sided, sketch modify tools) | in progress | 16 | -| M2 | Robust modelling: coplanar booleans in the kernel, holes, patterns, mirror, shell, draft, split, construction geometry | planned | 24 | +| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 | | M3 | General fillets and chamfers (curved edges, chains, vertex blends) | planned | 30 | | M4 | Files: STEP import, mesh import, DXF sketches, 3MF | planned | 16 | | M5 | Persistent naming of faces and edges | planned | 20 | -| M6 | UI depth: profile shading, selection filters, measure tool, section view, edit dialogs, drag-solve | planned | 24 | +| M6 | UI depth: selection feedback and origin (done), timeline editing (edit feature, rollback marker, reorder, suppress, re-resolved references), live previews, measure tool, section view, drag-solve | in progress | 24 | | M7 | Sweep, loft, rib, web, emboss, thread, coil, pipe | planned | 24 | | M8 | Surface workspace | planned | 30 | | M9 | Assemblies and joints | planned | 40 | @@ -41,6 +41,8 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat extended automatically); fully internal voids are not supported. - Mass properties come from fine tessellation (curved faces within ~1e-4 relative). - No STEP import yet; no persistent naming (fillet edges are re-found by position). +- Shell and draft work on convex bodies with planar faces; loft is ruled (no tangency end conditions). +- Dialogs have no live preview of the result yet. ## Kernel diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6fd51184..362d848e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -99,6 +99,19 @@ pub enum Sel { sketch: u64, index: usize, }, + /// An origin plane (XY, XZ, YZ) or a construction plane (by feature name). + Plane { + name: String, + }, + /// An origin axis (X, Y, Z). + Axis { + name: String, + }, + /// A body vertex at a point. + Vertex { + body: String, + point: Vec3, + }, } /// One undo step. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 591d0225..4e6685ac 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -312,3 +312,27 @@ fn sample_design_builds() { let v = m["total"]["volume_mm3"].as_f64().unwrap(); assert!(rel(v, plate + boss - bore) < 1e-3, "{v} vs {}", plate + boss - bore); } + +#[test] +fn sample_edges_chain_and_faces() { + let mut s = Session::default(); + s.run_script(&crate::sample::script()).unwrap(); + let st = s.model.state(); + let m = st.bodies[0].mesh(); + let e = (0..m.edges.len()).find(|i| m.edges[*i].iter().all(|p| (p.z - 8.0).abs() < 1e-6 && p.y.abs() < 1e-6)).expect("front top edge"); + let chain = m.tangent_chain(e, 2f64.to_radians()); + // The plate's top outline: 4 lines and 4 corner arcs (a boolean may leave an edge twice, + // once per face; count distinct ones). + let mut mids: Vec = Vec::new(); + for i in &chain { + let p = &m.edges[*i]; + let mid = p[p.len() / 2]; + assert!(p.iter().all(|q| (q.z - 8.0).abs() < 1e-6), "{p:?}"); + if !mids.iter().any(|q| q.dist(mid) < 1e-6) { + mids.push(mid); + } + } + assert_eq!(mids.len(), 8, "{chain:?}"); + // Every edge bounds a face, and the top face has the outline and the hole rims. + assert!(m.edge_faces.iter().all(|f| !f.is_empty())); +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index e3b728bb..4f3a3887 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -143,3 +143,17 @@ pub fn home_camera(s: &Session) -> Camera { } c } + +/// Construction planes of the timeline: (feature id, name, plane). +pub fn construction_planes(s: &Session) -> Vec<(u64, String, Plane)> { + use solvecraft_doc::FeatureKind; + let (vals, _) = s.doc.param_values(); + s.doc + .features + .iter() + .filter_map(|f| match &f.kind { + FeatureKind::ConstructionPlane { plane } if !f.suppressed => s.doc.resolve_plane(&vals, plane, 0).ok().map(|p| (f.id, f.name.clone(), p)), + _ => None, + }) + .collect() +} diff --git a/crates/geom/src/mesh.rs b/crates/geom/src/mesh.rs index 76ef9b3a..8ad84338 100644 --- a/crates/geom/src/mesh.rs +++ b/crates/geom/src/mesh.rs @@ -64,6 +64,9 @@ pub struct Mesh { /// Per edge: a seam between two pieces of the same smooth surface (not drawn). #[serde(default)] pub seams: Vec, + /// Per edge: the B-rep faces it bounds (one or two). + #[serde(default)] + pub edge_faces: Vec>, } /// Mass properties of a closed mesh. @@ -75,6 +78,53 @@ pub struct MeshMeasure { } impl Mesh { + /// Edges bounding face `f`. + pub fn face_edges(&self, f: u32) -> Vec { + self.edge_faces.iter().enumerate().filter(|(_, fs)| fs.contains(&f)).map(|(i, _)| i).collect() + } + /// The edges reached from `e` through vertices where the next edge continues smoothly + /// (angle between the end tangents below `max_angle`), including `e` itself. + pub fn tangent_chain(&self, e: usize, max_angle: f64) -> Vec { + // (end point, direction leaving the edge there). A polyline's end chord leans inward + // on a curve by half the turn to the next chord; extrapolating the two end chords + // recovers the curve's end tangent. + let end_dir = |p0: Vec3, p1: Vec3, p2: Option| -> Option { + let u1 = (p0 - p1).normalized()?; + match p2.and_then(|p2| (p1 - p2).normalized()) { + Some(u2) => (u1 * 1.5 - u2 * 0.5).normalized(), + None => Some(u1), + } + }; + let ends = |i: usize| -> Option<[(Vec3, Vec3); 2]> { + let p = self.edges.get(i)?; + let n = p.len(); + let (a, a1, a2) = (*p.first()?, *p.get(1)?, p.get(2).copied().filter(|_| n > 2)); + let (b, b1) = (*p.last()?, *p.get(n.checked_sub(2)?)?); + let b2 = n.checked_sub(3).and_then(|k| p.get(k)).copied(); + Some([(a, end_dir(a, a1, a2)?), (b, end_dir(b, b1, b2)?)]) + }; + let size = self.bounds().diagonal().max(1e-9); + let tol = size * 1e-7; + let cos = max_angle.cos(); + let mut chain = vec![e]; + let mut todo = vec![e]; + while let Some(cur) = todo.pop() { + let Some(ce) = ends(cur) else { continue }; + for (i, _) in self.edges.iter().enumerate() { + if chain.contains(&i) || self.seams.get(i).copied().unwrap_or(false) { + continue; + } + let Some(ne) = ends(i) else { continue }; + // Smooth: leaving `cur` at a shared end continues into `i` (opposite directions). + let smooth = ce.iter().any(|(p, d)| ne.iter().any(|(q, dn)| p.dist(*q) < tol && d.dot(-*dn) > cos)); + if smooth && chain.len() < 10_000 { + chain.push(i); + todo.push(i); + } + } + } + chain + } pub fn tri(&self, t: &[u32; 3]) -> Option<[Vec3; 3]> { Some([*self.positions.get(t[0] as usize)?, *self.positions.get(t[1] as usize)?, *self.positions.get(t[2] as usize)?]) } diff --git a/crates/geom/src/profile.rs b/crates/geom/src/profile.rs index 0e78e2e4..0d5ee390 100644 --- a/crates/geom/src/profile.rs +++ b/crates/geom/src/profile.rs @@ -233,6 +233,44 @@ impl Region2 { pub fn contains(&self, p: Vec2) -> bool { self.outer.contains(p) && !self.holes.iter().any(|h| h.contains(p)) } + /// Triangles covering the region (for filled display), by slabs between the vertex + /// heights: inside each slab the boundary edges don't cross, so consecutive crossings pair + /// up into trapezoids. + pub fn triangulate(&self, tol: f64) -> Vec<[Vec2; 3]> { + let mut edges: Vec<(Vec2, Vec2)> = Vec::new(); + for l in std::iter::once(&self.outer).chain(&self.holes) { + let pl = l.polyline(tol); + let n = pl.len(); + for i in 0..n { + let (Some(a), Some(b)) = (pl.get(i), pl.get((i + 1) % n)) else { continue }; + if (a.y - b.y).abs() > 1e-12 { + edges.push(if a.y < b.y { (*a, *b) } else { (*b, *a) }); + } + } + } + if edges.len() > 20_000 { + return Vec::new(); + } + let mut ys: Vec = edges.iter().flat_map(|(a, b)| [a.y, b.y]).collect(); + ys.sort_by(f64::total_cmp); + ys.dedup_by(|a, b| (*a - *b).abs() < 1e-12); + let x_at = |e: &(Vec2, Vec2), y: f64| e.0.x + (y - e.0.y) / (e.1.y - e.0.y) * (e.1.x - e.0.x); + let mut out = Vec::new(); + for w in ys.windows(2) { + let (y0, y1) = (w[0], w[1]); + let ym = (y0 + y1) * 0.5; + let mut span: Vec<&(Vec2, Vec2)> = edges.iter().filter(|e| e.0.y <= ym && e.1.y >= ym).collect(); + span.sort_by(|a, b| x_at(a, ym).total_cmp(&x_at(b, ym))); + for pair in span.chunks(2) { + let [l, r] = pair else { continue }; + let (a, b) = (Vec2::new(x_at(l, y0), y0), Vec2::new(x_at(r, y0), y0)); + let (c, d) = (Vec2::new(x_at(r, y1), y1), Vec2::new(x_at(l, y1), y1)); + out.push([a, b, c]); + out.push([a, c, d]); + } + } + out + } pub fn centroid(&self) -> Vec2 { let ao = self.outer.signed_area().abs(); let mut c = self.outer.centroid() * ao; @@ -250,6 +288,20 @@ impl Region2 { mod tests { use super::*; + #[test] + fn triangulated_area() { + let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 8.0), Vec2::new(5.0, 3.0), Vec2::new(0.0, 8.0)]); + let r = Region2 { outer: sq, holes: vec![Loop2::circle(Vec2::new(3.0, 1.5), 1.0).reversed()] }; + let tris = r.triangulate(1e-3); + let a: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).abs() * 0.5).sum(); + // The circle is a polyline within the tolerance, so the areas agree to about that. + assert!((a - r.area()).abs() < 1e-3 * r.area(), "{a} vs {}", r.area()); + for [a, b, c] in &tris { + let m = (*a + *b + *c) / 3.0; + assert!((*b - *a).cross(*c - *a).abs() < 1e-12 || r.contains(m), "{m:?}"); + } + } + #[test] fn areas() { let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(4.0, 3.0), Vec2::new(0.0, 3.0)]); diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index ccb050e7..bd6fd93c 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -6,7 +6,7 @@ fn cube_mesh(s: f64) -> Mesh { let triangles = vec![[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]]; let n = triangles.len(); - Mesh { normals: vec![Vec3::Z; 8], positions, triangles, tri_face: vec![0; n], edges: vec![], seams: vec![] } + Mesh { normals: vec![Vec3::Z; 8], positions, triangles, tri_face: vec![0; n], edges: vec![], seams: vec![], edge_faces: vec![] } } #[test] @@ -62,3 +62,24 @@ fn aabb() { b.add(Vec3::new(-1.0, 0.0, 0.0)); assert_eq!(b.size(), Vec3::new(2.0, 2.0, 3.0)); } + +#[test] +fn tangent_chains_follow_smooth_edges() { + // A line, then an arc leaving it tangentially, then a sharp corner. + let mut m = Mesh { positions: vec![Vec3::ZERO, Vec3::new(30.0, 30.0, 0.0)], ..Default::default() }; + m.edges.push(vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 0.0)]); + let arc: Vec = (0..=16) + .map(|k| { + let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::FRAC_PI_2 * k as f64 / 16.0; + Vec3::new(10.0 + 5.0 * a.cos(), 5.0 + 5.0 * a.sin(), 0.0) + }) + .collect(); + m.edges.push(arc); + m.edges.push(vec![Vec3::new(15.0, 5.0, 0.0), Vec3::new(5.0, 5.0, 0.0)]); + m.edge_faces = vec![vec![0], vec![0, 1], vec![1]]; + let mut c = m.tangent_chain(0, 5f64.to_radians()); + c.sort(); + assert_eq!(c, vec![0, 1]); + assert_eq!(m.tangent_chain(2, 5f64.to_radians()), vec![2]); + assert_eq!(m.face_edges(1), vec![1, 2]); +} diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 3f5084d6..d3ea3094 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -141,14 +141,27 @@ impl Body { } } } - let mut seen = std::collections::HashSet::new(); + let mut seen = std::collections::HashMap::new(); for e in meshed.edge_iter() { - if !seen.insert(e.id()) { + if seen.contains_key(&e.id()) { continue; } + seen.insert(e.id(), out.edges.len()); let c = e.oriented_curve(); out.edges.push(c.0.iter().map(|p| Vec3::new(p.x, p.y, p.z)).collect()); } + // Which faces each edge bounds. + out.edge_faces = vec![Vec::new(); out.edges.len()]; + for (fi, face) in meshed.face_iter().enumerate() { + for e in face.edge_iter() { + if let Some(slot) = seen.get(&e.id()).and_then(|i| out.edge_faces.get_mut(*i)) { + let f = u32::try_from(fi).unwrap_or(u32::MAX); + if !slot.contains(&f) { + slot.push(f); + } + } + } + } Ok(out) }) } diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 598d6289..e41ac70f 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -220,6 +220,7 @@ mod tests { tri_face: vec![0], edges: vec![], seams: vec![], + edge_faces: vec![], }; let scene = Scene { meshes: vec![SceneMesh { mesh: Arc::new(mesh), color: Rgb(200, 30, 30) }], lines: vec![], background: None, radius: 20.0 }; diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 6acbabe9..29804c5d 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -29,6 +29,24 @@ fn eye(ui: &mut egui::Ui, visible: bool) -> bool { resp.on_hover_text(if visible { "Hide" } else { "Show" }).clicked() } +/// A browser click on a plane or axis: it goes to the open dialog's input if that takes it, +/// otherwise it becomes the selection. +fn pick_from_browser(app: &mut SolveApp, x: Sel) { + let hit = match &x { + Sel::Plane { name } => crate::viewport::Hit::Plane { name: name.clone(), point: solvecraft_engine::geom::Vec3::ZERO }, + Sel::Axis { name } => crate::viewport::Hit::Axis { name: name.clone() }, + _ => return, + }; + if let Some(mut d) = app.dialog.take() { + if let Some(sel) = d.candidate(&app.session, &hit) { + d.pick(&app.session, sel); + } + app.dialog = Some(d); + return; + } + let _ = app.run("select.set", json!({ "items": [x] })); +} + pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); egui::Panel::left("sc_browser") @@ -53,16 +71,38 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) { app.start("ChangeParameterCommand"); } }); - egui::CollapsingHeader::new("Origin").default_open(false).show(ui, |ui| { - ui.horizontal(|ui| { - if eye(ui, app.ui.show_origin) { - app.ui.show_origin = !app.ui.show_origin; - } - ui.label("O, X, Y, Z"); - }); - for p in ["XY", "XZ", "YZ"] { - row(ui, "plane", p, None, false); + ui.horizontal(|ui| { + if eye(ui, app.ui.show_origin) { + app.ui.show_origin = !app.ui.show_origin; } + egui::CollapsingHeader::new("Origin").default_open(false).show(ui, |ui| { + let items = [("O", "point", "Origin point"), ("X", "axis", "X axis"), ("Y", "axis", "Y axis"), ("Z", "axis", "Z axis")] + .into_iter() + .chain([("XY", "plane", "XY"), ("XZ", "plane", "XZ"), ("YZ", "plane", "YZ")]); + for (key, icon, label) in items { + let visible = !app.ui.hidden_origin.iter().any(|h| h == key); + let sel = match icon { + "plane" => Some(Sel::Plane { name: key.into() }), + "axis" => Some(Sel::Axis { name: key.into() }), + _ => None, + }; + let selected = sel.as_ref().is_some_and(|x| app.session.selection.contains(x)); + ui.horizontal(|ui| { + if eye(ui, visible) { + if visible { + app.ui.hidden_origin.push(key.into()); + } else { + app.ui.hidden_origin.retain(|h| h != key); + } + } + if row(ui, icon, label, None, selected).clicked() + && let Some(x) = &sel + { + pick_from_browser(app, x.clone()); + } + }); + } + }); }); egui::CollapsingHeader::new(format!("Bodies ({})", st.bodies.len())).default_open(true).show(ui, |ui| { for b in &st.bodies { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1f16f407..7d87df5b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -6,6 +6,8 @@ //! - `engine.commands`: every command with tab, panel, params and enablement //! - `document.inspect {measure?}`: the design (parameters, timeline, bodies, sketches) //! - `ui.inspect`: UI state, viewport rect, camera, tool and dialog +//! - `ui.drag {x0, y0, x1, y1, button?, shift?, ctrl?, steps?}` (box selection, navigation); +//! `ui.selection` (selection, dialog inputs, hover) //! - `ui.set {...UiState fields}`; `ui.view {view: front|back|top|bottom|left|right|iso|home|fit, animate?: bool}` (snaps unless animate) //! - `ui.start {command}`: like clicking the toolbar button (starts tools/dialogs) //! - `ui.click {x, y, button?, shift?}`, `ui.move {x, y}`, `ui.scroll {x, y, delta}`: real @@ -147,12 +149,35 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> Some("middle") => egui::PointerButton::Middle, _ => egui::PointerButton::Primary, }; - let modifiers = egui::Modifiers { shift: b("shift"), ..Default::default() }; + let modifiers = egui::Modifiers { shift: b("shift"), ctrl: b("ctrl"), command: b("ctrl"), ..Default::default() }; app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers }); app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers }); } ok(Value::Null) } + "ui.drag" => { + let (Some(x0), Some(y0), Some(x1), Some(y1)) = (f("x0"), f("y0"), f("x1"), f("y1")) else { return err("missing x0/y0/x1/y1") }; + let button = match s("button") { + Some("right") => egui::PointerButton::Secondary, + Some("middle") => egui::PointerButton::Middle, + _ => egui::PointerButton::Primary, + }; + let modifiers = egui::Modifiers { shift: b("shift"), ctrl: b("ctrl"), command: b("ctrl"), ..Default::default() }; + let (a, z) = (egui::pos2(x0 as f32, y0 as f32), egui::pos2(x1 as f32, y1 as f32)); + app.synthetic.push(egui::Event::PointerMoved(a)); + app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); + let steps = f("steps").unwrap_or(10.0).clamp(1.0, 200.0) as usize; + for k in 1..=steps { + app.synthetic.push(egui::Event::PointerMoved(a + (z - a) * (k as f32 / steps as f32))); + } + app.synthetic.push(egui::Event::PointerButton { pos: z, button, pressed: false, modifiers }); + ok(Value::Null) + } + "ui.selection" => ok(json!({ + "selection": app.session.selection, + "dialog": app.dialog.as_ref().map(|d| d.inputs.iter().map(|i| json!({"label": i.label, "items": i.items})).collect::>()), + "hover": app.viewport.hover.as_ref().map(|h| format!("{h:?}")), + })), "ui.scroll" => { let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") }; app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x as f32, y as f32))); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c853e7ad..9bd62060 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,15 +1,17 @@ -//! Command dialogs (shown at the right of the viewport). A dialog collects inputs and picks, -//! then runs its command; it never changes the design itself. +//! Command dialogs (shown at the right of the viewport). A dialog collects values and selection +//! inputs (geometry picked in the viewport, never typed), then runs its command; it never +//! changes the design itself. Things selected before the command starts become its input. use std::hash::{Hash, Hasher}; use egui::{Color32, RichText, vec2}; use serde_json::{Value, json}; +use solvecraft_engine::Sel; use solvecraft_engine::Session; -use solvecraft_engine::doc::FeatureKind; use solvecraft_engine::geom::Vec3; use crate::SolveApp; +use crate::selection::{self, AXES, Accept, BODIES, EDGES, FACES, PLANAR_FACES, PLANES, PROFILES, SelInput}; use crate::theme::Tokens; use crate::viewport::Hit; @@ -17,132 +19,288 @@ const OPS: [&str; 4] = ["new", "join", "cut", "intersect"]; const OP_LABELS: [&str; 4] = ["New Body", "Join", "Cut", "Intersect"]; const DIRS: [&str; 3] = ["positive", "negative", "symmetric"]; const DIR_LABELS: [&str; 3] = ["One side", "Flip", "Symmetric"]; +const HOLE_TYPES: [&str; 3] = ["simple", "counterbore", "countersink"]; +const HOLE_LABELS: [&str; 3] = ["Simple", "Counterbore", "Countersink"]; #[derive(Clone, Debug)] -pub enum Dialog { - Sketch { pick_face: bool, picked: Option }, - Extrude { profiles: Vec<(u64, usize)>, distance: String, direction: usize, operation: usize }, - Revolve { profiles: Vec<(u64, usize)>, axis: String, angle: String, operation: usize }, - Fillet { edges: Vec, radius: String, chamfer: bool }, - Primitive { cmd: &'static str, fields: Vec<(&'static str, String)>, operation: usize }, - Combine { target: String, tools: Vec, operation: usize }, - Params { new_name: String, new_expr: String }, - EditParam { name: String, expr: String, error: Option }, +pub enum Kind { + /// Pick a plane or planar face; the sketch starts on it at once. + Sketch, + Extrude { + distance: String, + direction: usize, + operation: usize, + }, + Revolve { + angle: String, + operation: usize, + }, + Fillet { + radius: String, + chamfer: bool, + chain: bool, + }, + Shell { + thickness: String, + }, + Draft { + angle: String, + }, + Mirror, + Hole { + diameter: String, + depth: String, + kind: usize, + }, + Primitive { + cmd: &'static str, + fields: Vec<(&'static str, String)>, + operation: usize, + }, + Combine { + operation: usize, + keep_tools: bool, + }, + Params { + new_name: String, + new_expr: String, + }, + EditParam { + name: String, + expr: String, + }, } -/// Profiles of the sketch a feature would use (active, else the last), when there is just one. -fn default_profiles(s: &Session) -> Vec<(u64, usize)> { - let st = s.model.state(); - let sid = s.active_sketch.or_else(|| s.doc.features.iter().rev().find(|f| matches!(f.kind, FeatureKind::Sketch { .. })).map(|f| f.id)); - match sid.and_then(|id| st.sketch(id).map(|ss| (id, ss.profiles.len()))) { - Some((id, 1)) => vec![(id, 0)], - _ => Vec::new(), +#[derive(Clone, Debug)] +pub struct Dialog { + pub kind: Kind, + /// Selection inputs, in order; clicks in the viewport go to the active one. + pub inputs: Vec, + pub active: usize, + pub error: Option, +} + +/// Can a selection go into an input that accepts `a`? (Planar-ness is checked when picking.) +fn fits(a: Accept, s: &Sel) -> bool { + match s { + Sel::Profile { .. } => a & PROFILES != 0, + Sel::Edge { .. } => a & EDGES != 0, + Sel::Face { .. } => a & (FACES | PLANAR_FACES) != 0, + Sel::Body { .. } => a & BODIES != 0, + Sel::Plane { .. } => a & PLANES != 0, + Sel::Axis { .. } | Sel::SketchCurve { .. } => a & AXES != 0, + Sel::Vertex { .. } => a & selection::VERTICES != 0, + Sel::Feature { .. } | Sel::SketchPoint { .. } => false, } } impl Dialog { + fn new(kind: Kind, inputs: Vec) -> Dialog { + Dialog { kind, inputs, active: 0, error: None } + } + pub fn for_command(app: &SolveApp, id: &str) -> Option { let s = &app.session; - Some(match id { - "SketchCreate" => Dialog::Sketch { pick_face: false, picked: None }, - "Extrude" => Dialog::Extrude { - profiles: default_profiles(s), - distance: "10 mm".into(), - direction: 0, - operation: if s.model.state().bodies.is_empty() { 0 } else { 1 }, - }, - "Revolve" => Dialog::Revolve { profiles: default_profiles(s), axis: "y".into(), angle: "360 deg".into(), operation: 0 }, - "FusionFilletEdgesCommand" => Dialog::Fillet { edges: selected_edges(s), radius: "2 mm".into(), chamfer: false }, - "FusionChamferCommand" => Dialog::Fillet { edges: selected_edges(s), radius: "1 mm".into(), chamfer: true }, - "PrimitiveBox" => Dialog::Primitive { - cmd: "PrimitiveBox", - fields: vec![("length", "20".into()), ("width", "20".into()), ("height", "20".into())], - operation: 0, - }, - "PrimitiveCylinder" => { - Dialog::Primitive { cmd: "PrimitiveCylinder", fields: vec![("diameter", "20".into()), ("height", "20".into())], operation: 0 } + let has_bodies = !s.model.state().bodies.is_empty(); + let mut d = match id { + "SketchCreate" => Dialog::new(Kind::Sketch, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)]), + "Extrude" => Dialog::new( + Kind::Extrude { distance: "10 mm".into(), direction: 0, operation: usize::from(has_bodies) }, + vec![SelInput::new("Profiles", PROFILES, true)], + ), + "Revolve" => Dialog::new( + Kind::Revolve { angle: "360 deg".into(), operation: 0 }, + vec![SelInput::new("Profiles", PROFILES, true), SelInput::new("Axis", AXES, false)], + ), + "FusionFilletEdgesCommand" => { + Dialog::new(Kind::Fillet { radius: "2 mm".into(), chamfer: false, chain: true }, vec![SelInput::new("Edges", EDGES | FACES, true)]) } - "PrimitiveSphere" => Dialog::Primitive { cmd: "PrimitiveSphere", fields: vec![("diameter", "20".into())], operation: 0 }, - "PrimitiveTorus" => { - Dialog::Primitive { cmd: "PrimitiveTorus", fields: vec![("major", "20".into()), ("minor", "5".into())], operation: 0 } + "FusionChamferCommand" => { + Dialog::new(Kind::Fillet { radius: "1 mm".into(), chamfer: true, chain: true }, vec![SelInput::new("Edges", EDGES | FACES, true)]) } - "FusionCombineCommand" => { - let bodies: Vec = s.model.state().bodies.iter().map(|b| b.name.clone()).collect(); - Dialog::Combine { target: bodies.first().cloned().unwrap_or_default(), tools: bodies.iter().skip(1).cloned().collect(), operation: 1 } + "FusionShellBodyCommand" => Dialog::new(Kind::Shell { thickness: "2 mm".into() }, vec![SelInput::new("Faces", FACES, true)]), + "FusionDraftCommand" => Dialog::new( + Kind::Draft { angle: "5 deg".into() }, + vec![SelInput::new("Faces", FACES, true), SelInput::new("Neutral plane", PLANES | PLANAR_FACES, false)], + ), + "MirrorCommand" => { + Dialog::new(Kind::Mirror, vec![SelInput::new("Bodies", BODIES, true), SelInput::new("Mirror plane", PLANES | PLANAR_FACES, false)]) } - "ChangeParameterCommand" => Dialog::Params { new_name: String::new(), new_expr: String::new() }, + "FusionHoleCommand" => { + Dialog::new(Kind::Hole { diameter: "5 mm".into(), depth: String::new(), kind: 0 }, vec![SelInput::new("Position", FACES, true)]) + } + "PrimitiveBox" => Dialog::new( + Kind::Primitive { + cmd: "PrimitiveBox", + fields: vec![("length", "20".into()), ("width", "20".into()), ("height", "20".into())], + operation: 0, + }, + vec![], + ), + "PrimitiveCylinder" => Dialog::new( + Kind::Primitive { cmd: "PrimitiveCylinder", fields: vec![("diameter", "20".into()), ("height", "20".into())], operation: 0 }, + vec![], + ), + "PrimitiveSphere" => { + Dialog::new(Kind::Primitive { cmd: "PrimitiveSphere", fields: vec![("diameter", "20".into())], operation: 0 }, vec![]) + } + "PrimitiveTorus" => Dialog::new( + Kind::Primitive { cmd: "PrimitiveTorus", fields: vec![("major", "20".into()), ("minor", "5".into())], operation: 0 }, + vec![], + ), + "FusionCombineCommand" => Dialog::new( + Kind::Combine { operation: 1, keep_tools: false }, + vec![SelInput::new("Target body", BODIES, false), SelInput::new("Tool bodies", BODIES, true)], + ), + "ChangeParameterCommand" => Dialog::new(Kind::Params { new_name: String::new(), new_expr: String::new() }, vec![]), _ => return None, - }) + }; + // Pre-selection: what is selected now becomes the input (first input that takes it). + for sel in &s.selection { + if let Some(inp) = d.inputs.iter_mut().find(|i| fits(i.accept, sel) && (i.multi || i.items.is_empty())) { + inp.items.push(sel.clone()); + } + } + // With nothing pre-selected, a single profile in the sketch being used is taken. + if matches!(d.kind, Kind::Extrude { .. } | Kind::Revolve { .. }) + && let Some(inp) = d.inputs.first_mut() + && inp.items.is_empty() + { + inp.items = default_profiles(s); + } + d.advance(); + Some(d) } pub fn edit_param(s: &Session, name: &str) -> Dialog { - Dialog::EditParam { name: name.into(), expr: s.doc.param(name).map(|p| p.expr.clone()).unwrap_or_default(), error: None } + Dialog::new(Kind::EditParam { name: name.into(), expr: s.doc.param(name).map(|p| p.expr.clone()).unwrap_or_default() }, vec![]) + } + + /// Make the first single input still empty (after the filled ones) active. + fn advance(&mut self) { + if let Some(i) = self.inputs.iter().position(|i| i.items.is_empty()) { + self.active = i; + } else { + self.active = self.active.min(self.inputs.len().saturating_sub(1)); + } } pub fn wants_picks(&self) -> bool { - matches!(self, Dialog::Extrude { .. } | Dialog::Revolve { .. } | Dialog::Fillet { .. } | Dialog::Sketch { pick_face: true, .. }) + !self.inputs.is_empty() } - pub fn profiles(&self) -> Vec<(u64, usize)> { - match self { - Dialog::Extrude { profiles, .. } | Dialog::Revolve { profiles, .. } => profiles.clone(), - _ => Vec::new(), - } + pub fn active_input(&self) -> Option<&SelInput> { + self.inputs.get(self.active) } - pub fn edges(&self) -> Vec { - match self { - Dialog::Fillet { edges, .. } => edges.clone(), - _ => Vec::new(), - } + /// Everything the inputs hold (for highlighting). + pub fn items(&self) -> Vec { + self.inputs.iter().flat_map(|i| i.items.iter().cloned()).collect() } pub fn highlight_key(&self) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); - self.profiles().hash(&mut h); - for e in self.edges() { - for v in [e.x, e.y, e.z] { - v.to_bits().hash(&mut h); - } - } + serde_json::to_string(&self.items()).unwrap_or_default().hash(&mut h); + self.active.hash(&mut h); h.finish() } - pub fn on_pick(&mut self, hits: &[Hit]) { - match self { - Dialog::Extrude { profiles, .. } | Dialog::Revolve { profiles, .. } => { - if let Some(Hit::Profile { sketch, index }) = hits.iter().find(|h| matches!(h, Hit::Profile { .. })) { - let k = (*sketch, *index); - if let Some(i) = profiles.iter().position(|p| *p == k) { - profiles.remove(i); - } else { - // Profiles from one sketch only. - profiles.retain(|p| p.0 == k.0); - profiles.push(k); - } - } + /// The selection a hit would add to the active input. + pub fn candidate(&self, s: &Session, h: &Hit) -> Option { + self.active_input()?.accepts(h, |body, face| planar_face(s, body, face).is_some()) + } + + /// A pick in the viewport (already accepted by the active input). + pub fn pick(&mut self, s: &Session, sel: Sel) { + let chain = matches!(self.kind, Kind::Fillet { chain: true, .. }); + let mut picked = vec![sel.clone()]; + // Tangent chain: an edge brings the edges that continue it smoothly. + if chain + && let Sel::Edge { body, index, .. } = &sel + && let Some(b) = s.model.state().body(body) + { + let m = b.mesh(); + picked = 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(); + } + // Extrude and revolve take profiles of one sketch. + if let Sel::Profile { sketch, .. } = &sel + && let Some(inp) = self.inputs.get_mut(self.active) + { + inp.items.retain(|x| matches!(x, Sel::Profile { sketch: s2, .. } if s2 == sketch)); + } + let Some(inp) = self.inputs.get_mut(self.active) else { return }; + inp.toggle(picked); + if !inp.multi && !inp.items.is_empty() && self.active + 1 < self.inputs.len() { + self.advance(); + } + self.error = None; + } + + /// Box selection result: replace (or add to) the active input. + pub fn take_box(&mut self, sels: Vec, add: bool) { + let Some(inp) = self.inputs.get_mut(self.active) else { return }; + if !add { + inp.items.clear(); + } + for x in sels { + if fits(inp.accept, &x) && !inp.items.contains(&x) { + inp.items.push(x); } - Dialog::Fillet { edges, .. } => { - if let Some(Hit::Edge { mid, .. }) = hits.iter().find(|h| matches!(h, Hit::Edge { .. })) { - if let Some(i) = edges.iter().position(|e| e.dist(*mid) < 1e-6) { - edges.remove(i); - } else { - edges.push(*mid); - } - } - } - Dialog::Sketch { picked, .. } => { - if let Some(Hit::Face { point, .. }) = hits.iter().find(|h| matches!(h, Hit::Face { .. })) { - *picked = Some(*point); - } - } - _ => {} } } } -fn selected_edges(s: &Session) -> Vec { - s.selection.iter().filter_map(|x| if let solvecraft_engine::Sel::Edge { point, .. } = x { Some(*point) } else { None }).collect() +/// Profiles of the sketch a feature would use (active, else the last), when there is just one. +fn default_profiles(s: &Session) -> Vec { + use solvecraft_engine::doc::FeatureKind; + let st = s.model.state(); + let sid = s.active_sketch.or_else(|| s.doc.features.iter().rev().find(|f| matches!(f.kind, FeatureKind::Sketch { .. })).map(|f| f.id)); + match sid.and_then(|id| st.sketch(id).map(|ss| (id, ss.profiles.len()))) { + Some((id, 1)) => vec![Sel::Profile { sketch: id, index: 0 }], + _ => Vec::new(), + } +} + +/// The plane of a planar body face: (point on it, unit normal). +pub fn planar_face(s: &Session, body: &str, face: usize) -> Option<(Vec3, Vec3)> { + let st = s.model.state(); + let b = st.body(body)?; + let tol = (b.body.size() * 1e-3).max(1e-3); + let f = b.body.faces(tol).ok()?.into_iter().find(|f| f.index == face)?; + Some((f.centroid, f.plane_normal?)) +} + +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 pt(v: Vec3) -> Value { + json!([v.x, v.y, v.z]) +} + +/// A plane input as a command parameter: an origin/construction plane name or a face plane. +fn plane_value(s: &Session, sel: Option<&Sel>) -> Option { + match sel? { + Sel::Plane { name } => Some(json!(name)), + Sel::Face { body, index, point } => { + let (_, n) = planar_face(s, body, *index)?; + Some(json!({"origin": pt(*point), "normal": pt(n)})) + } + _ => None, + } } fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usize) { @@ -153,37 +311,92 @@ fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usize) { }); } -fn profile_param(p: &[(u64, usize)]) -> (Value, Value) { - let sketch = p.first().map(|x| json!(x.0)).unwrap_or(Value::Null); - (sketch, json!(p.iter().map(|x| x.1).collect::>())) +fn title(k: &Kind) -> &'static str { + match k { + Kind::Sketch => "CREATE SKETCH", + Kind::Extrude { .. } => "EXTRUDE", + Kind::Revolve { .. } => "REVOLVE", + Kind::Fillet { chamfer: false, .. } => "FILLET", + Kind::Fillet { chamfer: true, .. } => "CHAMFER", + Kind::Shell { .. } => "SHELL", + Kind::Draft { .. } => "DRAFT", + Kind::Mirror => "MIRROR", + Kind::Hole { .. } => "HOLE", + Kind::Primitive { cmd, .. } => match *cmd { + "PrimitiveBox" => "BOX", + "PrimitiveCylinder" => "CYLINDER", + "PrimitiveSphere" => "SPHERE", + _ => "TORUS", + }, + Kind::Combine { .. } => "COMBINE", + Kind::Params { .. } => "PARAMETERS", + Kind::EditParam { .. } => "EDIT DIMENSION", + } +} + +fn hint(inp: &SelInput) -> &'static str { + let a = inp.accept; + if a & PROFILES != 0 { + "click profiles in the view" + } else if a & EDGES != 0 { + "click edges or faces" + } else if a & PLANES != 0 { + "click a plane or planar face" + } else if a & AXES != 0 { + "click an axis or sketch line" + } else if a & BODIES != 0 { + "click bodies" + } else { + "click faces" + } +} + +/// Selection input rows: label, "N selected" (or a hint) and a clear button; clicking a row +/// makes it the active input. +fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) { + let t = Tokens::get(); + let mut clear: Option = None; + let mut activate: Option = None; + for (i, inp) in d.inputs.iter().enumerate() { + ui.label(inp.label); + ui.horizontal(|ui| { + let active = i == d.active; + let text = if inp.items.is_empty() { + RichText::new(hint(inp)).color(if active { t.warning } else { t.text_dim }) + } else { + RichText::new(format!("{} selected", inp.items.len())).color(t.text) + }; + let b = egui::Button::new(text).fill(if active { t.accent_soft } else { Color32::TRANSPARENT }).min_size(vec2(170.0, 22.0)); + if ui.add(b).on_hover_text("Click to make this the input that viewport picks go to").clicked() { + activate = Some(i); + } + if !inp.items.is_empty() && ui.small_button("×").on_hover_text("Clear the selection").clicked() { + clear = Some(i); + } + }); + ui.end_row(); + } + if let Some(i) = activate { + d.active = i; + } + if let Some(i) = clear + && let Some(inp) = d.inputs.get_mut(i) + { + inp.items.clear(); + d.active = i; + } } /// Show the active dialog (if any). pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let Some(mut d) = app.dialog.take() else { return }; let t = Tokens::get(); - let title = match &d { - Dialog::Sketch { .. } => "CREATE SKETCH", - Dialog::Extrude { .. } => "EXTRUDE", - Dialog::Revolve { .. } => "REVOLVE", - Dialog::Fillet { chamfer: false, .. } => "FILLET", - Dialog::Fillet { chamfer: true, .. } => "CHAMFER", - Dialog::Primitive { cmd, .. } => match *cmd { - "PrimitiveBox" => "BOX", - "PrimitiveCylinder" => "CYLINDER", - "PrimitiveSphere" => "SPHERE", - _ => "TORUS", - }, - Dialog::Combine { .. } => "COMBINE", - Dialog::Params { .. } => "PARAMETERS", - Dialog::EditParam { .. } => "EDIT DIMENSION", - }; let pos = app.viewport.rect.map(|r| egui::pos2(r.right() - 330.0, r.top() + 150.0)).unwrap_or(egui::pos2(900.0, 200.0)); let mut keep = true; let mut ok = false; let mut cancel = false; - let wide = matches!(d, Dialog::Params { .. }); - egui::Window::new(RichText::new(title).strong().size(13.0)) + let wide = matches!(d.kind, Kind::Params { .. }); + egui::Window::new(RichText::new(title(&d.kind)).strong().size(13.0)) .id(egui::Id::new("sc_dialog")) .default_pos(if wide { egui::pos2(pos.x - 260.0, pos.y - 60.0) } else { pos }) .resizable(false) @@ -191,264 +404,333 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { .open(&mut keep) .show(ctx, |ui| { ui.set_min_width(if wide { 520.0 } else { 280.0 }); - egui::Grid::new("sc_dialog_grid").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| match &mut d { - Dialog::Sketch { pick_face, .. } => { - ui.label("Plane"); - ui.horizontal(|ui| { - for p in ["XY", "XZ", "YZ"] { - if ui.button(p).clicked() { - if app.run("SketchCreate", json!({"plane": p})).is_ok() { - look_at_sketch(app); - } - cancel = true; - } - } - }); - ui.end_row(); - ui.label("Face"); - ui.toggle_value(pick_face, "Pick a planar face"); - ui.end_row(); - } - Dialog::Extrude { profiles, distance, direction, operation } => { - ui.label("Profiles"); - ui.label(if profiles.is_empty() { - RichText::new("click profiles in the view").color(t.warning) - } else { - RichText::new(format!("{} selected", profiles.len())) - }); - ui.end_row(); - ui.label("Direction"); - combo(ui, "ex_dir", &DIR_LABELS, direction); - ui.end_row(); - ui.label("Distance"); - ui.text_edit_singleline(distance); - ui.end_row(); - ui.label("Operation"); - combo(ui, "ex_op", &OP_LABELS, operation); - ui.end_row(); - } - Dialog::Revolve { profiles, axis, angle, operation } => { - ui.label("Profiles"); - ui.label(if profiles.is_empty() { - RichText::new("click profiles in the view").color(t.warning) - } else { - RichText::new(format!("{} selected", profiles.len())) - }); - ui.end_row(); - ui.label("Axis"); - ui.text_edit_singleline(axis).on_hover_text("sketch line id, x / y (sketch axes) or X / Y / Z"); - ui.end_row(); - ui.label("Angle"); - ui.text_edit_singleline(angle); - ui.end_row(); - ui.label("Operation"); - combo(ui, "rv_op", &OP_LABELS, operation); - ui.end_row(); - } - Dialog::Fillet { edges, radius, chamfer } => { - ui.label("Edges"); - ui.label(if edges.is_empty() { - RichText::new("click edges in the view").color(t.warning) - } else { - RichText::new(format!("{} selected", edges.len())) - }); - ui.end_row(); - ui.label(if *chamfer { "Distance" } else { "Radius" }); - ui.text_edit_singleline(radius); - ui.end_row(); - } - Dialog::Primitive { fields, operation, .. } => { - for (k, v) in fields.iter_mut() { - ui.label(*k); - ui.text_edit_singleline(v); + egui::Grid::new("sc_dialog_grid").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { + input_rows(&mut d, ui); + match &mut d.kind { + Kind::Sketch => {} + Kind::Extrude { distance, direction, operation } => { + ui.label("Direction"); + combo(ui, "ex_dir", &DIR_LABELS, direction); + ui.end_row(); + ui.label("Distance"); + ui.text_edit_singleline(distance); + ui.end_row(); + ui.label("Operation"); + combo(ui, "ex_op", &OP_LABELS, operation); ui.end_row(); } - ui.label("Operation"); - combo(ui, "pr_op", &OP_LABELS, operation); - ui.end_row(); - } - Dialog::Combine { target, tools, operation } => { - let bodies: Vec = app.session.model.state().bodies.iter().map(|b| b.name.clone()).collect(); - ui.label("Target body"); - egui::ComboBox::from_id_salt("cb_target").selected_text(target.clone()).show_ui(ui, |ui| { - for b in &bodies { - ui.selectable_value(target, b.clone(), b); - } - }); - ui.end_row(); - ui.label("Tool bodies"); - ui.vertical(|ui| { - for b in bodies.iter().filter(|b| *b != target) { - let mut on = tools.contains(b); - if ui.checkbox(&mut on, b).changed() { - if on { - tools.push(b.clone()); - } else { - tools.retain(|x| x != b); - } - } - } - }); - ui.end_row(); - ui.label("Operation"); - let mut op = operation.saturating_sub(1); - combo(ui, "cb_op", &OP_LABELS[1..], &mut op); - *operation = op + 1; - ui.end_row(); - } - Dialog::Params { new_name, new_expr } => { - ui.label(RichText::new("Name").strong()); - ui.label(RichText::new("Expression · value").strong()); - ui.end_row(); - let (vals, errs) = app.session.doc.param_values(); - let params = app.session.doc.params.clone(); - for p in params { - ui.label(if p.model { RichText::new(&p.name).color(t.text_dim) } else { RichText::new(&p.name) }); - ui.horizontal(|ui| { - let id = egui::Id::new(("pexpr", &p.name)); - let mut e = ui.data(|dd| dd.get_temp::(id)).unwrap_or_else(|| p.expr.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut e).desired_width(160.0)); - if r.changed() { - ui.data_mut(|dd| dd.insert_temp(id, e.clone())); - } - if r.lost_focus() && e != p.expr { - let _ = app.run("ChangeParameterCommand", json!({"name": p.name, "expression": e})); - ui.data_mut(|dd| dd.remove::(id)); - } - let v = vals - .get(&p.name) - .map(|v| if p.unit == "deg" { format!("{:.3}°", v.v.to_degrees()) } else { format!("{:.4} {}", v.v, p.unit) }); - match (v, errs.get(&p.name)) { - (_, Some(e)) => ui.label(RichText::new(e).color(t.error)), - (Some(v), None) => ui.label(RichText::new(v).color(t.text_dim)), - _ => ui.label(""), - }; - }); + Kind::Revolve { angle, operation } => { + ui.label("Angle"); + ui.text_edit_singleline(angle); + ui.end_row(); + ui.label("Operation"); + combo(ui, "rv_op", &OP_LABELS, operation); ui.end_row(); } - ui.add(egui::TextEdit::singleline(new_name).hint_text("new name").desired_width(110.0)); - ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(new_expr).hint_text("expression, e.g. 25 mm").desired_width(160.0)); - if ui.button("Add").clicked() - && !new_name.is_empty() - && app.run("ChangeParameterCommand", json!({"name": new_name, "expression": new_expr})).is_ok() - { - new_name.clear(); - new_expr.clear(); - } - }); - ui.end_row(); - } - Dialog::EditParam { name, expr, error } => { - ui.label(name.as_str()); - let r = ui.text_edit_singleline(expr); - if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - ok = true; - } - r.request_focus(); - ui.end_row(); - if let Some(e) = error { - ui.label(""); - ui.label(RichText::new(e.as_str()).color(t.error)); + Kind::Fillet { radius, chamfer, chain } => { + ui.label(if *chamfer { "Distance" } else { "Radius" }); + ui.text_edit_singleline(radius); + ui.end_row(); + ui.label("Tangent chain"); + ui.checkbox(chain, ""); ui.end_row(); } + Kind::Shell { thickness } => { + ui.label("Inside thickness"); + ui.text_edit_singleline(thickness); + ui.end_row(); + } + Kind::Draft { angle } => { + ui.label("Angle"); + ui.text_edit_singleline(angle); + ui.end_row(); + } + Kind::Mirror => {} + Kind::Hole { diameter, depth, kind } => { + ui.label("Type"); + combo(ui, "hole_kind", &HOLE_LABELS, kind); + ui.end_row(); + ui.label("Diameter"); + ui.text_edit_singleline(diameter); + ui.end_row(); + ui.label("Depth"); + ui.add(egui::TextEdit::singleline(depth).hint_text("through all")); + ui.end_row(); + } + Kind::Primitive { fields, operation, .. } => { + for (k, v) in fields.iter_mut() { + ui.label(*k); + ui.text_edit_singleline(v); + ui.end_row(); + } + ui.label("Operation"); + combo(ui, "pr_op", &OP_LABELS, operation); + ui.end_row(); + } + Kind::Combine { operation, keep_tools } => { + ui.label("Operation"); + let mut op = operation.saturating_sub(1); + combo(ui, "cb_op", &OP_LABELS[1..], &mut op); + *operation = op + 1; + ui.end_row(); + ui.label("Keep tools"); + ui.checkbox(keep_tools, ""); + ui.end_row(); + } + Kind::Params { new_name, new_expr } => params_table(app, ui, new_name, new_expr), + Kind::EditParam { name, expr } => { + ui.label(name.as_str()); + let r = ui.text_edit_singleline(expr); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + ok = true; + } + r.request_focus(); + ui.end_row(); + } + } + if let Some(e) = &d.error { + ui.label(""); + ui.label(RichText::new(e.as_str()).color(t.error)); + ui.end_row(); } }); ui.add_space(6.0); ui.horizontal(|ui| { - if !matches!(d, Dialog::Sketch { .. } | Dialog::Params { .. }) + if !matches!(d.kind, Kind::Sketch | Kind::Params { .. }) && ui.add(egui::Button::new(RichText::new("OK").color(Color32::WHITE)).fill(t.accent).min_size(vec2(70.0, 24.0))).clicked() { ok = true; } - if ui.add(egui::Button::new(if matches!(d, Dialog::Params { .. }) { "Close" } else { "Cancel" }).min_size(vec2(70.0, 24.0))).clicked() - { + let close = if matches!(d.kind, Kind::Params { .. }) { "Close" } else { "Cancel" }; + if ui.add(egui::Button::new(close).min_size(vec2(70.0, 24.0))).clicked() { cancel = true; } }); }); - if ok { - let r = run_dialog(app, &d); - match r { - Ok(()) => cancel = true, - Err(e) => { - if let Dialog::EditParam { error, .. } = &mut d { - *error = Some(e); - } - } - } - } - // A face picked in the view while the sketch dialog waits for one. - if let Dialog::Sketch { picked: Some(point), .. } = d { - if app.run("SketchCreate", json!({"plane": {"face": [point.x, point.y, point.z]}})).is_ok() { - look_at_sketch(app); - } + // A plane picked for a new sketch starts it right away. + if matches!(d.kind, Kind::Sketch) + && let Some(sel) = d.inputs.first().and_then(|i| i.items.first()).cloned() + { + start_sketch(app, &sel); cancel = true; } + if ok { + match run_dialog(app, &d) { + Ok(()) => cancel = true, + Err(e) => d.error = Some(e), + } + } if keep && !cancel { app.dialog = Some(d); } } -fn look_at_sketch(app: &mut SolveApp) { - let st = app.session.model.state(); - if let Some(ss) = app.session.active_sketch.and_then(|id| st.sketch(id)) { - let n = ss.plane.normal(); - let yaw = (-n.x).atan2(-n.y); - let pitch = n.z.clamp(-1.0, 1.0).asin(); - app.cam.yaw = if n.z.abs() > 0.999 { 0.0 } else { yaw }; - app.cam.pitch = pitch.clamp(-std::f64::consts::FRAC_PI_2 + 1e-4, std::f64::consts::FRAC_PI_2 - 1e-4); - app.cam.target = ss.plane.origin; +fn params_table(app: &mut SolveApp, ui: &mut egui::Ui, new_name: &mut String, new_expr: &mut String) { + let t = Tokens::get(); + ui.label(RichText::new("Name").strong()); + ui.label(RichText::new("Expression · value").strong()); + ui.end_row(); + let (vals, errs) = app.session.doc.param_values(); + let params = app.session.doc.params.clone(); + for p in params { + ui.label(if p.model { RichText::new(&p.name).color(t.text_dim) } else { RichText::new(&p.name) }); + ui.horizontal(|ui| { + let id = egui::Id::new(("pexpr", &p.name)); + let mut e = ui.data(|dd| dd.get_temp::(id)).unwrap_or_else(|| p.expr.clone()); + let r = ui.add(egui::TextEdit::singleline(&mut e).desired_width(160.0)); + if r.changed() { + ui.data_mut(|dd| dd.insert_temp(id, e.clone())); + } + if r.lost_focus() && e != p.expr { + let _ = app.run("ChangeParameterCommand", json!({"name": p.name, "expression": e})); + ui.data_mut(|dd| dd.remove::(id)); + } + let v = vals.get(&p.name).map(|v| if p.unit == "deg" { format!("{:.3}°", v.v.to_degrees()) } else { format!("{:.4} {}", v.v, p.unit) }); + match (v, errs.get(&p.name)) { + (_, Some(e)) => ui.label(RichText::new(e).color(t.error)), + (Some(v), None) => ui.label(RichText::new(v).color(t.text_dim)), + _ => ui.label(""), + }; + }); + ui.end_row(); + } + ui.add(egui::TextEdit::singleline(new_name).hint_text("new name").desired_width(110.0)); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(new_expr).hint_text("expression, e.g. 25 mm").desired_width(160.0)); + if ui.button("Add").clicked() + && !new_name.is_empty() + && app.run("ChangeParameterCommand", json!({"name": new_name, "expression": new_expr})).is_ok() + { + new_name.clear(); + new_expr.clear(); + } + }); + ui.end_row(); +} + +/// Start a sketch on a picked plane or planar face and turn the view to face it. +pub fn start_sketch(app: &mut SolveApp, sel: &Sel) { + let plane = match sel { + Sel::Plane { name } => json!(name), + Sel::Face { point, .. } => json!({"face": pt(*point)}), + _ => return, + }; + let before = app.cam; + if app.run("SketchCreate", json!({ "plane": plane })).is_ok() { + app.pre_sketch_cam = Some(before); + look_at_sketch(app); } } +/// Animate the camera to look straight at the active sketch plane. +pub fn look_at_sketch(app: &mut SolveApp) { + let st = app.session.model.state(); + if let Some(ss) = app.session.active_sketch.and_then(|id| st.sketch(id)) { + let mut to = app.cam.looking_from(ss.plane.normal()); + // Keep the sketch's own x axis to the right when looking straight down or up. + if ss.plane.normal().z.abs() > 0.999 { + let x = ss.plane.x; + to.yaw = if ss.plane.normal().z > 0.0 { (-x.y).atan2(x.x) } else { x.y.atan2(x.x) }; + } + to.target = ss.plane.origin; + app.animate_to(to); + } +} + +fn sels(d: &Dialog, i: usize) -> &[Sel] { + d.inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[]) +} + fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> { - match d { - Dialog::Extrude { profiles, distance, direction, operation } => { - if profiles.is_empty() { - return Err("select profiles first".into()); - } - let (sketch, idx) = profile_param(profiles); - app.run( + let s = &app.session; + let need = |i: usize, what: &str| -> Result<(), String> { if sels(d, i).is_empty() { Err(format!("select {what} first")) } else { Ok(()) } }; + let profiles = || -> (Value, Value) { + let ps: Vec<(u64, usize)> = + sels(d, 0).iter().filter_map(|x| if let Sel::Profile { sketch, index } = x { Some((*sketch, *index)) } else { None }).collect(); + (ps.first().map(|x| json!(x.0)).unwrap_or(Value::Null), json!(ps.iter().map(|x| x.1).collect::>())) + }; + let body_names = + |i: usize| -> Vec { sels(d, i).iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect() }; + let face_points = |i: usize| -> Vec { + sels(d, i).iter().filter_map(|x| if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None }).collect() + }; + let (cmd, params): (&str, Value) = match &d.kind { + Kind::Extrude { distance, direction, operation } => { + need(0, "profiles")?; + let (sketch, idx) = profiles(); + ( "Extrude", json!({"sketch": sketch, "profiles": idx, "distance": distance, "direction": DIRS.get(*direction).copied().unwrap_or("positive"), "operation": OPS.get(*operation).copied().unwrap_or("new")}), ) - .map(drop) } - Dialog::Revolve { profiles, axis, angle, operation } => { - if profiles.is_empty() { - return Err("select profiles first".into()); - } - let (sketch, idx) = profile_param(profiles); - app.run( + Kind::Revolve { angle, operation } => { + need(0, "profiles")?; + need(1, "an axis")?; + let axis = match sels(d, 1).first() { + Some(Sel::Axis { name }) => json!(name), + Some(Sel::SketchCurve { id }) => json!(id), + _ => return Err("select an axis".into()), + }; + let (sketch, idx) = profiles(); + ( "Revolve", json!({"sketch": sketch, "profiles": idx, "axis": axis, "angle": angle, "operation": OPS.get(*operation).copied().unwrap_or("new")}), ) - .map(drop) } - Dialog::Fillet { edges, radius, chamfer } => { - if edges.is_empty() { - return Err("select edges first".into()); + Kind::Fillet { radius, chamfer, .. } => { + need(0, "edges")?; + // Faces stand for all their edges. + let st = s.model.state(); + let mut pts: Vec = Vec::new(); + for x in sels(d, 0) { + match x { + Sel::Edge { point, .. } => pts.push(*point), + Sel::Face { body, index, .. } => { + if let Some(b) = st.body(body) { + let m = b.mesh(); + for e in m.face_edges(u32::try_from(*index).unwrap_or(u32::MAX)) { + if !m.seams.get(e).copied().unwrap_or(false) + && let Some(p) = m.edges.get(e) + { + pts.push(polyline_mid(p)); + } + } + } + } + _ => {} + } } - let pts: Vec<[f64; 3]> = edges.iter().map(|e| [e.x, e.y, e.z]).collect(); + let mut uniq: Vec = Vec::new(); + for p in pts { + if !uniq.iter().any(|q| q.dist(p) < 1e-9) { + uniq.push(p); + } + } + let edges: Vec = uniq.into_iter().map(pt).collect(); if *chamfer { - app.run("FusionChamferCommand", json!({"edges": pts, "distance": radius})).map(drop) + ("FusionChamferCommand", json!({"edges": edges, "distance": radius})) } else { - app.run("FusionFilletEdgesCommand", json!({"edges": pts, "radius": radius})).map(drop) + ("FusionFilletEdgesCommand", json!({"edges": edges, "radius": radius})) } } - Dialog::Primitive { cmd, fields, operation } => { + Kind::Shell { thickness } => { + need(0, "faces to remove")?; + ("FusionShellBodyCommand", json!({"faces": face_points(0), "thickness": thickness})) + } + Kind::Draft { angle } => { + need(0, "faces")?; + need(1, "the neutral plane")?; + let neutral = plane_value(s, sels(d, 1).first()).ok_or("the neutral plane must be a plane or a planar face")?; + ("FusionDraftCommand", json!({"faces": face_points(0), "angle": angle, "neutral": neutral})) + } + Kind::Mirror => { + need(0, "bodies")?; + need(1, "the mirror plane")?; + let st = s.model.state(); + let mut features: Vec = Vec::new(); + for n in body_names(0) { + if let Some(f) = st.body(&n).and_then(|b| s.doc.feature(b.feature)).map(|f| f.name.clone()) + && !features.contains(&f) + { + features.push(f); + } + } + let plane = plane_value(s, sels(d, 1).first()).ok_or("the mirror plane must be a plane or a planar face")?; + ("MirrorCommand", json!({"features": features, "plane": plane})) + } + Kind::Hole { diameter, depth, kind } => { + need(0, "a face position")?; + let ty = HOLE_TYPES.get(*kind).copied().unwrap_or("simple"); + // One hole per picked position. + for p in face_points(0) { + let mut params = json!({"position": p, "diameter": diameter, "type": ty}); + if !depth.trim().is_empty() { + params["depth"] = json!(depth); + } + app.run("FusionHoleCommand", params)?; + } + return Ok(()); + } + Kind::Primitive { cmd, fields, operation } => { let mut p = serde_json::Map::new(); for (k, v) in fields { p.insert((*k).into(), json!(v)); } p.insert("operation".into(), json!(OPS.get(*operation).copied().unwrap_or("new"))); - app.run(cmd, Value::Object(p)).map(drop) + (cmd, Value::Object(p)) } - Dialog::Combine { target, tools, operation } => app - .run("FusionCombineCommand", json!({"target": target, "tools": tools, "operation": OPS.get(*operation).copied().unwrap_or("join")})) - .map(drop), - Dialog::EditParam { name, expr, .. } => app.run("ChangeParameterCommand", json!({"name": name, "expression": expr})).map(drop), - Dialog::Sketch { .. } | Dialog::Params { .. } => Ok(()), - } + Kind::Combine { operation, keep_tools } => { + need(0, "the target body")?; + need(1, "tool bodies")?; + let target = body_names(0).into_iter().next().unwrap_or_default(); + let tools: Vec = body_names(1).into_iter().filter(|n| *n != target).collect(); + ( + "FusionCombineCommand", + json!({"target": target, "tools": tools, "operation": OPS.get(*operation).copied().unwrap_or("join"), "keep_tools": keep_tools}), + ) + } + Kind::EditParam { name, expr } => ("ChangeParameterCommand", json!({"name": name, "expression": expr})), + Kind::Sketch | Kind::Params { .. } => return Ok(()), + }; + app.run(cmd, params).map(drop) } diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index a201d2e5..bf2b0647 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -13,10 +13,15 @@ pub const TRI_SIZE: usize = 28; pub const LINE_SIZE: usize = 32; const UNIFORM_SIZE: u64 = 112; -/// CPU-side geometry waiting to be uploaded. +/// CPU-side geometry waiting to be uploaded. The model scene changes with the design; the +/// highlight scene (hover, selection, the origin widget) changes often and is small. #[derive(Default, Clone)] pub struct GpuScene { + /// Opaque triangles. A zero normal draws the colour flat (unlit). pub tris: Vec, + /// Translucent triangles (alpha from the colour), drawn after the opaque ones without + /// writing depth. + pub trans: Vec, /// Depth-tested lines. pub lines: Vec, /// Lines drawn over everything (active sketch, highlights). @@ -30,6 +35,12 @@ impl GpuScene { } self.tris.extend_from_slice(&c); } + pub fn trans_tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) { + for v in p.iter().chain(&n) { + self.trans.extend_from_slice(&v.to_le_bytes()); + } + self.trans.extend_from_slice(&c); + } pub fn line(&mut self, a: [f32; 3], b: [f32; 3], c: [u8; 4], width: f32, on_top: bool) { let out = if on_top { &mut self.overlay } else { &mut self.lines }; for v in a.iter().chain(&b) { @@ -46,6 +57,9 @@ pub type SceneSlot = Arc>>; pub struct ViewportCallback { pub key: u64, pub slot: SceneSlot, + /// The highlight scene and its key. + pub hl_key: u64, + pub hl_slot: SceneSlot, /// Column-major view-projection. pub view_proj: [[f32; 4]; 4], /// Direction toward the eye (for lighting). @@ -59,17 +73,42 @@ struct Batch { count: u32, } +#[derive(Default)] +struct Batches { + key: Option, + tris: Option, + trans: Option, + lines: Option, + overlays: Option, +} + +impl Batches { + fn take(&mut self, device: &wgpu::Device, key: u64, slot: &SceneSlot) { + if self.key == Some(key) { + return; + } + if let Some(sc) = slot.lock().ok().and_then(|mut s| s.take()) { + self.tris = upload(device, "sc_tris", &sc.tris, TRI_SIZE); + self.trans = upload(device, "sc_trans", &sc.trans, TRI_SIZE); + self.lines = upload(device, "sc_lines", &sc.lines, LINE_SIZE); + self.overlays = upload(device, "sc_overlay", &sc.overlay, LINE_SIZE); + self.key = Some(key); + } + } +} + struct Resources { tri: wgpu::RenderPipeline, + /// Highlight triangles over the model (depth test ≤, no depth write). + tri_hl: wgpu::RenderPipeline, + trans: wgpu::RenderPipeline, line: wgpu::RenderPipeline, overlay: wgpu::RenderPipeline, uniform: wgpu::Buffer, bind: wgpu::BindGroup, linear_out: bool, - key: Option, - tris: Option, - lines: Option, - overlays: Option, + model: Batches, + highlight: Batches, } const SHADER: &str = r#" @@ -108,6 +147,10 @@ fn vs_tri(@location(0) p: vec3, @location(1) n: vec3, @location(2) c: @fragment fn fs_tri(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4 { + // A zero normal marks flat colour (selection fills, translucent planes). + if (length(i.n) < 0.5) { + return out_color(i.c); + } let v = normalize(u.back.xyz); var n = normalize(i.n); if (dot(n, v) < 0.0) { n = -n; } @@ -117,7 +160,7 @@ fn fs_tri(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4 let h = normalize(key + v); let spec = pow(max(dot(n, h), 0.0), 40.0) * 0.25; let k = 0.42 + 0.38 * diff + 0.25 * fill; - return out_color(vec4(min(i.c.rgb * k + vec3(spec), vec3(1.0)), 1.0)); + return out_color(vec4(min(i.c.rgb * k + vec3(spec), vec3(1.0)), i.c.a)); } struct LOut { @@ -202,7 +245,7 @@ impl Resources { layout: &bgl, entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }], }); - let depth = |compare: wgpu::CompareFunction, write: bool| { + let depth = |compare: wgpu::CompareFunction, write: bool| -> Option { t.depth.map(|format| wgpu::DepthStencilState { format, depth_write_enabled: Some(write), @@ -237,15 +280,15 @@ impl Resources { let alpha = Some(wgpu::BlendState::ALPHA_BLENDING); Resources { tri: pipeline("sc_tris", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Less, true), None), + tri_hl: pipeline("sc_tris_hl", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), + trans: pipeline("sc_trans", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), line: pipeline("sc_lines", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), overlay: pipeline("sc_overlay", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::Always, false), alpha), uniform, bind, linear_out: t.format.is_srgb(), - key: None, - tris: None, - lines: None, - overlays: None, + model: Batches::default(), + highlight: Batches::default(), } } } @@ -282,14 +325,8 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { resources: &mut egui_wgpu::CallbackResources, ) -> Vec { let Some(res) = resources.get_mut::() else { return Vec::new() }; - if res.key != Some(self.key) - && let Some(sc) = self.slot.lock().ok().and_then(|mut s| s.take()) - { - res.tris = upload(device, "sc_tris", &sc.tris, TRI_SIZE); - res.lines = upload(device, "sc_lines", &sc.lines, LINE_SIZE); - res.overlays = upload(device, "sc_overlay", &sc.overlay, LINE_SIZE); - res.key = Some(self.key); - } + res.model.take(device, self.key, &self.slot); + res.highlight.take(device, self.hl_key, &self.hl_slot); queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, self.size_px[0], self.size_px[1], res.linear_out)); Vec::new() } @@ -302,21 +339,29 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { } pass.set_viewport(vp.left_px as f32, vp.top_px as f32, vp.width_px as f32, vp.height_px as f32, 0.0, 1.0); pass.set_bind_group(0, &res.bind, &[]); - if let Some(b) = &res.tris { - pass.set_pipeline(&res.tri); - pass.set_vertex_buffer(0, b.buffer.slice(..)); - pass.draw(0..b.count, 0..1); - } - if let Some(b) = &res.lines { - pass.set_pipeline(&res.line); - pass.set_vertex_buffer(0, b.buffer.slice(..)); - pass.draw(0..6, 0..b.count); - } - if let Some(b) = &res.overlays { - pass.set_pipeline(&res.overlay); - pass.set_vertex_buffer(0, b.buffer.slice(..)); - pass.draw(0..6, 0..b.count); - } + let tris = |pass: &mut wgpu::RenderPass<'static>, p: &wgpu::RenderPipeline, b: &Option| { + if let Some(b) = b { + pass.set_pipeline(p); + pass.set_vertex_buffer(0, b.buffer.slice(..)); + pass.draw(0..b.count, 0..1); + } + }; + let lines = |pass: &mut wgpu::RenderPass<'static>, p: &wgpu::RenderPipeline, b: &Option| { + if let Some(b) = b { + pass.set_pipeline(p); + pass.set_vertex_buffer(0, b.buffer.slice(..)); + pass.draw(0..6, 0..b.count); + } + }; + let (m, h) = (&res.model, &res.highlight); + tris(pass, &res.tri, &m.tris); + tris(pass, &res.tri_hl, &h.tris); + lines(pass, &res.line, &m.lines); + lines(pass, &res.line, &h.lines); + tris(pass, &res.trans, &m.trans); + tris(pass, &res.trans, &h.trans); + lines(pass, &res.overlay, &m.overlays); + lines(pass, &res.overlay, &h.overlays); } } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index f2198dbb..ccd26066 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -402,6 +402,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce pen.arrow((6.0, 18.0), (15.0, 9.0), Color32::from_rgb(60, 160, 60)); } "plane" => pen.poly(&[(3.0, 15.0), (12.0, 8.0), (21.0, 11.0), (12.0, 18.0)], Color32::from_rgb(244, 214, 150), ink), + "axis" => { + pen.line(&[(4.0, 20.0), (20.0, 4.0)], ink); + pen.circle(4.0, 20.0, 2.0, ink, ink); + } "settings" => { pen.circle(12.0, 12.0, 6.5, pen.fill, ink); pen.circle(12.0, 12.0, 2.5, Color32::WHITE, ink); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index aeec3547..e892fbcc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -14,6 +14,7 @@ pub mod dialogs; pub mod gpu; pub mod icons; pub mod palette; +pub mod selection; pub mod theme; pub mod timeline; pub mod toolbar; @@ -41,6 +42,8 @@ pub struct UiState { pub show_origin: bool, pub perspective: bool, pub hidden_bodies: Vec, + /// Origin items (O, X, Y, Z, XY, XZ, YZ) and construction planes hidden one by one. + pub hidden_origin: Vec, pub show_sketches: bool, pub palette_open: bool, } @@ -55,6 +58,7 @@ impl Default for UiState { show_origin: true, perspective: false, hidden_bodies: Vec::new(), + hidden_origin: Vec::new(), show_sketches: true, palette_open: false, } @@ -76,6 +80,8 @@ pub struct SolveApp { pub cam_anim: Option, /// Seconds since start, as of this frame. pub now: f64, + /// The view before the current sketch started (Finish Sketch returns to it). + pub pre_sketch_cam: Option, pub services: Services, pub viewport: viewport::ViewportState, pub tool: Option, @@ -101,6 +107,7 @@ impl SolveApp { cam: Camera::default(), cam_anim: None, now: 0.0, + pre_sketch_cam: None, services, viewport: viewport::ViewportState::default(), tool: None, @@ -159,18 +166,45 @@ impl SolveApp { /// that have them, otherwise run it with defaults. pub fn start(&mut self, id: &str) { self.tool = None; + if id == "SketchStop" { + let r = self.run(id, json!({})); + if r.is_ok() + && let Some(c) = self.pre_sketch_cam.take() + { + self.animate_to(c); + } + return; + } if let Some(t) = tools::Tool::for_command(id) { self.tool = Some(t); self.set_status(tools::hint(id), false); return; } if let Some(d) = dialogs::Dialog::for_command(self, id) { + // The pre-selection now belongs to the dialog's inputs. + if d.wants_picks() && !self.session.selection.is_empty() { + let _ = self.session.execute("select.clear", &json!({})); + } self.dialog = Some(d); return; } let _ = self.run(id, json!({})); } + /// Everything shown as selected: the selection plus the open dialog's inputs. + pub fn highlighted(&self) -> Vec { + let mut v = self.session.selection.clone(); + if let Some(d) = &self.dialog { + v.extend(d.items()); + } + v + } + + /// Is the origin widget shown (always while picking a sketch plane)? + pub fn origin_visible(&self) -> bool { + self.ui.show_origin || self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::Sketch)) + } + pub fn set_status(&mut self, s: impl Into, error: bool) { self.status = Some((s.into(), now_ms(), error)); } @@ -279,6 +313,14 @@ impl SolveApp { if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { raw.events.push(egui::Event::PointerMoved(*p)); } + // egui keeps the modifier keys as input state, changed by their own event. + match self.synthetic.first() { + Some(egui::Event::PointerButton { modifiers, .. } | egui::Event::Key { modifiers, .. }) => { + raw.events.push(egui::Event::ModifiersChanged(*modifiers)) + } + Some(egui::Event::PointerMoved(_)) => raw.events.push(egui::Event::ModifiersChanged(egui::Modifiers::default())), + _ => {} + } let n = n.min(self.synthetic.len()); raw.events.extend(self.synthetic.drain(..n)); } diff --git a/crates/ui-egui/src/selection.rs b/crates/ui-egui/src/selection.rs new file mode 100644 index 00000000..c81df023 --- /dev/null +++ b/crates/ui-egui/src/selection.rs @@ -0,0 +1,234 @@ +//! Selection inputs and the geometry behind picking: what a command input accepts, the origin +//! widget (a fixed size on screen), ray tests against planar quads, and window/crossing box +//! selection. The pure parts are unit tested; the viewport uses them. + +use egui::{Pos2, Rect}; +use solvecraft_engine::Sel; +use solvecraft_engine::geom::Vec3; + +use crate::viewport::Hit; + +/// What a selection input accepts (bit set). +pub type Accept = u16; +pub const PROFILES: Accept = 1; +pub const EDGES: Accept = 2; +pub const FACES: Accept = 4; +/// Planar faces only. +pub const PLANAR_FACES: Accept = 8; +pub const BODIES: Accept = 16; +/// Origin and construction planes. +pub const PLANES: Accept = 32; +/// Origin axes and straight sketch curves. +pub const AXES: Accept = 64; +pub const VERTICES: Accept = 128; + +/// One selection input of a command dialog ("Profiles", "Edges", "Plane", ...). +#[derive(Clone, Debug, PartialEq)] +pub struct SelInput { + pub label: &'static str, + pub accept: Accept, + /// Many items (clicks toggle them), or one (a click replaces it). + pub multi: bool, + pub items: Vec, +} + +impl SelInput { + pub fn new(label: &'static str, accept: Accept, multi: bool) -> SelInput { + SelInput { label, accept, multi, items: Vec::new() } + } + + /// The selection a hit stands for, if this input accepts it. + pub fn accepts(&self, h: &Hit, planar: impl Fn(&str, usize) -> bool) -> Option { + let a = self.accept; + match h { + Hit::Profile { sketch, index } if a & PROFILES != 0 => Some(Sel::Profile { sketch: *sketch, index: *index }), + Hit::Edge { body, index, mid } if a & EDGES != 0 => Some(Sel::Edge { body: body.clone(), index: *index, point: *mid }), + Hit::Face { body, index, point } if a & FACES != 0 || (a & PLANAR_FACES != 0 && planar(body, *index)) => { + Some(Sel::Face { body: body.clone(), index: *index, point: *point }) + } + Hit::Face { body, .. } if a & BODIES != 0 => Some(Sel::Body { name: body.clone() }), + Hit::Plane { name, .. } if a & PLANES != 0 => Some(Sel::Plane { name: name.clone() }), + Hit::Axis { name } if a & AXES != 0 => Some(Sel::Axis { name: name.clone() }), + Hit::SketchCurve { id, straight: true, .. } if a & AXES != 0 => Some(Sel::SketchCurve { id: id.clone() }), + Hit::Vertex { body, point } if a & VERTICES != 0 => Some(Sel::Vertex { body: body.clone(), point: *point }), + _ => None, + } + } + + /// Add or remove items: in a multi input a pick toggles, otherwise it replaces. + pub fn toggle(&mut self, picked: Vec) { + if picked.is_empty() { + return; + } + if !self.multi { + self.items = picked.into_iter().take(1).collect(); + return; + } + // A pick of something already in (or a chain fully in) takes it out. + if picked.iter().all(|p| self.items.contains(p)) { + self.items.retain(|x| !picked.contains(x)); + } else { + for p in picked { + if !self.items.contains(&p) { + self.items.push(p); + } + } + } + } +} + +/// World size of the origin widget: a fixed fraction of the viewport height, whatever the zoom +/// (`half_height` is half the visible height in mm at the target). +pub fn origin_size(half_height: f64) -> f64 { + (half_height * 2.0 * 0.075).max(1e-6) +} + +/// The three origin plane squares (positive quadrant): name, plane normal and corners. +pub fn origin_quads(size: f64) -> [(&'static str, Vec3, [Vec3; 4]); 3] { + let s = size; + [ + ("XY", Vec3::Z, [Vec3::ZERO, Vec3::new(s, 0.0, 0.0), Vec3::new(s, s, 0.0), Vec3::new(0.0, s, 0.0)]), + ("XZ", Vec3::new(0.0, -1.0, 0.0), [Vec3::ZERO, Vec3::new(s, 0.0, 0.0), Vec3::new(s, 0.0, s), Vec3::new(0.0, 0.0, s)]), + ("YZ", Vec3::X, [Vec3::ZERO, Vec3::new(0.0, s, 0.0), Vec3::new(0.0, s, s), Vec3::new(0.0, 0.0, s)]), + ] +} + +/// Ray parameter where the ray (o, d) hits the planar quad (corners in order), if it does. +pub fn ray_quad(o: Vec3, d: Vec3, q: &[Vec3; 4]) -> Option { + let [a, b, _, dd] = *q; + let (u, v) = (b - a, dd - a); + let n = u.cross(v); + let den = n.dot(d); + if den.abs() < 1e-12 { + return None; + } + let t = n.dot(a - o) / den; + if !(t.is_finite() && t >= 0.0) { + return None; + } + let p = o + d * t - a; + // Coordinates in the (u, v) frame (the quad is a parallelogram). + let (uu, uv, vv) = (u.dot(u), u.dot(v), v.dot(v)); + let det = uu * vv - uv * uv; + if det.abs() < 1e-300 { + return None; + } + let (pu, pv) = (p.dot(u), p.dot(v)); + let s = (pu * vv - pv * uv) / det; + let r = (pv * uu - pu * uv) / det; + ((0.0..=1.0).contains(&s) && (0.0..=1.0).contains(&r)).then_some(t) +} + +/// A rectangle dragged in the viewport selects by window (dragged left to right: items fully +/// inside) or by crossing (right to left: items touching it). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BoxSel { + pub rect: Rect, + pub crossing: bool, +} + +impl BoxSel { + pub fn from_drag(start: Pos2, end: Pos2) -> BoxSel { + BoxSel { rect: Rect::from_two_pos(start, end), crossing: end.x < start.x } + } + + /// Does the polyline (screen points) count as selected? + pub fn takes(&self, pts: &[Pos2]) -> bool { + if pts.is_empty() { + return false; + } + if !self.crossing { + return pts.iter().all(|p| self.rect.contains(*p)); + } + pts.iter().any(|p| self.rect.contains(*p)) || pts.windows(2).any(|w| segment_hits_rect(w[0], w[1], self.rect)) + } +} + +/// Does the segment a–b cross the rectangle's boundary or lie inside it? +pub fn segment_hits_rect(a: Pos2, b: Pos2, r: Rect) -> bool { + if r.contains(a) || r.contains(b) { + return true; + } + let c = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()]; + (0..4).any(|i| segments_cross(a, b, c[i], c[(i + 1) % 4])) +} + +fn segments_cross(p: Pos2, q: Pos2, r: Pos2, s: Pos2) -> bool { + let o = |a: Pos2, b: Pos2, c: Pos2| (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); + let (d1, d2, d3, d4) = (o(r, s, p), o(r, s, q), o(p, q, r), o(p, q, s)); + (d1 > 0.0) != (d2 > 0.0) && (d3 > 0.0) != (d4 > 0.0) +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::pos2; + use solvecraft_engine::render::{Camera, StandardView}; + + #[test] + fn origin_widget_keeps_its_screen_size() { + let (w, h) = (1200.0, 800.0); + let mut sizes = Vec::new(); + for distance in [20.0, 200.0, 5000.0] { + let mut cam = Camera { distance, ..Default::default() }; + cam.set_view(StandardView::Front); + let s = origin_size(cam.half_height()); + let a = cam.to_screen(Vec3::ZERO, w, h, distance).unwrap(); + let b = cam.to_screen(Vec3::new(s, 0.0, 0.0), w, h, distance).unwrap(); + sizes.push(b.0 - a.0); + } + assert!(sizes.iter().all(|x| (x - sizes[0]).abs() < 1e-6 && *x > 40.0), "{sizes:?}"); + } + + #[test] + fn rays_hit_origin_quads() { + let q = origin_quads(10.0); + // Straight down onto XY at (3, 4). + let t = ray_quad(Vec3::new(3.0, 4.0, 50.0), Vec3::new(0.0, 0.0, -1.0), &q[0].2).unwrap(); + assert!((t - 50.0).abs() < 1e-12); + // Outside the quadrant: misses. + assert!(ray_quad(Vec3::new(-3.0, 4.0, 50.0), Vec3::new(0.0, 0.0, -1.0), &q[0].2).is_none()); + // Parallel: misses. + assert!(ray_quad(Vec3::new(3.0, 4.0, 50.0), Vec3::X, &q[0].2).is_none()); + // From the front onto XZ. + assert!(ray_quad(Vec3::new(5.0, -100.0, 5.0), Vec3::Y, &q[1].2).is_some()); + // Behind the ray origin: misses. + assert!(ray_quad(Vec3::new(3.0, 4.0, -50.0), Vec3::new(0.0, 0.0, -1.0), &q[0].2).is_none()); + } + + #[test] + fn window_and_crossing_selection() { + let inside = [pos2(20.0, 20.0), pos2(40.0, 30.0)]; + let partly = [pos2(20.0, 20.0), pos2(140.0, 30.0)]; + let through = [pos2(-10.0, 50.0), pos2(200.0, 50.0)]; + let away = [pos2(300.0, 300.0), pos2(320.0, 330.0)]; + // Left to right: window. + let w = BoxSel::from_drag(pos2(0.0, 0.0), pos2(100.0, 100.0)); + assert!(!w.crossing); + assert!(w.takes(&inside) && !w.takes(&partly) && !w.takes(&through) && !w.takes(&away)); + // Right to left: crossing. + let c = BoxSel::from_drag(pos2(100.0, 100.0), pos2(0.0, 0.0)); + assert!(c.crossing); + assert!(c.takes(&inside) && c.takes(&partly) && c.takes(&through) && !c.takes(&away)); + } + + #[test] + fn inputs_toggle_and_filter() { + let mut inp = SelInput::new("Edges", EDGES, true); + let e = |i: usize| Sel::Edge { body: "B".into(), index: i, point: Vec3::new(i as f64, 0.0, 0.0) }; + inp.toggle(vec![e(1)]); + inp.toggle(vec![e(2), e(3)]); + assert_eq!(inp.items.len(), 3); + inp.toggle(vec![e(2), e(3)]); + assert_eq!(inp.items, vec![e(1)]); + let face = Hit::Face { body: "B".into(), index: 0, point: Vec3::ZERO }; + assert!(inp.accepts(&face, |_, _| true).is_none()); + let planes = SelInput::new("Plane", PLANES | PLANAR_FACES, false); + assert!(planes.accepts(&face, |_, _| true).is_some()); + assert!(planes.accepts(&face, |_, _| false).is_none()); + let mut one = SelInput::new("Plane", PLANES, false); + one.toggle(vec![Sel::Plane { name: "XY".into() }]); + one.toggle(vec![Sel::Plane { name: "YZ".into() }]); + assert_eq!(one.items, vec![Sel::Plane { name: "YZ".into() }]); + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 758dd939..a144b9e7 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -22,6 +22,38 @@ pub struct Tokens { pub icon_fill: Color32, pub timeline: Color32, pub hover: Color32, + // ---- viewport selection feedback ---- + /// Selected face (flat fill). + pub sel_face: Color32, + /// Selected edge core and its lighter rim. + pub sel_edge: Color32, + pub sel_edge_rim: Color32, + /// Hovered edge: dark core with a light halo. + pub hover_edge: Color32, + pub hover_edge_halo: Color32, + /// How much lighter a hovered face is (grey levels). + pub hover_face_lift: u8, + /// Selected profile fill and outline. + pub sel_profile: Color32, + pub sel_profile_edge: Color32, + /// Hovered profile: faint tint and a bright outline. + pub hover_profile: Color32, + pub hover_profile_edge: Color32, + /// Selected vertex dot. + pub sel_vertex: Color32, + // ---- origin ---- + pub origin_plane: Color32, + pub origin_plane_edge: Color32, + pub origin_plane_hover: Color32, + pub origin_point: Color32, + pub axis_x: Color32, + pub axis_y: Color32, + pub axis_z: Color32, + /// Construction planes. + pub construction_plane: Color32, + // ---- box selection ---- + pub box_window: Color32, + pub box_crossing: Color32, } impl Tokens { @@ -44,6 +76,27 @@ impl Tokens { icon_fill: Color32::from_rgb(178, 196, 222), timeline: Color32::from_rgb(232, 235, 240), hover: Color32::from_rgb(226, 234, 246), + sel_face: Color32::from_rgb(76, 127, 203), + sel_edge: Color32::from_rgb(0, 127, 255), + sel_edge_rim: Color32::from_rgb(127, 191, 255), + hover_edge: Color32::from_rgb(0, 0, 0), + hover_edge_halo: Color32::from_rgb(203, 203, 203), + hover_face_lift: 40, + sel_profile: Color32::from_rgb(26, 84, 167), + sel_profile_edge: Color32::from_rgb(47, 109, 198), + hover_profile: Color32::from_rgba_unmultiplied_const(75, 129, 253, 48), + hover_profile_edge: Color32::from_rgb(75, 129, 253), + sel_vertex: Color32::from_rgb(70, 110, 170), + origin_plane: Color32::from_rgba_unmultiplied_const(249, 184, 134, 94), + origin_plane_edge: Color32::from_rgba_unmultiplied_const(214, 140, 84, 200), + origin_plane_hover: Color32::from_rgba_unmultiplied_const(120, 130, 230, 150), + origin_point: Color32::from_rgb(214, 215, 218), + axis_x: Color32::from_rgb(255, 0, 0), + axis_y: Color32::from_rgb(0, 255, 0), + axis_z: Color32::from_rgb(2, 2, 248), + construction_plane: Color32::from_rgba_unmultiplied_const(249, 184, 134, 70), + box_window: Color32::from_rgb(38, 120, 218), + box_crossing: Color32::from_rgb(40, 160, 80), } } } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index a9ecb397..6ecaa554 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -11,6 +11,7 @@ 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}; @@ -27,21 +28,59 @@ pub struct ViewportState { pub gpu: Option, slot: SceneSlot, key: u64, + hl_slot: SceneSlot, + hl_key: u64, cpu: Option<(u64, egui::TextureHandle)>, 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, pub build_ms: f64, } /// Something under the cursor. #[derive(Clone, Debug, PartialEq)] pub enum Hit { - SketchPoint { sketch: u64, id: String, at: Vec2 }, - SketchCurve { sketch: u64, id: String }, - Edge { body: String, index: usize, mid: Vec3 }, - Profile { sketch: u64, index: usize }, - Face { body: String, index: usize, point: Vec3 }, + 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. @@ -99,16 +138,14 @@ fn visible_sketches(app: &SolveApp) -> Vec { fn scene_key(app: &SolveApp) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); app.session.revision.hash(&mut h); - serde_json::to_string(&app.session.selection).unwrap_or_default().hash(&mut h); - serde_json::to_string(&app.ui).unwrap_or_default().hash(&mut h); + app.ui.show_grid.hash(&mut h); + app.ui.show_sketches.hash(&mut h); + app.ui.hidden_bodies.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); - if let Some(d) = &app.dialog { - d.highlight_key().hash(&mut h); - } h.finish() } @@ -130,19 +167,11 @@ fn build_scene(app: &SolveApp) -> GpuScene { sc.line((c + Vec3::new(-ext, t, 0.0)).to_f32(), (c + Vec3::new(ext, t, 0.0)).to_f32(), col, w, false); } } - if app.ui.show_origin { - let a = app.cam.half_height() * 0.3; - for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] { - sc.line([0.0; 3], (d * a).to_f32(), rgba(col), 2.0, false); - } - } - let dialog_edges: Vec = app.dialog.as_ref().map(|d| d.edges()).unwrap_or_default(); for b in &st.bodies { if app.ui.hidden_bodies.contains(&b.name) { continue; } - let selected = s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == b.name)); - let col = rgba(if selected { colors::BODY_SELECTED } else { colors::BODY }); + let col = rgba(colors::BODY); let m = b.mesh(); for t in &m.triangles { for k in t { @@ -156,18 +185,11 @@ fn build_scene(app: &SolveApp) -> GpuScene { if m.seams.get(ei).copied().unwrap_or(false) { continue; } - let sel = s.selection.iter().any(|x| matches!(x, Sel::Edge { body, index, .. } if *body == b.name && *index == ei)) - || dialog_edges.iter().any(|p| e.windows(2).any(|w| p.dist_to_segment(w[0], w[1]) < 1e-6 * b.body.size().max(1.0) + 1e-6)); for w in e.windows(2) { - if sel { - sc.line(w[0].to_f32(), w[1].to_f32(), [30, 120, 230, 255], 3.5, true); - } else { - sc.line(w[0].to_f32(), w[1].to_f32(), rgba(colors::EDGE), 1.3, false); - } + sc.line(w[0].to_f32(), w[1].to_f32(), rgba(colors::EDGE), 1.3, false); } } } - let dialog_profiles = app.dialog.as_ref().map(|d| d.profiles()).unwrap_or_default(); for sid in visible_sketches(app) { let Some(ss) = st.sketch(sid) else { continue }; let active = s.active_sketch == Some(sid); @@ -176,34 +198,55 @@ fn build_scene(app: &SolveApp) -> GpuScene { sc.line(w[0].to_f32(), w[1].to_f32(), rgba(col), if cons { 1.2 } else { 2.0 }, active); } } - // Selected profiles: outline. - for (pi, p) in ss.profiles.iter().enumerate() { - let picked = dialog_profiles.contains(&(sid, pi)) - || s.selection.iter().any(|x| matches!(x, Sel::Profile { sketch, index } if *sketch == sid && *index == pi)); - if !picked { - continue; - } - 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(ss.plane.to_world(*a).to_f32(), ss.plane.to_world(*b).to_f32(), rgba(colors::PROFILE), 4.0, true); - } - } - } - } } sc } -/// Pick what is under `pos`, most specific first. +/// 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(); + } + let creating = 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.ui.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.ui.hidden_origin.iter().any(|h| h == n)).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.ui.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. pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { let s = &app.session; let st = s.model.state(); let mut hits = Vec::new(); let (o, d) = proj.ray(pos); - // Sketch points and curves of the active sketch. + // Sketch points of the active sketch. if let Some(sid) = s.active_sketch && let Some(ss) = st.sketch(sid) { @@ -219,43 +262,29 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { if let Some((_, h)) = best { hits.push(h); } - let mut bestc: Option<(f32, Hit)> = None; + } + // 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() { + 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]); + 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() })); + bestc = Some((dd, Hit::SketchCurve { sketch: sid, id: c.id.clone(), straight })); } } } } - if let Some((_, h)) = bestc { - hits.push(h); - } } - // Edges. - let mut beste: Option<(f32, Hit)> = None; - for b in &st.bodies { - if app.ui.hidden_bodies.contains(&b.name) { - continue; - } - let m = b.mesh(); - for (ei, e) in m.edges.iter().enumerate() { - if m.seams.get(ei).copied().unwrap_or(false) { - continue; - } - let pts: Vec = e.iter().filter_map(|q| proj.to_screen(*q)).collect(); - for w in pts.windows(2) { - let dd = seg_dist(pos, w[0], w[1]); - if dd < 6.0 && beste.as_ref().is_none_or(|(bd, _)| dd < *bd) { - beste = Some((dd, Hit::Edge { body: b.name.clone(), index: ei, mid: polyline_mid(e) })); - } - } - } + if let Some((_, h)) = bestc { + hits.push(h); } - // Faces (nearest along the ray), used to reject edges hidden behind faces. + // Nearest face along the ray (hides what is behind it). let mut bestf: Option<(f64, Hit)> = None; for b in &st.bodies { if app.ui.hidden_bodies.contains(&b.name) { @@ -269,37 +298,342 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { bestf = Some((t, Hit::Face { body: b.name.clone(), index: fi, point: o + d * t })); } } - if let Some((_, h)) = beste { - // An edge counts only when it is not hidden behind the first face along the ray. - let visible = match (&h, &bestf) { - (Hit::Edge { mid, .. }, Some((t, _))) => (*mid - o).dot(d) <= *t + scene_radius(app) * 0.05, - _ => true, - }; - if visible { - hits.push(h); + 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.ui.hidden_bodies.contains(&b.name) { + continue; } - } - // Profiles of visible sketches. - let mut bestp: Option<(f64, Hit)> = None; - for sid in visible_sketches(app) { - let Some(ss) = st.sketch(sid) else { continue }; - let Some(w) = ss.plane.intersect_ray(o, d) else { continue }; - let lp = ss.plane.to_local(w); - for (pi, p) in ss.profiles.iter().enumerate() { - if p.region.contains(lp) && bestp.as_ref().is_none_or(|(a, _)| p.area < *a) { - bestp = Some((p.area, Hit::Profile { sketch: sid, index: pi })); + 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 })); + } + } } } } - if let Some((_, h)) = bestp { - hits.push(h); + 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() })); + } + } } - if let Some((_, h)) = bestf { - hits.push(h); + 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); + for (pi, p) in ss.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); + } + for (name, _, q) in origin_planes(app) { + if let Some(t) = ray_quad(o, d, &q) { + surf.push((t, 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, 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 } +/// 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))); + } + hits.iter() + .filter(|h| match h { + Hit::SketchCurve { sketch, .. } => app.session.active_sketch == Some(*sketch), + _ => true, + }) + .find_map(|h| 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); + } + } + } +} + +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); + } + } +} + +fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index: usize, fill: Color32, edge: Color32) { + let st = app.session.model.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.origin_visible().hash(&mut h); + app.ui.hidden_origin.hash(&mut h); + app.ui.hidden_bodies.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.model.state(); + let sel = app.highlighted(); + 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; + } + } + // Selected things. + for x in &sel { + match x { + Sel::Face { body, index, .. } => { + if let Some(b) = st.body(body) { + face_tris(&mut sc, &b.mesh(), *index, c4(t.sel_face), false); + } + } + Sel::Body { name } => { + if let Some(b) = st.body(name) + && !app.ui.hidden_bodies.contains(name) + { + let m = b.mesh(); + for f in 0..b.body.face_count() { + face_tris( + &mut sc, + &m, + f, + c4(Color32::from_rgb(colors::BODY_SELECTED.0, colors::BODY_SELECTED.1, colors::BODY_SELECTED.2)), + true, + ); + } + } + } + Sel::Edge { body, index, point } => { + if let Some(b) = st.body(body) + && let Some(e) = edge_of(&b.mesh(), *index, *point) + { + edge_lines(&mut sc, e, t.sel_edge, t.sel_edge_rim, 3.0); + } + } + Sel::Profile { sketch, index } => profile_fill(&mut sc, app, *sketch, *index, t.sel_profile, t.sel_profile_edge), + Sel::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); + } + } + } + } + _ => {} + } + } + // Hover (pre-highlight). + match hover { + Some(Hit::Face { body, index, .. }) if !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 = colors::BODY; + face_tris(&mut sc, &b.mesh(), *index, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); + let m = b.mesh(); + for e in m.face_edges(u32::try_from(*index).unwrap_or(u32::MAX)) { + 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 Some(e) = edge_of(&b.mesh(), *index, *mid) + { + let selected = sel.iter().any(|x| matches!(x, Sel::Edge { point, .. } if point.dist(*mid) < 1e-9)); + if !selected { + 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 +} + 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; @@ -451,10 +785,20 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { 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(), view_proj: proj.vp.to_f32(), back: proj.cam.back().to_f32(), size_px: [rect.width() * ppp, rect.height() * ppp], @@ -465,8 +809,10 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } // ---- interaction ---- - let hits = hover.filter(|_| inside).map(|p| pick(app, &proj, p)).unwrap_or_default(); - app.viewport.hover = hits.first().cloned(); + let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none()).map(|p| pick(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 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); @@ -475,19 +821,53 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { 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(d) = app.dialog.as_mut() { - d.on_pick(&hits); + 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 { - select(app, &hits, mods.shift || mods.command); + select(app, cand.and_then(|c| c.1), add); } } if resp.secondary_clicked() && delta == egui::Vec2::ZERO && app.tool.is_some() { crate::tools::finish(app); } } + // Box selection: a primary drag on the model when no navigation mode is on. + if app.tool.is_none() && app.viewport.nav.is_none() { + 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; + } overlays(app, ui, &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); } @@ -508,37 +888,137 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { } } -fn select(app: &mut SolveApp, hits: &[Hit], add: bool) { - let item = hits.first().map(|h| match h { - Hit::SketchPoint { id, .. } => json!({"type": "sketch_point", "id": id}), - Hit::SketchCurve { id, .. } => json!({"type": "sketch_curve", "id": id}), - Hit::Edge { body, index, mid } => json!({"type": "edge", "body": body, "index": index, "point": mid}), - Hit::Profile { sketch, index } => json!({"type": "profile", "sketch": sketch, "index": index}), - Hit::Face { body, .. } => json!({"type": "body", "name": body}), - }); - match item { - Some(i) => { - let _ = app.run("select.set", json!({"items": [i], "add": add})); +/// 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 item = serde_json::to_value(&x).unwrap_or_default(); + if add && app.session.selection.contains(&x) { + let rest: Vec = app.session.selection.iter().filter(|y| **y != x).cloned().collect(); + let _ = app.run("select.set", json!({ "items": rest })); + } else { + let _ = app.run("select.set", json!({"items": [item], "add": add})); + } } - None => { + None if !add => { let _ = app.run("select.clear", json!({})); } + None => {} } } -fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { - let col = Color32::from_rgb(40, 140, 255); +/// 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.model.state(); - match &app.viewport.hover { - Some(Hit::Edge { body, index, .. }) => { - if let Some(b) = st.body(body) - && let Some(e) = b.mesh().edges.get(*index) - { - let pts: Vec = e.iter().filter_map(|q| proj.to_screen(*q)).collect(); - painter.add(Shape::line(pts, Stroke::new(3.0, col))); + 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)) + { + for (i, c) in ss.sketch.curves.iter().enumerate() { + 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() }); } } - Some(Hit::SketchCurve { sketch, id }) => { + for p in &ss.sketch.points { + if bx.takes(&to2(&[ss.plane.to_world(p.pos)])) { + out.push(Sel::SketchPoint { id: p.id.clone() }); + } + } + } 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.ui.hidden_bodies.contains(&b.name) { + continue; + } + let m = b.mesh(); + if accept & EDGES != 0 { + for (ei, e) in m.edges.iter().enumerate() { + if !m.seams.get(ei).copied().unwrap_or(false) && bx.takes(&to2(e)) { + 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); + if let Some(point) = point + && bx.takes(&to2(&pts)) + { + 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; + } + } + } + if let Some(mut d) = app.dialog.take() { + d.take_box(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.ui.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.model.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) { @@ -555,14 +1035,6 @@ fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { painter.circle_stroke(p, 6.0, Stroke::new(2.0, col)); } } - Some(Hit::Profile { sketch, index }) if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) => { - if let Some(ss) = st.sketch(*sketch) - && let Some(p) = ss.profiles.get(*index) - { - let pts: Vec = p.region.outer.polyline(0.05).iter().filter_map(|q| proj.to_screen(ss.plane.to_world(*q))).collect(); - painter.add(Shape::closed_line(pts, Stroke::new(2.0, Color32::from_rgb(255, 170, 60)))); - } - } _ => {} } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0d875b6b..4a6194ac 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -25,8 +25,10 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `ui.set` | any `UiState` field (`tab`, `perspective`, `showGrid`, `hiddenBodies`, …) | | | `ui.view` | `view`: `front`, `back`, `top`, `bottom`, `left`, `right`, `iso`, `home`, `fit`; `animate` (bool, default false: snap) | | | `ui.start` | `command` | like a toolbar click: starts the sketch tool or dialog for the command | -| `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?` | real pointer input | +| `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?`, `ctrl?` | real pointer input | | `ui.move` | `x`, `y` | | +| `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing) | +| `ui.selection` | | the selection, the open dialog's inputs and the hovered item | | `ui.scroll` | `x`, `y`, `delta?` | wheel zoom at the cursor | | `ui.key` | `key` (egui key name, `Enter`, `Escape`…), `cmd?`, `shift?` | | | `ui.text` | `text` | typed text | diff --git a/docs/parity.md b/docs/parity.md index 46fdaee3..3da63ff7 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,12 +2,12 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the toolbar commands of Fusion's Design workspace, ids and names only) and the live command registry (`solvecraft-cli commands`). A command counts as live when SolveCraft registers the same command id. This measures breadth, not depth; see ROADMAP.md and docs/oracle.md for depth. -**SOLID + SKETCH: 53 / 291 commands live (18%).** All Design tabs: 129 / 1175 (11%). Registered commands: 74. +**SOLID + SKETCH: 57 / 291 commands live (20%).** All Design tabs: 137 / 1175 (12%). Registered commands: 78. | Tab | Panel | Live | Total | % | |---|---|---:|---:|---:| -| SOLID | CREATE | 11 | 35 | 31% | -| SOLID | MODIFY | 8 | 32 | 25% | +| SOLID | CREATE | 13 | 35 | 37% | +| SOLID | MODIFY | 10 | 32 | 31% | | SOLID | ASSEMBLE | 0 | 21 | 0% | | SOLID | CONFIGURE | 0 | 3 | 0% | | SOLID | CONSTRUCT | 2 | 20 | 10% | @@ -65,8 +65,8 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool | SHEET METAL | REFOLD | 0 | 1 | 0% | | SHEET METAL | POSITION | 0 | 2 | 0% | | PLASTIC | SETUP | 0 | 2 | 0% | -| PLASTIC | CREATE | 11 | 35 | 31% | -| PLASTIC | MODIFY | 8 | 32 | 25% | +| PLASTIC | CREATE | 13 | 35 | 37% | +| PLASTIC | MODIFY | 10 | 32 | 31% | | PLASTIC | ASSEMBLE | 0 | 20 | 0% | | PLASTIC | CONFIGURE | 0 | 3 | 0% | | PLASTIC | CONSTRUCT | 2 | 20 | 10% | @@ -105,6 +105,8 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool - SOLID › CREATE › Create Sketch (`SketchCreate`) - SOLID › CREATE › Extrude (`Extrude`) - SOLID › CREATE › Revolve (`Revolve`) +- SOLID › CREATE › Sweep (`Sweep`) +- SOLID › CREATE › Loft (`SolidLoft`) - SOLID › CREATE › Hole (`FusionHoleCommand`) - SOLID › CREATE › Box (`PrimitiveBox`) - SOLID › CREATE › Cylinder (`PrimitiveCylinder`) @@ -115,6 +117,8 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool - SOLID › CREATE › Mirror (`MirrorCommand`) - SOLID › MODIFY › Fillet (`FusionFilletEdgesCommand`) - SOLID › MODIFY › Chamfer (`FusionChamferCommand`) +- SOLID › MODIFY › Shell (`FusionShellBodyCommand`) +- SOLID › MODIFY › Draft (`FusionDraftCommand`) - SOLID › MODIFY › Combine (`FusionCombineCommand`) - SOLID › MODIFY › Split Body (`FusionSplitBodyCommand`) - SOLID › MODIFY › Move/Copy (`FusionMoveCommand`) @@ -161,8 +165,6 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool - SOLID › CREATE › Create Form (`TSplineBaseFeatureCreationCommand`) - SOLID › CREATE › Derive (`PushDeriveCommand`) - SOLID › CREATE › Automated Modeling (`AutomatedModelingCommand`) -- SOLID › CREATE › Sweep (`Sweep`) -- SOLID › CREATE › Loft (`SolidLoft`) - SOLID › CREATE › Rib (`FusionRibCommand`) - SOLID › CREATE › Web (`FusionWebCommand`) - SOLID › CREATE › Emboss (`EmbossCmd`) @@ -184,8 +186,6 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool - SOLID › CREATE › Generate Timeline (`TimelineBuilderCommand`) - SOLID › MODIFY › Press Pull (`FusionPressPullCommand`) - SOLID › MODIFY › Edit Face (`EditFaceCommand`) -- SOLID › MODIFY › Shell (`FusionShellBodyCommand`) -- SOLID › MODIFY › Draft (`FusionDraftCommand`) - SOLID › MODIFY › Scale (`ModifyScale`) - SOLID › MODIFY › Offset Face (`FusionOffsetFacesCommand`) - SOLID › MODIFY › Replace Face (`FusionReplaceFaceCommand`)