diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a43802aa..ced9cb1b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -52,7 +52,6 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { | Kind::OffsetFaces { .. } => (at, normal), // Shell thickness grows into the body. Kind::Shell { .. } => (at, normal.map(|n| -n)), - Kind::Move { .. } => (at, Some(Vec3::Z)), 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)), @@ -96,6 +95,16 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj app.dialog = Some(d); return; } + // Move: the triad at the pivot; dragging it sets the distances and angles. + if matches!(d.kind, Kind::Move { .. }) { + 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); + } + app.dialog = Some(d); + return; + } if let Some((base, dir)) = anchor(app, &d) && let Some(bs) = proj.to_screen(base) { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 406d8c2b..c34c67c7 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -280,6 +280,11 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> // Along an origin axis, on its solid part. let size = crate::selection::origin_size(app.cam.half_height()); crate::viewport::origin_axes(app).into_iter().find(|(name, _)| *name == n).map(|(_, d)| d * (size * 0.8)) + } else if let Some(n) = p.get("gizmo").and_then(Value::as_str) { + // A handle of the move triad ("x", "xy", "rz", "ball"), moved `by` [x,y,z] mm or + // turned `turn` degrees (rings). + let by = p.get("by").map(|b| solvecraft_engine::geom::Vec3::new(num(b, 0), num(b, 1), num(b, 2))).unwrap_or_default(); + crate::gizmo::handle_point(n, by, p.get("turn").and_then(Value::as_f64).unwrap_or(0.0)) } else { None }; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4536e809..71562e2f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -156,10 +156,17 @@ pub enum Kind { of: Vec, }, /// Move bodies by a distance along X, Y and Z. + /// Move/Copy (see `dialogs_move`): the move type, X/Y/Z distances (Point to Position: the + /// target), X/Y/Z angles (Free Move) and the angle (Rotate). Move { + mode: usize, x: String, y: String, z: String, + rx: String, + ry: String, + rz: String, + angle: String, }, Hole { diameter: String, @@ -367,9 +374,7 @@ impl Dialog { Kind::Sweep { operation: usize::from(has_bodies) }, vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true)], ), - "FusionMoveCommand" => { - Dialog::new(Kind::Move { x: "0 mm".into(), y: "0 mm".into(), z: "10 mm".into() }, vec![SelInput::new("Bodies", BODIES, true)]) - } + "FusionMoveCommand" => Dialog::new(crate::dialogs_move::kind(0), crate::dialogs_move::inputs(0)), "FusionHoleCommand" => Dialog::new(hole_defaults(), vec![SelInput::new("Face", PLANAR_FACES, true)]), "PrimitiveBox" => Dialog::new( Kind::Primitive { @@ -550,7 +555,7 @@ impl Dialog { Kind::Shell { thickness } => ("Thickness", ValueKind::Length, thickness), Kind::Draft { angle } => ("Angle", ValueKind::Angle, angle), Kind::Hole { diameter, .. } => ("Diameter", ValueKind::Length, diameter), - Kind::Move { z, .. } => ("Z", ValueKind::Length, z), + Kind::Move { x, .. } => ("X", ValueKind::Length, x), Kind::PatternRect { spacing, .. } => ("Spacing", ValueKind::Length, spacing), Kind::PatternCirc { angle, .. } => ("Angle", ValueKind::Angle, angle), Kind::Section { offset, .. } => ("Distance", ValueKind::Length, offset), @@ -891,6 +896,10 @@ fn object_type_row(d: &mut Dialog, ui: &mut egui::Ui) { /// Rows above the selection inputs: the pattern type and object type, the Hole placement (one /// face point, or sketch points). fn placement_row(d: &mut Dialog, ui: &mut egui::Ui) { + if matches!(d.kind, Kind::Move { .. }) { + crate::dialogs_move::type_row(d, ui); + return; + } let current = match d.kind { Kind::PatternRect { .. } => Some(0), Kind::PatternCirc { .. } => Some(1), @@ -1370,13 +1379,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { combo(ui, "sw_op", &OP_LABELS, operation); ui.end_row(); } - Kind::Move { x, y, z } => { - for (l, v) in [("X distance", x), ("Y distance", y), ("Z distance", z)] { - row_label(ui, l); - enter |= field(ui, v); - ui.end_row(); - } - } + k @ Kind::Move { .. } => enter |= crate::dialogs_move::rows(ui, k), Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } => { row_label(ui, "Extents"); let mut ext = usize::from(opts.all); @@ -2031,10 +2034,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S }; ("FusionHalfSectionViewCommand", json!({"plane": plane, "offset": offset, "flip": flip})) } - Kind::Move { x, y, z } => { - need(0, "bodies")?; - ("FusionMoveCommand", json!({"bodies": body_names(0), "translate": [x, y, z]})) - } + Kind::Move { .. } => return Ok(vec![("FusionMoveCommand".into(), crate::dialogs_move::params(app, d)?)]), Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } => { need(0, if opts.multiple { "sketch points" } else { "a face position" })?; let ty = match *kind { @@ -2709,17 +2709,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { let mut d = start("FusionMoveCommand")?; - let [x, y, z] = translate.clone(); - d.kind = Kind::Move { x, y, z }; - if let Some(inp) = d.inputs.first_mut() { - inp.items = bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect(); - } - if let Some(a) = rotate_axis { - d.extra.insert("axis".into(), pt3(*a)); - } - if let Some(a) = angle { - d.extra.insert("angle".into(), json!(a)); - } + crate::dialogs_move::for_feature(&mut d, bodies, translate, *rotate_axis, angle.as_ref()); d } other => { diff --git a/crates/ui-egui/src/dialogs_move.rs b/crates/ui-egui/src/dialogs_move.rs new file mode 100644 index 00000000..62d7eadd --- /dev/null +++ b/crates/ui-egui/src/dialogs_move.rs @@ -0,0 +1,314 @@ +//! Move/Copy: the move types (Free Move, Translate, Rotate, Point to Point, Point to Position), +//! their rows and inputs, the triad on the canvas and the FusionMoveCommand they make. Nothing +//! moves until a value is typed or the triad is dragged. + +use serde_json::{Value, json}; +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, combo, field, row_label}; +use crate::gizmo::{self, Change, Drag}; +use crate::selection::{AXES, BODIES, EDGES, FACES, SelInput, VERTICES}; + +pub const TYPES: [&str; 5] = ["Free Move", "Translate", "Rotate", "Point to Point", "Point to Position"]; +const FREE: usize = 0; +const TRANSLATE: usize = 1; +const ROTATE: usize = 2; +const POINT_TO_POINT: usize = 3; +const POINT_TO_POSITION: usize = 4; +const POINTS: u16 = VERTICES | FACES | EDGES; + +/// A fresh Move dialog of a type (all values zero). +pub fn kind(mode: usize) -> Kind { + let z = || "0 mm".to_string(); + let a = || "0 deg".to_string(); + Kind::Move { mode, x: z(), y: z(), z: z(), rx: a(), ry: a(), rz: a(), angle: a() } +} + +/// The selection inputs of a move type (the bodies first). +pub fn inputs(mode: usize) -> Vec { + let mut v = vec![SelInput::new("Bodies", BODIES, true)]; + match mode { + FREE => v.push(SelInput::new("Pivot", POINTS, false)), + ROTATE => v.push(SelInput::new("Axis", AXES | EDGES, false)), + POINT_TO_POINT => { + v.push(SelInput::new("From", POINTS, false)); + v.push(SelInput::new("To", POINTS, false)); + } + POINT_TO_POSITION => v.push(SelInput::new("Point", POINTS, false)), + _ => {} + } + v +} + +/// The Move Type row (above the inputs); switching keeps the bodies and resets the values. +pub fn type_row(d: &mut Dialog, ui: &mut egui::Ui) { + let Kind::Move { mode, .. } = d.kind else { return }; + row_label(ui, "Move Type"); + let mut m = mode; + combo(ui, "move_type", &TYPES, &mut m); + ui.end_row(); + if m != mode { + let bodies = d.inputs.first().map(|i| i.items.clone()).unwrap_or_default(); + d.kind = kind(m); + d.inputs = inputs(m); + if let Some(i) = d.inputs.first_mut() { + i.items = bodies; + } + d.extra.remove("pivot"); + d.active = if bodies_empty(d) { 0 } else { 1.min(d.inputs.len() - 1) }; + } +} + +fn bodies_empty(d: &Dialog) -> bool { + d.inputs.first().is_none_or(|i| i.items.is_empty()) +} + +/// The value rows after the inputs; true when Enter was pressed in one. +pub fn rows(ui: &mut egui::Ui, k: &mut Kind) -> bool { + let Kind::Move { mode, x, y, z, rx, ry, rz, angle } = k else { return false }; + let mut enter = false; + let mut row = |ui: &mut egui::Ui, l: &str, v: &mut String| { + row_label(ui, l); + enter |= field(ui, v); + ui.end_row(); + }; + match *mode { + FREE | TRANSLATE => { + row(ui, "X Distance", x); + row(ui, "Y Distance", y); + row(ui, "Z Distance", z); + if *mode == FREE { + row(ui, "X Angle", rx); + row(ui, "Y Angle", ry); + row(ui, "Z Angle", rz); + } + } + ROTATE => row(ui, "Angle", angle), + POINT_TO_POSITION => { + row(ui, "X", x); + row(ui, "Y", y); + row(ui, "Z", z); + } + _ => {} + } + enter +} + +fn sel_point(s: Option<&Sel>) -> Option { + match s? { + Sel::Vertex { point, .. } | Sel::Face { point, .. } | Sel::Edge { point, .. } => Some(*point), + _ => None, + } +} + +fn items(d: &Dialog, i: usize) -> &[Sel] { + d.inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[]) +} + +fn body_names(d: &Dialog) -> Vec { + items(d, 0).iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect() +} + +/// The centre of the picked bodies where they are before this move. +fn bodies_center(app: &SolveApp, d: &Dialog) -> Option { + let st = match d.editing { + Some((id, _)) => app.session.model.state_before(id), + None => app.session.model.state(), + }; + let mut bb: Option<(Vec3, Vec3)> = None; + for n in body_names(d) { + let Some(b) = st.body(&n) else { continue }; + let x = b.mesh().bounds(); + bb = Some(match bb { + None => (x.min, x.max), + Some((lo, hi)) => ( + Vec3::new(lo.x.min(x.min.x), lo.y.min(x.min.y), lo.z.min(x.min.z)), + Vec3::new(hi.x.max(x.max.x), hi.y.max(x.max.y), hi.z.max(x.max.z)), + ), + }); + } + bb.map(|(lo, hi)| (lo + hi) * 0.5) +} + +/// What a Free Move turns about: the picked pivot, else the kept one (editing), else the +/// bodies' centre. +fn pivot(app: &SolveApp, d: &Dialog) -> Option { + if let Some(p) = sel_point(items(d, 1).first()) { + return Some(p); + } + if let Some(a) = d.extra.get("pivot").and_then(|v| serde_json::from_value::<[f64; 3]>(v.clone()).ok()) { + return Some(Vec3::new(a[0], a[1], a[2])); + } + bodies_center(app, d) +} + +fn len(app: &SolveApp, s: &str) -> Result { + app.session.doc.eval(s, ValueKind::Length).map_err(|e| e.to_string()).and_then(|v| if v.is_finite() { Ok(v) } else { Err("not a number".into()) }) +} + +fn ang(app: &SolveApp, s: &str) -> Result { + app.session.doc.eval(s, ValueKind::Angle).map_err(|e| e.to_string()).and_then(|v| if v.is_finite() { Ok(v) } else { Err("not a number".into()) }) +} + +fn mm(v: f64) -> String { + let s = format!("{v:.6}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + format!("{} mm", if s == "-0" || s.is_empty() { "0" } else { s }) +} + +fn deg(v: f64) -> String { + let s = format!("{:.4}", v.to_degrees()); + let s = s.trim_end_matches('0').trim_end_matches('.'); + format!("{} deg", if s == "-0" || s.is_empty() { "0" } else { s }) +} + +/// The FusionMoveCommand parameters (bodies, translate, axis, angle). +pub fn params(app: &SolveApp, d: &Dialog) -> Result { + let Kind::Move { mode, x, y, z, rx, ry, rz, angle } = &d.kind else { return Err("not a move".into()) }; + let bodies = body_names(d); + if bodies.is_empty() { + return Err("select bodies first".into()); + } + let need_point = |i: usize, what: &str| sel_point(items(d, i).first()).ok_or(format!("select {what} first")); + let num = |t: Vec3| json!([mm(t.x), mm(t.y), mm(t.z)]); + let mut p = json!({ "bodies": bodies }); + match *mode { + TRANSLATE => p["translate"] = json!([x, y, z]), + FREE => { + let a = [ang(app, rx)?, ang(app, ry)?, ang(app, rz)?]; + let turned: Vec = (0..3).filter(|i| a[*i].abs() > 1e-12).collect(); + if turned.is_empty() { + p["translate"] = json!([x, y, z]); + // A rotation kept from an edited move about another axis. + for k in ["axis", "angle"] { + if let Some(v) = d.extra.get(k) { + p[k] = v.clone(); + } + } + } else { + let piv = pivot(app, d).ok_or("select bodies first")?; + let r = gizmo::mul(&gizmo::mul(&gizmo::rotation(Vec3::Z, a[2]), &gizmo::rotation(Vec3::Y, a[1])), &gizmo::rotation(Vec3::X, a[0])); + let (axis, th) = gizmo::axis_angle(&r); + match turned.as_slice() { + // One turn about the origin: the typed values stay as they are. + [i] if piv.len() < 1e-9 => { + p["translate"] = json!([x, y, z]); + p["axis"] = json!([[1, 0, 0], [0, 1, 0], [0, 0, 1]][*i]); + p["angle"] = json!([rx, ry, rz][*i]); + } + _ => { + let t = Vec3::new(len(app, x)?, len(app, y)?, len(app, z)?); + p["translate"] = num(piv - gizmo::apply(&r, piv) + t); + p["axis"] = json!([axis.x, axis.y, axis.z]); + p["angle"] = json!(deg(th)); + } + } + } + } + ROTATE => { + let (o, a) = items(d, 1).first().and_then(|s| axis_line(app, s)).ok_or("select an axis or a straight edge first")?; + let th = ang(app, angle)?; + let r = gizmo::rotation(a, th); + p["translate"] = num(o - gizmo::apply(&r, o)); + p["axis"] = json!([a.x, a.y, a.z]); + p["angle"] = json!(angle); + } + POINT_TO_POINT => { + let (from, to) = (need_point(1, "the point to move from")?, need_point(2, "the point to move to")?); + p["translate"] = num(to - from); + } + _ => { + let from = need_point(1, "the point to move")?; + let at = Vec3::new(len(app, x)?, len(app, y)?, len(app, z)?); + p["translate"] = num(at - from); + } + } + Ok(p) +} + +/// An axis line: origin axes and sketch lines, or a straight body edge. +fn axis_line(app: &SolveApp, s: &Sel) -> Option<(Vec3, Vec3)> { + if let Some(a) = crate::dialogs::axis_of(app, s) { + return Some(a); + } + let Sel::Edge { body, index, .. } = s else { return None }; + let st = app.session.model.state(); + let m = st.body(body)?.mesh(); + let e = m.edges.get(*index)?; + let (a, b) = (*e.first()?, *e.last()?); + let dir = (b - a).normalized()?; + // Straight: every point on the chord. + e.iter().all(|p| (*p - a).cross(dir).len() < 1e-6 * (1.0 + (b - a).len())).then_some((a, dir)) +} + +/// Where the triad sits: the pivot carried by the move so far (Free Move and Translate only). +pub fn triad(app: &SolveApp, d: &Dialog) -> Option { + let Kind::Move { mode, x, y, z, .. } = &d.kind else { return None }; + if !matches!(*mode, FREE | TRANSLATE) || bodies_empty(d) { + return None; + } + let t = Vec3::new(len(app, x).unwrap_or(0.0), len(app, y).unwrap_or(0.0), len(app, z).unwrap_or(0.0)); + Some(gizmo::Triad { center: pivot(app, d)? + t, translate: true, rotate: *mode == FREE }) +} + +/// Apply a triad drag to the dialog's values (added to the values when the drag began). +pub fn drag(app: &SolveApp, d: &mut Dialog, g: Drag) { + let Kind::Move { x, y, z, rx, ry, rz, .. } = &d.kind else { return }; + if g.started || !d.extra.contains_key("drag0") { + let v = [len(app, x), len(app, y), len(app, z), ang(app, rx), ang(app, ry), ang(app, rz)].map(|r| r.unwrap_or(0.0)); + d.extra.insert("drag0".into(), json!(v)); + } + let v0: [f64; 6] = d.extra.get("drag0").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let Kind::Move { x, y, z, rx, ry, rz, .. } = &mut d.kind else { return }; + match g.change { + Change::Translate(t) => { + *x = mm(v0[0] + t.x); + *y = mm(v0[1] + t.y); + *z = mm(v0[2] + t.z); + } + Change::Rotate { axis, angle } => { + let slot = match axis { + 0 => rx, + 1 => ry, + _ => rz, + }; + *slot = deg(v0.get(3 + axis.min(2)).copied().unwrap_or(0.0) + angle); + } + } + if g.done { + d.extra.remove("drag0"); + } +} + +/// The dialog for editing a move: Free Move with its translation and, about a world axis +/// through the origin, its angle (another axis is kept as it is). +pub fn for_feature(d: &mut Dialog, bodies: &[String], translate: &[String; 3], axis: Option, angle: Option<&String>) { + let [x, y, z] = translate.clone(); + let mut k = kind(FREE); + if let Kind::Move { x: kx, y: ky, z: kz, rx, ry, rz, .. } = &mut k { + (*kx, *ky, *kz) = (x, y, z); + if let Some(a) = angle { + let dir = axis.unwrap_or(Vec3::Z); + let world = [Vec3::X, Vec3::Y, Vec3::Z].iter().position(|w| dir.cross(*w).len() < 1e-9 && dir.dot(*w) > 0.0); + match world { + Some(0) => *rx = a.clone(), + Some(1) => *ry = a.clone(), + Some(_) => *rz = a.clone(), + None => { + d.extra.insert("axis".into(), json!([dir.x, dir.y, dir.z])); + d.extra.insert("angle".into(), json!(a)); + } + } + // The stored move turns about the origin. + d.extra.insert("pivot".into(), json!([0.0, 0.0, 0.0])); + } + } + d.kind = k; + d.inputs = inputs(FREE); + if let Some(inp) = d.inputs.first_mut() { + inp.items = bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect(); + } +} diff --git a/crates/ui-egui/src/gizmo.rs b/crates/ui-egui/src/gizmo.rs new file mode 100644 index 00000000..014c8099 --- /dev/null +++ b/crates/ui-egui/src/gizmo.rs @@ -0,0 +1,425 @@ +//! The move/rotate triad shared by Move/Copy and occurrence moves: X/Y/Z arrows, XY/YZ/XZ plane +//! squares, X/Y/Z rotation rings and a centre ball for a free drag in the view plane. It is a +//! constant size on screen. A drag reports its change since the drag began (distances snap to +//! round steps at the current zoom, angles to 5°; hold Alt or Ctrl for a free drag), so the caller +//! adds it to the values it had when the drag started. + +use std::cell::RefCell; + +use egui::{Color32, Pos2, Rect, Sense, Shape, Stroke, vec2}; +use solvecraft_engine::geom::Vec3; + +use crate::theme::Tokens; +use crate::viewport::Proj; + +/// A part of the triad. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Handle { + /// Arrow along X, Y or Z (0, 1, 2). + Axis(usize), + /// Square in the plane across that axis (Plane(2) is XY). + Plane(usize), + /// Ring turning about that axis. + Ring(usize), + /// Centre ball: free drag in the view plane. + Ball, +} + +/// What a drag has done so far. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Change { + Translate(Vec3), + /// About X, Y or Z through the centre, in radians. + Rotate { + axis: usize, + angle: f64, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Drag { + pub handle: Handle, + pub change: Change, + /// The first frame of the drag (take a snapshot of the values now). + pub started: bool, + /// The drag ended this frame. + pub done: bool, +} + +/// Where the triad is and which handles it shows. +#[derive(Clone, Copy, Debug)] +pub struct Triad { + pub center: Vec3, + pub translate: bool, + pub rotate: bool, +} + +#[derive(Clone, Copy, Debug)] +struct State { + handle: Handle, + center: Vec3, + /// Grab point on the plane (planes, ball) or parameter along the axis. + grab: Vec3, + t0: f64, + /// Ring: last angle seen and the angle turned so far. + last: f64, + turned: f64, +} + +const DIRS: [Vec3; 3] = [Vec3::X, Vec3::Y, Vec3::Z]; +/// Arrow length on screen (px). +const LEN_PX: f64 = 110.0; + +thread_local! { + /// The last triad drawn: centre and arrow length in mm (for scenarios and tests). + static LAST: RefCell> = const { RefCell::new(None) }; +} + +/// A world point on a handle of the last triad drawn: "x"/"y"/"z" on an arrow, "xy"/"yz"/"xz" +/// in a square, "rx"/"ry"/"rz" on a ring, "ball". `by` moves it, `turn` (degrees) turns a ring +/// point about its axis. +pub fn handle_point(name: &str, by: Vec3, turn: f64) -> Option { + let (c, l) = LAST.with(|x| *x.borrow())?; + let axis = |s: &str| match s { + "x" => Some(0), + "y" => Some(1), + "z" => Some(2), + _ => None, + }; + let p = match name { + "ball" => c, + "xy" => c + (Vec3::X + Vec3::Y) * (0.35 * l), + "yz" => c + (Vec3::Y + Vec3::Z) * (0.35 * l), + "xz" => c + (Vec3::X + Vec3::Z) * (0.35 * l), + r if r.starts_with('r') => { + let i = axis(&r[1..])?; + let (e1, e2) = ring_basis(i); + let th = turn.to_radians(); + return Some(c + (e1 * th.cos() + e2 * th.sin()) * (RING * l) + by); + } + a => c + DIRS[axis(a)?] * (0.75 * l), + }; + Some(p + by) +} + +/// Ring radius as a share of the arrow length. +const RING: f64 = 0.62; + +/// In-plane directions of the ring about an axis (right-handed: e1 × e2 = axis). +fn ring_basis(i: usize) -> (Vec3, Vec3) { + match i { + 0 => (Vec3::Y, Vec3::Z), + 1 => (Vec3::Z, Vec3::X), + _ => (Vec3::X, Vec3::Y), + } +} + +fn axis_color(i: usize) -> Color32 { + match i { + 0 => Color32::from_rgb(225, 70, 60), + 1 => Color32::from_rgb(80, 190, 80), + _ => Color32::from_rgb(70, 130, 240), + } +} + +/// Distance from `p` to the screen segment a–b. +fn seg_dist(p: Pos2, a: Pos2, b: Pos2) -> f32 { + let ab = b - a; + let t = ((p - a).dot(ab) / ab.length_sq().max(1e-6)).clamp(0.0, 1.0); + (a + ab * t).distance(p) +} + +fn in_quad(p: Pos2, q: &[Pos2]) -> bool { + if q.len() < 3 { + return false; + } + let mut sign = 0.0f32; + for i in 0..q.len() { + let (a, b) = (q[i], q[(i + 1) % q.len()]); + let c = (b - a).x * (p - a).y - (b - a).y * (p - a).x; + if c.abs() < 1e-6 { + continue; + } + if sign == 0.0 { + sign = c.signum(); + } else if c.signum() != sign { + return false; + } + } + true +} + +/// Where the view ray through `pos` meets the plane through `o` with normal `n`. +fn on_plane(proj: &Proj, pos: Pos2, o: Vec3, n: Vec3) -> Option { + let (ro, rd) = proj.ray(pos); + let den = rd.dot(n); + if den.abs() < 1e-9 { + return None; + } + Some(ro + rd * ((o - ro).dot(n) / den)) +} + +/// Parameter along the line `o + d t` closest to the view ray through `pos`. +fn along(proj: &Proj, pos: Pos2, o: Vec3, d: Vec3) -> Option { + let (ro, r) = proj.ray(pos); + let w0 = o - ro; + let (b, dd, e) = (d.dot(r), d.dot(w0), r.dot(w0)); + let den = 1.0 - b * b; + if den.abs() < 1e-6 { + return None; + } + Some((b * e - dd) / den) +} + +/// The triad's shapes: (handle, screen polygon or polyline, is it a filled area). +struct Shapes { + arrows: Vec<(usize, Pos2, Pos2)>, + planes: Vec<(usize, Vec)>, + rings: Vec<(usize, Vec)>, + ball: Pos2, +} + +fn shapes(proj: &Proj, t: &Triad, l: f64) -> Option { + let c = t.center; + let ball = proj.to_screen(c)?; + let mut s = Shapes { arrows: Vec::new(), planes: Vec::new(), rings: Vec::new(), ball }; + if t.translate { + for (i, d) in DIRS.iter().enumerate() { + if let (Some(a), Some(b)) = (proj.to_screen(c + *d * (0.18 * l)), proj.to_screen(c + *d * l)) { + s.arrows.push((i, a, b)); + } + } + for i in 0..3 { + let (e1, e2) = ring_basis(i); + let q: Vec = [(0.25, 0.25), (0.45, 0.25), (0.45, 0.45), (0.25, 0.45)] + .iter() + .filter_map(|(a, b)| proj.to_screen(c + e1 * (a * l) + e2 * (b * l))) + .collect(); + if q.len() == 4 { + s.planes.push((i, q)); + } + } + } + if t.rotate { + for i in 0..3 { + let (e1, e2) = ring_basis(i); + let pts: Vec = (0..=64) + .map(|k| k as f64 / 64.0 * std::f64::consts::TAU) + .filter_map(|th| proj.to_screen(c + (e1 * th.cos() + e2 * th.sin()) * (RING * l))) + .collect(); + if pts.len() > 2 { + s.rings.push((i, pts)); + } + } + } + Some(s) +} + +fn hit(s: &Shapes, p: Pos2) -> Option { + if s.ball.distance(p) < 9.0 { + return Some(Handle::Ball); + } + if let Some((i, ..)) = s.arrows.iter().find(|(_, a, b)| seg_dist(p, *a, *b) < 6.0) { + return Some(Handle::Axis(*i)); + } + if let Some((i, _)) = s.planes.iter().find(|(_, q)| in_quad(p, q)) { + return Some(Handle::Plane(*i)); + } + s.rings.iter().find(|(_, pts)| pts.windows(2).any(|w| seg_dist(p, w[0], w[1]) < 5.0)).map(|(i, _)| Handle::Ring(*i)) +} + +/// Millimetres per screen pixel at a point. +fn mm_per_px(proj: &Proj, c: Vec3) -> Option { + let (_, view) = proj.ray(proj.to_screen(c)?); + let u = view.any_perp().normalized()?; + let (a, b) = (proj.to_screen(c)?, proj.to_screen(c + u)?); + let px = f64::from(a.distance(b)); + (px > 1e-9).then(|| 1.0 / px) +} + +fn snap(v: f64, step: f64, free: bool) -> f64 { + if free || step <= 0.0 { v } else { (v / step).round() * step } +} + +/// Draw the triad and run a drag on it. `step` is the distance snap step. +pub fn show(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, id: egui::Id, t: &Triad, step: f64) -> Option { + let tk = Tokens::get(); + let l = mm_per_px(proj, t.center)? * LEN_PX; + LAST.with(|x| *x.borrow_mut() = Some((t.center, l))); + let sh = shapes(proj, t, l)?; + let state: Option = ui.data(|d| d.get_temp(id)); + let pointer = ui.input(|i| i.pointer.hover_pos()); + let hovered = if state.is_none() { pointer.filter(|p| proj.rect.contains(*p)).and_then(|p| hit(&sh, p)) } else { None }; + let active = state.map(|s| s.handle).or(hovered); + let hot = |h: Handle| active == Some(h); + let col = |h: Handle, i: usize| if hot(h) { tk.accent } else { axis_color(i) }; + for (i, pts) in &sh.rings { + painter.add(Shape::line(pts.clone(), Stroke::new(if hot(Handle::Ring(*i)) { 3.0 } else { 1.8 }, col(Handle::Ring(*i), *i)))); + } + for (i, q) in &sh.planes { + let c = col(Handle::Plane(*i), *i); + painter.add(Shape::convex_polygon(q.clone(), c.gamma_multiply(if hot(Handle::Plane(*i)) { 0.7 } else { 0.35 }), Stroke::new(1.0, c))); + } + for (i, a, b) in &sh.arrows { + arrow(painter, *a, *b, col(Handle::Axis(*i), *i), hot(Handle::Axis(*i))); + } + let bc = if hot(Handle::Ball) { tk.accent } else { Color32::from_gray(235) }; + painter.circle(sh.ball, 6.0, bc, Stroke::new(1.5, Color32::from_gray(60))); + let p = pointer?; + active?; + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + let r = ui.interact(Rect::from_center_size(p, vec2(24.0, 24.0)), id.with("hit"), Sense::drag()); + let free = ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command); + let view = proj.ray(sh.ball).1; + let plane_normal = |h: Handle| match h { + Handle::Plane(i) | Handle::Ring(i) => DIRS[i], + _ => view, + }; + let mut started = false; + let mut st = match state { + Some(s) => s, + None => { + if !r.drag_started() { + return None; + } + let h = hovered?; + let from = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); + let c = t.center; + let mut s = State { handle: h, center: c, grab: c, t0: 0.0, last: 0.0, turned: 0.0 }; + match h { + Handle::Axis(i) => s.t0 = along(proj, from, c, DIRS[i])?, + Handle::Ring(i) => { + let q = on_plane(proj, from, c, DIRS[i])? - c; + let (e1, e2) = ring_basis(i); + s.last = q.dot(e2).atan2(q.dot(e1)); + } + _ => s.grab = on_plane(proj, from, c, plane_normal(h))?, + } + started = true; + s + } + }; + let change = match st.handle { + Handle::Axis(i) => { + let v = along(proj, p, st.center, DIRS[i]).map(|v| v - st.t0).unwrap_or(0.0); + Change::Translate(DIRS[i] * snap(v, step, free)) + } + Handle::Ring(i) => { + if let Some(q) = on_plane(proj, p, st.center, DIRS[i]).map(|q| q - st.center) { + let (e1, e2) = ring_basis(i); + let th = q.dot(e2).atan2(q.dot(e1)); + let mut d = th - st.last; + while d > std::f64::consts::PI { + d -= std::f64::consts::TAU; + } + while d < -std::f64::consts::PI { + d += std::f64::consts::TAU; + } + st.turned += d; + st.last = th; + } + Change::Rotate { axis: i, angle: snap(st.turned, 5f64.to_radians(), free) } + } + h => { + let d = on_plane(proj, p, st.center, plane_normal(h)).map(|q| q - st.grab).unwrap_or(Vec3::ZERO); + Change::Translate(Vec3::new(snap(d.x, step, free), snap(d.y, step, free), snap(d.z, step, free))) + } + }; + let done = r.drag_stopped() || !r.dragged() && !started; + if done { + ui.data_mut(|d| d.remove::(id)); + } else { + ui.data_mut(|d| d.insert_temp(id, st)); + } + Some(Drag { handle: st.handle, change, started, done }) +} + +fn arrow(painter: &egui::Painter, a: Pos2, b: Pos2, col: Color32, hot: bool) { + let v = b - a; + let len = v.length(); + if len < 1.0 { + return; + } + let u = v / len; + let n = vec2(-u.y, u.x); + painter.line_segment([a, b], Stroke::new(if hot { 3.0 } else { 2.0 }, col)); + let head = 12.0f32.min(len * 0.5); + painter.add(Shape::convex_polygon(vec![b + u * 2.0, b - u * head + n * head * 0.4, b - u * head - n * head * 0.4], col, Stroke::NONE)); +} + +/// A 3×3 rotation (rows) about a unit axis. +pub fn rotation(axis: Vec3, angle: f64) -> [[f64; 3]; 3] { + let (c, s) = (angle.cos(), angle.sin()); + let t = 1.0 - c; + let (x, y, z) = (axis.x, axis.y, axis.z); + [ + [t * x * x + c, t * x * y - s * z, t * x * z + s * y], + [t * x * y + s * z, t * y * y + c, t * y * z - s * x], + [t * x * z - s * y, t * y * z + s * x, t * z * z + c], + ] +} + +pub fn mul(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] { + let mut m = [[0.0; 3]; 3]; + for (i, row) in m.iter_mut().enumerate() { + for (j, v) in row.iter_mut().enumerate() { + *v = (0..3).map(|k| a[i][k] * b[k][j]).sum(); + } + } + m +} + +pub fn apply(m: &[[f64; 3]; 3], p: Vec3) -> Vec3 { + Vec3::new( + m[0][0] * p.x + m[0][1] * p.y + m[0][2] * p.z, + m[1][0] * p.x + m[1][1] * p.y + m[1][2] * p.z, + m[2][0] * p.x + m[2][1] * p.y + m[2][2] * p.z, + ) +} + +/// The axis and angle of a rotation (angle 0 for none). +pub fn axis_angle(m: &[[f64; 3]; 3]) -> (Vec3, f64) { + let tr = m[0][0] + m[1][1] + m[2][2]; + let angle = ((tr - 1.0) / 2.0).clamp(-1.0, 1.0).acos(); + if angle < 1e-12 { + return (Vec3::Z, 0.0); + } + let v = Vec3::new(m[2][1] - m[1][2], m[0][2] - m[2][0], m[1][0] - m[0][1]); + if let Some(a) = v.normalized().filter(|_| v.len() > 1e-6) { + return (a, angle); + } + // Half a turn: the axis from the diagonal. + let d = [((m[0][0] + 1.0) / 2.0).max(0.0).sqrt(), ((m[1][1] + 1.0) / 2.0).max(0.0).sqrt(), ((m[2][2] + 1.0) / 2.0).max(0.0).sqrt()]; + let (x, mut y, mut z) = (d[0], d[1], d[2]); + if m[0][1] + m[1][0] < 0.0 && x > 1e-6 { + y = -y; + } + if m[0][2] + m[2][0] < 0.0 && x > 1e-6 { + z = -z; + } + if x <= 1e-6 && m[1][2] + m[2][1] < 0.0 { + z = -z; + } + (Vec3::new(x, y, z).normalized().unwrap_or(Vec3::Z), angle) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rotations_compose_to_one_axis_angle() { + let r = mul(&rotation(Vec3::Z, 0.7), &rotation(Vec3::X, 0.4)); + let (a, ang) = axis_angle(&r); + let back = rotation(a, ang); + for i in 0..3 { + for j in 0..3 { + assert!((back[i][j] - r[i][j]).abs() < 1e-9); + } + } + let h = rotation(Vec3::Y, std::f64::consts::PI); + let (a, ang) = axis_angle(&h); + assert!((ang - std::f64::consts::PI).abs() < 1e-9 && (a.y.abs() - 1.0).abs() < 1e-9); + assert!((apply(&rotation(Vec3::Z, std::f64::consts::FRAC_PI_2), Vec3::X) - Vec3::Y).len() < 1e-12); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b0b07f71..89aa4734 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -18,10 +18,12 @@ pub mod dialogs; pub mod dialogs_appearance; pub mod dialogs_assembly; pub mod dialogs_motion; +pub mod dialogs_move; pub mod dialogs_plastic; pub mod dialogs_sheet; pub mod dim_view; pub mod documents; +pub mod gizmo; pub mod gpu; pub mod help; pub mod home; diff --git a/crates/ui-egui/tests/scenarios.rs b/crates/ui-egui/tests/scenarios.rs index 044eca74..2bcb9e32 100644 --- a/crates/ui-egui/tests/scenarios.rs +++ b/crates/ui-egui/tests/scenarios.rs @@ -48,7 +48,8 @@ scenarios!( s22_sheet_metal_fold, s23_configurations, s24_motion_study, - s25_autosave_recovery + s25_autosave_recovery, + s27_move_triad ); /// Scenarios written ahead of their fixes or features (the user's open bugs): run with diff --git a/crates/ui-egui/tests/scenarios/s27_move_triad.json b/crates/ui-egui/tests/scenarios/s27_move_triad.json new file mode 100644 index 00000000..23d9f7f9 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s27_move_triad.json @@ -0,0 +1,20 @@ +[ + {"note": "Move opens with nothing moved; the triad's X arrow moves +20 X only; a ring turns 90 degrees"}, + {"call": "engine.execute", "params": {"command": "PrimitiveBox", "params": {"length": 20, "width": 30, "height": 10}}}, + {"call": "ui.view", "params": {"view": "iso"}}, + {"start": "FusionMoveCommand"}, + {"click": {"world": [10, 15, 10]}}, + {"key": "Enter"}, + {"expect": {"errors": 0, "dialog": null, "bbox": {"body": "Body1", "min": [0, 0, 0], "max": [20, 30, 10]}}}, + {"start": "FusionMoveCommand"}, + {"click": {"world": [10, 15, 10]}}, + {"drag": [{"gizmo": "x"}, {"gizmo": "x", "by": [20, 0, 0]}]}, + {"key": "Enter"}, + {"expect": {"errors": 0, "dialog": null, "bbox": {"body": "Body1", "min": [20, 0, 0], "max": [40, 30, 10]}}}, + {"start": "FusionMoveCommand"}, + {"click": {"world": [30, 15, 10]}}, + {"drag": [{"gizmo": "rz", "turn": 60}, {"gizmo": "rz", "turn": 150}], "steps": 12}, + {"key": "Enter"}, + {"expect": {"errors": 0, "dialog": null, "bbox": {"body": "Body1", "min": [15, 5, 0], "max": [45, 25, 10]}}}, + {"shot": "s27_move_triad"} +]