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] 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 {