diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0a3646ed..59d6be62 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -117,6 +117,12 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, let focus = std::mem::take(&mut d.focus); let half_height = app.cam.half_height(); let axis = revolve_axis(app, d); + // Two sides: the second distance gets its own arrow the other way. + if let (Kind::Extrude { direction: crate::dialogs::TWO_SIDES, distance2, .. }, Some(n)) = (&mut d.kind, dir) + && let Some(l2) = app.session.doc.eval(distance2, ValueKind::Length).ok().filter(|v| v.is_finite()) + { + drag_arrow(ui, painter, proj, base, bs, -n, l2, false, egui::Id::new("sc_manipulator2"), half_height, distance2); + } 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); @@ -127,36 +133,11 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, } if let (Some(n), Some(l)) = (dir, len.filter(|_| kind == ValueKind::Length)) { let n = n * sign; - // A short arrow still shows (and can be grabbed) when the value is near zero. - let min = 40.0 * 2.0 * half_height / f64::from(proj.rect.height().max(1.0)); - let shown = if l.abs() < min { min * if l < 0.0 { -1.0 } else { 1.0 } } else { l }; - let tip = base + n * shown; - if let Some(ts) = proj.to_screen(tip) { - let handle = egui::Rect::from_center_size(ts, vec2(20.0, 20.0)); - let r = ui.interact(handle, egui::Id::new("sc_manipulator"), egui::Sense::drag()); - let hot = r.hovered() || r.dragged(); - let col = if hot { t.accent } else { t.manipulator }; - arrow(painter, bs, ts, col); - if symmetric && let Some(bt) = proj.to_screen(base - n * shown) { - arrow(painter, bs, bt, col); - } - painter.circle(bs, 3.5, col, Stroke::new(1.0, Color32::WHITE)); - if hot { - painter.circle_stroke(ts, 8.0, Stroke::new(1.5, col)); - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); - } - if r.dragged() - && let Some(p) = r.interact_pointer_pos() - && let Some(v) = along(proj, p, base, n) - { - let free = ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command); - let step = snap_step(half_height); - let mut v = if free { v } else { (v / step).round() * step }; - if !signed { - v = v.max(if free { 1e-3 } else { step }); - } - *value = format_len(v); + if let Some(ts) = drag_arrow(ui, painter, proj, base, bs, n, l, signed, egui::Id::new("sc_manipulator"), half_height, value) { + 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); } } @@ -178,6 +159,49 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, }); } +/// A draggable arrow from `base` along `n` showing `l` (at least a short stub); dragging writes +/// the snapped length into `value`. Returns the tip's screen position. +#[allow(clippy::too_many_arguments)] +fn drag_arrow( + ui: &mut egui::Ui, + painter: &egui::Painter, + proj: &Proj, + base: Vec3, + bs: Pos2, + n: Vec3, + l: f64, + signed: bool, + id: egui::Id, + half_height: f64, + value: &mut String, +) -> Option { + let t = Tokens::get(); + let min = 40.0 * 2.0 * half_height / f64::from(proj.rect.height().max(1.0)); + let shown = if l.abs() < min { min * if l < 0.0 { -1.0 } else { 1.0 } } else { l }; + let ts = proj.to_screen(base + n * shown)?; + let r = ui.interact(egui::Rect::from_center_size(ts, vec2(20.0, 20.0)), id, egui::Sense::drag()); + let hot = r.hovered() || r.dragged(); + let col = if hot { t.accent } else { t.manipulator }; + arrow(painter, bs, ts, col); + if hot { + painter.circle_stroke(ts, 8.0, Stroke::new(1.5, col)); + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + if r.dragged() + && let Some(p) = r.interact_pointer_pos() + && let Some(v) = along(proj, p, base, n) + { + let free = ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command); + let step = snap_step(half_height); + let mut v = if free { v } else { (v / step).round() * step }; + if !signed { + v = v.max(if free { 1e-3 } else { step }); + } + *value = format_len(v); + } + Some(ts) +} + /// The revolve axis (a point on it and its unit direction). fn revolve_axis(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Vec3)> { if !matches!(d.kind, Kind::Revolve { .. }) { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1ca07136..747644bc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -19,8 +19,10 @@ use crate::viewport::Hit; 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 DIRS: [&str; 4] = ["positive", "negative", "symmetric", "positive"]; +const DIR_LABELS: [&str; 4] = ["One side", "Flip", "Symmetric", "Two sides"]; +/// The "Two sides" direction (a second distance the other way). +pub const TWO_SIDES: usize = 3; const HOLE_TYPES: [&str; 3] = ["simple", "counterbore", "countersink"]; const HOLE_LABELS: [&str; 3] = ["Simple", "Counterbore", "Countersink"]; @@ -35,6 +37,10 @@ pub enum Kind { /// The operation follows the geometry (into a body: cut, out of one: join, else new) /// until the user picks one. auto_op: bool, + /// The other side's distance (two sides). + distance2: String, + /// Taper angle (empty: none). + taper: String, }, Revolve { angle: String, @@ -157,7 +163,14 @@ impl Dialog { 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), auto_op: true }, + Kind::Extrude { + distance: "10 mm".into(), + direction: 0, + operation: usize::from(has_bodies), + auto_op: true, + distance2: "10 mm".into(), + taper: String::new(), + }, vec![SelInput::new("Profiles", PROFILES | PLANAR_FACES, true)], ), "Revolve" => Dialog::new( @@ -594,13 +607,23 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { input_rows(&mut d, ui); match &mut d.kind { Kind::Sketch => {} - Kind::Extrude { distance, direction, operation, auto_op } => { + Kind::Extrude { distance, direction, operation, auto_op, distance2, taper } => { ui.label("Direction"); combo(ui, "ex_dir", &DIR_LABELS, direction); ui.end_row(); ui.label("Distance"); enter |= field(ui, distance); ui.end_row(); + if *direction == TWO_SIDES { + ui.label("Distance 2"); + enter |= field(ui, distance2); + ui.end_row(); + } else if *direction < 2 { + ui.label("Taper angle"); + let r = ui.add(egui::TextEdit::singleline(taper).hint_text("0 deg")); + enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.end_row(); + } ui.label("Operation"); let before = *operation; combo(ui, "ex_op", &OP_LABELS, operation); @@ -938,9 +961,14 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S 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, .. } => { + Kind::Extrude { distance, direction, operation, distance2, taper, .. } => { 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 mut common = json!({"distance": distance, "direction": DIRS.get(*direction).copied().unwrap_or("positive"), "operation": OPS.get(*operation).copied().unwrap_or("new")}); + if *direction == TWO_SIDES { + common["distance2"] = json!(distance2); + } else if *direction < 2 && !taper.trim().is_empty() { + common["taper"] = json!(taper); + } let with = |extra: Value| -> Value { let mut p = common.clone(); if let (Value::Object(m), Value::Object(e)) = (&mut p, extra) { @@ -1284,22 +1312,23 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option 0, - Direction::Negative => 1, - Direction::Symmetric => 2, + direction: match (extent.direction, &extent.distance2) { + (_, Some(_)) => TWO_SIDES, + (Direction::Positive, None) => 0, + (Direction::Negative, None) => 1, + (Direction::Symmetric, None) => 2, }, operation: op_index(operation), auto_op: false, + distance2: extent.distance2.clone().unwrap_or_else(|| "10 mm".into()), + taper: extent.taper.clone().unwrap_or_default(), }; let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default(); if let Some(inp) = d.inputs.first_mut() { inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect(); } for (k, v) in [ - ("distance2", extent.distance2.as_ref().map(|x| json!(x))), ("start_offset", extent.start_offset.as_ref().map(|x| json!(x))), - ("taper", extent.taper.as_ref().map(|x| json!(x))), ("through_all", extent.through_all.then_some(json!(true))), ("targets", (!targets.is_empty()).then(|| json!(targets))), ] { diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index ac6d01d2..0664014d 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -31,6 +31,9 @@ pub struct GpuScene { pub ghost: Vec, /// Translucent triangles drawn through everything (a cut's tool body). pub xray: Vec, + /// Lines drawn first, under everything (the ground grid: the model hides it, it never + /// hides the model). + pub under: Vec, } impl GpuScene { @@ -58,6 +61,14 @@ impl GpuScene { } self.xray.extend_from_slice(&c); } + /// A line under everything (see [`GpuScene::under`]). + pub fn under_line(&mut self, a: [f32; 3], b: [f32; 3], c: [u8; 4], width: f32) { + for v in a.iter().chain(&b) { + self.under.extend_from_slice(&v.to_le_bytes()); + } + self.under.extend_from_slice(&c); + self.under.extend_from_slice(&width.to_le_bytes()); + } 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) { @@ -102,6 +113,7 @@ struct Batches { overlays: Option, ghost: Option, xray: Option, + under: Option, } impl Batches { @@ -116,6 +128,7 @@ impl Batches { 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.under = upload(device, "sc_under", &sc.under, LINE_SIZE); self.key = Some(key); } } @@ -395,6 +408,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { } }; let (m, h, pv) = (&res.model, &res.highlight, &res.preview); + lines(pass, &res.overlay, &m.under); tris(pass, &res.tri, &m.tris); tris(pass, &res.tri, &pv.tris); tris(pass, &res.tri_hl, &h.tris); diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs index 7f6213de..c6e2b029 100644 --- a/crates/ui-egui/src/preview.rs +++ b/crates/ui-egui/src/preview.rs @@ -11,7 +11,7 @@ use std::sync::mpsc::{Receiver, TryRecvError}; use serde_json::Value; use solvecraft_engine::Session; use solvecraft_engine::doc::ModelState; -use solvecraft_engine::geom::{Mesh, Vec3}; +use solvecraft_engine::geom::{Aabb3, Mesh, Vec3}; use crate::SolveApp; use crate::gpu::{GpuScene, SceneSlot}; @@ -39,6 +39,8 @@ pub struct Built { pub replaced: Vec, /// Why the feature failed, when the scene only shows its tool body. pub error: Option, + /// Extent of what the preview shows (the view's depth range must cover it). + pub bounds: Aabb3, } type Job = Result; @@ -59,6 +61,8 @@ pub struct PreviewState { pub error: Option, /// A preview is being computed. pub busy: bool, + /// Extent of the preview scene. + pub bounds: Aabb3, /// Time the last preview took (ms), for `ui.inspect`. pub ms: f64, started: f64, @@ -72,6 +76,7 @@ impl PreviewState { self.busy = false; if self.active || !self.replaced.is_empty() { self.active = false; + self.bounds = Aabb3::EMPTY; self.replaced.clear(); self.set_scene(GpuScene::default()); } @@ -90,6 +95,7 @@ impl PreviewState { match job { Ok(b) => { self.replaced = b.replaced; + self.bounds = b.bounds; self.active = true; self.error = b.error; self.set_scene(b.scene); @@ -176,15 +182,22 @@ pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job { }; let mut b = guard(&|| build_tool(&tool.before, &tool.after, colors, &op))?; b.error = real.err().map(|e| e.to_string()); + b.bounds = state_bounds(&tool.after); Ok(b) } None => { let p = real.map_err(|e| e.to_string())?; - guard(&|| build(&p.before, &p.after, colors)) + let mut b = guard(&|| build(&p.before, &p.after, colors))?; + b.bounds = state_bounds(&p.after); + Ok(b) } } } +fn state_bounds(st: &ModelState) -> Aabb3 { + st.bodies.iter().fold(Aabb3::EMPTY, |b, x| b.union(&x.mesh().bounds())) +} + /// 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 { @@ -212,7 +225,7 @@ fn build_tool(before: &ModelState, after: &ModelState, c: Colors, op: &str) -> B } } } - Built { scene: sc, replaced: Vec::new(), error: None } + Built { scene: sc, replaced: Vec::new(), error: None, bounds: Aabb3::EMPTY } } /// The same commands making a new body instead of joining, cutting or intersecting, and the @@ -288,7 +301,7 @@ pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built { replaced.push(b.name.clone()); } } - Built { scene: sc, replaced, error: None } + Built { scene: sc, replaced, error: None, bounds: Aabb3::EMPTY } } /// Is face `f` of `m` new surface, i.e. not on the old mesh? Decided by a vote of points on it diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 2eb1cbb2..8e2ad77c 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -117,7 +117,10 @@ fn rgba(c: Rgb) -> [u8; 4] { } pub fn scene_radius(app: &SolveApp) -> f64 { - let b = solvecraft_engine::view::bounds(&app.session); + let mut b = solvecraft_engine::view::bounds(&app.session); + if !app.preview.bounds.is_empty() { + b = b.union(&app.preview.bounds); + } let r = if b.is_empty() { 50.0 } else { b.diagonal() * 0.5 + b.center().dist(app.cam.target) }; r.max(app.cam.half_height() * 2.0).max(10.0) } @@ -181,8 +184,8 @@ fn build_scene(app: &SolveApp) -> GpuScene { let maj = (t / major).round() * major == t; let col = if maj { c4(tk.grid_major) } else { c4(tk.grid) }; let w = if maj { 1.0 } else { 0.7 }; - sc.line((c + Vec3::new(t, -ext, 0.0)).to_f32(), (c + Vec3::new(t, ext, 0.0)).to_f32(), col, w, false); - sc.line((c + Vec3::new(-ext, t, 0.0)).to_f32(), (c + Vec3::new(ext, t, 0.0)).to_f32(), col, w, false); + sc.under_line((c + Vec3::new(t, -ext, 0.0)).to_f32(), (c + Vec3::new(t, ext, 0.0)).to_f32(), col, w); + sc.under_line((c + Vec3::new(-ext, t, 0.0)).to_f32(), (c + Vec3::new(ext, t, 0.0)).to_f32(), col, w); } } for b in &st.bodies {