mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-07 20:41:50 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0160e67ea1 | |||
| c29f396f72 | |||
| 46edecca67 | |||
| a36c1743dc | |||
| 7b3ca00054 | |||
| 91a06a546e | |||
| fd02123b3e | |||
| 13ab728d07 | |||
| 4a07865288 | |||
| b73d6e6e6f | |||
| 3b2ca62c86 | |||
| 24c7bb7d70 |
Generated
+22
-22
@@ -4069,7 +4069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4107,11 +4107,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4125,7 +4125,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4134,7 +4134,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4153,7 +4153,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-dynamics"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4164,7 +4164,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4187,7 +4187,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4197,7 +4197,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4218,7 +4218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4245,7 +4245,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4264,7 +4264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde_json",
|
||||
@@ -4274,7 +4274,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-profile"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"web-time",
|
||||
@@ -4282,7 +4282,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4300,7 +4300,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"serde",
|
||||
@@ -4314,7 +4314,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-smlib"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"log",
|
||||
@@ -4327,14 +4327,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-step"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"solidcore-record",
|
||||
"solidcraft-geom",
|
||||
@@ -4343,11 +4343,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-testkit"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4379,7 +4379,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5794,7 +5794,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proc-macro2",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.40.0-rc.3"
|
||||
version = "0.40.0-rc.2"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -265,6 +265,7 @@
|
||||
"Edge": "حافة",
|
||||
"Edges": "الحواف",
|
||||
"Edit": "تعديل",
|
||||
"Edit Holes": "تحرير الثقوب",
|
||||
"Edit Profile Sketch": "تعديل رسم المقطع",
|
||||
"Edit shortcuts": "تعديل الاختصارات",
|
||||
"Enable rule": "تفعيل القاعدة",
|
||||
@@ -1366,6 +1367,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "مماس يمر بنقطة خارجية",
|
||||
"cmd.sketch.construction_line.tangent_two": "مماس مشترك",
|
||||
"cmd.sketch.construction_line.vertical": "خط إنشائي رأسي",
|
||||
"cmd.sketch.replace": "استبدال الرسم",
|
||||
"cmd.sketch_arc3d": "قوس ثلاثي الأبعاد",
|
||||
"cmd.sketch_arc_center_point": "قوس بنقطة مركز",
|
||||
"cmd.sketch_arc_tangent": "قوس مماسي",
|
||||
@@ -2016,6 +2018,7 @@
|
||||
"help.view.visual_style": "يبدّل طريقة رسم النموذج: مظلَّل مع الحواف أو بدونها، أو إطار سلكي، أو مع الحواف المخفية. استخدم الإطار السلكي لرؤية الداخل، والمظلَّل لمظهر نظيف.",
|
||||
"history.sketch_under_constrained": "قيود ناقصة ({count} درجات حرية)",
|
||||
"history.sketch_under_constrained_one": "قيود ناقصة ({count} درجة حرية)",
|
||||
"hole_folder.applies_to": "تنطبق التغييرات على الثقوب الـ {count} في {folder}؛ ويحتفظ كل ثقب بموضعه.",
|
||||
"hole_presets.confirm_delete": "تأكيد الحذف",
|
||||
"hole_presets.delete": "حذف",
|
||||
"hole_presets.delete_prompt": "هل تحذف هذا الإعداد المسبق الشخصي؟ الثقوب الموجودة لا تتغير.",
|
||||
@@ -5908,6 +5911,8 @@
|
||||
"ui.renderer": "محرك التصيير",
|
||||
"ui.repeat": "تكرار",
|
||||
"ui.repeat_nothing": "لا يوجد ما يُكرَّر بعد: ابدأ أداة أو مربع حوار أولًا.",
|
||||
"ui.replace_sketch.from_file": "من ملف تصميم…",
|
||||
"ui.replace_sketch.no_sketches": "لا يحتوي هذا التصميم على رسم يمكن أخذه",
|
||||
"ui.replace_with_link_done": "أصبحت النسخ الآن مواضع للتصميم المرتبط.",
|
||||
"ui.report_a_bug": "الإبلاغ عن خطأ",
|
||||
"ui.report_characters_left_one": "الأحرف المتبقية: {count}",
|
||||
|
||||
@@ -206,6 +206,7 @@
|
||||
"Edge": "Kante",
|
||||
"Edges": "Kanten",
|
||||
"Edit": "Bearbeiten",
|
||||
"Edit Holes": "Bohrungen bearbeiten",
|
||||
"Edit Profile Sketch": "Profile-Skizze bearbeiten",
|
||||
"Edit shortcuts": "Tastenkürzel bearbeiten",
|
||||
"Enable rule": "Regel aktivieren",
|
||||
@@ -1177,6 +1178,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "Tangente durch äußeren Punkt",
|
||||
"cmd.sketch.construction_line.tangent_two": "Gemeinsame Tangente",
|
||||
"cmd.sketch.construction_line.vertical": "Vertikale Konstruktionslinie",
|
||||
"cmd.sketch.replace": "Skizze ersetzen",
|
||||
"cmd.sketch_arc3d": "3D-Bogen",
|
||||
"cmd.sketch_arc_center_point": "Mittelpunkt-Kreisbogen",
|
||||
"cmd.sketch_arc_tangent": "Tangentialbogen",
|
||||
@@ -1819,6 +1821,7 @@
|
||||
"help.view.visual_style": "Schaltet um, wie das Modell gezeichnet wird: schattiert mit oder ohne Kanten, Drahtmodell oder mit verdeckten Kanten. Drahtmodell zum Hineinschauen, schattiert für ein sauberes Bild.",
|
||||
"history.sketch_under_constrained": "Unterbestimmt ({count} Freiheitsgrade)",
|
||||
"history.sketch_under_constrained_one": "Unterbestimmt ({count} Freiheitsgrad)",
|
||||
"hole_folder.applies_to": "Änderungen gelten für die {count} Bohrungen in {folder}; jede behält ihre Position.",
|
||||
"hole_presets.confirm_delete": "Löschen bestätigen",
|
||||
"hole_presets.delete": "Löschen",
|
||||
"hole_presets.delete_prompt": "Möchten Sie diese persönliche Vorlage wirklich löschen? Bereits vorhandene Bohrungen bleiben unverändert.",
|
||||
@@ -5622,6 +5625,8 @@
|
||||
"ui.renderer": "Renderer",
|
||||
"ui.repeat": "Wiederholen",
|
||||
"ui.repeat_nothing": "Noch nichts zu wiederholen: Starten Sie zuerst ein Werkzeug oder einen Dialog.",
|
||||
"ui.replace_sketch.from_file": "Aus einer Konstruktionsdatei…",
|
||||
"ui.replace_sketch.no_sketches": "Diese Konstruktion enthält keine Skizze",
|
||||
"ui.replace_with_link_done": "Die Kopien sind jetzt Platzierungen des verknüpften Designs.",
|
||||
"ui.report_a_bug": "Fehler melden",
|
||||
"ui.report_characters_left_one": "{count} Zeichen übrig",
|
||||
|
||||
@@ -265,6 +265,7 @@
|
||||
"Edge": "Edge",
|
||||
"Edges": "Edges",
|
||||
"Edit": "Edit",
|
||||
"Edit Holes": "Edit Holes",
|
||||
"Edit Profile Sketch": "Edit Profile Sketch",
|
||||
"Edit shortcuts": "Edit shortcuts",
|
||||
"Enable rule": "Enable rule",
|
||||
@@ -1366,6 +1367,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "Tangent through External Point",
|
||||
"cmd.sketch.construction_line.tangent_two": "Common Tangent",
|
||||
"cmd.sketch.construction_line.vertical": "Vertical Construction Line",
|
||||
"cmd.sketch.replace": "Replace Sketch",
|
||||
"cmd.sketch_arc3d": "3D Arc",
|
||||
"cmd.sketch_arc_center_point": "Center Point Arc",
|
||||
"cmd.sketch_arc_tangent": "Tangent Arc",
|
||||
@@ -2016,6 +2018,7 @@
|
||||
"help.view.visual_style": "Switches how the model is drawn: shaded with or without edges, wireframe, or with hidden edges. Use wireframe to see inside, shaded for a clean look.",
|
||||
"history.sketch_under_constrained": "Under constrained ({count} degrees of freedom)",
|
||||
"history.sketch_under_constrained_one": "Under constrained ({count} degree of freedom)",
|
||||
"hole_folder.applies_to": "Changes apply to the {count} holes in {folder}; each keeps its place.",
|
||||
"hole_presets.confirm_delete": "Confirm delete",
|
||||
"hole_presets.delete": "Delete",
|
||||
"hole_presets.delete_prompt": "Delete this personal preset? Existing holes are unchanged.",
|
||||
@@ -5908,6 +5911,8 @@
|
||||
"ui.renderer": "Renderer",
|
||||
"ui.repeat": "Repeat",
|
||||
"ui.repeat_nothing": "Nothing to repeat yet: start a tool or dialog first.",
|
||||
"ui.replace_sketch.from_file": "From a Design File…",
|
||||
"ui.replace_sketch.no_sketches": "That design has no sketch to take",
|
||||
"ui.replace_with_link_done": "The copies are now placements of the linked design.",
|
||||
"ui.report_a_bug": "Report a bug",
|
||||
"ui.report_characters_left_one": "{count} character left",
|
||||
|
||||
@@ -1139,6 +1139,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "Tangente passant par un point extérieur",
|
||||
"cmd.sketch.construction_line.tangent_two": "Tangente commune",
|
||||
"cmd.sketch.construction_line.vertical": "Droite de construction verticale",
|
||||
"cmd.sketch.replace": "Remplacer l’esquisse",
|
||||
"cmd.sketch_arc3d": "Arc 3D",
|
||||
"cmd.sketch_arc_center_point": "Arc par centre",
|
||||
"cmd.sketch_arc_tangent": "Arc tangent",
|
||||
@@ -5015,6 +5016,8 @@
|
||||
"ui.renderer": "Moteur de rendu",
|
||||
"ui.repeat": "Répéter",
|
||||
"ui.repeat_nothing": "Rien à répéter pour l'instant : lancer d'abord un outil ou une boîte de dialogue.",
|
||||
"ui.replace_sketch.from_file": "Depuis un fichier de conception…",
|
||||
"ui.replace_sketch.no_sketches": "Cette conception ne contient aucune esquisse",
|
||||
"ui.replace_with_link_done": "Les copies sont maintenant des placements de la conception liée.",
|
||||
"ui.report_a_bug": "Signaler un bug",
|
||||
"ui.reset": "Réinitialiser",
|
||||
|
||||
@@ -265,6 +265,7 @@
|
||||
"Edge": "מקצוע",
|
||||
"Edges": "מקצועות",
|
||||
"Edit": "עריכה",
|
||||
"Edit Holes": "עריכת חורים",
|
||||
"Edit Profile Sketch": "עריכת סקיצת הפרופיל",
|
||||
"Edit shortcuts": "עריכת קיצורי דרך",
|
||||
"Enable rule": "הפעלת הכלל",
|
||||
@@ -1366,6 +1367,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "משיק דרך נקודה חיצונית",
|
||||
"cmd.sketch.construction_line.tangent_two": "משיק משותף",
|
||||
"cmd.sketch.construction_line.vertical": "ישר עזר אנכי",
|
||||
"cmd.sketch.replace": "החלפת סקיצה",
|
||||
"cmd.sketch_arc3d": "קשת תלת־ממדית",
|
||||
"cmd.sketch_arc_center_point": "קשת מרכז ונקודה",
|
||||
"cmd.sketch_arc_tangent": "קשת משיקה",
|
||||
@@ -2016,6 +2018,7 @@
|
||||
"help.view.visual_style": "החלפת אופן הציור של המודל: מוצלל עם מקצועות או בלעדיהם, מסגרת תיל, או עם מקצועות מוסתרים. מסגרת תיל מאפשרת לראות פנימה, ומוצלל נותן מראה נקי.",
|
||||
"history.sketch_under_constrained": "אילוצים חסרים ({count} דרגות חופש)",
|
||||
"history.sketch_under_constrained_one": "אילוצים חסרים (דרגת חופש {count})",
|
||||
"hole_folder.applies_to": "השינויים חלים על {count} החורים ב-{folder}; כל חור שומר על מיקומו.",
|
||||
"hole_presets.confirm_delete": "אישור מחיקה",
|
||||
"hole_presets.delete": "מחיקה",
|
||||
"hole_presets.delete_prompt": "למחוק את ההגדרה המוכנה האישית הזו? חורים קיימים לא ישתנו.",
|
||||
@@ -5908,6 +5911,8 @@
|
||||
"ui.renderer": "מנוע גרפי",
|
||||
"ui.repeat": "חזרה",
|
||||
"ui.repeat_nothing": "אין עדיין מה לחזור עליו: התחל קודם כלי או תיבת דו־שיח.",
|
||||
"ui.replace_sketch.from_file": "מקובץ תכנון…",
|
||||
"ui.replace_sketch.no_sketches": "בתכנון הזה אין סקיצה לקחת",
|
||||
"ui.replace_with_link_done": "העותקים הם עכשיו מיקומים של התכן המקושר.",
|
||||
"ui.report_a_bug": "דיווח על באג",
|
||||
"ui.report_characters_left_one": "תווים שנותרו: {count}",
|
||||
|
||||
@@ -265,6 +265,7 @@
|
||||
"Edge": "エッジ",
|
||||
"Edges": "エッジ",
|
||||
"Edit": "編集",
|
||||
"Edit Holes": "穴を編集",
|
||||
"Edit Profile Sketch": "プロファイルスケッチを編集",
|
||||
"Edit shortcuts": "ショートカットを編集",
|
||||
"Enable rule": "ルールを有効化",
|
||||
@@ -1366,6 +1367,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "外部点を通る接線",
|
||||
"cmd.sketch.construction_line.tangent_two": "共通接線",
|
||||
"cmd.sketch.construction_line.vertical": "垂直補助直線",
|
||||
"cmd.sketch.replace": "スケッチを置き換え",
|
||||
"cmd.sketch_arc3d": "3D 円弧",
|
||||
"cmd.sketch_arc_center_point": "中心点円弧",
|
||||
"cmd.sketch_arc_tangent": "正接円弧",
|
||||
@@ -2016,6 +2018,7 @@
|
||||
"help.view.visual_style": "モデルの描画方法(エッジ付きまたはなしのシェード、ワイヤーフレーム、隠線付き)を切り替えます。内部を見るにはワイヤーフレーム、すっきり見せるにはシェードを使います。",
|
||||
"history.sketch_under_constrained": "拘束不足(自由度 {count})",
|
||||
"history.sketch_under_constrained_one": "拘束不足(自由度 {count})",
|
||||
"hole_folder.applies_to": "変更は {folder} の {count} 個の穴に適用されます。各穴の位置はそのままです。",
|
||||
"hole_presets.confirm_delete": "削除を確認",
|
||||
"hole_presets.delete": "削除",
|
||||
"hole_presets.delete_prompt": "この個人用プリセットを削除しますか?既存の穴は変更されません。",
|
||||
@@ -5908,6 +5911,8 @@
|
||||
"ui.renderer": "レンダラー",
|
||||
"ui.repeat": "繰り返し",
|
||||
"ui.repeat_nothing": "まだ繰り返すものがありません。先にツールかダイアログを開始してください。",
|
||||
"ui.replace_sketch.from_file": "デザインファイルから…",
|
||||
"ui.replace_sketch.no_sketches": "そのデザインには取り込めるスケッチがありません",
|
||||
"ui.replace_with_link_done": "コピーはリンクされたデザインの配置になりました。",
|
||||
"ui.report_a_bug": "不具合を報告",
|
||||
"ui.report_characters_left_one": "残り {count} 文字",
|
||||
|
||||
@@ -1117,6 +1117,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "외부 점을 지나는 접선",
|
||||
"cmd.sketch.construction_line.tangent_two": "공통 접선",
|
||||
"cmd.sketch.construction_line.vertical": "수직 보조 직선",
|
||||
"cmd.sketch.replace": "스케치 바꾸기",
|
||||
"cmd.sketch_arc_center_point": "중심점 호",
|
||||
"cmd.sketch_arc_tangent": "접선 호",
|
||||
"cmd.sketch_arc_three_point": "3점 호",
|
||||
@@ -5078,6 +5079,8 @@
|
||||
"ui.renderer": "렌더러",
|
||||
"ui.repeat": "반복",
|
||||
"ui.repeat_nothing": "아직 반복할 것이 없습니다. 먼저 도구나 대화 상자를 시작하세요.",
|
||||
"ui.replace_sketch.from_file": "디자인 파일에서…",
|
||||
"ui.replace_sketch.no_sketches": "그 디자인에는 가져올 스케치가 없습니다",
|
||||
"ui.replace_with_link_done": "복사본이 이제 링크된 디자인의 배치가 되었습니다.",
|
||||
"ui.report_a_bug": "버그 신고",
|
||||
"ui.reset": "재설정",
|
||||
|
||||
+2703
-1
File diff suppressed because it is too large
Load Diff
+3721
-1
File diff suppressed because it is too large
Load Diff
+527
-4
File diff suppressed because it is too large
Load Diff
@@ -265,6 +265,7 @@
|
||||
"Edge": "Cạnh",
|
||||
"Edges": "Các cạnh",
|
||||
"Edit": "Sửa",
|
||||
"Edit Holes": "Sửa các lỗ",
|
||||
"Edit Profile Sketch": "Chỉnh sửa phác thảo biên dạng",
|
||||
"Edit shortcuts": "Sửa phím tắt",
|
||||
"Enable rule": "Bật quy tắc",
|
||||
@@ -1366,6 +1367,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "Tiếp tuyến qua điểm ngoài",
|
||||
"cmd.sketch.construction_line.tangent_two": "Tiếp tuyến chung",
|
||||
"cmd.sketch.construction_line.vertical": "Đường dựng hình thẳng đứng",
|
||||
"cmd.sketch.replace": "Thay thế phác thảo",
|
||||
"cmd.sketch_arc3d": "Cung tròn 3D",
|
||||
"cmd.sketch_arc_center_point": "Cung tròn xác định bởi tâm và điểm",
|
||||
"cmd.sketch_arc_tangent": "Cung tròn tiếp tuyến",
|
||||
@@ -2016,6 +2018,7 @@
|
||||
"help.view.visual_style": "Đổi cách vẽ mô hình: tô bóng có hoặc không có cạnh, khung dây, hoặc hiện cạnh khuất. Dùng khung dây để nhìn vào bên trong, tô bóng để có hình ảnh gọn gàng.",
|
||||
"history.sketch_under_constrained": "Thiếu ràng buộc ({count} bậc tự do)",
|
||||
"history.sketch_under_constrained_one": "Thiếu ràng buộc ({count} bậc tự do)",
|
||||
"hole_folder.applies_to": "Thay đổi áp dụng cho {count} lỗ trong {folder}; mỗi lỗ giữ nguyên vị trí.",
|
||||
"hole_presets.confirm_delete": "Xác nhận xóa",
|
||||
"hole_presets.delete": "Xóa",
|
||||
"hole_presets.delete_prompt": "Xóa mẫu thiết lập sẵn cá nhân này? Các lỗ ren/khoan hiện có sẽ không thay đổi.",
|
||||
@@ -5908,6 +5911,8 @@
|
||||
"ui.renderer": "Trình kết xuất hình ảnh (Renderer)",
|
||||
"ui.repeat": "Lặp lại",
|
||||
"ui.repeat_nothing": "Chưa có gì để lặp lại: hãy bắt đầu một công cụ hoặc hộp thoại trước.",
|
||||
"ui.replace_sketch.from_file": "Từ một tệp thiết kế…",
|
||||
"ui.replace_sketch.no_sketches": "Thiết kế đó không có phác thảo nào để lấy",
|
||||
"ui.replace_with_link_done": "Các bản sao hiện đã trở thành các vị trí đặt của thiết kế liên kết.",
|
||||
"ui.report_a_bug": "Báo cáo lỗi",
|
||||
"ui.report_characters_left_one": "Còn {count} ký tự",
|
||||
|
||||
@@ -265,6 +265,7 @@
|
||||
"Edge": "边",
|
||||
"Edges": "边",
|
||||
"Edit": "编辑",
|
||||
"Edit Holes": "编辑孔",
|
||||
"Edit Profile Sketch": "编辑轮廓草图",
|
||||
"Edit shortcuts": "编辑快捷键",
|
||||
"Enable rule": "启用规则",
|
||||
@@ -1366,6 +1367,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "过外部点的切线",
|
||||
"cmd.sketch.construction_line.tangent_two": "公切线",
|
||||
"cmd.sketch.construction_line.vertical": "竖直构造直线",
|
||||
"cmd.sketch.replace": "替换草图",
|
||||
"cmd.sketch_arc3d": "三维圆弧",
|
||||
"cmd.sketch_arc_center_point": "中心点圆弧",
|
||||
"cmd.sketch_arc_tangent": "相切圆弧",
|
||||
@@ -2016,6 +2018,7 @@
|
||||
"help.view.visual_style": "切换模型的绘制方式:带边或不带边的着色、线框,或带隐藏边。用线框看内部,用着色得到干净的外观。",
|
||||
"history.sketch_under_constrained": "欠约束({count} 个自由度)",
|
||||
"history.sketch_under_constrained_one": "欠约束({count} 个自由度)",
|
||||
"hole_folder.applies_to": "更改将应用于 {folder} 中的 {count} 个孔;每个孔保持原位置。",
|
||||
"hole_presets.confirm_delete": "确认删除",
|
||||
"hole_presets.delete": "删除",
|
||||
"hole_presets.delete_prompt": "删除此个人预设?现有孔不会改变。",
|
||||
@@ -5908,6 +5911,8 @@
|
||||
"ui.renderer": "渲染器",
|
||||
"ui.repeat": "重复",
|
||||
"ui.repeat_nothing": "还没有可重复的操作:请先启动一个工具或对话框。",
|
||||
"ui.replace_sketch.from_file": "从设计文件…",
|
||||
"ui.replace_sketch.no_sketches": "该设计中没有可用的草图",
|
||||
"ui.replace_with_link_done": "这些副本现在是链接设计的放置。",
|
||||
"ui.report_a_bug": "报告缺陷",
|
||||
"ui.report_characters_left_one": "剩余 {count} 个字符",
|
||||
|
||||
@@ -196,6 +196,45 @@ fn edge_pick<'a>(st: &'a ModelState, at: Vec3, name: &Option<String>) -> Result<
|
||||
}
|
||||
}
|
||||
|
||||
/// The closest point on a polyline to `target`, projecting onto each segment rather than
|
||||
/// choosing only its vertices. This keeps an edge pick on the edge between tessellation points.
|
||||
fn closest_point_on_polyline(pts: &[Vec3], target: Vec3) -> Option<Vec3> {
|
||||
let mut best: Option<(f64, Vec3)> = None;
|
||||
for w in pts.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let ab = b - a;
|
||||
let len_sq = ab.dot(ab);
|
||||
let q = if len_sq < 1e-18 {
|
||||
a
|
||||
} else {
|
||||
let t = ((target - a).dot(ab) / len_sq).clamp(0.0, 1.0);
|
||||
a + ab * t
|
||||
};
|
||||
let d = target.dist(q);
|
||||
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
|
||||
best = Some((d, q));
|
||||
}
|
||||
}
|
||||
best.map(|(_, q)| q)
|
||||
}
|
||||
|
||||
/// The point on an edge nearest a pick location, projecting onto exact edge geometry
|
||||
/// (such as `kernel::edge_circle`) when available, else onto its drawn polyline segments.
|
||||
fn edge_point_at_pick(st: &ModelState, pick: Vec3, name: &Option<String>) -> Option<Vec3> {
|
||||
let (b, i, pts) = edge_pick(st, pick, name).ok()?;
|
||||
if let Some(c) = kernel::edge_circle(&b.body, i) {
|
||||
let v = pick - c.center;
|
||||
let v_perp = v - *c.axis * v.dot(*c.axis);
|
||||
if let Some(dir) = v_perp.normalized() {
|
||||
return Some(c.center + dir * c.radius);
|
||||
}
|
||||
}
|
||||
// Until the kernel can project onto any edge geometry (circles are exact above via
|
||||
// kernel::edge_circle), the fallback projects onto the drawn polyline. This is exact for
|
||||
// straight edges and approximates splines within mesh display tolerance.
|
||||
closest_point_on_polyline(&pts, pick)
|
||||
}
|
||||
|
||||
/// The circle a picked edge lies on, from its exact curve (`kernel::edge_circle`) when the drawn
|
||||
/// edge lies on it, else fitted to the drawn points: (centre, unit normal).
|
||||
fn edge_circle(b: &ModelBody, index: usize, pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
|
||||
@@ -743,7 +782,29 @@ pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
|
||||
PlaneRef::AxisPoint { axis, point: at } => {
|
||||
let (origin, dir) = line(ctx, axis)?;
|
||||
let dir = dir.normalized().ok_or_else(|| bad("the axis has no direction"))?;
|
||||
let delta = point(ctx, at)? - origin;
|
||||
let p = match point(ctx, at) {
|
||||
Ok(p) => {
|
||||
let delta = p - origin;
|
||||
let across = delta - dir * delta.dot(dir);
|
||||
if across.len().is_finite() && across.len() >= 1e-9 {
|
||||
p
|
||||
} else if let GeoRef::Edge { at: pick, name } = at.as_ref() {
|
||||
// When the centre of a circular edge lies on the axis, resolve to the picked point on the edge.
|
||||
edge_point_at_pick(ctx.st, *pick, name).unwrap_or(p)
|
||||
} else {
|
||||
p
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Non-circular edges are not converted to centres by `point`; resolve to the picked edge point.
|
||||
if let GeoRef::Edge { at: pick, name } = at.as_ref() {
|
||||
edge_point_at_pick(ctx.st, *pick, name).ok_or(e)?
|
||||
} else {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
let delta = p - origin;
|
||||
let across = delta - dir * delta.dot(dir);
|
||||
if !across.len().is_finite() || across.len() < 1e-9 {
|
||||
return Err(bad("the point is on the axis: pick a point off the axis to define a unique plane"));
|
||||
|
||||
+146
-66
@@ -1161,6 +1161,18 @@ pub struct Model {
|
||||
placement_inputs: Option<placement_reuse::Inputs>,
|
||||
/// All evaluated features use supported local inputs; computed with the existing owner index.
|
||||
placement_local: bool,
|
||||
/// A failed sketch shown while it is edited ([`Model::hold_failed_sketch`]).
|
||||
held: Option<HeldSketch>,
|
||||
}
|
||||
|
||||
/// A failed sketch built for its editor (#12085): `shown` is the final state `base` with that
|
||||
/// sketch in it. [`Model::state`] gives it only while the results still end in `base`, so the
|
||||
/// next evaluation, or anything else that changes the results, lets it go.
|
||||
#[derive(Clone, Debug)]
|
||||
struct HeldSketch {
|
||||
feature: u64,
|
||||
base: Arc<ModelState>,
|
||||
shown: Arc<ModelState>,
|
||||
}
|
||||
|
||||
/// Parameters a feature uses directly (inputs, sketch dimensions and the expressions of a
|
||||
@@ -2004,9 +2016,56 @@ impl Model {
|
||||
|
||||
/// The final model state.
|
||||
pub fn state(&self) -> Arc<ModelState> {
|
||||
let last = self.evaluated_state();
|
||||
match &self.held {
|
||||
Some(held) if Arc::ptr_eq(&held.base, &last) => held.shown.clone(),
|
||||
_ => last,
|
||||
}
|
||||
}
|
||||
|
||||
/// The state after the last feature evaluated, without a failed sketch held for its editor.
|
||||
fn evaluated_state(&self) -> Arc<ModelState> {
|
||||
self.results.iter().rev().find(|r| !r.skipped).map(|r| r.state.clone()).unwrap_or_else(|| self.empty.clone())
|
||||
}
|
||||
|
||||
/// Show the sketch `feature` while it is edited although it failed (#12085): a link whose
|
||||
/// named face or edge is gone fails the sketch, so the evaluation keeps no sketch for it, and
|
||||
/// its editor had nothing to show or repair. Here the sketch is built as it is evaluated, but
|
||||
/// with such a link marked lost and kept where it was (no other face or edge is taken for
|
||||
/// it), and added to the final state only: the sketch's result keeps its error and the
|
||||
/// features after it still fail. `None`, a sketch that did not fail, or one that fails for
|
||||
/// another reason too, shows nothing more. Returns whether a held sketch is shown.
|
||||
pub fn hold_failed_sketch(&mut self, doc: &Document, feature: Option<u64>) -> bool {
|
||||
let Some(id) = feature else {
|
||||
self.held = None;
|
||||
return false;
|
||||
};
|
||||
let base = self.evaluated_state();
|
||||
if self.held.as_ref().is_some_and(|held| held.feature == id && Arc::ptr_eq(&held.base, &base)) {
|
||||
return true;
|
||||
}
|
||||
self.held = None;
|
||||
if base.sketch(id).is_some() || !self.result(id).is_some_and(|r| r.error.is_some() && !r.skipped && !r.stale) {
|
||||
return false;
|
||||
}
|
||||
let Some(f) = doc.feature(id) else { return false };
|
||||
let FeatureKind::Sketch { plane, sketch } = &f.kind else { return false };
|
||||
let mut next = (*self.state_before(id)).clone();
|
||||
let vals = evaluation_values(doc).0;
|
||||
let mut warning = None;
|
||||
let built = kernel::guard(&f.name, || {
|
||||
let _frames = ToolFrames::install(doc, f, &next);
|
||||
eval_sketch(doc, &vals, f, plane, sketch, &mut next, &mut warning, crate::project::LostNames::Mark)
|
||||
.map_err(|e| kernel::KernelError::Failed(e.to_string()))
|
||||
});
|
||||
let Some(solved) = built.ok().and_then(|()| next.sketches.iter().find(|s| s.feature == id).cloned()) else { return false };
|
||||
let mut shown = (*base).clone();
|
||||
shown.sketches.retain(|s| s.feature != id);
|
||||
shown.sketches.push(solved);
|
||||
self.held = Some(HeldSketch { feature: id, base, shown: Arc::new(shown) });
|
||||
true
|
||||
}
|
||||
|
||||
/// State just before the feature with this id (for editing a sketch in context).
|
||||
pub fn state_before(&self, id: u64) -> Arc<ModelState> {
|
||||
let Some(i) = self.results.iter().position(|r| r.id == id) else { return self.state() };
|
||||
@@ -2170,6 +2229,10 @@ impl Model {
|
||||
|
||||
/// Re-evaluate the document, reusing results up to the first changed feature.
|
||||
pub fn evaluate(&mut self, doc: &Document) {
|
||||
// A failed sketch held for its editor is built again from the evaluated design: when the
|
||||
// sketch and every feature after it fail, the results end in the state they ended in
|
||||
// before, which would keep showing the sketch as it was before the edit.
|
||||
self.held = None;
|
||||
let _span = solidcraft_profile::span("doc", "evaluate");
|
||||
self.placement_inputs = None;
|
||||
self.fingerprinted = None;
|
||||
@@ -7406,72 +7469,7 @@ fn eval_feature(
|
||||
| FeatureKind::ReplaceFace { .. }
|
||||
| FeatureKind::Align { .. }
|
||||
| FeatureKind::Remove { .. } => more::eval(doc, vals, f, st, warning),
|
||||
FeatureKind::Sketch { plane, sketch } => {
|
||||
let anchor = crate::face_anchor::plane_face_anchor(plane);
|
||||
let fst = match anchor {
|
||||
Some(anchor) => crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?,
|
||||
// A sketch that reads no geometry (a fixed plane, no projections) needs no other
|
||||
// component's bodies moved into its frame: placing every body of a large
|
||||
// assembly again is what made each sketch edit as slow as the design is big.
|
||||
None if !plane_reads_geometry(plane) && sketch.links.is_empty() => std::borrow::Cow::Borrowed(st),
|
||||
None => state_in_frame(doc, st, f.component)?,
|
||||
};
|
||||
// A further placement a link reads is placed in its frame too (#9957).
|
||||
let named: Vec<&str> = sketch.links.iter().flat_map(|l| l.source.bodies()).collect();
|
||||
let fst = match anchor.map(|anchor| anchor.author_frame(doc, f.component)) {
|
||||
_ if named.is_empty() => fst,
|
||||
Some(Ok(frame)) => with_placed_copies(doc, st, fst, &frame, &named),
|
||||
Some(Err(_)) => fst,
|
||||
None => with_placed_copies(doc, st, fst, &doc.component_transform(f.component), &named),
|
||||
};
|
||||
let (plane, face_warning) = match (anchor, plane) {
|
||||
(Some(_), plane) => doc.resolve_attached_plane_noted(vals, plane, f.component, st, &fst)?,
|
||||
// A flat face of a sheet: the panel's exact plane, from its flat region and fold.
|
||||
(None, plane) if crate::sheet::plane_sheet_face(plane).is_some() => {
|
||||
(doc.resolve_plane_in(vals, &sheet_face_plane_in_frame(doc, st, f.component, plane)?, 0, Some(&fst))?, None)
|
||||
}
|
||||
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref())?,
|
||||
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
|
||||
};
|
||||
let mut sk = sketch.clone();
|
||||
// A text's outlines are rebuilt from its numbers when the links are refreshed: the
|
||||
// ones its expressions give come first.
|
||||
doc.apply_text_values(vals, &mut sk)?;
|
||||
let mut warns = match anchor {
|
||||
Some(anchor) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor)?,
|
||||
None => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk)?,
|
||||
};
|
||||
drop(fst);
|
||||
sk.refresh_offsets()?;
|
||||
if let Some(w) = face_warning {
|
||||
warns.insert(0, w);
|
||||
}
|
||||
// The stored sketch is solved for its stored values, with its links where they
|
||||
// were stored: a link that moved must not change the side a dimension from it is on.
|
||||
let stored = sk.driving_values();
|
||||
let report = doc.solve_sketch(vals, &mut sk, &stored, Some(sketch))?;
|
||||
if !report.keeps_orientation() {
|
||||
warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})")));
|
||||
} else {
|
||||
// The solve may have moved the curve a text runs along.
|
||||
warns.extend(crate::project::relay_path_texts(&mut sk));
|
||||
}
|
||||
if !warns.is_empty() {
|
||||
*warning = Some(warns.join("; "));
|
||||
}
|
||||
sk.validate_periodic_splines()?;
|
||||
// A group of touching curves too large to search whole says so, rather than
|
||||
// leaving profiles out silently (#6896).
|
||||
let search = solidcraft_sketch::find_profiles_checked(&sk);
|
||||
if search.incomplete {
|
||||
append_warning(warning, profiles_cut_short_note());
|
||||
}
|
||||
let profiles = search.profiles;
|
||||
let ends = solidcraft_sketch::loose_ends(&sk);
|
||||
st.sketches.retain(|s| s.feature != f.id);
|
||||
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles, ends }));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Sketch { plane, sketch } => eval_sketch(doc, vals, f, plane, sketch, st, warning, crate::project::LostNames::Fail),
|
||||
FeatureKind::Extrude { operation, targets, .. }
|
||||
| FeatureKind::Pipe { operation, targets, .. }
|
||||
| FeatureKind::Revolve { operation, targets, .. }
|
||||
@@ -8290,6 +8288,88 @@ fn eval_feature(
|
||||
}
|
||||
}
|
||||
|
||||
/// A sketch feature: its links refreshed against the model before it, solved, its profiles
|
||||
/// found. `lost_names` says what a link whose named face or edge is gone does: the evaluation
|
||||
/// fails the sketch ([`crate::project::LostNames::Fail`]); a failed sketch shown for editing
|
||||
/// marks the link lost instead ([`Model::hold_failed_sketch`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn eval_sketch(
|
||||
doc: &Document,
|
||||
vals: &BTreeMap<String, Value>,
|
||||
f: &Feature,
|
||||
plane: &PlaneRef,
|
||||
sketch: &solidcraft_sketch::Sketch,
|
||||
st: &mut ModelState,
|
||||
warning: &mut Option<String>,
|
||||
lost_names: crate::project::LostNames,
|
||||
) -> Result<()> {
|
||||
let anchor = crate::face_anchor::plane_face_anchor(plane);
|
||||
let fst = match anchor {
|
||||
Some(anchor) => crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?,
|
||||
// A sketch that reads no geometry (a fixed plane, no projections) needs no other
|
||||
// component's bodies moved into its frame: placing every body of a large
|
||||
// assembly again is what made each sketch edit as slow as the design is big.
|
||||
None if !plane_reads_geometry(plane) && sketch.links.is_empty() => std::borrow::Cow::Borrowed(st),
|
||||
None => state_in_frame(doc, st, f.component)?,
|
||||
};
|
||||
// A further placement a link reads is placed in its frame too (#9957).
|
||||
let named: Vec<&str> = sketch.links.iter().flat_map(|l| l.source.bodies()).collect();
|
||||
let fst = match anchor.map(|anchor| anchor.author_frame(doc, f.component)) {
|
||||
_ if named.is_empty() => fst,
|
||||
Some(Ok(frame)) => with_placed_copies(doc, st, fst, &frame, &named),
|
||||
Some(Err(_)) => fst,
|
||||
None => with_placed_copies(doc, st, fst, &doc.component_transform(f.component), &named),
|
||||
};
|
||||
let (plane, face_warning) = match (anchor, plane) {
|
||||
(Some(_), plane) => doc.resolve_attached_plane_noted(vals, plane, f.component, st, &fst)?,
|
||||
// A flat face of a sheet: the panel's exact plane, from its flat region and fold.
|
||||
(None, plane) if crate::sheet::plane_sheet_face(plane).is_some() => {
|
||||
(doc.resolve_plane_in(vals, &sheet_face_plane_in_frame(doc, st, f.component, plane)?, 0, Some(&fst))?, None)
|
||||
}
|
||||
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref())?,
|
||||
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
|
||||
};
|
||||
let mut sk = sketch.clone();
|
||||
// A text's outlines are rebuilt from its numbers when the links are refreshed: the
|
||||
// ones its expressions give come first.
|
||||
doc.apply_text_values(vals, &mut sk)?;
|
||||
let mut warns = match (anchor, lost_names) {
|
||||
(Some(anchor), crate::project::LostNames::Fail) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor)?,
|
||||
(None, crate::project::LostNames::Fail) => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk)?,
|
||||
(anchor, crate::project::LostNames::Mark) => crate::project::refresh_links_marking_lost(doc, vals, &fst, &plane, &mut sk, anchor)?,
|
||||
};
|
||||
drop(fst);
|
||||
sk.refresh_offsets()?;
|
||||
if let Some(w) = face_warning {
|
||||
warns.insert(0, w);
|
||||
}
|
||||
// The stored sketch is solved for its stored values, with its links where they
|
||||
// were stored: a link that moved must not change the side a dimension from it is on.
|
||||
let stored = sk.driving_values();
|
||||
let report = doc.solve_sketch(vals, &mut sk, &stored, Some(sketch))?;
|
||||
if !report.keeps_orientation() {
|
||||
warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})")));
|
||||
} else {
|
||||
// The solve may have moved the curve a text runs along.
|
||||
warns.extend(crate::project::relay_path_texts(&mut sk));
|
||||
}
|
||||
if !warns.is_empty() {
|
||||
*warning = Some(warns.join("; "));
|
||||
}
|
||||
sk.validate_periodic_splines()?;
|
||||
// A group of touching curves too large to search whole says so, rather than
|
||||
// leaving profiles out silently (#6896).
|
||||
let search = solidcraft_sketch::find_profiles_checked(&sk);
|
||||
if search.incomplete {
|
||||
append_warning(warning, profiles_cut_short_note());
|
||||
}
|
||||
let profiles = search.profiles;
|
||||
let ends = solidcraft_sketch::loose_ends(&sk);
|
||||
st.sketches.retain(|s| s.feature != f.id);
|
||||
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles, ends }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `copy`, a moved copy of `source` (instance `instance` of a pattern or mirror): its faces are
|
||||
/// named after the source's.
|
||||
fn moved_copy(source: &ModelBody, copy: Body, instance: usize) -> Traced {
|
||||
|
||||
@@ -982,6 +982,12 @@ fn call(f: &str, args: &[Value], d: Defaults) -> Result<Value, DocError> {
|
||||
// Round in the value's own default unit (round(2.4 in) = 2 in for an inch parameter).
|
||||
let scale = scale_bare(Value::num(1.0), a.len, a.ang, d);
|
||||
let x = if scale != 0.0 { a.v / scale } else { a.v };
|
||||
// Unit conversions leave a rounding error of an ulp or two (`3 in / 1 in` is
|
||||
// 2.9999999999999996): a value that close to a whole number is that number, so `floor`
|
||||
// does not drop to 2. The tolerance is relative to the whole number, so a tiny value
|
||||
// never snaps to 0, and it stays under 1/8 so no real fraction is absorbed.
|
||||
let nearest = x.round();
|
||||
let x = if (x - nearest).abs() <= (4.0 * f64::EPSILON * nearest.abs()).min(0.125) { nearest } else { x };
|
||||
let r = match f {
|
||||
"floor" => x.floor(),
|
||||
"ceil" => x.ceil(),
|
||||
@@ -1444,6 +1450,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `floor`, `ceil`, `trunc` and `round` work on the number a person typed, not on float noise:
|
||||
/// `3 in / 1 in` is 2.9999999999999996 in floats, and `floor` of it must still be 3.
|
||||
#[test]
|
||||
fn rounding_functions_ignore_float_noise() {
|
||||
for (e, want) in [("floor(3 in / 1 in)", 3.0), ("ceil(3 in / 1 in)", 3.0), ("trunc(3 in / 1 in)", 3.0), ("floor(-3 in / 1 in)", -3.0)] {
|
||||
assert_eq!(ev(e, Kind::Unitless).unwrap(), want, "{e}");
|
||||
}
|
||||
let d = Defaults { len: 25.4, ang: std::f64::consts::PI / 180.0 };
|
||||
let look = |_: &str| Err(DocError::Expr("no parameters".into()));
|
||||
for e in ["floor(3 in)", "ceil(3 in)", "trunc(3 in)", "round(3 in)"] {
|
||||
let got = eval_with_defaults(e, &look, d).unwrap().to_kind_in(Kind::Length, d).unwrap();
|
||||
assert!((got - 76.2).abs() < 1e-9, "{e} gave {got}");
|
||||
}
|
||||
let got = eval_with_defaults("floor(30 deg)", &look, Defaults::default()).unwrap().to_kind(Kind::Angle).unwrap();
|
||||
assert!((got - 30f64.to_radians()).abs() < 1e-9, "floor(30 deg) gave {got}");
|
||||
// A real fraction still rounds the way its name says, however small, and large values keep working.
|
||||
for (e, want) in [
|
||||
("floor(2.9999)", 2.0),
|
||||
("ceil(3.0001)", 4.0),
|
||||
("floor(100.99999995)", 100.0),
|
||||
("ceil(100.00000005)", 101.0),
|
||||
("floor(1000000000.7)", 1_000_000_000.0),
|
||||
("ceil(1000000000.7)", 1_000_000_001.0),
|
||||
("trunc(1000000000.7)", 1_000_000_000.0),
|
||||
("round(1000000000.7)", 1_000_000_001.0),
|
||||
("ceil(1e-16)", 1.0),
|
||||
("floor(-1e-16)", -1.0),
|
||||
("trunc(1e-16)", 0.0),
|
||||
("trunc(-1e-16)", 0.0),
|
||||
("round(1e-16)", 0.0),
|
||||
("round(-1e-16)", 0.0),
|
||||
("floor(1000000000000000.5)", 1_000_000_000_000_000.0),
|
||||
] {
|
||||
assert_eq!(ev(e, Kind::Unitless).unwrap(), want, "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A count from a length (`(L - 20) / 210`, the 210 read as unit-less) names the fix (#5901).
|
||||
#[test]
|
||||
fn a_length_where_a_count_is_expected_says_to_divide_by_a_length() {
|
||||
|
||||
+55
-13
@@ -912,7 +912,31 @@ pub fn refresh_face_anchor_links(
|
||||
sk: &mut Sketch,
|
||||
anchor: &crate::FaceAnchor,
|
||||
) -> Result<Vec<String>> {
|
||||
refresh_links_in(doc, vals, st, plane, sk, Some(anchor))
|
||||
refresh_links_in(doc, vals, st, plane, sk, Some(anchor), LostNames::Fail)
|
||||
}
|
||||
|
||||
/// What a link kept by the name of a face or edge does when the name finds nothing any more.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum LostNames {
|
||||
/// The sketch fails, asking for the reference to be picked again ([`DocError::Reselect`]).
|
||||
Fail,
|
||||
/// The link is marked lost and keeps its last geometry, with a warning naming the reference.
|
||||
/// No other face or edge is taken for it. Only for showing a failed sketch while it is edited
|
||||
/// (#12085), never for the evaluation the features after it build from.
|
||||
Mark,
|
||||
}
|
||||
|
||||
/// [`refresh_links`] (or [`refresh_face_anchor_links`] with `anchor`) that marks a link whose
|
||||
/// named face or edge is gone lost instead of failing ([`LostNames::Mark`]).
|
||||
pub(crate) fn refresh_links_marking_lost(
|
||||
doc: &Document,
|
||||
vals: &BTreeMap<String, Value>,
|
||||
st: &ModelState,
|
||||
plane: &Plane,
|
||||
sk: &mut Sketch,
|
||||
anchor: Option<&crate::FaceAnchor>,
|
||||
) -> Result<Vec<String>> {
|
||||
refresh_links_in(doc, vals, st, plane, sk, anchor, LostNames::Mark)
|
||||
}
|
||||
|
||||
/// Remove degree-two straight stations introduced by coplanar face subdivision.
|
||||
@@ -989,7 +1013,7 @@ fn merge_family_lines(geom: Vec<LinkGeom>) -> Result<Vec<LinkGeom>> {
|
||||
/// longer finds exactly that face or edge is not lost: the sketch fails, asking for the
|
||||
/// reference to be picked again ([`DocError::Reselect`]).
|
||||
pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, plane: &Plane, sk: &mut Sketch) -> Result<Vec<String>> {
|
||||
refresh_links_in(doc, vals, st, plane, sk, None)
|
||||
refresh_links_in(doc, vals, st, plane, sk, None, LostNames::Fail)
|
||||
}
|
||||
|
||||
/// Lay every text that runs along a curve of its sketch along that curve as the solve left it.
|
||||
@@ -1025,6 +1049,7 @@ fn refresh_links_in(
|
||||
plane: &Plane,
|
||||
sk: &mut Sketch,
|
||||
anchor: Option<&crate::FaceAnchor>,
|
||||
lost_names: LostNames,
|
||||
) -> Result<Vec<String>> {
|
||||
let mut warn = Vec::new();
|
||||
let links = sk.links.clone();
|
||||
@@ -1034,16 +1059,17 @@ fn refresh_links_in(
|
||||
if !l.auto_face
|
||||
&& let (Some(name), LinkSource::Face { body, at }) = (&l.face_name, &mut source)
|
||||
{
|
||||
let (index, p) = st
|
||||
.body(body)
|
||||
.and_then(|target| {
|
||||
if l.kind == LinkKind::Project {
|
||||
crate::name_lookup::locate_projected_face(target, name, *at)
|
||||
} else {
|
||||
crate::naming::face_on_body(target, name, *at)
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| DocError::reselect(name.as_str()))?;
|
||||
let found = st.body(body).and_then(|target| {
|
||||
if l.kind == LinkKind::Project {
|
||||
crate::name_lookup::locate_projected_face(target, name, *at)
|
||||
} else {
|
||||
crate::naming::face_on_body(target, name, *at)
|
||||
}
|
||||
});
|
||||
let Some((index, p)) = found else {
|
||||
lose_named(sk, &l.id, name, lost_names, &mut warn)?;
|
||||
continue;
|
||||
};
|
||||
*at = p;
|
||||
named_face = Some((body.clone(), index, p));
|
||||
}
|
||||
@@ -1051,7 +1077,10 @@ fn refresh_links_in(
|
||||
// edge has the name any more no other edge is taken for it: the sketch fails.
|
||||
let mut named_edge = None;
|
||||
if let (Some(name), LinkSource::Edge { body, at }) = (&l.edge_name, &source) {
|
||||
let (body, hit) = locate_named_edge(st, body, name, *at).ok_or_else(|| DocError::reselect(name.as_str()))?;
|
||||
let Some((body, hit)) = locate_named_edge(st, body, name, *at) else {
|
||||
lose_named(sk, &l.id, name, lost_names, &mut warn)?;
|
||||
continue;
|
||||
};
|
||||
warn.extend(hit.note(name).map(|note| format!("projected {note}")));
|
||||
named_edge = Some((body, hit.edge, *at));
|
||||
}
|
||||
@@ -1119,6 +1148,19 @@ fn refresh_links_in(
|
||||
Ok(warn)
|
||||
}
|
||||
|
||||
/// A link whose face or edge `name` finds nothing: the sketch fails asking for it again, or (only
|
||||
/// while a failed sketch is shown for editing) the link is marked lost, with a warning naming it.
|
||||
fn lose_named(sk: &mut Sketch, id: &str, name: &str, lost_names: LostNames, warn: &mut Vec<String>) -> Result<()> {
|
||||
match lost_names {
|
||||
LostNames::Fail => Err(DocError::reselect(name)),
|
||||
LostNames::Mark => {
|
||||
sk.set_link_lost(id, true);
|
||||
warn.push(format!("projected reference lost: `{name}` no longer exists as it was picked; reselect it"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Model lookups
|
||||
|
||||
|
||||
@@ -465,17 +465,7 @@ mod tests {
|
||||
let doc = crate::Document { render: Arc::new(settings.clone()), ..Default::default() };
|
||||
let wire = doc.to_json();
|
||||
assert_eq!(crate::Document::from_json(&wire).unwrap(), doc);
|
||||
// The saved design holds the texture's bytes under the resource's own key.
|
||||
fn resource_saved(v: &serde_json::Value, key: &str) -> bool {
|
||||
match v {
|
||||
serde_json::Value::Object(map) => {
|
||||
map.get("texture_resources").and_then(|t| t.get(key)).is_some() || map.values().any(|v| resource_saved(v, key))
|
||||
}
|
||||
serde_json::Value::Array(items) => items.iter().any(|v| resource_saved(v, key)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
assert!(resource_saved(&serde_json::from_str(&wire).unwrap(), &id.to_string()));
|
||||
assert!(wire.contains("embedded-surface-textures"));
|
||||
assert!(!wire.contains("builtin://"));
|
||||
assert!(!wire.contains("file://"));
|
||||
settings.texture_resources.remove(&id);
|
||||
|
||||
@@ -457,8 +457,10 @@ fn axis_named(s: &Session, name: &str) -> Option<(Vec3, Vec3)> {
|
||||
}
|
||||
|
||||
/// The closest points of two exact shapes, where the sampled points would be wrong: a plane or
|
||||
/// an axis runs on, so a point set of it is not the object.
|
||||
fn exact_min(a: &Shape, b: &Shape) -> Option<(f64, Vec3, Vec3)> {
|
||||
/// an axis runs on, so a point set of it is not the object. `anchor` is a point of `a` to
|
||||
/// measure from where any point would do (two parallel planes); `unbounded` says whether `a`
|
||||
/// and `b` run on for ever (datum planes and axes) rather than ending (faces and edges).
|
||||
fn exact_min(a: &Shape, b: &Shape, anchor: Option<Vec3>, unbounded: (bool, bool)) -> Option<(f64, Vec3, Vec3)> {
|
||||
use Shape::*;
|
||||
let to_plane = |p: Vec3, point: Vec3, normal: UnitVec3| {
|
||||
let n = normal.vec();
|
||||
@@ -484,17 +486,7 @@ fn exact_min(a: &Shape, b: &Shape) -> Option<(f64, Vec3, Vec3)> {
|
||||
}
|
||||
(Point(p), Point(q)) => Some((p.dist(*q), *p, *q)),
|
||||
(Plane { point: p1, normal: n1 }, Plane { point: p2, normal: n2 }) => {
|
||||
let (v1, v2) = (n1.vec(), n2.vec());
|
||||
if v1.cross(v2).len() < 1e-9 {
|
||||
let h = (*p2 - *p1).dot(v1);
|
||||
Some((h.abs(), *p1, *p2 - v1 * h))
|
||||
} else {
|
||||
// They meet: a point on the line of intersection has distance zero.
|
||||
let dir = v1.cross(v2);
|
||||
let t = -(*p1 - *p2).dot(v2) / dir.dot(v2);
|
||||
let p = *p1 + dir * t;
|
||||
Some((0.0, p, p))
|
||||
}
|
||||
super::measure_shape::plane_plane(*p1, *n1, *p2, *n2, anchor.unwrap_or(*p1), unbounded.0 && unbounded.1)
|
||||
}
|
||||
(Plane { point, normal }, Line { a: la, b: lb }) => {
|
||||
let d = *lb - *la;
|
||||
@@ -712,7 +704,10 @@ pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
|
||||
out["delta"] = json!(q - p);
|
||||
out["min"] = json!(d);
|
||||
}
|
||||
if let Some((d, p, q)) = exact_min(&a.shape, &b.shape) {
|
||||
let unbounded = |i: usize| matches!(sels.get(i), Some(Sel::Plane { .. } | Sel::Axis { .. }));
|
||||
if let Some((d, p, q)) = exact_min(&a.shape, &b.shape, near.map(|(p, _)| p), (unbounded(0), unbounded(1)))
|
||||
.filter(|(d, p, q)| d.is_finite() && p.is_finite() && q.is_finite())
|
||||
{
|
||||
out["distance_mm"] = json!(d);
|
||||
out["from"] = json!(p);
|
||||
out["to"] = json!(q);
|
||||
|
||||
@@ -271,6 +271,32 @@ fn from_round(a: &Shape, b: &Shape) -> Option<Centre> {
|
||||
(c.distance.is_finite() && c.from.is_finite() && c.to.is_finite()).then_some(c)
|
||||
}
|
||||
|
||||
/// The distance between two planes, `from` on the first and `to` on the second.
|
||||
///
|
||||
/// Parallel planes (normals within 1e-6 rad, as [`parallel`] counts axes) are the
|
||||
/// perpendicular gap between them (#11833), measured from `anchor` (a point of the first item,
|
||||
/// so the dimension is drawn on it) moved onto the first plane. Planes at an angle meet: only
|
||||
/// when both run on for ever (datum planes, `meet`) is that a distance of zero on their line of
|
||||
/// intersection; a face ends, so its distance is left to its own points (`None`).
|
||||
pub(crate) fn plane_plane(p1: Vec3, n1: UnitVec3, p2: Vec3, n2: UnitVec3, anchor: Vec3, meet: bool) -> Option<(f64, Vec3, Vec3)> {
|
||||
let (v1, v2) = (n1.vec(), n2.vec());
|
||||
let across = v1.cross(v2);
|
||||
if across.len() < 1e-6 {
|
||||
let from = anchor - v1 * (anchor - p1).dot(v1);
|
||||
let h = (from - p2).dot(v1);
|
||||
return Some((h.abs(), from, from - v1 * h));
|
||||
}
|
||||
if !meet {
|
||||
return None;
|
||||
}
|
||||
// The point of both planes nearest the first plane's point: from `p1` across the first
|
||||
// plane, square to the line `n1 × n2`, until it reaches the second plane. Worked from the
|
||||
// cross product, not `1 - (n1·n2)²`, which cancels when the planes are nearly parallel.
|
||||
let w = across.cross(v1);
|
||||
let p = p1 + w * ((p2 - p1).dot(v2) / across.dot(across));
|
||||
p.is_finite().then_some((0.0, p, p))
|
||||
}
|
||||
|
||||
/// A slot's sizes: width (the ends' diameter), the distance between the ends' centres and the
|
||||
/// overall length, with the two centres.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -504,4 +530,42 @@ mod tests {
|
||||
assert!(slot_outline(&arcs, &skew).is_none());
|
||||
assert!(slot_outline(&[(Vec2::new(0.0, 0.0), 3.0, PI), (Vec2::new(20.0, 0.0), 4.0, PI)], &lines).is_none());
|
||||
}
|
||||
|
||||
/// Two walls of a box whose normals are parallel only to rounding (#11833): the gap between
|
||||
/// the planes, from the picked point on the first to its foot on the second, never NaN.
|
||||
#[test]
|
||||
fn nearly_parallel_planes_measure_the_gap_between_them() {
|
||||
let tilt = UnitVec3::new(Vec3::new(1e-8, 0.0, 1.0)).unwrap();
|
||||
for (n2, gap) in [(UnitVec3::Z, 20.0), (-UnitVec3::Z, 20.0), (tilt, 20.0)] {
|
||||
let anchor = Vec3::new(7.0, 3.0, 0.5);
|
||||
let (d, from, to) = plane_plane(Vec3::new(100.0, -50.0, 0.0), UnitVec3::Z, Vec3::new(-4.0, 9.0, gap), n2, anchor, false).unwrap();
|
||||
assert!((d - gap).abs() < 1e-6, "{n2:?}: {d}");
|
||||
assert!(from.is_finite() && to.is_finite());
|
||||
assert!(from.dist(Vec3::new(7.0, 3.0, 0.0)) < 1e-12, "from lies on the first plane under the pick: {from:?}");
|
||||
assert!((to.z - gap).abs() < 1e-6 && (to - from).cross(Vec3::Z).len() < 1e-6, "to lies on the second plane: {to:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Planes at an angle: datum planes meet (zero, on both planes); faces end, so their
|
||||
/// distance is left to their points.
|
||||
#[test]
|
||||
fn planes_at_an_angle_meet_only_when_they_run_on() {
|
||||
let (p1, p2) = (Vec3::new(0.0, 0.0, 5.0), Vec3::new(3.0, 0.0, 0.0));
|
||||
let n2 = UnitVec3::new(Vec3::new(1.0, 0.0, 1.0)).unwrap();
|
||||
let (d, from, to) = plane_plane(p1, UnitVec3::Z, p2, n2, p1, true).unwrap();
|
||||
assert_eq!((d, from), (0.0, to));
|
||||
assert!(from.is_finite() && (from.z - 5.0).abs() < 1e-12 && (from - p2).dot(n2.vec()).abs() < 1e-12, "{from:?}");
|
||||
assert!(plane_plane(p1, UnitVec3::Z, p2, n2, p1, false).is_none());
|
||||
assert!(plane_plane(p1, UnitVec3::Z, p2, UnitVec3::X, p1, true).is_some_and(|(_, p, _)| p.is_finite()));
|
||||
// Just past parallel and far from the origin: the point still lies on both planes.
|
||||
for (tilt, z1, z2) in [(2e-6, 0.0, 20.0), (2e-6, 100.0, 120.0), (1.01e-6, 1e5, 100_020.0)] {
|
||||
let (p1, p2) = (Vec3::new(3.0, -4.0, z1), Vec3::new(-7.0, 2.0, z2));
|
||||
let n2 = UnitVec3::new(Vec3::new(tilt, 0.0, 1.0)).unwrap();
|
||||
let (d, p, _) = plane_plane(p1, UnitVec3::Z, p2, n2, p1, true).unwrap();
|
||||
let scale = 1.0 + p.len();
|
||||
assert_eq!(d, 0.0);
|
||||
assert!((p.z - z1).abs() < 1e-9 * scale, "tilt {tilt}: {p:?} off the first plane");
|
||||
assert!((p - p2).dot(n2.vec()).abs() < 1e-9 * scale, "tilt {tilt}: {p:?} off the second plane");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_doc::Document;
|
||||
use solidcraft_geom::Aabb2;
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch};
|
||||
|
||||
@@ -16,13 +17,14 @@ use super::sketch::edit;
|
||||
use super::sketch_store;
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, string_list, vec2};
|
||||
use crate::schema::sketch_clipboard::{Copied, CopyParams, CutParams, PasteParams, Pasted};
|
||||
use crate::schema::sketch_clipboard::{Copied, CopyParams, CutParams, PasteParams, Pasted, ReplaceParams, Replaced};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Sel, Session};
|
||||
|
||||
const COPY_CONTRACT: TypedContract<CopyParams> = TypedContract::new();
|
||||
const CUT_CONTRACT: TypedContract<CutParams> = TypedContract::new();
|
||||
const PASTE_CONTRACT: TypedContract<PasteParams> = TypedContract::new();
|
||||
const REPLACE_CONTRACT: TypedContract<ReplaceParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.copy", "Copy Sketch Entities", crate::checked!("sketch.copy", copy)).icon("copy")
|
||||
@@ -44,6 +46,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params(
|
||||
"at?: [x, y] (sketch point the copy's centre goes to) | translate?: [x, y]; without either, offset from the original in its own sketch and at the same coordinates in another; sketch?: target (default: the active sketch)",
|
||||
),
|
||||
CommandSpec::new("sketch.replace", "Replace Sketch", crate::checked!("sketch.replace", replace))
|
||||
.icon("sketch")
|
||||
.contract(&REPLACE_CONTRACT)
|
||||
.output(output_schema_for::<Replaced>)
|
||||
.params(
|
||||
"sketch?: the sketch to replace (default: the active sketch); from: the sketch whose contents take its place (id or name); file?: a design file `from` is in (default: this design). The sketch keeps its plane, place in the timeline and the features built on it; its curves, points, constraints, dimensions and texts become a copy of `from`'s, at the same sketch coordinates (one undo step)",
|
||||
),
|
||||
];
|
||||
|
||||
/// A curve of the clipboard; its points are indices into [`SketchClip::points`].
|
||||
@@ -85,8 +94,14 @@ pub struct SketchClip {
|
||||
pub extent: f64,
|
||||
/// Pastes made without a position since the copy (each lands one offset further on).
|
||||
pub pastes: u32,
|
||||
/// The clipboard point that stands for the sketch origin (in a whole-sketch copy): a paste
|
||||
/// ties it to the origin of the sketch it goes to rather than adding a point.
|
||||
pub origin: Option<usize>,
|
||||
}
|
||||
|
||||
/// The id of a sketch's origin point.
|
||||
const ORIGIN: &str = "origin";
|
||||
|
||||
impl SketchClip {
|
||||
/// This clipboard as another design pastes it (#9258): at the same sketch coordinates (its
|
||||
/// sketch ids mean nothing there), and with nothing that names the copied design's
|
||||
@@ -146,8 +161,17 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "select the sketch curves or points to copy"));
|
||||
}
|
||||
let sk = sketch_store::read(&s.doc, id)?;
|
||||
let (clip, out) = clip_of(&s.doc, sk, id, &ids, false, cmd)?;
|
||||
s.sketch_clipboard = Some(clip);
|
||||
reply(cmd, out)
|
||||
}
|
||||
|
||||
/// The clipboard copy of the entities `ids` of sketch `id` (`sk`, in `doc`), and what it holds.
|
||||
/// `whole` is a copy of the whole sketch for [`replace`]: its fixes come along, its constraints to
|
||||
/// the origin stay tied to the origin of the sketch it goes to, and an empty sketch is a copy too.
|
||||
fn clip_of(doc: &Document, sk: &Sketch, id: u64, ids: &[String], whole: bool, cmd: &str) -> Result<(SketchClip, Copied)> {
|
||||
let (mut cs, mut ps, mut texts): (Vec<usize>, Vec<usize>, Vec<String>) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for e in &ids {
|
||||
for e in ids {
|
||||
if let Some(c) = sk.curve_index(e) {
|
||||
match text_of(sk, c) {
|
||||
Some(t) if !texts.iter().any(|x| x == t) => texts.push(t.to_string()),
|
||||
@@ -169,6 +193,11 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut clip = SketchClip { sketch: id, ..Default::default() };
|
||||
// Points: those of the copied curves, then the lone ones, each once.
|
||||
let mut pmap: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
if whole && let Some(o) = sk.points.iter().position(|q| q.id == ORIGIN) {
|
||||
clip.points.push(Vec2::ZERO);
|
||||
clip.origin = Some(0);
|
||||
pmap.insert(o, 0);
|
||||
}
|
||||
let take = |q: usize, clip: &mut SketchClip, pmap: &mut BTreeMap<usize, usize>| -> usize {
|
||||
*pmap.entry(q).or_insert_with(|| {
|
||||
clip.points.push(sk.point(q).unwrap_or_default());
|
||||
@@ -193,21 +222,17 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
for c in &sk.constraints {
|
||||
// Fixes and pattern or offset relations stay with the original.
|
||||
if matches!(
|
||||
c.kind,
|
||||
ConstraintKind::Fix { .. }
|
||||
| ConstraintKind::PatternPoint { .. }
|
||||
| ConstraintKind::PatternRadius { .. }
|
||||
| ConstraintKind::OffsetDistance { .. }
|
||||
) {
|
||||
// Pattern or offset relations stay with the original, and so do the fixes of a part copy.
|
||||
if matches!(c.kind, ConstraintKind::PatternPoint { .. } | ConstraintKind::PatternRadius { .. } | ConstraintKind::OffsetDistance { .. })
|
||||
|| (!whole && matches!(c.kind, ConstraintKind::Fix { .. }))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut kind = c.kind.clone();
|
||||
if !kind.remap(&|q| pmap.get(&q).copied(), &|k| cmap.get(&k).copied()) {
|
||||
continue;
|
||||
}
|
||||
let param = c.param.as_ref().map(|name| match s.doc.param(name) {
|
||||
let param = c.param.as_ref().map(|name| match doc.param(name) {
|
||||
Some(x) => (x.expr.clone(), x.unit.clone()),
|
||||
None => (format!("{}", kind.value().unwrap_or_default()), if kind.is_angle() { "rad".into() } else { "mm".into() }),
|
||||
});
|
||||
@@ -238,12 +263,13 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add(*q);
|
||||
}
|
||||
}
|
||||
if bounds.is_empty() {
|
||||
if !bounds.is_empty() {
|
||||
clip.center = bounds.center();
|
||||
let size = bounds.size();
|
||||
clip.extent = size.x.max(size.y);
|
||||
} else if !whole {
|
||||
return Err(bad(cmd, "nothing to copy"));
|
||||
}
|
||||
clip.center = bounds.center();
|
||||
let size = bounds.size();
|
||||
clip.extent = size.x.max(size.y);
|
||||
let out = Copied {
|
||||
curves: clip.curves.len() + texts.iter().map(|t| sk.link_curves(t).len()).sum::<usize>(),
|
||||
points: clip.lone.len(),
|
||||
@@ -252,8 +278,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
center: [clip.center.x, clip.center.y],
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
s.sketch_clipboard = Some(clip);
|
||||
reply(cmd, out)
|
||||
Ok((clip, out))
|
||||
}
|
||||
|
||||
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
@@ -315,58 +340,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut p = p.clone();
|
||||
p["sketch"] = json!(target);
|
||||
let ((curves, lone, constraints), info) = edit(s, &p, cmd, false, |sk, doc| {
|
||||
if sk.curves.len().saturating_add(clip.curves.len()) > solidcraft_sketch::MAX_CURVES {
|
||||
return Err(solidcraft_sketch::SketchError::TooLarge.into());
|
||||
}
|
||||
let mut pts = Vec::with_capacity(clip.points.len());
|
||||
for q in &clip.points {
|
||||
pts.push(sk.add_point(*q + t, None)?);
|
||||
}
|
||||
let mut made = Vec::with_capacity(clip.curves.len());
|
||||
for c in &clip.curves {
|
||||
let mut kind = c.kind.clone();
|
||||
kind.map_points(&|q| pts.get(q).copied().unwrap_or(usize::MAX));
|
||||
let i = sk.add_curve(kind, None)?;
|
||||
if let Some(cu) = sk.curves.get_mut(i) {
|
||||
cu.construction = c.construction;
|
||||
cu.centerline = c.centerline;
|
||||
cu.reversed = c.reversed;
|
||||
cu.infinite = c.infinite;
|
||||
}
|
||||
made.push(i);
|
||||
}
|
||||
let mut cons = Vec::new();
|
||||
for c in &clip.constraints {
|
||||
let mut kind = c.kind.clone();
|
||||
if !kind.remap(&|q| pts.get(q).copied(), &|k| made.get(k).copied()) {
|
||||
continue;
|
||||
}
|
||||
// Each pasted dimension gets its own parameter, with the same expression.
|
||||
let param = c.param.as_ref().map(|(e, u)| doc.new_model_param(e, u));
|
||||
let id = sk.add_constraint(kind, param)?;
|
||||
if let Some(nc) = sk.constraints.last_mut() {
|
||||
nc.driven = c.driven;
|
||||
nc.text = c.text;
|
||||
}
|
||||
cons.push(id);
|
||||
}
|
||||
let mut curve_ids: Vec<String> = made.iter().filter_map(|i| sk.curves.get(*i).map(|c| c.id.clone())).collect();
|
||||
for (src, path) in &clip.texts {
|
||||
let mut src = src.clone();
|
||||
let LinkSource::Text { at, style, exprs, .. } = &mut src else { continue };
|
||||
style.path = path.and_then(|k| made.get(k)).and_then(|i| sk.curves.get(*i)).map(|c| c.id.clone());
|
||||
*at += t;
|
||||
// The copy sits where it was pasted: its place no longer follows the original's expressions.
|
||||
exprs.x = None;
|
||||
exprs.y = None;
|
||||
let geom = sk.text_source_geometry(&src)?;
|
||||
let link = sk.add_link(LinkKind::Text, src, &geom)?;
|
||||
curve_ids.extend(sk.link_curves(&link).into_iter().filter_map(|i| sk.curves.get(i).map(|c| c.id.clone())));
|
||||
}
|
||||
let lone: Vec<String> = clip.lone.iter().filter_map(|n| pts.get(*n)).filter_map(|i| sk.points.get(*i).map(|q| q.id.clone())).collect();
|
||||
Ok((curve_ids, lone, cons))
|
||||
})?;
|
||||
let ((curves, lone, constraints), info) = edit(s, &p, cmd, false, |sk, doc| paste_clip(sk, doc, &clip, t))?;
|
||||
if !positioned
|
||||
&& target == clip.sketch
|
||||
&& let Some(c) = s.sketch_clipboard.as_mut()
|
||||
@@ -384,6 +358,160 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
reply(cmd, Pasted { curves, points: lone, constraints, translate: [t.x, t.y], sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The feature id of the sketch `key` (an id or a name, as [`sketch_store::target`] reads them)
|
||||
/// in `doc`.
|
||||
fn sketch_in(doc: &Document, key: &Value, cmd: &str) -> Result<u64> {
|
||||
let (name, found) = match key {
|
||||
Value::Number(n) => {
|
||||
let id = n.as_u64().ok_or_else(|| bad(cmd, "`from` must be a sketch id or name"))?;
|
||||
(id.to_string(), doc.feature(id))
|
||||
}
|
||||
Value::String(x) => (x.clone(), doc.find_feature(x)),
|
||||
_ => return Err(bad(cmd, "`from` must be a sketch id or name")),
|
||||
};
|
||||
let f = found.ok_or_else(|| bad(cmd, format!("no sketch `{name}`")))?;
|
||||
if !matches!(f.kind, solidcraft_doc::FeatureKind::Sketch { .. }) {
|
||||
return Err(bad(cmd, format!("`{name}` is not a sketch")));
|
||||
}
|
||||
Ok(f.id)
|
||||
}
|
||||
|
||||
/// A copy of every curve, point, constraint, dimension and text of sketch `id` in `doc`.
|
||||
fn whole_clip(doc: &Document, id: u64, cmd: &str) -> Result<SketchClip> {
|
||||
let sk = sketch_store::read(doc, id)?;
|
||||
let ids: Vec<String> = sk.curves.iter().map(|c| c.id.clone()).chain(sk.points.iter().filter(|q| q.id != ORIGIN).map(|q| q.id.clone())).collect();
|
||||
Ok(clip_of(doc, sk, id, &ids, true, cmd)?.0)
|
||||
}
|
||||
|
||||
/// `sketch.replace` (#11125): the sketch keeps its feature (plane, place in the timeline, name,
|
||||
/// display options and what is built on it) and takes a copy of another sketch's contents, as a
|
||||
/// whole-sketch copy and paste would. Projected geometry comes as ordinary geometry; patterns,
|
||||
/// offsets and formula curves come as the geometry they made. The dimension parameters the old
|
||||
/// contents had, when nothing else uses them, go with them.
|
||||
fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.replace";
|
||||
if !(p.is_object() || p.is_null()) {
|
||||
return Err(bad(cmd, "params must be an object"));
|
||||
}
|
||||
let target = sketch_store::target(s, p, cmd)?;
|
||||
let from = p.get("from").filter(|v| !v.is_null()).ok_or_else(|| bad(cmd, "`from`: the sketch to take the contents of"))?;
|
||||
let file = match p.get("file").filter(|v| !v.is_null()) {
|
||||
None => None,
|
||||
Some(Value::String(f)) if !f.trim().is_empty() => Some(f.clone()),
|
||||
Some(_) => return Err(bad(cmd, "`file` must be the path of a design file")),
|
||||
};
|
||||
let clip = match &file {
|
||||
Some(path) => {
|
||||
let (other, _) =
|
||||
solidcraft_io::read_design_file(path, None::<fn(solidcraft_io::DesignPreview)>).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
let source = sketch_in(&other, from, cmd)?;
|
||||
whole_clip(&other, source, cmd)?.for_another_design()
|
||||
}
|
||||
None => {
|
||||
let source = sketch_in(&s.doc, from, cmd)?;
|
||||
if source == target {
|
||||
return Err(bad(cmd, "a sketch can't be replaced by itself"));
|
||||
}
|
||||
whole_clip(&s.doc, source, cmd)?
|
||||
}
|
||||
};
|
||||
let ((curves, points, constraints), info) = edit(s, &json!({ "sketch": target }), cmd, false, |sk, doc| {
|
||||
let old: Vec<String> = sk.constraints.iter().filter_map(|c| c.param.clone()).collect();
|
||||
// Emptied, but its id counters kept: the new contents' ids carry on from the old ones, so
|
||||
// a reference to an old curve or point (a sweep path, a profile picked by its curves)
|
||||
// finds nothing and says so, rather than taking a new, unrelated curve that happens to get
|
||||
// the same id.
|
||||
let mut fresh = sk.clone();
|
||||
fresh.points.retain(|q| q.id == ORIGIN);
|
||||
fresh.curves.clear();
|
||||
fresh.constraints.clear();
|
||||
fresh.patterns.clear();
|
||||
fresh.links.clear();
|
||||
fresh.offsets.clear();
|
||||
fresh.freeform_offsets.clear();
|
||||
fresh.wires.clear();
|
||||
fresh.equations.clear();
|
||||
// The old contents' own dimension parameters go with them; one something else uses stays.
|
||||
sketch_store::write(doc, target, fresh.clone())?;
|
||||
for name in &old {
|
||||
if doc.param(name).is_some_and(|x| x.model) {
|
||||
let _ = doc.remove_param(name);
|
||||
}
|
||||
}
|
||||
*sk = fresh;
|
||||
paste_clip(sk, doc, &clip, Vec2::ZERO)
|
||||
})?;
|
||||
if s.active_sketch == Some(target) {
|
||||
s.selection.clear();
|
||||
}
|
||||
let source = match from {
|
||||
Value::String(x) => x.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
reply(cmd, Replaced { sketch_id: target, from: source, file, curves, points, constraints, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The ids of what a paste added: curves (those of texts included), lone points, constraints.
|
||||
type PastedIds = (Vec<String>, Vec<String>, Vec<String>);
|
||||
|
||||
/// Add a copy of `clip`, moved by `t`, to `sk`.
|
||||
fn paste_clip(sk: &mut Sketch, doc: &mut Document, clip: &SketchClip, t: Vec2) -> Result<PastedIds> {
|
||||
if sk.curves.len().saturating_add(clip.curves.len()) > solidcraft_sketch::MAX_CURVES {
|
||||
return Err(solidcraft_sketch::SketchError::TooLarge.into());
|
||||
}
|
||||
let mut pts = Vec::with_capacity(clip.points.len());
|
||||
let origin = sk.points.iter().position(|q| q.id == ORIGIN);
|
||||
for (i, q) in clip.points.iter().enumerate() {
|
||||
match origin.filter(|_| clip.origin == Some(i)) {
|
||||
Some(o) => pts.push(o),
|
||||
None => pts.push(sk.add_point(*q + t, None)?),
|
||||
}
|
||||
}
|
||||
let mut made = Vec::with_capacity(clip.curves.len());
|
||||
for c in &clip.curves {
|
||||
let mut kind = c.kind.clone();
|
||||
kind.map_points(&|q| pts.get(q).copied().unwrap_or(usize::MAX));
|
||||
let i = sk.add_curve(kind, None)?;
|
||||
if let Some(cu) = sk.curves.get_mut(i) {
|
||||
cu.construction = c.construction;
|
||||
cu.centerline = c.centerline;
|
||||
cu.reversed = c.reversed;
|
||||
cu.infinite = c.infinite;
|
||||
}
|
||||
made.push(i);
|
||||
}
|
||||
let mut cons = Vec::new();
|
||||
for c in &clip.constraints {
|
||||
let mut kind = c.kind.clone();
|
||||
if !kind.remap(&|q| pts.get(q).copied(), &|k| made.get(k).copied()) {
|
||||
continue;
|
||||
}
|
||||
// Each pasted dimension gets its own parameter, with the same expression.
|
||||
let param = c.param.as_ref().map(|(e, u)| doc.new_model_param(e, u));
|
||||
let id = sk.add_constraint(kind, param)?;
|
||||
if let Some(nc) = sk.constraints.last_mut() {
|
||||
nc.driven = c.driven;
|
||||
nc.text = c.text;
|
||||
}
|
||||
cons.push(id);
|
||||
}
|
||||
let mut curve_ids: Vec<String> = made.iter().filter_map(|i| sk.curves.get(*i).map(|c| c.id.clone())).collect();
|
||||
for (src, path) in &clip.texts {
|
||||
let mut src = src.clone();
|
||||
let LinkSource::Text { at, style, exprs, .. } = &mut src else { continue };
|
||||
style.path = path.and_then(|k| made.get(k)).and_then(|i| sk.curves.get(*i)).map(|c| c.id.clone());
|
||||
*at += t;
|
||||
// The copy sits where it was pasted: its place no longer follows the original's expressions.
|
||||
exprs.x = None;
|
||||
exprs.y = None;
|
||||
let geom = sk.text_source_geometry(&src)?;
|
||||
let link = sk.add_link(LinkKind::Text, src, &geom)?;
|
||||
curve_ids.extend(sk.link_curves(&link).into_iter().filter_map(|i| sk.curves.get(i).map(|c| c.id.clone())));
|
||||
}
|
||||
let lone: Vec<String> = clip.lone.iter().filter_map(|n| pts.get(*n)).filter_map(|i| sk.points.get(*i).map(|q| q.id.clone())).collect();
|
||||
Ok((curve_ids, lone, cons))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_clipboard_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -294,3 +294,166 @@ fn sketch_geometry_pastes_into_another_design_with_values_not_foreign_parameters
|
||||
let at = pos(sketch(&b, sb), &again[0]);
|
||||
assert!(at.iter().zip(&corner).all(|(p, q)| p.dist(*q) < 1e-6), "{at:?}");
|
||||
}
|
||||
|
||||
fn volume(s: &Session) -> f64 {
|
||||
s.model.state().bodies.iter().map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).sum()
|
||||
}
|
||||
|
||||
/// A design with sketch `Small` (a 10 x 10 square from the origin) extruded 5 mm, then sketch
|
||||
/// `Big` (a dimensioned 30 x 20 rectangle tied to the origin) on the same plane.
|
||||
fn two_sketches() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Small"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Small", "distance": 5.0, "operation": "new_body"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Big"}));
|
||||
let rect = strs(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 20]}))["curves"]);
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{}.start", rect[0]), "b": "origin"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [rect[0]], "value": 30}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [rect[1]], "value": 20}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
s
|
||||
}
|
||||
|
||||
/// Replace Sketch (#11125): the sketch keeps its feature, and the extrude built on it now makes
|
||||
/// the other sketch's shape (10 x 10 x 5 = 500 mm³ becomes 30 x 20 x 5 = 3000 mm³). The copy's
|
||||
/// constraints come along, the one to the origin tied to this sketch's origin, its dimensions
|
||||
/// with their own parameters; the source is untouched; one undo step puts it all back.
|
||||
#[test]
|
||||
fn replace_sketch_gives_a_sketch_another_sketchs_contents_and_keeps_its_features() {
|
||||
let mut s = two_sketches();
|
||||
assert!((volume(&s) - 500.0).abs() < 1e-6, "{}", volume(&s));
|
||||
let small = s.doc.find_feature("Small").unwrap().id;
|
||||
let big = s.doc.find_feature("Big").unwrap().id;
|
||||
let features = s.doc.features.len();
|
||||
let params = s.doc.params.len();
|
||||
let source = s.doc.sketch(big).unwrap().clone();
|
||||
let before = s.doc.to_json();
|
||||
|
||||
let out = run(&mut s, "sketch.replace", json!({"sketch": "Small", "from": "Big"}));
|
||||
assert_eq!(out["sketch_id"], json!(small));
|
||||
assert_eq!(strs(&out["curves"]).len(), 4);
|
||||
assert_eq!(s.doc.features.len(), features, "no feature added or removed");
|
||||
assert_eq!(s.doc.find_feature("Small").map(|f| f.id), Some(small), "the sketch keeps its id and name");
|
||||
assert!((volume(&s) - 3000.0).abs() < 1e-6, "the extrude follows: {}", volume(&s));
|
||||
let sk = sketch(&s, small);
|
||||
assert_eq!(sk.constraints.len(), source.constraints.len(), "every constraint comes along");
|
||||
assert_eq!(sk.points.iter().filter(|q| q.id == "origin").count(), 1, "one origin");
|
||||
let origin = sk.points.iter().position(|q| q.id == "origin").unwrap();
|
||||
assert!(
|
||||
sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Coincident { p, q } if p == origin || q == origin)),
|
||||
"the corner stays tied to the origin: {:?}",
|
||||
sk.constraints
|
||||
);
|
||||
let names: Vec<String> = sk.constraints.iter().filter_map(|c| c.param.clone()).collect();
|
||||
let theirs: Vec<String> = source.constraints.iter().filter_map(|c| c.param.clone()).collect();
|
||||
assert_eq!(names.len(), 2);
|
||||
assert!(names.iter().all(|n| !theirs.contains(n)), "its own parameters: {names:?} vs {theirs:?}");
|
||||
assert_eq!(s.doc.params.len(), params + 2);
|
||||
assert_eq!(serde_json::to_value(s.doc.sketch(big).unwrap()).unwrap(), serde_json::to_value(&source).unwrap(), "the source is untouched");
|
||||
// Its dimensions drive it: a new width changes this sketch only.
|
||||
run(&mut s, "parameters.change", json!({"name": names[0], "expression": "40"}));
|
||||
assert!((volume(&s) - 4000.0).abs() < 1e-6, "{}", volume(&s));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before, "one undo step");
|
||||
}
|
||||
|
||||
/// The old contents' dimension parameters go with them, unless something else uses them; a
|
||||
/// parameter the user made stays.
|
||||
#[test]
|
||||
fn replace_sketch_drops_the_old_dimension_parameters_nothing_else_uses() {
|
||||
let mut s = two_sketches();
|
||||
run(&mut s, "parameters.change", json!({"name": "w", "expression": "12"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Third"}));
|
||||
let rect = strs(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [12, 8]}))["curves"]);
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [rect[0]], "value": "w"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [rect[1]], "value": 8}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let third = s.doc.find_feature("Third").unwrap().id;
|
||||
let params: Vec<String> = sketch(&s, third).constraints.iter().filter_map(|c| c.param.clone()).collect();
|
||||
assert_eq!(params.len(), 2, "{params:?}");
|
||||
run(&mut s, "sketch.replace", json!({"sketch": third, "from": "Small"}));
|
||||
for name in ¶ms {
|
||||
let kept = s.doc.param(name);
|
||||
assert!(kept.is_none_or(|p| !p.model), "`{name}` went with the old dimension: {kept:?}");
|
||||
}
|
||||
assert!(s.doc.param("w").is_some(), "the user's parameter stays");
|
||||
}
|
||||
|
||||
/// A feature that names the old contents' curves (here a profile picked by its curve ids) never
|
||||
/// takes the new contents' curves that happen to get the same ids: the new ids carry on from the
|
||||
/// old ones, so the reference finds nothing and the feature says so, instead of quietly building
|
||||
/// from unrelated geometry (30 x 20 x 5 = 3000 mm³ here).
|
||||
#[test]
|
||||
fn replace_sketch_never_rebinds_a_reference_to_an_old_curve_onto_a_new_one() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Small"}));
|
||||
let square = strs(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}))["curves"]);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Small", "distance": 5.0, "operation": "new_body", "profiles": [{"curves": square}]}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Big"}));
|
||||
let rect = strs(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 20]}))["curves"]);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(square, rect, "both sketches numbered their curves alike: {square:?} {rect:?}");
|
||||
assert!((volume(&s) - 500.0).abs() < 1e-6, "{}", volume(&s));
|
||||
let before = s.doc.to_json();
|
||||
match s.execute("sketch.replace", &json!({"sketch": "Small", "from": "Big"})) {
|
||||
Ok(out) => {
|
||||
let new = strs(&out["curves"]);
|
||||
assert!(new.iter().all(|id| !square.contains(id)), "new ids, not the old ones again: {new:?} vs {square:?}");
|
||||
assert!(s.model.results.iter().any(|r| r.error.is_some()), "the extrude says its curves are gone");
|
||||
}
|
||||
Err(_) => assert_eq!(s.doc.to_json(), before, "a refused replace changes nothing"),
|
||||
}
|
||||
assert!((volume(&s) - 3000.0).abs() > 1.0, "never built from the unrelated new curves: {}", volume(&s));
|
||||
}
|
||||
|
||||
/// Sketch contents come from a sketch in another design file, at the same sketch coordinates; a
|
||||
/// dimension that named that design's parameter keeps its value, without the parameter.
|
||||
#[test]
|
||||
fn replace_sketch_takes_a_sketch_from_another_design_file() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-replace-sketch-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Other.sc");
|
||||
let mut other = Session::default();
|
||||
run(&mut other, "parameters.change", json!({"name": "w", "expression": "1 in"}));
|
||||
run(&mut other, "sketch.create", json!({"plane": "XZ", "name": "Plate"}));
|
||||
let rect = strs(&run(&mut other, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [25.4, 10]}))["curves"]);
|
||||
run(&mut other, "sketch.dimension", json!({"entities": [rect[0]], "value": "w"}));
|
||||
run(&mut other, "sketch.finish", json!({}));
|
||||
run(&mut other, "file.save_as", json!({"path": path.to_string_lossy()}));
|
||||
|
||||
let mut s = two_sketches();
|
||||
let file = path.to_string_lossy().to_string();
|
||||
let out = run(&mut s, "sketch.replace", json!({"sketch": "Small", "from": "Plate", "file": file}));
|
||||
assert_eq!(out["file"], json!(file));
|
||||
assert!((volume(&s) - 25.4 * 10.0 * 5.0).abs() < 1e-6, "{}", volume(&s));
|
||||
assert!(s.doc.param("w").is_none(), "the other design's parameter is not brought in");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_sketch_refuses_bad_requests_and_leaves_the_design_as_it_was() {
|
||||
let mut s = two_sketches();
|
||||
let before = s.doc.to_json();
|
||||
let extrude = s.doc.features.iter().find(|f| !matches!(f.kind, solidcraft_doc::FeatureKind::Sketch { .. })).unwrap().id;
|
||||
for bad in [
|
||||
json!({"sketch": "Small"}),
|
||||
json!({"sketch": "Small", "from": "Small"}),
|
||||
json!({"sketch": "Small", "from": "Nope"}),
|
||||
json!({"sketch": "Small", "from": 1.5}),
|
||||
json!({"sketch": "Small", "from": [1]}),
|
||||
json!({"sketch": "Small", "from": extrude}),
|
||||
json!({"sketch": "Nope", "from": "Big"}),
|
||||
json!({"from": "Big"}),
|
||||
json!({"sketch": "Small", "from": "Big", "file": 3}),
|
||||
json!({"sketch": "Small", "from": "Big", "file": "Z:/no/such/file.solidcraft"}),
|
||||
json!([1, 2]),
|
||||
json!("text"),
|
||||
] {
|
||||
assert!(s.execute("sketch.replace", &bad).is_err(), "{bad}");
|
||||
assert_eq!(s.doc.to_json(), before, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,59 @@ fn a_projected_edge_that_is_gone_fails_its_sketch_not_replaced() {
|
||||
/// A boss 20 x 20 x 10 made as its own body on the plate and joined into it afterwards: no body
|
||||
/// has the link's body name any more. The join left the boss's top edge from (30, 10, 30) to
|
||||
/// (30, 30, 30) as it was, and the link finds it by its name on the body that has it now.
|
||||
/// A sketch that fails for an edge gone by name still opens for editing (#12085): its own circle
|
||||
/// and the projected line, kept where it was and marked lost, are there to repair. It still
|
||||
/// fails, as does the extrude built from it; deleting the lost projection repairs both.
|
||||
#[test]
|
||||
fn a_sketch_that_lost_its_projected_edge_opens_with_its_curves_and_the_link_marked_lost() {
|
||||
let mut s = Session::default();
|
||||
plate(&mut s);
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[100, 25, 20]], "radius": "4 mm", "name": "Round"}));
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": true}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "20 mm", "name": "Top"}));
|
||||
let projected_curves = run(&mut s, "sketch.project", json!({"refs": [{"edge": [100, 25, 20], "body": "Body1"}]}))["created"]["curves"].clone();
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50, 25], "radius": 10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Top", "distance": "5 mm", "name": "Boss"}));
|
||||
let top = s.doc.find_feature("Top").unwrap().id;
|
||||
let boss_error = |s: &Session| s.model.result(s.doc.find_feature("Boss").unwrap().id).unwrap().error.clone();
|
||||
let curves = |s: &mut Session| run(s, "sketch.inspect", json!({"sketch": "Top"}))["curves"].as_array().map_or(0, Vec::len);
|
||||
let name = stored(&s).edge_name.unwrap();
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": false}));
|
||||
assert_eq!(reselect(&s), Some(name.clone()), "{}", trouble(&s));
|
||||
assert!(s.model.state().sketch(top).is_none(), "a failed sketch not edited is not shown");
|
||||
assert!(boss_error(&s).is_some());
|
||||
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Top"}));
|
||||
assert_eq!(reselect(&s), Some(name.clone()), "still fails while edited");
|
||||
assert!(boss_error(&s).is_some(), "the extrude does not build from the held sketch");
|
||||
assert!(link(&s).lost);
|
||||
assert_line(&s, 100.0, (0.0, 50.0), "kept where it was");
|
||||
assert_eq!(curves(&mut s), 2, "the circle and the lost projection");
|
||||
// Another edit keeps it shown, with what the edit added: the sketch and the extrude after it
|
||||
// fail again, so the design ends in the same state as before the edit.
|
||||
let has_point = |s: &Session| {
|
||||
let st = s.model.state();
|
||||
let sk = &st.sketch(top).unwrap().sketch;
|
||||
(0..sk.points.len()).filter_map(|i| sk.point(i)).any(|p| (p.x - 10.0).abs() < 1e-6 && (p.y - 10.0).abs() < 1e-6)
|
||||
};
|
||||
assert!(!has_point(&s));
|
||||
run(&mut s, "sketch.point", json!({"point": [10, 10]}));
|
||||
assert!(link(&s).lost);
|
||||
assert!(has_point(&s), "the held sketch shows the new point");
|
||||
assert_eq!(curves(&mut s), 2);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert!(s.model.state().sketch(top).is_none(), "finished, it is not shown");
|
||||
|
||||
// Repaired from inside: the lost projection deleted.
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Top"}));
|
||||
run(&mut s, "sketch.delete", json!({"entities": projected_curves}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(trouble(&s), "");
|
||||
assert_eq!(boss_error(&s), None);
|
||||
assert!(s.doc.sketch(top).unwrap().links.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_projected_edge_whose_body_was_joined_into_another_is_found_there() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -213,6 +213,44 @@ fn plane_through_axis_and_point_accepts_straight_model_and_sketch_lines() {
|
||||
assert!(parallel(plane(&s, "Side").normal(), Vec3::Y));
|
||||
}
|
||||
|
||||
/// Issue #12851: construct.plane.axis_point with an edge pick whose circle centre lies on the
|
||||
/// axis (e.g. rim of a cylinder or revolved torus). The centre is on the axis, so resolving the
|
||||
/// edge pick to the centre would refuse with "the point is on the axis". Instead, resolve to the
|
||||
/// picked point on the edge, supporting picks at any angle around the rim.
|
||||
#[test]
|
||||
fn plane_through_axis_and_circular_edge_centered_on_axis_succeeds() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "axis": [0, 1, 0], "radius": 15, "height": 30, "body_name": "Cyl"}));
|
||||
// Edge at 0 degrees round the rim (x = 15, y = 30, z = 0).
|
||||
run(
|
||||
&mut s,
|
||||
"construct.plane.axis_point",
|
||||
json!({
|
||||
"axis": {"axis": "Y"},
|
||||
"point": {"edge": [15, 30, 0]},
|
||||
"name": "PlaneAt0Deg"
|
||||
}),
|
||||
);
|
||||
let pl0 = plane(&s, "PlaneAt0Deg");
|
||||
assert!(parallel(pl0.normal(), Vec3::Z), "normal is {:?}", pl0.normal());
|
||||
assert!(on_plane(&pl0, Vec3::new(15.0, 30.0, 0.0)));
|
||||
assert!(on_plane(&pl0, Vec3::new(0.0, 0.0, 0.0)));
|
||||
|
||||
// Edge pick at 90 degrees round the rim (x = 0, y = 30, z = 15).
|
||||
run(
|
||||
&mut s,
|
||||
"construct.plane.axis_point",
|
||||
json!({
|
||||
"axis": {"axis": "Y"},
|
||||
"point": {"edge": [0, 30, 15]},
|
||||
"name": "PlaneAt90Deg"
|
||||
}),
|
||||
);
|
||||
let pl90 = plane(&s, "PlaneAt90Deg");
|
||||
assert!(parallel(pl90.normal(), Vec3::X), "normal is {:?}", pl90.normal());
|
||||
assert!(on_plane(&pl90, Vec3::new(0.0, 30.0, 15.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plane_through_axis_and_point_in_a_moved_component_keeps_its_world_picks() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -1800,6 +1800,11 @@ impl Session {
|
||||
Err(e)
|
||||
}
|
||||
};
|
||||
// A sketch open for editing that failed (a projected edge lost by name) is still shown, its
|
||||
// lost link marked, so it can be repaired from inside it (#12085).
|
||||
if outermost {
|
||||
self.model.hold_failed_sketch(&self.doc, self.active_sketch);
|
||||
}
|
||||
if let Some((before, changed, failed)) = crash_outcome {
|
||||
// Refresh and body-name pinning can copy the shared document. Remember the final
|
||||
// document, so the next command does not mistake that work for an outside edit
|
||||
|
||||
@@ -114,6 +114,44 @@ pub struct Pasted {
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Replace the contents of a sketch with a copy of another sketch's, in one undo step. The sketch
|
||||
/// keeps its plane, its place in the timeline and the features built on it.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ReplaceParams {
|
||||
/// The sketch to replace (default: the one being edited).
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchRef>,
|
||||
/// The sketch whose curves, points, constraints, dimensions and texts take its place.
|
||||
pub from: SketchRef,
|
||||
/// A design file `from` is in (default: this design).
|
||||
#[serde(default)]
|
||||
pub file: Option<String>,
|
||||
}
|
||||
|
||||
/// What `sketch.replace` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Replaced {
|
||||
/// The replaced sketch's feature id.
|
||||
pub sketch_id: u64,
|
||||
/// The sketch the contents came from.
|
||||
pub from: String,
|
||||
/// The design file it is in, when not this design.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub file: Option<String>,
|
||||
/// The sketch's new curves' ids.
|
||||
pub curves: Vec<String>,
|
||||
/// Its new lone points' ids.
|
||||
pub points: Vec<String>,
|
||||
/// Its new constraints' and dimensions' ids.
|
||||
pub constraints: Vec<String>,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_clipboard_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1918,9 +1918,17 @@ fn measure_items() {
|
||||
let r = run(&mut s, "inspect.measure", json!({"items": [face(ft, [20.0, 15.0, 20.0]), face(fb, [20.0, 15.0, 0.0])]}));
|
||||
assert!((r["distance_mm"].as_f64().unwrap() - 20.0).abs() < 1e-9, "{r}");
|
||||
assert!((r["angle_deg"].as_f64().unwrap() - 180.0).abs() < 1e-6, "{r}");
|
||||
// The ends of a distance are real points (#11833: NaN ends hid the Distance row): one on
|
||||
// each face, straight across.
|
||||
let end = |k: &str| -> Vec3 { serde_json::from_value(r[k].clone()).unwrap_or_else(|e| panic!("{k}: {e} in {r}")) };
|
||||
let (from, to) = (end("from"), end("to"));
|
||||
assert!((from.z - 20.0).abs() < 1e-9 && to.z.abs() < 1e-9 && (to - from).cross(Vec3::Z).len() < 1e-9, "{r}");
|
||||
let r = run(&mut s, "inspect.measure", json!({"items": [face(ft, [20.0, 15.0, 20.0]), face(ff, [20.0, 0.0, 10.0])]}));
|
||||
assert!(r["distance_mm"].as_f64().unwrap() < 1e-9);
|
||||
assert!((r["angle_deg"].as_f64().unwrap() - 90.0).abs() < 1e-6);
|
||||
for k in ["from", "to"] {
|
||||
assert!(serde_json::from_value::<Vec3>(r[k].clone()).is_ok_and(|p| p.is_finite()), "{k} in {r}");
|
||||
}
|
||||
let r = run(&mut s, "inspect.measure", json!({"items": [face(ft, [20.0, 15.0, 20.0])]}));
|
||||
assert!((r["items"][0]["area_mm2"].as_f64().unwrap() - 1200.0).abs() < 1e-6, "{r}");
|
||||
let v = json!({"type": "vertex", "body": "Body1", "point": [0, 0, 0]});
|
||||
|
||||
@@ -2219,6 +2219,7 @@ mod capture_credit {
|
||||
_worker: None,
|
||||
}))
|
||||
}
|
||||
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||
fn mapped() -> (ProductScene, ImageRequest, Weak<Vec<u8>>) {
|
||||
use crate::{
|
||||
job::{
|
||||
@@ -2467,6 +2468,7 @@ mod capture_credit {
|
||||
assert!(weak.upgrade().is_none());
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
}
|
||||
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn capture_rechecks_actual_registered_layout_after_admission_before_merging_credit() {
|
||||
let (scene, mut image, weak) = mapped();
|
||||
@@ -2485,6 +2487,7 @@ mod capture_credit {
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
assert!(host.0.shared.queue.lock().unwrap().is_empty());
|
||||
}
|
||||
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn capture_registered_factory_failure_releases_source_credit_and_preserves_only_record_summary() {
|
||||
let (scene, mut image, weak) = mapped();
|
||||
|
||||
@@ -339,10 +339,9 @@ fn feature_row(
|
||||
}
|
||||
}
|
||||
if r.double {
|
||||
if !path.is_empty() {
|
||||
let _ = app.run("component.activate", json!({"component":component,"occurrence":path.last(),"path":path}));
|
||||
}
|
||||
app.edit_feature(id);
|
||||
// Inside a placement, the feature's component is active while it is edited, and the one
|
||||
// active before comes back when the editor ends (#12720).
|
||||
app.edit_feature_in(id, Some((component, path)));
|
||||
} else if r.clicked {
|
||||
click_select(app, ui, format!("f:{id}"), Some(Sel::Feature { id }));
|
||||
}
|
||||
@@ -1008,6 +1007,87 @@ mod tests {
|
||||
assert!(labels.contains(&block_name), "a rolled-back feature stays listed: {labels:?}");
|
||||
}
|
||||
|
||||
/// #12720: a feature inside a placed component, double-clicked in its Features folder, is
|
||||
/// edited with its component active, in its placement; OK and Cancel make the component active
|
||||
/// before the double-click active again, as Finish Sketch does for a sketch row (#8127). A
|
||||
/// component activated while the editor is open stays active, and a feature of the design
|
||||
/// itself activates nothing.
|
||||
#[test]
|
||||
fn a_feature_edited_from_a_placed_components_folder_gives_the_active_component_back() {
|
||||
let mut h = Harness::new();
|
||||
let plate = h.app.run("solid.box", json!({"length":60,"width":40,"height":8,"body_name":"Plate"})).unwrap()["feature"].as_u64().unwrap();
|
||||
let bracket = h.app.run("component.create", json!({"name":"Bracket","grounded":false})).unwrap();
|
||||
let pad = h.app.run("solid.box", json!({"length":20,"width":20,"height":20,"body_name":"Block"})).unwrap()["feature"].as_u64().unwrap();
|
||||
let comp = bracket["component"].as_u64().unwrap();
|
||||
let path = vec![bracket["occurrence"].as_u64().unwrap()];
|
||||
h.app.run("component.activate", json!({"component":0})).unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":path[0],"translate":[0,0,8]})).unwrap();
|
||||
// Find in Timeline opens the placement's rows down to the feature (and the root's folder).
|
||||
let block = h.app.session.occurrence_bodies(&path, false).unwrap()[0].clone();
|
||||
reveal(&mut h.app, pad, &block);
|
||||
h.app.tree.expanded.insert("c0/features".into());
|
||||
h.frames(2);
|
||||
let row = json!({"handle": format!("row:browser-feature:{}:{pad}", occurrence_key(&path))});
|
||||
let closed = |h: &mut Harness| {
|
||||
for _ in 0..500 {
|
||||
if h.app.dialog.is_none() && h.app.applying.is_none() && h.app.deferred.is_none() && h.app.deferred_queue.is_empty() {
|
||||
break;
|
||||
}
|
||||
h.frame();
|
||||
}
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().and_then(|d| d.error.clone()));
|
||||
// Long enough that the next click is not a double or triple click.
|
||||
h.frames(40);
|
||||
};
|
||||
let active = |h: &Harness| (h.app.session.active_component, h.app.session.active_occurrence_path.clone());
|
||||
for close in ["dialog:OK", "dialog:Cancel"] {
|
||||
h.step(&json!({"click": row, "double": true})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(h.app.dialog.as_ref().is_some_and(|d| d.editing.is_some_and(|(id, _)| id == pad)), "{close}: the editor opened");
|
||||
assert_eq!(active(&h), (comp, Some(path.clone())), "{close}: the bracket is active while its feature is edited");
|
||||
h.step(&json!({"click": {"handle": close}})).unwrap();
|
||||
closed(&mut h);
|
||||
assert_eq!(active(&h), (0, None), "{close}: the design is active again");
|
||||
}
|
||||
// Another command taking over ends the editor the same way.
|
||||
h.step(&json!({"click": row, "double": true})).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(active(&h).0, comp);
|
||||
h.step(&json!({"start": "solid.box"})).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(active(&h), (0, None), "the design is active again once another command took over");
|
||||
crate::dialogs::cancel(&mut h.app);
|
||||
h.frames(40);
|
||||
// A component activated while the editor is open stays active.
|
||||
let other = h.app.run("component.create", json!({"name":"Other"})).unwrap()["component"].as_u64().unwrap();
|
||||
h.app.run("component.activate", json!({"component":0})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"click": row, "double": true})).unwrap();
|
||||
h.frames(2);
|
||||
h.app.run("component.activate", json!({"component":other})).unwrap();
|
||||
h.step(&json!({"click": {"handle": "dialog:Cancel"}})).unwrap();
|
||||
closed(&mut h);
|
||||
assert_eq!(active(&h).0, other, "the component activated meanwhile stays active");
|
||||
// The bracket already active: editing its feature leaves it active.
|
||||
h.app.run("component.activate", json!({"component":comp,"occurrence":path[0],"path":path})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"click": row, "double": true})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"click": {"handle": "dialog:Cancel"}})).unwrap();
|
||||
closed(&mut h);
|
||||
assert_eq!(active(&h), (comp, Some(path.clone())), "a component active already stays active");
|
||||
// A feature of the design itself, edited from the root Features folder, activates nothing.
|
||||
h.app.run("component.activate", json!({"component":0})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"click": {"handle": format!("row:browser-feature:c0:{plate}")}, "double": true})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(h.app.dialog.as_ref().is_some_and(|d| d.editing.is_some_and(|(id, _)| id == plate)));
|
||||
assert!(h.app.pre_edit_component.is_none());
|
||||
h.step(&json!({"click": {"handle": "dialog:Cancel"}})).unwrap();
|
||||
closed(&mut h);
|
||||
assert_eq!(active(&h), (0, None));
|
||||
}
|
||||
|
||||
/// #10079: three extrusions of one visible sketch, the first renamed. A click just past the end
|
||||
/// of a feature's name, or a press on the name that slips a few points before its release,
|
||||
/// selects the feature; only the sketch button itself opens the sketch.
|
||||
|
||||
@@ -1501,11 +1501,11 @@ fn measure_line(
|
||||
for s in [sa, sb] {
|
||||
painter.circle(s, 3.5, t.manipulator, Stroke::new(1.0, Color32::WHITE));
|
||||
}
|
||||
pick_numbers(painter, sa, sb);
|
||||
let mid = sa + (sb - sa) * 0.5;
|
||||
let galley = painter.layout_no_wrap(crate::prefs::show_mm(app, d, 1), egui::FontId::proportional(crate::theme::Tokens::get().font_label), t.text);
|
||||
let own = egui::Rect::from_min_size(mid + vec2(10.0, -14.0), galley.size() + vec2(10.0, 6.0));
|
||||
let rect = moved_label(proj, own, labels.offset(0));
|
||||
pick_numbers(painter, sa, sb, rect);
|
||||
crate::scenario::publish_target("measure:label", rect);
|
||||
if app.preferences.measure_axis_distances {
|
||||
measure_components(app, ui, painter, proj, a, b, rect, labels);
|
||||
@@ -1519,23 +1519,46 @@ fn measure_line(
|
||||
}
|
||||
|
||||
/// "1" and "2" beside the measured line's ends (#6020), as the panel lists the picks: each just
|
||||
/// past its end, away from the line and its length in the middle.
|
||||
fn pick_numbers(painter: &egui::Painter, sa: Pos2, sb: Pos2) {
|
||||
/// past its end, away from the line and from the distance `label`.
|
||||
fn pick_numbers(painter: &egui::Painter, sa: Pos2, sb: Pos2, label: egui::Rect) {
|
||||
let t = Tokens::get();
|
||||
let along = (sb - sa).normalized();
|
||||
// Ends on one spot (or one behind the other): side by side above it.
|
||||
let (out_a, out_b) = if along.is_finite() && along.length_sq() > 0.5 { (-along, along) } else { (vec2(-0.7, -0.7), vec2(0.7, -0.7)) };
|
||||
for (n, at, out) in [("1", sa, out_a), ("2", sb, out_b)] {
|
||||
let galley = painter.layout_no_wrap(n.to_string(), egui::FontId::proportional(t.font_label), t.text);
|
||||
let size = galley.size() + vec2(8.0, 4.0);
|
||||
let side = size.x.max(size.y);
|
||||
let rect = egui::Rect::from_center_size(at + out * (side * 0.5 + 8.0), vec2(side, size.y));
|
||||
let galleys = ["1", "2"].map(|n| painter.layout_no_wrap(n.to_string(), egui::FontId::proportional(t.font_label), t.text));
|
||||
let size = galleys.iter().map(|g| g.size()).fold(egui::Vec2::ZERO, |a, b| a.max(b)) + vec2(8.0, 4.0);
|
||||
let rects = pick_number_rects(sa, sb, size, label);
|
||||
for ((n, galley), rect) in ["1", "2"].into_iter().zip(galleys).zip(rects) {
|
||||
painter.rect(rect, t.radius_sm.saturating_sub(1), t.overlay, Stroke::new(1.0, t.manipulator), egui::StrokeKind::Inside);
|
||||
painter.galley(rect.center() - galley.size() * 0.5, galley, t.text);
|
||||
crate::scenario::publish_target(&format!("measure:item:{n}"), rect);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where "1" and "2" go, each `size` (text and padding): just past its end of the line, away
|
||||
/// from it. Where that would cover the distance `label` or the other number (ends on one spot or
|
||||
/// a few pixels apart, as two faces measured at their shared corner, #11833), the first free spot
|
||||
/// around its end instead, so the two never hide each other.
|
||||
fn pick_number_rects(sa: Pos2, sb: Pos2, size: egui::Vec2, label: egui::Rect) -> [egui::Rect; 2] {
|
||||
let side = size.x.max(size.y);
|
||||
let reach = side * 0.5 + 8.0;
|
||||
let along = (sb - sa).normalized();
|
||||
// Ends on one spot (or one behind the other): side by side above it.
|
||||
let (out_a, out_b) = if along.is_finite() && along.length_sq() > 0.5 { (-along, along) } else { (vec2(-0.7, -0.7), vec2(0.7, -0.7)) };
|
||||
// Below, left, then the other ways round; the distance label sits up and to the right.
|
||||
let around =
|
||||
[vec2(0.0, 1.0), vec2(-1.0, 0.0), vec2(-0.7, 0.7), vec2(0.0, -1.0), vec2(-0.7, -0.7), vec2(1.0, 0.0), vec2(0.7, 0.7), vec2(0.7, -0.7)];
|
||||
let place = |at: Pos2, out: egui::Vec2, taken: Option<egui::Rect>| {
|
||||
let towards = |d: egui::Vec2| egui::Rect::from_center_size(at + d * reach, vec2(side, size.y));
|
||||
let first = towards(out);
|
||||
std::iter::once(first)
|
||||
.chain(around.iter().map(|d| towards(*d)))
|
||||
.chain(around.iter().map(|d| towards(*d * 2.0)))
|
||||
.find(|r| !r.intersects(label) && taken.is_none_or(|t| !r.intersects(t)))
|
||||
.unwrap_or(first)
|
||||
};
|
||||
let a = place(sa, out_a, None);
|
||||
let b = place(sb, out_b, Some(a));
|
||||
[a, b]
|
||||
}
|
||||
|
||||
/// A world-axis-aligned chain between the same two measured points. Zero-length and
|
||||
/// view-aligned legs still get a label; labels move apart when their projections coincide, and
|
||||
/// the user can drag each away (`labels` 1 to 3).
|
||||
@@ -2065,6 +2088,34 @@ fn arrow(painter: &egui::Painter, a: Pos2, b: Pos2, col: Color32) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// "1" and "2" never cover each other or the distance (#11833): ends on one spot (two faces
|
||||
/// measured at their shared corner, a distance of 0), a few pixels apart, and a line long
|
||||
/// enough that both keep their own place past their ends.
|
||||
#[test]
|
||||
fn pick_numbers_stay_apart_and_off_the_distance() {
|
||||
let size = vec2(16.0, 18.0);
|
||||
let label_at = |sa: Pos2, sb: Pos2| egui::Rect::from_min_size(sa + (sb - sa) * 0.5 + vec2(10.0, -14.0), vec2(70.0, 22.0));
|
||||
let corner = egui::pos2(400.0, 300.0);
|
||||
let mut cases = vec![(corner, corner)];
|
||||
for d in [vec2(1.0, 0.0), vec2(0.5, -0.5), vec2(3.0, 2.0), vec2(-2.0, -4.0), vec2(6.0, 0.0), vec2(0.0, 7.0)] {
|
||||
cases.push((corner, corner + d));
|
||||
}
|
||||
for (sa, sb) in cases {
|
||||
let label = label_at(sa, sb);
|
||||
let [a, b] = pick_number_rects(sa, sb, size, label);
|
||||
assert!(!a.intersects(b), "{sa:?} {sb:?}: 1 {a:?} and 2 {b:?} overlap");
|
||||
for (n, r, own) in [("1", a, sa), ("2", b, sb)] {
|
||||
assert!(!r.intersects(label), "{sa:?} {sb:?}: {n} {r:?} covers the distance {label:?}");
|
||||
assert!(r.center().distance(own) < 80.0, "{sa:?} {sb:?}: {n} strays from its end");
|
||||
}
|
||||
}
|
||||
// A long line: each number just past its own end, as before.
|
||||
let (sa, sb) = (egui::pos2(100.0, 400.0), egui::pos2(300.0, 200.0));
|
||||
let [a, b] = pick_number_rects(sa, sb, size, label_at(sa, sb));
|
||||
let along = (sb - sa).normalized();
|
||||
assert!((a.center() - (sa - along * 17.0)).length() < 1e-3 && (b.center() - (sb + along * 17.0)).length() < 1e-3, "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
/// The arrow's point on a picked edge: one point, a zero-length piece, a click past the end.
|
||||
#[test]
|
||||
fn nearest_on_polyline_handles_short_edges_and_clicks_past_the_end() {
|
||||
|
||||
@@ -68,6 +68,13 @@ pub enum Target {
|
||||
InsertDesign,
|
||||
/// Model › Edit Sketch: choose an existing sketch when none is uniquely selected.
|
||||
EditSketch,
|
||||
/// Replace Sketch from a design file (#11125): the file's sketches, one of which takes the
|
||||
/// place of sketch `sketch`'s contents.
|
||||
ReplaceSketchFile {
|
||||
sketch: u64,
|
||||
path: String,
|
||||
sketches: Vec<(u64, String)>,
|
||||
},
|
||||
/// View › Visual style: the four styles, the current one marked (#8868).
|
||||
VisualStyle,
|
||||
/// View › Navigation tool: Orbit, Pan, Zoom; the latched one marked (#8868).
|
||||
@@ -466,6 +473,10 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
|
||||
.collect(),
|
||||
Target::NamedView { name } => crate::browser::view_items(app, name),
|
||||
Target::InsertDesign => insert_design_items(app),
|
||||
Target::ReplaceSketchFile { sketch, path, sketches } => sketches
|
||||
.iter()
|
||||
.map(|(from, name)| cmd(app, "sketch.replace", name).with(json!({ "sketch": sketch, "from": from, "file": path })))
|
||||
.collect(),
|
||||
Target::EditSketch => {
|
||||
let items: Vec<Item> = app
|
||||
.session
|
||||
@@ -878,6 +889,7 @@ fn sketch_items(app: &SolveApp, id: u64) -> Vec<Item> {
|
||||
Item::sep(),
|
||||
act("ui.editSketch", "Edit Sketch", "sketch").with(json!({ "sketch": id })).on(!editing),
|
||||
act("ui.redefineSketch", "Redefine Sketch Plane", "plane").with(json!({ "sketch": id })),
|
||||
replace_sketch_item(app, id).on(!editing),
|
||||
act("ui.exportDxf", "Export DXF…", "export").with(json!({ "sketch": id })),
|
||||
Item::sep(),
|
||||
act("ui.cut", "Cut", "cut").key("Ctrl+X").on(crate::edit_clipboard::can_cut(app)),
|
||||
@@ -898,6 +910,41 @@ fn sketch_items(app: &SolveApp, id: u64) -> Vec<Item> {
|
||||
items
|
||||
}
|
||||
|
||||
/// Replace Sketch (#11125): each other sketch of the design, whose contents can take the place
|
||||
/// of this one's, then a design file to take a sketch from.
|
||||
fn replace_sketch_item(app: &SolveApp, id: u64) -> Item {
|
||||
let mut item = act("ui.replaceSketch", "Replace Sketch", "sketch");
|
||||
let others = app.session.doc.features.iter().filter(|f| f.id != id && matches!(f.kind, FeatureKind::Sketch { .. }));
|
||||
for f in others {
|
||||
item.children.push(cmd(app, "sketch.replace", &f.name).with(json!({ "sketch": id, "from": f.id })));
|
||||
}
|
||||
if !item.children.is_empty() {
|
||||
item.children.push(Item::sep());
|
||||
}
|
||||
item.children.push(act("ui.replaceSketchFromFile", app.tr("ui.replace_sketch.from_file"), "open").with(json!({ "sketch": id })));
|
||||
item
|
||||
}
|
||||
|
||||
/// Replace Sketch from a file: the design file's sketches to choose from, in a menu at `at`;
|
||||
/// a file with one sketch replaces at once.
|
||||
fn replace_sketch_from_file(app: &mut SolveApp, sketch: u64, at: Pos2) {
|
||||
app.pick_open_for(&solidcraft_engine::io::DESIGN_EXTS, move |app, path| {
|
||||
let Some(path) = path else { return };
|
||||
let sketches: Vec<(u64, String)> = match solidcraft_engine::io::read_design_file(&path, None::<fn(solidcraft_engine::io::DesignPreview)>) {
|
||||
Ok((doc, _)) => doc.features.iter().filter(|f| matches!(f.kind, FeatureKind::Sketch { .. })).map(|f| (f.id, f.name.clone())).collect(),
|
||||
Err(e) => {
|
||||
app.set_status(format!("{path}: {e}"), true);
|
||||
return;
|
||||
}
|
||||
};
|
||||
match sketches.as_slice() {
|
||||
[] => app.set_status(app.tr("ui.replace_sketch.no_sketches").to_string(), true),
|
||||
[(from, _)] => drop(app.run("sketch.replace", json!({ "sketch": sketch, "from": from, "file": path }))),
|
||||
_ => open_for(app, at, Target::ReplaceSketchFile { sketch, path, sketches }),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Construction geometry's row: edit, delete, rename, show or hide; sketch on planes.
|
||||
fn plane_items(app: &SolveApp, id: u64) -> Vec<Item> {
|
||||
let Some(f) = app.session.doc.feature(id) else { return Vec::new() };
|
||||
@@ -1731,6 +1778,13 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
crate::browser::redefine_sketch(app, id);
|
||||
}
|
||||
}
|
||||
// Its entries are in its submenu.
|
||||
"ui.replaceSketch" => {}
|
||||
"ui.replaceSketchFromFile" => {
|
||||
if let Some(id) = id_of(&p, "sketch") {
|
||||
replace_sketch_from_file(app, id, at);
|
||||
}
|
||||
}
|
||||
"ui.hideSketch" | "ui.showSketch" => {
|
||||
if let Some(id) = id_of(&p, "sketch") {
|
||||
crate::browser::set_sketch_visible(app, id, item.id == "ui.showSketch");
|
||||
@@ -2324,7 +2378,9 @@ pub fn check_target(app: &SolveApp, target: &Target) -> Result<(), String> {
|
||||
| Target::NamedViews
|
||||
| Target::ViewGizmo => true,
|
||||
Target::Body { name } => crate::open_meshing::session_world(&app.session).body(name).is_some(),
|
||||
Target::Sketch { id } => doc.feature(*id).is_some_and(|f| matches!(f.kind, FeatureKind::Sketch { .. })),
|
||||
Target::Sketch { id } | Target::ReplaceSketchFile { sketch: id, .. } => {
|
||||
doc.feature(*id).is_some_and(|f| matches!(f.kind, FeatureKind::Sketch { .. }))
|
||||
}
|
||||
// Construction geometry, as the browser's Construction folder lists it.
|
||||
Target::Plane { id } => doc.feature(*id).is_some_and(|f| {
|
||||
matches!(f.kind, FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. })
|
||||
|
||||
@@ -1148,6 +1148,19 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
|
||||
}
|
||||
ok(reply)
|
||||
}
|
||||
"ui.editFolder" => {
|
||||
let Some(id) = (match p.get("folder") {
|
||||
Some(Value::Number(n)) => n.as_u64(),
|
||||
Some(Value::String(x)) => app.session.doc.timeline_groups.iter().find(|group| group.name == *x).map(|group| group.id),
|
||||
_ => return err("`folder` must be a timeline folder id or name"),
|
||||
}) else {
|
||||
return err("no such timeline folder");
|
||||
};
|
||||
if let Err(why) = crate::hole_folder::open(app, id) {
|
||||
return err(why);
|
||||
}
|
||||
ok(started(app))
|
||||
}
|
||||
"ui.selection" => ok(json!({
|
||||
"selection": app.session.selection,
|
||||
"dialog": app.dialog.as_ref().map(|d| d.inputs.iter().map(|i| json!({"label": i.label, "items": i.items})).collect::<Vec<_>>()),
|
||||
|
||||
@@ -2834,6 +2834,10 @@ fn placement_row(d: &mut Dialog, ui: &mut egui::Ui, app: &SolveApp) {
|
||||
object_type_row(d, ui);
|
||||
return;
|
||||
}
|
||||
if crate::hole_folder::active(d) {
|
||||
crate::hole_folder::note(app, d, ui);
|
||||
return;
|
||||
}
|
||||
let Kind::Hole { opts, .. } = &mut d.kind else { return };
|
||||
let mut m = usize::from(opts.multiple);
|
||||
choice_row(ui, "Placement", "hole_place", &mut m, &["Single", "Multiple"]);
|
||||
@@ -4995,6 +4999,9 @@ pub fn apply_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>
|
||||
let mut cmds = dialog_commands(app, &normalized)?;
|
||||
exact_regions(d, &mut cmds);
|
||||
crate::op_auto::scope_targets(app, d, &mut cmds)?;
|
||||
if crate::hole_folder::active(d) {
|
||||
return crate::hole_folder::commands(app, d, cmds);
|
||||
}
|
||||
if let Some((id, _)) = d.editing {
|
||||
let (cmd, mut params) = cmds.into_iter().next().ok_or("nothing to apply")?;
|
||||
if let Some(feature) = app.session.doc.feature(id) {
|
||||
@@ -5769,14 +5776,20 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
}
|
||||
Kind::Move { .. } => return Ok(vec![("solid.move".into(), crate::dialogs_move::params(app, d)?)]),
|
||||
Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } => {
|
||||
need(0, if opts.multiple { "sketch points" } else { "a face position" })?;
|
||||
let folder = crate::hole_folder::active(d);
|
||||
if !folder {
|
||||
need(0, if opts.multiple { "sketch points" } else { "a face position" })?;
|
||||
}
|
||||
let ty = match *kind {
|
||||
0 if opts.angled => "drilled",
|
||||
k => HOLE_TYPES.get(k).copied().unwrap_or("simple"),
|
||||
};
|
||||
// One hole per picked face position, or one Hole feature per sketch for sketch points.
|
||||
let mut places: Vec<Value> = Vec::new();
|
||||
if opts.multiple {
|
||||
if folder {
|
||||
// Every hole of a folder keeps its own place (`hole_folder`).
|
||||
places.push(json!({}));
|
||||
} else if opts.multiple {
|
||||
let mut by_sketch: Vec<(u64, Vec<String>)> = Vec::new();
|
||||
for x in sels(d, 0) {
|
||||
if let Sel::SketchPoint { id } = x
|
||||
|
||||
@@ -45,6 +45,7 @@ pub struct DocSlot {
|
||||
pub(crate) pending_feature_edit: Option<crate::feature_edit::PendingFeatureEdit>,
|
||||
pre_sketch_cam: Option<crate::PreSketchView>,
|
||||
pre_sketch_component: Option<crate::PreSketchComponent>,
|
||||
pre_edit_component: Option<crate::PreEditComponent>,
|
||||
view: DocView,
|
||||
occurrence_move: Option<crate::browser::OccMove>,
|
||||
reveals: crate::browser::PendingReveals,
|
||||
@@ -146,6 +147,7 @@ impl DocSlot {
|
||||
pending_feature_edit: None,
|
||||
pre_sketch_cam: None,
|
||||
pre_sketch_component: None,
|
||||
pre_edit_component: None,
|
||||
view: DocView::default(),
|
||||
occurrence_move: None,
|
||||
reveals: Default::default(),
|
||||
@@ -288,6 +290,7 @@ fn swap_in(app: &mut SolveApp, mut slot: DocSlot) -> DocSlot {
|
||||
pending_feature_edit: std::mem::replace(&mut app.pending_feature_edit, slot.pending_feature_edit),
|
||||
pre_sketch_cam: std::mem::replace(&mut app.pre_sketch_cam, slot.pre_sketch_cam),
|
||||
pre_sketch_component: std::mem::replace(&mut app.pre_sketch_component, slot.pre_sketch_component),
|
||||
pre_edit_component: std::mem::replace(&mut app.pre_edit_component, slot.pre_edit_component),
|
||||
view: take_view(app),
|
||||
occurrence_move: std::mem::replace(&mut app.tree.occurrence_move, slot.occurrence_move),
|
||||
reveals,
|
||||
|
||||
@@ -19,6 +19,11 @@ impl PendingFeatureEdit {
|
||||
self.next_command = None;
|
||||
}
|
||||
|
||||
/// The feature whose editor this prepares, until the preparation is cancelled.
|
||||
pub(crate) fn feature(&self) -> Option<u64> {
|
||||
self.feature
|
||||
}
|
||||
|
||||
pub(crate) fn then_start(&mut self, command: &str) {
|
||||
self.cancel();
|
||||
self.next_command = Some(command.to_owned());
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Editing every hole in a History folder at once. A folder that holds only holes (and the folders
|
||||
//! inside it) opens the Hole dialog filled in from its first hole, with nothing to pick: what OK
|
||||
//! changes there (size, depth, type, thread, …) goes to each hole, and the rest of each hole (its
|
||||
//! place first of all) stays its own. Each hole is redefined with `timeline.redefine`, as editing
|
||||
//! it on its own does.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::dialogs::Dialog;
|
||||
|
||||
/// Where the folder dialog keeps the folder and its holes, and the parameters it opened with.
|
||||
const FOLDER: &str = "hole_folder";
|
||||
const OPENED: &str = "hole_folder_opened";
|
||||
|
||||
/// The holes of folder `id`, in History order, when it (with the folders inside it) holds holes
|
||||
/// only and no joints.
|
||||
pub(crate) fn holes(app: &SolveApp, id: u64) -> Option<Vec<u64>> {
|
||||
let doc = &app.session.doc;
|
||||
let mut pending = vec![id];
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut ids = Vec::new();
|
||||
while let Some(id) = pending.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let group = doc.timeline_groups.iter().find(|group| group.id == id)?;
|
||||
if !group.joints.is_empty() {
|
||||
return None;
|
||||
}
|
||||
for feature in &group.features {
|
||||
if !matches!(doc.feature(*feature)?.kind, solidcraft_engine::doc::FeatureKind::Hole { .. }) {
|
||||
return None;
|
||||
}
|
||||
ids.push(*feature);
|
||||
}
|
||||
pending.extend(doc.timeline_groups.iter().filter(|child| child.parent == Some(id)).map(|child| child.id));
|
||||
}
|
||||
ids.sort_by_key(|feature| doc.feature_index(*feature));
|
||||
(!ids.is_empty()).then_some(ids)
|
||||
}
|
||||
|
||||
/// Whether `d` edits a folder of holes.
|
||||
pub(crate) fn active(d: &Dialog) -> bool {
|
||||
d.extra.contains_key(FOLDER)
|
||||
}
|
||||
|
||||
/// How many holes the open folder dialog edits, and the folder's name.
|
||||
pub(crate) fn summary(app: &SolveApp, d: &Dialog) -> Option<(usize, String)> {
|
||||
let folder = d.extra.get(FOLDER)?;
|
||||
let id = folder.get("group").and_then(Value::as_u64)?;
|
||||
let count = folder.get("holes").and_then(Value::as_array).map_or(0, Vec::len);
|
||||
let name = app.session.doc.timeline_groups.iter().find(|group| group.id == id).map(|group| group.name.clone())?;
|
||||
Some((count, name))
|
||||
}
|
||||
|
||||
/// Open the Hole dialog for every hole in folder `id`.
|
||||
pub(crate) fn open(app: &mut SolveApp, id: u64) -> Result<(), String> {
|
||||
let ids = holes(app, id).ok_or("the folder holds something other than holes")?;
|
||||
let first = *ids.first().ok_or("the folder holds no holes")?;
|
||||
crate::dialogs::cancel(app);
|
||||
let mut d = crate::dialogs::for_feature(app, first, None).ok_or("the hole can't be edited here")?;
|
||||
d.editing = None;
|
||||
d.inputs.clear();
|
||||
d.active = 0;
|
||||
for key in ["direction", "position_exprs", "all_points"] {
|
||||
d.extra.remove(key);
|
||||
}
|
||||
d.extra.insert(FOLDER.into(), json!({"group": id, "holes": ids}));
|
||||
let opened = hole_params(&crate::dialogs::apply_commands(app, &d)?)?;
|
||||
d.extra.insert(OPENED.into(), opened);
|
||||
app.dialog = Some(d);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The parameters of the one `solid.hole` command among `cmds`.
|
||||
fn hole_params(cmds: &[(String, Value)]) -> Result<Value, String> {
|
||||
match cmds {
|
||||
[(cmd, params)] if cmd == "solid.hole" => Ok(params.clone()),
|
||||
_ => Err("the Hole dialog made no hole".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder dialog's commands: `edited` is the one hole its fields make (with no place). Each
|
||||
/// hole of the folder is redefined from its own parameters with the fields that differ from those
|
||||
/// the dialog opened with; none at all before the dialog has opened.
|
||||
pub(crate) fn commands(app: &SolveApp, d: &Dialog, edited: Vec<(String, Value)>) -> Result<Vec<(String, Value)>, String> {
|
||||
let Some(opened) = d.extra.get(OPENED) else { return Ok(edited) };
|
||||
let edited = hole_params(&edited)?;
|
||||
let (Some(opened), Some(edited)) = (opened.as_object(), edited.as_object()) else { return Err("the hole's fields are not a map".into()) };
|
||||
let changed: Vec<(&String, Option<&Value>)> =
|
||||
opened.keys().chain(edited.keys()).filter(|key| opened.get(*key) != edited.get(*key)).map(|key| (key, edited.get(key))).collect();
|
||||
let ids: Vec<u64> = d
|
||||
.extra
|
||||
.get(FOLDER)
|
||||
.and_then(|folder| folder.get("holes"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|ids| ids.iter().filter_map(Value::as_u64).collect())
|
||||
.unwrap_or_default();
|
||||
let mut out = Vec::new();
|
||||
for id in ids {
|
||||
let one = crate::dialogs::for_feature(app, id, None).ok_or("a hole in the folder can't be edited")?;
|
||||
let Some((cmd, mut redefine)) = crate::dialogs::apply_commands(app, &one)?.into_iter().next() else { continue };
|
||||
let Some(params) = redefine.get_mut("params").and_then(Value::as_object_mut) else { continue };
|
||||
// A hole at a point keeps the point it was made at: its edit dialog finds the face there
|
||||
// in the model before the hole (History rolled back to it), which this one is not.
|
||||
if let Some(solidcraft_engine::doc::FeatureKind::Hole { points: None, position, at, .. }) = app.session.doc.feature(id).map(|f| &f.kind) {
|
||||
let place = match at {
|
||||
Some(exprs) => json!(exprs.iter().map(|e| e.as_str()).collect::<Vec<_>>()),
|
||||
None => json!([position.0.x, position.0.y, position.0.z]),
|
||||
};
|
||||
params.insert("position".into(), place);
|
||||
}
|
||||
for (key, value) in &changed {
|
||||
match value {
|
||||
Some(value) => {
|
||||
params.insert((*key).clone(), (*value).clone());
|
||||
}
|
||||
None => {
|
||||
params.remove(*key);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push((cmd, redefine));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The line the folder dialog shows over its fields.
|
||||
pub(crate) fn note(app: &SolveApp, d: &Dialog, ui: &mut egui::Ui) {
|
||||
if let Some((count, name)) = summary(app, d) {
|
||||
let text = crate::i18n::format("hole_folder.applies_to", &[("count", &count.to_string()), ("folder", &name)]);
|
||||
ui.add(egui::Label::new(egui::RichText::new(text).color(crate::theme::Tokens::get().text_dim)).wrap());
|
||||
}
|
||||
}
|
||||
@@ -569,3 +569,56 @@ fn single_holes_on_two_faces_share_a_parameter_typed_in_the_dialog() {
|
||||
assert!(!solid_at(&h, Vec3::new(30.0, 20.0, 17.0)) && !solid_at(&h, Vec3::new(25.0, 3.0, 10.0)), "both holes are drilled");
|
||||
assert!(solid_at(&h, Vec3::new(30.0, 20.0, 8.0)), "5 mm deep (and its drill point), not through");
|
||||
}
|
||||
|
||||
/// Each hole's (diameter, depth, position) in History order.
|
||||
fn hole_fields(h: &Harness) -> Vec<(String, Option<String>, Vec3)> {
|
||||
h.app
|
||||
.session
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.filter_map(|f| match &f.kind {
|
||||
FeatureKind::Hole { diameter, depth, position, .. } => Some((diameter.to_string(), depth.as_ref().map(|d| d.to_string()), position.0)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_holes_on_a_folder_changes_what_was_edited_in_every_hole_and_keeps_the_rest() {
|
||||
let mut h = block_as_installed();
|
||||
// Bore is through; the others are 4 and 6 mm deep.
|
||||
h.app.run("solid.hole", json!({"position": [30, 8, 20], "diameter": 6, "depth": 4, "name": "A"})).unwrap();
|
||||
h.app.run("solid.hole", json!({"position": [30, 22, 20], "diameter": 6, "depth": 6, "name": "B"})).unwrap();
|
||||
let folder = h.app.run("timeline.group", json!({"name": "Holes", "features": ["Bore", "A", "B"]})).unwrap()["group"].as_u64().unwrap();
|
||||
let before = hole_fields(&h);
|
||||
h.step(&json!({"call": "ui.editFolder", "params": {"folder": "Holes"}})).unwrap();
|
||||
h.frames(3);
|
||||
let d = h.app.dialog.as_ref().expect("the Hole dialog opened");
|
||||
assert!(d.editing.is_none() && d.inputs.is_empty(), "nothing to pick: each hole keeps its place");
|
||||
assert_eq!(crate::hole_folder::summary(&h.app, d), Some((3, "Holes".to_string())));
|
||||
// One redefinition per hole.
|
||||
let cmds = crate::dialogs::apply_commands(&h.app, d).unwrap();
|
||||
assert_eq!(cmds.iter().map(|(cmd, _)| cmd.as_str()).collect::<Vec<_>>(), ["timeline.redefine"; 3]);
|
||||
match h.app.dialog.as_mut().map(|d| &mut d.kind) {
|
||||
Some(Kind::Hole { diameter, .. }) => *diameter = "4 mm".into(),
|
||||
_ => panic!("the Hole dialog is open"),
|
||||
}
|
||||
h.step(&json!({"click": {"handle": "dialog:ok"}})).unwrap();
|
||||
h.settle();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().map(|d| &d.error));
|
||||
let after = hole_fields(&h);
|
||||
assert_eq!(after.len(), 3);
|
||||
for ((_, depth, position), (diameter, depth_now, position_now)) in before.iter().zip(&after) {
|
||||
assert_eq!(diameter, "4 mm", "every hole takes the new diameter");
|
||||
assert_eq!(depth, depth_now, "and keeps its own depth");
|
||||
assert!(position.dist(*position_now) < 1e-9, "and its place");
|
||||
}
|
||||
assert!(!solid_at(&h, Vec3::new(31.5, 8.0, 18.0)) && solid_at(&h, Vec3::new(33.5, 8.0, 18.0)), "A is 4 mm wide now");
|
||||
assert_eq!(h.app.session.doc.timeline_groups[0].id, folder, "still in their folder");
|
||||
// A folder with anything but holes has no Edit Holes.
|
||||
h.app.run("timeline.group", json!({"name": "Mixed", "features": [h.app.session.doc.features[0].id], "groups": [folder]})).unwrap();
|
||||
let mixed = h.app.session.doc.timeline_groups.iter().find(|g| g.name == "Mixed").unwrap().id;
|
||||
assert!(crate::hole_folder::holes(&h.app, mixed).is_none());
|
||||
assert!(h.step(&json!({"call": "ui.editFolder", "params": {"folder": "Mixed"}})).is_err());
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ mod history_folds;
|
||||
pub(crate) mod history_rows;
|
||||
#[cfg(test)]
|
||||
mod hole_direction_ui_tests;
|
||||
pub(crate) mod hole_folder;
|
||||
pub(crate) mod hole_place;
|
||||
#[cfg(test)]
|
||||
mod hole_place_ui_tests;
|
||||
@@ -518,6 +519,17 @@ pub struct PreSketchComponent {
|
||||
pub opened_in: u64,
|
||||
}
|
||||
|
||||
/// The active component (and placement) before a feature row inside a placed component was
|
||||
/// double-clicked in the Browser, which activates the feature's own component (#12720), and the
|
||||
/// component the editor opened in.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PreEditComponent {
|
||||
pub feature: u64,
|
||||
pub component: u64,
|
||||
pub path: Option<Vec<u64>>,
|
||||
pub opened_in: u64,
|
||||
}
|
||||
|
||||
/// Which late UI handler has first refusal of this frame's Escape. Reserving input
|
||||
/// is distinct from having performed cancellation (`esc_handled`).
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
@@ -563,6 +575,9 @@ pub struct SolveApp {
|
||||
/// The component active before Edit Sketch activated the one the sketch is in (#8127);
|
||||
/// leaving the sketch makes it active again.
|
||||
pub pre_sketch_component: Option<PreSketchComponent>,
|
||||
/// The component active before a Browser double-click activated the one a feature is in
|
||||
/// (#12720); the end of that feature's editor makes it active again.
|
||||
pub pre_edit_component: Option<PreEditComponent>,
|
||||
pub services: Services,
|
||||
file_picker: Option<file_picker::Pending>,
|
||||
/// A slicer started alone and waited for before the file is sent (Send to Slicer).
|
||||
@@ -854,6 +869,7 @@ impl SolveApp {
|
||||
pre_extrude_cam: None,
|
||||
pre_command_cam: None,
|
||||
pre_sketch_component: None,
|
||||
pre_edit_component: None,
|
||||
services,
|
||||
file_picker: None,
|
||||
slicer_wait: None,
|
||||
@@ -1377,6 +1393,15 @@ impl SolveApp {
|
||||
if let Ok(v) = &r {
|
||||
match id {
|
||||
"sketch.finish" | "sketch.cancel" => self.sketch_closed(v.get("sketch").and_then(Value::as_u64)),
|
||||
// The sketch being edited has all new contents: a tool's picks named the old ones.
|
||||
"sketch.replace" if v.get("sketch_id").and_then(Value::as_u64).is_some_and(|id| self.session.active_sketch == Some(id)) => {
|
||||
if let Some(tool) = self.tool.as_mut() {
|
||||
tool.pts.clear();
|
||||
tool.hover = None;
|
||||
tool.picks.clear();
|
||||
tool.point_picks.clear();
|
||||
}
|
||||
}
|
||||
"view.fit" | "view.home" => self.frame_view(id == "view.home"),
|
||||
"view.tool" => {
|
||||
self.viewport.nav = match v.get("tool").and_then(Value::as_str) {
|
||||
@@ -1618,6 +1643,7 @@ impl SolveApp {
|
||||
self.applying = None;
|
||||
self.editing_state = None;
|
||||
self.pending_feature_edit = None;
|
||||
self.pre_edit_component = None;
|
||||
// So does what it had computing: none of it may land in the new design.
|
||||
self.cancel_computing();
|
||||
self.tool = None;
|
||||
@@ -2350,6 +2376,48 @@ impl SolveApp {
|
||||
self.edit_feature_at(id, None);
|
||||
}
|
||||
|
||||
/// [`Self::edit_feature`] from a feature row inside one placement of a component (the
|
||||
/// Browser's Features folder): that component is active while the feature is edited, so the
|
||||
/// edit happens in its placement, and the one active before comes back when the editor ends
|
||||
/// (#12720), as a sketch opened the same way does (#8127). A feature of the design itself, or
|
||||
/// of the component already active, is edited as it is.
|
||||
pub fn edit_feature_in(&mut self, id: u64, placement: Option<(u64, &[u64])>) {
|
||||
let Some((component, path)) = placement.filter(|(_, path)| !path.is_empty()) else {
|
||||
self.edit_feature(id);
|
||||
return;
|
||||
};
|
||||
// An editor still open from an earlier double-click ends first, giving back its own
|
||||
// component, so the one remembered here is the one the user sees before this click.
|
||||
dialogs::cancel(self);
|
||||
self.return_from_feature_edit();
|
||||
let context = (self.session.active_component, self.session.active_occurrence_path.clone());
|
||||
let _ = self.run("component.activate", json!({"component": component, "occurrence": path.last(), "path": path}));
|
||||
let opened_in = self.session.active_component;
|
||||
let moved = (opened_in, &self.session.active_occurrence_path) != (context.0, &context.1);
|
||||
self.pre_edit_component = moved.then_some(PreEditComponent { feature: id, component: context.0, path: context.1, opened_in });
|
||||
self.edit_feature(id);
|
||||
// Nothing opened (a feature without an editor goes to the command palette): the
|
||||
// component comes back at once.
|
||||
self.return_from_feature_edit();
|
||||
}
|
||||
|
||||
/// Once the editor of the feature a Browser double-click activated a component for has ended,
|
||||
/// however it ended (OK, Cancel, another command, a sketch finished), the component active
|
||||
/// before the double-click is active again (#12720). Unless another component was activated
|
||||
/// meanwhile: that one stays, as after a sketch.
|
||||
fn return_from_feature_edit(&mut self) {
|
||||
let Some(id) = self.pre_edit_component.as_ref().map(|pre| pre.feature) else { return };
|
||||
let open = self.dialog.as_ref().is_some_and(|d| d.editing.is_some_and(|(feature, _)| feature == id))
|
||||
|| self.pending_feature_edit.as_ref().is_some_and(|pending| pending.feature() == Some(id))
|
||||
|| self.session.active_sketch == Some(id);
|
||||
if open {
|
||||
return;
|
||||
}
|
||||
if let Some(pre) = self.pre_edit_component.take().filter(|pre| pre.opened_in == self.session.active_component) {
|
||||
self.restore_component(pre.component, pre.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::edit_feature`] opened from a click at `at` on what the feature made: the dialog's
|
||||
/// arrow sits by that point (a fillet's or chamfer's edge nearest it).
|
||||
pub(crate) fn edit_feature_at(&mut self, id: u64, at: Option<solidcore_record::Vec3>) {
|
||||
@@ -3222,6 +3290,7 @@ impl SolveApp {
|
||||
self.drain_control(ctx);
|
||||
self.follow_view_request(true);
|
||||
self.return_from_extrude_view();
|
||||
self.return_from_feature_edit();
|
||||
agent_cursor::step(self, ctx);
|
||||
browser::eye_drag_frame(self, ctx);
|
||||
if !self.synthetic.is_empty() || !self.held_click.is_empty() {
|
||||
|
||||
@@ -327,6 +327,77 @@ fn sketch_menu_hides_and_moves_to_another_plane() {
|
||||
assert_eq!(app.tree.redefine, Some(id));
|
||||
}
|
||||
|
||||
/// Replace Sketch (#11125): a sketch row's flyout lists the design's other sketches, one of which
|
||||
/// gives this sketch its contents, then a design file to take one from; that file's sketches are
|
||||
/// listed the same way.
|
||||
#[test]
|
||||
fn sketch_menu_replaces_a_sketch_with_another_sketchs_contents() {
|
||||
let mut app = SolveApp::new(Session::default(), Services::default());
|
||||
app.run("sketch.create", json!({"plane": "XY", "name": "Small"})).unwrap();
|
||||
app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]})).unwrap();
|
||||
app.finish_sketch();
|
||||
app.run("sketch.create", json!({"plane": "XY", "name": "Round"})).unwrap();
|
||||
app.run("sketch.circle.center", json!({"center": [0, 0], "diameter": 6})).unwrap();
|
||||
app.finish_sketch();
|
||||
let small = app.session.doc.find_feature("Small").unwrap().id;
|
||||
let t = Target::Sketch { id: small };
|
||||
let replace = find(&context_menu::items(&app, &t), "Replace Sketch");
|
||||
assert!(replace.enabled);
|
||||
let labels: Vec<&str> = replace.children.iter().map(|i| i.label.as_str()).collect();
|
||||
assert_eq!(labels, ["Round", "", "From a Design File…"]);
|
||||
act(&mut app, &t, "Round");
|
||||
assert_eq!(app.session.doc.sketch(small).unwrap().curves.len(), 1, "the circle took the square's place");
|
||||
assert_eq!(app.session.doc.find_feature("Small").map(|f| f.id), Some(small));
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-menu-replace-sketch-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Other.sc").to_string_lossy().to_string();
|
||||
let mut other = Session::default();
|
||||
other.execute("sketch.create", &json!({"plane": "XY", "name": "Lines"})).unwrap();
|
||||
other.execute("sketch.line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
other.execute("sketch.finish", &json!({})).unwrap();
|
||||
other.execute("sketch.create", &json!({"plane": "XY", "name": "Slot"})).unwrap();
|
||||
other.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [4, 2]})).unwrap();
|
||||
other.execute("sketch.finish", &json!({})).unwrap();
|
||||
other.execute("file.save_as", &json!({"path": path})).unwrap();
|
||||
let sketches: Vec<(u64, String)> = other.doc.features.iter().map(|f| (f.id, f.name.clone())).collect();
|
||||
let file = Target::ReplaceSketchFile { sketch: small, path: path.clone(), sketches };
|
||||
assert!(context_menu::check_target(&app, &file).is_ok());
|
||||
act(&mut app, &file, "Slot");
|
||||
assert_eq!(app.session.doc.sketch(small).unwrap().curves.len(), 4, "the file's rectangle took the circle's place");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A script or the control channel may replace the sketch being edited: a constraint tool's
|
||||
/// first pick named the old contents, so it is dropped, and the next click starts afresh rather
|
||||
/// than constraining whichever new curve took that id.
|
||||
#[test]
|
||||
fn replacing_the_sketch_being_edited_drops_the_tools_picks() {
|
||||
let mut app = SolveApp::new(Session::default(), Services::default());
|
||||
app.run("sketch.create", json!({"plane": "XY", "name": "Round"})).unwrap();
|
||||
app.run("sketch.circle.center", json!({"center": [0, 0], "diameter": 6})).unwrap();
|
||||
app.finish_sketch();
|
||||
app.run("sketch.create", json!({"plane": "XY", "name": "Small"})).unwrap();
|
||||
let square: Vec<String> = app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]})).unwrap()["curves"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|c| c.as_str().map(str::to_string))
|
||||
.collect();
|
||||
let small = app.session.active_sketch.unwrap();
|
||||
let mut tool = crate::tools::Tool::for_command("sketch.constraint.parallel").unwrap();
|
||||
tool.picks.push(square[0].clone());
|
||||
app.tool = Some(tool);
|
||||
app.run("sketch.replace", json!({"sketch": small, "from": "Round"})).unwrap();
|
||||
let tool = app.tool.as_ref().expect("the tool stays");
|
||||
assert!(tool.picks.is_empty(), "{:?}", tool.picks);
|
||||
// Another sketch's replacement leaves the tool alone.
|
||||
app.tool.as_mut().unwrap().picks.push("x".into());
|
||||
let round = app.session.doc.find_feature("Round").unwrap().id;
|
||||
app.run("sketch.replace", json!({"sketch": round, "from": "Small"})).unwrap();
|
||||
assert_eq!(app.tool.as_ref().unwrap().picks, vec!["x".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selected_arc_offers_an_arc_length_dimension() {
|
||||
// #6039: the arc length dimension existed as a command, but nothing in the UI led to it.
|
||||
|
||||
@@ -849,6 +849,19 @@ pub(crate) fn floating_rect(available: Rect) -> Rect {
|
||||
Container::floating_card(Surface::Panel).bottom_strip_rect(available, 40.0)
|
||||
}
|
||||
|
||||
/// A folder's Edit Holes item, when it holds holes only: one Hole dialog for all of them.
|
||||
pub(crate) fn edit_holes_item(app: &mut SolveApp, ui: &mut egui::Ui, folder: u64) {
|
||||
if crate::hole_folder::holes(app, folder).is_none() {
|
||||
return;
|
||||
}
|
||||
if crate::context_menu::button(ui, "Edit Holes", "hole").clicked() {
|
||||
if let Err(why) = crate::hole_folder::open(app, folder) {
|
||||
app.set_status(why, true);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
fn timeline_contents(app: &mut SolveApp, ui: &mut egui::Ui, floating: bool) {
|
||||
let t = Tokens::get();
|
||||
let accent = crate::theme::TabAccent::for_tab(&t, &app.ui.tab);
|
||||
|
||||
@@ -295,6 +295,7 @@ fn folder_menu(app: &mut SolveApp, ui: &mut egui::Ui, folder: &Folder, view: &cr
|
||||
crate::context_menu::start_rename(app, crate::context_menu::RenameWhat::TimelineGroup(folder.id), at);
|
||||
ui.close();
|
||||
}
|
||||
crate::timeline::edit_holes_item(app, ui, folder.id);
|
||||
if crate::context_menu::button(ui, "Ungroup", "feature_group").backing_on_hover_text("Keep its features and folders in the history").clicked() {
|
||||
app.tree.picked_timeline_groups.retain(|g| *g != folder.id);
|
||||
let _ = app.run("timeline.ungroup", json!({ "group": folder.id }));
|
||||
|
||||
@@ -1174,6 +1174,7 @@ fn vertical_group_row(
|
||||
crate::context_menu::start_rename(app, crate::context_menu::RenameWhat::TimelineGroup(group.id), rect.center());
|
||||
ui.close();
|
||||
}
|
||||
crate::timeline::edit_holes_item(app, ui, group.id);
|
||||
ui.separator();
|
||||
selection_actions(app, ui, &table.groups, false);
|
||||
});
|
||||
|
||||
@@ -805,6 +805,7 @@ no box selection.
|
||||
| `ui.at` | `world: [x,y,z]` \| `sketch: [x,y]` \| `plane: "XY"` \| `axis: "Z"` \| `dimension: "d1"` (its value text) \| `handle: "arrow"` (a manipulator handle, see `scenario::publish_handle`) | the screen point (a scenario step's target; see `crates/ui-egui/src/scenario.rs`) |
|
||||
| `ui.pick` | `pixels?: [[x,y]…]`, `rays?: [{origin, dir}…]`, `frame?: "window"\|"model"`, `width?`, `height?`, `edges?` (default true), `edge_px?` (default 6), `all?`, `include_ghosted?` | read-only geometry under each pixel or ray (#6304): one entry per input, `null` where nothing is hit — body, face index, exact world `point` and outward `normal`, `depth`, `occurrence`/`component` for placed parts, `edge`/`vertex` within `edge_px` of a pixel, `section_cap: true` on a cut face. `window` pixels are physical pixels of a window screenshot; `model` pixels are pixels of a `ui.render` of `width`×`height` (default 960×600), so it works headless. As `view.pick` through `engine.execute`/`engine.batch`. See `crates/ui-egui/src/view_pick.rs` |
|
||||
| `ui.handles` | `prefix?` | the handles drawn last frame, `[{name, x, y}]`: toolbar buttons and drop-down items `toolbar:<command id>` (the button alone: `button:<command id>`; a drop-down row: `dropdown:<command id>`, highlighted `dropdown_hot:<command id>`, its pin `dropdown_pin:<command id>`; Customize Toolbar's check boxes `customize:<command id>`), ribbon tabs `tab:<name>` (MODEL, SURFACE, ASSEMBLE, INSERT, INSPECT, MANAGE, VIEW, SHEET METAL, WELDMENTS; contextual SKETCH; workspace RENDER), the toolbar menu button `ribbon:add_tab`, the hint when every tab is hidden `ribbon:tabs_hidden`, Preferences tab check boxes `prefs:toolbar_tab:<NAME>`, panel labels `panel:<tab>:<panel>` (also `ribbon:section:<TAB>:<SECTION>`), document overflow list `tab:overflow`, workspace column `ribbon:workspace`, command-search launcher `ribbon:search`, browser rows `row:<label>`, where each row's depth puts it `indent:<label>`, and fold arrows `fold:<label>`, manipulator handles; signed-axis tips `axis_view:z`, `axis_view:-y`; optional cube facets `navigation_cube:top`, `navigation_cube:top_front_right`, controls `navigation_cube:control:home` (and other actions), plus signed-axis aliases for visible cube faces; Preferences navigator picker `prefs:navigator`, options `prefs:navigator:Signed axes` and `prefs:navigator:Cube`; in the Motion Study dialog, where the chart puts its first and last step `motion:chart_start` and `motion:chart_end`, the Check collisions button `motion:check` and each collision found `motion:collision:<n>` |
|
||||
| `ui.editFolder` | `folder` (id or name) | Edit Holes on a History folder that holds holes only: one Hole dialog, filled in from its first hole, whose OK gives every hole in the folder the fields changed there. Replies as `ui.start` does |
|
||||
| `ui.editFeature` | `feature` (id or name) | what double-clicking a timeline item does: rolls back to it and opens its dialog filled in (sketches open in sketch mode). Replies as `ui.start` does, with the history `marker` |
|
||||
| `ui.scroll` | `x`, `y`, `delta?`, `ctrl?`, `unit?`: `point` \| `line` | wheel zoom at the cursor. `ctrl: true` replays a Ctrl+wheel pinch (Ctrl held and the wheel in points); `unit` selects the wheel unit and defaults to points with `ctrl` and lines without it. `delta` is in points either way. Invalid units or coordinates outside the finite screen-coordinate range are rejected before queuing input |
|
||||
| `ui.key` | `key` (egui key name, `Enter`, `Escape`…), `cmd?`, `ctrl?`, `shift?`, `alt?`, `pressed?` | By default, press and release, then release modifiers. With `pressed: true` or `false`, send only that edge; modifiers remain as given. `cmd` retains the legacy primary-modifier alias unless `ctrl` is explicit: `cmd:true, ctrl:false` sends physical Command, and both true sends Control+Command. `ui.move` can change modifiers between edges. |
|
||||
|
||||
@@ -112,6 +112,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
| 3-Point Rectangle | `sketch.rectangle.three_point` | SKETCH › CREATE |
|
||||
| 2-Point Rectangle (R) | `sketch.rectangle.two_point` | SKETCH › CREATE |
|
||||
| Redefine Sketch Plane | `sketch.redefine` | – |
|
||||
| Replace Sketch | `sketch.replace` | – |
|
||||
| Sketch Scale | `sketch.scale` | SKETCH › MODIFY |
|
||||
| Center Point Arc Slot | `sketch.slot.arc_center` | SKETCH › CREATE |
|
||||
| Three Point Arc Slot | `sketch.slot.arc_three_point` | SKETCH › CREATE |
|
||||
|
||||
@@ -298,6 +298,7 @@ sketch.point3d 2D sketch only (the sketch solver and engine t
|
||||
sketch.polygon.edge 2D sketch only (the sketch solver and engine tests); no kernel operation
|
||||
sketch.rectangle.three_point 2D sketch only (the sketch solver and engine tests); no kernel operation
|
||||
sketch.redefine 2D sketch only (the sketch solver and engine tests); no kernel operation
|
||||
sketch.replace 2D sketch only (the sketch solver and engine tests); no kernel operation
|
||||
sketch.scale 2D sketch only (the sketch solver and engine tests); no kernel operation
|
||||
sketch.slot.arc_center 2D sketch only (the sketch solver and engine tests); no kernel operation
|
||||
sketch.slot.arc_three_point 2D sketch only (the sketch solver and engine tests); no kernel operation
|
||||
|
||||
Reference in New Issue
Block a user