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.
This commit is contained in:
Karim Jerbi 2026-08-23 17:12:24 +01:00
commit 236b7756f8
30 changed files with 979 additions and 61 deletions

View file

@ -21,6 +21,7 @@ SPL, *SPLINE
H, *HATCH H, *HATCH
BH, *HATCH BH, *HATCH
HE, *HATCHEDIT HE, *HATCHEDIT
HB, *HATCHTOBACK
BO, *BOUNDARY BO, *BOUNDARY
GD, *GRADIENT GD, *GRADIENT
XL, *XLINE XL, *XLINE

View file

@ -2347,6 +2347,7 @@ view =
.zoom-extents = .zoom-extents =
تكبير إلى تكبير إلى
النطاقات النطاقات
.cmd-no-hatch-objects = __ocs_fmt_0__: ┘äϺ Ϭ┘êϼϻ ┘âϺϪ┘åϺϬ Ϭϩ┘ä┘è┘ä.
.cmd-no-text-or-dimension-objects = __ocs_fmt_0__: لا توجد كائنات نص أو أبعاد. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: لا توجد كائنات نص أو أبعاد.
.cmd-not-yet-implemented = __ocs_fmt_0__: لم يُنفّذ بعد. .cmd-not-yet-implemented = __ocs_fmt_0__: لم يُنفّذ بعد.
.viewport-s-in-layout = __ocs_fmt_0__ منفذ عرض في التخطيط "__ocs_fmt_1__": .viewport-s-in-layout = __ocs_fmt_0__ منفذ عرض في التخطيط "__ocs_fmt_1__":

View file

@ -2334,6 +2334,7 @@ view =
.zoom-extents = .zoom-extents =
Мащаб Мащаб
Обхват Обхват
.cmd-no-hatch-objects = __ocs_fmt_0__: ð¢ÐÅð╝ð░ ÐëÐÇð©Ðàð¥ð▓ÐèÐçð¢ð© ð¥ð▒ðÁð║Ðéð©.
.cmd-no-text-or-dimension-objects = __ocs_fmt_0__: няма текстови обекти или оразмерявания. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: няма текстови обекти или оразмерявания.
.cmd-not-yet-implemented = __ocs_fmt_0__: все още не е реализирано. .cmd-not-yet-implemented = __ocs_fmt_0__: все още не е реализирано.
.viewport-s-in-layout = __ocs_fmt_0__ изглед(а) в оформление "__ocs_fmt_1__": .viewport-s-in-layout = __ocs_fmt_0__ изглед(а) в оформление "__ocs_fmt_1__":

View file

@ -2334,6 +2334,7 @@ view =
.zoom-extents = .zoom-extents =
Přiblížení Přiblížení
Rozsahy 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-no-text-or-dimension-objects = __ocs_fmt_0__: žádný text ani kótovací objekty.
.cmd-not-yet-implemented = __ocs_fmt_0__: dosud neimplementováno. .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__": .viewport-s-in-layout =Výřezy __ocs_fmt_0__ v rozložení "__ocs_fmt_1__":

View file

@ -2333,6 +2333,7 @@ view =
.zoom-extents = .zoom-extents =
Zoom Zoom
Umfang Umfang
.cmd-no-hatch-objects = __ocs_fmt_0__: keine Schraffurobjekte.
.cmd-no-text-or-dimension-objects = __ocs_fmt_0__: keine Text- oder Dimensionsobjekte. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: keine Text- oder Dimensionsobjekte.
.cmd-not-yet-implemented = __ocs_fmt_0__: noch nicht umgesetzt. .cmd-not-yet-implemented = __ocs_fmt_0__: noch nicht umgesetzt.
.viewport-s-in-layout = __ocs_fmt_0__ Ansichtsfenster(s) im Layout "__ocs_fmt_1__": .viewport-s-in-layout = __ocs_fmt_0__ Ansichtsfenster(s) im Layout "__ocs_fmt_1__":

View file

@ -2363,6 +2363,7 @@ view =
.zoom-extents = .zoom-extents =
Zoom Zoom
Extents 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-no-text-or-dimension-objects = __ocs_fmt_0__: no text or dimension objects.
.cmd-not-yet-implemented = __ocs_fmt_0__: not yet implemented. .cmd-not-yet-implemented = __ocs_fmt_0__: not yet implemented.
.viewport-s-in-layout = __ocs_fmt_0__ viewport(s) in layout "__ocs_fmt_1__": .viewport-s-in-layout = __ocs_fmt_0__ viewport(s) in layout "__ocs_fmt_1__":

View file

@ -2335,6 +2335,7 @@ view =
.zoom-extents = .zoom-extents =
Zoom Zoom
Extensión 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-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. .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__»: .viewport-s-in-layout = __ocs_fmt_0__ ventanas gráficas en la presentación «__ocs_fmt_1__»:

View file

@ -2334,6 +2334,7 @@ view =
.zoom-extents = .zoom-extents =
Zoomaa Zoomaa
Laajuudet 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-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. .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__": .viewport-s-in-layout =__ocs_fmt_0__-näkymä(t) asettelussa "__ocs_fmt_1__":

View file

@ -2335,6 +2335,7 @@ view =
.zoom-extents = .zoom-extents =
Zoom Zoom
Portée 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-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. .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__ » : .viewport-s-in-layout = __ocs_fmt_0__ fenêtre(s) dans la présentation « __ocs_fmt_1__ » :

View file

@ -2323,6 +2323,7 @@ view =
.zoom-extents = .zoom-extents =
ज़ूम ज़ूम
बहार बहार
.cmd-no-hatch-objects = __ocs_fmt_0__: ÓñòÓÑïÓñê Óñ╣ÓÑêÓñÜ ÓñæÓñ¼ÓÑìÓñ£ÓÑçÓñòÓÑìÓñƒ Óñ¿Óñ╣ÓÑÇÓñéÓÑñ
.cmd-no-text-or-dimension-objects = __ocs_fmt_0__: कोई टेक्स्ट या डाइमेंशन ऑब्जेक्ट नहीं। .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: कोई टेक्स्ट या डाइमेंशन ऑब्जेक्ट नहीं।
.cmd-not-yet-implemented = __ocs_fmt_0__: अभी तक लागू नहीं है। .cmd-not-yet-implemented = __ocs_fmt_0__: अभी तक लागू नहीं है।
.viewport-s-in-layout = लेआउट "__ocs_fmt_1__" में __ocs_fmt_0__ व्यूपोर्ट: .viewport-s-in-layout = लेआउट "__ocs_fmt_1__" में __ocs_fmt_0__ व्यूपोर्ट:

View file

@ -2334,6 +2334,7 @@ view =
.zoom-extents = .zoom-extents =
Zoom Zoom
Terjedelem 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-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. .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: .viewport-s-in-layout =__ocs_fmt_0__ nézetablak(ok) a „__ocs_fmt_1__” elrendezésben:

View file

@ -2334,6 +2334,7 @@ view =
.zoom-extents = .zoom-extents =
Zoom Zoom
Estensioni 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-no-text-or-dimension-objects = __ocs_fmt_0__: nessun testo o oggetto dimensione.
.cmd-not-yet-implemented = __ocs_fmt_0__: non ancora implementato. .cmd-not-yet-implemented = __ocs_fmt_0__: non ancora implementato.
.viewport-s-in-layout =Finestre __ocs_fmt_0__ nel layout "__ocs_fmt_1__": .viewport-s-in-layout =Finestre __ocs_fmt_0__ nel layout "__ocs_fmt_1__":

View file

@ -2323,6 +2323,7 @@ view =
.zoom-extents = .zoom-extents =
全体表示 全体表示
ズーム ズーム
.cmd-no-hatch-objects = __ocs_fmt_0__: ÒâÅÒââÒâüÒâ│Òé░ Òé¬ÒâûÒé©ÒéºÒé»ÒâêÒüîÒüéÒéèÒü¥ÒüøÒéôÒÇé
.cmd-no-text-or-dimension-objects = __ocs_fmt_0__: テキストまたは寸法オブジェクトがありません。 .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: テキストまたは寸法オブジェクトがありません。
.cmd-not-yet-implemented = __ocs_fmt_0__: まだ実装されていません。 .cmd-not-yet-implemented = __ocs_fmt_0__: まだ実装されていません。
.viewport-s-in-layout = レイアウト「__ocs_fmt_1__」内の __ocs_fmt_0__ 個のビューポート: .viewport-s-in-layout = レイアウト「__ocs_fmt_1__」内の __ocs_fmt_0__ 個のビューポート:

View file

@ -2334,6 +2334,7 @@ view =
.zoom-extents = .zoom-extents =
범위 범위
.cmd-no-hatch-objects = __ocs_fmt_0__: Ýò┤ý╣ÿ Û░Øý▓┤Û░Ç ýùåýèÁÙïêÙïñ.
.cmd-no-text-or-dimension-objects = __ocs_fmt_0__: 문자 또는 치수 객체가 없습니다. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: 문자 또는 치수 객체가 없습니다.
.cmd-not-yet-implemented = __ocs_fmt_0__: 아직 구현되지 않았습니다. .cmd-not-yet-implemented = __ocs_fmt_0__: 아직 구현되지 않았습니다.
.viewport-s-in-layout ="__ocs_fmt_1__" 레이아웃의 __ocs_fmt_0__ 뷰포트: .viewport-s-in-layout ="__ocs_fmt_1__" 레이아웃의 __ocs_fmt_0__ 뷰포트:

View file

@ -2335,6 +2335,7 @@ view =
.zoom-extents = .zoom-extents =
Zoomen Zoomen
Maten Maten
.cmd-no-hatch-objects = __ocs_fmt_0__: geen arceerobjecten.
.cmd-no-text-or-dimension-objects = __ocs_fmt_0__: geen tekst- of maatobjecten. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: geen tekst- of maatobjecten.
.cmd-not-yet-implemented = __ocs_fmt_0__: nog niet geïmplementeerd. .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__": .viewport-s-in-layout = __ocs_fmt_0__ weergavevenster(s) in indeling "__ocs_fmt_1__":

View file

@ -2334,6 +2334,7 @@ view =
.zoom-extents = .zoom-extents =
Powiększ Powiększ
Zakresy 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-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. .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__”: .viewport-s-in-layout =Okna widokowe __ocs_fmt_0__ w układzie „__ocs_fmt_1__”:

View file

@ -2335,6 +2335,7 @@ view =
.zoom-extents = .zoom-extents =
Zoom nas Zoom nas
extensões 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-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. .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__”: .viewport-s-in-layout = __ocs_fmt_0__ janelas de visualização no layout “__ocs_fmt_1__”:

View file

@ -2338,6 +2338,7 @@ view =
.zoom-extents = .zoom-extents =
Зум Зум
размеры размеры
.cmd-no-hatch-objects = __ocs_fmt_0__: ð¢ðÁÐé ð¥ð▒ÐèðÁð║Ðéð¥ð▓ ÐêÐéÐÇð©Ðàð¥ð▓ð║ð©.
.cmd-no-text-or-dimension-objects = __ocs_fmt_0__: нет текстовых или размерных объектов. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: нет текстовых или размерных объектов.
.cmd-not-yet-implemented = __ocs_fmt_0__: еще не реализовано. .cmd-not-yet-implemented = __ocs_fmt_0__: еще не реализовано.
.viewport-s-in-layout = Видовые экраны (__ocs_fmt_0__) на листе «__ocs_fmt_1__»: .viewport-s-in-layout = Видовые экраны (__ocs_fmt_0__) на листе «__ocs_fmt_1__»:

View file

@ -2303,6 +2303,7 @@ view =
.zoom-window-specify-first-corner = ZOOM WINDOW İlk köşeyi belirtin: .zoom-window-specify-first-corner = ZOOM WINDOW İlk köşeyi belirtin:
.zoom-window-specify-opposite-corner = ZOOM WINDOW Karşı köşeyi belirtin: .zoom-window-specify-opposite-corner = ZOOM WINDOW Karşı köşeyi belirtin:
.zoom-extents = Sınırlara Yakınlaştır .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-no-text-or-dimension-objects = __ocs_fmt_0__: Metin veya ölçü nesnesi yok.
.cmd-not-yet-implemented = __ocs_fmt_0__: Henüz uygulanmadı. .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ı: .viewport-s-in-layout = "__ocs_fmt_1__" yerleşiminde __ocs_fmt_0__ görünüm alanı:

View file

@ -2318,6 +2318,7 @@ view =
.zoom-extents = .zoom-extents =
缩放 缩放
范围 范围
.cmd-no-hatch-objects = __ocs_fmt_0__:µ▓íµ£ëÕø¥µíêÕí½ÕààÕ»╣Þ▒íÒÇé
.cmd-no-text-or-dimension-objects = __ocs_fmt_0__:没有文本或维度对象。 .cmd-no-text-or-dimension-objects = __ocs_fmt_0__:没有文本或维度对象。
.cmd-not-yet-implemented = __ocs_fmt_0__:尚未执行。 .cmd-not-yet-implemented = __ocs_fmt_0__:尚未执行。
.viewport-s-in-layout = 布局“__ocs_fmt_1__”中的视口__ocs_fmt_0__ 个): .viewport-s-in-layout = 布局“__ocs_fmt_1__”中的视口__ocs_fmt_0__ 个):

View file

@ -2330,6 +2330,7 @@ view =
.zoom-extents = .zoom-extents =
縮放 縮放
範圍 範圍
.cmd-no-hatch-objects = __ocs_fmt_0__:µ▓Ƶ£ëÕí½ÕààþÀÜþë®õ╗ÂÒÇé
.cmd-no-text-or-dimension-objects = __ocs_fmt_0__:沒有文字或維度物件。 .cmd-no-text-or-dimension-objects = __ocs_fmt_0__:沒有文字或維度物件。
.cmd-not-yet-implemented = __ocs_fmt_0__:尚未實作。 .cmd-not-yet-implemented = __ocs_fmt_0__:尚未實作。
.viewport-s-in-layout = 配置“__ocs_fmt_1__”中的視口__ocs_fmt_0__ 個): .viewport-s-in-layout = 配置“__ocs_fmt_1__”中的視口__ocs_fmt_0__ 個):

View file

@ -73,8 +73,9 @@ const WEB_ALIAS_KEY: &str = "opencadstudio.aliases";
/// Version of the shipped default alias table. Bump this whenever the embedded /// 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 /// defaults add aliases or change a default target, so existing profiles can be
/// migrated forward (see `introduced_at` / `migrate_aliases`). Version 1 shipped /// migrated forward (see `introduced_at` / `migrate_aliases`). Version 1 shipped
/// the pre-REDRAW table; version 2 introduced the REDRAW-family aliases. /// the pre-REDRAW table; version 2 introduced the REDRAW-family aliases; version 3
const DEFAULT_ALIASES_VERSION: u32 = 2; /// introduced HB (HATCHTOBACK).
const DEFAULT_ALIASES_VERSION: u32 = 3;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
const WEB_ALIAS_VERSION_KEY: &str = "opencadstudio.aliases.version"; 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"), ("RE", "REGEN"),
("REA", "REGENALL"), ("REA", "REGENALL"),
], ],
3 => &[("HB", "HATCHTOBACK")],
_ => &[], _ => &[],
} }
} }
@ -330,17 +332,18 @@ mod tests {
.collect() .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. /// new defaults, with every pre-existing mapping kept.
#[test] #[test]
fn migration_adds_new_defaults_preserving_existing() { fn migration_adds_new_defaults_preserving_existing() {
let mut stored = map(&[("L", "LINE"), ("Z", "ZOOM"), ("HI", "HIDE")]); let mut stored = map(&[("L", "LINE"), ("Z", "ZOOM"), ("HI", "HIDE")]);
let changed = migrate_aliases(&mut stored, 0); 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("R").map(String::as_str), Some("REDRAW"));
assert_eq!(stored.get("RA").map(String::as_str), Some("REDRAWALL")); 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("RE").map(String::as_str), Some("REGEN"));
assert_eq!(stored.get("REA").map(String::as_str), Some("REGENALL")); 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. // Existing entries must survive untouched.
assert_eq!(stored.get("L").map(String::as_str), Some("LINE")); assert_eq!(stored.get("L").map(String::as_str), Some("LINE"));
assert_eq!(stored.get("Z").map(String::as_str), Some("ZOOM")); 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")); 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. /// additions, not hypothetical earlier ones.
#[test] #[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 mut stored = map(&[("L", "LINE")]);
let changed = migrate_aliases(&mut stored, 1); let changed = migrate_aliases(&mut stored, 1);
assert!(changed); assert!(changed);
@ -386,5 +389,16 @@ mod tests {
assert_eq!(stored.get("RA").map(String::as_str), Some("REDRAWALL")); 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("RE").map(String::as_str), Some("REGEN"));
assert_eq!(stored.get("REA").map(String::as_str), Some("REGENALL")); 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"));
} }
} }

View file

@ -18,7 +18,7 @@ mod view;
// `DrawOrderRefCommand` lives in the `view` family file but is referenced by // `DrawOrderRefCommand` lives in the `view` family file but is referenced by
// path (`commands::DrawOrderRefCommand`) from `update.rs`, so re-export it at // path (`commands::DrawOrderRefCommand`) from `update.rs`, so re-export it at
// the module root to keep that path valid. // the module root to keep that path valid.
pub(crate) use view::DrawOrderRefCommand; pub(crate) use view::DrawOrderCommand;
impl OpenCADStudio { impl OpenCADStudio {
/// First `"{prefix}{n}"` (n ≥ 1) not already used by a block record in the /// First `"{prefix}{n}"` (n ≥ 1) not already used by a block record in the
@ -397,9 +397,11 @@ inventory::submit!(crate::command::CommandRegistration {
"CUILOAD", "CUILOAD",
// Save every open drawing. // Save every open drawing.
"SAVEALL", "SAVEALL",
// Draw-order: all text/dims to front or back. // Draw-order: all text/dims to front or back; hatches to back.
"TEXTTOFRONT", "TEXTTOFRONT",
"TEXTTOBACK", "TEXTTOBACK",
"HATCHTOBACK",
"HB",
// Criteria-based selection (same as QSELECT) + copy with picked base. // Criteria-based selection (same as QSELECT) + copy with picked base.
"FILTER", "FILTER",
"FI", "FI",

View file

@ -772,17 +772,20 @@ impl OpenCADStudio {
// the front (or back) of the draw order via the DRAWORDER machinery. // the front (or back) of the draw order via the DRAWORDER machinery.
"TEXTTOFRONT" | "TEXTTOBACK" => { "TEXTTOFRONT" | "TEXTTOBACK" => {
let to_front = cmd.ends_with("FRONT"); let to_front = cmd.ends_with("FRONT");
let block_handle = self.tabs[i].scene.current_layout_block_handle_pub();
let handles: rustc_hash::FxHashSet<acadrust::Handle> = self.tabs[i] let handles: rustc_hash::FxHashSet<acadrust::Handle> = self.tabs[i]
.scene .scene
.document .document
.entities() .entities()
.filter(|e| { .filter(|e| {
matches!( let c = e.common();
e, (c.owner_handle == block_handle || c.owner_handle.is_null())
acadrust::EntityType::Text(_) && matches!(
| acadrust::EntityType::MText(_) e,
| acadrust::EntityType::Dimension(_) acadrust::EntityType::Text(_)
) | acadrust::EntityType::MText(_)
| acadrust::EntityType::Dimension(_)
)
}) })
.map(|e| e.common().handle) .map(|e| e.common().handle)
.collect(); .collect();
@ -802,15 +805,129 @@ impl OpenCADStudio {
); );
} }
"DRAWORDER" => { // HATCHTOBACK ÔÇö move every hatch object in the active space to the back of the draw order.
use crate::command::SelectThenKeywordCommand; "HATCHTOBACK" => {
let has_sel = !self.tabs[i].scene.selected_entities().is_empty(); use acadrust::objects::{ObjectType, SortEntitiesTable};
let c = SelectThenKeywordCommand::new( let block_handle = self.tabs[i].scene.current_layout_block_handle_pub();
"DRAWORDER", let doc_ref = &self.tabs[i].scene.document;
"DRAWORDER [Front / Back] (Above/Under <handle> by typing):",
vec![("Front", "FRONT", None), ("Back", "BACK", None)], // 1. Single scan over objects to find existing SortEntitiesTable handle & overrides.
has_sel, let mut existing_table_handle = None;
let mut overrides: Option<rustc_hash::FxHashMap<u64, u64>> = 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<acadrust::Handle> = 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<acadrust::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
.map(|(h, _)| *h)
.collect();
let c = DrawOrderCommand::new(selected);
self.command_line.push_info(&c.prompt()); self.command_line.push_info(&c.prompt());
self.tabs[i].active_cmd = Some(Box::new(c)); self.tabs[i].active_cmd = Some(Box::new(c));
} }
@ -1083,38 +1200,159 @@ impl OpenCADStudio {
} }
} }
// ── Draw Order: interactive reference-object pick ────────────────────────── // ── Draw Order: interactive command ──────────────────────────
/// Moves a captured selection above or below a reference object the user #[derive(Clone, Copy, Debug, PartialEq, Eq)]
/// picks in the viewport. On pick it relaunches `DRAWORDER A|U <handle>` enum DrawOrderStep {
/// with the captured handles reinstalled as the selection, so the existing SelectObjects,
/// command path performs the actual reorder. ChooseVerb,
pub(crate) struct DrawOrderRefCommand { PickReference { above: bool },
to_move: Vec<acadrust::Handle>,
above: bool,
} }
impl DrawOrderRefCommand { /// Interactive front-end for the DRAWORDER command.
pub(crate) fn new(to_move: Vec<acadrust::Handle>, above: bool) -> Self { ///
Self { to_move, above } /// 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<acadrust::Handle>,
step: DrawOrderStep,
}
impl DrawOrderCommand {
pub(crate) fn new(selected: Vec<acadrust::Handle>) -> Self {
let step = if selected.is_empty() {
DrawOrderStep::SelectObjects
} else {
DrawOrderStep::ChooseVerb
};
Self { selected, step }
}
pub(crate) fn for_reference_pick(selected: Vec<acadrust::Handle>, above: bool) -> Self {
Self {
selected,
step: DrawOrderStep::PickReference { above },
}
} }
} }
impl CadCommand for DrawOrderRefCommand { impl CadCommand for DrawOrderCommand {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"DRAWORDER" "DRAWORDER"
} }
fn prompt(&self) -> String { fn prompt(&self) -> String {
if self.above { match self.step {
crate::t!("DRAWORDER Select reference object (move selection above):").into_owned() DrawOrderStep::SelectObjects => {
} else { crate::t!("DRAWORDER select objects, then press Enter:").into_owned()
crate::t!("DRAWORDER Select reference object (move selection under):").into_owned() }
DrawOrderStep::ChooseVerb => {
crate::t!("DRAWORDER [Above / Under / Front / Back] <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<crate::command::CmdOption> {
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<acadrust::Handle>) -> 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<crate::command::CmdResult> {
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 { fn needs_entity_pick(&self) -> bool {
true matches!(self.step, DrawOrderStep::PickReference { .. })
} }
fn on_entity_pick( fn on_entity_pick(
@ -1125,16 +1363,606 @@ impl CadCommand for DrawOrderRefCommand {
if handle.is_null() { if handle.is_null() {
return crate::command::CmdResult::NeedPoint; return crate::command::CmdResult::NeedPoint;
} }
let opt = if self.above { "A" } else { "U" }; if let DrawOrderStep::PickReference { above } = self.step {
let cmd = format!("DRAWORDER {} {:x}", opt, handle.value()); let opt = if above { "A" } else { "U" };
crate::command::CmdResult::Relaunch(cmd, std::mem::take(&mut self.to_move)) 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 { fn on_point(&mut self, _pt: glam::DVec3) -> crate::command::CmdResult {
crate::command::CmdResult::NeedPoint crate::command::CmdResult::NeedPoint
} }
}
fn on_enter(&mut self) -> crate::command::CmdResult { #[cfg(test)]
crate::command::CmdResult::Cancel 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<u64, u64> = 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<u64, u64> = 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<u64, u64> = 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<u64, u64> = 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<u64, u64> = 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<u64, u64> {
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);
} }
} }

View file

@ -4129,7 +4129,7 @@ impl OpenCADStudio {
.push_error(crate::t!("DRAWORDER: select entities first.").as_ref()); .push_error(crate::t!("DRAWORDER: select entities first.").as_ref());
} else { } else {
use crate::command::CadCommand; 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.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd)); self.tabs[i].active_cmd = Some(Box::new(cmd));
} }

View file

@ -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 { impl CadCommand for KeywordCommand {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
self.name self.name
@ -581,10 +609,7 @@ impl CadCommand for KeywordCommand {
// Consumed inputs that keep prompting return `Some(NeedPoint)` — // Consumed inputs that keep prompting return `Some(NeedPoint)` —
// `None` would hand the same text to the command a second time. // `None` would hand the same text to the command a second time.
None => { None => {
let up = t.to_uppercase(); let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t)
let Some((_, keyword, value_prompt)) = self.options.iter().find(|(label, k, _)| {
k.eq_ignore_ascii_case(&up) || label.eq_ignore_ascii_case(t)
})
else { else {
// Unknown verb — keep prompting rather than dispatch garbage. // Unknown verb — keep prompting rather than dispatch garbage.
return Some(CmdResult::NeedPoint); return Some(CmdResult::NeedPoint);
@ -783,11 +808,18 @@ impl CadCommand for SelectThenKeywordCommand {
return None; return None;
} }
match self.pending { 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 => { None => {
let up = t.to_uppercase(); let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t)
let Some((_, keyword, value_prompt)) =
self.options.iter().find(|(_, k, _)| k.eq_ignore_ascii_case(&up))
else { else {
// Unknown verb — consumed, keep prompting (`None` would // Unknown verb — consumed, keep prompting (`None` would
// feed the same text to the command a second time). // feed the same text to the command a second time).
@ -798,7 +830,16 @@ impl CadCommand for SelectThenKeywordCommand {
self.pending = Some((keyword, vp)); self.pending = Some((keyword, vp));
Some(CmdResult::NeedPoint) 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),
))
}
}
} }
} }
} }

View file

@ -3070,6 +3070,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"those objects" => Some(("modal", "those-objects")), "those objects" => Some(("modal", "those-objects")),
"units" => Some(("common", "units")), "units" => Some(("common", "units")),
"{cmd}: cannot create folder ({e})." => Some(("file", "cmd-cannot-create-folder-e")), "{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}: no text or dimension objects." => Some(("view", "cmd-no-text-or-dimension-objects")),
"{cmd}: not yet implemented." => Some(("view", "cmd-not-yet-implemented")), "{cmd}: not yet implemented." => Some(("view", "cmd-not-yet-implemented")),
"{cmd}: packaged {copied} file(s) into {}" => Some(("file", "cmd-packaged-copied-file-s-into")), "{cmd}: packaged {copied} file(s) into {}" => Some(("file", "cmd-packaged-copied-file-s-into")),

View file

@ -2399,8 +2399,6 @@ impl Scene {
let mut bits = 0; let mut bits = 0;
if is_outer { if is_outer {
bits |= acadrust::entities::hatch::BoundaryPathFlags::OUTERMOST.bits(); bits |= acadrust::entities::hatch::BoundaryPathFlags::OUTERMOST.bits();
}
if !handles.is_empty() {
bits |= acadrust::entities::hatch::BoundaryPathFlags::EXTERNAL.bits(); bits |= acadrust::entities::hatch::BoundaryPathFlags::EXTERNAL.bits();
} }
let mut path = BoundaryPath::with_flags( let mut path = BoundaryPath::with_flags(

View file

@ -5458,6 +5458,11 @@ impl Scene {
.collect() .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. /// Per-entity stable draw-order depth, keyed by entity handle value.
/// A full build assigns sparse labels in effective draw order. Incremental /// A full build assigns sparse labels in effective draw order. Incremental
/// Add/Remove then changes only the named handle: existing siblings retain /// Add/Remove then changes only the named handle: existing siblings retain

View file

@ -82,7 +82,7 @@ fn block_internal_hatch_reaches_export() {
.expect("wrap hatch into a block + insert"); .expect("wrap hatch into a block + insert");
scene.populate_hatches_from_document(); 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(); let blue = hatches.iter().filter(|m| is_blue(&m.color)).count();
assert!( assert!(
blue > 0, 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.add_entity(EntityType::Hatch(ansi31_stored(0.5, 10.0)));
scene.populate_hatches_from_document(); scene.populate_hatches_from_document();
let hatches = scene.paper_canvas_hatches(); let hatches = scene.paper_plot_hatches();
let m = hatches let m = hatches
.iter() .iter()
.find(|m| matches!(m.pattern, HatchPattern::Pattern(_))) .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.add_entity(EntityType::Hatch(ansi31_stored_at(0.3, 1.0, 4000.0, 0.0)));
scene.populate_hatches_from_document(); scene.populate_hatches_from_document();
let hatches = scene.paper_canvas_hatches(); let hatches = scene.paper_plot_hatches();
let m = hatches let m = hatches
.iter() .iter()
.find(|m| matches!(m.pattern, HatchPattern::Pattern(_))) .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.add_entity(EntityType::Hatch(hatch));
scene.populate_hatches_from_document(); scene.populate_hatches_from_document();
let hatches = scene.paper_canvas_hatches(); let hatches = scene.paper_plot_hatches();
let m = hatches.first().expect("hatch present"); let m = hatches.first().expect("hatch present");
// The boundary must not extend into the 200x50 TEXTBOX rectangle. // The boundary must not extend into the 200x50 TEXTBOX rectangle.
let max_x = m let max_x = m
@ -244,8 +244,12 @@ fn app_created_hatch_roundtrips_catalog_spacing() {
world_origin: [0.0, 0.0], world_origin: [0.0, 0.0],
boundary: Arc::new(boundary), boundary: Arc::new(boundary),
boundary_wcs: None, boundary_wcs: None,
fill_plane: None,
fill_plane_boundary: None,
boundary_exterior: None, boundary_exterior: None,
boundary_sources: None, boundary_sources: None,
boundary_paths: None,
style: acadrust::entities::HatchStyleType::Normal,
pattern: entry.gpu.clone(), pattern: entry.gpu.clone(),
name: "ANSI31".into(), name: "ANSI31".into(),
color: [0.75, 0.75, 0.75, 0.85], 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.add_hatch(model, None, None);
scene.populate_hatches_from_document(); scene.populate_hatches_from_document();
let hatches = scene.paper_canvas_hatches(); let hatches = scene.paper_plot_hatches();
let m = hatches let m = hatches
.iter() .iter()
.find(|m| matches!(m.pattern, HatchPattern::Pattern(_))) .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], world_origin: [0.0, 0.0],
boundary: Arc::new(boundary_f32), boundary: Arc::new(boundary_f32),
boundary_wcs: Some(Arc::new(wcs)), boundary_wcs: Some(Arc::new(wcs)),
fill_plane: None,
fill_plane_boundary: None,
boundary_exterior: None, boundary_exterior: None,
boundary_sources: None, boundary_sources: None,
boundary_paths: None,
style: acadrust::entities::HatchStyleType::Normal,
pattern: HatchPattern::Solid, pattern: HatchPattern::Solid,
name: "SOLID".into(), name: "SOLID".into(),
color: [0.45, 0.45, 0.45, 0.60], color: [0.45, 0.45, 0.45, 0.60],