From 236b7756f83605dac18599069c7b265a472f715c Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Sun, 23 Aug 2026 17:12:24 +0100 Subject: [PATCH] feat(draworder): HATCHTOBACK command and interactive DRAWORDER workflows Implement the AutoCAD compatible HATCHTOBACK (HB) command and enhance the interactive DRAWORDER command pipeline with comprehensive verb selection and viewport entity picking. Key implementations and architectural details: 1. HATCHTOBACK command execution * Isolates entities belonging to the active layout block record or global space. * Filters out entities residing on locked layers using an efficient hash set lookup. * Records targeted delta undo operations on SortEntitiesTable objects (~100 bytes) rather than snapshotting full document structures. * Performs granular draw depth cache invalidation via Scene::invalidate_draw_depth, preserving existing geometry tessellations and spatial indices. * Emits localized user notifications when no qualifying hatch entities are found. 2. Interactive DrawOrderCommand state machine * Supports preselected entities as well as interactive on demand selection gathering. * Implements subverb selection supporting single character shortcuts (A for Above, U for Under, F for Front, B for Back) and case insensitive keyword parsing. * Enables interactive reference object picking directly in the viewport via needs_entity_pick and on_entity_pick callbacks. * Supports hexadecimal handle specification directly from the command line interface. 3. Ecosystem and system integration * Bumps DEFAULT_ALIASES_VERSION from 2 to 3 to register the HB alias while preserving existing user overrides. * Integrates HATCHTOBACK and HB into dynamic autocomplete registries. * Adds localized message strings across all 20 Fluent translation catalogs. * Corrects hatch boundary serialization flags to ensure outermost boundary rings consistently carry the external attribute. --- assets/ocad.pgp | 1 + 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/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/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/alias.rs | 26 +- src/app/commands/mod.rs | 6 +- src/app/commands/view.rs | 902 ++++++++++++++++++++++++++++++-- src/app/update/mod.rs | 2 +- src/command.rs | 59 ++- src/locale_catalog.rs | 1 + src/scene/entity.rs | 2 - src/scene/mod.rs | 5 + tests/block_hatch_export.rs | 18 +- 30 files changed, 980 insertions(+), 62 deletions(-) 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],