//! 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, and an arrow //! (extrude, fillet, shell, move) that can be dragged: values snap to round steps (hold Alt or //! Ctrl for a free drag) and the dialog follows. 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 solidcraft_engine::Sel; use solidcraft_engine::doc::expr::Kind as ValueKind; use solidcraft_engine::geom::{Mesh, Vec2, Vec3}; use crate::SolveApp; use crate::dialogs::{Dialog, Kind}; use crate::theme::Tokens; use crate::viewport::{Proj, projected_polygon}; /// Where the dialog's geometry is: a base point and, for values that can be dragged, the /// direction a growing value points. pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { let st = app.session.world_state(); let first = d.inputs.first()?.items.first()?; let (at, normal) = match first { Sel::Profile { sketch, index } => { let ss = st.sketch(*sketch)?; let plane = profile_plane(app, *sketch)?; let p = ss.profiles.get(*index)?; let centre = if matches!(d.kind, Kind::Extrude { .. }) { let regions: Vec<_> = d .inputs .first()? .items .iter() .filter_map(|item| { if let Sel::Profile { sketch: id, index } = item { (*id == *sketch).then(|| ss.profiles.get(*index)).flatten() } else { None } }) .map(|p| p.region.clone()) .collect(); let regions = solidcraft_engine::sketch::merge_regions(®ions); let points: Vec<_> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect(); points.iter().fold(solidcraft_engine::geom::Vec2::default(), |sum, p| sum + *p) * (1.0 / points.len().max(1) as f64) } else { p.region.interior_point() }; (plane.to_world(centre), Some(plane.normal())) } Sel::Face { body, index, point } => (*point, crate::dialogs::planar_face(&app.session, body, *index).map(|(_, n)| n)), Sel::Edge { body, index, point } => (*point, st.body(body).and_then(|b| edge_bisector(&b.mesh(), *index, *point))), Sel::Vertex { point, .. } => (*point, None), Sel::SketchCurve { id } => { let ss = st.sketches.iter().rev().find(|ss| ss.sketch.curve_index(id).is_some())?; let pts = ss.sketch.polyline(ss.sketch.curve_index(id)?); let (mid, tangent) = poly_mid(&pts)?; // An offset's drag arrow: the in-plane direction the distance grows along. The // command offsets a picked curve to its left, and the window's Flip row reverses it. // Symmetric hides Flip and offsets both ways, so the arrow keeps the left side. let dir = match &d.kind { Kind::SketchModify(crate::sketch_modify_ui::Mod::Offset { flip, mode, .. }) => { let n = if *flip && !crate::sketch_modify_ui::offset_symmetric(*mode) { -tangent.perp() } else { tangent.perp() }; Some(ss.plane.to_world(mid + n) - ss.plane.to_world(mid)) } // A thin extrude's curves extrude along their sketch's normal. Kind::Extrude { .. } => Some(ss.plane.normal()), _ => None, }; (ss.plane.to_world(mid), dir) } // A corner picked into a sketch window (":", or a bare id when it // was selected outside one): the value box sits on the corner itself. Sel::SketchPoint { id } => { let (sketch, point) = match id.split_once(':') { Some((sketch, point)) => (sketch.parse().ok()?, point), None => (app.session.active_sketch?, id.as_str()), }; let ss = st.sketch(sketch)?; (ss.plane.to_world(ss.sketch.point(ss.sketch.resolve_point(point)?)?), None) } Sel::Body { name } => (st.body(name)?.mesh().bounds().center(), None), Sel::Plane { name } => { let pl = solidcraft_engine::geom::Plane::named(name).or_else(|| { solidcraft_engine::view::construction_planes(&app.session).into_iter().find(|(_, n, _)| n == name).map(|(_, _, pl)| pl) })?; (pl.origin, Some(pl.normal())) } _ => return None, }; Some(match &d.kind { Kind::Extrude { start, .. } => { let n = normal?; let plane = solidcraft_engine::geom::Plane::from_normal(at, n)?; let from = reference_height(app, d, "From Object", &plane, at).unwrap_or(0.0); let offset = if start.trim().is_empty() { 0.0 } else { app.session.doc.eval(start, ValueKind::Length).ok()? }; (at + n * (from + offset), Some(n)) } // A hole on a curved face drills against the face's normal where it was picked. Kind::Hole { .. } if matches!(first, Sel::Face { .. }) => { (at, normal.or_else(|| solidcraft_engine::doc::hole_direction_at(&st, at).map(|d| -d))) } // Holes at sketch points go into the part the sketch touches (or away, flipped): the // arrow shows which way. Kind::Hole { .. } => { let mut by_sketch: Vec<(u64, Vec)> = Vec::new(); for x in &d.inputs.first()?.items { if let Sel::SketchPoint { id } = x && let Some((sk, pid)) = id.split_once(':') && let Ok(sk) = sk.parse::() { match by_sketch.iter_mut().find(|(s, _)| *s == sk) { Some((_, v)) => v.push(pid.to_string()), None => by_sketch.push((sk, vec![pid.to_string()])), } } } let flip = d.extra.get("flip").and_then(serde_json::Value::as_bool).unwrap_or(false); let dir = by_sketch.first().and_then(|(sk, ids)| solidcraft_engine::doc::sketch_hole_direction(&st, *sk, ids, flip)); (at, normal.or(dir.map(|d| -d))) } Kind::Section { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal), // Fillet radii, chamfer distances and shell thickness grow into the body. Kind::Fillet { .. } | Kind::Shell { .. } => (at, normal.map(|n| -n)), Kind::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)), Kind::Plastic(_) => (at, normal), Kind::PatternRect { .. } => (at, d.inputs.get(1).and_then(|i| i.items.first()).and_then(|x| crate::dialogs::axis_of(app, x)).map(|a| a.1)), // A sketch move's X Distance grows along the sketch's own x axis, so its drag arrow // lies in the sketch plane. Scale has no arrow: a factor is not a length, and the // square handle it wants is its own change. Kind::SketchModify(crate::sketch_modify_ui::Mod::Move { .. }) => { (at, app.session.active_sketch.and_then(|id| st.sketch(id)).and_then(|ss| ss.plane.x.normalized())) } // The rectangular pattern's spacing grows along the direction it was given, which the // module works out per variant. Offset's arrow is already computed above, per picked // curve, and `arrow` returns None for it so that one stands. Kind::SketchModify(_) => (at, crate::sketch_modify_ui::arrow(app, d).or(normal)), _ => (at, None), }) } /// The point halfway along a polyline, with the unit tangent there. Indexing the middle sample /// instead would land on a two-point line's far end, putting the value box and its arrow on a /// corner. None for a polyline with no length. fn poly_mid(poly: &[Vec2]) -> Option<(Vec2, Vec2)> { let total: f64 = poly.windows(2).map(|w| w[0].dist(w[1])).sum(); if !total.is_finite() || total <= 0.0 { return None; } let half = total / 2.0; let mut walked = 0.0; let mut last = None; for w in poly.windows(2) { let (a, b) = (w[0], w[1]); let Some(dir) = (b - a).normalized() else { continue }; let len = a.dist(b); if walked + len >= half { return Some((a + dir * (half - walked), dir)); } walked += len; last = Some((b, dir)); } last } fn profile_plane(app: &SolveApp, sketch: u64) -> Option { let component = app.session.doc.feature(sketch)?.component; let state = app.session.model.state(); let plane = state.sketch(sketch)?.plane; let frame = crate::dialogs::feature_frame(&app.session, component); solidcraft_engine::geom::Plane::new( solidcraft_engine::doc::apply_point(&frame, plane.origin), solidcraft_engine::doc::apply_vector(&frame, plane.x), solidcraft_engine::doc::apply_vector(&frame, plane.y), ) } fn reference_height(app: &SolveApp, d: &Dialog, label: &str, plane: &solidcraft_engine::geom::Plane, centre: Vec3) -> Option { let point = crate::dialogs::object_point(&d.inputs, label)?; let p = point.as_array()?; let p = Vec3::new(p.first()?.as_f64()?, p.get(1)?.as_f64()?, p.get(2)?.as_f64()?); Some(solidcraft_engine::doc::object_height(&app.session.world_state(), plane, centre, p)) } /// The outward direction between the two faces at an edge (a fillet's radius arrow points the /// other way, into the body). fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option { let faces = m.edge_faces.get(index)?; let mut sum = Vec3::ZERO; for f in faces { let n = m .triangles .iter() .zip(&m.tri_face) .filter(|(_, tf)| *tf == f) .filter_map(|(t, _)| m.tri(t)) .min_by(|a, b| ((a[0] + a[1] + a[2]) / 3.0).dist(p).total_cmp(&((b[0] + b[1] + b[2]) / 3.0).dist(p))) .and_then(|[a, b, c]| (b - a).cross(c - a).normalized())?; sum += n; } sum.normalized() } /// Draw the manipulator 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 matches!(d.kind, Kind::Assembly(_)) { crate::dialogs_assembly::overlay(app, painter, proj, &mut d); app.dialog = Some(d); return; } if crate::dialogs_sheet::overlay(app, painter, proj.rect, &d) { app.dialog = Some(d); return; } if let Kind::Measure { result: Some(r), .. } = &d.kind { measure_line(app, painter, proj, r); app.dialog = Some(d); return; } // Move: the triad at the pivot; dragging it sets the distances and angles. Its value box // sits beside it and reads the move's main value, so a drag shows its number and typing // one sets it. The triad is the manipulator here, so the box comes without the drag arrow // every other dialog's value gets (`dir` is None below). if matches!(d.kind, Kind::Move { .. }) { let mut dragging = false; if let Some(t) = crate::dialogs_move::triad(app, &d) && let Some(g) = crate::gizmo::show(ui, painter, proj, egui::Id::new("sc_move_triad"), &t, snap_step(app.cam.half_height())) { crate::dialogs_move::drag(app, &mut d, g); dragging = true; } // Read the triad again after the drag, so the box follows it in the same frame. Move // types with no triad (Rotate, Point to Point, Point to Position) put the box on the // picked bodies instead. let triad = crate::dialogs_move::triad(app, &d); let at = triad.map(|t| t.center).or_else(|| anchor(app, &d).map(|(base, _)| base)); let focus = std::mem::take(&mut d.focus); if let Some(bs) = at.and_then(|base| proj.to_screen(base)) && let Some((label, _, value)) = d.primary() { // Clear of the triad: its arrows reach LEN_PX and its rings RING of that, so a box // beside the centre would cover the handles the user is reaching for. let off = if triad.is_some() { vec2(crate::gizmo::LEN_PX as f32 + 16.0, -12.0) } else { vec2(16.0, -30.0) }; value_box(ui, label, value, bs + off, focus || dragging); } app.dialog = Some(d); 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); } /// A round step for dragged values at this zoom (1, 2 or 5 times a power of ten). pub fn snap_step(half_height: f64) -> f64 { let raw = (half_height / 40.0).max(1e-4); let p = 10f64.powf(raw.log10().floor()); if raw / p < 2.0 { p } else if raw / p < 5.0 { 2.0 * p } else { 5.0 * p } } /// Parameter along the line `base + dir * t` closest to the view ray through `pos`. fn along(proj: &Proj, pos: Pos2, base: Vec3, dir: Vec3) -> Option { let (o, r) = proj.ray(pos); let w0 = base - o; let (b, d, e) = (dir.dot(r), dir.dot(w0), r.dot(w0)); let den = 1.0 - b * b; // Looking straight along the line: nothing to drag. if den.abs() < 1e-6 { return None; } Some((b * e - d) / den) } fn format_len(v: f64) -> String { let s = format!("{v:.3}"); let s = s.trim_end_matches('0').trim_end_matches('.'); format!("{} mm", if s == "-0" { "0" } else { s }) } /// The offset plane preview: a square of half side `h` around `c`, square to `n`. fn offset_plane_quad(proj: &Proj, c: Vec3, n: Vec3, h: f64) -> Option> { let u = n.cross(if n.z.abs() < 0.9 { Vec3::Z } else { Vec3::X }).normalized()?; let v = n.cross(u); [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].iter().map(|(a, b)| proj.to_screen(c + u * (a * h) + v * (b * h))).collect() } #[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 to_span = dir.and_then(|n| { let plane = solidcraft_engine::geom::Plane::from_normal(base, n)?; reference_height(app, d, "To Object", &plane, base) }); let all_span = if matches!(d.kind, Kind::Extrude { all: true, .. }) { dir.and_then(|n| { let state = app.session.world_state(); let plane = match d.inputs.first()?.items.first()? { Sel::Profile { sketch, .. } => profile_plane(app, *sketch)?, _ => solidcraft_engine::geom::Plane::from_normal(base, n)?, }; let targets: Vec = d .extra .get("targets") .and_then(serde_json::Value::as_array) .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()) .unwrap_or_default(); solidcraft_engine::doc::through_all_distance(&state, &plane, &targets).ok() }) } else { None }; let sign = match (to_span, &d.kind) { (Some(span), _) => { if span < 0.0 { -1.0 } else { 1.0 } } (_, Kind::Extrude { direction: 1, .. }) => -1.0, _ => 1.0, }; let symmetric = to_span.is_none() && matches!(d.kind, Kind::Extrude { direction: 2, .. }); // Extrudes and moves go either way; radii and thicknesses stay positive. let signed = matches!( d.kind, Kind::Extrude { .. } | Kind::Move { .. } | Kind::Section { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } | Kind::SketchModify(crate::sketch_modify_ui::Mod::Move { .. }) ); let focus = std::mem::take(&mut d.focus); let half_height = app.cam.half_height(); let axis = revolve_axis(app, d); if matches!(d.kind, Kind::Revolve { .. }) { if let Some(axis) = axis { draw_revolve(app, ui, painter, proj, d, base, bs, axis, focus); } return; } // 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, true, egui::Id::new("sc_manipulator2"), half_height, distance2); } // An offset plane shows where it will be. if let (Kind::OffsetPlane { offset }, Some(n)) = (&d.kind, dir) && let Ok(off) = app.session.doc.eval(offset, ValueKind::Length) && let Some(q) = offset_plane_quad(proj, base + n * off, n, half_height * 0.25) { painter.extend(projected_polygon(&q, t.construction_plane, Stroke::new(1.2, t.origin_plane_edge))); } // Holes: the depth arrow points into the material (empty depth: through all); the // diameter is typed in the box. let mut dir = dir; // Extrude taper: a handle beside the arrow tip; dragging it sideways tilts the walls. let cam_right = app.cam.basis().0; if let (Kind::Extrude { direction: 0 | 1, distance, taper, taper_present, to_offset, .. }, Some(n)) = (&mut d.kind, dir) { let n = n * sign; let span = to_span.map(|span| span.abs() + app.session.doc.eval(to_offset, ValueKind::Length).unwrap_or(0.0)).or(all_span); if let Ok(l) = span.map(Ok).unwrap_or_else(|| app.session.doc.eval(distance, ValueKind::Length)) && l.is_finite() && l.abs() > 1e-9 && let Some(e) = (cam_right - n * cam_right.dot(n)).normalized() { let px = 2.0 * half_height / f64::from(proj.rect.height().max(1.0)); let ang = app.session.doc.eval(taper, ValueKind::Angle).ok().filter(|v| v.is_finite()).unwrap_or(0.0); let before = taper.clone(); taper_handle(ui, painter, proj, base - e * (40.0 * px), n * l, -e, ang, taper); if *taper != before { *taper_present = true; } } } if let (Kind::Hole { depth, opts, .. }, Some(n)) = (&mut d.kind, dir) { let l = if opts.all { 0.0 } else { app.session.doc.eval(depth, ValueKind::Length).ok().filter(|v| v.is_finite()).unwrap_or(0.0) }; let before = depth.clone(); drag_arrow(ui, painter, proj, base, bs, -n, l, false, egui::Id::new("sc_manipulator_depth"), half_height, depth); // Dragging the depth turns "through all" into a distance. if *depth != before { opts.all = false; } dir = None; } // A fillet with a radius spline is shaped by its handles along the edge; the arrow would sit // on one of them. if radius_spline_handles(app, ui, painter, proj, d) { dir = None; } let Some((label, kind, value)) = d.primary() else { return }; let len = all_span.or_else(|| { if to_span.is_some() && value.trim().is_empty() { Some(0.0) } else { app.session.doc.eval(value, kind).ok().filter(|v| v.is_finite()) } }); let before = value.clone(); 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 baseline = to_span.map(f64::abs).unwrap_or(0.0); let mut shown_value = format_len(l + baseline); let original_shown = shown_value.clone(); let dragged = if to_span.is_some() { &mut shown_value } else { &mut *value }; if let Some(ts) = drag_arrow(ui, painter, proj, base, bs, n, l + baseline, signed, egui::Id::new("sc_manipulator"), half_height, dragged) { if to_span.is_some() && shown_value != original_shown && let Ok(v) = app.session.doc.eval(&shown_value, ValueKind::Length) { *value = format_len(v - baseline); } 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); } } value_box(ui, label, value, box_at, focus || *value != before); if *value != before && let Kind::Extrude { all, .. } = &mut d.kind { *all = false; } } /// The command's main value in the viewport, next to the geometry: the keyboard goes here while /// the window is open, so a number typed straight after a pick sets the value. `at` is its /// top-left corner. `select_all` re-selects the whole field, both right after a pick and on every /// frame a drag is changing the value underneath it: without the latter, a drag that grows the /// text (e.g. "4 mm" -> "15 mm") would leave the stale, shorter selection from the pick in place, /// covering only part of the new text. fn value_box(ui: &mut egui::Ui, label: &str, value: &mut String, at: Pos2, select_all: bool) { let t = Tokens::get(); let id = egui::Id::new("sc_canvas_value"); crate::frame::area(egui::Id::new("sc_canvas_area")).fixed_pos(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)); crate::params_dialog::complete(ui, &r, value); if select_all { 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); } }); }); }); } /// 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)?; crate::scenario::publish_handle(if id == egui::Id::new("sc_manipulator") { "arrow" } else { "arrow2" }, ts); 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) } /// A fillet's radius spline on each of its edges (a spline fillet, or the splines a taper gave /// them): the radius curve drawn into the body from the edge, and a handle per control radius at /// its Greville position, that far from the edge. Dragging a handle sets its control radius /// (snapped unless Alt/Ctrl); double-clicking the curve adds a control radius there without /// changing it, double-clicking a handle between the ends removes it. A spline fillet's edges /// share one spline, so every edge's handles edit it. A taper's handles show on a single edge /// only: the first edit makes the fillet a spline fillet starting from that edge's taper, which /// would put the same radius on every other edge. Whether the handles are shown. fn radius_spline_handles(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, d: &mut Dialog) -> bool { use serde_json::{Value, json}; use solidcraft_engine::doc::kernel::RadiusSpline; enum Edit { Set(usize, f64), Insert(f64), Remove(usize), } let t = Tokens::get(); let Kind::Fillet { chamfer: false, .. } = d.kind else { return false }; let st = app.session.world_state(); let edges: Vec<(std::sync::Arc, usize, Vec3, Vec3)> = d .inputs .first() .into_iter() .flat_map(|i| &i.items) .filter_map(|x| match x { Sel::Edge { body, index, .. } => { let m = st.body(body)?.mesh(); let (a, b) = m.edges.get(*index).and_then(|e| Some((*e.first()?, *e.last()?)))?; Some((m, *index, a, b)) } _ => None, }) .collect(); let Some(&(_, _, a0, b0)) = edges.first() else { return false }; let eval = |e: &str| app.session.doc.eval(e, ValueKind::Length).ok().filter(|v| v.is_finite()); let style = d.extra.get("type").and_then(Value::as_str).unwrap_or("constant").to_string(); // Each edge's spline with the end its positions start from; the spline the handles edit, // whether it starts at the first edge's far end, and whether it is still the taper's. let (laws, base, swap, taper): (Vec>, Option, bool, bool) = if style == "spline" { let Kind::Fillet { radius, .. } = &d.kind else { return false }; let knots: Vec = d.extra.get("knots").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_f64).collect(); let exprs: Vec = d.extra.get("radii").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect(); let Some(radii) = std::iter::once(radius.as_str()).chain(exprs.iter().map(String::as_str)).map(eval).collect::>>() else { return false; }; let spline = solidcraft_engine::doc::FilletStyle::spline(&knots, radii); let swap = d.extra.get("swap_ends").and_then(Value::as_bool).unwrap_or(false); let start = if swap { b0 } else { a0 }; // Each edge runs from its end nearest the start, as the fillet builds it. let laws = edges.iter().map(|(_, _, a, b)| Some((spline.clone(), if start.dist(*a) <= start.dist(*b) { *a } else { *b }))).collect(); (laws, Some(spline), swap, false) } else if style == "constant" && d.extra.get("fit").and_then(Value::as_str) == Some("taper") { // The taper of an edge: its point lies on the edge's chord. An edge that fits has none. let on_edge = |a: Vec3, b: Vec3, p: Vec3| { let ab = b - a; let s = ((p - a).dot(ab) / ab.dot(ab).max(1e-300)).clamp(0.0, 1.0); (a + ab * s).dist(p) <= 1e-6 * (1.0 + ab.len()) }; let laws: Vec> = edges .iter() .map(|(_, _, a, b)| app.preview.tapered.iter().find(|(p, _)| on_edge(*a, *b, *p)).map(|(_, tp)| (tp.spline.clone(), tp.start))) .collect(); let base = laws.first().cloned().flatten().filter(|_| edges.len() == 1); let swap = base.as_ref().is_some_and(|(_, s)| s.dist(b0) < s.dist(a0)); (laws, base.map(|(l, _)| l), swap, true) } else { return false; }; // Each drawn edge: its spline, the ends its positions run between, the direction into the // body, and the curve on screen with its positions. struct Drawn<'a> { spline: &'a RadiusSpline, start: Vec3, other: Vec3, n: Vec3, curve: Vec<(f64, Pos2)>, } let drawn: Vec = edges .iter() .zip(&laws) .filter_map(|((mesh, index, a, b), law)| { let (spline, start) = law.as_ref()?; let other = if start.dist(*a) <= start.dist(*b) { *b } else { *a }; let mid = *start + (other - *start) * 0.5; // Into the body, between the two faces (the edge is straight between planar faces). let n = -edge_bisector(mesh, *index, mid)?; let curve = (0..=48) .filter_map(|k| { let u = f64::from(k) / 48.0; Some((u, proj.to_screen(*start + (other - *start) * u + n * spline.eval(u))?)) }) .collect(); Some(Drawn { spline, start: *start, other, n, curve }) }) .collect(); for e in &drawn { painter.add(egui::Shape::line(e.curve.iter().map(|c| c.1).collect(), Stroke::new(1.5, t.manipulator))); } let Some(base) = base.filter(|_| !drawn.is_empty()) else { return false }; let mut edit = None; // Double-clicking the curve adds a control radius at the nearest position along it. Under // the handles, which are registered after it and so take the pointer first. let near = ui.input(|i| i.pointer.hover_pos()).and_then(|p| { drawn .iter() .flat_map(|e| e.curve.windows(2)) .map(|w| { let ((u0, p0), (u1, p1)) = (w[0], w[1]); let seg = p1 - p0; let s = ((p - p0).dot(seg) / seg.length_sq().max(1e-6)).clamp(0.0, 1.0); (p0.lerp(p1, s).distance(p), u0 + (u1 - u0) * f64::from(s)) }) .min_by(|x, y| x.0.total_cmp(&y.0)) .filter(|(dist, _)| *dist < 6.0) .map(|(_, u)| (p, u)) }); if let Some((p, u)) = near { crate::scenario::publish_handle("fillet_curve", p); if ui.interact(egui::Rect::from_center_size(p, vec2(12.0, 12.0)), egui::Id::new("sc_fillet_curve"), egui::Sense::click()).double_clicked() { edit = Some(Edit::Insert(u)); } } let half_height = app.cam.half_height(); let last = base.radii.len().saturating_sub(1); for (i, e) in drawn.iter().enumerate() { let at = |u: f64| e.start + (e.other - e.start) * u; let g = e.spline.greville(); let tips: Vec> = g.iter().zip(&e.spline.radii).map(|(u, r)| proj.to_screen(at(*u) + e.n * *r)).collect(); painter.add(egui::Shape::dashed_line(&tips.iter().flatten().copied().collect::>(), Stroke::new(1.0, t.manipulator), 4.0, 3.0)); for (k, (u, ts)) in g.iter().zip(&tips).enumerate() { let Some(ts) = *ts else { continue }; crate::scenario::publish_handle(&if i == 0 { format!("fillet_r{k}") } else { format!("fillet_e{i}_r{k}") }, ts); let r = ui.interact(egui::Rect::from_center_size(ts, vec2(16.0, 16.0)), egui::Id::new(("sc_fillet_r", i, k)), egui::Sense::click_and_drag()); let hot = r.hovered() || r.dragged(); let col = if hot { t.accent } else { t.manipulator }; if let Some(fs) = proj.to_screen(at(*u)) { painter.line_segment([fs, ts], Stroke::new(1.0, col)); } painter.circle(ts, if hot { 6.5 } else { 5.0 }, t.overlay, Stroke::new(2.0, col)); if hot { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } if r.double_clicked() { edit = Some(Edit::Remove(k)); } else if r.dragged() && let Some(p) = r.interact_pointer_pos() && let Some(v) = along(proj, p, at(*u), e.n) { let free = ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command); let step = snap_step(half_height); let v = if free { v } else { (v / step).round() * step }; // Zero only at an end, where the round closes to a point. let floor = if k == 0 || k == last { 0.0 } else if free { 1e-3 } else { step }; edit = Some(Edit::Set(k, v.max(floor))); } } } // A dragged radius on a spline fillet changes its own expression; anything else writes the // whole spline (inserting or removing a knot moves its neighbours too). let whole = match edit { Some(Edit::Set(k, v)) if !taper => { if k == 0 { if let Kind::Fillet { radius, .. } = &mut d.kind { *radius = format_len(v); } } else if let Some(Value::Array(rs)) = d.extra.get_mut("radii") && let Some(slot) = rs.get_mut(k - 1) { *slot = json!(format_len(v)); } None } Some(Edit::Set(k, v)) => { let mut law = base.clone(); if let Some(r) = law.radii.get_mut(k) { *r = v; } Some(law) } Some(Edit::Insert(u)) => base.insert(u), Some(Edit::Remove(k)) => base.remove(k), None => None, }; if let Some(law) = whole { d.extra.insert("type".into(), json!("spline")); d.extra.remove("fit"); d.extra.insert("knots".into(), json!(law.knots)); d.extra.insert("swap_ends".into(), json!(swap)); d.extra.insert("radii".into(), json!(law.radii.iter().skip(1).map(|r| format_len(*r)).collect::>())); if let Kind::Fillet { radius, .. } = &mut d.kind { *radius = format_len(law.radii.first().copied().unwrap_or(0.0)); } } true } /// The measured distance: a line between the closest points with its length. fn measure_line(app: &SolveApp, painter: &egui::Painter, proj: &Proj, r: &serde_json::Value) { let t = Tokens::get(); let p = |k: &str| -> Option { let a = r.get(k)?.as_array()?; Some(Vec3::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?)) }; let (Some(a), Some(b), Some(d)) = (p("from"), p("to"), r.get("distance_mm").and_then(|v| v.as_f64())) else { return }; let (Some(sa), Some(sb)) = (proj.to_screen(a), proj.to_screen(b)) else { return }; painter.line_segment([sa, sb], Stroke::new(2.0, t.manipulator)); for s in [sa, sb] { painter.circle(s, 3.5, t.manipulator, Stroke::new(1.0, Color32::WHITE)); } let mid = sa + (sb - sa) * 0.5 + vec2(10.0, -14.0); let galley = painter.layout_no_wrap(crate::prefs::show_mm(app, d, 1), egui::FontId::proportional(12.5), t.text); let rect = egui::Rect::from_min_size(mid, galley.size() + vec2(10.0, 6.0)); painter.rect(rect, 3.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); painter.galley(rect.min + vec2(5.0, 3.0), galley, t.text); } /// The taper handle: a line from `foot` along the extrude (`span`) leaning by `angle` towards /// `e`, with a ring at its end; dragging the ring sideways sets the angle (1° steps, Alt/Ctrl: /// free). #[allow(clippy::too_many_arguments)] fn taper_handle(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, foot: Vec3, span: Vec3, e: Vec3, angle: f64, value: &mut String) { let t = Tokens::get(); let l = span.len(); let end = foot + span + e * (l * angle.tan().clamp(-10.0, 10.0)); let (Some(fs), Some(es)) = (proj.to_screen(foot), proj.to_screen(end)) else { return }; let r = ui.interact(egui::Rect::from_center_size(es, vec2(16.0, 16.0)), egui::Id::new("sc_taper"), egui::Sense::drag()); let hot = r.hovered() || r.dragged(); let col = if hot { t.accent } else { t.manipulator }; painter.add(egui::Shape::dashed_line(&[fs, es], Stroke::new(1.2, col), 5.0, 3.0)); painter.circle(es, if hot { 6.5 } else { 5.0 }, t.overlay, Stroke::new(2.0, col)); if hot { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } if r.dragged() && let Some(p) = r.interact_pointer_pos() && let Some(s) = along(proj, p, foot + span, e) { let mut deg = s.atan2(l).to_degrees(); if !ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command) { deg = deg.round(); } let txt = format!("{deg:.1}"); *value = format!("{} deg", txt.trim_end_matches('0').trim_end_matches('.')); } } /// The revolve axis (a point on it and its unit direction). pub(crate) fn revolve_axis(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Vec3)> { if !matches!(d.kind, Kind::Revolve { .. }) { return None; } if let Some(selected) = d.inputs.get(1)?.items.first() { if let Sel::Axis { name } = selected && matches!(name.as_str(), "X" | "Y" | "Z") && let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()? { let component = app.session.doc.feature(*sketch)?.component; let frame = crate::dialogs::feature_frame(&app.session, component); let dir = match name.as_str() { "X" => Vec3::X, "Y" => Vec3::Y, _ => Vec3::Z, }; return Some((solidcraft_engine::doc::apply_point(&frame, Vec3::ZERO), solidcraft_engine::doc::apply_vector(&frame, dir))); } if let Sel::Axis { name } = selected && matches!(name.as_str(), "x" | "y") && let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()? { let plane = profile_plane(app, *sketch)?; return Some((plane.origin, if name == "x" { plane.x } else { plane.y })); } return crate::dialogs::axis_of(app, selected); } let Kind::Revolve { axis: Some(axis), .. } = &d.kind else { return None }; let vector = |key: &str| -> Option { let p = axis.get(key)?.as_array()?; Some(Vec3::new(p.first()?.as_f64()?, p.get(1)?.as_f64()?, p.get(2)?.as_f64()?)) }; Some((vector("origin")?, vector("dir")?.normalized()?)) } /// Draw angular extents in the same clockwise sense as the modeled solid. #[allow(clippy::too_many_arguments)] fn draw_revolve( app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, d: &mut Dialog, base: Vec3, bs: Pos2, axis: (Vec3, Vec3), focus: bool, ) { let to_mode = d.inputs.iter().any(|input| input.label == "To Object"); let extent = revolve_display_extent(app, d, axis).unwrap_or(solidcraft_engine::doc::RevolveExtent { start: 0.0, sweep: f64::NAN, rotation_axis: axis.1, display_start: 0.0, curved_to: false, }); let mut box_at = bs + vec2(16.0, -30.0); let angle_before = if let Kind::Revolve { angle, .. } = &d.kind { angle.clone() } else { String::new() }; if !extent.curved_to && extent.sweep.is_finite() { let (origin, direction) = axis; let centre = origin + direction * (base - origin).dot(direction); let radius = (base - centre).len(); if radius > 1e-9 { let e1 = (base - centre) / radius; let e2 = extent.rotation_axis.cross(e1); let handed = match revolve_convention(d) { solidcraft_engine::doc::RevolveConvention::LegacyAxisStart => -direction.dot(extent.rotation_axis), solidcraft_engine::doc::RevolveConvention::SketchNormal => 1.0, }; let at = |angle: f64| centre + (e1 * angle.cos() + e2 * angle.sin()) * radius; let points: Vec<_> = (0..=48).filter_map(|i| proj.to_screen(at(extent.display_start + extent.sweep * i as f64 / 48.0))).collect(); painter.add(egui::Shape::line(points, Stroke::new(2.0, Tokens::get().manipulator))); if let Some(end) = proj.to_screen(at(extent.display_start + extent.sweep)) { box_at = end + vec2(14.0, -12.0); } if !to_mode { let symmetric = !d.extra.contains_key("angle2") && d.extra.get("direction").and_then(serde_json::Value::as_str) == Some("symmetric"); let other_angle = d .extra .get("angle2") .and_then(|value| value.as_str()) .and_then(|value| app.session.doc.eval(value, ValueKind::Angle).ok()) .unwrap_or(0.0); let range = if extent.sweep < 0.0 { (-std::f64::consts::TAU, 0.0) } else { (0.0, std::f64::consts::TAU) }; if let Kind::Revolve { angle, .. } = &mut d.kind { let boundary = extent.display_start + extent.sweep; if let Some(end) = rotation_handle( ui, painter, proj, centre, e1, e2, direction, radius, boundary, if symmetric { 1.0 / (1.0 - handed * 0.5) } else { 1.0 }, if symmetric { 0.0 } else { -other_angle * (1.0 - handed) }, (range.0 - other_angle, range.1 - other_angle), "revolve", angle, ) { box_at = end + vec2(14.0, -12.0); } if symmetric { rotation_handle( ui, painter, proj, centre, e1, e2, direction, radius, extent.display_start, -2.0 * handed, 0.0, range, "revolve2", angle, ); } } if let Some(other) = d.extra.get_mut("angle2") { let mut value = other.as_str().map(str::to_owned).unwrap_or_else(|| other.to_string()); let before = value.clone(); let first_angle = extent.sweep - other_angle; rotation_handle( ui, painter, proj, centre, e1, e2, direction, radius, extent.display_start, -handed, 0.0, (range.0 - first_angle, range.1 - first_angle), "revolve2", &mut value, ); if value != before { *other = serde_json::Value::String(value); } } } } } if to_mode { crate::frame::area(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| { let text = if extent.curved_to { "To surface".into() } else if extent.sweep.is_finite() { format!("Sweep {:.1}°", extent.sweep.to_degrees()) } else { "Choose target".into() }; ui.label(text).on_hover_text(crate::i18n::tr_label("Determined by To Object. Choose Angle to edit the angular extent.")); }); }); } else if let Kind::Revolve { angle, .. } = &mut d.kind { let dragging = *angle != angle_before; value_box(ui, crate::i18n::tr_label("Angle"), angle, box_at, focus || dragging); } } fn revolve_convention(d: &Dialog) -> solidcraft_engine::doc::RevolveConvention { d.extra .get("convention") .and_then(|value| serde_json::from_value(value.clone()).ok()) .unwrap_or(solidcraft_engine::doc::RevolveConvention::SketchNormal) } fn revolve_display_extent(app: &SolveApp, d: &Dialog, axis: (Vec3, Vec3)) -> Option { let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()? else { return None }; let state = app.session.world_state(); let ss = state.sketch(*sketch)?; let regions: Vec<_> = d .inputs .first()? .items .iter() .filter_map(|item| match item { Sel::Profile { sketch: id, index } if id == sketch => ss.profiles.get(*index).map(|profile| profile.region.clone()), _ => None, }) .collect(); let regions = solidcraft_engine::sketch::merge_regions(®ions); let plane = profile_plane(app, *sketch)?; let to_mode = d.inputs.iter().any(|input| input.label == "To Object"); let to = if to_mode { let point = crate::dialogs::object_point(&d.inputs, "To Object")?; Some(Vec3::new(point.get(0)?.as_f64()?, point.get(1)?.as_f64()?, point.get(2)?.as_f64()?)) } else { None }; let Kind::Revolve { angle, .. } = &d.kind else { return None }; let angles = if to_mode { (0.0, None) } else { let angle = app.session.doc.eval(angle, ValueKind::Angle).ok()?; let other = d.extra.get("angle2").map(|value| app.session.doc.eval(value.as_str().unwrap_or_default(), ValueKind::Angle)).transpose().ok()?; (angle, other) }; solidcraft_engine::doc::revolve_extent_with_convention( &state, &plane, ®ions, axis, angles, d.extra.get("direction").and_then(serde_json::Value::as_str) == Some("symmetric"), to, revolve_convention(d), ) .ok() } #[allow(clippy::too_many_arguments)] fn rotation_handle( ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, centre: Vec3, e1: Vec3, e2: Vec3, axis: Vec3, radius: f64, boundary: f64, scale: f64, offset: f64, range: (f64, f64), name: &str, value: &mut String, ) -> Option { let end = proj.to_screen(centre + (e1 * boundary.cos() + e2 * boundary.sin()) * radius)?; crate::scenario::publish_handle(name, end); let response = ui.interact(egui::Rect::from_center_size(end, vec2(20.0, 20.0)), egui::Id::new(("sc_rotator", name)), egui::Sense::drag()); let hot = response.hovered() || response.dragged(); let color = if hot { Tokens::get().accent } else { Tokens::get().manipulator }; painter.circle(end, if hot { 7.0 } else { 5.5 }, color, Stroke::new(1.5, Color32::WHITE)); if hot { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } if response.dragged() && let Some(pointer) = response.interact_pointer_pos() { let (origin, direction) = proj.ray(pointer); let den = direction.dot(axis); let (a, b) = ((range.0 - offset) / scale, (range.1 - offset) / scale); let bounds = (a.min(b), a.max(b)); // Near edge-on, a plane intersection magnifies subpixel pointer error. let raw = if den.abs() > 0.05 { let point = origin + direction * ((centre - origin).dot(axis) / den) - centre; Some(point.dot(e2).atan2(point.dot(e1))) } else { projected_rotation_angle(proj, pointer, centre, e1, e2, radius, boundary, bounds) }; if let Some(raw) = raw { let boundary_angle = (-4..=4) .map(|k| raw + k as f64 * std::f64::consts::TAU) .filter(|angle| *angle >= bounds.0 - 1e-4 && *angle <= bounds.1 + 1e-4) .min_by(|a, b| (a - boundary).abs().total_cmp(&(b - boundary).abs())) .unwrap_or_else(|| raw.clamp(bounds.0, bounds.1)); let mut angle = (boundary_angle.clamp(bounds.0, bounds.1) * scale + offset).to_degrees(); if !ui.input(|input| input.modifiers.alt || input.modifiers.ctrl || input.modifiers.command) { angle = (angle / 5.0).round() * 5.0; } if angle.abs() < 0.05 { angle = 0.0; } let text = format!("{angle:.1}"); *value = format!("{} deg", text.trim_end_matches('0').trim_end_matches('.')); } } Some(end) } /// An edge-on ring has two equally valid depths; retain the branch nearest the current handle. #[allow(clippy::too_many_arguments)] fn projected_rotation_angle( proj: &Proj, pointer: Pos2, centre: Vec3, e1: Vec3, e2: Vec3, radius: f64, current: f64, (lo, hi): (f64, f64), ) -> Option { let distance = |angle: f64| { proj.to_screen(centre + (e1 * angle.cos() + e2 * angle.sin()) * radius) .map(|point| f64::from(point.distance_sq(pointer))) .unwrap_or(f64::INFINITY) }; let step = (hi - lo) / 720.0; let mut best = None; for i in 0..=720 { let angle = lo + step * i as f64; let score = distance(angle); if !score.is_finite() { continue; } if best.is_none_or(|(old_angle, old_score): (f64, f64)| { score < old_score - 1e-4 || (score - old_score).abs() <= 1e-4 && (angle - current).abs() < (old_angle - current).abs() }) { best = Some((angle, score)); } } let (angle, _) = best?; let (mut a, mut b) = ((angle - step).max(lo), (angle + step).min(hi)); for _ in 0..24 { let (left, right) = (a + (b - a) / 3.0, b - (b - a) / 3.0); if distance(left) <= distance(right) { b = right; } else { a = left; } } Some((a + b) / 2.0) } 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)); } #[cfg(test)] mod tests { use super::*; #[test] fn offset_plane_preview_stays_within_its_square() { let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); let mut worst = f32::NEG_INFINITY; for n in [Vec3::X, Vec3::Y, Vec3::Z] { for dir in crate::viewport::orbit_directions() { let cam = solidcraft_engine::render::Camera::default().looking_from(dir); let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect }; let h = cam.half_height() * 0.25; let (Some(q), Some(c), Some(side)) = (offset_plane_quad(&proj, Vec3::ZERO, n, h), proj.to_screen(Vec3::ZERO), proj.to_screen(cam.basis().0 * h)) else { continue; }; // The square's half diagonal on screen (an orthographic view), plus stroke and feathering. let limit = c.distance(side) * 2f32.sqrt() + 3.0; for p in crate::viewport::drawn_points(projected_polygon(&q, Color32::GRAY, Stroke::new(1.2, Color32::BLACK))) { worst = worst.max(p.distance(c) - limit); } } } assert!(worst <= 0.0, "a vertex {worst} px past the square"); } #[test] fn snapping_steps_and_formatting() { assert_eq!(snap_step(40.0), 1.0); assert_eq!(snap_step(100.0), 2.0); assert_eq!(snap_step(4.0), 0.1); assert_eq!(format_len(12.0), "12 mm"); assert_eq!(format_len(-2.5), "-2.5 mm"); assert_eq!(format_len(-0.0001), "0 mm"); } }