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
BH, *HATCH
HE, *HATCHEDIT
HB, *HATCHTOBACK
BO, *BOUNDARY
GD, *GRADIENT
XL, *XLINE

View file

@ -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__":

View file

@ -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__":

View file

@ -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__":

View file

@ -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__":

View file

@ -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__":

View file

@ -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__»:

View file

@ -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__":

View file

@ -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__ » :

View file

@ -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__ व्यूपोर्ट:

View file

@ -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:

View file

@ -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__":

View file

@ -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__ 個のビューポート:

View file

@ -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__ 뷰포트:

View file

@ -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__":

View file

@ -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__”:

View file

@ -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__”:

View file

@ -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__»:

View file

@ -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ı:

View file

@ -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__ 个):

View file

@ -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__ 個):

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
/// 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"));
}
}

View file

@ -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",

View file

@ -772,12 +772,15 @@ 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<acadrust::Handle> = self.tabs[i]
.scene
.document
.entities()
.filter(|e| {
matches!(
let c = e.common();
(c.owner_handle == block_handle || c.owner_handle.is_null())
&& matches!(
e,
acadrust::EntityType::Text(_)
| acadrust::EntityType::MText(_)
@ -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 <handle> 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<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.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
/// picks in the viewport. On pick it relaunches `DRAWORDER A|U <handle>`
/// with the captured handles reinstalled as the selection, so the existing
/// command path performs the actual reorder.
pub(crate) struct DrawOrderRefCommand {
to_move: Vec<acadrust::Handle>,
above: bool,
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DrawOrderStep {
SelectObjects,
ChooseVerb,
PickReference { above: bool },
}
impl DrawOrderRefCommand {
pub(crate) fn new(to_move: Vec<acadrust::Handle>, 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<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 {
"DRAWORDER"
}
fn prompt(&self) -> String {
if self.above {
match self.step {
DrawOrderStep::SelectObjects => {
crate::t!("DRAWORDER select objects, then press Enter:").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()
} else {
}
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 {
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" };
if let DrawOrderStep::PickReference { above } = self.step {
let opt = if above { "A" } else { "U" };
let cmd = format!("DRAWORDER {} {:x}", opt, handle.value());
crate::command::CmdResult::Relaunch(cmd, std::mem::take(&mut self.to_move))
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<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());
} 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));
}

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 {
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),
))
}
}
}
}
}

View file

@ -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")),

View file

@ -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(

View file

@ -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

View file

@ -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],