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:
bherbruckandClaude Opus 5.5 committed 2026-10-08 14:14:29 +00:00
1 parent fba397ff65
commit 2b9ed16cc8
8 files changed
+794 -28

No files matched your search

+10 -1
View File
@@ -52,7 +52,6 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
| 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)
{
+5
View File
@@ -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
};
+16 -26
View File
@@ -156,10 +156,17 @@ pub enum Kind {
of: Vec<Sel>,
},
/// 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<Vec<(String, Value)>, 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<usize>) -> Option<Dia
}
FeatureKind::Move { bodies, translate, rotate_axis, angle } => {
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 => {
+314
View File
@@ -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();
}
}
+425
View File
@@ -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);
}
}
+2
View File
@@ -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;
+2 -1
View File
@@ -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
@@ -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"}
]