From f0ac89629b9fa1b106f37f913f31cb09825f3e20 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 13 Oct 2026 21:23:48 +0300 Subject: [PATCH] feat(array): ARRAYRECT and ARRAYPOLAR create associative arrays With the new Associative option on (the default), ARRAYRECT and ARRAYPOLAR build the array the way the drawing format stores one: the selected objects move into an anonymous source block, an anonymous array block holds one ByBlock reference to it per item, an INSERT of the array block takes their place, and an AcDbAssocAction with an AcDbAssocArrayActionBody, its three dependencies, the BaseNormal / Origin / basePoint parameters and the value parameters tie them together in the model-space network. The array is then edited from Properties like an array read from a file. The option flips with AS and is remembered for the session; on a rotated working plane the commands keep making plain copies. Saving drops the action of an array whose reference was erased or exploded. Checked: created arrays reopen as associative with their parameters and items, and the ODA converter reads the saved DWG without errors. Refs #1109 Co-Authored-By: Claude Opus 5.5 --- locales/ar-SA/opencadstudio.ftl | 1 + locales/bg-BG/opencadstudio.ftl | 1 + locales/cs-CZ/opencadstudio.ftl | 1 + locales/de-DE/opencadstudio.ftl | 1 + locales/el-GR/opencadstudio.ftl | 1 + locales/en-US/opencadstudio.ftl | 1 + locales/es-ES/opencadstudio.ftl | 1 + locales/fi-FI/opencadstudio.ftl | 1 + locales/fr-FR/opencadstudio.ftl | 1 + locales/hi-IN/opencadstudio.ftl | 1 + locales/hu-HU/opencadstudio.ftl | 1 + locales/it-IT/opencadstudio.ftl | 1 + locales/ja-JP/opencadstudio.ftl | 1 + locales/ko-KR/opencadstudio.ftl | 1 + locales/lo-LA/opencadstudio.ftl | 1 + locales/nl-NL/opencadstudio.ftl | 1 + locales/pl-PL/opencadstudio.ftl | 1 + locales/pt-BR/opencadstudio.ftl | 1 + locales/ru-RU/opencadstudio.ftl | 1 + locales/tr-TR/opencadstudio.ftl | 1 + locales/zh-CN/opencadstudio.ftl | 1 + locales/zh-TW/opencadstudio.ftl | 1 + src/app/command_driver/clipboard.rs | 40 ++ src/app/command_driver/mod.rs | 3 + src/command.rs | 5 + src/locale_catalog.rs | 1 + src/modules/draw/defaults.rs | 2 + src/modules/draw/modify/array.rs | 66 ++++ src/scene/assoc_array.rs | 557 ++++++++++++++++++++++++++++ src/scene/dwg_native_constraints.rs | 12 +- 30 files changed, 705 insertions(+), 3 deletions(-) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 54ddae65e..d7f98e422 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -295,6 +295,7 @@ command-copy = .mode = COPY أدخل خيار وضع النسخ [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: تم إنشاء مصفوفة ترابطية. .assoc-array-array-path = مصفوفة (مسار) .assoc-array-array-polar = مصفوفة (قطبية) .assoc-array-array-rectangular = مصفوفة (مستطيلة) diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index caac7af4f..cb7085f4d 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -282,6 +282,7 @@ command-copy = .displacement = COPY Задайте преместване: .mode = COPY Въведете режим на копиране [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: създаден е асоциативен масив. .assoc-array-array-path = Масив (по път) .assoc-array-array-polar = Масив (полярен) .assoc-array-array-rectangular = Масив (правоъгълен) diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 4d14ecb17..71bf17c6f 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -282,6 +282,7 @@ command-copy = .mode = COPY Zadejte režim kopírování [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: vytvořeno asociativní pole. .assoc-array-array-path = Pole (podél cesty) .assoc-array-array-polar = Pole (kruhové) .assoc-array-array-rectangular = Pole (obdélníkové) diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index f7f626bcb..6ef673520 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -283,6 +283,7 @@ command-copy = .mode = COPY Kopiermodus eingeben [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: assoziative Reihe erstellt. .assoc-array-array-path = Reihe (Pfad) .assoc-array-array-polar = Reihe (polar) .assoc-array-array-rectangular = Reihe (rechteckig) diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 665c5f931..48f3a6273 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -282,6 +282,7 @@ command-copy = .mode = COPY Επιλέξτε λειτουργία αντιγραφής [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: δημιουργήθηκε συσχετιστικός πίνακας. .assoc-array-array-path = Πίνακας (διαδρομή) .assoc-array-array-polar = Πίνακας (πολικός) .assoc-array-array-rectangular = Πίνακας (ορθογώνιος) diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 1a7e856c3..d1781a219 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -282,6 +282,7 @@ command-copy = .mode = COPY Enter a copy mode option [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: associative array created. .assoc-array-array-path = Array (Path) .assoc-array-array-polar = Array (Polar) .assoc-array-array-rectangular = Array (Rectangular) diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index ef384b506..96d19d420 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -283,6 +283,7 @@ command-copy = .mode = COPY Indique modo de copia [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: se ha creado una matriz asociativa. .assoc-array-array-path = Matriz (trayectoria) .assoc-array-array-polar = Matriz (polar) .assoc-array-array-rectangular = Matriz (rectangular) diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index a2780d625..672a1be0e 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -282,6 +282,7 @@ command-copy = .mode = COPY Valitse kopiointitila [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: assosiatiivinen taulukko luotu. .assoc-array-array-path = Taulukko (polku) .assoc-array-array-polar = Taulukko (polaarinen) .assoc-array-array-rectangular = Taulukko (suorakulmainen) diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index f0a89f5fc..eb9f59748 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -283,6 +283,7 @@ command-copy = .mode = COPY Entrez le mode de copie [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__ : réseau associatif créé. .assoc-array-array-path = Réseau (trajectoire) .assoc-array-array-polar = Réseau (polaire) .assoc-array-array-rectangular = Réseau (rectangulaire) diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 6f8c8cff4..6c58c7b57 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -271,6 +271,7 @@ command-copy = .mode = COPY कॉपी मोड विकल्प दर्ज करें [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: सहयोगी ऐरे बनाया गया। .assoc-array-array-path = ऐरे (पथ) .assoc-array-array-polar = ऐरे (ध्रुवीय) .assoc-array-array-rectangular = ऐरे (आयताकार) diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index d9e0e6191..75d445a98 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -281,6 +281,7 @@ command-copy = .mode = COPY Adja meg a másolási módot [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: asszociatív tömb létrehozva. .assoc-array-array-path = Tömb (útvonal) .assoc-array-array-polar = Tömb (poláris) .assoc-array-array-rectangular = Tömb (téglalap) diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 084c7be63..fc91c30db 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -282,6 +282,7 @@ command-copy = .mode = COPY Specificare modalità di copia [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: serie associativa creata. .assoc-array-array-path = Serie (percorso) .assoc-array-array-polar = Serie (polare) .assoc-array-array-rectangular = Serie (rettangolare) diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index c8e6e5786..5fbb8a6e6 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -273,6 +273,7 @@ command-copy = .mode = COPY コピーモードを入力 [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: 自動調整配列複写を作成しました。 .assoc-array-array-path = 配列複写 (パス) .assoc-array-array-polar = 配列複写 (円形状) .assoc-array-array-rectangular = 配列複写 (矩形状) diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index bc8c8e056..8d25f85c4 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -282,6 +282,7 @@ command-copy = .mode = COPY 복사 모드 옵션 입력 [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: 연관 배열을 만들었습니다. .assoc-array-array-path = 배열 (경로) .assoc-array-array-polar = 배열 (원형) .assoc-array-array-rectangular = 배열 (직사각형) diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index dc08d761c..c188faa06 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -282,6 +282,7 @@ command-copy = .mode = COPY ປ້ອນຕົວເລືອກໂໝດການຄັດລອກ [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: ສ້າງອາເຣແບບເຊື່ອມໂຍງແລ້ວ. .assoc-array-array-path = ອາເຣ (ເສັ້ນທາງ) .assoc-array-array-polar = ອາເຣ (ວົງມົນ) .assoc-array-array-rectangular = ອາເຣ (ສີ່ຫຼ່ຽມ) diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 93ef29985..f621a55f9 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -283,6 +283,7 @@ command-copy = .mode = COPY Geef kopieermodus op [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: associatieve reeks gemaakt. .assoc-array-array-path = Reeks (pad) .assoc-array-array-polar = Reeks (polair) .assoc-array-array-rectangular = Reeks (rechthoekig) diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 37ceb77c7..648827533 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -282,6 +282,7 @@ command-copy = .mode = COPY Podaj tryb kopiowania [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: utworzono szyk skojarzony. .assoc-array-array-path = Szyk (ścieżka) .assoc-array-array-polar = Szyk (biegunowy) .assoc-array-array-rectangular = Szyk (prostokątny) diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 8ff75756a..eacb449da 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -283,6 +283,7 @@ command-copy = .mode = COPY Informe o modo de cópia [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: matriz associativa criada. .assoc-array-array-path = Matriz (caminho) .assoc-array-array-polar = Matriz (polar) .assoc-array-array-rectangular = Matriz (retangular) diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 2deb1f83f..eac8d2f50 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -286,6 +286,7 @@ command-copy = .mode = COPY Задайте режим копирования [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: создан ассоциативный массив. .assoc-array-array-path = Массив (по траектории) .assoc-array-array-polar = Массив (круговой) .assoc-array-array-rectangular = Массив (прямоугольный) diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 103792d3a..de6964037 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -268,6 +268,7 @@ command-copy = .mode = COPY Kopyalama modu seçin [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: ilişkisel dizi oluşturuldu. .assoc-array-array-path = Dizi (Yol) .assoc-array-array-polar = Dizi (Kutupsal) .assoc-array-array-rectangular = Dizi (Dikdörtgen) diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 14d64345a..c1c21d60e 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -270,6 +270,7 @@ command-copy = .mode = COPY 输入复制模式选项 [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: 已创建关联阵列。 .assoc-array-array-path = 阵列 (路径) .assoc-array-array-polar = 阵列 (环形) .assoc-array-array-rectangular = 阵列 (矩形) diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index be38f6cbc..11dd5b6ee 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -282,6 +282,7 @@ command-copy = .mode = COPY 輸入複製模式選項 [Single/Multiple] : common = + .label-associative-array-created = __ocs_fmt_0__: 已建立關聯陣列。 .assoc-array-array-path = 陣列 (路徑) .assoc-array-array-polar = 陣列 (環形) .assoc-array-array-rectangular = 陣列 (矩形) diff --git a/src/app/command_driver/clipboard.rs b/src/app/command_driver/clipboard.rs index 9c1ffc150..1bfcfce95 100644 --- a/src/app/command_driver/clipboard.rs +++ b/src/app/command_driver/clipboard.rs @@ -66,6 +66,46 @@ impl OpenCADStudio { None } + /// ARRAYRECT / ARRAYPOLAR with Associative on: the objects become the + /// source of an associative array, in one undo step. + pub(super) fn handle_assoc_array( + &mut self, + mut handles: Vec, + spec: crate::scene::assoc_array::AssocArraySpec, + ) { + let i = self.active_tab; + handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + if handles.is_empty() { + return; + } + let label = self.history_label_from_active_cmd(i, "ARRAY"); + let source_point = crate::app::helpers::entities_lower_left_by_bbox( + &self.tabs[i].scene.document, + &handles, + ); + self.push_undo_snapshot(i, label.clone()); + match crate::scene::assoc_array::create_from_spec( + &mut self.tabs[i].scene, + &handles, + source_point, + &spec, + ) { + Ok(array) => { + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entity(array, false); + self.command_line + .push_output(crate::tf!("{label}: associative array created.").as_ref()); + } + Err(error) => self.command_line.push_error(&format!("{label}: {error}")), + } + self.tabs[i].dirty = true; + self.refresh_properties(); + } + pub(super) fn handle_replace_many( &mut self, replacements: Vec<(Handle, Vec)>, diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index 5a7580e02..7c4a40807 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -606,6 +606,9 @@ impl OpenCADStudio { return task; } } + CmdResult::AssocArray { handles, spec } => { + self.handle_assoc_array(handles, spec); + } CmdResult::ReplaceMany(replacements, additions) => { if let Some(task) = self.handle_replace_many(replacements, additions) { return task; diff --git a/src/command.rs b/src/command.rs index a7a24d303..b3b3a9b1c 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1621,6 +1621,11 @@ pub enum CmdResult { }, /// Copy selected entities with multiple transforms (e.g. rectangular array); end command. BatchCopy(Vec, Vec), + /// Turn the objects into an associative array; end command. + AssocArray { + handles: Vec, + spec: crate::scene::assoc_array::AssocArraySpec, + }, /// Erase `handle` and replace with new entities; command stays active. ReplaceEntity(Handle, Vec), /// XCLIP on block references; ends the command. diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index c73cbb067..9bb77b77b 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3960,6 +3960,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "{exploded} object(s) exploded." => Some(("annotate", "exploded-object-s-exploded")), "{label}:" => Some(("command", "label")), "{label}: {count} {noun} created." => Some(("common", "label-count-noun-created")), + "{label}: associative array created." => Some(("common", "label-associative-array-created")), "{n} object(s) erased." => Some(("draw", "n-object-s-erased")), "{prefix}: '{style_name}' font set to '{font}'." => Some(("layers", "prefix-style-name-font-set-to-font")), "{prefix}: '{style_name}' oblique angle set to {deg:.1}°." => Some(("layers", "prefix-style-name-oblique-angle-set-to-deg-1")), diff --git a/src/modules/draw/defaults.rs b/src/modules/draw/defaults.rs index ac0ce079e..0da88ee54 100644 --- a/src/modules/draw/defaults.rs +++ b/src/modules/draw/defaults.rs @@ -17,6 +17,7 @@ thread_local! { static ARRAY_P_COUNT: Cell = Cell::new(6.0); static ARRAY_P_ANGLE: Cell = Cell::new(360.0); // degrees static ARRAY_PATH_COUNT: Cell = Cell::new(6.0); + static ARRAY_ASSOC: Cell = Cell::new(1.0); // 1 = associative static POLYGON_SIDES: Cell = Cell::new(6.0); static RECT_ELEVATION: Cell = Cell::new(0.0); static RECT_THICKNESS: Cell = Cell::new(0.0); @@ -61,6 +62,7 @@ accessors!(get_fillet_radius, set_fillet_radius, FILLET_RADIUS); accessors!(get_chamfer_dist1, set_chamfer_dist1, CHAMFER_DIST1); accessors!(get_chamfer_dist2, set_chamfer_dist2, CHAMFER_DIST2); accessors!(get_array_rows, set_array_rows, ARRAY_ROWS); +accessors!(get_array_assoc, set_array_assoc, ARRAY_ASSOC); accessors!(get_array_cols, set_array_cols, ARRAY_COLS); accessors!(get_array_row_sp, set_array_row_sp, ARRAY_ROW_SP); accessors!(get_array_col_sp, set_array_col_sp, ARRAY_COL_SP); diff --git a/src/modules/draw/modify/array.rs b/src/modules/draw/modify/array.rs index fdf7ed41b..ff9dbb9a7 100644 --- a/src/modules/draw/modify/array.rs +++ b/src/modules/draw/modify/array.rs @@ -19,6 +19,29 @@ use glam::DVec3; use crate::command::{CadCommand, CmdResult, EntityTransform, WorkingPlane}; use crate::modules::draw::defaults; + +/// The Associative option button, labelled with its current state. +fn associative_option(on: bool) -> crate::command::CmdOption { + crate::command::CmdOption { + label: format!( + "{}: {}", + crate::t!("Associative"), + crate::t!(if on { "Yes" } else { "No" }) + ), + keyword: "AS".to_string(), + } +} + +/// Typed `AS` / `ASSOCIATIVE` flips the Associative option. +fn is_associative_keyword(text: &str) -> bool { + matches!(text.trim().to_ascii_uppercase().as_str(), "AS" | "ASSOCIATIVE" | "_AS") +} + +/// Associative arrays are built in world axes; a rotated working plane +/// falls back to plain copies. +fn world_plane(plane: WorkingPlane) -> bool { + plane.x.abs_diff_eq(DVec3::X, 1e-12) && plane.z.abs_diff_eq(DVec3::Z, 1e-12) +} use crate::modules::IconKind; use crate::scene::model::wire_model::WireModel; @@ -84,6 +107,7 @@ pub struct ArrayRectCommand { default_cols: u32, default_row_sp: f64, default_col_sp: f64, + associative: bool, plane: WorkingPlane, } @@ -97,6 +121,7 @@ impl ArrayRectCommand { default_cols: (defaults::get_array_cols() as u32).clamp(1, MAX_ARRAY_ITEMS as u32), default_row_sp: defaults::get_array_row_sp(), default_col_sp: defaults::get_array_col_sp(), + associative: defaults::get_array_assoc() != 0.0, plane: WorkingPlane::default(), } } @@ -129,6 +154,10 @@ impl CadCommand for ArrayRectCommand { "ARRAYRECT" } + fn options(&self) -> Vec { + vec![associative_option(self.associative)] + } + fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; } @@ -165,6 +194,11 @@ impl CadCommand for ArrayRectCommand { } fn on_text_input(&mut self, text: &str) -> Option { + if is_associative_keyword(text) { + self.associative = !self.associative; + defaults::set_array_assoc(f64::from(u8::from(self.associative))); + return None; + } let t = text.trim().replace(',', "."); let t = t.as_str(); match self.step { @@ -221,6 +255,17 @@ impl CadCommand for ArrayRectCommand { defaults::set_array_col_sp(v); v }; + if self.associative && world_plane(self.plane) { + return Some(CmdResult::AssocArray { + handles: self.handles.clone(), + spec: crate::scene::assoc_array::AssocArraySpec::Rectangular { + columns: cols, + rows, + column_spacing: col_sp, + row_spacing: row_sp, + }, + }); + } Some(CmdResult::BatchCopy( self.handles.clone(), Self::build_transforms(rows, cols, row_sp, col_sp, self.plane), @@ -295,6 +340,7 @@ pub struct ArrayPolarCommand { step: PolarStep, default_count: u32, default_angle: f64, + associative: bool, plane: WorkingPlane, } @@ -306,6 +352,7 @@ impl ArrayPolarCommand { step: PolarStep::Center, default_count: (defaults::get_array_p_count() as u32).clamp(2, MAX_ARRAY_ITEMS as u32), default_angle: defaults::get_array_p_angle(), + associative: defaults::get_array_assoc() != 0.0, plane: WorkingPlane::default(), } } @@ -320,6 +367,10 @@ impl CadCommand for ArrayPolarCommand { "ARRAYPOLAR" } + fn options(&self) -> Vec { + vec![associative_option(self.associative)] + } + fn prompt(&self) -> String { match &self.step { PolarStep::Center => crate::tf!( @@ -351,6 +402,11 @@ impl CadCommand for ArrayPolarCommand { } fn on_text_input(&mut self, text: &str) -> Option { + if is_associative_keyword(text) { + self.associative = !self.associative; + defaults::set_array_assoc(f64::from(u8::from(self.associative))); + return None; + } let t = text.trim().replace(',', "."); let t = t.as_str(); match &self.step { @@ -383,6 +439,16 @@ impl CadCommand for ArrayPolarCommand { defaults::set_array_p_angle(v); v }; + if self.associative && world_plane(self.plane) { + return Some(CmdResult::AssocArray { + handles: self.handles.clone(), + spec: crate::scene::assoc_array::AssocArraySpec::Polar { + center, + items: count, + fill_angle: total_deg.to_radians(), + }, + }); + } let step_rad = total_deg.to_radians() / count as f64; let transforms = (1..count) .map(|n| EntityTransform::Rotate { diff --git a/src/scene/assoc_array.rs b/src/scene/assoc_array.rs index 133b5b51d..7402a7f26 100644 --- a/src/scene/assoc_array.rs +++ b/src/scene/assoc_array.rs @@ -108,6 +108,24 @@ pub fn array_of_insert(document: &CadDocument, insert: Handle) -> Option bool { + let Some(action) = action(document, handle) else { + return false; + }; + if body(document, action.action_body).is_none() { + return false; + } + !action.dependencies.iter().any(|link| { + dependency(document, link.dependency).is_some_and(|dependency| { + dependency.is_read_dependency + && dependency.is_write_dependency + && document.get_entity(dependency.dependent_on).is_some() + }) + }) +} + /// The value parameters of an array action: inline in the action (R2013+) /// or in the body's parameter section (R2007). pub fn value_params<'a>(document: &'a CadDocument, array: &AssocArray) -> &'a [AssocValueParam] { @@ -556,3 +574,542 @@ pub fn update( scene.bump_geometry(); Ok(()) } + +/// Class records the array objects need, with the values the reference +/// product writes: (DXF name, class name, DWG version, maintenance version). +const ARRAY_CLASSES: &[(&str, &str, i32, i32)] = &[ + ("ACDBASSOCNETWORK", "AcDbAssocNetwork", 27, 45), + ("ACDBASSOCACTION", "AcDbAssocAction", 27, 45), + ("ACDBASSOCDEPENDENCY", "AcDbAssocDependency", 27, 50), + ("ACDBASSOCVERTEXACTIONPARAM", "AcDbAssocVertexActionParam", 27, 50), + ("ACDBASSOCEDGEACTIONPARAM", "AcDbAssocEdgeActionParam", 27, 50), + ("ACDBASSOCARRAYACTIONBODY", "AcDbAssocArrayActionBody", 29, 55), + ("ACDBASSOCARRAYRECTANGULARPARAMETERS", "AcDbAssocArrayRectangularParameters", 29, 55), + ("ACDBASSOCARRAYPOLARPARAMETERS", "AcDbAssocArrayPolarParameters", 29, 55), +]; + +fn register_array_classes(document: &mut CadDocument) { + for &(dxf_name, cpp_class_name, dwg_version, maintenance_version) in ARRAY_CLASSES { + if document.classes.get_by_name(dxf_name).is_some() { + continue; + } + document.classes.add_or_update(codec::classes::DxfClass { + dxf_name: dxf_name.to_string(), + cpp_class_name: cpp_class_name.to_string(), + application_name: "ObjectDBX Classes".to_string(), + proxy_flags: codec::classes::ProxyFlags(1025), + instance_count: 0, + was_zombie: false, + is_an_entity: false, + class_number: 0, + item_class_id: 0x1F3, + dwg_version, + maintenance_version, + unknown1: 0, + unknown2: 0, + }); + } +} + +fn next_anonymous_name(document: &CadDocument) -> String { + let next = document + .block_records + .iter() + .filter_map(|record| record.name.strip_prefix("*U")?.parse::().ok()) + .max() + .map_or(1, |n| n + 1); + format!("*U{next}") +} + +fn associative( + handle: Handle, + owner: Handle, + dxf_name: &str, + cpp_class_name: &str, + data: AssociativeData, +) -> ObjectType { + let mut object = codec::objects::AssociativeObject::new(dxf_name, cpp_class_name); + object.handle = handle; + object.owner = owner; + object.data = data; + ObjectType::Associative(object) +} + +fn dictionary_entry(document: &CadDocument, dictionary: Handle, key: &str) -> Option { + match document.objects.get(&dictionary)? { + ObjectType::Dictionary(dictionary) => dictionary.get(key), + _ => None, + } +} + +/// A dictionary stored under `key` in `parent`, created when absent. +fn child_dictionary(document: &mut CadDocument, parent: Handle, key: &str) -> Handle { + if let Some(handle) = dictionary_entry(document, parent, key) + .filter(|handle| matches!(document.objects.get(handle), Some(ObjectType::Dictionary(_)))) + { + return handle; + } + let handle = document.allocate_handle(); + let mut dictionary = codec::objects::Dictionary::new(); + dictionary.handle = handle; + dictionary.owner = parent; + dictionary.reactors.push(parent); + document.objects.insert(handle, ObjectType::Dictionary(dictionary)); + if let Some(ObjectType::Dictionary(parent)) = document.objects.get_mut(&parent) { + parent.add_entry(key, handle); + } + handle +} + +/// The network holding the actions of model space, created with the root +/// network above it when the drawing has none. +fn model_space_network(document: &mut CadDocument) -> Handle { + const KEY: &str = "ACAD_ASSOCNETWORK"; + let model = document.header.model_space_block_handle; + let scope = document.ensure_extension_dictionary(model); + let holder = child_dictionary(document, scope, KEY); + if let Some(network) = dictionary_entry(document, holder, KEY).filter(|handle| { + matches!( + document.objects.get(handle), + Some(ObjectType::Associative(object)) + if matches!(object.data, AssociativeData::Network(_)) + ) + }) { + return network; + } + let root_holder = child_dictionary(document, document.header.named_objects_dict_handle, KEY); + let root = match dictionary_entry(document, root_holder, KEY) { + Some(root) if matches!(document.objects.get(&root), Some(ObjectType::Associative(_))) => { + root + } + _ => { + let root = document.allocate_handle(); + let network = codec::objects::AssocNetwork { + action: AssocAction { + class_version: 2, + ..Default::default() + }, + ..Default::default() + }; + let mut object = associative( + root, + root_holder, + "ACDBASSOCNETWORK", + "AcDbAssocNetwork", + AssociativeData::Network(network), + ); + if let ObjectType::Associative(object) = &mut object { + object.reactors.push(root_holder); + } + document.objects.insert(root, object); + if let Some(ObjectType::Dictionary(holder)) = document.objects.get_mut(&root_holder) { + holder.add_entry(KEY, root); + } + root + } + }; + let network = document.allocate_handle(); + let mut object = associative( + network, + holder, + "ACDBASSOCNETWORK", + "AcDbAssocNetwork", + AssociativeData::Network(codec::objects::AssocNetwork { + action: AssocAction { + class_version: 2, + owning_network: root, + ..Default::default() + }, + ..Default::default() + }), + ); + if let ObjectType::Associative(object) = &mut object { + object.reactors.push(holder); + } + document.objects.insert(network, object); + if let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&holder) { + dictionary.add_entry(KEY, network); + } + if let Some(ObjectType::Associative(object)) = document.objects.get_mut(&root) { + if let AssociativeData::Network(root) = &mut object.data { + root.network_action_index += 1; + root.actions.push(codec::objects::AssocActionDependency { + is_owned: false, + dependency: network, + }); + } + } + network +} + +fn value_param(name: &str, unit_type: i32, value: f64, whole: bool) -> AssocValueParam { + let variant = if whole { + codec::objects::AssocEvalVariant { + code: 90, + value: AssocEvalValue::Long(value.round() as i32), + } + } else { + codec::objects::AssocEvalVariant { + code: 40, + value: AssocEvalValue::Real(value), + } + }; + AssocValueParam { + name: name.to_string(), + unit_type, + variables: vec![codec::objects::AssocValueParamVariable { + value: variant, + handle: Handle::NULL, + }], + ..Default::default() + } +} + +/// The value parameters of a new rectangular array, in the order the +/// reference product writes them. +pub fn rectangular_params( + columns: u32, + rows: u32, + column_spacing: f64, + row_spacing: f64, +) -> Vec { + vec![ + value_param("AlignItems", 0, 0.0, true), + value_param("AxesAngle", 2, std::f64::consts::FRAC_PI_2, false), + value_param("ItemSpacing", 1, column_spacing, false), + value_param("Items", 0, f64::from(columns), true), + value_param("LevelSpacing", 1, 1.0, false), + value_param("Levels", 0, 1.0, true), + value_param("RowElevation", 1, 0.0, false), + value_param("RowSpacing", 1, row_spacing, false), + value_param("Rows", 0, f64::from(rows), true), + ] +} + +/// The value parameters of a new polar array. +pub fn polar_params( + items: u32, + fill_angle: f64, + radius: f64, + start_angle: f64, +) -> Vec { + let count = f64::from(items.max(1)); + let full = (fill_angle.abs() - std::f64::consts::TAU).abs() < 1e-9; + let spacing = if full { + fill_angle / count + } else if items > 1 { + fill_angle / (count - 1.0) + } else { + 0.0 + }; + vec![ + value_param("FillAngle", 2, fill_angle, false), + value_param("ItemSpacing", 2, spacing, false), + value_param("Items", 0, count, true), + value_param("LevelSpacing", 1, 1.0, false), + value_param("Levels", 0, 1.0, true), + value_param("RowElevation", 1, 0.0, false), + value_param("RowSpacing", 1, radius.abs().max(1.0), false), + value_param("Rows", 0, 1.0, true), + value_param("Radius", 1, radius, false), + value_param("StartAngle", 2, start_angle, false), + ] +} + +/// What ARRAYRECT / ARRAYPOLAR ask for an associative array. +#[derive(Clone, Debug, PartialEq)] +pub enum AssocArraySpec { + Rectangular { + columns: u32, + rows: u32, + column_spacing: f64, + row_spacing: f64, + }, + Polar { + center: DVec3, + items: u32, + fill_angle: f64, + }, +} + +/// Turn `sources` into an associative array: they move into an anonymous +/// source block whose origin is `source_point`, an anonymous array block +/// holds one reference to it per item, and an INSERT of the array block at +/// `base` takes their place, tied together by an array action in the +/// model-space network. A polar array turns about `base`. Returns the +/// array INSERT. +pub fn create( + scene: &mut crate::scene::Scene, + sources: &[Handle], + source_point: DVec3, + base: DVec3, + kind: ArrayKind, + params: Vec, +) -> Result { + use codec::objects::{ + AssocActionBody, AssocActionDependency, AssocActionParam, AssocArrayItem, + AssocSingleDependencyActionParam, AssocVertexActionParam, + }; + use codec::EntityType; + let normal = DVec3::Z; + let placements: Vec<([i32; 3], DMat4)> = match kind { + ArrayKind::Rectangular => rectangular_items(¶ms, DVec3::X, normal) + .into_iter() + .map(|(location, offset)| (location, DMat4::from_translation(offset))) + .collect(), + ArrayKind::Polar => polar_items(¶ms, DVec3::ZERO, normal), + ArrayKind::Path => return Err("path arrays cannot be created yet".into()), + }; + let mut entities: Vec = sources + .iter() + .filter_map(|handle| scene.document.get_entity(*handle).cloned()) + .collect(); + if entities.is_empty() { + return Err("nothing to array".into()); + } + let layer = scene.document.header.current_layer_name.clone(); + for entity in &mut entities { + crate::scene::view::dispatch::apply_transform( + entity, + &crate::command::EntityTransform::Translate(-source_point), + ); + } + register_array_classes(&mut scene.document); + + let source_name = next_anonymous_name(&scene.document); + scene.define_block_raw(&source_name, codec::types::Vector3::ZERO, entities); + let item = |offset: DMat4| { + let position = offset.w_axis.truncate(); + let mut insert = codec::entities::Insert::new( + source_name.clone(), + codec::types::Vector3::new(position.x, position.y, position.z), + ); + insert.rotation = offset.x_axis.y.atan2(offset.x_axis.x); + insert.common.layer = "0".to_string(); + insert.common.color = codec::types::Color::ByBlock; + insert.common.linetype = "ByBlock".to_string(); + insert.common.line_weight = codec::types::LineWeight::ByBlock; + insert.common.transparency = codec::types::Transparency::ByBlock; + EntityType::Insert(insert) + }; + let array_name = next_anonymous_name(&scene.document); + scene.define_block_raw( + &array_name, + codec::types::Vector3::ZERO, + placements.iter().map(|(_, placement)| item(*placement)).collect(), + ); + let record = |document: &CadDocument, name: &str| { + document + .block_records + .get(name) + .map(|record| (record.handle, record.entity_handles.clone())) + }; + let (source_block, _) = record(&scene.document, &source_name).ok_or("source block")?; + let (array_block, item_handles) = record(&scene.document, &array_name).ok_or("array block")?; + for name in [&source_name, &array_name] { + if let Some(record) = scene.document.block_records.get_mut(name) { + record.flags.anonymous = true; + record.explodable = true; + record.scale_uniformly = *name == array_name; + } + } + if let Some(record) = scene.document.block_records.get_mut(&source_name) { + record.insert_handles = item_handles.clone(); + } + + let mut array_insert = codec::entities::Insert::new( + array_name.clone(), + codec::types::Vector3::new(base.x, base.y, base.z), + ); + array_insert.common.layer = layer; + let insert = scene.add_entity(EntityType::Insert(array_insert)); + scene.erase_entities(sources); + + let document = &mut scene.document; + let network = model_space_network(document); + let [action, body, write_dep, read_dep, both_dep] = + std::array::from_fn(|_| document.allocate_handle()); + let mut owned_parameters = Vec::new(); + let vertex_params = + [("BaseNormal", normal), ("Origin", DVec3::ZERO), ("basePoint", DVec3::ZERO)]; + for (name, point) in vertex_params { + let handle = document.allocate_handle(); + document.objects.insert( + handle, + associative( + handle, + action, + "ACDBASSOCVERTEXACTIONPARAM", + "AcDbAssocVertexActionParam", + AssociativeData::VertexActionParam(AssocVertexActionParam { + single_dependency: AssocSingleDependencyActionParam { + action_param: AssocActionParam { + is_r2013: 1, + version: 0, + name: name.to_string(), + }, + ..Default::default() + }, + point: codec::types::Vector3::new(point.x, point.y, point.z), + }), + ), + ); + owned_parameters.push(handle); + } + for (handle, read, write, order, target, id) in [ + (write_dep, false, true, 0, array_block, 1), + (read_dep, true, false, 0, source_block, 2), + (both_dep, true, true, i32::MIN, insert, 3), + ] { + document.objects.insert( + handle, + associative( + handle, + action, + "ACDBASSOCDEPENDENCY", + "AcDbAssocDependency", + AssociativeData::Dependency(AssocDependency { + class_version: 2, + is_read_dependency: read, + is_write_dependency: write, + is_attached_to_object: true, + is_delegating_to_owning_action: true, + order, + dependent_on: target, + dependency_body_id: id, + ..Default::default() + }), + ), + ); + } + if let Some(EntityType::Insert(array)) = document.get_entity_mut(insert) { + array.common.reactors.push(both_dep); + } + let items = placements + .iter() + .zip(&item_handles) + .map(|((location, placement), handle)| { + let position = placement.w_axis.truncate(); + let turned = (placement.x_axis.truncate() - DVec3::X).length() > 1e-12; + AssocArrayItem { + location: *location, + flags: if turned { 12 } else { 8 }, + uses_default_transform: !turned, + x_direction: if turned { + codec::types::Vector3::ZERO + } else { + codec::types::Vector3::new(position.x, position.y, position.z) + }, + transform: if turned { + placement.transpose().to_cols_array() + } else { + [0.0; 16] + }, + first_handle: Some(*handle), + ..Default::default() + } + }) + .collect(); + let parameter_block = match kind { + ArrayKind::Polar => "AcDbAssocArrayPolarParameters", + _ => "AcDbAssocArrayRectangularParameters", + }; + document.objects.insert( + body, + associative( + body, + action, + "ACDBASSOCARRAYACTIONBODY", + "AcDbAssocArrayActionBody", + AssociativeData::ArrayActionBody(AssocArrayActionBody { + action_body: AssocActionBody { version: 2 }, + version: 1, + parameter_block: parameter_block.to_string(), + item_class: "AcDbAssocArrayItem".to_string(), + items, + transform: DMat4::from_translation(base).transpose().to_cols_array(), + ..Default::default() + }), + ), + ); + let action_index = match document.objects.get_mut(&network) { + Some(ObjectType::Associative(object)) => match &mut object.data { + AssociativeData::Network(network) => { + network.network_action_index += 1; + network.actions.push(AssocActionDependency { + is_owned: true, + dependency: action, + }); + network.network_action_index + } + _ => 1, + }, + _ => 1, + }; + document.objects.insert( + action, + associative( + action, + network, + "ACDBASSOCACTION", + "AcDbAssocAction", + AssociativeData::Action(AssocAction { + class_version: 2, + owning_network: network, + action_body: body, + action_index, + max_dependency_index: 3, + dependencies: [write_dep, read_dep, both_dep] + .map(|dependency| AssocActionDependency { + is_owned: true, + dependency, + }) + .to_vec(), + owned_parameters, + values: params, + ..Default::default() + }), + ), + ); + scene.bump_geometry(); + Ok(insert) +} + +/// Build an associative array from `sources` per `spec`, its source block +/// based at their lower-left corner `source_point`. +pub fn create_from_spec( + scene: &mut crate::scene::Scene, + sources: &[Handle], + source_point: DVec3, + spec: &AssocArraySpec, +) -> Result { + match *spec { + AssocArraySpec::Rectangular { + columns, + rows, + column_spacing, + row_spacing, + } => create( + scene, + sources, + source_point, + source_point, + ArrayKind::Rectangular, + rectangular_params(columns, rows, column_spacing, row_spacing), + ), + AssocArraySpec::Polar { + center, + items, + fill_angle, + } => { + let offset = source_point - center; + let params = polar_params( + items, + fill_angle, + offset.truncate().length(), + offset.y.atan2(offset.x), + ); + create(scene, sources, source_point, center, ArrayKind::Polar, params) + } + } +} diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index 22efdc24f..80ffa9a55 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -2489,9 +2489,15 @@ fn detach_preserved_scope( Some(ObjectType::Associative(object)) => match &object.data { AssociativeData::ConstraintGroup(_) | AssociativeData::Network(_) => false, AssociativeData::Variable(variable) => variable.name.trim().is_empty(), - // A surface action whose surface was erased goes with it. - AssociativeData::Action(_) => action_surface(document, *handle) - .is_none_or(|surface| document.get_entity(surface).is_some()), + // A surface action whose surface was erased goes with it, + // and an array action whose array reference was. + AssociativeData::Action(_) => { + action_surface(document, *handle) + .is_none_or(|surface| document.get_entity(surface).is_some()) + && !crate::scene::assoc_array::orphaned_array_action( + document, *handle, + ) + } _ => true, }, _ => false,