From 055f1cdb1a1ac86ee64ea3eccd550ce55014a22f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 5 Sep 2026 14:46:06 +0300 Subject: [PATCH 01/24] Redesign modelling ribbon icons --- assets/icons/model/box.svg | 8 +++++++ assets/icons/model/chamfer.svg | 9 ++++++++ assets/icons/model/cone.svg | 6 ++++++ assets/icons/model/cylinder.svg | 8 +++++++ assets/icons/model/extrude.svg | 9 ++++++++ assets/icons/model/fillet.svg | 9 ++++++++ assets/icons/model/intersect.svg | 13 ++++++++++++ assets/icons/model/loft.svg | 7 +++++++ assets/icons/model/polysolid.svg | 10 +++++++++ assets/icons/model/presspull.svg | 11 ++++++++++ assets/icons/model/pyramid.svg | 8 +++++++ assets/icons/model/revolve.svg | 8 +++++++ assets/icons/model/sphere.svg | 6 ++++++ assets/icons/model/subtract.svg | 12 +++++++++++ assets/icons/model/sweep.svg | 8 +++++++ assets/icons/model/torus.svg | 6 ++++++ assets/icons/model/union.svg | 10 +++++++++ assets/icons/model/wedge.svg | 7 +++++++ src/modules/model/mod.rs | 36 ++++++++++++++++---------------- 19 files changed, 173 insertions(+), 18 deletions(-) create mode 100644 assets/icons/model/box.svg create mode 100644 assets/icons/model/chamfer.svg create mode 100644 assets/icons/model/cone.svg create mode 100644 assets/icons/model/cylinder.svg create mode 100644 assets/icons/model/extrude.svg create mode 100644 assets/icons/model/fillet.svg create mode 100644 assets/icons/model/intersect.svg create mode 100644 assets/icons/model/loft.svg create mode 100644 assets/icons/model/polysolid.svg create mode 100644 assets/icons/model/presspull.svg create mode 100644 assets/icons/model/pyramid.svg create mode 100644 assets/icons/model/revolve.svg create mode 100644 assets/icons/model/sphere.svg create mode 100644 assets/icons/model/subtract.svg create mode 100644 assets/icons/model/sweep.svg create mode 100644 assets/icons/model/torus.svg create mode 100644 assets/icons/model/union.svg create mode 100644 assets/icons/model/wedge.svg diff --git a/assets/icons/model/box.svg b/assets/icons/model/box.svg new file mode 100644 index 000000000..b5bdd3387 --- /dev/null +++ b/assets/icons/model/box.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/model/chamfer.svg b/assets/icons/model/chamfer.svg new file mode 100644 index 000000000..709ae1d0c --- /dev/null +++ b/assets/icons/model/chamfer.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/icons/model/cone.svg b/assets/icons/model/cone.svg new file mode 100644 index 000000000..1de0d9973 --- /dev/null +++ b/assets/icons/model/cone.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/model/cylinder.svg b/assets/icons/model/cylinder.svg new file mode 100644 index 000000000..e67f2ddd3 --- /dev/null +++ b/assets/icons/model/cylinder.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/model/extrude.svg b/assets/icons/model/extrude.svg new file mode 100644 index 000000000..2d4313745 --- /dev/null +++ b/assets/icons/model/extrude.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/icons/model/fillet.svg b/assets/icons/model/fillet.svg new file mode 100644 index 000000000..94745172c --- /dev/null +++ b/assets/icons/model/fillet.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/icons/model/intersect.svg b/assets/icons/model/intersect.svg new file mode 100644 index 000000000..a0c28e880 --- /dev/null +++ b/assets/icons/model/intersect.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/icons/model/loft.svg b/assets/icons/model/loft.svg new file mode 100644 index 000000000..983a96523 --- /dev/null +++ b/assets/icons/model/loft.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/model/polysolid.svg b/assets/icons/model/polysolid.svg new file mode 100644 index 000000000..2f56acdaf --- /dev/null +++ b/assets/icons/model/polysolid.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icons/model/presspull.svg b/assets/icons/model/presspull.svg new file mode 100644 index 000000000..ecc793f02 --- /dev/null +++ b/assets/icons/model/presspull.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/icons/model/pyramid.svg b/assets/icons/model/pyramid.svg new file mode 100644 index 000000000..d2a5a1324 --- /dev/null +++ b/assets/icons/model/pyramid.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/model/revolve.svg b/assets/icons/model/revolve.svg new file mode 100644 index 000000000..047ebf48f --- /dev/null +++ b/assets/icons/model/revolve.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/model/sphere.svg b/assets/icons/model/sphere.svg new file mode 100644 index 000000000..3ff1a9687 --- /dev/null +++ b/assets/icons/model/sphere.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/model/subtract.svg b/assets/icons/model/subtract.svg new file mode 100644 index 000000000..933b09690 --- /dev/null +++ b/assets/icons/model/subtract.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/icons/model/sweep.svg b/assets/icons/model/sweep.svg new file mode 100644 index 000000000..c7e69eee0 --- /dev/null +++ b/assets/icons/model/sweep.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/model/torus.svg b/assets/icons/model/torus.svg new file mode 100644 index 000000000..75835fea7 --- /dev/null +++ b/assets/icons/model/torus.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/model/union.svg b/assets/icons/model/union.svg new file mode 100644 index 000000000..34a79d089 --- /dev/null +++ b/assets/icons/model/union.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icons/model/wedge.svg b/assets/icons/model/wedge.svg new file mode 100644 index 000000000..d54f4b295 --- /dev/null +++ b/assets/icons/model/wedge.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/modules/model/mod.rs b/src/modules/model/mod.rs index c430ffc30..18ee79a87 100644 --- a/src/modules/model/mod.rs +++ b/src/modules/model/mod.rs @@ -10,24 +10,24 @@ use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, pub struct ModelModule; -const BOX_ICON: &[u8] = include_bytes!("../../../assets/icons/box3d.svg"); -const CYLINDER_ICON: &[u8] = include_bytes!("../../../assets/icons/cylinder3d.svg"); -const CONE_ICON: &[u8] = include_bytes!("../../../assets/icons/cone3d.svg"); -const SPHERE_ICON: &[u8] = include_bytes!("../../../assets/icons/sphere3d.svg"); -const PYRAMID_ICON: &[u8] = include_bytes!("../../../assets/icons/pyramid3d.svg"); -const WEDGE_ICON: &[u8] = include_bytes!("../../../assets/icons/wedge3d.svg"); -const TORUS_ICON: &[u8] = include_bytes!("../../../assets/icons/torus3d.svg"); -const POLYSOLID_ICON: &[u8] = include_bytes!("../../../assets/icons/polysolid.svg"); -const EXTRUDE_ICON: &[u8] = include_bytes!("../../../assets/icons/extrude.svg"); -const REVOLVE_ICON: &[u8] = include_bytes!("../../../assets/icons/revolve.svg"); -const LOFT_ICON: &[u8] = include_bytes!("../../../assets/icons/loft.svg"); -const SWEEP_ICON: &[u8] = include_bytes!("../../../assets/icons/sweep.svg"); -const PRESSPULL_ICON: &[u8] = include_bytes!("../../../assets/icons/presspull.svg"); -const UNION_ICON: &[u8] = include_bytes!("../../../assets/icons/union.svg"); -const SUBTRACT_ICON: &[u8] = include_bytes!("../../../assets/icons/subtract.svg"); -const INTERSECT_ICON: &[u8] = include_bytes!("../../../assets/icons/intersect.svg"); -const FILLET_ICON: &[u8] = include_bytes!("../../../assets/icons/fillet.svg"); -const CHAMFER_ICON: &[u8] = include_bytes!("../../../assets/icons/chamfer.svg"); +const BOX_ICON: &[u8] = include_bytes!("../../../assets/icons/model/box.svg"); +const CYLINDER_ICON: &[u8] = include_bytes!("../../../assets/icons/model/cylinder.svg"); +const CONE_ICON: &[u8] = include_bytes!("../../../assets/icons/model/cone.svg"); +const SPHERE_ICON: &[u8] = include_bytes!("../../../assets/icons/model/sphere.svg"); +const PYRAMID_ICON: &[u8] = include_bytes!("../../../assets/icons/model/pyramid.svg"); +const WEDGE_ICON: &[u8] = include_bytes!("../../../assets/icons/model/wedge.svg"); +const TORUS_ICON: &[u8] = include_bytes!("../../../assets/icons/model/torus.svg"); +const POLYSOLID_ICON: &[u8] = include_bytes!("../../../assets/icons/model/polysolid.svg"); +const EXTRUDE_ICON: &[u8] = include_bytes!("../../../assets/icons/model/extrude.svg"); +const REVOLVE_ICON: &[u8] = include_bytes!("../../../assets/icons/model/revolve.svg"); +const LOFT_ICON: &[u8] = include_bytes!("../../../assets/icons/model/loft.svg"); +const SWEEP_ICON: &[u8] = include_bytes!("../../../assets/icons/model/sweep.svg"); +const PRESSPULL_ICON: &[u8] = include_bytes!("../../../assets/icons/model/presspull.svg"); +const UNION_ICON: &[u8] = include_bytes!("../../../assets/icons/model/union.svg"); +const SUBTRACT_ICON: &[u8] = include_bytes!("../../../assets/icons/model/subtract.svg"); +const INTERSECT_ICON: &[u8] = include_bytes!("../../../assets/icons/model/intersect.svg"); +const FILLET_ICON: &[u8] = include_bytes!("../../../assets/icons/model/fillet.svg"); +const CHAMFER_ICON: &[u8] = include_bytes!("../../../assets/icons/model/chamfer.svg"); /// Helper to declare a ribbon tool that fires a named command. fn tool(id: &'static str, label: &'static str, icon: &'static [u8]) -> ToolDef { From 9129c85f97a5c8c4337924a1e8f84620a4814016 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 5 Sep 2026 14:52:45 +0300 Subject: [PATCH 02/24] Add Greek README translation and language links --- README.md | 5 +- docs/readme/README.ar.md | 6 +- docs/readme/README.bg.md | 6 +- docs/readme/README.cs.md | 6 +- docs/readme/README.de.md | 6 +- docs/readme/README.el.md | 210 ++++++++++++++++++++++++++++++++++++ docs/readme/README.es.md | 6 +- docs/readme/README.fi.md | 6 +- docs/readme/README.fr.md | 6 +- docs/readme/README.hi.md | 6 +- docs/readme/README.hu.md | 6 +- docs/readme/README.it.md | 6 +- docs/readme/README.ja.md | 6 +- docs/readme/README.ko.md | 6 +- docs/readme/README.nl.md | 6 +- docs/readme/README.pl.md | 6 +- docs/readme/README.pt-BR.md | 6 +- docs/readme/README.ru.md | 6 +- docs/readme/README.tr.md | 6 +- docs/readme/README.zh-CN.md | 6 +- docs/readme/README.zh-TW.md | 6 +- 21 files changed, 270 insertions(+), 59 deletions(-) create mode 100644 docs/readme/README.el.md diff --git a/README.md b/README.md index f65e23ab6..c4c255e0e 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Français · Suomi · Deutsch · + Ελληνικά · Magyar · Italiano · 日本語 · @@ -122,9 +123,9 @@ The application is ad-hoc signed but is not currently notarized by Apple. ## Languages -Open CAD Studio can follow the system language or use any of these 20 interface languages: +Open CAD Studio can follow the system language or use any of these 21 interface languages: -> Arabic · Brazilian Portuguese · Bulgarian · Czech · Dutch · English · Finnish · French · German · Hindi · Hungarian · Italian · Japanese · Korean · Polish · Russian · Simplified Chinese · Spanish · Traditional Chinese · Turkish +> Arabic · Brazilian Portuguese · Bulgarian · Czech · Dutch · English · Finnish · French · German · Greek · Hindi · Hungarian · Italian · Japanese · Korean · Polish · Russian · Simplified Chinese · Spanish · Traditional Chinese · Turkish Change the language from the application settings. The browser version also uses the browser's preferred locale when **System** is selected. diff --git a/docs/readme/README.ar.md b/docs/readme/README.ar.md index eeb10743b..80464a85a 100644 --- a/docs/readme/README.ar.md +++ b/docs/readme/README.ar.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

شعار Open CAD Studio

@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage ## اللغات -يمكن لـ Open CAD Studio اتباع لغة النظام أو استخدام إحدى لغات الواجهة العشرين التالية: +يمكن لـ Open CAD Studio اتباع لغة النظام أو استخدام إحدى لغات الواجهة التالية البالغ عددها 21 لغة: -> العربية · البرتغالية البرازيلية · البلغارية · التشيكية · الهولندية · الإنجليزية · الفنلندية · الفرنسية · الألمانية · الهندية · المجرية · الإيطالية · اليابانية · الكورية · البولندية · الروسية · الصينية المبسطة · الإسبانية · الصينية التقليدية · التركية +> العربية · البرتغالية البرازيلية · البلغارية · التشيكية · الهولندية · الإنجليزية · الفنلندية · الفرنسية · الألمانية · اليونانية · الهندية · المجرية · الإيطالية · اليابانية · الكورية · البولندية · الروسية · الصينية المبسطة · الإسبانية · الصينية التقليدية · التركية غيّر اللغة من إعدادات التطبيق. عند اختيار **النظام**، يستخدم إصدار المتصفح أيضاً الإعدادات المحلية المفضلة للمتصفح. diff --git a/docs/readme/README.bg.md b/docs/readme/README.bg.md index 975f642b0..ddc65ecb6 100644 --- a/docs/readme/README.bg.md +++ b/docs/readme/README.bg.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Лого на Open CAD Studio

@@ -93,9 +93,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage ## Езици -Open CAD Studio може да следва системния език или да използва един от следните 20 езика на интерфейса: +Open CAD Studio може да следва системния език или да използва един от следните 21 езика на интерфейса: -> Арабски · Бразилски португалски · Български · Чешки · Нидерландски · Английски · Фински · Френски · Немски · Хинди · Унгарски · Италиански · Японски · Корейски · Полски · Руски · Опростен китайски · Испански · Традиционен китайски · Турски +> Арабски · Бразилски португалски · Български · Чешки · Нидерландски · Английски · Фински · Френски · Немски · Гръцки · Хинди · Унгарски · Италиански · Японски · Корейски · Полски · Руски · Опростен китайски · Испански · Традиционен китайски · Турски Променете езика от настройките на приложението. Когато е избрано **Системен език**, браузърната версия също използва предпочитания локал на браузъра. diff --git a/docs/readme/README.cs.md b/docs/readme/README.cs.md index be39bd983..819c3c212 100644 --- a/docs/readme/README.cs.md +++ b/docs/readme/README.cs.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Logo Open CAD Studio

@@ -91,9 +91,9 @@ Aplikace je podepsána ad hoc, ale v současnosti není notářsky ověřena spo ## Jazyky -Open CAD Studio může používat jazyk systému nebo jeden z těchto 20 jazyků rozhraní: +Open CAD Studio může používat jazyk systému nebo jeden z těchto 21 jazyků rozhraní: -> Arabština · Brazilská portugalština · Bulharština · Čeština · Nizozemština · Angličtina · Finština · Francouzština · Němčina · Hindština · Maďarština · Italština · Japonština · Korejština · Polština · Ruština · Zjednodušená čínština · Španělština · Tradiční čínština · Turečtina +> Arabština · Brazilská portugalština · Bulharština · Čeština · Nizozemština · Angličtina · Finština · Francouzština · Němčina · Řečtina · Hindština · Maďarština · Italština · Japonština · Korejština · Polština · Ruština · Zjednodušená čínština · Španělština · Tradiční čínština · Turečtina Jazyk změníte v nastavení aplikace. Pokud je vybrána možnost **Systém**, webová verze používá také preferované národní prostředí prohlížeče. diff --git a/docs/readme/README.de.md b/docs/readme/README.de.md index 4c7d8a979..f6c1b1818 100644 --- a/docs/readme/README.de.md +++ b/docs/readme/README.de.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Open-CAD-Studio-Logo

@@ -91,9 +91,9 @@ Die Anwendung ist ad hoc signiert, wird derzeit aber nicht von Apple notarisiert ## Sprachen -Open CAD Studio kann der Systemsprache folgen oder eine dieser 20 Oberflächensprachen verwenden: +Open CAD Studio kann der Systemsprache folgen oder eine dieser 21 Oberflächensprachen verwenden: -> Arabisch · Brasilianisches Portugiesisch · Bulgarisch · Tschechisch · Niederländisch · Englisch · Finnisch · Französisch · Deutsch · Hindi · Ungarisch · Italienisch · Japanisch · Koreanisch · Polnisch · Russisch · Vereinfachtes Chinesisch · Spanisch · Traditionelles Chinesisch · Türkisch +> Arabisch · Brasilianisches Portugiesisch · Bulgarisch · Tschechisch · Niederländisch · Englisch · Finnisch · Französisch · Deutsch · Griechisch · Hindi · Ungarisch · Italienisch · Japanisch · Koreanisch · Polnisch · Russisch · Vereinfachtes Chinesisch · Spanisch · Traditionelles Chinesisch · Türkisch Die Sprache lässt sich in den Anwendungseinstellungen ändern. Die Browserversion verwendet bei Auswahl von **System** ebenfalls das bevorzugte Gebietsschema des Browsers. diff --git a/docs/readme/README.el.md b/docs/readme/README.el.md new file mode 100644 index 000000000..db0381d38 --- /dev/null +++ b/docs/readme/README.el.md @@ -0,0 +1,210 @@ +

+ English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية +

+ +

Λογότυπο του Open CAD Studio

+ +

Open CAD Studio

+ +

Εφαρμογή ανοιχτού κώδικα για δισδιάστατη σχεδίαση και τρισδιάστατη μοντελοποίηση, για υπολογιστές και τον ιστό, γραμμένη σε Rust.

+ +

+ Τελευταία έκδοση + Λήψεις εκδόσεων + Αστέρια στο GitHub + Άδεια GPL-3.0 +

+ +

+ Άνοιγμα της διαδικτυακής εφαρμογής · + Λήψη της εφαρμογής για υπολογιστές · + Συμμετοχή στη συζήτηση +

+ +

Χώρος εργασίας του Open CAD Studio

+ +## Επισκόπηση + +Το Open CAD Studio είναι μια εφαρμογή για τεχνική σχεδίαση, διάταξη φύλλων και μοντελοποίηση στερεών, η οποία λειτουργεί σε διαφορετικές πλατφόρμες. Διαβάζει και γράφει απευθείας σχέδια DWG και DXF, με κοινό πυρήνα επεξεργασίας στις εκδόσεις για υπολογιστές και προγράμματα περιήγησης. + +Το έργο βρίσκεται υπό ενεργή ανάπτυξη. Διατηρείτε αντίγραφα ασφαλείας των σημαντικών σχεδίων σας και αναφέρετε προβλήματα που μπορούν να αναπαραχθούν μέσω των [GitHub Issues](https://github.com/HakanSeven12/OpenCADStudio/issues). + +## Κύρια χαρακτηριστικά + +- **Άμεση επεξεργασία σχεδίων** — άνοιγμα, επεξεργασία, ανάκτηση και αποθήκευση αρχείων DWG και DXF χωρίς υπηρεσία μετατροπής. +- **Ακριβής δισδιάστατη σχεδίαση** — γραμμές, πολυγραμμές, καμπύλες, splines, διαγραμμίσεις, έλξεις αντικειμένων, παρακολούθηση, στρώσεις, μπλοκ και εξωτερικές αναφορές. +- **Εργαλεία τεκμηρίωσης** — κείμενο, διαστάσεις, γραμμές υπόδειξης, ανοχές, πίνακες, χώρος μοντέλου, χώρος χαρτιού, παράθυρα προβολής και στυλ εκτύπωσης. +- **Τρισδιάστατη μοντελοποίηση με γεωμετρικό πυρήνα** — βασικά στερεά, εξώθηση, περιστροφή, σάρωση, loft, πράξεις Boolean και ψηφίδωση οντοτήτων ACIS. +- **Απόδοση μέσω GPU** — επιταχυνόμενες δισδιάστατες και τρισδιάστατες προβολές μέσω του `wgpu`, με ορθογραφικές και προοπτικές κάμερες. +- **Επεκτάσιμες ροές εργασίας** — εγγενή πρόσθετα, δέσμες εντολών, μετατροπή χωρίς γραφικό περιβάλλον και API αυτοματισμού JSON με ένα αντικείμενο ανά γραμμή. + +

Τρισδιάστατο μοντέλο στο Open CAD Studio

+ +## Μορφές αρχείων και εργασίες + +| Μορφή ή εργασία | Υποστήριξη | +| --- | --- | +| DWG | Ανάγνωση και εγγραφή· αποθήκευση σε εκδόσεις από R14 έως 2018 | +| DXF | Ανάγνωση και εγγραφή· αποθήκευση σε εκδόσεις από R14 έως 2018 | +| BAK / SV$ | Άνοιγμα αντιγράφων ασφαλείας σχεδίων και αρχείων αυτόματης αποθήκευσης | +| OBJ | Εισαγωγή πολυγωνικών πλεγμάτων | +| LandXML | Εισαγωγή τοπογραφικών σημείων `CgPoint` | +| STL | Εξαγωγή δεδομένων τρισδιάστατου πλέγματος | +| STEP AP203 | Εξαγωγή δεδομένων τρισδιάστατου πλέγματος | +| PDF | Εκτύπωση διατάξεων και επιλεγμένης γεωμετρίας στην εφαρμογή για υπολογιστές | +| CSV | Εξαγωγή δεδομένων ιδιοτήτων οντοτήτων | +| CTB / STB | Φόρτωση και επεξεργασία πινάκων στυλ εκτύπωσης | + +## Υπολογιστής ή ιστός + +Χρησιμοποιήστε τη [διαδικτυακή εφαρμογή](https://www.opencadstudio.com) για άμεση πρόσβαση χωρίς εγκατάσταση. Τα σχέδια επιλέγονται μέσω του προγράμματος περιήγησης και αποθηκεύονται ως τοπικές λήψεις. + +Χρησιμοποιήστε την εφαρμογή για υπολογιστές για συσχετίσεις αρχείων, μικρογραφίες στη διαχείριση αρχείων, εκτύπωση μέσω του συστήματος, εξαγωγή PDF, εξωτερικά πρόσθετα, δέσμες εντολών και αυτοματισμό χωρίς γραφικό περιβάλλον. Διατίθενται εκδόσεις για Windows, Linux και macOS με Apple Silicon. + +## Εγκατάσταση + +Κατεβάστε όλα τα τρέχοντα πακέτα από την [τελευταία έκδοση](https://github.com/HakanSeven12/OpenCADStudio/releases/latest). + +### Windows + +Επιλέξτε ένα από τα υπογεγραμμένα πακέτα x86-64: + +- `OpenCADStudio-*-windows-x86_64-installer.msi` — το προτεινόμενο πρόγραμμα εγκατάστασης, με συντομεύσεις στο μενού Έναρξη, συσχετίσεις αρχείων DWG/DXF και μικρογραφίες σχεδίων. +- `OpenCADStudio-*-windows-x86_64-portable.exe` — αυτόνομη εφαρμογή που δεν απαιτεί εγκατάσταση. + +### Linux + +Κατεβάστε το AppImage για x86-64, κάντε το εκτελέσιμο και εκκινήστε το: + +```bash +chmod +x OpenCADStudio-*-linux-x86_64.AppImage +./OpenCADStudio-*-linux-x86_64.AppImage +``` + +### macOS + +Το διαθέσιμο πακέτο για macOS υποστηρίζει Apple Silicon: + +1. Κατεβάστε το `OpenCADStudio-*-macos-arm64.dmg`. +2. Ανοίξτε την εικόνα δίσκου και σύρετε το `OpenCADStudio.app` στον φάκελο **Εφαρμογές (Applications)**. +3. Αν το Gatekeeper εμποδίσει την πρώτη εκκίνηση, εγκρίνετε την εφαρμογή από τις **Ρυθμίσεις συστήματος → Απόρρητο και ασφάλεια (System Settings → Privacy & Security)**. + +Η εφαρμογή διαθέτει υπογραφή ad-hoc, αλλά δεν έχει ακόμη επικυρωθεί μέσω της διαδικασίας notarization της Apple. + +## Γλώσσες + +Το Open CAD Studio μπορεί να ακολουθεί τη γλώσσα του συστήματος ή να χρησιμοποιεί μία από τις ακόλουθες 21 γλώσσες περιβάλλοντος: + +> Αραβικά · Πορτογαλικά Βραζιλίας · Βουλγαρικά · Τσεχικά · Ολλανδικά · Αγγλικά · Φινλανδικά · Γαλλικά · Γερμανικά · Ελληνικά · Χίντι · Ουγγρικά · Ιταλικά · Ιαπωνικά · Κορεατικά · Πολωνικά · Ρωσικά · Απλοποιημένα κινεζικά · Ισπανικά · Παραδοσιακά κινεζικά · Τουρκικά + +Αλλάξτε τη γλώσσα από τις ρυθμίσεις της εφαρμογής. Όταν είναι επιλεγμένη η **Γλώσσα συστήματος**, η διαδικτυακή έκδοση χρησιμοποιεί επίσης την προτιμώμενη γλώσσα του προγράμματος περιήγησης. + +## Μεταγλώττιση από τον πηγαίο κώδικα + +### Εφαρμογή για υπολογιστές + +Απαιτήσεις: + +- Git +- Τρέχουσα σταθερή έκδοση της εργαλειοθήκης Rust +- Βιβλιοθήκες ανάπτυξης γραφικών και γραμματοσειρών για την πλατφόρμα σας + +Σε Ubuntu ή Debian, εγκαταστήστε τις εξαρτήσεις με: + +```bash +sudo apt update +sudo apt install libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev \ + libxrandr-dev libxkbcommon-dev libwayland-dev libfontconfig1-dev \ + libfreetype6-dev +``` + +Στη συνέχεια, μεταγλωττίστε την εφαρμογή: + +```bash +git clone https://github.com/HakanSeven12/OpenCADStudio.git +cd OpenCADStudio +cargo build --release --bin OpenCADStudio +``` + +Το εκτελέσιμο αρχείο δημιουργείται στη θέση `target/release/OpenCADStudio` (`OpenCADStudio.exe` στα Windows). + +### Διαδικτυακή εφαρμογή + +Εγκαταστήστε μία φορά τον στόχο WebAssembly και τα εργαλεία μεταγλώττισης: + +```bash +rustup target add wasm32-unknown-unknown +cargo install trunk wasm-bindgen-cli +``` + +Εκκινήστε τον διακομιστή ανάπτυξης: + +```bash +trunk serve +``` + +## Αυτοματισμός + +Το εκτελέσιμο για υπολογιστές υποστηρίζει μεμονωμένες μετατροπές, έναν μόνιμο διακομιστή χωρίς γραφικό περιβάλλον και ένα σημείο σύνδεσης MCP για εφαρμογές τεχνητής νοημοσύνης, ανεξάρτητα από τον πελάτη: + +```bash +OpenCADStudio --export input.dwg output.dxf +OpenCADStudio --serve +OpenCADStudio --serve --port 4242 +OpenCADStudio --mcp +``` + +Ο διακομιστής αυτοματισμού ανταλλάσσει ένα αντικείμενο JSON ανά γραμμή μέσω της τυπικής εισόδου/εξόδου ή μιας τοπικής υποδοχής TCP. Το αυτοτελές σημείο σύνδεσης MCP παρέχει πρόσβαση στον ενεργό επεξεργαστή της εφαρμογής μέσω των ίδιων εργαλείων σε κάθε συμβατό πελάτη. Για τη σύνδεση, ρυθμίστε τον πελάτη ώστε να εκκινεί το `OpenCADStudio --mcp`. Δείτε τον [οδηγό ελέγχου MCP](../automation/README.md). + +## Πρόσθετα + +Τα πρόσθετα για υπολογιστές εκτελούνται σε ξεχωριστές διεργασίες και επικοινωνούν με την κύρια εφαρμογή μέσω του API προσθέτων με καθορισμένη έκδοση. Η διαδικτυακή έκδοση δεν φορτώνει εγγενή πρόσθετα. + +- [Αρχιτεκτονική προσθέτων](../plugin-architecture.md) +- [Πρότυπο προσθέτου](../plugin-template/README.md) +- [Μητρώο προσθέτων](../../plugins/README.md) + +## Τεκμηρίωση του έργου + +- [API αυτοματισμού](../automation/README.md) +- [Αρχιτεκτονική προσθέτων](../plugin-architecture.md) +- [Στάδια ψηφίδωσης](../tessellation.md) +- [Πολιτική ασφάλειας](../../SECURITY.md) + +## Συνεισφορά + +Είναι ευπρόσδεκτες οι αναφορές σφαλμάτων, τα pull requests με συγκεκριμένο αντικείμενο, οι μεταφράσεις, οι βελτιώσεις στην τεκμηρίωση και οι συνεισφορές προσθέτων. + +- Αναζητήστε στα υπάρχοντα [issues](https://github.com/HakanSeven12/OpenCADStudio/issues) πριν υποβάλετε νέα αναφορά. +- Χρησιμοποιήστε τα [Discussions](https://github.com/HakanSeven12/OpenCADStudio/discussions) για ερωτήσεις και ιδέες. +- Αναφέρετε ιδιωτικά τυχόν ευπάθειες, ακολουθώντας την [πολιτική ασφάλειας](../../SECURITY.md). + +## Ανάπτυξη του έργου + +### Αστέρια + + + + + + Ιστορικό αστεριών του Open CAD Studio + + + +### Λήψεις εκδόσεων + + + + + + Ιστορικό λήψεων εκδόσεων του Open CAD Studio + + + +## Υποστήριξη του έργου + +Αν το Open CAD Studio σας βοηθά στην εργασία σας, υποστηρίξτε τη συνεχή ανάπτυξή του μέσω του [GitHub Sponsors](https://github.com/sponsors/HakanSeven12) ή του [Patreon](https://www.patreon.com/HakanSeven12). + +## Άδεια χρήσης + +Το Open CAD Studio διανέμεται υπό τη [Γενική Άδεια Δημόσιας Χρήσης GNU v3.0](../../LICENSE). diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 2d0e937bc..f210a2b72 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Logotipo de Open CAD Studio

@@ -91,9 +91,9 @@ La aplicación tiene firma ad hoc, pero actualmente no está notarizada por Appl ## Idiomas -Open CAD Studio puede seguir el idioma del sistema o usar cualquiera de estos 20 idiomas de interfaz: +Open CAD Studio puede seguir el idioma del sistema o usar cualquiera de estos 21 idiomas de interfaz: -> Árabe · Portugués de Brasil · Búlgaro · Checo · Neerlandés · Inglés · Finés · Francés · Alemán · Hindi · Húngaro · Italiano · Japonés · Coreano · Polaco · Ruso · Chino simplificado · Español · Chino tradicional · Turco +> Árabe · Portugués de Brasil · Búlgaro · Checo · Neerlandés · Inglés · Finés · Francés · Alemán · Griego · Hindi · Húngaro · Italiano · Japonés · Coreano · Polaco · Ruso · Chino simplificado · Español · Chino tradicional · Turco Cambia el idioma en los ajustes de la aplicación. La versión web también usa la configuración regional preferida del navegador cuando se selecciona **Sistema**. diff --git a/docs/readme/README.fi.md b/docs/readme/README.fi.md index b4badeb71..bab3fb45a 100644 --- a/docs/readme/README.fi.md +++ b/docs/readme/README.fi.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Open CAD Studion logo

@@ -91,9 +91,9 @@ Sovellus on allekirjoitettu ad hoc -allekirjoituksella, mutta Apple ei ole täll ## Kielet -Open CAD Studio voi seurata järjestelmän kieltä tai käyttää jotakin näistä 20 käyttöliittymäkielestä: +Open CAD Studio voi seurata järjestelmän kieltä tai käyttää jotakin näistä 21 käyttöliittymäkielestä: -> Arabia · Brasilianportugali · Bulgaria · Tšekki · Hollanti · Englanti · Suomi · Ranska · Saksa · Hindi · Unkari · Italia · Japani · Korea · Puola · Venäjä · Yksinkertaistettu kiina · Espanja · Perinteinen kiina · Turkki +> Arabia · Brasilianportugali · Bulgaria · Tšekki · Hollanti · Englanti · Suomi · Ranska · Saksa · Kreikka · Hindi · Unkari · Italia · Japani · Korea · Puola · Venäjä · Yksinkertaistettu kiina · Espanja · Perinteinen kiina · Turkki Vaihda kieli sovelluksen asetuksissa. Selainversio käyttää myös selaimen ensisijaista kielialuetta, kun **Järjestelmä** on valittu. diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index 2371fec90..e0085596c 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Logo Open CAD Studio

@@ -91,9 +91,9 @@ L’application est signée de façon ad hoc, mais n’est actuellement pas nota ## Langues -Open CAD Studio peut suivre la langue du système ou utiliser l’une de ces 20 langues d’interface : +Open CAD Studio peut suivre la langue du système ou utiliser l’une de ces 21 langues d’interface : -> Arabe · Portugais du Brésil · Bulgare · Tchèque · Néerlandais · Anglais · Finnois · Français · Allemand · Hindi · Hongrois · Italien · Japonais · Coréen · Polonais · Russe · Chinois simplifié · Espagnol · Chinois traditionnel · Turc +> Arabe · Portugais du Brésil · Bulgare · Tchèque · Néerlandais · Anglais · Finnois · Français · Allemand · Grec · Hindi · Hongrois · Italien · Japonais · Coréen · Polonais · Russe · Chinois simplifié · Espagnol · Chinois traditionnel · Turc Changez la langue dans les paramètres de l’application. La version web utilise aussi la langue préférée du navigateur lorsque **Système** est sélectionné. diff --git a/docs/readme/README.hi.md b/docs/readme/README.hi.md index 67d7cd94f..253cc155e 100644 --- a/docs/readme/README.hi.md +++ b/docs/readme/README.hi.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Open CAD Studio लोगो

@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage ## भाषाएँ -Open CAD Studio सिस्टम भाषा का अनुसरण कर सकता है या इन 20 इंटरफ़ेस भाषाओं में से किसी एक का उपयोग कर सकता है: +Open CAD Studio सिस्टम भाषा का अनुसरण कर सकता है या इन 21 इंटरफ़ेस भाषाओं में से किसी एक का उपयोग कर सकता है: -> अरबी · ब्राज़ीलियाई पुर्तगाली · बुल्गारियाई · चेक · डच · अंग्रेज़ी · फ़िनिश · फ़्रेंच · जर्मन · हिन्दी · हंगेरियन · इतालवी · जापानी · कोरियाई · पोलिश · रूसी · सरलीकृत चीनी · स्पैनिश · पारंपरिक चीनी · तुर्की +> अरबी · ब्राज़ीलियाई पुर्तगाली · बुल्गारियाई · चेक · डच · अंग्रेज़ी · फ़िनिश · फ़्रेंच · जर्मन · यूनानी · हिन्दी · हंगेरियन · इतालवी · जापानी · कोरियाई · पोलिश · रूसी · सरलीकृत चीनी · स्पैनिश · पारंपरिक चीनी · तुर्की अनुप्रयोग सेटिंग में भाषा बदलें। **सिस्टम** चुने जाने पर ब्राउज़र संस्करण भी ब्राउज़र की पसंदीदा लोकेल का उपयोग करता है। diff --git a/docs/readme/README.hu.md b/docs/readme/README.hu.md index 655315564..d2e2d94f0 100644 --- a/docs/readme/README.hu.md +++ b/docs/readme/README.hu.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Open CAD Studio logó

@@ -91,9 +91,9 @@ Az alkalmazás ad hoc aláírással rendelkezik, de az Apple jelenleg nem hitele ## Nyelvek -Az Open CAD Studio követheti a rendszer nyelvét, vagy használhatja az alábbi 20 felületi nyelv egyikét: +Az Open CAD Studio követheti a rendszer nyelvét, vagy használhatja az alábbi 21 felületi nyelv egyikét: -> Arab · Brazil portugál · Bolgár · Cseh · Holland · Angol · Finn · Francia · Német · Hindi · Magyar · Olasz · Japán · Koreai · Lengyel · Orosz · Egyszerűsített kínai · Spanyol · Hagyományos kínai · Török +> Arab · Brazil portugál · Bolgár · Cseh · Holland · Angol · Finn · Francia · Német · Görög · Hindi · Magyar · Olasz · Japán · Koreai · Lengyel · Orosz · Egyszerűsített kínai · Spanyol · Hagyományos kínai · Török A nyelv az alkalmazás beállításaiban módosítható. **Rendszer** választásakor a böngészős változat is a böngésző előnyben részesített területi beállítását használja. diff --git a/docs/readme/README.it.md b/docs/readme/README.it.md index e342cf765..45721e569 100644 --- a/docs/readme/README.it.md +++ b/docs/readme/README.it.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Logo di Open CAD Studio

@@ -91,9 +91,9 @@ L’applicazione è firmata ad hoc, ma attualmente non è autenticata da Apple. ## Lingue -Open CAD Studio può seguire la lingua di sistema o usare una di queste 20 lingue dell’interfaccia: +Open CAD Studio può seguire la lingua di sistema o usare una di queste 21 lingue dell’interfaccia: -> Arabo · Portoghese brasiliano · Bulgaro · Ceco · Olandese · Inglese · Finlandese · Francese · Tedesco · Hindi · Ungherese · Italiano · Giapponese · Coreano · Polacco · Russo · Cinese semplificato · Spagnolo · Cinese tradizionale · Turco +> Arabo · Portoghese brasiliano · Bulgaro · Ceco · Olandese · Inglese · Finlandese · Francese · Tedesco · Greco · Hindi · Ungherese · Italiano · Giapponese · Coreano · Polacco · Russo · Cinese semplificato · Spagnolo · Cinese tradizionale · Turco Cambia la lingua nelle impostazioni dell’applicazione. La versione browser usa anche la lingua preferita del browser quando è selezionato **Sistema**. diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index 2fbba316a..bfee5811d 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Open CAD Studio ロゴ

@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage ## 言語 -Open CAD Studio はシステム言語に従うか、次の 20 のインターフェース言語を使用できます。 +Open CAD Studio はシステム言語に従うか、次の 21 のインターフェース言語を使用できます。 -> アラビア語 · ブラジルポルトガル語 · ブルガリア語 · チェコ語 · オランダ語 · 英語 · フィンランド語 · フランス語 · ドイツ語 · ヒンディー語 · ハンガリー語 · イタリア語 · 日本語 · 韓国語 · ポーランド語 · ロシア語 · 簡体字中国語 · スペイン語 · 繁体字中国語 · トルコ語 +> アラビア語 · ブラジルポルトガル語 · ブルガリア語 · チェコ語 · オランダ語 · 英語 · フィンランド語 · フランス語 · ドイツ語 · ギリシャ語 · ヒンディー語 · ハンガリー語 · イタリア語 · 日本語 · 韓国語 · ポーランド語 · ロシア語 · 簡体字中国語 · スペイン語 · 繁体字中国語 · トルコ語 言語はアプリケーション設定から変更できます。**システム** を選ぶと、ブラウザ版もブラウザの優先ロケールを使用します。 diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index ded42890c..e6da6dd7c 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Open CAD Studio 로고

@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage ## 언어 -Open CAD Studio는 시스템 언어를 따르거나 다음 19개 인터페이스 언어 중 하나를 사용할 수 있습니다. +Open CAD Studio는 시스템 언어를 따르거나 다음 21개 인터페이스 언어 중 하나를 사용할 수 있습니다. -> 아랍어 · 브라질 포르투갈어 · 불가리아어 · 체코어 · 네덜란드어 · 영어 · 핀란드어 · 프랑스어 · 독일어 · 힌디어 · 헝가리어 · 이탈리아어 · 일본어 · 한국어 · 폴란드어 · 러시아어 · 중국어 간체 · 스페인어 · 중국어 번체 · 터키어 +> 아랍어 · 브라질 포르투갈어 · 불가리아어 · 체코어 · 네덜란드어 · 영어 · 핀란드어 · 프랑스어 · 독일어 · 그리스어 · 힌디어 · 헝가리어 · 이탈리아어 · 일본어 · 한국어 · 폴란드어 · 러시아어 · 중국어 간체 · 스페인어 · 중국어 번체 · 터키어 애플리케이션 설정에서 언어를 변경할 수 있습니다. **시스템**을 선택하면 브라우저 버전도 브라우저의 기본 로캘을 사용합니다. diff --git a/docs/readme/README.nl.md b/docs/readme/README.nl.md index 7d2e4156d..27b73f256 100644 --- a/docs/readme/README.nl.md +++ b/docs/readme/README.nl.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Logo van Open CAD Studio

@@ -91,9 +91,9 @@ De toepassing is ad hoc ondertekend, maar momenteel niet door Apple genotariseer ## Talen -Open CAD Studio kan de systeemtaal volgen of een van deze 20 interfacetalen gebruiken: +Open CAD Studio kan de systeemtaal volgen of een van deze 21 interfacetalen gebruiken: -> Arabisch · Braziliaans-Portugees · Bulgaars · Tsjechisch · Nederlands · Engels · Fins · Frans · Duits · Hindi · Hongaars · Italiaans · Japans · Koreaans · Pools · Russisch · Vereenvoudigd Chinees · Spaans · Traditioneel Chinees · Turks +> Arabisch · Braziliaans-Portugees · Bulgaars · Tsjechisch · Nederlands · Engels · Fins · Frans · Duits · Grieks · Hindi · Hongaars · Italiaans · Japans · Koreaans · Pools · Russisch · Vereenvoudigd Chinees · Spaans · Traditioneel Chinees · Turks Wijzig de taal in de toepassingsinstellingen. De browserversie gebruikt ook de voorkeurstaal van de browser wanneer **Systeem** is geselecteerd. diff --git a/docs/readme/README.pl.md b/docs/readme/README.pl.md index 680881a89..c41814eb9 100644 --- a/docs/readme/README.pl.md +++ b/docs/readme/README.pl.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Logo Open CAD Studio

@@ -91,9 +91,9 @@ Aplikacja jest podpisana ad hoc, ale obecnie nie jest notaryzowana przez Apple. ## Języki -Open CAD Studio może korzystać z języka systemu lub jednego z 20 języków interfejsu: +Open CAD Studio może korzystać z języka systemu lub jednego z 21 języków interfejsu: -> Arabski · Portugalski brazylijski · Bułgarski · Czeski · Niderlandzki · Angielski · Fiński · Francuski · Niemiecki · Hindi · Węgierski · Włoski · Japoński · Koreański · Polski · Rosyjski · Chiński uproszczony · Hiszpański · Chiński tradycyjny · Turecki +> Arabski · Portugalski brazylijski · Bułgarski · Czeski · Niderlandzki · Angielski · Fiński · Francuski · Niemiecki · Grecki · Hindi · Węgierski · Włoski · Japoński · Koreański · Polski · Rosyjski · Chiński uproszczony · Hiszpański · Chiński tradycyjny · Turecki Język zmienisz w ustawieniach aplikacji. Gdy wybrano **System**, wersja przeglądarkowa używa także preferowanych ustawień regionalnych przeglądarki. diff --git a/docs/readme/README.pt-BR.md b/docs/readme/README.pt-BR.md index bb7824322..8950a211f 100644 --- a/docs/readme/README.pt-BR.md +++ b/docs/readme/README.pt-BR.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Logotipo do Open CAD Studio

@@ -91,9 +91,9 @@ O aplicativo possui assinatura ad hoc, mas atualmente não é notarizado pela Ap ## Idiomas -Open CAD Studio pode seguir o idioma do sistema ou usar um destes 20 idiomas de interface: +Open CAD Studio pode seguir o idioma do sistema ou usar um destes 21 idiomas de interface: -> Árabe · Português do Brasil · Búlgaro · Tcheco · Holandês · Inglês · Finlandês · Francês · Alemão · Hindi · Húngaro · Italiano · Japonês · Coreano · Polonês · Russo · Chinês simplificado · Espanhol · Chinês tradicional · Turco +> Árabe · Português do Brasil · Búlgaro · Tcheco · Holandês · Inglês · Finlandês · Francês · Alemão · Grego · Hindi · Húngaro · Italiano · Japonês · Coreano · Polonês · Russo · Chinês simplificado · Espanhol · Chinês tradicional · Turco Altere o idioma nas configurações do aplicativo. A versão web também usa a localidade preferida do navegador quando **Sistema** está selecionado. diff --git a/docs/readme/README.ru.md b/docs/readme/README.ru.md index 5b2d178f7..a60d046c0 100644 --- a/docs/readme/README.ru.md +++ b/docs/readme/README.ru.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Логотип Open CAD Studio

@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage ## Языки -Open CAD Studio может следовать языку системы или использовать один из 20 языков интерфейса: +Open CAD Studio может следовать языку системы или использовать любой из следующих языков интерфейса (всего 21): -> Арабский · Бразильский португальский · Болгарский · Чешский · Нидерландский · Английский · Финский · Французский · Немецкий · Хинди · Венгерский · Итальянский · Японский · Корейский · Польский · Русский · Упрощённый китайский · Испанский · Традиционный китайский · Турецкий +> Арабский · Бразильский португальский · Болгарский · Чешский · Нидерландский · Английский · Финский · Французский · Немецкий · Греческий · Хинди · Венгерский · Итальянский · Японский · Корейский · Польский · Русский · Упрощённый китайский · Испанский · Традиционный китайский · Турецкий Язык меняется в настройках приложения. При выборе **Система** версия для браузера также использует предпочтительную локаль браузера. diff --git a/docs/readme/README.tr.md b/docs/readme/README.tr.md index 52c760b6b..04ee08115 100644 --- a/docs/readme/README.tr.md +++ b/docs/readme/README.tr.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Open CAD Studio logosu

@@ -93,9 +93,9 @@ Uygulama geçici imzaya sahiptir ancak şu anda Apple tarafından noterlenmemiş ## Diller -Open CAD Studio sistem dilini izleyebilir veya şu 20 arayüz dilinden birini kullanabilir: +Open CAD Studio sistem dilini izleyebilir veya şu 21 arayüz dilinden birini kullanabilir: -> Arapça · Brezilya Portekizcesi · Bulgarca · Çekçe · Felemenkçe · İngilizce · Fince · Fransızca · Almanca · Hintçe · Macarca · İtalyanca · Japonca · Korece · Lehçe · Rusça · Basitleştirilmiş Çince · İspanyolca · Geleneksel Çince · Türkçe +> Arapça · Brezilya Portekizcesi · Bulgarca · Çekçe · Felemenkçe · İngilizce · Fince · Fransızca · Almanca · Yunanca · Hintçe · Macarca · İtalyanca · Japonca · Korece · Lehçe · Rusça · Basitleştirilmiş Çince · İspanyolca · Geleneksel Çince · Türkçe Dili uygulama ayarlarından değiştirin. **Sistem** seçildiğinde tarayıcı sürümü de tarayıcının tercih ettiği yerel ayarı kullanır. diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 901bd350b..a561344af 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Open CAD Studio 标志

@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage ## 语言 -Open CAD Studio 可以跟随系统语言,也可以使用以下 20 种界面语言: +Open CAD Studio 可以跟随系统语言,也可以使用以下 21 种界面语言: -> 阿拉伯语 · 巴西葡萄牙语 · 保加利亚语 · 捷克语 · 荷兰语 · 英语 · 芬兰语 · 法语 · 德语 · 印地语 · 匈牙利语 · 意大利语 · 日语 · 韩语 · 波兰语 · 俄语 · 简体中文 · 西班牙语 · 繁体中文 · 土耳其语 +> 阿拉伯语 · 巴西葡萄牙语 · 保加利亚语 · 捷克语 · 荷兰语 · 英语 · 芬兰语 · 法语 · 德语 · 希腊语 · 印地语 · 匈牙利语 · 意大利语 · 日语 · 韩语 · 波兰语 · 俄语 · 简体中文 · 西班牙语 · 繁体中文 · 土耳其语 可在应用设置中更改语言。选择**系统**时,浏览器版也会使用浏览器的首选区域设置。 diff --git a/docs/readme/README.zh-TW.md b/docs/readme/README.zh-TW.md index 8d9f2f21f..e84a40497 100644 --- a/docs/readme/README.zh-TW.md +++ b/docs/readme/README.zh-TW.md @@ -1,5 +1,5 @@

- English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية + English · Български · Português (Brasil) · Čeština · Nederlands · Français · Suomi · Deutsch · Ελληνικά · Magyar · Italiano · 日本語 · 한국어 · Polski · Русский · 简体中文 · Español · 繁體中文 · Türkçe · हिन्दी · العربية

Open CAD Studio 標誌

@@ -91,9 +91,9 @@ chmod +x OpenCADStudio-*-linux-x86_64.AppImage ## 語言 -Open CAD Studio 可以跟隨系統語言,也可以使用以下 20 種介面語言: +Open CAD Studio 可以跟隨系統語言,也可以使用以下 21 種介面語言: -> 阿拉伯文 · 巴西葡萄牙文 · 保加利亞文 · 捷克文 · 荷蘭文 · 英文 · 芬蘭文 · 法文 · 德文 · 北印度文 · 匈牙利文 · 義大利文 · 日文 · 韓文 · 波蘭文 · 俄文 · 簡體中文 · 西班牙文 · 繁體中文 · 土耳其文 +> 阿拉伯文 · 巴西葡萄牙文 · 保加利亞文 · 捷克文 · 荷蘭文 · 英文 · 芬蘭文 · 法文 · 德文 · 希臘文 · 北印度文 · 匈牙利文 · 義大利文 · 日文 · 韓文 · 波蘭文 · 俄文 · 簡體中文 · 西班牙文 · 繁體中文 · 土耳其文 可在應用程式設定中變更語言。選擇**系統**時,瀏覽器版也會使用瀏覽器的偏好地區設定。 From 826a437dfcadb6720209d897c90c6a2e0b59e8e3 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 5 Sep 2026 17:14:08 +0300 Subject: [PATCH 03/24] Clarify subtract and intersect modelling icons --- assets/icons/model/intersect.svg | 17 +++++++++-------- assets/icons/model/subtract.svg | 15 +++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/assets/icons/model/intersect.svg b/assets/icons/model/intersect.svg index a0c28e880..e5c466613 100644 --- a/assets/icons/model/intersect.svg +++ b/assets/icons/model/intersect.svg @@ -1,13 +1,14 @@ - - - - - + + + + + - - - + + + + diff --git a/assets/icons/model/subtract.svg b/assets/icons/model/subtract.svg index 933b09690..f0babf9a0 100644 --- a/assets/icons/model/subtract.svg +++ b/assets/icons/model/subtract.svg @@ -1,12 +1,11 @@ - - - - - - - + + + + + + - + From 200ed541f5713e036d366a66cc0430351aba9cf3 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 5 Sep 2026 17:28:30 +0300 Subject: [PATCH 04/24] Update intersection icon artwork --- assets/icons/model/intersect.svg | 90 ++++++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 11 deletions(-) diff --git a/assets/icons/model/intersect.svg b/assets/icons/model/intersect.svg index e5c466613..0647a86b2 100644 --- a/assets/icons/model/intersect.svg +++ b/assets/icons/model/intersect.svg @@ -1,14 +1,82 @@ - - - - - - + + + + + + + + - - - - + + + + + + + - From 0e4f755e6f9097377100d674048ede6fde7f8074 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 5 Sep 2026 18:22:05 +0300 Subject: [PATCH 05/24] Make native MCP self-describing --- docs/automation/README.md | 21 +++- docs/automation/mcp_smoke.py | 30 ++++- src/app/control/mod.rs | 149 +++++++++++++++++++++-- src/app/mod.rs | 3 + src/mcp.rs | 221 ++++++++++++++++++++++++++++++++++- 5 files changed, 407 insertions(+), 17 deletions(-) diff --git a/docs/automation/README.md b/docs/automation/README.md index e59e070f7..18775e0ae 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -31,9 +31,28 @@ The normal flow is to call `ocs_sessions`, read the chosen session with `ocs_rea } ``` +To discover command syntax without reading application source, call `ocs_read` with `op: "commands"`. The unfiltered response lists commands and actions. Add `parameters: {"name": "PLINE"}` for a command's batch examples and interactive guidance. + +A complete command can be sent with `run`. Prompt answers are separated by spaces, points use `x,y` or `x,y,z`, and option answers use their displayed token: + +```json +{ + "session_id": "SESSION_FROM_OCS_SESSIONS", + "request": { + "op": "run", + "request_id": "polyline-1", + "document_id": 1, + "revision": 12, + "cmd": "PLINE 0,0 10,0 10,10 C" + } +} +``` + +For unfamiliar or conditional commands, use `start`, then inspect `state.command` in every response. Its `accepts` array gives the valid MCP input kinds, `options` gives the current tokens, and `input_example` gives the next request shape. Add the current state fields and a new `request_id` to each step. + Every `ocs_execute` request requires a caller-generated `request_id`. Reuse that ID only to retry the identical request after a timeout, together with the same session ID, document ID, expected revision, and selection. Commands report `waiting_input` while more input is required and asynchronous work remains `running` until its real callback finishes. Geometry stays in OpenCADStudio and its geometry kernel. -The source-tree protocol smoke test uses only the Python standard library: +The source-tree protocol smoke test treats the executable as a black box and uses only the Python standard library. It checks both supported protocol styles, the published schemas, structured errors, and the four-tool surface: ```sh cargo build diff --git a/docs/automation/mcp_smoke.py b/docs/automation/mcp_smoke.py index 47ff970c4..6c651d3af 100644 --- a/docs/automation/mcp_smoke.py +++ b/docs/automation/mcp_smoke.py @@ -44,12 +44,21 @@ def legacy(server: Path) -> None: "params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "smoke", "version": "1"}}, }) assert initialized["result"]["serverInfo"]["name"] == "OpenCADStudio" - assert initialized["result"]["instructions"] + assert "state.command.accepts" in initialized["result"]["instructions"] process.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n") process.stdin.flush() tools = request(process, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) - names = {tool["name"] for tool in tools["result"]["tools"]} + definitions = {tool["name"]: tool for tool in tools["result"]["tools"]} + names = set(definitions) assert names == TOOLS, names + execute_request = definitions["ocs_execute"]["inputSchema"]["properties"]["request"] + assert len(execute_request["oneOf"]) == 14 + assert execute_request["properties"]["cmd"]["examples"][0] == "LINE 0,0 10,10" + assert execute_request["properties"]["kind"]["enum"] == [ + "text", "token", "point", "entity", "structure", "selection", "enter" + ] + for name in {"ocs_sessions", "ocs_read", "ocs_execute"}: + assert definitions[name]["outputSchema"]["type"] == "object" assert "resultType" not in tools["result"] sessions = request(process, { "jsonrpc": "2.0", @@ -89,6 +98,23 @@ def modern(server: Path) -> None: assert tools["result"]["ttlMs"] >= 0 assert tools["result"]["cacheScope"] in {"public", "private"} + missing_cmd = request(process, { + "jsonrpc": "2.0", + "id": "missing-cmd", + "method": "tools/call", + "params": { + "name": "ocs_execute", + "arguments": { + "session_id": "missing", + "request": {"op": "run", "request_id": "run-1"}, + }, + "_meta": meta, + }, + }) + error = missing_cmd["result"]["structuredContent"] + assert error["code"] == "invalid_arguments" + assert "LINE 0,0 10,10" in error["error"] + invalid_mutation = request(process, { "jsonrpc": "2.0", "id": "invalid-mutation", diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index e0c93dfc3..64efd2f7e 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -1,6 +1,6 @@ //! Semantic control shared by the GUI, headless client and web adapter. use super::{Message, OpenCADStudio}; -use crate::command::StepInput; +use crate::command::{InputKind, StepInput}; use iced::Task; use serde_json::{json, Value}; use std::{collections::VecDeque, sync::OnceLock}; @@ -166,6 +166,78 @@ fn point(req: &Value) -> Result { Ok(glam::DVec3::from_array(p)) } +fn command_examples(name: &str) -> &'static [&'static str] { + match name { + "LINE" => &["LINE 0,0 10,10"], + "CIRCLE" => &["CIRCLE 5,5 3"], + "PLINE" => &["PLINE 0,0 10,0 10,10 C"], + "ZOOM" => &["ZOOM EXTENTS"], + _ => &[], + } +} + +fn active_command_metadata(command: &dyn crate::command::CadCommand) -> Value { + let options = command.options(); + let mut accepts = Vec::new(); + if command.is_selection_gathering() { + accepts.push("selection"); + } + if command.needs_structure_point_pick() { + accepts.push("structure"); + } + if command.needs_entity_pick() { + accepts.push("entity"); + } + if accepts.is_empty() && !command.needs_tangent_pick() { + accepts.push(match command.input_kind() { + InputKind::Point => "point", + InputKind::SingleToken => "token", + InputKind::FreeText => "text", + }); + } + if options.iter().any(|option| !option.keyword.is_empty()) && !accepts.contains(&"token") { + accepts.push("token"); + } + accepts.push("enter"); + + let input_example = if accepts.contains(&"point") { + json!({"op":"input","kind":"point","point":[0,0,0],"space":"wcs"}) + } else if accepts.contains(&"entity") { + json!({"op":"input","kind":"entity","handle":"HANDLE","point":[0,0,0]}) + } else if accepts.contains(&"structure") { + json!({"op":"input","kind":"structure","handle":"HANDLE","point":[0,0,0]}) + } else if accepts.contains(&"selection") { + json!({"op":"input","kind":"selection"}) + } else if accepts.contains(&"text") { + json!({"op":"input","kind":"text","text":"value"}) + } else if accepts.contains(&"token") { + let token = options + .iter() + .find(|option| !option.keyword.is_empty()) + .map_or("VALUE", |option| option.keyword.as_str()); + json!({"op":"input","kind":"token","text":token}) + } else { + json!({"op":"input","kind":"enter"}) + }; + + json!({ + "name":command.name(), + "prompt":command.prompt(), + "accepts":accepts, + "input_example":input_example, + "options":options.iter().map(|option| json!({ + "label":option.label, + "keyword":option.keyword, + "kind":if option.keyword.is_empty(){"enter"}else{"token"} + })).collect::>(), + "entity_pick":command.needs_entity_pick(), + "structure_pick":command.needs_structure_point_pick(), + "selection":command.is_selection_gathering(), + "tangent_pick":command.needs_tangent_pick(), + "free_text":command.is_free_text_step() + }) +} + impl OpenCADStudio { pub(super) fn control_busy(&self) -> bool { self.control.pending.is_some() @@ -173,10 +245,7 @@ impl OpenCADStudio { pub(super) fn control_state(&self) -> Value { let tab = &self.tabs[self.active_tab]; - let command = tab.active_cmd.as_ref().map(|c| json!({ - "name":c.name(),"prompt":c.prompt(),"options":c.options().iter().map(|o|json!({"label":o.label,"keyword":o.keyword})).collect::>(), - "entity_pick":c.needs_entity_pick(),"structure_pick":c.needs_structure_point_pick(),"selection":c.is_selection_gathering(),"tangent_pick":c.needs_tangent_pick(),"free_text":c.is_free_text_step() - })); + let command = tab.active_cmd.as_deref().map(active_command_metadata); json!({"ok":true,"protocol":1,"session_id":session_id(),"version":env!("OCS_APP_VERSION"), "mode":if self.main_window.is_some(){"gui"}else{"headless"},"enabled":self.control.enabled, "document_id":tab.id,"revision":tab.edit_revision,"geometry_revision":tab.scene.geometry_epoch,"camera_revision":tab.scene.camera_generation, @@ -272,8 +341,45 @@ impl OpenCADStudio { names.extend(self.command_line.dynamic_commands.iter().cloned()); names.sort_unstable(); names.dedup(); + if let Some(requested) = req["name"].as_str() { + let Some(name) = names + .iter() + .find(|name| name.eq_ignore_ascii_case(requested)) + else { + return ( + failure( + "unknown_command", + format!("Unknown command {requested}; call commands without name to list commands"), + ), + Task::none(), + ); + }; + return ( + json!({ + "ok":true, + "command":{ + "name":name, + "batch":{ + "op":"run", + "syntax":"Command name followed by prompt answers separated by spaces. Points use x,y or x,y,z; options use their token.", + "examples":command_examples(name) + }, + "interactive":{ + "op":"start", + "request":{"op":"start","cmd":name}, + "next":"Follow state.command.accepts, options and input_example after every step." + } + } + }), + Task::none(), + ); + } return ( - json!({"ok":true,"commands":names,"actions":actions::NAMES}), + json!({ + "ok":true,"commands":names,"actions":actions::NAMES, + "detail_parameters":{"name":"LINE"}, + "guidance":"Request one command by name for batch examples and interactive input guidance." + }), Task::none(), ); } @@ -787,6 +893,9 @@ impl OpenCADStudio { } } mod actions; +pub(super) fn action_names() -> &'static [&'static str] { + actions::NAMES +} #[cfg(test)] mod tests { @@ -816,9 +925,15 @@ mod tests { request(&mut app, json!({"op":"new"}))["status"], "completed" ); + let started = request(&mut app, json!({"op":"start","cmd":"LINE"})); + assert_eq!(started["status"], "waiting_input"); + assert!(started["state"]["command"]["accepts"] + .as_array() + .unwrap() + .contains(&json!("point"))); assert_eq!( - request(&mut app, json!({"op":"start","cmd":"LINE"}))["status"], - "waiting_input" + started["state"]["command"]["input_example"]["kind"], + "point" ); assert_eq!( request( @@ -864,6 +979,24 @@ mod tests { assert_eq!(app.automation_op(r#"{"op":"entities"}"#)["total"], 0); } #[test] + fn command_discovery_explains_batch_and_interactive_use() { + let mut app = OpenCADStudio::new_for_test(); + let detail = app + .control_request(json!({"op":"commands","name":"pline"})) + .0; + assert_eq!(detail["command"]["name"], "PLINE"); + assert_eq!( + detail["command"]["batch"]["examples"][0], + "PLINE 0,0 10,0 10,10 C" + ); + assert_eq!(detail["command"]["interactive"]["op"], "start"); + assert_eq!( + app.control_request(json!({"op":"commands","name":"NOT_A_COMMAND"})) + .0["code"], + "unknown_command" + ); + } + #[test] fn control_retry_is_idempotent_and_stale_state_rejected() { let mut app = OpenCADStudio::new_for_test(); request(&mut app, json!({"op":"new"})); diff --git a/src/app/mod.rs b/src/app/mod.rs index eb89bc04c..563b7e0e9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,6 +1,9 @@ mod alias; mod automation; mod control; +pub(crate) fn automation_action_names() -> &'static [&'static str] { + control::action_names() +} pub(crate) mod config; #[cfg(not(target_arch = "wasm32"))] pub use automation::{export_headless, serve}; diff --git a/src/mcp.rs b/src/mcp.rs index 20bafd840..ac479b52f 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -22,7 +22,7 @@ const MODERN_PROTOCOL_VERSION: &str = "2026-07-28"; const MAX_REQUEST: usize = 1_048_576; const MAX_RESPONSE: u64 = 16 * 1024 * 1024; const CACHE_TTL_MS: u64 = 3_600_000; -const INSTRUCTIONS: &str = "Use ocs_sessions first; it opens OpenCADStudio when needed. Read state and commands before editing. Preserve session_id, document_id, revision, selection and request_id. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OpenCADStudio and its geometry kernel calculate geometry. Verify important results with queries and ocs_capture, and save only to an explicit path."; +const INSTRUCTIONS: &str = "Call ocs_sessions, then ocs_read state before editing. Preserve session_id, document_id, revision, selection and request_id. Use ocs_read commands with parameters.name for command help. For interactive work, call start and follow state.command.accepts, options and input_example. For batch work, run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OpenCADStudio and its geometry kernel calculate geometry. Verify important results with queries and ocs_capture, and save only to an explicit path."; const READ_OPS: &[&str] = &[ "state", "hello", @@ -306,6 +306,87 @@ fn required_string<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, Strin .ok_or_else(|| format!("Missing {key}")) } +fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> { + let missing = |field: &str, example: &str| { + Err(format!( + "Missing {field} for {op}. Example request: {example}" + )) + }; + match op { + "open" if request["path"].as_str().is_none_or(str::is_empty) => { + missing("path", r#"{"op":"open","path":"/path/drawing.dxf"}"#) + } + "activate" if request["document_id"].as_u64().is_none() => { + missing("document_id", r#"{"op":"activate","document_id":2}"#) + } + "run" | "start" if request["cmd"].as_str().is_none_or(str::is_empty) => missing( + "cmd", + r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#, + ), + "input" => match request["kind"].as_str() { + Some("text") => Ok(()), + Some("token") if request["text"].as_str().is_some_and(|value| !value.is_empty()) => { + Ok(()) + } + Some("token") => missing( + "text", + r#"{"op":"input","kind":"token","text":"C"}"#, + ), + Some("point") + if request["point"] + .as_array() + .is_some_and(|values| values.len() == 3 && values.iter().all(Value::is_number)) => + { + Ok(()) + } + Some("point") => missing( + "point", + r#"{"op":"input","kind":"point","point":[0,0,0],"space":"wcs"}"#, + ), + Some("entity" | "structure") + if request["handle"].as_str().is_some_and(|value| !value.is_empty()) + && request["point"] + .as_array() + .is_some_and(|values| values.len() == 3 && values.iter().all(Value::is_number)) => + { + Ok(()) + } + Some("entity" | "structure") => missing( + "handle and point", + r#"{"op":"input","kind":"entity","handle":"2A","point":[0,0,0]}"#, + ), + Some("selection" | "enter") => Ok(()), + Some(kind) => Err(format!( + "Unknown input kind {kind}. Use text, token, point, entity, structure, selection or enter" + )), + None => missing( + "kind", + r#"{"op":"input","kind":"point","point":[0,0,0]}"#, + ), + }, + "property" + if request["field"].as_str().is_none_or(str::is_empty) + || request.get("value").is_none() => + { + missing( + "field or value", + r#"{"op":"property","field":"color","value":1}"#, + ) + } + "action" => match request["name"].as_str() { + Some(name) if crate::app::automation_action_names().contains(&name) => Ok(()), + Some(name) => Err(format!( + "Unknown action {name}. Call ocs_read commands to list actions" + )), + None => missing( + "name", + r#"{"op":"action","name":"zoom_extents"}"#, + ), + }, + _ => Ok(()), + } +} + fn call_tool( name: &str, arguments: &Value, @@ -344,6 +425,7 @@ fn call_tool( if request_id.len() > 128 { return Err("request_id must not exceed 128 bytes".into()); } + validate_execute_request(&request, op)?; let wait = arguments["wait_seconds"].as_f64().unwrap_or(30.0); client(clients, session_id)?.request(request, wait) } @@ -365,24 +447,117 @@ fn call_tool( } } +fn execute_request_schema() -> Value { + let handle = json!({ + "type":"string", + "pattern":"^(0[xX])?[0-9A-Fa-f]+$", + "description":"Entity handle returned by ocs_read entities or state selection." + }); + let point = json!({ + "type":"array","items":{"type":"number"},"minItems":3,"maxItems":3, + "description":"Finite [x,y,z] coordinates." + }); + json!({ + "type":"object", + "properties":{ + "op":{"type":"string","enum":EXECUTE_OPS,"description":"Semantic editor operation."}, + "request_id":{"type":"string","minLength":1,"maxLength":128,"description":"Caller-generated idempotency key. Reuse it only when retrying the identical request."}, + "document_id":{"type":"integer","minimum":0,"description":"Target document from current state."}, + "revision":{"type":"integer","minimum":0,"description":"Expected edit revision from current state."}, + "geometry_revision":{"type":"integer","minimum":0,"description":"Expected geometry revision when geometry state matters."}, + "camera_revision":{"type":"integer","minimum":0,"description":"Expected camera revision when view state matters."}, + "selection":{"type":"array","items":handle.clone(),"description":"Expected selected handles from current state."}, + "cmd":{"type":"string","minLength":1,"description":"Command name followed by its prompt answers separated by spaces. Points use x,y or x,y,z; option answers use their token. Read command details first when unsure.","examples":["LINE 0,0 10,10","CIRCLE 5,5 3","PLINE 0,0 10,0 10,10 C"]}, + "path":{"type":"string","minLength":1,"description":"Absolute drawing path for open or save."}, + "kind":{"type":"string","enum":["text","token","point","entity","structure","selection","enter"],"description":"Input kind listed in state.command.accepts."}, + "text":{"type":"string","description":"Free text or one option/value token."}, + "point":point, + "space":{"type":"string","enum":["wcs","ucs","relative"],"default":"wcs","description":"Coordinate space for point input."}, + "handle":handle.clone(), + "handles":{"type":"array","items":handle,"description":"Entity handles to select."}, + "type":{"type":"string","description":"Entity type filter for select."}, + "layer":{"type":"string","description":"Layer filter for select."}, + "clear":{"type":"boolean","description":"Clear the current selection before applying select filters."}, + "field":{"type":"string","minLength":1,"description":"Property id returned by ocs_read properties."}, + "value":{"description":"New property value; its JSON type must match the property kind.","anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"object"},{"type":"array"},{"type":"null"}]}, + "name":{"type":"string","enum":crate::app::automation_action_names(),"description":"UI action returned by ocs_read commands."} + }, + "required":["op","request_id"], + "additionalProperties":false, + "oneOf":[ + {"properties":{"op":{"const":"new"}}}, + {"properties":{"op":{"const":"open"}},"required":["path"]}, + {"properties":{"op":{"const":"activate"}},"required":["document_id"]}, + {"properties":{"op":{"const":"run"}},"required":["cmd"]}, + {"properties":{"op":{"const":"start"}},"required":["cmd"]}, + {"properties":{"op":{"const":"input"}},"required":["kind"],"oneOf":[ + {"properties":{"kind":{"const":"text"}}}, + {"properties":{"kind":{"const":"token"}},"required":["text"]}, + {"properties":{"kind":{"const":"point"}},"required":["point"]}, + {"properties":{"kind":{"const":"entity"}},"required":["handle","point"]}, + {"properties":{"kind":{"const":"structure"}},"required":["handle","point"]}, + {"properties":{"kind":{"const":"selection"}}}, + {"properties":{"kind":{"const":"enter"}}} + ]}, + {"properties":{"op":{"const":"cancel"}}}, + {"properties":{"op":{"const":"undo"}}}, + {"properties":{"op":{"const":"redo"}}}, + {"properties":{"op":{"const":"select"}}}, + {"properties":{"op":{"const":"property"}},"required":["field","value"]}, + {"properties":{"op":{"const":"action"}},"required":["name"]}, + {"properties":{"op":{"const":"save"}}}, + {"properties":{"op":{"const":"stop"}}} + ] + }) +} + +fn read_output_schema() -> Value { + json!({ + "type":"object", + "properties":{ + "ok":{"type":"boolean"},"status":{"type":"string"},"code":{"type":"string"}, + "error":{"anyOf":[{"type":"string"},{"type":"null"}]},"document_id":{"type":"integer"}, + "revision":{"type":"integer"},"geometry_revision":{"type":"integer"}, + "camera_revision":{"type":"integer"} + }, + "required":["ok"],"additionalProperties":true + }) +} + +fn execute_output_schema() -> Value { + json!({ + "type":"object", + "properties":{ + "ok":{"type":"boolean"}, + "status":{"type":"string","enum":["accepted","running","waiting_input","completed","cancelled","failed"]}, + "request_id":{"type":"string"},"code":{"type":"string"},"error":{"anyOf":[{"type":"string"},{"type":"null"}]}, + "result":{"type":"object"},"changes":{"anyOf":[{"type":"array"},{"type":"null"}]},"state":{"type":"object"} + }, + "required":["ok"],"additionalProperties":true + }) +} + fn tool_definitions() -> Value { json!([ { "name":"ocs_sessions", "description":"List real OpenCADStudio GUI sessions and documents. Launch the installed editor if none is running.", "inputSchema":{"type":"object","properties":{"launch_if_none":{"type":"boolean","default":true,"description":"Launch OpenCADStudio when no live session exists."}},"additionalProperties":false}, + "outputSchema":{"type":"object","properties":{"result":{"type":"array","items":{"type":"object","properties":{"ok":{"const":true},"session_id":{"type":"string"},"document_id":{"type":"integer"},"revision":{"type":"integer"},"selection":{"type":"array","items":{"type":"string"}},"documents":{"type":"array"}},"required":["ok","session_id","document_id","revision","selection","documents"],"additionalProperties":true}}},"required":["result"],"additionalProperties":false}, "annotations":{"title":"List OCS sessions","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} }, { "name":"ocs_read", - "description":"Read state, commands/actions, query, entities, layers, header, properties, measurements, history, events or operation status from a live OCS session.", - "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters such as document_id, handles, after, or request_id."}},"required":["session_id"],"additionalProperties":false}, + "description":"Read state, commands/actions, query, entities, layers, header, properties, measurements, history, events or operation status from a live OCS session. For command help use op commands with parameters.name.", + "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name for detailed commands help."},"document_id":{"type":"integer","minimum":0},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"}},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1}},"additionalProperties":true}},"required":["session_id"],"additionalProperties":false}, + "outputSchema":read_output_schema(), "annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} }, { "name":"ocs_execute", - "description":"Execute one semantic OCS action. Use state document_id and revision. request_id is the idempotency key. Accepted, running and waiting_input are not completion.", - "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"request":{"type":"object","properties":{"op":{"type":"string","enum":EXECUTE_OPS,"description":"Semantic editor operation."},"request_id":{"type":"string","minLength":1,"maxLength":128,"description":"Caller-generated idempotency key. Reuse it only when retrying the identical request."},"document_id":{"type":"integer","minimum":0,"description":"Target document from current state."},"revision":{"type":"integer","minimum":0,"description":"Expected edit revision from current state."},"geometry_revision":{"type":"integer","minimum":0,"description":"Expected geometry revision when geometry state matters."},"camera_revision":{"type":"integer","minimum":0,"description":"Expected camera revision when view state matters."},"selection":{"type":"array","items":{"type":"string"},"description":"Expected selected handles from current state."}},"required":["op","request_id"]},"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30}},"required":["session_id","request"],"additionalProperties":false}, + "description":"Execute one semantic OCS action. Use current state fields and a unique request_id. Use run for a complete command line or start plus input for guided steps. accepted, running and waiting_input are not completion.", + "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"request":execute_request_schema(),"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30,"description":"Time to wait for asynchronous completion before returning."}},"required":["session_id","request"],"additionalProperties":false}, + "outputSchema":execute_output_schema(), "annotations":{"title":"Execute OCS action","readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":false} }, { @@ -414,7 +589,7 @@ fn error_result(message: impl ToString) -> Value { let message = message.to_string(); json!({ "content":[{"type":"text","text":message.clone()}], - "structuredContent":{"ok":false,"error":message}, + "structuredContent":{"ok":false,"status":"failed","code":"invalid_arguments","error":message,"retryable":false}, "isError":true }) } @@ -589,6 +764,20 @@ mod tests { tools[2]["inputSchema"]["properties"]["request"]["required"], json!(["op", "request_id"]) ); + assert_eq!( + tools[2]["inputSchema"]["properties"]["request"]["oneOf"] + .as_array() + .unwrap() + .len(), + EXECUTE_OPS.len() + ); + assert_eq!( + tools[2]["inputSchema"]["properties"]["request"]["properties"]["cmd"]["examples"][0], + "LINE 0,0 10,10" + ); + assert!(tools[0].get("outputSchema").is_some()); + assert!(tools[1].get("outputSchema").is_some()); + assert!(tools[2].get("outputSchema").is_some()); } #[test] @@ -684,6 +873,26 @@ mod tests { .contains("request_id")); } + #[test] + fn execute_errors_explain_missing_operation_fields() { + let mut clients = HashMap::new(); + let called = handle_message( + json!({"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"session_id":"missing","request":{"op":"run","request_id":"run-1"}}}}), + &mut clients, + ) + .unwrap(); + assert_eq!(called["result"]["isError"], true); + assert_eq!( + called["result"]["structuredContent"]["code"], + "invalid_arguments" + ); + let error = called["result"]["structuredContent"]["error"] + .as_str() + .unwrap(); + assert!(error.contains("Missing cmd"), "{error}"); + assert!(error.contains("LINE 0,0 10,10"), "{error}"); + } + #[test] fn gui_failures_are_mcp_errors() { let result = tool_result(json!({ From efcd73727fbacd758836135c497ff9c197de67a0 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:48:47 +0300 Subject: [PATCH 06/24] perf: select a block's contents from an index, not by scanning the drawing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching to a paper layout took 6.9 s, of which the switch itself was 8.66 ms; the rest landed in the next frame. Part of it was this: wires-build total=334.0ms visible=313.3 entities=43 wires=43 313 ms of a 334 ms resident build, to gather the 43 entities of a paper sheet — because the selection walked all 1 166 357 entities in the document and asked `belongs_to_visible_block` about each one. The same cost is paid on every resident rebuild of any block, so it also shows up as ~315 ms in every model-space rebuild. Those membership rules depend only on owner handles and the block records, so one pass answers them for every block at once. `block_members` resolves them per `geometry_epoch`, mirroring the predicate exactly: a non-null owner is the block; a null owner falls back to the reverse map built from the block records; an entity neither naming nor named by any block is unowned. Two things kept deliberately: * The index is filled by walking `document.entities()`, so each block's list is already in document order. That is draw order for the wire pass, where submission order decides alpha blending, so the wires come out in exactly the order the full scan produced. * The permissive legacy-DXF case — no block record enumerates anything, and unowned entities count towards every block — still takes the full walk. Splicing unowned entities onto a block's list would have changed their relative order. Adds a test asserting the index and the old scan agree, in order, across adds and modifies. Co-Authored-By: Claude Opus 5 --- src/scene/mod.rs | 137 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 1 deletion(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 9642523f1..8c93fa5bf 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1495,6 +1495,9 @@ pub enum ViewportRefreshScope { All, } +/// `(geometry_epoch, entities per block, entities no block claims)`. +type BlockMembers = (u64, HashMap>, Vec); + pub struct Scene { pub camera: Rc>, /// View saved immediately before the latest navigation operation. ZOOM @@ -1833,6 +1836,18 @@ pub struct Scene { /// Reverse map: entity_handle → block_record_handle, built from entity_handles lists. /// Keyed by geometry_epoch. Eliminates the O(B) fallback scan in belongs_to_visible_block. entity_block_map_cache: RefCell)>>, + /// Candidate entities per block, keyed by `geometry_epoch`. + /// + /// Selecting a block's contents used to walk every entity in the document + /// and ask `belongs_to_visible_block` about each: 313 ms of a 334 ms + /// resident build for a paper sheet holding 43 entities, on a drawing with + /// 1.17 M. The membership rules only depend on owner handles and the block + /// records, so one pass answers them for every block at once. + /// + /// The second field holds entities no block claims, which + /// `belongs_to_visible_block` treats as belonging to everything — but only + /// when no block record enumerates its contents at all. + block_members_cache: RefCell>, /// Distinct entity type names present in a layout, keyed by /// (geometry_epoch, layout block). The status bar asks for this on every /// frame to populate the selection-filter menu, but the answer only @@ -2077,6 +2092,7 @@ impl Scene { annotation_affects_wires: std::cell::Cell::new(None), model_extents_cache: RefCell::new(None), entity_block_map_cache: RefCell::new(None), + block_members_cache: RefCell::new(None), layout_type_names_cache: RefCell::new(None), dependency_index_cache: RefCell::new(None), associative_hatch_source_cache: RefCell::new(None), @@ -8929,11 +8945,36 @@ impl Scene { } } out - } else { + } else if block_handle.is_null() || self.entity_block_map().is_empty() { + // Either every entity qualifies, or the drawing declares no block + // contents anywhere and `belongs_to_visible_block` turns permissive + // — the legacy-DXF case. Both need the full walk, and both must + // keep document order, which is draw order for the wire pass. self.document .entities() .filter(|e| visibility_ok(e)) .collect() + } else { + // Ask the per-epoch membership index for this block's candidates + // instead of testing every entity in the document. For a paper + // sheet holding 43 entities in a 1.17 M-entity drawing that is the + // difference between 313 ms and nothing measurable. + // + // The index is filled by walking `document.entities()`, so each + // block's list is already in document order and the wires come out + // in exactly the order the full scan produced. + let members = self.block_members(); + let (_, by_block, _) = &*members; + let claimed = by_block.get(&block_handle).map(Vec::as_slice).unwrap_or(&[]); + let mut out: Vec<&EntityType> = Vec::with_capacity(claimed.len()); + for &handle in claimed { + if let Some(entity) = self.document.get_entity(handle) { + if visibility_ok(entity) { + out.push(entity); + } + } + } + out }; let visible_ms = crate::perf::elapsed_ms(t_visible); @@ -9182,6 +9223,48 @@ entities={} memo_hit={} memo_miss={} wires={} memo={}", } /// Decide whether an entity should be drawn as direct content of `block_handle`. + /// Entities each block may contain, and the ones no block claims. + /// + /// Mirrors `belongs_to_visible_block` exactly, resolved once per + /// `geometry_epoch` for every block instead of per entity per query: + /// + /// * a non-null owner *is* the block, so the entity is that block's; + /// * a null owner falls back to the reverse map built from the block + /// records' `entity_handles`; + /// * an entity neither names nor is named by any block is "unowned", and + /// counts towards every block only when no block record enumerates its + /// contents at all — the legacy-DXF case that predicate allows. + fn block_members(&self) -> std::cell::Ref<'_, BlockMembers> { + { + let cache = self.block_members_cache.borrow(); + if cache.as_ref().is_some_and(|(epoch, _, _)| *epoch == self.geometry_epoch) { + drop(cache); + return std::cell::Ref::map(self.block_members_cache.borrow(), |c| { + c.as_ref().unwrap() + }); + } + } + let mut members: HashMap> = HashMap::default(); + let mut unowned: Vec = Vec::new(); + { + let map = self.entity_block_map(); + for entity in self.document.entities() { + let common = entity.common(); + let owner = common.owner_handle; + if !owner.is_null() { + members.entry(owner).or_default().push(common.handle); + } else if let Some(&block) = map.get(&common.handle) { + members.entry(block).or_default().push(common.handle); + } else { + unowned.push(common.handle); + } + } + } + *self.block_members_cache.borrow_mut() = + Some((self.geometry_epoch, members, unowned)); + std::cell::Ref::map(self.block_members_cache.borrow(), |c| c.as_ref().unwrap()) + } + fn belongs_to_visible_block( &self, entity_handle: Handle, @@ -10397,6 +10480,58 @@ mod journal_tests { // Differential oracle: the incrementally-patched entity index must always // equal a from-scratch rebuild after any add / move / erase. + #[test] + fn block_member_index_matches_the_full_scan() { + use acadrust::entities::Line; + use acadrust::types::Vector3; + + fn line(a: (f64, f64), b: (f64, f64)) -> EntityType { + EntityType::Line(Line::from_points( + Vector3::new(a.0, a.1, 0.0), + Vector3::new(b.0, b.1, 0.0), + )) + } + + // What the membership index reports for `block`, in order. + fn indexed(s: &Scene, block: Handle) -> Vec { + let members = s.block_members(); + let (_, by_block, _) = &*members; + by_block.get(&block).cloned().unwrap_or_default() + } + + // What testing every entity the old way reports, in document order. + fn scanned(s: &Scene, block: Handle) -> Vec { + s.document + .entities() + .filter(|entity| { + let common = entity.common(); + s.belongs_to_visible_block(common.handle, common.owner_handle, block) + }) + .map(|entity| entity.common().handle) + .collect() + } + + let mut s = Scene::new(); + let block = s.model_space_block_handle(); + let h1 = s.add_entity(line((0.0, 0.0), (10.0, 10.0))); + let h2 = s.add_entity(line((5.0, 5.0), (20.0, 3.0))); + let _ = (h1, h2); + + // Order matters: it is the wire pass's draw order. + assert_eq!(indexed(&s, block), scanned(&s, block), "after adds"); + + let h3 = s.add_entity(line((100.0, 100.0), (140.0, 90.0))); + assert_eq!(indexed(&s, block), scanned(&s, block), "after another add"); + + // A modify must not reorder the block's contents either. + { + let mut moved = line((900.0, 900.0), (950.0, 970.0)); + moved.common_mut().handle = h3; + s.update_entity(moved); + } + assert_eq!(indexed(&s, block), scanned(&s, block), "after modify"); + } + #[test] fn entity_index_incremental_matches_full_rebuild() { use acadrust::entities::Line; From e0f5126a2b00be13f152393ea2743fb6f17fdc01 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:49:20 +0300 Subject: [PATCH 07/24] perf: break the view frame down by widget-tree section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first Model→Layout switch costs ~9.2 s, and the largest single piece of it is `view` at 5810 ms — iced widget-tree construction, not the scene. The scene work that follows it in `prepare` (two `wires-build` calls plus `frame-prepare`) accounts for 3377 ms, and the log's ordering shows the two are sequential, not nested: `view` prints before any of them. `view` being one number could not say which part of a 1950-line `view_main` that is. It now reports eight sections — viewport_3d, model_input, grid, selection_overlay, viewport_mouse, viewport_stack, active_vp_rect, chrome — plus the preamble as `head`. The marks go through a closure over a `RefCell` rather than one binding per section: two of the sections sit inside a nested block, where per-section bindings are out of scope by the time the report runs. Co-Authored-By: Claude Opus 5 --- src/app/view/mod.rs | 50 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 3789f6366..78962a4b1 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -197,6 +197,20 @@ impl OpenCADStudio { /// `view` so the single-window web build can render it directly, bypassing /// the multi-window id dispatch above (the web build has no extra windows). pub fn view_main(&self) -> Element<'_, Message> { + // Section marks for `view-detail`. A closure over a RefCell rather + // than one binding per section: two of these sections sit inside a + // nested block and would otherwise be out of scope at the report. + let view_t0 = crate::perf::enabled().then(iced::time::Instant::now); + let view_marks: std::cell::RefCell> = + std::cell::RefCell::new(Vec::with_capacity(8)); + let mark = |name: &'static str| { + if let Some(t0) = view_t0 { + view_marks + .borrow_mut() + .push((name, t0.elapsed().as_secs_f64() * 1000.0)); + } + }; + let i = self.active_tab; let tab = &self.tabs[i]; let thumbnail_capture_clean = self.thumbnail_capture_clean; @@ -257,6 +271,7 @@ impl OpenCADStudio { // the same shader as model space: a full-canvas top-locked "sheet" // viewport draws the layout's own geometry (white sheet + entities + // borders) and the floating content viewports blit on top. + mark("viewport_3d"); let viewport_3d: Element<'_, Message> = if tab.is_start { start_page_view( &self.patrons, @@ -334,6 +349,7 @@ impl OpenCADStudio { // the shader pane_grid (same `model_panes` → identical layout). Layered // above the crosshair overlay so it actually receives mouse events, and // it owns the divider resize. Only built for the Model layout. + mark("model_input"); let model_input_layer: Option> = if is_paper || tab.is_start { None } else { @@ -351,6 +367,7 @@ impl OpenCADStudio { ) }; + mark("grid"); let grid_overlay = { let (vw, vh) = tab.scene.selection.borrow().vp_size; let model_basis = { @@ -420,6 +437,7 @@ impl OpenCADStudio { crate::ui::overlay::grid_overlay(grid, grid_style) }; + mark("selection_overlay"); let selection_overlay = { // Hold a single `Ref<'_, SelectionState>` for the whole overlay // block. The `vp_size` and `last_move_pos` reads below become @@ -790,6 +808,7 @@ impl OpenCADStudio { ) }; + mark("viewport_mouse"); let viewport_mouse = mouse_area(container( iced::widget::Space::new().width(Fill).height(Fill), )) @@ -935,6 +954,7 @@ impl OpenCADStudio { None }; + mark("viewport_stack"); let mut viewport_stack = if tab.is_start { // Start tab: only the welcome widget over a flat background. // Skip every drawing-only overlay (selection markers, snap info, @@ -1053,6 +1073,7 @@ impl OpenCADStudio { // both layered ABOVE the viewport mouse_area so they receive // clicks (the shader viewport sits below it). Positioned with // leading Spaces sized to the viewport's screen rectangle. + mark("active_vp_rect"); let active_vp_rect: Option<(acadrust::Handle, iced::Rectangle)> = if is_paper && !tab.is_start { tab.scene.active_viewport.and_then(|h| { @@ -1383,6 +1404,7 @@ impl OpenCADStudio { } // Reserve the overlaid command line when placing cursor-anchored panels. + mark("chrome"); let command_line_inset = if self.command_line.history_open { self.command_line.history_height.clamp( crate::ui::command_line::HISTORY_HEIGHT_MIN, @@ -2102,6 +2124,34 @@ impl OpenCADStudio { }; // Shared CAD colour picker. Indexed ACI colours use OpenCADStudio's own // dialog; True Color keeps the existing iced_aw gradient picker. + // Which part of the widget tree the frame went into. Reported as a + // breakdown rather than one number, because a first switch to a paper + // layout spends seconds here and `view` alone cannot say where. + if let Some(t0) = view_t0 { + mark("end"); + let total = t0.elapsed().as_secs_f64() * 1000.0; + if total >= 1.0 { + let marks = view_marks.borrow(); + // A mark opens a section; the following mark closes it, so a + // section's cost is the gap to its successor. The first mark + // is preceded by the preamble, reported as `head`. + let mut detail = String::new(); + if let Some((_, first)) = marks.first() { + let _ = std::fmt::Write::write_fmt( + &mut detail, + format_args!(" head={first:.1}"), + ); + } + for pair in marks.windows(2) { + let _ = std::fmt::Write::write_fmt( + &mut detail, + format_args!(" {}={:.1}", pair[0].0, pair[1].1 - pair[0].1), + ); + } + crate::perf_record!("[perf] view-detail total={total:.1}ms{detail}"); + } + } + if let Some((_, current)) = self.color_pick_target.as_ref() { match self.color_picker_tab { super::ColorPickerTab::Index => { From 67ea5fa2dc6695ee01355cbbe46e6386e946144a Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:02:00 +0300 Subject: [PATCH 08/24] perf: report which path the visible-entity selection took MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block member index cut the paper sheet's selection from 313 ms to 112 ms but left model space at 296 ms, which is not what a shared per-epoch index should do — the second call in a frame ought to be free. Either the drawing falls into the permissive legacy-DXF case, where the predicate still needs the full walk, or the index is being rebuilt between the two calls. The wires-build line now says which path ran and how many candidates it considered, so one run settles it. Co-Authored-By: Claude Opus 5 --- src/scene/mod.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 8c93fa5bf..9c38d148c 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -8901,6 +8901,9 @@ impl Scene { let sort_cache_ms = crate::perf::elapsed_ms(t_fn); let t_visible = perf.then(iced::time::Instant::now); + // Which path the selection took, and how many candidates it looked at. + let mut visible_path = "quadtree"; + let mut visible_candidates = 0usize; // Phase 2.1 — quadtree-driven candidate selection. When a view // AABB exists (Model layout with a settled camera), only iterate @@ -9200,7 +9203,7 @@ impl Scene { crate::perf_record!( "[perf] wires-build total={:.1}ms sort_cache={:.1} visible={:.1} blk_cache={:.1} \ build={:.1} [classify={:.1} hits={:.1} tess={:.1} materialize={:.1}] sort={:.1}({}) \ -entities={} memo_hit={} memo_miss={} wires={} memo={}", +entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates={}", crate::perf::elapsed_ms(t_fn), sort_cache_ms, visible_ms, @@ -9217,6 +9220,8 @@ entities={} memo_hit={} memo_miss={} wires={} memo={}", memo_misses, wires.len(), if memo_active { "on" } else { "off" }, + visible_path, + visible_candidates, ); } wires From 91b2344db1a7ca59ae8b23072e05806d151a1b63 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:17:32 +0300 Subject: [PATCH 09/24] perf: break the grid overlay down by part MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `view-detail` located the first paper frame's cost precisely: 7826.9 of 7832.8 ms in `grid`. Once, on the first frame after a layout switch — every other frame in a 2769-frame session reports it at 0.0. That is the largest single cost in a layout switch, larger than the model re-tessellation and the arena rebuild combined, and it is not geometry work at all. `grid_overlay` itself only wraps a canvas; `grid_segments` runs later, in `draw`. So the time is in `grid_views` or in the per-view parameter build, and reading the two does not settle which: both reach helpers that can fall back to a full document walk, and neither obviously fires for this drawing. `grid-detail views= params= bg= n=` splits it, and reports only when the block costs at least a millisecond so the quiet frames stay quiet. Co-Authored-By: Claude Opus 5 --- src/app/view/mod.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 78962a4b1..864496a60 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -382,9 +382,15 @@ impl OpenCADStudio { ); (o, (ux.as_vec3(), uy.as_vec3(), uz.as_vec3())) }; - let grid: Vec = tab - .scene - .grid_views(vw, vh) + // The first paper frame spends seconds in this block and `grid` + // alone cannot say which part. Reported only when it costs + // something, so ordinary frames stay quiet. + let t_grid = crate::perf::enabled().then(iced::time::Instant::now); + let views = tab.scene.grid_views(vw, vh); + let views_ms = crate::perf::elapsed_ms(t_grid); + let view_count = views.len(); + let t_params = crate::perf::enabled().then(iced::time::Instant::now); + let grid: Vec = views .into_iter() .map(|(bounds, cam, handle)| { let (origin, mut axes): (glam::DVec3, _) = if is_paper { @@ -428,7 +434,16 @@ impl OpenCADStudio { } }) .collect(); + let params_ms = crate::perf::elapsed_ms(t_params); + let t_bg = crate::perf::enabled().then(iced::time::Instant::now); let bg = crosshair_background(tab, is_paper); + let bg_ms = crate::perf::elapsed_ms(t_bg); + if views_ms + params_ms + bg_ms >= 1.0 { + crate::perf_record!( + "[perf] grid-detail views={views_ms:.1}ms params={params_ms:.1}ms \ +bg={bg_ms:.1}ms n={view_count}" + ); + } let bg_lum = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2]; let grid_style = crate::ui::overlay::GridStyle { opacity: self.model_space.grid_opacity, From 1f14ba35d17431d7f105b0269eb4c53c6e029516 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:22:49 +0300 Subject: [PATCH 10/24] perf: index the entities the quadtree cannot hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After querying the quadtree and appending the unbounded entities, the quadtree path has to add the ones the tree cannot index — Insert, Viewport, Block, BlockEnd — and did so by walking every entity in the document. On a 1.17 M-entity drawing that is a full walk per resident rebuild to find a few thousand, which defeats the point of having a spatial index on that path at all. Membership depends only on the entity's type, so it is resolved once per `geometry_epoch` and reused. The list is built by walking `document.entities()`, so it stays in document order and the wires come out in the order the loop produced before — which matters, because that is draw order for a pass that alpha-blends. This is the same treatment the previous commit gave the block-membership path. Which of the two a layout switch actually takes is still unmeasured: the diagnostic added to report it was defaulted rather than assigned, so its `visible_path=quadtree` output was a constant. The next commit fixes that. Both branches carried an avoidable full walk either way. Co-Authored-By: Claude Opus 5 --- src/scene/mod.rs | 50 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 9c38d148c..2a151ba04 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1848,6 +1848,13 @@ pub struct Scene { /// `belongs_to_visible_block` treats as belonging to everything — but only /// when no block record enumerates its contents at all. block_members_cache: RefCell>, + /// Handles of entities the quadtree cannot index — Insert, Viewport, Block, + /// BlockEnd — in document order, keyed by `geometry_epoch`. + /// + /// The quadtree path has to append these regardless of the view, and did so + /// by walking every entity in the document: ~300 ms of every resident + /// rebuild on a 1.17 M-entity drawing, to find a few thousand. + unindexable_cache: RefCell)>>, /// Distinct entity type names present in a layout, keyed by /// (geometry_epoch, layout block). The status bar asks for this on every /// frame to populate the selection-filter menu, but the answer only @@ -2093,6 +2100,7 @@ impl Scene { model_extents_cache: RefCell::new(None), entity_block_map_cache: RefCell::new(None), block_members_cache: RefCell::new(None), + unindexable_cache: RefCell::new(None), layout_type_names_cache: RefCell::new(None), dependency_index_cache: RefCell::new(None), associative_hatch_source_cache: RefCell::new(None), @@ -8902,7 +8910,7 @@ impl Scene { let sort_cache_ms = crate::perf::elapsed_ms(t_fn); let t_visible = perf.then(iced::time::Instant::now); // Which path the selection took, and how many candidates it looked at. - let mut visible_path = "quadtree"; + let mut visible_path = "quadtree+index"; let mut visible_candidates = 0usize; // Phase 2.1 — quadtree-driven candidate selection. When a view @@ -8941,10 +8949,19 @@ impl Scene { } } // Inserts/Viewports/Block/BlockEnd — handled by their own paths - // (block expansion, viewport rendering); always candidates. - for e in self.document.entities() { - if is_unindexable_entity(e) && visibility_ok(e) { - out.push(e); + // (block expansion, viewport rendering); always candidates. Taken + // from the per-epoch list rather than by walking the document, + // which preserves document order because the list is built that + // way. + { + let unindexable = self.unindexable_handles(); + visible_candidates = unindexable.1.len(); + for &h in &unindexable.1 { + if let Some(e) = self.document.get_entity(h) { + if visibility_ok(e) { + out.push(e); + } + } } } out @@ -9239,6 +9256,29 @@ entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates /// * an entity neither names nor is named by any block is "unowned", and /// counts towards every block only when no block record enumerates its /// contents at all — the legacy-DXF case that predicate allows. + /// Entities the quadtree cannot index, in document order, resolved once per + /// `geometry_epoch`. Membership depends only on the entity's type, so an + /// edit that does not add or remove one leaves the list unchanged. + fn unindexable_handles(&self) -> std::cell::Ref<'_, (u64, Vec)> { + { + let cache = self.unindexable_cache.borrow(); + if cache.as_ref().is_some_and(|(epoch, _)| *epoch == self.geometry_epoch) { + drop(cache); + return std::cell::Ref::map(self.unindexable_cache.borrow(), |c| { + c.as_ref().unwrap() + }); + } + } + let handles: Vec = self + .document + .entities() + .filter(|entity| is_unindexable_entity(entity)) + .map(|entity| entity.common().handle) + .collect(); + *self.unindexable_cache.borrow_mut() = Some((self.geometry_epoch, handles)); + std::cell::Ref::map(self.unindexable_cache.borrow(), |c| c.as_ref().unwrap()) + } + fn block_members(&self) -> std::cell::Ref<'_, BlockMembers> { { let cache = self.block_members_cache.borrow(); From 0b4551b6ec5ca84c12984fe9041d0ed59d909012 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:35:34 +0300 Subject: [PATCH 11/24] perf: stop tessellating the whole model to measure it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `view-detail` put 7826.9 of the first paper frame's 7832.8 ms in the grid overlay. The path is `grid_views` → `active_viewports` → `camera_for_viewport` → `model_space_extents`, whose fast path reads the already-built wire AABBs but is gated on `current_layout == "Model"` — correctly, because `wire_cache` holds the *current layout's* wires, which in a Layout are the paper sheet's. So a paper frame takes the fallback, which tessellates every model-space entity to read its key vertices. That fallback called `tessellate_one` per entity, and `tessellate_one` resolves the background, annotation scale and block cache from `&self` every time — including a `RefCell` borrow and a cache lookup for a block cache that is identical on all 186 455 calls. The walk was also serial, while the wire builder's own tessellation pass has been parallel for a while. Those inputs are now resolved once, by a `tessellation_context` that `tessellate_one` also uses so the two cannot drift, and the walk runs through rayon. The reduction is min/max/or, so parallel order does not change the result; the arguments to `tessellate_entity` are unchanged. This makes the fallback cheaper, not absent. Tessellating a million entities to obtain a bounding box is the wrong shape, and the structural fix — a model-space extent that does not depend on which layout is being viewed — needs the next measurement to size. Recorded in DEBUG_NOTES. Co-Authored-By: Claude Opus 5 --- src/scene/mod.rs | 118 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 94 insertions(+), 24 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 28cab5091..9a3c58ac1 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -10014,20 +10014,31 @@ entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates } /// Full tessellation pipeline for one entity. - fn tessellate_one(&self, e: &EntityType) -> Vec { - let bg = if self.current_layout == "Model" { + /// The document-wide inputs `tessellate_entity` takes, resolved from the + /// current layout and viewport. + /// + /// Every entity in a walk gets the same four, and resolving them per entity + /// is not free — `block_cache_arc_for` takes a `RefCell` borrow and a cache + /// lookup. Callers that tessellate more than one entity resolve them once + /// and pass them down; sharing the resolution keeps those callers from + /// drifting away from `tessellate_one`. + fn tessellation_context( + &self, + ) -> ([f32; 4], f32, Option, Arc, bool) { + let is_model = self.current_layout == "Model"; + let bg = if is_model { self.bg_color } else { self.paper_bg_color }; - let anno = if self.current_layout == "Model" { + let anno = if is_model { self.annotation_scale } else if let Some(viewport) = self.active_viewport { self.viewport_annotation_multiplier(viewport) } else { 1.0 }; - let annotation_scale_handle = if self.current_layout == "Model" { + let annotation_scale_handle = if is_model { crate::scene::annotative::scale_handle_by_name( &self.document, &self.document.header.current_annotation_scale, @@ -10042,6 +10053,12 @@ entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates self.annotation_all_visible(), self.active_viewport, ); + (bg, anno, annotation_scale_handle, blk_cache, !is_model) + } + + fn tessellate_one(&self, e: &EntityType) -> Vec { + let (bg, anno, annotation_scale_handle, blk_cache, paper) = + self.tessellation_context(); // tessellate_one is used for one-off lookups (hit test, properties). // Skip culling here so the caller always gets the full geometry. tessellate_entity( @@ -10055,7 +10072,7 @@ entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates Some(&blk_cache), None, None, - self.current_layout != "Model", + paper, ) } @@ -10240,27 +10257,80 @@ entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates // offset-rel coords there silently double-subtracts world_offset // inside `camera_for_viewport` and points the viewport at the // wrong location on UTM-scale drawings. - for entity in self.document.entities() { - let c = entity.common(); - if c.owner_handle != model_block - || c.invisible - || self.entity_temporarily_hidden(c.handle) - { - continue; - } - for wire in self.tessellate_one(entity) { - for &[x, y, z] in &wire.key_vertices { - let v = glam::Vec3::new(x as f32, y as f32, z as f32); - // Check finiteness *after* the f32 cast: a ray/xline endpoint - // is a huge-but-finite f64 that overflows to inf in f32, which - // the f64 `is_finite` test would have let through. - if v.is_finite() { - min = min.min(v); - max = max.max(v); - any = true; + // + // `tessellate_one` resolves the background, annotation scale and block + // cache from `&self` on every call, and this walk calls it once per + // model entity — 186 k times on a large drawing, each one taking a + // `RefCell` borrow for a block cache that is the same every time. Those + // inputs do not vary across the loop, so they are resolved once and the + // shared tessellator is called directly, which also lets the walk run + // in parallel the way the wire builder's own tessellation pass does. + // Same arguments, same output: only who computes them changed. + let (bg_for_tess, anno_for_tess, anno_handle_for_tess, blk_for_tess, paper_for_tess) = + self.tessellation_context(); + let subjects: Vec<&EntityType> = self + .document + .entities() + .filter(|entity| { + let c = entity.common(); + c.owner_handle == model_block + && !c.invisible + && !self.entity_temporarily_hidden(c.handle) + }) + .collect(); + let doc = &self.document; + let sel = &self.selected; + let avp = self.active_viewport; + let blk_ref: &cache::block_cache::BlockCache = &blk_for_tess; + use rayon::iter::{IntoParallelIterator, ParallelIterator}; + let reduced = subjects + .into_par_iter() + .map(|entity| { + let mut lo = glam::Vec3::splat(f32::INFINITY); + let mut hi = glam::Vec3::splat(f32::NEG_INFINITY); + let mut hit = false; + for wire in tessellate_entity( + doc, + sel, + avp, + bg_for_tess, + anno_for_tess, + anno_handle_for_tess, + entity, + Some(blk_ref), + None, + None, + paper_for_tess, + ) { + for &[x, y, z] in &wire.key_vertices { + let v = glam::Vec3::new(x as f32, y as f32, z as f32); + // Check finiteness *after* the f32 cast: a ray/xline + // endpoint is a huge-but-finite f64 that overflows to + // inf in f32, which the f64 `is_finite` test would have + // let through. + if v.is_finite() { + lo = lo.min(v); + hi = hi.max(v); + hit = true; + } } } - } + (lo, hi, hit) + }) + .reduce( + || { + ( + glam::Vec3::splat(f32::INFINITY), + glam::Vec3::splat(f32::NEG_INFINITY), + false, + ) + }, + |a, b| (a.0.min(b.0), a.1.max(b.1), a.2 || b.2), + ); + if reduced.2 { + min = min.min(reduced.0); + max = max.max(reduced.1); + any = true; } // Same mesh inclusion for the tessellate fallback path. for (&handle, set) in &self.meshes { From 4e6a9c517773074012f38803a1ddaf1cc31ba255 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:28:53 +0300 Subject: [PATCH 12/24] fix: make the visible-path diagnostic actually report a path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version declared `visible_path` with a default of "quadtree" and never assigned it in any branch, so every run printed `visible_path=quadtree candidates=0` no matter which of the three branches ran. Clippy's "does not need to be mutable" on the declaration was the tell. All three branches now name themselves — `quadtree`, `full-scan`, `block-index` — and report how many candidates they considered. The binding is left uninitialised so the compiler rejects any branch that forgets, which is the property the first version lacked. The full-scan branch reports no count: obtaining one means walking the document a second time, on the one branch already doing that walk. Co-Authored-By: Claude Opus 5 --- src/scene/mod.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 2a151ba04..28cab5091 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -8910,7 +8910,10 @@ impl Scene { let sort_cache_ms = crate::perf::elapsed_ms(t_fn); let t_visible = perf.then(iced::time::Instant::now); // Which path the selection took, and how many candidates it looked at. - let mut visible_path = "quadtree+index"; + // Left uninitialised on purpose: every branch below must set it, and + // the compiler enforces that. A default here is what made the first + // version of this diagnostic report a constant. + let visible_path: &str; let mut visible_candidates = 0usize; // Phase 2.1 — quadtree-driven candidate selection. When a view @@ -8920,6 +8923,7 @@ impl Scene { // Paper space and the first-frame "settle" path fall back to the // full doc scan — preserving prior behaviour. let visible: Vec<&EntityType> = if let Some(local_view) = view_aabb { + visible_path = "quadtree"; let view_wcs: [f64; 4] = [ local_view[0] as f64, local_view[1] as f64, @@ -8932,6 +8936,7 @@ impl Scene { }; let mut out: Vec<&EntityType> = Vec::with_capacity(candidates.len() + unbounded.len() + 16); + visible_candidates = candidates.len() + unbounded.len(); for h in candidates { if let Some(e) = self.document.get_entity(h) { if visibility_ok(e) { @@ -8955,7 +8960,7 @@ impl Scene { // way. { let unindexable = self.unindexable_handles(); - visible_candidates = unindexable.1.len(); + visible_candidates += unindexable.1.len(); for &h in &unindexable.1 { if let Some(e) = self.document.get_entity(h) { if visibility_ok(e) { @@ -8970,6 +8975,7 @@ impl Scene { // contents anywhere and `belongs_to_visible_block` turns permissive // — the legacy-DXF case. Both need the full walk, and both must // keep document order, which is draw order for the wire pass. + visible_path = "full-scan"; self.document .entities() .filter(|e| visibility_ok(e)) @@ -8983,9 +8989,11 @@ impl Scene { // The index is filled by walking `document.entities()`, so each // block's list is already in document order and the wires come out // in exactly the order the full scan produced. + visible_path = "block-index"; let members = self.block_members(); let (_, by_block, _) = &*members; let claimed = by_block.get(&block_handle).map(Vec::as_slice).unwrap_or(&[]); + visible_candidates = claimed.len(); let mut out: Vec<&EntityType> = Vec::with_capacity(claimed.len()); for &handle in claimed { if let Some(entity) = self.document.get_entity(handle) { From d9a1d55f2eb6e68acdb3f2dfd326aab0688866a6 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:14:02 +0300 Subject: [PATCH 13/24] perf: don't resolve viewport cameras for a grid nobody draws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `grid-detail` came back `views=1095.2ms params=0.0ms bg=0.0ms n=0`. Zero grid views — every millisecond of it thrown away. `grid_views` asked `active_viewports` for every viewport, which resolves a camera for each, and `camera_for_viewport` reaches `model_space_extents`, which outside the Model layout tessellates the whole drawing. Only then did it filter on `grid_on`, which removed all of them. The parallel walk from the previous commit had already cut this from 7826.9 ms; it was 7.8 seconds of work for an empty vector. `grid_views` now asks first. `any_viewport_grid_on` reads the same fields `active_viewports` copies into `ViewportInstance::grid_on`, and skips the visibility filters that function also applies: answering "maybe" costs only the work that would have happened anyway, while answering "no" wrongly would drop a grid the user asked for. Also reports the tessellation memo's guard and the inputs that can move it. Two consecutive identical resident builds each missed all 186 455 entries — `memo_hit=0` twice in a row — which a stable guard cannot explain, so something is clearing the memo between them and reading the code has not said what. `guard=`, `guard_stale=`, `avp=`, `anno=`, `anno_h=`, `all_vis=` and `sdf_gen=` will. Co-Authored-By: Claude Opus 5 --- src/scene/mod.rs | 19 +++++++++++++++++-- src/scene/paper.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 9a3c58ac1..6e8298c8f 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -9080,6 +9080,11 @@ impl Scene { let mut materialize_ms = 0.0f64; let mut memo_hits = 0usize; let memo_misses; + // Two consecutive identical resident builds both missed every entry, + // which a stable guard cannot explain — the memo is being cleared + // between them. Report the guard and the inputs that can move it. + let mut guard_dbg = 0u64; + let mut guard_was_stale = false; let t_build = perf.then(iced::time::Instant::now); let mut wires: Vec = if memo_active { // Guard hash of everything tessellate_entity output depends on @@ -9114,7 +9119,9 @@ impl Scene { } else { (&self.tess_memo, &self.tess_memo_guard) }; - if guard_cell.get() != guard { + guard_dbg = guard; + guard_was_stale = guard_cell.get() != guard; + if guard_was_stale { memo_cell.borrow_mut().clear(); guard_cell.set(guard); } @@ -9228,7 +9235,8 @@ impl Scene { crate::perf_record!( "[perf] wires-build total={:.1}ms sort_cache={:.1} visible={:.1} blk_cache={:.1} \ build={:.1} [classify={:.1} hits={:.1} tess={:.1} materialize={:.1}] sort={:.1}({}) \ -entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates={}", +entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates={} \ +guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", crate::perf::elapsed_ms(t_fn), sort_cache_ms, visible_ms, @@ -9247,6 +9255,13 @@ entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates if memo_active { "on" } else { "off" }, visible_path, visible_candidates, + guard_dbg, + guard_was_stale, + avp.map(|h| h.value()).unwrap_or(0), + anno, + annotation_scale_handle.map(|h| h.value()).unwrap_or(0), + all_visible, + crate::scene::text::sdf_atlas::generation(), ); } wires diff --git a/src/scene/paper.rs b/src/scene/paper.rs index d7a1d9285..450074122 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -69,7 +69,36 @@ impl Scene { (layout_block, sheet, content) } + /// Could any viewport in this layout draw a grid? + /// + /// Read from the same fields `active_viewports` copies into + /// `ViewportInstance::grid_on`, and deliberately without the visibility + /// filters that function also applies: answering "maybe" only costs the + /// work that would have happened anyway, while answering "no" wrongly + /// would drop a grid the user asked for. + fn any_viewport_grid_on(&self) -> bool { + if self.current_layout == "Model" { + return self.model_tiles.borrow().iter().any(|tile| tile.grid_on); + } + let (_, sheet, content) = self.paper_viewport_handles(); + let grid_on = |handle: Handle| { + matches!( + self.document.get_entity(handle), + Some(EntityType::Viewport(vp)) if vp.status.grid_on + ) + }; + grid_on(sheet) || content.iter().copied().any(grid_on) + } + pub fn grid_views(&self, vw: f32, vh: f32) -> Vec<(iced::Rectangle, Camera, Handle)> { + // `active_viewports` resolves a camera for every viewport, and + // `camera_for_viewport` reaches `model_space_extents`, which outside the + // Model layout tessellates the whole drawing. Filtering on `grid_on` + // afterwards threw all of that away: on the reproducer the first paper + // frame spent 7.8 s here to produce `n=0` views. Ask first. + if !self.any_viewport_grid_on() { + return Vec::new(); + } self.active_viewports(vw, vh, acadrust::entities::ViewportRenderMode::Wireframe2D) .into_iter() .filter(|inst| inst.grid_on) From 09838be9eca467679e8ae0132cc1daeb20004ad3 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:33:43 +0300 Subject: [PATCH 14/24] feat: draw a notice for one frame before rebuilding a layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching to a Layout stops the main thread long enough that the compositor decides the application has stopped responding and offers to kill it. The switch itself is not the problem — `layout-switch` reports about 10 ms — it is the frame after it, which rebuilds the whole wire set. That frame now has a cheap one in front of it. `layout_settling` is set when the switch takes, and while it holds, the viewport is replaced by a centered "Preparing layout…" on the canvas background. No shader widget means no scene primitive, so `prepare` has nothing to do and the frame costs nothing. A `window::frames` subscription clears the flag, which is the guarantee this needs: that callback runs once a frame has actually been presented, so the notice is on screen and the compositor has been answered before the expensive frame starts. The deferral is of the *frame*, not the switch. Two callers depend on the switch having completed when it returns — one calls `fit_all` immediately after, another compares `current_layout` — so deferring the switch itself would have broken both. This restarts the compositor's timer immediately before the slow frame and replaces a stale window with something that says what is happening. It does not make the slow frame fast: if the rebuild still exceeds the compositor's timeout the dialog can return, and the durable fix is the 2 s `memo_miss` behind it, which is a separate change. Only `en-US` is validated against at build time; Greek is filled in too since it is in use here, and the rest fall back until translated. Co-Authored-By: Claude Opus 5 --- locales/el-GR/opencadstudio.ftl | 1 + locales/en-US/opencadstudio.ftl | 1 + src/app/mod.rs | 13 +++++++++++ src/app/update/mod.rs | 6 ++++++ src/app/update/viewport.rs | 4 ++++ src/app/view/mod.rs | 38 ++++++++++++++++++++++++++++++++- 6 files changed, 62 insertions(+), 1 deletion(-) diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index c97e91a90..6e57b1115 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -167,6 +167,7 @@ clipboard = .paste-to-original-coordinates = Επικόλληση στις αρχικές συντεταγμένες viewport = + .preparing-layout = Προετοιμασία διάταξης… .split-vertical = Κατακόρυφος διαχωρισμός θύρας προβολής .split-horizontal = Οριζόντιος διαχωρισμός θύρας προβολής .move = Μετακίνηση θύρας προβολής diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index d812808aa..f18f782ad 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -167,6 +167,7 @@ clipboard = .paste-to-original-coordinates = Paste to Original Coordinates viewport = + .preparing-layout = Preparing layout… .split-vertical = Split Viewport Vertically .split-horizontal = Split Viewport Horizontally .move = Move Viewport diff --git a/src/app/mod.rs b/src/app/mod.rs index 563b7e0e9..261a32d27 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1055,6 +1055,15 @@ pub(super) struct OpenCADStudio { /// `Some` while a CAD file is loading — drives the modal overlay. /// Cleared when the load finishes, errors, or the user cancels. pub(super) opening: Option, + /// Set the moment a layout switch changes `current_layout`, cleared on the + /// next presented frame. + /// + /// The switch itself costs about 10 ms; the frame after it rebuilds the + /// whole wire set and can block the main thread for seconds. While this is + /// set the viewport is replaced by a notice, so that frame is cheap — which + /// gives the compositor an answer, and the user something other than a + /// stale window, immediately before the expensive one. + pub(super) layout_settling: bool, open_job_serial: u64, /// Last repair or failed-open report shown in the recovery modal. recovery_report: Option, @@ -2283,6 +2292,9 @@ pub enum Message { /// Timer pulse while the cursor is dwelling on a grip; drives the /// dwell-to-popup transition without requiring further mouse motion. GripDwellTick, + /// One frame has been presented since a layout switch; the next may do the + /// expensive rebuild. + LayoutSettled, /// Timer pulse while a rollover hit-test is queued; fires when the /// cursor has been still long enough to safely run the pick. HoverDwellTick, @@ -3484,6 +3496,7 @@ impl OpenCADStudio { print_all_plot_window_prev: None, print_all_plot_setup_prev: None, opening: None, + layout_settling: false, open_job_serial: 0, recovery_report: None, pending_opens: std::collections::VecDeque::new(), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index bd903e6d5..47ae2acb7 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -3045,6 +3045,12 @@ impl OpenCADStudio { Task::none() } + Message::LayoutSettled => { + // The notice frame has been presented, so the compositor has + // had its answer. Let the next frame build the real thing. + self.layout_settling = false; + Task::none() + } Message::GripDwellTick => { let i = self.active_tab; // Reuse the move-time logic — `p` is the last cursor diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 3474330de..5917e7f0f 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -5534,6 +5534,10 @@ impl OpenCADStudio { .layers .sync_with_viewports(&doc_layers, vp_info); let layers_ms = perf_phase.elapsed().as_secs_f64() * 1000.0; + // The switch is cheap; the frame that follows it is not. Draw a notice + // for one frame first, so the compositor gets an answer before the main + // thread disappears into rebuilding the wire set. + self.layout_settling = true; if perf { crate::perf_record!( "[perf] layout-switch from={} to={} total={:.2}ms sync={:.2}ms switch={:.2}ms restore={:.2}ms layers={:.2}ms epoch={}", diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 864496a60..334de7834 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -272,7 +272,33 @@ impl OpenCADStudio { // viewport draws the layout's own geometry (white sheet + entities + // borders) and the floating content viewports blit on top. mark("viewport_3d"); - let viewport_3d: Element<'_, Message> = if tab.is_start { + let viewport_3d: Element<'_, Message> = if self.layout_settling && !tab.is_start { + // One cheap frame between the switch and the rebuild. No shader + // widget means no scene primitive, so `prepare` has nothing to do + // and the event loop turns — which is what stops the compositor + // deciding the application has stopped responding. + let bg = crosshair_background(tab, is_paper); + let lum = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2]; + let ink = if lum > 0.5 { + Color::from_rgba(0.0, 0.0, 0.0, 0.55) + } else { + Color::from_rgba(1.0, 1.0, 1.0, 0.55) + }; + container( + text(crate::tr!("viewport", "preparing-layout")) + .size(15) + .color(ink), + ) + .width(Fill) + .height(Fill) + .center_x(Fill) + .center_y(Fill) + .style(move |_| container::Style { + background: Some(Color::from_rgba(bg[0], bg[1], bg[2], bg[3]).into()), + ..container::Style::default() + }) + .into() + } else if tab.is_start { start_page_view( &self.patrons, &self.videos, @@ -2253,6 +2279,15 @@ impl OpenCADStudio { // the mouse perfectly still — `ViewportMove` alone would never // fire again. Auto-stops once the hover clears or the popup is // already open. + // One frame after a layout switch, then stop. `window::frames` fires + // once a frame has actually been presented, which is the guarantee this + // needs: the notice is on screen and the compositor has been answered + // before the expensive frame starts. + let layout_settle = if self.layout_settling { + window::frames().map(|_| Message::LayoutSettled) + } else { + Subscription::none() + }; let grip_dwell = if self.grip_hover.is_some() && self.grip_popup.is_none() { window::frames().map(|_| Message::GripDwellTick) } else { @@ -2478,6 +2513,7 @@ impl OpenCADStudio { control, frames, history_tick, + layout_settle, grip_dwell, hover_dwell, nav_settle, From a9a2c82478e62a822d78ea01536f29f6c1b9614a Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:26:51 +0300 Subject: [PATCH 15/24] fix: share block definition geometry across viewport slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zooming a paper layout out until several viewports fit on screen ran the card out of memory. The log correlates exactly: every error cluster follows a `nav-prepare … space=Paper` with `viewports=3`, `4` or `5`, where earlier sessions had reported `viewports=2` and stayed inside the budget. Each viewport gets its own `Pipeline` slot, and `block_geometry` was a slot field — so every slot uploaded its own copy of the same definition segments. That is the single largest upload this renderer makes: 1 101 definitions, 1 040 267 segments, 59.5 MiB. Three of them land back to back in one burst in the log, each bracketed by its own full arena rebuild. The cache key is `(source_id, mesh_edge, colour, base translation)` — nothing a viewport contributes. Two viewports drawing the same definition draw the same segments, so the cache belongs beside `wire_buffer_cache` on the `MultiPipeline`, which already shares the finished `BlockWireGpu` batches across slots for the same reason. That existing sharing also settles the one question this raises: bind groups built against one slot's layout are already handed to other slots today, so nothing new is being asked of wgpu here. Both GPU regression tests still pass on a real adapter with validation scopes armed. This does not address the wire arena, which stays per slot and rebuilds 52 MiB of instances each time a slot changes hands — `arena-base ok=false` on every single build in the session, never `ok=true`. Co-Authored-By: Claude Opus 5 --- src/scene/pipeline/gpu_tests.rs | 15 +++++++++------ src/scene/pipeline/mod.rs | 22 ++++++++++++++++------ src/scene/view/render.rs | 9 ++++++++- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/scene/pipeline/gpu_tests.rs b/src/scene/pipeline/gpu_tests.rs index def4a18fc..47d4c6fe2 100644 --- a/src/scene/pipeline/gpu_tests.rs +++ b/src/scene/pipeline/gpu_tests.rs @@ -16,6 +16,9 @@ fn block_edits_preserve_cache_coordinates_and_arena_partition() { .expect("GPU device"); let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb); + // Definition geometry is shared across viewport slots, so it lives on the + // MultiPipeline; a single-slot test supplies its own. + let mut block_geometry = wire_gpu::BlockGeometryCache::default(); let block = |handle: u64, x: f64| WireModel { name: handle.to_string(), points: vec![[x as f32, 0.0, 0.0], [x as f32 + 1.0, 0.0, 0.0]], @@ -28,25 +31,25 @@ fn block_edits_preserve_cache_coordinates_and_arena_partition() { let first = block(1, 10.0); let second = block(2, 20.0); let depth = rustc_hash::FxHashMap::from_iter([(1, [0.1, 0.01]), (2, [0.2, 0.01])]); - let original = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth); - let unchanged = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth); + let original = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth, &mut block_geometry); + let unchanged = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth, &mut block_geometry); assert_eq!(original[0].geometry_id(), unchanged[0].geometry_id()); assert_eq!(original[0].instance_count, 2); - let removed = pipeline.upload_block_wires(&device, &queue, &[&second], &depth); + let removed = pipeline.upload_block_wires(&device, &queue, &[&second], &depth, &mut block_geometry); assert_ne!( original[0].geometry_id(), removed[0].geometry_id(), "removing the base instance must replace vertices baked at its old position" ); let moved = block(2, 30.0); - let moved_gpu = pipeline.upload_block_wires(&device, &queue, &[&moved], &depth); + let moved_gpu = pipeline.upload_block_wires(&device, &queue, &[&moved], &depth, &mut block_geometry); assert_ne!(removed[0].geometry_id(), moved_gpu[0].geometry_id()); - let restored = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth); + let restored = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth, &mut block_geometry); assert_ne!(moved_gpu[0].geometry_id(), restored[0].geometry_id()); assert_eq!(restored[0].instance_count, 2); assert_eq!( - pipeline.block_geometry.len(), + block_geometry.len(), 1, "discard obsolete geometry" ); diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 441704005..b2005ba22 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -217,9 +217,6 @@ pub struct Pipeline { /// shared resident buffer. pub(crate) gpu_wires: std::sync::Arc>, pub(crate) gpu_block_wires: std::sync::Arc>, - /// Block geometry that survives an edit, keyed by the definition it - /// expands. See `wire_gpu::BlockGeometryCache`. - pub(crate) block_geometry: wire_gpu::BlockGeometryCache, /// Persistent per-entity wire instance arena (capability-selected format). /// When active, `gpu_wires` is a thin wrapper over this arena's buffers and an /// edit patches one entity's slab in place instead of rebuilding every wire. @@ -2300,7 +2297,6 @@ impl Pipeline { surface_format: format, gpu_wires: std::sync::Arc::new(vec![]), gpu_block_wires: std::sync::Arc::new(vec![]), - block_geometry: Default::default(), wire_arena: None, wire_arena_mesh: None, wire_arena_fallback: std::sync::Arc::new(Vec::new()), @@ -2360,6 +2356,7 @@ impl Pipeline { queue: &wgpu::Queue, wires: &[&WireModel], depth_map: &rustc_hash::FxHashMap, + block_geometry: &mut wire_gpu::BlockGeometryCache, ) -> Vec { BlockWireGpu::from_wires( device, @@ -2371,7 +2368,7 @@ impl Pipeline { const_bgl: &self.block_wire_const_bgl, mode: self.block_wire_mode, }, - Some(&mut self.block_geometry), + Some(block_geometry), ) } @@ -2382,6 +2379,7 @@ impl Pipeline { queue: &wgpu::Queue, wires: &[WireModel], depth_map: &rustc_hash::FxHashMap, + block_geometry: &mut wire_gpu::BlockGeometryCache, ) -> ( std::sync::Arc>, std::sync::Arc>, @@ -2440,7 +2438,7 @@ impl Pipeline { const_bgl: &self.block_wire_const_bgl, mode: self.block_wire_mode, }, - Some(&mut self.block_geometry), + Some(block_geometry), ); // Parse handles in parallel, preserving wire order for hover slot lists. @@ -4830,6 +4828,17 @@ pub struct MultiPipeline { /// once between them instead of once per slot. Kept trim by dropping entries /// no slot still references (`Arc::strong_count == 1`) once it grows past a /// small bound. + /// Block geometry that survives an edit, keyed by the definition it + /// expands rather than by anything a viewport contributes. See + /// `wire_gpu::BlockGeometryCache`. + /// + /// Shared across slots for the same reason `wire_buffer_cache` is: a + /// definition's segments are identical in every viewport that draws it, and + /// this is the largest upload the renderer makes — 59.5 MiB on the + /// reproducer. Held per slot, zooming a paper layout out until four or five + /// viewports were on screen uploaded it once each and ran a 2 GB card out + /// of memory. + pub(crate) block_geometry: wire_gpu::BlockGeometryCache, pub(crate) wire_buffer_cache: rustc_hash::FxHashMap< u64, ( @@ -4985,6 +4994,7 @@ impl iced::widget::shader::Pipeline for MultiPipeline { fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self { install_gpu_error_handler(device); Self { + block_geometry: Default::default(), inners: vec![Pipeline::new(device, queue, format)], format, slot_by_instance: rustc_hash::FxHashMap::default(), diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 7d5d9eded..60de2fc0a 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -860,6 +860,7 @@ impl shader::Primitive for Primitive { queue, &instanced, &draw_depths, + &mut pipeline.block_geometry, ), ); if _patched { @@ -936,7 +937,13 @@ impl shader::Primitive for Primitive { let errors_before = crate::scene::pipeline::gpu_errors_seen(); let entry = - inner.build_wire_buffers(device, queue, &vp_wires[..], &draw_depths); + inner.build_wire_buffers( + device, + queue, + &vp_wires[..], + &draw_depths, + &mut pipeline.block_geometry, + ); if let Some(start) = t_upload { crate::perf_record!( "[perf] wire-upload {:.1}ms wires={} content_id={}", From d81a858040df5fca44990b67059a9f43e93b8dfb Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:38:25 +0300 Subject: [PATCH 16/24] perf: account for the device memory the renderer holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 0 of the roadmap asks for a memory baseline before any more optimisation, and explicitly not from RSS: the allocator and the driver both retain high-water memory that RSS cannot tell apart from live resources. `gpu-live` counts from the sizes this renderer allocated, in the categories large enough to decide where the next fix goes: slots total shadow targets text_atlas wire_arena block_geometry It is partial by construction and says so, rather than presenting itself as a total. Hatches, images, meshes and Face3D are not counted yet. Three of the numbers needed a size that was not reachable before: the block-geometry chunk records its bytes at build because in storage mode the buffer belongs to the bind group, the arena exposes its allocation rather than its occupied prefix — headroom is what a budget has to answer for — and the text atlas reports its `R8` texel count. Emitted only when a number moves. `prepare` runs thousands of times in a session and these change a handful of times, so a per-frame line would bury the log it is meant to explain. This answers the question the previous fix left open: whether the arena's 52 MiB per slot, the 16 MiB shadow map every slot allocates whether or not shadows are on, or the render targets dominate once a paper layout has four or five viewports on screen. Co-Authored-By: Claude Opus 5 --- src/scene/pipeline/mod.rs | 74 ++++++++++++++++++++++++++++++++ src/scene/pipeline/text_gpu.rs | 5 +++ src/scene/pipeline/wire_arena.rs | 12 ++++++ src/scene/pipeline/wire_gpu.rs | 14 ++++++ src/scene/view/render.rs | 37 ++++++++++++++++ 5 files changed, 142 insertions(+) diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index b2005ba22..72d24b0c4 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -4727,6 +4727,80 @@ fn aabb_below_pixel( (max_px - min_px).max(max_py - min_py) < threshold_px } +/// Device bytes a renderer is holding, counted from the sizes it allocated +/// rather than from process RSS — the allocator and the driver both keep +/// high-water memory that RSS cannot distinguish from live resources. +/// +/// Partial by construction: it covers the categories large enough to decide +/// where the next fix goes, and says so rather than pretending to be a total. +#[derive(Default, Clone, Copy, PartialEq)] +pub(crate) struct GpuLiveBytes { + pub slots: usize, + pub shadow: u64, + pub render_targets: u64, + pub text_atlas: u64, + pub wire_arena: u64, + pub block_geometry: u64, +} + +impl GpuLiveBytes { + pub(crate) fn total(&self) -> u64 { + self.shadow + self.render_targets + self.text_atlas + self.wire_arena + self.block_geometry + } +} + +impl Pipeline { + /// This slot's share. `alloc_size` is the real allocation, which is rounded + /// up from the requested size and grows only. + fn gpu_live_bytes(&self) -> GpuLiveBytes { + // 4x MSAA colour (4 B/sample) + 4x MSAA depth-stencil (4 B/sample) + + // one single-sample resolve target. + const BYTES_PER_PIXEL: u64 = (MSAA_SAMPLES as u64) * 4 + (MSAA_SAMPLES as u64) * 4 + 4; + let pixels = u64::from(self.alloc_size.width) * u64::from(self.alloc_size.height); + GpuLiveBytes { + slots: 1, + // Allocated by every slot whether or not shadows are enabled. + shadow: u64::from(SHADOW_MAP_SIZE) * u64::from(SHADOW_MAP_SIZE) * 4, + render_targets: pixels * BYTES_PER_PIXEL, + text_atlas: self + .text_atlas_gpu + .as_ref() + .map(text_gpu::TextAtlasGpu::gpu_bytes) + .unwrap_or(0), + wire_arena: self + .wire_arena + .as_ref() + .map(wire_arena::PersistentWireArena::gpu_bytes) + .unwrap_or(0) + + self + .wire_arena_mesh + .as_ref() + .map(wire_arena::PersistentWireArena::gpu_bytes) + .unwrap_or(0), + block_geometry: 0, + } + } +} + +impl MultiPipeline { + /// Sum across slots, plus the caches held once for all of them. + pub(crate) fn gpu_live_bytes(&self) -> GpuLiveBytes { + let mut total = GpuLiveBytes { + block_geometry: wire_gpu::block_geometry_bytes(&self.block_geometry), + ..Default::default() + }; + for inner in &self.inners { + let slot = inner.gpu_live_bytes(); + total.slots += slot.slots; + total.shadow += slot.shadow; + total.render_targets += slot.render_targets; + total.text_atlas += slot.text_atlas; + total.wire_arena += slot.wire_arena; + } + total + } +} + /// Bind one block-wire batch and draw it. /// /// The two pipeline modes lay the vertex slots out differently — packed puts diff --git a/src/scene/pipeline/text_gpu.rs b/src/scene/pipeline/text_gpu.rs index cb3e1e482..136e0945b 100644 --- a/src/scene/pipeline/text_gpu.rs +++ b/src/scene/pipeline/text_gpu.rs @@ -221,6 +221,11 @@ pub struct TextAtlasGpu { } impl TextAtlasGpu { + /// Device bytes this atlas holds. `R8`, so one byte per texel. + pub fn gpu_bytes(&self) -> u64 { + u64::from(self._texture.width()) * u64::from(self._texture.height()) + } + /// Bind-group layout for group 1: `{ R8 atlas texture, linear sampler }`. pub fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index 8a98b41a5..4fe85fab0 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -1369,6 +1369,18 @@ impl PersistentWireArena { } } + /// Device bytes this arena holds. The instance buffer is reserved with + /// headroom, so this is the allocation, not the occupied prefix — which is + /// what a memory budget has to answer for. + pub fn gpu_bytes(&self) -> u64 { + match &self.inner { + PersistentWireArenaKind::Indexed(arena) => { + arena.inst_buf.size() + arena.const_buf.size() + } + PersistentWireArenaKind::Packed(arena) => arena.inst_buf.size(), + } + } + pub fn wire_gpus_visible( &self, view_rot: glam::Mat4, diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index 8774ed251..ab1fd455b 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -703,6 +703,15 @@ pub struct BlockWireTarget<'a> { pub mode: WirePipelineMode, } +/// Device bytes held by a whole block-geometry cache. +pub fn block_geometry_bytes(cache: &BlockGeometryCache) -> u64 { + cache + .values() + .flat_map(|chunks| chunks.iter()) + .map(|chunk| chunk.gpu_bytes) + .sum() +} + /// `(source_id, mesh_edge, colour bits, base translation bits)`. pub type BlockGeometryKey = (u64, bool, [u32; 4], [u64; 3]); @@ -722,6 +731,9 @@ pub struct BlockGeometryChunk { pub vertex_buffer: Option, pub vertex_count: u32, pub bind_group: std::sync::Arc, + /// Device bytes this chunk holds. Recorded at build because in storage mode + /// the buffer is owned by `bind_group` and cannot be measured from here. + pub gpu_bytes: u64, } impl BlockWireGpu { @@ -856,6 +868,7 @@ impl BlockWireGpu { BlockGeometryChunk { vertex_buffer: None, vertex_count: chunk.len() as u32 * 6, + gpu_bytes: std::mem::size_of_val(chunk) as u64, bind_group: std::sync::Arc::new(device.create_bind_group( &wgpu::BindGroupDescriptor { label: Some("block_wire.const.bg"), @@ -897,6 +910,7 @@ impl BlockWireGpu { vertices .chunks(max_verts) .map(|chunk| BlockGeometryChunk { + gpu_bytes: std::mem::size_of_val(chunk) as u64, vertex_buffer: Some(super::gpu_upload::upload_buffer( device, queue, diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 60de2fc0a..3b3e18a06 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -1217,6 +1217,7 @@ impl shader::Primitive for Primitive { self.viewports.len(), ); } + report_gpu_live(pipeline); } fn render( @@ -1568,6 +1569,42 @@ fn solar_direction( // ── Render-style helpers (impl Scene) ──────────────────────────────────── +/// Report what the renderer is holding on the device, when it changes. +/// +/// Only on change: `prepare` runs thousands of times a session and these +/// numbers move a handful of times. RSS is deliberately not used — the +/// allocator and the driver both retain high-water memory that RSS cannot tell +/// apart from live resources. +fn report_gpu_live(pipeline: &crate::scene::pipeline::MultiPipeline) { + use std::cell::Cell; + if !crate::perf::enabled() { + return; + } + thread_local! { + static LAST: Cell> = const { Cell::new(None) }; + } + let now = pipeline.gpu_live_bytes(); + LAST.with(|last| { + if last.get() == Some(now) { + return; + } + last.set(Some(now)); + const MIB: f64 = 1024.0 * 1024.0; + let mib = |bytes: u64| bytes as f64 / MIB; + crate::perf_record!( + "[perf] gpu-live slots={} total={:.1}MiB shadow={:.1} targets={:.1} \ +text_atlas={:.1} wire_arena={:.1} block_geometry={:.1}", + now.slots, + mib(now.total()), + mib(now.shadow), + mib(now.render_targets), + mib(now.text_atlas), + mib(now.wire_arena), + mib(now.block_geometry), + ); + }); +} + impl Scene { fn model_tile_vport(&self, index: usize) -> Option<&acadrust::tables::VPort> { let rect = self.model_tiles.borrow().get(index)?.rect; From 0118b28d107b711357d780f9543c7e03ac4acfcf Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:52:42 +0300 Subject: [PATCH 17/24] fix: give back the memory of viewports nobody is drawing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MultiPipeline::inners` only ever grew. A slot that no viewport is using still owned its wire arena, its render targets, its text atlas and every category buffer — tens of megabytes each — and nothing ever asked for them back. Zoom a paper layout out until five viewports are on screen, and the memory the hidden ones are holding is exactly what the visible one cannot get. `Pipeline::release_heavy_resources` hands it back: arenas, wire and block batches, the handle index, selection and preview buffers, wipeouts, images, mesh batches, silhouettes, the text atlas, and the render targets, which go through `ensure_depth_texture` at its smallest size rather than being reached for directly so the blit bind group stays consistent with the views it samples — about 0.6 MiB against the ~70 MiB a full-canvas slot holds. Everything released is derived state whose rebuild is guarded by a cache key, and every one of those keys is reset. That reset was already written once, inline, for slot reuse; both now call `forget_cached_keys` so they cannot disagree about what "this slot holds nothing you can trust" means. It gained two entries in the move — `cached_highlight_key` and `silhouette_source_key` — which slot reuse had not been clearing, so a recycled slot could keep a stale highlight or silhouette. The policy is gradual while the device is healthy: a slot is released once it has been idle for 64 prepare calls, so an ordinary session pays nothing and a layout the user keeps returning to stays warm. It stops being patient the moment the device refuses an allocation — at that point holding an invisible viewport is what turns a degraded frame into a session that cannot recover, and the operator needs the drawing to stay usable long enough to close a viewport or two. The cost of releasing a slot that is wanted again is one slow frame. `slots-released n= urgent= slots=` reports when it happens, since the alternative is inferring it from a memory number. Co-Authored-By: Claude Opus 5 --- src/scene/pipeline/mod.rs | 129 ++++++++++++++++++++++++++++++++++++++ src/scene/view/render.rs | 38 ++++++----- 2 files changed, 147 insertions(+), 20 deletions(-) diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 72d24b0c4..abe0c1a18 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -4593,6 +4593,92 @@ impl Pipeline { } } + /// Forget every cache key that describes this slot's uploaded content. + /// + /// Shared by slot reuse and by [`Self::release_heavy_resources`] so the two + /// cannot disagree about what "this slot holds nothing you can trust" + /// means. Keys only — the buffers themselves are dropped by the caller that + /// wants the memory back. + pub(crate) fn forget_cached_keys(&mut self) { + self.cached_epoch = (u64::MAX, u64::MAX, u64::MAX); + self.cached_wire_id = u64::MAX; + self.cached_selection = (u64::MAX, u64::MAX); + self.cached_highlight_key = (u64::MAX, u64::MAX); + self.cached_mesh_content_id = u64::MAX; + self.cached_face3d_key = (u64::MAX, false, false, u64::MAX); + self.cached_solid_visibility = (u64::MAX, [u32::MAX; 3], u64::MAX); + self.cached_hatch_source = None; + self.cached_preview_hatch_source = None; + self.cached_wipeout_source = None; + self.cached_image_source = None; + self.cached_text_source = None; + self.cached_mesh_source = None; + self.cached_face3d_source = None; + self.cached_face3d_depth_source = None; + self.wire_cull_key = (u64::MAX, u64::MAX, 0, 0); + self.hatch_lod_key = (usize::MAX, u64::MAX, 0, 0, false); + self.wipeout_lod_key = (usize::MAX, u64::MAX, 0, 0, false); + self.silhouette_key = (usize::MAX, u64::MAX, [u32::MAX; 3], false); + self.silhouette_source_key = (usize::MAX, usize::MAX, u64::MAX); + self.render_sig = u64::MAX; + } + + /// Give this slot's device memory back and forget what it held. + /// + /// A slot nobody is drawing still owns its wire arena, its render targets, + /// its text atlas and every category buffer — tens of megabytes each. Held + /// while another viewport is asking for an allocation, that is the + /// difference between a degraded frame and a session that cannot recover. + /// + /// Everything released here is derived state with a rebuild path guarded by + /// a cache key, and every one of those keys is reset, so the next frame that + /// needs this slot builds it again. The cost of releasing a slot that turns + /// out to be needed is one slow frame. + pub(crate) fn release_heavy_resources(&mut self, device: &wgpu::Device) { + self.forget_cached_keys(); + + self.gpu_wires = std::sync::Arc::new(Vec::new()); + self.gpu_block_wires = std::sync::Arc::new(Vec::new()); + self.wire_handle_index = std::sync::Arc::new(rustc_hash::FxHashMap::default()); + self.wire_arena = None; + self.wire_arena_mesh = None; + self.wire_arena_fallback = std::sync::Arc::new(Vec::new()); + self.wire_arena_fallback_kind = None; + self.wire_arena_fallback_handles.clear(); + self.wire_arena_id = u64::MAX; + + self.gpu_selected_wires.clear(); + self.gpu_selected_block_wires.clear(); + self.gpu_preview_wires.clear(); + self.gpu_wipeouts.clear(); + self.wipeout_skip_flags.clear(); + self.gpu_images.clear(); + self.gpu_mesh_batch.clear(); + self.gpu_mesh_dynamic.clear(); + self.mesh_disabled_chunks.clear(); + self.mesh_dynamic_handles.clear(); + self.mesh_ranges_by_handle.clear(); + self.mesh_highlight_draws.clear(); + self.silhouette_chunks.clear(); + self.silhouette_source_groups.clear(); + self.clip_boundary = None; + + self.text_atlas_gpu = None; + self.text_gpu.clear(); + self.block_text_gpu.clear(); + self.block_text_highlight_gpu.clear(); + self.text_highlight_gpu.clear(); + self.text_preview_gpu.clear(); + + // Back to the smallest allocation the rounding allows (128x128, about + // 0.6 MiB against the ~70 MiB a full-canvas slot holds). Going through + // `ensure_depth_texture` rather than reaching for the textures directly + // keeps the blit bind group consistent with the views it samples. + self.ensure_depth_texture(device, Size::new(1, 1)); + // ...and make the next real size a mismatch, so it reallocates. + self.alloc_size = Size::new(0, 0); + } + pub fn ensure_depth_texture(&mut self, device: &wgpu::Device, size: Size) { // Record the requested render size every frame (the blit UV scale reads // it); only reallocate the textures when the *rounded* size changes. @@ -4783,6 +4869,49 @@ impl Pipeline { } impl MultiPipeline { + /// Hand back the device memory of slots nobody is drawing. + /// + /// `reserved` are the slots this frame is about to use; they are never + /// touched. Everything else is released once it has been idle for + /// `idle_frames` prepare calls — gradually, so an ordinary session pays + /// nothing and a layout the user keeps switching back to stays warm. + /// + /// `urgent` drops that patience: the device has just refused an allocation, + /// and holding a viewport nobody can see is what turns a degraded frame + /// into a session that never recovers. Releasing early costs one slow frame + /// if the slot is wanted again; not releasing costs the drawing. + /// + /// Returns how many slots were released. + pub(crate) fn release_idle_slots( + &mut self, + device: &wgpu::Device, + reserved: &[usize], + urgent: bool, + ) -> usize { + const IDLE_FRAMES: u64 = 64; + let idle_frames = if urgent { 0 } else { IDLE_FRAMES }; + let mut released = 0; + for index in 0..self.inners.len() { + if reserved.contains(&index) { + continue; + } + let idle = self.slot_clock.saturating_sub(self.slot_last_used[index]); + if idle < idle_frames { + continue; + } + // Nothing to give back — releasing again would only churn the + // render targets it just rebuilt at their smallest size. + if self.inners[index].gpu_live_bytes().wire_arena == 0 + && self.inners[index].alloc_size == Size::new(0, 0) + { + continue; + } + self.inners[index].release_heavy_resources(device); + released += 1; + } + released + } + /// Sum across slots, plus the caches held once for all of them. pub(crate) fn gpu_live_bytes(&self) -> GpuLiveBytes { let mut total = GpuLiveBytes { diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 3b3e18a06..60343204a 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -367,6 +367,9 @@ impl shader::Primitive for Primitive { let scale = viewport.scale_factor() as f32; let instance_ids: Vec = self.viewports.iter().map(|vp| vp.instance_id).collect(); let slots = pipeline.resolve_slots(device, queue, &instance_ids); + // Compared after the frame's uploads: a move means the device refused + // something, which changes how patient the slot release below can be. + let errors_at_entry = crate::scene::pipeline::gpu_errors_seen(); for (i, vp) in self.viewports.iter().enumerate() { let inner = &mut pipeline.inners[slots[i]]; @@ -381,26 +384,7 @@ impl shader::Primitive for Primitive { // the surviving viewport's text/geometry vanish. if inner.slot_id != vp.instance_id { inner.slot_id = vp.instance_id; - inner.cached_epoch = (u64::MAX, u64::MAX, u64::MAX); - inner.cached_wire_id = u64::MAX; - inner.cached_selection = (u64::MAX, u64::MAX); - inner.cached_mesh_content_id = u64::MAX; - inner.cached_face3d_key = (u64::MAX, false, false, u64::MAX); - inner.cached_solid_visibility = - (u64::MAX, [u32::MAX; 3], u64::MAX); - inner.cached_hatch_source = None; - inner.cached_preview_hatch_source = None; - inner.cached_wipeout_source = None; - inner.cached_image_source = None; - inner.cached_text_source = None; - inner.cached_mesh_source = None; - inner.cached_face3d_source = None; - inner.cached_face3d_depth_source = None; - inner.wire_cull_key = (u64::MAX, u64::MAX, 0, 0); - inner.hatch_lod_key = (usize::MAX, u64::MAX, 0, 0, false); - inner.wipeout_lod_key = (usize::MAX, u64::MAX, 0, 0, false); - inner.silhouette_key = (usize::MAX, u64::MAX, [u32::MAX; 3], false); - inner.render_sig = u64::MAX; + inner.forget_cached_keys(); } // The MSAA / depth / resolve textures are always sized to the // FULL viewport rectangle (not the on-canvas-visible portion) @@ -1217,6 +1201,20 @@ impl shader::Primitive for Primitive { self.viewports.len(), ); } + // Give back what nobody is drawing. Gradual while the device is + // healthy; immediate once it has refused an allocation, because the + // memory a hidden viewport is holding is exactly what the visible one + // could not get — and freeing it is what lets the operator close a + // viewport or two and carry on instead of restarting. + let errors_now = crate::scene::pipeline::gpu_errors_seen(); + let urgent = errors_now != errors_at_entry; + let released = pipeline.release_idle_slots(device, &slots, urgent); + if released > 0 && crate::perf::enabled() { + crate::perf_record!( + "[perf] slots-released n={released} urgent={urgent} slots={}", + pipeline.inners.len(), + ); + } report_gpu_live(pipeline); } From deebc1401225848534a01ad0d5928e8cf60b70c9 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:10:20 +0300 Subject: [PATCH 18/24] fix: a rejected upload must not latch the slot as up to date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drawing vanished from Model and only came back with REGENALL. That is this bug's exact signature, and the guard added two commits ago was incomplete. `cached_wire_id` gates the entire wire upload block — line 620 skips it whenever the slot's id already matches the viewport's content id — and it was stamped unconditionally at the end of that block, whether or not the device had accepted what was uploaded. So a rejected allocation left the slot holding invalid buffers *and* claiming to be current. Nothing rebuilt it. REGENALL worked because it mints a new content id, which is the one thing that makes the ids differ again. `wire_buffer_cache` and `wire_arena_id` were guarded; this third latch, the one that gates them both, was missed. Guarding it individually would leave the same question for `cached_text_source`, `cached_hatch_source` and the rest, each stamped after its own upload. So the check moves to where it covers all of them: if the device reported anything during this viewport's turn, the slot forgets every cache key it holds and the next frame rebuilds it. The counter is device-wide, so an error raised by one viewport also invalidates its neighbours in that frame. That is deliberate — the cost is one slow frame, and the alternative is the failure this fixes. The narrower guards stay: `wire_buffer_cache` is shared across slots, so keeping a bad entry out of it protects viewports that were healthy. Co-Authored-By: Claude Opus 5 --- src/scene/view/render.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 60343204a..7994a84dc 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -373,6 +373,12 @@ impl shader::Primitive for Primitive { for (i, vp) in self.viewports.iter().enumerate() { let inner = &mut pipeline.inners[slots[i]]; + // Compared at the end of this viewport's turn. Every "we hold this + // now" latch below is stamped after its upload, whether or not the + // device accepted it — and the wire latch gates the whole upload + // block, so one rejected allocation could leave a slotpermanently empty with + // nothing that would ever rebuild it. + let slot_errors_before = crate::scene::pipeline::gpu_errors_seen(); // Pipeline slots are addressed by list index, but off-canvas // viewports are dropped from the list — so a slot can be reused by a // DIFFERENT viewport across frames (e.g. the first viewport scrolls @@ -1180,6 +1186,15 @@ impl shader::Primitive for Primitive { self.viewcube_text_color, ); } + + // A rejected allocation leaves buffers that are invalid but + // indistinguishable from good ones at every cache key. Forget + // them all, so the next frame rebuilds this slot instead of + // drawing nothing for the rest of the session — which is what + // sent a user to REGENALL to get the model back. + if crate::scene::pipeline::gpu_errors_seen() != slot_errors_before { + inner.forget_cached_keys(); + } } let prepare_ms = nav_prepare_started.elapsed().as_secs_f64() * 1000.0; if let Some(sample) = self.nav_perf { From 6599b82776924741ec02f6cd3832c96c2280c09c Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:04:02 +0300 Subject: [PATCH 19/24] perf: store block-wire segments once instead of six times MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layout-switch OOM names its buffer: `Buffer with 'block_wire.vertices' label is invalid`, right after `uncaptured wgpu error #1 … Out of Memory`. That buffer is the largest thing this renderer allocates, and five sixths of it is duplication. A segment reaches the shader once per corner of its quad, and the packed path satisfies that by writing the whole 60-byte record six times — 360 bytes a segment — with `vs_main` recovering the corner from `vertex_index % 6`. Six identical copies, on a 2 GB card. `block_wire_storage.wgsl` puts the segments in a read-only storage buffer indexed by `vertex_index / 6` instead, so each record is stored once. The geometry vertex buffer disappears; the placements move to slot 0 and the segments arrive through the definition's bind group, which becomes per-chunk because each chunk binds its own slice. This is the pattern `wire_indexed.wgsl` already uses for per-wire constants, down to the capability gate: `supports_wire_storage` asks for exactly one vertex-stage storage binding, which is exactly what this needs. Where it is false — WebGL, the forced compatibility renderer — the packed shader and its vertex buffer are built as before. `Segment` is declared with scalar members rather than `vec3`, which would align to 16 in storage and reintroduce the padding this removes. A test validates the shader through naga and asserts the struct's stride equals `size_of::()`, because a layout mismatch would otherwise surface only as garbage geometry on a real device. `block-wire-geometry` reports the bytes uploaded and what the packed path would have taken, so the saving is measured rather than assumed. The cache value becomes `Arc>` while keeping the translation-aware key from #1006 — both modes still have to invalidate when the base instance moves, because the records hold its world coordinates either way. `const_bgl` and the mode are decided together when the pipeline is built, so they travel as one `BlockWireTarget` rather than pushing `from_wires` past the argument limit. Co-Authored-By: Claude Opus 5 --- src/scene/pipeline/gpu_tests.rs | 9 +- src/scene/pipeline/mod.rs | 82 +++++-- src/scene/pipeline/wire_gpu.rs | 327 +++++++++++++++++++++------- src/shaders/block_wire_storage.wgsl | 323 +++++++++++++++++++++++++++ 4 files changed, 634 insertions(+), 107 deletions(-) create mode 100644 src/shaders/block_wire_storage.wgsl diff --git a/src/scene/pipeline/gpu_tests.rs b/src/scene/pipeline/gpu_tests.rs index 232393f6c..def4a18fc 100644 --- a/src/scene/pipeline/gpu_tests.rs +++ b/src/scene/pipeline/gpu_tests.rs @@ -30,19 +30,20 @@ fn block_edits_preserve_cache_coordinates_and_arena_partition() { let depth = rustc_hash::FxHashMap::from_iter([(1, [0.1, 0.01]), (2, [0.2, 0.01])]); let original = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth); let unchanged = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth); - assert_eq!(original[0].vertex_buffer, unchanged[0].vertex_buffer); + assert_eq!(original[0].geometry_id(), unchanged[0].geometry_id()); assert_eq!(original[0].instance_count, 2); let removed = pipeline.upload_block_wires(&device, &queue, &[&second], &depth); assert_ne!( - original[0].vertex_buffer, removed[0].vertex_buffer, + original[0].geometry_id(), + removed[0].geometry_id(), "removing the base instance must replace vertices baked at its old position" ); let moved = block(2, 30.0); let moved_gpu = pipeline.upload_block_wires(&device, &queue, &[&moved], &depth); - assert_ne!(removed[0].vertex_buffer, moved_gpu[0].vertex_buffer); + assert_ne!(removed[0].geometry_id(), moved_gpu[0].geometry_id()); let restored = pipeline.upload_block_wires(&device, &queue, &[&first, &second], &depth); - assert_ne!(moved_gpu[0].vertex_buffer, restored[0].vertex_buffer); + assert_ne!(moved_gpu[0].geometry_id(), restored[0].geometry_id()); assert_eq!(restored[0].instance_count, 2); assert_eq!( pipeline.block_geometry.len(), diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index df3e32a4d..a98627f59 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -94,6 +94,10 @@ pub struct Pipeline { /// compatibility mode. Passed to `WireGpu::from_run` / `from_batch`. pub(crate) wire_const_bgl: Option, block_wire_const_bgl: wgpu::BindGroupLayout, + /// Which block-wire pipeline was built. Decides whether a definition's + /// segments go to a vertex buffer six times over or to a storage buffer + /// once. + block_wire_mode: wire_gpu::WirePipelineMode, wipeout_pipeline: wgpu::RenderPipeline, /// Capability-selected hatch renderer. Storage and texture transports are /// private backends behind one upload/LOD/draw lifecycle. @@ -581,7 +585,8 @@ impl Pipeline { bind_group_layouts: &wire_bgls, immediate_size: 0, }); - let block_wire_const_bgl = wire_gpu::block_const_bind_group_layout(device); + let block_wire_const_bgl = + wire_gpu::block_const_bind_group_layout(device, wire_mode.uses_storage()); let block_wire_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("block_wire.pipeline_layout"), bind_group_layouts: &[Some(&frame_bgl), Some(&block_wire_const_bgl)], @@ -602,9 +607,13 @@ impl Pipeline { }); let block_wire_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("block_wire.shader"), - source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( - "../../shaders/block_wire.wgsl" - ))), + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed( + if wire_mode.uses_storage() { + include_str!("../../shaders/block_wire_storage.wgsl") + } else { + include_str!("../../shaders/block_wire.wgsl") + }, + )), }); // Stencil test shared by every paper content pipeline: draw only where @@ -880,6 +889,14 @@ impl Pipeline { multiview_mask: None, cache: None, }); + let block_wire_buffers: &[wgpu::VertexBufferLayout<'_>] = if wire_mode.uses_storage() { + &[wire_gpu::BlockWireInstance::layout()] + } else { + &[ + wire_gpu::BlockWireVertex::layout(), + wire_gpu::BlockWireInstance::layout(), + ] + }; let make_block_wire_pipeline = | label: &'static str, fragment: &'static str, @@ -892,10 +909,10 @@ impl Pipeline { vertex: wgpu::VertexState { module: &block_wire_shader, entry_point: Some("vs_main"), - buffers: &[ - wire_gpu::BlockWireVertex::layout(), - wire_gpu::BlockWireInstance::layout(), - ], + // Storage mode has no geometry vertex buffer: the + // instances become slot 0 and the segments arrive through + // the bind group. + buffers: block_wire_buffers, compilation_options: wgpu::PipelineCompilationOptions::default(), }, primitive: wgpu::PrimitiveState { @@ -2215,6 +2232,7 @@ impl Pipeline { block_wire_xray_pipeline, wire_const_bgl, block_wire_const_bgl, + block_wire_mode: wire_mode, wipeout_pipeline, hatch_gpu, image_pipeline, @@ -2349,7 +2367,10 @@ impl Pipeline { wires, depth_map, None, - &self.block_wire_const_bgl, + wire_gpu::BlockWireTarget { + const_bgl: &self.block_wire_const_bgl, + mode: self.block_wire_mode, + }, Some(&mut self.block_geometry), ) } @@ -2415,7 +2436,10 @@ impl Pipeline { &block_wires, depth_map, None, - &self.block_wire_const_bgl, + wire_gpu::BlockWireTarget { + const_bgl: &self.block_wire_const_bgl, + mode: self.block_wire_mode, + }, Some(&mut self.block_geometry), ); @@ -2554,7 +2578,10 @@ impl Pipeline { &selected_blocks, depth_map, Some(tint), - &self.block_wire_const_bgl, + wire_gpu::BlockWireTarget { + const_bgl: &self.block_wire_const_bgl, + mode: self.block_wire_mode, + }, None, ) } else { @@ -2566,7 +2593,10 @@ impl Pipeline { &hover_blocks, depth_map, Some(WireModel::HOVER), - &self.block_wire_const_bgl, + wire_gpu::BlockWireTarget { + const_bgl: &self.block_wire_const_bgl, + mode: self.block_wire_mode, + }, None, )); self.gpu_selected_block_wires = block_gpu; @@ -4288,10 +4318,7 @@ impl Pipeline { }); block_black_active = use_black; } - pass.set_bind_group(1, wire.const_bind_group.as_ref(), &[]); - pass.set_vertex_buffer(0, wire.vertex_buffer.slice(..)); - pass.set_vertex_buffer(1, wire.instance_buffer.slice(..)); - pass.draw(0..wire.vertex_count, 0..wire.instance_count); + bind_and_draw_block_wire(&mut pass, wire); } // Live overlay wires (command preview / interim / grip drag) always // on top: the xray pipeline (depth_compare=Always, no depth write) @@ -4480,10 +4507,7 @@ impl Pipeline { if wire.instance_count == 0 { continue; } - pass.set_bind_group(1, wire.const_bind_group.as_ref(), &[]); - pass.set_vertex_buffer(0, wire.vertex_buffer.slice(..)); - pass.set_vertex_buffer(1, wire.instance_buffer.slice(..)); - pass.draw(0..wire.vertex_count, 0..wire.instance_count); + bind_and_draw_block_wire(&mut pass, wire); } } if let Some(atlas) = &self.text_atlas_gpu { @@ -4705,6 +4729,24 @@ fn aabb_below_pixel( (max_px - min_px).max(max_py - min_py) < threshold_px } +/// Bind one block-wire batch and draw it. +/// +/// The two pipeline modes lay the vertex slots out differently — packed puts +/// the six-per-segment geometry in slot 0 and the placements in slot 1, while +/// storage has no geometry vertex buffer at all and the placements become slot +/// 0. Both draw loops go through here so they cannot drift apart. +fn bind_and_draw_block_wire(pass: &mut wgpu::RenderPass<'_>, wire: &wire_gpu::BlockWireGpu) { + pass.set_bind_group(1, wire.const_bind_group.as_ref(), &[]); + match &wire.vertex_buffer { + Some(vertices) => { + pass.set_vertex_buffer(0, vertices.slice(..)); + pass.set_vertex_buffer(1, wire.instance_buffer.slice(..)); + } + None => pass.set_vertex_buffer(0, wire.instance_buffer.slice(..)), + } + pass.draw(0..wire.vertex_count, 0..wire.instance_count); +} + fn create_depth_texture(device: &wgpu::Device, size: Size) -> wgpu::Texture { device.create_texture(&wgpu::TextureDescriptor { label: Some("viewer.depth_texture"), diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index 01e382fb2..722452b87 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -334,7 +334,9 @@ impl BlockWireInstance { #[derive(Clone)] pub struct BlockWireGpu { - pub vertex_buffer: wgpu::Buffer, + /// Packed mode only; `None` in storage mode, where the segments reach the + /// shader through `const_bind_group` instead. + pub vertex_buffer: Option, pub instance_buffer: wgpu::Buffer, pub vertex_count: u32, pub instance_count: u32, @@ -342,19 +344,38 @@ pub struct BlockWireGpu { pub const_bind_group: std::sync::Arc, } -pub fn block_const_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { - device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("block_wire_const.bgl"), - entries: &[wgpu::BindGroupLayoutEntry { - binding: 0, +/// Binding 0 is the definition's constants. In storage mode binding 1 carries +/// that chunk's segments, which is why the bind group is per-chunk there and +/// shared across chunks in packed mode. +pub fn block_const_bind_group_layout( + device: &wgpu::Device, + uses_storage: bool, +) -> wgpu::BindGroupLayout { + let mut entries = vec![wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }]; + if uses_storage { + entries.push(wgpu::BindGroupLayoutEntry { + binding: 1, visibility: wgpu::ShaderStages::VERTEX, ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, + ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None, }, count: None, - }], + }); + } + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("block_wire_const.bgl"), + entries: &entries, }) } @@ -669,23 +690,64 @@ pub(crate) fn wire_draw_depth( /// Vertices use the first instance's world coordinates, so changing that base /// must invalidate the geometry as well as rebuild the relative placements. pub type BlockGeometryCache = rustc_hash::FxHashMap< - (u64, bool, [u32; 4], [u64; 3]), - ( - std::sync::Arc>, - std::sync::Arc, - ), + BlockGeometryKey, + std::sync::Arc>, >; +/// How this device draws block wires. The layout and the mode are decided +/// together when the pipeline is built and are never chosen independently, so +/// they travel as one argument. +#[derive(Clone, Copy)] +pub struct BlockWireTarget<'a> { + pub const_bgl: &'a wgpu::BindGroupLayout, + pub mode: WirePipelineMode, +} + +/// `(source_id, mesh_edge, colour bits, base translation bits)`. +pub type BlockGeometryKey = (u64, bool, [u32; 4], [u64; 3]); + +/// One drawable slice of a block definition's geometry. +/// +/// The two pipeline modes put the segments in different places, so the chunk +/// carries whichever the mode produced: +/// +/// * packed — `vertex_buffer` holds six copies of each segment, one per corner +/// of its quad, and `bind_group` is the definition's constants alone; +/// * storage — the segments are in a read-only storage buffer inside +/// `bind_group`, one copy each, and `vertex_buffer` is `None`. +/// +/// `vertex_count` is six per segment either way: in storage mode the shader +/// divides `vertex_index` by six to find its segment. +pub struct BlockGeometryChunk { + pub vertex_buffer: Option, + pub vertex_count: u32, + pub bind_group: std::sync::Arc, +} + impl BlockWireGpu { + /// Identity of the geometry this batch draws, comparable across pipeline + /// modes. + /// + /// Packed mode keeps the segments in `vertex_buffer`; storage mode keeps + /// them inside `const_bind_group`, which is per-chunk there, and leaves + /// `vertex_buffer` `None`. Both are built on the same cache miss and + /// reused together on a hit, so the pair answers "is this the same + /// geometry as before?" in either mode — which `vertex_buffer` alone + /// cannot once it is always `None`. + pub fn geometry_id(&self) -> (Option<&wgpu::Buffer>, &wgpu::BindGroup) { + (self.vertex_buffer.as_ref(), self.const_bind_group.as_ref()) + } + pub fn from_wires( device: &wgpu::Device, queue: &wgpu::Queue, wires: &[&WireModel], depth_map: &rustc_hash::FxHashMap, color_override: Option<[f32; 4]>, - const_bgl: &wgpu::BindGroupLayout, + target: BlockWireTarget<'_>, mut cache: Option<&mut BlockGeometryCache>, ) -> Vec { + let BlockWireTarget { const_bgl, mode } = target; let mut slots: rustc_hash::FxHashMap<(u64, bool), usize> = rustc_hash::FxHashMap::default(); let mut groups: Vec<(bool, Vec<&WireModel>)> = Vec::new(); @@ -710,7 +772,12 @@ impl BlockWireGpu { } let mut out = Vec::new(); - let mut live_keys = rustc_hash::FxHashSet::default(); + let mut live_keys: rustc_hash::FxHashSet = + rustc_hash::FxHashSet::default(); + // Geometry bytes actually sent to the device this call, so the storage + // path's saving is visible rather than assumed. + let mut uploaded_bytes = 0usize; + let mut uploaded_segments = 0usize; for (mesh_edge, group) in groups { let Some(&source) = group.first() else { continue; @@ -727,21 +794,13 @@ impl BlockWireGpu { ); live_keys.insert(cache_key); // Reuse this definition's geometry when it is already on the GPU. - if let Some((chunks, bind_group)) = cache + if let Some(chunks) = cache .as_deref() .and_then(|c| c.get(&cache_key)) - .map(|(v, b)| (std::sync::Arc::clone(v), std::sync::Arc::clone(b))) + .map(std::sync::Arc::clone) { let instances = block_instances(&group, base, mesh_edge, depth_map); - push_block_batches( - &mut out, - device, - queue, - &chunks, - &instances, - mesh_edge, - &bind_group, - ); + push_block_batches(&mut out, device, queue, &chunks, &instances, mesh_edge); continue; } let (segments, mut constant) = emit_wire_native(source, 0, color, 0.0); @@ -749,34 +808,15 @@ impl BlockWireGpu { continue; } constant.draw_depth = 0.0; - let mut vertices = Vec::with_capacity(segments.len() * 6); - for segment in segments { - let vertex = BlockWireVertex { + let records: Vec = segments + .iter() + .map(|segment| BlockWireVertex { pos_a: segment.pos_a, pos_a_low: segment.pos_a_low, pos_b: segment.pos_b, pos_b_low: segment.pos_b_low, distances: [segment.distance_a, segment.distance_b], taper_ratio: segment.taper_ratio, - }; - vertices.extend_from_slice(&[vertex; 6]); - } - // Bound uploads without splitting a segment's six vertices. - let max_verts = - super::gpu_budget::max_elements_grouped::(device, 6); - let vertex_chunks: Vec<(wgpu::Buffer, u32)> = vertices - .chunks(max_verts) - .map(|chunk| { - ( - super::gpu_upload::upload_buffer( - device, - queue, - "block_wire.vertices", - chunk, - wgpu::BufferUsages::VERTEX, - ), - chunk.len() as u32, - ) }) .collect(); // Queue uploads avoid mapping a failed allocation. @@ -787,41 +827,106 @@ impl BlockWireGpu { std::slice::from_ref(&constant), wgpu::BufferUsages::UNIFORM, ); - let const_bind_group = std::sync::Arc::new(device.create_bind_group( - &wgpu::BindGroupDescriptor { - label: Some("block_wire.const.bg"), - layout: const_bgl, - entries: &[wgpu::BindGroupEntry { - binding: 0, - resource: const_buffer.as_entire_binding(), - }], - }, - )); - let vertex_chunks = std::sync::Arc::new(vertex_chunks); + // Bound uploads so a block-heavy drawing cannot ask a low-VRAM + // device for one multi-hundred-MB buffer. + let chunks: Vec = if mode.uses_storage() { + // One record per segment. A storage chunk is bounded by the + // binding size as well as the buffer budget, and needs no + // grouping: the shader indexes segments directly. + let max_records = + super::gpu_budget::max_storage_elements::(device); + records + .chunks(max_records) + .map(|chunk| { + let segments = super::gpu_upload::upload_buffer( + device, + queue, + "block_wire.segments", + chunk, + wgpu::BufferUsages::STORAGE, + ); + BlockGeometryChunk { + vertex_buffer: None, + vertex_count: chunk.len() as u32 * 6, + bind_group: std::sync::Arc::new(device.create_bind_group( + &wgpu::BindGroupDescriptor { + label: Some("block_wire.const.bg"), + layout: const_bgl, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: const_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: segments.as_entire_binding(), + }, + ], + }, + )), + } + }) + .collect() + } else { + // Six copies per segment, one per corner of its quad, without + // splitting a segment's six vertices across two buffers. + let mut vertices = Vec::with_capacity(records.len() * 6); + for record in &records { + vertices.extend_from_slice(&[*record; 6]); + } + let max_verts = + super::gpu_budget::max_elements_grouped::(device, 6); + let shared = std::sync::Arc::new(device.create_bind_group( + &wgpu::BindGroupDescriptor { + label: Some("block_wire.const.bg"), + layout: const_bgl, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: const_buffer.as_entire_binding(), + }], + }, + )); + vertices + .chunks(max_verts) + .map(|chunk| BlockGeometryChunk { + vertex_buffer: Some(super::gpu_upload::upload_buffer( + device, + queue, + "block_wire.vertices", + chunk, + wgpu::BufferUsages::VERTEX, + )), + vertex_count: chunk.len() as u32, + bind_group: std::sync::Arc::clone(&shared), + }) + .collect() + }; + uploaded_segments += records.len(); + uploaded_bytes += records.len() + * std::mem::size_of::() + * if mode.uses_storage() { 1 } else { 6 }; + let chunks = std::sync::Arc::new(chunks); if let Some(cache) = cache.as_deref_mut() { - cache.insert( - cache_key, - ( - std::sync::Arc::clone(&vertex_chunks), - std::sync::Arc::clone(&const_bind_group), - ), - ); + cache.insert(cache_key, std::sync::Arc::clone(&chunks)); } let instances = block_instances(&group, base, mesh_edge, depth_map); - push_block_batches( - &mut out, - device, - queue, - &vertex_chunks, - &instances, - mesh_edge, - &const_bind_group, - ); + push_block_batches(&mut out, device, queue, &chunks, &instances, mesh_edge); } // Release geometry that this build no longer uses. if let Some(cache) = cache { cache.retain(|key, _| live_keys.contains(key)); } + if crate::perf::enabled() && uploaded_segments > 0 { + crate::perf_record!( + "[perf] block-wire-geometry mode={} definitions={} segments={} bytes={} \ +bytes_if_packed={}", + if mode.uses_storage() { "storage" } else { "packed" }, + live_keys.len(), + uploaded_segments, + uploaded_bytes, + uploaded_segments * std::mem::size_of::() * 6, + ); + } out } } @@ -863,16 +968,15 @@ fn block_instances( .collect() } -/// One batch per (vertex chunk × instance chunk). Instance buffers are built -/// once and shared across vertex chunks rather than re-uploaded per chunk. +/// One batch per (geometry chunk × instance chunk). Instance buffers are built +/// once and shared across geometry chunks rather than re-uploaded per chunk. fn push_block_batches( out: &mut Vec, device: &wgpu::Device, queue: &wgpu::Queue, - vertex_chunks: &[(wgpu::Buffer, u32)], + chunks: &[BlockGeometryChunk], instances: &[BlockWireInstance], mesh_edge: bool, - const_bind_group: &std::sync::Arc, ) { let max_instances = super::gpu_budget::max_elements::(device); let instance_chunks: Vec<(wgpu::Buffer, u32)> = instances @@ -884,15 +988,15 @@ fn push_block_batches( ) }) .collect(); - for (vertex_buffer, vertex_count) in vertex_chunks { + for chunk in chunks { for (instance_buffer, instance_count) in &instance_chunks { out.push(BlockWireGpu { - vertex_buffer: vertex_buffer.clone(), + vertex_buffer: chunk.vertex_buffer.clone(), instance_buffer: instance_buffer.clone(), - vertex_count: *vertex_count, + vertex_count: chunk.vertex_count, instance_count: *instance_count, is_3d_mesh_edge: mesh_edge, - const_bind_group: const_bind_group.clone(), + const_bind_group: std::sync::Arc::clone(&chunk.bind_group), }); } } @@ -1271,3 +1375,60 @@ impl WireGpu { .collect() } } + +#[cfg(test)] +mod block_wire_storage_tests { + use super::BlockWireVertex; + + fn module(source: &str) -> naga::Module { + naga::front::wgsl::parse_str(source).expect("shader parses") + } + + fn validate(module: &naga::Module) -> naga::valid::ModuleInfo { + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ) + .validate(module) + .expect("shader validates") + } + + /// A layout mismatch here is invisible until a block draws as garbage on a + /// real device, so the stride is checked against the Rust record instead. + #[test] + fn segment_struct_matches_the_rust_record() { + let module = module(include_str!("../../shaders/block_wire_storage.wgsl")); + validate(&module); + let mut layouter = naga::proc::Layouter::default(); + layouter + .update(module.to_ctx()) + .expect("layouts are computable"); + let (handle, _) = module + .types + .iter() + .find(|(_, ty)| ty.name.as_deref() == Some("Segment")) + .expect("Segment is declared"); + assert_eq!( + layouter[handle].size as usize, + std::mem::size_of::(), + "WGSL Segment and Rust BlockWireVertex must agree byte for byte; \ + a vec3 member would pad to 16 and break this", + ); + assert_eq!(layouter[handle].alignment * 1u32, 4); + } + + /// The two shaders must stay interchangeable: same entry points, same + /// fragment behaviour. Only the geometry source differs. + #[test] + fn both_block_wire_shaders_expose_the_same_entry_points() { + let packed = module(include_str!("../../shaders/block_wire.wgsl")); + let storage = module(include_str!("../../shaders/block_wire_storage.wgsl")); + validate(&packed); + let names = |m: &naga::Module| { + let mut n: Vec = m.entry_points.iter().map(|e| e.name.clone()).collect(); + n.sort(); + n + }; + assert_eq!(names(&packed), names(&storage)); + } +} diff --git a/src/shaders/block_wire_storage.wgsl b/src/shaders/block_wire_storage.wgsl new file mode 100644 index 000000000..98cda4857 --- /dev/null +++ b/src/shaders/block_wire_storage.wgsl @@ -0,0 +1,323 @@ +// Block-wire vertex shader, storage-buffer variant. +// +// `block_wire.wgsl` carries the segment endpoints as vertex attributes, and a +// segment must reach the shader once per corner of its quad, so every record +// is written to VRAM six times: 360 bytes a segment. On a block-heavy drawing +// that is the buffer that runs a 2 GB card out of memory — the OOM names +// `block_wire.vertices` directly. +// +// Here the segments live in a read-only storage buffer indexed by +// `vertex_index / 6`, so each record is stored once: 60 bytes a segment. The +// two shaders are otherwise identical, and must stay so. +// +// `Segment` is declared with scalar fields rather than `vec3`, which +// would align to 16 bytes in storage and reintroduce padding. All-f32 members +// give it align 4 and stride 60, matching `BlockWireVertex` byte for byte. +// +// Requires storage buffers in the vertex stage; callers select +// `block_wire.wgsl` when `DeviceCapabilities::supports_wire_storage` is false. + +struct Uniforms { + viewport_size: vec2, + world_per_pixel: f32, + lwdisplay_enable: f32, + flat_shade: f32, + transparency_enable: f32, + linetype_scale: f32, + lineweight_scale: f32, + view_rot: mat4x4, + eye_high: vec3, + _pad_eh: f32, + eye_low: vec3, + _pad_el: f32, +} +@group(0) @binding(0) var u: Uniforms; + +struct WireConst { + color: vec4, + pat0: vec4, + pat1: vec4, + half_width: f32, + pattern_length: f32, + draw_depth: f32, + align_end: f32, + align_total: f32, + world_half_width: f32, + _pad1: f32, + _pad2: f32, + marker_origin_high: vec4, + marker_origin_low: vec4, + marker_normal_scale: vec4, +} +@group(1) @binding(0) var wire_const: WireConst; + +struct Segment { + pos_a_x: f32, pos_a_y: f32, pos_a_z: f32, + pos_a_low_x: f32, pos_a_low_y: f32, pos_a_low_z: f32, + pos_b_x: f32, pos_b_y: f32, pos_b_z: f32, + pos_b_low_x: f32, pos_b_low_y: f32, pos_b_low_z: f32, + distance_a: f32, distance_b: f32, + // Two `u16` unorms packed into one word, as `Unorm16x2` delivered them. + taper_ratio: u32, +} +@group(1) @binding(1) var segments: array; + +struct VertexIn { + @location(6) translation: vec3, + @location(7) translation_low: vec3, + @location(8) depth: vec2, +} + +const DRAW_ORDER_BIAS: f32 = 0.001; +const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; +const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; + +struct VertexOut { + @builtin(position) clip_pos: vec4, + @location(0) color: vec4, + @location(1) distance: f32, + @location(2) pattern_length: f32, + @location(3) pat0: vec4, + @location(4) pat1: vec4, + @location(5) @interpolate(flat) min_elem: f32, + @location(6) @interpolate(flat) align_end: f32, + @location(7) @interpolate(flat) align_total: f32, + @location(8) cap: vec2, + @location(9) @interpolate(flat) cap_ends: vec3, +} + +fn resolve_hw(taper_ratio: f32, world_hw: f32, px_hw: f32) -> f32 { + if taper_ratio > 0.0 { + return max((taper_ratio * world_hw) / u.world_per_pixel, 0.5); + } + if world_hw > 0.0 { + return max(world_hw / u.world_per_pixel, 0.5); + } + var display_hw = max(px_hw * u.lineweight_scale, 0.5); + if u.lineweight_scale < 0.0 { + let scale = -u.lineweight_scale; + let base_hw = select( + min(px_hw * MODEL_LINEWEIGHT_BOOST, MODEL_LINEWEIGHT_MAX_PX * 0.5), + 0.5, + px_hw <= 0.5, + ); + display_hw = max(base_hw * scale, 0.5); + } + return select(0.5, display_hw, u.lwdisplay_enable > 0.5); +} + +fn marker_relative( + position_high: vec3, + position_low: vec3, + translation: vec3, + translation_low: vec3, +) -> vec3 { + if wire_const.marker_normal_scale.w <= 0.0 { + return (position_high + translation - u.eye_high) + + (position_low + translation_low - u.eye_low); + } + let origin_high = wire_const.marker_origin_high.xyz; + let origin_low = wire_const.marker_origin_low.xyz; + let origin_relative = (origin_high + translation - u.eye_high) + + (origin_low + translation_low - u.eye_low); + let delta = (position_high - origin_high) + (position_low - origin_low); + let normal = normalize(wire_const.marker_normal_scale.xyz); + let axial = normal * dot(delta, normal); + let planar = delta - axial; + let origin_clip = u.view_rot * vec4(origin_relative, 1.0); + let projection_scale = max(length(vec3( + u.view_rot[0].y, + u.view_rot[1].y, + u.view_rot[2].y, + )), 1e-12); + let view_height = 2.0 * max(abs(origin_clip.w), 1e-6) / projection_scale; + let world_size = wire_const.marker_normal_scale.w * 0.01 * view_height; + return origin_relative + axial + planar * world_size; +} + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut { + let corner = vertex_index % 6u; + let seg = segments[vertex_index / 6u]; + let seg_pos_a = vec3(seg.pos_a_x, seg.pos_a_y, seg.pos_a_z); + let seg_pos_a_low = vec3(seg.pos_a_low_x, seg.pos_a_low_y, seg.pos_a_low_z); + let seg_pos_b = vec3(seg.pos_b_x, seg.pos_b_y, seg.pos_b_z); + let seg_pos_b_low = vec3(seg.pos_b_low_x, seg.pos_b_low_y, seg.pos_b_low_z); + let seg_distances = vec2(seg.distance_a, seg.distance_b); + let seg_taper_ratio = unpack2x16unorm(seg.taper_ratio); + let which_end_arr = array(0.0, 1.0, 1.0, 0.0, 1.0, 0.0); + let side_arr = array(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0); + let which_end = which_end_arr[corner]; + let side = side_arr[corner]; + + let rel_a = marker_relative(seg_pos_a, seg_pos_a_low, in.translation, in.translation_low); + let rel_b = marker_relative(seg_pos_b, seg_pos_b_low, in.translation, in.translation_low); + let clip_a = u.view_rot * vec4(rel_a, 1.0); + let clip_b = u.view_rot * vec4(rel_b, 1.0); + let screen_a = clip_a.xy / clip_a.w * u.viewport_size * 0.5; + let screen_b = clip_b.xy / clip_b.w * u.viewport_size * 0.5; + let segment = screen_b - screen_a; + let segment_length = length(segment); + var direction = vec2(1.0, 0.0); + if segment_length > 1e-4 { + direction = segment / segment_length; + } + let perpendicular = vec2(-direction.y, direction.x); + let clip_position = mix(clip_a, clip_b, which_end); + let half_width_a = resolve_hw( + seg_taper_ratio.x, + wire_const.world_half_width, + wire_const.half_width, + ); + let half_width_b = resolve_hw( + seg_taper_ratio.y, + wire_const.world_half_width, + wire_const.half_width, + ); + let half_width = mix(half_width_a, half_width_b, which_end); + let extension = which_end * 2.0 - 1.0; + let offset_px = perpendicular * half_width * side + + direction * half_width * extension; + let ndc_offset = offset_px / (u.viewport_size * 0.5); + let final_clip = clip_position + + vec4(ndc_offset * clip_position.w, 0.0, 0.0); + + let scale = u.linetype_scale; + var min_element = wire_const.pattern_length * scale; + let elements = array( + wire_const.pat0.x * scale, + wire_const.pat0.y * scale, + wire_const.pat0.z * scale, + wire_const.pat0.w * scale, + wire_const.pat1.x * scale, + wire_const.pat1.y * scale, + wire_const.pat1.z * scale, + wire_const.pat1.w * scale, + ); + for (var i = 0u; i < 8u; i++) { + let value = abs(elements[i]); + if value > 0.0 && value < min_element { + min_element = value; + } + } + + var out: VertexOut; + out.clip_pos = final_clip; + out.clip_pos.z -= in.depth.x * DRAW_ORDER_BIAS * out.clip_pos.w; + out.color = wire_const.color; + out.distance = mix(seg_distances.x, seg_distances.y, which_end) + + extension * half_width * u.world_per_pixel; + out.pattern_length = wire_const.pattern_length * scale; + out.pat0 = wire_const.pat0 * scale; + out.pat1 = wire_const.pat1 * scale; + out.min_elem = min_element; + out.align_end = wire_const.align_end * scale; + out.align_total = wire_const.align_total; + out.cap = vec2( + which_end * segment_length + extension * half_width, + half_width * side, + ); + out.cap_ends = vec3(segment_length, half_width_a, half_width_b); + return out; +} + +fn in_dash( + distance: f32, + pattern_length: f32, + pat0: vec4, + pat1: vec4, + align_end: f32, + align_total: f32, +) -> bool { + let elements = array( + pat0.x, pat0.y, pat0.z, pat0.w, + pat1.x, pat1.y, pat1.z, pat1.w, + ); + var count = 0u; + for (var i = 0u; i < 8u; i++) { + if elements[i] != 0.0 { + count = i + 1u; + } + } + var value: f32; + if align_total > 0.0 { + if distance <= align_end || distance >= align_total - align_end { + return true; + } + var first_dash = 0.0; + for (var i = 0u; i < count; i++) { + if elements[i] > 0.0 { + first_dash = elements[i]; + break; + } + } + value = ((distance - align_end + first_dash) % pattern_length + + pattern_length) % pattern_length; + } else { + value = ((distance % pattern_length) + pattern_length) % pattern_length; + } + var position = 0.0; + let dot_half = u.world_per_pixel * 0.75; + for (var i = 0u; i < count; i++) { + let element = elements[i]; + if element == 0.0 { + let delta = abs(value - position); + if min(delta, pattern_length - delta) <= dot_half { + return true; + } + } else if element > 0.0 { + if value >= position && value < position + element { + return true; + } + position += element; + } else { + position -= element; + } + } + return false; +} + +fn clipped_cap(cap: vec2, ends: vec3) -> bool { + if cap.x < 0.0 { + return length(cap) > ends.y; + } + if cap.x > ends.x { + return length(vec2(cap.x - ends.x, cap.y)) > ends.z; + } + return false; +} + +fn visible_fragment(in: VertexOut) -> bool { + if clipped_cap(in.cap, in.cap_ends) { + return false; + } + if in.pattern_length > 0.0 && in.min_elem >= u.world_per_pixel { + return in_dash( + in.distance, + in.pattern_length, + in.pat0, + in.pat1, + in.align_end, + in.align_total, + ); + } + return true; +} + +@fragment +fn fs_main(in: VertexOut) -> @location(0) vec4 { + if !visible_fragment(in) { + discard; + } + let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5); + return vec4(in.color.rgb, alpha); +} + +@fragment +fn fs_black(in: VertexOut) -> @location(0) vec4 { + if !visible_fragment(in) { + discard; + } + let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5); + return vec4(0.0, 0.0, 0.0, alpha); +} From d9c998fa1712086a6b2de841f16473e7580e0155 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:43:23 +0300 Subject: [PATCH 20/24] fix: never cache a buffer the device rejected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After an out-of-memory the viewport stopped updating for the rest of the session — both Model and Layout frozen, the UI still responsive. The log shows why: `block-wire-geometry` prints *after* the errors it provoked. The failed uploads were cached. A rejected allocation still yields a `Buffer`. It is invalid, every later use reports again, and it is indistinguishable from a good one at the cache key. So the entry keeps hitting, nothing ever rebuilds, and the frame stays empty even once memory frees up. Sharing `block_geometry` across slots in the previous commit made that worse, not better: one poisoned entry now serves every viewport. The device's uncaptured-error counter was already there, private to the handler that throttles the log. It is now readable, and the three caches that can retain a bad upload — block definition geometry, the shared wire buffer cache, and the arena's content id — snapshot it across their build and decline to record what it condemns. The degraded frame still draws; the next frame tries again. Best effort by construction: wgpu surfaces some errors asynchronously, so a moved counter means something failed, while an unmoved one is not proof that nothing did. That asymmetry is the right way round here — believing a bad buffer costs a frozen viewport, rebuilding a good one costs a frame. `block-wire-geometry` gains `poisoned=` so a run says whether this happened rather than leaving it to be inferred. Co-Authored-By: Claude Opus 5 --- src/scene/pipeline/mod.rs | 21 +++++++++++++++++---- src/scene/pipeline/wire_gpu.rs | 24 +++++++++++++++++++++--- src/scene/view/render.rs | 28 ++++++++++++++++++++++++---- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index a98627f59..441704005 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -4956,12 +4956,25 @@ impl MultiPipeline { /// `Pipeline::new`, which runs again for every pane `ensure_len` adds. If iced /// ever rebuilds the device it builds a new `MultiPipeline` too, so the fresh /// device is covered (and the throttle restarts with it). +static GPU_ERRORS_SEEN: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// How many uncaptured device errors have been reported since this device was +/// created. +/// +/// Callers snapshot it around an allocation to find out whether what they just +/// built is usable. Best effort: wgpu surfaces some errors asynchronously, so a +/// move means "something failed", while no move is not proof that nothing did. +/// That is the right asymmetry here — the cost of believing a bad buffer is a +/// frozen viewport, the cost of rebuilding a good one is a frame. +pub(crate) fn gpu_errors_seen() -> usize { + GPU_ERRORS_SEEN.load(std::sync::atomic::Ordering::Relaxed) +} + fn install_gpu_error_handler(device: &wgpu::Device) { - use std::sync::atomic::{AtomicUsize, Ordering}; - static SEEN: AtomicUsize = AtomicUsize::new(0); - SEEN.store(0, Ordering::Relaxed); + use std::sync::atomic::Ordering; + GPU_ERRORS_SEEN.store(0, Ordering::Relaxed); device.on_uncaptured_error(std::sync::Arc::new(|e: wgpu::Error| { - let n = SEEN.fetch_add(1, Ordering::Relaxed) + 1; + let n = GPU_ERRORS_SEEN.fetch_add(1, Ordering::Relaxed) + 1; if n.is_power_of_two() { eprintln!("[gpu] uncaptured wgpu error #{n} (frame degraded, session kept alive): {e}"); } diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index 722452b87..8774ed251 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -778,6 +778,8 @@ impl BlockWireGpu { // path's saving is visible rather than assumed. let mut uploaded_bytes = 0usize; let mut uploaded_segments = 0usize; + // Definitions whose upload the device rejected, so they were not cached. + let mut poisoned = 0usize; for (mesh_edge, group) in groups { let Some(&source) = group.first() else { continue; @@ -803,6 +805,12 @@ impl BlockWireGpu { push_block_batches(&mut out, device, queue, &chunks, &instances, mesh_edge); continue; } + // A failed allocation still produces a `Buffer`; it is simply + // invalid, and every later use of it reports again. Cached, it + // freezes the viewport for the rest of the session because the key + // keeps hitting and nothing ever rebuilds. Watch the device's error + // count across the build and refuse to cache what it condemns. + let errors_before = super::gpu_errors_seen(); let (segments, mut constant) = emit_wire_native(source, 0, color, 0.0); if segments.is_empty() { continue; @@ -906,8 +914,17 @@ impl BlockWireGpu { * std::mem::size_of::() * if mode.uses_storage() { 1 } else { 6 }; let chunks = std::sync::Arc::new(chunks); - if let Some(cache) = cache.as_deref_mut() { - cache.insert(cache_key, std::sync::Arc::clone(&chunks)); + let built_clean = super::gpu_errors_seen() == errors_before; + if built_clean { + if let Some(cache) = cache.as_deref_mut() { + cache.insert(cache_key, std::sync::Arc::clone(&chunks)); + } + } else { + // Draw this frame with what we have — a degraded frame beats a + // blank one — but leave the cache clean so the next frame tries + // again. `live_keys` still holds the key, so the retain below + // does not evict a good entry from an earlier frame. + poisoned += 1; } let instances = block_instances(&group, base, mesh_edge, depth_map); push_block_batches(&mut out, device, queue, &chunks, &instances, mesh_edge); @@ -919,12 +936,13 @@ impl BlockWireGpu { if crate::perf::enabled() && uploaded_segments > 0 { crate::perf_record!( "[perf] block-wire-geometry mode={} definitions={} segments={} bytes={} \ -bytes_if_packed={}", +bytes_if_packed={} poisoned={}", if mode.uses_storage() { "storage" } else { "packed" }, live_keys.len(), uploaded_segments, uploaded_bytes, uploaded_segments * std::mem::size_of::() * 6, + poisoned, ); } out diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 768f20ce4..7d5d9eded 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -714,6 +714,9 @@ impl shader::Primitive for Primitive { }) }) }; + // Snapshot before any arena allocation, so a rejected + // upload cannot claim this content id below. + let arena_errors_before = crate::scene::pipeline::gpu_errors_seen(); let reg_ok = base_ok && if let Some(arena) = inner.wire_arena.as_mut() { let patch = patch.unwrap(); @@ -868,7 +871,15 @@ impl shader::Primitive for Primitive { inner.wire_handle_index = wire_arena::build_handle_index(&vp_wires[..]); } - inner.wire_arena_id = vp.wire_content_id; + // Only claim the content id when the device accepted + // the upload; otherwise the slot believes it holds this + // content and never rebuilds it. + inner.wire_arena_id = + if crate::scene::pipeline::gpu_errors_seen() == arena_errors_before { + vp.wire_content_id + } else { + u64::MAX + }; arena_served = true; } else { inner.wire_arena = None; @@ -922,6 +933,8 @@ impl shader::Primitive for Primitive { ); } let t_upload = _perf.then(iced::time::Instant::now); + let errors_before = + crate::scene::pipeline::gpu_errors_seen(); let entry = inner.build_wire_buffers(device, queue, &vp_wires[..], &draw_depths); if let Some(start) = t_upload { @@ -933,9 +946,16 @@ impl shader::Primitive for Primitive { ); } - pipeline - .wire_buffer_cache - .insert(vp.wire_content_id, entry.clone()); + // Buffers the device rejected are still `Buffer`s. + // Cached under a content id that keeps matching, + // they render nothing for the rest of the session + // and nothing ever rebuilds them. Draw the degraded + // frame, but let the next one try again. + if crate::scene::pipeline::gpu_errors_seen() == errors_before { + pipeline + .wire_buffer_cache + .insert(vp.wire_content_id, entry.clone()); + } entry } }; From 49318a1c559ec221b225867b85aef19f99a0c0f2 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:22:06 +0300 Subject: [PATCH 21/24] perf: allocate a shadow map only for viewports that cast shadows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gpu-live` made this the obvious next one. Once idle slots started giving their memory back, the shadow maps were the largest thing that never did: 128 MiB across eight slots, 37 % of the 341 MiB floor, and none of it sampled — `shadow_enabled` defaults to false and most visual styles never turn it on. Every slot allocated `SHADOW_MAP_SIZE` squared at `Depth32Float` in `Pipeline::new` and held it for the life of the slot, because the frame bind group needs *something* at its shadow binding and the field was not optional. It is now `Option<(Texture, TextureView)>` alongside a 1x1 placeholder that satisfies the binding while shadows are off — nothing samples it then, so one texel is the whole requirement. `sync_shadow_target` allocates on the transition into shadows and drops on the way out, and only on the transition, so a viewport that keeps its setting does not rebuild a bind group every frame. The frame bind group had two inline builders — one in `Pipeline::new`, one in `upload_background_images` — and this adds a third caller, so they are now one `rebuild_frame_bind_group`. A shadow transition cannot forget the background image the slot already had. The shadow pass now tests for the target rather than the flag: wanting shadows and having somewhere to put them are different questions, and without the second one a failed 16 MiB allocation would have rendered the pass into the 1x1 placeholder. Releasing a cold slot hands the target back too, and `gpu-live` reports what is actually held rather than a constant. Covered by a GPU test that toggles the setting off/on/off/on against a real adapter with a validation scope armed, then releases the slot — the transition is the risky path and the other GPU tests never take it. Co-Authored-By: Claude Opus 5 --- src/scene/pipeline/gpu_tests.rs | 51 ++++++++ src/scene/pipeline/mod.rs | 213 ++++++++++++++++++++++---------- src/scene/view/render.rs | 2 +- 3 files changed, 199 insertions(+), 67 deletions(-) diff --git a/src/scene/pipeline/gpu_tests.rs b/src/scene/pipeline/gpu_tests.rs index 47d4c6fe2..72fe30e25 100644 --- a/src/scene/pipeline/gpu_tests.rs +++ b/src/scene/pipeline/gpu_tests.rs @@ -268,3 +268,54 @@ fn bounded_gpu_uploads_preserve_geometry_and_instancing() { "GPU validation failed" ); } + +/// The shadow map is 16 MiB and every slot used to hold one whether or not its +/// visual style enabled shadows. Now it is allocated on the transition, which +/// means the frame bind group is rebuilt while the device is watching — the one +/// path the other GPU tests never take. +#[test] +#[ignore = "requires a GPU adapter"] +fn toggling_shadows_reallocates_without_upsetting_the_device() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .expect("GPU adapter"); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .expect("GPU device"); + let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); + let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb); + + assert!( + pipeline.shadow_full.is_none(), + "a fresh slot must not hold a shadow map" + ); + let camera = crate::scene::view::camera::Camera::default(); + let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(64.0, 64.0)); + let with_shadows = |on: bool| { + let mut u = Uniforms::new(&camera, bounds, false); + u.shadow_params[0] = if on { 1.0 } else { 0.0 }; + u + }; + + // Off → on → off → on, so both transitions are exercised twice and the + // second `on` proves the release did not leave the bind group stale. + for expected in [true, false, true] { + pipeline.upload_uniforms(&device, &queue, &with_shadows(expected)); + assert_eq!( + pipeline.shadow_full.is_some(), + expected, + "shadow target should follow shadow_params[0]" + ); + } + // Releasing a cold slot gives it back and must rebuild the bind group too. + pipeline.release_heavy_resources(&device); + assert!(pipeline.shadow_full.is_none(), "a released slot holds none"); + + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + assert!( + block_on(validation.pop()).is_none(), + "GPU validation failed" + ); +} diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index abe0c1a18..da2248a23 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -150,8 +150,16 @@ pub struct Pipeline { background_sampler: wgpu::Sampler, background_source_id: usize, environment_source_id: usize, - _shadow_texture: wgpu::Texture, - shadow_view: wgpu::TextureView, + /// The shadow depth target, allocated only while this viewport actually + /// casts shadows. `SHADOW_MAP_SIZE` squared at `Depth32Float` is 16 MiB, + /// and every slot used to hold one whether or not its visual style enabled + /// shadows — 128 MiB across eight viewports, none of it ever sampled. + shadow_full: Option<(wgpu::Texture, wgpu::TextureView)>, + /// Bound at the frame group's shadow slot while `shadow_full` is `None`. + /// The binding must be filled for the layout to be satisfied, but nothing + /// samples it when shadows are off, so 1x1 is the whole requirement. + _shadow_fallback_texture: wgpu::Texture, + shadow_fallback_view: wgpu::TextureView, shadow_sampler: wgpu::Sampler, shadow_enabled: bool, wipeout_bgl1: wgpu::BindGroupLayout, @@ -489,21 +497,9 @@ impl Pipeline { mipmap_filter: wgpu::MipmapFilterMode::Linear, ..Default::default() }); - let shadow_texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("shadow.texture"), - size: wgpu::Extent3d { - width: SHADOW_MAP_SIZE, - height: SHADOW_MAP_SIZE, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Depth32Float, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }); - let shadow_view = shadow_texture.create_view(&wgpu::TextureViewDescriptor::default()); + let shadow_fallback_texture = create_shadow_texture(device, 1); + let shadow_fallback_view = + shadow_fallback_texture.create_view(&wgpu::TextureViewDescriptor::default()); let shadow_sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("shadow.sampler"), address_mode_u: wgpu::AddressMode::ClampToEdge, @@ -542,7 +538,7 @@ impl Pipeline { }, wgpu::BindGroupEntry { binding: 5, - resource: wgpu::BindingResource::TextureView(&shadow_view), + resource: wgpu::BindingResource::TextureView(&shadow_fallback_view), }, wgpu::BindGroupEntry { binding: 6, @@ -2276,8 +2272,9 @@ impl Pipeline { background_sampler, background_source_id: 0, environment_source_id: 0, - _shadow_texture: shadow_texture, - shadow_view, + shadow_full: None, + _shadow_fallback_texture: shadow_fallback_texture, + shadow_fallback_view, shadow_sampler, shadow_enabled: false, wipeout_bgl1, @@ -3494,8 +3491,90 @@ impl Pipeline { } } - pub fn upload_uniforms(&mut self, queue: &wgpu::Queue, uniforms: &Uniforms) { + /// Whatever is bound at the frame group's shadow slot: the real depth + /// target when this viewport casts shadows, the 1x1 placeholder otherwise. + fn shadow_view(&self) -> &wgpu::TextureView { + match &self.shadow_full { + Some((_, view)) => view, + None => &self.shadow_fallback_view, + } + } + + /// Rebuild the frame bind group from whatever this slot currently holds. + /// + /// Three things can change what belongs in it — a background image, an + /// environment image, and whether the shadow target exists — and it used to + /// be assembled inline at each. One builder means a shadow transition + /// cannot forget the background the slot already had. + fn rebuild_frame_bind_group(&mut self, device: &wgpu::Device) { + let background_view = self + .background_texture + .create_view(&wgpu::TextureViewDescriptor::default()); + let environment_view = self + .environment_texture + .create_view(&wgpu::TextureViewDescriptor::default()); + self.uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("viewer.bind_group"), + layout: &self.frame_bgl, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&background_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.background_sampler), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(&environment_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&self.background_sampler), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(self.shadow_view()), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::Sampler(&self.shadow_sampler), + }, + ], + }); + } + + /// Allocate or drop the shadow target to match `shadow_enabled`. + /// + /// Only on a transition: the frame bind group has to be rebuilt with the + /// new view, and a viewport that keeps its shadow setting should not pay + /// for that every frame. + fn sync_shadow_target(&mut self, device: &wgpu::Device) { + match (self.shadow_enabled, self.shadow_full.is_some()) { + (true, false) => { + let texture = create_shadow_texture(device, SHADOW_MAP_SIZE); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + self.shadow_full = Some((texture, view)); + } + (false, true) => self.shadow_full = None, + _ => return, + } + self.rebuild_frame_bind_group(device); + } + + pub fn upload_uniforms( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + uniforms: &Uniforms, + ) { self.shadow_enabled = uniforms.shadow_params[0] > 0.5; + self.sync_shadow_target(device); queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(uniforms)); } @@ -3562,46 +3641,7 @@ impl Pipeline { environment, [128, 128, 128, 255], ); - let background_view = self - .background_texture - .create_view(&wgpu::TextureViewDescriptor::default()); - let environment_view = self - .environment_texture - .create_view(&wgpu::TextureViewDescriptor::default()); - self.uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("viewer.bind_group"), - layout: &self.frame_bgl, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: self.uniform_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::TextureView(&background_view), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: wgpu::BindingResource::Sampler(&self.background_sampler), - }, - wgpu::BindGroupEntry { - binding: 3, - resource: wgpu::BindingResource::TextureView(&environment_view), - }, - wgpu::BindGroupEntry { - binding: 4, - resource: wgpu::BindingResource::Sampler(&self.background_sampler), - }, - wgpu::BindGroupEntry { - binding: 5, - resource: wgpu::BindingResource::TextureView(&self.shadow_view), - }, - wgpu::BindGroupEntry { - binding: 6, - resource: wgpu::BindingResource::Sampler(&self.shadow_sampler), - }, - ], - }); + self.rebuild_frame_bind_group(device); self.background_source_id = background_id; self.environment_source_id = environment_id; } @@ -3670,12 +3710,20 @@ impl Pipeline { // render rectangle does the clipping). let stencil_ref: u32 = if self.clip_boundary.is_some() { 0xFF } else { 0 }; - if self.shadow_enabled && !self.skip_geometry && !mesh_wireframe { + // `shadow_enabled` says the style wants shadows; the target existing + // says the device could give us one. Without the second test a failed + // 16 MiB allocation would render the pass into the 1x1 placeholder. + if let Some(shadow_target) = self + .shadow_full + .as_ref() + .map(|(_, view)| view) + .filter(|_| self.shadow_enabled && !self.skip_geometry && !mesh_wireframe) + { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("shadow.render_pass"), color_attachments: &[], depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: &self.shadow_view, + view: shadow_target, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Store, @@ -4663,6 +4711,13 @@ impl Pipeline { self.silhouette_source_groups.clear(); self.clip_boundary = None; + // A cold slot's shadow target goes back too. `shadow_enabled` is left + // alone, so `sync_shadow_target` reallocates on the next frame that + // still wants shadows. + if self.shadow_full.take().is_some() { + self.rebuild_frame_bind_group(device); + } + self.text_atlas_gpu = None; self.text_gpu.clear(); self.block_text_gpu.clear(); @@ -4845,8 +4900,12 @@ impl Pipeline { let pixels = u64::from(self.alloc_size.width) * u64::from(self.alloc_size.height); GpuLiveBytes { slots: 1, - // Allocated by every slot whether or not shadows are enabled. - shadow: u64::from(SHADOW_MAP_SIZE) * u64::from(SHADOW_MAP_SIZE) * 4, + // Only while this viewport casts shadows; the placeholder that + // stands in otherwise is 4 bytes. + shadow: match &self.shadow_full { + Some(_) => u64::from(SHADOW_MAP_SIZE) * u64::from(SHADOW_MAP_SIZE) * 4, + None => 4, + }, render_targets: pixels * BYTES_PER_PIXEL, text_atlas: self .text_atlas_gpu @@ -4901,7 +4960,9 @@ impl MultiPipeline { } // Nothing to give back — releasing again would only churn the // render targets it just rebuilt at their smallest size. - if self.inners[index].gpu_live_bytes().wire_arena == 0 + let held = self.inners[index].gpu_live_bytes(); + if held.wire_arena == 0 + && held.shadow <= 4 && self.inners[index].alloc_size == Size::new(0, 0) { continue; @@ -4948,6 +5009,26 @@ fn bind_and_draw_block_wire(pass: &mut wgpu::RenderPass<'_>, wire: &wire_gpu::Bl pass.draw(0..wire.vertex_count, 0..wire.instance_count); } +/// A square `Depth32Float` render target for the shadow pass. `side` is 1 for +/// the placeholder that keeps the frame bind group satisfied while shadows are +/// off, and `SHADOW_MAP_SIZE` for the real thing. +fn create_shadow_texture(device: &wgpu::Device, side: u32) -> wgpu::Texture { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("shadow.texture"), + size: wgpu::Extent3d { + width: side, + height: side, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Depth32Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) +} + fn create_depth_texture(device: &wgpu::Device, size: Size) -> wgpu::Texture { device.create_texture(&wgpu::TextureDescriptor { label: Some("viewer.depth_texture"), diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 7994a84dc..99ca3cf57 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -424,7 +424,7 @@ impl shader::Primitive for Primitive { vp.background_image.as_ref(), vp.environment_image.as_ref(), ); - inner.upload_uniforms(queue, &vp.uniforms); + inner.upload_uniforms(device, queue, &vp.uniforms); // ── Scene-render cache ──────────────────────────────────────── // A pure cursor move — or any frame where the view, geometry, From 391a74a61569ce3f7e51b171e7a32bdd20376f9f Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:06:08 +0300 Subject: [PATCH 22/24] perf: let one tessellation serve every background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching to a Layout re-tessellated the whole model — `memo_miss=186455`, 2034 ms — to arrive at geometry identical to what was already in the memo. The only thing that differed was colour. `local_wires_for` already knows this: it stores the raw colour and drops its background argument with `let _ = bg_color;`, noting that `Batches::finalize` applies `adapt_to_bg` last, "so the cache no longer has to rebuild on BACKGROUND / layout-switch". The tessellation memo's guard had not been told, and still mixed the background in, so every entry was discarded on a switch. `finalize` now records what its colour resolution consumed — the raw colour, the raw glyph colours, and the three flags it branches on — as `WireModel::bg_adapt`, and `resolve_colors_for_bg` replays it for another background. The guard drops the background, so a memo entry serves any layout. The resolution recomputes from the recorded raw colour rather than from the resolved one, which makes it idempotent and therefore safe over a set that mixes memo hits with freshly tessellated misses. Re-adapting the resolved colour would not be safe: `adapt_to_bg` snaps a near-white colour to pure black on a light background, and pure black comes back pure *white* on a dark one, losing the tint. A test pins that asymmetry so the raw colour is not optimised away later. The contract is tested against the real `finalize` rather than a copy of its logic: for every background pair, building under one and resolving for the other lands exactly where building under the other would have. `bg_adapt` is `None` for wires built anywhere else, and the resolution skips them, so nothing outside the block cache changes colour. It is also `None` when the resolution would be a no-op — a preserved colour with no canvas fill looks the same under every background — which keeps the common case at no cost. Ordering is unchanged: the pass runs inside the builder, before `append_scene_markers` and `apply_refedit_fade`, both of which have their own separate background dependency. `wires-build` gains `colors=` so the pass has a number of its own. Co-Authored-By: Claude Opus 5 --- src/app/update/viewport.rs | 1 + src/entities/dimension.rs | 7 ++ src/entities/leader.rs | 2 + src/entities/multileader.rs | 5 + src/entities/table.rs | 1 + src/modules/annotate/aligned_dim.rs | 2 + src/modules/annotate/angular_dim.rs | 1 + src/modules/annotate/data_link.rs | 1 + src/modules/annotate/diameter_dim.rs | 1 + src/modules/annotate/dim_baseline.rs | 1 + src/modules/annotate/dimjogline.rs | 1 + src/modules/annotate/dimtedit.rs | 1 + src/modules/annotate/jogged_radius_dim.rs | 1 + src/modules/annotate/leader_cmd.rs | 1 + src/modules/annotate/linear_dim.rs | 1 + src/modules/annotate/mleader_cmd.rs | 1 + src/modules/annotate/mleader_edit.rs | 1 + src/modules/annotate/ordinate_dim.rs | 1 + src/modules/annotate/radius_dim.rs | 1 + src/modules/annotate/table_cmd.rs | 1 + src/modules/annotate/tolerance_cmd.rs | 1 + src/modules/draw/draw/attdef.rs | 1 + src/modules/draw/draw/mline.rs | 1 + src/modules/draw/draw/raster_image.rs | 1 + src/modules/draw/draw/ray.rs | 2 + src/modules/draw/inquiry/area.rs | 1 + src/modules/draw/inquiry/dist.rs | 1 + src/modules/draw/inquiry/measuregeom.rs | 1 + src/modules/draw/modify/trim.rs | 1 + src/modules/model/primitive_cmd.rs | 1 + src/modules/view/plot_window.rs | 1 + src/modules/view/zoom_window.rs | 1 + src/scene/cache/block_cache.rs | 136 ++++++++++++++++++++++ src/scene/convert/tess.rs | 4 + src/scene/convert/tessellate.rs | 19 +++ src/scene/mod.rs | 34 ++++-- src/scene/model/wire_model.rs | 39 +++++++ src/scene/pick/xclip.rs | 1 + src/scene/text/complex_lt.rs | 2 + src/scene/view/render.rs | 37 ++++++ 40 files changed, 308 insertions(+), 9 deletions(-) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 3474330de..757a8a276 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2407,6 +2407,7 @@ impl OpenCADStudio { let far_pos = base + dir * far; let far_neg = base - dir * far; let guide = crate::scene::WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 730cb421c..a219b0de3 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -3882,6 +3882,7 @@ fn tessellate_dimension_inner( let (ext1, ext2) = split_ext_lines(&geom.ext_lines); if !ext1.is_empty() { wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -3917,6 +3918,7 @@ fn tessellate_dimension_inner( } if !ext2.is_empty() { wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -3952,6 +3954,7 @@ fn tessellate_dimension_inner( } } else { wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -3988,6 +3991,7 @@ fn tessellate_dimension_inner( } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -4032,6 +4036,7 @@ fn tessellate_dimension_inner( let mut points = Vec::new(); add_polyline(&mut points, &symbol); wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -4080,6 +4085,7 @@ fn tessellate_dimension_inner( aci_to_rgba(&c) }; wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -4130,6 +4136,7 @@ fn tessellate_dimension_inner( let p3 = rect[2]; let p4 = rect[5]; wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/entities/leader.rs b/src/entities/leader.rs index 91aee670c..901cf6f63 100644 --- a/src/entities/leader.rs +++ b/src/entities/leader.rs @@ -672,6 +672,7 @@ impl LeaderTess for Leader { if verts.len() < 2 { return WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -852,6 +853,7 @@ impl LeaderTess for Leader { crate::scene::convert::tessellate::points_to_ds(fill_tris); WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index c1c4bcc65..d020227e4 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -2068,6 +2068,7 @@ impl MultiLeaderTess for MultiLeader { // WireModels so the renderer respects per-piece coloring. let mut wires: Vec = Vec::new(); wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -2367,6 +2368,7 @@ impl MultiLeaderTess for MultiLeader { xy = xy.max(p[1] as f64); } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -2441,6 +2443,7 @@ impl MultiLeaderTess for MultiLeader { pts.push([bx, by, z]); } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -2530,6 +2533,7 @@ impl MultiLeaderTess for MultiLeader { wcs_corners[3], ]; wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -2574,6 +2578,7 @@ impl MultiLeaderTess for MultiLeader { wcs_corners[0], ]; wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/entities/table.rs b/src/entities/table.rs index 3b5174840..cb1c8c9c9 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -1990,6 +1990,7 @@ pub fn tessellate_table( let mk = |color: [f32; 4], points: Vec<[f32; 3]>, fill_tris: Vec<[f32; 3]>, lw: f32| -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index e4dd5d475..d6eb177a3 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -286,6 +286,7 @@ impl CadCommand for AlignedDimensionCommand { let p1 = p1.as_vec3(); let p2 = p2.as_vec3(); Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -348,6 +349,7 @@ fn preview_aligned(p1: DVec3, p2: DVec3, dim_pt: DVec3) -> WireModel { let half_height = 0.16; let to_point = |point: DVec3| point.as_vec3().to_array(); WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index 811dc0602..ae736581b 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -1132,6 +1132,7 @@ fn nan() -> DVec3 { fn preview_wire(points: Vec) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/data_link.rs b/src/modules/annotate/data_link.rs index 80032620a..6669a04f9 100644 --- a/src/modules/annotate/data_link.rs +++ b/src/modules/annotate/data_link.rs @@ -63,6 +63,7 @@ impl DataLinkPlaceCommand { ); } WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index 5077dae15..de60b43b4 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -272,6 +272,7 @@ fn dvec(point: Vector3) -> DVec3 { fn preview_line(far_chord: Vec3, chord: Vec3, text: Vec3) -> WireModel { let separator = [f32::NAN; 3]; WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/dim_baseline.rs b/src/modules/annotate/dim_baseline.rs index 834222a34..a5188dacb 100644 --- a/src/modules/annotate/dim_baseline.rs +++ b/src/modules/annotate/dim_baseline.rs @@ -709,6 +709,7 @@ fn preview_for_dimension(dimension: &Dimension) -> WireModel { _ => {} } WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/dimjogline.rs b/src/modules/annotate/dimjogline.rs index 168b631da..02fbd90b3 100644 --- a/src/modules/annotate/dimjogline.rs +++ b/src/modules/annotate/dimjogline.rs @@ -86,6 +86,7 @@ impl CadCommand for DimJogLineCommand { } let d = 0.3_f32; Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/dimtedit.rs b/src/modules/annotate/dimtedit.rs index 6c5a60b98..97c28168e 100644 --- a/src/modules/annotate/dimtedit.rs +++ b/src/modules/annotate/dimtedit.rs @@ -103,6 +103,7 @@ impl CadCommand for DimTeditCommand { } let d = 0.2_f32; Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/jogged_radius_dim.rs b/src/modules/annotate/jogged_radius_dim.rs index 8c3f219cf..711935b49 100644 --- a/src/modules/annotate/jogged_radius_dim.rs +++ b/src/modules/annotate/jogged_radius_dim.rs @@ -442,6 +442,7 @@ fn dvec(point: Vector3) -> DVec3 { fn preview_wire(points: Vec) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/leader_cmd.rs b/src/modules/annotate/leader_cmd.rs index c8dfece71..0c1552b8f 100644 --- a/src/modules/annotate/leader_cmd.rs +++ b/src/modules/annotate/leader_cmd.rs @@ -300,6 +300,7 @@ fn preview_wire(pts: &[Vec3], arrow_size: f32) -> WireModel { points.push([w2.x, w2.y, w2.z]); } WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index 68bb4d133..78a3e6772 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -467,6 +467,7 @@ fn ocs_point(point: [f64; 2], elevation: f64, normal: Vector3) -> DVec3 { fn preview_wire(points: Vec) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/mleader_cmd.rs b/src/modules/annotate/mleader_cmd.rs index c10f4e04a..d897d49d4 100644 --- a/src/modules/annotate/mleader_cmd.rs +++ b/src/modules/annotate/mleader_cmd.rs @@ -764,6 +764,7 @@ fn preview_wire(pts: &[Vec3], arrow_size: f32) -> WireModel { points.push([w2.x, w2.y, w2.z]); } WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/mleader_edit.rs b/src/modules/annotate/mleader_edit.rs index 07a7feab9..f01134038 100644 --- a/src/modules/annotate/mleader_edit.rs +++ b/src/modules/annotate/mleader_edit.rs @@ -490,6 +490,7 @@ impl CadCommand for MLeaderCollectCommand { fn preview_wire(pts: &[Vec3]) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/ordinate_dim.rs b/src/modules/annotate/ordinate_dim.rs index ea5b94c8c..8b917e33f 100644 --- a/src/modules/annotate/ordinate_dim.rs +++ b/src/modules/annotate/ordinate_dim.rs @@ -270,6 +270,7 @@ fn is_x_type(feature: DVec3, leader: DVec3) -> bool { fn preview_wire(points: Vec) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index f68c26f6b..b61db83fb 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -277,6 +277,7 @@ fn dvec(point: Vector3) -> DVec3 { fn preview_wire(points: Vec) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/table_cmd.rs b/src/modules/annotate/table_cmd.rs index 2ffb34878..664532093 100644 --- a/src/modules/annotate/table_cmd.rs +++ b/src/modules/annotate/table_cmd.rs @@ -212,6 +212,7 @@ impl TableCommand { ); } WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/tolerance_cmd.rs b/src/modules/annotate/tolerance_cmd.rs index 192835bc8..e20995b65 100644 --- a/src/modules/annotate/tolerance_cmd.rs +++ b/src/modules/annotate/tolerance_cmd.rs @@ -81,6 +81,7 @@ impl CadCommand for ToleranceCommand { })); } Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/draw/attdef.rs b/src/modules/draw/draw/attdef.rs index bba45cb11..a6ee36198 100644 --- a/src/modules/draw/draw/attdef.rs +++ b/src/modules/draw/draw/attdef.rs @@ -199,6 +199,7 @@ impl CadCommand for AttdefCommand { pt + self.plane.y * d, ]; Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/draw/mline.rs b/src/modules/draw/draw/mline.rs index c476b2358..79d24ff5f 100644 --- a/src/modules/draw/draw/mline.rs +++ b/src/modules/draw/draw/mline.rs @@ -180,6 +180,7 @@ impl MlineCommand { let (fill_tris, fill_tris_low) = crate::scene::convert::tessellate::points_to_ds(fill_tris); Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/draw/raster_image.rs b/src/modules/draw/draw/raster_image.rs index f2001719b..8f169fb9c 100644 --- a/src/modules/draw/draw/raster_image.rs +++ b/src/modules/draw/draw/raster_image.rs @@ -123,6 +123,7 @@ impl CadCommand for ImageCommand { let [p0, p1, p2, p3] = corners; Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/draw/ray.rs b/src/modules/draw/draw/ray.rs index 63d374d00..0b349b42e 100644 --- a/src/modules/draw/draw/ray.rs +++ b/src/modules/draw/draw/ray.rs @@ -85,6 +85,7 @@ impl CadCommand for RayCommand { let dir = (pt - base).normalize_or_zero(); let far = base + dir * DISPLAY_EXTENT; Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -189,6 +190,7 @@ impl CadCommand for XLineCommand { let far_pos = base + dir * DISPLAY_EXTENT; let far_neg = base - dir * DISPLAY_EXTENT; Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/inquiry/area.rs b/src/modules/draw/inquiry/area.rs index 2e41e149a..645dc701c 100644 --- a/src/modules/draw/inquiry/area.rs +++ b/src/modules/draw/inquiry/area.rs @@ -365,6 +365,7 @@ impl CadCommand for AreaCommand { points.push(to_render(point)); points.push(to_render(self.points[0])); Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/inquiry/dist.rs b/src/modules/draw/inquiry/dist.rs index ff3729f19..3ee40fb6f 100644 --- a/src/modules/draw/inquiry/dist.rs +++ b/src/modules/draw/inquiry/dist.rs @@ -86,6 +86,7 @@ impl CadCommand for DistCommand { let p1 = self.first?; // The preview wire is purely visual, so f32 vertices are fine here. Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/inquiry/measuregeom.rs b/src/modules/draw/inquiry/measuregeom.rs index 25e47ac68..fd5844d51 100644 --- a/src/modules/draw/inquiry/measuregeom.rs +++ b/src/modules/draw/inquiry/measuregeom.rs @@ -70,6 +70,7 @@ impl MeasureGeomCommand { /// Build a cyan preview wire connecting the picked points and the cursor. fn preview_wire(name: &str, pts: Vec<[f32; 3]>) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index d1341013a..e5f2a5b59 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -3746,6 +3746,7 @@ fn collect_runs(pts: &[[f64; 2]], take: &dyn Fn([f64; 2]) -> bool, out: &mut Vec /// Build a preview wire from a NaN-break point list. fn preview_wire(points: Vec<[f32; 3]>, color: [f32; 4], name: &str) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/model/primitive_cmd.rs b/src/modules/model/primitive_cmd.rs index 3beecc06f..bc586caca 100644 --- a/src/modules/model/primitive_cmd.rs +++ b/src/modules/model/primitive_cmd.rs @@ -3621,6 +3621,7 @@ inventory::submit!(crate::command::CommandRegistration { fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/view/plot_window.rs b/src/modules/view/plot_window.rs index 03341c085..896985e39 100644 --- a/src/modules/view/plot_window.rs +++ b/src/modules/view/plot_window.rs @@ -54,6 +54,7 @@ impl CadCommand for PlotWindowCommand { let p1 = self.p1?.as_vec3(); // Draw the selection rectangle. Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/view/zoom_window.rs b/src/modules/view/zoom_window.rs index a22f030e5..35fefe986 100644 --- a/src/modules/view/zoom_window.rs +++ b/src/modules/view/zoom_window.rs @@ -136,6 +136,7 @@ impl CadCommand for ZoomWindowCommand { let max = p1.max(pt); // Draw a rectangle preview Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index 5e04a9613..aa8acca81 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -1591,6 +1591,25 @@ impl Batches { [b.min_x, b.min_y, b.max_x, b.max_y] }; let contrast_bg = b.contrast_bg.unwrap_or(bg_color); + // Record what this resolution consumed, so switching layout can + // redo it for another background instead of re-running the + // tessellation that produced identical geometry. Skipped when + // the resolution is a no-op — a preserved colour with no canvas + // fill looks the same under every background. + let bg_adapt: crate::scene::model::wire_model::BgAdapt = + (b.canvas_color || !b.preserve_color).then(|| { + Box::new(crate::scene::model::wire_model::BgAdaptInputs { + raw_color: b.color, + text_raw_colors: if b.preserve_color { + Vec::new() + } else { + b.text_verts.iter().map(|v| v.color).collect() + }, + contrast_bg: b.contrast_bg, + canvas_color: b.canvas_color, + preserve_color: b.preserve_color, + }) + }); let color = if b.canvas_color { bg_color } else if b.preserve_color { @@ -1615,6 +1634,7 @@ impl Batches { ); } WireModel { + bg_adapt, point_marker: b.point_marker, taper_widths: Vec::new(), pattern_stations: b.pattern_stations, @@ -2728,3 +2748,119 @@ fn is_unreasonable_extent(e: &EntityType) -> bool { _ => false, } } + +#[cfg(test)] +mod bg_resolution_tests { + use super::*; + use crate::scene::pipeline::text_gpu::TextVertex; + use crate::scene::view::render::{adapt_to_bg, resolve_colors_for_bg}; + + const DARK: [f32; 4] = [0.1, 0.1, 0.1, 1.0]; + const LIGHT: [f32; 4] = [0.95, 0.95, 0.95, 1.0]; + /// Near-white, not pure white: the case that makes `adapt_to_bg` + /// non-re-appliable, so it must survive a round trip through the recorded + /// raw colour. + const NEAR_WHITE: [f32; 4] = [0.97, 0.99, 0.96, 1.0]; + + fn entry( + color: [f32; 4], + contrast_bg: Option<[f32; 4]>, + preserve_color: bool, + canvas_color: bool, + ) -> BatchEntry { + let mut e = BatchEntry::new( + color, + contrast_bg, + preserve_color, + canvas_color, + 0.0, + [0.0; 8], + 1.0, + 0.0, + 0.0, + None, + 1, + false, + false, + false, + false, + true, + false, + ); + e.points = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]; + e.text_verts = vec![TextVertex { + pos: [0.0; 3], + pos_low: [0.0; 3], + uv: [0.0; 2], + color, + draw_depth: 0.0, + }]; + e + } + + /// Every combination `finalize` distinguishes, in one set. + fn batches() -> Batches { + Batches { + by_style: HashMap::default(), + closed: vec![ + entry(NEAR_WHITE, None, false, false), + entry([0.0, 0.0, 0.0, 1.0], None, false, false), + entry(NEAR_WHITE, None, true, false), + entry(NEAR_WHITE, Some(LIGHT), false, false), + entry(NEAR_WHITE, None, false, true), + ], + extra_wires: Vec::new(), + nested_prototypes: HashMap::default(), + } + } + + fn colors(wires: &[WireModel]) -> Vec<([f32; 4], Vec<[f32; 4]>)> { + wires + .iter() + .map(|w| (w.color, w.text_verts.iter().map(|v| v.color).collect())) + .collect() + } + + /// The contract the whole background-key removal rests on: resolving a set + /// built under one background for another must land exactly where building + /// it under that background would have. + #[test] + fn resolving_for_a_background_matches_building_under_it() { + for (from, to) in [(DARK, LIGHT), (LIGHT, DARK), (DARK, DARK), (LIGHT, LIGHT)] { + let mut resolved = batches().finalize("b", false, from); + resolve_colors_for_bg(&mut resolved, to); + let direct = batches().finalize("b", false, to); + assert_eq!( + colors(&resolved), + colors(&direct), + "built under {from:?}, resolved for {to:?}", + ); + } + } + + /// Safe to apply twice, and over a set mixing already-resolved wires with + /// freshly built ones — which is exactly what a memo hit plus a miss is. + #[test] + fn resolving_twice_changes_nothing() { + let mut once = batches().finalize("b", false, DARK); + resolve_colors_for_bg(&mut once, LIGHT); + let mut twice = once.clone(); + resolve_colors_for_bg(&mut twice, LIGHT); + assert_eq!(colors(&once), colors(&twice)); + } + + /// Why the raw colour is stored instead of re-adapting the resolved one. + /// If this ever starts passing, `raw_color` could be dropped — and if it + /// is removed while this still fails, near-white geometry loses its tint + /// on the first layout switch. + #[test] + fn adapt_to_bg_cannot_be_reapplied() { + let once = adapt_to_bg(NEAR_WHITE, LIGHT); + assert_eq!(once, [0.0, 0.0, 0.0, 1.0], "near-white snaps to pure black"); + assert_ne!( + adapt_to_bg(once, DARK), + adapt_to_bg(NEAR_WHITE, DARK), + "re-adapting the resolved colour must not equal adapting the raw one", + ); + } +} diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index b877827dd..a84d3d60d 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -923,6 +923,7 @@ fn tessellate_entity_inner( Vec::new() }; return vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1167,6 +1168,7 @@ fn tessellate_entity_inner( (ins.insert_point.z) as f32, ); let marker = WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1510,6 +1512,7 @@ fn lod_stub_wire( // LOD boundary. #19. let stored_color = if selected { WireModel::SELECTED } else { color }; WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1605,6 +1608,7 @@ fn lod_stub_wire_3d( } let stored_color = if selected { WireModel::SELECTED } else { color }; WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 6bf477734..5d0044060 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -220,6 +220,7 @@ fn point_cloud_wires( }; let (points, points_low) = points_to_ds(body_points); let mut wires = vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -474,6 +475,7 @@ pub fn tessellate( } }; out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -637,6 +639,7 @@ pub fn tessellate( None }; elem_wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1026,6 +1029,7 @@ pub fn tessellate( } } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1082,6 +1086,7 @@ pub fn tessellate( } } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1135,6 +1140,7 @@ pub fn tessellate( low.push(ll); } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1169,6 +1175,7 @@ pub fn tessellate( }); } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1233,6 +1240,7 @@ pub fn tessellate( (Vec::new(), Vec::new(), Vec::new()) }; out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1279,6 +1287,7 @@ pub fn tessellate( (Vec::new(), Vec::new(), Vec::new()) }; out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1322,6 +1331,7 @@ pub fn tessellate( // are empty → the early-return path above). if !sdf_verts.is_empty() { out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1358,6 +1368,7 @@ pub fn tessellate( if out.is_empty() { out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1428,6 +1439,7 @@ pub fn tessellate( .map(|[kx, ky, kz]| [kx, ky, kz]) .collect(); return vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1556,6 +1568,7 @@ pub fn tessellate( let point_marker = crate::entities::point::relative_marker_spec(entity, document); out.push(WireModel { + bg_adapt: None, point_marker, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1601,6 +1614,7 @@ pub fn tessellate( (Vec::new(), Vec::new(), Vec::new()) }; out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1637,6 +1651,7 @@ pub fn tessellate( if out.is_empty() { out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1695,6 +1710,7 @@ pub fn tessellate( &station_pieces, ); return vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: station_data, @@ -1741,6 +1757,7 @@ pub fn tessellate( // treatment as the Contour arm, restarting the dash per segment. let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris); return vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1791,6 +1808,7 @@ pub fn tessellate( let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris); let world_width = widths.iter().copied().fold(0.0f32, f32::max); return vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: widths, pattern_stations: Vec::new(), @@ -1899,6 +1917,7 @@ pub fn tessellate( _ => (Vec::new(), Vec::new()), }; vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 9642523f1..91c0e95f7 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1939,12 +1939,14 @@ pub struct Scene { /// Per-entity memo for the **resident** model wire set (`model_tile_wires_arc`, /// the one the main GPU render holds). Kept separate from `tess_memo` because /// the resident set is camera-INDEPENDENT (no view cull, no zoom LOD), so its - /// guard depends only on anno-scale / background — it survives pan/zoom, and a + /// guard depends only on anno-scale — it survives pan/zoom and layout switches, and a /// single-entity edit re-tessellates just the changed entity instead of the /// whole model. Sharing `tess_memo` would let the camera-dependent culled path /// thrash it on every zoom. (#perf) resident_tess_memo: RefCell>>>, - /// Guard hash for `resident_tess_memo` (anno-scale / bg only). + /// Guard hash for `resident_tess_memo` (anno-scale only). The background + /// is not part of it: it changes colours, not geometry, and + /// `resolve_colors_for_bg` reapplies it to the assembled set. resident_tess_guard: std::cell::Cell, /// Per-mutation delta journal: which handles changed on each `geometry_epoch` /// bump. Bounded ring ([`GEOMETRY_JOURNAL_CAP`]); a derived cache replays the @@ -9013,10 +9015,11 @@ impl Scene { let memo_misses; let t_build = perf.then(iced::time::Instant::now); let mut wires: Vec = if memo_active { - // Guard hash of everything tessellate_entity output depends on + // Guard hash of everything tessellate_entity's GEOMETRY depends on // besides the entity itself. A mismatch (zoom/tol, view, anno, - // offset, bg, entered viewport) means the memo is stale. For the - // resident path wpp/view_aabb are None, so this collapses to anno/bg. + // offset, entered viewport) means the memo is stale. For the + // resident path wpp/view_aabb are None, so this collapses to anno. + // The background is excluded on purpose — see the note below. let guard = { let mut g: u64 = 0xcbf2_9ce4_8422_2325; let mut mix = |x: u64| g = g.rotate_left(13) ^ x; @@ -9029,9 +9032,13 @@ impl Scene { mix(anno.to_bits() as u64); mix(annotation_scale_handle.map(|handle| handle.value()).unwrap_or(0)); mix(all_visible as u64); - for c in bg { - mix(c.to_bits() as u64); - } + // The background is deliberately NOT mixed in. It changes + // colours, never geometry — `local_wires_for` discards it with + // `let _ = bg_color;` and `Batches::finalize` applies it last — + // so a memo entry built under one background serves any other + // once `resolve_colors_for_bg` has run over the assembled set + // below. Keying on it made a layout switch re-tessellate the + // whole model to arrive at identical points. mix(avp.map(|h| h.value()).unwrap_or(0)); // SDF glyph quads bake the atlas UV of each tile, so a growth or // a re-bake (which rescale / rewind every UV) makes memoized text @@ -9133,6 +9140,14 @@ impl Scene { }; let build_ms = crate::perf::elapsed_ms(t_build); + // Resolve display colours for the live background. Memo hits were + // built under whatever background was current then, misses under this + // one; the pass recomputes both from their recorded raw colour, so a + // mixed set lands in the same place either way. + let t_colors = perf.then(iced::time::Instant::now); + crate::scene::view::render::resolve_colors_for_bg(&mut wires, bg); + let colors_ms = crate::perf::elapsed_ms(t_colors); + // Apply draw order via the cached index (O(1) block lookup). let t_sort = perf.then(iced::time::Instant::now); let mut sorted = false; @@ -9157,13 +9172,14 @@ impl Scene { } if perf { crate::perf_record!( - "[perf] wires-build total={:.1}ms sort_cache={:.1} visible={:.1} blk_cache={:.1} \ + "[perf] wires-build total={:.1}ms sort_cache={:.1} visible={:.1} blk_cache={:.1} colors={:.1} \ build={:.1} [classify={:.1} hits={:.1} tess={:.1} materialize={:.1}] sort={:.1}({}) \ entities={} memo_hit={} memo_miss={} wires={} memo={}", crate::perf::elapsed_ms(t_fn), sort_cache_ms, visible_ms, blk_ms, + colors_ms, build_ms, classify_ms, hits_ms, diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index bc5549f46..a7938bfa3 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -140,6 +140,40 @@ impl PointMarker { /// /// Linetype is encoded as a GPU-side dash pattern so the CPU never needs to /// split wires into per-dash segments. `pattern_length = 0.0` means solid. +/// Everything needed to resolve a wire's display colour against a background, +/// kept so the resolution can be redone for another background without +/// re-tessellating. +/// +/// `None` — the default — means the wire did not record its inputs and a +/// resolution pass must leave it alone. Only `Batches::finalize` fills it in. +pub type BgAdapt = Option>; + +/// The inputs `Batches::finalize` consumes when it resolves a colour. +/// +/// The flags are independent — a wire can paint the background itself while +/// its glyphs still adapt — so this mirrors that code's branches rather than +/// collapsing them into one enum. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct BgAdaptInputs { + /// Colour before adaptation. Resolution always recomputes from here rather + /// than from `WireModel::color`, so it is idempotent and safe over a set + /// mixing memo hits with freshly tessellated wires. `adapt_to_bg` is + /// **not** re-appliable: a near-white colour snaps to pure black on a + /// light background, and pure black comes back pure *white* on a dark one, + /// losing the tint. + pub raw_color: [f32; 4], + /// Glyph colours before adaptation, parallel to `WireModel::text_verts`. + /// Empty when the wire has no glyphs or their colour never adapts. + pub text_raw_colors: Vec<[f32; 4]>, + /// Adapt against this instead of the live background: an MTEXT carrying + /// its own background fill. + pub contrast_bg: Option<[f32; 4]>, + /// The wire paints the background; its colour *is* the live background. + pub canvas_color: bool, + /// Authored colour. The background must not touch it, glyphs included. + pub preserve_color: bool, +} + #[derive(Clone, Debug)] pub struct WireModel { /// Unique identifier — the handle value as a decimal string. @@ -156,6 +190,9 @@ pub struct WireModel { pub point_marker: Option, /// RGBA colour in [0, 1]. pub color: [f32; 4], + /// How `color` and `text_verts` follow the background, or `None` when this + /// wire's colour must not be re-resolved. + pub bg_adapt: BgAdapt, /// Whether this wire is currently selected. #[allow(dead_code)] pub selected: bool, @@ -315,6 +352,7 @@ impl WireModel { /// Create a solid wire (no dash pattern, 1px weight). pub fn solid(name: String, points: Vec<[f32; 3]>, color: [f32; 4], selected: bool) -> Self { Self { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -688,6 +726,7 @@ impl Default for WireModel { points: Vec::new(), points_low: Vec::new(), color: Self::WHITE, + bg_adapt: None, selected: false, pattern_length: 0.0, pattern: [0.0; 8], diff --git a/src/scene/pick/xclip.rs b/src/scene/pick/xclip.rs index 40786c911..26f0b2982 100644 --- a/src/scene/pick/xclip.rs +++ b/src/scene/pick/xclip.rs @@ -296,6 +296,7 @@ pub fn frame_wire( lo.push([lx, ly, 0.0]); } WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/scene/text/complex_lt.rs b/src/scene/text/complex_lt.rs index 1cb8c7101..0cac9c0f2 100644 --- a/src/scene/text/complex_lt.rs +++ b/src/scene/text/complex_lt.rs @@ -309,6 +309,7 @@ pub fn apply_along( .into_iter() .filter(|s| s.len() >= 2) .map(|pts| WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -357,6 +358,7 @@ pub fn apply_along( xy = xy.max(y); } out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 768f20ce4..6d014f905 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -3145,6 +3145,43 @@ pub(crate) fn adapt_to_bg(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] { } } +/// Re-resolve display colours for `bg`, for wires that recorded what their +/// resolution consumed. +/// +/// This is what lets a set tessellated under one background be shown under +/// another without rebuilding it: the geometry never depended on the +/// background, only the colours did — see the `let _ = bg_color;` in +/// `block_cache::local_wires_for`. +/// +/// Every colour is recomputed from `raw_color`, never from the current +/// `color`, so the pass is idempotent and safe over a set that mixes memoized +/// wires with freshly tessellated ones. Recomputing from `color` would not be: +/// `adapt_to_bg` maps a near-white colour to pure black, and pure black back +/// to pure *white*, so a second application loses the original tint. +/// +/// Wires with `bg_adapt: None` — everything not built by `Batches::finalize` — +/// are left alone. +pub(crate) fn resolve_colors_for_bg(wires: &mut [WireModel], bg: [f32; 4]) { + for wire in wires { + let Some(adapt) = wire.bg_adapt.as_deref() else { + continue; + }; + let contrast = adapt.contrast_bg.unwrap_or(bg); + wire.color = if adapt.canvas_color { + bg + } else if adapt.preserve_color { + adapt.raw_color + } else { + adapt_to_bg(adapt.raw_color, contrast) + }; + if !adapt.preserve_color { + for (vertex, raw) in wire.text_verts.iter_mut().zip(&adapt.text_raw_colors) { + vertex.color = adapt_to_bg(*raw, contrast); + } + } + } +} + // ── Primitive builder helpers (called by ViewportPane's shader::Program impl) ── impl Scene { From cb88f2ded63557214e0cd0178e8392b0a14d850f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 5 Sep 2026 19:03:16 +0300 Subject: [PATCH 23/24] Accelerate native MCP workflows --- docs/automation/README.md | 45 ++- docs/automation/mcp_eval.py | 182 +++++++++ docs/automation/mcp_smoke.py | 7 +- src/app/automation.rs | 207 +++++++++-- src/app/control/mod.rs | 256 ++++++++++++- src/mcp.rs | 692 ++++++++++++++++++++++++++++++++--- 6 files changed, 1291 insertions(+), 98 deletions(-) create mode 100644 docs/automation/mcp_eval.py diff --git a/docs/automation/README.md b/docs/automation/README.md index 18775e0ae..d812c6244 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -13,9 +13,9 @@ The client must start that command over standard input/output. If it asks for th The server provides four tools: - `ocs_sessions` finds running editor sessions and opens OpenCADStudio when none exists. -- `ocs_read` reads document state, commands, entities, layers, properties, measurements, history, events, and operation status. -- `ocs_execute` performs semantic commands and input against the real editor. -- `ocs_capture` returns a PNG of the current window for visual verification. +- `ocs_read` reads document state, command manifests, entities, layers, properties, kernel measurements and spatial relationships, history, events, and operation status. +- `ocs_execute` performs one operation or a sequential batch against the real editor. +- `ocs_capture` returns a bounded PNG of the drawing viewport or complete window. The normal flow is to call `ocs_sessions`, read the chosen session with `ocs_read`, then pass the returned document state into `ocs_execute`. For example, an undo request has this shape: @@ -48,13 +48,52 @@ A complete command can be sent with `run`. Prompt answers are separated by space } ``` +Use `batch` when the steps are already known. OCS supplies each step with the state produced by the previous one and stops on the first failure. `completed_steps` and `next_step` show exactly what committed: + +```json +{ + "session_id": "SESSION_FROM_OCS_SESSIONS", + "response_detail": "changed_entities", + "request": { + "op": "batch", + "request_id": "shape-1", + "steps": [ + {"op": "run", "cmd": "LINE 0,0 10,0"}, + {"op": "run", "cmd": "CIRCLE 5,5 2"} + ] + } +} +``` + +Execute responses use `response_detail: "compact"` by default and return only the state needed for the next edit. Use `changed_entities` to receive the current geometry of affected handles in the same response, or `full` when the complete editor state is needed. + +`ocs_read` query accepts exact handles, type/layer filters, field projection, world-XY bounds, nearest-curve ranking, closed-curve containment, and exact intersections between two planar curves. Nearest points, containment, curve length, area, and intersections are calculated by the geometry kernel: + +```json +{ + "session_id": "SESSION_FROM_OCS_SESSIONS", + "op": "query", + "parameters": {"intersections": ["2A", "31"]} +} +``` + For unfamiliar or conditional commands, use `start`, then inspect `state.command` in every response. Its `accepts` array gives the valid MCP input kinds, `options` gives the current tokens, and `input_example` gives the next request shape. Add the current state fields and a new `request_id` to each step. Every `ocs_execute` request requires a caller-generated `request_id`. Reuse that ID only to retry the identical request after a timeout, together with the same session ID, document ID, expected revision, and selection. Commands report `waiting_input` while more input is required and asynchronous work remains `running` until its real callback finishes. Geometry stays in OpenCADStudio and its geometry kernel. +`ocs_capture` defaults to the drawing viewport and a longest edge of 1600 pixels. Set `scope` to `window` for the full interface or change `max_dimension` between 256 and 4096. + +Clients using MCP 2026-07-28 can advertise `io.modelcontextprotocol/tasks`. OCS then returns a standard task handle when an operation is still running and accepts `tasks/get`, `tasks/update`, and `tasks/cancel`. Other clients continue to receive the existing operation status and can read it with `ocs_read`. + The source-tree protocol smoke test treats the executable as a black box and uses only the Python standard library. It checks both supported protocol styles, the published schemas, structured errors, and the four-tool surface: ```sh cargo build python3 docs/automation/mcp_smoke.py target/debug/OpenCADStudio ``` + +The repeatable live-editor evaluation draws three isolated entities in one batch, verifies exact intersections, nearest geometry and kernel measurements, removes the entities, and reports call count, elapsed time and wire bytes: + +```sh +python3 docs/automation/mcp_eval.py target/debug/OpenCADStudio +``` diff --git a/docs/automation/mcp_eval.py b/docs/automation/mcp_eval.py new file mode 100644 index 000000000..956633c4f --- /dev/null +++ b/docs/automation/mcp_eval.py @@ -0,0 +1,182 @@ +"""Repeatable black-box speed and capability check for the native MCP server.""" + +import json +from pathlib import Path +import subprocess +import sys +import time + + +MODERN = "2026-07-28" +META = { + "io.modelcontextprotocol/protocolVersion": MODERN, + "io.modelcontextprotocol/clientInfo": {"name": "ocs-eval", "version": "1"}, + "io.modelcontextprotocol/clientCapabilities": { + "extensions": {"io.modelcontextprotocol/tasks": {}} + }, +} + + +class Client: + def __init__(self, server: Path) -> None: + self.process = subprocess.Popen( + [str(server), "--mcp"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + self.serial = 0 + self.rpc_calls = 0 + self.tool_calls = 0 + self.tasks = 0 + self.request_bytes = 0 + self.response_bytes = 0 + + def rpc(self, method: str, params: dict) -> dict: + assert self.process.stdin and self.process.stdout + self.serial += 1 + payload = {"jsonrpc": "2.0", "id": self.serial, "method": method, "params": params} + wire = json.dumps(payload, separators=(",", ":")) + self.request_bytes += len(wire.encode()) + self.rpc_calls += 1 + self.process.stdin.write(wire + "\n") + self.process.stdin.flush() + line = self.process.stdout.readline() + self.response_bytes += len(line.encode()) + response = json.loads(line) + if "error" in response: + raise RuntimeError(response["error"]) + return response["result"] + + def tool(self, name: str, arguments: dict) -> dict: + self.tool_calls += 1 + result = self.rpc("tools/call", {"name": name, "arguments": arguments, "_meta": META}) + if result.get("resultType") == "task": + self.tasks += 1 + task_id = result["taskId"] + poll_interval = result.get("pollIntervalMs", 250) + while True: + time.sleep(poll_interval / 1000) + task = self.rpc("tasks/get", {"taskId": task_id, "_meta": META}) + if task["status"] in {"failed", "cancelled"}: + raise RuntimeError(task) + if task["status"] == "completed": + result = task["result"] + break + structured = result.get("structuredContent") + if structured is None: + raise RuntimeError(result) + if structured.get("ok") is False: + raise RuntimeError(structured) + return structured + + def close(self) -> None: + assert self.process.stdin + self.process.stdin.close() + if self.process.wait(timeout=5) != 0: + raise RuntimeError(self.process.stderr.read() if self.process.stderr else "MCP exited") + + +def main() -> None: + server = Path(sys.argv[1] if len(sys.argv) > 1 else "target/debug/OpenCADStudio").resolve() + client = Client(server) + started = time.perf_counter() + handles: list[str] = [] + session: str | None = None + base = 0 + succeeded = False + try: + discovery = client.rpc("server/discover", {"_meta": META}) + assert "io.modelcontextprotocol/tasks" in discovery["capabilities"]["extensions"] + sessions = client.tool("ocs_sessions", {"launch_if_none": True})["result"] + session = sessions[0]["session_id"] + active = next( + document for document in sessions[0]["documents"] + if document["id"] == sessions[0]["document_id"] + ) + if active.get("start"): + client.tool( + "ocs_execute", + {"session_id": session, "request": {"op": "new", "request_id": "eval-new"}}, + ) + + base = 1_000_000 + int(time.time()) % 100_000 + draw = client.tool( + "ocs_execute", + { + "session_id": session, + "response_detail": "changed_entities", + "wait_seconds": 0, + "request": { + "op": "batch", + "request_id": f"eval-draw-{base}", + "steps": [ + {"op": "run", "cmd": f"LINE {base-5},0 {base+5},0"}, + {"op": "run", "cmd": f"LINE {base},-5 {base},5"}, + {"op": "run", "cmd": f"CIRCLE {base+20},0 2"}, + ], + }, + }, + ) + assert draw["completed_steps"] == 3, draw + assert client.tasks > 0, "wait_seconds=0 did not exercise MCP Tasks" + changed = draw["changed_entities"] + line_handles = [entity["handle"] for entity in changed if entity["type"] == "Line"] + circle_handles = [entity["handle"] for entity in changed if entity["type"] == "Circle"] + assert len(line_handles) == 2 and len(circle_handles) == 1, changed + handles = line_handles + circle_handles + + crossings = client.tool( + "ocs_read", + {"session_id": session, "op": "query", "parameters": {"intersections": line_handles}}, + ) + assert crossings["count"] == 1 and crossings["intersections"][0]["point"] == [base, 0.0] + + nearest = client.tool( + "ocs_read", + {"session_id": session, "op": "query", "parameters": {"near": [base + 20, 0], "handles": handles, "limit": 1}}, + ) + assert nearest["entities"][0]["handle"] == circle_handles[0] + + measured = client.tool( + "ocs_read", + {"session_id": session, "op": "measure", "parameters": {"handles": circle_handles}}, + ) + assert abs(measured["measurements"][0]["curve"]["area"] - 12.566370614359172) < 1e-9 + succeeded = True + finally: + if handles and session: + try: + client.tool( + "ocs_execute", + { + "session_id": session, + "request": { + "op": "batch", + "request_id": f"eval-clean-{base}", + "steps": [ + {"op": "select", "handles": handles}, + {"op": "run", "cmd": "ERASE"}, + ], + }, + }, + ) + except Exception: + pass + elapsed = (time.perf_counter() - started) * 1000 + metrics = { + "ok": succeeded, + "elapsed_ms": round(elapsed, 1), + "tool_calls": client.tool_calls, + "tasks": client.tasks, + "rpc_calls": client.rpc_calls, + "request_bytes": client.request_bytes, + "response_bytes": client.response_bytes, + } + client.close() + print(json.dumps(metrics, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/docs/automation/mcp_smoke.py b/docs/automation/mcp_smoke.py index 6c651d3af..e28fb6867 100644 --- a/docs/automation/mcp_smoke.py +++ b/docs/automation/mcp_smoke.py @@ -52,7 +52,8 @@ def legacy(server: Path) -> None: names = set(definitions) assert names == TOOLS, names execute_request = definitions["ocs_execute"]["inputSchema"]["properties"]["request"] - assert len(execute_request["oneOf"]) == 14 + assert len(execute_request["oneOf"]) == 15 + assert execute_request["properties"]["steps"]["maxItems"] == 64 assert execute_request["properties"]["cmd"]["examples"][0] == "LINE 0,0 10,10" assert execute_request["properties"]["kind"]["enum"] == [ "text", "token", "point", "entity", "structure", "selection", "enter" @@ -86,6 +87,7 @@ def modern(server: Path) -> None: assert discovered["result"]["resultType"] == "complete" assert discovered["result"]["ttlMs"] >= 0 assert discovered["result"]["cacheScope"] in {"public", "private"} + assert "io.modelcontextprotocol/tasks" in discovered["result"]["capabilities"]["extensions"] tools = request(process, { "jsonrpc": "2.0", @@ -97,6 +99,9 @@ def modern(server: Path) -> None: assert tools["result"]["resultType"] == "complete" assert tools["result"]["ttlMs"] >= 0 assert tools["result"]["cacheScope"] in {"public", "private"} + definitions = {tool["name"]: tool for tool in tools["result"]["tools"]} + assert definitions["ocs_execute"]["inputSchema"]["properties"]["response_detail"]["default"] == "compact" + assert definitions["ocs_capture"]["inputSchema"]["properties"]["scope"]["default"] == "viewport" missing_cmd = request(process, { "jsonrpc": "2.0", diff --git a/src/app/automation.rs b/src/app/automation.rs index 3836ada6b..a3c33d0a0 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -138,9 +138,9 @@ fn v3(v: acadrust::types::Vector3) -> Value { json!([v.x, v.y, v.z]) } -/// One entity as JSON: handle, type, layer, plus basic geometry for the common -/// types (others carry only the common fields). -fn entity_json(e: &acadrust::EntityType) -> Value { +/// One entity as JSON. Summary mode carries identity only, geometry adds the +/// entity's defining values, and full also includes its world bounds. +fn entity_json(e: &acadrust::EntityType, detail: &str) -> Value { use acadrust::EntityType as E; let c = e.common(); let mut obj = json!({ @@ -148,6 +148,9 @@ fn entity_json(e: &acadrust::EntityType) -> Value { "type": crate::entities::names::ui_name(e), "layer": c.layer, }); + if detail == "summary" { + return obj; + } let map = obj.as_object_mut().expect("json object"); match e { E::Line(l) => { @@ -194,9 +197,41 @@ fn entity_json(e: &acadrust::EntityType) -> Value { } _ => {} } + if detail == "full" { + let bounds = e.as_entity().bounding_box(); + map.insert("bounds".into(), json!({ + "min":[bounds.min.x,bounds.min.y,bounds.min.z], + "max":[bounds.max.x,bounds.max.y,bounds.max.z] + })); + } obj } +fn request_point(req: &Value, key: &str) -> Option<[f64; 2]> { + let values = req[key].as_array()?; + if !(2..=3).contains(&values.len()) { + return None; + } + let x = values[0].as_f64()?; + let y = values[1].as_f64()?; + (x.is_finite() && y.is_finite()).then_some([x, y]) +} + +fn request_handle(value: &Value) -> Option { + value.as_str() + .and_then(|value| u64::from_str_radix(value.trim_start_matches("0x"), 16).ok()) + .map(acadrust::Handle::new) +} + +fn projected_fields(mut entity: Value, fields: Option<&Vec>) -> Value { + let Some(fields) = fields else { return entity }; + let Some(source) = entity.as_object_mut() else { return entity }; + let keep: std::collections::HashSet<&str> = + fields.iter().filter_map(Value::as_str).collect(); + source.retain(|key, _| key == "handle" || keep.contains(key.as_str())); + entity +} + impl OpenCADStudio { /// Handle one JSON request line and return the JSON response. #[cfg(any(test, not(target_arch = "wasm32")))] @@ -445,38 +480,156 @@ impl OpenCADStudio { Ok(()) } - /// List entities (handle, type, layer, basic geometry), optionally filtered - /// by `type` and/or `layer`, capped by `limit` (default 1000). + /// Query entities by identity, metadata and exact plane-curve relationships. fn entity_query(&self, req: &Value) -> Value { let i = self.active_tab; + let tab = &self.tabs[i]; + if let Some(pair) = req["intersections"].as_array() { + if pair.len() != 2 { + return err("query intersections expects exactly two handles"); + } + let Some(first) = request_handle(&pair[0]) else { + return err("query intersections contains an invalid first handle"); + }; + let Some(second) = request_handle(&pair[1]) else { + return err("query intersections contains an invalid second handle"); + }; + let Some(first_entity) = tab.scene.document.get_entity(first) else { + return err("query intersections first entity does not exist"); + }; + let Some(second_entity) = tab.scene.document.get_entity(second) else { + return err("query intersections second entity does not exist"); + }; + let Some(first_curve) = crate::entities::curve::entity_curve_xy(first_entity) else { + return err("query intersections first entity is not a planar curve"); + }; + let Some(second_curve) = crate::entities::curve::entity_curve_xy(second_entity) else { + return err("query intersections second entity is not a planar curve"); + }; + let crossings = cadkernel::geom2d::intersect( + &first_curve, + &second_curve, + cadkernel::geom2d::Tolerance::default(), + ); + return json!({ + "ok":true, + "document_id":tab.id, + "geometry_revision":tab.scene.geometry_epoch, + "handles":[format!("{:X}",first.value()),format!("{:X}",second.value())], + "count":crossings.len(), + "intersections":crossings.into_iter().map(|crossing|json!({ + "point":[crossing.point[0],crossing.point[1]], + "parameter_first":crossing.t_a, + "parameter_second":crossing.t_b + })).collect::>() + }); + } + let type_filter = req["type"].as_str(); let layer_filter = req["layer"].as_str(); - let limit = req["limit"].as_u64().unwrap_or(1000).min(10000) as usize; - let offset = req["offset"].as_u64().unwrap_or(0); - - let mut entities = Vec::new(); - let mut matched = 0u64; - for e in self.tabs[i].scene.document.entities() { - if let Some(tf) = type_filter { - if !crate::entities::names::ui_name(e).eq_ignore_ascii_case(tf) { - continue; - } - } - if let Some(lf) = layer_filter { - if e.common().layer != lf { - continue; - } - } - matched += 1; - if matched > offset && entities.len() < limit { - entities.push(entity_json(e)); - } + let handles: Option> = req["handles"] + .as_array() + .map(|values| values.iter().filter_map(request_handle).map(|h| h.value()).collect()) + .or_else(|| request_handle(&req["handle"]) + .map(|handle| std::iter::once(handle.value()).collect())); + let near = request_point(req, "near"); + let contains = request_point(req, "contains_point"); + let bounds = req["bounds"].as_array().and_then(|values| { + (values.len() == 4).then(|| Some([ + values[0].as_f64()?, values[1].as_f64()?, + values[2].as_f64()?, values[3].as_f64()?, + ])).flatten() + }); + if req.get("handles").is_some() && handles.as_ref().is_some_and(|parsed| { + parsed.len() != req["handles"].as_array().map_or(0, Vec::len) + }) { + return err("query handles contains an invalid hexadecimal handle"); } + if req.get("handle").is_some() && request_handle(&req["handle"]).is_none() { + return err("query handle must be hexadecimal"); + } + if req.get("near").is_some() && near.is_none() { + return err("query near expects two or three finite coordinates"); + } + if req.get("contains_point").is_some() && contains.is_none() { + return err("query contains_point expects two or three finite coordinates"); + } + if req.get("bounds").is_some() && bounds.is_none_or(|bounds| { + !bounds.iter().all(|value| value.is_finite()) + || bounds[0] > bounds[2] || bounds[1] > bounds[3] + }) { + return err("query bounds expects finite [min_x,min_y,max_x,max_y]"); + } + let detail = req["detail"].as_str().unwrap_or("geometry"); + if !matches!(detail, "summary" | "geometry" | "full") { + return err("query detail must be summary, geometry or full"); + } + let limit = req["limit"].as_u64().unwrap_or(1000).min(10000) as usize; + let offset = req["offset"].as_u64().unwrap_or(0) as usize; + + let mut matched = Vec::new(); + for e in tab.scene.document.entities() { + if handles.as_ref().is_some_and(|handles| { + !handles.contains(&e.common().handle.value()) + }) { + continue; + } + if type_filter.is_some_and(|value| { + !crate::entities::names::ui_name(e).eq_ignore_ascii_case(value) + }) || layer_filter.is_some_and(|value| e.common().layer != value) { + continue; + } + if let Some(bounds) = bounds { + let entity_bounds = e.as_entity().bounding_box(); + if entity_bounds.max.x < bounds[0] || entity_bounds.max.y < bounds[1] + || entity_bounds.min.x > bounds[2] || entity_bounds.min.y > bounds[3] + { + continue; + } + } + let curve = (near.is_some() || contains.is_some()) + .then(|| crate::entities::curve::entity_curve_xy(e)).flatten(); + if let Some(point) = contains { + let Some(curve) = curve.as_ref().filter(|curve| curve.is_closed()) else { + continue; + }; + if !cadkernel::geom2d::contains( + std::slice::from_ref(curve), point, + cadkernel::geom2d::Tolerance::default(), + ) { + continue; + } + } + let nearest = near.and_then(|point| curve.as_ref() + .map(|curve| cadkernel::geom2d::closest_point(curve, point))); + if near.is_some() && nearest.is_none() { + continue; + } + let mut entity = entity_json(e, detail); + if let Some(nearest) = nearest { + let object = entity.as_object_mut().expect("entity JSON object"); + object.insert("distance".into(), json!(nearest.distance)); + object.insert("closest_point".into(), + json!([nearest.point[0], nearest.point[1]])); + object.insert("parameter".into(), json!(nearest.t)); + } + matched.push((nearest.map(|nearest| nearest.distance), entity)); + } + if near.is_some() { + matched.sort_by(|left, right| left.0.partial_cmp(&right.0) + .unwrap_or(std::cmp::Ordering::Equal)); + } + let count = matched.len(); + let fields = req["fields"].as_array(); + let entities: Vec = matched.into_iter().skip(offset).take(limit) + .map(|(_, entity)| projected_fields(entity, fields)).collect(); json!({ "ok": true, - "count": matched, + "document_id":tab.id, + "geometry_revision":tab.scene.geometry_epoch, + "count": count, "returned": entities.len(), - "next_offset": if offset + (entities.len() as u64) < matched { Some(offset + entities.len() as u64) } else { None }, + "next_offset": (offset + entities.len() < count).then_some(offset + entities.len()), "entities": entities, }) } diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 64efd2f7e..7bc23ef39 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -172,10 +172,110 @@ fn command_examples(name: &str) -> &'static [&'static str] { "CIRCLE" => &["CIRCLE 5,5 3"], "PLINE" => &["PLINE 0,0 10,0 10,10 C"], "ZOOM" => &["ZOOM EXTENTS"], + "MOVE" => &["MOVE 0,0 100,0"], + "ROTATE" => &["ROTATE 0,0 90"], + "UCS" => &["UCS Z 90"], + "PDMODE" => &["PDMODE 3"], + "LTSCALE" => &["LTSCALE 2.5"], _ => &[], } } +fn command_category(name: &str) -> &'static str { + if [ + "LINE", "CIRCLE", "PLINE", "ARC", "ELLIPSE", "POINT", "SPLINE", "RECT", "RECTANG", + "POLYGON", "HATCH", "RAY", "XLINE", "3DPOLY", "MLINE", "TEXT", "MTEXT", "TABLE", + ] + .contains(&name) + { + "create" + } else if [ + "MOVE", + "COPY", + "ROTATE", + "SCALE", + "MIRROR", + "ERASE", + "TRIM", + "EXTEND", + "OFFSET", + "FILLET", + "CHAMFER", + "BREAK", + "JOIN", + "EXPLODE", + "STRETCH", + "ARRAY", + "PEDIT", + "SPLINEDIT", + "REVERSE", + "ALIGN", + ] + .contains(&name) + { + "modify" + } else if ["DIST", "ID", "AREA", "MEASURE", "MEASUREGEOM"].contains(&name) { + "inspect" + } else if ["ZOOM", "PAN", "UCS", "PLAN", "VPORTS", "3DORBIT"].contains(&name) { + "view" + } else if name.starts_with("DIM") || ["LEADER", "MLEADER", "TOLERANCE"].contains(&name) { + "annotate" + } else if [ + "BOX", + "CYLINDER", + "CONE", + "SPHERE", + "TORUS", + "WEDGE", + "EXTRUDE", + "REVOLVE", + "SWEEP", + "LOFT", + "UNION", + "SUBTRACT", + "INTERSECT", + "PRESSPULL", + ] + .contains(&name) + { + "model" + } else { + "other" + } +} + +fn command_selection_policy(name: &str) -> &'static str { + if [ + "ARRAY", + "ARRAYPATH", + "ARRAYPOLAR", + "ARRAYRECT", + "BLOCK", + "COPY", + "COPYCLIP", + "CUTCLIP", + "ERASE", + "EXPLODE", + "GROUP", + "LAYFRZ", + "LAYLCK", + "LAYMCUR", + "LAYOFF", + "LAYULK", + "MIRROR", + "MOVE", + "ROTATE", + "SCALE", + "STRETCH", + ] + .contains(&name) + { + "current_selection_or_prompt" + } else { + "command_defined" + } +} + fn active_command_metadata(command: &dyn crate::command::CadCommand) -> Value { let options = command.options(); let mut accepts = Vec::new(); @@ -261,7 +361,7 @@ impl OpenCADStudio { "mtext_editor":self.mtext_editor.as_ref().map(|e|json!({"text":e.content.text(),"height":e.height,"style":e.style})), "text_editor":self.text_inline.is_some(),"event_cursor":self.control.serial, "operation":self.control.pending.as_ref().map(|p| &p.id), - "capabilities":["commands","step_input","entity_pick","structure_pick","selection","properties","layers","history","documents","events","capture","measure"] + "capabilities":["commands","command_manifest","step_input","batch","compact_results","entity_pick","structure_pick","selection","properties","layers","history","documents","events","capture","viewport_capture","measure","spatial_query"] }) } @@ -359,6 +459,11 @@ impl OpenCADStudio { "ok":true, "command":{ "name":name, + "category":command_category(name), + "preconditions":{ + "document":true, + "selection":command_selection_policy(name) + }, "batch":{ "op":"run", "syntax":"Command name followed by prompt answers separated by spaces. Points use x,y or x,y,z; options use their token.", @@ -368,16 +473,27 @@ impl OpenCADStudio { "op":"start", "request":{"op":"start","cmd":name}, "next":"Follow state.command.accepts, options and input_example after every step." - } + }, + "completion":"completed is final; waiting_input requires another input step; failed includes the reason." } }), Task::none(), ); } + if let Some(search) = req["search"].as_str().filter(|search| !search.is_empty()) { + let search = search.to_ascii_uppercase(); + names.retain(|name| name.contains(&search)); + } + let count = names.len(); + let offset = req["offset"].as_u64().unwrap_or(0) as usize; + let limit = req["limit"].as_u64().unwrap_or(200).min(1000) as usize; + let commands: Vec = names.into_iter().skip(offset).take(limit).collect(); return ( json!({ - "ok":true,"commands":names,"actions":actions::NAMES, - "detail_parameters":{"name":"LINE"}, + "ok":true,"count":count,"returned":commands.len(), + "next_offset":(offset + commands.len() < count).then_some(offset + commands.len()), + "commands":commands,"actions":actions::NAMES, + "detail_parameters":{"name":"LINE"},"search_parameter":{"search":"LINE"}, "guidance":"Request one command by name for batch examples and interactive input guidance." }), Task::none(), @@ -857,7 +973,16 @@ impl OpenCADStudio { h.isolines.max(0) as usize, ) }); - out.push(json!({"handle":format!("{:X}",handle.value()),"type":crate::entities::names::ui_name(entity),"bounds":{"min":[bb.min.x,bb.min.y,bb.min.z],"max":[bb.max.x,bb.max.y,bb.max.z]},"mesh":metrics.map(|m|json!({"vertices":m.metrics.vertices,"triangles":m.metrics.triangles,"surface_area":m.metrics.surface_area,"volume":m.metrics.volume,"centroid":m.metrics.centroid}))})); + let curve = crate::entities::curve::entity_curve(entity).map(|planar| { + let length = planar.curve.length(); + json!({ + "bounded":length.is_finite(), + "closed":planar.curve.is_closed(), + "length":length.is_finite().then_some(length), + "area":planar.curve.is_closed().then(|| planar.curve.enclosed_area().abs()) + }) + }); + out.push(json!({"handle":format!("{:X}",handle.value()),"type":crate::entities::names::ui_name(entity),"bounds":{"min":[bb.min.x,bb.min.y,bb.min.z],"max":[bb.max.x,bb.max.y,bb.max.z]},"curve":curve,"mesh":metrics.map(|m|json!({"vertices":m.metrics.vertices,"triangles":m.metrics.triangles,"surface_area":m.metrics.surface_area,"volume":m.metrics.volume,"centroid":m.metrics.centroid}))})); } json!({"ok":true,"document_id":tab.id,"geometry_revision":tab.scene.geometry_epoch,"measurements":out}) } @@ -869,17 +994,66 @@ impl OpenCADStudio { ) { let result = (|| -> Result { let s = screenshot.ok_or("Renderer did not return an image")?; - image::save_buffer_with_format( - &path, - &s.rgba, - s.size.width, - s.size.height, - image::ColorType::Rgba8, - image::ImageFormat::Png, - ) - .map_err(|e| e.to_string())?; + let requested_scope = self + .control + .pending + .as_ref() + .and_then(|pending| pending.request["scope"].as_str()) + .unwrap_or("window"); + let max_dimension = self + .control + .pending + .as_ref() + .and_then(|pending| pending.request["max_dimension"].as_u64()) + .unwrap_or(1600) + .clamp(256, 4096) as u32; + let mut image = + image::RgbaImage::from_raw(s.size.width, s.size.height, s.rgba.to_vec()) + .ok_or("Renderer returned malformed image data")?; + let mut actual_scope = "window"; + if requested_scope == "viewport" { + if let Some(bounds) = crate::ui::wrap_bar::dropdown_bounds( + crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, + ) { + let scale = s.scale_factor; + let left = (bounds.x * scale).floor().clamp(0.0, image.width() as f32) as u32; + let top = (bounds.y * scale).floor().clamp(0.0, image.height() as f32) as u32; + let right = ((bounds.x + bounds.width) * scale) + .ceil() + .clamp(0.0, image.width() as f32) as u32; + let bottom = ((bounds.y + bounds.height) * scale) + .ceil() + .clamp(0.0, image.height() as f32) as u32; + if right > left && bottom > top { + image = image::imageops::crop_imm( + &image, + left, + top, + right - left, + bottom - top, + ) + .to_image(); + actual_scope = "viewport"; + } + } + } + let longest = image.width().max(image.height()); + if longest > max_dimension { + let scale = max_dimension as f64 / longest as f64; + let width = ((image.width() as f64 * scale).round() as u32).max(1); + let height = ((image.height() as f64 * scale).round() as u32).max(1); + image = image::imageops::resize( + &image, + width, + height, + image::imageops::FilterType::Triangle, + ); + } + image + .save_with_format(&path, image::ImageFormat::Png) + .map_err(|e| e.to_string())?; Ok( - json!({"path":path,"width":s.size.width,"height":s.size.height,"scale_factor":s.scale_factor,"document_id":self.tabs[self.active_tab].id,"revision":self.tabs[self.active_tab].edit_revision,"camera_revision":self.tabs[self.active_tab].scene.camera_generation}), + json!({"path":path,"scope":actual_scope,"width":image.width(),"height":image.height(),"scale_factor":s.scale_factor,"document_id":self.tabs[self.active_tab].id,"revision":self.tabs[self.active_tab].edit_revision,"camera_revision":self.tabs[self.active_tab].scene.camera_generation}), ) })(); match result { @@ -990,6 +1164,8 @@ mod tests { "PLINE 0,0 10,0 10,10 C" ); assert_eq!(detail["command"]["interactive"]["op"], "start"); + assert_eq!(detail["command"]["category"], "create"); + assert_eq!(detail["command"]["preconditions"]["document"], true); assert_eq!( app.control_request(json!({"op":"commands","name":"NOT_A_COMMAND"})) .0["code"], @@ -997,6 +1173,56 @@ mod tests { ); } #[test] + fn control_queries_exact_curve_relationships_and_metrics() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + request(&mut app, json!({"op":"run","cmd":"LINE -5,0 5,0"})); + request(&mut app, json!({"op":"run","cmd":"LINE 0,-5 0,5"})); + request(&mut app, json!({"op":"run","cmd":"CIRCLE 20,0 2"})); + + let lines = app.control_request(json!({"op":"query","type":"Line"})).0; + let first = lines["entities"][0]["handle"].as_str().unwrap(); + let second = lines["entities"][1]["handle"].as_str().unwrap(); + let intersections = app + .control_request(json!({"op":"query","intersections":[first,second]})) + .0; + assert_eq!(intersections["count"], 1, "{intersections}"); + assert_eq!( + intersections["intersections"][0]["point"], + json!([0.0, 0.0]) + ); + + let nearest = app + .control_request(json!({"op":"query","near":[20.0,0.0],"limit":1})) + .0; + assert_eq!(nearest["entities"][0]["type"], "Circle"); + assert_eq!(nearest["entities"][0]["distance"], 2.0); + let circle = nearest["entities"][0]["handle"] + .as_str() + .unwrap() + .to_owned(); + + let contains = app + .control_request(json!({"op":"query","contains_point":[20.0,0.0]})) + .0; + assert_eq!(contains["count"], 1, "{contains}"); + assert_eq!(contains["entities"][0]["handle"], circle); + + let projected = app + .control_request( + json!({"op":"query","handle":circle,"detail":"full","fields":["type"]}), + ) + .0; + assert_eq!(projected["entities"][0].as_object().unwrap().len(), 2); + + let measured = app + .control_request(json!({"op":"measure","handles":[circle]})) + .0; + let curve = &measured["measurements"][0]["curve"]; + assert!((curve["length"].as_f64().unwrap() - std::f64::consts::TAU * 2.0).abs() < 1e-9); + assert!((curve["area"].as_f64().unwrap() - std::f64::consts::PI * 4.0).abs() < 1e-9); + } + #[test] fn control_retry_is_idempotent_and_stale_state_rejected() { let mut app = OpenCADStudio::new_for_test(); request(&mut app, json!({"op":"new"})); diff --git a/src/mcp.rs b/src/mcp.rs index ac479b52f..887528b62 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -3,11 +3,11 @@ //! `OpenCADStudio --mcp` speaks MCP over stdio. All drawing work is forwarded //! to the authenticated GUI control bridge; this module contains no geometry. -use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use serde::Deserialize; -use serde_json::{json, Map, Value}; +use serde_json::{Map, Value, json}; use std::{ - collections::HashMap, + collections::{HashMap, VecDeque}, fs::{File, OpenOptions}, io::{self, BufRead, BufReader, Read, Write}, net::TcpStream, @@ -22,7 +22,7 @@ const MODERN_PROTOCOL_VERSION: &str = "2026-07-28"; const MAX_REQUEST: usize = 1_048_576; const MAX_RESPONSE: u64 = 16 * 1024 * 1024; const CACHE_TTL_MS: u64 = 3_600_000; -const INSTRUCTIONS: &str = "Call ocs_sessions, then ocs_read state before editing. Preserve session_id, document_id, revision, selection and request_id. Use ocs_read commands with parameters.name for command help. For interactive work, call start and follow state.command.accepts, options and input_example. For batch work, run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OpenCADStudio and its geometry kernel calculate geometry. Verify important results with queries and ocs_capture, and save only to an explicit path."; +const INSTRUCTIONS: &str = "Call ocs_sessions, then ocs_read state before editing. Preserve session_id, document_id, revision, selection and request_id. Use ocs_read commands with parameters.name for a command manifest. Use ocs_execute batch when several steps are known, and request changed_entities when the resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. A run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. Verify important results with queries and a viewport capture, and save only to an explicit path."; const READ_OPS: &[&str] = &[ "state", "hello", @@ -38,9 +38,14 @@ const READ_OPS: &[&str] = &[ "operation", ]; const EXECUTE_OPS: &[&str] = &[ + "new", "open", "activate", "run", "start", "input", "cancel", "undo", "redo", "select", + "property", "action", "save", "stop", "batch", +]; +const BATCH_STEP_OPS: &[&str] = &[ "new", "open", "activate", "run", "start", "input", "cancel", "undo", "redo", "select", "property", "action", "save", "stop", ]; +const MAX_BATCH_STEPS: usize = 64; #[derive(Clone, Deserialize)] struct Descriptor { @@ -53,6 +58,44 @@ struct GuiClient { descriptor: Descriptor, state: Value, client_id: String, + batches: VecDeque, +} + +struct BatchExecution { + id: String, + request: Value, + steps: Vec, + next: usize, + active: Option, + results: Vec, + changes: Vec, + state: Option, + terminal: Option, +} + +struct McpTask { + id: String, + name: String, + arguments: Value, + created_at: String, + last_updated_at: String, + result: Option, + error: Option, +} + +#[derive(Default)] +struct TaskStore { + tasks: VecDeque, +} + +impl TaskStore { + fn insert(&mut self, task: McpTask) { + self.tasks.push_back(task); + } + + fn get_mut(&mut self, id: &str) -> Option<&mut McpTask> { + self.tasks.iter_mut().find(|task| task.id == id) + } } fn random_id() -> Result { @@ -61,6 +104,31 @@ fn random_id() -> Result { Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) } +fn iso8601_now() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let days = seconds.div_euclid(86_400); + let day_seconds = seconds.rem_euclid(86_400); + let shifted = days + 719_468; + let era = shifted.div_euclid(146_097); + let day_of_era = shifted - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_prime + 2) / 5 + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + year += i64::from(month <= 2); + let hour = day_seconds / 3_600; + let minute = day_seconds % 3_600 / 60; + let second = day_seconds % 60; + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") +} + #[cfg(unix)] fn private_descriptor(path: &Path) -> bool { use std::os::unix::fs::MetadataExt; @@ -226,6 +294,7 @@ impl GuiClient { descriptor, state, client_id: random_id()?, + batches: VecDeque::new(), }) } @@ -287,6 +356,206 @@ impl GuiClient { } Ok(response) } + + fn execute_batch(&mut self, request: Value, wait_seconds: f64) -> Result { + let id = required_string(&request, "request_id")?.to_owned(); + let mut batch = if let Some(position) = self.batches.iter().position(|batch| batch.id == id) + { + let batch = self + .batches + .remove(position) + .expect("batch position exists"); + if batch.request != request { + self.batches.push_back(batch); + return Err("request_id was already used for a different batch".into()); + } + batch + } else { + let steps = request["steps"] + .as_array() + .cloned() + .ok_or_else(|| "batch requires a steps array".to_string())?; + BatchExecution { + id, + request, + steps, + next: 0, + active: None, + results: Vec::new(), + changes: Vec::new(), + state: None, + terminal: None, + } + }; + + if let Some(result) = batch.terminal.clone() { + self.batches.push_back(batch); + return Ok(result); + } + + let deadline = Instant::now() + Duration::from_secs_f64(wait_seconds.clamp(0.0, 60.0)); + let mut attempted = false; + loop { + if batch.next == batch.steps.len() { + let waiting = batch + .state + .as_ref() + .is_some_and(|state| !state["command"].is_null()); + let result = batch_result( + &batch, + if waiting { + "waiting_input" + } else { + "completed" + }, + true, + ); + batch.terminal = Some(result.clone()); + self.batches.push_back(batch); + trim_batches(&mut self.batches); + return Ok(result); + } + if attempted && Instant::now() >= deadline { + let result = batch_result(&batch, "running", true); + self.batches.push_back(batch); + trim_batches(&mut self.batches); + return Ok(result); + } + attempted = true; + + let step_id = batch + .active + .clone() + .unwrap_or_else(|| batch_step_id(&batch.id, batch.next)); + let response = if batch.active.is_some() { + self.request( + json!({"op":"operation","request_id":step_id}), + deadline + .saturating_duration_since(Instant::now()) + .as_secs_f64(), + ) + } else { + let mut step = batch.steps[batch.next] + .as_object() + .cloned() + .ok_or_else(|| format!("batch step {} must be an object", batch.next))?; + for key in [ + "revision", + "geometry_revision", + "camera_revision", + "selection", + "client_id", + ] { + step.remove(key); + } + step.insert("request_id".into(), Value::String(step_id.clone())); + self.request( + Value::Object(step), + deadline + .saturating_duration_since(Instant::now()) + .as_secs_f64(), + ) + }; + let response = match response { + Ok(response) => response, + Err(error) => { + batch.active = Some(step_id); + self.batches.push_back(batch); + trim_batches(&mut self.batches); + return Err(error); + } + }; + + if matches!(response["status"].as_str(), Some("accepted" | "running")) { + batch.active = Some(step_id); + let result = batch_result(&batch, "running", true); + self.batches.push_back(batch); + trim_batches(&mut self.batches); + return Ok(result); + } + + batch.active = None; + if let Some(state) = response.get("state") { + batch.state = Some(state.clone()); + } + if let Some(changes) = response["changes"].as_array() { + batch + .changes + .extend(changes.iter().cloned().map(|mut change| { + if let Some(object) = change.as_object_mut() { + object.insert("step".into(), Value::from(batch.next)); + } + change + })); + } + let mut compact = response.clone(); + if let Some(object) = compact.as_object_mut() { + object.remove("state"); + object.remove("changes"); + object.insert("step".into(), Value::from(batch.next)); + object.insert("op".into(), batch.steps[batch.next]["op"].clone()); + } + batch.results.push(compact); + batch.next += 1; + + if response["ok"].as_bool() == Some(false) + || matches!(response["status"].as_str(), Some("failed" | "cancelled")) + { + let result = batch_result( + &batch, + response["status"].as_str().unwrap_or("failed"), + false, + ); + batch.terminal = Some(result.clone()); + self.batches.push_back(batch); + trim_batches(&mut self.batches); + return Ok(result); + } + if response["status"] == "waiting_input" + && batch + .steps + .get(batch.next) + .and_then(|step| step["op"].as_str()) + .is_none_or(|op| !matches!(op, "input" | "cancel")) + { + let result = batch_result(&batch, "waiting_input", true); + batch.terminal = Some(result.clone()); + self.batches.push_back(batch); + trim_batches(&mut self.batches); + return Ok(result); + } + } + } +} + +fn batch_step_id(id: &str, step: usize) -> String { + let hash = id + .as_bytes() + .iter() + .fold(0xcbf29ce484222325u64, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) + }); + format!("batch-{hash:016x}-{step}") +} + +fn batch_result(batch: &BatchExecution, status: &str, ok: bool) -> Value { + json!({ + "ok":ok, + "status":status, + "request_id":batch.id, + "completed_steps":batch.next, + "total_steps":batch.steps.len(), + "next_step":(batch.next < batch.steps.len()).then_some(batch.next), + "results":batch.results, + "changes":batch.changes, + "state":batch.state + }) +} + +fn trim_batches(batches: &mut VecDeque) { + while batches.len() > 64 { + batches.pop_front(); + } } fn client<'a>( @@ -313,29 +582,59 @@ fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> { )) }; match op { + "batch" => { + let steps = request["steps"].as_array().ok_or_else(|| { + r#"Missing steps for batch. Example request: {"op":"batch","request_id":"draw-1","steps":[{"op":"run","cmd":"LINE 0,0 10,0"}]}"#.to_string() + })?; + if steps.is_empty() || steps.len() > MAX_BATCH_STEPS { + return Err(format!( + "batch steps must contain 1 to {MAX_BATCH_STEPS} operations" + )); + } + for (index, step) in steps.iter().enumerate() { + let step = step + .as_object() + .ok_or_else(|| format!("batch step {index} must be an object"))?; + if step.contains_key("request_id") { + return Err(format!( + "batch step {index} must omit request_id; the batch assigns idempotency keys" + )); + } + let step = Value::Object(step.clone()); + let step_op = step["op"] + .as_str() + .ok_or_else(|| format!("batch step {index} is missing op"))?; + if !BATCH_STEP_OPS.contains(&step_op) { + return Err(format!("Unknown operation {step_op} in batch step {index}")); + } + validate_execute_request(&step, step_op) + .map_err(|error| format!("batch step {index}: {error}"))?; + } + Ok(()) + } "open" if request["path"].as_str().is_none_or(str::is_empty) => { missing("path", r#"{"op":"open","path":"/path/drawing.dxf"}"#) } "activate" if request["document_id"].as_u64().is_none() => { missing("document_id", r#"{"op":"activate","document_id":2}"#) } - "run" | "start" if request["cmd"].as_str().is_none_or(str::is_empty) => missing( - "cmd", - r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#, - ), + "run" | "start" if request["cmd"].as_str().is_none_or(str::is_empty) => { + missing("cmd", r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#) + } "input" => match request["kind"].as_str() { Some("text") => Ok(()), - Some("token") if request["text"].as_str().is_some_and(|value| !value.is_empty()) => { + Some("token") + if request["text"] + .as_str() + .is_some_and(|value| !value.is_empty()) => + { Ok(()) } - Some("token") => missing( - "text", - r#"{"op":"input","kind":"token","text":"C"}"#, - ), + Some("token") => missing("text", r#"{"op":"input","kind":"token","text":"C"}"#), Some("point") - if request["point"] - .as_array() - .is_some_and(|values| values.len() == 3 && values.iter().all(Value::is_number)) => + if request["point"].as_array().is_some_and(|values| { + values.len() == 3 && values.iter().all(Value::is_number) + }) => { Ok(()) } @@ -344,10 +643,12 @@ fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> { r#"{"op":"input","kind":"point","point":[0,0,0],"space":"wcs"}"#, ), Some("entity" | "structure") - if request["handle"].as_str().is_some_and(|value| !value.is_empty()) - && request["point"] - .as_array() - .is_some_and(|values| values.len() == 3 && values.iter().all(Value::is_number)) => + if request["handle"] + .as_str() + .is_some_and(|value| !value.is_empty()) + && request["point"].as_array().is_some_and(|values| { + values.len() == 3 && values.iter().all(Value::is_number) + }) => { Ok(()) } @@ -359,10 +660,7 @@ fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> { Some(kind) => Err(format!( "Unknown input kind {kind}. Use text, token, point, entity, structure, selection or enter" )), - None => missing( - "kind", - r#"{"op":"input","kind":"point","point":[0,0,0]}"#, - ), + None => missing("kind", r#"{"op":"input","kind":"point","point":[0,0,0]}"#), }, "property" if request["field"].as_str().is_none_or(str::is_empty) @@ -378,15 +676,75 @@ fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> { Some(name) => Err(format!( "Unknown action {name}. Call ocs_read commands to list actions" )), - None => missing( - "name", - r#"{"op":"action","name":"zoom_extents"}"#, - ), + None => missing("name", r#"{"op":"action","name":"zoom_extents"}"#), }, _ => Ok(()), } } +fn compact_state(state: &Value) -> Value { + let mut compact = Map::new(); + for key in [ + "session_id", + "document_id", + "revision", + "geometry_revision", + "camera_revision", + "selection", + "command", + "modal", + "event_cursor", + "operation", + ] { + if let Some(value) = state.get(key) { + compact.insert(key.into(), value.clone()); + } + } + Value::Object(compact) +} + +fn response_handles(response: &Value) -> Vec { + let mut handles = Vec::new(); + if let Some(changes) = response["changes"].as_array() { + for handle in changes + .iter() + .filter_map(|change| change["handle"].as_str()) + { + if !handles.iter().any(|value| value == handle) { + handles.push(handle.to_owned()); + } + } + } + handles +} + +fn shape_execute_response( + mut response: Value, + detail: &str, + gui: &mut GuiClient, +) -> Result { + if detail == "changed_entities" { + let handles = response_handles(&response); + if !handles.is_empty() { + let entities = gui.request( + json!({"op":"query","handles":handles,"detail":"geometry","limit":MAX_BATCH_STEPS * 100}), + 30.0, + )?; + if let Some(object) = response.as_object_mut() { + object.insert("changed_entities".into(), entities["entities"].clone()); + } + } + } + if detail != "full" { + if let Some(state) = response.get("state").cloned() { + if let Some(object) = response.as_object_mut() { + object.insert("state".into(), compact_state(&state)); + } + } + } + Ok(response) +} + fn call_tool( name: &str, arguments: &Value, @@ -427,13 +785,22 @@ fn call_tool( } validate_execute_request(&request, op)?; let wait = arguments["wait_seconds"].as_f64().unwrap_or(30.0); - client(clients, session_id)?.request(request, wait) + let detail = arguments["response_detail"].as_str().unwrap_or("compact"); + let gui = client(clients, session_id)?; + let response = if op == "batch" { + gui.execute_batch(request, wait)? + } else { + gui.request(request, wait)? + }; + shape_execute_response(response, detail, gui) } "ocs_capture" => { let session_id = required_string(arguments, "session_id")?; let path = std::env::temp_dir().join(format!("ocs-capture-{}.png", random_id()?)); + let scope = arguments["scope"].as_str().unwrap_or("viewport"); + let max_dimension = arguments["max_dimension"].as_u64().unwrap_or(1600); let result = client(clients, session_id)? - .request(json!({"op":"capture","path":path.to_string_lossy()}), 30.0)?; + .request(json!({"op":"capture","path":path.to_string_lossy(),"scope":scope,"max_dimension":max_dimension}), 30.0)?; if result["ok"].as_bool() != Some(true) || result["status"].as_str() != Some("completed") { @@ -447,6 +814,32 @@ fn call_tool( } } +fn batch_step_schema() -> Value { + json!({ + "type":"object", + "properties":{ + "op":{"type":"string","enum":BATCH_STEP_OPS}, + "document_id":{"type":"integer","minimum":0}, + "cmd":{"type":"string","minLength":1}, + "path":{"type":"string","minLength":1}, + "kind":{"type":"string","enum":["text","token","point","entity","structure","selection","enter"]}, + "text":{"type":"string"}, + "point":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}, + "space":{"type":"string","enum":["wcs","ucs","relative"]}, + "handle":{"type":"string"}, + "handles":{"type":"array","items":{"type":"string"}}, + "type":{"type":"string"}, + "layer":{"type":"string"}, + "clear":{"type":"boolean"}, + "field":{"type":"string"}, + "value":{"description":"New property value; its JSON type must match the property kind.","anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"object"},{"type":"array"},{"type":"null"}]}, + "name":{"type":"string","enum":crate::app::automation_action_names()} + }, + "required":["op"], + "additionalProperties":false + }) +} + fn execute_request_schema() -> Value { let handle = json!({ "type":"string", @@ -480,7 +873,8 @@ fn execute_request_schema() -> Value { "clear":{"type":"boolean","description":"Clear the current selection before applying select filters."}, "field":{"type":"string","minLength":1,"description":"Property id returned by ocs_read properties."}, "value":{"description":"New property value; its JSON type must match the property kind.","anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"object"},{"type":"array"},{"type":"null"}]}, - "name":{"type":"string","enum":crate::app::automation_action_names(),"description":"UI action returned by ocs_read commands."} + "name":{"type":"string","enum":crate::app::automation_action_names(),"description":"UI action returned by ocs_read commands."}, + "steps":{"type":"array","minItems":1,"maxItems":MAX_BATCH_STEPS,"description":"Sequential editor operations executed with fresh state and idempotency keys. Execution stops at the first failure; completed_steps says what committed.","items":batch_step_schema()} }, "required":["op","request_id"], "additionalProperties":false, @@ -506,7 +900,8 @@ fn execute_request_schema() -> Value { {"properties":{"op":{"const":"property"}},"required":["field","value"]}, {"properties":{"op":{"const":"action"}},"required":["name"]}, {"properties":{"op":{"const":"save"}}}, - {"properties":{"op":{"const":"stop"}}} + {"properties":{"op":{"const":"stop"}}}, + {"properties":{"op":{"const":"batch"}},"required":["steps"]} ] }) } @@ -548,22 +943,22 @@ fn tool_definitions() -> Value { }, { "name":"ocs_read", - "description":"Read state, commands/actions, query, entities, layers, header, properties, measurements, history, events or operation status from a live OCS session. For command help use op commands with parameters.name.", - "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name for detailed commands help."},"document_id":{"type":"integer","minimum":0},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"}},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1}},"additionalProperties":true}},"required":["session_id"],"additionalProperties":false}, + "description":"Read state, command manifests, entities, layers, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.", + "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name for detailed commands help."},"search":{"type":"string","description":"Case-insensitive command-name search."},"document_id":{"type":"integer","minimum":0},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity handles for query or measure."},"type":{"type":"string","description":"Entity type filter for query."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["session_id"],"additionalProperties":false}, "outputSchema":read_output_schema(), "annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} }, { "name":"ocs_execute", - "description":"Execute one semantic OCS action. Use current state fields and a unique request_id. Use run for a complete command line or start plus input for guided steps. accepted, running and waiting_input are not completion.", - "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"request":execute_request_schema(),"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30,"description":"Time to wait for asynchronous completion before returning."}},"required":["session_id","request"],"additionalProperties":false}, + "description":"Execute semantic OCS actions. Use current state fields and a unique request_id. Use run for one complete command, batch to remove round trips, or start plus input for guided steps. accepted, running and waiting_input are not completion.", + "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"request":execute_request_schema(),"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30,"description":"Total time to wait for completion before returning."},"response_detail":{"type":"string","enum":["compact","changed_entities","full"],"default":"compact","description":"compact returns only state needed for the next edit; changed_entities also returns current geometry for changed handles; full preserves the complete editor state."}},"required":["session_id","request"],"additionalProperties":false}, "outputSchema":execute_output_schema(), "annotations":{"title":"Execute OCS action","readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":false} }, { "name":"ocs_capture", - "description":"Capture the actual current OpenCADStudio window as PNG for visual verification.", - "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."}},"required":["session_id"],"additionalProperties":false}, + "description":"Capture the actual current OCS drawing viewport or window as a bounded PNG for visual verification.", + "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"scope":{"type":"string","enum":["viewport","window"],"default":"viewport","description":"Capture only the drawing viewport by default, or the complete application window."},"max_dimension":{"type":"integer","minimum":256,"maximum":4096,"default":1600,"description":"Resize the longest image edge to at most this many pixels."}},"required":["session_id"],"additionalProperties":false}, "annotations":{"title":"Capture OCS window","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} } ]) @@ -607,6 +1002,56 @@ fn modern_request(params: &Value) -> bool { == Some(MODERN_PROTOCOL_VERSION) } +fn supports_tasks(params: &Value) -> bool { + params["_meta"]["io.modelcontextprotocol/clientCapabilities"]["extensions"] + ["io.modelcontextprotocol/tasks"] + .is_object() +} + +fn task_value(task: &McpTask, status: &str) -> Value { + let mut value = json!({ + "resultType":"complete", + "taskId":task.id, + "status":status, + "createdAt":task.created_at, + "lastUpdatedAt":task.last_updated_at, + "ttlMs":3_600_000, + "pollIntervalMs":250 + }); + if let Some(result) = &task.result { + value["result"] = result.clone(); + } + if let Some(error) = &task.error { + value["error"] = error.clone(); + } + value +} + +fn poll_task(task: &mut McpTask, clients: &mut HashMap) -> Value { + if task.result.is_some() { + return task_value(task, "completed"); + } + if task.error.is_some() { + return task_value(task, "failed"); + } + task.last_updated_at = iso8601_now(); + let mut arguments = task.arguments.clone(); + arguments["wait_seconds"] = Value::from(0); + match call_tool(&task.name, &arguments, clients) { + Ok(value) if matches!(value["status"].as_str(), Some("accepted" | "running")) => { + task_value(task, "working") + } + Ok(value) => { + task.result = Some(tool_result(value)); + task_value(task, "completed") + } + Err(error) => { + task.error = Some(json!({"code":-32000,"message":error})); + task_value(task, "failed") + } + } +} + fn protocol_result(mut result: Value, modern: bool, cacheable: bool) -> Value { if modern { let object = result @@ -643,7 +1088,11 @@ fn unsupported_protocol(id: Value, requested: &str) -> Value { }) } -fn handle_message(message: Value, clients: &mut HashMap) -> Option { +fn handle_message( + message: Value, + clients: &mut HashMap, + tasks: &mut TaskStore, +) -> Option { let id = message.get("id").cloned(); let method = message.get("method").and_then(Value::as_str)?; if id.is_none() { @@ -684,7 +1133,7 @@ fn handle_message(message: Value, clients: &mut HashMap) -> O protocol_result( json!({ "supportedVersions":[MODERN_PROTOCOL_VERSION,PROTOCOL_VERSION], - "capabilities":{"tools":{}}, + "capabilities":{"tools":{},"extensions":{"io.modelcontextprotocol/tasks":{}}}, "instructions":INSTRUCTIONS }), true, @@ -704,11 +1153,72 @@ fn handle_message(message: Value, clients: &mut HashMap) -> O .get("arguments") .cloned() .unwrap_or_else(|| json!({})); - let result = call_tool(name, &arguments, clients) - .map(tool_result) - .unwrap_or_else(error_result); + let called = call_tool(name, &arguments, clients); + if modern && supports_tasks(¶ms) { + if let Ok(value) = &called { + if matches!(value["status"].as_str(), Some("accepted" | "running")) { + let task_id = + random_id().unwrap_or_else(|_| format!("task-{}", iso8601_now())); + let now = iso8601_now(); + tasks.insert(McpTask { + id: task_id.clone(), + name: name.to_owned(), + arguments, + created_at: now.clone(), + last_updated_at: now, + result: None, + error: None, + }); + return Some(response( + id, + protocol_result( + json!({ + "resultType":"task", + "taskId":task_id, + "status":"working", + "statusMessage":"OCS operation is running.", + "createdAt":tasks.tasks.back().unwrap().created_at, + "lastUpdatedAt":tasks.tasks.back().unwrap().last_updated_at, + "ttlMs":3_600_000, + "pollIntervalMs":250 + }), + true, + false, + ), + )); + } + } + } + let result = called.map(tool_result).unwrap_or_else(error_result); response(id, protocol_result(result, modern, false)) } + "tasks/get" if modern && supports_tasks(¶ms) => { + let Some(task_id) = params["taskId"].as_str() else { + return Some(rpc_error(id, -32602, "Missing taskId")); + }; + let Some(task) = tasks.get_mut(task_id) else { + return Some(rpc_error(id, -32602, "Unknown or expired taskId")); + }; + response(id, protocol_result(poll_task(task, clients), true, false)) + } + "tasks/update" if modern && supports_tasks(¶ms) => { + let Some(task_id) = params["taskId"].as_str() else { + return Some(rpc_error(id, -32602, "Missing taskId")); + }; + if tasks.get_mut(task_id).is_none() { + return Some(rpc_error(id, -32602, "Unknown or expired taskId")); + } + response(id, protocol_result(json!({}), true, false)) + } + "tasks/cancel" if modern && supports_tasks(¶ms) => { + let Some(task_id) = params["taskId"].as_str() else { + return Some(rpc_error(id, -32602, "Missing taskId")); + }; + if tasks.get_mut(task_id).is_none() { + return Some(rpc_error(id, -32602, "Unknown or expired taskId")); + } + response(id, protocol_result(json!({}), true, false)) + } _ => rpc_error(id, -32601, format!("Method not found: {method}")), }) } @@ -719,10 +1229,11 @@ pub fn run() { let stdout = io::stdout(); let mut output = stdout.lock(); let mut clients = HashMap::new(); + let mut tasks = TaskStore::default(); for line in stdin.lock().lines() { let response = match line { Ok(line) if !line.trim().is_empty() => match serde_json::from_str::(&line) { - Ok(message) => handle_message(message, &mut clients), + Ok(message) => handle_message(message, &mut clients, &mut tasks), Err(error) => Some(rpc_error(Value::Null, -32700, error)), }, Ok(_) => None, @@ -771,6 +1282,18 @@ mod tests { .len(), EXECUTE_OPS.len() ); + assert_eq!( + tools[2]["inputSchema"]["properties"]["request"]["properties"]["steps"]["maxItems"], + MAX_BATCH_STEPS + ); + assert_eq!( + tools[2]["inputSchema"]["properties"]["response_detail"]["default"], + "compact" + ); + assert_eq!( + tools[3]["inputSchema"]["properties"]["scope"]["default"], + "viewport" + ); assert_eq!( tools[2]["inputSchema"]["properties"]["request"]["properties"]["cmd"]["examples"][0], "LINE 0,0 10,10" @@ -786,17 +1309,21 @@ mod tests { let initialized = handle_message( json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(initialized["result"]["protocolVersion"], "2025-11-25"); - assert!(initialized["result"]["instructions"] - .as_str() - .unwrap() - .contains("geometry kernel")); + assert!( + initialized["result"]["instructions"] + .as_str() + .unwrap() + .contains("geometry kernel") + ); let listed = handle_message( json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(listed["result"]["tools"].as_array().unwrap().len(), 4); @@ -810,12 +1337,17 @@ mod tests { let discovered = handle_message( json!({"jsonrpc":"2.0","id":"discover","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(discovered["result"]["resultType"], "complete"); assert_eq!(discovered["result"]["ttlMs"], CACHE_TTL_MS); assert_eq!(discovered["result"]["cacheScope"], "public"); assert_eq!(discovered["result"]["supportedVersions"][0], "2026-07-28"); + assert!( + discovered["result"]["capabilities"]["extensions"]["io.modelcontextprotocol/tasks"] + .is_object() + ); assert_eq!( discovered["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], "OpenCADStudio" @@ -824,6 +1356,7 @@ mod tests { let listed = handle_message( json!({"jsonrpc":"2.0","id":"tools","method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(listed["result"]["resultType"], "complete"); @@ -837,6 +1370,7 @@ mod tests { let called = handle_message( json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ocs_read","arguments":{"session_id":"missing","op":"save"}}}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(called["result"]["isError"], true); @@ -848,6 +1382,7 @@ mod tests { let rejected = handle_message( json!({"jsonrpc":"2.0","id":4,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2099-01-01"}}}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(rejected["error"]["code"], -32022); @@ -864,13 +1399,16 @@ mod tests { let called = handle_message( json!({"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"session_id":"missing","request":{"op":"undo"}}}}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(called["result"]["isError"], true); - assert!(called["result"]["structuredContent"]["error"] - .as_str() - .unwrap() - .contains("request_id")); + assert!( + called["result"]["structuredContent"]["error"] + .as_str() + .unwrap() + .contains("request_id") + ); } #[test] @@ -879,6 +1417,7 @@ mod tests { let called = handle_message( json!({"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"session_id":"missing","request":{"op":"run","request_id":"run-1"}}}}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(called["result"]["isError"], true); @@ -893,6 +1432,55 @@ mod tests { assert!(error.contains("LINE 0,0 10,10"), "{error}"); } + #[test] + fn validates_batch_steps_and_compacts_state() { + let batch = json!({ + "op":"batch", + "request_id":"draw", + "steps":[{"op":"run","cmd":"LINE 0,0 10,0"},{"op":"run","cmd":"CIRCLE 5,5 2"}] + }); + assert!(validate_execute_request(&batch, "batch").is_ok()); + let invalid = json!({ + "op":"batch", + "request_id":"draw", + "steps":[{"op":"run","request_id":"nested","cmd":"LINE 0,0 10,0"}] + }); + assert!( + validate_execute_request(&invalid, "batch") + .unwrap_err() + .contains("omit request_id") + ); + + let compact = compact_state(&json!({ + "session_id":"s","document_id":3,"revision":4,"geometry_revision":5, + "camera_revision":6,"selection":[],"command":null,"documents":[1,2,3], + "camera":{"distance":100.0},"event_cursor":7 + })); + assert_eq!(compact["revision"], 4); + assert!(compact.get("camera").is_none()); + assert!(compact.get("documents").is_none()); + } + + #[test] + fn task_metadata_uses_the_modern_shape() { + let now = iso8601_now(); + assert_eq!(now.len(), 20); + assert!(now.ends_with('Z')); + let task = McpTask { + id: "task".into(), + name: "ocs_execute".into(), + arguments: json!({}), + created_at: now.clone(), + last_updated_at: now, + result: None, + error: None, + }; + let value = task_value(&task, "working"); + assert_eq!(value["resultType"], "complete"); + assert_eq!(value["status"], "working"); + assert_eq!(value["pollIntervalMs"], 250); + } + #[test] fn gui_failures_are_mcp_errors() { let result = tool_result(json!({ From ac8a5456690fee5dc7ee12f641faa500e620831a Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 5 Sep 2026 19:56:49 +0300 Subject: [PATCH 24/24] Fix layout cache correctness and GPU resource recovery --- locales/tr-TR/opencadstudio.ftl | 1 + src/app/automation.rs | 30 +++ src/app/mod.rs | 12 +- src/app/update/mod.rs | 3 +- src/app/update/viewport.rs | 4 +- src/app/view/mod.rs | 30 +-- src/scene/mod.rs | 363 ++++++++++------------------ src/scene/pipeline/gpu_tests.rs | 89 +++++++ src/scene/pipeline/hatch_gpu/mod.rs | 13 + src/scene/pipeline/mod.rs | 106 ++++---- src/scene/view/render.rs | 27 +-- 11 files changed, 352 insertions(+), 326 deletions(-) diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 11b14c6a0..2a7aa4118 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -165,6 +165,7 @@ clipboard = .paste-to-original-coordinates = Özgün Koordinatlara Yapıştır viewport = + .preparing-layout = Yerleşim hazırlanıyor… .split-vertical = Görünüm Penceresini Dikey Böl .split-horizontal = Görünüm Penceresini Yatay Böl .move = Görünüm Penceresini Taşı diff --git a/src/app/automation.rs b/src/app/automation.rs index a3c33d0a0..e0b11eb66 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -653,6 +653,36 @@ impl OpenCADStudio { mod tests { use crate::app::OpenCADStudio; + #[test] + fn layout_notice_skips_grid_camera_and_scene_builds() { + let mut app = OpenCADStudio::new_for_test(); + assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], true); + assert_eq!(app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#)["ok"], true); + let i = app.active_tab; + let scene = &mut app.tabs[i].scene; + scene.document.add_layout("Review").unwrap(); + scene.set_current_layout("Review".to_string()); + let mut viewport = acadrust::entities::Viewport::new(); + viewport.id = 2; + viewport.width = 100.0; + viewport.height = 50.0; + viewport.status.is_on = true; + scene.add_entity(acadrust::EntityType::Viewport(viewport)); + for entity in scene.document.entities_mut() { + if let acadrust::EntityType::Viewport(viewport) = entity { + viewport.status.grid_on = true; + } + } + let before = scene.last_tess_wires.get(); + app.layout_settling = true; + drop(app.view_main()); + assert_eq!(app.tabs[i].scene.last_tess_wires.get(), before); + let _ = app.update(crate::app::Message::LayoutSettled); + assert!(!app.layout_settling); + drop(app.view_main()); + assert!(app.tabs[i].scene.last_tess_wires.get() > before); + } + #[test] fn automation_ops_round_trip() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 261a32d27..a4547f0dd 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1055,14 +1055,7 @@ pub(super) struct OpenCADStudio { /// `Some` while a CAD file is loading — drives the modal overlay. /// Cleared when the load finishes, errors, or the user cancels. pub(super) opening: Option, - /// Set the moment a layout switch changes `current_layout`, cleared on the - /// next presented frame. - /// - /// The switch itself costs about 10 ms; the frame after it rebuilds the - /// whole wire set and can block the main thread for seconds. While this is - /// set the viewport is replaced by a notice, so that frame is cheap — which - /// gives the compositor an answer, and the user something other than a - /// stale window, immediately before the expensive one. + /// Show a lightweight notice until the next layout redraw. pub(super) layout_settling: bool, open_job_serial: u64, /// Last repair or failed-open report shown in the recovery modal. @@ -2292,8 +2285,7 @@ pub enum Message { /// Timer pulse while the cursor is dwelling on a grip; drives the /// dwell-to-popup transition without requiring further mouse motion. GripDwellTick, - /// One frame has been presented since a layout switch; the next may do the - /// expensive rebuild. + /// The notice redraw allows the next frame to build the scene. LayoutSettled, /// Timer pulse while a rollover hit-test is queued; fires when the /// cursor has been still long enough to safely run the pick. diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 47ae2acb7..5e04de369 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -3046,8 +3046,7 @@ impl OpenCADStudio { } Message::LayoutSettled => { - // The notice frame has been presented, so the compositor has - // had its answer. Let the next frame build the real thing. + // Restore the scene after the notice redraw. self.layout_settling = false; Task::none() } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 141dc8ef3..7541e8742 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -5535,9 +5535,7 @@ impl OpenCADStudio { .layers .sync_with_viewports(&doc_layers, vp_info); let layers_ms = perf_phase.elapsed().as_secs_f64() * 1000.0; - // The switch is cheap; the frame that follows it is not. Draw a notice - // for one frame first, so the compositor gets an answer before the main - // thread disappears into rebuilding the wire set. + // Give the layout rebuild a visible notice first. self.layout_settling = true; if perf { crate::perf_record!( diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 334de7834..2895b2aae 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -36,11 +36,7 @@ pub(in crate::app) const VIEWPORT_CAPTURE_BOUNDS_ID: &str = "viewport-capture-bo const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_REGION_PX; -/// Base surface directly under the crosshair, selection marquee, and grid overlay. -/// In paper space, drafting surface context is unconditionally the white sheet (`tab.scene.paper_bg_color`), -/// matching AutoCAD's model (where crosshairs and overlays adapt per context — model vs. sheet/layout — -/// rather than per-pixel cursor sampling). This avoids flicker across sheet edges and provides a clean -/// dark crosshair that maintains ~5.85:1 contrast against the default medium-gray desk margin (#8A8A8A). +/// Background used by drafting overlays in model or paper space. fn crosshair_background(tab: &DocumentTab, is_paper: bool) -> [f32; 4] { if !is_paper { return tab.scene.bg_color; @@ -273,10 +269,7 @@ impl OpenCADStudio { // borders) and the floating content viewports blit on top. mark("viewport_3d"); let viewport_3d: Element<'_, Message> = if self.layout_settling && !tab.is_start { - // One cheap frame between the switch and the rebuild. No shader - // widget means no scene primitive, so `prepare` has nothing to do - // and the event loop turns — which is what stops the compositor - // deciding the application has stopped responding. + // Omit the shader and drafting overlays for the notice frame. let bg = crosshair_background(tab, is_paper); let lum = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2]; let ink = if lum > 0.5 { @@ -376,7 +369,7 @@ impl OpenCADStudio { // above the crosshair overlay so it actually receives mouse events, and // it owns the divider resize. Only built for the Model layout. mark("model_input"); - let model_input_layer: Option> = if is_paper || tab.is_start { + let model_input_layer: Option> = if is_paper || tab.is_start || self.layout_settling { None } else { let scene = &tab.scene; @@ -394,7 +387,9 @@ impl OpenCADStudio { }; mark("grid"); - let grid_overlay = { + let grid_overlay = if self.layout_settling || tab.is_start { + Space::new().into() + } else { let (vw, vh) = tab.scene.selection.borrow().vp_size; let model_basis = { let (o, ux, uy, uz) = tab.ucs_xform().axes(); @@ -479,7 +474,9 @@ bg={bg_ms:.1}ms n={view_count}" }; mark("selection_overlay"); - let selection_overlay = { + let selection_overlay = if self.layout_settling || tab.is_start { + Space::new().into() + } else { // Hold a single `Ref<'_, SelectionState>` for the whole overlay // block. The `vp_size` and `last_move_pos` reads below become // field accesses on `sel_ref` (no redundant `borrow()`s), and @@ -996,7 +993,7 @@ bg={bg_ms:.1}ms n={view_count}" }; mark("viewport_stack"); - let mut viewport_stack = if tab.is_start { + let mut viewport_stack = if tab.is_start || self.layout_settling { // Start tab: only the welcome widget over a flat background. // Skip every drawing-only overlay (selection markers, snap info, // mouse-area capturing draw clicks, viewcube, nav toolbar, …). @@ -1057,7 +1054,7 @@ bg={bg_ms:.1}ms n={view_count}" .height(Fill) }; - if !thumbnail_capture_clean { + if !thumbnail_capture_clean && !self.layout_settling { // Per-pane input pane_grid goes ABOVE the crosshair overlay so it // receives mouse events (the overlay's `Hidden` cursor would otherwise // starve any layer beneath it). The controls bar is pushed on top of it. @@ -2279,10 +2276,7 @@ impl OpenCADStudio { // the mouse perfectly still — `ViewportMove` alone would never // fire again. Auto-stops once the hover clears or the popup is // already open. - // One frame after a layout switch, then stop. `window::frames` fires - // once a frame has actually been presented, which is the guarantee this - // needs: the notice is on screen and the compositor has been answered - // before the expensive frame starts. + // Resume scene construction after the notice redraw. let layout_settle = if self.layout_settling { window::frames().map(|_| Message::LayoutSettled) } else { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index adde471ab..d817ff041 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1495,8 +1495,8 @@ pub enum ViewportRefreshScope { All, } -/// `(geometry_epoch, entities per block, entities no block claims)`. -type BlockMembers = (u64, HashMap>, Vec); +/// Entity membership in document order, keyed by geometry epoch. +type BlockMembers = (u64, HashMap>); pub struct Scene { pub camera: Rc>, @@ -1836,24 +1836,9 @@ pub struct Scene { /// Reverse map: entity_handle → block_record_handle, built from entity_handles lists. /// Keyed by geometry_epoch. Eliminates the O(B) fallback scan in belongs_to_visible_block. entity_block_map_cache: RefCell)>>, - /// Candidate entities per block, keyed by `geometry_epoch`. - /// - /// Selecting a block's contents used to walk every entity in the document - /// and ask `belongs_to_visible_block` about each: 313 ms of a 334 ms - /// resident build for a paper sheet holding 43 entities, on a drawing with - /// 1.17 M. The membership rules only depend on owner handles and the block - /// records, so one pass answers them for every block at once. - /// - /// The second field holds entities no block claims, which - /// `belongs_to_visible_block` treats as belonging to everything — but only - /// when no block record enumerates its contents at all. + /// Candidate handles per block, in document order and keyed by geometry epoch. block_members_cache: RefCell>, - /// Handles of entities the quadtree cannot index — Insert, Viewport, Block, - /// BlockEnd — in document order, keyed by `geometry_epoch`. - /// - /// The quadtree path has to append these regardless of the view, and did so - /// by walking every entity in the document: ~300 ms of every resident - /// rebuild on a 1.17 M-entity drawing, to find a few thousand. + /// Insert/Viewport/Block/BlockEnd handles omitted by the spatial index. unindexable_cache: RefCell)>>, /// Distinct entity type names present in a layout, keyed by /// (geometry_epoch, layout block). The status bar asks for this on every @@ -1958,17 +1943,9 @@ pub struct Scene { /// Hash of the tessellation parameters `tess_memo` was built under. When /// the current call's parameters differ, the memo is stale and cleared. tess_memo_guard: std::cell::Cell, - /// Per-entity memo for the **resident** model wire set (`model_tile_wires_arc`, - /// the one the main GPU render holds). Kept separate from `tess_memo` because - /// the resident set is camera-INDEPENDENT (no view cull, no zoom LOD), so its - /// guard depends only on anno-scale — it survives pan/zoom and layout switches, and a - /// single-entity edit re-tessellates just the changed entity instead of the - /// whole model. Sharing `tess_memo` would let the camera-dependent culled path - /// thrash it on every zoom. (#perf) + /// Camera-independent model tessellation, separate from the culled memo. resident_tess_memo: RefCell>>>, - /// Guard hash for `resident_tess_memo` (anno-scale only). The background - /// is not part of it: it changes colours, not geometry, and - /// `resolve_colors_for_bg` reapplies it to the assembled set. + /// Annotation, background, viewport and glyph-atlas signature. resident_tess_guard: std::cell::Cell, /// Per-mutation delta journal: which handles changed on each `geometry_epoch` /// bump. Bounded ring ([`GEOMETRY_JOURNAL_CAP`]); a derived cache replays the @@ -8916,7 +8893,7 @@ impl Scene { // the compiler enforces that. A default here is what made the first // version of this diagnostic report a constant. let visible_path: &str; - let mut visible_candidates = 0usize; + let mut visible_candidates: usize; // Phase 2.1 — quadtree-driven candidate selection. When a view // AABB exists (Model layout with a settled camera), only iterate @@ -8978,6 +8955,7 @@ impl Scene { // — the legacy-DXF case. Both need the full walk, and both must // keep document order, which is draw order for the wire pass. visible_path = "full-scan"; + visible_candidates = self.document.entity_count(); self.document .entities() .filter(|e| visibility_ok(e)) @@ -8993,7 +8971,7 @@ impl Scene { // in exactly the order the full scan produced. visible_path = "block-index"; let members = self.block_members(); - let (_, by_block, _) = &*members; + let (_, by_block) = &*members; let claimed = by_block.get(&block_handle).map(Vec::as_slice).unwrap_or(&[]); visible_candidates = claimed.len(); let mut out: Vec<&EntityType> = Vec::with_capacity(claimed.len()); @@ -9089,11 +9067,7 @@ impl Scene { let mut guard_was_stale = false; let t_build = perf.then(iced::time::Instant::now); let mut wires: Vec = if memo_active { - // Guard hash of everything tessellate_entity's GEOMETRY depends on - // besides the entity itself. A mismatch (zoom/tol, view, anno, - // offset, entered viewport) means the memo is stale. For the - // resident path wpp/view_aabb are None, so this collapses to anno. - // The background is excluded on purpose — see the note below. + // Invalidate when any baked display input changes. let guard = { let mut g: u64 = 0xcbf2_9ce4_8422_2325; let mut mix = |x: u64| g = g.rotate_left(13) ^ x; @@ -9106,13 +9080,11 @@ impl Scene { mix(anno.to_bits() as u64); mix(annotation_scale_handle.map(|handle| handle.value()).unwrap_or(0)); mix(all_visible as u64); - // The background is deliberately NOT mixed in. It changes - // colours, never geometry — `local_wires_for` discards it with - // `let _ = bg_color;` and `Batches::finalize` applies it last — - // so a memo entry built under one background serves any other - // once `resolve_colors_for_bg` has run over the assembled set - // below. Keying on it made a layout switch re-tessellate the - // whole model to arrive at identical points. + // Direct entities and inherited/faded block colours still bake + // the background before Batches::finalize records its inputs. + for channel in bg { + mix(channel.to_bits() as u64); + } mix(avp.map(|h| h.value()).unwrap_or(0)); // SDF glyph quads bake the atlas UV of each tile, so a growth or // a re-bake (which rescale / rewind every UV) makes memoized text @@ -9283,21 +9255,7 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", wires } - /// Decide whether an entity should be drawn as direct content of `block_handle`. - /// Entities each block may contain, and the ones no block claims. - /// - /// Mirrors `belongs_to_visible_block` exactly, resolved once per - /// `geometry_epoch` for every block instead of per entity per query: - /// - /// * a non-null owner *is* the block, so the entity is that block's; - /// * a null owner falls back to the reverse map built from the block - /// records' `entity_handles`; - /// * an entity neither names nor is named by any block is "unowned", and - /// counts towards every block only when no block record enumerates its - /// contents at all — the legacy-DXF case that predicate allows. - /// Entities the quadtree cannot index, in document order, resolved once per - /// `geometry_epoch`. Membership depends only on the entity's type, so an - /// edit that does not add or remove one leaves the list unchanged. + /// Unindexable entity handles in document order, cached per geometry epoch. fn unindexable_handles(&self) -> std::cell::Ref<'_, (u64, Vec)> { { let cache = self.unindexable_cache.borrow(); @@ -9318,10 +9276,11 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", std::cell::Ref::map(self.unindexable_cache.borrow(), |c| c.as_ref().unwrap()) } + /// Resolve owner handles and block-record membership once per geometry epoch. fn block_members(&self) -> std::cell::Ref<'_, BlockMembers> { { let cache = self.block_members_cache.borrow(); - if cache.as_ref().is_some_and(|(epoch, _, _)| *epoch == self.geometry_epoch) { + if cache.as_ref().is_some_and(|(epoch, _)| *epoch == self.geometry_epoch) { drop(cache); return std::cell::Ref::map(self.block_members_cache.borrow(), |c| { c.as_ref().unwrap() @@ -9329,7 +9288,6 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", } } let mut members: HashMap> = HashMap::default(); - let mut unowned: Vec = Vec::new(); { let map = self.entity_block_map(); for entity in self.document.entities() { @@ -9339,16 +9297,15 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", members.entry(owner).or_default().push(common.handle); } else if let Some(&block) = map.get(&common.handle) { members.entry(block).or_default().push(common.handle); - } else { - unowned.push(common.handle); } } } *self.block_members_cache.borrow_mut() = - Some((self.geometry_epoch, members, unowned)); + Some((self.geometry_epoch, members)); std::cell::Ref::map(self.block_members_cache.borrow(), |c| c.as_ref().unwrap()) } + /// Whether the entity is direct content of this block. fn belongs_to_visible_block( &self, entity_handle: Handle, @@ -10044,15 +10001,7 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", std::cell::Ref::map(self.entity_index_cache.borrow(), |c| &c.as_ref().unwrap().1) } - /// Full tessellation pipeline for one entity. - /// The document-wide inputs `tessellate_entity` takes, resolved from the - /// current layout and viewport. - /// - /// Every entity in a walk gets the same four, and resolving them per entity - /// is not free — `block_cache_arc_for` takes a `RefCell` borrow and a cache - /// lookup. Callers that tessellate more than one entity resolve them once - /// and pass them down; sharing the resolution keeps those callers from - /// drifting away from `tessellate_one`. + /// Resolve the active layout and annotation context for tessellation. fn tessellation_context( &self, ) -> ([f32; 4], f32, Option, Arc, bool) { @@ -10227,167 +10176,43 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", if model_block.is_null() { return None; } - let mut min = glam::Vec3::splat(f32::INFINITY); - let mut max = glam::Vec3::splat(f32::NEG_INFINITY); - let mut any = false; - - // Prefer the already-computed wire AABB cache when available — avoids re-tessellating. - if self.current_layout == "Model" { - let cache = self.wire_cache.borrow(); - if let Some(((epoch, _cam_gen), _gen, ref arc)) = *cache { - if epoch == self.geometry_epoch { - for wire in arc.iter() { - let [ax, ay, bx, by] = wire.aabb; - let lo = glam::Vec3::new(ax, ay, 0.0); - let hi = glam::Vec3::new(bx, by, 0.0); - // Reject the whole AABB unless every component is finite: - // rays/xlines carry an unbounded AABB, and checking only - // x let a vertical ray's infinite y poison the extents. - if lo.is_finite() && hi.is_finite() { - min = min.min(lo); - max = max.max(hi); - any = true; - } - } - // 3D solids render as meshes, not wires, so fold their - // complete 3D boxes in too — otherwise view fitting loses - // both solid-only drawings and their depth. - for (&handle, set) in &self.meshes { - if !self.mesh_entity_visible(handle) - || !self.document.get_entity(handle).is_some_and(|entity| { - self.belongs_to_visible_block( - handle, - entity.common().owner_handle, - model_block, - ) - }) - { - continue; - } - let [ax, ay, bx, by] = set.world_aabb; - let [az, bz] = set.z_aabb; - let lo = glam::Vec3::new(ax, ay, az); - let hi = glam::Vec3::new(bx, by, bz); - if lo.is_finite() && hi.is_finite() { - min = min.min(lo); - max = max.max(hi); - any = true; - } - } - return if any { Some((min, max)) } else { None }; - } - } - } - - // Fallback: tessellate (first call or paper-space context). - // wire.key_vertices live in offset-rel coords (world_offset - // already subtracted at tessellation time). Add it back so the - // result matches Path 1 above and the caller's expectation — - // callers (auto_fit_viewport) write the centroid directly to - // `Viewport.view_target`, which is a WCS field; storing - // offset-rel coords there silently double-subtracts world_offset - // inside `camera_for_viewport` and points the viewport at the - // wrong location on UTM-scale drawings. - // - // `tessellate_one` resolves the background, annotation scale and block - // cache from `&self` on every call, and this walk calls it once per - // model entity — 186 k times on a large drawing, each one taking a - // `RefCell` borrow for a block cache that is the same every time. Those - // inputs do not vary across the loop, so they are resolved once and the - // shared tessellator is called directly, which also lets the walk run - // in parallel the way the wire builder's own tessellation pass does. - // Same arguments, same output: only who computes them changed. - let (bg_for_tess, anno_for_tess, anno_handle_for_tess, blk_for_tess, paper_for_tess) = - self.tessellation_context(); - let subjects: Vec<&EntityType> = self - .document - .entities() - .filter(|entity| { - let c = entity.common(); - c.owner_handle == model_block - && !c.invisible - && !self.entity_temporarily_hidden(c.handle) - }) - .collect(); - let doc = &self.document; - let sel = &self.selected; - let avp = self.active_viewport; - let blk_ref: &cache::block_cache::BlockCache = &blk_for_tess; - use rayon::iter::{IntoParallelIterator, ParallelIterator}; - let reduced = subjects - .into_par_iter() - .map(|entity| { - let mut lo = glam::Vec3::splat(f32::INFINITY); - let mut hi = glam::Vec3::splat(f32::NEG_INFINITY); - let mut hit = false; - for wire in tessellate_entity( - doc, - sel, - avp, - bg_for_tess, - anno_for_tess, - anno_handle_for_tess, - entity, - Some(blk_ref), - None, - None, - paper_for_tess, - ) { - for &[x, y, z] in &wire.key_vertices { - let v = glam::Vec3::new(x as f32, y as f32, z as f32); - // Check finiteness *after* the f32 cast: a ray/xline - // endpoint is a huge-but-finite f64 that overflows to - // inf in f32, which the f64 `is_finite` test would have - // let through. - if v.is_finite() { - lo = lo.min(v); - hi = hi.max(v); - hit = true; - } - } - } - (lo, hi, hit) - }) - .reduce( - || { - ( - glam::Vec3::splat(f32::INFINITY), - glam::Vec3::splat(f32::NEG_INFINITY), - false, - ) - }, - |a, b| (a.0.min(b.0), a.1.max(b.1), a.2 || b.2), - ); - if reduced.2 { - min = min.min(reduced.0); - max = max.max(reduced.1); - any = true; - } - // Same mesh inclusion for the tessellate fallback path. - for (&handle, set) in &self.meshes { + // Reuse the full resident source in every layout. The kernel bounds + // its world-space key vertices; camera fitting never tessellates a + // second copy or reads the current paper sheet's wire cache. + let scale = crate::scene::annotative::scale_handle_by_name( + &self.document, + &self.document.header.current_annotation_scale, + ); + let wires = self.resident_wires_for( + model_block, + Some(self.annotation_scale), + scale, + None, + None, + ); + let wire_points = wires.iter().flat_map(|wire| wire.key_vertices.iter().copied()); + let mesh_points = self.meshes.iter().filter_map(|(&handle, set)| { + let entity = self.document.get_entity(handle)?; if !self.mesh_entity_visible(handle) - || !self.document.get_entity(handle).is_some_and(|entity| { - self.belongs_to_visible_block( - handle, - entity.common().owner_handle, - model_block, - ) - }) + || !self.belongs_to_visible_block(handle, entity.common().owner_handle, model_block) { - continue; + return None; } let [ax, ay, bx, by] = set.world_aabb; let [az, bz] = set.z_aabb; - let lo = glam::Vec3::new(ax, ay, az); - let hi = glam::Vec3::new(bx, by, bz); - if lo.is_finite() && hi.is_finite() { - min = min.min(lo); - max = max.max(hi); - any = true; - } - } - if any { - return Some((min, max)); + Some([ + [ax as f64, ay as f64, az as f64], + [bx as f64, by as f64, bz as f64], + ]) + }).flatten(); + let points = wire_points.chain(mesh_points).filter(|point| { + point.iter().all(|coordinate| (*coordinate as f32).is_finite()) + }); + if let Some(bounds) = cadkernel::brep::bounds::Aabb::around(points) { + return Some(( + glam::Vec3::from_array(bounds.min.map(|v| v as f32)), + glam::Vec3::from_array(bounds.max.map(|v| v as f32)), + )); } // Last resort: saved EXTMIN/EXTMAX before the wire cache is built. const SANE_EXTENT: f64 = 1.0e16; @@ -10649,7 +10474,7 @@ mod journal_tests { // What the membership index reports for `block`, in order. fn indexed(s: &Scene, block: Handle) -> Vec { let members = s.block_members(); - let (_, by_block, _) = &*members; + let (_, by_block) = &*members; by_block.get(&block).cloned().unwrap_or_default() } @@ -11311,3 +11136,83 @@ mod selection_arc_tests { assert!(true); } } + +#[cfg(test)] +mod layout_cache_tests { + use super::*; + #[test] + fn memoized_plain_line_matches_fresh_background() { + let mut s = Scene::new(); + s.bg_color = [0.1, 0.1, 0.1, 1.0]; + let handle = s.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + ))); + let build = |s: &Scene| { + s.wires_for_block_culled( + s.model_space_block_handle(), + None, + None, + None, + None, + None, + true, + None, + ) + .into_iter() + .find(|wire| wire.name == handle.value().to_string()) + .unwrap() + }; + let original = build(&s); + assert!(s.resident_tess_memo.borrow().contains_key(&handle)); + s.paper_bg_color = [1.0; 4]; + s.document.add_layout("Review").unwrap(); + s.set_current_layout("Review".to_string()); + let cached = build(&s); + s.resident_tess_memo.borrow_mut().clear(); + let fresh = build(&s); + assert_ne!( + original.color, fresh.color, + "fixture must depend on background" + ); + assert_eq!( + cached.color, fresh.color, + "memo hit should match fresh tessellation on a white background" + ); + } + + #[test] + fn model_extents_reuse_resident_geometry_in_every_layout() { + let mut s = Scene::new(); + s.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(1_000_000.0, 2_000_000.0, 30.0), + acadrust::types::Vector3::new(1_000_010.0, 2_000_020.0, 40.0), + ))); + s.document.add_layout("Review").unwrap(); + for layout in ["Model", "Review"] { + s.set_current_layout(layout.to_string()); + *s.model_extents_cache.borrow_mut() = None; + let scale = crate::scene::annotative::scale_handle_by_name( + &s.document, + &s.document.header.current_annotation_scale, + ); + let source = s.resident_wires_for( + s.model_space_block_handle(), + Some(s.annotation_scale), + scale, + None, + None, + ); + let generation = s.last_model_wire_gen.get(); + let bounds = s.model_space_extents().unwrap(); + assert_eq!(bounds.0, glam::Vec3::new(1_000_000.0, 2_000_000.0, 30.0)); + assert_eq!(bounds.1, glam::Vec3::new(1_000_010.0, 2_000_020.0, 40.0)); + assert_eq!( + s.last_model_wire_gen.get(), + generation, + "fitting must reuse the resident source" + ); + assert!(!source.is_empty()); + } + } +} diff --git a/src/scene/pipeline/gpu_tests.rs b/src/scene/pipeline/gpu_tests.rs index 72fe30e25..90064d106 100644 --- a/src/scene/pipeline/gpu_tests.rs +++ b/src/scene/pipeline/gpu_tests.rs @@ -319,3 +319,92 @@ fn toggling_shadows_reallocates_without_upsetting_the_device() { "GPU validation failed" ); } + +#[test] +#[ignore = "requires a GPU adapter"] +fn urgent_release_preserves_visible_panes_and_frees_cold_slots() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = + block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).unwrap(); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .unwrap(); + let mut pipeline = ::new( + &device, + &queue, + wgpu::TextureFormat::Bgra8UnormSrgb, + ); + let first = pipeline.resolve_slots(&device, &queue, &[10])[0]; + pipeline.inners[first].slot_id = 10; + pipeline.inners[first].ensure_depth_texture(&device, Size::new(512, 512)); + let second = pipeline.resolve_slots(&device, &queue, &[20])[0]; + pipeline.inners[second].slot_id = 20; + pipeline.inners[second].ensure_depth_texture(&device, Size::new(512, 512)); + pipeline.release_idle_slots(&device, &[second], true); + assert_eq!( + pipeline.inners[first].alloc_size, + Size::new(512, 512), + "a sibling pane prepared immediately before this pane is still visible" + ); + + pipeline + .frame_rendered + .store(true, std::sync::atomic::Ordering::Relaxed); + let next = pipeline.resolve_slots(&device, &queue, &[20]); + assert_eq!( + pipeline.release_idle_slots(&device, &next, true), + 0, + "a previous-frame pane may still be prepared later in this frame" + ); + pipeline + .frame_rendered + .store(true, std::sync::atomic::Ordering::Relaxed); + let next = pipeline.resolve_slots(&device, &queue, &[20]); + assert_eq!(pipeline.release_idle_slots(&device, &next, true), 1); + assert_eq!(pipeline.inners[first].alloc_size, Size::new(0, 0)); + assert_eq!(pipeline.inners[second].alloc_size, Size::new(512, 512)); +} + +#[test] +#[ignore = "requires a GPU adapter"] +fn late_device_errors_invalidate_uploaded_content_and_targets() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = + block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).unwrap(); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .unwrap(); + let mut pipeline = ::new( + &device, + &queue, + wgpu::TextureFormat::Bgra8UnormSrgb, + ); + let inner = &mut pipeline.inners[0]; + inner.cached_wire_id = 7; + inner.wire_arena_id = 7; + inner.ensure_depth_texture(&device, Size::new(512, 512)); + let before = gpu_errors_seen(); + let _rejected = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("rejected test allocation"), + size: device.limits().max_buffer_size + 4, + usage: wgpu::BufferUsages::VERTEX, + mapped_at_creation: false, + }); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + assert!(gpu_errors_seen() > before); + inner.sync_gpu_error_epoch(); + assert_eq!(inner.cached_wire_id, u64::MAX); + assert_eq!(inner.wire_arena_id, u64::MAX); + assert_eq!(inner.alloc_size, Size::new(0, 0)); + assert_eq!(inner.background_source_id, usize::MAX); + inner.cached_wire_id = 8; + inner.sync_gpu_error_epoch(); + assert_eq!( + inner.cached_wire_id, 8, + "the same error must not invalidate twice" + ); +} diff --git a/src/scene/pipeline/hatch_gpu/mod.rs b/src/scene/pipeline/hatch_gpu/mod.rs index 8731bfd9c..ea5db5789 100644 --- a/src/scene/pipeline/hatch_gpu/mod.rs +++ b/src/scene/pipeline/hatch_gpu/mod.rs @@ -159,6 +159,19 @@ impl HatchGpu { } } + pub(super) fn clear(&mut self) { + match &mut self.backend { + HatchBackend::Storage { resident, preview } => { + resident.clear(); + preview.clear(); + } + HatchBackend::Texture { resident, preview } => { + resident.clear(); + preview.clear(); + } + } + } + pub fn upload(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, hatches: &[HatchModel]) { match &mut self.backend { HatchBackend::Storage { resident, .. } => { diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index da2248a23..908fece79 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -74,6 +74,7 @@ struct MeshHighlightDraw { } pub struct Pipeline { + gpu_error_epoch: usize, background_pipeline: wgpu::RenderPipeline, shadow_pipeline: wgpu::RenderPipeline, shadow_plain_pipeline: wgpu::RenderPipeline, @@ -2226,6 +2227,7 @@ impl Pipeline { wire_const_bgl, block_wire_const_bgl, block_wire_mode: wire_mode, + gpu_error_epoch: gpu_errors_seen(), wipeout_pipeline, hatch_gpu, image_pipeline, @@ -3557,9 +3559,12 @@ impl Pipeline { fn sync_shadow_target(&mut self, device: &wgpu::Device) { match (self.shadow_enabled, self.shadow_full.is_some()) { (true, false) => { + let errors_before = gpu_errors_seen(); let texture = create_shadow_texture(device, SHADOW_MAP_SIZE); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - self.shadow_full = Some((texture, view)); + if gpu_errors_seen() == errors_before { + self.shadow_full = Some((texture, view)); + } } (false, true) => self.shadow_full = None, _ => return, @@ -3575,7 +3580,11 @@ impl Pipeline { ) { self.shadow_enabled = uniforms.shadow_params[0] > 0.5; self.sync_shadow_target(device); - queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(uniforms)); + let mut uniforms = *uniforms; + if self.shadow_full.is_none() { + uniforms.shadow_params[0] = 0.0; + } + queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniforms)); } pub fn upload_background_images( @@ -3710,9 +3719,7 @@ impl Pipeline { // render rectangle does the clipping). let stencil_ref: u32 = if self.clip_boundary.is_some() { 0xFF } else { 0 }; - // `shadow_enabled` says the style wants shadows; the target existing - // says the device could give us one. Without the second test a failed - // 16 MiB allocation would render the pass into the 1x1 placeholder. + // Render shadows only when a full target was allocated. if let Some(shadow_target) = self .shadow_full .as_ref() @@ -4671,6 +4678,23 @@ impl Pipeline { self.render_sig = u64::MAX; } + /// Catch errors delivered after the previous upload/submit completed. + pub(crate) fn sync_gpu_error_epoch(&mut self) { + let errors = gpu_errors_seen(); + if errors == self.gpu_error_epoch { + return; + } + self.gpu_error_epoch = errors; + self.forget_cached_keys(); + self.wire_arena = None; + self.wire_arena_mesh = None; + self.wire_arena_id = u64::MAX; + self.alloc_size = Size::new(0, 0); + self.shadow_full = None; + self.background_source_id = usize::MAX; + self.environment_source_id = usize::MAX; + } + /// Give this slot's device memory back and forget what it held. /// /// A slot nobody is drawing still owns its wire arena, its render targets, @@ -4698,6 +4722,7 @@ impl Pipeline { self.gpu_selected_wires.clear(); self.gpu_selected_block_wires.clear(); self.gpu_preview_wires.clear(); + self.hatch_gpu.clear(); self.gpu_wipeouts.clear(); self.wipeout_skip_flags.clear(); self.gpu_images.clear(); @@ -4928,19 +4953,8 @@ impl Pipeline { } impl MultiPipeline { - /// Hand back the device memory of slots nobody is drawing. - /// - /// `reserved` are the slots this frame is about to use; they are never - /// touched. Everything else is released once it has been idle for - /// `idle_frames` prepare calls — gradually, so an ordinary session pays - /// nothing and a layout the user keeps switching back to stays warm. - /// - /// `urgent` drops that patience: the device has just refused an allocation, - /// and holding a viewport nobody can see is what turns a degraded frame - /// into a session that never recovers. Releasing early costs one slow frame - /// if the slot is wanted again; not releasing costs the drawing. - /// - /// Returns how many slots were released. + /// Release cold slots after 64 frames, or two frames under memory pressure. + /// Current and previous frame slots remain reserved for sibling widgets. pub(crate) fn release_idle_slots( &mut self, device: &wgpu::Device, @@ -4948,7 +4962,9 @@ impl MultiPipeline { urgent: bool, ) -> usize { const IDLE_FRAMES: u64 = 64; - let idle_frames = if urgent { 0 } else { IDLE_FRAMES }; + // Other shader widgets may prepare later in this frame. Keep every + // slot used in this or the previous frame, even under memory pressure. + let idle_frames = if urgent { 2 } else { IDLE_FRAMES }; let mut released = 0; for index in 0..self.inners.len() { if reserved.contains(&index) { @@ -4970,6 +4986,15 @@ impl MultiPipeline { self.inners[index].release_heavy_resources(device); released += 1; } + if released > 0 { + self.wire_buffer_cache.retain(|_, entry| { + std::sync::Arc::strong_count(&entry.0) > 1 + || std::sync::Arc::strong_count(&entry.1) > 1 + }); + self.block_geometry.retain(|_, chunks| { + chunks.iter().any(|chunk| std::sync::Arc::strong_count(&chunk.bind_group) > 1) + }); + } released } @@ -5104,25 +5129,11 @@ pub struct MultiPipeline { pub(crate) slot_by_instance: rustc_hash::FxHashMap, slot_last_used: Vec, slot_clock: u64, - /// The resident wire batches, keyed by `wire_content_id` and shared across - /// every slot (and every pane — one `MultiPipeline` backs all of them) that - /// renders the same content. `prepare` builds an entry once on a cache miss - /// then hands `Arc` clones to each slot, so N paper viewports / Model tiles - /// showing one identical resident set upload the wire vertex buffers exactly - /// once between them instead of once per slot. Kept trim by dropping entries - /// no slot still references (`Arc::strong_count == 1`) once it grows past a - /// small bound. - /// Block geometry that survives an edit, keyed by the definition it - /// expands rather than by anything a viewport contributes. See - /// `wire_gpu::BlockGeometryCache`. - /// - /// Shared across slots for the same reason `wire_buffer_cache` is: a - /// definition's segments are identical in every viewport that draws it, and - /// this is the largest upload the renderer makes — 59.5 MiB on the - /// reproducer. Held per slot, zooming a paper layout out until four or five - /// viewports were on screen uploaded it once each and ran a 2 GB card out - /// of memory. + pub(crate) frame_rendered: std::sync::atomic::AtomicBool, + pub(crate) gpu_error_epoch: usize, + /// Definition geometry shared across viewport slots. pub(crate) block_geometry: wire_gpu::BlockGeometryCache, + /// Shared resident batches, keyed by wire content identity. pub(crate) wire_buffer_cache: rustc_hash::FxHashMap< u64, ( @@ -5134,11 +5145,7 @@ pub struct MultiPipeline { } impl MultiPipeline { - /// Ensure at least `n` (≥1) inner pipelines exist, creating any missing - /// ones. Grow-only: extra pipelines are NOT dropped, because per-pane Model - /// shader widgets share this storage and each only touches its own slot — - /// truncating mid-frame would destroy another pane's slot. Stale inners (a - /// closed pane, or fewer paper viewports) are harmless idle GPU resources. + /// Grow slot storage without disturbing sibling shader widgets. pub(crate) fn ensure_len( &mut self, device: &wgpu::Device, @@ -5152,12 +5159,7 @@ impl MultiPipeline { } } - /// Resolve stable slots for the viewport identities in one primitive. - /// Thirty-two hot slots cover ordinary tiled/paper drawings. A cold slot - /// is recycled only after several other prepare calls, which prevents - /// sibling Model panes prepared in the same frame from evicting each - /// other. If every slot is still hot, growing is safer than a visible - /// rebuild hitch. + /// Preserve viewport slots across frames; recycle only cold slots above the limit. pub(crate) fn resolve_slots( &mut self, device: &wgpu::Device, @@ -5167,7 +5169,11 @@ impl MultiPipeline { const SOFT_LIMIT: usize = 32; const HOT_WINDOW: u64 = 8; - self.slot_clock = self.slot_clock.wrapping_add(1).max(1); + if self.frame_rendered.swap(false, std::sync::atomic::Ordering::Relaxed) + || self.slot_clock == 0 + { + self.slot_clock = self.slot_clock.wrapping_add(1).max(1); + } let now = self.slot_clock; let reserved: rustc_hash::FxHashSet = instance_ids.iter().copied().collect(); // `inner.slot_id` is updated later in `prepare`, after this whole @@ -5284,6 +5290,8 @@ impl iced::widget::shader::Pipeline for MultiPipeline { slot_by_instance: rustc_hash::FxHashMap::default(), slot_last_used: vec![0], slot_clock: 0, + frame_rendered: std::sync::atomic::AtomicBool::new(false), + gpu_error_epoch: gpu_errors_seen(), wire_buffer_cache: rustc_hash::FxHashMap::default(), } } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 89c90f10a..72fd2b92d 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -364,20 +364,20 @@ impl shader::Primitive for Primitive { viewport: &Viewport, ) { let nav_prepare_started = iced::time::Instant::now(); + let errors_at_entry = crate::scene::pipeline::gpu_errors_seen(); + let recovering = pipeline.gpu_error_epoch != errors_at_entry; + if recovering { + pipeline.wire_buffer_cache.clear(); + pipeline.block_geometry.clear(); + pipeline.gpu_error_epoch = errors_at_entry; + } let scale = viewport.scale_factor() as f32; let instance_ids: Vec = self.viewports.iter().map(|vp| vp.instance_id).collect(); let slots = pipeline.resolve_slots(device, queue, &instance_ids); - // Compared after the frame's uploads: a move means the device refused - // something, which changes how patient the slot release below can be. - let errors_at_entry = crate::scene::pipeline::gpu_errors_seen(); - for (i, vp) in self.viewports.iter().enumerate() { let inner = &mut pipeline.inners[slots[i]]; - // Compared at the end of this viewport's turn. Every "we hold this - // now" latch below is stamped after its upload, whether or not the - // device accepted it — and the wire latch gates the whole upload - // block, so one rejected allocation could leave a slotpermanently empty with - // nothing that would ever rebuild it. + inner.sync_gpu_error_epoch(); + // Do not latch rejected uploads as current content. let slot_errors_before = crate::scene::pipeline::gpu_errors_seen(); // Pipeline slots are addressed by list index, but off-canvas // viewports are dropped from the list — so a slot can be reused by a @@ -1216,13 +1216,9 @@ impl shader::Primitive for Primitive { self.viewports.len(), ); } - // Give back what nobody is drawing. Gradual while the device is - // healthy; immediate once it has refused an allocation, because the - // memory a hidden viewport is holding is exactly what the visible one - // could not get — and freeing it is what lets the operator close a - // viewport or two and carry on instead of restarting. + // Memory pressure shortens cold-slot retention without evicting siblings. let errors_now = crate::scene::pipeline::gpu_errors_seen(); - let urgent = errors_now != errors_at_entry; + let urgent = recovering || errors_now != errors_at_entry; let released = pipeline.release_idle_slots(device, &slots, urgent); if released > 0 && crate::perf::enabled() { crate::perf_record!( @@ -1241,6 +1237,7 @@ impl shader::Primitive for Primitive { clip: &Rectangle, ) { let nav_render_started = iced::time::Instant::now(); + pipeline.frame_rendered.store(true, std::sync::atomic::Ordering::Relaxed); let cw = clip.width as f32; let ch = clip.height as f32; let clip_right = clip.x + clip.width;