From 9b136d6c19ed699b72b6429dc45257bdd7fa1ec9 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 17:23:59 +0200 Subject: [PATCH] Draw order: DRAWORDER Above/Under objects, TEXTTOFRONT, HATCHTOBACK, SORTENTSTABLE read (part of #439) (#446) Squash of PR #446 by @vangel76. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/store.rs | 108 +++++++++ crates/engine/src/cmd/draworder.rs | 362 +++++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/modify.rs | 36 +-- crates/engine/tests/draworder.rs | 147 ++++++++++++ crates/io/src/dxf_read.rs | 59 +++++ crates/io/tests/sortents.rs | 48 ++++ crates/ui-egui/src/i18n/uk.tsv | 8 + 8 files changed, 736 insertions(+), 34 deletions(-) create mode 100644 crates/engine/src/cmd/draworder.rs create mode 100644 crates/engine/tests/draworder.rs create mode 100644 crates/io/tests/sortents.rs diff --git a/crates/doc/src/store.rs b/crates/doc/src/store.rs index 5540e68..3e9b440 100644 --- a/crates/doc/src/store.rs +++ b/crates/doc/src/store.rs @@ -189,6 +189,70 @@ impl EntityStore { None => false, } } + /// Move `h` directly above (`above`) or under `anchor` in the draw order. False (and nothing + /// moves) when either is absent or they are the same object. + pub fn move_next_to(&mut self, h: Handle, anchor: Handle, above: bool) -> bool { + if h == anchor || !self.contains(anchor) { + return false; + } + let Some(e) = self.remove(h) else { return false }; + let at = self.chunk_id(anchor).and_then(|id| Some((id, self.chunk_pos(id)?))); + let Some((id, pos)) = at else { + // Unreachable in practice (the anchor was present); keep the object on top. + self.push(Arc::unwrap_or_clone(e)); + return false; + }; + let Some(c) = self.chunks.get_mut(pos) else { + // Unreachable in practice too; never lose the object. + self.push(Arc::unwrap_or_clone(e)); + return false; + }; + let chunk = Arc::make_mut(c); + let i = chunk.items.iter().position(|x| x.handle == anchor).unwrap_or(chunk.items.len()); + let i = if above { (i + 1).min(chunk.items.len()) } else { i }; + chunk.items.insert(i, e); + let split = chunk.items.len() > 2 * CHUNK_MAX; + self.set_index(h, Some(id)); + self.len += 1; + if split { + self.split_chunk(pos); + } + true + } + /// Split an oversized chunk in two, keeping the order. + fn split_chunk(&mut self, pos: usize) { + let mut fresh = self.new_chunk(); + let Some(c) = self.chunks.get_mut(pos) else { return }; + let chunk = Arc::make_mut(c); + let tail = chunk.items.split_off(chunk.items.len() / 2); + let new = Arc::make_mut(&mut fresh); + new.items = tail; + let (id, moved): (u32, Vec) = (new.id, new.items.iter().map(|e| e.handle).collect()); + self.chunks.insert(pos + 1, fresh); + for h in moved { + self.set_index(h, Some(id)); + } + } + /// Reorder the whole store by a key (stable: equal keys keep their order). + pub fn sort_by_key(&mut self, mut key: impl FnMut(&Entity) -> K) { + let mut items: Vec> = self.iter().cloned().collect(); + items.sort_by_cached_key(|e| key(e)); + *self = EntityStore::default(); + for e in items { + let needs_new = self.chunks.last().is_none_or(|c| c.items.len() >= CHUNK_MAX); + if needs_new { + let c = self.new_chunk(); + self.chunks.push(c); + } + let h = e.handle; + let Some(last) = self.chunks.last_mut() else { return }; + let chunk = Arc::make_mut(last); + chunk.items.push(e); + let id = chunk.id; + self.set_index(h, Some(id)); + self.len += 1; + } + } /// True when `other` shares every chunk with `self` (same draw order, same entities). Holding /// a clone of a store makes this exact: any edit to the original copies the touched chunk. pub fn same_as(&self, other: &EntityStore) -> bool { @@ -256,4 +320,48 @@ mod tests { assert_eq!(s.iter().next().map(|e| e.handle), Some(Handle(5))); assert_eq!(s.len(), 5); } + + fn order(s: &EntityStore) -> Vec { + s.iter().map(|e| e.handle.0).collect() + } + + #[test] + fn move_next_to_and_sort() { + let mut s = EntityStore::new(); + for h in 1..=5 { + s.push(ent(h)); + } + assert!(s.move_next_to(Handle(1), Handle(3), true)); + assert_eq!(order(&s), [2, 3, 1, 4, 5]); + assert!(s.move_next_to(Handle(5), Handle(2), false)); + assert_eq!(order(&s), [5, 2, 3, 1, 4]); + assert!(!s.move_next_to(Handle(5), Handle(5), true)); + assert!(!s.move_next_to(Handle(5), Handle(99), true)); + assert!(!s.move_next_to(Handle(99), Handle(5), true)); + assert_eq!(order(&s), [5, 2, 3, 1, 4]); + assert_eq!(s.len(), 5); + s.sort_by_key(|e| e.handle.0 % 2); + assert_eq!(order(&s), [2, 4, 5, 3, 1]); + assert!(s.get(Handle(3)).is_some()); + } + + #[test] + fn move_next_to_splits_big_chunks() { + let mut s = EntityStore::new(); + for h in 1..=3000 { + s.push(ent(h)); + } + // Pile everything into the first chunk, under handle 1. + for h in 1500..=3000 { + assert!(s.move_next_to(Handle(h), Handle(1), true)); + } + assert_eq!(s.len(), 3000); + assert!(s.chunks.iter().all(|c| c.items.len() <= 2 * CHUNK_MAX)); + let o = order(&s); + assert_eq!(o.first(), Some(&1)); + assert_eq!(o.get(1), Some(&3000)); + for h in 1..=3000 { + assert_eq!(s.get(Handle(h)).map(|e| e.handle.0), Some(h)); + } + } } diff --git a/crates/engine/src/cmd/draworder.rs b/crates/engine/src/cmd/draworder.rs new file mode 100644 index 0000000..fab6733 --- /dev/null +++ b/crates/engine/src/cmd/draworder.rs @@ -0,0 +1,362 @@ +//! Draw order: DRAWORDER (Above objects / Under objects / Front / Back), TEXTTOFRONT and +//! HATCHTOBACK. The order is the order of a space's entity store, which DXF files keep as the +//! order of their entity records (`cadcraft-io` also applies a SORTENTSTABLE when it reads one). + +use std::collections::{HashMap, HashSet}; + +use cadcraft_doc::{EntityKind, Handle}; +use serde_json::{Value, json}; + +use super::machines::{SelOutcome, SelectPhase, SelectRun}; +use super::*; +use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("draworder", "Draw Order", run_draworder) + .alias(&["dr"]) + .params("{handles?, order?: \"front\"|\"back\"|\"above\"|\"under\" (default back), reference?: [hex] (above/under)} → {moved}") + .interactive(|_| Ok(Box::new(DrawOrderM::new(None)))), + CommandSpec::new("draworder.front", "Bring to Front", run_front) + .menu(&["Tools", "Draw Order", "Bring to Front"]) + .params("{handles?}") + .interactive(|_| Ok(Box::new(SelectRun::new("draworder.front", "DRAWORDER")))), + CommandSpec::new("draworder.back", "Send to Back", run_back) + .menu(&["Tools", "Draw Order", "Send to Back"]) + .params("{handles?}") + .interactive(|_| Ok(Box::new(SelectRun::new("draworder.back", "DRAWORDER")))), + CommandSpec::new("draworder.above", "Bring Above Objects", run_above) + .menu(&["Tools", "Draw Order", "Bring Above Objects"]) + .params("{handles?, reference: [hex]}") + .interactive(|_| Ok(Box::new(DrawOrderM::new(Some(Order::Above))))), + CommandSpec::new("draworder.under", "Send Under Objects", run_under) + .menu(&["Tools", "Draw Order", "Send Under Objects"]) + .params("{handles?, reference: [hex]}") + .interactive(|_| Ok(Box::new(DrawOrderM::new(Some(Order::Under))))), + CommandSpec::new("texttofront", "Bring Annotations to Front", run_texttofront) + .params("{objects?: \"text\"|\"dimensions\"|\"leaders\"|\"all\" (default all)} → {moved}") + .interactive(|_| Ok(Box::new(TextToFrontM))), + CommandSpec::new("texttofront.text", "Text Objects Only", |s, _| annotations_to_front(s, Annot::Text)) + .menu(&["Tools", "Draw Order", "Bring Annotations to Front", "Text Objects Only"]) + .params("{} → {moved}"), + CommandSpec::new("texttofront.dimensions", "Dimension Objects Only", |s, _| annotations_to_front(s, Annot::Dimensions)) + .menu(&["Tools", "Draw Order", "Bring Annotations to Front", "Dimension Objects Only"]) + .params("{} → {moved}"), + CommandSpec::new("texttofront.leaders", "Leaders Only", |s, _| annotations_to_front(s, Annot::Leaders)) + .menu(&["Tools", "Draw Order", "Bring Annotations to Front", "Leaders Only"]) + .params("{} → {moved}"), + CommandSpec::new("texttofront.all", "All Annotation Objects", |s, _| annotations_to_front(s, Annot::All)) + .menu(&["Tools", "Draw Order", "Bring Annotations to Front", "All Annotation Objects"]) + .params("{} → {moved}"), + CommandSpec::new("hatchtoback", "Send Hatches to Back", run_hatchtoback) + .menu(&["Tools", "Draw Order", "Send Hatches to Back"]) + .params("{} → {moved}"), + ] +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Order { + Front, + Back, + Above, + Under, +} + +/// Move `hs` in the current space's draw order, keeping their order among themselves. Above and +/// Under place them right over the topmost reference object or right under the lowest one. +/// Returns how many objects moved. +fn reorder(s: &mut Session, hs: &[Handle], order: Order, refs: &[Handle]) -> Result { + let space = s.space(); + let d = s.doc_mut()?; + let Some(st) = d.space_mut(&space) else { return Ok(0) }; + let index: HashMap = st.handles().into_iter().enumerate().map(|(i, h)| (h, i)).collect(); + let mut moving: Vec<(usize, Handle)> = hs.iter().filter_map(|h| index.get(h).map(|i| (*i, *h))).collect(); + moving.sort_unstable(); + moving.dedup(); + let set: HashSet = moving.iter().map(|m| m.1).collect(); + let refs: Vec<(usize, Handle)> = refs.iter().filter(|h| !set.contains(h)).filter_map(|h| index.get(h).map(|i| (*i, *h))).collect(); + match order { + Order::Front => { + for (_, h) in &moving { + st.bring_to_front(*h); + } + } + Order::Back => { + for (_, h) in moving.iter().rev() { + st.send_to_back(*h); + } + } + Order::Above => { + let Some(&(_, mut cur)) = refs.iter().max() else { return Err(EngineError::Other("no reference objects".into())) }; + for (_, h) in &moving { + st.move_next_to(*h, cur, true); + cur = *h; + } + } + Order::Under => { + let Some(&(_, anchor)) = refs.iter().min() else { return Err(EngineError::Other("no reference objects".into())) }; + for (_, h) in &moving { + st.move_next_to(*h, anchor, false); + } + } + } + Ok(moving.len()) +} + +fn parse_order(p: &Value) -> Result { + match p.get("order") { + None | Some(Value::Null) => Ok(Order::Back), + Some(v) => match v.as_str().map(|t| t.trim().to_ascii_lowercase()).as_deref() { + Some("front") => Ok(Order::Front), + Some("back") => Ok(Order::Back), + Some("above") => Ok(Order::Above), + Some("under") => Ok(Order::Under), + _ => Err(bad("draworder", format!("`order` must be \"front\", \"back\", \"above\" or \"under\", got {v}"))), + }, + } +} + +fn reference(s: &Session, cmd: &str, p: &Value) -> Result> { + match p.get("reference") { + None | Some(Value::Null) => Err(bad(cmd, "`reference` (object handles) is required")), + Some(v) => targets(s, &json!({ "handles": v })), + } +} + +fn run_draworder(s: &mut Session, p: &Value) -> Result { + let order = parse_order(p)?; + let hs = targets(s, p)?; + let refs = if matches!(order, Order::Above | Order::Under) { reference(s, "draworder", p)? } else { Vec::new() }; + let n = reorder(s, &hs, order, &refs)?; + Ok(json!({ "moved": n })) +} + +fn run_front(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + reorder(s, &hs, Order::Front, &[])?; + ok() +} + +fn run_back(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + reorder(s, &hs, Order::Back, &[])?; + ok() +} + +fn run_above(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let refs = reference(s, "draworder.above", p)?; + let n = reorder(s, &hs, Order::Above, &refs)?; + Ok(json!({ "moved": n })) +} + +fn run_under(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let refs = reference(s, "draworder.under", p)?; + let n = reorder(s, &hs, Order::Under, &refs)?; + Ok(json!({ "moved": n })) +} + +/// TEXTTOFRONT's object classes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Annot { + Text, + Dimensions, + Leaders, + All, +} + +impl Annot { + fn matches(self, k: &EntityKind) -> bool { + let text = matches!(k, EntityKind::Text(_) | EntityKind::MText(_)); + let dim = matches!(k, EntityKind::Dimension(_)); + let leader = matches!(k, EntityKind::Leader(_) | EntityKind::MLeader(_)); + match self { + Annot::Text => text, + Annot::Dimensions => dim, + Annot::Leaders => leader, + Annot::All => text || dim || leader, + } + } +} + +/// Every object of the current space that `keep` accepts, in draw order. +fn of_kind(s: &Session, keep: impl Fn(&EntityKind) -> bool) -> Result> { + let d = s.doc()?; + Ok(d.space(&s.space()).map(|st| st.iter().filter(|e| keep(&e.kind)).map(|e| e.handle).collect()).unwrap_or_default()) +} + +fn annotations_to_front(s: &mut Session, which: Annot) -> Result { + let hs = of_kind(s, |k| which.matches(k))?; + let n = reorder(s, &hs, Order::Front, &[])?; + Ok(json!({ "moved": n, "message": format!("{n} object(s) brought to front.") })) +} + +fn run_texttofront(s: &mut Session, p: &Value) -> Result { + let which = match p.get("objects") { + None | Some(Value::Null) => Annot::All, + Some(v) => match v.as_str().map(|t| t.trim().to_ascii_lowercase()).as_deref() { + Some("text") => Annot::Text, + Some("dimensions") => Annot::Dimensions, + Some("leaders") => Annot::Leaders, + Some("all") => Annot::All, + _ => return Err(bad("texttofront", format!("`objects` must be \"text\", \"dimensions\", \"leaders\" or \"all\", got {v}"))), + }, + }; + annotations_to_front(s, which) +} + +fn run_hatchtoback(s: &mut Session, _p: &Value) -> Result { + let hs = of_kind(s, |k| matches!(k, EntityKind::Hatch(_)))?; + let n = reorder(s, &hs, Order::Back, &[])?; + Ok(json!({ "moved": n, "message": format!("{n} hatch object(s) sent to back.") })) +} + +/// TEXTTOFRONT typed: "Bring to front [Text/Dimensions/Leaders/All] :". +struct TextToFrontM; + +impl Interactive for TextToFrontM { + fn name(&self) -> &'static str { + "TEXTTOFRONT" + } + fn prompt(&self, _s: &Session) -> Prompt { + Prompt::new("Bring to front", Accept::TEXT).kw(&["Text", "Dimensions", "Leaders", "All"]).default("All") + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let which = match &i { + Input::Enter => Annot::All, + Input::Keyword(k) | Input::Text(k) => match k.trim().to_ascii_lowercase().as_str() { + "text" | "t" => Annot::Text, + "dimensions" | "d" => Annot::Dimensions, + "leaders" | "l" => Annot::Leaders, + "all" | "a" => Annot::All, + _ => { + s.echo("Invalid option keyword."); + return Ok(Step::Continue); + } + }, + _ => return Ok(Step::Continue), + }; + let r = annotations_to_front(s, which)?; + if let Some(m) = r.get("message").and_then(Value::as_str) { + s.echo(m.to_string()); + } + Ok(Step::Done) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Stage { + Select, + Option, + Reference, +} + +/// DRAWORDER typed: "Select objects:", then "Enter object ordering option [Above objects/Under +/// objects/Front/Back] :", and for Above/Under "Select reference objects:". The Bring Above +/// / Send Under menu items start with the option chosen. +struct DrawOrderM { + stage: Stage, + preset: Option, + order: Order, + sel: SelectPhase, + objs: Vec, +} + +impl DrawOrderM { + fn new(preset: Option) -> Self { + DrawOrderM { stage: Stage::Select, preset, order: preset.unwrap_or(Order::Back), sel: SelectPhase::default(), objs: Vec::new() } + } + /// The objects are chosen: ask for the option, or go on with the preset one. + fn selected(&mut self, s: &mut Session, hs: Vec) -> Result { + self.objs = hs; + match self.preset { + Some(o) => self.chosen(s, o), + None => { + self.stage = Stage::Option; + Ok(Step::Continue) + } + } + } + fn chosen(&mut self, s: &mut Session, o: Order) -> Result { + self.order = o; + match o { + Order::Front | Order::Back => self.finish(s, &[]), + Order::Above | Order::Under => { + self.stage = Stage::Reference; + self.sel = SelectPhase::default(); + Ok(Step::Continue) + } + } + } + fn finish(&mut self, s: &mut Session, refs: &[Handle]) -> Result { + let objs = std::mem::take(&mut self.objs); + match reorder(s, &objs, self.order, refs) { + Ok(_) => {} + Err(e) => s.echo(e.to_string()), + } + s.set_selection(Vec::new()); + Ok(Step::Done) + } +} + +impl Interactive for DrawOrderM { + fn name(&self) -> &'static str { + "DRAWORDER" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = SelectPhase::begin(s); + if self.sel.done { + let hs = self.sel.picked.clone(); + return self.selected(s, hs); + } + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.stage { + Stage::Select => self.sel.prompt(), + Stage::Option => { + Prompt::new("Enter object ordering option", Accept::TEXT).kw(&["Above objects", "Under objects", "Front", "Back"]).default("Back") + } + Stage::Reference if self.sel.removing => self.sel.prompt(), + Stage::Reference => Prompt::new("Select reference objects", Accept::SELECT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match self.stage { + Stage::Select => match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Done), + SelOutcome::Done(hs) => { + s.set_selection(Vec::new()); + self.selected(s, hs) + } + }, + Stage::Option => { + let o = match &i { + Input::Enter => Order::Back, + Input::Keyword(k) | Input::Text(k) => match k.trim().to_ascii_lowercase().as_str() { + "above objects" | "a" | "above" => Order::Above, + "under objects" | "u" | "under" => Order::Under, + "front" | "f" => Order::Front, + "back" | "b" => Order::Back, + _ => { + s.echo("Invalid option keyword."); + return Ok(Step::Continue); + } + }, + _ => return Ok(Step::Continue), + }; + self.chosen(s, o) + } + Stage::Reference => match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => { + s.set_selection(Vec::new()); + Ok(Step::Done) + } + SelOutcome::Done(refs) => self.finish(s, &refs), + }, + } + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 7f5c6be..1b10e44 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -9,6 +9,7 @@ pub mod clipboard; pub mod constraints; mod draw; mod draw2; +mod draworder; mod edit; mod exchange; pub mod file; @@ -172,6 +173,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(hatch::specs()); v.extend(blocks::specs()); v.extend(modify::specs()); + v.extend(draworder::specs()); v.extend(array::specs()); v.extend(modify2::specs()); v.extend(groups::specs()); diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index e78eb2e..660d443 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1,5 +1,5 @@ //! Modify commands: ERASE, MOVE, COPY, ROTATE, SCALE, MIRROR, OFFSET, TRIM, EXTEND, FILLET, -//! CHAMFER, EXPLODE, STRETCH, DRAWORDER, BREAK, JOIN (arrays: `array`). +//! CHAMFER, EXPLODE, STRETCH, BREAK, JOIN (arrays: `array`; draw order: `draworder`). use cadcraft_doc::{Entity, EntityKind, Handle, Prim}; use cadcraft_geom::{ @@ -8,7 +8,7 @@ use cadcraft_geom::{ use serde_json::{Value, json}; use super::helpers::*; -use super::machines::{SelOutcome, SelectPhase, SelectRun, number}; +use super::machines::{SelOutcome, SelectPhase, number}; use super::trimextend::{self, TrimM}; use super::*; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; @@ -82,14 +82,6 @@ pub fn specs() -> Vec { .alias(&["x"]) .params("{handles?}") .interactive(|_| Ok(Box::new(SelectThen::new(Op::Explode)))), - CommandSpec::new("draworder.front", "Bring to Front", run_front) - .menu(&["Tools", "Draw Order", "Bring to Front"]) - .params("{handles?}") - .interactive(|_| Ok(Box::new(SelectRun::new("draworder.front", "DRAWORDER")))), - CommandSpec::new("draworder.back", "Send to Back", run_back) - .menu(&["Tools", "Draw Order", "Send to Back"]) - .params("{handles?}") - .interactive(|_| Ok(Box::new(SelectRun::new("draworder.back", "DRAWORDER")))), CommandSpec::new("break", "Break", run_break) .menu(&["Modify", "Break"]) .alias(&["br"]) @@ -1290,30 +1282,6 @@ fn run_explode(s: &mut Session, p: &Value) -> Result { Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) } -fn run_front(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - let space = s.space(); - let d = s.doc_mut()?; - if let Some(st) = d.space_mut(&space) { - for h in hs { - st.bring_to_front(h); - } - } - ok() -} - -fn run_back(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - let space = s.space(); - let d = s.doc_mut()?; - if let Some(st) = d.space_mut(&space) { - for h in hs.into_iter().rev() { - st.send_to_back(h); - } - } - ok() -} - /// Break `h` between two points (or at one point when p1 == p2). pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Result> { let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; diff --git a/crates/engine/tests/draworder.rs b/crates/engine/tests/draworder.rs new file mode 100644 index 0000000..6dec0bc --- /dev/null +++ b/crates/engine/tests/draworder.rs @@ -0,0 +1,147 @@ +//! Draw order (#439): DRAWORDER's Above objects / Under objects / Front / Back, typed and as JSON, +//! TEXTTOFRONT and HATCHTOBACK. + +use cadcraft_engine::doc::{EntityKind, Handle}; +use cadcraft_engine::{Input, Session}; +use serde_json::json; + +/// A session with five lines; their handles in draw order. +fn five_lines() -> (Session, Vec) { + let mut s = Session::new(); + for y in 0..5 { + s.execute("line", &json!({ "points": [[0, y], [10, y]] })).unwrap(); + } + let hs = order(&s); + (s, hs) +} + +fn order(s: &Session) -> Vec { + s.doc().unwrap().model.iter().map(|e| e.handle).collect() +} + +fn hex(hs: &[Handle]) -> Vec { + hs.iter().map(|h| h.hex()).collect() +} + +#[test] +fn draworder_json_keeps_relative_order() { + let (mut s, h) = five_lines(); + // Front/back keep the moved objects' order among themselves, whatever order they are given in. + s.execute("draworder", &json!({ "handles": hex(&[h[2], h[0]]), "order": "front" })).unwrap(); + assert_eq!(order(&s), [h[1], h[3], h[4], h[0], h[2]]); + s.execute("draworder", &json!({ "handles": hex(&[h[2], h[0]]) })).unwrap(); + assert_eq!(order(&s), [h[0], h[2], h[1], h[3], h[4]], "back is the default"); + // Above: right over the topmost reference object. + let r = s.execute("draworder", &json!({ "handles": hex(&[h[0], h[2]]), "order": "above", "reference": hex(&[h[1], h[3]]) })).unwrap(); + assert_eq!(r["moved"], 2); + assert_eq!(order(&s), [h[1], h[3], h[0], h[2], h[4]]); + // Under: right under the lowest reference object; a moved object among the references is skipped. + s.execute("draworder.under", &json!({ "handles": hex(&[h[4]]), "reference": hex(&[h[3], h[0], h[4]]) })).unwrap(); + assert_eq!(order(&s), [h[1], h[4], h[3], h[0], h[2]]); + s.execute("draworder.above", &json!({ "handles": hex(&[h[1]]), "reference": hex(&[h[2]]) })).unwrap(); + assert_eq!(order(&s), [h[4], h[3], h[0], h[2], h[1]]); + // Bad options, missing references and unknown handles are refused without changes. + let before = order(&s); + assert!(s.execute("draworder", &json!({ "handles": hex(&[h[0]]), "order": "sideways" })).is_err()); + assert!(s.execute("draworder", &json!({ "handles": hex(&[h[0]]), "order": 3 })).is_err()); + assert!(s.execute("draworder", &json!({ "handles": hex(&[h[0]]), "order": "above" })).is_err()); + assert!(s.execute("draworder.above", &json!({ "handles": hex(&[h[0]]), "reference": "1A" })).is_err()); + assert!(s.execute("draworder.under", &json!({ "handles": hex(&[h[0]]), "reference": ["FFFFF"] })).is_err()); + assert!(s.execute("draworder.under", &json!({ "handles": hex(&[h[0]]), "reference": hex(&[h[0]]) })).is_err()); + assert_eq!(order(&s), before); + // The old menu commands still work. + s.execute("draworder.front", &json!({ "handles": hex(&[h[4]]) })).unwrap(); + assert_eq!(order(&s).last(), Some(&h[4])); + s.execute("draworder.back", &json!({ "handles": hex(&[h[4]]) })).unwrap(); + assert_eq!(order(&s).first(), Some(&h[4])); + assert!(s.running.is_none(), "JSON calls never prompt"); +} + +#[test] +fn draworder_typed_options_and_undo() { + let (mut s, h) = five_lines(); + let undo = s.state().unwrap().undo.len(); + s.cmdline("draworder").unwrap(); + assert!(s.prompt_text().contains("Select objects"), "{}", s.prompt_text()); + s.input(Input::Pick(vec![h[0]])).unwrap(); + s.cmdline("").unwrap(); + assert!(s.prompt_text().contains("[Above objects/Under objects/Front/Back] "), "{}", s.prompt_text()); + s.cmdline("a").unwrap(); + assert!(s.prompt_text().contains("Select reference objects"), "{}", s.prompt_text()); + s.input(Input::Pick(vec![h[2]])).unwrap(); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert_eq!(order(&s), [h[1], h[2], h[0], h[3], h[4]]); + assert_eq!(s.state().unwrap().undo.len(), undo + 1, "one undo step"); + assert!(s.selection().is_empty(), "picked objects don't stay selected"); + // Enter takes the default, Back. + s.cmdline("dr").unwrap(); + s.input(Input::Pick(vec![h[4]])).unwrap(); + s.cmdline("").unwrap(); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert_eq!(order(&s).first(), Some(&h[4])); + // Front by keyword; with a pickfirst selection the option is asked at once. + s.set_selection(vec![h[4]]); + s.cmdline("draworder").unwrap(); + assert!(s.prompt_text().contains("ordering option"), "{}", s.prompt_text()); + s.cmdline("f").unwrap(); + assert_eq!(order(&s).last(), Some(&h[4])); + // The Send Under Objects menu item goes from the objects straight to the references. + s.start("draworder.under").unwrap(); + s.input(Input::Pick(vec![h[4]])).unwrap(); + s.cmdline("").unwrap(); + assert!(s.prompt_text().contains("Select reference objects"), "{}", s.prompt_text()); + s.input(Input::Pick(vec![h[1]])).unwrap(); + s.cmdline("").unwrap(); + assert_eq!(order(&s).first(), Some(&h[4])); + // An empty reference set ends without changes. + let before = order(&s); + s.cmdline("draworder").unwrap(); + s.input(Input::Pick(vec![h[0]])).unwrap(); + s.cmdline("").unwrap(); + s.cmdline("u").unwrap(); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert_eq!(order(&s), before); + s.execute("u", &json!({})).unwrap(); + s.execute("u", &json!({})).unwrap(); + s.execute("u", &json!({})).unwrap(); + s.execute("u", &json!({})).unwrap(); + assert_eq!(order(&s), h, "undo restores the order"); +} + +#[test] +fn texttofront_and_hatchtoback() { + let mut s = Session::new(); + s.execute("text", &json!({ "at": [0, 0], "height": 2, "text": "A" })).unwrap(); + s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [10, 0], "at": [5, 5] })).unwrap(); + s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); + s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "SOLID" })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [10, 10]] })).unwrap(); + let kinds = |s: &Session| s.doc().unwrap().model.iter().map(|e| e.kind.type_name()).collect::>(); + // HATCH already puts its hatch at the back; move it to the front first. + let hatch = s.doc().unwrap().model.iter().find(|e| matches!(e.kind, EntityKind::Hatch(_))).unwrap().handle; + s.execute("draworder.front", &json!({ "handles": [hatch.hex()] })).unwrap(); + assert_eq!(kinds(&s).last(), Some(&"Hatch")); + let r = s.execute("hatchtoback", &json!({})).unwrap(); + assert_eq!(r["moved"], 1); + assert_eq!(kinds(&s).first(), Some(&"Hatch")); + // Text only, then all annotation. + let r = s.execute("texttofront", &json!({ "objects": "text" })).unwrap(); + assert_eq!(r["moved"], 1); + assert_eq!(kinds(&s).last(), Some(&"Text")); + s.execute("texttofront.dimensions", &json!({})).unwrap(); + assert!(kinds(&s).last().is_some_and(|k| k.contains("Dimension")), "{:?}", kinds(&s)); + assert!(s.execute("texttofront", &json!({ "objects": "everything" })).is_err()); + // Typed: the option prompt, Enter = All. + s.cmdline("texttofront").unwrap(); + assert!(s.prompt_text().contains("[Text/Dimensions/Leaders/All] "), "{}", s.prompt_text()); + s.cmdline("t").unwrap(); + assert!(s.running.is_none()); + assert_eq!(kinds(&s).last(), Some(&"Text")); + assert!(s.log.iter().any(|l| l.contains("1 object(s) brought to front")), "{:?}", s.log); + s.cmdline("texttofront").unwrap(); + s.cmdline("").unwrap(); + assert!(s.log.iter().any(|l| l.contains("2 object(s) brought to front")), "{:?}", s.log); +} diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 766569b..1677638 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -1499,6 +1499,7 @@ pub fn read(bytes: &[u8]) -> Result { d.model.push(e); } } + apply_sortents(&mut d, &objs.sortents, &rx.brs, &paper_block_for, &first_layout); objs.apply(&mut d, &rx); crate::dxf_image::resolve(&secs, &mut d); // Keep handles unique against the header's seed. @@ -1512,6 +1513,57 @@ pub fn read(bytes: &[u8]) -> Result { Ok(d) } +/// A SORTENTSTABLE (DXF Reference, OBJECTS section): after its AcDbSortentsTable marker, the +/// owning block record (330), then pairs of an entity handle (331) and the sort handle (5) it is +/// drawn by. +fn sortents_table(tags: &[Tag]) -> Option<(String, HashMap)> { + let start = tags.iter().position(|t| t.code == 100 && t.str() == "AcDbSortentsTable")?; + let tail = tags.get(start + 1..)?; + let owner = tail.iter().find(|t| t.code == 330)?.str().trim().to_ascii_uppercase(); + let mut map = HashMap::new(); + let mut ent: Option = None; + for t in tail { + match t.code { + 331 => ent = Handle::parse_hex(&t.str()), + 5 => { + if let (Some(e), Some(k)) = (ent.take(), Handle::parse_hex(&t.str())) + && map.len() < MAX_OBJECTS + { + map.insert(e, k.0); + } + } + _ => {} + } + } + (!map.is_empty()).then_some((owner, map)) +} + +/// Draw each space with a SORTENTSTABLE in sort-handle order: an entity listed in the table by +/// its sort handle, any other by its own handle. +fn apply_sortents( + d: &mut Drawing, + tables: &[(String, HashMap)], + brs: &HashMap, + paper_for: &HashMap, + first_layout: &str, +) { + for (owner, map) in tables { + let Some(name) = brs.get(owner) else { continue }; + let up = name.to_ascii_uppercase(); + let key = |e: &Entity| map.get(&e.handle).copied().unwrap_or(e.handle.0); + if up == "*MODEL_SPACE" { + d.model.sort_by_key(key); + } else if up.starts_with("*PAPER_SPACE") { + let lname = paper_for.get(&up).map(String::as_str).unwrap_or(first_layout); + if let Some(l) = d.layouts.iter_mut().find(|l| l.name == lname) { + l.entities.sort_by_key(key); + } + } else if let Some(b) = d.blocks.get_mut(name.as_str()) { + std::sync::Arc::make_mut(b).entities.sort_by_key(key); + } + } +} + /// Non-graphical objects applied once every entity is placed. #[derive(Default)] struct Objects { @@ -1529,6 +1581,8 @@ struct Objects { groups: Vec<(String, Vec)>, /// Saved paper-space views of layouts: (layout name, view). layout_views: Vec<(String, (Vec2, f64))>, + /// SORTENTSTABLE objects: (owning block record handle, entity handle → sort handle). + sortents: Vec<(String, HashMap)>, } const MAX_OBJECTS: usize = 1_000_000; @@ -1560,6 +1614,11 @@ impl Objects { "DIMASSOC" if self.dimassocs.len() < MAX_OBJECTS => self.dimassocs.push(tags.to_vec()), "MLEADERSTYLE" if self.mleader_styles.len() < MAX_OBJECTS => self.mleader_styles.push((h, tags.to_vec())), "GROUP" if self.groups.len() < MAX_OBJECTS => self.groups.push((h, tags.to_vec())), + "SORTENTSTABLE" if self.sortents.len() < MAX_OBJECTS => { + if let Some(t) = sortents_table(tags) { + self.sortents.push(t); + } + } "LAYOUT" if self.layout_views.len() < MAX_OBJECTS => { let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbLayout").unwrap_or(0); if let (Some(name), Some(view)) = (T(tags.get(start..).unwrap_or(&[])).s(1), layout_view(tags)) { diff --git a/crates/io/tests/sortents.rs b/crates/io/tests/sortents.rs new file mode 100644 index 0000000..0be17e9 --- /dev/null +++ b/crates/io/tests/sortents.rs @@ -0,0 +1,48 @@ +//! Draw order from DXF files (#439): a SORTENTSTABLE reorders its space by sort handle, and the +//! order CADCraft writes (the order of the entity records) reads back unchanged. + +use cadcraft_doc::*; + +fn line(h: &str, y: f64) -> String { + format!("0\nLINE\n5\n{h}\n330\n1F\n8\n0\n10\n0\n20\n{y}\n30\n0\n11\n10\n21\n{y}\n31\n0\n") +} + +fn dxf(objects: &str) -> String { + let mut s = String::from("0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nBLOCK_RECORD\n0\nBLOCK_RECORD\n5\n1F\n2\n*Model_Space\n0\nENDTAB\n0\nENDSEC\n"); + s.push_str("0\nSECTION\n2\nENTITIES\n"); + s.push_str(&line("A1", 0.0)); + s.push_str(&line("A2", 1.0)); + s.push_str(&line("A3", 2.0)); + s.push_str("0\nENDSEC\n0\nSECTION\n2\nOBJECTS\n"); + s.push_str(objects); + s.push_str("0\nENDSEC\n0\nEOF\n"); + s +} + +fn order(d: &Drawing) -> Vec { + d.model.iter().map(|e| e.handle.hex()).collect() +} + +#[test] +fn sortentstable_sets_the_draw_order() { + // A1 is drawn by sort handle A4 (over A2 and A3); A3 by 10 (under everything). + let table = "0\nSORTENTSTABLE\n5\nB0\n330\nB1\n100\nAcDbSortentsTable\n330\n1F\n331\nA1\n5\nA4\n331\nA3\n5\n10\n"; + let d = cadcraft_io::read_dxf(dxf(table).as_bytes()).unwrap(); + assert_eq!(order(&d), ["A3", "A2", "A1"]); + // Written back in draw order, without a table, the order survives. + let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap(); + assert_eq!(order(&back), ["A3", "A2", "A1"]); +} + +#[test] +fn broken_sortentstables_are_ignored() { + for table in [ + "0\nSORTENTSTABLE\n5\nB0\n331\nA1\n5\nA4\n", + "0\nSORTENTSTABLE\n100\nAcDbSortentsTable\n330\nFFFF\n331\nA1\n5\nA4\n", + "0\nSORTENTSTABLE\n100\nAcDbSortentsTable\n330\n1F\n331\nzz\n5\nA4\n331\nA2\n5\n\n5\nA9\n", + "0\nSORTENTSTABLE\n100\nAcDbSortentsTable\n", + ] { + let d = cadcraft_io::read_dxf(dxf(table).as_bytes()).unwrap(); + assert_eq!(order(&d), ["A1", "A2", "A3"], "{table:?}"); + } +} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 614fbcc..049c475 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -35,6 +35,7 @@ Align Text Вирівняти текст Aligned Паралельний All Усі + All Annotation Objects Усі об'єкти анотацій All Used Layers Усі використані шари All files Усі файли Angle Кут @@ -79,6 +80,8 @@ Box Паралелепіпед Break Розірвати Break At Point Розірвати в точці + Bring Above Objects Над об'єктами + Bring Annotations to Front Анотації на передній план Bring to Front На передній план ByBlock За блоком ByColor За кольором @@ -173,6 +176,7 @@ Diameter Діаметр Dim style Розмірний стиль Dimension Розміри + Dimension Objects Only Лише розміри Dimension Space Відстань між розмірами Dimension Style Override Перевизначення розмірного стилю Dimension Style... Розмірний стиль... @@ -388,6 +392,7 @@ Layout Аркуш Layout list Список аркушів Leader Виноска + Leaders Only Лише виноски Left Ліворуч Length Довжина Length X Довжина X @@ -542,6 +547,9 @@ New STB Нова STB New style Новий стиль Reset Panel Layout Скинути розташування панелей + Send Hatches to Back Штрихування на задній план + Send Under Objects Під об'єкти + Text Objects Only Лише текстові об'єкти Use for this layout Використати для цього аркуша Display plot styles Показувати стилі друку No plot style tables. Немає таблиць стилів друку.