diff --git a/assets/icons/attributes/lineweight.svg b/assets/icons/attributes/lineweight.svg new file mode 100644 index 000000000..1cd7f45e2 --- /dev/null +++ b/assets/icons/attributes/lineweight.svg @@ -0,0 +1,57 @@ + + + + + + + + + diff --git a/assets/icons/attributes/transparency.svg b/assets/icons/attributes/transparency.svg new file mode 100644 index 000000000..451288a65 --- /dev/null +++ b/assets/icons/attributes/transparency.svg @@ -0,0 +1,50 @@ + + + + + + + diff --git a/assets/icons/gradient/gradient_curved.svg b/assets/icons/gradient/gradient_curved.svg new file mode 100644 index 000000000..62e2dcada --- /dev/null +++ b/assets/icons/gradient/gradient_curved.svg @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/gradient/gradient_curved_inverted.svg b/assets/icons/gradient/gradient_curved_inverted.svg new file mode 100644 index 000000000..75177eb43 --- /dev/null +++ b/assets/icons/gradient/gradient_curved_inverted.svg @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/gradient/gradient_cylindrical.svg b/assets/icons/gradient/gradient_cylindrical.svg new file mode 100644 index 000000000..5ae41e792 --- /dev/null +++ b/assets/icons/gradient/gradient_cylindrical.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/gradient/gradient_cylindrical_inverted.svg b/assets/icons/gradient/gradient_cylindrical_inverted.svg new file mode 100644 index 000000000..015caca73 --- /dev/null +++ b/assets/icons/gradient/gradient_cylindrical_inverted.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/gradient/gradient_hemispherical.svg b/assets/icons/gradient/gradient_hemispherical.svg new file mode 100644 index 000000000..675bc94a8 --- /dev/null +++ b/assets/icons/gradient/gradient_hemispherical.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + diff --git a/assets/icons/gradient/gradient_hemispherical_inverted.svg b/assets/icons/gradient/gradient_hemispherical_inverted.svg new file mode 100644 index 000000000..e2a254153 --- /dev/null +++ b/assets/icons/gradient/gradient_hemispherical_inverted.svg @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/icons/gradient/gradient_linear.svg b/assets/icons/gradient/gradient_linear.svg new file mode 100644 index 000000000..f1f3c2736 --- /dev/null +++ b/assets/icons/gradient/gradient_linear.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + diff --git a/assets/icons/gradient/gradient_spherical.svg b/assets/icons/gradient/gradient_spherical.svg new file mode 100644 index 000000000..b95f3b4f7 --- /dev/null +++ b/assets/icons/gradient/gradient_spherical.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + diff --git a/assets/icons/gradient/gradient_spherical_inverted.svg b/assets/icons/gradient/gradient_spherical_inverted.svg new file mode 100644 index 000000000..2e6816449 --- /dev/null +++ b/assets/icons/gradient/gradient_spherical_inverted.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + diff --git a/assets/icons/graphic_attributes.svg b/assets/icons/graphic_attributes.svg new file mode 100644 index 000000000..aed1edd32 --- /dev/null +++ b/assets/icons/graphic_attributes.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/hatch/hatch_gradient.svg b/assets/icons/hatch/hatch_gradient.svg index 199d8a941..7faf6ee0a 100644 --- a/assets/icons/hatch/hatch_gradient.svg +++ b/assets/icons/hatch/hatch_gradient.svg @@ -1,10 +1,87 @@ - - - - - + + + + + + + + + + + + - - + diff --git a/assets/icons/hatch/hatch_lines.svg b/assets/icons/hatch/hatch_lines.svg index 1b72bb148..0ec5b480c 100644 --- a/assets/icons/hatch/hatch_lines.svg +++ b/assets/icons/hatch/hatch_lines.svg @@ -6,7 +6,7 @@ version="1.1" id="svg6" sodipodi:docname="hatch_lines.svg" - inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)" + inkscape:version="1.4.3 (0d15f75042, 2025-12-25)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" @@ -20,15 +20,15 @@ inkscape:pageopacity="0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#505050" - inkscape:zoom="6.3152247" - inkscape:cx="34.044711" - inkscape:cy="32.698757" - inkscape:window-width="1920" - inkscape:window-height="939" + inkscape:zoom="8.9310764" + inkscape:cx="26.032696" + inkscape:cy="34.878215" + inkscape:window-width="2560" + inkscape:window-height="1368" inkscape:window-x="0" inkscape:window-y="0" inkscape:window-maximized="1" - inkscape:current-layer="svg6" /> + inkscape:current-layer="g6" /> + id="line1" + style="stroke-width:1.25222463;stroke-dasharray:none" /> + id="line2" + style="stroke-width:1.25222;stroke-dasharray:none" /> + id="line3" + inkscape:transform-center-x="1.4695877" + inkscape:transform-center-y="-1.4695877" + style="stroke-width:1.25222;stroke-dasharray:none" /> + id="line5" + style="stroke-width:1.25222;stroke-dasharray:none" /> + id="rect6" + style="stroke-width:1.875;stroke-dasharray:none" /> diff --git a/assets/icons/hatch/hatch_none.svg b/assets/icons/hatch/hatch_none.svg new file mode 100644 index 000000000..fe8936ab9 --- /dev/null +++ b/assets/icons/hatch/hatch_none.svg @@ -0,0 +1,50 @@ + + + + + + + + diff --git a/assets/icons/hatch/hatch_solid.svg b/assets/icons/hatch/hatch_solid.svg index 5cccf8100..846d024a7 100644 --- a/assets/icons/hatch/hatch_solid.svg +++ b/assets/icons/hatch/hatch_solid.svg @@ -1,5 +1,45 @@ - + + + + - - + diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index d2055bb35..a13a13223 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -6185,3 +6185,38 @@ dock = .new-group = مجموعة جديدة .browser = المستعرض .node-graph = مخطط العقد + +graphic-attributes = + .title = سمات الرسم + .set-all-bylayer = تعيين كل السمات إلى ByLayer + .set-all-byblock = تعيين كل السمات إلى ByBlock + .remove-bylayer-byblock = إزالة ByLayer / ByBlock + .create-layer-active-settings = إنشاء طبقة بالإعدادات النشطة + .fills-on-current-layer = إنشاء التعبئات على الطبقة الحالية + .edit-hatch = تحرير التهشير + .edit-gradient = تحرير التدرج + .close-open-object = الكائن المحدد مفتوح. هل تريد إغلاقه قبل إنشاء التعبئة؟ [Yes / No] + .close-open-objects = __ocs_fmt_0__ من الكائنات المحددة مفتوحة. هل تريد إغلاقها قبل إنشاء التعبئة؟ [Yes / No] + .enter-new-layer-name = أدخل اسمًا للطبقة الجديدة: + .invalid-layer-name = سمات الرسم: أدخل اسم طبقة صالحًا بدون <>/\":;?*|,=`. + .layer-exists = سمات الرسم: الطبقة "__ocs_fmt_0__" موجودة بالفعل. + .layer-not-created = سمات الرسم: تعذر إنشاء الطبقة. + .layer-created = تم إنشاء الطبقة "__ocs_fmt_0__" بإعدادات سمات الرسم النشطة. + .gradient-fill-locked = تعبئة متدرجة: التعبئة على طبقة مقفلة. + .select-closed-objects = حدد كائنًا مغلقًا واحدًا أو أكثر أولاً. + .locked-objects-skipped = سمات الرسم: لم يتم تغيير __ocs_fmt_0__ من الكائنات على طبقة مقفلة. + .no-hatch-pattern = سمات الرسم: لا يتوفر نمط تهشير. + .associated-fill-locked = سمات الرسم: توجد تعبئة مرتبطة على طبقة مقفلة. + .one-unsupported = سمات الرسم: لم يتم تغيير كائن واحد غير مدعوم. + .unsupported-skipped = سمات الرسم: لم يتم تغيير __ocs_fmt_0__ من الكائنات غير المدعومة. + .fill-transparency = شفافية التعبئة + .fill-change = تغيير التعبئة + .line-transparency = شفافية الخطوط + .cylindrical-short = أسطواني + .spherical-short = كروي + .hemispherical-short = نصف كروي + .invert-gradient = عكس التدرج + .tone = الدرجة + .reset-angle = إعادة تعيين الزاوية + .one-color = لون واحد + .two-colors = لونان diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index c843de1e1..d6d751979 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -6173,3 +6173,38 @@ dock = .new-group = Нова група .browser = Браузър .node-graph = Граф на възлите + +graphic-attributes = + .title = Графични атрибути + .set-all-bylayer = Задаване на всички атрибути на ByLayer + .set-all-byblock = Задаване на всички атрибути на ByBlock + .remove-bylayer-byblock = Премахване на ByLayer / ByBlock + .create-layer-active-settings = Създаване на слой с активните настройки + .fills-on-current-layer = Създаване на запълвания в текущия слой + .edit-hatch = Редактиране на щриховката + .edit-gradient = Редактиране на преливането + .close-open-object = Избраният обект е отворен. Да се затвори ли преди създаване на запълването? [Yes / No] + .close-open-objects = __ocs_fmt_0__ от избраните обекти са отворени. Да се затворят ли преди създаване на запълването? [Yes / No] + .enter-new-layer-name = Въведете име за новия слой: + .invalid-layer-name = Графични атрибути: въведете валидно име на слой без <>/\":;?*|,=`. + .layer-exists = Графични атрибути: слоят "__ocs_fmt_0__" вече съществува. + .layer-not-created = Графични атрибути: слоят не можа да бъде създаден. + .layer-created = Създаден е слой "__ocs_fmt_0__" с активните настройки на графичните атрибути. + .gradient-fill-locked = Преливащо запълване: запълването е в заключен слой. + .select-closed-objects = Първо изберете един или повече затворени обекта. + .locked-objects-skipped = Графични атрибути: __ocs_fmt_0__ обекта в заключен слой не бяха променени. + .no-hatch-pattern = Графични атрибути: няма наличен шаблон за щриховка. + .associated-fill-locked = Графични атрибути: свързано запълване е в заключен слой. + .one-unsupported = Графични атрибути: 1 неподдържан обект не беше променен. + .unsupported-skipped = Графични атрибути: __ocs_fmt_0__ неподдържани обекта не бяха променени. + .fill-transparency = Прозрачност на запълването + .fill-change = Промяна на запълването + .line-transparency = Прозрачност на линиите + .cylindrical-short = Цил. + .spherical-short = Сфер. + .hemispherical-short = Полусф. + .invert-gradient = Обръщане на преливането + .tone = Тон + .reset-angle = Нулиране на ъгъла + .one-color = Един цвят + .two-colors = Два цвята diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 1ff56bf14..779f63fb7 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -6170,3 +6170,38 @@ dock = .new-group = Nová skupina .browser = Prohlížeč .node-graph = Graf uzlů + +graphic-attributes = + .title = Grafické atributy + .set-all-bylayer = Nastavit všechny atributy na ByLayer + .set-all-byblock = Nastavit všechny atributy na ByBlock + .remove-bylayer-byblock = Odebrat ByLayer / ByBlock + .create-layer-active-settings = Vytvořit hladinu s aktivním nastavením + .fills-on-current-layer = Vytvářet výplně v aktuální hladině + .edit-hatch = Upravit šrafu + .edit-gradient = Upravit přechod + .close-open-object = Vybraný objekt je otevřený. Uzavřít jej před vytvořením výplně? [Yes / No] + .close-open-objects = Vybrané objekty jsou otevřené (__ocs_fmt_0__). Uzavřít je před vytvořením výplně? [Yes / No] + .enter-new-layer-name = Zadejte název nové hladiny: + .invalid-layer-name = Grafické atributy: zadejte platný název hladiny bez <>/\":;?*|,=`. + .layer-exists = Grafické atributy: hladina "__ocs_fmt_0__" již existuje. + .layer-not-created = Grafické atributy: hladinu nelze vytvořit. + .layer-created = Hladina "__ocs_fmt_0__" byla vytvořena s aktivním nastavením grafických atributů. + .gradient-fill-locked = Přechodová výplň: výplň je v zamčené hladině. + .select-closed-objects = Nejprve vyberte jeden nebo více uzavřených objektů. + .locked-objects-skipped = Grafické atributy: objekty v zamčené hladině (__ocs_fmt_0__) nebyly změněny. + .no-hatch-pattern = Grafické atributy: není k dispozici žádný vzor šraf. + .associated-fill-locked = Grafické atributy: přidružená výplň je v zamčené hladině. + .one-unsupported = Grafické atributy: 1 nepodporovaný objekt nebyl změněn. + .unsupported-skipped = Grafické atributy: nepodporované objekty (__ocs_fmt_0__) nebyly změněny. + .fill-transparency = Průhlednost výplně + .fill-change = Změna výplně + .line-transparency = Průhlednost čar + .cylindrical-short = Válc. + .spherical-short = Kul. + .hemispherical-short = Polokul. + .invert-gradient = Obrátit přechod + .tone = Tón + .reset-angle = Obnovit úhel + .one-color = Jedna barva + .two-colors = Dvě barvy diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index b7ee02cae..f0e33e76b 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -6171,3 +6171,38 @@ dock = .new-group = Neue Gruppe .browser = Browser .node-graph = Knotengraph + +graphic-attributes = + .title = Grafikattribute + .set-all-bylayer = Alle Attribute auf ByLayer setzen + .set-all-byblock = Alle Attribute auf ByBlock setzen + .remove-bylayer-byblock = ByLayer / ByBlock entfernen + .create-layer-active-settings = Layer mit aktiven Einstellungen erstellen + .fills-on-current-layer = Füllungen auf dem aktuellen Layer erstellen + .edit-hatch = Schraffur bearbeiten + .edit-gradient = Verlauf bearbeiten + .close-open-object = Das ausgewählte Objekt ist offen. Vor dem Erstellen der Füllung schließen? [Yes / No] + .close-open-objects = __ocs_fmt_0__ ausgewählte Objekte sind offen. Vor dem Erstellen der Füllung schließen? [Yes / No] + .enter-new-layer-name = Namen für den neuen Layer eingeben: + .invalid-layer-name = Grafikattribute: gültigen Layernamen ohne <>/\":;?*|,=` eingeben. + .layer-exists = Grafikattribute: Layer "__ocs_fmt_0__" ist bereits vorhanden. + .layer-not-created = Grafikattribute: Der Layer konnte nicht erstellt werden. + .layer-created = Layer "__ocs_fmt_0__" mit den aktiven Grafikattributen erstellt. + .gradient-fill-locked = Verlaufsfüllung: Die Füllung liegt auf einem gesperrten Layer. + .select-closed-objects = Zuerst ein oder mehrere geschlossene Objekte auswählen. + .locked-objects-skipped = Grafikattribute: __ocs_fmt_0__ Objekt(e) auf einem gesperrten Layer wurden nicht geändert. + .no-hatch-pattern = Grafikattribute: Kein Schraffurmuster verfügbar. + .associated-fill-locked = Grafikattribute: Eine zugehörige Füllung liegt auf einem gesperrten Layer. + .one-unsupported = Grafikattribute: 1 nicht unterstütztes Objekt wurde nicht geändert. + .unsupported-skipped = Grafikattribute: __ocs_fmt_0__ nicht unterstützte Objekte wurden nicht geändert. + .fill-transparency = Füllungstransparenz + .fill-change = Füllung ändern + .line-transparency = Linientransparenz + .cylindrical-short = Zyl. + .spherical-short = Kugel + .hemispherical-short = Halbk. + .invert-gradient = Verlauf umkehren + .tone = Ton + .reset-angle = Winkel zurücksetzen + .one-color = Einfarbig + .two-colors = Zweifarbig diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 9d7e93337..d3906e20d 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -6173,3 +6173,38 @@ dock = .new-group = Νέα ομάδα .browser = Περιηγητής .node-graph = Γράφος κόμβων + +graphic-attributes = + .title = Γραφικά χαρακτηριστικά + .set-all-bylayer = Ορισμός όλων των χαρακτηριστικών σε ByLayer + .set-all-byblock = Ορισμός όλων των χαρακτηριστικών σε ByBlock + .remove-bylayer-byblock = Αφαίρεση ByLayer / ByBlock + .create-layer-active-settings = Δημιουργία επιπέδου με τις ενεργές ρυθμίσεις + .fills-on-current-layer = Δημιουργία γεμισμάτων στο τρέχον επίπεδο + .edit-hatch = Επεξεργασία διαγράμμισης + .edit-gradient = Επεξεργασία διαβάθμισης + .close-open-object = Το επιλεγμένο αντικείμενο είναι ανοιχτό. Να κλείσει πριν από τη δημιουργία του γεμίσματος; [Yes / No] + .close-open-objects = __ocs_fmt_0__ επιλεγμένα αντικείμενα είναι ανοιχτά. Να κλείσουν πριν από τη δημιουργία του γεμίσματος; [Yes / No] + .enter-new-layer-name = Εισαγάγετε όνομα για το νέο επίπεδο: + .invalid-layer-name = Γραφικά χαρακτηριστικά: εισαγάγετε έγκυρο όνομα επιπέδου χωρίς <>/\":;?*|,=`. + .layer-exists = Γραφικά χαρακτηριστικά: το επίπεδο "__ocs_fmt_0__" υπάρχει ήδη. + .layer-not-created = Γραφικά χαρακτηριστικά: δεν ήταν δυνατή η δημιουργία του επιπέδου. + .layer-created = Δημιουργήθηκε το επίπεδο "__ocs_fmt_0__" με τις ενεργές ρυθμίσεις γραφικών χαρακτηριστικών. + .gradient-fill-locked = Γέμισμα διαβάθμισης: το γέμισμα βρίσκεται σε κλειδωμένο επίπεδο. + .select-closed-objects = Επιλέξτε πρώτα ένα ή περισσότερα κλειστά αντικείμενα. + .locked-objects-skipped = Γραφικά χαρακτηριστικά: __ocs_fmt_0__ αντικείμενα σε κλειδωμένο επίπεδο δεν άλλαξαν. + .no-hatch-pattern = Γραφικά χαρακτηριστικά: δεν υπάρχει διαθέσιμο μοτίβο διαγράμμισης. + .associated-fill-locked = Γραφικά χαρακτηριστικά: ένα συσχετισμένο γέμισμα βρίσκεται σε κλειδωμένο επίπεδο. + .one-unsupported = Γραφικά χαρακτηριστικά: 1 μη υποστηριζόμενο αντικείμενο δεν άλλαξε. + .unsupported-skipped = Γραφικά χαρακτηριστικά: __ocs_fmt_0__ μη υποστηριζόμενα αντικείμενα δεν άλλαξαν. + .fill-transparency = Διαφάνεια γεμίσματος + .fill-change = Αλλαγή γεμίσματος + .line-transparency = Διαφάνεια γραμμών + .cylindrical-short = Κυλ. + .spherical-short = Σφαιρ. + .hemispherical-short = Ημισφ. + .invert-gradient = Αντιστροφή διαβάθμισης + .tone = Τόνος + .reset-angle = Επαναφορά γωνίας + .one-color = Ένα χρώμα + .two-colors = Δύο χρώματα diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 744f93a65..8ae79dd11 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -6174,3 +6174,38 @@ dock = .new-group = New group .browser = Browser .node-graph = Node Graph + +graphic-attributes = + .title = Graphic Attributes + .set-all-bylayer = Set all attributes ByLayer + .set-all-byblock = Set all attributes ByBlock + .remove-bylayer-byblock = Remove ByLayer / ByBlock + .create-layer-active-settings = Create Layer with active settings + .fills-on-current-layer = Create fills on the current layer + .edit-hatch = Edit hatch + .edit-gradient = Edit gradient + .close-open-object = The selected object is open. Close it before creating the fill? [Yes / No] + .close-open-objects = __ocs_fmt_0__ selected objects are open. Close them before creating the fill? [Yes / No] + .enter-new-layer-name = Enter a name for the new layer: + .invalid-layer-name = Graphic Attributes: enter a valid layer name without <>/\":;?*|,=`. + .layer-exists = Graphic Attributes: layer "__ocs_fmt_0__" already exists. + .layer-not-created = Graphic Attributes: the layer could not be created. + .layer-created = Created layer "__ocs_fmt_0__" with the active Graphic Attributes settings. + .gradient-fill-locked = Gradient Fill: the fill is on a locked layer. + .select-closed-objects = Select one or more closed objects first. + .locked-objects-skipped = Graphic Attributes: __ocs_fmt_0__ object(s) on a locked layer were not changed. + .no-hatch-pattern = Graphic Attributes: no hatch pattern is available. + .associated-fill-locked = Graphic Attributes: an associated fill is on a locked layer. + .one-unsupported = Graphic Attributes: 1 unsupported object was not changed. + .unsupported-skipped = Graphic Attributes: __ocs_fmt_0__ unsupported objects were not changed. + .fill-transparency = Fill transparency + .fill-change = Fill change + .line-transparency = Line transparency + .cylindrical-short = Cyl. + .spherical-short = Spher. + .hemispherical-short = Hemi. + .invert-gradient = Invert gradient + .tone = Tone + .reset-angle = Reset angle + .one-color = One Color + .two-colors = Two Colors diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 11687f899..282915ac7 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -6173,3 +6173,38 @@ dock = .new-group = Nuevo grupo .browser = Explorador .node-graph = Grafo de nodos + +graphic-attributes = + .title = Atributos gráficos + .set-all-bylayer = Establecer todos los atributos en ByLayer + .set-all-byblock = Establecer todos los atributos en ByBlock + .remove-bylayer-byblock = Quitar ByLayer / ByBlock + .create-layer-active-settings = Crear capa con la configuración activa + .fills-on-current-layer = Crear rellenos en la capa actual + .edit-hatch = Editar sombreado + .edit-gradient = Editar degradado + .close-open-object = El objeto seleccionado está abierto. ¿Cerrarlo antes de crear el relleno? [Yes / No] + .close-open-objects = __ocs_fmt_0__ objetos seleccionados están abiertos. ¿Cerrarlos antes de crear el relleno? [Yes / No] + .enter-new-layer-name = Introduzca un nombre para la capa nueva: + .invalid-layer-name = Atributos gráficos: introduzca un nombre de capa válido sin <>/\":;?*|,=`. + .layer-exists = Atributos gráficos: la capa "__ocs_fmt_0__" ya existe. + .layer-not-created = Atributos gráficos: no se pudo crear la capa. + .layer-created = Se creó la capa "__ocs_fmt_0__" con la configuración activa de atributos gráficos. + .gradient-fill-locked = Relleno degradado: el relleno está en una capa bloqueada. + .select-closed-objects = Seleccione primero uno o varios objetos cerrados. + .locked-objects-skipped = Atributos gráficos: no se modificaron __ocs_fmt_0__ objeto(s) en una capa bloqueada. + .no-hatch-pattern = Atributos gráficos: no hay ningún patrón de sombreado disponible. + .associated-fill-locked = Atributos gráficos: un relleno asociado está en una capa bloqueada. + .one-unsupported = Atributos gráficos: no se modificó 1 objeto no admitido. + .unsupported-skipped = Atributos gráficos: no se modificaron __ocs_fmt_0__ objetos no admitidos. + .fill-transparency = Transparencia del relleno + .fill-change = Cambio de relleno + .line-transparency = Transparencia de línea + .cylindrical-short = Cil. + .spherical-short = Esfér. + .hemispherical-short = Semiesf. + .invert-gradient = Invertir degradado + .tone = Tono + .reset-angle = Restablecer ángulo + .one-color = Un color + .two-colors = Dos colores diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index bc4d6dbb2..71e626c9e 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -6170,3 +6170,38 @@ dock = .new-group = Uusi ryhmä .browser = Selain .node-graph = Solmukaavio + +graphic-attributes = + .title = Grafiikkamääritteet + .set-all-bylayer = Aseta kaikki määritteet arvoon ByLayer + .set-all-byblock = Aseta kaikki määritteet arvoon ByBlock + .remove-bylayer-byblock = Poista ByLayer / ByBlock + .create-layer-active-settings = Luo taso aktiivisilla asetuksilla + .fills-on-current-layer = Luo täytöt nykyiselle tasolle + .edit-hatch = Muokkaa viivoitusta + .edit-gradient = Muokkaa liukuväriä + .close-open-object = Valittu objekti on avoin. Suljetaanko se ennen täytön luomista? [Yes / No] + .close-open-objects = __ocs_fmt_0__ valittua objektia on avoimia. Suljetaanko ne ennen täytön luomista? [Yes / No] + .enter-new-layer-name = Anna uuden tason nimi: + .invalid-layer-name = Grafiikkamääritteet: anna kelvollinen tason nimi ilman merkkejä <>/\":;?*|,=`. + .layer-exists = Grafiikkamääritteet: taso "__ocs_fmt_0__" on jo olemassa. + .layer-not-created = Grafiikkamääritteet: tasoa ei voitu luoda. + .layer-created = Taso "__ocs_fmt_0__" luotiin aktiivisilla grafiikkamääritteillä. + .gradient-fill-locked = Liukuväritäyttö: täyttö on lukitulla tasolla. + .select-closed-objects = Valitse ensin yksi tai useampi suljettu objekti. + .locked-objects-skipped = Grafiikkamääritteet: __ocs_fmt_0__ lukitulla tasolla olevaa objektia jätettiin muuttamatta. + .no-hatch-pattern = Grafiikkamääritteet: viivoituskuviota ei ole saatavilla. + .associated-fill-locked = Grafiikkamääritteet: liitetty täyttö on lukitulla tasolla. + .one-unsupported = Grafiikkamääritteet: 1 tukematonta objektia ei muutettu. + .unsupported-skipped = Grafiikkamääritteet: __ocs_fmt_0__ tukematonta objektia jätettiin muuttamatta. + .fill-transparency = Täytön läpinäkyvyys + .fill-change = Täytön muutos + .line-transparency = Viivojen läpinäkyvyys + .cylindrical-short = Lieriö + .spherical-short = Pallo + .hemispherical-short = Puolip. + .invert-gradient = Käännä liukuväri + .tone = Sävy + .reset-angle = Palauta kulma + .one-color = Yksi väri + .two-colors = Kaksi väriä diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index e27a389f4..47ab852c5 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -6172,3 +6172,38 @@ dock = .new-group = Nouveau groupe .browser = Navigateur .node-graph = Graphe de nœuds + +graphic-attributes = + .title = Attributs graphiques + .set-all-bylayer = Définir tous les attributs sur ByLayer + .set-all-byblock = Définir tous les attributs sur ByBlock + .remove-bylayer-byblock = Supprimer ByLayer / ByBlock + .create-layer-active-settings = Créer un calque avec les paramètres actifs + .fills-on-current-layer = Créer les remplissages sur le calque courant + .edit-hatch = Modifier la hachure + .edit-gradient = Modifier le dégradé + .close-open-object = L'objet sélectionné est ouvert. Le fermer avant de créer le remplissage ? [Yes / No] + .close-open-objects = __ocs_fmt_0__ objets sélectionnés sont ouverts. Les fermer avant de créer le remplissage ? [Yes / No] + .enter-new-layer-name = Entrez un nom pour le nouveau calque : + .invalid-layer-name = Attributs graphiques : entrez un nom de calque valide sans <>/\":;?*|,=`. + .layer-exists = Attributs graphiques : le calque "__ocs_fmt_0__" existe déjà. + .layer-not-created = Attributs graphiques : impossible de créer le calque. + .layer-created = Calque "__ocs_fmt_0__" créé avec les attributs graphiques actifs. + .gradient-fill-locked = Remplissage dégradé : le remplissage est sur un calque verrouillé. + .select-closed-objects = Sélectionnez d'abord un ou plusieurs objets fermés. + .locked-objects-skipped = Attributs graphiques : __ocs_fmt_0__ objet(s) sur un calque verrouillé n'ont pas été modifiés. + .no-hatch-pattern = Attributs graphiques : aucun motif de hachure n'est disponible. + .associated-fill-locked = Attributs graphiques : un remplissage associé est sur un calque verrouillé. + .one-unsupported = Attributs graphiques : 1 objet non pris en charge n'a pas été modifié. + .unsupported-skipped = Attributs graphiques : __ocs_fmt_0__ objets non pris en charge n'ont pas été modifiés. + .fill-transparency = Transparence du remplissage + .fill-change = Modification du remplissage + .line-transparency = Transparence des lignes + .cylindrical-short = Cyl. + .spherical-short = Sphér. + .hemispherical-short = Hémi. + .invert-gradient = Inverser le dégradé + .tone = Ton + .reset-angle = Réinitialiser l'angle + .one-color = Une couleur + .two-colors = Deux couleurs diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index d73c8d401..b6e4ed947 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -6161,3 +6161,38 @@ dock = .new-group = नया समूह .browser = ब्राउज़र .node-graph = नोड ग्राफ़ + +graphic-attributes = + .title = ग्राफ़िक गुण + .set-all-bylayer = सभी गुण ByLayer पर सेट करें + .set-all-byblock = सभी गुण ByBlock पर सेट करें + .remove-bylayer-byblock = ByLayer / ByBlock हटाएँ + .create-layer-active-settings = सक्रिय सेटिंग्स के साथ लेयर बनाएँ + .fills-on-current-layer = भराव वर्तमान लेयर पर बनाएँ + .edit-hatch = हैच संपादित करें + .edit-gradient = ग्रेडिएंट संपादित करें + .close-open-object = चयनित ऑब्जेक्ट खुला है। भराव बनाने से पहले इसे बंद करें? [Yes / No] + .close-open-objects = __ocs_fmt_0__ चयनित ऑब्जेक्ट खुले हैं। भराव बनाने से पहले इन्हें बंद करें? [Yes / No] + .enter-new-layer-name = नई लेयर का नाम दर्ज करें: + .invalid-layer-name = ग्राफ़िक गुण: <>/\":;?*|,=` के बिना मान्य लेयर नाम दर्ज करें। + .layer-exists = ग्राफ़िक गुण: लेयर "__ocs_fmt_0__" पहले से मौजूद है। + .layer-not-created = ग्राफ़िक गुण: लेयर नहीं बनाई जा सकी। + .layer-created = सक्रिय ग्राफ़िक गुण सेटिंग्स के साथ लेयर "__ocs_fmt_0__" बनाई गई। + .gradient-fill-locked = ग्रेडिएंट भराव: भराव लॉक की गई लेयर पर है। + .select-closed-objects = पहले एक या अधिक बंद ऑब्जेक्ट चुनें। + .locked-objects-skipped = ग्राफ़िक गुण: लॉक की गई लेयर पर __ocs_fmt_0__ ऑब्जेक्ट नहीं बदले गए। + .no-hatch-pattern = ग्राफ़िक गुण: कोई हैच पैटर्न उपलब्ध नहीं है। + .associated-fill-locked = ग्राफ़िक गुण: एक संबद्ध भराव लॉक की गई लेयर पर है। + .one-unsupported = ग्राफ़िक गुण: 1 असमर्थित ऑब्जेक्ट नहीं बदला गया। + .unsupported-skipped = ग्राफ़िक गुण: __ocs_fmt_0__ असमर्थित ऑब्जेक्ट नहीं बदले गए। + .fill-transparency = भराव पारदर्शिता + .fill-change = भराव परिवर्तन + .line-transparency = रेखा पारदर्शिता + .cylindrical-short = बेलन. + .spherical-short = गोल. + .hemispherical-short = अर्धगोल. + .invert-gradient = ग्रेडिएंट उलटें + .tone = टोन + .reset-angle = कोण रीसेट करें + .one-color = एक रंग + .two-colors = दो रंग diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index a18d25a9b..00674b1f2 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -6159,3 +6159,38 @@ dock = .new-group = Új csoport .browser = Böngésző .node-graph = Csomópontgráf + +graphic-attributes = + .title = Grafikus jellemzők + .set-all-bylayer = Minden jellemző ByLayer értékre + .set-all-byblock = Minden jellemző ByBlock értékre + .remove-bylayer-byblock = ByLayer / ByBlock eltávolítása + .create-layer-active-settings = Fólia létrehozása az aktív beállításokkal + .fills-on-current-layer = Kitöltések létrehozása az aktuális fólián + .edit-hatch = Sraffozás szerkesztése + .edit-gradient = Színátmenet szerkesztése + .close-open-object = A kijelölt objektum nyitott. Bezárja a kitöltés létrehozása előtt? [Yes / No] + .close-open-objects = __ocs_fmt_0__ kijelölt objektum nyitott. Bezárja őket a kitöltés létrehozása előtt? [Yes / No] + .enter-new-layer-name = Adja meg az új fólia nevét: + .invalid-layer-name = Grafikus jellemzők: adjon meg érvényes fólianevet <>/\":;?*|,=` nélkül. + .layer-exists = Grafikus jellemzők: a(z) "__ocs_fmt_0__" fólia már létezik. + .layer-not-created = Grafikus jellemzők: a fóliát nem sikerült létrehozni. + .layer-created = A(z) "__ocs_fmt_0__" fólia létrejött az aktív grafikus jellemzőkkel. + .gradient-fill-locked = Színátmenetes kitöltés: a kitöltés zárolt fólián van. + .select-closed-objects = Először jelöljön ki egy vagy több zárt objektumot. + .locked-objects-skipped = Grafikus jellemzők: __ocs_fmt_0__ zárolt fólián lévő objektum nem változott. + .no-hatch-pattern = Grafikus jellemzők: nincs elérhető sraffozási minta. + .associated-fill-locked = Grafikus jellemzők: egy társított kitöltés zárolt fólián van. + .one-unsupported = Grafikus jellemzők: 1 nem támogatott objektum nem változott. + .unsupported-skipped = Grafikus jellemzők: __ocs_fmt_0__ nem támogatott objektum nem változott. + .fill-transparency = Kitöltés átlátszósága + .fill-change = Kitöltés módosítása + .line-transparency = Vonalak átlátszósága + .cylindrical-short = Henger + .spherical-short = Gömb + .hemispherical-short = Félgömb + .invert-gradient = Színátmenet megfordítása + .tone = Tónus + .reset-angle = Szög visszaállítása + .one-color = Egy szín + .two-colors = Két szín diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 9ae5c0d42..d84e500ad 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -6172,3 +6172,38 @@ dock = .new-group = Nuovo gruppo .browser = Browser .node-graph = Grafo dei nodi + +graphic-attributes = + .title = Attributi grafici + .set-all-bylayer = Imposta tutti gli attributi su ByLayer + .set-all-byblock = Imposta tutti gli attributi su ByBlock + .remove-bylayer-byblock = Rimuovi ByLayer / ByBlock + .create-layer-active-settings = Crea layer con le impostazioni attive + .fills-on-current-layer = Crea i riempimenti sul layer corrente + .edit-hatch = Modifica tratteggio + .edit-gradient = Modifica sfumatura + .close-open-object = L'oggetto selezionato è aperto. Chiuderlo prima di creare il riempimento? [Yes / No] + .close-open-objects = __ocs_fmt_0__ oggetti selezionati sono aperti. Chiuderli prima di creare il riempimento? [Yes / No] + .enter-new-layer-name = Immettere un nome per il nuovo layer: + .invalid-layer-name = Attributi grafici: immettere un nome di layer valido senza <>/\":;?*|,=`. + .layer-exists = Attributi grafici: il layer "__ocs_fmt_0__" esiste già. + .layer-not-created = Attributi grafici: impossibile creare il layer. + .layer-created = Layer "__ocs_fmt_0__" creato con le impostazioni attive degli attributi grafici. + .gradient-fill-locked = Riempimento sfumato: il riempimento è su un layer bloccato. + .select-closed-objects = Selezionare prima uno o più oggetti chiusi. + .locked-objects-skipped = Attributi grafici: __ocs_fmt_0__ oggetto/i su un layer bloccato non sono stati modificati. + .no-hatch-pattern = Attributi grafici: nessun modello di tratteggio disponibile. + .associated-fill-locked = Attributi grafici: un riempimento associato è su un layer bloccato. + .one-unsupported = Attributi grafici: 1 oggetto non supportato non è stato modificato. + .unsupported-skipped = Attributi grafici: __ocs_fmt_0__ oggetti non supportati non sono stati modificati. + .fill-transparency = Trasparenza riempimento + .fill-change = Modifica riempimento + .line-transparency = Trasparenza linee + .cylindrical-short = Cil. + .spherical-short = Sfer. + .hemispherical-short = Emisf. + .invert-gradient = Inverti sfumatura + .tone = Tono + .reset-angle = Reimposta angolo + .one-color = Un colore + .two-colors = Due colori diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index e09121acc..ed37d6a97 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -6161,3 +6161,38 @@ dock = .new-group = 新しいグループ .browser = ブラウザー .node-graph = ノードグラフ + +graphic-attributes = + .title = グラフィック属性 + .set-all-bylayer = すべての属性を ByLayer に設定 + .set-all-byblock = すべての属性を ByBlock に設定 + .remove-bylayer-byblock = ByLayer / ByBlock を解除 + .create-layer-active-settings = 現在の設定で画層を作成 + .fills-on-current-layer = 塗り潰しを現在の画層に作成 + .edit-hatch = ハッチングを編集 + .edit-gradient = グラデーションを編集 + .close-open-object = 選択したオブジェクトは開いています。塗り潰しを作成する前に閉じますか? [Yes / No] + .close-open-objects = 選択した __ocs_fmt_0__ 個のオブジェクトは開いています。塗り潰しを作成する前に閉じますか? [Yes / No] + .enter-new-layer-name = 新しい画層の名前を入力: + .invalid-layer-name = グラフィック属性: <>/\":;?*|,=` を含まない有効な画層名を入力してください。 + .layer-exists = グラフィック属性: 画層 "__ocs_fmt_0__" は既に存在します。 + .layer-not-created = グラフィック属性: 画層を作成できませんでした。 + .layer-created = 現在のグラフィック属性の設定で画層 "__ocs_fmt_0__" を作成しました。 + .gradient-fill-locked = グラデーション塗り潰し: 塗り潰しはロックされた画層にあります。 + .select-closed-objects = 先に 1 つ以上の閉じたオブジェクトを選択してください。 + .locked-objects-skipped = グラフィック属性: ロックされた画層上の __ocs_fmt_0__ 個のオブジェクトは変更されませんでした。 + .no-hatch-pattern = グラフィック属性: 使用できるハッチング パターンがありません。 + .associated-fill-locked = グラフィック属性: 関連付けられた塗り潰しがロックされた画層にあります。 + .one-unsupported = グラフィック属性: サポートされていないオブジェクト 1 個は変更されませんでした。 + .unsupported-skipped = グラフィック属性: サポートされていないオブジェクト __ocs_fmt_0__ 個は変更されませんでした。 + .fill-transparency = 塗り潰しの透過性 + .fill-change = 塗り潰しの変更 + .line-transparency = 線の透過性 + .cylindrical-short = 円柱 + .spherical-short = 球 + .hemispherical-short = 半球 + .invert-gradient = グラデーションを反転 + .tone = 濃淡 + .reset-angle = 角度をリセット + .one-color = 1 色 + .two-colors = 2 色 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index fc7b52230..a5ed580ee 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -6172,3 +6172,38 @@ dock = .new-group = 새 그룹 .browser = 브라우저 .node-graph = 노드 그래프 + +graphic-attributes = + .title = 그래픽 속성 + .set-all-bylayer = 모든 속성을 ByLayer로 설정 + .set-all-byblock = 모든 속성을 ByBlock으로 설정 + .remove-bylayer-byblock = ByLayer / ByBlock 제거 + .create-layer-active-settings = 현재 설정으로 도면층 작성 + .fills-on-current-layer = 현재 도면층에 채우기 작성 + .edit-hatch = 해치 편집 + .edit-gradient = 그라데이션 편집 + .close-open-object = 선택한 객체가 열려 있습니다. 채우기를 작성하기 전에 닫겠습니까? [Yes / No] + .close-open-objects = 선택한 객체 __ocs_fmt_0__개가 열려 있습니다. 채우기를 작성하기 전에 닫겠습니까? [Yes / No] + .enter-new-layer-name = 새 도면층 이름 입력: + .invalid-layer-name = 그래픽 속성: <>/\":;?*|,=`이(가) 없는 올바른 도면층 이름을 입력하십시오. + .layer-exists = 그래픽 속성: 도면층 "__ocs_fmt_0__"이(가) 이미 있습니다. + .layer-not-created = 그래픽 속성: 도면층을 작성할 수 없습니다. + .layer-created = 현재 그래픽 속성 설정으로 도면층 "__ocs_fmt_0__"을(를) 작성했습니다. + .gradient-fill-locked = 그라데이션 채우기: 채우기가 잠긴 도면층에 있습니다. + .select-closed-objects = 먼저 닫힌 객체를 하나 이상 선택하십시오. + .locked-objects-skipped = 그래픽 속성: 잠긴 도면층의 객체 __ocs_fmt_0__개가 변경되지 않았습니다. + .no-hatch-pattern = 그래픽 속성: 사용 가능한 해치 패턴이 없습니다. + .associated-fill-locked = 그래픽 속성: 연관된 채우기가 잠긴 도면층에 있습니다. + .one-unsupported = 그래픽 속성: 지원되지 않는 객체 1개가 변경되지 않았습니다. + .unsupported-skipped = 그래픽 속성: 지원되지 않는 객체 __ocs_fmt_0__개가 변경되지 않았습니다. + .fill-transparency = 채우기 투명도 + .fill-change = 채우기 변경 + .line-transparency = 선 투명도 + .cylindrical-short = 원통 + .spherical-short = 구 + .hemispherical-short = 반구 + .invert-gradient = 그라데이션 반전 + .tone = 색조 + .reset-angle = 각도 재설정 + .one-color = 단색 + .two-colors = 두 색 diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index 71e4dcad5..7304c8344 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -6174,3 +6174,38 @@ dock = .new-group = ກຸ່ມໃໝ່ .browser = ບຣາວເຊີ .node-graph = ກຣາຟໂນດ + +graphic-attributes = + .title = ຄຸນລັກສະນະກຣາຟິກ + .set-all-bylayer = ຕັ້ງຄຸນລັກສະນະທັງໝົດເປັນ ByLayer + .set-all-byblock = ຕັ້ງຄຸນລັກສະນະທັງໝົດເປັນ ByBlock + .remove-bylayer-byblock = ລຶບ ByLayer / ByBlock + .create-layer-active-settings = ສ້າງເລເຢີດ້ວຍການຕັ້ງຄ່າປັດຈຸບັນ + .fills-on-current-layer = ສ້າງການຕື່ມໃສ່ເລເຢີປັດຈຸບັນ + .edit-hatch = ແກ້ໄຂແຮັດ + .edit-gradient = ແກ້ໄຂການໄລ່ສີ + .close-open-object = ວັດຖຸທີ່ເລືອກເປັນແບບເປີດ. ປິດມັນກ່ອນສ້າງການຕື່ມບໍ່? [Yes / No] + .close-open-objects = ວັດຖຸທີ່ເລືອກ __ocs_fmt_0__ ອັນເປັນແບບເປີດ. ປິດພວກມັນກ່ອນສ້າງການຕື່ມບໍ່? [Yes / No] + .enter-new-layer-name = ປ້ອນຊື່ສຳລັບເລເຢີໃໝ່: + .invalid-layer-name = ຄຸນລັກສະນະກຣາຟິກ: ປ້ອນຊື່ເລເຢີທີ່ຖືກຕ້ອງ ໂດຍບໍ່ມີ <>/\":;?*|,=`. + .layer-exists = ຄຸນລັກສະນະກຣາຟິກ: ເລເຢີ "__ocs_fmt_0__" ມີຢູ່ແລ້ວ. + .layer-not-created = ຄຸນລັກສະນະກຣາຟິກ: ບໍ່ສາມາດສ້າງເລເຢີໄດ້. + .layer-created = ສ້າງເລເຢີ "__ocs_fmt_0__" ດ້ວຍການຕັ້ງຄ່າຄຸນລັກສະນະກຣາຟິກປັດຈຸບັນແລ້ວ. + .gradient-fill-locked = ການຕື່ມໄລ່ສີ: ການຕື່ມຢູ່ໃນເລເຢີທີ່ຖືກລັອກ. + .select-closed-objects = ເລືອກວັດຖຸປິດໜຶ່ງອັນ ຫຼື ຫຼາຍກວ່ານັ້ນກ່ອນ. + .locked-objects-skipped = ຄຸນລັກສະນະກຣາຟິກ: ວັດຖຸ __ocs_fmt_0__ ອັນໃນເລເຢີທີ່ຖືກລັອກບໍ່ໄດ້ຖືກປ່ຽນ. + .no-hatch-pattern = ຄຸນລັກສະນະກຣາຟິກ: ບໍ່ມີຮູບແບບແຮັດ. + .associated-fill-locked = ຄຸນລັກສະນະກຣາຟິກ: ການຕື່ມທີ່ກ່ຽວຂ້ອງຢູ່ໃນເລເຢີທີ່ຖືກລັອກ. + .one-unsupported = ຄຸນລັກສະນະກຣາຟິກ: ວັດຖຸທີ່ບໍ່ຮອງຮັບ 1 ອັນບໍ່ໄດ້ຖືກປ່ຽນ. + .unsupported-skipped = ຄຸນລັກສະນະກຣາຟິກ: ວັດຖຸທີ່ບໍ່ຮອງຮັບ __ocs_fmt_0__ ອັນບໍ່ໄດ້ຖືກປ່ຽນ. + .fill-transparency = ຄວາມໂປ່ງໃສຂອງການຕື່ມ + .fill-change = ປ່ຽນການຕື່ມ + .line-transparency = ຄວາມໂປ່ງໃສຂອງເສັ້ນ + .cylindrical-short = ທໍ່. + .spherical-short = ກົມ. + .hemispherical-short = ເຄິ່ງກົມ. + .invert-gradient = ກັບການໄລ່ສີ + .tone = ໂທນ + .reset-angle = ຣີເຊັດມຸມ + .one-color = ໜຶ່ງສີ + .two-colors = ສອງສີ diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 80ef8724d..4ad871607 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -6171,3 +6171,38 @@ dock = .new-group = Nieuwe groep .browser = Browser .node-graph = Knooppuntgrafiek + +graphic-attributes = + .title = Grafische attributen + .set-all-bylayer = Alle attributen op ByLayer zetten + .set-all-byblock = Alle attributen op ByBlock zetten + .remove-bylayer-byblock = ByLayer / ByBlock verwijderen + .create-layer-active-settings = Laag maken met actieve instellingen + .fills-on-current-layer = Vullingen op de huidige laag maken + .edit-hatch = Arcering bewerken + .edit-gradient = Kleurverloop bewerken + .close-open-object = Het geselecteerde object is open. Sluiten voordat de vulling wordt gemaakt? [Yes / No] + .close-open-objects = __ocs_fmt_0__ geselecteerde objecten zijn open. Sluiten voordat de vulling wordt gemaakt? [Yes / No] + .enter-new-layer-name = Voer een naam in voor de nieuwe laag: + .invalid-layer-name = Grafische attributen: voer een geldige laagnaam in zonder <>/\":;?*|,=`. + .layer-exists = Grafische attributen: laag "__ocs_fmt_0__" bestaat al. + .layer-not-created = Grafische attributen: de laag kon niet worden gemaakt. + .layer-created = Laag "__ocs_fmt_0__" gemaakt met de actieve instellingen van Grafische attributen. + .gradient-fill-locked = Verloopvulling: de vulling staat op een vergrendelde laag. + .select-closed-objects = Selecteer eerst een of meer gesloten objecten. + .locked-objects-skipped = Grafische attributen: __ocs_fmt_0__ object(en) op een vergrendelde laag zijn niet gewijzigd. + .no-hatch-pattern = Grafische attributen: er is geen arceerpatroon beschikbaar. + .associated-fill-locked = Grafische attributen: een gekoppelde vulling staat op een vergrendelde laag. + .one-unsupported = Grafische attributen: 1 niet-ondersteund object is niet gewijzigd. + .unsupported-skipped = Grafische attributen: __ocs_fmt_0__ niet-ondersteunde objecten zijn niet gewijzigd. + .fill-transparency = Transparantie vulling + .fill-change = Vulling wijzigen + .line-transparency = Transparantie lijnen + .cylindrical-short = Cil. + .spherical-short = Bol + .hemispherical-short = Halfbol + .invert-gradient = Kleurverloop omkeren + .tone = Toon + .reset-angle = Hoek herstellen + .one-color = Eén kleur + .two-colors = Twee kleuren diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index b2c44d546..7ba65e368 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -6172,3 +6172,38 @@ dock = .new-group = Nowa grupa .browser = Przeglądarka .node-graph = Graf węzłów + +graphic-attributes = + .title = Atrybuty graficzne + .set-all-bylayer = Ustaw wszystkie atrybuty na ByLayer + .set-all-byblock = Ustaw wszystkie atrybuty na ByBlock + .remove-bylayer-byblock = Usuń ByLayer / ByBlock + .create-layer-active-settings = Utwórz warstwę z aktywnymi ustawieniami + .fills-on-current-layer = Twórz wypełnienia na bieżącej warstwie + .edit-hatch = Edytuj kreskowanie + .edit-gradient = Edytuj gradient + .close-open-object = Wybrany obiekt jest otwarty. Zamknąć go przed utworzeniem wypełnienia? [Yes / No] + .close-open-objects = Wybrane obiekty są otwarte (__ocs_fmt_0__). Zamknąć je przed utworzeniem wypełnienia? [Yes / No] + .enter-new-layer-name = Podaj nazwę nowej warstwy: + .invalid-layer-name = Atrybuty graficzne: podaj prawidłową nazwę warstwy bez <>/\":;?*|,=`. + .layer-exists = Atrybuty graficzne: warstwa "__ocs_fmt_0__" już istnieje. + .layer-not-created = Atrybuty graficzne: nie można utworzyć warstwy. + .layer-created = Utworzono warstwę "__ocs_fmt_0__" z aktywnymi atrybutami graficznymi. + .gradient-fill-locked = Wypełnienie gradientowe: wypełnienie jest na zablokowanej warstwie. + .select-closed-objects = Najpierw wybierz jeden lub więcej zamkniętych obiektów. + .locked-objects-skipped = Atrybuty graficzne: obiekty na zablokowanej warstwie (__ocs_fmt_0__) nie zostały zmienione. + .no-hatch-pattern = Atrybuty graficzne: brak dostępnego wzoru kreskowania. + .associated-fill-locked = Atrybuty graficzne: skojarzone wypełnienie jest na zablokowanej warstwie. + .one-unsupported = Atrybuty graficzne: 1 nieobsługiwany obiekt nie został zmieniony. + .unsupported-skipped = Atrybuty graficzne: nieobsługiwane obiekty (__ocs_fmt_0__) nie zostały zmienione. + .fill-transparency = Przezroczystość wypełnienia + .fill-change = Zmiana wypełnienia + .line-transparency = Przezroczystość linii + .cylindrical-short = Walc. + .spherical-short = Sfer. + .hemispherical-short = Półsf. + .invert-gradient = Odwróć gradient + .tone = Ton + .reset-angle = Resetuj kąt + .one-color = Jeden kolor + .two-colors = Dwa kolory diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 92a940a8d..2e21eeeef 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -6173,3 +6173,38 @@ dock = .new-group = Novo grupo .browser = Navegador .node-graph = Grafo de nós + +graphic-attributes = + .title = Atributos gráficos + .set-all-bylayer = Definir todos os atributos como ByLayer + .set-all-byblock = Definir todos os atributos como ByBlock + .remove-bylayer-byblock = Remover ByLayer / ByBlock + .create-layer-active-settings = Criar camada com as configurações ativas + .fills-on-current-layer = Criar preenchimentos na camada atual + .edit-hatch = Editar hachura + .edit-gradient = Editar gradiente + .close-open-object = O objeto selecionado está aberto. Fechá-lo antes de criar o preenchimento? [Yes / No] + .close-open-objects = __ocs_fmt_0__ objetos selecionados estão abertos. Fechá-los antes de criar o preenchimento? [Yes / No] + .enter-new-layer-name = Digite um nome para a nova camada: + .invalid-layer-name = Atributos gráficos: digite um nome de camada válido sem <>/\":;?*|,=`. + .layer-exists = Atributos gráficos: a camada "__ocs_fmt_0__" já existe. + .layer-not-created = Atributos gráficos: não foi possível criar a camada. + .layer-created = Camada "__ocs_fmt_0__" criada com as configurações ativas de atributos gráficos. + .gradient-fill-locked = Preenchimento gradiente: o preenchimento está em uma camada bloqueada. + .select-closed-objects = Selecione primeiro um ou mais objetos fechados. + .locked-objects-skipped = Atributos gráficos: __ocs_fmt_0__ objeto(s) em uma camada bloqueada não foram alterados. + .no-hatch-pattern = Atributos gráficos: nenhum padrão de hachura disponível. + .associated-fill-locked = Atributos gráficos: um preenchimento associado está em uma camada bloqueada. + .one-unsupported = Atributos gráficos: 1 objeto não suportado não foi alterado. + .unsupported-skipped = Atributos gráficos: __ocs_fmt_0__ objetos não suportados não foram alterados. + .fill-transparency = Transparência do preenchimento + .fill-change = Alteração de preenchimento + .line-transparency = Transparência da linha + .cylindrical-short = Cil. + .spherical-short = Esfér. + .hemispherical-short = Hemisf. + .invert-gradient = Inverter gradiente + .tone = Tom + .reset-angle = Redefinir ângulo + .one-color = Uma cor + .two-colors = Duas cores diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 5bf008a66..9672928b6 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -6174,3 +6174,38 @@ dock = .new-group = Новая группа .browser = Обозреватель .node-graph = Граф узлов + +graphic-attributes = + .title = Графические атрибуты + .set-all-bylayer = Задать всем атрибутам ByLayer + .set-all-byblock = Задать всем атрибутам ByBlock + .remove-bylayer-byblock = Убрать ByLayer / ByBlock + .create-layer-active-settings = Создать слой с текущими настройками + .fills-on-current-layer = Создавать заливки на текущем слое + .edit-hatch = Редактировать штриховку + .edit-gradient = Редактировать градиент + .close-open-object = Выбранный объект разомкнут. Замкнуть его перед созданием заливки? [Yes / No] + .close-open-objects = Выбранные объекты разомкнуты (__ocs_fmt_0__). Замкнуть их перед созданием заливки? [Yes / No] + .enter-new-layer-name = Введите имя нового слоя: + .invalid-layer-name = Графические атрибуты: введите допустимое имя слоя без <>/\":;?*|,=`. + .layer-exists = Графические атрибуты: слой "__ocs_fmt_0__" уже существует. + .layer-not-created = Графические атрибуты: не удалось создать слой. + .layer-created = Создан слой "__ocs_fmt_0__" с текущими графическими атрибутами. + .gradient-fill-locked = Градиентная заливка: заливка находится на заблокированном слое. + .select-closed-objects = Сначала выберите один или несколько замкнутых объектов. + .locked-objects-skipped = Графические атрибуты: объекты на заблокированном слое (__ocs_fmt_0__) не изменены. + .no-hatch-pattern = Графические атрибуты: нет доступного образца штриховки. + .associated-fill-locked = Графические атрибуты: связанная заливка находится на заблокированном слое. + .one-unsupported = Графические атрибуты: 1 неподдерживаемый объект не изменён. + .unsupported-skipped = Графические атрибуты: неподдерживаемые объекты (__ocs_fmt_0__) не изменены. + .fill-transparency = Прозрачность заливки + .fill-change = Изменение заливки + .line-transparency = Прозрачность линий + .cylindrical-short = Цил. + .spherical-short = Сфер. + .hemispherical-short = Полусф. + .invert-gradient = Инвертировать градиент + .tone = Тон + .reset-angle = Сбросить угол + .one-color = Один цвет + .two-colors = Два цвета diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 07531cada..31ed14f3f 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -6096,3 +6096,38 @@ dock = .new-group = Yeni grup .browser = Tarayıcı .node-graph = Düğüm Grafiği + +graphic-attributes = + .title = Grafik öznitelikleri + .set-all-bylayer = Tüm öznitelikleri ByLayer yap + .set-all-byblock = Tüm öznitelikleri ByBlock yap + .remove-bylayer-byblock = ByLayer / ByBlock kaldır + .create-layer-active-settings = Etkin ayarlarla katman oluştur + .fills-on-current-layer = Dolguları geçerli katmanda oluştur + .edit-hatch = Taramayı düzenle + .edit-gradient = Gradyanı düzenle + .close-open-object = Seçili nesne açık. Dolgu oluşturmadan önce kapatılsın mı? [Yes / No] + .close-open-objects = __ocs_fmt_0__ seçili nesne açık. Dolgu oluşturmadan önce kapatılsın mı? [Yes / No] + .enter-new-layer-name = Yeni katman için bir ad girin: + .invalid-layer-name = Grafik öznitelikleri: <>/\":;?*|,=` içermeyen geçerli bir katman adı girin. + .layer-exists = Grafik öznitelikleri: "__ocs_fmt_0__" katmanı zaten var. + .layer-not-created = Grafik öznitelikleri: katman oluşturulamadı. + .layer-created = "__ocs_fmt_0__" katmanı etkin grafik öznitelikleriyle oluşturuldu. + .gradient-fill-locked = Gradyan dolgu: dolgu kilitli bir katmanda. + .select-closed-objects = Önce bir veya daha fazla kapalı nesne seçin. + .locked-objects-skipped = Grafik öznitelikleri: kilitli katmandaki __ocs_fmt_0__ nesne değiştirilmedi. + .no-hatch-pattern = Grafik öznitelikleri: kullanılabilir tarama deseni yok. + .associated-fill-locked = Grafik öznitelikleri: ilişkili bir dolgu kilitli katmanda. + .one-unsupported = Grafik öznitelikleri: desteklenmeyen 1 nesne değiştirilmedi. + .unsupported-skipped = Grafik öznitelikleri: desteklenmeyen __ocs_fmt_0__ nesne değiştirilmedi. + .fill-transparency = Dolgu saydamlığı + .fill-change = Dolgu değişikliği + .line-transparency = Çizgi saydamlığı + .cylindrical-short = Sil. + .spherical-short = Küre + .hemispherical-short = Yarıküre + .invert-gradient = Gradyanı ters çevir + .tone = Ton + .reset-angle = Açıyı sıfırla + .one-color = Tek renk + .two-colors = İki renk diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 9a70bd46a..521e92320 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -6156,3 +6156,38 @@ dock = .new-group = 新建组 .browser = 浏览器 .node-graph = 节点图 + +graphic-attributes = + .title = 图形特性 + .set-all-bylayer = 将所有特性设为 ByLayer + .set-all-byblock = 将所有特性设为 ByBlock + .remove-bylayer-byblock = 移除 ByLayer / ByBlock + .create-layer-active-settings = 用当前设置创建图层 + .fills-on-current-layer = 在当前图层上创建填充 + .edit-hatch = 编辑填充图案 + .edit-gradient = 编辑渐变色 + .close-open-object = 选定对象未闭合。创建填充前是否将其闭合? [Yes / No] + .close-open-objects = __ocs_fmt_0__ 个选定对象未闭合。创建填充前是否将其闭合? [Yes / No] + .enter-new-layer-name = 输入新图层的名称: + .invalid-layer-name = 图形特性: 请输入不含 <>/\":;?*|,=` 的有效图层名称。 + .layer-exists = 图形特性: 图层 "__ocs_fmt_0__" 已存在。 + .layer-not-created = 图形特性: 无法创建图层。 + .layer-created = 已用当前图形特性设置创建图层 "__ocs_fmt_0__"。 + .gradient-fill-locked = 渐变填充: 填充位于锁定的图层上。 + .select-closed-objects = 请先选择一个或多个闭合对象。 + .locked-objects-skipped = 图形特性: 锁定图层上的 __ocs_fmt_0__ 个对象未更改。 + .no-hatch-pattern = 图形特性: 没有可用的填充图案。 + .associated-fill-locked = 图形特性: 关联的填充位于锁定的图层上。 + .one-unsupported = 图形特性: 1 个不支持的对象未更改。 + .unsupported-skipped = 图形特性: __ocs_fmt_0__ 个不支持的对象未更改。 + .fill-transparency = 填充透明度 + .fill-change = 更改填充 + .line-transparency = 线的透明度 + .cylindrical-short = 圆柱 + .spherical-short = 球形 + .hemispherical-short = 半球 + .invert-gradient = 反转渐变 + .tone = 明暗 + .reset-angle = 重置角度 + .one-color = 单色 + .two-colors = 双色 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index afd769830..3346a8f09 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -6168,3 +6168,38 @@ dock = .new-group = 新增群組 .browser = 瀏覽器 .node-graph = 節點圖 + +graphic-attributes = + .title = 圖形特性 + .set-all-bylayer = 將所有特性設為 ByLayer + .set-all-byblock = 將所有特性設為 ByBlock + .remove-bylayer-byblock = 移除 ByLayer / ByBlock + .create-layer-active-settings = 以目前設定建立圖層 + .fills-on-current-layer = 在目前圖層上建立填充 + .edit-hatch = 編輯填充線 + .edit-gradient = 編輯漸層 + .close-open-object = 選取的物件未封閉。建立填充前是否將其封閉? [Yes / No] + .close-open-objects = __ocs_fmt_0__ 個選取的物件未封閉。建立填充前是否將其封閉? [Yes / No] + .enter-new-layer-name = 輸入新圖層的名稱: + .invalid-layer-name = 圖形特性: 請輸入不含 <>/\":;?*|,=` 的有效圖層名稱。 + .layer-exists = 圖形特性: 圖層 "__ocs_fmt_0__" 已存在。 + .layer-not-created = 圖形特性: 無法建立圖層。 + .layer-created = 已以目前圖形特性設定建立圖層 "__ocs_fmt_0__"。 + .gradient-fill-locked = 漸層填充: 填充位於鎖定的圖層上。 + .select-closed-objects = 請先選取一個或多個封閉物件。 + .locked-objects-skipped = 圖形特性: 鎖定圖層上的 __ocs_fmt_0__ 個物件未變更。 + .no-hatch-pattern = 圖形特性: 沒有可用的填充線樣式。 + .associated-fill-locked = 圖形特性: 關聯的填充位於鎖定的圖層上。 + .one-unsupported = 圖形特性: 1 個不支援的物件未變更。 + .unsupported-skipped = 圖形特性: __ocs_fmt_0__ 個不支援的物件未變更。 + .fill-transparency = 填充透明度 + .fill-change = 變更填充 + .line-transparency = 線的透明度 + .cylindrical-short = 圓柱 + .spherical-short = 球形 + .hemispherical-short = 半球 + .invert-gradient = 反轉漸層 + .tone = 明暗 + .reset-angle = 重設角度 + .one-color = 單色 + .two-colors = 雙色 diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 4e06139a9..3820f560e 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -500,6 +500,11 @@ impl OpenCADStudio { return Some(Task::done(Message::ToggleProperties)); } + // ── GRAPHICATTRIBUTES — toggle Graphic Attributes palette ─────────── + "GRAPHICATTRIBUTES" => { + return Some(Task::done(Message::ToggleGraphicAttributes)); + } + // ── FILETAB — toggle file/document tabs ────────────────────────────── "FILETAB" => { return Some(Task::done(Message::ToggleFileTabs)); diff --git a/src/app/commands/layers.rs b/src/app/commands/layers.rs index 725b15704..7560af332 100644 --- a/src/app/commands/layers.rs +++ b/src/app/commands/layers.rs @@ -3,6 +3,10 @@ use super::*; impl OpenCADStudio { pub(super) fn dispatch_layers(&mut self, cmd: &str, i: usize) -> Option> { match cmd { + cmd if cmd.starts_with("GRAPHICATTRIBUTELAYER ") => { + let name = cmd.trim_start_matches("GRAPHICATTRIBUTELAYER").trim(); + return Some(self.create_graphic_attributes_layer(name)); + } // ── Layer object commands ────────────────────────────────────── "LAYOFF" => { let handles: Vec<_> = self.tabs[i] diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index be8cdbfa7..367b7262a 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -468,6 +468,7 @@ inventory::submit!(crate::command::CommandRegistration { "DSETTINGS", "PARAMETERS", "GRID", + "GRAPHICATTRIBUTES", "ISODRAFT", "ISOPLANE", "OSNAP", diff --git a/src/app/config.rs b/src/app/config.rs index 0b2224c79..958111e8e 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -30,6 +30,11 @@ pub struct AppConfig { /// General edge-stack dock layout (which panels are docked, side, order, /// width and auto-collapse) for the Properties panel and block palette. pub dock: crate::ui::dock::DockState, + /// Whether the optional Graphic Attributes palette is visible. + pub show_graphic_attributes: bool, + /// Graphic Attributes creates fills on the current layer instead of the + /// layer of the filled object. + pub graphic_fills_on_current_layer: bool, /// Add a newly selected annotation scale to existing annotative objects. pub annotation_auto_scale: i8, /// Ribbon collapse density. @@ -52,6 +57,8 @@ impl Default for AppConfig { start: StartConfig::default(), statusbar: StatusBarConfig::default(), dock: crate::ui::dock::DockState::default(), + show_graphic_attributes: false, + graphic_fills_on_current_layer: false, annotation_auto_scale: -4, ribbon: RibbonConfig::default(), plot: PlotDialogState::default(), @@ -612,6 +619,18 @@ mod tests { assert_eq!(deserialized.model_space.selection_window_color, 5); } + #[test] + fn graphic_attributes_visibility_defaults_off_and_round_trips() { + let missing: AppConfig = serde_json::from_str("{}").expect("deserialize old config"); + assert!(!missing.show_graphic_attributes); + + let mut original = AppConfig::default(); + original.show_graphic_attributes = true; + let serialized = serde_json::to_string(&original).expect("serialize config"); + let restored: AppConfig = serde_json::from_str(&serialized).expect("deserialize config"); + assert!(restored.show_graphic_attributes); + } + #[test] fn test_parse_theme_name() { assert_eq!(parse_theme_name("light"), Some(iced::Theme::Light)); diff --git a/src/app/dock/update.rs b/src/app/dock/update.rs index e526c7d11..4ca867c98 100644 --- a/src/app/dock/update.rs +++ b/src/app/dock/update.rs @@ -442,6 +442,11 @@ impl OpenCADStudio { self.show_properties = true; self.ribbon.set_properties(true); } + PanelId::GraphicAttributes => { + self.show_graphic_attributes = true; + self.ribbon.set_graphic_attributes(true); + self.save_config(); + } PanelId::BlockPalette => self.open_blocks_palette(None), PanelId::ExternalReferences => { self.show_external_references = true; @@ -488,6 +493,12 @@ impl OpenCADStudio { self.show_layers = false; self.ribbon.deactivate_tool_if("LAYERS"); } + PanelId::GraphicAttributes => { + self.show_graphic_attributes = false; + self.ribbon.set_graphic_attributes(false); + self.graphic_attributes.close_popups(); + self.save_config(); + } } self.dock_unpeek(id); // A closed pallet cannot stay held by the pointer. @@ -555,6 +566,7 @@ impl OpenCADStudio { PanelId::Count => self.count_palette.show, PanelId::SheetSetManager => self.sheet_set.show, PanelId::Layers => self.show_layers, + PanelId::GraphicAttributes => self.show_graphic_attributes, } } diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs index f1d4d3ff6..7fb744d6a 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -381,6 +381,20 @@ impl OpenCADStudio { }; match id { crate::ui::dock::PanelId::Properties => tab.properties.view(width, chrome), + crate::ui::dock::PanelId::GraphicAttributes => crate::ui::window::graphic_attributes::view( + &self.graphic_attributes, + &tab.scene, + &tab.properties, + width, + chrome, + self.dock + .location(id) + .map_or(crate::app::config::DockSide::Right, |(side, _)| side), + matches!( + self.color_pick_target, + Some((crate::app::ColorPickTarget::Gradient(_), _)) + ), + ), crate::ui::dock::PanelId::BlockPalette => { crate::ui::window::block_palette::view(&self.block_palette, width, chrome) } diff --git a/src/app/mod.rs b/src/app/mod.rs index 682787688..4462dd48a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -817,6 +817,9 @@ pub(super) struct OpenCADStudio { show_properties: bool, /// The Layer Manager pallet (LAYERS) is open. pub(crate) show_layers: bool, + /// Whether the Graphic Attributes palette is shown (GRAPHICATTRIBUTES). + show_graphic_attributes: bool, + graphic_attributes: crate::ui::window::graphic_attributes::GraphicAttributesState, /// Docked Insert Block panel visibility. pub(crate) show_block_palette: bool, /// Node graph overlay over the viewport. @@ -1616,6 +1619,9 @@ pub enum ColorPickerTab { /// Where a colour chosen in the standalone palette window should be applied. #[derive(Debug, Clone)] pub enum ColorPickTarget { + Gradient(u8), + GraphicAttributesLine, + GraphicAttributesSolid, DimStyle(DsField), MLeader(&'static str), Table(u8, &'static str), @@ -2155,6 +2161,7 @@ pub enum PasteFocus { #[derive(Debug, Clone)] pub enum Message { + GraphicAttributes(crate::ui::window::graphic_attributes::GraphicAttributesMsg), SpaceMouseWake, SpaceMouseFrame(iced::time::Instant), SpaceMouseFocus(iced::window::Id, bool), @@ -2892,6 +2899,8 @@ pub enum Message { ToggleViewCube, /// Toggle the Properties panel visibility (PROPERTIES). ToggleProperties, + /// Toggle the Graphic Attributes panel visibility (GRAPHICATTRIBUTES). + ToggleGraphicAttributes, /// Toggle the document file tabs at the top (FILETAB). ToggleFileTabs, /// Toggle the layout tabs at the bottom (LAYOUTTAB). @@ -4309,6 +4318,8 @@ impl OpenCADStudio { render_mode_preview: None, show_properties: true, show_layers: false, + show_graphic_attributes: false, + graphic_attributes: Default::default(), show_block_palette: false, show_node_graph: false, property_target_override: None, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index dd34454fe..9e13601a1 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -394,6 +394,10 @@ impl OpenCADStudio { // dropdown so the dispatched command's new prompt is // immediately visible on the overlay. self.command_line.close_history(); + if self.awaiting_fill_close_answer() { + let input = std::mem::take(&mut self.command_line.input); + return self.on_graphic_fill_close_input(&input); + } // A leading `>` was only a "literal spaces" typing hint (see // CommandSpace) — drop it before the input is interpreted. // Free-form text prompts keep it: there it is content, not a @@ -1100,6 +1104,9 @@ impl OpenCADStudio { if !self.command_line.input.trim().is_empty() { return self.update(Message::CommandSubmit); } + if self.awaiting_fill_close_answer() { + return self.on_graphic_fill_close_input(""); + } // A grip edit is not an active CAD command, but its Dynamic Input // fields use the same keyboard path. Route Enter through CommandSubmit, // whose grip branch resolves Distance / Angle and finalizes the edit. @@ -1154,6 +1161,11 @@ impl OpenCADStudio { if self.ribbon.escape_extension() { return Task::none(); } + if self.awaiting_fill_close_answer() { + self.graphic_attributes.pending_fill_close = None; + self.command_line.push_info(crate::t!("*Cancel*").as_ref()); + return Task::none(); + } // Esc drops an unconsumed one-shot snap override and closes // its menu (#337). Falls through — Esc keeps its usual effect. self.snap_override_popup = None; @@ -2225,6 +2237,35 @@ impl OpenCADStudio { Task::none() } + pub(super) fn on_ribbon_lineweight_changed( + &mut self, + lw: codec::types::LineWeight, + ) -> Task { + let i = self.active_tab; + self.ribbon.close_dropdown(); + let handles = self.property_target_handles(i); + if handles.is_empty() { + if self.has_property_selection(i) { + return Task::none(); + } + // Persist into the tab's header (CELWEIGHT). #21. + self.tabs[i].scene.document.header.current_line_weight = lw.value(); + self.tabs[i].dirty = true; + self.ribbon.active_lineweight = lw; + } else { + // Lineweight is baked into the cached wire geometry — + // re-tessellate so the change shows immediately (issue #231 + // class). + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + crate::scene::view::dispatch::apply_line_weight(entity, lw); + } + }); + self.ribbon.active_lineweight = lw; + } + Task::none() + } + pub(super) fn on_ribbon_linetype_changed(&mut self, lt: String) -> Task { let i = self.active_tab; self.ribbon.close_dropdown(); @@ -2331,11 +2372,20 @@ impl OpenCADStudio { pub(super) fn on_prop_hatch_pattern_changed(&mut self, name: String) -> Task { let i = self.active_tab; let handles = self.property_target_handles(i); + self.apply_hatch_pattern(i, &handles, name) + } + + pub(super) fn apply_hatch_pattern( + &mut self, + i: usize, + handles: &[codec::Handle], + name: String, + ) -> Task { if !handles.is_empty() { use crate::scene::model::hatch_patterns; if let Some(entry) = hatch_patterns::find(&name) { self.push_undo_snapshot(i, "HATCHEDIT"); - for &handle in &handles { + for &handle in handles { if let Some(codec::EntityType::Hatch(dxf)) = self.tabs[i].scene.document.get_entity_mut(handle) { diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 33ce63c39..78e51ec7a 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1326,6 +1326,8 @@ impl OpenCADStudio { dock.ensure_settings(); dock }, + show_graphic_attributes: self.show_graphic_attributes, + graphic_fills_on_current_layer: self.graphic_attributes.fill_on_current_layer, annotation_auto_scale: self.annotation_auto_scale, ribbon: crate::app::config::RibbonConfig { collapse: self.ribbon.collapse_mode(), @@ -1384,6 +1386,13 @@ impl OpenCADStudio { let mut dock = cfg.dock; dock.ensure_settings(); self.dock = dock; + self.show_graphic_attributes = cfg.show_graphic_attributes; + self.graphic_attributes.fill_on_current_layer = cfg.graphic_fills_on_current_layer; + if self.show_graphic_attributes { + self.dock.dock_below_properties(crate::ui::dock::PanelId::GraphicAttributes); + } + self.ribbon + .set_graphic_attributes(self.show_graphic_attributes); self.annotation_auto_scale = cfg.annotation_auto_scale.clamp(-4, 4); self.ribbon.set_collapse_mode(cfg.ribbon.collapse); self.plot_dialog = cfg.plot; diff --git a/src/app/update/graphic_attributes.rs b/src/app/update/graphic_attributes.rs new file mode 100644 index 000000000..737621450 --- /dev/null +++ b/src/app/update/graphic_attributes.rs @@ -0,0 +1,1689 @@ +//! Graphic Attributes palette: applies line attributes and associative fills +//! to the selection, or to the creation defaults when nothing is selected. + +use crate::app::{Message, OpenCADStudio}; +use crate::scene::model::hatch_model::{GradientKind, HatchPattern}; +use crate::ui::window::gradient_editor::{GradientColorMode, GradientEditorState, GradientMsg}; +use crate::ui::window::graphic_attributes::{ + self as palette, GraphicAttribute, GraphicAttributesMsg, HatchEditorState, PendingFillClose, +}; +use codec::types::{Color as AcadColor, LineWeight, Transparency}; +use codec::{EntityType, Handle}; +use iced::Task; +use rustc_hash::{FxHashMap, FxHashSet}; + +/// Which objects of the selection a palette control edits. +#[derive(Clone, Copy)] +enum Part { + /// Non-HATCH objects and the boundaries of selected associative fills. + Line, + /// Selected HATCH entities and the fills associated with selected objects. + Fill, + Both, +} + +/// The first line pattern of the catalogue, used when a fill becomes a hatch. +fn default_hatch_pattern() -> Option<&'static crate::scene::model::hatch_patterns::PatternEntry> { + crate::scene::model::hatch_patterns::catalog() + .iter() + .find(|entry| matches!(entry.gpu, HatchPattern::Pattern(_))) +} + +/// Pattern name and render pattern of a new fill; `None` for no fill. +fn fill_pattern(value: GraphicAttribute) -> Option<(String, HatchPattern)> { + match value { + GraphicAttribute::Solid => Some(("SOLID".into(), HatchPattern::Solid)), + GraphicAttribute::Hatch => { + default_hatch_pattern().map(|entry| (entry.name.clone(), entry.gpu.clone())) + } + GraphicAttribute::Gradient => Some(( + "LINEAR".into(), + HatchPattern::Gradient { + angle_deg: 0.0, + color2: [0.18, 0.18, 0.18, 0.0], + kind: GradientKind::Linear, + invert: false, + shift: 0.0, + }, + )), + GraphicAttribute::None | GraphicAttribute::Varies => None, + } +} + +/// Convert a selected HATCH to another fill kind in place. +fn set_hatch_graphic_attribute( + hatch: &mut codec::entities::Hatch, + value: GraphicAttribute, + visible_color: [f32; 4], +) { + match value { + GraphicAttribute::Solid => { + hatch.pattern = codec::entities::HatchPattern::new("SOLID"); + hatch.pattern_type = codec::entities::HatchPatternType::Predefined; + hatch.is_solid = true; + hatch.gradient_color.enabled = false; + } + GraphicAttribute::Hatch => { + let Some(entry) = default_hatch_pattern() else { + return; + }; + let mut pattern = crate::scene::model::hatch_patterns::build_dxf_pattern(entry); + crate::entities::hatch::scale_pattern_geometry( + &mut pattern, + hatch.pattern_scale.max(1.0e-6), + ); + crate::entities::hatch::rotate_pattern_geometry(&mut pattern, hatch.pattern_angle); + let origin = hatch.pattern_origin(); + crate::entities::hatch::translate_pattern_geometry(&mut pattern, origin.x, origin.y); + hatch.pattern = pattern; + hatch.pattern_type = codec::entities::HatchPatternType::Predefined; + hatch.is_solid = false; + hatch.gradient_color.enabled = false; + } + GraphicAttribute::Gradient => { + let channel = |value: f32| (value * 255.0).round().clamp(0.0, 255.0) as u8; + hatch.is_solid = true; + hatch.gradient_color.enabled = true; + hatch.gradient_color.name = "LINEAR".into(); + hatch.gradient_color.angle = 0.0; + hatch.pattern_angle = 0.0; + hatch.gradient_color.shift = 0.0; + hatch.gradient_color.is_single_color = false; + hatch.gradient_color.colors = vec![ + codec::entities::hatch::GradientColorEntry { + value: 0.0, + color: AcadColor::Rgb { + r: channel(visible_color[0]), + g: channel(visible_color[1]), + b: channel(visible_color[2]), + }, + }, + codec::entities::hatch::GradientColorEntry { + value: 1.0, + color: AcadColor::Rgb { + r: 46, + g: 46, + b: 46, + }, + }, + ]; + } + GraphicAttribute::None | GraphicAttribute::Varies => {} + } +} + +fn is_open_fill_boundary(entity: &EntityType) -> bool { + match entity { + EntityType::LwPolyline(polyline) => !polyline.is_closed && polyline.vertices.len() >= 3, + EntityType::Polyline(polyline) => { + !polyline.flags.is_closed() && polyline.vertices.len() >= 3 + } + EntityType::Polyline2D(polyline) => { + !polyline.is_closed() && polyline.vertices.len() >= 3 + } + EntityType::Polyline3D(polyline) => { + !polyline.flags.closed && polyline.vertices.len() >= 3 + } + _ => false, + } +} + +fn close_fill_boundary(entity: &mut EntityType) -> bool { + match entity { + EntityType::LwPolyline(polyline) if !polyline.is_closed => { + polyline.is_closed = true; + true + } + EntityType::Polyline(polyline) if !polyline.flags.is_closed() => { + polyline.flags.set_closed(true); + true + } + EntityType::Polyline2D(polyline) if !polyline.is_closed() => { + polyline.flags.set_closed(true); + true + } + EntityType::Polyline3D(polyline) if !polyline.flags.closed => { + polyline.flags.closed = true; + true + } + _ => false, + } +} + +/// Line attributes with ByLayer / ByBlock resolved to the values they show. +struct ConcreteGraphicAttributes { + color: AcadColor, + linetype: String, + lineweight: LineWeight, + transparency: Transparency, +} + +fn concrete_graphic_attributes( + color: AcadColor, + linetype: &str, + lineweight: LineWeight, + transparency: Transparency, + layer: Option<&codec::tables::Layer>, +) -> ConcreteGraphicAttributes { + let layer_color = layer.map_or(AcadColor::Index(7), |layer| match layer.color { + AcadColor::ByLayer | AcadColor::ByBlock => AcadColor::Index(7), + color => color, + }); + let layer_linetype = layer + .map(|layer| layer.line_type.as_str()) + .filter(|value| { + !value.is_empty() + && !value.eq_ignore_ascii_case("ByLayer") + && !value.eq_ignore_ascii_case("ByBlock") + }) + .unwrap_or("Continuous"); + let layer_lineweight = layer.map_or(LineWeight::Default, |layer| match layer.line_weight { + LineWeight::ByLayer | LineWeight::ByBlock => LineWeight::Default, + value => value, + }); + let layer_transparency = layer + .map(|layer| layer.transparency) + .filter(|value| !value.is_by_layer() && !value.is_by_block()) + .unwrap_or_else(|| Transparency::from_percent(0.0)); + + ConcreteGraphicAttributes { + color: match color { + AcadColor::ByLayer => layer_color, + AcadColor::ByBlock => AcadColor::Index(7), + value => value, + }, + linetype: if linetype.is_empty() || linetype.eq_ignore_ascii_case("ByLayer") { + layer_linetype.to_owned() + } else if linetype.eq_ignore_ascii_case("ByBlock") { + "Continuous".to_owned() + } else { + linetype.to_owned() + }, + lineweight: match lineweight { + LineWeight::ByLayer => layer_lineweight, + LineWeight::ByBlock => LineWeight::Default, + value => value, + }, + transparency: if transparency.is_by_layer() { + layer_transparency + } else if transparency.is_by_block() { + Transparency::from_percent(0.0) + } else { + transparency + }, + } +} + +impl OpenCADStudio { + pub(super) fn on_graphic_attributes(&mut self, message: GraphicAttributesMsg) -> Task { + use GraphicAttributesMsg as M; + if !matches!( + message, + M::ToggleMenu(_) + | M::HatchPatternSearch(_) + | M::HatchPatternFocus(_) + | M::LineLinetypeScaleInput(_) + | M::Gradient(_) + ) { + self.graphic_attributes.open_menu = None; + } + match message { + M::ToggleMenu(menu) => { + let state = &mut self.graphic_attributes; + state.open_menu = (state.open_menu != Some(menu)).then_some(menu); + Task::none() + } + M::CloseMenu => Task::none(), + M::SetAllByLayer => self.on_graphic_attributes_set_all(true), + M::SetAllByBlock => self.on_graphic_attributes_set_all(false), + M::RemoveReferences => self.on_graphic_attributes_remove_references(), + M::ToggleFillOnCurrentLayer => { + self.graphic_attributes.fill_on_current_layer ^= true; + self.save_config(); + Task::none() + } + M::CreateLayer => { + use crate::command::CadCommand; + let command = crate::command::FreeTextValuePromptCommand::new( + "GRAPHICATTRIBUTELAYER", + "Enter a name for the new layer:", + ); + self.command_line.push_info(&command.prompt()); + self.tabs[self.active_tab].active_cmd = Some(Box::new(command)); + self.focus_cmd_input() + } + M::Fill(value) => self.on_graphic_attribute(value), + M::LineColor(color) => self.on_line_color(color), + M::LineLinetype(linetype) => self.on_line_linetype(linetype), + M::LineLineweight(lineweight) => self.on_line_lineweight(lineweight), + M::LineLinetypeScale(scale) => self.on_line_linetype_scale(scale), + M::LineLinetypeScaleInput(value) => { + self.graphic_attributes.linetype_scale_input = Some(value); + Task::none() + } + M::LineLinetypeScaleSubmit => { + match self.graphic_attributes.linetype_scale_input.take() { + Some(typed) => { + let value = crate::app::expr_eval::eval_number(&typed.replace(',', ".")); + value.map_or_else(Task::none, |scale| self.on_line_linetype_scale(scale)) + } + None => Task::none(), + } + } + M::LineTransparency(transparency) => self.on_line_transparency(transparency), + M::FillColor(color) => self.on_solid_fill_color(color), + M::FillTransparency(transparency) => self.on_fill_transparency(transparency), + M::HatchEditorOpen => self.open_hatch_editor(), + M::HatchEditorClose => { + self.graphic_attributes.hatch_editor = None; + Task::none() + } + M::HatchPatternSearch(search) => { + if let Some(editor) = &mut self.graphic_attributes.hatch_editor { + editor.search = search; + editor.focus = 0; + } + Task::none() + } + M::HatchPatternFocus(index) => { + if let Some(editor) = &mut self.graphic_attributes.hatch_editor { + if index < crate::ui::properties::filtered_hatch_patterns(&editor.search).len() + { + editor.focus = index; + } + } + Task::none() + } + M::HatchPattern(name) => self.apply_graphic_hatch_pattern(name), + M::HatchPatternConfirm => { + let name = self.graphic_attributes.hatch_editor.as_ref().and_then(|editor| { + crate::ui::properties::filtered_hatch_patterns(&editor.search) + .get(editor.focus) + .map(|entry| entry.name.clone()) + }); + match name { + Some(name) => self.apply_graphic_hatch_pattern(name), + None => Task::none(), + } + } + M::Gradient(message) => self.on_gradient_editor(message), + } + } + + /// Prompt answers belong to the fill question only while no command runs. + pub(super) fn awaiting_fill_close_answer(&self) -> bool { + self.graphic_attributes.pending_fill_close.is_some() + && self.tabs[self.active_tab].active_cmd.is_none() + } + + /// Answer the "close open boundaries first?" prompt; Enter means Yes. + pub(super) fn on_graphic_fill_close_input(&mut self, input: &str) -> Task { + match input.trim().to_ascii_uppercase().as_str() { + "" | "Y" | "YES" => self.on_graphic_fill_close_response(true), + "N" | "NO" => self.on_graphic_fill_close_response(false), + _ => { + self.command_line.push_error(crate::t!("Enter Yes or No.").as_ref()); + Task::none() + } + } + } + + fn graphic_handles(&self, i: usize, part: Part) -> Vec { + let selected = self.tabs[i].scene.selected_handles_in_order(); + let document = &self.tabs[i].scene.document; + match part { + Part::Line => palette::line_handles(document, &selected), + Part::Fill => palette::fill_handles(document, &selected), + Part::Both => { + let mut seen = FxHashSet::default(); + palette::line_handles(document, &selected) + .into_iter() + .chain(palette::fill_handles(document, &selected)) + .filter(|handle| seen.insert(*handle)) + .collect() + } + } + } + + fn unlocked_graphic_handles(&self, i: usize, part: Part) -> Vec { + let mut handles = self.graphic_handles(i, part); + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + handles + } + + /// Edit the unlocked `part` of the selection as one undo step. + fn edit_graphic_part(&mut self, part: Part, label: &str, mut edit: impl FnMut(&mut EntityType)) { + let i = self.active_tab; + let handles = self.unlocked_graphic_handles(i, part); + self.apply_property_op(i, label, &handles, |app, handle| { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + edit(entity); + } + }); + } + + fn has_selection(&self) -> bool { + !self.tabs[self.active_tab].scene.selected_handles_in_order().is_empty() + } + + fn current_graphic_attributes(&self, i: usize) -> ConcreteGraphicAttributes { + let document = &self.tabs[i].scene.document; + let handles = self.graphic_handles(i, Part::Both); + let draw_depth = self.tabs[i].scene.draw_depth_map(); + if let Some(entity) = + palette::top_handle(&handles, &draw_depth).and_then(|handle| document.get_entity(handle)) + { + let common = entity.common(); + return concrete_graphic_attributes( + common.color, + &common.linetype, + common.line_weight, + common.transparency, + document.layers.get(&common.layer), + ); + } + + let header = &document.header; + let layer_name = if header.current_layer_name.is_empty() { + self.tabs[i].active_layer.as_str() + } else { + header.current_layer_name.as_str() + }; + concrete_graphic_attributes( + header.current_entity_color, + &header.current_linetype_name, + LineWeight::from_value(header.current_line_weight), + document.current_entity_transparency(), + document.layers.get(layer_name), + ) + } + + /// Store line attributes as the creation defaults (CECOLOR, CELTYPE, + /// CELWEIGHT, CETRANSPARENCY). + fn set_current_graphic_attributes( + &mut self, + color: AcadColor, + linetype: String, + lineweight: LineWeight, + transparency: Transparency, + ) { + let i = self.active_tab; + let linetype_handle = self.tabs[i] + .scene + .document + .line_types + .iter() + .find(|line_type| line_type.name.eq_ignore_ascii_case(&linetype)) + .map_or(Handle::NULL, |line_type| line_type.handle); + let header = &mut self.tabs[i].scene.document.header; + header.current_entity_color = color; + header.current_linetype_name = linetype; + header.current_linetype_handle = linetype_handle; + header.current_line_weight = lineweight.value(); + self.tabs[i] + .scene + .document + .set_current_entity_transparency(transparency); + self.tabs[i].dirty = true; + self.sync_ribbon_from_selection(); + self.refresh_properties(); + } + + pub(super) fn on_graphic_attributes_set_all(&mut self, by_layer: bool) -> Task { + let (color, linetype, lineweight, transparency) = if by_layer { + (AcadColor::ByLayer, "ByLayer", LineWeight::ByLayer, Transparency::BY_LAYER) + } else { + (AcadColor::ByBlock, "ByBlock", LineWeight::ByBlock, Transparency::BY_BLOCK) + }; + if !self.has_selection() { + self.set_current_graphic_attributes(color, linetype.to_owned(), lineweight, transparency); + return Task::none(); + } + self.edit_graphic_part(Part::Both, &crate::t!("Graphic Attributes"), |entity| { + crate::scene::view::dispatch::apply_color(entity, color); + crate::scene::view::dispatch::apply_common_prop(entity, "linetype", linetype); + crate::scene::view::dispatch::apply_line_weight(entity, lineweight); + entity.common_mut().transparency = transparency; + }); + Task::none() + } + + pub(super) fn on_graphic_attributes_remove_references(&mut self) -> Task { + let i = self.active_tab; + if !self.has_selection() { + let values = self.current_graphic_attributes(i); + self.set_current_graphic_attributes( + values.color, + values.linetype, + values.lineweight, + values.transparency, + ); + return Task::none(); + } + + let document = &self.tabs[i].scene.document; + let values: FxHashMap<_, _> = self + .unlocked_graphic_handles(i, Part::Both) + .into_iter() + .filter_map(|handle| { + let common = document.get_entity(handle)?.common(); + Some(( + handle, + concrete_graphic_attributes( + common.color, + &common.linetype, + common.line_weight, + common.transparency, + document.layers.get(&common.layer), + ), + )) + }) + .collect(); + let handles: Vec<_> = values.keys().copied().collect(); + self.apply_property_op(i, crate::t!("Graphic Attributes"), &handles, |app, handle| { + let (Some(values), Some(entity)) = ( + values.get(&handle), + app.tabs[i].scene.document.get_entity_mut(handle), + ) else { + return; + }; + crate::scene::view::dispatch::apply_color(entity, values.color); + crate::scene::view::dispatch::apply_common_prop(entity, "linetype", &values.linetype); + crate::scene::view::dispatch::apply_line_weight(entity, values.lineweight); + entity.common_mut().transparency = values.transparency; + }); + Task::none() + } + + pub(crate) fn create_graphic_attributes_layer(&mut self, raw_name: &str) -> Task { + let i = self.active_tab; + let name = raw_name.trim(); + if !crate::scene::valid_block_name(name) { + self.command_line + .push_error(crate::t!("Graphic Attributes: enter a valid layer name without <>/\\\":;?*|,=`.").as_ref()); + return Task::none(); + } + if self.tabs[i] + .scene + .document + .layers + .iter() + .any(|layer| layer.name.eq_ignore_ascii_case(name)) + { + self.command_line.push_error( + crate::tf!("Graphic Attributes: layer \"{name}\" already exists.").as_ref(), + ); + return Task::none(); + } + + let values = self.current_graphic_attributes(i); + let layer_name = name.to_owned(); + let undo = self.begin_layer_undo( + i, + crate::t!("Create Layer with active settings"), + std::slice::from_ref(&layer_name), + ); + let mut layer = codec::tables::Layer::new(name); + layer.handle = self.tabs[i].scene.document.allocate_handle(); + layer.color = values.color; + layer.line_type = values.linetype; + layer.line_weight = values.lineweight; + layer.transparency = values.transparency; + if self.tabs[i].scene.document.layers.add(layer).is_err() { + self.command_line + .push_error(crate::t!("Graphic Attributes: the layer could not be created.").as_ref()); + return Task::none(); + } + self.tabs[i].dirty = true; + self.commit_layer_undo(i, undo); + self.refresh_layer_panel(); + self.command_line.push_output( + crate::tf!("Created layer \"{name}\" with the active Graphic Attributes settings.") + .as_ref(), + ); + Task::none() + } + + fn open_hatch_editor(&mut self) -> Task { + self.graphic_attributes.gradient_editor = None; + let i = self.active_tab; + let document = &self.tabs[i].scene.document; + let selected = self.tabs[i].scene.selected_handles_in_order(); + let current = palette::fill_handles(document, &selected) + .into_iter() + .find_map(|handle| match document.get_entity(handle)? { + EntityType::Hatch(hatch) => Some(hatch.pattern.name.as_str()), + _ => None, + }); + let focus = current + .and_then(|current| { + crate::ui::properties::filtered_hatch_patterns("") + .iter() + .position(|entry| entry.name.eq_ignore_ascii_case(current)) + }) + .unwrap_or(0); + self.graphic_attributes.hatch_editor = Some(HatchEditorState { + handles: selected, + search: String::new(), + focus, + }); + iced::widget::operation::focus(iced::widget::Id::new("graphic-hatch-pattern-search")) + } + + fn apply_graphic_hatch_pattern(&mut self, name: String) -> Task { + self.graphic_attributes.hatch_editor = None; + let i = self.active_tab; + let handles = self.graphic_handles(i, Part::Fill); + self.apply_hatch_pattern(i, &handles, name) + } + + fn on_gradient_editor(&mut self, message: GradientMsg) -> Task { + match message { + GradientMsg::Open => { + self.graphic_attributes.hatch_editor = None; + self.open_gradient_editor(); + } + GradientMsg::Cancel => self.graphic_attributes.gradient_editor = None, + GradientMsg::Apply => return self.apply_gradient_editor(), + message => { + if let Some(editor) = &mut self.graphic_attributes.gradient_editor { + editor.update(message); + } + } + } + Task::none() + } + + pub(super) fn open_gradient_editor(&mut self) { + let i = self.active_tab; + let document = &self.tabs[i].scene.document; + let selected = self.tabs[i].scene.selected_handles_in_order(); + let gradient = palette::fill_handles(document, &selected) + .into_iter() + .find_map(|handle| match document.get_entity(handle)? { + EntityType::Hatch(hatch) if hatch.gradient_color.enabled => Some(hatch), + _ => None, + }); + if let Some(hatch) = gradient { + self.graphic_attributes.gradient_editor = + Some(GradientEditorState::from_hatch(selected, hatch)); + } + } + + pub(super) fn apply_gradient_editor(&mut self) -> Task { + let Some(state) = self.graphic_attributes.gradient_editor.take() else { + return Task::none(); + }; + let i = self.active_tab; + let document = &self.tabs[i].scene.document; + let targets: Vec<_> = palette::fill_handles(document, &state.handles) + .into_iter() + .filter(|handle| { + matches!(document.get_entity(*handle), + Some(EntityType::Hatch(hatch)) if hatch.gradient_color.enabled) + }) + .collect(); + if targets.is_empty() { + return Task::none(); + } + if targets + .iter() + .any(|handle| self.tabs[i].scene.is_layer_locked(*handle)) + { + self.command_line + .push_error(crate::t!("Gradient Fill: the fill is on a locked layer.").as_ref()); + self.graphic_attributes.gradient_editor = Some(state); + return Task::none(); + } + let angle = state.angle_radians(); + let pending = self.begin_undo(i, crate::t!("Edit gradient"), targets.len(), true); + for handle in &targets { + let Some(mut entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { + continue; + }; + if let EntityType::Hatch(hatch) = &mut entity { + let gradient = &mut hatch.gradient_color; + gradient.name = state.kind.dxf_name(state.inverted).into(); + gradient.angle = angle; + gradient.shift = if state.centered { 0.0 } else { 1.0 }; + gradient.is_single_color = state.color_mode == GradientColorMode::One; + gradient.color_tint = state.shade_tint as f64; + gradient.colors = vec![ + codec::entities::hatch::GradientColorEntry { + value: 0.0, + color: state.color_1, + }, + codec::entities::hatch::GradientColorEntry { + value: 1.0, + color: state.color_2, + }, + ]; + hatch.pattern_angle = angle; + } + self.tabs[i].scene.update_entity(entity); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + Task::none() + } + + /// Give every selected object the chosen fill. Open polylines first ask + /// whether they should be closed. + pub(super) fn on_graphic_attribute(&mut self, value: GraphicAttribute) -> Task { + self.graphic_attributes.close_popups(); + self.graphic_attributes.pending_fill_close = None; + if value == GraphicAttribute::Varies { + return Task::none(); + } + let i = self.active_tab; + let selected = self.tabs[i].scene.selected_handles_in_order(); + if selected.is_empty() { + self.command_line + .push_info(crate::t!("Select one or more closed objects first.").as_ref()); + return Task::none(); + } + if value != GraphicAttribute::None { + let open_boundaries: Vec<_> = selected + .iter() + .copied() + .filter(|handle| { + !self.tabs[i].scene.is_layer_locked(*handle) + && self.tabs[i] + .scene + .document + .get_entity(*handle) + .is_some_and(is_open_fill_boundary) + }) + .collect(); + if !open_boundaries.is_empty() { + self.graphic_attributes.pending_fill_close = Some(PendingFillClose { + fill: value, + open_boundaries, + selection: selected, + }); + return Task::none(); + } + } + self.apply_graphic_fill(i, value, &selected, &[]) + } + + pub(super) fn on_graphic_fill_close_response(&mut self, close: bool) -> Task { + let Some(pending) = self.graphic_attributes.pending_fill_close.take() else { + return Task::none(); + }; + let i = self.active_tab; + let to_close: Vec<_> = if close { + pending + .open_boundaries + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect() + } else { + Vec::new() + }; + self.apply_graphic_fill(i, pending.fill, &pending.selection, &to_close) + } + + /// Close `to_close` and replace the fills of `selected`, as one undo step. + fn apply_graphic_fill( + &mut self, + i: usize, + value: GraphicAttribute, + selected: &[Handle], + to_close: &[Handle], + ) -> Task { + let (selected, locked): (Vec, Vec) = selected + .iter() + .partition(|handle| !self.tabs[i].scene.is_layer_locked(**handle)); + if !locked.is_empty() { + self.command_line.push_info( + crate::tf!( + "Graphic Attributes: {} object(s) on a locked layer were not changed.", + locked.len() + ) + .as_ref(), + ); + } + let selected = selected.as_slice(); + let pending = self.begin_undo(i, crate::t!("Fill change"), selected.len(), true); + let mut changed = false; + for handle in to_close { + let Some(mut entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { + continue; + }; + if close_fill_boundary(&mut entity) { + self.tabs[i].scene.update_entity(entity); + changed = true; + } + } + changed |= self.replace_graphic_fills(i, value, selected); + if changed { + self.tabs[i].dirty = true; + self.refresh_properties(); + } + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + Task::none() + } + + /// Replace the fills of `selected` by one associative fill per object. + /// Returns whether the document changed. + fn replace_graphic_fills( + &mut self, + i: usize, + value: GraphicAttribute, + selected: &[Handle], + ) -> bool { + let document = &self.tabs[i].scene.document; + let (associated, new_hatches, updated_hatches, skipped) = if value == GraphicAttribute::None + { + (palette::fill_handles(document, selected), Vec::new(), Vec::new(), 0) + } else { + let Some((name, pattern)) = fill_pattern(value) else { + self.command_line + .push_error(crate::t!("Graphic Attributes: no hatch pattern is available.").as_ref()); + return false; + }; + let working = if self.tabs[i].editing_model_space() { + self.tabs[i].ucs_xform().working_plane() + } else { + crate::command::WorkingPlane::default() + }; + let normal = working.z.normalize_or(glam::DVec3::Z); + let storage = crate::entities::curve::ocs_plane( + codec::types::Vector3::new(normal.x, normal.y, normal.z), + working.origin.dot(normal), + ); + let plane = crate::command::WorkingPlane::new( + glam::DVec3::from_array(storage.origin), + glam::DVec3::from_array(storage.x_axis), + glam::DVec3::from_array(storage.y_axis), + ); + let sources = self.tabs[i].scene.boundary_sources_on_plane(plane, 1.0e-6); + let origin = document.hatch_origin(); + let mut new_hatches = Vec::new(); + let mut updated_hatches = Vec::new(); + let mut directly_updated = FxHashSet::default(); + let mut changed_boundaries = FxHashSet::default(); + let mut skipped = 0; + for handle in selected { + let Some(entity) = document.get_entity(*handle) else { + continue; + }; + if let EntityType::Hatch(hatch) = entity { + let mut hatch = hatch.clone(); + let visible_color = + crate::scene::view::render::render_style_for_common_viewport( + document, + &hatch.common, + None, + ) + .0; + set_hatch_graphic_attribute(&mut hatch, value, visible_color); + updated_hatches.push(EntityType::Hatch(hatch)); + directly_updated.insert(*handle); + continue; + } + // Object mode only needs this object's source. Including all + // scene sources can associate coincident, unselected objects. + let object_sources = sources + .get(handle) + .map(|source| (*handle, source.clone())) + .into_iter() + .collect(); + let command = crate::modules::draw::draw::hatch::HatchCommand::new( + Vec::new(), + object_sources, + vec![*handle], + None, + plane, + ) + .with_origin(origin) + .with_pattern(name.clone(), pattern.clone()); + if let crate::command::CmdResult::CommitHatch(hatch) = command.finish_selected() { + let layer = if self.graphic_attributes.fill_on_current_layer { + self.tabs[i].active_layer.clone() + } else { + entity.common().layer.clone() + }; + new_hatches.push((hatch, layer)); + changed_boundaries.insert(*handle); + } else if !is_open_fill_boundary(entity) { + // Open boundaries the user chose not to close are expected + // to stay unfilled; anything else is unsupported. + skipped += 1; + } + } + // Replace only fills belonging to boundaries that successfully + // produced a new hatch. Existing fills on skipped objects remain. + let associated: Vec<_> = document + .entities() + .filter_map(|entity| { + let EntityType::Hatch(hatch) = entity else { + return None; + }; + (hatch.is_associative + && !directly_updated.contains(&entity.common().handle) + && hatch + .paths + .iter() + .flat_map(|path| &path.boundary_handles) + .any(|handle| changed_boundaries.contains(handle))) + .then_some(entity.common().handle) + }) + .collect(); + (associated, new_hatches, updated_hatches, skipped) + }; + if associated + .iter() + .chain(updated_hatches.iter().map(|entity| &entity.common().handle)) + .any(|handle| self.tabs[i].scene.is_layer_locked(*handle)) + { + self.command_line + .push_error(crate::t!("Graphic Attributes: an associated fill is on a locked layer.").as_ref()); + return false; + } + if skipped == 1 { + self.command_line + .push_info(crate::t!("Graphic Attributes: 1 unsupported object was not changed.").as_ref()); + } else if skipped > 1 { + self.command_line.push_info( + crate::tf!("Graphic Attributes: {skipped} unsupported objects were not changed.") + .as_ref(), + ); + } + if associated.is_empty() && new_hatches.is_empty() && updated_hatches.is_empty() { + return false; + } + self.tabs[i].scene.erase_entities(&associated); + for entity in updated_hatches { + self.tabs[i].scene.update_entity(entity); + } + for (hatch, layer) in new_hatches { + self.tabs[i].scene.add_hatch(hatch, Some(&layer), None); + } + true + } + + pub(super) fn on_solid_fill_color(&mut self, color: AcadColor) -> Task { + self.edit_graphic_part(Part::Fill, &crate::t!("Fill color"), |entity| { + crate::scene::view::dispatch::apply_color(entity, color); + }); + Task::none() + } + + pub(super) fn on_fill_transparency(&mut self, transparency: Transparency) -> Task { + self.edit_graphic_part(Part::Fill, &crate::t!("Fill transparency"), |entity| { + entity.common_mut().transparency = transparency; + }); + Task::none() + } + + pub(super) fn on_line_color(&mut self, color: AcadColor) -> Task { + if !self.has_selection() { + return self.on_ribbon_color_changed(color); + } + self.edit_graphic_part(Part::Line, &crate::t!("Line color"), |entity| { + crate::scene::view::dispatch::apply_color(entity, color); + }); + Task::none() + } + + pub(super) fn on_line_linetype(&mut self, linetype: String) -> Task { + if !self.has_selection() { + return self.on_ribbon_linetype_changed(linetype); + } + self.edit_graphic_part(Part::Line, &crate::t!("Linetype"), |entity| { + crate::scene::view::dispatch::apply_common_prop(entity, "linetype", &linetype); + }); + Task::none() + } + + pub(super) fn on_line_lineweight(&mut self, lineweight: LineWeight) -> Task { + if !self.has_selection() { + return self.on_ribbon_lineweight_changed(lineweight); + } + self.edit_graphic_part(Part::Line, &crate::t!("Line weight"), |entity| { + crate::scene::view::dispatch::apply_line_weight(entity, lineweight); + }); + Task::none() + } + + pub(super) fn on_line_linetype_scale(&mut self, scale: f64) -> Task { + let Some(scale) = palette::normalized_linetype_scale(scale) else { + return Task::none(); + }; + if !self.has_selection() { + let i = self.active_tab; + self.tabs[i].scene.document.header.current_entity_linetype_scale = scale; + self.tabs[i].dirty = true; + self.refresh_properties(); + return Task::none(); + } + self.edit_graphic_part(Part::Line, &crate::t!("Linetype scale"), |entity| { + entity.common_mut().linetype_scale = scale; + }); + Task::none() + } + + pub(super) fn on_line_transparency(&mut self, transparency: Transparency) -> Task { + if !self.has_selection() { + let i = self.active_tab; + if self.tabs[i] + .scene + .document + .set_current_entity_transparency(transparency) + { + self.tabs[i].dirty = true; + self.refresh_properties(); + } + return Task::none(); + } + self.edit_graphic_part(Part::Line, &crate::t!("Line transparency"), |entity| { + entity.common_mut().transparency = transparency; + }); + Task::none() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codec::entities::{Line, LwPolyline}; + use codec::types::Vector2; + + fn ga(message: GraphicAttributesMsg) -> Message { + Message::GraphicAttributes(message) + } + + fn fresh() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app + } + + fn add_closed_square(app: &mut OpenCADStudio, x: f64) -> Handle { + let mut polyline = LwPolyline::new(); + for (px, py) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] { + polyline.add_point(Vector2::new(px + x, py)); + } + polyline.close(); + let i = app.active_tab; + app.tabs[i].scene.add_entity(EntityType::LwPolyline(polyline)) + } + + fn select(app: &mut OpenCADStudio, handles: &[Handle]) { + let i = app.active_tab; + app.tabs[i].scene.deselect_all(); + for handle in handles { + app.tabs[i].scene.select_entity(*handle, false); + } + } + + fn hatches(app: &OpenCADStudio) -> Vec<&codec::entities::Hatch> { + app.tabs[app.active_tab] + .scene + .document + .entities() + .filter_map(|entity| match entity { + EntityType::Hatch(hatch) => Some(hatch), + _ => None, + }) + .collect() + } + + fn common(app: &OpenCADStudio, handle: Handle) -> &codec::entities::EntityCommon { + app.tabs[app.active_tab] + .scene + .document + .get_entity(handle) + .expect("entity") + .common() + } + + fn fill_kind(app: &OpenCADStudio, handle: Handle) -> GraphicAttribute { + palette::current(&app.tabs[app.active_tab].scene.document, &[handle]) + } + + /// A closed square with a fill of `kind`; the fill HATCH is selected. + fn filled_square(app: &mut OpenCADStudio, kind: GraphicAttribute) -> (Handle, Handle) { + let boundary = add_closed_square(app, 0.0); + select(app, &[boundary]); + let _ = app.on_graphic_attribute(kind); + let hatch = hatches(app)[0].common.handle; + select(app, &[hatch]); + (boundary, hatch) + } + + #[test] + fn coincident_boundaries_each_get_their_own_associative_fill() { + let mut app = fresh(); + let boundaries = [add_closed_square(&mut app, 0.0), add_closed_square(&mut app, 0.0)]; + select(&mut app, &boundaries); + + let _ = app.on_graphic_attribute(GraphicAttribute::Solid); + let fills = hatches(&app); + assert_eq!(fills.len(), 2); + assert!(fills.iter().all(|hatch| hatch.is_solid && hatch.is_associative)); + assert!(fills.iter().all(|hatch| hatch + .paths + .iter() + .flat_map(|path| &path.boundary_handles) + .count() + == 1)); + + select(&mut app, &boundaries[..1]); + let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); + assert_eq!(fill_kind(&app, boundaries[0]), GraphicAttribute::Gradient); + assert_eq!(fill_kind(&app, boundaries[1]), GraphicAttribute::Solid); + assert_eq!(hatches(&app).len(), 2); + } + + #[test] + fn unsupported_objects_are_skipped_while_the_rest_is_filled() { + let mut app = fresh(); + let i = app.active_tab; + let boundaries = [add_closed_square(&mut app, 0.0), add_closed_square(&mut app, 20.0)]; + select(&mut app, &boundaries); + let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); + + let line = app.tabs[i].scene.add_entity(EntityType::Line(Line::new())); + select(&mut app, &[boundaries[0], line]); + let _ = app.on_graphic_attribute(GraphicAttribute::Solid); + assert_eq!(fill_kind(&app, boundaries[0]), GraphicAttribute::Solid); + assert_eq!(fill_kind(&app, boundaries[1]), GraphicAttribute::Gradient); + assert_eq!(fill_kind(&app, line), GraphicAttribute::None); + assert_eq!(hatches(&app).len(), 2); + } + + #[test] + fn editing_a_shared_gradient_is_one_undo_step() { + let mut app = fresh(); + let i = app.active_tab; + let boundaries = [add_closed_square(&mut app, 0.0), add_closed_square(&mut app, 20.0)]; + select(&mut app, &boundaries); + let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); + + app.open_gradient_editor(); + let editor = app + .graphic_attributes + .gradient_editor + .as_mut() + .expect("gradient editor"); + assert_eq!(editor.handles.len(), 2); + editor.kind = crate::scene::model::hatch_model::GradientKind::Curved; + editor.inverted = true; + editor.angle = "405°".into(); + editor.centered = false; + let undo_before = app.tabs[i].history.undo_stack.len(); + let _ = app.apply_gradient_editor(); + assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1); + + let gradients = hatches(&app); + assert_eq!(gradients.len(), 2); + assert!(gradients.iter().all(|hatch| { + hatch.gradient_color.name == "INVCURVED" + && (hatch.gradient_color.angle.to_degrees() - 45.0).abs() < 1.0e-9 + && (hatch.gradient_color.shift - 1.0).abs() < 1.0e-9 + })); + } + + #[test] + fn gradient_editor_closes_when_the_selection_changes() { + let mut app = fresh(); + filled_square(&mut app, GraphicAttribute::Gradient); + app.open_gradient_editor(); + assert!(app.graphic_attributes.gradient_editor.is_some()); + + app.tabs[app.active_tab].scene.deselect_all(); + let _ = app.update(ga(GraphicAttributesMsg::CloseMenu)); + assert!(app.graphic_attributes.gradient_editor.is_none()); + } + + #[test] + fn a_selected_hatch_is_converted_in_place() { + let mut app = fresh(); + let (boundary, hatch) = filled_square(&mut app, GraphicAttribute::Gradient); + let document = &app.tabs[app.active_tab].scene.document; + assert_eq!(palette::line_handles(document, &[hatch]), vec![boundary]); + assert_eq!(fill_kind(&app, hatch), GraphicAttribute::Gradient); + + for kind in [GraphicAttribute::Solid, GraphicAttribute::Hatch, GraphicAttribute::Gradient] { + let _ = app.on_graphic_attribute(kind); + assert_eq!(fill_kind(&app, hatch), kind); + } + assert_eq!(hatches(&app).len(), 1); + + let pattern = default_hatch_pattern().expect("pattern hatch").name.clone(); + let _ = app.update(ga(GraphicAttributesMsg::HatchPattern(pattern.clone()))); + assert_eq!(hatches(&app)[0].pattern.name, pattern); + } + + #[test] + fn line_and_fill_controls_only_edit_their_own_part() { + let mut app = fresh(); + let i = app.active_tab; + let (_, hatch) = filled_square(&mut app, GraphicAttribute::Solid); + let line = app.tabs[i].scene.add_entity(EntityType::Line(Line::new())); + select(&mut app, &[hatch, line]); + + let _ = app.on_solid_fill_color(AcadColor::Index(3)); + let _ = app.on_line_color(AcadColor::Index(4)); + assert_eq!(common(&app, hatch).color, AcadColor::Index(3)); + assert_eq!(common(&app, line).color, AcadColor::Index(4)); + + let _ = app.on_line_linetype("ByBlock".to_string()); + assert_eq!(common(&app, line).linetype, "ByBlock"); + + let _ = app.on_line_lineweight(LineWeight::Value(50)); + assert_eq!(common(&app, line).line_weight, LineWeight::Value(50)); + assert_ne!(common(&app, hatch).line_weight, LineWeight::Value(50)); + + let _ = app.on_line_linetype_scale(2.5); + assert_eq!(common(&app, line).linetype_scale, 2.5); + assert_eq!(common(&app, hatch).linetype_scale, 1.0); + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScale(f64::NAN))); + assert_eq!(common(&app, line).linetype_scale, 2.5); + + let _ = app.on_line_transparency(Transparency::from_percent(0.2)); + let _ = app.on_fill_transparency(Transparency::from_percent(0.4)); + assert!((common(&app, line).transparency.as_percent() - 0.2).abs() < 0.01); + assert!((common(&app, hatch).transparency.as_percent() - 0.4).abs() < 0.01); + let _ = app.on_fill_transparency(Transparency::BY_LAYER); + assert!(common(&app, hatch).transparency.is_by_layer()); + assert!((common(&app, line).transparency.as_percent() - 0.2).abs() < 0.01); + } + + #[test] + fn choosing_another_fill_closes_the_editors() { + let mut app = fresh(); + filled_square(&mut app, GraphicAttribute::Gradient); + app.open_gradient_editor(); + assert!(app.graphic_attributes.gradient_editor.is_some()); + let _ = app.on_graphic_attribute(GraphicAttribute::Hatch); + assert!(app.graphic_attributes.gradient_editor.is_none()); + + let _ = app.update(ga(GraphicAttributesMsg::HatchEditorOpen)); + assert!(app.graphic_attributes.hatch_editor.is_some()); + let _ = app.on_graphic_attribute(GraphicAttribute::Gradient); + assert!(app.graphic_attributes.hatch_editor.is_none()); + assert!(app.graphic_attributes.gradient_editor.is_none()); + } + + #[test] + fn removing_the_fill_erases_it_in_one_undo_step() { + let mut app = fresh(); + let i = app.active_tab; + let (_, hatch) = filled_square(&mut app, GraphicAttribute::Solid); + let undo_before = app.tabs[i].history.undo_stack.len(); + let _ = app.on_graphic_attribute(GraphicAttribute::None); + assert!(app.tabs[i].scene.document.get_entity(hatch).is_none()); + assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1); + } + + #[test] + fn graphic_attributes_can_close_an_open_boundary_from_the_command_prompt() { + let mut app = fresh(); + let i = app.active_tab; + let mut polyline = LwPolyline::new(); + for (x, y) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] { + polyline.add_point(Vector2::new(x, y)); + } + let boundary = app.tabs[i] + .scene + .add_entity(EntityType::LwPolyline(polyline)); + app.tabs[i].scene.select_entity(boundary, false); + + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + assert!(app.graphic_attributes.pending_fill_close.is_some()); + assert!(!app.tabs[i].scene.document.entities().any(|entity| { + matches!(entity, EntityType::Hatch(_)) + })); + + let _ = app.update(Message::CommandOptionPick("Y".to_string())); + assert!(app.graphic_attributes.pending_fill_close.is_none()); + assert!(matches!( + app.tabs[i].scene.document.get_entity(boundary), + Some(EntityType::LwPolyline(polyline)) if polyline.is_closed + )); + assert!(app.tabs[i].scene.document.entities().any(|entity| { + matches!(entity, EntityType::Hatch(hatch) if hatch.is_solid && hatch.is_associative) + })); + } + + #[test] + fn graphic_line_controls_update_creation_defaults_without_a_selection() { + let mut app = fresh(); + let i = app.active_tab; + app.tabs[i].scene.deselect_all(); + + let _ = app.on_line_color(codec::types::Color::Index(2)); + let _ = app.on_line_linetype("Continuous".to_string()); + let _ = app.on_line_lineweight(codec::types::LineWeight::Value(35)); + let _ = app.on_line_linetype_scale(2.5); + let _ = app.on_line_transparency(codec::types::Transparency::from_percent(0.3)); + + let header = &app.tabs[i].scene.document.header; + assert_eq!( + header.current_entity_color, + codec::types::Color::Index(2) + ); + assert_eq!(header.current_linetype_name, "Continuous"); + assert_eq!(header.current_line_weight, 35); + assert!((header.current_entity_linetype_scale - 2.5).abs() < f64::EPSILON); + assert!( + (app.tabs[i] + .scene + .document + .current_entity_transparency() + .as_percent() + - 0.3) + .abs() + < 0.01 + ); + } + + #[test] + fn graphic_attributes_bulk_modes_and_remove_references_preserve_visible_values() { + let mut app = fresh(); + let i = app.active_tab; + { + let layer = app.tabs[i] + .scene + .document + .layers + .get_mut("0") + .expect("default layer"); + layer.color = codec::types::Color::Index(3); + layer.line_type = "Continuous".to_string(); + layer.line_weight = codec::types::LineWeight::Value(50); + layer.transparency = codec::types::Transparency::from_percent(0.25); + } + let handle = app.tabs[i].scene.add_entity(EntityType::Line(Line::new())); + app.tabs[i].scene.select_entity(handle, false); + + let _ = app.on_graphic_attributes_set_all(false); + let common = app.tabs[i] + .scene + .document + .get_entity(handle) + .expect("line") + .common(); + assert_eq!(common.color, codec::types::Color::ByBlock); + assert_eq!(common.linetype, "ByBlock"); + assert_eq!(common.line_weight, codec::types::LineWeight::ByBlock); + assert!(common.transparency.is_by_block()); + + let _ = app.on_graphic_attributes_set_all(true); + let _ = app.on_graphic_attributes_remove_references(); + let common = app.tabs[i] + .scene + .document + .get_entity(handle) + .expect("line") + .common(); + assert_eq!(common.color, codec::types::Color::Index(3)); + assert_eq!(common.linetype, "Continuous"); + assert_eq!(common.line_weight, codec::types::LineWeight::Value(50)); + assert!((common.transparency.as_percent() - 0.25).abs() < 0.01); + } + + #[test] + fn graphic_attributes_create_layer_prompt_keeps_spaces_and_active_settings() { + let mut app = fresh(); + let i = app.active_tab; + app.tabs[i].scene.deselect_all(); + let _ = app.on_line_color(codec::types::Color::Index(2)); + let _ = app.on_line_linetype("Continuous".to_string()); + let _ = app.on_line_lineweight(codec::types::LineWeight::Value(35)); + let _ = app.on_line_transparency(codec::types::Transparency::from_percent(0.3)); + + let _ = app.create_graphic_attributes_layer("Invalid\nLayer"); + assert!(app.tabs[i] + .scene + .document + .layers + .get("Invalid\nLayer") + .is_none()); + + let _ = app.update(ga(GraphicAttributesMsg::CreateLayer)); + assert_eq!( + app.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.name()), + Some("GRAPHICATTRIBUTELAYER") + ); + let _ = app.update(Message::CommandInput("Graphic Settings".to_string())); + let _ = app.update(Message::CommandFinalize); + + let layer = app.tabs[i] + .scene + .document + .layers + .get("Graphic Settings") + .expect("created layer"); + assert_eq!(layer.color, codec::types::Color::Index(2)); + assert_eq!(layer.line_type, "Continuous"); + assert_eq!(layer.line_weight, codec::types::LineWeight::Value(35)); + assert!((layer.transparency.as_percent() - 0.3).abs() < 0.01); + } + + fn open_square(app: &mut OpenCADStudio) -> Handle { + let i = app.active_tab; + let mut polyline = LwPolyline::new(); + for (x, y) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] { + polyline.add_point(Vector2::new(x, y)); + } + let handle = app.tabs[i] + .scene + .add_entity(EntityType::LwPolyline(polyline)); + app.tabs[i].scene.select_entity(handle, false); + handle + } + + fn has_hatch(app: &OpenCADStudio) -> bool { + app.tabs[app.active_tab] + .scene + .document + .entities() + .any(|entity| matches!(entity, EntityType::Hatch(_))) + } + + fn is_closed(app: &OpenCADStudio, handle: Handle) -> bool { + matches!( + app.tabs[app.active_tab].scene.document.get_entity(handle), + Some(EntityType::LwPolyline(polyline)) if polyline.is_closed + ) + } + + fn type_and_enter(app: &mut OpenCADStudio, input: &str) { + let _ = app.update(Message::CommandInput(input.to_string())); + let _ = app.update(Message::CommandSubmit); + } + + #[test] + fn closing_an_open_boundary_and_filling_it_is_one_undo_step() { + let mut app = fresh(); + let i = app.active_tab; + let boundary = open_square(&mut app); + let undo_before = app.tabs[i].history.undo_stack.len(); + + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + let _ = app.update(Message::CommandOptionPick("Y".to_string())); + assert!(is_closed(&app, boundary)); + assert!(has_hatch(&app)); + assert_eq!(app.tabs[i].history.undo_stack.len(), undo_before + 1); + + let _ = app.update(Message::Undo); + assert!(!is_closed(&app, boundary)); + assert!(!has_hatch(&app)); + } + + #[test] + fn typed_answers_to_the_close_prompt() { + // Yes, typed and submitted with Enter. + let mut app = fresh(); + let boundary = open_square(&mut app); + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + type_and_enter(&mut app, "y"); + assert!(app.graphic_attributes.pending_fill_close.is_none()); + assert!(is_closed(&app, boundary) && has_hatch(&app)); + + // No leaves the object open and unfilled. + let mut app = fresh(); + let boundary = open_square(&mut app); + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + type_and_enter(&mut app, "No"); + assert!(app.graphic_attributes.pending_fill_close.is_none()); + assert!(!is_closed(&app, boundary) && !has_hatch(&app)); + + // Anything else keeps asking; a bare Enter takes the default Yes. + let mut app = fresh(); + let boundary = open_square(&mut app); + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + type_and_enter(&mut app, "maybe"); + assert!(app.graphic_attributes.pending_fill_close.is_some()); + assert!(app.tabs[app.active_tab].active_cmd.is_none()); + let _ = app.update(Message::CommandFinalize); + assert!(is_closed(&app, boundary) && has_hatch(&app)); + + // Escape cancels the request. + let mut app = fresh(); + let boundary = open_square(&mut app); + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + let _ = app.update(Message::CommandEscape); + assert!(app.graphic_attributes.pending_fill_close.is_none()); + assert!(!is_closed(&app, boundary) && !has_hatch(&app)); + } + + #[test] + fn linetype_scale_keeps_iso_pen_width_values() { + let mut app = fresh(); + let i = app.active_tab; + let line = app.tabs[i].scene.add_entity(EntityType::Line(Line::new())); + app.tabs[i].scene.select_entity(line, false); + for scale in [0.13, 0.18, 0.25, 0.35, 0.05] { + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScale(scale))); + let stored = app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype_scale; + assert_eq!(stored, scale); + } + // Spinner steps do not accumulate floating-point noise. + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScale(0.25 + 0.1))); + let stored = app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype_scale; + assert_eq!(stored, 0.35); + for invalid in [0.0, -1.0, f64::INFINITY] { + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScale(invalid))); + } + let stored = app.tabs[i] + .scene + .document + .get_entity(line) + .expect("line") + .common() + .linetype_scale; + assert_eq!(stored, 0.35); + } + + #[test] + fn graphic_attributes_starts_hidden_and_toggle_is_persisted() { + let mut app = OpenCADStudio::new_for_test(); + app.apply_config(crate::app::config::AppConfig::default()); + assert!(!app.show_graphic_attributes); + assert!(!app.ribbon.show_graphic_attributes); + + let _ = app.update(Message::ToggleGraphicAttributes); + assert!(app.show_graphic_attributes); + assert!(app.ribbon.show_graphic_attributes); + assert!(app.current_config().show_graphic_attributes); + + let _ = app.update(Message::ToggleGraphicAttributes); + assert!(!app.show_graphic_attributes); + assert!(!app.ribbon.show_graphic_attributes); + assert!(!app.current_config().show_graphic_attributes); + } + + fn closed_square_on(app: &mut OpenCADStudio, layer: &str) -> Handle { + let i = app.active_tab; + if app.tabs[i].scene.document.layers.get(layer).is_none() { + let mut new_layer = codec::tables::Layer::new(layer); + new_layer.handle = app.tabs[i].scene.document.allocate_handle(); + let _ = app.tabs[i].scene.document.layers.add(new_layer); + } + let handle = open_square(app); + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + if let EntityType::LwPolyline(polyline) = entity { + polyline.is_closed = true; + } + entity.common_mut().layer = layer.to_string(); + } + handle + } + + fn fill_layers(app: &OpenCADStudio) -> Vec { + app.tabs[app.active_tab] + .scene + .document + .entities() + .filter_map(|entity| match entity { + EntityType::Hatch(hatch) => Some(hatch.common.layer.clone()), + _ => None, + }) + .collect() + } + + #[test] + fn new_fills_follow_the_object_layer_unless_the_menu_option_is_set() { + let mut app = fresh(); + closed_square_on(&mut app, "Walls"); + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + assert_eq!(fill_layers(&app), vec!["Walls".to_string()]); + + let mut app = fresh(); + let current = app.tabs[app.active_tab].active_layer.clone(); + closed_square_on(&mut app, "Walls"); + let _ = app.update(ga(GraphicAttributesMsg::ToggleFillOnCurrentLayer)); + assert!(app.current_config().graphic_fills_on_current_layer); + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + assert_eq!(fill_layers(&app), vec![current]); + } + + #[test] + fn objects_on_locked_layers_get_no_fill() { + let mut app = fresh(); + let i = app.active_tab; + closed_square_on(&mut app, "Locked"); + closed_square_on(&mut app, "Open"); + app.tabs[i] + .scene + .document + .layers + .get_mut("Locked") + .expect("locked layer") + .lock(); + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + assert_eq!(fill_layers(&app), vec!["Open".to_string()]); + } + + #[test] + fn palette_fill_lookup_follows_document_changes() { + let mut app = fresh(); + let i = app.active_tab; + let boundary = closed_square_on(&mut app, "0"); + let selected = vec![boundary]; + let (fills, kind) = app.graphic_attributes.fills(&app.tabs[i].scene, &selected); + assert!(fills.is_empty()); + assert_eq!(kind, GraphicAttribute::None); + + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + let (fills, kind) = app.graphic_attributes.fills(&app.tabs[i].scene, &selected); + assert_eq!(fills.len(), 1); + assert_eq!(kind, GraphicAttribute::Solid); + } + + #[test] + fn showing_the_palette_docks_it_below_properties_once() { + use crate::app::config::DockSide; + use crate::ui::dock::PanelId; + let mut app = OpenCADStudio::new_for_test(); + app.apply_config(crate::app::config::AppConfig::default()); + assert_eq!(app.dock.location(PanelId::GraphicAttributes), None); + + let _ = app.update(Message::ToggleGraphicAttributes); + let properties = app.dock.location(PanelId::Properties).expect("properties docked"); + assert_eq!( + app.dock.location(PanelId::GraphicAttributes), + Some(properties) + ); + + // A place the user chose is kept when the palette is shown again. + app.dock.dock(PanelId::GraphicAttributes, DockSide::Right, 0); + let _ = app.update(Message::ToggleGraphicAttributes); + let _ = app.update(Message::ToggleGraphicAttributes); + assert_eq!( + app.dock.location(PanelId::GraphicAttributes), + Some((DockSide::Right, 0)) + ); + } + + fn output_since(app: &OpenCADStudio, start: usize) -> String { + app.command_line.history[start..] + .iter() + .map(|entry| entry.text.as_str()) + .collect::>() + .join("\n") + } + + #[test] + fn undo_names_fill_and_linetype_changes() { + let mut app = fresh(); + let boundary = add_closed_square(&mut app, 0.0); + select(&mut app, &[boundary]); + + let _ = app.update(ga(GraphicAttributesMsg::Fill(GraphicAttribute::Solid))); + let start = app.command_line.history.len(); + let _ = app.update(Message::Undo); + assert!(output_since(&app, start).contains("Undo: Fill change")); + + let _ = app.update(ga(GraphicAttributesMsg::LineLinetype("Continuous".into()))); + let start = app.command_line.history.len(); + let _ = app.update(Message::Undo); + assert!(output_since(&app, start).contains("Undo: Linetype")); + } + + #[test] + fn typed_linetype_scale_is_applied_on_enter() { + let mut app = fresh(); + let i = app.active_tab; + let line = app.tabs[i].scene.add_entity(EntityType::Line(Line::new())); + select(&mut app, &[line]); + + // Enter without typing changes nothing. + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScaleSubmit)); + assert_eq!(common(&app, line).linetype_scale, 1.0); + + // Typing alone does not apply; Enter does, with a decimal comma too. + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScaleInput("0,35".into()))); + assert_eq!(common(&app, line).linetype_scale, 1.0); + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScaleSubmit)); + assert_eq!(common(&app, line).linetype_scale, 0.35); + assert!(app.graphic_attributes.linetype_scale_input.is_none()); + + // Invalid or non-positive input is dropped. + for typed in ["abc", "0", "-2"] { + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScaleInput(typed.into()))); + let _ = app.update(ga(GraphicAttributesMsg::LineLinetypeScaleSubmit)); + assert_eq!(common(&app, line).linetype_scale, 0.35); + } + } + + #[test] + fn clicking_the_linetype_scale_field_selects_it_once() { + let mut app = fresh(); + let field = iced::widget::Id::new(palette::LINETYPE_SCALE_FIELD); + let _ = app.update(Message::PropSyncActive(Some(field.clone()))); + assert!(app.graphic_attributes.linetype_scale_focused); + + // Focus elsewhere drops unsubmitted text. + app.graphic_attributes.linetype_scale_input = Some("3".into()); + let _ = app.update(Message::PropSyncActive(None)); + assert!(!app.graphic_attributes.linetype_scale_focused); + assert!(app.graphic_attributes.linetype_scale_input.is_none()); + } +} diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 645ef10ee..0ab8654a2 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -99,6 +99,7 @@ mod context_menu; mod dialog; mod dynamic; mod file; +mod graphic_attributes; pub(in crate::app) use file::background_task; #[cfg(not(target_arch = "wasm32"))] pub(in crate::app) use file::native_paths_match; @@ -473,6 +474,10 @@ impl OpenCADStudio { } } self.refresh_gpu_status(); + if self.graphic_attributes.tracks_selection() { + let selected = self.tabs[self.active_tab].scene.selected_handles_in_order(); + self.graphic_attributes.retain_selection(&selected); + } self.show_next_startup_modal(); self.sync_open_command_history(); // Close the document-level first-touch transaction started by @@ -486,6 +491,7 @@ impl OpenCADStudio { .active_cmd .as_ref() .map(|c| c.prompt()) + .or_else(|| self.graphic_attributes.fill_close_prompt()) .or_else(|| self.pending_pick_label()); self.command_line.set_step_prompt(prompt); // Mirror the step's clickable options so they render as buttons (#304). @@ -493,6 +499,14 @@ impl OpenCADStudio { .active_cmd .as_ref() .map(|c| c.options()) + .or_else(|| { + self.graphic_attributes.pending_fill_close.as_ref().map(|_| { + vec![ + crate::command::CmdOption::new("Yes", "Y"), + crate::command::CmdOption::new("No", "N"), + ] + }) + }) .unwrap_or_default(); self.command_line.set_step_options(opts); // Persist UI preferences whenever a toggle changes them (issue #68). @@ -2535,6 +2549,9 @@ impl OpenCADStudio { // finishes the step like Enter. (#304) self.command_line.input.clear(); self.command_line.close_history(); + if self.awaiting_fill_close_answer() { + return self.on_graphic_fill_close_input(&kw); + } if kw.is_empty() { return self.feed_command(crate::command::StepInput::Enter); } @@ -3877,6 +3894,7 @@ impl OpenCADStudio { } Message::ViewportLeftPress => { + self.graphic_attributes.open_menu = None; let sweep = self.sync_active_field_if_any(); Task::batch(vec![sweep, self.on_viewport_left_press()]) } @@ -3884,6 +3902,7 @@ impl OpenCADStudio { Message::ViewportLeftRelease => self.on_viewport_left_release(), Message::ViewportRightPress => { + self.graphic_attributes.open_menu = None; let i = self.active_tab; self.ribbon.close_dropdown(); // Shift+RMB: the one-shot snap override menu at the cursor — @@ -3986,7 +4005,10 @@ impl OpenCADStudio { self.unfocus_widgets() } - Message::ViewportMiddlePress => self.on_viewport_middle_press(), + Message::ViewportMiddlePress => { + self.graphic_attributes.open_menu = None; + self.on_viewport_middle_press() + } Message::ViewportMiddleRelease => { let i = self.active_tab; @@ -4318,6 +4340,14 @@ impl OpenCADStudio { self.ribbon.set_properties(self.show_properties); Task::none() } + Message::ToggleGraphicAttributes => { + let id = crate::ui::dock::PanelId::GraphicAttributes; + let open = !self.show_graphic_attributes; + if open { + self.dock.dock_below_properties(id); + } + self.dock_set_open(id, open) + } Message::ToggleFileTabs => { self.show_file_tabs ^= true; self.ribbon.set_file_tabs(self.show_file_tabs); @@ -6697,31 +6727,7 @@ impl OpenCADStudio { Task::none() } Message::RibbonLinetypeChanged(lt) => self.on_ribbon_linetype_changed(lt), - Message::RibbonLineweightChanged(lw) => { - let i = self.active_tab; - self.ribbon.close_dropdown(); - let handles = self.property_target_handles(i); - if handles.is_empty() { - if self.has_property_selection(i) { - return Task::none(); - } - // Persist into the tab's header (CELWEIGHT). #21. - self.tabs[i].scene.document.header.current_line_weight = lw.value(); - self.tabs[i].dirty = true; - self.ribbon.active_lineweight = lw; - } else { - // Lineweight is baked into the cached wire geometry — - // re-tessellate so the change shows immediately (issue #231 - // class). - self.apply_property_op(i, "CHPROP", &handles, |app, handle| { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::view::dispatch::apply_line_weight(entity, lw); - } - }); - self.ribbon.active_lineweight = lw; - } - Task::none() - } + Message::RibbonLineweightChanged(lw) => self.on_ribbon_lineweight_changed(lw), Message::RibbonStyleChanged { key, name } => self.on_ribbon_style_changed(key, name), @@ -7132,13 +7138,28 @@ impl OpenCADStudio { Message::PropAttrCommit(tag) => self.on_prop_attr_commit(tag), Message::PropPointerPressed => { - if !self.dock_panel_visible(crate::ui::dock::PanelId::Properties) { + if !self.dock_panel_visible(crate::ui::dock::PanelId::Properties) + && !self.dock_panel_visible(crate::ui::dock::PanelId::GraphicAttributes) + { return Task::none(); } crate::ui::properties::sync_active_field_task() } Message::PropSyncActive(focused) => { + // The Graphic Attributes linetype scale field behaves like a + // Properties field: the first click selects its value. + let scale_field = iced::widget::Id::new( + crate::ui::window::graphic_attributes::LINETYPE_SCALE_FIELD, + ); + if focused.as_ref() == Some(&scale_field) { + if !std::mem::replace(&mut self.graphic_attributes.linetype_scale_focused, true) { + return iced::widget::operation::select_all(scale_field); + } + return Task::none(); + } + self.graphic_attributes.linetype_scale_focused = false; + self.graphic_attributes.linetype_scale_input = None; let panel = &mut self.tabs[self.active_tab].properties; if let Some(id) = focused.as_ref() { if let Some(key) = panel.prop_field_key_for_id(id) { @@ -9853,6 +9874,7 @@ impl OpenCADStudio { Message::PlotDialogOpen => self.on_plot_dialog_open(), Message::PlotDlg(m) => self.on_plot_dlg(m), Message::BlockPalette(m) => self.on_block_palette(m), + Message::GraphicAttributes(m) => self.on_graphic_attributes(m), Message::Dock(m) => self.on_dock(m), Message::PrintAllOpen => self.on_print_all_open(), Message::PrintAllToggle(name) => { @@ -11015,6 +11037,13 @@ impl OpenCADStudio { Task::none() } Message::OpenColorWindow(target, color) => { + if matches!( + target, + crate::app::ColorPickTarget::GraphicAttributesLine + | crate::app::ColorPickTarget::GraphicAttributesSolid + ) { + self.graphic_attributes.open_menu = None; + } self.color_pick_target = Some((target, color)); // Always open the shared CAD colour picker on the indexed ACI page. diff --git a/src/app/update/style.rs b/src/app/update/style.rs index 8bf8fb7e1..2deac74ec 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -13,6 +13,7 @@ use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; +use crate::ui::window::graphic_attributes::GraphicAttributesMsg; use crate::ui::PropertiesPanel; use codec::types::Color as AcadColor; use codec::{EntityType as AcadEntityType, Handle}; @@ -1156,6 +1157,17 @@ impl OpenCADStudio { } let s = crate::ui::color_select::color_to_aci_string(color); let edit = match self.color_pick_target.take().map(|(target, _)| target) { + Some(crate::app::ColorPickTarget::Gradient(index)) => { + Some(Message::GraphicAttributes(GraphicAttributesMsg::Gradient( + crate::ui::window::gradient_editor::GradientMsg::Color(index, color), + ))) + } + Some(crate::app::ColorPickTarget::GraphicAttributesLine) => Some( + Message::GraphicAttributes(GraphicAttributesMsg::LineColor(color)), + ), + Some(crate::app::ColorPickTarget::GraphicAttributesSolid) => Some( + Message::GraphicAttributes(GraphicAttributesMsg::FillColor(color)), + ), Some(crate::app::ColorPickTarget::DimStyle(f)) => Some(Message::DsEdit(f, s)), Some(crate::app::ColorPickTarget::MLeader(f)) => { Some(Message::MLeaderStyleEdit { field: f, value: s }) diff --git a/src/command.rs b/src/command.rs index 788286362..fbe52867d 100644 --- a/src/command.rs +++ b/src/command.rs @@ -354,6 +354,46 @@ impl CadCommand for ValuePromptCommand { } } +/// One-value prompt that preserves spaces and letter casing. This is intended +/// for symbol names entered through the command line, where a space belongs to +/// the value instead of submitting the current token. +pub struct FreeTextValuePromptCommand { + name: &'static str, + prompt: &'static str, +} + +impl FreeTextValuePromptCommand { + pub fn new(name: &'static str, prompt: &'static str) -> Self { + Self { name, prompt } + } +} + +impl CadCommand for FreeTextValuePromptCommand { + fn name(&self) -> &'static str { + self.name + } + + fn prompt(&self) -> String { + crate::t!(self.prompt).into_owned() + } + + fn input_kind(&self) -> InputKind { + InputKind::FreeText + } + + fn on_text_input(&mut self, text: &str) -> Option { + Some(CmdResult::Dispatch(format!("{} {}", self.name, text.trim()))) + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Dispatch(format!("{} ", self.name)) + } +} + /// Interactive pick for `UCS FACE` and `UCS OBJECT`. /// /// Hands the picked entity to the inline `UCS FACE ` / diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index efbe56a04..ae206883d 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -5772,6 +5772,39 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "New group" => Some(("dock", "new-group")), "Browser" => Some(("dock", "browser")), "Node Graph" => Some(("dock", "node-graph")), + "Graphic Attributes" => Some(("graphic-attributes", "title")), + "Set all attributes ByLayer" => Some(("graphic-attributes", "set-all-bylayer")), + "Set all attributes ByBlock" => Some(("graphic-attributes", "set-all-byblock")), + "Remove ByLayer / ByBlock" => Some(("graphic-attributes", "remove-bylayer-byblock")), + "Create Layer with active settings" => Some(("graphic-attributes", "create-layer-active-settings")), + "Create fills on the current layer" => Some(("graphic-attributes", "fills-on-current-layer")), + "Edit hatch" => Some(("graphic-attributes", "edit-hatch")), + "Edit gradient" => Some(("graphic-attributes", "edit-gradient")), + "The selected object is open. Close it before creating the fill? [Yes / No] " => Some(("graphic-attributes", "close-open-object")), + "{count} selected objects are open. Close them before creating the fill? [Yes / No] " => Some(("graphic-attributes", "close-open-objects")), + "Enter a name for the new layer:" => Some(("graphic-attributes", "enter-new-layer-name")), + "Graphic Attributes: enter a valid layer name without <>/\\\":;?*|,=`." => Some(("graphic-attributes", "invalid-layer-name")), + "Graphic Attributes: layer \"{name}\" already exists." => Some(("graphic-attributes", "layer-exists")), + "Graphic Attributes: the layer could not be created." => Some(("graphic-attributes", "layer-not-created")), + "Created layer \"{name}\" with the active Graphic Attributes settings." => Some(("graphic-attributes", "layer-created")), + "Gradient Fill: the fill is on a locked layer." => Some(("graphic-attributes", "gradient-fill-locked")), + "Select one or more closed objects first." => Some(("graphic-attributes", "select-closed-objects")), + "Graphic Attributes: {} object(s) on a locked layer were not changed." => Some(("graphic-attributes", "locked-objects-skipped")), + "Graphic Attributes: no hatch pattern is available." => Some(("graphic-attributes", "no-hatch-pattern")), + "Graphic Attributes: an associated fill is on a locked layer." => Some(("graphic-attributes", "associated-fill-locked")), + "Graphic Attributes: 1 unsupported object was not changed." => Some(("graphic-attributes", "one-unsupported")), + "Graphic Attributes: {skipped} unsupported objects were not changed." => Some(("graphic-attributes", "unsupported-skipped")), + "Fill transparency" => Some(("graphic-attributes", "fill-transparency")), + "Fill change" => Some(("graphic-attributes", "fill-change")), + "Line transparency" => Some(("graphic-attributes", "line-transparency")), + "Cyl." => Some(("graphic-attributes", "cylindrical-short")), + "Spher." => Some(("graphic-attributes", "spherical-short")), + "Hemi." => Some(("graphic-attributes", "hemispherical-short")), + "Invert gradient" => Some(("graphic-attributes", "invert-gradient")), + "Tone" => Some(("graphic-attributes", "tone")), + "Reset angle" => Some(("graphic-attributes", "reset-angle")), + "One Color" => Some(("graphic-attributes", "one-color")), + "Two Colors" => Some(("graphic-attributes", "two-colors")), _ => None, } } diff --git a/src/modules/draw/draw/hatch.rs b/src/modules/draw/draw/hatch.rs index 99fa47e0a..587073aba 100644 --- a/src/modules/draw/draw/hatch.rs +++ b/src/modules/draw/draw/hatch.rs @@ -253,6 +253,13 @@ impl HatchCommand { self } + pub fn with_pattern(mut self, name: impl Into, pattern: HatchPattern) -> Self { + self.pattern_override = Some((name.into(), pattern)); + self + } + + pub fn finish_selected(mut self) -> CmdResult { self.on_enter() } + fn set_object_selection(&mut self, handles: Vec) { let mut segments = Vec::new(); for handle in &handles { diff --git a/src/modules/view/graphic_attributes_palette.rs b/src/modules/view/graphic_attributes_palette.rs new file mode 100644 index 000000000..03896aa11 --- /dev/null +++ b/src/modules/view/graphic_attributes_palette.rs @@ -0,0 +1,14 @@ +use crate::modules::{IconKind, ModuleEvent, ToolDef}; + +pub const ICON: IconKind = IconKind::Svg(include_bytes!( + "../../../assets/icons/graphic_attributes.svg" +)); + +pub fn tool() -> ToolDef { + ToolDef { + id: "GRAPHICATTRIBUTES", + label: "Graphic Attributes", + icon: ICON, + event: ModuleEvent::Command("GRAPHICATTRIBUTES".to_string()), + } +} diff --git a/src/modules/view/mod.rs b/src/modules/view/mod.rs index 39387f4fa..eb448e4dc 100644 --- a/src/modules/view/mod.rs +++ b/src/modules/view/mod.rs @@ -3,6 +3,7 @@ mod cascade; mod count_palette; mod file_tabs; +mod graphic_attributes_palette; mod layout_tabs; pub mod limits; mod orbit; @@ -123,6 +124,7 @@ impl CadModule for ViewModule { RibbonItem::LargeTool(tool_palettes::tool()), RibbonItem::LargeTool(properties_palette::tool()), RibbonItem::LargeTool(count_palette::tool()), + RibbonItem::LargeTool(graphic_attributes_palette::tool()), RibbonItem::LargeTool(sheetset::tool()), ], }, diff --git a/src/scene/entity.rs b/src/scene/entity.rs index c1419b4e6..19e6210bf 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -103,7 +103,7 @@ fn family_from_stored_line( /// Preserve the selected hue while moving its HSL lightness towards the /// persisted one-colour tint/shade target (0 = black, 1 = white). -fn gradient_tint_color(base: [f32; 4], target: f32) -> [f32; 4] { +pub(crate) fn gradient_tint_color(base: [f32; 4], target: f32) -> [f32; 4] { let max = base[0].max(base[1]).max(base[2]); let min = base[0].min(base[1]).min(base[2]); let lightness = (max + min) * 0.5; @@ -1394,10 +1394,12 @@ impl Scene { style.0 = crate::scene::view::render::adapt_to_bg(style.0, hatch_bg); m.aci = style.4; m.line_weight_px = style.3; - // A gradient's colour is its first stop (already baked into - // the cached model); only solid / pattern fills take the - // entity's resolved colour. - if !matches!(m.pattern, model::hatch_model::HatchPattern::Gradient { .. }) { + // Gradient RGB comes from its own colour stops, while + // transparency remains an entity property shared by both + // stops. Solid/pattern fills take the complete style. + if matches!(m.pattern, model::hatch_model::HatchPattern::Gradient { .. }) { + m.set_alpha(style.0[3]); + } else { m.color = style.0; } if let EntityType::Hatch(dxf) = e { @@ -2213,7 +2215,7 @@ impl Scene { .colors .get(i) .and_then(|e| e.color.rgb()) - .map(|(r, g, b)| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]) + .map(|(r, g, b)| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, color[3]]) }; gradient_color1 = stop(0); let color1 = gradient_color1.unwrap_or(color); diff --git a/src/scene/hatch_boundary/tests.rs b/src/scene/hatch_boundary/tests.rs index 8a03e4da3..f6f5c72c6 100644 --- a/src/scene/hatch_boundary/tests.rs +++ b/src/scene/hatch_boundary/tests.rs @@ -4,6 +4,43 @@ use codec::types::{Vector2, Vector3}; use kernel::geom2d::{refine_spline_boundary, triangulate_rings, Curve, NurbsCurve}; use kernel::tessellation::DEFAULT_ANGLE; +#[test] +fn gradient_stops_keep_entity_transparency() { + let mut hatch = Hatch::new(); + hatch.is_solid = true; + hatch + .paths + .push(rectangle([0.0, 0.0], [10.0, 10.0], [0.0; 2])); + hatch.gradient_color.enabled = true; + hatch.gradient_color.name = "LINEAR".into(); + hatch.gradient_color.colors = vec![ + codec::entities::hatch::GradientColorEntry { + value: 0.0, + color: codec::types::Color::Rgb { r: 255, g: 0, b: 0 }, + }, + codec::entities::hatch::GradientColorEntry { + value: 1.0, + color: codec::types::Color::Rgb { r: 0, g: 0, b: 255 }, + }, + ]; + + let mut model = Scene::hatch_model_from_dxf(&hatch, [0.2, 0.3, 0.4, 0.35]).unwrap(); + assert!((model.color[3] - 0.35).abs() < f32::EPSILON); + let crate::scene::model::hatch_model::HatchPattern::Gradient { color2, .. } = &model.pattern + else { + panic!("gradient hatch must produce a gradient render pattern"); + }; + assert!((color2[3] - 0.35).abs() < f32::EPSILON); + + model.set_alpha(0.6); + assert!((model.color[3] - 0.6).abs() < f32::EPSILON); + let crate::scene::model::hatch_model::HatchPattern::Gradient { color2, .. } = &model.pattern + else { + unreachable!(); + }; + assert!((color2[3] - 0.6).abs() < f32::EPSILON); +} + fn narrow(clearance: f64, offset: [f64; 2]) -> NurbsCurve { let line = |a: [f64; 2], b: [f64; 2]| { [ diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 46921453f..a895f6dd8 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -37,6 +37,7 @@ pub use page_setup::{apply_default_page_setup, document_page_setups, rotated_mar mod dwg_native_constraints; mod entity; pub use camera_ops::named_view_center; +pub(crate) use entity::gradient_tint_color; pub use entity::CreateBlockOptions; mod group_layer; #[cfg(test)] diff --git a/src/scene/model/hatch_model.rs b/src/scene/model/hatch_model.rs index c6655ed20..0d4863c71 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -272,6 +272,18 @@ pub struct HatchModel { pub draw_depth: f32, } +impl HatchModel { + /// Apply an entity's resolved opacity to every colour stop. Gradient stop + /// colours come from the HATCH payload, but transparency remains a common + /// entity property and must affect both ends equally. + pub(crate) fn set_alpha(&mut self, alpha: f32) { + self.color[3] = alpha; + if let HatchPattern::Gradient { color2, .. } = &mut self.pattern { + color2[3] = alpha; + } + } +} + impl HatchModel { pub(crate) fn gradient_frame( &self, diff --git a/src/scene/paper.rs b/src/scene/paper.rs index b713ad29f..bdf30a628 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -475,7 +475,11 @@ impl Scene { _ => model.clone(), }; let style = self.render_style(entity); - m.color = style.0; + if matches!(m.pattern, model::hatch_model::HatchPattern::Gradient { .. }) { + m.set_alpha(style.0[3]); + } else { + m.color = style.0; + } m.aci = style.4; m.line_weight_px = style.3; if let EntityType::Hatch(dxf) = entity { @@ -600,7 +604,11 @@ impl Scene { _ => model.clone(), }; let style = self.render_style(entity); - hatch.color = style.0; + if matches!(hatch.pattern, model::hatch_model::HatchPattern::Gradient { .. }) { + hatch.set_alpha(style.0[3]); + } else { + hatch.color = style.0; + } hatch.aci = style.4; hatch.line_weight_px = style.3; hatch.draw_depth = depth_map.get(&handle.value()).map_or(0.0, |depth| depth[0]); diff --git a/src/ui/color_select.rs b/src/ui/color_select.rs index c6efde206..3658a251e 100644 --- a/src/ui/color_select.rs +++ b/src/ui/color_select.rs @@ -230,18 +230,7 @@ fn color_selector_with_indicator<'a>( } let popup = container(color_list(extras, on_select, on_more)) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color(palette.background.weak.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() - } - }) + .style(popup_panel_style) .padding(2); // `DropDown` keeps the popup outside the surrounding form layout and @@ -255,7 +244,20 @@ fn color_selector_with_indicator<'a>( .into() } -fn list_row_style(theme: &Theme, status: button::Status) -> button::Style { +pub(crate) fn popup_panel_style(theme: &Theme) -> container::Style { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + } +} + +pub(crate) fn list_row_style(theme: &Theme, status: button::Status) -> button::Style { let palette = theme.palette(); let hovered = matches!(status, button::Status::Hovered); let text_color = if hovered { diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 771c4b908..f5fac7a7d 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -104,6 +104,7 @@ pub const DOCK_HOVER_DELAY: std::time::Duration = std::time::Duration::from_mill #[serde(rename_all = "snake_case")] pub enum PanelId { Properties, + GraphicAttributes, BlockPalette, ExternalReferences, /// Outline of the drawing's origin planes, open sketch and solid bodies. @@ -122,8 +123,9 @@ pub enum PanelId { impl PanelId { /// Every panel, in the order the pallet menu lists them. - pub const ALL: [PanelId; 9] = [ + pub const ALL: [PanelId; 10] = [ PanelId::Properties, + PanelId::GraphicAttributes, PanelId::BlockPalette, PanelId::ExternalReferences, PanelId::Browser, @@ -138,6 +140,7 @@ impl PanelId { pub fn title(self) -> &'static str { match self { PanelId::Properties => "Properties", + PanelId::GraphicAttributes => "Graphic Attributes", PanelId::BlockPalette => "Block Palette", PanelId::ExternalReferences => "External References", PanelId::Browser => "Browser", @@ -153,6 +156,7 @@ impl PanelId { pub fn icon(self) -> &'static [u8] { match self { PanelId::Properties => include_bytes!("../../assets/icons/properties.svg"), + PanelId::GraphicAttributes => include_bytes!("../../assets/icons/graphic_attributes.svg"), PanelId::BlockPalette => include_bytes!("../../assets/icons/blocks/insert.svg"), PanelId::ExternalReferences => crate::ui::icons::FOLDER_OPEN, PanelId::Browser => include_bytes!("../../assets/icons/content_browser.svg"), @@ -168,6 +172,7 @@ impl PanelId { fn default_width(self) -> f32 { match self { PanelId::Properties => 250.0, + PanelId::GraphicAttributes => 250.0, PanelId::BlockPalette => 260.0, PanelId::ExternalReferences => 460.0, PanelId::Browser => 230.0, @@ -518,6 +523,23 @@ impl DockState { self.floating.iter_mut().for_each(FloatPanel::heal); } + /// Stack `id` under Properties in its group (or dock it at the end of + /// the left edge) unless it already has a place, which is then kept. + pub fn dock_below_properties(&mut self, id: PanelId) { + if self.is_placed(id) { + return; + } + match self.location(PanelId::Properties) { + Some((side, group)) => { + let index = self.groups(side)[group].panels.len(); + self.join_group(id, side, group, index); + } + None => { + self.dock(id, DockSide::Left, usize::MAX); + } + } + } + /// Where (if anywhere) a panel is docked: its side and group index. pub fn location(&self, id: PanelId) -> Option<(DockSide, usize)> { for side in [DockSide::Left, DockSide::Right] { @@ -1054,8 +1076,25 @@ pub struct Chrome { /// and close. Pressing the row starts re-docking the panel. A floating panel /// gets nothing here: its frame draws a vertical title bar beside it. pub fn title_bar<'a>(id: PanelId, title: String, chrome: Chrome) -> Element<'a, Message> { + title_bar_with(id, title, chrome, None) +} + +/// [`title_bar`] with a pallet's own control (a menu button) before the pin +/// and close. A floating pallet has no title bar, so the control then sits +/// alone at the top right. +pub fn title_bar_with<'a>( + id: PanelId, + title: String, + chrome: Chrome, + extra: Option>, +) -> Element<'a, Message> { if chrome.floating { - return Space::new().width(0).height(0).into(); + return match extra { + Some(extra) => row![Space::new().width(Length::Fill), extra] + .padding([3, 6]) + .into(), + None => Space::new().width(0).height(0).into(), + }; } // The pin and close only show while the pointer is over the bar; the // fixed height keeps the bar from jumping when they appear. @@ -1063,6 +1102,9 @@ pub fn title_bar<'a>(id: PanelId, title: String, chrome: Chrome) -> Element<'a, .spacing(3) .height(Length::Fixed(TITLE_BUTTON_H)) .align_y(iced::Center); + if let Some(extra) = extra { + bar = bar.push(extra); + } if chrome.title_hovered { bar = bar .push(pin_button( @@ -1230,6 +1272,7 @@ mod tests { let mut state = DockState::default(); state.ensure_settings(); assert_eq!(state.width(PanelId::Properties, 1600.0), 250.0); + assert_eq!(state.width(PanelId::GraphicAttributes, 1600.0), 250.0); assert_eq!(state.width(PanelId::BlockPalette, 1600.0), 260.0); assert!(!state.auto_collapse(PanelId::Properties)); } diff --git a/src/ui/properties.rs b/src/ui/properties.rs index ff7235eea..20e0eb9a8 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -55,6 +55,9 @@ pub fn linetype_display_name(name: &str) -> String { impl fmt::Display for LinetypeItem { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.name.is_empty() && !self.art.is_empty() { + return f.write_str(&self.art); + } let name = linetype_display_name(&self.name); if self.art.is_empty() { write!(f, "{name}") @@ -95,6 +98,7 @@ impl fmt::Display for SelectionGroup { #[derive(Clone)] struct HatchPatternPreview { pattern: crate::scene::model::hatch_model::HatchPattern, + padding: f32, } impl canvas::Program for HatchPatternPreview { @@ -112,7 +116,7 @@ impl canvas::Program for HatchPatternPreview { let mut frame = canvas::Frame::new(renderer, bounds.size()); let palette = theme.palette(); - let pad = 4.0; + let pad = self.padding; let sample = canvas::Path::rectangle( Point::new(pad, pad), Size::new( @@ -201,6 +205,31 @@ fn hatch_preview_scale(pattern: &crate::scene::model::hatch_model::HatchPattern) } } +fn hatch_pattern_preview( + pattern: crate::scene::model::hatch_model::HatchPattern, + height: f32, +) -> Element<'static, Message> { + canvas(HatchPatternPreview { + pattern, + padding: 4.0, + }) + .width(Length::Fill) + .height(height) + .into() +} + +pub(crate) fn compact_hatch_pattern_preview( + pattern: crate::scene::model::hatch_model::HatchPattern, +) -> Element<'static, Message> { + canvas(HatchPatternPreview { + pattern, + padding: 0.5, + }) + .width(Length::Fill) + .height(ROW_H) + .into() +} + fn hatch_pattern_matches( entry: &crate::scene::model::hatch_patterns::PatternEntry, search: &str, @@ -223,6 +252,113 @@ pub(crate) fn filtered_hatch_patterns( .collect() } +/// Messages of a hatch pattern picker; the Properties panel and the Graphic +/// Attributes palette each route them to their own state. +pub(crate) struct PatternPickerMessages { + pub search_id: &'static str, + pub on_search: fn(String) -> Message, + pub on_confirm: Message, + pub on_focus: fn(usize) -> Message, + pub on_changed: fn(String) -> Message, +} + +pub(crate) fn hatch_pattern_picker_content<'a>( + search_value: &'a str, + focus: usize, + current: &'a str, + messages: PatternPickerMessages, +) -> Element<'a, Message> { + let PatternPickerMessages { + search_id, + on_search, + on_confirm, + on_focus, + on_changed, + } = messages; + let search = text_input(t!("Search patterns…").as_ref(), search_value) + .id(iced::widget::Id::new(search_id)) + .on_input(on_search) + .on_submit(on_confirm) + .size(FONT_SZ) + .padding([5, 7]) + .width(Length::Fill); + + let mut grid = column![].spacing(6); + let visible = filtered_hatch_patterns(search_value); + for (row_index, pair) in visible.chunks(2).enumerate() { + let mut cards = row![].spacing(6); + for (column_index, entry) in pair.iter().enumerate() { + let index = row_index * 2 + column_index; + let selected = current.eq_ignore_ascii_case(&entry.name); + let focused = focus == index; + let name = entry.name.clone(); + let preview = hatch_pattern_preview(entry.gpu.clone(), PATTERN_PREVIEW_H); + let card = button( + column![ + preview, + container(text(crate::ui::text_util::elide(&entry.name, 20)).size(FONT_SZ)) + .width(Length::Fill) + .align_x(iced::Center), + ] + .spacing(3), + ) + .on_press(on_changed(name)) + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed); + let pair = if selected { + palette.primary.weak + } else if hovered || focused { + palette.background.strong + } else { + palette.background.weak + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, + border: Border { + color: if selected || focused { + palette.primary.base.color + } else { + palette.background.neutral.color + }, + width: if selected || focused { 2.0 } else { 1.0 }, + radius: 4.0.into(), + }, + ..Default::default() + } + }) + .padding(5) + .width(PATTERN_CARD_W); + cards = cards.push(mouse_area(card).on_enter(on_focus(index))); + } + grid = grid.push(cards); + } + + let results: Element<'_, Message> = if visible.is_empty() { + container( + text(t!("No matching patterns")) + .size(FONT_SZ) + .style(hint_text_style), + ) + .padding(12) + .width(Length::Fill) + .center_x(Length::Fill) + .into() + } else { + scrollable(grid) + .height(Length::Fill) + .width(Length::Fill) + .into() + }; + container(column![search, results].spacing(7)) + .style(container::bordered_box) + .padding(8) + .width(PATTERN_PICKER_W) + .height(Length::Fixed(PATTERN_PICKER_H)) + .into() +} + /// All standard CAD lineweight options for the combobox. pub fn lw_options() -> Vec { [ @@ -1504,99 +1640,24 @@ impl PropertiesPanel { return prop_row_widget(label, head.into()); } - let search = text_input(t!("Search patterns…").as_ref(), &self.hatch_pattern_search) - .id(iced::widget::Id::new("hatch-pattern-search")) - .on_input(Message::PropHatchPatternSearchChanged) - .on_submit(Message::PropHatchPatternConfirm) - .size(FONT_SZ) - .padding([5, 7]) - .width(Length::Fill); - - let mut grid = column![].spacing(6); - let visible = filtered_hatch_patterns(&self.hatch_pattern_search); - for (row_index, pair) in visible.chunks(2).enumerate() { - let mut cards = row![].spacing(6); - for (column_index, entry) in pair.iter().enumerate() { - let index = row_index * 2 + column_index; - let selected = current.eq_ignore_ascii_case(&entry.name); - let focused = self.hatch_pattern_focus == index; - let name = entry.name.clone(); - let preview = canvas(HatchPatternPreview { - pattern: entry.gpu.clone(), - }) - .width(Length::Fill) - .height(PATTERN_PREVIEW_H); - let card = button( - column![ - preview, - container(text(crate::ui::text_util::elide(&entry.name, 20)).size(FONT_SZ)) - .width(Length::Fill) - .align_x(iced::Center), - ] - .spacing(3), - ) - .on_press(Message::PropHatchPatternChanged(name)) - .style(move |theme: &Theme, status| { - let palette = theme.palette(); - let hovered = - matches!(status, button::Status::Hovered | button::Status::Pressed); - let pair = if selected { - palette.primary.weak - } else if hovered || focused { - palette.background.strong - } else { - palette.background.weak - }; - button::Style { - background: Some(Background::Color(pair.color)), - text_color: pair.text, - border: Border { - color: if selected || focused { - palette.primary.base.color - } else { - palette.background.neutral.color - }, - width: if selected || focused { 2.0 } else { 1.0 }, - radius: 4.0.into(), - }, - ..Default::default() - } - }) - .padding(5) - .width(PATTERN_CARD_W); - cards = - cards.push(mouse_area(card).on_enter(Message::PropHatchPatternFocus(index))); - } - grid = grid.push(cards); - } - - let results: Element<'_, Message> = if visible.is_empty() { - container( - text(t!("No matching patterns")) - .size(FONT_SZ) - .style(hint_text_style), - ) - .padding(12) - .width(Length::Fill) - .center_x(Length::Fill) - .into() - } else { - scrollable(grid) - .height(Length::Fill) - .width(Length::Fill) - .into() - }; - let popup = container(column![search, results].spacing(7)) - .style(container::bordered_box) - .padding(8) - .width(PATTERN_PICKER_W) - .height(Length::Fixed(PATTERN_PICKER_H)); + let popup = hatch_pattern_picker_content( + &self.hatch_pattern_search, + self.hatch_pattern_focus, + current, + PatternPickerMessages { + search_id: "hatch-pattern-search", + on_search: Message::PropHatchPatternSearchChanged, + on_confirm: Message::PropHatchPatternConfirm, + on_focus: Message::PropHatchPatternFocus, + on_changed: Message::PropHatchPatternChanged, + }, + ); prop_row_widget( label, crate::ui::color_select::drop_down_below( head.into(), - popup.into(), + popup, Some(Length::Fixed(PATTERN_PICKER_W)), Length::Fixed(PATTERN_PICKER_H), Message::PropHatchPatternPickerToggle(current.to_string()), @@ -2158,7 +2219,7 @@ fn text_input_style(theme: &Theme, status: text_input::Status) -> text_input::St } } -fn combo_input_style(theme: &Theme, status: text_input::Status) -> text_input::Style { +pub(crate) fn combo_input_style(theme: &Theme, status: text_input::Status) -> text_input::Style { text_input_style(theme, status) } diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 3552e17f1..149f4e9a1 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -62,6 +62,8 @@ pub struct Ribbon { pub show_count_palette: bool, /// Sheet Set Manager palette shown (SSMSTATE). pub show_sheet_set: bool, + /// Graphic Attributes panel visibility — drives its ribbon button highlight. + pub show_graphic_attributes: bool, /// File tabs (FILETAB) visibility — drives the File Tabs button highlight. pub show_file_tabs: bool, /// Layout tabs (LAYOUTTAB) visibility — drives the Layout Tabs button highlight. @@ -181,6 +183,7 @@ impl Ribbon { show_properties: true, show_count_palette: false, show_sheet_set: false, + show_graphic_attributes: false, show_file_tabs: true, show_layout_tabs: true, open_dropdown: None, @@ -346,6 +349,9 @@ impl Ribbon { pub fn set_sheet_set(&mut self, on: bool) { self.show_sheet_set = on; } + pub fn set_graphic_attributes(&mut self, on: bool) { + self.show_graphic_attributes = on; + } pub fn set_file_tabs(&mut self, on: bool) { self.show_file_tabs = on; } @@ -364,6 +370,7 @@ impl Ribbon { show_ucs_icon: self.show_ucs_icon, show_properties: self.show_properties, show_count_palette: self.show_count_palette, + show_graphic_attributes: self.show_graphic_attributes, show_block_palette, show_sheet_set: self.show_sheet_set, show_file_tabs: self.show_file_tabs, diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index 8133405f4..989ea879b 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -38,6 +38,7 @@ pub(super) struct ToggleState { pub show_ucs_icon: bool, pub show_properties: bool, pub show_count_palette: bool, + pub show_graphic_attributes: bool, pub show_block_palette: bool, pub show_sheet_set: bool, pub show_file_tabs: bool, @@ -370,6 +371,7 @@ pub(super) fn is_active_tool(id: &str, active_tool: &Option, state: &Tog "UCSICON" => state.show_ucs_icon, "PROPERTIES" => state.show_properties, "COUNTLIST" => state.show_count_palette, + "GRAPHICATTRIBUTES" => state.show_graphic_attributes, "BLOCKPALETTE" => state.show_block_palette, "SHEETSET" => state.show_sheet_set, "FILETAB" => state.show_file_tabs, diff --git a/src/ui/window/gradient_editor.rs b/src/ui/window/gradient_editor.rs new file mode 100644 index 000000000..8c5622785 --- /dev/null +++ b/src/ui/window/gradient_editor.rs @@ -0,0 +1,498 @@ +//! Gradient flyout of the Graphic Attributes palette. + +use crate::app::Message; +use crate::scene::model::hatch_model::GradientKind; +use crate::ui::window::graphic_attributes::GraphicAttributesMsg; +use codec::types::Color as AcadColor; +use iced::widget::{ + button, checkbox, column, container, image, mouse_area, row, slider, text, text_input, tooltip, + Space, +}; +use iced::{Background, Border, Color, Element, Fill, Length, Theme}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GradientColorMode { + One, + Two, +} + +#[derive(Debug, Clone)] +pub struct GradientEditorState { + pub handles: Vec, + pub color_mode: GradientColorMode, + pub color_1: AcadColor, + pub color_2: AcadColor, + pub shade_tint: f32, + pub kind: GradientKind, + pub inverted: bool, + pub angle: String, + pub centered: bool, + /// Large preview, rebuilt only when a setting it depends on changes. + preview: std::cell::RefCell)>>, +} + +type PreviewKey = (GradientColorMode, AcadColor, AcadColor, u32, GradientKind, bool, String, bool); + +#[derive(Debug, Clone)] +pub enum GradientMsg { + Open, + Cancel, + Apply, + ColorMode(GradientColorMode), + /// Stop colour 1 or 2. + Color(u8, AcadColor), + ShadeTint(f32), + Kind(GradientKind), + InvertToggled, + Angle(String), + AngleReset, + Centered(bool), +} + +fn msg(message: GradientMsg) -> Message { + Message::GraphicAttributes(GraphicAttributesMsg::Gradient(message)) +} + +impl GradientEditorState { + pub fn from_hatch( + handles: Vec, + hatch: &codec::entities::Hatch, + ) -> Self { + let color = |index: usize| { + hatch + .gradient_color + .colors + .get(index) + .map(|entry| entry.color) + .unwrap_or(AcadColor::Index(7)) + }; + let (kind, inverted) = GradientKind::from_name(&hatch.gradient_color.name); + Self { + handles, + color_mode: if hatch.gradient_color.is_single_color { + GradientColorMode::One + } else { + GradientColorMode::Two + }, + color_1: color(0), + color_2: color(1), + shade_tint: hatch.gradient_color.color_tint as f32, + kind, + inverted, + angle: format!("{:.1}°", hatch.gradient_color.angle.to_degrees()), + centered: hatch.gradient_color.shift.abs() < 1e-9, + preview: Default::default(), + } + } + + pub fn update(&mut self, message: GradientMsg) { + match message { + GradientMsg::ColorMode(mode) => self.color_mode = mode, + GradientMsg::Color(1, color) => self.color_1 = color, + GradientMsg::Color(_, color) => self.color_2 = color, + GradientMsg::ShadeTint(value) => self.shade_tint = value.clamp(0.0, 1.0), + GradientMsg::Kind(kind) => { + self.kind = kind; + if kind == GradientKind::Linear { + self.inverted = false; + } + } + GradientMsg::InvertToggled => { + if self.kind != GradientKind::Linear { + self.inverted ^= true; + } + } + GradientMsg::Angle(value) => self.angle = value, + GradientMsg::AngleReset => self.angle = "0.0°".into(), + GradientMsg::Centered(value) => self.centered = value, + GradientMsg::Open | GradientMsg::Cancel | GradientMsg::Apply => {} + } + } + + pub fn angle_radians(&self) -> f64 { + self.angle + .trim() + .trim_end_matches('°') + .replace(',', ".") + .parse::() + .unwrap_or(0.0) + .rem_euclid(360.0) + .to_radians() + } +} + +fn rgb(color: &AcadColor) -> Color { + let (r, g, b) = match color { + AcadColor::Rgb { r, g, b } => (*r, *g, *b), + AcadColor::Index(i) => { + codec::types::aci_table::aci_to_rgb(*i).unwrap_or((128, 128, 128)) + } + _ => (255, 255, 255), + }; + Color::from_rgb8(r, g, b) +} + +fn rgb_array(color: &AcadColor) -> [f32; 4] { + let c = rgb(color); + [c.r, c.g, c.b, 1.0] +} + +fn mix_rgba(a: Color, b: Color, t: f32) -> [u8; 4] { + let channel = |a: f32, b: f32| ((a + (b - a) * t) * 255.0).round() as u8; + [channel(a.r, b.r), channel(a.g, b.g), channel(a.b, b.b), 255] +} + +fn preview_sized( + state: &GradientEditorState, + width: u32, + height: u32, +) -> Option { + let c1 = rgb(&state.color_1); + let c2 = if state.color_mode == GradientColorMode::One { + let c = crate::scene::gradient_tint_color(rgb_array(&state.color_1), state.shade_tint); + Color::from_rgb(c[0], c[1], c[2]) + } else { + rgb(&state.color_2) + }; + let angle = state.angle_radians(); + let frame = kernel::geom2d::gradient_frame( + &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], + angle, + if state.centered { 0.0 } else { 1.0 }, + kernel::geom2d::Tolerance::default(), + )?; + let (sin, cos) = angle.sin_cos(); + let radial = state.kind.radial(); + let mut pixels = Vec::with_capacity((width * height * 4) as usize); + for y in 0..height { + let py = y as f64 / (height - 1) as f64; + for x in 0..width { + let px = x as f64 / (width - 1) as f64; + let mut t = if radial { + ((px - frame.center[0]).hypot(py - frame.center[1]) / frame.radius).clamp(0.0, 1.0) + as f32 + } else { + (((px * cos + py * sin) - frame.projection_min) / frame.projection_span) + .clamp(0.0, 1.0) as f32 + }; + if state.kind == GradientKind::Cylinder { + t = 1.0 - (2.0 * t - 1.0).abs(); + } else if state.kind == GradientKind::Curved { + t *= t; + } else if state.kind == GradientKind::Hemispherical { + t = t.sqrt(); + } + if state.inverted { + t = 1.0 - t; + } + pixels.extend_from_slice(&if radial { + mix_rgba(c2, c1, t) + } else { + mix_rgba(c1, c2, t) + }); + } + } + Some(iced::widget::image::Handle::from_rgba(width, height, pixels)) +} + +fn preview(state: &GradientEditorState) -> Element<'static, Message> { + let key: PreviewKey = ( + state.color_mode, + state.color_1, + state.color_2, + state.shade_tint.to_bits(), + state.kind, + state.inverted, + state.angle.clone(), + state.centered, + ); + let mut cache = state.preview.borrow_mut(); + if cache.as_ref().is_none_or(|(cached, _)| *cached != key) { + *cache = Some((key, preview_sized(state, 370, 80))); + } + preview_image(cache.as_ref().and_then(|(_, handle)| handle.clone())) +} + +pub(crate) fn compact_preview( + hatch: &codec::entities::Hatch, +) -> Option { + preview_sized(&GradientEditorState::from_hatch(Vec::new(), hatch), 256, 26) +} + +pub(crate) fn preview_image(handle: Option) -> Element<'static, Message> { + match handle { + Some(handle) => image(handle) + .width(Fill) + .height(Fill) + .content_fit(iced::ContentFit::Fill) + .into(), + None => Space::new().width(Fill).height(Fill).into(), + } +} + +fn icon(kind: GradientKind, inverted: bool) -> &'static [u8] { + match (kind, inverted) { + (GradientKind::Linear, _) => { + include_bytes!("../../../assets/icons/gradient/gradient_linear.svg") + } + (GradientKind::Cylinder, false) => { + include_bytes!("../../../assets/icons/gradient/gradient_cylindrical.svg") + } + (GradientKind::Cylinder, true) => { + include_bytes!("../../../assets/icons/gradient/gradient_cylindrical_inverted.svg") + } + (GradientKind::Spherical, false) => { + include_bytes!("../../../assets/icons/gradient/gradient_spherical.svg") + } + (GradientKind::Spherical, true) => { + include_bytes!("../../../assets/icons/gradient/gradient_spherical_inverted.svg") + } + (GradientKind::Hemispherical, false) => { + include_bytes!("../../../assets/icons/gradient/gradient_hemispherical.svg") + } + (GradientKind::Hemispherical, true) => { + include_bytes!("../../../assets/icons/gradient/gradient_hemispherical_inverted.svg") + } + (GradientKind::Curved, false) => { + include_bytes!("../../../assets/icons/gradient/gradient_curved.svg") + } + (GradientKind::Curved, true) => { + include_bytes!("../../../assets/icons/gradient/gradient_curved_inverted.svg") + } + } +} + +pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> { + let segment = |label, mode| { + let selected = state.color_mode == mode; + button(text(label).size(11)) + .on_press(msg(GradientMsg::ColorMode(mode))) + .width(Fill) + .style(move |theme: &Theme, status| { + if selected { + button::primary(theme, status) + } else { + button::secondary(theme, status) + } + }) + }; + let color_field = |label: std::borrow::Cow<'static, str>, index, color: AcadColor| { + let (background, _) = crate::ui::properties::acad_color_display(color); + let value = crate::ui::color_select::color_display_name(color); + let picker = button( + row![ + crate::ui::color_select::swatch(background), + text(value).size(10), + Space::new().width(Fill), + ] + .spacing(5) + .align_y(iced::Center), + ) + .on_press(Message::OpenColorWindow( + crate::app::ColorPickTarget::Gradient(index), + color, + )) + .style(|theme: &Theme, status| { + let palette = theme.palette(); + button::Style { + background: Some(Background::Color( + if matches!(status, button::Status::Hovered) { + palette.background.weak.color + } else { + palette.background.base.color + }, + )), + text_color: palette.background.base.text, + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + } + }) + .height(crate::ui::ROW_H) + .padding([3, 6]) + .width(Fill); + row![text(label).size(11).width(43), picker] + .spacing(4) + .align_y(iced::Center) + .width(Fill) + }; + let kinds = [ + (GradientKind::Linear, crate::t!("Linear")), + (GradientKind::Cylinder, crate::t!("Cyl.")), + (GradientKind::Spherical, crate::t!("Spher.")), + (GradientKind::Hemispherical, crate::t!("Hemi.")), + (GradientKind::Curved, crate::t!("Curved")), + ]; + let mut type_row = row![].spacing(3); + for (kind, label) in kinds { + let selected = state.kind == kind; + let b = button( + column![ + crate::ui::icons::semantic( + icon(kind, state.inverted && kind != GradientKind::Linear), + 25.0 + ), + text(label).size(9) + ] + .align_x(iced::Center), + ) + .on_press(msg(GradientMsg::Kind(kind))) + .width(48) + .height(48) + .style(move |theme: &Theme, status| { + let mut s = button::subtle(theme, status); + if selected { + s.border.color = theme.palette().primary.base.color; + s.border.width = 1.0; + } + s + }); + type_row = type_row.push(tooltip( + b, + text(kind.choice_label(false)).size(10), + tooltip::Position::Bottom, + )); + } + let invert_selected = state.inverted && state.kind != GradientKind::Linear; + let invert = button(column![text("⇄").size(18), text(crate::t!("Invert")).size(9)].align_x(iced::Center)) + .on_press_maybe( + (state.kind != GradientKind::Linear).then(|| msg(GradientMsg::InvertToggled)), + ) + .width(42) + .height(48) + .style(move |theme: &Theme, status| { + let mut style = button::secondary(theme, status); + if invert_selected { + style.border.color = theme.palette().primary.base.color; + style.border.width = 1.0; + } + style + }); + let separator = + container(Space::new().width(1).height(36)).style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }); + type_row = type_row.push(separator).push(tooltip( + invert, + text(crate::t!("Invert gradient")).size(10), + tooltip::Position::Bottom, + )); + let colors: Element<'_, Message> = if state.color_mode == GradientColorMode::Two { + row![ + color_field(crate::t!("Color 1"), 1, state.color_1), + color_field(crate::t!("Color 2"), 2, state.color_2) + ] + .spacing(6) + .into() + } else { + row![ + color_field(crate::t!("Color 1"), 1, state.color_1), + row![ + text(crate::t!("Tone")).size(11).width(43), + slider(0..=100, (state.shade_tint * 100.0) as i32, |v| { + msg(GradientMsg::ShadeTint(v as f32 / 100.0)) + }) + .width(Fill) + ] + .spacing(4) + .align_y(iced::Center) + .height(crate::ui::ROW_H) + .width(Fill) + ] + .spacing(6) + .align_y(iced::Center) + .into() + }; + let angle = row![ + text(crate::t!("Angle")).size(11).width(43), + text_input("0.0°", &state.angle) + .on_input(|value| msg(GradientMsg::Angle(value))) + .size(11) + .width(Length::Fixed(92.0)), + tooltip( + button(crate::ui::icons::themed_undo(11.0, true)) + .on_press(msg(GradientMsg::AngleReset)) + .style(button::subtle) + .padding(3) + .width(24) + .height(crate::ui::ROW_H), + text(crate::t!("Reset angle")).size(10), + tooltip::Position::Bottom, + ), + Space::new().width(Fill), + checkbox(state.centered) + .label(crate::t!("Centered")) + .on_toggle(|value| msg(GradientMsg::Centered(value))) + .size(14) + .text_size(11) + ] + .spacing(6) + .align_y(iced::Center); + let types = row![text(crate::t!("Type")).size(11).width(43), type_row] + .spacing(4) + .align_y(iced::Center); + let actions = container( + row![ + button(text(crate::t!("Cancel")).size(11)) + .on_press(msg(GradientMsg::Cancel)) + .style(button::secondary) + .padding([5, 14]), + button(text(crate::t!("OK")).size(11)) + .on_press(msg(GradientMsg::Apply)) + .style(button::primary) + .padding([5, 18]), + ] + .spacing(8), + ) + .width(Fill) + .align_x(iced::alignment::Horizontal::Right); + let content = column![ + container(preview(state)).width(Fill).height(80), + row![ + segment(crate::t!("One Color"), GradientColorMode::One), + segment(crate::t!("Two Colors"), GradientColorMode::Two) + ] + .spacing(2), + colors, + types, + angle, + actions + ] + .spacing(8) + .padding(10); + let panel: Element<'_, Message> = container(content) + .width(Length::Fixed(390.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: theme.palette().primary.base.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }) + .into(); + let panel: Element<'_, Message> = if dimmed { + let shield = container(Space::new()) + .width(Fill) + .height(Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.strongest.color.scale_alpha(0.55), + )), + ..Default::default() + }); + iced::widget::stack![panel, iced::widget::opaque(shield)] + .width(Length::Fixed(390.0)) + .into() + } else { + panel + }; + mouse_area(panel) + .interaction(iced::mouse::Interaction::Pointer) + .into() +} diff --git a/src/ui/window/graphic_attributes.rs b/src/ui/window/graphic_attributes.rs new file mode 100644 index 000000000..7603b3c4e --- /dev/null +++ b/src/ui/window/graphic_attributes.rs @@ -0,0 +1,1379 @@ +//! Graphic Attributes palette: line attributes and the associative fill of +//! the selected objects. + +use crate::app::config::DockSide; +use crate::app::{ColorPickTarget, Message}; +use crate::scene::Scene; +use crate::ui::dock::PanelId; +use crate::ui::window::gradient_editor::{GradientEditorState, GradientMsg}; +use codec::entities::{EntityCommon, Hatch, HatchGradientPattern}; +use codec::types::{Color as AcadColor, LineWeight, Transparency}; +use codec::{CadDocument, EntityType, Handle}; +use iced::widget::{ + button, image, column, combo_box, container, row, slider, text, text_input, tooltip, + Space, +}; +use iced::{Background, Border, Element, Fill, Length, Padding, Theme}; +use rustc_hash::{FxHashMap, FxHashSet}; +use std::cell::RefCell; + +type DrawDepth = FxHashMap; + +const PALETTE_CONTROL_ICON_SIZE: f32 = 20.0; +const COMPACT_BUTTON_HEIGHT: f32 = 22.0; +const TRANSPARENCY_ICON: &[u8] = + include_bytes!("../../../assets/icons/attributes/transparency.svg"); +const LINEWEIGHT_ICON: &[u8] = include_bytes!("../../../assets/icons/attributes/lineweight.svg"); + +/// A usable object linetype scale (CELTSCALE): any positive finite value, +/// rounded to six decimals so arithmetic noise does not reach the drawing. +/// Typed values such as the ISO pen widths 0.13, 0.18, 0.25 and 0.35 are +/// kept as entered. +pub(crate) fn normalized_linetype_scale(value: f64) -> Option { + let value = (value * 1.0e6).round() / 1.0e6; + (value.is_finite() && value > 0.0).then_some(value) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GraphicAttribute { + Varies, + None, + Solid, + Hatch, + Gradient, +} + +impl GraphicAttribute { + pub const ALL: [Self; 4] = [Self::None, Self::Solid, Self::Hatch, Self::Gradient]; + + fn of(hatch: &Hatch) -> Self { + if hatch.gradient_color.enabled { + Self::Gradient + } else if hatch.is_solid { + Self::Solid + } else { + Self::Hatch + } + } + + fn icon(self) -> &'static [u8] { + match self { + Self::Varies | Self::None => { + include_bytes!("../../../assets/icons/hatch/hatch_none.svg") + } + Self::Solid => include_bytes!("../../../assets/icons/hatch/hatch_solid.svg"), + Self::Hatch => include_bytes!("../../../assets/icons/hatch/hatch_lines.svg"), + Self::Gradient => include_bytes!("../../../assets/icons/hatch/hatch_gradient.svg"), + } + } +} + +impl std::fmt::Display for GraphicAttribute { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&match self { + Self::Varies => "*VARIES*".into(), + Self::None => crate::t!("None"), + Self::Solid => crate::t!("Solid"), + Self::Hatch => crate::t!("Hatch"), + Self::Gradient => crate::t!("Gradient"), + }) + } +} + +/// The palette's drop-down menus; at most one is open at a time. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Menu { + Header, + Fill, + LineColor, + LineLinetype, + LineLineweight, + LineTransparency, + FillColor, + FillTransparency, +} + +#[derive(Debug, Clone)] +pub enum GraphicAttributesMsg { + ToggleMenu(Menu), + CloseMenu, + SetAllByLayer, + SetAllByBlock, + RemoveReferences, + CreateLayer, + /// Put new fills on the current layer instead of their object's layer. + ToggleFillOnCurrentLayer, + Fill(GraphicAttribute), + LineColor(AcadColor), + LineLinetype(String), + LineLineweight(LineWeight), + LineLinetypeScale(f64), + /// Text typed in the linetype scale field, applied on Enter. + LineLinetypeScaleInput(String), + LineLinetypeScaleSubmit, + LineTransparency(Transparency), + FillColor(AcadColor), + FillTransparency(Transparency), + HatchEditorOpen, + HatchEditorClose, + HatchPatternSearch(String), + HatchPatternFocus(usize), + HatchPattern(String), + HatchPatternConfirm, + Gradient(GradientMsg), +} + +fn msg(message: GraphicAttributesMsg) -> Message { + Message::GraphicAttributes(message) +} + +/// The hatch pattern flyout, opened for one selection. +#[derive(Debug)] +pub struct HatchEditorState { + pub handles: Vec, + pub search: String, + pub focus: usize, +} + +/// A fill request waiting for the user to decide whether its open +/// boundaries are closed first. +#[derive(Debug)] +pub struct PendingFillClose { + pub fill: GraphicAttribute, + pub open_boundaries: Vec, + pub selection: Vec, +} + +#[derive(Debug, Default)] +pub struct GraphicAttributesState { + pub open_menu: Option, + pub hatch_editor: Option, + pub gradient_editor: Option, + pub pending_fill_close: Option, + /// New fills go to the current layer instead of their object's layer. + pub fill_on_current_layer: bool, + /// Text typed in the linetype scale field, until Enter applies it. + pub linetype_scale_input: Option, + /// Whether the linetype scale field has focus (its value was selected). + pub linetype_scale_focused: bool, + /// View-side caches; `view` runs after every message, so neither the + /// fill lookup nor the gradient preview is rebuilt unless its input changed. + fill_index: RefCell>, + gradient_preview: RefCell)>>, +} + +impl GraphicAttributesState { + pub fn close_popups(&mut self) { + self.open_menu = None; + self.hatch_editor = None; + self.gradient_editor = None; + } + + /// The fills and fill kind of `selected`, from an index of associative + /// fills that is rebuilt only when the scene's geometry changes. + pub(crate) fn fills(&self, scene: &Scene, selected: &[Handle]) -> (Vec, GraphicAttribute) { + // Geometry epochs are unique across all scenes, so the epoch alone + // identifies both the tab and the state of its drawing. + let key = scene.geometry_epoch; + let mut cache = self.fill_index.borrow_mut(); + if cache.as_ref().is_none_or(|(cached, _)| *cached != key) { + *cache = Some((key, FillIndex::build(&scene.document))); + } + let index = &cache.as_ref().expect("fill index was just built").1; + ( + index.fill_handles(&scene.document, selected), + index.current(&scene.document, selected), + ) + } + + fn gradient_preview(&self, hatch: &Hatch) -> Element<'static, Message> { + let mut cache = self.gradient_preview.borrow_mut(); + if cache + .as_ref() + .is_none_or(|(gradient, _)| *gradient != hatch.gradient_color) + { + *cache = Some(( + hatch.gradient_color.clone(), + crate::ui::window::gradient_editor::compact_preview(hatch), + )); + } + let handle = cache.as_ref().and_then(|(_, handle)| handle.clone()); + crate::ui::window::gradient_editor::preview_image(handle) + } + + /// Whether a flyout or question is bound to the selection it was opened for. + pub fn tracks_selection(&self) -> bool { + self.hatch_editor.is_some() + || self.gradient_editor.is_some() + || self.pending_fill_close.is_some() + } + + /// Drop flyouts and questions that were opened for another selection. + pub fn retain_selection(&mut self, selected: &[Handle]) { + let same = |handles: &[Handle]| { + handles.len() == selected.len() && selected.iter().all(|h| handles.contains(h)) + }; + if self.gradient_editor.as_ref().is_some_and(|e| !same(&e.handles)) { + self.gradient_editor = None; + } + if self.hatch_editor.as_ref().is_some_and(|e| !same(&e.handles)) { + self.hatch_editor = None; + } + if self.pending_fill_close.as_ref().is_some_and(|p| !same(&p.selection)) { + self.pending_fill_close = None; + } + } + + pub fn fill_close_prompt(&self) -> Option { + let count = self.pending_fill_close.as_ref()?.open_boundaries.len(); + Some(if count == 1 { + crate::t!("The selected object is open. Close it before creating the fill? [Yes / No] ") + .into_owned() + } else { + crate::tf!( + "{count} selected objects are open. Close them before creating the fill? [Yes / No] " + ) + .into_owned() + }) + } +} + +/// Associative HATCH entities per boundary object, in document order. +#[derive(Debug, Default)] +pub(crate) struct FillIndex { + by_boundary: FxHashMap>, +} + +impl FillIndex { + pub(crate) fn build(document: &CadDocument) -> Self { + let mut by_boundary: FxHashMap> = FxHashMap::default(); + for entity in document.entities() { + let EntityType::Hatch(hatch) = entity else { + continue; + }; + if !hatch.is_associative { + continue; + } + for boundary in hatch.paths.iter().flat_map(|path| &path.boundary_handles) { + let fills = by_boundary.entry(*boundary).or_default(); + if !fills.contains(&hatch.common.handle) { + fills.push(hatch.common.handle); + } + } + } + Self { by_boundary } + } + + /// The fill kind of each selected object: its own kind for a selected + /// HATCH, otherwise the kind of the first associative HATCH it bounds. + pub(crate) fn current(&self, document: &CadDocument, selected: &[Handle]) -> GraphicAttribute { + let fill_of = |handle: &Handle| { + let own = match document.get_entity(*handle) { + Some(EntityType::Hatch(hatch)) => return GraphicAttribute::of(hatch), + _ => None, + }; + own.or_else(|| { + self.by_boundary.get(handle)?.iter().find_map(|fill| { + match document.get_entity(*fill)? { + EntityType::Hatch(hatch) => Some(GraphicAttribute::of(hatch)), + _ => None, + } + }) + }) + .unwrap_or(GraphicAttribute::None) + }; + let Some((first, rest)) = selected.split_first() else { + return GraphicAttribute::None; + }; + let first = fill_of(first); + if rest.iter().any(|handle| fill_of(handle) != first) { + GraphicAttribute::Varies + } else { + first + } + } + + /// HATCH entities that are selected or associated with a selected boundary. + pub(crate) fn fill_handles(&self, document: &CadDocument, selected: &[Handle]) -> Vec { + let mut seen = FxHashSet::default(); + let mut fills = Vec::new(); + for handle in selected { + if matches!(document.get_entity(*handle), Some(EntityType::Hatch(_))) + && seen.insert(*handle) + { + fills.push(*handle); + } + for fill in self.by_boundary.get(handle).into_iter().flatten() { + if document.get_entity(*fill).is_some() && seen.insert(*fill) { + fills.push(*fill); + } + } + } + fills + } +} + +/// The fill kind of the selection; see [`FillIndex::current`]. +#[cfg(test)] +pub(crate) fn current(document: &CadDocument, selected: &[Handle]) -> GraphicAttribute { + FillIndex::build(document).current(document, selected) +} + +/// HATCH entities that are selected or associated with a selected boundary. +/// Scans the whole drawing; the palette view uses its cached [`FillIndex`]. +pub(crate) fn fill_handles(document: &CadDocument, selected: &[Handle]) -> Vec { + FillIndex::build(document).fill_handles(document, selected) +} + +/// Selected non-HATCH objects plus the boundaries of selected associative +/// HATCH entities. +pub(crate) fn line_handles(document: &CadDocument, selected: &[Handle]) -> Vec { + let mut handles = Vec::new(); + let mut seen = FxHashSet::default(); + for handle in selected { + match document.get_entity(*handle) { + Some(EntityType::Hatch(hatch)) if hatch.is_associative => { + for boundary in hatch.paths.iter().flat_map(|path| &path.boundary_handles) { + if seen.insert(*boundary) + && document + .get_entity(*boundary) + .is_some_and(|entity| !matches!(entity, EntityType::Hatch(_))) + { + handles.push(*boundary); + } + } + } + Some(entity) if !matches!(entity, EntityType::Hatch(_)) && seen.insert(*handle) => { + handles.push(*handle); + } + _ => {} + } + } + handles +} + +/// The handle drawn on top, which decides the value shown for a selection. +pub(crate) fn top_handle(handles: &[Handle], draw_depth: &DrawDepth) -> Option { + handles.iter().copied().max_by(|left, right| { + let depth = |handle: &Handle| draw_depth.get(&handle.value()).map_or(0.0, |d| d[0]); + depth(left).total_cmp(&depth(right)) + }) +} + +/// The value of the topmost object and whether any of `handles` differs. +fn shared_value<'a, T: PartialEq>( + document: &'a CadDocument, + handles: &[Handle], + draw_depth: &DrawDepth, + value: impl Fn(&EntityCommon) -> T, +) -> Option<(&'a EntityCommon, T, bool)> { + let top = document.get_entity(top_handle(handles, draw_depth)?)?.common(); + let shared = value(top); + let varies = handles.iter().any(|handle| { + document + .get_entity(*handle) + .is_some_and(|entity| value(entity.common()) != shared) + }); + Some((top, shared, varies)) +} + +fn current_layer_name(document: &CadDocument) -> &str { + if document.header.current_layer_name.is_empty() { + "0" + } else { + &document.header.current_layer_name + } +} + +/// Layer of the topmost object, or the current layer without a selection. +fn top_layer<'a>(document: &'a CadDocument, handles: &[Handle], draw_depth: &DrawDepth) -> &'a str { + top_handle(handles, draw_depth) + .and_then(|handle| document.get_entity(handle)) + .map_or_else( + || current_layer_name(document), + |entity| entity.common().layer.as_str(), + ) +} + +#[derive(Clone, Copy)] +enum PropertyMode { + ByLayer, + ByBlock, + Custom, + Varies, +} + +impl PropertyMode { + fn button_label(self) -> &'static str { + match self { + Self::ByLayer => "L", + Self::ByBlock => "B", + Self::Custom => "C", + Self::Varies => "V", + } + } +} + +struct ColorInfo { + color: AcadColor, + display: iced::Color, + varies: bool, +} + +impl ColorInfo { + fn mode(&self) -> PropertyMode { + match (self.varies, self.color) { + (true, _) => PropertyMode::Varies, + (false, AcadColor::ByLayer) => PropertyMode::ByLayer, + (false, AcadColor::ByBlock) => PropertyMode::ByBlock, + _ => PropertyMode::Custom, + } + } +} + +struct TransparencyInfo { + value: u8, + stored: Transparency, + varies: bool, +} + +impl TransparencyInfo { + fn new(document: &CadDocument, layer: &str, stored: Transparency, varies: bool) -> Self { + let effective = if stored.is_by_layer() { + document + .layers + .get(layer) + .map_or(stored, |layer| layer.transparency) + } else if stored.is_by_block() { + Transparency::from_percent(0.0) + } else { + stored + }; + Self { + value: (effective.as_percent() * 100.0).round().clamp(0.0, 90.0) as u8, + stored, + varies, + } + } + + fn mode(&self) -> PropertyMode { + if self.varies { + PropertyMode::Varies + } else if self.stored.is_by_layer() { + PropertyMode::ByLayer + } else if self.stored.is_by_block() { + PropertyMode::ByBlock + } else { + PropertyMode::Custom + } + } +} + +fn color_info(document: &CadDocument, handles: &[Handle], draw_depth: &DrawDepth) -> Option { + let (top, color, varies) = shared_value(document, handles, draw_depth, |c| c.color)?; + let rgba = + crate::scene::view::render::render_style_for_common_viewport(document, top, None).0; + Some(ColorInfo { + color, + display: iced::Color::from_rgba(rgba[0], rgba[1], rgba[2], 1.0), + varies, + }) +} + +/// Line colour for new objects (CECOLOR) when nothing is selected. +fn current_color_info(document: &CadDocument) -> ColorInfo { + let color = document.header.current_entity_color; + let shown = match color { + AcadColor::ByLayer => document + .layers + .get(current_layer_name(document)) + .map_or(color, |layer| layer.color), + _ => color, + }; + ColorInfo { + color, + display: crate::ui::properties::acad_color_display(shown).0, + varies: false, + } +} + +fn transparency_info( + document: &CadDocument, + handles: &[Handle], + draw_depth: &DrawDepth, +) -> Option { + let (top, stored, varies) = shared_value(document, handles, draw_depth, |c| c.transparency)?; + Some(TransparencyInfo::new(document, &top.layer, stored, varies)) +} + +fn effective_lineweight( + document: &CadDocument, + handles: &[Handle], + draw_depth: &DrawDepth, + stored: LineWeight, +) -> LineWeight { + if !matches!(stored, LineWeight::ByLayer) { + return stored; + } + document + .layers + .get(top_layer(document, handles, draw_depth)) + .map_or(LineWeight::Default, |layer| layer.line_weight) +} + +fn current_linetype_name(document: &CadDocument) -> &str { + if !document.header.current_linetype_name.is_empty() { + document.header.current_linetype_name.as_str() + } else if !document.header.current_linetype_handle.is_null() { + document + .line_types + .iter() + .find(|line_type| line_type.handle == document.header.current_linetype_handle) + .map_or("ByLayer", |line_type| line_type.name.as_str()) + } else { + "ByLayer" + } +} + +/// The linetype actually drawn: ByLayer resolves to the layer's linetype, +/// ByBlock to Continuous. +fn effective_linetype<'a>(document: &'a CadDocument, layer: &str, logical: &'a str) -> &'a str { + if logical.is_empty() || logical.eq_ignore_ascii_case("ByLayer") { + document + .layers + .get(if layer.is_empty() { "0" } else { layer }) + .map_or("Continuous", |layer| layer.line_type.as_str()) + } else if logical.eq_ignore_ascii_case("ByBlock") { + "Continuous" + } else { + logical + } +} + +fn is_continuous(name: &str) -> bool { + name.eq_ignore_ascii_case("Continuous") || name.eq_ignore_ascii_case("Solid") +} + +fn linetype_preview_art(document: &CadDocument, linetype: &str) -> String { + if is_continuous(linetype) { + return "_".repeat(80); + } + let art = document + .line_types + .iter() + .find(|line_type| line_type.name.eq_ignore_ascii_case(linetype)) + .map(|line_type| { + if line_type.elements.is_empty() { + "____________".to_string() + } else { + crate::io::linetypes::extract_pattern(&line_type.description) + } + }) + .unwrap_or_default(); + let pattern_start = art.split_whitespace().find_map(|part| { + (part + .chars() + .filter(|character| matches!(character, '_' | '.' | '-' | '/' | '\\' | '|' | '~')) + .count() + >= 2) + .then(|| art.find(part)) + .flatten() + }); + let pattern = pattern_start.map_or(art.as_str(), |start| &art[start..]); + if pattern.is_empty() { + return String::new(); + } + let mut full_width = pattern.to_string(); + while full_width.chars().count() < 80 { + full_width.push(' '); + full_width.push_str(pattern); + } + full_width +} + +/// The topmost selected fill of the given kind. +fn top_fill_hatch<'a>( + document: &'a CadDocument, + fills: &[Handle], + draw_depth: &DrawDepth, + kind: GraphicAttribute, +) -> Option<&'a Hatch> { + let fills: Vec<_> = fills + .iter() + .copied() + .filter(|handle| { + matches!(document.get_entity(*handle), + Some(EntityType::Hatch(hatch)) if GraphicAttribute::of(hatch) == kind) + }) + .collect(); + match document.get_entity(top_handle(&fills, draw_depth)?)? { + EntityType::Hatch(hatch) => Some(hatch), + _ => None, + } +} + +fn gradients_vary(document: &CadDocument, fills: &[Handle]) -> bool { + let mut gradients = fills + .iter() + .filter_map(|handle| match document.get_entity(*handle) { + Some(EntityType::Hatch(hatch)) if hatch.gradient_color.enabled => { + Some(&hatch.gradient_color) + } + _ => None, + }); + let Some(first) = gradients.next() else { + return false; + }; + gradients.any(|gradient| gradient != first) +} + +fn popup_alignment(side: DockSide) -> iced_aw::drop_down::Alignment { + match side { + DockSide::Left => iced_aw::drop_down::Alignment::BottomEnd, + DockSide::Right => iced_aw::drop_down::Alignment::BottomStart, + } +} + +fn compact_button_content(label: &'static str) -> Element<'static, Message> { + container(text(label).size(crate::ui::ROW_H * 0.42)) + .width(12) + .height(Fill) + .align_x(iced::Center) + .align_y(iced::Center) + .into() +} + +fn popup_item<'a>(label: std::borrow::Cow<'a, str>, message: Message) -> Element<'a, Message> { + button(text(label).size(11)) + .on_press(message) + .style(crate::ui::color_select::list_row_style) + .width(Fill) + .padding([2, 4]) + .into() +} + +/// The small L / B / C / V button next to a control, with its drop-down. +fn mode_menu<'a>( + side: DockSide, + mode: PropertyMode, + menu: Menu, + open_menu: Option, + height: f32, + items: Vec<(std::borrow::Cow<'a, str>, Message)>, +) -> Element<'a, Message> { + let open = open_menu == Some(menu); + let more = button(compact_button_content(mode.button_label())) + .on_press(msg(GraphicAttributesMsg::ToggleMenu(menu))) + .style(move |theme: &Theme, status| { + if open { + button::primary(theme, status) + } else { + button::secondary(theme, status) + } + }) + .height(height) + .padding([1, 3]); + if !open { + return more.into(); + } + let popup = container(column( + items + .into_iter() + .map(|(label, message)| popup_item(label, message)), + )) + .style(crate::ui::color_select::popup_panel_style) + .padding(2); + iced_aw::DropDown::new(more, popup, true) + .width(Length::Fixed(110.0)) + .height(Length::Shrink) + .alignment(popup_alignment(side)) + .offset(2.0) + .on_dismiss(msg(GraphicAttributesMsg::CloseMenu)) + .into() +} + +fn color_control<'a>( + info: ColorInfo, + side: DockSide, + menu: Menu, + open_menu: Option, + target: ColorPickTarget, + changed: fn(AcadColor) -> GraphicAttributesMsg, +) -> Element<'a, Message> { + // ByLayer / ByBlock show only on the L / B button; differing colours are + // also spelled out on the swatch. + let label: Element<'a, Message> = if info.varies { + container(text("*VARIES*").size(10)) + .width(Fill) + .height(Fill) + .align_x(iced::Left) + .align_y(iced::Center) + .padding([0, 6]) + .into() + } else { + Space::new().width(Fill).height(Fill).into() + }; + let display = info.display; + let swatch = button(label) + .on_press(Message::OpenColorWindow(target.clone(), info.color)) + .width(Fill) + .height(COMPACT_BUTTON_HEIGHT) + .padding(0) + .style(move |theme: &Theme, status| button::Style { + background: Some(Background::Color(display)), + text_color: if display.r * 0.299 + display.g * 0.587 + display.b * 0.114 > 0.55 { + iced::Color::BLACK + } else { + iced::Color::WHITE + }, + border: Border { + color: if matches!(status, button::Status::Hovered) { + theme.palette().primary.base.color + } else { + theme.palette().background.neutral.color + }, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + }); + let more = mode_menu( + side, + info.mode(), + menu, + open_menu, + COMPACT_BUTTON_HEIGHT, + vec![ + ("ByLayer".into(), msg(changed(AcadColor::ByLayer))), + ("ByBlock".into(), msg(changed(AcadColor::ByBlock))), + (crate::t!("Custom…"), Message::OpenColorWindow(target, info.color)), + ], + ); + row![swatch, more].spacing(4).align_y(iced::Center).into() +} + +fn transparency_control<'a>( + info: TransparencyInfo, + side: DockSide, + menu: Menu, + open_menu: Option, + changed: fn(Transparency) -> GraphicAttributesMsg, +) -> Element<'a, Message> { + let value_label = if info.varies { + "*VARIES*".to_string() + } else { + format!("{}%", info.value) + }; + let percent = |value: u8| Transparency::from_percent(f64::from(value) / 100.0); + let control = row![ + crate::ui::icons::semantic(TRANSPARENCY_ICON, PALETTE_CONTROL_ICON_SIZE), + text(value_label).size(10).width(38), + slider(0..=90, i32::from(info.value), move |value| { + msg(changed(percent(value as u8))) + }) + .width(Fill), + ] + .spacing(5) + .align_y(iced::Center) + .height(crate::ui::ROW_H); + let more = mode_menu( + side, + info.mode(), + menu, + open_menu, + crate::ui::ROW_H, + vec![ + ("ByLayer".into(), msg(changed(Transparency::BY_LAYER))), + ("ByBlock".into(), msg(changed(Transparency::BY_BLOCK))), + (crate::t!("Custom"), msg(changed(percent(info.value)))), + ], + ); + row![control, more].spacing(4).align_y(iced::Center).into() +} + +fn header_menu(side: DockSide, open: bool, fill_on_current_layer: bool) -> Element<'static, Message> { + let menu_button = button(crate::ui::icons::themed_secondary( + crate::ui::icons::MENU, + 12.0, + )) + .on_press(msg(GraphicAttributesMsg::ToggleMenu(Menu::Header))) + .style(move |theme: &Theme, status| { + if open { + button::primary(theme, status) + } else { + button::subtle(theme, status) + } + }) + .padding([3, 5]); + if !open { + return menu_button.into(); + } + + let item = |label: &'static str, message| { + button(text(crate::t!(label)).size(11)) + .on_press(msg(message)) + .style(crate::ui::color_select::list_row_style) + .width(Fill) + .padding([4, 8]) + }; + let popup = container(column![ + item("Set all attributes ByLayer", GraphicAttributesMsg::SetAllByLayer), + item("Set all attributes ByBlock", GraphicAttributesMsg::SetAllByBlock), + item("Remove ByLayer / ByBlock", GraphicAttributesMsg::RemoveReferences), + item("Create Layer with active settings", GraphicAttributesMsg::CreateLayer), + container(iced::widget::rule::horizontal(1)).padding([2, 4]), + container( + iced::widget::checkbox(fill_on_current_layer) + .label(crate::t!("Create fills on the current layer")) + .on_toggle(|_| msg(GraphicAttributesMsg::ToggleFillOnCurrentLayer)) + .size(12) + .text_size(11), + ) + .padding([4, 8]), + ]) + .style(crate::ui::color_select::popup_panel_style) + .padding(2); + iced_aw::DropDown::new(menu_button, popup, true) + .width(Length::Fixed(220.0)) + .height(Length::Shrink) + .alignment(popup_alignment(side)) + .offset(2.0) + .on_dismiss(msg(GraphicAttributesMsg::CloseMenu)) + .into() +} + +fn fill_picker<'a>(current: GraphicAttribute, open: bool) -> Element<'a, Message> { + let picker = button( + row![ + crate::ui::icons::semantic(current.icon(), 14.0), + text(current.to_string()).size(11), + Space::new().width(Fill), + crate::ui::icons::themed_arrow_toggle(open, 9.0), + ] + .spacing(5) + .align_y(iced::Center), + ) + .on_press(msg(GraphicAttributesMsg::ToggleMenu(Menu::Fill))) + .style(move |theme: &Theme, _| { + let palette = theme.palette(); + button::Style { + background: Some(Background::Color(palette.background.base.color)), + text_color: palette.background.base.text, + border: Border { + color: if open { + palette.primary.base.color + } else { + palette.background.neutral.color + }, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + } + }) + .height(crate::ui::ROW_H) + .width(Fill) + .padding([3, 6]); + if !open { + return picker.into(); + } + let rows = column(GraphicAttribute::ALL.into_iter().map(|value| { + button( + row![ + crate::ui::icons::semantic(value.icon(), 14.0), + text(value.to_string()).size(11), + ] + .spacing(5) + .align_y(iced::Center), + ) + .on_press(msg(GraphicAttributesMsg::Fill(value))) + .style(crate::ui::color_select::list_row_style) + .width(Fill) + .padding([2, 4]) + .into() + })); + let popup = container(rows) + .style(crate::ui::color_select::popup_panel_style) + .padding(2); + crate::ui::color_select::drop_down_below( + picker.into(), + popup.into(), + None, + Length::Shrink, + msg(GraphicAttributesMsg::CloseMenu), + ) +} + +/// A preview button plus the `⋮` button that toggles its editor flyout. +fn preview_row<'a>( + preview: Element<'a, Message>, + editor_open: bool, + toggle: GraphicAttributesMsg, + tooltip_label: std::borrow::Cow<'static, str>, + flyout: Option<(Element<'a, Message>, f32, Option)>, + side: DockSide, +) -> Element<'a, Message> { + let preview = button(preview) + .on_press(msg(toggle.clone())) + .width(Fill) + .height(COMPACT_BUTTON_HEIGHT) + .padding(0) + .style(move |theme: &Theme, status| button::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: if editor_open + || matches!(status, button::Status::Hovered | button::Status::Pressed) + { + theme.palette().primary.base.color + } else { + theme.palette().background.neutral.color + }, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + }); + let edit = button(compact_button_content("⋮")) + .on_press(msg(toggle)) + .style(move |theme: &Theme, status| { + if editor_open { + button::primary(theme, status) + } else { + button::secondary(theme, status) + } + }) + .height(COMPACT_BUTTON_HEIGHT) + .padding([1, 3]); + let edit = tooltip(edit, text(tooltip_label).size(10), tooltip::Position::Bottom); + let edit: Element<'a, Message> = match flyout { + Some((popup, width, height)) => { + let mut drop_down = iced_aw::DropDown::new(edit, popup, true) + .width(Length::Fixed(width)) + .alignment(popup_alignment(side)) + .offset(4.0); + if let Some(height) = height { + drop_down = drop_down + .height(Length::Fixed(height)) + .on_dismiss(msg(GraphicAttributesMsg::HatchEditorClose)); + } + drop_down.into() + } + None => edit.into(), + }; + row![preview, edit].spacing(4).align_y(iced::Center).into() +} + +fn linetype_row<'a>( + document: &'a CadDocument, + properties: &'a crate::ui::properties::PropertiesPanel, + linetype: &str, + varies: bool, + layer: &str, + side: DockSide, + open_menu: Option, +) -> Element<'a, Message> { + let selected = (!varies).then(|| crate::ui::properties::LinetypeItem { + // An empty name shows only the preview art in the closed control; + // the menu items keep their full names. + name: String::new(), + art: linetype_preview_art(document, effective_linetype(document, layer, linetype)), + }); + let combo = combo_box( + &properties.linetype_combo, + "*VARIES*", + selected.as_ref(), + |item: crate::ui::properties::LinetypeItem| { + msg(GraphicAttributesMsg::LineLinetype(item.name)) + }, + ) + .size(crate::ui::ROW_H * 0.42) + .padding(Padding { + top: 3.0, + bottom: 3.0, + left: 6.0, + right: 6.0, + }) + .input_style(crate::ui::properties::combo_input_style) + .width(Fill); + let mode = if varies { + PropertyMode::Varies + } else if linetype.eq_ignore_ascii_case("ByLayer") { + PropertyMode::ByLayer + } else if linetype.eq_ignore_ascii_case("ByBlock") { + PropertyMode::ByBlock + } else { + PropertyMode::Custom + }; + let more = mode_menu( + side, + mode, + Menu::LineLinetype, + open_menu, + COMPACT_BUTTON_HEIGHT, + vec![ + ("ByLayer".into(), msg(GraphicAttributesMsg::LineLinetype("ByLayer".into()))), + ("ByBlock".into(), msg(GraphicAttributesMsg::LineLinetype("ByBlock".into()))), + ], + ); + row![crate::ui::wide_menu::wide_menu(combo, 220.0), more] + .spacing(4) + .align_y(iced::Center) + .into() +} + +/// Widget id of the linetype scale field, for select-all on focus. +pub(crate) const LINETYPE_SCALE_FIELD: &str = "graphic-linetype-scale"; + +/// Like a Properties field: clicking selects the value, Enter applies it. +fn linetype_scale_field<'a>( + scale: f64, + varies: bool, + continuous: bool, + typed: Option<&str>, +) -> Element<'a, Message> { + let shown = match typed { + Some(typed) => typed.to_string(), + None if varies => String::new(), + None => normalized_linetype_scale(scale).unwrap_or(1.0).to_string(), + }; + let mut field = text_input(if varies { "*VARIES*" } else { "" }, &shown) + .id(iced::widget::Id::new(LINETYPE_SCALE_FIELD)) + .size(crate::ui::ROW_H * 0.42) + .padding([5, 5]) + .style(move |theme: &Theme, status| { + let mut style = crate::ui::properties::combo_input_style(theme, status); + if continuous { + let disabled = theme.palette().background.base.text.scale_alpha(0.42); + style.value = disabled; + style.placeholder = disabled; + } + style + }) + .width(Fill); + if !continuous { + field = field + .on_input(|value| msg(GraphicAttributesMsg::LineLinetypeScaleInput(value))) + .on_submit(msg(GraphicAttributesMsg::LineLinetypeScaleSubmit)); + } + let icon = GraphicAttribute::Solid.icon(); + let icon = if continuous { + crate::ui::icons::themed_disabled(icon, PALETTE_CONTROL_ICON_SIZE) + } else { + crate::ui::icons::semantic(icon, PALETTE_CONTROL_ICON_SIZE) + }; + row![icon, field] + .spacing(3) + .align_y(iced::Center) + .width(Length::FillPortion(1)) + .into() +} + +fn lineweight_field<'a>( + properties: &'a crate::ui::properties::PropertiesPanel, + stored: LineWeight, + effective: LineWeight, + varies: bool, + side: DockSide, + open_menu: Option, +) -> Element<'a, Message> { + let selected = (!varies).then_some(crate::ui::properties::LwItem(effective)); + let mode = if varies { + PropertyMode::Varies + } else { + match stored { + LineWeight::ByLayer => PropertyMode::ByLayer, + LineWeight::ByBlock => PropertyMode::ByBlock, + _ => PropertyMode::Custom, + } + }; + let picker = combo_box( + &properties.lineweight_combo, + "*VARIES*", + selected.as_ref(), + |item: crate::ui::properties::LwItem| msg(GraphicAttributesMsg::LineLineweight(item.0)), + ) + .size(crate::ui::ROW_H * 0.42) + .padding(Padding { + top: 3.0, + bottom: 3.0, + left: 6.0, + right: 6.0, + }) + .input_style(crate::ui::properties::combo_input_style) + .width(Fill); + let more = mode_menu( + side, + mode, + Menu::LineLineweight, + open_menu, + COMPACT_BUTTON_HEIGHT, + vec![ + ("ByLayer".into(), msg(GraphicAttributesMsg::LineLineweight(LineWeight::ByLayer))), + ("ByBlock".into(), msg(GraphicAttributesMsg::LineLineweight(LineWeight::ByBlock))), + ], + ); + row![ + crate::ui::icons::semantic(LINEWEIGHT_ICON, PALETTE_CONTROL_ICON_SIZE), + picker, + more, + ] + .spacing(3) + .align_y(iced::Center) + .width(Length::FillPortion(1)) + .into() +} + +/// The Line section: values of the selection's line objects, or the +/// creation defaults when nothing is selected. +fn line_section<'a>( + document: &'a CadDocument, + properties: &'a crate::ui::properties::PropertiesPanel, + handles: &[Handle], + no_selection: bool, + draw_depth: &DrawDepth, + side: DockSide, + state: &GraphicAttributesState, +) -> Vec> { + let open_menu = state.open_menu; + let typed_scale = state.linetype_scale_input.as_deref(); + let mut rows = Vec::new(); + if handles.is_empty() && !no_selection { + return rows; + } + let layer = top_layer(document, handles, draw_depth).to_string(); + let header = &document.header; + let (linetype, linetype_varies) = shared_value(document, handles, draw_depth, |c| { + c.linetype.clone() + }) + .map_or_else( + || (current_linetype_name(document).to_string(), false), + |(_, value, varies)| (if value.is_empty() { "ByLayer".into() } else { value }, varies), + ); + let (scale, scale_varies) = + shared_value(document, handles, draw_depth, |c| c.linetype_scale).map_or( + (header.current_entity_linetype_scale, false), + |(_, value, varies)| (value, varies), + ); + let (lineweight, lineweight_varies) = + shared_value(document, handles, draw_depth, |c| c.line_weight).map_or( + (LineWeight::from_value(header.current_line_weight), false), + |(_, value, varies)| (value, varies), + ); + let color = color_info(document, handles, draw_depth) + .unwrap_or_else(|| current_color_info(document)); + let transparency = transparency_info(document, handles, draw_depth).unwrap_or_else(|| { + TransparencyInfo::new(document, &layer, document.current_entity_transparency(), false) + }); + let continuous = + !linetype_varies && is_continuous(effective_linetype(document, &layer, &linetype)); + + rows.push(linetype_row( + document, + properties, + &linetype, + linetype_varies, + &layer, + side, + open_menu, + )); + rows.push(color_control( + color, + side, + Menu::LineColor, + open_menu, + ColorPickTarget::GraphicAttributesLine, + GraphicAttributesMsg::LineColor, + )); + let effective = effective_lineweight(document, handles, draw_depth, lineweight); + rows.push( + row![ + linetype_scale_field(scale, scale_varies, continuous, typed_scale), + lineweight_field( + properties, + lineweight, + effective, + lineweight_varies, + side, + open_menu + ), + ] + .spacing(12) + .align_y(iced::Center) + .height(crate::ui::ROW_H) + .into(), + ); + rows.push(transparency_control( + transparency, + side, + Menu::LineTransparency, + open_menu, + GraphicAttributesMsg::LineTransparency, + )); + rows +} + +pub fn view<'a>( + state: &'a GraphicAttributesState, + scene: &'a Scene, + properties: &'a crate::ui::properties::PropertiesPanel, + width: f32, + chrome: crate::ui::dock::Chrome, + side: DockSide, + gradient_color_picking: bool, +) -> Element<'a, Message> { + let document = &scene.document; + let selected = scene.selected_handles_in_order(); + let draw_depth = scene.draw_depth_map(); + let draw_depth = draw_depth.as_ref(); + let open_menu = state.open_menu; + let line_handles = line_handles(document, &selected); + let (fill_handles, current) = state.fills(scene, &selected); + + let mut body = column![text(crate::t!("Line")).size(11)].spacing(6); + body = body.extend(line_section( + document, + properties, + &line_handles, + selected.is_empty(), + draw_depth, + side, + state, + )); + + body = body + .push(Space::new().height(2)) + .push(text(crate::t!("Fill")).size(11)) + .push(fill_picker(current, open_menu == Some(Menu::Fill))); + if !matches!(current, GraphicAttribute::None | GraphicAttribute::Gradient) { + if let Some(info) = color_info(document, &fill_handles, draw_depth) { + body = body.push(color_control( + info, + side, + Menu::FillColor, + open_menu, + ColorPickTarget::GraphicAttributesSolid, + GraphicAttributesMsg::FillColor, + )); + } + } + match current { + GraphicAttribute::Hatch => { + let hatch = top_fill_hatch(document, &fill_handles, draw_depth, current); + let preview = hatch + .and_then(|hatch| Scene::hatch_model_from_dxf(hatch, [1.0; 4])) + .map(|model| crate::ui::properties::compact_hatch_pattern_preview(model.pattern)) + .unwrap_or_else(|| Space::new().width(Fill).height(Fill).into()); + let flyout = state.hatch_editor.as_ref().map(|editor| { + let picker = crate::ui::properties::hatch_pattern_picker_content( + &editor.search, + editor.focus, + hatch.map_or("", |hatch| hatch.pattern.name.as_str()), + crate::ui::properties::PatternPickerMessages { + search_id: "graphic-hatch-pattern-search", + on_search: |search| msg(GraphicAttributesMsg::HatchPatternSearch(search)), + on_confirm: msg(GraphicAttributesMsg::HatchPatternConfirm), + on_focus: |index| msg(GraphicAttributesMsg::HatchPatternFocus(index)), + on_changed: |name| msg(GraphicAttributesMsg::HatchPattern(name)), + }, + ); + let popup = container(picker) + .padding(1) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: theme.palette().primary.base.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }); + (popup.into(), 352.0, Some(724.0)) + }); + let open = state.hatch_editor.is_some(); + body = body.push(preview_row( + preview, + open, + if open { + GraphicAttributesMsg::HatchEditorClose + } else { + GraphicAttributesMsg::HatchEditorOpen + }, + crate::t!("Edit hatch"), + flyout, + side, + )); + } + GraphicAttribute::Gradient => { + if let Some(hatch) = top_fill_hatch(document, &fill_handles, draw_depth, current) { + let preview = state.gradient_preview(hatch); + let preview = if gradients_vary(document, &fill_handles) { + iced::widget::stack![ + preview, + container(text("*VARIES*").size(10)) + .width(Fill) + .height(Fill) + .align_x(iced::Left) + .align_y(iced::Center) + .padding([0, 6]), + ] + .into() + } else { + preview + }; + let flyout = state.gradient_editor.as_ref().map(|editor| { + ( + crate::ui::window::gradient_editor::view(editor, gradient_color_picking), + 390.0, + None, + ) + }); + let open = state.gradient_editor.is_some(); + body = body.push(preview_row( + preview, + open, + GraphicAttributesMsg::Gradient(if open { + GradientMsg::Cancel + } else { + GradientMsg::Open + }), + crate::t!("Edit gradient"), + flyout, + side, + )); + } + } + GraphicAttribute::None | GraphicAttribute::Solid | GraphicAttribute::Varies => {} + } + if current != GraphicAttribute::None { + if let Some(info) = transparency_info(document, &fill_handles, draw_depth) { + body = body.push(transparency_control( + info, + side, + Menu::FillTransparency, + open_menu, + GraphicAttributesMsg::FillTransparency, + )); + } + } + + container(column![ + crate::ui::dock::title_bar_with( + PanelId::GraphicAttributes, + crate::t!("Graphic Attributes").into_owned(), + chrome, + Some(header_menu( + side, + open_menu == Some(Menu::Header), + state.fill_on_current_layer + )), + ), + body.padding(8) + ]) + .width(Length::Fixed(width)) + .height(Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + ..Default::default() + }) + .into() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 9de0f986c..c6c870366 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -13,6 +13,8 @@ pub mod drawing_units; pub mod field_dialog; pub mod find_replace; pub mod geometric_tolerance; +pub mod gradient_editor; +pub mod graphic_attributes; pub mod layer_state_manager; pub mod layer_translator; pub mod layers;