Motion Trace: a point's path as a joint is driven or a study plays, kept as a 3D spline (#1529)

Thanks @SychPL.
This commit is contained in:
Mateusz Sych authored and GitHub committed 2026-10-16 01:54:34 -04:00
1 parent dcffab09b1
commit 571122c4ba
19 files changed
+769 -9

No files matched your search

+1
View File
@@ -56,6 +56,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops of any shape and number of sides as one face, G0/G1/G2 to the faces beside each edge, through points, #759), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), gears, rack and pinion and screws (motion links whose ratio follows picked radii, parameters or a pitch, also inside closed loops), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Motion Trace (a picked point's path as a joint is driven or a study plays, closed loops following; kept as a 3D sketch spline), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Gear, Rack and Pinion, Screw and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), gears, rack and pinion and screws (motion links whose ratio follows picked radii, parameters or a pitch, also inside closed loops), inverse kinematics (`joint.reach`: a part's point to a target through the joints on its chain, within limits and with loops closed, nearest pose and its stopper when out of reach; dragging a jointed part in the viewport moves it as far as its joints allow), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Gear, Rack and Pinion, Screw and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
+8
View File
@@ -1293,6 +1293,14 @@ pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: C
pen.arc(12.0, 12.0, 8.5, 0.3, 4.6, a);
pen.arrow((11.0, 20.4), (14.5, 20.3), a);
}
// A crank on its pivot and the closed path its far end draws (accent).
"trace" => {
pen.curve([(15.0, 13.0), (22.0, 13.0), (23.0, 3.0), (15.0, 3.0)], a);
pen.curve([(15.0, 3.0), (8.0, 3.0), (9.0, 13.0), (15.0, 13.0)], a);
pen.line(&[(5.0, 20.0), (15.0, 13.0)], ink);
pen.circle(5.0, 20.0, 2.4, pen.fill, ink);
pen.dot(15.0, 13.0, a);
}
"motion_link" => {
pen.circle(7.0, 14.0, 4.5, pen.fill, ink);
pen.circle(17.5, 9.0, 3.0, pen.fill, ink);
+5
View File
@@ -228,6 +228,8 @@
"cmd.motion_list": "Bewegungsuntersuchungen auflisten",
"cmd.motion_play": "Bewegungsuntersuchung abspielen",
"cmd.motion_study": "Bewegungsuntersuchung",
"cmd.motion_trace": "Bewegungsbahn",
"cmd.motion_trace_keep": "Bewegungsbahn behalten",
"cmd.occurrence_copy": "Einfügen (Instanz)",
"cmd.occurrence_ground": "Festsetzen",
"cmd.occurrence_move": "Instanz verschieben",
@@ -549,6 +551,9 @@
"toolbar.image": "BILD",
"toolbar.render": "RENDERN",
"toolbar.render_scene": "SZENE",
"trace.points": "{count} Punkte entlang der Bahn",
"trace.stopped_by_contact": "Endet bei Probe {sample}: {a} würde {b} durchdringen",
"trace.stopped_by_loop": "Endet bei Probe {sample}: die Schleife über {joint} lässt sich nicht weiter schließen",
"ui.1_degree_of_freedom": "1 Freiheitsgrad",
"ui.3d_sketch": "3D-Skizze",
"ui.about": "Über",
+5
View File
@@ -429,6 +429,8 @@
"cmd.motion_list": "List Motion Studies",
"cmd.motion_play": "Play Motion Study",
"cmd.motion_study": "Motion Study",
"cmd.motion_trace": "Motion Trace",
"cmd.motion_trace_keep": "Keep Motion Trace",
"cmd.occurrence_copy": "Paste (Instance)",
"cmd.occurrence_ground": "Ground",
"cmd.occurrence_move": "Move Occurrence",
@@ -766,6 +768,9 @@
"toolbar.image": "IMAGE",
"toolbar.render": "RENDER",
"toolbar.render_scene": "SCENE",
"trace.points": "{count} points along the path",
"trace.stopped_by_contact": "Ends at sample {sample}: {a} would pass through {b}",
"trace.stopped_by_loop": "Ends at sample {sample}: the loop through {joint} can't close any further",
"ui.1_degree_of_freedom": "1 degree of freedom",
"ui.3d_mouse_speed": "3D mouse speed",
"ui.3d_mouse_windows": "3D mouse",
+5
View File
@@ -409,6 +409,8 @@
"cmd.motion_list": "Lister les études de mouvement",
"cmd.motion_play": "Lire l'étude de mouvement",
"cmd.motion_study": "Étude de mouvement",
"cmd.motion_trace": "Trajectoire de mouvement",
"cmd.motion_trace_keep": "Conserver la trajectoire de mouvement",
"cmd.occurrence_copy": "Coller (instance)",
"cmd.occurrence_ground": "Fixer",
"cmd.occurrence_move": "Déplacer l'occurrence",
@@ -746,6 +748,9 @@
"toolbar.image": "IMAGE",
"toolbar.render": "RENDU",
"toolbar.render_scene": "SCÈNE",
"trace.points": "{count} points le long de la trajectoire",
"trace.stopped_by_contact": "Se termine à l'échantillon {sample} : {a} traverserait {b}",
"trace.stopped_by_loop": "Se termine à l'échantillon {sample} : la boucle passant par {joint} ne peut plus se fermer",
"ui.1_degree_of_freedom": "1 degré de liberté",
"ui.3d_mouse_speed": "Vitesse de la souris 3D",
"ui.3d_mouse_windows": "Souris 3D",
+5
View File
@@ -429,6 +429,8 @@
"cmd.motion_list": "モーション スタディを一覧表示",
"cmd.motion_play": "モーション スタディを再生",
"cmd.motion_study": "モーション スタディ",
"cmd.motion_trace": "モーション トレース",
"cmd.motion_trace_keep": "モーション トレースを保持",
"cmd.occurrence_copy": "貼り付け(インスタンス)",
"cmd.occurrence_ground": "空間固定",
"cmd.occurrence_move": "オカレンスを移動",
@@ -766,6 +768,9 @@
"toolbar.image": "画像",
"toolbar.render": "レンダリング",
"toolbar.render_scene": "シーン",
"trace.points": "経路上の {count} 点",
"trace.stopped_by_contact": "サンプル {sample} で終了: {a} が {b} を通り抜けてしまいます",
"trace.stopped_by_loop": "サンプル {sample} で終了: {joint} を通るループはこれ以上閉じられません",
"ui.1_degree_of_freedom": "自由度 1",
"ui.3d_mouse_speed": "3D マウス速度",
"ui.3d_mouse_windows": "3D マウス",
+5
View File
@@ -228,6 +228,8 @@
"cmd.motion_list": "운동 연구 목록",
"cmd.motion_play": "운동 연구 재생",
"cmd.motion_study": "운동 연구",
"cmd.motion_trace": "모션 궤적",
"cmd.motion_trace_keep": "모션 궤적 유지",
"cmd.occurrence_copy": "붙여넣기(인스턴스)",
"cmd.occurrence_ground": "고정",
"cmd.occurrence_move": "인스턴스 이동",
@@ -549,6 +551,9 @@
"toolbar.image": "이미지",
"toolbar.render": "렌더링",
"toolbar.render_scene": "장면",
"trace.points": "경로를 따라 {count}개 점",
"trace.stopped_by_contact": "샘플 {sample}에서 끝남: {a}이(가) {b}을(를) 통과합니다",
"trace.stopped_by_loop": "샘플 {sample}에서 끝남: {joint}을(를) 지나는 루프를 더 이상 닫을 수 없습니다",
"ui.1_degree_of_freedom": "자유도 1개",
"ui.3d_sketch": "3D 스케치",
"ui.about": "정보",
+5
View File
@@ -381,6 +381,8 @@
"cmd.motion_list": "Список исследований движения",
"cmd.motion_play": "Воспроизвести исследование движения",
"cmd.motion_study": "Исследование движения",
"cmd.motion_trace": "Траектория движения",
"cmd.motion_trace_keep": "Сохранить траекторию движения",
"cmd.occurrence_copy": "Вставить (вхождение)",
"cmd.occurrence_ground": "Закрепить",
"cmd.occurrence_move": "Переместить вхождение",
@@ -705,6 +707,9 @@
"toolbar.image": "ИЗОБРАЖЕНИЕ",
"toolbar.render": "РЕНДЕРИНГ",
"toolbar.render_scene": "СЦЕНА",
"trace.points": "{count} точек на траектории",
"trace.stopped_by_contact": "Заканчивается на отсчёте {sample}: {a} прошёл бы сквозь {b}",
"trace.stopped_by_loop": "Заканчивается на отсчёте {sample}: контур через {joint} дальше не замыкается",
"ui.1_degree_of_freedom": "1 степень свободы",
"ui.3d_mouse_speed": "Скорость 3D-мыши",
"ui.3d_mouse_windows": "3D-мышь",
+5
View File
@@ -386,6 +386,8 @@
"cmd.motion_list": "Список досліджень руху",
"cmd.motion_play": "Відтворити дослідження руху",
"cmd.motion_study": "Дослідження руху",
"cmd.motion_trace": "Траєкторія руху",
"cmd.motion_trace_keep": "Зберегти траєкторію руху",
"cmd.occurrence_copy": "Вставити (екземпляр)",
"cmd.occurrence_ground": "Закріпити",
"cmd.occurrence_move": "Перемістити екземпляр",
@@ -634,6 +636,9 @@
"timeline.reorder_many": "Перевпорядкувати операції",
"timeline.ungroup": "Прибрати теки операцій",
"to the next face": "до наступної грані",
"trace.points": "{count} точок на траєкторії",
"trace.stopped_by_contact": "Закінчується на відліку {sample}: {a} пройшов би крізь {b}",
"trace.stopped_by_loop": "Закінчується на відліку {sample}: контур через {joint} далі не замикається",
"ui.1_degree_of_freedom": "1 ступінь вільності",
"ui.3d_mouse_speed": "Швидкість 3D-миші",
"ui.3d_mouse_windows": "3D-миша",
+5
View File
@@ -429,6 +429,8 @@
"cmd.motion_list": "列出运动研究",
"cmd.motion_play": "播放运动研究",
"cmd.motion_study": "运动研究",
"cmd.motion_trace": "运动轨迹",
"cmd.motion_trace_keep": "保留运动轨迹",
"cmd.occurrence_copy": "粘贴(实例)",
"cmd.occurrence_ground": "固定",
"cmd.occurrence_move": "移动实例",
@@ -766,6 +768,9 @@
"toolbar.image": "图像",
"toolbar.render": "渲染",
"toolbar.render_scene": "场景",
"trace.points": "路径上的 {count} 个点",
"trace.stopped_by_contact": "在采样 {sample} 处结束:{a} 会穿过 {b}",
"trace.stopped_by_loop": "在采样 {sample} 处结束:经过 {joint} 的环路无法再闭合",
"ui.1_degree_of_freedom": "1 个自由度",
"ui.3d_mouse_speed": "3D 鼠标速度",
"ui.3d_mouse_windows": "3D 鼠标",
+230
View File
@@ -1179,5 +1179,235 @@ fn a_new_joint_starts_inside_its_limits_and_says_so() {
assert!(r.get("warnings").is_none(), "{r}");
}
/// The trace's points as world points.
fn traced(r: &Value) -> Vec<Vec3> {
r["points"].as_array().into_iter().flatten().filter_map(crate::params::vec3).collect()
}
/// A point rigidly carried from segment (a0, b0) to segment (a, b) in the XY plane.
fn carried(p0: Vec3, a0: Vec3, b0: Vec3, a: Vec3, b: Vec3) -> Vec3 {
let turn = (b.y - a.y).atan2(b.x - a.x) - (b0.y - a0.y).atan2(b0.x - a0.x);
let d = p0 - a0;
a + Vec3::new(d.x * turn.cos() - d.y * turn.sin(), d.x * turn.sin() + d.y * turn.cos(), d.z)
}
/// A point on the four-bar's coupler, 10 mm off the line between its pins, traces the coupler
/// curve: the crank pin on its circle, the rocker pin where the coupler and rocker circles meet
/// (on the open branch), and the point carried with the pins. A motion study turning the crank
/// traces the same curve, and the design is left as it was.
#[test]
fn four_bar_coupler_point_traces_its_closed_form_curve() {
let (mut s, p0, q0) = four_bar();
let d = (q0 - p0) * (1.0 / q0.dist(p0));
let c0 = (p0 + q0) * 0.5 + Vec3::new(-d.y, d.x, 0.0) * 10.0;
let before = s.doc.clone();
let p = json!({"occurrence": "Coupler", "point": [c0.x, c0.y, c0.z], "joint": "Crank", "from": 0, "to": 360, "samples": 73});
let r = run(&mut s, "motion.trace", p);
assert_eq!(*s.doc, *before, "a trace leaves the design as it was");
assert!(r["stopped_at"].is_null() && r["samples"] == 73, "{r}");
let pts = traced(&r);
assert_eq!(pts.len(), 73);
let mut q = q0;
for (k, got) in pts.iter().enumerate() {
let v = 5.0 * k as f64;
if k > 0 {
q = rocker_pin(v - 5.0, v, q);
}
let th = (90.0 + v).to_radians();
let want = carried(c0, p0, q0, Vec3::new(20.0 * th.cos(), 20.0 * th.sin(), 0.0), q);
assert!(near(*got, want), "{v}°: {got:?} wants {want:?}");
}
assert!(pts.first().zip(pts.last()).is_some_and(|(a, b)| near(*a, *b)), "a full turn closes the curve");
run(&mut s, "motion.study", json!({"name": "Turn", "steps": 72, "keys": [{"joint": "Crank", "points": [[0, 0], [72, 360]]}]}));
let r = run(&mut s, "motion.trace", json!({"occurrence": "Coupler", "point": [c0.x, c0.y, c0.z], "study": "Turn"}));
let from_study = traced(&r);
assert_eq!(from_study.len(), 73, "{r}");
assert!(from_study.iter().zip(&pts).all(|(a, b)| near(*a, *b)), "{r}");
}
/// The middle of a slider-crank's rod: halfway between the crank pin on its circle and the wrist
/// pin at a·cos θ + √(b² − (a·sin θ)²).
#[test]
fn slider_crank_rod_traces_its_closed_form_curve() {
let (mut s, p0, s0) = slider_crank();
let m0 = (p0 + s0) * 0.5;
let r =
run(&mut s, "motion.trace", json!({"occurrence": "Rod", "point": [m0.x, m0.y, m0.z], "joint": "Crank", "from": 0, "to": 360, "samples": 37}));
let pts = traced(&r);
assert_eq!(pts.len(), 37, "{r}");
for (k, got) in pts.iter().enumerate() {
let th = (90.0 + 10.0 * k as f64).to_radians();
let x = 15.0 * th.cos() + (40.0f64.powi(2) - (15.0 * th.sin()).powi(2)).sqrt();
let want = (Vec3::new(15.0 * th.cos(), 15.0 * th.sin(), 0.0) + Vec3::new(x, 0.0, 0.0)) * 0.5;
assert!(near(*got, want), "{}°: {got:?} wants {want:?}", 10 * k);
}
}
/// Driving the rocker past its swing stops where the loop can't close: the trace ends there.
#[test]
fn a_trace_ends_where_the_loop_stops_the_motion() {
let (mut s, _, q0) = four_bar();
let start = q0.y.atan2(q0.x - 45.0).to_degrees();
let r = run(
&mut s,
"motion.trace",
json!({"occurrence": "Rocker", "point": [q0.x, q0.y, q0.z], "joint": "Rocker", "from": 0, "to": 100, "samples": 21}),
);
assert!(r["stopped_by_loop"].is_object(), "{r}");
let k = r["stopped_at"].as_u64().unwrap_or(u64::MAX) as usize;
let pts = traced(&r);
assert!(k > 0 && k < 20 && pts.len() == k + 1 && r["samples"] == k + 1, "{r}");
let angle = |p: &Vec3| p.y.atan2(p.x - 45.0).to_degrees();
for (i, p) in pts.iter().take(k).enumerate() {
assert!((angle(p) - (start + 5.0 * i as f64)).abs() < 1e-6, "sample {i}: {}", angle(p));
}
let limit = ((900.0f64 - 3625.0) / 3600.0).acos().to_degrees();
let last = pts.last().map(angle).unwrap_or(f64::NAN);
assert!(last > limit - 5.0 && last <= limit + 1e-9, "stopped at {last}°, the limit is {limit}°");
}
/// Keep makes a 3D sketch spline through the trace, in one undo step.
#[test]
fn a_kept_trace_is_a_3d_sketch_spline() {
let (mut s, p0, q0) = four_bar();
let c0 = (p0 + q0) * 0.5;
let p = json!({"occurrence": "Coupler", "point": [c0.x, c0.y, c0.z], "joint": "Crank", "from": 0, "to": 180, "samples": 19});
let pts = traced(&run(&mut s, "motion.trace", p.clone()));
let features = s.doc.features.len();
let r = run(&mut s, "motion.trace.keep", p);
assert_eq!((r["name"].as_str(), r["points"].as_u64()), (Some("Motion Trace1"), Some(19)), "{r}");
assert!(s.active_sketch.is_none(), "the sketch is left");
let id = r["sketch"].as_u64().unwrap_or(0);
let sk = s.doc.sketch(id).cloned().unwrap_or_default();
assert!(sk.view.three_d && sk.wires.len() == 1, "{r}");
let fit = sk.wires.first().map(|w| w.fit.clone()).unwrap_or_default();
assert!(fit.len() == pts.len() && fit.iter().zip(&pts).all(|(a, b)| a.dist(*b) < 1e-9), "{fit:?}");
let again = json!({"occurrence": "Coupler", "point": [0, 0, 0], "joint": "Crank", "from": 0, "to": 90});
assert_eq!(run(&mut s, "motion.trace.keep", again)["name"], "Motion Trace2");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.features.len(), features);
// A point on the ground does not move: there is nothing to keep.
let e = s.execute("motion.trace.keep", &json!({"occurrence": "Ground", "point": [0, 0, 0], "joint": "Crank", "from": 0, "to": 90})).unwrap_err();
assert!(e.to_string().contains("does not move"), "{e}");
}
/// A full turn kept closes exactly on its start, also when the sample before the end lies a hair
/// from it (a study keyed to 359.99999° one step before 360°).
#[test]
fn a_kept_full_turn_closes_on_its_start() {
let mut s = Session::default();
part(&mut s, "Ground", [-1.0, -1.0, -5.0]);
part(&mut s, "Arm", [0.0, -1.0, 0.0]);
let g = occ(&s, "Ground");
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
pin(&mut s, "revolute", "Pivot", "Ground", "Arm", Vec3::ZERO, Vec3::Z);
run(&mut s, "motion.study", json!({"name": "Turn", "steps": 5, "keys": [{"joint": "Pivot", "points": [[0, 0], [4, 359.99999], [5, 360]]}]}));
let r = run(&mut s, "motion.trace.keep", json!({"occurrence": "Arm", "point": [1, 0, 0], "study": "Turn"}));
let id = r["sketch"].as_u64().unwrap_or(0);
let fit = s.doc.sketch(id).ok().and_then(|sk| sk.wires.first().map(|w| w.fit.clone())).unwrap_or_default();
assert_eq!(fit.len(), 5, "{fit:?}");
assert_eq!(fit.first(), fit.last(), "{fit:?}");
}
/// Kept while a component is active and shown moved (a move not captured yet): the spline goes
/// in the root, where no joint moves it, through the traced points; the component stays active.
#[test]
fn a_trace_kept_in_a_moved_component_stays_on_the_path() {
let (mut s, p0, q0) = four_bar();
let crank = occ(&s, "Crank");
let c0 = (p0 + q0) * 0.5;
let p = json!({"occurrence": "Coupler", "point": [c0.x, c0.y, c0.z], "joint": "Crank", "from": 0, "to": 90, "samples": 4});
let pts = traced(&run(&mut s, "motion.trace", p.clone()));
run(&mut s, "component.activate", json!({"component": "Crank", "occurrence": crank, "path": [crank]}));
run(&mut s, "occurrence.move", json!({"occurrence": crank, "translate": [0, 0, 100], "capture": false}));
let active = (s.active_component, s.active_occurrence_path.clone());
let r = run(&mut s, "motion.trace.keep", p);
assert_eq!((s.active_component, s.active_occurrence_path.clone()), active);
let id = r["sketch"].as_u64().unwrap_or(0);
assert_eq!(s.doc.feature(id).map(|f| f.component), Some(0), "{r}");
let fit = s.doc.sketch(id).ok().and_then(|sk| sk.wires.first().map(|w| w.fit.clone())).unwrap_or_default();
assert!(fit.len() == pts.len() && fit.iter().zip(&pts).all(|(a, b)| near(*a, *b)), "{fit:?} wants {pts:?}");
}
/// A part inside a sub-assembly placed twice: the occurrence path picks the instance traced. The
/// second Arm turns about its pivot and its Tip goes round; the first Arm, and so the Tip at its
/// first placement, stays put.
#[test]
fn a_trace_follows_the_instance_its_path_names() {
let mut s = Session::default();
part(&mut s, "Ground", [-1.0, -1.0, -5.0]);
let g = occ(&s, "Ground");
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
run(&mut s, "component.activate", json!({"component": "root"}));
let arm = run(&mut s, "component.create", json!({"name": "Arm"}));
run(&mut s, "component.create", json!({"name": "Tip"}));
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [10, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
let second = run(&mut s, "occurrence.copy", json!({"component": arm["component"], "translate": [0, 50, 0]}))["occurrence"].as_u64().unwrap_or(0);
let at = |o: Value| json!({"occurrence": o, "point": [0, 50, 0], "z": [0, 0, 1]});
run(&mut s, "joint.create", json!({"type": "revolute", "a": at(json!(g)), "b": at(json!(second)), "flip": true, "name": "Pivot"}));
let tip = occ(&s, "Tip");
let trace = |s: &mut Session, o: Value| {
traced(&run(s, "motion.trace", json!({"occurrence": o, "point": [11, 51, 1], "joint": "Pivot", "from": 0, "to": 90, "samples": 3})))
};
let turned = trace(&mut s, json!([second, tip]));
let pivot = Vec3::new(0.0, 50.0, 1.0);
assert!(turned.iter().all(|p| (p.dist(pivot) - 122f64.sqrt()).abs() < 1e-6), "{turned:?}");
assert!(turned.first().zip(turned.last()).is_some_and(|(a, b)| (a.dist(*b) - 244f64.sqrt()).abs() < 1e-6), "a quarter turn: {turned:?}");
let first = trace(&mut s, json!(tip));
assert!(first.iter().all(|p| p.dist(Vec3::new(11.0, 51.0, 1.0)) < 1e-9), "{first:?}");
assert!(s.execute("motion.trace", &json!({"occurrence": [tip, second], "point": [0, 0, 0], "joint": "Pivot", "from": 0, "to": 90})).is_err());
assert!(s.execute("motion.trace", &json!({"occurrence": vec![second; 65], "point": [0, 0, 0], "joint": "Pivot", "from": 0, "to": 90})).is_err());
}
/// Trace params come from scripts and the control channel: bad ones are refused, never a panic.
#[test]
fn hostile_trace_params_are_refused() {
let (mut s, _, _) = four_bar();
run(&mut s, "motion.study", json!({"name": "Turn", "steps": 4, "keys": [{"joint": "Crank", "points": [[0, 0], [4, 90]]}]}));
let base = json!({"occurrence": "Coupler", "point": [0, 0, 0], "joint": "Crank", "from": 0, "to": 90});
let with = |k: &str, v: Value| {
let mut p = base.clone();
p[k] = v;
p
};
let without = |k: &str| {
let mut p = base.clone();
p.as_object_mut().map(|o| o.remove(k));
p
};
for p in [
with("samples", json!(0)),
with("samples", json!(1)),
with("samples", json!(501)),
with("samples", json!(1e300)),
with("samples", json!(-3)),
with("samples", json!("many")),
with("point", json!([f64::MAX, 0, 0])),
with("point", json!([0, 0])),
with("point", json!("here")),
without("point"),
with("occurrence", json!("Nowhere")),
without("occurrence"),
with("joint", json!("Nowhere")),
with("index", json!(1)),
with("index", json!(-1)),
with("from", json!("nonsense +")),
with("to", json!(1e300)),
without("from"),
without("to"),
with("study", json!("Turn")),
with("study", json!(null)),
without("joint"),
] {
for cmd in ["motion.trace", "motion.trace.keep"] {
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
}
}
run(&mut s, "joint.as_built", json!({"type": "rigid", "a": "Ground", "b": "Crank", "name": "Lock"}));
assert!(s.execute("motion.trace", &with("joint", json!("Lock"))).is_err(), "a rigid joint does not move");
}
#[path = "joints_coupling_tests.rs"]
mod coupling;
+178
View File
@@ -17,6 +17,14 @@ use crate::{EngineError, Result, Session};
#[path = "contact_sweep.rs"]
mod contact_sweep;
macro_rules! trace_doc {
() => {
"occurrence: id|name, or an occurrence path [ids] (an instance inside sub-assemblies); point: [x,y,z] world point on it, where it is placed now; study: id|name, or joint: id|name with from and \
to (degrees for angles, mm for distances, or expressions) and index? (which value, default 0); samples? (2…500, default \
the study's steps + 1, or 100) - the point's path as the study plays or the joint is driven, closed loops following"
};
}
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("contact.enable_sets", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("joint").params(
"enabled?: bool (default true) — driving joints and motion studies stop where bodies in one contact set would pass through each other",
@@ -46,6 +54,16 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("motion.export", "Export Motion Study", export)
.noundo()
.params("study; samples? (default steps + 1); path? (.json or .csv) → occurrence positions per step (the design is not changed)"),
CommandSpec::new("motion.trace", "Motion Trace", trace).at("SOLID", "ASSEMBLE").icon("trace").noundo().params(concat!(
trace_doc!(),
" → points: [[x,y,z]…] world positions, samples (taken), stopped_at (the sample where the motion stopped short; the trace \
ends there), stopped_by_loop, stopped_by_contact (the design is not changed)"
)),
CommandSpec::new("motion.trace.keep", "Keep Motion Trace", keep_trace).params(concat!(
trace_doc!(),
"; name? (default Motion TraceN) - keeps the trace as a 3D sketch spline through its points (a new sketch with 3D Sketch \
on, in the root component, where no joint moves it; a fixed curve, it does not follow later changes) → sketch, name, curves, points"
)),
CommandSpec::new("motion.list", "List Motion Studies", list_studies).noundo(),
CommandSpec::new("motion.delete", "Delete Motion Study", delete_study).params("study"),
CommandSpec::new("explode.create", "Exploded View", explode_create).at("SOLID", "ASSEMBLE").icon("joint").params(
@@ -658,6 +676,166 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
/// Most samples of a motion trace: each is a solve, and a kept trace keeps every one as a fit
/// point of its spline.
const MAX_TRACE: usize = solidcraft_sketch::MAX_FIT_POINTS;
/// The joint values a trace drives to at sample `k` of `n`.
type Plan = Box<dyn Fn(&Session, usize, usize) -> Vec<(u64, Vec<f64>)>>;
/// Where a point of an occurrence goes as a study plays or a joint is driven: its world
/// positions, sampled on a copy (the design is not changed). Each sample is driven from the one
/// before, so closed loops follow on their branch and contact sets stop them; the trace ends at
/// the sample where the motion stops short. Returns the points and the answer without them.
fn sample_trace(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<Vec3>, Value)> {
// The placement traced: an occurrence path through nested sub-assemblies (one instance of
// each), or an occurrence at its first placement (none: the root).
let path: Vec<u64> = match p.get("occurrence") {
Some(Value::Array(ids)) if ids.len() <= 64 => {
ids.iter().map(|v| v.as_u64().ok_or_else(|| bad(cmd, "an occurrence path lists occurrence ids"))).collect::<Result<_>>()?
}
Some(Value::Array(_)) => return Err(bad(cmd, "an occurrence path has at most 64 ids")),
v => {
let occ = occurrence_id(s, v, cmd)?;
match s.doc.occurrences.iter().find(|o| o.id == occ) {
Some(o) => s.doc.first_path(o.parent).into_iter().chain([o.id]).collect(),
None => Vec::new(),
}
}
};
let at = p.get("point").and_then(vec3).filter(|v| v.len() < 1e7).ok_or_else(|| bad(cmd, "`point` must be [x, y, z], a world point"))?;
let mut t = s.scratch();
// Traced from where the parts are placed: moves only shown (an exploded view, a move not
// captured yet) are not where the joints are solved.
t.pending_moves.clear();
let world = |t: &Session| -> Result<Mat> {
if path.is_empty() {
Ok(solidcraft_doc::IDENTITY)
} else {
t.occurrence_placement(&path).map(|o| o.world).map_err(|e| bad(cmd, e.to_string()))
}
};
let local = solidcraft_doc::mat_inverse(&world(&t)?)
.map(|m| solidcraft_doc::apply_point(&m, at))
.ok_or_else(|| bad(cmd, "the occurrence's placement is not invertible"))?;
let samples = |default: usize| match p.get("samples") {
Some(v) => v
.as_f64()
.filter(|n| n.is_finite() && *n >= 2.0 && *n <= MAX_TRACE as f64)
.map(|n| n.round() as usize)
.ok_or_else(|| bad(cmd, format!("`samples` must be 2…{MAX_TRACE}"))),
None => Ok(default),
};
let (n, given, plan): (usize, BTreeSet<(u64, usize)>, Plan) = match (p.get("study"), p.get("joint")) {
(Some(_), Some(_)) => return Err(bad(cmd, "give `study` or `joint`, not both")),
(Some(_), None) => {
let m = s.doc.assembly.studies.get(study_index(s, p, cmd)?).cloned().ok_or_else(|| bad(cmd, "study"))?;
let n = samples((m.steps as usize).saturating_add(1).clamp(2, MAX_TRACE))?;
(n, keyed(&m), Box::new(move |t, k, n| values_at(t, &m, f64::from(m.steps) * k as f64 / (n - 1) as f64)))
}
(None, Some(j)) => {
let id = joint_id(s, Some(j), cmd)?;
let kind = s.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.kind).ok_or_else(|| bad(cmd, "joint"))?;
let dofs = kind.dofs().len();
if dofs == 0 {
return Err(bad(cmd, "a rigid joint does not move"));
}
let index = match p.get("index") {
None => 0,
Some(v) => v
.as_u64()
.and_then(|i| usize::try_from(i).ok())
.filter(|i| *i < dofs)
.ok_or_else(|| bad(cmd, format!("`index` must be below {dofs}, the joint's value count")))?,
};
let end = |key: &str| match p.get(key) {
Some(v) => value(s, v, kind.is_angle(index), cmd),
None => Err(bad(cmd, format!("give `from` and `to`: the joint's values the trace runs between (no `{key}`)"))),
};
let (from, to) = (end("from")?, end("to")?);
let plan: Plan = Box::new(move |t, k, n| {
let mut q = t.doc.assembly.joints.iter().find(|j| j.id == id).map(|j| j.values.clone()).unwrap_or_default();
q.resize(q.len().max(dofs), 0.0);
if let Some(x) = q.get_mut(index) {
*x = from + (to - from) * k as f64 / (n - 1) as f64;
}
vec![(id, q)]
});
(samples(100)?, BTreeSet::from([(id, index)]), plan)
}
(None, None) => return Err(bad(cmd, "give a `study`, or a `joint` with `from` and `to`")),
};
let mut points = Vec::new();
let mut stop = (Value::Null, Value::Null, Value::Null);
for k in 0..n {
let want = plan(&t, k, n);
let (r, _) = drive_with_contact(&mut t, &want, &given)?;
points.push(solidcraft_doc::apply_point(&world(&t)?, local));
if r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object() {
stop = (json!(k), r["stopped_by_loop"].clone(), r["stopped_by_contact"].clone());
break;
}
}
let name = s.doc.occurrences.iter().find(|o| Some(&o.id) == path.last()).map_or_else(|| "root".to_string(), |o| o.name.clone());
let out = json!({"occurrence": name, "samples": points.len(), "stopped_at": stop.0, "stopped_by_loop": stop.1, "stopped_by_contact": stop.2});
Ok((points, out))
}
fn trace(s: &mut Session, p: &Value) -> Result<Value> {
let (points, mut out) = sample_trace(s, p, "motion.trace")?;
out["points"] = points.iter().map(|v| json!([v.x, v.y, v.z])).collect();
Ok(out)
}
/// The trace as a 3D sketch spline, in one undo step: a new sketch of the root component (the
/// world, which no joint moves) with 3D Sketch on and the spline through the trace's points.
fn keep_trace(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "motion.trace.keep";
let (mut points, mut out) = sample_trace(s, p, cmd)?;
// Where the motion stood still (at a limit or a stop), one fit point.
points.dedup_by(|a, b| a.dist(*b) < 1e-6);
// A full cycle comes back to where it started (to the loop solver's tolerance): the spline
// closes there, smoothly, through the start itself (and a sample that would double it goes).
if points.len() > 3
&& let (Some(&first), Some(&last)) = (points.first(), points.last())
&& first.dist(last) < 1e-4
{
points.pop();
if points.last().is_some_and(|p| p.dist(first) < 1e-6) {
points.pop();
}
points.push(first);
}
if points.len() < 2 {
return Err(bad(cmd, "the point does not move: there is no trace to keep"));
}
let name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => n.to_string(),
None => (1..=s.doc.features.len() + 1).map(|k| format!("Motion Trace{k}")).find(|n| s.doc.find_feature(n).is_none()).unwrap_or_default(),
};
let pts: Vec<Value> = points.iter().map(|v| json!([v.x, v.y, v.z])).collect();
// Made in the root, whatever is being edited, which is then as it was (also on an error).
let before = (s.active_sketch, s.active_component, s.active_occurrence_path.take());
s.active_component = 0;
let made = (|| {
let run = |s: &mut Session, id: &str, q: Value| super::find_command(id).ok_or_else(|| bad(cmd, id.to_string()))?.run_nested(s, &q);
let made = run(s, "sketch.create", json!({"plane": "XY", "project_edges": false, "name": name}))?;
let sketch = made.get("sketch").cloned().unwrap_or(Value::Null);
run(s, "sketch.options", json!({"sketch": sketch, "sketch_3d": true}))?;
let spline = run(s, "sketch.spline3d", json!({"sketch": sketch, "points": pts}))?;
Ok::<_, EngineError>((made, spline))
})();
(s.active_sketch, s.active_component, s.active_occurrence_path) = before;
s.revision += 1;
let (made, spline) = made?;
let sketch = made.get("sketch").cloned().unwrap_or(Value::Null);
out["sketch"] = sketch;
out["name"] = made.get("name").cloned().unwrap_or(Value::Null);
out["curves"] = spline.get("curves").cloned().unwrap_or(Value::Null);
out["points"] = json!(points.len());
Ok(out)
}
fn list_studies(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"studies": s.doc.assembly.studies}))
}
+20
View File
@@ -61,9 +61,13 @@ pub fn fit_polyline(fit: &[Vec3]) -> Vec<Vec3> {
return fit.to_vec();
}
let n = fit.len();
// A curve that ends where it starts is smooth through that point (its neighbours wrap).
let closed = n > 3 && fit[0].dist(fit[n - 1]) < 1e-9;
let at = |i: isize| -> Vec3 {
match usize::try_from(i) {
Ok(i) if i < n => fit[i],
Ok(_) if closed => fit[1],
Err(_) if closed => fit[n - 2],
// Mirrored end tangents.
Ok(_) => fit[n - 1] * 2.0 - fit[n - 2],
Err(_) => fit[0] * 2.0 - fit[1],
@@ -330,4 +334,20 @@ mod tests {
assert_eq!(sk, before);
sk.validate_wires().unwrap();
}
/// Through points around a circle that end where they start, the spline turns as smoothly at
/// the closing point as everywhere else; an open one keeps its mirrored end tangents.
#[test]
fn a_closed_spline_is_smooth_where_it_closes() {
let ring: Vec<Vec3> = (0..=12).map(|k| f64::from(k) * std::f64::consts::TAU / 12.0).map(|a| Vec3::new(a.cos(), a.sin(), 0.0)).collect();
let pts = fit_polyline(&ring);
let dir = |a: Vec3, b: Vec3| (b - a).normalized().unwrap();
let n = pts.len();
let (start, end) = (dir(pts[0], pts[1]), dir(pts[n - 2], pts[n - 1]));
let mid = |i: usize| dir(pts[i - 1], pts[i]).dot(dir(pts[i], pts[i + 1]));
assert!(start.dot(end) >= mid(SPAN_SAMPLES) - 1e-9, "turn at the close {}, at a fit point {}", start.dot(end), mid(SPAN_SAMPLES));
let open = fit_polyline(&ring[..12]);
assert_eq!(open.first(), ring.first());
assert_eq!(open.last(), ring.get(11));
}
}
+25 -9
View File
@@ -21,6 +21,10 @@ use crate::theme::Tokens;
use crate::viewport::Proj;
#[path = "dialogs_trace.rs"]
mod trace;
pub use trace::TraceForm;
/// Joint types in the order the Type box lists them (command names).
pub const TYPES: [&str; 7] = ["rigid", "revolute", "slider", "cylindrical", "pin_slot", "planar", "ball"];
const TYPE_LABELS: [&str; 7] = ["Rigid", "Revolute", "Slider", "Cylindrical", "Pin-slot", "Planar", "Ball"];
@@ -73,6 +77,8 @@ pub enum Asm {
overlaps: Vec<Mesh>,
of: Vec<Sel>,
},
/// Motion Trace: a picked point's path, previewed; OK keeps it as a 3D sketch spline.
Trace(Box<TraceForm>),
}
/// The snap under the cursor, remembered for the selection it was found for.
@@ -146,6 +152,7 @@ impl Asm {
_ => "SCREW",
},
Asm::Interference { .. } => "INTERFERENCE",
Asm::Trace(_) => "MOTION TRACE",
}
}
@@ -169,16 +176,10 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
"joint.origin" => (Asm::JointOrigin { name: String::new(), hover: None }, vec![SelInput::new("Snap", SNAPS, false)]),
"joint.rigid_group" => (Asm::RigidGroup, vec![SelInput::new("Components", BODIES, true)]),
"joint.drive" => {
// The joint picked in the browser, else the first that moves.
let list = movable(s);
let picked = app
.tree
.picked_joints
.iter()
.find_map(|id| list.iter().copied().find(|i| s.doc.assembly.joints.get(*i).is_some_and(|j| j.id == *id)));
let joint = picked.or(list.first().copied()).unwrap_or(0);
let joint = picked_joint(app);
(Asm::Drive { joint, values: current_values(s, joint), focus: true }, vec![])
}
"motion.trace" => (Asm::Trace(Box::new(trace::form(s, picked_joint(app)))), vec![SelInput::new("Point", SNAPS, false)]),
"joint.motion_link" => {
let b = usize::from(movable(s).len() > 1);
(Asm::MotionLink { a: 0, b, ia: 0, ib: 0, ratio: "1".into(), offset: "0".into() }, vec![])
@@ -234,6 +235,15 @@ pub fn preset(s: &Session, k: &mut Asm, p: &Value) -> Result<(), String> {
Ok(())
}
/// The joint picked in the browser, else the first that moves (an index into the design's joints).
fn picked_joint(app: &SolveApp) -> usize {
let s = &app.session;
let list = movable(s);
let picked =
app.tree.picked_joints.iter().find_map(|id| list.iter().copied().find(|i| s.doc.assembly.joints.get(*i).is_some_and(|j| j.id == *id)));
picked.or(list.first().copied()).unwrap_or(0)
}
/// Indices (into the design's joints) of joints that move (not rigid).
fn movable(s: &Session) -> Vec<usize> {
s.doc.assembly.joints.iter().enumerate().filter(|(_, j)| j.kind != JointKind::Rigid).map(|(i, _)| i).collect()
@@ -691,6 +701,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
interference_rows(app, ui, r);
}
}
Asm::Trace(f) => enter |= trace::rows(app, ui, f, inputs),
}
enter
}
@@ -905,6 +916,7 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result<Vec<(Str
Asm::Coupling(f) => return crate::dialogs_coupling::commands(app, f, inputs),
// The check runs from its Compute button; OK only closes.
Asm::Interference { .. } => Vec::new(),
Asm::Trace(f) => cmd("motion.trace.keep", trace::params(s, f, inputs)?),
})
}
@@ -1052,11 +1064,15 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
}
return;
}
// A joint names its two picks; a joint origin has one.
if let Asm::Trace(f) = k {
trace::overlay(painter, proj, f);
}
// A joint names its two picks; a joint origin and a trace have one.
let two = matches!(k, Asm::Joint(_));
let hover = match k {
Asm::Joint(f) => Some(&mut f.hover),
Asm::JointOrigin { hover, .. } => Some(hover),
Asm::Trace(f) => Some(&mut f.hover),
_ => None,
};
let Some(hover) = hover else { return };
@@ -551,3 +551,53 @@ fn drive_preset_refuses_a_rigid_joint() {
let e = preset(&app.session, &mut k, &json!({"joint": "Glue"})).unwrap_err();
assert!(e.contains("rigid"), "{e}");
}
/// Motion Trace on the four-bar: a pick on the coupler and the first joint (the crank) driven a
/// turn give the trace's parameters, and OK keeps the path as a 3D sketch spline. A pick on the
/// coupler where an exploded view shows it traces from where the coupler is placed.
#[test]
fn motion_trace_keeps_the_picked_point_path() {
let mut app = four_bar();
let coupler = occ(&app, "Coupler");
let top = |app: &SolveApp| {
let b = occurrence_bounds(&app.session, &[coupler]);
Vec3::new((b.min.x + b.max.x) / 2.0, (b.min.y + b.max.y) / 2.0, b.max.z)
};
let placed = top(&app);
app.start("motion.trace");
let mut d = app.dialog.clone().unwrap();
assert!(apply_commands(&app, &d).is_err(), "no point yet");
d.pick(&app.session, face_sel(&app.session, placed).unwrap());
let c = apply_commands(&app, &d).unwrap();
let (id, p) = &c[0];
assert_eq!(id, "motion.trace.keep");
let crank = app.session.doc.assembly.joints.iter().find(|j| j.name == "Crank").unwrap().id;
assert_eq!((&p["occurrence"], &p["joint"], &p["from"], &p["to"]), (&json!([coupler]), &json!(crank), &json!("0 deg"), &json!("360 deg")), "{p}");
let at = |p: &Value| Vec3::new(p["point"][0].as_f64().unwrap(), p["point"][1].as_f64().unwrap(), p["point"][2].as_f64().unwrap());
assert!(at(p).dist(placed) < 1e-6, "{p}");
let preview = app.session.scratch().execute("motion.trace", p).unwrap();
assert!(preview["samples"] == 100 && preview["stopped_at"].is_null(), "{preview}");
crate::dialogs::run_dialog(&mut app, &d).unwrap();
let sketch = app.session.doc.find_feature("Motion Trace1").unwrap().id;
let wire = app.session.doc.sketch(sketch).unwrap().wires[0].clone();
assert_eq!(wire.fit.len(), 100);
assert_eq!(wire.fit.first(), wire.fit.last(), "a full turn closes the spline");
// Exploded: the coupler shows away from its placement; the pick maps back to it.
app.run("explode.create", json!({"name": "Apart", "scale": 1})).unwrap();
app.run("explode.show", json!({"view": "Apart"})).unwrap();
let shown = top(&app);
assert!(shown.dist(placed) > 1.0, "the exploded view moves the coupler");
app.start("motion.trace");
let mut d = app.dialog.clone().unwrap();
d.pick(&app.session, face_sel(&app.session, shown).unwrap());
let p = apply_commands(&app, &d).unwrap()[0].1.clone();
assert!(at(&p).dist(placed) < 1e-6, "{p}");
// The preview is drawn moved with the coupler: it starts at the point picked where it shows.
let start = app.session.scratch().execute("motion.trace", &p).unwrap()["points"][0].clone();
let (_, _, seen, put) = trace::placement(&app.session, &d.inputs).unwrap();
let drawn = {
use solidcraft_engine::doc::{apply_point, mat_inverse};
apply_point(&seen, apply_point(&mat_inverse(&put).unwrap(), at(&json!({"point": start}))))
};
assert!(drawn.dist(shown) < 1e-6, "{drawn:?} wants {shown:?}");
}
+199
View File
@@ -0,0 +1,199 @@
//! Motion Trace: the path a picked point of a component takes while a joint is driven between
//! two values or a motion study plays. The path is computed on a scratch copy (`motion.trace`)
//! and drawn in the viewport; OK keeps it as a 3D sketch spline (`motion.trace.keep`).
use egui::{Pos2, RichText, Stroke};
use serde_json::{Value, json};
use solidcraft_engine::doc::{IDENTITY, Mat, apply_point, mat_inverse, mat_mul};
use solidcraft_engine::geom::Vec3;
use solidcraft_engine::{Sel, Session};
use super::{Hover, body_of, capital, movable, num, pt, sel_items, snap_of};
use crate::dialogs::{combo, field, row_label};
use crate::selection::SelInput;
use crate::theme::Tokens;
use crate::viewport::Proj;
#[derive(Clone, Debug)]
pub struct TraceForm {
/// Traced as a motion study plays (else as a joint is driven from `from` to `to`).
pub by_study: bool,
/// Indices into the design's joints and motion studies.
pub joint: usize,
pub index: usize,
pub from: String,
pub to: String,
pub study: usize,
/// Empty: the command's default.
pub samples: String,
pub hover: Hover,
/// The trace shown: what it was computed for, and `motion.trace`'s answer (or why none).
pub shown: Option<(String, Result<Value, String>)>,
/// The picked part's frame as shown and the inverse of its placement: the traced path (where
/// the parts are placed) is drawn moved with the part, through the picked point.
pub view: Option<(Mat, Mat)>,
}
/// The dialog on a joint (an index into the design's joints), from its value now over a turn.
pub fn form(s: &Session, joint: usize) -> TraceForm {
let (from, to) = range(s, joint, 0);
TraceForm { by_study: false, joint, index: 0, from, to, study: 0, samples: String::new(), hover: None, shown: None, view: None }
}
/// From a joint's value now over a turn (an angle) or 20 mm (a distance).
fn range(s: &Session, joint: usize, index: usize) -> (String, String) {
let Some(j) = s.doc.assembly.joints.get(joint) else { return Default::default() };
let v = j.values.get(index).copied().unwrap_or(0.0);
if j.kind.is_angle(index) { (num(v.to_degrees(), "deg"), num(v.to_degrees() + 360.0, "deg")) } else { (num(v, "mm"), num(v + 20.0, "mm")) }
}
/// The occurrence path placing the picked body: the instance clicked, also when its component
/// (or a sub-assembly holding it) is placed more than once (empty: the root design).
fn path(s: &Session, sel: &Sel) -> Option<Vec<u64>> {
let body = body_of(sel)?;
s.doc.body_frames(&s.model.state()).into_iter().find(|(name, ..)| name == body).map(|(_, _, path, _)| path)
}
/// The pick: its occurrence path, the point where the part shows, and the part's frame as shown
/// and as placed (they differ under an exploded view or a move not captured yet).
pub(super) fn placement(s: &Session, inputs: &[SelInput]) -> Option<(Vec<u64>, Vec3, Mat, Mat)> {
let pick = sel_items(inputs, 0).first()?;
let (snap, path) = (snap_of(s, pick)?, path(s, pick)?);
let world = |shown: bool| {
path.iter().try_fold(IDENTITY, |m, id| {
let o = s.doc.occurrences.iter().find(|o| o.id == *id)?;
Some(mat_mul(&m, s.pending_moves.get(id).filter(|_| shown).unwrap_or(&o.transform)))
})
};
let (shown, placed) = (world(true)?, world(false)?);
Some((path, snap.at, shown, placed))
}
/// The trace's parameters (OK's `motion.trace.keep` and the preview's `motion.trace`).
pub fn params(s: &Session, f: &TraceForm, inputs: &[SelInput]) -> Result<Value, String> {
let (path, at, shown, placed) = placement(s, inputs).ok_or("select a point on a component")?;
// The trace starts where the part is placed, not where it may be shown.
let at = mat_inverse(&shown).map(|m| apply_point(&placed, apply_point(&m, at))).ok_or("select a point on a component")?;
let occurrence = if path.is_empty() { json!(0) } else { json!(path) };
let mut p = json!({"occurrence": occurrence, "point": pt(at)});
if f.by_study {
p["study"] = json!(s.doc.assembly.studies.get(f.study).ok_or("no motion study yet")?.id);
} else {
let j = s.doc.assembly.joints.get(f.joint).ok_or("no joint that moves yet")?;
p["joint"] = json!(j.id);
p["index"] = json!(f.index);
p["from"] = json!(f.from.trim());
p["to"] = json!(f.to.trim());
}
if !f.samples.trim().is_empty() {
p["samples"] = json!(f.samples.trim().parse::<f64>().map_err(|_| "samples must be a number")?);
}
Ok(p)
}
/// Where and why the motion stopped short, ending the trace (`None`: it went all the way).
fn stop_text(v: &Value) -> Option<String> {
let at = v["stopped_at"].as_u64()?.to_string();
let name = |x: &Value| x.as_str().unwrap_or_default().to_string();
if let Some(l) = v["stopped_by_loop"].as_object() {
return Some(crate::i18n::format("trace.stopped_by_loop", &[("sample", &at), ("joint", &name(&l["joint"]))]));
}
let c = v["stopped_by_contact"].as_object()?;
Some(crate::i18n::format("trace.stopped_by_contact", &[("sample", &at), ("a", &name(&c["a"])), ("b", &name(&c["b"]))]))
}
/// The dialog's rows; true when Enter was pressed in a value.
pub fn rows(app: &mut crate::SolveApp, ui: &mut egui::Ui, f: &mut TraceForm, inputs: &[SelInput]) -> bool {
let t = Tokens::get();
let s = &app.session;
let mut enter = false;
let studies: Vec<&str> = s.doc.assembly.studies.iter().map(|m| m.name.as_str()).collect();
f.by_study &= !studies.is_empty();
if !studies.is_empty() {
row_label(ui, "Motion");
let mut by = usize::from(f.by_study);
combo(ui, "tr_by", &["Joint", "Motion Study"], &mut by);
f.by_study = by == 1;
ui.end_row();
}
if f.by_study {
row_label(ui, "Study");
combo(ui, "tr_study", &studies, &mut f.study);
ui.end_row();
} else {
let list = movable(s);
if list.is_empty() {
row_label(ui, "");
ui.label(RichText::new(crate::i18n::tr_label("no joint that moves yet")).color(t.text_dim));
ui.end_row();
return false;
}
let labels: Vec<&str> = list.iter().filter_map(|i| s.doc.assembly.joints.get(*i)).map(|j| j.name.as_str()).collect();
let mut pos = list.iter().position(|i| *i == f.joint).unwrap_or(0);
row_label(ui, "Joint");
combo(ui, "tr_joint", &labels, &mut pos);
ui.end_row();
let joint = list.get(pos).copied().unwrap_or(0);
let dofs = s.doc.assembly.joints.get(joint).map(|j| j.kind.dofs()).unwrap_or(&[]);
let mut index = if joint == f.joint { f.index.min(dofs.len().saturating_sub(1)) } else { 0 };
if dofs.len() > 1 {
row_label(ui, " Of");
let caps: Vec<String> = dofs.iter().map(|d| capital(d)).collect();
let refs: Vec<&str> = caps.iter().map(String::as_str).collect();
combo(ui, "tr_index", &refs, &mut index);
ui.end_row();
}
if joint != f.joint || index != f.index {
(f.joint, f.index) = (joint, index);
(f.from, f.to) = range(s, joint, index);
}
row_label(ui, "From");
enter |= field(ui, &mut f.from);
ui.end_row();
row_label(ui, "To");
enter |= field(ui, &mut f.to);
ui.end_row();
}
let default = match s.doc.assembly.studies.get(f.study).filter(|_| f.by_study) {
Some(m) => (u64::from(m.steps) + 1).clamp(2, 500),
None => 100,
};
row_label(ui, "Samples");
let r = ui.add(egui::TextEdit::singleline(&mut f.samples).hint_text(default.to_string()));
enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.end_row();
// The preview, traced again when what it traces (or the design) changes.
let p = params(s, f, inputs);
let key = format!("{}:{p:?}", s.revision);
if f.shown.as_ref().is_none_or(|(k, _)| *k != key) {
let r = p.and_then(|p| s.scratch().execute("motion.trace", &p).map_err(|e| e.to_string()));
f.shown = Some((key, r));
f.view = placement(s, inputs).and_then(|(_, _, shown, placed)| Some((shown, mat_inverse(&placed)?)));
}
if let Some((_, r)) = &f.shown {
let (text, colour) = match r {
Ok(v) => match stop_text(v) {
Some(why) => (why, t.warning),
None => (crate::i18n::format("trace.points", &[("count", &v["samples"].to_string())]), t.text_dim),
},
Err(e) => (e.clone(), t.text_dim),
};
row_label(ui, "");
ui.add(egui::Label::new(RichText::new(text).color(colour)).wrap());
ui.end_row();
}
enter
}
/// The traced path in the viewport, with a dot where it starts.
pub fn overlay(painter: &egui::Painter, proj: &Proj, f: &TraceForm) {
let Some((_, Ok(v))) = &f.shown else { return };
let at = |p: &Value| p.as_array().and_then(|a| Some(Vec3::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?)));
let shown = |p: Vec3| f.view.map_or(p, |(shown, inverse)| apply_point(&shown, apply_point(&inverse, p)));
let pts: Vec<Pos2> = v["points"].as_array().into_iter().flatten().filter_map(at).map(shown).filter_map(|p| proj.to_screen(p)).collect();
let t = Tokens::get();
if let Some(first) = pts.first() {
painter.circle_filled(*first, 3.5, t.preview_sketch);
}
painter.add(egui::Shape::line(pts, Stroke::new(2.0, t.preview_sketch)));
}
+1
View File
@@ -20,6 +20,7 @@ const ASSEMBLE: &[&str] = &[
"joint.rack_pinion",
"joint.screw",
"motion.study",
"motion.trace",
"explode.create",
"contact.enable_sets",
"contact.enable_all",
+16
View File
@@ -106,6 +106,22 @@ instead, so two origins given at one point with the same z stay as they are. For
drive many steps, `quiet: true` leaves the per-part degrees of freedom out of joint answers, and
`motion.play` with `quiet: true` gives the poses of only the parts that moved.
**Motion Trace** (`motion.trace`, on the **ASSEMBLE** panel) draws the path a point of a part
takes while a joint is driven or a motion study plays, such as the coupler curve of a four-bar.
Pick the point on the part (a face centre, a circle centre, an edge midpoint or a vertex), then
either a joint with the values to drive it from and to (a turn of the joint from where it is, at
first) or a motion study. The path previews in the viewport as the mechanism would move: closed
loops follow, and the path ends where a loop can no longer close or a contact set stops the
motion, which the dialog says under the values. OK keeps the path as a 3D sketch spline in a new
sketch of the root component (**Motion Trace1**, ...), where no joint moves it, and it can serve
as a sweep or pipe path. It is a fixed curve: it
does not follow later changes to the mechanism. A path that comes back to where it started is
kept as a closed, smooth spline.
`motion.trace {occurrence, point, joint, from, to, index?, samples?}` (or `{occurrence, point,
study, samples?}`) answers the world points without changing the design, and
`motion.trace.keep` with the same parameters (and `name?`) keeps them. A trace takes 2 to 500
samples: 100 by default for a joint, one per step for a study.
**Insert Design** (`component.insert_design {path, translate?}`, on the **ASSEMBLE** panel and
in the **File** menu) adds a component linked to another saved design, so each part can live in
its own document. A STEP or mesh file picked there is inserted as **Insert STEP** or **Insert
+1
View File
@@ -55,6 +55,7 @@ Written against SolidCraft `main` after 0.11.1, which counts the degrees of free
- **Contact:** `contact.create {"occurrences":[...], "name":...}` and `contact.enable_sets {}`. A drive then stops where two bodies of one set would pass through each other, with `stopped_by_contact: {a, b, fraction}`. `contact.check {}` lists the pairs that overlap now.
- **Motion study:** `motion.study {"name":..., "steps":N, "keys":[{"joint":..., "points":[[step, value], ...]}]}`. Values are degrees or mm, linear between points. Key only the driving joints; linked joints and loops follow.
- **Play:** `motion.play {"study":..., "step":s}` sets the joints there, which changes the design, and answers every occurrence's pose (about 0.2 KB per occurrence). `motion.export {"study":..., "samples":n}` gives the poses at every step without changing the design.
- **Trace:** `motion.trace {"occurrence":..., "point":[x,y,z], "joint":..., "from":0, "to":360}` (or `"study":...` instead of joint, from and to) answers the world path of a point of a part, such as a coupler curve, without changing the design. It ends at `stopped_at` where a loop or contact stops the motion. `motion.trace.keep` with the same params keeps it as a 3D sketch spline.
## Verify
- **Each joint:** `execute occurrence.properties {"occurrence":"Wing:1"}` (under 1 KB) gives the world bbox and centre of mass of one occurrence. Joining must not move them.