Compare commits

..
Author SHA1 Message Date
PaddleStrokeandClaude Opus 5.5 6f5894683c Sketch: a preference to stop a quick double Esc from finishing the sketch (#12069) (#12694)
* claim: #12069

* wip: Sketch preference: a quick double Esc can be kept from finishing the sketch (#12069)

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

* wip: profile key count includes the new sketch preference (#12069)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:08:58 +00:00
PaddleStrokeandClaude Opus 5.5 a6c0e8a959 Parameters: the Filter box finds a name by the starts of its words, so toodia finds tooth_outward_flange_diameter (#11978) (#12695)
* claim: #11978

* wip: Parameters filter and completion match the starts of a name's words (#11978)

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

* wip: describe the Parameters filter's word-start matching in the guide (#11978)

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

* Parameters autocomplete: list word-start matches after substring matches (#11978)

A name matched only by the starts of its words now comes after every name that contains the typed word, so it never pushes an earlier suggestion out of the list.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:08:48 +00:00
PaddleStrokeandClaude Opus 5.5 46438b14a7 Sketch Center Point Arc: a click places each point, typed values stay optional (#12208) (#12696)
* claim: #12208

* wip: Center Point Arc three-click regression test (#12208)

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

* Sketch Center Point Arc: keep the Radius box off the cursor so a click places the start (#12208)

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

* Arc click test: is_dimension is on the constraint's kind

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

* Sketch value boxes: a box that would sit on the pointer moves beside it (#12208)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:08:39 +00:00
PaddleStrokeandClaude Opus 5.5 3900dc742c Insert Mesh: optional move to origin on import (#12543) (#12698)
* claim: #12543

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

* wip: Insert Mesh origin option (#12543)

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

* Insert Mesh: name the Move to origin checkbox's handle mesh_units:origin (#12543)

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

* Insert Mesh origin: measure in the active component's frame, first placement when it has no path (#12543)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:08:23 +00:00
PaddleStrokeandClaude Opus 5.5 6b235ff0d1 German: label the Look At view button "Senkrecht auf" instead of "Ansehen" (#12494) (#12699)
* claim: #12494

* German: call Look At "Senkrecht auf" instead of "Ansehen" (#12494)

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

* German: the navigation bar's Look At is "Senkrecht auf" too (#12494)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:08:14 +00:00
PaddleStrokeandClaude Opus 5.5 4774efe1ee Sketch Dimension: the value box stays clear of the mouse pointer, also when it is tall or the view is small (#11057) (#12711)
* claim: #11057

* Sketch Dimension: the value box stays clear of the pointer when tall or in a small view (#11057)

Place the type-first value box by its drawn size and try the four corners around
the pointer; where none fits, keep the 30 px gap on one axis and slide along the
other, instead of clamping both axes back over the pointer.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:06:59 +00:00
PaddleStrokeandClaude Opus 5.5 4f687454fc Canvas: lock a reference image so it cannot be moved or deleted by mistake while sketching (#12310) (#12713)
* claim: #12310

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

* wip: canvas lock (#12310)

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

* wip: canvas lock: badge binding, save check on the value (#12310)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:06:42 +00:00
PaddleStrokeandClaude Opus 5.5 7f257866d9 Sketch: test that a line starts on projected edges, face edges and in construction mode (#11369) (#12715)
* claim: #11369

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

* wip: test a Line started on projected geometry, face edges and in construction mode (#11369)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:06:24 +00:00
PaddleStrokeandClaude Opus 5.5 feeac232f0 Sketch Mirror: a circle clicked after the window opens is mirrored too (#11700) (#12747)
* Sketch Mirror: a curve clicked while the mirror line is active joins the entities (#11700)

With entities preselected, the Mirror window opens on the empty Mirror line field.
A circle clicked then was refused without a word, so it was left out of the mirror.
The commands whose first input is the shared Entities list now take such a pick
there and stay on the input they were on.

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

* Sketch modify: candidate stays the active input's pick; clicks fall back to Entities

sketch_modify_ui::candidate again answers only for the active input, so the
Mirror line still refuses curved and external references. The Entities fallback
for a click moved to pick_candidate, which Dialog::candidate_on uses.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:05:18 +00:00
PaddleStrokeandClaude Opus 5.5 4f77836834 Sketch: a test that Symmetry about the origin point accepts two corners of the same rectangle edge (#4850) (#12772)
* claim: #4850

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

* Sketch: test Symmetry of two corners of one rectangle edge about the origin (#4850)

Already fixed on main by #4626; this guards it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:05:01 +00:00
CaRLymxandcarlymx af21f2aa13 Spanish (es-ES): catch up with en.json (2026-11-07) (#12792)
* i18n(es): catch-up con en.json del 2026-11-07 (507 nuevas, 23 cambiadas)

Catch-up with `en.json` at `871014f95`.

* i18n(es): el título REST es Apoyo, como la orden y la ayuda

---------

Co-authored-by: carlymx <24634598+carlymx@users.noreply.github.com>
2026-11-07 17:02:44 +00:00
bherbruckandClaude Opus 5.5 142285bff3 Docs: remove the browser runtime design note, fix the web app's share link (#13086)
* docs: drop CollabCraft and craft-cloud from the browser runtime design

SolidCraft's cloud Open/Save is its own design. Removes the CollabCraft,
craft.quasarke.net, craft-cloud and craft-nextcloud references added in #1258.

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

* docs: remove the browser runtime design note

docs/web-runtime.md described crates that do not exist in SolidCraft
(craft-runtime, craft-tier-store, craft-worker-host, craft-gpu) as its plan.
Removes the note and the links to it, and the craft-libs section in plugins.md.

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

* web: share previews point at app.solidcraft.io

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 16:57:39 +00:00
Nathan Dane ea4513728b Panels: keep text readable on translucent surfaces (#11307) (#12985)
* claim: #11307

* Panels: preserve readable text on translucent surfaces

* Panels: keep surface snapshots small and color math in theme
2026-11-07 14:35:56 +00:00
81 changed files with 2952 additions and 550 deletions

No files matched your search

Generated
+2 -3
View File
@@ -4051,16 +4051,15 @@ dependencies = [
[[package]]
name = "solidcore"
version = "1.0.1"
source = "git+https://github.com/tangency-labs/solidcore?rev=99ed57c4d4f4cd77d942ac6cd2e4d09f612acb20#99ed57c4d4f4cd77d942ac6cd2e4d09f612acb20"
source = "git+https://github.com/tangency-labs/solidcore?rev=8ddce901671f1cebd9214a31d21895d725e886c8#8ddce901671f1cebd9214a31d21895d725e886c8"
dependencies = [
"serde",
"thiserror 2.0.21",
]
[[package]]
name = "solidcore-record"
version = "0.1.0"
source = "git+https://github.com/tangency-labs/solidcore?rev=99ed57c4d4f4cd77d942ac6cd2e4d09f612acb20#99ed57c4d4f4cd77d942ac6cd2e4d09f612acb20"
source = "git+https://github.com/tangency-labs/solidcore?rev=f5e8e9a29e0c9b7cf6c4040a5bc8878382b3c62f#f5e8e9a29e0c9b7cf6c4040a5bc8878382b3c62f"
dependencies = [
"serde",
"serde_json",
+2 -2
View File
@@ -36,10 +36,10 @@ solidcraft-3dx-client = { path = "crates/threedx-client" }
# move the rev on its own commit. For a
# local checkout, add `[patch."https://github.com/tangency-labs/solidcore"] solidcore = { path =
# "../solidcore/crates/solidcore" }` to a `.cargo/config.toml` outside the repository.
solidcore = { git = "https://github.com/tangency-labs/solidcore", rev = "99ed57c4d4f4cd77d942ac6cd2e4d09f612acb20" }
solidcore = { git = "https://github.com/tangency-labs/solidcore", rev = "8ddce901671f1cebd9214a31d21895d725e886c8" }
# Geometry as data (solidcore's layers §1, "Record"): the B-rep record, its structural validation
# and the native body file. Pinned like solidcore; for a local checkout, patch it as above.
solidcore-record = { git = "https://github.com/tangency-labs/solidcore", rev = "99ed57c4d4f4cd77d942ac6cd2e4d09f612acb20" }
solidcore-record = { git = "https://github.com/tangency-labs/solidcore", rev = "f5e8e9a29e0c9b7cf6c4040a5bc8878382b3c62f" }
# test tooling: a dev-dependency only (xtask/src/layers.rs)
solidcraft-testkit = { path = "crates/testkit" }
+2 -2
View File
@@ -318,7 +318,7 @@ fn a_model_saved_under_another_check_mode_is_evaluated_again() {
/// opened from its model or evaluated anew. Two edges of a 20 mm box rounded r2 (a mitre at the
/// corner (20, 0, 20)), saved; opened, the upright edge there rounded r3: the corner all three
/// rounded at once make (SMLib's setback corner, kernel `three_rounds_at_a_corner`), 15 faces and
/// 7927.72428251348 mm³, the same body as the design evaluated anew.
/// 7928.142563938 mm³, the same body as the design evaluated anew.
#[test]
fn a_later_round_after_opening_blends_the_saved_rounds_again() {
let mut built = Session::default();
@@ -336,7 +336,7 @@ fn a_later_round_after_opening_blends_the_saved_rounds_again() {
let b = &st.body("Box").unwrap().body;
let v = solidcraft_engine::kernel::measure(b).unwrap().volume;
assert_eq!(b.face_count(), 15);
assert!((v - 7927.72428251348).abs() < 1e-6, "{v}"); // solidcore#2382's setback corner
assert!((v - 7928.142563938).abs() < 1e-6, "{v}");
}
assert_eq!(model("later round", &cached), model("later round", &rebuilt));
let _ = std::fs::remove_file(&path);
+1 -1
View File
@@ -9,7 +9,7 @@
<meta property="og:site_name" content="SolidCraft">
<meta property="og:title" content="SolidCraft: try it in your browser">
<meta property="og:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
<meta property="og:url" content="https://solidcraft.ai/">
<meta property="og:url" content="https://app.solidcraft.io/">
<meta property="og:image" content="https://docs.solidcraft.io/og.png">
<meta property="og:image:width" content="1280">
<meta property="og:image:height" content="640">
+7 -7
View File
@@ -264,8 +264,8 @@
"Flat size": "Abwicklungsgröße",
"Flat size X": "Abwicklungsmaß X",
"Flat size Y": "Abwicklungsmaß Y",
"Follow camera (Look At sketches)": "Kamera folgen (Skizzen ansehen)",
"Follow the camera (Look At sketches)": "Der Kamera folgen (Skizzen ansehen)",
"Follow camera (Look At sketches)": "Kamera folgen (senkrecht auf Skizzen)",
"Follow the camera (Look At sketches)": "Der Kamera folgen (senkrecht auf Skizzen)",
"Font": "Schriftart",
"Free Move": "Frei verschieben",
"From": "Von",
@@ -2147,7 +2147,7 @@
"navbar.ground_shadow": "Bodenschatten",
"navbar.home": "Start",
"navbar.later": "Später",
"navbar.look_at": "Ansehen",
"navbar.look_at": "Senkrecht auf",
"navbar.more": "Mehr",
"navbar.orbit": "Drehen",
"navbar.orthographic": "Orthografisch",
@@ -2646,7 +2646,7 @@
"prefs.sketch_face_activates_part_note": "Eine Skizze, die auf einer Fläche am Körper einer anderen Komponente begonnen wird, aktiviert zuerst diese Komponente, sodass die Skizze zu ihr gehört und im Browser bei diesem Teil bleibt. Das Teil bleibt danach aktiv. Aus: Die Skizze gehört zur aktiven Komponente.",
"prefs.sketch_mirror_auto_line": "Vorausgewählte Konstruktionslinie zum Spiegeln verwenden",
"prefs.sketch_open_look_at": "Beim Öffnen einer Skizze auf sie ausrichten",
"prefs.sketch_open_look_at_note": "Aus: Beim Erstellen oder Bearbeiten einer Skizze bleibt die Ansicht, wie sie ist. „Ansehen“ richtet die Ansicht bei Bedarf auf die Skizze aus.",
"prefs.sketch_open_look_at_note": "Aus: Beim Erstellen oder Bearbeiten einer Skizze bleibt die Ansicht, wie sie ist. „Senkrecht auf“ richtet die Ansicht bei Bedarf auf die Skizze aus.",
"prefs.sketch_recent_snap": "Automatisch an zuletzt verwendete Mittelpunkte fangen",
"prefs.sketch_recent_snap_note": "Fängt den Zeiger beim Zeichnen dort ein, wo sich die Hilfslinien der beiden zuletzt verwendeten Kreismittelpunkte oder Kantenmitten kreuzen. Aus: die Hilfslinien bleiben sichtbar und der Zeiger bewegt sich nicht.",
"prefs.sketch_selection": "Skizzen und Auswahl",
@@ -5333,8 +5333,8 @@
"ui.link_copies_text": "Diese Komponenten erzeugen dieselben Körper wie {name}. Sollen sie zu Platzierungen seiner Verknüpfung werden?",
"ui.lock": "Sperren",
"ui.loft": "Loft",
"ui.look_at": "Ansehen",
"ui.look_at_anim_desc": "Achsenansicht, Ansehen, Home und Einpassen",
"ui.look_at": "Senkrecht auf",
"ui.look_at_anim_desc": "Achsenansicht, Senkrecht auf, Home und Einpassen",
"ui.major_radius": "Großer Radius",
"ui.manipulator_free_alt": "Freies Ziehen des Manipulators auch mit Alt/Option",
"ui.manipulator_free_alt_tip": "An: Alt/Option oder Strg/Cmd auf Extrusions-/Gizmo-Pfeilen deaktiviert das Einrasten von Werten. Aus: nur Strg/Cmd.",
@@ -5790,7 +5790,7 @@
"ui.unsuppress": "Unterdrückung aufheben",
"ui.untied_ends_tip": "Berührt nur nach Position",
"ui.untitled": "Ohne Titel",
"ui.up_axis_note": "Welche Achse in den Ansichten nach oben zeigt (Achsenansicht, Home, Ansehen). Designs bleiben unverändert.",
"ui.up_axis_note": "Welche Achse in den Ansichten nach oben zeigt (Achsenansicht, Home, Senkrecht auf). Designs bleiben unverändert.",
"ui.updateToCurrentView": "Auf aktuelle Ansicht aktualisieren",
"ui.upload": "Hochladen",
"ui.vertical_constraint": "Vertikale Bedingung",
+522 -15
View File
File diff suppressed because it is too large. Load diff
+10
View File
@@ -0,0 +1,10 @@
{
"en": { "browser.canvas_locked": "The canvas is locked. Unlock it first." },
"ar": { "browser.canvas_locked": "اللوحة مقفلة. ألغِ قفلها أولاً." },
"de": { "browser.canvas_locked": "Die Leinwand ist gesperrt. Entsperren Sie sie zuerst." },
"he": { "browser.canvas_locked": "הקנבס נעול. בטל את הנעילה תחילה." },
"ja": { "browser.canvas_locked": "キャンバスはロックされています。先にロックを解除してください。" },
"ko": { "browser.canvas_locked": "캔버스가 잠겨 있습니다. 먼저 잠금을 해제하세요." },
"vi": { "browser.canvas_locked": "Canvas đang bị khóa. Hãy mở khóa trước." },
"zh": { "browser.canvas_locked": "画布已锁定。请先解锁。" }
}
@@ -0,0 +1,38 @@
{
"en": {
"prefs.sketch.escape_twice_finishes": "Double Esc finishes the sketch",
"prefs.sketch.escape_twice_finishes_note": "Two quick presses of Esc end the sketch. Off: Esc only cancels the tool or the selection, and Finish Sketch ends the sketch."
},
"ar": {
"prefs.sketch.escape_twice_finishes": "الضغط المزدوج على Esc ينهي الرسم التخطيطي",
"prefs.sketch.escape_twice_finishes_note": "ضغطتان سريعتان على Esc تنهيان الرسم التخطيطي. عند الإيقاف: يلغي Esc الأداة أو التحديد فقط، وينهي أمر «إنهاء الرسم» الرسم التخطيطي."
},
"de": {
"prefs.sketch.escape_twice_finishes": "Doppeltes Esc beendet die Skizze",
"prefs.sketch.escape_twice_finishes_note": "Zweimal schnell Esc drücken beendet die Skizze. Aus: Esc bricht nur das Werkzeug oder die Auswahl ab, und Skizze beenden schließt die Skizze."
},
"fr": {
"prefs.sketch.escape_twice_finishes": "Un double Échap termine l'esquisse",
"prefs.sketch.escape_twice_finishes_note": "Deux pressions rapides sur Échap terminent l'esquisse. Désactivé : Échap annule seulement l'outil ou la sélection, et Terminer l'esquisse ferme l'esquisse."
},
"he": {
"prefs.sketch.escape_twice_finishes": "לחיצה כפולה על Esc מסיימת את הסקיצה",
"prefs.sketch.escape_twice_finishes_note": "שתי לחיצות מהירות על Esc מסיימות את הסקיצה. כבוי: Esc רק מבטל את הכלי או את הבחירה, ו'סיום סקיצה' מסיים את הסקיצה."
},
"ja": {
"prefs.sketch.escape_twice_finishes": "Esc を 2 回押してスケッチを終了",
"prefs.sketch.escape_twice_finishes_note": "Esc をすばやく 2 回押すとスケッチを終了します。オフ: Esc はツールや選択の取り消しだけを行い、スケッチは「スケッチを終了」で終了します。"
},
"ko": {
"prefs.sketch.escape_twice_finishes": "Esc를 두 번 누르면 스케치 종료",
"prefs.sketch.escape_twice_finishes_note": "Esc를 빠르게 두 번 누르면 스케치를 끝냅니다. 끄면: Esc는 도구나 선택만 취소하고, 스케치는 스케치 종료로 끝냅니다."
},
"vi": {
"prefs.sketch.escape_twice_finishes": "Nhấn Esc hai lần để kết thúc bản phác thảo",
"prefs.sketch.escape_twice_finishes_note": "Nhấn nhanh Esc hai lần sẽ kết thúc bản phác thảo. Tắt: Esc chỉ hủy công cụ hoặc vùng chọn, và Hoàn tất phác thảo sẽ kết thúc bản phác thảo."
},
"zh": {
"prefs.sketch.escape_twice_finishes": "连按两次 Esc 结束草图",
"prefs.sketch.escape_twice_finishes_note": "快速按两次 Esc 会结束草图。关闭时:Esc 只取消工具或选择,用“完成草图”结束草图。"
}
}
@@ -0,0 +1,53 @@
{
"en": {
"ui.mesh_import_origin": "Move to origin",
"ui.mesh_import_origin_tip": "Center the mesh on X and Y with its base on Z = 0. When off, the mesh keeps the coordinates stored in the file."
},
"en-GB": {
"ui.mesh_import_origin_tip": "Centre the mesh on X and Y with its base on Z = 0. When off, the mesh keeps the coordinates stored in the file."
},
"ar": {
"ui.mesh_import_origin": "النقل إلى نقطة الأصل",
"ui.mesh_import_origin_tip": "توسيط الشبكة على المحورين X وY ووضع قاعدتها على Z = 0. عند إيقافه تحتفظ الشبكة بالإحداثيات المخزنة في الملف."
},
"de": {
"ui.mesh_import_origin": "In den Ursprung verschieben",
"ui.mesh_import_origin_tip": "Das Netz in X und Y zentrieren und seine Unterseite auf Z = 0 legen. Wenn aus, behält das Netz die Koordinaten aus der Datei."
},
"es": {
"ui.mesh_import_origin": "Mover al origen",
"ui.mesh_import_origin_tip": "Centra la malla en X e Y con su base en Z = 0. Si está desactivado, la malla conserva las coordenadas guardadas en el archivo."
},
"fr": {
"ui.mesh_import_origin": "Déplacer à l'origine",
"ui.mesh_import_origin_tip": "Centre le maillage en X et Y et pose sa base sur Z = 0. Désactivé, le maillage garde les coordonnées enregistrées dans le fichier."
},
"he": {
"ui.mesh_import_origin": "הזזה לראשית",
"ui.mesh_import_origin_tip": "מרכוז הרשת ב-X וב-Y והנחת הבסיס שלה על Z = 0. כשהאפשרות כבויה, הרשת שומרת על הקואורדינטות שבקובץ."
},
"it": {
"ui.mesh_import_origin": "Sposta all'origine",
"ui.mesh_import_origin_tip": "Centra la mesh in X e Y con la base su Z = 0. Se disattivato, la mesh mantiene le coordinate salvate nel file."
},
"ja": {
"ui.mesh_import_origin": "原点へ移動",
"ui.mesh_import_origin_tip": "メッシュを X と Y の中心に置き、底面を Z = 0 に合わせます。オフの場合、ファイルに保存された座標のままになります。"
},
"ko": {
"ui.mesh_import_origin": "원점으로 이동",
"ui.mesh_import_origin_tip": "메시를 X와 Y의 중앙에 놓고 바닥을 Z = 0에 맞춥니다. 끄면 파일에 저장된 좌표를 그대로 유지합니다."
},
"pt-BR": {
"ui.mesh_import_origin": "Mover para a origem",
"ui.mesh_import_origin_tip": "Centraliza a malha em X e Y com a base em Z = 0. Desligado, a malha mantém as coordenadas salvas no arquivo."
},
"vi": {
"ui.mesh_import_origin": "Di chuyển về gốc tọa độ",
"ui.mesh_import_origin_tip": "Căn giữa lưới theo X và Y, đặt đáy của nó tại Z = 0. Khi tắt, lưới giữ nguyên tọa độ lưu trong tệp."
},
"zh": {
"ui.mesh_import_origin": "移动到原点",
"ui.mesh_import_origin_tip": "将网格在 X 和 Y 方向居中,并将底面置于 Z = 0。关闭时,网格保留文件中存储的坐标。"
}
}
+4
View File
@@ -37,6 +37,10 @@ pub struct Canvas {
pub flip: bool,
#[serde(default = "yes")]
pub visible: bool,
/// Locked (#12310): its place, size, angle and flip stay as they are, and it is not deleted,
/// until it is unlocked. Its opacity, visibility and name can still change.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub locked: bool,
/// The component it belongs to (its plane is in that component's frame, so it moves with
/// the component's occurrence); 0 = the root.
#[serde(default, skip_serializing_if = "is_root")]
@@ -0,0 +1,4 @@
//! A canvas can be locked (#12310): `locked: true` on the canvas keeps its place, size, angle and
//! flip, and keeps it from being deleted, until it is unlocked. Older designs need no
//! transformation: without the field a canvas is unlocked, as before. The last release warns about
//! the newer format number and opens the design with its canvases unlocked.
+1 -1
View File
@@ -102,7 +102,7 @@ pub use eval::{
pub use eval::{EvalCache, KeptResults};
pub use expr::Expr;
pub use names::{BodyName, FeatureId, FeatureName, SketchId, copy_name};
pub use params::{ParamRow, ParamUser, cycles};
pub use params::{ParamRow, ParamUser, cycles, name_words_match};
pub use points::{Dir, FacePick, Position, plain};
pub use solidcraft_kernel as kernel;
pub use solidcraft_sketch as sketch;
+114
View File
@@ -1150,6 +1150,76 @@ fn csv_fields(record: &str) -> Result<Vec<String>> {
.collect()
}
/// Whether `query` spells the starts of some words of `name`, in order, ignoring case: for
/// `tooth_outward_flange_diameter`, `toodia`, `toofla`, `outdia` and `toofladia` all match
/// (#11978). Words may be skipped but not reordered. Underscores, hyphens, spaces and other
/// separators split `name` into words, and so do camelCase and letter-digit boundaries.
/// Underscores, hyphens and spaces in `query` are ignored. A query with any other symbol (an
/// expression such as `width *`) or with no letters or digits never matches here. This only adds
/// to a plain substring search: callers keep that one too.
pub fn name_words_match(name: &str, query: &str) -> bool {
if query.chars().any(|c| !(c.is_alphanumeric() || matches!(c, '_' | '-' | ' '))) {
return false;
}
let query: Vec<char> = query.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect();
let words = name_words(name);
// Every query character must come from some word, so a longer query can never match;
// this also bounds the work below by the name's length.
if query.is_empty() || query.len() > words.iter().map(Vec::len).sum::<usize>() {
return false;
}
// `reached[i]`: the first `i` query characters are spelled by word starts seen so far.
let mut reached = vec![false; query.len() + 1];
if let Some(first) = reached.first_mut() {
*first = true;
}
for word in &words {
// From the highest start down, so each word extends a match at most once.
for start in (0..query.len()).rev() {
if !reached.get(start).copied().unwrap_or(false) {
continue;
}
let rest = query.get(start..).unwrap_or_default();
for (k, _) in word.iter().zip(rest).take_while(|(a, b)| a == b).enumerate() {
if let Some(r) = reached.get_mut(start + k + 1) {
*r = true;
}
}
}
if reached.last().copied().unwrap_or(false) {
return true;
}
}
false
}
/// The words of a parameter name, in lower case: see [`name_words_match`].
fn name_words(name: &str) -> Vec<Vec<char>> {
let mut words = Vec::new();
let mut word: Vec<char> = Vec::new();
let mut previous: Option<char> = None;
for c in name.chars() {
let split = match previous {
_ if !c.is_alphanumeric() => true,
Some(p) => (p.is_lowercase() && c.is_uppercase()) || p.is_numeric() != c.is_numeric(),
None => false,
};
if split && !word.is_empty() {
words.push(std::mem::take(&mut word));
}
if c.is_alphanumeric() {
word.extend(c.to_lowercase());
previous = Some(c);
} else {
previous = None;
}
}
if !word.is_empty() {
words.push(word);
}
words
}
/// Parameters whose definitions depend on each other in a loop.
pub fn cycles(list: &[(String, String, Kind)]) -> BTreeSet<String> {
let refs: BTreeMap<&str, Vec<String>> = list.iter().map(|(n, e, _)| (n.as_str(), expr::references(e))).collect();
@@ -1236,3 +1306,47 @@ mod design_length_tests {
}
}
}
#[cfg(test)]
mod name_words_tests {
use super::name_words_match;
#[test]
fn the_starts_of_words_in_order_find_a_long_name() {
let name = "tooth_outward_flange_diameter";
for query in ["too", "dia", "toodia", "toofla", "outdia", "toofladia", "TooDia", "too_dia", "tooth outward", "toothoutwardflangediameter"] {
assert!(name_words_match(name, query), "{query}");
}
// Out of order, from inside a word, more than the words hold, or not a name at all.
for query in ["diatoo", "ooth", "toodiax", "toox", "", "_", "- ", "tooth *", "too.dia"] {
assert!(!name_words_match(name, query), "{query}");
}
}
#[test]
fn camel_case_hyphens_and_digits_split_words() {
assert!(name_words_match("boltHoleDepth", "bhd"));
assert!(name_words_match("boltHoleDepth", "holedep"));
assert!(name_words_match("rib-count-max", "rcm"));
assert!(name_words_match("hole2_depth", "h2d"));
assert!(name_words_match("Länge_Außen", "läau"));
assert!(!name_words_match("boltholedepth", "bhd"));
}
#[test]
fn a_later_word_can_take_over_part_of_a_match() {
// `ab` must not be taken whole from `abc` when the match needs `a` and then `bd...`.
assert!(name_words_match("abc_bd", "abd"));
assert!(name_words_match("aa_ab", "aab"));
}
#[test]
fn hostile_names_and_queries_never_panic() {
let long = "a_".repeat(5_000);
assert!(name_words_match(&long, &"a".repeat(5_000)));
assert!(!name_words_match(&long, &"a".repeat(5_001)));
for (name, query) in [("", "a"), ("\u{0}", "\u{0}"), ("İ", "i"), ("ß", "ss"), ("🙂_x", "x"), ("x", "🙂")] {
let _ = name_words_match(name, query);
}
}
}
+1 -51
View File
@@ -1627,57 +1627,7 @@ pub(crate) fn section(plane: &Plane, m: &Mesh, face: Option<u32>) -> Vec<LinkGeo
let tight = size * 1e-4;
let checkable = exact.len().saturating_mul(close.len()) <= 4_000_000;
let strays = |v: &[LinkGeom]| exact.iter().any(|p| !v.iter().any(|g| plain_distance(g, *p).is_none_or(|d| d <= tight)));
let fitted = if plain(&close) && (close.len() <= lossy.len() || (checkable && strays(&lossy) && !strays(&close))) { close } else { lossy };
join_rounds(fitted, size * 1e-5)
}
/// Where the fit left a line and an arc ends apart by at most `tol`, both end at one point. A
/// line can swallow an arc's first chord (a mesh crossing just past the tangent line), and
/// [`refine_round`] moves an arc's ends but not the lines': the outline would not close.
fn join_rounds(mut fitted: Vec<LinkGeom>, tol: f64) -> Vec<LinkGeom> {
for i in 0..fitted.len() {
let Some(&LinkGeom::Arc { c, mut a, mut b }) = fitted.get(i) else { continue };
let r = c.dist(a);
for end in [&mut a, &mut b] {
for curve in &mut fitted {
let LinkGeom::Line(p, q) = curve else { continue };
let (near, far) = if p.dist(*end) <= q.dist(*end) { (p, q) } else { (q, p) };
if near.dist(*end) <= tol {
let joint = line_meets_round(*far, *near, c, r, tol);
*near = joint;
*end = joint;
}
}
}
if let Some(arc) = fitted.get_mut(i) {
*arc = LinkGeom::Arc { c, a, b };
}
}
fitted
}
/// Where a line from `far` ending near `near` meets the circle (`c`, `r`): the tangent point when
/// the line runs into the circle there, within `tol` of it over the arc's first chord, else its
/// crossing nearest `near`, else the circle's point nearest `near`.
fn line_meets_round(far: Vec2, near: Vec2, c: Vec2, r: f64, tol: f64) -> Vec2 {
let nearest = |[x, y]: [Vec2; 2]| if x.dist(near) <= y.dist(near) { x } else { y };
let to_centre = c - far;
let span = to_centre.len();
if let Some(unit) = to_centre.normalized().filter(|_| span > r) {
let reach = (span * span - r * r).sqrt();
let (along, side) = (unit * (reach * reach / span), unit.perp() * (reach * r / span));
let tangent = nearest([far + along + side, far + along - side]);
if tangent.dist(near) <= tol + (2.0 * r * tol).sqrt() {
return tangent;
}
}
let Some(dir) = (near - far).normalized() else { return near };
let foot = far + dir * (c - far).dot(dir);
let half = r * r - (foot - c).len2();
if half >= 0.0 {
return nearest([foot + dir * half.sqrt(), foot - dir * half.sqrt()]);
}
(near - c).normalized().map_or(near, |unit| c + unit * r)
if plain(&close) && (close.len() <= lossy.len() || (checkable && strays(&lossy) && !strays(&close))) { close } else { lossy }
}
/// How far `p` lies from a line, arc or circle; None for any other curve.
+5
View File
@@ -211,6 +211,11 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
}
}
for k in keys(p, "canvases") {
// A locked canvas stays (#12310), as a locked body does.
if let Some(c) = s.doc.canvases.iter().find(|c| c.id.to_string() == k || c.name == k).filter(|c| c.locked) {
skip(super::sketch_canvas::locked_reason(c));
continue;
}
sub(s, "canvas.delete", json!({ "canvas": k }))?;
done = true;
}
+38 -6
View File
@@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("open")
.key("Ctrl+O")
.noundo()
.params("path: .sc design (or a .solidcraft design from an earlier release), .dxf/.svg drawing (opens as a new, unsaved design with an XY sketch, using sketch.insert_dxf/sketch.insert_svg units and defaults), or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make), or a .ply/.xyz/.pts point cloud (opens as a new design holding it, as pointcloud.insert makes; its `units` and `max_points` as there); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); units?: for an STL or OBJ file (see file.insert_mesh); folder?: bool for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh); newer_file: {format, reads_up_to, unknown_changes, not_understood, notice} and warnings when a newer SolidCraft saved it: it opens all the same, and what this version can't read is named there and kept by saving"),
.params("path: .sc design (or a .solidcraft design from an earlier release), .dxf/.svg drawing (opens as a new, unsaved design with an XY sketch, using sketch.insert_dxf/sketch.insert_svg units and defaults), or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make), or a .ply/.xyz/.pts point cloud (opens as a new design holding it, as pointcloud.insert makes; its `units` and `max_points` as there); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); units?: for an STL or OBJ file (see file.insert_mesh); origin?: bool for a mesh file (see file.insert_mesh); folder?: bool for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh); newer_file: {format, reads_up_to, unknown_changes, not_understood, notice} and warnings when a newer SolidCraft saved it: it opens all the same, and what this version can't read is named there and kept by saving"),
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
.at("SOLID", "INSERT")
.icon("import")
@@ -39,7 +39,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
.at("SOLID", "INSERT")
.icon("import")
.params("path: .3mf, .stl, .obj, .ply (with faces), .glb or .gltf file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence; a glTF file's node tree becomes nested components held as one rigid group until component.unlock, a mesh placed again another occurrence, its materials the bodies' appearances, and its metres millimetres); name?; up?: z|y for a glTF file (default y: glTF has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence; z: the file's axes are kept); units?: mm|cm|m|in|ft for an STL or OBJ file, or a PLY file with faces (default mm): the unit its coordinates are in, scaled to millimetres as the file is read (a 3MF or glTF file states its own). The design keeps the millimetre mesh and not the unit, so insert the file again to try another"),
.params("path: .3mf, .stl, .obj, .ply (with faces), .glb or .gltf file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence; a glTF file's node tree becomes nested components held as one rigid group until component.unlock, a mesh placed again another occurrence, its materials the bodies' appearances, and its metres millimetres); name?; up?: z|y for a glTF file (default y: glTF has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence; z: the file's axes are kept); units?: mm|cm|m|in|ft for an STL or OBJ file, or a PLY file with faces (default mm): the unit its coordinates are in, scaled to millimetres as the file is read (a 3MF or glTF file states its own). The design keeps the millimetre mesh and not the unit, so insert the file again to try another; origin?: bool (default false: the mesh keeps its file's coordinates; true: once read, scaled and stood up, the import's occurrence is moved so the box around its bodies is centred on X and Y of the active component with its lowest point on Z = 0 → moved_to_origin: [x, y, z], the shift made)"),
CommandSpec::new("file.save", "Save", save)
.icon("save")
.key("Ctrl+S")
@@ -262,7 +262,7 @@ fn created_bodies(s: &Session, feature: u64) -> Vec<String> {
/// Add a MeshImport feature for a 3MF, STL, OBJ or glTF file, in a new component named after the
/// file. A glTF file's nodes become components in it, and its materials the bodies' looks; it is
/// in metres with Y up, so unless `y_up` is false its component is turned to Z up (#1505).
fn add_mesh(s: &mut Session, path: &str, name: Option<&str>, y_up: Option<bool>, unit: Option<(&'static str, f64)>) -> Result<Value> {
fn add_mesh(s: &mut Session, path: &str, name: Option<&str>, y_up: Option<bool>, unit: Option<(&'static str, f64)>, origin: bool) -> Result<Value> {
let err = |e: solidcraft_io::IoError| EngineError::Other(format!("{path}: {e}"));
let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_3MF_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let gltf = solidcraft_io::is_gltf_path(path);
@@ -296,9 +296,14 @@ fn add_mesh(s: &mut Session, path: &str, name: Option<&str>, y_up: Option<bool>,
s.refresh();
}
let turned = if gltf && y_up.unwrap_or(true) { turn_up(s, &placed)? } else { None };
// After the turn, so it is the stood-up mesh that sits on Z = 0.
let moved = if origin { import::move_to_origin(s, &placed)? } else { None };
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
let mut out = json!({"path": path, "feature": id, "name": fname, "bodies": bodies, "created_bodies": bodies, "warnings": mf.warnings});
placed.report(&mut out);
if let Some(shift) = moved {
out["moved_to_origin"] = json!([shift.x, shift.y, shift.z]);
}
if let Some((name, _)) = unit {
out["units"] = json!(name);
}
@@ -360,7 +365,20 @@ fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
}
let up = mesh_up(p, path, cmd)?;
let unit = mesh_units(p, path, cmd)?;
add_mesh(s, path, str_(p, "name"), up, unit)
add_mesh(s, path, str_(p, "name"), up, unit, mesh_origin(p, cmd)?)
}
/// `origin`: whether a mesh import is moved to the origin (#12543). Off unless asked, so a mesh
/// keeps the coordinates its file gives it.
fn mesh_origin(p: &Value, cmd: &str) -> Result<bool> {
match p.get("origin") {
None | Some(Value::Null) => Ok(false),
Some(Value::Bool(origin)) => Ok(*origin),
Some(_) => Err(bad(
cmd,
"`origin` must be true (the mesh is moved so its box is centred on X and Y with its base on Z = 0) or false (the file's coordinates are kept)",
)),
}
}
fn insert_step(s: &mut Session, p: &Value) -> Result<Value> {
@@ -398,6 +416,9 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
if !solidcraft_io::is_step_path(path) && !solidcraft_io::is_gltf_path(path) && p.get("up").is_some_and(|v| !v.is_null()) {
return Err(bad("doc.open", "`up` applies to STEP, IGES and glTF files only"));
}
if !solidcraft_io::is_mesh_path(path) && p.get("origin").is_some_and(|v| !v.is_null()) {
return Err(bad("doc.open", "`origin` applies to 3MF, STL, OBJ and glTF files only"));
}
if let Some(command) = super::drawing_import_command(path) {
let stem = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled".into());
let mut fresh = Session::new(Document::new(&stem));
@@ -437,7 +458,7 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
let r = if solidcraft_io::is_step_path(path) {
add_step(&mut fresh, path, None, &step_options(p, "doc.open")?)?
} else {
add_mesh(&mut fresh, path, None, mesh_up(p, path, "doc.open")?, mesh_units(p, path, "doc.open")?)?
add_mesh(&mut fresh, path, None, mesh_up(p, path, "doc.open")?, mesh_units(p, path, "doc.open")?, mesh_origin(p, "doc.open")?)?
};
// The design is the file: its component is the one being worked in.
if let Some(c) = r["component"].as_u64() {
@@ -448,7 +469,18 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
s.replace_design(fresh);
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
let mut out = json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]});
for k in ["component", "component_name", "occurrence", "subcomponents", "rigid_joints", "stand_up", "system", "exporter", "exporter_up"] {
for k in [
"component",
"component_name",
"occurrence",
"subcomponents",
"rigid_joints",
"stand_up",
"moved_to_origin",
"system",
"exporter",
"exporter_up",
] {
if let Some(v) = r.get(k) {
out[k] = v.clone();
}
+57
View File
@@ -320,6 +320,63 @@ pub(crate) fn add_in_component(
Ok((id, Placed { component: top, occurrence: top_occ, name, components: comps.len(), joints: joint_ids }))
}
/// Move an import to the origin of the component it was inserted into (#12543): the box around
/// its bodies, where they are placed, comes to sit centred on X and Y with its lowest point on
/// Z = 0. The import's occurrence moves; the file's coordinates in the feature are kept, so the
/// move is its placement and Move Occurrence takes it on from there. Returns the shift, in that
/// component's frame; `None` when the import has no bodies to place.
pub(crate) fn move_to_origin(s: &mut Session, placed: &Placed) -> Result<Option<Vec3>> {
let occ = placed.occurrence.ok_or_else(|| EngineError::Other("the import has no placement to move".into()))?;
// A component activated without a path is shown at its first placement, as elsewhere.
let active = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(s.active_component));
// The frame the import was placed in: the origin it goes to.
let parent = s.doc.path_transform(s.active_component, &active).unwrap_or(IDENTITY);
let to_parent =
solidcraft_doc::mat_inverse(&parent).ok_or_else(|| EngineError::Other("the active component's placement can't be inverted".into()))?;
// This placement of the import only: the bodies whose path runs through its occurrence
// where it sits in the active component.
let ours: Vec<String> = s
.doc
.body_frames(&s.model.state())
.into_iter()
.filter(|(_, _, path, _)| path.starts_with(&active) && path.get(active.len()) == Some(&occ))
.map(|(name, ..)| name)
.collect();
let world = s.world_state()?;
let mut bounds = solidcraft_geom::Aabb3::EMPTY;
for body in world.bodies.iter().filter(|b| ours.contains(&b.name)) {
// Each body's own box in the active component's frame: a turned component's frame would
// only take a larger box around the world box's corners, leaving the mesh off Z = 0.
let b = if solidcraft_doc::is_identity(&to_parent) {
body.body.exact_bounds()?
} else {
solidcraft_doc::kernel::place_matrix(&body.body, to_parent)?.exact_bounds()?
};
if b.is_empty() {
continue;
}
bounds = bounds.union(&b);
}
if bounds.is_empty() {
return Ok(None);
}
let shift = Vec3::new(-(bounds.min.x + bounds.max.x) / 2.0, -(bounds.min.y + bounds.max.y) / 2.0, -bounds.min.z);
if ![shift.x, shift.y, shift.z].iter().all(|v| v.is_finite()) {
return Err(EngineError::Other("the import's bounds can't be placed at the origin".into()));
}
let mut by = IDENTITY;
by[3] = [shift.x, shift.y, shift.z, 1.0];
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.id == occ) {
let moved = solidcraft_doc::mat_mul(&by, &o.transform);
if !solidcraft_doc::is_placement(&moved) {
return Err(EngineError::Other("moving the import to the origin takes it out of a placement within 1e9 mm".into()));
}
o.transform = moved;
}
s.refresh();
Ok(Some(shift))
}
/// Record that body `names[b]` lives in component `c`, placed by `offset`.
fn put_bodies(s: &mut Session, names: &[Option<String>], moves: &[(usize, u64, Mat)]) {
if moves.is_empty() {
+8 -5
View File
@@ -560,13 +560,16 @@ fn complete(s: &mut Session, p: &Value) -> Result<Value> {
let lower = word.to_ascii_lowercase();
let starts = |n: &str| word.is_empty() || n.to_ascii_lowercase().starts_with(&lower);
// A parameter matches wherever the word appears in its name (`side` finds
// `cab_side_thickness`, #2757); functions, constants and units match from the start.
let within = |n: &str| word.is_empty() || n.to_ascii_lowercase().contains(&lower);
// `cab_side_thickness`, #2757) or by the starts of its words in order (`toodia` finds
// `tooth_outward_flange_diameter`, #11978); functions, constants and units match from the
// start.
let contains = |n: &str| word.is_empty() || n.to_ascii_lowercase().contains(&lower);
let within = |n: &str| contains(n) || solidcraft_doc::name_words_match(n, &word);
let mut out: Vec<Value> = Vec::new();
let mut rows: Vec<solidcraft_doc::ParamRow> = s.doc.param_rows().into_iter().filter(|r| within(&r.name)).collect();
// Names that start with the word, then the rest; within each, favourites, then user
// parameters, then the others.
rows.sort_by_key(|r| (!starts(&r.name), !r.favorite, r.source != "user"));
// Names that start with the word, then names that contain it, then names matched only by
// the starts of their words; within each, favourites, then user parameters, then the others.
rows.sort_by_key(|r| (!starts(&r.name), !contains(&r.name), !r.favorite, r.source != "user"));
for r in rows.into_iter().take(200) {
// The completion shows the parameter's own unit, so it reads `display_value`.
let v = r.display_value.or(r.value).map(|v| format!("{v:.4} {}", r.unit).trim().to_string()).unwrap_or_else(|| "error".into());
+11
View File
@@ -350,6 +350,17 @@ fn completion_matches_parameters_anywhere_in_their_name() {
let c = run(&mut s, "parameters.complete", json!({"prefix": "THICK"}));
assert_eq!(texts(&c), ["cab_side_thickness"], "any case: {c}");
assert!(texts(&run(&mut s, "parameters.complete", json!({"prefix": "xyz"}))).is_empty());
// #11978: the starts of the name's words, in order, find it; out of order they don't.
let c = run(&mut s, "parameters.complete", json!({"prefix": "2 * cabthi"}));
assert_eq!(texts(&c), ["cab_side_thickness"], "{c}");
assert_eq!(texts(&run(&mut s, "parameters.complete", json!({"prefix": "doowid"}))), ["door_width"]);
assert!(texts(&run(&mut s, "parameters.complete", json!({"prefix": "thicab"}))).is_empty());
// A match by word starts alone comes after every name that contains the word, even a
// favourite: `inner_diameter` has no `id` in it.
run(&mut s, "parameters.add", json!({"name": "inner_diameter", "expression": "6 mm"}));
run(&mut s, "parameters.favorite", json!({"name": "inner_diameter", "favorite": true}));
let c = run(&mut s, "parameters.complete", json!({"prefix": "id"}));
assert_eq!(texts(&c), ["cab_side_thickness", "side", "door_width", "inner_diameter"], "{c}");
// Functions keep matching from the start: `in` is not `sin(`.
let c = run(&mut s, "parameters.complete", json!({"prefix": "in"}));
assert!(!c["suggestions"].as_array().unwrap().iter().any(|x| x["text"] == "sin("), "{c}");
+67 -1
View File
@@ -43,7 +43,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("canvas")
.contract(&EDIT_CONTRACT)
.output(output_schema_for::<CanvasEdited>)
.params("canvas: id or name; center?, width?, angle?: deg, opacity?, flip?, visible?, name?"),
.params("canvas: id or name; center?, width?, angle?: deg, opacity?, flip?, visible?, name?, locked? (a locked canvas refuses center, width, angle, flip, Calibrate and Delete)"),
CommandSpec::new("canvas.calibrate", "Calibrate Canvas", crate::checked!("canvas.calibrate", calibrate))
.icon("measure")
.contract(&CALIBRATE_CONTRACT)
@@ -109,6 +109,7 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
opacity,
flip: bool_(p, "flip").unwrap_or(false),
visible: true,
locked: false,
component,
});
canvas_reply(cmd, CanvasPlaced { canvas: id, name, pixels, param_warnings: Vec::new() })
@@ -139,6 +140,19 @@ fn find(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
s.doc.canvases.iter().position(|c| c.id.to_string() == key || c.name == key).ok_or_else(|| bad(cmd, format!("no canvas `{key}`")))
}
/// A locked canvas (#12310) keeps its place and is not deleted: the refusal names it.
fn refuse_locked(s: &Session, i: usize, cmd: &str) -> Result<()> {
match s.doc.canvases.get(i) {
Some(c) if c.locked => Err(bad(cmd, locked_reason(c))),
_ => Ok(()),
}
}
/// Why a locked canvas is left as it is.
pub(crate) fn locked_reason(c: &Canvas) -> String {
format!("{} is locked; unlock it first", c.name)
}
fn edit_canvas(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "canvas.edit";
let i = find(s, p, cmd)?;
@@ -146,7 +160,15 @@ fn edit_canvas(s: &mut Session, p: &Value) -> Result<Value> {
if width.is_some_and(|w| !(w > 1e-6 && w < 1e7)) {
return Err(bad(cmd, "`width` must be positive"));
}
// Unlocking in the same call lets the other changes through.
let moves = ["center", "width", "angle", "flip"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null()));
if moves && bool_(p, "locked") != Some(false) {
refuse_locked(s, i, cmd)?;
}
let c = s.doc_mut().canvases.get_mut(i).ok_or_else(|| bad(cmd, "canvas"))?;
if let Some(l) = bool_(p, "locked") {
c.locked = l;
}
if let Some(v) = p.get("center").and_then(vec2) {
c.center = v;
}
@@ -182,6 +204,7 @@ fn calibrate(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "the points must differ"));
}
let k = d / now;
refuse_locked(s, i, cmd)?;
let c = s.doc_mut().canvases.get_mut(i).ok_or_else(|| bad(cmd, "canvas"))?;
c.width *= k;
c.center = a + (c.center - a) * k;
@@ -191,6 +214,7 @@ fn calibrate(s: &mut Session, p: &Value) -> Result<Value> {
fn delete_canvas(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "canvas.delete";
let i = find(s, p, cmd)?;
refuse_locked(s, i, cmd)?;
let c = s.doc_mut().canvases.remove(i);
canvas_reply(cmd, CanvasDeleted { deleted: c.id, param_warnings: Vec::new() })
}
@@ -223,6 +247,48 @@ mod tests {
assert!(s.execute("canvas.edit", &json!({"canvas": first, "width": 500})).is_err());
}
/// A locked canvas (#12310) refuses a move, a resize, a turn, a flip, Calibrate and Delete,
/// keeps taking opacity, visibility and a new name, is saved locked, and moves again once
/// unlocked. Delete of a selection with it deletes the rest and says why it stayed.
#[test]
fn a_locked_canvas_keeps_its_place_and_is_not_deleted() {
let mut s = Session::default();
let id = s.execute("canvas.insert", &json!({"data": png(10, 10), "width": 50})).unwrap()["canvas"].as_u64().unwrap();
let other = s.execute("canvas.insert", &json!({"data": png(10, 10)})).unwrap()["canvas"].as_u64().unwrap();
s.execute("canvas.edit", &json!({"canvas": id, "locked": true})).unwrap();
let locked = s.doc.canvases[0].clone();
assert!(locked.locked);
for p in [
json!({"canvas": id, "center": [5, 5]}),
json!({"canvas": id, "width": 80}),
json!({"canvas": id, "angle": 30}),
json!({"canvas": id, "flip": true}),
json!({"canvas": id, "locked": true, "width": 80}),
] {
let e = s.execute("canvas.edit", &p).unwrap_err().to_string();
assert!(e.contains("Canvas1 is locked"), "{p}: {e}");
}
assert!(s.execute("canvas.calibrate", &json!({"canvas": id, "a": [0, 0], "b": [10, 0], "distance": 20})).is_err());
assert!(s.execute("canvas.delete", &json!({"canvas": id})).is_err());
assert_eq!(s.doc.canvases[0], locked, "nothing changed");
s.execute("canvas.edit", &json!({"canvas": id, "opacity": 0.9, "visible": false, "name": "Blueprint"})).unwrap();
assert!(s.doc.canvases[0].locked && !s.doc.canvases[0].visible && s.doc.canvases[0].name == "Blueprint");
// Saved locked; a design without the field opens unlocked.
let back = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
assert!(back.canvases[0].locked && !back.canvases[1].locked);
assert!(serde_json::to_value(&s.doc.canvases[1]).unwrap().get("locked").is_none(), "an unlocked canvas saves as before");
// Delete of both deletes the unlocked one and names the locked one.
let r = s.execute("selection.delete", &json!({"canvases": [id, other]})).unwrap();
assert_eq!(r["skipped"], json!(["Blueprint is locked; unlock it first"]));
assert_eq!(s.doc.canvases.iter().map(|c| c.id).collect::<Vec<_>>(), [id]);
assert!(s.execute("selection.delete", &json!({"canvases": [id]})).is_err());
// Unlocking in the same call lets the move through.
s.execute("canvas.edit", &json!({"canvas": id, "locked": false, "width": 80})).unwrap();
assert!(!s.doc.canvases[0].locked && (s.doc.canvases[0].width - 80.0).abs() < 1e-12);
s.execute("canvas.delete", &json!({"canvas": id})).unwrap();
assert!(s.doc.canvases.is_empty());
}
#[test]
fn canvas_insert_edit_calibrate_save() {
let mut s = Session::default();
@@ -749,6 +749,37 @@ fn symmetry_slides_a_rectangle_whose_width_is_dimensioned() {
assert!((r.x - l.x - 42.0).abs() < 1e-6 && (l.x + 21.0).abs() < 1e-6, "sides at ±21: {l:?} {r:?}");
}
#[test]
fn symmetry_of_two_corners_of_one_edge_about_the_origin() {
// #4850: two corners of the same rectangle edge, symmetric about the origin point, were
// refused as "neither entity can move" although sliding the rectangle meets it.
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-12, -6], "p1": [18, 10]}));
let sides = ids(&r["curves"]);
let sk = sketch(&s);
let bottom = sides
.iter()
.find(|id| {
let (a, b) = line_ends(&sk, id);
(a.y + 6.0).abs() < 1e-9 && (b.y + 6.0).abs() < 1e-9
})
.unwrap_or_else(|| panic!("no bottom side in {sides:?}"))
.clone();
run(&mut s, "sketch.constraint.symmetry", json!({"a": format!("{bottom}.start"), "b": format!("{bottom}.end"), "point": "origin"}));
let sk = sketch(&s);
let (a, b) = line_ends(&sk, &bottom);
assert!((a.x + b.x).abs() < 1e-6 && (a.y + b.y).abs() < 1e-6, "corners not mirrored about the origin: {a:?} {b:?}");
assert!(a.y.abs() < 1e-6 && b.y.abs() < 1e-6, "corners not on y = 0: {a:?} {b:?}");
assert!((a.x - b.x).abs() > 1.0, "the bottom side collapsed: {a:?} {b:?}");
for corner in [a, b] {
let met = sides.iter().filter(|id| **id != bottom).any(|id| {
let (p, q) = line_ends(&sk, id);
p.dist(corner) < 1e-6 || q.dist(corner) < 1e-6
});
assert!(met, "no adjacent side meets the corner {corner:?}");
}
}
#[test]
fn symmetry_pairs_the_ends_of_fixed_lines_drawn_the_opposite_way() {
// #1796: the sides of a face outline run in opposite directions (up one side, down the
+21
View File
@@ -762,6 +762,27 @@ fn surface_precedence_and_truthful_contrast_fallback_have_wire_contracts() {
let mut opaque = spec.clone();
opaque.surface = PanelSurface::Opaque;
assert_eq!(group_surface(Zone::Left, [&spec, &opaque]), PanelSurface::Opaque);
let mut follow = spec.clone();
follow.surface = PanelSurface::FollowZone;
follow.text_heavy = false;
let mut text_heavy = follow.clone();
text_heavy.text_heavy = true;
for zone in [Zone::Left, Zone::Right, Zone::Floating, Zone::Bottom, Zone::Window] {
assert_eq!(group_surface(zone, [&spec, &opaque]), PanelSurface::Opaque);
assert_eq!(group_surface(zone, [&opaque, &spec]), PanelSurface::Opaque);
assert_eq!(group_surface(zone, [&follow, &text_heavy]), PanelSurface::Opaque);
assert_eq!(group_surface(zone, [&text_heavy, &follow]), PanelSurface::Opaque, "inactive metadata cannot depend on tab order");
assert_eq!(
group_surface(zone, [&text_heavy, &spec]),
PanelSurface::Translucent,
"explicit translucency overrides both text-heavy and zone defaults"
);
assert_eq!(group_surface(zone, [&spec, &text_heavy]), PanelSurface::Translucent);
assert_eq!(
group_surface(zone, [&follow]),
if matches!(zone, Zone::Bottom | Zone::Window) { PanelSurface::Opaque } else { PanelSurface::Translucent }
);
}
let outcome = ContrastOutcome::RetainReadableFallback { reason: ContrastFailure::UnreadableAtFullOpacity };
let roundtrip: ContrastOutcome = serde_json::from_value(serde_json::to_value(&outcome).unwrap()).unwrap();
assert_eq!(roundtrip, outcome);
+4
View File
@@ -147,6 +147,10 @@ pub struct CanvasEditParams {
/// Its new name; blank text keeps the name.
#[serde(default)]
pub name: Option<String>,
/// Lock it or unlock it. A locked canvas refuses a new centre, width, angle or flip, Calibrate
/// and Delete until it is unlocked; its opacity, visibility and name can still change.
#[serde(default)]
pub locked: Option<bool>,
}
/// Scale a canvas so that two points on it are a known distance apart: the image is scaled about
+1 -1
View File
@@ -28,7 +28,7 @@ fn schemas_and_the_key_check_agree() {
input_schema_for::<CanvasDecalParams>(),
&["angle", "at", "center", "data", "face", "flip", "name", "offset", "opacity", "path", "width"],
);
assert_keys(input_schema_for::<CanvasEditParams>(), &["angle", "canvas", "center", "flip", "name", "opacity", "visible", "width"]);
assert_keys(input_schema_for::<CanvasEditParams>(), &["angle", "canvas", "center", "flip", "locked", "name", "opacity", "visible", "width"]);
assert_keys(input_schema_for::<CanvasCalibrateParams>(), &["a", "b", "canvas", "distance"]);
assert_keys(input_schema_for::<CanvasDeleteParams>(), &["canvas"]);
}
+9 -8
View File
@@ -109,10 +109,11 @@ fn fillet_as_wide_as_a_face_rounds_it_off() {
/// The owner's ruling of 2026-10-30: two edges of a 20 mm box rounded r2 in one fillet (they
/// meet in a mitre at the corner (20, 0, 20), the upright edge there sharp), then the upright edge
/// r3 in a second fillet, whose tangent chain takes the mitre's seam with it. The corner is
/// solidcore's setback corner for three rounds of different radii there (solidcore#2382): 15
/// faces, 7927.724282512234 mm³. The r2 rounds keep their names, and the r3 round and the
/// corner's six patches are the second fillet's.
/// r3 in a second fillet, whose tangent chain takes the mitre's seam with it. The corner is the
/// one solidcore 8ddce9016 builds for three rounds of different radii there: one patch, 10
/// faces, 7928.743041456134 mm³ (the fork's setback corner, 15 faces and 7928.142563938, waits
/// for solidcore#2310). The r2 rounds keep their names, and the r3 round and the corner's patch
/// are the second fillet's.
#[test]
fn a_later_round_at_a_mitred_corner_is_blended_with_the_rounds_there() {
let mut s = Session::default();
@@ -134,17 +135,17 @@ fn a_later_round_at_a_mitred_corner_is_blended_with_the_rounds_there() {
let second = run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 10]], "radius": 3}))["feature"].as_u64().unwrap();
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
let v = volume(&mut s);
assert!((v - 7927.724282512234).abs() < 1e-6, "{v}");
assert!((v - 7928.743041456134).abs() < 1e-6, "{v}");
let faces = run(&mut s, "model.faces", json!({"body": "Box"}))["faces"].as_array().cloned().unwrap_or_default();
assert_eq!(faces.len(), 15);
assert_eq!(faces.len(), 10);
let after: Vec<String> = rounds.iter().map(|p| named(&mut s, *p)).collect();
assert_eq!(after, before);
// The r3 round (halfway up, at 45°) and the corner's patches: the faces the second fillet made.
let k3 = 3.0 - 4.5_f64.sqrt();
assert!(named(&mut s, solidcore_record::Vec3::new(20.0 - k3, k3, 10.0)).starts_with(&format!("F{second}:blend:")));
let made = faces.iter().filter(|f| f["name"].as_str().is_some_and(|n| n.starts_with(&format!("F{second}:")))).count();
// the r3 round and the corner's six patches
assert_eq!(made, 7, "{faces:?}");
// the r3 round and the corner's patch
assert_eq!(made, 2, "{faces:?}");
}
#[test]
+103
View File
@@ -278,6 +278,109 @@ fn a_mesh_file_comes_in_as_one_component() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A 10 x 20 x 30 box with its low corner at (100, 50, 7), saved as `block.stl` in `dir`.
fn off_origin_stl(dir: &std::path::Path) -> String {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30, "corner": [100, 50, 7]}));
let stl = dir.join("block.stl").to_string_lossy().to_string();
run(&mut s, "file.save_mesh", json!({"path": stl}));
stl
}
/// The one box the design shows.
fn only_box(s: &Session) -> [i64; 6] {
let boxes = shown_boxes(s);
assert_eq!(boxes.len(), 1, "{boxes:?}");
boxes[0]
}
/// Insert Mesh's `origin` (#12543): off, the mesh stays where its file puts it; on, its box is
/// centred on X and Y with its base on Z = 0, by moving the import's occurrence, and the reply
/// says by how much. A unit scales the mesh first, so the scaled box is the one centred.
#[test]
fn a_mesh_import_moves_to_the_origin_only_when_asked() {
let dir = temp_dir("origin");
let stl = off_origin_stl(&dir);
let mut s = Session::default();
let r = run(&mut s, "file.insert_mesh", json!({"path": stl}));
assert!(r.get("moved_to_origin").is_none(), "{r}");
assert_eq!(only_box(&s), [100_000, 50_000, 7_000, 110_000, 70_000, 37_000], "the file's coordinates by default");
let mut s = Session::default();
let r = run(&mut s, "file.insert_mesh", json!({"path": stl, "origin": true}));
assert_eq!(only_box(&s), [-5_000, -10_000, 0, 5_000, 10_000, 30_000], "centred on X and Y, base on Z = 0");
assert_eq!(r["moved_to_origin"], json!([-105.0, -60.0, -7.0]), "{r}");
let occ = r["occurrence"].as_u64().unwrap();
let placed = s.doc.occurrences.iter().find(|o| o.id == occ).unwrap().transform;
assert_eq!(placed[3], [-105.0, -60.0, -7.0, 1.0], "the occurrence carries the move");
// One undo step takes the whole import, move and all, away.
run(&mut s, "edit.undo", json!({}));
assert!(s.world_state().unwrap().bodies.is_empty());
let mut s = Session::default();
run(&mut s, "file.insert_mesh", json!({"path": stl, "origin": true, "units": "cm"}));
assert_eq!(only_box(&s), [-50_000, -100_000, 0, 50_000, 100_000, 300_000], "scaled to millimetres, then centred");
let mut s = Session::default();
let e = s.execute("file.insert_mesh", &json!({"path": stl, "origin": "yes"})).unwrap_err().to_string();
assert!(e.contains("`origin` must be true"), "{e}");
assert!(s.doc.features.is_empty(), "a refused import adds nothing");
let _ = std::fs::remove_dir_all(&dir);
}
/// `origin` is the origin of the component the mesh is inserted into, wherever that component
/// is placed; and opening a mesh file as a design takes it too.
#[test]
fn a_mesh_moved_to_the_origin_sits_on_the_active_components_origin() {
let dir = temp_dir("origin-active");
let stl = off_origin_stl(&dir);
let mut s = Session::default();
let holder = run(&mut s, "component.create", json!({"name": "Holder", "grounded": false}));
// Made active as it is made.
run(&mut s, "occurrence.move", json!({"occurrence": holder["occurrence"], "translate": [0, 0, 40]}));
assert_ne!(s.active_component, 0);
run(&mut s, "file.insert_mesh", json!({"path": stl, "origin": true}));
assert_eq!(only_box(&s), [-5_000, -10_000, 40_000, 5_000, 10_000, 70_000], "on Holder's origin, 40 mm up");
let mut s = Session::default();
let r = run(&mut s, "doc.open", json!({"path": stl, "origin": true}));
assert_eq!(r["moved_to_origin"], json!([-105.0, -60.0, -7.0]), "{r}");
assert_eq!(only_box(&s), [-5_000, -10_000, 0, 5_000, 10_000, 30_000]);
let e = s.execute("doc.open", &json!({"path": dir.join("x.sc").to_string_lossy(), "origin": true})).unwrap_err().to_string();
assert!(e.contains("`origin` applies to"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
/// Moving to the origin measures the mesh in the active component's own frame, so a component
/// turned 45 degrees takes exactly the file box's shift (not a larger box around the turned
/// one's corners); and a component made active without an occurrence path is taken at its first
/// placement, as everywhere else.
#[test]
fn a_mesh_moves_to_the_origin_of_a_turned_or_pathless_active_component() {
let dir = temp_dir("origin-turned");
let stl = off_origin_stl(&dir);
let shift = |r: &Value| -> Vec3 {
let v = r["moved_to_origin"].as_array().unwrap_or_else(|| panic!("not moved: {r}"));
Vec3::new(v[0].as_f64().unwrap(), v[1].as_f64().unwrap(), v[2].as_f64().unwrap())
};
let mut s = Session::default();
let holder = run(&mut s, "component.create", json!({"name": "Holder", "grounded": false}));
run(&mut s, "occurrence.move", json!({"occurrence": holder["occurrence"], "axis": [1, 0, 0], "angle": "45 deg", "translate": [0, 0, 40]}));
assert_ne!(s.active_component, 0);
let r = run(&mut s, "file.insert_mesh", json!({"path": stl, "origin": true}));
assert!(close(shift(&r), Vec3::new(-105.0, -60.0, -7.0)), "in Holder's frame: {r}");
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Holder", "grounded": false}));
assert_ne!(s.active_component, 0);
s.active_occurrence_path = None;
let r = run(&mut s, "file.insert_mesh", json!({"path": stl, "origin": true}));
assert!(close(shift(&r), Vec3::new(-105.0, -60.0, -7.0)), "at Holder's first placement: {r}");
assert_eq!(only_box(&s), [-5_000, -10_000, 0, 5_000, 10_000, 30_000]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn undo_takes_the_whole_import_away() {
let dir = temp_dir("undo");
+1
View File
@@ -1873,6 +1873,7 @@ mod tests {
opacity: 0.5,
flip: false,
visible: true,
locked: false,
component: 0,
});
doc
+34 -31
View File
@@ -6,6 +6,7 @@
//! fork fixed in its own sources; each one turned on here says which fork commit it matches.
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, PoisonError};
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
@@ -107,6 +108,7 @@ pub(crate) fn fixes() -> UpstreamFixes {
f.offset_resolve_crossing_loops = true;
f.offset_walls_touch_refused = true;
f.offset_analytic_mate_edges = true;
f.precise_properties_split_at_surface_knots = true;
f.nurb_cylinder_inside_out_frame = true;
f.shell_inner_shell_of_statuses = true;
f.local_operation_offset_status = true;
@@ -145,10 +147,13 @@ pub(crate) fn fixes() -> UpstreamFixes {
f.torus_signed_gen_curve_interval = true;
f.sheet_sweep_joins_profile_wires = true;
f.point_solve_keeps_triangle_guess = true;
f.fillet_setback_from_start_vertex = true;
f.imprint_trims_edge_to_proto_vertex = true;
f.imprint_extends_edge_to_proto_vertex = true;
f.fillet_point_step_per_point = true;
f.analytic_copy_keeps_parameterization = true;
f.fillet_point_bigger_step_retry = true;
f.fillet_through_extended_rail_end = true;
f.precise_properties_body_length_scale = true;
f.offset_extension_from_base = true;
f.corner_blend_stays_inside_neighbours = true;
@@ -169,9 +174,11 @@ pub(crate) fn fixes() -> UpstreamFixes {
f.boolean_skips_faces_apart = true;
f.uv_trim_checks_on_surface = true;
f.loop_orientation_floor_from_loop_box = true;
f.offset_extends_cones_as_themselves = true;
f.local_operation_box_holds_new_surfaces = true;
f.uv_trim_joins_drop_pieces = true;
f.shell_edges_on_analytics_exact = true;
f.ssi_no_reverse_of_empty_trace = true;
f.ssi_pole_point_keeps_its_tangent = true;
f.iso_curve_sliver_domain_is_degenerate = true;
f.corner_blend_coons_patch_tangent_to_sides = true;
@@ -223,6 +230,7 @@ pub(crate) fn fixes() -> UpstreamFixes {
f.fillet_cleanup_removes_input_slits = true;
f.fillet_rail_end_on_continued_surface = true;
f.glue_vertices_joins_every_region = true;
f.fillet_nx1_tangent_side_edge_same_convexity = true;
f.fillet_removal_takes_ring_fillets = true;
// The fork's commits that reached `SMLIB_REV` at eaa998a (tangency-labs/smlib#68, #69, #70).
f.fillet_trim_inside_by_best_dot = true;
@@ -266,7 +274,9 @@ pub(crate) fn fixes() -> UpstreamFixes {
f.offset_keep_cavity_regions = true;
f.offset_four_face_vertex_split = true;
f.gap_samples_non_bspline_v_stride = true;
f.gap_array_append_updates_longest = true;
f.imprint_skips_collapsed_definition = true;
f.precise_properties_trim_base_v = true;
f.fillet_blend_curve_revolved_on_arc = true;
f.temporary_radius_setup_errors = true;
f.ellipse_step_interval_checks = true;
@@ -309,6 +319,7 @@ pub(crate) fn fixes() -> UpstreamFixes {
f.circle_silhouette_perspective_center = true;
f.ellipse_convert_to_2d_uses_both_axes = true;
f.circle_offset_antiparallel_normal = true;
f.masked_array_uses_bit_shift = true;
f.global_solver_identical_prefers_larger_when_maximizing = true;
f.surface_continuity_checks_last_v_knot = true;
f.surface_point_solver_propagates_bbox_failure = true;
@@ -320,6 +331,7 @@ pub(crate) fn fixes() -> UpstreamFixes {
f.crv_in_volume_mults_by_break_index = true;
f.curve_bounded_surface_read_uv_type_and_implicit_outer = true;
f.polar_box_region_to_arc_transposes_domain = true;
f.point_set_centroid_all_blocks = true;
f.local_solve1d_unbounded_newton_step = true;
f.composite_curve_bbox_segment_intervals = true;
f.knot_vector_valid_requires_span = true;
@@ -327,12 +339,14 @@ pub(crate) fn fixes() -> UpstreamFixes {
f.curve_property_gs_branch_bounds = true;
f.volume_representational_end_continuities = true;
f.integrator_propagates_failure = true;
f.precise_properties_span_end_from_below = true;
f.offset_open_edge_second_trim_point = true;
f.close_loop_relates_only_coincident_edges = true;
f.ssi_split_curves_at_singular_points = true;
f.surface_parallel_to_vector_checks = true;
f.fillet_round_ends_at_tangent_continuation = true;
f.geometric_g2_continuity = true;
f.hash_table_grow_preserves_removed_records = true;
f.bend_volume_bbox_contains_wide_angle_and_inf_u = true;
f.bend_volume_trim_param_box_across_period = true;
f.point_set_tight_pbox_projects_points = true;
@@ -363,6 +377,7 @@ pub(crate) fn fixes() -> UpstreamFixes {
f.fillet_section_end_trim_at_creases = true;
f.fillet_refuse_mixed_corner_crossing_rails = true;
f.fillet_keep_features = true;
f.fillet_blend_corner = true;
f.fillet_exact_arc_sections = true;
f.fillet_exact_arc_spans = true;
// Held back: see `HELD_BACK`.
@@ -370,21 +385,11 @@ pub(crate) fn fixes() -> UpstreamFixes {
f.cone_unbounded_parameterized_as_nurb = true;
f.ellipse_open_arc_keeps_end_angle = true;
f.fillet_round_as_definition = true;
f.fillet_round_on_round_as_definition = true;
f.fillet_overflow_crease_along_edge = true;
f.plane_first_torus_section_exact = true;
f.keep_exact_pcurves = true;
f.shell_one_construction = true;
f.approximate_surface_checks_every_row = true;
f.topological_vertex_has_only_edge_uses = true;
f.planar_section_face_tolerance = true;
f.face_swap_uv_transposes_trims = true;
f.closed_smooth_pole_tangent_limit = true;
// Held back: see `HELD_BACK`.
f.fillet_round_ends_on_outline = false;
f.fillet_setback_beside_curved_faces = true;
f.fillet_chamfer_corner = true;
f.brep_ray_classifies_current_trims = true;
f.revolution_frame_from_profile_origin = true;
f
}
@@ -394,31 +399,29 @@ pub(crate) fn fixes() -> UpstreamFixes {
/// the fork doesn't have stays on, and where solidcore builds what SMLib refuses, a result right
/// against its closed form is a win.
///
/// `fillet_refuse_rounds_breaking_through` (WR-707, solidcore#2311): sound geometry refused. At
/// solidcore 9856ffee5, with it on, the fillet rig's 1668-fillet-both-sides-of-thin-wall-r7 is
/// refused as breaking through the wall into the hollow behind it ("the largest radius that fits is
/// 6.828 mm"), where with it off it builds the closed form, 39509.938 (at 8ddce9016 it refused 11
/// rig cases that pass with it off; at this pin the other 270 fillet rig cases come out as with it
/// off).
///
/// `fillet_round_ends_on_outline` (WR-730, solidcore#2541): volumes wrong against their closed
/// forms, and rounds too big for their faces built instead of refused. With it on, each of these
/// passes with it off: `fillet_over_a_cliff` measures 721.832 against the closed form's 693.205;
/// `a_round_wider_than_its_faces_overflows_onto_its_tangent_sides` 6125.719 against 6107.529;
/// `taper_follows_the_room_along_the_edge` 4210.926 against 4195.579; the round larger than the
/// cube in `hostile_inputs_are_errors` builds (4773.168 against the 1094.853 its refusal leaves),
/// as do the fillets the CLI's `run_strict` and `json_out` tests need to fail; and
/// `a_corner_round_wider_than_its_faces_overflows_and_takes_the_corners_other_edges`,
/// `overflow_goes_first`, `corners_refuse_what_they_cannot_build` and
/// `fillet_edge_removed_by_a_larger_overflowing_round_is_noted` are refused (too large, no room,
/// status 1001), where with it off they build. Measured again at solidcore 9856ffee5: the same.
/// `fillet_refuse_rounds_breaking_through` (WR-707, solidcore#2311): sound geometry refused. With it
/// on, 11 rig cases that pass with it off, each graded against its closed form or invariants, are
/// refused as breaking through the part (190-fillet-disc-rim-past-its-2mm-wall fails status 1001
/// instead): every-hole-top-rim-r12-past-plate-bottom, 1080-chamfer-runs-into-openings-cut-by-them
/// (3350, its closed form), 1080-fillet-notched-block-top-outline-r8,
/// 324-fillet-over-hole-cut-by-overlong-cylinder, 324-fillet-over-hole-overlap,
/// 5158-fillet-adjacent-plate-edges-as-thick-as-plate and the sloped and equal-slope basin rims of
/// #1080 and #5835. A fix under the same flag is in progress on solidcore.
#[cfg(test)]
const HELD_BACK: &[&str] = &["fillet_refuse_rounds_breaking_through", "fillet_round_ends_on_outline"];
const HELD_BACK: &[&str] = &["fillet_refuse_rounds_breaking_through"];
/// A new kernel, with this backend's fixes.
/// How far apart two kernels' procedural blend serials start. A body keeps the serials of the
/// kernel that made it and may be used on any other, whose memo of solved stations is keyed by
/// serial alone: the ranges keep one definition's stations from answering for another's. A serial
/// only names a definition, so which range one comes from changes no answer.
pub(crate) const BLEND_SERIALS_PER_KERNEL: u64 = 1 << 40;
/// A new kernel. Each takes the next range of blend serials, the first starting at 0.
fn fresh() -> Kernel {
static MADE: AtomicU64 = AtomicU64::new(0);
let mut k = Kernel::new(&KernelOptions::default());
k.fixes = fixes();
k.blend_last_serial.set(MADE.fetch_add(1, Ordering::Relaxed).wrapping_mul(BLEND_SERIALS_PER_KERNEL));
k
}
+102 -53
View File
@@ -1,7 +1,7 @@
//! Fillets and chamfers on solidcore: the C API's composition round SMLib's fillet executive,
//! ported from the fork's `capi/src/smlib_ops.cpp` (`blend_call`, `round_edges`,
//! `chamfer_edges`, `outer_faceuse`, `face_frame`,
//! `blend_failure`, `setbacks_fit`, `overflow_first`, `Picked`,
//! `chamfer_offsets`, `chamfer_edges`, `chamfer_radius`, `outer_faceuse`, `face_frame`,
//! `sides_offsets`, `blend_failure`, `setbacks_fit`, `overflow_first`, `Picked`,
//! `size_ok`, `points_ok`, `tolerance_ok`, `law_ok`, `law_pinches`, `law_along`, `finish`,
//! `smlib_fillet`, `smlib_chamfer`, `smlib_blend`, `smlib_blend_dropping`, `smlib_blend_setback`,
//! `smlib_blend_setback_open`, `smlib_fillet_variable`, `smlib_chamfer_sides`) and
@@ -31,9 +31,7 @@ use solidcore::blend::fillet_executive::p3::{ErrorInfo, FitKind, NoRoomReason};
use solidcore::blend::fillet_executive::session::{FilletObject, FilletSession};
use solidcore::blend::fillet_geom::p1::{FilletCornerType, FilletErrorType};
use solidcore::blend::fillet_section::section::GN_THUMBWEIGHT_EQUAL_AREA;
use solidcore::blend::chamfer_side::faces_along;
use solidcore::blend::fillet_solver::p1::FilletSurfaceGeneratorType;
use solidcore::surface::procedural_blend::chamfer::ChamferSize;
use solidcore::geom::attribute::{Attribute, AttributeHandlers, Behavior, ids as attribute_ids};
use solidcore::geom::vector3d::{Point3, Vec3};
use solidcore::kernel::Kernel;
@@ -224,7 +222,30 @@ fn mid(b: &Brep, e: EdgeId) -> solidcore::Result<f64> {
Ok(b.edge(e)?.interval.evaluate(0.5))
}
/// What `round_edges` makes.
/// `chamfer_radius`: the radius of the ball whose tangent points on the two faces of `e` lie
/// `distance` back from the edge at its midpoint, `distance * tan(a / 2)` with `a` the angle
/// between the directions into the faces; at a cusp (a knife edge) `distance` itself, the ball
/// whose contacts the chamfer joins. `None` where the capi's returns false.
fn chamfer_radius(k: &Kernel, b: &mut Brep, e: EdgeId, distance: f64) -> solidcore::Result<Option<f64>> {
let Some(u0) = edge::blend_edgeuse(k, b, e)? else { return Ok(None) };
let Some(u1) = eu_p1::radial(b, u0)? else { return Ok(None) };
let t = mid(b, e)?;
let options = BinormalOptions::default();
let Some(e0) = answer(evaluate_binormal(k, b, u0, t, false, &options))? else { return Ok(None) };
let Some(e1) = answer(evaluate_binormal(k, b, u1, t, false, &options))? else { return Ok(None) };
// Unitize's failure (a zero vector) is the capi's `return false`.
let (Ok(n0), Ok(b0), Ok(b1)) = (e0.face_normal.unitize(), e0.binormal.unitize(), e1.binormal.unitize()) else { return Ok(None) };
let c = b0.dot(b1).clamp(-1.0, 1.0);
// At a cusp (a knife edge: the faces tangent within the half degree `GetBlendEdgeuse` calls
// tangent, folded back on each other, the binormals the same way) a is 0 and no ball lies
// `distance` back along both: the chamfer is the chord between the contacts of the ball of
// radius `distance` (fork e96898d).
let cusp = c > 0.0 && b1.dot(n0).abs() < (0.5 * std::f64::consts::PI / 180.0).sin();
let radius = if cusp { distance } else { distance * ((1.0 - c) / (1.0 + c)).sqrt() };
Ok((radius.is_finite() && radius > 0.0).then_some(radius))
}
/// What `round_edges` makes: the capi's kinds less the chamfer, which `chamfer_offsets` makes.
#[derive(Clone, Copy, PartialEq)]
enum Section<'a> {
/// A circular section of constant radius (or of a variable radius's law), the turns of two
@@ -235,10 +256,9 @@ enum Section<'a> {
/// `fillet_gn_section_generator`), which takes off what the ball's circle does.
Curvature,
Chord,
/// A chamfer of the sizes asked: one for all the edges or one per edge, and for each edge
/// the face its first size is along (empty: the blend edge use's). The kernel makes their
/// offsets, one pair for a tangent chain of one size.
Chamfer { sizes: &'a [ChamferSize], along: &'a [FaceId] },
/// A chamfer, with each edge's offset of its second surface (`chamfer_offsets`' `second`;
/// the sizes are the first's).
Chamfer(&'a [f64]),
}
/// The faces of `b`, for `made_faces`: a face whose id is new after the operation was made by
@@ -353,16 +373,13 @@ fn blend_failure_unsized(info: &ErrorInfo<FilletObject>, e: SolidError, corner:
}
/// `e` ([`Error::TooLarge`]) with the point at the middle of `edge` of `b`, the edge its size was
/// found for; an equal chamfer's size (an offset, [`Section::Chamfer`]) as its distance, by the
/// edge's own ratio of the distance asked to the offset the kernel made of it (`chamfered`: per
/// edge of an equal chamfer, the two). Where the edge or its point can't be read the size stays
/// as it is, with no point (a chamfer's size then is not a distance, so it is left a plain
/// refusal).
fn sized_on(k: &Kernel, b: &mut Brep, edge: Option<EdgeId>, section: Section<'_>, chamfered: &[(EdgeId, f64, f64)], e: Error) -> solidcore::Result<Error> {
/// found for; an equal chamfer's size (its ball's radius, [`chamfer_radius`]) as its distance.
/// Where the edge or its point can't be read the size stays as it is, with no point (a chamfer's
/// size then is not a distance, so it is left a plain refusal).
fn sized_on(k: &Kernel, b: &mut Brep, edge: Option<EdgeId>, section: Section<'_>, e: Error) -> solidcore::Result<Error> {
let Error::TooLarge { size, kind, why, .. } = e else { return Ok(e) };
let chamfer = matches!(section, Section::Chamfer { .. }) && kind == crate::FitKind::Radius;
let Some(edge) = edge else {
return Ok(if chamfer { Error::NoRoom } else { e });
return Ok(if matches!(section, Section::Chamfer(_)) && kind == crate::FitKind::Radius { Error::NoRoom } else { e });
};
let at = match b.edge(edge) {
Ok(rec) => match rec.curve.as_ref() {
@@ -371,13 +388,11 @@ fn sized_on(k: &Kernel, b: &mut Brep, edge: Option<EdgeId>, section: Section<'_>
},
Err(_) => None,
};
if chamfer {
// The offset is linear in the distance along an edge.
return Ok(match chamfered.iter().find(|(e, ..)| *e == edge) {
Some(&(_, distance, offset)) if offset > 0.0 => {
Error::TooLarge { size: size / offset * distance, kind: crate::FitKind::Distance, at, why }
}
_ => Error::NoRoom,
if matches!(section, Section::Chamfer(_)) && kind == crate::FitKind::Radius {
// The ball's radius is linear in the chamfer's distance along an edge.
return Ok(match chamfer_radius(k, b, edge, 1.0)? {
Some(per_mm) => Error::TooLarge { size: size / per_mm, kind: crate::FitKind::Distance, at, why },
None => Error::NoRoom,
});
}
Ok(Error::TooLarge { size, kind, at, why })
@@ -435,8 +450,6 @@ fn blend_edges_stopping(
let before = face_set(b)?;
// The edge a sized refusal names ([`blend_failure`]), read after the session lets `b` go.
let mut fit_edge = None;
// An equal chamfer's edges, each with its distance and the offset the kernel made of it.
let mut chamfered: Vec<(EdgeId, f64, f64)> = Vec::new();
let done = {
let mut s = FilletSession::new(k, b)?;
// `SetSweptTurns`: read as the corners are made (`fixes.fillet_blend_corner`).
@@ -458,21 +471,12 @@ fn blend_edges_stopping(
s.prepare_per_edge_constant_radius(k, edges, sizes, &[FilletSurfaceGeneratorType::BlendCurve], 2, GN_THUMBWEIGHT_EQUAL_AREA)
}
(Section::Chord, _) => s.prepare_per_edge_constant_distance(k, edges, sizes, &[FilletSurfaceGeneratorType::Circular], 1, 1.0),
(Section::Chamfer { sizes, along }, _) => s.prepare_chamfers_along(k, edges, sizes, along).map(|offsets| {
for (i, (&e, [r, _])) in edges.iter().zip(offsets).enumerate() {
let size = sizes.get(if sizes.len() == 1 { 0 } else { i });
if let Some(&ChamferSize::Distances([d, d2])) = size
&& d == d2
{
chamfered.push((e, d, r));
}
}
}),
(Section::Chamfer(second), _) => chamfer_parameters(k, &mut s, edges, sizes, second),
};
// `AddSetBackVertex` for each set-back vertex, before the corners are made: a set-back
// vertex is no Ball (`IsSetBackVertex`, read by `ClassifyBall`), so its corner is the one
// the setback is set on.
if !matches!(section, Section::Chamfer { .. }) {
if !matches!(section, Section::Chamfer(_)) {
for sb in setbacks {
s.add_set_back_vertex(sb.vertex);
}
@@ -525,7 +529,7 @@ fn blend_edges_stopping(
Err(refusal) => {
b.refresh_cached_topology();
return Ok(Err(match refusal {
Err(e @ Error::TooLarge { .. }) => Err(sized_on(k, b, fit_edge, section, &chamfered, e)?),
Err(e @ Error::TooLarge { .. }) => Err(sized_on(k, b, fit_edge, section, e)?),
r => r,
}));
}
@@ -567,6 +571,16 @@ fn blend_edges_stopping(
Ok(Ok(()))
}
/// `chamfer_offsets`' `SetFilletParameters(b, es, kinds, solvers, first, NULL, -1, -1, -1, -1,
/// 1, 1.0, &second)` with every kind `SM_FSG_LINEAR` and every solver `SM_FS_CONST_RADIUS`, as the
/// fork's `SetFilletParameters` runs it (its `pOptSecondRadii`, fork f653ac9).
fn chamfer_parameters(k: &mut Kernel, s: &mut FilletSession<'_>, edges: &[EdgeId], first: &[f64], second: &[f64]) -> solidcore::Result<()> {
if edges.is_empty() {
return Ok(());
}
s.prepare_per_edge_constant_radius_with_second(k, edges, first, second, &[FilletSurfaceGeneratorType::Linear], 1, 1.0)
}
/// The corners' part of `round_edges` / `chamfer_offsets`: a chamfer's corners bevelled; each
/// setback set on its vertex's corner (`GetFilletCornerOfVertex`), which must be an
/// `SM_FCR_N_x_N_CONVEX` corner or a blend corner whose event is the setback (`SM_FCR_BLEND` with
@@ -578,7 +592,7 @@ fn corners(
setbacks: &[SetBack],
) -> solidcore::Result<std::result::Result<Vec<usize>, std::result::Result<usize, Error>>> {
let ids = s.fillet_corners()?;
if matches!(section, Section::Chamfer { .. }) {
if matches!(section, Section::Chamfer(_)) {
for id in ids {
s.set_corner_bevel(id, true)?;
}
@@ -922,11 +936,16 @@ fn blend_call(
Blend::Curvature if !one_wedge(k, &mut w, &picked)? => return Ok(Err(Error::Unported(CURVATURE_MIXED_WEDGES))),
Blend::Curvature => Section::Curvature,
Blend::Chord => Section::Chord,
// `chamfer_edges`: the sizes asked; the kernel makes each edge's offsets.
// `chamfer_edges`: each edge's offsets from its setback at its midpoint.
Blend::Chamfer => {
let chamfers: Vec<ChamferSize> = each.iter().map(|&d| ChamferSize::Distances([d, d])).collect();
let section = Section::Chamfer { sizes: &chamfers, along: &[] };
return Ok(match blend_edges(k, &mut w, &picked, &[], section, None, &[], None)? {
let mut radii = Vec::with_capacity(picked.len());
for (&e, &d) in picked.iter().zip(&each) {
match chamfer_radius(k, &mut w, e, d)? {
Some(r) => radii.push(r),
None => return Ok(Err(Error::Status(INVALID_INPUT))),
}
}
return Ok(match blend_edges(k, &mut w, &picked, &radii, Section::Chamfer(&radii), None, &[], None)? {
Ok(()) => finish(w, solid_in)?,
Err(Ok(_)) => Err(Error::Unsupported),
Err(Err(e)) => Err(e),
@@ -1146,6 +1165,39 @@ fn face_frame(k: &Kernel, b: &mut Brep, u: EdgeuseId) -> solidcore::Result<Optio
Ok(Some((normal * (sign / len), at.binormal)))
}
/// `sides_offsets`: the surface offsets of `e` (its blend edge use's surface first) at which
/// `chamfer_offsets` cuts a chamfer `d1` back along the face whose outward normal at the edge's
/// midpoint leans most along `up`, and `second` back along the other (or at the angle `second`
/// to the first face). With b0, b1 the directions into the faces and θ the angle between them,
/// setbacks s0, s1 lie at offsets r0 = (s1 - s0 cos θ) / sin θ and r1 = (s0 - s1 cos θ) / sin θ;
/// an angle φ to the first face gives s1 = s0 sin φ / sin(θ + φ).
fn sides_offsets(k: &Kernel, b: &mut Brep, e: EdgeId, d1: f64, second: ChamferSecond, up: Vec3) -> solidcore::Result<Option<(f64, f64)>> {
let Some(u0) = edge::blend_edgeuse(k, b, e)? else { return Ok(None) };
let Some(u1) = eu_p1::radial(b, u0)? else { return Ok(None) };
let (Some((n0, b0)), Some((n1, b1))) = (face_frame(k, b, u0)?, face_frame(k, b, u1)?) else { return Ok(None) };
let c = b0.dot(b1).clamp(-1.0, 1.0);
let sn = (1.0 - c * c).max(0.0).sqrt();
if !(sn > 1e-6) {
return Ok(None);
}
let mut s0 = d1;
let mut s1 = match second {
ChamferSecond::Distance(d) => d,
ChamferSecond::Angle(phi) => {
let d = (c.acos() + phi).sin();
if !(d > 1e-9) {
return Ok(None);
}
d1 * phi.sin() / d
}
};
if n1.dot(up) > n0.dot(up) {
std::mem::swap(&mut s0, &mut s1);
}
let (r0, r1) = ((s1 - s0 * c) / sn, (s0 - s1 * c) / sn);
Ok((r0.is_finite() && r1.is_finite() && r0 > 0.0 && r1 > 0.0).then_some((r0, r1)))
}
/// `smlib_chamfer_sides`: the edges nearest `points` chamfered `distance` back along the face
/// whose outward normal leans most along `first`, and by `second` on the other, with bevelled
/// corners.
@@ -1167,20 +1219,17 @@ pub(crate) fn chamfer_sides(body: &Body, points: &[[f64; 3]], tolerance: f64, di
Err(m) => return Ok(Err(edge_miss(m))),
};
let solid_in = brep_p2::is_manifold_solid(&w)?;
// The first size is along the face leaning most along `up`.
let mut along = Vec::with_capacity(picked.len());
let (mut r0, mut r1) = (Vec::with_capacity(picked.len()), Vec::with_capacity(picked.len()));
for &e in &picked {
match answer(faces_along(k, &mut w, e, up))? {
Some([(f0, l0), (f1, l1)]) => along.push(if l1 > l0 { f1 } else { f0 }),
match sides_offsets(k, &mut w, e, distance, second, up)? {
Some((a, b)) => {
r0.push(a);
r1.push(b);
}
None => return Ok(Err(Error::Status(INVALID_INPUT))),
}
}
let size = match second {
ChamferSecond::Distance(d) => ChamferSize::Distances([distance, d]),
ChamferSecond::Angle(angle) => ChamferSize::DistanceAngle { distance, angle },
};
let section = Section::Chamfer { sizes: std::slice::from_ref(&size), along: &along };
Ok(match blend_edges(k, &mut w, &picked, &[], section, None, &[], None)? {
Ok(match blend_edges(k, &mut w, &picked, &r0, Section::Chamfer(&r1), None, &[], None)? {
Ok(()) => finish(w, solid_in)?,
Err(Ok(_)) => Err(Error::Unsupported),
Err(Err(e)) => Err(e),
+11 -14
View File
@@ -18,8 +18,6 @@
use solidcore::EntityRef;
use solidcore::SolidError;
use solidcore::error::offset::OffsetFailure;
use solidcore::error::{ErrorKind, Reason};
use solidcore::geom::attribute::{Attribute, Behavior, ids};
use solidcore::geom::axis2_placement::Axis2Placement;
use solidcore::geom::object::Shape;
@@ -31,6 +29,7 @@ use solidcore::nurbs::b_spline_curve::ControlPointForm;
use solidcore::nurbs::b_spline_surface::p1::SurfParamType;
use solidcore::nurbs::b_spline_surface_n_lib::p2::OrientType;
use solidcore::nurbs::knots::Side;
use solidcore::offset::construction::ShellRefusal;
use solidcore::offset::offset_executive::p2::{OffsetOptions, ShellOptions, local_operation_simple, offset_brep_simple, shell_brep_simple};
use solidcore::offset::offset_faces::{OffsetFacesExecutive, OffsetFacesRefusal};
use solidcore::query::api_queries::{face_bounding_box, face_classify_uv, face_closest_point_in_shape, face_internal_point};
@@ -51,23 +50,21 @@ type Out<T> = std::result::Result<T, Error>;
/// `SM_ERR_INVALID_INPUT`.
const INVALID_INPUT: i32 = 1007;
/// `SM_ERR_OFFSET_WALLS_TOUCH` (fork a759622), which `status` (smlib_shim.h) reports as
/// `SMLIB_ERR_WALLS_TOUCH`.
const OFFSET_WALLS_TOUCH: i32 = 1042;
/// `status` (smlib_shim.h): an offset whose walls touch is [`Error::WallsTouch`]; a vertex moved
/// past an edge's other end (`SM_ERR_OFFSET_EDGE_VANISHES`, fork 34318f4) is
/// `SMLIB_ERR_UNSUPPORTED`; a wall whose face's offset folds, which solidcore's one construction
/// refuses (`fixes.shell_one_construction`, solidcore#2367), is [`Error::WallFolds`] with the
/// largest wall it names; any other status as the blends pass it on. solidcore names an offset's
/// refusals by their kind and reason (its errors in its own terms, solidcore#2654), whichever
/// status the pinned C++ gives them.
/// largest wall it names; any other status as the blends pass it on.
fn status(e: SolidError) -> Error {
if let SolidError::Failed(f) = &e
&& let Reason::Offset(OffsetFailure::Folds { largest, .. }) = f.reason
{
return Error::WallFolds { largest: Some(largest) };
}
match e.kind() {
ErrorKind::OffsetWallsTouch => Error::WallsTouch,
ErrorKind::OffsetEdgeVanishes => Error::Unsupported,
_ => blend_status(e),
match e {
SolidError::Upstream { code: OFFSET_WALLS_TOUCH, .. } => Error::WallsTouch,
SolidError::ShellRefused { refusal: ShellRefusal::Fold, size, .. } => Error::WallFolds { largest: size },
e if e.is_offset_edge_vanishes() => Error::Unsupported,
e => blend_status(e),
}
}
+4 -3
View File
@@ -546,8 +546,9 @@ fn offset_faces_close_a_step_wall() {
/// Rig case shell/4492's rounded box: four mitred rounds, r 3, come off a 40 x 30 x 10 box's top
/// together and follow it. From height 0 the outline is the 34 x 24 top moved out by the round's
/// w(z) = sqrt(9 - (z - axis)^2), or 3 below the axis. With the fork's values at +19, and bodies
/// whose rounds go all the way down from -7 and are cut short from -7.5 (solidcore#2027). At -9,
/// which solidcore refused before 9856ffee5 (solidcore#2020), it builds the closed form too.
/// whose rounds go all the way down from -7 and are cut short from -7.5 (solidcore#2027). At -9
/// solidcore refuses to put the rounds back (solidcore#2020, open): SolidCraft says it does not
/// make that move.
#[test]
fn mitred_rounds_follow_a_moved_top() {
use super::blend::{Corners, blend};
@@ -573,7 +574,6 @@ fn mitred_rounds_follow_a_moved_top() {
(-7.0, [6, 12, 8]),
(-7.5, [6, 12, 8]),
(-8.0, [6, 12, 8]),
(-9.0, [6, 12, 8]),
(-9.5, [6, 12, 8]),
];
for (d, n) in moves {
@@ -582,6 +582,7 @@ fn mitred_rounds_follow_a_moved_top() {
assert_eq!(counts(&moved).unwrap(), n, "d {d}");
assert!(rel(v, analytic(d)) < 1e-6, "d {d}: {v} vs {}", analytic(d));
}
assert!(matches!(offset_faces(&rounded, &[top], 0.1, -9.0), Err(Error::Unsupported)));
}
/// `moved_faces`: drafts so small they leave a plane or cylinder where it was still moved a face
+140 -23
View File
@@ -24,20 +24,21 @@
use std::sync::PoisonError;
use solidcore::EntityRef;
use solidcore::curve::curve::Curve;
use solidcore::geom::extent2d::Extent2d;
use solidcore::geom::geom_utility::p2::ContinuityType;
use solidcore::geom::object::Shape;
use solidcore::geom::tol::USE_DEFAULT;
use solidcore::geom::vector2d::Point2;
use solidcore::geom::vector3d::{Point3, Vec3};
use solidcore::kernel::Kernel;
use solidcore::nurbs::b_spline_surface::p1::SurfParamType;
use solidcore::nurbs::b_spline_surface_n_lib::p2::EvaluateFlags;
use solidcore::nurbs::b_spline_surface_n_lib::p2::OrientType;
use solidcore::query::api_queries::{edge_closest_point, face_closest_point_in_shape, face_internal_point};
use solidcore::query::brep::{BrepQuery, LiveBrepObjects};
use solidcore::surface::cone::p1::Cone;
use solidcore::surface::extend::Continuity;
use solidcore::surface::side::BoxSide;
use solidcore::surface::surface::Surface;
use solidcore::surface::surface::SurfaceKind;
use solidcore::topology::brep::p1 as brep_p1;
@@ -53,7 +54,6 @@ use solidcore::topology::face::{p1 as face_p1, p2 as face_p2, p4 as face_p4};
use solidcore::topology::loopuse::{loop_edges, loop_loopuses, notify_ref};
use solidcore::topology::topology_traverser::Marks;
use solidcore::topology::{Brep, BrepId, EdgeId, FaceId, LoopId, edge};
use solidcore::{EntityRef, SolidError};
use super::blend::{p3, status};
use super::sweep::build_faces;
@@ -454,17 +454,17 @@ pub(crate) fn untrim_face(body: &Body, point: [f64; 3], tol: f64, holes: Option<
/// grows in u), and whether it is at the box's max.
#[derive(Clone, Copy)]
struct Side {
which: BoxSide,
which: SurfParamType,
u: bool,
high: bool,
}
/// `SIDES`.
const SIDES: [Side; 4] = [
Side { which: BoxSide::UMin, u: true, high: false },
Side { which: BoxSide::UMax, u: true, high: true },
Side { which: BoxSide::VMin, u: false, high: false },
Side { which: BoxSide::VMax, u: false, high: true },
Side { which: SurfParamType::UMin, u: true, high: false },
Side { which: SurfParamType::UMax, u: true, high: true },
Side { which: SurfParamType::VMin, u: false, high: false },
Side { which: SurfParamType::VMax, u: false, high: true },
];
/// `SAMPLES`.
@@ -546,6 +546,55 @@ fn side_edge(k: &Kernel, b: &Brep, surf: &Surface, dom: &Extent2d, s: Side, edge
e.filter(|_| d <= on)
}
/// `across`: how far `ext` (the face's surface extended past side `s` of its box `dom`, on the
/// same parameters) runs past that side, measured across it through the side's middle, by
/// 16-point Gauss-Legendre quadrature on 64 pieces. Negative when it can't be evaluated.
fn across(k: &Kernel, ext: &Surface, dom: &Extent2d, s: Side) -> f64 {
const X: [f64; 8] = [
0.0950125098376374,
0.2816035507792589,
0.4580167776572274,
0.6178762444026438,
0.755404408355003,
0.8656312023878318,
0.9445750230732326,
0.9894009349916499,
];
const W: [f64; 8] = [
0.1894506104550685,
0.1826034150449236,
0.1691565193950025,
0.1495959888165767,
0.1246289712555339,
0.0951585116824928,
0.0622535239386479,
0.0271524594117541,
];
const PIECES: usize = 64;
let Ok(bx) = ext.natural_uv_domain_with_nurb(k) else { return -1.0 };
let mid = on_side(dom, s, 0.5);
let from = if s.u { mid.x } else { mid.y };
let to = match (s.u, s.high) {
(true, true) => bx.max().x,
(true, false) => bx.min().x,
(false, true) => bx.max().y,
(false, false) => bx.min().y,
};
let h = (to - from) / PIECES as f64;
let mut length = 0.0;
for piece in 0..PIECES {
let c = from + (piece as f64 + 0.5) * h;
for i in 0..16 {
let (x, w) = if i < 8 { (-X[i], W[i]) } else { (X[i - 8], W[i - 8]) };
let t = c + 0.5 * h * x;
let uv = if s.u { Point2::new(t, mid.y) } else { Point2::new(mid.x, t) };
let Some((_, du, dv)) = eval(k, ext, uv) else { return -1.0 };
length += 0.5 * h.abs() * w * if s.u { du } else { dv }.length();
}
}
length
}
/// `cone_continued`: a cone's or cylinder's face continued naturally `distance` past side `s` of
/// its box `dom`, as the analytic itself over a wider STEP domain (degrees round the axis,
/// height along it). `None` where the cone can't be widened so (more than a turn, or past the
@@ -607,16 +656,82 @@ fn same_patch(k: &Kernel, p: &Surface, q: &Surface, tol: f64) -> bool {
}
/// `extended_by`: the surface `surf` (whose face is its whole box `dom`) extended past side `s` as
/// `how` says, so that it runs `distance` past that side measured across it, exactly: solidcore's
/// one extension ([`Surface::extended_by_distance`]), which keeps the source's own part bit for bit
/// and meets the distance within 1e-9 of it. A distance it cannot meet exactly is
/// [`Error::NotExact`], never a surface that falls short or runs past.
fn extended_by(k: &Kernel, surf: &Surface, dom: &Extent2d, s: Side, how: Continuity, distance: f64) -> Result<Surface> {
match surf.extended_by_distance(k, dom, s.which, how, distance) {
Ok(e) => Ok(e.surface),
Err(SolidError::ExtendRefused { .. } | SolidError::NotConverged { .. }) => Err(Error::NotExact),
Err(e) => Err(status(e)),
/// `ct` says, so that it runs `distance` past that side measured across it ([`across`]), within
/// 1e-9 of it. `CreateExtendedSurface` takes its distance as an estimate, and how far it then runs
/// is increasing in what it is asked: a bracketed solve on that (each ask scaled by the distance
/// over what it gave until two asks fall either side, then Illinois false position). An extension
/// that moves the face's side off its parameters, or a target not met, is [`Error::NotExact`],
/// never the surface as it came out.
fn extended_by(k: &Kernel, surf: &Surface, dom: &Extent2d, s: Side, ct: ContinuityType, distance: f64, model_tol: f64) -> Result<Surface> {
const BRACKET_TRIES: usize = 40;
const SOLVE_TRIES: usize = 100;
let tol = 1e-9 * distance;
// How far asking `ask` runs, and the surface it makes.
let try_ask = |ask: f64| -> Result<(Surface, f64)> {
let tried = surf.create_extended_surface_dir(k, s.which, ask, ct).map_err(status)?;
// The face's side stays where it was on the extended surface's parameters.
let mid = on_side(dom, s, 0.5);
let stays = matches!((point_at(k, surf, mid), point_at(k, &tried, mid)), (Some(was), Some(now)) if (was - now).length() <= model_tol);
if !stays {
return Err(Error::NotExact);
}
let got = across(k, &tried, dom, s);
if got.is_finite() && got > 0.0 { Ok((tried, got)) } else { Err(Error::NotExact) }
};
// Asks that run short of and past `distance`, by f = got - distance.
let (mut short, mut past) = (None, None);
let mut ask = distance;
for _ in 0..BRACKET_TRIES {
let (made, got) = try_ask(ask)?;
let f = got - distance;
if f.abs() <= tol {
return Ok(made);
}
if f < 0.0 {
short = Some((ask, f));
} else {
past = Some((ask, f));
}
if short.is_some() && past.is_some() {
break;
}
ask *= distance / got;
}
let (Some((mut lo, mut flo)), Some((mut hi, mut fhi))) = (short, past) else { return Err(Error::NotExact) };
// Illinois: false position, halving the value at the end that stays when the same end moves
// twice running.
#[derive(PartialEq)]
enum Moved {
Neither,
Lo,
Hi,
}
let mut moved = Moved::Neither;
for _ in 0..SOLVE_TRIES {
let x = hi - fhi * (hi - lo) / (fhi - flo);
if !(x > lo.min(hi) && x < lo.max(hi)) {
break;
}
let (made, got) = try_ask(x)?;
let f = got - distance;
if f.abs() <= tol {
return Ok(made);
}
if f < 0.0 {
(lo, flo) = (x, f);
if moved == Moved::Lo {
fhi *= 0.5;
}
moved = Moved::Lo;
} else {
(hi, fhi) = (x, f);
if moved == Moved::Hi {
flo *= 0.5;
}
moved = Moved::Hi;
}
}
Err(Error::NotExact)
}
/// `smlib_extend_sheet`: the one-face sheet `sheet`, whose face is its surface's whole parameter
@@ -672,13 +787,15 @@ pub(crate) fn extend_sheet(sheet: &Body, point: [f64; 3], tol: f64, distance: f6
if (nl.x - dl.x).abs() > ptol || (nl.y - dl.y).abs() > ptol || (nh.x - dh.x).abs() > ptol || (nh.y - dh.y).abs() > ptol {
return Ok(Err(Error::Unsupported));
}
let continuity = match how {
Continuation::Tangent => Continuity::Tangent,
Continuation::Curvature => Continuity::Curvature,
Continuation::Natural => Continuity::Natural,
let ct = match how {
Continuation::Tangent => ContinuityType::G1,
Continuation::Curvature => ContinuityType::G1G2,
Continuation::Natural => ContinuityType::CInfinity,
};
// The extension runs `distance` across the side, exactly (solidcore's one extension).
let extended = match extended_by(k, &surf, &dom, grow, continuity, distance) {
// CreateExtendedSurface takes its distance as an estimate (a natural parabola falls 2.5%
// short, a G2 run-off of a circle runs more than twice as far): the distance asked of it
// is solved for until the extension measures `distance` across the side's middle.
let extended = match extended_by(k, &surf, &dom, grow, ct, distance, model_tol) {
Ok(e) => e,
Err(e) => return Ok(Err(e)),
};
+1 -1
View File
@@ -55,7 +55,7 @@ pub enum Error {
/// middle of the edge it was found for, and why there is no room, when the refusal names them.
TooLarge { size: f64, kind: FitKind, at: Option<[f64; 3]>, why: Option<RoomReason> },
/// A shell, offset or thicken whose wall is thicker than a face curves: the face's offset
/// folds (solidcore's `OffsetFailure::Folds`). The largest wall that works,
/// folds (solidcore's `ShellRefused`, `ShellRefusal::Fold`). The largest wall that works,
/// when solidcore names one.
WallFolds { largest: Option<f64> },
/// SMLib cannot build this exactly yet (a variable radius whose law has a corner).
+13
View File
@@ -486,6 +486,19 @@ fn bodies_move_between_kernels_and_calls_run_side_by_side() {
}
}
/// Each kernel has its own range of blend serials: a body keeps the serials of the kernel that
/// made it, and another kernel's memo of solved stations must not take them for its own.
#[test]
fn kernels_never_issue_the_same_blend_serial() {
let range = core::BLEND_SERIALS_PER_KERNEL as usize;
let ranges = core::run("outer", |outer| {
let inner = core::run("inner", |inner| Ok(inner.blend_last_serial.get() as usize)).unwrap_or_default();
Ok([outer.blend_last_serial.get() as usize / range, inner / range])
});
let [outer, inner] = ranges.unwrap();
assert_ne!(outer, inner);
}
/// A panic in a call running beside another costs neither: the panicking call's kernel is thrown
/// away and the other call and later ones go on.
#[test]
+16 -7
View File
@@ -2380,7 +2380,7 @@ pub const CANVASES: &str = "c0/canvases";
/// The Canvases folder (reference images) with an eye per canvas.
fn canvas_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize, acts: &mut Actions) {
let canvases: Vec<(u64, String, bool)> = app.session.doc.canvases.iter().map(|c| (c.id, c.name.clone(), c.visible)).collect();
let canvases: Vec<(u64, String, bool, bool)> = app.session.doc.canvases.iter().map(|c| (c.id, c.name.clone(), c.visible, c.locked)).collect();
if canvases.is_empty() {
return;
}
@@ -2403,8 +2403,9 @@ fn canvas_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize, acts: &mut A
if !open {
return;
}
for (id, name, visible) in canvases {
for (id, name, visible, locked) in canvases {
let selected = app.tree.picked_canvases.contains(&id);
let badges: &[&str] = if locked { &["lock"] } else { &[] };
let r = draw_row(
ui,
ui.scope_id().with(("canvas", id)),
@@ -2416,6 +2417,7 @@ fn canvas_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize, acts: &mut A
name: true,
selected,
dim: !visible || !shown,
badges,
..Default::default()
},
);
@@ -2435,7 +2437,9 @@ fn canvas_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize, acts: &mut A
app.tree.picked_canvases.push(id);
}
}
if r.double {
if r.double && locked {
app.set_status(crate::i18n::tr("browser.canvas_locked"), true);
} else if r.double {
app.tree.canvas_panel = Some(CanvasPanel::new(app, id, false));
}
if let Some(p) = r.secondary {
@@ -2454,14 +2458,19 @@ pub fn point_cloud_items(app: &SolveApp, id: u64) -> Vec<Item> {
/// The menu of a canvas.
pub fn canvas_items(app: &SolveApp, id: u64) -> Vec<Item> {
let visible = app.session.doc.canvases.iter().find(|c| c.id == id).is_some_and(|c| c.visible);
let canvas = app.session.doc.canvases.iter().find(|c| c.id == id);
let visible = canvas.is_some_and(|c| c.visible);
// A locked canvas (#12310) is not moved, calibrated or deleted until it is unlocked.
let locked = canvas.is_some_and(|c| c.locked);
let why = locked.then(|| crate::i18n::tr("browser.canvas_locked").to_string());
vec![
Item::action("ui.editCanvas", "Edit Canvas", "canvas").with(json!({ "canvas": id })),
Item::action("ui.calibrateCanvas", "Calibrate", "measure").with(json!({ "canvas": id })),
Item::action("ui.editCanvas", "Edit Canvas", "canvas").with(json!({ "canvas": id })).off_because(why.clone()),
Item::action("ui.calibrateCanvas", "Calibrate", "measure").with(json!({ "canvas": id })).off_because(why.clone()),
Item::action("canvas.to_sketch", "Canvas to Sketch", "canvas").with(json!({ "canvas": id })),
Item::sep(),
Item::action("ui.delete", "Delete", "delete").with(json!({ "canvases": [id] })),
Item::action("ui.delete", "Delete", "delete").with(json!({ "canvases": [id] })).off_because(why),
Item::action("ui.rename", "Rename", "rename").key("F2").with(json!({ "canvas": id })),
Item::action("ui.canvasLocked", if locked { "Unlock" } else { "Lock" }, "ground").with(json!({ "canvas": id, "locked": !locked })),
Item::sep(),
Item::action("ui.canvasVisible", "Show/Hide", "eye").key("V").with(json!({ "canvas": id, "visible": !visible })),
]
@@ -217,6 +217,9 @@ fn colours_outside_the_theme_are_only_the_listed_neutral_ones() {
continue;
}
let src = std::fs::read_to_string(&path).unwrap();
if src.trim_start().starts_with("#![cfg(test)]") {
continue;
}
let code = src.split("#[cfg(test)]").next().unwrap_or(&src);
let n: usize = COLOUR_LITERALS.iter().map(|p| code.matches(p).count()).sum();
let allowed = ALLOWED_LITERALS.iter().find(|(f, _, _)| *f == relative).map_or(0, |(_, n, _)| *n);
+1 -1
View File
@@ -1801,7 +1801,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
"ui.isolateOccurrence" => {
let _ = app.run("browser.visibility", json!({"visible": true, "paths": [p["path"]], "isolate": true}));
}
"ui.canvasVisible" => drop(app.run("canvas.edit", p)),
"ui.canvasVisible" | "ui.canvasLocked" => drop(app.run("canvas.edit", p)),
"ui.toolbarPin"
| "ui.toolbarRemove"
| "ui.toolbarReset"
+86 -1
View File
@@ -27,7 +27,7 @@
//! section the viewport shows (no window needed); the ground grid and the origin's axes as the
//! window shows them, unless `grid` or `axes` says
//! - `ui.icon_audit {reset?, stop?}`: collect/drain requested icon names, resolved masters and placeholder flags;
//! - `ui.panel_fixture {enabled?, preset?}`: opt-in synthetic content in the real panel host;
//! - `ui.panel_fixture {enabled?, preset?, translucency_percent?}`: opt-in synthetic content in the real panel host;
//! `{}` observes the current host, probe and Session without ticking or model commands
//! - `ui.menu {x?, y?, target?, close?}`: open a context menu at a point (the viewport's marking
//! menu, or a browser menu with `target: {type: body|sketch|component, …}`); without a point,
@@ -1890,6 +1890,12 @@ mod tests {
json!({"preset":"x".repeat(4096)}),
json!({"enabled":false,"preset":"docks"}),
json!({"enabled":true,"unknown":0}),
json!({"preset":"surfaces","translucency_percent":-1}),
json!({"preset":"surfaces","translucency_percent":101}),
json!({"preset":"surfaces","translucency_percent":"50"}),
json!({"preset":"surfaces","translucency_percent":null}),
json!({"translucency_percent":50}),
json!({"enabled":false,"translucency_percent":50}),
] {
assert!(crate::panel_runtime::control_specimen(&mut app, &params).is_err(), "{params}");
assert!(app.panel_runtime().is_none());
@@ -1917,6 +1923,7 @@ mod tests {
("stack_tabs", 4),
("content_fill", 2),
("inline", 1),
("surfaces", 6),
] {
let before = crate::panel_runtime::inspect_host_specimen(&mut h.app).unwrap()["session"].clone();
h.step(&json!({"panel_fixture":{"enabled":true,"preset":name}})).unwrap();
@@ -1939,6 +1946,84 @@ mod tests {
}
}
#[test]
fn panel_surface_fixture_converts_translucency_without_saving_preferences() {
for percent in [0, 50, 100] {
let mut h = crate::scenario::Harness::new();
let preferences = serde_json::to_value(&h.app.preferences).unwrap();
crate::panel_runtime::control_specimen(&mut h.app, &json!({"preset":"surfaces","translucency_percent":percent})).unwrap();
h.frames(4);
let reply = crate::panel_runtime::inspect_host_specimen(&mut h.app).unwrap();
let sections = reply["host"]["sections"].as_array().unwrap();
assert!(!sections.is_empty());
for section in sections {
let alpha = section["paint_alpha"].as_u64().unwrap();
assert!(
alpha >= ((100 - percent) as f64 * 255.0 / 100.0).ceil() as u64,
"readability protection can only increase the requested opacity: {section}"
);
assert!(!section["contrast"].is_null(), "every painted surface reports its contrast decision");
}
assert_eq!(serde_json::to_value(&h.app.preferences).unwrap(), preferences);
}
}
#[test]
fn panel_fixture_live_opacity_updates_retain_controllers_layout_and_document() {
let mut h = crate::scenario::Harness::new();
crate::panel_runtime::control_specimen(&mut h.app, &json!({"preset":"surfaces","translucency_percent":0})).unwrap();
h.frames(4);
let document = h.app.session.doc.clone();
let model = h.app.session.model.state();
let camera = h.app.cam;
let preferences = serde_json::to_value(&h.app.preferences).unwrap();
let before = crate::panel_runtime::inspect_host_specimen(&mut h.app).unwrap();
let sections = h
.app
.universal_panel_frame()
.unwrap()
.layout
.sections
.iter()
.map(|section| (section.section.clone(), section.outer, section.body, section.surface))
.collect::<Vec<_>>();
for percent in [50, 100, 0] {
let prior = crate::panel_runtime::inspect_host_specimen(&mut h.app).unwrap();
let updated = crate::panel_runtime::control_specimen(&mut h.app, &json!({"translucency_percent":percent})).unwrap();
assert_eq!(updated["runtime"], prior["runtime"], "updating opacity does not tick, dispatch or replace content");
h.frames(4);
let after = crate::panel_runtime::inspect_host_specimen(&mut h.app).unwrap();
assert_eq!(after["runtime"]["layout"], before["runtime"]["layout"]);
for (old, new) in
before["runtime"]["probe"]["controllers"].as_array().unwrap().iter().zip(after["runtime"]["probe"]["controllers"].as_array().unwrap())
{
assert_eq!(new["controller_identity"], old["controller_identity"]);
assert_eq!(new["draft"], old["draft"]);
assert_eq!(new["transaction_live"], old["transaction_live"]);
}
assert_eq!(
h.app
.universal_panel_frame()
.unwrap()
.layout
.sections
.iter()
.map(|section| (section.section.clone(), section.outer, section.body, section.surface))
.collect::<Vec<_>>(),
sections
);
assert!(std::sync::Arc::ptr_eq(&document, &h.app.session.doc));
assert!(std::sync::Arc::ptr_eq(&model, &h.app.session.model.state()));
assert_eq!(h.app.cam, camera);
assert_eq!(serde_json::to_value(&h.app.preferences).unwrap(), preferences);
if percent == 0 {
assert!(h.app.universal_panel_frame().unwrap().layout.sections.iter().all(|section| section.paint_alpha == Some(255)));
}
}
h.app.disable_universal_panel_host().unwrap();
assert!(crate::panel_runtime::control_specimen(&mut h.app, &json!({"translucency_percent":50})).is_err());
}
#[test]
fn panel_fixture_teardown_honors_live_editor_guard_atomically() {
fn click_visible_guard(h: &mut crate::scenario::Harness, guard: &str) {
+1 -1
View File
@@ -1589,7 +1589,7 @@ impl Dialog {
/// lies on that face (a circle's centre) is taken at the point, as a points input picks it.
pub(crate) fn candidate_on(&self, s: &Session, h: &Hit, snap: Option<&FaceSnap>) -> Option<Sel> {
if matches!(self.kind, Kind::SketchModify(_)) {
return crate::sketch_modify_ui::candidate(self, s, h);
return crate::sketch_modify_ui::pick_candidate(self, s, h);
}
if let Some(target) = self.extrude_target(s, h) {
return Some(target);
+113 -20
View File
@@ -1086,26 +1086,47 @@ pub fn pick_reference(app: &SolveApp, ctx: &egui::Context, pos: Pos2, double: bo
/// The gap the value box keeps from the point it belongs to (#11057): a click at the pointer
/// reaches the sketch (to place the text or pick a second entity) instead of the box.
const EDIT_GAP: f32 = 30.0;
/// The box's height as its placement works it out: a name line, the value and a possible error.
/// The box's least height as its placement works it out: a name line, the value and a possible
/// error. A box drawn taller (a wrapped error) is placed by the height it was drawn at.
const EDIT_HEIGHT: f32 = 80.0;
/// The room the box leaves at the view's right and bottom edges.
const EDIT_MARGIN: f32 = 6.0;
/// Where the value box opens: beside the text, or beside the pointer while a value is typed
/// first. Near the viewport's edge it opens on the point's other side rather than slide back
/// over it.
fn edit_anchor(at: Pos2, live: bool, drawable: Option<egui::Rect>, popup_width: f32) -> Pos2 {
let mut anchor = if live { at + vec2(EDIT_GAP, EDIT_GAP) } else { at - vec2(45.0, 11.0) };
let Some(rect) = drawable else { return anchor };
if live {
if anchor.x + popup_width > rect.right() - 6.0 {
anchor.x = at.x - popup_width - EDIT_GAP;
}
if anchor.y + EDIT_HEIGHT > rect.bottom() - 6.0 {
anchor.y = at.y - EDIT_HEIGHT - EDIT_GAP;
}
/// first. `size` is the box's (at least as wide as its field and [`EDIT_HEIGHT`] tall).
///
/// Beside the pointer the box keeps [`EDIT_GAP`] from it on at least one axis, so it never
/// covers the square around the pointer a click picks in (#11057). It opens below and right of
/// the pointer; where the view has no room there it tries left, then above, then above and left.
/// Where no corner fits it keeps the gap on one axis and slides along the other, and it only
/// gives up the gap when the view is too small for the box on either side.
fn edit_anchor(at: Pos2, live: bool, drawable: Option<egui::Rect>, size: egui::Vec2) -> Pos2 {
let (w, h) = (size.x, size.y);
let Some(rect) = drawable else {
return if live { at + vec2(EDIT_GAP, EDIT_GAP) } else { at - vec2(45.0, 11.0) };
};
let clamp_x = |x: f32| x.clamp(rect.left(), (rect.right() - w - EDIT_MARGIN).max(rect.left()));
let clamp_y = |y: f32| y.clamp(rect.top(), (rect.bottom() - h - EDIT_MARGIN).max(rect.top()));
if !live {
return Pos2::new(clamp_x(at.x - 45.0), clamp_y(at.y - 11.0));
}
anchor.x = anchor.x.clamp(rect.left(), (rect.right() - popup_width - 6.0).max(rect.left()));
anchor.y = anchor.y.clamp(rect.top(), (rect.bottom() - EDIT_HEIGHT).max(rect.top()));
anchor
let fits_x = |x: f32| x >= rect.left() && x + w <= rect.right() - EDIT_MARGIN;
let fits_y = |y: f32| y >= rect.top() && y + h <= rect.bottom() - EDIT_MARGIN;
let (right, left) = (at.x + EDIT_GAP, at.x - EDIT_GAP - w);
let (below, above) = (at.y + EDIT_GAP, at.y - EDIT_GAP - h);
let corners = [(right, below), (left, below), (right, above), (left, above)];
if let Some(&(x, y)) = corners.iter().find(|(x, y)| fits_x(*x) && fits_y(*y)) {
return Pos2::new(x, y);
}
// No corner fits: clear of the pointer below or above it, slid sideways into the view, or
// else clear of it to one side, slid up or down.
if let Some(y) = [below, above].into_iter().find(|y| fits_y(*y)) {
return Pos2::new(clamp_x(right), y);
}
if let Some(x) = [right, left].into_iter().find(|x| fits_x(*x)) {
return Pos2::new(x, clamp_y(below));
}
Pos2::new(clamp_x(right), clamp_y(below))
}
fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
@@ -1132,7 +1153,11 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
// picks next, and it opens on the pointer's other side at the viewport's edge (#11057).
let drawable = app.viewport.layout.as_ref().map(|l| l.drawable).filter(|r| r.is_finite() && r.is_positive());
let popup_width = drawable.map_or(220.0, |rect| (rect.width() - 8.0).clamp(24.0, 220.0));
let anchor = edit_anchor(at, e.live.is_some(), drawable, popup_width);
// Placed by the size it was drawn at last frame (its frame's margins included), so a
// taller box flipped above the pointer still ends short of it.
let drawn = ctx.memory(|m| m.area_rect(egui::Id::unique("sc_dim_edit_area"))).map(|r| r.size()).filter(|s| s.is_finite());
let size = drawn.map_or(vec2(popup_width, EDIT_HEIGHT), |s| s.max(vec2(popup_width, EDIT_HEIGHT)));
let anchor = edit_anchor(at, e.live.is_some(), drawable, size);
// Background: under Preferences and Report a bug (#9016), like canvas value boxes.
Container::new(ctx, Surface::Canvas).area(egui::Id::unique("sc_dim_edit_area")).fixed_pos(anchor).order(egui::Order::Background).show(
ctx,
@@ -1357,13 +1382,81 @@ mod tests {
fn the_value_box_keeps_its_gap_from_the_pointer() {
use egui::pos2;
let view = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 600.0));
let a = edit_anchor(pos2(300.0, 200.0), true, Some(view), 220.0);
let size = vec2(220.0, 80.0);
let a = edit_anchor(pos2(300.0, 200.0), true, Some(view), size);
assert_eq!(a, pos2(330.0, 230.0), "30 px below and right of the pointer");
// Its right and bottom edges stop 30 px short of the pointer.
let a = edit_anchor(pos2(960.0, 560.0), true, Some(view), 220.0);
let a = edit_anchor(pos2(960.0, 560.0), true, Some(view), size);
assert_eq!((a.x + 220.0, a.y + 80.0), (930.0, 530.0), "left of and above the pointer");
// Editing an existing dimension still opens where its text is.
assert_eq!(edit_anchor(pos2(300.0, 200.0), false, Some(view), 220.0), pos2(255.0, 189.0));
assert_eq!(edit_anchor(pos2(300.0, 200.0), false, Some(view), size), pos2(255.0, 189.0));
}
/// #11057: wherever the pointer is and whatever the box's size, the box stays inside the view
/// and out of the square of `EDIT_GAP` around the pointer: a box drawn taller than its
/// estimate, flipped above the pointer, ends short of it, and in a small view the box slides
/// along one axis but keeps its gap on the other.
#[test]
fn the_value_box_never_covers_the_pointer_and_stays_in_the_view() {
use egui::pos2;
let clear = |a: Pos2, size: egui::Vec2, p: Pos2| {
let r = Rect::from_min_size(a, size);
r.left() >= p.x + EDIT_GAP - 1e-3
|| r.right() <= p.x - EDIT_GAP + 1e-3
|| r.top() >= p.y + EDIT_GAP - 1e-3
|| r.bottom() <= p.y - EDIT_GAP + 1e-3
};
// A tall box (a wrapped error line) near the bottom: above the pointer by its real height.
let view = Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 600.0));
let tall = vec2(228.0, 130.0);
let a = edit_anchor(pos2(300.0, 560.0), true, Some(view), tall);
assert_eq!(a.y + tall.y, 530.0, "the bottom edge stops 30 px above the pointer");
// A view too short for the box above or below the pointer but wide enough beside it.
let short = Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 120.0));
let a = edit_anchor(pos2(500.0, 60.0), true, Some(short), vec2(220.0, 80.0));
assert_eq!(a.x, 530.0, "beside the pointer, right of it");
assert!(clear(a, vec2(220.0, 80.0), pos2(500.0, 60.0)));
// A view too narrow for the box beside the pointer but tall enough below it.
let narrow = Rect::from_min_size(pos2(0.0, 0.0), vec2(260.0, 600.0));
let a = edit_anchor(pos2(130.0, 100.0), true, Some(narrow), vec2(220.0, 80.0));
assert_eq!(a.y, 130.0, "below the pointer, slid sideways into the view");
assert!(clear(a, vec2(220.0, 80.0), pos2(130.0, 100.0)));
// A sweep of pointers over views of several sizes and offsets, and boxes of several sizes.
let views = [
view,
Rect::from_min_size(pos2(240.0, 60.0), vec2(700.0, 420.0)),
Rect::from_min_size(pos2(40.0, 30.0), vec2(300.0, 260.0)),
short,
narrow,
];
for view in views {
for size in [vec2(220.0, 80.0), vec2(236.0, 96.0), vec2(228.0, 140.0), vec2(120.0, 80.0)] {
for i in 0..=20 {
for j in 0..=20 {
let p = pos2(view.left() + view.width() * i as f32 / 20.0, view.top() + view.height() * j as f32 / 20.0);
let a = edit_anchor(p, true, Some(view), size);
let r = Rect::from_min_size(a, size);
let room = view.width() >= size.x + EDIT_MARGIN && view.height() >= size.y + EDIT_MARGIN;
if room {
assert!(r.left() >= view.left() && r.top() >= view.top(), "{view:?} {size:?} {p:?}: {r:?}");
assert!(
r.right() <= view.right() - EDIT_MARGIN + 1e-3 && r.bottom() <= view.bottom() - EDIT_MARGIN + 1e-3,
"{view:?} {size:?} {p:?}: {r:?}"
);
}
// Clear of the pointer whenever the view has room for the box on one of its
// sides: above or below it, or left or right of it.
let vertical = p.y + EDIT_GAP + size.y <= view.bottom() - EDIT_MARGIN || p.y - EDIT_GAP - size.y >= view.top();
let horizontal = p.x + EDIT_GAP + size.x <= view.right() - EDIT_MARGIN || p.x - EDIT_GAP - size.x >= view.left();
if room && (vertical || horizontal) {
assert!(clear(a, size, p), "{view:?} {size:?} {p:?}: {r:?}");
}
}
}
}
}
// A view or pointer that is not a number does not panic.
let _ = edit_anchor(pos2(f32::NAN, f32::INFINITY), true, Some(view), vec2(220.0, 80.0));
}
#[test]
+14
View File
@@ -840,6 +840,20 @@ fn canvas_menu_hides_renames_calibrates_and_deletes() {
assert!(app.session.doc.canvases.is_empty());
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc.canvases.len(), 1);
// A locked canvas (#12310) can't be edited, calibrated or deleted, from its menu or the Delete key.
act(&mut app, &t, "Lock");
assert!(app.session.doc.canvases[0].locked);
let items = context_menu::items(&app, &t);
for label in ["Edit Canvas", "Calibrate", "Delete"] {
let item = find(&items, label);
assert!(!item.enabled && item.tip.as_deref() == Some(crate::i18n::tr("browser.canvas_locked")), "{label}: {item:?}");
}
assert!(find(&items, "Show/Hide").enabled && find(&items, "Rename").enabled);
app.tree.picked_canvases = vec![id];
crate::delete::delete_selection(&mut app);
assert_eq!(app.session.doc.canvases.len(), 1, "the locked canvas stays");
act(&mut app, &t, "Unlock");
assert!(!app.session.doc.canvases[0].locked);
// A canvas picked in the browser goes with the Delete key.
app.tree.picked_canvases = vec![id];
crate::delete::delete_selection(&mut app);
+49 -8
View File
@@ -3,7 +3,9 @@
//! dialog asks for the unit first: OK imports with it (`units` of `file.insert_mesh` and
//! `doc.open`, which scale the coordinates to millimetres as the file is read), Cancel imports
//! nothing. The design keeps the millimetre mesh and not the unit, so a different unit is tried by
//! importing the file again.
//! importing the file again. The same dialog offers Move to origin (`origin` of the same
//! commands, off unless ticked, #12543): the import is moved so its box is centred on X and Y
//! with its base on Z = 0.
//!
//! Only interactive entry points ask ([`file_load::begin`]): a script, the control channel or MCP
//! calls the commands with their own `units`, or none for millimetres, and never meets a dialog.
@@ -24,6 +26,8 @@ pub struct Ask {
pub choice: usize,
/// Insert it as a mesh whatever its extension says (a PLY chosen with Insert Mesh).
pub mesh: bool,
/// Move it to the origin as it comes in (`origin`); off until ticked.
pub origin: bool,
}
/// Files that state no unit of their own and take one when imported.
@@ -39,7 +43,7 @@ pub fn ask(app: &mut SolveApp, path: &str, insert: bool) -> bool {
if !needs_unit(path) {
return false;
}
app.mesh_units = Some(Ask { path: path.into(), insert, choice: default_choice(), mesh: false });
app.mesh_units = Some(Ask { path: path.into(), insert, choice: default_choice(), mesh: false, origin: false });
true
}
@@ -50,7 +54,7 @@ pub fn ask_ply_as_mesh(app: &mut SolveApp, path: &str) -> bool {
if !solidcraft_engine::io::is_ply_path(path) {
return false;
}
app.mesh_units = Some(Ask { path: path.into(), insert: true, choice: default_choice(), mesh: true });
app.mesh_units = Some(Ask { path: path.into(), insert: true, choice: default_choice(), mesh: true, origin: false });
true
}
@@ -74,6 +78,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui_kit::Window::new(egui::Id::unique("sc_mesh_units"), "import", crate::i18n::tr("ui.point_cloud_units")).show(ctx, true, |ui| {
ui_kit::text(ui, &file, ui_kit::Text::Report("file", false));
ui_kit::choice_row(ui, crate::i18n::tr("ui.units"), "mesh_units", &mut ask.choice, &names);
let origin = ui_kit::checkbox(ui, crate::i18n::tr("ui.mesh_import_origin"), &mut ask.origin)
.on_hover_text(crate::i18n::tr("ui.mesh_import_origin_tip"));
// Named like the dialog's buttons: outside a labelled row the checkbox's own handle
// is a bare `dialog:`, which scripts can't tell apart.
crate::scenario::publish_handle("mesh_units:origin", origin.rect.center());
});
for (handle, key) in [("mesh_units:ok", "dialog:ok"), ("mesh_units:cancel", "dialog:cancel")] {
if let Some(at) = crate::scenario::handle_at(key) {
@@ -83,11 +92,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
match action {
ui_kit::Action::Ok => {
app.mesh_units = None;
let kind = if ask.insert { file_load::Kind::Insert } else { file_load::Kind::Open };
let mut request = file_load::Request::new(&ask.path, kind);
request.mesh = ask.mesh;
request.options.insert("units".into(), json!(unit_at(ask.choice)));
let _ = file_load::begin_request(app, request);
let _ = file_load::begin_request(app, request(&ask));
}
ui_kit::Action::Cancel => app.mesh_units = None,
ui_kit::Action::None if closed => app.mesh_units = None,
@@ -95,6 +100,19 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
}
/// The import the dialog's answer starts: the file with its unit, and `origin` when ticked (left
/// out otherwise, so the command's default stands).
fn request(ask: &Ask) -> file_load::Request {
let kind = if ask.insert { file_load::Kind::Insert } else { file_load::Kind::Open };
let mut request = file_load::Request::new(&ask.path, kind);
request.mesh = ask.mesh;
request.options.insert("units".into(), json!(unit_at(ask.choice)));
if ask.origin {
request.options.insert("origin".into(), json!(true));
}
request
}
#[cfg(test)]
mod tests {
use super::*;
@@ -137,4 +155,27 @@ mod tests {
assert_eq!((ask.path.as_str(), ask.insert, unit_at(ask.choice)), ("part.stl", true, "mm"));
assert!(app.file_load.idle(), "nothing is imported until OK");
}
#[test]
fn move_to_origin_is_off_until_ticked_and_then_sent() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
assert!(ask(&mut app, "part.stl", true));
let mut held = app.mesh_units.clone().expect("asked");
assert!(!held.origin, "off by default");
assert!(!request(&held).options.contains_key("origin"), "unticked: the command's default");
held.origin = true;
held.choice = 3;
let sent = request(&held);
assert_eq!((sent.options.get("origin"), sent.options.get("units")), (Some(&json!(true)), Some(&json!("in"))));
}
#[test]
fn the_move_to_origin_checkbox_is_found_by_its_name() {
let mut h = crate::scenario::Harness::new();
assert!(ask(&mut h.app, "part.stl", true));
h.frames(3);
for handle in ["mesh_units:origin", "mesh_units:ok", "mesh_units:cancel"] {
assert!(crate::scenario::handle_at(handle).is_some(), "missing {handle}");
}
}
}
+5 -1
View File
@@ -419,6 +419,9 @@ impl AppPanelRuntime {
self.host = Some(WorkspacePanelHost::new(options)?);
Ok(())
}
pub fn set_requested_opacity(&mut self, opacity: Option<f32>) -> PanelResult<()> {
self.host.as_mut().ok_or_else(|| error(Rule::Lifecycle, "universal panel host disabled"))?.set_requested_opacity(opacity)
}
pub fn disable_host(&mut self) -> PanelResult<()> {
if self.host.is_some() {
self.with_adapters(|runtime, _native, services| runtime.mount_in_app(&ResolvedLayout::default(), &mut EmptyPanelHost, services))?;
@@ -1007,11 +1010,12 @@ impl crate::SolveApp {
.filter(|bounds| bounds.is_positive() && bounds.is_finite())
.map(logical_rect)
.collect();
let force_opaque = self.services.reduce_transparency.as_ref().and_then(|read| read(true)) == Some(true);
let prepared = match &mut self.panel_runtime {
Some(integration) => {
let result = (|| {
if let Some(host) = &mut integration.host {
host.note_egui_context(ui.ctx());
host.note_egui_context(ui.ctx(), force_opaque);
host.set_gap(gap)?;
}
integration.prepare_host_in_domain(&self.session, logical_rect(render_domain), logical_rect(placement), &chrome)
+21 -2
View File
@@ -5,6 +5,7 @@ use std::collections::{BTreeMap, BTreeSet};
use solidcraft_engine::panels::*;
use super::HostSurfaceStyle;
use super::interaction::{HostPointerTarget, HostProposalSnapshot, HostResizeEdge};
/// Compatibility constant only; the host never allocates rails.
@@ -568,6 +569,10 @@ pub struct HostSurfaceAlpha {
#[derive(Clone, Debug)]
pub struct HostGeometryPolicy {
pub surface_alpha: Option<HostSurfaceAlpha>,
/// Captured renderer state; never written into a layout envelope.
pub surfaces: Option<HostSurfaceStyle>,
/// Runtime normalized opacity override; None retains authored theme opacity.
pub requested_opacity: Option<f32>,
pub default_opaque: bool,
pub effective_surfaces: BTreeMap<SectionId, PanelSurface>,
pub content: Vec<SideContentMeasure>,
@@ -579,7 +584,9 @@ pub struct HostGeometryPolicy {
impl PartialEq for HostGeometryPolicy {
fn eq(&self, other: &Self) -> bool {
self.surface_alpha == other.surface_alpha
self.surfaces == other.surfaces
&& self.requested_opacity.map(f32::to_bits) == other.requested_opacity.map(f32::to_bits)
&& self.surface_alpha == other.surface_alpha
&& self.default_opaque == other.default_opaque
&& self.effective_surfaces == other.effective_surfaces
&& self.identity_reservations == other.identity_reservations
@@ -598,6 +605,8 @@ impl Default for HostGeometryPolicy {
fn default() -> Self {
Self {
surface_alpha: None,
surfaces: None,
requested_opacity: None,
default_opaque: true,
effective_surfaces: BTreeMap::new(),
content: Vec::new(),
@@ -625,6 +634,7 @@ pub struct HostTab {
#[derive(Clone, Debug, PartialEq)]
pub struct HostSection {
pub contrast: Option<ContrastOutcome>,
pub paint_alpha: Option<u8>,
pub section: SectionId,
pub instance: InstanceId,
@@ -765,6 +775,9 @@ impl HostLayout {
}
}
self.metrics.validate()?;
if self.geometry_policy.requested_opacity.is_some_and(|value| !value.is_finite() || !(0.0..=1.0).contains(&value)) {
return Err(error(Rule::Bounds, "invalid requested panel opacity"));
}
if self.geometry_policy.effective_surfaces.len() > MAX_SECTIONS {
return Err(error(Rule::CollectionLimit, "host geometry policy bound"));
}
@@ -1349,8 +1362,14 @@ fn append_section(
"ui.panel_host.resize",
);
}
let paint_alpha = policy.surface_alpha.map(|alpha| if section.location.zone() == Zone::Floating { alpha.floating } else { alpha.docked });
let floating = section.location.zone() == Zone::Floating;
let appearance = policy.surfaces.as_ref().map(|style| style.appearance(surface, floating));
let paint_alpha = appearance
.map(|appearance| appearance.fill.a())
.or_else(|| policy.surface_alpha.map(|alpha| if floating { alpha.floating } else { alpha.docked }));
let contrast = appearance.map(|appearance| appearance.contrast.clone());
out.sections.push(HostSection {
contrast,
paint_alpha,
section: section.id.clone(),
instance: instance.clone(),
+2
View File
@@ -8,6 +8,7 @@ mod layout;
mod preferences;
mod runtime;
mod specimen;
mod surfaces;
mod workspace;
pub use app::*;
@@ -19,6 +20,7 @@ pub use preferences::*;
pub(crate) use preferences::{PanelLayoutSnapshot, panel_layout_matches, panel_layout_snapshot};
pub use runtime::*;
pub use specimen::*;
pub use surfaces::*;
pub use workspace::*;
#[cfg(test)]
+60 -16
View File
@@ -13,7 +13,8 @@ use super::*;
const WORKSPACE: &str = "panel_fixture.workspace";
const INSPECT: &str = "panel_fixture.inspect";
const PRESETS: &[&str] = &["docks", "tabs", "collapsed", "floating", "overflow", "pinned", "columns", "stack_tabs", "content_fill", "inline"];
const PRESETS: &[&str] =
&["docks", "tabs", "collapsed", "floating", "overflow", "pinned", "columns", "stack_tabs", "content_fill", "inline", "surfaces"];
type SpecimenIdentities = Rc<RefCell<BTreeMap<ControllerKey, Rc<()>>>>;
fn preset(value: &str) -> PanelResult<()> {
@@ -23,10 +24,10 @@ fn preset(value: &str) -> PanelResult<()> {
/// The caller explicitly opts into original synthetic content at the actual
/// workspace composition point. An existing runtime is never replaced.
pub fn enable_host_specimen(app: &mut crate::SolveApp, name: &str) -> PanelResult<()> {
enable_host_specimen_remembered(app, name, false)
enable_host_specimen_remembered(app, name, false, None)
}
fn enable_host_specimen_remembered(app: &mut crate::SolveApp, name: &str, remember: bool) -> PanelResult<()> {
fn enable_host_specimen_remembered(app: &mut crate::SolveApp, name: &str, remember: bool, requested_opacity: Option<f32>) -> PanelResult<()> {
preset(name)?;
if app.panel_runtime().is_some() {
return Err(error(Rule::Lifecycle, "panel specimen cannot take over an existing runtime"));
@@ -95,7 +96,10 @@ fn enable_host_specimen_remembered(app: &mut crate::SolveApp, name: &str, rememb
// the app or leaving a partially enabled specimen behind.
let mut integration = AppPanelRuntime::new(runtime, Box::new(services))?;
integration.tick(&app.session)?;
integration.enable_host(HostOptions::default())?;
let mut options = HostOptions::default();
options.geometry.default_opaque = name != "surfaces";
integration.enable_host(options)?;
integration.set_requested_opacity(requested_opacity)?;
let repaired = if remember {
let mut catalogue = specimen_catalogue(name)?;
super::preferences::configure_preferences(&mut integration, &app.panel_layout_preferences, &mut catalogue)?
@@ -157,7 +161,13 @@ fn specimen_layout(name: &str) -> PanelResult<(Layout, PanelRegistry)> {
default_size: Size { width: 280.0, height: 240.0 },
max_size: Size { width: MAX_LOGICAL, height: 1200.0 },
edge_rule: if pinned { EdgeRule::PinOuter } else { EdgeRule::None },
surface: PanelSurface::FollowZone,
surface: if name == "surfaces" && fill {
PanelSurface::Opaque
} else if name == "surfaces" && pinned {
PanelSurface::Translucent
} else {
PanelSurface::FollowZone
},
features: PanelFeatures { multi_instance: !editor, popout: false, hot_closable: editor, ..PanelFeatures::default() },
side_height_policy: if fill { SideHeightPolicy::Fill } else { SideHeightPolicy::Content },
header_actions: if editor {
@@ -169,7 +179,9 @@ fn specimen_layout(name: &str) -> PanelResult<(Layout, PanelRegistry)> {
})?;
}
let mut layout = Layout::empty(WorkspaceId::new(WORKSPACE)?);
let count = if name == "overflow" {
let count = if name == "surfaces" {
6
} else if name == "overflow" {
24
} else if name == "columns" {
3
@@ -184,16 +196,23 @@ fn specimen_layout(name: &str) -> PanelResult<(Layout, PanelRegistry)> {
let id = InstanceId::new(format!("specimen-{index}"))?;
let kind = if name == "inline" {
"fixture.log"
} else if name == "content_fill" && index == 1 {
} else if (name == "content_fill" && index == 1) || (name == "surfaces" && index == 3) {
"fixture.fill"
} else if name == "pinned" && index == 1 {
} else if (name == "pinned" && index == 1) || (name == "surfaces" && index == 2) {
"fixture.pin"
} else if index == 0 {
"fixture.editor"
} else {
"fixture.log"
};
let zone = if name == "inline" {
let zone = if name == "surfaces" {
match index {
0 | 1 => Zone::Left,
2 | 3 => Zone::Right,
4 => Zone::Bottom,
_ => Zone::Floating,
}
} else if name == "inline" {
Zone::Bottom
} else if matches!(name, "columns" | "stack_tabs") {
Zone::Left
@@ -206,12 +225,14 @@ fn specimen_layout(name: &str) -> PanelResult<(Layout, PanelRegistry)> {
_ => Zone::Bottom,
}
};
let location = if name == "floating" {
let location = if name == "floating" || (name == "surfaces" && index == 5) {
SectionLocation::Floating { origin: Point { x: 48.0 + index as f32 * 64.0, y: 48.0 + index as f32 * 48.0 } }
} else {
SectionLocation::Dock { zone }
};
let group = if name == "tabs" {
let group = if name == "surfaces" && index == 1 {
0
} else if name == "tabs" {
index % 2
} else if name == "stack_tabs" && index == 3 {
1
@@ -233,6 +254,10 @@ fn specimen_layout(name: &str) -> PanelResult<(Layout, PanelRegistry)> {
let (location, column, collapsed, tab_index) = if let Some(section) = layout.sections.iter_mut().find(|section| section.id == section_id) {
let tab_index = section.tabs.len();
section.tabs.push(id.clone());
if name == "surfaces" {
// The inactive text-heavy editor still controls FollowZone.
section.active = id.clone();
}
(section.location.clone(), section.column.clone(), section.collapsed, tab_index)
} else {
if let Some(column) = &column
@@ -538,23 +563,42 @@ fn session_observation(app: &crate::SolveApp) -> Value {
/// An empty object inspects without ticking services or dispatching layout/model actions.
pub(crate) fn control_specimen(app: &mut crate::SolveApp, params: &Value) -> Result<Value, String> {
let object = params.as_object().ok_or("panel_fixture params must be an object")?;
if object.keys().any(|key| !matches!(key.as_str(), "enabled" | "preset" | "remember")) {
if object.keys().any(|key| !matches!(key.as_str(), "enabled" | "preset" | "remember" | "translucency_percent")) {
return Err("unknown panel_fixture parameter".into());
}
let enabled = object.get("enabled").map(|v| v.as_bool().ok_or("enabled must be a boolean")).transpose()?;
let name = object.get("preset").map(|v| v.as_str().ok_or("preset must be a string")).transpose()?;
let remember = object.get("remember").map(|v| v.as_bool().ok_or("remember must be a boolean")).transpose()?;
let requested_opacity = object
.get("translucency_percent")
.map(|value| {
let percent = value
.as_f64()
.filter(|percent| percent.is_finite() && (0.0..=100.0).contains(percent))
.ok_or("translucency_percent must be a finite number from 0 to 100")?;
Ok::<f32, &str>((1.0 - percent / 100.0) as f32)
})
.transpose()?;
if let Some(name) = name {
preset(name).map_err(|e| e.to_string())?;
}
if enabled == Some(false) && (name.is_some() || remember.is_some()) {
return Err("preset or remember cannot be combined with enabled=false".into());
if enabled == Some(false) && (name.is_some() || remember.is_some() || requested_opacity.is_some()) {
return Err("preset, remember or translucency_percent cannot be combined with enabled=false".into());
}
if remember.is_some() && enabled != Some(true) && name.is_none() {
return Err("remember requires enabling a panel specimen".into());
}
if enabled == Some(true) || name.is_some() {
enable_host_specimen_remembered(app, name.unwrap_or("docks"), remember.unwrap_or(false)).map_err(|e| e.to_string())?;
enable_host_specimen_remembered(app, name.unwrap_or("docks"), remember.unwrap_or(false), requested_opacity).map_err(|e| e.to_string())?;
} else if let Some(opacity) = requested_opacity {
if !app.universal_panel_host_enabled() || !app.panel_runtime().is_some_and(|runtime| specimen_workspace(&runtime.environment().workspace)) {
return Err("translucency_percent requires an enabled panel specimen host".into());
}
let integration = app.panel_runtime_mut().ok_or("panel specimen runtime disappeared")?;
// Confirm the provider as well as the workspace marker before changing
// an existing host. Updating presentation retains controllers and drafts.
integration.with_services(|_, services| services.models.read(&ContentOwner::Application, INSPECT)).map_err(|e| e.to_string())?;
integration.set_requested_opacity(Some(opacity)).map_err(|e| e.to_string())?;
}
let owns_runtime = app.panel_runtime().is_some_and(|runtime| specimen_workspace(&runtime.environment().workspace));
if enabled == Some(false) && app.panel_runtime().is_some() {
@@ -616,7 +660,7 @@ fn host_observation(app: &crate::SolveApp) -> Value {
json!({
"section":section.section,"instance":section.instance,"zone":section.zone,
"outer":section.outer,"header":section.header,"body":section.body,"footer":section.footer,
"collapsed":section.collapsed,"inline":section.inline,"surface":section.surface,"paint_alpha":section.paint_alpha,"hidden_tabs":section.hidden_tabs,
"collapsed":section.collapsed,"inline":section.inline,"surface":section.surface,"paint_alpha":section.paint_alpha,"contrast":section.contrast,"hidden_tabs":section.hidden_tabs,
})
})
.collect();
@@ -0,0 +1,84 @@
//! Captured, paint-only surface readability. Geometry and authored theme intent stay unchanged.
use std::sync::Arc;
use egui::Color32;
use solidcraft_engine::panels::{ContrastFailure, ContrastOutcome, PanelSurface};
use crate::theme::{self, Tokens};
#[derive(Clone, Debug, PartialEq)]
pub struct HostSurfaceAppearance {
pub fill: Color32,
pub contrast: ContrastOutcome,
}
#[derive(Clone, Debug, PartialEq)]
pub struct HostSurfaceStyle {
authored: Arc<Tokens>,
fallback: Arc<Tokens>,
raw_fills: [Color32; 2],
force_opaque: bool,
requested_bits: Option<u32>,
dark: bool,
opaque: HostSurfaceAppearance,
docked: HostSurfaceAppearance,
floating: HostSurfaceAppearance,
}
impl HostSurfaceStyle {
/// `requested_opacity` is normalized opacity, not translucency or percent. None
/// retains the actual shared frame's effective theme/blur alpha.
pub fn capture(ctx: &egui::Context, force_opaque: bool, requested_opacity: Option<f32>, previous: Option<&Self>) -> Self {
let authored = Tokens::get();
let raw_fills = [crate::ui_kit::panel_host_frame(ctx, false).fill, crate::ui_kit::panel_host_frame(ctx, true).fill];
let requested_bits = requested_opacity.map(f32::to_bits);
let dark = theme::is_dark();
if let Some(previous) = previous.filter(|previous| {
*previous.authored == authored
&& previous.raw_fills == raw_fills
&& previous.force_opaque == force_opaque
&& previous.requested_bits == requested_bits
&& previous.dark == dark
}) {
return previous.clone();
}
let fallback = theme::panel_fallback_tokens(authored);
let invalid = requested_opacity.is_some_and(|alpha| !alpha.is_finite() || !(0.0..=1.0).contains(&alpha));
let resolve = |fill: Color32, opaque: bool| {
if invalid {
return fallback_appearance(&fallback, ContrastFailure::InvalidTokens);
}
let requested = if opaque || force_opaque { 1.0 } else { requested_opacity.unwrap_or(f32::from(fill.a()) / 255.0) };
resolve_appearance(fill, &authored, &fallback, requested)
};
let opaque = resolve(raw_fills[0], true);
let docked = resolve(raw_fills[0], false);
let floating = resolve(raw_fills[1], false);
Self { authored: Arc::new(authored), fallback: Arc::new(fallback), raw_fills, force_opaque, requested_bits, dark, opaque, docked, floating }
}
pub fn appearance(&self, surface: PanelSurface, floating: bool) -> &HostSurfaceAppearance {
if surface == PanelSurface::Opaque || self.force_opaque {
&self.opaque
} else if floating {
&self.floating
} else {
&self.docked
}
}
pub fn palette(&self, appearance: &HostSurfaceAppearance) -> &Tokens {
if matches!(appearance.contrast, ContrastOutcome::RetainReadableFallback { .. }) { &self.fallback } else { &self.authored }
}
}
fn fallback_appearance(tokens: &Tokens, reason: ContrastFailure) -> HostSurfaceAppearance {
HostSurfaceAppearance { fill: tokens.panel, contrast: ContrastOutcome::RetainReadableFallback { reason } }
}
fn resolve_appearance(fill: Color32, tokens: &Tokens, fallback: &Tokens, requested: f32) -> HostSurfaceAppearance {
match theme::panel_surface_fill(fill, tokens, requested) {
Some(fill) => HostSurfaceAppearance { fill, contrast: ContrastOutcome::Readable { effective_background_alpha: f32::from(fill.a()) / 255.0 } },
None => fallback_appearance(fallback, ContrastFailure::UnreadableAtFullOpacity),
}
}
+298 -3
View File
@@ -1,7 +1,285 @@
#![cfg(test)]
use solidcraft_engine::panels::*;
use super::*;
fn surface_test_palette(dark: bool) -> crate::theme::Tokens {
let mut tokens = if dark { crate::theme::Tokens::dark() } else { crate::theme::Tokens::light() };
tokens.panel = if dark { egui::Color32::BLACK } else { egui::Color32::WHITE };
let ink = if dark { egui::Color32::WHITE } else { egui::Color32::BLACK };
tokens.text = ink;
tokens.text_dim = ink;
tokens.form_ink = ink;
tokens.form_dim = ink;
tokens.dialog_label = ink;
tokens
}
#[test]
fn panel_surface_policy_keeps_disabled_host_storage_small() {
assert!(
std::mem::size_of::<HostSurfaceStyle>() < std::mem::size_of::<crate::theme::Tokens>(),
"disabled host policy must not embed whole palettes in SolveApp"
);
}
#[test]
fn panel_surface_contrast_covers_actual_light_dark_and_bundled_high_contrast() {
use egui::Color32;
use solidcraft_engine::geom::srgb::{decode8, encode8};
let high_contrast = crate::theme_files::resolve(crate::theme_files::find("high-contrast", &[]).unwrap());
// Independent stored-premultiplied source-over for both real renderer target
// formats. This does not call the production contrast/bounds predicate.
let blend = |source: Color32, ground: Color32, linear: bool| {
let remain = 1.0 - f64::from(source.a()) / 255.0;
let channel = |source: u8, ground: u8| {
if linear {
encode8(decode8(source) + decode8(ground) * remain)
} else {
(f64::from(source) + f64::from(ground) * remain).round().clamp(0.0, 255.0) as u8
}
};
Color32::from_rgb(channel(source.r(), ground.r()), channel(source.g(), ground.g()), channel(source.b(), ground.b()))
};
let inks = |tokens: &crate::theme::Tokens| [tokens.text, tokens.text_dim, tokens.form_ink, tokens.form_dim, tokens.dialog_label];
for (name, authored) in [("light", crate::theme::Tokens::light()), ("dark", crate::theme::Tokens::dark()), ("high-contrast", high_contrast)] {
let ctx = egui::Context::default();
let _scope = crate::theme::scoped_panel_tokens(authored);
assert!(inks(&authored).iter().all(|ink| ink.a() == 255), "these shipped canonical ink pairs are opaque: {name}");
for floating in [false, true] {
let raw = crate::ui_kit::panel_host_frame(&ctx, floating).fill.to_srgba_unmultiplied();
let opaque = Color32::from_rgb(raw[0], raw[1], raw[2]);
let impossible = inks(&authored).iter().any(|ink| crate::theme::contrast_ratio(*ink, opaque) < 4.5);
for translucency_percent in [0.0_f32, 50.0, 100.0] {
let requested = 1.0 - translucency_percent / 100.0;
let style = HostSurfaceStyle::capture(&ctx, false, Some(requested), None);
let appearance = style.appearance(PanelSurface::Translucent, floating);
let palette = style.palette(appearance);
match &appearance.contrast {
ContrastOutcome::Readable { effective_background_alpha } => {
assert!(!impossible, "a shipped unreadable authored pair cannot be reported compliant: {name}");
assert!(*effective_background_alpha >= requested);
assert_eq!(inks(palette), inks(&authored), "ordinary resolution preserves every canonical ink: {name}");
}
ContrastOutcome::RetainReadableFallback { reason } => {
assert!(impossible, "fallback is justified by an independently checked opaque pair: {name}");
assert_eq!(*reason, ContrastFailure::UnreadableAtFullOpacity);
assert_eq!(appearance.fill.a(), 255);
}
}
for viewport in [0, 1, 64, 117, 192, 254, 255] {
for linear in [false, true] {
let background = blend(appearance.fill, Color32::from_gray(viewport), linear);
for ink in inks(palette) {
let physical_ink = blend(ink, background, linear);
assert!(
crate::theme::contrast_ratio(physical_ink, background) >= 4.5,
"{name}: primary/muted text at translucency {translucency_percent}, viewport {viewport}, linear={linear}"
);
}
}
}
}
}
}
}
#[test]
fn captured_panel_surfaces_protect_all_inks_at_requested_translucencies() {
let ctx = egui::Context::default();
for dark in [false, true] {
let tokens = surface_test_palette(dark);
let _scope = crate::theme::scoped_panel_tokens(tokens);
for translucency_percent in [0.0_f32, 50.0, 100.0] {
let opacity = 1.0 - translucency_percent / 100.0;
let style = HostSurfaceStyle::capture(&ctx, false, Some(opacity), None);
for floating in [false, true] {
let appearance = style.appearance(PanelSurface::Translucent, floating);
let ContrastOutcome::Readable { effective_background_alpha } = appearance.contrast else {
panic!("valid theme must remain authored")
};
assert!(effective_background_alpha >= opacity);
assert_eq!(effective_background_alpha, f32::from(appearance.fill.a()) / 255.0);
assert_eq!(style.palette(appearance), &tokens);
assert!(crate::theme::panel_surface_readable(appearance.fill, style.palette(appearance)));
if appearance.fill.a() > (opacity * 255.0).ceil() as u8 {
let [r, g, b, alpha] = appearance.fill.to_srgba_unmultiplied();
let previous = egui::Color32::from_rgba_unmultiplied(r, g, b, alpha - 1);
assert!(!crate::theme::panel_surface_readable(previous, &tokens), "chosen byte is the first readable candidate");
}
}
assert_eq!(style.appearance(PanelSurface::Opaque, false).fill.a(), 255);
}
}
}
#[test]
fn panel_contrast_checks_interior_luminance_and_translucent_muted_inks() {
let mut tokens = surface_test_palette(false);
// This ink has >=4.5 contrast against both black and white, yet an interior
// viewport background can match it exactly. Endpoint checks alone are false.
let middle = egui::Color32::from_gray(117);
tokens.form_dim = middle;
assert!(crate::theme::contrast_ratio(middle, egui::Color32::BLACK) >= 4.5);
assert!(crate::theme::contrast_ratio(middle, egui::Color32::WHITE) >= 4.5);
assert!(!crate::theme::panel_surface_readable(egui::Color32::TRANSPARENT, &tokens));
tokens.form_dim = egui::Color32::from_rgba_unmultiplied(0, 0, 0, 240);
assert!(crate::theme::panel_surface_readable(egui::Color32::WHITE, &tokens), "RGBA ink is composited rather than rejected");
tokens.form_dim = egui::Color32::TRANSPARENT;
assert!(!crate::theme::panel_surface_readable(egui::Color32::WHITE, &tokens), "invisible muted ink cannot satisfy contrast");
}
#[test]
fn panel_contrast_encloses_actual_premultiplied_gpu_blends() {
use egui::Color32;
use solidcraft_engine::geom::srgb::{decode8, encode8};
// Independent physical source-over oracle: read stored premultiplied bytes,
// never the production readability predicate or unpremultiplication helper.
let physical = |source: Color32, background: Color32, linear: bool, dither: f64| {
let remain = 1.0 - f64::from(source.a()) / 255.0;
let channel = |source: u8, background: u8| {
let source = f64::from(source) + dither;
if linear {
// Test source channels here are integer and undithered on the
// linear path; transfer-function fixtures independently cover it.
encode8(decode8(source.round().clamp(0.0, 255.0) as u8) + decode8(background) * remain)
} else {
(source + f64::from(background) * remain).round().clamp(0.0, 255.0) as u8
}
};
Color32::from_rgb(channel(source.r(), background.r()), channel(source.g(), background.g()), channel(source.b(), background.b()))
};
let premultiplied = Color32::from_rgba_premultiplied(32, 32, 32, 128);
let ground = Color32::from_gray(1);
assert_eq!(physical(premultiplied, ground, false, 0.0), Color32::from_gray(32));
assert_eq!(crate::theme::composite(premultiplied, ground), Color32::from_gray(33), "round-trip reconstruction is a different blend");
for background in [0, 1, 64, 117, 192, 254, 255] {
let ground = Color32::from_gray(background);
let (low, high) = crate::theme::panel_composite_bounds(premultiplied, ground);
for (linear, dither) in [(false, -0.475), (false, 0.0), (false, 0.475), (true, 0.0)] {
let actual = physical(premultiplied, ground, linear, dither);
assert!(low.r() <= actual.r() && actual.r() <= high.r(), "actual stored-byte blend lies inside conservative bounds");
}
}
let ctx = egui::Context::default();
for dark in [false, true] {
let mut tokens = surface_test_palette(dark);
tokens.form_dim = if dark { Color32::from_rgba_unmultiplied(255, 255, 255, 240) } else { Color32::from_rgba_unmultiplied(0, 0, 0, 240) };
let _scope = crate::theme::scoped_panel_tokens(tokens);
let style = HostSurfaceStyle::capture(&ctx, false, Some(0.0), None);
let appearance = style.appearance(PanelSurface::Translucent, false);
assert!(matches!(appearance.contrast, ContrastOutcome::Readable { .. }));
for viewport in [0, 1, 64, 117, 192, 254, 255] {
for linear in [false, true] {
let background = physical(appearance.fill, Color32::from_gray(viewport), linear, 0.0);
for ink in [tokens.text, tokens.text_dim, tokens.form_ink, tokens.form_dim, tokens.dialog_label] {
let text = physical(ink, background, linear, 0.0);
assert!(
crate::theme::contrast_ratio(text, background) >= 4.5,
"independent framebuffer blend preserves both primary and muted text"
);
}
}
}
}
}
#[test]
fn panel_surface_cache_tracks_dark_mode_even_with_identical_authored_tokens() {
struct RestoreDark(bool);
impl Drop for RestoreDark {
fn drop(&mut self) {
crate::theme::set_dark(self.0);
}
}
let _restore = RestoreDark(crate::theme::is_dark());
let ctx = egui::Context::default();
let mut tokens = surface_test_palette(false);
tokens.form_dim = tokens.panel;
let _scope = crate::theme::scoped_panel_tokens(tokens);
crate::theme::set_dark(false);
let light = HostSurfaceStyle::capture(&ctx, false, Some(0.5), None);
crate::theme::set_dark(true);
let dark = HostSurfaceStyle::capture(&ctx, false, Some(0.5), Some(&light));
let light_appearance = light.appearance(PanelSurface::Translucent, false);
let dark_appearance = dark.appearance(PanelSurface::Translucent, false);
assert_ne!(light_appearance.fill, dark_appearance.fill, "fallback family changes despite identical captured authored tokens");
assert_eq!(HostSurfaceStyle::capture(&ctx, false, Some(0.5), Some(&dark)), dark);
}
#[test]
fn invalid_panel_theme_reports_failure_and_uses_opaque_known_tokens() {
let ctx = egui::Context::default();
let mut tokens = surface_test_palette(false);
tokens.form_dim = tokens.panel;
tokens.space_4 = 7.0;
let _scope = crate::theme::scoped_panel_tokens(tokens);
for override_alpha in [Some(0.5), Some(f32::NAN), Some(f32::INFINITY), Some(-0.1), Some(1.1)] {
let style = HostSurfaceStyle::capture(&ctx, false, override_alpha, None);
let appearance = style.appearance(PanelSurface::Translucent, false);
let expected = if override_alpha.is_some_and(|a| !a.is_finite() || !(0.0..=1.0).contains(&a)) {
ContrastFailure::InvalidTokens
} else {
ContrastFailure::UnreadableAtFullOpacity
};
assert_eq!(appearance.contrast, ContrastOutcome::RetainReadableFallback { reason: expected });
assert_eq!(appearance.fill.a(), 255);
assert!(crate::theme::panel_surface_readable(appearance.fill, style.palette(appearance)));
assert_eq!(style.palette(appearance).space_4, tokens.space_4, "fallback does not change layout metrics");
let cached = HostSurfaceStyle::capture(&ctx, false, override_alpha, Some(&style));
assert_eq!(cached, style, "even an invalid raw NaN override has a stable bitwise cache key");
}
}
#[test]
fn panel_surface_capture_matches_shared_frames_and_respects_accessibility() {
let ctx = egui::Context::default();
let tokens = surface_test_palette(false);
let _scope = crate::theme::scoped_panel_tokens(tokens);
let style = HostSurfaceStyle::capture(&ctx, false, None, None);
for floating in [false, true] {
let requested = crate::ui_kit::panel_host_frame(&ctx, floating).fill;
assert!(style.appearance(PanelSurface::Translucent, floating).fill.a() >= requested.a());
}
assert_eq!(HostSurfaceStyle::capture(&ctx, false, None, Some(&style)), style);
let forced = HostSurfaceStyle::capture(&ctx, true, Some(0.0), Some(&style));
assert_ne!(forced, style);
for floating in [false, true] {
let appearance = forced.appearance(PanelSurface::Translucent, floating);
assert_eq!(appearance.fill.a(), 255);
assert!(crate::theme::panel_surface_readable(appearance.fill, forced.palette(appearance)));
}
let second_context = egui::Context::default();
assert_eq!(HostSurfaceStyle::capture(&second_context, false, None, Some(&style)), style);
let mut changed = tokens;
changed.text_dim = egui::Color32::from_gray(20);
let _changed = crate::theme::scoped_panel_tokens(changed);
assert_ne!(HostSurfaceStyle::capture(&ctx, false, None, Some(&style)), style);
}
#[test]
fn panel_paint_palette_scope_restores_nested_tokens_without_changing_base_theme() {
let before = crate::theme::Tokens::get();
let base = crate::theme::Tokens::base();
let mut outer = before;
outer.text = outer.file_primary_ink;
{
let _outer = crate::theme::scoped_panel_tokens(outer);
assert_eq!(crate::theme::Tokens::get(), outer);
assert_eq!(crate::theme::Tokens::get_in(crate::theme::ColorVision::Off), outer);
assert_eq!(crate::theme::Tokens::base(), base);
{
let inner = surface_test_palette(false);
let _inner = crate::theme::scoped_panel_tokens(inner);
assert_eq!(crate::theme::Tokens::get(), inner);
}
assert_eq!(crate::theme::Tokens::get(), outer);
}
assert_eq!(crate::theme::Tokens::get(), before);
assert_eq!(crate::theme::Tokens::base(), base);
}
fn prepare_preference_fixture(app: &mut crate::SolveApp) {
app.panel_runtime
.as_mut()
@@ -12111,12 +12389,18 @@ fn captured_renderer_alpha_reaches_new_sections_and_alpha_only_drift_invalidates
h.app.ui.layout = mode;
h.frames(2);
let painted = h.app.universal_panel_frame().unwrap().layout.clone();
let captured_style = painted.geometry_policy.surfaces.as_ref().unwrap();
let alpha = HostSurfaceAlpha {
docked: crate::ui_kit::panel_host_frame(&h.ctx, false).fill.a(),
floating: crate::ui_kit::panel_host_frame(&h.ctx, true).fill.a(),
docked: captured_style.appearance(PanelSurface::FollowZone, false).fill.a(),
floating: captured_style.appearance(PanelSurface::FollowZone, true).fill.a(),
};
assert_eq!(painted.geometry_policy.surface_alpha, Some(alpha), "the actual app captures the shared renderer policy before allocation");
assert!(painted.sections.iter().all(|card| card.paint_alpha == Some(alpha.docked)));
for card in &painted.sections {
let appearance = captured_style.appearance(card.surface, card.peek || card.zone == Zone::Floating);
assert_eq!(card.paint_alpha, Some(appearance.fill.a()));
assert_eq!(card.contrast.as_ref(), Some(&appearance.contrast));
assert_eq!(card.paint_alpha, Some(255), "Classic's default opaque policy is preserved");
}
let gap = crate::ui_kit::Container::for_layout(mode, crate::ui_kit::Surface::Panel).gap();
assert_eq!(gap, if mode == crate::shell::LayoutMode::Floating { 7.0 } else { 0.0 });
assert_eq!(painted.metrics.gap, gap);
@@ -12159,6 +12443,17 @@ fn captured_renderer_alpha_reaches_new_sections_and_alpha_only_drift_invalidates
.unwrap()
};
let reference = resolve(&policy);
let mut styled_policy = policy.clone();
styled_policy.default_opaque = false;
styled_policy.surfaces = Some(HostSurfaceStyle::capture(&h.ctx, false, Some(0.5), None));
let styled = resolve(&styled_policy);
for card in &styled.sections {
let appearance = styled_policy.surfaces.as_ref().unwrap().appearance(card.surface, card.peek || card.zone == Zone::Floating);
assert_eq!(card.paint_alpha, Some(appearance.fill.a()));
assert_eq!(card.contrast.as_ref(), Some(&appearance.contrast));
}
assert_eq!(styled.render_domain, reference.render_domain);
assert_eq!(styled.controls, reference.controls, "appearance leaves all pointer bounds and identities intact");
let action = transaction(vec![
fixture_open("fixture.log", "alpha-dock", "alpha-dock", Zone::Right, 1).unwrap(),
Mutation::Open {
+38 -10
View File
@@ -243,7 +243,26 @@ pub struct WorkspacePanelHost {
last_chrome: Vec<LogicalRect>,
}
fn validate_requested_opacity(opacity: Option<f32>) -> PanelResult<()> {
if opacity.is_some_and(|value| !value.is_finite() || !(0.0..=1.0).contains(&value)) {
return Err(error(Rule::Bounds, "invalid requested panel opacity"));
}
Ok(())
}
impl WorkspacePanelHost {
/// Override host paint opacity for this runtime only, without changing saved layout.
pub fn set_requested_opacity(&mut self, opacity: Option<f32>) -> PanelResult<()> {
validate_requested_opacity(opacity)?;
if self.options.geometry.requested_opacity != opacity {
self.invalidate_geometry();
self.options.geometry.requested_opacity = opacity;
self.options.geometry.surfaces = None;
self.options.geometry.surface_alpha = None;
}
Ok(())
}
pub(crate) fn set_gap(&mut self, gap: f32) -> PanelResult<()> {
let metrics = HostMetrics { gap, ..self.options.metrics };
metrics.validate()?;
@@ -265,12 +284,7 @@ impl WorkspacePanelHost {
for section in options.geometry.effective_surfaces.keys() {
valid_id(&section.0)?;
}
// Declarative transparency/contrast policy belongs to the surface peer.
// Appearance follows the existing adaptive Container, including its fill
// alpha; this guard does not force an opaque paint override.
if !options.geometry.default_opaque || options.geometry.effective_surfaces.values().any(|surface| *surface == PanelSurface::Translucent) {
return Err(error(Rule::Capability, "translucent host requires the surface renderer"));
}
validate_requested_opacity(options.geometry.requested_opacity)?;
Ok(Self {
options,
native_capabilities: HostCapabilities::default(),
@@ -461,12 +475,16 @@ impl WorkspacePanelHost {
self.prepare_in_domain(runtime, capacity, capacity, &[])
}
pub(crate) fn note_egui_context(&mut self, ctx: &egui::Context) {
pub(crate) fn note_egui_context(&mut self, ctx: &egui::Context, force_opaque: bool) {
self.egui_context = Some(ctx.clone());
let surface_alpha =
Some(HostSurfaceAlpha { docked: ui_kit::panel_host_frame(ctx, false).fill.a(), floating: ui_kit::panel_host_frame(ctx, true).fill.a() });
if self.options.geometry.surface_alpha != surface_alpha {
let surfaces = HostSurfaceStyle::capture(ctx, force_opaque, self.options.geometry.requested_opacity, self.options.geometry.surfaces.as_ref());
let surface_alpha = Some(HostSurfaceAlpha {
docked: surfaces.appearance(PanelSurface::FollowZone, false).fill.a(),
floating: surfaces.appearance(PanelSurface::FollowZone, true).fill.a(),
});
if self.options.geometry.surfaces.as_ref() != Some(&surfaces) || self.options.geometry.surface_alpha != surface_alpha {
self.invalidate_geometry();
self.options.geometry.surfaces = Some(surfaces);
self.options.geometry.surface_alpha = surface_alpha;
}
}
@@ -1845,11 +1863,21 @@ impl PanelHost for WorkspaceContentHost<'_> {
let surface_target = HostControlTarget::Pointer(HostPointerTarget::Surface { instance: geometry.instance.clone() });
let surface_id = scoped_id(self.scope, self.window, Some(&context.owner), &surface_target);
let mut failure = None;
let fallback;
let style = if let Some(style) = self.layout.geometry_policy.surfaces.as_ref() {
style
} else {
fallback = HostSurfaceStyle::capture(self.ui.ctx(), false, self.layout.geometry_policy.requested_opacity, None);
&fallback
};
let appearance = style.appearance(section.surface, section.peek || section.zone == Zone::Floating);
ui_kit::panel_host_surface(
self.ui,
section_surface_id(self.scope, self.window, &section.section),
egui_rect(section.outer),
section.peek || section.zone == Zone::Floating,
appearance.fill,
matches!(appearance.contrast, ContrastOutcome::RetainReadableFallback { .. }).then(|| style.palette(appearance)),
|ui, content_clip| {
if (section.peek || section.zone == Zone::Floating) && (!ui.is_visible() || ui.is_sizing_pass()) {
self.unpainted.push((ui.layer_id(), section.outer));
+39 -9
View File
@@ -113,13 +113,15 @@ struct Row {
impl Row {
/// Whether the Filter box's text (lower case) is in the row's name, expression, text,
/// comment or group. The expression lets a parameter's name find every one that uses it
/// (#9706).
/// (#9706). The name also matches by the starts of its words, in order: `toodia` finds
/// `tooth_outward_flange_diameter` (#11978).
fn matches(&self, filter: &str) -> bool {
filter.is_empty()
|| [&self.name, &self.expression, &self.comment, &self.group]
.into_iter()
.chain(self.text.as_ref())
.any(|field| field.to_ascii_lowercase().contains(filter))
|| solidcraft_engine::doc::name_words_match(&self.name, filter)
}
}
@@ -924,9 +926,19 @@ pub(crate) fn suggestions_for(ctx: &egui::Context, word: &str, explicit: bool) -
let offered = |s: &&Suggestion| (explicit || s.text.trim_end_matches('(') != word) && !(exact && s.kind == "unit");
let starts = |s: &&Suggestion| s.text.to_ascii_lowercase().starts_with(&w);
// A parameter is also offered for a word anywhere in its name (`side` finds
// `cab_side_thickness`, #2757), after the names that start with it.
let within = |s: &&Suggestion| !exact && s.kind == "parameter" && !starts(s) && s.text.to_ascii_lowercase().contains(&w);
all.iter().filter(|s| starts(s) && offered(s)).chain(all.iter().filter(|s| within(s) && offered(s))).take(8).cloned().collect()
// `cab_side_thickness`, #2757), after the names that start with it, and then by the starts
// of its words in order (`toodia` finds `tooth_outward_flange_diameter`, #11978). That last
// group comes after the others, so it never pushes an earlier match out of the eight.
let parameter = |s: &&Suggestion| !exact && s.kind == "parameter" && !starts(s);
let within = |s: &&Suggestion| parameter(s) && s.text.to_ascii_lowercase().contains(&w);
let words = |s: &&Suggestion| parameter(s) && !within(s) && solidcraft_engine::doc::name_words_match(&s.text, word);
all.iter()
.filter(|s| starts(s) && offered(s))
.chain(all.iter().filter(|s| within(s) && offered(s)))
.chain(all.iter().filter(|s| words(s) && offered(s)))
.take(8)
.cloned()
.collect()
}
/// The named parameters, in the order the list offers them (favourites and the design's own
@@ -1346,13 +1358,21 @@ mod tests {
fn a_parameter_is_offered_for_any_part_of_its_name() {
let ctx = egui::Context::default();
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
for (name, e) in [("cab_side_thickness", "18 mm"), ("sides", "4")] {
for (name, e) in [("cab_side_thickness", "18 mm"), ("sides", "4"), ("inner_diameter", "6 mm")] {
let _ = app.run("parameters.add", json!({"name": name, "expression": e}));
}
// A favourite is listed first in the cache, but a match by word starts alone still
// comes after every name that contains the word (#11978).
let _ = app.run("parameters.favorite", json!({"name": "inner_diameter", "favorite": true}));
publish(&mut app, &ctx);
let texts = |w: &str| suggestions(&ctx, w).into_iter().map(|s| s.text).collect::<Vec<_>>();
assert_eq!(texts("side"), ["sides", "cab_side_thickness"]);
assert_eq!(texts("Thick"), ["cab_side_thickness"]);
// #11978: the starts of the name's words, in order.
assert_eq!(texts("cabthi"), ["cab_side_thickness"]);
assert_eq!(texts("sidthi"), ["cab_side_thickness"]);
assert!(texts("thicab").is_empty());
assert_eq!(texts("id"), ["cab_side_thickness", "sides", "inner_diameter"]);
assert!(!texts("in").iter().any(|t| t == "sin("), "functions match from the start");
}
@@ -1481,19 +1501,29 @@ mod tests {
}
/// #9706: the Filter box reads expressions too, so a parameter's name lists every parameter
/// that uses it, and a value finds the parameter that holds it.
/// that uses it, and a value finds the parameter that holds it. #11978: the starts of a
/// name's words, in order, find it.
#[test]
fn the_filter_lists_the_parameters_that_use_a_name() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.call("ui.resize", json!({"width": 1600, "height": 1400}));
for (name, expression) in [("width", "40 mm"), ("height", "width * 2"), ("depth", "5 mm")] {
for (name, expression) in [("width", "40 mm"), ("height", "width * 2"), ("depth", "5 mm"), ("tooth_outward_flange_diameter", "8 mm")] {
h.app.run("parameters.add", json!({"name": name, "expression": expression})).unwrap();
}
h.app.start("parameters.change");
h.frames(6);
let all = ["width", "height", "depth"];
for (filter, expected) in [("width", vec!["width", "height"]), ("WIDTH *", vec!["height"]), ("5 mm", vec!["depth"]), ("", all.to_vec())] {
let all = ["width", "height", "depth", "tooth_outward_flange_diameter"];
let tooth = vec!["tooth_outward_flange_diameter"];
for (filter, expected) in [
("width", vec!["width", "height"]),
("WIDTH *", vec!["height"]),
("5 mm", vec!["depth"]),
("toodia", tooth.clone()),
("OutDia", tooth.clone()),
("diatoo", vec![]),
("", all.to_vec()),
] {
h.app.params.as_mut().unwrap().filter = filter.into();
h.frames(4);
let shown: Vec<&str> = all.into_iter().filter(|n| crate::scenario::rect_of(&format!("dialog:table:user:{n}")).is_some()).collect();
+7 -2
View File
@@ -1643,6 +1643,10 @@ preference_settings! {
/// Opening or editing a sketch turns the view to face it (the default). Off: the view stays as
/// it is, and Look At faces the sketch when asked.
sketch_open_look_at: bool = true, PAGE_SKETCH;
/// Two quick presses of Esc in a sketch, with nothing left to cancel, finish it (the default,
/// as before). Off: Esc only cancels the tool, dialog or selection and the sketch stays open;
/// Finish Sketch ends it (#12069).
escape_twice_finishes_sketch: bool = true, PAGE_SKETCH;
/// A sketch started on a face of a body in another component than the active one activates
/// that component first, so the sketch belongs to it (`sketch.create` `activate_part`,
/// #10215). Off (the default): the sketch belongs to the active component, as before.
@@ -3575,7 +3579,7 @@ pub(crate) fn blur_availability(app: &SolveApp) -> crate::theme_options::Availab
fn blur_availability_for(app: &SolveApp, offered: bool) -> crate::theme_options::Availability {
use crate::theme_options::Availability::{Available, Unavailable};
let reduced = app.services.reduce_transparency.as_ref().and_then(|read| read(offered));
let reduced = app.services.reduce_transparency.as_ref().and_then(|read| read(offered || app.universal_panel_host_enabled()));
if !offered && app.blur_session.harness_radius.is_none() {
return Available;
}
@@ -3615,7 +3619,7 @@ pub(crate) fn prepare_blur(app: &mut SolveApp, ctx: &egui::Context) {
blur_availability_for(app, intent.offered)
} else {
if let Some(read) = &app.services.reduce_transparency {
read(intent.offered);
read(intent.offered || app.universal_panel_host_enabled());
}
crate::theme_options::Availability::Available
};
@@ -7823,6 +7827,7 @@ mod tests {
("project_options_dialog", "prefs.sketch.project_options"),
("sketch_face_activates_part", "prefs.sketch_face_activates_part"),
("sketch_open_look_at", "prefs.sketch_open_look_at"),
("escape_twice_finishes_sketch", "prefs.sketch.escape_twice_finishes"),
("selection_shift_add_only", "prefs.shift_add_ctrl_toggle"),
("prefer_free_sketch_points", "prefs.sketch.prefer_free_points"),
("select_other_hidden", "prefs.sketch.select_other_hidden"),
+7
View File
@@ -205,6 +205,13 @@ fn opening(app: &mut SolveApp, ui: &mut egui::Ui) {
Some("prefs.sketch_open_look_at_note"),
&mut app.preferences.sketch_open_look_at,
);
check(
ui,
"escape_twice_finishes_sketch",
"prefs.sketch.escape_twice_finishes",
Some("prefs.sketch.escape_twice_finishes_note"),
&mut app.preferences.escape_twice_finishes_sketch,
);
picture_group(ui, app.tr("prefs.sketch_exit_view"), |ui| {
let label = app.tr("prefs.sketch_exit_view");
let mut indexed = crate::prefs_search::texts_hit(ui, &[label]);
+1
View File
@@ -56,6 +56,7 @@ const PROFILE_KEYS: &[&str] = &[
"dialog_buttons",
"dialog_place",
"direct_interaction",
"escape_twice_finishes_sketch",
"free_orbit",
"free_orbit_upright",
"hidden_toolbar_tabs",
@@ -17,7 +17,7 @@ fn error(h: &mut Harness, id: &str, params: Value) -> String {
#[test]
fn a_profile_holds_input_and_interface_settings_but_not_the_machine() {
let keys: Vec<&str> = keys().collect();
assert_eq!(keys.len(), 102, "Appearance includes its two system-theme slots");
assert_eq!(keys.len(), 103, "Appearance includes its two system-theme slots");
for key in [
"nav",
"mouse_gestures",
@@ -787,6 +787,7 @@ fn profile_keys_preserve_the_baseline_and_explicit_additions() {
"dialog_buttons",
"dialog_place",
"direct_interaction",
"escape_twice_finishes_sketch",
"free_orbit",
"free_orbit_upright",
"hidden_toolbar_tabs",
@@ -880,7 +881,7 @@ fn profile_keys_preserve_the_baseline_and_explicit_additions() {
];
let mut actual: Vec<_> = keys().collect();
actual.sort_unstable();
assert_eq!(expected.len(), 102);
assert_eq!(expected.len(), 103);
assert_eq!(actual, expected);
}
+40 -1
View File
@@ -48,7 +48,7 @@ fn assert_panel_host_preserves_design(h: &mut Harness, exercise: impl FnOnce(&mu
#[test]
fn universal_panel_host_is_opt_in_and_each_specimen_preserves_design_and_preferences() {
for preset in ["docks", "tabs", "collapsed", "floating", "overflow", "pinned"] {
for preset in ["docks", "tabs", "collapsed", "floating", "overflow", "pinned", "surfaces"] {
let mut h = Harness::new();
h.app.home.open = false;
h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap();
@@ -76,6 +76,45 @@ fn universal_panel_host_is_opt_in_and_each_specimen_preserves_design_and_prefere
}
}
#[test]
fn surface_fixture_routes_real_input_through_short_panel_gaps_at_both_scales_and_directions() {
use crate::panel_runtime::egui_rect;
use solidcraft_engine::panels::{PanelSurface, Point, Zone};
for scale in [1.0, 2.0] {
for language in ["en", "ar"] {
let mut h = Harness::new();
h.app.home.open = false;
h.app.preferences.language = language.into();
h.call("ui.resize", json!({"width":1600,"height":1100}));
h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap();
h.settle();
h.ctx.set_pixels_per_point(scale);
crate::panel_runtime::control_specimen(&mut h.app, &json!({"preset":"surfaces","translucency_percent":100})).unwrap();
h.frames(5);
let frame = h.app.universal_panel_frame().unwrap();
assert_eq!(frame.layout.render_domain, crate::panel_runtime::logical_rect(h.app.viewport.rect.unwrap()));
let left = frame.layout.sections.iter().find(|section| section.zone == Zone::Left).unwrap();
assert_eq!(left.surface, PanelSurface::Opaque, "the inactive text-heavy editor constrains its tab group");
let clear = frame.layout.sections.iter().find(|section| section.instance.0 == "specimen-2").unwrap();
assert_eq!(clear.surface, PanelSurface::Translucent);
assert!(clear.contrast.is_some(), "effective alpha must have a truthful diagnostic");
let body = egui_rect(clear.body.unwrap());
let blocked = body.center();
let gap = egui::pos2(left.outer.origin.x + left.outer.size.width * 0.5, left.outer.origin.y + left.outer.size.height + 24.0);
assert!(!frame.layout.consumes_pointer(Point { x: gap.x, y: gap.y }), "short side content leaves a real gap");
let camera = h.app.cam;
h.call("ui.move", json!({"x":blocked.x,"y":blocked.y}));
h.call("ui.scroll", json!({"x":blocked.x,"y":blocked.y,"delta":120}));
assert!(h.app.viewport.mouse.is_none(), "translucent content shields model picking");
assert_eq!(h.app.cam, camera, "content wheel cannot reach model navigation");
h.call("ui.move", json!({"x":gap.x,"y":gap.y}));
assert_eq!(h.app.viewport.mouse, Some(gap), "uncovered points reach the existing picking path");
h.call("ui.scroll", json!({"x":gap.x,"y":gap.y,"delta":120}));
assert_ne!(h.app.cam.distance.to_bits(), camera.distance.to_bits(), "gap wheel reaches the existing navigation path");
}
}
}
#[test]
fn specimen_rejects_unknown_presets_and_cannot_take_over_an_existing_runtime() {
let mut h = Harness::new();
+39 -11
View File
@@ -341,8 +341,11 @@ fn live(app: &SolveApp, t: &Tool, e: Vec2, px: f64) -> Vec<(f64, Vec2)> {
("sketch.circle.two_tangent", 2) => {
vec![(crate::sketch_tools::two_tangent_default_radius(app, t), (first + a) * 0.5 + dir_or_x(a - first).perp() * off)]
}
("sketch.arc.center_point" | "sketch.slot.arc_center", 1)
| ("sketch.polygon.inscribed" | "sketch.polygon.circumscribed" | "sketch.polygon.edge", 1) => {
// Out along the radius, beyond the cursor, as the sweep box: at the radius's middle the
// box covered the cursor of a short radius and took the click meant for the start point,
// so the arc waited for Enter (#12208).
("sketch.arc.center_point" | "sketch.slot.arc_center", 1) => vec![((e - a).len(), e + dir_or_x(e - a) * (2.5 * off))],
("sketch.polygon.inscribed" | "sketch.polygon.circumscribed" | "sketch.polygon.edge", 1) => {
vec![((e - a).len(), (a + e) * 0.5), (6.0, a + (e - a) * 0.5 + (e - a).perp() * 0.3)]
}
("sketch.arc.center_point" | "sketch.slot.arc_center", 2) => {
@@ -735,20 +738,45 @@ pub(crate) fn over_tool_boxes(app: &SolveApp, ctx: &egui::Context, p: egui::Pos2
/// Where a value box whose top left would be `pos` goes so it stays in the drawing area `view`:
/// as it is when it fits; else mirrored through the pointer (a box set off the cursor, past the
/// view's edge, goes to the cursor's other side, still clear of it, #2201); else pushed inside.
/// A box that would still sit on the pointer moves beside it: there it would take the click meant
/// for the next point, and the shape would wait for Enter (#12208).
fn in_view(pos: Pos2, view: Option<egui::Rect>, pointer: Option<Pos2>) -> Pos2 {
let Some(view) = view else { return pos };
let fits = |p: Pos2| view.contains_rect(egui::Rect::from_min_size(p, BOX));
if fits(pos) {
let placed = if fits(pos) {
pos
} else if let Some(mirrored) = pointer.map(|c| c + (c - (pos + BOX * 0.5)) - BOX * 0.5).filter(|p| fits(*p)) {
mirrored
} else {
let max = (view.max - BOX).max(view.min);
pos.clamp(view.min, max)
};
off_pointer(placed, pointer, fits)
}
/// Gap between the pointer and a value box moved off it.
const POINTER_GAP: f32 = 12.0;
/// The most a value box spans from its top left: wider than `BOX`, as a box grows with its label
/// and value ("Radius 12.345").
const REACH: egui::Vec2 = egui::vec2(110.0, BOX.y);
/// `pos` when the box there is clear of the pointer; else the box beside the pointer (right,
/// left, below, above, the first that `fits`), keeping its other coordinate.
fn off_pointer(pos: Pos2, pointer: Option<Pos2>, fits: impl Fn(Pos2) -> bool) -> Pos2 {
let Some(c) = pointer else { return pos };
if !egui::Rect::from_min_size(pos, REACH).expand(2.0).contains(c) {
return pos;
}
if let Some(c) = pointer {
let mirrored = c + (c - (pos + BOX * 0.5)) - BOX * 0.5;
if fits(mirrored) {
return mirrored;
}
}
let max = (view.max - BOX).max(view.min);
pos.clamp(view.min, max)
[
egui::pos2(c.x + POINTER_GAP, pos.y),
egui::pos2(c.x - POINTER_GAP - REACH.x, pos.y),
egui::pos2(pos.x, c.y + POINTER_GAP),
egui::pos2(pos.x, c.y - POINTER_GAP - REACH.y),
]
.into_iter()
.find(|p| fits(*p))
.unwrap_or(pos)
}
#[cfg(test)]
+39 -2
View File
@@ -332,8 +332,45 @@ pub(crate) fn selected_equation(app: &SolveApp) -> Option<String> {
state.sketch(sid)?.sketch.equation_of(id).map(|e| e.curve.clone())
}
/// Sketch modify inputs accept only the sketch being edited. Origin axes remain valid
/// rectangular-pattern directions, while Hole continues using its separate POINTS mask.
/// What a click on `hit` picks in a sketch modify window: what the active input takes
/// ([`candidate`]), else what the Entities list takes ([`entities_candidate`]).
pub(crate) fn pick_candidate(d: &Dialog, session: &solidcraft_engine::Session, hit: &Hit) -> Option<Sel> {
candidate(d, session, hit).or_else(|| entities_candidate(d, session, hit))
}
/// The selection a hit adds to the Entities list although another input is active: the commands
/// whose first input is that shared list (Mirror, Move/Copy, Scale, Transform, the patterns) take
/// a curve, point or region the active input refuses there, so one clicked after the window
/// moved on to the mirror line, centre or direction joins the others instead of being dropped
/// without a word (#11700).
fn entities_candidate(d: &Dialog, session: &solidcraft_engine::Session, hit: &Hit) -> Option<Sel> {
let active = session.active_sketch?;
let shared = matches!(
&d.kind,
Kind::SketchModify(
Mod::Scale { .. } | Mod::Mirror | Mod::Move { .. } | Mod::Transform { .. } | Mod::PatternRect { .. } | Mod::PatternCirc { .. }
)
);
if !shared || d.active == 0 {
return None;
}
if let Hit::SketchCurve { sketch, .. } | Hit::SketchPoint { sketch, .. } | Hit::Profile { sketch, .. } = hit
&& *sketch != active
{
return None;
}
d.inputs.first().filter(|input| input.multi)?.accepts(hit, Some(active), |_, _| false)
}
/// Whether a click on `hit` goes to the Entities list rather than the active input (see
/// [`entities_candidate`]).
pub(crate) fn picks_entity(d: &Dialog, session: &solidcraft_engine::Session, hit: &Hit) -> bool {
candidate(d, session, hit).is_none() && entities_candidate(d, session, hit).is_some()
}
/// What the active input of a sketch modify window takes. Sketch modify inputs accept only the
/// sketch being edited. Origin axes remain valid rectangular-pattern directions, while Hole
/// continues using its separate POINTS mask.
pub fn candidate(d: &Dialog, session: &solidcraft_engine::Session, hit: &Hit) -> Option<Sel> {
let active = session.active_sketch?;
match hit {
@@ -242,6 +242,54 @@ fn mirror_uses_the_only_preselected_construction_line_among_multiple_lines() {
}
}
/// #11700: with circles preselected, Mirror opens on the empty Mirror line. A further circle
/// clicked then joins the Entities (the line field refuses it), so every selected circle is
/// mirrored instead of the last one being dropped without a word; the line is still picked next.
#[test]
fn mirror_takes_a_circle_clicked_while_the_mirror_line_is_active_into_the_entities() {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
h.app.run("sketch.line", json!({"points": [[0, -30], [0, 30]], "construction": true, "ids": ["axis"]})).unwrap();
for y in [-15, 0, 15] {
h.app.run("sketch.circle.center", json!({"center": [10, y], "radius": 3})).unwrap();
}
h.settle();
h.step(&json!({"click": {"world": [13, -15, 0]}})).unwrap();
h.step(&json!({"click": {"world": [13, 0, 0]}, "ctrl": true})).unwrap();
h.step(&json!({"start": "sketch.mirror"})).unwrap();
assert_eq!(h.app.dialog.as_ref().unwrap().active, 1, "the window waits for the mirror line");
h.step(&json!({"click": {"world": [13, 15, 0]}})).unwrap();
let d = h.app.dialog.as_ref().unwrap();
let curve = |id: &str| Sel::SketchCurve { id: id.into(), sketch: None };
assert_eq!(d.inputs[0].items, vec![curve("c1"), curve("c2"), curve("c3")]);
assert!(d.inputs[1].items.is_empty());
assert_eq!(d.active, 1, "the mirror line is still the next pick");
// Clicked again, the circle leaves the Entities, as a pick in that list does.
h.step(&json!({"click": {"world": [13, 15, 0]}})).unwrap();
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items, vec![curve("c1"), curve("c2")]);
h.step(&json!({"click": {"world": [13, 15, 0]}})).unwrap();
h.step(&json!({"click": {"world": [0, 25, 0]}})).unwrap();
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[1].items, vec![curve("axis")]);
h.frames(2);
h.step(&json!({"key": "Enter"})).unwrap();
h.settle();
assert!(h.app.dialog.is_none(), "{:?}", h.app.status);
let state = h.app.session.model.state();
let sketch = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
let mut centres: Vec<(i64, i64)> = sketch
.curves
.iter()
.filter_map(|c| match c.kind {
solidcraft_engine::sketch::CurveKind::Circle { c, .. } => sketch.point(c),
_ => None,
})
.filter(|p| p.x < 0.0)
.map(|p| (p.x.round() as i64, p.y.round() as i64))
.collect();
centres.sort();
assert_eq!(centres, vec![(-10, -15), (-10, 0), (-10, 15)], "every selected circle is mirrored");
}
#[test]
fn mirror_auto_line_requires_one_preselected_straight_construction_line_and_the_preference() {
for (count, selected, enabled) in [(0, 1, true), (1, 0, true), (2, 0, true), (1, 1, true), (2, 1, true), (2, 2, true), (1, 1, false)] {
+49
View File
@@ -1218,6 +1218,55 @@ mod tests {
assert!(h.app.session.active_sketch.is_none());
}
/// With "Double Esc finishes the sketch" off (#12069), any number of quick presses only cancel
/// the tool and the selection, and the sketch stays open, blank or not; Finish Sketch still
/// ends it. On (the default) keeps the double press finishing it.
#[test]
fn escape_never_leaves_the_sketch_when_the_double_press_preference_is_off() {
assert!(crate::prefs::Prefs::default().escape_twice_finishes_sketch, "the default keeps today's double Esc");
let mut h = crate::scenario::Harness::new();
h.call("ui.prefs", json!({"escape_twice_finishes_sketch": false}));
assert!(!h.app.preferences.escape_twice_finishes_sketch, "set through the control channel");
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.settle();
let sketch = h.app.session.active_sketch;
assert!(sketch.is_some());
// Blank: a double Esc would take it away when the preference is on.
for _ in 0..10 {
h.step(&json!({"key":"Escape"})).unwrap();
}
h.settle();
assert_eq!(h.app.session.active_sketch, sketch, "ten quick presses keep the blank sketch open");
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]})).unwrap();
h.settle();
h.step(&json!({"start":"sketch.line"})).unwrap();
assert!(h.app.tool.is_some());
for _ in 0..4 {
h.step(&json!({"key":"Escape"})).unwrap();
}
h.settle();
assert!(h.app.tool.is_none(), "Esc still puts the tool down");
assert!(h.app.session.selection.is_empty(), "and clears the selection");
assert_eq!(h.app.session.active_sketch, sketch, "the sketch stays open");
h.app.finish_sketch();
h.settle();
assert!(h.app.session.active_sketch.is_none(), "Finish Sketch still ends it");
assert!(crate::scenario::handle_at("timeline:Sketch1").is_some(), "the sketch is kept");
// Back on, a quick double press finishes again.
h.call("ui.prefs", json!({"escape_twice_finishes_sketch": true}));
h.step(&json!({"click": {"handle": "timeline:Sketch1"}, "double": true})).unwrap();
h.settle();
assert!(h.app.session.active_sketch.is_some());
h.frames(60);
h.step(&json!({"key":"Escape"})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
h.settle();
assert!(h.app.session.active_sketch.is_none(), "on: a double Esc finishes the sketch");
}
/// A double Esc on a sketch left blank takes it away (no empty sketch in the browser or the
/// timeline, one undo step) and leaves the view alone; one line is enough to keep it.
#[test]
@@ -608,6 +608,72 @@ fn a_line_starts_on_a_snap_point_of_an_existing_shape() {
}
}
/// The Line tool's first click also starts on projected geometry and in construction mode, as its
/// last click does: a projected origin axis or plane (#11598), the projected edges of the face a
/// sketch sits on (#12382), and a construction line on a rectangle's edge (#12228). Reported as one
/// failure with #11369, #11449, #11465, #11736 and #12571.
#[test]
fn a_line_starts_on_projected_geometry_and_in_construction_mode() {
use solidcraft_engine::sketch::{ConstraintKind, CurveKind};
for case in ["projected X axis", "projected YZ plane", "face edge", "construction on a rectangle edge"] {
let mut h = Harness::new();
// Where the first click goes and where the line ends, in world coordinates.
let (start, end) = match case {
"projected X axis" => {
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.project", json!({"refs":["X"]})).unwrap();
([17.0, 0.0, 0.0], [30.0, 20.0, 0.0])
}
"projected YZ plane" => {
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.project", json!({"refs":[{"plane":"YZ"}]})).unwrap();
([0.0, 13.0, 0.0], [20.0, 25.0, 0.0])
}
"face edge" => {
h.app.run("solid.box", json!({"length":40,"width":40,"height":10,"body_name":"Block"})).unwrap();
h.app.run("sketch.create", json!({"plane":{"face":[20,20,10]}})).unwrap();
([0.0, 13.0, 10.0], [15.0, 25.0, 10.0])
}
_ => {
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0":[-35,-40],"p1":[-15,-25]})).unwrap();
([-35.0, -28.0, 0.0], [-50.0, -5.0, 0.0])
}
};
h.call("ui.view", json!({"view":"top"}));
h.call("ui.camera", json!({"target":[start[0], start[1], start[2]],"distance":200}));
h.settle();
let construction = case.starts_with("construction");
h.step(&json!({"start":"sketch.line"})).unwrap();
if construction {
crate::sketch_palette::set_mode(&mut h.app, 1);
}
let curves = |h: &Harness| {
let state = h.app.session.world_state().unwrap();
state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch.curves.len()
};
let before = curves(&h);
h.step(&json!({"click":{"world":start}})).unwrap();
h.step(&json!({"move":{"world":[(start[0] + end[0]) / 2.0, (start[1] + end[1]) / 2.0, start[2]]}})).unwrap();
h.step(&json!({"click":{"world":end}})).unwrap();
h.settle();
assert_eq!(curves(&h), before + 1, "{case}: no line from the first click; {:?}", h.app.status);
let state = h.app.session.world_state().unwrap();
let ss = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
let sketch = &ss.sketch;
let new = sketch.curves.last().unwrap();
let CurveKind::Line { a, .. } = new.kind else { panic!("{case}: the new curve is not a line") };
assert_eq!(new.construction, construction, "{case}: line type");
let at = ss.plane.to_world(sketch.point(a).unwrap());
assert!((at.x - start[0]).abs() < 1e-3 && (at.y - start[1]).abs() < 1e-3, "{case}: the line starts at {at:?}, expected {start:?}");
let tied = sketch.constraints.iter().any(|c| match c.kind {
ConstraintKind::PointOnCurve { p, c } | ConstraintKind::Midpoint { p, l: c } => p == a && Some(c) != sketch.curves.len().checked_sub(1),
_ => false,
});
assert!(tied, "{case}: the line's start is not tied to the edge it was started on: {:?}", sketch.constraints);
}
}
#[test]
fn a_line_snap_whose_source_curve_is_undone_ends_without_an_error() {
for start in [[-20.0, 10.0], [-2.5, 10.0]] {
@@ -330,3 +330,41 @@ fn the_arc_end_pick_snaps_to_a_point_under_the_value_box() {
assert!(snapped, "{command}: the third pick snapped to the line's end: {:?}", sketch.constraints.iter().map(|c| &c.kind).collect::<Vec<_>>());
}
}
/// A Center Point Arc is drawn with three clicks, its value boxes optional: the Radius box never
/// sits under the cursor, so the second click places the start point instead of landing in the
/// box and waiting for Enter (#12208, #12205). Small radii in every direction, where a box at the
/// radius's middle covered the cursor.
#[test]
fn a_center_point_arc_takes_three_clicks_without_enter() {
for (radius, angle) in [(2.0f64, 0.0f64), (4.0, 0.0), (4.0, 90.0), (4.0, 180.0), (4.0, 270.0), (6.0, 45.0), (15.0, 0.0)] {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 60.0;
h.app.cam.target = solidcore_record::Vec3::new(0.0, 0.0, 0.0);
h.settle();
h.step(&json!({"start":"sketch.arc.center_point"})).unwrap();
h.step(&json!({"click":{"world":[0, 0, 0]}})).unwrap();
let (s, c) = angle.to_radians().sin_cos();
let start = [radius * c, radius * s];
h.step(&json!({"move":{"world":[start[0], start[1], 0]}})).unwrap();
let cursor = h.at(&json!({"world":[start[0], start[1], 0]})).unwrap();
let cursor = egui::pos2(cursor[0] as f32, cursor[1] as f32);
let blocking = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("sc_dim_area", 0usize)))).filter(|r| r.expand(2.0).contains(cursor));
assert!(blocking.is_none(), "radius {radius} at {angle} deg: the Radius box {blocking:?} sits under the cursor {cursor:?}");
h.step(&json!({"click":{"world":[start[0], start[1], 0]}})).unwrap();
assert_eq!(h.app.tool.as_ref().unwrap().pts.len(), 2, "radius {radius} at {angle} deg: the click placed the start point");
let (s, c) = (angle + 90.0).to_radians().sin_cos();
h.step(&json!({"move":{"world":[radius * c, radius * s, 0]}})).unwrap();
h.step(&json!({"click":{"world":[radius * c, radius * s, 0]}})).unwrap();
let state = h.app.session.world_state().unwrap();
let sketch = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
let arc = sketch.curves.iter().find_map(|curve| match curve.kind {
CurveKind::Arc { c, a, .. } => Some(sketch.points[a].pos.dist(sketch.points[c].pos)),
_ => None,
});
assert!(arc.is_some_and(|r| (r - radius).abs() < 0.5), "radius {radius} at {angle} deg: three clicks made the arc, got {arc:?}");
assert!(!sketch.constraints.iter().any(|c| c.kind.is_dimension()), "radius {radius} at {angle} deg: clicks alone add no typed dimension");
}
}
+107
View File
@@ -30,6 +30,8 @@ thread_local! {
/// The palette of a theme file (`theme_files`) in place of the built-in one of its base
/// (dark or light), per thread like the rest. `None`: the built-in palettes.
static CUSTOM: Cell<Option<Custom>> = const { Cell::new(None) };
/// Paint-only panel palette. It never changes authored theme state or cache generations.
static PANEL_TOKENS: Cell<Option<Tokens>> = const { Cell::new(None) };
static CUSTOM_GENERATION: Cell<u64> = const { Cell::new(0) };
/// The application bar's logo in the theme's accent instead of its own logo colours
/// (Preferences › Interface › Logo), per thread like the rest.
@@ -577,6 +579,21 @@ pub(crate) fn scoped_tokens(tokens: Tokens) -> impl Drop {
restore
}
/// Temporarily bind a panel's paint palette without publishing a theme change.
pub(crate) fn scoped_panel_tokens(tokens: Tokens) -> impl Drop {
struct Restore {
previous: Option<Tokens>,
// The override belongs to this UI thread, so its guard must not move to another one.
_thread: std::marker::PhantomData<std::rc::Rc<()>>,
}
impl Drop for Restore {
fn drop(&mut self) {
PANEL_TOKENS.set(self.previous);
}
}
Restore { previous: PANEL_TOKENS.replace(Some(tokens)), _thread: std::marker::PhantomData }
}
/// A tab's selection colours. This is deliberately separate from the global widget style:
/// application chrome, checkboxes, status colours and dialog actions retain their own tokens.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -615,6 +632,93 @@ pub(crate) fn composite(fill: Color32, background: Color32) -> Color32 {
Color32::from_rgb(channel(0), channel(1), channel(2))
}
pub(crate) fn panel_fallback_tokens(mut authored: Tokens) -> Tokens {
// Bypass custom/published themes. These existing built-in pairs have their own
// contrast tests. Copy only surface/ink roles to retain captured layout metrics.
let known = if is_dark() { Tokens::dark() } else { Tokens::light() };
authored.panel = known.file_background.to_opaque();
authored.panel_header = authored.panel;
authored.text = known.file_primary_ink;
authored.form_ink = known.file_primary_ink;
authored.dialog_label = known.file_primary_ink;
authored.text_dim = known.file_dim;
authored.form_dim = known.file_dim;
authored
}
/// Raise a captured panel fill to the first quantized alpha that protects all
/// canonical inks. None reports an authored pair that fails at full opacity.
pub(crate) fn panel_surface_fill(fill: Color32, tokens: &Tokens, requested: f32) -> Option<Color32> {
let [r, g, b, _] = fill.to_srgba_unmultiplied();
if !requested.is_finite() || !(0.0..=1.0).contains(&requested) || !panel_surface_readable(Color32::from_rgb(r, g, b), tokens) {
return None;
}
// Each candidate is the renderer's exact quantized fill. Round upward so
// effective alpha never undercuts the caller's requested opacity.
for alpha in (requested * 255.0).ceil() as u8..=255 {
let candidate = Color32::from_rgba_unmultiplied(r, g, b, alpha);
if panel_surface_readable(candidate, tokens) {
return Some(candidate);
}
}
None
}
fn panel_luminance(color: Color32) -> f64 {
let decode = solidcraft_engine::geom::srgb::decode8;
0.2126 * decode(color.r()) + 0.7152 * decode(color.g()) + 0.0722 * decode(color.b())
}
pub(crate) fn panel_surface_readable(fill: Color32, tokens: &Tokens) -> bool {
let (low, _) = panel_composite_bounds(fill, Color32::BLACK);
let (_, high) = panel_composite_bounds(fill, Color32::WHITE);
let (background_low, background_high) = (panel_luminance(low), panel_luminance(high));
[tokens.text, tokens.text_dim, tokens.form_ink, tokens.form_dim, tokens.dialog_label].into_iter().all(|ink| {
// RGBA inks remain authored. Conservative intervals include every possible
// composited text luminance, including an interior contrast minimum.
let ink_low = panel_luminance(panel_composite_bounds(ink, low).0);
let ink_high = panel_luminance(panel_composite_bounds(ink, high).1);
if ink_low <= background_high && ink_high >= background_low {
false
} else if ink_low > background_high {
(ink_low + 0.05) / (background_high + 0.05) >= 4.5
} else {
(background_low + 0.05) / (ink_high + 0.05) >= 4.5
}
})
}
/// Enclose the actual stored-premultiplied source-over, without unpremultiplying
/// and rounding the source again. egui-wgpu's renderer uses One/OneMinusSrcAlpha;
/// its two WGSL entry points blend gamma codes or decoded premultiplied codes.
/// Include both framebuffer formats and the shader's <half-byte source dither,
/// then round outward to cover framebuffer quantization. This intentionally
/// overestimates uncertainty instead of promising threshold contrast falsely.
pub(crate) fn panel_composite_bounds(source: Color32, background: Color32) -> (Color32, Color32) {
use solidcraft_engine::geom::srgb::{decode, encode};
if source.a() == 255 {
// Flat opaque integer colours survive the shader's <0.5-byte dither and
// final nearest-byte conversion unchanged on either framebuffer format.
return (source, source);
}
let remain = 1.0 - f64::from(source.a()) / 255.0;
let channel = |source: u8, background: u8| {
let source = f64::from(source);
let background = f64::from(background);
let low_source = (source - 0.5).max(0.0);
let high_source = (source + 0.5).min(255.0);
let gamma_low = low_source + background * remain;
let gamma_high = high_source + background * remain;
let linear_low = encode(decode(low_source / 255.0) + decode(background / 255.0) * remain) * 255.0;
let linear_high = encode(decode(high_source / 255.0) + decode(background / 255.0) * remain) * 255.0;
(gamma_low.min(linear_low).floor().clamp(0.0, 255.0) as u8, gamma_high.max(linear_high).ceil().clamp(0.0, 255.0) as u8)
};
let (r0, r1) = channel(source.r(), background.r());
let (g0, g1) = channel(source.g(), background.g());
let (b0, b1) = channel(source.b(), background.b());
(Color32::from_rgb(r0, g0, b0), Color32::from_rgb(r1, g1, b1))
}
fn readable_fill(fill: Color32, inks: &[Color32], scale: f32) -> Color32 {
let readable = |candidate| {
[Color32::BLACK, Color32::WHITE].iter().all(|background| {
@@ -1328,6 +1432,9 @@ impl Tokens {
/// [`Tokens::get`] in another colour vision mode: the Colour vision preference previews the
/// mode being chosen before it is applied (#7152).
pub fn get_in(mode: ColorVision) -> Tokens {
if let Some(tokens) = PANEL_TOKENS.get() {
return tokens;
}
if let Some(published) = PUBLISHED.get().filter(|p| p.environment == Environment::current()) {
if mode == published.environment.vision {
return published.tokens;
+3 -2
View File
@@ -3696,9 +3696,10 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
continue;
}
// Two quick presses in a sketch finish it (keeping it, like Finish Sketch);
// the first one has already cancelled whatever was active.
// the first one has already cancelled whatever was active. A preference turns
// this off, so Esc never leaves the sketch (#12069).
let now = ctx.input(|i| i.time);
let again = app.last_escape.is_some_and(|t| now - t <= ESCAPE_TWICE_S);
let again = app.preferences.escape_twice_finishes_sketch && app.last_escape.is_some_and(|t| now - t <= ESCAPE_TWICE_S);
app.last_escape = Some(now);
// Not while a tool is still out (its first Esc only ended a chain): this press
// puts it down, and the next quick one finishes.
+14 -1
View File
@@ -31,10 +31,14 @@ pub(crate) fn panel_host_surface<R>(
id: egui::Id,
bounds: egui::Rect,
floating: bool,
fill: egui::Color32,
palette: Option<&Tokens>,
body: impl FnOnce(&mut egui::Ui, egui::Rect) -> R,
) -> R {
let _tokens = palette.map(|tokens| crate::theme::scoped_panel_tokens(*tokens));
let container = panel_host_container(ui.ctx(), floating);
let frame = panel_host_frame(ui.ctx(), floating);
let mut frame = panel_host_frame(ui.ctx(), floating);
frame.fill = fill;
let clip = ui.clip_rect().intersect(bounds);
// egui clips to rectangles. This inscribed rectangle keeps arbitrary content
// fills inside the rounded frame without changing any header hit target.
@@ -46,6 +50,15 @@ pub(crate) fn panel_host_surface<R>(
let mut child = ui.new_child(egui::UiBuilder::new().id_salt(id).max_rect(bounds));
child.set_clip_rect(clip);
container.paint_background(child.painter(), frame, bounds - frame.total_margin());
if let Some(tokens) = palette {
let visuals = child.visuals_mut();
visuals.override_text_color = Some(tokens.text);
visuals.weak_text_color = Some(tokens.form_dim);
visuals.widgets.noninteractive.fg_stroke.color = tokens.text;
for state in [&mut visuals.widgets.inactive, &mut visuals.widgets.hovered, &mut visuals.widgets.active, &mut visuals.widgets.open] {
state.fg_stroke.color = tokens.form_ink;
}
}
let result = body(&mut child, content_clip);
Container::paint_inset_clipped(child.painter(), bounds, frame, clip);
result
+127 -31
View File
@@ -457,35 +457,43 @@ fn universal_kit_chrome_uses_one_container_and_preserves_parent_clipping_and_foc
let mut calls = 0;
let output = crate::test_support::frame(&ctx, egui::RawInput::default(), |ui| {
ui.set_clip_rect(clip);
panel_host_surface(ui, egui::Id::unique("synthetic-section"), bounds, false, |ui, content_clip| {
calls += 1;
assert_eq!(ui.clip_rect(), clip);
assert!(clip.contains_rect(content_clip));
let outer_frames = ui.stack().iter().filter(|node| node.frame().fill != egui::Color32::TRANSPARENT).count();
assert!(outer_frames <= 1, "a section must not introduce nested outer frames");
let title = panel_host_control(
ui,
egui::Id::unique("synthetic-header"),
header,
HostChrome {
label: "Synthetic long localized panel title",
icon: Some("list"),
icon_only: false,
attention: true,
selected: false,
enabled: true,
drag: true,
title: true,
grip_only: false,
rtl: false,
},
);
let resize = panel_host_sash(ui, egui::Id::unique("synthetic-sash"), sash, "Resize synthetic neighbours");
assert!(title.sense.is_focusable());
assert!(resize.sense.is_focusable());
assert_eq!(title.rect, header);
assert_eq!(resize.rect, sash);
});
panel_host_surface(
ui,
egui::Id::unique("synthetic-section"),
bounds,
false,
panel_host_frame(ui.ctx(), false).fill,
None,
|ui, content_clip| {
calls += 1;
assert_eq!(ui.clip_rect(), clip);
assert!(clip.contains_rect(content_clip));
let outer_frames = ui.stack().iter().filter(|node| node.frame().fill != egui::Color32::TRANSPARENT).count();
assert!(outer_frames <= 1, "a section must not introduce nested outer frames");
let title = panel_host_control(
ui,
egui::Id::unique("synthetic-header"),
header,
HostChrome {
label: "Synthetic long localized panel title",
icon: Some("list"),
icon_only: false,
attention: true,
selected: false,
enabled: true,
drag: true,
title: true,
grip_only: false,
rtl: false,
},
);
let resize = panel_host_sash(ui, egui::Id::unique("synthetic-sash"), sash, "Resize synthetic neighbours");
assert!(title.sense.is_focusable());
assert!(resize.sense.is_focusable());
assert_eq!(title.rect, header);
assert_eq!(resize.rect, sash);
},
);
});
assert!(calls >= 1);
let tree = output.platform_output.accesskit_update.as_ref().unwrap();
@@ -528,12 +536,12 @@ fn universal_surface_uses_adaptive_workspace_fill_and_rounding_with_safe_content
if free_card {
// A new floating Area first measures invisibly, even with fade disabled.
crate::test_support::frame(&ctx, egui::RawInput { time: Some(0.0), ..Default::default() }, |ui| {
panel_host_surface(ui, egui::Id::unique("adaptive-surface"), bounds, free_card, |_, _| {});
panel_host_surface(ui, egui::Id::unique("adaptive-surface"), bounds, free_card, expected.fill, None, |_, _| {});
});
}
for time in [1.0, 2.0] {
let output = crate::test_support::frame(&ctx, egui::RawInput { time: Some(time), ..Default::default() }, |ui| {
panel_host_surface(ui, egui::Id::unique("adaptive-surface"), bounds, free_card, |ui, clip| {
panel_host_surface(ui, egui::Id::unique("adaptive-surface"), bounds, free_card, expected.fill, None, |ui, clip| {
assert!(ui.is_visible() && !ui.is_sizing_pass(), "assert the first visible paint and its subsequent frame");
assert!(bounds.contains_rect(clip));
let stroke = expected.stroke.width;
@@ -2596,3 +2604,91 @@ fn page_sidebar_footer_stays_visible_when_the_page_list_scrolls() {
assert!(nav.contains_rect(scenario::rect_of("prefs:page:Plugins").unwrap()), "last page is reachable in {theme}");
}
}
#[test]
fn host_surface_fallback_paints_readable_plain_labels_without_changing_body_layout() {
use solidcraft_engine::panels::{ContrastOutcome, PanelSurface};
let bounds = egui::Rect::from_min_size(egui::pos2(40.0, 40.0), egui::vec2(320.0, 180.0));
for floating in [false, true] {
for fallback in [false, true] {
let ctx = crate::fonts::test_context();
let mut authored = Tokens::get();
authored.panel = egui::Color32::WHITE;
authored.text = egui::Color32::BLACK;
authored.text_dim = egui::Color32::BLACK;
authored.form_ink = egui::Color32::BLACK;
authored.form_dim = egui::Color32::BLACK;
authored.dialog_label = egui::Color32::BLACK;
if fallback {
authored.text = authored.panel;
}
let _authored = crate::theme::scoped_panel_tokens(authored);
let style = crate::panel_runtime::HostSurfaceStyle::capture(&ctx, false, Some(0.0), None);
let appearance = style.appearance(PanelSurface::Translucent, floating);
let failed = matches!(appearance.contrast, ContrastOutcome::RetainReadableFallback { .. });
assert_eq!(failed, fallback, "exercise both authored paint and deliberately unreadable fallback");
let palette = failed.then(|| style.palette(appearance));
let font = egui::FontId::monospace(19.0);
// Floating Areas create a fresh Ui from the context style; they do
// not inherit a caller's local Ui overrides. Set the common baseline
// before the sizing pass so both existing host paths inherit it.
ctx.global_style_mut(|style| {
style.override_font_id = Some(font.clone());
style.spacing.item_spacing = egui::vec2(13.0, 17.0);
style.wrap_mode = Some(egui::TextWrapMode::Wrap);
style.visuals.override_text_color = Some(authored.panel);
});
if floating {
crate::test_support::frame(&ctx, egui::RawInput::default(), |ui| {
panel_host_surface(ui, egui::Id::unique("captured-fill"), bounds, floating, appearance.fill, palette, |_, _| {});
});
}
let mut expected_ink = egui::Color32::TRANSPARENT;
let output = crate::test_support::frame(&ctx, egui::RawInput::default(), |ui| {
let parent_visuals = ui.visuals().clone();
let parent_spacing = ui.spacing().item_spacing;
let parent_fonts = ui.style().text_styles.clone();
let parent_wrap = ui.style().wrap_mode;
expected_ink = palette.map_or(authored.panel, |tokens| tokens.text);
panel_host_surface(ui, egui::Id::unique("captured-fill"), bounds, floating, appearance.fill, palette, |ui, clip| {
assert_eq!(Tokens::get(), palette.copied().unwrap_or(authored));
assert_eq!(ui.spacing().item_spacing, parent_spacing);
assert_eq!(ui.style().text_styles, parent_fonts);
assert_eq!(ui.style().override_font_id, Some(font.clone()));
assert_eq!(ui.style().wrap_mode, parent_wrap);
if palette.is_none() {
assert_eq!(ui.visuals(), &parent_visuals, "normal bodies inherit unchanged visuals");
}
assert!(bounds.contains_rect(clip));
ui.label("Readable captured panel");
});
assert_eq!(Tokens::get(), authored);
assert_eq!(ui.visuals(), &parent_visuals, "fallback visuals restore immediately");
assert_eq!(ui.spacing().item_spacing, parent_spacing);
assert_eq!(ui.style().text_styles, parent_fonts);
});
let (mut backgrounds, mut labels) = (0, 0);
for shape in &output.shapes {
crate::test_support::each_shape(&shape.shape, &mut |shape| match shape {
egui::Shape::Rect(rect) if rect.rect == bounds && rect.fill != egui::Color32::TRANSPARENT => {
assert_eq!(rect.fill, appearance.fill, "actual paint uses the captured fill");
backgrounds += 1;
}
egui::Shape::Text(text) if text.galley.job.text == "Readable captured panel" => {
for section in &text.galley.job.sections {
let ink = text.override_text_color.unwrap_or_else(|| {
if section.format.color == egui::Color32::PLACEHOLDER { text.fallback_color } else { section.format.color }
});
assert_eq!(ink, expected_ink, "the actual plain-label galley uses readable fallback ink");
assert_eq!(section.format.font_id, font, "fallback preserves the body's chosen font");
}
labels += 1;
}
_ => {}
});
}
assert!(backgrounds > 0 && labels > 0, "inspect real background and label paint");
assert_eq!(Tokens::get(), authored);
}
}
}
+7
View File
@@ -3742,6 +3742,13 @@ pub(crate) fn dialog_pick(app: &mut SolveApp, hit: &Hit, sel: Sel) {
d.focus = true;
d.awaiting_pick = false;
d.pick_refused = None;
} else if crate::sketch_modify_ui::picks_entity(&d, &app.session, hit) {
// A curve the mirror line, centre or direction refuses joins the Entities list, and the
// window stays on the input it was on (#11700).
let on = d.active;
d.active = 0;
d.pick(&app.session, sel);
d.active = on;
} else {
d.pick(&app.session, sel);
}
-4
View File
@@ -136,10 +136,6 @@ keeps its designs, settings and recovery copies apart from the release's, so try
changes what the release sees, and nothing is copied between them: to move a design, save it to
a file in one and open the file in the other. Search engines are told to skip both.
Planned worker and tiered-storage integration is
described in [web-runtime.md](https://github.com/tangency-labs/solidcraft/blob/main/docs/web-runtime.md)
(design; not yet wired in the shipped wasm build).
After the first visit the web app works offline: the browser keeps a copy of it. Online, each
launch gets the latest published build (the browser downloads it once when it changes); offline,
when the network doesn't answer within a few seconds, or when a new build can't be downloaded
+5
View File
@@ -46,6 +46,11 @@ In the MANAGE tab's PARAMETERS section, click **Parameters** to open the user an
parameter editor. The **Configurations** button in CONFIGURATIONS opens the configuration table;
**Configure** remains in the dropdown for configuring columns.
The **Filter** box lists the parameters whose name, expression, comment or group contains what
you type, in any case. It also finds a name by the starts of its words, in order: `toodia`,
`toofla` and `outdia` all find `tooth_outward_flange_diameter`. Underscores, hyphens and
camelCase (`boltHoleDepth`) separate the words. Autocomplete offers parameters the same way.
Press **Enter** in the new parameter's name, expression or comment field to add it.
Valid entries return focus to the name field for the next parameter; invalid entries remain
editable. In expression fields, type a parameter or dimension name for autocomplete,
+5 -2
View File
@@ -263,7 +263,7 @@ actual current Session without another service tick. Runtime inspection remains
read-only. The shared host adds no production command registration beyond the
application-layout commands above.
For native host evidence, `ui.panel_fixture {enabled?, preset?, remember?}` installs bounded
For native host evidence, `ui.panel_fixture {enabled?, preset?, remember?, translucency_percent?}` installs bounded
synthetic content in the real `SolveApp` workspace host. The normal connection
authentication applies. It is off by default and ephemeral unless explicitly
enabled with `remember:true`. It adds no design change, production content
@@ -275,12 +275,14 @@ registration or native panel window.
| `{"enabled":true}` | Enable the `docks` specimen if no runtime exists. |
| `{"preset":"tabs"}` | Enable the named specimen if no runtime exists. |
| `{"preset":"tabs","remember":true}` | Enable the specimen and its application preference catalogue. Restore a saved record after valid host dimensions are available. |
| `{"preset":"surfaces","translucency_percent":50}` | Enable declared surface-policy specimens with a runtime-only translucency request (finite 0–100 percent). Readability may raise effective opacity. |
| `{"translucency_percent":100}` | Change the enabled specimen's request without replacing controllers, drafts or layout. Refuse if no specimen owns the host. |
| `{"enabled":false}` | Preflight and tear down only the specimen runtime; refusal leaves it intact. |
Presets are `docks` (Left/Right/Bottom, with two Left sections), `tabs` (two groups
of two), `collapsed` (four full-width collapsed headers), `floating` (four in-app floating
sections), `overflow` (24 instances), and `pinned` (an outer pinned Right section
plus ordinary docks), `columns` (three independent Left columns), `stack_tabs`, `content_fill` (sole Content versus Fill), and `inline` (one editable total row). The retired `rails` preset is rejected. Original synthetic content retains editable drafts and
plus ordinary docks), `columns` (three independent Left columns), `stack_tabs`, `content_fill` (sole Content versus Fill), `inline` (one editable total row), and `surfaces` (mixed tab and explicit surface policies). Only `surfaces` opts out of compatibility opaque presentation. Requested opacity is `1 - translucency_percent / 100`; the override never changes saved preferences. The retired `rails` preset is rejected. Original synthetic content retains editable drafts and
controller state across hiding and moves. Services keep ticking while content is
hidden. Apply/Finish/Cancel are synthetic actions; the Session is never passed to
their command sink. The editor's cancellation guard checkbox can refuse explicit
@@ -884,6 +886,7 @@ One setting per line, sorted by name. A change that adds a setting adds its own
- `dimension_input` (`view` (default): a sketch dimension's value is typed in a box on the dimension; `window`: placing a dimension with the Dimension tool, or double-clicking one, opens a small window at it with the value field (handle `dimwin:value`), arrows that step the value (`dimwin:up`, `dimwin:down`), a check (`dimwin:ok`, also Enter) and a cross (`dimwin:cancel`, also Esc), #7246)
- `dimension_step_deg` (default 1, 0.001…180: what the dimension window's arrows, Up/Down and the wheel over its field add to an angle, in degrees)
- `dimension_step_mm` (default 1, 0.001…1000: what the window's arrows, Up/Down and the wheel over its field add to a length, in millimetres, written in the unit the value is written in)
- `escape_twice_finishes_sketch` (default true: in a sketch, two quick presses of Esc (0.4 s apart or less) with no tool or dialog left to cancel finish it, as before; false: Esc only cancels the tool, dialog or selection and the sketch stays open until Finish Sketch, #12069)
- `extrude_arrow_face_stops` (default true: dragging Extrude's distance arrow stops at flat faces parallel to the profile that lie over it, takes their exact distance and outlines them, #2398; false: the drag moves in round steps only, as before)
- `extrude_auto_to_object` (default true: in the Extrude dialog, with a profile picked, a face, vertex or parallel plane picked off its plane switches the extent to To Object, unless the Profiles box was clicked into; it applies to Extrude dialogs opened afterwards)
- `frame_rate_details` (default false: with the frame rate, a frame-time graph of the frames drawn with the triangles over it, a frame-time histogram and what the GPU drew; clicking the readout switches it)
+1
View File
@@ -39,6 +39,7 @@ Two columns of checkboxes.
|---|---|---|---|---|---|---|
| Sketching on a part's face makes that part active | Checkbox | `Prefs.sketch_face_activates_part` | `false` | Existing `prefs.sketch_face_activates_part_note`: activate the other component before creating the sketch; it stays active. Off keeps the active component. | none | Retained newer control; first row, immediately before turn-to-face. |
| Turn to face a sketch when it opens | Checkbox | `Prefs.sketch_open_look_at` | `true` | none | none | |
| Double Esc finishes the sketch | Checkbox | `Prefs.escape_twice_finishes_sketch` | `true` | `prefs.sketch.escape_twice_finishes_note`: two quick presses of Esc end the sketch; off, Esc only cancels the tool or the selection. | none | #12069; immediately after turn-to-face. |
| When a sketch ends: Return to the view before / Keep (default) / Unless turned (return if not rotated) | Three picture cards (196 x 152.5), a before / sketch / after trio each | `Prefs.sketch_exit_view` (`SketchExitView`: Previous, Keep, UnlessRotated) | Keep | none | `sketch-exit-previous`, `sketch-exit-keep`, `sketch-exit-unless-turned` | Card labels: "Return to the view before" / "Keep" with sub-label "default" / "Unless turned" with sub-label "return if not rotated". The 30x30 panels are nested SVGs inside the figure. |
## Marks in the sketch
+1 -2
View File
@@ -10,8 +10,7 @@ ordinary `execute` tool: `render.preflight`, `render.start`, `render.status`,
`render.cancel` and `render.export`. Keep the complete returned job ID; image bytes remain
in the session until explicit PNG export. See [render jobs](render-jobs.md).
For how the same command surface should work in the browser (workers, tiered storage, in-tab
agents), see [web-runtime.md](web-runtime.md). For sandboxed plugins and parametric timeline
For sandboxed plugins and parametric timeline
features that also use `run(command, args)`, see [plugins.md](plugins.md). Desktop MCP in this
document remains the reference implementation today.
+52 -6
View File
@@ -306,18 +306,20 @@ reparent the window.
The default adapter truthfully advertises no native windows. Actual Window
placement needs the native sibling's monitor/DPI, focus and close/redock services.
The renderer retains the shared Container's fill alpha; captured `paint_alpha`
reports it separately from declarative surface intent. Input and occlusion remain
reserved regardless of alpha. Unsupported translucent effective policies still
refuse. Contrast proof, comprehensive accessibility/keyboard bindings, production
workspace rollout and real content adapters remain their named peers' work.
The renderer resolves surface intent through the shared Container, raising its
fill alpha when canonical text needs more contrast. Captured `paint_alpha` and
`contrast` report the effective paint and any readable fallback separately from
declarative intent. Input and occlusion remain reserved regardless of alpha.
Comprehensive accessibility/keyboard bindings, production workspace rollout and
real content adapters remain their named peers' work.
## Specimens and verification boundary
`ui.panel_fixture` is authenticated, off by default, and refuses to replace another
runtime. Presets: `docks`, `tabs`, `collapsed`, `floating`, `overflow` (24 instances),
`pinned`, `columns` (three independent Left columns), `stack_tabs`, `content_fill`
(short Content versus Fill), and `inline` (one editable total row). The old `rails`
(short Content versus Fill), `inline` (one editable total row), and `surfaces`
(declared surface preferences with the existing synthetic bodies). The old `rails`
preset is replaced by `collapsed`; it is not an alias to the retired experience.
The optional `remember:true` enable parameter attaches the application preference
catalogue for isolated restart review. A restart must explicitly enable the same
@@ -325,6 +327,50 @@ specimen with `remember:true`; loading preferences alone never starts it. Saved
restoration waits for valid host dimensions and uses normal runtime admission.
Fixture workspace/preset registrations remain synthetic evidence, not shipping
production registrations.
### Surface review specimen
The `surfaces` preset alone opts out of the host's compatibility opaque default.
It contains an inactive text-heavy editor tab beside an active log tab, explicit
Opaque and Translucent side members, and FollowZone Bottom and Floating members.
The same existing content, commands and translated captions remain in use.
Other presets retain their previous opaque presentation.
`ui.panel_fixture {"preset":"surfaces","translucency_percent":50}` requests
50% translucency for this explicitly enabled specimen. The optional value is a
finite number from 0 to 100; opacity is `1 - translucency_percent / 100`.
Zero requests an opaque surface and 100 requests a fully transparent surface
before readability protection. The override is not a saved preference.
`ui.panel_fixture {"translucency_percent":100}` updates the already enabled
specimen host in place, preserving retained controllers, drafts and layout.
It refuses when no specimen host is enabled or another provider owns the runtime.
The parameter is also accepted with other explicitly enabled specimen presets;
their compatibility opaque policy still applies. Omitting the
override uses current theme/preferences rather than a specimen colour palette.
Surface resolution uses every registered tab's metadata, including inactive tabs.
Explicit Opaque wins, then explicit Translucent. Otherwise FollowZone is opaque
for Bottom/Window or a multi-tab group containing a text-heavy member, and
translucent elsewhere. Effective opacity may increase to protect both primary
and muted text at 4.5:1 over possible viewport backgrounds. When the authored
token pair cannot meet that guarantee even at full opacity, the renderer uses a
known accessible opaque fallback pair and reports the failure, rather than
describing the authored pair as compliant. Existing blur infrastructure remains
independent of opacity and input capture.
Section inspection reports declarative `surface`, actual `paint_alpha`, and
optional `contrast`. The latter preserves `ContrastOutcome`, including the
fallback reason; fallback is not a successful proof for the original pair.
Actual clipped surface footprints continue to own input even when translucent.
Uncovered short-panel gaps remain navigable, while content scrolling and actual
controls shield the model. Temporary popup shielding is not a Fit/Centre obstacle.
This is shared surface policy and an opt-in review seam, not a production content
migration or a new appearance preset. Classic and shipping defaults remain.
Future approved docked adapters are Browser, History, Assistant and their allowed
RenderSceneBrowser counterpart; other content conversions require a separate
scope decision. Persistent legacy forms retain their existing hosts and continue
to contribute their actual footprints until their own migrations.
The original synthetic controllers expose draft, transaction, action, lifecycle,
render and service counters plus process-local equality tokens. They do not expose
fake production actions or pretend to perform CAD work.
+4 -8
View File
@@ -2,8 +2,7 @@
Design companion to [#1221](https://github.com/bherbruck/solidcraft/issues/1221): sandboxed
WebAssembly plugins with a WIT interface, the same `run(command, args)` JSON as
[MCP](mcp.md) and the [control protocol](control-protocol.md). Browser hosting is in
[web-runtime.md](web-runtime.md).
[MCP](mcp.md) and the [control protocol](control-protocol.md).
This document adds **parametric timeline features**: a generic host-owned feature shell that
plugins fill with parameters, display metadata, and rebuild logic, without a second timeline
@@ -100,7 +99,7 @@ Manifest declares **`fallback_policy`**:
| `suppress_with_warning` | Feature suppressed; user sees clear message |
| `error_on_rebuild` | Hard fail on eval (strict teams) |
Design files shared in CollabCraft should prefer a policy that **opens safely** (`recorded_mesh` or
Shared design files should prefer a policy that **opens safely** (`recorded_mesh` or
`recorded_script`). CI for plugins (#1221) should include: uninstall plugin, reopen file, assert
document still opens and policy matches manifest.
@@ -123,12 +122,9 @@ header carries `PluginDisplay` metadata while children are ordinary native featu
not need the plugin until the user edits plugin-owned params on the header. Migrates cleanly to
full `FeatureKind::Plugin` when `evaluate` lands.
## Relationship to craft-libs
## Where the code lives
Plugin **document model and eval** stay in SolidCraft (`crates/doc`, `crates/engine`). craft-libs
provides worker/broker hosting only ([web-runtime.md](web-runtime.md)). Neutral serde types
(`VersionedParams`, `PluginDisplay`) may move to a shared crate later if multiple apps need them;
CAD plugins remain SolidCraft-specific.
Plugin **document model and eval** stay in SolidCraft (`crates/doc`, `crates/engine`).
## Phasing
+14 -2
View File
@@ -99,12 +99,24 @@ adapter while native placements exist. The default adapter truthfully supplies n
native Window service. Host cards reuse adaptive Container styling, title fonts,
the six-dot grip and header separator. Shared shell gaps are allocation space.
Captured frame alpha is reported separately from declarative surface intent;
unsupported effective translucency still refuses. Native windows, contrast proof, comprehensive accessibility/keyboard
the opt-in surface renderer reports contrast outcomes and raises opacity to retain
readable primary and muted text. An invalid authored pair uses an opaque accessible
fallback and reports its reason rather than claiming the original pair complies.
Native windows, comprehensive accessibility/keyboard
bindings, saved workspace rollout and real content migrations remain peer work.
The authenticated `ui.panel_fixture` seam is off by default and rejects replacing
an existing runtime. Presets are `docks`, `tabs`, `collapsed`, `floating`, `overflow`
(24 instances), `pinned`, `columns`, `stack_tabs`, `content_fill` and `inline`.
(24 instances), `pinned`, `columns`, `stack_tabs`, `content_fill`, `inline` and `surfaces`.
Only `surfaces` opts out of the compatibility opaque host policy. Its optional
`translucency_percent` request (finite 0–100) converts to opacity as
`1 - percent / 100`; readability protection may increase effective opacity.
This fixture override changes no saved preference. Inspection reports each
section's declared `surface`, actual `paint_alpha` and optional `contrast` outcome,
including fallback reasons. See [the surface specimen contract](panel-foundation.md#surface-review-specimen).
An enabled specimen accepts `translucency_percent` alone to update presentation
in place, retaining its controllers, drafts, layout and model. A missing/disabled
specimen host or a runtime owned by another provider refuses this update.
Drafts, controller identities and lifecycle/service/action counters are inspectable.
The native specimen exposes editor Apply/Finish/Cancel actions. Separate
action-binding fixtures retain their declared test actions.
-156
View File
@@ -1,156 +0,0 @@
# Browser runtime (design)
Design note for SolidCraft in the browser: tiered storage, worker offload, GPU jobs, and
agent control that stay aligned with the desktop app, the control channel, and
[MCP](mcp.md). Implementation lives primarily in
[craft-libs](https://github.com/storytold/craft-libs) (`craft-tier-store`, `craft-worker-host`,
`craft-gpu`, `craft-runtime`); SolidCraft wires those crates into
[`apps/solidcraft-web`](../apps/solidcraft-web) and [`files.rs`](../apps/solidcraft-web/src/files.rs)
rather than growing a second stack here.
Related: [plugins.md](plugins.md) and [#1221](https://github.com/bherbruck/solidcraft/issues/1221)
(desktop WIT + wasmtime first; parametric timeline features in phase 2), [design containers](design-container.md),
[control protocol](control-protocol.md).
## Problem
The wasm app today ([`getting-started`](../docs/book/src/guide/getting-started.md)) keeps designs in
the origin private file system (OPFS) behind virtual `/opfs/` paths, with optional File System
Access import/export. That works for small and medium designs but hits three walls:
| Wall | Symptom in SolidCraft |
|------|------------------------|
| Quota | Large STEP imports and container blobs compete with autosave and cache in OPFS |
| Main thread | Open, save, and timeline evaluation run synchronously on the UI thread with egui |
| Agents | MCP runs out of process (`solidcraft-cli mcp`); the tab has no first-class agent session |
WebGPU for the viewport already runs on the main thread (wgpu via eframe). Moving the entire UI
renderer into a worker is out of scope for the first integration; worker GPU is for offload jobs
(tessellation warm paths, future kernel work), not for egui.
## Principles
1. **One command surface.** Toolbar, scripts, control channel, MCP, and (eventually) in-browser
agents all use the same engine commands. Dialog-free commands only for unattended paths, same
rule as [MCP](mcp.md).
2. **Generic traits in craft-libs.** Storage tiers, worker RPC, and GPU dispatch are implemented
once in craft-libs. Web and native use the same traits; hosts differ (OPFS vs paths, SharedWorker
vs thread pool).
3. **Do not duplicate cloud IO.** Authenticated cloud Open/Save uses `craft-cloud` and CollabCraft
([craft-libs #48](https://github.com/storytold/craft-libs/pull/48)). Browser-local tiers
(`craft-tier-store`) are separate: hot mirror, warm cache, cold working folder, optional range
reads of cloud blobs through CollabCraft without vendor types in generic crates.
4. **One worker broker.** A SharedWorker (or dedicated-worker fallback) coordinates storage writes,
job queues, and shared wasm module compile per origin. Plugins and offload jobs register on the
broker; do not spawn one SharedWorker per plugin ([#1221 comment thread](https://github.com/bherbruck/solidcraft/issues/1221)).
## Architecture
```text
Main thread (tab)
eframe + viewport WebGPU
hot mirror (sync reads for engine)
craft_runtime HostFacade ----MessagePort RPC----> SharedWorker broker
tier-store coordinator (single writer)
job queue + dedup
headless engine session(s)
optional dedicated GPU workers
ProviderRegistry (GpuJob dispatch)
Tiers (craft-tier-store)
Hot RAM mirror, LRU budget
Warm OPFS under craft/<appId>/… (today: /opfs/ mapping)
Cold File System Access project folder (no duplicate bytes in OPFS)
Cloud craft-cloud refs, range fetch via CollabCraft API (not in wasm credentials)
```
On **desktop**, the same `BlobStore` and job envelopes apply: warm/cold map to normal directories;
[`solidcraft-cli mcp`](mcp.md) and wasmtime plugin hosts ([#1221](https://github.com/bherbruck/solidcraft/issues/1221))
call the same `run(command, args)` JSON.
## Mapping SolidCraft today
| Today | Target |
|-------|--------|
| [`files.rs`](../apps/solidcraft-web/src/files.rs) OPFS + `/opfs/` VFS | Warm tier keys; migration keeps existing filenames initially |
| Recovery autosave | Warm tier, same semantics as [`compatibility.md`](compatibility.md) |
| [Design containers](design-container.md) content-addressed blobs | Tier keys by hash; cold tier holds large blobs without copying into OPFS |
| [Control protocol](control-protocol.md) over TCP | Same methods over `MessagePort` to broker headless session (WebMCP-shaped, not a second registry) |
| Parallel mesh work ([roadmap detail](roadmap-detail.md): web path serial) | Offload eval / IO jobs via `JobSubmit`; viewport stays on main thread |
## craft-libs crates (reference)
These names follow the craft-libs performance platform design; SolidCraft depends on them as
published from storytold/craft-libs after cloud contracts land (#48, #52).
| Crate | Role |
|-------|------|
| `craft-tier-store` | `BlobStore`, tier policy, sync mirror over async persist; native + wasm hosts |
| `craft-worker-host` | SharedWorker bootstrap, RPC messages (`Init`, `StorageOp`, `JobSubmit`, …) |
| `craft-gpu` | `GpuContext`, `GpuJob`, `GpuProvider`, `ProviderRegistry` (no app WGSL here) |
| `craft-gpu-wgpu` | wgpu backend: WebGPU on wasm32, Vulkan/DX12/Metal on desktop |
| `craft-gpu-providers` | Shared readback/composite helpers extracted from Craft app gpu crates |
| `craft-runtime` | Facade: tier store + worker host + optional `cloud-delegate` hooks |
Generic runtime code must not depend on SolidCraft crates. SolidCraft registers app-specific
providers and key namespaces only.
## GPU and the viewport
- **Main thread:** eframe owns the WebGPU device used for the 3D view and UI.
- **Workers:** `craft_runtime::gpu::dispatch(registry, jobs)` runs `GpuJob` lists (chunked buffers,
readback fences) for offload; same entry point on native thread pools.
- **Limits:** Capability probe once at init; UI and workers share reported max buffer sizes.
## Agents in the browser
| Mode | Transport |
|------|-----------|
| Desktop agent | `solidcraft-cli mcp` (stdio) or `--connect` to [control protocol](control-protocol.md) |
| Browser agent | RPC to broker session; dialog-free tools only |
| IDE bridge | Optional local relay (stdio MCP ↔ tab); not required for CollabCraft-hosted wasm |
File policy for web agents mirrors MCP folders: design directory, cold tier roots, recovery;
never `--allow-any-path` in the tab.
## Plugins ([#1221](https://github.com/bherbruck/solidcraft/issues/1221))
| Host | Runtime |
|------|---------|
| Desktop | wasmtime + fuel/memory limits; WIT `solidcraft:plugin@1.0.0` with `run`, `manifest`, permissions |
| Web | Component or module in broker; same WIT contract; sandbox = manifest permissions enforced before `run()` |
Milestone 1: one-shot commands and importers/exporters. Milestone 2: **parametric timeline
features** via a generic host-owned `FeatureKind::Plugin` shell (params + display metadata
including timeline icon and label); plugins rebuild through WIT `evaluate()` calling the same
`run()` JSON. See [plugins.md](plugins.md). Command ids may use a `pluginname.function` prefix to
avoid collisions.
## CollabCraft deployment
Static wasm apps on `craft.quasarke.net` ship `craft-worker-host.js` (brotli-precompressed) beside
the app bundle. Prefer one global runtime worker per origin once message routing and wasm size are
budgeted; per-app worker URLs are acceptable for pilots. CSP must allow `worker-src` / SharedWorker.
## Phasing (SolidCraft)
1. **Document and track** (this file); no user-visible change.
2. **Tier-store wiring:** replace ad-hoc OPFS glue with `craft-tier-store` keys; keep on-disk layout.
3. **Worker broker:** headless `engine.execute` batches and container IO off UI thread.
4. **Agent bridge:** MessagePort tool surface equivalent to MCP dialog-free tools.
5. **Plugins on web:** after desktop wasmtime milestone and WIT stability.
6. **Parametric plugin features:** [plugins.md](plugins.md) phase 2 (`evaluate`, display metadata, fallback policy).
## Out of scope (initial integration)
- Moving egui/viewport WebGPU into SharedWorker
- Second non-wgpu GPU backend in craft-libs
- **Implementing** parametric `FeatureKind::Plugin` (designed in [plugins.md](plugins.md); follows craft-runtime wiring)
- Duplicating `craft-cloud` or embedding `craft-nextcloud` in SolidCraft wasm
## Tests
- Extend control-channel scenarios for broker RPC parity where methods map 1:1
- Worker tests: SharedWorker multi-tab dedup (EffectCraft `workers.mjs` pattern)
- Tier-store: native LRU tests + wasm OPFS smoke
- GPU: golden frames remain in app crates; craft-gpu-null for CI dispatch smoke
+1 -1
View File
@@ -22,4 +22,4 @@ description: "Use when a SolidCraft change is slow, blocks the window, runs long
- Everything below the apps must build for `wasm32-unknown-unknown`: `cargo xtask wasm` checks every library crate and the web app, and is part of `cargo xtask ci`. Do not add a dependency that cannot.
- Application code is Rust only. The browser requires two pieces of JavaScript: the service worker `apps/solidcraft-web/sw.js` and the small inline scripts in `apps/solidcraft-web/index.html`. Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
- Python 3.11+ is allowed only for asset authoring, lint and review tools (`assets/AGENTS.md`), never as an application dependency.
- Design notes for the browser runtime: `docs/web-runtime.md`, `docs/browser-api.md`.
- Design notes for the browser runtime: `docs/browser-api.md`.
+10 -2
View File
@@ -7,8 +7,9 @@
# (`gh run download <run> -R tangency-labs/solidcraft -n <artifact> -D <dir>/<artifact>` for junit
# and junit-gpu-1..4), run `cargo xtask known-failing --junit <dir>`, then drop each FIXED line and
# add each REGRESSION line with its cause, from the test's failure message.
solidcraft-cli::action_matrix::action_matrix_sample 2 baseline actions fail: 2 shells (Bad Loop Orient on issue-616-lid-ribs; status 1001 on filleted-shelled-box)
solidcraft-cli::naming_corpus::corpus_designs_name_alike_every_build_and_fail_where_a_reference_is_gone oracle/83-boundary-loop-thicken does not load: step 8, the offset of face 0 by -1 is not proved regular, so thicken is refused with no size named
solidcraft-cli::action_matrix::action_matrix_sample 3 baseline actions fail, the same 3 as on C++: 2 shells (Bad Loop Orient on issue-616-lid-ribs; status 1001 on filleted-shelled-box), 1 fillet (SmSurfOfRevolution Test #7 on oblique_cut_of_cylinder)
solidcraft-cli::model_cache::a_later_round_after_opening_blends_the_saved_rounds_again the later r3 round at the r2 mitre builds solidcore's 10-face corner (7928.743041), the test wants SMLib f916a8d's setback corner (smlib#104), which solidcore ports in solidcore#2310: 15 faces, 7928.142563938
solidcraft-cli::naming_corpus::corpus_designs_name_alike_every_build_and_fail_where_a_reference_is_gone oracle/83-boundary-loop-thicken does not load: step 8, thicken status 1001, as on C++
solidcraft-doc::eval::cost_tests::a_tap_marking_searches_only_the_bodies_near_it main's mesh-based version (#10303) not taken; the exact replacement is to come
solidcraft-doc::eval::cost_tests::a_tap_marking_still_searches_the_bodies_within_its_reach main's mesh-based version (#10303) not taken; the exact replacement is to come
solidcraft-engine::cmd::edit::tests::inserting_variable_fillet_control_preserves_parameters_at_existing_positions solidcore refuses a variable fillet whose law has a corner (solidcore#430)
@@ -20,12 +21,19 @@ solidcraft-engine::measure_centre_tests::a_fillet_round_a_boss_reports_its_radiu
solidcraft-engine::tests::chord_and_variable_fillets its 2 → 4 → 2 waist: solidcore refuses a variable fillet whose law has a corner (solidcore#430)
solidcraft-engine::tests::fillet_fit_taper_closes_to_a_point fillet status 1001 on the taper fit (radius 16 closing to 0), as on C++
solidcraft-engine::tests::mirror_placed::dragging_a_mirrored_part_meshes_its_copies_once a copy of a copy drawn from its first copy's triangles (#10101) is triangulated differently from a fresh mesh of it, where truck's meshes are the same under a rigid move, as on SMLib
solidcraft-engine::tests::shell_tangent_chain_that_opens_every_face_explains_how_to_keep_walls the 2 mm shell of the box with every edge rounded r 1.5, its front face open, fails SM_ERR_INVALID_INPUT at solidcore 8ddce9016: the wall is thicker than the rounds and their Ball corners (BallPatch, fillet_blend_corner), whose inward offsets collapse
solidcraft-kernel::blend::corner_tests::a_round_ending_in_an_earlier_round_matches_both_together fillet status 1001 on the round that ends in the earlier one, as on C++
solidcraft-kernel::blend::corner_tests::corner_blends_hold_at_any_scale at 0.01x the arc case's rounds remove 72.783 (scaled back) against 72.713 at 1x, as on C++
solidcraft-kernel::blend_corner::tests::issue2422_vertical_after_separate_bottom_rounds the vertical edge after the separate bottom rounds is refused as smooth ("its faces meet tangentially, so there is nothing to round"): the rounds leave it tangent at solidcore 8ddce9016
solidcraft-kernel::blend_corner::tests::three_rounds_at_a_corner the corner's variable-radius transition between rounds of different radii meets its neighbours at 0.144 degrees once its sections are the balls' touch circles (fillet_gn_section_generator, solidcore#2262; 0 with the great circles), as on SMLib d01d7b0; the corner step replaces that transition
solidcraft-kernel::blend_variable::tests::variable_radius_on_a_box_edge the variable round r 2 to 5 along a box edge fails status 1001 at solidcore 8ddce9016
solidcraft-kernel::blend_variable::tests::variable_radius_with_a_corner_through_a_middle_point solidcore refuses a variable fillet whose law has a corner (solidcore#430)
solidcraft-kernel::blend_wrong_way_tests::a_chamfer_that_would_grow_the_part_on_a_convex_edge_is_refused at solidcore 8ddce9016 the 0.5 chamfer of the ball cut's rim is no longer refused as too large: the session builds no face (made no change), its tangent chain running into an unblended edge
solidcraft-kernel::corner_g2::tests::a_long_setback_keeps_the_g2_patch_inside_the_box at set backs 11 to 15 the G2 corner patch bulges past the box, as on C++
solidcraft-kernel::corner_g2::tests::curvature_rounds_meet_at_a_g2_corner the corner between curvature rounds jumps 2.07 in curvature over 538 stations on solidcore; the test wants it curvature continuous, as SMLib f916a8d's setback corner (smlib#104), which solidcore ports in solidcore#2310 builds it
solidcraft-kernel::corner_g2::tests::the_box_corner_g2_patch_is_a_closed_solid_curvature_continuous_to_its_neighbours the fitted G2 corner patch bulges 1.7e-5 past the box, as on C++
solidcraft-kernel::fillet_setback_tests::a_setback_not_past_the_radius_is_refused_naming_the_least solidcore builds setback 5.5 (15 faces) where SMLib f916a8d's setback corner (smlib#104), which solidcore ports in solidcore#2310 refuses it naming the least that builds; nothing names the least setback on solidcore until then
solidcraft-kernel::modify_history_tests::fillet_variable_and_chord the variable round r 2 to 5 along the cube's edge fails status 1001 at solidcore 8ddce9016, as variable_radius_on_a_box_edge
solidcraft-kernel::native::tests::box_and_fillet_have_stable_wire_records the box-edge round's wire record differs from the fixture at solidcore 8ddce9016 (a value 3 is 5): the fixture is re-blessed (NATIVE_BREP_BLESS=1) once the change is shown right
solidcraft-smlib::ops::tests::a_variable_round_with_a_corner_in_its_law_is_its_balls_union solidcore refuses a variable fillet whose law has a corner (solidcore#430)
solidcraft-ui-egui::preselect_tests::variable_fillet_round_trip the dialog's variable round fails status 1001 at solidcore 8ddce9016, as variable_radius_on_a_box_edge
+4 -1
View File
@@ -4,6 +4,8 @@
1080-fillet-equal-slope-basin-1-mm-rim-loop-4 [fail] continuity_order: got {'body': 'Slab', 'edges': 'blend', 'order': 'g0', want >= g1 at every blend seam
1080-fillet-sloped-basin-1-mm-rim-r4-2-edges [fail] continuity_order: got {'body': 'Slab', 'edges': 'blend', 'order': 'g0', want >= g1 at every blend seam
1080-fillet-sloped-basin-1-mm-rim-r4-4-edges [fail] continuity_order: got {'body': 'Slab', 'edges': 'blend', 'order': 'g0', want >= g1 at every blend seam
2893-chamfer-distance-and-angle-past-the-wall-refused [fail] refused, but not because "angle": step 2 (solid.chamfer): SMLib chamfer: operation: SMLib rejected the input (SM_ERR_INVALID_INPUT)
2893-chamfer-two-distances-elliptical-rim [fail] refused, but not because "not supported yet": step 3 (solid.chamfer): SMLib chamfer: operation: SMLib failed (status 1001) (tangent propagation blended the picked edges' tangent chain with them; with it off, tangent_chain:
4782-fillet-bar-edge-wider-than-both-faces [fail] continuity_order: got Box: no null seams want >= g1 at every blend seam
9-fillet-corner-curvature-with-reduce-fit [unsupported] solid.fillet builds `corner: g2` with `fit: exact` only
9-fillet-corner-curvature-with-taper-fit [unsupported] solid.fillet builds `corner: g2` with `fit: exact` only
@@ -14,8 +16,9 @@ cov-fillet-g2-mixed-corner [unsupported] refused as on SMLib d01d7b0: a G2 corne
cov-fillet-g2-oblique-three-edge-corner [unsupported] refused on solidcore: its three edges meet at different wedge angles, and one curvature fillet takes the first edge's equal-area thumbweight for every edge (solidcore#2250, issuecomment-6563419897); SMLib d01d7b0 fails it too
cov-fillet-g2-pocket-corner [fail] continuity_order: G1 at one seam where the corner patch meets a contact face (g2 wanted), as on SMLib d01d7b0
cov-junction-boss-tilt-20-root-round [fail] fillet status 1001 on a cylinder tilted 20 degrees into the plate, as on SMLib
cov-junction-boss-tilt-40-root-round [fail] fillet status 1001 on a cylinder tilted 40 degrees into the plate, as on SMLib
cov-junction-tee-equal-round [fail] the result is not a closed solid, as on SMLib: equal pipes' intersection is two plane ellipses crossing, a corner the old corner code cannot join (the corner step)
cov-junction-y-junction-120-round [unsupported] refused as on SMLib: the faces between the three intersection curves narrow to nothing at the two triple points, so the room check trips (the corner step)
report-drafted-peg-front-and-concave-rounds-together-r2 [unsupported] step 13 (solid.fillet): invalid input: not supported yet: the fillet meets a corner of this kind, which SMLib's blends do not handle yet (where its ed
report-fillet-curvature-three-edges-at-bar-corner [fail] continuity_order: G1 at one seam where the corner patch meets a contact face (g2 wanted), as on SMLib d01d7b0 (see KNOWN_FAILING)
report-fillet-curvature-three-edges-at-bar-corner-solver-residues [fail] continuity_order: G1 at one seam where the corner patch meets a contact face (g2 wanted), as on SMLib d01d7b0 (see KNOWN_FAILING)
report-fillet-curvature-three-edges-at-bar-corner-solver-residues-outside-setback [unsupported] solidcore#2697: solidcore's own filled corner builds this to its closed form (7e-11); SolidCraft's least-squares fit misses curvature by 4.02e-3 against 2.5e-3 at this pin
@@ -2,9 +2,9 @@
"sources": [],
"what": "the running part with every tier: back wall, boss, slot, spline window, tapped holes, rounds, chamfers, shell, vents, and a modelled M16 thread on the boss",
"source": "analytic",
"method": "10/008 less the M16 groove on the boss: its cylinder runs down to the r1 root round's shoulder (End::Shoulder), where it is trimmed at z 21, up to the 1 mm top chamfer (z 29, run out through it, a free end); the groove's root r 6.917 stays outside the boss's 1.5 wall's inside (r 6.5), so it cuts the wall only. Bbox: the part's",
"method": "10/008 less the M16 groove on the boss: its cylinder runs from the r1 root round (z 21, a shoulder: the groove stops w = 0.932735 clear of it) to the 1 mm top chamfer (z 29, run out through it, a free end); the groove's root r 6.917 stays outside the boss's 1.5 wall's inside (r 6.5), so it cuts the wall only. Bbox: the part's",
"body_count": 1,
"volume_mm3": 15111.5575,
"volume_mm3": 15138.532607554,
"bbox": {
"min": [
0,
+5 -5
View File
@@ -1822,12 +1822,12 @@
},
"ladder/11-cumulative-parts/004-housing": {
"measured": {
"area_mm2": 21156.778657103445,
"area_mm2": 21105.287553425696,
"body_count": 1,
"edges": 218,
"faces": 81,
"vertices": 144,
"volume_mm3": 15111.545095719644
"edges": 210,
"faces": 78,
"vertices": 139,
"volume_mm3": 15138.519861452509
},
"seconds": 4.128625132,
"status": "pass"
@@ -1,5 +1,6 @@
{
"source": "analytic",
"known_failure": "the variable round r 2 to 5 along a box edge fails status 1001 at solidcore 8ddce9016 (as blend_variable::tests::variable_radius_on_a_box_edge in tests/KNOWN_FAILING_TESTS)",
"method": "box 40x30x20 with its top front edge (y=0, z=20) rounded at a radius running straight from 2 (x=0) to 5 (x=40). The round is the union of the balls rolled along the edge, ball s touching both faces at r(s), the law continued past the edge's ends along its end slopes: each section loses the corner points no ball reaches, up to rho(x, t), the nearest entry into a ball along the ray from the edge at the angle t. V, moments and areas integrated by Gauss-Legendre panels in x and t (tools/oracle-analytic/cases_fillets.py)",
"body_count": 1,
"volume_mm3": 23888.613525715446,