From c5790c74a9cf89a86b7b79e89fae7def15c010db Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:55:32 +0300 Subject: [PATCH 1/4] ATTDEF: dialog, -ATTDEF prompts, tag display, Properties and ribbon Attribute Definition dialog and Edit Attribute Definition dialog, -ATTDEF with the reference prompts and modes (AFLAGS), tag shown in the drawing, justification-aware grips, reworked Properties with validation, Home Block slide-out and Insert Block Definition panel, and the codec patch for ATTDEF DXF output and the R2013+ solid wire cache. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 4 + assets/ocad.pgp | 1 + locales/ar-SA/opencadstudio.ftl | 12 + locales/bg-BG/opencadstudio.ftl | 12 + locales/cs-CZ/opencadstudio.ftl | 12 + locales/de-DE/opencadstudio.ftl | 12 + locales/el-GR/opencadstudio.ftl | 12 + locales/en-US/opencadstudio.ftl | 12 + locales/es-ES/opencadstudio.ftl | 12 + locales/fi-FI/opencadstudio.ftl | 12 + locales/fr-FR/opencadstudio.ftl | 12 + locales/hi-IN/opencadstudio.ftl | 12 + locales/hu-HU/opencadstudio.ftl | 12 + locales/it-IT/opencadstudio.ftl | 12 + locales/ja-JP/opencadstudio.ftl | 12 + locales/ko-KR/opencadstudio.ftl | 12 + locales/nl-NL/opencadstudio.ftl | 12 + locales/pl-PL/opencadstudio.ftl | 12 + locales/pt-BR/opencadstudio.ftl | 12 + locales/ru-RU/opencadstudio.ftl | 12 + locales/tr-TR/opencadstudio.ftl | 12 + locales/zh-CN/opencadstudio.ftl | 12 + locales/zh-TW/opencadstudio.ftl | 12 + src/app/alias.rs | 3 +- src/app/commands/attdef.rs | 320 ++++++++ src/app/commands/draw.rs | 15 - src/app/commands/mod.rs | 4 + src/app/control/actions.rs | 48 ++ src/app/mod.rs | 8 + src/app/text_inline.rs | 7 + src/app/update/command.rs | 8 + src/app/update/mod.rs | 10 + src/app/view/modal.rs | 10 + src/entities/attribute.rs | 572 ++++++++++----- src/entities/common.rs | 21 + src/locale_catalog.rs | 12 + src/modules/draw/draw/attdef.rs | 1166 +++++++++++++++++++++++++----- src/modules/draw/mod.rs | 8 +- src/modules/insert/attsync.rs | 10 - src/modules/insert/base_point.rs | 11 - src/modules/insert/mod.rs | 19 +- src/scene/cache/block_cache.rs | 12 + src/ui/ribbon/draw_panel.rs | 52 +- src/ui/ribbon/mod.rs | 6 +- src/ui/window/attdef_dialog.rs | 350 +++++++++ src/ui/window/mod.rs | 1 + src/ui/window/pdf_dialogs.rs | 4 +- 48 files changed, 2501 insertions(+), 435 deletions(-) create mode 100644 src/app/commands/attdef.rs delete mode 100644 src/modules/insert/attsync.rs create mode 100644 src/ui/window/attdef_dialog.rs diff --git a/Cargo.lock b/Cargo.lock index 48147ad77..ee73fdd2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4205,7 +4205,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=5ef9376#5ef9376e02996097ff649cd9268c343327413787" +source = "git+https://github.com/ramox81/cadcodec.git?rev=282b130#282b1303e4d096febd37a898850a988595fd8d0e" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index a4eb66f0a..96c4aa6a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,6 +94,10 @@ objc2-foundation = { version = "0.2", features = ["NSArray", "NSURL", "NSString" iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } +# ATTDEF/ATTRIB DXF output and the R2013+ solid wire cache (cadcodec PR pending). +[patch."https://github.com/HakanSeven12/opencadcodec.git"] +opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "282b130" } + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] getrandom = "0.3" ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } diff --git a/assets/ocad.pgp b/assets/ocad.pgp index 1e5790c6c..cf8ba9bc6 100644 --- a/assets/ocad.pgp +++ b/assets/ocad.pgp @@ -65,6 +65,7 @@ PC, *PASTECLIP ; ── Blocks / references ─────────────────────────────────────────────────── ATT, *ATTDEF +-ATT, *-ATTDEF BE, *BEDIT IAT, *IMAGEATTACH XA, *XATTACH diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 49b2b7ec2..34db39d32 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -4032,6 +4032,18 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .attdef-on-screen = Specify on-screen + .attdef-insertion-point = Insertion Point + .attdef-insert-field = Insert field + .attdef-text-settings = Text Settings + .attdef-align-below = Align below previous attribute definition + .attdef-define = Define + .attdef-edit-title = Edit Attribute Definition + .attdef-edit-help = Changes the tag, prompt and default value of an attribute definition. + .attdef-help = Defines an attribute: a tag, prompt and default value placed as text in a block definition. + .attdef-define-attributes = Define Attributes + .attdef-manage-attributes = Manage Attributes + .attdef-set-base-point = Set Base Point model = .create = إنشاء .boolean = عمليات منطقية diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 11eec1078..b67ac4bf5 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -4019,6 +4019,18 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .attdef-on-screen = Specify on-screen + .attdef-insertion-point = Insertion Point + .attdef-insert-field = Insert field + .attdef-text-settings = Text Settings + .attdef-align-below = Align below previous attribute definition + .attdef-define = Define + .attdef-edit-title = Edit Attribute Definition + .attdef-edit-help = Changes the tag, prompt and default value of an attribute definition. + .attdef-help = Defines an attribute: a tag, prompt and default value placed as text in a block definition. + .attdef-define-attributes = Define Attributes + .attdef-manage-attributes = Manage Attributes + .attdef-set-base-point = Set Base Point model = .create = Създаване .boolean = Булеви операции diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index ecef5c891..d32997df1 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -4017,6 +4017,18 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .attdef-on-screen = Specify on-screen + .attdef-insertion-point = Insertion Point + .attdef-insert-field = Insert field + .attdef-text-settings = Text Settings + .attdef-align-below = Align below previous attribute definition + .attdef-define = Define + .attdef-edit-title = Edit Attribute Definition + .attdef-edit-help = Changes the tag, prompt and default value of an attribute definition. + .attdef-help = Defines an attribute: a tag, prompt and default value placed as text in a block definition. + .attdef-define-attributes = Define Attributes + .attdef-manage-attributes = Manage Attributes + .attdef-set-base-point = Set Base Point model = .create = Vytvořit .boolean = Booleovské operace diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 9f6184f50..f2a104e7e 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -4018,6 +4018,18 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .attdef-on-screen = Specify on-screen + .attdef-insertion-point = Insertion Point + .attdef-insert-field = Insert field + .attdef-text-settings = Text Settings + .attdef-align-below = Align below previous attribute definition + .attdef-define = Define + .attdef-edit-title = Edit Attribute Definition + .attdef-edit-help = Changes the tag, prompt and default value of an attribute definition. + .attdef-help = Defines an attribute: a tag, prompt and default value placed as text in a block definition. + .attdef-define-attributes = Define Attributes + .attdef-manage-attributes = Manage Attributes + .attdef-set-base-point = Set Base Point model = .create = Erstellen .boolean = Boolesche Operationen diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index c4fa2d108..e8f306d87 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -4020,6 +4020,18 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .attdef-on-screen = Specify on-screen + .attdef-insertion-point = Insertion Point + .attdef-insert-field = Insert field + .attdef-text-settings = Text Settings + .attdef-align-below = Align below previous attribute definition + .attdef-define = Define + .attdef-edit-title = Edit Attribute Definition + .attdef-edit-help = Changes the tag, prompt and default value of an attribute definition. + .attdef-help = Defines an attribute: a tag, prompt and default value placed as text in a block definition. + .attdef-define-attributes = Define Attributes + .attdef-manage-attributes = Manage Attributes + .attdef-set-base-point = Set Base Point model = .create = Δημιουργία .boolean = Δυαδική πράξη diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index f958e0a44..a3f43b6d6 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -4021,6 +4021,18 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .attdef-on-screen = Specify on-screen + .attdef-insertion-point = Insertion Point + .attdef-insert-field = Insert field + .attdef-text-settings = Text Settings + .attdef-align-below = Align below previous attribute definition + .attdef-define = Define + .attdef-edit-title = Edit Attribute Definition + .attdef-edit-help = Changes the tag, prompt and default value of an attribute definition. + .attdef-help = Defines an attribute: a tag, prompt and default value placed as text in a block definition. + .attdef-define-attributes = Define Attributes + .attdef-manage-attributes = Manage Attributes + .attdef-set-base-point = Set Base Point model = .create = Create .boolean = Boolean diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 9adc06b83..8c2857ee7 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -4020,6 +4020,18 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .attdef-on-screen = Specify on-screen + .attdef-insertion-point = Insertion Point + .attdef-insert-field = Insert field + .attdef-text-settings = Text Settings + .attdef-align-below = Align below previous attribute definition + .attdef-define = Define + .attdef-edit-title = Edit Attribute Definition + .attdef-edit-help = Changes the tag, prompt and default value of an attribute definition. + .attdef-help = Defines an attribute: a tag, prompt and default value placed as text in a block definition. + .attdef-define-attributes = Define Attributes + .attdef-manage-attributes = Manage Attributes + .attdef-set-base-point = Set Base Point model = .create = Crear .boolean = Booleanas diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 20dd5dfa0..2d88f63da 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -4019,6 +4019,18 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .attdef-on-screen = Specify on-screen + .attdef-insertion-point = Insertion Point + .attdef-insert-field = Insert field + .attdef-text-settings = Text Settings + .attdef-align-below = Align below previous attribute definition + .attdef-define = Define + .attdef-edit-title = Edit Attribute Definition + .attdef-edit-help = Changes the tag, prompt and default value of an attribute definition. + .attdef-help = Defines an attribute: a tag, prompt and default value placed as text in a block definition. + .attdef-define-attributes = Define Attributes + .attdef-manage-attributes = Manage Attributes + .attdef-set-base-point = Set Base Point model = .create = Luo .boolean = Boolen operaatiot diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 01c1d1bb6..741de7b31 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -4020,6 +4020,18 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .attdef-on-screen = Specify on-screen + .attdef-insertion-point = Insertion Point + .attdef-insert-field = Insert field + .attdef-text-settings = Text Settings + .attdef-align-below = Align below previous attribute definition + .attdef-define = Define + .attdef-edit-title = Edit Attribute Definition + .attdef-edit-help = Changes the tag, prompt and default value of an attribute definition. + .attdef-help = Defines an attribute: a tag, prompt and default value placed as text in a block definition. + .attdef-define-attributes = Define Attributes + .attdef-manage-attributes = Manage Attributes + .attdef-set-base-point = Set Base Point model = .create = Créer .boolean = Opérations booléennes diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index ed27d6574..8cdb4a8ce 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -4008,6 +4008,18 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .attdef-on-screen = Specify on-screen + .attdef-insertion-point = Insertion Point + .attdef-insert-field = Insert field + .attdef-text-settings = Text Settings + .attdef-align-below = Align below previous attribute definition + .attdef-define = Define + .attdef-edit-title = Edit Attribute Definition + .attdef-edit-help = Changes the tag, prompt and default value of an attribute definition. + .attdef-help = Defines an attribute: a tag, prompt and default value placed as text in a block definition. + .attdef-define-attributes = Define Attributes + .attdef-manage-attributes = Manage Attributes + .attdef-set-base-point = Set Base Point model = .create = बनाएँ .boolean = बूलियन संक्रियाएँ diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index bc78e77d9..d0d7ee442 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -4019,6 +4019,18 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .attdef-on-screen = Specify on-screen + .attdef-insertion-point = Insertion Point + .attdef-insert-field = Insert field + .attdef-text-settings = Text Settings + .attdef-align-below = Align below previous attribute definition + .attdef-define = Define + .attdef-edit-title = Edit Attribute Definition + .attdef-edit-help = Changes the tag, prompt and default value of an attribute definition. + .attdef-help = Defines an attribute: a tag, prompt and default value placed as text in a block definition. + .attdef-define-attributes = Define Attributes + .attdef-manage-attributes = Manage Attributes + .attdef-set-base-point = Set Base Point model = .create = Létrehozás .boolean = Logikai műveletek diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 835f99590..e005dd34c 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -4019,6 +4019,18 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .attdef-on-screen = Specify on-screen + .attdef-insertion-point = Insertion Point + .attdef-insert-field = Insert field + .attdef-text-settings = Text Settings + .attdef-align-below = Align below previous attribute definition + .attdef-define = Define + .attdef-edit-title = Edit Attribute Definition + .attdef-edit-help = Changes the tag, prompt and default value of an attribute definition. + .attdef-help = Defines an attribute: a tag, prompt and default value placed as text in a block definition. + .attdef-define-attributes = Define Attributes + .attdef-manage-attributes = Manage Attributes + .attdef-set-base-point = Set Base Point model = .create = Crea .boolean = Operazioni booleane diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 969801da3..a76a30075 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -4008,6 +4008,18 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .attdef-on-screen = Specify on-screen + .attdef-insertion-point = Insertion Point + .attdef-insert-field = Insert field + .attdef-text-settings = Text Settings + .attdef-align-below = Align below previous attribute definition + .attdef-define = Define + .attdef-edit-title = Edit Attribute Definition + .attdef-edit-help = Changes the tag, prompt and default value of an attribute definition. + .attdef-help = Defines an attribute: a tag, prompt and default value placed as text in a block definition. + .attdef-define-attributes = Define Attributes + .attdef-manage-attributes = Manage Attributes + .attdef-set-base-point = Set Base Point model = .create = 作成 .boolean = ブール演算 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 8cce74f8e..81e62ae84 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -4019,6 +4019,18 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .attdef-on-screen = Specify on-screen + .attdef-insertion-point = Insertion Point + .attdef-insert-field = Insert field + .attdef-text-settings = Text Settings + .attdef-align-below = Align below previous attribute definition + .attdef-define = Define + .attdef-edit-title = Edit Attribute Definition + .attdef-edit-help = Changes the tag, prompt and default value of an attribute definition. + .attdef-help = Defines an attribute: a tag, prompt and default value placed as text in a block definition. + .attdef-define-attributes = Define Attributes + .attdef-manage-attributes = Manage Attributes + .attdef-set-base-point = Set Base Point model = .create = 생성 .boolean = 불리언 연산 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index a1f00441a..a5963eb1d 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -4018,6 +4018,18 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .attdef-on-screen = Specify on-screen + .attdef-insertion-point = Insertion Point + .attdef-insert-field = Insert field + .attdef-text-settings = Text Settings + .attdef-align-below = Align below previous attribute definition + .attdef-define = Define + .attdef-edit-title = Edit Attribute Definition + .attdef-edit-help = Changes the tag, prompt and default value of an attribute definition. + .attdef-help = Defines an attribute: a tag, prompt and default value placed as text in a block definition. + .attdef-define-attributes = Define Attributes + .attdef-manage-attributes = Manage Attributes + .attdef-set-base-point = Set Base Point model = .create = Maken .boolean = Booleaanse bewerkingen diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index f48cd9afb..ba1116512 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -4019,6 +4019,18 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .attdef-on-screen = Specify on-screen + .attdef-insertion-point = Insertion Point + .attdef-insert-field = Insert field + .attdef-text-settings = Text Settings + .attdef-align-below = Align below previous attribute definition + .attdef-define = Define + .attdef-edit-title = Edit Attribute Definition + .attdef-edit-help = Changes the tag, prompt and default value of an attribute definition. + .attdef-help = Defines an attribute: a tag, prompt and default value placed as text in a block definition. + .attdef-define-attributes = Define Attributes + .attdef-manage-attributes = Manage Attributes + .attdef-set-base-point = Set Base Point model = .create = Utwórz .boolean = Operacje boolowskie diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index ca46a1361..b7fc9bbe4 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -4020,6 +4020,18 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .attdef-on-screen = Specify on-screen + .attdef-insertion-point = Insertion Point + .attdef-insert-field = Insert field + .attdef-text-settings = Text Settings + .attdef-align-below = Align below previous attribute definition + .attdef-define = Define + .attdef-edit-title = Edit Attribute Definition + .attdef-edit-help = Changes the tag, prompt and default value of an attribute definition. + .attdef-help = Defines an attribute: a tag, prompt and default value placed as text in a block definition. + .attdef-define-attributes = Define Attributes + .attdef-manage-attributes = Manage Attributes + .attdef-set-base-point = Set Base Point model = .create = Criar .boolean = Operações booleanas diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index dc296c1f3..92913c7ae 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -4021,6 +4021,18 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .attdef-on-screen = Specify on-screen + .attdef-insertion-point = Insertion Point + .attdef-insert-field = Insert field + .attdef-text-settings = Text Settings + .attdef-align-below = Align below previous attribute definition + .attdef-define = Define + .attdef-edit-title = Edit Attribute Definition + .attdef-edit-help = Changes the tag, prompt and default value of an attribute definition. + .attdef-help = Defines an attribute: a tag, prompt and default value placed as text in a block definition. + .attdef-define-attributes = Define Attributes + .attdef-manage-attributes = Manage Attributes + .attdef-set-base-point = Set Base Point model = .create = Создание .boolean = Булевы операции diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 0c73a6649..83ea6e9bc 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -3980,6 +3980,18 @@ insert = .pcs-positive = Pozitif bir sayı gerekir. .pcs-positive-int = Pozitif bir tamsayı gerekir. .pcs-help = Canlı kesitin korunan tarafındaki noktaları çizgilerle izler. + .attdef-on-screen = Ekranda belirt + .attdef-insertion-point = Ekleme Noktası + .attdef-insert-field = Alan ekle + .attdef-text-settings = Metin Ayarları + .attdef-align-below = Önceki öznitelik tanımının altına hizala + .attdef-define = Tanımla + .attdef-edit-title = Öznitelik Tanımını Düzenle + .attdef-edit-help = Bir öznitelik tanımının etiketini, istemini ve varsayılan değerini değiştirir. + .attdef-help = Bir öznitelik tanımlar: blok tanımına metin olarak yerleştirilen etiket, istem ve varsayılan değer. + .attdef-define-attributes = Öznitelik Tanımla + .attdef-manage-attributes = Öznitelikleri Yönet + .attdef-set-base-point = Taban Noktası Belirle model = .create = Oluştur .boolean = Boole işlemleri diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index be1fdc04e..6357efdd5 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -4003,6 +4003,18 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .attdef-on-screen = Specify on-screen + .attdef-insertion-point = Insertion Point + .attdef-insert-field = Insert field + .attdef-text-settings = Text Settings + .attdef-align-below = Align below previous attribute definition + .attdef-define = Define + .attdef-edit-title = Edit Attribute Definition + .attdef-edit-help = Changes the tag, prompt and default value of an attribute definition. + .attdef-help = Defines an attribute: a tag, prompt and default value placed as text in a block definition. + .attdef-define-attributes = Define Attributes + .attdef-manage-attributes = Manage Attributes + .attdef-set-base-point = Set Base Point model = .create = 创建 .boolean = 布尔运算 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 38707859d..f5f77d4e8 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -4015,6 +4015,18 @@ insert = .pcs-positive = Requires a positive number. .pcs-positive-int = Requires a positive integer. .pcs-help = Traces the points on the kept side of a live section as lines. + .attdef-on-screen = Specify on-screen + .attdef-insertion-point = Insertion Point + .attdef-insert-field = Insert field + .attdef-text-settings = Text Settings + .attdef-align-below = Align below previous attribute definition + .attdef-define = Define + .attdef-edit-title = Edit Attribute Definition + .attdef-edit-help = Changes the tag, prompt and default value of an attribute definition. + .attdef-help = Defines an attribute: a tag, prompt and default value placed as text in a block definition. + .attdef-define-attributes = Define Attributes + .attdef-manage-attributes = Manage Attributes + .attdef-set-base-point = Set Base Point model = .create = 建立 .boolean = 布林運算 diff --git a/src/app/alias.rs b/src/app/alias.rs index 0f47f5b1e..931e1fa3d 100644 --- a/src/app/alias.rs +++ b/src/app/alias.rs @@ -80,7 +80,7 @@ const WEB_ALIAS_KEY: &str = "opencadstudio.aliases"; /// migrated forward (see `introduced_at` / `migrate_aliases`). Version 1 shipped /// the pre-REDRAW table; version 2 introduced the REDRAW-family aliases; version 3 /// introduced HB (HATCHTOBACK); version 4 introduced ER (EXTERNALREFERENCES). -const DEFAULT_ALIASES_VERSION: u32 = 4; +const DEFAULT_ALIASES_VERSION: u32 = 5; #[cfg(target_arch = "wasm32")] const WEB_ALIAS_VERSION_KEY: &str = "opencadstudio.aliases.version"; @@ -99,6 +99,7 @@ fn introduced_at(version: u32) -> &'static [(&'static str, &'static str)] { ], 3 => &[("HB", "HATCHTOBACK")], 4 => &[("ER", "EXTERNALREFERENCES")], + 5 => &[("-ATT", "-ATTDEF")], _ => &[], } } diff --git a/src/app/commands/attdef.rs b/src/app/commands/attdef.rs new file mode 100644 index 000000000..65dca2328 --- /dev/null +++ b/src/app/commands/attdef.rs @@ -0,0 +1,320 @@ +//! ATTDEF (dialog), -ATTDEF (command line), AFLAGS, and the Edit Attribute +//! Definition dialog opened by double-click / TEXTEDIT / DDEDIT. + +use crate::app::{Message, OpenCADStudio}; +use crate::command::CadCommand; +use crate::modules::draw::draw::attdef::{ + self as attdef, AttdefCommand, AttdefPickCommand, AttdefPlaceCommand, AttdefSpec, Justify, +}; +use crate::ui::window::attdef_dialog::{ + tag_error, AttdefDialogMsg, AttdefDialogState, AttdefEditState, +}; +use iced::Task; + +fn short(v: f64) -> String { + let t = format!("{v:.4}"); + t.trim_end_matches('0').trim_end_matches('.').to_string() +} + +impl OpenCADStudio { + pub(super) fn dispatch_attdef(&mut self, cmd: &str, i: usize) -> Option> { + if matches!(cmd, "ATTDEF" | "-ATTDEF") { + // Placement measures the tag with its style's font. + crate::entities::common::set_fixed_text_heights(&self.tabs[i].scene.document); + } + match cmd { + "ATTDEF" => { + self.open_attdef_dialog(i); + Some(Task::none()) + } + "-ATTDEF" => { + let defaults = + crate::scene::creation_style::current_text_defaults(&self.tabs[i].scene.document); + let styles: Vec<_> = self.tabs[i].scene.document.text_styles.iter().cloned().collect(); + let command = AttdefCommand::new( + "-ATTDEF", + defaults.style_name, + defaults.height, + defaults.width_factor, + defaults.oblique_angle, + styles, + ); + self.reset_command_start_state(i); + self.command_line.push_output(&command.intro()); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + self.push_ucs_to_cmd(i); + Some(Task::none()) + } + cmd if cmd == "_ATTDEF_PICKED" || cmd.starts_with("_ATTDEF_PICKED ") => { + let mut parts = cmd.split_whitespace().skip(1); + if let (Some(kind), Some(value), Some(state)) = + (parts.next(), parts.next(), self.attdef_dialog.as_mut()) + { + match kind { + "H" => state.height = value.to_string(), + "R" => state.rotation = value.to_string(), + _ => state.width = value.to_string(), + } + } + if self.attdef_dialog.is_some() { + self.active_modal = Some(crate::app::ModalKind::AttDef); + } + Some(Task::none()) + } + _ => self.dispatch_aflags(cmd), + } + } + + /// AFLAGS: the session's attribute modes (0–63, default 16). + fn dispatch_aflags(&mut self, cmd: &str) -> Option> { + let rest = cmd.strip_prefix("SETVAR ").map(str::trim).unwrap_or(cmd); + let mut parts = rest.splitn(2, char::is_whitespace); + if !parts.next().unwrap_or("").eq_ignore_ascii_case("AFLAGS") { + return None; + } + let current = attdef::session().aflags; + match parts.next().map(str::trim).filter(|v| !v.is_empty()) { + None => { + self.command_line + .push_output(&format!("Enter new value for AFLAGS <{current}>:")); + self.pending_setvar = Some("AFLAGS".to_string()); + } + Some(v) => match v.parse::().ok().filter(|v| *v <= 63) { + Some(v) => attdef::session().aflags = v, + None => { + self.command_line.push_error("Requires an integer between 0 and 63."); + self.command_line + .push_output(&format!("Enter new value for AFLAGS <{current}>:")); + self.pending_setvar = Some("AFLAGS".to_string()); + } + }, + } + Some(Task::none()) + } + + fn open_attdef_dialog(&mut self, i: usize) { + let document = &self.tabs[i].scene.document; + let defaults = crate::scene::creation_style::current_text_defaults(document); + let mut styles: Vec = document + .text_styles + .iter() + .filter(|s| !s.name.is_empty()) + .map(|s| s.name.clone()) + .collect(); + styles.sort_by_key(|s| s.to_lowercase()); + let session = attdef::session(); + self.attdef_dialog = Some(AttdefDialogState { + aflags: session.aflags, + annotative: session.annotative, + tag: String::new(), + prompt: String::new(), + default: String::new(), + justify: Justify::Left, + style: defaults.style_name, + styles, + height: short(defaults.height), + rotation: "0".into(), + width: "0".into(), + on_screen: true, + coords: ["0".into(), "0".into(), "0".into()], + align_below: false, + can_align_below: session.last.is_some(), + }); + drop(session); + self.tabs[i].active_cmd = None; + self.active_modal = Some(crate::app::ModalKind::AttDef); + } + + /// The Edit Attribute Definition dialog for `handle`. + pub(in crate::app) fn open_attdef_edit(&mut self, handle: codec::Handle) { + let i = self.active_tab; + let Some(codec::EntityType::AttributeDefinition(a)) = self.tabs[i].scene.document.get_entity(handle) + else { + return; + }; + self.attdef_edit = Some(AttdefEditState { + handle, + tag: a.tag.clone(), + prompt: a.prompt.clone(), + default: a.default_value.clone(), + constant: a.flags.constant, + }); + self.active_modal = Some(crate::app::ModalKind::AttDefEdit); + } + + pub(in crate::app) fn on_attdef_dialog(&mut self, m: AttdefDialogMsg) -> Task { + let i = self.active_tab; + if let Some(edit) = self.attdef_edit.as_mut() { + match m { + AttdefDialogMsg::EditTag(v) => edit.tag = v, + AttdefDialogMsg::EditPrompt(v) => edit.prompt = v, + AttdefDialogMsg::EditDefault(v) => edit.default = v, + AttdefDialogMsg::Help => self.command_line.push_info( + crate::t!("Changes the tag, prompt and default value of an attribute definition.") + .as_ref(), + ), + AttdefDialogMsg::EditOk => return self.attdef_edit_ok(i), + _ => {} + } + return Task::none(); + } + let Some(state) = self.attdef_dialog.as_mut() else { + return Task::none(); + }; + match m { + AttdefDialogMsg::Mode(bit, on) => { + if on { + state.aflags |= bit; + } else { + state.aflags &= !bit; + } + } + AttdefDialogMsg::Annotative(v) => state.annotative = v, + AttdefDialogMsg::Tag(v) => state.tag = v, + AttdefDialogMsg::Prompt(v) => state.prompt = v, + AttdefDialogMsg::Default(v) => state.default = v, + AttdefDialogMsg::Justify(c) => state.justify = c.0, + AttdefDialogMsg::Style(v) => state.style = v, + AttdefDialogMsg::Height(v) => state.height = v, + AttdefDialogMsg::Rotation(v) => state.rotation = v, + AttdefDialogMsg::Width(v) => state.width = v, + AttdefDialogMsg::OnScreen(v) => state.on_screen = v, + AttdefDialogMsg::Coord(k, v) => state.coords[k] = v, + AttdefDialogMsg::AlignBelow(v) => state.align_below = v, + AttdefDialogMsg::PickHeight | AttdefDialogMsg::PickRotation | AttdefDialogMsg::PickWidth => { + let (kind, current) = match m { + AttdefDialogMsg::PickHeight => ("H", state.height.clone()), + AttdefDialogMsg::PickRotation => ("R", state.rotation.clone()), + _ => ("W", state.width.clone()), + }; + // Keep the dialog's fields; it reopens when the value is in. + self.active_modal = None; + let command = AttdefPickCommand::new(kind, current); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + self.push_ucs_to_cmd(i); + return self.focus_cmd_input(); + } + AttdefDialogMsg::Help => self.command_line.push_info( + crate::t!("Defines an attribute: a tag, prompt and default value placed as text in a block definition.") + .as_ref(), + ), + AttdefDialogMsg::Ok => return self.attdef_dialog_ok(i), + _ => {} + } + Task::none() + } + + fn attdef_dialog_ok(&mut self, i: usize) -> Task { + let Some(state) = self.attdef_dialog.clone() else { + return Task::none(); + }; + if let Some(error) = tag_error(&state.tag) { + self.command_line.push_error(error); + return Task::none(); + } + let number = |s: &str| s.trim().parse::().ok().filter(|v| v.is_finite()); + let height = match number(&state.height) { + Some(v) if v > 0.0 => v, + _ => { + self.command_line.push_error("Value must be positive and nonzero."); + return Task::none(); + } + }; + let Some(rotation) = number(&state.rotation) else { + self.command_line.push_error("Requires valid numeric angle or second point."); + return Task::none(); + }; + let width = number(&state.width).unwrap_or(0.0).max(0.0); + let defaults = + crate::scene::creation_style::current_text_defaults(&self.tabs[i].scene.document); + let spec = AttdefSpec { + aflags: state.aflags, + annotative: state.annotative, + tag: state.tag.clone(), + prompt: state.prompt.clone(), + value: state.default.clone(), + justify: state.justify, + style: state.style.clone(), + height, + rotation: rotation.to_radians(), + width_factor: defaults.width_factor, + oblique_angle: defaults.oblique_angle, + boundary_width: width, + line_spacing: 1.0, + }; + self.attdef_dialog = None; + self.close_active_modal(); + { + let mut session = attdef::session(); + session.aflags = spec.aflags; + session.annotative = spec.annotative; + } + self.reset_command_start_state(i); + if state.align_below { + let previous = attdef::session().last.clone(); + if let Some(previous) = previous { + let entity = attdef::build_below(&spec, &previous); + self.tabs[i].active_cmd = Some(Box::new(AttdefPlaceCommand::new(spec.clone()))); + attdef::remember(&spec, &entity); + return self.apply_cmd_result(crate::command::CmdResult::CommitAndExit(entity)); + } + } + let plane = if self.tabs[i].editing_model_space() { + self.tabs[i].ucs_xform().working_plane() + } else { + crate::command::WorkingPlane::default() + }; + let mut command = AttdefPlaceCommand::new(spec); + command.set_working_plane(plane); + if state.on_screen { + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + return self.focus_cmd_input(); + } + let local = glam::DVec3::new( + number(&state.coords[0]).unwrap_or(0.0), + number(&state.coords[1]).unwrap_or(0.0), + number(&state.coords[2]).unwrap_or(0.0), + ); + let result = command.on_point(plane.to_world(local)); + self.tabs[i].active_cmd = Some(Box::new(command)); + self.apply_cmd_result(result) + } + + fn attdef_edit_ok(&mut self, i: usize) -> Task { + let Some(state) = self.attdef_edit.clone() else { + return Task::none(); + }; + if let Some(error) = tag_error(&state.tag) { + self.command_line.push_error(error); + return Task::none(); + } + self.attdef_edit = None; + self.close_active_modal(); + let changed = matches!( + self.tabs[i].scene.document.get_entity(state.handle), + Some(codec::EntityType::AttributeDefinition(a)) + if a.tag != attdef::normalize_tag(&state.tag) || a.prompt != state.prompt || a.default_value != state.default + ); + if changed && !self.reject_locked_edit(i, state.handle) { + self.push_undo_snapshot(i, "DDEDIT"); + if let Some(codec::EntityType::AttributeDefinition(a)) = + self.tabs[i].scene.document.get_entity_mut(state.handle) + { + a.tag = attdef::normalize_tag(&state.tag); + if !a.flags.constant { + a.prompt = state.prompt.clone(); + } + a.default_value = state.default.clone(); + } + self.tabs[i] + .scene + .bump_entities(&[(state.handle, crate::scene::ChangeKind::Modified)]); + self.tabs[i].dirty = true; + self.refresh_properties(); + } + self.post_editor_closed(changed) + } +} diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index ba5fee7f4..e85c5c522 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -79,21 +79,6 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(command)); } - "ATTDEF" => { - use crate::modules::draw::draw::attdef::AttdefCommand; - let defaults = crate::scene::creation_style::current_text_defaults( - &self.tabs[i].scene.document, - ); - let cmd = AttdefCommand::with_text_defaults( - defaults.height, - defaults.style_name, - defaults.width_factor, - defaults.oblique_angle, - ); - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); - } - // Command-line attribute editing on selected Insert entities. Bare // ATTEDIT and the ATE alias launch the interactive editor instead // (see the ATTEDIT arm in the inquiry family); the dash form is the diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index c39a58a6a..ac09175ad 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -4,6 +4,7 @@ use crate::scene::Scene; use iced::Task; use std::path::PathBuf; +mod attdef; mod blocks; mod xref_attach; mod dim; @@ -278,6 +279,9 @@ impl OpenCADStudio { if let Some(t) = self.dispatch_pc_extract(cmd, i) { return Some(t); } + if let Some(t) = self.dispatch_attdef(cmd, i) { + return Some(t); + } if let Some(t) = self.dispatch_draw(cmd, i) { return Some(t); } diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 274f1f1da..3c4a9b7c5 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -79,6 +79,7 @@ pub(super) const NAMES: &[&str] = &[ "ribbon_tab", "ribbon_dropdown", "dialog_ok", + "attdef_dialog", "close_document", "toggle_properties", "toggle_layers", @@ -788,6 +789,47 @@ impl OpenCADStudio { } return Ok(Task::none()); } + // One field of the open attribute definition dialog: `tag=…`, + // `prompt=…`, `default=…`, `justify=MC`, `style=…`, `height=…`, + // `rotation=…`, `width=…`, `on_screen=0|1`, `x=…` / `y=…` / `z=…`, + // `mode=:0|1`, `annotative=0|1`, `align_below=0|1`. + "attdef_dialog" => { + use crate::ui::window::attdef_dialog::{AttdefDialogMsg as M, JustifyChoice}; + let value = string(req, "value")?; + let (key, v) = value.split_once('=').unwrap_or((value, "")); + let on = v == "1"; + let editing = self.active_modal == Some(crate::app::ModalKind::AttDefEdit); + Message::AttdefDialog(match key { + "tag" if editing => M::EditTag(v.into()), + "prompt" if editing => M::EditPrompt(v.into()), + "default" if editing => M::EditDefault(v.into()), + "tag" => M::Tag(v.into()), + "prompt" => M::Prompt(v.into()), + "default" => M::Default(v.into()), + "style" => M::Style(v.into()), + "height" => M::Height(v.into()), + "rotation" => M::Rotation(v.into()), + "width" => M::Width(v.into()), + "on_screen" => M::OnScreen(on), + "annotative" => M::Annotative(on), + "align_below" => M::AlignBelow(on), + "x" => M::Coord(0, v.into()), + "y" => M::Coord(1, v.into()), + "z" => M::Coord(2, v.into()), + "justify" => M::Justify(JustifyChoice( + crate::modules::draw::draw::attdef::Justify::from_keyword(v) + .ok_or_else(|| failure("invalid_value", "Unknown justification"))?, + )), + "mode" => { + let (bit, state) = v.split_once(':').unwrap_or((v, "1")); + let bit = bit + .parse::() + .map_err(|_| failure("invalid_value", "mode=:0|1"))?; + M::Mode(bit, state == "1") + } + _ => return Err(failure("invalid_value", "Unknown attdef dialog field")), + }) + } "ribbon_dropdown" => Message::ToggleRibbonDropdown(string(req, "value")?.into()), "pdf_dialog_ok" => { use crate::ui::window::pdf_dialogs::PdfDialogMsg; @@ -808,6 +850,12 @@ impl OpenCADStudio { crate::ui::window::xref_attach::XrefAttachMsg::Apply, ), Some(crate::app::ModalKind::BlockDefinition) => Message::BlockDefApply, + Some(crate::app::ModalKind::AttDef) => Message::AttdefDialog( + crate::ui::window::attdef_dialog::AttdefDialogMsg::Ok, + ), + Some(crate::app::ModalKind::AttDefEdit) => Message::AttdefDialog( + crate::ui::window::attdef_dialog::AttdefDialogMsg::EditOk, + ), _ => return Err(failure("no_dialog", "No dialog with an OK button is open")), }, "close_document" => Message::TabClose(self.tabs[self.active_tab].id), diff --git a/src/app/mod.rs b/src/app/mod.rs index 65df40ec0..d24cbb9a5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -498,6 +498,9 @@ pub(super) struct OpenCADStudio { /// PDF dialogs' working copies; `None` while closed. pdf_attach: Option, point_cloud_attach: Option, + /// ATTDEF dialog and Edit Attribute Definition dialog; `None` while closed. + attdef_dialog: Option, + attdef_edit: Option, point_cloud_color_map: Option, pc_section: Option, underlay_layers: Option, @@ -1917,6 +1920,8 @@ pub enum ModalKind { XrefAttach, WriteBlock, GeometricTolerance, + AttDef, + AttDefEdit, DraftingSettings, AutoConstrainSettings, LayerStateEditor, @@ -3864,6 +3869,7 @@ pub enum Message { PdfImportPick, /// An edit in one of the PDF dialogs. PdfDialog(crate::ui::window::pdf_dialogs::PdfDialogMsg), + AttdefDialog(crate::ui::window::attdef_dialog::AttdefDialogMsg), /// A click or search in the Point Cloud Manager. PcManager(crate::ui::window::pc_manager::PcManagerMsg), PdfImportPickResult(Result<(std::path::PathBuf, std::sync::Arc>), String>), @@ -4057,6 +4063,8 @@ impl OpenCADStudio { block_definition: None, pdf_attach: None, point_cloud_attach: None, + attdef_dialog: None, + attdef_edit: None, point_cloud_color_map: None, pc_section: None, underlay_layers: None, diff --git a/src/app/text_inline.rs b/src/app/text_inline.rs index 13c2b97f4..dd1a7bdd9 100644 --- a/src/app/text_inline.rs +++ b/src/app/text_inline.rs @@ -155,6 +155,13 @@ impl super::OpenCADStudio { self.open_tolerance_dialog(Some(target)); return iced::Task::none(); } + if matches!( + self.tabs[i].scene.document.get_entity(target), + Some(EntityType::AttributeDefinition(_)) + ) { + self.open_attdef_edit(target); + return iced::Task::none(); + } // Snapshot what we need before borrowing `self` mutably to open. let Some(entity) = self.tabs[i].scene.document.get_entity(target) else { return iced::Task::none(); diff --git a/src/app/update/command.rs b/src/app/update/command.rs index e9917ffdc..420fa65c6 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -3627,6 +3627,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { return Task::none(); } } + if field.starts_with("att_") { + if let Err(message) = + crate::entities::attribute::validate_definition_property(field, &val) + { + self.command_line.push_error(message); + return Task::none(); + } + } if field.starts_with("ul_") { if let Err(message) = crate::entities::underlay::validate_property(field, &val) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index ce1d7ccd3..7702bf6a6 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1806,6 +1806,7 @@ impl OpenCADStudio { } Message::PdfDialog(message) => self.update_pdf_dialog(message), + Message::AttdefDialog(message) => self.on_attdef_dialog(message), Message::PcManager(message) => self.update_pc_manager(message), Message::RibbonSelectTab(idx) => { self.ribbon.select(idx); @@ -8496,6 +8497,15 @@ impl OpenCADStudio { } Message::CloseModal => { + if matches!( + self.active_modal, + Some(super::ModalKind::AttDef | super::ModalKind::AttDefEdit) + ) { + self.attdef_dialog = None; + let editing = self.attdef_edit.take().is_some(); + self.close_active_modal(); + return if editing { self.post_editor_closed(false) } else { Task::none() }; + } // Cancelling the section settings drops the waiting extraction. if self.active_modal == Some(super::ModalKind::PcSection) { crate::modules::insert::pc_extract::drop_job(); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 571cf3dd4..0b2a697b9 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -38,6 +38,8 @@ impl OpenCADStudio { Some(K::XrefAttach) => crate::t!("Attach External Reference").into_owned(), Some(K::WriteBlock) => crate::t!("Write Block").into_owned(), Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(), + Some(K::AttDef) => crate::t!("Attribute Definition").into_owned(), + Some(K::AttDefEdit) => crate::t!("Edit Attribute Definition").into_owned(), Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(), Some(K::AutoConstrainSettings) => crate::t!("Constraint Settings").into_owned(), Some(K::LayerStateEditor) => crate::tr!("modal", "edit-layer-state"), @@ -572,6 +574,14 @@ impl OpenCADStudio { crate::ui::window::wblock::view_window(state, flow) }) } + super::super::ModalKind::AttDef => { + let state = self.attdef_dialog.as_ref()?; + sized_flow(ex, 720, 520, |flow| crate::ui::window::attdef_dialog::view(state, flow)) + } + super::super::ModalKind::AttDefEdit => { + let state = self.attdef_edit.as_ref()?; + sized_flow(ex, 460, 220, |flow| crate::ui::window::attdef_dialog::view_edit(state, flow)) + } super::super::ModalKind::GeometricTolerance => { let state = self.geometric_tolerance.as_ref()?; sized_flow(ex, 670, 530, |flow| { diff --git a/src/entities/attribute.rs b/src/entities/attribute.rs index d2fc9e838..2247cfc29 100644 --- a/src/entities/attribute.rs +++ b/src/entities/attribute.rs @@ -339,17 +339,11 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &codec::CadDocument) - impl RenderConvertible for AttributeDefinition { fn to_render(&self, document: &codec::CadDocument) -> Option { - // An attribute definition previews its tag when it has no default - // value, so the placeholder is visible where a block will prompt for - // input. (Passed as a non-empty value, so it renders as-is.) - let display_value = if self.default_value.is_empty() { - self.tag.clone() - } else { - self.default_value.clone() - }; + // A definition shows its tag; the value appears only on the block + // references (block content renders a constant one's value itself). Some(build_attr_render( AttrTextInputs { - value: &display_value, + value: &self.tag, insertion_point: self.insertion_point, alignment_point: self.alignment_point, height: self.height, @@ -361,82 +355,262 @@ impl RenderConvertible for AttributeDefinition { horizontal_alignment: self.horizontal_alignment, vertical_alignment: self.vertical_alignment, normal: self.normal, - mtext_flag: self.mtext_flag, - is_multiline: self.is_multiline, - line_count: self.line_count, + // The tag is one line even on a multi-line definition. + mtext_flag: MTextFlag::SingleLine, + is_multiline: false, + line_count: 1, }, document, )) } } +fn is_plain_left(a: &AttributeDefinition) -> bool { + a.is_multiline + || matches!( + (a.horizontal_alignment, a.vertical_alignment), + (AHA::Left, AVA::Baseline) + ) +} + +fn is_two_point(a: &AttributeDefinition) -> bool { + !a.is_multiline && matches!(a.horizontal_alignment, AHA::Aligned | AHA::Fit) +} + +fn dv(p: Vector3) -> glam::DVec3 { + glam::DVec3::new(p.x, p.y, p.z) +} + +fn shift(p: &mut Vector3, d: glam::DVec3) { + p.x += d.x; + p.y += d.y; + p.z += d.z; +} + impl Grippable for AttributeDefinition { + /// Left text has one grip at its start; every other justification adds + /// the alignment point. Lock position only pins attributes on block + /// references, never the definition itself. fn grips(&self) -> Vec { - // lock_position blocks the position grip. Show an empty grip set so - // the renderer can't drag the entity in either AttributeFlags or - // top-level lock_position is on. - if self.lock_position || self.flags.locked_position { - return vec![]; + let mut grips = vec![square_grip(0, dv(self.insertion_point))]; + if !is_plain_left(self) { + grips.push(square_grip(1, dv(self.alignment_point))); } - vec![square_grip( - 0, - glam::DVec3::new( - self.insertion_point.x, - self.insertion_point.y, - self.insertion_point.z, - ), - )] + grips } fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { - if self.lock_position || self.flags.locked_position { + let current = if grip_id == 1 { self.alignment_point } else { self.insertion_point }; + let d = match apply { + GripApply::Translate(d) => glam::DVec3::new(d.x as f64, d.y as f64, d.z as f64), + GripApply::Absolute(p) => glam::DVec3::new(p.x as f64, p.y as f64, p.z as f64) - dv(current), + }; + // A two-point definition stretches the moved endpoint; any other + // grip moves the whole text. + if is_two_point(self) { + if grip_id == 1 { + shift(&mut self.alignment_point, d); + } else { + shift(&mut self.insertion_point, d); + } return; } - if grip_id == 0 { - match apply { - GripApply::Translate(d) => { - self.insertion_point.x += d.x as f64; - self.insertion_point.y += d.y as f64; - self.insertion_point.z += d.z as f64; - self.alignment_point.x += d.x as f64; - self.alignment_point.y += d.y as f64; - self.alignment_point.z += d.z as f64; - } - GripApply::Absolute(p) => { - self.insertion_point.x = p.x as f64; - self.insertion_point.y = p.y as f64; - self.insertion_point.z = p.z as f64; - self.alignment_point.x = p.x as f64; - self.alignment_point.y = p.y as f64; - self.alignment_point.z = p.z as f64; - } - } + shift(&mut self.insertion_point, d); + shift(&mut self.alignment_point, d); + if let Some(m) = self.embedded_mtext.as_mut() { + shift(&mut m.insertion_point, d); } } } +const JUSTIFY_OPTIONS: [&str; 15] = [ + "Left", + "Align", + "Fit", + "Center", + "Middle", + "Right", + "Top left", + "Top center", + "Top right", + "Middle left", + "Middle center", + "Middle right", + "Bottom left", + "Bottom center", + "Bottom right", +]; + +fn justify_label(a: &AttributeDefinition) -> &'static str { + match (a.horizontal_alignment, a.vertical_alignment) { + (AHA::Aligned, _) => "Align", + (AHA::Fit, _) => "Fit", + (AHA::Middle, _) => "Middle", + (AHA::Left, AVA::Baseline) => "Left", + (AHA::Center, AVA::Baseline) => "Center", + (AHA::Right, AVA::Baseline) => "Right", + (AHA::Left, AVA::Top) => "Top left", + (AHA::Center, AVA::Top) => "Top center", + (AHA::Right, AVA::Top) => "Top right", + (AHA::Left, AVA::Middle) => "Middle left", + (AHA::Center, AVA::Middle) => "Middle center", + (AHA::Right, AVA::Middle) => "Middle right", + (AHA::Left, AVA::Bottom) => "Bottom left", + (AHA::Center, AVA::Bottom) => "Bottom center", + (AHA::Right, AVA::Bottom) => "Bottom right", + } +} + +fn parse_justify(s: &str) -> Option<(AHA, AVA)> { + Some(match s { + "Left" => (AHA::Left, AVA::Baseline), + "Align" | "Aligned" => (AHA::Aligned, AVA::Baseline), + "Fit" => (AHA::Fit, AVA::Baseline), + "Center" => (AHA::Center, AVA::Baseline), + "Middle" => (AHA::Middle, AVA::Baseline), + "Right" => (AHA::Right, AVA::Baseline), + "Top left" => (AHA::Left, AVA::Top), + "Top center" => (AHA::Center, AVA::Top), + "Top right" => (AHA::Right, AVA::Top), + "Middle left" => (AHA::Left, AVA::Middle), + "Middle center" => (AHA::Center, AVA::Middle), + "Middle right" => (AHA::Right, AVA::Middle), + "Bottom left" => (AHA::Left, AVA::Bottom), + "Bottom center" => (AHA::Center, AVA::Bottom), + "Bottom right" => (AHA::Right, AVA::Bottom), + _ => return None, + }) +} + +/// Offset of the justification anchor from the baseline start, in the +/// text's own frame (the shown tag measured with its style's font). +fn justify_offset(a: &AttributeDefinition) -> Option<(f64, f64)> { + let b = text_local_bounds( + &crate::entities::common::style_font(&a.text_style), + &a.tag, + a.height as f32, + a.width_factor as f32, + a.oblique_angle as f32, + )?; + let ax = match a.horizontal_alignment { + AHA::Left => 0.0, + AHA::Center | AHA::Middle => b.advance as f64 * 0.5, + AHA::Right | AHA::Aligned | AHA::Fit => b.advance as f64, + }; + let ay = match a.vertical_alignment { + AVA::Baseline => 0.0, + AVA::Bottom => b.ink_min[1] as f64, + AVA::Middle => (b.ink_min[1] + b.ink_max[1]) as f64 * 0.5, + AVA::Top => b.ink_max[1] as f64, + }; + Some((ax, ay)) +} + +fn rotate(a: &AttributeDefinition, (x, y): (f64, f64)) -> (f64, f64) { + let (sin, cos) = a.rotation.sin_cos(); + (x * cos - y * sin, x * sin + y * cos) +} + +/// The baseline start a justified definition stores as its insertion point +/// (the alignment point minus the justification offset). +pub fn definition_text_start(a: &AttributeDefinition) -> Vector3 { + let Some(offset) = justify_offset(a) else { + return a.alignment_point; + }; + let (dx, dy) = rotate(a, offset); + let p = a.alignment_point; + Vector3::new(p.x - dx, p.y - dy, p.z) +} + +fn yes_no(flag: bool) -> PropValue { + PropValue::Choice { + selected: if flag { t!("Yes") } else { t!("No") }.into_owned(), + options: vec![t!("Yes").into_owned(), t!("No").into_owned()], + } +} + +fn is_yes(value: &str) -> bool { + value == t!("Yes").as_ref() || value.eq_ignore_ascii_case("yes") +} + +fn row(label: &str, field: &'static str, value: PropValue) -> Property { + Property { label: label.into(), field, value } +} + +fn set_bit(flags: i16, bit: i16, on: bool) -> i16 { + if on { + flags | bit + } else { + flags & !bit + } +} + +/// What the Properties palette refuses for a definition, with the message +/// the reference shows. +pub fn validate_definition_property(field: &str, value: &str) -> Result<(), &'static str> { + match field { + "att_tag" if value.trim().is_empty() || value.trim().contains(char::is_whitespace) => { + Err("Invalid argument Tag in setting TagString") + } + "att_h" | "att_wf" if !parse_f64(value).is_some_and(|v| v > 0.0) => Err("Invalid input"), + _ => Ok(()), + } +} + +/// Multiple lines on: the value moves into an embedded MText anchored at the +/// start point. Off: back to single-line Left text. +fn set_multiline(a: &mut AttributeDefinition, on: bool) { + if on == a.is_multiline { + return; + } + a.is_multiline = on; + if on { + a.mtext_flag = MTextFlag::ConstantMultiLine; + a.horizontal_alignment = AHA::Left; + a.vertical_alignment = AVA::Top; + a.alignment_point = a.insertion_point; + a.line_count = a.default_value.split("\\P").count().max(1) as i16; + let mut mtext = codec::entities::MText::new(); + mtext.value = a.default_value.clone(); + mtext.insertion_point = a.insertion_point; + mtext.height = a.height; + mtext.rotation = a.rotation; + mtext.style = a.text_style.clone(); + a.embedded_mtext = Some(Box::new(mtext)); + } else { + a.mtext_flag = MTextFlag::SingleLine; + a.horizontal_alignment = AHA::Left; + a.vertical_alignment = AVA::Baseline; + a.line_count = 1; + a.default_value = a.default_value.replace("\\P", " "); + a.embedded_mtext = None; + } +} + impl PropertyEditable for AttributeDefinition { fn geometry_properties(&self, text_style_names: &[String]) -> Vec { - let mut text_props = vec![ - Property { - label: t!("Tag").into_owned(), - field: "att_tag", - value: PropValue::PlainText(self.tag.clone()), + let left = is_plain_left(self); + // Text alignment is the anchor of every justification but Left. + let alignment = |label: std::borrow::Cow<'static, str>, field: &'static str, v: f64| { + if left { + ro(label.as_ref(), field, String::new()) + } else { + edit(label.as_ref(), field, v) + } + }; + let text_props = vec![ + row(t!("Tag").as_ref(), "att_tag", PropValue::PlainText(self.tag.clone())), + row(t!("Annotative").as_ref(), "att_annotative", yes_no(self.flags.annotative)), + if self.flags.constant { + ro(t!("Prompt").as_ref(), "att_prompt", self.prompt.clone()) + } else { + row(t!("Prompt").as_ref(), "att_prompt", PropValue::PlainText(self.prompt.clone())) }, - Property { - label: t!("Prompt").into_owned(), - field: "att_prompt", - value: PropValue::PlainText(self.prompt.clone()), - }, - Property { - label: t!("Value").into_owned(), - field: "att_default", - value: PropValue::PlainText(self.default_value.clone()), - }, - Property { - label: t!("Style").into_owned(), - field: "att_style", - value: PropValue::Choice { + row(t!("Value").as_ref(), "att_default", PropValue::PlainText(self.default_value.clone())), + row( + t!("Style").as_ref(), + "att_style", + PropValue::Choice { selected: if self.text_style.trim().is_empty() { "Standard".into() } else { @@ -444,33 +618,18 @@ impl PropertyEditable for AttributeDefinition { }, options: text_style_names.to_vec(), }, - }, - ro( - t!("Annotative").as_ref(), - "att_annotative", - bool_yn(self.flags.annotative), ), - Property { - label: t!("Justify").into_owned(), - field: "att_halign", - value: PropValue::Choice { - selected: halign_str(self.horizontal_alignment).to_string(), - options: ["Left", "Center", "Right", "Aligned", "Middle", "Fit"] - .into_iter() - .map(str::to_string) - .collect(), - }, - }, - Property { - label: t!("V-Align").into_owned(), - field: "att_valign", - value: PropValue::Choice { - selected: valign_str(self.vertical_alignment).to_string(), - options: ["Baseline", "Bottom", "Middle", "Top"] - .into_iter() - .map(str::to_string) - .collect(), - }, + if self.is_multiline { + ro(t!("Justify").as_ref(), "att_justify", t!(justify_label(self)).into_owned()) + } else { + row( + t!("Justify").as_ref(), + "att_justify", + PropValue::Choice { + selected: justify_label(self).to_string(), + options: JUSTIFY_OPTIONS.into_iter().map(str::to_string).collect(), + }, + ) }, // Fixed by the style when the style fixes its height. crate::entities::common::num_prop( @@ -482,125 +641,148 @@ impl PropertyEditable for AttributeDefinition { edit_angle(t!("Rotation").as_ref(), "att_rot", self.rotation.to_degrees()), edit(t!("Width factor").as_ref(), "att_wf", self.width_factor), edit_angle(t!("Obliquing").as_ref(), "att_ob", self.oblique_angle.to_degrees()), - edit(t!("Text alignment X").as_ref(), "att_ax", self.alignment_point.x), - edit(t!("Text alignment Y").as_ref(), "att_ay", self.alignment_point.y), - edit(t!("Text alignment Z").as_ref(), "att_az", self.alignment_point.z), - ro( - t!("Boundary width").as_ref(), - "att_field_len", - self.field_length.to_string(), - ), - ro( - t!("Upside down").as_ref(), - "att_upside_down", - bool_yn(self.text_generation_flags & 0x4 != 0), - ), - ro( - t!("Backward").as_ref(), - "att_backward", - bool_yn(self.text_generation_flags & 0x2 != 0), - ), + ro(t!("Direction").as_ref(), "att_direction", t!("By style").into_owned()), + if self.is_multiline { + edit( + t!("Boundary width").as_ref(), + "att_bwidth", + self.embedded_mtext.as_ref().map_or(0.0, |m| m.rectangle_width), + ) + } else { + ro(t!("Boundary width").as_ref(), "att_bwidth", String::new()) + }, + alignment(t!("Text alignment X"), "att_ax", self.alignment_point.x), + alignment(t!("Text alignment Y"), "att_ay", self.alignment_point.y), + alignment(t!("Text alignment Z"), "att_az", self.alignment_point.z), ]; - // Constant attributes can't be edited at insert time — surface that - // by marking the Value field read-only. - if self.flags.constant { - if let Some(p) = text_props.iter_mut().find(|p| p.field == "att_default") { - p.value = PropValue::ReadOnly(self.default_value.clone()); - } - } vec![ - PropSection { - title: t!("Text").into_owned(), - props: text_props, - }, - PropSection { - title: t!("Geometry").into_owned(), - props: vec![ - edit(t!("Position X").as_ref(), "att_ix", self.insertion_point.x), - edit(t!("Position Y").as_ref(), "att_iy", self.insertion_point.y), - edit(t!("Position Z").as_ref(), "att_iz", self.insertion_point.z), - ], - }, + PropSection { title: t!("Text").into_owned(), props: text_props }, PropSection { title: t!("Misc").into_owned(), props: vec![ - ro(t!("Invisible").as_ref(), "att_invisible", bool_yn(self.flags.invisible)), - ro(t!("Constant").as_ref(), "att_constant", bool_yn(self.flags.constant)), - ro(t!("Verify").as_ref(), "att_verify", bool_yn(self.flags.verify)), - ro(t!("Preset").as_ref(), "att_preset", bool_yn(self.flags.preset)), - ro(t!("Lock position").as_ref(), "att_lock_pos", bool_yn(self.lock_position)), - Property { - label: t!("Multiple lines").into_owned(), - field: "att_mtext_flag", - value: PropValue::Choice { - selected: mtext_flag_str(self.mtext_flag).to_string(), - options: ["SingleLine", "MultiLine", "ConstantMultiLine"] - .into_iter() - .map(str::to_string) - .collect(), - }, - }, + row(t!("Upside down").as_ref(), "att_upside_down", yes_no(self.text_generation_flags & 0x4 != 0)), + row(t!("Backward").as_ref(), "att_backward", yes_no(self.text_generation_flags & 0x2 != 0)), + row(t!("Invisible").as_ref(), "att_invisible", yes_no(self.flags.invisible)), + row(t!("Constant").as_ref(), "att_constant", yes_no(self.flags.constant)), + row(t!("Verify").as_ref(), "att_verify", yes_no(self.flags.verify)), + row(t!("Preset").as_ref(), "att_preset", yes_no(self.flags.preset)), + row(t!("Multiple lines").as_ref(), "att_multiline", yes_no(self.is_multiline)), + row( + t!("Lock position").as_ref(), + "att_lock_pos", + yes_no(self.lock_position || self.flags.locked_position), + ), ], }, ] } fn apply_geom_prop(&mut self, field: &str, value: &str) { - // String / enum fields first. - if field == "att_tag" { - self.tag = value.to_string(); + if validate_definition_property(field, value).is_err() { return; } - if field == "att_prompt" { - self.prompt = value.to_string(); - return; - } - if field == "att_default" { - if !self.flags.constant { - self.default_value = value.to_string(); - } - return; - } - if field == "att_style" { - self.text_style = value.to_string(); - return; - } - if field == "att_halign" { - if let Some(a) = parse_halign(value) { - self.horizontal_alignment = a; - } - return; - } - if field == "att_valign" { - if let Some(a) = parse_valign(value) { - self.vertical_alignment = a; - } - return; - } - if field == "att_mtext_flag" { - self.mtext_flag = match value { - "MultiLine" => MTextFlag::MultiLine, - "ConstantMultiLine" => MTextFlag::ConstantMultiLine, - _ => MTextFlag::SingleLine, - }; - return; - } - // Numeric scalars. - let Some(v) = parse_f64(value) else { - return; - }; match field { - "att_ix" => self.insertion_point.x = v, - "att_iy" => self.insertion_point.y = v, - "att_iz" => self.insertion_point.z = v, - "att_ax" => self.alignment_point.x = v, - "att_ay" => self.alignment_point.y = v, - "att_az" => self.alignment_point.z = v, - "att_h" if v > 0.0 => self.height = v, - "att_rot" => self.rotation = v.to_radians(), - "att_wf" if v.abs() > 1e-9 => self.width_factor = v, - "att_ob" => self.oblique_angle = v.to_radians(), - _ => {} + "att_tag" => self.tag = value.trim().to_string(), + "att_prompt" if !self.flags.constant => self.prompt = value.to_string(), + "att_default" => { + self.default_value = value.to_string(); + if let Some(m) = self.embedded_mtext.as_mut() { + m.value = value.to_string(); + } + } + "att_style" => { + self.text_style = value.to_string(); + if let Some(m) = self.embedded_mtext.as_mut() { + m.style = value.to_string(); + } + } + "att_annotative" => self.flags.annotative = is_yes(value), + "att_invisible" => self.flags.invisible = is_yes(value), + "att_constant" => { + self.flags.constant = is_yes(value); + if self.flags.constant { + self.prompt.clear(); + } + } + "att_verify" => self.flags.verify = is_yes(value), + "att_preset" => self.flags.preset = is_yes(value), + "att_lock_pos" => { + self.lock_position = is_yes(value); + self.flags.locked_position = self.lock_position; + } + "att_upside_down" => { + self.text_generation_flags = set_bit(self.text_generation_flags, 0x4, is_yes(value)) + } + "att_backward" => { + self.text_generation_flags = set_bit(self.text_generation_flags, 0x2, is_yes(value)) + } + "att_multiline" => set_multiline(self, is_yes(value)), + "att_justify" => { + let Some((h, v)) = parse_justify(value) else { + return; + }; + let was_two_point = is_two_point(self); + self.horizontal_alignment = h; + self.vertical_alignment = v; + if is_plain_left(self) { + return; + } + // The text stays where it is: the baseline start is kept and + // the alignment point moves to the new anchor. + if !is_two_point(self) { + let (dx, dy) = justify_offset(self).map_or((0.0, 0.0), |o| rotate(self, o)); + let s = self.insertion_point; + self.alignment_point = Vector3::new(s.x + dx, s.y + dy, s.z); + return; + } + let span = (self.alignment_point.x - self.insertion_point.x) + .hypot(self.alignment_point.y - self.insertion_point.y); + if !was_two_point || span < 1.0e-9 { + // A baseline as long as the tag. + let length = justify_offset(self).map_or(self.height, |(advance, _)| advance); + let (sin, cos) = self.rotation.sin_cos(); + self.alignment_point = Vector3::new( + self.insertion_point.x + cos * length, + self.insertion_point.y + sin * length, + self.insertion_point.z, + ); + } + } + _ => { + let Some(v) = parse_f64(value) else { + return; + }; + match field { + "att_ax" => self.alignment_point.x = v, + "att_ay" => self.alignment_point.y = v, + "att_az" => self.alignment_point.z = v, + "att_h" => { + self.height = v; + if let Some(m) = self.embedded_mtext.as_mut() { + m.height = v; + } + } + "att_rot" => { + self.rotation = v.to_radians(); + if let Some(m) = self.embedded_mtext.as_mut() { + m.rotation = self.rotation; + } + } + "att_wf" => self.width_factor = v, + "att_ob" => self.oblique_angle = v.to_radians(), + "att_bwidth" if v >= 0.0 => { + if let Some(m) = self.embedded_mtext.as_mut() { + m.rectangle_width = v; + } + } + _ => {} + } + } + } + // A one-point justified definition keeps its stored baseline start in + // step with the anchor, height, style and rotation. + if !is_plain_left(self) && !is_two_point(self) { + self.insertion_point = definition_text_start(self); } } } diff --git a/src/entities/common.rs b/src/entities/common.rs index ab2e35674..4f5e799d7 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -68,6 +68,10 @@ thread_local! { /// the same place. static FIXED_TEXT_HEIGHTS: std::cell::RefCell> = std::cell::RefCell::new(rustc_hash::FxHashMap::default()); + /// Resolved font of every text style, for text measured without the + /// document at hand (justification changes, attribute placement). + static STYLE_FONTS: std::cell::RefCell> = + std::cell::RefCell::new(rustc_hash::FxHashMap::default()); } /// Record which text styles fix their height, from the drawing's style table. @@ -81,6 +85,23 @@ pub fn set_fixed_text_heights(document: &codec::CadDocument) { } } }); + STYLE_FONTS.with(|cell| { + let mut map = cell.borrow_mut(); + map.clear(); + for style in document.text_styles.iter() { + let font = crate::entities::text_support::resolve_text_style(&style.name, document).font_name; + map.insert(style.name.to_ascii_lowercase(), font); + } + }); +} + +/// The resolved font of `style` (Standard when unnamed); empty when unknown. +pub fn style_font(style: &str) -> String { + let key = match style.trim() { + "" => "standard".to_string(), + name => name.to_ascii_lowercase(), + }; + STYLE_FONTS.with(|cell| cell.borrow().get(&key).cloned().unwrap_or_default()) } /// The height `style` fixes, or `None` when it leaves the height to the entity. diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 2c8c5156d..f0f3e1557 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -163,6 +163,18 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Requires a positive number." => Some(("insert", "pcs-positive")), "Requires a positive integer." => Some(("insert", "pcs-positive-int")), "Traces the points on the kept side of a live section as lines." => Some(("insert", "pcs-help")), + "Specify on-screen" => Some(("insert", "attdef-on-screen")), + "Insertion Point" => Some(("insert", "attdef-insertion-point")), + "Insert field" => Some(("insert", "attdef-insert-field")), + "Text Settings" => Some(("insert", "attdef-text-settings")), + "Align below previous attribute definition" => Some(("insert", "attdef-align-below")), + "Define" => Some(("insert", "attdef-define")), + "Edit Attribute Definition" => Some(("insert", "attdef-edit-title")), + "Changes the tag, prompt and default value of an attribute definition." => Some(("insert", "attdef-edit-help")), + "Defines an attribute: a tag, prompt and default value placed as text in a block definition." => Some(("insert", "attdef-help")), + "Define Attributes" => Some(("insert", "attdef-define-attributes")), + "Manage Attributes" => Some(("insert", "attdef-manage-attributes")), + "Set Base Point" => Some(("insert", "attdef-set-base-point")), "Stylization" => Some(("properties", "stylization")), "Color scheme" => Some(("properties", "color-scheme")), "Scan Colors" => Some(("properties", "scan-colors")), diff --git a/src/modules/draw/draw/attdef.rs b/src/modules/draw/draw/attdef.rs index 053ffe8af..786e95ecd 100644 --- a/src/modules/draw/draw/attdef.rs +++ b/src/modules/draw/draw/attdef.rs @@ -1,244 +1,1046 @@ -// ATTDEF command — define a block attribute (AttributeDefinition entity). +// ATTDEF / -ATTDEF — attribute definitions. // -// Workflow (command-line only): -// 1. Text: Enter attribute tag (required, no spaces) -// 2. Text: Enter attribute prompt (optional — press Enter to use tag) -// 3. Text: Enter default value (optional — press Enter for blank) -// 4. Point: Click insertion point +// The modes (Invisible, Constant, Verify, Preset, Lock position, Multiple +// lines) persist for the session as AFLAGS; Annotative persists beside them. +// `-ATTDEF` asks everything on the command line; ATTDEF fills the same +// `AttdefSpec` from its dialog and only asks for the placement. -use codec::entities::AttributeDefinition; +use std::sync::Mutex; + +use codec::entities::attribute_definition::{ + HorizontalAlignment as HA, MTextFlag, VerticalAlignment as VA, +}; +use codec::entities::{AttributeDefinition, MText}; +use codec::tables::TextStyle; use codec::types::Vector3; use codec::EntityType; use glam::DVec3; -use crate::t; use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane}; use crate::scene::model::wire_model::WireModel; -enum Step { - Tag, - Prompt { - tag: String, - }, - Default { - tag: String, - prompt: String, - }, - Insertion { - tag: String, - prompt: String, - default: String, - }, +pub const AFLAG_INVISIBLE: u8 = 1; +pub const AFLAG_CONSTANT: u8 = 2; +pub const AFLAG_VERIFY: u8 = 4; +pub const AFLAG_PRESET: u8 = 8; +pub const AFLAG_LOCK: u8 = 16; +pub const AFLAG_MULTILINE: u8 = 32; + +/// Session state shared by ATTDEF and -ATTDEF: the AFLAGS modes, the +/// Annotative switch and the last definition placed (for "align below"). +pub struct AttdefSession { + pub aflags: u8, + pub annotative: bool, + pub last: Option, } -pub struct AttdefCommand { - step: Step, - height: f64, - text_style: String, - width_factor: f64, - oblique_angle: f64, - plane: WorkingPlane, +pub static SESSION: Mutex = Mutex::new(AttdefSession { + aflags: AFLAG_LOCK, + annotative: false, + last: None, +}); + +pub fn session() -> std::sync::MutexGuard<'static, AttdefSession> { + SESSION.lock().unwrap_or_else(|e| e.into_inner()) } -impl AttdefCommand { - pub fn with_text_defaults( - height: f64, - text_style: String, - width_factor: f64, - oblique_angle: f64, - ) -> Self { - Self { - step: Step::Tag, - height, - text_style, - width_factor, - oblique_angle, - plane: WorkingPlane::default(), +/// The fifteen justifications, in the order the dialog lists them. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Justify { + #[default] + Left, + Align, + Fit, + Center, + Middle, + Right, + TL, + TC, + TR, + ML, + MC, + MR, + BL, + BC, + BR, +} + +impl Justify { + pub const ALL: [Justify; 15] = [ + Justify::Left, + Justify::Align, + Justify::Fit, + Justify::Center, + Justify::Middle, + Justify::Right, + Justify::TL, + Justify::TC, + Justify::TR, + Justify::ML, + Justify::MC, + Justify::MR, + Justify::BL, + Justify::BC, + Justify::BR, + ]; + + /// The name the dialog and the status line use. + pub fn label(self) -> &'static str { + match self { + Justify::Left => "Left", + Justify::Align => "Align", + Justify::Fit => "Fit", + Justify::Center => "Center", + Justify::Middle => "Middle", + Justify::Right => "Right", + Justify::TL => "Top left", + Justify::TC => "Top center", + Justify::TR => "Top right", + Justify::ML => "Middle left", + Justify::MC => "Middle center", + Justify::MR => "Middle right", + Justify::BL => "Bottom left", + Justify::BC => "Bottom center", + Justify::BR => "Bottom right", + } + } + + pub fn from_keyword(word: &str) -> Option { + let w = word.trim().to_ascii_uppercase(); + let w = w.trim_start_matches('_'); + Some(match w { + "L" | "LEFT" => Justify::Left, + "A" | "ALIGN" => Justify::Align, + "F" | "FIT" => Justify::Fit, + "C" | "CENTER" => Justify::Center, + "M" | "MIDDLE" => Justify::Middle, + "R" | "RIGHT" => Justify::Right, + "TL" => Justify::TL, + "TC" => Justify::TC, + "TR" => Justify::TR, + "ML" => Justify::ML, + "MC" => Justify::MC, + "MR" => Justify::MR, + "BL" => Justify::BL, + "BC" => Justify::BC, + "BR" => Justify::BR, + _ => return None, + }) + } + + pub fn from_alignment(h: HA, v: VA) -> Justify { + match (h, v) { + (HA::Aligned, _) => Justify::Align, + (HA::Fit, _) => Justify::Fit, + (HA::Middle, _) => Justify::Middle, + (HA::Left, VA::Top) => Justify::TL, + (HA::Center, VA::Top) => Justify::TC, + (HA::Right, VA::Top) => Justify::TR, + (HA::Left, VA::Middle) => Justify::ML, + (HA::Center, VA::Middle) => Justify::MC, + (HA::Right, VA::Middle) => Justify::MR, + (HA::Left, VA::Bottom) => Justify::BL, + (HA::Center, VA::Bottom) => Justify::BC, + (HA::Right, VA::Bottom) => Justify::BR, + (HA::Center, VA::Baseline) => Justify::Center, + (HA::Right, VA::Baseline) => Justify::Right, + (HA::Left, VA::Baseline) => Justify::Left, + } + } + + pub fn alignment(self) -> (HA, VA) { + match self { + Justify::Left => (HA::Left, VA::Baseline), + Justify::Align => (HA::Aligned, VA::Baseline), + Justify::Fit => (HA::Fit, VA::Baseline), + Justify::Center => (HA::Center, VA::Baseline), + Justify::Middle => (HA::Middle, VA::Baseline), + Justify::Right => (HA::Right, VA::Baseline), + Justify::TL => (HA::Left, VA::Top), + Justify::TC => (HA::Center, VA::Top), + Justify::TR => (HA::Right, VA::Top), + Justify::ML => (HA::Left, VA::Middle), + Justify::MC => (HA::Center, VA::Middle), + Justify::MR => (HA::Right, VA::Middle), + Justify::BL => (HA::Left, VA::Bottom), + Justify::BC => (HA::Center, VA::Bottom), + Justify::BR => (HA::Right, VA::Bottom), + } + } + + /// Align and Fit are placed by two baseline endpoints. + pub fn two_point(self) -> bool { + matches!(self, Justify::Align | Justify::Fit) + } + + /// The point prompt after the justification is chosen. + fn point_prompt(self) -> &'static str { + match self { + Justify::Left => "Specify start point of text:", + Justify::Align | Justify::Fit => "Specify first endpoint of text baseline:", + Justify::Center => "Specify center point of text:", + Justify::Middle | Justify::MC => "Specify middle point of text:", + Justify::Right => "Specify right endpoint of text baseline:", + Justify::TL => "Specify top-left point of text:", + Justify::TC => "Specify top-center point of text:", + Justify::TR => "Specify top-right point of text:", + Justify::ML => "Specify middle-left point of text:", + Justify::MR => "Specify middle-right point of text:", + Justify::BL => "Specify bottom-left point of text:", + Justify::BC => "Specify bottom-center point of text:", + Justify::BR => "Specify bottom-right point of text:", } } } +/// Everything an attribute definition is made from, before it is placed. +#[derive(Debug, Clone)] +pub struct AttdefSpec { + pub aflags: u8, + pub annotative: bool, + pub tag: String, + pub prompt: String, + /// Default (or constant) value; multi-line values joined with `\P`. + pub value: String, + pub justify: Justify, + pub style: String, + pub height: f64, + /// Radians. + pub rotation: f64, + pub width_factor: f64, + pub oblique_angle: f64, + /// Multi-line boundary width (0 = none). + pub boundary_width: f64, + pub line_spacing: f64, +} + +impl AttdefSpec { + pub fn multiline(&self) -> bool { + self.aflags & AFLAG_MULTILINE != 0 + } + + pub fn constant(&self) -> bool { + self.aflags & AFLAG_CONSTANT != 0 + } +} + +/// Tags are stored upper case. +pub fn normalize_tag(tag: &str) -> String { + tag.trim().to_uppercase() +} + +/// Build the entity. `first` is the start / justification point (world), +/// `second` the second baseline endpoint for Align / Fit. +pub fn build( + spec: &AttdefSpec, + plane: WorkingPlane, + first: DVec3, + second: Option, +) -> EntityType { + let p = plane.to_local(first); + let point = Vector3::new(p.x, p.y, p.z); + let (h, v) = spec.justify.alignment(); + let mut attdef = AttributeDefinition { + tag: normalize_tag(&spec.tag), + prompt: if spec.constant() { String::new() } else { spec.prompt.clone() }, + default_value: spec.value.clone(), + insertion_point: point, + height: spec.height, + rotation: spec.rotation, + width_factor: spec.width_factor, + oblique_angle: spec.oblique_angle, + text_style: spec.style.clone(), + horizontal_alignment: h, + vertical_alignment: v, + lock_position: spec.aflags & AFLAG_LOCK != 0, + ..Default::default() + }; + attdef.flags.invisible = spec.aflags & AFLAG_INVISIBLE != 0; + attdef.flags.constant = spec.constant(); + attdef.flags.verify = spec.aflags & AFLAG_VERIFY != 0; + attdef.flags.preset = spec.aflags & AFLAG_PRESET != 0; + attdef.flags.annotative = spec.annotative; + if spec.multiline() { + // The reference writes MText flag 4 for every multi-line definition. + attdef.mtext_flag = MTextFlag::ConstantMultiLine; + attdef.is_multiline = true; + attdef.line_count = spec.value.split("\\P").count().max(1) as i16; + attdef.horizontal_alignment = HA::Left; + attdef.vertical_alignment = VA::Top; + attdef.alignment_point = point; + let mut mtext = MText::new(); + mtext.value = spec.value.clone(); + mtext.insertion_point = point; + mtext.height = spec.height; + mtext.rectangle_width = spec.boundary_width; + mtext.rotation = spec.rotation; + mtext.style = spec.style.clone(); + mtext.line_spacing_factor = spec.line_spacing; + attdef.embedded_mtext = Some(Box::new(mtext)); + } else if spec.justify != Justify::Left { + let anchor = match (spec.justify.two_point(), second) { + (true, Some(s)) => { + let s = plane.to_local(s); + attdef.rotation = (s.y - p.y).atan2(s.x - p.x); + Vector3::new(s.x, s.y, s.z) + } + _ => point, + }; + attdef.alignment_point = anchor; + if !spec.justify.two_point() { + attdef.insertion_point = crate::entities::attribute::definition_text_start(&attdef); + } + } + plane.place_entity(EntityType::AttributeDefinition(attdef)) +} + +/// Where "align below the previous definition" puts the next one: one line +/// (12/7 of the text height, as the reference spaces it) down along the +/// previous definition's own direction. +pub fn below(previous: &AttributeDefinition) -> (Vector3, f64) { + let step = previous.height * 12.0 / 7.0; + let (sin, cos) = previous.rotation.sin_cos(); + let base = if matches!(previous.horizontal_alignment, HA::Left) + && matches!(previous.vertical_alignment, VA::Baseline) + || previous.is_multiline + { + previous.insertion_point + } else { + previous.alignment_point + }; + ( + Vector3::new(base.x + sin * step, base.y - cos * step, base.z), + step, + ) +} + +/// Place `spec` directly below `previous` (same justification, height and +/// rotation). +pub fn build_below(spec: &AttdefSpec, previous: &AttributeDefinition) -> EntityType { + let (point, _) = below(previous); + // Justified definitions line up by their alignment points. + let mut spec = spec.clone(); + spec.height = previous.height; + spec.rotation = previous.rotation; + spec.justify = Justify::from_alignment(previous.horizontal_alignment, previous.vertical_alignment); + let mut entity = build(&spec, WorkingPlane::default(), DVec3::new(point.x, point.y, point.z), None); + if let EntityType::AttributeDefinition(a) = &mut entity { + a.normal = previous.normal; + if spec.justify.two_point() { + let (dx, dy) = ( + previous.alignment_point.x - previous.insertion_point.x, + previous.alignment_point.y - previous.insertion_point.y, + ); + a.alignment_point = Vector3::new(point.x + dx, point.y + dy, point.z); + a.insertion_point = point; + } + } + entity +} + +/// Text height an Align definition gets from its baseline length. +fn aligned_height(spec: &AttdefSpec, length: f64) -> f64 { + let font = crate::entities::common::style_font(&spec.style); + let text = if spec.value.is_empty() { spec.tag.clone() } else { spec.value.clone() }; + crate::entities::text_support::text_local_bounds(&font, &text, 1.0, spec.width_factor as f32, 0.0) + .map(|b| b.advance as f64) + .filter(|w| *w > 1.0e-9) + .map(|w| length / w) + .unwrap_or(spec.height) +} + +fn short(v: f64) -> String { + let t = format!("{v:.4}"); + t.trim_end_matches('0').trim_end_matches('.').to_string() +} + +fn yn(on: bool) -> char { + if on { 'Y' } else { 'N' } +} + +fn modes_line(aflags: u8, annotative: bool) -> String { + format!( + "Invisible={} Constant={} Verify={} Preset={} Lock position={} Annotative={} Multiple line={}", + yn(aflags & AFLAG_INVISIBLE != 0), + yn(aflags & AFLAG_CONSTANT != 0), + yn(aflags & AFLAG_VERIFY != 0), + yn(aflags & AFLAG_PRESET != 0), + yn(aflags & AFLAG_LOCK != 0), + yn(annotative), + yn(aflags & AFLAG_MULTILINE != 0), + ) +} + +/// Output the reference prints before the modes prompt. +pub fn modes_report(aflags: u8, annotative: bool) -> String { + format!("Current attribute modes:\n{}", modes_line(aflags, annotative)) +} + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Step { + Modes, + Tag, + Prompt, + Value, + NextLine, + Start, + Justify, + Style, + JustifiedPoint, + SecondPoint, + Height, + Rotation, + // Multi-line placement. + Location, + Corner, + MHeight, + MJustify, + MSpacingType, + MSpacing, + MRotation, + MStyle, + MWidth, +} + +/// `-ATTDEF` on the command line. +pub struct AttdefCommand { + step: Step, + spec: AttdefSpec, + lines: Vec, + styles: Vec, + plane: WorkingPlane, + first: Option, + second: Option, + previous: Option, + name: &'static str, +} + +impl AttdefCommand { + pub fn new( + name: &'static str, + style: String, + height: f64, + width_factor: f64, + oblique_angle: f64, + styles: Vec, + ) -> Self { + let s = session(); + Self { + step: Step::Modes, + spec: AttdefSpec { + aflags: s.aflags, + annotative: s.annotative, + tag: String::new(), + prompt: String::new(), + value: String::new(), + justify: Justify::Left, + style, + height, + rotation: 0.0, + width_factor, + oblique_angle, + boundary_width: 0.0, + line_spacing: 1.0, + }, + lines: Vec::new(), + styles, + plane: WorkingPlane::default(), + first: None, + second: None, + previous: s.last.clone(), + name, + } + } + + /// The lines printed before the first prompt. + pub fn intro(&self) -> String { + modes_report(self.spec.aflags, self.spec.annotative) + } + + fn status(&self) -> String { + format!( + "Current text style: \"{}\" Text height: {:.4} Justify: {}", + self.spec.style, + self.spec.height, + self.spec.justify.label() + ) + } + + fn style_exists(&self, name: &str) -> Option { + self.styles + .iter() + .find(|s| !s.name.is_empty() && s.name.eq_ignore_ascii_case(name)) + .map(|s| s.name.clone()) + } + + fn report(&self, text: &str) -> CmdResult { + CmdResult::ReportMeasurement(text.to_string()) + } + + /// Finish: build, remember and commit. + fn finish(&mut self) -> CmdResult { + let first = self.first.unwrap_or(DVec3::ZERO); + let entity = build(&self.spec, self.plane, first, self.second); + remember(&self.spec, &entity); + CmdResult::CommitAndExit(entity) + } + + fn after_point(&mut self) -> CmdResult { + if self.spec.justify == Justify::Align { + let (a, b) = (self.first.unwrap_or_default(), self.second.unwrap_or_default()); + let length = self.plane.to_local(b).truncate().distance(self.plane.to_local(a).truncate()); + self.spec.height = aligned_height(&self.spec, length); + return self.finish(); + } + self.step = Step::Height; + CmdResult::NeedPoint + } +} + +/// Keep AFLAGS / Annotative and the placed definition for the next ATTDEF. +pub fn remember(spec: &AttdefSpec, entity: &EntityType) { + let mut s = session(); + s.aflags = spec.aflags; + s.annotative = spec.annotative; + if let EntityType::AttributeDefinition(a) = entity { + s.last = Some(a.clone()); + } +} + +const MODES_PROMPT: &str = + "Enter an option to change [Invisible/Constant/Verify/Preset/Lock position/Annotative/Multiple lines] :"; + impl CadCommand for AttdefCommand { + fn name(&self) -> &'static str { + self.name + } + fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; } - fn name(&self) -> &'static str { - "ATTDEF" - } - fn prompt(&self) -> String { - match &self.step { - Step::Tag => t!("ATTDEF Enter attribute tag (no spaces):").into_owned(), - Step::Prompt { tag } => t!( - "ATTDEF Enter prompt for '%{tag}' (Enter=use tag):", - tag = tag - ) - .into_owned(), - Step::Default { tag, .. } => t!( - "ATTDEF Enter default value for '%{tag}' (Enter=blank):", - tag = tag - ) - .into_owned(), - Step::Insertion { tag, .. } => t!( - "ATTDEF Specify insertion point for '%{tag}':", - tag = tag - ) - .into_owned(), + match self.step { + Step::Modes => MODES_PROMPT.to_string(), + Step::Tag => "Enter attribute tag name:".to_string(), + Step::Prompt => "Enter attribute prompt:".to_string(), + Step::Value if self.spec.constant() => "Enter attribute value:".to_string(), + Step::Value => "Enter default attribute value:".to_string(), + Step::NextLine => "Next line or :".to_string(), + Step::Start => "Specify start point of text or [Justify/Style]:".to_string(), + Step::Justify => { + "Enter an option [Left/Center/Right/Align/Middle/Fit/TL/TC/TR/ML/MC/MR/BL/BC/BR]:" + .to_string() + } + Step::Style | Step::MStyle => { + format!("Enter style name or [?] <{}>:", self.spec.style) + } + Step::JustifiedPoint => self.spec.justify.point_prompt().to_string(), + Step::SecondPoint => "Specify second endpoint of text baseline:".to_string(), + Step::Height | Step::MHeight => format!("Specify height <{:.4}>:", self.spec.height), + Step::Rotation => format!( + "Specify rotation angle of text <{}>:", + short(self.spec.rotation.to_degrees()) + ), + Step::Location => "Specify location of multiline attribute:".to_string(), + Step::Corner => { + "Specify opposite corner or [Height/Justify/Line spacing/Rotation/Style/Width]:" + .to_string() + } + Step::MJustify => "Enter justification [TL/TC/TR/ML/MC/MR/BL/BC/BR] :".to_string(), + Step::MSpacingType => "Enter line spacing type [At least/Exactly] :".to_string(), + Step::MSpacing => format!( + "Enter line spacing factor or distance <{}x>:", + short(self.spec.line_spacing) + ), + Step::MRotation => format!( + "Specify rotation angle <{}>:", + short(self.spec.rotation.to_degrees()) + ), + Step::MWidth => "Specify width:".to_string(), } } fn input_kind(&self) -> InputKind { match self.step { - Step::Insertion { .. } => InputKind::Point, - Step::Tag => InputKind::SingleToken, - Step::Prompt { .. } | Step::Default { .. } => InputKind::FreeText, + Step::Prompt | Step::Value | Step::NextLine => InputKind::FreeText, + Step::Modes | Step::Tag | Step::Justify | Step::Style | Step::MStyle | Step::MJustify + | Step::MSpacingType => InputKind::SingleToken, + _ => InputKind::Point, } } + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::Start | Step::Corner) + } + fn on_text_input(&mut self, text: &str) -> Option { - match &self.step { + let raw = text.trim(); + let word = raw.trim_start_matches('_').to_ascii_uppercase(); + match self.step { + Step::Modes => { + if word.is_empty() { + return Some(self.on_enter()); + } + let bit = match word.as_str() { + "I" | "INVISIBLE" => Some(AFLAG_INVISIBLE), + "C" | "CONSTANT" => Some(AFLAG_CONSTANT), + "V" | "VERIFY" => Some(AFLAG_VERIFY), + "P" | "PRESET" => Some(AFLAG_PRESET), + "L" | "LOCK" | "LOCKPOSITION" => Some(AFLAG_LOCK), + "M" | "MULTIPLE" | "MULTIPLELINES" => Some(AFLAG_MULTILINE), + "A" | "ANNOTATIVE" => None, + _ => return Some(CmdResult::ReportError("Invalid option keyword.".into())), + }; + match bit { + Some(bit) => self.spec.aflags ^= bit, + None => self.spec.annotative = !self.spec.annotative, + } + { + let mut s = session(); + s.aflags = self.spec.aflags; + s.annotative = self.spec.annotative; + } + Some(self.report(&modes_report(self.spec.aflags, self.spec.annotative))) + } Step::Tag => { - let tag = text.trim().replace(' ', "_"); - if tag.is_empty() { + if raw.is_empty() { + return Some(CmdResult::ReportError("Tag cannot be null".into())); + } + self.spec.tag = normalize_tag(raw); + self.step = if self.spec.constant() { Step::Value } else { Step::Prompt }; + Some(CmdResult::NeedPoint) + } + Step::Prompt => { + self.spec.prompt = text.to_string(); + self.step = Step::Value; + Some(CmdResult::NeedPoint) + } + Step::Value => { + if self.spec.multiline() { + if text.is_empty() { + self.lines_done(); + return Some(CmdResult::NeedPoint); + } + self.lines.push(text.to_string()); + self.step = Step::NextLine; + } else { + self.spec.value = text.to_string(); + self.step = Step::Start; + return Some(self.report(&self.status())); + } + Some(CmdResult::NeedPoint) + } + Step::NextLine => { + if text.is_empty() { + self.lines_done(); return Some(CmdResult::NeedPoint); } - self.step = Step::Prompt { tag }; + self.lines.push(text.to_string()); Some(CmdResult::NeedPoint) } - Step::Prompt { tag } => { - let tag = tag.clone(); - let prompt = if text.trim().is_empty() { - tag.clone() + Step::Start => match word.as_str() { + "J" | "JUSTIFY" => { + self.step = Step::Justify; + Some(CmdResult::NeedPoint) + } + "S" | "STYLE" => { + self.step = Step::Style; + Some(CmdResult::NeedPoint) + } + "" => Some(self.on_enter()), + _ => Some(CmdResult::ReportError("Point or option keyword required.".into())), + }, + Step::Justify => match Justify::from_keyword(&word) { + Some(j) => { + self.spec.justify = j; + self.step = Step::JustifiedPoint; + Some(CmdResult::NeedPoint) + } + None => Some(CmdResult::ReportError("Invalid option keyword.".into())), + }, + Step::Style | Step::MStyle => { + let back = if self.step == Step::Style { Step::Start } else { Step::Corner }; + if raw.is_empty() { + self.step = back; + return Some(if back == Step::Start { + self.report(&self.status()) + } else { + CmdResult::NeedPoint + }); + } + if raw == "?" { + let names: Vec = self + .styles + .iter() + .filter(|s| !s.name.is_empty()) + .map(|s| format!("Style name: \"{}\" Font files: {}", s.name, s.font_file)) + .collect(); + return Some(self.report(&format!("Text styles:\n{}", names.join("\n")))); + } + match self.style_exists(raw) { + Some(name) => { + self.spec.style = name; + self.step = back; + Some(if back == Step::Start { + self.report(&self.status()) + } else { + CmdResult::NeedPoint + }) + } + None => Some(CmdResult::ReportError(format!("Cannot find text style \"{raw}\"."))), + } + } + Step::Height | Step::MHeight => match raw.parse::() { + Ok(v) if v > 0.0 => { + self.spec.height = v; + if self.step == Step::MHeight { + self.step = Step::Corner; + return Some(CmdResult::NeedPoint); + } + if self.spec.justify == Justify::Fit { + return Some(self.finish()); + } + self.step = Step::Rotation; + Some(CmdResult::NeedPoint) + } + Ok(_) => Some(CmdResult::ReportError("Value must be positive and nonzero.".into())), + Err(_) => Some(CmdResult::ReportError("Requires numeric distance or two points.".into())), + }, + Step::Rotation | Step::MRotation => match raw.parse::() { + Ok(v) => { + self.spec.rotation = v.to_radians(); + if self.step == Step::MRotation { + self.step = Step::Corner; + return Some(CmdResult::NeedPoint); + } + Some(self.finish()) + } + Err(_) => Some(CmdResult::ReportError("Requires valid numeric angle or second point.".into())), + }, + Step::Corner => match word.as_str() { + "H" | "HEIGHT" => { + self.step = Step::MHeight; + Some(CmdResult::NeedPoint) + } + "J" | "JUSTIFY" => { + self.step = Step::MJustify; + Some(CmdResult::NeedPoint) + } + "L" | "LINE" | "LINESPACING" => { + self.step = Step::MSpacingType; + Some(CmdResult::NeedPoint) + } + "R" | "ROTATION" => { + self.step = Step::MRotation; + Some(CmdResult::NeedPoint) + } + "S" | "STYLE" => { + self.step = Step::MStyle; + Some(CmdResult::NeedPoint) + } + "W" | "WIDTH" => { + self.step = Step::MWidth; + Some(CmdResult::NeedPoint) + } + _ => Some(CmdResult::ReportError("2D point or option keyword required.".into())), + }, + Step::MJustify => { + // Multi-line definitions keep their attachment in the embedded + // MText; only the keyword is validated here. + if word.is_empty() + || ["TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"].contains(&word.as_str()) + { + self.step = Step::Corner; + Some(CmdResult::NeedPoint) } else { - text.trim().to_string() - }; - self.step = Step::Default { tag, prompt }; - Some(CmdResult::NeedPoint) + Some(CmdResult::ReportError("Invalid option keyword.".into())) + } } - Step::Default { tag, prompt } => { - let tag = tag.clone(); - let prompt = prompt.clone(); - let default = text.trim().to_string(); - self.step = Step::Insertion { - tag, - prompt, - default, - }; - Some(CmdResult::NeedPoint) + Step::MSpacingType => { + if word.is_empty() || word.starts_with('A') || word.starts_with('E') { + self.step = Step::MSpacing; + Some(CmdResult::NeedPoint) + } else { + Some(CmdResult::ReportError("Invalid option keyword.".into())) + } } - Step::Insertion { .. } => None, + Step::MSpacing => { + let t = raw.trim_end_matches(['x', 'X']); + if raw.is_empty() { + self.step = Step::Corner; + return Some(CmdResult::NeedPoint); + } + match t.parse::() { + Ok(v) if v > 0.0 => { + self.spec.line_spacing = v; + self.step = Step::Corner; + Some(CmdResult::NeedPoint) + } + _ => Some(CmdResult::ReportError("Value must be positive and nonzero.".into())), + } + } + Step::MWidth => match raw.parse::() { + Ok(v) if v >= 0.0 => { + self.spec.boundary_width = v; + self.spec.value = self.lines.join("\\P"); + Some(self.finish()) + } + _ => Some(CmdResult::ReportError("Requires numeric distance or second point.".into())), + }, + _ => None, } } fn on_point(&mut self, pt: DVec3) -> CmdResult { - if let Step::Insertion { - tag, - prompt, - default, - } = &self.step - { - let point = self.plane.to_local(pt); - let mut attdef = AttributeDefinition { - tag: tag.clone(), - prompt: prompt.clone(), - default_value: default.clone(), - insertion_point: Vector3::new(point.x, point.y, point.z), - height: self.height, - text_style: self.text_style.clone(), - width_factor: self.width_factor, - oblique_angle: self.oblique_angle, - ..Default::default() - }; - attdef.common.layer = "0".into(); - CmdResult::CommitAndExit( - self.plane - .place_entity(EntityType::AttributeDefinition(attdef)), - ) - } else { - CmdResult::NeedPoint + match self.step { + Step::Start => { + self.spec.justify = Justify::Left; + self.first = Some(pt); + self.after_point() + } + Step::JustifiedPoint => { + self.first = Some(pt); + if self.spec.justify.two_point() { + self.step = Step::SecondPoint; + return CmdResult::NeedPoint; + } + self.after_point() + } + Step::SecondPoint => { + self.second = Some(pt); + self.after_point() + } + Step::Height | Step::MHeight => { + let base = self.first.unwrap_or(pt); + let d = self.plane.to_local(pt).truncate().distance(self.plane.to_local(base).truncate()); + self.on_text_input(&d.to_string()).unwrap_or(CmdResult::NeedPoint) + } + Step::Rotation | Step::MRotation => { + let base = self.plane.to_local(self.first.unwrap_or(pt)); + let p = self.plane.to_local(pt); + let angle = (p.y - base.y).atan2(p.x - base.x).to_degrees(); + self.on_text_input(&angle.to_string()).unwrap_or(CmdResult::NeedPoint) + } + Step::Location => { + self.first = Some(pt); + self.step = Step::Corner; + CmdResult::NeedPoint + } + Step::Corner | Step::MWidth => { + let a = self.plane.to_local(self.first.unwrap_or(pt)); + let b = self.plane.to_local(pt); + let (sin, cos) = self.spec.rotation.sin_cos(); + let width = ((b.x - a.x) * cos + (b.y - a.y) * sin).abs(); + self.spec.boundary_width = width; + self.spec.value = self.lines.join("\\P"); + self.finish() + } + _ => CmdResult::NeedPoint, } } fn on_enter(&mut self) -> CmdResult { - match &self.step { - Step::Tag => CmdResult::Cancel, - // Treat Enter as empty text input for prompt/default steps. - Step::Prompt { tag } => { - let tag = tag.clone(); - self.step = Step::Default { - tag: tag.clone(), - prompt: tag, - }; + match self.step { + Step::Modes => { + self.step = Step::Tag; CmdResult::NeedPoint } - Step::Default { tag, prompt } => { - let (tag, prompt) = (tag.clone(), prompt.clone()); - self.step = Step::Insertion { - tag, - prompt, - default: String::new(), - }; - CmdResult::NeedPoint + Step::Tag => CmdResult::ReportError("Tag cannot be null".into()), + Step::Prompt | Step::Value | Step::NextLine | Step::Style | Step::MStyle + | Step::MSpacingType | Step::MSpacing | Step::MJustify => { + self.on_text_input("").unwrap_or(CmdResult::NeedPoint) } - Step::Insertion { .. } => CmdResult::Cancel, + Step::Start => match self.previous.clone() { + Some(previous) => { + let entity = build_below(&self.spec, &previous); + remember(&self.spec, &entity); + CmdResult::CommitAndExit(entity) + } + None => CmdResult::ReportError("Point or option keyword required.".into()), + }, + Step::Height | Step::MHeight => { + let h = self.spec.height; + self.on_text_input(&h.to_string()).unwrap_or(CmdResult::NeedPoint) + } + Step::Rotation | Step::MRotation => { + let r = self.spec.rotation.to_degrees(); + self.on_text_input(&r.to_string()).unwrap_or(CmdResult::NeedPoint) + } + _ => CmdResult::Cancel, } } fn on_mouse_move(&mut self, pt: DVec3) -> Option { - if !matches!(self.step, Step::Insertion { .. }) { + if !matches!(self.step, Step::Start | Step::JustifiedPoint | Step::Location) { return None; } - // Show a small cross at the insertion point. - let d = 0.15; - let points = [ - pt - self.plane.x * d, - pt + self.plane.x * d, - DVec3::splat(f64::NAN), - pt - self.plane.y * d, - pt + self.plane.y * d, - ]; - Some(WireModel { - bg_adapt: None, - point_marker: None, - taper_widths: Vec::new(), - pattern_stations: Vec::new(), - world_width: 0.0, - depth_override: None, - display_visible: true, - snap_only: false, - plot_visible: true, - fill_is_3d: false, - fill_is_2d_solid: false, - render_instance: None, - pick_tris: Vec::new(), - pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), - name: "attdef_preview".into(), - points: points - .iter() - .map(|point| point.as_vec3().to_array()) - .collect(), - points_low: Vec::new(), - color: WireModel::CYAN, - selected: false, - pattern_length: 0.0, - pattern: [0.0; 8], - line_weight_px: 1.0, - snap_pts: vec![], - tangent_geoms: vec![], - aci: 0, - key_vertices: vec![], - aabb: WireModel::UNBOUNDED_AABB, - plinegen: true, - fill_tris: vec![], - fill_tris_low: Vec::new(), - }) + Some(cross_preview(pt, self.plane)) } } +/// The value step after the modes: the multi-line value is collected line by +/// line, then the location is asked. +impl AttdefCommand { + pub fn lines_done(&mut self) { + self.spec.value = self.lines.join("\\P"); + self.step = Step::Location; + } +} + +/// Dialog placement: the spec is complete; only the start point (and the +/// second endpoint for Align / Fit) is asked. +pub struct AttdefPlaceCommand { + spec: AttdefSpec, + plane: WorkingPlane, + first: Option, +} + +impl AttdefPlaceCommand { + pub fn new(spec: AttdefSpec) -> Self { + Self { spec, plane: WorkingPlane::default(), first: None } + } +} + +impl CadCommand for AttdefPlaceCommand { + fn name(&self) -> &'static str { + "ATTDEF" + } + + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + + fn prompt(&self) -> String { + match (self.first, self.spec.justify.two_point()) { + (None, true) => "Specify first endpoint of text baseline:".into(), + (Some(_), _) => "Specify second endpoint of text baseline:".into(), + (None, false) => "Specify start point:".into(), + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + if self.spec.justify.two_point() && self.first.is_none() { + self.first = Some(pt); + return CmdResult::NeedPoint; + } + let (first, second) = match self.first { + Some(first) => (first, Some(pt)), + None => (pt, None), + }; + if self.spec.justify == Justify::Align { + let length = self.plane.to_local(pt).truncate().distance(self.plane.to_local(first).truncate()); + self.spec.height = aligned_height(&self.spec, length); + } + let entity = build(&self.spec, self.plane, first, second); + remember(&self.spec, &entity); + CmdResult::CommitAndExit(entity) + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + Some(cross_preview(pt, self.plane)) + } +} + +/// A small cross at the cursor while a placement point is asked. +fn cross_preview(pt: DVec3, plane: WorkingPlane) -> WireModel { + let d = 0.15; + let points = [ + pt - plane.x * d, + pt + plane.x * d, + DVec3::splat(f64::NAN), + pt - plane.y * d, + pt + plane.y * d, + ]; + WireModel::solid( + "attdef_preview".into(), + points.iter().map(|point| point.as_vec3().to_array()).collect(), + WireModel::CYAN, + false, + ) +} // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["ATTDEF"] }); // AttdefCommand +inventory::submit!(crate::command::CommandRegistration { names: &["ATTDEF", "-ATTDEF", "AFLAGS"] }); + +/// A value the ATTDEF dialog takes from the drawing (its ⌖ buttons): typed, +/// or measured from two points. Hands the value back to reopen the dialog. +pub struct AttdefPickCommand { + /// "H" height, "R" rotation, "W" boundary width. + kind: &'static str, + current: String, + first: Option, + plane: WorkingPlane, +} + +impl AttdefPickCommand { + pub fn new(kind: &'static str, current: String) -> Self { + Self { kind, current, first: None, plane: WorkingPlane::default() } + } + + fn back(&self, value: &str) -> CmdResult { + CmdResult::Dispatch(format!("_ATTDEF_PICKED {} {}", self.kind, value)) + } +} + +impl CadCommand for AttdefPickCommand { + fn name(&self) -> &'static str { + "ATTDEF" + } + + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + + fn prompt(&self) -> String { + match (self.kind, self.first.is_some()) { + (_, true) => "Specify second point:".into(), + ("H", _) => format!("Specify height <{}>:", self.current), + ("R", _) => format!("Specify rotation angle <{}>:", self.current), + _ => "Specify width:".into(), + } + } + + fn input_kind(&self) -> InputKind { + InputKind::Point + } + + fn on_text_input(&mut self, text: &str) -> Option { + let raw = text.trim(); + if raw.is_empty() { + return Some(self.on_enter()); + } + match raw.parse::() { + Ok(v) if self.kind == "R" || v > 0.0 => Some(self.back(&short(v))), + Ok(_) => Some(CmdResult::ReportError("Value must be positive and nonzero.".into())), + Err(_) => Some(CmdResult::ReportError("Requires numeric distance or two points.".into())), + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + let Some(first) = self.first else { + self.first = Some(pt); + return CmdResult::NeedPoint; + }; + let (a, b) = (self.plane.to_local(first), self.plane.to_local(pt)); + let value = if self.kind == "R" { + (b.y - a.y).atan2(b.x - a.x).to_degrees() + } else { + a.truncate().distance(b.truncate()) + }; + self.back(&short(value)) + } + + fn on_enter(&mut self) -> CmdResult { + let current = self.current.clone(); + self.back(¤t) + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Dispatch("_ATTDEF_PICKED".into()) + } +} diff --git a/src/modules/draw/mod.rs b/src/modules/draw/mod.rs index a9db09f43..b5297b206 100644 --- a/src/modules/draw/mod.rs +++ b/src/modules/draw/mod.rs @@ -31,7 +31,7 @@ impl CadModule for DrawModule { use crate::modules::annotate::{ angular_dim, leader_cmd, linear_dim, mleader_cmd, mtext, radius_dim, text, }; - use crate::modules::insert::{create_block, insert_block}; + use crate::modules::insert::{attedit, create_block, edit_block, insert_block}; use clipboard::{copy_clip, cut, paste}; use draw::{arc, circle, ellipse, hatch, line, polyline, shapes}; use groups::{group, ungroup}; @@ -200,9 +200,13 @@ impl CadModule for DrawModule { }, RibbonGroup { title: "Block", + // Slide-out: Define / Manage / Synchronize Attributes and + // Set Base Point. tools: vec![ - RibbonItem::LargeTool(create_block::tool()), RibbonItem::LargeTool(insert_block::tool()), + RibbonItem::Tool(create_block::tool()), + RibbonItem::Tool(edit_block::tool()), + RibbonItem::Tool(attedit::tool()), ], }, RibbonGroup { diff --git a/src/modules/insert/attsync.rs b/src/modules/insert/attsync.rs deleted file mode 100644 index ec7a1304c..000000000 --- a/src/modules/insert/attsync.rs +++ /dev/null @@ -1,10 +0,0 @@ -use crate::modules::{IconKind, ModuleEvent, ToolDef}; -pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/attsync.svg")); -pub fn tool() -> ToolDef { - ToolDef { - id: "ATTSYNC", - label: "Synchronize", - icon: ICON, - event: ModuleEvent::Command("ATTSYNC".to_string()), - } -} diff --git a/src/modules/insert/base_point.rs b/src/modules/insert/base_point.rs index 689b28a1a..f0984affa 100644 --- a/src/modules/insert/base_point.rs +++ b/src/modules/insert/base_point.rs @@ -1,19 +1,8 @@ use crate::t; use crate::command::{CadCommand, CmdResult}; -use crate::modules::{IconKind, ModuleEvent, ToolDef}; use glam::DVec3; -pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/base_point.svg")); -pub fn tool() -> ToolDef { - ToolDef { - id: "BASE", - label: "Set Base\nPoint", - icon: ICON, - event: ModuleEvent::Command("BASE".to_string()), - } -} - // ── Command implementation ──────────────────────────────────────────────── /// Picks one point and delegates to the inline `BASE ` handler, diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index b79112659..976901322 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -1,14 +1,13 @@ // Insert module — references, point clouds, blocks, attributes, import, content. mod attdef; -mod attedit; +pub(crate) mod attedit; mod attman; -mod attsync; pub mod base_point; mod content_browser; pub(crate) mod create_block; mod design_center; -mod edit_block; +pub(crate) mod edit_block; pub(crate) mod image_transparency; pub(crate) mod insert_block; mod landxml; @@ -106,19 +105,17 @@ impl CadModule for InsertModule { tools: vec![ RibbonItem::LargeTool(mview_block::tool()), RibbonItem::LargeTool(insert_block::tool()), - RibbonItem::Tool(create_block::tool()), - RibbonItem::Tool(edit_block::tool()), - RibbonItem::Tool(base_point::tool()), + RibbonItem::LargeTool(attedit::tool()), ], }, - // ── Attributes ──────────────────────────────────────────────────── + // ── Block Definition (slide-out: Set Base Point, Synchronize) ───── RibbonGroup { - title: "Attributes", + title: "Block Definition", tools: vec![ + RibbonItem::LargeTool(create_block::tool()), RibbonItem::LargeTool(attdef::tool()), - RibbonItem::LargeTool(attedit::tool()), - RibbonItem::Tool(attman::tool()), - RibbonItem::Tool(attsync::tool()), + RibbonItem::LargeTool(attman::tool()), + RibbonItem::LargeTool(edit_block::tool()), ], }, // ── Import ──────────────────────────────────────────────────────── diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index ddddc40fd..4617de531 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -617,6 +617,18 @@ fn build_defn( continue } _ => { + // A standalone definition draws its tag; as block content a + // constant one draws its value. + let shown; + let entity = match entity { + EntityType::AttributeDefinition(ad) => { + let mut value = ad.clone(); + value.tag = ad.default_value.clone(); + shown = EntityType::AttributeDefinition(value); + &shown + } + other => other, + }; // A wide polyline inside a block carries its `world_width` on // the LocalWire; `emit_wire` scales it by the insert transform // so the shader band matches the scaled geometry (same band the diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index 8ad7d7eaa..fbebf55ee 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -106,6 +106,9 @@ const TOOLS: &[Tool] = &[ #[derive(Clone, Copy)] struct Panel { id: &'static str, + /// A command the owning ribbon group must hold, for titles more than one + /// tab uses ("Block" on Home and Insert). + anchor: Option<&'static str>, title_id: &'static str, title: &'static str, tools: &'static [Tool], @@ -121,25 +124,60 @@ const REFERENCE_TOOLS: &[Tool] = &[Tool { }]; pub(super) const REFERENCE_PANEL_ID: &str = "reference_extension"; +const fn icon_tool(command: &'static str, label: &'static str, icon: &'static [u8]) -> Tool { + Tool { command, label, icon, options: &[] } +} + +/// Home > Block slide-out. +const HOME_BLOCK_TOOLS: &[Tool] = &[ + icon_tool("ATTDEF", "Define Attributes", include_bytes!("../../../assets/icons/attdef.svg")), + icon_tool("ATTMAN", "Manage Attributes", include_bytes!("../../../assets/icons/attman.svg")), + icon_tool("ATTSYNC", "Synchronize", include_bytes!("../../../assets/icons/attsync.svg")), + icon_tool("BASE", "Set Base Point", include_bytes!("../../../assets/icons/base_point.svg")), +]; + +/// Insert > Block Definition slide-out. +const BLOCK_DEFINITION_TOOLS: &[Tool] = &[ + icon_tool("BASE", "Set Base Point", include_bytes!("../../../assets/icons/base_point.svg")), + icon_tool("ATTSYNC", "Synchronize", include_bytes!("../../../assets/icons/attsync.svg")), +]; + const PANELS: &[Panel] = &[ Panel { id: PANEL_ID, + anchor: None, title_id: TITLE_ID, title: "Draw", tools: TOOLS, }, Panel { id: "modify_extension", + anchor: None, title_id: "modify_extension_title", title: "Modify", tools: super::modify_panel::TOOLS, }, Panel { id: REFERENCE_PANEL_ID, + anchor: None, title_id: "reference_extension_title", title: "Reference", tools: REFERENCE_TOOLS, }, + Panel { + id: "home_block_extension", + anchor: Some("BEDIT"), + title_id: "home_block_extension_title", + title: "Block", + tools: HOME_BLOCK_TOOLS, + }, + Panel { + id: "block_definition_extension", + anchor: None, + title_id: "block_definition_extension_title", + title: "Block Definition", + tools: BLOCK_DEFINITION_TOOLS, + }, ]; /// Command, label and icon of every tool in the expanded Draw and Modify @@ -169,10 +207,16 @@ pub(super) fn parent_panel(id: &str) -> Option<&'static str> { panel_for_dropdown(id).map(|panel| panel.id) } -pub(super) fn group_title<'a>(title: &'static str, open: &Option) -> Element<'a, Message> { - let Some(panel) = PANELS - .iter() - .find(|panel| panel.title == title && !panel.tools.is_empty()) +pub(super) fn group_title<'a>( + title: &'static str, + group_ids: &[&'static str], + open: &Option, +) -> Element<'a, Message> { + let Some(panel) = PANELS.iter().copied().find(|panel| { + panel.title == title + && !panel.tools.is_empty() + && panel.anchor.is_none_or(|anchor| group_ids.contains(&anchor)) + }) else { return container(text(t!(title)).size(9).style(muted_text_style)) .padding([1, 4]) diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index dae84c28f..e277820b7 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -1397,7 +1397,11 @@ fn render_group<'a>( column![ tools_el, - draw_panel::group_title(group.title, open_dd), + draw_panel::group_title( + group.title, + &group.tools.iter().filter_map(item_id).collect::>(), + open_dd, + ), ] .align_x(iced::Center) .spacing(0) diff --git a/src/ui/window/attdef_dialog.rs b/src/ui/window/attdef_dialog.rs new file mode 100644 index 000000000..66bee80d4 --- /dev/null +++ b/src/ui/window/attdef_dialog.rs @@ -0,0 +1,350 @@ +//! Attribute Definition (ATTDEF) and Edit Attribute Definition dialogs: +//! cards with accent headings, a help button on the left and the named +//! action on the right, like the other drawing dialogs. + +use iced::widget::{button, checkbox, column, container, pick_list, row, text, text_input, Space}; +use iced::{Element, Fill, Length}; + +use crate::app::Message; +use crate::modules::draw::draw::attdef::{ + Justify, AFLAG_CONSTANT, AFLAG_INVISIBLE, AFLAG_LOCK, AFLAG_MULTILINE, AFLAG_PRESET, + AFLAG_VERIFY, +}; +use crate::t; +use crate::ui::style::common::muted_style; +use crate::ui::style::form::{button_style, dialog_button, field_style}; +use crate::ui::window::pdf_dialogs::{accent_text, card_style}; + +/// One edit in either attribute definition dialog. +#[derive(Debug, Clone)] +pub enum AttdefDialogMsg { + Mode(u8, bool), + Annotative(bool), + Tag(String), + Prompt(String), + Default(String), + Justify(JustifyChoice), + Style(String), + Height(String), + Rotation(String), + Width(String), + OnScreen(bool), + Coord(usize, String), + AlignBelow(bool), + PickHeight, + PickRotation, + PickWidth, + Ok, + Help, + EditTag(String), + EditPrompt(String), + EditDefault(String), + EditOk, +} + +/// A justification as the dialog list shows it (translated label). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct JustifyChoice(pub Justify); + +impl std::fmt::Display for JustifyChoice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&t!(self.0.label())) + } +} + +/// The ATTDEF dialog's fields. +#[derive(Debug, Clone)] +pub struct AttdefDialogState { + pub aflags: u8, + pub annotative: bool, + pub tag: String, + pub prompt: String, + pub default: String, + pub justify: Justify, + pub style: String, + pub styles: Vec, + pub height: String, + pub rotation: String, + pub width: String, + pub on_screen: bool, + pub coords: [String; 3], + pub align_below: bool, + /// A definition was placed before in this session. + pub can_align_below: bool, +} + +/// The Edit Attribute Definition dialog's fields. +#[derive(Debug, Clone)] +pub struct AttdefEditState { + pub handle: codec::Handle, + pub tag: String, + pub prompt: String, + pub default: String, + pub constant: bool, +} + +fn msg(m: AttdefDialogMsg) -> Message { + Message::AttdefDialog(m) +} + +fn card<'a>(title: String, content: impl Into>) -> Element<'a, Message> { + container(column![text(title.to_uppercase()).size(10).style(accent_text), content.into()].spacing(8)) + .padding([10, 12]) + .width(Fill) + .style(card_style) + .into() +} + +fn labelled<'a>(label: String, field: Element<'a, Message>, enabled: bool) -> Element<'a, Message> { + let label = text(label).size(12).width(Length::Fixed(118.0)); + let label = if enabled { label } else { label.style(muted_style) }; + row![label, field].spacing(8).align_y(iced::Center).into() +} + +fn input<'a>(value: &str, enabled: bool, on: fn(String) -> AttdefDialogMsg) -> Element<'a, Message> { + let mut field = text_input("", value).size(12).padding([5, 8]).width(Fill).style(field_style); + if enabled { + field = field.on_input(move |v| msg(on(v))); + } + field.into() +} + +/// The small button beside a field that takes the value from the drawing. +fn pick<'a>(glyph: &'static str, tip: String, message: Option) -> Element<'a, Message> { + let b = button(text(glyph).size(13).align_x(iced::Center).width(Fill)) + .style(button_style(false)) + .padding([4, 0]) + .width(Length::Fixed(28.0)); + let b = match message { + Some(m) => b.on_press(msg(m)), + None => b, + }; + iced::widget::tooltip(b, text(tip).size(11), iced::widget::tooltip::Position::Bottom).into() +} + +fn mode<'a>(label: String, on: bool, bit: u8) -> Element<'a, Message> { + checkbox(on) + .label(label) + .text_size(12) + .size(14) + .on_toggle(move |v| msg(AttdefDialogMsg::Mode(bit, v))) + .into() +} + +fn footer<'a>(action: std::borrow::Cow<'static, str>, ok: AttdefDialogMsg) -> Element<'a, Message> { + row![ + button(text("?").size(12)) + .on_press(msg(AttdefDialogMsg::Help)) + .style(button_style(false)) + .padding([5, 11]), + Space::new().width(Fill), + dialog_button(t!("Cancel"), Message::CloseModal, false), + dialog_button(action, msg(ok), true), + ] + .spacing(6) + .align_y(iced::Center) + .into() +} + +pub fn view<'a>(state: &'a AttdefDialogState, sizing: crate::ui::modal::ModalSizing) -> Element<'a, Message> { + let f = state.aflags; + let constant = f & AFLAG_CONSTANT != 0; + let multiline = f & AFLAG_MULTILINE != 0; + let modes = card( + t!("Mode").into_owned(), + column![ + mode(t!("Invisible").into_owned(), f & AFLAG_INVISIBLE != 0, AFLAG_INVISIBLE), + mode(t!("Constant").into_owned(), constant, AFLAG_CONSTANT), + mode(t!("Verify").into_owned(), f & AFLAG_VERIFY != 0, AFLAG_VERIFY), + mode(t!("Preset").into_owned(), f & AFLAG_PRESET != 0, AFLAG_PRESET), + mode(t!("Lock position").into_owned(), f & AFLAG_LOCK != 0, AFLAG_LOCK), + mode(t!("Multiple lines").into_owned(), multiline, AFLAG_MULTILINE), + ] + .spacing(7), + ); + let coords_on = !state.on_screen && !state.align_below; + let coord = |i: usize, axis: &'static str| -> Element<'a, Message> { + let mut field = text_input("", &state.coords[i]).size(12).padding([5, 8]).width(Fill).style(field_style); + if coords_on { + field = field.on_input(move |v| msg(AttdefDialogMsg::Coord(i, v))); + } + let label = text(axis).size(12).width(Length::Fixed(14.0)); + let label = if coords_on { label } else { label.style(muted_style) }; + row![label, field].spacing(8).align_y(iced::Center).into() + }; + let mut on_screen = checkbox(state.on_screen) + .label(t!("Specify on-screen").into_owned()) + .text_size(12) + .size(14); + if !state.align_below { + on_screen = on_screen.on_toggle(|v| msg(AttdefDialogMsg::OnScreen(v))); + } + let insertion = card( + t!("Insertion Point").into_owned(), + column![on_screen, coord(0, "X"), coord(1, "Y"), coord(2, "Z")].spacing(7), + ); + let attribute = card( + t!("Attribute").into_owned(), + column![ + labelled(t!("Tag").into_owned(), input(&state.tag, true, AttdefDialogMsg::Tag), true), + labelled( + t!("Prompt").into_owned(), + input(&state.prompt, !constant, AttdefDialogMsg::Prompt), + !constant + ), + labelled( + if constant { t!("Value") } else { t!("Default") }.into_owned(), + row![ + input(&state.default, true, AttdefDialogMsg::Default), + // No field picker in this application yet. + pick("ƒ", t!("Insert field").into_owned(), None), + ] + .spacing(6) + .into(), + true + ), + ] + .spacing(8), + ); + let text_on = !state.align_below; + let justify: Element<'a, Message> = if text_on && !multiline { + pick_list( + Some(JustifyChoice(state.justify)), + Justify::ALL.map(JustifyChoice).to_vec(), + |c: &JustifyChoice| c.to_string(), + ) + .on_select(|c| msg(AttdefDialogMsg::Justify(c))) + .text_size(12) + .padding([5, 8]) + .width(Fill) + .into() + } else { + container(text(JustifyChoice(state.justify).to_string()).size(12).style(muted_style)) + .padding([5, 8]) + .width(Fill) + .into() + }; + let style: Element<'a, Message> = if text_on { + pick_list(Some(state.style.clone()), state.styles.clone(), |s: &String| s.clone()) + .on_select(|s| msg(AttdefDialogMsg::Style(s))) + .text_size(12) + .padding([5, 8]) + .width(Fill) + .into() + } else { + container(text(state.style.clone()).size(12).style(muted_style)).padding([5, 8]).width(Fill).into() + }; + let mut annotative = checkbox(state.annotative).label(t!("Annotative").into_owned()).text_size(12).size(14); + if text_on { + annotative = annotative.on_toggle(|v| msg(AttdefDialogMsg::Annotative(v))); + } + let size_on = text_on && state.justify != Justify::Align; + let rot_on = text_on && !state.justify.two_point(); + let settings = card( + t!("Text Settings").into_owned(), + column![ + labelled(t!("Justification").into_owned(), justify, text_on && !multiline), + labelled(t!("Text style").into_owned(), style, text_on), + row![Space::new().width(Length::Fixed(126.0)), annotative], + labelled( + t!("Text height").into_owned(), + row![ + input(&state.height, size_on, AttdefDialogMsg::Height), + pick("⌖", t!("Specify on-screen").into_owned(), size_on.then_some(AttdefDialogMsg::PickHeight)), + ] + .spacing(6) + .into(), + size_on + ), + labelled( + t!("Rotation").into_owned(), + row![ + input(&state.rotation, rot_on, AttdefDialogMsg::Rotation), + pick("⌖", t!("Specify on-screen").into_owned(), rot_on.then_some(AttdefDialogMsg::PickRotation)), + ] + .spacing(6) + .into(), + rot_on + ), + labelled( + t!("Boundary width").into_owned(), + row![ + input(&state.width, text_on && multiline, AttdefDialogMsg::Width), + pick( + "⌖", + t!("Specify on-screen").into_owned(), + (text_on && multiline).then_some(AttdefDialogMsg::PickWidth) + ), + ] + .spacing(6) + .into(), + text_on && multiline + ), + ] + .spacing(8), + ); + let mut align = checkbox(state.align_below) + .label(t!("Align below previous attribute definition").into_owned()) + .text_size(12) + .size(14); + if state.can_align_below { + align = align.on_toggle(|v| msg(AttdefDialogMsg::AlignBelow(v))); + } + let body = row![ + column![modes, insertion].spacing(10).width(Length::Fixed(200.0)), + column![attribute, settings].spacing(10).width(Fill), + ] + .spacing(10); + column![body, align, footer(t!("Define"), AttdefDialogMsg::Ok)] + .spacing(10) + .padding([10, 12]) + .width(sizing.width) + .into() +} + +pub fn view_edit<'a>(state: &'a AttdefEditState, sizing: crate::ui::modal::ModalSizing) -> Element<'a, Message> { + let field = |label: String, value: &'a str, enabled: bool, on: fn(String) -> AttdefDialogMsg| { + let mut input = text_input("", value).size(12).padding([5, 8]).width(Fill).style(field_style); + if enabled { + input = input.on_input(move |v| msg(on(v))); + } + let label = text(label).size(12).width(Length::Fixed(80.0)); + let label = if enabled { label } else { label.style(muted_style) }; + row![label, input].spacing(8).align_y(iced::Center) + }; + let content = card( + t!("Attribute Definition").into_owned(), + column![ + field(t!("Tag").into_owned(), &state.tag, true, AttdefDialogMsg::EditTag), + field(t!("Prompt").into_owned(), &state.prompt, !state.constant, AttdefDialogMsg::EditPrompt), + row![ + field( + if state.constant { t!("Value") } else { t!("Default") }.into_owned(), + &state.default, + true, + AttdefDialogMsg::EditDefault + ), + pick("ƒ", t!("Insert field").into_owned(), None), + ] + .spacing(6) + .align_y(iced::Center), + ] + .spacing(8), + ); + column![content, footer(t!("Apply"), AttdefDialogMsg::EditOk)] + .spacing(10) + .padding([10, 12]) + .width(sizing.width) + .into() +} + +/// The tag messages the reference shows for the dialogs. +pub fn tag_error(tag: &str) -> Option<&'static str> { + if tag.trim().is_empty() { + Some("The tag cannot be empty.") + } else if tag.trim().contains(char::is_whitespace) { + Some("The tag may not contain spaces.") + } else { + None + } +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 260b93cde..af3a8ebd1 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -1,4 +1,5 @@ pub mod about; +pub mod attdef_dialog; pub mod block_definition; pub mod block_palette; pub mod browser; diff --git a/src/ui/window/pdf_dialogs.rs b/src/ui/window/pdf_dialogs.rs index a59d918b1..685409323 100644 --- a/src/ui/window/pdf_dialogs.rs +++ b/src/ui/window/pdf_dialogs.rs @@ -233,13 +233,13 @@ pub fn click_page(selected: &mut Vec, anchor: &mut usize, page: usize, ct // accent-coloured card titles, segmented choices and toggle chips, with the // help button on the left of the footer and the named action on the right. -fn accent_text(theme: &Theme) -> text::Style { +pub(crate) fn accent_text(theme: &Theme) -> text::Style { text::Style { color: Some(theme.palette().primary.base.color), } } -fn card_style(theme: &Theme) -> container::Style { +pub(crate) fn card_style(theme: &Theme) -> container::Style { container::Style { background: Some(Background::Color(theme.palette().background.weak.color)), border: Border { From c992b73cdfb69883dbc8649503e0d6b3103735ec Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:16:06 +0300 Subject: [PATCH 2/4] ATTDEF: Fit/Align attribute text, TEXTSIZE, single-line restart Align and Fit attributes run their text between the two baseline points, an ATTDEF height becomes the next text height, and Multiple lines is not carried to the next definition (measured). Co-Authored-By: Claude Opus 5.5 --- src/app/command_driver/entity_commit.rs | 6 +++++ src/entities/attribute.rs | 33 ++++++++++++++++++++++++- src/modules/draw/draw/attdef.rs | 4 ++- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/app/command_driver/entity_commit.rs b/src/app/command_driver/entity_commit.rs index 2fcb4e256..13b3c1ddf 100644 --- a/src/app/command_driver/entity_commit.rs +++ b/src/app/command_driver/entity_commit.rs @@ -220,6 +220,12 @@ impl OpenCADStudio { // Record where this draw ended so ARC_CONT can continue from it // (before `entity` is moved into commit_entity). self.update_cont_anchor(&entity); + // ATTDEF's height becomes the next text height (TEXTSIZE), as TEXT's does. + if let codec::EntityType::AttributeDefinition(attribute) = &entity { + if self.tabs[i].active_cmd.as_ref().is_some_and(|cmd| cmd.name().ends_with("ATTDEF")) { + self.tabs[i].scene.document.header.text_height = attribute.height; + } + } let label = self.history_label_from_active_cmd(i, "ENTITY"); let delta_safe = self.delta_add_safe(i, &entity); let pending = self.begin_undo(i, label, 1, delta_safe); diff --git a/src/entities/attribute.rs b/src/entities/attribute.rs index 2247cfc29..aa382cc08 100644 --- a/src/entities/attribute.rs +++ b/src/entities/attribute.rs @@ -101,7 +101,7 @@ fn mtext_flag_str(f: MTextFlag) -> &'static str { /// Render text strokes for an attribute, honouring alignment, oblique angle, /// width factor, generation flags (backward / upside-down), text-style /// resolution, and basic multiline splitting on `\n` / `\\P`. -fn build_attr_render(input: AttrTextInputs<'_>, document: &codec::CadDocument) -> RenderEntity { +fn build_attr_render(mut input: AttrTextInputs<'_>, document: &codec::CadDocument) -> RenderEntity { let normal = (input.normal.x, input.normal.y, input.normal.z); let (wsx, wsy, wsz) = transform::ocs_point_to_wcs( ( @@ -147,6 +147,37 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &codec::CadDocument) - resolved.oblique_angle }; + // Align / Fit run the text along the baseline between the two points: + // Fit stretches its width, Align scales its height too. + let (mut rotation, mut width_factor) = (rotation, width_factor); + let span = ( + input.alignment_point.x - input.insertion_point.x, + input.alignment_point.y - input.insertion_point.y, + ); + let length = span.0.hypot(span.1); + if matches!(input.horizontal_alignment, AHA::Aligned | AHA::Fit) + && length > 1.0e-9 + && !input.value.contains("\\P") + { + if let Some(b) = text_local_bounds( + &resolved.font_name, + &resolve_dxf_special_chars(input.value), + input.height as f32, + width_factor.abs(), + oblique_angle, + ) + .filter(|b| b.advance > 1.0e-6) + { + let scale = length / b.advance as f64; + rotation = span.1.atan2(span.0) as f32; + if matches!(input.horizontal_alignment, AHA::Aligned) { + input.height *= scale; + } else { + width_factor *= scale as f32; + } + } + } + // Anchor selection mirrors Text: only Left/Baseline uses insertion_point; // every other alignment uses alignment_point. let needs_align_pt = !(matches!(input.horizontal_alignment, AHA::Left) diff --git a/src/modules/draw/draw/attdef.rs b/src/modules/draw/draw/attdef.rs index 786e95ecd..d6e5c83a9 100644 --- a/src/modules/draw/draw/attdef.rs +++ b/src/modules/draw/draw/attdef.rs @@ -498,9 +498,11 @@ impl AttdefCommand { } /// Keep AFLAGS / Annotative and the placed definition for the next ATTDEF. +/// Multiple lines is not kept: the reference starts the next definition +/// single-line again. pub fn remember(spec: &AttdefSpec, entity: &EntityType) { let mut s = session(); - s.aflags = spec.aflags; + s.aflags = spec.aflags & !AFLAG_MULTILINE; s.annotative = spec.annotative; if let EntityType::AttributeDefinition(a) = entity { s.last = Some(a.clone()); From 7f55043210e480760a79ac04f158e42074eb07b9 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:11:45 +0300 Subject: [PATCH 3/4] ATTDEF dialog error band and multi-line editor; ATTDISP sets ATTMODE Validation messages show in the dialogs, the Default value of a multi-line definition can be edited in the multi-line text editor, "Specify on-screen" is kept for the session, ATTDISP sets ATTMODE with the reference prompt instead of changing the definitions, and Insert > Block gets the Edit Attribute (Single / Multiple) and attribute display lists. Co-Authored-By: Claude Opus 5.5 --- locales/ar-SA/opencadstudio.ftl | 9 ++++ locales/bg-BG/opencadstudio.ftl | 9 ++++ locales/cs-CZ/opencadstudio.ftl | 9 ++++ locales/de-DE/opencadstudio.ftl | 9 ++++ locales/el-GR/opencadstudio.ftl | 9 ++++ locales/en-US/opencadstudio.ftl | 9 ++++ locales/es-ES/opencadstudio.ftl | 9 ++++ locales/fi-FI/opencadstudio.ftl | 9 ++++ locales/fr-FR/opencadstudio.ftl | 9 ++++ locales/hi-IN/opencadstudio.ftl | 9 ++++ locales/hu-HU/opencadstudio.ftl | 9 ++++ locales/it-IT/opencadstudio.ftl | 9 ++++ locales/ja-JP/opencadstudio.ftl | 9 ++++ locales/ko-KR/opencadstudio.ftl | 9 ++++ locales/nl-NL/opencadstudio.ftl | 9 ++++ locales/pl-PL/opencadstudio.ftl | 9 ++++ locales/pt-BR/opencadstudio.ftl | 9 ++++ locales/ru-RU/opencadstudio.ftl | 9 ++++ locales/tr-TR/opencadstudio.ftl | 9 ++++ locales/zh-CN/opencadstudio.ftl | 9 ++++ locales/zh-TW/opencadstudio.ftl | 9 ++++ src/app/commands/attdef.rs | 55 ++++++++++++++++--- src/app/commands/draw.rs | 74 +++++++------------------ src/app/commands/mod.rs | 3 ++ src/app/update/mod.rs | 6 +++ src/locale_catalog.rs | 9 ++++ src/modules/draw/draw/attdef.rs | 95 +++++++++++++++++++++++++++++++++ src/modules/insert/mod.rs | 23 +++++++- src/ui/window/attdef_dialog.rs | 65 +++++++++++++++++++--- 29 files changed, 449 insertions(+), 70 deletions(-) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 34db39d32..90ca7ab4d 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -4044,6 +4044,15 @@ insert = .attdef-define-attributes = Define Attributes .attdef-manage-attributes = Manage Attributes .attdef-set-base-point = Set Base Point + .attdef-attmode-retain = Retain Attribute Display + .attdef-attmode-all = Display All Attributes + .attdef-attmode-none = Hide All Attributes + .attdef-edit-attribute = Edit Attribute + .attdef-multiline-editor = Multiline editor + .attdef-tag-empty = The tag cannot be empty. + .attdef-tag-spaces = The tag may not contain spaces. + .attdef-positive = Value must be positive and nonzero. + .attdef-angle = Requires valid numeric angle or second point. model = .create = إنشاء .boolean = عمليات منطقية diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index b67ac4bf5..2db9fb1be 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -4031,6 +4031,15 @@ insert = .attdef-define-attributes = Define Attributes .attdef-manage-attributes = Manage Attributes .attdef-set-base-point = Set Base Point + .attdef-attmode-retain = Retain Attribute Display + .attdef-attmode-all = Display All Attributes + .attdef-attmode-none = Hide All Attributes + .attdef-edit-attribute = Edit Attribute + .attdef-multiline-editor = Multiline editor + .attdef-tag-empty = The tag cannot be empty. + .attdef-tag-spaces = The tag may not contain spaces. + .attdef-positive = Value must be positive and nonzero. + .attdef-angle = Requires valid numeric angle or second point. model = .create = Създаване .boolean = Булеви операции diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index d32997df1..568d18c19 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -4029,6 +4029,15 @@ insert = .attdef-define-attributes = Define Attributes .attdef-manage-attributes = Manage Attributes .attdef-set-base-point = Set Base Point + .attdef-attmode-retain = Retain Attribute Display + .attdef-attmode-all = Display All Attributes + .attdef-attmode-none = Hide All Attributes + .attdef-edit-attribute = Edit Attribute + .attdef-multiline-editor = Multiline editor + .attdef-tag-empty = The tag cannot be empty. + .attdef-tag-spaces = The tag may not contain spaces. + .attdef-positive = Value must be positive and nonzero. + .attdef-angle = Requires valid numeric angle or second point. model = .create = Vytvořit .boolean = Booleovské operace diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index f2a104e7e..854583d3c 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -4030,6 +4030,15 @@ insert = .attdef-define-attributes = Define Attributes .attdef-manage-attributes = Manage Attributes .attdef-set-base-point = Set Base Point + .attdef-attmode-retain = Retain Attribute Display + .attdef-attmode-all = Display All Attributes + .attdef-attmode-none = Hide All Attributes + .attdef-edit-attribute = Edit Attribute + .attdef-multiline-editor = Multiline editor + .attdef-tag-empty = The tag cannot be empty. + .attdef-tag-spaces = The tag may not contain spaces. + .attdef-positive = Value must be positive and nonzero. + .attdef-angle = Requires valid numeric angle or second point. model = .create = Erstellen .boolean = Boolesche Operationen diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index e8f306d87..1bc916265 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -4032,6 +4032,15 @@ insert = .attdef-define-attributes = Define Attributes .attdef-manage-attributes = Manage Attributes .attdef-set-base-point = Set Base Point + .attdef-attmode-retain = Retain Attribute Display + .attdef-attmode-all = Display All Attributes + .attdef-attmode-none = Hide All Attributes + .attdef-edit-attribute = Edit Attribute + .attdef-multiline-editor = Multiline editor + .attdef-tag-empty = The tag cannot be empty. + .attdef-tag-spaces = The tag may not contain spaces. + .attdef-positive = Value must be positive and nonzero. + .attdef-angle = Requires valid numeric angle or second point. model = .create = Δημιουργία .boolean = Δυαδική πράξη diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index a3f43b6d6..fc3a39030 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -4033,6 +4033,15 @@ insert = .attdef-define-attributes = Define Attributes .attdef-manage-attributes = Manage Attributes .attdef-set-base-point = Set Base Point + .attdef-attmode-retain = Retain Attribute Display + .attdef-attmode-all = Display All Attributes + .attdef-attmode-none = Hide All Attributes + .attdef-edit-attribute = Edit Attribute + .attdef-multiline-editor = Multiline editor + .attdef-tag-empty = The tag cannot be empty. + .attdef-tag-spaces = The tag may not contain spaces. + .attdef-positive = Value must be positive and nonzero. + .attdef-angle = Requires valid numeric angle or second point. model = .create = Create .boolean = Boolean diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 8c2857ee7..8154c0646 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -4032,6 +4032,15 @@ insert = .attdef-define-attributes = Define Attributes .attdef-manage-attributes = Manage Attributes .attdef-set-base-point = Set Base Point + .attdef-attmode-retain = Retain Attribute Display + .attdef-attmode-all = Display All Attributes + .attdef-attmode-none = Hide All Attributes + .attdef-edit-attribute = Edit Attribute + .attdef-multiline-editor = Multiline editor + .attdef-tag-empty = The tag cannot be empty. + .attdef-tag-spaces = The tag may not contain spaces. + .attdef-positive = Value must be positive and nonzero. + .attdef-angle = Requires valid numeric angle or second point. model = .create = Crear .boolean = Booleanas diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 2d88f63da..c49419d28 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -4031,6 +4031,15 @@ insert = .attdef-define-attributes = Define Attributes .attdef-manage-attributes = Manage Attributes .attdef-set-base-point = Set Base Point + .attdef-attmode-retain = Retain Attribute Display + .attdef-attmode-all = Display All Attributes + .attdef-attmode-none = Hide All Attributes + .attdef-edit-attribute = Edit Attribute + .attdef-multiline-editor = Multiline editor + .attdef-tag-empty = The tag cannot be empty. + .attdef-tag-spaces = The tag may not contain spaces. + .attdef-positive = Value must be positive and nonzero. + .attdef-angle = Requires valid numeric angle or second point. model = .create = Luo .boolean = Boolen operaatiot diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 741de7b31..8a393e26d 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -4032,6 +4032,15 @@ insert = .attdef-define-attributes = Define Attributes .attdef-manage-attributes = Manage Attributes .attdef-set-base-point = Set Base Point + .attdef-attmode-retain = Retain Attribute Display + .attdef-attmode-all = Display All Attributes + .attdef-attmode-none = Hide All Attributes + .attdef-edit-attribute = Edit Attribute + .attdef-multiline-editor = Multiline editor + .attdef-tag-empty = The tag cannot be empty. + .attdef-tag-spaces = The tag may not contain spaces. + .attdef-positive = Value must be positive and nonzero. + .attdef-angle = Requires valid numeric angle or second point. model = .create = Créer .boolean = Opérations booléennes diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 8cdb4a8ce..ff357f061 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -4020,6 +4020,15 @@ insert = .attdef-define-attributes = Define Attributes .attdef-manage-attributes = Manage Attributes .attdef-set-base-point = Set Base Point + .attdef-attmode-retain = Retain Attribute Display + .attdef-attmode-all = Display All Attributes + .attdef-attmode-none = Hide All Attributes + .attdef-edit-attribute = Edit Attribute + .attdef-multiline-editor = Multiline editor + .attdef-tag-empty = The tag cannot be empty. + .attdef-tag-spaces = The tag may not contain spaces. + .attdef-positive = Value must be positive and nonzero. + .attdef-angle = Requires valid numeric angle or second point. model = .create = बनाएँ .boolean = बूलियन संक्रियाएँ diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index d0d7ee442..053a20ff4 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -4031,6 +4031,15 @@ insert = .attdef-define-attributes = Define Attributes .attdef-manage-attributes = Manage Attributes .attdef-set-base-point = Set Base Point + .attdef-attmode-retain = Retain Attribute Display + .attdef-attmode-all = Display All Attributes + .attdef-attmode-none = Hide All Attributes + .attdef-edit-attribute = Edit Attribute + .attdef-multiline-editor = Multiline editor + .attdef-tag-empty = The tag cannot be empty. + .attdef-tag-spaces = The tag may not contain spaces. + .attdef-positive = Value must be positive and nonzero. + .attdef-angle = Requires valid numeric angle or second point. model = .create = Létrehozás .boolean = Logikai műveletek diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index e005dd34c..541650845 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -4031,6 +4031,15 @@ insert = .attdef-define-attributes = Define Attributes .attdef-manage-attributes = Manage Attributes .attdef-set-base-point = Set Base Point + .attdef-attmode-retain = Retain Attribute Display + .attdef-attmode-all = Display All Attributes + .attdef-attmode-none = Hide All Attributes + .attdef-edit-attribute = Edit Attribute + .attdef-multiline-editor = Multiline editor + .attdef-tag-empty = The tag cannot be empty. + .attdef-tag-spaces = The tag may not contain spaces. + .attdef-positive = Value must be positive and nonzero. + .attdef-angle = Requires valid numeric angle or second point. model = .create = Crea .boolean = Operazioni booleane diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index a76a30075..0a7379a95 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -4020,6 +4020,15 @@ insert = .attdef-define-attributes = Define Attributes .attdef-manage-attributes = Manage Attributes .attdef-set-base-point = Set Base Point + .attdef-attmode-retain = Retain Attribute Display + .attdef-attmode-all = Display All Attributes + .attdef-attmode-none = Hide All Attributes + .attdef-edit-attribute = Edit Attribute + .attdef-multiline-editor = Multiline editor + .attdef-tag-empty = The tag cannot be empty. + .attdef-tag-spaces = The tag may not contain spaces. + .attdef-positive = Value must be positive and nonzero. + .attdef-angle = Requires valid numeric angle or second point. model = .create = 作成 .boolean = ブール演算 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 81e62ae84..6103f015b 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -4031,6 +4031,15 @@ insert = .attdef-define-attributes = Define Attributes .attdef-manage-attributes = Manage Attributes .attdef-set-base-point = Set Base Point + .attdef-attmode-retain = Retain Attribute Display + .attdef-attmode-all = Display All Attributes + .attdef-attmode-none = Hide All Attributes + .attdef-edit-attribute = Edit Attribute + .attdef-multiline-editor = Multiline editor + .attdef-tag-empty = The tag cannot be empty. + .attdef-tag-spaces = The tag may not contain spaces. + .attdef-positive = Value must be positive and nonzero. + .attdef-angle = Requires valid numeric angle or second point. model = .create = 생성 .boolean = 불리언 연산 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index a5963eb1d..79975ad46 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -4030,6 +4030,15 @@ insert = .attdef-define-attributes = Define Attributes .attdef-manage-attributes = Manage Attributes .attdef-set-base-point = Set Base Point + .attdef-attmode-retain = Retain Attribute Display + .attdef-attmode-all = Display All Attributes + .attdef-attmode-none = Hide All Attributes + .attdef-edit-attribute = Edit Attribute + .attdef-multiline-editor = Multiline editor + .attdef-tag-empty = The tag cannot be empty. + .attdef-tag-spaces = The tag may not contain spaces. + .attdef-positive = Value must be positive and nonzero. + .attdef-angle = Requires valid numeric angle or second point. model = .create = Maken .boolean = Booleaanse bewerkingen diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index ba1116512..f1c5ceae0 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -4031,6 +4031,15 @@ insert = .attdef-define-attributes = Define Attributes .attdef-manage-attributes = Manage Attributes .attdef-set-base-point = Set Base Point + .attdef-attmode-retain = Retain Attribute Display + .attdef-attmode-all = Display All Attributes + .attdef-attmode-none = Hide All Attributes + .attdef-edit-attribute = Edit Attribute + .attdef-multiline-editor = Multiline editor + .attdef-tag-empty = The tag cannot be empty. + .attdef-tag-spaces = The tag may not contain spaces. + .attdef-positive = Value must be positive and nonzero. + .attdef-angle = Requires valid numeric angle or second point. model = .create = Utwórz .boolean = Operacje boolowskie diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index b7fc9bbe4..1c3ddf058 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -4032,6 +4032,15 @@ insert = .attdef-define-attributes = Define Attributes .attdef-manage-attributes = Manage Attributes .attdef-set-base-point = Set Base Point + .attdef-attmode-retain = Retain Attribute Display + .attdef-attmode-all = Display All Attributes + .attdef-attmode-none = Hide All Attributes + .attdef-edit-attribute = Edit Attribute + .attdef-multiline-editor = Multiline editor + .attdef-tag-empty = The tag cannot be empty. + .attdef-tag-spaces = The tag may not contain spaces. + .attdef-positive = Value must be positive and nonzero. + .attdef-angle = Requires valid numeric angle or second point. model = .create = Criar .boolean = Operações booleanas diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 92913c7ae..96391bb95 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -4033,6 +4033,15 @@ insert = .attdef-define-attributes = Define Attributes .attdef-manage-attributes = Manage Attributes .attdef-set-base-point = Set Base Point + .attdef-attmode-retain = Retain Attribute Display + .attdef-attmode-all = Display All Attributes + .attdef-attmode-none = Hide All Attributes + .attdef-edit-attribute = Edit Attribute + .attdef-multiline-editor = Multiline editor + .attdef-tag-empty = The tag cannot be empty. + .attdef-tag-spaces = The tag may not contain spaces. + .attdef-positive = Value must be positive and nonzero. + .attdef-angle = Requires valid numeric angle or second point. model = .create = Создание .boolean = Булевы операции diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 83ea6e9bc..e7d0a2914 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -3992,6 +3992,15 @@ insert = .attdef-define-attributes = Öznitelik Tanımla .attdef-manage-attributes = Öznitelikleri Yönet .attdef-set-base-point = Taban Noktası Belirle + .attdef-attmode-retain = Öznitelik görünümünü koru + .attdef-attmode-all = Tüm öznitelikleri göster + .attdef-attmode-none = Tüm öznitelikleri gizle + .attdef-edit-attribute = Öznitelik Düzenle + .attdef-multiline-editor = Çok satırlı düzenleyici + .attdef-tag-empty = Etiket boş olamaz. + .attdef-tag-spaces = Etiket boşluk içeremez. + .attdef-positive = Değer pozitif ve sıfırdan farklı olmalı. + .attdef-angle = Geçerli bir sayısal açı veya ikinci nokta gerekir. model = .create = Oluştur .boolean = Boole işlemleri diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 6357efdd5..8a71f831c 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -4015,6 +4015,15 @@ insert = .attdef-define-attributes = Define Attributes .attdef-manage-attributes = Manage Attributes .attdef-set-base-point = Set Base Point + .attdef-attmode-retain = Retain Attribute Display + .attdef-attmode-all = Display All Attributes + .attdef-attmode-none = Hide All Attributes + .attdef-edit-attribute = Edit Attribute + .attdef-multiline-editor = Multiline editor + .attdef-tag-empty = The tag cannot be empty. + .attdef-tag-spaces = The tag may not contain spaces. + .attdef-positive = Value must be positive and nonzero. + .attdef-angle = Requires valid numeric angle or second point. model = .create = 创建 .boolean = 布尔运算 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index f5f77d4e8..ec5f8dc6c 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -4027,6 +4027,15 @@ insert = .attdef-define-attributes = Define Attributes .attdef-manage-attributes = Manage Attributes .attdef-set-base-point = Set Base Point + .attdef-attmode-retain = Retain Attribute Display + .attdef-attmode-all = Display All Attributes + .attdef-attmode-none = Hide All Attributes + .attdef-edit-attribute = Edit Attribute + .attdef-multiline-editor = Multiline editor + .attdef-tag-empty = The tag cannot be empty. + .attdef-tag-spaces = The tag may not contain spaces. + .attdef-positive = Value must be positive and nonzero. + .attdef-angle = Requires valid numeric angle or second point. model = .create = 建立 .boolean = 布林運算 diff --git a/src/app/commands/attdef.rs b/src/app/commands/attdef.rs index 65dca2328..ff5dabea1 100644 --- a/src/app/commands/attdef.rs +++ b/src/app/commands/attdef.rs @@ -46,6 +46,19 @@ impl OpenCADStudio { self.push_ucs_to_cmd(i); Some(Task::none()) } + "ATTMODE0" => self.dispatch_draw("ATTDISP OFF", i), + "ATTMODE1" => self.dispatch_draw("ATTDISP NORMAL", i), + "ATTMODE2" => self.dispatch_draw("ATTDISP ON", i), + "_ATTDEF_VALUE" => { + let value = attdef::session().editor_value.take(); + if let Some(state) = self.attdef_dialog.as_mut() { + if let Some(value) = value { + state.default = value; + } + self.active_modal = Some(crate::app::ModalKind::AttDef); + } + Some(Task::none()) + } cmd if cmd == "_ATTDEF_PICKED" || cmd.starts_with("_ATTDEF_PICKED ") => { let mut parts = cmd.split_whitespace().skip(1); if let (Some(kind), Some(value), Some(state)) = @@ -116,10 +129,11 @@ impl OpenCADStudio { height: short(defaults.height), rotation: "0".into(), width: "0".into(), - on_screen: true, + on_screen: session.on_screen, coords: ["0".into(), "0".into(), "0".into()], align_below: false, can_align_below: session.last.is_some(), + error: None, }); drop(session); self.tabs[i].active_cmd = None; @@ -139,6 +153,7 @@ impl OpenCADStudio { prompt: a.prompt.clone(), default: a.default_value.clone(), constant: a.flags.constant, + error: None, }); self.active_modal = Some(crate::app::ModalKind::AttDefEdit); } @@ -155,6 +170,7 @@ impl OpenCADStudio { .as_ref(), ), AttdefDialogMsg::EditOk => return self.attdef_edit_ok(i), + AttdefDialogMsg::DismissError => edit.error = None, _ => {} } return Task::none(); @@ -201,30 +217,50 @@ impl OpenCADStudio { .as_ref(), ), AttdefDialogMsg::Ok => return self.attdef_dialog_ok(i), + AttdefDialogMsg::DismissError => state.error = None, + AttdefDialogMsg::EditValue => { + // The dialog waits while the multi-line editor collects the value. + let initial = state.default.clone(); + let height = state.height.trim().parse::().ok().filter(|h| *h > 0.0).unwrap_or(2.5); + self.active_modal = None; + attdef::session().editor_value = None; + self.tabs[i].active_cmd = Some(Box::new(attdef::AttdefValueCommand)); + let pos = self.tabs[i].scene.camera.borrow().target; + return self.apply_cmd_result(crate::command::CmdResult::SuspendForMTextInput { + pos, + initial, + height, + }); + } _ => {} } Task::none() } + /// Show a validation message in the dialog; it stays open. + fn attdef_dialog_error(&mut self, error: &str) -> Task { + if let Some(state) = self.attdef_dialog.as_mut() { + state.error = Some(crate::t!(error).into_owned()); + } + Task::none() + } + fn attdef_dialog_ok(&mut self, i: usize) -> Task { let Some(state) = self.attdef_dialog.clone() else { return Task::none(); }; if let Some(error) = tag_error(&state.tag) { - self.command_line.push_error(error); - return Task::none(); + return self.attdef_dialog_error(error); } let number = |s: &str| s.trim().parse::().ok().filter(|v| v.is_finite()); let height = match number(&state.height) { Some(v) if v > 0.0 => v, _ => { - self.command_line.push_error("Value must be positive and nonzero."); - return Task::none(); + return self.attdef_dialog_error("Value must be positive and nonzero."); } }; let Some(rotation) = number(&state.rotation) else { - self.command_line.push_error("Requires valid numeric angle or second point."); - return Task::none(); + return self.attdef_dialog_error("Requires valid numeric angle or second point."); }; let width = number(&state.width).unwrap_or(0.0).max(0.0); let defaults = @@ -250,6 +286,7 @@ impl OpenCADStudio { let mut session = attdef::session(); session.aflags = spec.aflags; session.annotative = spec.annotative; + session.on_screen = state.on_screen; } self.reset_command_start_state(i); if state.align_below { @@ -288,7 +325,9 @@ impl OpenCADStudio { return Task::none(); }; if let Some(error) = tag_error(&state.tag) { - self.command_line.push_error(error); + if let Some(edit) = self.attdef_edit.as_mut() { + edit.error = Some(crate::t!(error).into_owned()); + } return Task::none(); } self.attdef_edit = None; diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index e85c5c522..bb529a6ff 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -183,68 +183,32 @@ impl OpenCADStudio { } } - // ATTDISP — control attribute display visibility. - // ATTDISP ON — make all AttributeDefinitions visible - // ATTDISP OFF — make all AttributeDefinitions invisible - // ATTDISP NORMAL — restore: show only those without the invisible flag + // ATTDISP sets ATTMODE: 0 hides every attribute, 1 shows the + // visible ones, 2 shows them all. The definitions are not touched. "ATTDISP" => { - use crate::command::KeywordCommand; - let c = KeywordCommand::new( - "ATTDISP", - "ATTDISP attribute display [On / Off / Normal]:", - vec![ - ("On", "ON", None), - ("Off", "OFF", None), - ("Normal", "NORMAL", None), - ], + let command = crate::modules::draw::draw::attdef::AttdispCommand::new( + self.tabs[i].scene.document.header.attribute_visibility, ); - self.command_line.push_info(&c.prompt()); - self.tabs[i].active_cmd = Some(Box::new(c)); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); } cmd if cmd.starts_with("ATTDISP ") => { - let sub = cmd.split_whitespace().nth(1).unwrap_or("").to_uppercase(); - match sub.as_str() { - "ON" | "OFF" | "NORMAL" => { - let handles: Vec<_> = self.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| { - matches!(entity, codec::EntityType::AttributeDefinition(_)) - .then_some(entity.common().handle) - }) - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - self.push_undo_snapshot(i, "ATTDISP"); - let mut count = 0usize; - for handle in handles { - if let Some(codec::EntityType::AttributeDefinition(ad)) = - self.tabs[i].scene.document.get_entity_mut(handle) - { - match sub.as_str() { - "ON" => { - ad.flags.invisible = false; - count += 1; - } - "OFF" => { - ad.flags.invisible = true; - count += 1; - } - _ => {} - } - } - } - self.tabs[i].dirty = true; - self.command_line.push_output( - crate::tf!("ATTDISP {sub}: {count} attribute definition(s) updated.") - .as_ref(), - ); - } + let mode = match cmd[8..].trim().to_ascii_uppercase().as_str() { + "OFF" => 0, + "ON" => 2, + "NORMAL" | "N" => 1, _ => { - self.command_line - .push_info(crate::t!("Usage: ATTDISP ON | OFF | NORMAL").as_ref()); + self.command_line.push_error("Invalid option keyword."); + return Some(Task::none()); } + }; + if self.tabs[i].scene.document.header.attribute_visibility != mode { + self.push_undo_snapshot(i, "ATTDISP"); + self.tabs[i].scene.document.header.attribute_visibility = mode; + self.tabs[i].dirty = true; + self.tabs[i].scene.bump_geometry(); } + self.command_line.push_output("Regenerating model."); } "DONUT" => { diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index ac09175ad..6c0027791 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -666,6 +666,9 @@ inventory::submit!(crate::command::CommandRegistration { "POINTCLOUDPOINTMAXLEGACY", "XCLIPFRAME", "WIPEOUTFRAME", + "ATTMODE0", + "ATTMODE1", + "ATTMODE2", "FRAMES0", "FRAMES1", "FRAMES3", diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 7702bf6a6..bb7e0318e 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -344,6 +344,12 @@ impl OpenCADStudio { _ => "FRAMES3", }; self.ribbon.set_dropdown_current("FRAMES_DROPDOWN", current); + let attmode = match tab.scene.document.header.attribute_visibility { + 0 => "ATTMODE0", + 2 => "ATTMODE2", + _ => "ATTMODE1", + }; + self.ribbon.set_dropdown_current("ATTMODE_DROPDOWN", attmode); } fn update_message(&mut self, msg: Message) -> Task { diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index f0f3e1557..7a4ba7dc3 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -175,6 +175,15 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Define Attributes" => Some(("insert", "attdef-define-attributes")), "Manage Attributes" => Some(("insert", "attdef-manage-attributes")), "Set Base Point" => Some(("insert", "attdef-set-base-point")), + "Retain Attribute Display" => Some(("insert", "attdef-attmode-retain")), + "Display All Attributes" => Some(("insert", "attdef-attmode-all")), + "Hide All Attributes" => Some(("insert", "attdef-attmode-none")), + "Edit Attribute" => Some(("insert", "attdef-edit-attribute")), + "Multiline editor" => Some(("insert", "attdef-multiline-editor")), + "The tag cannot be empty." => Some(("insert", "attdef-tag-empty")), + "The tag may not contain spaces." => Some(("insert", "attdef-tag-spaces")), + "Value must be positive and nonzero." => Some(("insert", "attdef-positive")), + "Requires valid numeric angle or second point." => Some(("insert", "attdef-angle")), "Stylization" => Some(("properties", "stylization")), "Color scheme" => Some(("properties", "color-scheme")), "Scan Colors" => Some(("properties", "scan-colors")), diff --git a/src/modules/draw/draw/attdef.rs b/src/modules/draw/draw/attdef.rs index d6e5c83a9..f2d1e0366 100644 --- a/src/modules/draw/draw/attdef.rs +++ b/src/modules/draw/draw/attdef.rs @@ -32,12 +32,18 @@ pub struct AttdefSession { pub aflags: u8, pub annotative: bool, pub last: Option, + /// The dialog's "Specify on-screen" choice, kept for the session. + pub on_screen: bool, + /// Text the multi-line editor handed back to the dialog's Default field. + pub editor_value: Option, } pub static SESSION: Mutex = Mutex::new(AttdefSession { aflags: AFLAG_LOCK, annotative: false, last: None, + on_screen: true, + editor_value: None, }); pub fn session() -> std::sync::MutexGuard<'static, AttdefSession> { @@ -1046,3 +1052,92 @@ impl CadCommand for AttdefPickCommand { CmdResult::Dispatch("_ATTDEF_PICKED".into()) } } + +/// ATTDISP: `Enter attribute visibility setting [Normal/ON/OFF] :`. +/// Hands the chosen setting back as `ATTDISP `; Enter keeps the current +/// one. +pub struct AttdispCommand { + current: &'static str, +} + +impl AttdispCommand { + /// `mode` is ATTMODE: 0 OFF, 1 Normal, 2 ON. + pub fn new(mode: i16) -> Self { + Self { current: attdisp_word(mode) } + } +} + +/// The ATTDISP keyword for an ATTMODE value. +pub fn attdisp_word(mode: i16) -> &'static str { + match mode { + 0 => "OFF", + 2 => "ON", + _ => "Normal", + } +} + +impl CadCommand for AttdispCommand { + fn name(&self) -> &'static str { + "ATTDISP" + } + + fn prompt(&self) -> String { + format!("Enter attribute visibility setting [Normal/ON/OFF] <{}>:", self.current) + } + + fn input_kind(&self) -> InputKind { + InputKind::SingleToken + } + + fn on_text_input(&mut self, text: &str) -> Option { + let word = text.trim().trim_start_matches('_').to_ascii_uppercase(); + if word.is_empty() { + return Some(self.on_enter()); + } + let mode = match word.as_str() { + "N" | "NORMAL" => "NORMAL", + "ON" => "ON", + "OF" | "OFF" => "OFF", + _ => return Some(CmdResult::ReportError("Invalid option keyword.".into())), + }; + Some(CmdResult::Dispatch(format!("ATTDISP {mode}"))) + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +/// The dialog's "…" button: the default value is edited in the multi-line +/// text editor, then handed back to the dialog (`_ATTDEF_VALUE`). +pub struct AttdefValueCommand; + +impl CadCommand for AttdefValueCommand { + fn name(&self) -> &'static str { + "ATTDEF" + } + + fn prompt(&self) -> String { + String::new() + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_editor_text(&mut self, value: String) { + session().editor_value = Some(value); + } + + fn on_editor_closed(&mut self, _committed: bool) -> CmdResult { + CmdResult::Dispatch("_ATTDEF_VALUE".into()) + } +} diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index 976901322..92ee61dcd 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -105,7 +105,28 @@ impl CadModule for InsertModule { tools: vec![ RibbonItem::LargeTool(mview_block::tool()), RibbonItem::LargeTool(insert_block::tool()), - RibbonItem::LargeTool(attedit::tool()), + RibbonItem::LabeledDropdown { + id: "ATTEDIT_DROPDOWN", + label: "Edit Attribute", + icon: attedit::ICON, + items: vec![ + ("ATTEDIT", "Single", attedit::ICON), + ("-ATTEDIT", "Multiple", attedit::ICON), + ], + default: "ATTEDIT", + }, + // ATTMODE: the face shows the drawing's current setting. + RibbonItem::LabeledDropdown { + id: "ATTMODE_DROPDOWN", + label: "", + icon: attedit::ICON, + items: vec![ + ("ATTMODE1", "Retain Attribute Display", attedit::ICON), + ("ATTMODE2", "Display All Attributes", attedit::ICON), + ("ATTMODE0", "Hide All Attributes", attedit::ICON), + ], + default: "ATTMODE1", + }, ], }, // ── Block Definition (slide-out: Set Base Point, Synchronize) ───── diff --git a/src/ui/window/attdef_dialog.rs b/src/ui/window/attdef_dialog.rs index 66bee80d4..c74c81f08 100644 --- a/src/ui/window/attdef_dialog.rs +++ b/src/ui/window/attdef_dialog.rs @@ -34,6 +34,9 @@ pub enum AttdefDialogMsg { PickHeight, PickRotation, PickWidth, + /// Edit the default value in the multi-line text editor. + EditValue, + DismissError, Ok, Help, EditTag(String), @@ -71,6 +74,8 @@ pub struct AttdefDialogState { pub align_below: bool, /// A definition was placed before in this session. pub can_align_below: bool, + /// Validation message shown above the buttons. + pub error: Option, } /// The Edit Attribute Definition dialog's fields. @@ -81,6 +86,7 @@ pub struct AttdefEditState { pub prompt: String, pub default: String, pub constant: bool, + pub error: Option, } fn msg(m: AttdefDialogMsg) -> Message { @@ -131,6 +137,36 @@ fn mode<'a>(label: String, on: bool, bit: u8) -> Element<'a, Message> { .into() } +/// The validation message band, as in the Write Block dialog. +fn error_band<'a>(error: &Option) -> Option> { + let error = error.clone()?; + Some( + container( + row![ + crate::ui::icons::semantic(ICON_WARN, 14.0), + text(error).size(11), + Space::new().width(Fill), + button(text("×").size(12)) + .on_press(msg(AttdefDialogMsg::DismissError)) + .style(button_style(false)) + .padding([1, 6]), + ] + .spacing(8) + .align_y(iced::Center), + ) + .padding([6, 10]) + .width(Fill) + .style(|theme: &iced::Theme| container::Style { + background: Some(iced::Background::Color(theme.palette().danger.base.color.scale_alpha(0.18))), + border: iced::Border { radius: 4.0.into(), ..Default::default() }, + ..Default::default() + }) + .into(), + ) +} + +static ICON_WARN: &[u8] = include_bytes!("../../../assets/icons/ui/warning_triangle.svg"); + fn footer<'a>(action: std::borrow::Cow<'static, str>, ok: AttdefDialogMsg) -> Element<'a, Message> { row![ button(text("?").size(12)) @@ -194,11 +230,18 @@ pub fn view<'a>(state: &'a AttdefDialogState, sizing: crate::ui::modal::ModalSiz ), labelled( if constant { t!("Value") } else { t!("Default") }.into_owned(), - row![ - input(&state.default, true, AttdefDialogMsg::Default), + { + let mut value = row![input(&state.default, true, AttdefDialogMsg::Default)].spacing(6); + if multiline { + value = value.push(pick( + "…", + t!("Multiline editor").into_owned(), + Some(AttdefDialogMsg::EditValue), + )); + } // No field picker in this application yet. - pick("ƒ", t!("Insert field").into_owned(), None), - ] + value.push(pick("ƒ", t!("Insert field").into_owned(), None)) + } .spacing(6) .into(), true @@ -295,7 +338,12 @@ pub fn view<'a>(state: &'a AttdefDialogState, sizing: crate::ui::modal::ModalSiz column![attribute, settings].spacing(10).width(Fill), ] .spacing(10); - column![body, align, footer(t!("Define"), AttdefDialogMsg::Ok)] + let mut content = column![body, align]; + if let Some(band) = error_band(&state.error) { + content = content.push(band); + } + content + .push(footer(t!("Define"), AttdefDialogMsg::Ok)) .spacing(10) .padding([10, 12]) .width(sizing.width) @@ -331,7 +379,12 @@ pub fn view_edit<'a>(state: &'a AttdefEditState, sizing: crate::ui::modal::Modal ] .spacing(8), ); - column![content, footer(t!("Apply"), AttdefDialogMsg::EditOk)] + let mut content = column![content]; + if let Some(band) = error_band(&state.error) { + content = content.push(band); + } + content + .push(footer(t!("Apply"), AttdefDialogMsg::EditOk)) .spacing(10) .padding([10, 12]) .width(sizing.width) From 1c10e5be6aac4db4751af29b869945392b944f85 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:30:40 +0300 Subject: [PATCH 4/4] ATTDEF automation: edit_value and dismiss dialog fields Co-Authored-By: Claude Opus 5.5 --- src/app/control/actions.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 3c4a9b7c5..12a544ffb 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -792,7 +792,7 @@ impl OpenCADStudio { // One field of the open attribute definition dialog: `tag=…`, // `prompt=…`, `default=…`, `justify=MC`, `style=…`, `height=…`, // `rotation=…`, `width=…`, `on_screen=0|1`, `x=…` / `y=…` / `z=…`, - // `mode=:0|1`, `annotative=0|1`, `align_below=0|1`. + // `mode=:0|1`, `annotative=0|1`, `align_below=0|1`, `edit_value`, `dismiss`. "attdef_dialog" => { use crate::ui::window::attdef_dialog::{AttdefDialogMsg as M, JustifyChoice}; let value = string(req, "value")?; @@ -813,6 +813,8 @@ impl OpenCADStudio { "on_screen" => M::OnScreen(on), "annotative" => M::Annotative(on), "align_below" => M::AlignBelow(on), + "edit_value" => M::EditValue, + "dismiss" => M::DismissError, "x" => M::Coord(0, v.into()), "y" => M::Coord(1, v.into()), "z" => M::Coord(2, v.into()),