Move/Copy like Fusion: opens with nothing moved and a full triad at the bodies' centre (X/Y/Z arrows, plane squares, rotation rings, free-drag centre ball; snapped drags, Alt/Ctrl free), Move Type (Free Move, Translate, Rotate, Point to Point, Point to Position), X/Y/Z distances and angles with live preview, a pivot input; the triad is one shared module (gizmo.rs); UI scenario s27
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
fba397ff65
commit
2b9ed16cc8
8 files changed
+794
-28
No files matched your search
@@ -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<SelInput> {
|
||||
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<Vec3> {
|
||||
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<String> {
|
||||
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<Vec3> {
|
||||
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<Vec3> {
|
||||
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<f64, String> {
|
||||
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<f64, String> {
|
||||
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<Value, String> {
|
||||
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<usize> = (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<gizmo::Triad> {
|
||||
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<Vec3>, 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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user