Compare commits

...

12 Commits

Author SHA1 Message Date
github-actions[bot] 0160e67ea1 Regenerate generated files and fold pending files [skip ci] 2026-11-07 20:15:34 +00:00
Steve Jason c29f396f72 Expressions: floor and ceil of 3 in / 1 in are 3, not 2 (#12489)
* Expressions: floor, ceil and trunc ignore float noise from unit conversion

* Expressions: snap only a few ulps to a whole number in floor and ceil

* Expressions: never snap a tiny value to zero in floor and ceil
2026-11-07 17:38:56 +00:00
Daniel Dimitrov 46edecca67 History: Edit Holes on a folder edits all its holes at once (#12520) (#12521)
A History folder that holds only holes (with the folders inside it) gets
Edit Holes in its right-click menu, in the History bar and the side
History, and over the control channel as ui.editFolder. It opens one
Hole dialog filled in from the first hole, with nothing to pick; OK
redefines each hole with the fields changed there, and each hole keeps
its own place and everything not changed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:38:15 +00:00
Igor Bronovskyi a36c1743dc Ukrainian: translate 523 missing messages and four toolbar labels (#12566)
Fill the gaps in the Ukrainian catalog with a pending locale file, and translate the Appearance, Camera, Environment and Lighting toolbar labels.
2026-11-07 17:38:05 +00:00
PaddleStroke 7b3ca00054 Measure: two flat faces always show the distance between them (#11833) (#12708)
* claim: #11833

* wip: Measure: exact plane-to-plane distance with real ends (#11833)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Measure: datum planes' meeting point from the cross product, exact near parallel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Measure: the 1 and 2 end numbers never cover each other or the distance (#11833)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:37:16 +00:00
PaddleStroke 91a06a546e Sketch: replace a sketch's contents with another sketch, from this design or another file (#11125) (#12712)
* claim: #11125

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* wip: sketch.replace command, tests and Browser menu entry (#11125)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* wip: menu test, formatting (#11125)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* kernel-cases: sketch.replace is 2D sketch only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Replace Sketch: new ids never reuse the old contents' ids; a tool's picks on the replaced sketch are dropped

A reference to an old curve (a profile picked by its curves, a sweep path) now finds nothing and
the feature reports it, instead of quietly binding to a new curve that got the same id. Replacing
the sketch being edited clears the active tool's picks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:36:45 +00:00
PaddleStroke fd02123b3e Sketch: a sketch that lost a projected edge opens for editing with its curves and the lost link marked (#12085) (#12714)
* claim: #12085

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sketch: a sketch that lost a projected edge by name opens for editing with its curves and the lost link marked (#12085)

The sketch still fails and features built from it still fail; while it is
edited, the model holds it built with the lost named link marked lost, in the
final state only, so it can be repaired from inside.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sketch: a failed sketch held for its editor is built again on every evaluation (#12085)

When the sketch and every feature after it fail, the results end in the
same state as before an edit, so the held sketch kept its old geometry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:36:35 +00:00
Mek 13ab728d07 Browser: Edit Feature from a component's Features folder gives the active component back (#12720) (#12723)
* claim: #12720

* Browser: Edit Feature from a component's Features folder gives the active component back (#12720)

A double-click on a feature row inside a placed component activates that
component and opens Edit Feature, so the edit happens in its placement.
OK and Cancel only closed the editor: the component stayed active, every
other body stayed faded and the History bar stayed filtered on it.

The double-click now remembers the component and placement active before
it, and the end of that feature's editor, however it ends, makes that one
active again, as leaving a sketch opened the same way does (#8127). A
component activated while the editor is open stays active.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Mek <mf@linescode.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-07 17:36:25 +00:00
PaddleStroke 4a07865288 Brazilian Portuguese: translate the 3720 messages that still showed in English (#11760) (#12697)
* claim: #11760

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n(pt-BR): translate 1594 missing messages (labels, commands, ui)

Refs #11760

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n(pt-BR): translate 405 missing preference messages

Refs #11760

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n(pt-BR): translate 309 missing command help messages

Refs #11760

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* wip: i18n(pt-BR): translate 600 of 1412 missing render messages

Refs #11760

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* wip: i18n(pt-BR): translate 1000 of 1412 missing render messages

Refs #11760

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n(pt-BR): translate the remaining missing render messages

Refs #11760

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:27:34 +00:00
CamWight b73d6e6e6f Render: the crate's tests compile without the raytrace-cpu feature (#12535) (#12542)
The test helper mapped() in job/host_tests.rs reads a texture's encoded
bytes through TextureMap::source() and TextureSource::encoded_weak(), which
exist only with raytrace-cpu, but it wasn't gated, so the render crate's
tests failed to compile when it was checked on its own. The helper and the
two registered-executor tests that call it now take the cfg the helper's
other callers and its sibling cpu() already have. Workspace builds, which
turn the feature on, run all of them as before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:27:26 +00:00
Cyryl Sochacki 3b2ca62c86 i18n(pl): translate the render workspace and 1290 missing UI messages (#12734)
* i18n(pl): translate the render workspace (1412 messages)

Every render.* message of en.json: output, lighting, environment, stage,
studios, appearance editor, lens effects, aging, shots and the render
browser. Added as one pending locale file.

* i18n(pl): translate 1290 missing UI messages

Everything else en.json gained since the last catch-up: Preferences,
tool help texts, panels, construction lines, fillet options, import
report, personal sheet metal rules, joint limits and recovery. With the
render file, pl covers every message of en.json.

* i18n(pl): "Of" picks a joint's motion, not an object

The nested Of row in the motion study and motion trace dialogs chooses
which of a joint's motions (turn or slide) the track drives.
2026-11-07 17:27:14 +00:00
ashxddaga 24c7bb7d70 fix(doc): permit circular edge centered on axis in axis_point plane construction (#12851) (#12882)
`cmd.construct.plane.axis_point` failed with "the point is on the axis"
when the user selected a circular edge whose center lies on the construction
axis (e.g. rim of a cylinder, revolve, or torus). This occurred because
`point()` resolved `GeoRef::Edge` to its center, which is collinear with
the axis of revolution.

Resolve the edge pick to the nearest point on the edge polyline, projecting
onto the exact circle geometry via `kernel::edge_circle` when available,
enabling unique planes through the axis at any picked azimuth around the rim.

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