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 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 11:23:59 -04:00
1 parent 9427b77209
commit 9b136d6c19
8 files changed
+736 -34

No files matched your search

+108
View File
@@ -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<Handle>) = (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<K: Ord>(&mut self, mut key: impl FnMut(&Entity) -> K) {
let mut items: Vec<Arc<Entity>> = 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<u64> {
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));
}
}
}
+362
View File
@@ -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<CommandSpec> {
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<usize> {
let space = s.space();
let d = s.doc_mut()?;
let Some(st) = d.space_mut(&space) else { return Ok(0) };
let index: HashMap<Handle, usize> = 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<Handle> = 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<Order> {
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<Vec<Handle>> {
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<Value> {
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<Value> {
let hs = targets(s, p)?;
reorder(s, &hs, Order::Front, &[])?;
ok()
}
fn run_back(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
reorder(s, &hs, Order::Back, &[])?;
ok()
}
fn run_above(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Vec<Handle>> {
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<Value> {
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<Value> {
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<Value> {
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] <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<Step> {
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] <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: Order,
sel: SelectPhase,
objs: Vec<Handle>,
}
impl DrawOrderM {
fn new(preset: Option<Order>) -> 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<Handle>) -> Result<Step> {
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<Step> {
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<Step> {
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<Step> {
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<Step> {
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),
},
}
}
}
+2
View File
@@ -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());
+2 -34
View File
@@ -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<CommandSpec> {
.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<Value> {
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
fn run_front(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Vec<Handle>> {
let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?;
+147
View File
@@ -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<Handle>) {
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<Handle> {
s.doc().unwrap().model.iter().map(|e| e.handle).collect()
}
fn hex(hs: &[Handle]) -> Vec<String> {
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] <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::<Vec<_>>();
// 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] <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);
}
+59
View File
@@ -1499,6 +1499,7 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
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<Drawing> {
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<Handle, u64>)> {
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<Handle> = 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<Handle, u64>)],
brs: &HashMap<String, String>,
paper_for: &HashMap<String, String>,
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<Tag>)>,
/// 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<Handle, u64>)>,
}
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)) {
+48
View File
@@ -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<String> {
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:?}");
}
}
+8
View File
@@ -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. Немає таблиць стилів друку.
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
35
36
37
38
39
40
41
80
81
82
83
84
85
86
87
176
177
178
179
180
181
182
392
393
394
395
396
397
398
547
548
549
550
551
552
553
554
555