From 8fdf6a61a22921af152db7c32a145755c4f18759 Mon Sep 17 00:00:00 2001 From: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:09:18 +0200 Subject: [PATCH] Motion analysis: plots of a study's joint values and a point's path, and the parts that collide along it - motion.plot: joint values and a point's path sampled as the study plays (closed loops following), with velocities and accelerations by finite differences, per step or per second with a duration. - motion.interference: the pairs of parts that pass through each other along a study, with the first step, the step ranges, the depth and the shared volume; occurrences, against or contact sets choose the pairs; bounded samples and work. - Motion Study dialog: a Study box opens a saved study; a chart of a joint value or a picked point with a cursor at the step shown (click to move there); Check collisions with the steps shaded on the chart. ui.start motion.study takes study. --- ROADMAP.md | 2 +- assets/locales/de.json | 17 + assets/locales/en.json | 17 + assets/locales/fr.json | 17 + assets/locales/ja.json | 17 + assets/locales/ko.json | 17 + assets/locales/ru.json | 17 + assets/locales/tr.json | 17 + assets/locales/uk.json | 17 + assets/locales/zh.json | 17 + crates/engine/src/cmd/contact_sweep.rs | 44 +- crates/engine/src/cmd/joints_tests.rs | 4 + crates/engine/src/cmd/motion.rs | 46 +- crates/engine/src/cmd/motion_analysis.rs | 431 ++++++++++++++++++ .../engine/src/cmd/motion_analysis_tests.rs | 184 ++++++++ crates/ui-egui/src/control.rs | 14 +- crates/ui-egui/src/dialogs_assembly.rs | 8 +- crates/ui-egui/src/dialogs_motion.rs | 72 ++- crates/ui-egui/src/dialogs_motion_plot.rs | 431 ++++++++++++++++++ crates/ui-egui/src/dialogs_trace.rs | 15 +- docs/book/src/guide/assemblies.md | 30 ++ docs/control-protocol.md | 2 +- skills/solidcraft-mechanisms/SKILL.md | 2 + 23 files changed, 1398 insertions(+), 40 deletions(-) create mode 100644 crates/engine/src/cmd/motion_analysis.rs create mode 100644 crates/engine/src/cmd/motion_analysis_tests.rs create mode 100644 crates/ui-egui/src/dialogs_motion_plot.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1033266f..ad44debd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -54,7 +54,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. | M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 | | M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 | | M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), 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, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two boundaries exact, three/four refused pending a Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), and every remaining #757–#772 item | 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), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), 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, Cam 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), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), 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 analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), 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, Cam 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 | — | | M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 | diff --git a/assets/locales/de.json b/assets/locales/de.json index a7ef9a7b..3f540d24 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -4,7 +4,10 @@ "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Screenshot anhängen: kopieren und einfügen oder als PNG speichern und hochladen.", "3. Click Submit new issue on GitHub to send your report.": "3. Auf GitHub auf Submit new issue klicken, um den Bericht zu senden.", "AI": "KI", + "Acceleration": "Beschleunigung", + "Against": "Gegen", "Alignment": "Ausrichtung", + "All components": "Alle Komponenten", "Arc length": "Bogenlänge", "Area": "Fläche", "BROWSER": "BROWSER", @@ -13,6 +16,7 @@ "Centre to centre": "Mitte zu Mitte", "Circumference": "Umfang", "Construction": "Konstruktionslinie", + "Contact sets": "Kontaktsätze", "Continuity": "Stetigkeit", "Copy Error Message": "Fehlermeldung kopieren", "Copy Warning Message": "Warnmeldung kopieren", @@ -22,6 +26,7 @@ "Curvature (G2)": "Krümmung (G2)", "Do not export hidden edges": "Verdeckte Kanten nicht exportieren", "Drive Joint": "Gelenk antreiben", + "Duration": "Dauer", "Edges": "Kanten", "Edit Profile Sketch": "Profile-Skizze bearbeiten", "Export Animation…": "Animation exportieren…", @@ -42,6 +47,7 @@ "Max": "Max", "Min": "Min", "Mitre": "Gehrung", + "New study": "Neue Untersuchung", "No visible bodies to export.": "Keine sichtbaren Körper zum Exportieren.", "None": "Keine", "Notch": "Ausklinkung", @@ -49,9 +55,12 @@ "Overall length": "Gesamtlänge", "Pass through": "Durch Punkte", "Picked": "Ausgewählte", + "Picked point": "Gewählter Punkt", + "Plot": "Diagramm", "Position (G0)": "Position (G0)", "Profiles/Curves": "Profile/Kurven", "Projection": "Projektion", + "Quantity": "Größe", "RULED SURFACE": "REGELFLÄCHE", "Rectangular": "Rechteckig", "Reference face": "Referenzfläche", @@ -70,6 +79,7 @@ "Sketch geometry and profiles": "Skizzengeometrie und Profile", "Sketch points": "Skizzenpunkte", "Slot width": "Langlochbreite", + "Study": "Untersuchung", "Tangent (G1)": "Tangente (G1)", "Tear": "Reißen", "Thickness Outside": "Dicke außen", @@ -78,6 +88,7 @@ "Turn off section analysis before exporting the view.": "Deaktivieren Sie die Schnittanalyse, bevor Sie die Ansicht exportieren.", "Type": "Typ", "UNTRIM": "TRIMMEN AUFHEBEN", + "Velocity": "Geschwindigkeit", "Vertices": "Scheitelpunkte", "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Nur sichtbare Körper. Kurven werden durch Liniensegmente angenähert (Toleranz: 0,01 mm).", "Wall Location": "Wandlage", @@ -236,8 +247,10 @@ "cmd.motion_delete": "Bewegungsuntersuchung löschen", "cmd.motion_export": "Bewegungsuntersuchung exportieren", "cmd.motion_export_animation": "Bewegungsanimation exportieren", + "cmd.motion_interference": "Kollisionen der Bewegungsuntersuchung", "cmd.motion_list": "Bewegungsuntersuchungen auflisten", "cmd.motion_play": "Bewegungsuntersuchung abspielen", + "cmd.motion_plot": "Diagramm der Bewegungsuntersuchung", "cmd.motion_study": "Bewegungsuntersuchung", "cmd.motion_trace": "Bewegungsbahn", "cmd.motion_trace_keep": "Bewegungsbahn behalten", @@ -479,6 +492,10 @@ "joint.from_bodies_hint": "Jeder ausgewählte Körper kommt in eine eigene neue Komponente und bleibt, wo er ist.", "joint.one_body": "Beide Auswahlen liegen auf {body}. Wählen Sie die zweite auf einem anderen Teil.", "joint.one_component": "Diese Teile sind Körper einer Komponente: {bodies}. Ein Gelenk bewegt eine Komponente gegenüber einer anderen, daher braucht jedes Teil eine eigene Komponente.", + "motion.checked_to": "Nur bis Schritt {step} geprüft: der Prüfaufwand ist erschöpft", + "motion.collision": "{a} · {b}: Schritte {steps}", + "motion.duration_hint": "Sekunden (leer: pro Schritt)", + "motion.no_collisions": "Keine Kollisionen in der Untersuchung", "nav.touchpad.help": "Umschalt gedrückt halten und den Zeiger zum Verschieben bewegen; mit Alt/Option drehen. Keine Maustaste nötig. Vertikales Scrollen zoomt.", "nav.touchpad.label": "Touchpad", "nav.touchpad.orbit": "Drehen (Alt/Option halten und bewegen; ohne Maustaste)", diff --git a/assets/locales/en.json b/assets/locales/en.json index b0a05d49..f6e8309e 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -7,6 +7,7 @@ "A new configuration, a copy of the active one": "A new configuration, a copy of the active one", "AI": "AI", "About SolidCraft": "About SolidCraft", + "Acceleration": "Acceleration", "Active": "Active", "Active rule": "Active rule", "Add": "Add", @@ -14,8 +15,10 @@ "Add Mid Radius": "Add Mid Radius", "Add Row": "Add Row", "Add condition": "Add condition", + "Against": "Against", "Alignment": "Alignment", "All Sketch Points": "All Sketch Points", + "All components": "All components", "All open designs": "All open designs", "Allowed expressions, separated by semicolons": "Allowed expressions, separated by semicolons", "Also deletes": "Also deletes", @@ -57,6 +60,7 @@ "Configuration rules": "Configuration rules", "Construction": "Construction", "Contact": "Contact", + "Contact sets": "Contact sets", "Continuity": "Continuity", "Copy Error Message": "Copy Error Message", "Copy Warning Message": "Copy Warning Message", @@ -87,6 +91,7 @@ "Double-click to rename": "Double-click to rename", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing", "Drive Joint": "Drive Joint", + "Duration": "Duration", "Edges": "Edges", "Edit Profile Sketch": "Edit Profile Sketch", "Enable rule": "Enable rule", @@ -150,6 +155,7 @@ "Move earlier": "Move earlier", "Move later": "Move later", "Name": "Name", + "New study": "New study", "No crash reports.": "No crash reports.", "No matching appearances": "No matching appearances", "No visible bodies to export.": "No visible bodies to export.", @@ -168,8 +174,10 @@ "Per Edge": "Per Edge", "Pick the tool bodies": "Pick the tool bodies", "Picked": "Picked", + "Picked point": "Picked point", "Place at the origin instead": "Place at the origin instead", "Playback": "Playback", + "Plot": "Plot", "Position": "Position", "Position (0 to 1)": "Position (0 to 1)", "Position (G0)": "Position (G0)", @@ -178,6 +186,7 @@ "Preview leaves the current design unchanged.": "Preview leaves the current design unchanged.", "Profiles/Curves": "Profiles/Curves", "Projection": "Projection", + "Quantity": "Quantity", "RECENT": "RECENT", "RULED SURFACE": "RULED SURFACE", "Radius Fit": "Radius Fit", @@ -243,6 +252,7 @@ "Stand this part up?": "Stand this part up?", "Step": "Step", "Steps": "Steps", + "Study": "Study", "Style": "Style", "Suppressed": "Suppressed", "Swap Ends": "Swap Ends", @@ -272,6 +282,7 @@ "Use selected face as fixed plane": "Use selected face as fixed plane", "VIEW": "VIEW", "Value": "Value", + "Velocity": "Velocity", "Vertices": "Vertices", "View navigator": "View navigator", "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Visible bodies only. Curves use line segments (0.01 mm tolerance).", @@ -437,8 +448,10 @@ "cmd.motion_delete": "Delete Motion Study", "cmd.motion_export": "Export Motion Study", "cmd.motion_export_animation": "Export Motion Animation", + "cmd.motion_interference": "Motion Interference", "cmd.motion_list": "List Motion Studies", "cmd.motion_play": "Play Motion Study", + "cmd.motion_plot": "Plot Motion Study", "cmd.motion_study": "Motion Study", "cmd.motion_trace": "Motion Trace", "cmd.motion_trace_keep": "Keep Motion Trace", @@ -685,6 +698,10 @@ "joint.from_bodies_hint": "Each picked body moves into a new component of its own and stays where it is.", "joint.one_body": "Both picks are on {body}. Pick the second one on another part.", "joint.one_component": "These parts are bodies of one component: {bodies}. A joint moves one component against another, so each part needs a component of its own.", + "motion.checked_to": "Checked to step {step} only: the check ran out of its work budget", + "motion.collision": "{a} · {b}: steps {steps}", + "motion.duration_hint": "seconds (empty: per step)", + "motion.no_collisions": "No collisions over the study", "nav.touchpad.help": "Hold Shift and move the pointer to pan; hold Alt/Option and move it to orbit. No mouse button is needed. Vertical scroll zooms.", "nav.touchpad.label": "Touchpad", "nav.touchpad.orbit": "Orbit (hold Alt/Option and move; no mouse button)", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 2c853bd5..7046e221 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -6,6 +6,7 @@ "3. Click Submit new issue on GitHub to send your report.": "3. Cliquer sur Submit new issue sur GitHub pour envoyer votre rapport.", "A new configuration, a copy of the active one": "Nouvelle configuration, copie de la configuration active", "About SolidCraft": "À propos de SolidCraft", + "Acceleration": "Accélération", "Active": "Actif", "Active rule": "Règle active", "Add": "Ajouter", @@ -13,8 +14,10 @@ "Add Mid Radius": "Ajouter un rayon intermédiaire", "Add Row": "Ajouter une ligne", "Add condition": "Ajouter une condition", + "Against": "Contre", "Alignment": "Alignement", "All Sketch Points": "Tous les points d'esquisse", + "All components": "Tous les composants", "All open designs": "Toutes les conceptions ouvertes", "Allowed expressions, separated by semicolons": "Expressions autorisées, séparées par des points-virgules", "Also deletes": "Supprime aussi", @@ -53,6 +56,7 @@ "Configuration rules": "Règles de configuration", "Construction": "Construction", "Contact": "Contact", + "Contact sets": "Jeux de contact", "Continuity": "Continuité", "Copy Error Message": "Copier le message d'erreur", "Copy Warning Message": "Copier le message d'avertissement", @@ -80,6 +84,7 @@ "Double-click to rename": "Double-cliquer pour renommer", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Double-clic sur le bouton du milieu dans la vue : Accueil (vue d'accueil, cadrant ce qui est affiché), Cadrer (cadrer ce qui est affiché en gardant l'orientation) ou rien", "Drive Joint": "Piloter la liaison", + "Duration": "Durée", "Edges": "Arêtes", "Edit Profile Sketch": "Modifier l'esquisse du profil", "Enable rule": "Activer la règle", @@ -136,6 +141,7 @@ "Move earlier": "Déplacer plus tôt", "Move later": "Déplacer plus tard", "Name": "Nom", + "New study": "Nouvelle étude", "No crash reports.": "Aucun rapport de plantage.", "No matching appearances": "Aucune apparence correspondante", "None": "Aucun", @@ -152,8 +158,10 @@ "Per Edge": "Par arête", "Pick the tool bodies": "Choisir les corps outils", "Picked": "Sélectionnés", + "Picked point": "Point choisi", "Place at the origin instead": "Placer plutôt à l'origine", "Playback": "Lecture", + "Plot": "Graphique", "Position": "Position", "Position (0 to 1)": "Position (0 à 1)", "Position (G0)": "Position (G0)", @@ -161,6 +169,7 @@ "Preview Row": "Aperçu de la ligne", "Preview leaves the current design unchanged.": "L'aperçu ne modifie pas la conception actuelle.", "Profiles/Curves": "Profils/Courbes", + "Quantity": "Grandeur", "RECENT": "RÉCENTS", "RULED SURFACE": "SURFACE RÉGLÉE", "Radius Fit": "Ajustement du rayon", @@ -225,6 +234,7 @@ "Stand this part up?": "Redresser cette pièce ?", "Step": "Étape", "Steps": "Étapes", + "Study": "Étude", "Style": "Style", "Suppressed": "Désactivé", "Swap Ends": "Permuter les extrémités", @@ -253,6 +263,7 @@ "Use selected face as fixed plane": "Utiliser la face sélectionnée comme plan fixe", "VIEW": "VUE", "Value": "Valeur", + "Velocity": "Vitesse", "Vertices": "Sommets", "View navigator": "Repère de navigation", "Volume": "Volume", @@ -417,8 +428,10 @@ "cmd.motion_delete": "Supprimer l'étude de mouvement", "cmd.motion_export": "Exporter l'étude de mouvement", "cmd.motion_export_animation": "Exporter l'animation du mouvement", + "cmd.motion_interference": "Collisions de l'étude de mouvement", "cmd.motion_list": "Lister les études de mouvement", "cmd.motion_play": "Lire l'étude de mouvement", + "cmd.motion_plot": "Graphique de 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", @@ -651,6 +664,10 @@ "joint.from_bodies_hint": "Chaque corps sélectionné passe dans un nouveau composant à lui et reste où il est.", "joint.one_body": "Les deux sélections sont sur {body}. Choisissez la seconde sur une autre pièce.", "joint.one_component": "Ces pièces sont des corps d'un même composant : {bodies}. Une liaison déplace un composant par rapport à un autre, chaque pièce a donc besoin de son propre composant.", + "motion.checked_to": "Vérifié jusqu'à l'étape {step} seulement : la vérification a épuisé son budget de calcul", + "motion.collision": "{a} · {b} : étapes {steps}", + "motion.duration_hint": "secondes (vide : par étape)", + "motion.no_collisions": "Aucune collision au cours de l'étude", "nav.touchpad.help": "Maintenir Maj et déplacer le pointeur pour faire un panoramique ; maintenir Alt/Option et le déplacer pour orbiter. Aucun bouton de souris n'est nécessaire. Le défilement vertical zoome.", "nav.touchpad.label": "Pavé tactile", "nav.touchpad.orbit": "Orbite (maintenir Alt/Option et déplacer ; sans bouton de souris)", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 9e8c692d..e2dfd33e 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -7,6 +7,7 @@ "A new configuration, a copy of the active one": "アクティブな構成をコピーして新しい構成を作成", "AI": "AI", "About SolidCraft": "SolidCraft について", + "Acceleration": "加速度", "Active": "アクティブ", "Active rule": "有効なルール", "Add": "追加", @@ -14,8 +15,10 @@ "Add Mid Radius": "中間半径を追加", "Add Row": "行を追加", "Add condition": "条件を追加", + "Against": "相手", "Alignment": "配置", "All Sketch Points": "すべてのスケッチ点", + "All components": "すべてのコンポーネント", "All open designs": "すべての開いているデザイン", "Allowed expressions, separated by semicolons": "使用可能な式(セミコロンで区切る)", "Also deletes": "同時に削除される要素:", @@ -57,6 +60,7 @@ "Configuration rules": "構成ルール", "Construction": "コンストラクション", "Contact": "接触", + "Contact sets": "接触セット", "Continuity": "連続性", "Copy Error Message": "エラーメッセージをコピー", "Copy Warning Message": "警告メッセージをコピー", @@ -87,6 +91,7 @@ "Double-click to rename": "ダブルクリックで名前変更", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "ビュー上で中ボタンをダブルクリックした時の動作: ホーム(全体を収めたホームビュー)、全体表示(向きを維持して全体を収める)、または無効", "Drive Joint": "ジョイントを駆動", + "Duration": "時間", "Edges": "エッジ", "Edit Profile Sketch": "プロファイルスケッチを編集", "Enable rule": "ルールを有効化", @@ -150,6 +155,7 @@ "Move earlier": "前へ移動", "Move later": "後へ移動", "Name": "名前", + "New study": "新規スタディ", "No crash reports.": "クラッシュレポートはありません。", "No matching appearances": "一致する外観がありません", "No visible bodies to export.": "出力する表示中のボディがありません。", @@ -168,8 +174,10 @@ "Per Edge": "エッジごと", "Pick the tool bodies": "ツールボディを選択", "Picked": "選択したもの", + "Picked point": "選択した点", "Place at the origin instead": "代わりに原点に配置", "Playback": "再生", + "Plot": "グラフ", "Position": "位置", "Position (0 to 1)": "位置 (0〜1)", "Position (G0)": "位置 (G0)", @@ -178,6 +186,7 @@ "Preview leaves the current design unchanged.": "プレビューは現在のデザインを変更しません。", "Profiles/Curves": "プロファイル/曲線", "Projection": "投影", + "Quantity": "量", "RECENT": "最近使用した項目", "RULED SURFACE": "ルールドサーフェス", "Radius Fit": "半径フィット", @@ -243,6 +252,7 @@ "Stand this part up?": "このパーツを正立させますか?", "Step": "ステップ", "Steps": "ステップ数", + "Study": "スタディ", "Style": "スタイル", "Suppressed": "抑制", "Swap Ends": "端点を入れ替え", @@ -272,6 +282,7 @@ "Use selected face as fixed plane": "選択した面を固定平面として使用", "VIEW": "表示", "Value": "値", + "Velocity": "速度", "Vertices": "頂点", "View navigator": "ビューナビゲーター", "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "表示中のボディを出力します。曲線は線分で近似します(許容誤差0.01 mm)。", @@ -437,8 +448,10 @@ "cmd.motion_delete": "モーション スタディを削除", "cmd.motion_export": "モーション スタディをエクスポート", "cmd.motion_export_animation": "モーション アニメーションをエクスポート", + "cmd.motion_interference": "モーション スタディの干渉", "cmd.motion_list": "モーション スタディを一覧表示", "cmd.motion_play": "モーション スタディを再生", + "cmd.motion_plot": "モーション スタディのグラフ", "cmd.motion_study": "モーション スタディ", "cmd.motion_trace": "モーション トレース", "cmd.motion_trace_keep": "モーション トレースを保持", @@ -685,6 +698,10 @@ "joint.from_bodies_hint": "選択した各ボディを、位置はそのままで専用の新しいコンポーネントに移動します。", "joint.one_body": "両方の選択が {body} 上にあります。2 つ目は別の部品で選択してください。", "joint.one_component": "これらの部品は 1 つのコンポーネントのボディです: {bodies}。ジョイントはコンポーネント同士を動かすため、部品ごとに専用のコンポーネントが必要です。", + "motion.checked_to": "ステップ {step} までしか確認していません: 確認の計算量の上限に達しました", + "motion.collision": "{a} · {b}: ステップ {steps}", + "motion.duration_hint": "秒 (空欄: ステップごと)", + "motion.no_collisions": "スタディ全体で衝突はありません", "nav.touchpad.help": "Shift を押しながらポインターを動かすとパン、Alt/Option を押しながら動かすと回転します。マウスボタンは不要です。縦スクロールでズームします。", "nav.touchpad.label": "タッチパッド", "nav.touchpad.orbit": "回転(Alt/Option を押しながら移動、マウスボタン不要)", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index df52217a..aaecb5df 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -4,7 +4,10 @@ "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. 스크린샷을 첨부하세요. 복사하여 붙여넣거나 PNG로 저장한 뒤 업로드하세요.", "3. Click Submit new issue on GitHub to send your report.": "3. GitHub에서 Submit new issue를 클릭하여 보고서를 보내세요.", "AI": "AI", + "Acceleration": "가속도", + "Against": "상대", "Alignment": "정렬", + "All components": "모든 구성요소", "Arc length": "호 길이", "Area": "면적", "BROWSER": "브라우저", @@ -13,6 +16,7 @@ "Centre to centre": "중심 간", "Circumference": "원주", "Construction": "구성선", + "Contact sets": "접촉 집합", "Continuity": "연속성", "Copy Error Message": "오류 메시지 복사", "Copy Warning Message": "경고 메시지 복사", @@ -22,6 +26,7 @@ "Curvature (G2)": "곡률 (G2)", "Do not export hidden edges": "숨겨진 모서리 내보내지 않기", "Drive Joint": "조인트 구동", + "Duration": "지속 시간", "Edges": "모서리", "Edit Profile Sketch": "윤곽 스케치 편집", "Export Animation…": "애니메이션 내보내기…", @@ -42,6 +47,7 @@ "Max": "최대", "Min": "최소", "Mitre": "연귀", + "New study": "새 연구", "No visible bodies to export.": "표시된 바디가 없어 내보낼 수 없습니다.", "None": "없음", "Notch": "노치", @@ -49,9 +55,12 @@ "Overall length": "전체 길이", "Pass through": "통과점", "Picked": "선택한 항목", + "Picked point": "선택한 점", + "Plot": "그래프", "Position (G0)": "위치 (G0)", "Profiles/Curves": "프로파일/곡선", "Projection": "투영", + "Quantity": "양", "RULED SURFACE": "룰드 서피스", "Rectangular": "직사각형", "Reference face": "참조 면", @@ -70,6 +79,7 @@ "Sketch geometry and profiles": "스케치 기하 및 프로파일", "Sketch points": "스케치 점", "Slot width": "슬롯 폭", + "Study": "연구", "Tangent (G1)": "접선 (G1)", "Tear": "찢김", "Thickness Outside": "바깥쪽 두께", @@ -78,6 +88,7 @@ "Turn off section analysis before exporting the view.": "뷰를 내보내기 전에 단면 분석을 끄세요.", "Type": "유형", "UNTRIM": "트림 해제", + "Velocity": "속도", "Vertices": "꼭짓점", "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "표시된 바디만 내보냅니다. 곡선은 선분으로 근사합니다 (허용오차 0.01 mm).", "Wall Location": "벽 위치", @@ -236,8 +247,10 @@ "cmd.motion_delete": "운동 연구 삭제", "cmd.motion_export": "운동 연구 내보내기", "cmd.motion_export_animation": "모션 애니메이션 내보내기", + "cmd.motion_interference": "운동 연구 간섭", "cmd.motion_list": "운동 연구 목록", "cmd.motion_play": "운동 연구 재생", + "cmd.motion_plot": "운동 연구 그래프", "cmd.motion_study": "운동 연구", "cmd.motion_trace": "모션 궤적", "cmd.motion_trace_keep": "모션 궤적 유지", @@ -479,6 +492,10 @@ "joint.from_bodies_hint": "선택한 각 바디가 제자리에 그대로 있으면서 자체의 새 구성 요소로 옮겨집니다.", "joint.one_body": "두 선택이 모두 {body}에 있습니다. 두 번째는 다른 부품에서 선택하세요.", "joint.one_component": "이 부품들은 한 구성 요소의 바디입니다: {bodies}. 관절은 한 구성 요소를 다른 구성 요소에 대해 움직이므로 부품마다 자체 구성 요소가 필요합니다.", + "motion.checked_to": "{step} 단계까지만 확인했습니다: 확인 작업량 한도에 도달했습니다", + "motion.collision": "{a} · {b}: {steps} 단계", + "motion.duration_hint": "초 (비우면: 단계당)", + "motion.no_collisions": "연구 전체에서 충돌이 없습니다", "nav.touchpad.help": "Shift를 누른 채 포인터를 움직이면 화면이 이동하고, Alt/Option을 누른 채 움직이면 회전합니다. 마우스 버튼은 필요 없습니다. 세로 스크롤로 확대/축소합니다.", "nav.touchpad.label": "터치패드", "nav.touchpad.orbit": "회전 (Alt/Option을 누르고 이동, 마우스 버튼 불필요)", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index b50bfd44..b84919c1 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -2,6 +2,7 @@ "0 turns it off": "0 отключает", "A new configuration, a copy of the active one": "Новая конфигурация — копия активной", "About SolidCraft": "О SolidCraft", + "Acceleration": "Ускорение", "Active": "Активный", "Active rule": "Активное правило", "Add": "Добавить", @@ -9,8 +10,10 @@ "Add Mid Radius": "Добавить средний радиус", "Add Row": "Добавить строку", "Add condition": "Добавить условие", + "Against": "Против", "Alignment": "Выравнивание", "All Sketch Points": "Все точки эскиза", + "All components": "Все компоненты", "All open designs": "Все открытые дизайны", "Allowed expressions, separated by semicolons": "Разрешённые выражения через точку с запятой", "Also deletes": "Также удаляет", @@ -49,6 +52,7 @@ "Configuration rules": "Правила конфигурации", "Construction": "Вспомогательная", "Contact": "Контакт", + "Contact sets": "Наборы контактов", "Continuity": "Непрерывность", "Copy Error Message": "Скопировать сообщение об ошибке", "Copy Warning Message": "Скопировать предупреждение", @@ -76,6 +80,7 @@ "Double-click to rename": "Двойной щелчок — переименовать", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Двойной щелчок средней кнопкой в виде: «Главная» (главный вид, кадрирует показанное), «Вписать» (кадрирует, сохраняя ориентацию) или ничего", "Drive Joint": "Привод сопряжения", + "Duration": "Длительность", "Edges": "Рёбра", "Edit Profile Sketch": "Редактировать эскиз профиля", "Enable rule": "Включить правило", @@ -126,6 +131,7 @@ "Move earlier": "Сдвинуть раньше", "Move later": "Сдвинуть позже", "Name": "Имя", + "New study": "Новое исследование", "No crash reports.": "Нет отчётов о сбоях.", "No matching appearances": "Нет подходящих внешних видов", "Not available yet": "Пока недоступно", @@ -139,14 +145,17 @@ "Path": "Траектория", "Per Edge": "По каждому ребру", "Pick the tool bodies": "Выберите тела-инструменты", + "Picked point": "Выбранная точка", "Place at the origin instead": "Разместить в начале координат", "Playback": "Воспроизведение", + "Plot": "График", "Position": "Положение", "Position (0 to 1)": "Положение (от 0 до 1)", "Position (G0)": "Положение (G0)", "Position along the edge, 0 at the start end, 1 at the other": "Положение вдоль ребра: 0 — начало, 1 — конец", "Preview Row": "Строка предпросмотра", "Preview leaves the current design unchanged.": "Предпросмотр не меняет текущий дизайн.", + "Quantity": "Величина", "RECENT": "НЕДАВНИЕ", "Radius Fit": "Подобрать радиус", "Ratio": "Отношение", @@ -207,6 +216,7 @@ "Stand this part up?": "Поставить эту деталь вертикально?", "Step": "Шаг", "Steps": "Шаги", + "Study": "Исследование", "Style": "Стиль", "Suppressed": "Подавлено", "Swap Ends": "Поменять концы", @@ -231,6 +241,7 @@ "Use selected face as fixed plane": "Использовать выбранную грань как фиксированную плоскость", "VIEW": "ВИД", "Value": "Значение", + "Velocity": "Скорость", "Vertices": "Вершины", "View navigator": "Навигатор видов", "Volume": "Объём", @@ -389,8 +400,10 @@ "cmd.motion_delete": "Удалить исследование движения", "cmd.motion_export": "Экспортировать исследование движения", "cmd.motion_export_animation": "Экспортировать анимацию движения", + "cmd.motion_interference": "Столкновения в исследовании движения", "cmd.motion_list": "Список исследований движения", "cmd.motion_play": "Воспроизвести исследование движения", + "cmd.motion_plot": "График исследования движения", "cmd.motion_study": "Исследование движения", "cmd.motion_trace": "Траектория движения", "cmd.motion_trace_keep": "Сохранить траекторию движения", @@ -619,6 +632,10 @@ "joint.from_bodies_hint": "Каждое выбранное тело переходит в свой новый компонент и остаётся на месте.", "joint.one_body": "Оба выбора на {body}. Выберите второй на другой детали.", "joint.one_component": "Эти детали - тела одного компонента: {bodies}. Сопряжение двигает один компонент относительно другого, поэтому каждой детали нужен свой компонент.", + "motion.checked_to": "Проверено только до шага {step}: проверка исчерпала свой бюджет работы", + "motion.collision": "{a} · {b}: шаги {steps}", + "motion.duration_hint": "секунды (пусто: на шаг)", + "motion.no_collisions": "За всё исследование столкновений нет", "needs two joints": "нужны два сопряжения", "new configuration": "новая конфигурация", "new name": "новое имя", diff --git a/assets/locales/tr.json b/assets/locales/tr.json index eb75ca65..001f671b 100644 --- a/assets/locales/tr.json +++ b/assets/locales/tr.json @@ -7,6 +7,7 @@ "A new configuration, a copy of the active one": "Etkin konfigürasyonun kopyası olan yeni bir konfigürasyon", "AI": "AI", "About SolidCraft": "SolidCraft hakkında", + "Acceleration": "İvme", "Active": "Etkin", "Active rule": "Etkin kural", "Add": "Ekle", @@ -14,8 +15,10 @@ "Add Mid Radius": "Ara yarıçap ekle", "Add Row": "Satır ekle", "Add condition": "Koşul ekle", + "Against": "Karşı", "Alignment": "Hizalama", "All Sketch Points": "Tüm eskiz noktaları", + "All components": "Tüm bileşenler", "All open designs": "Tüm açık tasarımlar", "Allowed expressions, separated by semicolons": "Noktalı virgülle ayrılmış izin verilen ifadeler", "Also deletes": "Bunları da siler", @@ -56,6 +59,7 @@ "Configuration rules": "Konfigürasyon kuralları", "Construction": "Yapı çizgisi", "Contact": "Temas", + "Contact sets": "Temas kümeleri", "Copy Error Message": "Hata mesajını kopyala", "Copy Warning Message": "Uyarı mesajını kopyala", "Copy all licences": "Tüm lisansları kopyala", @@ -80,6 +84,7 @@ "Double-click to rename": "Yeniden adlandırmak için çift tıklayın", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Görünümde orta tuşla çift tıklama: Başlangıç (gösterileni çerçeveleyen başlangıç görünümü), Sığdır (yönü koruyarak gösterileni çerçeveler) veya hiçbir şey", "Drive Joint": "Bağlantıyı tahrik et", + "Duration": "Süre", "Edges": "Kenarlar", "Edit Profile Sketch": "Profil eskizini düzenle", "Enable rule": "Kuralı etkinleştir", @@ -138,6 +143,7 @@ "Move earlier": "Öne al", "Move later": "Sonraya al", "Name": "Ad", + "New study": "Yeni etüt", "No crash reports.": "Çökme raporu yok.", "No matching appearances": "Eşleşen görünüm yok", "None": "Hiçbiri", @@ -153,14 +159,17 @@ "Per Edge": "Kenar başına", "Pick the tool bodies": "Araç gövdelerini seçin", "Picked": "Seçilenler", + "Picked point": "Seçilen nokta", "Place at the origin instead": "Bunun yerine orijine yerleştir", "Playback": "Oynatma", + "Plot": "Grafik", "Position": "Konum", "Position (0 to 1)": "Konum (0 ile 1 arası)", "Position along the edge, 0 at the start end, 1 at the other": "Kenar boyunca konum: başlangıç ucunda 0, diğer uçta 1", "Preview Row": "Satırı önizle", "Preview leaves the current design unchanged.": "Önizleme geçerli tasarımı değiştirmez.", "Profiles/Curves": "Profiller/Eğriler", + "Quantity": "Büyüklük", "RECENT": "SON KULLANILANLAR", "RULED SURFACE": "REGLE YÜZEY", "Radius Fit": "Yarıçap uydurma", @@ -225,6 +234,7 @@ "Stand this part up?": "Bu parça dik çevrilsin mi?", "Step": "Adım", "Steps": "Adımlar", + "Study": "Etüt", "Style": "Stil", "Suppressed": "Bastırılmış", "Swap Ends": "Uçları değiştir", @@ -251,6 +261,7 @@ "Use selected face as fixed plane": "Seçili yüzü sabit düzlem olarak kullan", "VIEW": "GÖRÜNÜM", "Value": "Değer", + "Velocity": "Hız", "Vertices": "Köşe noktaları", "View navigator": "Görünüm gezgini", "Volume": "Hacim", @@ -403,8 +414,10 @@ "cmd.motion_delete": "Hareket etüdünü sil", "cmd.motion_export": "Hareket etüdünü dışa aktar", "cmd.motion_export_animation": "Hareket animasyonunu dışa aktar", + "cmd.motion_interference": "Hareket etüdü çarpışmaları", "cmd.motion_list": "Hareket etütlerini listele", "cmd.motion_play": "Hareket etüdünü oynat", + "cmd.motion_plot": "Hareket etüdü grafiği", "cmd.motion_study": "Hareket etüdü", "cmd.occurrence_copy": "Yapıştır (örnek)", "cmd.occurrence_ground": "Sabitle", @@ -618,6 +631,10 @@ "joint.from_bodies_hint": "Seçilen her gövde kendi yeni bileşenine konur ve bulunduğu yerde kalır.", "joint.one_body": "Her iki seçim de {body} üzerinde. İkincisini başka bir parçada seçin.", "joint.one_component": "Bu parçalar tek bir bileşenin gövdeleridir: {bodies}. Bir bağlantı bir bileşeni diğerine göre hareket ettirir, bu nedenle her parçanın kendi bileşeni olması gerekir.", + "motion.checked_to": "Yalnızca {step}. adıma kadar denetlendi: denetim iş bütçesini tüketti", + "motion.collision": "{a} · {b}: adımlar {steps}", + "motion.duration_hint": "saniye (boş: adım başına)", + "motion.no_collisions": "Etüt boyunca çarpışma yok", "nav.touchpad.help": "Kaydırmak için Shift tuşunu basılı tutun ve imleci hareket ettirin; Alt/Option ile döndürün. Fare tuşu gerekmez. Dikey kaydırma yakınlaştırır.", "nav.touchpad.label": "Dokunmatik yüzey", "nav.touchpad.orbit": "Döndür (Alt/Option tuşunu basılı tutup hareket ettirin; fare tuşu olmadan)", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index 3b931791..b1e3ae32 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -7,6 +7,7 @@ "A new configuration, a copy of the active one": "Нова конфігурація, копія активної", "AI": "ШІ", "About SolidCraft": "Про SolidCraft", + "Acceleration": "Прискорення", "Active": "Активна", "Active rule": "Активне правило", "Add": "Додати", @@ -14,8 +15,10 @@ "Add Mid Radius": "Додати середній радіус", "Add Row": "Додати рядок", "Add condition": "Додати умову", + "Against": "Проти", "Alignment": "Вирівнювання", "All Sketch Points": "Усі точки ескізу", + "All components": "Усі компоненти", "All open designs": "Усі відкриті проєкти", "Allowed expressions, separated by semicolons": "Дозволені вирази, розділені крапками з комою", "Also deletes": "Також видаляє", @@ -57,6 +60,7 @@ "Configuration rules": "Правила конфігурації", "Construction": "Конструкційна", "Contact": "Контакт", + "Contact sets": "Набори контактів", "Continuity": "Неперервність", "Copy Error Message": "Копіювати повідомлення про помилку", "Copy Warning Message": "Копіювати попереджувальне повідомлення", @@ -87,6 +91,7 @@ "Double-click to rename": "Двічі клацніть, щоб перейменувати", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Подвійне клацання середньою кнопкою у виді: головний (головний вид, що охоплює показане), вписування (охопити показане, зберігаючи орієнтацію) або нічого", "Drive Joint": "Керувати з'єднанням", + "Duration": "Тривалість", "Edges": "Ребра", "Edit Profile Sketch": "Редагувати ескіз профілю", "Enable rule": "Увімкнути правило", @@ -150,6 +155,7 @@ "Move earlier": "Перемістити раніше", "Move later": "Перемістити пізніше", "Name": "Назва", + "New study": "Нове дослідження", "No crash reports.": "Немає звітів про збої.", "No matching appearances": "Немає відповідних виглядів", "No visible bodies to export.": "Немає видимих тіл для експорту.", @@ -168,8 +174,10 @@ "Per Edge": "На кожне ребро", "Pick the tool bodies": "Виберіть тіла інструмента", "Picked": "Вибрано", + "Picked point": "Вибрана точка", "Place at the origin instead": "Натомість розмістити в початку координат", "Playback": "Відтворення", + "Plot": "Графік", "Position": "Положення", "Position (0 to 1)": "Положення (від 0 до 1)", "Position (G0)": "Положення (G0)", @@ -178,6 +186,7 @@ "Preview leaves the current design unchanged.": "Попередній перегляд не змінює поточний проєкт.", "Profiles/Curves": "Профілі/криві", "Projection": "Проєкція", + "Quantity": "Величина", "RECENT": "НЕДАВНІ", "RULED SURFACE": "ЛІНІЙЧАСТА ПОВЕРХНЯ", "Radius Fit": "Підгонка за радіусом", @@ -243,6 +252,7 @@ "Stand this part up?": "Вирівняти цю деталь?", "Step": "Крок", "Steps": "Кроки", + "Study": "Дослідження", "Style": "Стиль", "Suppressed": "Придушено", "Swap Ends": "Поміняти кінці місцями", @@ -272,6 +282,7 @@ "Use selected face as fixed plane": "Використати вибрану грань як фіксовану площину", "VIEW": "ВИД", "Value": "Значення", + "Velocity": "Швидкість", "Vertices": "Вершини", "View navigator": "Навігатор виду", "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Лише видимі тіла. Криві подаються відрізками (допуск 0,01 mm).", @@ -437,8 +448,10 @@ "cmd.motion_delete": "Видалити дослідження руху", "cmd.motion_export": "Експортувати дослідження руху", "cmd.motion_export_animation": "Експортувати анімацію руху", + "cmd.motion_interference": "Зіткнення в дослідженні руху", "cmd.motion_list": "Список досліджень руху", "cmd.motion_play": "Відтворити дослідження руху", + "cmd.motion_plot": "Графік дослідження руху", "cmd.motion_study": "Дослідження руху", "cmd.motion_trace": "Траєкторія руху", "cmd.motion_trace_keep": "Зберегти траєкторію руху", @@ -671,6 +684,10 @@ "joint.from_bodies_hint": "Кожне вибране тіло переходить у власний новий компонент і лишається на місці.", "joint.one_body": "Обидва вибори на {body}. Виберіть другий на іншій деталі.", "joint.one_component": "Ці деталі - тіла одного компонента: {bodies}. З'єднання рухає один компонент відносно іншого, тому кожній деталі потрібен власний компонент.", + "motion.checked_to": "Перевірено лише до кроку {step}: перевірка вичерпала свій бюджет роботи", + "motion.collision": "{a} · {b}: кроки {steps}", + "motion.duration_hint": "секунди (порожньо: на крок)", + "motion.no_collisions": "За все дослідження зіткнень немає", "nav.touchpad.help": "Утримуйте Shift і рухайте вказівник, щоб панорамувати; утримуйте Alt/Option і рухайте, щоб обертати. Кнопка миші не потрібна. Вертикальна прокрутка масштабує.", "nav.touchpad.label": "Трекпад", "nav.touchpad.orbit": "Обертання (утримуйте Alt/Option і рухайте; без кнопки миші)", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 34e1af5a..59a416c9 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -7,6 +7,7 @@ "A new configuration, a copy of the active one": "新建配置,为当前配置的副本", "AI": "AI", "About SolidCraft": "关于 SolidCraft", + "Acceleration": "加速度", "Active": "活动", "Active rule": "活动规则", "Add": "添加", @@ -14,8 +15,10 @@ "Add Mid Radius": "添加中间半径", "Add Row": "添加行", "Add condition": "添加条件", + "Against": "对照", "Alignment": "对齐", "All Sketch Points": "所有草图点", + "All components": "所有零部件", "All open designs": "所有打开的设计", "Allowed expressions, separated by semicolons": "允许的表达式,以分号分隔", "Also deletes": "同时删除", @@ -57,6 +60,7 @@ "Configuration rules": "配置规则", "Construction": "构造", "Contact": "接触", + "Contact sets": "接触集", "Continuity": "连续性", "Copy Error Message": "复制错误消息", "Copy Warning Message": "复制警告消息", @@ -87,6 +91,7 @@ "Double-click to rename": "双击重命名", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "在视图中双击中键:主视图(主视图,框住所显示的内容)、适合窗口(框住所显示的内容,保持方向)或无动作", "Drive Joint": "驱动接头", + "Duration": "时长", "Edges": "边", "Edit Profile Sketch": "编辑轮廓草图", "Enable rule": "启用规则", @@ -150,6 +155,7 @@ "Move earlier": "向前移动", "Move later": "向后移动", "Name": "名称", + "New study": "新建研究", "No crash reports.": "没有崩溃报告。", "No matching appearances": "没有匹配的外观", "No visible bodies to export.": "没有可导出的可见实体。", @@ -168,8 +174,10 @@ "Per Edge": "每条边", "Pick the tool bodies": "选择工具实体", "Picked": "所选", + "Picked point": "选取的点", "Place at the origin instead": "改为放置在原点", "Playback": "播放", + "Plot": "图表", "Position": "位置", "Position (0 to 1)": "位置(0 到 1)", "Position (G0)": "位置 (G0)", @@ -178,6 +186,7 @@ "Preview leaves the current design unchanged.": "预览不会更改当前设计。", "Profiles/Curves": "轮廓/曲线", "Projection": "投影", + "Quantity": "物理量", "RECENT": "最近使用", "RULED SURFACE": "直纹曲面", "Radius Fit": "半径拟合", @@ -243,6 +252,7 @@ "Stand this part up?": "立起此零件?", "Step": "步骤", "Steps": "步数", + "Study": "研究", "Style": "样式", "Suppressed": "已抑制", "Swap Ends": "交换端点", @@ -272,6 +282,7 @@ "Use selected face as fixed plane": "使用所选面作为固定平面", "VIEW": "视图", "Value": "值", + "Velocity": "速度", "Vertices": "顶点", "View navigator": "视图导航器", "Visible bodies only. Curves use line segments (0.01 mm tolerance).": "仅导出可见实体。曲线以线段近似(公差0.01毫米)。", @@ -437,8 +448,10 @@ "cmd.motion_delete": "删除运动研究", "cmd.motion_export": "导出运动研究", "cmd.motion_export_animation": "导出运动动画", + "cmd.motion_interference": "运动研究干涉", "cmd.motion_list": "列出运动研究", "cmd.motion_play": "播放运动研究", + "cmd.motion_plot": "运动研究图表", "cmd.motion_study": "运动研究", "cmd.motion_trace": "运动轨迹", "cmd.motion_trace_keep": "保留运动轨迹", @@ -685,6 +698,10 @@ "joint.from_bodies_hint": "每个所选实体移入各自的新组件,位置保持不变。", "joint.one_body": "两次选择都在 {body} 上。请在另一个零件上选择第二个。", "joint.one_component": "这些零件是同一组件的实体:{bodies}。接头使一个组件相对另一个组件运动,因此每个零件都需要自己的组件。", + "motion.checked_to": "仅检查到第 {step} 步:检查已用完其工作量上限", + "motion.collision": "{a} · {b}:第 {steps} 步", + "motion.duration_hint": "秒(留空:按步)", + "motion.no_collisions": "整个研究中没有碰撞", "nav.touchpad.help": "按住 Shift 并移动指针可平移;按住 Alt/Option 并移动可旋转。无需按鼠标按钮。垂直滚动可缩放。", "nav.touchpad.label": "触控板", "nav.touchpad.orbit": "旋转(按住 Alt/Option 并移动,无需鼠标按钮)", diff --git a/crates/engine/src/cmd/contact_sweep.rs b/crates/engine/src/cmd/contact_sweep.rs index b1875e22..887a728b 100644 --- a/crates/engine/src/cmd/contact_sweep.rs +++ b/crates/engine/src/cmd/contact_sweep.rs @@ -27,9 +27,13 @@ struct CachedBody { size: f64, } +/// A member of a contact set: (occurrence id, name). +pub(super) type Member = (u64, String); + pub(super) struct ContactGeometry { bodies: Vec, - pairs: Vec<(usize, usize, String, String)>, + /// Body indices, and the set members (occurrence id, name) they are under. + pairs: Vec<(usize, usize, Member, Member)>, step: f64, } @@ -41,6 +45,12 @@ impl ContactGeometry { } else { assembly.contact_sets.iter().filter(|set| !set.suppressed).map(|set| set.occurrences.clone()).collect() }; + Self::of_sets(s, &sets) + } + + /// The geometry of the bodies under `sets` (occurrence ids), paired as contact sets pair + /// them: bodies under different members of one set. + pub(super) fn of_sets(s: &Session, sets: &[Vec]) -> Result { let mut out = Self { bodies: Vec::new(), pairs: Vec::new(), step: f64::INFINITY }; if sets.is_empty() { return Ok(out); @@ -120,7 +130,7 @@ impl ContactGeometry { } } let mut pairs = BTreeSet::new(); - for set in &sets { + for set in sets { let tagged: Vec<(usize, u64)> = out.bodies.iter().enumerate().filter_map(|(i, b)| b.path.iter().rev().find(|id| set.contains(id)).map(|id| (i, *id))).collect(); for (i, (a, oa)) in tagged.iter().enumerate() { @@ -132,7 +142,7 @@ impl ContactGeometry { } } let name = |id| s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_default(); - out.pairs = pairs.into_iter().map(|(a, b, oa, ob)| (a, b, name(oa), name(ob))).collect(); + out.pairs = pairs.into_iter().map(|(a, b, oa, ob)| (a, b, (oa, name(oa)), (ob, name(ob)))).collect(); Ok(out) } @@ -140,6 +150,16 @@ impl ContactGeometry { !self.pairs.is_empty() } + /// Keeps only the pairs of set members (occurrence ids) `keep` takes. + pub(super) fn keep_pairs(&mut self, keep: impl Fn(u64, u64) -> bool) { + self.pairs.retain(|(_, _, a, b)| keep(a.0, b.0)); + } + + /// How many pairs of set members are checked against each other. + pub(super) fn pair_count(&self) -> usize { + self.pairs.iter().map(|(_, _, a, b)| (a.0, b.0)).collect::>().len() + } + fn frame(body: &CachedBody, s: &Session) -> Result { let mut frame = IDENTITY; for id in &body.path { @@ -205,6 +225,13 @@ impl ContactGeometry { } pub(super) fn first_hit(&self, s: &Session, work: &mut usize) -> Result> { + Ok(self.hits(s, work, MAX_WORK, false)?.into_iter().next().map(|(a, b, depth)| (a.1, b.1, depth))) + } + + /// The pairs that pass through each other in `s`, with how deep (every pair, or the first + /// found when `all` is false). `work` adds up the cost, refused past `budget`. + pub(super) fn hits(&self, s: &Session, work: &mut usize, budget: usize, all: bool) -> Result> { + let mut out = Vec::new(); let meshes: Vec = self .bodies .iter() @@ -214,7 +241,7 @@ impl ContactGeometry { Ok(Mesh { positions, triangles: body.mesh.triangles.clone(), ..Mesh::default() }) }) .collect::>()?; - for (a, b, aname, bname) in &self.pairs { + for (a, b, oa, ob) in &self.pairs { let (Some(ma), Some(mb), Some(ba), Some(bb)) = (meshes.get(*a), meshes.get(*b), self.bodies.get(*a), self.bodies.get(*b)) else { return Err(limit("invalid contact geometry pair")); }; @@ -235,14 +262,17 @@ impl ContactGeometry { .saturating_mul(mb.triangles.len()) .saturating_add(mb.positions.len().saturating_add(mb.triangles.len().saturating_mul(4)).saturating_mul(ma.triangles.len())); *work = work.saturating_add(cost); - if *work > MAX_WORK { + if *work > budget { return Err(limit("motion exceeds contact sweep collision budget; simplify the contact set or use smaller increments")); } if let Some(depth) = super::mesh_overlap(ma, mb, 1e-4 * ba.size.max(bb.size).max(1.0)) { - return Ok(Some((aname.clone(), bname.clone(), depth))); + out.push((oa.clone(), ob.clone(), depth)); + if !all { + break; + } } } - Ok(None) + Ok(out) } } diff --git a/crates/engine/src/cmd/joints_tests.rs b/crates/engine/src/cmd/joints_tests.rs index d02a803c..f0b42b4e 100644 --- a/crates/engine/src/cmd/joints_tests.rs +++ b/crates/engine/src/cmd/joints_tests.rs @@ -1448,3 +1448,7 @@ mod coupling; #[path = "joints_cam_tests.rs"] mod cam; + +// Plots and interference of motion studies, on the four-bar and slider-crank above. +#[path = "motion_analysis_tests.rs"] +mod motion_analysis; diff --git a/crates/engine/src/cmd/motion.rs b/crates/engine/src/cmd/motion.rs index cc42c151..63fa3a26 100644 --- a/crates/engine/src/cmd/motion.rs +++ b/crates/engine/src/cmd/motion.rs @@ -16,6 +16,8 @@ use crate::{EngineError, Result, Session}; #[path = "motion_animation.rs"] mod animation; +#[path = "motion_analysis.rs"] +mod analysis; #[path = "contact_sweep.rs"] mod contact_sweep; @@ -69,6 +71,8 @@ pub static COMMANDS: &[CommandSpec] = &[ "; 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.plot", "Plot Motion Study", analysis::plot).noundo().params(analysis::PLOT_DOC), + CommandSpec::new("motion.interference", "Motion Interference", analysis::interference).noundo().params(analysis::INTERFERENCE_DOC), 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( @@ -688,13 +692,11 @@ 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). +/// The point `p` names (`occurrence` and `point`): the occurrence path placing it and the point +/// in that placement's frame. The occurrence is an occurrence path through nested sub-assemblies +/// (one instance of each), or an occurrence at its first placement (none: the root). The point +/// is a world point where the parts are placed (moves only shown are left out). +fn traced_point(s: &Session, p: &Value, cmd: &str) -> Result<(Vec, Vec3)> { 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::>()? @@ -710,19 +712,29 @@ fn sample_trace(s: &Session, p: &Value, cmd: &str) -> Result<(Vec, Value)> }; 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(); + t.pending_moves.clear(); + let local = solidcraft_doc::mat_inverse(&path_world(&t, &path, cmd)?) + .map(|m| solidcraft_doc::apply_point(&m, at)) + .ok_or_else(|| bad(cmd, "the occurrence's placement is not invertible"))?; + Ok((path, local)) +} + +/// The world placement of an occurrence path (empty: the root). +fn path_world(t: &Session, path: &[u64], cmd: &str) -> 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())) } +} + +/// 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)> { + let (path, local) = traced_point(s, p, cmd)?; + 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 world = |t: &Session| path_world(t, &path, cmd); let (default, given, plan) = motion_plan(s, p, cmd)?; let n = sample_count(p, default.min(MAX_TRACE), MAX_TRACE, cmd)?; let mut points = Vec::new(); diff --git a/crates/engine/src/cmd/motion_analysis.rs b/crates/engine/src/cmd/motion_analysis.rs new file mode 100644 index 00000000..e9412361 --- /dev/null +++ b/crates/engine/src/cmd/motion_analysis.rs @@ -0,0 +1,431 @@ +//! Motion analysis: a study played sample by sample on a copy of the design, the way +//! `motion.play` places it (closed loops following on their branch). `motion.plot` answers the +//! joint values and a point's path with their velocities and accelerations; `motion.interference` +//! answers the pairs of parts that pass through each other along the way. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use serde_json::{Value, json}; +use solidcraft_doc::joints; +use solidcraft_doc::motion::MotionStudy; +use solidcraft_doc::{Document, Mat, apply_point, mat_mul}; +use solidcraft_geom::{Aabb3, Vec3}; + +use super::contact_sweep::{ContactGeometry, Member}; +use super::{drive_with_contact, joint_id, keyed, occurrences, path_world, study_index, traced_point, values_at}; +use crate::params::{bad, bool_}; +use crate::{EngineError, Result, Session}; + +pub const PLOT_DOC: &str = "study: id|name; joints?: [id|name] (default: every joint that moves); occurrence? and point? (a point \ + of a part to follow, as motion.trace takes them); samples? (3…1000, default the study's steps + 1); duration?: seconds the \ + whole study takes (velocities and accelerations then per second, else per step) → step: [the study step of each sample], \ + time?: [s], joints: [{joint, name, index, dof, unit, value, velocity, acceleration, velocity_unit, acceleration_unit}] \ + (degrees for angles, mm for distances; angles unwrapped), point?: {occurrence, position: [[x,y,z]…], velocity, \ + acceleration (vectors), speed, acceleration_norm, unit, velocity_unit, acceleration_unit}, samples (taken), stopped_at, \ + stopped_by_loop, stopped_by_contact. The study plays as motion.play plays it, each sample driven from the one before, on \ + a copy (the design is not changed); velocities and accelerations are finite differences between samples (central, \ + one-sided at the ends)"; + +pub const INTERFERENCE_DOC: &str = "study: id|name; occurrences?: [ids or names] (two or more, checked against each other; \ + default every occurrence), with against?: [ids or names] (then each of `occurrences` is checked only against these, such \ + as an arm against the parts it must miss), or contact_sets?: true (the pairs of the design's contact sets); samples? \ + (2…1000, default the study's steps + 1) → collisions: [{a, b, first_step, last_step, steps: [[from, to]…] (the step ranges where they overlap), \ + depth_mm (the deepest), deepest_step, volume_mm3 (their overlap there; null when it could not be worked out)}], clear, \ + pairs (checked), samples (taken), truncated (the work budget ran out; checked_to: the last step checked), stopped_at, \ + stopped_by_loop. The study plays as motion.play plays it but contact sets do not stop it, on a copy (the design is not \ + changed). Parts are tested at the samples only: one that passes wholly through another between two samples is missed"; + +/// Most samples a plot or an interference check takes: each is a solve. +const MAX_SAMPLES: usize = 1000; +/// Most joint values one plot answers. +const MAX_SERIES: usize = 256; +/// Most collision work over a whole study (see `ContactGeometry::hits`). +const MAX_WORK: usize = 500_000_000; +/// Most colliding pairs whose overlap volume is worked out, and most body booleans that takes. +const MAX_VOLUMES: usize = 16; +const MAX_BOOLEANS: usize = 64; + +/// How many samples `p` asks for (`min`…`MAX_SAMPLES`, default the study's steps + 1). +fn samples(p: &Value, m: &MotionStudy, min: usize, cmd: &str) -> Result { + if m.steps == 0 { + return Err(bad(cmd, "the study has no steps")); + } + match p.get("samples") { + Some(v) => v + .as_f64() + .filter(|n| n.is_finite() && *n >= min as f64 && *n <= MAX_SAMPLES as f64) + .map(|n| n.round() as usize) + .ok_or_else(|| bad(cmd, format!("`samples` must be {min}…{MAX_SAMPLES}"))), + None => Ok((m.steps as usize).saturating_add(1).clamp(min, MAX_SAMPLES)), + } +} + +/// The study step sample `k` of `n` is at. +fn step_of(m: &MotionStudy, k: usize, n: usize) -> f64 { + f64::from(m.steps) * k as f64 / n.saturating_sub(1).max(1) as f64 +} + +/// Plays study `m` on a copy of the design in `n` samples, each driven from the one before (so +/// closed loops follow on their branch, as `motion.play` places them; contact sets stop it only +/// when `contact`). `each` sees the copy at every sample and answers whether to go on. Returns +/// where the motion stopped short (all null when it did not). +fn play(s: &Session, m: &MotionStudy, n: usize, contact: bool, mut each: impl FnMut(&Session, usize) -> Result) -> Result { + let mut t = s.scratch(); + // Played from where the parts are placed: moves only shown are not where joints are solved. + t.pending_moves.clear(); + if !contact { + t.doc_mut().assembly.contact_enabled = false; + } + let given = keyed(m); + for k in 0..n { + let want = values_at(&t, m, step_of(m, k, n)); + let (r, _) = drive_with_contact(&mut t, &want, &given)?; + let go = each(&t, k)?; + if r["stopped_by_loop"].is_object() || r["stopped_by_contact"].is_object() { + return Ok(json!({"stopped_at": k, "stopped_by_loop": r["stopped_by_loop"], "stopped_by_contact": r["stopped_by_contact"]})); + } + if !go { + break; + } + } + Ok(json!({"stopped_at": null, "stopped_by_loop": null, "stopped_by_contact": null})) +} + +/// The derivative of samples `v` spaced `h` apart: central differences inside, second-order +/// one-sided at the ends (a plain difference with two samples, 0 with one). +fn derivative(v: &[f64], h: f64) -> Vec { + let n = v.len(); + let at = |i: usize| v.get(i).copied().unwrap_or(0.0); + (0..n) + .map(|k| match n { + 0 | 1 => 0.0, + 2 => (at(1) - at(0)) / h, + _ if k == 0 => (-3.0 * at(0) + 4.0 * at(1) - at(2)) / (2.0 * h), + _ if k + 1 == n => (3.0 * at(k) - 4.0 * at(k - 1) + at(k - 2)) / (2.0 * h), + _ => (at(k + 1) - at(k - 1)) / (2.0 * h), + }) + .collect() +} + +/// The second derivative of samples `v` spaced `h` apart: central differences inside, +/// second-order one-sided at the ends (the one difference there is with three samples, 0 with +/// fewer). +fn second_derivative(v: &[f64], h: f64) -> Vec { + let n = v.len(); + let at = |i: usize| v.get(i).copied().unwrap_or(0.0); + let h2 = h * h; + (0..n) + .map(|k| match n { + 0..=2 => 0.0, + 3 => (at(0) - 2.0 * at(1) + at(2)) / h2, + _ if k == 0 => (2.0 * at(0) - 5.0 * at(1) + 4.0 * at(2) - at(3)) / h2, + _ if k + 1 == n => (2.0 * at(k) - 5.0 * at(k - 1) + 4.0 * at(k - 2) - at(k - 3)) / h2, + _ => (at(k + 1) - 2.0 * at(k) + at(k - 1)) / h2, + }) + .collect() +} + +/// Angles in degrees made continuous: a jump of more than half a turn between samples is taken +/// as the angle going round. +fn unwrap_degrees(v: &mut [f64]) { + let (mut turns, mut prev) = (0.0, None::); + for x in v.iter_mut() { + let raw = *x; + if let Some(p) = prev { + turns -= 360.0 * ((raw - p) / 360.0).round(); + } + prev = Some(raw); + *x = raw + turns; + } +} + +/// The joint values a plot follows: (joint id, value index). +fn plotted(s: &Session, p: &Value, cmd: &str) -> Result> { + let joints = &s.doc.assembly.joints; + let ids: Vec = match p.get("joints") { + None | Some(Value::Null) => joints.iter().filter(|j| !j.suppressed).map(|j| j.id).collect(), + Some(Value::Array(list)) if list.len() <= MAX_SERIES => list.iter().map(|v| joint_id(s, Some(v), cmd)).collect::>()?, + Some(_) => return Err(bad(cmd, format!("`joints` must list at most {MAX_SERIES} joints"))), + }; + let mut out = Vec::new(); + for id in ids { + let dofs = joints.iter().find(|j| j.id == id).map_or(0, |j| j.kind.dofs().len()); + for i in 0..dofs { + if !out.contains(&(id, i)) { + out.push((id, i)); + } + } + } + if out.len() > MAX_SERIES { + return Err(bad(cmd, format!("more than {MAX_SERIES} joint values: list the `joints` to plot"))); + } + Ok(out) +} + +pub(super) fn plot(s: &mut Session, p: &Value) -> Result { + let cmd = "motion.plot"; + let m = s.doc.assembly.studies.get(study_index(s, p, cmd)?).cloned().ok_or_else(|| bad(cmd, "study"))?; + let n = samples(p, &m, 3, cmd)?; + let duration = match p.get("duration") { + None | Some(Value::Null) => None, + Some(v) => { + Some(v.as_f64().filter(|d| d.is_finite() && *d > 0.0 && *d <= 1e9).ok_or_else(|| bad(cmd, "`duration` must be seconds, more than 0"))?) + } + }; + let series = plotted(s, p, cmd)?; + let point = if p.get("point").is_some() || p.get("occurrence").is_some() { Some(traced_point(s, p, cmd)?) } else { None }; + let mut values: Vec> = vec![Vec::new(); series.len()]; + let mut path: Vec = Vec::new(); + let mut taken = 0; + let stop = play(s, &m, n, true, |t, _| { + taken += 1; + let now = joints::joint_values(&t.doc.assembly); + for ((id, i), out) in series.iter().zip(values.iter_mut()) { + out.push(now.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)); + } + if let Some((occ, local)) = &point { + path.push(apply_point(&path_world(t, occ, cmd)?, *local)); + } + Ok(true) + })?; + // Sample spacing in steps, or in seconds when the study has a duration. + let seconds_per_step = duration.map(|d| d / f64::from(m.steps)); + let h = f64::from(m.steps) / (n - 1) as f64 * seconds_per_step.unwrap_or(1.0); + let per = if duration.is_some() { "s" } else { "step" }; + let steps: Vec = (0..taken).map(|k| step_of(&m, k, n)).collect(); + let mut out = json!({"study": m.name, "steps": m.steps, "samples": taken, "step": steps}); + if let Some(sps) = seconds_per_step { + out["time"] = steps.iter().map(|st| st * sps).collect(); + } + let joints_out: Vec = series + .iter() + .zip(values.iter_mut()) + .map(|((id, i), v)| { + let j = s.doc.assembly.joints.iter().find(|j| j.id == *id); + let angle = j.is_some_and(|j| j.kind.is_angle(*i)); + let unit = if angle { "deg" } else { "mm" }; + if angle { + v.iter_mut().for_each(|x| *x = x.to_degrees()); + unwrap_degrees(v); + } + json!({"joint": id, "name": j.map(|j| j.name.clone()), "index": i, "dof": j.and_then(|j| j.kind.dofs().get(*i).copied()), + "unit": unit, "velocity_unit": format!("{unit}/{per}"), "acceleration_unit": format!("{unit}/{per}²"), + "value": v, "velocity": derivative(v, h), "acceleration": second_derivative(v, h)}) + }) + .collect(); + out["joints"] = json!(joints_out); + if let Some((occ, _)) = &point { + let axis = |c: usize| -> Vec { path.iter().map(|q| [q.x, q.y, q.z].get(c).copied().unwrap_or(0.0)).collect() }; + let d: Vec<(Vec, Vec)> = (0..3).map(|c| (derivative(&axis(c), h), second_derivative(&axis(c), h))).collect(); + let vec_at = |k: usize, second: bool| -> Vec3 { + let c = |i: usize| d.get(i).and_then(|(v, a)| if second { a.get(k) } else { v.get(k) }).copied().unwrap_or(0.0); + Vec3::new(c(0), c(1), c(2)) + }; + let (vel, acc): (Vec, Vec) = (0..path.len()).map(|k| (vec_at(k, false), vec_at(k, true))).unzip(); + let xyz = |list: &[Vec3]| -> Value { list.iter().map(|v| json!([v.x, v.y, v.z])).collect() }; + let name = s.doc.occurrences.iter().find(|o| Some(&o.id) == occ.last()).map_or_else(|| "root".to_string(), |o| o.name.clone()); + out["point"] = json!({"occurrence": name, "unit": "mm", "velocity_unit": format!("mm/{per}"), "acceleration_unit": format!("mm/{per}²"), + "position": xyz(&path), "velocity": xyz(&vel), "acceleration": xyz(&acc), + "speed": vel.iter().map(|v| v.len()).collect::>(), "acceleration_norm": acc.iter().map(|v| v.len()).collect::>()}); + } + for k in ["stopped_at", "stopped_by_loop", "stopped_by_contact"] { + out[k] = stop[k].clone(); + } + Ok(out) +} + +/// One pair of parts that collides along a study. +struct Collision { + names: (String, String), + /// The samples where they overlap, in order. + samples: Vec, + depth: f64, + deepest: usize, + /// The design at the deepest sample (its placements). + at_deepest: Arc, +} + +/// The volume the parts under occurrences `a` and `b` share, placed as in `doc` (`None`: not +/// worked out). Only bodies whose boxes overlap are intersected, at most `booleans` of them. +fn shared_volume(s: &Session, doc: &Document, a: u64, b: u64, booleans: &mut usize) -> Option { + let st = s.model.state(); + // The bodies under each (and not under the other), with their world frames and boxes. + let mut sides: [Vec<(Mat, Aabb3, &solidcraft_doc::ModelBody)>; 2] = Default::default(); + for (comp, path, m) in doc.placements() { + let side = match (path.contains(&a), path.contains(&b)) { + (true, false) => 0, + (false, true) => 1, + _ => continue, + }; + for body in st.bodies.iter().filter(|x| doc.model_body_component(x) == comp && !x.body.is_mesh()) { + let frame = doc.model_body_offset(body).map(|o| mat_mul(&m, o)).unwrap_or(m); + let local = body.mesh().bounds(); + let mut world = Aabb3::EMPTY; + for c in 0..8 { + let pick = |bit: usize, lo: f64, hi: f64| if c & bit == 0 { lo } else { hi }; + let corner = Vec3::new(pick(1, local.min.x, local.max.x), pick(2, local.min.y, local.max.y), pick(4, local.min.z, local.max.z)); + world.add(apply_point(&frame, corner)); + } + sides.get_mut(side)?.push((frame, world, body)); + } + } + let mut total = 0.0; + for (fa, xa, ba) in sides.first()? { + for (fb, xb, bb) in sides.get(1)? { + if xa.max.x < xb.min.x || xb.max.x < xa.min.x || xa.max.y < xb.min.y || xb.max.y < xa.min.y || xa.max.z < xb.min.z || xb.max.z < xa.min.z + { + continue; + } + *booleans = booleans.checked_sub(1)?; + let (pa, pb) = (solidcraft_kernel::transform_matrix(&ba.body, *fa).ok()?, solidcraft_kernel::transform_matrix(&bb.body, *fb).ok()?); + if let Some(shared) = solidcraft_kernel::boolean(&pa, &pb, solidcraft_kernel::BoolOp::Intersect).ok()? { + total += solidcraft_kernel::measure(&shared).ok()?.volume; + } + } + } + Some(total) +} + +/// Contiguous runs of samples, as study step ranges. +fn ranges(m: &MotionStudy, samples: &[usize], n: usize) -> Vec<[f64; 2]> { + let mut out: Vec<(usize, usize)> = Vec::new(); + for &k in samples { + match out.last_mut() { + Some(run) if run.1 + 1 == k => run.1 = k, + _ => out.push((k, k)), + } + } + out.into_iter().map(|(a, b)| [step_of(m, a, n), step_of(m, b, n)]).collect() +} + +pub(super) fn interference(s: &mut Session, p: &Value) -> Result { + let cmd = "motion.interference"; + let m = s.doc.assembly.studies.get(study_index(s, p, cmd)?).cloned().ok_or_else(|| bad(cmd, "study"))?; + let n = samples(p, &m, 2, cmd)?; + let everything = || vec![s.doc.occurrences.iter().map(|o| o.id).collect::>()]; + // Each of `occurrences` only against these. + let against = match p.get("against") { + None | Some(Value::Null) => None, + Some(_) if p.get("occurrences").is_none() => return Err(bad(cmd, "`against` checks the `occurrences` against other parts: list them too")), + Some(v) => Some(occurrences(s, &json!({"occurrences": v}), cmd)?), + }; + let sets: Vec> = match (p.get("occurrences"), bool_(p, "contact_sets").unwrap_or(false)) { + (Some(_), true) => return Err(bad(cmd, "give `occurrences` or `contact_sets`, not both")), + (Some(_), false) => { + let mut list = occurrences(s, p, cmd)?; + if list.len() + against.as_ref().map_or(0, Vec::len) < 2 { + return Err(bad(cmd, "list two or more occurrences to check against each other")); + } + for o in against.iter().flatten() { + if !list.contains(o) { + list.push(*o); + } + } + vec![list] + } + (None, true) => { + let a = &s.doc.assembly; + let sets: Vec> = + if a.contact_all { everything() } else { a.contact_sets.iter().filter(|c| !c.suppressed).map(|c| c.occurrences.clone()).collect() }; + if sets.is_empty() { + return Err(bad(cmd, "the design has no contact sets")); + } + sets + } + (None, false) => everything(), + }; + let mut geometry = ContactGeometry::of_sets(s, &sets).map_err(|e| match e { + EngineError::BadParams { msg, .. } => bad(cmd, format!("{msg} (check fewer parts: list the `occurrences`)")), + e => e, + })?; + if let Some(b) = &against { + let a = occurrences(s, p, cmd)?; + geometry.keep_pairs(|x, y| (a.contains(&x) && b.contains(&y)) || (a.contains(&y) && b.contains(&x))); + } + let mut found: BTreeMap<(u64, u64), Collision> = BTreeMap::new(); + let (mut work, mut checked, mut truncated) = (0usize, 0usize, false); + let stop = play(s, &m, n, false, |t, k| { + let hits: Vec<(Member, Member, f64)> = match geometry.hits(t, &mut work, MAX_WORK, true) { + Ok(h) => h, + Err(_) if work > MAX_WORK => { + truncated = true; + return Ok(false); + } + Err(e) => return Err(e), + }; + checked = k + 1; + for ((a, an), (b, bn), depth) in hits { + // Each pair once, the older occurrence (the lower id) first. + let ((a, an), (b, bn)) = if a <= b { ((a, an), (b, bn)) } else { ((b, bn), (a, an)) }; + let c = found.entry((a, b)).or_insert_with(|| Collision { + names: (an, bn), + samples: Vec::new(), + depth: 0.0, + deepest: k, + at_deepest: t.doc.clone(), + }); + if c.samples.last() != Some(&k) { + c.samples.push(k); + } + if depth > c.depth { + (c.depth, c.deepest, c.at_deepest) = (depth, k, t.doc.clone()); + } + } + Ok(true) + })?; + let mut list: Vec<(&(u64, u64), &Collision)> = found.iter().collect(); + list.sort_by_key(|(_, c)| c.samples.first().copied().unwrap_or(usize::MAX)); + let mut booleans = MAX_BOOLEANS; + let collisions: Vec = list + .iter() + .enumerate() + .map(|(i, ((a, b), c))| { + let volume = if i < MAX_VOLUMES { shared_volume(s, &c.at_deepest, *a, *b, &mut booleans) } else { None }; + let first = c.samples.first().map(|k| step_of(&m, *k, n)); + let last = c.samples.last().map(|k| step_of(&m, *k, n)); + json!({"a": c.names.0, "b": c.names.1, "first_step": first, "last_step": last, "steps": ranges(&m, &c.samples, n), + "depth_mm": c.depth, "deepest_step": step_of(&m, c.deepest, n), "volume_mm3": volume}) + }) + .collect(); + let mut out = json!({"study": m.name, "steps": m.steps, "clear": collisions.is_empty(), "collisions": collisions, + "pairs": geometry.pair_count(), "samples": checked, "truncated": truncated}); + if truncated { + out["checked_to"] = json!(checked.checked_sub(1).map(|k| step_of(&m, k, n))); + } + out["stopped_at"] = stop["stopped_at"].clone(); + out["stopped_by_loop"] = stop["stopped_by_loop"].clone(); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The differences are exact for a quadratic: its velocity and acceleration everywhere, + /// the ends too. + #[test] + fn finite_differences_follow_a_polynomial() { + let h = 0.5; + let f = |x: f64| 2.0 * x * x - 3.0 * x + 1.0; + let v: Vec = (0..7).map(|k| f(k as f64 * h)).collect(); + let d = derivative(&v, h); + let a = second_derivative(&v, h); + for (k, (dk, ak)) in d.iter().zip(&a).enumerate() { + let x = k as f64 * h; + assert!((dk - (4.0 * x - 3.0)).abs() < 1e-9, "{k}: {dk}"); + assert!((ak - 4.0).abs() < 1e-9, "{k}: {ak}"); + } + assert_eq!(derivative(&[1.0, 3.0], 2.0), vec![1.0, 1.0]); + assert_eq!(second_derivative(&[1.0, 3.0], 2.0), vec![0.0, 0.0]); + assert_eq!(derivative(&[5.0], 1.0), vec![0.0]); + assert!(derivative(&[], 1.0).is_empty()); + } + + #[test] + fn angles_unwrap_across_half_turns() { + let mut v = vec![170.0, 179.0, -172.0, -160.0, 175.0]; + unwrap_degrees(&mut v); + assert_eq!(v, vec![170.0, 179.0, 188.0, 200.0, 175.0]); + } +} diff --git a/crates/engine/src/cmd/motion_analysis_tests.rs b/crates/engine/src/cmd/motion_analysis_tests.rs new file mode 100644 index 00000000..872aceaf --- /dev/null +++ b/crates/engine/src/cmd/motion_analysis_tests.rs @@ -0,0 +1,184 @@ +//! `motion.plot` against the analytic motion of a four-bar and a slider-crank, and +//! `motion.interference` on two blocks pushed through each other at a known step. + +use serde_json::{Value, json}; +use solidcraft_geom::Vec3; + +use super::{four_bar, occ, rocker_pin, run, slider_crank}; +use crate::Session; + +/// A joint's series in a plot, by joint name. +fn series<'a>(r: &'a Value, name: &str) -> &'a Value { + r["joints"].as_array().and_then(|list| list.iter().find(|j| j["name"] == name)).unwrap_or(&Value::Null) +} + +fn nums(v: &Value) -> Vec { + v.as_array().into_iter().flatten().map(|x| x.as_f64().unwrap_or(f64::NAN)).collect() +} + +/// The crank turns at a constant 50°/s (a full turn in 7.2 s). The rocker turns at +/// ω4 = ω2·a·sin(θ2 − θ3) / (c·sin(θ4 − θ3)) (crank a = 20, rocker c = 40; θ2, θ3 and θ4 the +/// crank, coupler and rocker angles), from differentiating the loop a·e^iθ2 + b·e^iθ3 = d + c·e^iθ4. +#[test] +fn four_bar_rocker_velocity_matches_its_analytic_derivative() { + let (mut s, _, q0) = four_bar(); + run(&mut s, "motion.study", json!({"name": "Turn", "steps": 360, "keys": [{"joint": "Crank", "points": [[0, 0], [360, 360]]}]})); + let before = s.doc.clone(); + let r = run(&mut s, "motion.plot", json!({"study": "Turn", "joints": ["Crank", "Rocker"], "samples": 181, "duration": 7.2})); + assert_eq!(*s.doc, *before, "a plot leaves the design as it was"); + assert!(r["stopped_at"].is_null() && r["samples"] == 181, "{}", r["stopped_by_loop"]); + assert_eq!(r["joints"].as_array().map(Vec::len), Some(2)); + let (crank, rocker) = (series(&r, "Crank"), series(&r, "Rocker")); + assert_eq!((rocker["unit"].as_str(), rocker["velocity_unit"].as_str()), (Some("deg"), Some("deg/s"))); + let time = nums(&r["time"]); + assert!((time.last().copied().unwrap_or(0.0) - 7.2).abs() < 1e-9 && (time[1] - 0.04).abs() < 1e-12, "{time:?}"); + // The crank: exactly as keyed, at a constant speed. + for ((v, w), a) in nums(&crank["value"]).iter().zip(nums(&crank["velocity"])).zip(nums(&crank["acceleration"])) { + assert!((w - 50.0).abs() < 1e-6 && a.abs() < 1e-6, "{v}°: {w} deg/s, {a} deg/s²"); + } + let (value, velocity) = (nums(&rocker["value"]), nums(&rocker["velocity"])); + let start = q0.y.atan2(q0.x - 45.0); + let (mut q, mut peak, mut worst) = (q0, 0.0f64, 0.0f64); + for k in 0..181 { + let v = 2.0 * k as f64; + if k > 0 { + q = rocker_pin(v - 2.0, v, q); + } + let th2 = (90.0 + v).to_radians(); + let p = Vec3::new(20.0 * th2.cos(), 20.0 * th2.sin(), 0.0); + let (th3, th4) = ((q - p).y.atan2((q - p).x), q.y.atan2(q.x - 45.0)); + // The rocker's value is its turn from where it was assembled, made continuous. + let turned = (th4 - start).to_degrees(); + assert!(((value[k] - turned + 540.0).rem_euclid(360.0) - 180.0).abs() < 1e-6, "{v}°: {} wants {turned}", value[k]); + let w4 = 50.0 * 20.0 * (th2 - th3).sin() / (40.0 * (th4 - th3).sin()); + peak = peak.max(w4.abs()); + worst = worst.max((velocity[k] - w4).abs()); + } + // Central differences 0.04 s apart: within 0.2 % of the fastest swing. + assert!(peak > 40.0 && worst < 2e-3 * peak, "worst {worst} deg/s of {peak} deg/s"); +} + +/// The piston of a slider-crank (crank a = 15, rod b = 40) turning at 100°/s sits at +/// x = a·cos θ + S with S = √(b² − a²·sin²θ), so it accelerates at +/// ω²·(−a·cos θ − a²·cos 2θ / S − a⁴·sin²θ·cos²θ / S³). +#[test] +fn slider_crank_piston_acceleration_matches_its_analytic_form() { + let (mut s, _, s0) = slider_crank(); + run(&mut s, "motion.study", json!({"name": "Turn", "steps": 360, "keys": [{"joint": "Crank", "points": [[0, 0], [360, 360]]}]})); + let p = json!({"study": "Turn", "joints": ["Bore"], "occurrence": "Piston", "point": [s0.x, s0.y, s0.z], "samples": 181, "duration": 3.6}); + let r = run(&mut s, "motion.plot", p); + assert!(r["stopped_at"].is_null() && r["samples"] == 181, "{}", r["stopped_by_loop"]); + let point = &r["point"]; + assert_eq!((point["occurrence"].as_str(), point["acceleration_unit"].as_str()), (Some("Piston:1"), Some("mm/s²"))); + let at = |key: &str, k: usize, c: usize| point[key][k][c].as_f64().unwrap_or(f64::NAN); + let (a, b, w) = (15.0f64, 40.0f64, 100.0f64.to_radians()); + let (mut peak, mut worst) = (0.0f64, 0.0f64); + for k in 0..181 { + let th = (90.0 + 2.0 * k as f64).to_radians(); + let root = (b * b - (a * th.sin()).powi(2)).sqrt(); + assert!((at("position", k, 0) - (a * th.cos() + root)).abs() < 1e-6, "{k}"); + let want = w * w * (-a * th.cos() - a * a * (2.0 * th).cos() / root - a.powi(4) * (th.sin() * th.cos()).powi(2) / root.powi(3)); + peak = peak.max(want.abs()); + // The ends take one-sided differences; inside, central ones. + let tolerance = if k == 0 || k == 180 { 2e-2 } else { 2e-3 }; + worst = worst.max((at("acceleration", k, 0) - want).abs() / tolerance); + assert!(at("acceleration", k, 1).abs() < 1e-6 && at("acceleration", k, 2).abs() < 1e-6, "it stays on its axis"); + } + assert!(peak > 40.0 && worst < peak, "worst {worst} of {peak} mm/s² (scaled by tolerance)"); + // The bore's value is the piston's travel, so its acceleration is the piston's along x. + let bore = series(&r, "Bore"); + assert_eq!(bore["acceleration_unit"], "mm/s²"); + let along = |k: usize| nums(&bore["value"])[k] - nums(&bore["value"])[0]; + let sign = if along(90) * (at("position", 90, 0) - at("position", 0, 0)) < 0.0 { -1.0 } else { 1.0 }; + for (k, acc) in nums(&bore["acceleration"]).iter().enumerate() { + assert!((acc - sign * at("acceleration", k, 0)).abs() < 1e-6, "{k}: {acc}"); + } + // Per step without a duration: 2 steps between samples, 1° a step. + let r = run(&mut s, "motion.plot", json!({"study": "Turn", "joints": ["Crank"], "samples": 181})); + let crank = series(&r, "Crank"); + assert!(r["time"].is_null() && crank["velocity_unit"] == "deg/step", "{crank}"); + assert!(nums(&crank["velocity"]).iter().all(|v| (v - 1.0).abs() < 1e-9)); +} + +/// Cubes A (grounded, x 0…10) and B (x 20…30) on a slider along x, B 10 mm away at value 10. +fn rail() -> Session { + let mut s = Session::default(); + for (name, x) in [("A", 0), ("B", 20)] { + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": name})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]})); + } + run(&mut s, "component.activate", json!({"component": "root"})); + let a = occ(&s, "A"); + run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true})); + run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"})); + run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10})); + s +} + +/// B is pushed 1 mm a step from 10 mm away to 5 mm into A: the faces touch at step 10 and the +/// blocks overlap from step 11 to the end, 100 mm³ more each step. +#[test] +fn interference_names_the_step_two_parts_start_to_collide() { + let mut s = rail(); + run(&mut s, "motion.study", json!({"name": "Push", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [15, -5]]}]})); + // A contact set would stop the motion where the faces touch; the check plays through. + run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]})); + let before = s.doc.clone(); + for p in [json!({"study": "Push"}), json!({"study": "Push", "occurrences": ["A", "B"]}), json!({"study": "Push", "contact_sets": true})] { + let r = run(&mut s, "motion.interference", p.clone()); + assert_eq!(*s.doc, *before, "the design is left as it was"); + assert!(r["clear"] == false && r["samples"] == 16 && r["pairs"] == 1 && r["truncated"] == false, "{p}: {r}"); + let c = &r["collisions"][0]; + assert_eq!((c["a"].as_str(), c["b"].as_str()), (Some("A:1"), Some("B:1")), "{r}"); + assert_eq!((c["first_step"].as_f64(), c["last_step"].as_f64()), (Some(11.0), Some(15.0)), "{r}"); + assert_eq!(c["steps"], json!([[11.0, 15.0]]), "{r}"); + let deepest = c["deepest_step"].as_f64().unwrap_or(f64::NAN); + assert!(c["depth_mm"].as_f64().unwrap_or(0.0) > 1.0 && (11.0..=15.0).contains(&deepest), "{r}"); + let volume = c["volume_mm3"].as_f64().unwrap_or(f64::NAN); + assert!((volume - 100.0 * (deepest - 10.0)).abs() < 1e-6, "{r}"); + } + // B against a far block C only: B's pass through A is not asked about. + run(&mut s, "component.create", json!({"name": "C"})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0, 50, 0]})); + run(&mut s, "component.activate", json!({"component": "root"})); + let r = run(&mut s, "motion.interference", json!({"study": "Push", "occurrences": ["B"], "against": ["C"]})); + assert!(r["clear"] == true && r["pairs"] == 1, "{r}"); + let r = run(&mut s, "motion.interference", json!({"study": "Push", "occurrences": ["B"], "against": ["A", "C"]})); + assert!(r["pairs"] == 2 && r["collisions"].as_array().map(Vec::len) == Some(1) && r["collisions"][0]["first_step"] == 11.0, "{r}"); + assert!(s.execute("motion.interference", &json!({"study": "Push", "against": ["A"]})).is_err()); + // Pushed only to touching, they never pass through each other. + run(&mut s, "motion.study", json!({"study": "Push", "keys": [{"joint": "Rail", "points": [[0, 10], [15, 0]]}]})); + let r = run(&mut s, "motion.interference", json!({"study": "Push"})); + assert!(r["clear"] == true && r["collisions"] == json!([]), "{r}"); +} + +/// Hostile parameters are refused, never a panic. +#[test] +fn motion_analysis_refuses_what_it_cannot_do() { + let mut s = rail(); + run(&mut s, "motion.study", json!({"name": "Push", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [15, -5]]}]})); + let refused = [ + ("motion.plot", json!({"study": "Nope"})), + ("motion.plot", json!({"study": "Push", "samples": 2})), + ("motion.plot", json!({"study": "Push", "samples": 1e9})), + ("motion.plot", json!({"study": "Push", "samples": "many"})), + ("motion.plot", json!({"study": "Push", "duration": 0})), + ("motion.plot", json!({"study": "Push", "duration": -3})), + ("motion.plot", json!({"study": "Push", "joints": ["Nope"]})), + ("motion.plot", json!({"study": "Push", "joints": vec![json!("Rail"); 300]})), + ("motion.plot", json!({"study": "Push", "point": [1, 2]})), + ("motion.plot", json!({"study": "Push", "occurrence": "B", "point": [1e300, 0, 0]})), + ("motion.interference", json!({"study": "Push", "samples": 1})), + ("motion.interference", json!({"study": "Push", "samples": f64::MAX})), + ("motion.interference", json!({"study": "Push", "occurrences": ["A"]})), + ("motion.interference", json!({"study": "Push", "occurrences": ["A", "B"], "contact_sets": true})), + ("motion.interference", json!({"study": "Push", "contact_sets": true})), + ]; + for (cmd, p) in refused { + assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}"); + } + // A point of a part that does not move has no velocity. + let r = run(&mut s, "motion.plot", json!({"study": "Push", "occurrence": "A", "point": [5, 5, 5]})); + assert!(r["point"]["speed"].as_array().is_some_and(|v| v.len() == 16 && v.iter().all(|x| x.as_f64() == Some(0.0))), "{r}"); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8601a3bb..48c6fe40 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -371,11 +371,19 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> let Some(id) = s("command") else { return err("missing `command`") }; // `params`: what the dialog opens on. Refused: nothing starts, or the dialog closes. let params = p.get("params").filter(|v| !v.is_null()); - if params.is_some() && id != "joint.drive" { - return err(format!("`{id}` takes no ui.start params yet (joint.drive takes joint, values)")); + if params.is_some() && id != "joint.drive" && id != "motion.study" { + return err(format!("`{id}` takes no ui.start params yet (joint.drive takes joint, values; motion.study takes study)")); } app.start(id); - if let Some(params) = params { + if let Some(params) = params.filter(|_| id == "motion.study") { + let Some(crate::dialogs::Kind::Motion(k)) = app.dialog.as_mut().map(|d| &mut d.kind) else { + return err("Motion Study did not open"); + }; + if let Err(e) = crate::dialogs_motion::preset(&app.session, k, params) { + crate::dialogs::cancel(app); + return err(e); + } + } else if let Some(params) = params { if app.tree.capture_prompt.is_some() { return err("Drive Joints waits for the Capture Position question (ui.capture)"); } diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 05fe187d..4294c0fe 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -23,13 +23,13 @@ use crate::viewport::Proj; #[path = "dialogs_trace.rs"] mod trace; -pub use trace::TraceForm; +pub use trace::{TraceForm, picked_point}; /// 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"]; /// What a joint origin input accepts (snaps on faces, edges and vertices). -const SNAPS: u16 = FACES | EDGES | VERTICES; +pub const SNAPS: u16 = FACES | EDGES | VERTICES; /// Commands whose effect is where occurrences sit (previewed on the placed model). const MOVES: [&str; 12] = [ "joint.create", @@ -265,7 +265,7 @@ fn current_values(s: &Session, index: usize) -> Vec { } /// A value with its unit, without trailing zeros ("12 mm", "37.5 deg"). -fn num(v: f64, unit: &str) -> String { +pub(crate) fn num(v: f64, unit: &str) -> String { let s = format!("{v:.4}"); let s = s.trim_end_matches('0').trim_end_matches('.'); format!("{} {unit}", if s == "-0" { "0" } else { s }) @@ -780,7 +780,7 @@ fn sel_items(inputs: &[SelInput], i: usize) -> &[Sel] { } /// The body a pick is on. -fn body_of(sel: &Sel) -> Option<&str> { +pub(crate) fn body_of(sel: &Sel) -> Option<&str> { match sel { Sel::Face { body, .. } | Sel::Edge { body, .. } | Sel::Vertex { body, .. } => Some(body), Sel::Body { name } => Some(name), diff --git a/crates/ui-egui/src/dialogs_motion.rs b/crates/ui-egui/src/dialogs_motion.rs index 3bb4f9c2..af1a0e78 100644 --- a/crates/ui-egui/src/dialogs_motion.rs +++ b/crates/ui-egui/src/dialogs_motion.rs @@ -18,6 +18,8 @@ use crate::theme::Tokens; #[path = "configuration_rules_ui.rs"] mod configuration_rules_ui; +#[path = "dialogs_motion_plot.rs"] +pub mod plot; /// One keyed value of a motion study: a joint's value (index) at steps. #[derive(Clone, Debug, PartialEq)] @@ -52,6 +54,8 @@ pub enum Mo { shown: Option<(u64, u64)>, saved: Option>, error: Option, + /// The chart and the collision check by the timeline. + analysis: Box, }, Explode { name: String, @@ -114,8 +118,14 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { shown: None, saved: None, error: None, + analysis: Box::default(), }, - vec![], + // A point to plot, and the parts to check for collisions (each optional). + vec![ + SelInput::new("Point", crate::dialogs_assembly::SNAPS, false), + SelInput::new("Components", BODIES, true), + SelInput::new("Against", BODIES, true), + ], ), "explode.create" => (Mo::Explode { name: n("Exploded View", "explode.list"), scale: 1.0, shown: None, saved: None, error: None }, vec![]), "config.table" | "config.configure" => (Mo::Configurations { new_row: String::new(), error: None, rule_editor: Default::default() }, vec![]), @@ -193,6 +203,43 @@ fn transport(ui: &mut egui::Ui, what: Transport, tip: &str) -> egui::Response { resp.on_hover_text(tip) } +/// Study `key` (an id or a name) as the Motion Study dialog edits it: its name, steps and keys +/// as tracks (values in degrees and mm). +pub fn study_tracks(s: &Session, key: &str) -> Result<(String, String, Vec), String> { + let m = s.doc.assembly.studies.iter().find(|m| m.id.to_string() == key || m.name == key).ok_or_else(|| format!("no motion study `{key}`"))?; + let tracks = m + .keys + .iter() + .filter_map(|k| { + let joint = s.doc.assembly.joints.iter().position(|j| j.id == k.joint)?; + let angle = s.doc.assembly.joints.get(joint)?.kind.is_angle(k.index); + let value = |v: f64| if angle { crate::dialogs_assembly::num(v.to_degrees(), "deg") } else { crate::dialogs_assembly::num(v, "mm") }; + let points = k.points.iter().map(|(st, v)| (st.round().clamp(0.0, f64::from(m.steps)) as u32, value(*v))).collect(); + Some(Track { joint, index: k.index, points }) + }) + .collect(); + Ok((m.name.clone(), m.steps.to_string(), tracks)) +} + +/// What the Motion Study dialog opens on when the control channel's `ui.start` passes `params`: +/// `study` (an id or a name), opened to edit. +pub fn preset(s: &Session, k: &mut Mo, p: &Value) -> Result<(), String> { + let Mo::MotionStudy { name, study, steps, tracks, shown, .. } = k else { return Err("not a Motion Study dialog".into()) }; + let o = p.as_object().ok_or("`params` must be an object")?; + if let Some(key) = o.keys().find(|k| k.as_str() != "study") { + return Err(format!("unknown param `{key}` (Motion Study takes study)")); + } + let key = match o.get("study") { + Some(Value::Number(n)) => n.to_string(), + Some(Value::String(x)) => x.clone(), + Some(_) => return Err("`study` must be an id or a name".into()), + None => return Ok(()), + }; + let (n, st, t) = study_tracks(s, &key)?; + (*study, *name, *steps, *tracks, *shown) = (Some(n.clone()), n, st, t, None); + Ok(()) +} + /// The study command's parameters. pub fn study_params(s: &Session, name: &str, study: Option<&str>, steps: &str, tracks: &[Track]) -> Result { let steps: u32 = steps.trim().parse().map_err(|_| "steps must be a whole number".to_string())?; @@ -432,7 +479,24 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel ui.end_row(); } } - Mo::MotionStudy { name, study, steps, tracks, step, playing, looping, speed, shown, saved, error } => { + Mo::MotionStudy { name, study, steps, tracks, step, playing, looping, speed, shown, saved, error, analysis } => { + // An existing study opens to edit (and to chart); New makes another from these keys. + let names: Vec = app.session.doc.assembly.studies.iter().map(|m| m.name.clone()).collect(); + if !names.is_empty() { + let labels: Vec<&str> = std::iter::once("New study").chain(names.iter().map(String::as_str)).collect(); + let was = study.as_ref().and_then(|n| names.iter().position(|x| x == n)).map_or(0, |i| i + 1); + let mut pick = was; + row_label(ui, "Study"); + combo(ui, "ms_study", &labels, &mut pick); + ui.end_row(); + if pick != was { + match pick.checked_sub(1).and_then(|i| names.get(i)).map(|n| study_tracks(&app.session, n)) { + Some(Ok((n, st, t))) => (*study, *name, *steps, *tracks, *shown, *playing) = (Some(n.clone()), n, st, t, None, false), + Some(Err(e)) => *error = Some(e), + None => *study = None, + } + } + } row_label(ui, "Name"); ui.text_edit_singleline(name); ui.end_row(); @@ -497,6 +561,9 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel (*step, *playing) = advance(*step, dt * fps, total, *looping); ui.ctx().request_repaint(); } + // The chart and the collision check, by the timeline. + let study_now = study_params(&app.session, name, study.as_deref(), steps, tracks); + plot::rows(app, ui, analysis, &study_now, step, playing, total, inputs); // Tracks: a row per keyed joint value, its keys on a strip under the steps. let now = step.round() as u32; let mut remove: Option = None; @@ -1043,6 +1110,7 @@ mod tests { shown: None, saved, error: None, + analysis: Box::default(), } } else { Mo::Explode { name: "E".into(), scale: 1.0, shown: None, saved, error: None } diff --git a/crates/ui-egui/src/dialogs_motion_plot.rs b/crates/ui-egui/src/dialogs_motion_plot.rs new file mode 100644 index 00000000..ea1960f7 --- /dev/null +++ b/crates/ui-egui/src/dialogs_motion_plot.rs @@ -0,0 +1,431 @@ +//! Motion Study analysis rows: a chart over the study's timeline of a joint's value, or of a +//! picked point's travel, as value, velocity or acceleration (`motion.plot`), with a cursor at +//! the step shown; and the parts that collide as the study plays (`motion.interference`). Both +//! are worked out on a scratch copy, again only when what they are for changes. + +use std::hash::{Hash, Hasher}; + +use egui::{Align2, FontId, Rect, RichText, Sense, Stroke, pos2, vec2}; +use serde_json::{Value, json}; +use solidcraft_engine::Session; +use solidcraft_engine::doc::joints::JointKind; + +use crate::SolveApp; +use crate::dialogs::{combo, row_label}; +use crate::selection::SelInput; +use crate::theme::Tokens; + +/// Most samples the chart takes (each is a solve). +const SAMPLES: u64 = 201; +pub const QUANTITIES: [&str; 3] = ["Value", "Velocity", "Acceleration"]; + +/// What the chart shows. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Plotted { + Nothing, + /// A joint's value: (joint id, value index). + Joint(u64, usize), + /// The picked point: its distance from where it starts. + Point, +} + +#[derive(Clone, Debug, Default)] +pub struct Analysis { + /// Index into `choices`. + pub what: usize, + /// Index into `QUANTITIES`. + pub quantity: usize, + /// Seconds the whole study takes (empty: rates per step). + pub duration: String, + /// Collisions among all components (0) or in the design's contact sets (1). + pub among: usize, + /// The chart's `motion.plot` answer (or why none) and what it was for. + plot: Option<(u64, Result)>, + /// The last collision check's answer and what it was for. + collisions: Option<(u64, Result)>, +} + +/// What the chart can show: (label, what). +pub fn choices(s: &Session, point: bool) -> Vec<(String, Plotted)> { + let mut out = vec![("None".to_string(), Plotted::Nothing)]; + for j in s.doc.assembly.joints.iter().filter(|j| j.kind != JointKind::Rigid && !j.suppressed) { + let dofs = j.kind.dofs(); + for (i, d) in dofs.iter().enumerate() { + out.push((if dofs.len() > 1 { format!("{} · {d}", j.name) } else { j.name.clone() }, Plotted::Joint(j.id, i))); + } + } + if point { + out.push(("Picked point".into(), Plotted::Point)); + } + out +} + +/// `motion.plot`'s parameters for `study` (the study command's parameters). +pub fn plot_params(s: &Session, what: Plotted, study: &Value, duration: &str, inputs: &[SelInput]) -> Result { + let name = study.get("study").or_else(|| study.get("name")).cloned().unwrap_or(Value::Null); + let steps = study["steps"].as_u64().unwrap_or(100); + let mut p = json!({"study": name, "samples": steps.saturating_add(1).clamp(3, SAMPLES)}); + match what { + Plotted::Nothing => return Err(String::new()), + Plotted::Joint(id, _) => p["joints"] = json!([id]), + Plotted::Point => { + let picked = crate::dialogs_assembly::picked_point(s, inputs)?; + p["joints"] = json!([]); + p["occurrence"] = picked["occurrence"].clone(); + p["point"] = picked["point"].clone(); + } + } + let d = duration.trim(); + if !d.is_empty() { + p["duration"] = json!(d.parse::().map_err(|_| "the duration must be a number of seconds".to_string())?); + } + Ok(p) +} + +fn nums(v: &Value) -> Vec { + v.as_array().into_iter().flatten().map(|x| x.as_f64().unwrap_or(f64::NAN)).collect() +} + +/// The chart's line from `motion.plot`'s answer: (study steps, values, unit). +pub fn line(v: &Value, what: Plotted, quantity: usize) -> Option<(Vec, Vec, String)> { + let series = match what { + Plotted::Nothing => return None, + Plotted::Joint(_, i) => v["joints"].as_array()?.iter().find(|j| j["index"] == i)?, + Plotted::Point => &v["point"], + }; + let unit = series[["unit", "velocity_unit", "acceleration_unit"].get(quantity)?].as_str()?.to_string(); + let ys = match (what, quantity) { + (Plotted::Point, 0) => { + let at = |p: &Value| [0, 1, 2].map(|c| p[c].as_f64().unwrap_or(f64::NAN)); + let pts: Vec<[f64; 3]> = series["position"].as_array()?.iter().map(at).collect(); + let start = *pts.first()?; + pts.iter().map(|p| p.iter().zip(&start).map(|(a, b)| (a - b).powi(2)).sum::().sqrt()).collect() + } + (Plotted::Point, 1) => nums(&series["speed"]), + (Plotted::Point, _) => nums(&series["acceleration_norm"]), + (_, q) => nums(&series[*["value", "velocity", "acceleration"].get(q)?]), + }; + Some((nums(&v["step"]), ys, unit)) +} + +/// A short number for an axis or a readout. +fn short(v: f64) -> String { + let a = v.abs(); + if a >= 1000.0 { + format!("{v:.0}") + } else if a >= 10.0 { + format!("{v:.1}") + } else { + format!("{v:.3}") + } +} + +/// The value of a line at step `at` (linear between samples; `None` outside it). +pub fn value_at(xs: &[f64], ys: &[f64], at: f64) -> Option { + let k = xs.iter().position(|x| *x >= at)?; + let (x1, y1) = (*xs.get(k)?, *ys.get(k)?); + if k == 0 || x1 <= at { + return Some(y1); + } + let (x0, y0) = (*xs.get(k - 1)?, *ys.get(k - 1)?); + Some(y0 + (y1 - y0) * (at - x0) / (x1 - x0).max(1e-12)) +} + +/// A line chart of `ys` over study steps `xs` (0…`total`), with the step shown as a cursor and +/// `bands` (step ranges where parts collide) shaded. Returns the step clicked or dragged to. +fn chart(ui: &mut egui::Ui, xs: &[f64], ys: &[f64], unit: &str, total: u32, now: f64, bands: &[[f64; 2]], t: &Tokens) -> Option { + let (r, resp) = ui.allocate_exact_size(vec2(300.0, 120.0), Sense::click_and_drag()); + crate::scenario::publish_rect("motion:chart", r); + let p = ui.painter_at(r); + p.rect(r, 3.0, t.field, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + let plot = Rect::from_min_max(r.min + vec2(6.0, 16.0), r.max - vec2(6.0, 16.0)); + let total = f64::from(total.max(1)); + let x = |s: f64| plot.left() + plot.width() * (s / total).clamp(0.0, 1.0) as f32; + for [a, b] in bands { + let (a, b) = (x(*a), x(*b)); + p.rect_filled(Rect::from_x_y_ranges((a - 1.0)..=(b + 1.0), r.y_range()), 0.0, t.interference.gamma_multiply(0.35)); + } + let (lo, hi) = ys.iter().filter(|v| v.is_finite()).fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), v| (a.min(*v), b.max(*v))); + if lo.is_finite() { + // A flat line sits in the middle. + let pad = if hi - lo < 1e-9 * hi.abs().max(1.0) { 1.0f64.max(hi.abs() * 0.1) } else { 0.0 }; + let (lo, hi) = (lo - pad, hi + pad); + let y = |v: f64| plot.bottom() - plot.height() * ((v - lo) / (hi - lo)) as f32; + if lo < 0.0 && hi > 0.0 { + p.line_segment([pos2(plot.left(), y(0.0)), pos2(plot.right(), y(0.0))], Stroke::new(1.0, t.border)); + } + let pts: Vec = xs.iter().zip(ys).filter(|(_, v)| v.is_finite()).map(|(s, v)| pos2(x(*s), y(*v))).collect(); + p.add(egui::Shape::line(pts, Stroke::new(1.5, t.accent))); + let font = FontId::proportional(10.0); + p.text(r.left_top() + vec2(4.0, 2.0), Align2::LEFT_TOP, format!("{} {unit}", short(hi)), font.clone(), t.text_dim); + p.text(r.left_bottom() + vec2(4.0, -2.0), Align2::LEFT_BOTTOM, format!("{} {unit}", short(lo)), font.clone(), t.text_dim); + if let Some(v) = value_at(xs, ys, now) { + p.text(r.right_top() + vec2(-4.0, 2.0), Align2::RIGHT_TOP, format!("{} {unit}", short(v)), font, t.text); + } + } + let cx = x(now); + p.line_segment([pos2(cx, r.top()), pos2(cx, r.bottom())], Stroke::new(1.5, t.warning)); + let pos = resp.interact_pointer_pos().filter(|_| resp.clicked() || resp.dragged())?; + Some((f64::from((pos.x - plot.left()) / plot.width().max(1.0)).clamp(0.0, 1.0) * total).round()) +} + +/// What the motion stopping short ends the chart with (`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"]))])) +} + +fn key_of(x: impl std::fmt::Debug) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + format!("{x:?}").hash(&mut h); + h.finish() +} + +/// The occurrences of the bodies picked in input `i` (the instance each pick is on). +fn picked_occurrences(s: &Session, inputs: &[SelInput], i: usize) -> Vec { + let names: Vec<&str> = inputs.get(i).map(|inp| inp.items.iter().filter_map(crate::dialogs_assembly::body_of).collect()).unwrap_or_default(); + if names.is_empty() { + return Vec::new(); + } + let mut out = Vec::new(); + for (name, _, path, _) in s.doc.body_frames(&s.model.state()) { + if let Some(o) = path.last().filter(|_| names.contains(&name.as_str())) + && !out.contains(o) + { + out.push(*o); + } + } + out +} + +/// The step ranges where any pair collides, from `motion.interference`'s answer. +pub fn collision_bands(v: &Value) -> Vec<[f64; 2]> { + let ranges = v["collisions"].as_array().into_iter().flatten().flat_map(|c| c["steps"].as_array().cloned().unwrap_or_default()); + ranges.filter_map(|r| Some([r.get(0)?.as_f64()?, r.get(1)?.as_f64()?])).collect() +} + +/// The analysis rows under a study's tracks: `study` is the study command's parameters (or why +/// there are none yet), `step` the step shown (a click on the chart or a collision moves it). +#[allow(clippy::too_many_arguments)] +pub fn rows( + app: &SolveApp, + ui: &mut egui::Ui, + a: &mut Analysis, + study: &Result, + step: &mut f64, + playing: &mut bool, + total: u32, + inputs: &[SelInput], +) { + let t = Tokens::get(); + let s = &app.session; + let picked = inputs.first().is_some_and(|i| !i.items.is_empty()); + let list = choices(s, picked); + a.what = a.what.min(list.len().saturating_sub(1)); + row_label(ui, "Plot"); + let labels: Vec<&str> = list.iter().map(|(l, _)| l.as_str()).collect(); + combo(ui, "ms_plot", &labels, &mut a.what); + ui.end_row(); + let what = list.get(a.what).map_or(Plotted::Nothing, |(_, w)| *w); + let bands = a.collisions.as_ref().and_then(|(_, r)| r.as_ref().ok()).map(collision_bands).unwrap_or_default(); + if what != Plotted::Nothing { + row_label(ui, "Quantity"); + combo(ui, "ms_quantity", &QUANTITIES, &mut a.quantity); + ui.end_row(); + row_label(ui, "Duration"); + ui.add(egui::TextEdit::singleline(&mut a.duration).hint_text(crate::i18n::tr("motion.duration_hint"))); + ui.end_row(); + let params = study.clone().and_then(|st| plot_params(s, what, &st, &a.duration, inputs).map(|p| (st, p))); + let key = key_of(¶ms); + if a.plot.as_ref().is_none_or(|(k, _)| *k != key) { + let r = params.and_then(|(st, p)| { + let mut sc = s.scratch(); + sc.execute("motion.study", &st).map_err(|e| e.to_string())?; + sc.execute("motion.plot", &p).map_err(|e| e.to_string()) + }); + a.plot = Some((key, r)); + } + row_label(ui, ""); + match a.plot.as_ref().map(|(_, r)| r) { + Some(Ok(v)) => { + ui.vertical(|ui| { + if let Some((xs, ys, unit)) = line(v, what, a.quantity) { + if let Some(to) = chart(ui, &xs, &ys, &unit, total, *step, &bands, &t) { + (*step, *playing) = (to, false); + } + } + if let Some(why) = stop_text(v) { + ui.add(egui::Label::new(RichText::new(why).color(t.warning)).wrap()); + } + }); + } + Some(Err(e)) => { + ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.text_dim)).wrap()); + } + None => {} + } + ui.end_row(); + } + // Collisions over the whole study: of the picked components (against the other picks, if + // any), else among all components or in the contact sets. + let sets = !app.session.doc.assembly.contact_sets.is_empty() || app.session.doc.assembly.contact_all; + a.among = if sets { a.among.min(1) } else { 0 }; + let (parts, against) = (picked_occurrences(&app.session, inputs, 1), picked_occurrences(&app.session, inputs, 2)); + let among = if !parts.is_empty() { + let mut p = json!({"occurrences": parts}); + if !against.is_empty() { + p["against"] = json!(against); + } + p + } else { + json!({"contact_sets": a.among == 1}) + }; + let check = study.as_ref().ok().map(|st| (st.clone(), among)); + let key = key_of(&check); + if a.collisions.as_ref().is_some_and(|(k, _)| *k != key) { + a.collisions = None; + } + row_label(ui, "Collisions"); + ui.horizontal(|ui| { + if sets && parts.is_empty() { + combo(ui, "ms_among", &["All components", "Contact sets"], &mut a.among); + } + if ui.add_enabled(check.is_some(), egui::Button::new(crate::i18n::tr_label("Check collisions"))).clicked() + && let Some((st, mut p)) = check + { + let mut sc = app.session.scratch(); + p["study"] = st.get("study").or_else(|| st.get("name")).cloned().unwrap_or(Value::Null); + let r = sc.execute("motion.study", &st).and_then(|_| sc.execute("motion.interference", &p)).map_err(|e| e.to_string()); + a.collisions = Some((key, r)); + } + }); + ui.end_row(); + match a.collisions.as_ref().map(|(_, r)| r) { + Some(Ok(v)) => { + let hits = v["collisions"].as_array().cloned().unwrap_or_default(); + row_label(ui, ""); + ui.vertical(|ui| { + if hits.is_empty() { + ui.label(RichText::new(crate::i18n::tr("motion.no_collisions")).color(t.text_dim)); + } + for c in &hits { + let steps: Vec = c["steps"] + .as_array() + .into_iter() + .flatten() + .map(|r| { + let (from, to) = (r[0].as_f64().unwrap_or(0.0).round(), r[1].as_f64().unwrap_or(0.0).round()); + if from == to { format!("{from}") } else { format!("{from}-{to}") } + }) + .collect(); + let name = |k: &str| c[k].as_str().unwrap_or_default().to_string(); + let text = crate::i18n::format("motion.collision", &[("a", &name("a")), ("b", &name("b")), ("steps", &steps.join(", "))]); + // A click shows where they first collide. + if ui.link(RichText::new(text).color(t.error)).clicked() + && let Some(first) = c["first_step"].as_f64() + { + (*step, *playing) = (first, false); + } + } + if v["truncated"] == true { + let to = v["checked_to"].as_f64().map(|x| x.round().to_string()).unwrap_or_default(); + ui.label(RichText::new(crate::i18n::format("motion.checked_to", &[("step", &to)])).color(t.warning)); + } + }); + ui.end_row(); + } + Some(Err(e)) => { + row_label(ui, ""); + ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap()); + ui.end_row(); + } + None => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The chart's line: a joint's quantity with its unit, or a point's distance from its start. + #[test] + fn lines_come_from_the_plot_answer() { + let v = json!({"step": [0.0, 1.0, 2.0], + "joints": [{"index": 0, "unit": "deg", "velocity_unit": "deg/s", "acceleration_unit": "deg/s²", + "value": [0, 10, 30], "velocity": [5, 15, 25], "acceleration": [10, 10, 10]}], + "point": {"unit": "mm", "velocity_unit": "mm/step", "acceleration_unit": "mm/step²", + "position": [[0, 0, 0], [3, 4, 0], [6, 8, 0]], "speed": [5, 5, 5], "acceleration_norm": [0, 0, 0]}}); + assert_eq!(line(&v, Plotted::Joint(7, 0), 1), Some((vec![0.0, 1.0, 2.0], vec![5.0, 15.0, 25.0], "deg/s".into()))); + assert_eq!(line(&v, Plotted::Point, 0).map(|l| l.1), Some(vec![0.0, 5.0, 10.0])); + assert_eq!(line(&v, Plotted::Point, 2).map(|l| l.2), Some("mm/step²".into())); + assert_eq!(line(&v, Plotted::Joint(7, 1), 0), None); + assert_eq!(line(&v, Plotted::Nothing, 0), None); + assert_eq!(value_at(&[0.0, 2.0, 4.0], &[0.0, 10.0, 30.0], 3.0), Some(20.0)); + assert_eq!(value_at(&[0.0, 2.0], &[1.0, 3.0], 9.0), None); + let hits = json!({"collisions": [{"steps": [[11.0, 15.0], [20.0, 20.0]]}]}); + assert_eq!(collision_bands(&hits), vec![[11.0, 15.0], [20.0, 20.0]]); + } + + /// The analysis of the open Motion Study dialog, and its step. + fn shown(h: &crate::scenario::Harness) -> (Analysis, f64) { + match h.app.dialog.as_ref().map(|d| &d.kind) { + Some(crate::dialogs::Kind::Motion(crate::dialogs_motion::Mo::MotionStudy { analysis, step, .. })) => ((**analysis).clone(), *step), + _ => panic!("no Motion Study dialog"), + } + } + + /// In the dialog: a slider pushing B 1 mm a step from 10 mm off A to 5 mm into it. Plotting + /// the slider's velocity draws a chart of -1 mm/step; a click on the chart moves the step + /// there, and Check collisions names A and B from step 11. + #[test] + fn the_study_dialog_charts_a_joint_and_finds_collisions() { + let mut h = crate::scenario::Harness::new(); + for (name, x) in [("A", 0), ("B", 20)] { + h.app.run("component.activate", json!({"component": "root"})).unwrap(); + h.app.run("component.create", json!({ "name": name })).unwrap(); + h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]})).unwrap(); + } + h.app.run("component.activate", json!({"component": "root"})).unwrap(); + h.app.run("occurrence.ground", json!({"occurrence": "A:1", "grounded": true})).unwrap(); + h.app.run("joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"})).unwrap(); + h.app.run("joint.drive", json!({"joint": "Rail", "value": 10})).unwrap(); + h.app.run("motion.study", json!({"name": "Push", "steps": 15, "keys": [{"joint": "Rail", "points": [[0, 10], [15, -5]]}]})).unwrap(); + for bad in [json!({"study": "Nope"}), json!({"study": "Push", "step": 3}), json!({"study": [1]})] { + let r = h.call("ui.start", json!({"command": "motion.study", "params": bad})); + assert!(r["ok"] == false && h.app.dialog.is_none(), "{bad}: {r}"); + } + // The control channel opens the saved study to edit (and chart). + let r = h.call("ui.start", json!({"command": "motion.study", "params": {"study": "Push"}})); + assert_eq!(r["ok"], true, "{r}"); + if let Some(crate::dialogs::Kind::Motion(crate::dialogs_motion::Mo::MotionStudy { study, steps, tracks, analysis, .. })) = + h.app.dialog.as_mut().map(|d| &mut d.kind) + { + assert_eq!((study.as_deref(), steps.as_str()), (Some("Push"), "15")); + assert_eq!(*tracks, vec![crate::dialogs_motion::Track { joint: 0, index: 0, points: vec![(0, "10 mm".into()), (15, "-5 mm".into())] }]); + (analysis.what, analysis.quantity) = (1, 1); + } + h.frames(4); + let (a, _) = shown(&h); + let plot = a.plot.as_ref().and_then(|(_, r)| r.as_ref().ok()).cloned().unwrap_or_default(); + let (_, ys, unit) = line(&plot, Plotted::Joint(0, 0), 1).unwrap(); + assert!(unit == "mm/step" && ys.len() == 16 && ys.iter().all(|v| (v + 1.0).abs() < 1e-9), "{plot}"); + let chart = crate::scenario::rect_of("motion:chart").unwrap(); + // The plot area starts 6 points in: two thirds of the way along is step 10. + let x = chart.left() + 6.0 + (chart.width() - 12.0) * 2.0 / 3.0; + h.step(&json!({"click": [x, chart.center().y]})).unwrap(); + h.frames(2); + assert_eq!(shown(&h).1, 10.0); + let [bx, by] = h.widget("Check collisions").unwrap(); + h.step(&json!({"click": [bx, by]})).unwrap(); + h.frames(2); + let (a, _) = shown(&h); + let found = a.collisions.as_ref().and_then(|(_, r)| r.as_ref().ok()).cloned().unwrap_or_default(); + assert_eq!((&found["collisions"][0]["a"], &found["collisions"][0]["first_step"]), (&json!("A:1"), &json!(11.0)), "{:?}", a.collisions); + assert_eq!(collision_bands(&found), vec![[11.0, 15.0]]); + } +} diff --git a/crates/ui-egui/src/dialogs_trace.rs b/crates/ui-egui/src/dialogs_trace.rs index 32c88ceb..0433434a 100644 --- a/crates/ui-egui/src/dialogs_trace.rs +++ b/crates/ui-egui/src/dialogs_trace.rs @@ -71,11 +71,7 @@ pub(super) fn placement(s: &Session, inputs: &[SelInput]) -> Option<(Vec, V /// 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)}); + let mut p = picked_point(s, inputs)?; if f.by_study { p["study"] = json!(s.doc.assembly.studies.get(f.study).ok_or("no motion study yet")?.id); } else { @@ -91,6 +87,15 @@ pub fn params(s: &Session, f: &TraceForm, inputs: &[SelInput]) -> Result Result { + let (path, at, shown, placed) = placement(s, inputs).ok_or("select a point on a component")?; + // The point 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) }; + Ok(json!({"occurrence": occurrence, "point": pt(at)})) +} + /// 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(); diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index 975528b4..2b928534 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -166,6 +166,36 @@ changed. A GIF keeps one palette for all its frames, so colours don't flicker, a what changed from one frame to the next. Frames are capped: at most 1000, and a GIF holds at most 2^26 pixels of frames (about 160 frames of 800 × 500). +**Motion analysis.** The **Motion Study** dialog's **Study** box opens a saved study to edit, and +by its timeline the dialog charts what the study does. +**Plot** picks a moving joint value (or **Picked point**, once a point is picked in the dialog's +**Point** box) and whether to show its value, velocity or acceleration; the chart runs over the +study's steps with a cursor at the step shown, and a click or a drag on it moves to that step. +Values are in degrees and millimetres; rates are per step, or per second once **Duration** says +how many seconds the whole study takes. A picked point charts its distance from where it +starts, its speed and the size of its acceleration. **Check collisions** plays the study and +lists the pairs of parts that pass through each other, with the steps where they do (a click on +one shows the first of them); those steps are shaded on the chart. It checks the parts picked in +**Components** (against the ones picked in **Against**, when there are any), else every +component, or the pairs of the design's contact sets (a choice beside the button once it has any). + +`motion.plot {study, joints?, occurrence?, point?, samples?, duration?}` answers the same +numbers: for each joint value its `value`, `velocity` and `acceleration` per sample (with their +units), and for a point its `position`, `velocity` and `acceleration` vectors, `speed` and +`acceleration_norm`. The study plays as `motion.play` places it (closed loops follow, contact sets +stop it), each sample from the one before, on a copy: the design is not changed. Velocities and +accelerations are finite differences between samples, central inside and one-sided at the ends, +so they are as smooth as the samples are dense (up to 1000, one per step by default). Angles are +unwrapped, so a joint that turns on past half a turn keeps counting. +`motion.interference {study, occurrences?, against?, contact_sets?, samples?}` answers each +colliding pair with its `first_step`, `last_step`, the `steps` ranges where they overlap, how +deep (`depth_mm`, the deepest at `deepest_step`) and their shared `volume_mm3` there. It plays +the study as `motion.play` does, but contact sets don't stop it, and parts are tested at the +samples only: a part that passes right through another between two samples is missed. A check +takes at most 256 bodies; on a large design, list the `occurrences` to check (an arm, with +`against` the parts it must miss). When the work runs out it says `truncated` and the step it +`checked_to`. + **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/docs/control-protocol.md b/docs/control-protocol.md index 019701a4..cc85bf88 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -125,7 +125,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox. | `ui.set` | any `UiState` field (`tab`, `perspective`, `showGrid`, `dark`, …), and `hiddenBodies`, `hiddenOrigin`, `hiddenSketches`, `shownSketches`, `hiddenJoints` (whole lists; `browser.visibility` changes single items) | `dark: false` switches to the light theme | | `ui.view` | `view`: `front`, `back`, `top`, `bottom`, `left`, `right`, `iso`, `home`, `fit`; `animate` (bool, default false: snap) | | | `ui.camera` | `target?`: `[x, y, z]` (mm), `yaw?`, `pitch?`, `roll?` (radians), `distance?` (mm), `fov?`, `up?`; `fit?`: `true`, `false` or `[occurrence id or name…]` (`root`: the bodies outside every component) | the viewport's orbit camera: it looks at `target` from `distance` away; `yaw` 0 looks along +Y (from the front), `pitch` is the elevation (±π/2: straight down or up), `roll` turns the image. Fields given are set first (the others keep their values), then `fit` frames the listed occurrences (or everything), keeping the direction. The reply, passed back, sets the same view again, so a framing can be stored and replayed. `fov` and `up` follow the perspective setting (`ui.set`) and the up axis preference (`ui.prefs`) and can't change here; a refused field changes nothing | -| `ui.start` | `command`, `params?` | like a toolbar click: starts the sketch tool or dialog for the command. `params` sets what the dialog opens on, and nothing runs until OK: `joint.drive` takes `joint` (id or name; without it, the joint picked in the browser, else the first joint that moves) and `values` (numbers in degrees or millimetres, or expressions). A command that takes no params is refused before it starts; a param the dialog doesn't take is refused and the dialog closes | +| `ui.start` | `command`, `params?` | like a toolbar click: starts the sketch tool or dialog for the command. `params` sets what the dialog opens on, and nothing runs until OK: `joint.drive` takes `joint` (id or name; without it, the joint picked in the browser, else the first joint that moves) and `values` (numbers in degrees or millimetres, or expressions); `motion.study` takes `study` (id or name), which opens that study to edit and chart. A command that takes no params is refused before it starts; a param the dialog doesn't take is refused and the dialog closes | | `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?`, `ctrl?`, `double?` | real pointer input | | `ui.move` | `x`, `y`, `shift?`, `alt?`, `ctrl?`, `cmd?` | pointer movement without a button; modifiers apply until the next synthetic move/click; a move with omitted modifiers releases them. With `nav: "touchpad"`, Shift pans and Alt/Option orbits. | | `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?`, `hold?`, `hold_end?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing); with OpenSCAD navigation, plain left-drag on empty space orbits, Shift+left-drag box-selects, and drags starting on sketch geometry edit it; `hold` keeps the button down that many frames first (a right press-and-hold opens the marking menu, releasing over a direction picks it), `hold_end` that many frames at the end (a sketch drag shows its snap) | diff --git a/skills/solidcraft-mechanisms/SKILL.md b/skills/solidcraft-mechanisms/SKILL.md index a9c2fcff..a2e3616b 100644 --- a/skills/solidcraft-mechanisms/SKILL.md +++ b/skills/solidcraft-mechanisms/SKILL.md @@ -57,6 +57,8 @@ Written against SolidCraft `main` after 0.11.1, which counts the degrees of free - **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. +- **Plot:** `motion.plot {"study":..., "joints":[...], "duration":seconds}` answers each joint value's `value`, `velocity` and `acceleration` per sample (finite differences; per step, or per second with a duration), and with `"occurrence"` and `"point"` a point's `position`, `velocity`, `acceleration`, `speed`. Check a follower's rate against the analytic one this way, without changing the design. +- **Collisions:** `motion.interference {"study":..., "occurrences":[...], "against":[...]}` plays the study (contact sets don't stop it) and lists each colliding pair with `first_step` and `steps` ranges; `clear: true` when none. It takes at most 256 bodies: on a big design list the moving part as `occurrences` and what it must miss as `against`. Parts joined at a knuckle often overlap by design and show up at every step. ## 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.