From 7ef3426cb32f47e6919e914a4b7f44fe7fca4edb Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 7 Oct 2026 21:23:47 +0000 Subject: [PATCH] Keyboard-first commands: E/F/H open with the on-canvas value box focused as soon as the geometry is picked (typing replaces the value, Enter applies, Tab moves on, Esc cancels), extrude direction arrow; Extrude takes planar body faces (face: [x,y,z] copies the face boundary into a sketch), so select-face-then-E press-pulls; join/cut previews show the tool body (cut see-through red) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/engine/src/cmd/face.rs | 128 ++++++++++++++++++++++++++++++ crates/engine/src/cmd/features.rs | 16 +++- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/tests.rs | 23 ++++++ crates/ui-egui/src/canvas.rs | 103 ++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 97 +++++++++++++++++----- crates/ui-egui/src/gpu.rs | 13 +++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/preview.rs | 90 ++++++++++++++++----- crates/ui-egui/src/theme.rs | 3 + crates/ui-egui/src/viewport.rs | 1 + 11 files changed, 433 insertions(+), 43 deletions(-) create mode 100644 crates/engine/src/cmd/face.rs create mode 100644 crates/ui-egui/src/canvas.rs diff --git a/crates/engine/src/cmd/face.rs b/crates/engine/src/cmd/face.rs new file mode 100644 index 00000000..7c7ded0e --- /dev/null +++ b/crates/engine/src/cmd/face.rs @@ -0,0 +1,128 @@ +//! Sketches made from a planar body face, so a face can be extruded like a profile (Extrude +//! with `face`, Press Pull). The face boundary is copied into a new sketch on the face: straight +//! edges become lines, circular edges circles or arcs, anything else a chain of lines. + +use serde_json::{Value, json}; +use solvecraft_geom::{Mesh, Vec2, Vec3}; + +use crate::params::bad; +use crate::{Result, Session}; + +fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 { + let n = (b - a).cross(c - a); + let Some(nn) = n.normalized() else { return f64::INFINITY }; + let h = (p - a).dot(nn); + let q = p - nn * h; + let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-12); + if inside { h.abs() } else { [(a, b), (b, c), (c, a)].iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min) } +} + +/// The body face under a point: (mesh, face index, distance). +fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc, u32, f64)> { + let st = s.model.state(); + let mut best: Option<(std::sync::Arc, u32, f64)> = None; + for b in &st.bodies { + let m = b.mesh(); + let mut near: Option<(u32, f64)> = None; + for (t, f) in m.triangles.iter().zip(&m.tri_face) { + let Some([a, bb, c]) = m.tri(t) else { continue }; + let d = point_triangle_dist(p, a, bb, c); + if near.is_none_or(|(_, nd)| d < nd) { + near = Some((*f, d)); + } + } + if let Some((f, d)) = near + && best.as_ref().is_none_or(|x| d < x.2) + { + best = Some((m.clone(), f, d)); + } + } + best +} + +/// Circle through three points. +fn circumcircle(a: Vec2, b: Vec2, c: Vec2) -> Option<(Vec2, f64)> { + let d = 2.0 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)); + if d.abs() < 1e-12 { + return None; + } + let (a2, b2, c2) = (a.len2(), b.len2(), c.len2()); + let o = Vec2::new((a2 * (b.y - c.y) + b2 * (c.y - a.y) + c2 * (a.y - b.y)) / d, (a2 * (c.x - b.x) + b2 * (a.x - c.x) + c2 * (b.x - a.x)) / d); + let r = o.dist(a); + (o.is_finite() && r.is_finite() && r < 1e8).then_some((o, r)) +} + +fn xy(v: Vec2) -> Value { + json!([v.x, v.y]) +} + +/// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns +/// the sketch id and a point inside the face (sketch coordinates). The sketch is left active. +pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<(u64, Vec2)> { + let (m, f, d) = face_at(s, point).ok_or_else(|| bad(cmd, "no body face at `face`"))?; + let tol = (m.bounds().diagonal() * 1e-3).max(1e-3); + if d > tol * 10.0 { + return Err(bad(cmd, "no body face at `face`")); + } + let tris: Vec<[Vec3; 3]> = m.triangles.iter().zip(&m.tri_face).filter(|(_, tf)| **tf == f).filter_map(|(t, _)| m.tri(t)).collect(); + let normal = tris.iter().find_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).ok_or_else(|| bad(cmd, "degenerate face"))?; + if tris.iter().any(|[a, b, c]| (*b - *a).cross(*c - *a).normalized().is_some_and(|n| n.dot(normal) < 1.0 - 1e-6)) { + return Err(bad(cmd, "only planar faces can be extruded")); + } + // A point well inside the face: the centroid of its largest triangle. + let inside = tris + .iter() + .max_by(|x, y| (x[1] - x[0]).cross(x[2] - x[0]).len().total_cmp(&(y[1] - y[0]).cross(y[2] - y[0]).len())) + .map(|[a, b, c]| (*a + *b + *c) / 3.0) + .unwrap_or(point); + let edges: Vec> = + m.face_edges(f).into_iter().filter(|e| !m.seams.get(*e).copied().unwrap_or(false)).filter_map(|e| m.edges.get(e).cloned()).collect(); + if edges.is_empty() || edges.len() > 5000 { + return Err(bad(cmd, "the face has no usable boundary")); + } + let run = |s: &mut Session, id: &str, p: Value| -> Result { + let spec = crate::find_command(id).ok_or_else(|| bad(cmd, id))?; + (spec.run)(s, &p) + }; + let r = run(s, "SketchCreate", json!({"plane": {"face": [inside.x, inside.y, inside.z]}}))?; + let sid = r.get("sketch").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?; + s.refresh(); + let plane = s.model.state().sketch(sid).map(|ss| ss.plane).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?; + for e in &edges { + let pts: Vec = e.iter().map(|p| plane.to_local(*p)).collect(); + let (Some(first), Some(last)) = (pts.first().copied(), pts.last().copied()) else { continue }; + let closed = pts.len() > 3 && first.dist(last) < tol; + let circle = if pts.len() >= 4 { + let n = pts.len() - usize::from(closed); + let (a, b, c) = (pts.first(), pts.get(n / 3), pts.get(2 * n / 3)); + match (a, b, c) { + (Some(a), Some(b), Some(c)) => circumcircle(*a, *b, *c).filter(|(o, r)| pts.iter().all(|q| (q.dist(*o) - r).abs() < tol)), + _ => None, + } + } else { + None + }; + match circle { + Some((o, r)) if closed => run(s, "CircleCenterRadius", json!({"center": xy(o), "radius": r}))?, + Some(_) => { + let mid = pts.get(pts.len() / 2).copied().unwrap_or(first); + run(s, "ArcThreePoint", json!({"start": xy(first), "end": xy(last), "through": xy(mid)}))? + } + None => { + let mut list: Vec = Vec::new(); + for q in &pts { + // Drop points that repeat (a closed polyline ends where it starts). + if list.last().is_none_or(|l| l != &xy(*q)) { + list.push(xy(*q)); + } + } + let closed_poly = list.len() > 2 && list.first() == list.last(); + if closed_poly { + list.pop(); + } + run(s, "DrawPolyline", json!({"points": list, "closed": closed_poly}))? + } + }; + } + Ok((sid, plane.to_local(inside))) +} diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 76d31716..c9ecca07 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "CREATE") .icon("extrude") .key("E") - .params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; direction?: positive|negative|symmetric; distance2?; start_offset?; operation?: new|join|cut|intersect; targets?: [body]; name?; body_name?"), + .params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?; operation?: new|join|cut|intersect; targets?: [body]; name?; body_name?"), CommandSpec::new("Revolve", "Revolve", revolve) .at("SOLID", "CREATE") .icon("revolve") @@ -178,7 +178,14 @@ fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Result { fn extrude(s: &mut Session, p: &Value) -> Result { let cmd = "Extrude"; - let sketch = feature_sketch(s, p, cmd)?; + // A planar body face extrudes like a profile: its boundary is copied into a sketch on it. + let (sketch, face_profile) = match p.get("face").and_then(vec3) { + Some(fp) => { + let (id, inside) = super::face::sketch_of_face(s, fp, cmd)?; + (id, Some(ProfileSel::Points { points: vec![inside] })) + } + None => (feature_sketch(s, p, cmd)?, None), + }; let through_all = bool_(p, "through_all").unwrap_or(false) || str_(p, "extent").is_some_and(|e| e.eq_ignore_ascii_case("through_all")); let distance = if through_all { expr(p, "distance").unwrap_or_else(|| "0".into()) } else { req_expr(cmd, p, "distance")? }; check_expr(s, &distance, Kind::Length, cmd, "distance")?; @@ -202,7 +209,10 @@ fn extrude(s: &mut Session, p: &Value) -> Result { } let kind = FeatureKind::Extrude { sketch, - profiles: profiles(p, cmd)?, + profiles: match face_profile { + Some(f) => f, + None => profiles(p, cmd)?, + }, extent: Extent { distance, direction, distance2, start_offset, through_all, taper }, operation: operation(p, cmd)?, targets: string_list(p, "targets"), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index eab96bb3..53139d64 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -3,6 +3,7 @@ //! as the parity metric (`cargo xtask parity`). mod edit; +mod face; mod features; mod file; mod inspect; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c1d7647a..056859c1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -525,3 +525,26 @@ fn preview_of_an_edit() { assert!(rel(body_volume(&p.after), 12000.0) < 1e-9); assert!(Arc::ptr_eq(&doc, &s.doc)); } + +/// Extruding a planar body face (press-pull style): the face boundary becomes a sketch profile. +#[test] +fn extrude_a_body_face() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 20})); + run(&mut s, "Extrude", json!({"face": [20, 15, 20], "distance": 15, "operation": "join"})); + assert!(rel(volume(&mut s), 40.0 * 30.0 * 35.0) < 1e-6); + assert!(s.active_sketch.is_none()); + // A face with a round hole: the hole stays open (a new body: joins of curved bodies on a + // shared face are a kernel gap for now). + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 20})); + run(&mut s, "SketchCreate", json!({"plane": "XY"})); + run(&mut s, "CircleCenterRadius", json!({"center": [20, 15], "radius": 5})); + run(&mut s, "SketchStop", json!({})); + run(&mut s, "Extrude", json!({"distance": 20, "operation": "cut"})); + let before = volume(&mut s); + run(&mut s, "Extrude", json!({"face": [5, 5, 20], "distance": 10, "operation": "new"})); + let v = volume(&mut s); + assert!(rel(v - before, (1200.0 - PI * 25.0) * 10.0) < 1e-3, "{v} {before}"); + assert!(s.execute("Extrude", &json!({"face": [500, 5, 20], "distance": 10})).is_err()); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs new file mode 100644 index 00000000..f1c1c3b8 --- /dev/null +++ b/crates/ui-egui/src/canvas.rs @@ -0,0 +1,103 @@ +//! On-canvas command input: while a feature dialog is open, its main value (extrude distance, +//! fillet radius…) gets a value box in the viewport next to the geometry, with a direction +//! arrow for extrudes. The box takes the keyboard as soon as the command has its geometry, so +//! typing a number sets the value at once; Enter applies, Tab moves on, Esc cancels. + +use egui::{Color32, Pos2, Stroke, vec2}; +use solvecraft_engine::Sel; +use solvecraft_engine::doc::expr::Kind as ValueKind; +use solvecraft_engine::geom::Vec3; + +use crate::SolveApp; +use crate::dialogs::{Dialog, Kind}; +use crate::theme::Tokens; +use crate::viewport::Proj; + +/// Where the dialog's geometry is: a base point and, for directional values, the direction a +/// positive value points. +pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { + let st = app.session.model.state(); + let first = d.inputs.first()?.items.first()?; + let at = match first { + Sel::Profile { sketch, index } => { + let ss = st.sketch(*sketch)?; + let p = ss.profiles.get(*index)?; + (ss.plane.to_world(p.region.interior_point()), Some(ss.plane.normal())) + } + Sel::Face { body, index, point } => (*point, crate::dialogs::planar_face(&app.session, body, *index).map(|(_, n)| n)), + Sel::Edge { point, .. } | Sel::Vertex { point, .. } => (*point, None), + _ => return None, + }; + // Only extrudes point along the normal. + Some(match d.kind { + Kind::Extrude { .. } => at, + _ => (at.0, None), + }) +} + +/// Draw the arrow and the value box (called by the viewport after the model is drawn). +pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj) { + let Some(mut d) = app.dialog.take() else { return }; + if let Some((base, dir)) = anchor(app, &d) + && let Some(bs) = proj.to_screen(base) + { + draw(app, ui, painter, proj, &mut d, base, bs, dir); + } + app.dialog = Some(d); +} + +#[allow(clippy::too_many_arguments)] +fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, d: &mut Dialog, base: Vec3, bs: Pos2, dir: Option) { + let t = Tokens::get(); + let sign = match d.kind { + Kind::Extrude { direction: 1, .. } => -1.0, + _ => 1.0, + }; + let symmetric = matches!(d.kind, Kind::Extrude { direction: 2, .. }); + let focus = std::mem::take(&mut d.focus); + let Some((label, kind, value)) = d.primary() else { return }; + let len = app.session.doc.eval(value, kind).ok().filter(|v| v.is_finite()); + let mut box_at = bs + vec2(16.0, -30.0); + if let (Some(n), Some(l)) = (dir, len.filter(|_| kind == ValueKind::Length)) { + let n = n * sign; + let tip = base + n * l; + let back = if symmetric { base - n * l } else { base }; + if let (Some(ts), Some(b0)) = (proj.to_screen(tip), proj.to_screen(back)) { + arrow(painter, b0, ts, t.manipulator); + if symmetric && let Some(bt) = proj.to_screen(base - n * l) { + arrow(painter, bs, bt, t.manipulator); + } + painter.circle(bs, 3.5, t.manipulator, Stroke::new(1.0, Color32::WHITE)); + box_at = ts + vec2(14.0, -12.0); + } + } + let id = egui::Id::new("sc_canvas_value"); + egui::Area::new(egui::Id::new("sc_canvas_area")).fixed_pos(box_at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(label).color(t.text_dim).size(11.5)); + let r = ui.add(egui::TextEdit::singleline(value).id(id).desired_width(72.0)); + if focus { + r.request_focus(); + let n = value.chars().count(); + let mut st = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default(); + st.cursor.set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); + st.store(ui.ctx(), id); + } + }); + }); + }); +} + +fn arrow(painter: &egui::Painter, a: Pos2, b: Pos2, col: Color32) { + let v = b - a; + let l = v.length(); + if l < 1.0 { + return; + } + let u = v / l; + let n = vec2(-u.y, u.x); + painter.line_segment([a, b], Stroke::new(2.0, col)); + let head = 11.0f32.min(l * 0.6); + painter.add(egui::Shape::convex_polygon(vec![b, b - u * head + n * head * 0.45, b - u * head - n * head * 0.45], col, Stroke::NONE)); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 12845b22..d1c82894 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -8,6 +8,7 @@ use egui::{Color32, RichText, vec2}; use serde_json::{Value, json}; use solvecraft_engine::Sel; use solvecraft_engine::Session; +use solvecraft_engine::doc::expr::Kind as ValueKind; use solvecraft_engine::doc::{AxisRef, Direction, FeatureKind, HoleKind, Operation, PlaneRef, ProfileSel}; use solvecraft_engine::geom::Vec3; @@ -95,6 +96,8 @@ pub struct Dialog { pub error: Option, /// Editing an existing feature (its id, and the timeline marker to restore afterwards). pub editing: Option<(u64, Option)>, + /// Give the on-canvas value box the keyboard (and select its text) on the next frame. + pub focus: bool, /// Parameters the dialog doesn't show but the command needs (kept when editing). pub extra: serde_json::Map, } @@ -115,7 +118,7 @@ fn fits(a: Accept, s: &Sel) -> bool { impl Dialog { fn new(kind: Kind, inputs: Vec) -> Dialog { - Dialog { kind, inputs, active: 0, error: None, editing: None, extra: serde_json::Map::new() } + Dialog { kind, inputs, active: 0, error: None, editing: None, focus: true, extra: serde_json::Map::new() } } pub fn for_command(app: &SolveApp, id: &str) -> Option { @@ -125,7 +128,7 @@ impl Dialog { "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)], + vec![SelInput::new("Profiles", PROFILES | PLANAR_FACES, true)], ), "Revolve" => Dialog::new( Kind::Revolve { angle: "360 deg".into(), operation: 0 }, @@ -226,6 +229,19 @@ impl Dialog { ) } + /// The main value (shown on the canvas too): label, kind and the expression. + pub fn primary(&mut self) -> Option<(&'static str, ValueKind, &mut String)> { + Some(match &mut self.kind { + Kind::Extrude { distance, .. } => ("Distance", ValueKind::Length, distance), + Kind::Revolve { angle, .. } => ("Angle", ValueKind::Angle, angle), + Kind::Fillet { radius, chamfer, .. } => (if *chamfer { "Distance" } else { "Radius" }, ValueKind::Length, radius), + Kind::Shell { thickness } => ("Thickness", ValueKind::Length, thickness), + Kind::Draft { angle } => ("Angle", ValueKind::Angle, angle), + Kind::Hole { diameter, .. } => ("Diameter", ValueKind::Length, diameter), + _ => return None, + }) + } + pub fn wants_picks(&self) -> bool { !self.inputs.is_empty() } @@ -271,10 +287,11 @@ impl Dialog { 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)); + 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); + self.focus = true; if !inp.multi && !inp.items.is_empty() && self.active + 1 < self.inputs.len() { self.advance(); } @@ -343,6 +360,12 @@ fn plane_value(s: &Session, sel: Option<&Sel>) -> Option { } } +/// A value field; true when Enter was pressed in it. +fn field(ui: &mut egui::Ui, s: &mut String) -> bool { + let r = ui.text_edit_singleline(s); + r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) +} + fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usize) { egui::ComboBox::from_id_salt(id).selected_text(labels.get(*sel).copied().unwrap_or("")).width(FIELD_W).show_ui(ui, |ui| { for (i, l) in labels.iter().enumerate() { @@ -408,7 +431,11 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) { } 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(FIELD_W, 22.0)); + // Not in the Tab order: Tab moves between values. + let b = egui::Button::new(text) + .fill(if active { t.accent_soft } else { Color32::TRANSPARENT }) + .min_size(vec2(FIELD_W, 22.0)) + .sense(egui::Sense::CLICK); if ui.add(b).on_hover_text("Click to make this the input that viewport picks go to").clicked() { activate = Some(i); } @@ -445,6 +472,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let mut keep = true; let mut ok = false; let mut cancel = false; + let mut enter = false; let wide = matches!(d.kind, Kind::Params { .. }); let heading = if d.editing.is_some() { format!("EDIT {}", title(&d.kind)) } else { title(&d.kind).to_string() }; let frame = egui::Frame::window(&ctx.global_style()).corner_radius(egui::CornerRadius { nw: 6, sw: 6, ne: 0, se: 0 }).shadow(egui::Shadow { @@ -478,7 +506,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { combo(ui, "ex_dir", &DIR_LABELS, direction); ui.end_row(); ui.label("Distance"); - ui.text_edit_singleline(distance); + enter |= field(ui, distance); ui.end_row(); ui.label("Operation"); combo(ui, "ex_op", &OP_LABELS, operation); @@ -486,7 +514,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } Kind::Revolve { angle, operation } => { ui.label("Angle"); - ui.text_edit_singleline(angle); + enter |= field(ui, angle); ui.end_row(); ui.label("Operation"); combo(ui, "rv_op", &OP_LABELS, operation); @@ -494,7 +522,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } Kind::Fillet { radius, chamfer, chain } => { ui.label(if *chamfer { "Distance" } else { "Radius" }); - ui.text_edit_singleline(radius); + enter |= field(ui, radius); ui.end_row(); ui.label("Tangent chain"); ui.checkbox(chain, ""); @@ -502,12 +530,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } Kind::Shell { thickness } => { ui.label("Inside thickness"); - ui.text_edit_singleline(thickness); + enter |= field(ui, thickness); ui.end_row(); } Kind::Draft { angle } => { ui.label("Angle"); - ui.text_edit_singleline(angle); + enter |= field(ui, angle); ui.end_row(); } Kind::Mirror => {} @@ -516,32 +544,33 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { combo(ui, "hole_kind", &HOLE_LABELS, kind); ui.end_row(); ui.label("Diameter"); - ui.text_edit_singleline(diameter); + enter |= field(ui, diameter); ui.end_row(); ui.label("Depth"); - ui.add(egui::TextEdit::singleline(depth).hint_text("through all")); + let r = ui.add(egui::TextEdit::singleline(depth).hint_text("through all")); + enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); ui.end_row(); if *kind == 1 { ui.label("Counterbore Ø"); - ui.text_edit_singleline(cb_diameter); + enter |= field(ui, cb_diameter); ui.end_row(); ui.label("Counterbore depth"); - ui.text_edit_singleline(cb_depth); + enter |= field(ui, cb_depth); ui.end_row(); } if *kind == 2 { ui.label("Countersink Ø"); - ui.text_edit_singleline(cs_diameter); + enter |= field(ui, cs_diameter); ui.end_row(); ui.label("Countersink angle"); - ui.text_edit_singleline(cs_angle); + enter |= field(ui, cs_angle); ui.end_row(); } } Kind::Primitive { fields, operation, .. } => { for (k, v) in fields.iter_mut() { ui.label(*k); - ui.text_edit_singleline(v); + enter |= field(ui, v); ui.end_row(); } ui.label("Operation"); @@ -617,6 +646,16 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } }); }); + // Keyboard: Enter applies (from a value field, or with nothing focused), Esc cancels. + let (enter_free, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); + let nothing_focused = ctx.memory(|m| m.focused().is_none()); + let canvas_enter = enter_free && ctx.memory(|m| m.had_focus_last_frame(egui::Id::new("sc_canvas_value"))); + if d.previews() && (enter || canvas_enter || (enter_free && nothing_focused)) { + ok = true; + } + if esc { + cancel = true; + } // 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() @@ -768,12 +807,28 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S }; let (cmd, params): (&str, Value) = match &d.kind { Kind::Extrude { distance, direction, operation } => { - need(0, "profiles")?; + need(0, "profiles or a planar face")?; + let common = json!({"distance": distance, "direction": DIRS.get(*direction).copied().unwrap_or("positive"), "operation": OPS.get(*operation).copied().unwrap_or("new")}); + let with = |extra: Value| -> Value { + let mut p = common.clone(); + if let (Value::Object(m), Value::Object(e)) = (&mut p, extra) { + m.extend(e); + for (k, v) in &d.extra { + m.entry(k.clone()).or_insert_with(|| v.clone()); + } + } + p + }; + let mut out = Vec::new(); 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")}), - ) + if !sketch.is_null() { + out.push(("Extrude".to_string(), with(json!({"sketch": sketch, "profiles": idx})))); + } + // Each planar body face extrudes on its own. + for p in face_points(0) { + out.push(("Extrude".to_string(), with(json!({ "face": p })))); + } + return Ok(out); } Kind::Revolve { angle, operation } => { need(0, "profiles")?; diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 2a2bc95d..ac6d01d2 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -29,6 +29,8 @@ pub struct GpuScene { /// Translucent triangles pushed slightly back in depth, so they only show where nothing /// coincides with them (removed material in a preview). pub ghost: Vec, + /// Translucent triangles drawn through everything (a cut's tool body). + pub xray: Vec, } impl GpuScene { @@ -50,6 +52,12 @@ impl GpuScene { } self.ghost.extend_from_slice(&c); } + pub fn xray_tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) { + for v in p.iter().chain(&n) { + self.xray.extend_from_slice(&v.to_le_bytes()); + } + self.xray.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) { @@ -93,6 +101,7 @@ struct Batches { lines: Option, overlays: Option, ghost: Option, + xray: Option, } impl Batches { @@ -106,6 +115,7 @@ impl Batches { self.lines = upload(device, "sc_lines", &sc.lines, LINE_SIZE); self.overlays = upload(device, "sc_overlay", &sc.overlay, LINE_SIZE); self.ghost = upload(device, "sc_ghost", &sc.ghost, TRI_SIZE); + self.xray = upload(device, "sc_xray", &sc.xray, TRI_SIZE); self.key = Some(key); } } @@ -117,6 +127,7 @@ struct Resources { tri_hl: wgpu::RenderPipeline, trans: wgpu::RenderPipeline, ghost: wgpu::RenderPipeline, + xray: wgpu::RenderPipeline, line: wgpu::RenderPipeline, overlay: wgpu::RenderPipeline, uniform: wgpu::Buffer, @@ -309,6 +320,7 @@ impl Resources { 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), ghost: pipeline("sc_ghost", "vs_ghost", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), + xray: pipeline("sc_xray", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Always, 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, @@ -392,6 +404,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { tris(pass, &res.ghost, &pv.ghost); tris(pass, &res.trans, &m.trans); tris(pass, &res.trans, &h.trans); + tris(pass, &res.xray, &pv.xray); lines(pass, &res.overlay, &m.overlays); lines(pass, &res.overlay, &h.overlays); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 80eeff9d..a7ec23f3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -9,6 +9,7 @@ #![forbid(unsafe_code)] pub mod browser; +pub mod canvas; pub mod control; pub mod dialogs; pub mod gpu; diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs index 00a50329..7f6213de 100644 --- a/crates/ui-egui/src/preview.rs +++ b/crates/ui-egui/src/preview.rs @@ -161,36 +161,75 @@ fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, V } /// Evaluate the commands on the scratch session and build the preview scene. -/// When the feature itself fails (a boolean the kernel can't do yet), the tool body alone is -/// shown with the error, so the shape can still be judged. +/// Features that add or remove a tool body (extrude, revolve, primitives with join, cut or +/// intersect) show the tool itself: tinted for join, see-through red for cut. Other features +/// show their result in place of the bodies they change. When the feature fails (a boolean the +/// kernel can't do yet), the tool body is still shown, with the error. pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job { - let (p, error) = match s.preview(cmds) { - Ok(p) => (p, None), - Err(e) => match as_new_body(cmds).and_then(|alt| s.preview(&alt).ok()) { - Some(p) => (p, Some(e.to_string())), - None => return Err(e.to_string()), - }, - }; - let mut b = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| build(&p.before, &p.after, colors))) - .map_err(|_| "the preview failed".to_string())?; - b.error = error; - Ok(b) + let real = s.preview(cmds); + let guard = |f: &dyn Fn() -> Built| std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|_| "the preview failed".to_string()); + match as_new_body(cmds) { + Some((alt, op)) => { + let tool = match s.preview(&alt) { + Ok(t) => t, + Err(e) => return Err(real.err().map_or_else(|| e.to_string(), |r| r.to_string())), + }; + let mut b = guard(&|| build_tool(&tool.before, &tool.after, colors, &op))?; + b.error = real.err().map(|e| e.to_string()); + Ok(b) + } + None => { + let p = real.map_err(|e| e.to_string())?; + guard(&|| build(&p.before, &p.after, colors)) + } + } } -/// The same commands making a new body instead of joining, cutting or intersecting. -fn as_new_body(cmds: &[(String, Value)]) -> Option> { +/// The tool bodies a join/cut/intersect adds: opaque and tinted for join, see-through for cut +/// (red) and intersect. +fn build_tool(before: &ModelState, after: &ModelState, c: Colors, op: &str) -> Built { + let mut sc = GpuScene::default(); + for a in after.bodies.iter().filter(|a| before.body(&a.name).is_none()) { + let m = a.mesh(); + for t in &m.triangles { + for k in t { + let i = *k as usize; + if let (Some(p), Some(n)) = (m.positions.get(i), m.normals.get(i)) { + match op { + "cut" => sc.xray_tri(p.to_f32(), n.to_f32(), c.removed), + "intersect" => sc.xray_tri(p.to_f32(), n.to_f32(), [c.added[0], c.added[1], c.added[2], c.removed[3]]), + _ => sc.tri(p.to_f32(), n.to_f32(), c.added), + } + } + } + } + let edge = if op == "cut" { [c.removed[0], c.removed[1], c.removed[2], 255] } else { c.edge }; + for (ei, e) in m.edges.iter().enumerate() { + if !m.seams.get(ei).copied().unwrap_or(false) { + for w in e.windows(2) { + sc.line(w[0].to_f32(), w[1].to_f32(), edge, 1.3, op != "join"); + } + } + } + } + Built { scene: sc, replaced: Vec::new(), error: None } +} + +/// The same commands making a new body instead of joining, cutting or intersecting, and the +/// operation they had. +fn as_new_body(cmds: &[(String, Value)]) -> Option<(Vec<(String, Value)>, String)> { let mut out = cmds.to_vec(); - let mut changed = false; + let mut changed: Option = None; for (_, p) in &mut out { let p = if p.get("command").is_some() { p.get_mut("params")? } else { p }; if let Some(op) = p.get_mut("operation") - && op.as_str().is_some_and(|o| o != "new") + && let Some(o) = op.as_str().filter(|o| *o != "new").map(str::to_string) { *op = Value::from("new"); - changed = true; + changed = Some(o); } } - changed.then_some(out) + changed.map(|op| (out, op)) } fn same(a: &Mesh, b: &Mesh) -> bool { @@ -333,6 +372,19 @@ mod tests { assert_eq!(s.undo.len(), undo); } + /// A cut shows its tool see-through and replaces nothing. + #[test] + fn cut_preview_scene() { + let mut s = plate(); + s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap(); + s.execute("CircleCenterRadius", &json!({"center": [20, 15], "radius": 5})).unwrap(); + s.execute("SketchStop", &json!({})).unwrap(); + let b = compute(&s, &[("Extrude".into(), json!({"distance": 20, "operation": "cut"}))], colors()).unwrap(); + assert!(b.replaced.is_empty()); + assert!(b.scene.tris.is_empty() && !b.scene.xray.is_empty()); + assert!(b.error.is_none()); + } + /// A new body is all new surface and replaces nothing; a bad value is an error. #[test] fn extrude_preview_scene() { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 6b99b6f2..b1cec5f4 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -54,6 +54,8 @@ pub struct Tokens { // ---- bodies and live previews ---- pub body: Color32, pub body_edge: Color32, + /// Command manipulators (direction arrows, handles). + pub manipulator: Color32, /// New surface in a feature preview. pub preview_add: Color32, /// Removed material in a feature preview (translucent). @@ -104,6 +106,7 @@ impl Tokens { construction_plane: Color32::from_rgba_unmultiplied_const(249, 184, 134, 70), body: Color32::from_rgb(176, 186, 198), body_edge: Color32::from_rgb(40, 44, 52), + manipulator: Color32::from_rgb(40, 110, 220), preview_add: Color32::from_rgb(112, 160, 226), preview_cut: Color32::from_rgba_unmultiplied_const(232, 84, 44, 110), box_window: Color32::from_rgb(38, 120, 218), diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index ffaa6e91..f439efda 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -901,6 +901,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if let Some(tl) = app.tool.as_ref() { crate::tools::preview(app, tl, &painter, &proj); } + crate::canvas::show(app, ui, &painter, &proj); view_cube(app, ui, rect); nav_bar(app, ui, rect); // Status chip.