mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 11:05:56 +00:00
Compare commits
1 Commits
main
..
fix-ru-notes
| Author | SHA1 | Date | |
|---|---|---|---|
| fb0b795e7e |
@@ -786,7 +786,6 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
|
||||
| tests/kernel-cases/import/4561-convert-box-mesh-with-a-zero-area-triangle/box-degenerate.stl | SolidCraft contributors | synthetic ASCII STL written by hand: a box with one zero-area triangle, for the kernel regression case of tangency-labs/solidcraft#4561 | MIT OR Apache-2.0 |
|
||||
| assets/locales/pending/theme-blur-ui.json | SolidCraft contributors | Original blur controls, notices and translations | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/tests/fixtures/theme-blur.json | SolidCraft contributors | Original theme for blur option scenarios and manager review | MIT OR Apache-2.0 |
|
||||
| assets/locales/ka.json | David Machakhelidze (devius) | Original Georgian translation of SolidCraft's English catalog, written with Claude Code from the meaning of each English string; terminology and doubtful strings decided by a native Georgian speaker; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
|
||||
@@ -1396,9 +1396,6 @@
|
||||
"dialog.custom_axis": "↖ محور مخصص",
|
||||
"dialog.dimension_insert_hint": "انقر على بُعد قائد آخر لإدراج اسمه",
|
||||
"dialog.dimension_name": "البُعد: {name}",
|
||||
"dialog.dimension_step_down": "إنقاص بمقدار {step}",
|
||||
"dialog.dimension_step_expression": "يتغير الرقم فقط بالخطوات؛ يبقى التعبير كما كُتب",
|
||||
"dialog.dimension_step_up": "زيادة بمقدار {step}",
|
||||
"dialog.edit_title": "تعديل {title}",
|
||||
"dialog.no_shown_body": "لا يوجد جسم ظاهر لتغييره: أظهر جسمًا، أو اختر «جسم جديد»",
|
||||
"dialog.pattern_sketch_not_object": "اختر أجسامًا أو ميزات: الرسم ليس أيًّا منهما. كرّر منحنياته بأداة النمط أو المرآة داخل الرسم، أو ابثقه أولًا.",
|
||||
@@ -1406,7 +1403,6 @@
|
||||
"dialog.selected_count": "↖ {count} محدد",
|
||||
"dialog.selection_constraint": "قيد {id}",
|
||||
"dialog.selection_external_curve": "{sketch} · منحنى {id}",
|
||||
"dialog.selection_external_point": "{point} · {sketch}",
|
||||
"dialog.selection_guidance": "حدد مُدخلًا لتغييره. انقر على العناصر المحددة لإزالتها؛ Ctrl/Cmd مع النقر يبدّل تحديدها أيضًا. راجع المعاينة ثم اضغط موافق.",
|
||||
"dialog.selection_midpoint": "نقطة المنتصف · {id}",
|
||||
"dialog.selection_profile": "{sketch} · مقطع {number}",
|
||||
@@ -1424,7 +1420,6 @@
|
||||
"document.read_only_save": "فُتح هذا التصميم للقراءة فقط: احفظه كملف جديد",
|
||||
"document.save_changes": "حفظ التغييرات على {name}؟",
|
||||
"document.save_copy": "حفظ نسخة…",
|
||||
"document.tab_unsaved_changes": "تغييرات غير محفوظة",
|
||||
"document.unsaved_changes": "في {name} تغييرات غير محفوظة. إن لم تحفظها فستضيع.",
|
||||
"drive.rigid": "{joint} مفصل صلب: يثبّت أجزاءه في مكانها، لذا لا يوجد ما يمكن تحريكه. لتتحرك الأجزاء، غيّر نوعه في «تعديل المفصل»: «دوراني» للدوران، «منزلق» للانزلاق، «أسطواني» لكليهما.",
|
||||
"drive.stopped_by_contact": "توقف عند {value}: سيخترق {a} الجسم {b}",
|
||||
@@ -2004,7 +1999,6 @@
|
||||
"material.selection.mixed": "مواد مختلفة",
|
||||
"measure.at": "عند",
|
||||
"measure.item": "العنصر {number}",
|
||||
"measure.line_to_centre": "من الخط إلى المركز",
|
||||
"measure.quick_copied": "تم نسخ القياس",
|
||||
"measure.quick_copy": "انقر لنسخ القياس",
|
||||
"measure.quick_edges": "{count} حواف",
|
||||
@@ -2337,13 +2331,6 @@
|
||||
"prefs.dialogs_edge": "عند حافة العرض",
|
||||
"prefs.dialogs_left_column": "في العمود الأيسر",
|
||||
"prefs.dialogs_note": "مكان فتح حوارات الأوامر: مثبتة عند حافة العرض، أو في العمود الأيسر مكان المستعرض والسجل، اللذين يعودان كما كانا عند إغلاق الحوار.",
|
||||
"prefs.dimension_step_angle": "ما تضيفه أسهم النافذة وأعلى/أسفل والعجلة إلى الزاوية",
|
||||
"prefs.dimension_step_length": "ما تضيفه أسهم النافذة وأعلى/أسفل والعجلة إلى الطول",
|
||||
"prefs.dimension_steps": "خطوات النافذة",
|
||||
"prefs.dimension_values": "قيم الأبعاد",
|
||||
"prefs.dimension_values_note": "مكان كتابة قيمة البعد. للنافذة أسهم تغيّرها بخطوات؛ تضع أداة البعد النص أولًا.",
|
||||
"prefs.dimension_values_view": "في العرض",
|
||||
"prefs.dimension_values_window": "في نافذة",
|
||||
"prefs.display": "العرض",
|
||||
"prefs.edges": "الحواف والتظليل",
|
||||
"prefs.extend": "توسيع",
|
||||
@@ -4810,7 +4797,6 @@
|
||||
"ui.default_modelling_orientation": "اتجاه النمذجة الافتراضي",
|
||||
"ui.delete": "حذف",
|
||||
"ui.delete_select_first": "حدّد أولًا ما تريد حذفه: الميزات أو الأجسام أو المكوّنات أو هندسة الرسم",
|
||||
"ui.dependents_select_first": "حدّد أولًا ما تريد سرد معتمداته",
|
||||
"ui.depth": "العمق",
|
||||
"ui.design": "التصميم",
|
||||
"ui.design_properties.author": "المؤلف",
|
||||
|
||||
@@ -1391,9 +1391,6 @@
|
||||
"dialog.custom_axis": "↖ Eigene Achse",
|
||||
"dialog.dimension_insert_hint": "Doppelklicken Sie auf eine andere steuernde Bemaßung, um ihren Namen einzufügen",
|
||||
"dialog.dimension_name": "Bemaßung: {name}",
|
||||
"dialog.dimension_step_down": "Um {step} verringern",
|
||||
"dialog.dimension_step_expression": "Nur eine Zahl lässt sich schrittweise ändern; ein Ausdruck bleibt, wie er eingegeben ist",
|
||||
"dialog.dimension_step_up": "Um {step} erhöhen",
|
||||
"dialog.edit_title": "{title} bearbeiten",
|
||||
"dialog.no_shown_body": "Kein sichtbarer Körper zum Ändern vorhanden. Blenden Sie einen Körper ein oder wählen Sie „Neuer Körper“.",
|
||||
"dialog.pattern_sketch_not_object": "Körper oder Funktionen wählen: Eine Skizze ist keines von beiden. Ihre Kurven mit Muster oder Spiegeln in der Skizze wiederholen oder sie zuerst extrudieren.",
|
||||
@@ -1418,7 +1415,6 @@
|
||||
"document.read_only_save": "Dieses Design wurde schreibgeschützt geöffnet: Speichern Sie es als neue Datei",
|
||||
"document.save_changes": "Änderungen in {name} speichern?",
|
||||
"document.save_copy": "Kopie speichern…",
|
||||
"document.tab_unsaved_changes": "Ungespeicherte Änderungen",
|
||||
"document.unsaved_changes": "{name} enthält ungespeicherte Änderungen. Wenn Sie nicht speichern, gehen sie verloren.",
|
||||
"drive.rigid": "{joint} ist ein starres Gelenk. Es hält die verbundenen Teile fest zusammen und ermöglicht daher keine Bewegung. Um die Teile beweglich zu machen, ändern Sie den Gelenktyp unter „Gelenk bearbeiten“: Drehgelenk für Drehbewegungen, Schubgelenk für lineare Bewegungen oder Zylindergelenk für beides.",
|
||||
"drive.stopped_by_contact": "Angehalten bei {value}: {a} würde {b} durchdringen",
|
||||
@@ -1986,7 +1982,6 @@
|
||||
"material.selection.mixed": "Unterschiedliche Materialien",
|
||||
"measure.at": "Bei",
|
||||
"measure.item": "Element {number}",
|
||||
"measure.line_to_centre": "Linie zum Mittelpunkt",
|
||||
"measure.quick_copied": "Messwert kopiert",
|
||||
"measure.quick_copy": "Klicken, um den Messwert zu kopieren",
|
||||
"measure.quick_edges": "{count} Kanten",
|
||||
@@ -2319,13 +2314,6 @@
|
||||
"prefs.dialogs_edge": "Am Rand der Ansicht",
|
||||
"prefs.dialogs_left_column": "In der linken Spalte",
|
||||
"prefs.dialogs_note": "Wo Befehlsdialoge öffnen: am Rand der Ansicht angedockt oder in der linken Spalte anstelle von Browser und Verlauf, die beim Schließen des Dialogs wie zuvor zurückkehren.",
|
||||
"prefs.dimension_step_angle": "Was die Pfeile des Fensters, Auf/Ab und das Mausrad zu einem Winkel addieren",
|
||||
"prefs.dimension_step_length": "Was die Pfeile des Fensters, Auf/Ab und das Mausrad zu einer Länge addieren",
|
||||
"prefs.dimension_steps": "Schritte im Fenster",
|
||||
"prefs.dimension_values": "Bemaßungswerte",
|
||||
"prefs.dimension_values_note": "Wo der Wert einer Bemaßung eingegeben wird. Ein Fenster hat Pfeile, die ihn schrittweise ändern; das Bemaßungswerkzeug platziert zuerst den Text.",
|
||||
"prefs.dimension_values_view": "In der Ansicht",
|
||||
"prefs.dimension_values_window": "In einem Fenster",
|
||||
"prefs.display": "Anzeige",
|
||||
"prefs.edges": "Kanten und Schattierung",
|
||||
"prefs.extend": "Erweitern",
|
||||
@@ -4784,7 +4772,6 @@
|
||||
"ui.default_modelling_orientation": "Standard-Modellierungsorientierung",
|
||||
"ui.delete": "Löschen",
|
||||
"ui.delete_select_first": "Wählen Sie zuerst aus, was gelöscht werden soll: Features, Körper, Komponenten oder Skizzengeometrie",
|
||||
"ui.dependents_select_first": "Wählen Sie zuerst aus, wessen Abhängige aufgelistet werden sollen",
|
||||
"ui.depth": "Tiefe",
|
||||
"ui.design": "Design",
|
||||
"ui.design_properties.author": "Autor",
|
||||
|
||||
@@ -69,7 +69,6 @@
|
||||
"help.solid.split_face": "Divides the selected faces along a plane, the face of another body or sketch curves, without cutting the body. Use it to give part of a face its own draft, colour or offset.",
|
||||
"help.third_party": "Third-party licences",
|
||||
"home.new_mm_hint": "Millimetres · opens in Design",
|
||||
"measure.line_to_centre": "Line to centre",
|
||||
"navbar.custom.anchor.bottom_centre": "Bottom centre",
|
||||
"navbar.custom.anchor.top_centre": "Top centre",
|
||||
"navbar.custom.customise": "Customise navigation bar…",
|
||||
|
||||
@@ -1396,9 +1396,6 @@
|
||||
"dialog.custom_axis": "↖ Custom Axis",
|
||||
"dialog.dimension_insert_hint": "Click another driving dimension to insert its name",
|
||||
"dialog.dimension_name": "Dimension: {name}",
|
||||
"dialog.dimension_step_down": "Down by {step}",
|
||||
"dialog.dimension_step_expression": "Only a number steps; an expression stays as typed",
|
||||
"dialog.dimension_step_up": "Up by {step}",
|
||||
"dialog.edit_title": "EDIT {title}",
|
||||
"dialog.no_shown_body": "No shown body to change: show one, or choose New Body",
|
||||
"dialog.pattern_sketch_not_object": "Pick bodies or features: a sketch is neither. Repeat its curves with the sketch's own Pattern or Mirror, or extrude it first.",
|
||||
@@ -1406,7 +1403,6 @@
|
||||
"dialog.selected_count": "↖ {count} selected",
|
||||
"dialog.selection_constraint": "Constraint {id}",
|
||||
"dialog.selection_external_curve": "{sketch} · curve {id}",
|
||||
"dialog.selection_external_point": "{point} · {sketch}",
|
||||
"dialog.selection_guidance": "Select an input to change it. Click selected objects to remove them; Ctrl/Cmd-click also toggles them. Review the preview, then press OK.",
|
||||
"dialog.selection_midpoint": "Midpoint · {id}",
|
||||
"dialog.selection_profile": "{sketch} · profile {number}",
|
||||
@@ -1424,7 +1420,6 @@
|
||||
"document.read_only_save": "This design was opened read-only: save it as a new file",
|
||||
"document.save_changes": "Save changes to {name}?",
|
||||
"document.save_copy": "Save a Copy…",
|
||||
"document.tab_unsaved_changes": "Unsaved changes",
|
||||
"document.unsaved_changes": "{name} has unsaved changes. If you don't save, they are lost.",
|
||||
"drive.rigid": "{joint} is a rigid joint: it holds its parts still, so it has nothing to drive. To let them move, change its type in Edit Joint: Revolute to rotate, Slider to slide, Cylindrical for both.",
|
||||
"drive.stopped_by_contact": "Stopped at {value}: {a} would pass through {b}",
|
||||
@@ -2004,7 +1999,6 @@
|
||||
"material.selection.mixed": "Different materials",
|
||||
"measure.at": "At",
|
||||
"measure.item": "Item {number}",
|
||||
"measure.line_to_centre": "Line to center",
|
||||
"measure.quick_copied": "Measurement copied",
|
||||
"measure.quick_copy": "Click to copy the measurement",
|
||||
"measure.quick_edges": "{count} edges",
|
||||
@@ -2337,13 +2331,6 @@
|
||||
"prefs.dialogs_edge": "At the view's edge",
|
||||
"prefs.dialogs_left_column": "In the left column",
|
||||
"prefs.dialogs_note": "Where command dialogs open: docked at the view's edge, or in the left column in place of the browser and the History, which come back as they were when the dialog closes.",
|
||||
"prefs.dimension_step_angle": "What the window's arrows, Up/Down and the wheel add to an angle",
|
||||
"prefs.dimension_step_length": "What the window's arrows, Up/Down and the wheel add to a length",
|
||||
"prefs.dimension_steps": "Window steps",
|
||||
"prefs.dimension_values": "Dimension values",
|
||||
"prefs.dimension_values_note": "Where you type a dimension's value. A window has arrows that step it; the Dimension tool places the text first.",
|
||||
"prefs.dimension_values_view": "In the view",
|
||||
"prefs.dimension_values_window": "In a window",
|
||||
"prefs.display": "Display",
|
||||
"prefs.edges": "Edges and shading",
|
||||
"prefs.extend": "Extend",
|
||||
@@ -4810,7 +4797,6 @@
|
||||
"ui.default_modelling_orientation": "Default modeling orientation",
|
||||
"ui.delete": "Delete",
|
||||
"ui.delete_select_first": "Select what to delete first: features, bodies, components or sketch geometry",
|
||||
"ui.dependents_select_first": "Select what to list the dependents of first",
|
||||
"ui.depth": "Depth",
|
||||
"ui.design": "Design",
|
||||
"ui.design_properties.author": "Author",
|
||||
|
||||
@@ -1358,7 +1358,6 @@
|
||||
"document.read_only_save": "Este diseño se abrió en solo lectura: guárdalo como un archivo nuevo",
|
||||
"document.save_changes": "¿Guardar los cambios de {name}?",
|
||||
"document.save_copy": "Guardar una copia…",
|
||||
"document.tab_unsaved_changes": "Cambios sin guardar",
|
||||
"document.unsaved_changes": "{name} tiene cambios sin guardar. Si no los guardas, se perderán.",
|
||||
"drive.rigid": "{joint} es una junta rígida: mantiene sus partes quietas, así que no tiene nada que impulsar.",
|
||||
"drive.stopped_by_contact": "Detenido en {value}: {a} atravesaría {b}",
|
||||
|
||||
@@ -1391,7 +1391,6 @@
|
||||
"document.read_only_save": "Cette conception a été ouverte en lecture seule : enregistrez-la dans un nouveau fichier",
|
||||
"document.save_changes": "Enregistrer les modifications de {name} ?",
|
||||
"document.save_copy": "Enregistrer une copie…",
|
||||
"document.tab_unsaved_changes": "Modifications non enregistrées",
|
||||
"document.unsaved_changes": "{name} contient des modifications non enregistrées. Si vous n'enregistrez pas, elles seront perdues.",
|
||||
"drive.rigid": "{joint} est une liaison rigide : elle maintient ses pièces immobiles, il n'y a donc rien à piloter. Pour les laisser bouger, changer son type dans Modifier la liaison : Revolute pour tourner, Slider pour coulisser, Cylindrical pour les deux.",
|
||||
"drive.stopped_by_contact": "Arrêté à {value} : {a} traverserait {b}",
|
||||
|
||||
@@ -1396,9 +1396,6 @@
|
||||
"dialog.custom_axis": "↖ ציר מותאם",
|
||||
"dialog.dimension_insert_hint": "יש ללחוץ על מידה מניעה אחרת כדי להוסיף את שמה",
|
||||
"dialog.dimension_name": "מידה: {name}",
|
||||
"dialog.dimension_step_down": "הפחתה ב־{step}",
|
||||
"dialog.dimension_step_expression": "רק מספר משתנה בצעדים; ביטוי נשאר כפי שנכתב",
|
||||
"dialog.dimension_step_up": "הוספה של {step}",
|
||||
"dialog.edit_title": "עריכת {title}",
|
||||
"dialog.no_shown_body": "אין גוף מוצג לשינוי: הציגו גוף, או בחרו „גוף חדש”",
|
||||
"dialog.pattern_sketch_not_object": "בחרו גופים או פיצ'רים: סקיצה אינה אף אחד מהם. חזרו על העקומות שלה בעזרת המערך או השיקוף של הסקיצה עצמה, או בצעו לה שליפה קודם.",
|
||||
@@ -1406,7 +1403,6 @@
|
||||
"dialog.selected_count": "↖ {count} נבחרו",
|
||||
"dialog.selection_constraint": "אילוץ {id}",
|
||||
"dialog.selection_external_curve": "{sketch} · עקומה {id}",
|
||||
"dialog.selection_external_point": "{point} · {sketch}",
|
||||
"dialog.selection_guidance": "יש לבחור קלט כדי לשנות אותו. לחיצה על אובייקט שנבחר מסירה אותו; גם Ctrl/Cmd+לחיצה מחליפה את מצבו. לאחר בדיקת התצוגה המקדימה יש ללחוץ על אישור.",
|
||||
"dialog.selection_midpoint": "נקודת אמצע · {id}",
|
||||
"dialog.selection_profile": "{sketch} · פרופיל {number}",
|
||||
@@ -1424,7 +1420,6 @@
|
||||
"document.read_only_save": "התכן הזה נפתח לקריאה בלבד: יש לשמור אותו כקובץ חדש",
|
||||
"document.save_changes": "לשמור את השינויים ב־{name}?",
|
||||
"document.save_copy": "שמירת עותק…",
|
||||
"document.tab_unsaved_changes": "שינויים שלא נשמרו",
|
||||
"document.unsaved_changes": "ב־{name} יש שינויים שלא נשמרו. ללא שמירה, הם יאבדו.",
|
||||
"drive.rigid": "{joint} הוא מפרק קשיח: הוא מחזיק את החלקים שלו במקומם, ולכן אין מה להניע. כדי שהחלקים יזוזו, שנו את הסוג שלו ב״עריכת מפרק״: Revolute לסיבוב, Slider להחלקה, Cylindrical לשניהם.",
|
||||
"drive.stopped_by_contact": "נעצר ב־{value}: {a} היה עובר דרך {b}",
|
||||
@@ -2004,7 +1999,6 @@
|
||||
"material.selection.mixed": "חומרים שונים",
|
||||
"measure.at": "מיקום",
|
||||
"measure.item": "פריט {number}",
|
||||
"measure.line_to_centre": "מהקו למרכז",
|
||||
"measure.quick_copied": "המדידה הועתקה",
|
||||
"measure.quick_copy": "לחיצה מעתיקה את המדידה",
|
||||
"measure.quick_edges": "{count} מקצועות",
|
||||
@@ -2337,13 +2331,6 @@
|
||||
"prefs.dialogs_edge": "בקצה התצוגה",
|
||||
"prefs.dialogs_left_column": "בעמודה השמאלית",
|
||||
"prefs.dialogs_note": "היכן נפתחות תיבות הדו־שיח של הפקודות: מעוגנות בקצה התצוגה, או בעמודה השמאלית במקום הדפדפן וההיסטוריה, שחוזרים כפי שהיו כשהתיבה נסגרת.",
|
||||
"prefs.dimension_step_angle": "מה שהחצים בחלון, למעלה/למטה והגלגלת מוסיפים לזווית",
|
||||
"prefs.dimension_step_length": "מה שהחצים בחלון, למעלה/למטה והגלגלת מוסיפים לאורך",
|
||||
"prefs.dimension_steps": "צעדי החלון",
|
||||
"prefs.dimension_values": "ערכי מידות",
|
||||
"prefs.dimension_values_note": "היכן מקלידים את ערך המידה. לחלון יש חצים שמשנים אותו בצעדים; כלי המידה מציב קודם את הטקסט.",
|
||||
"prefs.dimension_values_view": "בתצוגה",
|
||||
"prefs.dimension_values_window": "בחלון",
|
||||
"prefs.display": "תצוגה",
|
||||
"prefs.edges": "מקצועות והצללה",
|
||||
"prefs.extend": "הרחבה",
|
||||
@@ -4810,7 +4797,6 @@
|
||||
"ui.default_modelling_orientation": "כיוון מידול ברירת מחדל",
|
||||
"ui.delete": "מחיקה",
|
||||
"ui.delete_select_first": "בחרו קודם מה למחוק: תכונות, גופים, רכיבים או גאומטריית סקיצה",
|
||||
"ui.dependents_select_first": "בחרו קודם מה להציג את התלויים שלו",
|
||||
"ui.depth": "עומק",
|
||||
"ui.design": "תכן",
|
||||
"ui.design_properties.author": "מחבר",
|
||||
|
||||
@@ -1392,7 +1392,6 @@
|
||||
"document.read_only_save": "Questa progettazione è stata aperta in sola lettura: salvala come nuovo file",
|
||||
"document.save_changes": "Salvare le modifiche a {name}?",
|
||||
"document.save_copy": "Salva una copia…",
|
||||
"document.tab_unsaved_changes": "Modifiche non salvate",
|
||||
"document.unsaved_changes": "{name} contiene modifiche non salvate. Se non salvi, andranno perse.",
|
||||
"drive.rigid": "{joint} è un giunto rigido: tiene ferme le sue parti, quindi non c'è nulla da azionare. Per farle muovere, cambia il suo tipo in Modifica giunto: Rotante per ruotare, Scorrevole per scorrere, Cilindrico per entrambi.",
|
||||
"drive.stopped_by_contact": "Arrestato a {value}: {a} attraverserebbe {b}",
|
||||
|
||||
@@ -1396,9 +1396,6 @@
|
||||
"dialog.custom_axis": "↖ カスタム軸",
|
||||
"dialog.dimension_insert_hint": "別の駆動寸法をクリックして名前を挿入",
|
||||
"dialog.dimension_name": "寸法: {name}",
|
||||
"dialog.dimension_step_down": "{step} 減らす",
|
||||
"dialog.dimension_step_expression": "増減できるのは数値だけです。式はそのまま残ります",
|
||||
"dialog.dimension_step_up": "{step} 増やす",
|
||||
"dialog.edit_title": "{title}を編集",
|
||||
"dialog.no_shown_body": "変更できる表示中のボディがありません。ボディを表示するか、新規ボディを選択してください",
|
||||
"dialog.pattern_sketch_not_object": "ボディかフィーチャーを選んでください。スケッチはどちらでもありません。曲線はスケッチ内のパターンかミラーで繰り返すか、先に押し出してください。",
|
||||
@@ -1406,7 +1403,6 @@
|
||||
"dialog.selected_count": "↖ {count} 個選択中",
|
||||
"dialog.selection_constraint": "拘束 {id}",
|
||||
"dialog.selection_external_curve": "{sketch} · 曲線 {id}",
|
||||
"dialog.selection_external_point": "{point} · {sketch}",
|
||||
"dialog.selection_guidance": "入力欄を選んで変更します。選択済みのオブジェクトをクリックすると解除できます。Ctrl/Cmd を押しながらクリックしても切り替えられます。プレビューを確認してから OK を押してください。",
|
||||
"dialog.selection_midpoint": "中点 · {id}",
|
||||
"dialog.selection_profile": "{sketch} · プロファイル {number}",
|
||||
@@ -1424,7 +1420,6 @@
|
||||
"document.read_only_save": "このデザインは読み取り専用で開かれています:新しいファイルとして保存してください",
|
||||
"document.save_changes": "{name} への変更を保存しますか?",
|
||||
"document.save_copy": "コピーを保存…",
|
||||
"document.tab_unsaved_changes": "未保存の変更",
|
||||
"document.unsaved_changes": "{name} には保存されていない変更があります。保存しない場合、変更内容は失われます。",
|
||||
"drive.rigid": "{joint} は剛体ジョイントです。部品をその場に固定するため、駆動するものがありません。動かすには「ジョイントを編集」で種類を変更してください: 回転させるなら Revolute、スライドさせるなら Slider、両方なら Cylindrical。",
|
||||
"drive.stopped_by_contact": "{value} で停止: {a} が {b} を通り抜けてしまいます",
|
||||
@@ -2004,7 +1999,6 @@
|
||||
"material.selection.mixed": "異なる材料",
|
||||
"measure.at": "位置",
|
||||
"measure.item": "項目 {number}",
|
||||
"measure.line_to_centre": "線から中心まで",
|
||||
"measure.quick_copied": "測定値をコピーしました",
|
||||
"measure.quick_copy": "クリックで測定値をコピー",
|
||||
"measure.quick_edges": "エッジ {count} 本",
|
||||
@@ -2337,13 +2331,6 @@
|
||||
"prefs.dialogs_edge": "ビューの端",
|
||||
"prefs.dialogs_left_column": "左の列",
|
||||
"prefs.dialogs_note": "コマンドダイアログを開く場所:ビューの端にドッキングするか、左の列でブラウザーと履歴の代わりに表示します。ダイアログを閉じるとブラウザーと履歴は元どおりに戻ります。",
|
||||
"prefs.dimension_step_angle": "ウィンドウの矢印、上下キー、ホイールで角度に加える量",
|
||||
"prefs.dimension_step_length": "ウィンドウの矢印、上下キー、ホイールで長さに加える量",
|
||||
"prefs.dimension_steps": "ウィンドウの増減量",
|
||||
"prefs.dimension_values": "寸法値の入力",
|
||||
"prefs.dimension_values_note": "寸法値を入力する場所です。ウィンドウでは矢印で値を増減でき、寸法ツールは先に文字を配置します。",
|
||||
"prefs.dimension_values_view": "ビュー内",
|
||||
"prefs.dimension_values_window": "ウィンドウ",
|
||||
"prefs.display": "表示",
|
||||
"prefs.edges": "エッジと陰影",
|
||||
"prefs.extend": "拡張",
|
||||
@@ -4810,7 +4797,6 @@
|
||||
"ui.default_modelling_orientation": "モデリングのデフォルト方向",
|
||||
"ui.delete": "削除",
|
||||
"ui.delete_select_first": "先に削除するものを選択してください: フィーチャ、ボディ、コンポーネント、スケッチ ジオメトリ",
|
||||
"ui.dependents_select_first": "先に、依存先を表示する対象を選択してください",
|
||||
"ui.depth": "深さ",
|
||||
"ui.design": "デザイン",
|
||||
"ui.design_properties.author": "作成者",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1207,9 +1207,6 @@
|
||||
"dialog.custom_axis": "↖ 사용자 정의 축",
|
||||
"dialog.dimension_insert_hint": "다른 구동 치수를 두 번 클릭하여 이름 삽입",
|
||||
"dialog.dimension_name": "치수: {name}",
|
||||
"dialog.dimension_step_down": "{step} 줄이기",
|
||||
"dialog.dimension_step_expression": "숫자만 증감할 수 있으며 수식은 입력한 그대로 유지됩니다",
|
||||
"dialog.dimension_step_up": "{step} 늘리기",
|
||||
"dialog.edit_title": "{title} 편집",
|
||||
"dialog.pattern_sketch_not_object": "바디나 피처를 선택하세요. 스케치는 둘 다 아닙니다. 곡선은 스케치 안의 패턴이나 대칭 복사로 반복하거나, 먼저 돌출하세요.",
|
||||
"dialog.select": "↖ 선택",
|
||||
@@ -1434,7 +1431,6 @@
|
||||
"material.selection.mixed": "서로 다른 재질",
|
||||
"measure.at": "위치",
|
||||
"measure.item": "항목 {number}",
|
||||
"measure.line_to_centre": "선에서 중심까지",
|
||||
"messages.ago_h": "{n}시간 전",
|
||||
"messages.ago_min": "{n}분 전",
|
||||
"messages.ago_s": "{n}초 전",
|
||||
@@ -1724,13 +1720,6 @@
|
||||
"prefs.dialogs_edge": "뷰 가장자리",
|
||||
"prefs.dialogs_left_column": "왼쪽 열",
|
||||
"prefs.dialogs_note": "명령 대화 상자가 열리는 위치: 뷰 가장자리에 고정하거나, 왼쪽 열에서 브라우저와 이력 대신 표시합니다. 대화 상자를 닫으면 브라우저와 이력이 그대로 돌아옵니다.",
|
||||
"prefs.dimension_step_angle": "창의 화살표, 위/아래 키와 휠이 각도에 더하는 값",
|
||||
"prefs.dimension_step_length": "창의 화살표, 위/아래 키와 휠이 길이에 더하는 값",
|
||||
"prefs.dimension_steps": "창의 증감 단위",
|
||||
"prefs.dimension_values": "치수 값",
|
||||
"prefs.dimension_values_note": "치수 값을 입력하는 위치입니다. 창에는 값을 증감하는 화살표가 있으며, 치수 도구는 텍스트를 먼저 배치합니다.",
|
||||
"prefs.dimension_values_view": "뷰에서",
|
||||
"prefs.dimension_values_window": "창에서",
|
||||
"prefs.display": "표시",
|
||||
"prefs.edges": "모서리 및 음영",
|
||||
"prefs.extend": "확장",
|
||||
@@ -3586,7 +3575,6 @@
|
||||
"ui.default_modelling_orientation": "기본 모델링 방향",
|
||||
"ui.delete": "삭제",
|
||||
"ui.delete_select_first": "먼저 삭제할 항목을 선택하세요: 피처, 바디, 컴포넌트 또는 스케치 형상",
|
||||
"ui.dependents_select_first": "먼저 종속 항목을 표시할 대상을 선택하세요",
|
||||
"ui.depth": "깊이",
|
||||
"ui.design": "디자인",
|
||||
"ui.design_properties.author": "작성자",
|
||||
|
||||
@@ -1368,9 +1368,6 @@
|
||||
"dialog.custom_axis": "↖ Aangepaste as",
|
||||
"dialog.dimension_insert_hint": "Klik op een andere aandrijvende maat om de naam ervan in te voegen",
|
||||
"dialog.dimension_name": "Maat: {name}",
|
||||
"dialog.dimension_step_down": "{step} omlaag",
|
||||
"dialog.dimension_step_expression": "Alleen een getal verspringt; een uitdrukking blijft zoals ingevoerd",
|
||||
"dialog.dimension_step_up": "{step} omhoog",
|
||||
"dialog.edit_title": "{title} BEWERKEN",
|
||||
"dialog.no_shown_body": "Geen zichtbaar lichaam om te wijzigen: toon er een of kies Nieuw lichaam",
|
||||
"dialog.pattern_sketch_not_object": "Kies lichamen of kenmerken: een schets is geen van beide. Herhaal de curven ervan met het eigen Patroon of Spiegelen van de schets, of extrudeer de schets eerst.",
|
||||
@@ -1395,7 +1392,6 @@
|
||||
"document.read_only_save": "Dit ontwerp is alleen-lezen geopend: sla het op als nieuw bestand",
|
||||
"document.save_changes": "Wijzigingen in {name} opslaan?",
|
||||
"document.save_copy": "Kopie opslaan…",
|
||||
"document.tab_unsaved_changes": "Niet-opgeslagen wijzigingen",
|
||||
"document.unsaved_changes": "{name} heeft niet-opgeslagen wijzigingen. Als u niet opslaat, gaan ze verloren.",
|
||||
"drive.rigid": "{joint} is een starre verbinding: die houdt zijn onderdelen vast en heeft dus niets om aan te sturen. Wijzig het type in Verbinding bewerken om ze te laten bewegen: Draaiend om te draaien, Schuivend om te schuiven, Cilindrisch voor beide.",
|
||||
"drive.stopped_by_contact": "Gestopt bij {value}: {a} zou door {b} heen gaan",
|
||||
@@ -2198,13 +2194,6 @@
|
||||
"prefs.dialogs_edge": "Aan de rand van de weergave",
|
||||
"prefs.dialogs_left_column": "In de linkerkolom",
|
||||
"prefs.dialogs_note": "Waar opdrachtdialogen openen: vastgezet aan de rand van de weergave, of in de linkerkolom in plaats van de browser en de geschiedenis, die terugkomen zoals ze waren wanneer de dialoog sluit.",
|
||||
"prefs.dimension_step_angle": "Wat de pijlen van het venster, Omhoog/Omlaag en het wieltje bij een hoek optellen",
|
||||
"prefs.dimension_step_length": "Wat de pijlen van het venster, Omhoog/Omlaag en het wieltje bij een lengte optellen",
|
||||
"prefs.dimension_steps": "Stappen in het venster",
|
||||
"prefs.dimension_values": "Maatwaarden",
|
||||
"prefs.dimension_values_note": "Waar de waarde van een maat wordt ingevoerd. Een venster heeft pijlen die de waarde laten verspringen; het maatgereedschap plaatst eerst de tekst.",
|
||||
"prefs.dimension_values_view": "In de weergave",
|
||||
"prefs.dimension_values_window": "In een venster",
|
||||
"prefs.display": "Weergave",
|
||||
"prefs.edges": "Randen en schaduwing",
|
||||
"prefs.extend": "Verlengen",
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"en": {
|
||||
"dialog.dimension_step_down": "Down by {step}",
|
||||
"dialog.dimension_step_expression": "Only a number steps; an expression stays as typed",
|
||||
"dialog.dimension_step_up": "Up by {step}",
|
||||
"prefs.dimension_step_angle": "What the window's arrows, Up/Down and the wheel add to an angle",
|
||||
"prefs.dimension_step_length": "What the window's arrows, Up/Down and the wheel add to a length",
|
||||
"prefs.dimension_steps": "Window steps",
|
||||
"prefs.dimension_values": "Dimension values",
|
||||
"prefs.dimension_values_note": "Where you type a dimension's value. A window has arrows that step it; the Dimension tool places the text first.",
|
||||
"prefs.dimension_values_view": "In the view",
|
||||
"prefs.dimension_values_window": "In a window"
|
||||
},
|
||||
"ja": {
|
||||
"dialog.dimension_step_down": "{step} 減らす",
|
||||
"dialog.dimension_step_expression": "増減できるのは数値だけです。式はそのまま残ります",
|
||||
"dialog.dimension_step_up": "{step} 増やす",
|
||||
"prefs.dimension_step_angle": "ウィンドウの矢印、上下キー、ホイールで角度に加える量",
|
||||
"prefs.dimension_step_length": "ウィンドウの矢印、上下キー、ホイールで長さに加える量",
|
||||
"prefs.dimension_steps": "ウィンドウの増減量",
|
||||
"prefs.dimension_values": "寸法値の入力",
|
||||
"prefs.dimension_values_note": "寸法値を入力する場所です。ウィンドウでは矢印で値を増減でき、寸法ツールは先に文字を配置します。",
|
||||
"prefs.dimension_values_view": "ビュー内",
|
||||
"prefs.dimension_values_window": "ウィンドウ"
|
||||
},
|
||||
"zh": {
|
||||
"dialog.dimension_step_down": "减少 {step}",
|
||||
"dialog.dimension_step_expression": "只有数字可以增减;表达式保持不变",
|
||||
"dialog.dimension_step_up": "增加 {step}",
|
||||
"prefs.dimension_step_angle": "窗口的箭头、上下方向键和滚轮给角度增减的量",
|
||||
"prefs.dimension_step_length": "窗口的箭头、上下方向键和滚轮给长度增减的量",
|
||||
"prefs.dimension_steps": "窗口步长",
|
||||
"prefs.dimension_values": "尺寸值",
|
||||
"prefs.dimension_values_note": "输入尺寸值的位置。窗口带有可增减数值的箭头;尺寸工具会先放置文字。",
|
||||
"prefs.dimension_values_view": "在视图中",
|
||||
"prefs.dimension_values_window": "在窗口中"
|
||||
},
|
||||
"de": {
|
||||
"dialog.dimension_step_down": "Um {step} verringern",
|
||||
"dialog.dimension_step_expression": "Nur eine Zahl lässt sich schrittweise ändern; ein Ausdruck bleibt, wie er eingegeben ist",
|
||||
"dialog.dimension_step_up": "Um {step} erhöhen",
|
||||
"prefs.dimension_step_angle": "Was die Pfeile des Fensters, Auf/Ab und das Mausrad zu einem Winkel addieren",
|
||||
"prefs.dimension_step_length": "Was die Pfeile des Fensters, Auf/Ab und das Mausrad zu einer Länge addieren",
|
||||
"prefs.dimension_steps": "Schritte im Fenster",
|
||||
"prefs.dimension_values": "Bemaßungswerte",
|
||||
"prefs.dimension_values_note": "Wo der Wert einer Bemaßung eingegeben wird. Ein Fenster hat Pfeile, die ihn schrittweise ändern; das Bemaßungswerkzeug platziert zuerst den Text.",
|
||||
"prefs.dimension_values_view": "In der Ansicht",
|
||||
"prefs.dimension_values_window": "In einem Fenster"
|
||||
},
|
||||
"ko": {
|
||||
"dialog.dimension_step_down": "{step} 줄이기",
|
||||
"dialog.dimension_step_expression": "숫자만 증감할 수 있으며 수식은 입력한 그대로 유지됩니다",
|
||||
"dialog.dimension_step_up": "{step} 늘리기",
|
||||
"prefs.dimension_step_angle": "창의 화살표, 위/아래 키와 휠이 각도에 더하는 값",
|
||||
"prefs.dimension_step_length": "창의 화살표, 위/아래 키와 휠이 길이에 더하는 값",
|
||||
"prefs.dimension_steps": "창의 증감 단위",
|
||||
"prefs.dimension_values": "치수 값",
|
||||
"prefs.dimension_values_note": "치수 값을 입력하는 위치입니다. 창에는 값을 증감하는 화살표가 있으며, 치수 도구는 텍스트를 먼저 배치합니다.",
|
||||
"prefs.dimension_values_view": "뷰에서",
|
||||
"prefs.dimension_values_window": "창에서"
|
||||
},
|
||||
"nl": {
|
||||
"dialog.dimension_step_down": "{step} omlaag",
|
||||
"dialog.dimension_step_expression": "Alleen een getal verspringt; een uitdrukking blijft zoals ingevoerd",
|
||||
"dialog.dimension_step_up": "{step} omhoog",
|
||||
"prefs.dimension_step_angle": "Wat de pijlen van het venster, Omhoog/Omlaag en het wieltje bij een hoek optellen",
|
||||
"prefs.dimension_step_length": "Wat de pijlen van het venster, Omhoog/Omlaag en het wieltje bij een lengte optellen",
|
||||
"prefs.dimension_steps": "Stappen in het venster",
|
||||
"prefs.dimension_values": "Maatwaarden",
|
||||
"prefs.dimension_values_note": "Waar de waarde van een maat wordt ingevoerd. Een venster heeft pijlen die de waarde laten verspringen; het maatgereedschap plaatst eerst de tekst.",
|
||||
"prefs.dimension_values_view": "In de weergave",
|
||||
"prefs.dimension_values_window": "In een venster"
|
||||
},
|
||||
"ar": {
|
||||
"dialog.dimension_step_down": "إنقاص بمقدار {step}",
|
||||
"dialog.dimension_step_expression": "يتغير الرقم فقط بالخطوات؛ يبقى التعبير كما كُتب",
|
||||
"dialog.dimension_step_up": "زيادة بمقدار {step}",
|
||||
"prefs.dimension_step_angle": "ما تضيفه أسهم النافذة وأعلى/أسفل والعجلة إلى الزاوية",
|
||||
"prefs.dimension_step_length": "ما تضيفه أسهم النافذة وأعلى/أسفل والعجلة إلى الطول",
|
||||
"prefs.dimension_steps": "خطوات النافذة",
|
||||
"prefs.dimension_values": "قيم الأبعاد",
|
||||
"prefs.dimension_values_note": "مكان كتابة قيمة البعد. للنافذة أسهم تغيّرها بخطوات؛ تضع أداة البعد النص أولًا.",
|
||||
"prefs.dimension_values_view": "في العرض",
|
||||
"prefs.dimension_values_window": "في نافذة"
|
||||
},
|
||||
"he": {
|
||||
"dialog.dimension_step_down": "הפחתה ב־{step}",
|
||||
"dialog.dimension_step_expression": "רק מספר משתנה בצעדים; ביטוי נשאר כפי שנכתב",
|
||||
"dialog.dimension_step_up": "הוספה של {step}",
|
||||
"prefs.dimension_step_angle": "מה שהחצים בחלון, למעלה/למטה והגלגלת מוסיפים לזווית",
|
||||
"prefs.dimension_step_length": "מה שהחצים בחלון, למעלה/למטה והגלגלת מוסיפים לאורך",
|
||||
"prefs.dimension_steps": "צעדי החלון",
|
||||
"prefs.dimension_values": "ערכי מידות",
|
||||
"prefs.dimension_values_note": "היכן מקלידים את ערך המידה. לחלון יש חצים שמשנים אותו בצעדים; כלי המידה מציב קודם את הטקסט.",
|
||||
"prefs.dimension_values_view": "בתצוגה",
|
||||
"prefs.dimension_values_window": "בחלון"
|
||||
},
|
||||
"vi": {
|
||||
"dialog.dimension_step_down": "Giảm {step}",
|
||||
"dialog.dimension_step_expression": "Chỉ số mới thay đổi theo bước; biểu thức giữ nguyên như đã nhập",
|
||||
"dialog.dimension_step_up": "Tăng {step}",
|
||||
"prefs.dimension_step_angle": "Lượng mà mũi tên của cửa sổ, Lên/Xuống và con lăn cộng vào một góc",
|
||||
"prefs.dimension_step_length": "Lượng mà mũi tên của cửa sổ, Lên/Xuống và con lăn cộng vào một độ dài",
|
||||
"prefs.dimension_steps": "Bước của cửa sổ",
|
||||
"prefs.dimension_values": "Giá trị kích thước",
|
||||
"prefs.dimension_values_note": "Nơi bạn nhập giá trị kích thước. Cửa sổ có mũi tên để thay đổi theo bước; công cụ Kích thước đặt văn bản trước.",
|
||||
"prefs.dimension_values_view": "Trong khung nhìn",
|
||||
"prefs.dimension_values_window": "Trong một cửa sổ"
|
||||
}
|
||||
}
|
||||
@@ -1347,7 +1347,6 @@
|
||||
"document.read_only_save": "Ten projekt otwarto tylko do odczytu: zapisz go jako nowy plik",
|
||||
"document.save_changes": "Zapisać zmiany w {name}?",
|
||||
"document.save_copy": "Zapisz kopię…",
|
||||
"document.tab_unsaved_changes": "Niezapisane zmiany",
|
||||
"document.unsaved_changes": "{name} ma niezapisane zmiany. Jeśli ich nie zapiszesz, zostaną utracone.",
|
||||
"drive.rigid": "{joint} to złącze sztywne: utrzymuje swoje części nieruchomo, więc nie ma czym sterować. Aby mogły się poruszać, zmień jego typ w Edytuj złącze: Obrotowe do obrotu, Przesuwne do przesuwu, Walcowe do obu.",
|
||||
"drive.stopped_by_contact": "Zatrzymano przy {value}: {a} przeszedłby przez {b}",
|
||||
|
||||
@@ -1722,7 +1722,7 @@
|
||||
"help.view.tool": "Закрепляет инструмент камеры (орбиту, панорамирование или масштаб), так что перетаскивание левой кнопкой мыши двигает вид, пока вы его не отключите. Используйте на трекпаде или без средней кнопки мыши.",
|
||||
"help.view.visual_style": "Переключает способ отрисовки модели: тонированная с рёбрами или без них, каркас или со скрытыми рёбрами. Используйте каркас, чтобы заглянуть внутрь, а тонированный вид — для чистого вида.",
|
||||
"history.sketch_under_constrained": "Недоопределён (степеней свободы: {count})",
|
||||
"history.sketch_under_constrained_one": "Недоопределён (степеней свободы: {count})",
|
||||
"history.sketch_under_constrained_one": "Недоопределён ({count} степень свободы)",
|
||||
"hole_presets.confirm_delete": "Подтвердить удаление",
|
||||
"hole_presets.delete": "Удалить",
|
||||
"hole_presets.delete_prompt": "Удалить этот личный пресет? Существующие отверстия не изменятся.",
|
||||
@@ -2135,7 +2135,7 @@
|
||||
"prefs.applies_on_restart": "Применяется после перезапуска",
|
||||
"prefs.assistant": "Ассистент",
|
||||
"prefs.auto_operation": "Выбирать операцию автоматически",
|
||||
"prefs.auto_operation_note": "Окна операций сами выбирают «Объединить», «Новое тело» или «Вырезать» в зависимости от того, чего касается новый материал, пока вы не выберете операцию вручную. Выкл.: окна сохраняют ту операцию, с которой открылись.",
|
||||
"prefs.auto_operation_note": "Выбор «Объединить», «Новое тело» или «Вырезать» по тому, чего касается материал.",
|
||||
"prefs.axes": "Оси",
|
||||
"prefs.axis": "Ось",
|
||||
"prefs.bodies_folders": "Папки тел",
|
||||
@@ -2374,7 +2374,7 @@
|
||||
"prefs.stand_up_unknown.ask": "Спрашивать в диалоговом окне",
|
||||
"prefs.stand_up_unknown.note": "Показывать уведомление",
|
||||
"prefs.stand_up_unknown.off": "Не предлагать",
|
||||
"prefs.stand_up_unknown_note": "Как делается предложение, когда в заголовке файла не указана программа, о которой известно, что она сохраняет с осью Y вверх. Уведомление показывается, только если файл, судя по всему, лежит на спине; «Спрашивать в диалоговом окне» спрашивает о каждом таком файле, как раньше.",
|
||||
"prefs.stand_up_unknown_note": "Как делается предложение, когда в файле не указана известная программа.",
|
||||
"prefs.status_bar": "Строка состояния",
|
||||
"prefs.status_bar_note": "Тонкая полоса вдоль нижнего края окна показывает, что изменяется, состояние открытого эскиза его цветом и единицы дизайна; щелчок по единицам изменяет их. Состояние эскиза при этом больше не показывается в углу вида.",
|
||||
"prefs.status_done": "Готово",
|
||||
@@ -3878,7 +3878,7 @@
|
||||
"status.navigation": "Назначение кнопок мыши: {mapping}",
|
||||
"status.navigation_tip": "Как мышь двигает вид. Щёлкните, чтобы выбрать другое назначение",
|
||||
"status.under_constrained": "Недоопределён (степеней свободы: {count})",
|
||||
"status.under_constrained_one": "Недоопределён (степеней свободы: {count})",
|
||||
"status.under_constrained_one": "Недоопределён ({count} степень свободы)",
|
||||
"status.units_tip": "Единицы дизайна. Щёлкните, чтобы изменить",
|
||||
"the angle must be a number": "угол должен быть числом",
|
||||
"the curves must be in one sketch": "кривые должны быть в одном эскизе",
|
||||
|
||||
@@ -1392,7 +1392,6 @@
|
||||
"document.read_only_save": "Den här konstruktionen öppnades skrivskyddad: spara den som en ny fil",
|
||||
"document.save_changes": "Spara ändringar i {name}?",
|
||||
"document.save_copy": "Spara en kopia…",
|
||||
"document.tab_unsaved_changes": "Osparade ändringar",
|
||||
"document.unsaved_changes": "{name} har osparade ändringar. Om du inte sparar går de förlorade.",
|
||||
"drive.rigid": "{joint} är ett stelt led: det håller sina delar stilla och har därför inget att driva. Om delarna ska kunna röra sig, ändra ledets typ i Redigera led: Roterande för att rotera, Glidande för att glida, Cylindrisk för båda.",
|
||||
"drive.stopped_by_contact": "Stoppade vid {value}: {a} skulle gå igenom {b}",
|
||||
|
||||
@@ -1396,9 +1396,6 @@
|
||||
"dialog.custom_axis": "↖ Trục tùy chỉnh",
|
||||
"dialog.dimension_insert_hint": "Nhấp vào một kích thước điều khiển khác để chèn tên của nó",
|
||||
"dialog.dimension_name": "Kích thước: {name}",
|
||||
"dialog.dimension_step_down": "Giảm {step}",
|
||||
"dialog.dimension_step_expression": "Chỉ số mới thay đổi theo bước; biểu thức giữ nguyên như đã nhập",
|
||||
"dialog.dimension_step_up": "Tăng {step}",
|
||||
"dialog.edit_title": "CHỈNH SỬA {title}",
|
||||
"dialog.no_shown_body": "Không có thân khối đang hiển thị để thay đổi: hãy hiển thị một thân khối, hoặc chọn “Tạo mới Thân khối”",
|
||||
"dialog.pattern_sketch_not_object": "Hãy chọn thân khối hoặc tính năng: bản phác thảo không phải là cả hai. Lặp lại các đường cong của nó bằng Mảng hoặc Lấy đối xứng trong chính bản phác thảo, hoặc đùn khối nó trước.",
|
||||
@@ -1406,7 +1403,6 @@
|
||||
"dialog.selected_count": "↖ {count} đã chọn",
|
||||
"dialog.selection_constraint": "Ràng buộc {id}",
|
||||
"dialog.selection_external_curve": "{sketch} · đường cong {id}",
|
||||
"dialog.selection_external_point": "{point} · {sketch}",
|
||||
"dialog.selection_guidance": "Chọn một mục đầu vào để thay đổi. Nhấp vào đối tượng đã chọn để bỏ chọn; Ctrl/Cmd-nhấp để bật/tắt chọn. Xem trước kết quả, sau đó nhấn OK.",
|
||||
"dialog.selection_midpoint": "Trung điểm · {id}",
|
||||
"dialog.selection_profile": "{sketch} · biên dạng {number}",
|
||||
@@ -1424,7 +1420,6 @@
|
||||
"document.read_only_save": "Thiết kế này được mở ở chế độ chỉ đọc: hãy lưu thành tệp mới",
|
||||
"document.save_changes": "Lưu các thay đổi đối với {name}?",
|
||||
"document.save_copy": "Lưu bản sao…",
|
||||
"document.tab_unsaved_changes": "Thay đổi chưa lưu",
|
||||
"document.unsaved_changes": "{name} có các thay đổi chưa lưu. Nếu không lưu, các thay đổi sẽ bị mất.",
|
||||
"drive.rigid": "{joint} là khớp nối cứng: nó giữ cố định các chi tiết, nên không có gì để dẫn động. Để các chi tiết chuyển động, hãy đổi loại khớp nối trong “Chỉnh sửa khớp nối”: Revolute để xoay, Slider để trượt, Cylindrical cho cả hai.",
|
||||
"drive.stopped_by_contact": "Đã dừng tại {value}: {a} sẽ xuyên qua {b}",
|
||||
@@ -2004,7 +1999,6 @@
|
||||
"material.selection.mixed": "Các vật liệu khác nhau",
|
||||
"measure.at": "Tại",
|
||||
"measure.item": "Mục {number}",
|
||||
"measure.line_to_centre": "Từ đường đến tâm",
|
||||
"measure.quick_copied": "Đã sao chép số đo",
|
||||
"measure.quick_copy": "Nhấp để sao chép số đo",
|
||||
"measure.quick_edges": "{count} cạnh",
|
||||
@@ -2337,13 +2331,6 @@
|
||||
"prefs.dialogs_edge": "Ở cạnh khung nhìn",
|
||||
"prefs.dialogs_left_column": "Trong cột bên trái",
|
||||
"prefs.dialogs_note": "Nơi hộp thoại lệnh mở ra: gắn ở cạnh khung nhìn, hoặc trong cột bên trái thay cho trình duyệt và Lịch sử, hai phần này trở lại như cũ khi hộp thoại đóng.",
|
||||
"prefs.dimension_step_angle": "Lượng mà mũi tên của cửa sổ, Lên/Xuống và con lăn cộng vào một góc",
|
||||
"prefs.dimension_step_length": "Lượng mà mũi tên của cửa sổ, Lên/Xuống và con lăn cộng vào một độ dài",
|
||||
"prefs.dimension_steps": "Bước của cửa sổ",
|
||||
"prefs.dimension_values": "Giá trị kích thước",
|
||||
"prefs.dimension_values_note": "Nơi bạn nhập giá trị kích thước. Cửa sổ có mũi tên để thay đổi theo bước; công cụ Kích thước đặt văn bản trước.",
|
||||
"prefs.dimension_values_view": "Trong khung nhìn",
|
||||
"prefs.dimension_values_window": "Trong một cửa sổ",
|
||||
"prefs.display": "Hiển thị",
|
||||
"prefs.edges": "Cạnh và đổ bóng",
|
||||
"prefs.extend": "Kéo dài",
|
||||
@@ -4810,7 +4797,6 @@
|
||||
"ui.default_modelling_orientation": "Hướng mô hình hóa mặc định",
|
||||
"ui.delete": "Xóa",
|
||||
"ui.delete_select_first": "Hãy chọn trước những gì cần xóa: tính năng, khối, thành phần hoặc hình học phác thảo",
|
||||
"ui.dependents_select_first": "Hãy chọn trước đối tượng để liệt kê các phần phụ thuộc của nó",
|
||||
"ui.depth": "Độ sâu",
|
||||
"ui.design": "Thiết kế",
|
||||
"ui.design_properties.author": "Tác giả",
|
||||
|
||||
@@ -1396,9 +1396,6 @@
|
||||
"dialog.custom_axis": "↖ 自定义轴",
|
||||
"dialog.dimension_insert_hint": "单击其他驱动尺寸以插入其名称",
|
||||
"dialog.dimension_name": "尺寸:{name}",
|
||||
"dialog.dimension_step_down": "减少 {step}",
|
||||
"dialog.dimension_step_expression": "只有数字可以增减;表达式保持不变",
|
||||
"dialog.dimension_step_up": "增加 {step}",
|
||||
"dialog.edit_title": "编辑 {title}",
|
||||
"dialog.no_shown_body": "没有可更改的显示实体:请显示一个实体,或选择新建实体",
|
||||
"dialog.pattern_sketch_not_object": "请选择实体或特征:草图两者都不是。请用草图自身的阵列或镜像重复其曲线,或先将其拉伸。",
|
||||
@@ -1406,7 +1403,6 @@
|
||||
"dialog.selected_count": "↖ 已选择 {count} 个",
|
||||
"dialog.selection_constraint": "约束 {id}",
|
||||
"dialog.selection_external_curve": "{sketch} · 曲线 {id}",
|
||||
"dialog.selection_external_point": "{point} · {sketch}",
|
||||
"dialog.selection_guidance": "选择输入项进行修改。点击已选对象可取消选择;按住 Ctrl/Cmd 点击也可切换选择。检查预览后,再按“确定”。",
|
||||
"dialog.selection_midpoint": "中点 · {id}",
|
||||
"dialog.selection_profile": "{sketch} · 轮廓 {number}",
|
||||
@@ -1424,7 +1420,6 @@
|
||||
"document.read_only_save": "此设计以只读方式打开:请另存为新文件",
|
||||
"document.save_changes": "保存对 {name} 的更改?",
|
||||
"document.save_copy": "保存副本…",
|
||||
"document.tab_unsaved_changes": "未保存的更改",
|
||||
"document.unsaved_changes": "{name} 有未保存的更改。如果不保存,将丢失这些更改。",
|
||||
"drive.rigid": "{joint} 是刚性接头:它把零件固定在原位,因此没有可驱动的运动。要让零件运动,请在“编辑接头”中更改其类型:旋转用 Revolute,滑动用 Slider,两者兼有用 Cylindrical。",
|
||||
"drive.stopped_by_contact": "停在 {value}:{a} 会穿过 {b}",
|
||||
@@ -2004,7 +1999,6 @@
|
||||
"material.selection.mixed": "不同材料",
|
||||
"measure.at": "位置",
|
||||
"measure.item": "项目 {number}",
|
||||
"measure.line_to_centre": "线到圆心",
|
||||
"measure.quick_copied": "已复制测量值",
|
||||
"measure.quick_copy": "点击复制测量值",
|
||||
"measure.quick_edges": "{count} 条边",
|
||||
@@ -2337,13 +2331,6 @@
|
||||
"prefs.dialogs_edge": "视图边缘",
|
||||
"prefs.dialogs_left_column": "左侧栏",
|
||||
"prefs.dialogs_note": "命令对话框的打开位置:停靠在视图边缘,或在左侧栏中替代浏览器和历史;关闭对话框后,浏览器和历史恢复原样。",
|
||||
"prefs.dimension_step_angle": "窗口的箭头、上下方向键和滚轮给角度增减的量",
|
||||
"prefs.dimension_step_length": "窗口的箭头、上下方向键和滚轮给长度增减的量",
|
||||
"prefs.dimension_steps": "窗口步长",
|
||||
"prefs.dimension_values": "尺寸值",
|
||||
"prefs.dimension_values_note": "输入尺寸值的位置。窗口带有可增减数值的箭头;尺寸工具会先放置文字。",
|
||||
"prefs.dimension_values_view": "在视图中",
|
||||
"prefs.dimension_values_window": "在窗口中",
|
||||
"prefs.display": "显示",
|
||||
"prefs.edges": "边缘与着色",
|
||||
"prefs.extend": "扩展",
|
||||
@@ -4810,7 +4797,6 @@
|
||||
"ui.default_modelling_orientation": "默认建模方向",
|
||||
"ui.delete": "删除",
|
||||
"ui.delete_select_first": "请先选择要删除的内容:特征、实体、组件或草图几何",
|
||||
"ui.dependents_select_first": "请先选择要列出其依赖项的对象",
|
||||
"ui.depth": "深度",
|
||||
"ui.design": "设计",
|
||||
"ui.design_properties.author": "作者",
|
||||
|
||||
@@ -802,9 +802,7 @@ fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3<f32>, @location(
|
||||
let sb = cb.xy / max(cb.w, 1e-6) * half;
|
||||
var dir = sb - sa;
|
||||
let len = length(dir);
|
||||
// Intel OpenGL can miscompile vector/scalar division here (#8053). Scalar
|
||||
// quotients keep the same direction and the fallback for a point.
|
||||
if (len < 1e-6) { dir = vec2<f32>(1.0, 0.0); } else { dir = vec2<f32>(dir.x / len, dir.y / len); }
|
||||
if (len < 1e-6) { dir = vec2<f32>(1.0, 0.0); } else { dir = dir / len; }
|
||||
let nrm = vec2<f32>(-dir.y, dir.x);
|
||||
// Two triangles: corners (t, side).
|
||||
var ts = array<f32, 6>(0.0, 1.0, 1.0, 0.0, 1.0, 0.0);
|
||||
|
||||
@@ -757,19 +757,10 @@ pub(crate) fn order_lumps(bodies: &mut [crate::ModelBody], origin: &Arc<crate::n
|
||||
// cut body's own feature's and the rest are the cut's, and `body.rename` stores a name in
|
||||
// the `body_names` of the feature a body says made it (#10438).
|
||||
let names: Vec<(String, u64)> = members.iter().filter_map(|i| bodies.get(*i).map(|b| (b.name.clone(), b.feature))).collect();
|
||||
// The pieces take the family's places in that order too, so the state lists them as
|
||||
// their names run. The session pins a feature's body names in state order, and the next
|
||||
// evaluation hands them out in this order: listed in the kernel's order, the pieces
|
||||
// swapped names once the design was evaluated again from its pinned names (opened).
|
||||
let moved: Vec<crate::ModelBody> = keys.iter().filter_map(|(_, i)| bodies.get(*i).cloned()).collect();
|
||||
if moved.len() != members.len() || names.len() != members.len() {
|
||||
continue;
|
||||
}
|
||||
for ((mut b, (name, feature)), place) in moved.into_iter().zip(names).zip(&members) {
|
||||
b.name = name;
|
||||
b.feature = feature;
|
||||
if let Some(slot) = bodies.get_mut(*place) {
|
||||
*slot = b;
|
||||
for ((name, feature), (_, i)) in names.into_iter().zip(keys) {
|
||||
if let Some(b) = bodies.get_mut(i) {
|
||||
b.name = name;
|
||||
b.feature = feature;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! `solidcraft-cli mcp setup` and Help > Connect AI assistants: add a `solidcraft` MCP server entry
|
||||
//! to the AI clients installed on this machine, using the `solidcraft-cli` path given.
|
||||
//!
|
||||
//! Only the `solidcraft` entries change; every other byte (other servers, other settings, comments,
|
||||
//! key order) stays as it was. Updates replace the file atomically, keeping the previous version
|
||||
//! as `<name>.bak`. A file that cannot be read as JSON (with comments) or TOML is
|
||||
//! The clients' own files are edited in place, not rewritten: only the `solidcraft` entries change,
|
||||
//! every other byte (other servers, other settings, comments, key order) stays as it was. The file
|
||||
//! is copied to `<name>.bak` first. A file that cannot be read as JSON (with comments) or TOML is
|
||||
//! left alone, and so is an entry the user changed (its command is not a SolidCraft CLI, or its
|
||||
//! arguments differ); an entry that only has an old path to the CLI is updated.
|
||||
//! A client counts as installed when its configuration folder exists; no client program is run.
|
||||
@@ -606,7 +606,11 @@ fn toml_to_json(t: &toml_edit::Table) -> Value {
|
||||
/// One client's outcome, a line of the report.
|
||||
fn configure_file(t: &Target, exe: &str, live: bool, dry_run: bool, mode: Mode) -> Result<(String, Vec<(&'static str, Change)>), String> {
|
||||
let fail = |e: String| format!("{}: {e}; left unchanged", t.file.display());
|
||||
let existing = read_config(&t.file).map_err(|e| format!("{}: {e}", t.file.display()))?;
|
||||
let existing = match std::fs::read_to_string(&t.file) {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(format!("{}: {e}", t.file.display())),
|
||||
};
|
||||
let mut text = existing.clone().unwrap_or_default();
|
||||
let changes =
|
||||
if t.shape == Shape::Codex { edit_toml(&mut text, exe, live, mode) } else { edit_json(&mut text, t.shape, exe, live, mode) }.map_err(fail)?;
|
||||
@@ -629,30 +633,20 @@ fn configure_file(t: &Target, exe: &str, live: bool, dry_run: bool, mode: Mode)
|
||||
if !changed {
|
||||
return done(said);
|
||||
}
|
||||
write_config(&t.file, existing.as_deref(), &text).map_err(|e| format!("{}: {e}", t.file.display()))?;
|
||||
if existing.is_some() {
|
||||
let mut bak = t.file.clone().into_os_string();
|
||||
bak.push(".bak");
|
||||
// The first backup is the user's original: later runs keep it.
|
||||
if !Path::new(&bak).exists() {
|
||||
std::fs::copy(&t.file, &bak).map_err(|e| format!("{}: cannot back up: {e}", t.file.display()))?;
|
||||
}
|
||||
} else if let Some(dir) = t.file.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
std::fs::write(&t.file, text).map_err(|e| format!("{}: {e}", t.file.display()))?;
|
||||
done(format!("{said} ({})", t.file.display()))
|
||||
}
|
||||
|
||||
fn read_config(path: &Path) -> std::io::Result<Option<String>> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => Ok(Some(text)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse a stale edit before writing. Other clients do not share a lock with SolidCraft, so
|
||||
/// this narrows the race with their writes; it cannot exclude a write after the comparison.
|
||||
fn write_config(path: &Path, expected: Option<&str>, text: &str) -> std::io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
if read_config(path)?.as_deref() != expected {
|
||||
return Err(std::io::Error::other("configuration changed during setup; left unchanged, try again"));
|
||||
}
|
||||
solidcraft_io::safe_write::write_atomic(path, text.as_bytes(), true)
|
||||
}
|
||||
|
||||
/// Set up every detected client (or `--client NAME`; flags `--dry-run`, `--live`) for the
|
||||
/// `solidcraft-cli` at `exe`. Returns the result lines, one per client file, and whether every
|
||||
/// client succeeded.
|
||||
@@ -848,96 +842,6 @@ mod tests {
|
||||
assert!(toml.contains("[mcp_servers.other]") && !toml.contains("solidcraft"), "{toml}");
|
||||
}
|
||||
|
||||
fn config_dir(label: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-agent-config-{label}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_backup_tracks_the_file_before_each_change() {
|
||||
let dir = config_dir("backup");
|
||||
let target = Target { client: "claude-code", file: dir.join("config.json"), shape: Shape::ClaudeCode };
|
||||
let backup = dir.join("config.json.bak");
|
||||
let original = "{\n \"personal_setting\": true\n}\n";
|
||||
std::fs::write(&target.file, original).unwrap();
|
||||
configure_file(&target, EXE, false, false, Mode::Add).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&backup).unwrap(), original);
|
||||
let changed_by_client = std::fs::read_to_string(&target.file).unwrap().replace("true", "false");
|
||||
std::fs::write(&target.file, &changed_by_client).unwrap();
|
||||
configure_file(&target, "/new/solidcraft-cli", false, false, Mode::Add).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&backup).unwrap(), changed_by_client);
|
||||
let current = std::fs::read_to_string(&target.file).unwrap();
|
||||
assert!(current.contains("false") && current.contains("/new/solidcraft-cli"));
|
||||
// A no-op or dry run must leave both the file and its backup alone.
|
||||
configure_file(&target, "/new/solidcraft-cli", false, false, Mode::Add).unwrap();
|
||||
configure_file(&target, "/future/solidcraft-cli", false, true, Mode::Add).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&target.file).unwrap(), current);
|
||||
assert_eq!(std::fs::read_to_string(&backup).unwrap(), changed_by_client);
|
||||
configure_file(&target, "/new/solidcraft-cli", false, false, Mode::Remove).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&backup).unwrap(), current);
|
||||
assert!(!std::fs::read_to_string(&target.file).unwrap().contains("solidcraft"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_is_unchanged_when_its_backup_cannot_be_replaced() {
|
||||
let dir = config_dir("backup-failure");
|
||||
let target = Target { client: "codex", file: dir.join("config.toml"), shape: Shape::Codex };
|
||||
let original = "# personal setting\nmodel = \"custom\"\n";
|
||||
std::fs::write(&target.file, original).unwrap();
|
||||
std::fs::create_dir(dir.join("config.toml.bak")).unwrap();
|
||||
assert!(configure_file(&target, EXE, false, false, Mode::Add).is_err());
|
||||
assert_eq!(std::fs::read_to_string(&target.file).unwrap(), original);
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "no temporary files remain");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_refuses_an_edit_based_on_an_outdated_read() {
|
||||
let dir = config_dir("concurrent");
|
||||
let path = dir.join("config.json");
|
||||
let backup = dir.join("config.json.bak");
|
||||
std::fs::write(&backup, "previous backup").unwrap();
|
||||
for (expected, current) in [(Some("old"), Some("new")), (Some("old"), None), (None, Some("new"))] {
|
||||
if let Some(text) = current {
|
||||
std::fs::write(&path, text).unwrap();
|
||||
} else {
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
}
|
||||
let error = write_config(&path, expected, "stale edit").unwrap_err();
|
||||
assert!(error.to_string().contains("configuration changed"), "{error}");
|
||||
assert_eq!(read_config(&path).unwrap().as_deref(), current);
|
||||
assert_eq!(std::fs::read_to_string(&backup).unwrap(), "previous backup");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn configuration_replaces_a_complete_file_and_keeps_links_and_permissions() {
|
||||
use std::io::Read;
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
let dir = config_dir("atomic");
|
||||
let real = dir.join("real.json");
|
||||
let target = Target { client: "claude-code", file: dir.join("config.json"), shape: Shape::ClaudeCode };
|
||||
let original = "{\"personal_setting\": true}\n";
|
||||
std::fs::write(&real, original).unwrap();
|
||||
std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
symlink("real.json", &target.file).unwrap();
|
||||
let mut reader = std::fs::File::open(&target.file).unwrap();
|
||||
configure_file(&target, EXE, false, false, Mode::Add).unwrap();
|
||||
let mut old = String::new();
|
||||
reader.read_to_string(&mut old).unwrap();
|
||||
assert_eq!(old, original, "an existing reader sees the complete old file");
|
||||
assert!(std::fs::read_to_string(&target.file).unwrap().contains(EXE));
|
||||
assert!(std::fs::symlink_metadata(&target.file).unwrap().file_type().is_symlink());
|
||||
assert_eq!(std::fs::metadata(&real).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
assert_eq!(std::fs::read_to_string(dir.join("real.json.bak")).unwrap(), original);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_adds_only_the_plain_entry_and_names_new_clients_once() {
|
||||
let h = std::env::temp_dir().join(format!("solidcraft-quiet-{}", std::process::id()));
|
||||
|
||||
@@ -64,7 +64,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
or features?: [feature id or name] (the bodies they made; path?: [occurrence ids] for only that placement's); \
|
||||
or folder?: bodies|sketches|construction|origin|joints|all (all of it; showing also shows its browser folders and groups, and `all` takes in the occurrence hides too); \
|
||||
or tree_folders?: [browser folder or group key: c0/bodies, o:4/sketches, c0/g7, c0/joints, c0/canvases, c0/point_clouds] (the folder or group itself: its items keep their own state); \
|
||||
or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (with paths or items of bodies, and visible:true: everything else is hidden, other placements as placements). Saved with the design and undone like any other change (the camera is not)",
|
||||
or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). Saved with the design and undone like any other change (the camera is not)",
|
||||
),
|
||||
CommandSpec::new("browser.expand_all", "Expand All", expand_all).icon("folder").noundo().params(
|
||||
"path?: [occurrence ids] (that placement and its descendants); component?: id (that component's own folders); \
|
||||
@@ -436,89 +436,6 @@ fn tree_folder_key(k: &str) -> bool {
|
||||
k.len() <= 2048 && scope_ok && last_ok
|
||||
}
|
||||
|
||||
/// Isolate bodies (#7896): the picked bodies stay, and so do the placements that hold them, down
|
||||
/// to their own; those placements' other bodies and the root's other bodies are hidden one by
|
||||
/// one, and every other placement is hidden as a placement, so its Browser row shows it is off and
|
||||
/// its eye brings it back.
|
||||
fn isolate_bodies(s: &mut Session, p: &Value, visible: bool) -> Result<Value> {
|
||||
let cmd = "browser.visibility";
|
||||
if !visible {
|
||||
return Err(bad(cmd, "isolate requires visible:true"));
|
||||
}
|
||||
if ["folder", "features", "joints"].iter().any(|k| p.get(*k).is_some()) {
|
||||
return Err(bad(cmd, "isolate takes `paths` or `items` (bodies)"));
|
||||
}
|
||||
let keys = keys(p, "items", cmd)?;
|
||||
if keys.is_empty() {
|
||||
return Err(bad(cmd, "isolate needs `paths` or `items` (bodies)"));
|
||||
}
|
||||
let world = s.world_state()?;
|
||||
let keep: Vec<String> = keys
|
||||
.iter()
|
||||
.map(|k| match shown_item(s, &world, k) {
|
||||
Some(Shown::Body(name)) => Ok(name),
|
||||
_ => Err(bad(cmd, format!("no body `{k}`"))),
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let mut hidden_bodies: Vec<String> =
|
||||
s.model.state().bodies.iter().filter(|b| s.doc.model_body_component(b) == 0 && !keep.contains(&b.name)).map(|b| b.name.clone()).collect();
|
||||
let mut hidden = Vec::new();
|
||||
let mut stack = vec![Vec::<u64>::new()];
|
||||
let mut count = 0;
|
||||
while let Some(parent) = stack.pop() {
|
||||
for child in s.occurrence_children(&parent)? {
|
||||
count += 1;
|
||||
if count > 100_000 {
|
||||
return Err(bad(cmd, "isolate exceeds 100000 placements"));
|
||||
}
|
||||
if s.occurrence_bodies_shared(&child.path, true)?.iter().any(|b| keep.contains(b)) {
|
||||
hidden_bodies.extend(child.bodies.into_iter().map(|b| b.world).filter(|b| !keep.contains(b)));
|
||||
stack.push(child.path);
|
||||
} else {
|
||||
hidden.push(child.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A hidden Bodies folder or group a picked body is in would keep it hidden: those show
|
||||
// again. Their other bodies are hidden one by one above, as the root's are.
|
||||
let shown_folders = folders_holding(s, &keep);
|
||||
s.visibility.hidden_folders.retain(|k| !shown_folders.contains(k));
|
||||
s.visibility.hidden_occurrences = hidden;
|
||||
s.visibility.hidden_bodies = hidden_bodies;
|
||||
s.refresh_display();
|
||||
Ok(json!({"visible":true,"isolated":keep,"visibility":s.visibility}))
|
||||
}
|
||||
|
||||
/// The tree keys of the Bodies folders and groups (with the groups they are in) that hold the
|
||||
/// bodies named `world` in some placement.
|
||||
fn folders_holding(s: &Session, world: &[String]) -> std::collections::BTreeSet<String> {
|
||||
let mut keys = std::collections::BTreeSet::new();
|
||||
if s.visibility.hidden_folders.is_empty() {
|
||||
return keys;
|
||||
}
|
||||
let st = s.model.state();
|
||||
let names = solidcraft_doc::WorldNames::new(&s.doc, &st);
|
||||
// Each body's component, found once rather than once per placement.
|
||||
let owned: Vec<(u64, &str)> = s.doc.model_body_components(&st).into_iter().zip(st.bodies.iter().map(|b| b.name.as_str())).collect();
|
||||
for (component, path, _) in s.doc.placements() {
|
||||
let scope = Session::folder_scope(&path);
|
||||
for b in owned.iter().filter(|(c, _)| *c == component).map(|(_, name)| *name) {
|
||||
if !world.contains(&names.name(b, component, &path)) {
|
||||
continue;
|
||||
}
|
||||
keys.insert(format!("{scope}/bodies"));
|
||||
for g in s.doc.browser_groups.iter().filter(|g| g.component == component && g.folder == "bodies" && g.has_item(b)) {
|
||||
for id in std::iter::successors(Some(g.id), |id| s.doc.browser_group(*id).and_then(|g| g.parent))
|
||||
.take(solidcraft_doc::MAX_GROUP_NESTING + 1)
|
||||
{
|
||||
keys.insert(format!("{scope}/g{id}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "browser.visibility";
|
||||
let visible = bool_(p, "visible").ok_or_else(|| bad(cmd, "`visible` must be true or false"))?;
|
||||
@@ -608,9 +525,6 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.refresh_display();
|
||||
return Ok(json!({"visible":visible,"changed":paths.len(),"visibility":s.visibility}));
|
||||
}
|
||||
if bool_(p, "isolate").unwrap_or(false) {
|
||||
return isolate_bodies(s, p, visible);
|
||||
}
|
||||
let items: Vec<Shown> = match (str_(p, "folder"), p.get("items")) {
|
||||
_ if p.get("features").is_some() => {
|
||||
if ["items", "folder", "joints"].iter().any(|k| p.get(*k).is_some()) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! well as any; each place's volume, centre and box are then measured exactly, on the pieces of
|
||||
//! the two cuts a − b and b − a that lie there, as `inspect.measure` measures a body.
|
||||
|
||||
use serde_json::Value;
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::Aabb3;
|
||||
use solidcraft_geom::compare::{self, CompareOptions, Side};
|
||||
@@ -13,19 +13,9 @@ use solidcraft_geom::prelude::*;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::schema::compare::{
|
||||
Bounds, CompareParams, CompareSide, Comparison, Deviation, Deviations, Difference, MeasuredBy, Region, Resolution, Sampling, SideTotal,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Sel, Session};
|
||||
|
||||
const CONTRACT: TypedContract<CompareParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Compare Bodies", crate::checked!("inspect.compare", compare_bodies))
|
||||
.icon("compare")
|
||||
.contract(&CONTRACT)
|
||||
.output(output_schema_for::<Comparison>)
|
||||
.params(
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Compare Bodies", compare_bodies).icon("compare").params(
|
||||
"a, b: body names (default: the two selected bodies, or the design's only two), in world placement; tolerance?: mm (0.001 to 10, default 0.01): \
|
||||
surfaces closer than this agree; spacing?: mm (default 0.5): the largest piece of a face sampled as one point, coarser for a body that would need \
|
||||
more than max_points? (default 8 million, up to 50 million) samples; max_regions? (default 50); color?: true | {only_in_a?, only_in_b?: \"#rrggbb\"} \
|
||||
@@ -42,10 +32,10 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Com
|
||||
const ONLY_IN_A: [u8; 3] = [0xe0, 0x31, 0x31];
|
||||
const ONLY_IN_B: [u8; 3] = [0x2f, 0x9e, 0x44];
|
||||
|
||||
fn side_of(s: Side) -> CompareSide {
|
||||
fn side_name(s: Side) -> &'static str {
|
||||
match s {
|
||||
Side::OnlyInA => CompareSide::OnlyInA,
|
||||
Side::OnlyInB => CompareSide::OnlyInB,
|
||||
Side::OnlyInA => "only_in_a",
|
||||
Side::OnlyInB => "only_in_b",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,21 +105,21 @@ fn v3(v: &Value) -> Option<Vec3> {
|
||||
}
|
||||
|
||||
/// b − a of the two bodies' measures.
|
||||
fn difference(ma: &Value, mb: &Value) -> Difference {
|
||||
fn difference(ma: &Value, mb: &Value) -> Value {
|
||||
let f = |m: &Value, k: &str| m.get(k).and_then(Value::as_f64);
|
||||
let sub = |x: Option<f64>, y: Option<f64>| x.zip(y).map(|(x, y)| y - x);
|
||||
let (va, vb) = (f(ma, "volume_mm3"), f(mb, "volume_mm3"));
|
||||
let com = v3(&ma["center_of_mass"]).zip(v3(&mb["center_of_mass"])).map(|(a, b)| b - a);
|
||||
let corner = |k: &str| v3(&ma["bbox"][k]).zip(v3(&mb["bbox"][k])).map(|(a, b)| (b - a).into());
|
||||
Difference {
|
||||
volume_mm3: sub(va, vb),
|
||||
volume_percent: va.zip(vb).filter(|(a, _)| a.abs() > 0.0).map(|(a, b)| 100.0 * (b - a) / a),
|
||||
area_mm2: sub(f(ma, "area_mm2"), f(mb, "area_mm2")),
|
||||
center_of_mass: com.map(Into::into),
|
||||
center_of_mass_distance_mm: com.map(Vec3::len),
|
||||
bbox_min: corner("min"),
|
||||
bbox_max: corner("max"),
|
||||
}
|
||||
let corner = |k: &str| v3(&ma["bbox"][k]).zip(v3(&mb["bbox"][k])).map(|(a, b)| b - a);
|
||||
json!({
|
||||
"volume_mm3": sub(va, vb),
|
||||
"volume_percent": va.zip(vb).filter(|(a, _)| a.abs() > 0.0).map(|(a, b)| 100.0 * (b - a) / a),
|
||||
"area_mm2": sub(f(ma, "area_mm2"), f(mb, "area_mm2")),
|
||||
"center_of_mass": com,
|
||||
"center_of_mass_distance_mm": com.map(Vec3::len),
|
||||
"bbox_min": corner("min"),
|
||||
"bbox_max": corner("max"),
|
||||
})
|
||||
}
|
||||
|
||||
/// The volume a closed mesh encloses (divergence theorem, about its box's centre).
|
||||
@@ -255,8 +245,8 @@ fn exact_regions(
|
||||
(measured, notes)
|
||||
}
|
||||
|
||||
fn deviation(d: &compare::Deviation) -> Deviation {
|
||||
Deviation { max_mm: d.max, mean_mm: d.mean, at: d.at.into(), area_beyond_mm2: d.beyond_area, area_mm2: d.area, points: d.points }
|
||||
fn deviation_json(d: &compare::Deviation) -> Value {
|
||||
json!({"max_mm": d.max, "mean_mm": d.mean, "at": d.at, "area_beyond_mm2": d.beyond_area, "area_mm2": d.area, "points": d.points})
|
||||
}
|
||||
|
||||
fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -318,36 +308,30 @@ fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
exact_notes.iter().find(mine).or_else(|| exact_notes.last()).cloned().unwrap_or_else(|| "the exact cut gave no piece here".to_string())
|
||||
};
|
||||
let axis = |k: usize| ["X", "Y", "Z"].get(k).copied().unwrap_or("?");
|
||||
let regions: Vec<Region> = order
|
||||
let regions: Vec<Value> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, &k)| c.regions.get(k).map(|r| (i, k, r)))
|
||||
.map(|(i, k, r)| {
|
||||
let m = measured(k);
|
||||
let bbox = m.map_or(r.bbox, |m| m.bbox);
|
||||
Region {
|
||||
index: i + 1,
|
||||
side: side_of(r.side),
|
||||
material_of: if r.side == Side::OnlyInA { na.clone() } else { nb.clone() },
|
||||
volume_mm3: m.map(|m| m.volume),
|
||||
measured_by: if m.is_some() { MeasuredBy::Boolean } else { MeasuredBy::NotMeasured },
|
||||
not_measured: if m.is_some() { None } else { Some(reason(k)) },
|
||||
area_mm2: r.area_a + r.area_b,
|
||||
area_a_mm2: r.area_a,
|
||||
area_b_mm2: r.area_b,
|
||||
max_deviation_mm: r.max_deviation,
|
||||
at: r.at.into(),
|
||||
center: m.map(|m| m.center.into()),
|
||||
bbox: Bounds { min: bbox.min.into(), max: bbox.max.into() },
|
||||
faces_a: r.faces_a.iter().map(|x| x.0).collect(),
|
||||
faces_b: r.faces_b.iter().map(|x| x.0).collect(),
|
||||
resolution: Resolution {
|
||||
along: axis(r.lines.axis).to_string(),
|
||||
line_spacing_mm: r.lines.spacing,
|
||||
lines: r.lines.count,
|
||||
skipped_lines: r.lines.skipped,
|
||||
},
|
||||
}
|
||||
json!({
|
||||
"index": i + 1,
|
||||
"side": side_name(r.side),
|
||||
"material_of": if r.side == Side::OnlyInA { &na } else { &nb },
|
||||
"volume_mm3": m.map(|m| m.volume),
|
||||
"measured_by": if m.is_some() { "boolean" } else { "not measured" },
|
||||
"not_measured": if m.is_some() { Value::Null } else { json!(reason(k)) },
|
||||
"area_mm2": r.area_a + r.area_b,
|
||||
"area_a_mm2": r.area_a,
|
||||
"area_b_mm2": r.area_b,
|
||||
"max_deviation_mm": r.max_deviation,
|
||||
"at": r.at,
|
||||
"center": m.map(|m| m.center),
|
||||
"bbox": m.map(|m| json!({"min": m.bbox.min, "max": m.bbox.max})).unwrap_or_else(|| json!({"min": r.bbox.min, "max": r.bbox.max})),
|
||||
"faces_a": r.faces_a.iter().map(|x| x.0).collect::<Vec<u32>>(),
|
||||
"faces_b": r.faces_b.iter().map(|x| x.0).collect::<Vec<u32>>(),
|
||||
"resolution": {"along": axis(r.lines.axis), "line_spacing_mm": r.lines.spacing, "lines": r.lines.count, "skipped_lines": r.lines.skipped},
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// A side's total, or none when one of its regions is not measured.
|
||||
@@ -377,28 +361,25 @@ fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !warnings.is_empty() {
|
||||
verdict += &format!(" ({} warning{})", warnings.len(), if warnings.len() == 1 { "" } else { "s" });
|
||||
}
|
||||
let colored_faces = match colors {
|
||||
Some(colors) => Some(color_regions(s, &c, [&na, &nb], colors)?),
|
||||
None => None,
|
||||
};
|
||||
let out = Comparison {
|
||||
difference: difference(&measure_a, &measure_b),
|
||||
a: measure_a.as_object().cloned().unwrap_or_default(),
|
||||
b: measure_b.as_object().cloned().unwrap_or_default(),
|
||||
tolerance_mm: tolerance,
|
||||
deviation: Deviations { a_to_b: deviation(&c.a_to_b), b_to_a: deviation(&c.b_to_a), max_mm: worst },
|
||||
identical,
|
||||
verdict,
|
||||
regions,
|
||||
only_in_a: SideTotal { regions: ca, volume_mm3: va },
|
||||
only_in_b: SideTotal { regions: cb, volume_mm3: vb },
|
||||
unlisted_regions: c.unlisted,
|
||||
warnings,
|
||||
sampling: Sampling { spacing_mm: c.spacing, chord_mm: chord, triangles_a: mesh_a.triangles.len(), triangles_b: mesh_b.triangles.len() },
|
||||
colored_faces,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(out).map_err(|e| bad(cmd, e.to_string()))
|
||||
let mut out = json!({
|
||||
"a": measure_a,
|
||||
"b": measure_b,
|
||||
"difference": difference(&measure_a, &measure_b),
|
||||
"tolerance_mm": tolerance,
|
||||
"deviation": {"a_to_b": deviation_json(&c.a_to_b), "b_to_a": deviation_json(&c.b_to_a), "max_mm": worst},
|
||||
"identical": identical,
|
||||
"verdict": verdict,
|
||||
"regions": regions,
|
||||
"only_in_a": {"regions": ca, "volume_mm3": va},
|
||||
"only_in_b": {"regions": cb, "volume_mm3": vb},
|
||||
"unlisted_regions": c.unlisted,
|
||||
"warnings": warnings,
|
||||
"sampling": {"spacing_mm": c.spacing, "chord_mm": chord, "triangles_a": mesh_a.triangles.len(), "triangles_b": mesh_b.triangles.len()},
|
||||
});
|
||||
if let Some(colors) = colors {
|
||||
out["colored_faces"] = json!(color_regions(s, &c, [&na, &nb], colors)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A face is coloured for a region when at least this share of it deviates there.
|
||||
|
||||
@@ -331,11 +331,6 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if rename.as_ref().is_some_and(|n| n != &name && s.doc.configs.rows.iter().any(|r| &r.name == n)) {
|
||||
return Err(bad(cmd, "configuration name already exists"));
|
||||
}
|
||||
for (column, value) in s.doc.configs.columns.iter().zip(&cells) {
|
||||
if matches!(column, Column::Suppress(_)) {
|
||||
solidcraft_doc::config::suppression_cell(value).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
}
|
||||
}
|
||||
let changes: Vec<_> = s
|
||||
.doc
|
||||
.configs
|
||||
@@ -748,78 +743,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rows_reject_invalid_suppression_without_changes() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY", "name":"TestSketch"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "config.configure", json!({"features":["TestSketch"]}));
|
||||
let before = s.doc.clone();
|
||||
let undo_len = s.undo.len();
|
||||
for value in [json!("banana"), json!(""), json!(2), json!(-1)] {
|
||||
for params in [
|
||||
json!({"name":"Bad", "values":{"suppress:TestSketch":value}}),
|
||||
json!({"name":"Copy", "from":"Default", "values":{"TestSketch":value}}),
|
||||
json!({"name":"Default", "rename":"Renamed", "values":{"TestSketch":value}}),
|
||||
] {
|
||||
let error = s.execute("config.row", ¶ms).expect_err("invalid suppression must be rejected when saving a row");
|
||||
assert!(error.to_string().contains("suppression must be true or false"), "{params}: {error}");
|
||||
assert_eq!(s.doc, before, "{params}");
|
||||
assert_eq!(s.undo.len(), undo_len, "{params}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rows_validate_inherited_suppression_and_allow_repair() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY", "name":"TestSketch"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "config.configure", json!({"features":["TestSketch"]}));
|
||||
// A row saved by an older reader may already contain invalid suppression text.
|
||||
s.doc_mut().configs.rows[0].cells[0] = "banana".into();
|
||||
let before = s.doc.clone();
|
||||
let undo_len = s.undo.len();
|
||||
for params in [json!({"name":"Copy", "from":"Default"}), json!({"name":"Default", "rename":"Renamed"})] {
|
||||
let error = s.execute("config.row", ¶ms).expect_err("inherited invalid suppression must also be checked");
|
||||
assert!(error.to_string().contains("suppression must be true or false"), "{params}: {error}");
|
||||
assert_eq!(s.doc, before, "{params}");
|
||||
assert_eq!(s.undo.len(), undo_len, "{params}");
|
||||
}
|
||||
run(&mut s, "config.row", json!({"name":"Default", "values":{"TestSketch":false}}));
|
||||
assert_eq!(s.doc.configs.rows[0].cells[0], "false");
|
||||
run(&mut s, "config.activate", json!({"row":"Default"}));
|
||||
assert!(!s.doc.features[0].suppressed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rows_keep_supported_suppression_spellings() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY", "name":"TestSketch"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "config.configure", json!({"features":["TestSketch"]}));
|
||||
for (value, suppressed) in [
|
||||
(json!(true), true),
|
||||
(json!(false), false),
|
||||
(json!(1), true),
|
||||
(json!(0), false),
|
||||
(json!("true"), true),
|
||||
(json!("false"), false),
|
||||
(json!(" YES "), true),
|
||||
(json!(" No "), false),
|
||||
(json!("1"), true),
|
||||
(json!("0"), false),
|
||||
] {
|
||||
let before = s.doc.features.clone();
|
||||
run(&mut s, "config.row", json!({"name":"Accepted", "values":{"suppress:TestSketch":value}}));
|
||||
assert_eq!(s.doc.features, before, "saving a row does not activate it");
|
||||
let text = value.as_str().map(str::to_owned).unwrap_or_else(|| value.to_string());
|
||||
assert_eq!(s.doc.configs.rows[1].cells[0], text, "accepted spellings are preserved");
|
||||
run(&mut s, "config.activate", json!({"row":"Accepted"}));
|
||||
assert_eq!(s.doc.features[0].suppressed, suppressed, "{value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rows_validate_the_final_parameter_graph_without_activating_it() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -10,52 +10,28 @@ use solidcraft_doc::metadata::{self, BOM_TYPE, MAX_KEYS, Owner};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
use crate::schema::metadata::{MetadataParams, MetadataRemoveParams, MetadataRemoveResult, MetadataResult, MetadataSetParams, MetadataSetResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const READ_CONTRACT: TypedContract<MetadataParams> = TypedContract::new();
|
||||
const SET_CONTRACT: TypedContract<MetadataSetParams> = TypedContract::new();
|
||||
const REMOVE_CONTRACT: TypedContract<MetadataRemoveParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("document.metadata", "Design Properties", crate::checked!("document.metadata", read))
|
||||
.icon("settings")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&READ_CONTRACT)
|
||||
.output(output_schema_for::<MetadataResult>)
|
||||
.params(
|
||||
"→ metadata: the design's metadata object as saved (other tools' keys too); properties: each of SolidCraft's design \
|
||||
properties (project, part number, revision…), its text or null (absent, or not text)",
|
||||
),
|
||||
CommandSpec::new("document.metadata.set", "Change Design Properties", crate::checked!("document.metadata.set", set))
|
||||
.icon("settings")
|
||||
.doc_only()
|
||||
.contract(&SET_CONTRACT)
|
||||
.output(output_schema_for::<MetadataSetResult>)
|
||||
.params(
|
||||
"key and value, or values: {key: value, …} (all or nothing, one undo step). SolidCraft's keys, the ones document.metadata's \
|
||||
properties lists, take text (\"\" or null removes); another tool's data goes under its reverse-DNS key such as \
|
||||
com.example.tool (an object; null removes) → metadata, changed: [keys]",
|
||||
),
|
||||
CommandSpec::new("document.metadata.remove", "Remove Design Property", crate::checked!("document.metadata.remove", remove))
|
||||
CommandSpec::new("document.metadata", "Design Properties", read).icon("settings").noundo().doc_only().params(
|
||||
"→ metadata: the design's metadata object as saved (other tools' keys too); properties: each of SolidCraft's design \
|
||||
properties (project, part number, revision…), its text or null (absent, or not text)",
|
||||
),
|
||||
CommandSpec::new("document.metadata.set", "Change Design Properties", set).icon("settings").doc_only().params(
|
||||
"key and value, or values: {key: value, …} (all or nothing, one undo step). SolidCraft's keys, the ones document.metadata's \
|
||||
properties lists, take text (\"\" or null removes); another tool's data goes under its reverse-DNS key such as \
|
||||
com.example.tool (an object; null removes) → metadata, changed: [keys]",
|
||||
),
|
||||
CommandSpec::new("document.metadata.remove", "Remove Design Property", remove)
|
||||
.icon("delete")
|
||||
.doc_only()
|
||||
.contract(&REMOVE_CONTRACT)
|
||||
.output(output_schema_for::<MetadataRemoveResult>)
|
||||
.params("key, or keys: [keys] (any key, also one another tool wrote) → metadata, removed: [keys that were there]"),
|
||||
];
|
||||
|
||||
/// A reply built from its schema's type, so the two can't drift apart.
|
||||
fn reply(cmd: &str, r: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(r).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn read(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let m = &s.doc.metadata;
|
||||
let properties: Map<String, Value> = metadata::keys(Owner::Design).map(|k| (k.name.to_string(), json!(m.text(k.name)))).collect();
|
||||
reply("document.metadata", MetadataResult { metadata: m.0.clone(), properties })
|
||||
Ok(json!({"metadata": m, "properties": properties}))
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -84,7 +60,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if next != s.doc.metadata {
|
||||
s.doc_mut().metadata = next;
|
||||
}
|
||||
reply(cmd, MetadataSetResult { metadata: s.doc.metadata.0.clone(), changed, param_warnings: Vec::new() })
|
||||
Ok(json!({"metadata": s.doc.metadata, "changed": changed}))
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -108,8 +84,7 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
m.remove(k);
|
||||
}
|
||||
}
|
||||
let removed = removed.into_iter().map(str::to_string).collect();
|
||||
reply(cmd, MetadataRemoveResult { metadata: s.doc.metadata.0.clone(), removed, param_warnings: Vec::new() })
|
||||
Ok(json!({"metadata": s.doc.metadata, "removed": removed}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -46,7 +46,6 @@ pub(crate) use features::profiles as feature_profiles;
|
||||
pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_object, edge_ref_form, explicit_plane, plane_name_problem, sketch_key};
|
||||
pub use features::{auto_operation, extrude_reach, shown_scope};
|
||||
pub use file::open_steps::OpenSteps;
|
||||
pub(crate) use inspect::color_param;
|
||||
pub use join_pieces::{Addition, addition};
|
||||
pub use parts::part_length_label;
|
||||
pub use sketch::dimension_roles;
|
||||
|
||||
@@ -541,84 +541,6 @@ fn isolate_nested_child_hides_ancestor_material_and_sibling() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #7896: isolating a body hides its own component's other bodies one by one and every other
|
||||
/// placement as a placement, so the Browser row of a component shows it is off and its eye
|
||||
/// brings it back. A second placement of the body's own component is another placement.
|
||||
/// #7896: isolating a body inside a hidden Bodies folder or group shows that folder and group
|
||||
/// again, so the body is visible; the folder's other bodies stay hidden.
|
||||
#[test]
|
||||
fn isolate_shows_the_hidden_folder_and_group_a_picked_body_is_in() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Kept"}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[20,0,0],"body_name":"Other","operation":"new"}));
|
||||
let group = run(&mut s, "browser.group", json!({"folder": "bodies", "items": ["Kept"], "name": "Held"}));
|
||||
let group = group["group"].as_u64().or_else(|| group["id"].as_u64()).expect("the group's id");
|
||||
run(&mut s, "browser.visibility", json!({"tree_folders": ["c0/bodies", format!("c0/g{group}")], "visible": false}));
|
||||
assert!(!s.body_visible("Kept") && !s.body_visible("Other"));
|
||||
run(&mut s, "browser.visibility", json!({"items": ["Kept"], "visible": true, "isolate": true}));
|
||||
assert!(s.body_visible("Kept"), "the picked body shows: {:?}", s.visibility.hidden_folders);
|
||||
assert!(!s.body_visible("Other"), "the folder's other body stays hidden");
|
||||
assert!(s.visibility.hidden_folders.is_empty(), "{:?}", s.visibility.hidden_folders);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolate_bodies_hides_other_components_as_components() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[-20,0,0],"body_name":"Root"}));
|
||||
let comp = |s: &mut Session, name: &str| {
|
||||
let c = run(s, "component.create", json!({"name": name, "parent": "root", "grounded": false}));
|
||||
c["occurrence"].as_u64().unwrap()
|
||||
};
|
||||
let a = comp(&mut s, "CompA");
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"BoxA"}));
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[0,0,20],"body_name":"BoxA2"}));
|
||||
let b = comp(&mut s, "CompB");
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[20,0,0],"body_name":"BoxB1"}));
|
||||
run(&mut s, "solid.box", json!({"length":5,"width":5,"height":5,"corner":[40,0,0],"body_name":"BoxB2"}));
|
||||
let a2 = run(&mut s, "occurrence.copy", json!({"occurrence":a,"translate":[0,40,0]}))["occurrence"].as_u64().unwrap();
|
||||
let in_a = s.occurrence_bodies(&[a], false).unwrap();
|
||||
let (box_a, box_a2) = (in_a.iter().find(|n| !n.contains("BoxA2")).unwrap().clone(), in_a.iter().find(|n| n.contains("BoxA2")).unwrap().clone());
|
||||
|
||||
let out = run(&mut s, "browser.visibility", json!({"visible":true,"items":[box_a],"isolate":true}));
|
||||
assert_eq!(out["isolated"], json!([box_a]));
|
||||
let mut occurrences = s.visibility.hidden_occurrences.clone();
|
||||
occurrences.sort();
|
||||
let mut others = vec![vec![b], vec![a2]];
|
||||
others.sort();
|
||||
assert_eq!(occurrences, others, "CompB and CompA's other placement go as placements");
|
||||
let mut bodies = s.visibility.hidden_bodies.clone();
|
||||
bodies.sort();
|
||||
assert_eq!(bodies, vec![box_a2.clone(), "Root".to_string()], "only bodies beside BoxA go one by one");
|
||||
assert!(s.body_visible(&box_a) && !s.body_visible(&box_a2) && !s.body_visible("Root"));
|
||||
for name in s.occurrence_bodies(&[b], false).unwrap().iter().chain(&s.occurrence_bodies(&[a2], false).unwrap()) {
|
||||
assert!(!s.body_visible(name), "{name}");
|
||||
}
|
||||
// CompB's eye brings it back whole.
|
||||
run(&mut s, "browser.visibility", json!({"visible":true,"paths":[[b]]}));
|
||||
assert!(s.occurrence_bodies(&[b], false).unwrap().iter().all(|n| s.body_visible(n)));
|
||||
|
||||
// Isolating a root body hides every placement as a placement and no body one by one.
|
||||
run(&mut s, "browser.visibility", json!({"visible":true,"items":["Root"],"isolate":true}));
|
||||
let mut occurrences = s.visibility.hidden_occurrences.clone();
|
||||
occurrences.sort();
|
||||
let mut all = vec![vec![a], vec![b], vec![a2]];
|
||||
all.sort();
|
||||
assert_eq!(occurrences, all);
|
||||
assert!(s.visibility.hidden_bodies.is_empty() && s.body_visible("Root"), "{:?}", s.visibility.hidden_bodies);
|
||||
|
||||
// Only bodies isolate, and only shown; a refusal changes nothing.
|
||||
let before = s.visibility.clone();
|
||||
for p in [
|
||||
json!({"visible":false,"items":["Root"],"isolate":true}),
|
||||
json!({"visible":true,"items":["XY"],"isolate":true}),
|
||||
json!({"visible":true,"items":["Nope"],"isolate":true}),
|
||||
json!({"visible":true,"folder":"bodies","isolate":true}),
|
||||
] {
|
||||
assert!(s.execute("browser.visibility", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.visibility, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_new_child_uses_inactive_parent_placement() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -91,17 +91,11 @@ CommandSpec::new("render.library.preview", "Inspect Original Surface Map", libra
|
||||
.params("id: SURF-*. Returns bounded hash-verified original base-color PNG bytes and source-map fidelity label, not a rendered material thumbnail. No external paths, rendering or document edits."),
|
||||
CommandSpec::new("render.library.physical.prepare", "Prepare Physical Reference", physical_library::prepare).icon("inspect").noundo().doc_only()
|
||||
.params("id: named built-in physical profile, name: proposed new material label, acknowledge_reference:true. Read-only loss-aware creation plan using native ValidatedCard admission and one point density; returns source receipts, extended fields and omissions. Does not execute material.create, assign bodies or infer effective-part physics."),
|
||||
CommandSpec::new("render.stage.ground", "Save Render Ground", crate::checked!("render.stage.ground", staging::ground)).icon("render_scene")
|
||||
.contract(&staging::GROUND_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_stage::StageSaved>)
|
||||
CommandSpec::new("render.stage.ground", "Save Render Ground", staging::ground).icon("render_scene")
|
||||
.params("name: typed raytrace setup, set?: setup patch, margin_fraction?:0.25, gap_mm?:0.1, reflectance_linear?:[0.18,0.18,0.18], replace_stage?:false. Fits one finite passive ground below the visible product once and saves its physical transforms in one undo. Product AABB separation is rechecked on every render."),
|
||||
CommandSpec::new("render.stage.backdrop", "Save Physical Render Background", crate::checked!("render.stage.backdrop", staging::backdrop)).icon("render_scene").doc_only()
|
||||
.contract(&staging::BACKDROP_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_stage::StageSaved>)
|
||||
CommandSpec::new("render.stage.backdrop", "Save Physical Render Background", staging::backdrop).icon("render_scene").doc_only()
|
||||
.params("name: typed raytrace setup, set?: setup patch, kind: floor_wall or cyclorama, reflectance_linear?: [0.18,0.18,0.18], expected_revision: reviewed revision, replace_stage?:false. Fits a finite floor/wall or a 24-strip polygonal cyclorama behind the visible subject using the saved camera azimuth; world +Z up and a minimum one-mm stage envelope. Preserves subject geometry, lighting, output, pose and engineering materials; one undo and rechecked product separation. Not an infinite/analytically smooth/terrain background."),
|
||||
CommandSpec::new("render.stage.clear", "Remove Render Stage", crate::checked!("render.stage.clear", staging::clear)).icon("render_scene").doc_only()
|
||||
.contract(&staging::CLEAR_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_stage::StageSaved>)
|
||||
CommandSpec::new("render.stage.clear", "Remove Render Stage", staging::clear).icon("render_scene").doc_only()
|
||||
.params("name: setup, set?: setup patch. Removes saved render-only panels atomically; CAD geometry and lighting are unchanged."),
|
||||
CommandSpec::new("render.library.surface.bind", "Save Surface Binding", surface_bindings::bind).icon("appearance")
|
||||
.params("name: GPU setup; set?: setup patch; surface: same input as render.library.surface.prepare; replace?: false. Pins exact source hashes and definition/feature/occurrence identity with local-mm mapping in one undo. No paths, mesh ordinals or physical material changes."),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! Explicit saved render-only ground. Does not add engineering bodies or material cards.
|
||||
use crate::schema::TypedContract;
|
||||
use crate::schema::render_stage::{BackdropKind, BackdropParams, ClearParams, GroundParams, StageSaved};
|
||||
use crate::{Result, Session, params::bad};
|
||||
use serde_json::Value;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::render_settings::{RenderJob, RenderSetup};
|
||||
use solidcraft_render::{
|
||||
ProductScene,
|
||||
@@ -10,41 +9,49 @@ use solidcraft_render::{
|
||||
stage::{Stage, StagePanel},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) const GROUND_CONTRACT: TypedContract<GroundParams> = TypedContract::new();
|
||||
pub(super) const BACKDROP_CONTRACT: TypedContract<BackdropParams> = TypedContract::new();
|
||||
pub(super) const CLEAR_CONTRACT: TypedContract<ClearParams> = TypedContract::new();
|
||||
|
||||
fn patch() -> Value {
|
||||
json!({})
|
||||
}
|
||||
fn margin() -> f64 {
|
||||
0.25
|
||||
}
|
||||
fn gap() -> f64 {
|
||||
0.1
|
||||
}
|
||||
fn reflectance() -> [f64; 3] {
|
||||
[0.18; 3]
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Ground {
|
||||
name: String,
|
||||
#[serde(default = "patch")]
|
||||
set: Value,
|
||||
#[serde(default = "margin")]
|
||||
margin_fraction: f64,
|
||||
#[serde(default = "gap")]
|
||||
gap_mm: f64,
|
||||
#[serde(default = "reflectance")]
|
||||
reflectance_linear: [f64; 3],
|
||||
#[serde(default)]
|
||||
replace_stage: bool,
|
||||
}
|
||||
pub(super) fn apply(scene: &mut ProductScene, setup: &RenderSetup, command: &str) -> Result<()> {
|
||||
setup.stage.append_to(scene, &CancelToken::default()).map_err(|e| bad(command, e.to_string()))
|
||||
}
|
||||
fn save(s: &mut Session, name: String, setup: RenderSetup, command: &str) -> Result<StageSaved> {
|
||||
fn save(s: &mut Session, name: String, setup: RenderSetup, command: &str) -> Result<Value> {
|
||||
let mut settings = (*s.doc.render).clone();
|
||||
settings.setups.insert(name.clone(), setup.clone());
|
||||
settings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
let setup = serde_json::to_value(&setup).map_err(|e| bad(command, e.to_string()))?;
|
||||
if settings != *s.doc.render {
|
||||
s.doc_mut().render = Arc::new(settings);
|
||||
}
|
||||
Ok(StageSaved {
|
||||
name,
|
||||
setup,
|
||||
engineering_geometry_unchanged: true,
|
||||
stage_only: true,
|
||||
clearance: "conservative product AABB separation; not a B-rep collision proof".into(),
|
||||
geometry_kind: None,
|
||||
light_transport: None,
|
||||
camera_preserved: None,
|
||||
limitations: None,
|
||||
param_warnings: Vec::new(),
|
||||
})
|
||||
}
|
||||
fn reply(command: &str, saved: StageSaved) -> Result<Value> {
|
||||
serde_json::to_value(saved).map_err(|e| bad(command, e.to_string()))
|
||||
Ok(json!({"name":name,"setup":setup,"engineering_geometry_unchanged":true,"stage_only":true,
|
||||
"clearance":"conservative product AABB separation; not a B-rep collision proof"}))
|
||||
}
|
||||
pub(super) fn ground(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.stage.ground";
|
||||
let input: GroundParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let input: Ground = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
if !input.margin_fraction.is_finite()
|
||||
|| !(0.0..=10.0).contains(&input.margin_fraction)
|
||||
|| !input.gap_mm.is_finite()
|
||||
@@ -81,22 +88,48 @@ pub(super) fn ground(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
}])
|
||||
.map_err(|e| bad(command, e.to_string()))?;
|
||||
apply(&mut scene, &setup, command)?;
|
||||
reply(command, save(s, input.name, setup, command)?)
|
||||
save(s, input.name, setup, command)
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Clear {
|
||||
name: String,
|
||||
#[serde(default = "patch")]
|
||||
set: Value,
|
||||
}
|
||||
pub(super) fn clear(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.stage.clear";
|
||||
let input: ClearParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let input: Clear = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let mut setup = super::patched_settings(s, &input.name, &input.set, command)?;
|
||||
setup.stage = Stage::default();
|
||||
reply(command, save(s, input.name, setup, command)?)
|
||||
save(s, input.name, setup, command)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum BackdropKind {
|
||||
FloorWall,
|
||||
Cyclorama,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct BackdropInput {
|
||||
name: String,
|
||||
#[serde(default = "patch")]
|
||||
set: Value,
|
||||
kind: BackdropKind,
|
||||
#[serde(default = "reflectance")]
|
||||
reflectance_linear: [f64; 3],
|
||||
#[serde(default)]
|
||||
replace_stage: bool,
|
||||
expected_revision: u64,
|
||||
}
|
||||
/// Original world-space stage derived from captured subject bounds and the chosen
|
||||
/// camera azimuth. Twenty-four explicit planar strips approximate the round cove;
|
||||
/// it is not an analytic smooth surface, terrain or engineering model operation.
|
||||
pub(super) fn backdrop(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.stage.backdrop";
|
||||
let input: BackdropParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let input: BackdropInput = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
if input.expected_revision != s.revision {
|
||||
return Err(bad(command, "the design changed; review the stage again"));
|
||||
}
|
||||
@@ -171,20 +204,17 @@ pub(super) fn backdrop(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
setup.stage = Stage::new(panels).map_err(|e| bad(command, e.to_string()))?;
|
||||
apply(&mut scene, &setup, command)?;
|
||||
let mut result = save(s, input.name, setup, command)?;
|
||||
result.geometry_kind = Some(
|
||||
match input.kind {
|
||||
BackdropKind::FloorWall => "finite_floor_and_wall",
|
||||
BackdropKind::Cyclorama => "24_segment_polygonal_cyclorama",
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
result.light_transport =
|
||||
Some("nonemissive world-space stage; receives native lighting and casts reflections/shadows supported by the selected executor".into());
|
||||
result.camera_preserved = Some(true);
|
||||
result.limitations = Some(vec![
|
||||
"Finite background, not an infinite floor or a terrain mesh.".into(),
|
||||
"Cyclorama is an explicit 24-strip polygonal approximation, not a smooth CAD face.".into(),
|
||||
"No change to HDR, light energy, subject optics, pose or engineering properties.".into(),
|
||||
result["geometry_kind"] = json!(match input.kind {
|
||||
BackdropKind::FloorWall => "finite_floor_and_wall",
|
||||
BackdropKind::Cyclorama => "24_segment_polygonal_cyclorama",
|
||||
});
|
||||
result["light_transport"] =
|
||||
json!("nonemissive world-space stage; receives native lighting and casts reflections/shadows supported by the selected executor");
|
||||
result["camera_preserved"] = json!(true);
|
||||
result["limitations"] = json!([
|
||||
"Finite background, not an infinite floor or a terrain mesh.",
|
||||
"Cyclorama is an explicit 24-strip polygonal approximation, not a smooth CAD face.",
|
||||
"No change to HDR, light energy, subject optics, pose or engineering properties."
|
||||
]);
|
||||
reply(command, result)
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -481,7 +481,7 @@ pub(super) mod solve_counts {
|
||||
}
|
||||
|
||||
/// How close (mm) a new point must land to an existing one to be connected to it.
|
||||
pub(super) const CONNECT_TOL: f64 = 1e-6;
|
||||
const CONNECT_TOL: f64 = 1e-6;
|
||||
|
||||
/// New points (index `first` on) that land on an existing point, the origin included, get a
|
||||
/// coincident constraint to it, as when a click snaps there; `connect: false` leaves them free.
|
||||
@@ -1171,8 +1171,7 @@ fn draw_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for i in 0..segs {
|
||||
let j = (i + 1) % n;
|
||||
let (Some(a), Some(b)) = (args.get(i), args.get(j)) else { continue };
|
||||
// Ends closer than this would both be connected to one point (#11001).
|
||||
if a.pos().dist(b.pos()) < CONNECT_TOL {
|
||||
if a.pos().dist(b.pos()) < 1e-9 {
|
||||
return Err(bad(cmd, "zero-length line"));
|
||||
}
|
||||
let id = ids.get(i).map(String::as_str);
|
||||
|
||||
@@ -6,7 +6,7 @@ use solidcore_record::{Seg2, Vec2};
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind, Sketch};
|
||||
|
||||
use super::sketch::{CONNECT_TOL, add_c, curve_ref, edit, ids_of, line_or_axis_ref, line_pts, req_parg, result};
|
||||
use super::sketch::{add_c, curve_ref, edit, ids_of, line_or_axis_ref, line_pts, req_parg, result};
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, expr, num, str_, vec2};
|
||||
use crate::schema::sketch_create::{ConstructionLineParams, ConstructionLineResult};
|
||||
@@ -261,10 +261,6 @@ fn run(s: &mut Session, p: &Value, default_mode: &str) -> Result<Value> {
|
||||
let first_new = sk.points.len();
|
||||
let at = req_parg(sk, doc, &p, "at", CMD)?;
|
||||
let through = if mode == "free" { Some(req_parg(sk, doc, &p, "through", CMD)?) } else { None };
|
||||
// A through-point next to the start leaves a line whose direction no constraint holds (#11001).
|
||||
if through.as_ref().is_some_and(|q| q.pos().dist(at.pos()) < CONNECT_TOL) {
|
||||
return Err(bad(CMD, "the through point must lie away from the start point"));
|
||||
}
|
||||
let mut params = p.clone();
|
||||
params["at"] = json!([at.pos().x, at.pos().y]);
|
||||
let angle_expression = if mode == "angle" {
|
||||
|
||||
@@ -326,25 +326,6 @@ fn construction_tangents_respect_arc_sweeps_and_invalid_inputs_are_atomic() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_point_next_to_the_first_is_refused() {
|
||||
// #11001: a through-point 0.0000001 mm from the start made an infinite line whose direction
|
||||
// Horizontal no longer held, and a line that short collapsed onto the origin.
|
||||
let mut s = new_sketch();
|
||||
for (id, p) in [
|
||||
("sketch.construction_line", json!({"at":[0,0],"through":[0.0000001,0]})),
|
||||
("sketch.construction_line", json!({"at":[5,5],"through":[5,5.0000005]})),
|
||||
("sketch.line", json!({"points":[[0,0],[0.0000001,0]]})),
|
||||
("sketch.line", json!({"points":[[5,5],[10,5],[10,5.0000005]]})),
|
||||
] {
|
||||
let before = s.doc.to_json();
|
||||
assert!(s.execute(id, &p).is_err(), "{id} {p}");
|
||||
assert_eq!(s.doc.to_json(), before, "{id} {p}");
|
||||
}
|
||||
run(&mut s, "sketch.construction_line", json!({"at":[0,0],"through":[0.00001,0]}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[5,5],[5.00001,5]]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_relations_follow_source_edits_without_an_auxiliary_dimension() {
|
||||
let mut s = new_sketch();
|
||||
|
||||
@@ -3538,76 +3538,17 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => 0.0,
|
||||
};
|
||||
let t = if p.get("translate").is_some() { req_vec2(cmd, p, "translate")? } else { Vec2::ZERO };
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (cs, ps) = entities(sk, p, cmd)?;
|
||||
let c = if p.get("center").is_some() { point_or_ref(sk, p, "center", cmd)? } else { Vec2::ZERO };
|
||||
let rot = rotate(c, ang);
|
||||
let f = move |q: Vec2| rot(c + (q - c) * k) + t;
|
||||
transform_in_place(sk, &cs, &ps, &f, k, cmd)?;
|
||||
let moved = involved_points(sk, &cs, &ps);
|
||||
rotate_axis_constraints(sk, doc, &moved, ang)?;
|
||||
scale_dimensions(sk, doc, &cs, &moved, k)?;
|
||||
Ok(ids_of(sk, &cs))
|
||||
})?;
|
||||
s.doc_mut().prune_model_params();
|
||||
Ok(json!({"curves": out, "sketch": info}))
|
||||
}
|
||||
|
||||
/// The driving lengths wholly inside a transformed selection scale with it. Keep formulas
|
||||
/// tied to their user parameters, and compensate references between scaled dimensions so a
|
||||
/// dimension such as `d1 / 2` does not get the factor twice.
|
||||
fn scale_dimensions(sk: &mut Sketch, doc: &mut solidcraft_doc::Document, curves: &[usize], points: &[usize], factor: f64) -> Result<()> {
|
||||
if factor == 1.0 {
|
||||
return Ok(());
|
||||
}
|
||||
let selected: Vec<usize> = sk
|
||||
.constraints
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| {
|
||||
let pts = sk.constraint_points(&c.kind);
|
||||
!c.driven
|
||||
&& c.kind.is_dimension()
|
||||
&& !c.kind.is_angle()
|
||||
&& !pts.is_empty()
|
||||
&& pts.iter().all(|p| points.contains(p))
|
||||
// A circle's centre may move with another curve while its radius stays put.
|
||||
&& c.kind.indices().1.iter().all(|i| {
|
||||
curves.contains(i)
|
||||
|| !matches!(sk.curves.get(*i).map(|c| &c.kind), Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. }))
|
||||
})
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
let mut expressions = std::collections::BTreeMap::new();
|
||||
for i in selected {
|
||||
let Some(c) = sk.constraints.get_mut(i) else { continue };
|
||||
if let Some(value) = c.kind.value() {
|
||||
c.kind.set_value(value * factor);
|
||||
}
|
||||
if let Some(name) = &c.param
|
||||
&& let Some(param) = doc.param(name)
|
||||
{
|
||||
let expression = if solidcraft_doc::expr::references(¶m.expr).is_empty() {
|
||||
format!("{} mm", doc.eval(name, solidcraft_doc::expr::Kind::Length)? * factor)
|
||||
} else {
|
||||
let formula = param.expr.trim().strip_prefix('=').unwrap_or(¶m.expr);
|
||||
format!("({formula}) * {factor}")
|
||||
};
|
||||
expressions.insert(name.clone(), expression);
|
||||
}
|
||||
}
|
||||
for (name, mut expression) in expressions.clone() {
|
||||
for reference in solidcraft_doc::expr::references(&expression) {
|
||||
if expressions.contains_key(&reference) {
|
||||
expression = solidcraft_doc::expr::rename_reference(&expression, &reference, &format!("({reference} / {factor})"));
|
||||
}
|
||||
}
|
||||
doc.set_param(&name, &expression, None, None)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.scale";
|
||||
let k = num(p, "factor").filter(|k| *k > 1e-9 && *k < 1e6).ok_or_else(|| bad(cmd, "`factor` must be a positive number"))?;
|
||||
|
||||
@@ -1194,65 +1194,6 @@ fn transform_scales_turns_and_moves_in_one_step() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform must leave a dimensioned rectangle where its preview put it, rather than letting
|
||||
/// the old horizontal/vertical relations and size dimensions pull it back (#11617).
|
||||
#[test]
|
||||
fn transform_turns_and_scales_a_dimensioned_rectangle() {
|
||||
for angle in [30.0_f64, 90.0, 180.0] {
|
||||
let mut s = new_sketch();
|
||||
let drawn = run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": [25, 12.5]}));
|
||||
let curves = ids(&drawn["curves"]);
|
||||
let width = run(&mut s, "sketch.dimension", json!({"entities": [curves[0]], "type": "length", "value": "50 mm"}));
|
||||
let height = run(&mut s, "sketch.dimension", json!({"entities": [curves[1]], "type": "length", "value": "25 mm"}));
|
||||
let before = sketch(&s);
|
||||
let result = run(&mut s, "sketch.transform", json!({"entities": curves, "factor": 2, "angle": angle, "center": [0, 0]}));
|
||||
assert_eq!(result["sketch"]["solved"], true);
|
||||
let after = sketch(&s);
|
||||
let (sin, cos) = angle.to_radians().sin_cos();
|
||||
for id in ["p1", "p2", "p3", "p4"] {
|
||||
let p = before.point(before.resolve_point(id).unwrap()).unwrap();
|
||||
let expected = Vec2::new(p.x * cos - p.y * sin, p.x * sin + p.y * cos) * 2.0;
|
||||
let actual = after.point(after.resolve_point(id).unwrap()).unwrap();
|
||||
assert!(actual.dist(expected) < 1e-6, "{angle} degrees, {id}: {actual:?}, expected {expected:?}");
|
||||
}
|
||||
for (dimension, expected) in [(&width, 100.0), (&height, 50.0)] {
|
||||
let name = dimension["param"].as_str().unwrap();
|
||||
assert!(close(s.doc.eval(name, solidcraft_doc::expr::Kind::Length).unwrap(), expected));
|
||||
}
|
||||
assert!(close(profiles(&s)[0], 5000.0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(sketch(&s).constraints, before.constraints);
|
||||
assert!(close(profiles(&s)[0], 1250.0));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(close(profiles(&s)[0], 5000.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_scales_dimension_formulas_once_and_keeps_unselected_dimensions() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "parameters.add", json!({"name": "width", "expression": "50 mm"}));
|
||||
let rectangle = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [60, 35]}));
|
||||
let curves = ids(&rectangle["curves"]);
|
||||
let width = run(&mut s, "sketch.dimension", json!({"entities": [curves[0]], "type": "length", "value": "=width"}));
|
||||
let width_param = width["param"].as_str().unwrap();
|
||||
let height = run(&mut s, "sketch.dimension", json!({"entities": [curves[1]], "type": "length", "value": format!("{width_param} / 2")}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [curves[2]], "type": "length", "driven": true}));
|
||||
let circle = run(&mut s, "sketch.circle.center", json!({"center": [100, 100], "radius": 5}));
|
||||
let radius = run(&mut s, "sketch.dimension", json!({"entities": [circle["curves"][0]], "type": "radius", "value": "5 mm"}));
|
||||
let outside = s.doc.param(radius["param"].as_str().unwrap()).unwrap().clone();
|
||||
run(&mut s, "sketch.transform", json!({"entities": curves, "factor": 2, "angle": 90, "center": [35, 22.5]}));
|
||||
let length = solidcraft_doc::expr::Kind::Length;
|
||||
assert!(close(s.doc.eval("width", length).unwrap(), 50.0), "the user parameter stays unchanged");
|
||||
assert!(close(s.doc.eval(width_param, length).unwrap(), 100.0));
|
||||
assert!(close(s.doc.eval(height["param"].as_str().unwrap(), length).unwrap(), 50.0));
|
||||
assert_eq!(s.doc.param(&outside.name).unwrap(), &outside);
|
||||
assert!(close(sketch(&s).radius(sketch(&s).curve_index(circle["curves"][0].as_str().unwrap()).unwrap()).unwrap(), 5.0));
|
||||
run(&mut s, "parameters.change", json!({"name": "width", "expression": "60 mm"}));
|
||||
assert!(close(s.doc.eval(width_param, length).unwrap(), 120.0), "the formula still follows its user parameter");
|
||||
assert!(close(s.doc.eval(height["param"].as_str().unwrap(), length).unwrap(), 60.0), "dependent dimensions scale only once");
|
||||
}
|
||||
|
||||
/// #1485: after a circle is trimmed between two construction lines, clicking the trimmed end
|
||||
/// picks the construction line's end lying there, not the arc's own; the tangent arc still starts
|
||||
/// from the trimmed arc, tangent to it.
|
||||
|
||||
@@ -264,40 +264,6 @@ fn pieces_of_a_cut_body_keep_their_body_names_when_the_cut_moves() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A hole that cuts a body in three pieces names them as the design names them when it is
|
||||
/// evaluated again from the names it pinned, as on opening it. Found by the model fuzzer: the two
|
||||
/// smaller pieces swapped names there, so a later feature naming one of them met the other.
|
||||
#[test]
|
||||
fn pieces_of_a_cut_keep_their_names_when_the_design_is_evaluated_again() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "XZ", "name": "P8", "offset": 5}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "P8"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-7, -9], "p1": [5, -7]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [7, -3], "radius": 4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
|
||||
run(&mut s, "solid.hole", json!({"depth": 14, "diameter": 5, "position": [-1, 10, 8]}));
|
||||
// Each body by name, with where it is (two pieces have the same volume).
|
||||
let pieces = |st: &solidcraft_doc::ModelState| -> Vec<(String, String)> {
|
||||
st.bodies
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let m = solidcraft_kernel::measure(&b.body).unwrap();
|
||||
(b.name.clone(), format!("{:.3} {:.3} {:.3} {:.3}", m.volume, m.centroid.x, m.centroid.y, m.centroid.z))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let live = pieces(&s.model.state());
|
||||
assert_eq!(live.len(), 3, "{live:?}");
|
||||
let mut again = solidcraft_doc::Model::new();
|
||||
again.no_cache = true;
|
||||
again.evaluate(&s.doc);
|
||||
assert_eq!(pieces(&again.state()), live);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_of_a_body_without_history_is_found_by_geometry_and_says_so_once_it_moves() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -466,19 +466,6 @@ mod tests {
|
||||
d
|
||||
}
|
||||
|
||||
/// Ticks (bounded) until the autosave a tick started has been written and taken up, as the
|
||||
/// app's own frames would. A tick right after the one that started a write can find it still
|
||||
/// running under load and take nothing up, so a test that then changes the session (marking
|
||||
/// it saved) and checks the files would race the worker.
|
||||
fn tick_until_caught_up(a: &mut Autosaver, s: &Session) {
|
||||
let began = Instant::now();
|
||||
while !a.caught_up(s) {
|
||||
assert!(began.elapsed() < Duration::from_secs(30), "the autosave never caught up");
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
a.tick(s, false).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosave_only_unsaved_changes_and_recover_after_a_crash() {
|
||||
let d = dir("crash");
|
||||
@@ -603,7 +590,6 @@ mod tests {
|
||||
let mut a = Autosaver::new(&d, Duration::ZERO).unwrap();
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
|
||||
assert!(a.tick(&s, false).unwrap());
|
||||
tick_until_caught_up(&mut a, &s);
|
||||
s.mark_saved();
|
||||
a.tick(&s, false).unwrap();
|
||||
assert!(!file(&d, &a.id, solidcraft_io::DESIGN_EXT).exists());
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
//! Contract and result types for `inspect.compare` in `cmd/compare.rs` (#10994).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::check::whole;
|
||||
|
||||
/// Compare two bodies laid over each other, in world placement: measure both, sample each
|
||||
/// surface's distance to the other, and find, measure and (with `color`) colour the places where
|
||||
/// only one of them has material. It works from the two surfaces, without a boolean of the
|
||||
/// bodies, so near-identical bodies compare too.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct CompareParams {
|
||||
/// The first body, by name. Give both `a` and `b`, or neither: then the two selected bodies,
|
||||
/// or the design's only two.
|
||||
#[serde(default, deserialize_with = "body_name")]
|
||||
pub a: Option<String>,
|
||||
/// The second body, by name.
|
||||
#[serde(default, deserialize_with = "body_name")]
|
||||
pub b: Option<String>,
|
||||
/// Surfaces closer than this agree, in mm: 0.001 to 10 (default 0.01).
|
||||
#[serde(default, deserialize_with = "tolerance")]
|
||||
#[schemars(schema_with = "tolerance_schema")]
|
||||
pub tolerance: Option<f64>,
|
||||
/// The largest piece of a face sampled as one point, in mm: 0.01 to 1000 (default 0.5),
|
||||
/// coarser for a body that would need more than `max_points` samples.
|
||||
#[serde(default, deserialize_with = "spacing")]
|
||||
#[schemars(schema_with = "spacing_schema")]
|
||||
pub spacing: Option<f64>,
|
||||
/// The most samples per body: 10 000 to 50 million (default 8 million).
|
||||
#[serde(default, deserialize_with = "max_points")]
|
||||
#[schemars(schema_with = "max_points_schema")]
|
||||
pub max_points: Option<f64>,
|
||||
/// The most regions measured: 1 to 1000 (default 50); more are counted in `unlisted_regions`.
|
||||
#[serde(default, deserialize_with = "max_regions")]
|
||||
#[schemars(schema_with = "max_regions_schema")]
|
||||
pub max_regions: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub color: Option<CompareColor>,
|
||||
}
|
||||
|
||||
/// A body name, with the handler's message for anything else; `null` is none.
|
||||
fn body_name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(s) => Ok(Some(s)),
|
||||
_ => Err(D::Error::custom("must be a body name")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A number in `lo..=hi` with the handler's message, `null` none, for each ranged key.
|
||||
macro_rules! ranged {
|
||||
($read:ident, $schema:ident, $lo:literal, $hi:literal) => {
|
||||
fn $read<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
v => v
|
||||
.as_f64()
|
||||
.filter(|x| x.is_finite() && ($lo..=$hi).contains(x))
|
||||
.map(Some)
|
||||
.ok_or_else(|| D::Error::custom(format!("must be a number from {} to {}", $lo, $hi))),
|
||||
}
|
||||
}
|
||||
|
||||
fn $schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": ["number", "null"], "minimum": $lo, "maximum": $hi})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
ranged!(tolerance, tolerance_schema, 0.001, 10.0);
|
||||
ranged!(spacing, spacing_schema, 0.01, 1000.0);
|
||||
ranged!(max_points, max_points_schema, 1e4, 5e7);
|
||||
ranged!(max_regions, max_regions_schema, 1.0, 1000.0);
|
||||
|
||||
/// Colour the faces that hold a region: `true` for red where only `a` has material and green
|
||||
/// where only `b` has, or `{only_in_a?, only_in_b?}` to choose either colour (`"#rrggbb"` or
|
||||
/// `[r, g, b]`, 0 to 255). `false` colours nothing. One undo step.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CompareColor(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for CompareColor {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
match &v {
|
||||
Value::Bool(_) => {}
|
||||
Value::Object(o) => {
|
||||
if o.keys().any(|k| k != "only_in_a" && k != "only_in_b") {
|
||||
return Err(D::Error::custom("must be true or {only_in_a, only_in_b}"));
|
||||
}
|
||||
for k in ["only_in_a", "only_in_b"] {
|
||||
if o.get(k).is_some_and(|c| !c.is_null() && crate::cmd::color_param(c).is_none()) {
|
||||
return Err(D::Error::custom(whole(format!("`color.{k}` must be \"#rrggbb\" or [r, g, b] with values 0–255"))));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return Err(D::Error::custom("must be true or {only_in_a, only_in_b}")),
|
||||
}
|
||||
Ok(CompareColor(v))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for CompareColor {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"CompareColor".into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let color = || {
|
||||
serde_json::json!({
|
||||
"anyOf": [
|
||||
{"description": "\"#rrggbb\": six hex digits, the # optional.", "type": "string", "pattern": "^\\s*#?[0-9a-fA-F]{6}\\s*$"},
|
||||
{"type": "array", "items": {"type": "number", "minimum": 0, "maximum": 255}, "minItems": 3, "maxItems": 3},
|
||||
{"type": "null"}
|
||||
]
|
||||
})
|
||||
};
|
||||
json_schema!({
|
||||
"description": "Colour the faces that hold a region: true for red where only `a` has material and green where only `b` has, or {only_in_a?, only_in_b?} to choose either colour (\"#rrggbb\" or [r, g, b], 0 to 255). false colours nothing.",
|
||||
"anyOf": [
|
||||
{"type": "boolean"},
|
||||
{"type": "object", "properties": {"only_in_a": color(), "only_in_b": color()}, "additionalProperties": false}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What `inspect.compare` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Comparison {
|
||||
/// `a`'s measures, as `inspect.measure` gives a body's.
|
||||
pub a: Map<String, Value>,
|
||||
/// `b`'s measures, as `inspect.measure` gives a body's.
|
||||
pub b: Map<String, Value>,
|
||||
/// `b` less `a`.
|
||||
pub difference: Difference,
|
||||
/// The tolerance used (mm).
|
||||
pub tolerance_mm: f64,
|
||||
/// How far each surface is from the other.
|
||||
pub deviation: Deviations,
|
||||
/// Whether no region differs by more than the tolerance.
|
||||
pub identical: bool,
|
||||
/// The comparison in a sentence.
|
||||
pub verdict: String,
|
||||
/// The regions where only one body has material, largest first.
|
||||
pub regions: Vec<Region>,
|
||||
/// The regions with material only in `a`, and their volume together.
|
||||
pub only_in_a: SideTotal,
|
||||
/// The regions with material only in `b`, and their volume together.
|
||||
pub only_in_b: SideTotal,
|
||||
/// Regions beyond `max_regions`, counted but not measured.
|
||||
pub unlisted_regions: usize,
|
||||
/// What makes the comparison less certain: a body whose surface doesn't enclose what it
|
||||
/// measures, a cut that couldn't be made, a body that encloses no volume.
|
||||
pub warnings: Vec<String>,
|
||||
/// How the surfaces were sampled.
|
||||
pub sampling: Sampling,
|
||||
/// With `color`: how many faces were coloured.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub colored_faces: Option<usize>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// `b` less `a`; `null` where either has no such measure.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Difference {
|
||||
pub volume_mm3: Option<f64>,
|
||||
/// The volume difference as a share of `a`'s (%).
|
||||
pub volume_percent: Option<f64>,
|
||||
pub area_mm2: Option<f64>,
|
||||
pub center_of_mass: Option<[f64; 3]>,
|
||||
pub center_of_mass_distance_mm: Option<f64>,
|
||||
pub bbox_min: Option<[f64; 3]>,
|
||||
pub bbox_max: Option<[f64; 3]>,
|
||||
}
|
||||
|
||||
/// How far each surface is from the other.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Deviations {
|
||||
/// From `a`'s surface to `b`'s.
|
||||
pub a_to_b: Deviation,
|
||||
/// From `b`'s surface to `a`'s.
|
||||
pub b_to_a: Deviation,
|
||||
/// The larger of the two maxima (mm).
|
||||
pub max_mm: f64,
|
||||
}
|
||||
|
||||
/// One surface's distance to the other, over its samples.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Deviation {
|
||||
pub max_mm: f64,
|
||||
pub mean_mm: f64,
|
||||
/// Where the largest is.
|
||||
pub at: [f64; 3],
|
||||
/// The area further than the tolerance (mm²).
|
||||
pub area_beyond_mm2: f64,
|
||||
/// The area sampled (mm²).
|
||||
pub area_mm2: f64,
|
||||
/// How many samples.
|
||||
pub points: usize,
|
||||
}
|
||||
|
||||
/// Which body alone has a region's material.
|
||||
#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CompareSide {
|
||||
OnlyInA,
|
||||
OnlyInB,
|
||||
}
|
||||
|
||||
/// A region where only one body has material.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Region {
|
||||
/// Its number, from 1, largest first.
|
||||
pub index: usize,
|
||||
pub side: CompareSide,
|
||||
/// The body that has the material.
|
||||
pub material_of: String,
|
||||
/// Its volume (mm³), measured exactly on the pieces of the cut on its side (a − b or b − a)
|
||||
/// that lie there; `null` when it isn't measured, never an estimate.
|
||||
pub volume_mm3: Option<f64>,
|
||||
/// `boolean` when the volume, centre and box were measured on the cut, else `not measured`.
|
||||
pub measured_by: MeasuredBy,
|
||||
/// Why it isn't measured (the cut couldn't be made, a body encloses no volume, no piece fell
|
||||
/// here); `null` when it is.
|
||||
pub not_measured: Option<String>,
|
||||
/// Its surface on both bodies (mm²).
|
||||
pub area_mm2: f64,
|
||||
pub area_a_mm2: f64,
|
||||
pub area_b_mm2: f64,
|
||||
/// How far the surfaces are apart there at most (mm).
|
||||
pub max_deviation_mm: f64,
|
||||
/// Where that is.
|
||||
pub at: [f64; 3],
|
||||
/// Its centre of volume, measured with the volume; `null` when it isn't measured.
|
||||
pub center: Option<[f64; 3]>,
|
||||
/// The measured pieces' box, or the box the region was found in when it isn't measured.
|
||||
pub bbox: Bounds,
|
||||
/// The faces of `a` it lies on, by index, as `model.faces` lists them.
|
||||
pub faces_a: Vec<u32>,
|
||||
/// The faces of `b` it lies on.
|
||||
pub faces_b: Vec<u32>,
|
||||
/// The lines through it the surfaces were sampled along.
|
||||
pub resolution: Resolution,
|
||||
}
|
||||
|
||||
/// How a region was measured.
|
||||
#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
|
||||
pub enum MeasuredBy {
|
||||
/// Exactly, on the pieces of the cut that lie there.
|
||||
#[serde(rename = "boolean")]
|
||||
Boolean,
|
||||
/// Not at all: see `not_measured`.
|
||||
#[serde(rename = "not measured")]
|
||||
NotMeasured,
|
||||
}
|
||||
|
||||
/// A box, by its corners.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Bounds {
|
||||
pub min: [f64; 3],
|
||||
pub max: [f64; 3],
|
||||
}
|
||||
|
||||
/// The lines through a region the surfaces were sampled along.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Resolution {
|
||||
/// The axis they run along: `X`, `Y` or `Z`.
|
||||
pub along: String,
|
||||
/// Their spacing across the other two axes (mm).
|
||||
pub line_spacing_mm: [f64; 2],
|
||||
pub lines: usize,
|
||||
/// Lines that crossed a gap in a surface and were left out.
|
||||
pub skipped_lines: usize,
|
||||
}
|
||||
|
||||
/// The regions on one side, and their volume together.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SideTotal {
|
||||
pub regions: usize,
|
||||
/// Their volume together (mm³); `null` when one of them isn't measured.
|
||||
pub volume_mm3: Option<f64>,
|
||||
}
|
||||
|
||||
/// How the surfaces were sampled.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Sampling {
|
||||
/// The sample spacing used (mm).
|
||||
pub spacing_mm: f64,
|
||||
/// How closely the meshes follow the surfaces (mm): a quarter of the tolerance.
|
||||
pub chord_mm: f64,
|
||||
pub triangles_a: usize,
|
||||
pub triangles_b: usize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "compare_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,74 +0,0 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CompareParams, Comparison};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<CompareParams>(), &["a", "b", "color", "max_points", "max_regions", "spacing", "tolerance"]);
|
||||
}
|
||||
|
||||
/// The handler reads the numbers and the colours before it looks for the bodies, so each of
|
||||
/// these fails the same way on a design with none.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("inspect.compare", json!({"tolerance": 0})),
|
||||
("inspect.compare", json!({"tolerance": "x"})),
|
||||
("inspect.compare", json!({"spacing": 2000})),
|
||||
("inspect.compare", json!({"max_points": 1})),
|
||||
("inspect.compare", json!({"max_regions": 0})),
|
||||
("inspect.compare", json!({"color": 5})),
|
||||
("inspect.compare", json!({"color": "red"})),
|
||||
("inspect.compare", json!({"color": {"only_in_a": "#zz0000"}})),
|
||||
("inspect.compare", json!({"color": {"only_in_b": [1, 2]}})),
|
||||
("inspect.compare", json!({"a": 5})),
|
||||
("inspect.compare", json!({"a": "A"})),
|
||||
("inspect.compare", json!({})),
|
||||
("inspect.compare", json!({"a": "X", "b": "Y"})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// #10994: a comparison replies as its output schema says, in Warn without warnings for good
|
||||
/// calls and with them for a misspelt key: a box and its copy (identical, no regions), and the
|
||||
/// copy with a boss, compared with every key and coloured both ways.
|
||||
#[test]
|
||||
fn a_comparison_replies_with_its_schema() {
|
||||
let schema = output_schema_for::<Comparison>();
|
||||
let matches = |r: &Value, warned: bool| {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
|
||||
assert_eq!(r.get("param_warnings").is_some(), warned, "{r}");
|
||||
};
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "A"}));
|
||||
run(&mut s, "solid.move", json!({"bodies": ["A"], "translate": [0, 0, 0], "copy": true}));
|
||||
let r = run(&mut s, "inspect.compare", json!({}));
|
||||
matches(&r, false);
|
||||
assert!(r["identical"] == true && r["regions"] == json!([]) && r.get("colored_faces").is_none(), "{r}");
|
||||
run(&mut s, "solid.box", json!({"length": 6, "width": 5, "height": 2, "corner": [5, 5, 10], "operation": "join", "targets": ["A (2)"]}));
|
||||
let r = run(
|
||||
&mut s,
|
||||
"inspect.compare",
|
||||
json!({"a": "A", "b": "A (2)", "tolerance": 0.01, "spacing": 0.5, "max_points": 1e6, "max_regions": 10, "color": true}),
|
||||
);
|
||||
matches(&r, false);
|
||||
assert_eq!((r["regions"][0]["side"].as_str(), r["identical"].as_bool()), (Some("only_in_b"), Some(false)), "{r}");
|
||||
assert!(r["colored_faces"].as_u64().is_some_and(|n| n > 0), "{r}");
|
||||
let r = run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)", "color": {"only_in_b": "#123456", "only_in_a": null}}));
|
||||
matches(&r, false);
|
||||
matches(&run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)", "color": false, "tolerence": 1})), true);
|
||||
}
|
||||
|
||||
/// A `color` string in the published schema carries the same pattern the handler checks.
|
||||
#[test]
|
||||
fn the_color_string_has_a_pattern() {
|
||||
let schema = serde_json::to_string(&input_schema_for::<CompareParams>()).unwrap();
|
||||
assert!(schema.contains(r"^\\s*#?[0-9a-fA-F]{6}\\s*$"), "{schema}");
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
//! Contracts and result types for the commands in `cmd/metadata.rs`.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// Read the design's properties: its `metadata` object as saved, and SolidCraft's own design
|
||||
/// properties (project, part number, revision…) as text.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct MetadataParams {}
|
||||
|
||||
/// What `document.metadata` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MetadataResult {
|
||||
/// The design's metadata object as saved, other tools' keys included.
|
||||
pub metadata: Map<String, Value>,
|
||||
/// Each of SolidCraft's design properties, by key: its text, or null when it is absent or
|
||||
/// not text.
|
||||
pub properties: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// Change design properties in one undo step: `key` and `value`, or `values` for several at
|
||||
/// once (all or nothing). A change that sets what is already there makes no undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct MetadataSetParams {
|
||||
/// The property to set: one of SolidCraft's keys (the ones `document.metadata`'s
|
||||
/// `properties` lists), which take text, or another tool's reverse-DNS key such as
|
||||
/// `com.example.tool`, which takes an object. `bom_type` is a component's property and is
|
||||
/// refused here. Needs `value`; not with `values`.
|
||||
#[serde(default)]
|
||||
pub key: Option<String>,
|
||||
/// The value for `key`: text for SolidCraft's keys, an object for another tool's. `""` or
|
||||
/// null removes the key. Required with `key`.
|
||||
#[serde(default)]
|
||||
#[schemars(with = "Value")]
|
||||
pub value: Option<Value>,
|
||||
/// Several properties at once, `{key: value, …}`, at most 256 keys, each as `key` and
|
||||
/// `value` take them. Not with `key`.
|
||||
#[serde(default)]
|
||||
pub values: Option<Map<String, Value>>,
|
||||
}
|
||||
|
||||
/// What `document.metadata.set` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MetadataSetResult {
|
||||
/// The design's metadata object after the change.
|
||||
pub metadata: Map<String, Value>,
|
||||
/// The keys whose value changed, in key order.
|
||||
pub changed: Vec<String>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// Remove design properties in one undo step: `key`, or `keys` for several. Any key can go,
|
||||
/// also one another tool wrote; a key that isn't there is left out of `removed`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct MetadataRemoveParams {
|
||||
/// The property to remove. Not with `keys`.
|
||||
#[serde(default)]
|
||||
pub key: Option<String>,
|
||||
/// The properties to remove, at most 256. Not with `key`.
|
||||
#[serde(default)]
|
||||
pub keys: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// What `document.metadata.remove` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct MetadataRemoveResult {
|
||||
/// The design's metadata object after the change.
|
||||
pub metadata: Map<String, Value>,
|
||||
/// The keys that were there and are removed, each once, in the order asked.
|
||||
pub removed: Vec<String>,
|
||||
/// 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 = "metadata_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,54 +0,0 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{MetadataParams, MetadataRemoveParams, MetadataRemoveResult, MetadataResult, MetadataSetParams, MetadataSetResult};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<MetadataParams>(), &[]);
|
||||
assert_keys(input_schema_for::<MetadataSetParams>(), &["key", "value", "values"]);
|
||||
assert_keys(input_schema_for::<MetadataRemoveParams>(), &["key", "keys"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("document.metadata.set", json!({"key": 5, "value": "Gearbox"})),
|
||||
("document.metadata.remove", json!({"key": 5})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Every form of the three commands, in Warn: each reply matches its output schema and carries
|
||||
/// no warning; a misspelt key's warning still matches.
|
||||
#[test]
|
||||
fn replies_match_the_output_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let matches = |schema: schemars::Schema, reply: &Value| {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, reply), "{reply} doesn't match {schema}");
|
||||
};
|
||||
let set = |reply: &Value| matches(output_schema_for::<MetadataSetResult>(), reply);
|
||||
let remove = |reply: &Value| matches(output_schema_for::<MetadataRemoveResult>(), reply);
|
||||
let calls = [
|
||||
("document.metadata.set", json!({"values": {"author": "Jane Doe", "com.acme.shop": {"shop_id": 88123}}})),
|
||||
("document.metadata.set", json!({"key": "project", "value": "Gearbox"})),
|
||||
("document.metadata.set", json!({"key": "project", "value": null})),
|
||||
("document.metadata.remove", json!({"keys": ["author", "author", "not.there"]})),
|
||||
("document.metadata.remove", json!({"key": "com.acme.shop"})),
|
||||
];
|
||||
for (id, p) in calls {
|
||||
let reply = run(id, p.clone());
|
||||
assert!(reply.get("param_warnings").is_none(), "{id} {p}: {reply}");
|
||||
if id == "document.metadata.set" { set(&reply) } else { remove(&reply) }
|
||||
}
|
||||
run("document.metadata.set", json!({"key": "part_number", "value": "ACME-1042"}));
|
||||
let read = run("document.metadata", json!({}));
|
||||
assert_eq!(read["properties"]["part_number"], "ACME-1042");
|
||||
matches(output_schema_for::<MetadataResult>(), &read);
|
||||
let warned = run("document.metadata.set", json!({"key": "author", "value": "J", "valeu": 1}));
|
||||
assert!(warned["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{warned}");
|
||||
set(&warned);
|
||||
}
|
||||
@@ -58,7 +58,6 @@ pub mod appearance;
|
||||
pub mod bom;
|
||||
pub mod browser;
|
||||
pub mod clearance;
|
||||
pub mod compare;
|
||||
pub mod component;
|
||||
pub mod config;
|
||||
pub mod construct;
|
||||
@@ -71,13 +70,11 @@ pub mod joints;
|
||||
pub mod libraries;
|
||||
pub mod mass_properties;
|
||||
pub mod material_properties;
|
||||
pub mod metadata;
|
||||
pub mod motion;
|
||||
pub mod panels;
|
||||
pub mod print_face;
|
||||
pub mod render;
|
||||
pub mod render_lights;
|
||||
pub mod render_stage;
|
||||
pub mod render_ui;
|
||||
pub mod section;
|
||||
pub mod sheet;
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
//! Contracts and result type for the render stage commands in `cmd/render/staging.rs` (#11455).
|
||||
//! A stage is a set of render-only panels saved with a render setup. The handlers read these
|
||||
//! same parameter types, so each contract is exactly what its handler takes; like the handlers,
|
||||
//! a contract refuses unknown keys.
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
fn patch() -> Value {
|
||||
serde_json::json!({})
|
||||
}
|
||||
|
||||
fn set_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "object", "maxProperties": 13, "default": {}})
|
||||
}
|
||||
|
||||
fn margin() -> f64 {
|
||||
0.25
|
||||
}
|
||||
|
||||
fn gap() -> f64 {
|
||||
0.1
|
||||
}
|
||||
|
||||
fn reflectance() -> [f64; 3] {
|
||||
[0.18; 3]
|
||||
}
|
||||
|
||||
/// Fit one ground panel under the visible product, a little below its lowest point, and save
|
||||
/// it with a render setup in one undo step. The setup must render with the CPU raytracer or the
|
||||
/// GPU path tracer. The product's clearance from the panel is checked again on every render.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GroundParams {
|
||||
/// The render setup, saved or new.
|
||||
pub name: String,
|
||||
/// Changes to the setup before the ground is fitted, as `render.settings.set` takes them:
|
||||
/// an object of at most 13 setup fields. Default: none.
|
||||
#[serde(default = "patch")]
|
||||
#[schemars(schema_with = "set_schema")]
|
||||
pub set: Value,
|
||||
/// How far the ground reaches past the product on each side, as a share of the product's
|
||||
/// largest size: 0 to 10. Default: 0.25.
|
||||
#[serde(default = "margin")]
|
||||
#[schemars(range(min = 0.0, max = 10.0))]
|
||||
pub margin_fraction: f64,
|
||||
/// The gap between the product's lowest point and the ground (mm): 0.0001 to 1000.
|
||||
/// Default: 0.1.
|
||||
#[serde(default = "gap")]
|
||||
#[schemars(range(min = 0.0001, max = 1000.0))]
|
||||
pub gap_mm: f64,
|
||||
/// How much light the ground reflects, as linear red, green and blue: each 0 to 1.
|
||||
/// Default: 0.18 each.
|
||||
#[serde(default = "reflectance")]
|
||||
pub reflectance_linear: [f64; 3],
|
||||
/// Replace a stage the setup already has. Without it, such a setup is refused.
|
||||
#[serde(default)]
|
||||
pub replace_stage: bool,
|
||||
}
|
||||
|
||||
/// Fit a floor and a wall, or a curved cyclorama of 24 flat strips, behind the visible product,
|
||||
/// facing the setup's camera, and save it with the render setup in one undo step. The setup
|
||||
/// must render with the CPU raytracer or the GPU path tracer. World +Z is up. This is a finite
|
||||
/// background, not an infinite floor or a smooth CAD face.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BackdropParams {
|
||||
/// The render setup, saved or new.
|
||||
pub name: String,
|
||||
/// Changes to the setup before the backdrop is fitted, as `render.settings.set` takes them:
|
||||
/// an object of at most 13 setup fields. Default: none.
|
||||
#[serde(default = "patch")]
|
||||
#[schemars(schema_with = "set_schema")]
|
||||
pub set: Value,
|
||||
pub kind: BackdropKind,
|
||||
/// How much light the panels reflect, as linear red, green and blue: each 0 to 1.
|
||||
/// Default: 0.18 each.
|
||||
#[serde(default = "reflectance")]
|
||||
pub reflectance_linear: [f64; 3],
|
||||
/// Replace a stage the setup already has. Without it, such a setup is refused.
|
||||
#[serde(default)]
|
||||
pub replace_stage: bool,
|
||||
/// The design's revision when the caller last read it, as `document.inspect` gives it. If
|
||||
/// the design changed since, the call is refused: read it again and retry.
|
||||
pub expected_revision: u64,
|
||||
}
|
||||
|
||||
/// The backdrop's shape.
|
||||
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BackdropKind {
|
||||
/// A floor and a flat wall.
|
||||
FloorWall,
|
||||
/// A floor that curves up into the wall, in 24 flat strips.
|
||||
Cyclorama,
|
||||
}
|
||||
|
||||
/// Remove the stage from a render setup in one undo step. The design's geometry and the
|
||||
/// setup's lighting stay as they are.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ClearParams {
|
||||
/// The render setup, saved or new.
|
||||
pub name: String,
|
||||
/// Changes to the setup made with the removal, as `render.settings.set` takes them:
|
||||
/// an object of at most 13 setup fields. Default: none.
|
||||
#[serde(default = "patch")]
|
||||
#[schemars(schema_with = "set_schema")]
|
||||
pub set: Value,
|
||||
}
|
||||
|
||||
/// What `render.stage.ground`, `render.stage.backdrop` and `render.stage.clear` return.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct StageSaved {
|
||||
/// The render setup.
|
||||
pub name: String,
|
||||
/// The setup as saved, as the render settings commands give it.
|
||||
pub setup: Value,
|
||||
/// Always true: the design's bodies don't change.
|
||||
pub engineering_geometry_unchanged: bool,
|
||||
/// Always true: the panels exist only in renders.
|
||||
pub stage_only: bool,
|
||||
/// How the product's clearance from the panels is checked.
|
||||
pub clearance: String,
|
||||
/// A backdrop's shape: `finite_floor_and_wall` or `24_segment_polygonal_cyclorama`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub geometry_kind: Option<String>,
|
||||
/// A backdrop's part in the lighting.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub light_transport: Option<String>,
|
||||
/// A backdrop: always true, the setup's camera stays as it is.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub camera_preserved: Option<bool>,
|
||||
/// A backdrop's limits, in sentences.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limitations: Option<Vec<String>>,
|
||||
/// 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 = "render_stage_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,99 +0,0 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{BackdropParams, ClearParams, GroundParams, StageSaved};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
/// A design with a box and a saved render setup, `Shot`, that renders with the GPU path tracer,
|
||||
/// at the parameter-check level `level`.
|
||||
fn with_a_shot(level: ParamCheck) -> Session {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
for (id, p) in [
|
||||
("solid.box", json!({"length": 20, "width": 15, "height": 10, "body_name": "Subject"})),
|
||||
("render.settings.set", json!({"name": "Shot", "set": {"width": 96, "height": 64, "job": {"method": "gpu_path", "options": {}}}})),
|
||||
] {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<GroundParams>(), &["gap_mm", "margin_fraction", "name", "reflectance_linear", "replace_stage", "set"]);
|
||||
assert_keys(input_schema_for::<BackdropParams>(), &["expected_revision", "kind", "name", "reflectance_linear", "replace_stage", "set"]);
|
||||
assert_keys(input_schema_for::<ClearParams>(), &["name", "set"]);
|
||||
}
|
||||
|
||||
/// The handlers refuse these calls after reading them; strict refuses them with their message.
|
||||
/// They need a design with a body and a saved setup, and the design's revision, so the shared
|
||||
/// helper, which has a sketch only, doesn't fit.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let cases: [(&str, fn(u64) -> Value); 12] = [
|
||||
("render.stage.ground", |_| json!({"name": "Shot", "margin_fraction": 11})),
|
||||
("render.stage.ground", |_| json!({"name": "Shot", "gap_mm": 0})),
|
||||
("render.stage.ground", |_| json!({"name": "Shot", "reflectance_linear": [2, 0, 0]})),
|
||||
("render.stage.ground", |_| json!({"name": "Raster"})),
|
||||
("render.stage.ground", |_| json!({"name": " Shot"})),
|
||||
("render.stage.ground", |_| json!({"name": "Shot", "set": {"width": 0}})),
|
||||
("render.stage.backdrop", |_| json!({"name": "Shot", "kind": "cyclorama", "expected_revision": 999_999})),
|
||||
("render.stage.backdrop", |r| json!({"name": "Raster", "kind": "floor_wall", "expected_revision": r})),
|
||||
("render.stage.backdrop", |r| json!({"name": "Shot", "kind": "floor_wall", "reflectance_linear": [0, -1, 0], "expected_revision": r})),
|
||||
("render.stage.clear", |_| json!({"name": ""})),
|
||||
("render.stage.clear", |_| json!({"name": "Shot", "set": {"width": 0}})),
|
||||
("render.stage.clear", |_| json!({"name": "Shot", "set": 5})),
|
||||
];
|
||||
for (id, p) in cases {
|
||||
let error = |level: ParamCheck| {
|
||||
let mut s = with_a_shot(level);
|
||||
let p = p(s.revision);
|
||||
s.execute(id, &p).err().map(|e| e.to_string())
|
||||
};
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.is_some(), "{id} {} should fail today", p(0));
|
||||
assert_eq!(error(ParamCheck::Strict), off, "{id} {}", p(0));
|
||||
}
|
||||
// A stage already there needs `replace_stage`, for a ground and a backdrop alike.
|
||||
for level in [ParamCheck::Off, ParamCheck::Strict] {
|
||||
let mut s = with_a_shot(level);
|
||||
s.execute("render.stage.ground", &json!({"name": "Shot"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let ground = s.execute("render.stage.ground", &json!({"name": "Shot"})).err().map(|e| e.to_string());
|
||||
let p = json!({"name": "Shot", "kind": "floor_wall", "expected_revision": s.revision});
|
||||
let backdrop = s.execute("render.stage.backdrop", &p).err().map(|e| e.to_string());
|
||||
for error in [ground, backdrop] {
|
||||
assert!(error.as_deref().is_some_and(|e| e.contains("replace_stage: true")), "{level:?}: {error:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #11455: a ground, both backdrops and a clear reply as the output schema says, in Warn
|
||||
/// without warnings; only a backdrop carries its shape and limits. Like the handlers, warn and
|
||||
/// strict refuse a misspelt key.
|
||||
#[test]
|
||||
fn stage_commands_reply_with_their_schema() {
|
||||
let schema = output_schema_for::<StageSaved>();
|
||||
let schema = schema.as_value();
|
||||
let mut s = with_a_shot(ParamCheck::Warn);
|
||||
let run = |s: &mut Session, id: &str, p: Value| {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{r}");
|
||||
r
|
||||
};
|
||||
let p = json!({"name": "Shot", "set": {}, "margin_fraction": 0.5, "gap_mm": 1, "reflectance_linear": [0.2, 0.2, 0.2], "replace_stage": false});
|
||||
let r = run(&mut s, "render.stage.ground", p);
|
||||
assert!(r["setup"].is_object() && r.get("geometry_kind").is_none(), "{r}");
|
||||
for kind in ["floor_wall", "cyclorama"] {
|
||||
let revision = s.revision;
|
||||
let p = json!({"name": "Shot", "kind": kind, "replace_stage": true, "expected_revision": revision});
|
||||
let r = run(&mut s, "render.stage.backdrop", p);
|
||||
assert!(r["geometry_kind"].is_string() && r["limitations"].is_array(), "{r}");
|
||||
}
|
||||
run(&mut s, "render.stage.clear", json!({"name": "Shot", "set": {}}));
|
||||
for level in [ParamCheck::Warn, ParamCheck::Strict] {
|
||||
let mut s = with_a_shot(level);
|
||||
let error = s.execute("render.stage.clear", &json!({"nme": "Shot"})).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("nme"), "{level:?}: {error}");
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,9 @@ doc.recover
|
||||
doc.recovery_discard
|
||||
doc.recovery_list
|
||||
document.inspect
|
||||
document.metadata
|
||||
document.metadata.remove
|
||||
document.metadata.set
|
||||
engine.commands
|
||||
explode.create
|
||||
explode.delete
|
||||
@@ -75,6 +78,7 @@ frame.member
|
||||
frame.profile.insert
|
||||
frame.profile.list
|
||||
frame.profile.properties
|
||||
inspect.compare
|
||||
inspect.interference
|
||||
inspect.measure
|
||||
inspect.parameter_sweep
|
||||
@@ -180,6 +184,9 @@ render.settings.set
|
||||
render.shot.duplicate
|
||||
render.shot.remove
|
||||
render.shot.rename
|
||||
render.stage.backdrop
|
||||
render.stage.clear
|
||||
render.stage.ground
|
||||
render.start
|
||||
render.status
|
||||
select.clear
|
||||
|
||||
@@ -4681,55 +4681,6 @@ mod tests {
|
||||
mod glass_tests {
|
||||
use super::*;
|
||||
|
||||
/// Intel UHD 620 OpenGL collapsed long line quads or expanded short ones across the
|
||||
/// viewport with vector/scalar direction division (#8053). Exercise the actual
|
||||
/// OpenGL shader rather than the native headless selector, which skips GL on Windows.
|
||||
#[test]
|
||||
fn opengl_draws_grid_lines_in_every_direction_and_at_a_point() {
|
||||
let mut descriptor = crate::instance_descriptor_from_env();
|
||||
descriptor.backends = wgpu::Backends::GL;
|
||||
let instance = wgpu::Instance::new(descriptor);
|
||||
let policy = crate::select::Policy::from_env().expect("a valid GPU test policy");
|
||||
let Some((adapter, _)) = crate::select::pick_among(pollster::block_on(instance.enumerate_adapters(wgpu::Backends::GL)), policy) else {
|
||||
eprintln!("skipped: no OpenGL adapter qualifies under {}", policy.name());
|
||||
return;
|
||||
};
|
||||
eprintln!("OpenGL regression adapter: {:?}", adapter.get_info());
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: wgpu::Limits::downlevel_webgl2_defaults().using_resolution(adapter.limits()),
|
||||
..Default::default()
|
||||
}))
|
||||
.expect("the OpenGL adapter opens a device");
|
||||
let window = GpuTarget { format: wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false, oit: false };
|
||||
// Ten world units span 32 pixels here; a four-pixel line covers over 100 pixels.
|
||||
// A point or a very short line stays a small cap, never a fill over the viewport.
|
||||
let cases = [
|
||||
([-5.0, 0.0, 0.0], [5.0, 0.0, 0.0], 100..=256),
|
||||
([5.0, 0.0, 0.0], [-5.0, 0.0, 0.0], 100..=256),
|
||||
([0.0, -5.0, 0.0], [0.0, 5.0, 0.0], 100..=256),
|
||||
([0.0, 5.0, 0.0], [0.0, -5.0, 0.0], 100..=256),
|
||||
([-5.0, -5.0, 0.0], [5.0, 5.0, 0.0], 120..=256),
|
||||
([5.0, 5.0, 0.0], [-5.0, -5.0, 0.0], 120..=256),
|
||||
([-5.0, 5.0, 0.0], [5.0, -5.0, 0.0], 120..=256),
|
||||
([0.0; 3], [0.0; 3], 10..=64),
|
||||
([0.0; 3], [0.0001, 0.0001, 0.0], 10..=64),
|
||||
];
|
||||
for offscreen in [false, true] {
|
||||
let target = if offscreen { GpuTarget { depth: Some(wgpu::TextureFormat::Depth24PlusStencil8), ..window } } else { window };
|
||||
let mut renderer = Renderer::with_window_target(&device, target, window, None);
|
||||
for (index, (a, b, expected)) in cases.iter().enumerate() {
|
||||
let mut scene = GpuScene::default();
|
||||
let red = scene.color([255, 0, 0, 255]);
|
||||
scene.under_line(*a, *b, red, 4.0);
|
||||
let mut frame = test_frame(scene, 1.0);
|
||||
frame.key = index as u64 + 1;
|
||||
let pixels = render_frame(&mut renderer, &device, &queue, &frame, false);
|
||||
let coloured = pixels.as_chunks::<4>().0.iter().filter(|p| p[0] > 240 && p[1] < 30 && p[2] < 30).count();
|
||||
assert!(expected.contains(&coloured), "{a:?} to {b:?}, offscreen {offscreen}: {coloured} line pixels, expected {expected:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ground_shadow_does_not_require_order_independent_transparency() {
|
||||
let batch = || Some(Batch { parts: Vec::new(), chunk: TRI_SIZE * 6 });
|
||||
|
||||
@@ -60,33 +60,10 @@ fn host(url: &str) -> &str {
|
||||
rest.split(['/', '?', '#']).next().unwrap_or(rest)
|
||||
}
|
||||
|
||||
/// A selectable label of the conversation. egui leaves a label scrolled out of view out of the
|
||||
/// text selection, so a selection whose start had scrolled away was dropped, and Copy missed the
|
||||
/// hidden text (#11527). Out of view this one still takes part: it keeps its end of the selection
|
||||
/// and gives its text to Copy, clipped to nothing so it is not drawn and never moves the selection.
|
||||
pub(super) fn selectable(ui: &mut egui::Ui, label: egui::Label) -> egui::Response {
|
||||
use egui::text_selection::LabelSelectionState;
|
||||
let (pos, galley, response) = label.selectable(true).layout_in_ui(ui);
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), galley.text()));
|
||||
// As `egui::Label` draws a label that is not a button.
|
||||
let colour = ui.visuals().text_color();
|
||||
if ui.is_rect_visible(response.rect) {
|
||||
let underline = if response.has_focus() || response.highlighted() { Stroke::new(1.0, colour) } else { Stroke::NONE };
|
||||
LabelSelectionState::label_text_selection(ui, &response, pos, galley, colour, underline);
|
||||
} else if ui.ctx().plugin::<LabelSelectionState>().lock().has_selection() {
|
||||
let clip = ui.clip_rect();
|
||||
ui.set_clip_rect(egui::Rect::NOTHING);
|
||||
LabelSelectionState::label_text_selection(ui, &response, pos, galley, colour, Stroke::NONE);
|
||||
ui.set_clip_rect(clip);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
/// A paragraph's text, then its links as buttons (a laid-out text cannot be clicked in parts).
|
||||
fn spans(ui: &mut egui::Ui, spans: &[Span], size: f32, color: Color32) {
|
||||
let t = Tokens::get();
|
||||
let text = job(ui, spans, size, color);
|
||||
selectable(ui, egui::Label::new(text).wrap());
|
||||
ui.add(egui::Label::new(job(ui, spans, size, color)).selectable(true).wrap());
|
||||
let links: Vec<(&str, &str)> =
|
||||
spans.iter().filter_map(|s| if let Span::Link { label, url } = s { Some((label.as_str(), url.as_str())) } else { None }).collect();
|
||||
if !links.is_empty() {
|
||||
@@ -115,7 +92,7 @@ pub fn show(ui: &mut egui::Ui, slot: usize, src: &str) {
|
||||
_ => t.font_heading,
|
||||
};
|
||||
ui.add_space(4.0);
|
||||
selectable(ui, egui::Label::new(RichText::new(markdown::plain(s)).size(size).semibold().color(t.text)).wrap());
|
||||
ui.add(egui::Label::new(RichText::new(markdown::plain(s)).size(size).semibold().color(t.text)).selectable(true).wrap());
|
||||
}
|
||||
Block::Code { text, .. } => {
|
||||
crate::ui_kit::Container::show_backing(
|
||||
@@ -123,9 +100,10 @@ pub fn show(ui: &mut egui::Ui, slot: usize, src: &str) {
|
||||
egui::Frame::NONE.fill(t.field).corner_radius(t.radius_sm).inner_margin(Margin::same(6)),
|
||||
|ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
selectable(
|
||||
ui,
|
||||
egui::Label::new(RichText::new(text).monospace().size(size - (t.font_button - t.font_body)).color(t.text)).wrap(),
|
||||
ui.add(
|
||||
egui::Label::new(RichText::new(text).monospace().size(size - (t.font_button - t.font_body)).color(t.text))
|
||||
.selectable(true)
|
||||
.wrap(),
|
||||
);
|
||||
if crate::ui_kit::button(ui, tr("ui.agent_chat_copy"), crate::ui_kit::Button::Small).clicked() {
|
||||
ui.ctx().copy_text(text.clone());
|
||||
@@ -151,7 +129,7 @@ pub fn show(ui: &mut egui::Ui, slot: usize, src: &str) {
|
||||
}
|
||||
Block::Pre(text) => {
|
||||
egui::ScrollArea::horizontal().id_salt(("agent_chat_pre", slot, text.len())).show(ui, |ui| {
|
||||
selectable(ui, egui::Label::new(RichText::new(text).monospace().size(t.font_small).color(t.text)));
|
||||
ui.add(egui::Label::new(RichText::new(text).monospace().size(t.font_small).color(t.text)).selectable(true));
|
||||
});
|
||||
}
|
||||
Block::Rule => {
|
||||
|
||||
@@ -1056,8 +1056,6 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui.spacing_mut().item_spacing.y = Tokens::get().space_10;
|
||||
conversation(app, ui);
|
||||
});
|
||||
// Last, so the scroll goes to this area and not to one nested in the conversation.
|
||||
scroll_while_selecting(ui);
|
||||
});
|
||||
if esc || stop_clicked {
|
||||
cancel(app);
|
||||
@@ -1680,40 +1678,6 @@ fn copy_message_menu(ui: &egui::Ui, response: &egui::Response, n: usize, text: &
|
||||
}
|
||||
}
|
||||
|
||||
/// A text selection dragged near or past the top or bottom of the conversation scrolls it, faster
|
||||
/// the further out, so a long answer can be selected in one drag (#11527). Only a drag towards
|
||||
/// that edge does: a click, a double click or a held press on a line by the edge, or a drag from
|
||||
/// there into the conversation, scrolls nothing. `ui`: the conversation's scroll area, once its
|
||||
/// content is drawn.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn scroll_while_selecting(ui: &egui::Ui) {
|
||||
// Scrolling starts this far inside the edge, and goes this many points a second for each
|
||||
// point further out, up to four times as far out; once the pointer has moved more than
|
||||
// `TOWARDS` that way since the press.
|
||||
const EDGE: f32 = 32.0;
|
||||
const SPEED: f32 = 16.0;
|
||||
const TOWARDS: f32 = 8.0;
|
||||
let view = ui.clip_rect();
|
||||
let pointer = ui.input(|i| i.pointer.press_origin().filter(|p| i.pointer.primary_down() && view.contains(*p)).zip(i.pointer.latest_pos()));
|
||||
let Some((from, at)) = pointer else {
|
||||
return;
|
||||
};
|
||||
if !ui.ctx().plugin::<egui::text_selection::LabelSelectionState>().lock().has_selection() {
|
||||
return;
|
||||
}
|
||||
let content = ui.min_rect();
|
||||
let out = if at.y < view.top() + EDGE && from.y - at.y > TOWARDS && content.top() < view.top() - 0.5 {
|
||||
at.y - (view.top() + EDGE)
|
||||
} else if at.y > view.bottom() - EDGE && at.y - from.y > TOWARDS && content.bottom() > view.bottom() + 0.5 {
|
||||
at.y - (view.bottom() - EDGE)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let step = out.clamp(-4.0 * EDGE, 4.0 * EDGE) * SPEED * ui.input(|i| i.stable_dt).min(0.1);
|
||||
// A positive delta moves the content down, showing what is above.
|
||||
ui.scroll_with_delta_animation(vec2(0.0, -step), egui::style::ScrollAnimation::none());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
@@ -1732,7 +1696,7 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let before = ui.cursor();
|
||||
ui_kit::assistant_bubble(ui, |ui| {
|
||||
if !text.is_empty() {
|
||||
super::markdown_view::selectable(ui, egui::Label::new(RichText::new(text).color(t.text)).wrap());
|
||||
ui.add(egui::Label::new(RichText::new(text).color(t.text)).selectable(true).wrap());
|
||||
}
|
||||
if !pictures.is_empty() {
|
||||
if let Some(at) = picture_strip(ui, n, pictures) {
|
||||
@@ -1756,7 +1720,7 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
Item::Agent { thought, text } => {
|
||||
if *thought {
|
||||
super::markdown_view::selectable(ui, egui::Label::new(RichText::new(text).italics().color(t.text_dim)).wrap());
|
||||
ui.add(egui::Label::new(RichText::new(text).italics().color(t.text_dim)).selectable(true).wrap());
|
||||
} else {
|
||||
let before = ui.cursor();
|
||||
ui_kit::assistant_prose(ui, |ui| super::markdown_view::show(ui, n, text));
|
||||
@@ -1837,7 +1801,7 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
Item::Approval { id, title, tool, answer: given } => {
|
||||
ui_kit::assistant_approval(ui, tr("ui.agent_chat_approval"), |ui| {
|
||||
ui_kit::assistant_tool_detail(ui, |ui| {
|
||||
super::markdown_view::selectable(ui, egui::Label::new(RichText::new(title).monospace().size(t.font_caption)).wrap());
|
||||
ui.add(egui::Label::new(RichText::new(title).monospace().size(t.font_caption)).selectable(true).wrap());
|
||||
});
|
||||
match given {
|
||||
Some(true) => {
|
||||
@@ -1946,7 +1910,7 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
_ => {
|
||||
if !p.text.is_empty() {
|
||||
super::markdown_view::selectable(ui, egui::Label::new(RichText::new(&p.text).color(t.text)).wrap());
|
||||
ui.add(egui::Label::new(RichText::new(&p.text).color(t.text)).selectable(true).wrap());
|
||||
}
|
||||
if p.image {
|
||||
ui.label(RichText::new(tr("ui.agent_chat_view_attached")).color(t.text_dim).size(crate::theme::Tokens::get().font_small));
|
||||
|
||||
@@ -2905,133 +2905,6 @@ fn narrow_chat() -> crate::scenario::Harness {
|
||||
h
|
||||
}
|
||||
|
||||
/// The rows `a_selection_held_at_the_edge_scrolls_and_keeps_its_start` draws that are in view:
|
||||
/// each row's number, where its text is, and the conversation's view.
|
||||
fn rows_in_view(h: &crate::scenario::Harness) -> Vec<(usize, egui::Rect, egui::Rect)> {
|
||||
let mut rows = Vec::new();
|
||||
for clipped in &h.shapes {
|
||||
crate::test_support::each_shape(&clipped.shape, &mut |shape| {
|
||||
if let egui::Shape::Text(text) = shape
|
||||
&& let Some(n) = text.galley.text().strip_prefix("Parts list row ").and_then(|n| n.parse::<usize>().ok())
|
||||
{
|
||||
let at = egui::Rect::from_min_size(text.pos, text.galley.size());
|
||||
if clipped.clip_rect.intersects(at) {
|
||||
rows.push((n, at, clipped.clip_rect));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
rows.sort_by_key(|r| r.0);
|
||||
rows
|
||||
}
|
||||
|
||||
/// #11527: a text selection held at the conversation's edge scrolls it, and keeps its start once
|
||||
/// that has scrolled out of view, so Copy takes the hidden rows too. Resting there with no button
|
||||
/// down, or after letting go, scrolls nothing.
|
||||
#[test]
|
||||
fn a_selection_held_at_the_edge_scrolls_and_keeps_its_start() {
|
||||
let mut h = narrow_chat();
|
||||
for n in 0..80 {
|
||||
h.app.agent_chat.items.push(Item::Agent { thought: false, text: format!("Parts list row {n}") });
|
||||
}
|
||||
h.frames(4);
|
||||
let rows = rows_in_view(&h);
|
||||
let (&(top, _, view), &(last, last_at, _)) = (rows.first().expect("rows in view"), rows.last().expect("rows in view"));
|
||||
assert_eq!(last, 79, "the conversation shows its end");
|
||||
let edge = egui::pos2(last_at.left() + 10.0, view.top() + 4.0);
|
||||
let press = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
h.frame_with(vec![egui::Event::PointerMoved(edge)]);
|
||||
h.frames(20);
|
||||
assert_eq!(rows_in_view(&h)[0].0, top, "no button down: the pointer at the edge scrolls nothing");
|
||||
|
||||
// Start the selection at the end of the last row and hold it at the top edge.
|
||||
let start = last_at.right_center() - egui::vec2(1.0, 0.0);
|
||||
h.frame_with(vec![egui::Event::PointerMoved(start), press(start, true)]);
|
||||
h.frame_with(vec![egui::Event::PointerMoved(edge)]);
|
||||
for _ in 0..600 {
|
||||
if !rows_in_view(&h).iter().any(|r| r.0 == last) {
|
||||
break;
|
||||
}
|
||||
h.frame();
|
||||
}
|
||||
let rows = rows_in_view(&h);
|
||||
assert!(!rows.iter().any(|r| r.0 == last), "holding the selection at the top scrolls the last row out of view");
|
||||
let reached = rows[0].0;
|
||||
assert!(reached < top, "row {reached} is in view, it was {top}");
|
||||
h.frame_with(vec![press(edge, false)]);
|
||||
let after = rows_in_view(&h)[0].0;
|
||||
h.frames(20);
|
||||
assert_eq!(rows_in_view(&h)[0].0, after, "letting go stops the scrolling");
|
||||
assert!(h.ctx.plugin::<egui::text_selection::LabelSelectionState>().lock().has_selection(), "the selection keeps its start out of view");
|
||||
|
||||
h.frame_with(vec![egui::Event::Copy]);
|
||||
let copied = h
|
||||
.output
|
||||
.commands
|
||||
.iter()
|
||||
.find_map(|c| if let egui::OutputCommand::CopyText(text) = c { Some(text.clone()) } else { None })
|
||||
.expect("Copy takes the selection");
|
||||
// The selection's moving end is under the pointer, in the first row or two in view.
|
||||
for n in [reached + 2, 78] {
|
||||
assert!(copied.contains(&format!("Parts list row {n}\n")), "row {n} is copied whole: {copied:?}");
|
||||
}
|
||||
assert!(copied.trim_end().ends_with("Parts list row 79"), "the selection ends where it started, out of view: {copied:?}");
|
||||
}
|
||||
|
||||
/// #11527: only a drag towards the edge scrolls. Holding a press on the line at the top edge, a
|
||||
/// double click there, or a drag from there into the conversation leaves it where it is.
|
||||
#[test]
|
||||
fn a_press_at_the_edge_without_a_drag_towards_it_scrolls_nothing() {
|
||||
let mut h = narrow_chat();
|
||||
// One paragraph taller than the conversation: every point in view is on its text.
|
||||
h.app.agent_chat.items.push(Item::Agent { thought: false, text: "Bill of materials ".repeat(400) });
|
||||
h.frames(4);
|
||||
// Where the answer's text starts, the conversation's view, and the first line wholly in view
|
||||
// (egui itself scrolls a pressed line that is cut off at the edge into view).
|
||||
let answer = |h: &crate::scenario::Harness| {
|
||||
let mut found = None;
|
||||
for clipped in &h.shapes {
|
||||
crate::test_support::each_shape(&clipped.shape, &mut |shape| {
|
||||
if let egui::Shape::Text(text) = shape
|
||||
&& text.galley.text().starts_with("Bill of materials")
|
||||
&& clipped.clip_rect.intersects(egui::Rect::from_min_size(text.pos, text.galley.size()))
|
||||
{
|
||||
let line = text.galley.rows.iter().map(|r| r.rect().translate(text.pos.to_vec2())).find(|r| r.top() >= clipped.clip_rect.top());
|
||||
found = Some((text.pos.y, clipped.clip_rect, line.expect("a line wholly in view")));
|
||||
}
|
||||
});
|
||||
}
|
||||
found.expect("the answer is in view")
|
||||
};
|
||||
let (rest, view, line) = answer(&h);
|
||||
let press = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
let edge = egui::pos2(view.left() + 40.0, line.center().y);
|
||||
assert!(edge.y < view.top() + 32.0, "the first line in view is by the edge");
|
||||
h.frame_with(vec![egui::Event::PointerMoved(edge), press(edge, true)]);
|
||||
h.frames(30);
|
||||
assert_eq!(answer(&h).0, rest, "a press held at the edge scrolls nothing");
|
||||
h.frame_with(vec![press(edge, false)]);
|
||||
for _ in 0..2 {
|
||||
h.frame_with(vec![press(edge, true)]);
|
||||
h.frame_with(vec![press(edge, false)]);
|
||||
}
|
||||
h.frames(10);
|
||||
assert_eq!(answer(&h).0, rest, "a double click at the edge scrolls nothing");
|
||||
let inside = edge + egui::vec2(0.0, 20.0);
|
||||
h.frame_with(vec![egui::Event::PointerMoved(edge), press(edge, true)]);
|
||||
h.frame_with(vec![egui::Event::PointerMoved(inside)]);
|
||||
h.frames(30);
|
||||
assert_eq!(answer(&h).0, rest, "a drag from the edge into the conversation scrolls nothing");
|
||||
h.frame_with(vec![press(inside, false)]);
|
||||
// Towards the edge it does.
|
||||
let middle = view.center();
|
||||
h.frame_with(vec![egui::Event::PointerMoved(middle), press(middle, true)]);
|
||||
h.frame_with(vec![egui::Event::PointerMoved(edge)]);
|
||||
h.frames(30);
|
||||
assert!(answer(&h).0 > rest, "a drag to the top edge scrolls the answer down");
|
||||
h.frame_with(vec![press(edge, false)]);
|
||||
}
|
||||
|
||||
/// #10172: before a connection, what the panel draws under the agent switcher (the Agent row
|
||||
/// with Add agent and Refresh) keeps the panel's side margin, docked and floating: nothing starts
|
||||
/// at the panel's edge or is cut off at the other one.
|
||||
|
||||
+172
-357
@@ -36,12 +36,10 @@ pub(crate) fn reveal_feature(app: &mut SolveApp, id: u64, body: &str) {
|
||||
/// to it, its Bodies folder and any group around it) and scroll its row into view. The rows
|
||||
/// are opened here, not while drawing, because a collapsed ancestor never draws its children.
|
||||
pub(crate) fn reveal_body(app: &mut SolveApp, body: &str) {
|
||||
sync_reveals(app);
|
||||
app.ui.show_browser = true;
|
||||
app.preferences.timeline_left_browser_collapsed = false;
|
||||
app.tree.reveal_feature = None;
|
||||
app.tree.reveal = Some(body.to_string());
|
||||
remember_reveal_source(app, body);
|
||||
let state = app.session.model.state();
|
||||
let Some(source) = crate::open_meshing::session_world(&app.session).body(body).map(|selected| selected.definition_name().to_string()) else {
|
||||
return;
|
||||
@@ -98,14 +96,20 @@ use crate::theme::Tokens;
|
||||
|
||||
use crate::SolveApp;
|
||||
|
||||
/// The Browser's rows on screen: (label, row rect, fold arrow area, open).
|
||||
/// Other presentations expose their geometry through `semantic_content::output`.
|
||||
pub fn drawn_rows() -> Vec<crate::semantic_content::DrawnRow> {
|
||||
crate::semantic_content::output(crate::semantic_content::ContentOwner::BROWSER).rows
|
||||
thread_local! {
|
||||
/// The rows drawn this frame: (label, row, fold arrow area, open). For automation
|
||||
/// (`ui.at {browser | chevron}`, `ui.browser`).
|
||||
static DRAWN: std::cell::RefCell<Vec<(String, Rect, Option<Rect>, Option<bool>)>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
static OCCURRENCES: std::cell::RefCell<Vec<(Vec<u64>, String, Rect, bool)>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
pub fn drawn_occurrences() -> Vec<crate::semantic_content::DrawnOccurrence> {
|
||||
crate::semantic_content::output(crate::semantic_content::ContentOwner::BROWSER).occurrences
|
||||
/// The rows on screen: (label, row rect, fold arrow area, open).
|
||||
pub fn drawn_rows() -> Vec<(String, Rect, Option<Rect>, Option<bool>)> {
|
||||
DRAWN.with(|d| d.borrow().clone())
|
||||
}
|
||||
|
||||
pub fn drawn_occurrences() -> Vec<(Vec<u64>, String, Rect, bool)> {
|
||||
OCCURRENCES.with(|d| d.borrow().clone())
|
||||
}
|
||||
|
||||
/// Browser state kept between frames.
|
||||
@@ -121,10 +125,6 @@ pub struct TreeState {
|
||||
/// of a part apart) and open the components, placements and folders above it: Find in
|
||||
/// Browser, and a body picked in the viewport (#3005).
|
||||
pub reveal: Option<String>,
|
||||
/// Enough provenance to distinguish temporary rollback/suppression from target removal.
|
||||
reveal_source: Option<BodyRevealSource>,
|
||||
/// Runtime owner of the active pending requests, including requests with numeric IDs.
|
||||
reveal_session: Option<u64>,
|
||||
/// A native feature to reveal in its placed component's Features folder (Find in Timeline).
|
||||
pub(super) reveal_feature: Option<features::Reveal>,
|
||||
/// The Move/Copy panel of an occurrence.
|
||||
@@ -155,8 +155,13 @@ pub struct TreeState {
|
||||
pub view_pick: Option<u64>,
|
||||
/// The Edit Canvas / Calibrate panel.
|
||||
pub canvas_panel: Option<CanvasPanel>,
|
||||
/// The bodies a row at the top of the history lights this frame (`history_rows`), for the
|
||||
/// browser, drawn after it, to keep.
|
||||
history_hover: Vec<String>,
|
||||
/// The Edit Point Cloud panel.
|
||||
pub cloud_panel: Option<CloudPanel>,
|
||||
/// The plane a row at the top of the history lights this frame, kept the same way (#6975).
|
||||
history_plane: Option<String>,
|
||||
/// A Section Analysis hidden with its eye in the Analysis folder: its plane (origin,
|
||||
/// normal), so the eye can bring it back (#822).
|
||||
pub hidden_section: Option<(solidcore_record::Vec3, solidcore_record::Vec3)>,
|
||||
@@ -170,8 +175,15 @@ pub struct TreeState {
|
||||
pub capture_prompt: Option<String>,
|
||||
/// The design, undo depth and visibility when the button last went down: a drag over the eyes
|
||||
/// merges the steps it made into one (`eye_drag_frame`).
|
||||
eye_press: Option<(u64, Arc<Document>, usize, solidcraft_engine::Visibility)>,
|
||||
pub(crate) content: crate::semantic_content::Frame,
|
||||
eye_press: Option<(Arc<Document>, usize, solidcraft_engine::Visibility)>,
|
||||
/// The last row clicked (for Shift ranges).
|
||||
anchor: Option<String>,
|
||||
/// Row keys in drawing order this frame (for Shift ranges).
|
||||
order: Vec<String>,
|
||||
/// The previous frame's complete row order: a Shift click resolves its range here, since
|
||||
/// this frame's `order` does not yet hold the rows below the clicked one (#4991).
|
||||
order_prev: Vec<String>,
|
||||
order_len: usize,
|
||||
cache: Option<cache::Cache>,
|
||||
}
|
||||
|
||||
@@ -181,71 +193,6 @@ struct FolderPick {
|
||||
doc: Weak<Document>,
|
||||
}
|
||||
|
||||
struct BodyRevealSource {
|
||||
body: String,
|
||||
session: u64,
|
||||
creator: u64,
|
||||
}
|
||||
|
||||
/// Pending presentation requests parked alongside their session in a bounded document slot.
|
||||
/// Pointer input (`view_pick`) deliberately never travels with the slot.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct PendingReveals {
|
||||
session: Option<u64>,
|
||||
body: Option<String>,
|
||||
source: Option<BodyRevealSource>,
|
||||
feature: Option<features::Reveal>,
|
||||
timeline: Option<u64>,
|
||||
}
|
||||
|
||||
pub(crate) fn discard_reveals(app: &mut SolveApp) {
|
||||
app.tree.reveal = None;
|
||||
app.tree.reveal_source = None;
|
||||
app.tree.reveal_feature = None;
|
||||
app.tree.timeline_reveal = None;
|
||||
app.tree.view_pick = None;
|
||||
app.tree.reveal_session = Some(app.session.uid);
|
||||
}
|
||||
|
||||
/// Guard request creation and presentation even when a session changes after frame begin.
|
||||
pub(crate) fn sync_reveals(app: &mut SolveApp) {
|
||||
if app.tree.reveal_session.is_some_and(|session| session != app.session.uid) {
|
||||
discard_reveals(app);
|
||||
}
|
||||
if app.tree.reveal_source.as_ref().is_some_and(|source| source.session != app.session.uid) {
|
||||
app.tree.reveal = None;
|
||||
app.tree.reveal_source = None;
|
||||
}
|
||||
app.tree.reveal_session = Some(app.session.uid);
|
||||
}
|
||||
|
||||
pub(crate) fn take_reveals(app: &mut SolveApp) -> PendingReveals {
|
||||
sync_reveals(app);
|
||||
app.tree.view_pick = None;
|
||||
PendingReveals {
|
||||
session: Some(app.session.uid),
|
||||
body: app.tree.reveal.take(),
|
||||
source: app.tree.reveal_source.take(),
|
||||
feature: app.tree.reveal_feature.take(),
|
||||
timeline: app.tree.timeline_reveal.take(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn restore_reveals(app: &mut SolveApp, pending: PendingReveals) {
|
||||
discard_reveals(app);
|
||||
if pending.session == Some(app.session.uid) {
|
||||
app.tree.reveal = pending.body;
|
||||
app.tree.reveal_source = pending.source;
|
||||
app.tree.reveal_feature = pending.feature;
|
||||
app.tree.timeline_reveal = pending.timeline;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn request_timeline_reveal(app: &mut SolveApp, id: u64) {
|
||||
sync_reveals(app);
|
||||
app.tree.timeline_reveal = Some(id);
|
||||
}
|
||||
|
||||
impl FolderPick {
|
||||
fn current(&self, app: &SolveApp) -> bool {
|
||||
self.session == app.session.uid && std::ptr::eq(self.doc.as_ptr(), Arc::as_ptr(&app.session.doc))
|
||||
@@ -253,10 +200,6 @@ impl FolderPick {
|
||||
}
|
||||
|
||||
impl TreeState {
|
||||
pub(crate) fn for_session(session: u64) -> Self {
|
||||
Self { reveal_session: Some(session), ..Default::default() }
|
||||
}
|
||||
|
||||
pub(crate) fn clear_folder_pick(&mut self) {
|
||||
self.picked_folder = None;
|
||||
}
|
||||
@@ -281,7 +224,15 @@ impl TreeState {
|
||||
}
|
||||
|
||||
fn remember(&mut self, key: &str) {
|
||||
self.content.current_mut().remember(key);
|
||||
if let Some(slot) = self.order.get_mut(self.order_len) {
|
||||
if slot != key {
|
||||
slot.clear();
|
||||
slot.push_str(key);
|
||||
}
|
||||
} else {
|
||||
self.order.push(key.to_owned());
|
||||
}
|
||||
self.order_len += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,29 +478,15 @@ pub fn toggle_folder(app: &mut SolveApp, key: &str) {
|
||||
/// hidden items are noted; when it comes up, the steps the drag made are merged into the first of
|
||||
/// them. Runs every frame, whatever else is open, so a release is never missed.
|
||||
pub(crate) fn eye_drag_frame(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
crate::semantic_content::ensure_session(app, ctx);
|
||||
let (pressed, down) = ctx.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down()));
|
||||
if pressed && app.tree.eye_press.is_none() {
|
||||
app.tree.eye_press = Some((app.session.uid, app.session.doc.clone(), app.session.undo.len(), without_origin(app.session.visibility.clone())));
|
||||
if pressed {
|
||||
app.tree.eye_press = Some((app.session.doc.clone(), app.session.undo.len(), without_origin(app.session.visibility.clone())));
|
||||
}
|
||||
if down {
|
||||
return;
|
||||
}
|
||||
end_eye_drag(app, ctx);
|
||||
}
|
||||
|
||||
/// Also called when the presentation holding the gesture disappears while the button is down.
|
||||
pub(crate) fn end_eye_drag(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let capture = crate::semantic_content::release_eye(ctx);
|
||||
let dragged = ctx.data_mut(|d| d.remove_temp::<bool>(crate::ui_kit::eye_drag_id())).is_some();
|
||||
let Some((_, doc, depth, visibility)) = app
|
||||
.tree
|
||||
.eye_press
|
||||
.take()
|
||||
.filter(|(session, ..)| dragged && *session == app.session.uid && capture.is_none_or(|source| source.session == *session))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some((doc, depth, visibility)) = app.tree.eye_press.take().filter(|_| dragged) else { return };
|
||||
// shortcut: once the history is full (200 steps) its length stops growing, so the drag is left as
|
||||
// separate steps, like the point-cloud and canvas drags; the visibility test keeps a trimmed front
|
||||
// from being merged wrongly.
|
||||
@@ -559,10 +496,6 @@ pub(crate) fn end_eye_drag(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn discard_eye_press(app: &mut SolveApp) {
|
||||
app.tree.eye_press = None;
|
||||
}
|
||||
|
||||
/// The Origin's own eye is not an undo step (`set_origin_shown`), so the drag's steps do not hold it:
|
||||
/// it is left out of the comparison.
|
||||
fn without_origin(mut visibility: solidcraft_engine::Visibility) -> solidcraft_engine::Visibility {
|
||||
@@ -679,12 +612,11 @@ fn eye_left(ui: &egui::Ui) -> bool {
|
||||
|
||||
pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row<'_>) -> RowResp {
|
||||
let compact = compact_tree(ui);
|
||||
let mut out = crate::ui_kit::tree_row(ui, id, &Row { compact, eye_left: eye_left(ui), ..*row });
|
||||
crate::semantic_content::route_response(ui, id, &mut out);
|
||||
let out = crate::ui_kit::tree_row(ui, id, &Row { compact, eye_left: eye_left(ui), ..*row });
|
||||
if let Some(r) = out.rect.filter(|r| ui.is_rect_visible(*r)) {
|
||||
let x = r.left() + if compact { Tokens::get().space_8 } else { 2.0 } + row.depth as f32 * INDENT;
|
||||
let fold = row.fold.map(|_| Rect::from_min_max(pos2(x - 4.0, r.top()), pos2(x + 15.0, r.bottom())));
|
||||
crate::semantic_content::record_row(ui, (row.label.to_string(), r, fold, row.fold), &out);
|
||||
DRAWN.with(|d| d.borrow_mut().push((row.label.to_string(), r, fold, row.fold)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -692,11 +624,11 @@ pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row<'_>) -> RowRes
|
||||
/// A Ctrl/Shift-aware click on a selectable row.
|
||||
fn click_select(app: &mut SolveApp, ui: &egui::Ui, key: String, sel: Option<Sel>) {
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
let rows = app.tree.content.current_mut();
|
||||
if mods.shift
|
||||
&& let Some(a) = rows.anchor.as_deref()
|
||||
&& let Some(a) = app.tree.anchor.as_deref()
|
||||
{
|
||||
let items = shift_range(&rows.previous, a, &key).or_else(|| shift_range(rows.current_order(), a, &key));
|
||||
let order = app.tree.order.get(..app.tree.order_len).unwrap_or(&[]);
|
||||
let items = shift_range(&app.tree.order_prev, a, &key).or_else(|| shift_range(order, a, &key));
|
||||
if let Some(items) = items {
|
||||
// Move/Copy (and any open command whose active input takes bodies) gets every body
|
||||
// in the range. Otherwise the range is an ordinary selection.
|
||||
@@ -714,7 +646,7 @@ fn click_select(app: &mut SolveApp, ui: &egui::Ui, key: String, sel: Option<Sel>
|
||||
return;
|
||||
}
|
||||
}
|
||||
app.tree.content.current_mut().anchor = Some(key);
|
||||
app.tree.anchor = Some(key);
|
||||
let Some(sel) = sel else { return };
|
||||
if mods.command || mods.ctrl {
|
||||
if app.session.selection.contains(&sel) {
|
||||
@@ -769,29 +701,18 @@ pub(crate) fn picked_body(sel: &Sel) -> Option<&str> {
|
||||
/// A pick in the viewport: the Browser, when shown, opens down to the picked body's row and
|
||||
/// scrolls to it (#3005). The body of the last item picked.
|
||||
pub(crate) fn reveal_pick(app: &mut SolveApp) {
|
||||
sync_reveals(app);
|
||||
if app.ui.show_browser
|
||||
&& let Some(body) = app.session.selection.last().and_then(picked_body)
|
||||
{
|
||||
let body = body.to_owned();
|
||||
remember_reveal_source(app, &body);
|
||||
app.tree.reveal = Some(body);
|
||||
app.tree.reveal = Some(body.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn remember_reveal_source(app: &mut SolveApp, body: &str) {
|
||||
app.tree.reveal_source = crate::open_meshing::session_world(&app.session).body(body).map(|selected| BodyRevealSource {
|
||||
body: body.to_owned(),
|
||||
session: app.session.uid,
|
||||
creator: selected.feature,
|
||||
});
|
||||
}
|
||||
|
||||
/// The placement path of the body being revealed, resolved once per model: a row opens when
|
||||
/// the body's path runs through it, instead of every row expanding its subtree to test
|
||||
/// containment (#7502). `None` when nothing is revealed or the body is a top-level design body.
|
||||
fn reveal_path(app: &mut SolveApp) -> Option<Vec<u64>> {
|
||||
let body = body_reveal(app)?.to_owned();
|
||||
let body = app.tree.reveal.clone()?;
|
||||
prepare_model(app);
|
||||
app.tree.model.as_mut()?.body_path(&app.session, &body)
|
||||
}
|
||||
@@ -853,24 +774,17 @@ pub fn apply_fold(app: &mut SolveApp, open: bool, open_by_default: &[String], cl
|
||||
|
||||
/// Everything a frame's rows want done, applied after drawing.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Actions {
|
||||
pub(crate) session: Option<u64>,
|
||||
struct Actions {
|
||||
menu: Option<(Pos2, Target)>,
|
||||
pub(crate) hover_bodies: Vec<String>,
|
||||
pub(crate) hovered: bool,
|
||||
hover_bodies: Vec<String>,
|
||||
hovered: bool,
|
||||
/// The plane whose row is under the pointer, lit in the view (#6975).
|
||||
pub(crate) hover_plane: Option<String>,
|
||||
drop: Option<(crate::semantic_content::ContentSource, DropAction)>,
|
||||
hover_plane: Option<String>,
|
||||
drop: Option<DropAction>,
|
||||
/// What the selection and the 3D view's pointer light in the tree.
|
||||
lit: Lit,
|
||||
}
|
||||
|
||||
impl Actions {
|
||||
fn drop_from(&mut self, source: crate::semantic_content::ContentSource, action: DropAction) {
|
||||
self.drop = Some((source, action));
|
||||
}
|
||||
}
|
||||
|
||||
/// Something in the 3D view with a row in the tree.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
enum Thing {
|
||||
@@ -1104,11 +1018,16 @@ fn reveal_tab(ui: &egui::Ui, card: Rect, opacity: f32) {
|
||||
|
||||
/// One content-hugging browser; the shared container supplies floating or flush compact chrome.
|
||||
pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
sync_reveals(app);
|
||||
let available = ui.available_rect_before_wrap();
|
||||
let t = Tokens::get();
|
||||
std::mem::swap(&mut app.tree.order_prev, &mut app.tree.order);
|
||||
app.tree.order_len = 0;
|
||||
DRAWN.with(|d| d.borrow_mut().clear());
|
||||
OCCURRENCES.with(|d| d.borrow_mut().clear());
|
||||
let mut acts = Actions { hover_bodies: std::mem::take(&mut app.viewport.hover_bodies), ..Default::default() };
|
||||
if crate::render_workspace::active(app) {
|
||||
render_card(app, ui);
|
||||
render_card(app, ui, &mut acts);
|
||||
finish_browser(app, ui, acts);
|
||||
return;
|
||||
}
|
||||
let stacked = crate::timeline_left::stacked(app);
|
||||
@@ -1165,7 +1084,7 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if collapsed {
|
||||
browser_collapsed_rail(app, ui);
|
||||
} else {
|
||||
browser_contents(app, ui, true);
|
||||
browser_contents(app, ui, &mut acts, true);
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1185,9 +1104,10 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::scenario::publish_rect("browser:float", rect);
|
||||
}
|
||||
}
|
||||
finish_browser(app, ui, acts);
|
||||
}
|
||||
|
||||
fn render_card(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
fn render_card(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) {
|
||||
let bounds = crate::shell::panel_bounds(app, ui.available_rect_before_wrap());
|
||||
let narrow = bounds.width() < 800.0;
|
||||
crate::shell::register_panel(app, "render:browser:show", None, None);
|
||||
@@ -1234,7 +1154,7 @@ fn render_card(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_compact_tree"), true));
|
||||
browser_contents(app, ui, true);
|
||||
browser_contents(app, ui, acts, true);
|
||||
ui.ctx().data_mut(|d| d.remove::<bool>(egui::Id::new("sc_compact_tree")));
|
||||
},
|
||||
);
|
||||
@@ -1300,7 +1220,7 @@ pub(crate) fn panel_bounds(available: Rect, history: Option<Rect>, width: f32, g
|
||||
Rect::from_min_max(min, pos2((min.x + width).min(right), bottom))
|
||||
}
|
||||
|
||||
fn browser_contents(app: &mut SolveApp, ui: &mut egui::Ui, collapsible: bool) {
|
||||
fn browser_contents(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions, collapsible: bool) {
|
||||
let t = Tokens::get();
|
||||
// Only the floating card folds; the docked column keeps its full height.
|
||||
let render = crate::render_workspace::active(app);
|
||||
@@ -1328,9 +1248,9 @@ fn browser_contents(app: &mut SolveApp, ui: &mut egui::Ui, collapsible: bool) {
|
||||
}
|
||||
crate::edit_in_place::browser_strip(app, ui);
|
||||
if collapsible && !stacked {
|
||||
browser_rows_hugging(app, ui);
|
||||
browser_rows_hugging(app, ui, acts);
|
||||
} else {
|
||||
browser_rows(app, ui);
|
||||
browser_rows(app, ui, acts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1442,28 +1362,19 @@ fn model_feature_indices(app: &SolveApp, component: u64, sketches: bool) -> Vec<
|
||||
}
|
||||
}
|
||||
|
||||
fn browser_rows(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::semantic_content::show(
|
||||
app,
|
||||
ui,
|
||||
crate::semantic_content::ContentOwner::BROWSER,
|
||||
crate::semantic_content::ContentRequest::Browser { shrink_height: false },
|
||||
);
|
||||
fn browser_rows(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) {
|
||||
rows_scrolling(app, ui, acts, false);
|
||||
}
|
||||
|
||||
/// As [browser_rows], but the area hugs the rows it drew (the floating card), up to the height
|
||||
/// the caller allowed.
|
||||
fn browser_rows_hugging(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::semantic_content::show(
|
||||
app,
|
||||
ui,
|
||||
crate::semantic_content::ContentOwner::BROWSER,
|
||||
crate::semantic_content::ContentRequest::Browser { shrink_height: true },
|
||||
);
|
||||
fn browser_rows_hugging(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) {
|
||||
rows_scrolling(app, ui, acts, true);
|
||||
}
|
||||
|
||||
fn rows_scrolling(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions, shrink_height: bool) {
|
||||
prepare_model(app);
|
||||
acts.lit = Lit::of(app);
|
||||
let mut cache = app.tree.cache.take().unwrap_or_default();
|
||||
cache.prepare(app);
|
||||
egui::ScrollArea::vertical()
|
||||
@@ -1483,32 +1394,23 @@ fn rows_scrolling(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions, shr
|
||||
component_rows(app, ui, 0, 0, acts, &mut cache);
|
||||
}
|
||||
});
|
||||
// The row was reached and scrolled to, or isn't in the tree (a body a later feature removes):
|
||||
// either way the reveal is done.
|
||||
app.tree.reveal = None;
|
||||
app.tree.cache = Some(cache);
|
||||
}
|
||||
|
||||
/// Existing semantic rows, reusable by either presentation without a panel host.
|
||||
pub(crate) fn render_content(app: &mut SolveApp, ui: &mut egui::Ui, request: crate::semantic_content::ContentRequest) -> Actions {
|
||||
crate::semantic_content::ensure_session(app, ui.ctx());
|
||||
let mut acts = Actions { session: Some(app.session.uid), lit: Lit::of(app), ..Default::default() };
|
||||
match request {
|
||||
crate::semantic_content::ContentRequest::Browser { shrink_height } => rows_scrolling(app, ui, &mut acts, shrink_height),
|
||||
crate::semantic_content::ContentRequest::ActiveBodiesOrigin => active_bodies_origin(app, ui, &mut acts),
|
||||
}
|
||||
acts
|
||||
}
|
||||
|
||||
/// History's optional prefix shares the Browser's cache, folds, row renderers and commands.
|
||||
/// The Bodies folder and the Origin at the top of the vertical History (Preferences › Interface:
|
||||
/// Show origin and bodies at the top, #6490): the browser's own rows, with their eyes, selection,
|
||||
/// menus and fold state. The bodies are the active component's, in the placement being edited,
|
||||
/// as that component's folder in the browser lists them. The rows stay out of the browser's row
|
||||
/// order, automation list and reveal, which the browser keeps for its own rows.
|
||||
pub(crate) fn history_rows(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::semantic_content::show(
|
||||
app,
|
||||
ui,
|
||||
crate::semantic_content::ContentOwner::HISTORY,
|
||||
crate::semantic_content::ContentRequest::ActiveBodiesOrigin,
|
||||
);
|
||||
}
|
||||
|
||||
fn active_bodies_origin(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) {
|
||||
prepare_model(app);
|
||||
let mut acts = Actions { lit: Lit::of(app), ..Default::default() };
|
||||
let reveal = app.tree.reveal.take();
|
||||
let order = app.tree.order_len;
|
||||
let drawn = DRAWN.with(|d| d.borrow().len());
|
||||
let component = app.session.active_component;
|
||||
let path = (component != 0)
|
||||
.then(|| app.session.active_occurrence_path.clone().unwrap_or_else(|| app.session.doc.first_path(component)))
|
||||
@@ -1520,18 +1422,18 @@ fn active_bodies_origin(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Action
|
||||
app.tree.cache = Some(cache);
|
||||
ui.scope(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
folder_rows_at(app, ui, component, path.as_deref(), "bodies", 0, acts, &bodies);
|
||||
origin_rows(app, ui, 0, acts);
|
||||
folder_rows_at(app, ui, component, path.as_deref(), "bodies", 0, &mut acts, &bodies);
|
||||
origin_rows(app, ui, 0, &mut acts);
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn apply_content_actions(app: &mut SolveApp, acts: Actions) {
|
||||
if acts.session != Some(app.session.uid) {
|
||||
return;
|
||||
}
|
||||
if let Some((_, d)) = acts.drop.filter(|(source, _)| crate::semantic_content::source_present(app, *source)) {
|
||||
// The target may have consumed a payload before its source's absence was known.
|
||||
// Commit only after all owners have reported, using the payload's original source.
|
||||
app.tree.reveal = reveal;
|
||||
// The browser's own rows reuse these order slots (`TreeState::remember`).
|
||||
app.tree.order_len = order;
|
||||
DRAWN.with(|d| d.borrow_mut().truncate(drawn));
|
||||
app.viewport.hover_bodies = acts.hover_bodies.clone();
|
||||
app.tree.history_hover = acts.hover_bodies;
|
||||
app.viewport.hover_plane = acts.hover_plane.clone();
|
||||
app.tree.history_plane = acts.hover_plane;
|
||||
if let Some(d) = acts.drop {
|
||||
apply_drop(app, d);
|
||||
}
|
||||
if let Some((at, target)) = acts.menu {
|
||||
@@ -1539,16 +1441,22 @@ pub(crate) fn apply_content_actions(app: &mut SolveApp, acts: Actions) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_content_drag(ctx: &egui::Context, session: u64) {
|
||||
if egui::DragAndDrop::payload::<Drag>(ctx).is_some_and(|payload| payload.source.session == session)
|
||||
|| egui::DragAndDrop::payload::<OccurrenceDrag>(ctx).is_some_and(|payload| payload.source.session == session)
|
||||
{
|
||||
egui::DragAndDrop::clear_payload(ctx);
|
||||
fn finish_browser(app: &mut SolveApp, ui: &mut egui::Ui, mut acts: Actions) {
|
||||
app.tree.order.truncate(app.tree.order_len);
|
||||
if !acts.hovered {
|
||||
acts.hover_bodies.clear();
|
||||
}
|
||||
// The history's rows, drawn before the browser, light bodies too (`history_rows`).
|
||||
let history = std::mem::take(&mut app.tree.history_hover);
|
||||
app.viewport.hover_bodies = if acts.hover_bodies.is_empty() { history } else { acts.hover_bodies };
|
||||
let history_plane = app.tree.history_plane.take();
|
||||
app.viewport.hover_plane = acts.hover_plane.or(history_plane);
|
||||
if let Some(d) = acts.drop {
|
||||
apply_drop(app, d);
|
||||
}
|
||||
if let Some((at, target)) = acts.menu {
|
||||
crate::context_menu::open_for(app, at, target);
|
||||
}
|
||||
}
|
||||
|
||||
/// App-owned editor lifecycle; these windows do not depend on any tree being presented.
|
||||
pub(crate) fn editors(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::browser_prompts::occurrence_panel(app, ui);
|
||||
crate::browser_prompts::capture_prompt(app, ui.ctx());
|
||||
crate::browser_canvas::panel(app, ui.ctx());
|
||||
@@ -1556,43 +1464,6 @@ pub(crate) fn editors(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::sketch_attachment_ui::panel(app, ui.ctx());
|
||||
}
|
||||
|
||||
/// History's prefix must neither open ancestors nor acknowledge a Browser reveal.
|
||||
fn body_reveal(app: &SolveApp) -> Option<&str> {
|
||||
app.tree.content.browser_presentation().then_some(app.tree.reveal.as_deref()).flatten()
|
||||
}
|
||||
|
||||
/// A hidden or collapsed presentation retains its request. Removed targets expire here.
|
||||
pub(crate) fn prune_reveals(app: &mut SolveApp) {
|
||||
sync_reveals(app);
|
||||
if let Some(body) = app.tree.reveal.as_ref() {
|
||||
let world = crate::open_meshing::session_world(&app.session);
|
||||
let source = app.tree.reveal_source.as_ref().filter(|source| source.body == *body);
|
||||
let removed = source.is_some_and(|source| source.session != app.session.uid || app.session.doc.feature(source.creator).is_none());
|
||||
let evaluated = match source {
|
||||
Some(source) => app.session.doc.features.iter().enumerate().any(|(index, feature)| {
|
||||
feature.id == source.creator && !feature.suppressed && app.session.doc.marker.is_none_or(|marker| index < marker)
|
||||
}),
|
||||
None => app.session.doc.marker.is_none() && !app.session.doc.features.iter().any(|feature| feature.suppressed),
|
||||
};
|
||||
// A refused feature can temporarily remove a body from evaluation too. Its pending
|
||||
// reveal remains useful when the feature is repaired.
|
||||
let healthy =
|
||||
app.session.model.results.iter().all(|result| result.error.is_none()) && crate::open_meshing::placement_error(&app.session).is_none();
|
||||
if world.body(body).is_none() && (removed || (evaluated && healthy)) {
|
||||
app.tree.reveal = None;
|
||||
app.tree.reveal_source = None;
|
||||
}
|
||||
} else {
|
||||
app.tree.reveal_source = None;
|
||||
}
|
||||
if app.tree.reveal_feature.as_ref().is_some_and(|target| app.session.doc.feature(target.id).is_none()) {
|
||||
app.tree.reveal_feature = None;
|
||||
}
|
||||
if app.tree.timeline_reveal.is_some_and(|id| app.session.doc.feature(id).is_none()) {
|
||||
app.tree.timeline_reveal = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn component_name(app: &SolveApp, id: u64) -> String {
|
||||
if id == 0 {
|
||||
return app.session.doc.name.clone();
|
||||
@@ -1629,7 +1500,7 @@ fn component_rows_inner(
|
||||
return;
|
||||
}
|
||||
let key = &root.key;
|
||||
let reveal = body_reveal(app).map(str::to_owned);
|
||||
let reveal = app.tree.reveal.clone();
|
||||
if let Some(body) = reveal.as_ref() {
|
||||
let reveals =
|
||||
cache.root_bodies.contains(body) || reveal_path(app).is_some_and(|rp| root.children.iter().any(|path| rp.starts_with(path.as_slice())));
|
||||
@@ -1712,7 +1583,7 @@ fn component_rows_inner(
|
||||
if let Some(resp) = &r.resp
|
||||
&& let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp != id && d.folder != "construction" && d.groups.is_empty(), DropMark::Box)
|
||||
{
|
||||
acts.drop_from(d.source, DropAction::Component { to: id, folder: d.folder, keys: d.keys.clone() });
|
||||
acts.drop = Some(DropAction::Component { to: id, folder: d.folder, keys: d.keys.clone() });
|
||||
}
|
||||
|
||||
// A placement row dropped on the design row moves out to the design.
|
||||
@@ -1720,7 +1591,7 @@ fn component_rows_inner(
|
||||
&& let Some(d) = occurrence_drop_target(ui, resp, |d| occurrence_can_land(app, d, id))
|
||||
{
|
||||
let occurrence = d.path.last().copied().unwrap_or(0);
|
||||
acts.drop_from(d.source, DropAction::Reparent { occurrence, path: d.path.clone(), parent: id, parent_path: None });
|
||||
acts.drop = Some(DropAction::Reparent { occurrence, path: d.path.clone(), parent: id, parent_path: None });
|
||||
}
|
||||
if !open {
|
||||
return;
|
||||
@@ -1834,7 +1705,7 @@ fn occurrence_rows_inner(
|
||||
},
|
||||
);
|
||||
if let Some(rect) = r.rect.filter(|_| r.resp.is_some()) {
|
||||
crate::semantic_content::record_occurrence(ui, (path.to_vec(), placement.name.clone(), rect, open));
|
||||
OCCURRENCES.with(|d| d.borrow_mut().push((path.to_vec(), placement.name.clone(), rect, open)));
|
||||
// Near the row's end, clear of an eye there.
|
||||
let end = r.eye_at.filter(|e| e.x > rect.center().x).map_or(rect.right() - 12.0, |e| e.x - 24.0);
|
||||
crate::scenario::publish_handle(&format!("row:occurrence:{}", key.trim_start_matches("o:")), pos2(end, rect.center().y));
|
||||
@@ -1854,11 +1725,8 @@ fn occurrence_rows_inner(
|
||||
});
|
||||
// Dragging the row moves the placement it stands for. A press on its fold arrow, eye or
|
||||
// radio is not a drag of the row, so those controls keep their own jobs.
|
||||
if resp.drag_started()
|
||||
&& !r.on_control
|
||||
&& let Some(source) = crate::semantic_content::current_source(app)
|
||||
{
|
||||
resp.dnd_set_drag_payload(OccurrenceDrag { source, path: path.to_vec(), component: placement.component });
|
||||
if resp.drag_started() && !r.on_control {
|
||||
resp.dnd_set_drag_payload(OccurrenceDrag { path: path.to_vec(), component: placement.component });
|
||||
}
|
||||
}
|
||||
if r.fold {
|
||||
@@ -1902,7 +1770,7 @@ fn occurrence_rows_inner(
|
||||
&& let Some(d) =
|
||||
drop_target(ui, resp, |d: &Drag| d.comp != placement.component && d.folder != "construction" && d.groups.is_empty(), DropMark::Box)
|
||||
{
|
||||
acts.drop_from(d.source, DropAction::Component { to: placement.component, folder: d.folder, keys: d.keys.clone() });
|
||||
acts.drop = Some(DropAction::Component { to: placement.component, folder: d.folder, keys: d.keys.clone() });
|
||||
}
|
||||
|
||||
// The top of a sibling's row puts the dragged placement before it; the rest of the row, and
|
||||
@@ -1918,7 +1786,8 @@ fn occurrence_rows_inner(
|
||||
app.session.doc.sort_placements(parent, &mut order);
|
||||
let at = order.iter().position(|o| *o == placement.occurrence).unwrap_or(order.len());
|
||||
order.insert(at, dragged);
|
||||
acts.drop_from(d.source, DropAction::OccurrenceOrder { parent, order });
|
||||
let order: Vec<String> = order.iter().map(u64::to_string).collect();
|
||||
let _ = app.run("browser.order", json!({ "folder": "components", "component": parent, "order": order }));
|
||||
}
|
||||
// A placement row dropped on this one moves into this row's component.
|
||||
if let Some(resp) = &r.resp
|
||||
@@ -1927,10 +1796,7 @@ fn occurrence_rows_inner(
|
||||
})
|
||||
{
|
||||
let occurrence = d.path.last().copied().unwrap_or(0);
|
||||
acts.drop_from(
|
||||
d.source,
|
||||
DropAction::Reparent { occurrence, path: d.path.clone(), parent: placement.component, parent_path: Some(path.to_vec()) },
|
||||
);
|
||||
acts.drop = Some(DropAction::Reparent { occurrence, path: d.path.clone(), parent: placement.component, parent_path: Some(path.to_vec()) });
|
||||
}
|
||||
if !open {
|
||||
return;
|
||||
@@ -2048,7 +1914,7 @@ fn origin_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize, acts: &mut A
|
||||
app.set_origin_shown(!app.origin_shown());
|
||||
}
|
||||
if let Some(p) = r.secondary {
|
||||
acts.menu = Some((p, Target::Origin));
|
||||
crate::context_menu::open_for(app, p, Target::Origin);
|
||||
}
|
||||
if !open {
|
||||
return;
|
||||
@@ -2091,7 +1957,7 @@ fn origin_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize, acts: &mut A
|
||||
_ => None,
|
||||
};
|
||||
if let Some(target) = target {
|
||||
acts.menu = Some((p, target));
|
||||
crate::context_menu::open_for(app, p, target);
|
||||
}
|
||||
}
|
||||
if r.hovered && icon == "plane" {
|
||||
@@ -2584,7 +2450,6 @@ impl Entry {
|
||||
/// What is being dragged in the tree: items of one component folder.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Drag {
|
||||
source: crate::semantic_content::ContentSource,
|
||||
comp: u64,
|
||||
folder: &'static str,
|
||||
keys: Vec<String>,
|
||||
@@ -2602,7 +2467,6 @@ impl Drag {
|
||||
/// A placement row dragged in the tree: the exact path it sits at, and the component it places.
|
||||
#[derive(Clone, Debug)]
|
||||
struct OccurrenceDrag {
|
||||
source: crate::semantic_content::ContentSource,
|
||||
/// The placement's path from the design root (its own occurrence id last).
|
||||
path: Vec<u64>,
|
||||
/// The component the placement is an instance of.
|
||||
@@ -2758,7 +2622,7 @@ fn folder_rows_at(
|
||||
let fstatic = folder_static(folder);
|
||||
let key = &data.key;
|
||||
let prefix = key.rsplit_once('/').map_or(key.as_str(), |(prefix, _)| prefix);
|
||||
if let Some(r) = body_reveal(app)
|
||||
if let Some(r) = app.tree.reveal.as_deref()
|
||||
&& folder == "bodies"
|
||||
&& let Some(e) = data.entries().find(|e| e.row_key.strip_prefix("b:") == Some(r))
|
||||
{
|
||||
@@ -2829,20 +2693,17 @@ fn folder_rows_at(
|
||||
DropMark::Box,
|
||||
)
|
||||
{
|
||||
acts.drop_from(
|
||||
d.source,
|
||||
if d.comp == comp {
|
||||
DropAction::Order {
|
||||
comp,
|
||||
folder: fstatic,
|
||||
keys: d.keys.clone(),
|
||||
groups: d.groups.clone(),
|
||||
order: order_before(&data.top_order, &d.entries(), None),
|
||||
}
|
||||
} else {
|
||||
DropAction::Component { to: comp, folder: d.folder, keys: d.keys.clone() }
|
||||
},
|
||||
);
|
||||
acts.drop = Some(if d.comp == comp {
|
||||
DropAction::Order {
|
||||
comp,
|
||||
folder: fstatic,
|
||||
keys: d.keys.clone(),
|
||||
groups: d.groups.clone(),
|
||||
order: order_before(&data.top_order, &d.entries(), None),
|
||||
}
|
||||
} else {
|
||||
DropAction::Component { to: comp, folder: d.folder, keys: d.keys.clone() }
|
||||
});
|
||||
}
|
||||
if !open {
|
||||
return;
|
||||
@@ -2929,11 +2790,8 @@ fn folder_level(
|
||||
}
|
||||
if let Some(resp) = &r.resp {
|
||||
// Dragging a group row moves the group; a press on its arrow or eye is not a drag.
|
||||
if resp.drag_started()
|
||||
&& !r.on_control
|
||||
&& let Some(source) = crate::semantic_content::current_source(app)
|
||||
{
|
||||
resp.dnd_set_drag_payload(Drag { source, comp, folder: fstatic, keys: Vec::new(), groups: vec![id] });
|
||||
if resp.drag_started() && !r.on_control {
|
||||
resp.dnd_set_drag_payload(Drag { comp, folder: fstatic, keys: Vec::new(), groups: vec![id] });
|
||||
}
|
||||
// The top of the row puts the drop before the group; the rest puts it inside.
|
||||
let level = app.session.doc.group_level(id);
|
||||
@@ -2945,29 +2803,26 @@ fn folder_level(
|
||||
d.comp == comp && d.folder == fstatic && fits(d, if zone == DropMark::Above { level - 1 } else { level })
|
||||
}) {
|
||||
let before = BrowserEntry::Group(id);
|
||||
acts.drop_from(
|
||||
d.source,
|
||||
match (zone, holder) {
|
||||
(DropMark::Box, _) => {
|
||||
DropAction::Move { comp, folder: fstatic, keys: d.keys.clone(), groups: d.groups.clone(), group: Some(id), before: None }
|
||||
}
|
||||
(DropMark::Above, Some(h)) => DropAction::Move {
|
||||
comp,
|
||||
folder: fstatic,
|
||||
keys: d.keys.clone(),
|
||||
groups: d.groups.clone(),
|
||||
group: Some(h),
|
||||
before: Some(before),
|
||||
},
|
||||
(DropMark::Above, None) => DropAction::Order {
|
||||
comp,
|
||||
folder: fstatic,
|
||||
order: order_before(top, &d.entries(), Some(&before)),
|
||||
keys: d.keys.clone(),
|
||||
groups: d.groups.clone(),
|
||||
},
|
||||
acts.drop = Some(match (zone, holder) {
|
||||
(DropMark::Box, _) => {
|
||||
DropAction::Move { comp, folder: fstatic, keys: d.keys.clone(), groups: d.groups.clone(), group: Some(id), before: None }
|
||||
}
|
||||
(DropMark::Above, Some(h)) => DropAction::Move {
|
||||
comp,
|
||||
folder: fstatic,
|
||||
keys: d.keys.clone(),
|
||||
groups: d.groups.clone(),
|
||||
group: Some(h),
|
||||
before: Some(before),
|
||||
},
|
||||
);
|
||||
(DropMark::Above, None) => DropAction::Order {
|
||||
comp,
|
||||
folder: fstatic,
|
||||
order: order_before(top, &d.entries(), Some(&before)),
|
||||
keys: d.keys.clone(),
|
||||
groups: d.groups.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if gopen {
|
||||
@@ -3046,7 +2901,7 @@ fn entry_row(
|
||||
let pattern_key = e.patterns.as_deref();
|
||||
let patterns_open = pattern_key.is_some_and(|key| is_open(app, key, true));
|
||||
let rect = row_rect(ui);
|
||||
let revealed = folder == "bodies" && body_reveal(app).is_some_and(|b| e.row_key.strip_prefix("b:") == Some(b));
|
||||
let revealed = folder == "bodies" && app.tree.reveal.as_ref().is_some_and(|b| e.row_key.strip_prefix("b:") == Some(b.as_str()));
|
||||
if !ui.is_rect_visible(rect) && !patterns_open && !revealed {
|
||||
ui.allocate_space(rect.size());
|
||||
return;
|
||||
@@ -3104,10 +2959,10 @@ fn entry_row(
|
||||
toggle(app, key, true);
|
||||
}
|
||||
if folder == "bodies"
|
||||
&& body_reveal(app).is_some_and(|b| e.row_key.strip_prefix("b:") == Some(b))
|
||||
&& app.tree.reveal.as_ref().is_some_and(|b| e.row_key.strip_prefix("b:") == Some(b.as_str()))
|
||||
&& let Some(rect) = r.rect
|
||||
&& crate::semantic_content::scroll_acknowledged(ui, rect)
|
||||
{
|
||||
ui.scroll_to_rect(rect, Some(egui::Align::Center));
|
||||
app.tree.reveal = None;
|
||||
}
|
||||
if r.eye {
|
||||
@@ -3124,7 +2979,7 @@ fn entry_row(
|
||||
if folder == "construction" || (folder == "bodies" && !shift && app.dialog.is_some()) {
|
||||
if let Some(s) = e.sel.clone() {
|
||||
if matches!(s, Sel::Body { .. }) {
|
||||
app.tree.content.current_mut().anchor = Some(e.row_key.clone());
|
||||
app.tree.anchor = Some(e.row_key.clone());
|
||||
}
|
||||
pick_from_browser(app, s);
|
||||
}
|
||||
@@ -3175,12 +3030,9 @@ fn entry_row(
|
||||
}
|
||||
let Some(resp) = &r.resp else { return };
|
||||
// Drag: the selected items of this folder when this one is selected, else just this one.
|
||||
if resp.drag_started()
|
||||
&& !r.on_control
|
||||
&& let Some(source) = crate::semantic_content::current_source(app)
|
||||
{
|
||||
if resp.drag_started() && !r.on_control {
|
||||
let keys: Vec<String> = if selected { selected_keys(app, comp, folder) } else { vec![e.key.clone()] };
|
||||
resp.dnd_set_drag_payload(Drag { source, comp, folder, keys, groups: Vec::new() });
|
||||
resp.dnd_set_drag_payload(Drag { comp, folder, keys, groups: Vec::new() });
|
||||
}
|
||||
let fits = |d: &Drag| {
|
||||
let doc = &app.session.doc;
|
||||
@@ -3191,19 +3043,16 @@ fn entry_row(
|
||||
};
|
||||
if let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp == comp && d.folder == folder && !d.keys.contains(&e.key) && fits(d), DropMark::Above) {
|
||||
let before = BrowserEntry::item(&e.key);
|
||||
acts.drop_from(
|
||||
d.source,
|
||||
match group {
|
||||
Some(g) => DropAction::Move { comp, folder, keys: d.keys.clone(), groups: d.groups.clone(), group: Some(g), before: Some(before) },
|
||||
None => DropAction::Order {
|
||||
comp,
|
||||
folder,
|
||||
order: order_before(top, &d.entries(), Some(&before)),
|
||||
keys: d.keys.clone(),
|
||||
groups: d.groups.clone(),
|
||||
},
|
||||
acts.drop = Some(match group {
|
||||
Some(g) => DropAction::Move { comp, folder, keys: d.keys.clone(), groups: d.groups.clone(), group: Some(g), before: Some(before) },
|
||||
None => DropAction::Order {
|
||||
comp,
|
||||
folder,
|
||||
order: order_before(top, &d.entries(), Some(&before)),
|
||||
keys: d.keys.clone(),
|
||||
groups: d.groups.clone(),
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3222,7 +3071,6 @@ enum DropMark {
|
||||
|
||||
/// While a matching payload hovers the row, mark where it would land; on release, the payload.
|
||||
fn drop_target(ui: &egui::Ui, resp: &egui::Response, accept: impl Fn(&Drag) -> bool, mark: DropMark) -> Option<std::sync::Arc<Drag>> {
|
||||
let accept = |drag: &Drag| crate::semantic_content::same_document(ui, drag.source) && accept(drag);
|
||||
let t = Tokens::get();
|
||||
let hovering = resp.dnd_hover_payload::<Drag>().filter(|d| accept(d));
|
||||
if hovering.is_some() {
|
||||
@@ -3243,7 +3091,6 @@ fn drop_target(ui: &egui::Ui, resp: &egui::Response, accept: impl Fn(&Drag) -> b
|
||||
|
||||
/// Like [`drop_target`] for a row that takes a drop above it (its top part) or inside it.
|
||||
fn drop_target_zone(ui: &egui::Ui, resp: &egui::Response, accept: impl Fn(&Drag, DropMark) -> bool) -> Option<(std::sync::Arc<Drag>, DropMark)> {
|
||||
let accept = |drag: &Drag, zone| crate::semantic_content::same_document(ui, drag.source) && accept(drag, zone);
|
||||
let t = Tokens::get();
|
||||
let r = resp.rect;
|
||||
let at = ui.input(|i| i.pointer.hover_pos().or(i.pointer.latest_pos()));
|
||||
@@ -3296,7 +3143,6 @@ fn occurrence_reorder_target(
|
||||
resp: &egui::Response,
|
||||
accept: impl Fn(&OccurrenceDrag) -> bool,
|
||||
) -> Option<std::sync::Arc<OccurrenceDrag>> {
|
||||
let accept = |drag: &OccurrenceDrag| crate::semantic_content::same_document(ui, drag.source) && accept(drag);
|
||||
if resp.dnd_hover_payload::<OccurrenceDrag>().is_some_and(|d| accept(&d)) {
|
||||
ui.painter().hline(resp.rect.x_range(), resp.rect.top(), Stroke::new(2.0, Tokens::get().accent));
|
||||
}
|
||||
@@ -3308,7 +3154,6 @@ fn occurrence_reorder_target(
|
||||
|
||||
/// While a matching placement drag hovers the row, mark where it would land; on release, the payload.
|
||||
fn occurrence_drop_target(ui: &egui::Ui, resp: &egui::Response, accept: impl Fn(&OccurrenceDrag) -> bool) -> Option<std::sync::Arc<OccurrenceDrag>> {
|
||||
let accept = |drag: &OccurrenceDrag| crate::semantic_content::same_document(ui, drag.source) && accept(drag);
|
||||
let t = Tokens::get();
|
||||
let hovering = resp.dnd_hover_payload::<OccurrenceDrag>().filter(|d| accept(d));
|
||||
if hovering.is_some() {
|
||||
@@ -3324,40 +3169,14 @@ fn occurrence_drop_target(ui: &egui::Ui, resp: &egui::Response, accept: impl Fn(
|
||||
|
||||
/// A drop, applied after the tree is drawn.
|
||||
enum DropAction {
|
||||
OccurrenceOrder {
|
||||
parent: u64,
|
||||
order: Vec<u64>,
|
||||
},
|
||||
/// Into a group (before an item of it), or out of groups.
|
||||
Move {
|
||||
comp: u64,
|
||||
folder: &'static str,
|
||||
keys: Vec<String>,
|
||||
groups: Vec<u64>,
|
||||
group: Option<u64>,
|
||||
before: Option<BrowserEntry>,
|
||||
},
|
||||
Move { comp: u64, folder: &'static str, keys: Vec<String>, groups: Vec<u64>, group: Option<u64>, before: Option<BrowserEntry> },
|
||||
/// Out of groups, in this order of the folder's top level.
|
||||
Order {
|
||||
comp: u64,
|
||||
folder: &'static str,
|
||||
keys: Vec<String>,
|
||||
groups: Vec<u64>,
|
||||
order: Vec<BrowserEntry>,
|
||||
},
|
||||
Order { comp: u64, folder: &'static str, keys: Vec<String>, groups: Vec<u64>, order: Vec<BrowserEntry> },
|
||||
/// Into another component (bodies and sketches).
|
||||
Component {
|
||||
to: u64,
|
||||
folder: &'static str,
|
||||
keys: Vec<String>,
|
||||
},
|
||||
Component { to: u64, folder: &'static str, keys: Vec<String> },
|
||||
/// A placement moved into another component (or the design, `parent` 0).
|
||||
Reparent {
|
||||
occurrence: u64,
|
||||
path: Vec<u64>,
|
||||
parent: u64,
|
||||
parent_path: Option<Vec<u64>>,
|
||||
},
|
||||
Reparent { occurrence: u64, path: Vec<u64>, parent: u64, parent_path: Option<Vec<u64>> },
|
||||
}
|
||||
|
||||
fn entry_json(e: &BrowserEntry) -> Value {
|
||||
@@ -3374,10 +3193,6 @@ fn entries_json(keys: &[String], groups: &[u64]) -> Vec<Value> {
|
||||
|
||||
fn apply_drop(app: &mut SolveApp, d: DropAction) {
|
||||
match d {
|
||||
DropAction::OccurrenceOrder { parent, order } => {
|
||||
let order: Vec<String> = order.iter().map(u64::to_string).collect();
|
||||
let _ = app.run("browser.order", json!({ "folder": "components", "component": parent, "order": order }));
|
||||
}
|
||||
DropAction::Move { comp, folder, keys, groups, group, before } => {
|
||||
let items = entries_json(&keys, &groups);
|
||||
let before = before.map(|b| entry_json(&b));
|
||||
|
||||
@@ -2,14 +2,11 @@
|
||||
|
||||
use super::*;
|
||||
use crate::history_rows::{HistoryFeature, HistoryRows};
|
||||
use crate::semantic_content::FeatureDrag;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Reveal {
|
||||
pub id: u64,
|
||||
pub path: Vec<u64>,
|
||||
/// Native Features and combined History are distinct presentations of the same feature.
|
||||
history: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -22,7 +19,6 @@ thread_local! {
|
||||
/// active component or history marker. The folder is the one of the placement `body` belongs to:
|
||||
/// without a body there is no telling which copy of a component was meant.
|
||||
pub(crate) fn reveal(app: &mut SolveApp, id: u64, body: &str) {
|
||||
sync_reveals(app);
|
||||
let Some(feature) = app.session.doc.feature(id) else { return };
|
||||
if matches!(feature.kind, FeatureKind::Sketch { .. }) {
|
||||
return; // Sketches are listed in Sketches, not Features.
|
||||
@@ -50,31 +46,17 @@ pub(crate) fn reveal(app: &mut SolveApp, id: u64, body: &str) {
|
||||
let scope = if path.is_empty() { format!("c{component}") } else { occurrence_key(&path) };
|
||||
app.tree.open_row(&scope);
|
||||
app.tree.expanded.insert(format!("{scope}/features"));
|
||||
app.tree.reveal_feature = Some(Reveal { id, path, history: false });
|
||||
app.tree.reveal_feature = Some(Reveal { id, path });
|
||||
}
|
||||
|
||||
/// With the history in the browser: open the feature's component and history, and scroll to it.
|
||||
pub(crate) fn reveal_in_history(app: &mut SolveApp, id: u64) {
|
||||
sync_reveals(app);
|
||||
let Some(component) = app.session.doc.feature(id).map(|feature| feature.component) else { return };
|
||||
let path = if component == 0 {
|
||||
Vec::new()
|
||||
} else {
|
||||
app.session
|
||||
.active_occurrence_path
|
||||
.clone()
|
||||
.filter(|_| app.session.active_component == component)
|
||||
.unwrap_or_else(|| app.session.doc.first_path(component))
|
||||
};
|
||||
app.ui.show_browser = true;
|
||||
app.tree.collapsed.remove("c0");
|
||||
for depth in 1..=path.len() {
|
||||
app.tree.open_row(&occurrence_key(&path[..depth]));
|
||||
}
|
||||
let scope = if path.is_empty() { format!("c{component}") } else { occurrence_key(&path) };
|
||||
app.tree.open_row(&scope);
|
||||
app.tree.collapsed.remove(&format!("{scope}/history"));
|
||||
app.tree.reveal_feature = Some(Reveal { id, path, history: true });
|
||||
app.tree.collapsed.remove(&format!("c{component}"));
|
||||
app.tree.collapsed.remove(&format!("c{component}/history"));
|
||||
app.tree.reveal_feature = Some(Reveal { id, path: Vec::new() });
|
||||
}
|
||||
|
||||
/// Feature folders hide later rows only while editing; history always lists every feature.
|
||||
@@ -168,8 +150,8 @@ pub(super) fn rows(app: &mut SolveApp, ui: &mut egui::Ui, component: u64, path:
|
||||
let mut slots: Vec<(f32, usize)> = Vec::new();
|
||||
// Where dragged rows can be dropped, as in the History at the left: these slots and one
|
||||
// before the marker, each with the folder the rows join (#9565).
|
||||
let drag_id = ui.id().with(("sc_browser_history_drag", &scope));
|
||||
let mut dragged = app.tree.content.current_mut().feature_drags.get(&drag_id).cloned();
|
||||
let drag_id = egui::Id::new(("sc_browser_history_drag", &scope));
|
||||
let mut dragged: Option<Vec<u64>> = ui.data(|d| d.get_temp(drag_id));
|
||||
let mut drops: Vec<DropSlot> = Vec::new();
|
||||
let mut marker_resp = None;
|
||||
for row in &plan.rows {
|
||||
@@ -269,7 +251,7 @@ fn feature_row(
|
||||
depth: usize,
|
||||
fold: Option<bool>,
|
||||
mark: Mark,
|
||||
drag: Option<&mut Option<FeatureDrag>>,
|
||||
drag: Option<&mut Option<Vec<u64>>>,
|
||||
) -> bool {
|
||||
let Some(feature) = app.session.doc.features.get(index) else { return false };
|
||||
let (component, path) = (here.component, here.path);
|
||||
@@ -333,16 +315,10 @@ fn feature_row(
|
||||
let scope = here.scope;
|
||||
if let Some(rect) = r.rect {
|
||||
crate::scenario::publish_handle(&format!("row:browser-feature:{scope}:{id}"), rect.center());
|
||||
let history = app.preferences.history_in_browser;
|
||||
if app.tree.content.browser_presentation()
|
||||
&& app.tree.reveal_feature.as_ref().is_some_and(|target| target.id == id && target.path == path && target.history == history)
|
||||
&& crate::semantic_content::scroll_acknowledged(ui, rect)
|
||||
{
|
||||
if app.tree.reveal_feature.as_ref().is_some_and(|target| target.id == id && target.path == path) {
|
||||
ui.scroll_to_rect(rect, Some(egui::Align::Center));
|
||||
app.tree.reveal_feature = None;
|
||||
}
|
||||
if history && app.tree.timeline_reveal == Some(id) && crate::semantic_content::scroll_acknowledged(ui, rect) {
|
||||
app.tree.timeline_reveal = None;
|
||||
}
|
||||
}
|
||||
if r.double {
|
||||
if !path.is_empty() {
|
||||
@@ -359,9 +335,8 @@ fn feature_row(
|
||||
if let Some(drag) = drag
|
||||
&& !r.on_control
|
||||
&& r.resp.as_ref().is_some_and(egui::Response::drag_started)
|
||||
&& let Some(source) = crate::semantic_content::current_source(app)
|
||||
{
|
||||
*drag = Some(FeatureDrag { source, features: crate::timeline_left::drag_features(app, id, selected) });
|
||||
*drag = Some(crate::timeline_left::drag_features(app, id, selected));
|
||||
}
|
||||
r.fold
|
||||
}
|
||||
@@ -370,7 +345,6 @@ fn feature_row(
|
||||
fn marker_row(app: &SolveApp, ui: &mut egui::Ui, depth: usize) -> egui::Response {
|
||||
let t = Tokens::get();
|
||||
let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), t.space_12), Sense::drag());
|
||||
crate::semantic_content::record_capture(ui, &response);
|
||||
let line = Rect::from_min_max(pos2(rect.left() + 2.0 + depth as f32 * INDENT, rect.top()), rect.max);
|
||||
crate::ui_kit::history_marker(ui.painter(), line, true, crate::theme::TabAccent::for_tab(&t, &app.ui.tab));
|
||||
crate::scenario::publish_handle("browser:rollback", rect.center());
|
||||
@@ -392,11 +366,7 @@ fn drag_marker(app: &mut SolveApp, ui: &egui::Ui, resp: &egui::Response, slots:
|
||||
let accent = crate::theme::TabAccent::current(ui.ctx()).color;
|
||||
ui.painter().hline(resp.rect.left() + 4.0..=resp.rect.right() - 4.0, slot_y, Stroke::new(2.0, accent));
|
||||
}
|
||||
if resp.drag_stopped()
|
||||
&& position != marker
|
||||
&& crate::semantic_content::current_source(app).is_some_and(|source| crate::semantic_content::source_present(app, source))
|
||||
&& crate::semantic_content::claim_action(ui, resp.id)
|
||||
{
|
||||
if resp.drag_stopped() && position != marker {
|
||||
let n = app.session.doc.features.len();
|
||||
let _ = app.run_heavy("timeline.roll_to", if position >= n { json!({}) } else { json!({ "position": position }) });
|
||||
}
|
||||
@@ -404,23 +374,11 @@ fn drag_marker(app: &mut SolveApp, ui: &egui::Ui, resp: &egui::Response, slots:
|
||||
|
||||
/// Dragging feature rows shows where they land with the History's drop bar; letting go moves them
|
||||
/// with the History's own move, before or behind the marker by the side of it they land on.
|
||||
fn drag_rows(
|
||||
app: &mut SolveApp,
|
||||
ui: &egui::Ui,
|
||||
plan: &HistoryRows,
|
||||
drag_id: egui::Id,
|
||||
dragged: Option<FeatureDrag>,
|
||||
drops: &[DropSlot],
|
||||
marker_y: f32,
|
||||
) {
|
||||
let Some(drag) = dragged else { return };
|
||||
if !crate::semantic_content::source_present(app, drag.source) {
|
||||
app.tree.content.current_mut().feature_drags.remove(&drag_id);
|
||||
return;
|
||||
}
|
||||
fn drag_rows(app: &mut SolveApp, ui: &egui::Ui, plan: &HistoryRows, drag_id: egui::Id, dragged: Option<Vec<u64>>, drops: &[DropSlot], marker_y: f32) {
|
||||
let Some(features) = dragged else { return };
|
||||
let (released, down) = ui.input(|i| (i.pointer.any_released(), i.pointer.any_down()));
|
||||
if released || !down {
|
||||
app.tree.content.current_mut().feature_drags.remove(&drag_id);
|
||||
ui.data_mut(|d| d.remove::<Vec<u64>>(drag_id));
|
||||
}
|
||||
if !released && !down {
|
||||
return;
|
||||
@@ -436,7 +394,7 @@ fn drag_rows(
|
||||
.and_then(|p| drops.iter().min_by(|a, b| (a.y - p.y).abs().total_cmp(&(b.y - p.y).abs())).copied());
|
||||
if released {
|
||||
if let Some(drop) = slot {
|
||||
crate::timeline_left::drop_items(app, plan, &drag.features, &[], drop.group, drop.position, drop.y < marker_y);
|
||||
crate::timeline_left::drop_items(app, plan, &features, &[], drop.group, drop.position, drop.y < marker_y);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -449,7 +407,7 @@ fn drag_rows(
|
||||
Tokens::get().sketch_accent,
|
||||
);
|
||||
}
|
||||
app.tree.content.current_mut().feature_drags.insert(drag_id, drag);
|
||||
ui.data_mut(|d| d.insert_temp(drag_id, features));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -465,20 +423,9 @@ mod tests {
|
||||
_ => None,
|
||||
};
|
||||
h.app.tree.expanded.insert("c0/features".into());
|
||||
DRAWN.with(|d| d.borrow_mut().clear());
|
||||
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||||
h.ctx
|
||||
.run_ui(raw, |ui| {
|
||||
use crate::semantic_content::{self, ContentOwner, ContentRequest};
|
||||
semantic_content::begin(&mut h.app, ui.ctx());
|
||||
semantic_content::render_scope(&mut h.app, ui, ContentOwner::BROWSER, ContentRequest::Browser { shrink_height: false }, |app, ui| {
|
||||
let mut actions = Actions::default();
|
||||
rows(app, ui, 0, &[], 0, &mut actions);
|
||||
actions
|
||||
});
|
||||
semantic_content::finalize(&mut h.app, ui.ctx());
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
h.ctx.run_ui(raw, |ui| rows(&mut h.app, ui, 0, &[], 0, &mut Actions::default())).textures_delta.clear();
|
||||
drawn_rows().into_iter().map(|(label, ..)| label).collect()
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -95,10 +95,7 @@ fn a_face_picked_in_the_viewport_opens_the_browser_to_its_placements_body() {
|
||||
let (one, two) = (super::occurrence_key(&[occurrence]), super::occurrence_key(&[copy]));
|
||||
h.app.tree.collapsed.extend([one.clone(), two.clone(), format!("{two}/bodies")]);
|
||||
h.frames(2);
|
||||
assert!(
|
||||
!h.app.tree.content.owner(crate::semantic_content::ContentOwner::BROWSER).unwrap().order.contains(&format!("b:{second}")),
|
||||
"the body's row is closed away"
|
||||
);
|
||||
assert!(!h.app.tree.order.contains(&format!("b:{second}")), "the body's row is closed away");
|
||||
let face = Sel::Face { body: second.clone(), index: 0, point: solidcore_record::Vec3::new(55.0, 5.0, 10.0) };
|
||||
crate::viewport::select(&mut h.app, Some(face), false, false);
|
||||
assert_eq!(h.app.tree.reveal.as_deref(), Some(second.as_str()));
|
||||
@@ -106,14 +103,8 @@ fn a_face_picked_in_the_viewport_opens_the_browser_to_its_placements_body() {
|
||||
assert!(h.app.tree.reveal.is_none(), "revealed");
|
||||
assert!(!h.app.tree.collapsed.contains(&two) && !h.app.tree.collapsed.contains(&format!("{two}/bodies")));
|
||||
assert!(h.app.tree.collapsed.contains(&one), "the other placement stays as it was");
|
||||
assert!(
|
||||
h.app.tree.content.owner(crate::semantic_content::ContentOwner::BROWSER).unwrap().order.contains(&format!("b:{second}")),
|
||||
"the row is drawn"
|
||||
);
|
||||
assert!(
|
||||
!h.app.tree.content.owner(crate::semantic_content::ContentOwner::BROWSER).unwrap().order.contains(&format!("b:{first}")),
|
||||
"the other placement's row stays closed away"
|
||||
);
|
||||
assert!(h.app.tree.order.contains(&format!("b:{second}")), "the row is drawn");
|
||||
assert!(!h.app.tree.order.contains(&format!("b:{first}")), "the other placement's row stays closed away");
|
||||
}
|
||||
|
||||
/// Hovering a placement row lights its whole subtree: the subtree is walked once per model, not
|
||||
@@ -562,8 +553,5 @@ fn find_in_browser_opens_placements_that_start_closed() {
|
||||
super::reveal_body(&mut h.app, &body);
|
||||
h.frames(3);
|
||||
assert!(super::is_open(&h.app, &super::occurrence_key(&[occurrence]), true));
|
||||
assert!(
|
||||
h.app.tree.content.owner(crate::semantic_content::ContentOwner::BROWSER).unwrap().order.contains(&format!("b:{body}")),
|
||||
"the body's row is drawn"
|
||||
);
|
||||
assert!(h.app.tree.order.contains(&format!("b:{body}")), "the body's row is drawn");
|
||||
}
|
||||
|
||||
@@ -23,8 +23,7 @@ use crate::viewport::{Proj, projected_polygon};
|
||||
/// direction a growing value points.
|
||||
pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
|
||||
let st = crate::open_meshing::session_world(&app.session);
|
||||
let item = anchor_item(app, d);
|
||||
let Some(first) = &item else { return section_plane(d) };
|
||||
let first = &anchor_item(app, d)?;
|
||||
let (at, normal) = match first {
|
||||
Sel::Profile { sketch, index } => {
|
||||
let ss = st.sketch(*sketch)?;
|
||||
@@ -254,18 +253,6 @@ pub(crate) fn blend_grow_dir(m: &Mesh, index: usize, p: Vec3) -> Option<Vec3> {
|
||||
Some(if edge_concave(m, index, p).unwrap_or(false) { n } else { -n })
|
||||
}
|
||||
|
||||
/// A Section Analysis dialog reopened from its section's own geometry (Show viewport section
|
||||
/// status): the plane it keeps as numbers when nothing is picked, so its arrow stands there as on
|
||||
/// a picked plane (#11423).
|
||||
fn section_plane(d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
|
||||
if !matches!(d.kind, Kind::Section { .. }) {
|
||||
return None;
|
||||
}
|
||||
let plane = d.extra.get("plane")?;
|
||||
let point = |key: &str| serde_json::from_value::<[f64; 3]>(plane.get(key)?.clone()).ok().map(|[x, y, z]| Vec3::new(x, y, z));
|
||||
Some((point("origin")?, Some(point("normal")?.normalized()?)))
|
||||
}
|
||||
|
||||
/// The pick the dialog's arrow sits on: the first, or for a fillet or chamfer reopened from a
|
||||
/// click on one of its faces (Press Pull), its edge nearest the click, at the point on it nearest
|
||||
/// the click (#4986).
|
||||
|
||||
@@ -1507,10 +1507,14 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
let _ = app.run("browser.visibility", json!({ "folder": "all", "visible": true }));
|
||||
}
|
||||
"ui.isolate" => {
|
||||
// The picked bodies stay (shown if they were hidden) and everything else goes, other
|
||||
// components as components (#7896), in one step.
|
||||
// The picked bodies stay (shown first if they were hidden), everything else goes.
|
||||
let others: Vec<String> =
|
||||
crate::open_meshing::session_world(&app.session).bodies.iter().map(|b| b.name.clone()).filter(|b| !bodies.contains(b)).collect();
|
||||
if !bodies.is_empty() {
|
||||
let _ = app.run("browser.visibility", json!({ "items": bodies, "visible": true, "isolate": true }));
|
||||
let _ = app.run("browser.visibility", json!({ "items": bodies, "visible": true }));
|
||||
}
|
||||
if !others.is_empty() {
|
||||
let _ = app.run("browser.visibility", json!({ "items": others, "visible": false }));
|
||||
}
|
||||
}
|
||||
"ui.lock" => {
|
||||
@@ -1603,7 +1607,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
}
|
||||
}
|
||||
let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": f}]}));
|
||||
crate::browser::request_timeline_reveal(app, f);
|
||||
app.tree.timeline_reveal = Some(f);
|
||||
}
|
||||
}
|
||||
"ui.tangentChain" => tangent_chain(app),
|
||||
|
||||
@@ -2635,23 +2635,12 @@ mod tests {
|
||||
assert_eq!(call("ui.render", json!({"path": at(&root, "a.png"), "width": 32, "height": 32}), true)["ok"], true);
|
||||
}
|
||||
assert_eq!(call("ui.screenshot", json!({"path": at(&root, "b.png")}), true)["ok"], true);
|
||||
// Settings profiles are read and written by the app, not the engine, and stay inside too (#11362).
|
||||
let profile = |id: &str, params: Value| json!({"command": id, "params": params});
|
||||
let poc = json!({"format": "solidcraft-profile/1", "name": "scope-poc"});
|
||||
assert_eq!(call("engine.execute", profile("settings.profile.import", json!({"profile": poc})), true)["ok"], true);
|
||||
let inside = json!({"name": "scope-poc", "path": at(&root, "p.json")});
|
||||
assert_eq!(call("engine.execute", profile("settings.profile.export", inside), true)["ok"], true);
|
||||
assert_eq!(call("engine.execute", profile("settings.profile.import", json!({"path": at(&root, "p.json")})), true)["ok"], true);
|
||||
std::fs::write(at(&base, "p.json"), poc.to_string()).unwrap();
|
||||
for (method, params) in [
|
||||
("engine.execute", export(at(&out, "a.step"))),
|
||||
("engine.execute", export(at(&root, "../out/a.step"))),
|
||||
("engine.script", json!({"commands": [{"command": "file.export", "params": {"path": at(&out, "a.step")}}]})),
|
||||
("ui.render", json!({"path": at(&out, "a.png"), "width": 32, "height": 32})),
|
||||
("ui.screenshot", json!({"path": at(&out, "b.png")})),
|
||||
("engine.execute", profile("settings.profile.export", json!({"name": "scope-poc", "path": at(&out, "p.json")}))),
|
||||
("engine.execute", profile("settings.profile.import", json!({"path": at(&base, "p.json")}))),
|
||||
("engine.execute", profile("settings.profile.import", json!({"path": at(&out, "missing.json")}))),
|
||||
] {
|
||||
let v = call(method, params, true);
|
||||
assert!(v["ok"] == false && v.to_string().contains("outside the folders this agent may use"), "{method}: {v}");
|
||||
|
||||
@@ -1229,16 +1229,11 @@ impl Dialog {
|
||||
}
|
||||
}
|
||||
Self::take_component_bodies(app, &mut d);
|
||||
// A primitive goes where a face or vertex is picked, standing on a planar face. The Box,
|
||||
// Sphere and Torus also go to a sketch point (#9403).
|
||||
if let Kind::Primitive { cmd, at, .. } = &mut d.kind {
|
||||
let sketch_points = !crate::primitive_placement::inputs(cmd).is_empty();
|
||||
// A primitive goes where a face or vertex is picked, standing on a planar face.
|
||||
if let Kind::Primitive { at, .. } = &mut d.kind {
|
||||
*at = s.selection.iter().find_map(|sel| match sel {
|
||||
Sel::Face { body, index, point } => Some((*point, planar_face(s, body, *index).map(|(_, n)| n))),
|
||||
Sel::Vertex { point, .. } => Some((*point, None)),
|
||||
Sel::ExternalSketchPoint { .. } | Sel::SketchPoint { .. } if sketch_points => {
|
||||
crate::primitive_placement::world_point(s, sel).map(|p| (p, None))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
}
|
||||
@@ -2857,12 +2852,6 @@ pub(crate) fn selection_label(s: &Session, names: crate::prefs::OriginPlaneNames
|
||||
Sel::ExternalSketchCurve { sketch, id } => {
|
||||
crate::i18n::format("dialog.selection_external_curve", &[("sketch", &sketch_name(*sketch)), ("id", id)])
|
||||
}
|
||||
// Another sketch's point (a Sphere's centre, #9403), named as Select Other names it.
|
||||
Sel::ExternalSketchPoint { sketch, id } => {
|
||||
let state = crate::open_meshing::session_world(s);
|
||||
let point = state.sketch(*sketch).map_or_else(|| id.clone(), |ss| crate::select_other::point_label(&ss.sketch, id));
|
||||
crate::i18n::format("dialog.selection_external_point", &[("point", &point), ("sketch", &sketch_name(*sketch))])
|
||||
}
|
||||
Sel::SketchConstraint { id } => crate::i18n::format("dialog.selection_constraint", &[("id", id)]),
|
||||
Sel::Profile { sketch, index } => {
|
||||
crate::i18n::format("dialog.selection_profile", &[("sketch", &sketch_name(*sketch)), ("number", &index.saturating_add(1).to_string())])
|
||||
@@ -2887,6 +2876,7 @@ pub(crate) fn selection_label(s: &Session, names: crate::prefs::OriginPlaneNames
|
||||
format!("({:.2}, {:.2}) mm", p.x, p.y)
|
||||
}
|
||||
Sel::Vertex { body, .. } => format!("{body} · vertex"),
|
||||
_ => format!("{item:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,23 +126,11 @@ pub enum Asm {
|
||||
of: Vec<Sel>,
|
||||
/// Pairs closer than this (mm, as typed) are listed too.
|
||||
clearance: String,
|
||||
/// Compute's check under way (its reply lands here).
|
||||
check: Option<Check>,
|
||||
},
|
||||
/// Motion Trace: a picked point's path, previewed; OK keeps it as a 3D sketch spline.
|
||||
Trace(Box<TraceForm>),
|
||||
}
|
||||
|
||||
/// Interference's check off the window thread: where it leaves its reply (the check's and the
|
||||
/// shared volumes' meshes), the design and the picks it checked, and when it started.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Check {
|
||||
reply: std::sync::Arc<std::sync::Mutex<Option<(Value, Vec<Mesh>)>>>,
|
||||
design: std::sync::Weak<solidcraft_engine::doc::Document>,
|
||||
of: Vec<Sel>,
|
||||
started: f64,
|
||||
}
|
||||
|
||||
/// The snap under the cursor, remembered for the selection it was found for.
|
||||
pub type Hover = Option<(Sel, Option<SnapPt>)>;
|
||||
|
||||
@@ -300,7 +288,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
"joint.gear" | "joint.rack_pinion" | "joint.screw" => crate::dialogs_coupling::start(app, id)?,
|
||||
"joint.cam" => crate::dialogs_coupling::cam::start(app),
|
||||
"inspect.interference" => (
|
||||
Asm::Interference { result: None, overlaps: Vec::new(), of: Vec::new(), clearance: "0".into(), check: None },
|
||||
Asm::Interference { result: None, overlaps: Vec::new(), of: Vec::new(), clearance: "0".into() },
|
||||
vec![SelInput::new("Bodies (all if none)", BODIES, true)],
|
||||
),
|
||||
_ => return None,
|
||||
@@ -969,45 +957,10 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
|
||||
}
|
||||
Asm::Coupling(f) => enter |= crate::dialogs_coupling::rows(app, ui, f, inputs),
|
||||
Asm::Cam(f) => enter |= crate::dialogs_coupling::cam::rows(app, ui, f, inputs),
|
||||
Asm::Interference { result, overlaps, of, clearance, check } => {
|
||||
Asm::Interference { result, overlaps, of, clearance } => {
|
||||
let picked: Vec<Sel> = inputs.first().map(|i| i.items.clone()).unwrap_or_default();
|
||||
if let Some(c) = check.take() {
|
||||
match c.reply.lock().map(|mut r| r.take()).map_err(|_| ()) {
|
||||
Ok(None) => {
|
||||
*check = Some(c);
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
// Done: shown unless the design changed meanwhile (a script, the control
|
||||
// channel, undo). A poisoned lock (its thread panicked) leaves nothing.
|
||||
Ok(Some((r, o))) => {
|
||||
if c.design.upgrade().is_some_and(|d| std::sync::Arc::ptr_eq(&d, &app.session.doc)) {
|
||||
(*result, *overlaps, *of) = (Some(r), o, c.of);
|
||||
}
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
Err(()) => {}
|
||||
}
|
||||
}
|
||||
enter |= expr_row(ui, "Clearance", clearance);
|
||||
// While it checks: the seconds so far, and Cancel where Compute was.
|
||||
if let Some(started) = check.as_ref().map(|c| c.started) {
|
||||
let t = Tokens::get();
|
||||
form_row(ui, "", |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let seconds = ((crate::now_ms() - started) / 1000.0).floor().max(0.0);
|
||||
ui.add(egui::Spinner::new().size(t.font_small));
|
||||
ui.label(RichText::new(crate::i18n::format("dialog.computing", &[("seconds", &format!("{seconds:.0}"))])).color(t.text_dim));
|
||||
});
|
||||
});
|
||||
}
|
||||
form_row(ui, "", |ui| {
|
||||
if check.is_some() {
|
||||
if crate::ui_kit::button(ui, crate::i18n::tr("ui.cancel"), crate::ui_kit::Button::Normal).clicked() {
|
||||
// Its thread finishes the check; the reply is left unread.
|
||||
*check = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let stale = result.is_some() && *of != picked;
|
||||
if crate::ui_kit::button(
|
||||
ui,
|
||||
@@ -1016,14 +969,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
(*result, *overlaps) = (None, Vec::new());
|
||||
*check = Some(interference(app, &picked, clearance));
|
||||
ui.ctx().request_repaint();
|
||||
let (r, o) = interference(app, &picked, clearance);
|
||||
*result = Some(r);
|
||||
*overlaps = o;
|
||||
*of = picked;
|
||||
}
|
||||
});
|
||||
if check.is_none()
|
||||
&& let Some(r) = result.as_ref()
|
||||
{
|
||||
if let Some(r) = result.as_ref() {
|
||||
interference_rows(app, ui, r);
|
||||
}
|
||||
}
|
||||
@@ -1223,73 +1175,34 @@ fn fill_limits(doc: &solidcraft_engine::doc::Document, f: &mut JointForm, reply:
|
||||
}
|
||||
}
|
||||
|
||||
/// Start Compute's check (overlaps, pairs closer than the clearance, each body's nearest
|
||||
/// neighbour) and find the overlapping volumes (world placement) to show in red. Off the window
|
||||
/// thread, on a kernel thread, when the app runs commands there: a slow pair froze the window
|
||||
/// until the app was closed (#11483). Else at once.
|
||||
fn interference(app: &SolveApp, picked: &[Sel], clearance: &str) -> Check {
|
||||
let check =
|
||||
Check { reply: Default::default(), design: std::sync::Arc::downgrade(&app.session.doc), of: picked.to_vec(), started: crate::now_ms() };
|
||||
/// Run the check (overlaps, pairs closer than the clearance, each body's nearest neighbour) and
|
||||
/// find the overlapping volumes (world placement) to show in red.
|
||||
fn interference(app: &mut SolveApp, picked: &[Sel], clearance: &str) -> (Value, Vec<Mesh>) {
|
||||
let names: Vec<String> = picked.body_names();
|
||||
let (scale, _) = crate::prefs::unit_scale(&app.session.doc.units);
|
||||
let reply = match crate::prefs::parse_length_mm(clearance, scale) {
|
||||
Some(v) if v >= 0.0 => {
|
||||
let mut p = json!({ "clearance": v, "nearest": 1 });
|
||||
if !names.is_empty() {
|
||||
p["bodies"] = json!(names);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if app.apply_off_thread.is_some() {
|
||||
let (ahead, world, out, params) =
|
||||
(app.session.ahead(), crate::open_meshing::session_world(&app.session), check.reply.clone(), p.clone());
|
||||
let spawned = solidcraft_engine::kernel::kernel_thread("interference").spawn(move || {
|
||||
let r = check_now(ahead, &world, params);
|
||||
if let Ok(mut slot) = out.lock() {
|
||||
*slot = Some(r);
|
||||
}
|
||||
});
|
||||
if spawned.is_ok() {
|
||||
return check;
|
||||
}
|
||||
}
|
||||
check_now(app.session.ahead(), &crate::open_meshing::session_world(&app.session), p)
|
||||
}
|
||||
_ => (json!({ "error": crate::i18n::tr("dialog.clearance_invalid") }), Vec::new()),
|
||||
let clearance = match crate::prefs::parse_length_mm(clearance, scale) {
|
||||
Some(v) if v >= 0.0 => v,
|
||||
_ => return (json!({ "error": crate::i18n::tr("dialog.clearance_invalid") }), Vec::new()),
|
||||
};
|
||||
if let Ok(mut slot) = check.reply.lock() {
|
||||
*slot = Some(reply);
|
||||
let mut p = json!({ "clearance": clearance, "nearest": 1 });
|
||||
if !names.is_empty() {
|
||||
p["bodies"] = json!(names);
|
||||
}
|
||||
check
|
||||
}
|
||||
|
||||
/// `inspect.clearance` with `p` on a copy of the session, and the volume each pair it finds
|
||||
/// overlapping shares ([`overlap_meshes`]).
|
||||
fn check_now(ahead: solidcraft_engine::Ahead, world: &solidcraft_engine::doc::ModelState, p: Value) -> (Value, Vec<Mesh>) {
|
||||
// One reply for the one command.
|
||||
match ahead.replies(&[("inspect.clearance".to_string(), p)]).pop() {
|
||||
Some(Ok(r)) => {
|
||||
let meshes = overlap_meshes(world, &r);
|
||||
(r, meshes)
|
||||
let r = match app.run("inspect.clearance", p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return (json!({ "error": e }), Vec::new()),
|
||||
};
|
||||
let st = crate::open_meshing::session_world(&app.session);
|
||||
let mut meshes = Vec::new();
|
||||
for hit in r["overlaps"].as_array().into_iter().flatten() {
|
||||
let (Some(a), Some(b)) = (hit["a"].as_str().and_then(|n| st.body(n)), hit["b"].as_str().and_then(|n| st.body(n))) else { continue };
|
||||
if let Ok(Some(o)) = solidcraft_engine::kernel::boolean(&a.body, &b.body, solidcraft_engine::kernel::BoolOp::Intersect)
|
||||
&& let Ok(m) = o.mesh(&solidcraft_engine::kernel::MeshTolerance::chord((o.size() * 2e-3).max(1e-3)))
|
||||
{
|
||||
meshes.push(m);
|
||||
}
|
||||
Some(Err(e)) => (json!({ "error": e.to_string() }), Vec::new()),
|
||||
None => (json!({}), Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The volume each pair in an `inspect.clearance` reply's `overlaps` shares, meshed in world
|
||||
/// placement. Each pair runs on a kernel worker with the kernel's stack, whatever thread asks
|
||||
/// (the window thread's is 1 MiB on Windows).
|
||||
fn overlap_meshes(st: &solidcraft_engine::doc::ModelState, r: &Value) -> Vec<Mesh> {
|
||||
use solidcraft_engine::kernel;
|
||||
let pairs: Vec<_> =
|
||||
r["overlaps"].as_array().into_iter().flatten().filter_map(|hit| Some((st.body(hit["a"].as_str()?)?, st.body(hit["b"].as_str()?)?))).collect();
|
||||
kernel::map_with_local_pools(&pairs, usize::MAX, |&(a, b)| {
|
||||
let Some(o) = kernel::boolean(&a.body, &b.body, kernel::BoolOp::Intersect)? else { return Ok(None) };
|
||||
o.mesh(&kernel::MeshTolerance::chord((o.size() * 2e-3).max(1e-3))).map(Some)
|
||||
})
|
||||
.into_iter()
|
||||
.filter_map(|m| m.ok().flatten())
|
||||
.collect()
|
||||
(r, meshes)
|
||||
}
|
||||
|
||||
fn interference_rows(app: &SolveApp, ui: &mut egui::Ui, r: &Value) {
|
||||
|
||||
@@ -1337,66 +1337,3 @@ fn find_limits_searches_the_joint_as_edited_and_leaves_a_free_side() {
|
||||
find_limits(&app, f, id, &d.inputs);
|
||||
assert!(f.found_note.is_some() && f.found.is_empty(), "{:?}", f.found_note);
|
||||
}
|
||||
|
||||
/// Compute in Interference intersects and meshes each overlap on the kernel's workers. On the
|
||||
/// window thread (a 1 MiB stack on Windows) the kernel ran out of stack and the app closed
|
||||
/// (#11483); here the dialog's own thread has 64 KiB.
|
||||
#[test]
|
||||
fn interference_meshes_each_overlap_on_the_kernels_stack() {
|
||||
let mut s = Session::default();
|
||||
crate::test_support::grounded_boxes(&[("A", 0), ("B", 10)], |id, p| s.execute(id, &p).unwrap());
|
||||
let r = s.execute("inspect.clearance", &json!({"clearance": 0, "nearest": 1})).unwrap();
|
||||
assert_eq!(r["overlaps"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
let st = s.world_state().unwrap();
|
||||
let meshes = std::thread::Builder::new().stack_size(64 << 10).spawn(move || overlap_meshes(&st, &r)).unwrap().join().unwrap();
|
||||
// A's 0..20 and B's 10..30 along X share 10..20.
|
||||
let b = meshes.first().map(Mesh::bounds).expect("the shared volume");
|
||||
assert_eq!(meshes.len(), 1);
|
||||
let near = |p: Vec3, q: [f64; 3]| (p.x - q[0]).abs() < 1e-6 && (p.y - q[1]).abs() < 1e-6 && (p.z - q[2]).abs() < 1e-6;
|
||||
assert!(near(b.min, [10.0, 0.0, 0.0]) && near(b.max, [20.0, 20.0, 10.0]), "{b:?}");
|
||||
}
|
||||
|
||||
/// With the app running commands off the window thread, Compute in Interference checks on a
|
||||
/// thread of its own (a slow pair froze the window until the app was closed, #11483): the
|
||||
/// overlap and its shared volume arrive in a later frame, and a check of a design changed
|
||||
/// meanwhile is dropped.
|
||||
#[test]
|
||||
fn interference_checks_off_the_window_thread_and_drops_a_check_of_a_changed_design() {
|
||||
let mut s = Session::default();
|
||||
crate::test_support::grounded_boxes(&[("A", 0), ("B", 10)], |id, p| s.execute(id, &p).unwrap());
|
||||
let mut app = SolveApp::new(s, Services::default());
|
||||
app.apply_off_thread = Some(std::time::Duration::ZERO);
|
||||
app.start("inspect.interference");
|
||||
let ctx = crate::fonts::test_context();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
|
||||
let state = |app: &SolveApp| match app.dialog.as_ref().map(|d| &d.kind) {
|
||||
Some(Kind::Assembly(Asm::Interference { result, overlaps, check, .. })) => (result.clone(), overlaps.len(), check.is_some()),
|
||||
_ => panic!("the dialog stays open"),
|
||||
};
|
||||
// Compute as its button starts it, then frames until its reply is read.
|
||||
let compute = |app: &mut SolveApp, change: bool| {
|
||||
let c = interference(app, &[], "0");
|
||||
if let Some(Kind::Assembly(Asm::Interference { result, overlaps, check, .. })) = app.dialog.as_mut().map(|d| &mut d.kind) {
|
||||
(*result, *overlaps, *check) = (None, Vec::new(), Some(c));
|
||||
}
|
||||
if change {
|
||||
app.run("solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [100, 0, 0]})).unwrap();
|
||||
}
|
||||
for _ in 0..3000 {
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| crate::dialogs::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
if !state(app).2 {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
panic!("the check never came back");
|
||||
};
|
||||
compute(&mut app, false);
|
||||
let (result, overlaps, _) = state(&app);
|
||||
let result = result.expect("the check's reply");
|
||||
assert_eq!(result["overlaps"].as_array().map(Vec::len), Some(1), "{result}");
|
||||
assert_eq!(overlaps, 1, "the shared volume, drawn in red");
|
||||
compute(&mut app, true);
|
||||
assert_eq!(state(&app), (None, 0, false), "a check of the design before the change is dropped");
|
||||
}
|
||||
|
||||
@@ -1111,20 +1111,17 @@ fn measure_rows(app: &SolveApp, ui: &mut egui::Ui, r: Option<&Value>, show: &mut
|
||||
let lines = crate::quick_measure::item_lines(app, it);
|
||||
for (k, line) in lines.into_iter().enumerate() {
|
||||
let label = if k == 0 { crate::i18n::format("measure.item", &[("number", &(i + 1).to_string())]) } else { String::new() };
|
||||
// Coordinates need all three values; a fixed-height form cell elides the end.
|
||||
let text = if label.is_empty() { line } else { format!("{label} {line}") };
|
||||
ui_kit::text(ui, &text, ui_kit::Text::Report(&format!("measure_item_{i}_{k}"), false));
|
||||
value(ui, &label, &line);
|
||||
}
|
||||
}
|
||||
if let Some((now, d, a, b)) = shown_distance(r, *show) {
|
||||
if r.get("center_distance").is_some() {
|
||||
// Centre, min and max all listed; the chosen one is drawn and broken into ΔX/Y/Z.
|
||||
let centre_label = crate::quick_measure::centre_distance_label(r);
|
||||
for (which, label, key) in
|
||||
[(Shown::Centre, centre_label, "center_distance"), (Shown::Min, tr("Min"), "distance_mm"), (Shown::Max, tr("Max"), "max")]
|
||||
[(Shown::Centre, "Centre to centre", "center_distance"), (Shown::Min, "Min", "distance_mm"), (Shown::Max, "Max", "max")]
|
||||
{
|
||||
let Some(v) = r.get(key).filter(|v| v.is_number()) else { continue };
|
||||
if ui_kit::list_picker(ui, key, &format!("{} {}", label, mm(v)), now == which).clicked() {
|
||||
if ui_kit::list_picker(ui, key, &format!("{} {}", tr(label), mm(v)), now == which).clicked() {
|
||||
*show = Some(which);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,36 +648,3 @@ fn transform_handles_and_fields_scale_move_and_turn_the_picks() {
|
||||
let top = (0..sk.curves.len()).flat_map(|i| sk.polyline(i)).fold(f64::MIN, |m, p| m.max(p.x));
|
||||
assert!((top - 40.0).abs() < 1e-6, "one undo puts it back: {top}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_applies_the_dimensioned_rectangle_shown_in_its_preview() {
|
||||
for profile in [false, true] {
|
||||
let mut h = fixture();
|
||||
for (line, value) in [("l1", 30), ("l2", 20)] {
|
||||
h.app.run("sketch.dimension", json!({"entities": [line], "type": "length", "value": value})).unwrap();
|
||||
}
|
||||
let mut dialog = Dialog::for_command(&h.app, "sketch.transform").unwrap();
|
||||
dialog.inputs[0].items = if profile {
|
||||
vec![Sel::Profile { sketch: h.app.session.active_sketch.unwrap(), index: 0 }]
|
||||
} else {
|
||||
["l1", "l2", "l3", "l4"].map(|id| Sel::SketchCurve { id: id.into(), sketch: None }).to_vec()
|
||||
};
|
||||
h.app.dialog = Some(dialog);
|
||||
h.frames(4);
|
||||
if let Some(Kind::SketchModify(Mod::Transform { factor, angle, .. })) = h.app.dialog.as_mut().map(|d| &mut d.kind) {
|
||||
*factor = "2".into();
|
||||
*angle = "30 deg".into();
|
||||
}
|
||||
h.frames(3);
|
||||
h.step(&json!({"click": {"handle": "dialog:ok"}})).unwrap();
|
||||
h.settle();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().and_then(|d| d.error.clone()));
|
||||
let state = h.app.session.world_state().unwrap();
|
||||
let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
|
||||
let a = sk.point(sk.resolve_point("l1.start").unwrap()).unwrap();
|
||||
let b = sk.point(sk.resolve_point("l1.end").unwrap()).unwrap();
|
||||
let edge = b - a;
|
||||
assert!((edge.len() - 60.0).abs() < 1e-6, "the applied width matches Scale 2: {edge:?}");
|
||||
assert!((edge.angle().to_degrees() - 30.0).abs() < 1e-6, "the applied rotation matches Angle 30: {edge:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ pub struct DocSlot {
|
||||
pre_sketch_component: Option<crate::PreSketchComponent>,
|
||||
view: DocView,
|
||||
occurrence_move: Option<crate::browser::OccMove>,
|
||||
reveals: crate::browser::PendingReveals,
|
||||
agent_chat: crate::agent_chat::AgentChat,
|
||||
/// Its linked design files as the design last read them (`Documents::link_stamps`).
|
||||
link_stamps: Vec<(u64, crate::linked_source::Stamp)>,
|
||||
@@ -143,7 +142,6 @@ impl DocSlot {
|
||||
pre_sketch_component: None,
|
||||
view: DocView::default(),
|
||||
occurrence_move: None,
|
||||
reveals: Default::default(),
|
||||
agent_chat: Default::default(),
|
||||
link_stamps: Vec::new(),
|
||||
edit_in_place: None,
|
||||
@@ -217,8 +215,6 @@ fn put_view(app: &mut SolveApp, v: DocView) {
|
||||
|
||||
/// Put `slot` into the app and return what was there.
|
||||
fn swap_in(app: &mut SolveApp, mut slot: DocSlot) -> DocSlot {
|
||||
let reveals = crate::browser::take_reveals(app);
|
||||
crate::semantic_content::leave_document(app);
|
||||
if crate::render_workspace::active(app) {
|
||||
let _ = crate::render_workspace::enter(app, false);
|
||||
}
|
||||
@@ -252,14 +248,12 @@ fn swap_in(app: &mut SolveApp, mut slot: DocSlot) -> DocSlot {
|
||||
pre_sketch_component: std::mem::replace(&mut app.pre_sketch_component, slot.pre_sketch_component),
|
||||
view: take_view(app),
|
||||
occurrence_move: std::mem::replace(&mut app.tree.occurrence_move, slot.occurrence_move),
|
||||
reveals,
|
||||
agent_chat: slot.agent_chat,
|
||||
link_stamps: std::mem::replace(&mut app.docs.link_stamps, slot.link_stamps),
|
||||
edit_in_place: std::mem::replace(&mut app.edit_in_place.active, slot.edit_in_place),
|
||||
work,
|
||||
jobs: app.swap_jobs(slot.jobs),
|
||||
};
|
||||
crate::browser::restore_reveals(app, slot.reveals);
|
||||
// Only the displayed design owns the process-wide crash replay journal.
|
||||
app.session.crash_journal = out.session.crash_journal;
|
||||
out.session.crash_journal = false;
|
||||
@@ -464,8 +458,6 @@ pub fn active_is_blank(app: &SolveApp) -> bool {
|
||||
pub fn adopt(app: &mut SolveApp, session: Session) {
|
||||
if active_is_blank(app) {
|
||||
let rev = app.session.revision;
|
||||
crate::browser::discard_reveals(app);
|
||||
crate::semantic_content::leave_document(app);
|
||||
crate::dialogs::cancel(app);
|
||||
app.tool = None;
|
||||
crate::design_properties::close_object(app);
|
||||
@@ -553,8 +545,6 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) {
|
||||
}
|
||||
app.docs.closing = None;
|
||||
if n <= 1 {
|
||||
crate::browser::discard_reveals(app);
|
||||
crate::semantic_content::leave_document(app);
|
||||
// The last design: a blank one takes its place.
|
||||
reset_chat(app);
|
||||
if let Some(a) = app.autosave.as_mut().and_then(|a| a.active.take()) {
|
||||
@@ -751,11 +741,9 @@ fn tab_paths(app: &SolveApp) -> Vec<Option<String>> {
|
||||
}
|
||||
|
||||
/// A tab's tooltip: where its design is saved (home shortened to `~`), or that it isn't yet; with
|
||||
/// the full name above when the tab cut it short (#5868), and below, that a saved design has
|
||||
/// changes not saved yet (`dirty`), which the bullet after a cut name no longer shows (#11408).
|
||||
fn tab_tooltip(name: &str, path: Option<&str>, cut: bool, dirty: bool) -> String {
|
||||
/// the full name above when the tab cut it short (#5868).
|
||||
fn tab_tooltip(name: &str, path: Option<&str>, cut: bool) -> String {
|
||||
let place = path.map_or_else(|| crate::i18n::tr_label("Not saved yet").to_string(), crate::home::friendly_path);
|
||||
let place = if dirty && path.is_some() { format!("{place}\n{}", crate::i18n::tr("document.tab_unsaved_changes")) } else { place };
|
||||
if cut { format!("{name}\n{place}") } else { place }
|
||||
}
|
||||
|
||||
@@ -938,7 +926,7 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
|
||||
};
|
||||
let g = ui.painter().layout_no_wrap(label, font, ink);
|
||||
let text_w = (tr.width() - 30.0).max(10.0);
|
||||
let tip = tab_tooltip(&name_display, paths.get(i).cloned().flatten().as_deref(), g.size().x > text_w, *dirty);
|
||||
let tip = tab_tooltip(&name_display, paths.get(i).cloned().flatten().as_deref(), g.size().x > text_w);
|
||||
let clip = Rect::from_min_size(tr.min, vec2(text_w + 10.0, tr.height()));
|
||||
ui.painter().with_clip_rect(clip).galley(pos2(tr.left() + 10.0, tr.center().y - g.size().y / 2.0), g, ink);
|
||||
// ×
|
||||
@@ -1363,26 +1351,18 @@ mod tests {
|
||||
use egui::Color32;
|
||||
|
||||
/// #5868: a tab's tooltip says where its design is saved (home as `~`), or that it isn't
|
||||
/// yet, with the full name above when the tab cut the name short. #11408: a saved design with
|
||||
/// changes not saved yet says so below, since a cut name hides the tab's bullet.
|
||||
/// yet, with the full name above when the tab cut the name short.
|
||||
#[test]
|
||||
fn tab_tooltip_says_where_the_design_is_saved() {
|
||||
assert_eq!(tab_tooltip("plate", Some("/srv/designs/plate.solidcraft"), false, false), "/srv/designs/plate.solidcraft");
|
||||
assert_eq!(tab_tooltip("a-long-name", Some("/srv/a-long-name.solidcraft"), true, false), "a-long-name\n/srv/a-long-name.solidcraft");
|
||||
assert_eq!(tab_tooltip("Untitled", None, false, false), "Not saved yet");
|
||||
assert_eq!(tab_tooltip("Untitled", None, true, false), "Untitled\nNot saved yet");
|
||||
assert_eq!(tab_tooltip("plate", Some("/srv/designs/plate.solidcraft"), false, true), "/srv/designs/plate.solidcraft\nUnsaved changes");
|
||||
assert_eq!(
|
||||
tab_tooltip("a-long-name", Some("/srv/a-long-name.solidcraft"), true, true),
|
||||
"a-long-name\n/srv/a-long-name.solidcraft\nUnsaved changes"
|
||||
);
|
||||
// A design never saved says so already, changed or not.
|
||||
assert_eq!(tab_tooltip("Untitled", None, true, true), "Untitled\nNot saved yet");
|
||||
assert_eq!(tab_tooltip("plate", Some("/srv/designs/plate.solidcraft"), false), "/srv/designs/plate.solidcraft");
|
||||
assert_eq!(tab_tooltip("a-long-name", Some("/srv/a-long-name.solidcraft"), true), "a-long-name\n/srv/a-long-name.solidcraft");
|
||||
assert_eq!(tab_tooltip("Untitled", None, false), "Not saved yet");
|
||||
assert_eq!(tab_tooltip("Untitled", None, true), "Untitled\nNot saved yet");
|
||||
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).filter(|h| !h.is_empty()) {
|
||||
// The abbreviation keeps the relative path's native separators after `~/`.
|
||||
let relative = std::path::Path::new("Designs").join("bracket.solidcraft");
|
||||
let path = std::path::Path::new(&home).join(&relative);
|
||||
assert_eq!(tab_tooltip("bracket", path.to_str(), false, false), format!("~/{}", relative.display()));
|
||||
assert_eq!(tab_tooltip("bracket", path.to_str(), false), format!("~/{}", relative.display()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,6 @@ static HUNGARIAN: Catalog = Catalog::new("hu", "Magyar", include_str!(concat!(en
|
||||
static INDONESIAN: Catalog = Catalog::new("id", "Bahasa Indonesia", include_str!(concat!(env!("OUT_DIR"), "/locales/id.json")));
|
||||
static ITALIAN: Catalog = Catalog::new("it", "Italiano", include_str!(concat!(env!("OUT_DIR"), "/locales/it.json")));
|
||||
static JAPANESE: Catalog = Catalog::new("ja", "日本語", include_str!(concat!(env!("OUT_DIR"), "/locales/ja.json")));
|
||||
static GEORGIAN: Catalog = Catalog::new("ka", "ქართული", include_str!(concat!(env!("OUT_DIR"), "/locales/ka.json")));
|
||||
static KOREAN: Catalog = Catalog::new("ko", "한국어", include_str!(concat!(env!("OUT_DIR"), "/locales/ko.json")));
|
||||
static MALAY: Catalog = Catalog::new("ms", "Bahasa Melayu", include_str!(concat!(env!("OUT_DIR"), "/locales/ms.json")));
|
||||
static NORWEGIAN: Catalog = Catalog::new("nb", "Norsk bokmål", include_str!(concat!(env!("OUT_DIR"), "/locales/nb.json")));
|
||||
@@ -110,7 +109,6 @@ pub const LOCALES: &[&Catalog] = &[
|
||||
&INDONESIAN,
|
||||
&ITALIAN,
|
||||
&JAPANESE,
|
||||
&GEORGIAN,
|
||||
&KOREAN,
|
||||
&MALAY,
|
||||
&NORWEGIAN,
|
||||
@@ -1419,7 +1417,6 @@ mod tests {
|
||||
("bs", "Bosanski", "Postavke", "Ekstrudiraj", "Skica1", "Sačuvati promjene u My {design}?"),
|
||||
("hr", "Hrvatski", "Postavke", "Ekstrudiraj", "Skica1", "Spremiti promjene u My {design}?"),
|
||||
("sr", "Српски", "Подешавања", "Екструдирај", "Скица1", "Сачувати промене у My {design}?"),
|
||||
("ka", "ქართული", "პარამეტრები", "ამოწევა", "ესკიზი1", "შევინახოთ ცვლილებები My {design}-ში?"),
|
||||
("hu", "Magyar", "Beállítások", "Kihúzás", "Vázlat1", "Menti a(z) My {design} módosításait?"),
|
||||
("id", "Bahasa Indonesia", "Preferensi", "Ekstrusi", "Sketsa1", "Simpan perubahan ke My {design}?"),
|
||||
("nl", "Nederlands", "Voorkeuren", "Extruderen", "Schets1", "Wijzigingen in My {design} opslaan?"),
|
||||
|
||||
@@ -131,8 +131,6 @@ mod surface_clamp_tests;
|
||||
#[cfg(test)]
|
||||
mod surface_errors_tests;
|
||||
#[cfg(test)]
|
||||
mod timeline_dependents_tests;
|
||||
#[cfg(test)]
|
||||
mod timeline_marks_tests;
|
||||
#[cfg(test)]
|
||||
mod status_tests {
|
||||
@@ -239,7 +237,6 @@ pub mod scenario;
|
||||
pub(crate) mod scene_palette;
|
||||
pub mod select_other;
|
||||
pub mod selection;
|
||||
pub mod semantic_content;
|
||||
pub mod settings_profiles;
|
||||
#[cfg(test)]
|
||||
mod settings_profiles_tests;
|
||||
@@ -793,7 +790,6 @@ impl SolveApp {
|
||||
render_workspace.image_host = image_host;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
solidcraft_engine::slicer_files::clean_old();
|
||||
let tree = browser::TreeState::for_session(session.uid);
|
||||
let mut app = SolveApp {
|
||||
panel_runtime: None,
|
||||
viewport_status: Default::default(),
|
||||
@@ -826,7 +822,7 @@ impl SolveApp {
|
||||
material_card_review: None,
|
||||
menu: context_menu::MenuState::default(),
|
||||
gesture: mouse_gesture::State::default(),
|
||||
tree,
|
||||
tree: browser::TreeState::default(),
|
||||
linked_design_clipboard: None,
|
||||
sbox: shortcut_box::ShortcutBox::default(),
|
||||
keymap: keymap::Keymap::default(),
|
||||
@@ -1614,24 +1610,6 @@ impl SolveApp {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The Dependents button of INSPECT ▸ HISTORY asks for the dependents of ONE
|
||||
// feature, so it needs a target. With nothing selected it used to run the
|
||||
// command with no feature and the engine answered "Feature Dependents:
|
||||
// `feature` must be an id or name". It reports the dependents of what is
|
||||
// selected, one command call per selected feature.
|
||||
if id == "timeline.dependents" {
|
||||
let selected = timeline::selected_feature_ids(self);
|
||||
if selected.is_empty() {
|
||||
self.set_status(self.tr("ui.dependents_select_first"), false);
|
||||
return;
|
||||
}
|
||||
for feature_id in selected {
|
||||
if let Err(why) = self.run("timeline.dependents", json!({ "feature": feature_id })) {
|
||||
self.set_status(why, true);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Front … Isometric (Ctrl+1…7 by default) turn the view as the view cube's faces do. The
|
||||
// flat pattern is a 2D page: it has no other side to turn to.
|
||||
if solidcraft_engine::standard_view(id).is_some() {
|
||||
@@ -1956,15 +1934,6 @@ impl SolveApp {
|
||||
// it was clicked in, so take that one before the turn starts (#10571).
|
||||
self.pre_command_cam = finished_sketch.is_some().then(|| self.settled_cam());
|
||||
if finished_sketch.is_some() {
|
||||
// A point picked in the sketch being finished stays a pick of that sketch: its
|
||||
// placement reads where the sketch puts it once the sketch is no longer the open one.
|
||||
if let (Some(sketch), "solid.box" | "solid.sphere" | "solid.torus") = (finished_sketch, id) {
|
||||
for sel in self.session.selection.iter_mut() {
|
||||
if let solidcraft_engine::Sel::SketchPoint { id } = sel {
|
||||
*sel = solidcraft_engine::Sel::ExternalSketchPoint { sketch, id: id.clone() };
|
||||
}
|
||||
}
|
||||
}
|
||||
self.finish_sketch();
|
||||
// A feature committed before the marker is back would go in where the sketch was.
|
||||
if self.deferred.is_some() {
|
||||
@@ -3023,9 +2992,7 @@ impl SolveApp {
|
||||
let _language = crate::i18n::scope(&self.preferences.language);
|
||||
let mut report_input = None;
|
||||
issue_report::CadInput::block(self, ui, &mut report_input);
|
||||
semantic_content::begin(self, ui.ctx());
|
||||
self.cad_ui(ui, &mut report_input);
|
||||
semantic_content::finalize(self, ui.ctx());
|
||||
drop(report_input);
|
||||
// After everything else, on every page (the start page too, which returns early from
|
||||
// `cad_ui`): the window's edges resize it (#1381).
|
||||
@@ -3084,7 +3051,6 @@ impl SolveApp {
|
||||
agent_chat::poll(self, ui.ctx());
|
||||
// Preferences and Keyboard Shortcuts open from Home too (its links, the gear, Ctrl+,).
|
||||
prefs::show(self, ui.ctx());
|
||||
browser::editors(self, ui);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -3168,10 +3134,6 @@ impl SolveApp {
|
||||
if self.ui.show_browser && !render_ui::focused(self) {
|
||||
browser::browser(self, ui);
|
||||
}
|
||||
// All shared semantic content has now submitted its output. Apply it before the
|
||||
// viewport/dialog/menu consumers, regardless of which presentations were visible.
|
||||
semantic_content::finalize(self, ui.ctx());
|
||||
browser::editors(self, ui);
|
||||
if floating {
|
||||
agent_chat::show(self, ui);
|
||||
}
|
||||
|
||||
@@ -120,60 +120,6 @@ fn painted_text(h: &mut Harness) -> Vec<String> {
|
||||
output.shapes.iter().filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some(t.galley.text().to_string()) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// A point's complete position must be painted, not merely retained in an ellipsized galley.
|
||||
#[test]
|
||||
fn point_coordinates_are_not_truncated_in_the_measure_panel() {
|
||||
for layout in ["floating", "compact"] {
|
||||
for decimals in [2, 8] {
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.preferences.length_decimals = decimals;
|
||||
h.call("ui.set", json!({"layout":layout}));
|
||||
let point = [-99.56, -75.0, 12.345678];
|
||||
h.app.run("solid.box", json!({"corner":point,"length":1,"width":1,"height":1,"body_name":"Box"})).unwrap();
|
||||
h.app.session.selection = vec![Sel::Vertex { body: "Box".into(), point: point.into() }];
|
||||
h.app.start("inspect.measure");
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.frames(6);
|
||||
let line = crate::quick_measure::item_lines(&h.app, &result(&h)["items"][0]).remove(0);
|
||||
h.ctx = egui::Context::default();
|
||||
crate::fonts::setup_bundled_fonts(&h.ctx);
|
||||
let mut textures = std::collections::HashMap::new();
|
||||
let mut last = None;
|
||||
for frame in 0..6 {
|
||||
let mut output = h.ctx.run_ui(
|
||||
egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))),
|
||||
time: Some(frame as f64 / 60.0),
|
||||
..Default::default()
|
||||
},
|
||||
|ui| h.app.ui(ui),
|
||||
);
|
||||
crate::ui_kit::test_textures_update(&mut textures, &output.textures_delta);
|
||||
output.textures_delta.clear();
|
||||
last = Some(output);
|
||||
}
|
||||
let output = last.unwrap();
|
||||
if let Ok(dir) = std::env::var("SOLIDCRAFT_MEASURE_EVIDENCE") {
|
||||
let bounds = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
|
||||
let image = crate::ui_kit::test_raster(&h.ctx, output.shapes.clone(), output.pixels_per_point, bounds, &textures);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
image.save(std::path::Path::new(&dir).join(format!("coordinates-{layout}-{decimals}.png"))).unwrap();
|
||||
}
|
||||
let painted = output
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|s| match &s.shape {
|
||||
egui::Shape::Text(t) if t.galley.text().contains(&line) => Some((s.clip_rect, t)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the position is painted");
|
||||
assert!(!painted.1.galley.elided, "{layout}, {decimals} decimals: {line}");
|
||||
assert!(painted.0.expand(1.0).contains_rect(painted.1.visual_bounding_rect()), "the complete position stays inside its clip");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn component_rects() -> Vec<egui::Rect> {
|
||||
["measure:label", "measure:label:x", "measure:label:y", "measure:label:z"].into_iter().map(|key| crate::scenario::rect_of(key).unwrap()).collect()
|
||||
}
|
||||
|
||||
@@ -499,10 +499,7 @@ fn find_in_browser_reaches_a_folded_body() {
|
||||
let scope = super::occurrence_key(&[second]);
|
||||
h.app.tree.collapsed.extend([super::occurrence_key(&[first]), scope.clone(), format!("{scope}/bodies")]);
|
||||
h.frames(2);
|
||||
assert!(
|
||||
!h.app.tree.content.owner(crate::semantic_content::ContentOwner::BROWSER).unwrap().order.contains(&format!("b:{body}")),
|
||||
"the body's row is closed away"
|
||||
);
|
||||
assert!(!h.app.tree.order.contains(&format!("b:{body}")), "the body's row is closed away");
|
||||
h.app.session.selection = vec![Sel::Body { name: body.clone() }];
|
||||
let find = context_menu::items(&h.app, &Target::Viewport).into_iter().find(|i| i.id == "ui.findBrowser").unwrap();
|
||||
context_menu::run_item(&mut h.app, &find, egui::Pos2::ZERO);
|
||||
@@ -510,10 +507,7 @@ fn find_in_browser_reaches_a_folded_body() {
|
||||
h.frames(2);
|
||||
assert!(h.app.tree.reveal.is_none(), "revealed");
|
||||
assert!(!h.app.tree.collapsed.contains(&scope));
|
||||
assert!(
|
||||
h.app.tree.content.owner(crate::semantic_content::ContentOwner::BROWSER).unwrap().order.contains(&format!("b:{body}")),
|
||||
"the row is drawn"
|
||||
);
|
||||
assert!(h.app.tree.order.contains(&format!("b:{body}")), "the row is drawn");
|
||||
assert!(crate::scenario::handle_at("fold:Bodies").is_none(), "with no folder row");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
//! Where a primitive goes, typed in its dialog (#5638). The Box is placed by its corner or its
|
||||
//! centre, the Cylinder and Cone by the centre of their base, the Sphere and Torus by their
|
||||
//! centre, as X, Y and Z fields that take expressions and units like the size fields. A point
|
||||
//! picked in the view (a vertex, a point on an edge or face, the origin, a point of a shown
|
||||
//! sketch) fills the fields of the Box, Sphere and Torus; one selected before the window opens
|
||||
//! places them there (#9403).
|
||||
//! picked in the view (a vertex, a point on an edge or face, the origin) fills the fields of the
|
||||
//! Box, Sphere and Torus.
|
||||
//!
|
||||
//! The placement lives in the dialog's extra parameters under the command's own key (`corner`,
|
||||
//! `center` or `base`). It is written there only once a field or the choice changes, so a new
|
||||
@@ -14,10 +13,9 @@ use serde_json::{Map, Value, json};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::doc::expr::Kind as ValueKind;
|
||||
use solidcraft_engine::geom::PlaneGeom as _;
|
||||
|
||||
use crate::i18n::tr;
|
||||
use crate::selection::{EDGES, EXTERNAL_POINTS, FACES, ORIGIN, POINTS, SelInput, VERTICES};
|
||||
use crate::selection::{EDGES, FACES, ORIGIN, SelInput, VERTICES};
|
||||
use crate::ui_kit::{self, choice_row, expr_row};
|
||||
use solidcraft_engine::Sel;
|
||||
|
||||
@@ -35,13 +33,10 @@ fn keys(cmd: &str) -> &'static [&'static str] {
|
||||
const BOX_PLACEMENTS: [&str; 2] = ["Corner", "Center"];
|
||||
|
||||
/// The selection inputs of a primitive's dialog: the Box takes a point for its corner or centre,
|
||||
/// the Sphere and Torus for their centre. It takes the points of every shown sketch, each with
|
||||
/// its sketch (#9403).
|
||||
/// the Sphere and Torus for their centre.
|
||||
pub(crate) fn inputs(cmd: &str) -> Vec<SelInput> {
|
||||
match cmd {
|
||||
"solid.box" | "solid.sphere" | "solid.torus" => {
|
||||
vec![SelInput::new("Point", VERTICES | EDGES | FACES | ORIGIN | POINTS | EXTERNAL_POINTS, false)]
|
||||
}
|
||||
"solid.box" | "solid.sphere" | "solid.torus" => vec![SelInput::new("Point", VERTICES | EDGES | FACES | ORIGIN, false)],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -109,25 +104,13 @@ fn mm(v: f64) -> String {
|
||||
format!("{v} mm")
|
||||
}
|
||||
|
||||
/// Where a pick is in the world: a vertex, or a point on an edge or face, where it was picked; a
|
||||
/// sketch point where its sketch puts it (one without a sketch is the open sketch's).
|
||||
pub(crate) fn world_point(s: &Session, sel: &Sel) -> Option<Vec3> {
|
||||
let (sketch, id) = match sel {
|
||||
Sel::Vertex { point, .. } | Sel::Face { point, .. } | Sel::Edge { point, .. } => return Some(*point),
|
||||
Sel::ExternalSketchPoint { sketch, id } => (*sketch, id),
|
||||
Sel::SketchPoint { id } => (s.active_sketch?, id),
|
||||
_ => return None,
|
||||
};
|
||||
let st = s.world_state().ok()?;
|
||||
let ss = st.sketch(sketch)?;
|
||||
let at = ss.sketch.resolve_point(id).and_then(|i| ss.sketch.point(i))?;
|
||||
Some(ss.plane.to_world(at))
|
||||
}
|
||||
|
||||
/// A point picked into the Box's input: it goes into the X, Y and Z of the current placement
|
||||
/// (the corner or the centre), replacing a pre-selected face or vertex.
|
||||
pub(crate) fn picked(s: &Session, cmd: &str, at: &mut Option<(Vec3, Option<Vec3>)>, extra: &mut Map<String, Value>, sel: Option<&Sel>) {
|
||||
let Some(point) = sel.and_then(|sel| world_point(s, sel)) else { return };
|
||||
let point = match sel {
|
||||
Some(Sel::Vertex { point, .. } | Sel::Face { point, .. } | Sel::Edge { point, .. }) => *point,
|
||||
_ => return,
|
||||
};
|
||||
if !point.is_finite() || inputs(cmd).is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -720,53 +720,3 @@ fn a_point_picked_in_the_sphere_or_torus_dialog_becomes_its_centre() {
|
||||
h.app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sketch_point_places_the_box_sphere_and_torus() {
|
||||
// #9403: a point of a sketch, clicked in the window or selected before it opens, is where the
|
||||
// primitive goes, where the sketch puts it in the world (here 20 mm up, on an offset plane).
|
||||
use solidcraft_engine::Sel;
|
||||
let mut h = design("mm");
|
||||
h.app.run("sketch.create", json!({"plane": "XY", "offset": 20, "name": "Marks"})).unwrap();
|
||||
h.app.run("sketch.point", json!({"point": [12, 7], "id": "m"})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.frames(3);
|
||||
for (cmd, key) in [("solid.sphere", "center"), ("solid.torus", "center"), ("solid.box", "corner")] {
|
||||
h.app.start(cmd);
|
||||
h.frames(3);
|
||||
h.step(&json!({"click": {"world": [12, 7, 20]}})).unwrap();
|
||||
h.frames(2);
|
||||
assert_shown(&h, &[("X", "12 mm".into()), ("Y", "7 mm".into()), ("Z", "20 mm".into())]);
|
||||
assert_eq!(sent(&h)[key], json!(["12 mm", "7 mm", "20 mm"]), "{cmd}");
|
||||
let d = h.app.dialog.as_ref().unwrap();
|
||||
let chip = crate::dialogs::selection_label(&h.app.session, crate::prefs::OriginPlaneNames::Xyz, d, &d.inputs[0], &d.inputs[0].items[0]);
|
||||
// Numbered as Select Other numbers it: the sketch's origin is point 1.
|
||||
assert_eq!(chip, "Point 2 · Marks", "the chip names the point and its sketch");
|
||||
h.app.dialog = None;
|
||||
}
|
||||
let sketch = h.app.session.doc.features.iter().find(|f| f.name == "Marks").map(|f| f.id).unwrap();
|
||||
h.app.session.selection = vec![Sel::ExternalSketchPoint { sketch, id: "m".into() }];
|
||||
h.app.start("solid.sphere");
|
||||
h.frames(3);
|
||||
assert_eq!(sent(&h)["center"], json!([12.0, 7.0, 20.0]));
|
||||
ok(&mut h);
|
||||
let made = bounds(&mut h.app.session);
|
||||
assert_within(made[0].0, [2.0, -3.0, 10.0], 0.05);
|
||||
assert_within(made[0].1, [22.0, 17.0, 30.0], 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_selected_in_the_open_sketch_still_places_the_sphere_when_the_sketch_ends() {
|
||||
// Starting the sphere finishes the sketch; the point picked in it is then the finished
|
||||
// sketch's, and still where the sphere goes.
|
||||
use solidcraft_engine::Sel;
|
||||
let mut h = design("mm");
|
||||
h.app.run("sketch.create", json!({"plane": "XY", "offset": 20, "name": "Marks"})).unwrap();
|
||||
h.app.run("sketch.point", json!({"point": [12, 7], "id": "m"})).unwrap();
|
||||
assert!(h.app.session.active_sketch.is_some());
|
||||
h.app.session.selection = vec![Sel::SketchPoint { id: "m".into() }];
|
||||
h.app.start("solid.sphere");
|
||||
h.frames(3);
|
||||
assert!(h.app.session.active_sketch.is_none(), "the sphere ends the sketch");
|
||||
assert_eq!(sent(&h)["center"], json!([12.0, 7.0, 20.0]));
|
||||
}
|
||||
|
||||
@@ -173,17 +173,6 @@ pub(crate) fn item_lines(app: &SolveApp, it: &Value) -> Vec<String> {
|
||||
lines
|
||||
}
|
||||
|
||||
/// The label of a pair's centre distance: from a straight edge to a circle's centre, or
|
||||
/// centre to centre.
|
||||
pub(crate) fn centre_distance_label(r: &Value) -> &'static str {
|
||||
let is_circle = |item: &Value| item.get("round").and_then(Value::as_str) == Some("circle");
|
||||
let is_line = |item: &Value| matches!(item.get("type").and_then(Value::as_str), Some("edge" | "sketch_curve")) && item.get("round").is_none();
|
||||
match r["items"].as_array().map(Vec::as_slice) {
|
||||
Some([a, b]) if (is_circle(a) && is_line(b)) || (is_circle(b) && is_line(a)) => crate::i18n::tr("measure.line_to_centre"),
|
||||
_ => crate::i18n::tr("Centre to centre"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The readout's rows for a measurement.
|
||||
fn lines(app: &SolveApp, r: &Value) -> Vec<(String, String)> {
|
||||
let split = |line: String| match line.split_once(" ") {
|
||||
@@ -204,7 +193,7 @@ fn lines(app: &SolveApp, r: &Value) -> Vec<(String, String)> {
|
||||
}
|
||||
let mut rows = Vec::new();
|
||||
if let Some(d) = r["center_distance"].as_f64() {
|
||||
rows.push((centre_distance_label(r).to_string(), crate::prefs::show_mm(app, d, 1)));
|
||||
rows.push((crate::i18n::tr("Centre to centre").to_string(), crate::prefs::show_mm(app, d, 1)));
|
||||
}
|
||||
if let Some(d) = r["distance_mm"].as_f64() {
|
||||
rows.push((crate::i18n::tr_label("Distance").to_string(), crate::prefs::show_mm(app, d, 1)));
|
||||
|
||||
@@ -131,24 +131,6 @@ fn two_points_show_their_distance() {
|
||||
assert!(rows.contains(&row("Distance", crate::prefs::show_mm(&h.app, 1400f64.sqrt(), 1))), "{rows:?}");
|
||||
}
|
||||
|
||||
/// A circle and a line: the distance from the line to the circle's centre is named so.
|
||||
#[test]
|
||||
fn a_circle_and_a_line_show_the_distance_from_the_line_to_the_centre() {
|
||||
let mut h = harness();
|
||||
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
|
||||
h.app.run("sketch.circle.center", json!({"center": [0, 0], "radius": 5})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points": [[20, 0], [30, 0]]})).unwrap();
|
||||
h.app.session.selection = ["c1", "l1"].into_iter().map(|id| Sel::SketchCurve { id: id.into(), sketch: None }).collect();
|
||||
let rows = readout(&mut h);
|
||||
assert!(rows.contains(&row("Line to center", crate::prefs::show_mm(&h.app, 20.0, 1))), "{rows:?}");
|
||||
assert!(!rows.iter().any(|(l, _)| l == crate::i18n::tr("Centre to centre")), "{rows:?}");
|
||||
|
||||
h.app.run("sketch.circle.center", json!({"center": [50, 0], "radius": 5})).unwrap();
|
||||
h.app.session.selection = ["c1", "c2"].into_iter().map(|id| Sel::SketchCurve { id: id.into(), sketch: None }).collect();
|
||||
let rows = readout(&mut h);
|
||||
assert!(rows.iter().any(|(l, _)| l == crate::i18n::tr("Centre to centre")), "two circles still measure centre to centre: {rows:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_edges_show_their_count_and_total_length() {
|
||||
let mut h = block();
|
||||
|
||||
@@ -188,11 +188,7 @@ pub(crate) fn overlays(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
if crate::render_workspace::job_busy(app) {
|
||||
let pill = Container::navigation_card(ui.ctx(), egui::Id::new("sc_render_progress"), free.center_top(), egui::Align2::CENTER_TOP, |ui| {
|
||||
// Bound the row by the canvas, not by the card's size last frame: a longer status or
|
||||
// name would otherwise squeeze Cancel or cut a name that has room (#11222).
|
||||
let t = Tokens::get();
|
||||
ui.set_max_width((free.width() - t.space_24).min(t.form_card_width * 2.0).max(0.0));
|
||||
crate::render_workspace::progress_pill(app, ui, "render:progress");
|
||||
crate::render_workspace::progress_pill(app, ui);
|
||||
});
|
||||
crate::shell::register_panel(app, "render:progress-pill", Some(pill.response.rect), None);
|
||||
crate::scenario::publish_rect("render:progress-pill", pill.response.rect);
|
||||
|
||||
@@ -2351,8 +2351,7 @@ pub(crate) fn apply_command(app: &mut SolveApp, id: &str, reply: &serde_json::Va
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `key` names the row's scenario handles (`ui_kit::progress_action`).
|
||||
pub(crate) fn progress_pill(app: &mut SolveApp, ui: &mut egui::Ui, key: &str) -> bool {
|
||||
pub(crate) fn progress_pill(app: &mut SolveApp, ui: &mut egui::Ui) -> bool {
|
||||
if !ribbon::busy(app) {
|
||||
return false;
|
||||
}
|
||||
@@ -2367,8 +2366,8 @@ pub(crate) fn progress_pill(app: &mut SolveApp, ui: &mut egui::Ui, key: &str) ->
|
||||
let fraction = (done as f64 / total as f64).clamp(0.0, 1.0) as f32;
|
||||
let phase_key = format!("render.image_{phase}");
|
||||
let name = app.render_workspace.view.job_shot.as_ref().unwrap_or(&app.render_workspace.view.name).clone();
|
||||
let status = format!("· {} · {:.0}%", app.tr(&phase_key), fraction * 100.0);
|
||||
let cancel = crate::ui_kit::progress_action(ui, key, &name, &status, fraction, app.tr_label("Cancel"));
|
||||
let cancel =
|
||||
crate::ui_kit::progress_action(ui, &format!("{name} · {} · {:.0}%", app.tr(&phase_key), fraction * 100.0), fraction, app.tr_label("Cancel"));
|
||||
crate::scenario::publish_target("render:progress-cancel", cancel.rect);
|
||||
if cancel.clicked() {
|
||||
app.start("render.cancel");
|
||||
|
||||
@@ -584,7 +584,7 @@ fn render_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
);
|
||||
ui_kit::completion_text(ui, &text, app.render_workspace.view.completed.is_some());
|
||||
if super::ribbon::busy(app) {
|
||||
super::progress_pill(app, ui, "render:output-progress");
|
||||
super::progress_pill(app, ui);
|
||||
return;
|
||||
}
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
|
||||
@@ -5924,60 +5924,6 @@ fn output_viewer_copy_uses_retained_final_pixels_and_refuses_missing_or_oversize
|
||||
assert_eq!(undo, h.app.session.undo.len());
|
||||
}
|
||||
|
||||
/// #11222: during a render, Cancel keeps its whole label below and from 10 %. A name that fits
|
||||
/// is shown whole as the percentage grows; a long one is cut instead, and the row stays in the canvas.
|
||||
/// In a window too narrow for the row, Cancel still shows whole.
|
||||
#[test]
|
||||
fn progress_pill_cancel_keeps_its_label_and_a_long_name_gives_way() {
|
||||
let mut h = output_viewer_fixture();
|
||||
h.app.render_workspace.view.draft.width = 512;
|
||||
h.app.render_workspace.view.draft.height = 512;
|
||||
h.app.render_workspace.view.name = "A".into();
|
||||
ribbon::perform_job(&mut h.app, "render.start").unwrap();
|
||||
let job = h.app.render_workspace.view.job.unwrap();
|
||||
h.frames(3);
|
||||
let whole = crate::scenario::rect_of("render:progress:cancel").unwrap().width();
|
||||
let fraction = |h: &mut Harness| {
|
||||
let status = h.app.session.execute("render.status", &json!({"job":job})).unwrap();
|
||||
status["progress"]["done"].as_f64().unwrap() / status["progress"]["total"].as_f64().unwrap().max(1.0)
|
||||
};
|
||||
assert!(fraction(&mut h) < 0.1);
|
||||
let long = "A very long shot name that will not fit beside the progress meter ".repeat(4);
|
||||
let host = h.app.session.render_job_host().unwrap().clone();
|
||||
for below in [true, false] {
|
||||
if !below {
|
||||
for _ in 0..100_000 {
|
||||
if fraction(&mut h) >= 0.1 {
|
||||
break;
|
||||
}
|
||||
host.pump(64).unwrap();
|
||||
}
|
||||
let reached = fraction(&mut h);
|
||||
assert!((0.1..1.0).contains(&reached), "{reached}");
|
||||
}
|
||||
for name in ["Bracket front view", long.as_str()] {
|
||||
h.app.render_workspace.view.job_shot = Some(name.into());
|
||||
h.frames(3);
|
||||
let cancel = crate::scenario::rect_of("render:progress:cancel").unwrap();
|
||||
let pill = crate::scenario::rect_of("render:progress-pill").unwrap();
|
||||
let shown = crate::scenario::rect_of("render:progress:name").unwrap();
|
||||
let case = format!("below 10 %: {below}, {} characters", name.len());
|
||||
assert!((cancel.width() - whole).abs() < 0.5, "{case}: Cancel {} wide, whole {whole}", cancel.width());
|
||||
assert!(pill.contains_rect(cancel), "{case}: {cancel:?} outside {pill:?}");
|
||||
assert!(shown.right() < cancel.left(), "{case}");
|
||||
let cut = crate::scenario::count_of("render:progress:name:cut");
|
||||
assert_eq!(cut, Some(if name == long { 1.0 } else { 0.0 }), "{case}");
|
||||
assert!(h.app.viewport.rect.unwrap().contains_rect(pill), "{case}: {pill:?}");
|
||||
}
|
||||
}
|
||||
// A window too narrow for the whole row: the name is cut and Cancel still shows whole.
|
||||
h.call("ui.resize", json!({"width": 480, "height": 600}));
|
||||
h.frames(3);
|
||||
let cancel = crate::scenario::rect_of("render:progress:cancel").unwrap();
|
||||
assert!((cancel.width() - whole).abs() < 0.5, "narrow window: Cancel {} wide, whole {whole}", cancel.width());
|
||||
assert_eq!(crate::scenario::count_of("render:progress:name:cut"), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_viewer_progress_keeps_stage_tools_and_cancel_cancels_without_closing_viewer() {
|
||||
let mut h = output_viewer_fixture();
|
||||
|
||||
@@ -265,38 +265,3 @@ fn section_ribbon_toggle_and_setting_edit_have_separate_intent_and_compatibility
|
||||
assert!(h.app.dialog.is_some());
|
||||
assert_eq!(h.app.session.section, cut);
|
||||
}
|
||||
|
||||
/// Reopened from a section's own geometry (Graphics → Show viewport section status), the dialog
|
||||
/// holds the plane as numbers and picks none: the drag arrow still stands on that plane and its
|
||||
/// tip reaches the cut (#11423). A plane picked afterwards takes the arrow over.
|
||||
#[test]
|
||||
fn a_section_reopened_from_its_geometry_keeps_its_drag_arrow() {
|
||||
use solidcraft_engine::doc::expr::Kind as ValueKind;
|
||||
for params in [
|
||||
json!({"plane":"XY","at":-25.4,"flip":true}),
|
||||
json!({"plane":{"origin":[7,-12,4],"normal":[-3,-4,0]},"offset":5}),
|
||||
json!({"plane":{"origin":[7,-12,4],"normal":[0,0,-1]},"offset":-5,"flip":true}),
|
||||
] {
|
||||
let mut h = harness();
|
||||
h.app.preferences.viewport_section_status = true;
|
||||
h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap();
|
||||
h.app.run("inspect.section", params.clone()).unwrap();
|
||||
h.frames(3);
|
||||
let cut = h.app.session.section.unwrap();
|
||||
menu(&mut h, "Edit Section Analysis");
|
||||
let (offset, _, plane) = section_dialog(&h);
|
||||
assert!(plane.is_empty(), "{params}: no plane picked: {plane:?}");
|
||||
let d = h.app.dialog.as_ref().unwrap();
|
||||
let (base, dir) = crate::canvas::anchor(&h.app, d).unwrap_or_else(|| panic!("{params}: no arrow"));
|
||||
let dir = dir.unwrap_or_else(|| panic!("{params}: an arrow with no direction"));
|
||||
let distance = crate::length_input::eval(&h.app.session.doc, &offset, ValueKind::Length).unwrap();
|
||||
let tip = base + dir * distance;
|
||||
assert!((tip - cut.0).len() < 1e-9, "{params}: the arrow ends at {tip:?}, the cut is at {:?}", cut.0);
|
||||
assert!(dir.cross(cut.1).len() < 1e-9, "{params}: the arrow runs along the cut's normal: {dir:?} vs {:?}", cut.1);
|
||||
|
||||
let mut d = h.app.dialog.take().unwrap();
|
||||
d.pick(&h.app.session, solidcraft_engine::Sel::Plane { name: "XZ".into() });
|
||||
let (_, dir) = crate::canvas::anchor(&h.app, &d).unwrap_or_else(|| panic!("{params}: no arrow on the picked plane"));
|
||||
assert!(dir.is_some_and(|n| (n.y.abs() - 1.0).abs() < 1e-9), "{params}: the picked plane's arrow: {dir:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +293,7 @@ pub(crate) fn curve_label(sk: &solidcraft_engine::sketch::Sketch, id: &str) -> S
|
||||
}
|
||||
|
||||
/// "Point (arc 2 centre)": the role it plays in the first curve that uses it.
|
||||
pub(crate) fn point_label(sk: &solidcraft_engine::sketch::Sketch, id: &str) -> String {
|
||||
fn point_label(sk: &solidcraft_engine::sketch::Sketch, id: &str) -> String {
|
||||
let Some(pi) = sk.points.iter().position(|p| p.id == id) else { return id.to_string() };
|
||||
let role = sk.curves.iter().find_map(|c| {
|
||||
let name = format!("{} {}", curve_kind_name(&c.kind).to_lowercase(), id_number(&c.id).unwrap_or(0));
|
||||
@@ -417,7 +417,7 @@ pub fn close(app: &mut SolveApp) {
|
||||
if let Some(p) = app.viewport.select_other.popup.take()
|
||||
&& p.lit_body
|
||||
{
|
||||
crate::semantic_content::set_external_body_hover(app, Vec::new());
|
||||
app.viewport.hover_bodies.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,12 +557,12 @@ pub fn hover_override(app: &mut SolveApp) {
|
||||
app.viewport.hover = if body.is_some() { None } else { row.map(|r| r.hit.clone()) };
|
||||
match body {
|
||||
Some(b) => {
|
||||
app.viewport.hover_bodies = vec![b];
|
||||
p.lit_body = true;
|
||||
crate::semantic_content::set_external_body_hover(app, vec![b]);
|
||||
}
|
||||
None if p.lit_body => {
|
||||
app.viewport.hover_bodies.clear();
|
||||
p.lit_body = false;
|
||||
crate::semantic_content::set_external_body_hover(app, Vec::new());
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
@@ -7,50 +7,6 @@ use solidcraft_engine::geom::prelude::*;
|
||||
|
||||
use crate::scenario::Harness;
|
||||
|
||||
#[test]
|
||||
fn same_body_select_other_refresh_survives_disappearing_semantic_owners() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Body1"})).unwrap();
|
||||
h.frames(4);
|
||||
let body_row = crate::browser::drawn_rows().into_iter().find(|(label, ..)| label == "Body1").unwrap().1.center();
|
||||
h.step(&json!({"move":[body_row.x,body_row.y]})).unwrap();
|
||||
assert_eq!(h.app.viewport.hover_bodies, ["Body1"], "establish actual Browser row hover first");
|
||||
h.app.ui.show_browser = false;
|
||||
h.app.ui.show_timeline = false;
|
||||
h.app.viewport.select_other.popup = Some(super::Popup {
|
||||
at: egui::pos2(500.0, 300.0),
|
||||
rows: vec![super::Row {
|
||||
hit: crate::viewport::Hit::Face { body: "Body1".into(), index: 0, point: solidcore_record::Vec3::ZERO },
|
||||
sel: Sel::Body { name: "Body1".into() },
|
||||
label: "Body1".into(),
|
||||
icon: "body",
|
||||
}],
|
||||
total: 1,
|
||||
hovered: Some(0),
|
||||
revision: h.app.session.revision,
|
||||
dialog: false,
|
||||
include_hidden: h.app.preferences.select_other_hidden,
|
||||
lit_body: false,
|
||||
held: Default::default(),
|
||||
});
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), ..Default::default() };
|
||||
h.ctx
|
||||
.run_ui(raw, |ui| {
|
||||
crate::semantic_content::begin(&mut h.app, ui.ctx());
|
||||
// Floating show_workspace calls this real producer before semantic finalization.
|
||||
// Its new body name is deliberately identical to the previous Browser contribution.
|
||||
super::hover_override(&mut h.app);
|
||||
assert_eq!(h.app.viewport.hover_bodies, ["Body1"]);
|
||||
crate::semantic_content::finalize(&mut h.app, ui.ctx());
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
assert_eq!(h.app.viewport.hover_bodies, ["Body1"], "fresh external ownership survives missing semantic owners");
|
||||
assert!(crate::browser::drawn_rows().is_empty());
|
||||
super::close(&mut h.app);
|
||||
assert!(h.app.viewport.hover_bodies.is_empty(), "Select Other retains responsibility for clearing its own contribution");
|
||||
}
|
||||
|
||||
/// A sketch with two lines drawn on top of each other (l1, then l2), off the origin axes.
|
||||
fn two_lines() -> (Harness, Value) {
|
||||
let mut h = Harness::new();
|
||||
|
||||
@@ -1,466 +0,0 @@
|
||||
//! Shared tree content, independent of the panels which present it.
|
||||
//!
|
||||
//! A frame has one app-owned begin/finalize pair. Each presentation owns its range order,
|
||||
//! anchor, geometry and transient input; the document, selection, folds and row cache remain
|
||||
//! in `SolveApp`. Hosts and future adapters can render the same content through `show`.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use egui::{Rect, Ui};
|
||||
|
||||
use crate::SolveApp;
|
||||
|
||||
/// A presentation identity, independent of its placement or content composition.
|
||||
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct ContentOwner(pub u64);
|
||||
|
||||
impl ContentOwner {
|
||||
pub const BROWSER: Self = Self(0);
|
||||
pub const HISTORY: Self = Self(1);
|
||||
}
|
||||
|
||||
/// Runtime identity of the document and presentation which produced an interaction.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ContentSource {
|
||||
pub(crate) session: u64,
|
||||
pub(crate) owner: ContentOwner,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct FeatureDrag {
|
||||
pub(crate) source: ContentSource,
|
||||
pub(crate) features: Vec<u64>,
|
||||
}
|
||||
|
||||
/// Existing row compositions. This interface deliberately contains no panel or host policy.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ContentRequest {
|
||||
Browser { shrink_height: bool },
|
||||
ActiveBodiesOrigin,
|
||||
}
|
||||
|
||||
pub type DrawnRow = (String, Rect, Option<Rect>, Option<bool>);
|
||||
pub type DrawnOccurrence = (Vec<u64>, String, Rect, bool);
|
||||
|
||||
/// Geometry belongs to one presentation and expires at the next semantic frame.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Output {
|
||||
pub rows: Vec<DrawnRow>,
|
||||
pub occurrences: Vec<DrawnOccurrence>,
|
||||
pointer_owned: bool,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
// Compatibility/automation snapshots, published by the owner rather than by each row.
|
||||
static PUBLISHED: RefCell<BTreeMap<ContentOwner, Output>> = const { RefCell::new(BTreeMap::new()) };
|
||||
}
|
||||
|
||||
pub fn output(owner: ContentOwner) -> Output {
|
||||
PUBLISHED.with(|outputs| outputs.borrow().get(&owner).cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct OwnerState {
|
||||
pub(crate) order: Vec<String>,
|
||||
pub(crate) previous: Vec<String>,
|
||||
pub(crate) anchor: Option<String>,
|
||||
pub(crate) feature_drags: HashMap<egui::Id, FeatureDrag>,
|
||||
order_len: usize,
|
||||
output: Output,
|
||||
actions: Option<crate::browser::Actions>,
|
||||
presented: bool,
|
||||
}
|
||||
|
||||
impl OwnerState {
|
||||
pub(crate) fn remember(&mut self, key: &str) {
|
||||
if let Some(slot) = self.order.get_mut(self.order_len) {
|
||||
if slot != key {
|
||||
slot.clear();
|
||||
slot.push_str(key);
|
||||
}
|
||||
} else {
|
||||
self.order.push(key.to_owned());
|
||||
}
|
||||
self.order_len += 1;
|
||||
}
|
||||
|
||||
pub(crate) fn current_order(&self) -> &[String] {
|
||||
self.order.get(..self.order_len).unwrap_or(&[])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Frame {
|
||||
session: Option<u64>,
|
||||
context: Option<egui::Context>,
|
||||
owners: BTreeMap<ContentOwner, OwnerState>,
|
||||
active: Option<(ContentOwner, ContentRequest)>,
|
||||
last_bodies: Vec<String>,
|
||||
last_plane: Option<String>,
|
||||
plane_underlay: Option<String>,
|
||||
finalized: bool,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
pub(crate) fn owner(&self, owner: ContentOwner) -> Option<&OwnerState> {
|
||||
self.owners.get(&owner)
|
||||
}
|
||||
|
||||
pub(crate) fn current_mut(&mut self) -> &mut OwnerState {
|
||||
let owner = self.active.map_or(ContentOwner::BROWSER, |(owner, _)| owner);
|
||||
self.owners.entry(owner).or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn browser_presentation(&self) -> bool {
|
||||
matches!(self.active, Some((ContentOwner::BROWSER, ContentRequest::Browser { .. })))
|
||||
}
|
||||
}
|
||||
|
||||
fn active_id() -> egui::Id {
|
||||
egui::Id::new("sc_semantic_content_owner")
|
||||
}
|
||||
|
||||
fn output_id(owner: ContentOwner) -> egui::Id {
|
||||
egui::Id::new(("sc_semantic_content_output", owner))
|
||||
}
|
||||
|
||||
fn capture_id() -> egui::Id {
|
||||
egui::Id::new("sc_semantic_content_eye_owner")
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct EyeCapture {
|
||||
source: ContentSource,
|
||||
widget: egui::Id,
|
||||
target: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct RowCapture {
|
||||
source: ContentSource,
|
||||
widget: egui::Id,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct InputClaims {
|
||||
frame: u64,
|
||||
actions: HashSet<egui::Id>,
|
||||
}
|
||||
|
||||
/// Egui may repeat layout within an input frame. A consumed row action must not execute a
|
||||
/// second time on that pass (in particular a Ctrl toggle or an eye visibility command).
|
||||
pub(crate) fn claim_action(ui: &Ui, widget: egui::Id) -> bool {
|
||||
let frame = ui.ctx().cumulative_frame_nr();
|
||||
ui.ctx().data_mut(|data| {
|
||||
let claims = data.get_temp_mut_or_default::<InputClaims>(egui::Id::new("sc_semantic_content_input_claims"));
|
||||
if claims.frame != frame {
|
||||
claims.frame = frame;
|
||||
claims.actions.clear();
|
||||
}
|
||||
claims.actions.insert(widget)
|
||||
})
|
||||
}
|
||||
|
||||
fn row_capture_id() -> egui::Id {
|
||||
egui::Id::new("sc_semantic_content_row_capture")
|
||||
}
|
||||
|
||||
pub(crate) fn current_source(app: &SolveApp) -> Option<ContentSource> {
|
||||
let (owner, _) = app.tree.content.active?;
|
||||
(app.tree.content.session == Some(app.session.uid)).then_some(ContentSource { session: app.session.uid, owner })
|
||||
}
|
||||
|
||||
pub(crate) fn same_document(ui: &Ui, source: ContentSource) -> bool {
|
||||
ui.ctx().data(|data| data.get_temp::<ContentSource>(active_id())).is_some_and(|active| active.session == source.session)
|
||||
}
|
||||
|
||||
pub(crate) fn source_present(app: &SolveApp, source: ContentSource) -> bool {
|
||||
app.tree.content.session == Some(source.session)
|
||||
&& app.session.uid == source.session
|
||||
&& app.tree.content.owner(source.owner).is_some_and(|state| state.presented)
|
||||
}
|
||||
|
||||
/// Called while the outgoing session (and its undo stack) is still active. Context is retained
|
||||
/// solely for cancelling owned egui capture when a tab changes outside the drawing callback.
|
||||
pub(crate) fn leave_document(app: &mut SolveApp) {
|
||||
let context = app.tree.content.context.clone();
|
||||
if let (Some(ctx), Some(session)) = (&context, app.tree.content.session) {
|
||||
if ctx.data(|data| data.get_temp::<EyeCapture>(capture_id())).is_some_and(|capture| capture.source.session == session) {
|
||||
crate::browser::end_eye_drag(app, ctx);
|
||||
}
|
||||
if let Some(capture) = ctx.data(|data| data.get_temp::<RowCapture>(row_capture_id())).filter(|capture| capture.source.session == session) {
|
||||
if ctx.dragged_id() == Some(capture.widget) {
|
||||
ctx.stop_dragging();
|
||||
}
|
||||
ctx.data_mut(|data| data.remove::<RowCapture>(row_capture_id()));
|
||||
}
|
||||
crate::browser::cancel_content_drag(ctx, session);
|
||||
ctx.data_mut(|data| {
|
||||
if data.get_temp::<ContentSource>(active_id()).is_some_and(|source| source.session == session) {
|
||||
data.remove::<ContentSource>(active_id());
|
||||
}
|
||||
for owner in app.tree.content.owners.keys() {
|
||||
data.remove::<Output>(output_id(*owner));
|
||||
}
|
||||
});
|
||||
}
|
||||
crate::browser::discard_eye_press(app);
|
||||
let _ = expire_hover(app);
|
||||
app.tree.content = Frame { context, ..Default::default() };
|
||||
PUBLISHED.with(|outputs| outputs.borrow_mut().clear());
|
||||
}
|
||||
|
||||
/// Also protects callers which replace a Session without passing through Documents.
|
||||
pub(crate) fn ensure_session(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
crate::browser::sync_reveals(app);
|
||||
if app.tree.content.session != Some(app.session.uid) {
|
||||
leave_document(app);
|
||||
app.tree.content.session = Some(app.session.uid);
|
||||
}
|
||||
app.tree.content.context = Some(ctx.clone());
|
||||
}
|
||||
|
||||
/// An external producer replaced this slot. Its ownership supersedes our previous write even
|
||||
/// when the new vector contains precisely the same names (Select Other is one such producer).
|
||||
pub(crate) fn set_external_body_hover(app: &mut SolveApp, bodies: Vec<String>) {
|
||||
app.tree.content.last_bodies.clear();
|
||||
app.viewport.hover_bodies = bodies;
|
||||
}
|
||||
|
||||
fn expire_hover(app: &mut SolveApp) -> (Vec<String>, Option<String>) {
|
||||
let old_bodies = std::mem::take(&mut app.tree.content.last_bodies);
|
||||
let old_plane = app.tree.content.last_plane.take();
|
||||
for body in &old_bodies {
|
||||
if let Some(index) = app.viewport.hover_bodies.iter().position(|candidate| candidate == body) {
|
||||
app.viewport.hover_bodies.remove(index);
|
||||
}
|
||||
}
|
||||
let plane_underlay = app.tree.content.plane_underlay.take();
|
||||
if old_plane.is_some() && app.viewport.hover_plane == old_plane {
|
||||
app.viewport.hover_plane = plane_underlay;
|
||||
}
|
||||
(old_bodies, old_plane)
|
||||
}
|
||||
|
||||
pub(crate) fn begin(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ensure_session(app, ctx);
|
||||
let frame = &mut app.tree.content;
|
||||
frame.active = None;
|
||||
frame.finalized = false;
|
||||
for state in frame.owners.values_mut() {
|
||||
if state.presented {
|
||||
std::mem::swap(&mut state.previous, &mut state.order);
|
||||
}
|
||||
state.order_len = 0;
|
||||
state.output = Output::default();
|
||||
state.actions = None;
|
||||
state.presented = false;
|
||||
}
|
||||
PUBLISHED.with(|outputs| outputs.borrow_mut().clear());
|
||||
ctx.data_mut(|data| data.remove::<ContentSource>(active_id()));
|
||||
crate::browser::prune_reveals(app);
|
||||
}
|
||||
|
||||
/// Render into an existing UI. Stable item IDs and command paths belong to the row renderers.
|
||||
pub(crate) fn show(app: &mut SolveApp, ui: &mut Ui, owner: ContentOwner, request: ContentRequest) {
|
||||
render_scope(app, ui, owner, request, |app, ui| crate::browser::render_content(app, ui, request));
|
||||
}
|
||||
|
||||
pub(crate) fn render_scope(
|
||||
app: &mut SolveApp,
|
||||
ui: &mut Ui,
|
||||
owner: ContentOwner,
|
||||
request: ContentRequest,
|
||||
render: impl FnOnce(&mut SolveApp, &mut Ui) -> crate::browser::Actions,
|
||||
) {
|
||||
ensure_session(app, ui.ctx());
|
||||
if app.tree.content.owners.get(&owner).is_some_and(|state| state.presented) {
|
||||
return;
|
||||
}
|
||||
app.tree.content.active = Some((owner, request));
|
||||
app.tree.content.owners.entry(owner).or_default().presented = presented(ui);
|
||||
ui.ctx().data_mut(|data| {
|
||||
data.insert_temp(active_id(), ContentSource { session: app.session.uid, owner });
|
||||
data.insert_temp(output_id(owner), Output::default());
|
||||
});
|
||||
let session = app.session.uid;
|
||||
let actions = ui.push_id(("semantic_content", session, owner), |ui| render(app, ui)).inner;
|
||||
if session != app.session.uid {
|
||||
ensure_session(app, ui.ctx());
|
||||
return;
|
||||
}
|
||||
let output = ui.ctx().data_mut(|data| {
|
||||
data.remove::<ContentSource>(active_id());
|
||||
data.remove_temp::<Output>(output_id(owner)).unwrap_or_default()
|
||||
});
|
||||
PUBLISHED.with(|outputs| outputs.borrow_mut().insert(owner, output.clone()));
|
||||
let state = app.tree.content.owners.entry(owner).or_default();
|
||||
state.order.truncate(state.order_len);
|
||||
state.output = output;
|
||||
state.actions = Some(actions);
|
||||
app.tree.content.active = None;
|
||||
}
|
||||
|
||||
pub(crate) fn record_row(ui: &Ui, row: DrawnRow, response: &crate::ui_kit::RowResp) {
|
||||
if !presented(ui) {
|
||||
return;
|
||||
}
|
||||
let Some(source) = ui.ctx().data(|data| data.get_temp::<ContentSource>(active_id())) else { return };
|
||||
let owner = source.owner;
|
||||
ui.ctx().data_mut(|data| {
|
||||
let output = data.get_temp_mut_or_default::<Output>(output_id(owner));
|
||||
output.rows.push(row);
|
||||
output.pointer_owned |= response.hovered || response.resp.as_ref().is_some_and(|response| response.contains_pointer());
|
||||
});
|
||||
if !response.on_control
|
||||
&& let Some(response) = &response.resp
|
||||
{
|
||||
record_capture(ui, response);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_capture(ui: &Ui, response: &egui::Response) {
|
||||
if response.drag_started()
|
||||
&& let Some(source) = ui.ctx().data(|data| data.get_temp::<ContentSource>(active_id()))
|
||||
{
|
||||
ui.ctx().data_mut(|data| data.insert_temp(row_capture_id(), RowCapture { source, widget: response.id }));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_occurrence(ui: &Ui, row: DrawnOccurrence) {
|
||||
if !presented(ui) {
|
||||
return;
|
||||
}
|
||||
let Some(source) = ui.ctx().data(|data| data.get_temp::<ContentSource>(active_id())) else { return };
|
||||
let owner = source.owner;
|
||||
ui.ctx().data_mut(|data| data.get_temp_mut_or_default::<Output>(output_id(owner)).occurrences.push(row));
|
||||
}
|
||||
|
||||
/// The kit's eye gesture keeps its existing shared target value; its capture belongs to the
|
||||
/// presentation where it began, so another copy of a body cannot apply the same drag again.
|
||||
pub(crate) fn route_response(ui: &Ui, widget: egui::Id, response: &mut crate::ui_kit::RowResp) {
|
||||
let Some(source) = ui.ctx().data(|data| data.get_temp::<ContentSource>(active_id())) else { return };
|
||||
response.hovered &= response.resp.as_ref().is_some_and(|response| response.contains_pointer());
|
||||
let action =
|
||||
response.clicked || response.double || response.secondary.is_some() || response.fold || response.eye || response.radio || response.action;
|
||||
let visible = presented(ui);
|
||||
if !visible {
|
||||
response.hovered = false;
|
||||
response.resp = None;
|
||||
}
|
||||
if !visible || (action && !claim_action(ui, widget)) {
|
||||
response.clicked = false;
|
||||
response.double = false;
|
||||
response.secondary = None;
|
||||
response.fold = false;
|
||||
response.eye = false;
|
||||
response.radio = false;
|
||||
response.action = false;
|
||||
}
|
||||
let capture = ui.ctx().data(|data| data.get_temp::<EyeCapture>(capture_id()));
|
||||
if let Some(capture) = capture {
|
||||
// A layout repeat may run the kit's drag-start branch again after visibility changed.
|
||||
// Keep the gesture's original target rather than letting that branch invert it.
|
||||
ui.ctx().data_mut(|data| data.insert_temp(crate::ui_kit::eye_drag_id(), capture.target));
|
||||
}
|
||||
if capture.is_some_and(|capture| capture.source != source) {
|
||||
response.eye = false;
|
||||
} else if response.eye
|
||||
&& let Some(response) = response.resp.as_ref().filter(|response| response.drag_started())
|
||||
{
|
||||
ui.ctx().data_mut(|data| {
|
||||
if let Some(target) = data.get_temp::<bool>(crate::ui_kit::eye_drag_id()) {
|
||||
data.insert_temp(capture_id(), EyeCapture { source, widget: response.id, target });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn release_eye(ctx: &egui::Context) -> Option<ContentSource> {
|
||||
let capture = ctx.data_mut(|data| {
|
||||
let capture = data.get_temp::<EyeCapture>(capture_id());
|
||||
data.remove::<EyeCapture>(capture_id());
|
||||
capture
|
||||
})?;
|
||||
if ctx.dragged_id() == Some(capture.widget) {
|
||||
ctx.stop_dragging();
|
||||
}
|
||||
Some(capture.source)
|
||||
}
|
||||
|
||||
/// A reveal is acknowledged only after its row is presented in the scroll viewport. An
|
||||
/// offscreen target requests scrolling again on the next frame until that happens.
|
||||
pub(crate) fn scroll_acknowledged(ui: &Ui, rect: Rect) -> bool {
|
||||
ui.scroll_to_rect(rect, Some(egui::Align::Center));
|
||||
presented(ui) && ui.is_rect_visible(rect) && ui.clip_rect().contains(rect.center())
|
||||
}
|
||||
|
||||
fn presented(ui: &Ui) -> bool {
|
||||
ui.is_visible() && ui.opacity() > 0.0
|
||||
}
|
||||
|
||||
pub(crate) fn finalize(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ensure_session(app, ctx);
|
||||
if app.tree.content.finalized {
|
||||
return;
|
||||
}
|
||||
app.tree.content.finalized = true;
|
||||
let mut capture = ctx.data(|data| data.get_temp::<EyeCapture>(capture_id())).map(|capture| capture.source);
|
||||
if capture.is_some_and(|source| !source_present(app, source)) {
|
||||
crate::browser::end_eye_drag(app, ctx);
|
||||
capture = None;
|
||||
}
|
||||
if let Some(capture) = ctx.data(|data| data.get_temp::<RowCapture>(row_capture_id())) {
|
||||
if !source_present(app, capture.source) {
|
||||
crate::browser::cancel_content_drag(ctx, capture.source.session);
|
||||
if ctx.dragged_id() == Some(capture.widget) {
|
||||
ctx.stop_dragging();
|
||||
}
|
||||
ctx.data_mut(|data| data.remove::<RowCapture>(row_capture_id()));
|
||||
} else if !ctx.input(|input| input.pointer.primary_down()) {
|
||||
ctx.data_mut(|data| data.remove::<RowCapture>(row_capture_id()));
|
||||
}
|
||||
}
|
||||
// Keep the previous contribution until finalization: floating viewports paint before
|
||||
// their overlaid panels. Expire only our contribution, leaving other producers intact.
|
||||
let (old_bodies, old_plane) = expire_hover(app);
|
||||
// Egui's response hit testing supplies pointer ownership, including occlusion and capture.
|
||||
// Stable owner iteration only breaks ties; submission order never decides the winner.
|
||||
let winner = app.tree.content.owners.iter().find_map(|(owner, state)| {
|
||||
let actions = state.actions.as_ref()?;
|
||||
(state.presented
|
||||
&& actions.session == Some(app.session.uid)
|
||||
&& capture.is_none_or(|capture| capture.owner == *owner && capture.session == app.session.uid)
|
||||
&& (state.output.pointer_owned || actions.hovered || actions.hover_plane.is_some()))
|
||||
.then_some(*owner)
|
||||
});
|
||||
let mut effect = None;
|
||||
for (owner, state) in &mut app.tree.content.owners {
|
||||
if !state.presented {
|
||||
state.order.clear();
|
||||
state.feature_drags.clear();
|
||||
}
|
||||
let actions = state.actions.take();
|
||||
if Some(*owner) == winner {
|
||||
effect = actions;
|
||||
}
|
||||
}
|
||||
if let Some(actions) = effect {
|
||||
for body in &actions.hover_bodies {
|
||||
if !app.viewport.hover_bodies.contains(body) {
|
||||
app.viewport.hover_bodies.push(body.clone());
|
||||
app.tree.content.last_bodies.push(body.clone());
|
||||
}
|
||||
}
|
||||
if actions.hover_plane.is_some() && actions.hover_plane != app.viewport.hover_plane {
|
||||
app.tree.content.plane_underlay = app.viewport.hover_plane.take();
|
||||
app.tree.content.last_plane.clone_from(&actions.hover_plane);
|
||||
app.viewport.hover_plane.clone_from(&actions.hover_plane);
|
||||
}
|
||||
crate::browser::apply_content_actions(app, actions);
|
||||
}
|
||||
if old_bodies != app.tree.content.last_bodies || old_plane != app.tree.content.last_plane {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@
|
||||
//! parameters, and [`command`] does the rest after `SolveApp::run`.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcraft_engine::io::vfs;
|
||||
use solidcraft_engine::{MAX_PROFILE_NAME, profile_name};
|
||||
|
||||
use crate::ui_kit::BackingTooltipExt as _;
|
||||
@@ -419,14 +418,13 @@ fn apply(app: &mut SolveApp, doc: &Value) -> Vec<String> {
|
||||
skipped
|
||||
}
|
||||
|
||||
/// A profile file read and checked, refusing one too large to be a profile. Read through `vfs`, so
|
||||
/// an MCP agent's folders apply as they do to the engine's files (#11362).
|
||||
/// A profile file read and checked, refusing one too large to be a profile.
|
||||
fn read_file(path: &str) -> Result<Value, String> {
|
||||
let size = vfs::len(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let size = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len();
|
||||
if size > MAX_FILE_BYTES {
|
||||
return Err(format!("{path}: a settings profile file is at most {} MiB", MAX_FILE_BYTES / 1024 / 1024));
|
||||
}
|
||||
let text = vfs::read_to_string(path, MAX_FILE_BYTES as usize).map_err(|e| format!("{path}: {e}"))?;
|
||||
let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
serde_json::from_str(&text).map_err(|e| format!("{path}: not a settings profile: {e}"))
|
||||
}
|
||||
|
||||
@@ -496,7 +494,7 @@ pub fn command(app: &mut SolveApp, id: &str, v: &Value) -> Result<Value, String>
|
||||
let doc = app.settings_profiles.get(name)?.clone();
|
||||
if let Some(path) = v.get("path").and_then(Value::as_str) {
|
||||
let text = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())?;
|
||||
vfs::write(path, text.as_bytes()).map_err(|e| format!("{path}: {e}"))?;
|
||||
std::fs::write(path, text).map_err(|e| format!("{path}: {e}"))?;
|
||||
return Ok(json!({ "profile": doc, "path": path }));
|
||||
}
|
||||
Ok(json!({ "profile": doc }))
|
||||
|
||||
@@ -729,7 +729,7 @@ pub(crate) fn point_id<'a>(session: &solidcraft_engine::Session, sel: &'a Sel) -
|
||||
|
||||
/// The curves and lone points picked in input `i`, as the engine's `entities` list. A profile
|
||||
/// pick expands to every curve of its boundary and its holes (#8390).
|
||||
pub(crate) fn entity_ids(session: &solidcraft_engine::Session, inputs: &[SelInput], i: usize) -> Vec<String> {
|
||||
fn entity_ids(session: &solidcraft_engine::Session, inputs: &[SelInput], i: usize) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for x in items(inputs, i) {
|
||||
match x {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use egui::{Pos2, Sense, Stroke, Vec2 as Screen, vec2};
|
||||
use solidcore_record::{Plane, Vec2};
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::geom::prelude::*;
|
||||
|
||||
use crate::SolveApp;
|
||||
@@ -80,11 +81,19 @@ fn picked_box(app: &SolveApp, d: &crate::dialogs::Dialog) -> Option<(Vec<Vec<Vec
|
||||
let sk = &state.sketch(app.session.active_sketch?)?.sketch;
|
||||
let mut lines: Vec<Vec<Vec2>> = Vec::new();
|
||||
let mut points: Vec<Vec2> = Vec::new();
|
||||
for id in crate::sketch_modify_ui::entity_ids(&app.session, &d.inputs, 0) {
|
||||
if let Some(i) = sk.curve_index(&id) {
|
||||
lines.push(sk.polyline(i));
|
||||
} else if let Some(p) = sk.resolve_point(&id).and_then(|i| sk.point(i)) {
|
||||
points.push(p);
|
||||
for sel in &d.inputs.first()?.items {
|
||||
match sel {
|
||||
Sel::SketchCurve { id, .. } => {
|
||||
if let Some(i) = sk.curve_index(id) {
|
||||
lines.push(sk.polyline(i));
|
||||
}
|
||||
}
|
||||
Sel::SketchPoint { .. } => {
|
||||
if let Some(p) = crate::sketch_modify_ui::point_id(&app.session, sel).and_then(|id| sk.resolve_point(id)).and_then(|i| sk.point(i)) {
|
||||
points.push(p);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let all: Vec<Vec2> = lines.iter().flatten().copied().chain(points).collect();
|
||||
|
||||
@@ -93,16 +93,13 @@ pub(crate) fn picked_feature(app: &SolveApp) -> Option<u64> {
|
||||
/// A new face or edge picked in the view scrolls the history shown to the feature that made it,
|
||||
/// once per pick; the history stays where it is while the pick does not change.
|
||||
pub(crate) fn follow_view_pick(app: &mut SolveApp) {
|
||||
crate::browser::sync_reveals(app);
|
||||
let picked = picked_feature(app);
|
||||
if picked == app.tree.view_pick {
|
||||
return;
|
||||
}
|
||||
app.tree.view_pick = picked;
|
||||
if app.ui.show_timeline
|
||||
&& let Some(id) = picked
|
||||
{
|
||||
crate::browser::request_timeline_reveal(app, id);
|
||||
if picked.is_some() && app.ui.show_timeline {
|
||||
app.tree.timeline_reveal = picked;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -775,7 +772,6 @@ pub(crate) fn tick_playback(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
|
||||
pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::browser::sync_reveals(app);
|
||||
let t = Tokens::get();
|
||||
app.shell.history_dock_space = Some(ui.available_rect_before_wrap());
|
||||
if Container::for_layout(app.ui.layout, Surface::Panel).floating() {
|
||||
@@ -995,7 +991,7 @@ fn timeline_contents(app: &mut SolveApp, ui: &mut egui::Ui, floating: bool) {
|
||||
.collect();
|
||||
let order: Vec<u64> = feats.iter().map(|f| f.0).collect();
|
||||
// Find in Timeline: scroll the found feature into the strip on this frame (#5850).
|
||||
let reveal = app.tree.timeline_reveal;
|
||||
let reveal = app.tree.timeline_reveal.take();
|
||||
// The feature that made what is under the cursor or picked in the viewport.
|
||||
let from_view = [hovered_feature(app), picked_feature(app)];
|
||||
let drag_id = ui.id().with("tl_drag");
|
||||
@@ -1065,8 +1061,8 @@ fn timeline_contents(app: &mut SolveApp, ui: &mut egui::Ui, floating: bool) {
|
||||
*slot = x;
|
||||
}
|
||||
let br = Rect::from_min_size(pos2(x, r.top() + 6.0), vec2(28.0, 28.0));
|
||||
if reveal == Some(*id) && crate::semantic_content::scroll_acknowledged(ui, br) {
|
||||
app.tree.timeline_reveal = None;
|
||||
if reveal == Some(*id) {
|
||||
ui.scroll_to_rect(br, Some(egui::Align::Center));
|
||||
}
|
||||
crate::scenario::publish_handle(&format!("timeline:{name}"), br.center());
|
||||
// A chip scrolled out of the strip is neither drawn nor interactive: a long
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
//! The Dependents button of INSPECT ▸ HISTORY asks the engine for the dependents of ONE
|
||||
//! feature. The button used to run `timeline.dependents` with no `feature` at all, and the
|
||||
//! engine answered "`feature` must be an id or name" (#12007). With nothing selected it now
|
||||
//! says what to pick, and with a feature selected it asks about that feature.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::scenario::Harness;
|
||||
|
||||
/// A design with a body and a feature on it, so the timeline has something to ask about.
|
||||
fn design() -> Harness {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 10})).unwrap();
|
||||
h.app.run("sketch.create", json!({"plane": "XY", "name": "S1"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.run("solid.extrude", json!({"distance": 5})).unwrap();
|
||||
h.frames(2);
|
||||
h
|
||||
}
|
||||
|
||||
/// With nothing selected the button says what to pick, and the engine is never asked.
|
||||
#[test]
|
||||
fn the_dependents_button_with_nothing_selected_says_what_to_pick() {
|
||||
let mut h = design();
|
||||
h.app.run("select.clear", json!({})).unwrap();
|
||||
h.frames(2);
|
||||
|
||||
// What the ribbon button does: `app.start(id)` with no parameters.
|
||||
h.app.start("timeline.dependents");
|
||||
h.frames(2);
|
||||
|
||||
let (text, _, error) = h.app.status.clone().expect("the button says what to pick");
|
||||
assert!(!error, "the button must not report a failure: {text}");
|
||||
assert_eq!(text, h.app.tr("ui.dependents_select_first"));
|
||||
assert!(!text.contains("must be an id or name"), "{text}");
|
||||
}
|
||||
|
||||
/// With a feature selected the button asks the engine about that feature, and the engine
|
||||
/// answers instead of refusing the missing `feature`.
|
||||
#[test]
|
||||
fn the_dependents_button_with_a_feature_selected_asks_the_engine() {
|
||||
let mut h = design();
|
||||
let id = h.app.session.doc.find_feature("S1").expect("the sketch").id;
|
||||
h.app.run("select.set", json!({"items": [{"type": "feature", "id": id}]})).unwrap();
|
||||
h.frames(2);
|
||||
|
||||
h.app.start("timeline.dependents");
|
||||
h.frames(2);
|
||||
|
||||
let log = h.app.session.log.join("\n");
|
||||
assert!(!log.contains("must be an id or name"), "the engine refused the feature: {log}");
|
||||
assert!(log.contains("Feature Dependents done"), "the engine never answered: {log}");
|
||||
}
|
||||
@@ -226,7 +226,6 @@ fn card_fill(app: &SolveApp, floating: bool) -> egui::Color32 {
|
||||
|
||||
/// Full-height left card, or a card sharing the browser column below it.
|
||||
pub fn left_panel(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::browser::sync_reveals(app);
|
||||
let t = Tokens::get();
|
||||
let available = ui.available_rect_before_wrap();
|
||||
app.shell.history_dock_space = Some(available);
|
||||
@@ -1283,7 +1282,7 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui, names: bool) {
|
||||
})
|
||||
.collect(),
|
||||
results: crate::timeline::result_index(&app.session.model.results),
|
||||
reveal: app.tree.timeline_reveal,
|
||||
reveal: app.tree.timeline_reveal.take(),
|
||||
from_view,
|
||||
linked_hover: app.viewport.hover_feature.filter(|id| plan.referenced(*id)),
|
||||
drag: ui.data(|data| data.get_temp(drag_id)),
|
||||
@@ -1481,8 +1480,8 @@ fn feature_row(app: &mut SolveApp, ui: &mut egui::Ui, rows: &mut Rows, index: us
|
||||
None => {
|
||||
crate::scenario::publish_rect(&format!("history:feature:{name}"), rect);
|
||||
crate::scenario::publish_handle(&format!("timeline:{name}"), rect.center());
|
||||
if rows.reveal == Some(id) && crate::semantic_content::scroll_acknowledged(ui, rect) {
|
||||
app.tree.timeline_reveal = None;
|
||||
if rows.reveal == Some(id) {
|
||||
ui.scroll_to_rect(rect, Some(egui::Align::Center));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ const ELEMENTS: &[Element] = &[
|
||||
number_pair(ui, "Size", [&mut w, &mut h], 1..=16384, 1.0, "");
|
||||
}),
|
||||
element!("progress_action", |ui, _| {
|
||||
progress_action(ui, "gallery:progress", "Rendering", "· 62%", 0.62, "Cancel");
|
||||
progress_action(ui, "Rendering · 62%", 0.62, "Cancel");
|
||||
}),
|
||||
element!("settings_choice_row", |ui, _| {
|
||||
settings_choice_row(
|
||||
|
||||
@@ -214,41 +214,15 @@ pub(crate) fn check_status_line(ui: &mut egui::Ui, key: &str, label: &str, statu
|
||||
response
|
||||
}
|
||||
|
||||
/// Single-row render status for a shared Navigation card: name, status, meter and Cancel, within
|
||||
/// the width the caller's `ui` offers. Cancel and the status always show in full; a long name
|
||||
/// gives way first (cut with an ellipsis, its full text on hover), then the meter narrows
|
||||
/// (#11222). Publishes `dialog:progress:KEY`, `KEY:name` with `KEY:name:cut`, and `KEY:cancel`.
|
||||
/// The caller dispatches Cancel. Tokens: `font_label, form_ink, space_24, control_height`.
|
||||
pub(crate) fn progress_action(ui: &mut egui::Ui, key: &str, name: &str, status: &str, fraction: f32, cancel: &str) -> egui::Response {
|
||||
/// Single-row render status for a shared Navigation card. The caller dispatches Cancel.
|
||||
pub(crate) fn progress_action(ui: &mut egui::Ui, caption: &str, fraction: f32, cancel: &str) -> egui::Response {
|
||||
let t = Tokens::get();
|
||||
ui.horizontal(|ui| {
|
||||
let font = egui::FontId::proportional(t.font_label);
|
||||
let measure = |ui: &egui::Ui, text: &str| ui.painter().layout_no_wrap(text.to_owned(), font.clone(), t.form_ink).size().x.ceil();
|
||||
let gap = ui.spacing().item_spacing.x;
|
||||
let status_width = measure(ui, status);
|
||||
let cancel_width = measure(ui, cancel) + 2.0 * ui.spacing().button_padding.x;
|
||||
let (meter_full, meter_min, name_min) = (t.space_24 * 6.0, t.space_24 * 2.0, t.space_24);
|
||||
// What the name and the meter share once the status, Cancel and the gaps have their room.
|
||||
let shared = (ui.available_width() - status_width - cancel_width - 3.0 * gap).max(0.0);
|
||||
let name_natural = measure(ui, name);
|
||||
// The name keeps its length while the full meter still fits beside it; past that it is
|
||||
// cut, down to `name_min`, and only then does the meter narrow, down to `meter_min`.
|
||||
let name_width = name_natural.min((shared - meter_full).max(name_min));
|
||||
let meter = (shared - name_width).min(meter_full).max(meter_min);
|
||||
let fixed = |ui: &mut egui::Ui, width: f32, add: &dyn Fn(&mut egui::Ui) -> egui::Response| {
|
||||
ui.allocate_ui_with_layout(vec2(width, t.control_height), egui::Layout::left_to_right(egui::Align::Center), add).inner
|
||||
};
|
||||
let shown = fixed(ui, name_width, &|ui| super::text(ui, name, super::Text::Inline));
|
||||
let cut = name_natural > name_width + 0.5;
|
||||
crate::scenario::publish_rect(&format!("{key}:name"), shown.rect);
|
||||
crate::scenario::publish_count(&format!("{key}:name:cut"), f64::from(u8::from(cut)));
|
||||
fixed(ui, status_width, &|ui| super::text(ui, status, super::Text::Inline));
|
||||
fixed(ui, meter, &|ui| progress(ui, key, fraction));
|
||||
// Cancel keeps its whole label whatever room the row has left.
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
|
||||
let response = super::button(ui, cancel, super::Button::Normal);
|
||||
crate::scenario::publish_rect(&format!("{key}:cancel"), response.rect);
|
||||
response
|
||||
super::text(ui, caption, super::Text::Inline);
|
||||
ui.allocate_ui_with_layout(vec2(t.space_24 * 6.0, t.control_height), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
progress(ui, "render-pill", fraction);
|
||||
});
|
||||
super::button(ui, cancel, super::Button::Normal)
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
@@ -53,11 +53,6 @@ in an answer. Copy, Cut and Paste do not also operate on selected CAD geometry.
|
||||
pointer back over the model does not change this: click outside Assistant to return the
|
||||
keyboard to CAD. Closing or minimising the panel also releases it.
|
||||
|
||||
To select more of the conversation than fits in the panel, drag the selection to its top or
|
||||
bottom edge and hold it there: the conversation scrolls, faster the further past the edge you
|
||||
go, and the selection grows with it. It stays when its start scrolls out of view, and Copy takes
|
||||
all of it.
|
||||
|
||||
- **Docking:** on the left, Assistant is the first panel, before vertical history and the browser.
|
||||
The drag highlight shows the actual destination bounds. Drag the panel's header (the dotted grip and the title) to the other half of the
|
||||
window and let go. The side it would land on lights up while you drag. You can also pick
|
||||
|
||||
@@ -35,10 +35,6 @@ face appearances keep their existing priority. Physical material changes affect
|
||||
`delete` to remove it. Names must be unique. To remove a style column, use
|
||||
`config.column` with `material` or `appearance` set to the target body and `delete: true`.
|
||||
|
||||
Suppression cells accept `true`/`false`, `yes`/`no`, and `1`/`0`, ignoring case and surrounding
|
||||
whitespace. Creating, copying, or editing a row checks its final suppression values before saving.
|
||||
An invalid value leaves the table and undo history unchanged; accepted text keeps its spelling.
|
||||
|
||||
Body style columns target body names. Assignments remain stored when a row suppresses the
|
||||
feature that produces that body, and become visible when it returns.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# UI localization
|
||||
|
||||
SolidCraft embeds its translations and works offline. English, Arabic, Bosnian, Czech, Danish, German, English (UK), Spanish, Finnish, French, Galician, Hebrew, Croatian, Italian,
|
||||
Japanese, Georgian, Korean, Malay, Norwegian Bokmål, Dutch, Polish, Brazilian Portuguese, Russian, Serbian, Turkish, Ukrainian and Chinese are currently registered in `crates/ui-egui/src/i18n.rs`. Adding a language requires an
|
||||
Japanese, Korean, Malay, Norwegian Bokmål, Dutch, Polish, Brazilian Portuguese, Russian, Serbian, Turkish, Ukrainian and Chinese are currently registered in `crates/ui-egui/src/i18n.rs`. Adding a language requires an
|
||||
embedded catalog and one registration there; the preferences selector and validation use the same
|
||||
registry.
|
||||
|
||||
@@ -52,7 +52,7 @@ that looks untranslated. Two guards catch that in the build:
|
||||
|
||||
- `every_complete_catalog_carries_every_english_message` — adding a key to `en.json`
|
||||
without giving it to a complete catalog fails the tests. English is the source catalog,
|
||||
Arabic, Hebrew, Japanese and Chinese are delivered complete; Bosnian, Croatian, Serbian and Georgian carry
|
||||
Arabic, Hebrew, Japanese and Chinese are delivered complete; Bosnian, Croatian and Serbian carry
|
||||
every English message today but are not held to it, so a new label may follow later in them; Danish, German, Spanish, Finnish, Galician, Italian, Korean, Malay, Norwegian Bokmål, Polish, Brazilian Portuguese, Russian, French, Ukrainian and
|
||||
Turkish are partial — their gaps fall back to English and are reported by `cargo xtask i18n` without
|
||||
failing the build. A new label needs en, ar, he, ja, zh, de and ko; Russian, French, Ukrainian and Malay may
|
||||
@@ -102,8 +102,6 @@ traka. In Serbian, brand and file names, acronyms, units, key names and placehol
|
||||
script inside the Cyrillic text. Command IDs, shortcuts, file formats, units and user-provided names
|
||||
are not translated.
|
||||
|
||||
The Georgian catalog (`ka`, shown as ქართული) uses ესკიზი (Sketch), სხეული (Body), ოპერაცია (timeline Feature), შეზღუდვა (Constraint), ზომა (Dimension), ამოწევა (Extrude), ბრუნვით აგება (Revolve), მომრგვალება (Fillet), ფასკა (Chamfer), მასივი (Pattern), შეერთება (Joint), წახნაგი (Face), წიბო (Edge) and წვერო (Vertex); a design is a პროექტი. The Browser panel is მოდელის ხე (model tree) and the timeline ქრონოლოგია, apart from the undo history (ისტორია). Georgian has no letter case, so capitalised English labels are written in ordinary Mkhedruli, never in Mtavruli; a count stays singular after its number, so counted labels are written as `კომპონენტი: {count}`. The catalog is written in Mkhedruli; where no Georgian system font is found, egui's built-in Hack draws it in its monospace design. Command IDs, shortcuts, file formats, units and user-provided names stay unchanged. Written with Claude Code from the meaning of each English string; terminology and doubtful strings decided by a native Georgian speaker (@devius).
|
||||
|
||||
The Finnish catalog (`fi`, shown as Suomi) uses Luonnos (Sketch), Kappale (Body), Piirre (timeline Feature), Rajoite (Constraint), Mitta (Dimension), Pyöristys (Fillet), Viiste (Chamfer), Pursotus (Extrude) and Pyöräytys (Revolve); a design is a malli and an AI model a tekoälymalli. Draft is Päästö, Flat Pattern is Levityskuva, Hem is Huulos, and Counterbore/Countersink are Upotus/Kartioupotus. Camera and view angles are Nyökkäys (pitch), Kääntö (yaw) and Kallistus (roll); a saved render shot is a Kuvakulma. Finnish puts a space before % (50 %). Command IDs, shortcuts, file formats, units and user-provided names stay unchanged.
|
||||
|
||||
The Norwegian Bokmål catalog (`nb`, shown as Norsk bokmål) uses Skisse (Sketch),
|
||||
|
||||
+5
-7
@@ -191,13 +191,11 @@ configuration folder exists, and no client program is started. Supported:
|
||||
| `zed` | Zed's `settings.json` (`context_servers`) |
|
||||
| `vscode` | VS Code's user `mcp.json` |
|
||||
|
||||
Only the `solidcraft` entries change; everything else in the file (other servers, comments, order)
|
||||
stays as it was. Updates replace the file atomically and keep the previous version as `<name>.bak`.
|
||||
A failed write leaves the configuration intact. A file that does not parse is left alone.
|
||||
Setup also checks for changes since it read the file and refuses a stale edit. This check narrows
|
||||
the race with another program writing its settings; it cannot exclude a write after the check.
|
||||
After an update moves the program, running setup again only refreshes the path of an entry it wrote.
|
||||
An entry you changed (extra arguments, another program) is left as you have it. `--live` also adds `solidcraft-live`, which bridges to the open app (port
|
||||
The files are edited in place: only the `solidcraft` entries change, everything else in the file
|
||||
(other servers, comments, order) stays as it was, and the file is copied to `<name>.bak` first. A
|
||||
file that does not parse is left alone. Running it again after an update moves the program only
|
||||
refreshes the path of an entry it wrote; an entry you changed (extra arguments, another program)
|
||||
is left as you have it. `--live` also adds `solidcraft-live`, which bridges to the open app (port
|
||||
7878, see below). Restart the client afterwards.
|
||||
|
||||
SolidCraft also does this by itself: when the app starts it adds the `solidcraft` entry to each
|
||||
|
||||
+2
-3
@@ -867,9 +867,8 @@ shared token paint and hit targets. All are exercised in the kit gallery.
|
||||
|
||||
The compact `settings_choice_row` variant reuses the shared settings radio-card painter
|
||||
for preset lists: 40 pt rows, right-aligned summaries and the active workspace accent.
|
||||
`progress_action` keeps a name, a status, a bounded progress track and a Cancel action on
|
||||
one row inside a Navigation card. Cancel and the status always show whole; a long name is
|
||||
cut first (ellipsis, full name on hover), then the track narrows. `Container::below_navigation_row` reserves that card
|
||||
`progress_action` keeps a status caption, bounded progress track and Cancel action on
|
||||
one row inside a Navigation card. `Container::below_navigation_row` reserves that card
|
||||
and its shared gap above an inline result toolbar.
|
||||
|
||||
`number_pair` extends the shared value-pair row to bounded numeric fields (such as
|
||||
|
||||
@@ -148,13 +148,10 @@
|
||||
'';
|
||||
|
||||
# Linux only: the app reaches the window through dlopen'd libraries, so the packaged
|
||||
# binary needs the same LD_LIBRARY_PATH the shell exports. File dialogs go through the
|
||||
# desktop portal and fall back to zenity without one (#2177), so zenity comes along,
|
||||
# after the system's own on PATH. macOS needs no wrapper.
|
||||
# binary needs the same LD_LIBRARY_PATH the shell exports. macOS needs no wrapper.
|
||||
postFixup = pkgs.lib.optionalString pkgs.stdenv.isLinux ''
|
||||
wrapProgram $out/bin/solidcraft \
|
||||
--prefix LD_LIBRARY_PATH : "${libraryPath pkgs}" \
|
||||
--suffix PATH : "${pkgs.lib.makeBinPath [ pkgs.zenity ]}" \
|
||||
--set-default XDG_DATA_DIRS /usr/local/share:/usr/share \
|
||||
--prefix XDG_DATA_DIRS : "$out/share:${driverShareDir pkgs}"
|
||||
'';
|
||||
|
||||
Reference in New Issue
Block a user