mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 11:05:56 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00c1060b6a | |||
| 9f9037d055 | |||
| cb349abe83 |
@@ -317,7 +317,6 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/icons/ui/design/svg/model/circle_ttt.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/coil.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/colored_components.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/colored_components_off.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/combine.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/ui/design/svg/model/component.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
|
||||
@@ -786,7 +785,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
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ circle_ttt false
|
||||
close false
|
||||
coil false
|
||||
colored_components false
|
||||
colored_components_off false
|
||||
combine false
|
||||
compare false
|
||||
component false
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Disable component colors</title>
|
||||
<path d="M9.235 17.706L15.059 20.882L15.059 48.941L9.235 44.926Z" fill="#328bd3"/>
|
||||
<path d="M35.176 9.235L15.059 20.882L15.059 48.941L36.765 35.139Z" fill="#236ead"/>
|
||||
<path d="M29.046 5L35.176 8.176L15.196 19.718L9.066 16.541Z" fill="#b9ddf8"/>
|
||||
<path d="M29.046 5.529L35.176 8.706L35.176 36.235L15.059 49.471L9.235 45.765L9.066 17.071Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.066 17.6L15.059 20.882L34.647 9.235" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.059 20.882L15.059 47.067" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M32.222 18.595L44.949 25.944L44.949 51.122L32.222 43.774Z" fill="#328bd3"/>
|
||||
<path d="M54.934 20.173L44.949 25.944L44.949 51.122L54.934 45.352Z" fill="#236ead"/>
|
||||
<path d="M42.059 12.412L54.934 20.173L44.949 25.944L32 17.706Z" fill="#b9ddf8"/>
|
||||
<path d="M41.159 11.766L54.934 20.173L54.934 45.352L44.949 51.122L31.164 42.715L31.164 17.536Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M32.222 18.595L44.949 25.944L54.934 20.173" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M44.949 25.944L44.949 51.122" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.299 39.062L31.015 46.411L31.015 59L18.299 51.652Z" fill="#328bd3"/>
|
||||
<path d="M41.921 40.121L31.015 46.411L31.015 59L41.921 52.711Z" fill="#236ead"/>
|
||||
<path d="M29.194 32.773L41.921 40.121L31.015 46.411L18.299 39.062Z" fill="#b9ddf8"/>
|
||||
<path d="M29.194 32.773L41.921 40.121L41.921 52.711L31.015 59L18.299 51.652L18.299 39.062Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.299 39.062L31.015 46.411L41.921 40.121" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M31.015 46.411L31.015 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -963,8 +963,7 @@
|
||||
"cmd.inspect_analysis": "تحليل العرض",
|
||||
"cmd.inspect_center_of_mass": "مركز الكتلة",
|
||||
"cmd.inspect_clearance": "الخلوص",
|
||||
"cmd.inspect_colored_components": "تفعيل ألوان المكوّنات",
|
||||
"cmd.inspect_colored_components_disable": "تعطيل ألوان المكوّنات",
|
||||
"cmd.inspect_colored_components": "ألوان المكوّنات",
|
||||
"cmd.inspect_compare": "مقارنة الأجسام",
|
||||
"cmd.inspect_compare_picture": "مقارنة بصورة مرجعية",
|
||||
"cmd.inspect_continuity": "تحليل الاستمرارية",
|
||||
@@ -1406,7 +1405,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 +1422,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 +2001,6 @@
|
||||
"material.selection.mixed": "مواد مختلفة",
|
||||
"measure.at": "عند",
|
||||
"measure.item": "العنصر {number}",
|
||||
"measure.line_to_centre": "من الخط إلى المركز",
|
||||
"measure.quick_copied": "تم نسخ القياس",
|
||||
"measure.quick_copy": "انقر لنسخ القياس",
|
||||
"measure.quick_edges": "{count} حواف",
|
||||
@@ -4810,7 +4806,6 @@
|
||||
"ui.default_modelling_orientation": "اتجاه النمذجة الافتراضي",
|
||||
"ui.delete": "حذف",
|
||||
"ui.delete_select_first": "حدّد أولًا ما تريد حذفه: الميزات أو الأجسام أو المكوّنات أو هندسة الرسم",
|
||||
"ui.dependents_select_first": "حدّد أولًا ما تريد سرد معتمداته",
|
||||
"ui.depth": "العمق",
|
||||
"ui.design": "التصميم",
|
||||
"ui.design_properties.author": "المؤلف",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analiza dostupnosti",
|
||||
"cmd.inspect_center_of_mass": "Centar mase",
|
||||
"cmd.inspect_clearance": "Provjera zazora",
|
||||
"cmd.inspect_colored_components": "Omogući boje komponenti",
|
||||
"cmd.inspect_colored_components_disable": "Onemogući boje komponenti",
|
||||
"cmd.inspect_compare": "Uporedi tijela",
|
||||
"cmd.inspect_compare_picture": "Uporedi sliku",
|
||||
"cmd.inspect_continuity": "Analiza kontinuiteta",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analýza přístupnosti",
|
||||
"cmd.inspect_center_of_mass": "Těžiště",
|
||||
"cmd.inspect_clearance": "Vůle",
|
||||
"cmd.inspect_colored_components": "Zapnout barvy komponent",
|
||||
"cmd.inspect_colored_components_disable": "Vypnout barvy komponent",
|
||||
"cmd.inspect_compare": "Porovnat tělesa",
|
||||
"cmd.inspect_compare_picture": "Porovnat obrázek",
|
||||
"cmd.inspect_continuity": "Analýza spojitosti",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Tilgængelighedsanalyse",
|
||||
"cmd.inspect_center_of_mass": "Massecentrum",
|
||||
"cmd.inspect_clearance": "Frigangskontrol",
|
||||
"cmd.inspect_colored_components": "Aktivér komponentfarver",
|
||||
"cmd.inspect_colored_components_disable": "Deaktivér komponentfarver",
|
||||
"cmd.inspect_compare": "Sammenlign emner",
|
||||
"cmd.inspect_compare_picture": "Sammenlign billede",
|
||||
"cmd.inspect_continuity": "Kontinuitetsanalyse",
|
||||
|
||||
@@ -960,8 +960,6 @@
|
||||
"cmd.inspect_accessibility": "Zugänglichkeitsanalyse",
|
||||
"cmd.inspect_center_of_mass": "Massenschwerpunkt",
|
||||
"cmd.inspect_clearance": "Freiraum",
|
||||
"cmd.inspect_colored_components": "Komponentenfarben einschalten",
|
||||
"cmd.inspect_colored_components_disable": "Komponentenfarben ausschalten",
|
||||
"cmd.inspect_compare": "Körper vergleichen",
|
||||
"cmd.inspect_continuity": "Kontinuitätsprüfung",
|
||||
"cmd.inspect_curvature_comb": "Krümmungskamm-Analyse",
|
||||
@@ -1418,7 +1416,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 +1983,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",
|
||||
@@ -4784,7 +4780,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",
|
||||
|
||||
@@ -22,8 +22,7 @@
|
||||
"cmd.component_cycle_color": "Cycle Component Colour",
|
||||
"cmd.construct_point_center": "Point At Centre Of Circle/Sphere/Torus",
|
||||
"cmd.inspect_center_of_mass": "Centre of Mass",
|
||||
"cmd.inspect_colored_components": "Enable Component Colours",
|
||||
"cmd.inspect_colored_components_disable": "Disable Component Colours",
|
||||
"cmd.inspect_colored_components": "Component Colours",
|
||||
"cmd.parameters_favorite": "Favourite Parameter",
|
||||
"cmd.render_aging_describe": "Layered Surface Aging",
|
||||
"cmd.render_effects_describe": "Lens and Creative Effects",
|
||||
@@ -69,7 +68,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…",
|
||||
|
||||
@@ -963,8 +963,7 @@
|
||||
"cmd.inspect_analysis": "View Analysis",
|
||||
"cmd.inspect_center_of_mass": "Center of Mass",
|
||||
"cmd.inspect_clearance": "Clearance",
|
||||
"cmd.inspect_colored_components": "Enable Component Colors",
|
||||
"cmd.inspect_colored_components_disable": "Disable Component Colors",
|
||||
"cmd.inspect_colored_components": "Component Colors",
|
||||
"cmd.inspect_compare": "Compare Bodies",
|
||||
"cmd.inspect_compare_picture": "Compare Picture",
|
||||
"cmd.inspect_continuity": "Continuity Analysis",
|
||||
@@ -1406,7 +1405,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 +1422,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 +2001,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",
|
||||
@@ -4810,7 +4806,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",
|
||||
|
||||
@@ -939,8 +939,6 @@
|
||||
"cmd.inspect_accessibility": "Análisis de accesibilidad",
|
||||
"cmd.inspect_center_of_mass": "Centro de masas",
|
||||
"cmd.inspect_clearance": "Holgura",
|
||||
"cmd.inspect_colored_components": "Activar colores de componente",
|
||||
"cmd.inspect_colored_components_disable": "Desactivar colores de componente",
|
||||
"cmd.inspect_compare": "Comparar cuerpos",
|
||||
"cmd.inspect_continuity": "Análisis de continuidad",
|
||||
"cmd.inspect_curvature_comb": "Análisis de peine de curvatura",
|
||||
@@ -1358,7 +1356,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}",
|
||||
|
||||
@@ -935,8 +935,6 @@
|
||||
"cmd.inspect_accessibility": "Saavutettavuusanalyysi",
|
||||
"cmd.inspect_center_of_mass": "Massakeskipiste",
|
||||
"cmd.inspect_clearance": "Välys",
|
||||
"cmd.inspect_colored_components": "Ota komponenttivärit käyttöön",
|
||||
"cmd.inspect_colored_components_disable": "Poista komponenttivärit käytöstä",
|
||||
"cmd.inspect_compare": "Vertaa kappaleita",
|
||||
"cmd.inspect_continuity": "Jatkuvuusanalyysi",
|
||||
"cmd.inspect_curvature_comb": "Kaarevuuskampa-analyysi",
|
||||
|
||||
@@ -956,8 +956,6 @@
|
||||
"cmd.inspect_accessibility": "Analyse d'accessibilité",
|
||||
"cmd.inspect_center_of_mass": "Centre de gravité",
|
||||
"cmd.inspect_clearance": "Jeu",
|
||||
"cmd.inspect_colored_components": "Activer les couleurs des composants",
|
||||
"cmd.inspect_colored_components_disable": "Désactiver les couleurs des composants",
|
||||
"cmd.inspect_compare": "Comparer les corps",
|
||||
"cmd.inspect_compare_picture": "Comparer les images",
|
||||
"cmd.inspect_continuity": "Analyse de continuité",
|
||||
@@ -1391,7 +1389,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}",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Análise de accesibilidade",
|
||||
"cmd.inspect_center_of_mass": "Centro de masas",
|
||||
"cmd.inspect_clearance": "Folga",
|
||||
"cmd.inspect_colored_components": "Activar as cores dos compoñentes",
|
||||
"cmd.inspect_colored_components_disable": "Desactivar as cores dos compoñentes",
|
||||
"cmd.inspect_compare": "Comparar corpos",
|
||||
"cmd.inspect_compare_picture": "Comparar a imaxe",
|
||||
"cmd.inspect_continuity": "Análise de continuidade",
|
||||
|
||||
@@ -963,8 +963,7 @@
|
||||
"cmd.inspect_analysis": "ניתוח תצוגה",
|
||||
"cmd.inspect_center_of_mass": "מרכז מסה",
|
||||
"cmd.inspect_clearance": "מרווח",
|
||||
"cmd.inspect_colored_components": "הפעלת צבעי רכיבים",
|
||||
"cmd.inspect_colored_components_disable": "ביטול צבעי רכיבים",
|
||||
"cmd.inspect_colored_components": "צבעי רכיבים",
|
||||
"cmd.inspect_compare": "השוואת גופים",
|
||||
"cmd.inspect_compare_picture": "השוואה לתמונת ייחוס",
|
||||
"cmd.inspect_continuity": "ניתוח רציפות",
|
||||
@@ -1406,7 +1405,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 +1422,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 +2001,6 @@
|
||||
"material.selection.mixed": "חומרים שונים",
|
||||
"measure.at": "מיקום",
|
||||
"measure.item": "פריט {number}",
|
||||
"measure.line_to_centre": "מהקו למרכז",
|
||||
"measure.quick_copied": "המדידה הועתקה",
|
||||
"measure.quick_copy": "לחיצה מעתיקה את המדידה",
|
||||
"measure.quick_edges": "{count} מקצועות",
|
||||
@@ -4810,7 +4806,6 @@
|
||||
"ui.default_modelling_orientation": "כיוון מידול ברירת מחדל",
|
||||
"ui.delete": "מחיקה",
|
||||
"ui.delete_select_first": "בחרו קודם מה למחוק: תכונות, גופים, רכיבים או גאומטריית סקיצה",
|
||||
"ui.dependents_select_first": "בחרו קודם מה להציג את התלויים שלו",
|
||||
"ui.depth": "עומק",
|
||||
"ui.design": "תכן",
|
||||
"ui.design_properties.author": "מחבר",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analiza dostupnosti",
|
||||
"cmd.inspect_center_of_mass": "Središte mase",
|
||||
"cmd.inspect_clearance": "Provjera zazora",
|
||||
"cmd.inspect_colored_components": "Omogući boje komponenti",
|
||||
"cmd.inspect_colored_components_disable": "Onemogući boje komponenti",
|
||||
"cmd.inspect_compare": "Usporedi tijela",
|
||||
"cmd.inspect_compare_picture": "Usporedi sliku",
|
||||
"cmd.inspect_continuity": "Analiza kontinuiteta",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Hozzáférhetőség-elemzés",
|
||||
"cmd.inspect_center_of_mass": "Tömegközéppont",
|
||||
"cmd.inspect_clearance": "Hézag",
|
||||
"cmd.inspect_colored_components": "Komponensszínek bekapcsolása",
|
||||
"cmd.inspect_colored_components_disable": "Komponensszínek kikapcsolása",
|
||||
"cmd.inspect_compare": "Testek összehasonlítása",
|
||||
"cmd.inspect_compare_picture": "Kép összehasonlítása",
|
||||
"cmd.inspect_continuity": "Folytonosságelemzés",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analisis Aksesibilitas",
|
||||
"cmd.inspect_center_of_mass": "Pusat Massa",
|
||||
"cmd.inspect_clearance": "Kelonggaran",
|
||||
"cmd.inspect_colored_components": "Aktifkan Warna Komponen",
|
||||
"cmd.inspect_colored_components_disable": "Nonaktifkan Warna Komponen",
|
||||
"cmd.inspect_compare": "Bandingkan Bodi",
|
||||
"cmd.inspect_compare_picture": "Bandingkan dengan Gambar",
|
||||
"cmd.inspect_continuity": "Analisis Kontinuitas",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analisi di accessibilità",
|
||||
"cmd.inspect_center_of_mass": "Centro di massa",
|
||||
"cmd.inspect_clearance": "Distanza libera",
|
||||
"cmd.inspect_colored_components": "Attiva colori dei componenti",
|
||||
"cmd.inspect_colored_components_disable": "Disattiva colori dei componenti",
|
||||
"cmd.inspect_compare": "Confronta corpi",
|
||||
"cmd.inspect_compare_picture": "Confronta immagine",
|
||||
"cmd.inspect_continuity": "Analisi di continuità",
|
||||
@@ -1392,7 +1390,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}",
|
||||
|
||||
@@ -963,8 +963,7 @@
|
||||
"cmd.inspect_analysis": "表示解析",
|
||||
"cmd.inspect_center_of_mass": "重心",
|
||||
"cmd.inspect_clearance": "クリアランス",
|
||||
"cmd.inspect_colored_components": "コンポーネントの色分けを有効化",
|
||||
"cmd.inspect_colored_components_disable": "コンポーネントの色分けを無効化",
|
||||
"cmd.inspect_colored_components": "コンポーネントの色分け",
|
||||
"cmd.inspect_compare": "ボディを比較",
|
||||
"cmd.inspect_compare_picture": "参考画像と比較",
|
||||
"cmd.inspect_continuity": "連続性解析",
|
||||
@@ -1406,7 +1405,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 +1422,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 +2001,6 @@
|
||||
"material.selection.mixed": "異なる材料",
|
||||
"measure.at": "位置",
|
||||
"measure.item": "項目 {number}",
|
||||
"measure.line_to_centre": "線から中心まで",
|
||||
"measure.quick_copied": "測定値をコピーしました",
|
||||
"measure.quick_copy": "クリックで測定値をコピー",
|
||||
"measure.quick_edges": "エッジ {count} 本",
|
||||
@@ -4810,7 +4806,6 @@
|
||||
"ui.default_modelling_orientation": "モデリングのデフォルト方向",
|
||||
"ui.delete": "削除",
|
||||
"ui.delete_select_first": "先に削除するものを選択してください: フィーチャ、ボディ、コンポーネント、スケッチ ジオメトリ",
|
||||
"ui.dependents_select_first": "先に、依存先を表示する対象を選択してください",
|
||||
"ui.depth": "深さ",
|
||||
"ui.design": "デザイン",
|
||||
"ui.design_properties.author": "作成者",
|
||||
|
||||
@@ -1434,7 +1434,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}초 전",
|
||||
@@ -3586,7 +3585,6 @@
|
||||
"ui.default_modelling_orientation": "기본 모델링 방향",
|
||||
"ui.delete": "삭제",
|
||||
"ui.delete_select_first": "먼저 삭제할 항목을 선택하세요: 피처, 바디, 컴포넌트 또는 스케치 형상",
|
||||
"ui.dependents_select_first": "먼저 종속 항목을 표시할 대상을 선택하세요",
|
||||
"ui.depth": "깊이",
|
||||
"ui.design": "디자인",
|
||||
"ui.design_properties.author": "작성자",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analisis Kebolehcapaian",
|
||||
"cmd.inspect_center_of_mass": "Pusat Jisim",
|
||||
"cmd.inspect_clearance": "Kelegaan",
|
||||
"cmd.inspect_colored_components": "Aktifkan Warna Komponen",
|
||||
"cmd.inspect_colored_components_disable": "Nyahaktifkan Warna Komponen",
|
||||
"cmd.inspect_compare": "Bandingkan Jasad",
|
||||
"cmd.inspect_compare_picture": "Banding dengan Imej",
|
||||
"cmd.inspect_continuity": "Analisis Keselanjaran",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Tilgjengelighetsanalyse",
|
||||
"cmd.inspect_center_of_mass": "Massesenter",
|
||||
"cmd.inspect_clearance": "Klaring",
|
||||
"cmd.inspect_colored_components": "Aktiver komponentfarger",
|
||||
"cmd.inspect_colored_components_disable": "Deaktiver komponentfarger",
|
||||
"cmd.inspect_compare": "Sammenlign kropper",
|
||||
"cmd.inspect_compare_picture": "Sammenlign bilde",
|
||||
"cmd.inspect_continuity": "Kontinuitetsanalyse",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Toegankelijkheidsanalyse",
|
||||
"cmd.inspect_center_of_mass": "Zwaartepunt",
|
||||
"cmd.inspect_clearance": "Speling",
|
||||
"cmd.inspect_colored_components": "Componentkleuren inschakelen",
|
||||
"cmd.inspect_colored_components_disable": "Componentkleuren uitschakelen",
|
||||
"cmd.inspect_compare": "Lichamen vergelijken",
|
||||
"cmd.inspect_compare_picture": "Afbeelding vergelijken",
|
||||
"cmd.inspect_continuity": "Continuïteitsanalyse",
|
||||
@@ -1395,7 +1393,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",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"en": { "document.tab_unsaved_changes": "Unsaved changes" },
|
||||
"ar": { "document.tab_unsaved_changes": "تغييرات غير محفوظة" },
|
||||
"de": { "document.tab_unsaved_changes": "Ungespeicherte Änderungen" },
|
||||
"es": { "document.tab_unsaved_changes": "Cambios sin guardar" },
|
||||
"fr": { "document.tab_unsaved_changes": "Modifications non enregistrées" },
|
||||
"he": { "document.tab_unsaved_changes": "שינויים שלא נשמרו" },
|
||||
"it": { "document.tab_unsaved_changes": "Modifiche non salvate" },
|
||||
"ja": { "document.tab_unsaved_changes": "未保存の変更" },
|
||||
"nl": { "document.tab_unsaved_changes": "Niet-opgeslagen wijzigingen" },
|
||||
"pl": { "document.tab_unsaved_changes": "Niezapisane zmiany" },
|
||||
"sv": { "document.tab_unsaved_changes": "Osparade ändringar" },
|
||||
"vi": { "document.tab_unsaved_changes": "Thay đổi chưa lưu" },
|
||||
"zh": { "document.tab_unsaved_changes": "未保存的更改" }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"en": { "ui.dependents_select_first": "Select what to list the dependents of first" },
|
||||
"ar": { "ui.dependents_select_first": "حدّد أولًا ما تريد سرد معتمداته" },
|
||||
"de": { "ui.dependents_select_first": "Wählen Sie zuerst aus, wessen Abhängige aufgelistet werden sollen" },
|
||||
"he": { "ui.dependents_select_first": "בחרו קודם מה להציג את התלויים שלו" },
|
||||
"ja": { "ui.dependents_select_first": "先に、依存先を表示する対象を選択してください" },
|
||||
"ko": { "ui.dependents_select_first": "먼저 종속 항목을 표시할 대상을 선택하세요" },
|
||||
"vi": { "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ó" },
|
||||
"zh": { "ui.dependents_select_first": "请先选择要列出其依赖项的对象" }
|
||||
}
|
||||
@@ -934,8 +934,6 @@
|
||||
"cmd.inspect_accessibility": "Analiza dostępności",
|
||||
"cmd.inspect_center_of_mass": "Środek masy",
|
||||
"cmd.inspect_clearance": "Prześwit",
|
||||
"cmd.inspect_colored_components": "Włącz kolory komponentów",
|
||||
"cmd.inspect_colored_components_disable": "Wyłącz kolory komponentów",
|
||||
"cmd.inspect_compare": "Porównaj bryły",
|
||||
"cmd.inspect_continuity": "Analiza ciągłości",
|
||||
"cmd.inspect_curvature_comb": "Analiza grzebienia krzywizny",
|
||||
@@ -1347,7 +1345,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}",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Analiza accesibilității",
|
||||
"cmd.inspect_center_of_mass": "Centru de masă",
|
||||
"cmd.inspect_clearance": "Joc",
|
||||
"cmd.inspect_colored_components": "Activează culorile componentelor",
|
||||
"cmd.inspect_colored_components_disable": "Dezactivează culorile componentelor",
|
||||
"cmd.inspect_compare": "Compară corpurile",
|
||||
"cmd.inspect_compare_picture": "Compară imaginea",
|
||||
"cmd.inspect_continuity": "Analiza continuității",
|
||||
|
||||
@@ -956,8 +956,6 @@
|
||||
"cmd.inspect_accessibility": "Анализ доступности",
|
||||
"cmd.inspect_center_of_mass": "Центр масс",
|
||||
"cmd.inspect_clearance": "Зазор",
|
||||
"cmd.inspect_colored_components": "Включить цвета компонентов",
|
||||
"cmd.inspect_colored_components_disable": "Отключить цвета компонентов",
|
||||
"cmd.inspect_compare": "Сравнить тела",
|
||||
"cmd.inspect_compare_picture": "Сравнить с изображением",
|
||||
"cmd.inspect_continuity": "Анализ непрерывности",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Анализа доступности",
|
||||
"cmd.inspect_center_of_mass": "Центар масе",
|
||||
"cmd.inspect_clearance": "Провера зазора",
|
||||
"cmd.inspect_colored_components": "Омогући боје компоненти",
|
||||
"cmd.inspect_colored_components_disable": "Онемогући боје компоненти",
|
||||
"cmd.inspect_compare": "Упореди тела",
|
||||
"cmd.inspect_compare_picture": "Упореди слику",
|
||||
"cmd.inspect_continuity": "Анализа континуитета",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Tillgänglighetsanalys",
|
||||
"cmd.inspect_center_of_mass": "Masscentrum",
|
||||
"cmd.inspect_clearance": "Frigång",
|
||||
"cmd.inspect_colored_components": "Aktivera komponentfärger",
|
||||
"cmd.inspect_colored_components_disable": "Inaktivera komponentfärger",
|
||||
"cmd.inspect_compare": "Jämför kroppar",
|
||||
"cmd.inspect_compare_picture": "Jämför bild",
|
||||
"cmd.inspect_continuity": "Kontinuitetsanalys",
|
||||
@@ -1392,7 +1390,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}",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "Erişilebilirlik analizi",
|
||||
"cmd.inspect_center_of_mass": "Kütle merkezi",
|
||||
"cmd.inspect_clearance": "Açıklık",
|
||||
"cmd.inspect_colored_components": "Bileşen Renklerini Etkinleştir",
|
||||
"cmd.inspect_colored_components_disable": "Bileşen Renklerini Devre Dışı Bırak",
|
||||
"cmd.inspect_compare": "Gövdeleri karşılaştır",
|
||||
"cmd.inspect_compare_picture": "Görüntüyle karşılaştır",
|
||||
"cmd.inspect_continuity": "Süreklilik kontrolü",
|
||||
|
||||
@@ -947,8 +947,6 @@
|
||||
"cmd.inspect_accessibility": "Аналіз доступності",
|
||||
"cmd.inspect_center_of_mass": "Центр мас",
|
||||
"cmd.inspect_clearance": "Зазор",
|
||||
"cmd.inspect_colored_components": "Увімкнути кольори компонентів",
|
||||
"cmd.inspect_colored_components_disable": "Вимкнути кольори компонентів",
|
||||
"cmd.inspect_compare": "Порівняти тіла",
|
||||
"cmd.inspect_continuity": "Аналіз неперервності",
|
||||
"cmd.inspect_curvature_comb": "Аналіз гребеня кривизни",
|
||||
|
||||
@@ -963,8 +963,7 @@
|
||||
"cmd.inspect_analysis": "Phân tích khung nhìn",
|
||||
"cmd.inspect_center_of_mass": "Trọng tâm",
|
||||
"cmd.inspect_clearance": "Khoảng cách an toàn",
|
||||
"cmd.inspect_colored_components": "Bật màu cụm chi tiết",
|
||||
"cmd.inspect_colored_components_disable": "Tắt màu cụm chi tiết",
|
||||
"cmd.inspect_colored_components": "Màu cụm chi tiết",
|
||||
"cmd.inspect_compare": "So sánh các thân khối",
|
||||
"cmd.inspect_compare_picture": "So sánh với ảnh tham chiếu",
|
||||
"cmd.inspect_continuity": "Phân tích độ liên tục mặt",
|
||||
@@ -1406,7 +1405,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 +1422,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 +2001,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",
|
||||
@@ -4810,7 +4806,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ả",
|
||||
|
||||
@@ -957,8 +957,6 @@
|
||||
"cmd.inspect_accessibility": "可達性分析",
|
||||
"cmd.inspect_center_of_mass": "質心",
|
||||
"cmd.inspect_clearance": "間隙",
|
||||
"cmd.inspect_colored_components": "啟用元件顏色",
|
||||
"cmd.inspect_colored_components_disable": "停用元件顏色",
|
||||
"cmd.inspect_compare": "比較實體",
|
||||
"cmd.inspect_compare_picture": "與圖片比較",
|
||||
"cmd.inspect_continuity": "連續性分析",
|
||||
|
||||
@@ -963,8 +963,7 @@
|
||||
"cmd.inspect_analysis": "视图分析",
|
||||
"cmd.inspect_center_of_mass": "质心",
|
||||
"cmd.inspect_clearance": "间隙",
|
||||
"cmd.inspect_colored_components": "启用组件颜色",
|
||||
"cmd.inspect_colored_components_disable": "禁用组件颜色",
|
||||
"cmd.inspect_colored_components": "组件颜色",
|
||||
"cmd.inspect_compare": "比较实体",
|
||||
"cmd.inspect_compare_picture": "比较参考图",
|
||||
"cmd.inspect_continuity": "连续性分析",
|
||||
@@ -1406,7 +1405,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 +1422,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 +2001,6 @@
|
||||
"material.selection.mixed": "不同材料",
|
||||
"measure.at": "位置",
|
||||
"measure.item": "项目 {number}",
|
||||
"measure.line_to_centre": "线到圆心",
|
||||
"measure.quick_copied": "已复制测量值",
|
||||
"measure.quick_copy": "点击复制测量值",
|
||||
"measure.quick_edges": "{count} 条边",
|
||||
@@ -4810,7 +4806,6 @@
|
||||
"ui.default_modelling_orientation": "默认建模方向",
|
||||
"ui.delete": "删除",
|
||||
"ui.delete_select_first": "请先选择要删除的内容:特征、实体、组件或草图几何",
|
||||
"ui.dependents_select_first": "请先选择要列出其依赖项的对象",
|
||||
"ui.depth": "深度",
|
||||
"ui.design": "设计",
|
||||
"ui.design_properties.author": "作者",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
| 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 |
|
||||
@@ -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.
|
||||
|
||||
@@ -60,7 +60,7 @@ mod occurrence_tests;
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.cycle_color", "Cycle Component Color", cycle_color)
|
||||
.icon("colored_components").doc_only()
|
||||
.params("component: id or name; saves the next identification colour and enables Component Colors; shared by every instance, separate from appearances"),
|
||||
.params("component: id or name; saves the next identification colour and turns Component Colors on; shared by every instance, separate from appearances"),
|
||||
CommandSpec::new("component.hide_history", "Hide Component History", hide_history)
|
||||
.icon("eye").doc_only()
|
||||
.params("component: id or name; keeps the component's features in the design but leaves them out of the History bar (#10393)"),
|
||||
|
||||
@@ -27,7 +27,7 @@ mod appearance_chain;
|
||||
pub(super) mod appearance_gestures;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("inspect.colored_components", "Enable Component Colors", colored_components)
|
||||
CommandSpec::new("inspect.colored_components", "Component Colors", colored_components)
|
||||
.at("SOLID", "INSPECT").icon("colored_components").noundo()
|
||||
.params("enabled?: bool (omitted: toggle); temporary component colours, without changing appearances"),
|
||||
CommandSpec::new("inspect.measure", "Measure", measure)
|
||||
|
||||
@@ -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;
|
||||
@@ -261,16 +260,12 @@ impl CommandSpec {
|
||||
}
|
||||
pub fn info(&self, s: &Session) -> CommandInfo {
|
||||
let e = (self.enabled)(s);
|
||||
let (label, icon) = match self.id {
|
||||
"inspect.colored_components" if s.colored_components.is_some() => ("Disable Component Colors", "colored_components_off"),
|
||||
_ => (self.label, self.icon),
|
||||
};
|
||||
CommandInfo {
|
||||
id: self.id,
|
||||
label,
|
||||
label: self.label,
|
||||
tab: self.tab,
|
||||
panel: self.panel,
|
||||
icon,
|
||||
icon: self.icon,
|
||||
shortcut: self.shortcut,
|
||||
params: self.params,
|
||||
enabled: e.is_ok(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+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:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -239,7 +239,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 +792,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 +824,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(),
|
||||
@@ -1956,15 +1954,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 +3012,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 +3071,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 +3154,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();
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1152,16 +1152,26 @@ const PERSISTENT_COMMANDS: &[&str] = &[
|
||||
"inspect.zebra",
|
||||
];
|
||||
|
||||
/// Component Colors is a plain on/off display setting: it shows pressed like the effects above,
|
||||
/// but is no status item (nothing to clear with Esc), so it stays out of the list.
|
||||
const COMPONENT_COLORS: &str = "inspect.colored_components";
|
||||
|
||||
fn persistent_command(id: &str) -> bool {
|
||||
PERSISTENT_COMMANDS.contains(&id)
|
||||
PERSISTENT_COMMANDS.contains(&id) || id == COMPONENT_COLORS
|
||||
}
|
||||
|
||||
fn persistent_available(app: &SolveApp, id: &str) -> bool {
|
||||
persistent_command(id) && (id != "inspect.section" || app.preferences.viewport_section_status)
|
||||
}
|
||||
|
||||
fn persistent_pressed(app: &SolveApp, id: &str) -> bool {
|
||||
app.preferences.persistent_ribbon_pressed && persistent_available(app, id) && app.session.status_items().iter().any(|item| item.command == id)
|
||||
pub(crate) fn persistent_pressed(app: &SolveApp, id: &str) -> bool {
|
||||
app.preferences.persistent_ribbon_pressed
|
||||
&& persistent_available(app, id)
|
||||
&& if id == COMPONENT_COLORS {
|
||||
app.session.colored_components.is_some()
|
||||
} else {
|
||||
app.session.status_items().iter().any(|item| item.command == id)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn persistent_button_name(app: &SolveApp, command: &solidcraft_engine::CommandSpec) -> String {
|
||||
@@ -1171,7 +1181,7 @@ pub(crate) fn persistent_button_name(app: &SolveApp, command: &solidcraft_engine
|
||||
}
|
||||
|
||||
fn persistent_hint(app: &SolveApp, command: &solidcraft_engine::CommandSpec, enabled: bool) -> Option<String> {
|
||||
(enabled && persistent_pressed(app, command.id)).then(|| {
|
||||
(enabled && command.id != COMPONENT_COLORS && persistent_pressed(app, command.id)).then(|| {
|
||||
let name = persistent_button_name(app, command);
|
||||
crate::i18n::Locale::for_language(&app.preferences.language).format("ui.viewport_status_clear_hint", &[("name", &name)])
|
||||
})
|
||||
@@ -2608,18 +2618,24 @@ fn dropdown_row(
|
||||
let pin = ui.interact(pr, ui.id().with(("dropdown_pin", c.id)), Sense::click());
|
||||
let pointer_on = (resp.hovered() || pin.hovered()) && !egui::DragAndDrop::has_any_payload(ui.ctx());
|
||||
let label = app.tr_label(info.label).to_string();
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled() && info.enabled, &label));
|
||||
let mut checked = None;
|
||||
if persistent_available(app, c.id) && app.preferences.persistent_ribbon_pressed {
|
||||
checked = Some(persistent_pressed(app, c.id));
|
||||
}
|
||||
if c.id == "view.shots_strip" {
|
||||
checked = Some(app.preferences.render_shots_strip);
|
||||
}
|
||||
// A checkable row announces its on/off state, since its label no longer says it.
|
||||
resp.widget_info(|| match checked {
|
||||
Some(on) => egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled() && info.enabled, on, &label),
|
||||
None => egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled() && info.enabled, &label),
|
||||
});
|
||||
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), rect.center());
|
||||
crate::scenario::publish_handle(&format!("dropdown:{}", c.id), rect.center());
|
||||
let hot = info.enabled && (pointer_on || resp.has_focus());
|
||||
let mut row = crate::ui_kit::MenuItem::new(c.id, &label, info.icon);
|
||||
row.enabled = info.enabled;
|
||||
if persistent_available(app, c.id) && app.preferences.persistent_ribbon_pressed {
|
||||
row.checked = Some(persistent_pressed(app, c.id));
|
||||
}
|
||||
if c.id == "view.shots_strip" {
|
||||
row.checked = Some(app.preferences.render_shots_strip);
|
||||
}
|
||||
row.checked = checked;
|
||||
row.highlighted = hot || revealed.is_some();
|
||||
let shortcut = crate::keymap::display_for(app, c.id).unwrap_or_default();
|
||||
row.shortcut = &shortcut;
|
||||
|
||||
@@ -412,6 +412,12 @@ fn cancelling_theme_preview_restores_viewport_palette_keys() {
|
||||
assert_ne!(keys(&h.app), first);
|
||||
}
|
||||
|
||||
/// The on/off state a screen reader hears for the widget labelled `label`.
|
||||
fn announced_state(h: &crate::scenario::Harness, label: &str) -> Option<egui::accesskit::Toggled> {
|
||||
let tree = h.output.accesskit_update.as_ref().unwrap();
|
||||
tree.nodes.iter().find(|(_, n)| n.label() == Some(label)).and_then(|(_, n)| n.toggled())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_menu_toggles_component_colors_without_a_dialog_and_refreshes_scene() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
@@ -419,26 +425,30 @@ fn inspect_menu_toggles_component_colors_without_a_dialog_and_refreshes_scene()
|
||||
h.frames(3);
|
||||
let command = solidcraft_engine::find_command("inspect.colored_components").unwrap();
|
||||
let info = command.info(&h.app.session);
|
||||
assert_eq!((info.label, info.icon), ("Enable Component Colors", "colored_components"));
|
||||
assert_eq!((info.label, info.icon), ("Component Colors", "colored_components"));
|
||||
h.step(&json!({"click": {"handle": "tab:INSPECT"}})).unwrap();
|
||||
h.frames(2);
|
||||
let original_key = super::scene_key(&h.app);
|
||||
h.step(&json!({"click": {"handle": "panel:INSPECT:CHECK"}})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(h.widget("Enable Component Colors").is_some());
|
||||
assert!(h.widget("Component Colors").is_some());
|
||||
assert!(!crate::toolbar::persistent_pressed(&h.app, "inspect.colored_components"));
|
||||
assert_eq!(announced_state(&h, "Component Colors"), Some(egui::accesskit::Toggled::False));
|
||||
h.step(&json!({"click": {"handle": "dropdown:inspect.colored_components"}})).unwrap();
|
||||
assert!(h.app.session.colored_components.is_some());
|
||||
assert!(h.app.dialog.is_none());
|
||||
assert_ne!(super::scene_key(&h.app), original_key);
|
||||
let info = command.info(&h.app.session);
|
||||
assert_eq!((info.label, info.icon), ("Disable Component Colors", "colored_components_off"));
|
||||
assert_eq!((info.label, info.icon), ("Component Colors", "colored_components"));
|
||||
h.step(&json!({"click": {"handle": "panel:INSPECT:CHECK"}})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(h.widget("Disable Component Colors").is_some());
|
||||
assert!(h.widget("Component Colors").is_some());
|
||||
assert!(crate::toolbar::persistent_pressed(&h.app, "inspect.colored_components"));
|
||||
assert_eq!(announced_state(&h, "Component Colors"), Some(egui::accesskit::Toggled::True));
|
||||
h.step(&json!({"click": {"handle": "dropdown:inspect.colored_components"}})).unwrap();
|
||||
assert!(h.app.session.colored_components.is_none());
|
||||
let info = command.info(&h.app.session);
|
||||
assert_eq!((info.label, info.icon), ("Enable Component Colors", "colored_components"));
|
||||
assert_eq!((info.label, info.icon), ("Component Colors", "colored_components"));
|
||||
}
|
||||
|
||||
/// Every shape the last frame painted in `colour`: point squares, glyph outlines, dimension
|
||||
|
||||
+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
|
||||
|
||||
@@ -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