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.
This commit is contained in:
MateuszSychXpl committed 2026-10-16 17:52:38 +02:00
1 parent 8a055afdd6
commit 8fdf6a61a2
23 files changed
+1398 -40

No files matched your search

+1 -1
View File
@@ -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 |
+17
View File
@@ -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)",
+17
View File
@@ -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)",
+17
View File
@@ -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)",
+17
View File
@@ -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 を押しながら移動、マウスボタン不要)",
+17
View File
@@ -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을 누르고 이동, 마우스 버튼 불필요)",
+17
View File
@@ -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": "новое имя",
+17
View File
@@ -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)",
+17
View File
@@ -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 і рухайте; без кнопки миші)",
+17
View File
@@ -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 并移动,无需鼠标按钮)",
+37 -7
View File
@@ -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<CachedBody>,
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<u64>]) -> Result<Self> {
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::<BTreeSet<_>>().len()
}
fn frame(body: &CachedBody, s: &Session) -> Result<Mat> {
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<Option<(String, String, f64)>> {
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<Vec<(Member, Member, f64)>> {
let mut out = Vec::new();
let meshes: Vec<Mesh> = self
.bodies
.iter()
@@ -214,7 +241,7 @@ impl ContactGeometry {
Ok(Mesh { positions, triangles: body.mesh.triangles.clone(), ..Mesh::default() })
})
.collect::<Result<_>>()?;
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)
}
}
+4
View File
@@ -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;
+29 -17
View File
@@ -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<dyn Fn(&Session, usize, usize) -> Vec<(u64, Vec<f64>)>>;
/// Where a point of an occurrence goes as a study plays or a joint is driven: its world
/// positions, sampled on a copy (the design is not changed). Each sample is driven from the one
/// before, so closed loops follow on their branch and contact sets stop them; the trace ends at
/// the sample where the motion stops short. Returns the points and the answer without them.
fn sample_trace(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<Vec3>, Value)> {
// The placement traced: an occurrence path through nested sub-assemblies (one instance of
// each), or an occurrence at its first placement (none: the root).
/// 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<u64>, Vec3)> {
let path: Vec<u64> = match p.get("occurrence") {
Some(Value::Array(ids)) if ids.len() <= 64 => {
ids.iter().map(|v| v.as_u64().ok_or_else(|| bad(cmd, "an occurrence path lists occurrence ids"))).collect::<Result<_>>()?
@@ -710,19 +712,29 @@ fn sample_trace(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<Vec3>, 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<Mat> {
if path.is_empty() { Ok(solidcraft_doc::IDENTITY) } else { t.occurrence_placement(path).map(|o| o.world).map_err(|e| bad(cmd, e.to_string())) }
}
/// Where a point of an occurrence goes as a study plays or a joint is driven: its world
/// positions, sampled on a copy (the design is not changed). Each sample is driven from the one
/// before, so closed loops follow on their branch and contact sets stop them; the trace ends at
/// the sample where the motion stops short. Returns the points and the answer without them.
fn sample_trace(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<Vec3>, Value)> {
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<Mat> {
if path.is_empty() {
Ok(solidcraft_doc::IDENTITY)
} else {
t.occurrence_placement(&path).map(|o| o.world).map_err(|e| bad(cmd, e.to_string()))
}
};
let local = solidcraft_doc::mat_inverse(&world(&t)?)
.map(|m| solidcraft_doc::apply_point(&m, at))
.ok_or_else(|| bad(cmd, "the occurrence's placement is not invertible"))?;
let 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();
+431
View File
@@ -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<usize> {
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<bool>) -> Result<Value> {
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<f64> {
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<f64> {
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::<f64>);
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<Vec<(u64, usize)>> {
let joints = &s.doc.assembly.joints;
let ids: Vec<u64> = 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::<Result<_>>()?,
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<Value> {
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<f64>> = vec![Vec::new(); series.len()];
let mut path: Vec<Vec3> = 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<f64> = (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<Value> = 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<f64> { path.iter().map(|q| [q.x, q.y, q.z].get(c).copied().unwrap_or(0.0)).collect() };
let d: Vec<(Vec<f64>, Vec<f64>)> = (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<Vec3>, Vec<Vec3>) = (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::<Vec<_>>(), "acceleration_norm": acc.iter().map(|v| v.len()).collect::<Vec<_>>()});
}
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<usize>,
depth: f64,
deepest: usize,
/// The design at the deepest sample (its placements).
at_deepest: Arc<Document>,
}
/// 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<f64> {
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<Value> {
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::<Vec<u64>>()];
// 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<Vec<u64>> = 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<Vec<u64>> =
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<Value> = 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<f64> = (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]);
}
}
@@ -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<f64> {
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}");
}
+11 -3
View File
@@ -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)");
}
+4 -4
View File
@@ -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<String> {
}
/// 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),
+70 -2
View File
@@ -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<BTreeMap<u64, Mat>>,
error: Option<String>,
/// The chart and the collision check by the timeline.
analysis: Box<plot::Analysis>,
},
Explode {
name: String,
@@ -114,8 +118,14 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
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<Track>), 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<Value, String> {
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<String> = 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<usize> = 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 }
+431
View File
@@ -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<Value, String>)>,
/// The last collision check's answer and what it was for.
collisions: Option<(u64, Result<Value, String>)>,
}
/// 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<Value, String> {
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::<f64>().map_err(|_| "the duration must be a number of seconds".to_string())?);
}
Ok(p)
}
fn nums(v: &Value) -> Vec<f64> {
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<f64>, Vec<f64>, 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::<f64>().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<f64> {
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<f64> {
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<egui::Pos2> = 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<String> {
let at = v["stopped_at"].as_u64()?.to_string();
let name = |x: &Value| x.as_str().unwrap_or_default().to_string();
if let Some(l) = v["stopped_by_loop"].as_object() {
return Some(crate::i18n::format("trace.stopped_by_loop", &[("sample", &at), ("joint", &name(&l["joint"]))]));
}
let c = v["stopped_by_contact"].as_object()?;
Some(crate::i18n::format("trace.stopped_by_contact", &[("sample", &at), ("a", &name(&c["a"])), ("b", &name(&c["b"]))]))
}
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<u64> {
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<Value, String>,
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(&params);
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<String> = 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]]);
}
}
+10 -5
View File
@@ -71,11 +71,7 @@ pub(super) fn placement(s: &Session, inputs: &[SelInput]) -> Option<(Vec<u64>, 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<Value, String> {
let (path, at, shown, placed) = placement(s, inputs).ok_or("select a point on a component")?;
// The trace starts where the part is placed, not where it may be shown.
let at = mat_inverse(&shown).map(|m| apply_point(&placed, apply_point(&m, at))).ok_or("select a point on a component")?;
let occurrence = if path.is_empty() { json!(0) } else { json!(path) };
let mut p = json!({"occurrence": occurrence, "point": pt(at)});
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<Value,
Ok(p)
}
/// The picked point as `motion.trace` and `motion.plot` take it: `{occurrence, point}`.
pub fn picked_point(s: &Session, inputs: &[SelInput]) -> Result<Value, String> {
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<String> {
let at = v["stopped_at"].as_u64()?.to_string();
+30
View File
@@ -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
+1 -1
View File
@@ -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) |
+2
View File
@@ -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.