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
@@ -52,7 +52,6 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
|
|||||||
| Kind::OffsetFaces { .. } => (at, normal),
|
| Kind::OffsetFaces { .. } => (at, normal),
|
||||||
// Shell thickness grows into the body.
|
// Shell thickness grows into the body.
|
||||||
Kind::Shell { .. } => (at, normal.map(|n| -n)),
|
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::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)),
|
||||||
Kind::Plastic(_) => (at, 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)),
|
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);
|
app.dialog = Some(d);
|
||||||
return;
|
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)
|
if let Some((base, dir)) = anchor(app, &d)
|
||||||
&& let Some(bs) = proj.to_screen(base)
|
&& let Some(bs) = proj.to_screen(base)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -280,6 +280,11 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
|||||||
// Along an origin axis, on its solid part.
|
// Along an origin axis, on its solid part.
|
||||||
let size = crate::selection::origin_size(app.cam.half_height());
|
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))
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -156,10 +156,17 @@ pub enum Kind {
|
|||||||
of: Vec<Sel>,
|
of: Vec<Sel>,
|
||||||
},
|
},
|
||||||
/// Move bodies by a distance along X, Y and Z.
|
/// 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 {
|
Move {
|
||||||
|
mode: usize,
|
||||||
x: String,
|
x: String,
|
||||||
y: String,
|
y: String,
|
||||||
z: String,
|
z: String,
|
||||||
|
rx: String,
|
||||||
|
ry: String,
|
||||||
|
rz: String,
|
||||||
|
angle: String,
|
||||||
},
|
},
|
||||||
Hole {
|
Hole {
|
||||||
diameter: String,
|
diameter: String,
|
||||||
@@ -367,9 +374,7 @@ impl Dialog {
|
|||||||
Kind::Sweep { operation: usize::from(has_bodies) },
|
Kind::Sweep { operation: usize::from(has_bodies) },
|
||||||
vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true)],
|
vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true)],
|
||||||
),
|
),
|
||||||
"FusionMoveCommand" => {
|
"FusionMoveCommand" => Dialog::new(crate::dialogs_move::kind(0), crate::dialogs_move::inputs(0)),
|
||||||
Dialog::new(Kind::Move { x: "0 mm".into(), y: "0 mm".into(), z: "10 mm".into() }, vec![SelInput::new("Bodies", BODIES, true)])
|
|
||||||
}
|
|
||||||
"FusionHoleCommand" => Dialog::new(hole_defaults(), vec![SelInput::new("Face", PLANAR_FACES, true)]),
|
"FusionHoleCommand" => Dialog::new(hole_defaults(), vec![SelInput::new("Face", PLANAR_FACES, true)]),
|
||||||
"PrimitiveBox" => Dialog::new(
|
"PrimitiveBox" => Dialog::new(
|
||||||
Kind::Primitive {
|
Kind::Primitive {
|
||||||
@@ -550,7 +555,7 @@ impl Dialog {
|
|||||||
Kind::Shell { thickness } => ("Thickness", ValueKind::Length, thickness),
|
Kind::Shell { thickness } => ("Thickness", ValueKind::Length, thickness),
|
||||||
Kind::Draft { angle } => ("Angle", ValueKind::Angle, angle),
|
Kind::Draft { angle } => ("Angle", ValueKind::Angle, angle),
|
||||||
Kind::Hole { diameter, .. } => ("Diameter", ValueKind::Length, diameter),
|
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::PatternRect { spacing, .. } => ("Spacing", ValueKind::Length, spacing),
|
||||||
Kind::PatternCirc { angle, .. } => ("Angle", ValueKind::Angle, angle),
|
Kind::PatternCirc { angle, .. } => ("Angle", ValueKind::Angle, angle),
|
||||||
Kind::Section { offset, .. } => ("Distance", ValueKind::Length, offset),
|
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
|
/// Rows above the selection inputs: the pattern type and object type, the Hole placement (one
|
||||||
/// face point, or sketch points).
|
/// face point, or sketch points).
|
||||||
fn placement_row(d: &mut Dialog, ui: &mut egui::Ui) {
|
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 {
|
let current = match d.kind {
|
||||||
Kind::PatternRect { .. } => Some(0),
|
Kind::PatternRect { .. } => Some(0),
|
||||||
Kind::PatternCirc { .. } => Some(1),
|
Kind::PatternCirc { .. } => Some(1),
|
||||||
@@ -1370,13 +1379,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
combo(ui, "sw_op", &OP_LABELS, operation);
|
combo(ui, "sw_op", &OP_LABELS, operation);
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
}
|
}
|
||||||
Kind::Move { x, y, z } => {
|
k @ Kind::Move { .. } => enter |= crate::dialogs_move::rows(ui, k),
|
||||||
for (l, v) in [("X distance", x), ("Y distance", y), ("Z distance", z)] {
|
|
||||||
row_label(ui, l);
|
|
||||||
enter |= field(ui, v);
|
|
||||||
ui.end_row();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } => {
|
Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } => {
|
||||||
row_label(ui, "Extents");
|
row_label(ui, "Extents");
|
||||||
let mut ext = usize::from(opts.all);
|
let mut ext = usize::from(opts.all);
|
||||||
@@ -2031,10 +2034,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
|||||||
};
|
};
|
||||||
("FusionHalfSectionViewCommand", json!({"plane": plane, "offset": offset, "flip": flip}))
|
("FusionHalfSectionViewCommand", json!({"plane": plane, "offset": offset, "flip": flip}))
|
||||||
}
|
}
|
||||||
Kind::Move { x, y, z } => {
|
Kind::Move { .. } => return Ok(vec![("FusionMoveCommand".into(), crate::dialogs_move::params(app, d)?)]),
|
||||||
need(0, "bodies")?;
|
|
||||||
("FusionMoveCommand", json!({"bodies": body_names(0), "translate": [x, y, z]}))
|
|
||||||
}
|
|
||||||
Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } => {
|
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" })?;
|
need(0, if opts.multiple { "sketch points" } else { "a face position" })?;
|
||||||
let ty = match *kind {
|
let ty = match *kind {
|
||||||
@@ -2709,17 +2709,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
|||||||
}
|
}
|
||||||
FeatureKind::Move { bodies, translate, rotate_axis, angle } => {
|
FeatureKind::Move { bodies, translate, rotate_axis, angle } => {
|
||||||
let mut d = start("FusionMoveCommand")?;
|
let mut d = start("FusionMoveCommand")?;
|
||||||
let [x, y, z] = translate.clone();
|
crate::dialogs_move::for_feature(&mut d, bodies, translate, *rotate_axis, angle.as_ref());
|
||||||
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));
|
|
||||||
}
|
|
||||||
d
|
d
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Option<(Vec3, f64)>> = 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<Vec3> {
|
||||||
|
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<Vec3> {
|
||||||
|
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<f64> {
|
||||||
|
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<Pos2>)>,
|
||||||
|
rings: Vec<(usize, Vec<Pos2>)>,
|
||||||
|
ball: Pos2,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shapes(proj: &Proj, t: &Triad, l: f64) -> Option<Shapes> {
|
||||||
|
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<Pos2> = [(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<Pos2> = (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<Handle> {
|
||||||
|
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<f64> {
|
||||||
|
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<Drag> {
|
||||||
|
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<State> = 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::<State>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,10 +18,12 @@ pub mod dialogs;
|
|||||||
pub mod dialogs_appearance;
|
pub mod dialogs_appearance;
|
||||||
pub mod dialogs_assembly;
|
pub mod dialogs_assembly;
|
||||||
pub mod dialogs_motion;
|
pub mod dialogs_motion;
|
||||||
|
pub mod dialogs_move;
|
||||||
pub mod dialogs_plastic;
|
pub mod dialogs_plastic;
|
||||||
pub mod dialogs_sheet;
|
pub mod dialogs_sheet;
|
||||||
pub mod dim_view;
|
pub mod dim_view;
|
||||||
pub mod documents;
|
pub mod documents;
|
||||||
|
pub mod gizmo;
|
||||||
pub mod gpu;
|
pub mod gpu;
|
||||||
pub mod help;
|
pub mod help;
|
||||||
pub mod home;
|
pub mod home;
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ scenarios!(
|
|||||||
s22_sheet_metal_fold,
|
s22_sheet_metal_fold,
|
||||||
s23_configurations,
|
s23_configurations,
|
||||||
s24_motion_study,
|
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
|
/// Scenarios written ahead of their fixes or features (the user's open bugs): run with
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
]
|
||||||
Reference in new issue
Block a user