diff --git a/ROADMAP.md b/ROADMAP.md index 681339c8..1887bc9c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | — | diff --git a/assets/icons/ui/source/icons.rs b/assets/icons/ui/source/icons.rs index 64ba3d46..2f342a8c 100644 --- a/assets/icons/ui/source/icons.rs +++ b/assets/icons/ui/source/icons.rs @@ -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); diff --git a/assets/locales/de.json b/assets/locales/de.json index a66781c9..69381095 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -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", diff --git a/assets/locales/en.json b/assets/locales/en.json index a43d5e63..5767b5dd 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -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", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index ef81a808..8e52631c 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -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", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 5cf189b3..350dccc7 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -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 マウス", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index b091bd81..09c175fb 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -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": "정보", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 9557c926..30f1ab3b 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -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-мышь", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index 8ed2feab..bd52bef4 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -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-миша", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index fc26c846..264d61d7 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -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 鼠标", diff --git a/crates/engine/src/cmd/joints_tests.rs b/crates/engine/src/cmd/joints_tests.rs index 66da370e..e86571f8 100644 --- a/crates/engine/src/cmd/joints_tests.rs +++ b/crates/engine/src/cmd/joints_tests.rs @@ -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 { + 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; diff --git a/crates/engine/src/cmd/motion.rs b/crates/engine/src/cmd/motion.rs index 33332ca0..2e575911 100644 --- a/crates/engine/src/cmd/motion.rs +++ b/crates/engine/src/cmd/motion.rs @@ -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 { 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 Vec<(u64, Vec)>>; + +/// 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, 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 = 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::>()? + } + 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 { + 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 { + 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 { + 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 = 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 { Ok(json!({"studies": s.doc.assembly.studies})) } diff --git a/crates/sketch/src/wire.rs b/crates/sketch/src/wire.rs index 39db8b3b..b0bc5f32 100644 --- a/crates/sketch/src/wire.rs +++ b/crates/sketch/src/wire.rs @@ -61,9 +61,13 @@ pub fn fit_polyline(fit: &[Vec3]) -> Vec { 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 = (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)); + } } diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 06fd681a..7a51992f 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -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, of: Vec, }, + /// Motion Trace: a picked point's path, previewed; OK keeps it as a 3D sketch spline. + Trace(Box), } /// 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)> { "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 { 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 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 }; diff --git a/crates/ui-egui/src/dialogs_assembly_tests.rs b/crates/ui-egui/src/dialogs_assembly_tests.rs index 24c05c9b..2e36408c 100644 --- a/crates/ui-egui/src/dialogs_assembly_tests.rs +++ b/crates/ui-egui/src/dialogs_assembly_tests.rs @@ -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:?}"); +} diff --git a/crates/ui-egui/src/dialogs_trace.rs b/crates/ui-egui/src/dialogs_trace.rs new file mode 100644 index 00000000..32c88ceb --- /dev/null +++ b/crates/ui-egui/src/dialogs_trace.rs @@ -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)>, + /// 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> { + 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, 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 { + 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::().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 { + 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 = 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 = 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))); +} diff --git a/crates/ui-egui/src/workspace.rs b/crates/ui-egui/src/workspace.rs index bc3438c3..674d1437 100644 --- a/crates/ui-egui/src/workspace.rs +++ b/crates/ui-egui/src/workspace.rs @@ -20,6 +20,7 @@ const ASSEMBLE: &[&str] = &[ "joint.rack_pinion", "joint.screw", "motion.study", + "motion.trace", "explode.create", "contact.enable_sets", "contact.enable_all", diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index ca29a561..f3a1de7f 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -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 diff --git a/skills/solidcraft-mechanisms/SKILL.md b/skills/solidcraft-mechanisms/SKILL.md index 535bcdc0..14562cbd 100644 --- a/skills/solidcraft-mechanisms/SKILL.md +++ b/skills/solidcraft-mechanisms/SKILL.md @@ -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.