From afc12e4dce92a07921a3e2391f5508e5b3483278 Mon Sep 17 00:00:00 2001 From: aldozar Date: Thu, 29 Oct 2026 19:20:58 -0600 Subject: [PATCH 01/29] PEDIT Multiple at the command line (#2225) * PEDIT Multiple at the command line (#2182) `M` at "Select polyline" answered that Multiple was only available through the JSON form. It now works as in AutoCAD: select objects (polylines, lines and arcs), turn the lines and arcs into polylines on Yes (left out on No), then each option applies to all of them: Close, Open, Width, Fit, Spline, Decurve, Ltype gen and Reverse. Join joins the selected objects with each other, ends within a fuzz distance, and reports how many it added. Undo takes the last option back. Co-Authored-By: Claude Opus 5.5 * Geometry parity status line for PEDIT Multiple Co-Authored-By: Claude Opus 5.5 * PEDIT Multiple: objects selected first, and Decurve after Fit or Spline Several objects selected before PEDIT go straight into Multiple, as in AutoCAD, and M keeps a selection made first. Decurve gives back each polyline's vertices from before its first Fit or Spline, as single-polyline PEDIT does; a Join drops that record for the joined polylines. Co-Authored-By: Claude Opus 5.5 * PEDIT Multiple: the Decurve record follows Undo and survives a Join that joins nothing Each Undo step keeps the record of the vertices from before Fit or Spline, so Fit, Join, Undo, Decurve gives them back; a Join drops the record only for a polyline that took on segments. Co-Authored-By: Claude Opus 5.5 * PEDIT: Yes/No asked again on another answer; Decurve keeps a later Width The convert questions (single and Multiple) take Y/Yes, N/No or Enter and ask again for any other answer instead of treating it as Yes. Width clears the per-vertex widths of the vertices kept for Decurve too, so Decurve after Fit or Spline and Width keeps the new width. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/modify2.rs | 1 + crates/engine/src/cmd/modify2/pedit.rs | 52 +++- crates/engine/src/cmd/modify2/pedit_multi.rs | 235 +++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + crates/engine/tests/it/pedit_multiple.rs | 197 ++++++++++++++++ docs/geometry-parity.md | 3 +- 6 files changed, 484 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/cmd/modify2/pedit_multi.rs create mode 100644 crates/engine/tests/it/pedit_multiple.rs diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index 8d814ebe0..0198a962d 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -122,6 +122,7 @@ pub(crate) fn as_lwpoly(k: &EntityKind) -> Option { } } +mod pedit_multi; mod pedit_vertex; // ===================================================================================== diff --git a/crates/engine/src/cmd/modify2/pedit.rs b/crates/engine/src/cmd/modify2/pedit.rs index ae9110e48..0c1b64879 100644 --- a/crates/engine/src/cmd/modify2/pedit.rs +++ b/crates/engine/src/cmd/modify2/pedit.rs @@ -164,7 +164,7 @@ pub(crate) fn pedit_join(s: &mut Session, h: Handle, others: &[Handle], fuzz: f6 Ok(joined.len()) } -fn pedit_apply(s: &mut Session, h: Handle, opt: &str, p: &Value) -> Result { +pub(super) fn pedit_apply(s: &mut Session, h: Handle, opt: &str, p: &Value) -> Result { let e = curves::entity(s, h)?; if curves::is_locked(s, h) { return Err(other("The object is on a locked layer.")); @@ -287,6 +287,13 @@ pub(super) enum PPhase { /// The second marker of Break / Straighten. VMark, VTangent, + /// Multiple ([`super::pedit_multi`]): selecting the objects, converting lines and arcs, the + /// options, the width for all, Join's fuzz distance. + MSelect, + MConvert, + MMain, + MWidth, + MFuzz, } #[derive(Default)] @@ -305,6 +312,34 @@ pub(super) struct PeditM { pub(super) tangents: Vec<(Vec2, Vec2)>, /// Pieces split off by Break, with the history length after it (erased again by Undo). pub(super) split_off: Vec<(usize, Vec)>, + /// Multiple: the polylines being edited, their selection, and the drawing before each option + /// (with those polylines and their Decurve record) for Undo. + pub(super) multi: Vec, + pub(super) msel: SelectPhase, + pub(super) mundo: Vec<(cadcraft_doc::Drawing, Vec, std::collections::HashMap>)>, + /// Multiple: each polyline's vertices before its first Fit or Spline, which Decurve restores. + pub(super) morig: std::collections::HashMap>, +} + +/// A Yes/No answer: Enter is Yes, anything but Y/Yes/N/No is refused so the question is asked again. +pub(super) fn yes_no(i: &Input) -> Result { + match i { + Input::Enter => Ok(true), + Input::Keyword(k) | Input::Text(k) => match k.trim().to_ascii_lowercase().as_str() { + "y" | "yes" => Ok(true), + "n" | "no" => Ok(false), + _ => Err(other("Enter Yes or No.")), + }, + _ => Err(other("Enter Yes or No.")), + } +} + +/// Width sets one width for the whole polyline, so the vertices Decurve gives back take it too. +pub(super) fn clear_widths(vs: &mut [PolyVertex]) { + for v in vs { + v.start_width = 0.0; + v.end_width = 0.0; + } } impl PeditM { @@ -339,6 +374,10 @@ impl Interactive for PeditM { } fn begin(&mut self, s: &mut Session) -> Result { let sel = s.selection(); + // Several objects selected first: straight into Multiple, as in AutoCAD. + if sel.len() > 1 && s.settings.pickfirst { + return self.start_multi(s); + } if let [h] = sel.as_slice() { let k = curves::entity(s, *h)?.kind; match k { @@ -358,6 +397,7 @@ impl Interactive for PeditM { } fn prompt(&self, s: &Session) -> Prompt { match self.phase { + PPhase::MSelect | PPhase::MConvert | PPhase::MMain | PPhase::MWidth | PPhase::MFuzz => self.multi_prompt(), PPhase::Select => Prompt::new("Select polyline or", Accept::POINT).kw(&["Multiple"]), PPhase::Convert => { Prompt::new("Object selected is not a polyline. Do you want to turn it into one?", curves::KW).kw(&["Yes", "No"]).default("Y") @@ -398,6 +438,7 @@ impl Interactive for PeditM { _ => None, }; match self.phase { + PPhase::MSelect | PPhase::MConvert | PPhase::MMain | PPhase::MWidth | PPhase::MFuzz => self.multi_input(s, i), PPhase::Select => match i { Input::Point(p) => { let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; @@ -417,13 +458,13 @@ impl Interactive for PeditM { s.set_selection(vec![h]); Ok(Step::Continue) } - Input::Keyword(_) => Err(other("Select one polyline (Multiple is available through the JSON form).")), + Input::Keyword(k) if k.eq_ignore_ascii_case("multiple") || k.eq_ignore_ascii_case("m") => self.start_multi(s), + Input::Keyword(_) => Err(other("Invalid option keyword.")), Input::Enter => Ok(Step::Cancel), _ => Ok(Step::Continue), }, PPhase::Convert => { - let no = kw(&i).is_some_and(|k| k.starts_with('n')); - if no { + if !yes_no(&i)? { return Ok(Step::Done); } let h = self.h.ok_or_else(|| other("No object."))?; @@ -487,6 +528,9 @@ impl Interactive for PeditM { if let Input::Text(t) = &i { let w = number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?; self.apply(s, "width", json!({ "width": w }))?; + if let Some(o) = &mut self.original { + clear_widths(o); + } } self.phase = PPhase::Main; Ok(Step::Continue) diff --git a/crates/engine/src/cmd/modify2/pedit_multi.rs b/crates/engine/src/cmd/modify2/pedit_multi.rs new file mode 100644 index 000000000..5a599cb1b --- /dev/null +++ b/crates/engine/src/cmd/modify2/pedit_multi.rs @@ -0,0 +1,235 @@ +//! PEDIT Multiple: edit several polylines (and lines and arcs turned into polylines) at once, as +//! AutoCAD does after `M` at "Select polyline". Join joins the selected objects with each other. + +use cadcraft_doc::{Drawing, EntityKind, Handle}; +use cadcraft_geom::PolyVertex; +use serde_json::json; + +use super::super::curves; +use super::super::machines::{SelOutcome, SelectPhase, number}; +use super::pedit::{PPhase, PeditM, clear_widths, pedit_apply, pedit_join, yes_no}; +use super::{as_lwpoly, other, set_kind}; +use crate::{Accept, Input, Prompt, Result, Session, Step}; + +/// Most option steps Undo can take back. +const MAX_UNDO: usize = 64; + +/// The options of the Multiple menu. +const OPTIONS: [&str; 10] = ["Close", "Open", "Join", "Width", "Fit", "Spline", "Decurve", "Ltype gen", "Reverse", "Undo"]; + +impl PeditM { + /// Start Multiple: select the objects to edit, or take the ones selected first. + pub(super) fn start_multi(&mut self, s: &mut Session) -> Result { + self.phase = PPhase::MSelect; + self.msel = SelectPhase::begin(s); + if self.msel.done { + let hs = std::mem::take(&mut self.msel.picked); + return self.take_multi(s, hs); + } + s.set_selection(Vec::new()); + Ok(Step::Continue) + } + + /// The objects selected for Multiple: polylines, lines and arcs not on locked layers. + fn take_multi(&mut self, s: &mut Session, hs: Vec) -> Result { + let d = s.doc()?; + let kinds = |h: &Handle| d.entity(*h).map(|e| e.kind.clone()); + let editable: Vec = hs + .iter() + .copied() + .filter(|h| matches!(kinds(h), Some(EntityKind::LwPolyline(_) | EntityKind::Line(_) | EntityKind::Arc(_)))) + .filter(|h| !curves::is_locked(s, *h)) + .collect(); + let convert = editable.iter().any(|h| matches!(kinds(h), Some(EntityKind::Line(_) | EntityKind::Arc(_)))); + self.multi = editable; + if self.multi.is_empty() { + s.echo("No polylines, lines or arcs selected."); + return Ok(Step::Done); + } + self.phase = if convert { PPhase::MConvert } else { PPhase::MMain }; + s.set_selection(self.multi.clone()); + Ok(Step::Continue) + } + + /// The prompt of a Multiple phase. + pub(super) fn multi_prompt(&self) -> Prompt { + match self.phase { + PPhase::MConvert => Prompt::new("Convert Lines and Arcs to polylines", curves::KW).kw(&["Yes", "No"]).default("Y"), + PPhase::MMain => Prompt::new("Enter an option", curves::KW).kw(&OPTIONS), + PPhase::MWidth => Prompt::new("Specify new width for all segments", Accept::NUMBER), + PPhase::MFuzz => Prompt::new("Enter fuzz distance", Accept::NUMBER).default("0.0000"), + _ => self.msel.prompt(), + } + } + + /// Input in a Multiple phase. + pub(super) fn multi_input(&mut self, s: &mut Session, i: Input) -> Result { + let kw = |i: &Input| match i { + Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_ascii_lowercase()), + _ => None, + }; + match self.phase { + PPhase::MSelect => match self.msel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Done), + SelOutcome::Done(hs) => self.take_multi(s, hs), + }, + PPhase::MConvert => { + let no = !yes_no(&i)?; + let mut kept = Vec::new(); + for h in std::mem::take(&mut self.multi) { + let kind = curves::entity(s, h)?.kind; + match kind { + EntityKind::LwPolyline(_) => kept.push(h), + _ if no => {} + k => { + if let Some(pl) = as_lwpoly(&k) { + set_kind(s, h, EntityKind::LwPolyline(pl))?; + kept.push(h); + } + } + } + } + self.multi = kept; + if self.multi.is_empty() { + s.echo("No polylines to edit."); + return Ok(Step::Done); + } + s.set_selection(self.multi.clone()); + self.phase = PPhase::MMain; + Ok(Step::Continue) + } + PPhase::MMain => { + let Some(k) = kw(&i) else { + if i == Input::Enter { + s.set_selection(Vec::new()); + return Ok(Step::Done); + } + return Ok(Step::Continue); + }; + match k.as_str() { + "close" | "c" => self.apply_all(s, "close", &json!({}))?, + "open" | "o" => self.apply_all(s, "open", &json!({}))?, + "join" | "j" => self.phase = PPhase::MFuzz, + "width" | "w" => self.phase = PPhase::MWidth, + "fit" | "f" => self.curve_all(s, "fit")?, + "spline" | "s" => self.curve_all(s, "spline")?, + "decurve" | "d" => self.decurve_all(s)?, + "ltype gen" | "l" => { + // One setting for all: the opposite of the first one's. + let on = self.multi.first().and_then(|h| curves::entity(s, *h).ok()).is_some_and(|e| match e.kind { + EntityKind::LwPolyline(p) => !p.plinegen, + _ => true, + }); + self.apply_all(s, "ltypegen", &json!({ "on": on }))?; + } + "reverse" | "r" => self.apply_all(s, "reverse", &json!({}))?, + "undo" | "u" => match self.mundo.pop() { + Some((d, multi, morig)) => { + *s.doc_mut()? = d; + self.multi = multi; + self.morig = morig; + s.set_selection(self.multi.clone()); + } + None => s.echo("Nothing to undo."), + }, + _ => return Err(other("Invalid option keyword.")), + } + Ok(Step::Continue) + } + PPhase::MWidth => { + if let Input::Text(t) = &i { + let w = number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?; + self.apply_all(s, "width", &json!({ "width": w }))?; + self.morig.values_mut().for_each(|o| clear_widths(o)); + } + self.phase = PPhase::MMain; + Ok(Step::Continue) + } + PPhase::MFuzz => { + let fuzz = match &i { + Input::Text(t) => number(t).filter(|f| *f >= 0.0 && f.is_finite()).ok_or_else(|| other("Requires a non-negative distance."))?, + _ => 0.0, + }; + self.join_all(s, fuzz)?; + self.phase = PPhase::MMain; + Ok(Step::Continue) + } + _ => Ok(Step::Continue), + } + } + + /// Keep the drawing as it is for Undo (and the polylines being edited, with their Decurve + /// record). + fn remember(&mut self, s: &Session) -> Result<()> { + let d: Drawing = s.doc()?.clone(); + if self.mundo.len() >= MAX_UNDO { + self.mundo.remove(0); + } + self.mundo.push((d, self.multi.clone(), self.morig.clone())); + Ok(()) + } + + /// Apply option `opt` to every polyline being edited. + fn apply_all(&mut self, s: &mut Session, opt: &str, p: &serde_json::Value) -> Result<()> { + self.remember(s)?; + self.multi.retain(|h| s.doc().is_ok_and(|d| d.entity(*h).is_some())); + for h in self.multi.clone() { + pedit_apply(s, h, opt, p)?; + } + Ok(()) + } + + /// Fit or Spline all of them, first keeping each one's vertices for Decurve. + fn curve_all(&mut self, s: &mut Session, opt: &str) -> Result<()> { + for h in &self.multi { + if !self.morig.contains_key(h) + && let Ok(EntityKind::LwPolyline(p)) = curves::entity(s, *h).map(|e| e.kind) + { + self.morig.insert(*h, p.vertices); + } + } + self.apply_all(s, opt, &json!({})) + } + + /// Decurve all of them: one fitted or splined here gets back its vertices from before (as + /// single-polyline PEDIT does), the others lose their arcs. + fn decurve_all(&mut self, s: &mut Session) -> Result<()> { + self.apply_all(s, "decurve", &json!({}))?; + for h in self.multi.clone() { + if let Some(orig) = self.morig.get(&h) + && let Ok(EntityKind::LwPolyline(mut p)) = curves::entity(s, h).map(|e| e.kind) + { + p.vertices = orig.iter().map(|v| PolyVertex { bulge: 0.0, ..*v }).collect(); + set_kind(s, h, EntityKind::LwPolyline(p))?; + } + } + Ok(()) + } + + /// Join the polylines being edited with each other, ends within `fuzz`: each one still open + /// takes the others that meet it, and those go into it. + fn join_all(&mut self, s: &mut Session, fuzz: f64) -> Result<()> { + self.remember(s)?; + let mut rest: Vec = self.multi.iter().copied().filter(|h| s.doc().is_ok_and(|d| d.entity(*h).is_some())).collect(); + let before = rest.len(); + let mut kept = Vec::new(); + while !rest.is_empty() { + let h = rest.remove(0); + let open = matches!(curves::entity(s, h)?.kind, EntityKind::LwPolyline(p) if !p.closed); + if open { + // A polyline that took on segments no longer goes back past the Join on Decurve. + if pedit_join(s, h, &rest, fuzz)? > 0 { + self.morig.remove(&h); + } + rest.retain(|o| s.doc().is_ok_and(|d| d.entity(*o).is_some())); + } + kept.push(h); + } + let added = before - kept.len(); + s.echo(format!("{added} segments added to polyline")); + self.multi = kept; + s.set_selection(self.multi.clone()); + Ok(()) + } +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index c4c4a1ce6..4a2a829a7 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -85,6 +85,7 @@ mod offset_move; mod options_profile; mod overkill; mod parallel_display_list; +mod pedit_multiple; mod pedit_splinedit; mod pick_text; mod pickdrag; diff --git a/crates/engine/tests/it/pedit_multiple.rs b/crates/engine/tests/it/pedit_multiple.rs new file mode 100644 index 000000000..70a1a8bec --- /dev/null +++ b/crates/engine/tests/it/pedit_multiple.rs @@ -0,0 +1,197 @@ +//! #2182: PEDIT Multiple at the command line, as AutoCAD: select several objects, turn lines and +//! arcs into polylines, then each option applies to all of them; Join joins them with each other. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use serde_json::json; + +fn polylines(s: &Session) -> Vec<(usize, bool, f64)> { + s.doc() + .unwrap() + .model + .iter() + .filter_map(|e| if let EntityKind::LwPolyline(p) = &e.kind { Some((p.vertices.len(), p.closed, p.const_width)) } else { None }) + .collect() +} + +fn count(s: &Session) -> usize { + s.doc().unwrap().model.len() +} + +fn lines(s: &mut Session, pts: &[[f64; 4]]) { + for p in pts { + s.execute("line", &json!({ "points": [[p[0], p[1]], [p[2], p[3]]] })).unwrap(); + } +} + +#[test] +fn join_turns_four_lines_into_one_closed_polyline() { + let mut s = Session::new(); + lines(&mut s, &[[0.0, 0.0, 10.0, 0.0], [10.0, 0.0, 10.0, 5.0], [10.0, 5.0, 0.0, 5.0], [0.0, 5.0, 0.0, 0.0]]); + for t in ["PEDIT", "M", "ALL", "", "Y", "J", "", ""] { + s.cmdline(t).unwrap(); + } + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(polylines(&s), vec![(4, true, 0.0)]); + assert_eq!(count(&s), 1); + assert!(s.log.iter().any(|l| l.contains("3 segments added to polyline")), "{:?}", s.log); +} + +#[test] +fn join_bridges_gaps_within_the_fuzz_distance_only() { + // Two chains with a 0.5 gap between them. + let gap = |s: &mut Session| lines(s, &[[0.0, 0.0, 10.0, 0.0], [10.5, 0.0, 20.0, 0.0]]); + let mut s = Session::new(); + gap(&mut s); + for t in ["PE", "M", "ALL", "", "Y", "J", "0", ""] { + s.cmdline(t).unwrap(); + } + assert_eq!(polylines(&s).len(), 2, "no fuzz: still two"); + let mut s = Session::new(); + gap(&mut s); + for t in ["PE", "M", "ALL", "", "Y", "J", "1", ""] { + s.cmdline(t).unwrap(); + } + assert_eq!(polylines(&s).len(), 1, "a fuzz of 1 joins them"); +} + +#[test] +fn options_apply_to_all_and_undo_takes_one_back() { + let mut s = Session::new(); + s.cmdline("pline 0,0 10,0 10,10").unwrap(); + s.cmdline("").unwrap(); + s.cmdline("pline 20,0 30,0 30,10").unwrap(); + s.cmdline("").unwrap(); + for t in ["PEDIT", "M", "ALL", "", "W", "0.5", "C"] { + s.cmdline(t).unwrap(); + } + assert_eq!(polylines(&s), vec![(3, true, 0.5), (3, true, 0.5)], "width and close on both"); + s.cmdline("U").unwrap(); + assert_eq!(polylines(&s), vec![(3, false, 0.5), (3, false, 0.5)], "Undo opens them again"); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); +} + +#[test] +fn lines_left_out_when_not_converted() { + let mut s = Session::new(); + s.cmdline("pline 0,0 10,0 10,10").unwrap(); + s.cmdline("").unwrap(); + lines(&mut s, &[[20.0, 0.0, 30.0, 0.0]]); + for t in ["PEDIT", "M", "ALL", "", "N", "C", ""] { + s.cmdline(t).unwrap(); + } + assert_eq!(polylines(&s), vec![(3, true, 0.0)], "only the polyline is edited"); + assert!(s.doc().unwrap().model.iter().any(|e| matches!(e.kind, EntityKind::Line(_))), "the line stays a line"); +} + +#[test] +fn objects_selected_first_go_straight_to_multiple() { + let mut s = Session::new(); + s.cmdline("pline 0,0 10,0 10,10").unwrap(); + s.cmdline("").unwrap(); + s.cmdline("pline 20,0 30,0 30,10").unwrap(); + s.cmdline("").unwrap(); + let all: Vec<_> = s.doc().unwrap().model.iter().map(|e| e.handle).collect(); + s.set_selection(all); + for t in ["PEDIT", "W", "0.5", ""] { + s.cmdline(t).unwrap(); + } + assert_eq!(polylines(&s), vec![(3, false, 0.5), (3, false, 0.5)], "no second selection asked for"); + assert!(s.running.is_none()); +} + +#[test] +fn decurve_after_fit_gives_back_the_vertices() { + let mut s = Session::new(); + s.cmdline("pline 0,0 10,0 10,10 20,10").unwrap(); + s.cmdline("").unwrap(); + s.cmdline("pline 0,20 10,20 10,30").unwrap(); + s.cmdline("").unwrap(); + for t in ["PEDIT", "M", "ALL", "", "S"] { + s.cmdline(t).unwrap(); + } + assert!(polylines(&s).iter().all(|p| p.0 > 4), "Spline adds vertices: {:?}", polylines(&s)); + s.cmdline("D").unwrap(); + assert_eq!(polylines(&s), vec![(4, false, 0.0), (3, false, 0.0)], "Decurve gives back each one's vertices"); + s.cmdline("").unwrap(); +} + +#[test] +fn decurve_still_gives_back_the_vertices_after_a_join_that_joins_nothing_or_is_undone() { + let mut s = Session::new(); + s.cmdline("pline 0,0 10,0 10,10 20,10").unwrap(); + s.cmdline("").unwrap(); + s.cmdline("pline 0,20 10,20 10,30").unwrap(); + s.cmdline("").unwrap(); + // Join with nothing in reach joins nothing: Decurve still gives back the vertices. + for t in ["PEDIT", "M", "ALL", "", "S", "J", "0", "D"] { + s.cmdline(t).unwrap(); + } + assert_eq!(polylines(&s), vec![(4, false, 0.0), (3, false, 0.0)], "after a Join that joined nothing"); + // Spline, a Join that joins them, Undo: Decurve gives back the vertices again. + for t in ["S", "J", "100", "U", "D"] { + s.cmdline(t).unwrap(); + } + assert_eq!(polylines(&s), vec![(4, false, 0.0), (3, false, 0.0)], "after an undone Join"); + s.cmdline("").unwrap(); +} + +fn widths(s: &Session) -> Vec { + s.doc() + .unwrap() + .model + .iter() + .filter_map(|e| if let EntityKind::LwPolyline(p) = &e.kind { Some(p) } else { None }) + .flat_map(|p| p.vertices.iter().flat_map(|v| [v.start_width, v.end_width])) + .collect() +} + +#[test] +fn convert_asks_again_for_an_answer_that_is_not_yes_or_no() { + let mut s = Session::new(); + lines(&mut s, &[[0.0, 0.0, 10.0, 0.0]]); + for t in ["PEDIT", "M", "ALL", ""] { + s.cmdline(t).unwrap(); + } + s.cmdline("foo").unwrap(); + assert!(s.prompt_text().contains("Convert"), "{}", s.prompt_text()); + assert_eq!(polylines(&s), vec![], "nothing converted on a wrong answer"); + s.cmdline("Y").unwrap(); + assert_eq!(polylines(&s).len(), 1); + s.cmdline("").unwrap(); + + // Single-polyline PEDIT asks again too. + lines(&mut s, &[[0.0, 5.0, 10.0, 5.0]]); + let l = s.doc().unwrap().model.last().unwrap().handle; + s.set_selection(vec![l]); + s.cmdline("PEDIT").unwrap(); + s.cmdline("foo").unwrap(); + assert!(s.prompt_text().contains("turn it into one"), "{}", s.prompt_text()); + s.cmdline("N").unwrap(); + assert!(s.running.is_none()); +} + +#[test] +fn decurve_after_width_keeps_the_new_width() { + let mut s = Session::new(); + for t in ["PLINE", "0,0", "W", "0", "2", "10,0", "10,10", "20,10", ""] { + s.cmdline(t).unwrap(); + } + assert!(widths(&s).iter().any(|w| *w > 0.0), "{:?}", widths(&s)); + for t in ["PEDIT", "M", "ALL", "", "S", "W", "0", "D", ""] { + s.cmdline(t).unwrap(); + } + assert!(widths(&s).iter().all(|w| *w == 0.0), "Multiple: {:?}", widths(&s)); + + let mut s = Session::new(); + for t in ["PLINE", "0,0", "W", "0", "2", "10,0", "10,10", "20,10", ""] { + s.cmdline(t).unwrap(); + } + let h = s.doc().unwrap().model.last().unwrap().handle; + s.set_selection(vec![h]); + for t in ["PEDIT", "S", "W", "0", "D", ""] { + s.cmdline(t).unwrap(); + } + assert!(widths(&s).iter().all(|w| *w == 0.0), "single: {:?}", widths(&s)); +} diff --git a/docs/geometry-parity.md b/docs/geometry-parity.md index 74dfc1585..3809c637f 100644 --- a/docs/geometry-parity.md +++ b/docs/geometry-parity.md @@ -1,6 +1,6 @@ # Geometry parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (bounded constraint expression and parameter depth) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (PEDIT Multiple at the command line) · **Target:** Autodesk AutoCAD 2027 CAD users trust numbers: a trimmed endpoint, an offset, an intersection or an area is either right to the last displayed digit or the tool is unusable for production drawings. This document @@ -157,6 +157,7 @@ Total ≈ 90–140 h; items 1–6 parallelise across two or three agents. | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | PEDIT Multiple at the command line (#2182): select objects, lines and arcs turned into polylines on Yes, then Close/Open/Join/Width/Fit/Spline/Decurve/Ltype gen/Reverse/Undo apply to all; Join joins the selected objects with each other within a fuzz distance | | 2026-10-22 | minor | Constraint expressions count operator chains toward the syntax-tree depth limit; evaluation shares one depth limit across operators and parameter references, with a 1 MiB-stack regression (#1936) | | 2026-10-14 | trivial | JOIN Close: a single arc becomes its circle, a single elliptical arc its full ellipse (#1175) | | 2026-10-12 | trivial | TRIM on a closed polyline keeps only the complementary run when the pick lies past the last cut (#726) | From ab8953067a05025f0ac6b5498ca2ab3ca0b182ea Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:21:02 +0100 Subject: [PATCH 02/29] Reject out-of-range LUNITS, LUPREC, AUNITS and AUPREC in SETVAR (#2269) Found while testing crates/engine/src/sysvars.rs:379-420 (generic fallthrough in set); the valid ranges are enforced only in crates/engine/src/cmd/props.rs:611-616 (run_units) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/cmd.rs | 1 + crates/engine/src/cmd/props.rs | 5 ++++- crates/engine/src/sysvars.rs | 9 +++++++++ crates/engine/src/tests.rs | 11 +++++++++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 1856c6a15..172e416d1 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -33,6 +33,7 @@ pub mod plotters; mod props; pub use draw2::breakline::BreaklineOptions; pub use props::MatchProps; +pub(crate) use props::UNIT_RANGES; pub(crate) use props::{current_color_value, set_current_color_value}; mod qselect; mod selecttools; diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 817ff77a2..617d29756 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -645,9 +645,12 @@ impl Interactive for ThicknessM { } } +/// The unit variables (lower-case name, lowest, highest value) shared by UNITS and SETVAR. +pub(crate) const UNIT_RANGES: [(&str, i64, i64); 5] = [("lunits", 1, 5), ("luprec", 0, 8), ("aunits", 0, 4), ("auprec", 0, 8), ("insunits", 0, 24)]; + fn run_units(s: &mut Session, p: &Value) -> Result { let d = s.doc_mut()?; - for (k, lo, hi) in [("lunits", 1, 5), ("luprec", 0, 8), ("aunits", 0, 4), ("auprec", 0, 8), ("insunits", 0, 24)] { + for (k, lo, hi) in UNIT_RANGES { if let Some(v) = p.get(k).and_then(Value::as_i64) { d.header.set_i64(&k.to_ascii_uppercase(), v.clamp(lo, hi)); } diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index e22529aac..945a694bc 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -375,6 +375,15 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { let a = a.filter(|a| a.is_finite()).ok_or_else(bad)?; s.doc_mut()?.header.set_f64("ANGBASE", cadcraft_geom::norm_angle(a)); } + // Whole numbers inside the range UNITS clamps to. + "LUNITS" | "LUPREC" | "AUNITS" | "AUPREC" => { + let (_, lo, hi) = crate::cmd::UNIT_RANGES.iter().find(|(k, ..)| k.eq_ignore_ascii_case(&n)).copied().ok_or_else(bad)?; + let i = as_i(v) + .or_else(|| as_f64(v).filter(|f| f.fract() == 0.0 && f.abs() < 1e15).map(|f| f as i64)) + .filter(|i| (lo..=hi).contains(i)) + .ok_or_else(bad)?; + s.doc_mut()?.header.set_i64(&n, i); + } "CONSTRAINTINFER" => s.doc_mut()?.parametric.settings.infer = as_bool(v).ok_or_else(bad)?, _ => { if let Some(r) = table::set(s, &n, v) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8c590dd97..066df250b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -633,6 +633,17 @@ fn setvar_keeps_header_types() { assert_eq!(s.doc().unwrap().header.f64("LTSCALE", 0.0), 1.0); } +#[test] +fn setvar_rejects_out_of_range_units() { + let mut s = Session::new(); + for (name, v) in [("LUPREC", 100), ("LUNITS", 9), ("LUNITS", 0), ("AUPREC", -5), ("AUNITS", 5)] { + assert!(s.execute("setvar", &json!({"name": name, "value": v})).is_err(), "{name} {v}"); + } + s.execute("setvar", &json!({"name": "LUPREC", "value": 8})).unwrap(); + assert_eq!(s.doc().unwrap().header.i64("LUPREC", 0), 8); + assert_ne!(s.doc().unwrap().header.i64("LUNITS", 2), 9); +} + #[test] fn dynamic_input_pointer_settings() { let mut s = Session::new(); From 7efb32b455017318c368f11cd2e91e4725e41044 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:21:06 +0100 Subject: [PATCH 03/29] Cap autosave file names at 255 bytes for multibyte titles (#2289) Found while testing crates/engine/src/autosave.rs:61-66 (file_name) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/autosave.rs | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 14b363996..480d14c3b 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -59,10 +59,22 @@ pub fn set_savefilepath(path: &str) { /// The autosave file name for drawing `uid` titled `title`, written by app instance `instance`. pub fn file_name(title: &str, instance: &str, uid: u64) -> String { - let stem: String = title.chars().map(|c| if c.is_alphanumeric() || " .-_()".contains(c) { c } else { '-' }).take(80).collect(); + // File systems limit a name to 255 bytes, so the stem gets what the suffix leaves, cut at a character boundary. + let suffix = format!("_{instance}_{uid}.{EXT}"); + let budget = 255usize.saturating_sub(suffix.len()); + let mut used = 0; + let stem: String = title + .chars() + .map(|c| if c.is_alphanumeric() || " .-_()".contains(c) { c } else { '-' }) + .take(80) + .take_while(|c| { + used += c.len_utf8(); + used <= budget + }) + .collect(); let stem = stem.trim().trim_matches('.'); let stem = if stem.is_empty() { "Drawing" } else { stem }; - format!("{stem}_{instance}_{uid}.{EXT}") + format!("{stem}{suffix}") } /// Whether `s` has the form of an app instance id (`-`, see @@ -478,3 +490,20 @@ mod native { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_name_fits_255_bytes_for_multibyte_titles() { + let instance = "12345-abcdef0123456789"; + for title in ["图".repeat(100), "😀".repeat(100), "aé图😀".repeat(40), "a".repeat(300)] { + let name = file_name(&title, instance, u64::MAX); + assert!(name.len() <= 255, "{} bytes", name.len()); + let (t, i, u) = parse_file_name(&name).expect("round trips"); + assert_eq!((i, u), (instance, u64::MAX)); + assert!(!t.is_empty()); + } + } +} From c2695f6ab0ecbb8e566bbb154b3d8bac4871c754 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:21:10 +0100 Subject: [PATCH 04/29] Refuse invalid counts in JSON arraypolar and arrayrect (#2399) Found while testing crates/engine/src/cmd/array.rs:332-334 (count_or, used by run_arrayrect at 338-340 and run_arraypolar at 363-365) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/cmd/array.rs | 21 ++++++++++++--------- crates/engine/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/array.rs b/crates/engine/src/cmd/array.rs index 479b7873a..9d4a2fd9c 100644 --- a/crates/engine/src/cmd/array.rs +++ b/crates/engine/src/cmd/array.rs @@ -328,16 +328,19 @@ fn run_array(s: &mut Session, p: &Value) -> Result { (spec.run)(s, p) } -/// A count parameter (at least 1). -fn count_or(p: &Value, key: &str, default: u64) -> u64 { - p.get(key).and_then(Value::as_u64).unwrap_or(default).max(1) +/// A count parameter (at least 1); a missing key gives the default, an invalid value is refused. +fn count_or(cmd: &str, p: &Value, key: &str, default: u64) -> Result { + match p.get(key) { + None | Some(Value::Null) => Ok(default), + Some(v) => v.as_u64().filter(|n| *n >= 1).ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number of at least 1"))), + } } fn run_arrayrect(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; - let rows = count_or(p, "rows", 3); - let cols = count_or(p, "cols", 4); - let levels = count_or(p, "levels", 1); + let rows = count_or("arrayrect", p, "rows", 3)?; + let cols = count_or("arrayrect", p, "cols", 4)?; + let levels = count_or("arrayrect", p, "levels", 1)?; check_size(s, rows.saturating_mul(cols).saturating_mul(levels), hs.len())?; let kind = ArrayKind::Rect(RectArray { cols: count32(cols), @@ -360,9 +363,9 @@ fn run_arrayrect(s: &mut Session, p: &Value) -> Result { fn run_arraypolar(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; let c = point_req("arraypolar", p, "center")?; - let n = count_or(p, "count", 6); - let rows = count_or(p, "rows", 1); - let levels = count_or(p, "levels", 1); + let n = count_or("arraypolar", p, "count", 6)?; + let rows = count_or("arraypolar", p, "rows", 1)?; + let levels = count_or("arraypolar", p, "levels", 1)?; check_size(s, n.saturating_mul(rows).saturating_mul(levels), hs.len())?; let base = point_param(p, "base").or_else(|| s.doc().ok().and_then(|d| first_center(d, &hs))).unwrap_or(c); let row_spacing = f64_or(p, "rowSpacing", 1.0); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 066df250b..b06641fbb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1444,3 +1444,22 @@ fn hostile_variable_like_input_never_panics() { s.cmdline("pickadd").unwrap(); assert!(setvar_prompt_open(&s)); } + +#[test] +fn array_counts_reject_invalid_values() { + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap(); + for bad in [json!(-3), json!(0), json!(2.5)] { + for key in ["count", "rows", "levels"] { + let mut p = json!({"center": [0, 0], "associative": false}); + p[key] = bad.clone(); + assert!(s.execute("arraypolar", &p).is_err(), "arraypolar {key}={bad}"); + } + for key in ["rows", "cols", "levels"] { + let mut p = json!({"associative": false}); + p[key] = bad.clone(); + assert!(s.execute("arrayrect", &p).is_err(), "arrayrect {key}={bad}"); + } + } + s.execute("arraypolar", &json!({"center": [0, 0], "count": 1, "associative": false})).unwrap(); +} From bcf0dbc5e8bfba025fb46344b826d2b8da9c960d Mon Sep 17 00:00:00 2001 From: Books Date: Fri, 30 Oct 2026 12:21:15 +1100 Subject: [PATCH 05/29] DWG: keep the current layer (CLAYER) through a save and reopen (#2252) (#2407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * DWG: keep the current layer (CLAYER) through a save and reopen (#2252) The DWG header stores the current layer as a handle, and the one read from the DXF needn't be the layer's in the document, so the drawing reopened on layer 0. The bridge now resolves CLAYER by name to the layer's handle before writing, like the current text and dimension styles. Test: the_current_layer_round_trips_through_dwg. * Parity checklist: regenerated after merging main * DWG: resolve $CLAYER through the layer table's Unicode-aware lookup (#2252) eq_ignore_ascii_case missed non-ASCII case differences such as Survey_Ä / Survey_ä, leaving layer 0 current. acadrust's Table::get normalises names with to_uppercase. * DWG: test a current layer named in other letter case (#2252) --- crates/dwg/src/lib.rs | 12 ++++++++++++ crates/io/tests/it/dwg_header_vars.rs | 28 +++++++++++++++++++++++++++ docs/parity-checklist.md | 4 ++-- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index b81bd00d1..8abb696b5 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -512,6 +512,17 @@ mod native { } } + /// The current layer (`$CLAYER`) by its name, the same way as [`current_dimstyle`]: a DWG + /// stores the handle of the layer, and the one read with the DXF needn't be the layer's in + /// the document, so the drawing reopened with layer "0" current (#2252). + fn current_layer(doc: &mut acadrust::CadDocument) { + let name = doc.header.current_layer_name.clone(); + // The table's own lookup compares Unicode case-insensitively ("Survey_Ä" = "Survey_ä"). + if let Some(layer) = doc.layers.get(&name) { + doc.header.current_layer_handle = layer.handle; + } + } + fn convert(dxf: &[u8], version: Option) -> Result, String> { // Reject a pathologically large DXF before handing it to the third-party decoder, which // allocates tables and section buffers from the input's declared sizes and cannot be @@ -543,6 +554,7 @@ mod native { current_dimstyle(&mut doc); add_variable_dictionary(&mut doc); current_text_style(&mut doc); + current_layer(&mut doc); std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc))) .map_err(|_| "the DWG writer failed on this drawing".to_string())? .map_err(|e| format!("DWG write: {e}")) diff --git a/crates/io/tests/it/dwg_header_vars.rs b/crates/io/tests/it/dwg_header_vars.rs index d1850278f..1bebee75e 100644 --- a/crates/io/tests/it/dwg_header_vars.rs +++ b/crates/io/tests/it/dwg_header_vars.rs @@ -59,3 +59,31 @@ fn header_settings_round_trip_through_dwg() { let r = cadcraft_io::read(&dwg, "x.dwg").unwrap(); check("DWG", &r); } + +/// The current layer (`$CLAYER`) survives a DWG save and reopen, ASCII or not. A DWG stores a +/// handle to the layer, and the writer used the null handle, so the drawing came back on "0" (#2252). +#[test] +fn the_current_layer_round_trips_through_dwg() { + for name in ["Survey_B", "Survey_\u{c4}"] { + let mut d = Drawing::new_metric(); + d.layers.push(cadcraft_doc::Layer::new(name)); + d.header.set_str("CLAYER", name); + let dxf = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap(); + assert_eq!(dxf.header.str("CLAYER", "?"), name, "DXF keeps it"); + let dwg = cadcraft_io::write(&d, "x.dwg").unwrap(); + let r = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + assert_eq!(r.header.str("CLAYER", "?"), name, "DWG"); + } +} + +/// `$CLAYER` naming the layer in other letter case still finds it: the DWG comes back on the +/// layer, under the layer's own spelling, not on "0" (#2252). +#[test] +fn a_current_layer_in_other_case_resolves_to_the_layer() { + let mut d = Drawing::new_metric(); + d.layers.push(cadcraft_doc::Layer::new("Survey_\u{c4}")); + d.header.set_str("CLAYER", "Survey_\u{e4}"); + let dwg = cadcraft_io::write(&d, "x.dwg").unwrap(); + let r = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + assert_eq!(r.header.str("CLAYER", "?"), "Survey_\u{c4}"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index a6250d5dc..ee0358f9e 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,10 +1,10 @@ # Parity checklist -> **Generated:** 2026-10-25 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-28 by `cargo xtask parity`. Do not edit by hand. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment. -**Menu breadth: 297 / 491 items (60%)** · 442 registered commands. +**Menu breadth: 298 / 491 items (61%)** · 444 registered commands. | Menu | Items | Covered | % | |---|---|---|---| From 9a1f200795f9b2618cf197b2bb2e8c6acbb833ea Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:21:20 +0700 Subject: [PATCH 06/29] xtask: the test suite and clippy pass on Windows (#2198) * xtask: the test suite and clippy pass on Windows Two Windows-only failures stopped `cargo xtask ci` before it got going on a Windows checkout (CI runs the gate on Linux only, so neither showed): - parity::tests::civil_dates built a SystemTime 115,740,740 days after the epoch (year 318857). Windows' SystemTime ends in the year 30828, so the addition panicked ("overflow when adding duration to `SystemTime`"). That case now checks `civil_from_days` on the day count, which is what it was about, on every platform. - stats.rs imported `walk` for a test that only exists on Unix, so the import was unused on Windows and `clippy -D warnings` refused it. The Unix test calls `super::walk`, as its neighbour calls `super::walk_inner`. Checked on Windows 11 x64: `cargo test --workspace` and `cargo clippy --workspace --all-targets -- -D warnings` both pass. Co-Authored-By: Claude Opus 5.5 * Contributors: add 2ez4gcx (Doan Huynh Huu Khuong) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- contributors/people.toml | 5 +++++ xtask/src/parity.rs | 4 +++- xtask/src/stats.rs | 4 ++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 40d6ba823..101a329ac 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -91,3 +91,8 @@ sync_github_name = false real_name = "Robert Lee" sync_github_name = true +[people.2ez4gcx] +real_name = "Doan Huynh Huu Khuong" +display_name = "Doan Huynh Huu Khuong" +sync_github_name = false + diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index 39f8e8713..8c174d290 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -136,7 +136,9 @@ mod tests { assert_eq!(day(20_736), "2026-10-10"); assert_eq!(date(std::time::UNIX_EPOCH - std::time::Duration::from_secs(86_400)), "1970-01-01"); // Past chrono's range keeps the proleptic Gregorian date, not the epoch (old `civil_from_days`). - assert_eq!(day(115_740_740), "318857-05-20"); + // Checked on the day count: Windows' SystemTime ends in the year 30828, so building that + // SystemTime panics there. + assert_eq!(civil_from_days(115_740_740), (318_857, 5, 20)); // Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists. if let Some(far) = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(u64::MAX / 2)) { assert_eq!(date(far), "292277026596-12-04"); diff --git a/xtask/src/stats.rs b/xtask/src/stats.rs index c2c6de3d2..6136de15a 100644 --- a/xtask/src/stats.rs +++ b/xtask/src/stats.rs @@ -134,7 +134,7 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> { #[cfg(test)] mod tests { - use super::{Counts, count_tests, walk}; + use super::{Counts, count_tests}; #[test] fn counts_attributes_and_proptest_blocks() { @@ -155,7 +155,7 @@ mod tests { // A directory symlink pointing back at `real` would be re-scanned if followed. symlink(&real, base.join("alias")).unwrap(); let mut c = Counts::default(); - walk(&base, &mut c).unwrap(); + super::walk(&base, &mut c).unwrap(); assert_eq!(c.files, 1, "the aliased directory was followed and counted twice"); std::fs::remove_dir_all(&base).ok(); } From 35b4332ee2968de75e7da2ec0a8661338acb77a7 Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:21:24 +0700 Subject: [PATCH 07/29] MEASURE picks its starting end by the curve's parameter, as AutoCAD does (#2207) * MEASURE picks its starting end by the curve's parameter, as AutoCAD does MEASURE started from whichever end was nearer the pick in a straight line, and a closed curve always ran forwards from its first vertex. AutoCAD (2024, black-box: synthetic objects in accoreconsole, every POINT read back with entget) decides by the curve parameter of the pick, the segment index plus the fraction along it: - an open curve is measured from its end once the pick is halfway or further: an L-shaped polyline picked at (10, 30) on its long leg (parameter 1.3 of 2) runs from the end, although that end is the farther one; a U-shape picked at (90, 0) (parameter 0.9 of 3) runs from the start, although the end is nearer; - a closed polyline runs from its first vertex, forwards before half its parameter and backwards from then on; - a circle always runs counterclockwise from angle 0. Chain::param_of gives that parameter. The new test divide_and_measure_match_autocad pins nine measured DIVIDE and MEASURE cases (lines, arcs, circles, open and closed polylines with an arc segment), every point to 1e-9. Co-Authored-By: Claude Opus 5.5 * MEASURE: a spline's starting end comes from its own parameter A spline's chain is a tessellation with zero-length pieces dropped, so its segment count is not the spline's parameter range: a degree-1 spline whose first span has zero length spends half its parameter there, and a pick at x = 3 on its 10-unit run (parameter 1.3 of 0..2, past the middle) was measured from the start because it is short of the drawn middle. Splines now compare spline_param_of with the middle of their domain; other curves keep Chain::param_of. Pointed out in review. Co-Authored-By: Claude Opus 5.5 * MEASURE: polylines count coincident vertices in their parameter; ellipses pinned AutoCAD's polyline parameter is the vertex index, so a coincident vertex still takes one unit of the range. The chain drops zero-length spans, so its segment index and count shifted the middle: (0,0) (1,0) (101,0) (101,0) picked at x = 1.1 (parameter 1.001 of 3) was measured from the end, and (0,0) (0,0) (10,0) (20,0) picked at x = 7 (1.7 of 3) from the start. LWPOLYLINEs and 3D polylines now take the parameter from their source vertices (curves::vertex_param_of). Pointed out in review. Ellipses keep the parameter rule, which AutoCAD follows for them too: a full ellipse picked at t = pi and an elliptical arc picked past its middle both run from the end in AutoCAD 2024 (MEASURE-032 and -033 of the measured set; the other direction is 200 and 4 units off). The new test pins both, within the 1e-4 tessellation CADCraft measures along. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/curves.rs | 36 ++++ crates/engine/src/cmd/draw2/divide.rs | 40 +++- crates/engine/src/cmd/draw2/tests.rs | 273 ++++++++++++++++++++++++++ 3 files changed, 343 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/curves.rs b/crates/engine/src/cmd/curves.rs index 7fe05239d..2ef2e24f2 100644 --- a/crates/engine/src/cmd/curves.rs +++ b/crates/engine/src/cmd/curves.rs @@ -479,6 +479,19 @@ impl Chain { } best.1 } + /// The curve parameter of the point on the chain nearest `p`: the index of its segment plus + /// the fraction along it (AutoCAD's polyline parameter; 0..1 along a line or an arc). + pub fn param_of(&self, p: Vec2) -> f64 { + let mut best = (f64::INFINITY, 0.0); + for (i, s) in self.segs.iter().enumerate() { + let c = s.closest(p); + let dd = c.dist(p); + if dd < best.0 { + best = (dd, i as f64 + seg_param(s, c)); + } + } + best.1 + } /// Reversed copy. pub fn reversed(&self) -> Chain { Chain { segs: self.segs.iter().rev().map(Segment::reversed).collect(), closed: self.closed } @@ -506,6 +519,29 @@ impl Chain { } } +/// The polyline parameter of the point nearest `p` on spans `(start, end, bulge)` taken from the +/// source vertices: the span's index plus the fraction along it. Unlike [`Chain::param_of`] it +/// counts zero-length spans, which a polyline's parameter range includes (one per vertex) but its +/// chain drops. +pub fn vertex_param_of(spans: &[(Vec2, Vec2, f64)], p: Vec2) -> f64 { + let mut best = (f64::INFINITY, 0.0); + for (i, &(a, b, bulge)) in spans.iter().enumerate() { + if a.near(b, EPS) { + continue; + } + let seg = match cadcraft_geom::bulge_to_arc(a, b, bulge) { + Some((arc, ccw)) => Segment::Arc { arc, ccw }, + None => Segment::Line(Line::new(a, b)), + }; + let c = seg.closest(p); + let dd = c.dist(p); + if dd < best.0 { + best = (dd, i as f64 + seg_param(&seg, c)); + } + } + best.1 +} + /// Parameter (0..1 along travel) of a point on a segment. pub fn seg_param(s: &Segment, p: Vec2) -> f64 { match s { diff --git a/crates/engine/src/cmd/draw2/divide.rs b/crates/engine/src/cmd/draw2/divide.rs index 6b6ea28e3..65e7ab06c 100644 --- a/crates/engine/src/cmd/draw2/divide.rs +++ b/crates/engine/src/cmd/draw2/divide.rs @@ -74,12 +74,40 @@ pub(super) fn run_measure(s: &mut Session, p: &Value) -> Result { } let block = str_param(p, "block").map(str::to_string); check_block(s, block.as_deref())?; - if let Some(from) = point_param(p, "from") - && !c.closed - && let (Some(a), Some(b)) = (c.start(), c.end()) - && from.dist(b) < from.dist(a) - { - c = c.reversed(); + // AutoCAD measures from the end the pick is nearer by the curve's parameter (segment index + // plus the fraction along it), not by distance: an open curve from its end once the pick is + // halfway or further, a closed one from its first vertex, backwards from halfway on. A + // circle always runs counterclockwise from angle 0. + if let Some(from) = point_param(p, "from") { + let past_half = match curves::entity(s, h)?.kind { + EntityKind::Circle(_) => false, + // A spline by its own parameter: its tessellation drops zero-length pieces, so the + // segment count is not the spline's parameter range. + EntityKind::Spline(sp) => { + let (lo, hi) = sp.domain(); + curves::spline_param_of(&sp, from) >= (lo + hi) / 2.0 + } + // Polylines by their vertices, coincident ones included (one parameter unit each, + // as in AutoCAD), which the chain drops. + EntityKind::LwPolyline(pl) => { + let n = pl.vertices.len(); + let spans: Vec<_> = (0..if pl.closed { n } else { n.saturating_sub(1) }) + .filter_map(|i| Some((pl.vertices.get(i)?.p, pl.vertices.get((i + 1) % n)?.p, pl.vertices.get(i)?.bulge))) + .collect(); + curves::vertex_param_of(&spans, from) >= spans.len() as f64 / 2.0 + } + EntityKind::Polyline3d(pl) => { + let n = pl.points.len(); + let spans: Vec<_> = (0..if pl.closed { n } else { n.saturating_sub(1) }) + .filter_map(|i| Some((pl.points.get(i)?.xy(), pl.points.get((i + 1) % n)?.xy(), 0.0))) + .collect(); + curves::vertex_param_of(&spans, from) >= spans.len() as f64 / 2.0 + } + _ => c.param_of(from) >= c.segs.len() as f64 / 2.0, + }; + if past_half { + c = c.reversed(); + } } let pts = measure_points(&c, step); let hs = place_markers(s, &pts, block.as_deref(), bool_or(p, "align", true))?; diff --git a/crates/engine/src/cmd/draw2/tests.rs b/crates/engine/src/cmd/draw2/tests.rs index a28d92b4d..b5585e381 100644 --- a/crates/engine/src/cmd/draw2/tests.rs +++ b/crates/engine/src/cmd/draw2/tests.rs @@ -302,6 +302,279 @@ fn divide_and_measure_json() { assert!(s.execute("measure", &json!({"handle": l, "length": 1e-300})).is_err()); } +/// DIVIDE and MEASURE against the points AutoCAD 2024 places for the same object and pick +/// (black-box: synthetic objects in accoreconsole, `(command "_.MEASURE" (list ename point) …)`, +/// every POINT read back with `entget`). The pick chooses the end by the curve's parameter, not +/// by distance; a closed polyline runs from its first vertex, a circle from angle 0. +#[test] +fn divide_and_measure_match_autocad() { + type Case = ((&'static str, serde_json::Value), &'static str, f64, [f64; 2], usize, &'static [[f64; 2]]); + // ((seed command, params), command, segments or length, pick, POINT count, AutoCAD points) + let cases: Vec = vec![ + // DIVIDE-001: (list ename point). + ( + ("arc", json!({"center": [0.0, 0.0], "radius": 20.0, "start": 30.0, "end": 250.0})), + "divide", + 5.0, + [-2.090569265353071, 19.890437907365467], + 4, + &[ + [-19.021130325903, 6.180339887499], + [-17.975880925983, -8.767422935782], + [-9.389431255718, 17.658951857179], + [5.51274711634, 19.225233918766], + ], + ), + // DIVIDE-002: (list ename point). + ( + ("circle", json!({"center": [0.0, 0.0], "radius": 15.0})), + "divide", + 5.0, + [-4.63525491562421, 14.265847744427305], + 5, + &[ + [-12.135254915624, -8.816778784387], + [-12.135254915624, 8.816778784387], + [4.635254915624, -14.265847744427], + [4.635254915624, 14.265847744427], + [15.0, 0.0], + ], + ), + // MEASURE-016: (list ename point). + ( + ("line", json!({"points": [[0.0, 0.0], [37.0, 13.0]]})), + "measure", + 7.688652713279647, + [25.9, 9.1], + 5, + &[ + [0.730313187001, 0.256596525162], + [7.984250549601, 2.80527722013], + [15.2381879122, 5.353957915097], + [22.4921252748, 7.902638610065], + [29.7460626374, 10.451319305032], + ], + ), + // MEASURE-024: (list ename point). + ( + ( + "pline", + json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [100.0, 0.0], "bulge": 0.0}, {"p": [100.0, 10.0], "bulge": 0.0}, {"p": [5.0, 10.0], "bulge": 0.0}], "closed": false}), + ), + "measure", + 7.0, + [90.0, 0.0], + 29, + &[[7.0, 0.0], [7.0, 10.0], [14.0, 0.0], [98.0, 0.0], [98.0, 10.0], [100.0, 5.0]], + ), + // MEASURE-026: (list ename point). + ( + ( + "pline", + json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [10.0, 0.0], "bulge": 0.0}, {"p": [10.0, 100.0], "bulge": 0.0}], "closed": false}), + ), + "measure", + 7.0, + [10.0, 30.0], + 15, + &[[5.0, 0.0], [10.0, 2.0], [10.0, 9.0], [10.0, 79.0], [10.0, 86.0], [10.0, 93.0]], + ), + // MEASURE-030: (list ename point). + ( + ( + "pline", + json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [10.0, 35.0], "bulge": 0.0}], "closed": true}), + ), + "measure", + 7.5, + [5.0, 17.5], + 17, + &[ + [2.06040845923, 7.211429607306], + [4.120816918461, 14.422859214612], + [5.957390538273, 0.0], + [41.854257779673, 5.445746712402], + [45.137412221497, 19.497902817843], + [45.215233654661, 12.07010340037], + ], + ), + // DIVIDE-028: (list ename point). + ( + ( + "pline", + json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [10.0, 35.0], "bulge": 0.0}], "closed": true}), + ), + "divide", + 5.0, + [37.5, 10.0], + 5, + &[ + [0.0, 0.0], + [7.332712970983, 25.664495398441], + [25.609294779385, 28.310302237406], + [26.691478107655, 0.0], + [45.58190593662, 14.465330916891], + ], + ), + // MEASURE-041: (list ename point). + ( + ( + "pline", + json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [10.0, 35.0], "bulge": 0.0}], "closed": true}), + ), + "measure", + 7.5, + [45.54951565955276, 17.15901034980981], + 17, + &[ + [1.636621047999, 5.728173667998], + [3.69702950723, 12.939603275304], + [5.75743796646, 20.15103288261], + [42.799868254703, 6.663750217618], + [44.060611317761, 20.402595149531], + [45.491536690495, 13.587129550966], + ], + ), + // MEASURE-042: (list ename point). + ( + ( + "pline", + json!({"vertices": [{"p": [0.0, 0.0], "bulge": 0.0}, {"p": [30.0, 0.0], "bulge": 0.5}, {"p": [45.0, 20.0], "bulge": 0.0}, {"p": [10.0, 35.0], "bulge": 0.0}], "closed": true}), + ), + "measure", + 7.5, + [44.0, 20.5], + 17, + &[ + [2.06040845923, 7.211429607306], + [4.120816918461, 14.422859214612], + [5.957390538273, 0.0], + [41.854257779673, 5.445746712402], + [45.137412221497, 19.497902817843], + [45.215233654661, 12.07010340037], + ], + ), + ]; + for ((seed, params), what, value, pick, count, want) in cases { + let mut s = Session::new(); + let r = s.execute(seed, ¶ms).unwrap(); + let handle = r.get("handle").or_else(|| r["handles"].get(0)).and_then(serde_json::Value::as_str).unwrap().to_string(); + // AutoLISP's (list ename point): the object and where it was picked. + let params = if what == "divide" { + json!({"handle": handle, "segments": value as u64, "from": pick}) + } else { + json!({"handle": handle, "length": value, "from": pick}) + }; + s.execute(what, ¶ms).unwrap(); + let pts: Vec = + s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Point(p) = &e.kind { Some(p.p.xy()) } else { None }).collect(); + assert_eq!(pts.len(), count, "{what} {pick:?}: {pts:?}"); + for w in want { + let tol = 1e-9 * w[0].abs().max(w[1].abs()).max(1.0); + assert!(pts.iter().any(|p| p.near(Vec2::new(w[0], w[1]), tol)), "{what} {pick:?}: no point at {w:?} in {pts:?}"); + } + } +} + +/// A spline's end is chosen by its own parameter: this one spends half its parameter range on a +/// zero-length first span, so a pick at x = 3 is already past its middle (parameter 1.3 of 0..2) and +/// MEASURE runs from the end, though x = 3 is short of the middle of the drawn length. +#[test] +fn measure_spline_by_its_own_parameter() { + let mut s = Session::new(); + let sp = h(&s.execute("spline", &json!({"control": [[0, 0], [0, 0], [10, 0]], "degree": 1})).unwrap()); + s.execute("measure", &json!({"handle": sp, "length": 3, "from": [3, 0]})).unwrap(); + let pts: Vec = + s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Point(p) = &e.kind { Some(p.p.xy()) } else { None }).collect(); + assert!(pts.len() == 3 && [7.0, 4.0, 1.0].iter().all(|x| pts.iter().any(|p| near(*p, *x, 0.0))), "{pts:?}"); +} + +/// MEASURE counts every vertex in a polyline's parameter, coincident ones too (AutoCAD's polyline +/// parameter is the vertex index; the chain drops zero-length spans), and picks an ellipse's end +/// by the ellipse parameter like any other curve: AutoCAD 2024 runs both measured cases below from +/// the end (black-box, accoreconsole, POINTs read back with `entget`). +#[test] +fn measure_counts_coincident_vertices_and_runs_ellipses_by_parameter() { + let pts = |s: &Session| -> Vec { + s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Point(p) = &e.kind { Some(p.p.xy()) } else { None }).collect() + }; + // (0,0) (1,0) (101,0) (101,0) picked at x = 1.1: parameter 1.001 of 3, before the middle, so + // from the start (the chain alone, 1.001 of 2, ran it from the end). + let mut s = Session::new(); + let pl = h(&s.execute("pline", &json!({"vertices": [[0, 0], [1, 0], [101, 0], [101, 0]]})).unwrap()); + s.execute("measure", &json!({"handle": pl, "length": 30, "from": [1.1, 0]})).unwrap(); + assert!(pts(&s).iter().any(|p| near(*p, 30.0, 0.0)), "{:?}", pts(&s)); + // (0,0) (0,0) (10,0) (20,0) picked at x = 7: parameter 1.7 of 3, past the middle, so from the + // end; the same as a 3D polyline. + for (cmd, params) in [ + ("pline", json!({"vertices": [[0, 0], [0, 0], [10, 0], [20, 0]]})), + ("3dpoly", json!({"points": [[0, 0, 0], [0, 0, 0], [10, 0, 0], [20, 0, 0]]})), + ] { + let mut s = Session::new(); + let pl = h(&s.execute(cmd, ¶ms).unwrap()); + s.execute("measure", &json!({"handle": pl, "length": 3, "from": [7, 0]})).unwrap(); + assert!(pts(&s).iter().any(|p| near(*p, 17.0, 0.0)), "{cmd}: {:?}", pts(&s)); + } + // AutoCAD's points. CADCraft measures along a tessellation (1e-4 of the major axis), so its + // points sit within 2e-4 of the axis of AutoCAD's; the other end is 4 to 200 units away. + type Ellipse = ([f64; 2], [f64; 2], f64, f64, f64); + let cases: [((Ellipse, f64, [f64; 2]), &[[f64; 2]]); 2] = [ + // MEASURE-032-TO: a full ellipse picked at t = pi. + ( + (([-35000.0, 81000.0], [1000.0, 250.0], 0.5, 0.0, std::f64::consts::TAU), 325.0, [-36000.0, 80750.0]), + &[ + [-36005.13895764338, 80852.59066530415], + [-35871.99367437639, 80574.74515309861], + [-35846.48143804769, 81129.79467385833], + [-35585.0606965763, 81321.20806711384], + [-35570.69066464184, 80460.16118260568], + [-35288.01208649818, 81451.990340526], + [-35246.80459288685, 80443.34080344986], + [-34973.3365764063, 81531.8855660685], + [-34925.28109250207, 80488.09750605426], + [-34649.89308676404, 81558.94977503068], + [-34615.08169799787, 80583.88008545575], + [-34328.55263503237, 81517.06267451325], + [-34326.49236487653, 80732.20222770158], + [-34085.24055588816, 80947.67206458953], + [-34052.78599900089, 81354.8654746607], + ], + ), + // MEASURE-033: an elliptical arc picked past its middle. + ( + (([0.0, 0.0], [20.0, 5.0], 0.5, 0.5, 4.0), 4.25, [-14.28970951093154, -10.073417468999274]), + &[ + [-20.14815072114992, -3.432193431111516], + [-18.74987859459168, -7.329883069345779], + [-18.68559214389327, 0.5029121733397516], + [-15.81338990685036, 3.617741906558092], + [-15.26656288688172, -9.70206428611582], + [-12.33992351852055, 6.057945492888583], + [-8.547503008323163, 7.9699830580747], + [-4.557798848614094, 9.428419587410612], + [-0.4368018715850841, 10.45983604769949], + [3.769519107506058, 11.05284388252232], + [8.015496424961295, 11.15319778065308], + [12.22912105170455, 10.6383262710487], + [16.23125506912425, 9.247174221779819], + ], + ), + ]; + for (((center, major, ratio, t0, t1), length, from), want) in cases { + let mut s = Session::new(); + let e = h(&s + .execute("ellipse", &json!({"center": center, "major": major, "ratio": ratio, "start": t0.to_degrees(), "end": t1.to_degrees()})) + .unwrap()); + s.execute("measure", &json!({"handle": e, "length": length, "from": from})).unwrap(); + let got = pts(&s); + let tol = 2e-4 * major[0].hypot(major[1]); + assert_eq!(got.len(), want.len(), "{got:?}"); + for w in want { + assert!(got.iter().any(|p| p.dist(Vec2::new(w[0], w[1])) < tol), "no point near {w:?} in {got:?}"); + } + } +} + #[test] fn divide_and_measure_command_line() { let mut s = Session::new(); From 3ccfad60dca364fd9d44c0daf5168b66143263a9 Mon Sep 17 00:00:00 2001 From: Coast Date: Thu, 29 Oct 2026 19:21:27 -0600 Subject: [PATCH 08/29] Block Editor: BEDIT inside it switches to another block, asking to save first (#2219) * Block Editor: BEDIT inside it switches to another block, asking to save first BEDIT (typed, Edit Block, Tools > Block Editor) no longer refuses while the Block Editor is open: the Edit Block Definition dialog opens on the block being edited, and choosing another block switches to it. With changes not saved yet it first asks whether to save them (Yes saves, No discards, Cancel stays), at the command line as BCLOSE does and in the dialog. The JSON form takes `save: true|false` and refuses to switch away from unsaved changes without it; the block to switch to is checked before anything is saved or closed. Co-Authored-By: Claude Opus 5.5 * Block Editor: the save question is one translatable sentence The block name and punctuation were added around a translated fragment, so a translation couldn't place the name or use its own quotation marks. Co-Authored-By: Claude Opus 5.5 * BEDIT: a block the Block Editor can't switch to is refused before the save question --------- Co-authored-by: Cody Vogt <18035288+codyvogt@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/blocks/bedit.rs | 122 +++++++++++++++++++----- crates/engine/tests/block_editor.rs | 129 +++++++++++++++++++++++++- crates/ui-egui/src/bedit.rs | 81 ++++++++++++++-- crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/tests/bedit_dialog.rs | 67 ++++++++++++- docs/gaps.md | 5 +- 6 files changed, 370 insertions(+), 35 deletions(-) diff --git a/crates/engine/src/cmd/blocks/bedit.rs b/crates/engine/src/cmd/blocks/bedit.rs index 5b5d0b1cc..c570fa107 100644 --- a/crates/engine/src/cmd/blocks/bedit.rs +++ b/crates/engine/src/cmd/blocks/bedit.rs @@ -2,6 +2,8 @@ //! drawing's own tab, its base point at the origin; the block's objects are edited with the //! ordinary commands; BSAVE saves them into the definition, so every reference to the block in the //! drawing shows the change; BCLOSE goes back to the drawing, saving or discarding what isn't saved. +//! BEDIT while the Block Editor is open switches it to another block: changes not saved yet are +//! saved or discarded first (asked at the command line; `save` in JSON). //! The state is [`crate::blockedit`]. use cadcraft_doc::{Drawing, EntityKind, Handle}; @@ -16,10 +18,9 @@ pub(super) fn specs() -> Vec { CommandSpec::new("bedit", "Block Editor", run_bedit) .menu(&["Tools", "Block Editor"]) .alias(&["be", "-bedit"]) - .params("{name? (\"\": the drawing itself, a dynamic block's own drawing) | handle? (a block reference: edits its block); default the one selected block reference, create?: bool (a name no block has yet starts a new block; BSAVE creates it)} → {block, new}") - .enabled(not_editing) + .params("{name? (\"\": the drawing itself, a dynamic block's own drawing) | handle? (a block reference: edits its block); default the one selected block reference, create?: bool (a name no block has yet starts a new block; BSAVE creates it), save?: bool (in the Block Editor, switching to another block: save (true) or discard (false) the changes not saved yet; required when there are some), check?: bool (in the Block Editor: only check that the block can be switched to)} → {block, new, saved?}") .noundo() - .interactive(|_| Ok(Box::new(BeditM))), + .interactive(|_| Ok(Box::new(BeditM::default()))), CommandSpec::new("bsave", "Save Block", run_bsave).params("{} → {block}").enabled(editing).noundo(), CommandSpec::new("bsaveas", "Save Block As", run_bsaveas) .params("{name, replace?: bool (redefine an existing block of that name)} → {block}") @@ -53,16 +54,6 @@ fn editing_a_block(s: &Session) -> std::result::Result<(), String> { } } -fn not_editing(s: &Session) -> std::result::Result<(), String> { - match s.state() { - Ok(st) => match st.block_editor() { - Some(name) => Err(format!("The Block Editor is already open on \"{name}\"; close it first (BCLOSE).")), - None => Ok(()), - }, - Err(e) => Err(e.to_string()), - } -} - /// Commands that leave model space, which the Block Editor is: not available while it is open. const NOT_IN_EDITOR: &[&str] = &[ "layout", @@ -106,8 +97,53 @@ fn dynamic_definition(d: &Drawing, name: &str) -> Option { (kind == "BLOCK_RECORD").then(|| d.block(block).map(|b| b.name.clone())).flatten() } -/// Open the Block Editor on block `name` and frame its objects. -fn open(s: &mut Session, name: &str, create: bool) -> Result { +/// Whether the open Block Editor can switch to block `name`: the block is there (or `create` makes +/// it) and isn't one the editor refuses. Checked before anything is saved or closed, or asked. +fn check_target(s: &Session, name: &str, create: bool) -> Result<()> { + let host = s.state()?.block_edit.as_ref().map(|b| b.host.doc.clone()).ok_or_else(|| bad("bedit", "the Block Editor is not open"))?; + if name.trim().eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING) { + return Ok(()); + } + match host.block(name) { + None if !create => Err(bad("bedit", format!("Block \"{name}\" not found."))), + None => Ok(()), + Some(_) => crate::blockedit::refusal(&host, name).map_or(Ok(()), |why| Err(bad("bedit", why))), + } +} + +/// Open the Block Editor on block `name` and frame its objects. While it is open on another +/// block it switches: the changes not saved yet are saved first with `save` Some(true), +/// discarded with Some(false); with None there must be none. The block to switch to is checked +/// before anything is saved or closed, so a refused switch leaves the editor as it was. +fn open(s: &mut Session, name: &str, create: bool, save: Option) -> Result { + let mut saved = None; + if let Some(current) = s.state()?.block_editor().map(str::to_string) { + if current.eq_ignore_ascii_case(name.trim()) { + s.echo(format!("The Block Editor is already editing \"{current}\".")); + return Ok(json!({ "block": current, "new": false })); + } + check_target(s, name, create)?; + let save = match (s.state()?.block_unsaved(), save) { + (false, _) => false, + (true, Some(save)) => save, + (true, None) => { + return Err(bad( + "bedit", + format!("Block \"{current}\" has changes not saved; pass `save: true` to save them or `save: false` to discard them"), + )); + } + }; + saved = close(s, save)?.get("saved").cloned(); + } + let mut r = open_closed(s, name, create)?; + if let (Some(v), Some(o)) = (saved, r.as_object_mut()) { + o.insert("saved".into(), v); + } + Ok(r) +} + +/// [`open`] with the Block Editor closed. +fn open_closed(s: &mut Session, name: &str, create: bool) -> Result { let new = create && !name.eq_ignore_ascii_case(crate::blockedit::CURRENT_DRAWING) && s.doc()?.block(name).is_none(); s.state_mut()?.open_block_editor(name, create)?; let name = s.state()?.block_editor().unwrap_or(name).to_string(); @@ -153,7 +189,15 @@ fn run_bedit(s: &mut Session, p: &Value) -> Result { if create && !current && s.doc()?.block(name).is_none() && !super::valid_block_name(name) { return Err(bad("bedit", format!("\"{name}\" is not a valid block name"))); } - open(s, name, create) + if p.get("save").is_some_and(|v| !v.is_null() && !v.is_boolean()) { + return Err(bad("bedit", "`save` must be true or false")); + } + // Only whether the Block Editor, when open, can switch to the block: nothing opens or closes. + if bool_or(p, "check", false) && s.state()?.block_editor().is_some() { + check_target(s, name, create)?; + return Ok(json!({ "block": name, "checked": true })); + } + open(s, name, create, p.get("save").and_then(Value::as_bool)) } fn run_bsave(s: &mut Session, _p: &Value) -> Result { @@ -206,7 +250,27 @@ fn run_bclose(s: &mut Session, p: &Value) -> Result { // ---------------- interactive ---------------- /// BEDIT: the selected block reference's block, else a block name typed or a reference picked. -struct BeditM; +/// In the Block Editor with changes not saved yet, it asks whether to save them before switching. +#[derive(Default)] +struct BeditM { + /// The block to switch to, waiting for the answer to "Save changes?". + pending: Option, +} + +impl BeditM { + /// Go to block `name`: at once, or after asking about the changes not saved yet. + fn go(&mut self, s: &mut Session, name: &str) -> Result { + let st = s.state()?; + let other = st.block_editor().is_some_and(|b| !b.eq_ignore_ascii_case(name.trim())); + if other && st.block_unsaved() { + // A block it can't switch to is refused before the question, which would trap the user. + check_target(s, name.trim(), false)?; + self.pending = Some(name.trim().to_string()); + return Ok(Step::Continue); + } + open(s, name, false, Some(false)).map(|_| Step::Done) + } +} impl Interactive for BeditM { fn name(&self) -> &'static str { @@ -214,14 +278,30 @@ impl Interactive for BeditM { } fn begin(&mut self, s: &mut Session) -> Result { match selected_reference(s) { - Some(name) => open(s, &name, false).map(|_| Step::Done), + Some(name) => self.go(s, &name), None => Ok(Step::Continue), } } - fn prompt(&self, _s: &Session) -> Prompt { + fn prompt(&self, s: &Session) -> Prompt { + if self.pending.is_some() { + let name = s.state().ok().and_then(|st| st.block_editor().map(str::to_string)).unwrap_or_default(); + return Prompt::new(format!("Save changes to block \"{name}\"?"), KW).kw(&["Yes", "No"]).default("Y"); + } Prompt::new("Enter block name or select a block reference [?]", Accept { point: true, number: false, text: true, select: true, enter: true }) } fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(name) = self.pending.clone() { + let answer = match i { + Input::Enter => "Y".to_string(), + Input::Keyword(k) | Input::Text(k) => k, + _ => return Ok(Step::Continue), + }; + return match answer.trim().chars().next().map(|c| c.to_ascii_uppercase()) { + Some('Y') => open(s, &name, false, Some(true)).map(|_| Step::Done), + Some('N') => open(s, &name, false, Some(false)).map(|_| Step::Done), + _ => Err(EngineError::Other("Enter Yes or No.".into())), + }; + } match i { Input::Text(t) if t.trim() == "?" => { let names: Vec = { @@ -231,14 +311,14 @@ impl Interactive for BeditM { s.echo(if names.is_empty() { "No blocks to edit.".to_string() } else { names.join(", ") }); Ok(Step::Continue) } - Input::Text(t) | Input::Keyword(t) => open(s, t.trim(), false).map(|_| Step::Done), + Input::Text(t) | Input::Keyword(t) => self.go(s, t.trim()), Input::Pick(hs) => { let name = { let d = s.doc()?; hs.iter().find_map(|h| block_of(d, *h)) }; match name { - Some(n) => open(s, &n, false).map(|_| Step::Done), + Some(n) => self.go(s, &n), None => Err(EngineError::Other("Select a block reference.".into())), } } diff --git a/crates/engine/tests/block_editor.rs b/crates/engine/tests/block_editor.rs index 711bcd796..4eb8a2a2f 100644 --- a/crates/engine/tests/block_editor.rs +++ b/crates/engine/tests/block_editor.rs @@ -140,7 +140,8 @@ fn what_the_block_editor_refuses() { assert!(s.execute("bclose", &json!({})).is_err(), "not open"); s.execute("bedit", &json!({ "name": "Door" })).unwrap(); - assert!(s.execute("bedit", &json!({ "name": "Door" })).is_err(), "already open"); + s.execute("bedit", &json!({ "name": "DOOR" })).unwrap(); + assert_eq!(editor(&s).as_deref(), Some("Door"), "the block already open stays open"); let r = s.execute("layout.set", &json!({ "name": "Layout1" })); assert!(r.is_err_and(|e| e.to_string().contains("Block Editor"))); assert_eq!(s.layout_space(), cadcraft_engine::doc::Space::Model); @@ -370,3 +371,129 @@ fn the_current_drawing_opens_in_the_block_editor() { s.undo().unwrap(); assert_eq!(s.doc().unwrap().model.len(), 1); } + +/// BEDIT in the Block Editor switches to another block, as AutoCAD's does: changes not saved yet +/// are saved or discarded first (`save`), and a block it can't open leaves the editor as it was. +#[test] +fn bedit_in_the_block_editor_switches_blocks() { + let mut s = Session::new(); + door(&mut s); + let l = line(&mut s, [0.0, 50.0], [5.0, 50.0]); + s.execute("block", &json!({ "name": "Frame", "base": [0, 50], "handles": [l] })).unwrap(); + let model = s.doc().unwrap().model.len(); + + s.execute("bedit", &json!({ "name": "Door" })).unwrap(); + line(&mut s, [0.0, 0.0], [0.0, 3.0]); + // Changes not saved: JSON must say what to do with them. + let e = s.execute("bedit", &json!({ "name": "Frame" })).unwrap_err().to_string(); + assert!(e.contains("save"), "{e}"); + assert_eq!(editor(&s).as_deref(), Some("Door")); + assert!(s.state().unwrap().block_unsaved(), "nothing was lost"); + // A block that isn't there, or `save` that isn't a bool: refused, nothing changes. + assert!(s.execute("bedit", &json!({ "name": "Nope", "save": true })).is_err()); + assert!(s.execute("bedit", &json!({ "name": "Frame", "save": "yes" })).is_err()); + assert_eq!(editor(&s).as_deref(), Some("Door")); + assert!(s.state().unwrap().block_unsaved()); + + // Saved, then switched. + let r = s.execute("bedit", &json!({ "name": "frame", "save": true })).unwrap(); + assert_eq!((r["block"].as_str(), r["saved"].as_bool()), (Some("Frame"), Some(true))); + assert_eq!(editor(&s).as_deref(), Some("Frame")); + assert_eq!(lines_of(&s, "Door").len(), 2, "Door keeps the new line"); + + // Discarded, then switched; with nothing to save `save` isn't needed. + line(&mut s, [1.0, 1.0], [2.0, 2.0]); + s.execute("bedit", &json!({ "name": "Door", "save": false })).unwrap(); + assert_eq!(lines_of(&s, "Frame").len(), 1, "Frame's change was discarded"); + s.execute("bedit", &json!({ "name": "Frame" })).unwrap(); + assert_eq!(editor(&s).as_deref(), Some("Frame")); + // A new block, and the drawing itself. + s.execute("bedit", &json!({ "name": "Gate", "create": true })).unwrap(); + assert_eq!(editor(&s).as_deref(), Some("Gate")); + s.execute("bedit", &json!({ "name": "" })).unwrap(); + s.execute("bclose", &json!({ "save": false })).unwrap(); + assert!(editor(&s).is_none()); + assert_eq!(s.doc().unwrap().model.len(), model, "the drawing as it was"); +} + +/// A block BEDIT can't switch to is refused before it asks about the changes not saved, so the +/// question never traps the user; `check` asks the same of JSON callers. +#[test] +fn bedit_refuses_a_bad_target_before_asking_to_save() { + let mut s = Session::new(); + door(&mut s); + s.execute("bedit", &json!({ "name": "Door" })).unwrap(); + line(&mut s, [1.0, 1.0], [2.0, 2.0]); + assert!(s.state().unwrap().block_unsaved()); + s.script( + "BEDIT +Nope +", + ) + .unwrap(); + let message = s.current_prompt().map(|p| p.message).unwrap_or_default(); + assert!(!message.contains("Save changes"), "no question for a block it can't open: {message}"); + s.input(cadcraft_engine::Input::Cancel).unwrap(); + assert_eq!(editor(&s).as_deref(), Some("Door")); + assert!(s.state().unwrap().block_unsaved(), "nothing lost"); + // The check, without switching or saving. + assert!(s.execute("bedit", &json!({ "name": "Nope", "check": true })).is_err()); + assert!(s.execute("bedit", &json!({ "name": "Nope", "create": true, "check": true })).is_ok(), "a new name is fine"); + assert_eq!(editor(&s).as_deref(), Some("Door")); + assert!(s.state().unwrap().block_unsaved()); +} + +/// Typed, BEDIT in the Block Editor asks whether to save the changes before it switches. +#[test] +fn bedit_typed_in_the_block_editor_asks_to_save_first() { + let mut s = Session::new(); + door(&mut s); + let l = line(&mut s, [0.0, 50.0], [5.0, 50.0]); + s.execute("block", &json!({ "name": "Frame", "base": [0, 50], "handles": [l] })).unwrap(); + s.execute("bedit", &json!({ "name": "Door" })).unwrap(); + + // Nothing changed: it switches at once. + s.script( + "BEDIT +Frame +", + ) + .unwrap(); + assert!(s.running.is_none()); + assert_eq!(editor(&s).as_deref(), Some("Frame")); + + line(&mut s, [1.0, 1.0], [2.0, 2.0]); + s.script( + "BEDIT +Door +", + ) + .unwrap(); + assert_eq!(s.current_prompt().unwrap().message, "Save changes to block \"Frame\"?"); + // Escape keeps the editor on Frame with its change. + s.input(cadcraft_engine::Input::Cancel).unwrap(); + assert_eq!(editor(&s).as_deref(), Some("Frame")); + assert!(s.state().unwrap().block_unsaved()); + + s.script( + "BEDIT +Door +Y +", + ) + .unwrap(); + assert!(s.running.is_none()); + assert_eq!(editor(&s).as_deref(), Some("Door")); + assert_eq!(lines_of(&s, "Frame").len(), 2, "saved before switching"); + + line(&mut s, [3.0, 3.0], [4.0, 4.0]); + s.script( + "BEDIT +Frame +N +", + ) + .unwrap(); + assert_eq!(editor(&s).as_deref(), Some("Frame")); + assert_eq!(lines_of(&s, "Door").len(), 1, "discarded"); +} diff --git a/crates/ui-egui/src/bedit.rs b/crates/ui-egui/src/bedit.rs index 6137d5ccc..fa51656ac 100644 --- a/crates/ui-egui/src/bedit.rs +++ b/crates/ui-egui/src/bedit.rs @@ -1,7 +1,9 @@ //! The Edit Block Definition dialog: BEDIT typed or started from a menu or the Tool Sets with no //! block reference selected. It lists the drawing's blocks with a preview and description; a name //! no block has yet starts a new block. OK runs BEDIT with that name; the dialog itself never -//! changes the drawing. JSON calls (`bedit {name}`) and a selected block reference skip it. +//! changes the drawing. In the Block Editor it switches to the block chosen, first asking whether +//! to save the changes not saved yet. JSON calls (`bedit {name}`) and a selected block reference +//! skip it. use std::sync::Arc; @@ -24,6 +26,8 @@ pub struct State { pub name: String, /// Why the last OK failed, shown until the name changes. pub error: Option, + /// OK in the Block Editor with changes not saved: asking whether to save them first. + pub ask_save: bool, /// The preview of the block named, for (drawing uid, revision, block name). preview: Option<((u64, u64, String), Arc)>, } @@ -86,22 +90,19 @@ pub fn authoring_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) } /// BEDIT typed or from a menu without parameters opens the dialog, unless exactly one block -/// reference is selected (it opens at once) or the Block Editor is open (BEDIT says why not). +/// reference is selected (it opens at once). In the Block Editor it starts on the block edited. pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option> { if !matches!(id, "bedit" | "be") || !params.is_null() { return None; } let st = app.session.state().ok()?; - if st.block_edit.is_some() { - return None; - } let refs = st.selection.iter().filter(|h| st.doc.entity(**h).is_some_and(|e| matches!(e.kind, EntityKind::Insert(_)))).count(); if refs == 1 && st.selection.len() == 1 { return None; } // Start on the block last edited if it is still there, else the first one. let names = editable_blocks(&st.doc); - let last = app.bedit.name.clone(); + let last = st.block_editor().map(str::to_string).unwrap_or_else(|| app.bedit.name.clone()); let name = if names.iter().any(|n| n.eq_ignore_ascii_case(&last)) { last } else { names.first().cloned().unwrap_or_default() }; app.bedit = State { name, ..State::default() }; app.ui.dialog = Some("bedit".into()); @@ -164,6 +165,10 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let list = existing.as_deref().and_then(|n| preview(app, n)); let description = existing.as_deref().and_then(|n| d.block(n)).map(|b| b.description.clone()).unwrap_or_default(); let (mut ok, mut cancel) = (false, false); + if app.bedit.ask_save { + save_question(app, ctx, open); + return; + } egui::Window::new(crate::tl!("Edit Block Definition")) .id(egui::Id::unique("Edit Block Definition")) .open(open) @@ -236,14 +241,72 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } } -/// OK: open the Block Editor on the name given (a new name starts a new block). Returns whether -/// it opened; if not, the dialog shows why and stays open. +/// In the Block Editor with changes not saved: whether to save them before switching to the +/// block chosen. Yes saves, No discards, Cancel goes back to the list. +fn save_question(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { + let current = app.session.state().ok().and_then(|st| st.block_editor().map(str::to_string)).unwrap_or_default(); + let mut answer: Option> = None; + egui::Window::new(crate::tl!("Edit Block Definition")) + .id(egui::Id::unique("Edit Block Definition")) + .collapsible(false) + .resizable(false) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) + .show(ctx, |ui| { + ui.set_max_width(420.0); + ui.label(crate::tf!("Save changes to block \"{name}\"?", name = current)); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button(crate::tl!("Yes")).clicked() { + answer = Some(Some(true)); + } + if ui.button(crate::tl!("No")).clicked() { + answer = Some(Some(false)); + } + if ui.button(crate::tl!("Cancel")).clicked() { + answer = Some(None); + } + }); + }); + let Some(answer) = answer else { return }; + app.bedit.ask_save = false; + if let Some(save) = answer + && run(app, Some(save)) + { + *open = false; + } +} + +/// OK: open the Block Editor on the name given (a new name starts a new block). In the Block +/// Editor with changes not saved, it asks first whether to save them. Returns whether it opened; +/// if not, the dialog shows why (or asks) and stays open. pub fn confirm(app: &mut CadApp) -> bool { let name = app.bedit.name.trim().to_string(); if name.is_empty() { return false; } - match app.run("bedit", json!({ "name": name, "create": true })) { + let ask = app.session.state().is_ok_and(|st| st.block_unsaved() && st.block_editor().is_some_and(|b| !b.eq_ignore_ascii_case(&name))); + if ask { + // A block it can't switch to is refused before the question, which would trap the user. + if let Err(e) = app.run("bedit", json!({ "name": name, "create": true, "check": true })) { + app.bedit.error = Some(e); + return false; + } + app.bedit.ask_save = true; + return false; + } + run(app, None) +} + +/// Run BEDIT on the name given, saving (`Some(true)`) or discarding (`Some(false)`) the Block +/// Editor's changes not saved yet. Returns whether it went through; if not, the dialog shows why. +fn run(app: &mut CadApp, save: Option) -> bool { + let name = app.bedit.name.trim().to_string(); + let mut p = json!({ "name": name, "create": true }); + if let (Some(save), Some(o)) = (save, p.as_object_mut()) { + o.insert("save".into(), json!(save)); + } + match app.run("bedit", p) { Ok(_) => true, Err(e) => { app.bedit.error = Some(e); diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d71b3ccd7..3f2de02df 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1373,3 +1373,4 @@ Check for Updates… Перевірити оновлення… CADCraft is up to date. Встановлено найновішу версію CADCraft. Could not check for updates. Не вдалося перевірити оновлення. + Save changes to block "{name}"? Зберегти зміни в блоці «{name}»? diff --git a/crates/ui-egui/tests/bedit_dialog.rs b/crates/ui-egui/tests/bedit_dialog.rs index 95fdb4adc..21cd32155 100644 --- a/crates/ui-egui/tests/bedit_dialog.rs +++ b/crates/ui-egui/tests/bedit_dialog.rs @@ -95,9 +95,10 @@ fn a_selected_reference_and_json_calls_skip_the_dialog() { app.start("bedit"); assert!(app.ui.dialog.is_none()); assert_eq!(app.session.state().unwrap().block_editor(), Some("Window")); - // In the editor BEDIT says why it can't run instead of opening the dialog. + // In the editor BEDIT opens the dialog, to switch to another block. app.start("bedit"); - assert!(app.ui.dialog.is_none()); + assert_eq!(app.ui.dialog.as_deref(), Some("bedit")); + app.ui.dialog = None; app.run("bclose", json!({})).unwrap(); app.run("bedit", json!({ "name": "door" })).unwrap(); @@ -175,3 +176,65 @@ fn opening_a_drawing_with_authoring_elements_offers_the_block_editor() { assert!(app.ui.dialog.is_none()); let _ = std::fs::remove_dir_all(&dir); } + +/// A name the Block Editor can't switch to is refused before the dialog asks about the changes not +/// saved, so the question never traps the user. +#[test] +fn a_bad_name_is_refused_before_the_dialog_asks_to_save() { + let mut app = app_with_blocks(); + app.run("bedit", json!({ "name": "door" })).unwrap(); + app.run("line", json!({ "points": [[0, 0], [0, 4]] })).unwrap(); + app.start("bedit"); + app.bedit.name = "*U1".into(); + assert!(!bedit::confirm(&mut app)); + assert!(!app.bedit.ask_save, "no question"); + assert!(app.bedit.error.is_some(), "the dialog says why"); + assert!(app.session.state().unwrap().block_unsaved(), "nothing lost"); +} + +/// In the Block Editor, BEDIT opens the dialog on the block being edited; OK on another block +/// switches to it, first asking whether to save the changes not saved yet (Cancel goes back). +#[test] +fn in_the_block_editor_the_dialog_switches_blocks_asking_to_save_first() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + let mut app = app_with_blocks(); + app.run("bedit", json!({ "name": "door" })).unwrap(); + app.start("bedit"); + assert_eq!((app.ui.dialog.as_deref(), app.bedit.name.as_str()), (Some("bedit"), "door"), "starts on the block edited"); + // Nothing changed: OK switches at once. + app.bedit.name = "Window".into(); + assert!(bedit::confirm(&mut app)); + assert_eq!(app.session.state().unwrap().block_editor(), Some("Window")); + + app.run("line", json!({ "points": [[0, 0], [0, 4]] })).unwrap(); + app.start("bedit"); + app.bedit.name = "door".into(); + assert!(!bedit::confirm(&mut app), "asks first"); + assert!(app.bedit.ask_save); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(2); + assert!(h.query_by_label_contains("Save changes to block \"Window\"").is_some()); + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert!(!h.state().bedit.ask_save, "back to the list"); + assert_eq!(h.state().session.state().unwrap().block_editor(), Some("Window")); + assert!(h.state().session.state().unwrap().block_unsaved(), "nothing lost"); + + assert!(!bedit::confirm(h.state_mut())); + h.run_steps(2); + h.get_by_label("Yes").click(); + h.run_steps(2); + let st = h.state().session.state().unwrap(); + assert_eq!(st.block_editor(), Some("door")); + assert!(h.state().ui.dialog.is_none(), "the dialog closed"); + let window = h.state().session.doc().unwrap().block("Window").map(|b| b.entities.len()); + assert_eq!(window, Some(2), "Window saved with its new line before switching"); +} diff --git a/docs/gaps.md b/docs/gaps.md index 9c0bf3630..b270525d6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (Named Viewports dialog: U11 narrowed; MVIEW Restore and PURGE of multiline styles had landed: F15 and F21 corrected) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (BEDIT in the Block Editor switches blocks: F3 updated) · **Target:** Autodesk AutoCAD 2027 Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR @@ -26,7 +26,7 @@ do them first. |---|---|---|---|---| | F1 | **[alpha blocker]** Prompt options that still print "not available yet": -ATTEDIT one-at-a-time Position, Height, Angle and Style, colour books in -ATTEDIT Color, -RENAME of materials, plot styles and viewport configurations. SPLINEDIT Join, Edit vertex Add and Elevate order (#559), PEDIT Edit vertex and the other SPLINEDIT options (#558) done: Edit vertex Tangent directions last for the PEDIT session only (LWPOLYLINE stores none). MTEXT Dynamic/Static columns done (#547). SPLINE, MTEXT and MLEADER options done (#408): SPLINE Object fits the polyline's vertices (no spline frame is kept) (STRETCH polygon, CP and WP, and fence selection done; TRIM/EXTEND, PLINE arc mode and Length, XLINE done (#397); OFFSET, ROTATE and FILLET options done earlier) | `cmd/blocks/attedit.rs`, `cmd/props/rename.rs` | Low: the most-used commands are done | 1–3 | | F2 | XREF editing in place and clipping: XCLIP is missing. REFEDIT, REFSET and REFCLOSE edit a block reference in place (working set, faded rest of the block, everything else locked, save or discard; #813); left: editing an xref in place (its source drawing is not written back; XOPEN opens it), picking nested references, a dimmed look for the locked drawing and a banner with Save and Discard, attribute sync of other references (ATTSYNC). XATTACH, the External References palette, -XREF (Bind/Detach/Path/Unload/Reload/Overlay), XBIND, XOPEN, overlays, relative paths and loading at open are done (#525). Unloaded state and xref-layer overrides last only for the session | `cmd/blocks/xref.rs` | Medium: xrefs can't be edited in place or clipped | 15–25 | -| F3 | **[alpha blocker]** (dynamic-block authoring is beta) No dynamic-block authoring: parameters and actions can't be added in the Block Editor. Dynamic blocks from other files are read and their references edited (visibility, distances, stretch and move, lookups, grips). BEDIT (with the Edit Block Definition dialog, which also starts new blocks), BSAVE, BSAVEAS and BCLOSE edit a static block definition, and every reference updates; REFEDIT edits a reference in place; no authoring palettes yet | issue #185 offers help | High: standard libraries are dynamic | 40–65 | +| F3 | **[alpha blocker]** (dynamic-block authoring is beta) No dynamic-block authoring: parameters and actions can't be added in the Block Editor. Dynamic blocks from other files are read and their references edited (visibility, distances, stretch and move, lookups, grips). BEDIT (with the Edit Block Definition dialog, which also starts new blocks and, in the Block Editor, switches to another block after asking to save), BSAVE, BSAVEAS and BCLOSE edit a static block definition, and every reference updates; REFEDIT edits a reference in place; no authoring palettes yet | issue #185 offers help | High: standard libraries are dynamic | 40–65 | | F4 | Autosave leftovers: no autosave in the web build (browser storage); autosave files are DXF, not DWG; no separate RECOVER mode in the file readers (SAVETIME, Drawing Recovery, AUDIT and RECOVER landed with #406; AUDITCTL `.adt` log landed) | `engine/src/autosave.rs`, `engine/src/audit.rs` | Low-medium (web users) | 2–4 | | F5 | Associative arrays (ASsociative, Levels, polar ROWs, ARRAYEDIT, ARRAYCLOSE) landed (#448); left: grip editing of arrays (count/spacing grips), ARRAYPATH is not associative, ARRAYEDIT has no item deletion (CTRL+click) and no Key point base points, levels only shift Z (no 3D view) | `cmd/array.rs`, `cmd/array/edit.rs` | Medium | 4–6 | | F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects; with DIMTFILL 1 the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507; the DIMTFILL/DIMTFILLCLR text background done) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 | @@ -152,6 +152,7 @@ Detail: [localization-parity.md](localization-parity.md). |---|---|---| | 2026-10-25 | minor | Named Viewports (View ▸ Viewports ▸ Named Viewports..., `vports.named`) lists the saved viewport configurations with a preview and the current one's name, and restores or deletes one; in a layout Restore places it as MVIEW Restore does: U11 narrowed. F15 and U11 no longer list MVIEW Restore (#633), and F21 no longer says PURGE skips multiline styles | | 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated | +| 2026-10-26 | minor | BEDIT in the Block Editor (typed, Edit Block, or the Edit Block Definition dialog) switches to another block as AutoCAD's does, asking whether to save the changes not saved yet; the JSON form takes `save`: F3 evidence | | 2026-10-24 | minor | COMPAREEXPORT (typed, or Export on the comparison bar) opens the comparison as a new untitled drawing, `compare_ vs .dwg`: this drawing's objects in one group and the other drawing's differing objects in a second group, in the comparison colors (green, red, gray for objects in both), block contents and style colors included: F22 narrowed | | 2026-10-24 | minor | DIMTFILL and DIMTFILLCLR: a dimension's text background (none, the drawing's background color, or a color) hides what is drawn under the text; SETVAR, DIMOVERRIDE and style fields, DIMSTYLE groups 69 and 70 in DXF from R2007 (CADCraft xdata before), and a SOLID in the `*D` block and from EXPLODE for DIMTFILL 2: F7 narrowed | | 2026-10-22 | minor | APPLOAD typed or from Tools ▸ Load Application opens a `.lsp` file browser starting in the last-used folder (kept in the preferences); a picked file loads interactively like a typed name (routines that ask for input wait), the web says to drop the file (no file system), and the JSON form `{path}` reads the path as given and returns the commands the file defined, never opening a dialog (#1820): E1 narrowed | From 58687dda2dc904ce0c1572a1d0b58a7ee2dffc01 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:21:31 +0100 Subject: [PATCH 09/29] Reject out-of-range numeric colours in properties.set (#2286) Found while testing crates/engine/src/cmd/props.rs:155-157 (parse_color), with crates/color/src/lib.rs:60-66 (Color::from_aci) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/cmd/props.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 617d29756..dfe69fd8f 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -153,7 +153,9 @@ fn run_get(s: &mut Session, p: &Value) -> Result { } fn parse_color(v: &Value) -> Option { - v.as_str().and_then(Color::parse).or_else(|| v.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci)) + v.as_str() + .and_then(Color::parse) + .or_else(|| v.as_i64().filter(|i| (0..=256).contains(i)).and_then(|i| i16::try_from(i).ok()).map(Color::from_aci)) } /// A transparency as JSON, in the form `properties.set` takes: "ByLayer", "ByBlock" or a percentage. @@ -1221,6 +1223,24 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn properties_set_rejects_out_of_range_colour() { + let mut s = Session::new(); + s.execute("line", &json!({ "points": [[0, 0], [50, 0]] })).unwrap(); + let h = s.doc().unwrap().model.last().unwrap().handle.hex(); + s.execute("properties.set", &json!({ "handles": [h], "color": 1 })).unwrap(); + let col = |s: &Session| s.doc().unwrap().model.last().unwrap().common.color; + for bad in [300, -5, 257] { + let e = s.execute("properties.set", &json!({ "handles": [h], "color": bad })).unwrap_err(); + assert!(e.to_string().contains("bad colour"), "{e}"); + assert_eq!(col(&s), Color::Index(1)); + } + s.execute("properties.set", &json!({ "handles": [h], "color": 256 })).unwrap(); + assert_eq!(col(&s), Color::ByLayer); + s.execute("properties.set", &json!({ "handles": [h], "color": 0 })).unwrap(); + assert_eq!(col(&s), Color::ByBlock); + } + #[test] fn properties_set_rejects_unloaded_linetype() { let mut s = Session::new(); From 31dba6e7a77d94b218e0e3c63e605adbd2f18df8 Mon Sep 17 00:00:00 2001 From: Ayoze Torres <53948812+ayozetr@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:21:35 +0000 Subject: [PATCH 10/29] Command line: a LISP value at a prompt is the answer, not another expression (#1920) (#2338) An expression typed at a command's prompt is evaluated, and its value answers the prompt. That value went back through typed(), which treated it as typed text and evaluated it again when it looked like LISP. So `circle (setq q "!q")` returned "!q", which evaluated to "!q" again, forever, until the native stack overflowed and the process aborted (a stack overflow is not a panic, so the catch_unwind guards cannot help), losing unsaved work in every open drawing. It is reachable from the command line, .scr scripts, the control channel and MCP. The value now goes back with LISP evaluation off: a string that looks like an expression is taken as text, as AutoCAD does. --- crates/engine/src/lisp/tests.rs | 25 +++++++++++++++++++++++++ crates/engine/src/session/cmdline.rs | 15 +++++++++++---- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/lisp/tests.rs b/crates/engine/src/lisp/tests.rs index d65795631..58f421765 100644 --- a/crates/engine/src/lisp/tests.rs +++ b/crates/engine/src/lisp/tests.rs @@ -876,3 +876,28 @@ fn angtof_reads_the_string_in_the_given_units() { assert_eq!(ok(&mut s, "(angtof \"180\" 7)"), "nil"); assert_eq!(ok(&mut s, "(angtof \"180\" \"x\")"), "nil"); } + +/// #1920: the value of an expression typed at a prompt is the answer, not another expression. +/// `(setq q "!q")` returned `"!q"`, which was evaluated again as `!q`, forever, until the stack +/// overflowed and the process aborted (no `catch_unwind` catches that). +#[test] +fn a_lisp_value_at_a_prompt_is_not_evaluated_again() { + let mut s = session(); + s.cmdline("circle (setq q \"!q\")").unwrap(); + assert_eq!(ok(&mut s, "q"), "\"!q\""); + // "!q" is no centre point, so CIRCLE still asks for one; the session carries on. + assert!(s.running.is_some()); + s.cancel(); + // A value that is itself an expression stays text too: `(+ 1 2)` is not evaluated to 3. + s.cmdline("circle 0,0 (setq r \"(+ 1 2)\")").unwrap(); + assert!(s.running.is_some(), "\"(+ 1 2)\" is no radius"); + s.cancel(); + // Values that answer the prompt still do. + s.cmdline("(setq r \"4\")").unwrap(); + s.cmdline("circle 0,0 !r").unwrap(); + let r = s.doc().unwrap().model.iter().find_map(|e| match &e.kind { + cadcraft_doc::EntityKind::Circle(c) => Some(c.radius), + _ => None, + }); + assert_eq!(r, Some(4.0)); +} diff --git a/crates/engine/src/session/cmdline.rs b/crates/engine/src/session/cmdline.rs index 7b9bd53a2..2a1b45836 100644 --- a/crates/engine/src/session/cmdline.rs +++ b/crates/engine/src/session/cmdline.rs @@ -69,6 +69,13 @@ impl Session { /// One token typed while a command runs. pub(crate) fn typed(&mut self, t: &str) -> Result<()> { + self.typed_token(t, true) + } + + /// One token typed while a command runs; `lisp` evaluates a LISP expression first. The value + /// of that expression is the answer, so it comes back with `lisp` off: re-evaluating it would + /// let `(setq q "!q")` evaluate itself forever and overflow the stack (#1920). + fn typed_token(&mut self, t: &str, lisp: bool) -> Result<()> { let Some(prompt) = self.current_prompt() else { return Ok(()) }; let tt = t.trim(); if tt.is_empty() { @@ -84,7 +91,7 @@ impl Session { } // A LISP expression typed at a command's prompt: its value is the answer. // Not at a free-text prompt: there the text is the answer (CAL takes "(3+4)*2"). - if lisp_expression(tt) && !prompt.accept.text && self.lisp_prompt().is_none() && !self.lisp_groups_undo() { + if lisp && lisp_expression(tt) && !prompt.accept.text && self.lisp_prompt().is_none() && !self.lisp_groups_undo() { let src = tt.strip_prefix('!').unwrap_or(tt).to_string(); let v = match self.lisp_eval(&src, false, true, Vec::new()) { Ok(Some(Ok(v))) => v, @@ -96,9 +103,9 @@ impl Session { }; return match v { lisp::value::Value::Nil => Ok(()), - lisp::value::Value::Int(i) => self.typed(&i.to_string()), - lisp::value::Value::Real(r) => self.typed(&format!("{r}")), - lisp::value::Value::Str(s) => self.typed(&s), + lisp::value::Value::Int(i) => self.typed_token(&i.to_string(), false), + lisp::value::Value::Real(r) => self.typed_token(&format!("{r}"), false), + lisp::value::Value::Str(s) => self.typed_token(&s, false), other => match other.as_point() { Some(p) => self.input(Input::Point(Vec2::new(p[0], p[1]))), None => { From f817ea9a3a14be5ac582fa176f921d6ddf9a02e1 Mon Sep 17 00:00:00 2001 From: heejunB <125256444+heejunB@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:21:38 +0900 Subject: [PATCH 11/29] fix(render): fill font glyphs with the nonzero winding rule (#2189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TrueType and OpenType outlines are defined with the nonzero rule, but glyph meshes were tessellated even-odd, so any area covered by two contours came out empty. Arial Unicode (the macOS fallback CADCraft picks for Hangul) builds 진 (U+C9C4) from a component that already contains the final ㄴ plus a second copy of that ㄴ; even-odd cancelled the pair and 진 rendered as 지. Overlapping contours in other fonts lost their overlaps the same way. Tessellate glyph outlines with FillOptions::non_zero(). Outlines placed in drawing coordinates without a TextGlyph (complex-script runs) go through the same tessellator, offset to their first point to keep f32 precision, instead of the even-odd slab filler. --- crates/render/src/lib.rs | 2 +- crates/render/src/text.rs | 79 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5b9dc83a1..6fa236e4c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -235,7 +235,7 @@ impl Builder<'_> { self.tris_at(ctx, color, &mesh.triangles, xf); mesh.triangles.len() } else { - let tris = fill::triangulate_evenodd(g); + let tris = crate::text::triangulate_placed(g); self.tris(ctx, color, &tris); tris.len() }; diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 6595e145f..1f80d7af4 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -163,8 +163,10 @@ fn simplify(contour: &[Vec2], tolerance: f64) -> Vec { if result.len() >= 4 { result } else { contour.to_vec() } } -/// Monotone tessellation with even-odd holes and intersections. Work in font units so f32 -/// precision is independent of drawing coordinates; the placement stays f64. The existing +/// Monotone tessellation with the nonzero winding rule that TrueType and OpenType outlines are +/// defined with: overlapping contours stay filled (Arial Unicode's 진 draws its final ㄴ twice, +/// which even-odd cancelled out) and holes are contours wound the other way. Work in font units +/// so f32 precision is independent of drawing coordinates; the placement stays f64. The existing /// slab filler remains the fallback when tessellation fails (including its u16 vertex limit). fn triangulate(contours: &[Vec]) -> Vec { let mut path = Path::builder(); @@ -179,7 +181,7 @@ fn triangulate(contours: &[Vec]) -> Vec { let mut buffers: VertexBuffers = VertexBuffers::new(); let result = FillTessellator::new().tessellate_path( &path.build(), - &FillOptions::even_odd(), + &FillOptions::non_zero(), &mut BuffersBuilder::new(&mut buffers, |v: FillVertex<'_>| { let p = v.position(); Vec2::new(f64::from(p.x), f64::from(p.y)) @@ -190,3 +192,74 @@ fn triangulate(contours: &[Vec]) -> Vec { } buffers.indices.into_iter().filter_map(|i| buffers.vertices.get(usize::from(i)).copied()).collect() } + +/// [`triangulate`] for a glyph outline already placed in drawing coordinates (complex-script +/// runs carry no [`TextGlyph`]): tessellated relative to its first point so f32 keeps the +/// precision. Outlines past the limits [`mesh`] applies keep the slab filler. +pub(super) fn triangulate_placed(contours: &[Vec]) -> Vec { + let Some(&origin) = contours.iter().flatten().next() else { return Vec::new() }; + let points = contours.iter().map(Vec::len).try_fold(0usize, usize::checked_add); + let out_of_range = |p: &Vec2| { + let d = *p - origin; + !p.is_finite() || d.x.abs().max(d.y.abs()) > 1.0e6 + }; + if !points.is_some_and(|n| n <= MAX_POINTS) || contours.iter().flatten().any(out_of_range) { + return crate::fill::triangulate_evenodd(contours); + } + let local: Vec> = contours.iter().map(|c| c.iter().map(|&p| p - origin).collect()).collect(); + triangulate(&local).into_iter().map(|p| p + origin).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn square(x: f64, y: f64, s: f64) -> Vec { + // Clockwise, as TrueType draws outer contours. + vec![Vec2::new(x, y), Vec2::new(x, y + s), Vec2::new(x + s, y + s), Vec2::new(x + s, y), Vec2::new(x, y)] + } + + fn area(tris: &[Vec2]) -> f64 { + tris.chunks(3) + .filter_map(|t| match t { + [a, b, c] => Some(((*b - *a).x * (*c - *a).y - (*b - *a).y * (*c - *a).x).abs() / 2.0), + _ => None, + }) + .sum() + } + + #[test] + fn duplicated_contour_stays_filled() { + // A composite glyph that draws one component twice (Arial Unicode's 진 and its ㄴ). + let s = square(0.0, 0.0, 10.0); + assert!((area(&triangulate(&[s.clone(), s])) - 100.0).abs() < 1e-6); + } + + #[test] + fn overlapping_contours_fill_their_union() { + let tris = triangulate(&[square(0.0, 0.0, 10.0), square(5.0, 5.0, 10.0)]); + assert!((area(&tris) - 175.0).abs() < 1e-6); + } + + #[test] + fn counter_wound_contour_is_still_a_hole() { + let mut hole = square(4.0, 4.0, 2.0); + hole.reverse(); + assert!((area(&triangulate(&[square(0.0, 0.0, 10.0), hole])) - 96.0).abs() < 1e-6); + } + + #[test] + fn placed_outline_far_from_the_origin_keeps_its_place_and_fill() { + let s = square(5.0e5, -3.0e5, 2.0); + let tris = triangulate_placed(&[s.clone(), s]); + assert!((area(&tris) - 4.0).abs() < 1e-6); + assert!(tris.iter().all(|p| (5.0e5..=5.0e5 + 2.0).contains(&p.x) && (-3.0e5..=-3.0e5 + 2.0).contains(&p.y))); + } + + #[test] + fn hostile_placed_outline_never_panics() { + assert!(triangulate_placed(&[]).is_empty()); + let _ = triangulate_placed(&[vec![Vec2::new(f64::NAN, 0.0), Vec2::X, Vec2::Y]]); + let _ = triangulate_placed(&[vec![Vec2::ZERO, Vec2::new(1.0e300, 0.0), Vec2::Y]]); + } +} From 74e77f8a929e43e5d5bb0af57da34c0cb0bbe777 Mon Sep 17 00:00:00 2001 From: smacdmac101 <152641296+smacdmac101@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:21:42 -0400 Subject: [PATCH 12/29] feat(web): persist validated browser panel layouts (#2258) * xtask: the test suite and clippy pass on Windows Two Windows-only failures stopped `cargo xtask ci` before it got going on a Windows checkout (CI runs the gate on Linux only, so neither showed): - parity::tests::civil_dates built a SystemTime 115,740,740 days after the epoch (year 318857). Windows' SystemTime ends in the year 30828, so the addition panicked ("overflow when adding duration to `SystemTime`"). That case now checks `civil_from_days` on the day count, which is what it was about, on every platform. - stats.rs imported `walk` for a test that only exists on Unix, so the import was unused on Windows and `clippy -D warnings` refused it. The Unix test calls `super::walk`, as its neighbour calls `super::walk_inner`. Checked on Windows 11 x64: `cargo test --workspace` and `cargo clippy --workspace --all-targets -- -D warnings` both pass. Co-Authored-By: Claude Opus 5.5 * feat(web): persist validated browser panel layouts CC-053: keep embedded floating, docked and hidden panels across reloads through the existing host save cycle. Reject malformed/versioned records atomically; native workspaces and drawings remain unchanged. Validation: all seven cargo xtask ci gates passed (2558 pass, 0 fail, 1 ignored) on a2dd814 plus this exact change. Actual release Trunk bundle and Edge floating/close/reload/reset/reload passed. Forced WebGL startup, render and recovered layout passed; full edit cycle used default mode. Dependency: original #2198 Windows test prerequisite. No #2226 dependency. Revert this commit independently to stop using the optional storage key. Upstream c3b8e7b adds nextest minimum configuration only; no runtime overlap. --------- Co-authored-by: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- apps/cadcraft-web/src/main.rs | 14 +++- apps/cadcraft-web/src/panel_layout.rs | 104 ++++++++++++++++++++++++++ docs/ui-parity.md | 4 +- packaging/web/README.md | 12 +++ 4 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 apps/cadcraft-web/src/panel_layout.rs diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index f69e02d8a..a25782d6e 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -14,6 +14,9 @@ pub mod download; #[cfg(target_arch = "wasm32")] mod host; +#[cfg(any(target_arch = "wasm32", test))] +mod panel_layout; + #[cfg(target_arch = "wasm32")] mod web { use cadcraft_engine::Session; @@ -59,10 +62,14 @@ mod web { self.0.raw_input_hook(ctx, raw); cadcraft_ui_egui::cmdline::capture_tab(ctx, raw); } - /// Preferences (interface theme and language) survive reloads; egui state is not kept. + /// Preferences and validated panel placement survive reloads; egui memory is not kept. fn save(&mut self, storage: &mut dyn eframe::Storage) { storage.set_string(cadcraft_ui_egui::options::PROFILE_KEY, self.0.profile_json()); storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.0.prefs_json()); + match crate::panel_layout::save(&self.0.ui) { + Ok(layout) => storage.set_string(crate::panel_layout::KEY, layout), + Err(error) => log::warn!("panel layout was not saved: {error}"), + } } fn persist_egui_memory(&self) -> bool { false @@ -160,6 +167,11 @@ mod web { if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) { app.load_prefs(&prefs); } + if let Some(layout) = cc.storage.and_then(|s| s.get_string(crate::panel_layout::KEY)) + && let Err(error) = crate::panel_layout::restore(&mut app.ui, &layout) + { + log::warn!("panel layout was not restored: {error}"); + } // `?cpu`: no render state for the app, so the canvas draws with egui shapes. if !crate::wants_cpu(&query()) && let Some(rs) = &cc.wgpu_render_state diff --git a/apps/cadcraft-web/src/panel_layout.rs b/apps/cadcraft-web/src/panel_layout.rs new file mode 100644 index 000000000..766879c83 --- /dev/null +++ b/apps/cadcraft-web/src/panel_layout.rs @@ -0,0 +1,104 @@ +//! Browser panel layout storage, independent of drawings and native workspace files. + +use cadcraft_ui_egui::{UiState, docking}; +use serde_json::{Value, json}; + +#[cfg(target_arch = "wasm32")] +pub const KEY: &str = "cadcraft.panel-layout"; +const MAX_BYTES: usize = 64 * 1024; + +/// Serialize only validated panel placement and visibility, with a bounded record size. +pub fn save(ui: &UiState) -> Result { + docking::validate(&ui.docking)?; + let saved = json!({ + "version": docking::LAYOUT_VERSION, + "docking": ui.docking, + "showToolsets": ui.show_toolsets, + "showPalettes": ui.show_palettes, + }) + .to_string(); + if saved.len() > MAX_BYTES { + return Err("panel layout exceeds 64 KiB".into()); + } + Ok(saved) +} + +/// Validate the entire optional record before changing any UI field. +pub fn restore(ui: &mut UiState, saved: &str) -> Result<(), String> { + if saved.len() > MAX_BYTES { + return Err("panel layout exceeds 64 KiB".into()); + } + let value: Value = serde_json::from_str(saved).map_err(|error| error.to_string())?; + if value.get("version").and_then(Value::as_u64) != Some(u64::from(docking::LAYOUT_VERSION)) { + return Err("unsupported panel layout version".into()); + } + let workspace: docking::Workspace = + serde_json::from_value(value.get("docking").cloned().ok_or("missing panel layout")?).map_err(|error| error.to_string())?; + docking::validate(&workspace)?; + let tools = value.get("showToolsets").and_then(Value::as_bool).ok_or("invalid Tool Sets visibility")?; + let palettes = value.get("showPalettes").and_then(Value::as_bool).ok_or("invalid palette visibility")?; + ui.docking = workspace; + ui.show_toolsets = tools; + ui.show_palettes = palettes; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use cadcraft_engine::Session; + use cadcraft_ui_egui::{CadApp, Services}; + + #[test] + fn floating_and_hidden_panels_survive_storage_without_replacing_drawing_state() { + let mut source = CadApp::new(Session::default(), Services::default()); + source.run("ui.dock", json!({"operation":"float","panel":"toolSets","rect":[120,90,340,410]})).unwrap(); + source.run("ui.dock", json!({"operation":"close","panel":"properties"})).unwrap(); + let saved = save(&source.ui).unwrap(); + let mut destination = CadApp::new(Session::default(), Services::default()); + destination.session.start("line").unwrap(); + let before = destination.session.doc().unwrap().clone(); + restore(&mut destination.ui, &saved).unwrap(); + assert_eq!(destination.ui.docking, source.ui.docking); + assert_eq!(destination.ui.show_toolsets, source.ui.show_toolsets); + assert_eq!(destination.ui.show_palettes, source.ui.show_palettes); + assert_eq!(destination.session.running.as_ref().map(|command| command.id.as_str()), Some("line")); + assert_eq!(destination.session.doc().unwrap(), &before); + } + + #[test] + fn malformed_or_unsupported_storage_is_rejected_atomically() { + let mut ui = UiState::default(); + let before = serde_json::to_value(&ui).unwrap(); + let valid: Value = serde_json::from_str(&save(&ui).unwrap()).unwrap(); + for (field, bad) in [ + ("version", json!(0)), + ("version", json!(docking::LAYOUT_VERSION + 1)), + ("docking", json!({})), + ("showToolsets", json!("false")), + ("showPalettes", Value::Null), + ] { + let mut invalid = valid.clone(); + invalid[field] = bad; + assert!(restore(&mut ui, &invalid.to_string()).is_err()); + assert_eq!(serde_json::to_value(&ui).unwrap(), before); + } + for invalid in ["not json".to_string(), " ".repeat(MAX_BYTES + 1)] { + assert!(restore(&mut ui, &invalid).is_err()); + assert_eq!(serde_json::to_value(&ui).unwrap(), before); + } + } + + #[test] + fn storage_cannot_remove_the_document_view() { + let mut ui = UiState::default(); + let before = serde_json::to_value(&ui).unwrap(); + let mut invalid = ui.clone(); + invalid.docking.layout.root = None; + assert!(save(&invalid).is_err()); + let mut record: Value = serde_json::from_str(&save(&ui).unwrap()).unwrap(); + record["docking"] = serde_json::to_value(&invalid.docking).unwrap(); + assert!(restore(&mut ui, &record.to_string()).is_err()); + assert_eq!(serde_json::to_value(&ui).unwrap(), before); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c06e8f242..6bf0c20f8 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (distinct polyline midpoint grips) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (browser panel layout storage) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) How CADCraft's interaction compares with AutoCAD's: point entry, object snaps, tracking, dynamic input, grips, selection, the command line, chrome and navigation. A drafter's speed in AutoCAD @@ -166,6 +166,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | ViewCube | interactive 3D | clickable for 2D (Top/Home → extents); dragging does nothing (#7) | | Navigation bar, SteeringWheels | yes | no | | Workspaces | Drafting & Annotation, 3D Modeling | no | +| Browser panel layout storage | not applicable | Versioned, validated panel placement and visibility saved through the browser host; floating palettes remain embedded. Requires available browser storage and a completed host save; named workspaces and drawing persistence are separate. | | Options / Preferences dialog | many tabs | **no** (issue #23) | | Customisation (CUI, aliases editor, tool palettes) | yes | Command Aliases dialog (ALIASEDIT, #1381); no CUI or tool palettes | | Themes | Dark, Light | Dark, Light, System (#179) | @@ -203,6 +204,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | Browser host stores bounded, validated panel layout separately from drawing state; malformed or unsupported records retain defaults. Native workspace storage is unchanged. | | 2026-10-22 | minor | Polyline segment midpoint grips use rectangles, distinct from square vertex grips, with matching hover and pick bounds (#1950) | | 2026-10-13 | minor | Filled-region highlight boundaries are traversed in deterministic coordinate order, fixing dash-phase jumps on CPU hover/selection redraws and GPU highlight rebuilds; CPU dash-position, pixel and GPU mesh regressions | | 2026-10-14 | minor | Selection cycling (#1143): the list and badge of SELECTIONCYCLING, Shift+Space through overlapping objects (#1145) | diff --git a/packaging/web/README.md b/packaging/web/README.md index 024e00c01..73a74f493 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -50,6 +50,18 @@ location /cadcraft/ { Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/. +## Panel layout storage + +The browser host saves docked, floating and hidden palette arrangements alongside interface +preferences in its local storage. Floating palettes remain embedded in the browser canvas; +they do not become native operating-system windows. Window > Reset Palettes restores the +default arrangement. This record contains panel placement and visibility, not drawing data. + +The optional `cadcraft.panel-layout` record uses the shared docking layout version and is +limited to 64 KiB. Malformed, incompatible or invalid arrangements are ignored atomically, +leaving the startup defaults usable. Storage availability and autosave follow the browser +host's existing eframe persistence; clearing site data removes the saved layout. + ## Embedding in a page (iframe) ```html From 61fefc3ed931fe645432da8fb694409d881009bd Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:21:46 +0100 Subject: [PATCH 13/29] Skip a leading UTF-8 BOM when parsing ASCII DXF (#2287) Found while testing crates/dxf/src/lib.rs:189 and 267-274 (parse_with_limit via decode_ascii_file, then parse_ascii_with_limit) Co-authored-by: Claude Haiku 5.5 --- crates/dxf/src/lib.rs | 2 +- crates/dxf/tests/ascii_bom.rs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 crates/dxf/tests/ascii_bom.rs diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index 907c5abf7..4e888bdc0 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -264,7 +264,7 @@ pub fn parse_ascii(text: &str) -> Result> { #[allow(clippy::while_let_loop)] fn parse_ascii_with_limit(text: &str, max_tags: usize) -> Result> { let mut tags = Vec::new(); - let mut lines = text.lines().enumerate(); + let mut lines = text.strip_prefix('\u{feff}').unwrap_or(text).lines().enumerate(); loop { let Some((ln, code_line)) = lines.next() else { break }; let ct = code_line.trim(); diff --git a/crates/dxf/tests/ascii_bom.rs b/crates/dxf/tests/ascii_bom.rs new file mode 100644 index 000000000..e39b9b84c --- /dev/null +++ b/crates/dxf/tests/ascii_bom.rs @@ -0,0 +1,19 @@ +//! A UTF-8 byte order mark at the start of an ASCII DXF is skipped. + +const BODY: &str = " 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n"; + +#[test] +fn utf8_bom_bytes_are_skipped() { + let plain = cadcraft_dxf::parse(BODY.as_bytes()).expect("plain parses"); + let mut bytes = vec![0xEF, 0xBB, 0xBF]; + bytes.extend_from_slice(BODY.as_bytes()); + let with_bom = cadcraft_dxf::parse(&bytes); + assert!(with_bom.is_ok(), "{with_bom:?}"); + assert_eq!(with_bom.map(|t| t.len()).unwrap_or(0), plain.len()); +} + +#[test] +fn bom_text_is_skipped() { + let text = format!("\u{feff}{BODY}"); + assert!(cadcraft_dxf::parse_ascii(&text).is_ok()); +} From 1d6c72db119382214b55e06a84db541b0d71df53 Mon Sep 17 00:00:00 2001 From: Books Date: Fri, 30 Oct 2026 12:21:50 +1100 Subject: [PATCH 14/29] Bound the MCP client's reply read and the control server's total buffered bytes (#1925) (#2398) * Bound the MCP client's reply read and the control server's total buffered bytes (#1925) The MCP client read replies with an unbounded read_line; a process squatting on the control port could make it allocate without limit. It now reads through take() with a 256 MiB cap (the server's own request cap) and fails with a clear error. The control server allowed max_clients x max_line (16 x 256 MiB) buffered at once. Request-line bytes are now charged against a shared 512 MiB budget; a request that would pass it gets an error reply and the connection closes. * Parity checklist: regenerated after merging main * control_server: drop unused Read import (#1925) * control_server: tests import Read themselves (#1925) The unused-import cleanup removed std::io::Read from the module, but a test still calls read_to_end, so the test build failed on CI. --- apps/cadcraft/src/control_server.rs | 171 +++++++++++++++++++++++----- crates/mcp/src/backend.rs | 60 +++++++++- 2 files changed, 196 insertions(+), 35 deletions(-) diff --git a/apps/cadcraft/src/control_server.rs b/apps/cadcraft/src/control_server.rs index 14283e6fd..d502e1408 100644 --- a/apps/cadcraft/src/control_server.rs +++ b/apps/cadcraft/src/control_server.rs @@ -7,7 +7,7 @@ //! ([`Limits`]): simultaneous connections, request line length, queued requests, and how long a //! socket may sit idle or blocked on a write. -use std::io::{BufRead, BufReader, ErrorKind, Read, Write}; +use std::io::{BufRead, BufReader, ErrorKind, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -24,6 +24,9 @@ struct Limits { max_clients: usize, /// Longest request line in bytes (newline excluded); a longer one closes the connection. max_line: usize, + /// Request-line bytes buffered at once across all connections; a request that would pass it + /// is refused with an error reply. + total_bytes: usize, /// Requests waiting for the app at once; more are answered `busy`. queue: usize, /// How long a connection may go without sending a byte before it is closed. @@ -40,6 +43,8 @@ impl Default for Limits { max_clients: 16, // Generous, so requests that carry whole drawings or images as base64 still fit. max_line: 256 * 1024 * 1024, + // Room for one maximal request plus smaller ones, not `max_clients` maximal ones. + total_bytes: 512 * 1024 * 1024, queue: 64, idle_timeout: Duration::from_secs(600), write_timeout: Duration::from_secs(30), @@ -106,6 +111,7 @@ impl Drop for Slot { /// Accept clients within the connection limit and give each worker the same session token. fn accept_loop(listener: TcpListener, tx: SyncSender, ctx: egui::Context, limits: Limits, token: Arc) { let open = Arc::new(AtomicUsize::new(0)); + let budget = Budget::new(limits.total_bytes); for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) { let _ = stream.set_write_timeout(Some(limits.write_timeout)); if open.fetch_add(1, Ordering::SeqCst) >= limits.max_clients { @@ -117,39 +123,102 @@ fn accept_loop(listener: TcpListener, tx: SyncSender, ctx: egui: let tx = tx.clone(); let ctx = ctx.clone(); let token = Arc::clone(&token); + let budget = budget.clone(); let _ = std::thread::Builder::new().spawn(move || { let _slot = slot; - serve(stream, &tx, &ctx, limits, &token); + serve(stream, &tx, &ctx, limits, &token, budget); }); } } -/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped). -/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails. -fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize) -> Result { +/// A total-bytes budget for request lines buffered across all connections, so +/// [`Limits::max_clients`] peers can't each hold a [`Limits::max_line`] line at once (#1925). +#[derive(Clone, Debug)] +struct Budget { + used: Arc, + total: usize, +} + +impl Budget { + fn new(total: usize) -> Self { + Self { used: Arc::new(AtomicUsize::new(0)), total } + } + + /// Reserve `n` more bytes, or `false` (nothing reserved) when that would pass the total. + fn reserve(&self, n: usize) -> bool { + self.used.try_update(Ordering::SeqCst, Ordering::SeqCst, |u| u.checked_add(n).filter(|&t| t <= self.total)).is_ok() + } +} + +/// Bytes a connection holds from the [`Budget`] for its current request line; given back when the +/// next line starts or the connection ends. +struct Held { + budget: Budget, + bytes: usize, +} + +impl Held { + fn new(budget: Budget) -> Self { + Self { budget, bytes: 0 } + } + + fn release(&mut self) { + self.budget.used.fetch_sub(self.bytes, Ordering::SeqCst); + self.bytes = 0; + } +} + +impl Drop for Held { + fn drop(&mut self) { + self.release(); + } +} + +/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped), charging +/// the bytes buffered to `held`. `Ok(false)` at end of stream; `Err` when the line is too long, +/// the shared budget is used up, or the read fails. +fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize, held: &mut Held) -> Result { buf.clear(); - // `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`. - let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); - let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() { - // The idle timeout ran out (Windows reports it as TimedOut, Unix as WouldBlock). - ErrorKind::TimedOut | ErrorKind::WouldBlock => "request line not completed in time; closing the connection".to_string(), - _ => e.to_string(), - })?; - if n == 0 { - return Ok(false); - } - if buf.last() == Some(&b'\n') { - buf.pop(); - if buf.last() == Some(&b'\r') { + held.release(); + // A line is at most `max` bytes plus its newline; never buffer more than that. + let cap = max.saturating_add(1); + loop { + let chunk = match reader.fill_buf() { + Ok(c) => c, + Err(e) if e.kind() == ErrorKind::Interrupted => continue, + Err(e) => { + return Err(match e.kind() { + // The idle timeout ran out (Windows reports it as TimedOut, Unix as WouldBlock). + ErrorKind::TimedOut | ErrorKind::WouldBlock => "request line not completed in time; closing the connection".to_string(), + _ => e.to_string(), + }); + } + }; + if chunk.is_empty() { + // End of stream: nothing read, or a last line without a newline. + return Ok(!buf.is_empty()); + } + let line_end = chunk.iter().position(|&b| b == b'\n').map(|i| i + 1); + let n = line_end.unwrap_or(chunk.len()).min(cap.saturating_sub(buf.len())); + buf.extend_from_slice(chunk.get(..n).unwrap_or(chunk)); + reader.consume(n); + // The chunk is already buffered (a few KiB), so it is added before it is charged: a + // refusal still leaves `buf` non-empty and the connection gets an error reply. + if !held.budget.reserve(n) { + return Err("too many large requests in flight; try again later".to_string()); + } + held.bytes += n; + if buf.last() == Some(&b'\n') { buf.pop(); + if buf.last() == Some(&b'\r') { + buf.pop(); + } + return Ok(true); + } + if buf.len() > max { + return Err(format!("request line longer than {max} bytes; closing the connection")); } - return Ok(true); } - if buf.len() > max { - return Err(format!("request line longer than {max} bytes; closing the connection")); - } - // Last line without a newline, then end of stream. - Ok(true) } /// One request line, parsed. @@ -204,7 +273,7 @@ fn is_http_request_line(text: &str) -> bool { /// Serve bounded JSON lines, authenticating each request before enqueueing it for the UI. /// Invalid credentials close the connection; queue and reply limits report whether a request ran. -fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context, limits: Limits, token: &str) { +fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context, limits: Limits, token: &str, budget: Budget) { if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() { return; } @@ -212,8 +281,9 @@ fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context let mut out = stream; let mut reader = BufReader::new(read); let mut buf = Vec::new(); + let mut held = Held::new(budget); loop { - let reject = match read_bounded_line(&mut reader, &mut buf, limits.max_line) { + let reject = match read_bounded_line(&mut reader, &mut buf, limits.max_line, &mut held) { Ok(false) => break, // Nothing of a line arrived (idle timeout, or the peer is gone): close without a word. // A line nobody asked for would be read as the reply to the client's next request. @@ -264,6 +334,7 @@ fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context #[cfg(test)] mod tests { use super::*; + use std::io::Read; /// A failed accept is followed by a wait before the next one, so a listener that keeps /// failing does not spin, and the connections around the failures still come through. @@ -281,13 +352,13 @@ mod tests { fn lines_are_read_up_to_the_limit() { let mut buf = Vec::new(); let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc"; - assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true)); + assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true)); assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted"); - assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true)); + assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true)); assert_eq!(buf, b"bb", "CRLF is stripped"); - assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true)); + assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true)); assert_eq!(buf, b"cc", "a last line without a newline is still read"); - assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(false), "then end of stream"); + assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(false), "then end of stream"); } #[test] @@ -295,11 +366,15 @@ mod tests { let mut buf = Vec::new(); let long = [b'x'; 100]; let mut input: &[u8] = &long; - assert!(read_bounded_line(&mut input, &mut buf, 10).is_err()); + assert!(read_bounded_line(&mut input, &mut buf, 10, &mut held()).is_err()); assert!(buf.len() <= 11, "at most limit + 1 bytes are buffered, got {}", buf.len()); assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread"); } + fn held() -> Held { + Held::new(Budget::new(usize::MAX)) + } + fn short() -> Limits { Limits { queue: 1, @@ -414,6 +489,40 @@ mod tests { drop(rx); } + /// Connections can't together buffer more request-line bytes than the budget: a second large + /// request is refused with an error reply while the first still holds its bytes (#1925). + #[test] + fn requests_past_the_total_budget_are_refused() { + let (addr, _rx) = serve_with(Limits { max_line: 1000, total_bytes: 300, idle_timeout: Duration::from_secs(5), ..short() }); + // The first peer sends 200 bytes of an unfinished line and keeps the connection open. + let mut first = TcpStream::connect(addr).unwrap(); + first.write_all(&[b'a'; 200]).unwrap(); + std::thread::sleep(Duration::from_millis(200)); + // The second would take 200 more, past the 300 byte total. + let mut second = TcpStream::connect(addr).unwrap(); + second.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + second.write_all(&[b'b'; 200]).unwrap(); + let mut line = String::new(); + BufReader::new(second).read_line(&mut line).unwrap(); + let v: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(v["ok"], json!(false)); + assert!(v["error"].as_str().unwrap_or("").contains("too many large requests"), "{line}"); + drop(first); + } + + #[test] + fn the_budget_is_given_back_when_the_connection_ends() { + let budget = Budget::new(100); + { + let mut h = Held::new(budget.clone()); + let mut buf = Vec::new(); + let mut input: &[u8] = &[b'x'; 80]; + assert_eq!(read_bounded_line(&mut input, &mut buf, 1000, &mut h), Ok(true)); + assert!(!budget.reserve(30), "80 of 100 bytes are held"); + } + assert!(budget.reserve(100), "all given back on drop"); + } + /// Replies on one authenticated connection preserve the ID of each request. #[test] fn requests_are_answered_with_their_ids() { diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 9d012076a..20a86fce7 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -1,6 +1,6 @@ //! Where MCP tool calls end up: a control-channel method call. -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpStream, ToSocketAddrs}; use std::time::Duration; @@ -28,6 +28,8 @@ pub struct Remote { /// The per-session control token for this port, read from the owner-only token file on first /// use so requests can authenticate (CWE-306). token: Option, + /// Longest reply line accepted (`MAX_REPLY_LINE`; smaller in tests). + max_reply: usize, } /// The port component of a `HOST:PORT` address, for locating the token file. @@ -38,10 +40,29 @@ fn port_of(addr: &str) -> Option { /// How long a launched app has to open its control port. const LAUNCH_WAIT: Duration = Duration::from_secs(30); +/// Longest reply line in bytes the client will buffer. It matches the control server's request +/// cap (256 MiB, room for whole drawings and images as base64), so a peer squatting on the port +/// can't make the client allocate without bound (#1925). +const MAX_REPLY_LINE: usize = 256 * 1024 * 1024; + +/// Read one `\n`-terminated reply of at most `max` bytes (newline excluded). `Ok(0)` is end of +/// stream; a longer line is an `InvalidData` error and the rest of it stays unread. +fn read_reply_line(reader: &mut impl BufRead, reply: &mut String, max: usize) -> std::io::Result { + let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); + let n = reader.take(limit).read_line(reply)?; + if n > max && !reply.ends_with('\n') { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("reply from the control port is longer than {max} bytes; is that CADCraft's control server?"), + )); + } + Ok(n) +} + impl Remote { /// Connect to a running control server; its session token is read lazily when the first request is sent. pub fn connect(addr: &str) -> std::io::Result { - let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, launch: false, token: None }; + let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, launch: false, token: None, max_reply: MAX_REPLY_LINE }; r.reconnect()?; Ok(r) } @@ -49,7 +70,7 @@ impl Remote { /// the server before the app is open. With `launch`, a call that finds nothing at `addr` /// opens the app with that control port first (macOS) and waits for it. pub fn lazy(addr: &str, launch: bool) -> Self { - Self { addr: addr.to_string(), conn: None, next_id: 1, launch, token: None } + Self { addr: addr.to_string(), conn: None, next_id: 1, launch, token: None, max_reply: MAX_REPLY_LINE } } /// The per-session token for this port, read (and cached) from the owner-only token file. A @@ -120,7 +141,7 @@ impl Remote { return Err((e, true)); } let mut reply = String::new(); - match reader.read_line(&mut reply) { + match read_reply_line(reader, &mut reply, self.max_reply) { Ok(0) => { self.conn = None; Err((std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "connection closed"), true)) @@ -430,6 +451,37 @@ mod tests { use std::net::TcpListener; use std::sync::Arc; + /// A peer squatting on the control port can't make the client buffer an endless reply line (#1925). + #[test] + fn an_overlong_reply_is_refused_without_buffering_it() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (mut conn, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(conn.try_clone().unwrap()).read_line(&mut line).unwrap(); + // No newline: the reply never ends. A bounded client gives up after the limit. + let _ = conn.write_all(&[b'x'; 4096]); + let _ = conn.flush(); + std::thread::sleep(Duration::from_millis(500)); + }); + let mut remote = Remote::connect(&addr).unwrap(); + remote.max_reply = 100; + let err = remote.roundtrip("{}").unwrap_err().0; + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData, "{err}"); + assert!(err.to_string().contains("longer than 100 bytes"), "{err}"); + assert!(remote.conn.is_none()); + server.join().unwrap(); + } + + #[test] + fn replies_up_to_the_limit_are_read() { + let mut input: &[u8] = b"0123456789\nrest\n"; + let mut reply = String::new(); + assert_eq!(read_reply_line(&mut input, &mut reply, 10).unwrap(), 11); + assert_eq!(reply, "0123456789\n"); + } + /// A server that reads one request per connection and closes without replying must not see the request twice. #[test] fn lost_reply_to_a_mutation_is_not_resent() { From 1d987904f9064b8e712a4abbd4e065f3a238c1fa Mon Sep 17 00:00:00 2001 From: bmh129 Date: Thu, 29 Oct 2026 20:21:54 -0500 Subject: [PATCH 15/29] Tests remove the temp files they write (#2232) * Tests remove the temp files they write Every test run left about 22 folders and files in the system temp directory: xref, alias, template, insert, linetype, script and recent-file fixtures, plus the pathless screenshot folder. The PDFBOOK test also wrote its second book to `$TMPDIR/b.pdf`, because `a^b.pdf` splits into `-a.pdf` and `b.pdf`, and then removed a file that was never written. Tests that write files now use a `TempDir` guard (`tests/support/temp_dir.rs`, shared by the engine's unit and integration tests and copied for the UI crate). It gives each test its own folder and removes it on drop, even when an assertion fails. The PDFBOOK test also checks that both books land in its folder. The five tests that write pathless screenshots hold a test-only lock, so the shared screenshot folder can be removed once empty without racing another test's write. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012FFPi6hJVyaEKV4PyE2fKq * Pathless screenshot cleanup survives failed tests and writes The test lock's guard now empties and removes the per-process screenshot directory when dropped, so a test that panics no longer leaves files behind, and the per-test remove calls are gone. A failed PNG write or flush removes the partial file, since the reply carries no path for anyone else to clean up. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: bmh129 <11478682+bmh129@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/exchange.rs | 9 ++--- crates/engine/src/cmd/file.rs | 18 +++++----- crates/engine/src/lib.rs | 4 +++ crates/engine/tests/aliases.rs | 11 +++--- crates/engine/tests/insert_file.rs | 22 ++++++------ crates/engine/tests/lin_pat_files.rs | 25 +++++++------ crates/engine/tests/support/temp_dir.rs | 46 ++++++++++++++++++++++++ crates/engine/tests/templates.rs | 37 ++++++++++--------- crates/engine/tests/xref.rs | 16 +++++---- crates/ui-egui/src/capture/tests.rs | 12 +++---- crates/ui-egui/src/control.rs | 42 +++++++++++++++++++--- crates/ui-egui/tests/recent_files.rs | 25 ++++++------- crates/ui-egui/tests/support/temp_dir.rs | 46 ++++++++++++++++++++++++ crates/ui-egui/tests/xref_palette.rs | 16 ++++----- 14 files changed, 230 insertions(+), 99 deletions(-) create mode 100644 crates/engine/tests/support/temp_dir.rs create mode 100644 crates/ui-egui/tests/support/temp_dir.rs diff --git a/crates/engine/src/cmd/exchange.rs b/crates/engine/src/cmd/exchange.rs index f9acc909e..e28de89fc 100644 --- a/crates/engine/src/cmd/exchange.rs +++ b/crates/engine/src/cmd/exchange.rs @@ -754,19 +754,20 @@ mod tests { s.cmdline("").unwrap(); s.cmdline("pline 0,900 50,900 50,950 0,950 c").unwrap(); s.cmdline("").unwrap(); - let r = s.execute("pdfbook", &json!({ "path": temp("a^b.pdf").to_string_lossy() })); + // `a^b.pdf` names one book per row, both in the folder of the path. + let dir = crate::temp_dir::TempDir::new("pdfbook"); + let r = s.execute("pdfbook", &json!({ "path": dir.path_str("a^b.pdf") })); let r = r.unwrap(); let books = r["books"].as_array().unwrap(); assert_eq!(books.len(), 2); assert_eq!(books[0]["pages"], 3); assert_eq!(books[1]["pages"], 1); - let first = temp("a.pdf"); + let first = dir.join("a.pdf"); let text = String::from_utf8(std::fs::read(&first).unwrap()).unwrap(); let (a4, a3) = (text.find("ISO A4").unwrap(), text.find("ISO A3").unwrap()); assert!(a4 < a3, "frames go left to right: {text}"); assert!(text.contains("\"landscape\":true")); - let _ = std::fs::remove_file(first); - let _ = std::fs::remove_file(temp("b.pdf")); + assert!(dir.join("b.pdf").is_file(), "the second row's book is written beside the first"); // No frames: an error, not a panic. assert!(Session::new().execute("pdfbook", &json!({})).is_err()); } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 3a5296e88..7c26a71f9 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -652,20 +652,16 @@ mod tests { use serde_json::json; use crate::Session; + use crate::temp_dir::TempDir; fn active_title(s: &Session) -> String { s.state().unwrap().title.clone() } - fn temp_script(name: &str, text: &str) -> String { - let path = std::env::temp_dir().join(format!("cadcraft-script-{}-{name}", std::process::id())); - std::fs::write(&path, text).unwrap(); - path.to_string_lossy().into_owned() - } - #[test] fn script_command_runs_a_scr_file() { - let path = temp_script("run.scr", "; a comment\ncircle 50,25 20\nrectang 0,0 100,60\n"); + let dir = TempDir::new("script"); + let path = dir.file("run.scr", "; a comment\ncircle 50,25 20\nrectang 0,0 100,60\n"); let mut s = Session::new(); let r = s.execute("script", &json!({ "path": path })).unwrap(); assert_eq!(r["lines"], 3); @@ -683,8 +679,9 @@ mod tests { /// A script that runs itself must be refused, not recurse until the stack dies. #[test] fn a_script_cannot_start_a_script() { - let inner = temp_script("inner.scr", "circle 0,0 1\n"); - let outer = temp_script("outer.scr", &format!("script {}\ncircle 0,0 2\n", json!({ "path": inner }))); + let dir = TempDir::new("script"); + let inner = dir.file("inner.scr", "circle 0,0 1\n"); + let outer = dir.file("outer.scr", format!("script {}\ncircle 0,0 2\n", json!({ "path": inner }))); let mut s = Session::new(); let e = s.execute("script", &json!({ "path": outer })).unwrap_err().to_string(); assert!(e.contains("already running"), "{e}"); @@ -692,7 +689,8 @@ mod tests { #[test] fn typed_script_prompts_for_the_file_then_runs_or_reports_it() { - let good = temp_script("typed.scr", "circle 0,0 1\n"); + let dir = TempDir::new("script"); + let good = dir.file("typed.scr", "circle 0,0 1\n"); let mut s = Session::new(); s.cmdline("script").unwrap(); assert!(s.running.is_some(), "SCRIPT asks for the file name"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index dbb1d0e6b..2dea12394 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -324,3 +324,7 @@ impl Session { #[cfg(test)] mod tests; + +#[cfg(test)] +#[path = "../tests/support/temp_dir.rs"] +mod temp_dir; diff --git a/crates/engine/tests/aliases.rs b/crates/engine/tests/aliases.rs index 7587a9ffb..d1b39bb81 100644 --- a/crates/engine/tests/aliases.rs +++ b/crates/engine/tests/aliases.rs @@ -8,6 +8,11 @@ use cadcraft_engine::Session; use cadcraft_engine::cmd::resolve_alias; use serde_json::json; +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + static ONE_AT_A_TIME: Mutex<()> = Mutex::new(()); const PGP: &str = "\ @@ -55,10 +60,8 @@ fn an_alias_file_adds_and_overrides_aliases() { #[test] fn reinit_reads_the_alias_file_again() { let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner()); - let dir = std::env::temp_dir().join(format!("cadcraft-aliases-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let file = dir.join("mine.pgp").to_string_lossy().into_owned(); - std::fs::write(&file, "ZZR, *RECTANG\n").unwrap(); + let dir = TempDir::new("aliases"); + let file = dir.file("mine.pgp", "ZZR, *RECTANG\n"); let mut s = Session::new(); s.execute("aliasedit", &json!({ "file": file })).unwrap(); assert_eq!(resolve_alias("zzr"), "rectang"); diff --git a/crates/engine/tests/insert_file.rs b/crates/engine/tests/insert_file.rs index 77bfd9d85..41f7a92a7 100644 --- a/crates/engine/tests/insert_file.rs +++ b/crates/engine/tests/insert_file.rs @@ -8,6 +8,11 @@ use cadcraft_engine::doc::geom::Vec3; use cadcraft_engine::doc::{Block, Circle, Drawing, Entity, EntityKind, HVal, Handle, Insert, Layer, Line}; use serde_json::json; +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + /// A drawing file in a test format: its bytes are "x,y", the $INSBASE. Model space: a line from /// 10,10 to 20,10 on layer PARTS (colour 1), and a reference to block NUT (a circle) at 10,10. fn read(bytes: &[u8], _name: &str) -> Result { @@ -44,15 +49,6 @@ fn hooks() { set_io(IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None }); } -/// Write a test drawing file with $INSBASE `base`; returns its path. -fn file(name: &str, base: &str) -> String { - let dir = std::env::temp_dir().join(format!("cadcraft-insert-file-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let p = dir.join(name); - std::fs::write(&p, base).unwrap(); - p.to_string_lossy().into_owned() -} - /// Lines in model space as (ax, ay, bx, by), rounded. fn lines(s: &Session) -> Vec<(f64, f64, f64, f64)> { let r = |v: f64| (v * 1e6).round() / 1e6; @@ -105,7 +101,9 @@ fn star_name_inserts_the_blocks_objects_with_one_scale_factor() { #[test] fn a_drawing_file_becomes_a_block_with_its_insbase_and_tables() { hooks(); - let bolt = file("bolt.dxf", "10,10"); + // Test drawing files hold their $INSBASE. + let dir = TempDir::new("insert-file"); + let bolt = dir.file("bolt.dxf", "10,10"); let mut s = Session::new(); // This drawing's own layer PARTS keeps its colour. s.execute("layer.new", &json!({ "name": "PARTS", "color": 3 })).unwrap(); @@ -128,7 +126,7 @@ fn a_drawing_file_becomes_a_block_with_its_insbase_and_tables() { assert_eq!(inserts(&s).len(), 2); // NAME=path defines the block under that name without asking; *path inserts the file's objects. - let pin = file("pin.dxf", "10,10"); + let pin = dir.file("pin.dxf", "10,10"); s.script(&format!("-INSERT M8={bolt} 0,0 1 1 0\n-INSERT *{pin} 50,0 1 0\n")).unwrap(); assert!(s.running.is_none(), "INSERT finished: {}", s.prompt_text()); assert!(s.doc().unwrap().block("M8").is_some()); @@ -138,7 +136,7 @@ fn a_drawing_file_becomes_a_block_with_its_insbase_and_tables() { assert!(inserts(&s).iter().any(|i| i.0 == "NUT" && i.1 == 50.0 && i.2 == 0.0)); // JSON: `file` with an optional block name, never a dialog. - let r = s.execute("insert", &json!({ "file": file("washer.dxf", "0,0"), "at": [5, 5] })).unwrap(); + let r = s.execute("insert", &json!({ "file": dir.file("washer.dxf", "0,0"), "at": [5, 5] })).unwrap(); assert!(r["handle"].is_string(), "{r}"); assert!(inserts(&s).iter().any(|i| i.0 == "washer")); diff --git a/crates/engine/tests/lin_pat_files.rs b/crates/engine/tests/lin_pat_files.rs index 1c57d9f25..b09b504d4 100644 --- a/crates/engine/tests/lin_pat_files.rs +++ b/crates/engine/tests/lin_pat_files.rs @@ -6,16 +6,12 @@ use cadcraft_engine::Session; use cadcraft_engine::doc::{EntityKind, Hatch}; use serde_json::json; -const LIN: &str = "*MYDASH,My dash __ __\nA,.5,-.25\n*MYTEXT,My text --MY--\nA,.5,-.2,[\"MY\",Standard,S=.1,X=-.1,Y=-.05],-.2\n"; +#[path = "support/temp_dir.rs"] +mod temp_dir; -/// A user file in the test's temp folder; returns its path. -fn file(name: &str, text: &str) -> String { - let dir = std::env::temp_dir().join(format!("cadcraft-lin-pat-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let p = dir.join(name); - std::fs::write(&p, text).unwrap(); - p.to_string_lossy().into_owned() -} +use temp_dir::TempDir; + +const LIN: &str = "*MYDASH,My dash __ __\nA,.5,-.25\n*MYTEXT,My text --MY--\nA,.5,-.2,[\"MY\",Standard,S=.1,X=-.1,Y=-.05],-.2\n"; fn linetype_pattern(s: &Session, name: &str) -> Option> { s.doc().unwrap().linetype(name).map(|l| l.pattern.iter().map(|e| e.length).collect()) @@ -46,7 +42,8 @@ fn linetype_json_loads_named_linetypes_from_file_text() { #[test] fn linetype_json_reads_a_lin_file_by_path() { - let path = file("mine.lin", LIN); + let dir = TempDir::new("lin-pat"); + let path = dir.file("mine.lin", LIN); let mut s = Session::new(); s.execute("linetype", &json!({ "load": "MYTEXT", "file": path })).unwrap(); assert!(linetype_pattern(&s, "MYTEXT").is_some()); @@ -58,7 +55,8 @@ fn linetype_json_reads_a_lin_file_by_path() { #[test] fn dash_linetype_loads_from_a_file_and_the_library_at_the_command_line() { - let path = file("cmdline.lin", LIN); + let dir = TempDir::new("lin-pat"); + let path = dir.file("cmdline.lin", LIN); let mut s = Session::new(); s.cmdline("-linetype").unwrap(); for line in ["l", "MYDASH,MYTEXT", path.as_str(), "s", "MYTEXT"] { @@ -72,7 +70,7 @@ fn dash_linetype_loads_from_a_file_and_the_library_at_the_command_line() { assert!(s.log.iter().any(|l| l.contains("Linetype MYDASH loaded")), "{:?}", s.log); // Already loaded: asked whether to reload, in the file's order. - let changed = file("changed.lin", &LIN.replace(".5,-.25", "2,-2")); + let changed = dir.file("changed.lin", LIN.replace(".5,-.25", "2,-2")); s.cmdline("-linetype").unwrap(); for line in ["l", "*", changed.as_str()] { s.cmdline(line).unwrap(); @@ -132,7 +130,8 @@ fn hatch_uses_a_pattern_from_a_pat_file_and_keeps_its_definition() { #[test] fn hatch_pattern_prompt_takes_a_pat_file_name() { - let path = file("MYSTAIRS.pat", "*MYOTHER\n0,0,0,0,1\n*MYSTAIRS,Stairs\n0,0,0,.5,.5,.5,-.5\n90,.5,0,.5,.5,.5,-.5\n"); + let dir = TempDir::new("lin-pat"); + let path = dir.file("MYSTAIRS.pat", "*MYOTHER\n0,0,0,0,1\n*MYSTAIRS,Stairs\n0,0,0,.5,.5,.5,-.5\n90,.5,0,.5,.5,.5,-.5\n"); let mut s = Session::new(); s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); s.cmdline("hatch").unwrap(); diff --git a/crates/engine/tests/support/temp_dir.rs b/crates/engine/tests/support/temp_dir.rs new file mode 100644 index 000000000..3b4662e0d --- /dev/null +++ b/crates/engine/tests/support/temp_dir.rs @@ -0,0 +1,46 @@ +//! A test's own folder in the system temp directory, removed when the test ends, even when an +//! assertion fails. Shared by the integration tests (`#[path]`) and the crate's unit tests. +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// `cadcraft---`: the process id keeps parallel test binaries apart and the counter +/// keeps the tests of one binary apart, so no two tests ever share (or delete) one folder. +pub struct TempDir(PathBuf); + +impl TempDir { + pub fn new(tag: &str) -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let n = NEXT.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("cadcraft-{tag}-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create the test's temp folder"); + Self(dir) + } + + /// Write `contents` to `name` in the folder; returns the file's path as text. + pub fn file(&self, name: &str, contents: impl AsRef<[u8]>) -> String { + let p = self.0.join(name); + std::fs::write(&p, contents).expect("write a test file"); + p.to_string_lossy().into_owned() + } + + /// The path of `name` in the folder as text, without creating it. + pub fn path_str(&self, name: &str) -> String { + self.0.join(name).to_string_lossy().into_owned() + } +} + +impl std::ops::Deref for TempDir { + type Target = Path; + fn deref(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} diff --git a/crates/engine/tests/templates.rs b/crates/engine/tests/templates.rs index c518e3b06..cb8636061 100644 --- a/crates/engine/tests/templates.rs +++ b/crates/engine/tests/templates.rs @@ -9,6 +9,11 @@ use cadcraft_engine::cmd::file::{IoHooks, set_io}; use cadcraft_engine::doc::Drawing; use serde_json::json; +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + static FILES: Mutex> = Mutex::new(Vec::new()); fn write(d: &Drawing, name: &str) -> Result, String> { @@ -27,17 +32,11 @@ fn hooks() { set_io(IoHooks { read, write, plot: None }); } -fn path(name: &str) -> String { - let dir = std::env::temp_dir().join(format!("cadcraft-templates-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - dir.join(name).to_string_lossy().into_owned() -} - /// A drawing with an office layer and a title block circle, saved as a template. -fn office_template(s: &mut Session, name: &str) -> String { +fn office_template(s: &mut Session, dir: &TempDir, name: &str) -> String { s.execute("layer.new", &json!({ "name": "A-WALL", "color": 3 })).unwrap(); s.execute("circle", &json!({ "center": [5, 5], "radius": 2 })).unwrap(); - let p = path(name); + let p = dir.path_str(name); s.execute("saveas", &json!({ "path": p })).unwrap(); p } @@ -46,7 +45,8 @@ fn office_template(s: &mut Session, name: &str) -> String { fn saveas_dwt_then_new_from_the_template() { hooks(); let mut s = Session::new(); - let dwt = office_template(&mut s, "office.dwt"); + let dir = TempDir::new("templates"); + let dwt = office_template(&mut s, &dir, "office.dwt"); // A template is saved like a drawing: the format sees the .dwt name, the drawing is saved. assert!(FILES.lock().unwrap().iter().any(|(n, _)| n.ends_with("office.dwt"))); let st = s.state().unwrap(); @@ -68,7 +68,8 @@ fn saveas_dwt_then_new_from_the_template() { fn qnewtemplate_is_the_default_for_new_and_qnew() { hooks(); let mut s = Session::new(); - let dwt = office_template(&mut s, "default.dwt"); + let dir = TempDir::new("templates"); + let dwt = office_template(&mut s, &dir, "default.dwt"); let docs = s.docs.len(); s.execute("setvar", &json!({ "name": "QNEWTEMPLATE", "value": dwt })).unwrap(); @@ -99,8 +100,8 @@ fn a_template_that_cannot_be_read_makes_no_drawing() { let docs = s.docs.len(); let err = s.execute("new", &json!({ "template": "/no/such/folder/office.dwt" })).unwrap_err().to_string(); assert!(err.contains("office.dwt"), "{err}"); - let junk = path("junk.dwt"); - std::fs::write(&junk, b"not a drawing at all").unwrap(); + let dir = TempDir::new("templates"); + let junk = dir.file("junk.dwt", b"not a drawing at all"); assert!(s.execute("new", &json!({ "template": junk })).is_err()); assert!(s.execute("new", &json!({ "template": 42 })).is_err()); assert_eq!(s.docs.len(), docs); @@ -121,6 +122,7 @@ fn layout_names(d: &Drawing) -> Vec { fn layout_saveas_writes_the_layout_and_the_named_objects_it_uses() { hooks(); let mut s = Session::new(); + let dir = TempDir::new("templates"); for name in ["TITLE", "PARTS-LAYER", "UNUSED", "WALLS", "NOTES"] { s.execute("layer.new", &json!({ "name": name })).unwrap(); } @@ -138,7 +140,7 @@ fn layout_saveas_writes_the_layout_and_the_named_objects_it_uses() { s.execute("layer.current", &json!({ "name": "0" })).unwrap(); let sheet_objects = s.doc().unwrap().layout("Sheet").unwrap().entities.iter().count(); - let p = path("sheet.dwt"); + let p = dir.path_str("sheet.dwt"); let r = s.execute("layout.saveas", &json!({ "path": p })).unwrap(); assert_eq!(r["name"], json!("Sheet"), "the current layout by default"); assert_eq!(r["path"], json!(p)); @@ -175,13 +177,14 @@ fn layout_saveas_writes_the_layout_and_the_named_objects_it_uses() { fn typed_layout_saveas_asks_for_the_layout_and_the_template_file() { hooks(); let mut s = Session::new(); + let dir = TempDir::new("templates"); s.cmdline("LAYOUT").unwrap(); s.cmdline("sa").unwrap(); assert_eq!(s.current_prompt().unwrap().display(), "Enter layout to save to template :"); s.cmdline("Layout2").unwrap(); assert_eq!(s.current_prompt().unwrap().display(), "Enter template file name :"); // No extension: the template is a .dwt. - let p = path("typed"); + let p = dir.path_str("typed"); s.cmdline(&p).unwrap(); assert!(s.current_prompt().is_none()); let saved = format!("{p}.dwt"); @@ -189,11 +192,11 @@ fn typed_layout_saveas_asks_for_the_layout_and_the_template_file() { assert_eq!(layout_names(&written("typed.dwt")), ["Layout2"]); // The JSON form of LAYOUT, other file types, a missing path and an unknown layout. - let dxf = path("sheet1.dxf"); + let dxf = dir.path_str("sheet1.dxf"); let r = s.execute("layout", &json!({ "option": "saveas", "name": "layout1", "path": dxf })).unwrap(); assert_eq!(r["name"], json!("Layout1")); assert_eq!(layout_names(&written("sheet1.dxf")), ["Layout1"]); - assert!(s.execute("layout.saveas", &json!({ "path": path("sheet.png") })).is_err()); + assert!(s.execute("layout.saveas", &json!({ "path": dir.path_str("sheet.png") })).is_err()); assert!(s.execute("layout.saveas", &json!({})).is_err()); - assert!(s.execute("layout.saveas", &json!({ "name": "Nope", "path": path("nope.dwt") })).is_err()); + assert!(s.execute("layout.saveas", &json!({ "name": "Nope", "path": dir.path_str("nope.dwt") })).is_err()); } diff --git a/crates/engine/tests/xref.rs b/crates/engine/tests/xref.rs index d53e37886..c67c627b2 100644 --- a/crates/engine/tests/xref.rs +++ b/crates/engine/tests/xref.rs @@ -2,7 +2,7 @@ //! loading with `xref|name` symbols, saving only the reference, reopening, Reload/Unload/Detach/ //! Path, Bind and XBIND, overlays, missing files, circular and deeply nested references. -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Arc; use cadcraft_engine::Session; @@ -12,6 +12,11 @@ use cadcraft_engine::doc::geom::Vec3; use cadcraft_engine::doc::{Block, Circle, Drawing, Entity, EntityKind, Handle, Insert, Layer, Line, Linetype, MAX_BLOCK_DEPTH, XrefStatus}; use serde_json::json; +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + fn hooks() { set_io(IoHooks { read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()), @@ -20,12 +25,9 @@ fn hooks() { }); } -/// A fresh folder for one test. -fn folder(tag: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("cadcraft-xref-{}-{tag}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir +/// A fresh folder for one test, removed when the test ends. +fn folder(tag: &str) -> TempDir { + TempDir::new(&format!("xref-{tag}")) } fn save(d: &Drawing, path: &Path) { diff --git a/crates/ui-egui/src/capture/tests.rs b/crates/ui-egui/src/capture/tests.rs index 6e70aea7b..b85961c44 100644 --- a/crates/ui-egui/src/capture/tests.rs +++ b/crates/ui-egui/src/capture/tests.rs @@ -168,6 +168,7 @@ fn focus_change_cancels_an_unissued_capture() { /// Two requests issue from one settled frame; each reply carries its own token and a distinct, readable file with a PNG signature. #[test] fn issued_callbacks_reply_once_with_matching_tokens_and_distinct_pathless_files() { + let _shots = crate::control::lock_pathless_shots(); let ctx = egui::Context::default(); let mut app = app(); set_geometry(&mut app, &ctx, egui::vec2(800.0, 600.0)); @@ -190,7 +191,6 @@ fn issued_callbacks_reply_once_with_matching_tokens_and_distinct_pathless_files( assert_ne!(a, b, "one file per request"); for path in [&a, &b] { assert!(std::fs::read(path).is_ok_and(|bytes| bytes.starts_with(b"\x89PNG\r\n\x1a\n")), "{path}"); - let _ = std::fs::remove_file(path); } assert!(app.shots.is_empty()); } @@ -342,6 +342,7 @@ fn raw_geometry_uses_screen_rect_when_inner_rect_is_absent() { /// not issue, and the settled second pass must issue exactly once. #[test] fn a_discarded_pass_does_not_issue_and_the_settled_pass_does_once() { + let _shots = crate::control::lock_pathless_shots(); let ctx = egui::Context::default(); let mut app = app(); let (rx, _) = admit_one(&mut app, &ctx); @@ -365,13 +366,15 @@ fn a_discarded_pass_does_not_issue_and_the_settled_pass_does_once() { assert_eq!(app.ui_frames, 1, "one settled frame, not one per pass"); complete(callbacks, [800, 600]); drain(&mut app, &ctx); - assert_eq!(rx.recv().expect("a reply")["ok"], true); + let reply = rx.recv().expect("a reply"); + assert_eq!(reply["ok"], true, "{reply}"); } /// Issuance re-bases the expected image on the settled pass, so the saved interface zoom /// (applied by egui at the start of a later pass) cannot leave a stale admission estimate. #[test] fn issuance_uses_the_settled_interface_scale_not_the_admission_estimate() { + let _shots = crate::control::lock_pathless_shots(); let ctx = egui::Context::default(); let mut app = app(); app.options.interface_scale = 2.0; @@ -387,8 +390,6 @@ fn issuance_uses_the_settled_interface_scale_not_the_admission_estimate() { drain(&mut app, &ctx); let reply = rx.recv().expect("a reply"); assert_eq!(reply["ok"], true, "{reply}"); - let path = reply["result"]["path"].as_str().expect("a file").to_string(); - let _ = std::fs::remove_file(path); } /// A→B→A: a switch that returns to the same drawing still invalidates the queued request. @@ -447,6 +448,7 @@ fn a_settled_window_does_not_keep_changing_the_surface_epoch() { /// A window resized while the request is still settling is issued against the new size. #[test] fn a_resize_while_settling_is_absorbed_at_issue() { + let _shots = crate::control::lock_pathless_shots(); let ctx = egui::Context::default(); let mut app = app(); let (rx, _) = admit_one(&mut app, &ctx); @@ -464,6 +466,4 @@ fn a_resize_while_settling_is_absorbed_at_issue() { drain(&mut app, &ctx); let reply = rx.recv().expect("a reply"); assert_eq!(reply["ok"], true, "{reply}"); - let path = reply["result"]["path"].as_str().expect("a file").to_string(); - let _ = std::fs::remove_file(path); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2dc1f898a..7330681db 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -619,6 +619,35 @@ fn is_private_shot_dir(meta: &std::fs::Metadata) -> bool { } } +/// Pathless screenshots share one directory per process. Tests that write them hold this guard, +/// so no other test writes while it is cleaned up. Dropping it, even while a failed test unwinds, +/// removes every screenshot file in the directory and then the directory itself. +#[cfg(test)] +pub(crate) struct PathlessShots { + _lock: std::sync::MutexGuard<'static, ()>, +} + +#[cfg(test)] +pub(crate) fn lock_pathless_shots() -> PathlessShots { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + PathlessShots { _lock: LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } +} + +#[cfg(test)] +impl Drop for PathlessShots { + fn drop(&mut self) { + let Ok(dir) = private_shot_dir() else { return }; + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + if entry.file_name().to_string_lossy().starts_with("cadcraft-shot-") { + let _ = std::fs::remove_file(entry.path()); + } + } + } + let _ = std::fs::remove_dir(&dir); + } +} + /// Save a pathless screenshot into the private per-process directory: re-validate or recreate it as an owner-only real directory (0700 on Unix; plain create elsewhere), then create up to 64 exclusive files (0600 on Unix) and write a PNG. fn save_pathless_screenshot(img: &image::RgbaImage, token: u64) -> std::io::Result { use std::io::Write as _; @@ -641,8 +670,13 @@ fn save_pathless_screenshot(img: &image::RgbaImage, token: u64) -> std::io::Resu let path = dir.join(format!("cadcraft-shot-{token}-{}.png", next_pathless_nonce())); match create_private_file(&path) { Ok(mut file) => { - img.write_to(&mut file, image::ImageFormat::Png).map_err(std::io::Error::other)?; - file.flush()?; + let written = img.write_to(&mut file, image::ImageFormat::Png).map_err(std::io::Error::other).and_then(|()| file.flush()); + if let Err(e) = written { + // The reply carries no path on failure, so nobody else could remove the partial file. + drop(file); + let _ = std::fs::remove_file(&path); + return Err(e); + } return Ok(path); } Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, @@ -735,6 +769,7 @@ mod tests { /// Two pathless saves with the same token produce distinct, decodable PNGs; on Unix the files are 0600 and their directory 0700. #[test] fn pathless_screenshots_are_unique_private_png_files() { + let _shots = lock_pathless_shots(); let img = egui::ColorImage::filled([4, 3], egui::Color32::RED); let first = save_screenshot(&img, None, 1); let second = save_screenshot(&img, None, 1); @@ -759,9 +794,6 @@ mod tests { let dir_mode = std::fs::metadata(dir).expect("directory metadata").permissions().mode() & 0o777; assert_eq!(dir_mode, 0o700, "the screenshot directory is owner-only"); } - for path in [&a, &b] { - let _ = std::fs::remove_file(path); - } } /// `create_new` refuses a pre-existing symlink with `AlreadyExists` and leaves the link target unchanged. diff --git a/crates/ui-egui/tests/recent_files.rs b/crates/ui-egui/tests/recent_files.rs index 946a6792a..34d460255 100644 --- a/crates/ui-egui/tests/recent_files.rs +++ b/crates/ui-egui/tests/recent_files.rs @@ -9,17 +9,18 @@ use cadcraft_ui_egui::{CadApp, Services}; use egui::{Rect, Shape, vec2}; use egui_kittest::Harness; -/// Two drawing files on disk (the reader below accepts anything, so their contents don't matter). -fn drawings(test: &str) -> (String, String) { +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + +/// Two drawing files on disk (the reader below accepts anything, so their contents don't matter), +/// in a folder that is removed when the returned guard is dropped. +fn drawings(test: &str) -> (TempDir, String, String) { set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(Vec::new()), plot: None }); - let dir = std::env::temp_dir().join(format!("cadcraft-recent-{test}-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = |name: &str| { - let p = dir.join(name); - std::fs::write(&p, b"0\nEOF\n").unwrap(); - p.to_string_lossy().to_string() - }; - (path("plan.dxf"), path("section.dwg")) + let dir = TempDir::new(&format!("recent-{test}")); + let (plan, section) = (dir.file("plan.dxf", b"0\nEOF\n"), dir.file("section.dwg", b"0\nEOF\n")); + (dir, plan, section) } /// Wait for the drawings being read in the background to be added (or reported). @@ -49,7 +50,7 @@ fn open_recent(app: &CadApp) -> Vec<(String, String)> { #[test] fn opened_drawings_are_listed_newest_first_and_survive_a_restart() { - let (plan, section) = drawings("menu"); + let (_dir, plan, section) = drawings("menu"); let mut app = CadApp::new(Session::empty(), Services::default()); assert!(app.ui.recent_files.is_empty()); @@ -99,7 +100,7 @@ fn texts(shape: &Shape, out: &mut Vec<(String, Rect)>) { #[test] fn start_page_lists_recent_files_and_opens_one_on_click() { - let (plan, section) = drawings("start"); + let (_dir, plan, section) = drawings("start"); let mut app = CadApp::new(Session::empty(), Services::default()); app.ui.recent_files = vec![section.clone(), plan.clone()]; let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_ui_state( diff --git a/crates/ui-egui/tests/support/temp_dir.rs b/crates/ui-egui/tests/support/temp_dir.rs new file mode 100644 index 000000000..e1f16b8c1 --- /dev/null +++ b/crates/ui-egui/tests/support/temp_dir.rs @@ -0,0 +1,46 @@ +//! A test's own folder in the system temp directory, removed when the test ends, even when an +//! assertion fails. Shared by the integration tests through `#[path]`. +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// `cadcraft---`: the process id keeps parallel test binaries apart and the counter +/// keeps the tests of one binary apart, so no two tests ever share (or delete) one folder. +pub struct TempDir(PathBuf); + +impl TempDir { + pub fn new(tag: &str) -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let n = NEXT.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("cadcraft-{tag}-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create the test's temp folder"); + Self(dir) + } + + /// Write `contents` to `name` in the folder; returns the file's path as text. + pub fn file(&self, name: &str, contents: impl AsRef<[u8]>) -> String { + let p = self.0.join(name); + std::fs::write(&p, contents).expect("write a test file"); + p.to_string_lossy().into_owned() + } + + /// The path of `name` in the folder as text, without creating it. + pub fn path_str(&self, name: &str) -> String { + self.0.join(name).to_string_lossy().into_owned() + } +} + +impl std::ops::Deref for TempDir { + type Target = Path; + fn deref(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} diff --git a/crates/ui-egui/tests/xref_palette.rs b/crates/ui-egui/tests/xref_palette.rs index 2f242b277..f950427c4 100644 --- a/crates/ui-egui/tests/xref_palette.rs +++ b/crates/ui-egui/tests/xref_palette.rs @@ -11,6 +11,11 @@ use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use serde_json::json; +#[path = "support/temp_dir.rs"] +mod temp_dir; + +use temp_dir::TempDir; + /// Test drawings: any file holds one line on layer Walls. fn read(_bytes: &[u8], _name: &str) -> Result { let mut d = Drawing::new_metric(); @@ -21,14 +26,6 @@ fn read(_bytes: &[u8], _name: &str) -> Result { Ok(d) } -fn site_file() -> String { - let dir = std::env::temp_dir().join(format!("cadcraft-xref-palette-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let p = dir.join("site.dxf"); - std::fs::write(&p, "site").unwrap(); - p.to_string_lossy().into_owned() -} - fn status(app: &CadApp) -> Option { app.session.doc().ok()?.block("site").map(|b| b.xref_status) } @@ -37,7 +34,8 @@ fn status(app: &CadApp) -> Option { fn xref_opens_the_palette_and_its_buttons_manage_the_xref() { set_io(IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None }); let mut app = CadApp::new(Session::default(), Services::default()); - app.run("xattach", json!({ "path": site_file(), "at": [10, 10] })).unwrap(); + let dir = TempDir::new("xref-palette"); + app.run("xattach", json!({ "path": dir.file("site.dxf", "site"), "at": [10, 10] })).unwrap(); assert_eq!(status(&app), Some(XrefStatus::Loaded)); // Typed (by its alias) it opens the palette instead of listing. assert!(app.cmdline("XR").is_none()); From b9b7b3d439d0ff889475116a1a145b3f2e65f9c8 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:21:58 +0100 Subject: [PATCH 16/29] Refuse invalid count in JSON copy instead of clamping it (#2294) Found while testing crates/engine/src/cmd/modify/transform.rs:69 (run_copy) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/cmd/modify/transform.rs | 5 ++++- crates/engine/tests/it/copy_count.rs | 24 +++++++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/it/copy_count.rs diff --git a/crates/engine/src/cmd/modify/transform.rs b/crates/engine/src/cmd/modify/transform.rs index 5474dde16..5a86d9e07 100644 --- a/crates/engine/src/cmd/modify/transform.rs +++ b/crates/engine/src/cmd/modify/transform.rs @@ -66,7 +66,10 @@ pub(super) fn run_move(s: &mut Session, p: &Value) -> Result { pub(super) fn run_copy(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; let d = delta_of("copy", p)?; - let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 10_000); + let n = match p.get("count") { + None => 1, + Some(v) => v.as_u64().filter(|n| (1..=10_000).contains(n)).ok_or_else(|| bad("copy", "`count` must be an integer from 1 to 10000"))?, + }; let mut out = Vec::new(); for k in 1..=n { out.extend(transform_entities(s, &hs, &Mat3::translate(d * k as f64), true)?.into_iter().map(|h| h.hex())); diff --git a/crates/engine/tests/it/copy_count.rs b/crates/engine/tests/it/copy_count.rs new file mode 100644 index 000000000..64deab8f0 --- /dev/null +++ b/crates/engine/tests/it/copy_count.rs @@ -0,0 +1,24 @@ +//! JSON COPY refuses a `count` that is not an integer from 1 to 10000 instead of clamping it. + +use cadcraft_engine::Session; +use serde_json::{Value, json}; + +fn entities(s: &Session) -> usize { + s.doc().map(|d| d.model.iter().count()).unwrap_or(0) +} + +#[test] +fn invalid_copy_count_is_refused() { + let mut s = Session::new(); + let r = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + let h = r["handles"][0].clone(); + let before = entities(&s); + let bad: [Value; 7] = [json!(0), json!(-3), json!(2.5), json!("abc"), json!(null), json!(10001), json!(50000)]; + for c in bad { + let r = s.execute("copy", &json!({"handles": [h], "delta": [0, 5], "count": c})); + assert!(r.is_err(), "count {c} should be refused"); + assert_eq!(entities(&s), before, "count {c} changed the drawing"); + } + s.execute("copy", &json!({"handles": [h], "delta": [0, 5], "count": 3})).unwrap(); + assert_eq!(entities(&s), before + 3); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 4a2a829a7..51cec4590 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -21,6 +21,7 @@ mod burst; mod compare_export; mod constraint_geometry; mod constraint_infer; +mod copy_count; mod count; mod dash_block; mod data_extraction; From 303bc97f9aa9dfb541548ba99f2a1cadf09f3d34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Max=20Mittelst=C3=A4dt?= <62167861+TheGalitube@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:22:01 +0100 Subject: [PATCH 17/29] Count DXF group codes correctly when enforcing limits (#2408) Found while testing crates/dxf/src/lib.rs:185 Co-authored-by: GPT-6-Luna --- crates/dxf/src/lib.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs index 4e888bdc0..b9e394766 100644 --- a/crates/dxf/src/lib.rs +++ b/crates/dxf/src/lib.rs @@ -181,8 +181,20 @@ pub fn parse_with_limit(bytes: &[u8], max_tags: usize) -> Result> { if let Some(body) = bytes.strip_prefix(BINARY_SENTINEL) { return parse_binary_with_limit(body, max_tags); } - // Every group code takes two lines (code, value). - let tags = bytes.iter().filter(|&&b| b == b'\n').count().div_ceil(2); + // Count nonblank group-code/value pairs, stopping at EOF just as the parser does. + let mut lines = bytes.split(|&b| b == b'\n'); + let mut tags = 0; + while let Some(code) = lines.next() { + let code = code.trim_ascii(); + if code.is_empty() { + continue; + } + let Some(value) = lines.next() else { break }; + tags += 1; + if code == b"0" && value.trim_ascii() == b"EOF" { + break; + } + } if tags > max_tags { return Err(DxfError::TooLarge { tags, limit: max_tags }); } @@ -581,6 +593,12 @@ mod tests { } } + #[test] + fn blank_lines_do_not_consume_group_code_limit() { + let tags = parse_with_limit(b"0\nEOF\n\n\n", 1).expect("one group code should fit a limit of one"); + assert_eq!(tags, vec![Tag::s(0, "EOF")]); + } + #[test] fn ascii_roundtrip() { let tags = vec![ From 3243271d4eea800a02cf532b202d833429dc631c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:31:09 +0100 Subject: [PATCH 18/29] Reject zero distance in JSON offset command (#2190) Found while testing crates/engine/src/cmd/modify.rs:791 (run_offset) Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/modify/offset.rs | 3 +++ crates/engine/src/tests.rs | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/crates/engine/src/cmd/modify/offset.rs b/crates/engine/src/cmd/modify/offset.rs index 01ca46c04..20e6ba58e 100644 --- a/crates/engine/src/cmd/modify/offset.rs +++ b/crates/engine/src/cmd/modify/offset.rs @@ -197,6 +197,9 @@ fn join_point(a: &Segment, b: &Segment) -> Option { pub(super) fn run_offset(s: &mut Session, p: &Value) -> Result { let h = targets(s, p)?.first().copied().ok_or_else(|| bad("offset", "`handle` is required"))?; let dist = f64_req("offset", p, "distance")?; + if dist == 0.0 { + return Err(bad("offset", "Requires a positive distance.")); + } let side = point_req("offset", p, "side")?; let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| bad("offset", "no such object"))?; let k = offset_kind(&e.kind, dist.abs(), side).ok_or_else(|| bad("offset", "cannot offset that object"))?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b06641fbb..be6246824 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -344,6 +344,15 @@ fn fillet_two_lines() { assert!(g.iter().any(|p| p.near(Vec2::new(2.0, 0.0), 1e-9))); } +#[test] +fn offset_zero_distance_is_rejected() { + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + let r = s.execute("offset", &json!({"handle": "100", "distance": 0, "side": [1, 1]})); + assert!(r.is_err(), "offset distance 0 made a coincident copy: {r:?}"); + assert_eq!(s.doc().unwrap().model.len(), 1); +} + #[test] fn offset_circle_and_polyline() { let mut s = Session::new(); From 0d9e62bc9aa55356f65310417fd80602f2b4db7e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:31:13 +0100 Subject: [PATCH 19/29] Stop DIMTMOVE 2 text taking the DIMTAD 1 clearance twice (#2302) Fixes #2222 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/render/src/dim/linear.rs | 16 ++++++++++------ crates/render/src/dim/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/crates/render/src/dim/linear.rs b/crates/render/src/dim/linear.rs index 40b684832..8127aef8f 100644 --- a/crates/render/src/dim/linear.rs +++ b/crates/render/src/dim/linear.rs @@ -150,11 +150,13 @@ fn position_linear_text(config: &DimConfig<'_>, state: &mut LinearDimState) { }; state.side_out = if (config.dimension.defpt.xy() - state.p1).dot(state.n) >= 0.0 { state.n } else { -state.n }; state.text_axis = Vec2::from_angle(state.angle); - let off_along = |axis: Vec2| { - let glyph_extent = state.text_width * state.text_axis.dot(axis).abs() / 2.0 - + state.text_height * state.text_axis.perp().dot(axis).abs() * if st.text_above == 1 { 0.6 } else { 0.5 }; - config.gap + glyph_extent + let clearance = |axis: Vec2, across: f64| { + config.gap + + state.text_width * state.text_axis.dot(axis).abs() / 2.0 + + state.text_height * state.text_axis.perp().dot(axis).abs() * across }; + // Empirical #2004 match: 0.595× height with SHX and 0.601× with TrueType. + let off_along = |axis: Vec2| clearance(axis, if st.text_above == 1 { 0.6 } else { 0.5 }); let above = if state.angle.abs() < 1e-12 && state.n.y.abs() > 1e-9 { if state.n.y > 0.0 { state.n } else { -state.n } } else if state.angle.abs() < 1e-12 { @@ -181,11 +183,13 @@ fn position_linear_text(config: &DimConfig<'_>, state: &mut LinearDimState) { + outward * (config.gap + state.text_height / 2.0); } else if state.moved { let mid = state.f1.mid(state.f2); - let lift = off_along(state.side_out) + 2.0 * config.arrow_size.max(config.tick_size); + // Half the text height either way, whatever DIMTAD says (#2222). + let clear = clearance(state.side_out, 0.5); + let lift = clear + 2.0 * config.arrow_size.max(config.tick_size); state.text_mid = if st.text_move == 1 { mid + state.side_out * lift + state.u * state.half_extent } else { - mid + state.side_out * (lift + off_along(state.side_out)) + mid + state.side_out * (lift + clear) }; } else if !config.dimension.user_text_pos { state.text_mid = state.along_center + state.offset; diff --git a/crates/render/src/dim/tests.rs b/crates/render/src/dim/tests.rs index fd439097c..fe3b3c74e 100644 --- a/crates/render/src/dim/tests.rs +++ b/crates/render/src/dim/tests.rs @@ -233,6 +233,29 @@ fn text_that_does_not_fit_follows_dimtmove_2() { assert!(g.lines_of(LineRole::Dim).flatten().all(|p| p.x <= 0.5 + 1e-9 && p.y >= 5.0 - 1e-9), "no leader, no line out to the text"); } +#[test] +fn dimtmove_2_text_clearance_matches_autocad() { + // #2222: the 0.10 dimension of #1998 in the #2004 1:50 style. AutoCAD 2026 recomputes the + // moved text at about 0.358 from the dimension line (0.658214, 0.05). + let st = st_with(|s| { + s.scale = 50.0; + s.text_height = 0.0025; + s.text_gap = 0.0008; + s.arrow_size = 0.0015; + s.text_above = 1; + s.text_inside_horizontal = false; + s.text_outside_horizontal = false; + s.decimals = 2; + s.text_move = 2; + }); + let d = lin((0.0, 0.0), (0.0, 0.10), (0.30, 0.0), std::f64::consts::FRAC_PI_2); + let g = dimension_geometry(&d, &st, 50.0); + assert!(g.text_pos.dist(Vec2::new(0.658214, 0.05)) < 0.005, "vertical: {:?}", g.text_pos); + let d = lin((0.0, 0.0), (0.10, 0.0), (0.0, -0.30), 0.0); + let g = dimension_geometry(&d, &st, 50.0); + assert!(g.text_pos.dist(Vec2::new(0.05, -0.658214)) < 0.005, "horizontal: {:?}", g.text_pos); +} + #[test] fn text_that_does_not_fit_follows_dimtmove_1() { // Moved off the line with a leader from the middle of the dimension line. From dcbcb6ab32fc13a043a58d3e0d020029b7aa7d17 Mon Sep 17 00:00:00 2001 From: Jonas <80707953+jonascloud@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:31:17 +0100 Subject: [PATCH 20/29] fix: keep WIPEOUT closure coherent across grips and outlines (#2284) * fix: keep WIPEOUT closure coherent across grips and outlines Treat WIPEOUT as a closed polygon with an optional exact terminal closure marker, not as an open polyline. Centralize logical vertices, explicit outlines and closure-preserving vertex edits in the document type. Reuse this contract in grips, geometric primitives, rendering and DXF export without changing the stored schema or writer closure convention. Add six-version DXF ASCII/binary and DWG regressions for implicit/explicit closure, both windings, all corners, API/interactive edits, undo/redo and repeated saves. Keep open/closed polylines and their coincident endpoints distinct. Cover multiple hot grips, hostile inputs and equivalent mask/frame output. Validation: cargo xtask ci, all seven stages passed (2571 tests passed, one pre-existing skip). Two unrelated network tests failed transiently in earlier runs; each passed in isolation and the complete final run passed without exclusions. Direct and reopened grip edits produce byte-identical CLI PNG output. Fixes #2256; related test-only matrix PR #2237 and tracker #2238 B3. * fix(doc): keep coincident WIPEOUT endpoints separately editable When an edit makes the endpoints of an implicit boundary coincide, add a separate closing marker rather than reclassifying the edited corner as that marker. Retain the logical vertex count and allow either coincident corner to move independently afterwards. The document regression fails before the fix. Add API/interactive coverage for both endpoint directions and both closure forms, undo/redo, and subsequent separate edits across all six DXF/DWG versions including binary DXF and save-after-edit. Update the documented editing contract. Validation: cargo xtask ci passed all seven stages; 2573 tests passed, one pre-existing skip. Addresses the coincident-endpoint review on PR #2284; related #2256. --------- Co-authored-by: cloudj Co-authored-by: Brandon Thomas --- crates/doc/src/entity.rs | 12 +- crates/doc/src/entity/fill.rs | 5 - crates/doc/src/entity/wipeout.rs | 162 +++++++++++++++++ crates/engine/src/grips.rs | 2 +- crates/engine/src/grips/tests.rs | 15 ++ crates/engine/tests/it/main.rs | 1 + crates/engine/tests/it/wipeout_grips.rs | 227 ++++++++++++++++++++++++ crates/io/src/dxf_write/entity.rs | 9 +- crates/render/src/draw.rs | 6 +- crates/render/tests/it/wipeout_mask.rs | 37 ++++ docs/file-format-parity.md | 2 +- docs/ui-parity.md | 5 +- 12 files changed, 455 insertions(+), 28 deletions(-) create mode 100644 crates/doc/src/entity/wipeout.rs create mode 100644 crates/engine/tests/it/wipeout_grips.rs diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index 089904109..d13b82722 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -44,14 +44,16 @@ mod dim; mod fill; mod layout; mod text; +mod wipeout; pub use block::{Attrib, AttribModes, AttribProps, Insert, MultilineAttrib, mtext_literal}; pub use common::{Common, Handle, Lineweight, RawTag, Transparency}; pub use curves::{Arc, Circle, Ellipse, Face3d, Line, LwPolyline, Point, Polyline3d, RayLine, Solid}; pub use dim::{AssocSnap, DimAssoc, DimBreak, DimKind, DimMarks, Dimension, Inspection, JoggedRadius, Leader, MLeader}; -pub use fill::{Gradient, Hatch, HatchLoop, Wipeout}; +pub use fill::{Gradient, Hatch, HatchLoop}; pub use layout::{Image, Table, TableCell, Viewport}; pub use text::{HAlign, MText, MTextColumnType, MTextColumns, Text, VAlign}; +pub use wipeout::Wipeout; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Unknown { pub dxf_type: String, @@ -226,11 +228,7 @@ impl EntityKind { h.loops.iter().flat_map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.segments()).map(Prim::Seg).collect() } EntityKind::Wipeout(w) => { - let mut pts = w.boundary.clone(); - if let Some(f) = pts.first().copied() { - pts.push(f); - } - pts.windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(*w.first()?, *w.get(1)?))))).collect() + w.closed_boundary().windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(*w.first()?, *w.get(1)?))))).collect() } EntityKind::Viewport(v) => { let c = v.center.xy(); @@ -535,7 +533,7 @@ impl EntityKind { Bounds2::new(c - h, c + h).corners().to_vec() } EntityKind::Image(i) => vec![i.insert.xy()], - EntityKind::Wipeout(w) => w.boundary.clone(), + EntityKind::Wipeout(w) => w.vertices().to_vec(), EntityKind::Table(t) => vec![t.insert.xy()], EntityKind::Tolerance(t) => vec![t.insert.xy()], EntityKind::Unknown(_) => Vec::new(), diff --git a/crates/doc/src/entity/fill.rs b/crates/doc/src/entity/fill.rs index 6790b0a7c..cff172a71 100644 --- a/crates/doc/src/entity/fill.rs +++ b/crates/doc/src/entity/fill.rs @@ -54,8 +54,3 @@ pub struct Gradient { #[serde(default)] pub centered: bool, } - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct Wipeout { - pub boundary: Vec, -} diff --git a/crates/doc/src/entity/wipeout.rs b/crates/doc/src/entity/wipeout.rs new file mode 100644 index 000000000..c79e93275 --- /dev/null +++ b/crates/doc/src/entity/wipeout.rs @@ -0,0 +1,162 @@ +//! WIPEOUT is always a closed masking polygon, unlike a polyline with an open/closed flag. +//! One exact terminal copy of the first point is a closure marker, not an editable corner. +//! Preserve logical corners when editing, adding a separate marker if the endpoints meet. +//! Grips, edits and explicit outlines share this rule; other duplicates are not simplified. + +use std::borrow::Cow; + +use cadcraft_geom::Vec2; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Wipeout { + /// Ordered boundary, implicitly closed or ending in a repeated first point. + pub boundary: Vec, +} + +impl Wipeout { + /// Editable vertices, excluding one exactly repeated closing point, without changing storage. + pub fn vertices(&self) -> &[Vec2] { + match self.boundary.split_last() { + Some((last, vertices)) if vertices.first() == Some(last) => vertices, + _ => &self.boundary, + } + } + + /// An explicitly closed outline for geometry, rendering and export. Already closed or + /// empty/single-point boundaries are borrowed; otherwise one closing point is appended. + pub fn closed_boundary(&self) -> Cow<'_, [Vec2]> { + if self.vertices().len() < self.boundary.len() || self.boundary.len() < 2 { + Cow::Borrowed(&self.boundary) + } else { + let mut points = self.boundary.clone(); + points.extend(self.boundary.first()); + Cow::Owned(points) + } + } + + /// Move an editable vertex, carrying an existing closing marker with vertex zero. If an + /// implicit boundary's endpoints meet, add a marker so both corners remain addressable. + /// Invalid indices/non-finite points leave the boundary unchanged. + pub fn set_vertex(&mut self, index: usize, to: Vec2) -> Option<()> { + let count = self.vertices().len(); + if index >= count || !to.is_finite() { + return None; + } + let explicit = count < self.boundary.len(); + *self.boundary.get_mut(index)? = to; + if explicit + && index == 0 + && let Some(last) = self.boundary.last_mut() + { + *last = to; + } + if !explicit && self.vertices().len() < count { + // The moved endpoint is a logical corner, not the newly implied closing marker. + if let Some(first) = self.boundary.first().copied() { + self.boundary.push(first); + } + } + Some(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::EntityKind; + + #[test] + fn vertices_and_grips_exclude_only_the_exact_closing_point() { + let (a, b, c) = (Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0)); + let near_a = Vec2::new(1e-13, 0.0); + for (boundary, expected) in [ + (vec![], vec![]), + (vec![a], vec![a]), + (vec![a, a], vec![a]), + (vec![a, b, c], vec![a, b, c]), + (vec![a, b, c, a], vec![a, b, c]), + (vec![a, b, a, c, a], vec![a, b, a, c]), + (vec![a, b, c, a, a], vec![a, b, c, a]), + (vec![a, b, c, near_a], vec![a, b, c, near_a]), + ] { + let wipeout = Wipeout { boundary: boundary.clone() }; + assert_eq!(wipeout.vertices(), expected); + assert_eq!(EntityKind::Wipeout(wipeout.clone()).grips(), expected); + assert_eq!(wipeout.boundary, boundary); + } + } + + #[test] + fn explicit_outline_is_stable_and_does_not_add_zero_length_closing_edges() { + let points = vec![Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0)]; + let implicit = Wipeout { boundary: points.clone() }; + let mut expected = points.clone(); + expected.push(points[0]); + assert!(matches!(implicit.closed_boundary(), Cow::Owned(_))); + assert_eq!(implicit.closed_boundary().as_ref(), expected); + assert_eq!(implicit.boundary, points); + let explicit = Wipeout { boundary: expected.clone() }; + assert!(matches!(explicit.closed_boundary(), Cow::Borrowed(_))); + assert_eq!(explicit.closed_boundary().as_ref(), expected); + let a = EntityKind::Wipeout(implicit).prims(); + let b = EntityKind::Wipeout(explicit).prims(); + assert_eq!(a, b); + assert_eq!(a.len(), 3); + for boundary in [vec![], vec![Vec2::ZERO]] { + let wipeout = Wipeout { boundary: boundary.clone() }; + assert!(matches!(wipeout.closed_boundary(), Cow::Borrowed(_))); + assert_eq!(wipeout.closed_boundary().as_ref(), boundary); + assert!(EntityKind::Wipeout(wipeout).prims().is_empty()); + } + } + + #[test] + fn moving_endpoints_together_keeps_both_corners_editable() { + let points = [Vec2::ZERO, Vec2::new(8.0, 0.0), Vec2::new(8.0, 6.0), Vec2::new(0.0, 6.0)]; + for explicit in [false, true] { + for index in [0, 3] { + let other = 3 - index; + let mut boundary = points.to_vec(); + if explicit { + boundary.push(points[0]); + } + let mut wipeout = Wipeout { boundary }; + let mut expected = points; + expected[index] = points[other]; + for _ in 0..2 { + assert_eq!(wipeout.set_vertex(index, points[other]), Some(())); + assert_eq!(wipeout.vertices(), expected); + assert_eq!(wipeout.boundary.len(), 5, "one separate closing marker, no growth"); + } + expected[other] = Vec2::new(-2.0, 4.0); + assert_eq!(wipeout.set_vertex(other, expected[other]), Some(())); + assert_eq!(wipeout.vertices(), expected); + assert_eq!(wipeout.boundary.len(), 5); + assert_eq!(wipeout.boundary.first(), wipeout.boundary.last()); + } + } + } + + #[test] + fn editing_keeps_the_closure_but_not_other_coincident_vertices_attached() { + let (a, b, c, to) = (Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0), Vec2::new(2.0, 3.0)); + for explicit in [false, true] { + let mut boundary = vec![a, b, a, c]; + if explicit { + boundary.push(a); + } + let mut wipeout = Wipeout { boundary }; + assert_eq!(wipeout.set_vertex(0, to), Some(())); + let mut expected = vec![to, b, a, c]; + if explicit { + expected.push(to); + } + assert_eq!(wipeout.boundary, expected); + for (index, point) in [(4, a), (usize::MAX, a), (0, Vec2::new(f64::NAN, 0.0)), (0, Vec2::new(0.0, f64::INFINITY))] { + assert_eq!(wipeout.set_vertex(index, point), None); + assert_eq!(wipeout.boundary, expected); + } + } + } +} diff --git a/crates/engine/src/grips.rs b/crates/engine/src/grips.rs index a3b8921aa..8c58e30b5 100644 --- a/crates/engine/src/grips.rs +++ b/crates/engine/src/grips.rs @@ -239,7 +239,7 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option *w.boundary.get_mut(index)? = to, + EntityKind::Wipeout(w) => w.set_vertex(index, to)?, EntityKind::Unknown(_) => return None, } Some(k) diff --git a/crates/engine/src/grips/tests.rs b/crates/engine/src/grips/tests.rs index 971937704..2fc4502b6 100644 --- a/crates/engine/src/grips/tests.rs +++ b/crates/engine/src/grips/tests.rs @@ -169,6 +169,21 @@ fn other_entity_grips() { assert!(matches!(kind(&s, pt), EntityKind::Point(p) if near(p.p.xy(), 2.0, 2.0))); } +#[test] +fn wipeout_grips_reject_the_closing_marker_and_hostile_input() { + let kind = EntityKind::Wipeout(cadcraft_doc::Wipeout { boundary: vec![v(0.0, 0.0), v(7.0, 1.0), v(3.0, 7.0), v(0.0, 0.0)] }); + for (index, to) in [(3, v(2.0, 2.0)), (usize::MAX, v(2.0, 2.0)), (0, v(f64::NAN, 0.0)), (0, v(0.0, f64::INFINITY))] { + assert!(stretch_grip(&kind, index, to).is_none()); + } + for boundary in [vec![], vec![v(0.0, 0.0)], vec![v(0.0, 0.0), v(0.0, 0.0)]] { + let kind = EntityKind::Wipeout(cadcraft_doc::Wipeout { boundary }); + for index in [0, 1, usize::MAX] { + let result = stretch_grip(&kind, index, v(2.0, 2.0)); + assert_eq!(result.is_some(), index < kind.grips().len()); + } + } +} + #[test] fn grip_modes() { let mut s = Session::new(); diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 51cec4590..8e20e6fa8 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -144,6 +144,7 @@ mod undo_layout_tab; mod viewport_clip; mod vplayer_prompts; mod vports; +mod wipeout_grips; mod xdata_follows_entity; mod zoom_keywords; mod zoom_viewport; diff --git a/crates/engine/tests/it/wipeout_grips.rs b/crates/engine/tests/it/wipeout_grips.rs new file mode 100644 index 000000000..e34620089 --- /dev/null +++ b/crates/engine/tests/it/wipeout_grips.rs @@ -0,0 +1,227 @@ +//! WIPEOUT's optional repeated closing point is not a second editable corner (#2256). +//! Authored contours keep their grips and edit results through DXF/DWG saves, both via the +//! command API and the interactive grip machine, without changing the file's explicit closure. + +use cadcraft_doc::{Drawing, EntityKind, Handle, LwPolyline, Space}; +use cadcraft_engine::{ + Input, Session, + grips::{GripMode, stretch_grip}, +}; +use cadcraft_geom::{PolyVertex, Vec2}; +use cadcraft_io::{SaveOptions, Version}; +use serde_json::json; + +fn fixture(explicit: bool, reversed: bool) -> (Drawing, Handle, Vec) { + let mut points = vec![Vec2::new(1.0, 2.0), Vec2::new(7.0, 3.0), Vec2::new(4.0, 9.0)]; + if reversed { + points.reverse(); + } + let mut input = points.clone(); + if explicit { + input.push(points[0]); + } + let mut session = Session::new(); + let input: Vec<_> = input.iter().map(|p| [p.x, p.y]).collect(); + let reply = session.execute("wipeout", &json!({"points": input})).unwrap(); + let handle = Handle::parse_hex(reply["handle"].as_str().unwrap()).unwrap(); + (session.doc().unwrap().clone(), handle, points) +} + +fn points_equal(actual: &[Vec2], expected: &[Vec2]) { + assert_eq!(actual.len(), expected.len(), "{actual:?} != {expected:?}"); + for (a, b) in actual.iter().zip(expected) { + assert!(a.near(*b, 1e-12), "{actual:?} != {expected:?}"); + } +} + +fn assert_boundary(drawing: &Drawing, handle: Handle, points: &[Vec2], explicit: bool) { + let EntityKind::Wipeout(w) = &drawing.entity(handle).unwrap().kind else { panic!("WIPEOUT lost") }; + let mut expected = points.to_vec(); + if explicit { + expected.push(points[0]); + } + points_equal(&w.boundary, &expected); +} + +fn move_grip(session: &mut Session, handle: Handle, index: usize, to: Vec2, interactive: bool) { + session.set_selection(vec![handle]); + if interactive { + session.start_grip_edit(&[(handle, index)]).unwrap(); + session.input(Input::Point(to)).unwrap(); + assert!(!session.prompting()); + } else { + session.grip_edit(handle, index, to, GripMode::Stretch).unwrap(); + } +} + +fn edit(drawing: Drawing, handle: Handle, points: &[Vec2], explicit: bool, index: usize, interactive: bool) -> Drawing { + let mut session = Session::new(); + session.open_drawing(drawing, "wipeout", None); + points_equal(&session.grips_of(handle).unwrap(), points); + let to = points[index] + Vec2::new(2.0, 1.0); + let mut expected = points.to_vec(); + expected[index] = to; + move_grip(&mut session, handle, index, to, interactive); + assert_boundary(session.doc().unwrap(), handle, &expected, explicit); + points_equal(&session.grips_of(handle).unwrap(), &expected); + session.execute("undo", &json!({})).unwrap(); + assert_boundary(session.doc().unwrap(), handle, points, explicit); + session.execute("redo", &json!({})).unwrap(); + assert_boundary(session.doc().unwrap(), handle, &expected, explicit); + session.doc().unwrap().clone() +} + +fn check(format: &str, interactive: bool) { + for explicit in [false, true] { + for reversed in [false, true] { + let (drawing, handle, points) = fixture(explicit, reversed); + for index in 0..points.len() { + edit(drawing.clone(), handle, &points, explicit, index, interactive); + } + for version in Version::ALL { + for binary in [false, true].into_iter().filter(|binary| !*binary || format == "wipeout.dxf") { + let options = SaveOptions { version: Some(version), binary }; + let mut current = drawing.clone(); + for _pass in 0..2 { + let bytes = cadcraft_io::write_with(¤t, format, &options).unwrap(); + current = cadcraft_io::read(&bytes, format).unwrap(); + assert!(current.read_notes.is_empty() && current.skipped_records.is_empty()); + assert_boundary(¤t, handle, &points, true); + for index in 0..points.len() { + let edited = edit(current.clone(), handle, &points, true, index, interactive); + let mut expected = points.clone(); + expected[index] += Vec2::new(2.0, 1.0); + let bytes = cadcraft_io::write_with(&edited, format, &options).unwrap(); + let back = cadcraft_io::read(&bytes, format).unwrap(); + assert_boundary(&back, handle, &expected, true); + } + } + } + } + } + } +} + +#[test] +fn dxf_api_grips_survive_roundtrip() { + check("wipeout.dxf", false); +} + +#[test] +fn dxf_interactive_grips_survive_roundtrip() { + check("wipeout.dxf", true); +} + +#[test] +fn dwg_api_grips_survive_roundtrip() { + check("wipeout.dwg", false); +} + +#[test] +fn dwg_interactive_grips_survive_roundtrip() { + check("wipeout.dwg", true); +} + +#[test] +fn polyline_closedness_is_not_inferred_from_coincident_endpoints() { + for closed in [false, true] { + for repeated in [false, true] { + let mut points = vec![Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0)]; + if repeated { + points.push(points[0]); + } + let polyline = LwPolyline { + vertices: points.iter().copied().map(PolyVertex::new).collect(), + closed, + const_width: 0.0, + elevation: 0.0, + plinegen: false, + }; + let kind = EntityKind::LwPolyline(polyline); + let segments = if repeated || closed { 3 } else { 2 }; + assert_eq!(kind.prims().len(), segments); + assert_eq!(kind.grips().len(), points.len() + segments); + let to = Vec2::new(2.0, 3.0); + let EntityKind::LwPolyline(edited) = stretch_grip(&kind, 0, to).unwrap() else { panic!("polyline") }; + assert_eq!(edited.closed, closed); + let mut expected = points.clone(); + expected[0] = to; + assert_eq!(edited.vertices.iter().map(|v| v.p).collect::>(), expected); + + // WIPEOUT conversion requires the closed flag, even with coincident endpoints. + let mut session = Session::new(); + let handle = session.doc_mut().unwrap().add(&Space::Model, Default::default(), kind).unwrap(); + let result = session.execute("wipeout", &json!({"handle": handle.hex()})); + assert_eq!(result.is_ok(), closed); + if let Ok(result) = result { + let wipeout = Handle::parse_hex(result["handle"].as_str().unwrap()).unwrap(); + assert_boundary(session.doc().unwrap(), wipeout, &points[..3], repeated); + points_equal(&session.grips_of(wipeout).unwrap(), &points[..3]); + } else { + assert_eq!(session.doc().unwrap().model.len(), 1); + } + } + } +} + +#[test] +fn coincident_endpoints_remain_separately_editable_after_save() { + let original = [Vec2::ZERO, Vec2::new(8.0, 0.0), Vec2::new(8.0, 6.0), Vec2::new(0.0, 6.0)]; + for explicit in [false, true] { + for interactive in [false, true] { + for index in [0, 3] { + let other = 3 - index; + let mut input: Vec<_> = original.iter().map(|p| [p.x, p.y]).collect(); + if explicit { + input.push(input[0]); + } + let mut session = Session::new(); + let reply = session.execute("wipeout", &json!({"points": input})).unwrap(); + let handle = Handle::parse_hex(reply["handle"].as_str().unwrap()).unwrap(); + let mut coincident = original; + coincident[index] = original[other]; + move_grip(&mut session, handle, index, coincident[index], interactive); + points_equal(&session.grips_of(handle).unwrap(), &coincident); + assert_boundary(session.doc().unwrap(), handle, &coincident, true); + session.undo().unwrap(); + assert_boundary(session.doc().unwrap(), handle, &original, explicit); + session.execute("redo", &json!({})).unwrap(); + points_equal(&session.grips_of(handle).unwrap(), &coincident); + let collapsed = session.doc().unwrap().clone(); + for version in Version::ALL { + for (format, binary) in [("wipeout.dxf", false), ("wipeout.dxf", true), ("wipeout.dwg", false)] { + let options = SaveOptions { version: Some(version), binary }; + let bytes = cadcraft_io::write_with(&collapsed, format, &options).unwrap(); + let back = cadcraft_io::read(&bytes, format).unwrap(); + session.open_drawing(back, "coincident corners", None); + points_equal(&session.grips_of(handle).unwrap(), &coincident); + assert_boundary(session.doc().unwrap(), handle, &coincident, true); + let mut separated = coincident; + separated[other] = Vec2::new(-2.0, 4.0); + move_grip(&mut session, handle, other, separated[other], interactive); + points_equal(&session.grips_of(handle).unwrap(), &separated); + assert_boundary(session.doc().unwrap(), handle, &separated, true); + let bytes = cadcraft_io::write_with(session.doc().unwrap(), format, &options).unwrap(); + let back = cadcraft_io::read(&bytes, format).unwrap(); + assert_boundary(&back, handle, &separated, true); + } + } + } + } + } +} + +#[test] +fn multiple_hot_grips_keep_the_closing_point_attached() { + let (drawing, handle, mut points) = fixture(true, false); + let mut session = Session::new(); + session.open_drawing(drawing, "wipeout", None); + session.set_selection(vec![handle]); + session.start_grip_edit(&[(handle, 0), (handle, 1)]).unwrap(); + let delta = Vec2::new(2.0, 1.0); + session.input(Input::Point(points[0] + delta)).unwrap(); + points[0] += delta; + points[1] += delta; + assert_boundary(session.doc().unwrap(), handle, &points, true); + points_equal(&session.grips_of(handle).unwrap(), &points); +} diff --git a/crates/io/src/dxf_write/entity.rs b/crates/io/src/dxf_write/entity.rs index cd41cdf91..9c2f12896 100644 --- a/crates/io/src/dxf_write/entity.rs +++ b/crates/io/src/dxf_write/entity.rs @@ -665,14 +665,9 @@ fn write_wipeout_entity(w: &mut W, _d: &Drawing, e: &Entity, owner: &str, paper: w.i(282, 50); w.i(283, 0); w.i(71, 2); - let mut pts = wo.boundary.clone(); - if let (Some(first), Some(last)) = (pts.first().copied(), pts.last().copied()) - && first != last - { - pts.push(first); - } + let pts = wo.closed_boundary(); w.i(91, pts.len() as i64); - for q in pts { + for q in pts.iter() { w.p2(14, Vec2::new((q.x - o.x) / sx - 0.5, 0.5 - (q.y - o.y) / sy)); } } diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 04e2651c5..cf37e28f7 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -294,11 +294,7 @@ fn draw_wipeout(b: &mut Builder, ctx: &Ctx, w: &cadcraft_doc::Wipeout, rgb: Ink, _ => true, }; if frame { - let mut pts = w.boundary.clone(); - if let Some(f) = pts.first().copied() { - pts.push(f); - } - b.polyline(ctx, rgb, lw, &pts); + b.polyline(ctx, rgb, lw, &w.closed_boundary()); } } diff --git a/crates/render/tests/it/wipeout_mask.rs b/crates/render/tests/it/wipeout_mask.rs index 508b052b6..6c9531988 100644 --- a/crates/render/tests/it/wipeout_mask.rs +++ b/crates/render/tests/it/wipeout_mask.rs @@ -59,6 +59,43 @@ fn wipeout_hides_what_is_drawn_before_it() { assert!(inside > 0); } +#[test] +fn implicit_and_explicit_closure_draw_the_same_mask_and_frame() { + for reversed in [false, true] { + let mut implicit = drawing(false); + let handle = implicit.model.iter().find(|e| matches!(e.kind, EntityKind::Wipeout(_))).unwrap().handle; + if reversed { + implicit.model.modify(handle, |e| { + let EntityKind::Wipeout(w) = &mut e.kind else { panic!("wipeout") }; + w.boundary.reverse(); + }); + } + let mut explicit = implicit.clone(); + explicit.model.modify(handle, |e| { + let EntityKind::Wipeout(w) = &mut e.kind else { panic!("wipeout") }; + w.boundary.push(w.boundary[0]); + }); + for frame in [0, 1, 2] { + implicit.header.set_i64("WIPEOUTFRAME", frame); + explicit.header.set_i64("WIPEOUTFRAME", frame); + for plot in [false, true] { + let draw = if plot { build_plot } else { build }; + let a = draw(&implicit, &Space::Model, &Options::default()); + let b = draw(&explicit, &Space::Model, &Options::default()); + assert_eq!(a.prims, b.prims); + assert_eq!(a.verts, b.verts); + assert_eq!(a.tris, b.tris); + for primitive in b.prims.iter().filter(|p| p.handle == handle && p.kind == Kind::Polyline) { + let points = b.points(primitive); + assert_eq!(points.len(), 5, "four corners and exactly one closing point"); + assert_eq!(points.first(), points.last()); + } + assert_eq!(dark_pixels(&a), dark_pixels(&b)); + } + } + } +} + #[test] fn wipeoutframe_controls_the_frame() { // Polylines in the list: the line, plus the frame when it is drawn. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2bfea4a51..291c9b202 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -87,7 +87,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | ACAD_TABLE | yes | yes (entity + block) | | | VIEWPORT | yes | yes; frozen-layer lists kept (#150), also as standard group 341 layer handles that other programs read | DWG: the DXF→DWG converter does not yet write group 341 (frozen layers survive a CADCraft round trip via xdata); VP layer colour overrides lost (#99, PR #197) | | IMAGE | yes, with IMAGEDEF | yes, with IMAGEDEF and IMAGEDEF_REACTOR | no attach command or display of the picture yet (FF10) | -| WIPEOUT | yes | yes (#147, fixes #79) | | +| WIPEOUT | yes | yes (#147, fixes #79) | Implicit or explicit closure in memory; explicit closing point on save. Same logical grips and edit results before/after DXF/DWG round trips (#2256). | | MLINE (+ MLINESTYLE), HELIX, TOLERANCE | yes (#510, #420) | yes | | | REGION | other programs' REGIONs (ACIS data) kept as Unknown | our regions are written as closed polylines tagged with CADCraft xdata (#510; ACIS has no public spec) | | | 3DSOLID, BODY, SURFACE, MESH, SHAPE, UNDERLAY, OLE2FRAME, LIGHT, CAMERA, SECTION, POINTCLOUD, proxy entities | kept as Unknown (raw tags) | written back as read, with their CLASS records (#528); a proxy entity's class number (group 91) is renumbered to its class's place in the written CLASSES (#1812); **dropped by DWG save** (acadrust) | only their layer follows edits | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6bf0c20f8..3522b5091 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (browser panel layout storage) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (WIPEOUT closure-aware grips) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) How CADCraft's interaction compares with AutoCAD's: point entry, object snaps, tracking, dynamic input, grips, selection, the command line, chrome and navigation. A drafter's speed in AutoCAD @@ -111,7 +111,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | Feature | AutoCAD | CADCraft | |---|---|---| -| Stretch grips per object type | yes | lines (midpoint moves), arcs (start/mid/end), ellipses (axes), polylines (vertices; segment midpoints move or reshape the bulge), dimensions (text and definition points), MLEADER, viewport, wipeout, spline (`grips.rs:79-270`) | +| Stretch grips per object type | yes | lines (midpoint moves), arcs (start/mid/end), ellipses (axes), polylines (vertices; segment midpoints move or reshape the bulge), dimensions (text and definition points), MLEADER, viewport, wipeout (one grip per logical corner, including coincident corners; an explicit closing marker follows the first vertex), spline (`grips.rs`) | | Text/MText grips | position, width, column grips | move the whole object | | Hot-grip modes with Space/Enter cycling | Stretch, Move, Rotate, Scale, Mirror | yes (`canvas.rs:58-67`) | | Hot-grip options: Base point, Copy, Undo, Reference, eXit | yes | **advertised in the prompt but not parsed**; the engine's JSON grip commands accept `copy`, the UI never passes it | @@ -204,6 +204,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | WIPEOUT grips treat implicit and explicit closure alike; moving the first vertex carries the closing marker, and moving endpoints together keeps both corners editable. Open/closed polyline flags and coincident polyline vertices remain independent (#2256) | | 2026-10-26 | minor | Browser host stores bounded, validated panel layout separately from drawing state; malformed or unsupported records retain defaults. Native workspace storage is unchanged. | | 2026-10-22 | minor | Polyline segment midpoint grips use rectangles, distinct from square vertex grips, with matching hover and pick bounds (#1950) | | 2026-10-13 | minor | Filled-region highlight boundaries are traversed in deterministic coordinate order, fixing dash-phase jumps on CPU hover/selection redraws and GPU highlight rebuilds; CPU dash-position, pixel and GPU mesh regressions | From d15de754069bac149f2fe199310299752ebbcd0f Mon Sep 17 00:00:00 2001 From: Books Date: Fri, 30 Oct 2026 12:31:22 +1100 Subject: [PATCH 21/29] Dimensions: report the measurement as soon as one is created (#2421) (#2438) * Dimensions: fill in the measurement when one is created (#2421) A dimension made in the session kept measurement 0.0 until it was saved and reopened. add_dim now sets it with the geometry call the DXF/DWG writer uses for group 42. Block names are still assigned at save time. Test: a_new_dimension_reports_its_measurement. * Parity checklist: regenerated after merging main * Dimensions: a jogged radius dimension stores its radius as the measurement (#2421) draw_jogged_radius set the text but not DimGeometry.measurement, so DIMJOGGED dimensions kept measurement 0 (and the DXF/DWG group 42 was 0). It now sets the radius. Test: jogged_radius asserts the stored measurement. * Parity checklist: regenerated after merging main * Dimensions: fill in a new dimension's text position when it is created (#2421) A dimension made in the session kept text_mid (0,0) until it was saved and reopened. add_dim now sets it from the same dimension_geometry result as the measurement, the position the writer puts in group 11, unless the user placed the text. Test: a_new_dimension_reports_its_text_position. --------- Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/annotate/common.rs | 13 ++++++++++++ crates/engine/tests/it/dim_commands.rs | 27 ++++++++++++++++++++++++ crates/render/src/dim/variants.rs | 1 + docs/parity-checklist.md | 1 + 4 files changed, 42 insertions(+) diff --git a/crates/engine/src/cmd/annotate/common.rs b/crates/engine/src/cmd/annotate/common.rs index 2267f16bf..419723288 100644 --- a/crates/engine/src/cmd/annotate/common.rs +++ b/crates/engine/src/cmd/annotate/common.rs @@ -48,6 +48,19 @@ pub(super) fn add_dim(s: &mut Session, k: EntityKind) -> Result { { dm.assoc = crate::assoc::reassociate(s.doc()?, &s.space(), dm); } + // The measurement is what the DXF/DWG writer computes from the geometry (group 42); filling it + // in now means queries show it before the drawing is saved and reopened (#2421). + if let EntityKind::Dimension(dm) = &mut k + && let Ok(d) = s.doc() + { + let style = d.dim_style(&dm.style).cloned().unwrap_or_default(); + let g = cadcraft_render::dimension_geometry(dm, &style, d.header.f64("DIMSCALE", 1.0)); + dm.measurement = g.measurement; + // The text position the writer puts in group 11 when the user has not placed the text. + if !dm.user_text_pos { + dm.text_mid = g.text_pos.to3(0.0); + } + } let h = s.add_entity(k)?; s.last_dim = Some(h); Ok(h) diff --git a/crates/engine/tests/it/dim_commands.rs b/crates/engine/tests/it/dim_commands.rs index 8e67b1039..3c5b5c90a 100644 --- a/crates/engine/tests/it/dim_commands.rs +++ b/crates/engine/tests/it/dim_commands.rs @@ -99,6 +99,7 @@ fn jogged_radius() { let (dm, g) = dim_of(&s, &h); let j = dm.marks.jogged.clone().unwrap(); assert_eq!(g.value, "R50.0000", "the true radius"); + assert!((dm.measurement - 50.0).abs() < 1e-9, "the stored measurement is the radius, got {} (#2421)", dm.measurement); assert!(j.center.xy().dist(Vec2::new(20.0, -10.0)) < 1e-9); // The line runs from the arrowhead on the circle to the override, with a 45° jog (DIMJOGANG). let tip = Vec2::new(1.0, 1.0).normalized() * 50.0; @@ -257,3 +258,29 @@ fn marks_follow_move_and_rotate_and_hostile_values_do_not_panic() { .unwrap(); let _ = dim_of(&s, &h); } + +/// A dimension made in the session reports its measurement at once, not 0.0 until it is saved +/// and reopened (#2421). +#[test] +fn a_new_dimension_reports_its_measurement() { + let mut s = Session::new(); + let measurement = |s: &Session, h: &str| dim_of(s, h).0.measurement; + let horizontal = handle(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [100, 0], "at": [50, 20] })).unwrap()); + assert!((measurement(&s, &horizontal) - 100.0).abs() < 1e-9, "{}", measurement(&s, &horizontal)); + let vertical = handle(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [0, 37.5], "at": [-10, 20] })).unwrap()); + assert!((measurement(&s, &vertical) - 37.5).abs() < 1e-9, "{}", measurement(&s, &vertical)); +} + +/// A new dimension's text position is known at once, as it is after save and reopen (#2421). +#[test] +fn a_new_dimension_reports_its_text_position() { + let mut s = Session::new(); + let horizontal = handle(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [100, 0], "at": [50, 20] })).unwrap()); + let vertical = handle(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [0, 37.5], "at": [-20, 18] })).unwrap()); + for h in [&horizontal, &vertical] { + let (dm, g) = dim_of(&s, h); + assert!(dm.text_mid.xy().dist(g.text_pos) < 1e-9, "stored {:?}, drawn at {:?}", dm.text_mid, g.text_pos); + assert!(dm.text_mid.xy().dist(Vec2::ZERO) > 1.0, "still at the origin: {:?}", dm.text_mid); + } + assert!((dim_of(&s, &horizontal).0.text_mid.x - 50.0).abs() < 1e-9); +} diff --git a/crates/render/src/dim/variants.rs b/crates/render/src/dim/variants.rs index 240b5e88d..948483a54 100644 --- a/crates/render/src/dim/variants.rs +++ b/crates/render/src/dim/variants.rs @@ -27,6 +27,7 @@ pub(super) fn draw_jogged_radius(config: &DimConfig<'_>, g: &mut DimGeometry, te if !r.is_finite() { break 'jogged; } + g.measurement = r; let (tw, _) = measure(&g.mtext); let u = (tip - center).normalized(); let u = if u == Vec2::ZERO || !u.is_finite() { Vec2::X } else { u }; diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index ee0358f9e..d0bd6f967 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,5 +1,6 @@ # Parity checklist +> **Generated:** 2026-10-29 by `cargo xtask parity`. Do not edit by hand. > **Generated:** 2026-10-28 by `cargo xtask parity`. Do not edit by hand. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment. From 42667d1332ce9e9922771b8599caee796106190c Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:31:26 +0700 Subject: [PATCH 22/29] ELLIPSE from axis endpoints matches what AutoCAD stores (#2197) * ELLIPSE from axis endpoints matches what AutoCAD stores Typed ELLIPSE input was checked against AutoCAD 2024 black-box (synthetic input in accoreconsole, read back with entget); three rules differed: - The major axis pointed at the second axis endpoint; AutoCAD points it at the first. A full ellipse looks the same, but an elliptical arc's angles are measured from that axis, so the arc was drawn on the opposite side of the center, and the stored major vector, start point and quadrant order all differed. - A point at "Specify distance to other axis" gave its distance from the first axis; AutoCAD takes its distance from the center (the rubber band), then swaps the axes when it is the longer one. - The end-angle prompt had no Included angle option, so `_I` was rejected and the next number became an absolute end angle. AutoCAD adds the included angle (default 180) to the start angle. The JSON form (ellipse.axis / ellipse.arc with p1, p2, p3) follows the same rules. The regression test pins ten AutoCAD-measured cases, among them survey (VN-2000) coordinates, a near-millimetre ellipse and both Included angle cases. Co-Authored-By: Claude Opus 5.5 * docs: geometry parity says which part was measured against AutoCAD The "How this was measured" section still said no numeric comparison against AutoCAD had been run, next to the new ELLIPSE revision row that reports one. It now names the 11 measured ELLIPSE cases and the test that pins them, and keeps the rest of the document marked as read from the source. Pointed out in review. Co-Authored-By: Claude Opus 5.5 * ellipse: drop the import the axis-endpoint change left unused --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/draw/ellipse.rs | 47 +++++++++-- crates/engine/src/cmd/draw2.rs | 2 +- crates/engine/src/cmd/draw2/ellipse.rs | 11 ++- crates/engine/src/cmd/draw2/tests.rs | 112 +++++++++++++++++++++++++ docs/geometry-parity.md | 14 ++-- 5 files changed, 165 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/draw/ellipse.rs b/crates/engine/src/cmd/draw/ellipse.rs index c1e7967d9..b1cd56927 100644 --- a/crates/engine/src/cmd/draw/ellipse.rs +++ b/crates/engine/src/cmd/draw/ellipse.rs @@ -34,19 +34,24 @@ pub(super) struct EllipseM { /// The `Parameter` option: arc start/end values are ellipse parameters, not true angles. param: bool, start: Option, + /// The `Included angle` option: the next value is the arc's angle from its start angle. + included: bool, /// The Isocircle option (isometric snap style) in progress. iso: Option, } impl EllipseM { fn axis(&self) -> Option<(Vec2, Vec2)> { - // (center, major vector) + // (center, major vector): towards the first axis endpoint, as AutoCAD stores it. match (self.center_mode, self.pts.as_slice()) { (true, [c, e, ..]) => Some((*c, *e - *c)), - (false, [a, b, ..]) => Some((a.mid(*b), *b - a.mid(*b))), + (false, [a, b, ..]) => Some((a.mid(*b), *a - a.mid(*b))), _ => None, } } + /// The other half axis reaches as far as `other` is from the center (AutoCAD measures the + /// picked point's distance from the center, not from the first axis). Longer than the + /// first half axis, it becomes the major axis: the first axis turned 90° counterclockwise. fn build(&self, other: Vec2) -> Option { let (c, m) = self.axis()?; let ml = m.len(); @@ -54,7 +59,7 @@ impl EllipseM { return None; } let u = m / ml; - let minor = ((other - c).dot(u.perp())).abs(); + let minor = (other - c).len(); let (major, ratio) = if minor > ml { (u.perp() * minor, ml / minor) } else { (m, minor / ml) }; (ratio > 1e-9).then_some(Ellipse::full(c, major, ratio.min(1.0))) } @@ -88,13 +93,16 @@ impl Interactive for EllipseM { } fn prompt(&self, s: &Session) -> Prompt { if let Some(e) = &self.ellipse { - let msg = match (self.start.is_none(), self.param) { - (true, false) => "Specify start angle", - (false, false) => "Specify end angle", - (true, true) => "Specify start parameter", - (false, true) => "Specify end parameter", + if self.included { + return Prompt::new("Specify included angle for arc", Accept::POINT_OR_NUMBER).default("180").base(e.center); + } + let (msg, kw): (&str, &[&str]) = match (self.start.is_none(), self.param) { + (true, false) => ("Specify start angle", &["Parameter"]), + (false, false) => ("Specify end angle", &["Parameter", "Included angle"]), + (true, true) => ("Specify start parameter", &["Angle"]), + (false, true) => ("Specify end parameter", &["Angle"]), }; - return Prompt::new(msg, Accept::POINT_OR_NUMBER).kw(if self.param { &["Angle"] } else { &["Parameter"] }).base(e.center); + return Prompt::new(msg, Accept::POINT_OR_NUMBER).kw(kw).base(e.center); } if let Some(iso) = &self.iso { return iso.prompt(); @@ -112,11 +120,32 @@ impl Interactive for EllipseM { fn input(&mut self, s: &mut Session, i: Input) -> Result { if let Some(e) = self.ellipse { let ge = Ellipse { start: 0.0, end: cadcraft_geom::TAU, ..e }; + if self.included { + let incl = match i { + Input::Point(p) => (p - e.center).angle(), + Input::Text(t) => s.angle_settings().amount(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?, + Input::Enter => std::f64::consts::PI, + _ => return Ok(Step::Continue), + }; + // A true angle added to the start angle (AutoCAD: start 214.659°, included + // 232.716° ends the arc at 87.375° from the major axis). + let st = self.start.unwrap_or(0.0); + let end = ge.param_of(e.center + Vec2::from_angle((ge.at_param(st) - e.center).angle() + incl)); + s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse { + center: v3(e.center), + major: v3(e.major), + ratio: e.ratio, + start: st, + end, + }))?; + return Ok(Step::Done); + } let ang = match i { Input::Keyword(k) => { match k.as_str() { "Parameter" => self.param = true, "Angle" => self.param = false, + "Included angle" if self.start.is_some() && !self.param => self.included = true, _ => {} } return Ok(Step::Continue); diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index b37ebbc68..003edf2a8 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -105,7 +105,7 @@ pub fn specs() -> Vec { .interactive(|_| Ok(Box::new(TanCircleM::new(false)))), CommandSpec::new("ellipse.axis", "Axis, End", run_ellipse_axis) .menu(&["Draw", "Ellipse", "Axis, End"]) - .params("{p1, p2, distance} | {p1, p2, p3} (axis endpoints, then half the other axis)") + .params("{p1, p2, distance} | {p1, p2, p3} (axis endpoints, then half the other axis: a distance, or a point that far from the center)") .interactive(|_| Ok(super::draw::ellipse_machine(false, false))), CommandSpec::new("ellipse.arc", "Elliptical Arc", run_ellipse_arc) .menu(&["Draw", "Ellipse", "Arc"]) diff --git a/crates/engine/src/cmd/draw2/ellipse.rs b/crates/engine/src/cmd/draw2/ellipse.rs index e699eac58..430effa6b 100644 --- a/crates/engine/src/cmd/draw2/ellipse.rs +++ b/crates/engine/src/cmd/draw2/ellipse.rs @@ -1,10 +1,11 @@ -use super::{Ellipse, EntityKind, Line, Result, Session, TAU, Value, Vec2, added, bad, f64_req, point_param, point_req, v3}; +use super::{Ellipse, EntityKind, Result, Session, TAU, Value, Vec2, added, bad, f64_req, point_param, point_req, v3}; // ---------------- ellipse entries ---------------- fn ellipse_from_axis(p1: Vec2, p2: Vec2, half: f64) -> Option { let c = p1.mid(p2); - let m = p2 - c; + // The major axis points at the first endpoint, as AutoCAD stores it. + let m = p1 - c; let ml = m.len(); let half = half.abs(); if ml < 1e-12 || half < 1e-12 || !half.is_finite() { @@ -17,10 +18,8 @@ fn axis_ellipse_params(cmd: &str, p: &Value) -> Result { let p1 = point_req(cmd, p, "p1")?; let p2 = point_req(cmd, p, "p2")?; let half = match point_param(p, "p3") { - Some(p3) => { - let ax = Line::new(p1, p2); - ax.project(p3).dist(p3) - } + // As at the prompt: the point's distance from the center, not from the first axis. + Some(p3) => p3.dist(p1.mid(p2)), None => f64_req(cmd, p, "distance")?, }; ellipse_from_axis(p1, p2, half).ok_or_else(|| bad(cmd, "degenerate ellipse")) diff --git a/crates/engine/src/cmd/draw2/tests.rs b/crates/engine/src/cmd/draw2/tests.rs index b5585e381..8eedd3772 100644 --- a/crates/engine/src/cmd/draw2/tests.rs +++ b/crates/engine/src/cmd/draw2/tests.rs @@ -270,6 +270,118 @@ fn ellipse_arc_typed_angles_are_true_angles() { } } +/// Typed ELLIPSE input against what AutoCAD 2024 stores for the same input (black-box: synthetic +/// input in accoreconsole, read back with `entget`). The major axis points at the first axis +/// endpoint; a point at the distance prompt gives its distance from the center; an other axis +/// longer than the first becomes the major axis, the first turned 90° counterclockwise; the +/// Included angle is added to the start angle. +#[test] +fn ellipse_axis_endpoints_match_autocad() { + type Case = (&'static str, [f64; 2], [f64; 2], f64, Option<[f64; 2]>); + // (input after ELLIPSE, center, major axis, ratio, arc start/end parameters) + let cases: [Case; 10] = [ + // The other axis (27.02) is longer than the first half axis (26.42). + ( + "16.459468652708743,28.120073391143574 -5.034438825478263,-20.14843889196848 27.021868057769268", + [5.712514913615239, 3.985817249587547], + [-24.6850622596004, 10.99222700689644], + 0.9776866627982613, + None, + ), + ( + "33.45015752893423,-27.997665254733153 1.0902141405158297,-9.876490849047837 _R 36.361957504949", + [17.27018583472502, -18.93707805189049], + [16.1799716942092, -9.060587202842658], + 0.8052876303433125, + None, + ), + ( + "0.5161247992610992,0.27380397971284964 0.49874786589184156,0.23294359917255586 0.0026647279687818256", + [0.5074363325764704, 0.2533737894427027], + [0.00868846668462886, 0.02043019027014691], + 0.1200276615332929, + None, + ), + // Arcs: the angles are taken from the major axis, so a reversed axis draws the arc + // on the opposite side of the center. + ( + "_A 612354.66928411,2398796.595432055 612364.9910379789,2398751.70959102 3.5361631006792016 259.40675054209663 162.0895061623204", + [612359.8301610444, 2398774.152511537], + [-5.160876934416592, 22.44292051764205], + 0.1535548572193783, + Some([4.683678586016272, 2.014340486820176]), + ), + ( + "_A -32.49789607178266,25.826299666765127 16.360636510348964,-6.670894598441254 9.326202777586586 _P 235.35128996877393 341.0349294763654", + [-8.068629780716847, 9.577702534161936], + [-24.42926629106581, 16.24859713260318], + 0.317871961092373, + Some([4.107654908771007, 5.952182383669234]), + ), + ( + "_A _C 15.895131938563559,-16.135236520373613 1.9672122380661037,-18.61293795336168 14.456880177932417 214.65903577683966 _I 232.7163597705933", + [15.89513193856356, -16.13523652037361], + [2.53204749973717, -14.23341561039267], + 0.9785367111972643, + Some([3.756694588158375, 1.5259701966906878]), + ), + ( + "_A _C 1.8822645373261935,-7.920292708078534 28.89115893105938,-23.280023918605742 38.7551404612689 136.20071263173944 _I 129.90755425686075", + [1.882264537326193, -7.920292708078534], + [19.15838502833398, 33.68853210335513], + 0.8017236937148501, + Some([2.267136066480607, 4.657903136501946]), + ), + // The other axis given by a point. + ( + "-16.377480557612035,55.44699770062272 -15.579405511585826,-24.406726331471475 -4.27081078679767,42.69814401696656", + [-15.97844303459893, 15.52013568457562], + [-0.3990375230131046, 39.92686201604709], + 0.7411292927991227, + None, + ), + ( + "8.137326847228081,16.045209411091605 -2.4500932561568627,22.585405165312338 -2.4956573993017335,12.374292543621266", + [2.843616795535609, 19.31530728820197], + [4.602217644463162, 7.450176331143755], + 0.7105482899194384, + None, + ), + ( + "_C 15.418189586495892,-18.741133769301225 43.29367724019212,5.337090633133826 12.043993630141971,-7.0535727363182446", + [15.41818958649589, -18.74113376930122], + [27.87548765369622, 24.07822440243505], + 0.3302549104883693, + None, + ), + ]; + use std::f64::consts::{PI, TAU}; + let same = |a: f64, b: f64| (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0); + let same_angle = |a: f64, b: f64| { + let d = (a - b).rem_euclid(TAU); + d < 1e-9 || TAU - d < 1e-9 + }; + for (input, center, major, ratio, arc) in cases { + let mut s = Session::new(); + s.cmdline(&format!("ellipse {input}")).unwrap(); + assert!(s.running.is_none(), "{input}: {}", s.prompt_text()); + let EntityKind::Ellipse(e) = last(&s) else { panic!("{input}: no ellipse") }; + assert!(same(e.center.x, center[0]) && same(e.center.y, center[1]), "{input}: center {:?}", e.center); + assert!(same(e.major.x, major[0]) && same(e.major.y, major[1]), "{input}: major {:?}", e.major); + assert!(same(e.ratio, ratio), "{input}: ratio {}", e.ratio); + let [start, end] = arc.unwrap_or([0.0, TAU]); + assert!(same_angle(e.start, start) && same_angle(e.end, end), "{input}: {} {}", e.start, e.end); + } + // Enter takes the default included angle, 180°: half the ellipse. + let mut s = Session::new(); + s.cmdline("ellipse _A 10,0 -10,0 5 30 _I").unwrap(); + assert!(s.prompt_text().contains("included angle"), "{}", s.prompt_text()); + s.input(Input::Enter).unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let EntityKind::Ellipse(e) = last(&s) else { panic!("no ellipse") }; + assert!(same_angle(e.end, e.start + PI), "{} {}", e.start, e.end); +} + // ---------------- DIVIDE / MEASURE ---------------- #[test] diff --git a/docs/geometry-parity.md b/docs/geometry-parity.md index 3809c637f..6c11686a6 100644 --- a/docs/geometry-parity.md +++ b/docs/geometry-parity.md @@ -1,6 +1,6 @@ # Geometry parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (PEDIT Multiple at the command line) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (ELLIPSE axis endpoints, other-axis point and Included angle as AutoCAD stores them) · **Target:** Autodesk AutoCAD 2027 CAD users trust numbers: a trimmed endpoint, an offset, an intersection or an area is either right to the last displayed digit or the tool is unusable for production drawings. This document @@ -18,10 +18,13 @@ drawings behave differently from mid-sized ones. 3D geometry doesn't exist. Rema ## How this was measured -Read from the source on 2026-10-10 (main at bd2412f), with file:line evidence in the tables. No -numeric comparison against AutoCAD has been run yet: the plan is a set of synthetic drawings whose -exact answers are known analytically, measured in both apps black-box (LIST, ID, AREA results) -and in CADCraft headless. Until then the percentages are judgements. +Read from the source on 2026-10-10 (main at bd2412f), with file:line evidence in the tables. The +one numeric comparison against AutoCAD so far is ELLIPSE from axis endpoints (2026-10-26): 11 +typed inputs measured black-box in AutoCAD 2024 (accoreconsole, read back with `entget`) and +pinned by `ellipse_axis_endpoints_match_autocad`. Everything else is still to be measured: the +plan is a set of synthetic drawings whose exact answers are known analytically, measured in both +apps black-box (LIST, ID, AREA results) and in CADCraft headless. Until then the percentages are +judgements. Weights (how much AutoCAD users lean on each area in 2D drafting): numeric robustness 15%, curve representation 10%, intersections 15%, offsets 15%, trim/extend/fillet/chamfer/break/join @@ -157,6 +160,7 @@ Total ≈ 90–140 h; items 1–6 parallelise across two or three agents. | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | ELLIPSE from axis endpoints matches AutoCAD's stored geometry: the major axis points at the first endpoint (arcs drew on the opposite side of the center), a point at the other-axis prompt gives its distance from the center, and the end-angle prompt offers Included angle | | 2026-10-26 | minor | PEDIT Multiple at the command line (#2182): select objects, lines and arcs turned into polylines on Yes, then Close/Open/Join/Width/Fit/Spline/Decurve/Ltype gen/Reverse/Undo apply to all; Join joins the selected objects with each other within a fuzz distance | | 2026-10-22 | minor | Constraint expressions count operator chains toward the syntax-tree depth limit; evaluation shares one depth limit across operators and parameter references, with a 1 MiB-stack regression (#1936) | | 2026-10-14 | trivial | JOIN Close: a single arc becomes its circle, a single elliptical arc its full ellipse (#1175) | From 912558635e40d252c95c2dcc0b429c49b9bd1e1d Mon Sep 17 00:00:00 2001 From: Books Date: Fri, 30 Oct 2026 12:31:29 +1100 Subject: [PATCH 23/29] DWG: keep block draw-order sort handles through the DXF bridge (#2410) (#2439) * DWG: keep block draw-order sort handles through the DXF bridge (#2410) acadrust's DXF writer renumbers SORTENTSTABLE sort handles past every entity handle, so an entity left out of a table (sorted by its own handle) drew below every listed one. The table now carries its codes verbatim before writing. Tests in io/tests/it/sortents.rs; the bridge test fails without the fix. * Parity checklist: regenerated after merging main * DXF: a block/space sort handle of 0 draws last (#2439) A SORTENTSTABLE sort handle of 0 means the entity is drawn after every other (ezdxf reorder.py, which notes AutoCAD defines it). apply_sortents sorted it first; key it after all nonzero handles. Test: a_zero_sort_handle_draws_last (DXF and DWG bridge). * Parity checklist: regenerated after merging main * Draw order: test two block entities left out of a SORTENTSTABLE (#2439) Each unlisted entity must sort by its own handle, landing between the listed ones. A single fallback key for all of them would put them side by side. Checked in a DXF and across the DWG bridge; test-only. --------- Co-authored-by: Brandon Thomas --- crates/dwg/src/lib.rs | 22 +++++++ crates/io/src/dxf_read/document.rs | 6 +- crates/io/tests/it/sortents.rs | 93 ++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index 8abb696b5..7e4a88fee 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -107,6 +107,7 @@ mod native { current_style_names(&mut doc); table_merges(&mut doc); table_style_rows(&mut doc, false); + keep_sort_handles(&mut doc); let orphans = drop_orphaned_dgn_components(&mut doc); if orphans > 0 { let (s, were) = if orphans == 1 { ("", "was") } else { ("s", "were") }; @@ -265,6 +266,27 @@ mod native { } } + /// Draw-order tables (SORTENTSTABLE) keep the sort handles the file gave them. acadrust's DXF + /// writer renumbers them past every entity handle, but an entity left out of a table sorts by + /// its own handle, so after renumbering it would draw below every listed entity (#2410). Giving + /// the table its codes verbatim makes the writer emit them unchanged. + fn keep_sort_handles(doc: &mut acadrust::CadDocument) { + use acadrust::objects::ObjectType; + let version = doc.version; + for o in doc.objects.values_mut() { + let ObjectType::SortEntitiesTable(t) = o else { continue }; + let hex = |h: acadrust::types::Handle| format!("{:X}", h.value()); + let mut codes = + vec![(5, hex(t.handle)), (330, hex(t.owner_handle)), (100, "AcDbSortentsTable".to_string()), (330, hex(t.block_owner_handle))]; + for e in t.entries() { + codes.push((331, hex(e.entity_handle))); + codes.push((5, hex(e.sort_handle))); + } + t.raw_dxf_codes = Some(codes); + t.raw_dxf_version = Some(version); + } + } + /// Table styles' title and header suppression between the two places acadrust 0.6.3 keeps /// it. 2010+ (AC1024+) DWG stores it only in the style's flags (title 1, header 2), while /// acadrust's DXF reader and writer use the `title_suppressed`/`header_suppressed` fields diff --git a/crates/io/src/dxf_read/document.rs b/crates/io/src/dxf_read/document.rs index 2e16afbf3..02ea0f532 100644 --- a/crates/io/src/dxf_read/document.rs +++ b/crates/io/src/dxf_read/document.rs @@ -311,7 +311,11 @@ pub(super) fn apply_sortents( 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); + // A sort handle of 0 draws last (ezdxf `reorder`, which notes AutoCAD defines it, undocumented). + let key = |e: &Entity| { + let k = map.get(&e.handle).copied().unwrap_or(e.handle.0); + (k == 0, k) + }; if up == "*MODEL_SPACE" { d.model.sort_by_key(key); } else if up.starts_with("*PAPER_SPACE") { diff --git a/crates/io/tests/it/sortents.rs b/crates/io/tests/it/sortents.rs index 0be17e910..35b3e0dab 100644 --- a/crates/io/tests/it/sortents.rs +++ b/crates/io/tests/it/sortents.rs @@ -46,3 +46,96 @@ fn broken_sortentstables_are_ignored() { assert_eq!(order(&d), ["A1", "A2", "A3"], "{table:?}"); } } + +/// A block whose SORTENTSTABLE leaves out the circle (it sorts by its own handle) draws the +/// wipeout, the circle, then the two lines (#2410). +fn block_dxf() -> String { + block_dxf_with("331\n25B\n5\n25D\n331\n25D\n5\n25E\n331\n25E\n5\n25B\n") +} + +fn block_dxf_with(entries: &str) -> String { + let rec = |h: &str, n: &str| format!("0\nBLOCK_RECORD\n5\n{h}\n2\n{n}\n"); + let mut s = String::from("0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nBLOCK_RECORD\n"); + s.push_str(&rec("1F", "*Model_Space")); + s.push_str(&rec("2A", "MASKED_B")); + s.push_str("0\nENDTAB\n0\nENDSEC\n0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n5\n2B\n330\n2A\n8\n0\n2\nMASKED_B\n70\n0\n10\n0\n20\n0\n30\n0\n"); + let ent = |kind: &str, h: &str, body: &str| format!("0\n{kind}\n5\n{h}\n330\n2A\n8\n0\n{body}"); + s.push_str(&ent("LINE", "25B", "10\n0\n20\n0\n30\n0\n11\n10\n21\n0\n31\n0\n")); + s.push_str(&ent("CIRCLE", "25C", "10\n5\n20\n5\n30\n0\n40\n2\n")); + s.push_str(&ent("LINE", "25D", "10\n5\n20\n-5\n30\n0\n11\n5\n21\n5\n31\n0\n")); + s.push_str(&ent( + "WIPEOUT", + "25E", + "10\n0\n20\n0\n30\n0\n11\n1\n21\n0\n31\n0\n12\n0\n22\n1\n32\n0\n14\n-0.5\n24\n-0.5\n14\n9.5\n24\n-0.5\n14\n9.5\n24\n9.5\n14\n-0.5\n24\n9.5\n", + )); + s.push_str("0\nENDBLK\n5\n2C\n330\n2A\n8\n0\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nENDSEC\n0\nSECTION\n2\nOBJECTS\n"); + s.push_str("0\nSORTENTSTABLE\n5\nB0\n330\nB1\n100\nAcDbSortentsTable\n330\n2A\n"); + s.push_str(entries); + s.push_str("0\nENDSEC\n0\nEOF\n"); + s +} + +fn block_kinds(d: &Drawing) -> Vec<&'static str> { + let b = d.block("MASKED_B").unwrap(); + b.entities + .iter() + .map(|e| match &e.kind { + EntityKind::Wipeout(_) => "wipeout", + EntityKind::Circle(_) => "circle", + EntityKind::Line(l) if l.b.y == l.a.y => "hline", + EntityKind::Line(_) => "vline", + _ => "other", + }) + .collect() +} + +#[test] +fn a_block_entity_left_out_of_the_table_sorts_by_its_own_handle() { + let d = cadcraft_io::read_dxf(block_dxf().as_bytes()).unwrap(); + assert_eq!(block_kinds(&d), ["wipeout", "circle", "hline", "vline"]); +} + +#[test] +fn the_block_draw_order_survives_dxf_and_dwg() { + let d = cadcraft_io::read_dxf(block_dxf().as_bytes()).unwrap(); + let dxf = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap(); + assert_eq!(block_kinds(&dxf), ["wipeout", "circle", "hline", "vline"], "DXF"); + let dwg = cadcraft_io::read(&cadcraft_io::write(&d, "t.dwg").unwrap(), "t.dwg").unwrap(); + assert_eq!(block_kinds(&dwg), ["wipeout", "circle", "hline", "vline"], "DWG"); +} + +/// The table itself (not CADCraft's reordered records) goes through the DWG bridge (#2410). +#[test] +fn a_partial_block_table_survives_the_dwg_bridge() { + let dwg = cadcraft_dwg::dxf_to_dwg(block_dxf().as_bytes()).unwrap(); + let dxf = cadcraft_dwg::dwg_to_dxf(&dwg).unwrap(); + let d = cadcraft_io::read_dxf(&dxf).unwrap(); + assert_eq!(block_kinds(&d), ["wipeout", "circle", "hline", "vline"]); +} + +/// A sort handle of 0 draws the entity last, in a DXF and across the DWG bridge (#2439). +#[test] +fn a_zero_sort_handle_draws_last() { + // The horizontal line is keyed 0 (last); the vertical line 25E; the wipeout 25B; the circle its own 25C. + let dxf = block_dxf_with("331\n25B\n5\n0\n331\n25D\n5\n25E\n331\n25E\n5\n25B\n"); + let want = ["wipeout", "circle", "vline", "hline"]; + assert_eq!(block_kinds(&cadcraft_io::read_dxf(dxf.as_bytes()).unwrap()), want, "DXF"); + let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap(); + let back = cadcraft_io::read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap(); + assert_eq!(block_kinds(&back), want, "DWG"); +} + +/// Two entities left out of the table each sort by their own handle, so they land between the +/// listed ones (a single fallback key for every unlisted entity would put them side by side) +/// in a DXF and across the DWG bridge (#2439). +#[test] +fn two_unlisted_block_entities_each_sort_by_their_own_handle() { + // Listed: the horizontal line 25B and the vertical line 25D, each under its own handle. + // Unlisted: the circle 25C and the wipeout 25E. + let dxf = block_dxf_with("331\n25B\n5\n25B\n331\n25D\n5\n25D\n"); + let want = ["hline", "circle", "vline", "wipeout"]; + assert_eq!(block_kinds(&cadcraft_io::read_dxf(dxf.as_bytes()).unwrap()), want, "DXF"); + let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap(); + let back = cadcraft_io::read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap(); + assert_eq!(block_kinds(&back), want, "DWG"); +} From 9b4c1fb840b3bd6720e09a38e2a4ff89196a5185 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:31:33 +0100 Subject: [PATCH 24/29] Reject negative or non-finite JSON chamfer distances (#2406) Found while testing crates/engine/src/cmd/modify/fillet.rs:120-121 (run_chamfer) and 53-54 (fillet_lines) Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/cmd/modify/fillet.rs | 6 +++++- crates/engine/src/tests.rs | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/modify/fillet.rs b/crates/engine/src/cmd/modify/fillet.rs index 963de2607..2a75b22e7 100644 --- a/crates/engine/src/cmd/modify/fillet.rs +++ b/crates/engine/src/cmd/modify/fillet.rs @@ -118,7 +118,11 @@ pub(super) fn run_chamfer(s: &mut Session, p: &Value) -> Result { } (None, None) => { let d1 = f64_or(p, "d1", s.doc()?.header.f64("CHAMFERA", 0.0)); - ChamferCut::Dist(d1, f64_or(p, "d2", d1)) + let d2 = f64_or(p, "d2", d1); + if !(d1.is_finite() && d1 >= 0.0 && d2.is_finite() && d2 >= 0.0) { + return Err(bad("chamfer", "`d1` and `d2` must be >= 0")); + } + ChamferCut::Dist(d1, d2) } _ => return Err(bad("chamfer", "`length` (>= 0) and `angle` (degrees, 0 to 180) go together")), }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index be6246824..427bfe8d4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1454,6 +1454,18 @@ fn hostile_variable_like_input_never_panics() { assert!(setvar_prompt_open(&s)); } +#[test] +fn chamfer_rejects_negative_distances() { + let mut s = Session::new(); + let h1 = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + let h2 = s.execute("line", &json!({"points": [[0, 0], [0, 10]]})).unwrap(); + let before = format!("{:?}", s.doc().unwrap().model.iter().map(|e| e.kind.clone()).collect::>()); + let r = s.execute("chamfer", &json!({"h1": h1["handles"][0], "h2": h2["handles"][0], "p1": [5, 0], "p2": [0, 5], "d1": -3, "d2": 2})); + assert!(r.is_err(), "negative chamfer distance accepted: {r:?}"); + let after = format!("{:?}", s.doc().unwrap().model.iter().map(|e| e.kind.clone()).collect::>()); + assert_eq!(before, after); +} + #[test] fn array_counts_reject_invalid_values() { let mut s = Session::new(); From f883454d7dd4e1016a100fed90968e6825953d9d Mon Sep 17 00:00:00 2001 From: Books Date: Fri, 30 Oct 2026 12:37:44 +1100 Subject: [PATCH 25/29] DWG: post-read fix-ups run under catch_unwind; merge work is bounded (#1934) (#2402) * DWG: post-read fix-ups run under catch_unwind; merge work is bounded (#1934) table_merges and the other fix-ups on the acadrust document ran outside the panic guard, and each merge range could visit rows x cols cells with no total limit. They now run in one guarded post_process, and table_merges stops at MAX_MERGE_CELLS cells in all. Tests: merge_work_is_bounded_by_the_cell_budget, a_panic_in_post_processing_is_an_error. * Parity checklist: regenerated after merging main * DWG: test that the read and convert guards catch a panic through the public API (#2402) The existing test only exercised guarded() itself. A test-only hook now makes post_process or convert panic on entry, and tests go through dwg_to_dxf and dxf_to_dwg: both fail if their guard is removed. convert()'s fix-ups, which run on the third-party reader's document, are now under guarded() too, with their own error. * Parity checklist: regenerated after merging main * DWG: build the panic-test helpers only on native targets, like the tests that use them (#2402) PanicIn and MINIMAL_DXF referenced the native-only PANIC_IN, so the dwg tests did not compile for wasm32 (cargo check -p cadcraft-dwg --tests --target wasm32-unknown-unknown). --------- Co-authored-by: Brandon Thomas --- crates/dwg/src/lib.rs | 153 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 140 insertions(+), 13 deletions(-) diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index 7e4a88fee..f7bf13dd6 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -104,11 +104,7 @@ mod native { } let mut notes = read_notes(&outcome.stats); let mut doc = outcome.document; - current_style_names(&mut doc); - table_merges(&mut doc); - table_style_rows(&mut doc, false); - keep_sort_handles(&mut doc); - let orphans = drop_orphaned_dgn_components(&mut doc); + let orphans = guarded(AssertUnwindSafe(|| post_process(&mut doc))).map_err(|_| "the DWG post-processing failed on this file".to_string())?; if orphans > 0 { let (s, were) = if orphans == 1 { ("", "was") } else { ("s", "were") }; notes.push(format!("DWG: {orphans} DGN line style component{s} that no line style definition uses {were} left out")); @@ -238,6 +234,33 @@ mod native { /// cells already carry merges are left alone. Each range's first cell gets the span; the /// cells it covers are marked merged with no span of their own, as CADCraft's DXF writer does. fn table_merges(doc: &mut acadrust::CadDocument) { + table_merges_within(doc, MAX_MERGE_CELLS); + } + + /// The most table cells [`table_merges`] visits in one document: each range is capped, but + /// can cover rows × cols cells, so the total is bounded too. + const MAX_MERGE_CELLS: usize = 1 << 24; + + /// The fix-ups made to a document after the DWG read, returning how many orphaned DGN + /// components were dropped. The document comes from a third-party reader and is hostile. + fn post_process(doc: &mut acadrust::CadDocument) -> usize { + super::fail_in_test("post_process"); + current_style_names(doc); + table_merges(doc); + table_style_rows(doc, false); + keep_sort_handles(doc); + drop_orphaned_dgn_components(doc) + } + + /// Runs `f`, turning a panic into an error (the reader and writer are guarded the same way). + fn guarded(f: impl FnOnce() -> R + std::panic::UnwindSafe) -> Result { + std::panic::catch_unwind(f).map_err(|_| ()) + } + + /// [`table_merges`] visiting at most `budget` cells in all; a range that would pass it is + /// left unmerged. Returns the cells visited. + fn table_merges_within(doc: &mut acadrust::CadDocument, budget: usize) -> usize { + let mut left = budget; for e in doc.entities_mut() { let acadrust::entities::EntityType::Table(t) = e else { continue }; if t.merged_ranges.is_empty() || t.rows.iter().flat_map(|r| &r.cells).any(|c| c.merge_width > 1 || c.merge_height > 1) { @@ -253,6 +276,11 @@ mod native { if r.top_row > bottom || r.left_col > right || rows == 0 || cols == 0 || (r.top_row, r.left_col) == (bottom, right) { continue; } + let area = (bottom - r.top_row + 1).saturating_mul(right - r.left_col + 1); + if area > left { + continue; + } + left -= area; let span = |n: usize| i32::try_from(n + 1).unwrap_or(i32::MAX); for (ri, row) in t.rows.iter_mut().enumerate().take(bottom + 1).skip(r.top_row) { for (ci, cell) in row.cells.iter_mut().enumerate().take(right + 1).skip(r.left_col) { @@ -264,6 +292,7 @@ mod native { } } } + budget - left } /// Draw-order tables (SORTENTSTABLE) keep the sort handles the file gave them. acadrust's DXF @@ -569,14 +598,19 @@ mod native { None if doc.version < MIN_DWG_VERSION => doc.version = MIN_DWG_VERSION, None => {} } - other_layout_owners(&mut doc); - table_style_rows(&mut doc, true); - dimension_flags(&mut doc); - drop_unwritable(&mut doc); - current_dimstyle(&mut doc); - add_variable_dictionary(&mut doc); - current_text_style(&mut doc); - current_layer(&mut doc); + // The fix-ups run on the third-party reader's document, so a panic in one is an error, not a crash. + guarded(AssertUnwindSafe(|| { + super::fail_in_test("convert"); + other_layout_owners(&mut doc); + table_style_rows(&mut doc, true); + dimension_flags(&mut doc); + drop_unwritable(&mut doc); + current_dimstyle(&mut doc); + add_variable_dictionary(&mut doc); + current_text_style(&mut doc); + current_layer(&mut doc); + })) + .map_err(|_| "the DXF to DWG conversion failed on this file".to_string())?; std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc))) .map_err(|_| "the DWG writer failed on this drawing".to_string())? .map_err(|e| format!("DWG write: {e}")) @@ -673,11 +707,60 @@ mod native { } } } + + #[cfg(test)] + mod post_tests { + use super::*; + use acadrust::entities::{CellRange, EntityType, Table}; + + fn doc_with_table(rows: usize, cols: usize, ranges: usize) -> acadrust::CadDocument { + let mut t = Table::new(acadrust::types::Vector3::new(0.0, 0.0, 0.0), rows, cols); + t.merged_ranges = (0..ranges).map(|_| CellRange::new(0, 0, rows - 1, cols - 1)).collect(); + let mut doc = acadrust::CadDocument::new(); + assert!(doc.add_entity(EntityType::Table(Box::new(t))).is_ok()); + doc + } + + /// Overlapping whole-table ranges can't make the merge pass visit more than its budget (#1934). + #[test] + fn merge_work_is_bounded_by_the_cell_budget() { + let mut doc = doc_with_table(10, 10, 50); + assert_eq!(table_merges_within(&mut doc, 250), 200, "two whole-table ranges fit, the rest are skipped"); + let mut doc = doc_with_table(10, 10, 1); + assert_eq!(table_merges_within(&mut doc, usize::MAX), 100); + } + + /// A panic in the post-read fix-ups is an error, not a crash (#1934). + #[test] + fn a_panic_in_post_processing_is_an_error() { + #[allow(clippy::panic)] + let r = guarded(AssertUnwindSafe(|| -> usize { panic!("boom") })); + assert!(r.is_err()); + assert_eq!(guarded(|| 3), Ok(3)); + } + } } #[cfg(not(target_arch = "wasm32"))] pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as}; +// Test-only: the stage ("post_process" or "convert") whose guard the current test checks. The +// stage then panics on entry, so a test through the public API shows whether it is guarded. +#[cfg(all(test, not(target_arch = "wasm32")))] +thread_local! { + static PANIC_IN: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// Panics when a test asked for `stage` to fail (see [`PANIC_IN`]); does nothing otherwise. +#[cfg(not(target_arch = "wasm32"))] +#[allow(clippy::panic)] +fn fail_in_test(_stage: &str) { + #[cfg(test)] + if PANIC_IN.with(|p| p.get()) == Some(_stage) { + panic!("test-forced panic in {_stage}"); + } +} + /// The error for a DWG version CADCraft can't write. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] fn unsupported(version: &str) -> String { @@ -710,6 +793,50 @@ pub fn dxf_to_dwg_as(_dxf: &[u8], _version: &str) -> Result, String> { mod tests { use super::*; + /// The DXF of an empty drawing, enough for the bridge to make a small DWG. + #[cfg(not(target_arch = "wasm32"))] + const MINIMAL_DXF: &str = " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nEOF\n"; + + /// Makes `stage` panic on the current thread until dropped. + #[cfg(not(target_arch = "wasm32"))] + struct PanicIn; + + #[cfg(not(target_arch = "wasm32"))] + impl PanicIn { + fn at(stage: &'static str) -> Self { + PANIC_IN.with(|p| p.set(Some(stage))); + Self + } + } + + #[cfg(not(target_arch = "wasm32"))] + impl Drop for PanicIn { + fn drop(&mut self) { + PANIC_IN.with(|p| p.set(None)); + } + } + + /// The public read reports a panic in the post-read fix-ups as an error: this fails if the + /// `guarded` call around `post_process` is taken out (#2402). + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn reading_a_dwg_survives_a_panic_in_post_processing() { + let dwg = dxf_to_dwg(MINIMAL_DXF.as_bytes()).unwrap(); + assert!(dwg_to_dxf(&dwg).is_ok(), "the DWG reads without the forced panic"); + let _panic = PanicIn::at("post_process"); + let err = dwg_to_dxf(&dwg).unwrap_err(); + assert!(err.contains("the DWG post-processing failed on this file"), "{err}"); + } + + /// The DXF to DWG conversion reports a panic in its fix-ups as an error, not a crash (#2402). + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn converting_to_dwg_survives_a_panic_in_the_fix_ups() { + let _panic = PanicIn::at("convert"); + let err = dxf_to_dwg(MINIMAL_DXF.as_bytes()).unwrap_err(); + assert!(err.contains("the DXF to DWG conversion failed on this file"), "{err}"); + } + #[test] fn detects_magic() { assert!(is_dwg(b"AC1032\0\0\0\0")); From 2d2be155dd97212ecc3bb81dec0fe31bcb15d123 Mon Sep 17 00:00:00 2001 From: smacdmac101 <152641296+smacdmac101@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:38:15 -0400 Subject: [PATCH 26/29] fix(engine): refresh attribute baselines after value changes (#2214) * xtask: the test suite and clippy pass on Windows Two Windows-only failures stopped `cargo xtask ci` before it got going on a Windows checkout (CI runs the gate on Linux only, so neither showed): - parity::tests::civil_dates built a SystemTime 115,740,740 days after the epoch (year 318857). Windows' SystemTime ends in the year 30828, so the addition panicked ("overflow when adding duration to `SystemTime`"). That case now checks `civil_from_days` on the day count, which is what it was about, on every platform. - stats.rs imported `walk` for a test that only exists on Unix, so the import was unused on Windows and `clippy -D warnings` refused it. The Unix test calls `super::walk`, as its neighbour calls `super::walk_inner`. Checked on Windows 11 x64: `cargo test --workspace` and `cargo clippy --workspace --all-targets -- -D warnings` both pass. Co-Authored-By: Claude Opus 5.5 * fix(engine): refresh attribute baselines after value changes CC-046: invalidate the derived XY baseline for changed justified attribute values, including small changes inside the writer's imported-font tolerance. Preserve no-op placement, alignment anchors and Aligned/Fit endpoints. Regression reproduced on current upstream; all three focused tests pass. Full cargo xtask ci passed all seven stages on Windows (2558 passing test executions, zero failures, one ignored). Release Trunk web build passed. Browser interaction verification is pending user dismissal of a Windows Security prompt; do not treat the build as a runtime pass. Windows validation prerequisite: original upstream PR #2198 commit a2dd814. Independent of editor/ribbon changes; reverting restores only this setter, its regression tests and corresponding file-format parity record. * fix(io): preserve insertion elevation when refreshing text baselines CC-046 review follow-up to PR #2214 comment 4377706777. Regression reproduced insertion Z 9 becoming 7. Preserve group 10 Z; test DXF independent elevations and document the DWG bridge shared elevation. Full cargo xtask ci passes all seven gates. This separate commit is reversible; reverting it restores elevation loss during baseline recomputation. Existing browser UI verification predates this writer correction. * test(engine): cover global attribute no-ops and two-point exports CC-046 review 5653054817: verify equal-value global JSON and scripted -ATTEDIT preserve imported placement, and Aligned/Fit DXF save/reopen retain both placement points and justification. Validation: cargo xtask ci passed all seven gates on this branch; git diff --check passed. Test-only follow-up, no application change. Depends on CC-046 and unchanged Windows prerequisite #2198. Revert this commit independently to remove only these assertions. Runtime evidence remains separately recorded for the production commits. --------- Co-authored-by: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/blocks/attedit.rs | 22 +- .../tests/it/attribute_value_baseline.rs | 203 ++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + crates/io/src/dxf_write/common.rs | 3 +- docs/file-format-parity.md | 5 +- 5 files changed, 228 insertions(+), 6 deletions(-) create mode 100644 crates/engine/tests/it/attribute_value_baseline.rs diff --git a/crates/engine/src/cmd/blocks/attedit.rs b/crates/engine/src/cmd/blocks/attedit.rs index ad123f0b3..be59474c5 100644 --- a/crates/engine/src/cmd/blocks/attedit.rs +++ b/crates/engine/src/cmd/blocks/attedit.rs @@ -3,7 +3,7 @@ //! time or by replacing a string in all of them. Attribute tags match in any case, as in AutoCAD. use cadcraft_doc::color::Color; -use cadcraft_doc::{AttribProps, Common, EntityKind, Handle}; +use cadcraft_doc::{AttribProps, Common, EntityKind, HAlign, Handle, Text, VAlign}; use serde_json::{Map, Value, json}; use crate::cmd::curves::KW; @@ -67,7 +67,7 @@ fn run(s: &mut Session, p: &Value) -> Result { if let EntityKind::Insert(i) = &mut e.kind { for a in &mut i.attribs { if let Some(v) = value_for(&vals, &a.tag) { - a.text.value = v; + set_value(&mut a.text, v); n += 1; } } @@ -130,13 +130,29 @@ fn edit(s: &mut Session, (h, k): (Handle, usize), f: impl FnOnce(&str) -> Option && let Some(a) = ins.attribs.get_mut(k) && let Some(v) = f(&a.text.value).filter(|v| *v != a.text.value) { - a.text.value = v; + set_value(&mut a.text, v); changed = true; } })?; Ok(changed) } +/// Refresh the derived baseline after a value change, even one within the writer's +/// imported-font tolerance. Equal XY points request recomputation on export; retain +/// both elevations and the independent endpoints used by Aligned/Fit text. +fn set_value(text: &mut Text, value: String) { + if text.value == value { + return; + } + text.value = value; + if !matches!((text.halign, text.valign), (HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _)) + && let Some(anchor) = text.align_pt + { + text.insert.x = anchor.x; + text.insert.y = anchor.y; + } +} + /// Change the properties set on attribute `k` of block reference `h`. fn set_props(s: &mut Session, (h, k): (Handle, usize), f: impl FnOnce(&mut AttribProps)) -> Result<()> { s.doc_mut()?.modify_entity(h, |e| { diff --git a/crates/engine/tests/it/attribute_value_baseline.rs b/crates/engine/tests/it/attribute_value_baseline.rs new file mode 100644 index 000000000..8f7523e0b --- /dev/null +++ b/crates/engine/tests/it/attribute_value_baseline.rs @@ -0,0 +1,203 @@ +//! Small value changes must not export an imported justified attribute's old baseline. +use cadcraft_doc::{Attrib, Drawing, EntityKind, HAlign, Handle, Text, VAlign}; +use cadcraft_engine::{Input, Session}; +use cadcraft_geom::Vec3; +use serde_json::json; + +const BEFORE: &str = "POWER ADAPTER"; +const AFTER: &str = "POWER ADAPTER."; + +fn fixture() -> (Session, Handle, Handle) { + let mut s = Session::new(); + for (tag, value, y) in [("T", BEFORE, 15), ("U", "ADPT-01", 0)] { + s.execute("attdef", &json!({"tag":tag,"default":value,"at":[0,y],"height":3})).unwrap(); + } + s.execute("line", &json!({"points":[[0,-5],[40,-5]]})).unwrap(); + let handles: Vec<_> = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect(); + s.execute("block", &json!({"name":"Equipment","handles":handles,"base":[0,0],"keep":"delete"})).unwrap(); + let a = s.execute("insert", &json!({"name":"Equipment","at":[100,200],"scale":2,"rotation":30})).unwrap(); + let b = s.execute("insert", &json!({"name":"Equipment","at":[300,200],"attribs":{"T":"Other"}})).unwrap(); + let a = Handle::parse_hex(a["handle"].as_str().unwrap()).unwrap(); + let b = Handle::parse_hex(b["handle"].as_str().unwrap()).unwrap(); + // Set existing document fields directly: this regression does not require Text Options. + s.doc_mut() + .unwrap() + .modify_entity(a, |e| { + let EntityKind::Insert(i) = &mut e.kind else { panic!("insert") }; + let t = &mut i.attribs[0].text; + t.insert.z = 7.0; + t.align_pt = Some(t.insert); + t.halign = HAlign::Center; + t.valign = VAlign::Top; + }) + .unwrap(); + let imported = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap(); + let mut s = Session::empty(); + s.open_drawing(imported, "Imported", None); + (s, a, b) +} + +fn attributes(d: &Drawing, h: Handle) -> Vec { + let EntityKind::Insert(i) = &d.entity(h).unwrap().kind else { panic!("insert") }; + i.attribs.clone() +} + +fn baseline(d: &Drawing, t: &Text) -> cadcraft_geom::Vec2 { + let style = d.text_style(&t.style).unwrap(); + let (h, v) = cadcraft_render::text_align(t.halign, t.valign); + cadcraft_fonts::baseline_start( + &cadcraft_fonts::TextFont::resolve(&style.font), + &t.value, + &cadcraft_fonts::TextParams { + insert: t.insert.xy(), + align_pt: t.align_pt.map(|p| p.xy()), + height: t.height, + rotation: t.rotation, + width_factor: t.width_factor, + oblique: t.oblique, + h, + v, + backwards: style.backwards, + upside_down: style.upside_down, + }, + ) +} + +#[test] +fn all_attribute_value_paths_refresh_small_justified_export_changes() { + for path in ["json", "interactive", "global-json", "global-interactive"] { + let (mut s, a, b) = fixture(); + let before = attributes(s.doc().unwrap(), a); + let other = attributes(s.doc().unwrap(), b); + let definition = s.doc().unwrap().block("Equipment").unwrap().clone(); + match path { + "json" => { + s.execute("attedit", &json!({"handle":a.hex(),"values":{"t":AFTER}})).unwrap(); + } + "interactive" => { + s.start("attedit").unwrap(); + s.input(Input::Pick(vec![a])).unwrap(); + s.input(Input::Text(AFTER.into())).unwrap(); + s.input(Input::Enter).unwrap(); + assert!(s.running.is_none()); + } + "global-json" => { + s.execute("-attedit", &json!({"handles":[a.hex()],"tag":"T","find":"ADAPTER","replace":"ADAPTER."})).unwrap(); + } + "global-interactive" => { + s.script("-attedit\nn\nn\nEquipment\nT\n\nADAPTER\nADAPTER.\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + } + _ => panic!("path"), + } + let edited = attributes(s.doc().unwrap(), a); + assert_eq!(edited[0].text.value, AFTER, "{path}"); + assert_eq!(edited[0].text.align_pt, before[0].text.align_pt); + assert_eq!(edited[0].text.insert.z, 7.0); + assert_eq!(edited[1], before[1]); + assert_eq!(attributes(s.doc().unwrap(), b), other); + assert_eq!(s.doc().unwrap().block("Equipment").unwrap(), &definition); + let expected = baseline(s.doc().unwrap(), &edited[0].text); + assert!(expected.dist(before[0].text.insert.xy()) > 0.01, "fixture must change width"); + for name in ["edited.dxf", "edited.dwg"] { + let reopened = cadcraft_io::read(&cadcraft_io::write(s.doc().unwrap(), name).unwrap(), name).unwrap(); + let restored = attributes(&reopened, a); + assert!( + restored[0].text.insert.xy().dist(expected) < 1e-8, + "{path} {name}: stale {:?}, expected {expected:?}", + restored[0].text.insert.xy() + ); + assert_eq!(restored[0].text.align_pt, before[0].text.align_pt); + assert_eq!(restored[0].text.value, AFTER); + assert_eq!(restored[0].text.insert.z, 7.0); + } + s.undo().unwrap(); + assert_eq!(attributes(s.doc().unwrap(), a), before, "{path} undo"); + s.redo().unwrap(); + assert_eq!(attributes(s.doc().unwrap(), a), edited, "{path} redo"); + } +} + +#[test] +fn equal_attribute_values_preserve_an_imported_font_baseline() { + let (mut s, a, _) = fixture(); + // A small imported-font offset must survive a no-op edit (writer tolerance is deliberate). + s.doc_mut() + .unwrap() + .modify_entity(a, |e| { + let EntityKind::Insert(i) = &mut e.kind else { panic!("insert") }; + i.attribs[0].text.insert.x += 0.1; + }) + .unwrap(); + let before = attributes(s.doc().unwrap(), a); + s.execute("attedit", &json!({"handle":a.hex(),"values":{"T":BEFORE}})).unwrap(); + assert_eq!(attributes(s.doc().unwrap(), a), before); + s.start("attedit").unwrap(); + s.input(Input::Pick(vec![a])).unwrap(); + s.input(Input::Enter).unwrap(); + s.input(Input::Enter).unwrap(); + assert_eq!(attributes(s.doc().unwrap(), a), before); + s.execute("-attedit", &json!({"handles":[a.hex()],"tag":"T","find":"ADAPTER","replace":"ADAPTER"})).unwrap(); + assert_eq!(attributes(s.doc().unwrap(), a), before, "global JSON no-op"); + s.script("-attedit\nn\nn\nEquipment\nT\n\nADAPTER\nADAPTER\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(attributes(s.doc().unwrap(), a), before, "global interactive no-op"); +} + +/// Recomputing a justified baseline must not replace its independent insertion elevation. +#[test] +fn value_edits_preserve_distinct_insertion_and_alignment_elevations_on_export() { + let (mut s, a, _) = fixture(); + s.doc_mut() + .unwrap() + .modify_entity(a, |e| { + let EntityKind::Insert(i) = &mut e.kind else { panic!("insert") }; + i.attribs[0].text.insert.z = 9.0; + }) + .unwrap(); + let anchor = attributes(s.doc().unwrap(), a)[0].text.align_pt; + s.execute("attedit", &json!({"handle":a.hex(),"values":{"T":AFTER}})).unwrap(); + for name in ["elevations.dxf", "elevations.dwg"] { + let reopened = cadcraft_io::read(&cadcraft_io::write(s.doc().unwrap(), name).unwrap(), name).unwrap(); + let restored = attributes(&reopened, a); + assert_eq!(restored[0].text.insert.z, 9.0, "{name}: insertion elevation"); + assert_eq!(restored[0].text.align_pt.map(|p| p.xy()), anchor.map(|p| p.xy()), "{name}: alignment XY"); + if name.ends_with(".dxf") { + assert_eq!(restored[0].text.align_pt, anchor, "DXF retains independent alignment elevation"); + } else { + // The DWG bridge uses one text elevation for both points. + assert_eq!(restored[0].text.align_pt.unwrap().z, 9.0); + } + } +} + +#[test] +fn value_edits_preserve_left_baseline_and_two_point_placements() { + for horizontal in [HAlign::Left, HAlign::Aligned, HAlign::Fit] { + let (mut s, a, _) = fixture(); + s.doc_mut() + .unwrap() + .modify_entity(a, |e| { + let EntityKind::Insert(i) = &mut e.kind else { panic!("insert") }; + let t = &mut i.attribs[0].text; + t.halign = horizontal; + t.valign = VAlign::Baseline; + t.insert = Vec3::new(10.0, 20.0, 7.0); + t.align_pt = (horizontal != HAlign::Left).then_some(Vec3::new(40.0, 30.0, 9.0)); + }) + .unwrap(); + let before = attributes(s.doc().unwrap(), a); + s.execute("attedit", &json!({"handle":a.hex(),"values":{"T":AFTER}})).unwrap(); + let mut expected = before; + expected[0].text.value = AFTER.into(); + assert_eq!(attributes(s.doc().unwrap(), a), expected); + if horizontal != HAlign::Left { + let reopened = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap(); + let restored = attributes(&reopened, a); + assert_eq!(restored[0].text.insert, expected[0].text.insert); + assert_eq!(restored[0].text.align_pt, expected[0].text.align_pt); + assert_eq!(restored[0].text.halign, horizontal); + assert_eq!(restored[0].text.value, AFTER); + } + } +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 8e20e6fa8..ed88a2316 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -10,6 +10,7 @@ mod attedit_prompts; mod attrib_modes; mod attrib_props; mod attribute_tools; +mod attribute_value_baseline; mod block_description; mod block_keep; mod block_redefine; diff --git a/crates/io/src/dxf_write/common.rs b/crates/io/src/dxf_write/common.rs index bf8fad449..6ba3ce8ef 100644 --- a/crates/io/src/dxf_write/common.rs +++ b/crates/io/src/dxf_write/common.rs @@ -237,7 +237,8 @@ fn computed_text_insert(d: &Drawing, t: &Text) -> Vec3 { upside_down: ts.upside_down, }; let q = cadcraft_fonts::baseline_start(&cadcraft_fonts::TextFont::resolve(&ts.font), &t.value, &p); - if q.is_finite() { Vec3::new(q.x, q.y, a.z) } else { t.insert } + // Justification recalculates the baseline in XY; group 10 keeps its own elevation. + if q.is_finite() { Vec3::new(q.x, q.y, t.insert.z) } else { t.insert } } pub(super) fn text_tags(w: &mut W, d: &Drawing, t: &Text, attrib: Option<(&Attrib, Version)>, attdef_prompt: Option<&str>) { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 291c9b202..d2af85803 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LAYOUT SAveas writes a layout as a DWT template) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (attribute value edits refresh exported baselines) · **Target:** Autodesk AutoCAD 2027 Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work items are in [gaps.md](gaps.md#file-formats); the weighted score feeds @@ -75,7 +75,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG |---|---|---|---| | LINE, POINT, CIRCLE, ARC, ELLIPSE, LWPOLYLINE (widths, bulges, elevation), SPLINE, RAY, XLINE | yes | yes | round-trip tests | | POLYLINE 2D/3D | yes | yes (as LWPOLYLINE / 3D POLYLINE) | polyface and polygon meshes flattened: lossy | -| TEXT, MTEXT, ATTDEF, INSERT + ATTRIB | yes; multiline attributes (embedded MTEXT) read and drawn | yes; multiline attributes written as embedded MTEXT (DXF 2007 and later; older versions: one line) | ATTDEF prompt kept (#137) | +| TEXT, MTEXT, ATTDEF, INSERT + ATTRIB | yes; multiline attributes (embedded MTEXT) read and drawn | yes; multiline attributes written as embedded MTEXT (DXF 2007 and later; older versions: one line); changed attribute values refresh justified baselines, including small width changes | ATTDEF prompt kept (#137); unchanged values retain imported-font placement | | DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written for every associated kind — linear/aligned, angular, radial/diametric, ordinate and arc length — and read back without CADCraft's xdata (#1651) | | DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written; angular block text honors DIMDSEP, including per-dimension overrides (#1646) | | DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks; their text keeps the diameter sign as `%%c`, so SHX styles plot it (#1645) | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written | @@ -140,6 +140,7 @@ our output in AutoCAD black-box. | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | CC-046: ATTEDIT and -ATTEDIT value changes invalidate derived justified baselines before DXF/DWG export; preserve alignment anchors, unchanged values and Aligned/Fit endpoints. Review follow-up: baseline recomputation preserves insertion elevation independently of alignment elevation. The shared writer correction also applies to justified TEXT. Independent of attribute editor UI; reverting the follow-up restores elevation loss on recomputation, while reverting the value setter restores stale XY baselines. | | 2026-10-25 | minor | LAYOUT SAveas (`layout.saveas`) writes a layout as a drawing template: its page setup and paper space objects, no model space objects or other layouts, and only the named objects they use | | 2026-10-22 | minor | DXF/DWG save: angular, diametric and radial dimensions get standard DIMASSOC objects (one centre reference to the circle for radial/diametric, the definition points for angular), so AutoCAD follows their geometry; the standard objects of those kinds are read back too, without CADCraft's xdata (#1651) | | 2026-10-19 | minor | DXF/DWG save: angular dimension block text honors DIMDSEP, including per-dimension overrides (#1646) | From 7546277128b82ce3c89f0fdf6a663bbea2ad6bc4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 29 Oct 2026 21:39:34 -0400 Subject: [PATCH 27/29] fmt: rustfmt the DIMTMOVE 2 clearance port (#2302) Co-Authored-By: Claude Opus 5.5 --- crates/render/src/dim/linear.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/crates/render/src/dim/linear.rs b/crates/render/src/dim/linear.rs index 8127aef8f..da1b2a746 100644 --- a/crates/render/src/dim/linear.rs +++ b/crates/render/src/dim/linear.rs @@ -151,9 +151,7 @@ fn position_linear_text(config: &DimConfig<'_>, state: &mut LinearDimState) { state.side_out = if (config.dimension.defpt.xy() - state.p1).dot(state.n) >= 0.0 { state.n } else { -state.n }; state.text_axis = Vec2::from_angle(state.angle); let clearance = |axis: Vec2, across: f64| { - config.gap - + state.text_width * state.text_axis.dot(axis).abs() / 2.0 - + state.text_height * state.text_axis.perp().dot(axis).abs() * across + config.gap + state.text_width * state.text_axis.dot(axis).abs() / 2.0 + state.text_height * state.text_axis.perp().dot(axis).abs() * across }; // Empirical #2004 match: 0.595× height with SHX and 0.601× with TrueType. let off_along = |axis: Vec2| clearance(axis, if st.text_above == 1 { 0.6 } else { 0.5 }); @@ -186,11 +184,8 @@ fn position_linear_text(config: &DimConfig<'_>, state: &mut LinearDimState) { // Half the text height either way, whatever DIMTAD says (#2222). let clear = clearance(state.side_out, 0.5); let lift = clear + 2.0 * config.arrow_size.max(config.tick_size); - state.text_mid = if st.text_move == 1 { - mid + state.side_out * lift + state.u * state.half_extent - } else { - mid + state.side_out * (lift + clear) - }; + state.text_mid = + if st.text_move == 1 { mid + state.side_out * lift + state.u * state.half_extent } else { mid + state.side_out * (lift + clear) }; } else if !config.dimension.user_text_pos { state.text_mid = state.along_center + state.offset; } From b3158b56a8a2024681b33ad1a7655cc94bf82484 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 29 Oct 2026 22:14:18 -0400 Subject: [PATCH 28/29] CI: workflows only call craft-common-actions at @main (#2473) AGENTS.md (and CLAUDE.md where present) gets a CI workflows section: workflows only call craft-common-actions at @main, with no other jobs; feature and fix PRs don't touch CI and link or open a craft-common-actions issue (a PR only for a small fix that is safe for every app and does not increase CI time); legacy jobs aren't added to and move only as their own task, staying until a shared workflow runs them. windows-arm64.yml moves from its SHA pin to @main. The pinned revision is on craft-common-actions main and the workflow (and the actions it calls) is identical there, so nothing changes in behaviour. --- .github/workflows/windows-arm64.yml | 4 ++-- AGENTS.md | 5 +++++ CLAUDE.md | 5 +++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index a671ff2f2..167839e9e 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -25,8 +25,8 @@ permissions: jobs: windows-arm64: - # Immutable supporting workflow revision until the new install contract lands upstream. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main with: app-name: cadcraft app-dir: CADCraft diff --git a/AGENTS.md b/AGENTS.md index e436894b2..e3d7418d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,3 +66,8 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr GitHub profiles). Never hand-edit `contributors.json`. - Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/CLAUDE.md b/CLAUDE.md index 33e6a2121..8e4a1fdfa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,3 +47,8 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr ## Roadmap `ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the affected rows and the docs' status lines and revision history. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`. + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. From f8b09e92e55e869a0c5199f9f447e487fde0aeda Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 29 Oct 2026 22:22:29 -0400 Subject: [PATCH 29/29] deps: bump tar, zip, roxmltree, rustls-webpki and symphonia to their latest versions (#2475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `zip`: 9.1.1 → 9.1.3 Lockfile only. --- Cargo.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 34cfc44cc..1159481f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -268,7 +268,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.61.2", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -1642,7 +1642,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3434,7 +3434,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4029,7 +4029,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4086,7 +4086,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4561,10 +4561,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5470,7 +5470,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6114,9 +6114,9 @@ dependencies = [ [[package]] name = "zip" -version = "9.1.1" +version = "9.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4" +checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb" dependencies = [ "crc32fast", "flate2",