diff --git a/assets/ocad.pgp b/assets/ocad.pgp index 7c1598cd..84951d13 100644 --- a/assets/ocad.pgp +++ b/assets/ocad.pgp @@ -21,6 +21,7 @@ SPL, *SPLINE H, *HATCH BH, *HATCH HE, *HATCHEDIT +HB, *HATCHTOBACK BO, *BOUNDARY GD, *GRADIENT XL, *XLINE diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 54252271..62a03272 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -2347,6 +2347,7 @@ view = .zoom-extents = تكبير إلى النطاقات + .cmd-no-hatch-objects = __ocs_fmt_0__: ┘äϺ Ϭ┘êϼϻ ┘âϺϪ┘åϺϬ Ϭϩ┘ä┘è┘ä. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: لا توجد كائنات نص أو أبعاد. .cmd-not-yet-implemented = __ocs_fmt_0__: لم يُنفّذ بعد. .viewport-s-in-layout = __ocs_fmt_0__ منفذ عرض في التخطيط "__ocs_fmt_1__": diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 5da17064..fe6994b8 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -2334,6 +2334,7 @@ view = .zoom-extents = Мащаб Обхват + .cmd-no-hatch-objects = __ocs_fmt_0__: ð¢ÐÅð╝ð░ ÐëÐÇð©Ðàð¥ð▓ÐèÐçð¢ð© ð¥ð▒ðÁð║Ðéð©. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: няма текстови обекти или оразмерявания. .cmd-not-yet-implemented = __ocs_fmt_0__: все още не е реализирано. .viewport-s-in-layout = __ocs_fmt_0__ изглед(а) в оформление "__ocs_fmt_1__": diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 7c2337f8..92c95855 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -2334,6 +2334,7 @@ view = .zoom-extents = Přiblížení Rozsahy + .cmd-no-hatch-objects = __ocs_fmt_0__: ┼¥├ídn├® ┼írafovac├¡ objekty. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: žádný text ani kótovací objekty. .cmd-not-yet-implemented = __ocs_fmt_0__: dosud neimplementováno. .viewport-s-in-layout =Výřezy __ocs_fmt_0__ v rozložení "__ocs_fmt_1__": diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index c17b0730..87f3b152 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -2333,6 +2333,7 @@ view = .zoom-extents = Zoom Umfang + .cmd-no-hatch-objects = __ocs_fmt_0__: keine Schraffurobjekte. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: keine Text- oder Dimensionsobjekte. .cmd-not-yet-implemented = __ocs_fmt_0__: noch nicht umgesetzt. .viewport-s-in-layout = __ocs_fmt_0__ Ansichtsfenster(s) im Layout "__ocs_fmt_1__": diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 671366f4..6386d0d6 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -2363,6 +2363,7 @@ view = .zoom-extents = Zoom Extents + .cmd-no-hatch-objects = __ocs_fmt_0__: no hatch objects. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: no text or dimension objects. .cmd-not-yet-implemented = __ocs_fmt_0__: not yet implemented. .viewport-s-in-layout = __ocs_fmt_0__ viewport(s) in layout "__ocs_fmt_1__": diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index cf6101bd..3f4a04fe 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -2335,6 +2335,7 @@ view = .zoom-extents = Zoom Extensión + .cmd-no-hatch-objects = __ocs_fmt_0__: no hay objetos de sombreado. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: no hay objetos de texto ni de cota. .cmd-not-yet-implemented = __ocs_fmt_0__: aún no implementado. .viewport-s-in-layout = __ocs_fmt_0__ ventanas gráficas en la presentación «__ocs_fmt_1__»: diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 01752032..08717ec7 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -2334,6 +2334,7 @@ view = .zoom-extents = Zoomaa Laajuudet + .cmd-no-hatch-objects = __ocs_fmt_0__: ei t├ñytt├Âobjekteja. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: ei tekstiä tai mittaobjekteja. .cmd-not-yet-implemented = __ocs_fmt_0__: ei vielä otettu käyttöön. .viewport-s-in-layout =__ocs_fmt_0__-näkymä(t) asettelussa "__ocs_fmt_1__": diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 8270c93d..d2dbe5e9 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -2335,6 +2335,7 @@ view = .zoom-extents = Zoom Portée + .cmd-no-hatch-objects = __ocs_fmt_0__: aucun objet de hachure. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: aucun objet texte ou dimensionnel. .cmd-not-yet-implemented = __ocs_fmt_0__: pas encore mis en œuvre. .viewport-s-in-layout = __ocs_fmt_0__ fenêtre(s) dans la présentation « __ocs_fmt_1__ » : diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index abf200ab..d8f40a6c 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -2323,6 +2323,7 @@ view = .zoom-extents = ज़ूम बहार + .cmd-no-hatch-objects = __ocs_fmt_0__: ÓñòÓÑïÓñê Óñ╣ÓÑêÓñÜ ÓñæÓñ¼ÓÑìÓñ£ÓÑçÓñòÓÑìÓñƒ Óñ¿Óñ╣ÓÑÇÓñéÓÑñ .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: कोई टेक्स्ट या डाइमेंशन ऑब्जेक्ट नहीं। .cmd-not-yet-implemented = __ocs_fmt_0__: अभी तक लागू नहीं है। .viewport-s-in-layout = लेआउट "__ocs_fmt_1__" में __ocs_fmt_0__ व्यूपोर्ट: diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 13e4c28c..0b7a8f7b 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -2334,6 +2334,7 @@ view = .zoom-extents = Zoom Terjedelem + .cmd-no-hatch-objects = __ocs_fmt_0__: nincs sraffoz├ísi objektum. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: nincs szöveg vagy méretobjektum. .cmd-not-yet-implemented = __ocs_fmt_0__: még nincs implementálva. .viewport-s-in-layout =__ocs_fmt_0__ nézetablak(ok) a „__ocs_fmt_1__” elrendezésben: diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 5acada86..8c915bb8 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -2334,6 +2334,7 @@ view = .zoom-extents = Zoom Estensioni + .cmd-no-hatch-objects = __ocs_fmt_0__: nessun oggetto tratteggio. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: nessun testo o oggetto dimensione. .cmd-not-yet-implemented = __ocs_fmt_0__: non ancora implementato. .viewport-s-in-layout =Finestre __ocs_fmt_0__ nel layout "__ocs_fmt_1__": diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index c35743c2..4abf157c 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -2323,6 +2323,7 @@ view = .zoom-extents = 全体表示 ズーム + .cmd-no-hatch-objects = __ocs_fmt_0__: ÒâÅÒââÒâüÒâ│Òé░ Òé¬ÒâûÒé©ÒéºÒé»ÒâêÒüîÒüéÒéèÒü¥ÒüøÒéôÒÇé .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: テキストまたは寸法オブジェクトがありません。 .cmd-not-yet-implemented = __ocs_fmt_0__: まだ実装されていません。 .viewport-s-in-layout = レイアウト「__ocs_fmt_1__」内の __ocs_fmt_0__ 個のビューポート: diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index d2c649d8..9c1b5738 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -2334,6 +2334,7 @@ view = .zoom-extents = 줌 범위 + .cmd-no-hatch-objects = __ocs_fmt_0__: Ýò┤ý╣ÿ Û░Øý▓┤Û░Ç ýùåýèÁÙïêÙïñ. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: 문자 또는 치수 객체가 없습니다. .cmd-not-yet-implemented = __ocs_fmt_0__: 아직 구현되지 않았습니다. .viewport-s-in-layout ="__ocs_fmt_1__" 레이아웃의 __ocs_fmt_0__ 뷰포트: diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 246e073f..876a6cbf 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -2335,6 +2335,7 @@ view = .zoom-extents = Zoomen Maten + .cmd-no-hatch-objects = __ocs_fmt_0__: geen arceerobjecten. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: geen tekst- of maatobjecten. .cmd-not-yet-implemented = __ocs_fmt_0__: nog niet geïmplementeerd. .viewport-s-in-layout = __ocs_fmt_0__ weergavevenster(s) in indeling "__ocs_fmt_1__": diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index cf5a981e..9ac73584 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -2334,6 +2334,7 @@ view = .zoom-extents = Powiększ Zakresy + .cmd-no-hatch-objects = __ocs_fmt_0__: brak obiekt├│w kreskowania. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: brak obiektów tekstowych i wymiarowych. .cmd-not-yet-implemented = __ocs_fmt_0__: jeszcze nie wdrożony. .viewport-s-in-layout =Okna widokowe __ocs_fmt_0__ w układzie „__ocs_fmt_1__”: diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 128a43dc..4b70122d 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -2335,6 +2335,7 @@ view = .zoom-extents = Zoom nas extensões + .cmd-no-hatch-objects = __ocs_fmt_0__: nenhum objeto de hachura. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: nenhum objeto de texto ou cota. .cmd-not-yet-implemented = __ocs_fmt_0__: ainda não implementado. .viewport-s-in-layout = __ocs_fmt_0__ janelas de visualização no layout “__ocs_fmt_1__”: diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 0f154f49..957709db 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -2338,6 +2338,7 @@ view = .zoom-extents = Зум размеры + .cmd-no-hatch-objects = __ocs_fmt_0__: ð¢ðÁÐé ð¥ð▒ÐèðÁð║Ðéð¥ð▓ ÐêÐéÐÇð©Ðàð¥ð▓ð║ð©. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: нет текстовых или размерных объектов. .cmd-not-yet-implemented = __ocs_fmt_0__: еще не реализовано. .viewport-s-in-layout = Видовые экраны (__ocs_fmt_0__) на листе «__ocs_fmt_1__»: diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 5c3f9ff4..4f2d10e7 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -2303,6 +2303,7 @@ view = .zoom-window-specify-first-corner = ZOOM WINDOW İlk köşeyi belirtin: .zoom-window-specify-opposite-corner = ZOOM WINDOW Karşı köşeyi belirtin: .zoom-extents = Sınırlara Yakınlaştır + .cmd-no-hatch-objects = __ocs_fmt_0__: Tarama nesnesi yok. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: Metin veya ölçü nesnesi yok. .cmd-not-yet-implemented = __ocs_fmt_0__: Henüz uygulanmadı. .viewport-s-in-layout = "__ocs_fmt_1__" yerleşiminde __ocs_fmt_0__ görünüm alanı: diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 3440c473..0fb98a5a 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -2318,6 +2318,7 @@ view = .zoom-extents = 缩放 范围 + .cmd-no-hatch-objects = __ocs_fmt_0__:µ▓íµ£ëÕø¥µíêÕí½ÕààÕ»╣Þ▒íÒÇé .cmd-no-text-or-dimension-objects = __ocs_fmt_0__:没有文本或维度对象。 .cmd-not-yet-implemented = __ocs_fmt_0__:尚未执行。 .viewport-s-in-layout = 布局“__ocs_fmt_1__”中的视口(__ocs_fmt_0__ 个): diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index fb781a28..21f275b3 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -2330,6 +2330,7 @@ view = .zoom-extents = 縮放 範圍 + .cmd-no-hatch-objects = __ocs_fmt_0__:µ▓Ƶ£ëÕí½ÕààþÀÜþë®õ╗ÂÒÇé .cmd-no-text-or-dimension-objects = __ocs_fmt_0__:沒有文字或維度物件。 .cmd-not-yet-implemented = __ocs_fmt_0__:尚未實作。 .viewport-s-in-layout = 配置“__ocs_fmt_1__”中的視口(__ocs_fmt_0__ 個): diff --git a/src/app/alias.rs b/src/app/alias.rs index fc305430..d9c3aeb0 100644 --- a/src/app/alias.rs +++ b/src/app/alias.rs @@ -73,8 +73,9 @@ const WEB_ALIAS_KEY: &str = "opencadstudio.aliases"; /// Version of the shipped default alias table. Bump this whenever the embedded /// defaults add aliases or change a default target, so existing profiles can be /// migrated forward (see `introduced_at` / `migrate_aliases`). Version 1 shipped -/// the pre-REDRAW table; version 2 introduced the REDRAW-family aliases. -const DEFAULT_ALIASES_VERSION: u32 = 2; +/// the pre-REDRAW table; version 2 introduced the REDRAW-family aliases; version 3 +/// introduced HB (HATCHTOBACK). +const DEFAULT_ALIASES_VERSION: u32 = 3; #[cfg(target_arch = "wasm32")] const WEB_ALIAS_VERSION_KEY: &str = "opencadstudio.aliases.version"; @@ -91,6 +92,7 @@ fn introduced_at(version: u32) -> &'static [(&'static str, &'static str)] { ("RE", "REGEN"), ("REA", "REGENALL"), ], + 3 => &[("HB", "HATCHTOBACK")], _ => &[], } } @@ -330,17 +332,18 @@ mod tests { .collect() } - /// An existing profile that predates the REDRAW aliases gains exactly the + /// An existing profile that predates the REDRAW and HB aliases gains exactly the /// new defaults, with every pre-existing mapping kept. #[test] fn migration_adds_new_defaults_preserving_existing() { let mut stored = map(&[("L", "LINE"), ("Z", "ZOOM"), ("HI", "HIDE")]); let changed = migrate_aliases(&mut stored, 0); - assert!(changed, "migrating from v0 to v2 must add defaults"); + assert!(changed, "migrating from v0 to v3 must add defaults"); assert_eq!(stored.get("R").map(String::as_str), Some("REDRAW")); assert_eq!(stored.get("RA").map(String::as_str), Some("REDRAWALL")); assert_eq!(stored.get("RE").map(String::as_str), Some("REGEN")); assert_eq!(stored.get("REA").map(String::as_str), Some("REGENALL")); + assert_eq!(stored.get("HB").map(String::as_str), Some("HATCHTOBACK")); // Existing entries must survive untouched. assert_eq!(stored.get("L").map(String::as_str), Some("LINE")); assert_eq!(stored.get("Z").map(String::as_str), Some("ZOOM")); @@ -375,10 +378,10 @@ mod tests { assert_eq!(stored.get("L").map(String::as_str), Some("LINE")); } - /// Migrating a mid-range profile (one that saw v1) only pulls in v2's + /// Migrating a mid-range profile (one that saw v1) pulls in v2's and v3's /// additions, not hypothetical earlier ones. #[test] - fn migration_from_version_one_adds_v2_aliases() { + fn migration_from_version_one_adds_v2_and_v3_aliases() { let mut stored = map(&[("L", "LINE")]); let changed = migrate_aliases(&mut stored, 1); assert!(changed); @@ -386,5 +389,16 @@ mod tests { assert_eq!(stored.get("RA").map(String::as_str), Some("REDRAWALL")); assert_eq!(stored.get("RE").map(String::as_str), Some("REGEN")); assert_eq!(stored.get("REA").map(String::as_str), Some("REGENALL")); + assert_eq!(stored.get("HB").map(String::as_str), Some("HATCHTOBACK")); + } + + /// Migrating a profile that saw v2 pulls in v3's HB addition only. + #[test] + fn migration_from_version_two_adds_v3_aliases() { + let mut stored = map(&[("L", "LINE")]); + let changed = migrate_aliases(&mut stored, 2); + assert!(changed); + assert_eq!(stored.get("R"), None); + assert_eq!(stored.get("HB").map(String::as_str), Some("HATCHTOBACK")); } } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index ed249296..63bc9f7b 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -18,7 +18,7 @@ mod view; // `DrawOrderRefCommand` lives in the `view` family file but is referenced by // path (`commands::DrawOrderRefCommand`) from `update.rs`, so re-export it at // the module root to keep that path valid. -pub(crate) use view::DrawOrderRefCommand; +pub(crate) use view::DrawOrderCommand; impl OpenCADStudio { /// First `"{prefix}{n}"` (n ≥ 1) not already used by a block record in the @@ -397,9 +397,11 @@ inventory::submit!(crate::command::CommandRegistration { "CUILOAD", // Save every open drawing. "SAVEALL", - // Draw-order: all text/dims to front or back. + // Draw-order: all text/dims to front or back; hatches to back. "TEXTTOFRONT", "TEXTTOBACK", + "HATCHTOBACK", + "HB", // Criteria-based selection (same as QSELECT) + copy with picked base. "FILTER", "FI", diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 89ceea80..0e1779ca 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -772,17 +772,20 @@ impl OpenCADStudio { // the front (or back) of the draw order via the DRAWORDER machinery. "TEXTTOFRONT" | "TEXTTOBACK" => { let to_front = cmd.ends_with("FRONT"); + let block_handle = self.tabs[i].scene.current_layout_block_handle_pub(); let handles: rustc_hash::FxHashSet = self.tabs[i] .scene .document .entities() .filter(|e| { - matches!( - e, - acadrust::EntityType::Text(_) - | acadrust::EntityType::MText(_) - | acadrust::EntityType::Dimension(_) - ) + let c = e.common(); + (c.owner_handle == block_handle || c.owner_handle.is_null()) + && matches!( + e, + acadrust::EntityType::Text(_) + | acadrust::EntityType::MText(_) + | acadrust::EntityType::Dimension(_) + ) }) .map(|e| e.common().handle) .collect(); @@ -802,15 +805,129 @@ impl OpenCADStudio { ); } - "DRAWORDER" => { - use crate::command::SelectThenKeywordCommand; - let has_sel = !self.tabs[i].scene.selected_entities().is_empty(); - let c = SelectThenKeywordCommand::new( - "DRAWORDER", - "DRAWORDER [Front / Back] (Above/Under by typing):", - vec![("Front", "FRONT", None), ("Back", "BACK", None)], - has_sel, + // HATCHTOBACK ÔÇö move every hatch object in the active space to the back of the draw order. + "HATCHTOBACK" => { + use acadrust::objects::{ObjectType, SortEntitiesTable}; + let block_handle = self.tabs[i].scene.current_layout_block_handle_pub(); + let doc_ref = &self.tabs[i].scene.document; + + // 1. Single scan over objects to find existing SortEntitiesTable handle & overrides. + let mut existing_table_handle = None; + let mut overrides: Option> = None; + for (h, obj) in &doc_ref.objects { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + existing_table_handle = Some(*h); + if !t.is_empty() { + overrides = Some( + t.entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(), + ); + } + break; + } + } + } + + // 2. Pre-filter locked layers with zero string allocation. + let locked_layers: rustc_hash::FxHashSet<&str> = doc_ref + .layers + .iter() + .filter(|l| l.is_locked()) + .map(|l| l.name.as_str()) + .collect(); + let has_locked_layers = !locked_layers.is_empty(); + + // 3. Single pass over entities in the active space. + let mut hatches_to_move: Vec = Vec::new(); + let mut min_eff = u64::MAX; + + for e in doc_ref.entities() { + let c = e.common(); + if c.owner_handle != block_handle && !c.owner_handle.is_null() { + continue; + } + let hv = c.handle.value(); + if matches!(e, acadrust::EntityType::Hatch(_)) { + if !has_locked_layers || !locked_layers.contains(c.layer.as_str()) { + hatches_to_move.push(c.handle); + } + } else { + let eff = match &overrides { + Some(map) => map.get(&hv).copied().unwrap_or(hv), + None => hv, + }; + min_eff = min_eff.min(eff); + } + } + + if hatches_to_move.is_empty() { + self.command_line + .push_info(crate::tf!("{cmd}: no hatch objects.").as_ref()); + return Some(Task::none()); + } + + if min_eff == u64::MAX { + min_eff = 1; + } + + // 4. Ultra-fast targeted Delta Undo (snapshots ONLY the SortEntitiesTable, zero full-drawing clone). + let pending_delta = self.begin_undo(i, "DRAWORDER", hatches_to_move.len(), true); + + // 5. Update or insert SortEntitiesTable directly. + let table_before = existing_table_handle + .and_then(|h| self.tabs[i].scene.document.objects.get(&h).cloned()); + let th = existing_table_handle.unwrap_or_else(|| { + let nh = acadrust::Handle::new(self.tabs[i].scene.document.next_handle()); + let mut table = SortEntitiesTable::for_block(block_handle); + table.handle = nh; + self.tabs[i] + .scene + .document + .objects + .insert(nh, ObjectType::SortEntitiesTable(table)); + nh + }); + self.tabs[i].scene.record_undo_object_before(th, table_before); + + if let Some(ObjectType::SortEntitiesTable(table)) = + self.tabs[i].scene.document.objects.get_mut(&th) + { + for (k, h) in hatches_to_move.iter().enumerate() { + let sort = min_eff.saturating_sub(1 + k as u64).max(1); + table.add_entry(*h, acadrust::Handle::new(sort)); + } + } + + if let Some(pending) = pending_delta { + self.commit_undo_delta(i, pending); + } + + // 6. Invalidate ONLY draw-depth cache without dropping whole-drawing tessellations or spatial indexes. + let changes: Vec<(acadrust::Handle, crate::scene::ChangeKind)> = hatches_to_move + .iter() + .map(|h| (*h, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].scene.invalidate_draw_depth(); + + self.tabs[i].dirty = true; + self.command_line.push_info( + crate::tf!("DRAWORDER: moved {} entities to back.", hatches_to_move.len()).as_ref(), ); + + return Some(Task::none()); + } + + "DRAWORDER" => { + let selected: Vec = self.tabs[i] + .scene + .selected_entities() + .iter() + .map(|(h, _)| *h) + .collect(); + let c = DrawOrderCommand::new(selected); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } @@ -1083,38 +1200,159 @@ impl OpenCADStudio { } } -// ── Draw Order: interactive reference-object pick ────────────────────────── - -/// Moves a captured selection above or below a reference object the user -/// picks in the viewport. On pick it relaunches `DRAWORDER A|U ` -/// with the captured handles reinstalled as the selection, so the existing -/// command path performs the actual reorder. -pub(crate) struct DrawOrderRefCommand { - to_move: Vec, - above: bool, +// ── Draw Order: interactive command ────────────────────────── + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DrawOrderStep { + SelectObjects, + ChooseVerb, + PickReference { above: bool }, } -impl DrawOrderRefCommand { - pub(crate) fn new(to_move: Vec, above: bool) -> Self { - Self { to_move, above } +/// Interactive front-end for the DRAWORDER command. +/// +/// Flow: +/// 1. If nothing is selected, prompt to select objects (Enter confirms). +/// 2. Prompt for verb: `[Above / Under / Front / Back]`. +/// - `Front` / `F`: moves selection to front. +/// - `Back` / `B` / Enter: moves selection to back. +/// - `Above` / `A` / `Under` / `U`: advances to reference object pick. +/// 3. Reference object pick: user can click the reference entity in the viewport +/// or type its hex handle on the command line. +pub(crate) struct DrawOrderCommand { + selected: Vec, + step: DrawOrderStep, +} + +impl DrawOrderCommand { + pub(crate) fn new(selected: Vec) -> Self { + let step = if selected.is_empty() { + DrawOrderStep::SelectObjects + } else { + DrawOrderStep::ChooseVerb + }; + Self { selected, step } + } + + pub(crate) fn for_reference_pick(selected: Vec, above: bool) -> Self { + Self { + selected, + step: DrawOrderStep::PickReference { above }, + } } } -impl CadCommand for DrawOrderRefCommand { +impl CadCommand for DrawOrderCommand { fn name(&self) -> &'static str { "DRAWORDER" } fn prompt(&self) -> String { - if self.above { - crate::t!("DRAWORDER Select reference object (move selection above):").into_owned() - } else { - crate::t!("DRAWORDER Select reference object (move selection under):").into_owned() + match self.step { + DrawOrderStep::SelectObjects => { + crate::t!("DRAWORDER select objects, then press Enter:").into_owned() + } + DrawOrderStep::ChooseVerb => { + crate::t!("DRAWORDER [Above / Under / Front / Back] :").into_owned() + } + DrawOrderStep::PickReference { above: true } => { + crate::t!("DRAWORDER Select reference object (move selection above):").into_owned() + } + DrawOrderStep::PickReference { above: false } => { + crate::t!("DRAWORDER Select reference object (move selection under):").into_owned() + } + } + } + + fn options(&self) -> Vec { + match self.step { + DrawOrderStep::ChooseVerb => vec![ + crate::command::CmdOption::new("Above", "ABOVE"), + crate::command::CmdOption::new("Under", "UNDER"), + crate::command::CmdOption::new("Front", "FRONT"), + crate::command::CmdOption::new("Back", "BACK"), + ], + _ => Vec::new(), + } + } + + fn wants_text_input(&self) -> bool { + !matches!(self.step, DrawOrderStep::SelectObjects) + } + + fn is_selection_gathering(&self) -> bool { + matches!(self.step, DrawOrderStep::SelectObjects) + } + + fn on_selection_complete(&mut self, handles: Vec) -> crate::command::CmdResult { + self.selected = handles; + crate::command::CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> crate::command::CmdResult { + match self.step { + DrawOrderStep::SelectObjects => { + if self.selected.is_empty() { + crate::command::CmdResult::Cancel + } else { + self.step = DrawOrderStep::ChooseVerb; + crate::command::CmdResult::NeedPoint + } + } + DrawOrderStep::ChooseVerb => { + // Bare Enter defaults to Back + let handles = std::mem::take(&mut self.selected); + crate::command::CmdResult::Relaunch("DRAWORDER BACK".into(), handles) + } + DrawOrderStep::PickReference { .. } => crate::command::CmdResult::Cancel, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let t = text.trim(); + if t.is_empty() { + return None; + } + match self.step { + DrawOrderStep::SelectObjects => None, + DrawOrderStep::ChooseVerb => { + let up = t.to_uppercase(); + match up.as_str() { + "F" | "FRONT" => { + let handles = std::mem::take(&mut self.selected); + Some(crate::command::CmdResult::Relaunch("DRAWORDER FRONT".into(), handles)) + } + "B" | "BACK" => { + let handles = std::mem::take(&mut self.selected); + Some(crate::command::CmdResult::Relaunch("DRAWORDER BACK".into(), handles)) + } + "A" | "ABOVE" => { + self.step = DrawOrderStep::PickReference { above: true }; + Some(crate::command::CmdResult::NeedPoint) + } + "U" | "UNDER" | "BELOW" => { + self.step = DrawOrderStep::PickReference { above: false }; + Some(crate::command::CmdResult::NeedPoint) + } + _ => Some(crate::command::CmdResult::NeedPoint), + } + } + DrawOrderStep::PickReference { above } => { + let hex_str = t.trim_start_matches("0x").trim_start_matches("0X"); + if let Ok(val) = u64::from_str_radix(hex_str, 16) { + let opt = if above { "A" } else { "U" }; + let cmd = format!("DRAWORDER {} {:x}", opt, val); + let handles = std::mem::take(&mut self.selected); + Some(crate::command::CmdResult::Relaunch(cmd, handles)) + } else { + Some(crate::command::CmdResult::NeedPoint) + } + } } } fn needs_entity_pick(&self) -> bool { - true + matches!(self.step, DrawOrderStep::PickReference { .. }) } fn on_entity_pick( @@ -1125,16 +1363,606 @@ impl CadCommand for DrawOrderRefCommand { if handle.is_null() { return crate::command::CmdResult::NeedPoint; } - let opt = if self.above { "A" } else { "U" }; - let cmd = format!("DRAWORDER {} {:x}", opt, handle.value()); - crate::command::CmdResult::Relaunch(cmd, std::mem::take(&mut self.to_move)) + if let DrawOrderStep::PickReference { above } = self.step { + let opt = if above { "A" } else { "U" }; + let cmd = format!("DRAWORDER {} {:x}", opt, handle.value()); + let handles = std::mem::take(&mut self.selected); + crate::command::CmdResult::Relaunch(cmd, handles) + } else { + crate::command::CmdResult::NeedPoint + } } fn on_point(&mut self, _pt: glam::DVec3) -> crate::command::CmdResult { crate::command::CmdResult::NeedPoint } +} - fn on_enter(&mut self) -> crate::command::CmdResult { - crate::command::CmdResult::Cancel +#[cfg(test)] +mod tests { + use super::*; + use crate::app::OpenCADStudio; + use acadrust::objects::ObjectType; + use acadrust::EntityType; + + fn fresh_app() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app + } + + #[test] + fn hatchtoback_no_hatches_warns_and_noop() { + let mut app = fresh_app(); + let _ = app.run_command_line("HATCHTOBACK"); + let i = app.active_tab; + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table_exists = app.tabs[i].scene.document.objects.values().any(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + t.block_owner_handle == block_handle + } else { + false + } + }); + assert!(!table_exists, "No SortEntitiesTable created when no hatches exist"); + } + + #[test] + fn hatchtoback_moves_hatches_to_back() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line1 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_line2 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + + let _ = app.run_command_line("HATCHTOBACK"); + + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }).expect("SortEntitiesTable should exist for the active layout block"); + + let entries: rustc_hash::FxHashMap = table + .entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(); + + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let line1_sort = entries.get(&h_line1.value()).copied().unwrap_or(h_line1.value()); + let line2_sort = entries.get(&h_line2.value()).copied().unwrap_or(h_line2.value()); + + assert!( + hatch_sort < line1_sort, + "Hatch sort handle ({hatch_sort}) must be behind line 1 ({line1_sort})" + ); + assert!( + hatch_sort < line2_sort, + "Hatch sort handle ({hatch_sort}) must be behind line 2 ({line2_sort})" + ); + } + + #[test] + fn hb_alias_moves_hatches_to_back() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + let _ = app.run_command_line("HB"); + + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }).expect("SortEntitiesTable should exist for the active layout block"); + + let entries: rustc_hash::FxHashMap = table + .entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(); + + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); + + assert!( + hatch_sort < line_sort, + "Hatch sort handle ({hatch_sort}) must be behind line ({line_sort}) via HB alias" + ); + } + + #[test] + fn hatchtoback_ignores_hatches_in_other_spaces() { + let mut app = fresh_app(); + let i = app.active_tab; + let other_block = acadrust::Handle::new(0x9999); + let h_foreign = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(h_foreign) { + entity.common_mut().owner_handle = other_block; + } + + // When no hatches exist in the current layout, HATCHTOBACK should no-op. + let _ = app.run_command_line("HATCHTOBACK"); + + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table_exists = app.tabs[i].scene.document.objects.values().any(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + t.block_owner_handle == block_handle + } else { + false + } + }); + assert!(!table_exists, "Foreign hatch must not trigger table creation in active layout"); + + // Now add a line and a hatch in the active layout. + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + let _ = app.run_command_line("HATCHTOBACK"); + + let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }).expect("SortEntitiesTable should exist for current layout"); + + let entries: rustc_hash::FxHashMap = table + .entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(); + + assert!(!entries.contains_key(&h_foreign.value()), "Foreign hatch must not be in active layout table"); + assert!(entries.contains_key(&h_hatch.value()), "Active layout hatch must be in table"); + let hatch_sort = entries[&h_hatch.value()]; + let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); + assert!(hatch_sort < line_sort); + } + + #[test] + fn hatchtoback_multiple_calls_are_idempotent() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch1 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_hatch2 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + let _ = app.run_command_line("HATCHTOBACK"); + let _ = app.run_command_line("HATCHTOBACK"); + + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }).expect("SortEntitiesTable should exist"); + + let entries: rustc_hash::FxHashMap = table + .entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(); + + let h1_sort = entries.get(&h_hatch1.value()).copied().unwrap_or(h_hatch1.value()); + let h2_sort = entries.get(&h_hatch2.value()).copied().unwrap_or(h_hatch2.value()); + let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); + + assert!(h1_sort < line_sort); + assert!(h2_sort < line_sort); + } + + #[test] + fn hatchtoback_preserves_active_selection() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let _h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + // User had the line selected before running HATCHTOBACK. + app.tabs[i].scene.replace_selection(std::iter::once(h_line).collect()); + + let _ = app.run_command_line("HATCHTOBACK"); + + let selected = app.tabs[i].scene.selected_handles_in_order(); + assert_eq!(selected, vec![h_line], "HATCHTOBACK must not overwrite existing user selection"); + } + + #[test] + fn hatchtoback_skips_hatches_on_locked_layers() { + let mut app = fresh_app(); + let i = app.active_tab; + + // Lock layer "LOCKED_LAYER". + app.tabs[i].scene.ensure_layer("LOCKED_LAYER"); + if let Some(layer) = app.tabs[i].scene.document.layers.get_mut("LOCKED_LAYER") { + layer.flags.locked = true; + } + + let mut locked_hatch = acadrust::entities::Hatch::default(); + locked_hatch.common.layer = "LOCKED_LAYER".into(); + let h_locked_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(locked_hatch)); + + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + + // Run HATCHTOBACK when only a locked hatch exists: it should not move the locked hatch. + let _ = app.run_command_line("HATCHTOBACK"); + + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table_exists = app.tabs[i].scene.document.objects.values().any(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + t.block_owner_handle == block_handle + } else { + false + } + }); + assert!(!table_exists, "Locked hatch must not trigger table creation"); + + // Now add an unlocked hatch. + let h_unlocked_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + let _ = app.run_command_line("HATCHTOBACK"); + + let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }).expect("SortEntitiesTable should exist for unlocked hatch"); + + let entries: rustc_hash::FxHashMap = table + .entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(); + + assert!(!entries.contains_key(&h_locked_hatch.value()), "Locked hatch must not be reordered"); + assert!(entries.contains_key(&h_unlocked_hatch.value()), "Unlocked hatch must be reordered"); + let hatch_sort = entries[&h_unlocked_hatch.value()]; + let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); + assert!(hatch_sort < line_sort); + } + + #[test] + fn hatchtoback_undo_restores_draw_order() { + let mut app = fresh_app(); + let i = app.active_tab; + let _h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + let _ = app.run_command_line("HATCHTOBACK"); + + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }).expect("SortEntitiesTable should exist"); + assert!(table.entries().any(|e| e.entity_handle == h_hatch)); + + // Perform UNDO. + let _ = app.update(crate::app::Message::Undo); + + let table_after_undo = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }); + // Table was created fresh by HATCHTOBACK, so undo should remove it or leave it empty of the hatch. + let has_hatch = table_after_undo.map_or(false, |t| t.entries().any(|e| e.entity_handle == h_hatch)); + assert!(!has_hatch, "Undo must revert the SortEntitiesTable entry"); + } + + fn effective_sort_map( + app: &OpenCADStudio, + ) -> rustc_hash::FxHashMap { + let i = app.active_tab; + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + app.tabs[i] + .scene + .document + .objects + .values() + .find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some( + t.entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(), + ); + } + } + None + }) + .unwrap_or_default() + } + + // Regression guard for DRAWORDER BACK anchoring. Written while auditing + // the draw-order code, where BACK *looked* like it skipped the baseline + // scan and clamped every moved entity onto sort key 1 (a tie). Running + // this test disproved that — `to_front_opt` is Some(false) for BACK, so + // the baseline scan does run. Kept to lock in the strict-order behavior: + // a second BACK must land strictly below earlier assignments, never tie. + #[test] + fn draworder_back_twice_keeps_strict_order() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line1 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + // Send the hatch to back, then send line 1 behind it: the second BACK + // must anchor strictly below the first assignment, never tie with it. + app.tabs[i].scene.replace_selection(std::iter::once(h_hatch).collect()); + let _ = app.run_command_line("DRAWORDER BACK"); + app.tabs[i].scene.replace_selection(std::iter::once(h_line1).collect()); + let _ = app.run_command_line("DRAWORDER BACK"); + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let line1_sort = entries.get(&h_line1.value()).copied().unwrap_or(h_line1.value()); + + assert!( + line1_sort < hatch_sort, + "second BACK must land strictly below the first ({line1_sort} vs {hatch_sort})" + ); + } + + #[test] + fn draworder_front_moves_hatch_in_front_of_all() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line1 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_line2 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + + app.tabs[i].scene.replace_selection(std::iter::once(h_hatch).collect()); + let _ = app.run_command_line("DRAWORDER FRONT"); + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let line1_sort = entries.get(&h_line1.value()).copied().unwrap_or(h_line1.value()); + let line2_sort = entries.get(&h_line2.value()).copied().unwrap_or(h_line2.value()); + + assert!(hatch_sort > line1_sort, "hatch ({hatch_sort}) must render in front of line 1 ({line1_sort})"); + assert!(hatch_sort > line2_sort, "hatch ({hatch_sort}) must render in front of line 2 ({line2_sort})"); + } + + #[test] + fn draworder_back_moves_hatch_behind_all() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line1 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_line2 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + + app.tabs[i].scene.replace_selection(std::iter::once(h_hatch).collect()); + let _ = app.run_command_line("DRAWORDER BACK"); + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let line1_sort = entries.get(&h_line1.value()).copied().unwrap_or(h_line1.value()); + let line2_sort = entries.get(&h_line2.value()).copied().unwrap_or(h_line2.value()); + + assert!(hatch_sort < line1_sort, "hatch ({hatch_sort}) must render behind line 1 ({line1_sort})"); + assert!(hatch_sort < line2_sort, "hatch ({hatch_sort}) must render behind line 2 ({line2_sort})"); + } + + #[test] + fn draworder_above_reference_puts_hatch_in_front_of_object() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + app.tabs[i].scene.replace_selection(std::iter::once(h_hatch).collect()); + let cmd = format!("DRAWORDER ABOVE {:x}", h_ref.value()); + let _ = app.run_command_line(&cmd); + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); + + assert!(hatch_sort > ref_sort, "hatch ({hatch_sort}) must be above reference object ({ref_sort})"); + } + + #[test] + fn draworder_under_reference_puts_hatch_behind_object() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + app.tabs[i].scene.replace_selection(std::iter::once(h_hatch).collect()); + let cmd = format!("DRAWORDER UNDER {:x}", h_ref.value()); + let _ = app.run_command_line(&cmd); + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); + + assert!(hatch_sort < ref_sort, "hatch ({hatch_sort}) must be under reference object ({ref_sort})"); + } + + #[test] + fn draworder_interactive_shortcuts_f_b_a_u() { + use crate::command::CadCommand; + + let mut app = fresh_app(); + let i = app.active_tab; + let _h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + // 1. Shortcut 'F' -> Front + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + assert_eq!(cmd.wants_text_input(), true); + let res = cmd.on_text_input("F"); + match res { + Some(crate::command::CmdResult::Relaunch(c, handles)) => { + assert_eq!(c, "DRAWORDER FRONT"); + assert_eq!(handles, vec![h_hatch]); + } + _ => panic!("Expected Relaunch for shortcut F"), + } + + // 2. Shortcut 'B' -> Back + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + let res = cmd.on_text_input("B"); + match res { + Some(crate::command::CmdResult::Relaunch(c, handles)) => { + assert_eq!(c, "DRAWORDER BACK"); + assert_eq!(handles, vec![h_hatch]); + } + _ => panic!("Expected Relaunch for shortcut B"), + } + + // 3. Shortcut 'A' -> Advances to PickReference (above) + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + assert!(!cmd.needs_entity_pick()); + let res = cmd.on_text_input("A"); + assert!(matches!(res, Some(crate::command::CmdResult::NeedPoint))); + assert!(cmd.needs_entity_pick(), "Needs entity pick after choosing 'A'"); + + // 4. Shortcut 'U' -> Advances to PickReference (under) + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + let res = cmd.on_text_input("U"); + assert!(matches!(res, Some(crate::command::CmdResult::NeedPoint))); + assert!(cmd.needs_entity_pick(), "Needs entity pick after choosing 'U'"); + } + + #[test] + fn draworder_interactive_above_under_viewport_entity_pick() { + use crate::command::CadCommand; + + let mut app = fresh_app(); + let i = app.active_tab; + let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + // Above with viewport pick + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + let _ = cmd.on_text_input("A"); + assert!(cmd.needs_entity_pick()); + let pick_res = cmd.on_entity_pick(h_ref, glam::DVec3::ZERO); + match pick_res { + crate::command::CmdResult::Relaunch(relaunch_cmd, handles) => { + assert_eq!(relaunch_cmd, format!("DRAWORDER A {:x}", h_ref.value())); + assert_eq!(handles, vec![h_hatch]); + app.tabs[i].scene.replace_selection(handles.into_iter().collect()); + let _ = app.run_command_line(&relaunch_cmd); + } + _ => panic!("Expected Relaunch from entity pick"), + } + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); + assert!(hatch_sort > ref_sort, "Hatch must be above reference after viewport pick"); + + // Under with viewport pick + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + let _ = cmd.on_text_input("U"); + assert!(cmd.needs_entity_pick()); + let pick_res = cmd.on_entity_pick(h_ref, glam::DVec3::ZERO); + match pick_res { + crate::command::CmdResult::Relaunch(relaunch_cmd, handles) => { + assert_eq!(relaunch_cmd, format!("DRAWORDER U {:x}", h_ref.value())); + assert_eq!(handles, vec![h_hatch]); + app.tabs[i].scene.replace_selection(handles.into_iter().collect()); + let _ = app.run_command_line(&relaunch_cmd); + } + _ => panic!("Expected Relaunch from entity pick"), + } + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); + assert!(hatch_sort < ref_sort, "Hatch must be under reference after viewport pick"); + } + + #[test] + fn draworder_interactive_typed_hex_handle() { + use crate::command::CadCommand; + + let mut app = fresh_app(); + let i = app.active_tab; + let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + let _ = cmd.on_text_input("Above"); + let hex_input = format!("0x{:x}", h_ref.value()); + let typed_res = cmd.on_text_input(&hex_input); + match typed_res { + Some(crate::command::CmdResult::Relaunch(relaunch_cmd, handles)) => { + assert_eq!(relaunch_cmd, format!("DRAWORDER A {:x}", h_ref.value())); + assert_eq!(handles, vec![h_hatch]); + app.tabs[i].scene.replace_selection(handles.into_iter().collect()); + let _ = app.run_command_line(&relaunch_cmd); + } + _ => panic!("Expected Relaunch from typed hex handle"), + } + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); + assert!(hatch_sort > ref_sort); + } + + #[test] + fn draworder_interactive_without_preselection() { + use crate::command::CadCommand; + + let mut app = fresh_app(); + let i = app.active_tab; + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + // Start command with no pre-selection + let mut cmd = DrawOrderCommand::new(vec![]); + assert!(cmd.is_selection_gathering()); + assert!(!cmd.wants_text_input()); + + // Gather selection + let _ = cmd.on_selection_complete(vec![h_hatch]); + let enter_res = cmd.on_enter(); + assert!(matches!(enter_res, crate::command::CmdResult::NeedPoint)); + assert!(!cmd.is_selection_gathering()); + assert!(cmd.wants_text_input()); + + // Choose verb Front + let res = cmd.on_text_input("F"); + match res { + Some(crate::command::CmdResult::Relaunch(relaunch_cmd, handles)) => { + assert_eq!(relaunch_cmd, "DRAWORDER FRONT"); + assert_eq!(handles, vec![h_hatch]); + app.tabs[i].scene.replace_selection(handles.into_iter().collect()); + let _ = app.run_command_line(&relaunch_cmd); + } + _ => panic!("Expected Relaunch"), + } + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); + assert!(hatch_sort > line_sort); } } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 66ed2b60..679b3ad9 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4129,7 +4129,7 @@ impl OpenCADStudio { .push_error(crate::t!("DRAWORDER: select entities first.").as_ref()); } else { use crate::command::CadCommand; - let cmd = super::commands::DrawOrderRefCommand::new(to_move, above); + let cmd = super::commands::DrawOrderCommand::for_reference_pick(to_move, above); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } diff --git a/src/command.rs b/src/command.rs index fcc8344f..7cc6382e 100644 --- a/src/command.rs +++ b/src/command.rs @@ -542,6 +542,34 @@ impl KeywordCommand { } } +fn match_cmd_option<'a>( + options: &'a [(&'static str, &'static str, Option<&'static str>)], + text: &str, +) -> Option<&'a (&'static str, &'static str, Option<&'static str>)> { + let t = text.trim(); + let up = t.to_uppercase(); + if up.is_empty() { + return None; + } + // 1. Exact match on keyword or label (case-insensitive) + if let Some(opt) = options.iter().find(|(label, k, _)| { + k.eq_ignore_ascii_case(&up) || label.eq_ignore_ascii_case(t) + }) { + return Some(opt); + } + // 2. Unambiguous prefix match on keyword or label (e.g. "A" -> "ABOVE", "L" -> "LEFT") + let matches: Vec<_> = options + .iter() + .filter(|(label, k, _)| { + k.to_uppercase().starts_with(&up) || label.to_uppercase().starts_with(&up) + }) + .collect(); + if matches.len() == 1 { + return Some(matches[0]); + } + None +} + impl CadCommand for KeywordCommand { fn name(&self) -> &'static str { self.name @@ -581,10 +609,7 @@ impl CadCommand for KeywordCommand { // Consumed inputs that keep prompting return `Some(NeedPoint)` — // `None` would hand the same text to the command a second time. None => { - let up = t.to_uppercase(); - let Some((_, keyword, value_prompt)) = self.options.iter().find(|(label, k, _)| { - k.eq_ignore_ascii_case(&up) || label.eq_ignore_ascii_case(t) - }) + let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t) else { // Unknown verb — keep prompting rather than dispatch garbage. return Some(CmdResult::NeedPoint); @@ -783,11 +808,18 @@ impl CadCommand for SelectThenKeywordCommand { return None; } match self.pending { - Some((keyword, _)) => Some(CmdResult::Dispatch(format!("{} {keyword} {t}", self.name))), + Some((keyword, _)) => { + if self.selected.is_empty() { + Some(CmdResult::Dispatch(format!("{} {keyword} {t}", self.name))) + } else { + Some(CmdResult::Relaunch( + format!("{} {keyword} {t}", self.name), + std::mem::take(&mut self.selected), + )) + } + } None => { - let up = t.to_uppercase(); - let Some((_, keyword, value_prompt)) = - self.options.iter().find(|(_, k, _)| k.eq_ignore_ascii_case(&up)) + let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t) else { // Unknown verb — consumed, keep prompting (`None` would // feed the same text to the command a second time). @@ -798,7 +830,16 @@ impl CadCommand for SelectThenKeywordCommand { self.pending = Some((keyword, vp)); Some(CmdResult::NeedPoint) } - None => Some(CmdResult::Dispatch(format!("{} {keyword}", self.name))), + None => { + if self.selected.is_empty() { + Some(CmdResult::Dispatch(format!("{} {keyword}", self.name))) + } else { + Some(CmdResult::Relaunch( + format!("{} {keyword}", self.name), + std::mem::take(&mut self.selected), + )) + } + } } } } diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index c9d6a894..a2902bd6 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3070,6 +3070,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "those objects" => Some(("modal", "those-objects")), "units" => Some(("common", "units")), "{cmd}: cannot create folder ({e})." => Some(("file", "cmd-cannot-create-folder-e")), + "{cmd}: no hatch objects." => Some(("view", "cmd-no-hatch-objects")), "{cmd}: no text or dimension objects." => Some(("view", "cmd-no-text-or-dimension-objects")), "{cmd}: not yet implemented." => Some(("view", "cmd-not-yet-implemented")), "{cmd}: packaged {copied} file(s) into {}" => Some(("file", "cmd-packaged-copied-file-s-into")), diff --git a/src/scene/entity.rs b/src/scene/entity.rs index fb5e60f6..a257971e 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -2399,8 +2399,6 @@ impl Scene { let mut bits = 0; if is_outer { bits |= acadrust::entities::hatch::BoundaryPathFlags::OUTERMOST.bits(); - } - if !handles.is_empty() { bits |= acadrust::entities::hatch::BoundaryPathFlags::EXTERNAL.bits(); } let mut path = BoundaryPath::with_flags( diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 7f63fbe9..d887e77e 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -5458,6 +5458,11 @@ impl Scene { .collect() } + /// Invalidate only the draw-order depth cache without dropping geometry tessellation. + pub fn invalidate_draw_depth(&self) { + *self.draw_depth_cache.borrow_mut() = None; + } + /// Per-entity stable draw-order depth, keyed by entity handle value. /// A full build assigns sparse labels in effective draw order. Incremental /// Add/Remove then changes only the named handle: existing siblings retain diff --git a/tests/block_hatch_export.rs b/tests/block_hatch_export.rs index 4aad9d9f..01f0a0f8 100644 --- a/tests/block_hatch_export.rs +++ b/tests/block_hatch_export.rs @@ -82,7 +82,7 @@ fn block_internal_hatch_reaches_export() { .expect("wrap hatch into a block + insert"); scene.populate_hatches_from_document(); - let hatches = scene.paper_canvas_hatches(); + let hatches = scene.paper_plot_hatches(); let blue = hatches.iter().filter(|m| is_blue(&m.color)).count(); assert!( blue > 0, @@ -103,7 +103,7 @@ fn pattern_hatch_uses_stored_line_spacing() { scene.add_entity(EntityType::Hatch(ansi31_stored(0.5, 10.0))); scene.populate_hatches_from_document(); - let hatches = scene.paper_canvas_hatches(); + let hatches = scene.paper_plot_hatches(); let m = hatches .iter() .find(|m| matches!(m.pattern, HatchPattern::Pattern(_))) @@ -144,7 +144,7 @@ fn far_from_origin_pattern_hatch_still_fills() { scene.add_entity(EntityType::Hatch(ansi31_stored_at(0.3, 1.0, 4000.0, 0.0))); scene.populate_hatches_from_document(); - let hatches = scene.paper_canvas_hatches(); + let hatches = scene.paper_plot_hatches(); let m = hatches .iter() .find(|m| matches!(m.pattern, HatchPattern::Pattern(_))) @@ -189,7 +189,7 @@ fn textbox_boundary_path_is_not_filled() { scene.add_entity(EntityType::Hatch(hatch)); scene.populate_hatches_from_document(); - let hatches = scene.paper_canvas_hatches(); + let hatches = scene.paper_plot_hatches(); let m = hatches.first().expect("hatch present"); // The boundary must not extend into the 200x50 TEXTBOX rectangle. let max_x = m @@ -244,8 +244,12 @@ fn app_created_hatch_roundtrips_catalog_spacing() { world_origin: [0.0, 0.0], boundary: Arc::new(boundary), boundary_wcs: None, + fill_plane: None, + fill_plane_boundary: None, boundary_exterior: None, boundary_sources: None, + boundary_paths: None, + style: acadrust::entities::HatchStyleType::Normal, pattern: entry.gpu.clone(), name: "ANSI31".into(), color: [0.75, 0.75, 0.75, 0.85], @@ -258,7 +262,7 @@ fn app_created_hatch_roundtrips_catalog_spacing() { scene.add_hatch(model, None, None); scene.populate_hatches_from_document(); - let hatches = scene.paper_canvas_hatches(); + let hatches = scene.paper_plot_hatches(); let m = hatches .iter() .find(|m| matches!(m.pattern, HatchPattern::Pattern(_))) @@ -298,8 +302,12 @@ fn nested_hatch_serializes_only_outer_as_external() { world_origin: [0.0, 0.0], boundary: Arc::new(boundary_f32), boundary_wcs: Some(Arc::new(wcs)), + fill_plane: None, + fill_plane_boundary: None, boundary_exterior: None, boundary_sources: None, + boundary_paths: None, + style: acadrust::entities::HatchStyleType::Normal, pattern: HatchPattern::Solid, name: "SOLID".into(), color: [0.45, 0.45, 0.45, 0.60],