From 698f2c8c367f6f2210dbc3b1ce26c63cd89b0e84 Mon Sep 17 00:00:00 2001 From: Books Date: Sun, 11 Oct 2026 03:57:07 +1100 Subject: [PATCH 001/150] Redraw edits made while a command is still running (fixes #4) (#22) LINE and PLINE add geometry on every point, but the canvas only rebuilt its display list when the drawing revision or the document pointer changed. The revision was bumped when a command finished, and the pointer changed only on the first copy-on-write edit, so only the first segment appeared until the command ended. Session::feed now bumps the revision after each input once the running command has changed the drawing. --- crates/engine/src/lib.rs | 6 ++++++ crates/engine/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 412b3afd..71b7c736 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -627,6 +627,12 @@ impl Session { } None => run.machine.begin(self), }; + // Redraw what the command has added so far (LINE adds a segment per point), not only when it ends. + if let Ok(st) = self.state_mut() + && !Arc::ptr_eq(&run.before, &st.doc) + { + st.revision += 1; + } match step { Ok(Step::Continue) => { self.running = Some(run); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 98b21bbf..446639b8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -25,6 +25,26 @@ fn line_via_command_line() { assert_eq!(s.doc().unwrap().model.len(), 3); } +#[test] +fn edits_while_a_command_runs_redraw() { + // The canvas rebuilds when (revision, doc pointer) changes; each new segment must change it (issue #4). + fn key(s: &Session) -> (u64, usize) { + let st = s.state().unwrap(); + (st.revision, Arc::as_ptr(&st.doc) as usize) + } + let mut s = Session::new(); + for (cmd, pts) in [("line", ["0,0", "10,0", "10,5", "0,5"]), ("pline", ["20,0", "30,0", "30,5", "20,5"])] { + s.cmdline(cmd).unwrap(); + s.cmdline(pts[0]).unwrap(); + for p in &pts[1..] { + let before = key(&s); + s.cmdline(p).unwrap(); + assert_ne!(key(&s), before, "{cmd}: segment to {p} not redrawn"); + } + s.cmdline("").unwrap(); + } +} + #[test] fn line_undo_option_and_aliases() { let mut s = Session::new(); From 093c87d08a59605b0a7567cd2d686797e5718b78 Mon Sep 17 00:00:00 2001 From: Books Date: Sun, 11 Oct 2026 03:57:13 +1100 Subject: [PATCH 002/150] DIMANGULAR: accept polyline segments (fixes #10) (#15) Picking a side of a rectangle or a PLINE segment was rejected with "Select a line or an arc." because only LINE and ARC entities were accepted. The picked object is now resolved to the segment under the cursor: straight polyline segments work like lines and arc segments like arcs. --- crates/engine/src/cmd/annotate.rs | 23 +++++++++++++++++------ crates/engine/src/cmd/annotate_tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index a9250854..ee09c34f 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -760,6 +760,19 @@ fn pick_at(s: &Session, p: Vec2) -> Option { crate::select::pick(s.doc().ok()?, &s.space(), p, ap) } +/// The straight or curved piece of `h` picked at `p`: a line, an arc, or the polyline segment nearest `p`. +fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option { + match &s.doc().ok()?.entity(h)?.kind { + EntityKind::Line(l) => Some(Segment::Line(cadcraft_geom::Line::new(l.a.xy(), l.b.xy()))), + EntityKind::Arc(a) => Some(Segment::Arc { arc: cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }), + EntityKind::LwPolyline(pl) => cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: pl.closed } + .segments() + .into_iter() + .min_by(|x, y| x.closest(p).dist(p).total_cmp(&y.closest(p).dist(p))), + _ => None, + } +} + struct LinearM { aligned: bool, pts: Vec, @@ -1003,15 +1016,13 @@ impl Interactive for AngularM { s.echo("*Invalid selection*"); return Ok(Step::Continue); }; - let kind = s.doc()?.entity(h).map(|e| e.kind.clone()); - match kind { - Some(EntityKind::Arc(a)) if self.first.is_none() => { - let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end); + match segment_at(s, h, p) { + Some(Segment::Arc { arc: g, .. }) if self.first.is_none() => { self.arc = Some((g.center, g.start_point(), g.end_point())); } - Some(EntityKind::Line(l)) => { + Some(Segment::Line(l)) => { self.handles.push(h); - let seg = (l.a.xy(), l.b.xy()); + let seg = (l.a, l.b); if self.first.is_none() { self.first = Some(seg); } else { diff --git a/crates/engine/src/cmd/annotate_tests.rs b/crates/engine/src/cmd/annotate_tests.rs index f644acd1..02a117b1 100644 --- a/crates/engine/src/cmd/annotate_tests.rs +++ b/crates/engine/src/cmd/annotate_tests.rs @@ -143,6 +143,31 @@ fn angular_between_lines_follows_rotation() { assert_eq!(cadcraft_render::dimension_in(s.doc().unwrap(), &dim(&s, d)).value, "45°"); } +#[test] +fn interactive_angular_picks_polyline_segments() { + // Rectangle sides are polyline segments, not lines (issue #10). + let mut s = Session::new(); + s.cmdline("rectang 0,0 10,5").unwrap(); + s.cmdline("dimangular").unwrap(); + s.cmdline("5,0").unwrap(); + s.cmdline("0,2").unwrap(); + s.cmdline("2,2").unwrap(); + assert!(s.running.is_none()); + let d = s.last_dim.unwrap(); + assert_eq!(cadcraft_render::dimension_in(s.doc().unwrap(), &dim(&s, d)).value, "90°"); + // A polyline arc segment works like an arc. + s.cmdline("pline 20,0 30,0 a 30,10").unwrap(); + s.cmdline("").unwrap(); + s.cmdline("dimangular").unwrap(); + let (cx, cy) = (30.0 + 5.0 * std::f64::consts::FRAC_1_SQRT_2, 5.0 - 5.0 * std::f64::consts::FRAC_1_SQRT_2); + s.cmdline(&format!("{cx},{cy}")).unwrap(); + s.cmdline("40,5").unwrap(); + assert!(s.running.is_none()); + let d2 = s.last_dim.unwrap(); + assert_ne!(d, d2); + assert_eq!(cadcraft_render::dimension_in(s.doc().unwrap(), &dim(&s, d2)).value, "180°"); +} + #[test] fn erase_disassociate_and_reassociate() { let mut s = Session::new(); From 3278c58942e5382fbf84cfb2278e087abbb44a01 Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Sat, 10 Oct 2026 10:57:20 -0600 Subject: [PATCH 003/150] Fix #14: include DWG in the Open drawing filter (#21) --- apps/cadcraft/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index bdee8ecf..9ec57801 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -66,7 +66,7 @@ fn services() -> Services { Services { pick_open: Some(Box::new(|| { rfd::FileDialog::new() - .add_filter("Drawing (DXF)", &["dxf"]) + .add_filter("Drawings (DWG, DXF)", &["dwg", "dxf"]) .add_filter("All files", &["*"]) .pick_file() .map(|p| p.to_string_lossy().to_string()) From 986307f7640beea97fe7a6f3f5d995ee2fb0ee8d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:57:25 +0200 Subject: [PATCH 004/150] Print and Ctrl+P ask where to save the PDF (#5) (#26) PLOT/EXPORTPDF started from the menu, the toolbar, Ctrl+P or the command line had no path, so the PDF was built in memory and dropped. They now open the save dialog (PDF filter first) and write the file there. JSON calls from scripts, the control channel and MCP still never open a dialog. Ctrl+P was shown in the menu but not bound; it is now. --- apps/cadcraft/src/main.rs | 12 +++--- crates/ui-egui/src/menus.rs | 84 +++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 9ec57801..e3539844 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -72,11 +72,13 @@ fn services() -> Services { .map(|p| p.to_string_lossy().to_string()) })), pick_save: Some(Box::new(|name: &str| { - rfd::FileDialog::new() - .set_file_name(name) - .add_filter("Drawing (DXF)", &["dxf"]) - .add_filter("SVG", &["svg"]) - .add_filter("PNG", &["png"]) + // The suggested name's type goes first: Windows adds the first filter's extension. + let mut filters = [("Drawing (DXF)", "dxf"), ("SVG", "svg"), ("PNG", "png"), ("PDF", "pdf")]; + let lower = name.to_ascii_lowercase(); + filters.sort_by_key(|(_, ext)| !lower.ends_with(&format!(".{ext}"))); + filters + .iter() + .fold(rfd::FileDialog::new().set_file_name(name), |d, (label, ext)| d.add_filter(*label, &[*ext])) .save_file() .map(|p| p.to_string_lossy().to_string()) })), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 540d633d..27e14122 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -76,6 +76,21 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option return run_ui_command(app, "ui.saveas", &Value::Null), + // Menu, toolbar, Cmd+P or typed: ask where to save the PDF. JSON calls (scripts, control + // channel, MCP) always carry params, never get here and never open a dialog. + "plot" | "print" | "exportpdf" if params.is_null() && app.session.state().is_ok() => { + let pick = app.services.pick_save.as_ref()?; + let title = app.session.state().map(|s| s.title.clone()).unwrap_or_default(); + let stem = std::path::Path::new(&title).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()); + let Some(path) = pick(&format!("{}.pdf", stem.unwrap_or_else(|| "Drawing".into()))) else { return Some(Ok(Value::Null)) }; + let cmd = if id == "exportpdf" { "exportpdf" } else { "plot" }; + let r = app.session.execute(cmd, &json!({ "path": path })).map_err(|e| e.to_string()); + app.session.echo(match &r { + Ok(_) => format!("Plotted to {path}"), + Err(e) => e.clone(), + }); + r + } "ui.sample" => { let d = cadcraft_engine::sample::default_sample(); app.session.open_drawing(d, "Bracket", None); @@ -348,6 +363,7 @@ pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) { (sc(cmd, Key::O), "ui.open"), (sc(cmd_shift, Key::S), "ui.saveas"), (sc(cmd, Key::S), "qsave"), + (sc(cmd, Key::P), "plot"), (sc(cmd, Key::A), "selectall"), (sc(cmd_shift, Key::C), "copybase"), (sc(cmd, Key::C), "copyclip"), @@ -396,3 +412,71 @@ pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) { } } } + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use cadcraft_engine::Session; + use cadcraft_engine::cmd::file::{IoHooks, io, set_io}; + use serde_json::{Value, json}; + + use crate::{CadApp, Services}; + + fn fake_plot(_: &cadcraft_doc::Drawing, _: &cadcraft_doc::Space, _: &Value) -> Result, String> { + Ok(b"%PDF-1.4 test".to_vec()) + } + + /// An app whose save dialog records the suggested name and answers `answer`. + fn app_with_picker(answer: Option) -> (CadApp, Rc>>) { + let asked = Rc::new(RefCell::new(Vec::new())); + let log = Rc::clone(&asked); + let services = Services { + pick_open: None, + pick_save: Some(Box::new(move |name: &str| { + log.borrow_mut().push(name.to_string()); + answer.clone() + })), + }; + (CadApp::new(Session::new(), services), asked) + } + + #[test] + fn print_asks_where_to_save_the_pdf() { + set_io(IoHooks { read: |_, _| Err("no reader in tests".into()), write: |_, _| Err("no writer in tests".into()), plot: Some(fake_plot) }); + if io().and_then(|h| h.plot).is_none() { + return; // another test installed hooks without a plotter first + } + let path = std::env::temp_dir().join(format!("cadcraft-print-test-{}.pdf", std::process::id())); + let ps = path.to_string_lossy().to_string(); + let _ = std::fs::remove_file(&path); + let (mut app, asked) = app_with_picker(Some(ps.clone())); + + // Toolbar, menu and Cmd+P all start the command by name. + app.start("plot"); + assert_eq!(*asked.borrow(), vec!["Drawing1.pdf".to_string()]); + assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF")); + assert!(app.session.log.iter().any(|l| l.contains(&ps))); + let _ = std::fs::remove_file(&path); + + app.cmdline("print"); + app.start("exportpdf"); + assert_eq!(asked.borrow().len(), 3); + assert!(path.exists()); + let _ = std::fs::remove_file(&path); + + // Programmatic calls never open a dialog. + let r = app.run("plot", json!({})).unwrap(); + assert!(r["data"].is_string()); + assert_eq!(asked.borrow().len(), 3); + } + + #[test] + fn cancelled_print_writes_nothing() { + let (mut app, asked) = app_with_picker(None); + app.start("plot"); + assert_eq!(asked.borrow().len(), 1); + assert!(app.session.running.is_none()); + } +} From a1df2f2c8cf98ca74f8a85d06aba0c0a6ad75968 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:57:31 +0200 Subject: [PATCH 005/150] SCALE: Reference option (#17) (#27) SCALE > Reference printed "not available yet". It now asks for the reference length (typed, or two points) and the new length (typed, or a point measured from the base point), and scales by new / reference. Zero, negative or non-finite lengths are rejected and asked again. --- crates/engine/src/cmd/modify.rs | 75 ++++++++++++++++++++++++++++++++- crates/engine/src/tests.rs | 39 +++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 04c42ac4..cfc43207 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1297,6 +1297,10 @@ struct SelectThen { objs: Vec, pts: Vec, reference: bool, + /// SCALE Reference: first point of a reference length given by two points. + ref_from: Option, + /// SCALE Reference: the reference length, once known. + ref_len: Option, copy_mode: bool, window: Option, ask_erase: bool, @@ -1310,6 +1314,8 @@ impl SelectThen { objs: Vec::new(), pts: Vec::new(), reference: false, + ref_from: None, + ref_len: None, copy_mode: false, window: None, ask_erase: false, @@ -1357,6 +1363,54 @@ impl SelectThen { _ => Ok(Step::Continue), } } + + /// SCALE Reference: reference length (typed, or two points), then new length (typed, or a + /// point measured from the base point). The scale factor is new length / reference length. + fn scale_reference(&mut self, s: &mut Session, i: Input) -> Result { + let Some(base) = self.pts.first().copied() else { return Ok(Step::Done) }; + match (self.ref_len, self.ref_from, i) { + (None, None, Input::Point(p)) => { + self.ref_from = Some(p); + Ok(Step::Continue) + } + (None, None, Input::Text(t)) => { + self.ref_len = Some(require_length(number(&t))?); + Ok(Step::Continue) + } + (None, None, Input::Enter) => { + self.ref_len = Some(1.0); + Ok(Step::Continue) + } + (None, Some(a), Input::Point(p)) => { + self.ref_len = Some(require_length(Some(a.dist(p)))?); + Ok(Step::Continue) + } + (Some(r), _, Input::Point(p)) => self.scale_by(s, base, base.dist(p) / r), + (Some(r), _, Input::Text(t)) => { + let n = require_length(number(&t))?; + self.scale_by(s, base, n / r) + } + (_, _, Input::Enter) => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } + + fn scale_by(&mut self, s: &mut Session, base: Vec2, f: f64) -> Result { + let f = require_length(Some(f))?; + transform_entities(s, &self.objs, &Mat3::scale_about(base, f), self.copy_mode)?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } +} + +/// A finite length greater than zero (rejects 0, negatives, NaN and infinity). +fn positive_length(v: Option) -> Option { + v.filter(|f| f.is_finite() && *f > 1e-12) +} + +/// [`positive_length`], or the error that makes the prompt ask again. +fn require_length(v: Option) -> Result { + positive_length(v).ok_or_else(|| EngineError::Other("Requires a positive number.".into())) } impl Interactive for SelectThen { @@ -1406,6 +1460,13 @@ impl Interactive for SelectThen { let pts_from = if self.op == Op::Stretch { 2 } else { 0 }; let k = n.saturating_sub(pts_from); let bp = if self.op == Op::Stretch { self.pts.get(2).copied() } else { base }; + if self.op == Op::Scale && self.reference { + return match (self.ref_len, self.ref_from) { + (Some(_), _) => Prompt::new("Specify new length", Accept::POINT_OR_NUMBER).base_opt(base), + (None, Some(p)) => Prompt::new("Specify second point", Accept::POINT).base(p), + (None, None) => Prompt::new("Specify reference length", Accept::POINT_OR_NUMBER).default("1"), + }; + } match (self.op, k) { (Op::Move | Op::Copy | Op::Stretch, 0) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]), (Op::Move | Op::Stretch, _) => Prompt::new("Specify second point or ", Accept::POINT).base_opt(bp), @@ -1458,12 +1519,19 @@ impl Interactive for SelectThen { } let off = if self.op == Op::Stretch { 2 } else { 0 }; let k = self.pts.len() - off; + if self.op == Op::Scale && self.reference { + return self.scale_reference(s, i); + } match (self.op, k, i) { (_, _, Input::Keyword(kw)) if kw == "Copy" => { self.copy_mode = true; s.echo("Rotating/scaling a copy of the selected objects."); Ok(Step::Continue) } + (Op::Scale, 1, Input::Keyword(kw)) if kw == "Reference" => { + self.reference = true; + Ok(Step::Continue) + } (_, _, Input::Keyword(kw)) if kw == "Reference" => { self.reference = true; s.echo("Reference: not available yet; enter a value."); @@ -1568,7 +1636,12 @@ impl Interactive for SelectThen { let m = match self.op { Op::Move | Op::Copy | Op::Stretch => Mat3::translate(c - base), Op::Rotate => Mat3::rotate_about(base, base.angle_to(c)), - Op::Scale => Mat3::scale_about(base, base.dist(c).max(1e-9)), + Op::Scale if !self.reference => Mat3::scale_about(base, base.dist(c).max(1e-9)), + Op::Scale => match self.ref_len.and_then(|r| positive_length(Some(base.dist(c) / r))) { + Some(f) => Mat3::scale_about(base, f), + None if self.ref_len.is_some() => return Vec::new(), + None => return self.ref_from.map(|a| vec![line(a, c)]).unwrap_or_default(), + }, Op::Mirror if !self.ask_erase => Mat3::mirror(base, c), _ => return Vec::new(), }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 446639b8..3688c757 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -342,6 +342,45 @@ fn copy_rotate_scale_mirror() { assert_eq!(s.doc().unwrap().model.len(), 5); } +#[test] +fn scale_reference_typed_and_picked() { + // Issue #17: SCALE ▸ Reference scales by new length / reference length. + fn end(s: &Session) -> Vec2 { + let e = s.doc().unwrap().model.iter().next().unwrap(); + let EntityKind::Line(l) = &e.kind else { panic!("expected a line") }; + l.b.xy() + } + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [56, 0]]})).unwrap(); + // Typed lengths: 56 → 100. + for t in ["scale", "all", "", "0,0", "r"] { + s.cmdline(t).unwrap(); + } + assert_eq!(s.current_prompt().unwrap().message, "Specify reference length"); + s.cmdline("56").unwrap(); + assert_eq!(s.current_prompt().unwrap().message, "Specify new length"); + s.cmdline("100").unwrap(); + assert!(s.running.is_none()); + assert!(end(&s).near(Vec2::new(100.0, 0.0), 1e-9)); + // Reference from two points, a zero new length is rejected, new length picked from the base point. + for t in ["scale", "all", "", "0,0", "r", "0,0", "100,0"] { + s.cmdline(t).unwrap(); + } + s.cmdline("0").unwrap(); + assert!(s.running.is_some()); + s.cmdline("0,25").unwrap(); + assert!(s.running.is_none()); + assert!(end(&s).near(Vec2::new(25.0, 0.0), 1e-9)); + // A zero reference length is rejected and asked again. + for t in ["scale", "all", "", "0,0", "r", "0"] { + s.cmdline(t).unwrap(); + } + assert_eq!(s.current_prompt().unwrap().message, "Specify reference length"); + s.cmdline("25").unwrap(); + s.cmdline("50").unwrap(); + assert!(end(&s).near(Vec2::new(50.0, 0.0), 1e-9)); +} + #[test] fn layers_and_properties() { let mut s = Session::new(); From 8995fb9174098b11b629e60e491c1ad0dc9586d0 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:57:36 +0200 Subject: [PATCH 006/150] Blocks window: click a block to insert it (#8) (#28) Each block name in the Blocks window is now a button. Clicking it starts INSERT with that block already chosen, so the command line asks for the insertion point. --- crates/ui-egui/src/dialogs.rs | 67 ++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 07405eb1..7b463d68 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -155,6 +155,7 @@ fn commands(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } fn blocks(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { + let mut pick = None; egui::Window::new("Blocks").open(open).default_size(vec2(320.0, 360.0)).show(ctx, |ui| { let Ok(d) = app.session.doc() else { return }; let names: Vec<&String> = d.blocks.keys().filter(|k| !k.starts_with('*')).collect(); @@ -162,9 +163,26 @@ fn blocks(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { ui.label("No blocks defined in this drawing."); } for n in names { - ui.label(n); + if ui.button(n).on_hover_text("Insert this block").clicked() { + pick = Some(n.clone()); + } } }); + if let Some(n) = pick { + insert_block(app, &n); + } +} + +/// Start INSERT with `name` already given at its block name prompt, so the next prompt asks for +/// the insertion point. +fn insert_block(app: &mut CadApp, name: &str) { + app.start("insert"); + if !app.session.running.as_ref().is_some_and(|r| r.id == "insert") { + return; + } + if let Err(e) = app.session.input(cadcraft_engine::Input::Text(name.to_string())) { + app.session.echo(e.to_string()); + } } /// The multiline text editor shown while MTEXT asks for its contents. @@ -227,3 +245,50 @@ fn mtext_editor(app: &mut CadApp, ctx: &egui::Context) { app.session.cancel(); } } + +#[cfg(test)] +mod tests { + use cadcraft_engine::doc::EntityKind; + use cadcraft_engine::geom::Vec2; + use cadcraft_engine::{Input, Session}; + use serde_json::json; + + use crate::{CadApp, Services}; + + #[test] + fn picking_a_block_starts_insert_with_its_name() { + let mut app = CadApp::new(Session::new(), Services::default()); + let c = app.session.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap(); + let h = c["handle"].as_str().unwrap().to_string(); + app.session.execute("block", &json!({"name": "Door 2", "base": [0, 0], "handles": [h], "keep": "delete"})).unwrap(); + // A command already running is replaced, as when a command is typed. + app.start("line"); + + super::insert_block(&mut app, "Door 2"); + assert!(app.session.running.as_ref().is_some_and(|r| r.id == "insert")); + assert_eq!(app.session.current_prompt().map(|p| p.message), Some("Specify insertion point".to_string())); + + app.session.input(Input::Point(Vec2::new(5.0, 6.0))).unwrap(); + app.session.input(Input::Enter).unwrap(); + app.session.input(Input::Enter).unwrap(); + assert!(app.session.running.is_none()); + let d = app.session.doc().unwrap(); + let mut ins = Vec::new(); + for e in d.model.iter() { + if let EntityKind::Insert(i) = &e.kind { + ins.push(i.clone()); + } + } + assert_eq!(ins.len(), 1); + assert_eq!(ins[0].block, "Door 2"); + assert!((ins[0].insert.x - 5.0).abs() < 1e-9 && (ins[0].insert.y - 6.0).abs() < 1e-9); + } + + #[test] + fn picking_a_missing_block_keeps_asking_for_a_name() { + let mut app = CadApp::new(Session::new(), Services::default()); + super::insert_block(&mut app, "Nope"); + assert!(app.session.running.as_ref().is_some_and(|r| r.id == "insert")); + assert_eq!(app.session.current_prompt().map(|p| p.message), Some("Enter block name".to_string())); + } +} From 415da86d391b553e728c9a24ff6fde9622668f73 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:57:42 +0200 Subject: [PATCH 007/150] Scripts: split a line at keyword prompts so POLYGON 6 200,20 I 15 works (#43) (#45) --- crates/engine/src/lib.rs | 3 ++- crates/engine/src/tests.rs | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 71b7c736..4f702319 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -809,7 +809,8 @@ impl Session { } let mut rest = line; loop { - let text_prompt = self.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point); + // Free text only when the prompt has no keywords; otherwise split so `I 15` reaches the keyword and the value. + let text_prompt = self.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point && p.keywords.is_empty()); if text_prompt || self.running.is_none() && rest.contains('{') { self.cmdline(rest)?; break; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3688c757..0abd994a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -460,6 +460,13 @@ fn script_runs_commands() { assert!(matches!(&t.kind, EntityKind::Text(t) if t.value == "Hello world")); } +#[test] +fn script_splits_line_at_keyword_prompt() { + let mut s = Session::new(); + s.script("POLYGON 6 200,20 I 15").unwrap(); + assert_eq!(kinds(&s), vec!["Polyline"]); +} + #[test] fn enter_repeats_last_command() { let mut s = Session::new(); From 5cbc4f078e9a1c928f5f9d408d548a21f45f2975 Mon Sep 17 00:00:00 2001 From: LloydNicholson <34131158+LloydNicholson@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:57:48 +0200 Subject: [PATCH 008/150] Layer Properties Manager: stop the window growing every frame (fixes #29) (#46) The vertical separator between the filter tree and the table takes all the height it is offered, and a resizable window offers its previous height, so the window grew 30px per frame until it filled the screen. Cap the row at the table's fixed height. Regression test runs the dialog headless for 30 frames and checks the height stays put. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/layers.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index a6bdcc40..f30e1b8a 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -309,6 +309,9 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { // A fixed-height table: the window keeps its size instead of growing to the screen. let avail = 330.0; ui.horizontal_top(|ui| { + // Cap the row: the vertical separator below takes all the height it is offered, and a + // resizable window offers its whole previous height, so the window grew every frame (#29). + ui.set_max_height(avail); // ----- filter tree ----- ui.allocate_ui_with_layout(vec2(150.0, avail), egui::Layout::top_down(egui::Align::Min), |ui| { ui.set_min_width(150.0); @@ -693,4 +696,27 @@ mod tests { assert!(crate_wild("?alls", "walls")); assert!(!crate_wild("x*", "walls")); } + + /// Issue #29: just opening the dialog must not grow its window frame after frame. + #[test] + fn dialog_keeps_its_height() { + let mut session = cadcraft_engine::Session::new(); + session.open_drawing(cadcraft_engine::sample::default_sample(), "Bracket", None); + let mut app = CadApp::new(session, crate::Services::default()); + let ctx = egui::Context::default(); + let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 1000.0)); + let mut heights = Vec::new(); + for _ in 0..30 { + let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + let mut open = true; + dialog(&mut app, ui.ctx(), &mut open); + }); + out.textures_delta.clear(); + let id = egui::LayerId::new(egui::Order::Middle, egui::Id::new("lpm_window")); + heights.push(ctx.memory(|m| m.area_rect(id.id)).map_or(0.0, |r| r.height())); + } + let (first, last) = (heights[5], heights[29]); + assert!(first > 0.0 && first < 700.0 && (last - first).abs() < 0.5, "window height drifted: {heights:?}"); + } } From 3886add96782fa00100e58d1a445b0e5aedeb8ac Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:57:53 +0200 Subject: [PATCH 009/150] ui.render: fit the drawing and return pngBase64 like the headless render (#49, #50) (#52) --- crates/ui-egui/src/control.rs | 61 ++++++++++++++++++++++++++++++----- docs/control-protocol.md | 2 +- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 5432e095..0652944b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -14,8 +14,8 @@ //! - `ui.click {x, y}`, `ui.move {x, y}`: real egui pointer input in screen points //! - `ui.key {key, cmd?, shift?, alt?}`, `ui.text {text}`: synthetic keyboard input //! - `ui.set {...UiState fields}`, `ui.resize {width, height}` -//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path, width?, height?}`: headless -//! render of the drawing (no window needed) +//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path?, width?, height?, fit?}`: headless +//! render of the drawing (no window needed; fits the drawing unless `fit` is false; replies `pngBase64`) //! - `app.open {path}`, `app.save {path?}`, `app.quit` use std::sync::mpsc::Sender; @@ -279,18 +279,19 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou let Ok(st) = app.session.state() else { return err("no drawing") }; let list = cadcraft_render::build(&st.doc, &st.space, &cadcraft_render::Options::default()); let v = st.view(); - let view = cadcraft_render::raster::View { center: v.center, scale: f64::from(h) / v.height.max(1e-12), width: w, height: h }; + let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(true); + let view = render_view(&list.bounds, v.center, v.height, fit, w, h); let Some(png) = cadcraft_render::raster::render_png(&list, &view, &cadcraft_render::raster::RasterOptions::default()) else { return err("render failed"); }; #[cfg(not(target_arch = "wasm32"))] if let Some(path) = s("path") { - return match std::fs::write(path, &png) { - Ok(()) => ok(json!({"path": path, "bytes": png.len()})), - Err(e) => err(e), - }; + if let Err(e) = std::fs::write(path, &png) { + return err(e); + } + return ok(render_reply(&png, w, h, Some(path))); } - ok(json!({"bytes": png.len()})) + ok(render_reply(&png, w, h, None)) } "app.open" => { let Some(path) = s("path") else { return err("missing path") }; @@ -306,6 +307,24 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou } } +/// The raster view for `ui.render`: fitted to the drawing's bounds, or the current screen view. +fn render_view(bounds: &cadcraft_geom::Bounds2, center: Vec2, height: f64, fit: bool, w: u32, h: u32) -> cadcraft_render::raster::View { + if fit { + cadcraft_render::raster::View::fit(bounds, w, h, 0.05) + } else { + cadcraft_render::raster::View { center, scale: f64::from(h) / height.max(1e-12), width: w, height: h } + } +} + +/// The `ui.render` reply, matching the headless backend (`pngBase64`, `width`, `height`), plus `bytes` and `path`. +fn render_reply(png: &[u8], w: u32, h: u32, path: Option<&str>) -> Value { + let mut v = json!({"pngBase64": cadcraft_engine::cmd::file::base64_encode(png), "width": w, "height": h, "bytes": png.len()}); + if let (Some(path), Some(o)) = (path, v.as_object_mut()) { + o.insert("path".to_string(), json!(path)); + } + v +} + /// Save a screenshot PNG. pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value { let [w, h] = image.size; @@ -320,3 +339,29 @@ pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value { Err(e) => json!({"ok": false, "error": e.to_string()}), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_view_fit_finds_offscreen_drawing() { + let far = cadcraft_geom::Bounds2::new(Vec2::new(9000.0, 9000.0), Vec2::new(9100.0, 9050.0)); + let fitted = render_view(&far, Vec2::ZERO, 100.0, true, 800, 500); + assert_eq!(fitted.center, far.center()); + let kept = render_view(&far, Vec2::ZERO, 100.0, false, 800, 500); + assert_eq!(kept.center, Vec2::ZERO); + assert!((kept.scale - 5.0).abs() < 1e-9); + } + + #[test] + fn render_reply_has_base64_width_height_and_keeps_old_keys() { + let r = render_reply(b"abc", 7, 5, Some("x.png")); + assert_eq!(r["pngBase64"], "YWJj"); + assert_eq!(r["width"], 7); + assert_eq!(r["height"], 5); + assert_eq!(r["bytes"], 3); + assert_eq!(r["path"], "x.png"); + assert!(render_reply(b"abc", 7, 5, None).get("path").is_none()); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2353ab48..6acc01f2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -28,7 +28,7 @@ Errors come back as `{"id": …, "ok": false, "error": "message"}`. | `ui.set` | UiState fields | Show/hide palettes, toolbars, command line, ViewCube…; `theme: "system"\|"light"\|"dark"` (also `engine.execute` with `ui.theme {theme}`); `ui.inspect` reports the theme shown (`theme`: light/dark). | | `ui.resize` | `{width, height}` | Resize the window. | | `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). | -| `ui.render` | `{path?, width?, height?}` | Headless render of the current view of the drawing. | +| `ui.render` | `{path?, width?, height?, fit?}` | Headless render. `fit` (default true) frames the whole drawing; `false` uses the current view. Replies `{pngBase64, width, height, bytes, path?}`. | | `app.open` / `app.save` / `app.quit` | `{path}` | File operations. | The MCP server (`cadcraft-cli mcp --connect 127.0.0.1:PORT`) wraps this protocol; headless From 3e7e0bdf2aadec9ce94c28bc96a087eb908c3e96 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 10 Oct 2026 12:58:26 -0400 Subject: [PATCH 010/150] Linux: default to XWayland for file drag-and-drop on Wayland desktops (#159) * DIMANGULAR: accept polyline segments (fixes #10) Picking a side of a rectangle or a PLINE segment was rejected with "Select a line or an arc." because only LINE and ARC entities were accepted. The picked object is now resolved to the segment under the cursor: straight polyline segments work like lines and arc segments like arcs. * Redraw edits made while a command is still running (fixes #4) LINE and PLINE add geometry on every point, but the canvas only rebuilt its display list when the drawing revision or the document pointer changed. The revision was bumped when a command finished, and the pointer changed only on the first copy-on-write edit, so only the first segment appeared until the command ended. Session::feed now bumps the revision after each input once the running command has changed the drawing. * Fix #14: include DWG in the Open drawing filter * Blocks window: click a block to insert it (#8) Each block name in the Blocks window is now a button. Clicking it starts INSERT with that block already chosen, so the command line asks for the insertion point. * SCALE: Reference option (#17) SCALE > Reference printed "not available yet". It now asks for the reference length (typed, or two points) and the new length (typed, or a point measured from the base point), and scales by new / reference. Zero, negative or non-finite lengths are rejected and asked again. * Print and Ctrl+P ask where to save the PDF (#5) PLOT/EXPORTPDF started from the menu, the toolbar, Ctrl+P or the command line had no path, so the PDF was built in memory and dropped. They now open the save dialog (PDF filter first) and write the file there. JSON calls from scripts, the control channel and MCP still never open a dialog. Ctrl+P was shown in the menu but not bound; it is now. * Scripts: split a line at keyword prompts so POLYGON 6 200,20 I 15 works (#43) * Layer Properties Manager: stop the window growing every frame (fixes #29) The vertical separator between the filter tree and the table takes all the height it is offered, and a resizable window offers its previous height, so the window grew 30px per frame until it filled the screen. Cap the row at the table's fixed height. Regression test runs the dialog headless for 30 frames and checks the height stays put. Co-Authored-By: Claude Opus 5.5 * ui.render: fit the drawing and return pngBase64 like the headless render (#49, #50) * Linux: default to XWayland for file drag-and-drop on Wayland desktops * Fonts: per-character fallback, FONTALT and .ttc for CJK text in SHX styles (#130) CJK text in a style that uses an SHX font (romans.shx + chineset.shx big font) drew every ideograph as an empty box: the SHX name resolves to our stroke font, which has no CJK glyphs, and the font scan skipped .ttc collections, where most CJK system fonts live. - Per-character fallback: a character the text's font lacks (stroke font or TrueType) is drawn from an installed font that has it, checked by cmap. Wide-coverage fonts go first (Noto Sans CJK, Source Han, WenQuanYi, Droid Sans Fallback, PingFang, Microsoft JhengHei/YaHei, MS Gothic, ...), regular weights before others. Fallback glyphs are sized like TrueType text (cap height = text height). Lookups are cached; the search is bounded (24 files, 160 MiB, 256 faces per character, files over 96 MiB never read) and skips spaces, controls and private-use code points. No fallback: the box as before. - FONTALT (sysvar, seeded from CADCRAFT_FONTALT): font used for a style font that can't be found (an SHX file, a missing TTF). Empty = our stroke font. - FONTFALLBACK (sysvar, seeded from CADCRAFT_FONTFALLBACK): fonts tried first for missing characters, e.g. "Noto Sans CJK TC". - .ttc/.otc are scanned; TextFont::Outline carries a ttf::Face (bytes + face index). Fonts are also found by family name among files with a similar stem, so "Noto Sans CJK TC" picks face 3 of NotoSansCJK-Regular.ttc. - Cap height falls back to the height of "H" when OS/2 has none (DejaVu Sans), which fixes ttf_tests::system_font_outlines_when_available and render's truetype_text_is_filled_or_outlined on machines without Arial. - README: a Fonts section for the two settings; ROADMAP M4 note. Native SHX / big-font rendering stays on the roadmap (M4). Co-Authored-By: Claude Opus 5.5 * Fonts: cap the total bytes of system font files loaded from disk A drawing naming many text styles could otherwise load every installed font into memory through family-name lookups. Co-Authored-By: Claude Opus 5.5 * Pan with a two-finger trackpad swipe; keep wheel and pinch zoom Trackpad two-finger swipes arrive as scroll events, and the canvas turned every scroll into a zoom, so trackpad users had no realtime pan (middle-drag needs a middle button). Scroll now follows the device: precise point deltas (trackpad, Magic Mouse) pan the view by x and y; notched line deltas (mouse wheel) zoom about the cursor as before. Pinch and Cmd+scroll still zoom through zoom_delta, and middle-drag still pans. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019uMCkRAB8DMd8MEmoY9Vyr * Add Tru10 to contributors/people.toml Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019uMCkRAB8DMd8MEmoY9Vyr * Trackpad pan: only on macOS, where point deltas mean a trackpad Browsers and Wayland smooth-scroll mice report ordinary wheels in points too, so the wheel keeps zooming there. Co-Authored-By: Claude Opus 5.5 * Keep arc-length dimensions as arc-length through DXF save and reopen (#100) * PLINE: Halfwidth is centre-to-edge, and Width answers taper the next segment (fixes #83, #104) Interactive PLINE kept one width value: Halfwidth answers were stored as the total width, and the ending-width answer overwrote the starting one, so a taper became a constant width. The next segment's starting and ending widths are now tracked separately, Halfwidth answers are doubled, and each new segment (including the closing one) gets its widths on its first vertex; later segments are uniform at the ending width, as in AutoCAD. Uniform polylines are still stored as const_width, so their DXF output is unchanged. Fixes #83. Fixes #104. * Arc-length dimensions: write a standard aligned record plus CADCraft xdata DXF has no arc-length DIMENSION type, so write type 1 and mark the kind and arc centre in CADCRAFT xdata (like ASSOC). Other programs read an aligned dimension; CADCraft restores the arc length, through DXF and DWG. Co-Authored-By: Claude Opus 5.5 * Desktop: write log records to standard error and a rotating log file The desktop app installed no logger, so every log::warn!/log::error!/ log::info! in the workspace was discarded: a broken app icon, the GPU canvas set-up, the engine's command-line echo at debug level. A launch from a desktop menu has no terminal either, so there was nothing to attach to a bug report. apps/cadcraft/src/logging.rs (no new dependency): - standard error plus /logs/cadcraft.log; each launch rotates the previous log to cadcraft.1.log and .2.log, so the log of a run that crashed survives the next start; the file is capped at 16 MiB - records logged before the settings directory is known are buffered (bounded) and written once the file is attached - default levels: info for cadcraft*, warn for everything else; RUST_LOG replaces them with env_logger-style directives - never panics: an unusable directory or a failed write leaves standard error as the only sink; a poisoned lock is tolerated CADCraft had no per-user directory yet. config_dir in main.rs adds one: CADCRAFT_CONFIG_DIR, else $XDG_CONFIG_HOME/cadcraft or ~/.config/cadcraft (Linux, BSDs), ~/Library/Application Support/CADCraft (macOS), %APPDATA%\CADCraft (Windows). The logger is installed first in main and the file is opened after argument parsing (--version leaves no file). The control server's eprintln!s now go through the logger. A --control or CADCRAFT_CONTROL_PORT value that is not a port is now logged instead of silently leaving the control channel off. Documented in README.md (Quick start > Logs). --------- Co-authored-by: SBaksa Co-authored-by: DWAA1660 Co-authored-by: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Co-authored-by: lloyd <34131158+LloydNicholson@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: vangel76 Co-authored-by: Brandon Thomas Co-authored-by: Tru10 <133776756+Tru10@users.noreply.github.com> Co-authored-by: Gert Pellin --- Cargo.lock | 1 + README.md | 35 +- ROADMAP.md | 2 +- apps/cadcraft/Cargo.toml | 3 + apps/cadcraft/src/control_server.rs | 4 +- apps/cadcraft/src/logging.rs | 569 ++++++++++++++++++++++++++++ apps/cadcraft/src/main.rs | 123 +++++- contributors/people.toml | 4 + crates/engine/src/cmd/draw.rs | 52 ++- crates/engine/src/sysvars.rs | 7 + crates/engine/src/tests.rs | 61 +++ crates/fonts/src/lib.rs | 181 +++++++-- crates/fonts/src/mtext.rs | 4 +- crates/fonts/src/ttf.rs | 556 ++++++++++++++++++++++----- crates/io/src/dxf_ext.rs | 14 + crates/io/src/dxf_read.rs | 5 + crates/io/src/dxf_write.rs | 10 +- crates/io/src/tests.rs | 28 ++ crates/ui-egui/src/canvas.rs | 45 ++- 19 files changed, 1556 insertions(+), 148 deletions(-) create mode 100644 apps/cadcraft/src/logging.rs diff --git a/Cargo.lock b/Cargo.lock index f572aa69..4c95b3de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -551,6 +551,7 @@ dependencies = [ "muda", "rfd", "serde_json", + "winit", "winresource", ] diff --git a/README.md b/README.md index 44cb0f93..4e8c8a4b 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ numbers): | Modify | ERASE, MOVE, COPY, ROTATE, SCALE, MIRROR, STRETCH, OFFSET, TRIM, EXTEND, FILLET, CHAMFER, BREAK, JOIN, EXPLODE, rectangular/polar ARRAY, draw order, OVERKILL | | Precision | Object snaps (endpoint, midpoint, center, geometric center, node, quadrant, intersection, insertion, perpendicular, tangent, nearest; deferred tangent/perpendicular for the first point of a line, e.g. belt lines tangent to two circles), polar tracking, ortho, grid snap | | Layers & properties | Layers palette and Layer Properties Manager (on/off, freeze, lock, plot, colour, linetype), layer tools (isolate, freeze, off, lock, match, previous), Properties palette with per-object editing, linetypes, lineweights, colour index and true colour | -| Annotation | All DIM* commands with full DIMSTYLE variables, overrides and every arrowhead, associative dimensions that follow geometry, MLEADER, TABLE, TrueType fonts, MTEXT formatting codes (fonts, heights, colours, stacked fractions), our own single-stroke drafting font | +| Annotation | All DIM* commands with full DIMSTYLE variables, overrides and every arrowhead, associative dimensions that follow geometry, MLEADER, TABLE, TrueType fonts (`.ttf`, `.otf`, `.ttc` collections), MTEXT formatting codes (fonts, heights, colours, stacked fractions), our own single-stroke drafting font | | Hatch & blocks | Pick-point hatch boundaries with islands, pattern, solid and gradient fills from our own pattern library, BLOCK/INSERT, attributes and nested blocks | | Files | DXF read/write (R12–2018, including dimension styles, associativity, tables and constraints), DWG read/write (R13–2018, via the acadrust library), PDF plotting, SVG and PNG export | | Layouts & plotting | Paper-space layouts, viewports (scale, lock, per-viewport layer freeze), MSPACE/PSPACE through viewports, page setups, PLOT and EXPORTPDF | @@ -132,6 +132,39 @@ zoom e zoom to extents The web build: `cd apps/cadcraft-web && trunk serve` (needs [trunk](https://trunkrs.dev)). +### Fonts + +CADCraft never ships fonts; it uses the TrueType/OpenType fonts installed on your system. SHX +fonts (including big fonts such as `chineset.shx`) aren't read yet: text in an SHX style is drawn +with our own stroke font, and any character a text's font lacks (CJK, for example) is drawn from +an installed font that has it, preferring wide-coverage fonts such as Noto Sans CJK, Source Han +Sans, WenQuanYi, PingFang or Microsoft JhengHei. Two settings, set with `SETVAR` or (for every +run) an environment variable, steer this: + +| Variable | Environment | Meaning | +|---|---|---| +| `FONTALT` | `CADCRAFT_FONTALT` | Font used instead of a text style font that can't be found (an SHX file, a missing TTF), by file or family name, e.g. `Noto Sans CJK TC`. Empty (default) = our stroke font. | +| `FONTFALLBACK` | `CADCRAFT_FONTFALLBACK` | Fonts tried first for characters a text's font lacks, comma-separated, e.g. `Noto Sans CJK TC, msjh.ttc`. | + +`.` clears either one. + +### Logs + +The desktop app writes its `log` records to standard error and to `logs/cadcraft.log` in the +settings directory (Linux and the BSDs `~/.config/cadcraft/logs/`, or +`$XDG_CONFIG_HOME/cadcraft/logs/`; macOS `~/Library/Application Support/CADCraft/logs/`; Windows +`%APPDATA%\CADCraft\logs\`; or under `CADCRAFT_CONFIG_DIR`). A start from a desktop menu or the +Dock has no terminal, so this file is what to attach to a bug report. Each launch moves the +previous log to `cadcraft.1.log` (and that one to `cadcraft.2.log`), so the log of a run that +crashed survives the next start. The file stops growing at 16 MiB. `--version` writes no file. + +| Variable | Effect | +|---|---| +| `CADCRAFT_CONFIG_DIR` | Use this directory instead of the platform settings directory (the log goes to its `logs/`) | +| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for CADCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,cadcraft_io=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`cadcraft*=debug`). `cadcraft_engine=debug` also logs every command-line message. | + +The logger is `apps/cadcraft/src/logging.rs`; the web build logs to the browser console instead. + ## Drive it from agents, MCP and the CLI ```sh diff --git a/ROADMAP.md b/ROADMAP.md index f975cdd6..b84f3bca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -64,7 +64,7 @@ edge cases, dialogs), which is why it is lower. | M1 | Drafting core: every Draw-menu 2D command and option, dynamic input fields, object snap tracking, temporary snap overrides | ~85% (all 2D Draw items exist) | 8 | | M2 | Modify: grips (stretch/move/rotate/scale/mirror, multifunctional), PEDIT, SPLINEDIT, LENGTHEN, BLEND, ALIGN, associative arrays, trim/extend for all curve types, MATCHPROP UI, Quick Properties | ~65% (PEDIT, LENGTHEN, ALIGN, BLEND, grips API, TRIM on splines) | 18 | | M3 | Layers & properties: full Layer Properties Manager (filters, VP overrides, states), linetype manager, lineweight display, transparency, QSELECT dialog, Properties for every object type | ~75% (Layer Properties Manager with VP overrides/states/filters, QSELECT, Quick Properties) | 10 | -| M4 | Annotation: DIM* commands, DIMSTYLE manager, associative dimensions, MLEADER + styles, in-place MTEXT editor, fields, tables + table styles, annotative scaling, TrueType fonts, SHX reader | ~50% (TrueType text, MTEXT formatting codes, full DIMSTYLE variables + overrides, all arrowheads, associative dimensions, TABLE editing; DXF round trip; not yet: native MULTILEADER objects, jogged/ordinate polish, tolerance frames, fields, annotative scaling, SHX) | 30 | +| M4 | Annotation: DIM* commands, DIMSTYLE manager, associative dimensions, MLEADER + styles, in-place MTEXT editor, fields, tables + table styles, annotative scaling, TrueType fonts, SHX reader | ~50% (TrueType text incl. `.ttc`, per-character font fallback and FONTALT for unread SHX fonts, MTEXT formatting codes, full DIMSTYLE variables + overrides, all arrowheads, associative dimensions, TABLE editing; DXF round trip; not yet: native MULTILEADER objects, jogged/ordinate polish, tolerance frames, fields, annotative scaling, SHX) | 30 | | M5 | Hatch & blocks: pick-point boundary detection, islands, gradients, BLOCK/WBLOCK/INSERT dialogs, attributes (ATTDEF/ATTEDIT/BATTMAN), block editor, dynamic block parameters, xrefs, groups, Blocks palette | ~35% (pick-point boundaries with islands, gradients, BLOCK/INSERT, attributes, nested blocks) | 70 | | M6 | Layouts & plotting: paper space, viewports (rect/polygonal/object, scale, lock, per-VP layers), page setups, PLOT to PDF/PNG/SVG, plot styles (CTB/STB), PUBLISH | ~55% (layouts, viewports, MSPACE/PSPACE through viewports, page setup, PLOT/EXPORTPDF) | 25 | | M7 | Files: DWG read/write (done via acadrust), DXF fidelity (all object types, round-trip of unknown data), RECOVER/AUDIT, PURGE, templates, autosave, ETRANSMIT, PDF/raster underlays | ~55% (DXF round trip of dimension styles, associativity, tables and constraints) | 55 | diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml index b0803c69..a59177f4 100644 --- a/apps/cadcraft/Cargo.toml +++ b/apps/cadcraft/Cargo.toml @@ -22,6 +22,9 @@ log = { workspace = true } [build-dependencies] winresource = "0.1" +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] +winit = { version = "0.30", default-features = false, features = ["x11"] } + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } diff --git a/apps/cadcraft/src/control_server.rs b/apps/cadcraft/src/control_server.rs index ff519aa6..b83c4914 100644 --- a/apps/cadcraft/src/control_server.rs +++ b/apps/cadcraft/src/control_server.rs @@ -14,11 +14,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { let listener = match TcpListener::bind(("127.0.0.1", port)) { Ok(l) => l, Err(e) => { - eprintln!("cadcraft: control server failed to bind 127.0.0.1:{port}: {e}"); + log::error!("control server failed to bind 127.0.0.1:{port}: {e}"); return rx; } }; - eprintln!("cadcraft: control server listening on 127.0.0.1:{port}"); + log::info!("control server listening on 127.0.0.1:{port}"); std::thread::spawn(move || { for stream in listener.incoming().flatten() { let tx = tx.clone(); diff --git a/apps/cadcraft/src/logging.rs b/apps/cadcraft/src/logging.rs new file mode 100644 index 00000000..6678dec3 --- /dev/null +++ b/apps/cadcraft/src/logging.rs @@ -0,0 +1,569 @@ +//! The desktop app's logger: `log::` records go to standard error and to a log file. +//! +//! Without a logger every `log::warn!`/`log::error!`/`log::info!` in the workspace (a broken app +//! icon, the GPU canvas set-up, the control server) vanished. A launch from a desktop menu has no +//! terminal, so the file is what a bug report can attach: `/logs/cadcraft.log` +//! (`CADCRAFT_CONFIG_DIR`, or the platform settings directory; see `config_dir` in `main.rs`). +//! Each start moves the previous log to `cadcraft.1.log` (and that one to `.2`), so the log of a +//! run that crashed survives the next launch. +//! +//! Levels: `info` for CADCraft's own crates, `warn` for everything else (wgpu and naga are +//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`, +//! `warn,cadcraft_io=trace`, `wgpu_core=info`. A directive ending in `*` matches every target +//! that starts with it (`cadcraft*=debug`). `RUST_LOG=cadcraft_engine=debug` also logs every +//! command-line message (`Session::echo`). +//! +//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and +//! written once the file is attached. Writing never panics: a file that can't be created or +//! written leaves standard error as the only sink. A record takes the sink lock only for one +//! `write_all` of an already formatted line (standard error is written outside the lock). + +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock, PoisonError}; +use std::time::SystemTime; + +use log::LevelFilter; + +/// The current log file's name inside the log directory. +pub const LOG_FILE: &str = "cadcraft.log"; +/// The log directory inside the settings directory. +pub const LOG_DIR: &str = "logs"; +/// How many previous logs are kept (`cadcraft.1.log` … `cadcraft..log`). +pub const KEEP: usize = 2; +/// The log file stops growing past this size (a runaway warning can't fill the disk). +pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; +/// Records kept in memory until the log file is attached. +pub const MAX_PENDING: usize = 512; +/// The built-in filter when `RUST_LOG` is unset or empty. +pub const DEFAULT_FILTER: &str = "warn,cadcraft*=info"; + +/// Per-target level filter parsed from env_logger-style directives. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Filter { + default: LevelFilter, + directives: Vec<(String, LevelFilter)>, +} + +impl Filter { + /// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no + /// directive names log errors only. + pub fn parse(spec: &str) -> Filter { + let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() }; + for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) { + match part.split_once('=') { + Some((name, level)) => { + let name = name.trim(); + if let (false, Some(level)) = (name.is_empty(), parse_level(level)) { + f.directives.push((name.to_owned(), level)); + } + } + None => match parse_level(part) { + Some(level) => f.default = level, + // A bare target name: everything from it (env_logger does the same). + None => f.directives.push((part.to_owned(), LevelFilter::Trace)), + }, + } + } + f + } + + /// The level that applies to `target` (the most specific matching directive wins). + pub fn level_for(&self, target: &str) -> LevelFilter { + self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level) + } + + /// The most verbose level any target can reach (for `log::set_max_level`). + pub fn max(&self) -> LevelFilter { + self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max) + } +} + +fn parse_level(s: &str) -> Option { + s.trim().parse().ok() +} + +/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`); +/// `name*` matches every target starting with `name`. +fn matches(name: &str, target: &str) -> bool { + match name.strip_suffix('*') { + Some(prefix) => target.starts_with(prefix), + None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")), + } +} + +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +pub fn timestamp(t: SystemTime) -> String { + let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); + let secs = since.as_secs(); + let (days, rem) = (secs / 86_400, secs % 86_400); + let (y, m, d) = civil_from_days(days); + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) +} + +/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's +/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd +/// clock can't overflow). +fn civil_from_days(days: u64) -> (u64, u64, u64) { + let z = days.saturating_add(719_468); + let era = z / 146_097; + let doe = z % 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); + (y, m, d) +} + +fn numbered(dir: &Path, n: usize) -> PathBuf { + if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("cadcraft.{n}.log")) } +} + +/// Shift the previous logs up one (`.log` → `.1.log` → … → `..log`, the oldest dropped) +/// and create a fresh, empty log file in `dir` (created if missing). +pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { + std::fs::create_dir_all(dir)?; + for n in (1..=KEEP).rev() { + match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e), + _ => {} + } + } + let path = numbered(dir, 0); + let file = File::create(&path)?; + Ok((path, file)) +} + +/// One formatted log line: ` [] : \n`. +pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String { + let message = message.strip_suffix('\n').unwrap_or(message); + format!("{ts} {level:<5} [{thread}] {target}: {message}\n") +} + +/// Where formatted lines go besides standard error: kept in memory until a file is attached, +/// then appended to it until it reaches its size cap. +pub struct Sink { + file: Option, + pending: Vec, + dropped: usize, + written: u64, + max_bytes: u64, + capped: bool, +} + +impl Sink { + pub fn new(max_bytes: u64) -> Sink { + Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false } + } + + /// Append `line` to the file, or keep it until [`Sink::attach`]. + pub fn write(&mut self, line: &str) { + if self.file.is_none() { + if self.capped { + return; + } + if self.pending.len() < MAX_PENDING { + self.pending.push(line.to_owned()); + } else { + self.dropped = self.dropped.saturating_add(1); + } + return; + } + self.append(line); + } + + /// Start writing to `file`, first the lines kept so far. + pub fn attach(&mut self, file: File) { + self.file = Some(file); + for line in std::mem::take(&mut self.pending) { + self.append(&line); + } + if self.dropped > 0 { + let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped); + self.dropped = 0; + self.append(¬e); + } + } + + /// No file will come: drop the kept lines and keep none from now on. + pub fn close(&mut self) { + self.file = None; + self.pending = Vec::new(); + self.capped = true; + } + + fn append(&mut self, line: &str) { + if self.capped { + return; + } + let Some(file) = self.file.as_mut() else { return }; + let len = u64::try_from(line.len()).unwrap_or(u64::MAX); + let text = if self.written.saturating_add(len) > self.max_bytes { + self.capped = true; + format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes) + } else { + self.written = self.written.saturating_add(len); + line.to_owned() + }; + // A full disk or a vanished file must not take the app down: drop the file sink (and + // stop buffering, since no file will come). + if file.write_all(text.as_bytes()).is_err() { + self.file = None; + self.capped = true; + } + } +} + +/// The installed logger: a [`Filter`], standard error and a [`Sink`]. +pub struct AppLogger { + filter: Filter, + stderr: bool, + sink: Mutex, +} + +impl AppLogger { + pub fn new(filter: Filter, stderr: bool) -> AppLogger { + AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) } + } + + /// Rotate the logs in `dir` and send records to the fresh file; returns its path. + /// An unusable directory leaves standard error as the only sink ([`AppLogger::stderr_only`]). + pub fn attach_dir(&self, dir: &Path) -> Result { + match rotate(dir) { + Ok((path, file)) => { + self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file); + Ok(path) + } + Err(e) => { + self.stderr_only(); + Err(format!("{}: {e}", dir.display())) + } + } + } + + /// No log file this run (no settings directory, or it can't be used): stop keeping records for one. + pub fn stderr_only(&self) { + self.sink.lock().unwrap_or_else(PoisonError::into_inner).close(); + } +} + +impl log::Log for AppLogger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + metadata.level() <= self.filter.level_for(metadata.target()) + } + + fn log(&self, record: &log::Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + // Format before taking the lock, so the lock covers one write only. + let thread = std::thread::current(); + let line = + format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string()); + if self.stderr { + let _ = std::io::stderr().write_all(line.as_bytes()); + } + self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&line); + } + + fn flush(&self) {} +} + +/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another +/// logger was installed first. Call [`AppLogger::attach_dir`] once the settings directory is known. +pub fn install() -> Option<&'static AppLogger> { + static LOGGER: OnceLock = OnceLock::new(); + let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty()); + let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER)); + let max = filter.max(); + let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true)); + log::set_logger(logger).ok()?; + log::set_max_level(max); + Some(logger) +} + +#[cfg(test)] +mod tests { + use super::*; + use log::Log; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, UNIX_EPOCH}; + + fn temp_dir(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("cadcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + d + } + + fn read(path: &Path) -> String { + std::fs::read_to_string(path).expect("read log") + } + + fn record(logger: &AppLogger, level: log::Level, target: &'static str, msg: &'static str) { + logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); + } + + #[test] + fn the_default_filter_shows_cadcraft_info_and_other_crates_warnings() { + let f = Filter::parse(DEFAULT_FILTER); + assert_eq!(f.level_for("cadcraft"), LevelFilter::Info); + assert_eq!(f.level_for("cadcraft::control_server"), LevelFilter::Info); + assert_eq!(f.level_for("cadcraft_ui_egui::gpu"), LevelFilter::Info); + assert_eq!(f.level_for("cadcraft_engine"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); + assert_eq!(f.level_for("naga"), LevelFilter::Warn); + assert_eq!(f.max(), LevelFilter::Info); + } + + #[test] + fn directives_follow_env_logger_and_the_most_specific_one_wins() { + let f = Filter::parse("info,wgpu_core=error,cadcraft_io=trace,cadcraft_io::dxf_ext=off"); + assert_eq!(f.level_for("eframe"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); + assert_eq!(f.level_for("cadcraft_io::svg"), LevelFilter::Trace); + assert_eq!(f.level_for("cadcraft_io::dxf_ext"), LevelFilter::Off); + assert_eq!(f.max(), LevelFilter::Trace); + // A module name is matched at `::` boundaries, not as a bare prefix. + assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); + // A trailing `*` is a prefix. + assert_eq!(Filter::parse("warn,cadcraft_ui*=debug").level_for("cadcraft_ui_egui::gpu"), LevelFilter::Debug); + assert_eq!(Filter::parse("warn,cadcraft_ui*=debug").level_for("cadcraft_engine"), LevelFilter::Warn); + // A bare target name sets that target to the most verbose level, as env_logger does. + assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); + assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); + // Levels are case-insensitive; the last bare level wins. + assert_eq!(Filter::parse("debug,WARN").level_for("x"), LevelFilter::Warn); + } + + #[test] + fn hostile_specs_never_panic_and_fall_back_sensibly() { + let long = "a".repeat(100_000); + let specs = [ + "", + ",,,", + "=", + "==", + "=debug", + "cadcraft=", + "nonsense=loud", + "🦀=info", + "*", + "*=debug", + "a=b=c", + "warn,,cadcraft=DEBUG", + " \t ", + "\u{0}", + "é*=trace", + &long, + ]; + for spec in specs { + let f = Filter::parse(spec); + for target in ["cadcraft", "", "🦀", "é", "a::b"] { + let _ = f.level_for(target); + } + let _ = f.max(); + } + assert_eq!(Filter::parse("warn,,cadcraft=DEBUG").level_for("cadcraft"), LevelFilter::Debug); + assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); + assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error, "a directive without a name is skipped"); + assert_eq!(Filter::parse("*=debug").level_for("anything"), LevelFilter::Debug, "a lone `*` matches every target"); + assert_eq!(Filter::parse("é*=trace").level_for("éa"), LevelFilter::Trace); + } + + #[test] + fn timestamps_are_utc_with_milliseconds() { + assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); + // Leap day, and the day after it. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); + // 2000 is a leap year (divisible by 400), 2100 is not. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); + // Before the epoch (a clock set wrong) clamps instead of panicking. + assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + } + + #[test] + fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let dir = temp_dir("rotate"); + for run in 1..=4 { + let (path, _file) = rotate(&dir).expect("rotate"); + assert_eq!(path, dir.join(LOG_FILE)); + assert_eq!(read(&path), ""); + std::fs::write(&path, format!("run {run}")).expect("write"); + } + assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); + assert_eq!(read(&dir.join("cadcraft.1.log")), "run 3"); + assert_eq!(read(&dir.join("cadcraft.2.log")), "run 2"); + assert!(!dir.join("cadcraft.3.log").exists(), "only {KEEP} old logs are kept"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { + let dir = temp_dir("blocked"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "a file where the directory should be").expect("block"); + assert!(rotate(&dir).is_err()); + assert!(rotate(&dir.join("below")).is_err()); + let _ = std::fs::remove_file(&dir); + } + + #[test] + fn a_line_carries_time_level_thread_target_and_message() { + assert_eq!( + format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "cadcraft", "app icon: invalid PNG signature"), + "2026-10-08T07:59:17.728Z WARN [main] cadcraft: app icon: invalid PNG signature\n" + ); + // A multi-line message keeps its lines; the record still ends in exactly one newline. + assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); + } + + #[test] + fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { + let dir = temp_dir("pending"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early 1\n"); + sink.write("early 2\n"); + sink.attach(file); + sink.write("late\n"); + assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { + let dir = temp_dir("bounded"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING + 5 { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); + assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}"); + assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn exactly_max_pending_lines_drop_nothing() { + let dir = temp_dir("exact"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().count(), MAX_PENDING); + assert!(!text.contains("dropped"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_file_stops_at_its_size_cap_with_one_note() { + let dir = temp_dir("cap"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(100); + sink.attach(file); + for i in 0..50 { + sink.write(&format!("line {i:02} with some padding\n")); + } + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); + assert_eq!(text.matches("log file reached 100 bytes").count(), 1, "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_logger_filters_records_and_writes_them_to_the_attached_file() { + let dir = temp_dir("logger"); + let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + record(&logger, log::Level::Info, "cadcraft", "CADCraft starting"); + record(&logger, log::Level::Info, "wgpu_core::device", "too chatty"); + let path = logger.attach_dir(&dir).expect("attach"); + assert_eq!(path, dir.join(LOG_FILE)); + record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); + record(&logger, log::Level::Debug, "cadcraft_engine", "a command-line echo"); + let text = read(&path); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 2, "{text}"); + assert!(lines.first().is_some_and(|l| l.contains(" INFO [") && l.ends_with("cadcraft: CADCraft starting")), "{text}"); + assert!(lines.get(1).is_some_and(|l| l.ends_with("wgpu_hal::vulkan: a real warning")), "{text}"); + assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("cadcraft_ui_egui::gpu").build())); + assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); + // A second launch rotates: the first log becomes `.1`. + let second = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + assert_eq!(second.attach_dir(&dir).expect("attach again"), dir.join(LOG_FILE)); + assert_eq!(read(&dir.join("cadcraft.1.log")), text); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn records_from_many_threads_are_whole_lines() { + let dir = temp_dir("threads"); + let logger = AppLogger::new(Filter::parse("info"), false); + let path = logger.attach_dir(&dir).expect("attach"); + std::thread::scope(|s| { + for _ in 0..4 { + s.spawn(|| { + for _ in 0..100 { + record(&logger, log::Level::Info, "cadcraft", "from a worker"); + } + }); + } + }); + let text = read(&path); + assert_eq!(text.lines().count(), 400); + assert!(text.lines().all(|l| l.ends_with("cadcraft: from a worker")), "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { + let dir = temp_dir("unusable"); + std::fs::write(&dir, "not a directory").expect("block"); + let logger = AppLogger::new(Filter::parse("info"), false); + let e = logger.attach_dir(&dir).expect_err("a file is not a log directory"); + assert!(e.contains(&dir.display().to_string()), "{e}"); + record(&logger, log::Level::Error, "x", "still fine"); + let sink = logger.sink.lock().expect("sink"); + assert!(sink.file.is_none() && sink.pending.is_empty(), "nothing is kept for a file that will not come"); + drop(sink); + let _ = std::fs::remove_file(&dir); + } + + #[test] + fn stderr_only_stops_keeping_records_for_a_file() { + let logger = AppLogger::new(Filter::parse("info"), false); + record(&logger, log::Level::Info, "cadcraft", "before"); + logger.stderr_only(); + record(&logger, log::Level::Info, "cadcraft", "after"); + let sink = logger.sink.lock().expect("sink"); + assert!(sink.file.is_none() && sink.pending.is_empty(), "no memory is held for a file that will not come"); + } + + #[test] + fn a_poisoned_lock_does_not_stop_logging() { + let dir = temp_dir("poison"); + let logger = AppLogger::new(Filter::parse("info"), false); + let path = logger.attach_dir(&dir).expect("attach"); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = logger.sink.lock(); + panic!("poison the sink"); + })); + assert!(logger.sink.is_poisoned()); + record(&logger, log::Level::Info, "cadcraft", "after the panic"); + assert!(read(&path).contains("after the panic")); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index e3539844..7662f972 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -8,12 +8,16 @@ //! //! `CADCRAFT_VSYNC=1` presents with vsync on Linux/BSD, where the default is the low-latency //! present mode (`CADCRAFT_VSYNC=0` selects that elsewhere); see `cadcraft_ui_egui::gpu::surface_config`. +//! +//! `log` records go to standard error and `/logs/cadcraft.log` (`RUST_LOG` sets the +//! levels); see [`logging`] and [`config_dir`]. #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod control_server; #[cfg(any(target_os = "windows", test))] mod graphics; +mod logging; #[cfg(target_os = "macos")] mod native_menu; @@ -85,6 +89,30 @@ fn services() -> Services { } } +/// The per-user settings directory (today it holds `logs/`): `CADCRAFT_CONFIG_DIR` if set, else +/// `$XDG_CONFIG_HOME/cadcraft` or `~/.config/cadcraft` on Linux and the BSDs, +/// `~/Library/Application Support/CADCraft` on macOS, `%APPDATA%\CADCraft` on Windows. +fn config_dir() -> Option { + config_dir_from(|k| std::env::var_os(k)) +} + +/// [`config_dir`] with the environment passed in (`var` looks a variable up). An empty variable +/// counts as unset, so a blank value never means the working directory. +fn config_dir_from(var: impl Fn(&str) -> Option) -> Option { + let set = |k: &str| var(k).filter(|v| !v.is_empty()).map(std::path::PathBuf::from); + if let Some(dir) = set("CADCRAFT_CONFIG_DIR") { + return Some(dir); + } + if cfg!(target_os = "macos") { + set("HOME").map(|h| h.join("Library/Application Support/CADCraft")) + } else if cfg!(windows) { + set("APPDATA").map(|a| a.join("CADCraft")) + } else { + // The XDG spec ignores a relative XDG_CONFIG_HOME. + set("XDG_CONFIG_HOME").filter(|p| p.is_absolute()).or_else(|| set("HOME").map(|h| h.join(".config"))).map(|c| c.join("cadcraft")) + } +} + const APP_ID: &str = "ai.storyteller.cadcraft"; #[cfg(target_os = "macos")] @@ -101,14 +129,29 @@ fn version_string() -> String { format!("cadcraft {} ({sha})", env!("CARGO_PKG_VERSION")) } +/// The control port from `--control ` or `CADCRAFT_CONTROL_PORT` (`source`); a value that is +/// not a port is logged and ignored instead of silently starting no control channel. +fn control_port_from(source: &str, value: Option) -> Option { + let value = value?; + let port = value.trim().parse().ok(); + if port.is_none() { + log::warn!("{source}: {value:?} is not a port number; the control channel is off"); + } + port +} + fn main() -> eframe::Result { - let mut control_port: Option = std::env::var("CADCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); + // First, so every start-up record is captured; see `logging`. + let logger = logging::install(); + // lossy, so a value that is not Unicode is reported like any other bad port instead of ignored + let mut control_port = + control_port_from("CADCRAFT_CONTROL_PORT", std::env::var_os("CADCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned())); let mut files = Vec::new(); let mut sample = false; let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match a.as_str() { - "--control" => control_port = args.next().and_then(|p| p.parse().ok()), + "--control" => control_port = control_port_from("--control", args.next()), "--sample" => sample = true, "--version" | "-V" => { println!("{}", version_string()); @@ -117,6 +160,18 @@ fn main() -> eframe::Result { _ => files.push(a), } } + // The log file lives under the settings directory; opened after the arguments, so `--version` + // leaves no file behind. Records logged until now are written to it first. + if let Some(logger) = logger { + match config_dir().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) { + Some(Ok(path)) => log::info!("{} ({} {}), log file {}", version_string(), std::env::consts::OS, std::env::consts::ARCH, path.display()), + Some(Err(e)) => log::warn!("no log file: {e}"), + None => { + logger.stderr_only(); + log::warn!("no log file: no settings directory (set CADCRAFT_CONFIG_DIR)"); + } + } + } install_io(); let surface = cadcraft_ui_egui::gpu::surface_config(std::env::var("CADCRAFT_VSYNC").ok().as_deref(), cfg!(all(unix, not(target_os = "macos")))); let mut options = eframe::NativeOptions { @@ -141,6 +196,16 @@ fn main() -> eframe::Result { // Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs). #[cfg(target_os = "windows")] graphics::configure(&mut options, eframe::wgpu::Backends::from_env()); + // winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file + // manager showed a "no" cursor. Run through XWayland when it's there; CADCRAFT_WAYLAND=1 + // keeps the native Wayland backend. + #[cfg(all(unix, not(target_os = "macos")))] + if std::env::var_os("DISPLAY").is_some() && std::env::var_os("CADCRAFT_WAYLAND").is_none() { + options.event_loop_builder = Some(Box::new(|b| { + use winit::platform::x11::EventLoopBuilderExtX11; + b.with_x11(); + })); + } eframe::run_native( "CADCraft", options, @@ -177,3 +242,57 @@ fn main() -> eframe::Result { }), ) } + +#[cfg(test)] +mod tests { + use super::{config_dir_from, control_port_from}; + use std::ffi::OsString; + use std::path::PathBuf; + + fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |k| vars.iter().find(|(n, _)| *n == k).map(|(_, v)| OsString::from(v)) + } + + #[test] + fn control_port_values_that_are_not_ports_are_ignored() { + assert_eq!(control_port_from("--control", Some("7979".into())), Some(7979)); + assert_eq!(control_port_from("--control", Some(" 7979 ".into())), Some(7979)); + assert_eq!(control_port_from("--control", None), None); + for bad in ["", "abc", "-1", "65536", "99999999999999999999", "\u{0}"] { + assert_eq!(control_port_from("--control", Some(bad.into())), None, "{bad:?}"); + } + } + + #[test] + fn the_override_variable_wins_and_empty_values_are_unset() { + let vars = [("CADCRAFT_CONFIG_DIR", "/tmp/cc"), ("HOME", "/home/u"), ("XDG_CONFIG_HOME", "/x"), ("APPDATA", "C:\\a")]; + assert_eq!(config_dir_from(env(&vars)), Some(PathBuf::from("/tmp/cc"))); + let empty = [("CADCRAFT_CONFIG_DIR", ""), ("HOME", ""), ("XDG_CONFIG_HOME", ""), ("APPDATA", "")]; + assert_eq!(config_dir_from(env(&empty)), None, "no usable variable means no directory, not the working directory"); + assert_eq!(config_dir_from(env(&[])), None); + } + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn linux_and_bsd_follow_xdg_config_home() { + assert_eq!(config_dir_from(env(&[("XDG_CONFIG_HOME", "/x"), ("HOME", "/home/u")])), Some(PathBuf::from("/x/cadcraft"))); + assert_eq!(config_dir_from(env(&[("HOME", "/home/u")])), Some(PathBuf::from("/home/u/.config/cadcraft"))); + // The XDG spec says a relative XDG_CONFIG_HOME is ignored. + assert_eq!(config_dir_from(env(&[("XDG_CONFIG_HOME", "rel"), ("HOME", "/home/u")])), Some(PathBuf::from("/home/u/.config/cadcraft"))); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_uses_application_support() { + assert_eq!(config_dir_from(env(&[("HOME", "/Users/u")])), Some(PathBuf::from("/Users/u/Library/Application Support/CADCraft"))); + } + + #[cfg(windows)] + #[test] + fn windows_uses_appdata() { + assert_eq!( + config_dir_from(env(&[("APPDATA", "C:\\Users\\u\\AppData\\Roaming")])), + Some(PathBuf::from("C:\\Users\\u\\AppData\\Roaming").join("CADCraft")) + ); + } +} diff --git a/contributors/people.toml b/contributors/people.toml index 89c6db20..8ef40d37 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -21,3 +21,7 @@ real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false + +[people.Tru10] +display_name = "by Truitt" +sync_github_name = false diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 0bea1cff..a12847df 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -444,19 +444,40 @@ impl Interactive for LineM { struct PlineM { verts: Vec, arc_mode: bool, - width: f64, + /// Total width at the start and end of the next segment (Width / Halfwidth answers). + start_w: f64, + end_w: f64, handle: Option, + /// 1 = asking the starting width, 2 = the ending width. asking_width: u8, + /// The widths are being asked as half-widths (centre line to edge). + half: bool, } impl PlineM { fn entity(&self, closed: bool) -> EntityKind { let mut k = lwpoly(self.verts.clone(), closed); if let EntityKind::LwPolyline(pl) = &mut k { - pl.const_width = self.width; + // Every segment the same uniform width: store it as the constant width, as before. + let segs = if closed { pl.vertices.len() } else { pl.vertices.len().saturating_sub(1) }; + let w = pl.vertices.first().map_or(0.0, |v| v.start_width); + if pl.vertices.iter().take(segs).all(|v| v.start_width == w && v.end_width == w) { + pl.const_width = w; + for v in &mut pl.vertices { + (v.start_width, v.end_width) = (0.0, 0.0); + } + } } k } + /// The next segment, starting at the last vertex, takes the requested widths (#104); the + /// segments after it are uniform at the ending width, as in AutoCAD. + fn begin_segment(&mut self) { + if let Some(last) = self.verts.last_mut() { + (last.start_width, last.end_width) = (self.start_w, self.end_w); + self.start_w = self.end_w; + } + } fn sync(&mut self, s: &mut Session, closed: bool) -> Result<()> { if self.verts.len() < 2 { if let Some(h) = self.handle.take() { @@ -502,8 +523,10 @@ impl Interactive for PlineM { } fn prompt(&self, _s: &Session) -> Prompt { if self.asking_width > 0 { - return Prompt::new(if self.asking_width == 1 { "Specify starting width" } else { "Specify ending width" }, Accept::NUMBER) - .default(format!("{:.4}", self.width)); + let unit = if self.half { "half-width" } else { "width" }; + let (which, w) = if self.asking_width == 1 { ("starting", self.start_w) } else { ("ending", self.end_w) }; + let shown = if self.half { w / 2.0 } else { w }; + return Prompt::new(format!("Specify {which} {unit}"), Accept::NUMBER).default(format!("{shown:.4}")); } let base = self.verts.last().map(|v| v.p); match (self.verts.len(), self.arc_mode) { @@ -524,20 +547,31 @@ impl Interactive for PlineM { if self.asking_width > 0 { match i { Input::Text(t) => { - let w = number(&t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?; - self.width = w.max(0.0); + let w = number(&t).filter(|w| w.is_finite()).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?; + // Halfwidth is centre line to edge: the total width is twice the answer (#83). + let w = w.max(0.0) * if self.half { 2.0 } else { 1.0 }; + if self.asking_width == 1 { + self.start_w = w; + } else { + self.end_w = w; + } } Input::Enter => {} _ => return Ok(Step::Continue), } + if self.asking_width == 1 { + // The ending width defaults to the starting width. + self.end_w = self.start_w; + } self.asking_width = if self.asking_width == 1 { 2 } else { 0 }; return Ok(Step::Continue); } match i { Input::Point(p) => { if self.verts.is_empty() { - s.echo(format!("Current line-width is {:.4}", self.width)); + s.echo(format!("Current line-width is {:.4}", self.start_w)); } + self.begin_segment(); if self.arc_mode && !self.verts.is_empty() { let b = self.tangent_bulge(p); if let Some(last) = self.verts.last_mut() { @@ -566,6 +600,8 @@ impl Interactive for PlineM { last.bulge = b; } } + // The closing segment starts at the last vertex. + self.begin_segment(); self.sync(s, true)?; Ok(Step::Done) } @@ -573,11 +609,13 @@ impl Interactive for PlineM { self.verts.pop(); if let Some(l) = self.verts.last_mut() { l.bulge = 0.0; + (l.start_width, l.end_width) = (0.0, 0.0); } self.sync(s, false)?; Ok(Step::Continue) } "Width" | "Halfwidth" => { + self.half = k == "Halfwidth"; self.asking_width = 1; Ok(Step::Continue) } diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index 5a3994b0..78798415 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -24,6 +24,8 @@ const SESSION_VARS: &[&str] = &[ "GRIPSIZE", "CURSORSIZE", "LASTPOINT", + "FONTALT", + "FONTFALLBACK", ]; pub fn get(s: &Session, name: &str) -> Option { @@ -49,6 +51,9 @@ pub fn get(s: &Session, name: &str) -> Option { "GRIPSIZE" => json!(st.gripsize), "CURSORSIZE" => json!(st.cursorsize), "LASTPOINT" => json!([s.last_point.x, s.last_point.y, 0.0]), + // Process-wide font substitution (profile settings, not saved in the drawing). + "FONTALT" => json!(cadcraft_fonts::ttf::font_alt()), + "FONTFALLBACK" => json!(cadcraft_fonts::ttf::fallback_fonts()), "CMDNAMES" => json!(s.running.as_ref().map(|r| r.id.to_ascii_uppercase()).unwrap_or_default()), "DWGNAME" => json!(s.state().map(|d| d.title.clone()).unwrap_or_default()), "DBMOD" => json!(s.state().map(|d| i32::from(d.is_dirty())).unwrap_or(0)), @@ -116,6 +121,8 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { "PICKADD" => st.pickadd = as_bool(v).ok_or_else(bad)?, "GRIPSIZE" => st.gripsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 255.0), "CURSORSIZE" => st.cursorsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 100.0), + "FONTALT" => cadcraft_fonts::ttf::set_font_alt(v.as_str().ok_or_else(bad)?), + "FONTFALLBACK" => cadcraft_fonts::ttf::set_fallback_fonts(v.as_str().ok_or_else(bad)?), _ => { let d = s.doc_mut()?; let val = match v { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0abd994a..65dd97f7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -449,6 +449,13 @@ fn sysvars_roundtrip() { assert_eq!(s.doc().unwrap().header.f64("LTSCALE", 0.0), 2.5); assert!(s.execute("setvar", &json!({"name": "ORTHOMODE", "value": "x"})).is_err()); assert_eq!(sysvars::get(&s, "orthomode"), Some(json!(0))); + // FONTALT is a process-wide font setting, not a drawing header variable. + s.execute("setvar", &json!({"name": "FONTALT", "value": "no-such-font-130"})).unwrap(); + assert_eq!(sysvars::get(&s, "fontalt"), Some(json!("no-such-font-130"))); + assert!(s.doc().unwrap().header.get("FONTALT").is_none()); + assert!(s.execute("setvar", &json!({"name": "FONTALT", "value": 3})).is_err()); + s.execute("setvar", &json!({"name": "FONTALT", "value": "."})).unwrap(); + assert_eq!(sysvars::get(&s, "FONTALT"), Some(json!(""))); } #[test] @@ -933,3 +940,57 @@ fn arc_refuses_zero_radius() { other => panic!("{other:?}"), } } + +/// The single polyline in a fresh drawing after typing `script`, then pressing Enter. +fn typed_pline(script: &str) -> cadcraft_doc::LwPolyline { + let mut s = Session::new(); + for line in script.split('\n') { + s.cmdline(line).unwrap(); + } + s.cmdline("").unwrap(); + let d = s.doc().unwrap(); + let mut it = d.model.iter(); + let pl = match &it.next().unwrap().kind { + EntityKind::LwPolyline(p) => p.clone(), + other => panic!("{other:?}"), + }; + assert!(it.next().is_none(), "one polyline"); + pl +} + +/// (const_width, [(start_width, end_width) per vertex]). +fn widths(p: &cadcraft_doc::LwPolyline) -> (f64, Vec<(f64, f64)>) { + (p.const_width, p.vertices.iter().map(|v| (v.start_width, v.end_width)).collect()) +} + +#[test] +fn pline_halfwidth_is_centre_to_edge() { + // #83: half-width 0.5 at both prompts is a band 1.0 wide; Width keeps the total width. + assert_eq!(typed_pline("pline 0,0 h 0.5 0.5 10,0").const_width, 1.0); + assert_eq!(typed_pline("pline 0,0 w 0.5 0.5 10,0").const_width, 0.5); + assert_eq!(typed_pline("pline 0,0 w 1 1 10,0").const_width, 1.0); + // The ending half-width defaults to the starting one. + assert_eq!(typed_pline("pline 0,0 h 0.5\n\n10,0").const_width, 1.0); + // A half-width taper. + assert_eq!(widths(&typed_pline("pline 0,0 h 0.5 0.1 10,0")), (0.0, vec![(1.0, 0.2), (0.0, 0.0)])); +} + +#[test] +fn pline_width_answers_taper_the_next_segment() { + // #104: the starting and ending widths are the segment's own, not one constant width. + assert_eq!(widths(&typed_pline("pline 0,0 w 1 0.2 10,0")), (0.0, vec![(1.0, 0.2), (0.0, 0.0)])); + assert_eq!(widths(&typed_pline("pline 0,0 w 0.2 1 10,0")), (0.0, vec![(0.2, 1.0), (0.0, 0.0)])); + // Equal answers, or Enter at the ending prompt, stay one constant width. + assert_eq!(widths(&typed_pline("pline 0,0 w 1 1 10,0")), (1.0, vec![(0.0, 0.0), (0.0, 0.0)])); + assert_eq!(widths(&typed_pline("pline 0,0 w 1\n\n10,0")), (1.0, vec![(0.0, 0.0), (0.0, 0.0)])); + // After a taper, later segments are uniform at the ending width. + let p = typed_pline("pline 0,0 w 1 0.2 10,0 20,0 30,0"); + assert_eq!(widths(&p), (0.0, vec![(1.0, 0.2), (0.2, 0.2), (0.2, 0.2), (0.0, 0.0)])); + // A width set mid-polyline applies from that segment on; the closing segment takes it too. + let p = typed_pline("pline 0,0 10,0 w 2 2 10,10 c"); + assert!(p.closed); + assert_eq!(widths(&p), (0.0, vec![(0.0, 0.0), (2.0, 2.0), (2.0, 2.0)])); + // Undo drops the taper with its segment. + let p = typed_pline("pline 0,0 10,0 w 1 0.2 20,0 u"); + assert_eq!(widths(&p), (0.0, vec![(0.0, 0.0), (0.0, 0.0)])); +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index a8bd1690..aeb32063 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -1,9 +1,10 @@ //! CADCraft text layout. //! //! Turns TEXT and MTEXT into stroke polylines with the built-in single-stroke font -//! ("CADCraft Stroke"). Handles the `%%` control codes, alignment modes, width factor, -//! obliquing, MTEXT inline formatting (paragraphs, stacking, height changes), word wrap and -//! attachment points. +//! ("CADCraft Stroke") or glyph outlines of installed TrueType fonts ([`ttf`]), with a +//! per-character fallback to installed fonts for characters the chosen font lacks. Handles the +//! `%%` control codes, alignment modes, width factor, obliquing, MTEXT inline formatting +//! (paragraphs, stacking, height changes), word wrap and attachment points. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] @@ -11,8 +12,6 @@ mod mtext; mod stroke; pub mod ttf; -use std::sync::Arc; - use cadcraft_geom::{Bounds2, Vec2}; pub use mtext::{MTextColor, MTextLayout, MTextParams, MTextPiece, layout_mtext, layout_mtext_with, plain_mtext}; @@ -78,43 +77,66 @@ pub fn char_advance(c: char) -> f64 { } } -/// Lay out a single line at `height`, with `width_factor` and `oblique` (radians). -pub fn layout_line(s: &str, height: f64, width_factor: f64, oblique: f64) -> Run { +/// Advance of `c` in the stroke font at height `h` and width factor `wf`; characters the +/// stroke font lacks take a fallback font's advance when one has them. +fn stroke_advance(c: char, h: f64, wf: f64) -> f64 { + if stroke::glyph(c).is_none() + && !c.is_whitespace() + && let Some(g) = ttf::fallback_glyph(c) + { + return g.advance * h / g.cap * wf; + } + char_advance(c) * h * wf +} + +/// Shape a single line in the stroke font at `height`, with `width_factor` and `oblique` +/// (radians). Characters the stroke font lacks are drawn from an installed font that has them +/// (glyph outlines, sized like TrueType text), else as a missing-glyph box. +pub fn shape_stroke(s: &str, height: f64, width_factor: f64, oblique: f64) -> Shaped { let h = if height.is_finite() && height > 0.0 { height } else { 1.0 }; let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; let sc = h / stroke::CAP; let shear = oblique.tan().clamp(-10.0, 10.0); - let mut run = Run::default(); + let mut out = Shaped::default(); let mut x = 0.0; let mut under_start: Option = None; let mut over_start: Option = None; for (c, under, over) in decode_controls(s) { - let adv = char_advance(c) * h * wf; + let mut adv = char_advance(c) * h * wf; match stroke::glyph(c) { Some((_, spec)) => { for st in stroke::strokes(spec) { let pts: Vec = st.iter().map(|(gx, gy)| Vec2::new(x + (gx * sc * wf) + gy * sc * shear, gy * sc)).collect(); if pts.len() == 1 { if let Some(p) = pts.first() { - run.strokes.push(vec![*p, *p + Vec2::new(sc * 0.2, 0.0)]); + out.strokes.push(vec![*p, *p + Vec2::new(sc * 0.2, 0.0)]); } } else { - run.strokes.push(pts); + out.strokes.push(pts); } } } - None if !c.is_whitespace() => { - // Unknown glyph: a small box, like a missing-glyph rectangle. - let w = 4.0 * sc * wf; - run.strokes.push(vec![Vec2::new(x, 0.0), Vec2::new(x + w, 0.0), Vec2::new(x + w, h), Vec2::new(x, h), Vec2::new(x, 0.0)]); - } + None if !c.is_whitespace() => match ttf::fallback_glyph(c) { + Some(g) => { + let contours = ttf::place_glyph(&g, x, h, wf, shear); + if !contours.is_empty() { + out.glyphs.push(contours); + } + adv = g.advance * h / g.cap * wf; + } + None => { + // Unknown glyph: a small box, like a missing-glyph rectangle. + let w = 4.0 * sc * wf; + out.strokes.push(vec![Vec2::new(x, 0.0), Vec2::new(x + w, 0.0), Vec2::new(x + w, h), Vec2::new(x, h), Vec2::new(x, 0.0)]); + } + }, None => {} } // Decorations. match (under, under_start) { (true, None) => under_start = Some(x), (false, Some(sx)) => { - run.strokes.push(vec![Vec2::new(sx, -h * 0.2), Vec2::new(x, -h * 0.2)]); + out.strokes.push(vec![Vec2::new(sx, -h * 0.2), Vec2::new(x, -h * 0.2)]); under_start = None; } _ => {} @@ -122,7 +144,7 @@ pub fn layout_line(s: &str, height: f64, width_factor: f64, oblique: f64) -> Run match (over, over_start) { (true, None) => over_start = Some(x), (false, Some(sx)) => { - run.strokes.push(vec![Vec2::new(sx, h * 1.2), Vec2::new(x, h * 1.2)]); + out.strokes.push(vec![Vec2::new(sx, h * 1.2), Vec2::new(x, h * 1.2)]); over_start = None; } _ => {} @@ -131,34 +153,52 @@ pub fn layout_line(s: &str, height: f64, width_factor: f64, oblique: f64) -> Run } let end = (x - stroke::GAP * sc * wf).max(0.0); if let Some(sx) = under_start { - run.strokes.push(vec![Vec2::new(sx, -h * 0.2), Vec2::new(end, -h * 0.2)]); + out.strokes.push(vec![Vec2::new(sx, -h * 0.2), Vec2::new(end, -h * 0.2)]); } if let Some(sx) = over_start { - run.strokes.push(vec![Vec2::new(sx, h * 1.2), Vec2::new(end, h * 1.2)]); + out.strokes.push(vec![Vec2::new(sx, h * 1.2), Vec2::new(end, h * 1.2)]); } - run.width = end; - run + out.width = end; + out +} + +/// Lay out a single line at `height`, with `width_factor` and `oblique` (radians), in the +/// stroke font (fallback glyphs become closed outlines). +pub fn layout_line(s: &str, height: f64, width_factor: f64, oblique: f64) -> Run { + let sh = shape_stroke(s, height, width_factor, oblique); + let mut strokes = sh.strokes; + strokes.extend(sh.glyphs.into_iter().flatten()); + Run { strokes, width: sh.width } } /// Width of a line without building strokes. pub fn line_width(s: &str, height: f64, width_factor: f64) -> f64 { - let n: f64 = decode_controls(s).iter().map(|(c, _, _)| char_advance(*c)).sum(); - (n * height * width_factor - stroke::GAP / stroke::CAP * height * width_factor).max(0.0) + let n: f64 = decode_controls(s).iter().map(|(c, _, _)| stroke_advance(*c, height, width_factor)).sum(); + (n - stroke::GAP / stroke::CAP * height * width_factor).max(0.0) } /// The font a piece of text is set in: the built-in stroke font or an installed TrueType / -/// OpenType font (outlines). +/// OpenType face (outlines). Either way, characters the font lacks come from a fallback font. #[derive(Clone, Debug, Default)] pub enum TextFont { #[default] Stroke, - Outline(Arc>), + Outline(ttf::Face), } impl TextFont { - /// Resolve a text style font name: an installed TTF/OTF when found, else the stroke font. + /// Resolve a text style font name: an installed TTF/OTF/TTC when found; else, for a font we + /// can't find (an SHX file, a missing TTF), the FONTALT font ([`ttf::font_alt`]) when set; + /// else the stroke font. pub fn resolve(name: &str) -> TextFont { - ttf::find(name).map(TextFont::Outline).unwrap_or(TextFont::Stroke) + if let Some(f) = ttf::find(name) { + return TextFont::Outline(f); + } + let n = name.trim(); + if n.is_empty() || n.eq_ignore_ascii_case(BUILTIN_FONT) { + return TextFont::Stroke; + } + ttf::find(&ttf::font_alt()).map(TextFont::Outline).unwrap_or(TextFont::Stroke) } pub fn is_outline(&self) -> bool { matches!(self, TextFont::Outline(_)) @@ -237,19 +277,18 @@ pub(crate) fn decorations(spans: &[(f64, f64, bool, bool)], h: f64) -> Vec Shaped { - if let TextFont::Outline(bytes) = font - && let Some(sh) = ttf::shape(bytes, s, height, width_factor, oblique) + if let TextFont::Outline(face) = font + && let Some(sh) = ttf::shape(face, s, height, width_factor, oblique) { return sh; } - let run = layout_line(s, height, width_factor, oblique); - Shaped { strokes: run.strokes, glyphs: Vec::new(), width: run.width } + shape_stroke(s, height, width_factor, oblique) } /// Width of one line in `font`. pub fn text_width(font: &TextFont, s: &str, height: f64, width_factor: f64) -> f64 { - if let TextFont::Outline(bytes) = font - && let Some(w) = ttf::width(bytes, s, height, width_factor) + if let TextFont::Outline(face) = font + && let Some(w) = ttf::width(face, s, height, width_factor) { return w; } @@ -451,10 +490,10 @@ mod ttf_tests { #[test] fn system_font_outlines_when_available() { // Skips cleanly when no common system font is installed. - let Some(bytes) = ["Arial", "Helvetica", "DejaVuSans", "Verdana", "LiberationSans-Regular"].iter().find_map(|n| crate::ttf::find(n)) else { + let Some(face) = ["Arial", "Helvetica", "DejaVuSans", "Verdana", "LiberationSans-Regular"].iter().find_map(|n| crate::ttf::find(n)) else { return; }; - let run = crate::ttf::layout_line(&bytes, "CAD", 1.0, 1.0, 0.0).unwrap(); + let run = crate::ttf::layout_line(&face, "CAD", 1.0, 1.0, 0.0).unwrap(); assert!(run.strokes.len() >= 3, "glyph contours"); assert!(run.width > 1.5 && run.width < 4.0, "width {}", run.width); let maxy = run.strokes.iter().flatten().map(|p| p.y).fold(f64::MIN, f64::max); @@ -465,6 +504,74 @@ mod ttf_tests { fn stroke_names_are_not_ttf() { assert!(crate::ttf::find("txt.shx").is_none()); assert!(crate::ttf::find("").is_none()); - assert!(crate::ttf::layout_line(b"not a font", "x", 1.0, 1.0, 0.0).is_none()); + let junk = crate::ttf::Face { bytes: std::sync::Arc::new(b"not a font".to_vec()), index: 0 }; + assert!(crate::ttf::layout_line(&junk, "x", 1.0, 1.0, 0.0).is_none()); + } + + #[test] + fn fallback_ranking_prefers_wide_coverage_regular_fonts() { + let stems: Vec = + ["zapfino", "dejavusans", "notosanscjk-bold", "notosanscjk-regular", "wqy-zenhei", "notosansarabic-regular", "arial"] + .map(String::from) + .to_vec(); + let r = crate::ttf::rank_fallback(&stems); + assert_eq!(r.first().map(String::as_str), Some("notosanscjk-regular")); + assert_eq!(r.get(1).map(String::as_str), Some("notosanscjk-bold")); + assert_eq!(r.get(2).map(String::as_str), Some("wqy-zenhei")); + assert_eq!(r.last().map(String::as_str), Some("zapfino")); + assert_eq!(r.len(), stems.len()); + } + + #[test] + fn missing_glyph_without_fallback_draws_a_box() { + // Private-use code points never take a fallback font: the stroke font's box, no panic. + let sh = crate::shape_stroke("A\u{E000}", 1.0, 1.0, 0.0); + let sh2 = crate::shape_stroke("A\u{E000}B", 1.0, 1.0, 0.0); + assert!(sh.glyphs.is_empty() && sh2.glyphs.is_empty()); + let boxed = sh.strokes.iter().any(|s| s.len() == 5 && s.first() == s.last() && s.iter().any(|p| (p.y - 1.0).abs() < 1e-9)); + assert!(boxed, "missing-glyph box"); + assert!((crate::line_width("A\u{E000}B", 1.0, 1.0) - sh2.width).abs() < 1e-9); + } + + #[test] + fn hostile_collections_never_panic() { + // A TTC header claiming many faces with bogus or truncated offsets. + let mut ttc = b"ttcf\x00\x01\x00\x00\x7f\xff\xff\xff".to_vec(); + ttc.extend_from_slice(&[0xff; 16]); + for bytes in [ttc.clone(), ttc[..10].to_vec(), b"ttcf".to_vec(), Vec::new()] { + let arc = std::sync::Arc::new(bytes); + assert!(crate::ttf::faces(&arc).is_empty()); + assert!(crate::ttf::Face::new(arc.clone(), 3).is_none()); + assert!(!crate::ttf::Face { bytes: arc, index: u32::MAX }.has_glyph('a')); + } + crate::ttf::register("cadcraft-hostile-test.ttc", ttc); + assert!(crate::ttf::find("cadcraft-hostile-test.ttc").is_none()); + let _ = crate::ttf::fallback_for('\u{4E2D}'); + } + + #[test] + fn font_alt_that_is_not_installed_keeps_the_stroke_font() { + crate::ttf::set_fallback_fonts(" no-such-font-130 ; . ,other-missing "); + assert_eq!(crate::ttf::fallback_fonts(), "no-such-font-130, other-missing"); + crate::ttf::set_fallback_fonts(""); + crate::ttf::set_font_alt("no-such-font-130"); + assert_eq!(crate::ttf::font_alt(), "no-such-font-130"); + assert!(!crate::TextFont::resolve("romans.shx").is_outline()); + crate::ttf::set_font_alt("."); + assert_eq!(crate::ttf::font_alt(), ""); + } + + #[test] + fn cjk_falls_back_to_an_installed_font_when_available() { + // Skips cleanly when no installed font covers CJK. + if crate::ttf::fallback_for('\u{4E2D}').is_none() { + return; + } + let sh = crate::shape_stroke("AB\u{4E2D}\u{6587}", 1.0, 1.0, 0.0); + assert_eq!(sh.glyphs.len(), 2, "two fallback glyphs"); + assert!(!sh.strokes.iter().any(|s| s.len() == 5 && s.first() == s.last()), "no missing-glyph boxes"); + let maxy = sh.glyphs.iter().flatten().flatten().map(|p| p.y).fold(f64::MIN, f64::max); + assert!(maxy > 0.8 && maxy < 1.8, "sized like TrueType text (cap height = text height), got {maxy}"); + assert!((crate::line_width("AB\u{4E2D}\u{6587}", 1.0, 1.0) - sh.width).abs() < 1e-9); } } diff --git a/crates/fonts/src/mtext.rs b/crates/fonts/src/mtext.rs index eea30eb9..1fabdf66 100644 --- a/crates/fonts/src/mtext.rs +++ b/crates/fonts/src/mtext.rs @@ -207,8 +207,8 @@ fn font_switch(a: &str) -> Option { }; if let Some(st) = style { for cand in [format!("{name} {st}"), format!("{name}-{}", st.replace(' ', "")), format!("{name}{}", if bold { "bd" } else { "i" })] { - if let Some(b) = crate::ttf::find(&cand) { - return Some(TextFont::Outline(b)); + if let Some(f) = crate::ttf::find(&cand) { + return Some(TextFont::Outline(f)); } } } diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs index ca60178f..69358782 100644 --- a/crates/fonts/src/ttf.rs +++ b/crates/fonts/src/ttf.rs @@ -1,40 +1,175 @@ //! TrueType/OpenType text: system fonts found at run time (never bundled), glyph outlines as //! closed polylines in CAD text units (text height = cap height). +//! +//! Fonts are found by file stem (`arial.ttf`, `msjh.ttc`) and, failing that, by family name +//! (`Noto Sans CJK TC`) among installed files with a similar name. TrueType collections +//! (`.ttc` / `.otc`) are scanned too; a stem lookup takes their first face. +//! +//! Two process-wide settings (like AutoCAD's profile variables) control substitution: +//! - [`font_alt`] (`FONTALT`): the font used for a text style font that can't be found (an SHX +//! file we don't read, a missing TTF). Empty = the built-in stroke font. +//! - [`fallback_fonts`] (`FONTFALLBACK`): fonts tried first for characters the text's font +//! lacks (CJK in an SHX big font, for example), before the automatic system font search. +//! +//! On native builds both start from the `CADCRAFT_FONTALT` / `CADCRAFT_FONTFALLBACK` +//! environment variables. use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock, PoisonError}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError}; use cadcraft_geom::Vec2; use skrifa::instance::{LocationRef, Size}; use skrifa::outline::{DrawSettings, OutlinePen}; +use skrifa::raw::FileRef; use skrifa::{FontRef, MetadataProvider}; use crate::{Run, Shaped}; -/// Font bytes by lower-case family/file stem. -type Db = HashMap>>; +/// One face of a font file (a TrueType collection holds several). +#[derive(Clone)] +pub struct Face { + pub bytes: Arc>, + /// Face index inside a collection (0 for a plain font file). + pub index: u32, +} -fn db() -> &'static Mutex> { - static DB: OnceLock>> = OnceLock::new(); - DB.get_or_init(|| Mutex::new(None)) +impl std::fmt::Debug for Face { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Face").field("len", &self.bytes.len()).field("index", &self.index).finish() + } +} + +impl Face { + /// Face `index` of font file `bytes`, if it parses. + pub fn new(bytes: Arc>, index: u32) -> Option { + FontRef::from_index(&bytes, index).ok()?; + Some(Face { bytes, index }) + } + fn font(&self) -> Option> { + FontRef::from_index(&self.bytes, self.index).ok() + } + /// Whether the face maps `c` to a glyph. + pub fn has_glyph(&self, c: char) -> bool { + self.font().is_some_and(|f| f.charmap().map(c).is_some()) + } + fn names(&self) -> Vec { + let Some(f) = self.font() else { return Vec::new() }; + [skrifa::string::StringId::FAMILY_NAME, skrifa::string::StringId::TYPOGRAPHIC_FAMILY_NAME, skrifa::string::StringId::FULL_NAME] + .into_iter() + .flat_map(|id| f.localized_strings(id).map(|s| norm(&s.to_string())).collect::>()) + .collect() + } +} + +/// Faces of a font file: every face of a collection (capped), or the single font. +pub fn faces(bytes: &Arc>) -> Vec { + match FileRef::new(bytes) { + Ok(FileRef::Collection(c)) => (0..c.len().min(MAX_FACES_PER_FILE)).filter_map(|i| Face::new(bytes.clone(), i)).collect(), + Ok(FileRef::Font(_)) => vec![Face { bytes: bytes.clone(), index: 0 }], + Err(_) => Vec::new(), + } +} + +const MAX_FACES_PER_FILE: u32 = 32; +/// Bounds of the missing-glyph search: files loaded, bytes loaded, faces checked per character. +const MAX_FALLBACK_FILES: usize = 24; +const MAX_FALLBACK_BYTES: usize = 160 << 20; +const MAX_FALLBACK_FACES: usize = 256; +/// Files loaded by one family-name lookup. +const MAX_FAMILY_FILES: usize = 6; +/// Total bytes of system font files loaded from disk: a drawing naming many styles can't pull +/// every installed font into memory. +const MAX_LOADED_BYTES: usize = 256 << 20; +const MAX_CACHED_NAMES: usize = 4096; +const MAX_CACHED_CHARS: usize = 50_000; + +/// Fonts known to cover many scripts (CJK first), tried first for missing glyphs. Matched as +/// substrings of the normalised file stem. +const PREFERRED_FALLBACK: &[&str] = &[ + "notosanscjk", + "sourcehansans", + "notoserifcjk", + "sourcehanserif", + "wqyzenhei", + "wqymicrohei", + "droidsansfallback", + "pingfang", + "hiraginosans", + "stheiti", + "msjh", + "msyh", + "yugoth", + "msgothic", + "malgun", + "simsun", + "mingliu", + "arialuni", + "notosans", + "dejavusans", + "segoeui", + "arial", +]; +const WEIGHT_WORDS: &[&str] = &["thin", "light", "medium", "semibold", "bold", "black", "heavy", "italic", "oblique", "mono", "condensed"]; + +#[derive(Default)] +struct Db { + scanned: bool, + /// Font files found by the scan, by lower-case file stem. + paths: HashMap, + /// Loaded or registered font file bytes, by lower-case file stem. + files: HashMap>>, + /// Name lookups already answered, found or not. + resolved: HashMap>, + fallback: Fallback, + /// Bytes of font files loaded from disk so far (bounded by `MAX_LOADED_BYTES`). + loaded_bytes: usize, +} + +/// State of the missing-glyph search. +#[derive(Default)] +struct Fallback { + /// Candidate file stems, best first (built on first use). + order: Option>, + /// Next candidate in `order` to load. + next: usize, + files: usize, + bytes: usize, + /// Faces of the loaded candidates, in candidate order. + faces: Vec, + /// Character → index in `faces` (None: nothing covers it). + by_char: HashMap>, +} + +fn lock() -> MutexGuard<'static, Db> { + static DB: OnceLock> = OnceLock::new(); + let mut g = DB.get_or_init(|| Mutex::new(Db::default())).lock().unwrap_or_else(PoisonError::into_inner); + if !g.scanned { + g.scanned = true; + g.paths = scan(); + } + g } #[cfg(not(target_arch = "wasm32"))] -fn scan() -> Db { - let mut out = Db::new(); - let mut dirs: Vec = Vec::new(); +fn scan() -> HashMap { + let mut out = HashMap::new(); + let mut dirs: Vec = Vec::new(); if cfg!(target_os = "macos") { dirs.extend(["/System/Library/Fonts", "/System/Library/Fonts/Supplemental", "/Library/Fonts"].map(Into::into)); if let Some(h) = std::env::var_os("HOME") { - dirs.push(std::path::PathBuf::from(h).join("Library/Fonts")); + dirs.push(PathBuf::from(h).join("Library/Fonts")); } } else if cfg!(windows) { dirs.push("C:\\Windows\\Fonts".into()); + if let Some(l) = std::env::var_os("LOCALAPPDATA") { + dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); + } } else { dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into)); if let Some(h) = std::env::var_os("HOME") { - dirs.push(std::path::PathBuf::from(&h).join(".fonts")); - dirs.push(std::path::PathBuf::from(h).join(".local/share/fonts")); + dirs.push(PathBuf::from(&h).join(".fonts")); + dirs.push(PathBuf::from(h).join(".local/share/fonts")); } } let mut stack = dirs; @@ -52,79 +187,292 @@ fn scan() -> Db { continue; } let ext = p.extension().map(|x| x.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - if ext != "ttf" && ext != "otf" { + if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") { continue; } if let Some(stem) = p.file_stem().map(|s| s.to_string_lossy().to_ascii_lowercase()) { - out.entry(stem).or_insert_with(|| Arc::new(Vec::new())); - // Load lazily: store the path in a side table by using an empty marker. - PATHS - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .unwrap_or_else(PoisonError::into_inner) - .insert(p.file_stem().map(|s| s.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(), p.clone()); + out.entry(stem).or_insert(p); } } } out } +#[cfg(target_arch = "wasm32")] +fn scan() -> HashMap { + HashMap::new() +} + #[cfg(not(target_arch = "wasm32"))] -static PATHS: OnceLock>> = OnceLock::new(); +fn read_file(path: &std::path::Path) -> Option> { + /// Font files larger than this are never read. + const MAX_FILE_BYTES: u64 = 96 << 20; + if std::fs::metadata(path).ok()?.len() > MAX_FILE_BYTES { + return None; + } + std::fs::read(path).ok() +} #[cfg(target_arch = "wasm32")] -fn scan() -> Db { - Db::new() +fn read_file(_path: &std::path::Path) -> Option> { + None +} + +impl Db { + /// Bytes of the font file with this stem, loading it on first use. + fn file(&mut self, stem: &str) -> Option>> { + if let Some(b) = self.files.get(stem) { + return Some(b.clone()); + } + if self.loaded_bytes >= MAX_LOADED_BYTES { + return None; + } + let bytes = Arc::new(read_file(self.paths.get(stem)?)?); + self.loaded_bytes = self.loaded_bytes.saturating_add(bytes.len()); + self.files.insert(stem.to_string(), bytes.clone()); + Some(bytes) + } + + fn lookup(&mut self, k: &str) -> Option { + let candidates = [k.to_string(), k.replace(' ', ""), format!("{k} regular"), format!("{}-regular", k.replace(' ', ""))]; + for c in &candidates { + if let Some(face) = self.file(c).and_then(|b| faces(&b).into_iter().next()) { + return Some(face); + } + } + self.by_family(k) + } + + /// A face whose family or full name is `k`, among files with a similar stem. + fn by_family(&mut self, k: &str) -> Option { + let want = norm(k); + if want.len() < 5 { + return None; + } + let mut stems: Vec<(usize, usize, String)> = self + .paths + .keys() + .chain(self.files.keys()) + .map(|s| (weight_penalty(s, &want), common_prefix(&norm(s), &want), s.clone())) + .filter(|(_, p, _)| *p >= 5) + .collect(); + stems.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2))); + stems.dedup_by(|a, b| a.2 == b.2); + for (_, _, stem) in stems.into_iter().take(MAX_FAMILY_FILES) { + let Some(bytes) = self.file(&stem) else { continue }; + if let Some(face) = faces(&bytes).into_iter().find(|f| f.names().iter().any(|n| *n == want || format!("{n}regular") == want)) { + return Some(face); + } + } + None + } + + /// Load the next fallback candidate file. False when none is left or a bound is reached. + fn load_next_fallback(&mut self) -> bool { + if self.fallback.order.is_none() { + let stems: Vec = self.paths.keys().chain(self.files.keys()).cloned().collect(); + self.fallback.order = Some(rank_fallback(&stems)); + } + loop { + if self.fallback.files >= MAX_FALLBACK_FILES || self.fallback.bytes >= MAX_FALLBACK_BYTES { + return false; + } + let Some(stem) = self.fallback.order.as_ref().and_then(|o| o.get(self.fallback.next)).cloned() else { return false }; + self.fallback.next += 1; + let Some(bytes) = self.file(&stem) else { continue }; + self.fallback.files += 1; + self.fallback.bytes = self.fallback.bytes.saturating_add(bytes.len()); + let fs = faces(&bytes); + if !fs.is_empty() { + self.fallback.faces.extend(fs); + return true; + } + } + } + + /// Index in `fallback.faces` of the first face that has `c`. + fn search_fallback(&mut self, c: char) -> Option { + if let Some(r) = self.fallback.by_char.get(&c) { + return *r; + } + let mut found = None; + let mut i = 0; + while i < MAX_FALLBACK_FACES { + if i >= self.fallback.faces.len() && !self.load_next_fallback() { + break; + } + if self.fallback.faces.get(i).is_some_and(|f| f.has_glyph(c)) { + found = Some(i); + break; + } + i += 1; + } + if self.fallback.by_char.len() >= MAX_CACHED_CHARS { + self.fallback.by_char.clear(); + } + self.fallback.by_char.insert(c, found); + found + } +} + +/// Lower-case, without spaces, hyphens and underscores. +fn norm(s: &str) -> String { + s.chars().filter(|c| !matches!(c, ' ' | '-' | '_')).flat_map(char::to_lowercase).collect() +} + +fn common_prefix(a: &str, b: &str) -> usize { + a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count() +} + +/// Weight / style words in a file name that the wanted name doesn't ask for. +fn weight_penalty(stem: &str, want: &str) -> usize { + let n = norm(stem); + WEIGHT_WORDS.iter().filter(|w| n.contains(*w) && !want.contains(*w)).count() +} + +/// Order font file stems for the missing-glyph search: known wide-coverage (CJK) fonts first, +/// regular weights before bold, light and the like, then by name. +pub(crate) fn rank_fallback(stems: &[String]) -> Vec { + let mut v: Vec<(usize, usize, String)> = stems + .iter() + .map(|s| { + let n = norm(s); + let pref = PREFERRED_FALLBACK.iter().position(|p| n.contains(p)).unwrap_or(PREFERRED_FALLBACK.len()); + (pref, weight_penalty(s, ""), s.clone()) + }) + .collect(); + v.sort(); + v.dedup_by(|a, b| a.2 == b.2); + v.into_iter().map(|(_, _, s)| s).collect() } /// Register font bytes under a name (web builds and tests). pub fn register(name: &str, bytes: Vec) { - let mut g = db().lock().unwrap_or_else(PoisonError::into_inner); - g.get_or_insert_with(Db::new).insert(name.to_ascii_lowercase(), Arc::new(bytes)); + let mut g = lock(); + g.files.insert(key(name), Arc::new(bytes)); + g.resolved.clear(); + g.fallback = Fallback::default(); } /// Normalise a style font name ("arial.ttf", "Arial", "ARIALBD.TTF") to a lookup key. fn key(name: &str) -> String { let n = name.trim().to_ascii_lowercase(); - n.trim_end_matches(".ttf").trim_end_matches(".otf").trim_end_matches(".ttc").to_string() + n.trim_end_matches(".ttf").trim_end_matches(".otf").trim_end_matches(".ttc").trim_end_matches(".otc").to_string() } -/// Bytes of a font by name, if installed. Stroke-font names (".shx", empty) return `None`. -pub fn find(name: &str) -> Option>> { +/// Names that mean a stroke font: never looked up as TrueType. +fn is_stroke_name(k: &str) -> bool { + k.is_empty() || k.ends_with(".shx") || k == crate::BUILTIN_FONT.to_ascii_lowercase() || k == "txt" || k == "simplex" || k == "romans" +} + +/// A face of an installed (or registered) font by file name or family name. Stroke-font names +/// (".shx", empty) return `None`. +pub fn find(name: &str) -> Option { let k = key(name); - if k.is_empty() || k.ends_with(".shx") || k == crate::BUILTIN_FONT.to_ascii_lowercase() || k == "txt" || k == "simplex" || k == "romans" { + if is_stroke_name(&k) { return None; } - let mut g = db().lock().unwrap_or_else(PoisonError::into_inner); - let d = g.get_or_insert_with(scan); - let candidates = [k.clone(), k.replace(' ', ""), format!("{k} regular"), format!("{}-regular", k.replace(' ', ""))]; - for c in &candidates { - if let Some(b) = d.get(c) { - if !b.is_empty() { - return Some(b.clone()); - } - #[cfg(not(target_arch = "wasm32"))] - { - let path = PATHS.get().and_then(|m| m.lock().unwrap_or_else(PoisonError::into_inner).get(c).cloned())?; - let bytes = std::fs::read(path).ok()?; - let arc = Arc::new(bytes); - d.insert(c.clone(), arc.clone()); - return Some(arc); - } + let mut g = lock(); + if let Some(r) = g.resolved.get(&k) { + return r.clone(); + } + let r = g.lookup(&k); + if g.resolved.len() >= MAX_CACHED_NAMES { + g.resolved.clear(); + } + g.resolved.insert(k, r.clone()); + r +} + +struct Prefs { + alt: String, + fallback: Vec, +} + +fn prefs() -> MutexGuard<'static, Prefs> { + static P: OnceLock> = OnceLock::new(); + P.get_or_init(|| { + #[cfg(not(target_arch = "wasm32"))] + let env = |k: &str| std::env::var(k).unwrap_or_default(); + #[cfg(target_arch = "wasm32")] + let env = |_: &str| String::new(); + Mutex::new(Prefs { alt: clean_name(&env("CADCRAFT_FONTALT")), fallback: split_list(&env("CADCRAFT_FONTFALLBACK")) }) + }) + .lock() + .unwrap_or_else(PoisonError::into_inner) +} + +/// A font name setting: trimmed, capped, "." = none. +fn clean_name(s: &str) -> String { + let s = s.trim(); + if s == "." { String::new() } else { s.chars().take(255).collect() } +} + +fn split_list(s: &str) -> Vec { + s.split([',', ';']).map(clean_name).filter(|n| !n.is_empty()).take(16).collect() +} + +/// FONTALT: the font used for a text style font that can't be found. Empty = the built-in +/// stroke font. +pub fn font_alt() -> String { + prefs().alt.clone() +} + +/// Set FONTALT ("" or "." clears it). +pub fn set_font_alt(name: &str) { + prefs().alt = clean_name(name); +} + +/// FONTFALLBACK: fonts tried first for characters a text's font lacks, comma-separated. +pub fn fallback_fonts() -> String { + prefs().fallback.join(", ") +} + +/// Set FONTFALLBACK from a comma- or semicolon-separated list ("" or "." clears it). +pub fn set_fallback_fonts(list: &str) { + prefs().fallback = split_list(list); +} + +/// Characters worth a fallback search (not spaces, controls or private-use code points). +fn wants_fallback(c: char) -> bool { + let u = u32::from(c); + !(c.is_whitespace() || c.is_control() || (0xE000..=0xF8FF).contains(&u) || u >= 0xF_0000 || (u & 0xFFFE) == 0xFFFE) +} + +/// A face that has `c`, for text whose own font lacks it: FONTFALLBACK fonts, then FONTALT, +/// then installed fonts (wide-coverage CJK fonts first). Bounded and cached. +pub fn fallback_for(c: char) -> Option { + if !wants_fallback(c) { + return None; + } + let (list, alt) = { + let p = prefs(); + (p.fallback.clone(), p.alt.clone()) + }; + for n in list.iter().chain(std::iter::once(&alt)) { + if let Some(f) = find(n).filter(|f| f.has_glyph(c)) { + return Some(f); } } - None + let mut g = lock(); + let i = g.search_fallback(c)?; + g.fallback.faces.get(i).cloned() } -/// A glyph outline in font units (at 1000 units per em), flattened, plus its advance. -struct GlyphOutline { - contours: Vec>, - advance: f64, +/// A glyph outline in font units (at 1000 units per em), flattened, plus its advance and the +/// font's cap height. +#[derive(Default)] +pub(crate) struct GlyphOutline { + pub(crate) contours: Vec>, + pub(crate) advance: f64, + pub(crate) cap: f64, + /// The font maps the character (false: the missing-glyph shape). + mapped: bool, } -/// Cached glyph outlines keyed by (font bytes address, length, char). Fonts live for the whole -/// process in the font table, so the address identifies them. -type GlyphCache = HashMap<(usize, usize, char), Arc>; +/// Cached glyph outlines keyed by (font bytes address, length, face, char). Fonts live for the +/// whole process in the font table, so the address identifies them. +type GlyphCache = HashMap<(usize, usize, u32, char), Arc>; fn glyph_cache() -> &'static Mutex { static C: OnceLock> = OnceLock::new(); @@ -191,25 +539,36 @@ impl OutlinePen for Pen { } } -/// Cap height of a font in font units at 1000 units per em. +/// Cap height of a font in font units at 1000 units per em: the OS/2 value, else the height +/// of its "H", else an estimate from the ascent. fn cap_height(f: &FontRef) -> f64 { let metrics = f.metrics(Size::new(UPEM), LocationRef::default()); - f64::from(metrics.cap_height.filter(|c| *c > 0.0).unwrap_or(metrics.ascent * 0.72).max(1.0)) + let measured = || { + let gid = f.charmap().map('H')?; + let b = f.glyph_metrics(Size::new(UPEM), LocationRef::default()).bounds(gid)?; + Some(b.y_max).filter(|y| y.is_finite() && *y > 0.0) + }; + let cap = metrics.cap_height.filter(|c| c.is_finite() && *c > 0.0).or_else(measured).unwrap_or(metrics.ascent * 0.72); + f64::from(cap).max(1.0) } -fn glyph(font: &[u8], f: &FontRef, c: char) -> Arc { - let key = (font.as_ptr() as usize, font.len(), c); +/// The outline of `c` in `face` (its missing-glyph shape when unmapped; empty when the face +/// doesn't parse). +pub(crate) fn glyph(face: &Face, c: char) -> Arc { + let key = (face.bytes.as_ptr() as usize, face.bytes.len(), face.index, c); if let Some(g) = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner).get(&key) { return g.clone(); } - let gid = f.charmap().map(c).unwrap_or_default(); + let Some(f) = face.font() else { return Arc::new(GlyphOutline { cap: 1.0, ..Default::default() }) }; + let mapped = f.charmap().map(c); + let gid = mapped.unwrap_or_default(); let mut pen = Pen { contours: Vec::new(), cur: Vec::new() }; if let Some(g) = f.outline_glyphs().get(gid) { let _ = g.draw(DrawSettings::unhinted(Size::new(UPEM), LocationRef::default()), &mut pen); pen.flush(); } let advance = f64::from(f.glyph_metrics(Size::new(UPEM), LocationRef::default()).advance_width(gid).unwrap_or(UPEM * 0.5)); - let g = Arc::new(GlyphOutline { contours: pen.contours, advance }); + let g = Arc::new(GlyphOutline { contours: pen.contours, advance, cap: cap_height(&f), mapped: mapped.is_some() }); let mut cache = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner); if cache.len() > MAX_CACHED_GLYPHS { cache.clear(); @@ -218,37 +577,57 @@ fn glyph(font: &[u8], f: &FontRef, c: char) -> Arc { g } -/// Shape one line with a TrueType font: closed glyph contours grouped per glyph (baseline at +/// The outline of `c` from a fallback font, if any installed font has it. +pub(crate) fn fallback_glyph(c: char) -> Option> { + let f = fallback_for(c)?; + Some(glyph(&f, c)).filter(|g| g.mapped) +} + +/// The glyph of `c` in `face`, or from a fallback font when `face` lacks it. +fn glyph_or_fallback(face: &Face, c: char) -> Arc { + let g = glyph(face, c); + if g.mapped || c.is_whitespace() { + return g; + } + fallback_glyph(c).unwrap_or(g) +} + +/// Contours of a glyph scaled so its font's cap height is `h`, at pen position `x`. +pub(crate) fn place_glyph(g: &GlyphOutline, x: f64, h: f64, wf: f64, shear: f64) -> Vec> { + let scale = h / g.cap; + g.contours + .iter() + .map(|ct| { + ct.iter() + .map(|p| { + let y = p.y * scale; + Vec2::new(x + p.x * scale * wf + y * shear, y) + }) + .collect() + }) + .collect() +} + +/// Shape one line with a TrueType face: closed glyph contours grouped per glyph (baseline at /// y = 0, x from 0) plus `%%u`/`%%o` decorations as strokes. Text height = cap height. -pub fn shape(font: &[u8], s: &str, height: f64, width_factor: f64, oblique: f64) -> Option { - let f = FontRef::new(font).ok()?; +/// Characters the face lacks come from a fallback font, scaled to its own cap height. +pub fn shape(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64) -> Option { + face.font()?; let h = if height.is_finite() && height > 0.0 { height } else { 1.0 }; - let scale = h / cap_height(&f); let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; let shear = oblique.tan().clamp(-10.0, 10.0); let mut out = Shaped::default(); let mut spans = Vec::new(); let mut x = 0.0; for (c, under, over) in crate::decode_controls(s) { - let g = glyph(font, &f, c); + let g = glyph_or_fallback(face, c); if !c.is_whitespace() { - let contours: Vec> = g - .contours - .iter() - .map(|ct| { - ct.iter() - .map(|p| { - let y = p.y * scale; - Vec2::new(x + p.x * scale * wf + y * shear, y) - }) - .collect() - }) - .collect(); + let contours = place_glyph(&g, x, h, wf, shear); if !contours.is_empty() { out.glyphs.push(contours); } } - let adv = g.advance * scale * wf; + let adv = g.advance * h / g.cap * wf; spans.push((x, x + adv, under, over)); x += adv; } @@ -257,17 +636,24 @@ pub fn shape(font: &[u8], s: &str, height: f64, width_factor: f64, oblique: f64) Some(out) } -/// Width of one line set in a TrueType font. -pub fn width(font: &[u8], s: &str, height: f64, width_factor: f64) -> Option { - let f = FontRef::new(font).ok()?; - let scale = height / cap_height(&f); +/// Width of one line set in a TrueType face. +pub fn width(face: &Face, s: &str, height: f64, width_factor: f64) -> Option { + face.font()?; let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 }; - Some(crate::decode_controls(s).iter().map(|(c, _, _)| glyph(font, &f, *c).advance * scale * wf).sum()) + Some( + crate::decode_controls(s) + .iter() + .map(|(c, _, _)| { + let g = glyph_or_fallback(face, *c); + g.advance * height / g.cap * wf + }) + .sum(), + ) } -/// Lay out one line with a TrueType font: closed glyph contours (baseline at y = 0). -pub fn layout_line(font: &[u8], s: &str, height: f64, width_factor: f64, oblique: f64) -> Option { - let sh = shape(font, s, height, width_factor, oblique)?; +/// Lay out one line with a TrueType face: closed glyph contours (baseline at y = 0). +pub fn layout_line(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64) -> Option { + let sh = shape(face, s, height, width_factor, oblique)?; let mut strokes: Vec> = sh.glyphs.into_iter().flatten().collect(); strokes.extend(sh.strokes); Some(Run { strokes, width: sh.width }) diff --git a/crates/io/src/dxf_ext.rs b/crates/io/src/dxf_ext.rs index 548cfad0..a7af272f 100644 --- a/crates/io/src/dxf_ext.rs +++ b/crates/io/src/dxf_ext.rs @@ -336,6 +336,20 @@ pub(crate) fn assoc_xdata(assoc: &[DimAssoc]) -> Vec { v } +/// An arc-length dimension as CADCraft xdata: `1000 ARCLEN`, `1002 {`, the arc centre as an xdata +/// point (`1010`/`1020`/`1030`), `1002 }`. The record itself stays a standard aligned dimension +/// (DXF has no arc-length DIMENSION type), so other programs still read it. +pub(crate) fn arclen_xdata(center: cadcraft_geom::Vec3) -> Vec { + vec![Tag::s(1000, "ARCLEN"), Tag::s(1002, "{"), Tag::f(1010, center.x), Tag::f(1020, center.y), Tag::f(1030, center.z), Tag::s(1002, "}")] +} + +/// The arc centre written by [`arclen_xdata`]; None for other dimensions and other writers. +pub(crate) fn read_arclen(tags: &[Tag]) -> Option { + let list = xdata_list(xdata(tags, APP), "ARCLEN"); + let g = |c: i32| list.iter().find(|t| t.code == c).map(Tag::f64).filter(|v| v.is_finite()); + Some(cadcraft_geom::Vec3::new(g(1010)?, g(1020)?, g(1030).unwrap_or(0.0))) +} + /// A viewport's frozen layer names as CADCraft xdata: `1000 VPFROZEN`, `1002 {`, one `1000` per layer, `1002 }`. pub(crate) fn frozen_xdata(layers: &[String]) -> Vec { let mut v = vec![Tag::s(1000, "VPFROZEN"), Tag::s(1002, "{")]; diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 131a7da1..d9c60743 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -263,6 +263,7 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { 4 => DimKind::Radius, 5 => DimKind::Angular3P, 6 => DimKind::Ordinate { x_type: t.i(70).unwrap_or(0) & 64 != 0 }, + 8 => DimKind::ArcLength, _ => DimKind::Aligned, }; EntityKind::Dimension(Dimension { @@ -551,6 +552,10 @@ fn entity_extras(kind: &str, tags: &[Tag], h: Handle, k: &mut EntityKind, rx: &m ("DIMENSION", EntityKind::Dimension(dm)) => { dm.overrides = crate::dxf_ext::read_dstyle(tags, &rx.styles, &rx.brs); dm.assoc = crate::dxf_ext::read_assoc(tags); + if let Some(center) = crate::dxf_ext::read_arclen(tags) { + dm.kind = DimKind::ArcLength; + dm.p15 = center; + } } ("ACAD_TABLE", EntityKind::Table(_)) => { let t = T(tags); diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 0650a515..b27a3c81 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -162,9 +162,16 @@ fn dim_xdata(w: &mut W, dm: &Dimension, cx: &Ctx) { w.xdata(ov.t); w.s(1002, "}"); } + let mut ours = Vec::new(); + if matches!(dm.kind, DimKind::ArcLength) { + ours.extend(dxf_ext::arclen_xdata(dm.p15)); + } if !dm.assoc.is_empty() { + ours.extend(dxf_ext::assoc_xdata(&dm.assoc)); + } + if !ours.is_empty() { w.s(1001, dxf_ext::APP); - w.xdata(dxf_ext::assoc_xdata(&dm.assoc)); + w.xdata(ours); } } @@ -555,6 +562,7 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx DimKind::Radius => 4, DimKind::Angular3P => 5, DimKind::Ordinate { x_type } => 6 | if x_type { 64 } else { 0 }, + // Written as aligned, marked by CADCraft xdata (see `dim_xdata`). DimKind::ArcLength => 1, }; w.i(70, ty | 32 | if dm.user_text_pos { 128 } else { 0 }); diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 9f11318d..96ffced5 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -1287,3 +1287,31 @@ fn dimension_text_rotation_roundtrips() { let rot = b.entities.iter().find_map(|e| if let EntityKind::MText(t) = &e.kind { Some(t.rotation) } else { None }); assert!(rot.is_some_and(|r| (r - dm.text_rotation).abs() < 1e-9), "block text rotation {rot:?}"); } + +#[test] +fn arc_length_dimension_keeps_its_kind_and_centre_on_roundtrip() { + let mut d = Drawing::new_metric(); + // Arc of radius 10 centred off the origin, so a missing centre would read back as zero. + let mut al = dim(DimKind::ArcLength, Vec3::new(15.0, 5.0, 0.0), Vec3::new(5.0, 15.0, 0.0)); + al.p15 = Vec3::new(5.0, 5.0, 0.0); + let h = d.add(&Space::Model, Default::default(), EntityKind::Dimension(al)).unwrap(); + let text = write_dxf(&d); + // A standard aligned DIMENSION record (type 1 | 32), so other programs can read it. + let lines: Vec<&str> = text.lines().map(str::trim).collect(); + assert!(lines.windows(2).any(|w| w == ["70", "33"]) && !lines.windows(2).any(|w| w == ["70", "40"]), "{text}"); + let back = read_dxf(text.as_bytes()).unwrap(); + match &back.entity(h).unwrap().kind { + EntityKind::Dimension(x) => { + assert!(matches!(x.kind, DimKind::ArcLength)); + assert_eq!(x.p15, Vec3::new(5.0, 5.0, 0.0)); + } + _ => panic!("not a dimension"), + } + // And through DWG. + let dwg = cadcraft_dwg::dxf_to_dwg(text.as_bytes()).unwrap(); + let back = read_dxf(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap().as_bytes()).unwrap(); + let dims: Vec<_> = back.model.iter().filter_map(|e| if let EntityKind::Dimension(x) = &e.kind { Some(x.clone()) } else { None }).collect(); + assert_eq!(dims.len(), 1); + assert!(matches!(dims[0].kind, DimKind::ArcLength), "{:?}", dims[0].kind); + assert!((dims[0].p15 - Vec3::new(5.0, 5.0, 0.0)).len() < 1e-9); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 56be5664..a864d434 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -100,6 +100,9 @@ pub struct CanvasState { pub cursor: Option, pub polar_angle: Option, pan_last: Option, + /// True when the latest scroll came from a trackpad (or other precise-scrolling device): + /// scrolling then pans and pinch zooms. A notched mouse wheel clears it so the wheel zooms. + scroll_pans: bool, /// A hot (clicked) grip being dragged: entity, grip index, grip position, mode. pub hot_grip: Option, /// Zoom to extents once the canvas size is known (after opening a drawing). @@ -511,7 +514,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { let (hover_pos, scroll, mods, middle_down, pressed_primary, pressed_secondary, dbl_middle, dbl_primary) = ui.input(|i| { ( i.pointer.hover_pos(), - i.smooth_scroll_delta.y, + i.smooth_scroll_delta, i.modifiers, i.pointer.middle_down(), i.pointer.primary_clicked(), @@ -521,14 +524,36 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { ) }); let inside = hover_pos.is_some_and(|p| rect.contains(p)) && resp.hovered(); - // Zoom with the wheel about the cursor. - if inside - && scroll.abs() > 0.0 - && let Some(hp) = hover_pos - { - let f = (f64::from(scroll) / 300.0).exp(); - let about = xf.to_world(hp); - let _ = app.session.zoom_about(f, about); + // Which device is scrolling? On macOS, trackpads (and Magic Mouse) report precise point + // deltas; notched mouse wheels report lines. The answer is sticky because egui spreads a wheel + // notch over several frames. Elsewhere (browsers, Wayland smooth-scroll mice) ordinary wheels + // also report points, so the wheel keeps zooming there. + ui.input(|i| { + for e in &i.raw.events { + if let egui::Event::MouseWheel { unit, .. } = e { + app.canvas.scroll_pans = cfg!(target_os = "macos") && *unit == egui::MouseWheelUnit::Point; + } + } + }); + if inside && scroll != egui::Vec2::ZERO { + if app.canvas.scroll_pans { + // Two-finger swipe pans: the drawing follows the fingers, like a middle-drag. + // Pinch (and Cmd+scroll) still zoom via `zoom_delta` below. + if let Ok(st) = app.session.state_mut() { + let v = st.view(); + st.set_view_quiet(cadcraft_engine::View { + center: v.center - Vec2::new(f64::from(scroll.x) / scale, -f64::from(scroll.y) / scale), + height: v.height, + }); + } + } else if scroll.y.abs() > 0.0 + && let Some(hp) = hover_pos + { + // Zoom with the mouse wheel about the cursor. + let f = (f64::from(scroll.y) / 300.0).exp(); + let about = xf.to_world(hp); + let _ = app.session.zoom_about(f, about); + } } let zoom_pinch = ui.input(|i| i.zoom_delta()); if inside @@ -537,7 +562,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { { let _ = app.session.zoom_about(f64::from(zoom_pinch), xf.to_world(hp)); } - // Pan with the middle button (or Shift+right-drag / two-finger drag on trackpads is scroll). + // Pan with the middle button (trackpads pan with a two-finger swipe, above). if middle_down && let Some(hp) = hover_pos { if let Some(last) = app.canvas.pan_last { let d = hp - last; From b731a395df154b2f26529806992bcdcd664967d5 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 18:58:33 +0200 Subject: [PATCH 011/150] Fonts: per-character fallback, FONTALT and .ttc support for CJK text in SHX styles (#173) * DIMANGULAR: accept polyline segments (fixes #10) Picking a side of a rectangle or a PLINE segment was rejected with "Select a line or an arc." because only LINE and ARC entities were accepted. The picked object is now resolved to the segment under the cursor: straight polyline segments work like lines and arc segments like arcs. * Redraw edits made while a command is still running (fixes #4) LINE and PLINE add geometry on every point, but the canvas only rebuilt its display list when the drawing revision or the document pointer changed. The revision was bumped when a command finished, and the pointer changed only on the first copy-on-write edit, so only the first segment appeared until the command ended. Session::feed now bumps the revision after each input once the running command has changed the drawing. * Fix #14: include DWG in the Open drawing filter * Blocks window: click a block to insert it (#8) Each block name in the Blocks window is now a button. Clicking it starts INSERT with that block already chosen, so the command line asks for the insertion point. * SCALE: Reference option (#17) SCALE > Reference printed "not available yet". It now asks for the reference length (typed, or two points) and the new length (typed, or a point measured from the base point), and scales by new / reference. Zero, negative or non-finite lengths are rejected and asked again. * Print and Ctrl+P ask where to save the PDF (#5) PLOT/EXPORTPDF started from the menu, the toolbar, Ctrl+P or the command line had no path, so the PDF was built in memory and dropped. They now open the save dialog (PDF filter first) and write the file there. JSON calls from scripts, the control channel and MCP still never open a dialog. Ctrl+P was shown in the menu but not bound; it is now. * Scripts: split a line at keyword prompts so POLYGON 6 200,20 I 15 works (#43) * Layer Properties Manager: stop the window growing every frame (fixes #29) The vertical separator between the filter tree and the table takes all the height it is offered, and a resizable window offers its previous height, so the window grew 30px per frame until it filled the screen. Cap the row at the table's fixed height. Regression test runs the dialog headless for 30 frames and checks the height stays put. Co-Authored-By: Claude Opus 5.5 * ui.render: fit the drawing and return pngBase64 like the headless render (#49, #50) * Linux: default to XWayland for file drag-and-drop on Wayland desktops * Fonts: per-character fallback, FONTALT and .ttc for CJK text in SHX styles (#130) CJK text in a style that uses an SHX font (romans.shx + chineset.shx big font) drew every ideograph as an empty box: the SHX name resolves to our stroke font, which has no CJK glyphs, and the font scan skipped .ttc collections, where most CJK system fonts live. - Per-character fallback: a character the text's font lacks (stroke font or TrueType) is drawn from an installed font that has it, checked by cmap. Wide-coverage fonts go first (Noto Sans CJK, Source Han, WenQuanYi, Droid Sans Fallback, PingFang, Microsoft JhengHei/YaHei, MS Gothic, ...), regular weights before others. Fallback glyphs are sized like TrueType text (cap height = text height). Lookups are cached; the search is bounded (24 files, 160 MiB, 256 faces per character, files over 96 MiB never read) and skips spaces, controls and private-use code points. No fallback: the box as before. - FONTALT (sysvar, seeded from CADCRAFT_FONTALT): font used for a style font that can't be found (an SHX file, a missing TTF). Empty = our stroke font. - FONTFALLBACK (sysvar, seeded from CADCRAFT_FONTFALLBACK): fonts tried first for missing characters, e.g. "Noto Sans CJK TC". - .ttc/.otc are scanned; TextFont::Outline carries a ttf::Face (bytes + face index). Fonts are also found by family name among files with a similar stem, so "Noto Sans CJK TC" picks face 3 of NotoSansCJK-Regular.ttc. - Cap height falls back to the height of "H" when OS/2 has none (DejaVu Sans), which fixes ttf_tests::system_font_outlines_when_available and render's truetype_text_is_filled_or_outlined on machines without Arial. - README: a Fonts section for the two settings; ROADMAP M4 note. Native SHX / big-font rendering stays on the roadmap (M4). Co-Authored-By: Claude Opus 5.5 * Fonts: cap the total bytes of system font files loaded from disk A drawing naming many text styles could otherwise load every installed font into memory through family-name lookups. Co-Authored-By: Claude Opus 5.5 * Pan with a two-finger trackpad swipe; keep wheel and pinch zoom Trackpad two-finger swipes arrive as scroll events, and the canvas turned every scroll into a zoom, so trackpad users had no realtime pan (middle-drag needs a middle button). Scroll now follows the device: precise point deltas (trackpad, Magic Mouse) pan the view by x and y; notched line deltas (mouse wheel) zoom about the cursor as before. Pinch and Cmd+scroll still zoom through zoom_delta, and middle-drag still pans. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019uMCkRAB8DMd8MEmoY9Vyr * Add Tru10 to contributors/people.toml Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019uMCkRAB8DMd8MEmoY9Vyr * Trackpad pan: only on macOS, where point deltas mean a trackpad Browsers and Wayland smooth-scroll mice report ordinary wheels in points too, so the wheel keeps zooming there. Co-Authored-By: Claude Opus 5.5 * Keep arc-length dimensions as arc-length through DXF save and reopen (#100) * PLINE: Halfwidth is centre-to-edge, and Width answers taper the next segment (fixes #83, #104) Interactive PLINE kept one width value: Halfwidth answers were stored as the total width, and the ending-width answer overwrote the starting one, so a taper became a constant width. The next segment's starting and ending widths are now tracked separately, Halfwidth answers are doubled, and each new segment (including the closing one) gets its widths on its first vertex; later segments are uniform at the ending width, as in AutoCAD. Uniform polylines are still stored as const_width, so their DXF output is unchanged. Fixes #83. Fixes #104. * Arc-length dimensions: write a standard aligned record plus CADCraft xdata DXF has no arc-length DIMENSION type, so write type 1 and mark the kind and arc centre in CADCRAFT xdata (like ASSOC). Other programs read an aligned dimension; CADCraft restores the arc length, through DXF and DWG. Co-Authored-By: Claude Opus 5.5 * Desktop: write log records to standard error and a rotating log file The desktop app installed no logger, so every log::warn!/log::error!/ log::info! in the workspace was discarded: a broken app icon, the GPU canvas set-up, the engine's command-line echo at debug level. A launch from a desktop menu has no terminal either, so there was nothing to attach to a bug report. apps/cadcraft/src/logging.rs (no new dependency): - standard error plus /logs/cadcraft.log; each launch rotates the previous log to cadcraft.1.log and .2.log, so the log of a run that crashed survives the next start; the file is capped at 16 MiB - records logged before the settings directory is known are buffered (bounded) and written once the file is attached - default levels: info for cadcraft*, warn for everything else; RUST_LOG replaces them with env_logger-style directives - never panics: an unusable directory or a failed write leaves standard error as the only sink; a poisoned lock is tolerated CADCraft had no per-user directory yet. config_dir in main.rs adds one: CADCRAFT_CONFIG_DIR, else $XDG_CONFIG_HOME/cadcraft or ~/.config/cadcraft (Linux, BSDs), ~/Library/Application Support/CADCraft (macOS), %APPDATA%\CADCraft (Windows). The logger is installed first in main and the file is opened after argument parsing (--version leaves no file). The control server's eprintln!s now go through the logger. A --control or CADCRAFT_CONTROL_PORT value that is not a port is now logged instead of silently leaving the control channel off. Documented in README.md (Quick start > Logs). --------- Co-authored-by: SBaksa Co-authored-by: DWAA1660 Co-authored-by: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Co-authored-by: lloyd <34131158+LloydNicholson@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Dylan M. Taylor Co-authored-by: Brandon Thomas Co-authored-by: Tru10 <133776756+Tru10@users.noreply.github.com> Co-authored-by: Gert Pellin From 35521ad9573d80b535cb1618bdbcd0c54394dcec Mon Sep 17 00:00:00 2001 From: by Truitt <133776756+Tru10@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:58:39 -0500 Subject: [PATCH 012/150] Trackpad pan (#58) * DIMANGULAR: accept polyline segments (fixes #10) Picking a side of a rectangle or a PLINE segment was rejected with "Select a line or an arc." because only LINE and ARC entities were accepted. The picked object is now resolved to the segment under the cursor: straight polyline segments work like lines and arc segments like arcs. * Redraw edits made while a command is still running (fixes #4) LINE and PLINE add geometry on every point, but the canvas only rebuilt its display list when the drawing revision or the document pointer changed. The revision was bumped when a command finished, and the pointer changed only on the first copy-on-write edit, so only the first segment appeared until the command ended. Session::feed now bumps the revision after each input once the running command has changed the drawing. * Fix #14: include DWG in the Open drawing filter * Blocks window: click a block to insert it (#8) Each block name in the Blocks window is now a button. Clicking it starts INSERT with that block already chosen, so the command line asks for the insertion point. * SCALE: Reference option (#17) SCALE > Reference printed "not available yet". It now asks for the reference length (typed, or two points) and the new length (typed, or a point measured from the base point), and scales by new / reference. Zero, negative or non-finite lengths are rejected and asked again. * Print and Ctrl+P ask where to save the PDF (#5) PLOT/EXPORTPDF started from the menu, the toolbar, Ctrl+P or the command line had no path, so the PDF was built in memory and dropped. They now open the save dialog (PDF filter first) and write the file there. JSON calls from scripts, the control channel and MCP still never open a dialog. Ctrl+P was shown in the menu but not bound; it is now. * Scripts: split a line at keyword prompts so POLYGON 6 200,20 I 15 works (#43) * Layer Properties Manager: stop the window growing every frame (fixes #29) The vertical separator between the filter tree and the table takes all the height it is offered, and a resizable window offers its previous height, so the window grew 30px per frame until it filled the screen. Cap the row at the table's fixed height. Regression test runs the dialog headless for 30 frames and checks the height stays put. Co-Authored-By: Claude Opus 5.5 * ui.render: fit the drawing and return pngBase64 like the headless render (#49, #50) * Linux: default to XWayland for file drag-and-drop on Wayland desktops * Fonts: per-character fallback, FONTALT and .ttc for CJK text in SHX styles (#130) CJK text in a style that uses an SHX font (romans.shx + chineset.shx big font) drew every ideograph as an empty box: the SHX name resolves to our stroke font, which has no CJK glyphs, and the font scan skipped .ttc collections, where most CJK system fonts live. - Per-character fallback: a character the text's font lacks (stroke font or TrueType) is drawn from an installed font that has it, checked by cmap. Wide-coverage fonts go first (Noto Sans CJK, Source Han, WenQuanYi, Droid Sans Fallback, PingFang, Microsoft JhengHei/YaHei, MS Gothic, ...), regular weights before others. Fallback glyphs are sized like TrueType text (cap height = text height). Lookups are cached; the search is bounded (24 files, 160 MiB, 256 faces per character, files over 96 MiB never read) and skips spaces, controls and private-use code points. No fallback: the box as before. - FONTALT (sysvar, seeded from CADCRAFT_FONTALT): font used for a style font that can't be found (an SHX file, a missing TTF). Empty = our stroke font. - FONTFALLBACK (sysvar, seeded from CADCRAFT_FONTFALLBACK): fonts tried first for missing characters, e.g. "Noto Sans CJK TC". - .ttc/.otc are scanned; TextFont::Outline carries a ttf::Face (bytes + face index). Fonts are also found by family name among files with a similar stem, so "Noto Sans CJK TC" picks face 3 of NotoSansCJK-Regular.ttc. - Cap height falls back to the height of "H" when OS/2 has none (DejaVu Sans), which fixes ttf_tests::system_font_outlines_when_available and render's truetype_text_is_filled_or_outlined on machines without Arial. - README: a Fonts section for the two settings; ROADMAP M4 note. Native SHX / big-font rendering stays on the roadmap (M4). Co-Authored-By: Claude Opus 5.5 * Fonts: cap the total bytes of system font files loaded from disk A drawing naming many text styles could otherwise load every installed font into memory through family-name lookups. Co-Authored-By: Claude Opus 5.5 * Pan with a two-finger trackpad swipe; keep wheel and pinch zoom Trackpad two-finger swipes arrive as scroll events, and the canvas turned every scroll into a zoom, so trackpad users had no realtime pan (middle-drag needs a middle button). Scroll now follows the device: precise point deltas (trackpad, Magic Mouse) pan the view by x and y; notched line deltas (mouse wheel) zoom about the cursor as before. Pinch and Cmd+scroll still zoom through zoom_delta, and middle-drag still pans. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019uMCkRAB8DMd8MEmoY9Vyr * Add Tru10 to contributors/people.toml Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019uMCkRAB8DMd8MEmoY9Vyr * Trackpad pan: only on macOS, where point deltas mean a trackpad Browsers and Wayland smooth-scroll mice report ordinary wheels in points too, so the wheel keeps zooming there. Co-Authored-By: Claude Opus 5.5 * Keep arc-length dimensions as arc-length through DXF save and reopen (#100) * PLINE: Halfwidth is centre-to-edge, and Width answers taper the next segment (fixes #83, #104) Interactive PLINE kept one width value: Halfwidth answers were stored as the total width, and the ending-width answer overwrote the starting one, so a taper became a constant width. The next segment's starting and ending widths are now tracked separately, Halfwidth answers are doubled, and each new segment (including the closing one) gets its widths on its first vertex; later segments are uniform at the ending width, as in AutoCAD. Uniform polylines are still stored as const_width, so their DXF output is unchanged. Fixes #83. Fixes #104. * Arc-length dimensions: write a standard aligned record plus CADCraft xdata DXF has no arc-length DIMENSION type, so write type 1 and mark the kind and arc centre in CADCRAFT xdata (like ASSOC). Other programs read an aligned dimension; CADCraft restores the arc length, through DXF and DWG. Co-Authored-By: Claude Opus 5.5 * Desktop: write log records to standard error and a rotating log file The desktop app installed no logger, so every log::warn!/log::error!/ log::info! in the workspace was discarded: a broken app icon, the GPU canvas set-up, the engine's command-line echo at debug level. A launch from a desktop menu has no terminal either, so there was nothing to attach to a bug report. apps/cadcraft/src/logging.rs (no new dependency): - standard error plus /logs/cadcraft.log; each launch rotates the previous log to cadcraft.1.log and .2.log, so the log of a run that crashed survives the next start; the file is capped at 16 MiB - records logged before the settings directory is known are buffered (bounded) and written once the file is attached - default levels: info for cadcraft*, warn for everything else; RUST_LOG replaces them with env_logger-style directives - never panics: an unusable directory or a failed write leaves standard error as the only sink; a poisoned lock is tolerated CADCraft had no per-user directory yet. config_dir in main.rs adds one: CADCRAFT_CONFIG_DIR, else $XDG_CONFIG_HOME/cadcraft or ~/.config/cadcraft (Linux, BSDs), ~/Library/Application Support/CADCraft (macOS), %APPDATA%\CADCraft (Windows). The logger is installed first in main and the file is opened after argument parsing (--version leaves no file). The control server's eprintln!s now go through the logger. A --control or CADCRAFT_CONTROL_PORT value that is not a port is now logged instead of silently leaving the control channel off. Documented in README.md (Quick start > Logs). --------- Co-authored-by: SBaksa Co-authored-by: DWAA1660 Co-authored-by: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Co-authored-by: lloyd <34131158+LloydNicholson@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Dylan M. Taylor Co-authored-by: vangel76 Co-authored-by: Brandon Thomas Co-authored-by: Gert Pellin From a82f6ff3069ad8adc9b8906509d754a12a289a73 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:58:45 +0200 Subject: [PATCH 013/150] Keep arc-length dimensions as arc-length through DXF save and reopen (#100) (#140) * DIMANGULAR: accept polyline segments (fixes #10) Picking a side of a rectangle or a PLINE segment was rejected with "Select a line or an arc." because only LINE and ARC entities were accepted. The picked object is now resolved to the segment under the cursor: straight polyline segments work like lines and arc segments like arcs. * Redraw edits made while a command is still running (fixes #4) LINE and PLINE add geometry on every point, but the canvas only rebuilt its display list when the drawing revision or the document pointer changed. The revision was bumped when a command finished, and the pointer changed only on the first copy-on-write edit, so only the first segment appeared until the command ended. Session::feed now bumps the revision after each input once the running command has changed the drawing. * Fix #14: include DWG in the Open drawing filter * Blocks window: click a block to insert it (#8) Each block name in the Blocks window is now a button. Clicking it starts INSERT with that block already chosen, so the command line asks for the insertion point. * SCALE: Reference option (#17) SCALE > Reference printed "not available yet". It now asks for the reference length (typed, or two points) and the new length (typed, or a point measured from the base point), and scales by new / reference. Zero, negative or non-finite lengths are rejected and asked again. * Print and Ctrl+P ask where to save the PDF (#5) PLOT/EXPORTPDF started from the menu, the toolbar, Ctrl+P or the command line had no path, so the PDF was built in memory and dropped. They now open the save dialog (PDF filter first) and write the file there. JSON calls from scripts, the control channel and MCP still never open a dialog. Ctrl+P was shown in the menu but not bound; it is now. * Scripts: split a line at keyword prompts so POLYGON 6 200,20 I 15 works (#43) * Layer Properties Manager: stop the window growing every frame (fixes #29) The vertical separator between the filter tree and the table takes all the height it is offered, and a resizable window offers its previous height, so the window grew 30px per frame until it filled the screen. Cap the row at the table's fixed height. Regression test runs the dialog headless for 30 frames and checks the height stays put. Co-Authored-By: Claude Opus 5.5 * ui.render: fit the drawing and return pngBase64 like the headless render (#49, #50) * Linux: default to XWayland for file drag-and-drop on Wayland desktops * Fonts: per-character fallback, FONTALT and .ttc for CJK text in SHX styles (#130) CJK text in a style that uses an SHX font (romans.shx + chineset.shx big font) drew every ideograph as an empty box: the SHX name resolves to our stroke font, which has no CJK glyphs, and the font scan skipped .ttc collections, where most CJK system fonts live. - Per-character fallback: a character the text's font lacks (stroke font or TrueType) is drawn from an installed font that has it, checked by cmap. Wide-coverage fonts go first (Noto Sans CJK, Source Han, WenQuanYi, Droid Sans Fallback, PingFang, Microsoft JhengHei/YaHei, MS Gothic, ...), regular weights before others. Fallback glyphs are sized like TrueType text (cap height = text height). Lookups are cached; the search is bounded (24 files, 160 MiB, 256 faces per character, files over 96 MiB never read) and skips spaces, controls and private-use code points. No fallback: the box as before. - FONTALT (sysvar, seeded from CADCRAFT_FONTALT): font used for a style font that can't be found (an SHX file, a missing TTF). Empty = our stroke font. - FONTFALLBACK (sysvar, seeded from CADCRAFT_FONTFALLBACK): fonts tried first for missing characters, e.g. "Noto Sans CJK TC". - .ttc/.otc are scanned; TextFont::Outline carries a ttf::Face (bytes + face index). Fonts are also found by family name among files with a similar stem, so "Noto Sans CJK TC" picks face 3 of NotoSansCJK-Regular.ttc. - Cap height falls back to the height of "H" when OS/2 has none (DejaVu Sans), which fixes ttf_tests::system_font_outlines_when_available and render's truetype_text_is_filled_or_outlined on machines without Arial. - README: a Fonts section for the two settings; ROADMAP M4 note. Native SHX / big-font rendering stays on the roadmap (M4). Co-Authored-By: Claude Opus 5.5 * Fonts: cap the total bytes of system font files loaded from disk A drawing naming many text styles could otherwise load every installed font into memory through family-name lookups. Co-Authored-By: Claude Opus 5.5 * Pan with a two-finger trackpad swipe; keep wheel and pinch zoom Trackpad two-finger swipes arrive as scroll events, and the canvas turned every scroll into a zoom, so trackpad users had no realtime pan (middle-drag needs a middle button). Scroll now follows the device: precise point deltas (trackpad, Magic Mouse) pan the view by x and y; notched line deltas (mouse wheel) zoom about the cursor as before. Pinch and Cmd+scroll still zoom through zoom_delta, and middle-drag still pans. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019uMCkRAB8DMd8MEmoY9Vyr * Add Tru10 to contributors/people.toml Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019uMCkRAB8DMd8MEmoY9Vyr * Trackpad pan: only on macOS, where point deltas mean a trackpad Browsers and Wayland smooth-scroll mice report ordinary wheels in points too, so the wheel keeps zooming there. Co-Authored-By: Claude Opus 5.5 * Keep arc-length dimensions as arc-length through DXF save and reopen (#100) * PLINE: Halfwidth is centre-to-edge, and Width answers taper the next segment (fixes #83, #104) Interactive PLINE kept one width value: Halfwidth answers were stored as the total width, and the ending-width answer overwrote the starting one, so a taper became a constant width. The next segment's starting and ending widths are now tracked separately, Halfwidth answers are doubled, and each new segment (including the closing one) gets its widths on its first vertex; later segments are uniform at the ending width, as in AutoCAD. Uniform polylines are still stored as const_width, so their DXF output is unchanged. Fixes #83. Fixes #104. * Arc-length dimensions: write a standard aligned record plus CADCraft xdata DXF has no arc-length DIMENSION type, so write type 1 and mark the kind and arc centre in CADCRAFT xdata (like ASSOC). Other programs read an aligned dimension; CADCraft restores the arc length, through DXF and DWG. Co-Authored-By: Claude Opus 5.5 * Desktop: write log records to standard error and a rotating log file The desktop app installed no logger, so every log::warn!/log::error!/ log::info! in the workspace was discarded: a broken app icon, the GPU canvas set-up, the engine's command-line echo at debug level. A launch from a desktop menu has no terminal either, so there was nothing to attach to a bug report. apps/cadcraft/src/logging.rs (no new dependency): - standard error plus /logs/cadcraft.log; each launch rotates the previous log to cadcraft.1.log and .2.log, so the log of a run that crashed survives the next start; the file is capped at 16 MiB - records logged before the settings directory is known are buffered (bounded) and written once the file is attached - default levels: info for cadcraft*, warn for everything else; RUST_LOG replaces them with env_logger-style directives - never panics: an unusable directory or a failed write leaves standard error as the only sink; a poisoned lock is tolerated CADCraft had no per-user directory yet. config_dir in main.rs adds one: CADCRAFT_CONFIG_DIR, else $XDG_CONFIG_HOME/cadcraft or ~/.config/cadcraft (Linux, BSDs), ~/Library/Application Support/CADCraft (macOS), %APPDATA%\CADCraft (Windows). The logger is installed first in main and the file is opened after argument parsing (--version leaves no file). The control server's eprintln!s now go through the logger. A --control or CADCRAFT_CONTROL_PORT value that is not a port is now logged instead of silently leaving the control channel off. Documented in README.md (Quick start > Logs). --------- Co-authored-by: SBaksa Co-authored-by: DWAA1660 Co-authored-by: lloyd <34131158+LloydNicholson@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Dylan M. Taylor Co-authored-by: vangel76 Co-authored-by: Brandon Thomas Co-authored-by: Tru10 <133776756+Tru10@users.noreply.github.com> Co-authored-by: Gert Pellin From bd2412f49511dadc6b131dc086126784ffce524e Mon Sep 17 00:00:00 2001 From: Books Date: Sun, 11 Oct 2026 03:58:51 +1100 Subject: [PATCH 014/150] PLINE: Halfwidth is centre-to-edge, and Width answers taper the next segment (fixes #83, #104) (#121) * DIMANGULAR: accept polyline segments (fixes #10) Picking a side of a rectangle or a PLINE segment was rejected with "Select a line or an arc." because only LINE and ARC entities were accepted. The picked object is now resolved to the segment under the cursor: straight polyline segments work like lines and arc segments like arcs. * Redraw edits made while a command is still running (fixes #4) LINE and PLINE add geometry on every point, but the canvas only rebuilt its display list when the drawing revision or the document pointer changed. The revision was bumped when a command finished, and the pointer changed only on the first copy-on-write edit, so only the first segment appeared until the command ended. Session::feed now bumps the revision after each input once the running command has changed the drawing. * Fix #14: include DWG in the Open drawing filter * Blocks window: click a block to insert it (#8) Each block name in the Blocks window is now a button. Clicking it starts INSERT with that block already chosen, so the command line asks for the insertion point. * SCALE: Reference option (#17) SCALE > Reference printed "not available yet". It now asks for the reference length (typed, or two points) and the new length (typed, or a point measured from the base point), and scales by new / reference. Zero, negative or non-finite lengths are rejected and asked again. * Print and Ctrl+P ask where to save the PDF (#5) PLOT/EXPORTPDF started from the menu, the toolbar, Ctrl+P or the command line had no path, so the PDF was built in memory and dropped. They now open the save dialog (PDF filter first) and write the file there. JSON calls from scripts, the control channel and MCP still never open a dialog. Ctrl+P was shown in the menu but not bound; it is now. * Scripts: split a line at keyword prompts so POLYGON 6 200,20 I 15 works (#43) * Layer Properties Manager: stop the window growing every frame (fixes #29) The vertical separator between the filter tree and the table takes all the height it is offered, and a resizable window offers its previous height, so the window grew 30px per frame until it filled the screen. Cap the row at the table's fixed height. Regression test runs the dialog headless for 30 frames and checks the height stays put. Co-Authored-By: Claude Opus 5.5 * ui.render: fit the drawing and return pngBase64 like the headless render (#49, #50) * Linux: default to XWayland for file drag-and-drop on Wayland desktops * Fonts: per-character fallback, FONTALT and .ttc for CJK text in SHX styles (#130) CJK text in a style that uses an SHX font (romans.shx + chineset.shx big font) drew every ideograph as an empty box: the SHX name resolves to our stroke font, which has no CJK glyphs, and the font scan skipped .ttc collections, where most CJK system fonts live. - Per-character fallback: a character the text's font lacks (stroke font or TrueType) is drawn from an installed font that has it, checked by cmap. Wide-coverage fonts go first (Noto Sans CJK, Source Han, WenQuanYi, Droid Sans Fallback, PingFang, Microsoft JhengHei/YaHei, MS Gothic, ...), regular weights before others. Fallback glyphs are sized like TrueType text (cap height = text height). Lookups are cached; the search is bounded (24 files, 160 MiB, 256 faces per character, files over 96 MiB never read) and skips spaces, controls and private-use code points. No fallback: the box as before. - FONTALT (sysvar, seeded from CADCRAFT_FONTALT): font used for a style font that can't be found (an SHX file, a missing TTF). Empty = our stroke font. - FONTFALLBACK (sysvar, seeded from CADCRAFT_FONTFALLBACK): fonts tried first for missing characters, e.g. "Noto Sans CJK TC". - .ttc/.otc are scanned; TextFont::Outline carries a ttf::Face (bytes + face index). Fonts are also found by family name among files with a similar stem, so "Noto Sans CJK TC" picks face 3 of NotoSansCJK-Regular.ttc. - Cap height falls back to the height of "H" when OS/2 has none (DejaVu Sans), which fixes ttf_tests::system_font_outlines_when_available and render's truetype_text_is_filled_or_outlined on machines without Arial. - README: a Fonts section for the two settings; ROADMAP M4 note. Native SHX / big-font rendering stays on the roadmap (M4). Co-Authored-By: Claude Opus 5.5 * Fonts: cap the total bytes of system font files loaded from disk A drawing naming many text styles could otherwise load every installed font into memory through family-name lookups. Co-Authored-By: Claude Opus 5.5 * Pan with a two-finger trackpad swipe; keep wheel and pinch zoom Trackpad two-finger swipes arrive as scroll events, and the canvas turned every scroll into a zoom, so trackpad users had no realtime pan (middle-drag needs a middle button). Scroll now follows the device: precise point deltas (trackpad, Magic Mouse) pan the view by x and y; notched line deltas (mouse wheel) zoom about the cursor as before. Pinch and Cmd+scroll still zoom through zoom_delta, and middle-drag still pans. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019uMCkRAB8DMd8MEmoY9Vyr * Add Tru10 to contributors/people.toml Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019uMCkRAB8DMd8MEmoY9Vyr * Trackpad pan: only on macOS, where point deltas mean a trackpad Browsers and Wayland smooth-scroll mice report ordinary wheels in points too, so the wheel keeps zooming there. Co-Authored-By: Claude Opus 5.5 * Keep arc-length dimensions as arc-length through DXF save and reopen (#100) * PLINE: Halfwidth is centre-to-edge, and Width answers taper the next segment (fixes #83, #104) Interactive PLINE kept one width value: Halfwidth answers were stored as the total width, and the ending-width answer overwrote the starting one, so a taper became a constant width. The next segment's starting and ending widths are now tracked separately, Halfwidth answers are doubled, and each new segment (including the closing one) gets its widths on its first vertex; later segments are uniform at the ending width, as in AutoCAD. Uniform polylines are still stored as const_width, so their DXF output is unchanged. Fixes #83. Fixes #104. * Arc-length dimensions: write a standard aligned record plus CADCraft xdata DXF has no arc-length DIMENSION type, so write type 1 and mark the kind and arc centre in CADCRAFT xdata (like ASSOC). Other programs read an aligned dimension; CADCraft restores the arc length, through DXF and DWG. Co-Authored-By: Claude Opus 5.5 * Desktop: write log records to standard error and a rotating log file The desktop app installed no logger, so every log::warn!/log::error!/ log::info! in the workspace was discarded: a broken app icon, the GPU canvas set-up, the engine's command-line echo at debug level. A launch from a desktop menu has no terminal either, so there was nothing to attach to a bug report. apps/cadcraft/src/logging.rs (no new dependency): - standard error plus /logs/cadcraft.log; each launch rotates the previous log to cadcraft.1.log and .2.log, so the log of a run that crashed survives the next start; the file is capped at 16 MiB - records logged before the settings directory is known are buffered (bounded) and written once the file is attached - default levels: info for cadcraft*, warn for everything else; RUST_LOG replaces them with env_logger-style directives - never panics: an unusable directory or a failed write leaves standard error as the only sink; a poisoned lock is tolerated CADCraft had no per-user directory yet. config_dir in main.rs adds one: CADCRAFT_CONFIG_DIR, else $XDG_CONFIG_HOME/cadcraft or ~/.config/cadcraft (Linux, BSDs), ~/Library/Application Support/CADCraft (macOS), %APPDATA%\CADCraft (Windows). The logger is installed first in main and the file is opened after argument parsing (--version leaves no file). The control server's eprintln!s now go through the logger. A --control or CADCRAFT_CONTROL_PORT value that is not a port is now logged instead of silently leaving the control channel off. Documented in README.md (Quick start > Logs). --------- Co-authored-by: DWAA1660 Co-authored-by: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Co-authored-by: lloyd <34131158+LloydNicholson@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Dylan M. Taylor Co-authored-by: vangel76 Co-authored-by: Brandon Thomas Co-authored-by: Tru10 <133776756+Tru10@users.noreply.github.com> Co-authored-by: Gert Pellin From 8fceaaff3aa16df742320e36f6878413b2dc799b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 13:04:59 -0400 Subject: [PATCH 015/150] Fix the arc-length DWG round-trip test build; cargo fmt The squash for #159 picked up an earlier revision of the #140 test, which called as_bytes() on a Vec. Also apply rustfmt to code from #120 and #155. Co-Authored-By: Claude Opus 5.5 --- apps/cadcraft-cli/src/main.rs | 1 - crates/engine/src/cmd/inquiry.rs | 5 ++--- crates/io/src/tests.rs | 9 +++------ 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs index 661bba7c..88e2446f 100644 --- a/apps/cadcraft-cli/src/main.rs +++ b/apps/cadcraft-cli/src/main.rs @@ -705,7 +705,6 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - fn args(values: &[&str]) -> Vec { values.iter().map(|value| (*value).into()).collect() } diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index a2e2c0a1..e9550ca0 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -199,9 +199,8 @@ fn run_area(s: &mut Session, p: &Value) -> Result { let mut total = (0.0, 0.0); for (i, g) in geoms.iter().enumerate() { let probe = polys.get(i).and_then(|p| p.first().copied()); - let depth = probe.map_or(0, |pt| { - polys.iter().enumerate().filter(|(j, q)| *j != i && cadcraft_geom::point_in_polygon(q, pt)).count() - }); + let depth = + probe.map_or(0, |pt| polys.iter().enumerate().filter(|(j, q)| *j != i && cadcraft_geom::point_in_polygon(q, pt)).count()); total.0 += if depth % 2 == 0 { g.area().abs() } else { -g.area().abs() }; total.1 += g.len(); } diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 96ffced5..1daba594 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -1265,11 +1265,8 @@ fn attdef_prompt_survives_dxf_roundtrip() { d.add(&Space::Model, Default::default(), attdef("Enter value")).unwrap(); d.add(&Space::Model, Default::default(), attdef("")).unwrap(); let back = roundtrip(&d); - let prompts: Vec<&str> = back - .model - .iter() - .filter_map(|e| if let EntityKind::AttDef(a) = &e.kind { Some(a.prompt.as_str()) } else { None }) - .collect(); + let prompts: Vec<&str> = + back.model.iter().filter_map(|e| if let EntityKind::AttDef(a) = &e.kind { Some(a.prompt.as_str()) } else { None }).collect(); assert_eq!(prompts, vec!["Enter value", ""]); } @@ -1309,7 +1306,7 @@ fn arc_length_dimension_keeps_its_kind_and_centre_on_roundtrip() { } // And through DWG. let dwg = cadcraft_dwg::dxf_to_dwg(text.as_bytes()).unwrap(); - let back = read_dxf(cadcraft_dwg::dwg_to_dxf(&dwg).unwrap().as_bytes()).unwrap(); + let back = read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap(); let dims: Vec<_> = back.model.iter().filter_map(|e| if let EntityKind::Dimension(x) = &e.kind { Some(x.clone()) } else { None }).collect(); assert_eq!(dims.len(), 1); assert!(matches!(dims[0].kind, DimKind::ArcLength), "{:?}", dims[0].kind); From f0d6f248f70a21a90bfc0a80963d21b0592e587f Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 19:14:52 +0200 Subject: [PATCH 016/150] DXF/DWG: keep hatch gradients through save and reopen (fixes #80) (#162) * DXF/DWG: keep hatch gradients through save and reopen (fixes #80) The HATCH writer never emitted a gradient and the reader always set `gradient: None`, so a GRADIENT fill reopened as a plain SOLID hatch after any DXF or DWG save. - Writer: native HATCH groups per the DXF Reference (450 gradient flag, 451, 452 two-colour, 453 colour count, 460 angle in radians, 461 shift (0 = centered), 462 tint, 463 per-colour records with 63 ACI and 421 true colour, 470 name), plus a `CADCRAFT` `GRADIENT` xdata copy (name cut to 255 bytes, angle, centered, colour names). - Reader: native groups win when present (another program may have edited them); otherwise the xdata copy is used. The xdata is what survives DWG saves: the file is R2000 (AC1015), whose DWG HATCH has no gradient fields, so the DWG bridge drops the native groups. - Hostile values (NaN angles, out-of-range colours, extra colour records, missing groups) fall back to the GRADIENT command's defaults. Tests: gradient_hatch_roundtrips_through_dxf, gradient_hatch_roundtrips_through_dwg, gradient_falls_back_to_xdata_and_native_groups_win, long_gradient_name_is_cut_only_in_xdata, hostile_gradient_groups_never_panic. Co-Authored-By: Claude Opus 5.5 * contributors: add vangel76 Co-Authored-By: Claude Opus 5.5 * Gradient test: expect \U+ escapes now that non-ASCII text is escaped (#169) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- contributors/people.toml | 3 + crates/io/src/dxf_ext.rs | 3 +- crates/io/src/dxf_read.rs | 68 +++++++++++++++- crates/io/src/dxf_write.rs | 38 +++++++++ crates/io/src/tests.rs | 163 +++++++++++++++++++++++++++++++++++++ 5 files changed, 273 insertions(+), 2 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 8ef40d37..114c3adb 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -25,3 +25,6 @@ sync_github_name = false [people.Tru10] display_name = "by Truitt" sync_github_name = false + +[people.vangel76] +sync_github_name = true diff --git a/crates/io/src/dxf_ext.rs b/crates/io/src/dxf_ext.rs index a7af272f..3adafabe 100644 --- a/crates/io/src/dxf_ext.rs +++ b/crates/io/src/dxf_ext.rs @@ -1,7 +1,8 @@ //! DXF data shared by the reader and the writer beyond plain entity geometry: //! dimension-variable group codes (DIMSTYLE records and the `ACAD` "DSTYLE" override xdata, //! both per the DXF Reference), arrowhead block names, extended-data helpers and the -//! CADCraft-owned payloads (exact associativity, table flags, parametric constraints). +//! CADCraft-owned payloads (exact associativity, table flags, hatch gradients, parametric +//! constraints). //! //! CADCraft's own data lives under the registered application `CADCRAFT` (xdata) and the //! named-object-dictionary entry `CADCRAFT_CONSTRAINTS` (an XRECORD). Other readers keep or diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index d9c60743..7fff942a 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -524,12 +524,78 @@ fn hatch(tags: &[Tag], t: &T) -> Option { associative: t.i(71).unwrap_or(0) != 0, style: t.i(75).unwrap_or(0) as u8, elevation: 0.0, - gradient: None, + gradient: gradient(tags), origin: Vec2::ZERO, background: None, })) } +/// Gradient values missing from a file fall back to the GRADIENT command's defaults. +fn default_gradient() -> Gradient { + Gradient { name: "LINEAR".into(), color1: Color::Index(5), color2: Color::Index(7), angle: 0.0, centered: true } +} + +/// A hatch's gradient fill: the native groups when present (another program may have edited +/// them), otherwise CADCraft's `GRADIENT` xdata (kept by R2000 files and DWG conversions). +fn gradient(tags: &[Tag]) -> Option { + if tags.iter().any(|x| x.code == 450) { native_gradient(tags) } else { xdata_gradient(tags) } +} + +/// `CADCRAFT` xdata: `1000 GRADIENT`, then name, 1040 angle, 1070 centered, colour 1 and 2 names. +fn xdata_gradient(tags: &[Tag]) -> Option { + let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP); + let at = x.iter().position(|t| t.code == 1000 && t.str() == "GRADIENT")?; + let o = x.get(at + 1..).unwrap_or(&[]); + let d = default_gradient(); + let strs: Vec = o.iter().filter(|t| t.code == 1000).take(3).map(Tag::str).collect(); + let color = |i: usize, d: Color| strs.get(i).and_then(|s| Color::parse(s)).unwrap_or(d); + Some(Gradient { + name: strs.first().cloned().unwrap_or(d.name), + color1: color(1, d.color1), + color2: color(2, d.color2), + angle: o.iter().find(|t| t.code == 1040).map(Tag::f64).filter(|a| a.is_finite()).unwrap_or(d.angle), + centered: o.iter().find(|t| t.code == 1070).map_or(d.centered, |t| t.i64() != 0), + }) +} + +/// DXF Reference HATCH groups 450–470, when 450 says gradient. Each 463 starts a colour record +/// whose 63 (ACI) and 421 (true colour) groups follow. +fn native_gradient(tags: &[Tag]) -> Option { + let start = tags.iter().position(|x| x.code == 450)?; + if tags.get(start)?.i64() != 1 { + return None; + } + let mut g = default_gradient(); + // Per colour record: (ACI, true colour); a true colour wins over its ACI fallback. + let mut colors: [(Option, Option); 2] = [(None, None); 2]; + let mut slot: Option = None; + for x in tags.iter().skip(start + 1).take_while(|x| x.code != 1001) { + let rec = slot.and_then(|s| colors.get_mut(s)); + match x.code { + 460 => g.angle = Some(x.f64()).filter(|a| a.is_finite()).unwrap_or(0.0), + 461 => g.centered = x.f64().abs() < 1e-9, + 463 => slot = Some(slot.map_or(0, |s| s.saturating_add(1))), + 63 => { + if let Some(r) = rec { + r.0 = Some(Color::from_aci(i16::try_from(x.i64()).unwrap_or(256))); + } + } + 421 => { + if let Some(r) = rec { + r.1 = Some(Rgb::from_u32(u32::try_from(x.i64() & 0xff_ffff).unwrap_or(0))); + } + } + 470 => g.name = x.str(), + _ => {} + } + } + let [c1, c2] = colors; + let pick = |(aci, rgb): (Option, Option), d: Color| rgb.map(Color::True).or(aci).unwrap_or(d); + g.color1 = pick(c1, g.color1); + g.color2 = pick(c2, g.color2); + Some(g) +} + /// Handle maps and references gathered while reading. #[derive(Default)] struct Rx { diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index b27a3c81..09c97858 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -777,6 +777,9 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx } } w.i(98, 0); + if let Some(g) = &h.gradient { + gradient(w, g); + } } EntityKind::Viewport(v) => { w.s(0, "VIEWPORT"); @@ -799,6 +802,41 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx } } +/// A hatch's gradient fill (DXF Reference, HATCH group codes 450–470): two-colour gradient +/// with its rotation (radians), shift (0 = centered, 1 = shifted) and per-colour 463 records +/// carrying ACI (63) and, for true colours, the RGB value (421). Those groups belong to R2004+ +/// files, so the same gradient also travels as `CADCRAFT` xdata, which survives this R2000 +/// file's conversion to DWG (where the native fields don't exist). +fn gradient(w: &mut W, g: &Gradient) { + w.i(450, 1); + w.i(451, 0); + w.f(460, g.angle); + w.f(461, if g.centered { 0.0 } else { 1.0 }); + w.i(452, 0); + w.f(462, 0.0); + w.i(453, 2); + for (k, c) in [(0.0, g.color1), (1.0, g.color2)] { + w.f(463, k); + match c { + Color::True(rgb) => { + w.i(63, i64::from(cadcraft_color::nearest_aci(rgb))); + w.i(421, i64::from(rgb.to_u32())); + } + c => w.i(63, i64::from(c.to_aci())), + } + } + w.s(470, &g.name); + w.s(1001, dxf_ext::APP); + w.s(1000, "GRADIENT"); + // A 1000 group holds at most 255 bytes; cut at a character boundary. + let cut = g.name.char_indices().map(|(i, c)| i + c.len_utf8()).take_while(|end| *end <= 255).last().unwrap_or(0); + w.s(1000, g.name.get(..cut).unwrap_or_default()); + w.f(1040, g.angle); + w.i(1070, i64::from(g.centered)); + w.s(1000, g.color1.name()); + w.s(1000, g.color2.name()); +} + /// Anonymous dimension blocks (`*D1`…) with the rendered geometry, as consumers expect. fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec { let style = d.dim_style(&dm.style).cloned().unwrap_or_default(); diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 1daba594..febd26c2 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -1312,3 +1312,166 @@ fn arc_length_dimension_keeps_its_kind_and_centre_on_roundtrip() { assert!(matches!(dims[0].kind, DimKind::ArcLength), "{:?}", dims[0].kind); assert!((dims[0].p15 - Vec3::new(5.0, 5.0, 0.0)).len() < 1e-9); } + +/// Two gradient hatches (true colours, shifted, rotated; index colours, centered) and a plain +/// solid hatch over the same square. +fn gradient_sample() -> (Drawing, Vec>) { + let mut d = Drawing::new_metric(); + let square = vec![ + PolyVertex::new(Vec2::ZERO), + PolyVertex::new(Vec2::new(10.0, 0.0)), + PolyVertex::new(Vec2::new(10.0, 10.0)), + PolyVertex::new(Vec2::new(0.0, 10.0)), + ]; + let gradients = vec![ + Some(Gradient { + name: "LINEAR".into(), + color1: Color::True(cadcraft_color::Rgb(255, 0, 0)), + color2: Color::True(cadcraft_color::Rgb(0, 0, 255)), + angle: 30f64.to_radians(), + centered: false, + }), + Some(Gradient { name: "CURVED".into(), color1: Color::Index(5), color2: Color::ByLayer, angle: 0.0, centered: true }), + None, + ]; + for g in &gradients { + let h = Hatch { + pattern: "SOLID".into(), + solid: true, + loops: vec![HatchLoop { vertices: square.clone(), outer: true }], + scale: 1.0, + angle: 0.0, + associative: false, + style: 0, + elevation: 0.0, + gradient: g.clone(), + origin: Vec2::ZERO, + background: None, + }; + d.add(&Space::Model, Common::default(), EntityKind::Hatch(h)).unwrap(); + } + (d, gradients) +} + +fn hatches(d: &Drawing) -> Vec { + d.model.iter().filter_map(|e| if let EntityKind::Hatch(h) = &e.kind { Some(h.clone()) } else { None }).collect() +} + +/// Issue #80: a DXF save/reopen keeps each hatch's gradient (name, both colours, angle and +/// centered setting) as native HATCH groups 450–470, and a plain solid stays plain. +#[test] +fn gradient_hatch_roundtrips_through_dxf() { + let (d, gradients) = gradient_sample(); + let text = write_dxf(&d); + for code in ["450", "451", "452", "453", "460", "461", "462", "463", "421", "470"] { + assert!(text.lines().any(|l| l.trim() == code), "missing group {code}"); + } + let back = read_dxf(text.as_bytes()).unwrap(); + let (a, b) = (hatches(&d), hatches(&back)); + assert_eq!(b.len(), gradients.len()); + for ((x, y), g) in a.iter().zip(&b).zip(&gradients) { + assert_eq!(&y.gradient, g); + assert_eq!(x.loops, y.loops); + } + assert_eq!(roundtrip(&back).model.iter().map(|e| &e.kind).collect::>(), back.model.iter().map(|e| &e.kind).collect::>()); +} + +/// R2000 readers and DWG conversions drop the native groups; CADCraft's xdata copy restores the +/// gradient. When native groups are present (another program may have edited them) they win. +#[test] +fn gradient_falls_back_to_xdata_and_native_groups_win() { + let (d, gradients) = gradient_sample(); + let text = write_dxf(&d); + // Strip groups 450–470 and 63/421 (pairs of lines) from every entity. + let lines: Vec<&str> = text.lines().collect(); + let mut stripped = String::new(); + for pair in lines.chunks(2) { + let code = pair.first().map(|c| c.trim().parse::().unwrap_or(-1)).unwrap_or(-1); + if (450..=470).contains(&code) || code == 63 || code == 421 { + continue; + } + for l in pair { + stripped.push_str(l); + stripped.push('\n'); + } + } + assert!(!stripped.lines().any(|l| l.trim() == "450")); + let back = read_dxf(stripped.as_bytes()).unwrap(); + assert_eq!(hatches(&back).into_iter().map(|h| h.gradient).collect::>(), gradients); + + // Native groups edited elsewhere (new angle, colour, name) take precedence over stale xdata. + let edited = text.replacen("470\r\nLINEAR", "470\r\nSPHERICAL", 1).replacen("421\r\n16711680", "421\r\n65280", 1); + assert_ne!(edited, text); + let back = read_dxf(edited.as_bytes()).unwrap(); + let g = hatches(&back).into_iter().next().and_then(|h| h.gradient).unwrap(); + assert_eq!(g.name, "SPHERICAL"); + assert_eq!(g.color1, Color::True(cadcraft_color::Rgb(0, 255, 0))); +} + +/// Issue #80: DWG saves (R2000, which has no native gradient fields) keep the gradient too. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gradient_hatch_roundtrips_through_dwg() { + let (d, gradients) = gradient_sample(); + let back = read(&write(&d, "x.dwg").unwrap(), "x.dwg").unwrap(); + let hs = hatches(&back); + assert_eq!(hs.iter().map(|h| h.gradient.clone()).collect::>(), gradients); + assert_eq!(hs.iter().map(|h| h.loops.clone()).collect::>(), hatches(&d).into_iter().map(|h| h.loops).collect::>()); +} + +#[test] +fn hostile_gradient_groups_never_panic() { + let hatch = |tail: &str| { + format!( + "0\nSECTION\n2\nENTITIES\n0\nHATCH\n2\nSOLID\n70\n1\n91\n1\n92\n2\n72\n0\n73\n1\n93\n3\n10\n0\n20\n0\n10\n1\n20\n0\n10\n1\n20\n1\n98\n0\n{tail}0\nENDSEC\n0\nEOF\n" + ) + }; + let cases = [ + // Native: absurd counts, colour records past two, out-of-range ACI/true colours, NaN. + hatch( + "450\n1\n453\n2147483647\n460\nnan\n461\ninf\n463\n0\n63\n99999\n421\n-1\n463\n1\n63\n-32768\n421\n99999999999\n463\n2\n63\n3\n463\n7\n470\n\n", + ), + // Native flag only, or a non-gradient flag, or groups out of order. + hatch("450\n1\n"), + hatch("450\n-7\n470\nX\n"), + hatch("421\n255\n63\n1\n463\n0\n450\n1\n470\nLINEAR\n"), + // Xdata: missing values, junk colour names, huge numbers. + hatch("1001\nCADCRAFT\n1000\nGRADIENT\n"), + hatch("1001\nCADCRAFT\n1000\nGRADIENT\n1000\nX\n1040\n1e308\n1070\n-1\n1000\nnot a colour\n1000\n999,999,999\n"), + hatch("1001\nCADCRAFT\n1000\nGRADIENT\n1040\nnan\n1001\nACAD\n1000\n\n"), + ]; + for t in &cases { + let d = read(t.as_bytes(), "x.dxf").unwrap(); + for h in hatches(&d) { + if let Some(g) = &h.gradient { + assert!(g.angle.is_finite()); + } + } + let _ = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()); + let back = read_dxf(write_dxf(&d).as_bytes()).unwrap(); + assert_eq!( + hatches(&back).into_iter().map(|h| h.gradient).collect::>(), + hatches(&d).into_iter().map(|h| h.gradient).collect::>() + ); + } +} + +/// The native 470 group keeps a long name whole; the xdata copy is cut to 255 bytes at a +/// character boundary. +#[test] +fn long_gradient_name_is_cut_only_in_xdata() { + let name = "Ä".repeat(200); + let mut d = Drawing::new_metric(); + let mut h = hatches(&gradient_sample().0).into_iter().next().unwrap(); + if let Some(g) = &mut h.gradient { + g.name = name.clone(); + } + d.add(&Space::Model, Common::default(), EntityKind::Hatch(h)).unwrap(); + let text = write_dxf(&d); + let lines: Vec<&str> = text.lines().collect(); + // Non-ASCII text is written as \U+ escapes (R2000 files are ANSI). + assert!(lines.contains(&"\\U+00C4".repeat(200).as_str())); + assert!(lines.contains(&"\\U+00C4".repeat(127).as_str())); + let back = read_dxf(text.as_bytes()).unwrap(); + assert_eq!(hatches(&back).into_iter().next().and_then(|h| h.gradient).unwrap().name, name); +} From 1dfa3660a7509e9868ba8157b8d0a3fb11662125 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:15:07 +0200 Subject: [PATCH 017/150] Write Wipeout entities to DXF so saving keeps them (#79) (#147) * Write Wipeout entities to DXF so saving keeps them (#79) * Wipeout: clip vertices are in image pixel space (origin top-left, y down) Write and read the boundary with y flipped, so other programs show the same shape and wipeouts from other programs are no longer mirrored vertically. Tested with an asymmetric boundary. Co-Authored-By: Claude Opus 5.5 * Wipeout: collapse nested if (clippy) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/io/src/dxf_read.rs | 3 ++- crates/io/src/dxf_write.rs | 40 ++++++++++++++++++++++++++++++++++++-- crates/io/src/tests.rs | 39 +++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 7fff942a..7506f135 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -313,7 +313,8 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { let o = t.p(10); let u = t.p(11); let v = t.p(12); - let pts: Vec = t.pts(14).into_iter().map(|q| o.xy() + u.xy() * (q.x + 0.5) + v.xy() * (q.y + 0.5)).collect(); + // Clip vertices are in pixel space: origin at the image's top-left corner, y pointing down. + let pts: Vec = t.pts(14).into_iter().map(|q| o.xy() + u.xy() * (q.x + 0.5) + v.xy() * (0.5 - q.y)).collect(); EntityKind::Wipeout(Wipeout { boundary: pts }) } "IMAGE" => { diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 09c97858..f24f063f 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use cadcraft_color::Color; use cadcraft_doc::*; use cadcraft_dxf::Tag; -use cadcraft_geom::{Vec2, Vec3}; +use cadcraft_geom::{Bounds2, Vec2, Vec3}; use crate::dxf_ext::{self, DimVal, K}; @@ -797,7 +797,40 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx w.xdata(dxf_ext::frozen_xdata(&v.frozen_layers)); } } - // Not yet written: images, wipeouts, tables, multileaders, unknown objects. + EntityKind::Wipeout(wo) => { + // A 1x1 image spanning the boundary's extents. Clip vertices are in pixel space, whose origin is + // the image's top-left corner with y pointing down: the reader maps each vertex back through + // insert + u * (x + 0.5) + v * (0.5 - y). + // Polygonal clip boundaries are closed, so the first vertex is repeated when needed. + let b = Bounds2::from_points(wo.boundary.iter().copied()); + let o = if b.is_empty() { Vec2::ZERO } else { b.min }; + let (sx, sy) = (if b.width() > 0.0 { b.width() } else { 1.0 }, if b.height() > 0.0 { b.height() } else { 1.0 }); + w.s(0, "WIPEOUT"); + common(w, e, owner, paper, "AcDbWipeout"); + w.i(90, 0); + w.p(10, Vec3::new(o.x, o.y, 0.0)); + w.p(11, Vec3::new(sx, 0.0, 0.0)); + w.p(12, Vec3::new(0.0, sy, 0.0)); + w.p2(13, Vec2::new(1.0, 1.0)); + // Display flags 7 = show image, show unaligned, use clipping boundary; clipping on; default brightness/contrast/fade. + w.i(70, 7); + w.i(280, 1); + w.i(281, 50); + 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); + } + w.i(91, pts.len() as i64); + for q in pts { + w.p2(14, Vec2::new((q.x - o.x) / sx - 0.5, 0.5 - (q.y - o.y) / sy)); + } + } + // Not yet written: images, tables, multileaders, unknown objects. _ => {} } } @@ -1177,6 +1210,9 @@ pub fn write(d: &Drawing) -> String { if !cx.assoc.is_empty() { classes.push(("DIMASSOC", "AcDbDimAssoc", 0, false)); } + if every.iter().any(|e| matches!(e.kind, EntityKind::Wipeout(_))) { + classes.push(("WIPEOUT", "AcDbWipeout", 127, true)); + } for (dxf_name, cpp, proxy, is_entity) in classes { w.s(0, "CLASS"); w.s(1, dxf_name); diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index febd26c2..4734cc7a 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -1285,6 +1285,32 @@ fn dimension_text_rotation_roundtrips() { assert!(rot.is_some_and(|r| (r - dm.text_rotation).abs() < 1e-9), "block text rotation {rot:?}"); } +/// Wipeout clip vertices are in image pixel space (origin top-left, y down): an asymmetric +/// boundary must keep its orientation, both through our reader and in the file itself. +#[test] +fn wipeout_boundary_keeps_its_orientation() { + let mut d = Drawing::new_imperial(); + // A triangle with its apex at the top. + let boundary = vec![Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(0.0, 4.0)]; + d.add(&Space::Model, Common::default(), EntityKind::Wipeout(Wipeout { boundary: boundary.clone() })).unwrap(); + let text = write_dxf(&d); + // The apex (0, 4) is at the top-left corner of the image: pixel (-0.5, -0.5). + let pts: Vec<(f64, f64)> = { + let lines: Vec<&str> = text.lines().map(str::trim).collect(); + let mut v = Vec::new(); + for i in 0..lines.len().saturating_sub(3) { + if lines[i] == "14" && lines[i + 2] == "24" { + v.push((lines[i + 1].parse().unwrap(), lines[i + 3].parse().unwrap())); + } + } + v + }; + assert!(pts.iter().any(|&(x, y)| (x + 0.5).abs() < 1e-9 && (y + 0.5).abs() < 1e-9), "{pts:?}"); + let back = roundtrip(&d); + let got = first(&back, |k| if let EntityKind::Wipeout(w) = k { Some(w.boundary.clone()) } else { None }); + assert_eq!(&got[..3], &boundary[..]); +} + #[test] fn arc_length_dimension_keeps_its_kind_and_centre_on_roundtrip() { let mut d = Drawing::new_metric(); @@ -1475,3 +1501,16 @@ fn long_gradient_name_is_cut_only_in_xdata() { let back = read_dxf(text.as_bytes()).unwrap(); assert_eq!(hatches(&back).into_iter().next().and_then(|h| h.gradient).unwrap().name, name); } + +#[test] +fn wipeout_survives_dxf_roundtrip() { + let mut d = Drawing::new_imperial(); + let boundary = vec![Vec2::new(2.0, 3.0), Vec2::new(12.0, 3.0), Vec2::new(12.0, 13.0), Vec2::new(2.0, 13.0)]; + d.add(&Space::Model, Common::default(), EntityKind::Wipeout(Wipeout { boundary: boundary.clone() })).unwrap(); + let back = roundtrip(&d); + let got = first(&back, |k| if let EntityKind::Wipeout(w) = k { Some(w.boundary.clone()) } else { None }); + // The DXF polygon is closed, so the first vertex is repeated at the end. + assert_eq!(got.len(), 5); + assert_eq!(&got[..4], &boundary[..]); + assert_eq!(got[4], boundary[0]); +} From d87950afe8d646c8930903595cf96724408816b8 Mon Sep 17 00:00:00 2001 From: by Truitt <133776756+Tru10@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:15:20 -0500 Subject: [PATCH 018/150] Press-and-drag selection windows (PICKDRAG = 2) (#63) Selection windows only worked click-then-click: the canvas reacts to clicks, and a press that moves before release is a drag, not a click, so a drag was ignored entirely. Now, while objects are being selected (no command running, or a command asking for objects), a drag opens the window where the button went down and letting go closes it, as with AutoCAD's default PICKDRAG = 2. Dragging right is a window, dragging left a crossing window, Shift still removes, and click-click works as before. Drags that start on a grip or while a command asks for a point are left alone. Session::begin_window opens the window at the drag's start, even over an object (a click there would pick the object instead), with an engine test. Claude-Session: https://claude.ai/code/session_019uMCkRAB8DMd8MEmoY9Vyr Co-authored-by: Claude Opus 5.5 --- crates/engine/src/lib.rs | 13 +++++++++++++ crates/engine/src/tests.rs | 31 +++++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 27 +++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4f702319..d2d23948 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -863,6 +863,19 @@ impl Session { // ---------------- picking without a command ---------------- + /// Open a selection window with one corner at `corner`, for press-and-drag selection + /// (AutoCAD's PICKDRAG = 2: a drag opens the window wherever it starts, even over an object; + /// the next point, where the drag ends, closes it). Only while objects are being selected: no + /// command running, or a command asking for objects. Returns whether a window was opened. + pub fn begin_window(&mut self, corner: Vec2) -> bool { + let during_command = self.running.is_some(); + if !corner.is_finite() || (during_command && !self.current_prompt().is_some_and(|p| p.accept.select)) { + return false; + } + self.pending_window = Some(PendingWindow { corner, during_command }); + true + } + /// A click with no command running: pick/toggle objects or start a selection window. /// /// PICKADD on (the default): picks add to the selection and Shift removes. PICKADD off: each pick diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 65dd97f7..6fd34f5b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -994,3 +994,34 @@ fn pline_width_answers_taper_the_next_segment() { let p = typed_pline("pline 0,0 10,0 w 1 0.2 20,0 u"); assert_eq!(widths(&p), (0.0, vec![(0.0, 0.0), (0.0, 0.0)])); } + +#[test] +fn press_and_drag_selection_window() { + let mut s = Session::new(); + s.cmdline("line 0,0 10,0").unwrap(); + s.cmdline("").unwrap(); + s.cmdline("circle 50,50 1").unwrap(); + // No command: a drag from (-1,-1) to (11,1) is a window around the line only. + assert!(s.begin_window(Vec2::new(-1.0, -1.0))); + s.idle_click(Vec2::new(11.0, 1.0), false).unwrap(); + assert_eq!(s.selection().len(), 1); + assert!(s.pending_window.is_none()); + // Dragging right to left is a crossing window: it also catches the circle it touches. + s.set_selection(Vec::new()); + assert!(s.begin_window(Vec2::new(60.0, 60.0))); + s.idle_click(Vec2::new(50.0, 40.0), false).unwrap(); + assert_eq!(s.selection().len(), 1); + // A command asking for objects: the window feeds its selection. + s.set_selection(Vec::new()); + s.cmdline("erase").unwrap(); + assert!(s.begin_window(Vec2::new(-1.0, -1.0))); + s.input(Input::Point(Vec2::new(11.0, 1.0))).unwrap(); + s.input(Input::Enter).unwrap(); + assert_eq!(kinds(&s), vec!["Circle"]); + // A drawing command never opens one, and neither does a hostile point. + s.cmdline("line").unwrap(); + assert!(!s.begin_window(Vec2::ZERO)); + s.cancel(); + assert!(!s.begin_window(Vec2::new(f64::NAN, 0.0))); + assert!(s.pending_window.is_none()); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a864d434..debb41ed 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -112,6 +112,8 @@ pub struct CanvasState { hover_at: Option, /// Constraint glyphs for the parametric overlay (cached per drawing revision). pub param: crate::parametric::Cache, + /// A selection window opened by a press-and-drag, to close where the button is let go. + drag_window: bool, } /// Primitives of `list` sorted by entity handle, built on the first highlight after each list @@ -648,6 +650,31 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { app.session.echo(e.to_string()); } } + // Press-and-drag selection window (AutoCAD's PICKDRAG = 2; click-click still works): a drag + // that starts while objects are being selected opens a window where the button went down, + // and letting go closes it. Drags on grips and drags while drawing are left alone. + if resp.drag_started_by(egui::PointerButton::Primary) + && !middle_down + && app.canvas.hot_grip.is_none() + && app.session.pending_window.is_none() + && let Some(origin) = ui.input(|i| i.pointer.press_origin()) + && rect.contains(origin) + && grip_at(app, &xf, Some(origin)).is_none() + { + app.canvas.drag_window = app.session.begin_window(xf.to_world(origin)); + } + if app.canvas.drag_window && resp.drag_stopped_by(egui::PointerButton::Primary) { + app.canvas.drag_window = false; + if app.session.pending_window.is_some() + && let Some(end) = ui.input(|i| i.pointer.interact_pos()) + { + let end = xf.to_world(end); + let done = if app.session.running.is_some() { app.session.input(Input::Point(end)) } else { app.session.idle_click(end, mods.shift) }; + if let Err(e) = done { + app.session.echo(e.to_string()); + } + } + } if inside && pressed_secondary { // Right-click acts as Enter while a command runs (the classic CAD default). if app.session.running.is_some() { From d144d7f3719593b4b03fac7db23f75d75918108f Mon Sep 17 00:00:00 2001 From: kaffeochknivar Date: Sat, 10 Oct 2026 19:15:33 +0200 Subject: [PATCH 019/150] SETVAR DIM* variables become style overrides for new dimensions (#66) (#129) `SETVAR DIMSCALE 24` was stored in the header but never reached new dimensions: they were created with no overrides, and the header DIMSCALE was only a fallback for styles whose scale is zero. As in AutoCAD, the header DIM* variables that differ from the current dimension style are now its overrides, and new dimensions take them on (`Drawing::dim_overrides`). Existing dimensions keep their size. Making a style current, or editing the current style, sets the variables back to the style's values (`Drawing::sync_dim_vars`), so `dimstyle {"name": "Standard", "DIMSCALE": 24}` still works. GETVAR/SETVAR read DIM* variables the header doesn't carry from the current style. Dimension extents now use the same scale as the geometry (the style with the dimension's overrides) instead of multiplying by the header DIMSCALE. A DIMSCALE of zero (AutoCAD: scale to the layout viewport) falls back to the drawing's DIMSCALE and then 1 instead of 1e-9. Reading DXF, $DIMDSEP becomes the separator character, as the DIMSTYLE table already is, so AutoCAD's header variables don't turn into spurious overrides. Co-authored-by: Claude Opus 5.5 --- crates/doc/src/extents.rs | 9 +++- crates/doc/src/lib.rs | 56 +++++++++++++++++++++++++ crates/doc/src/tables.rs | 6 +++ crates/engine/src/cmd/annotate.rs | 2 +- crates/engine/src/cmd/annotate_tests.rs | 53 +++++++++++++++++++++++ crates/engine/src/cmd/props.rs | 5 +++ crates/engine/src/sysvars.rs | 12 +++++- crates/io/src/dxf_read.rs | 8 ++++ crates/io/src/tests.rs | 38 +++++++++++++++++ crates/render/src/dim.rs | 2 +- 10 files changed, 186 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/extents.rs b/crates/doc/src/extents.rs index c75054b9..41f25d0b 100644 --- a/crates/doc/src/extents.rs +++ b/crates/doc/src/extents.rs @@ -93,7 +93,14 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 { b.add(dm.text_mid.xy()); } // Room for text and arrows. - let th = d.dim_style(&dm.style).map(|s| s.text_height * s.scale.max(1e-9)).unwrap_or(0.18) * d.header.f64("DIMSCALE", 1.0).max(1e-9); + // The same scale the dimension's geometry uses. + let th = d + .dim_style(&dm.style) + .map(|s| { + let s = s.with_overrides(&dm.overrides); + s.text_height * s.effective_scale(d.header.f64("DIMSCALE", 1.0)) + }) + .unwrap_or(0.18 * d.header.f64("DIMSCALE", 1.0).max(1e-9)); b = b.expand(th * 2.5); if let Some(blk) = dm.block.as_ref().and_then(|n| d.block(n)) && depth < MAX_BLOCK_DEPTH diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d656a582..f9276e44 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -147,6 +147,62 @@ impl Drawing { pub fn dim_style(&self, name: &str) -> Option<&DimStyle> { self.dim_styles.iter().find(|l| l.name.eq_ignore_ascii_case(name)) } + + /// The DIM* variables in the header that differ from the current dimension style, as + /// [`DimStyle`] fields: AutoCAD's style overrides (set with SETVAR), which new dimensions + /// take on. + pub fn dim_overrides(&self) -> serde_json::Map { + let mut out = serde_json::Map::new(); + let Some(st) = self.dim_style(&self.header.str("DIMSTYLE", "Standard")) else { return out }; + let base = serde_json::to_value(st).unwrap_or_default(); + for (var, field) in DIMVARS { + let Some(v) = self.header.get(var).and_then(|h| serde_json::to_value(h).ok()) else { continue }; + let mut cand = st.clone(); + if !cand.apply_fields(&serde_json::Map::from_iter([((*field).to_string(), v)])).is_empty() { + continue; + } + let Some(cv) = serde_json::to_value(&cand).ok().and_then(|c| c.get(*field).cloned()) else { continue }; + let same = match (base.get(*field), &cv) { + // Text style and arrow block names compare without case. + (Some(serde_json::Value::String(a)), serde_json::Value::String(b)) + if matches!(*field, "textStyle" | "arrowBlock" | "arrowBlock1" | "arrowBlock2") => + { + a.eq_ignore_ascii_case(b) + } + (b, cv) => b == Some(cv), + }; + if !same { + out.insert((*field).to_string(), cv); + } + } + out + } + + /// Set the header's DIM* variables to the current dimension style's values, clearing the + /// overrides (as making a style current or editing it does). Variables the header doesn't + /// carry stay absent; ones that can't hold the style's value are removed. + pub fn sync_dim_vars(&mut self) { + let Some(st) = self.dim_style(&self.header.str("DIMSTYLE", "Standard")) else { return }; + let v = serde_json::to_value(st).unwrap_or_default(); + for (var, field) in DIMVARS { + let Some(old) = self.header.get(var) else { continue }; + let new = match (old, v.get(*field)) { + (HVal::Int(_), Some(serde_json::Value::Bool(b))) => Some(HVal::Int(i64::from(*b))), + (HVal::Int(_), Some(serde_json::Value::Number(n))) => { + n.as_i64().or_else(|| n.as_f64().filter(|f| f.fract() == 0.0 && f.abs() < 1e15).map(|f| f as i64)).map(HVal::Int) + } + (HVal::Real(_), Some(serde_json::Value::Number(n))) => n.as_f64().map(HVal::Real), + (HVal::Str(_), Some(serde_json::Value::String(s))) => Some(HVal::Str(s.clone())), + _ => None, + }; + match new { + Some(h) => self.header.set(var, h), + None => { + self.header.vars.remove(*var); + } + } + } + } pub fn block(&self, name: &str) -> Option<&std::sync::Arc> { self.blocks.get(name).or_else(|| self.blocks.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v)) } diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index 7089143d..33f080b8 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -382,6 +382,12 @@ impl DimStyle { rejected } + /// The overall scale: the style's DIMSCALE, else the drawing's `dimscale`, else 1. Zero means + /// "scale to the layout viewport" in AutoCAD, which isn't computed here. + pub fn effective_scale(&self, dimscale: f64) -> f64 { + [self.scale, dimscale].into_iter().find(|k| *k > 0.0 && k.is_finite()).unwrap_or(1.0) + } + /// This style with a dimension's overrides applied. pub fn with_overrides(&self, o: &serde_json::Map) -> DimStyle { let mut st = self.clone(); diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index ee09c34f..09dde6cb 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -125,7 +125,7 @@ fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, p15: Vec2, text_rotation: 0.0, user_text_pos: false, block: None, - overrides: Default::default(), + overrides: s.doc().map(|d| d.dim_overrides()).unwrap_or_default(), assoc: Vec::new(), }) } diff --git a/crates/engine/src/cmd/annotate_tests.rs b/crates/engine/src/cmd/annotate_tests.rs index 02a117b1..9020ffc8 100644 --- a/crates/engine/src/cmd/annotate_tests.rs +++ b/crates/engine/src/cmd/annotate_tests.rs @@ -408,3 +408,56 @@ fn mtext_rejects_nonpositive_height() { s.execute("mtext", &json!({ "at": [0, 0], "text": "hi", "height": 2 })).unwrap(); assert_eq!(s.doc().unwrap().model.len(), 1); } + +#[test] +fn setvar_dim_variables_apply_to_new_dimensions() { + // Issue #66: a DIM* variable set with SETVAR is a style override that new dimensions take on. + let mut s = Session::new(); + let linear = |s: &mut Session, y: f64| h(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [100, 0], "at": [50, y] })).unwrap()); + let text_height = |s: &Session, hd: Handle| { + let d = s.doc().unwrap(); + let dm = dim(s, hd); + let st = d.dim_style(&dm.style).cloned().unwrap(); + cadcraft_render::dimension_geometry(&dm, &st, d.header.f64("DIMSCALE", 1.0)).text_height + }; + let first = linear(&mut s, -20.0); + let base = text_height(&s, first); + s.execute("setvar", &json!({ "name": "DIMSCALE", "value": 24 })).unwrap(); + let scaled = linear(&mut s, -40.0); + assert!((text_height(&s, scaled) / base - 24.0).abs() < 1e-9); + assert_eq!(dim(&s, scaled).overrides.get("scale"), Some(&json!(24.0))); + assert!(dim(&s, first).overrides.is_empty(), "existing dimensions keep their size"); + assert!((text_height(&s, first) - base).abs() < 1e-12); + // Extents use the same scale as the geometry: room for 2.5 text heights beyond the points. + let d = s.doc().unwrap(); + let margin = |hd: Handle, span: f64| (cadcraft_doc::entity_bounds(d, d.entity(hd).unwrap(), 0).height() - span) / 2.0; + assert!((margin(first, 20.0) - 2.5 * base).abs() < 1e-9); + assert!((margin(scaled, 40.0) - 2.5 * text_height(&s, scaled)).abs() < 1e-9); + + // Making a style current clears the overrides. + s.execute("dimstyle.current", &json!({ "name": "Standard" })).unwrap(); + assert_eq!(crate::sysvars::get(&s, "DIMSCALE"), Some(json!(1.0))); + let plain = linear(&mut s, -60.0); + assert!(dim(&s, plain).overrides.is_empty()); + + // Editing the current style updates the variables, so the issue's workaround keeps working. + s.execute("dimstyle", &json!({ "name": "Standard", "DIMSCALE": 24 })).unwrap(); + assert_eq!(crate::sysvars::get(&s, "DIMSCALE"), Some(json!(24.0))); + let restyled = linear(&mut s, -80.0); + assert!(dim(&s, restyled).overrides.is_empty()); + assert!((text_height(&s, restyled) / base - 24.0).abs() < 1e-9); + + // A DIM* variable the header doesn't carry reads from the style and can be overridden too. + let dec = crate::sysvars::get(&s, "DIMDEC").unwrap(); + assert_eq!(dec, json!(s.doc().unwrap().dim_style("Standard").unwrap().decimals)); + s.execute("setvar", &json!({ "name": "DIMDEC", "value": 1 })).unwrap(); + let one = linear(&mut s, -100.0); + let one = dim(&s, one); + assert_eq!(one.overrides.get("decimals"), Some(&json!(1))); + assert_eq!(cadcraft_render::dimension_in(s.doc().unwrap(), &one).value, "100.0"); + + // DIMSCALE 0 (AutoCAD: scale to the layout viewport) never shrinks a dimension to nothing. + s.execute("setvar", &json!({ "name": "DIMSCALE", "value": 0 })).unwrap(); + let zero = linear(&mut s, -120.0); + assert!((text_height(&s, zero) - base).abs() < 1e-12); +} diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 6b446194..ad81882f 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -635,6 +635,10 @@ fn run_dimstyle(s: &mut Session, p: &Value) -> Result { if bool_or(p, "current", true) { d.header.set_str("DIMSTYLE", &name); } + // Editing or making the current style current clears the SETVAR overrides. + if d.header.str("DIMSTYLE", "Standard").eq_ignore_ascii_case(&name) { + d.sync_dim_vars(); + } let mut out = dimstyles_json(d); if !rejected.is_empty() && let Some(o) = out.as_object_mut() @@ -666,6 +670,7 @@ fn run_dimstyle_current(s: &mut Session, p: &Value) -> Result { let d = s.doc_mut()?; let n = d.dim_style(name).map(|t| t.name.clone()).ok_or_else(|| bad("dimstyle.current", format!("no dimension style `{name}`")))?; d.header.set_str("DIMSTYLE", &n); + d.sync_dim_vars(); Ok(dimstyles_json(d)) } diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index 78798415..ba0ed899 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -77,8 +77,16 @@ pub fn get(s: &Session, name: &str) -> Option { json!([p.x, p.y, 0.0]) } _ => { - let h = s.doc().ok()?.header.get(&n)?; - serde_json::to_value(h).ok()? + let d = s.doc().ok()?; + match d.header.get(&n) { + Some(h) => serde_json::to_value(h).ok()?, + // A dimension variable the header doesn't carry reads from the current style. + None => { + let field = cadcraft_doc::DIMVARS.iter().find(|(v, _)| *v == n)?.1; + let st = d.dim_style(&d.header.str("DIMSTYLE", "Standard"))?; + serde_json::to_value(st).ok()?.get(field)?.clone() + } + } } }; Some(v) diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 7506f135..f4a06915 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -761,6 +761,14 @@ fn header(tags: &[Tag], d: &mut Drawing) { }, _ => continue, }; + // $DIMDSEP holds a character code; the dimension style holds the character. + let hv = match hv { + HVal::Int(n) if name == "DIMDSEP" => match u32::try_from(n).ok().and_then(char::from_u32).filter(|c| !c.is_control()) { + Some(c) => HVal::Str(c.to_string()), + None => continue, + }, + hv => hv, + }; d.header.set(&name, hv); } else { i += 1; diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 4734cc7a..a473503d 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -1514,3 +1514,41 @@ fn wipeout_survives_dxf_roundtrip() { assert_eq!(&got[..4], &boundary[..]); assert_eq!(got[4], boundary[0]); } + +#[test] +fn autocad_header_dim_variables_are_not_overrides() { + // AutoCAD writes the current style's values as $DIM* header variables; reading them back must + // not turn them into style overrides for new dimensions (issue #66). + use crate::dxf_ext::{K, dim_code}; + let mut d = Drawing::new_imperial(); + d.text_styles.push(TextStyle { name: "Romans".into(), font: "romans.shx".into(), ..TextStyle::default() }); + d.dim_styles.push(full_dim_style()); + d.header.set_str("DIMSTYLE", "Mech"); + d.header.set_f64("DIMSCALE", full_dim_style().scale); + let st = serde_json::to_value(full_dim_style()).unwrap(); + let mut vars = String::new(); + for (var, field) in DIMVARS { + let Some((_, kind)) = dim_code(field) else { continue }; + let v = &st[*field]; + let (code, val) = match kind { + K::Real => (40, v.as_f64().unwrap().to_string()), + K::Int => (70, v.as_i64().unwrap().to_string()), + K::Bool => (70, i64::from(v.as_bool().unwrap()).to_string()), + K::Color => (70, serde_json::from_value::(v.clone()).unwrap().to_aci().to_string()), + K::Char => (70, u32::from(v.as_str().unwrap().chars().next().unwrap()).to_string()), + K::Str => (1, v.as_str().unwrap().to_string()), + // Names as AutoCAD may spell them. + K::Block => (1, v.as_str().unwrap().to_ascii_uppercase()), + K::TextStyle => (7, v.as_str().unwrap().to_ascii_uppercase()), + }; + vars.push_str(&format!(" 9\r\n${var}\r\n{code}\r\n{val}\r\n")); + } + let text = write_dxf(&d).replacen("HEADER\r\n", &format!("HEADER\r\n{vars}"), 1); + let back = read_dxf(text.as_bytes()).unwrap(); + assert_eq!(back.header.str("DIMDSEP", ""), ","); + assert!(back.header.get("DIMTXT").is_some() && back.header.get("DIMCLRD").is_some()); + assert_eq!(back.dim_overrides(), serde_json::Map::new()); + // A variable that really differs from the style is an override. + let text = text.replacen("$DIMDEC\r\n70\r\n3\r\n", "$DIMDEC\r\n70\r\n5\r\n", 1); + assert_eq!(read_dxf(text.as_bytes()).unwrap().dim_overrides().get("decimals"), Some(&serde_json::json!(5))); +} diff --git a/crates/render/src/dim.rs b/crates/render/src/dim.rs index 4190aa9f..809abd2c 100644 --- a/crates/render/src/dim.rs +++ b/crates/render/src/dim.rs @@ -363,7 +363,7 @@ pub fn dimension_geometry(d: &Dimension, st: &DimStyle, dimscale: f64) -> DimGeo /// Generate the geometry of a dimension. The dimension's own overrides are applied to `st`. pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font: &DimText) -> DimGeometry { let st = &st.with_overrides(&d.overrides); - let k = if st.scale > 0.0 && st.scale.is_finite() { st.scale } else { dimscale.max(1e-9) }; + let k = st.effective_scale(dimscale); let asz = st.arrow_size * k; let th = if font.fixed_height > 0.0 { font.fixed_height } else { st.text_height * k }; let gap = st.text_gap.abs() * k; From 5c2a18638e187ad91244b50cc6660e4ce2f12224 Mon Sep 17 00:00:00 2001 From: Books Date: Sun, 11 Oct 2026 04:15:47 +1100 Subject: [PATCH 020/150] DIMANGULAR: accept a circle (#20) * DIMANGULAR: accept a circle The first prompt offers "arc, circle, line" but picking a circle was rejected. Picking a circle now uses its center as the angle vertex and the picked point as the first endpoint, then asks for the second endpoint. * cargo fmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/annotate.rs | 12 +++++++++++- crates/engine/src/cmd/annotate_tests.rs | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index 09dde6cb..68ee7ee5 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -1016,6 +1016,16 @@ impl Interactive for AngularM { s.echo("*Invalid selection*"); return Ok(Step::Continue); }; + let circle = s + .doc()? + .entity(h) + .and_then(|e| if let EntityKind::Circle(c) = &e.kind { Some(cadcraft_geom::Circle::new(c.center.xy(), c.radius)) } else { None }); + if let Some(g) = circle.filter(|_| self.first.is_none()) { + // The center is the vertex and the pick the first endpoint; ask for the second. + self.vertex = true; + self.vertex_mode = vec![g.center, g.closest(p)]; + return Ok(Step::Continue); + } match segment_at(s, h, p) { Some(Segment::Arc { arc: g, .. }) if self.first.is_none() => { self.arc = Some((g.center, g.start_point(), g.end_point())); @@ -1029,7 +1039,7 @@ impl Interactive for AngularM { self.second = Some(seg); } } - _ => s.echo("Select a line or an arc."), + _ => s.echo("Select an arc, circle or line."), } Ok(Step::Continue) } diff --git a/crates/engine/src/cmd/annotate_tests.rs b/crates/engine/src/cmd/annotate_tests.rs index 9020ffc8..48dd9707 100644 --- a/crates/engine/src/cmd/annotate_tests.rs +++ b/crates/engine/src/cmd/annotate_tests.rs @@ -168,6 +168,22 @@ fn interactive_angular_picks_polyline_segments() { assert_eq!(cadcraft_render::dimension_in(s.doc().unwrap(), &dim(&s, d2)).value, "180°"); } +#[test] +fn interactive_angular_picks_circle() { + // Circle: the pick is the first endpoint, the center the vertex. + let mut s = Session::new(); + s.cmdline("circle 0,0 5").unwrap(); + s.cmdline("dimangular").unwrap(); + s.cmdline("5,0").unwrap(); + assert!(s.current_prompt().unwrap().message.contains("Specify second angle endpoint")); + s.cmdline("0,8").unwrap(); + s.cmdline("3,3").unwrap(); + assert!(s.running.is_none()); + let dm = dim(&s, s.last_dim.unwrap()); + assert!(near(dm.p15.xy(), Vec2::ZERO), "vertex at the center"); + assert_eq!(cadcraft_render::dimension_in(s.doc().unwrap(), &dm).value, "90°"); +} + #[test] fn erase_disassociate_and_reassociate() { let mut s = Session::new(); From d648e4d1f1a5570847b558a213ed50412cda732f Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:16:01 +0200 Subject: [PATCH 021/150] Show Ctrl/Shift shortcuts on Windows and Linux, symbols only on macOS (#53) (#54) --- crates/ui-egui/src/chrome.rs | 50 +++++++++++++++++++++-------------- crates/ui-egui/src/dialogs.rs | 3 ++- crates/ui-egui/src/menus.rs | 30 ++++++++++++++++++++- 3 files changed, 61 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index aa761327..e2b41c67 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -7,30 +7,35 @@ use crate::CadApp; use crate::icons::{self, Icon}; use crate::theme::Tokens; -const TOOLBAR: &[&[(Icon, &str, &str)]] = &[ +const TOOLBAR: &[&[(Icon, &str, &str, Option<&str>)]] = &[ &[ - (Icon::New, "new", "New Drawing (⌘N)"), - (Icon::Open, "open", "Open (⌘O)"), - (Icon::Save, "qsave", "Save (⌘S)"), - (Icon::SaveAs, "saveas", "Save As"), + (Icon::New, "new", "New Drawing", Some("Cmd+N")), + (Icon::Open, "open", "Open", Some("Cmd+O")), + (Icon::Save, "qsave", "Save", Some("Cmd+S")), + (Icon::SaveAs, "saveas", "Save As", None), ], - &[(Icon::Undo, "undo", "Undo (⌘Z)"), (Icon::Redo, "redo", "Redo (⇧⌘Z)")], + &[(Icon::Undo, "undo", "Undo", Some("Cmd+Z")), (Icon::Redo, "redo", "Redo", Some("Cmd+Shift+Z"))], &[ - (Icon::Plot, "plot", "Print"), - (Icon::Publish, "publish", "Batch Publish"), - (Icon::PageSetup, "pagesetup", "Page Setup Manager"), - (Icon::Preview, "preview", "Plot Preview"), + (Icon::Plot, "plot", "Print", None), + (Icon::Publish, "publish", "Batch Publish", None), + (Icon::PageSetup, "pagesetup", "Page Setup Manager", None), + (Icon::Preview, "preview", "Plot Preview", None), ], - &[(Icon::Import, "import", "Import"), (Icon::Export, "export", "Export"), (Icon::Attach, "attach", "Attach"), (Icon::Share, "share", "Share")], - &[(Icon::ZoomWindow, "zoom.window", "Zoom Window"), (Icon::Pan, "pan", "Pan"), (Icon::Orbit, "3dorbit", "Orbit")], &[ - (Icon::ZoomExtents, "zoom.extents", "Zoom Extents"), - (Icon::Properties, "ui.toggle.palettes", "Properties"), - (Icon::Layers, "ui.dialog.layers", "Layer Properties Manager"), - (Icon::Blocks, "ui.dialog.blocks", "Blocks"), + (Icon::Import, "import", "Import", None), + (Icon::Export, "export", "Export", None), + (Icon::Attach, "attach", "Attach", None), + (Icon::Share, "share", "Share", None), ], - &[(Icon::Measure, "dist", "Measure Distance"), (Icon::List, "list", "List"), (Icon::Area, "area", "Area")], - &[(Icon::Help, "ui.dialog.about", "Help")], + &[(Icon::ZoomWindow, "zoom.window", "Zoom Window", None), (Icon::Pan, "pan", "Pan", None), (Icon::Orbit, "3dorbit", "Orbit", None)], + &[ + (Icon::ZoomExtents, "zoom.extents", "Zoom Extents", None), + (Icon::Properties, "ui.toggle.palettes", "Properties", None), + (Icon::Layers, "ui.dialog.layers", "Layer Properties Manager", None), + (Icon::Blocks, "ui.dialog.blocks", "Blocks", None), + ], + &[(Icon::Measure, "dist", "Measure Distance", None), (Icon::List, "list", "List", None), (Icon::Area, "area", "Area", None)], + &[(Icon::Help, "ui.dialog.about", "Help", None)], ]; /// Title row (with the integrated macOS title bar) and the Tool Bar. @@ -68,15 +73,20 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { let mut x = r.left() + if app.integrated_titlebar { 120.0 } else { 12.0 }; let size = 26.0; let mut clicked = None; + let mac = ui.ctx().os().is_mac(); for group in TOOLBAR { - for (icon, cmd, tip) in group.iter() { + for (icon, cmd, label, shortcut) in group.iter() { let br = Rect::from_min_size(pos2(x, row.center().y - size / 2.0), vec2(size, size)); let resp = ui.interact(br, ui.id().with(("tb", *cmd)), Sense::click()); if resp.hovered() { ui.painter().rect_filled(br, 3.0, t.control_hover.gamma_multiply(0.6)); } icons::paint(ui.painter(), br.shrink(4.0), *icon, false); - if resp.on_hover_text(*tip).clicked() { + let tip = match shortcut { + Some(sc) => format!("{label} ({})", crate::menus::shortcut_label(sc, mac)), + None => (*label).to_string(), + }; + if resp.on_hover_text(tip).clicked() { clicked = Some(*cmd); } x += size + 6.0; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7b463d68..dbe4c1b4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -233,7 +233,8 @@ fn mtext_editor(app: &mut CadApp, ctx: &egui::Context) { if ui.button("Cancel").clicked() { cancel = true; } - ui.label(RichText::new("⌘↩ to finish").small()); + let finish = if ui.ctx().os().is_mac() { "⌘↩ to finish" } else { "Ctrl+Enter to finish" }; + ui.label(RichText::new(finish).small()); }); }); ctx.data_mut(|d| d.insert_temp(id, buf)); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 27e14122..dc444d28 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -298,12 +298,23 @@ pub fn tree(app: &CadApp) -> Vec<(String, Vec)> { out } +/// Turn a registry shortcut ("Cmd+Shift+Z") into the form shown on this platform: macOS symbols ("⇧⌘Z") or +/// Windows/Linux text ("Ctrl+Shift+Z"). `mac` comes from the runtime OS (`ctx.os()`), so the web build is right too. +pub(crate) fn shortcut_label(s: &str, mac: bool) -> String { + if !mac { + return s.replace("Cmd+", "Ctrl+"); + } + let shift = if s.contains("Shift+") { "⇧" } else { "" }; + let cmd = if s.contains("Cmd+") { "⌘" } else { "" }; + format!("{shift}{cmd}{}", s.replace("Cmd+", "").replace("Shift+", "")) +} + fn entry_ui(ui: &mut egui::Ui, e: &Entry, clicked: &mut Option) { match e { Entry::Item { label, id, shortcut, enabled } => { let mut b = egui::Button::new(label); if let Some(s) = shortcut { - b = b.shortcut_text(s.replace("Cmd+", "⌘").replace("Shift+", "⇧")); + b = b.shortcut_text(shortcut_label(s, ui.ctx().os().is_mac())); } if ui.add_enabled(*enabled, b).clicked() { *clicked = Some(id.clone()); @@ -480,3 +491,20 @@ mod tests { assert!(app.session.running.is_none()); } } + +#[cfg(test)] +mod shortcut_label_tests { + use super::shortcut_label; + + #[test] + fn shortcut_label_per_platform() { + assert_eq!(shortcut_label("Cmd+Shift+Z", true), "⇧⌘Z"); + assert_eq!(shortcut_label("Cmd+Z", true), "⌘Z"); + assert_eq!(shortcut_label("Cmd+Shift+Z", false), "Ctrl+Shift+Z"); + assert_eq!(shortcut_label("Cmd+Z", false), "Ctrl+Z"); + assert_eq!(shortcut_label("Shift+F3", false), "Shift+F3"); + assert_eq!(shortcut_label("Shift+F3", true), "⇧F3"); + assert_eq!(shortcut_label("F1", false), "F1"); + assert_eq!(shortcut_label("F1", true), "F1"); + } +} From a43e88e17acba555bfd9c4e7cb01ce9ed961a4a3 Mon Sep 17 00:00:00 2001 From: kaffeochknivar Date: Sat, 10 Oct 2026 19:16:16 +0200 Subject: [PATCH 022/150] UNITS opens a Drawing Units dialog; SETVAR prompts at the command line (#30) (#38) Typing UNITS ran the JSON form with no parameters, which changed nothing and printed nothing. Typed or menu-invoked UNITS (no parameters) now opens a Drawing Units dialog like QSELECT does: length type and precision, angle type and precision, insertion units and a sample output. OK applies the values through the `units` command; JSON calls run the command as before. SETVAR had no prompts either, so `SETVAR LUNITS 2` ran with no name and the remaining inputs were dropped. Space acting as Enter was not the cause. SETVAR now asks "Enter variable name or [?]" and then "Enter new value for NAME "; `?` lists the variables, unknown names end the command, and a value of the wrong type re-prompts. sysvars::set now refuses variables that `get` derives from state (DWGNAME, DBMOD, EXTMIN, ...) and keeps integer header variables integer, so a typo can no longer store text in LUNITS. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/settings.rs | 81 +++++++++++++++++++- crates/engine/src/sysvars.rs | 31 ++++++-- crates/engine/src/tests.rs | 45 +++++++++++ crates/ui-egui/src/dialogs.rs | 119 +++++++++++++++++++++++++++++- crates/ui-egui/src/menus.rs | 28 +++++++ 5 files changed, 293 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/settings.rs b/crates/engine/src/cmd/settings.rs index 8fbcb502..b5b658ab 100644 --- a/crates/engine/src/cmd/settings.rs +++ b/crates/engine/src/cmd/settings.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use super::*; -use crate::{Result, Session}; +use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; fn toggle(s: &mut Session, p: &Value, get: fn(&mut crate::Settings) -> &mut bool, name: &str) -> Result { let v = get(&mut s.settings); @@ -91,7 +91,8 @@ pub fn specs() -> Vec { .menu(&["Tools", "Inquiry", "Set Variable"]) .alias(&["set"]) .params("{name, value}") - .transparent(), + .transparent() + .interactive(|_| Ok(Box::new(SetvarM::default()))), CommandSpec::new("getvar", "Get Variable", run_getvar).params("{name}").enabled(always).noundo(), CommandSpec::new("sysvars", "List System Variables", |s, _| Ok(crate::sysvars::list(s))).enabled(always).noundo(), ] @@ -165,6 +166,82 @@ fn run_setvar(s: &mut Session, p: &Value) -> Result { Ok(json!({ "name": name.to_ascii_uppercase(), "value": crate::sysvars::get(s, name) })) } +/// A system variable value as the command line shows it. +fn show_value(v: &Value) -> String { + match v { + Value::String(t) => t.clone(), + Value::Array(a) => a.iter().map(show_value).collect::>().join(","), + other => other.to_string(), + } +} + +/// SETVAR at the command line: "Enter variable name or [?]", then "Enter new value for NAME ". +#[derive(Default)] +struct SetvarM { + name: Option, +} + +impl Interactive for SetvarM { + fn name(&self) -> &'static str { + "SETVAR" + } + fn prompt(&self, s: &Session) -> Prompt { + // Not a free-text prompt: Space ends the input, as it does for names and numbers. + let accept = Accept { number: true, ..Accept::TEXT }; + match &self.name { + None => Prompt::new("Enter variable name", accept).kw(&["?"]), + Some(n) => { + let p = Prompt::new(format!("Enter new value for {n}"), accept); + match crate::sysvars::get(s, n) { + Some(v) => p.default(show_value(&v)), + None => p, + } + } + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (self.name.clone(), i) { + (_, Input::Enter | Input::Cancel) => Ok(Step::Done), + (None, Input::Keyword(_)) => { + if let Value::Object(m) = crate::sysvars::list(s) { + for (k, v) in m { + s.echo(format!("{k:<12} {}", show_value(&v))); + } + } + Ok(Step::Done) + } + (None, Input::Text(t)) => { + let n = t.trim().to_ascii_uppercase(); + match crate::sysvars::get(s, &n) { + None => { + s.echo(format!("Unknown variable {n}. Type SETVAR ? to list variables.")); + Ok(Step::Done) + } + Some(v) if crate::sysvars::is_read_only(&n) => { + s.echo(format!("{n} = {} (read only)", show_value(&v))); + Ok(Step::Done) + } + Some(_) => { + self.name = Some(n); + Ok(Step::Continue) + } + } + } + (Some(n), Input::Text(t)) => match crate::sysvars::set(s, &n, &Value::String(t)) { + Ok(()) => { + s.touch(); + Ok(Step::Done) + } + Err(e) => { + s.echo(e.to_string()); + Ok(Step::Continue) + } + }, + _ => Ok(Step::Continue), + } + } +} + fn run_getvar(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("getvar", "`name` is required"))?; let v = crate::sysvars::get(s, name).ok_or_else(|| bad("getvar", format!("unknown variable `{name}`")))?; diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index ba0ed899..e5049fc5 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -106,8 +106,18 @@ fn as_pt(v: &Value) -> Option { crate::cmd::point_value(v).or_else(|| as_f64(v).map(|f| cadcraft_geom::Vec2::new(f, f))) } +/// Variables `get` reports from session or drawing state that `set` can't change. +const READ_ONLY: &[&str] = &["CMDNAMES", "DWGNAME", "DBMOD", "CTAB", "LASTPOINT", "VIEWCTR", "VIEWSIZE", "EXTMIN", "EXTMAX"]; + +pub fn is_read_only(name: &str) -> bool { + READ_ONLY.iter().any(|r| r.eq_ignore_ascii_case(name.trim())) +} + pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { let n = name.trim().to_ascii_uppercase(); + if is_read_only(&n) { + return Err(EngineError::BadParams { cmd: "setvar".into(), msg: format!("{n} is read-only") }); + } let bad = || EngineError::BadParams { cmd: "setvar".into(), msg: format!("invalid value for {n}") }; let st = &mut s.settings; match n.as_str() { @@ -148,12 +158,21 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { _ => return Err(bad()), }; // Keep the header type stable for known numeric vars. - if let Some(old) = d.header.get(&n) - && matches!(old, cadcraft_doc::HVal::Real(_)) - && let Some(f) = val.as_f64() - { - d.header.set_f64(&n, f); - return Ok(()); + match d.header.get(&n) { + Some(cadcraft_doc::HVal::Real(_)) => { + d.header.set_f64(&n, val.as_f64().ok_or_else(bad)?); + return Ok(()); + } + Some(cadcraft_doc::HVal::Int(_)) => { + let i = match val { + cadcraft_doc::HVal::Int(i) => i, + cadcraft_doc::HVal::Real(f) if f.fract() == 0.0 && f.abs() < 1e15 => f as i64, + _ => return Err(bad()), + }; + d.header.set_i64(&n, i); + return Ok(()); + } + _ => {} } d.header.set(&n, val); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6fd34f5b..f2242d53 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -458,6 +458,51 @@ fn sysvars_roundtrip() { assert_eq!(sysvars::get(&s, "FONTALT"), Some(json!(""))); } +#[test] +fn setvar_at_command_line() { + let mut s = Session::new(); + // One line, as typed in issue #30: Space separates the inputs. + s.cmdline("SETVAR LUNITS 4").unwrap(); + assert!(s.running.is_none()); + assert_eq!(s.doc().unwrap().header.i64("LUNITS", 0), 4); + // Step by step, offering the current value as the default. + s.cmdline("setvar").unwrap(); + s.cmdline("luprec").unwrap(); + assert_eq!(s.current_prompt().unwrap().default.as_deref(), Some("4")); + s.cmdline("2").unwrap(); + assert!(s.running.is_none()); + assert_eq!(s.doc().unwrap().header.i64("LUPREC", 0), 2); + // Enter keeps the value. + s.cmdline("set osmode").unwrap(); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + // A value of the wrong type re-prompts and changes nothing. + s.cmdline("SETVAR LUNITS abc").unwrap(); + assert!(s.running.is_some()); + assert_eq!(s.doc().unwrap().header.i64("LUNITS", 0), 4); + s.cancel(); + // Unknown and read-only names end the command without creating or changing anything. + s.cmdline("SETVAR NOSUCHVAR 1").unwrap(); + assert!(sysvars::get(&s, "NOSUCHVAR").is_none()); + s.cmdline("SETVAR DWGNAME").unwrap(); + assert!(s.running.is_none()); + assert!(s.execute("setvar", &json!({"name": "dbmod", "value": 0})).is_err()); + // `?` lists the variables. + s.cmdline("SETVAR ?").unwrap(); + assert!(s.running.is_none()); +} + +#[test] +fn setvar_keeps_header_types() { + let mut s = Session::new(); + assert!(s.execute("setvar", &json!({"name": "LUNITS", "value": "x"})).is_err()); + assert!(s.execute("setvar", &json!({"name": "LUNITS", "value": 2.5})).is_err()); + s.execute("setvar", &json!({"name": "LUNITS", "value": 3.0})).unwrap(); + assert_eq!(s.doc().unwrap().header.get("LUNITS"), Some(&cadcraft_doc::HVal::Int(3))); + assert!(s.execute("setvar", &json!({"name": "LTSCALE", "value": "big"})).is_err()); + assert_eq!(s.doc().unwrap().header.f64("LTSCALE", 0.0), 1.0); +} + #[test] fn script_runs_commands() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index dbe4c1b4..5fd0cc8c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,8 +1,9 @@ -//! Dialogs: Drafting Settings, About, command reference, blocks; dispatches the Layer Properties -//! Manager ([`crate::layers`]), Quick Select ([`crate::quick`]), Parameters Manager -//! ([`crate::parametric`]) and the style and settings managers ([`crate::managers`]). +//! Dialogs: Drafting Settings, Drawing Units, About, command reference, blocks; dispatches the +//! Layer Properties Manager ([`crate::layers`]), Quick Select ([`crate::quick`]), Parameters +//! Manager ([`crate::parametric`]) and the style and settings managers ([`crate::managers`]). use egui::{RichText, vec2}; +use serde_json::json; use crate::CadApp; use crate::theme::Tokens; @@ -17,6 +18,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { "qselect" => crate::quick::qselect_dialog(app, ctx, &mut open), "parameters" => crate::parametric::parameters_dialog(app, ctx, &mut open), "dsettings" => dsettings(app, ctx, &mut open), + "units" => units(app, ctx, &mut open), "about" => about(ctx, &mut open), "commands" => commands(app, ctx, &mut open), "blocks" => blocks(app, ctx, &mut open), @@ -89,6 +91,117 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { }); } +/// LUNITS values in the order the Type list shows them. +const LENGTH_TYPES: [(i64, &str); 5] = [(4, "Architectural"), (2, "Decimal"), (3, "Engineering"), (5, "Fractional"), (1, "Scientific")]; +/// AUNITS values. +const ANGLE_TYPES: [(i64, &str); 5] = [(0, "Decimal Degrees"), (1, "Deg/Min/Sec"), (2, "Grads"), (3, "Radians"), (4, "Surveyor's Units")]; +/// INSUNITS names, indexed by value (DXF Reference, header group code 70). +const INSERTION_UNITS: [&str; 25] = [ + "Unitless", + "Inches", + "Feet", + "Miles", + "Millimeters", + "Centimeters", + "Meters", + "Kilometers", + "Microinches", + "Mils", + "Yards", + "Angstroms", + "Nanometers", + "Microns", + "Decimeters", + "Decameters", + "Hectometers", + "Gigameters", + "Astronomical Units", + "Light Years", + "Parsecs", + "US Survey Feet", + "US Survey Inch", + "US Survey Yard", + "US Survey Mile", +]; + +/// A combo box over `(value, label)` options. +fn pick(ui: &mut egui::Ui, salt: &str, value: &mut i64, options: &[(i64, String)]) { + let current = options.iter().find(|o| o.0 == *value).map(|o| o.1.clone()).unwrap_or_default(); + egui::ComboBox::from_id_salt(salt).selected_text(current).width(180.0).show_ui(ui, |ui| { + for (k, label) in options { + ui.selectable_value(value, *k, label); + } + }); +} + +/// Drawing Units (UNITS): edits a copy of LUNITS/LUPREC/AUNITS/AUPREC/INSUNITS and applies it +/// through the `units` command on OK. +fn units(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { + use cadcraft_engine::units::{format_angle, format_distance}; + let id = egui::Id::new("units_dialog_values"); + let mut v = ctx.data_mut(|d| d.get_temp::<[i64; 5]>(id)).unwrap_or_else(|| { + app.session + .doc() + .map(|d| { + let h = &d.header; + [h.i64("LUNITS", 2), h.i64("LUPREC", 4), h.i64("AUNITS", 0), h.i64("AUPREC", 0), h.i64("INSUNITS", 1)] + }) + .unwrap_or([2, 4, 0, 0, 1]) + }); + let (mut apply, mut cancel) = (false, false); + egui::Window::new("Drawing Units").open(open).resizable(false).collapsible(false).show(ctx, |ui| { + let [lu, lp, au, ap, ins] = &mut v; + let types = |t: &[(i64, &str)]| t.iter().map(|(k, l)| (*k, l.to_string())).collect::>(); + egui::Grid::new("units_grid").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| { + ui.strong("Length"); + ui.end_row(); + ui.label("Type"); + pick(ui, "units_lunits", lu, &types(&LENGTH_TYPES)); + ui.end_row(); + ui.label("Precision"); + pick(ui, "units_luprec", lp, &(0..=8).map(|p| (p, format_distance(1.5, *lu, p))).collect::>()); + ui.end_row(); + ui.strong("Angle"); + ui.end_row(); + ui.label("Type"); + pick(ui, "units_aunits", au, &types(&ANGLE_TYPES)); + ui.end_row(); + ui.label("Precision"); + pick(ui, "units_auprec", ap, &(0..=8).map(|p| (p, format_angle(45f64.to_radians(), *au, p))).collect::>()); + ui.end_row(); + ui.strong("Insertion scale"); + ui.end_row(); + ui.label("Units to scale inserted content"); + let names = INSERTION_UNITS.iter().enumerate().map(|(k, l)| (k as i64, l.to_string())).collect::>(); + pick(ui, "units_insunits", ins, &names); + ui.end_row(); + ui.strong("Sample output"); + ui.end_row(); + ui.label(""); + ui.label(format!("{},{},{}", format_distance(1.5, *lu, *lp), format_distance(2.0039, *lu, *lp), format_distance(0.0, *lu, *lp))); + ui.end_row(); + ui.label(""); + ui.label(format_angle(45f64.to_radians(), *au, *ap)); + ui.end_row(); + }); + ui.separator(); + ui.horizontal(|ui| { + apply = ui.button("OK").clicked(); + cancel = ui.button("Cancel").clicked(); + }); + }); + if apply { + let [lu, lp, au, ap, ins] = v; + let _ = app.run("units", json!({ "lunits": lu, "luprec": lp, "aunits": au, "auprec": ap, "insunits": ins })); + } + if apply || cancel || !*open { + ctx.data_mut(|d| d.remove::<[i64; 5]>(id)); + *open = false; + } else { + ctx.data_mut(|d| d.insert_temp(id, v)); + } +} + fn about(ctx: &egui::Context, open: &mut bool) { let t = Tokens::get(); let tab_id = egui::Id::new("about_tab"); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index dc444d28..f3e6d4fb 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -210,6 +210,10 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option { + app.ui.dialog = Some("units".into()); + Ok(Value::Null) + } "parameters" | "par" if params.is_null() => { app.ui.dialog = Some("parameters".into()); Ok(Value::Null) @@ -508,3 +512,27 @@ mod shortcut_label_tests { assert_eq!(shortcut_label("F1", true), "F1"); } } + +#[cfg(test)] +mod units_dialog_tests { + use cadcraft_engine::Session; + use serde_json::json; + + use crate::{CadApp, Services}; + + #[test] + fn units_typed_opens_dialog_json_runs_command() { + let mut app = CadApp::new(Session::new(), Services::default()); + app.cmdline("UNITS"); + assert_eq!(app.ui.dialog.as_deref(), Some("units")); + app.ui.dialog = None; + app.start("un"); + assert_eq!(app.ui.dialog.as_deref(), Some("units")); + app.ui.dialog = None; + let r = app.run("units", json!({ "lunits": 4, "luprec": 3, "insunits": 4 })).unwrap(); + assert_eq!(r["lunits"], 4); + assert!(app.ui.dialog.is_none(), "JSON calls never open dialogs"); + let h = &app.session.doc().unwrap().header; + assert_eq!((h.i64("LUNITS", 0), h.i64("LUPREC", 0), h.i64("INSUNITS", 0)), (4, 3, 4)); + } +} From 3e0adadf94c577fad851d36b506ac84c99218192 Mon Sep 17 00:00:00 2001 From: by Truitt <133776756+Tru10@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:21:39 -0500 Subject: [PATCH 023/150] Dynamic Input: pointer input boxes with Tab, comma and relative entry (#60) * Dynamic Input: pointer input boxes, Tab separator, relative entry While a command asks for a point and Dynamic Input is on, the cursor tooltip shows a pair of value boxes, following AutoCAD's pointer input: - The first point of a command is X and Y, absolute. - Second and next points default to polar (distance and angle) measured from the last point, so no `@` is needed. - Typing `,` after a value locks it as X and moves to Y; `<` locks it as the distance and moves to the angle. A locked value holds the cursor and shows a lock. - Tab types the separator for the format on show, so `40` Tab `90` is 40 units at 90 degrees, and with the Cartesian setting `40` Tab `40` is 40 over and 40 up. - Enter or Space sends the entry with `@` in front, filling an empty box from the cursor. A plain number and Enter is still direct distance entry; `@`/`#` entries, keywords and command names go through untouched. The command-line text is the single source of truth: the boxes are a view of it, so the command line always shows exactly what will be sent (`40,`, `40<90`) and Backspace simply edits it. Tab is taken from egui in the raw input hook (desktop and web) while no widget has focus. Otherwise egui moves keyboard focus to the next button on Tab and the command line stops receiving keystrokes. Two new settings mirror AutoCAD's: DYNPIFORMAT (1 = second points start Cartesian) and DYNPICOORDS (1 = absolute), through SETVAR and in Drafting Settings under Dynamic Input. The tooltip uses the theme tokens, matching the command line. Scripts, the control channel and MCP never pass through the boxes and keep the plain command-line rules. The parsing lives in a small pure module (dyninput.rs) with unit tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019uMCkRAB8DMd8MEmoY9Vyr * Dynamic Input tooltip: border colour from theme tokens Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- apps/cadcraft-web/src/main.rs | 4 + apps/cadcraft/src/main.rs | 3 +- crates/engine/src/lib.rs | 8 + crates/engine/src/sysvars.rs | 6 + crates/engine/src/tests.rs | 11 ++ crates/ui-egui/src/canvas.rs | 136 +++++++++++++++- crates/ui-egui/src/cmdline.rs | 68 +++++++- crates/ui-egui/src/dialogs.rs | 14 ++ crates/ui-egui/src/dyninput.rs | 287 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 1 + 10 files changed, 524 insertions(+), 14 deletions(-) create mode 100644 crates/ui-egui/src/dyninput.rs diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index af50a2bb..4f4007a1 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -21,6 +21,10 @@ mod web { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.0.logic(ctx); } + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + self.0.raw_input_hook(raw); + cadcraft_ui_egui::cmdline::capture_tab(ctx, raw); + } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.0.ui(ui); } diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 7662f972..5e3870a6 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -42,8 +42,9 @@ impl eframe::App for App { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { self.0.raw_input_hook(raw); + cadcraft_ui_egui::cmdline::capture_tab(ctx, raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.0.ui(ui); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d2d23948..4596e399 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -221,6 +221,12 @@ pub struct Settings { /// Major grid line every N minor lines (GRIDMAJOR). pub gridmajor: u32, pub dynmode: bool, + /// DYNPIFORMAT = 1: Dynamic Input shows second and next points as Cartesian `x,y` instead of + /// polar `distance Option { "GRIDUNIT" => json!([st.gridunit.x, st.gridunit.y]), "GRIDMAJOR" => json!(st.gridmajor), "DYNMODE" => json!(i32::from(st.dynmode)), + "DYNPIFORMAT" => json!(i32::from(st.dynpi_cartesian)), + "DYNPICOORDS" => json!(i32::from(st.dynpi_absolute)), "LWDISPLAY" => json!(i32::from(st.lwdisplay)), "QPMODE" => json!(i32::from(st.qpmode)), "PICKBOX" => json!(st.pickbox), @@ -131,6 +135,8 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { "GRIDUNIT" => st.gridunit = as_pt(v).filter(|p| p.x > 0.0 && p.y > 0.0).ok_or_else(bad)?, "GRIDMAJOR" => st.gridmajor = as_f64(v).ok_or_else(bad)?.clamp(1.0, 100.0) as u32, "DYNMODE" => st.dynmode = as_bool(v).ok_or_else(bad)?, + "DYNPIFORMAT" => st.dynpi_cartesian = as_bool(v).ok_or_else(bad)?, + "DYNPICOORDS" => st.dynpi_absolute = as_bool(v).ok_or_else(bad)?, "LWDISPLAY" => st.lwdisplay = as_bool(v).ok_or_else(bad)?, "QPMODE" => st.qpmode = as_i(v).ok_or_else(bad)? > 0, "PICKBOX" => st.pickbox = as_f64(v).ok_or_else(bad)?.clamp(0.0, 50.0), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f2242d53..247bca96 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -503,6 +503,17 @@ fn setvar_keeps_header_types() { assert_eq!(s.doc().unwrap().header.f64("LTSCALE", 0.0), 1.0); } +#[test] +fn dynamic_input_pointer_settings() { + let mut s = Session::new(); + assert_eq!(sysvars::get(&s, "DYNPIFORMAT"), Some(json!(0))); + assert_eq!(sysvars::get(&s, "dynpicoords"), Some(json!(0))); + s.execute("setvar", &json!({"name": "dynpiformat", "value": 1})).unwrap(); + s.execute("setvar", &json!({"name": "DYNPICOORDS", "value": 1})).unwrap(); + assert!(s.settings.dynpi_cartesian && s.settings.dynpi_absolute); + assert!(s.execute("setvar", &json!({"name": "DYNPIFORMAT", "value": "x"})).is_err()); +} + #[test] fn script_runs_commands() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index debb41ed..4744ae24 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -617,8 +617,17 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { } let raw_world = hover_pos.filter(|_| inside).map(|p| xf.to_world(p)); + let dyn_frame = crate::cmdline::dyn_frame(app); if let Some(w) = raw_world { - let eff = effective_point(app, w, &xf); + let mut eff = effective_point(app, w, &xf); + // A locked Dynamic Input value (`40,` or `40<` typed) holds the cursor and overrides snaps. + if let Some(f) = dyn_frame + && let Some(e) = crate::dyninput::parse(&app.cmd.buffer, &f) + && e.first_locked().is_some() + { + eff = crate::dyninput::constrain(&e, &f, eff); + app.canvas.snap = None; + } app.canvas.cursor = Some(eff); app.session.cursor = eff; app.session.cursor_deferred = app.canvas.snap.and_then(|h| h.deferred); @@ -636,6 +645,12 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { { if app.session.running.is_some() { app.canvas.hot_grip = None; + // Clicking places the point; a value typed into the Dynamic Input boxes is used up. + if let Some(f) = dyn_frame + && crate::dyninput::parse(&app.cmd.buffer, &f).is_some() + { + app.cmd.buffer.clear(); + } // A deferred tangent/perpendicular goes to the command as such. let input = app.canvas.snap.filter(|h| h.deferred.is_some()).map_or(Input::Point(p), |h| h.input()); if let Err(e) = app.session.input(input) { @@ -924,8 +939,18 @@ fn draw_crosshair(app: &CadApp, p: &egui::Painter, rect: Rect, at: Pos2, pickbox } } +/// The Dynamic Input tooltip beside the cursor, styled like the command line. At a point prompt +/// it shows the pair of value boxes, a view of the command-line text (see [`crate::dyninput`]); +/// otherwise the prompt with what is being typed, or the live distance and angle. fn dynamic_input(app: &CadApp, p: &egui::Painter, at: Pos2) { let Some(prompt) = app.session.current_prompt() else { return }; + if let Some(f) = crate::cmdline::dyn_frame(app) + && let Some(e) = crate::dyninput::parse(&app.cmd.buffer, &f) + && let Some(c) = app.canvas.cursor + { + dynamic_input_boxes(p, at, &prompt.message, &f, &e, c); + return; + } let mut text = prompt.message.clone(); if !app.cmd.buffer.is_empty() { text = format!("{}: {}", prompt.message, app.cmd.buffer); @@ -944,11 +969,110 @@ fn dynamic_input(app: &CadApp, p: &egui::Painter, at: Pos2) { cadcraft_engine::units::format_distance(c.y, 2, 4) ); } - let galley = p.layout_no_wrap(text, crate::theme::small(), Color32::from_rgb(0x15, 0x15, 0x15)); - let r = Rect::from_min_size(at + vec2(18.0, -30.0), galley.size() + vec2(10.0, 6.0)); - p.rect_filled(r, 2.0, Color32::from_rgba_unmultiplied(0xe9, 0xec, 0xf0, 235)); - p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_rgb(0x8a, 0x93, 0xa0)), egui::StrokeKind::Inside); - p.galley(r.min + vec2(5.0, 3.0), galley, Color32::BLACK); + let t = Tokens::get(); + let galley = p.layout_no_wrap(text, crate::theme::small(), t.text); + let r = tooltip_frame(p, at, galley.size() + vec2(12.0, 8.0)); + p.galley(r.min + vec2(6.0, 4.0), galley, t.text); +} + +/// The tooltip background, matching the command line bar; returns its rectangle. +fn tooltip_frame(p: &egui::Painter, at: Pos2, size: egui::Vec2) -> Rect { + let t = Tokens::get(); + let r = Rect::from_min_size(at + vec2(18.0, -size.y - 14.0), size); + p.rect_filled(r, 3.0, t.cmd_bg); + p.rect_stroke(r, 3.0, Stroke::new(1.0, t.cmd_border), egui::StrokeKind::Inside); + r +} + +/// The Dynamic Input value boxes: X and Y, or distance and angle. Typed values show in full +/// text colour, a locked value (a separator typed after it) gets a lock, the active box gets the +/// accent outline, and open boxes show the cursor's live value, dimmed. Values measured from the +/// last point are marked with `@`. +fn dynamic_input_boxes(p: &egui::Painter, at: Pos2, message: &str, f: &crate::dyninput::Frame, e: &crate::dyninput::Entry<'_>, cursor: Vec2) { + use crate::dyninput::Format; + let t = Tokens::get(); + let font = crate::theme::small(); + let dist = |v: f64| cadcraft_engine::units::format_distance(v, 2, 4); + let (live_first, live_second) = f.live(e.format, cursor); + let live_second = match e.format { + Format::Cartesian => dist(live_second), + Format::Polar => format!("{}°", (live_second.to_degrees() * 10.0).round() / 10.0), + }; + let (label_first, label_second) = match e.format { + Format::Cartesian => ("X", "Y"), + Format::Polar => ("", "<"), + }; + // (label, text, typed, locked, active) + let cells = [ + ( + label_first, + if e.first.is_empty() { dist(live_first) } else { e.first.to_owned() }, + !e.first.is_empty(), + e.first_locked().is_some(), + !e.split, + ), + (label_second, if e.second.is_empty() { live_second } else { e.second.to_owned() }, !e.second.is_empty(), false, e.split), + ]; + + let msg = p.layout_no_wrap(message.to_owned(), font.clone(), t.text_dim); + let marker = p.layout_no_wrap(if f.relative() { "@".to_owned() } else { String::new() }, font.clone(), t.text_faint); + let lock_w = 10.0; + let laid: Vec<_> = cells + .into_iter() + .map(|(label, text, typed, locked, active)| { + let label = p.layout_no_wrap(label.to_owned(), font.clone(), t.text_faint); + let value = p.layout_no_wrap(text, font.clone(), if typed { t.text } else { t.text_faint }); + let w = (value.size().x + 10.0 + if locked { lock_w } else { 0.0 }).max(48.0); + (label, value, w, locked, active) + }) + .collect(); + + // Measure, then draw. + let pad = 6.0; + let gap = 6.0; + let box_h = msg.size().y + 6.0; + let mut w = pad + msg.size().x + gap + marker.size().x; + for (label, _, bw, _, _) in &laid { + w += gap + label.size().x + 3.0 + bw; + } + let r = tooltip_frame(p, at, vec2(w + pad, box_h + 2.0 * pad - 2.0)); + let cy = r.center().y; + let mut x = r.left() + pad; + let (msg_w, msg_h) = (msg.size().x, msg.size().y); + p.galley(pos2(x, cy - msg_h / 2.0), msg, t.text_dim); + x += msg_w + gap; + let mw = marker.size().x; + p.galley(pos2(x, cy - marker.size().y / 2.0), marker, t.text_faint); + x += mw; + for (label, value, bw, locked, active) in laid { + x += gap; + let lw = label.size().x; + p.galley(pos2(x, cy - label.size().y / 2.0), label, t.text_faint); + x += lw + 3.0; + let b = Rect::from_min_size(pos2(x, cy - box_h / 2.0), vec2(bw, box_h)); + p.rect_filled(b, 2.0, t.chrome_dark); + let stroke = if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.border) }; + p.rect_stroke(b, 2.0, stroke, egui::StrokeKind::Inside); + let vw = value.size().x; + p.galley(pos2(b.left() + 5.0, cy - value.size().y / 2.0), value, t.text); + if active { + let cx = b.left() + 6.0 + vw; + p.line_segment([pos2(cx, b.top() + 3.0), pos2(cx, b.bottom() - 3.0)], Stroke::new(1.0, t.text)); + } + if locked { + draw_lock(p, pos2(b.right() - lock_w / 2.0 - 3.0, cy), t.accent); + } + x += bw; + } +} + +/// A small padlock, drawn in code: body plus shackle. +fn draw_lock(p: &egui::Painter, c: Pos2, color: Color32) { + p.rect_filled(Rect::from_center_size(c + vec2(0.0, 1.5), vec2(7.0, 5.0)), 1.0, color); + p.add(Shape::line( + vec![pos2(c.x - 2.0, c.y - 1.0), pos2(c.x - 2.0, c.y - 4.0), pos2(c.x + 2.0, c.y - 4.0), pos2(c.x + 2.0, c.y - 1.0)], + Stroke::new(1.2, color), + )); } fn draw_ucs_icon(p: &egui::Painter, rect: Rect) { diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs index c66798a6..48bbf45b 100644 --- a/crates/ui-egui/src/cmdline.rs +++ b/crates/ui-egui/src/cmdline.rs @@ -26,6 +26,52 @@ pub struct CmdLine { pub const MAX_LINES: usize = 12; const LINE_H: f32 = 16.0; +/// The Dynamic Input frame for the prompt on screen, or `None` when the value boxes are not live +/// (Dynamic Input off, no point prompt, a hot grip or a pending selection window). +pub fn dyn_frame(app: &CadApp) -> Option { + let st = &app.session.settings; + if !st.dynmode || app.canvas.hot_grip.is_some() || app.session.pending_window.is_some() { + return None; + } + let p = app.session.current_prompt().filter(|p| p.accept.point && !p.accept.select)?; + Some(crate::dyninput::Frame { base: p.base, cartesian: st.dynpi_cartesian, absolute: st.dynpi_absolute }) +} + +/// Take Tab away from egui before it moves keyboard focus to the next widget (which would leave +/// the command line deaf to typing). While no widget has focus, a pressed Tab becomes a `"\t"` +/// text event, which keeps its place among the other keystrokes; [`keyboard`] handles it. +pub fn capture_tab(ctx: &egui::Context, raw: &mut egui::RawInput) { + if ctx.egui_wants_keyboard_input() { + return; + } + raw.events.retain_mut(|e| { + let tab = match e { + egui::Event::Key { key: Key::Tab, pressed, .. } => Some(*pressed), + _ => None, + }; + match tab { + Some(true) => { + *e = egui::Event::Text("\t".into()); + true + } + Some(false) => false, + None => true, + } + }); +} + +/// Tab on the command line: type the Dynamic Input separator (`,` or `<`) after a value, or +/// complete a command name. +fn tab(app: &mut CadApp) { + if let Some(f) = dyn_frame(app) + && let Some(sep) = crate::dyninput::tab_separator(&app.cmd.buffer, &f) + { + app.cmd.buffer.push(sep); + } else if let Some((id, _)) = suggestions(&app.cmd.buffer).first() { + app.cmd.buffer = id.clone(); + } +} + /// Matching command names/aliases for AutoComplete. fn suggestions(prefix: &str) -> Vec<(String, &'static str)> { if prefix.is_empty() || prefix.contains(' ') { @@ -91,7 +137,7 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) { if app.canvas.hot_grip.is_some() { for ev in &events { match ev { - egui::Event::Text(t) if t != " " => app.cmd.buffer.push_str(t), + egui::Event::Text(t) if t != " " && t != "\t" => app.cmd.buffer.push_str(t), egui::Event::Key { key: Key::Backspace, pressed: true, .. } => { app.cmd.buffer.pop(); } @@ -125,7 +171,9 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) { for ev in events { match ev { egui::Event::Text(t) => { - if t == " " && !text_prompt { + if t == "\t" { + tab(app); + } else if t == " " && !text_prompt { submit(app); } else { app.cmd.buffer.push_str(&t); @@ -173,11 +221,7 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) { } } } - Key::Tab => { - if let Some((id, _)) = suggestions(&app.cmd.buffer).first() { - app.cmd.buffer = id.clone(); - } - } + Key::Tab => tab(app), Key::Delete if app.cmd.buffer.is_empty() && app.session.running.is_none() => { let _ = app.run("erase.selection", serde_json::json!({})); } @@ -190,6 +234,16 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) { } pub fn submit(app: &mut CadApp) { + // Dynamic Input: a split entry (`40,30`, `40<90`, `40,`) is completed from the cursor and + // measured from the last point. + if let Some(f) = dyn_frame(app) { + let cursor = app.canvas.cursor.unwrap_or(app.session.cursor); + let last = app.session.last_point; + let done = crate::dyninput::parse(&app.cmd.buffer, &f).and_then(|e| crate::dyninput::complete(&e, &f, cursor, last)); + if let Some(text) = done { + app.cmd.buffer = text; + } + } let text = std::mem::take(&mut app.cmd.buffer); app.cmd.history_pos = None; if !text.trim().is_empty() && app.session.running.is_none() { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5fd0cc8c..356b35e3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -87,6 +87,20 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { }); ui.separator(); ui.checkbox(&mut s.dynmode, "Enable Dynamic Input (F12)"); + // Pointer input format for second and next points (DYNPIFORMAT, DYNPICOORDS). + ui.indent("dynpi", |ui| { + ui.add_enabled_ui(s.dynmode, |ui| { + ui.label("Second and next points:"); + ui.horizontal(|ui| { + ui.radio_value(&mut s.dynpi_cartesian, false, "Polar (distance < angle)"); + ui.radio_value(&mut s.dynpi_cartesian, true, "Cartesian (x, y)"); + }); + ui.horizontal(|ui| { + ui.radio_value(&mut s.dynpi_absolute, false, "Relative"); + ui.radio_value(&mut s.dynpi_absolute, true, "Absolute"); + }); + }); + }); ui.checkbox(&mut s.orthomode, "Ortho (F8)"); }); } diff --git a/crates/ui-egui/src/dyninput.rs b/crates/ui-egui/src/dyninput.rs new file mode 100644 index 00000000..01078a5d --- /dev/null +++ b/crates/ui-egui/src/dyninput.rs @@ -0,0 +1,287 @@ +//! Dynamic Input pointer input: the value boxes beside the cursor while a command asks for a point. +//! +//! The command-line text is the single source of truth; the boxes are a view of it: +//! - `40` is typing in the first box (X, or the distance for second and next points, which +//! default to polar as in AutoCAD; DYNPIFORMAT = 1 makes them Cartesian). +//! - `40,` locks X = 40 and moves to Y; `40<` locks the distance and moves to the angle. A locked +//! value holds the cursor. +//! - Tab types the separator for the format on show, so `40` Tab `90` reads `40<90`. +//! - Enter sends the entry with `@` in front when it is measured from the last point (relative is +//! the default; DYNPICOORDS = 1 makes it absolute), and fills an empty box from the cursor. +//! - A plain number and Enter stays direct distance entry; `@`/`#` entries, keywords and command +//! names go through untouched. +//! +//! Scripts, the control channel and MCP never pass through here, so they keep the plain +//! command-line rules. + +use cadcraft_geom::Vec2; + +/// How the boxes read the point. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Format { + /// X and Y. + #[default] + Cartesian, + /// Distance and angle. + Polar, +} + +impl Format { + /// The character that ends the first value. + pub fn separator(self) -> char { + match self { + Format::Cartesian => ',', + Format::Polar => '<', + } + } +} + +/// The settings and prompt the boxes are working for. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Frame { + /// The prompt's base point (the last point), if it has one. + pub base: Option, + /// DYNPIFORMAT = 1: second and next points start Cartesian instead of polar. + pub cartesian: bool, + /// DYNPICOORDS = 1: second and next points are absolute instead of relative. + pub absolute: bool, +} + +impl Frame { + /// The format the boxes show before a separator is typed. + pub fn default_format(&self) -> Format { + if self.base.is_some() && !self.cartesian { Format::Polar } else { Format::Cartesian } + } + /// Where the typed values are measured from. + pub fn origin(&self) -> Vec2 { + match self.base { + Some(b) if !self.absolute => b, + _ => Vec2::ZERO, + } + } + /// Whether values are offsets from the last point. + pub fn relative(&self) -> bool { + self.base.is_some() && !self.absolute + } + + /// The live (X, Y) or (distance, angle in radians) of `cursor`. + pub fn live(&self, format: Format, cursor: Vec2) -> (f64, f64) { + let d = cursor - self.origin(); + match format { + Format::Cartesian => (d.x, d.y), + Format::Polar => (d.len(), d.y.atan2(d.x)), + } + } +} + +/// The command-line text read as a pointer-input entry. +#[derive(Clone, Debug, PartialEq)] +pub struct Entry<'a> { + pub format: Format, + /// Text in the first box (X or distance). + pub first: &'a str, + /// A separator was typed: the first box is done and the second box is active. + pub split: bool, + /// Text in the second box (Y or angle). + pub second: &'a str, +} + +impl Entry<'_> { + /// The locked first value, once a separator follows it. + pub fn first_locked(&self) -> Option { + if self.split { cadcraft_engine::units::parse_distance(self.first) } else { None } + } + /// The value typed in the second box (radians for an angle). + pub fn second_value(&self) -> Option { + let t = self.second.trim(); + if t.is_empty() { + return None; + } + match self.format { + Format::Cartesian => cadcraft_engine::units::parse_distance(t), + Format::Polar => cadcraft_engine::units::parse_angle(t), + } + } +} + +/// Read `text` as a pointer-input entry, or `None` when it is something else (an explicit +/// `@`/`#` entry, a keyword, a command name, a 3D point). +pub fn parse<'a>(text: &'a str, frame: &Frame) -> Option> { + let t = text.trim(); + if t.starts_with('@') || t.starts_with('#') { + return None; + } + let entry = if let Some((a, b)) = t.split_once(',') { + Entry { format: Format::Cartesian, first: a.trim(), split: true, second: b.trim() } + } else if let Some((a, b)) = t.split_once('<') { + Entry { format: Format::Polar, first: a.trim(), split: true, second: b.trim() } + } else { + Entry { format: frame.default_format(), first: t, split: false, second: "" } + }; + let number_or_empty = |s: &str| s.is_empty() || cadcraft_engine::units::parse_distance(s).is_some(); + if !number_or_empty(entry.first) || entry.second.contains([',', '<']) { + return None; + } + if !entry.second.is_empty() && entry.second_value().is_none() { + return None; + } + Some(entry) +} + +/// What Tab types: the separator for the format on show, when the first box is still active. +pub fn tab_separator(text: &str, frame: &Frame) -> Option { + parse(text, frame).filter(|e| !e.split).map(|e| e.format.separator()) +} + +/// Hold the cursor to the locked first value. +pub fn constrain(entry: &Entry<'_>, frame: &Frame, p: Vec2) -> Vec2 { + let Some(v) = entry.first_locked() else { return p }; + let o = frame.origin(); + match entry.format { + Format::Cartesian => Vec2::new(o.x + v, p.y), + Format::Polar => { + let d = p - o; + let l = d.len(); + let dir = if l > 1e-12 { d * (1.0 / l) } else { Vec2::X }; + o + dir * v + } + } +} + +/// The command-line text to send for a finished entry, or `None` to send the text as typed +/// (nothing split yet: a plain number stays direct distance entry). Empty boxes take the cursor's +/// value. Entries measured from the last point are sent with `@` when the prompt's base is the +/// session's last point (`last`, what `@` resolves against); otherwise, and for absolute entries, +/// the finished point is sent as `#x,y`, so what was previewed is exactly what is placed. +pub fn complete(entry: &Entry<'_>, frame: &Frame, cursor: Vec2, last: Vec2) -> Option { + if !entry.split { + return None; + } + let (live_first, live_second) = frame.live(entry.format, constrain(entry, frame, cursor)); + let first = entry.first_locked().unwrap_or(live_first); + let second = entry.second_value().unwrap_or(live_second); + if frame.relative() && frame.base == Some(last) { + return Some(match entry.format { + Format::Cartesian => format!("@{first},{second}"), + Format::Polar => format!("@{first}<{}", second.to_degrees()), + }); + } + let p = match entry.format { + Format::Cartesian => frame.origin() + Vec2::new(first, second), + Format::Polar => frame.origin() + Vec2::from_angle(second) * first, + }; + p.is_finite().then(|| format!("#{},{}", p.x, p.y)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn second_point(base: Vec2) -> Frame { + Frame { base: Some(base), cartesian: false, absolute: false } + } + + fn sent(text: &str, frame: &Frame, cursor: Vec2, last: Vec2) -> Option { + let e = parse(text, frame)?; + let t = complete(&e, frame, cursor, last)?; + cadcraft_engine::prompt::parse_point(&t, last) + } + + fn close(a: Option, b: Vec2) -> bool { + a.is_some_and(|a| a.dist(b) < 1e-9) + } + + #[test] + fn rectangle_corner_with_comma_is_relative() { + // Corner snapped at (0,60); `40,40` Enter -> (40,100). + let base = Vec2::new(0.0, 60.0); + let f = second_point(base); + assert!(close(sent("40,40", &f, Vec2::ZERO, base), Vec2::new(40.0, 100.0))); + } + + #[test] + fn tab_types_the_separator_for_the_format_on_show() { + let base = Vec2::new(10.0, 10.0); + let polar = second_point(base); + assert_eq!(tab_separator("40", &polar), Some('<')); + // `40` Tab `90` Enter: 40 units straight up. + assert!(close(sent("40<90", &polar, Vec2::ZERO, base), Vec2::new(10.0, 50.0))); + // Already split: Tab does nothing more. + assert_eq!(tab_separator("40<", &polar), None); + let cart = Frame { cartesian: true, ..polar }; + assert_eq!(tab_separator("40", &cart), Some(',')); + // First point: always X then Y. + assert_eq!(tab_separator("3", &Frame::default()), Some(',')); + // Not a number: Tab falls back to AutoComplete. + assert_eq!(tab_separator("rec", &polar), None); + } + + #[test] + fn first_point_is_absolute() { + let f = Frame::default(); + assert!(close(sent("0,60", &f, Vec2::new(9.0, 9.0), Vec2::new(5.0, 5.0)), Vec2::new(0.0, 60.0))); + } + + #[test] + fn absolute_setting_measures_from_the_origin() { + let f = Frame { base: Some(Vec2::new(5.0, 5.0)), cartesian: true, absolute: true }; + assert!(close(sent("40,100", &f, Vec2::ZERO, Vec2::new(5.0, 5.0)), Vec2::new(40.0, 100.0))); + } + + #[test] + fn empty_box_takes_the_cursor() { + let base = Vec2::new(1.0, 1.0); + let f = second_point(base); + // X locked at 3 (relative), Y from the cursor. + assert!(close(sent("3,", &f, Vec2::new(-7.0, 9.0), base), Vec2::new(4.0, 9.0))); + // Distance locked at 5, angle from the cursor (straight up). + assert!(close(sent("5<", &f, Vec2::new(1.0, 100.0), base), Vec2::new(1.0, 6.0))); + } + + #[test] + fn locked_value_holds_the_cursor() { + let base = Vec2::new(1.0, 1.0); + let f = second_point(base); + let e = parse("5<", &f).unwrap(); + assert!(close(Some(constrain(&e, &f, Vec2::new(1.0, 100.0))), Vec2::new(1.0, 6.0))); + let e = parse("3,", &f).unwrap(); + assert_eq!(constrain(&e, &f, Vec2::new(-7.0, 9.0)), Vec2::new(4.0, 9.0)); + // Still typing the first value: nothing is locked yet. + let e = parse("3", &f).unwrap(); + assert_eq!(constrain(&e, &f, Vec2::new(-7.0, 9.0)), Vec2::new(-7.0, 9.0)); + } + + #[test] + fn plain_numbers_and_other_text_go_through_as_typed() { + let f = second_point(Vec2::ZERO); + // Direct distance entry. + assert_eq!(parse("25", &f).and_then(|e| complete(&e, &f, Vec2::X, Vec2::ZERO)), None); + for t in ["@40,40", "#0,0", "c", "rec", "1,2,3", "40,abc"] { + assert!(parse(t, &f).and_then(|e| complete(&e, &f, Vec2::X, Vec2::ZERO)).is_none(), "{t}"); + } + } + + #[test] + fn relative_entries_use_at_only_when_the_base_is_the_last_point() { + let base = Vec2::new(0.0, 60.0); + let f = second_point(base); + let e = parse("40,40", &f).unwrap(); + assert_eq!(complete(&e, &f, Vec2::ZERO, base).as_deref(), Some("@40,40")); + // A prompt whose base is not the last point: send the finished point itself. + let other_last = Vec2::new(500.0, 500.0); + let t = complete(&e, &f, Vec2::ZERO, other_last).unwrap(); + assert!(t.starts_with('#'), "{t}"); + assert!(close(cadcraft_engine::prompt::parse_point(&t, other_last), Vec2::new(40.0, 100.0))); + // Polar too. + let e = parse("40<90", &f).unwrap(); + let t = complete(&e, &f, Vec2::ZERO, other_last).unwrap(); + assert!(close(cadcraft_engine::prompt::parse_point(&t, other_last), Vec2::new(0.0, 100.0))); + } + + #[test] + fn empty_text_is_an_empty_entry() { + let f = second_point(Vec2::ZERO); + let e = parse("", &f).unwrap(); + assert_eq!((e.format, e.split), (Format::Polar, false)); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 59cc389e..e5cbf207 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -15,6 +15,7 @@ pub mod cmdline; pub mod control; pub mod credits; pub mod dialogs; +pub mod dyninput; pub mod gpu; pub mod icons; pub mod layers; From 1a4a477f919d19989f127b44bd4804f377afe76a Mon Sep 17 00:00:00 2001 From: LloydNicholson <34131158+LloydNicholson@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:23:37 +0200 Subject: [PATCH 024/150] Ask to save unsaved changes before closing the window (#34) (#47) Closing the window (title bar, File > Exit, Cmd+Q) with unsaved drawings now cancels the close and asks Save / Don't Save / Cancel for each one, through the same in-app prompt as File > Close All (#172). The app quits once every drawing has been saved or discarded; Cancel keeps it open. The control channel's app.quit never asks. Rebuilt on top of #172 from LloydNicholson's original PR. Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/cadcraft/src/main.rs | 2 +- crates/ui-egui/src/closing.rs | 95 +++++++++++++++++++++++++++++++++-- crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/lib.rs | 8 +++ 4 files changed, 101 insertions(+), 6 deletions(-) diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 5e3870a6..3da95cdf 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -38,7 +38,7 @@ impl eframe::App for App { } } self.0.logic(ctx); - if self.0.quit_requested { + if std::mem::take(&mut self.0.quit_requested) { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } } diff --git a/crates/ui-egui/src/closing.rs b/crates/ui-egui/src/closing.rs index 8362a0f8..ab2fd7b1 100644 --- a/crates/ui-egui/src/closing.rs +++ b/crates/ui-egui/src/closing.rs @@ -1,11 +1,12 @@ -//! Closing drawings from the UI: File ▸ Close (or typing CLOSE), a file tab's ×, and -//! File ▸ Close All. +//! Closing drawings from the UI: File ▸ Close (or typing CLOSE), a file tab's ×, +//! File ▸ Close All, and closing the app (the window's close button, File ▸ Exit, Cmd+Q). //! //! A drawing with unsaved changes is not closed until the user answers "Save changes to …?" //! (Save / Don't Save / Cancel) in an in-app modal — the same on desktop and on the web, where no //! native message box exists. Close All works through the drawings in tab order and asks about //! each dirty one; Cancel stops it there (drawings already closed stay closed). Save closes the -//! drawing only if it really was saved. +//! drawing only if it really was saved. Closing the app asks the same way, and quits once every +//! drawing has been dealt with; Cancel keeps the app open. //! //! Programmatic `close` / `closeall` (`engine.execute`, the control channel, MCP, scripts) never //! ask: they keep their discard semantics. @@ -33,6 +34,8 @@ pub struct Closing { queue: VecDeque, /// The drawing the prompt is asking about (always the front of `queue`). asking: Option, + /// Quit the app once the queue is done (the window was closed). + quit: bool, } impl CadApp { @@ -55,6 +58,21 @@ impl CadApp { self.begin_close(uids); } + /// The app is closing (window close button, File ▸ Exit, Cmd+Q): with unsaved changes, ask + /// about each drawing first and quit once all are dealt with. Returns whether the close has to + /// wait (the caller then cancels the window close). + pub fn request_quit(&mut self) -> bool { + if self.quit_confirmed || !self.session.docs.iter().any(|d| d.is_dirty()) { + return false; + } + if self.closing.is_none() { + let uids = self.session.docs.iter().map(|d| d.uid).collect(); + self.closing = Some(Closing { queue: uids, asking: None, quit: true }); + self.advance_close(); + } + true + } + /// Title of the drawing the close prompt is asking about, if a prompt is open. pub fn close_prompt(&self) -> Option<&str> { let uid = self.closing.as_ref()?.asking?; @@ -116,7 +134,7 @@ impl CadApp { // A prompt is already open; it has to be answered first. return; } - self.closing = Some(Closing { queue: uids.into(), asking: None }); + self.closing = Some(Closing { queue: uids.into(), asking: None, quit: false }); self.advance_close(); } @@ -137,7 +155,11 @@ impl CadApp { fn advance_close(&mut self) { loop { let Some(uid) = self.closing.as_ref().and_then(|c| c.queue.front().copied()) else { - self.closing = None; + if self.closing.take().is_some_and(|c| c.quit) { + // Every drawing was saved or discarded: now the app may close. + self.quit_confirmed = true; + self.quit_requested = true; + } return; }; let Some(i) = self.doc_index(uid) else { @@ -295,6 +317,69 @@ mod tests { assert!(app.session.docs[0].is_dirty()); } + #[test] + fn quitting_asks_about_each_dirty_drawing_then_quits() { + let mut app = app_with(&["A", "B", "C"]); + assert!(!app.request_quit(), "nothing unsaved: quit right away"); + dirty(&mut app, 0); + dirty(&mut app, 2); + assert!(app.request_quit(), "unsaved changes hold the window open"); + assert_eq!(app.close_prompt(), Some("A")); + app.answer_close(Choice::Discard); + assert_eq!(app.close_prompt(), Some("C")); + assert!(!app.quit_requested); + app.answer_close(Choice::Discard); + assert!(app.closing.is_none()); + assert!(app.quit_requested && app.quit_confirmed); + assert!(!app.request_quit(), "the confirmed close goes through"); + } + + #[test] + fn cancel_keeps_the_app_open() { + let mut app = app_with(&["A"]); + dirty(&mut app, 0); + assert!(app.request_quit()); + app.answer_close(Choice::Cancel); + assert!(app.closing.is_none()); + assert!(!app.quit_requested && !app.quit_confirmed); + assert_eq!(titles(&app), ["A"]); + assert!(app.session.docs[0].is_dirty()); + } + + /// One frame in which the window manager asks to close the window; returns whether the app + /// cancelled the close. + fn close_frame(app: &mut CadApp, ctx: &egui::Context) -> bool { + use egui::{ViewportCommand, ViewportEvent, ViewportId, ViewportInfo}; + let mut input = egui::RawInput::default(); + input.viewports.insert(ViewportId::ROOT, ViewportInfo { events: vec![ViewportEvent::Close], ..Default::default() }); + let mut out = ctx.run_ui(input, |ui| app.logic(ui.ctx())); + out.textures_delta.clear(); + out.viewport_output.get(&ViewportId::ROOT).is_some_and(|v| v.commands.contains(&ViewportCommand::CancelClose)) + } + + /// Issue #34: closing the window with unsaved changes asks first instead of losing them. + #[test] + fn window_close_with_unsaved_changes_is_held() { + let ctx = egui::Context::default(); + let mut app = app_with(&["A"]); + assert!(!close_frame(&mut app, &ctx), "a saved drawing closes without asking"); + dirty(&mut app, 0); + assert!(close_frame(&mut app, &ctx), "unsaved changes must cancel the close"); + assert_eq!(app.close_prompt(), Some("A")); + } + + /// Programmatic quit (control channel `app.quit`) never asks. + #[test] + fn programmatic_quit_skips_the_prompt() { + let ctx = egui::Context::default(); + let mut app = app_with(&["A"]); + dirty(&mut app, 0); + let (req, _rx) = crate::control::ControlRequest::new("app.quit", json!({})); + let _ = crate::control::handle(&mut app, &ctx, &req); + assert!(!close_frame(&mut app, &ctx)); + assert!(app.closing.is_none()); + } + #[test] fn programmatic_close_never_prompts() { let mut app = app_with(&["A", "B", "C"]); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 0652944b..1e322649 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -299,6 +299,8 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou } "app.save" => wrap(app.run(if s("path").is_some() { "saveas" } else { "qsave" }, p.clone())), "app.quit" => { + // Programmatic calls never open dialogs: quit without the unsaved-changes prompt. + app.quit_confirmed = true; app.quit_requested = true; ctx.send_viewport_cmd(egui::ViewportCommand::Close); ok(Value::Null) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e5cbf207..2c6ed3ae 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -116,6 +116,9 @@ pub struct CadApp { shown_theme: Option, pub frame_ms: f64, pub quit_requested: bool, + /// The app may close although drawings have unsaved changes (they were dealt with, or the + /// quit came from the control channel, which never asks). + pub quit_confirmed: bool, /// A close request waiting for "Save changes?" answers ([`closing`]). pub closing: Option, } @@ -141,6 +144,7 @@ impl CadApp { shown_theme: None, frame_ms: 0.0, quit_requested: false, + quit_confirmed: false, closing: None, } } @@ -233,6 +237,10 @@ impl CadApp { /// Per-frame logic before layout. pub fn logic(&mut self, ctx: &egui::Context) { + // Closing the window (title bar, File ▸ Exit, Cmd+Q) with unsaved changes asks first. + if ctx.input(|i| i.viewport().close_requested()) && self.request_quit() { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } if !self.styled { theme::install_fonts(ctx); self.styled = true; From 733fa94590d836c4820384ce864ca7014604d8a2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 13:32:38 -0400 Subject: [PATCH 025/150] docs/parity.md: recompute after the community PR batch (295 commands) Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/parity.md b/docs/parity.md index f42f050b..926c122a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,7 +2,7 @@ Generated by `cargo xtask parity`. 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 ROADMAP.md for the weighted estimate. -**Menu breadth: 233 / 491 items (47%)** · 290 registered commands. +**Menu breadth: 233 / 491 items (47%)** · 295 registered commands. | Menu | Items | Covered | % | |---|---|---|---| From 659d0fa1254627f28f6648e69419719ba4ee50af Mon Sep 17 00:00:00 2001 From: Dmytro <140102908+dmatviichuk@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:48:44 +0200 Subject: [PATCH 026/150] i18n: add Ukrainian interface translation (#36) Rebase onto main: the interface language is saved with main's other preferences (cadcraft.prefs, beside the theme) instead of its own storage key, on desktop and web; strings main added or changed in the touched places (Dynamic Input settings, Properties header and current-property rows, block picker, theme and command-history menu items, open/save filters) are wrapped and translated; main's native-menu poll order is kept. Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.md | 6 + Cargo.lock | 10 + README.md | 1 + ROADMAP.md | 2 +- apps/cadcraft-web/Cargo.toml | 4 +- apps/cadcraft-web/src/main.rs | 12 + apps/cadcraft/Cargo.toml | 1 + apps/cadcraft/src/main.rs | 9 +- apps/cadcraft/src/native_menu.rs | 85 ++- crates/ui-egui/src/canvas.rs | 28 +- crates/ui-egui/src/chrome.rs | 38 +- crates/ui-egui/src/cmdline.rs | 22 +- crates/ui-egui/src/credits.rs | 38 +- crates/ui-egui/src/dialogs.rs | 254 +++++---- crates/ui-egui/src/i18n/mod.rs | 186 ++++++ crates/ui-egui/src/i18n/tests.rs | 290 ++++++++++ crates/ui-egui/src/i18n/uk.tsv | 942 +++++++++++++++++++++++++++++++ crates/ui-egui/src/icons.rs | 2 +- crates/ui-egui/src/layers.rs | 102 ++-- crates/ui-egui/src/lib.rs | 20 +- crates/ui-egui/src/menus.rs | 26 +- crates/ui-egui/src/palettes.rs | 109 ++-- crates/ui-egui/src/parametric.rs | 112 ++-- crates/ui-egui/src/quick.rs | 265 ++++----- docs/control-protocol.md | 13 + docs/localization.md | 66 +++ 26 files changed, 2187 insertions(+), 456 deletions(-) create mode 100644 crates/ui-egui/src/i18n/mod.rs create mode 100644 crates/ui-egui/src/i18n/tests.rs create mode 100644 crates/ui-egui/src/i18n/uk.tsv create mode 100644 docs/localization.md diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 6cb71793..bbdb6900 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -51,3 +51,9 @@ Generated-in-code assets are original and have no file to list: | `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | | `docs/brand/artcraft-mark.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | | `docs/brand/artcraft-mark.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | + +## Interface translations + +| Asset | Author | Source | Licence | Notes | +|---|---|---|---|---| +| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk | Original Ukrainian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied | diff --git a/Cargo.lock b/Cargo.lock index 4c95b3de..4337ad05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -551,6 +551,7 @@ dependencies = [ "muda", "rfd", "serde_json", + "sys-locale", "winit", "winresource", ] @@ -3382,6 +3383,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" diff --git a/README.md b/README.md index 4e8c8a4b..b98da27e 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ numbers): | Layouts & plotting | Paper-space layouts, viewports (scale, lock, per-viewport layer freeze), MSPACE/PSPACE through viewports, page setups, PLOT and EXPORTPDF | | Parametric | Geometric and dimensional constraints, AUTOCONSTRAIN, PARAMETERS with expressions, conflict detection; constraints re-solve after every edit | | Grips | Hot grips with Space to cycle Stretch, Move, Rotate, Scale and Mirror | +| Interface languages | English and Ukrainian (Українська), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) | | Automation | MCP server, JSON control channel, `cadcraft-cli` (info, convert, run, commands, mcp) | ## Quick start diff --git a/ROADMAP.md b/ROADMAP.md index b84f3bca..bf055a17 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -73,7 +73,7 @@ edge cases, dialogs), which is why it is lower. | M10 | Performance: GPU canvas (wgpu batches), R-tree spatial index, incremental regen, 1M-entity drawings at 60 fps | ~70% (GPU canvas, incremental R-tree: pick 50 ms → 0.001 ms at 200k entities, ≈ 4 ms after an edit) | 12 | | M11 | 3D: UCS, orbit, visual styles, solids (box…loft, booleans, fillet edges), meshes, surfaces, sections, rendering | 0% | 200 | | M12 | Automation: an embedded safe AutoLISP-compatible interpreter, action recorder, sheet sets, CLI/MCP parity tests | ~25% | 60 | -| M13 | 1.0 polish: preferences, workspaces, themes, localisation, accessibility, signed packages for every platform, docs | ~10% | 40 | +| M13 | 1.0 polish: preferences, workspaces, themes, localisation, accessibility, signed packages for every platform, docs | ~10% (English/Ukrainian interface catalogs, live language switching and persisted system-language choice) | 40 | | | **Remaining total** | | **≈ 570 hours** | At roughly 570 more hours of Opus 5.5 wall-clock work (with parallel agents this compresses to diff --git a/apps/cadcraft-web/Cargo.toml b/apps/cadcraft-web/Cargo.toml index c966303b..c89e1272 100644 --- a/apps/cadcraft-web/Cargo.toml +++ b/apps/cadcraft-web/Cargo.toml @@ -22,7 +22,7 @@ log = { workspace = true } serde_json = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } +eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator"] } diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index 4f4007a1..6810ccea 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -25,6 +25,13 @@ mod web { self.0.raw_input_hook(raw); cadcraft_ui_egui::cmdline::capture_tab(ctx, raw); } + /// Preferences (interface theme and language) survive reloads; egui state is not kept. + fn save(&mut self, storage: &mut dyn eframe::Storage) { + storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.0.prefs_json()); + } + fn persist_egui_memory(&self) -> bool { + false + } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.0.ui(ui); } @@ -59,6 +66,11 @@ mod web { options, Box::new(move |cc| { let mut app = CadApp::new(Session::new(), Services::default()); + app.system_languages = + web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|v| v.as_string()).collect()).unwrap_or_default(); + if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) { + app.load_prefs(&prefs); + } if let Some(rs) = &cc.wgpu_render_state { app.set_wgpu(rs); } diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml index a59177f4..90b55b22 100644 --- a/apps/cadcraft/Cargo.toml +++ b/apps/cadcraft/Cargo.toml @@ -27,6 +27,7 @@ winit = { version = "0.30", default-features = false, features = ["x11"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } +sys-locale = "0.3" [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.21", default-features = false } diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 3da95cdf..3ae72eb3 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -22,7 +22,7 @@ mod logging; mod native_menu; use cadcraft_engine::Session; -use cadcraft_ui_egui::{CadApp, Services}; +use cadcraft_ui_egui::{CadApp, Services, i18n}; struct App(CadApp, #[cfg(target_os = "macos")] Option); @@ -71,8 +71,8 @@ fn services() -> Services { Services { pick_open: Some(Box::new(|| { rfd::FileDialog::new() - .add_filter("Drawings (DWG, DXF)", &["dwg", "dxf"]) - .add_filter("All files", &["*"]) + .add_filter(i18n::t("Drawings (DWG, DXF)"), &["dwg", "dxf"]) + .add_filter(i18n::t("All files"), &["*"]) .pick_file() .map(|p| p.to_string_lossy().to_string()) })), @@ -83,7 +83,7 @@ fn services() -> Services { filters.sort_by_key(|(_, ext)| !lower.ends_with(&format!(".{ext}"))); filters .iter() - .fold(rfd::FileDialog::new().set_file_name(name), |d, (label, ext)| d.add_filter(*label, &[*ext])) + .fold(rfd::FileDialog::new().set_file_name(name), |d, (label, ext)| d.add_filter(i18n::t(label), &[*ext])) .save_file() .map(|p| p.to_string_lossy().to_string()) })), @@ -212,6 +212,7 @@ fn main() -> eframe::Result { options, Box::new(move |cc| { let mut app = CadApp::new(Session::empty(), services()); + app.system_languages = std::env::var("CADCRAFT_LOCALE").map(|s| vec![s]).unwrap_or_else(|_| sys_locale::get_locales().collect()); if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) { app.load_prefs(&prefs); } diff --git a/apps/cadcraft/src/native_menu.rs b/apps/cadcraft/src/native_menu.rs index dfb8c905..be1c6905 100644 --- a/apps/cadcraft/src/native_menu.rs +++ b/apps/cadcraft/src/native_menu.rs @@ -4,14 +4,17 @@ use std::collections::HashMap; use std::str::FromStr; -use cadcraft_ui_egui::CadApp; use cadcraft_ui_egui::menus::{self, Entry}; +use cadcraft_ui_egui::{CadApp, i18n}; use muda::accelerator::Accelerator; use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; pub struct NativeMenu { _menu: Menu, - items: HashMap, + items: HashMap, + submenus: Vec<(String, Submenu)>, + predefined: Vec<(&'static str, PredefinedMenuItem)>, + language: &'static str, last_refresh: f64, } @@ -27,37 +30,42 @@ impl NativeMenu { pub fn install(app: &mut CadApp) -> Self { let menu = Menu::new(); let mut items = HashMap::new(); + let language = app.language(); + let mut submenus = Vec::new(); let mut counter = 0usize; let app_menu = Submenu::new("CADCraft", true); - let about = MenuItem::with_id("cc-about", "About CADCraft", true, None); - let discord = MenuItem::with_id("cc-discord", "Join the ArtCraft Discord…", true, None); - let _ = app_menu.append_items(&[ - &about, - &discord, - &PredefinedMenuItem::separator(), - &PredefinedMenuItem::services(None), - &PredefinedMenuItem::separator(), - &PredefinedMenuItem::hide(None), - &PredefinedMenuItem::hide_others(None), - &PredefinedMenuItem::show_all(None), - &PredefinedMenuItem::separator(), - &PredefinedMenuItem::quit(None), - ]); - items.insert("cc-about".into(), ("ui.dialog.about".into(), about)); - items.insert("cc-discord".into(), ("ui.discord".into(), discord)); + let about = MenuItem::with_id("cc-about", i18n::tr(language, "About CADCraft"), true, None); + let discord = MenuItem::with_id("cc-discord", i18n::tr(language, "Join the ArtCraft Discord…"), true, None); + let predefined = vec![ + ("Services", PredefinedMenuItem::services(Some(i18n::tr(language, "Services")))), + ("Hide CADCraft", PredefinedMenuItem::hide(Some(i18n::tr(language, "Hide CADCraft")))), + ("Hide Others", PredefinedMenuItem::hide_others(Some(i18n::tr(language, "Hide Others")))), + ("Show All", PredefinedMenuItem::show_all(Some(i18n::tr(language, "Show All")))), + ("Quit CADCraft", PredefinedMenuItem::quit(Some(i18n::tr(language, "Quit CADCraft")))), + ]; + let _ = app_menu.append_items(&[&about, &discord, &PredefinedMenuItem::separator()]); + for (label, item) in &predefined { + let _ = app_menu.append(item); + if ["Services", "Show All"].contains(label) { + let _ = app_menu.append(&PredefinedMenuItem::separator()); + } + } + items.insert("cc-about".into(), ("ui.dialog.about".into(), "About CADCraft".into(), about)); + items.insert("cc-discord".into(), ("ui.discord".into(), "Join the ArtCraft Discord…".into(), discord)); let _ = menu.append(&app_menu); for (title, entries) in menus::tree(app) { - let sub = Submenu::new(&title, true); - build(&sub, &entries, &mut items, &mut counter); + let sub = Submenu::new(i18n::tr(language, &title), true); + build(&sub, &entries, &mut items, &mut submenus, &mut counter, language); let _ = menu.append(&sub); + submenus.push((title, sub)); } menu.init_for_nsapp(); - Self { _menu: menu, items, last_refresh: 0.0 } + Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0 } } pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) { while let Ok(ev) = MenuEvent::receiver().try_recv() { - if let Some((cmd, _)) = self.items.get(ev.id.as_ref()) { + if let Some((cmd, _, _)) = self.items.get(ev.id.as_ref()) { if cmd == "ui.discord" { ctx.open_url(egui::OpenUrl::new_tab("https://discord.gg/artcraft")); } else { @@ -66,32 +74,53 @@ impl NativeMenu { ctx.request_repaint(); } } + let language = app.language(); + if self.language != language { + self.language = language; + for (_, label, item) in self.items.values() { + item.set_text(i18n::tr(language, label)); + } + for (label, item) in &self.predefined { + item.set_text(i18n::tr(language, label)); + } + for (label, submenu) in &self.submenus { + submenu.set_text(i18n::tr(language, label)); + } + } let now = cadcraft_ui_egui::now_ms(); if now - self.last_refresh < 300.0 { return; } self.last_refresh = now; - for (cmd, item) in self.items.values() { + for (cmd, _, item) in self.items.values() { let en = cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok()); item.set_enabled(en); } } } -fn build(parent: &Submenu, entries: &[Entry], items: &mut HashMap, counter: &mut usize) { +fn build( + parent: &Submenu, + entries: &[Entry], + items: &mut HashMap, + submenus: &mut Vec<(String, Submenu)>, + counter: &mut usize, + language: &str, +) { for e in entries { match e { Entry::Sub { label, children } => { - let sub = Submenu::new(label, true); - build(&sub, children, items, counter); + let sub = Submenu::new(i18n::tr(language, label), true); + build(&sub, children, items, submenus, counter, language); let _ = parent.append(&sub); + submenus.push((label.clone(), sub)); } Entry::Item { label, id, shortcut, enabled } => { *counter += 1; let mid = format!("cc{counter}"); - let i = MenuItem::with_id(mid.clone(), label, *enabled, shortcut.as_deref().and_then(accel)); + let i = MenuItem::with_id(mid.clone(), i18n::tr(language, label), *enabled, shortcut.as_deref().and_then(accel)); let _ = parent.append(&i); - items.insert(mid, (id.clone(), i)); + items.insert(mid, (id.clone(), label.clone(), i)); } } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4744ae24..7be3ea16 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -451,8 +451,8 @@ fn draw_snap_marker(p: &egui::Painter, at: Pos2, hit: &SnapHit) { p.line(vec![at + vec2(-s, -s), at + vec2(s, -s), at + vec2(-s, s), at + vec2(s, s), at + vec2(-s, -s)], st); } } - p.text(at + vec2(12.0, 12.0), egui::Align2::LEFT_TOP, hit.name, crate::theme::small(), Color32::BLACK); - let galley = p.layout_no_wrap(hit.name.to_string(), crate::theme::small(), Color32::BLACK); + p.text(at + vec2(12.0, 12.0), egui::Align2::LEFT_TOP, crate::i18n::t(hit.name), crate::theme::small(), Color32::BLACK); + let galley = p.layout_no_wrap(crate::i18n::t(hit.name).to_string(), crate::theme::small(), Color32::BLACK); let r = Rect::from_min_size(at + vec2(10.0, 10.0), galley.size() + vec2(6.0, 2.0)); p.rect_filled(r, 2.0, Color32::from_rgb(0xff, 0xff, 0xe1)); p.galley(r.min + vec2(3.0, 1.0), galley, Color32::BLACK); @@ -881,7 +881,11 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { let far = base + Vec2::from_angle(a) * (view.height * 4.0); let pts = [xf.to_screen(base), xf.to_screen(far)]; painter.extend(Shape::dashed_line(&pts, Stroke::new(1.0, Color32::from_rgb(0x4c, 0xd1, 0x37)), 3.0, 3.0)); - let tip = format!("Polar: {} < {}°", cadcraft_engine::units::format_distance(base.dist(c), 2, 4), (a.to_degrees().round() as i64)); + let tip = crate::tf!( + "Polar: {distance} < {angle}°", + distance = cadcraft_engine::units::format_distance(base.dist(c), 2, 4), + angle = a.to_degrees().round() as i64 + ); tooltip(&painter, xf.to_screen(c) + vec2(16.0, 18.0), &tip); } if let (Some(hit), Some(_)) = (app.canvas.snap, app.canvas.cursor) { @@ -948,23 +952,23 @@ fn dynamic_input(app: &CadApp, p: &egui::Painter, at: Pos2) { && let Some(e) = crate::dyninput::parse(&app.cmd.buffer, &f) && let Some(c) = app.canvas.cursor { - dynamic_input_boxes(p, at, &prompt.message, &f, &e, c); + dynamic_input_boxes(p, at, crate::i18n::t(&prompt.message), &f, &e, c); return; } - let mut text = prompt.message.clone(); + let mut text = crate::i18n::t(&prompt.message).to_string(); if !app.cmd.buffer.is_empty() { - text = format!("{}: {}", prompt.message, app.cmd.buffer); + text = format!("{}: {}", crate::i18n::t(&prompt.message), app.cmd.buffer); } else if let (Some(base), Some(c)) = (prompt.base, app.canvas.cursor) { text = format!( "{} {} < {}°", - prompt.message, + crate::i18n::t(&prompt.message), cadcraft_engine::units::format_distance(base.dist(c), 2, 4), (base.angle_to(c).to_degrees() * 10.0).round() / 10.0 ); } else if let Some(c) = app.canvas.cursor { text = format!( "{} {}, {}", - prompt.message, + crate::i18n::t(&prompt.message), cadcraft_engine::units::format_distance(c.x, 2, 4), cadcraft_engine::units::format_distance(c.y, 2, 4) ); @@ -1083,14 +1087,14 @@ fn draw_ucs_icon(p: &egui::Painter, rect: Rect) { p.line_segment([o, o + vec2(0.0, -60.0)], st); p.rect_stroke(Rect::from_center_size(o, vec2(9.0, 9.0)), 0.0, st, egui::StrokeKind::Middle); let f = egui::FontId::proportional(13.0); - p.text(o + vec2(70.0, 0.0), egui::Align2::LEFT_CENTER, "X", f.clone(), t.canvas_ink); - p.text(o + vec2(0.0, -70.0), egui::Align2::CENTER_BOTTOM, "Y", f, t.canvas_ink); + p.text(o + vec2(70.0, 0.0), egui::Align2::LEFT_CENTER, crate::tl!("X"), f.clone(), t.canvas_ink); + p.text(o + vec2(0.0, -70.0), egui::Align2::CENTER_BOTTOM, crate::tl!("Y"), f, t.canvas_ink); } fn viewport_label(p: &egui::Painter, rect: Rect) { let t = Tokens::get(); let at = pos2(rect.left() + 10.0, rect.top() + 8.0); - p.text(at, egui::Align2::LEFT_TOP, "+ | Top | 2D Wireframe", crate::theme::small(), t.canvas_ink); + p.text(at, egui::Align2::LEFT_TOP, crate::tl!("+ | Top | 2D Wireframe"), crate::theme::small(), t.canvas_ink); } fn draw_viewcube(app: &mut CadApp, ui: &mut egui::Ui, rect: Rect) { @@ -1102,7 +1106,7 @@ fn draw_viewcube(app: &mut CadApp, ui: &mut egui::Ui, rect: Rect) { // WCS pill. let pill = Rect::from_center_size(c + vec2(0.0, ring + 26.0), vec2(56.0, 16.0)); p.rect_filled(pill, 8.0, Color32::from_rgb(0x48, 0x50, 0x5c)); - p.text(pill.center(), egui::Align2::CENTER_CENTER, "WCS ⌄", crate::theme::small(), t.canvas_ink); + p.text(pill.center(), egui::Align2::CENTER_CENTER, crate::tl!("WCS ⌄"), crate::theme::small(), t.canvas_ink); } /// The grip of a selected object under the cursor, if any. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index e2b41c67..98871d1c 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -48,7 +48,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { if title_h > 0.0 { let title = match app.session.state() { Ok(st) if !app.ui.start_tab => format!("CADCraft {}{}", st.title, if st.title.contains('.') { "" } else { ".dwg" }), - _ => "CADCraft Start".into(), + _ => format!("CADCraft {}", crate::tl!("Start")), }; ui.painter().text( pos2(r.center().x, r.top() + title_h / 2.0 + 1.0), @@ -83,8 +83,8 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { } icons::paint(ui.painter(), br.shrink(4.0), *icon, false); let tip = match shortcut { - Some(sc) => format!("{label} ({})", crate::menus::shortcut_label(sc, mac)), - None => (*label).to_string(), + Some(sc) => format!("{} ({})", crate::i18n::t(label), crate::menus::shortcut_label(sc, mac)), + None => crate::i18n::t(label).to_string(), }; if resp.on_hover_text(tip).clicked() { clicked = Some(*cmd); @@ -112,7 +112,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) { let plus = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(18.0, 18.0)); let presp = ui.interact(plus, ui.id().with("tab+"), Sense::click()); icons::paint(ui.painter(), plus.shrink(2.0), Icon::Plus, false); - if presp.on_hover_text("New Drawing").clicked() { + if presp.on_hover_text(crate::tl!("New Drawing")).clicked() { app.start("new"); app.ui.start_tab = false; } @@ -149,7 +149,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) { x += w; (resp.clicked(), close) }; - let (c, _) = tab(ui, "Start", app.ui.start_tab || app.session.docs.is_empty(), 110.0, ui.id().with("tab_start")); + let (c, _) = tab(ui, crate::tl!("Start"), app.ui.start_tab || app.session.docs.is_empty(), 110.0, ui.id().with("tab_start")); if c { app.ui.start_tab = true; } @@ -199,7 +199,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let br = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(18.0, 18.0)); let resp = ui.interact(br, ui.id().with(("sb", tip)), Sense::click()); icons::paint(&p, br.shrink(2.0), icon, false); - if resp.on_hover_text(tip).clicked() && icon == Icon::Plus { + if resp.on_hover_text(crate::i18n::t(tip)).clicked() && icon == Icon::Plus { let _ = app.run("layout.new", json!({})); } x += 22.0; @@ -218,7 +218,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { } let mut switch = None; for (i, name) in tabs.iter().enumerate() { - let g = p.layout_no_wrap(name.clone(), crate::theme::body(), t.text); + let g = p.layout_no_wrap(if i == 0 { crate::tl!("Model").to_string() } else { name.clone() }, crate::theme::body(), t.text); let w = g.size().x + 26.0; let tr = Rect::from_min_size(pos2(x, r.top() + 1.0), vec2(w, r.height() - 2.0)); let active = cur.as_deref().is_ok_and(|c| c == name); @@ -237,7 +237,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let pr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(16.0, 16.0)); let presp = ui.interact(pr, ui.id().with("layout_plus"), Sense::click()); icons::paint(&p, pr.shrink(2.0), Icon::Plus, false); - if presp.on_hover_text("New layout").clicked() + if presp.on_hover_text(crate::tl!("New layout")).clicked() && let Ok(v) = app.run("layout.new", json!({})) && let Some(n) = v.get("name").and_then(serde_json::Value::as_str) { @@ -289,8 +289,8 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let br = Rect::from_min_max(pos2(rx - 60.0, r.center().y - 9.0), pos2(rx - 6.0, r.center().y + 9.0)); let resp = ui.interact(br, ui.id().with("mspace_toggle"), Sense::click()); p.rect_filled(br, 3.0, if resp.hovered() { t.control_hover } else { t.toggle_on.gamma_multiply(0.25) }); - p.text(br.center(), egui::Align2::CENTER_CENTER, label, crate::theme::small(), t.text); - if resp.on_hover_text("Switch between model space in a viewport and paper space").clicked() { + p.text(br.center(), egui::Align2::CENTER_CENTER, crate::i18n::t(label), crate::theme::small(), t.text); + if resp.on_hover_text(crate::tl!("Switch between model space in a viewport and paper space")).clicked() { let _ = app.run(if model { "pspace" } else { "mspace" }, json!({})); app.canvas.list = None; } @@ -314,7 +314,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { // Re-tint the icon blue-ish by an underline. p.hline(br.x_range().shrink(4.0), br.bottom() - 1.0, Stroke::new(1.5, t.toggle_on)); } - if resp.on_hover_text(tip).clicked() { + if resp.on_hover_text(crate::i18n::t(tip)).clicked() { toggle_cmd = Some(cmd); } rx += size + 4.0; @@ -336,42 +336,42 @@ pub fn start_page(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); let r = ui.max_rect(); let p = ui.painter().clone(); - let left = Rect::from_min_size(r.min, vec2(260.0, r.height())); + let left = Rect::from_min_size(r.min, vec2(300.0, r.height())); p.rect_filled(left, 0.0, t.chrome_dark); p.rect_filled(Rect::from_min_max(pos2(left.right(), r.top()), r.max), 0.0, t.chrome); - p.text(pos2(left.left() + 34.0, left.top() + 70.0), egui::Align2::LEFT_CENTER, "CADCraft", egui::FontId::proportional(28.0), t.text); + p.text(pos2(left.left() + 34.0, left.top() + 70.0), egui::Align2::LEFT_CENTER, crate::tl!("CADCraft"), egui::FontId::proportional(28.0), t.text); p.text( pos2(left.left() + 34.0, left.top() + 98.0), egui::Align2::LEFT_CENTER, - format!("Version {}", env!("CARGO_PKG_VERSION")), + crate::tf!("Version {version}", version = env!("CARGO_PKG_VERSION")), crate::theme::small(), t.text_faint, ); let mut y = left.top() + 140.0; let mut action = None; for (label, cmd) in [("Open...", "open"), ("New", "new"), ("New (metric)", "new.metric"), ("Open sample drawing", "sample")] { - let br = Rect::from_min_size(pos2(left.left() + 34.0, y), vec2(190.0, 34.0)); + let br = Rect::from_min_size(pos2(left.left() + 34.0, y), vec2(220.0, 34.0)); let resp = ui.interact(br, ui.id().with(("start", cmd)), Sense::click()); p.rect_stroke(br, 2.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.text_dim }), egui::StrokeKind::Inside); - p.text(pos2(br.left() + 16.0, br.center().y), egui::Align2::LEFT_CENTER, label, crate::theme::body(), t.text); + p.text(pos2(br.left() + 16.0, br.center().y), egui::Align2::LEFT_CENTER, crate::i18n::t(label), crate::theme::body(), t.text); if resp.clicked() { action = Some(cmd); } y += 44.0; } let main = Rect::from_min_max(pos2(left.right() + 36.0, r.top() + 40.0), r.max); - p.text(main.min, egui::Align2::LEFT_TOP, "Recent", egui::FontId::proportional(22.0), t.text); + p.text(main.min, egui::Align2::LEFT_TOP, crate::tl!("Recent"), egui::FontId::proportional(22.0), t.text); p.text( pos2(main.center().x, main.center().y), egui::Align2::CENTER_CENTER, - "There is nothing here yet.", + crate::tl!("There is nothing here yet."), egui::FontId::proportional(18.0), t.text, ); p.text( pos2(main.center().x, main.center().y + 26.0), egui::Align2::CENTER_CENTER, - "To get started, create or open a drawing.", + crate::tl!("To get started, create or open a drawing."), crate::theme::body(), t.text_dim, ); diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs index 48bbf45b..51681180 100644 --- a/crates/ui-egui/src/cmdline.rs +++ b/crates/ui-egui/src/cmdline.rs @@ -288,7 +288,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { let prompt = app.session.current_prompt(); let font = crate::theme::body(); if let Some(g) = app.canvas.hot_grip { - let gl = p.layout_no_wrap(g.label().to_string(), font.clone(), t.text); + let gl = p.layout_no_wrap(crate::i18n::t(g.label()).to_string(), font.clone(), t.text); let gw = gl.size().x; p.galley(pos2(x, bar.center().y - gl.size().y / 2.0), gl, t.text); x += gw + 6.0; @@ -300,12 +300,12 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text_faint); x += gw; - let g = p.layout_no_wrap(pr.message.clone(), font.clone(), t.text); + let g = p.layout_no_wrap(crate::i18n::t(&pr.message).to_string(), font.clone(), t.text); let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); x += gw; if !pr.keywords.is_empty() { - let g = p.layout_no_wrap(if pr.message.is_empty() { " [".into() } else { " or [".into() }, font.clone(), t.text); + let g = p.layout_no_wrap(if pr.message.is_empty() { " [".into() } else { crate::tl!(" or [").into() }, font.clone(), t.text); let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); x += gw; @@ -348,7 +348,13 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { } None => { if app.cmd.buffer.is_empty() { - p.text(pos2(x, bar.center().y), egui::Align2::LEFT_CENTER, "Type a command", egui::FontId::proportional(12.5), t.text_faint); + p.text( + pos2(x, bar.center().y), + egui::Align2::LEFT_CENTER, + crate::tl!("Type a command"), + egui::FontId::proportional(12.5), + t.text_faint, + ); } } } @@ -389,7 +395,13 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { p.rect_filled(r.shrink(1.0), 2.0, if resp.hovered() { t.cmd_keyword_hover } else { t.list_row }); } p.text(Pos2::new(r.left() + 8.0, r.center().y), egui::Align2::LEFT_CENTER, id, crate::theme::body(), t.text); - p.text(Pos2::new(r.right() - 8.0, r.center().y), egui::Align2::RIGHT_CENTER, *label, crate::theme::small(), t.text_faint); + p.text( + Pos2::new(r.right() - 8.0, r.center().y), + egui::Align2::RIGHT_CENTER, + crate::i18n::t(label), + crate::theme::small(), + t.text_faint, + ); if resp.clicked() { app.cmd.buffer.clear(); app.start(&id.to_ascii_lowercase()); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 765dec19..2385c61b 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -215,39 +215,47 @@ pub fn contributors_ui(ui: &mut egui::Ui) { let id = egui::Id::new("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { - ui.label("Show"); + ui.label(crate::tl!("Show")); for m in NameMode::ALL { - if ui.selectable_label(v.names == m, m.label()).clicked() { + if ui.selectable_label(v.names == m, crate::i18n::t(m.label())).clicked() { v.names = m; } } ui.separator(); - ui.label("Sort"); - egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + ui.label(crate::tl!("Sort")); + egui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::t(v.key.label().0)).show_ui(ui, |ui| { for k in SortKey::ALL { - if ui.selectable_label(v.key == k, k.label().0).clicked() { + if ui.selectable_label(v.key == k, crate::i18n::t(k.label().0)).clicked() { v.key = k; v.ascending = k.default_ascending(); } } }); - if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order").clicked() { + if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(crate::tl!("Reverse the order")).clicked() { v.ascending = !v.ascending; } ui.separator(); - if ui.selectable_label(!v.table, "Grab bag").clicked() { + if ui.selectable_label(!v.table, crate::tl!("Grab bag")).clicked() { v.table = false; } - if ui.selectable_label(v.table, "Table").clicked() { + if ui.selectable_label(v.table, crate::tl!("Table")).clicked() { v.table = true; } }); let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending); - ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak()); + ui.label( + RichText::new(crate::tf!( + "{contributors} · {commits}", + contributors = crate::i18n::count(list.len() as u64, "{n} contributor", "{n} contributors"), + commits = crate::i18n::count(TOTAL_COMMITS, "{n} commit", "{n} commits") + )) + .small() + .weak(), + ); ui.separator(); egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| { if list.is_empty() { - ui.label("No contributor data was built into this copy."); + ui.label(crate::tl!("No contributor data was built into this copy.")); } else if v.table { table(ui, &list, &mut v); } else { @@ -268,7 +276,11 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { for k in SortKey::ALL { let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; - if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() { + if ui + .button(RichText::new(format!("{}{arrow}", crate::i18n::t(k.label().1))).strong()) + .on_hover_text(crate::i18n::t(k.label().0)) + .clicked() + { if v.key == k { v.ascending = !v.ascending; } else { @@ -297,14 +309,14 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { /// About ▸ Models: AI models credited in Co-Authored-By trailers. pub fn models_ui(ui: &mut egui::Ui) { if MODELS.is_empty() { - ui.label("No model credits were built into this copy."); + ui.label(crate::tl!("No model credits were built into this copy.")); return; } let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1); egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| { egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| { for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] { - ui.label(RichText::new(h).strong()); + ui.label(RichText::new(crate::i18n::t(h)).strong()); } ui.end_row(); for m in MODELS { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 356b35e3..060504ac 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -14,6 +14,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { let Some(d) = app.ui.dialog.clone() else { return }; let mut open = true; match d.as_str() { + "language" => language(app, ctx, &mut open), "layers" => crate::layers::dialog(app, ctx, &mut open), "qselect" => crate::quick::qselect_dialog(app, ctx, &mut open), "parameters" => crate::parametric::parameters_dialog(app, ctx, &mut open), @@ -30,29 +31,29 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { } fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { - egui::Window::new("Drafting Settings").open(open).resizable(false).show(ctx, |ui| { + egui::Window::new(crate::tl!("Drafting Settings")).id(egui::Id::new("Drafting Settings")).open(open).resizable(false).show(ctx, |ui| { let s = &mut app.session.settings; - ui.heading("Snap and Grid"); - ui.checkbox(&mut s.snapmode, "Snap On (F9)"); + ui.heading(crate::tl!("Snap and Grid")); + ui.checkbox(&mut s.snapmode, crate::tl!("Snap On (F9)")); ui.horizontal(|ui| { - ui.label("Snap X spacing"); + ui.label(crate::tl!("Snap X spacing")); ui.add(egui::DragValue::new(&mut s.snapunit.x).speed(0.05).range(0.0001..=1e6)); - ui.label("Y"); + ui.label(crate::tl!("Y")); ui.add(egui::DragValue::new(&mut s.snapunit.y).speed(0.05).range(0.0001..=1e6)); }); - ui.checkbox(&mut s.gridmode, "Grid On (F7)"); + ui.checkbox(&mut s.gridmode, crate::tl!("Grid On (F7)")); ui.horizontal(|ui| { - ui.label("Grid spacing"); + ui.label(crate::tl!("Grid spacing")); ui.add(egui::DragValue::new(&mut s.gridunit.x).speed(0.05).range(0.0001..=1e6)); - ui.label("Major line every"); + ui.label(crate::tl!("Major line every")); ui.add(egui::DragValue::new(&mut s.gridmajor).range(1..=100)); }); ui.separator(); - ui.heading("Polar Tracking"); - ui.checkbox(&mut s.polarmode, "Polar Tracking On (F10)"); + ui.heading(crate::tl!("Polar Tracking")); + ui.checkbox(&mut s.polarmode, crate::tl!("Polar Tracking On (F10)")); let mut deg = s.polarang.to_degrees(); ui.horizontal(|ui| { - ui.label("Increment angle"); + ui.label(crate::tl!("Increment angle")); egui::ComboBox::from_id_salt("polarang").selected_text(format!("{deg}")).show_ui(ui, |ui| { for a in [90.0, 45.0, 30.0, 22.5, 18.0, 15.0, 10.0, 5.0] { ui.selectable_value(&mut deg, a, format!("{a}")); @@ -61,9 +62,9 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { }); s.polarang = deg.to_radians(); ui.separator(); - ui.heading("Object Snap"); + ui.heading(crate::tl!("Object Snap")); let mut on = s.osmode & cadcraft_engine::snap::mode::OFF == 0; - if ui.checkbox(&mut on, "Object Snap On (F3)").changed() { + if ui.checkbox(&mut on, crate::tl!("Object Snap On (F3)")).changed() { if on { s.osmode &= !cadcraft_engine::snap::mode::OFF; } else { @@ -73,7 +74,7 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { egui::Grid::new("osnap_grid").num_columns(2).show(ui, |ui| { for (i, (bit, name)) in cadcraft_engine::snap::mode::ALL.iter().enumerate() { let mut v = s.osmode & bit != 0; - if ui.checkbox(&mut v, *name).changed() { + if ui.checkbox(&mut v, crate::i18n::t(name)).changed() { if v { s.osmode |= bit; } else { @@ -86,22 +87,22 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } }); ui.separator(); - ui.checkbox(&mut s.dynmode, "Enable Dynamic Input (F12)"); + ui.checkbox(&mut s.dynmode, crate::tl!("Enable Dynamic Input (F12)")); // Pointer input format for second and next points (DYNPIFORMAT, DYNPICOORDS). ui.indent("dynpi", |ui| { ui.add_enabled_ui(s.dynmode, |ui| { - ui.label("Second and next points:"); + ui.label(crate::tl!("Second and next points:")); ui.horizontal(|ui| { - ui.radio_value(&mut s.dynpi_cartesian, false, "Polar (distance < angle)"); - ui.radio_value(&mut s.dynpi_cartesian, true, "Cartesian (x, y)"); + ui.radio_value(&mut s.dynpi_cartesian, false, crate::tl!("Polar (distance < angle)")); + ui.radio_value(&mut s.dynpi_cartesian, true, crate::tl!("Cartesian (x, y)")); }); ui.horizontal(|ui| { - ui.radio_value(&mut s.dynpi_absolute, false, "Relative"); - ui.radio_value(&mut s.dynpi_absolute, true, "Absolute"); + ui.radio_value(&mut s.dynpi_absolute, false, crate::tl!("Relative")); + ui.radio_value(&mut s.dynpi_absolute, true, crate::tl!("Absolute")); }); }); }); - ui.checkbox(&mut s.orthomode, "Ortho (F8)"); + ui.checkbox(&mut s.orthomode, crate::tl!("Ortho (F8)")); }); } @@ -219,62 +220,74 @@ fn units(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { fn about(ctx: &egui::Context, open: &mut bool) { let t = Tokens::get(); let tab_id = egui::Id::new("about_tab"); - egui::Window::new("About CADCraft").open(open).default_size(vec2(640.0, 420.0)).collapsible(false).show(ctx, |ui| { - let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); - ui.horizontal(|ui| { - for (i, l) in ["About", "Contributors", "Models"].iter().enumerate() { - if ui.selectable_label(tab == i as u8, *l).clicked() { - tab = i as u8; + egui::Window::new(crate::tl!("About CADCraft")) + .id(egui::Id::new("About CADCraft")) + .open(open) + .default_size(vec2(640.0, 420.0)) + .collapsible(false) + .show(ctx, |ui| { + let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); + ui.horizontal(|ui| { + for (i, l) in ["About", "Contributors", "Models"].iter().enumerate() { + if ui.selectable_label(tab == i as u8, crate::i18n::t(l)).clicked() { + tab = i as u8; + } + } + }); + ui.data_mut(|d| d.insert_temp(tab_id, tab)); + ui.separator(); + match tab { + 1 => crate::credits::contributors_ui(ui), + 2 => crate::credits::models_ui(ui), + _ => { + ui.heading(crate::tl!("CADCraft")); + ui.label(crate::tf!("Version {version}", version = env!("CARGO_PKG_VERSION"))); + ui.label(crate::tl!("Computer-aided design and drafting: an open-source, clean-room CAD application written in pure Rust.")); + ui.add_space(6.0); + ui.label(RichText::new(crate::tl!("One of the Crafting Apps by the ArtCraft team and community.")).color(t.text_dim)); + ui.hyperlink_to("getartcraft.com/apps/cadcraft", "https://getartcraft.com/apps/cadcraft"); + ui.hyperlink_to(crate::tl!("Join us on Discord"), "https://discord.gg/artcraft"); + ui.add_space(6.0); + ui.label(RichText::new(crate::tl!("MIT OR Apache-2.0. Not affiliated with Autodesk, Inc.")).small().color(t.text_faint)); } } }); - ui.data_mut(|d| d.insert_temp(tab_id, tab)); - ui.separator(); - match tab { - 1 => crate::credits::contributors_ui(ui), - 2 => crate::credits::models_ui(ui), - _ => { - ui.heading("CADCraft"); - ui.label(format!("Version {}", env!("CARGO_PKG_VERSION"))); - ui.label("Computer-aided design and drafting: an open-source, clean-room CAD application written in pure Rust."); - ui.add_space(6.0); - ui.label(RichText::new("One of the Crafting Apps by the ArtCraft team and community.").color(t.text_dim)); - ui.hyperlink_to("getartcraft.com/apps/cadcraft", "https://getartcraft.com/apps/cadcraft"); - ui.hyperlink_to("Join us on Discord", "https://discord.gg/artcraft"); - ui.add_space(6.0); - ui.label(RichText::new("MIT OR Apache-2.0. Not affiliated with Autodesk, Inc.").small().color(t.text_faint)); - } - } - }); } fn commands(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let mut start = None; - egui::Window::new("Command Reference").open(open).default_size(vec2(640.0, 480.0)).show(ctx, |ui| { - let id = ui.id().with("cmdfilter"); - let mut filter = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); - ui.horizontal(|ui| { - ui.label("Filter"); - ui.text_edit_singleline(&mut filter); - }); - ui.data_mut(|d| d.insert_temp(id, filter.clone())); - let f = filter.to_ascii_lowercase(); - egui::ScrollArea::vertical().show(ui, |ui| { - egui::Grid::new("cmdref").striped(true).num_columns(3).show(ui, |ui| { - for c in cadcraft_engine::command_specs() { - if !f.is_empty() && !c.id.contains(&f) && !c.label.to_ascii_lowercase().contains(&f) { - continue; - } - if ui.link(c.id.to_ascii_uppercase()).clicked() { - start = Some(c.id); - } - ui.label(c.label); - ui.label(RichText::new(if c.aliases.is_empty() { String::new() } else { c.aliases.join(", ").to_ascii_uppercase() }).small()); - ui.end_row(); - } + egui::Window::new(crate::tl!("Command Reference")).id(egui::Id::new("Command Reference")).open(open).default_size(vec2(640.0, 480.0)).show( + ctx, + |ui| { + let id = ui.id().with("cmdfilter"); + let mut filter = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(crate::tl!("Filter")); + ui.text_edit_singleline(&mut filter); }); - }); - }); + ui.data_mut(|d| d.insert_temp(id, filter.clone())); + let f = filter.to_lowercase(); + egui::ScrollArea::vertical().show(ui, |ui| { + egui::Grid::new("cmdref").striped(true).num_columns(3).show(ui, |ui| { + for c in cadcraft_engine::command_specs() { + if !f.is_empty() + && !c.id.contains(&f) + && !c.label.to_ascii_lowercase().contains(&f) + && !crate::i18n::t(c.label).to_lowercase().contains(&f) + { + continue; + } + if ui.link(c.id.to_ascii_uppercase()).clicked() { + start = Some(c.id); + } + ui.label(crate::i18n::t(c.label)); + ui.label(RichText::new(if c.aliases.is_empty() { String::new() } else { c.aliases.join(", ").to_ascii_uppercase() }).small()); + ui.end_row(); + } + }); + }); + }, + ); if let Some(c) = start { app.ui.dialog = None; app.start(c); @@ -283,14 +296,14 @@ fn commands(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { fn blocks(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let mut pick = None; - egui::Window::new("Blocks").open(open).default_size(vec2(320.0, 360.0)).show(ctx, |ui| { + egui::Window::new(crate::tl!("Blocks")).id(egui::Id::new("Blocks")).open(open).default_size(vec2(320.0, 360.0)).show(ctx, |ui| { let Ok(d) = app.session.doc() else { return }; let names: Vec<&String> = d.blocks.keys().filter(|k| !k.starts_with('*')).collect(); if names.is_empty() { - ui.label("No blocks defined in this drawing."); + ui.label(crate::tl!("No blocks defined in this drawing.")); } for n in names { - if ui.button(n).on_hover_text("Insert this block").clicked() { + if ui.button(n).on_hover_text(crate::tl!("Insert this block")).clicked() { pick = Some(n.clone()); } } @@ -324,46 +337,54 @@ fn mtext_editor(app: &mut CadApp, ctx: &egui::Context) { let mut buf = ctx.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); let mut submit = None; let mut cancel = false; - egui::Window::new("Text Editor").collapsible(false).resizable(true).default_size(vec2(460.0, 220.0)).show(ctx, |ui| { - ui.horizontal(|ui| { - ui.label("Style: Standard"); - ui.separator(); - let h = app.session.doc().map(|d| d.header.f64("TEXTSIZE", 0.2)).unwrap_or(0.2); - ui.label(format!("Height: {h:.4}")); - ui.separator(); - if ui.button("B").on_hover_text("Bold").clicked() { - buf.push_str("{\\fArial|b1;}"); - } - if ui.button("⅟").on_hover_text("Stack (type 1/2 then select)").clicked() { - buf.push_str("\\S1/2;"); - } - if ui.button("°").on_hover_text("Degree").clicked() { - buf.push_str("%%d"); - } - if ui.button("±").on_hover_text("Plus/minus").clicked() { - buf.push_str("%%p"); - } - if ui.button("⌀").on_hover_text("Diameter").clicked() { - buf.push_str("%%c"); + egui::Window::new(crate::tl!("Text Editor")) + .id(egui::Id::new("Text Editor")) + .collapsible(false) + .resizable(true) + .default_size(vec2(460.0, 220.0)) + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.label(crate::tl!("Style: Standard")); + ui.separator(); + let h = app.session.doc().map(|d| d.header.f64("TEXTSIZE", 0.2)).unwrap_or(0.2); + ui.label(crate::tf!("Height: {height}", height = format!("{h:.4}"))); + ui.separator(); + if ui.button(crate::tl!("B")).on_hover_text(crate::tl!("Bold")).clicked() { + buf.push_str("{\\fArial|b1;}"); + } + if ui.button("⅟").on_hover_text(crate::tl!("Stack (type 1/2 then select)")).clicked() { + buf.push_str("\\S1/2;"); + } + if ui.button("°").on_hover_text(crate::tl!("Degree")).clicked() { + buf.push_str("%%d"); + } + if ui.button("±").on_hover_text(crate::tl!("Plus/minus")).clicked() { + buf.push_str("%%p"); + } + if ui.button("⌀").on_hover_text(crate::tl!("Diameter")).clicked() { + buf.push_str("%%c"); + } + }); + let r = ui.add( + egui::TextEdit::multiline(&mut buf) + .desired_rows(6) + .desired_width(f32::INFINITY) + .hint_text(crate::tl!("Type text; Enter starts a new paragraph")), + ); + if !r.has_focus() && buf.is_empty() { + r.request_focus(); } + ui.horizontal(|ui| { + if ui.button(crate::tl!("OK")).clicked() || (r.has_focus() && ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Enter))) { + submit = Some(buf.replace('\n', "\\P")); + } + if ui.button(crate::tl!("Cancel")).clicked() { + cancel = true; + } + let finish = if ui.ctx().os().is_mac() { crate::tl!("⌘↩ to finish") } else { crate::tl!("Ctrl+Enter to finish") }; + ui.label(RichText::new(finish).small()); + }); }); - let r = ui.add( - egui::TextEdit::multiline(&mut buf).desired_rows(6).desired_width(f32::INFINITY).hint_text("Type text; Enter starts a new paragraph"), - ); - if !r.has_focus() && buf.is_empty() { - r.request_focus(); - } - ui.horizontal(|ui| { - if ui.button("OK").clicked() || (r.has_focus() && ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Enter))) { - submit = Some(buf.replace('\n', "\\P")); - } - if ui.button("Cancel").clicked() { - cancel = true; - } - let finish = if ui.ctx().os().is_mac() { "⌘↩ to finish" } else { "Ctrl+Enter to finish" }; - ui.label(RichText::new(finish).small()); - }); - }); ctx.data_mut(|d| d.insert_temp(id, buf)); if let Some(t) = submit { ctx.data_mut(|d| d.remove::(id)); @@ -374,6 +395,19 @@ fn mtext_editor(app: &mut CadApp, ctx: &egui::Context) { } } +fn language(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { + egui::Window::new(crate::tl!("Interface Language")).id(egui::Id::new("interface_language")).open(open).resizable(false).show(ctx, |ui| { + egui::ComboBox::from_id_salt("interface_language_choice").selected_text(app.ui.interface_language.name()).show_ui(ui, |ui| { + for preference in crate::i18n::Preference::ALL { + if ui.selectable_label(app.ui.interface_language == preference, preference.name()).clicked() { + let _ = app.run("ui.language", serde_json::json!({"lang": preference.code()})); + ui.ctx().request_repaint(); + } + } + }); + }); +} + #[cfg(test)] mod tests { use cadcraft_engine::doc::EntityKind; diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs new file mode 100644 index 00000000..c125a45e --- /dev/null +++ b/crates/ui-egui/src/i18n/mod.rs @@ -0,0 +1,186 @@ +//! Display-time localization, following the sibling Craft apps' TSV convention. +//! English strings remain lookup keys; command IDs, drawing data and automation stay unchanged. +//! Add a catalog, a `LANGUAGES` row and a preference variant to introduce another language. + +use std::cell::Cell; +use std::collections::HashMap; +use std::sync::OnceLock; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Preference { + #[default] + #[serde(rename = "auto")] + Auto, + #[serde(rename = "en")] + English, + #[serde(rename = "uk")] + Ukrainian, +} + +impl Preference { + pub const ALL: [Self; 3] = [Self::Auto, Self::English, Self::Ukrainian]; + + pub fn code(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::English => "en", + Self::Ukrainian => "uk", + } + } + + pub fn name(self) -> &'static str { + match self { + Self::Auto => t("Auto (system language)"), + Self::English => "English", + Self::Ukrainian => "Українська", + } + } + + pub fn parse(code: &str) -> Option { + Self::ALL.into_iter().find(|p| p.code() == code) + } + + /// An invalid or obsolete saved choice follows the system language. + pub fn restore(saved: Option<&str>) -> Self { + saved.and_then(Self::parse).unwrap_or_default() + } + + pub fn resolve(self, locales: &[String]) -> &'static str { + match self { + Self::English => "en", + Self::Ukrainian => "uk", + Self::Auto => locales.iter().find_map(|s| locale_language(s)).unwrap_or("en"), + } + } +} + +pub struct Language { + pub code: &'static str, + pub name: &'static str, + pub source: &'static str, + catalog: OnceLock>, +} + +pub static LANGUAGES: [Language; 2] = [ + Language { code: "en", name: "English", source: "", catalog: OnceLock::new() }, + Language { code: "uk", name: "Українська", source: include_str!("uk.tsv"), catalog: OnceLock::new() }, +]; + +/// BCP 47 and POSIX locale tags, including `uk-UA` and `uk_UA.UTF-8`. +pub fn locale_language(tag: &str) -> Option<&'static str> { + let primary = tag.trim().split(['-', '_', '.', '@']).next()?; + LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(primary)).map(|l| l.code) +} + +thread_local! { + // Scoped to a single app's render. Headless calls and parallel tests default to English. + static CURRENT: Cell<&'static str> = const { Cell::new("en") }; +} + +pub fn with_language(language: &'static str, draw: impl FnOnce() -> R) -> R { + struct Restore(&'static str); + impl Drop for Restore { + fn drop(&mut self) { + CURRENT.set(self.0); + } + } + let _restore = Restore(CURRENT.replace(language)); + draw() +} + +type Catalog<'a> = HashMap<(&'a str, &'a str), &'a str>; + +/// Malformed rows are ignored at runtime and diagnosed by the catalog tests. +fn parse(source: &str) -> (Catalog<'_>, Vec) { + let mut entries = HashMap::new(); + let mut errors = Vec::new(); + for (i, line) in source.lines().enumerate() { + if line.is_empty() || line.starts_with('#') { + continue; + } + let columns: Vec<_> = line.split('\t').collect(); + match columns.as_slice() { + [context, key, value] if !key.is_empty() && !value.is_empty() => { + if entries.insert((*context, *key), *value).is_some() { + errors.push(format!("line {}: duplicate key {key}", i + 1)); + } + } + _ => errors.push(format!("line {}: expected context, source, translation", i + 1)), + } + } + (entries, errors) +} + +fn catalog(language: &str) -> Option<&'static Catalog<'static>> { + LANGUAGES.iter().find(|l| l.code == language && !l.source.is_empty()).map(|l| l.catalog.get_or_init(|| parse(l.source).0)) +} + +pub fn tr<'a>(language: &str, source: &'a str) -> &'a str { + catalog(language).and_then(|c| c.get(&("", source))).copied().unwrap_or(source) +} + +pub fn t(source: &str) -> &str { + CURRENT.with(|l| tr(l.get(), source)) +} + +/// Named placeholders allow translations to reorder values. Inserted values are never looked up +/// or substituted again, so a user's name containing `{n}` is preserved verbatim. +pub fn format(source: &str, values: &[(&str, String)]) -> String { + let text = t(source); + let mut result = String::with_capacity(text.len()); + let mut rest = text; + while let Some((before, tail)) = rest.split_once('{') { + result.push_str(before); + let Some((key, after)) = tail.split_once('}') else { + result.push('{'); + rest = tail; + break; + }; + if let Some((_, value)) = values.iter().find(|(name, _)| *name == key) { + result.push_str(value); + } else { + result.push('{'); + result.push_str(key); + result.push('}'); + } + rest = after; + } + result.push_str(rest); + result +} + +/// Ukrainian integer plurals: one (1, 21), few (2–4, 22–24), many (0, 5–20, 25…). +pub fn plural_uk(n: u64) -> usize { + match (n % 10, n % 100) { + (1, 11) | (2..=4, 12..=14) => 2, + (1, _) => 0, + (2..=4, _) => 1, + _ => 2, + } +} + +pub fn count(n: u64, one: &str, other: &str) -> String { + let language = CURRENT.get(); + let source = catalog(language).and_then(|c| c.get(&("@plural", one))).copied(); + let form = source.and_then(|s| s.split('|').nth(plural_uk(n))).unwrap_or(if n == 1 { one } else { other }); + format(form, &[("n", n.to_string())]) +} + +#[macro_export] +macro_rules! tl { + ($source:literal) => { + $crate::i18n::t($source) + }; +} + +#[macro_export] +macro_rules! tf { + ($source:literal $(, $name:ident = $value:expr)* $(,)?) => { + $crate::i18n::format($source, &[$((stringify!($name), ($value).to_string())),*]) + }; +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs new file mode 100644 index 00000000..32a855ab --- /dev/null +++ b/crates/ui-egui/src/i18n/tests.rs @@ -0,0 +1,290 @@ +use std::collections::BTreeSet; + +use super::*; +use crate::{CadApp, Services}; +use cadcraft_engine::Session; +use serde_json::json; + +fn placeholders(text: &str) -> BTreeSet<&str> { + text.split('{').skip(1).filter_map(|s| s.split_once('}').map(|(key, _)| key)).collect() +} + +#[test] +fn catalog_is_valid_and_preserves_placeholders_and_ellipses() { + for language in &LANGUAGES { + let (catalog, errors) = parse(language.source); + assert!(errors.is_empty(), "{}: {errors:?}", language.code); + for ((context, key), value) in catalog { + assert!(["", "@plural"].contains(&context), "unknown context: {context}"); + let forms: Vec<_> = if context == "@plural" { value.split('|').collect() } else { vec![value] }; + if context == "@plural" { + assert_eq!(forms.len(), 3, "{key}"); + } + for form in forms { + assert_eq!(placeholders(key), placeholders(form), "{key}"); + for suffix in ["...", "…"] { + assert_eq!(key.ends_with(suffix), form.ends_with(suffix), "{key}"); + } + } + } + } + let (parsed, errors) = parse("broken\n\tOpen\tВідкрити\n\tOpen\tВідкрити\n\tEmpty\t\n"); + assert_eq!(parsed.len(), 1); + assert_eq!(errors.len(), 3); + assert_eq!(tr("uk", "a future untranslated label"), "a future untranslated label"); + assert_eq!(tr("unknown", "Open"), "Open"); +} + +#[test] +fn ukrainian_covers_commands_menus_tools_and_snap_labels() { + let catalog = catalog("uk").unwrap(); + let mut sources = BTreeSet::new(); + sources.extend(crate::menus::MENUS.iter().copied()); + for c in cadcraft_engine::command_specs() { + sources.insert(c.label); + sources.extend(c.menu.iter().copied()); + } + for (_, label, path, _) in crate::menus::UI_COMMANDS { + sources.insert(*label); + sources.extend(path.iter().copied()); + } + for groups in [crate::palettes::DRAFTING, crate::palettes::MODELING] { + for (name, large, small) in groups { + sources.insert(*name); + sources.extend(large.iter().chain(*small).map(|(_, _, label)| *label)); + } + } + sources.extend(cadcraft_engine::snap::mode::ALL.iter().map(|(_, name)| *name)); + let missing: Vec<_> = sources.into_iter().filter(|s| !s.is_empty() && !catalog.contains_key(&("", *s))).collect(); + assert!(missing.is_empty(), "missing translations: {missing:?}"); +} + +#[test] +fn every_marked_ui_literal_has_a_translation() { + let catalog = catalog("uk").unwrap(); + let sources = [ + include_str!("../canvas.rs"), + include_str!("../chrome.rs"), + include_str!("../cmdline.rs"), + include_str!("../credits.rs"), + include_str!("../dialogs.rs"), + include_str!("../layers.rs"), + include_str!("../palettes.rs"), + include_str!("../parametric.rs"), + include_str!("../quick.rs"), + ]; + let mut missing = BTreeSet::new(); + for source in sources { + for marker in ["crate::tl!(", "crate::tf!("] { + for rest in source.split(marker).skip(1) { + // These macros require a quoted literal. Use JSON's string decoding for escapes. + let rest = rest.trim_start(); + let mut escape = false; + let end = rest + .char_indices() + .skip(1) + .find_map(|(i, c)| { + if escape { + escape = false; + None + } else if c == '\\' { + escape = true; + None + } else if c == '"' { + Some(i + 1) + } else { + None + } + }) + .unwrap(); + let key: String = serde_json::from_str(&rest[..end]).unwrap(); + // Coordinate axes, glyph buttons and product names are not translated. + if !["X", "Y", "B", "CADCraft"].contains(&key.as_str()) && !catalog.contains_key(&("", key.as_str())) { + missing.insert(key); + } + } + } + } + assert!(missing.is_empty(), "missing UI translations: {missing:?}"); +} + +#[test] +fn locale_and_saved_preference_resolution() { + for tag in ["uk", "uk-UA", "uk_UA.UTF-8", "UK-ua", " uk-UA ", "uk_UA@euro"] { + assert_eq!(locale_language(tag), Some("uk"), "{tag}"); + } + for tag in ["ua", "ukulele", "ru-RU", "", "garbage", "💙"] { + assert_eq!(locale_language(tag), None, "{tag}"); + } + let locales = vec!["pl-PL".into(), "uk-UA".into(), "en-US".into()]; + assert_eq!(Preference::Auto.resolve(&locales), "uk"); + assert_eq!(Preference::English.resolve(&locales), "en"); + assert_eq!(Preference::Auto.resolve(&["en-US".into(), "uk-UA".into()]), "en"); + assert_eq!(Preference::Auto.resolve(&[]), "en"); + assert_eq!(Preference::restore(None), Preference::Auto); + assert_eq!(Preference::restore(Some("obsolete")), Preference::Auto); + for preference in Preference::ALL { + assert_eq!(Preference::restore(Some(preference.code())), preference); + let saved = serde_json::to_string(&preference).unwrap(); + assert_eq!(serde_json::from_str::(&saved).unwrap(), preference); + } + assert!(serde_json::from_str::("\"unsupported\"").is_err()); + assert_eq!(serde_json::from_value::(json!({})).unwrap().interface_language, Preference::Auto); + // Saved with the host's other preferences; a bad saved value keeps the current choice. + let mut a = CadApp::new(Session::new(), Services::default()); + a.ui.interface_language = Preference::Ukrainian; + let mut b = CadApp::new(Session::new(), Services::default()); + b.load_prefs(&a.prefs_json()); + assert_eq!(b.ui.interface_language, Preference::Ukrainian); + b.load_prefs(r#"{"interfaceLanguage":"klingon"}"#); + assert_eq!(b.ui.interface_language, Preference::Ukrainian); +} + +#[test] +fn plural_forms_and_templates_preserve_inserted_user_data() { + with_language("uk", || { + for (n, form) in [ + (0, 2), + (1, 0), + (2, 1), + (4, 1), + (5, 2), + (11, 2), + (12, 2), + (14, 2), + (21, 0), + (22, 1), + (25, 2), + (101, 0), + (111, 2), + (114, 2), + (u64::MAX, 2), + ] { + assert_eq!(plural_uk(n), form, "{n}"); + } + assert_eq!(count(1, "{n} contributor", "{n} contributors"), "1 учасник"); + assert_eq!(count(22, "{n} contributor", "{n} contributors"), "22 учасники"); + assert_eq!(count(11, "{n} contributor", "{n} contributors"), "11 учасників"); + assert_eq!(format("Current: {color}", &[("color", "{n} Open".into())]), "Поточний: {n} Open"); + assert_eq!(format("{b}/{a}", &[("a", "А".into()), ("b", "Б".into())]), "Б/А"); + }); + assert_eq!(t("Layers"), "Layers"); + with_language("uk", || { + assert_eq!(t("Layers"), "Шари"); + with_language("en", || assert_eq!(t("Layers"), "Layers")); + assert_eq!(t("Layers"), "Шари"); + }); + assert_eq!(count(2, "{n} contributor", "{n} contributors"), "2 contributors"); +} + +fn painted_text(shapes: &[egui::epaint::ClippedShape]) -> String { + fn collect(shape: &egui::Shape, text: &mut String) { + match shape { + egui::Shape::Text(s) => { + text.push_str(s.galley.text()); + text.push('\n'); + } + egui::Shape::Vec(shapes) => { + for shape in shapes { + collect(shape, text); + } + } + _ => {} + } + } + let mut text = String::new(); + for shape in shapes { + collect(&shape.shape, &mut text); + } + text +} + +fn render(app: &mut CadApp, ctx: &egui::Context) -> String { + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), ..Default::default() }; + // Windows fade in; a second pass makes their text visible without a time dependency. + let mut text = String::new(); + for _ in 0..2 { + let mut output = ctx.run_ui(input.clone(), |ui| app.ui(ui)); + text = painted_text(&output.shapes); + output.textures_delta.clear(); + } + text +} + +#[test] +fn live_switch_translates_the_shell_without_mutating_drawing_or_automation() { + let mut app = CadApp::new(Session::new(), Services::default()); + app.ui.in_window_menu = true; + app.session.state_mut().unwrap().title = "Open".into(); + let ctx = egui::Context::default(); + app.logic(&ctx); + let before = render(&mut app, &ctx); + assert!(before.contains("Properties")); + let commands = crate::control::all_commands(&app); + let (menu_request, _) = crate::ControlRequest::new("ui.menu.list", json!({})); + let crate::control::Outcome::Done(menu_before) = crate::control::handle(&mut app, &ctx, &menu_request) else { panic!("expected menu reply") }; + app.start("line"); + let prompt = crate::control::cmdline_state(&app); + app.run("ui.language", json!({"lang":"uk"})).unwrap(); + let after = render(&mut app, &ctx); + assert!(after.contains("Властивості"), "{after}"); + assert!(after.contains("Файл"), "{after}"); + assert!(after.contains("Укажіть першу точку"), "{after}"); + assert!(after.contains("Open"), "the drawing title must remain unchanged"); + assert_eq!(crate::control::cmdline_state(&app), prompt); + assert_eq!(crate::control::all_commands(&app), commands); + let crate::control::Outcome::Done(menu_after) = crate::control::handle(&mut app, &ctx, &menu_request) else { panic!("expected menu reply") }; + assert_eq!(menu_before, menu_after); + assert_eq!(app.session.state().unwrap().title, "Open"); + assert_eq!(t("Properties"), "Properties", "language must not leak out of the render scope"); + assert!(app.run("ui.language", json!({"lang":"bad"})).is_err()); + assert_eq!(app.language(), "uk"); + app.run("ui.resetpalettes", json!({})).unwrap(); + assert_eq!(app.language(), "uk"); + app.run("ui.language", json!({"lang":"en"})).unwrap(); + assert!(render(&mut app, &ctx).contains("Properties")); + // The menu tree used by the control channel keeps its original labels and IDs. + assert_eq!(crate::menus::tree(&app).first().unwrap().0, "File"); +} + +#[test] +fn dialogs_render_in_ukrainian_and_keep_their_source_ids() { + for (dialog, heading) in [ + ("dsettings", "Налаштування креслення"), + ("about", "Про CADCraft"), + ("commands", "Довідник команд"), + ("blocks", "Блоки"), + ("layers", "Диспетчер властивостей шарів"), + ("qselect", "Швидкий вибір"), + ("parameters", "Диспетчер параметрів"), + ("language", "Мова інтерфейсу"), + ] { + let mut app = CadApp::new(Session::new(), Services::default()); + app.ui.interface_language = Preference::Ukrainian; + app.ui.dialog = Some(dialog.into()); + let ctx = egui::Context::default(); + app.logic(&ctx); + let text = render(&mut app, &ctx); + assert!(text.contains(heading), "{dialog}: {text}"); + assert_eq!(app.ui.dialog.as_deref(), Some(dialog)); + } +} + +#[test] +fn default_fonts_cover_every_ukrainian_letter_in_the_catalog() { + let ctx = egui::Context::default(); + // Use only egui's bundled fonts, exactly as the web build does (no system-font loader). + let mut output = ctx.run_ui(Default::default(), |_| {}); + output.textures_delta.clear(); + let letters: BTreeSet<_> = include_str!("uk.tsv").chars().filter(|c| ('\u{0400}'..='\u{04ff}').contains(c)).collect(); + ctx.fonts_mut(|view| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { + // In egui 0.36, has_glyph can report a false negative for glyphs in the same + // face as the replacement character. Check the family's actual character maps. + let mut font = view.fonts.font(&family); + for c in &letters { + assert!(font.characters().contains_key(c), "missing {c} in {family:?}"); + } + } + }); +} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv new file mode 100644 index 00000000..c054517f --- /dev/null +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -0,0 +1,942 @@ +# CADCraft Ukrainian interface catalog — original translations by @dmatviichuk. +# License: MIT OR Apache-2.0 (the repository LICENSE-MIT and LICENSE-APACHE). +# Written from CADCraft's English strings; no proprietary translations were copied. +# UTF-8: contextEnglish sourceUkrainian translation. +# Empty context = ordinary label; @plural = one|few|many forms with {n}. +# Keep command IDs, keywords, technology/product names and user data unchanged. + Cancel Скасувати + OK Гаразд + or [ або [ + % of all commits % усіх комітів + * Wildcard Match * За шаблоном + *VARIES* *РІЗНІ* + + | Top | 2D Wireframe + | Зверху | 2D-каркас + +Bin +двійк. + +LOC +рядків + 1 Viewport 1 видовий екран + 2 Points 2 точки + 2 Viewports 2 видові екрани + 2D Solid 2D-заливка + 3 Points 3 точки + 3 Viewports 3 видові екрани + 3D Face 3D-грань + 3D Operations 3D-операції + 3D Polyline 3D-полілінія + 4 Viewports 4 видові екрани + < Less than < Менше ніж + <> Not Equal <> Не дорівнює + = Equals = Дорівнює + > Greater than > Більше ніж + About Про програму + About CADCraft Про CADCraft + Absolute Абсолютні + Add Leader Додати виноску + Align Вирівняти + Align Text Вирівняти текст + Aligned Паралельний + All Усі + All Used Layers Усі використані шари + All files Усі файли + Angle Кут + Angular Кутовий + Annotation Visibility Видимість анотацій + Annotation scale Масштаб анотацій + Apparent Intersection Уявний перетин + Append to current selection set Додати до поточного набору вибраних об'єктів + Apply to: Застосувати до: + Apply true color Застосувати колір RGB + Arc Дуга + Arc Length Довжина дуги + Area Площа + Array Масив + Attach Приєднати + Attribute Атрибут + Attribute Definition Визначення атрибута + Auto (system language) Авто (мова системи) + Auto Constrain Автоматичні обмеження + AutoConstrain Автоматичні обмеження + Axis, End Вісь, кінець + Base База + Baseline Базовий + Batch Publish Пакетне публікування + Binary assets added Додані двійкові ресурси + Binary assets removed Видалені двійкові ресурси + Blend Плавне з'єднання + Block Блок + Block Attribute Manager... Диспетчер атрибутів блоків... + Block Editor Редактор блоків + Block Reference Посилання на блок + Block... Блок... + Blocks Блоки + Blue Синій + Bold Жирний + Boolean Булеві операції + Boundary Межа + Boundary... Межа... + Box Паралелепіпед + Break Розірвати + Break At Point Розірвати в точці + Bring to Front На передній план + ByBlock За блоком + ByColor За кольором + ByLayer За шаром + CADCraft Help Довідка CADCraft + Cancel Скасувати + Cartesian (x, y) Декартові (x, y) + Center Центр + Center Line Осьова лінія + Center Mark Позначка центра + Center X Центр X + Center Y Центр Y + Center Z Центр Z + Center, Diameter Центр, діаметр + Center, Radius Центр, радіус + Center, Start, Angle Центр, початок, кут + Center, Start, End Центр, початок, кінець + Center, Start, Length Центр, початок, довжина + Chamfer Фаска + Change Space Змінити простір + Change to Current Layer Перенести на поточний шар + Circle Коло + Circumference Довжина кола + Clear Очистити + Close Закрити + Close All Закрити всі + Closed Замкнений + Coincident Збіг + Collapse Tool Sets Згорнути набори інструментів + Collinear Колінеарність + Color Колір + Color 8 Колір 8 + Color 9 Колір 9 + Color Index Індекс кольору + Color... Колір... + Command History Історія команд + Command Line Командний рядок + Command Line History Lines Рядки історії командного рядка + Command Reference Довідник команд + Commits Коміти + Company Компанія + Computer-aided design and drafting: an open-source, clean-room CAD application written in pure Rust. Автоматизоване проєктування та креслення: CAD-програма з відкритим кодом, незалежно розроблена мовою Rust. + Concentric Концентричність + Cone Конус + Constraint Bars Панелі обмежень + Constraint Settings Налаштування обмежень + Construction Line Допоміжна пряма + Contents Вміст + Continue Продовжити + Contributors Учасники + Control Vertices Керувальні вершини + Copy Копіювати + Copy Layout Копіювати аркуш + Copy Nested Objects Копіювати вкладені об'єкти + Copy with Base Point Копіювати з базовою точкою + Count Кількість + Create Block Створити блок + Creates a user parameter Створює параметр користувача + Ctrl+Enter to finish Ctrl+Enter для завершення + Current layer Поточний шар + Current selection Поточний вибір + Current: {color} Поточний: {color} + Cut Вирізати + Cyan Блакитний + Cylinder Циліндр + Dark Темна + Default Типово + Define Attributes Визначити атрибути + Define Attributes... Визначити атрибути... + Defined width Задана ширина + Degree Градус + Delete Видалити + Delete Column Видалити стовпець + Delete Constraints Видалити обмеження + Delete Dimension Style Видалити розмірний стиль + Delete Duplicate Objects Видалити дублікати об'єктів + Delete Layer Видалити шар + Delete Layer State Видалити стан шарів + Delete Layout Видалити аркуш + Delete Row Видалити рядок + Delete Text Style Видалити текстовий стиль + Delta X Різниця X + Delta Y Різниця Y + Description Опис + Diameter Діаметр + Dim style Розмірний стиль + Dimension Розміри + Dimension Space Відстань між розмірами + Dimension Style Override Перевизначення розмірного стилю + Dimension Style... Розмірний стиль... + Dimension Text Edit Редагувати розмірний текст + Dimension style Розмірний стиль + Dimensional Constraint Розмірне обмеження + Dimensional Constraint Parameters Параметри розмірних обмежень + Dimensional Constraints Розмірні обмеження + Disassociate Dimensions Скасувати асоціативність розмірів + Display name Відображуване ім'я + Distance Відстань + Divide Поділити + Do not plot (click: plot) Не друкувати (натисніть для друку) + Donut Кільце + Double-click to edit the description Двічі клацніть, щоб редагувати опис + Down Униз + Drafting Креслення + Drafting Settings Налаштування креслення + Drafting Settings... Налаштування креслення... + Draw Креслення + Draw Order Порядок відображення + Drawing (DXF) Креслення (DXF) + Drawing Limits Межі креслення + Drawings (DWG, DXF) Креслення (DWG, DXF) + Dynamic Dimensions Динамічні розміри + Dynamic Input Динамічне введення + Dynamic Input (F12) Динамічне введення (F12) + E Сх + Edit Редагування + Edit Text... Редагувати текст... + Elevation Відмітка + Ellipse Еліпс + Elliptical Arc Еліптична дуга + Enable Dynamic Input (F12) Увімкнути динамічне введення (F12) + End Width Кінцева ширина + End X Кінець X + End Y Кінець Y + End angle Кінцевий кут + Endpoint Кінцева точка + Enter Top, Zero or Bottom. Введіть Top, Zero або Bottom. + Enter a fit data option Введіть параметр апроксимації + Enter a pattern name Введіть назву візерунка + Enter a vertex editing option Введіть параметр редагування вершини + Enter an option Введіть параметр + Enter angle of xline Введіть кут допоміжної прямої + Enter angle of xline (0) or Введіть кут допоміжної прямої (0) або + Enter area of rectangle in current units Введіть площу прямокутника в поточних одиницях + Enter attribute prompt Введіть підказку атрибута + Enter attribute tag name Введіть ім'я тегу атрибута + Enter attribute value Введіть значення атрибута + Enter block name Введіть назву блоку + Enter constraint type Введіть тип обмеження + Enter continuity Введіть тип неперервності + Enter default attribute value Введіть типове значення атрибута + Enter degree of spline Введіть степінь сплайна + Enter delta angle Введіть зміну кута + Enter delta length or Введіть зміну довжини або + Enter dimension text Введіть розмірний текст + Enter dimension text (<> = measurement) Введіть розмірний текст (<> = виміряне значення) + Enter dimension value or [name=expression] Введіть розмірне значення або [name=expression] + Enter dimension variable name to override Введіть назву розмірної змінної для перевизначення + Enter justification type Введіть тип вирівнювання + Enter leader text Введіть текст виноски + Enter mline scale Введіть масштаб мультилінії + Enter mode Введіть режим + Enter name of block to insert Введіть назву блоку для вставлення + Enter new text Введіть новий текст + Enter next point Введіть наступну точку + Enter number of columns Введіть кількість стовпців + Enter number of data rows Введіть кількість рядків даних + Enter number of items Введіть кількість елементів + Enter number of items along path Введіть кількість елементів уздовж траєкторії + Enter number of sides Введіть кількість сторін + Enter number of turns Введіть кількість витків + Enter percentage length Введіть довжину у відсотках + Enter rectangle length Введіть довжину прямокутника + Enter rectangle width Введіть ширину прямокутника + Enter scale factor Введіть коефіцієнт масштабу + Enter text Введіть текст + Enter text (use \P for new paragraphs) Введіть текст (\P починає новий абзац) + Enter the number of segments Введіть кількість сегментів + Enter twist direction of helix Введіть напрямок закручування спіралі + Entire drawing Усе креслення + Equal Рівність + Erase Стерти + Exclude from new selection set Виключити з нового набору вибраних об'єктів + Explode Розчленувати + Export Експортувати + Export to PDF... Експортувати в PDF... + Expression Вираз + Extend Подовжити + Extension Продовження + Extents За межами об'єктів + Extrude Видавити + File Файл + File Tab Вкладка файлу + Fillet Спряження + Filter Фільтр + Filters Фільтри + Find and Replace Знайти й замінити + Find... Знайти... + First Перший + First commit Перший коміт + Fit Points Точки апроксимації + Fix Фіксація + Flatten Objects Спроєктувати об'єкти на площину + Format Формат + Freehand Від руки + Freeze Заморозити + Freeze Layer Заморозити шар + Freeze in new viewports Заморозити в нових видових екранах + Freeze/Thaw Заморозити/розморозити + Freeze/Thaw in all viewports Заморозити/розморозити в усіх видових екранах + Freeze/Thaw in the current viewport Заморозити/розморозити в поточному видовому екрані + General Загальні + Geometric Center Геометричний центр + Geometric Constraint Геометричне обмеження + Geometric Constraints Геометричні обмеження + Geometry Геометрія + Get Variable Отримати змінну + Get View Отримати вид + Global width Загальна ширина + Grab bag Список + Gradient Градієнт + Gradient... Градієнт... + Green Зелений + Grid (F7) Сітка (F7) + Grid Display Відображення сітки + Grid On (F7) Увімкнути сітку (F7) + Grid spacing Крок сітки + Grip Mirror Дзеркальне відображення ручками + Grip Rotate Поворот ручками + Grip Scale Масштабування ручками + Grip Stretch Розтягування ручками + Hatch Штрихування + Hatch Edit Редагувати штрихування + Hatch... Штрихування... + Height Висота + Height: {height} Висота: {height} + Helix Спіраль + Help Довідка + Hide All Приховати всі + Hide All Constraint Bars Приховати всі панелі обмежень + Hide All Dynamic Dimensions Приховати всі динамічні розміри + Hide Layer List Приховати список шарів + Hide Palettes Приховати палітри + Home Початковий вид + Horizontal Горизонтальність + HorizontalDistance Горизонтальна відстань + How to apply: Спосіб застосування: + ID Point Координати точки + Import Імпортувати + In Наблизити + In-window Menu Bar Рядок меню у вікні + Include in new selection set Включити до нового набору вибраних об'єктів + Increment angle Крок кута + Index Color Індексований колір + Index color: {index} Індексований колір: {index} + Inquiry Вимірювання + Insert Вставлення + Insert Block Вставити блок + Insert Column Вставити стовпець + Insert Row Вставити рядок + Insert this block Вставити цей блок + Insertion Точка вставлення + Insertion X Вставлення X + Insertion Y Вставлення Y + Insertion Z Вставлення Z + Inspect Constraints Переглянути обмеження + Inspect Drawing Переглянути креслення + Interface Language Мова інтерфейсу + Interface Theme Тема інтерфейсу + Intersect Перетин + Intersection Перетин + Isolate Ізолювати + Isolate Layer Ізолювати шар + Isometric Drafting Ізометричне креслення + Join Об'єднати + Join the ArtCraft Discord… Приєднатися до Discord ArtCraft… + Join us on Discord Приєднуйтеся до нас у Discord + Language Мова + Last Останній + Last commit Останній коміт + Layer Шар + Layer Match Узгодити шар + Layer Off Вимкнути шар + Layer Properties Властивості шару + Layer Properties Manager Диспетчер властивостей шарів + Layer Properties Manager — current viewport Диспетчер властивостей шарів — поточний видовий екран + Layer State Стан шарів + Layer States Manager... Диспетчер станів шарів... + Layer Tools Інструменти шарів + Layer contains objects (double-click to make current) Шар містить об'єкти (двічі клацніть, щоб зробити поточним) + Layer is empty (double-click to make current) Шар порожній (двічі клацніть, щоб зробити поточним) + Layers Шари + Layers displayed: {shown} of {total}. Current layer: {layer} Показано шарів: {shown} із {total}. Поточний шар: {layer} + Layout Аркуш + Layout list Список аркушів + Leader Виноска + Left Ліворуч + Length Довжина + Length X Довжина X + Length Y Довжина Y + Lengthen Змінити довжину + Light Світла + Line Відрізок + Line delta Зміна кількості рядків + Linear Лінійний + Lines +/− Рядки +/− + Lines added Додані рядки + Lines deleted Видалені рядки + Linetype Тип лінії + Linetype Scale Масштаб типу лінії + Linetype scale Масштаб типу лінії + Ltscale Масштаб типу лінії + Linetype... Тип лінії... + Lineweight Товщина лінії + Lineweight... Товщина лінії... + List Список + List Blocks Список блоків + List Dimension Styles Список розмірних стилів + List Layer States Список станів шарів + List Layouts Список аркушів + List System Variables Список системних змінних + List Text Styles Список текстових стилів + Load all Завантажити всі + Load... Завантажити... + Loaded linetypes Завантажені типи ліній + Lock Заблокувати + Lock Layer Заблокувати шар + Lock/Unlock Заблокувати/розблокувати + Loft Тіло за перерізами + MIT OR Apache-2.0. Not affiliated with Autodesk, Inc. MIT OR Apache-2.0. Не пов'язано з Autodesk, Inc. + MODEL МОДЕЛЬ + MText Багаторядковий текст + Magenta Пурпуровий + Major line every Основна лінія через кожні + Make Current Зробити поточним + Make Object's Layer Current Зробити шар об'єкта поточним + Make... Створити... + Match Layer Узгодити шар + Match Properties Копіювати властивості + Measure Розмітити + Measure Distance Виміряти відстань + Measure Geometry Виміряти геометрію + Merge Cells Об'єднати комірки + Merged PRs Об'єднані PR + Midpoint Середина + Mirror Дзеркальне відображення + Model Модель + Model Space (in viewport) Простір моделі (у видовому екрані) + Modeling Моделювання + Models Моделі + Modify Модифікація + Move Перемістити + Multileader Мультивиноска + Multileader Style... Стиль мультивиносок... + Multileader style Стиль мультивиносок + Multiline Мультилінія + Multiline Text Багаторядковий текст + Multiline Text... Багаторядковий текст... + Multiple Різні типи + Multiple Point Кілька точок + My Мої + My properties: not available yet Мої властивості: поки недоступно + N Пн + Name Назва + Name (A–Z) Назва (А–Я) + Nearest Найближча точка + New Створити + New (metric) Створити (метричне) + New Drawing Нове креслення + New Drawing (⌘N) Нове креслення (⌘N) + New Drawing... Нове креслення... + New Layer Новий шар + New Layout Новий аркуш + New VP Freeze Заморозити в нових ВЕ + New Viewports... Нові видові екрани... + New layout Новий аркуш + New state name Назва нового стану + No Ні + No blocks defined in this drawing. У цьому кресленні немає визначених блоків. + No contributor data was built into this copy. Ця збірка не містить даних про учасників. + No model credits were built into this copy. Ця збірка не містить даних про моделі. + No saved layer states Немає збережених станів шарів + No selection Нічого не вибрано + Node Вузол + None Немає + Not available Недоступно + Not available yet Поки недоступно + OK Гаразд + Object Об'єкт + Object Snap Об'єктна прив'язка + Object Snap (F3) Об'єктна прив'язка (F3) + Object Snap On (F3) Увімкнути об'єктну прив'язку (F3) + Object Snap Tracking Відстеження об'єктної прив'язки + Object Snap Tracking (F11) Відстеження об'єктної прив'язки (F11) + Object type: Тип об'єкта: + Off Вимкнено + Offset Зміщення + On Увімкнено + On/Off Увімкнути/вимкнути + One of the Crafting Apps by the ArtCraft team and community. Одна з програм Crafting Apps від команди та спільноти ArtCraft. + Open (⌘O) Відкрити (⌘O) + Open Sample Drawing Відкрити зразок креслення + Open sample drawing Відкрити зразок креслення + Open... Відкрити... + Operator: Оператор: + Orbit Орбіта + Ordinate Ординатний + Ortho (F8) Орто (F8) + Ortho Mode Режим Орто + Ortho Mode (F8) Режим Орто (F8) + Out Віддалити + Override Перевизначити + PAPER АРКУШ + Percent Відсоток + Polar (distance < angle) Полярні (відстань < кут) + PRs PR + Page Setup Manager Диспетчер параметрів аркушів + Page Setup Manager... Диспетчер параметрів аркушів... + Pan Панорамування + Pan Down Панорамувати вниз + Pan Left Панорамувати ліворуч + Pan Right Панорамувати праворуч + Pan Up Панорамувати вгору + Paper Space Простір аркуша + Parallel Паралельність + Parameters Manager Диспетчер параметрів + Parametric Параметризація + Paste Вставити + Paste to Original Coordinates Вставити у вихідні координати + Path Array Масив уздовж траєкторії + Pattern Візерунок + Perpendicular Перпендикулярність + Plot Друк креслення + Plot (click: do not plot) Друкувати (натисніть, щоб вимкнути друк) + Plot Preview Попередній перегляд друку + Plot style Стиль друку + Plot style attached to Стиль друку застосовано до + Plot style table Таблиця стилів друку + Plot table type Тип таблиці друку + Plus/minus Плюс/мінус + Point Точка + Point Style... Стиль точок... + Polar Array Круговий масив + Polar Tracking Полярне відстеження + Polar Tracking (F10) Полярне відстеження (F10) + Polar Tracking On (F10) Увімкнути полярне відстеження (F10) + Polar: {distance} < {angle}° Полярне: {distance} < {angle}° + Polygon Правильний багатокутник + Polygonal Багатокутний + Polyline Полілінія + Position X Позиція X + Position Y Позиція Y + Position Z Позиція Z + Previous Попередній + Previous Layer Попередній шар + Primitives Примітиви + Print Друк + Print... Друк... + Properties Властивості + Properties Inspector Інспектор властивостей + Properties: Властивості: + Proxy Проксі-об'єкт + Purge Очистити невикористане + Pyramid Піраміда + Quadrant Квадрант + Query Entities Запит об'єктів + Quick Швидкий + Quick Dimension Швидкий розмір + Quick Properties Швидкі властивості + Quick Select Швидкий вибір + Quick Select Properties Властивості швидкого вибору + Quick Select... Швидкий вибір... + QuickCalc Калькулятор + Quit CADCraft Вийти з CADCraft + Radius Радіус + Raster Image Растрове зображення + Ray Промінь + Read-only Лише для читання + Real name Справжнє ім'я + Realtime У реальному часі + Reassociate Dimensions Відновити асоціативність розмірів + Recent Нещодавні + Rectangle Прямокутник + Rectangular Прямокутний + Rectangular Array Прямокутний масив + Red Червоний + Redo Повторити + Redo (⇧⌘Z) Повторити (⇧⌘Z) + Redraw Перемалювати + Regen Регенерувати + Regen All Регенерувати все + Region Область + Region/Mass Properties Властивості області/маси + Relative Відносні + Remove Leader Видалити виноску + Remove the viewport override Видалити перевизначення видового екрана + Rename Dimension Style Перейменувати розмірний стиль + Rename Layer Перейменувати шар + Rename Layer State Перейменувати стан шарів + Rename Layout Перейменувати аркуш + Rename Text Style Перейменувати текстовий стиль + Rename... Перейменувати... + Requires a point (x,y, @dx,dy or @d Укажіть другу точку або <використати першу точку як зміщення> + Specify second source point Укажіть другу вихідну точку + Specify side to offset Укажіть бік зміщення + Specify start angle Укажіть початковий кут + Specify start point Укажіть початкову точку + Specify start point of arc Укажіть початкову точку дуги + Specify start point of polyline Укажіть початкову точку полілінії + Specify start point of text Укажіть початкову точку тексту + Specify starting width Укажіть початкову ширину + Specify starting width for next segment Укажіть початкову ширину наступного сегмента + Specify tangent direction for the start point of arc Укажіть напрямок дотичної в початковій точці дуги + Specify the elevation for rectangles Укажіть відмітку прямокутників + Specify thickness for rectangles Укажіть висоту видавлювання прямокутників + Specify third point on circle Укажіть третю точку на колі + Specify third point on circle: _tan to Укажіть третю точку на колі: _tan до + Specify third source point or Укажіть третю вихідну точку або <продовжити> + Specify through point Укажіть точку, через яку проходить пряма + Specify top diameter Укажіть діаметр вершини + Specify top radius or Укажіть радіус вершини або + Specify total angle Укажіть загальний кут + Specify total length or Укажіть загальну довжину або + Specify width for rectangles Укажіть ширину прямокутників + Sphere Сфера + Spline Сплайн + Stack (type 1/2 then select) Дріб (введіть 1/2, потім виділіть) + Standard Стандартний + Start Початок + Start Width Початкова ширина + Start X Початок X + Start Y Початок Y + Start angle Початковий кут + Start, Center, Angle Початок, центр, кут + Start, Center, End Початок, центр, кінець + Start, Center, Length Початок, центр, довжина + Start, End, Angle Початок, кінець, кут + Start, End, Direction Початок, кінець, напрямок + Start, End, Radius Початок, кінець, радіус + Status Стан + Status Bar Рядок стану + Stretch Розтягнути + Style: Standard Стиль: Standard + Subtract Відняти + Sweep Тіло вздовж траєкторії + Switch Drawing Перемкнути креслення + Switch Layout Перемкнути аркуш + Switch between model space in a viewport and paper space Перемкнути між простором моделі у видовому екрані та простором аркуша + Symmetric Симетрія + Toggle PICKADD (off: each pick replaces the selection) Перемкнути PICKADD (вимкнено: кожен вибір замінює набір вибраних об'єктів) + Toggle PICKADD (on: picks add to the selection) Перемкнути PICKADD (увімкнено: вибране додається до набору вибраних об'єктів) + TOP ЗВЕРХУ + Table Таблиця + Table Style... Стиль таблиць... + Table style Стиль таблиць + Table... Таблиця... + Tan, Tan, Radius Дотична, дотична, радіус + Tan, Tan, Tan Дотична, дотична, дотична + Tangent Дотичність + Text Текст + Text Editor Текстовий редактор + Text Height Висота тексту + Text Style... Текстовий стиль... + Text height Висота тексту + Text override Перевизначення тексту + Text style Текстовий стиль + Thaw All Layers Розморозити всі шари + There is nothing here yet. Тут поки що нічого немає. + Thickness Висота видавлювання + Time Час + To get started, create or open a drawing. Щоб почати, створіть або відкрийте креслення. + Tool Bar Панель інструментів + Tool Sets Набори інструментів + Tools Інструменти + Torus Тор + Trace Смуга + Transparency Прозорість + Trim Обрізати + True Color Колір RGB + Turn All Layers On Увімкнути всі шари + Turn Off Layer Вимкнути шар + Turn Quick Properties off (QPMODE) Вимкнути швидкі властивості (QPMODE) + Type Тип + Type a command Введіть команду + Type text; Enter starts a new paragraph Введіть текст; Enter починає новий абзац + UCS Icon Піктограма СКК + Undo Скасувати дію + Undo (⌘Z) Скасувати дію (⌘Z) + Union Об'єднання + Unisolate Layer Скасувати ізоляцію шарів + Units... Одиниці... + Unlock Layer Розблокувати шар + Unmerge Cells Розділити комірки + Unsaved Layer State Незбережений стан шарів + Up Угору + Update Оновити + Use System Setting Як у системі + User Користувач + User Parameters Параметри користувача + Username Ім'я користувача + VP Color Колір ВЕ + VP Freeze Заморожування ВЕ + Value Значення + Value: Значення: + Version Версія + Version {version} Версія {version} + Vertical Вертикальність + VerticalDistance Вертикальна відстань + View Вид + ViewCube Куб виду + Viewport Видовий екран + Viewport Layer Freeze Заморозити шар у видовому екрані + Viewport Properties Властивості видового екрана + Viewport {id} Видовий екран {id} + Viewports Видові екрани + W Зх + WCS ⌄ ССК ⌄ + White Білий + Width factor Коефіцієнт ширини + Window Вікно + Wipeout Маскування + Workspace Switching Перемикання робочого простору + Write Block Записати блок у файл + Xline Допоміжна пряма + Yellow Жовтий + Yes Так + Zoom Масштабування виду + Zoom All Показати все + Zoom Extents Показати межі об'єктів + Zoom In Наблизити + Zoom Object Показати об'єкт + Zoom Out Віддалити + Zoom Previous Попередній масштаб + Zoom Window Масштабувати рамкою + error помилка + {contributors} · {commits} {contributors} · {commits} + {kind} (constraint {id}) {kind} (обмеження {id}) + {kind} constraint {name} = {expression} (double-click to edit) Обмеження «{kind}» {name} = {expression} (двічі клацніть для редагування) + ƒx New User Parameter ƒx Новий параметр користувача + ΔLOC Δрядків + −Bin −двійк. + −LOC −рядків + ⌘↩ to finish ⌘↩ для завершення +@plural {n} commit {n} коміт|{n} коміти|{n} комітів +@plural {n} contributor {n} учасник|{n} учасники|{n} учасників + Drawing as bytes Креслення як байти + Services Служби + Hide CADCraft Приховати CADCraft + Hide Others Приховати інші diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 1367c329..272197b4 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -917,5 +917,5 @@ pub fn button(ui: &mut egui::Ui, icon: Icon, size: f32, tooltip: &str, selected: ui.painter().rect_filled(rect, 3.0, t.control_hover.gamma_multiply(0.6)); } paint(ui.painter(), rect.shrink(size * 0.08), icon, !ui.is_enabled()); - resp.on_hover_text(tooltip) + resp.on_hover_text(crate::i18n::t(tooltip)) } diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index f30e1b8a..93e40c06 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -49,7 +49,7 @@ pub fn color_button(ui: &mut egui::Ui, id: egui::Id, current: Color, by: bool, w p.rect_filled(rect, 2.0, t.control_hover.gamma_multiply(0.5)); } swatch(p, Rect::from_min_size(pos2(rect.left() + 3.0, rect.center().y - 6.0), vec2(12.0, 12.0)), current); - p.text(pos2(rect.left() + 20.0, rect.center().y), egui::Align2::LEFT_CENTER, current.name(), crate::theme::body(), t.text); + p.text(pos2(rect.left() + 20.0, rect.center().y), egui::Align2::LEFT_CENTER, crate::i18n::t(¤t.name()), crate::theme::body(), t.text); let mut out = None; egui::Popup::from_toggle_button_response(&resp).id(id).width(300.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { out = color_palette(ui, id, current, by); @@ -68,7 +68,7 @@ pub fn color_palette(ui: &mut egui::Ui, id: egui::Id, current: Color, by: bool) let mut out = None; ui.horizontal(|ui| { for (i, l) in ["Index Color", "True Color"].iter().enumerate() { - if ui.selectable_label(tab == i as u8, *l).clicked() { + if ui.selectable_label(tab == i as u8, crate::i18n::t(l)).clicked() { tab = i as u8; } } @@ -83,7 +83,7 @@ pub fn color_palette(ui: &mut egui::Ui, id: egui::Id, current: Color, by: bool) if sel || resp.hovered() { ui.painter().rect_stroke(r, 0.0, Stroke::new(if sel { 2.0 } else { 1.0 }, Color32::WHITE), egui::StrokeKind::Inside); } - if resp.on_hover_text(format!("Index color: {i}")).clicked() { + if resp.on_hover_text(crate::tf!("Index color: {index}", index = i)).clicked() { *out = Some(Color::Index(i)); } }; @@ -113,15 +113,15 @@ pub fn color_palette(ui: &mut egui::Ui, id: egui::Id, current: Color, by: bool) ui.add_space(6.0); if by { ui.horizontal(|ui| { - if ui.button("ByLayer").clicked() { + if ui.button(crate::tl!("ByLayer")).clicked() { out = Some(Color::ByLayer); } - if ui.button("ByBlock").clicked() { + if ui.button(crate::tl!("ByBlock")).clicked() { out = Some(Color::ByBlock); } }); } - ui.label(RichText::new(format!("Current: {}", current.name())).small().color(t.text_dim)); + ui.label(RichText::new(crate::tf!("Current: {color}", color = crate::i18n::t(¤t.name()))).small().color(t.text_dim)); } else { let rid = id.with("rgb"); let start = current.resolve(Color::Index(7), Color::Index(7)); @@ -138,7 +138,7 @@ pub fn color_palette(ui: &mut egui::Ui, id: egui::Id, current: Color, by: bool) } }); ui.data_mut(|d| d.insert_temp(rid, c)); - if ui.button("Apply true color").clicked() { + if ui.button(crate::tl!("Apply true color")).clicked() { out = Some(Color::True(cadcraft_color::Rgb(c[0], c[1], c[2]))); ui.data_mut(|d| d.remove::<[u8; 3]>(rid)); } @@ -237,9 +237,13 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { Some(_) => "Layer Properties Manager — current viewport", None => "Layer Properties Manager", }; - egui::Window::new(title).id(egui::Id::new("lpm_window")).open(open).default_size(vec2(1180.0, 440.0)).min_width(640.0).resizable(true).show( - ctx, - |ui| { + egui::Window::new(crate::i18n::t(title)) + .id(egui::Id::new("lpm_window")) + .open(open) + .default_size(vec2(1180.0, 440.0)) + .min_width(640.0) + .resizable(true) + .show(ctx, |ui| { // ----- tool row ----- ui.horizontal(|ui| { if icons::button(ui, Icon::Plus, 24.0, "New Layer", false).clicked() { @@ -264,8 +268,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } ui.separator(); // Layer states. - ui.label("Layer State"); - let label = st.state_sel.clone().unwrap_or_else(|| "Unsaved Layer State".into()); + ui.label(crate::tl!("Layer State")); + let label = st.state_sel.clone().unwrap_or_else(|| crate::tl!("Unsaved Layer State").into()); egui::ComboBox::from_id_salt("lpm_states").selected_text(label).width(170.0).show_ui(ui, |ui| { for s in &states { if ui.selectable_label(st.state_sel.as_deref() == Some(s), s).clicked() { @@ -273,29 +277,29 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } } if states.is_empty() { - ui.label(RichText::new("No saved layer states").color(t.text_dim)); + ui.label(RichText::new(crate::tl!("No saved layer states")).color(t.text_dim)); } }); let has = st.state_sel.is_some(); - if ui.add_enabled(has, egui::Button::new("Restore")).clicked() + if ui.add_enabled(has, egui::Button::new(crate::tl!("Restore"))).clicked() && let Some(s) = &st.state_sel { actions.push(("layerstate.restore", json!({ "name": s }))); } - if ui.add_enabled(has, egui::Button::new("Delete")).clicked() + if ui.add_enabled(has, egui::Button::new(crate::tl!("Delete"))).clicked() && let Some(s) = st.state_sel.take() { actions.push(("layerstate.delete", json!({ "name": s }))); } - ui.add(egui::TextEdit::singleline(&mut st.state_name).hint_text("New state name").desired_width(120.0)); - if ui.add_enabled(!st.state_name.trim().is_empty(), egui::Button::new("Save State")).clicked() { + ui.add(egui::TextEdit::singleline(&mut st.state_name).hint_text(crate::tl!("New state name")).desired_width(120.0)); + if ui.add_enabled(!st.state_name.trim().is_empty(), egui::Button::new(crate::tl!("Save State"))).clicked() { let n = st.state_name.trim().to_string(); actions.push(("layerstate.save", json!({ "name": n }))); st.state_sel = Some(n); st.state_name.clear(); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add(egui::TextEdit::singleline(&mut st.search).hint_text("Search for layer").desired_width(160.0)); + ui.add(egui::TextEdit::singleline(&mut st.search).hint_text(crate::tl!("Search for layer")).desired_width(160.0)); icons::paint( ui.painter(), Rect::from_center_size(ui.cursor().right_center() - vec2(8.0, 0.0), vec2(14.0, 14.0)), @@ -315,18 +319,18 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { // ----- filter tree ----- ui.allocate_ui_with_layout(vec2(150.0, avail), egui::Layout::top_down(egui::Align::Min), |ui| { ui.set_min_width(150.0); - ui.label(RichText::new("Filters").small().color(t.text_dim)); + ui.label(RichText::new(crate::tl!("Filters")).small().color(t.text_dim)); for (i, l) in ["All", "All Used Layers"].iter().enumerate() { ui.horizontal(|ui| { ui.add_space(if i == 0 { 0.0 } else { 14.0 }); - if ui.selectable_label(st.filter == i as u8, *l).clicked() { + if ui.selectable_label(st.filter == i as u8, crate::i18n::t(l)).clicked() { st.filter = i as u8; } }); } ui.add_space(8.0); if let Some((h, _)) = &vp { - ui.label(RichText::new(format!("Viewport {}", h.hex())).small().color(t.text_dim)); + ui.label(RichText::new(crate::tf!("Viewport {id}", id = h.hex())).small().color(t.text_dim)); } }); ui.separator(); @@ -345,11 +349,17 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { for h in &cols { if *h == "Name" { let arrow = if st.sort_desc { " ▼" } else { " ▲" }; - if ui.add(egui::Label::new(RichText::new(format!("Name{arrow}")).strong()).sense(Sense::click())).clicked() { + if ui + .add( + egui::Label::new(RichText::new(format!("{}{arrow}", crate::tl!("Name"))).strong()) + .sense(Sense::click()), + ) + .clicked() + { st.sort_desc = !st.sort_desc; } } else { - ui.label(RichText::new(*h).strong()); + ui.label(RichText::new(crate::i18n::t(h)).strong()); } } ui.end_row(); @@ -365,13 +375,17 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { ui.separator(); ui.horizontal(|ui| { ui.label( - RichText::new(format!("All: {} layer(s) displayed of {} total. Current layer: {}", shown.len(), total, cur)) - .small() - .color(t.text_dim), + RichText::new(crate::tf!( + "Layers displayed: {shown} of {total}. Current layer: {layer}", + shown = shown.len(), + total = total, + layer = cur + )) + .small() + .color(t.text_dim), ); }); - }, - ); + }); ctx.data_mut(|dd| dd.insert_temp(sid, st)); for (c, p) in actions { let _ = app.run(c, p); @@ -408,7 +422,7 @@ fn row( } else { "Layer is empty (double-click to make current)" }; - if sresp.on_hover_text(tip).double_clicked() { + if sresp.on_hover_text(crate::i18n::t(tip)).double_clicked() { actions.push(("layer.current", json!({ "name": name }))); } // Name (double-click or F2-style rename). @@ -444,15 +458,15 @@ fn row( st.renaming = Some((name.clone(), name.clone())); } r.context_menu(|ui| { - if ui.button("Set Current").clicked() { + if ui.button(crate::tl!("Set Current")).clicked() { actions.push(("layer.current", json!({ "name": name }))); ui.close(); } - if ui.button("Rename Layer").clicked() { + if ui.button(crate::tl!("Rename Layer")).clicked() { st.renaming = Some((name.clone(), name.clone())); ui.close(); } - if ui.button("Delete Layer").clicked() { + if ui.button(crate::tl!("Delete Layer")).clicked() { actions.push(("layer.delete", json!({ "name": name }))); ui.close(); } @@ -503,7 +517,7 @@ fn row( .width(240.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| { - ui.label(RichText::new("Loaded linetypes").small().color(t.text_dim)); + ui.label(RichText::new(crate::tl!("Loaded linetypes")).small().color(t.text_dim)); for lt in linetypes.iter().filter(|x| !["bylayer", "byblock"].contains(&x.name.to_ascii_lowercase().as_str())) { let (r, resp) = ui.allocate_exact_size(vec2(230.0, 20.0), Sense::click()); if resp.hovered() || lt.name.eq_ignore_ascii_case(&l.linetype) { @@ -529,7 +543,7 @@ fn row( } }); ui.separator(); - if ui.button("Load all").clicked() { + if ui.button(crate::tl!("Load all")).clicked() { actions.push(("linetype", json!({ "load": "*" }))); ui.close(); } @@ -544,7 +558,13 @@ fn row( ui.painter().rect_filled(r, 2.0, t.control_hover.gamma_multiply(0.5)); } lw_sample(ui.painter(), Rect::from_min_size(pos2(r.left() + 3.0, r.top()), vec2(22.0, r.height())), l.lineweight, t.text); - ui.painter().text(pos2(r.left() + 30.0, r.center().y), egui::Align2::LEFT_CENTER, l.lineweight.name(), crate::theme::body(), t.text); + ui.painter().text( + pos2(r.left() + 30.0, r.center().y), + egui::Align2::LEFT_CENTER, + crate::i18n::t(&l.lineweight.name()), + crate::theme::body(), + t.text, + ); resp }) .inner; @@ -561,7 +581,13 @@ fn row( ui.painter().rect_filled(r, 2.0, t.accent.gamma_multiply(if resp.hovered() { 0.6 } else { 0.3 })); } lw_sample(ui.painter(), Rect::from_min_size(pos2(r.left() + 4.0, r.top()), vec2(40.0, r.height())), lw, t.text); - ui.painter().text(pos2(r.left() + 52.0, r.center().y), egui::Align2::LEFT_CENTER, lw.name(), crate::theme::body(), t.text); + ui.painter().text( + pos2(r.left() + 52.0, r.center().y), + egui::Align2::LEFT_CENTER, + crate::i18n::t(&lw.name()), + crate::theme::body(), + t.text, + ); if resp.clicked() { let v = match lw { Lineweight::Mm100(v) => json!(f64::from(v) / 100.0), @@ -598,7 +624,7 @@ fn row( if let Some(c) = color_button(ui, egui::Id::new(("lpm_vpcolor", &name)), vc.unwrap_or(l.color), false, 96.0) { actions.push(("vplayer", json!({ "handle": h.hex(), "colors": { name.clone(): c.name() } }))); } - if vc.is_some() && ui.small_button("×").on_hover_text("Remove the viewport override").clicked() { + if vc.is_some() && ui.small_button("×").on_hover_text(crate::tl!("Remove the viewport override")).clicked() { actions.push(("vplayer", json!({ "handle": h.hex(), "colors": { name.clone(): Value::Null } }))); } }); @@ -632,7 +658,7 @@ fn row( _ => { let text = if l.description.is_empty() { RichText::new("—").color(t.text_faint) } else { RichText::new(&l.description) }; let r = ui.add_sized([200.0, 20.0], egui::Label::new(text).sense(Sense::click()).truncate()); - if r.on_hover_text("Double-click to edit the description").double_clicked() { + if r.on_hover_text(crate::tl!("Double-click to edit the description")).double_clicked() { st.describing = Some((name.clone(), l.description.clone())); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2c6ed3ae..f09f1e2a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -17,6 +17,7 @@ pub mod credits; pub mod dialogs; pub mod dyninput; pub mod gpu; +pub mod i18n; pub mod icons; pub mod layers; pub mod managers; @@ -42,6 +43,7 @@ pub const PREFS_KEY: &str = "cadcraft.prefs"; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { + pub interface_language: i18n::Preference, pub show_toolsets: bool, pub show_palettes: bool, pub show_toolbar: bool, @@ -67,6 +69,7 @@ pub struct UiState { impl Default for UiState { fn default() -> Self { UiState { + interface_language: i18n::Preference::default(), show_toolsets: true, show_palettes: true, show_toolbar: true, @@ -96,6 +99,7 @@ pub struct Services { } pub struct CadApp { + pub system_languages: Vec, pub session: Session, pub ui: UiState, pub services: Services, @@ -126,6 +130,7 @@ pub struct CadApp { impl CadApp { pub fn new(session: Session, services: Services) -> Self { CadApp { + system_languages: Vec::new(), session, ui: UiState::default(), services, @@ -149,6 +154,10 @@ impl CadApp { } } + pub fn language(&self) -> &'static str { + self.ui.interface_language.resolve(&self.system_languages) + } + /// Draw the canvas on the GPU with the app's wgpu render state (eframe's /// `CreationContext::wgpu_render_state`). Without it the canvas draws on the CPU. pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState) { @@ -211,7 +220,7 @@ impl CadApp { /// Preferences kept across restarts, as JSON for the host's storage ([`PREFS_KEY`]). pub fn prefs_json(&self) -> String { - json!({ "theme": self.ui.theme.as_str() }).to_string() + json!({ "theme": self.ui.theme.as_str(), "interfaceLanguage": self.ui.interface_language.code() }).to_string() } /// Restore preferences saved by [`Self::prefs_json`]; unknown or malformed values are ignored. @@ -220,6 +229,9 @@ impl CadApp { if let Some(t) = v.get("theme").and_then(Value::as_str).and_then(theme::ThemePref::parse) { self.ui.theme = t; } + if let Some(l) = v.get("interfaceLanguage").and_then(Value::as_str).and_then(i18n::Preference::parse) { + self.ui.interface_language = l; + } } pub fn set_status(&mut self, s: impl Into) { @@ -291,6 +303,10 @@ impl CadApp { /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { + i18n::with_language(self.language(), || self.draw(ui)); + } + + fn draw(&mut self, ui: &mut egui::Ui) { let t0 = now_ms(); let t = theme::Tokens::get(); chrome::title_and_toolbar(self, ui); @@ -461,6 +477,6 @@ mod tests { frame(&mut a, &ctx, None); assert_eq!(Tokens::get(), Tokens::of(theme::SYSTEM_FALLBACK), "no OS appearance: the documented fallback"); assert_eq!(a.ui.theme, ThemePref::System); - assert_eq!(a.prefs_json(), r#"{"theme":"system"}"#); + assert_eq!(a.prefs_json(), r#"{"interfaceLanguage":"auto","theme":"system"}"#); } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f3e6d4fb..a6fb240a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -38,6 +38,8 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("ui.dialog.commands", "Command Reference", &["Help", "CADCraft Help"], Some("F1")), ("ui.toggle.history", "Command History", &["Window", "Command History"], Some("F2")), ("ui.cmdline.lines", "Command Line History Lines", &[], None), + ("ui.dialog.language", "Interface Language", &["Edit", "Interface Language"], None), + ("ui.language", "Language", &[], None), ("ui.noop", "", &[], None), ("ui.quit", "Quit CADCraft", &[], Some("Cmd+Q")), ]; @@ -48,6 +50,10 @@ pub fn is_ui_command(id: &str) -> bool { /// Run a UI-only command. `None` if `id` isn't one. pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option> { + crate::i18n::with_language(app.language(), || run_ui_command_inner(app, id, params)) +} + +fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option> { if let Some(r) = crate::managers::route(app, id, params) { return Some(r); } @@ -60,6 +66,13 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option { + let Some(preference) = params.get("lang").and_then(Value::as_str).and_then(crate::i18n::Preference::parse) else { + return Some(Err("language must be auto, en or uk".into())); + }; + app.ui.interface_language = preference; + Ok(json!({"interfaceLanguage": preference.code()})) + } "ui.open" | "open" if no_path => { let picked = app.services.pick_open.as_ref().and_then(|f| f()); if let Some(p) = picked { @@ -152,8 +165,8 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option { - let (menu, theme) = (app.ui.in_window_menu, app.ui.theme); - app.ui = crate::UiState { in_window_menu: menu, theme, ..Default::default() }; + let (menu, theme, interface_language) = (app.ui.in_window_menu, app.ui.theme, app.ui.interface_language); + app.ui = crate::UiState { in_window_menu: menu, theme, interface_language, ..Default::default() }; Ok(Value::Null) } "ui.start" => { @@ -173,7 +186,8 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option String { fn entry_ui(ui: &mut egui::Ui, e: &Entry, clicked: &mut Option) { match e { Entry::Item { label, id, shortcut, enabled } => { - let mut b = egui::Button::new(label); + let mut b = egui::Button::new(crate::i18n::t(label)); if let Some(s) = shortcut { b = b.shortcut_text(shortcut_label(s, ui.ctx().os().is_mac())); } @@ -326,7 +340,7 @@ fn entry_ui(ui: &mut egui::Ui, e: &Entry, clicked: &mut Option) { } } Entry::Sub { label, children } => { - ui.menu_button(label, |ui| { + ui.menu_button(crate::i18n::t(label), |ui| { for c in children { entry_ui(ui, c, clicked); } @@ -344,7 +358,7 @@ pub fn menu_bar(app: &mut CadApp, ui: &mut egui::Ui) { |ui| { egui::MenuBar::new().ui(ui, |ui| { for (name, entries) in &tree { - ui.menu_button(name, |ui| { + ui.menu_button(crate::i18n::t(name), |ui| { for e in entries { entry_ui(ui, e, &mut clicked); } diff --git a/crates/ui-egui/src/palettes.rs b/crates/ui-egui/src/palettes.rs index 347329ff..80e39103 100644 --- a/crates/ui-egui/src/palettes.rs +++ b/crates/ui-egui/src/palettes.rs @@ -133,7 +133,13 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { let active = app.ui.toolset_tab == name; let resp = ui.interact(tr, ui.id().with(("ts", name)), Sense::click()); p.rect_filled(tr, 0.0, if active { t.tab_active } else { t.chrome }); - p.text(tr.center(), egui::Align2::CENTER_CENTER, name, egui::FontId::proportional(13.5), if active { t.text } else { t.text_dim }); + p.text( + tr.center(), + egui::Align2::CENTER_CENTER, + crate::i18n::t(name), + egui::FontId::proportional(13.5), + if active { t.text } else { t.text_dim }, + ); if resp.clicked() { app.ui.toolset_tab = name.into(); } @@ -143,7 +149,7 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { let cr = Rect::from_center_size(pos2(r.right() - 14.0, r.top() + tab_h / 2.0), vec2(14.0, 14.0)); let cresp = ui.interact(cr, ui.id().with("ts_collapse"), Sense::click()); icons::paint(&p, cr, Icon::ChevronLeft, false); - if cresp.on_hover_text("Collapse Tool Sets").clicked() { + if cresp.on_hover_text(crate::tl!("Collapse Tool Sets")).clicked() { app.ui.show_toolsets = false; } let body = Rect::from_min_max(pos2(r.left(), r.top() + tab_h), r.max); @@ -164,7 +170,13 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { if collapsed { Icon::ChevronRight } else { Icon::ChevronDown }, false, ); - pp.text(pos2(hr.left() + 22.0, hr.center().y), egui::Align2::LEFT_CENTER, *name, egui::FontId::proportional(12.5), t.text); + pp.text( + pos2(hr.left() + 22.0, hr.center().y), + egui::Align2::LEFT_CENTER, + crate::i18n::t(name), + egui::FontId::proportional(12.5), + t.text, + ); icons::paint(pp, Rect::from_center_size(pos2(hr.right() - 12.0, hr.center().y), vec2(11.0, 11.0)), Icon::Gear, false); if hresp.clicked() { toggle_group = Some(name.to_string()); @@ -239,7 +251,13 @@ pub const MODELING: &[(&str, &[Tool], &[Tool])] = &[ fn section_header(ui: &mut egui::Ui, title: &str) -> Rect { let t = Tokens::get(); let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::hover()); - ui.painter().text(pos2(hr.left() + 10.0, hr.center().y), egui::Align2::LEFT_CENTER, title, egui::FontId::proportional(14.0), t.text); + ui.painter().text( + pos2(hr.left() + 10.0, hr.center().y), + egui::Align2::LEFT_CENTER, + crate::i18n::t(title), + egui::FontId::proportional(14.0), + t.text, + ); hr } @@ -359,7 +377,13 @@ fn layers_section(app: &mut CadApp, ui: &mut egui::Ui) { let w = ui.available_width() - 10.0; let (rect, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover()); ui.painter().rect_filled(rect, 3.0, t.control); - ui.painter().text(pos2(rect.left() + 8.0, rect.center().y), egui::Align2::LEFT_CENTER, "Unsaved Layer State", crate::theme::body(), t.text); + ui.painter().text( + pos2(rect.left() + 8.0, rect.center().y), + egui::Align2::LEFT_CENTER, + crate::tl!("Unsaved Layer State"), + crate::theme::body(), + t.text, + ); icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.right() - 12.0, rect.center().y), vec2(12.0, 12.0)), Icon::ChevronDown, false); }); ui.add_space(4.0); @@ -373,7 +397,7 @@ fn layers_section(app: &mut CadApp, ui: &mut egui::Ui) { if app.ui.show_layer_list { Icon::ChevronDown } else { Icon::ChevronRight }, false, ); - ui.painter().text(pos2(rect.left() + 20.0, rect.center().y), egui::Align2::LEFT_CENTER, label, crate::theme::body(), t.text); + ui.painter().text(pos2(rect.left() + 20.0, rect.center().y), egui::Align2::LEFT_CENTER, crate::i18n::t(label), crate::theme::body(), t.text); if resp.clicked() { app.ui.show_layer_list = !app.ui.show_layer_list; } @@ -411,8 +435,11 @@ fn layers_section(app: &mut CadApp, ui: &mut egui::Ui) { fn prop_row(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) { let t = Tokens::get(); ui.horizontal(|ui| { - let (lr, _) = ui.allocate_exact_size(vec2(118.0, 22.0), Sense::hover()); - ui.painter().text(pos2(lr.right() - 6.0, lr.center().y), egui::Align2::RIGHT_CENTER, label, crate::theme::body(), t.text_dim); + let mut job = egui::text::LayoutJob::simple(crate::i18n::t(label).to_string(), crate::theme::body(), t.text_dim, 106.0); + job.halign = egui::Align::RIGHT; + let galley = ui.painter().layout_job(job); + let (lr, _) = ui.allocate_exact_size(vec2(118.0, galley.size().y.max(22.0)), Sense::hover()); + ui.painter().galley(pos2(lr.right() - 6.0, lr.center().y - galley.size().y / 2.0), galley, t.text_dim); add(ui); }); } @@ -460,10 +487,12 @@ fn edit_field(ui: &mut egui::Ui, id: egui::Id, value: String) -> Option /// A drop-down of `items` showing `current`; returns the item clicked. fn pick_menu(ui: &mut egui::Ui, current: &str, items: impl IntoIterator) -> Option { + // Names are the drawing's own; only the ByLayer/ByBlock keywords are translated. + let shown = |s: &str| if ["ByLayer", "ByBlock"].contains(&s) { crate::i18n::t(s).to_string() } else { s.to_string() }; let mut out = None; - ui.menu_button(current.to_string(), |ui| { + ui.menu_button(shown(current), |ui| { for it in items { - if ui.button(&it).clicked() { + if ui.button(shown(&it)).clicked() { out = Some(it); ui.close(); } @@ -474,12 +503,12 @@ fn pick_menu(ui: &mut egui::Ui, current: &str, items: impl IntoIterator "No selection".to_string(), - (1, [(n, _)]) => n.to_string(), - (n, [(ty, _)]) => format!("{ty} ({n})"), - (n, _) => format!("All ({n})"), + (0, _) => crate::tl!("No selection").to_string(), + (1, [(n, _)]) => crate::i18n::t(n).to_string(), + (n, [(ty, _)]) => format!("{} ({n})", crate::i18n::t(ty)), + (n, _) => format!("{} ({n})", crate::tl!("All")), }; let mut run: Option<(&str, Value)> = None; let mut start: Option<&str> = None; @@ -528,20 +557,24 @@ fn properties_header(app: &mut CadApp, ui: &mut egui::Ui) { // Narrow the selection to one object type. egui::Popup::from_toggle_button_response(&resp).width(w - 10.0).close_behavior(egui::PopupCloseBehavior::CloseOnClick).show(|ui| { for (ty, hs) in &types { - if ui.button(format!("{ty} ({})", hs.len())).clicked() { + if ui.button(format!("{} ({})", crate::i18n::t(ty), hs.len())).clicked() { run = Some(("select", json!({ "handles": hs }))); } } }); } - let tip = if pickadd { "Toggle PICKADD (on: picks add to the selection)" } else { "Toggle PICKADD (off: each pick replaces the selection)" }; + let tip = if pickadd { + crate::tl!("Toggle PICKADD (on: picks add to the selection)") + } else { + crate::tl!("Toggle PICKADD (off: each pick replaces the selection)") + }; if icons::button(ui, Icon::PickAdd, 22.0, tip, pickadd).clicked() { run = Some(("setvar", json!({ "name": "PICKADD", "value": i32::from(!pickadd) }))); } - if icons::button(ui, Icon::SelectObjects, 22.0, "Select Objects", false).clicked() { + if icons::button(ui, Icon::SelectObjects, 22.0, crate::tl!("Select Objects"), false).clicked() { start = Some("select"); } - if icons::button(ui, Icon::QuickSelect, 22.0, "Quick Select", false).clicked() { + if icons::button(ui, Icon::QuickSelect, 22.0, crate::tl!("Quick Select"), false).clicked() { start = Some("ui.dialog.qselect"); } }); @@ -571,7 +604,7 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let mut action: Option<(&str, Value)> = None; let mut act = |c: &'static str, p: Value| action = Some((c, p)); prop_row(ui, "Color", |ui| { - ui.menu_button(format!("■ {}", Color::from_aci(h.i64("CECOLOR", 256) as i16).name()), |ui| { + ui.menu_button(format!("■ {}", crate::i18n::t(&Color::from_aci(h.i64("CECOLOR", 256) as i16).name())), |ui| { for (n, c) in [ ("ByLayer", 256), ("ByBlock", 0), @@ -583,7 +616,7 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { ("Magenta", 6), ("White", 7), ] { - if ui.button(n).clicked() { + if ui.button(crate::i18n::t(n)).clicked() { act("color", json!({ "color": c })); ui.close(); } @@ -612,9 +645,9 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } }); prop_row(ui, "Lineweight", |ui| { - ui.menu_button(cadcraft_doc::Lineweight::from_dxf(h.i64("CELWEIGHT", -1) as i16).name(), |ui| { + ui.menu_button(crate::i18n::t(&cadcraft_doc::Lineweight::from_dxf(h.i64("CELWEIGHT", -1) as i16).name()), |ui| { for n in ["ByLayer", "ByBlock", "Default"] { - if ui.button(n).clicked() { + if ui.button(crate::i18n::t(n)).clicked() { act("lweight", json!({ "lineweight": n })); ui.close(); } @@ -695,11 +728,11 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let group = |ui: &mut egui::Ui, title: &str| { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::hover()); ui.painter().rect_filled(r.shrink2(vec2(6.0, 1.0)), 2.0, t.chrome_dark); - ui.painter().text(pos2(r.left() + 12.0, r.center().y), egui::Align2::LEFT_CENTER, title, crate::theme::small(), t.text_dim); + ui.painter().text(pos2(r.left() + 12.0, r.center().y), egui::Align2::LEFT_CENTER, crate::i18n::t(title), crate::theme::small(), t.text_dim); }; group(ui, "General"); prop_row(ui, "Color", |ui| { - ui.menu_button(format!("■ {color}"), |ui| { + ui.menu_button(format!("■ {}", crate::i18n::t(&color)), |ui| { for (n, c) in [ ("ByLayer", 256), ("ByBlock", 0), @@ -713,7 +746,7 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { ("Color 8", 8), ("Color 9", 9), ] { - if ui.button(n).clicked() { + if ui.button(crate::i18n::t(n)).clicked() { set = Some(json!({ "handles": ids, "color": c })); ui.close(); } @@ -731,12 +764,12 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { }); }); prop_row(ui, "Linetype", |ui| { - ui.menu_button(linetype.clone(), |ui| { + ui.menu_button(if ["ByLayer", "ByBlock", "*VARIES*"].contains(&linetype.as_str()) { crate::i18n::t(&linetype) } else { &linetype }, |ui| { for l in std::iter::once("ByLayer".to_string()) .chain(std::iter::once("ByBlock".to_string())) .chain(linetypes.iter().filter(|l| !["ByLayer", "ByBlock"].contains(&l.as_str())).cloned()) { - if ui.button(&l).clicked() { + if ui.button(if ["ByLayer", "ByBlock"].contains(&l.as_str()) { crate::i18n::t(&l) } else { &l }).clicked() { set = Some(json!({ "handles": ids, "linetype": l })); ui.close(); } @@ -751,9 +784,9 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } }); prop_row(ui, "Lineweight", |ui| { - ui.menu_button(lw.clone(), |ui| { + ui.menu_button(crate::i18n::t(&lw), |ui| { for n in ["ByLayer", "ByBlock", "Default"] { - if ui.button(n).clicked() { + if ui.button(crate::i18n::t(n)).clicked() { set = Some(json!({ "handles": ids, "lineweight": n })); ui.close(); } @@ -767,9 +800,9 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { }); }); prop_row(ui, "Transparency", |ui| { - ui.menu_button(tr.clone(), |ui| { + ui.menu_button(crate::i18n::t(&tr), |ui| { for v in ["ByLayer", "ByBlock"] { - if ui.button(v).clicked() { + if ui.button(crate::i18n::t(v)).clicked() { set = Some(json!({ "handles": ids, "transparency": v })); ui.close(); } @@ -780,7 +813,7 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let cur = tr.parse::().ok(); let mut pct = ui.data_mut(|d| d.get_temp::(tid)).or(cur).unwrap_or(0); ui.horizontal(|ui| { - ui.label("Percent"); + ui.label(crate::tl!("Percent")); let r = ui.add(egui::DragValue::new(&mut pct).range(0..=90).speed(0.5)); if r.dragged() || r.has_focus() { ui.data_mut(|d| d.insert_temp(tid, pct)); @@ -894,7 +927,7 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { prop_row(ui, "Closed", |ui| { let mut c = pl.closed; let label = if c { "Yes" } else { "No" }; - if ui.checkbox(&mut c, label).changed() { + if ui.checkbox(&mut c, crate::i18n::t(label)).changed() { set = Some(json!({ "handles": ids, "closed": c })); } }); diff --git a/crates/ui-egui/src/parametric.rs b/crates/ui-egui/src/parametric.rs index 8efc836b..f1d59fec 100644 --- a/crates/ui-egui/src/parametric.rs +++ b/crates/ui-egui/src/parametric.rs @@ -360,7 +360,12 @@ pub fn draw_overlay(app: &mut CadApp, ui: &mut egui::Ui, painter: &egui::Painter if resp.double_clicked() { act = Some(Act::Edit(c.name.clone(), c.expr.clone(), r.left_center())); } - resp.on_hover_text(format!("{} constraint {} = {} (double-click to edit)", c.kind.name(), c.name, c.expr)); + resp.on_hover_text(crate::tf!( + "{kind} constraint {name} = {expression} (double-click to edit)", + kind = crate::i18n::t(c.kind.name()), + name = c.name, + expression = c.expr + )); } // ----- geometric constraint bars ----- let mut bars: Vec<(Handle, Vec2, Vec<(&str, u32)>)> = Vec::new(); @@ -425,7 +430,7 @@ pub fn draw_overlay(app: &mut CadApp, ui: &mut egui::Ui, painter: &egui::Painter painter.rect_filled(cr.shrink(1.0), 3.0, Color32::from_rgb(0xf2, 0xf4, 0xf7)); } paint_glyph(painter, cr.shrink(2.0), kind, Color32::from_rgb(0x26, 0x2b, 0x33)); - resp.on_hover_text(format!("{kind} (constraint {id})")); + resp.on_hover_text(crate::tf!("{kind} (constraint {id})", kind = crate::i18n::t(kind), id = id)); } } let _ = act_take(app, act, ui); @@ -514,57 +519,66 @@ pub fn parameters_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) let filter_id = egui::Id::new("par_filter"); let mut filter = ctx.data_mut(|d| d.get_temp::(filter_id)).unwrap_or_default(); let mut action: Option = None; - egui::Window::new("Parameters Manager").open(open).default_size(vec2(560.0, 300.0)).resizable(true).show(ctx, |ui| { - ui.horizontal(|ui| { - if ui.button("ƒx New User Parameter").on_hover_text("Creates a user parameter").clicked() { - let mut n = 1; - while rows.iter().any(|r| r.name == format!("user{n}")) { - n += 1; - } - action = Some(json!({ "name": format!("user{n}"), "expr": "1" })); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add(egui::TextEdit::singleline(&mut filter).hint_text("Search for parameter").desired_width(170.0)); - }); - }); - ui.separator(); - let f = filter.to_ascii_lowercase(); - egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { - egui::Grid::new("par_grid").striped(true).num_columns(5).spacing(vec2(12.0, 4.0)).min_col_width(60.0).show(ui, |ui| { - for h in ["Name", "Expression", "Value", "Type", ""] { - ui.label(egui::RichText::new(h).strong()); - } - ui.end_row(); - for (kind, title) in [("dimensional", "Dimensional Constraint Parameters"), ("user", "User Parameters")] { - let group: Vec<&Row> = - rows.iter().filter(|r| r.kind == kind && (f.is_empty() || r.name.to_ascii_lowercase().contains(&f))).collect(); - ui.label(egui::RichText::new(title).color(t.text_dim).small()); - ui.end_row(); - for r in group { - ui.label(&r.name); - let id = egui::Id::new(("par_expr", &r.name)); - if let Some(v) = expr_field(ui, id, &r.expr) { - action = Some(json!({ "name": r.name, "expr": v })); - } - match (&r.value, &r.error) { - (Some(v), _) => ui.label(trim_num(*v, 4)), - (None, Some(e)) => ui.label(egui::RichText::new("error").color(Color32::from_rgb(0xff, 0x8a, 0x6a))).on_hover_text(e), - _ => ui.label(""), - }; - ui.label(egui::RichText::new(if kind == "user" { "User" } else { r.description.as_str() }).color(t.text_dim)); - if kind == "user" { - if ui.small_button("Delete").clicked() { - action = Some(json!({ "delete": r.name })); - } - } else { - ui.label(""); - } - ui.end_row(); + egui::Window::new(crate::tl!("Parameters Manager")) + .id(egui::Id::new("Parameters Manager")) + .open(open) + .default_size(vec2(560.0, 300.0)) + .resizable(true) + .show(ctx, |ui| { + ui.horizontal(|ui| { + if ui.button(crate::tl!("ƒx New User Parameter")).on_hover_text(crate::tl!("Creates a user parameter")).clicked() { + let mut n = 1; + while rows.iter().any(|r| r.name == format!("user{n}")) { + n += 1; } + action = Some(json!({ "name": format!("user{n}"), "expr": "1" })); } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add(egui::TextEdit::singleline(&mut filter).hint_text(crate::tl!("Search for parameter")).desired_width(170.0)); + }); + }); + ui.separator(); + let f = filter.to_ascii_lowercase(); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + egui::Grid::new("par_grid").striped(true).num_columns(5).spacing(vec2(12.0, 4.0)).min_col_width(60.0).show(ui, |ui| { + for h in ["Name", "Expression", "Value", "Type", ""] { + ui.label(egui::RichText::new(crate::i18n::t(h)).strong()); + } + ui.end_row(); + for (kind, title) in [("dimensional", "Dimensional Constraint Parameters"), ("user", "User Parameters")] { + let group: Vec<&Row> = + rows.iter().filter(|r| r.kind == kind && (f.is_empty() || r.name.to_ascii_lowercase().contains(&f))).collect(); + ui.label(egui::RichText::new(crate::i18n::t(title)).color(t.text_dim).small()); + ui.end_row(); + for r in group { + ui.label(&r.name); + let id = egui::Id::new(("par_expr", &r.name)); + if let Some(v) = expr_field(ui, id, &r.expr) { + action = Some(json!({ "name": r.name, "expr": v })); + } + match (&r.value, &r.error) { + (Some(v), _) => ui.label(trim_num(*v, 4)), + (None, Some(e)) => { + ui.label(egui::RichText::new(crate::tl!("error")).color(Color32::from_rgb(0xff, 0x8a, 0x6a))).on_hover_text(e) + } + _ => ui.label(""), + }; + ui.label( + egui::RichText::new(crate::i18n::t(if kind == "user" { "User" } else { r.description.as_str() })).color(t.text_dim), + ); + if kind == "user" { + if ui.small_button(crate::tl!("Delete")).clicked() { + action = Some(json!({ "delete": r.name })); + } + } else { + ui.label(""); + } + ui.end_row(); + } + } + }); }); }); - }); ctx.data_mut(|d| d.insert_temp(filter_id, filter)); if let Some(p) = action { let _ = app.run("parameters", p); diff --git a/crates/ui-egui/src/quick.rs b/crates/ui-egui/src/quick.rs index 29797b76..7211b832 100644 --- a/crates/ui-egui/src/quick.rs +++ b/crates/ui-egui/src/quick.rs @@ -76,132 +76,141 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let has_sel = !app.session.selection().is_empty(); let mut run: Option = None; let mut close = false; - egui::Window::new("Quick Select").open(open).collapsible(false).resizable(false).default_width(380.0).show(ctx, |ui| { - egui::Grid::new("qs_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { - ui.label("Apply to:"); - egui::ComboBox::from_id_salt("qs_apply") - .width(220.0) - .selected_text(if st.apply_selection { "Current selection" } else { "Entire drawing" }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut st.apply_selection, false, "Entire drawing"); - ui.add_enabled_ui(has_sel, |ui| { - ui.selectable_value(&mut st.apply_selection, true, "Current selection"); + egui::Window::new(crate::tl!("Quick Select")) + .id(egui::Id::new("Quick Select")) + .open(open) + .collapsible(false) + .resizable(false) + .default_width(380.0) + .show(ctx, |ui| { + egui::Grid::new("qs_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(crate::tl!("Apply to:")); + egui::ComboBox::from_id_salt("qs_apply") + .width(220.0) + .selected_text(crate::i18n::t(if st.apply_selection { "Current selection" } else { "Entire drawing" })) + .show_ui(ui, |ui| { + ui.selectable_value(&mut st.apply_selection, false, crate::tl!("Entire drawing")); + ui.add_enabled_ui(has_sel, |ui| { + ui.selectable_value(&mut st.apply_selection, true, crate::tl!("Current selection")); + }); }); + ui.end_row(); + ui.label(crate::tl!("Object type:")); + let types: Vec<(String, u64)> = st + .info + .get("types") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|v| Some((v.get("type")?.as_str()?.to_string(), v.get("count").and_then(Value::as_u64).unwrap_or(0)))) + .collect() + }) + .unwrap_or_default(); + egui::ComboBox::from_id_salt("qs_type").width(220.0).selected_text(crate::i18n::t(&st.ty)).show_ui(ui, |ui| { + ui.selectable_value(&mut st.ty, "Multiple".to_string(), crate::tl!("Multiple")); + for (n, c) in &types { + ui.selectable_value(&mut st.ty, n.clone(), format!("{} ({c})", crate::i18n::t(n))); + } }); - ui.end_row(); - ui.label("Object type:"); - let types: Vec<(String, u64)> = st - .info - .get("types") - .and_then(Value::as_array) - .map(|a| { - a.iter() - .filter_map(|v| Some((v.get("type")?.as_str()?.to_string(), v.get("count").and_then(Value::as_u64).unwrap_or(0)))) - .collect() - }) - .unwrap_or_default(); - egui::ComboBox::from_id_salt("qs_type").width(220.0).selected_text(st.ty.clone()).show_ui(ui, |ui| { - ui.selectable_value(&mut st.ty, "Multiple".to_string(), "Multiple"); - for (n, c) in &types { - ui.selectable_value(&mut st.ty, n.clone(), format!("{n} ({c})")); - } - }); - ui.end_row(); - ui.label("Properties:"); - let props: Vec = st - .info - .get("properties") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) - .unwrap_or_default(); - egui::Frame::NONE.fill(t.chrome_dark).corner_radius(3).inner_margin(4).show(ui, |ui| { - egui::ScrollArea::vertical().max_height(150.0).min_scrolled_height(150.0).show(ui, |ui| { - ui.set_width(212.0); - ui.vertical(|ui| { - for p in &props { - if ui.selectable_label(st.property == *p, pretty(p)).clicked() { - st.property = p.clone(); - } - } - }); - }); - }); - ui.end_row(); - ui.label("Operator:"); - let op_label = OPERATORS.iter().find(|o| o.0 == st.operator).map(|o| o.1).unwrap_or("= Equals"); - egui::ComboBox::from_id_salt("qs_op").width(220.0).selected_text(op_label).show_ui(ui, |ui| { - for (o, l) in OPERATORS { - ui.selectable_value(&mut st.operator, o.to_string(), *l); - } - }); - ui.end_row(); - ui.label("Value:"); - let choices: Vec = match st.property.as_str() { - "color" => { - ["ByLayer", "ByBlock", "Red", "Yellow", "Green", "Cyan", "Blue", "Magenta", "White"].iter().map(|s| s.to_string()).collect() - } - "layer" => app.session.doc().map(|d| d.layers.iter().map(|l| l.name.clone()).collect()).unwrap_or_default(), - "linetype" => app.session.doc().map(|d| d.linetypes.iter().map(|l| l.name.clone()).collect()).unwrap_or_default(), - "type" => types.iter().map(|(n, _)| n.clone()).collect(), - _ => Vec::new(), - }; - ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut st.value).desired_width(if choices.is_empty() { 220.0 } else { 190.0 })); - if !choices.is_empty() { - ui.menu_button("▼", |ui| { - egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| { - for c in &choices { - if ui.button(c).clicked() { - st.value = c.clone(); - ui.close(); + ui.end_row(); + ui.label(crate::tl!("Properties:")); + let props: Vec = st + .info + .get("properties") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) + .unwrap_or_default(); + egui::Frame::NONE.fill(t.chrome_dark).corner_radius(3).inner_margin(4).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(150.0).min_scrolled_height(150.0).show(ui, |ui| { + ui.set_width(212.0); + ui.vertical(|ui| { + for p in &props { + if ui.selectable_label(st.property == *p, crate::i18n::t(&pretty(p))).clicked() { + st.property = p.clone(); } } }); }); - } - }); - ui.end_row(); - }); - ui.separator(); - ui.label("How to apply:"); - ui.radio_value(&mut st.exclude, false, "Include in new selection set"); - ui.radio_value(&mut st.exclude, true, "Exclude from new selection set"); - ui.checkbox(&mut st.append, "Append to current selection set"); - ui.separator(); - ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button(" OK ").clicked() { - let mode = if st.exclude { - "exclude" - } else if st.append { - "append" - } else { - "new" - }; - let mut p = json!({ - "applyTo": if st.apply_selection { "selection" } else { "drawing" }, - "mode": mode, - }); - if let Some(o) = p.as_object_mut() { - if st.ty != "Multiple" { - o.insert("type".into(), json!(st.ty)); - } - if !st.property.is_empty() && (!st.value.trim().is_empty() || st.operator == "*") { - o.insert("property".into(), json!(st.property)); - o.insert("operator".into(), json!(st.operator)); - let v = st.value.trim(); - o.insert("value".into(), v.parse::().map(|f| json!(f)).unwrap_or_else(|_| json!(v))); - } + }); + ui.end_row(); + ui.label(crate::tl!("Operator:")); + let op_label = OPERATORS.iter().find(|o| o.0 == st.operator).map(|o| o.1).unwrap_or("= Equals"); + egui::ComboBox::from_id_salt("qs_op").width(220.0).selected_text(crate::i18n::t(op_label)).show_ui(ui, |ui| { + for (o, l) in OPERATORS { + ui.selectable_value(&mut st.operator, o.to_string(), crate::i18n::t(l)); } - run = Some(p); - close = true; - } - if ui.button("Cancel").clicked() { - close = true; - } + }); + ui.end_row(); + ui.label(crate::tl!("Value:")); + let choices: Vec = match st.property.as_str() { + "color" => { + ["ByLayer", "ByBlock", "Red", "Yellow", "Green", "Cyan", "Blue", "Magenta", "White"].iter().map(|s| s.to_string()).collect() + } + "layer" => app.session.doc().map(|d| d.layers.iter().map(|l| l.name.clone()).collect()).unwrap_or_default(), + "linetype" => app.session.doc().map(|d| d.linetypes.iter().map(|l| l.name.clone()).collect()).unwrap_or_default(), + "type" => types.iter().map(|(n, _)| n.clone()).collect(), + _ => Vec::new(), + }; + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut st.value).desired_width(if choices.is_empty() { 220.0 } else { 190.0 })); + if !choices.is_empty() { + ui.menu_button("▼", |ui| { + egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| { + for c in &choices { + if ui + .button(if ["color", "type"].contains(&st.property.as_str()) { crate::i18n::t(c) } else { c.as_str() }) + .clicked() + { + st.value = c.clone(); + ui.close(); + } + } + }); + }); + } + }); + ui.end_row(); + }); + ui.separator(); + ui.label(crate::tl!("How to apply:")); + ui.radio_value(&mut st.exclude, false, crate::tl!("Include in new selection set")); + ui.radio_value(&mut st.exclude, true, crate::tl!("Exclude from new selection set")); + ui.checkbox(&mut st.append, crate::tl!("Append to current selection set")); + ui.separator(); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(crate::tl!(" OK ")).clicked() { + let mode = if st.exclude { + "exclude" + } else if st.append { + "append" + } else { + "new" + }; + let mut p = json!({ + "applyTo": if st.apply_selection { "selection" } else { "drawing" }, + "mode": mode, + }); + if let Some(o) = p.as_object_mut() { + if st.ty != "Multiple" { + o.insert("type".into(), json!(st.ty)); + } + if !st.property.is_empty() && (!st.value.trim().is_empty() || st.operator == "*") { + o.insert("property".into(), json!(st.property)); + o.insert("operator".into(), json!(st.operator)); + let v = st.value.trim(); + o.insert("value".into(), v.parse::().map(|f| json!(f)).unwrap_or_else(|_| json!(v))); + } + } + run = Some(p); + close = true; + } + if ui.button(crate::tl!("Cancel")).clicked() { + close = true; + } + }); }); }); - }); ctx.data_mut(|d| d.insert_temp(sid, st)); if let Some(p) = run { let _ = app.run("qselect", p); @@ -220,7 +229,7 @@ struct QpState { } fn num_row(ui: &mut egui::Ui, label: &str, id: egui::Id, v: f64, set: &mut Option<(String, Value)>, key: &str) { - ui.label(RichText::new(label).color(Tokens::get().text_dim)); + ui.label(RichText::new(crate::i18n::t(label)).color(Tokens::get().text_dim)); let s = format!("{v:.4}"); if let Some(n) = crate::parametric::expr_field(ui, id, &s) && let Some(f) = cadcraft_engine::units::parse_distance(&n).or_else(|| n.parse::().ok()).filter(|f| f.is_finite()) @@ -231,7 +240,7 @@ fn num_row(ui: &mut egui::Ui, label: &str, id: egui::Id, v: f64, set: &mut Optio } fn ro_row(ui: &mut egui::Ui, label: &str, v: String) { - ui.label(RichText::new(label).color(Tokens::get().text_dim)); + ui.label(RichText::new(crate::i18n::t(label)).color(Tokens::get().text_dim)); ui.label(v); ui.end_row(); } @@ -271,9 +280,9 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { let title = if ents.len() == 1 { ty.to_string() } else if same_type { - format!("{ty} ({})", ents.len()) + format!("{} ({})", crate::i18n::t(ty), ents.len()) } else { - format!("All ({})", ents.len()) + format!("{} ({})", crate::tl!("All"), ents.len()) }; let same = |f: &dyn Fn(&cadcraft_doc::Entity) -> String| -> String { let v0 = ents.first().map(|e| f(e)).unwrap_or_default(); @@ -297,13 +306,13 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { .default_width(250.0) .show(ctx, |ui| { egui::Grid::new("qp_grid").num_columns(2).spacing(vec2(10.0, 4.0)).min_col_width(80.0).show(ui, |ui| { - ui.label(RichText::new("Color").color(Tokens::get().text_dim)); + ui.label(RichText::new(crate::tl!("Color")).color(Tokens::get().text_dim)); let cur = color.unwrap_or(cadcraft_color::Color::ByLayer); if let Some(c) = crate::layers::color_button(ui, egui::Id::new(("qp_color", &ids)), cur, true, 150.0) { set = Some(("color".into(), json!(c.name()))); } ui.end_row(); - ui.label(RichText::new("Layer").color(Tokens::get().text_dim)); + ui.label(RichText::new(crate::tl!("Layer")).color(Tokens::get().text_dim)); egui::ComboBox::from_id_salt("qp_layer").width(150.0).selected_text(layer.clone()).show_ui(ui, |ui| { for l in &layers { if ui.selectable_label(*l == layer, l).clicked() { @@ -312,7 +321,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { } }); ui.end_row(); - ui.label(RichText::new("Linetype").color(Tokens::get().text_dim)); + ui.label(RichText::new(crate::tl!("Linetype")).color(Tokens::get().text_dim)); egui::ComboBox::from_id_salt("qp_lt").width(150.0).selected_text(linetype.clone()).show_ui(ui, |ui| { for l in std::iter::once("ByLayer".to_string()) .chain(std::iter::once("ByBlock".to_string())) @@ -359,7 +368,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { num_row(ui, "Global width", id("w"), p.const_width, &mut set, "width"); } Some(EntityKind::Text(tx)) => { - ui.label(RichText::new("Contents").color(Tokens::get().text_dim)); + ui.label(RichText::new(crate::tl!("Contents")).color(Tokens::get().text_dim)); if let Some(v) = crate::parametric::expr_field(ui, id("txt"), &tx.value) { set = Some(("text".into(), json!(v))); } @@ -368,7 +377,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { num_row(ui, "Rotation", id("rot"), tx.rotation.to_degrees(), &mut set, "rotation"); } Some(EntityKind::MText(tx)) => { - ui.label(RichText::new("Contents").color(Tokens::get().text_dim)); + ui.label(RichText::new(crate::tl!("Contents")).color(Tokens::get().text_dim)); if let Some(v) = crate::parametric::expr_field(ui, id("mtxt"), &tx.contents) { set = Some(("text".into(), json!(v))); } @@ -381,7 +390,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { num_row(ui, "Scale", id("sc"), i.scale.x, &mut set, "scale"); } Some(EntityKind::Dimension(dm)) => { - ui.label(RichText::new("Text override").color(Tokens::get().text_dim)); + ui.label(RichText::new(crate::tl!("Text override")).color(Tokens::get().text_dim)); if let Some(v) = crate::parametric::expr_field(ui, id("dt"), &dm.text) { set = Some(("textOverride".into(), json!(v))); } @@ -392,7 +401,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { }); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.small_button("Close").on_hover_text("Turn Quick Properties off (QPMODE)").clicked() { + if ui.small_button(crate::tl!("Close")).on_hover_text(crate::tl!("Turn Quick Properties off (QPMODE)")).clicked() { close = true; } }); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6acc01f2..51926822 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -33,3 +33,16 @@ Errors come back as `{"id": …, "ok": false, "error": "message"}`. The MCP server (`cadcraft-cli mcp --connect 127.0.0.1:PORT`) wraps this protocol; headless (`cadcraft-cli mcp`) it serves the same methods from an in-process session. + +## Interface language + +Select a language with `engine.execute` and the UI command `ui.language`: + +```json +{"id":1,"method":"engine.execute","params":{"command":"ui.language","params":{"lang":"uk"}}} +``` + +Supported values are `auto`, `en` and `uk`. `ui.set` also accepts +`{"interfaceLanguage":"uk"}`; `ui.inspect` reports the saved choice in its UI state. +Invalid choices leave the current language unchanged. Menus, command labels and prompt text +returned by the control channel retain their English source text. See [localization.md](localization.md). diff --git a/docs/localization.md b/docs/localization.md new file mode 100644 index 00000000..395ac056 --- /dev/null +++ b/docs/localization.md @@ -0,0 +1,66 @@ +# Interface languages + +Choose **Edit → Interface Language** and select **Auto (system language)**, **English**, or +**Українська**. The interface updates without restarting, including the native macOS menus. +The choice is saved with the other interface preferences (`cadcraft.prefs`, beside the theme): +eframe storage on the desktop, browser local storage on the web. Installations without a saved +choice use Auto. + +Auto uses the first supported language in the operating system's preferred locales, or the +browser's `navigator.languages` on the web. Ukrainian tags such as `uk-UA` and `uk_UA.UTF-8` +resolve to `uk`; unsupported languages fall back to English. Set `CADCRAFT_LOCALE=uk-UA` to +override the desktop's locale for a launch. An explicit saved English or Ukrainian choice +takes precedence over that override. + +Automation can select the language through the existing control channel: + +```json +{"id":1,"method":"engine.execute","params":{"command":"ui.language","params":{"lang":"uk"}}} +``` + +The `ui.set` method also accepts `{"interfaceLanguage":"uk"}`. Supported values are `auto`, +`en`, and `uk`; invalid values are rejected without changing the current selection. Resetting +palettes keeps the language choice. + +## Translation coverage + +Ukrainian covers command and menu labels, tool groups and tooltips, the Start page, file and +model-space tabs, status controls, object snaps, drafting prompts, property labels, dialogs, +and the About window's controls. The command reference can be searched by English command +ID, English label, or Ukrainian label. + +Drawing titles, layer/layout/block/style names, parameter names and expressions, text in a +drawing, and command-line input are user data and retain their original spelling. Clickable +command keywords retain their English spelling so the text shown matches the input accepted +by the engine. CLI/MCP/control responses, engine diagnostics and command history remain +English. Uncatalogued text also falls back to English. Number formatting and the operating +system's own file-picker controls follow the existing behavior. + +The bundled egui fonts cover Ukrainian, including `Ґґ Єє Іі Її`; the translation adds no font +assets and works on the web without system fonts. + +## Adding translations + +The implementation follows the display-time catalog pattern used by +[VectorCraft #205](https://github.com/storytold/vectorcraft/pull/205), +[PdfCraft #106](https://github.com/storytold/pdfcraft/pull/106), and +[FilmCraft #238](https://github.com/storytold/filmcraft/pull/238). Its code is local to CADCraft. + +- `crates/ui-egui/src/i18n/uk.tsv` is UTF-8 with three tab-separated columns: + `context`, `English source`, and `translation`. Ordinary labels have an empty context. + `@plural` entries hold Ukrainian `one|few|many` forms for integer counts. +- Register a new catalog in `i18n::LANGUAGES`, add its preference variant and code/name, + and supply its integer plural rule if it uses `@plural` rows. +- Use `crate::tl!("English label")` for a literal, `i18n::t(label)` for a known dynamic UI + label, and `crate::tf!("Version {version}", version = value)` for named placeholders. + Translate only at the display boundary; never look up arbitrary user names or identifiers. +- Windows retain explicit IDs based on their English source, and tool groups retain their + source names as keys, so switching languages preserves their state. +- Catalogs are embedded and parsed once. Missing or malformed rows cannot crash the app; + tests reject invalid rows, duplicate keys, changed placeholders, and mismatched ellipses. +- `cargo test -p cadcraft-ui-egui i18n` checks menu/command/tool/snap coverage, every marked + UI literal, locale resolution, preferences, integer plurals, bundled glyphs and an actual + English/Ukrainian/English egui render. `cargo xtask ci` runs the workspace quality gates. + +Translations are original work under `MIT OR Apache-2.0`, written from the meaning of +CADCraft's English strings. Do not copy proprietary localization resources. From 3ea7de193824b732760f88d25ed5f261a8ab2bd7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 14:13:04 -0400 Subject: [PATCH 027/150] Progress docs: target-app parity, gaps, alpha gate, readiness by audience (#205) Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, geometry, hardware, UI and localization parity docs. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- CLAUDE.md | 2 +- README.md | 4 +- ROADMAP.md | 201 +++++++++++++-------- docs/architecture.md | 91 ++++++++++ docs/file-format-parity.md | 131 ++++++++++++++ docs/gaps.md | 163 +++++++++++++++++ docs/geometry-parity.md | 153 ++++++++++++++++ docs/hardware-parity.md | 44 +++++ docs/localization-parity.md | 78 ++++++++ docs/{parity.md => parity-checklist.md} | 6 +- docs/roadmap.md | 108 +++++++++++ docs/target-app-parity.md | 230 ++++++++++++++++++++++++ docs/ui-parity.md | 199 ++++++++++++++++++++ xtask/src/main.rs | 2 +- xtask/src/parity.rs | 41 ++++- 16 files changed, 1369 insertions(+), 86 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/file-format-parity.md create mode 100644 docs/gaps.md create mode 100644 docs/geometry-parity.md create mode 100644 docs/hardware-parity.md create mode 100644 docs/localization-parity.md rename docs/{parity.md => parity-checklist.md} (96%) create mode 100644 docs/roadmap.md create mode 100644 docs/target-app-parity.md create mode 100644 docs/ui-parity.md diff --git a/AGENTS.md b/AGENTS.md index a9893471..c9c3596c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk). ## Roadmap -`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a milestone task lands. `cargo xtask parity` recomputes the command-catalog parity in `docs/parity.md`. +`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`. ## Contributor credits (About window) diff --git a/CLAUDE.md b/CLAUDE.md index a65a20be..1ed44c1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,4 +46,4 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk). ## Roadmap -`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a milestone task lands. `cargo xtask parity` recomputes the command-catalog parity in `docs/parity.md`. +`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`. diff --git a/README.md b/README.md index b98da27e..726f9cf5 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ numbers): | Layers & properties | Layers palette and Layer Properties Manager (on/off, freeze, lock, plot, colour, linetype), layer tools (isolate, freeze, off, lock, match, previous), Properties palette with per-object editing, linetypes, lineweights, colour index and true colour | | Annotation | All DIM* commands with full DIMSTYLE variables, overrides and every arrowhead, associative dimensions that follow geometry, MLEADER, TABLE, TrueType fonts (`.ttf`, `.otf`, `.ttc` collections), MTEXT formatting codes (fonts, heights, colours, stacked fractions), our own single-stroke drafting font | | Hatch & blocks | Pick-point hatch boundaries with islands, pattern, solid and gradient fills from our own pattern library, BLOCK/INSERT, attributes and nested blocks | -| Files | DXF read/write (R12–2018, including dimension styles, associativity, tables and constraints), DWG read/write (R13–2018, via the acadrust library), PDF plotting, SVG and PNG export | +| Files | DXF read (R12–2018, ASCII and binary) and write (R2000), including dimension styles, associativity, tables and constraints; DWG read (via the acadrust library) and write (R2000); PDF plotting, SVG and PNG export. Entities CADCraft doesn't model yet are not kept on save: see [file-format parity](docs/file-format-parity.md) | | Layouts & plotting | Paper-space layouts, viewports (scale, lock, per-viewport layer freeze), MSPACE/PSPACE through viewports, page setups, PLOT and EXPORTPDF | | Parametric | Geometric and dimensional constraints, AUTOCONSTRAIN, PARAMETERS with expressions, conflict detection; constraints re-solve after every edit | | Grips | Hot grips with Space to cycle Stretch, Move, Rotate, Scale and Mirror | @@ -215,7 +215,7 @@ checks formatting, clippy, tests, asset attribution, the crate layering and the ## Roadmap -See [ROADMAP.md](ROADMAP.md) for milestones, measured parity and the estimate of remaining work. +See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of remaining work, and [docs/gaps.md](docs/gaps.md) for everything still missing. ## Downloads diff --git a/ROADMAP.md b/ROADMAP.md index bf055a17..0230a25e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,88 +1,139 @@ # CADCraft roadmap -Status as of **2026-10-07**. CADCraft targets full parity with AutoCAD (2D drafting first, then -annotation, layouts and plotting, DWG, parametrics and 3D), plus things AutoCAD doesn't have: -agent control over MCP, a scriptable CLI, a web build and a free licence. +**Stage: pre-alpha** · next: alpha, ~2 points (38% → 40% ready for real work) and ~125–190 Opus 5.5 hours away (the four failing core workflows of the [alpha gate](docs/roadmap.md#alpha-gate)) -## At a glance +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% for each readiness number) · **Target:** Autodesk AutoCAD 2027 -| Question | Answer | -|---|---| -| **How close is an alpha?** | **≈ 75% of the way.** About **50 Opus 5.5 wall-clock hours** remain (≈ 15–20 hours elapsed with 3–4 agents in parallel). | -| **How far is 100% AutoCAD parity?** | **≈ 29% parity overall** (weighted by how much each area matters). About **570 Opus 5.5 wall-clock hours** remain (≈ 150–190 hours elapsed in parallel). | -| **2D drafting parity** (what most AutoCAD users do every day: M1–M8) | **≈ 55%.** About **245 hours** remain. | -| **The biggest single gap** | 3D modelling (M11): solids, surfaces, meshes, visual styles and rendering — ≈ 200 hours on its own. | +CADCraft targets full parity with AutoCAD (2D drafting first, then annotation, layouts and +plotting, DWG, parametrics and 3D), plus what AutoCAD doesn't have: agent control over MCP, a +scriptable CLI, a web build, Linux and FreeBSD builds, and a free licence. -### What "alpha" means here +**Why pre-alpha:** four of AutoCAD's five core workflows fail the [alpha gate](docs/roadmap.md#alpha-gate). +Precise drafting lacks object snap tracking and has stubbed TRIM/EXTEND/OFFSET options; blocks +can't be edited (no block editor, REFEDIT or xrefs); plots ignore plot styles; and a DWG saved by +CADCraft drops everything it doesn't model, is always R2000 and has never been checked in +AutoCAD. Dimensioning passes. Ready for real work (≈ 38%) is also under the ~40% alpha bar. Beta +is ≈ 600–930 h away. -A person can do real 2D drafting work in CADCraft and trust it with their files: +## Headline numbers -- Installable, signed builds for macOS, Windows, Linux, FreeBSD and the web from the release pipeline. -- Drawings from AutoCAD open correctly, and CADCraft's DXF/DWG files open cleanly in AutoCAD and other readers. -- Every common 2D draw, modify, annotate, layer, block, layout and plot command works, both with the mouse and at the command line. -- No crashes and no lost work: autosave and recovery, plus undo that always works. - -### Alpha checklist - -| Item | State | Hours left | +| Number | Value | Kind | |---|---|---| -| 2D draw and modify commands, snaps, tracking, grips | done (minor options missing) | — | -| Dimensions, text, MTEXT, leaders, tables, TrueType fonts | done | — | -| Hatch with pick points, blocks, attributes | done | — | -| Layouts, viewports, MSPACE/PSPACE, page setup, PLOT to PDF | done | — | -| Parametric constraints, constraint bars and dimensional constraints on the canvas, Parameters Manager | done | — | -| DXF/DWG round trip of everything above | done for our data; MULTILEADER is written as LEADER + MTEXT; `*D` dimension blocks duplicate on re-save | 6 | -| Confirmed opening in AutoCAD (black-box check) | not yet confirmed | 4 | -| Layer Properties Manager (VP overrides, states, filters), QSELECT, Quick Properties | done | — | -| Autosave, crash recovery, RECOVER/AUDIT | partial | 6 | -| Options dialog and preferences | partial | 6 | -| Plot preview | missing | 4 | -| Scripted smoke test of all 290 commands through the UI, then fixing what it finds | not started | 12 | -| Release pipeline: first green signed build on every platform | workflows written; first run being started | 6 | -| Getting-started docs and the web build hosted | partial | 4 | -| **Total** | | **≈ 50** | +| Feature breadth (AutoCAD for Mac menu items with a live command) | **50%** (245 / 491; 2D menus only ≈ 65%) | measured ([parity-checklist.md](docs/parity-checklist.md) + UI-only commands) | +| Ready for real work | **≈ 38%** | estimated ([target-app-parity.md](docs/target-app-parity.md)) | +| Mainstream practitioner (2D drafter) | **≈ 28%** (lower than the full number: file exchange, stability and interaction discounts multiply) | estimated ([target-app-parity.md](docs/target-app-parity.md#mainstream-practitioner)) | +| Essentials user | **≈ 46%** | estimated ([target-app-parity.md](docs/target-app-parity.md#essentials-user)) | +| Remaining to alpha | **≈ 125–190 h** (the gate rows) | estimated | +| Remaining to beta | **≈ 600–930 h** (≈ 150–250 h elapsed with 4–6 agents) | estimated | +| Remaining to full parity | **≈ 1,300–2,100 h** (≈ 330–550 h elapsed) | estimated | -## Where we are +### Readiness by audience -| Measure | Value | +| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | +|---|---:|---|---| +| Full target (ready for real work) | ≈ 38% | ≈ 1,150–1,850 h (≈ 70% parallelises across 4–6 agents: ≈ 300–480 h elapsed) | 3D kernel (300–450 h), DWG/DXF and other formats, AutoLISP and ecosystem, blocks/xrefs/dynamic blocks, localization | +| Mainstream practitioner (2D drafter) | ≈ 28% | ≈ 570–880 h (≈ 70% parallelises: ≈ 150–240 h elapsed) | lossless DWG exchange and a real-file corpus, precision input (tracking, dynamic input), 2D geometry precision, blocks/xrefs, annotation depth, stability backlog | +| Essentials user | ≈ 46% | ≈ 140–225 h (≈ 50% parallelises across 3 agents: ≈ 60–110 h elapsed) | stubbed options in basic draw/modify commands, opening DWGs people send, launch/stability fixes, plot dialog, context menus and dynamic input | + +Hours are subsets (essentials ⊂ mainstream ⊂ full) and use the calibration in [target-app-parity.md](docs/target-app-parity.md#remaining-effort-and-how-it-was-calibrated): ≈ 25 commands +per agent-hour at presence quality in the first build (≈ 11.5 agent-hours), 0.3–0.7 h per option +or bug fix (≈ 60 such fixes on 2026-10-10), 2–5 h per moderate feature, 15–100 h per subsystem. + +Hours are Opus 5.5 agent wall-clock hours, calibrated on this repo's own build: the first version +(51k lines, 290 commands) took ≈ 11.5 agent-hours at presence quality, while depth work runs at +0.3–0.7 h per option or bug fix (≈ 60 such fixes landed on 2026-10-10). Details: +[target-app-parity.md](docs/target-app-parity.md#remaining-effort-and-how-it-was-calibrated). + +## By dimension + +| Dimension | Parity | Remaining (h) | Doc | +|---|---:|---|---| +| Features (depth, weighted by use) | 41% | 735–1,115 | [target-app-parity.md](docs/target-app-parity.md#by-feature-area-the-features-dimension) | +| UI/UX fidelity | 43% | 100–160 | [ui-parity.md](docs/ui-parity.md) | +| File formats | 30% | 170–280 | [file-format-parity.md](docs/file-format-parity.md) | +| Hardware | 45% | 15–25 | [hardware-parity.md](docs/hardware-parity.md) | +| Localization | 5% | 60–100 | [localization-parity.md](docs/localization-parity.md) | +| Performance | 60% | 20–40 | [hardware-parity.md](docs/hardware-parity.md#performance-on-hardware-internal-numbers-only) | +| Stability | 35% | 30–50 | [gaps.md](docs/gaps.md#stability) | +| Platforms | 75% | 15–25 | [gaps.md](docs/gaps.md#platforms) | +| Ecosystem and automation | 10% | 120–200 | [gaps.md](docs/gaps.md#ecosystem-and-automation) | +| AI features | 20% | 40–80 | [gaps.md](docs/gaps.md#ai-features) | + +## Features + +| Area | Parity | Remaining (h) | Doc | +|---|---:|---|---| +| Draw (2D) | 60% | 15–25 | [gaps.md](docs/gaps.md#features) | +| Modify | 50% | 40–60 | [gaps.md](docs/gaps.md#features) | +| Geometry precision | 40% | 70–110 | [geometry-parity.md](docs/geometry-parity.md) | +| Layers and properties | 70% | 10–15 | [gaps.md](docs/gaps.md#features) | +| Dimensions | 45% | 25–35 | [gaps.md](docs/gaps.md#features) | +| Text and MTEXT | 35% | 30–45 | [gaps.md](docs/gaps.md#features) | +| Leaders, tables, fields | 25% | 25–35 | [gaps.md](docs/gaps.md#features) | +| Hatch and gradients | 35% | 15–25 | [gaps.md](docs/gaps.md#features) | +| Blocks, attributes, xrefs, groups | 25% | 80–120 | [gaps.md](docs/gaps.md#features) | +| Layouts, viewports, plotting | 35% | 35–50 | [gaps.md](docs/gaps.md#features) | +| Inquiry and utilities | 30% | 30–45 | [gaps.md](docs/gaps.md#features) | +| Parametric constraints | 50% | 20–30 | [geometry-parity.md](docs/geometry-parity.md) | +| 3D modelling and rendering | 2% | 300–450 | [gaps.md](docs/gaps.md#features) | +| Collaboration and cloud | 5% | 40–70 | [gaps.md](docs/gaps.md#features) | + +## Languages + +AutoCAD 2027 for Mac ships 8 interface languages; CADCraft ships English only. Detail: +[localization-parity.md](docs/localization-parity.md). + +| Language | Status | UI translated | +|---|---|---:| +| English | full | 100% | +| Simplified Chinese | none | 0% | +| Spanish | none | 0% | +| Hindi | none | 0% | +| Arabic | none | 0% | +| French | none | 0% | +| Portuguese | none | 0% | +| Indonesian | none | 0% | +| Japanese | none | 0% | +| German | none | 0% | +| Korean | none | 0% | +| Vietnamese | none | 0% | + +Other languages shipped: Ukrainian, partial (936 catalog rows, landed with #36; engine messages stay English). + +## Upcoming + +Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md). + +1. Gate and land: fix fmt on main, run `cargo xtask ci` on every PR, land the 47 open PRs (15–25 h). +2. Stop losing data on save; native MULTILEADER; save DWG/DXF at a chosen version (30–45 h). +3. Real-file DWG/DXF corpus and a black-box check in AutoCAD (10–15 h + owner). +4. Precision input: object snap tracking, extension/parallel snaps, FROM/M2P/TK, snap overrides, + editable dynamic input (25–35 h). +5. Unstub prompt options in TRIM/EXTEND, OFFSET, ROTATE, PLINE, SPLINE, MTEXT, MLEADER (15–25 h). +6. Autosave, recovery, AUDIT, RECOVER (10–15 h). +7. Exact ellipse/spline geometry and correct polyline offsets (30–50 h). +8. Xrefs, block editor, groups, then dynamic blocks (80–120 h). + +## Progress log + +| Date | Entry | |---|---| -| Menu breadth (`cargo xtask parity`, [docs/parity.md](docs/parity.md)) | **233 / 491 reference menu items (47%)** | -| Registered commands | 290 (133 with interactive prompts) | -| **Estimated overall feature parity (weighted by how much each area matters)** | **≈ 29%** | -| Performance | 200k entities: pick 0.001 ms, pick after an edit ≈ 4 ms (incremental R-tree), GPU canvas ≈ 4 ms per frame | -| Tests | geometry, colour, document, fonts, render, DXF/DWG round trips, constraints solver, engine (commands, prompts, snaps, selection, undo, scripts, hostile input), MCP agent tasks, xtask | -| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm — green | +| 2026-10-10 | Merged main: Ukrainian interface (#36, first string catalog), Dynamic Input pointer boxes with Tab and relative entry (#60), press-drag selection windows (#63), WIPEOUT and hatch gradients kept through DXF/DWG save (#147, #162), SETVAR DIM* as style overrides (#129), UNITS dialog (#38), close-window save prompt (#47), DIMANGULAR on circles (#20), cargo fmt fixed on main. UI/UX 40% → 43% (dynamic input 15% → 45%), localization 3% → 5%; ready for real work ≈ 37% → 38% (weighted 37.2% → 37.7%), mainstream 27% → 28%, essentials 45% → 46%. Beta is now ~37 points away; hours unchanged within rounding. | +| 2026-10-10 | Added mainstream practitioner (≈ 27%) and essentials user (≈ 45%) numbers; ready for real work unchanged at ≈ 37%. User evidence: 2 praise comments, 0 "switched from AutoCAD" reports, 27 of 37 open issues on the core path. | +| 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the craftrules core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. | +| 2026-10-10 | Full re-measure against AutoCAD 2027 for Mac in the craftrules progress-docs format: menu breadth 245/491 (50%), ready for real work ≈ 37%, stage alpha. Remaining estimates rose (≈ 570 h → 1,300–2,100 h) on new evidence: behaviour audits found tracking/extension/parallel snaps and dynamic input are not real, saves drop unmodelled content, DWG saves as R2000, and the old estimate had no localization, ecosystem, stability, platform or AI rows. New docs: target-app-parity, gaps, roadmap, architecture, localization, file-format, hardware, UI and geometry parity; `docs/parity.md` became `docs/parity-checklist.md`. | +| 2026-10-10 | About 60 community PRs merged in a day: DXF fidelity (transparency, LWPOLYLINE elevation, frozen VP layers, dimension text rotation, arc-length dims, non-ASCII escapes), CJK font fallback, Save/Don't Save on close, Light/System themes, F2 history, trackpad pan, Linux XWayland drag-and-drop, correctness fixes across draw/modify/constraints. Release v0.4.0. | +| 2026-10-08 | Releases v0.1.0–v0.3.0: Flatpak, AppImage updates, signed macOS universal, Windows x64/x86/arm64, Linux, FreeBSD and web builds. | +| 2026-10-07 | Old assessment: menu breadth 233/491 (47%), ≈ 29% weighted parity, 2D drafting ≈ 55%, ≈ 570 h remaining; milestones as reported then: M1 ~85%, M2 ~65%, M3 ~75%, M4 ~50%, M5 ~35%, M6 ~55%, M7 ~55%, M8 ~20%, M9 ~60%, M10 ~70%, M11 0%, M12 ~25%, M13 ~10%. | +| 2026-10-07 | First version built in one session (≈ 6.9 h elapsed, ≈ 11.5 agent-hours): M0 vertical slice, dimensions, hatch, blocks, DWG bridge, layouts and PDF plotting, GPU canvas, R-tree, constraints, Layer Properties Manager, QSELECT, release pipeline. | -Menu breadth counts menu items that exist; the weighted parity estimate also counts depth (options, -edge cases, dialogs), which is why it is lower. +## Revision history -## Milestones - -| # | Milestone | Status | Estimate (Opus 5.5 wall-clock hours) | -|---|---|---|---| -| M0 | Skeleton + vertical slice: workspace, AutoCAD-style UI, command line, draw/modify basics, DXF, CLI, MCP, web | **done** | — | -| M1 | Drafting core: every Draw-menu 2D command and option, dynamic input fields, object snap tracking, temporary snap overrides | ~85% (all 2D Draw items exist) | 8 | -| M2 | Modify: grips (stretch/move/rotate/scale/mirror, multifunctional), PEDIT, SPLINEDIT, LENGTHEN, BLEND, ALIGN, associative arrays, trim/extend for all curve types, MATCHPROP UI, Quick Properties | ~65% (PEDIT, LENGTHEN, ALIGN, BLEND, grips API, TRIM on splines) | 18 | -| M3 | Layers & properties: full Layer Properties Manager (filters, VP overrides, states), linetype manager, lineweight display, transparency, QSELECT dialog, Properties for every object type | ~75% (Layer Properties Manager with VP overrides/states/filters, QSELECT, Quick Properties) | 10 | -| M4 | Annotation: DIM* commands, DIMSTYLE manager, associative dimensions, MLEADER + styles, in-place MTEXT editor, fields, tables + table styles, annotative scaling, TrueType fonts, SHX reader | ~50% (TrueType text incl. `.ttc`, per-character font fallback and FONTALT for unread SHX fonts, MTEXT formatting codes, full DIMSTYLE variables + overrides, all arrowheads, associative dimensions, TABLE editing; DXF round trip; not yet: native MULTILEADER objects, jogged/ordinate polish, tolerance frames, fields, annotative scaling, SHX) | 30 | -| M5 | Hatch & blocks: pick-point boundary detection, islands, gradients, BLOCK/WBLOCK/INSERT dialogs, attributes (ATTDEF/ATTEDIT/BATTMAN), block editor, dynamic block parameters, xrefs, groups, Blocks palette | ~35% (pick-point boundaries with islands, gradients, BLOCK/INSERT, attributes, nested blocks) | 70 | -| M6 | Layouts & plotting: paper space, viewports (rect/polygonal/object, scale, lock, per-VP layers), page setups, PLOT to PDF/PNG/SVG, plot styles (CTB/STB), PUBLISH | ~55% (layouts, viewports, MSPACE/PSPACE through viewports, page setup, PLOT/EXPORTPDF) | 25 | -| M7 | Files: DWG read/write (done via acadrust), DXF fidelity (all object types, round-trip of unknown data), RECOVER/AUDIT, PURGE, templates, autosave, ETRANSMIT, PDF/raster underlays | ~55% (DXF round trip of dimension styles, associativity, tables and constraints) | 55 | -| M8 | Inquiry & utilities: MEASUREGEOM, MASSPROP, QuickCalc, Find/Replace, spell check, COUNT, DWG Compare, Settings/OPTIONS, CUI-style customisation, alias editor | ~20% | 30 | -| M9 | Parametric: geometric + dimensional constraints, AutoConstrain, Parameters Manager (constraint solver, see plan/adr/0001) | ~60% (own solver crate, all GC*/DC* commands, AUTOCONSTRAIN, PARAMETERS, conflict detection, re-solve after edits, DXF persistence; not yet: inference while drawing, Smooth as true G2) | 12 | -| M10 | Performance: GPU canvas (wgpu batches), R-tree spatial index, incremental regen, 1M-entity drawings at 60 fps | ~70% (GPU canvas, incremental R-tree: pick 50 ms → 0.001 ms at 200k entities, ≈ 4 ms after an edit) | 12 | -| M11 | 3D: UCS, orbit, visual styles, solids (box…loft, booleans, fillet edges), meshes, surfaces, sections, rendering | 0% | 200 | -| M12 | Automation: an embedded safe AutoLISP-compatible interpreter, action recorder, sheet sets, CLI/MCP parity tests | ~25% | 60 | -| M13 | 1.0 polish: preferences, workspaces, themes, localisation, accessibility, signed packages for every platform, docs | ~10% (English/Ukrainian interface catalogs, live language switching and persisted system-language choice) | 40 | -| | **Remaining total** | | **≈ 570 hours** | - -At roughly 570 more hours of Opus 5.5 wall-clock work (with parallel agents this compresses to -about 150–190 hours of elapsed time), CADCraft would reach broad AutoCAD parity. 2D drafting parity -(M1–M8) is the first ≈ 250 hours. - -## Current focus - -1. Finish the alpha checklist above (release builds, AutoCAD open check, smoke test of every command, autosave/recovery, plot preview). -2. Layer filter groups, constraint bar polish (close buttons, hover highlight). -3. Native MULTILEADER objects in DXF; stop `*D` block duplication on re-save. -4. Then 3D foundations (UCS, orbit, solids via truck/csgrs per plan/adr/0001). +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Merged main's changes (Ukrainian catalog, dynamic input pointer boxes, DXF fixes); UI/UX and localization numbers updated | +| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for each number | +| 2026-10-10 | minor | Mainstream practitioner and essentials user headline numbers | +| 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate; banner, why, headline row | +| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; milestone detail moved to docs/roadmap.md, parity assessment to docs/target-app-parity.md | +| 2026-10-07 | major | Alpha checklist, milestone table, ≈ 29% weighted parity estimate | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..0b60cbe1 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,91 @@ +# CADCraft architecture + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first committed architecture doc, written from the code on main at bd2412f) · **Target:** Autodesk AutoCAD 2027 + +How CADCraft is built today. This describes the code on `main`, not plans; plans live in +[roadmap.md](roadmap.md). Agents: the rules that keep this shape (layering, never-crash, +everything-is-a-command, clean-room) are in [AGENTS.md](../AGENTS.md). + +## Workspace + +About 57,000 lines of Rust in 12 library crates, 3 apps and `xtask` (counted 2026-10-10). + +| Layer | Crate | Lines | What it does | +|---|---|---:|---| +| L0 | `geom` | 2,067 | f64 2D/3D vectors, `Mat3`/`Mat4`, bounds, lines, arcs and bulges, circles, ellipses, (rational) B-splines with fit-point interpolation, line/arc intersections, tessellation, planar region tracing for HATCH/BOUNDARY (`region.rs`) | +| L0 | `dxf` | 571 | Standalone DXF tag reader/writer: ASCII and binary, tag-count limits for hostile input | +| L1 | `color` | 265 | AutoCAD Color Index table, true colour, ByLayer/ByBlock | +| L1 | `doc` | 3,054 | The drawing database: header, symbol tables (layers, linetypes, text/dimension/table/multileader styles), blocks, model space, layouts, constraints; copy-on-write `EntityStore`; our own hatch pattern and linetype library (`library.rs`) | +| L2 | `fonts` | 2,062 | Our single-stroke drafting font, TrueType/OpenType/`.ttc` outlines through `skrifa`, TEXT and MTEXT layout and formatting codes, per-character fallback and FONTALT | +| L2 | `render` | 3,300 | Display lists (polylines, triangles, points tagged with entity handles): linetypes, hatches, gradients, text, block expansion, dimension geometry, paper-space clipping; CPU rasteriser (`tiny-skia`) for PNG, plot preview and tests | +| L2 | `constraints` | 2,504 | Own parametric solver: damped Gauss–Newton / Levenberg–Marquardt (`lm.rs`), 12 geometric and the dimensional constraints, expressions and parameters, AutoConstrain inference | +| L3 | `io` | 5,124 | DXF ⇄ document mapping (`dxf_read.rs`, `dxf_write.rs`, CADCraft xdata in `dxf_ext.rs`), PDF plotting, SVG and PNG export | +| L3 | `dwg` | 149 | DWG through a DXF bridge: the `acadrust` crate (MPL-2.0, unmodified) converts DWG ⇄ DXF bytes; nothing else sees its types. Native only | +| L4 | `engine` | 25,090 | `Session`, the command registry (`cmd/*`), command-line parser and prompt machines, object snaps and polar/ortho (`snap.rs`), selection (`select.rs`), grips (`grips.rs`), associativity (`assoc.rs`), system variables, units, undo, R-tree spatial index (`spatial.rs`) | +| L5 | `ui-egui` | 9,849 | The swappable egui front end: GPU canvas (`gpu.rs`, wgpu), menus and shortcuts (`menus.rs`), command line, palettes, Layer Properties Manager and other dialogs, ViewCube, icons drawn in code, themes, the interface string catalog (`i18n/`, English and Ukrainian), JSON control channel (`control.rs`) | +| L5 | `mcp` | 559 | MCP server (JSON-RPC 2.0 over stdio) on a headless session or bridged to a running app | +| app | `cadcraft` | 1,102 | Desktop app (eframe/wgpu; DirectX 12 by default on Windows) | +| app | `cadcraft-cli` | 823 | `info`, `convert`, `run` (scripts), `commands`, `sample`, `mcp` | +| app | `cadcraft-web` | 88 | WASM build of the same UI (no DWG) | +| tool | `xtask` | 1,064 | `ci`, `layers`, `assets`, `wasm`, `parity`, `version`, `stats`, icon generation | + +`cargo xtask layers` enforces the layering: nothing below L5 depends on egui, eframe, winit, +wgpu or rfd. + +## Data model + +- A `Drawing` is header variables, symbol tables, block definitions, model space and paper-space + layouts. Entities carry a handle, layer, colour, linetype, lineweight, transparency and an + `EntityKind`: Line, Point, Circle, Arc, Ellipse, LwPolyline, Polyline3d, Spline, Ray, XLine, + Text, MText, AttDef, Insert (with attributes), Dimension (linear, aligned, angular, diameter, + radius, ordinate, arc length), Leader, MLeader, Hatch (pattern, solid, gradient), Solid, + Trace, Face3d, Viewport, Image, Wipeout, Table and Unknown (raw DXF tags). +- There are no 3D solids, surfaces, meshes, regions (ACIS), multilines, tolerance frames or + underlays in the model yet ([gaps.md](gaps.md)). +- Entity collections are copy-on-write, so undo is a whole-drawing snapshot that costs little. +- Coordinates are f64 world units. 2D work happens in the WCS; there is no UCS yet. + +## Commands + +Every user-visible action is a `CommandSpec` (`crates/engine/src/cmd/mod.rs`): id = AutoCAD's +command name in lower case, label, menu path, shortcut, aliases, a JSON `run`, an `enabled` +check and an optional interactive prompt machine. 295 engine commands are registered (128 with +interactive prompts, counted from source 2026-10-10), plus about 30 UI-only commands in +`crates/ui-egui/src/menus.rs`. The command line, menus, toolbar, Tool Sets, scripts, CLI, control +channel and MCP all reach the same commands. JSON calls never open dialogs. + +## Rendering + +`render::build` turns a space into a display list once per change; the UI uploads it to wgpu in +batches (`gpu.rs`). Tessellation tolerance follows the view. The R-tree (`spatial.rs`) is patched +per changed store chunk after an edit, so picking stays near 0.001 ms at 200k entities. PNG, PDF, +SVG and plot preview reuse the same display list through the CPU rasteriser or vector writers. + +## Files + +DXF is the native path: `dxf` parses tags, `io::dxf_read` maps them to the document, and +`io::dxf_write` writes an R2000 (AC1015) DXF. DWG is converted to DXF in memory by `acadrust` and +read through the same path; saving DWG writes our DXF and converts it back. CADCraft-only data +(constraints, associativity details, table flags) travels in xdata under our own application +name. Detail and gaps: [file-format-parity.md](file-format-parity.md). + +## Agent control + +- **Control channel:** `cadcraft --control PORT` accepts JSON lines (`cmdline.input`, + `engine.execute`, `ui.screenshot`, `ui.render` …); see [control-protocol.md](control-protocol.md). +- **MCP:** `cadcraft-cli mcp` (headless) or `--connect HOST:PORT` (drive the running app); see + [mcp.md](mcp.md). +- **CLI:** `cadcraft-cli run --script … --export out.png` for headless batch work. + +## Safety + +Production crates deny `unwrap`/`expect`/`panic!` and forbid `unsafe`. `Session::execute` and +interactive input run under `catch_unwind`, restoring the drawing if a panic escapes. Input-sized +allocations are capped (DXF tag counts, DWG size limits, hatch segment and island limits, +`MAX_BLOCK_DEPTH`). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First committed architecture doc, measured from the code (crate sizes, entity kinds, command counts) | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md new file mode 100644 index 00000000..5b266096 --- /dev/null +++ b/docs/file-format-parity.md @@ -0,0 +1,131 @@ +# File-format parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (WIPEOUT and hatch gradients now written, #147, #162) · **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 +[target-app-parity.md](target-app-parity.md). + +**Headline (estimated):** file formats ≈ **30%** of AutoCAD's. Opening the commonest 2D DXF/DWG +drawings works, but saving is lossy: everything CADCraft doesn't model is dropped, output is +always R2000, and no test uses a real third-party file. Remaining: **≈ 170–280 Opus 5.5 hours**: +≈ 40–60 h for DXF/DWG fidelity (the part that decides beta), ≈ 110–180 h for the other formats +below, and ≈ 20–40 h for 3D exchange formats that wait for 3D modelling. + +## How this was measured + +- CADCraft: read from `crates/dxf`, `crates/io` (`dxf_read.rs`, `dxf_write.rs`, `pdf.rs`, + `svg.rs`, `tests.rs`) and `crates/dwg` on 2026-10-10. Nothing was run (the measurement pass + did no builds). +- AutoCAD: the formats in its Open/Save As/Import/Export/Attach/Plot dialogs, from Autodesk's + published documentation and the menu tree in `xtask/data/menu-catalog.txt`. The installed + AutoCAD 2027 for Mac bundle was inspected by listing names only (clean-room rule: nothing + inside it is read): `Resources/Support` lists `acad.lin`, `acadiso.lin`, `acad.pat`, + `acadiso.pat`, `acad.mln`, `.dct` spell dictionaries, `.pss` and `.shx` files, which confirms the + supporting formats below exist in the target. None of them may be copied. +- No AutoCAD-produced file is committed or used as a fixture. A real-file corpus needs owner + action (see [gaps.md](gaps.md)): files made by people who license them to us, kept in a + separate `storytold/cadcraft-corpus` repo. + +## DWG, version by version + +AutoCAD 2027 opens every DWG version from R2.0 onward and saves 2018 (AC1032, still the current +DWG format), 2013, 2010, 2007, 2004, 2000 and R14. CADCraft reads DWG by converting it to DXF +with the `acadrust` 0.6.1 crate and reading that DXF; it saves by writing its own R2000 DXF and +converting it to DWG. + +| DWG version | Code | AutoCAD 2027 | CADCraft read | CADCraft write | Tested how | +|---|---|---|---|---|---| +| R1.0–R2.6, R9, R10, R11/R12 | MC0.0–AC1009 | read | **no** (`is_dwg` accepts only `AC10xx` magic; acadrust doesn't target pre-R13) | no | — | +| R13 | AC1012 | read | acadrust claims support; untested | no | none | +| R14 | AC1014 | read, write | acadrust claims support; untested | **no** | none | +| 2000–2002 | AC1015 | read, write | yes via bridge | **yes, always this version** | synthetic round trips (`dwg_roundtrip_keeps_non_ascii_text`, `dwg_roundtrip_keeps_extension_data`) | +| 2004–2006 | AC1018 | read, write | via bridge; untested | no (version list in `crates/dwg` is unused) | none | +| 2007–2009 | AC1021 | read, write | via bridge; untested | no | none | +| 2010–2012 | AC1024 | read, write | via bridge | no | one synthetic file (`ac1024_dwg_with_many_entities_opens`, made by rewriting `$ACADVER`) | +| 2013–2017 | AC1027 | read, write | via bridge; untested | no | none | +| 2018–2027 | AC1032 | read, write (default) | via bridge; untested on real files | no | none | +| Web build | — | — | **no** (error: "save as DXF") | no | — | + +Everything lost in the DXF read/write paths below is also lost through DWG, plus whatever +`acadrust`'s own DXF output omits (proxy objects, ACIS data, custom objects: not measured). User +report #57: a 10 MB DWG failed after about two minutes; limits now stop runaway conversions +early (#175), but the throughput problem remains. **DWG ≈ 30% read, ≈ 15% write.** + +## DXF, version by version + +| DXF version | AutoCAD 2027 | CADCraft read | CADCraft write | +|---|---|---|---| +| R12 (AC1009), ASCII | read, write | yes: no version gating; old-style POLYLINE/VERTEX read (polyface meshes flattened to 3D polylines) | **no** | +| R12 binary | read, write | **no** (binary parser expects R13+ 2-byte group codes) | no | +| R13–R2018, ASCII | read, write each of 2000/2004/2007/2010/2013/2018 | yes, one reader for all versions | **R2000 (AC1015) only** | +| R13–R2018, binary | read, write | yes | **no** (ASCII only) | +| Code pages (`$DWGCODEPAGE`, ANSI_932/936/949/950/125x) | yes | **no**: UTF-8, else Latin-1, so pre-2007 CJK and Cyrillic files are garbled | writes `\U+` escapes (#169), which is correct | +| `\U+`/`\M+` escapes | yes | `\U+` yes; `\M+` not checked | `\U+` yes | + +### DXF content: read and write by object type + +| Content | Read | Write | Notes | +|---|---|---|---| +| 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 | yes | ATTDEF prompt kept (#137) | +| DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks | `*D` blocks duplicate on re-save (known); DIMASSOC objects written | +| LEADER | yes | yes | | +| MULTILEADER | **no** (becomes Unknown) | **no**: our MLeaders are written as LEADER + MTEXT | MULTILEADER is AutoCAD's default leader since 2008 | +| HATCH pattern / solid | yes | yes | | +| HATCH gradient | yes | yes (#162, fixes #80) | | +| SOLID, TRACE, 3DFACE | yes | yes | | +| ACAD_TABLE | yes | yes (entity + block) | | +| VIEWPORT | yes | yes; frozen-layer lists kept (#150) | VP layer colour overrides lost (#99, PR #197) | +| IMAGE | partial (no IMAGEDEF, so the file path is empty) | **dropped** | | +| WIPEOUT | yes | yes (#147, fixes #79) | | +| MLINE, REGION, 3DSOLID, BODY, SURFACE, MESH, TOLERANCE, SHAPE, HELIX, UNDERLAY, OLE2FRAME, LIGHT, CAMERA, SECTION, POINTCLOUD, proxy entities | kept as Unknown (raw tags) | **dropped on save** | the most serious file gap: opening and saving someone's drawing deletes what we don't model | +| Header variables | all read | 36 written | AutoCAD writes about 280 | +| Tables: LAYER (+ transparency, description), LTYPE, STYLE, DIMSTYLE, BLOCK_RECORD | yes | yes | | +| Tables: VPORT, VIEW, UCS, APPID | **not read** | VPORT one record; VIEW and UCS empty; APPID yes | named views and UCSs lost | +| Objects: LAYOUT, DICTIONARY, XRECORD, TABLESTYLE, DIMASSOC | yes | yes | | +| Objects: GROUP, MLINESTYLE, MLEADERSTYLE, IMAGEDEF, SCALE, VISUALSTYLE, PLOTSETTINGS, materials, field objects | ignored | not written (ACAD_GROUP empty) | groups lost | +| Foreign xdata, extension dictionaries, reactors | **not kept** | — | third-party application data lost | +| Handles and owners | regenerated | consistent | handles change on every save | + +**DXF ≈ 50% read, ≈ 30% write** (estimated; weights: common 2D entities 50%, annotation objects +20%, tables/objects 15%, foreign data preservation 15%). + +## Other formats + +| Format | AutoCAD 2027 | CADCraft | Hours to parity (est.) | +|---|---|---|---| +| DWT templates | open, save | no (no template support) | 3–5 | +| DWS standards | open, save, CHECKSTANDARDS | no | 6–10 | +| PDF output (PLOT, EXPORTPDF) | multi-sheet, layers, searchable text, hyperlinks | vector PDF 1.4, one page, lineweights; **no font objects** (text is strokes), no layers (OCG), no multi-sheet | 10–15 | +| PDF import (PDFIMPORT) and PDF underlays (PDFATTACH) | yes | no | 15–25 | +| Raster images attach (PNG, JPEG, TIFF, BMP …) | yes | IMAGE entity exists, no attach/loading/display path | 6–10 | +| PNG / JPEG / BMP / TIFF export | PNGOUT, JPGOUT, BMPOUT, TIFOUT | PNG; SVG too (AutoCAD doesn't export SVG) | 1–2 | +| DWF/DWFx export, DWF underlay | yes | no | 10–15 | +| DGN import/export/underlay | yes | no (needs a public spec; owner decision) | 25–40 | +| WMF, EPS, ACIS SAT, STL, FBX, IGES/STEP (Windows), 3DS | various | no; most are 3D and wait for M11 | 20–40 | +| SHX fonts (`.shx`, big fonts) | read | **not parsed**: our stroke font or FONTALT stands in, so text metrics differ from AutoCAD | 8–12 | +| Linetype (`.lin`) and hatch pattern (`.pat`) files | load user files | own built-in library only; user files can't be loaded | 3–5 | +| Plot styles (`.ctb`, `.stb`), plotter configs (`.pc3`), `.pmp` | yes | no | 8–12 | +| Scripts (`.scr`) | SCRIPT | script text through the CLI, MCP and control channel; no SCRIPT command reading a `.scr` file | 1–2 | +| Aliases (`.pgp`), CUI(x), workspaces | yes | no | 6–10 | +| AutoLISP (`.lsp`, `.fas`, `.vlx`), `.dll`/`.arx` plug-ins | yes | no | see [gaps.md](gaps.md) (M12) | +| Data extraction (`.csv`, `.xls`) and data links | yes | no | 6–10 | +| eTransmit (`.zip` packages) | yes | no | 4–6 | + +## Tests + +`crates/io/src/tests.rs` has 36 tests (round trips of entities, styles, associativity, tables, +constraints, non-ASCII text, DWG round trips, hostile input). **All inputs are synthetic**: +inline strings or our own writer's output. No test opens a file made by another program, and no +external validator runs in CI (ezdxf's audit was run by hand once, 2026-10-06). The next step is +a licensed real-file corpus with an open → save → re-open → compare test per file, and checking +our output in AutoCAD black-box. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | WIPEOUT and gradients written since #147/#162; other save losses remain (#201, #204, #208, #209, #213, #223) | +| 2026-10-10 | major | First file-format parity doc: DWG and DXF version by version, per-object read/write, other formats | diff --git a/docs/gaps.md b/docs/gaps.md new file mode 100644 index 00000000..c65b0c68 --- /dev/null +++ b/docs/gaps.md @@ -0,0 +1,163 @@ +# Where CADCraft falls short of AutoCAD + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (merged main: rows closed or narrowed by #36, #38, #54, #60, #63, #129, #147, #162 and the fmt fix) · **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 +that closes it. Numbers and weights live in [target-app-parity.md](target-app-parity.md); hours +are Opus 5.5 agent-hours, calibrated as explained there. "Est." means a judgement from the cited +evidence. Rows marked **[alpha blocker]** are what fails the [alpha gate](roadmap.md#alpha-gate); +do them first. + +## Top 10 across everything + +| # | Gap | Why it ranks here | Est. | Doc | +|---|---|---|---|---| +| 1 | **[alpha blocker]** **Saving drops whatever we don't model** (Unknown entities, IMAGE, layer states, block descriptions, true colours, MULTILEADER written as LEADER+MTEXT, groups, named views/UCS, foreign xdata) | Opening a colleague's DWG and saving it silently deletes content: the one thing a CAD user can't forgive | 15–25 | [file formats](file-format-parity.md) | +| 2 | **[alpha blocker]** **No real-file test corpus and no check in AutoCAD** | All 36 file tests are synthetic; nobody has verified a CADCraft DWG/DXF opens cleanly in AutoCAD | 10–15 + human | [file formats](file-format-parity.md#tests) | +| 3 | **[alpha blocker]** **Object snap tracking, extension, parallel, apparent-intersection snaps are fake** | Drafters place most points with tracking; the F11 button does nothing | 8–12 | [ui](ui-parity.md#tracking-polar-ortho-grid-and-snap--30) | +| 4 | **[alpha blocker]** **DWG always saves as R2000; DXF only R2000 ASCII; no code pages** | Can't exchange at 2018 format; pre-2007 CJK/Cyrillic files are garbled | 10–15 | [file formats](file-format-parity.md) | +| 5 | **[alpha blocker]** **Stubbed prompt options** in TRIM/EXTEND, OFFSET, ROTATE, PLINE, SPLINE, MTEXT, MLEADER, FILLET | Scripts and muscle memory hit "not available yet" in the most-used commands | 15–25 | [features](#features) | +| 6 | **Ellipse and spline geometry is tessellated** (intersections, offsets, closest points; polyline offset ignores bulges) | Coordinates are subtly wrong; offsets of ellipses come out as polylines | 30–50 | [geometry](geometry-parity.md) | +| 7 | **[alpha blocker]** **No xrefs, block editor, dynamic blocks or groups** | Most production drawings use them | 80–120 | [features](#features) | +| 8 | **No autosave, crash recovery, AUDIT or RECOVER** | Work is lost if anything goes wrong; damaged files can't be repaired | 10–15 | [features](#features) | +| 9 | **Dynamic input lacks grip input; no FROM/M2P/TK/point filters; grip options ignored** | Precision entry at the cursor is how AutoCAD is taught today | 15–25 | [ui](ui-parity.md) | +| 10 | **No CI runs the Rust tests on pull requests** (`cargo fmt` on main is fixed) | open PRs land without a gate; regressions go unseen | 2–3 | [stability](#stability) | + +## Features + +| # | Gap | Evidence | User impact | Est. | +|---|---|---|---|---| +| F1 | **[alpha blocker]** Prompt options print "not available yet": TRIM/EXTEND cuTting edges, Fence, Crossing, mOde, Project, eRase; OFFSET Erase, Layer, Multiple, Undo; ROTATE Reference; PLINE Angle, CEnter, Direction, Radius, Second pt; SPLINE Method, Knots, Object, Tangency, toLerance; MTEXT Height/Justify/Line spacing/Rotation/Style/Width/Columns; MLEADER options | `cmd/modify.rs:1537,1697-1730,1807-1843`, `cmd/draw.rs:542,623,1106-1128,1297-1320`, `modify2.rs:594,786,809` | High: the commands people use most | 15–25 | +| F2 | **[alpha blocker]** No xrefs (XATTACH, XREF palette, REFEDIT, BIND, XCLIP, overlay, paths); DXF xref blocks read with an empty path | `dxf_read.rs:923` | High: multi-file projects | 30–45 | +| F3 | **[alpha blocker]** (block editor and REFEDIT; dynamic-block authoring is beta) No block editor (BEDIT) or dynamic blocks (parameters, actions, visibility, lookup); dynamic blocks from other files lose their behaviour | issue #185 offers help | High: standard libraries are dynamic | 50–75 | +| F4 | No autosave (SAVETIME), crash recovery files, AUDIT, RECOVER, drawing recovery manager | no SAVETIME anywhere | High: lost work | 10–15 | +| F5 | Arrays are not associative: no ARRAYEDIT; the interactive rectangular array always makes 3×4 at 1.5× bounds | `modify.rs:1356-1360` | Medium-high | 10–15 | +| F6 | FILLET/CHAMFER/JOIN/LENGTHEN/EXTEND don't handle ellipses and splines; FILLET Trim/Multiple and polyline segment pairs; CHAMFER Angle/Method | [geometry-parity](geometry-parity.md#trim-extend-fillet-chamfer-break-join-lengthen--45) | Medium | 10–15 | +| F7 | Dimensions: no DIMSTYLE manager dialog; 43 of ~80 DIMSTYLE variables (DIMTOFL, DIMATFIT, DIMTMOVE, DIMLTYPE, DIMLWD, DIMFXL, DIMTOLJ, DIMALTU …); no smart DIM; no DIMJOGGED, DIMJOGLINE, DIMBREAK, oblique DIMEDIT, inspection; QDIM continuous only (SETVAR DIM* now become style overrides, #129) | `doc/src/tables.rs:223-266`, `annotate.rs:64,364` | High for detailers | 25–35 | +| F8 | No TOLERANCE (GD&T feature control frames) | menu "Dimension > Tolerance..." uncovered | Medium (mechanical) | 4–6 | +| F9 | MTEXT editor is a dialog box, not in place; no columns, bullets, numbering, tabs, indents; no fields (FIELD, UPDATEFIELD); no spell check (SPELL) | `ui-egui/dialogs.rs:188-240` | Medium-high | 30–45 | +| F10 | MLEADER: options ignored, no block content, no MLEADEREDIT/ALIGN/COLLECT; MLEADERSTYLE has 5 fields | `annotate.rs:1195-1230`, `tables.rs:417` | Medium | 10–15 | +| F11 | Tables: no cell styles, formulas, data links, table breaking | `cmd/table.rs` | Medium | 10–15 | +| F12 | Annotative scaling: the flag is stored, nothing scales; no scale list, no "Annotative Object Scale" commands | 4 uncovered menu items | Medium-high in layouts | 10–15 | +| F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); gradients render as a flat colour; associativity not updated when the boundary moves; origin always 0,0; island style always normal; no gap tolerance, separate hatches, hatch dialog, MPolygon | `doc/src/library.rs`, `render/lib.rs:430-437`, `cmd/hatch.rs:198,201` | Medium | 15–25 | +| F14 | **[alpha blocker]** Plot styles (CTB/STB) not applied; no plot preview, plotter devices, PUBLISH/batch plot; Print opens no plot dialog (#160, PR #200) | `palettes.rs:666` | High for sheet output | 25–35 | +| F15 | Viewports: no polygonal, object or clipped viewports; no viewport scale control in the UI | menu items uncovered | Medium | 6–10 | +| F16 | Groups (GROUP, GROUPEDIT, PICKSTYLE) missing; ACAD_GROUP written empty | `dxf_write.rs` | Medium | 4–6 | +| F17 | Attributes: BATTMAN lists only; no ATTSYNC, EATTEXT, global ATTEDIT | `blocks.rs:300` | Medium | 6–10 | +| F18 | Layer bugs: edits change geometry on locked layers (#87, PR #196); delete/purge removes layers used by blocks (#72, PR #189); rename misses paper space and blocks (#75, PR #191); linetype rename breaks assignments (#74, PR #195); rejected freeze changes colour (#71, PR #188) | issues | High (correctness) | 3–5 (PRs open) | +| F19 | MATCHPROP has no interactive picker or settings dialog; DRAWORDER front/back only; OVERKILL exact duplicates only | `props.rs:333`, `modify.rs:1258` | Medium | 5–8 | +| F20 | OPTIONS/Preferences dialog missing (#23) (UNITS has a dialog since #38) | — | Medium | 6–10 | +| F21 | MLINE is two polylines (no MLINE entity, MLSTYLE); REGION makes polylines (no region entity); HELIX is a polyline | `draw2.rs:116,123`, `hatch.rs:267` | Medium-low | 8–12 | +| F22 | Inquiry: MASSPROP approximate; COUNT counts model-space inserts only; no QuickCalc palette (CAL is an evaluator); no DWG Compare | `utility.rs:203,310-352`, `inquiry.rs:305` | Low-medium | 15–25 | +| F23 | No Sheet Set Manager, eTransmit, Share, Traces, Markup Import, Activity Insights | menu items uncovered | Low-medium (team workflows) | 40–70 | +| F24 | No 3D: UCS, 3D views, orbit, solids, surfaces, meshes, sections, visual styles, materials, lights, rendering (150 menu items) | [parity-checklist](parity-checklist.md) | Medium (3D users) | 300–450 | +| F25 | Constraint solver: no DOF report, Smooth not G2, no ellipse/spline/block geometry, no inference while drawing; DCANGULAR can succeed unsatisfied (#73, PR #190) | [geometry-parity](geometry-parity.md#constraint-solver--50) | Low-medium | 20–30 | + +## UI/UX + +Detail and evidence: [ui-parity.md](ui-parity.md). + +| # | Gap | User impact | Est. | +|---|---|---|---| +| U1 | **[alpha blocker]** Object snap tracking (acquire points, alignment paths); Extension and Parallel snaps; true apparent intersection | High | 8–12 | +| U2 | Dynamic input: grip dimensional input, lock icons, DYNPROMPT options menu (pointer boxes with Tab and relative entry landed in #60) | Medium | 3–5 | +| U3 | **[alpha blocker]** FROM, M2P/MTP, TK, `.x/.y/.xy` point filters, `*` WCS prefix, angle override ` Open does nothing (#55, PR #199), saves don't download (#133, PR #193), plot shows raw PDF bytes (#134), no DWG | 6–10 | +| P2 | Windows and Linux get little runtime testing; Linux Mint mouse delay (#25); Windows magnifier (#39) | 6–10 | +| P3 | macOS file associations: opening `.dxf` from Finder fails (#158) | 1–2 | + +## Ecosystem and automation + +| # | Gap | Est. | +|---|---|---| +| E1 | AutoLISP interpreter (safe subset first: `defun`, `command`, `entget`/`entmod`, selection sets, `getpoint` …), APPLOAD, `acad.lsp` startup | 60–100 | +| E2 | SCRIPT command reading `.scr` files (scripts run today through CLI/MCP/control only) | 1–2 | +| E3 | Express Tools equivalents (TXT2MTXT, BREAKLINE, BURST, NCOPY exists, SUPERHATCH, Move/Copy/Rotate) | 30–50 | +| E4 | Plug-in API (owner decision: Rust/WASM plug-ins rather than ObjectARX/.NET) | 30–50 | +| E5 | CUI customisation and alias (`.pgp`) editor | 15–25 | + +## AI features + +| # | Gap | Est. | +|---|---|---| +| A1 | Smart Blocks equivalents (placement suggestions, replace, detect and convert, search), using local open models | 25–50 + model licensing | +| A2 | An in-app assistant (AutoCAD has Autodesk Assistant); CADCraft's MCP server already lets any agent drive it | 10–20 | +| A3 | Markup Assist (import markups from PDFs/images) | 10–20 | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Merged main: closed or narrowed rows for the Ukrainian catalog, UNITS dialog, shortcut display, dynamic input pointer boxes, press-drag windows, SETVAR DIM*, WIPEOUT and gradients, fmt | +| 2026-10-10 | minor | Alpha blockers marked (rows failing the core-workflow gate) | +| 2026-10-10 | major | First gaps list: ranked top 10 and every known shortfall by dimension, with evidence and estimates | diff --git a/docs/geometry-parity.md b/docs/geometry-parity.md new file mode 100644 index 00000000..4dd79626 --- /dev/null +++ b/docs/geometry-parity.md @@ -0,0 +1,153 @@ +# Geometry parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first audit of the geometry kernel's precision against AutoCAD) · **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 +audits CADCraft's geometry kernel (`crates/geom`, the curve code in `crates/engine/src/cmd`, +`crates/constraints`) curve by curve against what AutoCAD users rely on. Snaps and tracking are +in [ui-parity.md](ui-parity.md); dimension and hatch behaviour in [gaps.md](gaps.md). + +**Headline (estimated): geometry ≈ 40% (weighted below: 41%), of which the constraint solver ≈ 50%.** Everything built +from lines, arcs and circles is exact. Ellipses and splines are handled by tessellating them, so +intersections, offsets, trims and snaps on them are approximations; polyline offsets don't +recompute arc bulges or clean up loops; tolerances are absolute, so very large or very small +drawings behave differently from mid-sized ones. 3D geometry doesn't exist. Remaining: +**≈ 90–140 Opus 5.5 hours** for 2D precision and the solver, plus the 3D kernel (M11, see +[target-app-parity.md](target-app-parity.md)). + +## 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. + +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 +15%, measurement (closest point, length, area, MASSPROP) 10%, hatch boundaries 10%, +constraints 10%. + +## Numbers and tolerances (≈ 30%) + +| Item | AutoCAD | CADCraft | Evidence | +|---|---|---|---| +| Representation | IEEE double, 3D throughout | f64, **2D kernel** (`Vec2`); Z kept only on entity points and 3D polylines | `geom/src/vec.rs` | +| Tolerance model | Relative to geometry (the ACIS/AcGe tolerance context), stable at survey coordinates | one absolute `EPS = 1e-9`, plus ad-hoc absolute 1e-12/1e-14 in intersections and 1e-3/1e-4 world units for tessellating curves; a few relative tolerances (line–line denominator, hatch, solver) | `geom/src/lib.rs:26`, `intersect.rs:8,19,34,50`, `cmd/modify.rs:469-474`, `snap.rs:98-110` | +| Robust predicates | Exact orientation decisions where it matters | none | — | +| Large coordinates (UTM, 1e6–1e7) | Works | untested; absolute tolerances will misjudge tangency and coincidence, and absolute tessellation steps create huge sample counts that hit caps | — | +| Point merging (JOIN, PEDIT Join fuzz, region) | Fuzz distance per command | JOIN 1e-9 absolute; PEDIT has a fuzz; region grid-key rounding at `tol` | `modify.rs:419`, `region.rs:42` | +| Display precision (LUPREC, AUPREC, units) | Decimal, Engineering, Architectural, Fractional, Scientific; 5 angle formats | units formatting and parsing of all of them | `engine/src/units.rs` | + +## Curves (≈ 60%) + +| Curve | AutoCAD | CADCraft | +|---|---|---| +| Line, ray, xline | yes | yes, exact | +| Circle, arc, bulge polyline | yes | yes, exact | +| Ellipse, elliptical arc | yes | yes (center, major axis, ratio, parameters) | +| Spline: CV form, rational, any degree | yes | yes: rational B-spline, basis-function evaluation | +| Spline: fit points | degree 3 with end tangents, knot parameterisation (chord, square root, uniform), fit tolerance | cubic, chord-length, averaged knots, dense solve; **no end tangents, no knot parameterisation choice, no fit tolerance** (`geom/src/spline.rs:31-88`) | +| Spline: closed/periodic | periodic | "closed" appends the first point and refits; **not periodic** (`modify2.rs:647`) | +| Spline editing: knot insertion, split, refine, rebuild, convert to polyline | SPLINEDIT, grips | knot insertion and splitting (`curves.rs:539-622`); refine/rebuild partial | +| Helix | yes | **no** | +| 3D polyline | yes | stored and drawn; no 3D operations | +| Regions, 3D solids, surfaces, meshes (ACIS/B-rep, NURBS surfaces, subdivision) | yes | **no** (M11) | + +## Intersections (≈ 40%) + +| Pair | AutoCAD | CADCraft | +|---|---|---| +| line–line, line–circle/arc, circle–circle | exact | closed form, exact; tangency decided by a fixed `|disc| ≤ 1e-12·a`, so near-tangent cases depend on scale | +| line/arc–ellipse | exact (quartic) | **tessellated**: 1e-4 world units in TRIM, 1e-3 in snaps; TRIM refines with 720 samples + 60-step bisection, near-exact against line/arc cutters (`modify2.rs:1946-1995`) | +| line/arc–spline | exact to tolerance (subdivision + Newton) | tessellated as above | +| ellipse–ellipse, ellipse–spline, spline–spline | exact to tolerance | **polygon approximations** only | +| Self-intersections (curves, offset loops) | yes | **no routine** | +| Apparent intersection (projected) | yes | treated as plain intersection | + +## Offsets (≈ 30%) + +| Curve | AutoCAD OFFSET | CADCraft (`cmd/modify.rs:276-424`) | +|---|---|---| +| Line, ray, xline | exact | exact | +| Circle, arc | exact | exact (radius change) | +| Ellipse | spline approximation within tolerance | **a polyline** from tessellation at 1e-3 × major axis | +| Spline | spline approximation within tolerance | tessellate at 1e-3, offset vertices along normals, keep about 40 points, refit: **lossy** | +| Polyline with arcs | exact, with self-intersection trimming, OFFSETGAPTYPE (extend, fillet, chamfer) | segments offset and joined at neighbours' intersections; **arc bulges not recomputed** after endpoints move (comment at L413), **no loop cleanup**, collapsed segments not removed; OFFSETGAPTYPE only echoed | +| Options: Through, Erase, Layer, Multiple | yes | see [gaps.md](gaps.md) | + +## Trim, extend, fillet, chamfer, break, join, lengthen (≈ 45%) + +| Command | AutoCAD supports | CADCraft supports | +|---|---|---| +| TRIM | every curve type, quick and standard modes | line, arc, circle, polyline; ellipse and spline via sampled cutting (`modify.rs:485-656`, `modify2.rs:1996`) | +| EXTEND | every open curve | line, arc, polyline, ellipse; **no spline** | +| FILLET | lines, arcs, circles, polylines, ellipses, splines, xlines, rays; Trim/No trim | lines, arcs, circles (TTR circle), polyline mode; **no ellipse or spline** (`modify2.rs:1804`) | +| CHAMFER | lines, polylines, xlines, rays | lines (`modify.rs:756`); polyline chamfer not confirmed | +| BREAK | every curve | line, arc, circle, polyline | +| JOIN | lines, arcs, polylines, ellipses, splines, helixes | line, arc, polyline | +| LENGTHEN | lines, arcs, polylines, elliptical arcs, splines | line, arc, polyline | + +Where line and arc are supported the results are exact. + +## Measurement (≈ 40%) + +| Quantity | AutoCAD | CADCraft | +|---|---|---| +| Closest point: line, arc, circle, polyline | exact | exact | +| Closest point: ellipse, spline | exact to tolerance | **sampled** (`curve.rs:264`, `curves.rs:637`) | +| Tangent points, common tangents | every curve | circles and arcs only (`curve.rs:170-183`) | +| AREA: circle, ellipse | exact | exact area (ellipse perimeter by Ramanujan, not exact) | +| AREA: polyline with arcs | exact (bulge segments) | **tessellated at 1e-4** (`curve.rs:491`) | +| MASSPROP (regions: area, perimeter, centroid, moments) | exact on regions and solids | shoelace over a tessellated circle, polyline or first hatch loop (`utility.rs:310-352`); no regions | +| LIST/DIST/ID precision | full double | full double | + +## Hatch boundaries (≈ 45%) + +`geom/src/region.rs`: curves are polygonised, crossings split on a grid, vertices merged at +`tol`, a ray picks the edge and the face is traced by sharpest left turn; islands by +connectivity (including islands made of separate lines, #163). Caps: 60,000 segments, 4,096 +islands. Missing: **gap tolerance (HPGAPTOL)**, true arcs and curves in boundaries (boundaries +come out tessellated, so a hatched circle's boundary is a polygon), island detection modes +beyond the default, boundary sets, and retained associative boundaries for every case. Pattern +lines are scanline families clipped against the tessellated loops (`render/src/hatch.rs`). + +## Constraint solver (≈ 50%) + +| Item | AutoCAD | CADCraft (`crates/constraints`) | +|---|---|---| +| Geometric constraints (12) | coincident, collinear, concentric, fix, parallel, perpendicular, horizontal, vertical, tangent, smooth (G2), symmetric, equal | all 12; **Smooth solved as tangency, not G2** | +| Dimensional constraints | linear, horizontal, vertical, aligned, angular, radial, diameter; dynamic and annotational; reference | distance (aligned/horizontal/vertical), angular, radius, diameter; parameters and expressions (`expr.rs`) | +| Geometry | lines, arcs, circles, polylines, ellipses, splines, blocks | line, circle, arc, polyline, point (`model.rs:42`); **no ellipse, spline or block** | +| Algorithm | proprietary | damped Gauss–Newton / Levenberg–Marquardt, numeric Jacobian, per connected component; MAX_ITER 200, MAX_ROWS 800 | +| Over-constraint | refused with a message | redundancy by Jacobian rank, conflicts by removal probing (up to 48 probes) | +| Degrees of freedom, fully constrained state | shown (constraint bars, status) | **not reported** | +| Inference while drawing (CONSTRAINTINFER) | yes | no | + +## Tests + +`#[test]` counts on 2026-10-10: geom 28, constraints 22, render 50, engine 174. No property-based +or fuzz tests of geometry, and no known-answer suite at large coordinates. Hostile-input tests +exist for parsing, not for numerics. + +## What to do first (ranked) + +1. Exact ellipse and spline intersection and closest point (subdivision + Newton with a + relative tolerance); use them in snaps, TRIM, EXTEND, BREAK. 15–25 h. +2. Polyline offset with bulge recomputation, self-intersection cleanup and OFFSETGAPTYPE; + spline/ellipse offset as fitted splines within a tolerance. 15–25 h. +3. A tolerance context relative to the drawing's extents, and a known-answer test suite at + origin and at 1e6–1e7 coordinates. 8–12 h. +4. FILLET/JOIN/LENGTHEN/EXTEND for ellipses and splines; CHAMFER on polylines. 10–15 h. +5. Exact areas and MASSPROP for bulge polylines, regions (2D) and hatch loops. 6–10 h. +6. Hatch gap tolerance and curved boundaries. 8–12 h. +7. Solver: DOF reporting, G2 Smooth, ellipse/spline geometry, constraint inference. 20–30 h. +8. Spline fit options (end tangents, knot parameterisation, fit tolerance, periodic). 6–10 h. + +Total ≈ 90–140 h; items 1–6 parallelise across two or three agents. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First geometry-precision audit: tolerances, curves, intersections, offsets, modify commands, measurement, hatch, solver | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md new file mode 100644 index 00000000..d6b03b4f --- /dev/null +++ b/docs/hardware-parity.md @@ -0,0 +1,44 @@ +# Hardware parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first hardware assessment) · **Target:** Autodesk AutoCAD 2027 (Mac and Windows) + +The hardware AutoCAD uses and how CADCraft compares, per platform. Work items: +[gaps.md](gaps.md#hardware). + +**Headline (estimated): hardware ≈ 45%.** 2D GPU drawing, HiDPI and trackpads are covered on +every platform. Missing: 3D mice, tablets/digitizers, GPU-accelerated 3D (there is no 3D), and +any deliberate multi-monitor handling. Remaining: **≈ 15–25 Opus 5.5 hours**, plus a human with +the devices to test 3D mice and tablets. + +Measured from the source on 2026-10-10 (`crates/ui-egui/src/gpu.rs`, `canvas.rs`, +`apps/cadcraft/src/graphics.rs`, `apps/cadcraft-web`); AutoCAD's side from Autodesk's system +requirements and graphics-performance documentation. Nothing was benchmarked in this pass. + +| Feature | AutoCAD 2027 | CADCraft macOS | CADCraft Windows | CADCraft Linux / FreeBSD | CADCraft web | +|---|---|---|---|---|---| +| GPU canvas for 2D | yes (hardware acceleration, GPU smoothing, high-quality geometry) | wgpu on Metal | wgpu on DirectX 12 by default (`WGPU_BACKEND` overrides; GL avoided after AMD crashes, #31) | wgpu on Vulkan (or GL) | WebGPU, WebGL2 fallback | +| CPU fallback | software mode | yes (`tiny-skia` rasteriser) | yes | yes | — | +| GPU for 3D (shaded visual styles, ray-traced rendering) | yes | no (no 3D) | no | no | no | +| Low-latency presentation | yes | vsync or low-latency via `CADCRAFT_VSYNC` | same | no vsync queue so the crosshair keeps up (#183) | browser-controlled | +| HiDPI / Retina, display scaling | yes | yes (egui points per pixel) | yes; a Windows 11 DPI fix is queued upstream (PR #110) | yes | yes | +| Multi-monitor (palettes on other screens, per-monitor DPI) | yes | not handled deliberately; one window | same | same | — | +| Trackpad pan, pinch zoom | yes (Mac) | yes, with trackpad vs wheel detection (#58) | wheel only (precision touchpad untested) | wheel; delay reported on Linux Mint (#25) | browser events | +| Magic Mouse | yes | treated as a trackpad | — | — | — | +| Wheel mouse, middle-button pan | yes | yes | yes | yes | yes | +| 3D mouse (3Dconnexion SpaceMouse) | yes | **no** | **no** | **no** | no | +| Pen tablet / digitizer (TABLET command, calibration) | yes (Windows) | **no** | **no** | **no** | no | +| Screen magnifier following the cursor | yes | — | breaks (#39; PR #187 adds a system-cursor option) | — | — | +| Plotters and printers (PC3 devices, system printers) | yes | **no**: PDF/PNG/SVG files only; Print asks where to save the PDF (#26) | same | same | PDF bytes shown instead of a download (#134) | + +## Performance on hardware (internal numbers only) + +From the earlier roadmap (measured by the agents that built it, 2026-10-06, `cadcraft-cli perf`): +200,000 entities pick in 0.001 ms, about 4 ms after an edit (incremental R-tree), and the GPU +canvas draws a frame in about 4 ms. Nothing has been compared with AutoCAD on the same machine, +and 1M-entity drawings are untested. A 10 MB DWG took about two minutes to fail (#57). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | First hardware parity doc: GPU backends per platform, input devices, displays, plotters | diff --git a/docs/localization-parity.md b/docs/localization-parity.md new file mode 100644 index 00000000..dfa286a3 --- /dev/null +++ b/docs/localization-parity.md @@ -0,0 +1,78 @@ +# Localization parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (Ukrainian catalog landed, #36: first string catalog) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) + +How far CADCraft's interface and drawing text are from AutoCAD's languages. Summarised in +[ROADMAP.md](../ROADMAP.md#languages); work items in [gaps.md](gaps.md#localization). + +## What the target ships + +Measured from the installed AutoCAD 2027 for Mac bundle by listing only (folder names, nothing +read): `Contents/Resources` has `Base.lproj` (English), `de`, `es`, `fr`, `it`, `ja`, `ko` and +`zh_CN` localizations, and `Resources/Support` has matching `@de@` … `@zh_CN@` folders. That is +**8 interface languages** on the Mac. AutoCAD for Windows also has language packs for Traditional +Chinese, Brazilian Portuguese, Russian, Polish, Czech and Hungarian (Autodesk's published +language-pack list, not measured here). AutoCAD's spell checker ships about 20 dictionaries +(`*.dct` names in `Resources/Support`). + +Drawing text in AutoCAD: TrueType and SHX big fonts (CJK), Unicode `\U+` escapes, complex-script +shaping and right-to-left text through TrueType, vertical SHX text styles. + +## What CADCraft has + +- **A display-time string catalog** (`crates/ui-egui/src/i18n/`, landed with #36): a TSV per + language (context, English source, translation, `@plural` rows), Edit → Interface Language with + Auto (system language), live switching, a saved choice, and tests for coverage, placeholders + and plurals ([localization.md](localization.md)). The Ukrainian catalog has **936 rows**, + covering menus, command labels, tool groups and tooltips, the Start page, tabs, status + controls, object snaps, drafting prompts, property labels and dialogs. Engine diagnostics, + CLI/MCP responses and command history stay English, and uncatalogued text falls back to + English. For scale: about 1,160 unique capitalised string literals exist in `crates/engine` + and `crates/ui-egui` (grep, 2026-10-10). +- **Drawing text:** TrueType/OpenType/`.ttc` through `skrifa` with per-character font fallback + and FONTALT, so CJK characters render (#173). DXF text is written with `\U+` escapes so + non-ASCII survives in R2000 files (#169). Glyphs are placed one per code point: there is **no + shaping** (Arabic joining, Devanagari conjuncts), **no bidi** (right-to-left order) and **no + vertical text**. SHX fonts are not read; our stroke font stands in. +- **Text input:** egui/winit IME. Several upstream IME fixes are queued as PRs (#110–#119: + macOS Korean, emoji picker, Windows caret placement, X11 and Wayland), so CJK input is not yet + reliable on every platform. + +## Languages + +Order and columns from craftrules' progress-docs standard. Every UI percentage is **measured** +from the catalogs (0 rows for every language without a catalog file). Hours are **estimated**: +about 3–5 h per language for an Opus 5.5 agent to translate the ~940 catalog rows and fix layout, +plus native review by a human; extending the catalog to the remaining English-only strings +(engine diagnostics, uncatalogued text) is about 4–6 h, counted once in the first row. + +| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Estimate to `full` | +|---|---|---|---|---|---|---|---| +| English | en | source (100%) | 100% (help is a command reference, not AutoCAD-sized help) | Latin: yes | n/a | full | — | +| Simplified Chinese | zh-Hans | 0 (0%) | 0% | CJK glyphs render via fallback; IME fixes pending (#110–#119); no vertical text | no | none | 8–12 h (includes extending the catalog, 4–6 h) | +| Spanish | es | 0 (0%) | 0% | Latin: yes | no | none | 3–5 h | +| Hindi | hi | 0 (0%) | 0% | Devanagari: no shaping | no | none | 3–5 h + 15–25 h shaping (shared with Arabic) | +| Arabic | ar | 0 (0%) | 0% | No shaping, no RTL layout or bidi text | no | none | 3–5 h + shaping + 15–25 h RTL UI | +| French | fr | 0 (0%) | 0% | Latin: yes | no | none | 3–5 h | +| Portuguese | pt | 0 (0%) | 0% | Latin: yes | no | none | 3–5 h | +| Indonesian | id | 0 (0%) | 0% | Latin: yes | no | none | 3–5 h | +| Japanese | ja | 0 (0%) | 0% | CJK glyphs render; IME pending; no vertical text | no | none | 4–6 h | +| German | de | 0 (0%) | 0% | Latin: yes (long strings will need layout checks) | no | none | 3–5 h | +| Korean | ko | 0 (0%) | 0% | Hangul renders; macOS Korean IME fixes pending (#118, #119) | no | none | 4–6 h | +| Vietnamese | vi | 0 (0%) | 0% | Latin with stacked diacritics: renders per code point, untested | no | none | 3–5 h | +| Ukrainian | uk | 936 catalog rows (≈ 100% of the catalog; ≈ 80% of all UI strings, estimated: engine diagnostics and uncatalogued text stay English) | dialogs and tooltips yes; command reference searchable in Ukrainian | Cyrillic: yes (bundled egui fonts) | translated by a contributor; no second review | partial | 2–4 h | + +Other languages shipped: Ukrainian (partial). AutoCAD for Mac ships 7 non-English languages +(zh-Hans, es, fr, ja, de, ko, it); CADCraft ships none of those, and Ukrainian, which AutoCAD +for Mac doesn't ship. + +**Total to put all twelve at `full`:** about 60–100 Opus 5.5 hours (catalog extension 4–6 h, +eleven translations 35–55 h, complex-script shaping and RTL 30–45 h), plus native-speaker review for +each language, which needs humans. Translations parallelise fully once the catalog exists. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Ukrainian catalog landed on main (#36): catalog infrastructure exists, Ukrainian partial; total hours 70–110 → 60–100 | +| 2026-10-10 | major | First localization measurement against AutoCAD 2027 for Mac's bundle localizations | diff --git a/docs/parity.md b/docs/parity-checklist.md similarity index 96% rename from docs/parity.md rename to docs/parity-checklist.md index 926c122a..2414c47a 100644 --- a/docs/parity.md +++ b/docs/parity-checklist.md @@ -1,6 +1,8 @@ -# Parity report +# Parity checklist -Generated by `cargo xtask parity`. 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 ROADMAP.md for the weighted estimate. +> **Generated:** 2026-10-10 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: 233 / 491 items (47%)** · 295 registered commands. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 00000000..5428a591 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,108 @@ +# CADCraft roadmap detail + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (merged main: fmt fixed, Ukrainian catalog in M13; alpha gate added earlier today) · **Target:** Autodesk AutoCAD 2027 + +Forward-looking plan: milestones with status, the current focus, and what comes next with +estimates. The summary is [ROADMAP.md](../ROADMAP.md); the work items behind every line are in +[gaps.md](gaps.md); numbers come from [target-app-parity.md](target-app-parity.md). Hours are +Opus 5.5 agent-hours. + +## Current focus + +1. **Gate and land.** (`cargo fmt` on main is fixed.) Add a PR workflow that runs + `cargo xtask ci`, then review and land the 47 open PRs, most of them community bug fixes + (S1–S3). ≈ 15–25 h. +2. **Stop losing data on save** (FF1, FF2, FF7) and save DWG/DXF at a chosen version (FF3). + ≈ 30–45 h. +3. **A real-file corpus** and a check of our output in AutoCAD (FF5, needs the owner). +4. **Precision input:** object snap tracking, extension/parallel snaps, FROM/M2P/TK, typed snap + overrides, editable dynamic input (U1–U4). ≈ 25–35 h. +5. **Unstub the prompt options** of the most-used commands (F1). ≈ 15–25 h. +6. **Autosave, recovery, AUDIT, RECOVER** (F4). ≈ 10–15 h. +7. **The rest of the alpha gate:** block editor and REFEDIT, xref attach (F2, F3), and plot styles + with a plot dialog and preview (F14). ≈ 50–80 h. + +Items 2–5 and 7 are the [alpha gate](#alpha-gate) rows. + +## Alpha gate + +The core workflows a typical AutoCAD professional does every day (craftrules progress-docs +standard). CADCraft is **pre-alpha** because four of the five fail the gate; the failing rows are +the alpha checklist. Main platform: macOS. + +| Core workflow | Works end to end? | Evidence | Hours to pass | +|---|---|---|---| +| Draw and modify precisely with object snaps and tracking | **partial, blocking** | Running snaps, polar, ortho and coordinate entry work; object snap tracking, extension and parallel snaps are toggles with no behaviour; no FROM/M2P/TK or typed snap overrides; TRIM/EXTEND standard mode, OFFSET Erase/Layer/Multiple, ROTATE Reference print "not available yet"; ellipse/spline intersections approximate ([ui-parity.md](ui-parity.md), [gaps.md](gaps.md) U1, U3, U4, F1) | 35–50 | +| Dimension and annotate | partial, not blocking | All core DIM types, associativity, DIMSTYLE variables (43 of ~80), TEXT/MTEXT, MLEADER, tables work; no DIMSTYLE dialog, MTEXT editor not in place, MLEADER thin (F7, F9, F10). A drafter can dimension a sheet end to end | 0 for the gate (beta work) | +| Use blocks (insert library blocks, edit them, attributes, xrefs) | **partial, blocking** | Static BLOCK/INSERT/attributes work; no block editor (BEDIT) or REFEDIT, so a block can't be edited in place; no xrefs; dynamic blocks from other drawings lose their behaviour (F2, F3) | 35–55 | +| Layers, layouts and plotting a sheet | **partial, blocking** | Layers, layouts, viewports, page setups and PDF output work; plot styles (CTB/STB) aren't applied, so office-standard monochrome/lineweight plots come out wrong; no plot preview or plot dialog (#160); printers/plotters only via PDF (F14, H3) | 15–25 | +| Save DWG that AutoCAD opens without loss, and reopen others' DWGs | **no** | Saving drops every entity and object we don't model (Unknown, IMAGE, WIPEOUT, gradients, groups, views, foreign xdata); MULTILEADER becomes LEADER+MTEXT; DWG always written as R2000; never checked in AutoCAD; no real-file tests; a 10 MB DWG failed after ~2 min (#57) (FF1–FF6) | 40–60 + owner (real-file corpus, AutoCAD check) | +| **To alpha** | | | **≈ 125–190** | + +Ready for real work is ≈ 38%, also under the ~40% alpha bar; closing the gate rows lifts it past +40%. + +## Beta checklist + +Beta means: close to feature complete for mainstream 2D work, opens and saves DWG reliably, +remaining gaps are known bugs and edge features (≈ 75% ready for real work). + +| Item | State | Est. | +|---|---|---| +| DWG/DXF: nothing lost on save; MULTILEADER native; version choice; code pages; real-file corpus green; verified in AutoCAD | not started | 50–75 | +| 2D geometry exact for ellipses and splines; polyline offset correct; relative tolerances | not started | 50–80 | +| Precision input at AutoCAD level (tracking, dynamic input, overrides, point modifiers, grips, selection keywords, context menus) | partial | 90–140 | +| Every Draw/Modify prompt option works; associative arrays | partial | 40–60 | +| Annotation depth: DIMSTYLE manager and variables, missing DIM commands, in-place MTEXT, fields, MLEADER editing, annotative scaling, tables | partial | 90–130 | +| Xrefs, block editor, dynamic blocks, groups, attribute tools | not started | 90–135 | +| Plot styles, plot preview, PUBLISH, non-rectangular viewports | partial | 30–45 | +| Autosave, recovery, AUDIT/RECOVER, Options dialog, templates, SHX, user `.lin`/`.pat` | partial | 40–60 | +| AutoLISP core subset and SCRIPT | not started | 40–70 | +| Catalog plus AutoCAD for Mac's languages | not started | 30–45 | +| Stability: CI gate, issue backlog, command smoke sweep, geometry fuzzing | partial | 30–50 | +| Platforms: web open/save, Windows/Linux runtime checks; performance at 1M entities and large DWG | partial | 20–35 | +| **Total to beta** | | **≈ 600–930** | + +About 70% of this parallelises across 4–6 agents (one per row), so elapsed time is roughly +150–250 hours. + +## Milestones + +Status re-scored on 2026-10-10 by depth, not presence (the 2026-10-07 percentages, reported by the +agents that built each milestone, are in the progress log of [ROADMAP.md](../ROADMAP.md)). + +| # | Milestone | Status | Remaining (h) | +|---|---|---|---| +| M0 | Skeleton and vertical slice: workspace, AutoCAD-style UI, command line, draw/modify basics, DXF, CLI, MCP, web | done | — | +| M1 | Drafting core: every 2D Draw command and option, dynamic input, object snap tracking, snap overrides | ≈ 55% (Draw breadth strong; tracking, dynamic input, overrides missing) | 35–55 | +| M2 | Modify: grips, PEDIT, SPLINEDIT, LENGTHEN, BLEND, ALIGN, associative arrays, trim/extend for all curves, MATCHPROP UI | ≈ 50% | 45–70 | +| M3 | Layers and properties | ≈ 70% (open layer bugs #71–#75, #87) | 10–15 | +| M4 | Annotation: DIM*, DIMSTYLE manager, MLEADER, in-place MTEXT, fields, tables, annotative scaling, SHX | ≈ 40% | 90–130 | +| M5 | Hatch and blocks: hatch depth, block editor, dynamic blocks, xrefs, groups, attributes | ≈ 30% | 105–160 | +| M6 | Layouts and plotting: viewports, page setups, plot styles, preview, PUBLISH | ≈ 35% | 35–50 | +| M7 | Files: DWG/DXF fidelity and versions, RECOVER/AUDIT, templates, autosave, ETRANSMIT, underlays | ≈ 30% | 170–280 (incl. other formats) | +| M8 | Inquiry and utilities, OPTIONS, customisation | ≈ 25% | 45–70 | +| M9 | Parametric | ≈ 50% | 20–30 | +| M10 | Performance: 1M entities at 60 fps, large DWG loads | ≈ 60% (internal numbers only) | 20–40 | +| M11 | 3D: UCS, orbit, visual styles, solids, surfaces, meshes, sections, rendering | ≈ 2% | 300–450 | +| M12 | Automation: AutoLISP, SCRIPT, action recorder, sheet sets, Express Tools | ≈ 15% (MCP, CLI, control channel done) | 130–220 | +| M13 | 1.0 polish: preferences, workspaces, localisation, accessibility, signed packages, docs | ≈ 15% (signed packages for every platform exist; themes done; string catalog with Ukrainian, live language switching, #36) | 110–170 | +| — | Geometry precision (cross-cutting, see [geometry-parity.md](geometry-parity.md)) | ≈ 40% | 70–110 | +| — | AI features | ≈ 0% in-app | 40–80 | +| | **Remaining to full parity** | | **≈ 1,300–2,100** | + +Milestone rows overlap a little with the cross-cutting rows; the total is the dimension sum in +[target-app-parity.md](target-app-parity.md), not the column sum. + +## After beta + +3D foundations (UCS, 3D views, orbit, then solids per `plan/adr/0001`), sheet sets, the full +AutoLISP surface, AI features with openly licensed local models, and every remaining language. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Merged main: fmt fixed, Ukrainian catalog noted in M13, ready for real work 38% | +| 2026-10-10 | minor | Alpha gate added (5 core workflows, 4 failing); stage pre-alpha | +| 2026-10-10 | major | Created from the old ROADMAP's milestone table and current focus; milestones re-scored by depth; beta checklist added | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md new file mode 100644 index 00000000..02990141 --- /dev/null +++ b/docs/target-app-parity.md @@ -0,0 +1,230 @@ +# Target-app parity: CADCraft vs AutoCAD 2027 + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (merged main: dynamic input pointer boxes, Ukrainian catalog, DXF fixes; numbers updated) · **Target:** Autodesk AutoCAD 2027 + +The authoritative assessment of how close CADCraft is to AutoCAD, and how much Opus 5.5 work +remains. [ROADMAP.md](../ROADMAP.md) summarises it, [gaps.md](gaps.md) lists every shortfall, +and the deep checklists are [geometry-parity.md](geometry-parity.md), +[ui-parity.md](ui-parity.md), [file-format-parity.md](file-format-parity.md), +[hardware-parity.md](hardware-parity.md) and [localization-parity.md](localization-parity.md). + +## Headline + +| Number | Value | Kind | +|---|---|---| +| **Feature breadth** (AutoCAD for Mac menu items with a live command) | **245 / 491 = 50%** (233 = 47% counted by `cargo xtask parity`, which sees engine commands only; +12 UI-only items) | measured ([parity-checklist.md](parity-checklist.md), source scan) | +| Feature breadth, 2D menus only (excluding 150 3D items) | **≈ 65%** (222 / 341 by `cargo xtask parity`'s rule) | measured (source scan, 2026-10-10) | +| Feature depth, weighted by use (features dimension) | **≈ 41%** | estimated (table below) | +| **Ready for real work** | **≈ 38%** | estimated (dimension table below; weighted 37.7%) | +| Mainstream practitioner (2D drafter, weekly tools) | **≈ 28%** | estimated ([below](#mainstream-practitioner)) | +| Essentials user (core tools, default settings) | **≈ 46%** | estimated ([below](#essentials-user)) | +| Remaining to alpha (gate rows) | **≈ 125–190 Opus 5.5 hours** | estimated | +| Remaining to beta | **≈ 600–930 Opus 5.5 hours** | estimated | +| Remaining to full parity | **≈ 1,300–2,100 Opus 5.5 hours** | estimated | + +Stage: **pre-alpha**. Four of the five core workflows fail the [alpha gate](roadmap.md#alpha-gate) (precise drafting with tracking, blocks, plotting, lossless DWG save), and ready for real work is under the ~40% alpha bar. Alpha is ≈ 125–190 h away. + +## Readiness by audience + +| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | +|---|---:|---|---| +| Full target (ready for real work) | ≈ 38% | ≈ 1,150–1,850 h (≈ 70% parallelises across 4–6 agents: ≈ 300–480 h elapsed) | 3D kernel (300–450 h), DWG/DXF and other formats, AutoLISP and ecosystem, blocks/xrefs/dynamic blocks, localization | +| Mainstream practitioner (2D drafter) | ≈ 28% | ≈ 570–880 h (≈ 70% parallelises: ≈ 150–240 h elapsed) | lossless DWG exchange and a real-file corpus, precision input (tracking, dynamic input), 2D geometry precision, blocks/xrefs, annotation depth, stability backlog | +| Essentials user | ≈ 46% | ≈ 140–225 h (≈ 50% parallelises across 3 agents: ≈ 60–110 h elapsed) | stubbed options in basic draw/modify commands, opening DWGs people send, launch/stability fixes, plot dialog, context menus and dynamic input | + +Hours are subsets (essentials ⊂ mainstream ⊂ full) and use the calibration in [Remaining effort](#remaining-effort-and-how-it-was-calibrated): ≈ 25 commands +per agent-hour at presence quality in the first build (≈ 11.5 agent-hours), 0.3–0.7 h per option +or bug fix (≈ 60 such fixes on 2026-10-10), 2–5 h per moderate feature, 15–100 h per subsystem. + +The full number is an additive weighted sum over the dimensions (table below); only the +mainstream and essentials numbers use multiplicative discounts. + +## Target and how it was measured + +- **Target:** Autodesk AutoCAD 2027 for Mac, version 26.0 (installed under + `/Applications/Autodesk/AutoCAD 2027`), and AutoCAD 2027 for Windows from Autodesk's + documentation where the two differ (Ribbon, Sheet Set Manager, Express Tools, .NET). +- **Clean room:** the installed bundle was inspected by **listing names only**: localizations + (`Base`, `de`, `es`, `fr`, `it`, `ja`, `ko`, `zh_CN` `.lproj` folders), `Contents/PlugIns` + module names (AcSolids, AcDim, AcTable, AcSection, AcSubD, AcPublish, AcETransmit, AcXDiff / + DWG Compare, AcCounting, AcMarkupAssist, AcTrace, AcSmartCenter, AcSpeller, Vlide …, about 130 + modules), and `Resources/Support` file names. Nothing inside a file was read, nothing was + copied, AutoCAD was not launched in this pass. +- **Menu oracle:** `xtask/data/menu-catalog.txt` (feature names only, 491 leaf items, recorded from + the running AutoCAD 2027 for Mac) against the command registry. +- **Behaviour reference:** `plan/autocad/01-observed-ui.md` and local reference screenshots (black + box, never committed); Autodesk's public command reference. +- **CADCraft:** the source on `main` at bd2412f (2026-10-10/11), read by four agents in + parallel: command-by-command option audits (prompts that print "not available yet"), the + geometry kernel, DXF/DWG paths, interaction code. Plus the 88 GitHub issues and 47 open PRs. + Disk was nearly full, so nothing was built or run: `cargo xtask parity` was reproduced by a + Python scan of `CommandSpec` menus with the xtask's matching rule, which gives exactly the same + uncovered list as the last generated report (233 / 491). Re-run `cargo xtask parity` to + confirm. + +## Mainstream practitioner + +The typical AutoCAD professional is a 2D drafter or detailer: draw, modify, dimension, use +blocks, manage layers, plot sheets, and exchange DWGs with colleagues, every week. Excluded per +the standard: 3D, AutoLISP and plug-ins, cloud/AI (Autodesk Assistant, Smart Blocks, Traces, +Share), Sheet Set Manager administration, specialist hardware, languages other than English. + +| Area (weekly use) | Weight | Depth | Weighted | +|---|---:|---:|---:| +| Draw (2D) | 15% | 60% | 9.0 | +| Modify | 18% | 50% | 9.0 | +| Precision input (snaps, tracking, coordinate entry, dynamic input) | 13% | 40% | 5.2 | +| Layers and properties | 8% | 70% | 5.6 | +| Dimensions | 10% | 45% | 4.5 | +| Text and MTEXT | 6% | 35% | 2.1 | +| Leaders and tables | 3% | 25% | 0.8 | +| Hatch | 5% | 35% | 1.8 | +| Blocks, attributes, xrefs | 8% | 25% | 2.0 | +| Layouts and plotting | 8% | 35% | 2.8 | +| Inquiry and utilities (incl. autosave) | 3% | 30% | 0.9 | +| Command line and navigation | 3% | 58% | 1.7 | +| **Average depth** | 100% | | **≈ 45.3%** | + +Depths are the area percentages above and in [ui-parity.md](ui-parity.md). File I/O is not an +area here; it is counted once, as the file-exchange discount. + +| Discount | Factor | Evidence | +|---|---:|---| +| Interaction fidelity (beyond the precision-input area) | ×0.90 | hot-grip options ignored, no context menus, selection keywords W/C/F/WP/CP rejected (#224), transparent 'ZOOM cancels the running command (#220), ELLIPSE/BREAK keywords ignored (#219) | +| Stability on real machines | ×0.85 | 27 of 37 open issues are core-path bugs; about 60 correctness fixes merged on 2026-10-10 alone; lines not visible mid-command (#44); locked layers edited (#87); no CI runs the Rust tests on PRs | +| Exchanging files with AutoCAD users | ×0.80 | saves drop unmodelled entities, images, layer states, multileader styles, true colours and more (#79, #201, #204, #208, #209, #213, #223); DWG always R2000; never checked in AutoCAD; a 10 MB DWG failed (#57) | + +**Mainstream practitioner ≈ 45.3% × 0.90 × 0.85 × 0.80 ≈ 28%** (estimated). It is lower than +ready for real work (≈ 38%) for CADCraft, unlike most apps: the full number credits additively +what CADCraft does well beyond 2D drafting (platforms 75%, performance 60%, hardware 45%), while +here the three blockers a working drafter hits every day multiply. + +**User evidence** (GitHub issues, PRs and comments, excluding maintainers, counted 2026-10-10): +97 issues filed (60 closed, 37 open) and 127 PRs from 19 outside authors. Praise: 2 (#65: +"I was looking for Autocad replacement for years"; #18), plus thanks on a translation PR (#218). +"Switched from AutoCAD" reports: 0. Open issues: 27 core-path bugs (file data loss, plotting, +selection, layers, web open/save, large DWG), 6 niche requests (ViewCube drag, preferences, SVG +icon, magnifier, constraint edge case, macOS window controls), 4 other (icon offer, trademark, +"Revitcraft", dynamic-block help offer). Interest is real; the open issues are about the core +path, which matches the discounts. + +## Essentials user + +A casual user who sticks to the essential features: start a drawing, the common draw and modify tools with default +settings, undo, save, print a PDF. Excluded: advanced options, sheet sets, xrefs, plot styles, +annotation styles, exchange edge cases and everything excluded above. + +| Core feature | Weight | Depth | Weighted | +|---|---:|---|---:| +| Launch, Start tab, new/open drawing | 10% | 70% | 7.0 | +| LINE, PLINE, CIRCLE, ARC, RECTANG | 20% | 70% | 14.0 | +| MOVE, COPY, ROTATE, SCALE, MIRROR, ERASE | 15% | 70% | 10.5 | +| OFFSET, TRIM (quick), EXTEND, FILLET | 10% | 50% | 5.0 | +| Running object snaps, ortho, polar | 10% | 55% | 5.5 | +| Basic dimensions (linear, aligned, radius) | 8% | 60% | 4.8 | +| TEXT and MTEXT | 5% | 55% | 2.8 | +| Layers (create, colour, on/off) | 7% | 75% | 5.3 | +| Pan, zoom, picking and window selection | 5% | 65% | 3.3 | +| Undo, redo | 3% | 85% | 2.6 | +| Save and reopen own drawings | 4% | 75% | 3.0 | +| Print or export to PDF | 3% | 45% | 1.4 | +| **Average depth** | 100% | | **≈ 65%** | + +| Discount | Factor | Evidence | +|---|---:|---| +| Launch and stability | ×0.90 | startup crash on Intel UHD graphics (#33, fixed in 0.4.0), lines not visible (#44), Linux mouse delay (#25), web open/save broken (#55, #133) | +| Discoverability and UI clarity | ×0.92 | no right-click menus (dynamic input got real pointer boxes in #60, which raised this from ×0.90), Print has no plot dialog (#160), some menu items only recently got dialogs (#126) | +| Opening files people send them | ×0.85 | DWGs from others untested on real files; a 10 MB DWG failed (#57); `.dxf` from Finder fails (#158); pre-2007 CJK/Cyrillic DXF garbled (no code pages) | + +**Essentials user ≈ 65% × 0.90 × 0.92 × 0.85 ≈ 46%** (estimated). + +## By feature area (the features dimension) + +Weights are the share of a typical AutoCAD user's working time (2D drafters and detailers are +the large majority; 3D modelling and cloud features are a minority). Percentages are depth, not +presence: an option that prints "not available yet" doesn't count. + +| Area | Weight | Parity | Remaining (Opus 5.5 h) | Evidence and main gaps | Doc | +|---|---:|---:|---|---|---| +| Draw (2D) | 13% | 60% | 15–25 | ARC 11/11 methods, CIRCLE 6/6, RECTANG all options, REVCLOUD, WIPEOUT, XLINE; PLINE Angle/CEnter/Direction/Radius missing; SPLINE options ignored; MLINE and REGION are polylines; HELIX approximate | [gaps](gaps.md#features) | +| Modify | 15% | 50% | 40–60 | TRIM/EXTEND standard mode, Fence, Crossing, Project, eRase stubs; OFFSET Erase/Layer/Multiple stubs; ROTATE Reference stub; arrays non-associative (no ARRAYEDIT, interactive rectangular array fixed 3×4); MATCHPROP JSON only | [gaps](gaps.md#features) | +| Geometry precision | 10% | 40% | 70–110 | exact for lines/arcs/circles; ellipse and spline intersections, offsets, closest points tessellated; absolute tolerances; polyline offset doesn't recompute bulges | [geometry-parity](geometry-parity.md) | +| Layers and properties | 7% | 70% | 10–15 | Layer Properties Manager with filters, states, VP overrides; QSELECT; Quick Properties; transparency; linetype manager thin; 19 linetypes of ~45 | [gaps](gaps.md#features) | +| Dimensions | 9% | 45% | 25–35 | all core DIM types and associativity; 43 of ~80 DIMSTYLE variables; no DIMSTYLE manager dialog, smart DIM, DIMJOGGED, DIMBREAK, DIMJOGLINE, oblique, TOLERANCE/GD&T, inspection; alt units thin | [gaps](gaps.md#features) | +| Text and MTEXT | 6% | 35% | 30–45 | TrueType, formatting codes, stacking; editor is a dialog, not in-place; most MTEXT prompt options ignored; no columns, bullets, tabs, fields, spell check, SHX, annotative behaviour | [gaps](gaps.md#features) | +| Leaders, tables, fields | 4% | 25% | 25–35 | basic MLEADER and TABLE; leader options ignored, no MLEADEREDIT/ALIGN/COLLECT, no block content; no formulas, data links, FIELD | [gaps](gaps.md#features) | +| Hatch and gradients | 5% | 35% | 15–25 | pick points with islands; 23 patterns of ~85; gradients render flat; associativity flag not acted on; no origin, gap tolerance, separate hatches, hatch dialog | [gaps](gaps.md#features) | +| Blocks, attributes, xrefs, groups | 9% | 25% | 80–120 | static blocks and attributes; no BEDIT, dynamic blocks, xrefs, REFEDIT, XCLIP, GROUP, ATTSYNC, EATTEXT, DesignCenter | [gaps](gaps.md#features) | +| Layouts, viewports, plotting | 9% | 35% | 35–50 | layouts, rectangular viewports, per-VP layers, page setups, PDF; no plot styles (CTB/STB), plot preview, devices, PUBLISH, polygonal/object viewports, annotative scales | [gaps](gaps.md#features) | +| Inquiry and utilities | 4% | 30% | 30–45 | DIST, AREA, ID, LIST, MEASUREGEOM, CAL, FIND, COUNT; no autosave/recovery, AUDIT, RECOVER, OPTIONS, SPELL, QuickCalc, DWG Compare, ETRANSMIT | [gaps](gaps.md#features) | +| Parametric constraints | 2% | 50% | 20–30 | all 12 geometric and the dimensional constraints, own solver; no DOF report, no G2 Smooth, no ellipse/spline geometry, no inference | [geometry-parity](geometry-parity.md#constraint-solver--50) | +| 3D modelling, visualisation, rendering | 5% | 2% | 300–450 | no UCS, 3D views, orbit, solids, surfaces, meshes, sections, visual styles, materials, lights or rendering; 150 of the 491 menu items | [gaps](gaps.md#features) | +| Collaboration and cloud (Sheet Set Manager, Share, Traces, Markup Import, Activity Insights, DWG Compare) | 2% | 5% | 40–70 | none of these exist | [gaps](gaps.md#features) | +| **Features dimension** | 100% | **≈ 41%** | **735–1,115** | | | + +## By dimension (ready for real work) + +| Dimension | Weight | Parity | Remaining (h) | Kind | Doc | +|---|---:|---:|---|---|---| +| Features (table above) | 35% | 41% | 735–1,115 | estimated | this file | +| UI/UX fidelity (point entry, snaps, tracking, dynamic input, grips, selection, command line, chrome) | 15% | 43% | 100–160 | estimated, weighted in the doc | [ui-parity](ui-parity.md) | +| File formats (DWG/DXF fidelity first) | 20% | 30% | 170–280 | estimated | [file-format-parity](file-format-parity.md) | +| Stability and correctness | 10% | 35% | 30–50 | estimated: about 60 bug fixes merged on 2026-10-10 alone, 30 open issues, `cargo fmt` failing on main (PR #194), no CI runs the Rust tests on pull requests | [gaps](gaps.md#stability) | +| Performance | 5% | 60% | 20–40 | internal numbers only (200k entities fluent); 1M entities and large DWG loads untested; a 10 MB DWG took ~2 min to fail (#57) | [hardware-parity](hardware-parity.md#performance-on-hardware-internal-numbers-only) | +| Ecosystem and automation (AutoLISP/Visual LISP, .NET/ObjectARX, CUI, APPLOAD, Express Tools, App Store) | 5% | 10% | 120–200 | estimated; MCP, CLI and the control channel are ahead of AutoCAD, but no LISP, CUI or plug-ins | [gaps](gaps.md#ecosystem-and-automation) | +| Hardware | 3% | 45% | 15–25 | estimated | [hardware-parity](hardware-parity.md) | +| Platforms | 3% | 75% | 15–25 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web: more than AutoCAD; but the web build can't open or save files reliably (#55, #133, #134) and has no DWG, and Windows/Linux get little runtime testing | [gaps](gaps.md#platforms) | +| Localization | 3% | 5% | 60–100 | measured: string catalog with Ukrainian (936 rows, #36); none of AutoCAD for Mac's 7 other languages | [localization-parity](localization-parity.md) | +| AI features (Autodesk Assistant, Smart Blocks, Markup Assist) | 1% | 20% | 40–80 | none of AutoCAD's in-app AI; agent control over MCP is a different strength | [gaps](gaps.md#ai-features) | +| **Ready for real work** | 100% | **≈ 38%** | **≈ 1,300–2,100** | | | + +The dimension weights describe what decides whether a professional can switch: features and +file compatibility dominate. + +## Remaining effort and how it was calibrated + +**Calibration.** The Claude Code transcripts of the session that built CADCraft show its whole +first version (about 51,000 lines, 290 commands, 47% menu breadth) took **about 11.5 Opus 5.5 +agent-hours**: 6.9 hours of the lead agent (2026-10-07 02:16–09:08 UTC) plus about 4.6 hours of +eight sub-agents, some in parallel. (Git commit dates were rewritten earlier than that session +and can't be used.) That is about 25 commands per agent-hour **at presence quality**. Depth costs +far more: the next day external contributors found and fixed about 60 correctness bugs, each a +small PR with a regression test, and the audits in this pass found dozens of prompt options that +only print "not available yet". The rates used here are: an option or bug fix 0.3–0.7 h; a +moderate feature (object snap tracking, editable dynamic input, a dialog) 2–5 h; a subsystem +(xrefs, dynamic blocks, AutoLISP, plot styles) 15–100 h; the 3D kernel 300–450 h; then a 1.3× +tail factor for verification against AutoCAD. + +**Why the total went up.** The 2026-10-07 roadmap put full parity at ≈ 29% with ≈ 570 h left, +from agent-reported milestone percentages. This pass audited behaviour instead of presence and +found new evidence: object snap tracking, extension, parallel and apparent snaps are toggles; +dynamic input is read-only; saves drop every entity the reader doesn't model; DWG always saves as +R2000; tolerances are absolute; and the old estimate had no rows for localization, ecosystem, +platforms, stability or AI. The weighted figure rose (29% → 41% features) because breadth and +depth both grew since 2026-10-07 and the weights are now written down; the hours rose because +the remaining work is depth and 3D. + +**Parallelism.** About 70% of the work splits across 4–6 agents by area (each feature area, +file formats, UI precision, localization per language), so elapsed time is roughly a quarter of +the agent-hours. Needs a human: native-speaker review of every translation; a licensed real-file +DWG/DXF corpus (files from people who own them); black-box checks in AutoCAD of what we save; +3D mouse and tablet hardware; an owner decision on plug-in hosting and on DGN. + +## History of this assessment + +| Date | Feature breadth | Weighted parity | Remaining | Note | +|---|---|---|---|---| +| 2026-10-10 (merge) | unchanged (233/491 re-scanned) | ready ≈ 38% (37.7%), mainstream ≈ 28%, essentials ≈ 46% | unchanged within rounding | main landed dynamic input pointer boxes (#60), the Ukrainian catalog (#36), WIPEOUT/gradient DXF fixes (#147, #162): UI/UX 40% → 43%, localization 3% → 5% | +| 2026-10-10 (later) | unchanged | ready for real work ≈ 37% (unchanged: already built from the written dimension weights, no hidden judgement); mainstream practitioner ≈ 27%; essentials user ≈ 45% | unchanged | mainstream and essentials numbers added (craftrules standard) | +| 2026-10-10 | 245/491 (50%), engine-only 233/491 | features ≈ 41%, ready for real work ≈ 37% | 1,300–2,100 h | full re-measure in this format (this file) | +| 2026-10-07 | 233/491 (47%) | ≈ 29% overall, 2D ≈ 55% | ≈ 570 h | old ROADMAP: milestone percentages reported by the building agents | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Merged main: dynamic input pointer boxes (#60) lift UI/UX to 43%, Ukrainian catalog (#36) lifts localization to 5%; ready 37% → 38%, mainstream 27% → 28%, essentials 45% → 46% | +| 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for full (1,150–1,850 h), mainstream (570–880 h) and essentials (140–225 h); full number confirmed as an additive weighted sum, unchanged | +| 2026-10-10 | minor | Mainstream-practitioner (≈ 27%) and essentials-user (≈ 45%) numbers with written weights, discounts and user evidence | +| 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate; remaining-to-alpha row | +| 2026-10-10 | major | Created from the old ROADMAP's parity section; full re-measure with weights, two numbers, per-area hours and calibration from the build session's transcripts | diff --git a/docs/ui-parity.md b/docs/ui-parity.md new file mode 100644 index 00000000..b9d65a1b --- /dev/null +++ b/docs/ui-parity.md @@ -0,0 +1,199 @@ +# UI parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (dynamic input pointer boxes #60 and press-drag selection windows #63 landed) · **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 +comes from these, so "the command exists" isn't enough: it has to accept the same input, snap to +the same points and respond to the same keys. Work items: [gaps.md](gaps.md#uiux). + +**Headline (estimated): UI/UX fidelity ≈ 43%.** The command line, point syntax, the common +running snaps, polar/ortho and the palettes are real. Object snap tracking, extension, parallel +and apparent-intersection snaps are toggles with no behaviour; dynamic input is a read-only +tooltip; FROM, M2P, TK and point filters don't exist; hot-grip options are advertised but +ignored; there are no context menus. Remaining: **≈ 110–170 Opus 5.5 hours**. + +## How this was measured + +- CADCraft: read from the source on 2026-10-10 (main at bd2412f): `crates/engine/src/{prompt, + snap, select, grips, lib, units}.rs`, `cmd/*`, `crates/ui-egui/src/*`. Not run in this pass. +- AutoCAD: behaviour observed black-box in the running AutoCAD 2027 for Mac + (`plan/autocad/01-observed-ui.md`, local only, with reference screenshots that are never + committed), its menu tree (`xtask/data/menu-catalog.txt`) and Autodesk's documentation. +- Weights (how often a drafter hits each area): point entry 15%, object snaps 15%, tracking and + polar 10%, dynamic input 10%, grips 10%, selection 10%, command line 10%, chrome and palettes + 10%, navigation 5%, keyboard and context menus 5%. + +| Area | Weight | CADCraft | Weighted | +|---|---:|---:|---:| +| Point entry | 15% | 45% | 6.8 | +| Object snaps | 15% | 40% | 6.0 | +| Tracking, polar, ortho, grid/snap | 10% | 30% | 3.0 | +| Dynamic input | 10% | 45% | 4.5 | +| Grips | 10% | 40% | 4.0 | +| Selection | 10% | 40% | 4.0 | +| Command line | 10% | 60% | 6.0 | +| Chrome and palettes | 10% | 45% | 4.5 | +| Navigation | 5% | 55% | 2.8 | +| Keyboard shortcuts, context menus | 5% | 25% | 1.3 | +| **Total** | 100% | | **≈ 43%** | + +## Point entry (≈ 45%) + +| Input | AutoCAD | CADCraft | Evidence | +|---|---|---|---| +| `x,y[,z]`, `@dx,dy`, `@d ... pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh) "; diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index 53eea4fc..6adee8bd 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -1,6 +1,7 @@ //! `cargo xtask parity`: compare the reference app's menu tree (feature names only, in //! `xtask/data/menu-catalog.txt`) with the live command registry (`cadcraft-cli commands`), and -//! write `docs/parity.md`. +//! write `docs/parity-checklist.md` (dated; the assessment that interprets it is +//! `docs/target-app-parity.md`). use std::path::Path; use std::process::Command; @@ -24,6 +25,30 @@ pub fn leaves(catalog: &str) -> Vec { out } +/// Where the generated checklist goes. +pub const OUT: &str = "docs/parity-checklist.md"; + +/// Today's UTC date as `YYYY-MM-DD` (from the system clock; no date crate needed). +fn today() -> String { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let (y, m, d) = civil_from_days((secs / 86_400) as i64); + format!("{y:04}-{m:02}-{d:02}") +} + +/// Days since 1970-01-01 → (year, month, day) in the proleptic Gregorian calendar. +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let y = yoe + era * 400 + i64::from(m <= 2); + (y, m, d) +} + pub fn run(root: &Path) -> Result<(), String> { let catalog = std::fs::read_to_string(root.join("xtask/data/menu-catalog.txt")).map_err(|e| format!("menu catalog: {e}"))?; let out = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())) @@ -67,7 +92,8 @@ pub fn run(root: &Path) -> Result<(), String> { } let pct = |a: usize, b: usize| if b == 0 { 0.0 } else { 100.0 * a as f64 / b as f64 }; let mut md = format!( - "# Parity report\n\nGenerated by `cargo xtask parity`. 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 ROADMAP.md for the weighted estimate.\n\n**Menu breadth: {hit} / {} items ({:.0}%)** · {} registered commands.\n\n| Menu | Items | Covered | % |\n|---|---|---|---|\n", + "# Parity checklist\n\n> **Generated:** {} by `cargo xtask parity`. Do not edit by hand.\n\nMenu-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.\n\n**Menu breadth: {hit} / {} items ({:.0}%)** · {} registered commands.\n\n| Menu | Items | Covered | % |\n|---|---|---|---|\n", + today(), ls.len(), pct(hit, ls.len()), cmds.as_array().map(Vec::len).unwrap_or(0) @@ -79,8 +105,8 @@ pub fn run(root: &Path) -> Result<(), String> { for m in &missing { md += &format!("- {m}\n"); } - std::fs::write(root.join("docs/parity.md"), md).map_err(|e| format!("docs/parity.md: {e}"))?; - println!("parity: {hit}/{} menu items ({:.0}%) → docs/parity.md", ls.len(), pct(hit, ls.len())); + std::fs::write(root.join(OUT), md).map_err(|e| format!("{OUT}: {e}"))?; + println!("parity: {hit}/{} menu items ({:.0}%) → {OUT}", ls.len(), pct(hit, ls.len())); Ok(()) } @@ -88,6 +114,13 @@ pub fn run(root: &Path) -> Result<(), String> { mod tests { use super::*; + #[test] + fn civil_dates() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + assert_eq!(civil_from_days(11_016), (2000, 2, 29)); + assert_eq!(civil_from_days(20_736), (2026, 10, 10)); + } + #[test] fn leaves_with_paths() { let c = "Draw\n Line\n Circle\n 3 Points\n 2 Points\nModify\n Move\n"; From 2e3dbe029f79e97b7e68a46f2979e29a1f6f276e Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:36:05 +0700 Subject: [PATCH 028/150] fix(geom): preserve three-point circle precision at large coordinates (#210) --- crates/geom/src/curve.rs | 30 +++++++++++++++++++++--------- crates/geom/src/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/crates/geom/src/curve.rs b/crates/geom/src/curve.rs index f657d1a6..c6be8dab 100644 --- a/crates/geom/src/curve.rs +++ b/crates/geom/src/curve.rs @@ -141,17 +141,29 @@ impl Circle { Circle { center, radius: radius.abs() } } pub fn from_3_points(p1: Vec2, p2: Vec2, p3: Vec2) -> Option { - let d = 2.0 * (p1.x * (p2.y - p3.y) + p2.x * (p3.y - p1.y) + p3.x * (p1.y - p2.y)); - if d.abs() < 1e-12 { + // Work in point-relative coordinates. Absolute coordinate squares lose the small + // differences between points in drawings far from the origin. + if !p1.is_finite() || !p2.is_finite() || !p3.is_finite() { return None; } - let s1 = p1.len2(); - let s2 = p2.len2(); - let s3 = p3.len2(); - let ux = (s1 * (p2.y - p3.y) + s2 * (p3.y - p1.y) + s3 * (p1.y - p2.y)) / d; - let uy = (s1 * (p3.x - p2.x) + s2 * (p1.x - p3.x) + s3 * (p2.x - p1.x)) / d; - let c = Vec2::new(ux, uy); - Some(Circle::new(c, c.dist(p1))) + let u = p2 - p1; + let v = p3 - p1; + let det = 2.0 * u.cross(v); + // The collinearity threshold must scale with the triangle, not drawing units. + if !det.is_finite() || det.abs() <= 1e-12 * (u.len() * v.len()).max(1e-300) { + return None; + } + let u2 = u.len2(); + let v2 = v.len2(); + let c = p1 + Vec2::new((u2 * v.y - v2 * u.y) / det, (v2 * u.x - u2 * v.x) / det); + if !c.is_finite() { + return None; + } + let radius = c.dist(p1); + if !radius.is_finite() { + return None; + } + Some(Circle::new(c, radius)) } pub fn from_2_points(p1: Vec2, p2: Vec2) -> Circle { Circle::new(p1.mid(p2), p1.dist(p2) / 2.0) diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 22875b70..d6f1320f 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -181,3 +181,28 @@ fn common_tangents_of_two_circles() { let z = a.common_tangents(&Circle::new(Vec2::new(30.0, 0.0), 0.0)); assert!(!z.is_empty() && z.iter().all(|(p, q)| close(dist(a.center, *p, *q), 10.0) && q.near(Vec2::new(30.0, 0.0), 1e-9))); } + +#[test] +fn circle_three_points_preserves_large_world_coordinates() { + // A unit circle near a survey coordinate. The absolute-square formula suffers + // catastrophic cancellation and returns a center tens of millions of units away. + let origin = 1.0e12; + let c = Circle::from_3_points( + Vec2::new(origin + 1.0, origin), + Vec2::new(origin, origin + 1.0), + Vec2::new(origin - 1.0, origin), + ) + .unwrap(); + assert!(c.center.near(Vec2::new(origin, origin), 1e-3), "{c:?}"); + assert!((c.radius - 1.0).abs() < 1e-3, "{c:?}"); +} + +#[test] +fn circle_three_points_accepts_small_valid_triangles() { + let r = 1.0e-7; + let c = Circle::from_3_points(Vec2::new(r, 0.0), Vec2::new(0.0, r), Vec2::new(-r, 0.0)).unwrap(); + assert!(c.center.near(Vec2::ZERO, 1e-12), "{c:?}"); + assert!((c.radius - r).abs() < 1e-12, "{c:?}"); + assert!(Circle::from_3_points(Vec2::ZERO, Vec2::new(r, 0.0), Vec2::new(2.0 * r, 0.0)).is_none()); + assert!(Circle::from_3_points(Vec2::ZERO, Vec2::X, Vec2::new(f64::NAN, 0.0)).is_none()); +} From 5a214a0cac4dd65457fbe6213a2c2d5a2be4a91e Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:36:18 +0200 Subject: [PATCH 029/150] Reject layer.set atomically: a refused call no longer applies other changes (#71) (#188) --- crates/engine/src/cmd/layer.rs | 42 ++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 7e6a7b82..d902ec6a 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -172,22 +172,28 @@ fn run_new(s: &mut Session, p: &Value) -> Result { fn run_set(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("layer.set", "`name` is required"))?.to_string(); let new_name = str_param(p, "newName").map(str::to_string); - let d = s.doc_mut()?; + // Validate on a copy before borrowing the drawing mutably, so a rejected call + // leaves the layer untouched and records no undo step. + let d = s.doc()?; let cur = d.header.str("CLAYER", "0"); - let l = d.layer_mut(&name).ok_or_else(|| bad("layer.set", format!("no layer `{name}`")))?; - apply(l, p)?; - if p.get("frozen").and_then(Value::as_bool) == Some(true) && l.name.eq_ignore_ascii_case(&cur) { - l.frozen = false; + let mut updated = d.layer(&name).cloned().ok_or_else(|| bad("layer.set", format!("no layer `{name}`")))?; + let old = updated.name.clone(); + apply(&mut updated, p)?; + if p.get("frozen").and_then(Value::as_bool) == Some(true) && old.eq_ignore_ascii_case(&cur) { return Err(bad("layer.set", "cannot freeze the current layer")); } - if let Some(nn) = new_name { - if l.name == "0" || l.name.eq_ignore_ascii_case("Defpoints") { + if let Some(nn) = &new_name { + if old == "0" || old.eq_ignore_ascii_case("Defpoints") { return Err(bad("layer.set", "cannot rename layer 0 or Defpoints")); } - if !valid_name(&nn) { + if !valid_name(nn) { return Err(bad("layer.set", "invalid layer name")); } - let old = l.name.clone(); + } + let d = s.doc_mut()?; + let l = d.layer_mut(&old).ok_or_else(|| bad("layer.set", format!("no layer `{name}`")))?; + *l = updated; + if let Some(nn) = new_name { l.name = nn.clone(); // Re-point entities. let hs: Vec<_> = d.model.iter().filter(|e| e.common.layer.eq_ignore_ascii_case(&old)).map(|e| e.handle).collect(); @@ -414,4 +420,22 @@ mod tests { let used: Vec = v["layers"].as_array().unwrap().iter().map(|l| l["used"].as_bool().unwrap()).collect(); assert!(used.contains(&true) && used.contains(&false)); } + + #[test] + fn rejected_layer_set_changes_nothing() { + let mut s = Session::new(); + // Layer 0 is current; freezing it is rejected and must not apply the colour either. + let before = s.doc().unwrap().layer("0").unwrap().clone(); + let undo = s.state().unwrap().undo.len(); + assert!(s.execute("layer.set", &json!({ "name": "0", "frozen": true, "color": 3 })).is_err()); + assert_eq!(s.doc().unwrap().layer("0").unwrap(), &before); + assert_eq!(s.state().unwrap().undo.len(), undo, "no undo step for a rejected call"); + // Same for a rejected rename. + assert!(s.execute("layer.set", &json!({ "name": "0", "newName": "X", "color": 3 })).is_err()); + assert_eq!(s.doc().unwrap().layer("0").unwrap(), &before); + assert_eq!(s.state().unwrap().undo.len(), undo, "no undo step for a rejected call"); + // A valid colour-only edit of the current layer still applies. + s.execute("layer.set", &json!({ "name": "0", "color": 3 })).unwrap(); + assert_ne!(s.doc().unwrap().layer("0").unwrap(), &before); + } } From da4aa413c69cfd30eeea70cc7162c582290f3e42 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:36:27 +0200 Subject: [PATCH 030/150] Reject two-line angular constraints over 180 degrees that can never be satisfied (#73) (#190) --- crates/constraints/src/residual.rs | 5 ++++- crates/constraints/src/tests.rs | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/constraints/src/residual.rs b/crates/constraints/src/residual.rs index eb2131a6..fa6b4703 100644 --- a/crates/constraints/src/residual.rs +++ b/crates/constraints/src/residual.rs @@ -244,7 +244,10 @@ impl Built { return need("two lines or an arc"); } b.target = target.to_radians(); - if !b.target.is_finite() || b.target.abs() > std::f64::consts::TAU { + // Two lines measure a signed angle in [-180, 180] and the sign is re-read from the geometry, so a larger + // target would flip the sign once solved and never be satisfied; only an arc sweep can reach a full turn. + let max = if b.form == Form::LL { std::f64::consts::PI } else { std::f64::consts::TAU }; + if !b.target.is_finite() || b.target.abs() > max { return Err("angle out of range".into()); } } diff --git a/crates/constraints/src/tests.rs b/crates/constraints/src/tests.rs index 4924c46a..ece0146e 100644 --- a/crates/constraints/src/tests.rs +++ b/crates/constraints/src/tests.rs @@ -155,6 +155,19 @@ fn symmetric_fix() { ok(&mut d, con(ConstraintKind::Symmetric, &[w(p), w(q), w(axis)])); } +#[test] +fn two_line_angle_over_180_is_rejected() { + let mut d = Drawing::default(); + let a = line(&mut d, (0.0, 0.0), (10.0, 0.0)); + let b = line(&mut d, (0.0, 0.0), (0.0, 5.0)); + let before = (ln(&d, a), ln(&d, b)); + assert!(add(&mut d, dim(ConstraintKind::Angular, &[w(a), w(b)], "", "200"), &SolveOptions::default()).is_err()); + assert!(d.constraints.is_empty()); + assert_eq!((ln(&d, a), ln(&d, b)), before); + ok(&mut d, dim(ConstraintKind::Angular, &[w(a), w(b)], "", "180")); + assert!(all_satisfied(&d)); +} + #[test] fn dimensional_constraints() { let mut d = Drawing::default(); From 6957ef8756d849b2af4405d39f9f770988238068 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:36:37 +0200 Subject: [PATCH 031/150] Keep the active drawing when closing an earlier inactive one (#76) (#192) --- crates/engine/src/cmd/file.rs | 37 +++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 967d8703..c27e23da 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -99,6 +99,10 @@ fn run_close(s: &mut Session, p: &Value) -> Result { } s.cancel(); s.docs.remove(i); + if i < s.active { + // An earlier drawing went away: the active one shifted down by one. + s.active -= 1; + } if s.active >= s.docs.len() { s.active = s.docs.len().saturating_sub(1); } @@ -201,3 +205,36 @@ pub fn base64_decode(s: &str) -> Option> { } Some(out) } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + fn active_title(s: &Session) -> String { + s.state().unwrap().title.clone() + } + + #[test] + fn closing_earlier_inactive_drawing_keeps_active_drawing() { + let mut s = Session::new(); + s.execute("new", &json!({})).unwrap(); + s.execute("new", &json!({})).unwrap(); + s.execute("document.switch", &json!({"index": 1})).unwrap(); + assert_eq!(active_title(&s), "Drawing2"); + s.execute("close", &json!({"index": 0})).unwrap(); + assert_eq!(active_title(&s), "Drawing2"); + assert_eq!(s.docs.len(), 2); + } + + #[test] + fn closing_later_inactive_drawing_keeps_active_drawing() { + let mut s = Session::new(); + s.execute("new", &json!({})).unwrap(); + s.execute("new", &json!({})).unwrap(); + s.execute("document.switch", &json!({"index": 1})).unwrap(); + s.execute("close", &json!({"index": 2})).unwrap(); + assert_eq!(active_title(&s), "Drawing2"); + } +} From 932ba8cc58da9e805275db7adec6e6e53c8f21ac Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:36:48 +0200 Subject: [PATCH 032/150] Respect locked layers in Clear, Cut, Stretch, Trim, Extend, Fillet, Explode and SplineEdit (#87) (#196) --- crates/engine/src/cmd/edit.rs | 6 ++-- crates/engine/src/cmd/modify.rs | 16 ++++++++++- crates/engine/src/cmd/modify2.rs | 9 ++++++ crates/engine/src/cmd/modify2_tests.rs | 40 ++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 25310e91..9b615307 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -106,7 +106,8 @@ fn run_copybase(s: &mut Session, p: &Value) -> Result { } fn run_cut(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; + let mut hs = targets(s, p)?; + hs.retain(|h| !super::curves::is_locked(s, *h)); let n = copy_to_clip(s, &hs, None)?; let d = s.doc_mut()?; for h in &hs { @@ -153,7 +154,8 @@ fn run_pasteorig(s: &mut Session, _p: &Value) -> Result { } fn run_clear(s: &mut Session, _p: &Value) -> Result { - let hs = s.selection(); + let mut hs = s.selection(); + hs.retain(|h| !super::curves::is_locked(s, *h)); let d = s.doc_mut()?; let mut n = 0; for h in hs { diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index cfc43207..a665962e 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -264,7 +264,8 @@ fn run_stretch(s: &mut Session, p: &Value) -> Result { let bx = cadcraft_geom::Bounds2::new(*a, *b); let d = delta_of("stretch", p)?; let space = s.space(); - let hs = crate::select::select_window(s.doc()?, &space, bx, true); + let mut hs = crate::select::select_window(s.doc()?, &space, bx, true); + hs.retain(|h| !super::curves::is_locked(s, *h)); let doc = s.doc_mut()?; for h in &hs { doc.modify_entity(*h, |e| stretch_entity(e, &bx, d))?; @@ -483,6 +484,9 @@ fn prim_to_segments(p: &Prim) -> Vec { /// Trim `h` at the piece containing `pick`. Returns the handles that replace it. pub(crate) fn trim(s: &mut Session, h: Handle, pick: Vec2, edges: Option<&[Handle]>) -> Result> { + if super::curves::is_locked(s, h) { + return Err(EngineError::Other("The object is on a locked layer.".into())); + } let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; let cut = edge_segments(s, edges, h)?; let pieces: Vec = match &e.kind { @@ -666,6 +670,9 @@ fn param_on(s: &Segment, p: Vec2) -> f64 { /// Extend the end of `h` nearest `pick` to the nearest boundary edge. pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Option<&[Handle]>) -> Result<()> { + if super::curves::is_locked(s, h) { + return Err(EngineError::Other("The object is on a locked layer.".into())); + } let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; let cut = edge_segments(s, edges, h)?; let new_kind = match &e.kind { @@ -762,6 +769,9 @@ pub(crate) fn fillet_lines( radius: f64, chamfer: Option<(f64, f64)>, ) -> Result> { + if super::curves::is_locked(s, h1) || super::curves::is_locked(s, h2) { + return Err(EngineError::Other("The object is on a locked layer.".into())); + } let d = s.doc()?; let e1 = d.entity(h1).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; let e2 = d.entity(h2).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; @@ -980,6 +990,9 @@ fn explode(s: &mut Session, hs: &[Handle]) -> Result> { let space = s.space(); let mut out = Vec::new(); for h in hs { + if super::curves::is_locked(s, *h) { + continue; + } let Some(e) = s.doc()?.entity(*h).map(|e| (**e).clone()) else { continue }; let Some(parts) = explode_kind(s.doc()?, &e) else { continue }; let d = s.doc_mut()?; @@ -1490,6 +1503,7 @@ impl Interactive for SelectThen { self.window = Some(bx); let space = s.space(); self.objs = crate::select::select_window(s.doc()?, &space, bx, true); + self.objs.retain(|h| !super::curves::is_locked(s, *h)); s.echo(format!("{} found", self.objs.len())); s.set_selection(self.objs.clone()); self.sel.done = true; diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index b0a7f720..d1ba72b8 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -689,6 +689,9 @@ pub(crate) fn spline_to_poly(sp: &Spline, precision: usize) -> LwPolyline { fn run_splinedit(s: &mut Session, p: &Value) -> Result { let h = targets(s, p)?.first().copied().ok_or_else(|| bad("splinedit", "`handle` is required"))?; let e = curves::entity(s, h)?; + if curves::is_locked(s, h) { + return Err(other("The object is on a locked layer.")); + } let EntityKind::Spline(sp) = &e.kind else { return Err(other("Object selected is not a spline.")) }; let opt = str_param(p, "option").ok_or_else(|| bad("splinedit", "`option` is required"))?.to_ascii_lowercase(); let k = match opt.as_str() { @@ -1802,6 +1805,9 @@ fn trim_for_fillet(k: &EntityKind, pick: Vec2, t: Vec2) -> Option<(Option Result> { + if curves::is_locked(s, h1) || curves::is_locked(s, h2) { + return Err(other("The object is on a locked layer.")); + } let e1 = curves::entity(s, h1)?; let e2 = curves::entity(s, h2)?; let unsupported = || other("Fillet works on lines, arcs and circles (and polylines with the Polyline option)."); @@ -1858,6 +1864,9 @@ pub(crate) fn fillet_curves(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p /// Fillet every corner between two straight segments of a polyline. Returns the count. pub(crate) fn fillet_polyline(s: &mut Session, h: Handle, r: f64) -> Result { + if curves::is_locked(s, h) { + return Err(other("The object is on a locked layer.")); + } let e = curves::entity(s, h)?; let EntityKind::LwPolyline(pl) = &e.kind else { return Err(other("Select a 2D polyline.")) }; let vs = &pl.vertices; diff --git a/crates/engine/src/cmd/modify2_tests.rs b/crates/engine/src/cmd/modify2_tests.rs index 4746ac6c..c2b541f6 100644 --- a/crates/engine/src/cmd/modify2_tests.rs +++ b/crates/engine/src/cmd/modify2_tests.rs @@ -641,3 +641,43 @@ fn explode_polyline3d_keeps_elevations() { .collect(); assert_eq!(lines, vec![(5.0, 6.0), (6.0, 7.0)]); } + +/// Snapshot of every model entity, to prove a command left the drawing untouched. +fn model_dump(s: &Session) -> String { + format!("{:?}", s.doc().unwrap().model.iter().map(|e| e.kind.clone()).collect::>()) +} + +#[test] +fn editing_commands_leave_locked_layer_objects_alone() { + let mut s = Session::new(); + let a = h(&s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap()); + let b = h(&s.execute("line", &json!({"points": [[5, -5], [5, 5]]})).unwrap()); + let c = h(&s.execute("line", &json!({"points": [[0, 0], [0, 10]]})).unwrap()); + let e = h(&s.execute("line", &json!({"points": [[0, 10], [3, 10]]})).unwrap()); + let r = h(&s.execute("rectang", &json!({"p1": [20, 0], "p2": [25, 5]})).unwrap()); + let sp = h(&s.execute("spline", &json!({"control": [[0, 20], [2, 23], [4, 23], [6, 20]], "degree": 3})).unwrap()); + s.execute("layer.set", &json!({"name": "0", "locked": true})).unwrap(); + let before = model_dump(&s); + // Commands that reject the request. + assert!(s.execute("trim", &json!({"handle": a, "pick": [2, 0], "edges": [b]})).is_err()); + assert!(s.execute("extend", &json!({"handle": e, "pick": [3, 10], "edges": [b]})).is_err()); + assert!(s.execute("fillet", &json!({"h1": a, "p1": [8, 0], "h2": c, "p2": [0, 8], "radius": 2})).is_err()); + assert!(s.execute("splinedit", &json!({"handle": sp, "option": "move", "index": 1, "to": [2, 29]})).is_err()); + // Commands that skip the locked objects. + s.execute("select", &json!({"handles": [a, r]})).unwrap(); + assert_eq!(s.execute("erase.selection", &json!({})).unwrap()["erased"], 0); + assert_eq!(s.execute("cutclip", &json!({"handles": [a]})).unwrap()["cut"], 0); + assert_eq!(s.execute("stretch", &json!({"window": [[-1, -1], [11, 1]], "delta": [10, 0]})).unwrap()["stretched"], 0); + s.execute("explode", &json!({"handles": [r]})).unwrap(); + // The interactive STRETCH (crossing window, base point, second point) too. + s.start("stretch").unwrap(); + for (x, y) in [(-1.0, -1.0), (11.0, 1.0), (0.0, 0.0), (10.0, 0.0)] { + s.input(crate::Input::Point(Vec2::new(x, y))).unwrap(); + } + assert_eq!(model_dump(&s), before); + // Unlocked, the same edits work. + s.execute("layer.set", &json!({"name": "0", "locked": false})).unwrap(); + s.execute("trim", &json!({"handle": a, "pick": [2, 0], "edges": [b]})).unwrap(); + s.execute("explode", &json!({"handles": [r]})).unwrap(); + assert_ne!(model_dump(&s), before); +} From e5a91f2eee50a2449cbcf96618f8f418f888eb4e Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:36:53 +0200 Subject: [PATCH 033/150] Keep viewport layer colour overrides through DXF save and reopen (#99) (#197) --- crates/io/src/dxf_ext.rs | 19 +++++++++++++++++++ crates/io/src/dxf_read.rs | 2 +- crates/io/src/dxf_write.rs | 10 ++++++++-- crates/io/src/tests.rs | 37 +++++++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/crates/io/src/dxf_ext.rs b/crates/io/src/dxf_ext.rs index 3adafabe..162d3e37 100644 --- a/crates/io/src/dxf_ext.rs +++ b/crates/io/src/dxf_ext.rs @@ -365,6 +365,25 @@ pub(crate) fn read_frozen(tags: &[Tag]) -> Vec { list.iter().take(MAX_XDATA_ITEMS).filter(|t| t.code == 1000).map(Tag::str).collect() } +/// A viewport's layer colour overrides as CADCraft xdata: `1000 VPCOLORS`, `1002 {`, then per override +/// `1000 layer` and `1000 colour` (the [`Color::name`] text, which keeps true colours), `1002 }`. +pub(crate) fn layer_colors_xdata(colors: &[(String, Color)]) -> Vec { + let mut v = vec![Tag::s(1000, "VPCOLORS"), Tag::s(1002, "{")]; + for (layer, color) in colors { + v.push(Tag::s(1000, layer.clone())); + v.push(Tag::s(1000, color.name())); + } + v.push(Tag::s(1002, "}")); + v +} + +/// The overrides written by [`layer_colors_xdata`]; empty for files from other writers, bad pairs are skipped. +pub(crate) fn read_layer_colors(tags: &[Tag]) -> Vec<(String, Color)> { + let list = xdata_list(xdata(tags, APP), "VPCOLORS"); + let names: Vec = list.iter().take(MAX_XDATA_ITEMS * 2).filter(|t| t.code == 1000).map(Tag::str).collect(); + names.chunks_exact(2).filter_map(|p| Some((p.first()?.clone(), Color::parse(p.get(1)?)?))).collect() +} + const POINT_NAMES: [&str; 5] = ["defpt", "p13", "p14", "p15", "p16"]; /// Associativity links from CADCraft xdata; malformed links are skipped. diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index f4a06915..f8738191 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -307,7 +307,7 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { id: t.i(69).unwrap_or(0) as u32, locked: t.i(90).unwrap_or(0) & 16384 != 0, frozen_layers: crate::dxf_ext::read_frozen(tags), - layer_colors: Vec::new(), + layer_colors: crate::dxf_ext::read_layer_colors(tags), }), "WIPEOUT" => { let o = t.p(10); diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index f24f063f..5f999e23 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -792,9 +792,15 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx w.f(45, v.view_height); // Status flags: 16384 = display locked. w.i(90, if v.locked { 16384 } else { 0 }); - if !v.frozen_layers.is_empty() { + // One CADCraft xdata group holds both lists: readers stop at the first group of an app. + if !v.frozen_layers.is_empty() || !v.layer_colors.is_empty() { w.s(1001, dxf_ext::APP); - w.xdata(dxf_ext::frozen_xdata(&v.frozen_layers)); + if !v.frozen_layers.is_empty() { + w.xdata(dxf_ext::frozen_xdata(&v.frozen_layers)); + } + if !v.layer_colors.is_empty() { + w.xdata(dxf_ext::layer_colors_xdata(&v.layer_colors)); + } } } EntityKind::Wipeout(wo) => { diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index a473503d..e2da8fa0 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -571,6 +571,43 @@ fn viewport_frozen_layers_roundtrip() { assert!(frozen.contains(&(3, Vec::new())), "{frozen:?}"); } +#[test] +fn viewport_layer_colors_roundtrip() { + let mut d = sample(); + let paper = Space::Paper("Layout1".into()); + let vp = |id: u32, frozen: Vec, colors: Vec<(String, Color)>| { + EntityKind::Viewport(Viewport { + center: Vec3::new(5.0, 4.0, 0.0), + width: 8.0, + height: 6.0, + view_center: Vec2::new(5.0, 2.5), + view_height: 12.0, + id, + locked: false, + frozen_layers: frozen, + layer_colors: colors, + }) + }; + let colors = vec![("Walls".to_string(), Color::Index(5)), ("A B".to_string(), Color::True(cadcraft_color::Rgb(255, 128, 0)))]; + d.add(&paper, Common::default(), vp(2, Vec::new(), colors.clone())).unwrap(); + d.add(&paper, Common::default(), vp(3, Vec::new(), Vec::new())).unwrap(); + // Frozen layers and colour overrides share one CADCraft xdata group. + d.add(&paper, Common::default(), vp(4, vec!["Walls".into()], colors.clone())).unwrap(); + let back = roundtrip(&d); + let found: Vec<(u32, Vec, Vec<(String, Color)>)> = back + .layouts + .iter() + .flat_map(|l| l.entities.iter()) + .filter_map(|e| match &e.kind { + EntityKind::Viewport(v) => Some((v.id, v.frozen_layers.clone(), v.layer_colors.clone())), + _ => None, + }) + .collect(); + assert!(found.contains(&(2, Vec::new(), colors.clone())), "{found:?}"); + assert!(found.contains(&(3, Vec::new(), Vec::new())), "{found:?}"); + assert!(found.contains(&(4, vec!["Walls".to_string()], colors)), "{found:?}"); +} + fn full_dim_style() -> DimStyle { DimStyle { name: "Mech".into(), From b265cb17727a6248d81d19f5bf0819b883002447 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 20:37:01 +0200 Subject: [PATCH 034/150] DXF: keep saved layer states through save and reopen (fixes #201) (#203) LAYERSTATE.SAVE stored its states in Drawing::layer_states, but the DXF writer never wrote them and the reader never read any, so every saved layer state was gone after saving and reopening (DWG too, as it is written from the same DXF). They are now written like the parametric constraints: as JSON in a CADCRAFT_LAYERSTATES XRECORD of the named object dictionary, read back with the same payload and size limits. The XRECORD chunking and reading are shared by both records. Co-authored-by: Claude Opus 5.5 --- crates/io/src/dxf_ext.rs | 50 +++++++++++++++++++++++++++++++++----- crates/io/src/dxf_read.rs | 34 ++++++++++++++++---------- crates/io/src/dxf_write.rs | 22 +++++++++++------ crates/io/src/tests.rs | 29 ++++++++++++++++++++++ 4 files changed, 108 insertions(+), 27 deletions(-) diff --git a/crates/io/src/dxf_ext.rs b/crates/io/src/dxf_ext.rs index 162d3e37..e973a01a 100644 --- a/crates/io/src/dxf_ext.rs +++ b/crates/io/src/dxf_ext.rs @@ -5,13 +5,13 @@ //! constraints). //! //! CADCraft's own data lives under the registered application `CADCRAFT` (xdata) and the -//! named-object-dictionary entry `CADCRAFT_CONSTRAINTS` (an XRECORD). Other readers keep or -//! ignore both. +//! named-object-dictionary entries `CADCRAFT_CONSTRAINTS` and `CADCRAFT_LAYERSTATES` +//! (XRECORDs). Other readers keep or ignore them. use std::collections::HashMap; use cadcraft_color::Color; -use cadcraft_doc::{AssocSnap, Constraint, DimAssoc, DimStyle, Handle, Parametric}; +use cadcraft_doc::{AssocSnap, Constraint, DimAssoc, DimStyle, Handle, LayerState, Parametric}; use cadcraft_dxf::Tag; use cadcraft_render::Arrowhead; use serde::{Deserialize, Serialize}; @@ -21,11 +21,14 @@ use serde_json::Value; pub(crate) const APP: &str = "CADCRAFT"; /// Named-object-dictionary key of the constraint XRECORD. pub(crate) const CONSTRAINTS_KEY: &str = "CADCRAFT_CONSTRAINTS"; +/// Named-object-dictionary key of the saved layer states XRECORD. +pub(crate) const LAYER_STATES_KEY: &str = "CADCRAFT_LAYERSTATES"; /// Caps for hostile input. pub(crate) const MAX_XDATA_ITEMS: usize = 4096; pub(crate) const MAX_PAYLOAD: usize = 64 << 20; pub(crate) const MAX_CONSTRAINTS: usize = 1_000_000; +pub(crate) const MAX_LAYER_STATES: usize = 100_000; /// How a dimension variable is stored. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -442,13 +445,18 @@ struct Payload { } /// JSON chunks (≤ 250 characters) for the constraint XRECORD, or `None` when the drawing has -/// no parametric data. Backslashes are written as `\` so no chunk contains a DXF -/// `\U+` escape. +/// no parametric data. pub(crate) fn constraint_chunks(constraints: &[Constraint], parametric: &Parametric) -> Option> { if constraints.is_empty() && *parametric == Parametric::default() { return None; } let json = serde_json::to_string(&Payload { version: 1, constraints: constraints.to_vec(), parametric: parametric.clone() }).ok()?; + Some(json_chunks(&json)) +} + +/// JSON text split into XRECORD strings of at most 250 characters. Backslashes are written as +/// `\u005c` so no chunk contains a DXF `\U+` escape. +fn json_chunks(json: &str) -> Vec { let mut safe = String::with_capacity(json.len()); let mut it = json.chars().peekable(); while let Some(c) = it.next() { @@ -466,7 +474,37 @@ pub(crate) fn constraint_chunks(constraints: &[Constraint], parametric: &Paramet } } let chars: Vec = safe.chars().collect(); - Some(chars.chunks(250).map(|c| c.iter().collect()).collect()) + chars.chunks(250).map(|c| c.iter().collect()).collect() +} + +/// The saved layer states stored in the `CADCRAFT_LAYERSTATES` XRECORD. +#[derive(Serialize, Deserialize)] +struct LayerStatesPayload { + version: u32, + #[serde(default)] + states: Vec, +} + +/// JSON chunks for the layer states XRECORD, or `None` when the drawing has no saved layer +/// states. +pub(crate) fn layer_state_chunks(states: &[LayerState]) -> Option> { + if states.is_empty() { + return None; + } + let json = serde_json::to_string(&LayerStatesPayload { version: 1, states: states.to_vec() }).ok()?; + Some(json_chunks(&json)) +} + +/// Parse the layer states XRECORD text; `None` when it is not a payload we understand. +pub(crate) fn parse_layer_states(text: &str) -> Option> { + if text.len() > MAX_PAYLOAD { + return None; + } + let p: LayerStatesPayload = serde_json::from_str(text).ok()?; + if p.version != 1 || p.states.len() > MAX_LAYER_STATES { + return None; + } + Some(p.states) } /// Parse the constraint XRECORD text; `None` when it is not a payload we understand. diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index f8738191..fbce6bac 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -1073,6 +1073,20 @@ impl Objects { } } + /// The text of the CADCraft XRECORD stored under `key` in a dictionary (its 1/3 strings). + fn xrecord_text(&self, key: &str) -> Option { + let h = self.names.iter().find(|(_, n)| n.as_str() == key).map(|(h, _)| h)?; + let tags = self.xrecords.get(h)?; + let mut text = String::new(); + for t in tags.iter().filter(|t| t.code == 1 || t.code == 3) { + if text.len() > crate::dxf_ext::MAX_PAYLOAD { + break; + } + text.push_str(&t.str()); + } + Some(text) + } + fn apply(self, d: &mut Drawing, rx: &Rx) { // Table styles (named by their ACAD_TABLESTYLE dictionary entries). for (h, tags) in &self.table_styles { @@ -1102,19 +1116,13 @@ impl Objects { } } // Parametric constraints and parameters. - let key = self.names.iter().find(|(_, n)| n.as_str() == crate::dxf_ext::CONSTRAINTS_KEY).map(|(h, _)| h.clone()); - if let Some(tags) = key.and_then(|k| self.xrecords.get(&k)) { - let mut text = String::new(); - for t in tags.iter().filter(|t| t.code == 1 || t.code == 3) { - if text.len() > crate::dxf_ext::MAX_PAYLOAD { - break; - } - text.push_str(&t.str()); - } - if let Some((c, p)) = crate::dxf_ext::parse_constraints(&text) { - d.constraints = c; - d.parametric = p; - } + if let Some((c, p)) = self.xrecord_text(crate::dxf_ext::CONSTRAINTS_KEY).and_then(|t| crate::dxf_ext::parse_constraints(&t)) { + d.constraints = c; + d.parametric = p; + } + // Saved layer states. + if let Some(states) = self.xrecord_text(crate::dxf_ext::LAYER_STATES_KEY).and_then(|t| crate::dxf_ext::parse_layer_states(&t)) { + d.layer_states = states; } // Standard associativity, for dimensions without CADCraft's exact links. for tags in &self.dimassocs { diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 5f999e23..9e8c9a4b 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -1177,12 +1177,18 @@ pub fn write(d: &Drawing) -> String { let default_table_style = [TableStyle::default()]; let table_styles: &[TableStyle] = if d.table_styles.is_empty() { &default_table_style } else { &d.table_styles }; cx.table_styles = table_styles.iter().map(|s| (s.name.clone(), w.h())).collect(); - let constraint_chunks = dxf_ext::constraint_chunks(&d.constraints, &d.parametric); let root_dict = w.h(); let group_dict = w.h(); let layout_dict = w.h(); let table_style_dict = w.h(); - let constraints_xrec = w.h(); + // CADCraft data in XRECORDs of the named object dictionary: (key, handle, text chunks). + let mut xrecords: Vec<(&str, String, Vec)> = Vec::new(); + if let Some(chunks) = dxf_ext::constraint_chunks(&d.constraints, &d.parametric) { + xrecords.push((dxf_ext::CONSTRAINTS_KEY, w.h(), chunks)); + } + if let Some(chunks) = dxf_ext::layer_state_chunks(&d.layer_states) { + xrecords.push((dxf_ext::LAYER_STATES_KEY, w.h(), chunks)); + } let model_layout = w.h(); let layout_handles: Vec = ps_brs.iter().map(|_| w.h()).collect(); @@ -1473,9 +1479,9 @@ pub fn write(d: &Drawing) -> String { w.s(350, layout_dict.clone()); w.s(3, "ACAD_TABLESTYLE"); w.s(350, table_style_dict.clone()); - if constraint_chunks.is_some() { - w.s(3, dxf_ext::CONSTRAINTS_KEY); - w.s(350, constraints_xrec.clone()); + for (key, h, _) in &xrecords { + w.s(3, *key); + w.s(350, h); } w.s(0, "DICTIONARY"); w.s(5, group_dict); @@ -1495,10 +1501,10 @@ pub fn write(d: &Drawing) -> String { for (s, (_, h)) in table_styles.iter().zip(&cx.table_styles) { table_style_obj(&mut w, s, h, &table_style_dict); } - // Parametric constraints and parameters (CADCraft data). - if let Some(chunks) = &constraint_chunks { + // Parametric constraints and parameters, saved layer states (CADCraft data). + for (_, h, chunks) in &xrecords { w.s(0, "XRECORD"); - w.s(5, constraints_xrec.clone()); + w.s(5, h); w.group("ACAD_REACTORS", 330, &[&root_dict]); w.s(330, root_dict.clone()); w.s(100, "AcDbXrecord"); diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index e2da8fa0..09d30d8d 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -945,6 +945,35 @@ fn constraints_and_parameters_roundtrip_exactly() { assert!(!write_dxf(&Drawing::new_imperial()).contains("CADCRAFT_CONSTRAINTS")); } +#[test] +fn layer_states_roundtrip_next_to_constraints() { + let mut d = Drawing::new_imperial(); + let (constraints, _) = parametric_sample(&mut d); + d.layers.push(Layer { + name: "Wände".into(), + color: Color::True(cadcraft_color::Rgb(200, 10, 40)), + transparency: 30, + description: "walls \\ hidden".into(), + ..Layer::default() + }); + d.layer_states.push(LayerState { name: "Plot".into(), layers: d.layers.clone() }); + let mut off = d.layers.clone(); + for l in &mut off { + (l.on, l.frozen, l.locked, l.plot) = (false, true, true, false); + } + d.layer_states.push(LayerState { name: "All off".into(), layers: off }); + let text = write_dxf(&d); + assert!(text.contains("CADCRAFT_LAYERSTATES")); + let back = read_dxf(text.as_bytes()).unwrap(); + assert_eq!(back.layer_states, d.layer_states); + assert_eq!(back.constraints, constraints); + assert_eq!(roundtrip(&back).layer_states, d.layer_states); + #[cfg(not(target_arch = "wasm32"))] + assert_eq!(read(&write(&d, "x.dwg").unwrap(), "x.dwg").unwrap().layer_states, d.layer_states); + // Drawings without saved layer states carry no record. + assert!(!write_dxf(&Drawing::new_imperial()).contains("CADCRAFT_LAYERSTATES")); +} + fn table_sample() -> Table { let cell = |t: &str| TableCell { text: t.into(), merged: None }; let mut rows = vec![ From b4985c5d56cd174b124e69d51c51dfff4349803f Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 20:37:07 +0200 Subject: [PATCH 035/150] DXF: keep block descriptions and constant attribute definitions (fixes #204) (#207) The ATTDEF writer put only the invisible bit into group 70, so constant attribute definitions came back as variable ones: INSERT then created an attribute for them, and other programs stopped drawing their text. The constant bit (2) is now written too. BLOCK's description was never written (BLOCK group 4) nor read, and the insertion units (BLOCK_RECORD 70) and explodability (280) of blocks were neither read nor written. All three now round-trip. Co-authored-by: Claude Opus 5.5 --- crates/io/src/dxf_read.rs | 15 ++++++++--- crates/io/src/dxf_write.rs | 51 ++++++++++++++++++++++++-------------- crates/io/src/tests.rs | 38 ++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 22 deletions(-) diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index fbce6bac..565e3447 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -604,6 +604,8 @@ struct Rx { styles: HashMap, /// BLOCK_RECORD handle (upper case) → block name. brs: HashMap, + /// Upper-case block name → (insertion units, explodable) from its BLOCK_RECORD. + block_props: HashMap, /// Upper-case names of anonymous `*T` blocks drawn by ACAD_TABLE entities. table_blocks: std::collections::HashSet, /// Upper-case block names used by INSERTs. @@ -793,6 +795,8 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { rx.styles.insert(h.trim().to_ascii_uppercase(), n); } "BLOCK_RECORD" => { + let units = t.i(70).and_then(|u| u8::try_from(u).ok()).unwrap_or(0); + rx.block_props.insert(n.to_ascii_uppercase(), (units, t.i(280).unwrap_or(1) != 0)); rx.brs.insert(h.trim().to_ascii_uppercase(), n); } _ => {} @@ -903,7 +907,7 @@ pub fn read(bytes: &[u8]) -> Result { let mut d = Drawing::new_imperial(); d.layouts.clear(); let mut rx = Rx::default(); - let mut blocks: Vec<(String, Vec3, Vec<(Option, bool, Entity)>, bool)> = Vec::new(); + let mut blocks: Vec<(String, Vec3, String, Vec<(Option, bool, Entity)>, bool)> = Vec::new(); let mut layout_objs: Vec<(String, u32, String, PageSetup)> = Vec::new(); let mut entities = Vec::new(); let mut objs = Objects::default(); @@ -919,6 +923,7 @@ pub fn read(bytes: &[u8]) -> Result { let t = T(tg); let name = t.s(2).unwrap_or_default(); let base = t.p(10); + let description = t.s(4).unwrap_or_default(); let xref = t.i(70).unwrap_or(0) & 4 != 0; let mut j = i + 1; while recs.get(j).is_some_and(|(k, _)| k != "ENDBLK") { @@ -926,7 +931,7 @@ pub fn read(bytes: &[u8]) -> Result { } let body = recs.get(i + 1..j).unwrap_or(&[]); let ents = parse_entities(body, &mut d, &mut rx); - blocks.push((name, base, ents, xref)); + blocks.push((name, base, description, ents, xref)); i = j + 1; } else { i += 1; @@ -970,7 +975,7 @@ pub fn read(bytes: &[u8]) -> Result { } let first_layout = d.layouts.first().map(|l| l.name.clone()).unwrap_or_else(|| "Layout1".into()); // Blocks: model/paper space blocks hold entities in newer files. - for (name, base, ents, xref) in blocks { + for (name, base, description, ents, xref) in blocks { let up = name.to_ascii_uppercase(); if up == "*MODEL_SPACE" { for (_, _, e) in ents { @@ -995,6 +1000,10 @@ pub fn read(bytes: &[u8]) -> Result { } let mut b = Block::new(&name); b.base = base; + b.description = description; + if let Some(&(units, explodable)) = rx.block_props.get(&up) { + (b.units, b.explodable) = (units, explodable); + } if xref { b.xref_path = Some(String::new()); } diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 9e8c9a4b..8611cd1d 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -306,7 +306,12 @@ fn common_x(w: &mut W, e: &Entity, owner: &str, paper: bool, subclass: &str, ass } } -fn text_tags(w: &mut W, t: &Text, attrib_tag: Option<(&str, bool)>, attdef_prompt: Option<&str>) { +/// ATTRIB/ATTDEF flags (group 70): 1 invisible, 2 constant. +fn attrib_flags(a: &Attrib) -> i64 { + i64::from(a.invisible) | if a.constant { 2 } else { 0 } +} + +fn text_tags(w: &mut W, t: &Text, attrib_tag: Option<(&str, i64)>, attdef_prompt: Option<&str>) { w.p(10, t.insert); w.f(40, t.height); w.s(1, &t.value); @@ -341,13 +346,13 @@ fn text_tags(w: &mut W, t: &Text, attrib_tag: Option<(&str, bool)>, attdef_promp w.p(11, a); } match attrib_tag { - Some((tag, invisible)) => { + Some((tag, flags)) => { w.s(100, if attdef_prompt.is_some() { "AcDbAttributeDefinition" } else { "AcDbAttribute" }); if let Some(prompt) = attdef_prompt { w.s(3, prompt); } w.s(2, tag); - w.i(70, i64::from(invisible)); + w.i(70, flags); if v != 0 { w.i(74, v); } @@ -482,7 +487,7 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx EntityKind::AttDef(a) => { w.s(0, "ATTDEF"); common(w, e, owner, paper, "AcDbText"); - text_tags(w, &a.text, Some((&a.tag, a.invisible)), Some(&a.prompt)); + text_tags(w, &a.text, Some((&a.tag, attrib_flags(a))), Some(&a.prompt)); } EntityKind::MText(t) => { w.s(0, "MTEXT"); @@ -535,7 +540,7 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx w.s(100, "AcDbEntity"); w.s(8, &e.common.layer); w.s(100, "AcDbText"); - text_tags(w, &a.text, Some((&a.tag, a.invisible)), None); + text_tags(w, &a.text, Some((&a.tag, attrib_flags(a))), None); } let sh = w.h(); w.s(0, "SEQEND"); @@ -1368,7 +1373,7 @@ pub fn write(d: &Drawing) -> String { w.s(0, "ENDTAB"); // BLOCK_RECORD let th = table_head(&mut w, "BLOCK_RECORD", 1 + ps_brs.len() + user_blocks.len() + dim_defs.len() + arrow_defs.len() + table_defs.len()); - let br_rec = |w: &mut W, h: &str, name: &str, layout: Option<&str>| { + let br_rec = |w: &mut W, h: &str, name: &str, layout: Option<&str>, blk: Option<&Block>| { w.s(0, "BLOCK_RECORD"); w.s(5, h); w.s(330, th.clone()); @@ -1376,16 +1381,21 @@ pub fn write(d: &Drawing) -> String { w.s(100, "AcDbBlockTableRecord"); w.s(2, name); w.s(340, layout.unwrap_or("0")); + // User blocks: insertion units (70) and explodability (280). + if let Some(b) = blk { + w.i(70, i64::from(b.units)); + w.i(280, i64::from(b.explodable)); + } }; - br_rec(&mut w, &ms_br, "*Model_Space", Some(&model_layout)); + br_rec(&mut w, &ms_br, "*Model_Space", Some(&model_layout), None); for (i, (n, h, _)) in ps_brs.iter().enumerate() { - br_rec(&mut w, h, n, layout_handles.get(i).map(String::as_str)); + br_rec(&mut w, h, n, layout_handles.get(i).map(String::as_str), None); } - for (n, h, _) in &user_blocks { - br_rec(&mut w, h, n, None); + for (n, h, b) in &user_blocks { + br_rec(&mut w, h, n, None, Some(b)); } for (n, h, _) in dim_defs.iter().chain(&arrow_defs).chain(&table_defs) { - br_rec(&mut w, h, n, None); + br_rec(&mut w, h, n, None, None); } w.s(0, "ENDTAB"); w.s(0, "ENDSEC"); @@ -1393,7 +1403,7 @@ pub fn write(d: &Drawing) -> String { // ---------------- BLOCKS ---------------- w.s(0, "SECTION"); w.s(2, "BLOCKS"); - let block = |w: &mut W, name: &str, brh: &str, base: Vec3, flags: i64, ents: &mut dyn Iterator, paper: bool| { + let block = |w: &mut W, name: &str, desc: &str, brh: &str, base: Vec3, flags: i64, ents: &mut dyn Iterator, paper: bool| { let bh = w.h(); w.s(0, "BLOCK"); w.s(5, bh); @@ -1409,6 +1419,9 @@ pub fn write(d: &Drawing) -> String { w.p(10, base); w.s(3, name); w.s(1, ""); + if !desc.is_empty() { + w.s(4, desc); + } for e in ents { entity(w, d, e, brh, false, &cx); } @@ -1423,16 +1436,16 @@ pub fn write(d: &Drawing) -> String { w.s(8, "0"); w.s(100, "AcDbBlockEnd"); }; - block(&mut w, "*Model_Space", &ms_br, Vec3::ZERO, 0, &mut std::iter::empty(), false); + block(&mut w, "*Model_Space", "", &ms_br, Vec3::ZERO, 0, &mut std::iter::empty(), false); for (i, (n, h, l)) in ps_brs.iter().enumerate() { if i == 0 { - block(&mut w, n, h, Vec3::ZERO, 0, &mut std::iter::empty(), true); + block(&mut w, n, "", h, Vec3::ZERO, 0, &mut std::iter::empty(), true); } else { - block(&mut w, n, h, Vec3::ZERO, 0, &mut l.entities.iter().map(|e| e.as_ref()), true); + block(&mut w, n, "", h, Vec3::ZERO, 0, &mut l.entities.iter().map(|e| e.as_ref()), true); } } for (n, h, b) in &user_blocks { - block(&mut w, n, h, b.base, if b.anonymous { 1 } else { 0 }, &mut b.entities.iter().map(|e| e.as_ref()), false); + block(&mut w, n, &b.description, h, b.base, if b.anonymous { 1 } else { 0 }, &mut b.entities.iter().map(|e| e.as_ref()), false); } // Generated geometry entities (dimension, arrowhead and table blocks) need handles. for (_, _, ents) in dim_defs.iter_mut().chain(arrow_defs.iter_mut()).chain(table_defs.iter_mut()) { @@ -1442,13 +1455,13 @@ pub fn write(d: &Drawing) -> String { } } for (n, h, ents) in &dim_defs { - block(&mut w, n, h, Vec3::ZERO, 1, &mut ents.iter(), false); + block(&mut w, n, "", h, Vec3::ZERO, 1, &mut ents.iter(), false); } for (n, h, ents) in &arrow_defs { - block(&mut w, n, h, Vec3::ZERO, 0, &mut ents.iter(), false); + block(&mut w, n, "", h, Vec3::ZERO, 0, &mut ents.iter(), false); } for (n, h, ents) in &table_defs { - block(&mut w, n, h, Vec3::ZERO, 1, &mut ents.iter(), false); + block(&mut w, n, "", h, Vec3::ZERO, 1, &mut ents.iter(), false); } w.s(0, "ENDSEC"); diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 09d30d8d..8e167796 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -244,6 +244,44 @@ fn layer_transparency_and_description_roundtrip() { assert_eq!(crate::dxf_ext::transparency_from_dxf(0x0200_0000), Some(90)); } +#[test] +fn block_definition_properties_and_constant_attdefs_roundtrip() { + let mut d = Drawing::new_metric(); + let mut b = Block::new("Title"); + (b.description, b.units, b.explodable) = ("Drawing title block".into(), 4, false); + let text = Text { + insert: Vec3::ZERO, + align_pt: None, + height: 2.5, + value: "ACME".into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: HAlign::Left, + valign: VAlign::Baseline, + }; + let attdef = + |tag: &str, invisible: bool, constant: bool| Attrib { tag: tag.into(), text: text.clone(), invisible, constant, prompt: String::new() }; + for (i, a) in [attdef("COMPANY", false, true), attdef("SECRET", true, true), attdef("SHEET", false, false)].into_iter().enumerate() { + b.entities.push(Entity::new(Handle(0x500 + i as u64), EntityKind::AttDef(a))); + } + d.blocks.insert("Title".into(), std::sync::Arc::new(b)); + d.blocks.insert("Plain".into(), std::sync::Arc::new(Block::new("Plain"))); + d.bump_handseed(Handle(0x510)); + let back = roundtrip(&d); + let t = back.block("Title").unwrap(); + assert_eq!((t.description.as_str(), t.units, t.explodable), ("Drawing title block", 4, false)); + let flags: Vec<(String, bool, bool)> = t + .entities + .iter() + .filter_map(|e| if let EntityKind::AttDef(a) = &e.kind { Some((a.tag.clone(), a.invisible, a.constant)) } else { None }) + .collect(); + assert_eq!(flags, [("COMPANY".into(), false, true), ("SECRET".into(), true, true), ("SHEET".into(), false, false)]); + let p = back.block("Plain").unwrap(); + assert_eq!((p.description.as_str(), p.units, p.explodable), ("", 0, true)); +} + #[test] fn reads_r12_style_polyline_and_paper_flag() { let text = "0\nSECTION\n2\nENTITIES\n0\nPOLYLINE\n8\n0\n66\n1\n70\n1\n0\nVERTEX\n8\n0\n10\n0\n20\n0\n0\nVERTEX\n8\n0\n10\n5\n20\n0\n42\n1\n0\nVERTEX\n8\n0\n10\n5\n20\n5\n0\nSEQEND\n0\nLINE\n67\n1\n8\n0\n10\n0\n20\n0\n11\n1\n21\n1\n0\nENDSEC\n0\nEOF\n"; From ec80db936e67f5293a5fa924315c27fa20c66e8c Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 20:37:14 +0200 Subject: [PATCH 036/150] DXF: keep thickness, point angle, hatch origin/background/elevation, xref paths and the built-in font name (#213) (#215) Saving to DXF and reopening lost several entity, block and style properties: - Thickness (group 39) is written for LINE, POINT, CIRCLE, ARC, TEXT, SOLID, TRACE, LWPOLYLINE and POLYLINE when non-zero. - POINT writes its X-axis angle (group 50). - HATCH: the origin goes into the pattern lines' base points (43/44), so other programs see the shift, and travels with the background colour as CADCRAFT xdata (1000 HATCH, 1011 origin, 1000 colour name); the DXF Reference has no groups for either. It is appended to the gradient xdata when there is one. The reader takes the elevation from the z of the first group 10. - Xref blocks get BLOCK 70 bit 4 and their path in group 1, and the reader reads the path. - The built-in "CADCraft Stroke" font is still written as "txt" for other readers; its name travels as CADCRAFT xdata (1000 FONT) and is used while group 3 is still "txt". - Polyface (70 bit 64) and polygon mesh (bit 16) POLYLINEs from other programs are read as 3D faces instead of a 3D polyline through their vertices; face records no longer become vertices at the origin. Bad indices and counts are skipped. Co-authored-by: Claude Opus 5.5 --- crates/io/src/dxf_read.rs | 126 ++++++++++- crates/io/src/dxf_write.rs | 69 +++++- crates/io/tests/entity_props_roundtrip.rs | 262 ++++++++++++++++++++++ 3 files changed, 449 insertions(+), 8 deletions(-) create mode 100644 crates/io/tests/entity_props_roundtrip.rs diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 565e3447..f3da18bc 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -394,6 +394,7 @@ fn acad_table(tags: &[Tag]) -> Table { } fn hatch(tags: &[Tag], t: &T) -> Option { + let (origin, background) = hatch_xdata(tags); let pattern = t.s(2).unwrap_or_else(|| "SOLID".into()); let solid = t.i(70).unwrap_or(0) == 1; let n_loops = t.i(91).unwrap_or(0).clamp(0, 100_000) as usize; @@ -524,13 +525,27 @@ fn hatch(tags: &[Tag], t: &T) -> Option { angle: t.fd(52, 0.0).to_radians(), associative: t.i(71).unwrap_or(0) != 0, style: t.i(75).unwrap_or(0) as u8, - elevation: 0.0, + // The first 30 is the elevation point's z (boundary points are 2D). + elevation: t.f(30).filter(|z| z.is_finite()).unwrap_or(0.0), gradient: gradient(tags), - origin: Vec2::ZERO, - background: None, + origin, + background, })) } +/// Hatch origin and background colour from `CADCRAFT` xdata: `1000 HATCH`, `1011` origin, +/// `1000` background colour name (empty for none). Defaults when absent. +fn hatch_xdata(tags: &[Tag]) -> (Vec2, Option) { + let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP); + let Some(at) = x.windows(2).position(|w| matches!(w, [m, o] if m.code == 1000 && m.str() == "HATCH" && o.code == 1011)) else { + return (Vec2::ZERO, None); + }; + let val = |k: usize, code: i32| x.get(at + k).filter(|t| t.code == code); + let coord = |k: usize, code: i32| val(k, code).map(Tag::f64).filter(|v| v.is_finite()).unwrap_or(0.0); + let origin = Vec2::new(coord(1, 1011), coord(2, 1021)); + (origin, val(4, 1000).and_then(|t| Color::parse(&t.str()))) +} + /// Gradient values missing from a file fall back to the GRADIENT command's defaults. fn default_gradient() -> Gradient { Gradient { name: "LINEAR".into(), color1: Color::Index(5), color2: Color::Index(7), angle: 0.0, centered: true } @@ -612,6 +627,8 @@ struct Rx { inserted: std::collections::HashSet, /// Tables whose style is known only by TABLESTYLE handle: (table, style handle). table_style_fix: Vec<(Handle, String)>, + /// Upper-case xref block name → the external drawing's path (BLOCK group 1). + xref_paths: HashMap, } /// Dimension overrides and associativity, table references: data of an entity record that @@ -653,15 +670,22 @@ fn parse_entities(recs: &[(String, Vec)], d: &mut Drawing, rx: &mut Rx) -> let paper = t.i(67) == Some(1); let handle = t.s(5).and_then(|h| Handle::parse_hex(&h)); let mut kindent = entity(kind, tags); + // More entities from this record (the faces of a polyface or polygon mesh). + let mut extra: Vec = Vec::new(); i += 1; match kind.as_str() { "POLYLINE" => { let flags = t.i(70).unwrap_or(0); let mut pts: Vec<(Vec3, f64, f64, f64)> = Vec::new(); + // Polyface face records (VERTEX 70 bit 128 without 64): vertex indices 71–74. + let mut faces: Vec<[i64; 4]> = Vec::new(); while let Some((k2, t2)) = recs.get(i) { if k2 == "VERTEX" { let tt = T(t2); - if tt.i(70).unwrap_or(0) & 16 == 0 { + let vflags = tt.i(70).unwrap_or(0); + if vflags & 128 != 0 && vflags & 64 == 0 { + faces.push([71, 72, 73, 74].map(|c| tt.i(c).unwrap_or(0))); + } else if vflags & 16 == 0 { pts.push((tt.p(10), tt.fd(42, 0.0), tt.fd(40, 0.0), tt.fd(41, 0.0))); } i += 1; @@ -673,7 +697,17 @@ fn parse_entities(recs: &[(String, Vec)], d: &mut Drawing, rx: &mut Rx) -> } } let c = common(&t); - let k = if flags & (8 | 16 | 64) != 0 { + let k = if flags & (16 | 64) != 0 { + // Polyface (64) and polygon (16) meshes become 3D faces; the first takes the + // polyline's place. + let verts: Vec = pts.iter().map(|p| p.0).collect(); + let mut fs = if flags & 64 != 0 { polyface_faces(&verts, &faces) } else { mesh_faces(&verts, &t, flags) } + .into_iter() + .map(EntityKind::Face3d); + let Some(first) = fs.next() else { continue }; + extra.extend(fs); + first + } else if flags & 8 != 0 { EntityKind::Polyline3d(Polyline3d { points: pts.iter().map(|p| p.0).collect(), closed: flags & 1 != 0 }) } else { EntityKind::LwPolyline(LwPolyline { @@ -724,7 +758,10 @@ fn parse_entities(recs: &[(String, Vec)], d: &mut Drawing, rx: &mut Rx) -> None => d.new_handle(), }; entity_extras(kind, tags, h, &mut k, rx); + let more: Vec<_> = + extra.into_iter().map(|f| (owner.clone(), paper, Entity { handle: d.new_handle(), common: c.clone(), kind: f })).collect(); out.push((owner, paper, Entity { handle: h, common: c, kind: k })); + out.extend(more); } } // Assign fresh handles to duplicates (hostile files). @@ -738,6 +775,59 @@ fn parse_entities(recs: &[(String, Vec)], d: &mut Drawing, rx: &mut Rx) -> out } +/// Faces of a polyface mesh: each face record names three or four vertices by 1-based index; +/// a negative index hides the edge that starts at that vertex. Faces with fewer vertices or +/// an index outside the vertex list are skipped. +fn polyface_faces(verts: &[Vec3], faces: &[[i64; 4]]) -> Vec { + let mut out = Vec::new(); + for f in faces { + let mut corners: Vec = Vec::with_capacity(4); + let mut hidden = 0u8; + let mut ok = true; + for &ix in f.iter().filter(|&&ix| ix != 0) { + let at = usize::try_from(ix.unsigned_abs()).ok().and_then(|n| n.checked_sub(1)); + let Some(p) = at.and_then(|n| verts.get(n)) else { + ok = false; + break; + }; + if ix < 0 { + hidden |= 1 << corners.len(); + } + corners.push(*p); + } + match (ok, corners.as_slice()) { + // A triangle's closing edge is the 3DFACE's fourth (the third is degenerate). + (true, [a, b, c]) => out.push(Face3d { corners: [*a, *b, *c, *c], hidden_edges: hidden | if hidden & 4 != 0 { 8 } else { 0 } }), + (true, [a, b, c, d]) => out.push(Face3d { corners: [*a, *b, *c, *d], hidden_edges: hidden }), + _ => {} + } + } + out +} + +/// Quads of an M×N polygon mesh (71/72, or the smoothed 73/74 when the vertex count matches +/// those), closed in M by bit 1 and in N by bit 32. Vertices run row by row. +fn mesh_faces(verts: &[Vec3], t: &T, flags: i64) -> Vec { + let dims = |a: i32, b: i32| Some((usize::try_from(t.i(a)?).ok()?, usize::try_from(t.i(b)?).ok()?)); + let fits = |(m, n): (usize, usize)| m >= 2 && n >= 2 && m.checked_mul(n).is_some_and(|mn| mn <= verts.len()); + let exact = |(m, n): (usize, usize)| fits((m, n)) && m * n == verts.len(); + let Some((m, n)) = [dims(71, 72), dims(73, 74)].into_iter().flatten().find(|d| exact(*d)).or_else(|| dims(71, 72).filter(|d| fits(*d))) else { + return Vec::new(); + }; + let at = |i: usize, j: usize| verts.get((i % m) * n + j % n).copied(); + let rows = if flags & 1 != 0 { m } else { m - 1 }; + let cols = if flags & 32 != 0 { n } else { n - 1 }; + let mut out = Vec::new(); + for i in 0..rows { + for j in 0..cols { + if let (Some(a), Some(b), Some(c), Some(d)) = (at(i, j), at(i, j + 1), at(i + 1, j + 1), at(i + 1, j)) { + out.push(Face3d { corners: [a, b, c, d], hidden_edges: 0 }); + } + } + } + out +} + fn header(tags: &[Tag], d: &mut Drawing) { let mut i = 0; while let Some(t) = tags.get(i) { @@ -784,6 +874,21 @@ fn annotative(tags: &[Tag]) -> bool { list.iter().filter(|t| t.code == 1070).nth(1).is_some_and(|t| t.i64() != 0) } +/// A text style's font: CADCraft's own font name from `CADCRAFT` xdata (`1000 FONT`, name) +/// while the font file is still the `txt` written for other readers, otherwise the file. +fn style_font(tags: &[Tag], file: String) -> String { + let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP); + let own = x.windows(2).find_map(|w| match w { + [m, n] if m.code == 1000 && m.str() == "FONT" && n.code == 1000 => Some(n.str()), + _ => None, + }); + let stem = file.trim().to_ascii_lowercase(); + match own { + Some(name) if !name.trim().is_empty() && (stem == "txt" || stem == "txt.shx") => name, + _ => file, + } +} + fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { let recs = records(tags); // Handle maps first: DIMSTYLE records refer to text styles and arrow blocks by handle. @@ -856,7 +961,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { let gen_flags = t.i(71).unwrap_or(0); let st = TextStyle { name: name.clone(), - font: t.s(3).unwrap_or_default(), + font: style_font(&tg, t.s(3).unwrap_or_default()), big_font: t.s(4).unwrap_or_default(), height: t.fd(40, 0.0), width_factor: t.fd(41, 1.0), @@ -917,6 +1022,12 @@ pub fn read(bytes: &[u8]) -> Result { "TABLES" => tables(&s.tags, &mut d, &mut rx), "BLOCKS" => { let recs = records(&s.tags); + for (k, tg) in &recs { + let t = T(tg); + if k == "BLOCK" && t.i(70).unwrap_or(0) & 4 != 0 { + rx.xref_paths.insert(t.s(2).unwrap_or_default().to_ascii_uppercase(), t.s(1).unwrap_or_default()); + } + } let mut i = 0; while let Some((k, tg)) = recs.get(i) { if k == "BLOCK" { @@ -1010,6 +1121,9 @@ pub fn read(bytes: &[u8]) -> Result { for (_, _, e) in ents { b.entities.push(e); } + if let Some(path) = rx.xref_paths.remove(&up) { + b.xref_path = Some(path); + } d.blocks.insert(name, std::sync::Arc::new(b)); } // ENTITIES section: model space, or paper space when flagged (67=1). diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 8611cd1d..9427a7ca 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -371,6 +371,7 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx EntityKind::Line(l) => { w.s(0, "LINE"); common(w, e, owner, paper, "AcDbLine"); + thickness(w, e); w.p(10, l.a); w.p(11, l.b); } @@ -378,16 +379,22 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx w.s(0, "POINT"); common(w, e, owner, paper, "AcDbPoint"); w.p(10, p.p); + thickness(w, e); + if p.angle != 0.0 { + w.f(50, p.angle.to_degrees()); + } } EntityKind::Circle(c) => { w.s(0, "CIRCLE"); common(w, e, owner, paper, "AcDbCircle"); + thickness(w, e); w.p(10, c.center); w.f(40, c.radius); } EntityKind::Arc(a) => { w.s(0, "ARC"); common(w, e, owner, paper, "AcDbCircle"); + thickness(w, e); w.p(10, a.center); w.f(40, a.radius); w.s(100, "AcDbArc"); @@ -414,6 +421,7 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx if p.elevation != 0.0 { w.f(38, p.elevation); } + thickness(w, e); for v in &p.vertices { w.p2(10, v.p); if v.start_width != 0.0 || v.end_width != 0.0 { @@ -430,6 +438,7 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx common(w, e, owner, paper, "AcDb3dPolyline"); w.i(66, 1); w.p(10, Vec3::ZERO); + thickness(w, e); w.i(70, 8 | i64::from(p.closed)); for q in &p.points { let vh = w.h(); @@ -482,6 +491,7 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx EntityKind::Text(t) => { w.s(0, "TEXT"); common(w, e, owner, paper, "AcDbText"); + thickness(w, e); text_tags(w, t, None, None); } EntityKind::AttDef(a) => { @@ -724,6 +734,7 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx w.p(11, s.corners[1]); w.p(12, s.corners[2]); w.p(13, s.corners[3]); + thickness(w, e); } EntityKind::Face3d(f) => { w.s(0, "3DFACE"); @@ -769,7 +780,7 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx for pl in lines { let ang = pl.angle.to_radians() + h.angle; w.f(53, ang.to_degrees()); - let o = Vec2::new(pl.origin.0, pl.origin.1).rotate(h.angle) * h.scale; + let o = h.origin + Vec2::new(pl.origin.0, pl.origin.1).rotate(h.angle) * h.scale; w.f(43, o.x); w.f(44, o.y); let dl = Vec2::new(pl.delta.0, pl.delta.1).rotate(ang) * h.scale; @@ -785,6 +796,7 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx if let Some(g) = &h.gradient { gradient(w, g); } + hatch_xdata(w, h); } EntityKind::Viewport(v) => { w.s(0, "VIEWPORT"); @@ -881,6 +893,30 @@ fn gradient(w: &mut W, g: &Gradient) { w.s(1000, g.color2.name()); } +/// Group 39 (thickness) of the entities whose DXF Reference record has one; only when set. +fn thickness(w: &mut W, e: &Entity) { + if e.common.thickness != 0.0 { + w.f(39, e.common.thickness); + } +} + +/// Hatch origin and background colour as `CADCRAFT` xdata: `1000 HATCH`, the origin as a +/// world position (`1011`, moved with the hatch by other programs) and the background colour +/// name (`1000`, empty for none). The DXF Reference has no HATCH groups for either: other +/// readers get the origin through the pattern lines' base points (43/44), the background isn't +/// carried. Appended to the gradient's `CADCRAFT` xdata when there is one. +fn hatch_xdata(w: &mut W, h: &Hatch) { + if h.origin == Vec2::ZERO && h.background.is_none() { + return; + } + if h.gradient.is_none() { + w.s(1001, dxf_ext::APP); + } + w.s(1000, "HATCH"); + w.p(1011, h.origin.to3(0.0)); + w.s(1000, h.background.map(Color::name).unwrap_or_default()); +} + /// Anonymous dimension blocks (`*D1`…) with the rendered geometry, as consumers expect. fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec { let style = d.dim_style(&dm.style).cloned().unwrap_or_default(); @@ -1321,11 +1357,19 @@ pub fn write(d: &Drawing) -> String { w.f(50, s.oblique.to_degrees()); w.i(71, if s.backwards { 2 } else { 0 } | if s.upside_down { 4 } else { 0 }); w.f(42, 0.2); - w.s(3, if s.font == cadcraft_fonts_name() { "txt" } else { s.font.as_str() }); + // The built-in font goes out as `txt`, which every reader has; CADCraft's own name + // travels in `CADCRAFT` xdata (`1000 FONT`, name) so the style reopens with it. + let file = if s.font == cadcraft_fonts_name() { "txt" } else { s.font.as_str() }; + w.s(3, file); w.s(4, &s.big_font); if s.annotative { annotative_xdata(&mut w); } + if file != s.font { + w.s(1001, dxf_ext::APP); + w.s(1000, "FONT"); + w.s(1000, dxf_ext::xdata_str(&s.font)); + } } w.s(0, "ENDTAB"); for name in ["VIEW", "UCS"] { @@ -1641,9 +1685,30 @@ pub fn write(d: &Drawing) -> String { if let Some(t) = w.t.get_mut(seed_pos + 1) { *t = Tag::s(5, seed); } + mark_xrefs(&mut w.t, d); cadcraft_dxf::write_ascii(&w.t) } +/// Xref blocks (DXF Reference, BLOCK): bit 4 of group 70 and the external drawing's path in +/// group 1, set on the written BLOCK records of blocks that have an `xref_path`. +fn mark_xrefs(tags: &mut [Tag], d: &Drawing) { + let xrefs: HashMap = d.blocks.values().filter_map(|b| Some((b.name.to_ascii_uppercase(), b.xref_path.as_deref()?))).collect(); + if xrefs.is_empty() { + return; + } + // Inside a BLOCK record: the path once its name (2) says it is an xref. + let mut block: Option> = None; + for t in tags.iter_mut() { + match (t.code, block) { + (0, _) => block = (t.str() == "BLOCK").then_some(None), + (2, Some(None)) => block = Some(xrefs.get(&t.str().to_ascii_uppercase()).copied()), + (70, Some(Some(_))) => *t = Tag::i(70, t.i64() | 4), + (1, Some(Some(p))) => *t = Tag::s(1, p), + _ => {} + } + } +} + /// A TABLESTYLE object (DXF Reference, OBJECTS: TABLESTYLE): margins, title/header /// suppression and per row type (data, column header, title) text and border settings. fn table_style_obj(w: &mut W, s: &TableStyle, h: &str, dict: &str) { diff --git a/crates/io/tests/entity_props_roundtrip.rs b/crates/io/tests/entity_props_roundtrip.rs new file mode 100644 index 00000000..05617068 --- /dev/null +++ b/crates/io/tests/entity_props_roundtrip.rs @@ -0,0 +1,262 @@ +//! Entity-level properties that must survive a DXF save and reopen: thickness, POINT angle, +//! hatch origin/background/elevation, xref blocks, the built-in font name, and polyface / +//! polygon mesh POLYLINEs from other programs. + +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::*; +use cadcraft_geom::{PolyVertex, Vec2, Vec3}; +use cadcraft_io::{read_dxf, write_dxf}; + +fn roundtrip(d: &Drawing) -> Drawing { + read_dxf(write_dxf(d).as_bytes()).expect("reopen") +} + +fn add(d: &mut Drawing, thickness: f64, kind: EntityKind) -> Handle { + let common = Common { thickness, ..Common::default() }; + d.add(&Space::Model, common, kind).expect("add") +} + +fn get(d: &Drawing, h: Handle) -> &Entity { + d.model.get(h).expect("entity kept") +} + +fn square(size: f64) -> Vec { + [(0.0, 0.0), (size, 0.0), (size, size), (0.0, size)].into_iter().map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).collect() +} + +#[test] +fn thickness_roundtrips_on_every_entity_that_has_one() { + let mut d = Drawing::new_metric(); + let p = |x, y| Vec3::new(x, y, 0.0); + let text = Text { + insert: p(0.0, 0.0), + align_pt: None, + height: 2.5, + value: "T".into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: HAlign::Left, + valign: VAlign::Baseline, + }; + let kinds = vec![ + EntityKind::Line(Line { a: p(0.0, 0.0), b: p(10.0, 0.0) }), + EntityKind::Circle(Circle { center: p(0.0, 0.0), radius: 5.0 }), + EntityKind::Arc(Arc { center: p(0.0, 0.0), radius: 5.0, start: 0.0, end: 1.0 }), + EntityKind::Point(Point { p: p(1.0, 2.0), angle: 0.0 }), + EntityKind::Text(text), + EntityKind::LwPolyline(LwPolyline { vertices: square(4.0), closed: true, const_width: 0.0, elevation: 0.0, plinegen: false }), + EntityKind::Polyline3d(Polyline3d { points: vec![p(0.0, 0.0), Vec3::new(1.0, 1.0, 1.0)], closed: false }), + EntityKind::Solid(Solid { corners: [p(0.0, 0.0), p(1.0, 0.0), p(0.0, 1.0), p(1.0, 1.0)] }), + EntityKind::Trace(Solid { corners: [p(0.0, 0.0), p(1.0, 0.0), p(0.0, 1.0), p(1.0, 1.0)] }), + ]; + let handles: Vec = kinds.into_iter().enumerate().map(|(i, k)| add(&mut d, 1.5 + i as f64, k)).collect(); + let thin = add(&mut d, 0.0, EntityKind::Line(Line { a: p(0.0, 5.0), b: p(1.0, 5.0) })); + let r = roundtrip(&d); + for (i, h) in handles.iter().enumerate() { + let e = get(&r, *h); + assert_eq!(e.common.thickness, 1.5 + i as f64, "thickness of {:?}", e.kind); + } + assert_eq!(get(&r, thin).common.thickness, 0.0); +} + +#[test] +fn point_angle_roundtrips() { + let mut d = Drawing::new_metric(); + let h = add(&mut d, 0.0, EntityKind::Point(Point { p: Vec3::new(3.0, 4.0, 0.0), angle: 30f64.to_radians() })); + let r = roundtrip(&d); + let EntityKind::Point(pt) = &get(&r, h).kind else { panic!("not a point") }; + assert!((pt.angle - 30f64.to_radians()).abs() < 1e-12, "angle {}", pt.angle); +} + +fn hatch(pattern: &str, gradient: Option) -> Hatch { + Hatch { + pattern: pattern.into(), + solid: pattern == "SOLID", + loops: vec![HatchLoop { vertices: square(10.0), outer: true }], + scale: 2.0, + angle: 0.25, + associative: false, + style: 0, + elevation: 1.5, + gradient, + origin: Vec2::new(1.0, 2.0), + background: Some(Color::Index(2)), + } +} + +#[test] +fn hatch_origin_background_and_elevation_roundtrip() { + let mut d = Drawing::new_metric(); + let patterned = add(&mut d, 0.0, EntityKind::Hatch(hatch("ANSI31", None))); + let g = Gradient { name: "CYLINDER".into(), color1: Color::Index(1), color2: Color::True(Rgb(10, 20, 30)), angle: 0.5, centered: false }; + let mut with_gradient = hatch("SOLID", Some(g.clone())); + with_gradient.background = Some(Color::True(Rgb(1, 2, 3))); + let graded = add(&mut d, 0.0, EntityKind::Hatch(with_gradient)); + let mut plain = hatch("ANSI31", None); + (plain.origin, plain.background, plain.elevation) = (Vec2::ZERO, None, 0.0); + let plain_h = add(&mut d, 0.0, EntityKind::Hatch(plain)); + + let text = write_dxf(&d); + let r = read_dxf(text.as_bytes()).expect("reopen"); + let hatch_of = |h| match &get(&r, h).kind { + EntityKind::Hatch(x) => x.clone(), + k => panic!("not a hatch: {k:?}"), + }; + let a = hatch_of(patterned); + assert!(a.origin.near(Vec2::new(1.0, 2.0), 1e-12), "origin {:?}", a.origin); + assert_eq!(a.background, Some(Color::Index(2))); + assert_eq!(a.elevation, 1.5); + let b = hatch_of(graded); + assert!(b.origin.near(Vec2::new(1.0, 2.0), 1e-12)); + assert_eq!(b.background, Some(Color::True(Rgb(1, 2, 3)))); + assert_eq!(b.gradient, Some(g), "gradient xdata still read next to the hatch xdata"); + let c = hatch_of(plain_h); + assert_eq!((c.origin, c.background, c.elevation), (Vec2::ZERO, None, 0.0)); + + // Other readers see the origin in the pattern lines' base points (43/44): ANSI31's line + // passes through (0,0), so the first base point is the origin itself. + let g = groups(&text); + let in_hatch = g.iter().skip_while(|(c, v)| !(*c == 0 && v == "HATCH")); + let first_43 = in_hatch.clone().find(|(c, _)| *c == 43).map(|(_, v)| v.parse::().expect("43 value")); + let first_44 = in_hatch.clone().find(|(c, _)| *c == 44).map(|(_, v)| v.parse::().expect("44 value")); + assert_eq!((first_43, first_44), (Some(1.0), Some(2.0))); +} + +#[test] +fn xref_block_flag_and_path_roundtrip() { + let mut d = Drawing::new_metric(); + let mut x = Block::new("Site"); + x.xref_path = Some("plans/site.dxf".into()); + d.blocks.insert(x.name.clone(), std::sync::Arc::new(x)); + let mut local = Block::new("Door"); + local.entities.push(Entity::new(Handle(0x50), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }))); + d.blocks.insert(local.name.clone(), std::sync::Arc::new(local)); + let ins = Insert { + block: "Site".into(), + insert: Vec3::ZERO, + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + let h = add(&mut d, 0.0, EntityKind::Insert(ins)); + + let r = roundtrip(&d); + assert_eq!(r.blocks.get("Site").and_then(|b| b.xref_path.clone()), Some("plans/site.dxf".into())); + assert_eq!(r.blocks.get("Door").map(|b| b.xref_path.clone()), Some(None)); + let EntityKind::Insert(i) = &get(&r, h).kind else { panic!("not an insert") }; + assert_eq!((i.block.as_str(), i.cols, i.rows), ("Site", 1, 1), "the INSERT naming the xref is untouched"); +} + +#[test] +fn builtin_font_name_roundtrips_and_other_readers_get_txt() { + let mut d = Drawing::new_metric(); + d.text_styles.push(TextStyle { name: "Notes".into(), font: "romans.shx".into(), ..TextStyle::default() }); + let text = write_dxf(&d); + let g = groups(&text); + let standard = g.iter().position(|(c, v)| *c == 2 && v == "Standard").expect("Standard style"); + let file = g.iter().skip(standard).find(|(c, _)| *c == 3).map(|(_, v)| v.as_str()); + assert_eq!(file, Some("txt"), "font file group stays a file other readers have"); + let r = read_dxf(text.as_bytes()).expect("reopen"); + let font = |n: &str| r.text_styles.iter().find(|s| s.name == n).map(|s| s.font.clone()); + assert_eq!(font("Standard").as_deref(), Some("CADCraft Stroke")); + assert_eq!(font("Notes").as_deref(), Some("romans.shx")); + + // Another program that changed the font file wins over the stale xdata. + let edited = text.replacen(" 3\r\ntxt\r\n", " 3\r\nisocp.shx\r\n", 1); + assert_ne!(edited, text); + let r = read_dxf(edited.as_bytes()).expect("reopen edited"); + assert_eq!(r.text_styles.iter().find(|s| s.name == "Standard").map(|s| s.font.as_str()), Some("isocp.shx")); +} + +/// The (code, value) pairs of a DXF text. +fn groups(text: &str) -> Vec<(i32, String)> { + let lines: Vec<&str> = text.lines().map(str::trim).collect(); + lines.chunks(2).filter_map(|p| Some((p.first()?.parse().ok()?, (*p.get(1)?).to_string()))).collect() +} + +/// A minimal ENTITIES-only DXF from group/value pairs. +fn dxf(records: &[&[(i32, &str)]]) -> String { + let mut s = String::from("0\nSECTION\n2\nENTITIES\n"); + for r in records { + for (c, v) in *r { + s.push_str(&format!("{c}\n{v}\n")); + } + } + s.push_str("0\nENDSEC\n0\nEOF\n"); + s +} + +fn vertex(x: &str, y: &str, z: &str, flags: &str) -> Vec<(i32, &'static str)> { + let leak = |s: &str| -> &'static str { Box::leak(s.to_owned().into_boxed_str()) }; + vec![(0, "VERTEX"), (8, "0"), (10, leak(x)), (20, leak(y)), (30, leak(z)), (70, leak(flags))] +} + +fn face(idx: [&'static str; 4]) -> Vec<(i32, &'static str)> { + vec![(0, "VERTEX"), (8, "0"), (10, "0"), (20, "0"), (30, "0"), (70, "128"), (71, idx[0]), (72, idx[1]), (73, idx[2]), (74, idx[3])] +} + +fn faces(d: &Drawing) -> Vec { + d.model + .iter() + .map(|e| match &e.kind { + EntityKind::Face3d(f) => f.clone(), + k => panic!("expected only 3D faces, got {k:?}"), + }) + .collect() +} + +#[test] +fn polyface_and_polygon_mesh_polylines_become_faces_not_bogus_vertices() { + let p = |x: f64, y: f64, z: f64| Vec3::new(x, y, z); + // Polyface: four vertices, a quad, a triangle whose third edge is hidden, and faces with an + // out-of-range index or only two vertices (skipped). + let mut recs: Vec> = + vec![vec![(0, "POLYLINE"), (8, "0"), (66, "1"), (10, "0"), (20, "0"), (30, "0"), (70, "64"), (71, "4"), (72, "4")]]; + for (x, y) in [("0", "0"), ("1", "0"), ("1", "1"), ("0", "1")] { + recs.push(vertex(x, y, "2", "192")); + } + recs.push(face(["1", "2", "3", "4"])); + recs.push(face(["1", "3", "-4", "0"])); + recs.push(face(["1", "2", "99", "0"])); + recs.push(face(["1", "2", "0", "0"])); + recs.push(vec![(0, "SEQEND"), (8, "0")]); + let refs: Vec<&[(i32, &str)]> = recs.iter().map(Vec::as_slice).collect(); + let d = read_dxf(dxf(&refs).as_bytes()).expect("read polyface"); + let fs = faces(&d); + assert_eq!(fs.len(), 2, "{fs:?}"); + assert_eq!(fs[0].corners, [p(0.0, 0.0, 2.0), p(1.0, 0.0, 2.0), p(1.0, 1.0, 2.0), p(0.0, 1.0, 2.0)]); + assert_eq!(fs[0].hidden_edges, 0); + assert_eq!(fs[1].corners, [p(0.0, 0.0, 2.0), p(1.0, 1.0, 2.0), p(0.0, 1.0, 2.0), p(0.0, 1.0, 2.0)]); + assert_eq!(fs[1].hidden_edges, 4 | 8, "the triangle's closing edge is hidden"); + // The faces survive a save and reopen as 3DFACEs. + assert_eq!(faces(&roundtrip(&d)), fs); + + // 3×2 polygon mesh: two quads. + let mut recs: Vec> = + vec![vec![(0, "POLYLINE"), (8, "0"), (66, "1"), (10, "0"), (20, "0"), (30, "0"), (70, "16"), (71, "3"), (72, "2")]]; + for (x, y) in [("0", "0"), ("0", "1"), ("1", "0"), ("1", "1"), ("2", "0"), ("2", "1")] { + recs.push(vertex(x, y, "0", "64")); + } + recs.push(vec![(0, "SEQEND"), (8, "0")]); + let refs: Vec<&[(i32, &str)]> = recs.iter().map(Vec::as_slice).collect(); + let d = read_dxf(dxf(&refs).as_bytes()).expect("read mesh"); + let fs = faces(&d); + assert_eq!(fs.len(), 2); + assert_eq!(fs[1].corners, [p(1.0, 0.0, 0.0), p(1.0, 1.0, 0.0), p(2.0, 1.0, 0.0), p(2.0, 0.0, 0.0)]); + + // Hostile counts: a mesh claiming more vertices than it has yields nothing, without panicking. + let recs: Vec> = vec![ + vec![(0, "POLYLINE"), (8, "0"), (66, "1"), (70, "17"), (71, "9223372036854775807"), (72, "-5")], + vertex("0", "0", "0", "64"), + vec![(0, "SEQEND")], + ]; + let refs: Vec<&[(i32, &str)]> = recs.iter().map(Vec::as_slice).collect(); + let d = read_dxf(dxf(&refs).as_bytes()).expect("read hostile mesh"); + assert_eq!(d.model.iter().count(), 0); +} From 6b43587279077f77544d4c4c46fd0537564509a2 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 20:37:20 +0200 Subject: [PATCH 037/150] DXF: keep linetype text, multileader styles, page setups, views, UCSs and groups (fixes #209) (#216) Saving to DXF and reopening lost several table and object-section items. They are now written and read as the DXF Reference describes: - LTYPE: embedded text and shape elements (74 type flags, 75 shape number, 340 STYLE, 46 scale, 50 rotation, 44/45 offset, 9 text). Shapes from a shape file get an unnamed shape STYLE record (70 bit 1). DashElement gains an `absolute` flag for absolute rotations (74 bit 1). The built-in FENCELINE1 names its text style (Standard). - MLEADERSTYLE objects in ACAD_MLEADERSTYLE with arrow size, text height, landing gap, dogleg length and text style, plus $CMLEADERSTYLE, so the current multileader style survives reopening. - LAYOUT plot settings: device (2), plot style table (7), plot area (74), custom scale (142/143, 147), scale to fit (70 bit 16 with 75 = 0), centred plot and lineweights (70 bits 4 and 128). The layout's saved paper-space view goes in CADCraft xdata on the LAYOUT object. - VIEW and UCS table records, and GROUP objects in ACAD_GROUP (members that were written to the file). A named view's layer state is CADCraft xdata on the VIEW record. Co-authored-by: Claude Opus 5.5 --- crates/doc/src/library.rs | 2 +- crates/doc/src/tables.rs | 7 +- crates/io/src/dxf_read.rs | 172 ++++++++++++- crates/io/src/dxf_write.rs | 263 ++++++++++++++++++-- crates/io/tests/tables_objects_roundtrip.rs | 241 ++++++++++++++++++ 5 files changed, 662 insertions(+), 23 deletions(-) create mode 100644 crates/io/tests/tables_objects_roundtrip.rs diff --git a/crates/doc/src/library.rs b/crates/doc/src/library.rs index 8781051a..5e9eb64a 100644 --- a/crates/doc/src/library.rs +++ b/crates/doc/src/library.rs @@ -33,7 +33,7 @@ pub fn standard_linetypes() -> Vec { pattern: vec![ DashElement::dash(0.25), DashElement::dash(-0.1), - DashElement { text: Some("o".into()), scale: 0.1, ..DashElement::dash(-0.1) }, + DashElement { text: Some("o".into()), style: Some("Standard".into()), scale: 0.1, ..DashElement::dash(-0.1) }, DashElement::dash(0.5), ], }, diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index 33f080b8..ce1a09f4 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -70,15 +70,20 @@ pub struct DashElement { pub style: Option, #[serde(default)] pub scale: f64, + /// Rotation of embedded text or a shape, in radians. #[serde(default)] pub rotation: f64, #[serde(default)] pub offset: Vec2, + /// The rotation of embedded text or a shape is absolute (`A=`) instead of relative to the + /// line direction (`R=`). + #[serde(default)] + pub absolute: bool, } impl DashElement { pub fn dash(length: f64) -> Self { - DashElement { length, text: None, shape: None, style: None, scale: 1.0, rotation: 0.0, offset: Vec2::ZERO } + DashElement { length, text: None, shape: None, style: None, scale: 1.0, rotation: 0.0, offset: Vec2::ZERO, absolute: false } } } diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index f3da18bc..5574e727 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -891,6 +891,16 @@ fn style_font(tags: &[Tag], file: String) -> String { fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { let recs = records(tags); + // STYLE handle → name, or the shape file of an unnamed shape style (linetype elements). + let ltype_styles: HashMap = recs + .iter() + .filter(|(k, _)| k == "STYLE") + .filter_map(|(_, tg)| { + let t = T(tg); + let name = t.s(2).filter(|n| !n.trim().is_empty()); + Some((t.s(5)?.trim().to_ascii_uppercase(), name.or_else(|| t.s(3))?)) + }) + .collect(); // Handle maps first: DIMSTYLE records refer to text styles and arrow blocks by handle. for (kind, tg) in &recs { let t = T(tg); @@ -949,7 +959,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { if name.is_empty() || d.linetype(&name).is_some() { continue; } - let pattern = tg.iter().filter(|x| x.code == 49).map(|x| DashElement::dash(x.f64())).collect(); + let pattern = ltype_pattern(&tg, <ype_styles); d.linetypes.push(Linetype { name, description: t.s(3).unwrap_or_default(), pattern }); } "STYLE" => { @@ -976,6 +986,42 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { None => d.text_styles.push(st), } } + "VIEW" => { + let name = t.s(2).unwrap_or_default(); + let (h, w) = (t.fd(40, 0.0), t.fd(41, 0.0)); + if name.is_empty() || !(h.is_finite() && w.is_finite() && h > 0.0 && w > 0.0) || d.views.len() >= MAX_OBJECTS { + continue; + } + let center = t.p(10).xy(); + if !(center.x.is_finite() && center.y.is_finite()) { + continue; + } + let x = crate::dxf_ext::xdata(&tg, crate::dxf_ext::APP); + let layer_state = x + .iter() + .position(|t| t.code == 1000 && t.str() == "LAYERSTATE") + .and_then(|i| x.get(i + 1)) + .filter(|t| t.code == 1000) + .map(Tag::str); + let v = NamedView { name: name.clone(), center, height: h, width: w, layer_state }; + match d.views.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) { + Some(x) => *x = v, + None => d.views.push(v), + } + } + "UCS" => { + let name = t.s(2).unwrap_or_default(); + let (origin, x_axis, y_axis) = (t.p(10), t.p(11), t.p(12)); + let finite = [origin, x_axis, y_axis].iter().all(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()); + if name.is_empty() || !finite || d.ucss.len() >= MAX_OBJECTS { + continue; + } + let u = Ucs { name: name.clone(), origin, x_axis, y_axis }; + match d.ucss.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) { + Some(x) => *x = u, + None => d.ucss.push(u), + } + } "DIMSTYLE" => { let name = t.s(2).unwrap_or_default(); if name.is_empty() { @@ -1002,6 +1048,43 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { } } +/// The dash elements of an LTYPE record with their embedded text and shapes (DXF Reference, +/// LTYPE): each `49` length starts an element; `74` type flags (1 absolute rotation, 2 text, +/// 4 shape), `75` shape number, `340` STYLE, `46` scale, `50` rotation (radians), `44`/`45` +/// offset and `9` text follow it. +fn ltype_pattern(tags: &[Tag], styles: &HashMap) -> Vec { + let mut out: Vec = Vec::new(); + for t in tags.iter().take_while(|t| t.code < 1000) { + if t.code == 49 { + out.push(DashElement::dash(t.f64())); + continue; + } + let Some(el) = out.last_mut() else { continue }; + let complex = el.text.is_some() || el.shape.is_some(); + let v = t.f64(); + match t.code { + 74 => { + let flags = t.i64(); + el.absolute = flags & 1 != 0; + if flags & 2 != 0 { + el.text = Some(String::new()); + } else if flags & 4 != 0 { + el.shape = Some(0); + } + } + 75 if el.shape.is_some() => el.shape = Some(u16::try_from(t.i64()).unwrap_or(0)), + 340 if complex => el.style = styles.get(&t.str().trim().to_ascii_uppercase()).filter(|n| !n.trim().is_empty()).cloned(), + 9 if el.text.is_some() => el.text = Some(t.str()), + 46 if complex && v.is_finite() => el.scale = v, + 50 if complex && v.is_finite() => el.rotation = v, + 44 if complex && v.is_finite() => el.offset.x = v, + 45 if complex && v.is_finite() => el.offset.y = v, + _ => {} + } + } + out +} + /// Read a DXF file into a drawing. pub fn read(bytes: &[u8]) -> Result { let tags = cadcraft_dxf::parse(bytes).map_err(|e| IoError::Format(e.to_string()))?; @@ -1163,6 +1246,12 @@ struct Objects { table_styles: Vec<(String, Vec)>, /// DIMASSOC objects. dimassocs: Vec>, + /// MLEADERSTYLE objects: (handle, groups). + mleader_styles: Vec<(String, Vec)>, + /// GROUP objects: (handle, groups). + groups: Vec<(String, Vec)>, + /// Saved paper-space views of layouts: (layout name, view). + layout_views: Vec<(String, (Vec2, f64))>, } const MAX_OBJECTS: usize = 1_000_000; @@ -1192,6 +1281,14 @@ impl Objects { } "TABLESTYLE" if self.table_styles.len() < MAX_OBJECTS => self.table_styles.push((h, tags.to_vec())), "DIMASSOC" if self.dimassocs.len() < MAX_OBJECTS => self.dimassocs.push(tags.to_vec()), + "MLEADERSTYLE" if self.mleader_styles.len() < MAX_OBJECTS => self.mleader_styles.push((h, tags.to_vec())), + "GROUP" if self.groups.len() < MAX_OBJECTS => self.groups.push((h, tags.to_vec())), + "LAYOUT" if self.layout_views.len() < MAX_OBJECTS => { + let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbLayout").unwrap_or(0); + if let (Some(name), Some(view)) = (T(tags.get(start..).unwrap_or(&[])).s(1), layout_view(tags)) { + self.layout_views.push((name, view)); + } + } _ => {} } } @@ -1247,6 +1344,48 @@ impl Objects { if let Some(states) = self.xrecord_text(crate::dxf_ext::LAYER_STATES_KEY).and_then(|t| crate::dxf_ext::parse_layer_states(&t)) { d.layer_states = states; } + // Multileader styles (named by their ACAD_MLEADERSTYLE dictionary entries). + for (h, tags) in &self.mleader_styles { + let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; + let t = T(tags); + let def = MLeaderStyle::default(); + let num = |code: i32, d: f64, min: f64| t.f(code).filter(|v| v.is_finite() && *v >= min).unwrap_or(d); + let text_style = t.s(342).and_then(|sh| rx.styles.get(&sh.trim().to_ascii_uppercase()).cloned()).filter(|n| !n.is_empty()); + let st = MLeaderStyle { + name: name.clone(), + arrow_size: num(44, def.arrow_size, 0.0), + text_height: t.f(45).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(def.text_height), + landing_gap: num(42, def.landing_gap, 0.0), + dogleg: num(43, def.dogleg, 0.0), + text_style: text_style.unwrap_or(def.text_style), + }; + match d.mleader_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(name)) { + Some(x) => *x = st, + None => d.mleader_styles.push(st), + } + } + for (name, view) in &self.layout_views { + if let Some(l) = d.layouts.iter_mut().find(|l| l.name == *name) { + l.view = Some(*view); + } + } + // Groups (named by their ACAD_GROUP dictionary entries); members that exist. + for (h, tags) in &self.groups { + let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue }; + let t = T(tags); + let members: Vec = tags + .iter() + .filter(|x| x.code == 340) + .filter_map(|x| Handle::parse_hex(&x.str())) + .filter(|m| d.entity(*m).is_some()) + .take(MAX_OBJECTS) + .collect(); + let g = Group { name: name.clone(), description: t.s(300).unwrap_or_default(), selectable: t.i(71).unwrap_or(1) != 0, members }; + match d.groups.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) { + Some(x) => *x = g, + None => d.groups.push(g), + } + } // Standard associativity, for dimensions without CADCraft's exact links. for tags in &self.dimassocs { dimassoc(d, tags); @@ -1354,5 +1493,36 @@ fn page_setup(t: &T) -> PageSetup { if let Some(name) = t.s(4).filter(|n| !n.is_empty()) { p.paper = name.replace('_', " "); } + if let Some(dev) = t.s(2).map(|s| s.trim().to_string()) { + p.device = if dev.is_empty() || dev.eq_ignore_ascii_case("none_device") { "None".into() } else { dev }; + } + // Plot layout flags: 4 centred, 16 standard scale (75 = 0: scaled to fit), 128 lineweights. + if let Some(flags) = t.i(70) { + p.center = flags & 4 != 0; + p.lineweights = flags & 128 != 0; + p.scale_to_fit = flags & 16 != 0 && t.i(75) == Some(0); + } + // Custom scale: 142 paper units per 143 drawing units; 147 is the scale factor. + let pos = |v: Option| v.filter(|v| v.is_finite() && *v > 0.0); + let ratio = pos(t.f(142)).zip(pos(t.f(143))).map(|(n, d)| n / d); + if let Some(s) = pos(ratio).or_else(|| pos(t.f(147))) { + p.scale = s; + } + p.plot_area = match t.i(74) { + Some(0) => "display", + Some(1) => "extents", + Some(2) => "limits", + Some(4) => "window", + _ => "layout", + } + .into(); + p.plot_style_table = t.s(7).unwrap_or_default(); p } + +/// A layout's saved paper-space view (centre, height) from CADCraft xdata. +fn layout_view(tags: &[Tag]) -> Option<(Vec2, f64)> { + let list = crate::dxf_ext::xdata_list(crate::dxf_ext::xdata(tags, crate::dxf_ext::APP), "PSVIEW"); + let g = |c: i32| list.iter().find(|t| t.code == c).map(Tag::f64).filter(|v| v.is_finite()); + Some((Vec2::new(g(1010)?, g(1020)?), g(1040)?)) +} diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 9427a7ca..dbc57905 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -1248,6 +1248,11 @@ pub fn write(d: &Drawing) -> String { w.p(10, ext.max.to3(0.0)); } header_vars(&mut w, d); + // The current multileader style (an R2007 variable; older readers skip it). + if let Some(name) = d.header.get("CMLEADERSTYLE").and_then(HVal::as_str) { + w.s(9, "$CMLEADERSTYLE"); + w.s(2, name); + } let seed_pos = w.t.len(); w.s(9, "$HANDSEED"); w.s(5, "0"); @@ -1256,7 +1261,7 @@ pub fn write(d: &Drawing) -> String { // ---------------- CLASSES ---------------- w.s(0, "SECTION"); w.s(2, "CLASSES"); - let mut classes = vec![("TABLESTYLE", "AcDbTableStyle", 4095, false)]; + let mut classes = vec![("TABLESTYLE", "AcDbTableStyle", 4095, false), ("MLEADERSTYLE", "AcDbMLeaderStyle", 4095, false)]; if !cx.tables.is_empty() { classes.push(("ACAD_TABLE", "AcDbTable", 1025, true)); } @@ -1297,10 +1302,14 @@ pub fn write(d: &Drawing) -> String { w.f(41, 1.6); w.f(42, 50.0); w.s(0, "ENDTAB"); - // LTYPE + // LTYPE. Embedded text and shapes point at STYLE records (340), which get their handles + // in the STYLE table below: (tag position, style name, is a shape) are filled in there. + let mut ltype_handles: HashMap = HashMap::new(); + let mut ltype_style_refs: Vec<(usize, Option, bool)> = Vec::new(); let th = table_head(&mut w, "LTYPE", d.linetypes.len()); for lt in &d.linetypes { - record_head(&mut w, "LTYPE", &th, "AcDbLinetypeTableRecord"); + let h = record_head(&mut w, "LTYPE", &th, "AcDbLinetypeTableRecord"); + ltype_handles.entry(lt.name.to_ascii_uppercase()).or_insert(h); w.s(2, <.name); w.i(70, 0); w.s(3, <.description); @@ -1309,7 +1318,27 @@ pub fn write(d: &Drawing) -> String { w.f(40, lt.pattern_length()); for el in <.pattern { w.f(49, el.length); - w.i(74, 0); + // 74: 2 = text, 4 = shape, + 1 when the rotation is absolute (DXF Reference, LTYPE). + let (kind, shape) = match (&el.text, el.shape) { + (Some(_), _) => (2, 0), + (None, Some(n)) => (4, n), + (None, None) => (0, 0), + }; + if kind == 0 { + w.i(74, 0); + continue; + } + w.i(74, kind | i64::from(el.absolute)); + w.i(75, i64::from(shape)); + ltype_style_refs.push((w.t.len(), el.style.clone(), kind == 4)); + w.s(340, "0"); + w.f(46, el.scale); + w.f(50, el.rotation); + w.f(44, el.offset.x); + w.f(45, el.offset.y); + if let Some(t) = &el.text { + w.s(9, t.clone()); + } } } w.s(0, "ENDTAB"); @@ -1344,8 +1373,16 @@ pub fn write(d: &Drawing) -> String { } } w.s(0, "ENDTAB"); - // STYLE - let th = table_head(&mut w, "STYLE", d.text_styles.len()); + // STYLE. Linetype shapes from a shape file that isn't a text style get an unnamed shape + // style record (70 bit 1) naming the file. + let mut shape_files: Vec = Vec::new(); + for (_, style, shape) in <ype_style_refs { + let Some(f) = style.as_deref().map(str::trim).filter(|f| *shape && !f.is_empty() && d.text_style(f).is_none()) else { continue }; + if !shape_files.iter().any(|x| x.eq_ignore_ascii_case(f)) { + shape_files.push(f.to_string()); + } + } + let th = table_head(&mut w, "STYLE", d.text_styles.len() + shape_files.len()); for s in &d.text_styles { let h = record_head(&mut w, "STYLE", &th, "AcDbTextStyleTableRecord"); cx.styles.entry(s.name.to_ascii_uppercase()).or_insert(h); @@ -1371,11 +1408,68 @@ pub fn write(d: &Drawing) -> String { w.s(1000, dxf_ext::xdata_str(&s.font)); } } - w.s(0, "ENDTAB"); - for name in ["VIEW", "UCS"] { - table_head(&mut w, name, 0); - w.s(0, "ENDTAB"); + let mut shape_handles: HashMap = HashMap::new(); + for f in &shape_files { + let h = record_head(&mut w, "STYLE", &th, "AcDbTextStyleTableRecord"); + shape_handles.insert(f.to_ascii_uppercase(), h); + w.s(2, ""); + w.i(70, 1); + w.f(40, 0.0); + w.f(41, 1.0); + w.f(50, 0.0); + w.i(71, 0); + w.f(42, 0.2); + w.s(3, f.clone()); + w.s(4, ""); } + w.s(0, "ENDTAB"); + // Linetype text and shapes without a known style use Standard (else the first style). + let fallback_style = cx.style("Standard").or_else(|| d.text_styles.first().and_then(|s| cx.style(&s.name))).unwrap_or_else(|| "0".into()); + for (pos, style, shape) in ltype_style_refs { + let name = style.as_deref().map(str::trim).unwrap_or_default(); + let h = shape.then(|| shape_handles.get(&name.to_ascii_uppercase()).cloned()).flatten().or_else(|| cx.style(name)); + if let Some(t) = w.t.get_mut(pos) { + *t = Tag::s(340, h.unwrap_or_else(|| fallback_style.clone())); + } + } + // VIEW: named views (DXF Reference, VIEW). A view's layer state is CADCraft xdata. + let th = table_head(&mut w, "VIEW", d.views.len()); + for v in &d.views { + record_head(&mut w, "VIEW", &th, "AcDbViewTableRecord"); + w.s(2, &v.name); + w.i(70, 0); + w.f(40, v.height); + w.p2(10, v.center); + w.f(41, v.width); + w.p(11, Vec3::Z); + w.p(12, Vec3::ZERO); + w.f(42, 50.0); + w.f(43, 0.0); + w.f(44, 0.0); + w.f(50, 0.0); + w.i(71, 0); + w.i(281, 0); + w.i(72, 0); + if let Some(ls) = &v.layer_state { + w.s(1001, dxf_ext::APP); + w.s(1000, "LAYERSTATE"); + w.s(1000, dxf_ext::xdata_str(ls)); + } + } + w.s(0, "ENDTAB"); + // UCS: named user coordinate systems (DXF Reference, UCS). + let th = table_head(&mut w, "UCS", d.ucss.len()); + for u in &d.ucss { + record_head(&mut w, "UCS", &th, "AcDbUCSTableRecord"); + w.s(2, &u.name); + w.i(70, 0); + w.p(10, u.origin); + w.p(11, u.x_axis); + w.p(12, u.y_axis); + w.i(79, 0); + w.f(146, 0.0); + } + w.s(0, "ENDTAB"); let apps = ["ACAD", dxf_ext::APP, "AcadAnnotative", dxf_ext::LAYER_TRANSPARENCY_APP, dxf_ext::LAYER_DESCRIPTION_APP]; let th = table_head(&mut w, "APPID", apps.len()); for app in apps { @@ -1523,6 +1617,14 @@ pub fn write(d: &Drawing) -> String { w.s(0, "ENDSEC"); // ---------------- OBJECTS ---------------- + // Groups (members that still exist) and multileader styles. + let group_owner = group_dict.clone(); + let written: std::collections::HashSet = w.t.iter().filter(|t| t.code == 5).map(Tag::str).collect(); + let groups: Vec<(&Group, String)> = d.groups.iter().filter(|g| !g.name.is_empty()).map(|g| (g, w.h())).collect(); + let default_mleader_style = [MLeaderStyle::default()]; + let mleader_styles: &[MLeaderStyle] = if d.mleader_styles.is_empty() { &default_mleader_style } else { &d.mleader_styles }; + let mleader_dict = w.h(); + let mleader_handles: Vec = mleader_styles.iter().map(|_| w.h()).collect(); w.s(0, "SECTION"); w.s(2, "OBJECTS"); w.s(0, "DICTIONARY"); @@ -1534,6 +1636,8 @@ pub fn write(d: &Drawing) -> String { w.s(350, group_dict.clone()); w.s(3, "ACAD_LAYOUT"); w.s(350, layout_dict.clone()); + w.s(3, "ACAD_MLEADERSTYLE"); + w.s(350, mleader_dict.clone()); w.s(3, "ACAD_TABLESTYLE"); w.s(350, table_style_dict.clone()); for (key, h, _) in &xrecords { @@ -1545,6 +1649,27 @@ pub fn write(d: &Drawing) -> String { w.s(330, root_dict.clone()); w.s(100, "AcDbDictionary"); w.i(281, 1); + for (g, h) in &groups { + w.s(3, &g.name); + w.s(350, h); + } + for (g, h) in &groups { + group_obj(&mut w, g, h, &group_owner, &written); + } + // Multileader styles. + w.s(0, "DICTIONARY"); + w.s(5, mleader_dict.clone()); + w.s(330, root_dict.clone()); + w.s(100, "AcDbDictionary"); + w.i(281, 1); + for (s, h) in mleader_styles.iter().zip(&mleader_handles) { + w.s(3, &s.name); + w.s(350, h); + } + let byblock_ltype = ltype_handles.get("BYBLOCK").cloned(); + for (s, h) in mleader_styles.iter().zip(&mleader_handles) { + mleader_style_obj(&mut w, s, h, &mleader_dict, byblock_ltype.as_deref(), cx.style(&s.text_style)); + } // Table styles. w.s(0, "DICTIONARY"); w.s(5, table_style_dict.clone()); @@ -1622,13 +1747,26 @@ pub fn write(d: &Drawing) -> String { w.s(3, &l.name); w.s(350, layout_handles.get(i).cloned().unwrap_or_default()); } - let layout_obj = |w: &mut W, h: &str, name: &str, tab: i64, brh: &str, flags: i64, page: &PageSetup| { + let layout_obj = |w: &mut W, h: &str, name: &str, tab: i64, brh: &str, flags: i64, page: &PageSetup, view: Option<(Vec2, f64)>| { + // Plot scale: a standard scale when fitting or 1:1, otherwise custom (142 paper units + // per 143 drawing units). + let scale = if page.scale.is_finite() && page.scale > 0.0 { page.scale } else { 1.0 }; + let standard = page.scale_to_fit || scale == 1.0; + let plot_flags = 512 | 32 | if standard { 16 } else { 0 } | if page.lineweights { 128 } else { 0 } | if page.center { 4 } else { 0 }; + let plot_type = match page.plot_area.as_str() { + "display" => 0, + "extents" => 1, + "limits" => 2, + "window" => 4, + _ => 5, + }; w.s(0, "LAYOUT"); w.s(5, h); w.s(330, layout_dict.clone()); w.s(100, "AcDbPlotSettings"); w.s(1, ""); - w.s(2, "none_device"); + let device = page.device.trim(); + w.s(2, if device.is_empty() || device.eq_ignore_ascii_case("None") { "none_device" } else { device }); w.s(4, page.paper.replace(' ', "_")); w.s(6, ""); for (c, v) in [ @@ -1644,18 +1782,18 @@ pub fn write(d: &Drawing) -> String { (49, 0.0), (140, 0.0), (141, 0.0), - (142, 1.0), + (142, scale), (143, 1.0), ] { w.f(c, v); } - w.i(70, 688); + w.i(70, plot_flags); w.i(72, 0); w.i(73, i64::from(page.landscape)); - w.i(74, 5); - w.s(7, ""); - w.i(75, 16); - w.f(147, 1.0); + w.i(74, plot_type); + w.s(7, &page.plot_style_table); + w.i(75, if page.scale_to_fit { 0 } else { 16 }); + w.f(147, scale); w.f(148, 0.0); w.f(149, 0.0); w.s(100, "AcDbLayout"); @@ -1673,11 +1811,20 @@ pub fn write(d: &Drawing) -> String { w.p(17, Vec3::new(0.0, 1.0, 0.0)); w.i(76, 0); w.s(330, brh); + // The saved paper-space view (centre, height) as CADCraft xdata. + if let Some((c, height)) = view.filter(|(c, v)| c.x.is_finite() && c.y.is_finite() && v.is_finite()) { + w.s(1001, dxf_ext::APP); + w.s(1000, "PSVIEW"); + w.s(1002, "{"); + w.p(1010, c.to3(0.0)); + w.f(1040, height); + w.s(1002, "}"); + } }; - layout_obj(&mut w, &model_layout, "Model", 0, &ms_br, 1, &PageSetup::default()); + layout_obj(&mut w, &model_layout, "Model", 0, &ms_br, 1, &PageSetup::default(), None); for (i, (_, brh, l)) in ps_brs.iter().enumerate() { let h = layout_handles.get(i).cloned().unwrap_or_default(); - layout_obj(&mut w, &h, &l.name, i64::from(l.tab_order.max(1)), brh, 1, &l.page); + layout_obj(&mut w, &h, &l.name, i64::from(l.tab_order.max(1)), brh, 1, &l.page, l.view); } w.s(0, "ENDSEC"); w.s(0, "EOF"); @@ -1744,6 +1891,82 @@ fn table_style_obj(w: &mut W, s: &TableStyle, h: &str, dict: &str) { } } +/// A GROUP object (DXF Reference, OBJECTS: GROUP): description, unnamed (`*A…`) and selectable +/// flags, and the member entities that were written to this file. +fn group_obj(w: &mut W, g: &Group, h: &str, dict: &str, written: &std::collections::HashSet) { + w.s(0, "GROUP"); + w.s(5, h); + w.group("ACAD_REACTORS", 330, &[dict]); + w.s(330, dict); + w.s(100, "AcDbGroup"); + w.s(300, &g.description); + w.i(70, i64::from(g.name.starts_with('*'))); + w.i(71, i64::from(g.selectable)); + for m in g.members.iter().map(|m| m.hex()).filter(|m| written.contains(m)) { + w.s(340, m); + } +} + +/// A MLEADERSTYLE object (DXF Reference, OBJECTS: MLEADERSTYLE): MText content and straight +/// ByBlock leaders with the style's landing gap, dogleg length, arrow size, text style and +/// text height. +fn mleader_style_obj(w: &mut W, s: &MLeaderStyle, h: &str, dict: &str, byblock_ltype: Option<&str>, text_style: Option) { + // ByBlock as a 32-bit colour value (0xC1000000). + const BYBLOCK: i64 = -1_056_964_608; + w.s(0, "MLEADERSTYLE"); + w.s(5, h); + w.group("ACAD_REACTORS", 330, &[dict]); + w.s(330, dict); + w.s(100, "AcDbMLeaderStyle"); + w.i(179, 2); + w.i(170, 2); + w.i(171, 1); + w.i(172, 0); + w.i(90, 2); + w.f(40, 0.0); + w.f(41, 0.0); + w.i(173, 1); + w.i(91, BYBLOCK); + if let Some(lt) = byblock_ltype { + w.s(340, lt); + } + w.i(92, -2); + w.i(290, 1); + w.f(42, s.landing_gap); + w.i(291, 1); + w.f(43, s.dogleg); + w.s(3, ""); + w.f(44, s.arrow_size); + w.s(300, ""); + if let Some(ts) = text_style { + w.s(342, ts); + } + w.i(174, 1); + w.i(178, 1); + w.i(175, 1); + w.i(176, 0); + w.i(93, BYBLOCK); + w.f(45, s.text_height); + w.i(292, 0); + w.i(297, 0); + w.f(46, 4.0); + w.i(94, BYBLOCK); + for c in [47, 49, 140] { + w.f(c, 1.0); + } + w.i(293, 1); + w.f(141, 0.0); + w.i(294, 1); + w.i(177, 0); + w.f(142, 1.0); + w.i(295, 0); + w.i(296, 0); + w.f(143, 0.125); + w.i(271, 0); + w.i(272, 9); + w.i(273, 9); +} + fn cadcraft_fonts_name() -> &'static str { "CADCraft Stroke" } diff --git a/crates/io/tests/tables_objects_roundtrip.rs b/crates/io/tests/tables_objects_roundtrip.rs new file mode 100644 index 00000000..93b3898a --- /dev/null +++ b/crates/io/tests/tables_objects_roundtrip.rs @@ -0,0 +1,241 @@ +//! DXF save → reopen keeps table and object-section data: complex linetype elements, +//! multileader styles, layout page setups, named views, UCSs and groups. + +use cadcraft_doc::geom::{Vec2, Vec3}; +use cadcraft_doc::{Common, DashElement, Drawing, EntityKind, Group, Line, Linetype, MLeaderStyle, NamedView, Space, TextStyle, Ucs, library}; +use cadcraft_io::{read_dxf, write_dxf}; + +fn round_trip(d: &Drawing) -> Drawing { + read_dxf(write_dxf(d).as_bytes()).expect("reopen") +} + +#[test] +fn linetype_text_and_shape_elements_round_trip() { + let mut d = Drawing::new_imperial(); + let fence = library::standard_linetypes().into_iter().find(|l| l.name == "FENCELINE1").expect("FENCELINE1"); + d.linetypes.push(fence.clone()); + let gas = Linetype { + name: "GAS".into(), + description: "Gas line ----GAS----".into(), + pattern: vec![ + DashElement::dash(0.5), + DashElement { + text: Some("GAS".into()), + style: Some("Standard".into()), + scale: 0.1, + rotation: 0.25, + offset: Vec2::new(-0.1, -0.05), + ..DashElement::dash(-0.2) + }, + ], + }; + // A shape from a shape file (not a text style), with an absolute rotation. + let shape = Linetype { + name: "BATTING".into(), + description: String::new(), + pattern: vec![ + DashElement::dash(0.0001), + DashElement { + shape: Some(132), + style: Some("ltypeshp.shx".into()), + scale: 0.2, + rotation: 1.5, + absolute: true, + offset: Vec2::new(0.0, 0.1), + ..DashElement::dash(-0.3) + }, + ], + }; + d.linetypes.extend([gas.clone(), shape.clone()]); + let dxf = write_dxf(&d); + // The shape file is written as an unnamed shape style (STYLE flag 1). + assert!(dxf.contains("ltypeshp.shx"), "no shape style record"); + let r = read_dxf(dxf.as_bytes()).expect("reopen"); + for lt in [&fence, &gas, &shape] { + assert_eq!(r.linetype(<.name), Some(lt), "{}", lt.name); + } + // The shape style isn't a text style. + assert!(r.text_styles.iter().all(|s| !s.name.is_empty())); +} + +#[test] +fn multileader_styles_and_current_style_round_trip() { + let mut d = Drawing::new_imperial(); + d.text_styles.push(TextStyle { name: "Slanted".into(), oblique: 0.2, ..TextStyle::default() }); + let callout = + MLeaderStyle { name: "Callout".into(), arrow_size: 2.0, text_height: 3.0, landing_gap: 1.0, dogleg: 5.0, text_style: "Slanted".into() }; + d.mleader_styles.push(callout); + d.header.set_str("CMLEADERSTYLE", "Callout"); + let r = round_trip(&d); + assert_eq!(r.mleader_styles, d.mleader_styles); + assert_eq!(r.header.str("CMLEADERSTYLE", ""), "Callout"); +} + +#[test] +fn layout_page_setup_and_paper_view_round_trip() { + let mut d = Drawing::new_imperial(); + let first = d.layouts.get_mut(0).expect("Layout1"); + first.page.scale = 0.5; + first.page.plot_style_table = "monochrome.ctb".into(); + first.page.center = true; + first.page.plot_area = "extents".into(); + first.view = Some((Vec2::new(5.0, 4.0), 12.0)); + let second = d.layouts.get_mut(1).expect("Layout2"); + second.page.scale_to_fit = true; + second.page.scale = 0.25; + second.page.lineweights = false; + second.page.plot_area = "window".into(); + second.page.device = "DWG To PDF.pc3".into(); + let r = round_trip(&d); + for (a, b) in d.layouts.iter().zip(&r.layouts) { + assert_eq!(a.name, b.name); + assert_eq!(a.page, b.page, "{}", a.name); + assert_eq!(a.view, b.view, "{}", a.name); + } + assert_eq!(r.layouts.len(), d.layouts.len()); +} + +#[test] +fn named_views_ucss_and_groups_round_trip() { + let mut d = Drawing::new_imperial(); + let line = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(10.0, 0.0, 0.0) })).expect("line"); + d.views.push(NamedView { name: "Front".into(), center: Vec2::new(5.0, 5.0), height: 20.0, width: 30.0, layer_state: Some("Plan".into()) }); + d.ucss.push(Ucs { name: "Tilted".into(), origin: Vec3::new(1.0, 2.0, 0.0), x_axis: Vec3::new(0.6, 0.8, 0.0), y_axis: Vec3::new(-0.8, 0.6, 0.0) }); + d.groups.push(Group { name: "G1".into(), description: "grp".into(), selectable: false, members: vec![line] }); + // A member that no longer exists is not written. + let gone = d.new_handle(); + d.groups.push(Group { name: "*A1".into(), description: String::new(), selectable: true, members: vec![line, gone] }); + let r = round_trip(&d); + assert_eq!(r.views, d.views); + assert_eq!(r.ucss, d.ucss); + assert_eq!(r.groups.len(), 2); + assert_eq!(r.groups.first(), d.groups.first()); + let anon = r.groups.get(1).expect("*A1"); + assert_eq!((anon.name.as_str(), anon.members.as_slice()), ("*A1", [line].as_slice())); +} + +/// A minimal file in the shape other programs write: an unnamed shape style, a VIEW, a UCS and a +/// GROUP in ACAD_GROUP. Hostile values (NaN, out-of-range shape numbers, missing groups) are +/// skipped without panicking. +#[test] +fn foreign_and_hostile_tables_objects() { + let tags: &[(i32, &str)] = &[ + (0, "SECTION"), + (2, "TABLES"), + (0, "TABLE"), + (2, "STYLE"), + (0, "STYLE"), + (5, "11"), + (2, ""), + (70, "1"), + (3, "shapes.shx"), + (0, "ENDTAB"), + (0, "TABLE"), + (2, "LTYPE"), + (0, "LTYPE"), + (5, "12"), + (2, "ODD"), + (73, "3"), + (49, "0.5"), + (74, "4"), + (75, "70000"), + (340, "11"), + (46, "nan"), + (49, "-0.25"), + (74, "2"), + (340, "FFFF"), + (9, "X"), + (45, "1e400"), + (0, "ENDTAB"), + (0, "TABLE"), + (2, "VIEW"), + (0, "VIEW"), + (2, "Top"), + (40, "10"), + (41, "16"), + (10, "3"), + (20, "4"), + (0, "VIEW"), + (2, "Bad"), + (40, "-1"), + (41, "nan"), + (0, "ENDTAB"), + (0, "TABLE"), + (2, "UCS"), + (0, "UCS"), + (2, "Side"), + (10, "1"), + (20, "2"), + (30, "3"), + (11, "0"), + (21, "1"), + (31, "0"), + (12, "0"), + (22, "0"), + (32, "1"), + (0, "ENDTAB"), + (0, "ENDSEC"), + (0, "SECTION"), + (2, "ENTITIES"), + (0, "LINE"), + (5, "2A"), + (8, "0"), + (10, "0"), + (20, "0"), + (11, "1"), + (21, "1"), + (0, "ENDSEC"), + (0, "SECTION"), + (2, "OBJECTS"), + (0, "DICTIONARY"), + (5, "C"), + (3, "ACAD_GROUP"), + (350, "D"), + (3, "ACAD_MLEADERSTYLE"), + (350, "E"), + (0, "DICTIONARY"), + (5, "D"), + (3, "Doors"), + (350, "20"), + (0, "GROUP"), + (5, "20"), + (330, "D"), + (300, "the doors"), + (70, "0"), + (71, "1"), + (340, "2A"), + (340, "zz"), + (340, "99"), + (0, "DICTIONARY"), + (5, "E"), + (3, "Odd"), + (350, "21"), + (0, "MLEADERSTYLE"), + (5, "21"), + (42, "-2"), + (43, "nan"), + (45, "-3"), + (342, "404"), + (0, "ENDSEC"), + (0, "EOF"), + ]; + let dxf: String = tags.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect(); + let d = read_dxf(dxf.as_bytes()).expect("read"); + let odd = d.linetype("ODD").expect("ODD"); + let first = odd.pattern.first().expect("shape element"); + assert_eq!((first.shape, first.style.as_deref()), (Some(0), Some("shapes.shx"))); + assert!(first.scale.is_finite()); + let second = odd.pattern.get(1).expect("text element"); + assert_eq!((second.text.as_deref(), second.style.as_deref()), (Some("X"), None)); + assert_eq!(d.views.iter().map(|v| v.name.as_str()).collect::>(), ["Top"]); + assert_eq!(d.ucss.first().map(|u| u.origin), Some(Vec3::new(1.0, 2.0, 3.0))); + let g = d.groups.first().expect("group"); + assert_eq!((g.name.as_str(), g.description.as_str(), g.members.len()), ("Doors", "the doors", 1)); + let odd_style = d.mleader_styles.iter().find(|s| s.name == "Odd").expect("mleader style"); + // Unusable values fall back to the defaults (NaN reads as 0). + assert_eq!(odd_style, &MLeaderStyle { name: "Odd".into(), dogleg: 0.0, ..MLeaderStyle::default() }); + // Saving it again keeps everything readable. + let again = round_trip(&d); + assert_eq!(again.groups, d.groups); + assert_eq!(again.views, d.views); +} From 89799f48d44a1df05d3d333050c118aceb838674 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 20:37:26 +0200 Subject: [PATCH 038/150] Keep raster images through DXF save; report entities a save can't keep (#208) (#217) Raster images: the reader now follows an IMAGE's group 340 to its IMAGEDEF and keeps the file path and the definition's name (its ACAD_IMAGE_DICT key), pixel size and resolution unit. It also keeps the clip boundary (in pixel coordinates), the clipping state, the display flags and brightness/contrast/fade. The writer emits the IMAGE entity, plus one IMAGEDEF per path and name, an IMAGEDEF_REACTOR per image, RASTERVARIABLES, the ACAD_IMAGE_DICT and ACAD_IMAGE_VARS entries in the named object dictionary, and the four CLASS records, as the DXF Reference describes. An image without a clip boundary gets the full-image rectangle. DWG save keeps the same data, except the resolution unit, which the DWG bridge drops. The pixels are still not loaded. Entity types CADCraft doesn't model (MLINE, MULTILEADER, 3DSOLID, REGION, MESH, ...) are still not written. Writing their raw groups back into the R2000 file would leave references to objects that aren't kept (MLINESTYLE, MLEADERSTYLE, ACIS data in later versions), and some of the types don't exist in R2000 at all. Another CAD program could then reject the whole file. Instead, qsave, saveas and document.bytes now say on the command line which ones weren't saved ("Not saved (CADCraft can't write these object types yet): 1 3DSOLID, 2 MLINE") and list them by type in the result's `notSaved`. `cadcraft-cli run --save` and `convert` print the same line. Co-authored-by: Claude Opus 5.5 --- apps/cadcraft-cli/src/main.rs | 6 + crates/doc/src/entity.rs | 58 ++++ crates/engine/src/cmd/file.rs | 68 +++- crates/engine/tests/save_reports_not_saved.rs | 45 +++ crates/io/src/dxf_image.rs | 318 ++++++++++++++++++ crates/io/src/dxf_read.rs | 6 +- crates/io/src/dxf_write.rs | 31 +- crates/io/src/lib.rs | 1 + .../io/tests/passthrough_image_roundtrip.rs | 205 +++++++++++ 9 files changed, 729 insertions(+), 9 deletions(-) create mode 100644 crates/engine/tests/save_reports_not_saved.rs create mode 100644 crates/io/src/dxf_image.rs create mode 100644 crates/io/tests/passthrough_image_roundtrip.rs diff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs index 88e2446f..6c3b3563 100644 --- a/apps/cadcraft-cli/src/main.rs +++ b/apps/cadcraft-cli/src/main.rs @@ -182,6 +182,12 @@ fn export_framed(s: &Session, out: &str, window: Option) -> Result<(), let bytes = cadcraft_io::write_framed(d, out, window).map_err(|e| e.to_string())?; std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?; eprintln!("wrote {out} ({} bytes)", bytes.len()); + if !cadcraft_io::is_image(out) { + let lost = cadcraft_engine::cmd::file::not_saved(d); + if let Some(m) = cadcraft_engine::cmd::file::not_saved_message(&lost) { + eprintln!("{m}"); + } + } Ok(()) } diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index e77a3bca..6a791116 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -491,10 +491,68 @@ pub struct Viewport { #[serde(rename_all = "camelCase")] pub struct Image { pub insert: Vec3, + /// One pixel's width (U) and height (V) vectors in drawing units. pub u: Vec3, pub v: Vec3, + /// Image size in pixels. pub size: Vec2, + /// The image file (its IMAGEDEF object's path). pub path: String, + /// The image definition's name (its key in the drawing's image dictionary). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub name: String, + /// Clip boundary in pixel coordinates (origin at the top-left corner of the image, y down): + /// two opposite corners of a rectangle, or a polygon's vertices. Empty: the whole image. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub clip: Vec, + /// The clip boundary is applied. + #[serde(default)] + pub clipping: bool, + /// Display flags: 1 show the image, 2 show it when not aligned with the screen, 4 use the + /// clip boundary, 8 transparency on. + #[serde(default = "image_display")] + pub display: u16, + /// Brightness, contrast and fade, 0..=100. + #[serde(default = "image_fifty")] + pub brightness: u8, + #[serde(default = "image_fifty")] + pub contrast: u8, + #[serde(default)] + pub fade: u8, + /// The definition's default size of one pixel and its resolution unit (0 none, 2 cm, 5 inch). + #[serde(default = "image_pixel")] + pub pixel_size: Vec2, + #[serde(default)] + pub resolution_units: u8, +} +fn image_display() -> u16 { + 7 +} +fn image_fifty() -> u8 { + 50 +} +fn image_pixel() -> Vec2 { + Vec2::new(1.0, 1.0) +} +impl Default for Image { + fn default() -> Self { + Image { + insert: Vec3::ZERO, + u: Vec3::new(1.0, 0.0, 0.0), + v: Vec3::new(0.0, 1.0, 0.0), + size: Vec2::new(1.0, 1.0), + path: String::new(), + name: String::new(), + clip: Vec::new(), + clipping: false, + display: image_display(), + brightness: image_fifty(), + contrast: image_fifty(), + fade: 0, + pixel_size: image_pixel(), + resolution_units: 0, + } + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Wipeout { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index c27e23da..025b0e14 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -46,8 +46,8 @@ pub fn specs() -> Vec { .noundo(), CommandSpec::new("close", "Close", run_close).menu(&["File", "Close"]).key("Cmd+W").params("{index?}").noundo(), CommandSpec::new("closeall", "Close All", run_closeall).menu(&["File", "Close All"]).enabled(always).noundo(), - CommandSpec::new("qsave", "Save", run_save).menu(&["File", "Save"]).key("Cmd+S").alias(&["save"]).params("{path?}").noundo(), - CommandSpec::new("saveas", "Save As...", run_saveas) + CommandSpec::new("qsave", "Save", run_qsave_reporting).menu(&["File", "Save"]).key("Cmd+S").alias(&["save"]).params("{path?}").noundo(), + CommandSpec::new("saveas", "Save As...", run_saveas_reporting) .menu(&["File", "Save As..."]) .key("Cmd+Shift+S") .params("{path, format?: dxf|dwg}") @@ -160,8 +160,70 @@ fn run_switch(s: &mut Session, p: &Value) -> Result { fn run_bytes(s: &mut Session, p: &Value) -> Result { let hooks = io().ok_or_else(|| bad("document.bytes", "file formats are not available in this build"))?; let fmt = str_param(p, "format").unwrap_or("dxf"); + let lost = not_saved(s.doc()?); let bytes = (hooks.write)(s.doc()?, &format!("drawing.{fmt}")).map_err(|e| bad("document.bytes", e))?; - Ok(json!({ "data": base64_encode(&bytes), "bytes": bytes.len() })) + let mut r = json!({ "data": base64_encode(&bytes), "bytes": bytes.len() }); + if is_drawing_file(&format!("drawing.{fmt}")) { + report_not_saved(s, &mut r, &lost); + } + Ok(r) +} + +/// Entities a DXF or DWG file doesn't keep: those of object types CADCraft doesn't model +/// (MLINE, 3DSOLID, MULTILEADER… read from other programs' files and kept in the drawing, but +/// not written), counted by DXF type across model space, layouts and block definitions. +pub fn not_saved(d: &Drawing) -> std::collections::BTreeMap { + let mut out: std::collections::BTreeMap = std::collections::BTreeMap::new(); + let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities)); + for e in stores.flat_map(|s| s.iter()) { + if let cadcraft_doc::EntityKind::Unknown(u) = &e.kind { + let n = out.entry(u.dxf_type.clone()).or_insert(0); + *n = n.saturating_add(1); + } + } + out +} + +/// The command-line warning for [`not_saved`] entities, or `None` when the file keeps everything. +pub fn not_saved_message(lost: &std::collections::BTreeMap) -> Option { + if lost.is_empty() { + return None; + } + let list: Vec = lost.iter().map(|(t, n)| format!("{n} {t}")).collect(); + Some(format!("Not saved (CADCraft can't write these object types yet): {}", list.join(", "))) +} + +/// Whether `path` is saved as a drawing (DXF, DWG) rather than exported as an image. +fn is_drawing_file(path: &str) -> bool { + let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + matches!(ext.as_str(), "dxf" | "dwg" | "") +} + +/// Say on the command line what the file couldn't keep, and list it in the result's `notSaved`. +fn report_not_saved(s: &mut Session, r: &mut Value, lost: &std::collections::BTreeMap) { + let Some(msg) = not_saved_message(lost) else { return }; + s.echo(msg); + if let Some(o) = r.as_object_mut() { + o.insert("notSaved".into(), json!(lost)); + } +} + +/// Save, then report the entities the file couldn't keep (nothing is dropped silently). +fn save_reporting(s: &mut Session, p: &Value, run: fn(&mut Session, &Value) -> Result) -> Result { + let lost = s.doc().map(not_saved).unwrap_or_default(); + let mut r = run(s, p)?; + if r.get("path").and_then(Value::as_str).is_some_and(is_drawing_file) { + report_not_saved(s, &mut r, &lost); + } + Ok(r) +} + +fn run_qsave_reporting(s: &mut Session, p: &Value) -> Result { + save_reporting(s, p, run_save) +} + +fn run_saveas_reporting(s: &mut Session, p: &Value) -> Result { + save_reporting(s, p, run_saveas) } const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; diff --git a/crates/engine/tests/save_reports_not_saved.rs b/crates/engine/tests/save_reports_not_saved.rs new file mode 100644 index 00000000..7525a0b6 --- /dev/null +++ b/crates/engine/tests/save_reports_not_saved.rs @@ -0,0 +1,45 @@ +//! Saving a drawing with entities of types CADCraft doesn't model (read from another program's +//! file) says which ones the DXF/DWG file doesn't keep, instead of dropping them silently. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::{Block, Common, Drawing, EntityKind, RawTag, Space, Unknown}; +use serde_json::json; + +fn unknown(kind: &str) -> EntityKind { + EntityKind::Unknown(Unknown { dxf_type: kind.into(), tags: vec![RawTag { code: 8, value: "0".into() }] }) +} + +#[test] +fn save_lists_the_entities_the_file_does_not_keep() { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None }); + let mut d = Drawing::new_imperial(); + for k in ["MLINE", "3DSOLID", "MLINE"] { + d.add(&Space::Model, Common::default(), unknown(k)).unwrap(); + } + let mut b = Block::new("Part"); + let h = d.new_handle(); + b.entities.push(cadcraft_engine::doc::Entity { handle: h, common: Common::default(), kind: unknown("REGION") }); + d.blocks.insert("Part".into(), std::sync::Arc::new(b)); + let mut s = Session::empty(); + s.open_drawing(d, "foreign.dxf", None); + + let path = std::env::temp_dir().join(format!("cadcraft-not-saved-{}.dxf", std::process::id())); + let r = s.execute("saveas", &json!({ "path": path.to_string_lossy() })).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(r["notSaved"], json!({ "3DSOLID": 1, "MLINE": 2, "REGION": 1 })); + let msg = "Not saved (CADCraft can't write these object types yet): 1 3DSOLID, 2 MLINE, 1 REGION"; + assert!(s.log.iter().any(|l| l == msg), "{:?}", s.log); + + // Exporting an image isn't a save of the drawing: no report. + let png = std::env::temp_dir().join(format!("cadcraft-not-saved-{}.png", std::process::id())); + let r = s.execute("saveas", &json!({ "path": png.to_string_lossy() })).unwrap(); + let _ = std::fs::remove_file(&png); + assert!(r.get("notSaved").is_none()); + + // A drawing the file keeps completely: nothing to report. + s.open_drawing(Drawing::new_imperial(), "plain.dxf", None); + let n = s.log.len(); + let r = s.execute("document.bytes", &json!({})).unwrap(); + assert!(r.get("notSaved").is_none() && s.log.len() == n); +} diff --git a/crates/io/src/dxf_image.rs b/crates/io/src/dxf_image.rs new file mode 100644 index 00000000..1b16fe8d --- /dev/null +++ b/crates/io/src/dxf_image.rs @@ -0,0 +1,318 @@ +//! Raster images in DXF (DXF Reference: IMAGE entity; IMAGEDEF, IMAGEDEF_REACTOR and +//! RASTERVARIABLES objects). An IMAGE entity holds the placement, clip boundary and display +//! settings; it points (group 340) to an IMAGEDEF object holding the file path, which the +//! `ACAD_IMAGE_DICT` dictionary names. CADCraft keeps this data so images survive save and +//! reopen; the pixels themselves are not loaded. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use cadcraft_doc::{Drawing, EntityKind, EntityStore, Handle, Image}; +use cadcraft_dxf::{Section, Tag}; +use cadcraft_geom::{Vec2, Vec3}; + +/// Most clip boundary vertices kept per image (hostile files). +const MAX_CLIP: usize = 100_000; + +/// The IMAGE entity's own groups (after the `AcDbRasterImage` marker when present). +pub(crate) fn read_entity(tags: &[Tag]) -> Image { + let start = tags.iter().position(|t| t.code == 100 && t.str() == "AcDbRasterImage").unwrap_or(0); + let tags = tags.get(start..).unwrap_or(&[]); + let f = |c: i32| tags.iter().find(|t| t.code == c).map(Tag::f64); + let i = |c: i32| tags.iter().find(|t| t.code == c).map(Tag::i64); + let p = |c: i32| Vec3::new(f(c).unwrap_or(0.0), f(c + 10).unwrap_or(0.0), f(c + 20).unwrap_or(0.0)); + let pct = |c: i32, d: u8| i(c).and_then(|v| u8::try_from(v.clamp(0, 100)).ok()).unwrap_or(d); + let xs = tags.iter().filter(|t| t.code == 14).map(Tag::f64); + let ys = tags.iter().filter(|t| t.code == 24).map(Tag::f64); + let mut clip: Vec = xs.zip(ys).take(MAX_CLIP).map(|(x, y)| Vec2::new(x, y)).collect(); + // A rectangular boundary (type 1) is two opposite corners. + if i(71) == Some(1) { + clip.truncate(2); + } + let def = Image::default(); + Image { + insert: p(10), + u: p(11), + v: p(12), + size: Vec2::new(f(13).unwrap_or(1.0), f(23).unwrap_or(1.0)), + clip, + clipping: i(280).is_some_and(|v| v != 0), + display: i(70).and_then(|v| u16::try_from(v).ok()).unwrap_or(def.display), + brightness: pct(281, def.brightness), + contrast: pct(282, def.contrast), + fade: pct(283, def.fade), + ..def + } +} + +/// Call `f` with each record (`0 TYPE` and the groups after it) of a section, without copying. +fn each_record<'a>(tags: &'a [Tag], mut f: impl FnMut(&str, &'a [Tag])) { + let mut open: Option<(String, usize)> = None; + for (i, t) in tags.iter().enumerate() { + if t.code == 0 { + if let Some((k, s)) = open.take() { + f(&k, tags.get(s..i).unwrap_or(&[])); + } + open = Some((t.str(), i + 1)); + } + } + if let Some((k, s)) = open { + f(&k, tags.get(s..).unwrap_or(&[])); + } +} + +fn key(s: &str) -> String { + s.trim().to_ascii_uppercase() +} + +/// An IMAGEDEF object's data. +#[derive(Default)] +struct Def { + name: String, + path: String, + pixel_size: Option, + units: u8, +} + +fn has_images(store: &EntityStore) -> bool { + store.iter().any(|e| matches!(e.kind, EntityKind::Image(_))) +} + +/// Fill in each image's definition (path, name, pixel size) from the IMAGEDEF it points to. +pub(crate) fn resolve(secs: &[Section], d: &mut Drawing) { + if !has_images(&d.model) && !d.layouts.iter().any(|l| has_images(&l.entities)) && !d.blocks.values().any(|b| has_images(&b.entities)) { + return; + } + // IMAGE handle → IMAGEDEF handle. + let mut refs: HashMap = HashMap::new(); + for s in secs.iter().filter(|s| s.name == "ENTITIES" || s.name == "BLOCKS") { + each_record(&s.tags, |kind, tags| { + if kind != "IMAGE" { + return; + } + let h = tags.iter().find(|t| t.code == 5).and_then(|t| Handle::parse_hex(&t.str())); + let def = tags.iter().find(|t| t.code == 340).map(|t| key(&t.str())); + if let (Some(h), Some(def)) = (h, def) { + refs.insert(h, def); + } + }); + } + // IMAGEDEF objects, and the names dictionaries give them. + let mut defs: HashMap = HashMap::new(); + let mut names: HashMap = HashMap::new(); + for s in secs.iter().filter(|s| s.name == "OBJECTS") { + each_record(&s.tags, |kind, tags| match kind { + "IMAGEDEF" => { + let Some(h) = tags.iter().find(|t| t.code == 5).map(|t| key(&t.str())) else { return }; + let start = tags.iter().position(|t| t.code == 100 && t.str() == "AcDbRasterImageDef").unwrap_or(0); + let body = tags.get(start..).unwrap_or(&[]); + let f = |c: i32| body.iter().find(|t| t.code == c).map(Tag::f64); + let pixel = match (f(11), f(21)) { + (Some(x), Some(y)) if x > 0.0 && y > 0.0 => Some(Vec2::new(x, y)), + _ => None, + }; + let units = body.iter().find(|t| t.code == 281).and_then(|t| u8::try_from(t.i64()).ok()).unwrap_or(0); + let path = body.iter().find(|t| t.code == 1).map(Tag::str).unwrap_or_default(); + defs.insert(h, Def { path, pixel_size: pixel, units, ..Def::default() }); + } + "DICTIONARY" => { + let mut name: Option = None; + for t in tags { + match t.code { + 3 => name = Some(t.str()), + 350 | 360 => { + if let Some(n) = name.take() { + names.insert(key(&t.str()), n); + } + } + _ => {} + } + } + } + _ => {} + }); + } + for (h, def) in &mut defs { + if let Some(n) = names.get(h) { + def.name = n.clone(); + } + } + let by_image: HashMap = refs.iter().filter_map(|(img, dh)| Some((*img, defs.get(dh)?))).collect(); + if by_image.is_empty() { + return; + } + fill(&mut d.model, &by_image); + for l in &mut d.layouts { + fill(&mut l.entities, &by_image); + } + for b in d.blocks.values_mut() { + if b.entities.iter().any(|e| matches!(e.kind, EntityKind::Image(_)) && by_image.contains_key(&e.handle)) { + fill(&mut Arc::make_mut(b).entities, &by_image); + } + } +} + +fn fill(store: &mut EntityStore, by_image: &HashMap) { + let hs: Vec = + store.iter().filter(|e| matches!(e.kind, EntityKind::Image(_)) && by_image.contains_key(&e.handle)).map(|e| e.handle).collect(); + for h in hs { + let Some(def) = by_image.get(&h) else { continue }; + store.modify(h, |e| { + if let EntityKind::Image(i) = &mut e.kind { + i.path = def.path.clone(); + i.name = def.name.clone(); + if let Some(p) = def.pixel_size { + i.pixel_size = p; + } + i.resolution_units = def.units; + } + }); + } +} + +/// One IMAGEDEF to write, shared by the images with the same path and name. +struct PlannedDef { + handle: String, + name: String, + path: String, + size: Vec2, + pixel_size: Vec2, + units: u8, + /// (IMAGEDEF_REACTOR handle, IMAGE handle) per image using it. + reactors: Vec<(String, Handle)>, +} + +/// The objects behind a drawing's images, with handles allocated before anything is written. +#[derive(Default)] +pub(crate) struct Plan { + /// IMAGE entity → (IMAGEDEF handle, IMAGEDEF_REACTOR handle). + pub by_entity: HashMap, + defs: Vec, + /// The `ACAD_IMAGE_DICT` dictionary and the `ACAD_IMAGE_VARS` RASTERVARIABLES object. + pub dict: String, + pub vars: String, +} + +impl Plan { + pub fn is_empty(&self) -> bool { + self.defs.is_empty() + } +} + +/// The image's definition name: its own, or the file name without extension. +fn def_name(i: &Image) -> String { + let n = i.name.trim(); + if !n.is_empty() { + return n.to_string(); + } + let file = i.path.rsplit(['/', '\\']).next().unwrap_or(""); + let stem = file.rsplit_once('.').map(|(s, _)| s).unwrap_or(file).trim(); + if stem.is_empty() { "Image".to_string() } else { stem.to_string() } +} + +/// Allocate the IMAGEDEF, IMAGEDEF_REACTOR and dictionary handles for every image entity. +pub(crate) fn plan<'a>(entities: impl Iterator, mut next: impl FnMut() -> String) -> Plan { + let mut p = Plan::default(); + let mut index: HashMap<(String, String), usize> = HashMap::new(); + let mut used: HashSet = HashSet::new(); + for e in entities { + let EntityKind::Image(i) = &e.kind else { continue }; + if p.by_entity.contains_key(&e.handle) { + continue; + } + let k = (i.path.clone(), i.name.clone()); + let at = match index.get(&k) { + Some(&at) => at, + None => { + // Dictionary keys are unique regardless of case. + let base = def_name(i); + let mut name = base.clone(); + let mut n = 2usize; + while !used.insert(key(&name)) { + name = format!("{base}_{n}"); + n = n.saturating_add(1); + } + p.defs.push(PlannedDef { + handle: next(), + name, + path: i.path.clone(), + size: i.size, + pixel_size: i.pixel_size, + units: i.resolution_units, + reactors: Vec::new(), + }); + let at = p.defs.len().saturating_sub(1); + index.insert(k, at); + at + } + }; + let reactor = next(); + if let Some(def) = p.defs.get_mut(at) { + def.reactors.push((reactor.clone(), e.handle)); + p.by_entity.insert(e.handle, (def.handle.clone(), reactor)); + } + } + if !p.defs.is_empty() { + p.dict = next(); + p.vars = next(); + } + p +} + +fn p3(t: &mut Vec, c: i32, v: Vec3) { + t.extend([Tag::f(c, v.x), Tag::f(c + 10, v.y), Tag::f(c + 20, v.z)]); +} + +/// The IMAGE entity's groups after its common entity groups. +pub(crate) fn entity_tags(i: &Image, def: &str, reactor: &str) -> Vec { + let mut t = vec![Tag::i(90, 0)]; + p3(&mut t, 10, i.insert); + p3(&mut t, 11, i.u); + p3(&mut t, 12, i.v); + t.extend([Tag::f(13, i.size.x), Tag::f(23, i.size.y), Tag::s(340, def), Tag::i(70, i64::from(i.display))]); + t.extend([ + Tag::i(280, i64::from(i.clipping)), + Tag::i(281, i64::from(i.brightness.min(100))), + Tag::i(282, i64::from(i.contrast.min(100))), + Tag::i(283, i64::from(i.fade.min(100))), + Tag::s(360, reactor), + ]); + // Without a boundary of its own the image is clipped to its full extent (pixel corners). + let full = [Vec2::new(-0.5, -0.5), Vec2::new(i.size.x - 0.5, i.size.y - 0.5)]; + let clip: &[Vec2] = if i.clip.len() >= 2 { &i.clip } else { &full }; + t.push(Tag::i(71, if clip.len() == 2 { 1 } else { 2 })); + t.push(Tag::i(91, clip.len() as i64)); + for q in clip { + t.extend([Tag::f(14, q.x), Tag::f(24, q.y)]); + } + t +} + +/// The image dictionary, IMAGEDEF and IMAGEDEF_REACTOR objects and RASTERVARIABLES; `root` is +/// the named object dictionary, which lists `ACAD_IMAGE_DICT` and `ACAD_IMAGE_VARS`. +pub(crate) fn objects(p: &Plan, root: &str) -> Vec { + let mut t = Vec::new(); + if p.is_empty() { + return t; + } + t.extend([Tag::s(0, "DICTIONARY"), Tag::s(5, &p.dict), Tag::s(330, root), Tag::s(100, "AcDbDictionary"), Tag::i(281, 1)]); + for d in &p.defs { + t.extend([Tag::s(3, &d.name), Tag::s(350, &d.handle)]); + } + for d in &p.defs { + t.extend([Tag::s(0, "IMAGEDEF"), Tag::s(5, &d.handle), Tag::s(102, "{ACAD_REACTORS"), Tag::s(330, &p.dict)]); + t.extend(d.reactors.iter().map(|(r, _)| Tag::s(330, r))); + t.extend([Tag::s(102, "}"), Tag::s(330, &p.dict), Tag::s(100, "AcDbRasterImageDef"), Tag::i(90, 0), Tag::s(1, &d.path)]); + t.extend([Tag::f(10, d.size.x), Tag::f(20, d.size.y), Tag::f(11, d.pixel_size.x), Tag::f(21, d.pixel_size.y)]); + t.extend([Tag::i(280, 1), Tag::i(281, i64::from(d.units))]); + for (r, img) in &d.reactors { + let ih = img.hex(); + t.extend([Tag::s(0, "IMAGEDEF_REACTOR"), Tag::s(5, r), Tag::s(330, &ih), Tag::s(100, "AcDbRasterImageDefReactor")]); + t.extend([Tag::i(90, 2), Tag::s(330, ih)]); + } + } + // Image frame shown, high display quality, no insertion units. + t.extend([Tag::s(0, "RASTERVARIABLES"), Tag::s(5, &p.vars), Tag::s(102, "{ACAD_REACTORS"), Tag::s(330, root), Tag::s(102, "}")]); + t.extend([Tag::s(330, root), Tag::s(100, "AcDbRasterVariables"), Tag::i(90, 0), Tag::i(70, 1), Tag::i(71, 1), Tag::i(72, 0)]); + t +} diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 5574e727..9d1b38cc 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -317,9 +317,8 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { let pts: Vec = t.pts(14).into_iter().map(|q| o.xy() + u.xy() * (q.x + 0.5) + v.xy() * (0.5 - q.y)).collect(); EntityKind::Wipeout(Wipeout { boundary: pts }) } - "IMAGE" => { - EntityKind::Image(Image { insert: t.p(10), u: t.p(11), v: t.p(12), size: Vec2::new(t.fd(13, 1.0), t.fd(23, 1.0)), path: String::new() }) - } + // The file path comes from the IMAGEDEF object (`dxf_image::resolve`). + "IMAGE" => EntityKind::Image(crate::dxf_image::read_entity(tags)), other => EntityKind::Unknown(Unknown { dxf_type: other.to_string(), tags: tags.iter().map(|tg| RawTag { code: tg.code, value: tg.str() }).collect(), @@ -1224,6 +1223,7 @@ pub fn read(bytes: &[u8]) -> Result { } } objs.apply(&mut d, &rx); + crate::dxf_image::resolve(&secs, &mut d); // Keep handles unique against the header's seed. let seed = d.header.str("HANDSEED", ""); if let Some(h) = Handle::parse_hex(&seed) { diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index dbc57905..6a426c33 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -104,6 +104,8 @@ struct Ctx { tables: HashMap, /// Table style name → TABLESTYLE handle (the first is the fallback). table_styles: Vec<(String, String)>, + /// Raster images' IMAGEDEF, IMAGEDEF_REACTOR and dictionary handles. + images: crate::dxf_image::Plan, } impl Ctx { @@ -726,6 +728,13 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx w.p(10, *v); } } + EntityKind::Image(i) => { + // Every image has its IMAGEDEF (planned in `write`); the fallback never happens. + let Some((def, reactor)) = cx.images.by_entity.get(&e.handle) else { return }; + w.s(0, "IMAGE"); + common(w, e, owner, paper, "AcDbRasterImage"); + w.t.extend(crate::dxf_image::entity_tags(i, def, reactor)); + } EntityKind::Solid(s) | EntityKind::Trace(s) => { let solid = matches!(e.kind, EntityKind::Solid(_)); w.s(0, if solid { "SOLID" } else { "TRACE" }); @@ -853,8 +862,8 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx w.p2(14, Vec2::new((q.x - o.x) / sx - 0.5, 0.5 - (q.y - o.y) / sy)); } } - // Not yet written: images, tables, multileaders, unknown objects. - _ => {} + // Entity types CADCraft doesn't model aren't written; the save command reports them. + EntityKind::Unknown(_) => {} } } @@ -1204,6 +1213,8 @@ pub fn write(d: &Drawing) -> String { table_defs.push((name, brh, table_block_entities(d, t, &e.common.layer))); } } + // Image definitions (IMAGEDEF objects) of raster images anywhere in the drawing. + cx.images = crate::dxf_image::plan(every.iter().copied(), || w.h()); // DIMASSOC objects of associative dimensions in model and paper space. for st in &all_spaces { for e in st.iter() { @@ -1271,6 +1282,12 @@ pub fn write(d: &Drawing) -> String { if every.iter().any(|e| matches!(e.kind, EntityKind::Wipeout(_))) { classes.push(("WIPEOUT", "AcDbWipeout", 127, true)); } + if !cx.images.is_empty() { + classes.push(("IMAGE", "AcDbRasterImage", 127, true)); + classes.push(("IMAGEDEF", "AcDbRasterImageDef", 0, false)); + classes.push(("IMAGEDEF_REACTOR", "AcDbRasterImageDefReactor", 1, false)); + classes.push(("RASTERVARIABLES", "AcDbRasterVariables", 0, false)); + } for (dxf_name, cpp, proxy, is_entity) in classes { w.s(0, "CLASS"); w.s(1, dxf_name); @@ -1634,6 +1651,12 @@ pub fn write(d: &Drawing) -> String { w.i(281, 1); w.s(3, "ACAD_GROUP"); w.s(350, group_dict.clone()); + if !cx.images.is_empty() { + w.s(3, "ACAD_IMAGE_DICT"); + w.s(350, cx.images.dict.clone()); + w.s(3, "ACAD_IMAGE_VARS"); + w.s(350, cx.images.vars.clone()); + } w.s(3, "ACAD_LAYOUT"); w.s(350, layout_dict.clone()); w.s(3, "ACAD_MLEADERSTYLE"); @@ -1738,7 +1761,7 @@ pub fn write(d: &Drawing) -> String { } w.s(0, "DICTIONARY"); w.s(5, layout_dict.clone()); - w.s(330, root_dict); + w.s(330, root_dict.clone()); w.s(100, "AcDbDictionary"); w.i(281, 1); w.s(3, "Model"); @@ -1826,6 +1849,8 @@ pub fn write(d: &Drawing) -> String { let h = layout_handles.get(i).cloned().unwrap_or_default(); layout_obj(&mut w, &h, &l.name, i64::from(l.tab_order.max(1)), brh, 1, &l.page, l.view); } + // Raster image definitions. + w.t.extend(crate::dxf_image::objects(&cx.images, &root_dict)); w.s(0, "ENDSEC"); w.s(0, "EOF"); let seed = format!("{:X}", w.next); diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index c06d677c..8a25af5f 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -3,6 +3,7 @@ #![forbid(unsafe_code)] mod dxf_ext; +mod dxf_image; mod dxf_read; mod dxf_write; pub mod pdf; diff --git a/crates/io/tests/passthrough_image_roundtrip.rs b/crates/io/tests/passthrough_image_roundtrip.rs new file mode 100644 index 00000000..9b26c2b1 --- /dev/null +++ b/crates/io/tests/passthrough_image_roundtrip.rs @@ -0,0 +1,205 @@ +//! Raster images survive DXF save and reopen: the IMAGE entity's placement, clip boundary and +//! display settings, and its IMAGEDEF (file path and name), written with the IMAGEDEF_REACTOR, +//! RASTERVARIABLES and dictionaries the DXF Reference describes. + +use cadcraft_doc::{Drawing, EntityKind, Image}; +use cadcraft_dxf::{Tag, records, sections}; +use cadcraft_geom::Vec2; + +/// A small DXF as another program writes it: a line, an image on layer `Pics` pointing at an +/// IMAGEDEF named `logo`, and an MLINE (a type CADCraft doesn't model). +fn foreign_dxf() -> String { + let groups: &[(i32, &str)] = &[ + (0, "SECTION"), + (2, "ENTITIES"), + (0, "LINE"), + (5, "30"), + (8, "0"), + (10, "0"), + (20, "0"), + (11, "10"), + (21, "0"), + (0, "IMAGE"), + (5, "31"), + (100, "AcDbEntity"), + (8, "Pics"), + (100, "AcDbRasterImage"), + (90, "0"), + (10, "5"), + (20, "5"), + (30, "0"), + (11, "0.01"), + (21, "0"), + (31, "0"), + (12, "0"), + (22, "0.01"), + (32, "0"), + (13, "640"), + (23, "480"), + (340, "40"), + (70, "15"), + (280, "1"), + (281, "60"), + (282, "40"), + (283, "10"), + (360, "42"), + (71, "2"), + (91, "4"), + (14, "-0.5"), + (24, "-0.5"), + (14, "639.5"), + (24, "-0.5"), + (14, "320"), + (24, "479.5"), + (14, "-0.5"), + (24, "-0.5"), + (0, "MLINE"), + (5, "32"), + (100, "AcDbEntity"), + (8, "0"), + (100, "AcDbMline"), + (2, "STANDARD"), + (0, "ENDSEC"), + (0, "SECTION"), + (2, "OBJECTS"), + (0, "DICTIONARY"), + (5, "C"), + (330, "0"), + (100, "AcDbDictionary"), + (3, "ACAD_IMAGE_DICT"), + (350, "41"), + (0, "DICTIONARY"), + (5, "41"), + (330, "C"), + (100, "AcDbDictionary"), + (3, "logo"), + (350, "40"), + (0, "IMAGEDEF"), + (5, "40"), + (102, "{ACAD_REACTORS"), + (330, "41"), + (330, "42"), + (102, "}"), + (330, "41"), + (100, "AcDbRasterImageDef"), + (90, "0"), + (1, "images/logo.png"), + (10, "640"), + (20, "480"), + (11, "0.25"), + (21, "0.25"), + (280, "1"), + (281, "5"), + (0, "IMAGEDEF_REACTOR"), + (5, "42"), + (330, "31"), + (100, "AcDbRasterImageDefReactor"), + (90, "2"), + (330, "31"), + (0, "ENDSEC"), + (0, "EOF"), + ]; + groups.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect() +} + +fn images(d: &Drawing) -> Vec { + d.model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Image(i) => Some(i.clone()), + _ => None, + }) + .collect() +} + +/// The records of a section of a DXF text. +fn section(dxf: &str, name: &str) -> Vec<(String, Vec)> { + let tags = cadcraft_dxf::parse(dxf.as_bytes()).unwrap(); + let s = sections(&tags).into_iter().find(|s| s.name == name).unwrap(); + records(&s.tags) +} + +fn val(tags: &[Tag], code: i32) -> String { + tags.iter().find(|t| t.code == code).map(Tag::str).unwrap_or_default() +} + +#[test] +fn image_and_its_definition_survive_save_and_reopen() { + let d = cadcraft_io::read_dxf(foreign_dxf().as_bytes()).unwrap(); + let [img] = images(&d).try_into().unwrap(); + assert_eq!((img.path.as_str(), img.name.as_str()), ("images/logo.png", "logo")); + assert_eq!(img.size, Vec2::new(640.0, 480.0)); + assert_eq!(img.clip.len(), 4); + assert!(img.clipping); + assert_eq!((img.display, img.brightness, img.contrast, img.fade), (15, 60, 40, 10)); + assert_eq!((img.pixel_size, img.resolution_units), (Vec2::new(0.25, 0.25), 5)); + + let saved = cadcraft_io::write_dxf(&d); + let back = cadcraft_io::read_dxf(saved.as_bytes()).unwrap(); + assert_eq!(images(&back), vec![img.clone()]); + let e = back.model.iter().find(|e| matches!(e.kind, EntityKind::Image(_))).unwrap(); + assert_eq!(e.common.layer, "Pics"); + + // The saved objects link up as the DXF Reference describes. + let ents = section(&saved, "ENTITIES"); + let (_, it) = ents.iter().find(|(k, _)| k == "IMAGE").unwrap(); + let objs = section(&saved, "OBJECTS"); + let find = |kind: &str| objs.iter().filter(|(k, _)| k == kind).map(|(_, t)| t.clone()).collect::>(); + let [def] = find("IMAGEDEF").try_into().unwrap(); + let [reactor] = find("IMAGEDEF_REACTOR").try_into().unwrap(); + assert_eq!(find("RASTERVARIABLES").len(), 1); + assert_eq!(val(it, 340), val(&def, 5)); + assert_eq!(val(it, 360), val(&reactor, 5)); + assert_eq!(val(&reactor, 330), val(it, 5)); + assert!(def.iter().any(|t| t.code == 330 && t.str() == val(&reactor, 5)), "the IMAGEDEF lists its reactor"); + assert_eq!(val(&def, 1), "images/logo.png"); + let dicts = find("DICTIONARY"); + assert!(dicts.iter().any(|t| t.iter().any(|g| g.code == 3 && g.str() == "ACAD_IMAGE_DICT"))); + assert!(dicts.iter().any(|t| t.iter().any(|g| g.code == 3 && g.str() == "logo") && t.iter().any(|g| g.code == 350 && g.str() == val(&def, 5)))); + let classes = section(&saved, "CLASSES"); + for c in ["IMAGE", "IMAGEDEF", "IMAGEDEF_REACTOR", "RASTERVARIABLES"] { + assert!(classes.iter().any(|(_, t)| val(t, 1) == c), "CLASS {c}"); + } + // Saving again is stable. + let again = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&back).as_bytes()).unwrap(); + assert_eq!(images(&again), vec![img]); +} + +#[test] +fn images_of_one_file_share_a_definition() { + let mut d = cadcraft_io::read_dxf(foreign_dxf().as_bytes()).unwrap(); + let e = d.model.iter().find(|e| matches!(e.kind, EntityKind::Image(_))).unwrap().as_ref().clone(); + d.add(&cadcraft_doc::Space::Model, e.common.clone(), e.kind.clone()).unwrap(); + let saved = cadcraft_io::write_dxf(&d); + let objs = section(&saved, "OBJECTS"); + assert_eq!(objs.iter().filter(|(k, _)| k == "IMAGEDEF").count(), 1); + assert_eq!(objs.iter().filter(|(k, _)| k == "IMAGEDEF_REACTOR").count(), 2); + let back = cadcraft_io::read_dxf(saved.as_bytes()).unwrap(); + assert!(images(&back).iter().all(|i| i.path == "images/logo.png" && i.name == "logo")); + assert_eq!(images(&back).len(), 2); +} + +#[test] +fn image_without_definition_is_still_written() { + // An image whose IMAGEDEF is missing (or an empty path) keeps its placement. + let text = foreign_dxf().replace("340\n40\n", "340\n99\n"); + let d = cadcraft_io::read_dxf(text.as_bytes()).unwrap(); + let [img] = images(&d).try_into().unwrap(); + assert_eq!(img.path, ""); + let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap(); + let [b] = images(&back).try_into().unwrap(); + assert_eq!((b.insert, b.size, b.clip.len()), (img.insert, img.size, 4)); + assert_eq!(b.name, "Image"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn image_survives_dwg_save_and_reopen() { + let d = cadcraft_io::read_dxf(foreign_dxf().as_bytes()).unwrap(); + let [img] = images(&d).try_into().unwrap(); + let dwg = cadcraft_io::write(&d, "x.dwg").unwrap(); + let back = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + let [b] = images(&back).try_into().unwrap(); + // The DWG bridge keeps everything but the definition's resolution unit. + assert_eq!(Image { resolution_units: img.resolution_units, ..b }, img); +} From ba6af44d3681b5e869e0b05ef8050a6c7f963bf9 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 20:37:34 +0200 Subject: [PATCH 039/150] ELLIPSE Rotation/Parameter and BREAK First point take effect (fixes #219) (#222) ELLIPSE: after Rotation the next value is the rotation around the major axis (ratio = cos angle, 0 to 89.4 degrees; a picked point gives the angle from the centre), instead of the other-axis distance. At the elliptical arc's start/end prompts, Parameter reads the values as ellipse parameters and Angle switches back to true angles. BREAK: First point asks for a new first break point; the selection pick then only selects the object. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/draw.rs | 127 +++++++++++++++---------- crates/engine/src/cmd/modify.rs | 12 +++ crates/engine/tests/prompt_keywords.rs | 73 ++++++++++++++ 3 files changed, 164 insertions(+), 48 deletions(-) create mode 100644 crates/engine/tests/prompt_keywords.rs diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index a12847df..71aa8a97 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -861,7 +861,11 @@ struct EllipseM { center_mode: bool, arc_mode: bool, pts: Vec, + /// The `Rotation` option: the next value is the rotation around the major axis. + rotation: bool, ellipse: Option, + /// The `Parameter` option: arc start/end values are ellipse parameters, not true angles. + param: bool, start: Option, } @@ -885,6 +889,28 @@ impl EllipseM { 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))) } + /// The ellipse seen as a circle on the first axis rotated by `a` around it: ratio = cos(a), + /// for rotations up to 89.4°. + fn rotated(&self, a: f64) -> Option { + let (c, m) = self.axis()?; + let ratio = a.cos().abs(); + (m.len() >= 1e-12 && ratio >= 89.4f64.to_radians().cos()).then_some(Ellipse::full(c, m, ratio.min(1.0))) + } + /// The full ellipse is known: draw it, or go on to the arc angles. + fn place(&mut self, s: &mut Session, e: Ellipse) -> Result { + if self.arc_mode { + self.ellipse = Some(e); + return Ok(Step::Continue); + } + s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse { + center: v3(e.center), + major: v3(e.major), + ratio: e.ratio, + start: 0.0, + end: cadcraft_geom::TAU, + }))?; + Ok(Step::Done) + } } impl Interactive for EllipseM { @@ -893,30 +919,46 @@ impl Interactive for EllipseM { } fn prompt(&self, _s: &Session) -> Prompt { if let Some(e) = &self.ellipse { - return Prompt::new(if self.start.is_none() { "Specify start angle" } else { "Specify end angle" }, Accept::POINT_OR_NUMBER) - .kw(&["Parameter"]) - .base(e.center); + 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", + }; + return Prompt::new(msg, Accept::POINT_OR_NUMBER).kw(if self.param { &["Angle"] } else { &["Parameter"] }).base(e.center); } match (self.center_mode, self.pts.len()) { (false, 0) => Prompt::new("Specify axis endpoint of ellipse", Accept::POINT).kw(&["Arc", "Center"]), (false, 1) => Prompt::new("Specify other endpoint of axis", Accept::POINT).base_opt(self.pts.first().copied()), (true, 0) => Prompt::new("Specify center of ellipse", Accept::POINT), (true, 1) => Prompt::new("Specify endpoint of axis", Accept::POINT).base_opt(self.pts.first().copied()), + _ if self.rotation => Prompt::new("Specify rotation around major axis", Accept::POINT_OR_NUMBER).base_opt(self.axis().map(|a| a.0)), _ => Prompt::new("Specify distance to other axis", Accept::POINT_OR_NUMBER).kw(&["Rotation"]).base_opt(self.axis().map(|a| a.0)), } } 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 }; let ang = match i { - Input::Point(p) => { - let ge = Ellipse { start: 0.0, end: cadcraft_geom::TAU, ..e }; - ge.param_of(p) + Input::Keyword(k) => { + match k.as_str() { + "Parameter" => self.param = true, + "Angle" => self.param = false, + _ => {} + } + return Ok(Step::Continue); } + // A parameter is the angle from the major axis taken as the parameter itself. + Input::Point(p) if self.param => cadcraft_geom::norm_angle((p - e.center).angle() - e.major.angle()), + Input::Point(p) => ge.param_of(p), Input::Text(t) => { - // A typed angle is a true angle from the major axis; convert it to an ellipse parameter like a picked point. let a = crate::units::parse_angle(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?; - let ge = Ellipse { start: 0.0, end: cadcraft_geom::TAU, ..e }; - ge.param_of(e.center + Vec2::from_angle(a + e.major.angle())) + if self.param { + cadcraft_geom::norm_angle(a) + } else { + // A typed angle is a true angle from the major axis; convert it to an ellipse parameter like a picked point. + ge.param_of(e.center + Vec2::from_angle(a + e.major.angle())) + } } _ => return Ok(Step::Continue), }; @@ -940,41 +982,30 @@ impl Interactive for EllipseM { match i { Input::Keyword(k) if k == "Center" => self.center_mode = true, Input::Keyword(k) if k == "Arc" => self.arc_mode = true, - Input::Point(p) => { - if self.pts.len() < 2 { - self.pts.push(p); - } else if let Some(e) = self.build(p) { - if self.arc_mode { - self.ellipse = Some(e); - return Ok(Step::Continue); - } - s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse { - center: v3(e.center), - major: v3(e.major), - ratio: e.ratio, - start: 0.0, - end: cadcraft_geom::TAU, - }))?; - return Ok(Step::Done); + Input::Keyword(k) if k == "Rotation" && self.pts.len() == 2 => self.rotation = true, + Input::Point(p) if self.pts.len() < 2 => self.pts.push(p), + Input::Point(p) if self.rotation => { + if let Some(e) = self.axis().and_then(|(c, _)| self.rotated((p - c).angle())) { + return self.place(s, e); } } + Input::Point(p) => { + if let Some(e) = self.build(p) { + return self.place(s, e); + } + } + Input::Text(t) if self.pts.len() == 2 && self.rotation => { + let e = crate::units::parse_angle(&t) + .and_then(|a| self.rotated(a)) + .ok_or_else(|| crate::EngineError::Other("Requires an angle between 0 and 89.4 degrees.".into()))?; + return self.place(s, e); + } Input::Text(t) if self.pts.len() == 2 => { let d = number(&t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?; if let Some((c, m)) = self.axis() && let Some(e) = self.build(c + m.normalized().perp() * d) { - if self.arc_mode { - self.ellipse = Some(e); - return Ok(Step::Continue); - } - s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse { - center: v3(e.center), - major: v3(e.major), - ratio: e.ratio, - start: 0.0, - end: cadcraft_geom::TAU, - }))?; - return Ok(Step::Done); + return self.place(s, e); } } Input::Enter => return Ok(Step::Cancel), @@ -986,17 +1017,17 @@ impl Interactive for EllipseM { if self.pts.len() < 2 { return self.pts.first().map(|a| vec![line(*a, c)]).unwrap_or_default(); } - self.build(c) - .map(|e| { - vec![EntityKind::Ellipse(cadcraft_doc::Ellipse { - center: v3(e.center), - major: v3(e.major), - ratio: e.ratio, - start: 0.0, - end: cadcraft_geom::TAU, - })] - }) - .unwrap_or_default() + let e = if self.rotation { self.axis().and_then(|(ctr, _)| self.rotated((c - ctr).angle())) } else { self.build(c) }; + e.map(|e| { + vec![EntityKind::Ellipse(cadcraft_doc::Ellipse { + center: v3(e.center), + major: v3(e.major), + ratio: e.ratio, + start: 0.0, + end: cadcraft_geom::TAU, + })] + }) + .unwrap_or_default() } } diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index a665962e..b2c53ddf 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1975,6 +1975,8 @@ impl Interactive for FilletM { #[derive(Default)] struct BreakM { obj: Option<(Handle, Vec2)>, + /// The `First point` option: the next point replaces the selection pick as the first break point. + first_point: bool, } impl Interactive for BreakM { @@ -1984,6 +1986,7 @@ impl Interactive for BreakM { fn prompt(&self, _s: &Session) -> Prompt { match self.obj { None => Prompt::new("Select object", Accept::POINT), + Some(_) if self.first_point => Prompt::new("Specify first break point", Accept::POINT), Some(_) => Prompt::new("Specify second break point", Accept::POINT).kw(&["First point"]), } } @@ -2001,6 +2004,15 @@ impl Interactive for BreakM { } Ok(Step::Continue) } + (Some(_), Input::Keyword(k)) if k == "First point" => { + self.first_point = true; + Ok(Step::Continue) + } + (Some((h, _)), Input::Point(p1)) if self.first_point => { + self.obj = Some((h, p1)); + self.first_point = false; + Ok(Step::Continue) + } (Some((h, p1)), Input::Point(p2)) => { break_entity(s, h, p1, p2)?; s.set_selection(Vec::new()); diff --git a/crates/engine/tests/prompt_keywords.rs b/crates/engine/tests/prompt_keywords.rs new file mode 100644 index 00000000..a31e16d4 --- /dev/null +++ b/crates/engine/tests/prompt_keywords.rs @@ -0,0 +1,73 @@ +//! Prompt keywords that change how the next value is read: ELLIPSE Rotation and Parameter, BREAK +//! First point. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; + +fn ellipses(s: &Session) -> Vec { + s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Ellipse(el) = &e.kind { Some(el.clone()) } else { None }).collect() +} + +fn lines(s: &Session) -> Vec<((f64, f64), (f64, f64))> { + let mut v: Vec<_> = s + .doc() + .unwrap() + .model + .iter() + .filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some(((l.a.x, l.a.y), (l.b.x, l.b.y))) } else { None }) + .collect(); + v.sort_by(|a, b| a.0.0.total_cmp(&b.0.0)); + v +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +#[test] +fn ellipse_rotation_sets_the_axis_ratio() { + let mut s = Session::new(); + s.cmdline("ELLIPSE 0,0 10,0 R 60").unwrap(); + assert!(s.running.is_none()); + let e = ellipses(&s); + assert_eq!(e.len(), 1); + let e = &e[0]; + assert!(near(e.center.x, 5.0) && near(e.center.y, 0.0), "center {:?}", e.center); + assert!(near(e.major.x.abs(), 5.0) && near(e.major.y, 0.0), "major {:?}", e.major); + assert!(near(e.ratio, 0.5), "ratio {}", e.ratio); + // Center mode, via a script. + let mut s = Session::new(); + s.script("ELLIPSE C 0,0 0,8 R 0\n").unwrap(); + let e = ellipses(&s); + assert_eq!(e.len(), 1); + assert!(near(e[0].ratio, 1.0) && near(e[0].major.y, 8.0), "{:?}", e[0]); + // Out of range: re-prompts instead of drawing a degenerate ellipse. + let mut s = Session::new(); + s.cmdline("ELLIPSE 0,0 10,0 R 90").unwrap(); + assert!(s.running.is_some()); + assert!(ellipses(&s).is_empty()); +} + +#[test] +fn elliptical_arc_parameter_values() { + let mut s = Session::new(); + // A 10 x 5 ellipse: the angle 45° is the parameter atan2(sin45/5, cos45/10) ≠ 45°; with + // Parameter the values are taken as given. + s.cmdline("ELLIPSE A 0,0 20,0 5 P 45 A 180").unwrap(); + assert!(s.running.is_none()); + let e = ellipses(&s); + assert_eq!(e.len(), 1); + assert!(near(e[0].start, 45f64.to_radians()), "start {}", e[0].start); + // `A` switches the end value back to an angle; 180° is parameter π either way. + assert!(near(e[0].end, std::f64::consts::PI), "end {}", e[0].end); +} + +#[test] +fn break_first_point_option() { + let mut s = Session::new(); + s.cmdline("LINE 0,0 10,0").unwrap(); + s.cmdline("").unwrap(); + s.cmdline("BREAK 5,0 F 2,0 6,0").unwrap(); + assert!(s.running.is_none()); + assert_eq!(lines(&s), vec![((0.0, 0.0), (2.0, 0.0)), ((6.0, 0.0), (10.0, 0.0))]); +} From 98a1be7455f0fe9b35fb9ac51b8b25be8d1bf8e7 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 20:37:40 +0200 Subject: [PATCH 040/150] ROTATE: Reference option (fixes #235) (#236) "Specify the reference angle <0>" takes a typed angle or two points; "Specify the new angle or [Points] <0>" takes a typed angle, a point measured from the base point, or two points after `P`. The objects turn by the new angle minus the reference angle. Previously Reference only said "not available yet" and the values that followed were misread or run as commands. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/modify.rs | 104 ++++++++++++++++++++++-- crates/engine/tests/rotate_reference.rs | 45 ++++++++++ 2 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 crates/engine/tests/rotate_reference.rs diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index b2c53ddf..32c11792 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1310,6 +1310,11 @@ struct SelectThen { objs: Vec, pts: Vec, reference: bool, + /// ROTATE Reference: the reference angle (radians), once known. Its first point, and the + /// first of the two new-angle points, are kept in `ref_from`. + ref_angle: Option, + /// ROTATE Reference: the new angle is given by two points (the `Points` option). + ref_points: bool, /// SCALE Reference: first point of a reference length given by two points. ref_from: Option, /// SCALE Reference: the reference length, once known. @@ -1327,6 +1332,8 @@ impl SelectThen { objs: Vec::new(), pts: Vec::new(), reference: false, + ref_angle: None, + ref_points: false, ref_from: None, ref_len: None, copy_mode: false, @@ -1408,6 +1415,87 @@ impl SelectThen { } } + /// ROTATE Reference: reference angle (typed, or two points), then the new angle (typed, a + /// point measured from the base point, or two points after `Points`). The objects turn by the + /// new angle minus the reference angle. + fn rotate_reference(&mut self, s: &mut Session, i: Input) -> Result { + let Some(base) = self.pts.first().copied() else { return Ok(Step::Done) }; + let angle = + |t: &str| crate::units::parse_angle(t).filter(|a| a.is_finite()).ok_or_else(|| EngineError::Other("Requires an angle or point.".into())); + let two_points = |a: Vec2, p: Vec2| { + if a.near(p, 1e-12) { Err(EngineError::Other("The two points must differ.".into())) } else { Ok(a.angle_to(p)) } + }; + let Some(r) = self.ref_angle else { + match (self.ref_from, i) { + (None, Input::Point(p)) => self.ref_from = Some(p), + (None, Input::Text(t)) => self.ref_angle = Some(angle(&t)?), + (None, Input::Enter) => self.ref_angle = Some(0.0), + (Some(a), Input::Point(p)) => { + self.ref_angle = Some(two_points(a, p)?); + self.ref_from = None; + } + _ => {} + } + return Ok(Step::Continue); + }; + let new = match (self.ref_points, self.ref_from, i) { + (false, _, Input::Keyword(k)) if k == "Points" => { + self.ref_points = true; + return Ok(Step::Continue); + } + (false, _, Input::Point(p)) => base.angle_to(p), + (false, _, Input::Text(t)) => angle(&t)?, + (false, _, Input::Enter) => 0.0, + (true, None, Input::Point(p)) => { + self.ref_from = Some(p); + return Ok(Step::Continue); + } + (true, Some(a), Input::Point(p)) => two_points(a, p)?, + _ => return Ok(Step::Continue), + }; + transform_entities(s, &self.objs, &Mat3::rotate_about(base, new - r), self.copy_mode)?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } + + /// The prompt while ROTATE Reference collects its angles. + fn rotate_reference_prompt(&self) -> Prompt { + match (self.ref_angle, self.ref_points, self.ref_from) { + (None, _, None) => Prompt::new("Specify the reference angle", Accept::POINT_OR_NUMBER).default("0"), + (None, _, Some(p)) | (Some(_), true, Some(p)) => Prompt::new("Specify second point", Accept::POINT).base(p), + (Some(_), false, _) => { + Prompt::new("Specify the new angle", Accept::POINT_OR_NUMBER).kw(&["Points"]).default("0").base_opt(self.pts.first().copied()) + } + (Some(_), true, None) => Prompt::new("Specify first point", Accept::POINT), + } + } + + /// Rubber band while ROTATE Reference collects its angles: the objects turned to the cursor + /// once the reference angle is known, else the line from the first point. + fn rotate_reference_preview(&self, s: &Session, c: Vec2) -> Vec { + let (Some(base), Ok(d)) = (self.pts.first().copied(), s.doc()) else { return Vec::new() }; + match (self.ref_angle, self.ref_points, self.ref_from) { + (Some(r), false, _) => { + let m = Mat3::rotate_about(base, base.angle_to(c) - r); + let mut out: Vec = self + .objs + .iter() + .take(500) + .filter_map(|h| d.entity(*h)) + .map(|e| { + let mut k = e.kind.clone(); + k.transform(&m); + k + }) + .collect(); + out.push(line(base, c)); + out + } + (_, _, Some(a)) => vec![line(a, c)], + _ => Vec::new(), + } + } + fn scale_by(&mut self, s: &mut Session, base: Vec2, f: f64) -> Result { let f = require_length(Some(f))?; transform_entities(s, &self.objs, &Mat3::scale_about(base, f), self.copy_mode)?; @@ -1480,6 +1568,9 @@ impl Interactive for SelectThen { (None, None) => Prompt::new("Specify reference length", Accept::POINT_OR_NUMBER).default("1"), }; } + if self.op == Op::Rotate && self.reference { + return self.rotate_reference_prompt(); + } match (self.op, k) { (Op::Move | Op::Copy | Op::Stretch, 0) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]), (Op::Move | Op::Stretch, _) => Prompt::new("Specify second point or ", Accept::POINT).base_opt(bp), @@ -1536,21 +1627,19 @@ impl Interactive for SelectThen { if self.op == Op::Scale && self.reference { return self.scale_reference(s, i); } + if self.op == Op::Rotate && self.reference { + return self.rotate_reference(s, i); + } match (self.op, k, i) { (_, _, Input::Keyword(kw)) if kw == "Copy" => { self.copy_mode = true; s.echo("Rotating/scaling a copy of the selected objects."); Ok(Step::Continue) } - (Op::Scale, 1, Input::Keyword(kw)) if kw == "Reference" => { + (Op::Scale | Op::Rotate, 1, Input::Keyword(kw)) if kw == "Reference" => { self.reference = true; Ok(Step::Continue) } - (_, _, Input::Keyword(kw)) if kw == "Reference" => { - self.reference = true; - s.echo("Reference: not available yet; enter a value."); - Ok(Step::Continue) - } (Op::Copy, _, Input::Keyword(kw)) if kw == "Exit" => { s.set_selection(Vec::new()); Ok(Step::Done) @@ -1643,6 +1732,9 @@ impl Interactive for SelectThen { } } fn preview(&self, s: &Session, c: Vec2) -> Vec { + if self.op == Op::Rotate && self.reference { + return self.rotate_reference_preview(s, c); + } if !self.sel.done { return Vec::new(); } diff --git a/crates/engine/tests/rotate_reference.rs b/crates/engine/tests/rotate_reference.rs new file mode 100644 index 00000000..2cd81c3e --- /dev/null +++ b/crates/engine/tests/rotate_reference.rs @@ -0,0 +1,45 @@ +//! ROTATE Reference: the reference angle (typed, or two points), then the new angle (typed, a +//! point measured from the base point, or two points after `P`). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; + +fn session_with_line(l: &str) -> Session { + let mut s = Session::new(); + s.cmdline(&format!("LINE {l}")).unwrap(); + s.cmdline("").unwrap(); + s +} + +/// The end point of the only line. +fn end(s: &Session) -> (f64, f64) { + let e = s.doc().unwrap().model.iter().find_map(|e| if let EntityKind::Line(l) = &e.kind { Some((l.b.x, l.b.y)) } else { None }); + e.unwrap() +} + +fn near(a: (f64, f64), b: (f64, f64)) -> bool { + (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9 +} + +#[test] +fn rotate_reference_typed_angles() { + // Reference 30, new angle 120: rotates by 90 degrees. + let mut s = session_with_line("0,0 10,0"); + s.script("ROTATE ALL 0,0 R 30 120\nCIRCLE 0,0 1\n").unwrap(); + assert!(near(end(&s), (0.0, 10.0)), "{:?} {:?}", end(&s), s.log); + assert_eq!(s.doc().unwrap().model.len(), 2, "the tokens after ROTATE are not run as commands"); +} + +#[test] +fn rotate_reference_by_points() { + // Reference from the line itself (0 degrees), new angle towards 0,10: 90 degrees. + let mut s = session_with_line("0,0 10,0"); + s.script("ROTATE ALL 0,0 R 0,0 10,0 0,10\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert!(near(end(&s), (0.0, 10.0)), "{:?}", end(&s)); + // New angle by two points (`P`): from 5,5 to 5,-5 is -90 degrees. + let mut s = session_with_line("0,0 10,0"); + s.script("ROTATE ALL 0,0 R 0 P 5,5 5,-5\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert!(near(end(&s), (0.0, -10.0)), "{:?}", end(&s)); +} From a6b728b0f777549bfd8f821468ac971e42e4f6b9 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 20:37:46 +0200 Subject: [PATCH 041/150] AREA Object/Add area/Subtract area and DIST Multiple points take effect (#233) (#237) AREA's Object option measures picked objects; Add area / Subtract area keep a running "Total area" over areas measured by points or by object, and Enter at the first-corner prompt ends the command. Length continues the last segment by a typed length in AREA and DIST point sequences. DIST's Multiple points measures a path and reports the running and total distance. Arc now says it is not available yet instead of being ignored (or ending AREA as Total). Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/inquiry.rs | 312 +++++++++++++++++++----- crates/engine/tests/inquiry_keywords.rs | 50 ++++ 2 files changed, 304 insertions(+), 58 deletions(-) create mode 100644 crates/engine/tests/inquiry_keywords.rs diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index e9550ca0..45cefcdc 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -179,37 +179,45 @@ fn run_area(s: &mut Session, p: &Value) -> Result { area_of(&pts) } else { let h = targets(s, p)?.first().copied().ok_or_else(|| bad("area", "`points` or `handle` is required"))?; - let d = s.doc()?; - let e = d.entity(h).ok_or_else(|| bad("area", "no such object"))?; - match &e.kind { - EntityKind::Circle(c) => (c.radius * c.radius * cadcraft_geom::PI, c.radius * cadcraft_geom::TAU), - EntityKind::LwPolyline(pl) => { - let g = Polyline { vertices: pl.vertices.clone(), closed: true }; - (g.area().abs(), g.len()) - } - EntityKind::Ellipse(el) => { - let a = el.major.xy().len(); - let b = a * el.ratio; - (cadcraft_geom::PI * a * b, cadcraft_geom::PI * (3.0 * (a + b) - ((3.0 * a + b) * (a + 3.0 * b)).sqrt())) - } - EntityKind::Hatch(hh) => { - // Odd parity, as drawn: a loop nested inside an odd number of other loops is an unfilled island. - let geoms: Vec = hh.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }).collect(); - let polys: Vec> = geoms.iter().map(|g| g.tessellate(1e-3)).collect(); - let mut total = (0.0, 0.0); - for (i, g) in geoms.iter().enumerate() { - let probe = polys.get(i).and_then(|p| p.first().copied()); - let depth = - probe.map_or(0, |pt| polys.iter().enumerate().filter(|(j, q)| *j != i && cadcraft_geom::point_in_polygon(q, pt)).count()); - total.0 += if depth % 2 == 0 { g.area().abs() } else { -g.area().abs() }; - total.1 += g.len(); - } - total - } - _ => return Err(bad("area", "object has no area")), - } + object_area(s, h)? }; - Ok(json!({ "area": a, "perimeter": per, "message": format!("Area = {}, Perimeter = {}", fmt_d(s, a), fmt_d(s, per)) })) + Ok(json!({ "area": a, "perimeter": per, "message": area_text(s, a, per) })) +} + +fn area_text(s: &Session, a: f64, per: f64) -> String { + format!("Area = {}, Perimeter = {}", fmt_d(s, a), fmt_d(s, per)) +} + +/// Area and perimeter of a closed object (AREA's Object option). +fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> { + let d = s.doc()?; + let e = d.entity(h).ok_or_else(|| bad("area", "no such object"))?; + Ok(match &e.kind { + EntityKind::Circle(c) => (c.radius * c.radius * cadcraft_geom::PI, c.radius * cadcraft_geom::TAU), + EntityKind::LwPolyline(pl) => { + let g = Polyline { vertices: pl.vertices.clone(), closed: true }; + (g.area().abs(), g.len()) + } + EntityKind::Ellipse(el) => { + let a = el.major.xy().len(); + let b = a * el.ratio; + (cadcraft_geom::PI * a * b, cadcraft_geom::PI * (3.0 * (a + b) - ((3.0 * a + b) * (a + 3.0 * b)).sqrt())) + } + EntityKind::Hatch(hh) => { + // Odd parity, as drawn: a loop nested inside an odd number of other loops is an unfilled island. + let geoms: Vec = hh.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }).collect(); + let polys: Vec> = geoms.iter().map(|g| g.tessellate(1e-3)).collect(); + let mut total = (0.0, 0.0); + for (i, g) in geoms.iter().enumerate() { + let probe = polys.get(i).and_then(|p| p.first().copied()); + let depth = probe.map_or(0, |pt| polys.iter().enumerate().filter(|(j, q)| *j != i && cadcraft_geom::point_in_polygon(q, pt)).count()); + total.0 += if depth % 2 == 0 { g.area().abs() } else { -g.area().abs() }; + total.1 += g.len(); + } + total + } + _ => return Err(bad("area", "object has no area")), + }) } fn run_time(s: &mut Session, _p: &Value) -> Result { @@ -330,6 +338,47 @@ fn run_count(s: &mut Session, p: &Value) -> Result { #[derive(Default)] struct DistM { first: Option, + /// Multiple points: the points so far, the first included. + multi: Option>, + /// Asking for the length of a segment (the Length option). + length: bool, +} + +/// Total length of the segments through `pts`. +fn path_len(pts: &[Vec2]) -> f64 { + pts.windows(2) + .map(|w| match w { + [a, b] => a.dist(*b), + _ => 0.0, + }) + .sum() +} + +/// The point `len` beyond the last of `pts`, continuing the last segment (along X after a single +/// point): the Length option of AREA and DIST's point sequences. +fn extend_last(pts: &[Vec2], len: f64) -> Option { + let last = *pts.last()?; + let dir = match pts.len().checked_sub(2).and_then(|i| pts.get(i)) { + Some(prev) => (last - *prev).normalized(), + None => Vec2::X, + }; + let dir = if dir == Vec2::ZERO { Vec2::X } else { dir }; + let p = last + dir * len; + p.is_finite().then_some(p) +} + +fn length_prompt() -> Prompt { + Prompt::new("Specify length of line", Accept::NUMBER) +} + +/// Feed the Length option's answer: the next point, or `None` when the option was dismissed (Enter). +fn length_input(pts: &[Vec2], i: Input) -> Result> { + let t = match i { + Input::Text(t) => t, + _ => return Ok(None), + }; + let len = crate::units::parse_distance(&t).filter(|v| v.is_finite()).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?; + Ok(extend_last(pts, len)) } impl Interactive for DistM { @@ -337,12 +386,49 @@ impl Interactive for DistM { "DIST" } fn prompt(&self, _s: &Session) -> Prompt { - match self.first { - None => Prompt::new("Specify first point", Accept::POINT), - Some(a) => Prompt::new("Specify second point", Accept::POINT).kw(&["Multiple points"]).base(a), + if self.length { + return length_prompt(); + } + match (&self.multi, self.first) { + (Some(pts), _) if pts.len() < 2 => { + Prompt::new("Specify second point", Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(pts.last().copied()) + } + (Some(pts), _) => Prompt::new("Specify next point", Accept::POINT) + .kw(&["Arc", "Length", "Undo", "Total"]) + .default("Total") + .base_opt(pts.last().copied()), + (None, None) => Prompt::new("Specify first point", Accept::POINT), + (None, Some(a)) => Prompt::new("Specify second point", Accept::POINT).kw(&["Multiple points"]).base(a), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(pts) = &mut self.multi { + if self.length { + if let Some(p) = length_input(pts, i)? { + pts.push(p); + s.echo(format!("Distance = {}", fmt_d(s, path_len(pts)))); + } + self.length = false; + return Ok(Step::Continue); + } + match i { + Input::Point(p) => { + pts.push(p); + s.echo(format!("Distance = {}", fmt_d(s, path_len(pts)))); + } + Input::Keyword(k) if k == "Undo" => { + if pts.len() > 1 { + pts.pop(); + } + } + Input::Keyword(k) if k == "Length" => self.length = true, + Input::Keyword(k) if k == "Total" => return self.total(s), + Input::Keyword(k) => s.echo(format!("{k}: not available yet")), + Input::Enter => return self.total(s), + _ => {} + } + return Ok(Step::Continue); + } match (self.first, i) { (None, Input::Point(p)) => { self.first = Some(p); @@ -355,12 +441,31 @@ impl Interactive for DistM { } Ok(Step::Done) } + (Some(a), Input::Keyword(k)) if k == "Multiple points" => { + self.multi = Some(vec![a]); + Ok(Step::Continue) + } (_, Input::Enter) => Ok(Step::Done), _ => Ok(Step::Continue), } } fn preview(&self, _s: &Session, c: Vec2) -> Vec { - self.first.map(|a| vec![super::helpers::line(a, c)]).unwrap_or_default() + match &self.multi { + Some(pts) => { + let mut v: Vec = pts.iter().map(|p| cadcraft_geom::PolyVertex::new(*p)).collect(); + v.push(cadcraft_geom::PolyVertex::new(c)); + vec![super::helpers::lwpoly(v, false)] + } + None => self.first.map(|a| vec![super::helpers::line(a, c)]).unwrap_or_default(), + } + } +} + +impl DistM { + fn total(&self, s: &mut Session) -> Result { + let d = self.multi.as_deref().map_or(0.0, path_len); + s.echo(format!("Distance = {}", fmt_d(s, d))); + Ok(Step::Done) } } @@ -425,9 +530,56 @@ impl Interactive for ListM { } } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum AreaMode { + /// Measure one area and end. + #[default] + Single, + /// Add each measured area to the running total. + Add, + /// Subtract each measured area from the running total. + Subtract, +} + #[derive(Default)] struct AreaM { pts: Vec, + mode: AreaMode, + total: f64, + /// Selecting objects (the Object option). + object: bool, + /// Asking for the length of a segment (the Length option). + length: bool, +} + +impl AreaM { + fn mode_prefix(&self) -> &'static str { + match self.mode { + AreaMode::Single => "", + AreaMode::Add => "(ADD mode) ", + AreaMode::Subtract => "(SUBTRACT mode) ", + } + } + /// Report an area (and the running total in add/subtract mode); ends the command in single mode. + fn measured(&mut self, s: &mut Session, a: f64, per: f64) -> Step { + s.echo(area_text(s, a, per)); + match self.mode { + AreaMode::Single => return Step::Done, + AreaMode::Add => self.total += a, + AreaMode::Subtract => self.total -= a, + } + s.echo(format!("Total area = {}", fmt_d(s, self.total))); + Step::Continue + } + /// Close the boundary picked so far (Enter or Total at the next-point prompt). + fn close(&mut self, s: &mut Session) -> Step { + let pts = std::mem::take(&mut self.pts); + if pts.len() < 3 { + return if self.mode == AreaMode::Single { Step::Done } else { Step::Continue }; + } + let (a, per) = area_of(&pts); + self.measured(s, a, per) + } } impl Interactive for AreaM { @@ -435,37 +587,81 @@ impl Interactive for AreaM { "AREA" } fn prompt(&self, _s: &Session) -> Prompt { - match self.pts.len() { - 0 => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Add area", "Subtract area"]).default("Object"), - 1 => Prompt::new("Specify next point", Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(self.pts.last().copied()), - _ => Prompt::new("Specify next point", Accept::POINT) - .kw(&["Arc", "Length", "Undo", "Total"]) - .default("Total") - .base_opt(self.pts.last().copied()), + if self.object { + return Prompt::new(format!("{}Select objects", self.mode_prefix()), Accept::SELECT); + } + if self.length { + return length_prompt(); + } + let next = format!("{}Specify next point", self.mode_prefix()); + match (self.pts.len(), self.mode) { + (0, AreaMode::Single) => { + Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Add area", "Subtract area"]).default("Object") + } + (0, AreaMode::Add) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Subtract area"]), + (0, AreaMode::Subtract) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Add area"]), + (1, _) => Prompt::new(next, Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(self.pts.last().copied()), + _ => Prompt::new(next, Accept::POINT).kw(&["Arc", "Length", "Undo", "Total"]).default("Total").base_opt(self.pts.last().copied()), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { - match i { - Input::Point(p) => { - self.pts.push(p); - Ok(Step::Continue) + if self.object { + match i { + Input::Pick(hs) => { + for h in hs { + match object_area(s, h) { + Ok((a, per)) => { + if self.measured(s, a, per) == Step::Done { + return Ok(Step::Done); + } + } + Err(_) => s.echo("The selected object has no area."), + } + } + } + Input::Enter => { + self.object = false; + if self.mode == AreaMode::Single { + return Ok(Step::Done); + } + } + Input::Text(_) | Input::Keyword(_) => s.echo("*Invalid selection*"), + _ => {} } - Input::Keyword(k) if k == "Undo" => { - self.pts.pop(); - Ok(Step::Continue) - } - Input::Enter | Input::Keyword(_) if self.pts.len() >= 3 => { - let (a, per) = area_of(&self.pts); - let m = format!("Area = {}, Perimeter = {}", fmt_d(s, a), fmt_d(s, per)); - s.echo(m); - Ok(Step::Done) - } - Input::Enter => Ok(Step::Done), - _ => Ok(Step::Continue), + return Ok(Step::Continue); } + if self.length { + if let Some(p) = length_input(&self.pts, i)? { + self.pts.push(p); + } + self.length = false; + return Ok(Step::Continue); + } + match i { + Input::Point(p) => self.pts.push(p), + Input::Keyword(k) => match k.as_str() { + "Object" => self.object = true, + "Add area" => self.mode = AreaMode::Add, + "Subtract area" => self.mode = AreaMode::Subtract, + "Undo" => { + self.pts.pop(); + } + "Length" => self.length = true, + "Total" => return Ok(self.close(s)), + _ => s.echo(format!("{k}: not available yet")), + }, + Input::Enter if self.pts.is_empty() => match self.mode { + // Enter takes the default, Object; in add/subtract mode it ends the command. + AreaMode::Single => self.object = true, + AreaMode::Add | AreaMode::Subtract => return Ok(Step::Done), + }, + Input::Enter => return Ok(self.close(s)), + _ => {} + } + Ok(Step::Continue) } fn preview(&self, _s: &Session, c: Vec2) -> Vec { - if self.pts.is_empty() { + if self.pts.is_empty() || self.object || self.length { return Vec::new(); } let mut v: Vec = self.pts.iter().map(|p| cadcraft_geom::PolyVertex::new(*p)).collect(); diff --git a/crates/engine/tests/inquiry_keywords.rs b/crates/engine/tests/inquiry_keywords.rs new file mode 100644 index 00000000..4e69e14e --- /dev/null +++ b/crates/engine/tests/inquiry_keywords.rs @@ -0,0 +1,50 @@ +//! AREA's Object / Add area / Subtract area and DIST's Multiple points, typed as at the command line. + +use cadcraft_engine::Session; + +fn run(lines: &[&str]) -> (Session, Vec) { + let mut s = Session::new(); + for l in lines { + s.cmdline(l).unwrap(); + } + let log = s.log.clone(); + (s, log) +} + +fn has(log: &[String], text: &str) -> bool { + log.iter().any(|l| l.contains(text)) +} + +#[test] +fn area_object_add_and_subtract_keep_a_running_total() { + // A 10 x 10 square polyline and a circle of radius 1 inside it. + let setup = ["rectang 0,0 10,10", "circle 5,5 1"]; + + // Object (the default): pick the square by its edge. + let (s, log) = run(&[setup[0], setup[1], "area", "o", "10,5"]); + assert!(has(&log, "Area = 100.0000, Perimeter = 40.0000"), "{log:?}"); + assert!(s.running.is_none(), "{}", s.prompt_text()); + + // Add the square by points, subtract the circle as an object. + let (s, log) = run(&[setup[0], setup[1], "area", "a", "0,0", "10,0", "10,10", "0,10", "", "s", "o", "6,5", "", ""]); + assert!(has(&log, "Total area = 100.0000"), "{log:?}"); + assert!(has(&log, "Total area = 96.8584"), "{log:?}"); + assert!(s.running.is_none(), "{}", s.prompt_text()); +} + +#[test] +fn area_unsupported_keywords_say_so_and_length_extends_the_last_segment() { + let (s, log) = run(&["area", "0,0", "a"]); + assert!(has(&log, "not available yet"), "{log:?}"); + assert!(s.running.is_some()); + // Length continues along the previous segment: 0,0 -> 4,0 -> +6 -> 10,0, then 10,10. + let (_, log) = run(&["area", "0,0", "4,0", "l", "6", "10,10", ""]); + assert!(has(&log, "Area = 50.0000"), "{log:?}"); +} + +#[test] +fn dist_multiple_points_sums_the_segments() { + let (s, log) = run(&["dist", "0,0", "m", "3,4", "3,10", ""]); + assert!(has(&log, "Distance = 11.0000"), "{log:?}"); + assert!(s.running.is_none(), "{}", s.prompt_text()); +} From ca882b38e293f8aac15d9a54c5b7780fb562cece Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 20:37:52 +0200 Subject: [PATCH 042/150] TEXTEDIT prompt, Undo and Mode; MTEXT takes lines until an empty one (#234) (#239) TEXTEDIT's prompt no longer reads "or or". Undo reverts the last edit made in this TEXTEDIT, and Mode switches between Multiple (keep selecting) and Single (end after one edit). At the command line and in scripts, MTEXT takes one paragraph per input line until an empty line, instead of ending after the first line and running the next one as a command. The Text Editor window sends its contents and then Enter, so it still ends MTEXT. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/draw.rs | 14 ++++-- crates/engine/src/cmd/modify2.rs | 43 +++++++++++++++-- crates/engine/tests/text_commands.rs | 69 ++++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 8 +++- crates/ui-egui/src/i18n/uk.tsv | 2 +- 5 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 crates/engine/tests/text_commands.rs diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 71aa8a97..99a7d231 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -1315,6 +1315,9 @@ impl Interactive for TextM { struct MTextM { first: Option, second: Option, + /// Paragraphs typed so far: one per input line, an empty line ends the text (as on + /// AutoCAD's command line and in scripts). + lines: Vec, } impl Interactive for MTextM { @@ -1327,14 +1330,19 @@ impl Interactive for MTextM { (Some(a), None) => Prompt::new("Specify opposite corner", Accept::POINT) .kw(&["Height", "Justify", "Line spacing", "Rotation", "Style", "Width", "Columns"]) .base(a), - _ => Prompt::new("Enter text (use \\P for new paragraphs)", Accept::TEXT), + _ if self.lines.is_empty() => Prompt::new("Enter text (use \\P for new paragraphs)", Accept::TEXT), + _ => Prompt::new("Enter next line of text (empty line to finish)", Accept::TEXT), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { match (self.first, self.second, i) { (None, _, Input::Point(p)) => self.first = Some(p), (Some(_), None, Input::Point(p)) => self.second = Some(p), - (Some(a), Some(b), Input::Text(t)) => { + (Some(_), Some(_), Input::Text(t)) if !t.is_empty() => self.lines.push(t), + (Some(a), Some(b), Input::Text(_) | Input::Enter) => { + if self.lines.is_empty() { + return Ok(Step::Done); + } let d = s.doc()?; let h = d.header.f64("TEXTSIZE", 0.2); let style = d.header.str("TEXTSTYLE", "Standard"); @@ -1346,7 +1354,7 @@ impl Interactive for MTextM { attach: 1, rotation: 0.0, style, - contents: t, + contents: self.lines.join("\\P"), line_spacing: 1.0, }))?; return Ok(Step::Done); diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index d1ba72b8..ba21904c 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -2269,6 +2269,12 @@ fn run_textedit(s: &mut Session, p: &Value) -> Result { #[derive(Default)] struct TextEditM { h: Option<(Handle, String)>, + /// Edits made in this run, newest last: each object as it was before (the Undo option). + edits: Vec<(Handle, EntityKind)>, + /// Single mode: end after one edit (the Mode option; Multiple by default). + single: bool, + /// Asking for the edit mode. + mode: bool, } impl Interactive for TextEditM { @@ -2276,12 +2282,26 @@ impl Interactive for TextEditM { "TEXTEDIT" } fn prompt(&self, _s: &Session) -> Prompt { + if self.mode { + let current = if self.single { "Single" } else { "Multiple" }; + return Prompt::new("Enter a text edit mode option", curves::KW).kw(&["Single", "Multiple"]).default(current); + } match &self.h { - None => Prompt::new("Select an annotation object or", Accept::POINT).kw(&["Undo", "Mode"]), + None => Prompt::new("Select an annotation object", Accept::POINT).kw(&["Undo", "Mode"]), Some((_, old)) => Prompt::new("Enter new text", Accept::TEXT).default(old.chars().take(40).collect::()), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.mode { + match i { + Input::Keyword(k) if k == "Single" => self.single = true, + Input::Keyword(k) if k == "Multiple" => self.single = false, + Input::Enter => {} + _ => return Err(other("Enter Single or Multiple.")), + } + self.mode = false; + return Ok(Step::Continue); + } match (&self.h, i) { (None, Input::Point(p)) => { let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; @@ -2290,16 +2310,29 @@ impl Interactive for TextEditM { Ok(Step::Continue) } (Some((h, _)), Input::Text(t) | Input::Keyword(t)) => { - set_text(s, *h, &t)?; + let h = *h; + let before = curves::entity(s, h)?.kind; + set_text(s, h, &t)?; + self.edits.push((h, before)); self.h = None; - Ok(Step::Continue) + Ok(if self.single { Step::Done } else { Step::Continue }) } (Some(_), Input::Enter) => { self.h = None; - Ok(Step::Continue) + Ok(if self.single { Step::Done } else { Step::Continue }) } (None, Input::Enter) => Ok(Step::Done), - (None, Input::Keyword(_)) => Ok(Step::Continue), + (None, Input::Keyword(k)) if k == "Undo" => { + match self.edits.pop() { + Some((h, before)) => set_kind(s, h, before)?, + None => s.echo("Everything has been undone"), + } + Ok(Step::Continue) + } + (None, Input::Keyword(k)) if k == "Mode" => { + self.mode = true; + Ok(Step::Continue) + } _ => Ok(Step::Continue), } } diff --git a/crates/engine/tests/text_commands.rs b/crates/engine/tests/text_commands.rs new file mode 100644 index 00000000..e5ecc3a1 --- /dev/null +++ b/crates/engine/tests/text_commands.rs @@ -0,0 +1,69 @@ +//! MTEXT contents from scripts and the command line; TEXTEDIT's prompt and options. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Space}; + +fn kinds(s: &Session) -> Vec { + s.doc().ok().and_then(|d| d.space(&Space::Model)).map(|st| st.iter().map(|e| e.kind.clone()).collect()).unwrap_or_default() +} + +fn mtexts(s: &Session) -> Vec { + kinds(s) + .into_iter() + .filter_map(|k| match k { + EntityKind::MText(t) => Some(t.contents), + _ => None, + }) + .collect() +} + +fn texts(s: &Session) -> Vec { + kinds(s) + .into_iter() + .filter_map(|k| match k { + EntityKind::Text(t) => Some(t.value), + _ => None, + }) + .collect() +} + +#[test] +fn script_mtext_takes_lines_until_an_empty_line() { + let mut s = Session::new(); + s.script("MTEXT 0,10 20,0\nFirst line\nSecond line\n\nLINE 0,0 5,5\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(mtexts(&s), vec!["First line\\PSecond line".to_string()]); + assert_eq!(kinds(&s).len(), 2, "{:?}", kinds(&s)); + + // Typed at the command line: spaces stay in the text, Enter on an empty line ends it. + let mut s = Session::new(); + s.cmdline("MTEXT 0,10 20,0").unwrap(); + s.cmdline("Hello world").unwrap(); + assert!(s.running.is_some()); + s.cmdline("").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(mtexts(&s), vec!["Hello world".to_string()]); +} + +#[test] +fn textedit_prompt_mode_and_undo() { + let mut s = Session::new(); + s.cmdline("TEXT 0,0 1 0 old").unwrap(); + s.cmdline("").unwrap(); + s.cmdline("TEXTEDIT").unwrap(); + let p = s.prompt_text(); + assert!(!p.contains("or or"), "{p}"); + // Edit, then Undo restores the old text. + s.cmdline("0.5,0.5").unwrap(); + s.cmdline("new").unwrap(); + assert_eq!(texts(&s), vec!["new".to_string()]); + s.cmdline("u").unwrap(); + assert_eq!(texts(&s), vec!["old".to_string()]); + // Mode Single: the command ends after one edit. + s.cmdline("m").unwrap(); + s.cmdline("s").unwrap(); + s.cmdline("0.5,0.5").unwrap(); + s.cmdline("once").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(texts(&s), vec!["once".to_string()]); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 060504ac..cd2627e5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -388,7 +388,13 @@ fn mtext_editor(app: &mut CadApp, ctx: &egui::Context) { ctx.data_mut(|d| d.insert_temp(id, buf)); if let Some(t) = submit { ctx.data_mut(|d| d.remove::(id)); - let _ = app.session.input(cadcraft_engine::Input::Text(t)); + // The editor holds the whole contents: one input, then Enter ends MTEXT's text entry. + if !t.is_empty() { + let _ = app.session.input(cadcraft_engine::Input::Text(t)); + } + if app.session.running.as_ref().is_some_and(|r| r.id == "mtext") { + let _ = app.session.input(cadcraft_engine::Input::Enter); + } } else if cancel { ctx.data_mut(|d| d.remove::(id)); app.session.cancel(); diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c054517f..d8a3e613 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -612,7 +612,7 @@ Select a line. Виберіть відрізок. Select a polyline. Виберіть полілінію. Select a text, mtext, attribute definition or dimension. Виберіть текст, багаторядковий текст, визначення атрибута або розмір. - Select an annotation object or Виберіть об'єкт анотації або + Select an annotation object Виберіть об'єкт анотації Select an arc or circle Виберіть дугу або коло Select an arc. Виберіть дугу. Select an object Виберіть об'єкт From 6d7502abf9f837a5a8d82b230088012450223cd3 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 20:37:58 +0200 Subject: [PATCH 043/150] UNDO asks for the number of operations; U undoes one (fixes #241) (#242) UNDO now prompts "Enter the number of operations to undo or [Auto/Control/BEgin/End/Mark/Back] <1>" and undoes that many; Enter undoes one. Its other options say they are not available yet (Auto and Control still take their value), so nothing that follows is run as a command. The JSON form (`undo {count?}`) is unchanged. U is its own command, as in AutoCAD: it undoes one operation without asking and is what Edit > Undo, Cmd+Z and the toolbar button run. An interactive command outside undo that changed the drawing (UNDO) records no undo step when it finishes, matching `execute`. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 61 ++++++++++++++++++++++++++++++- crates/engine/src/lib.rs | 6 +++ crates/engine/tests/undo_count.rs | 49 +++++++++++++++++++++++++ crates/ui-egui/src/chrome.rs | 2 +- crates/ui-egui/src/menus.rs | 2 +- 5 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 crates/engine/tests/undo_count.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9b615307..996bc4d7 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -10,7 +10,9 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { vec![ - CommandSpec::new("undo", "Undo", run_undo).menu(&["Edit", "Undo"]).key("Cmd+Z").alias(&["u"]).params("{count?: n}").noundo(), + // U undoes the last operation at once (Edit > Undo, Cmd+Z); UNDO asks how many. + CommandSpec::new("u", "Undo", run_u).menu(&["Edit", "Undo"]).key("Cmd+Z").params("{}").noundo(), + CommandSpec::new("undo", "Undo", run_undo).params("{count?: n}").noundo().interactive(|_| Ok(Box::new(UndoM::default()))), CommandSpec::new("redo", "Redo", run_redo).menu(&["Edit", "Redo"]).key("Cmd+Shift+Z").alias(&["mredo"]).params("{count?: n}").noundo(), CommandSpec::new("cutclip", "Cut", run_cut) .menu(&["Edit", "Cut"]) @@ -69,6 +71,63 @@ fn run_undo(s: &mut Session, p: &Value) -> Result { Ok(json!({ "undone": labels, "message": labels.join(", ").to_string() })) } +fn run_u(s: &mut Session, _p: &Value) -> Result { + run_undo(s, &json!({})) +} + +/// UNDO at the command line: "Enter the number of operations to undo" (default 1). Its other +/// options are not available yet; Auto and Control still take their value, so it isn't run as a +/// command. +#[derive(Default)] +struct UndoM { + /// Auto or Control was chosen: its value is being asked for. + option: Option<&'static str>, +} + +impl Interactive for UndoM { + fn name(&self) -> &'static str { + "UNDO" + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.option { + Some("Auto") => Prompt::new("Enter UNDO Auto mode", Accept::TEXT).kw(&["ON", "OFF"]).default("ON"), + Some(_) => Prompt::new("Enter an UNDO control option", Accept::TEXT).kw(&["All", "None", "One", "Combine", "Layer"]).default("All"), + None => Prompt::new("Enter the number of operations to undo", Accept::NUMBER) + .kw(&["Auto", "Control", "BEgin", "End", "Mark", "Back"]) + .default("1"), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(o) = self.option { + s.echo(format!("UNDO {o}: not available yet.")); + return Ok(Step::Done); + } + let n = match i { + Input::Text(t) => { + t.trim().parse::().ok().filter(|n| *n >= 1).ok_or_else(|| EngineError::Other("Requires a positive integer.".into()))? + } + Input::Enter => 1, + Input::Keyword(k) => { + match k.as_str() { + "Auto" => self.option = Some("Auto"), + "Control" => self.option = Some("Control"), + _ => { + s.echo(format!("UNDO {k}: not available yet.")); + return Ok(Step::Done); + } + } + return Ok(Step::Continue); + } + _ => return Ok(Step::Continue), + }; + let r = run_undo(s, &json!({ "count": n }))?; + if let Some(m) = r.get("message").and_then(Value::as_str) { + s.echo(m.to_string()); + } + Ok(Step::Done) + } +} + fn run_redo(s: &mut Session, p: &Value) -> Result { end_running_command(s); let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4596e399..0172c266 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -673,6 +673,12 @@ impl Session { } fn finish(&mut self, run: Running, cancelled: bool) { + // A command outside undo that changed the drawing (UNDO, which swapped it for an earlier + // one) records no undo step, as in `execute`. + if find_command(&run.id).is_some_and(|c| !c.undoable) && self.state().is_ok_and(|st| !Arc::ptr_eq(&run.before, &st.doc)) { + self.pending_window = None; + return; + } self.pending_window = None; let label = find_command(&run.id).map(|c| c.label).unwrap_or("Command"); let _ = cancelled; diff --git a/crates/engine/tests/undo_count.rs b/crates/engine/tests/undo_count.rs new file mode 100644 index 00000000..741ac648 --- /dev/null +++ b/crates/engine/tests/undo_count.rs @@ -0,0 +1,49 @@ +//! UNDO takes the number of operations to undo; U (Edit > Undo, Cmd+Z) undoes one at once. + +use cadcraft_engine::Session; + +fn count(s: &Session) -> usize { + s.doc().unwrap().model.len() +} + +fn session_with_circles(n: usize) -> Session { + let mut s = Session::new(); + for i in 0..n { + s.cmdline(&format!("CIRCLE {},0 1", i * 3)).unwrap(); + } + assert_eq!(count(&s), n); + s +} + +#[test] +fn undo_takes_a_count() { + let mut s = session_with_circles(4); + s.script("UNDO 3\nCIRCLE 20,0 1\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + // Three undone, then the next line runs as a command: 4 - 3 + 1. + assert_eq!(count(&s), 2, "{:?}", s.log); + // The undone steps can be redone, and UNDO itself adds no undo step. + let mut s = session_with_circles(4); + s.cmdline("UNDO 3").unwrap(); + assert_eq!(count(&s), 1); + s.execute("redo", &serde_json::json!({ "count": 3 })).unwrap(); + assert_eq!(count(&s), 4); + // Enter takes the default of one. + s.cmdline("UNDO").unwrap(); + s.cmdline("").unwrap(); + assert_eq!(count(&s), 3); +} + +#[test] +fn undo_options_and_u() { + // An option that is not available yet ends UNDO without undoing; the next token is its value + // (Auto ON) or the next command. + let mut s = session_with_circles(2); + s.script("UNDO A ON\nUNDO M\nCIRCLE 20,0 1\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(count(&s), 3, "{:?}", s.log); + // U undoes one operation without asking. + s.cmdline("U").unwrap(); + assert!(s.running.is_none()); + assert_eq!(count(&s), 2); +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 98871d1c..f72ad0dc 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -14,7 +14,7 @@ const TOOLBAR: &[&[(Icon, &str, &str, Option<&str>)]] = &[ (Icon::Save, "qsave", "Save", Some("Cmd+S")), (Icon::SaveAs, "saveas", "Save As", None), ], - &[(Icon::Undo, "undo", "Undo", Some("Cmd+Z")), (Icon::Redo, "redo", "Redo", Some("Cmd+Shift+Z"))], + &[(Icon::Undo, "u", "Undo", Some("Cmd+Z")), (Icon::Redo, "redo", "Redo", Some("Cmd+Shift+Z"))], &[ (Icon::Plot, "plot", "Print", None), (Icon::Publish, "publish", "Batch Publish", None), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a6fb240a..821522b5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -386,7 +386,7 @@ pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) { let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; let pairs: &[(KeyboardShortcut, &str)] = &[ (sc(cmd_shift, Key::Z), "redo"), - (sc(cmd, Key::Z), "undo"), + (sc(cmd, Key::Z), "u"), (sc(cmd, Key::Y), "redo"), (sc(cmd, Key::N), "new"), (sc(cmd, Key::O), "ui.open"), From d4e1345974c01af30910df1535eccc586426c676 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 20:38:04 +0200 Subject: [PATCH 044/150] macOS: the window title lines up with the traffic lights (fixes #161) (#202) AppKit draws the traffic lights centred in its standard 28 pt title-bar band, and the integrated title row is that band, but the title text sat 1 pt below the centre line. It is now centred on the lights. Neither winit nor eframe can move the lights without AppKit calls (unsafe), so the row fits them instead, as in WordCraft (storytold/wordcraft#255). The geometry lives in chrome.rs. A long drawing name in a narrow window no longer slides under the lights: the title starts after them, with the same gap the lights keep from the top of the window. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/chrome.rs | 51 ++++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index f72ad0dc..d89b2778 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -38,10 +38,31 @@ const TOOLBAR: &[&[(Icon, &str, &str, Option<&str>)]] = &[ &[(Icon::Help, "ui.dialog.about", "Help", None)], ]; +/// Height of the macOS integrated title row (#161). AppKit draws the traffic lights itself, +/// centred in the standard 28 pt title-bar band at the top of the window; moving them would take +/// AppKit calls (`unsafe`, which the workspace forbids). So the title row is that band, and what +/// it holds is centred on the same line as the lights. +const MAC_TITLE_BAR: f32 = 28.0; +/// The traffic lights as AppKit lays them out: button diameter, centre-to-centre pitch and the +/// first button's left edge (8–10 pt depending on the macOS version; the larger value keeps the +/// gap after them from shrinking). +const MAC_LIGHT: f32 = 12.0; +const MAC_LIGHT_PITCH: f32 = 20.0; +const MAC_LIGHTS_LEFT: f32 = 10.0; +/// Where title-row content may start to the right of the traffic lights: the end of the three +/// lights plus the same gap the lights keep from the top of the window. +const MAC_CONTENT_LEFT: f32 = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT + (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; + +/// Left edge of the window title in a title row starting at `left`, `width` wide: centred, but +/// never under the traffic lights (a long drawing name in a narrow window slides right instead). +fn title_left(left: f32, width: f32, text_w: f32) -> f32 { + (left + (width - text_w) / 2.0).max(left + MAC_CONTENT_LEFT) +} + /// Title row (with the integrated macOS title bar) and the Tool Bar. pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); - let title_h = if app.integrated_titlebar { 28.0 } else { 0.0 }; + let title_h = if app.integrated_titlebar { MAC_TITLE_BAR } else { 0.0 }; let tb_h = if app.ui.show_toolbar { 34.0 } else { 0.0 }; egui::Panel::top("cc_title_toolbar").exact_size(title_h + tb_h).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { let r = ui.max_rect(); @@ -50,13 +71,11 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { Ok(st) if !app.ui.start_tab => format!("CADCraft {}{}", st.title, if st.title.contains('.') { "" } else { ".dwg" }), _ => format!("CADCraft {}", crate::tl!("Start")), }; - ui.painter().text( - pos2(r.center().x, r.top() + title_h / 2.0 + 1.0), - egui::Align2::CENTER_CENTER, - title, - egui::FontId::proportional(13.0), - t.text_dim, - ); + // Centred on the traffic lights' centre line (the row's middle): with the system font + // the capitals' middle is the text box's middle, so no optical nudge. + let galley = ui.painter().layout_no_wrap(title, egui::FontId::proportional(13.0), t.text_dim); + let x = title_left(r.left(), r.width(), galley.size().x); + ui.painter().galley(pos2(x, r.top() + (title_h - galley.size().y) / 2.0), galley, t.text_dim); // Allow dragging the window by the title row. let tr = Rect::from_min_size(r.min, vec2(r.width(), title_h)); let resp = ui.interact(tr, ui.id().with("titledrag"), Sense::click_and_drag()); @@ -391,3 +410,19 @@ pub fn start_page(app: &mut CadApp, ui: &mut egui::Ui) { _ => {} } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mac_title_row_centres_and_clears_the_traffic_lights() { + // The lights are centred in the row, and content keeps the same gap after them. + let margin = (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; + let lights_end = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT; + assert_eq!(MAC_CONTENT_LEFT - lights_end, margin); + // A short title is centred; one too wide for that starts after the lights. + assert_eq!(title_left(100.0, 1000.0, 200.0), 500.0); + assert_eq!(title_left(100.0, 900.0, 800.0), 100.0 + MAC_CONTENT_LEFT); + } +} From 238162f33287cf51db3ef0532c4283750bf364d9 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 20:38:13 +0200 Subject: [PATCH 045/150] Option to keep the system cursor over the drawing area for magnifiers (#39) (#187) The canvas hides the OS cursor (CursorIcon::None, which winit turns into ShowCursor(FALSE) on Windows) and draws its own crosshair, so Windows Magnifier's "follow the mouse pointer", screen readers, recorders and remote desktops lose the pointer there. View > Accessibility > Show System Cursor (ui.toggle.systemcursor, ui.set {"systemCursor": true}, or CADCRAFT_SYSTEM_CURSOR=1 at startup) keeps a system crosshair visible at the centre of the drawn one. Co-authored-by: Claude Opus 5.5 --- README.md | 4 +++ crates/ui-egui/src/canvas.rs | 3 ++- crates/ui-egui/src/lib.rs | 6 +++++ crates/ui-egui/src/menus.rs | 5 ++++ crates/ui-egui/tests/system_cursor.rs | 37 +++++++++++++++++++++++++++ 5 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/system_cursor.rs diff --git a/README.md b/README.md index 726f9cf5..e7ca2e5c 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,10 @@ apps: cadcraft · cadcraft-cli · cadcraft-web Nothing below `ui-egui` knows about egui, so the front end can be replaced. `cargo xtask ci` checks formatting, clippy, tests, asset attribution, the crate layering and the wasm build. +## Accessibility + +Over the drawing area CADCraft hides the system cursor and draws its own crosshair. Tools that follow the system cursor (Windows Magnifier set to follow or centre the mouse pointer, other screen magnifiers, screen recorders, remote desktops) lose track of it there. Turn on **View ▸ Accessibility ▸ Show System Cursor** to keep a small system crosshair visible at the centre of the drawn one, or start CADCraft with the environment variable `CADCRAFT_SYSTEM_CURSOR=1` (on Windows, set it once with `setx CADCRAFT_SYSTEM_CURSOR 1` and start CADCraft again). Agents can switch it with the control channel: `ui.set {"systemCursor": true}`. + ## Roadmap See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of remaining work, and [docs/gaps.md](docs/gaps.md) for everything still missing. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7be3ea16..c935ed0e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -906,7 +906,8 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { } // Crosshair cursor. if inside { - ui.ctx().set_cursor_icon(egui::CursorIcon::None); + // Hide the OS cursor under the drawn crosshair, unless assistive tech needs it (#39). + ui.ctx().set_cursor_icon(if app.ui.system_cursor { egui::CursorIcon::Crosshair } else { egui::CursorIcon::None }); if let Some(hp) = hover_pos { draw_crosshair(app, &painter, rect, hp, selecting); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f09f1e2a..fb570776 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -53,6 +53,11 @@ pub struct UiState { pub show_viewcube: bool, pub show_ucs_icon: bool, pub show_layer_list: bool, + /// Keep the system cursor visible over the drawing area (a small crosshair at the centre of the + /// drawn one) instead of hiding it. Screen magnifiers (Windows Magnifier "follow the mouse + /// pointer"), screen readers, recorders and remote desktops track the system cursor and lose it + /// when it is hidden. Off by default; `CADCRAFT_SYSTEM_CURSOR=1` turns it on at startup. + pub system_cursor: bool, /// "Drafting" or "Modeling". pub toolset_tab: String, pub collapsed_groups: Vec, @@ -79,6 +84,7 @@ impl Default for UiState { show_viewcube: true, show_ucs_icon: true, show_layer_list: false, + system_cursor: std::env::var("CADCRAFT_SYSTEM_CURSOR").is_ok_and(|v| !matches!(v.trim(), "" | "0" | "false" | "off")), toolset_tab: "Drafting".into(), collapsed_groups: Vec::new(), properties_all: true, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 821522b5..e41d4d8e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -15,6 +15,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("ui.sample", "Open Sample Drawing", &["Help", "Open Sample Drawing"], None), ("ui.toggle.toolsets", "Tool Sets", &["Window", "Tool Sets"], Some("Cmd+3")), ("ui.toggle.palettes", "Properties Inspector", &["Window", "Properties Inspector"], Some("Cmd+1")), + ("ui.toggle.systemcursor", "Show System Cursor", &["View", "Accessibility", "Show System Cursor"], None), ("ui.toggle.toolbar", "Tool Bar", &["Window", "Tool Bar"], None), ("ui.toggle.filetabs", "File Tab", &["Window", "File Tab"], None), ("ui.toggle.statusbar", "Status Bar", &["Window", "Status Bar"], None), @@ -135,6 +136,10 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option { + toggle(&mut app.ui.system_cursor, params); + Ok(Value::Null) + } "ui.toggle.viewcube" => { toggle(&mut app.ui.show_viewcube, params); Ok(Value::Null) diff --git a/crates/ui-egui/tests/system_cursor.rs b/crates/ui-egui/tests/system_cursor.rs new file mode 100644 index 00000000..ff2c8942 --- /dev/null +++ b/crates/ui-egui/tests/system_cursor.rs @@ -0,0 +1,37 @@ +//! The OS cursor over the drawing area (#39): hidden under the drawn crosshair by default, kept +//! visible with `systemCursor` so screen magnifiers and other assistive tech can follow it. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::{CadApp, Services}; + +/// The cursor icon the app asks for with the pointer in the middle of the drawing area. +fn canvas_cursor(app: &mut CadApp) -> egui::CursorIcon { + let ctx = egui::Context::default(); + let mut icon = egui::CursorIcon::Default; + // A few frames: layout settles and the pointer hover is known from the second frame on. + for _ in 0..3 { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))), + events: vec![egui::Event::PointerMoved(egui::pos2(640.0, 400.0))], + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + icon = out.platform_output.cursor_icon; + out.textures_delta.clear(); // no renderer here + } + icon +} + +#[test] +fn system_cursor_stays_visible_over_the_canvas_when_requested() { + let mut app = CadApp::new(Session::new(), Services::default()); + app.ui.system_cursor = false; + assert_eq!(canvas_cursor(&mut app), egui::CursorIcon::None, "default: the drawn crosshair replaces the OS cursor"); + + app.run("ui.toggle.systemcursor", serde_json::json!({ "on": true })).ok(); + assert!(app.ui.system_cursor); + assert_eq!(canvas_cursor(&mut app), egui::CursorIcon::Crosshair, "systemCursor keeps an OS cursor for magnifiers"); +} From a560048dd03caabc2eb5f69df0fc98afda995c92 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 20:38:20 +0200 Subject: [PATCH 046/150] =?UTF-8?q?Web:=20File=20>=20Open=E2=80=A6=20shows?= =?UTF-8?q?=20the=20browser's=20file=20picker=20(fixes=20#55)=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web app ran CadApp with Services::default(), so OPEN found no file picker and returned without doing anything: no picker, no message. The web app now installs an open picker (apps/cadcraft-web/src/upload.rs, web-sys only): a hidden is clicked, the chosen file is read in the background (Blob.arrayBuffer) and handed to the app on its next frame, which runs OPEN with {data, name} through the new CadApp::open_bytes. Cancelling the picker does nothing. Files dropped on the page, which eframe hands over unread on the web and the UI ignored there, take the same route. Files over 256 MB (CadApp::MAX_OPEN_BYTES; the browser holds the drawing several times while decoding it) are refused before they are read. Unreadable or rejected files report on the command line and in the status bar; nothing panics. Co-authored-by: Claude Opus 5.5 --- apps/cadcraft-web/Cargo.toml | 4 + apps/cadcraft-web/src/main.rs | 18 ++++- apps/cadcraft-web/src/upload.rs | 119 +++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 43 ++++++++++- crates/ui-egui/tests/open_bytes.rs | 38 +++++++++ 5 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 apps/cadcraft-web/src/upload.rs create mode 100644 crates/ui-egui/tests/open_bytes.rs diff --git a/apps/cadcraft-web/Cargo.toml b/apps/cadcraft-web/Cargo.toml index c89e1272..0aad3b25 100644 --- a/apps/cadcraft-web/Cargo.toml +++ b/apps/cadcraft-web/Cargo.toml @@ -21,6 +21,10 @@ egui = { workspace = true } log = { workspace = true } serde_json = { workspace = true } +# File > Open… and dropped files (src/upload.rs): the browser's file picker and file reads. +[target.wasm32-unknown-unknown.dependencies] +web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"] } + [target.'cfg(target_arch = "wasm32")'.dependencies] eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index 6810ccea..b15e68a0 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -15,10 +15,13 @@ mod web { const CANVAS_ID: &str = "cadcraft_canvas"; const LOADING_ID: &str = "cadcraft_loading"; - struct Shell(CadApp); + /// The app, and the files File > Open… and drag and drop read for it in the background. + struct Shell(CadApp, crate::upload::Inbox); impl eframe::App for Shell { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.1.take_dropped(ctx); + self.1.drain(&mut self.0); self.0.logic(ctx); } fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { @@ -77,7 +80,15 @@ mod web { if query().contains("sample") { let _ = app.run("ui.sample", serde_json::json!({})); } - Ok(Box::new(Shell(app))) + // No paths on the web: OPEN asks the browser's file picker, which reads the + // chosen file in the background (`upload.rs`), so the picker returns none. + let inbox = crate::upload::Inbox::new(&cc.egui_ctx); + let picker = inbox.clone(); + app.services.pick_open = Some(Box::new(move || { + picker.pick(); + None + })); + Ok(Box::new(Shell(app, inbox))) }), ) .await; @@ -93,6 +104,9 @@ mod web { } } +#[cfg(target_arch = "wasm32")] +mod upload; + #[cfg(target_arch = "wasm32")] fn main() { web::start(); diff --git a/apps/cadcraft-web/src/upload.rs b/apps/cadcraft-web/src/upload.rs new file mode 100644 index 00000000..d2edce74 --- /dev/null +++ b/apps/cadcraft-web/src/upload.rs @@ -0,0 +1,119 @@ +//! Browser uploads: File > Open… on the web, through web-sys only. There is no file system to pick +//! a path from, so the picker is a hidden ``; the chosen file (or one dropped on +//! the page) is read in the background and handed to the app on its next frame, which opens it +//! with OPEN `{data, name}` ([`CadApp::open_bytes`]). + +use std::cell::Cell; +use std::rc::Rc; + +use cadcraft_ui_egui::CadApp; +use wasm_bindgen::JsCast as _; +use web_sys::js_sys; + +/// The hidden file input (one at a time; a cancelled pick leaves it until the next one). +const INPUT_ID: &str = "cadcraft_open_input"; +/// What the picker offers: the formats OPEN reads. +const ACCEPT: &str = ".dxf,.dwg"; + +/// A file read in the browser (name, contents), or why it couldn't be read. +type Upload = Result<(String, Vec), String>; + +/// Files read in the background, waiting for the app's next frame. +#[derive(Clone)] +pub struct Inbox { + files: Rc>>, + ctx: egui::Context, +} + +impl Inbox { + pub fn new(ctx: &egui::Context) -> Self { + Inbox { files: Rc::default(), ctx: ctx.clone() } + } + + /// Show the browser's file picker and return at once. A chosen file arrives through + /// [`Self::drain`]; cancelling the picker does nothing. + pub fn pick(&self) { + if let Err(e) = self.show_picker() { + self.push(Err(format!("Open: the browser's file picker is not available ({e})"))); + } + } + + fn show_picker(&self) -> Result<(), String> { + let document = web_sys::window().and_then(|w| w.document()).ok_or("no browser document")?; + let body = document.body().ok_or("the page has no body")?; + if let Some(old) = document.get_element_by_id(INPUT_ID) { + old.remove(); + } + let input = document + .create_element("input") + .map_err(js_error)? + .dyn_into::() + .map_err(|_| "could not create a file input".to_string())?; + input.set_type("file"); + input.set_accept(ACCEPT); + input.set_id(INPUT_ID); + input.set_attribute("style", "display:none").map_err(js_error)?; + let (inbox, chosen) = (self.clone(), input.clone()); + // Called once, when a file was chosen (browsers don't fire `change` on cancel). + let on_change = wasm_bindgen::closure::Closure::once_into_js(move || { + if let Some(file) = chosen.files().and_then(|f| f.get(0)) { + inbox.read(file); + } + chosen.remove(); + }); + input.set_onchange(Some(on_change.unchecked_ref())); + body.append_child(&input).map_err(js_error)?; + input.click(); + Ok(()) + } + + /// Read the files dropped on the page this frame (eframe hands them over unread on the web). + pub fn take_dropped(&self, ctx: &egui::Context) { + for f in ctx.input(|i| i.raw.dropped_files.clone()) { + if let Some(file) = f.web_file() { + self.read(file.clone()); + } + } + } + + /// Read `file` in the background; it arrives through [`Self::drain`]. A file too large to + /// open is refused before it is read. + fn read(&self, file: web_sys::File) { + let name = file.name(); + // `File.size` is a whole number of bytes; anything else counts as too large. + let size = file.size(); + let len = if size.is_finite() && size >= 0.0 { size as u64 } else { u64::MAX }; + if let Some(e) = CadApp::open_size_error(&name, len) { + return self.push(Err(e)); + } + let inbox = self.clone(); + wasm_bindgen_futures::spawn_local(async move { + let r = match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await { + Ok(buf) => Ok((name, js_sys::Uint8Array::new(&buf).to_vec())), + Err(e) => Err(format!("Open: could not read {name}: {}", js_error(e))), + }; + inbox.push(r); + }); + } + + fn push(&self, upload: Upload) { + let mut files = self.files.take(); + files.push(upload); + self.files.set(files); + self.ctx.request_repaint(); + } + + /// Open what arrived since the last frame (or report why it couldn't be read). + pub fn drain(&self, app: &mut CadApp) { + for upload in self.files.take() { + match upload { + Ok((name, bytes)) => app.open_bytes(&name, &bytes), + Err(e) => app.open_failed(e), + } + } + } +} + +fn js_error(e: wasm_bindgen::JsValue) -> String { + e.as_string().unwrap_or_else(|| format!("{e:?}")) +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb570776..da19a0b4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -245,7 +245,48 @@ impl CadApp { } pub fn open_path(&mut self, path: &str) { - if let Err(e) = self.run("open", json!({ "path": path })) { + let r = self.run("open", json!({ "path": path })); + self.opened(r); + } + + /// The largest file [`Self::open_bytes`] opens. A drawing opened from its contents (the + /// browser, where files have no path) is held in memory several times over while it is decoded, + /// so it is capped below the DWG reader's own limit instead of running out of memory. + pub const MAX_OPEN_BYTES: u64 = 256 << 20; + + /// Why a `len`-byte file called `name` is too large for [`Self::open_bytes`], if it is. + pub fn open_size_error(name: &str, len: u64) -> Option { + (len > Self::MAX_OPEN_BYTES).then(|| { + format!( + "Open: {name} is {:.1} MB, larger than the {} MB CADCraft opens in the browser", + len as f64 / 1048576.0, + Self::MAX_OPEN_BYTES >> 20 + ) + }) + } + + /// Open a drawing from its contents where there is no path to open (on the web: the file + /// picker, a dropped file). Runs OPEN with `{data, name}` as [`Self::open_path`] runs it with + /// `{path}`; failures show on the command line and in the status bar. + pub fn open_bytes(&mut self, name: &str, bytes: &[u8]) { + if let Some(e) = Self::open_size_error(name, bytes.len() as u64) { + return self.open_failed(e); + } + let data = cadcraft_engine::cmd::file::base64_encode(bytes); + let r = self.run("open", json!({ "data": data, "name": name })); + self.opened(r); + } + + /// Report a file that could not be opened before OPEN ran (unreadable, too large) as a failed + /// OPEN is reported: on the command line and in the status bar. + pub fn open_failed(&mut self, e: impl Into) { + let e = e.into(); + self.session.echo(e.clone()); + self.set_status(e); + } + + fn opened(&mut self, r: Result) { + if let Err(e) = r { self.set_status(e); } else { self.ui.start_tab = false; diff --git a/crates/ui-egui/tests/open_bytes.rs b/crates/ui-egui/tests/open_bytes.rs new file mode 100644 index 00000000..bac55d07 --- /dev/null +++ b/crates/ui-egui/tests/open_bytes.rs @@ -0,0 +1,38 @@ +//! Opening a drawing from its contents, where the file has no path (the web's File > Open… picker +//! and files dropped on the page, #55). + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::Drawing; +use cadcraft_ui_egui::{CadApp, Services}; + +#[test] +fn open_bytes_opens_the_named_drawing_and_reports_failures() { + // A reader that only accepts what a DXF starts with: proves the bytes arrive unchanged. + set_io(IoHooks { + read: |b, _| if b.starts_with(b"0\nSECTION\n") { Ok(Drawing::new_imperial()) } else { Err("not a drawing".into()) }, + write: |_, _| Ok(Vec::new()), + plot: None, + }); + let mut app = CadApp::new(Session::empty(), Services::default()); + app.ui.start_tab = true; + + app.open_bytes("Bracket.dwg", b"0\nSECTION\n2\nHEADER\n"); + assert_eq!(app.session.docs.len(), 1); + assert_eq!(app.session.state().map(|s| s.title.clone()).ok().as_deref(), Some("Bracket.dwg")); + assert!(!app.ui.start_tab && app.canvas.zoom_pending, "shown and zoomed like a drawing opened from a path"); + + // A file the reader rejects: nothing opens; the error reaches the command line and status bar. + app.open_bytes("junk.dxf", b"junk"); + assert_eq!(app.session.docs.len(), 1); + assert!(app.session.log.last().is_some_and(|l| l.contains("not a drawing")), "{:?}", app.session.log.last()); + assert!(app.status.as_ref().is_some_and(|(s, _)| s.contains("not a drawing"))); + + // Too large to hold in memory: refused before anything is read, with the limit named. + assert!(CadApp::open_size_error("big.dwg", CadApp::MAX_OPEN_BYTES).is_none()); + let e = CadApp::open_size_error("huge.dwg", CadApp::MAX_OPEN_BYTES * 2); + assert_eq!(e.as_deref(), Some("Open: huge.dwg is 512.0 MB, larger than the 256 MB CADCraft opens in the browser")); + app.open_failed(e.unwrap_or_default()); + assert_eq!(app.session.log.last().map(String::as_str), Some("Open: huge.dwg is 512.0 MB, larger than the 256 MB CADCraft opens in the browser")); + assert_eq!(app.session.docs.len(), 1); +} From f38b3a6447204a8477afee2c9dfab692ce9ccffe Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:38:42 +0700 Subject: [PATCH 047/150] fix(geom): calculate tight ellipse and arc bounds analytically (#211) --- crates/geom/src/curve.rs | 19 ++++++++++++++++--- crates/geom/src/tests.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/geom/src/curve.rs b/crates/geom/src/curve.rs index c6be8dab..9d45453a 100644 --- a/crates/geom/src/curve.rs +++ b/crates/geom/src/curve.rs @@ -256,9 +256,22 @@ impl Ellipse { } } pub fn bounds(&self) -> Bounds2 { - let mut pts = Vec::new(); - self.tessellate(self.major.len() * 1e-4, &mut pts); - Bounds2::from_points(pts) + // Sampled bounds can cut off the true extrema, especially on a small arc or + // a large drawing. The derivative of each coordinate gives two exact + // candidate parameters (opposite sides of the ellipse). + let minor = self.minor(); + let mut bounds = Bounds2::from_points([self.at_param(self.start), self.at_param(self.end)]); + for angle in [ + minor.x.atan2(self.major.x), + minor.x.atan2(self.major.x) + PI, + minor.y.atan2(self.major.y), + minor.y.atan2(self.major.y) + PI, + ] { + if angle_in_sweep(angle, self.start, self.end) { + bounds.add(self.at_param(angle)); + } + } + bounds } /// Parameter of the point on the ellipse nearest the direction of `p` (approximate). pub fn param_of(&self, p: Vec2) -> f64 { diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index d6f1320f..a9f5daf6 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -206,3 +206,33 @@ fn circle_three_points_accepts_small_valid_triangles() { assert!(Circle::from_3_points(Vec2::ZERO, Vec2::new(r, 0.0), Vec2::new(2.0 * r, 0.0)).is_none()); assert!(Circle::from_3_points(Vec2::ZERO, Vec2::X, Vec2::new(f64::NAN, 0.0)).is_none()); } + +#[test] +fn ellipse_bounds_find_analytic_extrema() { + // A tilted ellipse's extrema generally fall between tessellated samples. + let center = Vec2::new(7.0, -11.0); + let e = Ellipse::full(center, Vec2::new(3.0, 4.0), 0.5); + let bounds = e.bounds(); + let dx = 13.0_f64.sqrt(); // hypot(major.x, minor.x) + let dy = 18.25_f64.sqrt(); // hypot(major.y, minor.y) + assert!((bounds.min.x - (center.x - dx)).abs() < 1e-12); + assert!((bounds.max.x - (center.x + dx)).abs() < 1e-12); + assert!((bounds.min.y - (center.y - dy)).abs() < 1e-12); + assert!((bounds.max.y - (center.y + dy)).abs() < 1e-12); +} + +#[test] +fn elliptical_arc_bounds_exclude_extrema_outside_the_sweep() { + let e = Ellipse { + center: Vec2::ZERO, + major: Vec2::new(3.0, 4.0), + ratio: 0.5, + start: 0.0, + end: PI / 4.0, + }; + let bounds = e.bounds(); + assert!((bounds.max.x - 3.0).abs() < 1e-12); + assert!((bounds.min.x - e.at_param(e.end).x).abs() < 1e-12); + assert!((bounds.min.y - e.at_param(e.end).y).abs() < 1e-12); + assert!((bounds.max.y - 18.25_f64.sqrt()).abs() < 1e-12); +} From c52f4d190ed0263d6ecb8d19facf0b2341677b83 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:38:57 +0700 Subject: [PATCH 048/150] fix(geom): measure bulged polylines and distant polygons accurately (#212) * fix(geom): compute polyline bulge areas without tessellation error * fix(geom): preserve polygon area far from the coordinate origin --- crates/geom/src/curve.rs | 34 +++++++++++++++++++++++++++++--- crates/geom/src/tests.rs | 42 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/crates/geom/src/curve.rs b/crates/geom/src/curve.rs index 9d45453a..89984cea 100644 --- a/crates/geom/src/curve.rs +++ b/crates/geom/src/curve.rs @@ -514,8 +514,31 @@ impl Polyline { } /// Signed area (CCW positive) of a closed polyline including bulge areas. pub fn area(&self) -> f64 { - let pts = self.tessellate(1e-4); - shoelace(&pts) + // Integrate the chords exactly and add the signed circular-segment area + // for every bulge. A tessellated shoelace loses precision on large arcs. + // Working relative to the first vertex avoids cancellation when a small + // shape is located far from the drawing origin. + let Some(first) = self.vertices.first() else { + return 0.0; + }; + let origin = first.p; + let n = self.vertices.len(); + let mut twice_area = 0.0; + for i in 0..n { + let (Some(a), Some(b)) = (self.vertices.get(i), self.vertices.get((i + 1) % n)) else { + continue; + }; + twice_area += (a.p - origin).cross(b.p - origin); + // Open polylines keep their historical implicit straight closing edge. + // The final vertex's bulge is only used for an explicit closing segment. + if (i + 1 < n || self.closed) + && let Some((arc, ccw)) = bulge_to_arc(a.p, b.p, a.bulge) + { + let sweep = if ccw { arc.sweep() } else { -arc.sweep() }; + twice_area += arc.radius * arc.radius * (sweep - sweep.sin()); + } + } + twice_area / 2.0 } pub fn closest(&self, p: Vec2) -> Option { let mut best: Option<(f64, Vec2)> = None; @@ -540,10 +563,15 @@ pub fn shoelace(pts: &[Vec2]) -> f64 { if n < 3 { return 0.0; } + let Some(origin) = pts.first().copied() else { + return 0.0; + }; + // Measure about a local origin so a small polygon at a large world + // coordinate does not lose its area to cancellation of huge products. let mut s = 0.0; for i in 0..n { if let (Some(a), Some(b)) = (pts.get(i), pts.get((i + 1) % n)) { - s += a.cross(*b); + s += (*a - origin).cross(*b - origin); } } s / 2.0 diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index a9f5daf6..1c17fb28 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -236,3 +236,45 @@ fn elliptical_arc_bounds_exclude_extrema_outside_the_sweep() { assert!((bounds.min.y - e.at_param(e.end).y).abs() < 1e-12); assert!((bounds.max.y - 18.25_f64.sqrt()).abs() < 1e-12); } + +#[test] +fn polyline_area_is_exact_for_bulge_segments() { + // One semicircular arc from left to right plus a straight closing edge. + let mut p = Polyline::from_points(&[Vec2::new(0.0, 0.0), Vec2::new(2.0, 0.0)], true); + p.vertices[0].bulge = 1.0; + assert!((p.area() - PI / 2.0).abs() < 1e-12, "{}", p.area()); + p.vertices[0].bulge = -1.0; + assert!((p.area() + PI / 2.0).abs() < 1e-12, "{}", p.area()); +} + +#[test] +fn polyline_area_is_stable_far_from_the_origin() { + let o = 1.0e12; + let p = Polyline::from_points( + &[ + Vec2::new(o, o), + Vec2::new(o + 10.0, o), + Vec2::new(o + 10.0, o + 10.0), + Vec2::new(o, o + 10.0), + ], + true, + ); + assert!((p.area() - 100.0).abs() < 1e-12, "{}", p.area()); + // An open polyline's measured area retains the implicit straight closing edge. + let mut open = p; + open.closed = false; + assert!((open.area() - 100.0).abs() < 1e-12); +} + +#[test] +fn shoelace_preserves_small_polygon_area_at_large_coordinates() { + let o = 1.0e12; + let corners = [ + Vec2::new(o, o), + Vec2::new(o + 10.0, o), + Vec2::new(o + 10.0, o + 10.0), + Vec2::new(o, o + 10.0), + ]; + assert_eq!(shoelace(&corners), 100.0); + assert_eq!(shoelace(&corners.iter().rev().copied().collect::>()), -100.0); +} From 9441dad29f1610b2a96423dc9d6577fdbbcd16a4 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:39:13 +0700 Subject: [PATCH 049/150] fix(geom): avoid false line-circle tangencies on long construction lines (#214) * fix(geom): avoid false line-circle tangencies on long lines * fix(geom): construct tangent points relative to the projection foot --- crates/geom/src/intersect.rs | 31 +++++++++++++++++++------------ crates/geom/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/crates/geom/src/intersect.rs b/crates/geom/src/intersect.rs index a6952ef2..59d4d97f 100644 --- a/crates/geom/src/intersect.rs +++ b/crates/geom/src/intersect.rs @@ -25,23 +25,30 @@ pub fn line_circle(l: &Line, c: &Circle) -> Vec<(Vec2, f64)> { let d = l.b - l.a; let f = l.a - c.center; let a = d.dot(d); - if a < EPS * EPS { + if !(a >= EPS * EPS && a.is_finite() && c.radius.is_finite() && f.is_finite()) { return Vec::new(); } - let b = 2.0 * f.dot(d); - let cc = f.dot(f) - c.radius * c.radius; - let disc = b * b - 4.0 * a * cc; - if disc < -1e-12 * a { + // The quadratic discriminant subtracts two O(length^4) numbers. For a + // long construction line nearly tangent to a small circle, that loses the + // gap entirely and reports a false intersection. Measure from the closest + // point on the infinite line instead. + let t = -f.dot(d) / a; + let nearest = f + d * t; + let delta = c.radius * c.radius - nearest.len2(); + let tolerance = 1e-12 * c.radius * c.radius; + if !delta.is_finite() || delta < -tolerance { return Vec::new(); } - if disc.abs() <= 1e-12 * a { - let t = -b / (2.0 * a); - return vec![(l.at(t), t)]; + let foot = c.center + nearest; + if delta <= tolerance { + return vec![(foot, t)]; } - let sq = disc.max(0.0).sqrt(); - let t1 = (-b - sq) / (2.0 * a); - let t2 = (-b + sq) / (2.0 * a); - vec![(l.at(t1), t1), (l.at(t2), t2)] + let offset = (delta / a).sqrt(); + let (t1, t2) = (t - offset, t + offset); + let step = d * offset; + // Construct the intersections around the foot, not from a tiny change + // to t on a very long line (which loses the small distance again). + vec![(foot - step, t1), (foot + step, t2)] } /// Intersections of two full circles. diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 1c17fb28..974647b5 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -278,3 +278,25 @@ fn shoelace_preserves_small_polygon_area_at_large_coordinates() { assert_eq!(shoelace(&corners), 100.0); assert_eq!(shoelace(&corners.iter().rev().copied().collect::>()), -100.0); } + +#[test] +fn near_tangent_long_line_does_not_invent_a_circle_hit() { + let c = Circle::new(Vec2::ZERO, 1.0); + let far = 1.0e8; + let outside = Line::new(Vec2::new(-far, 1.0 + 1e-9), Vec2::new(far, 1.0 + 1e-9)); + assert!(line_circle(&outside, &c).is_empty()); + let tangent = Line::new(Vec2::new(-far, 1.0), Vec2::new(far, 1.0)); + let hits = line_circle(&tangent, &c); + assert_eq!(hits.len(), 1); + assert!(hits[0].0.near(Vec2::new(0.0, 1.0), 1e-9)); +} + +#[test] +fn long_secant_keeps_both_intersections() { + let c = Circle::new(Vec2::ZERO, 2.0); + let l = Line::new(Vec2::new(-1.0e8, 0.0), Vec2::new(1.0e8, 0.0)); + let hits = line_circle(&l, &c); + assert_eq!(hits.len(), 2); + assert!(hits[0].0.near(Vec2::new(-2.0, 0.0), 1e-8), "{hits:?}"); + assert!(hits[1].0.near(Vec2::new(2.0, 0.0), 1e-8), "{hits:?}"); +} From e13de2c2f1d82c7ca2e82a587cf72b8490ee7561 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:39:28 +0200 Subject: [PATCH 050/150] Layer delete and purge keep layers used by block definitions (#72) (#189) --- crates/engine/src/cmd/blocks.rs | 3 ++- crates/engine/src/cmd/layer.rs | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index f6ee909d..ba82fc78 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -268,7 +268,8 @@ fn run_purge(s: &mut Session, _p: &Value) -> Result { let before_b = d.blocks.len(); d.blocks.retain(|k, _| used.contains(&k.to_ascii_uppercase())); let blocks = before_b - d.blocks.len(); - let used_layers: std::collections::HashSet = all.iter().map(|e| e.common.layer.to_ascii_lowercase()).collect(); + // Layers used by the model, layouts and the block definitions that survived above. + let used_layers = super::layer::used_layers(d); let cur = d.header.str("CLAYER", "0").to_ascii_lowercase(); let before_l = d.layers.len(); d.layers.retain(|l| { diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index d902ec6a..1867ce90 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -238,7 +238,8 @@ fn run_delete(s: &mut Session, p: &Value) -> Result { return Err(bad("layer.delete", "cannot delete layer 0, Defpoints or the current layer")); } let used = d.model.iter().any(|e| e.common.layer.eq_ignore_ascii_case(&name)) - || d.layouts.iter().any(|l| l.entities.iter().any(|e| e.common.layer.eq_ignore_ascii_case(&name))); + || d.layouts.iter().any(|l| l.entities.iter().any(|e| e.common.layer.eq_ignore_ascii_case(&name))) + || d.blocks.values().any(|b| b.entities.iter().any(|e| e.common.layer.eq_ignore_ascii_case(&name))); if used { return Err(bad("layer.delete", "layer has objects on it")); } @@ -438,4 +439,18 @@ mod tests { s.execute("layer.set", &json!({ "name": "0", "color": 3 })).unwrap(); assert_ne!(s.doc().unwrap().layer("0").unwrap(), &before); } + + #[test] + fn delete_and_purge_keep_layers_used_by_block_definitions() { + let mut s = Session::new(); + s.execute("layer.new", &json!({ "name": "A", "color": 1 })).unwrap(); + s.execute("layer.current", &json!({ "name": "A" })).unwrap(); + let l = s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap()["handles"][0].as_str().unwrap().to_string(); + s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [l], "keep": "delete" })).unwrap(); + s.execute("layer.current", &json!({ "name": "0" })).unwrap(); + s.execute("insert", &json!({ "name": "B", "at": [5, 5] })).unwrap(); + assert!(s.execute("layer.delete", &json!({ "name": "A" })).is_err()); + s.execute("purge", &json!({})).unwrap(); + assert!(s.doc().unwrap().layer("A").is_some()); + } } From 3d125493f94d0cb97e78e5757c2b6cff62ff3402 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:39:43 +0200 Subject: [PATCH 051/150] Layer rename re-points paper-space and block-definition entities (#75) (#191) --- crates/engine/src/cmd/layer.rs | 40 ++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 1867ce90..0b462abe 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -195,10 +195,15 @@ fn run_set(s: &mut Session, p: &Value) -> Result { *l = updated; if let Some(nn) = new_name { l.name = nn.clone(); - // Re-point entities. - let hs: Vec<_> = d.model.iter().filter(|e| e.common.layer.eq_ignore_ascii_case(&old)).map(|e| e.handle).collect(); - for h in hs { - d.model.modify(h, |e| e.common.layer = nn.clone()); + // Re-point entities in model space, every layout and every block definition. + repoint(&mut d.model, &old, &nn); + for layout in &mut d.layouts { + repoint(&mut layout.entities, &old, &nn); + } + for block in d.blocks.values_mut() { + if block.entities.iter().any(|e| e.common.layer.eq_ignore_ascii_case(&old)) { + repoint(&mut std::sync::Arc::make_mut(block).entities, &old, &nn); + } } if cur.eq_ignore_ascii_case(&old) { d.header.set_str("CLAYER", &nn); @@ -207,6 +212,13 @@ fn run_set(s: &mut Session, p: &Value) -> Result { ok() } +fn repoint(store: &mut cadcraft_doc::EntityStore, old: &str, new: &str) { + let hs: Vec<_> = store.iter().filter(|e| e.common.layer.eq_ignore_ascii_case(old)).map(|e| e.handle).collect(); + for h in hs { + store.modify(h, |e| e.common.layer = new.to_string()); + } +} + fn run_current(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("layer.current", "`name` is required"))?.to_string(); let d = s.doc_mut()?; @@ -453,4 +465,24 @@ mod tests { s.execute("purge", &json!({})).unwrap(); assert!(s.doc().unwrap().layer("A").is_some()); } + + #[test] + fn rename_repoints_paper_space_and_block_entities() { + let mut s = Session::new(); + s.execute("layer.new", &json!({ "name": "A", "color": "red", "current": true })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap(); + s.execute("selectall", &json!({})).unwrap(); + s.execute("block", &json!({ "name": "Part", "base": [0, 0], "keep": "delete" })).unwrap(); + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap(); + s.execute("layer.set", &json!({ "name": "A", "newName": "B" })).unwrap(); + let d = s.doc().unwrap(); + let on_layer = |name: &str| { + let paper = d.layouts.iter().flat_map(|l| l.entities.iter()); + let blocks = d.blocks.values().flat_map(|b| b.entities.iter()); + paper.chain(blocks).filter(|e| e.common.layer == name).count() + }; + assert_eq!(on_layer("A"), 0); + assert_eq!(on_layer("B"), 2); + } } From 3b73884abdc5be69a079b003c2564d79853f5452 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 20:39:58 +0200 Subject: [PATCH 052/150] Deliver browser saves as downloads; keep unsaved state when they fail (#133) (#193) On wasm, saving skipped the file write but still marked the drawing saved, renamed its tab and reported success, so nothing reached the user. Saving now hands the bytes to a host delivery hook (`file::set_deliver`); the web app installs a small web-sys download helper (Blob + object URL + temporary ), reusable for other files such as plots. Without the hook, or when the download can't be started, save errors and the drawing stays dirty. A started download counts as saved and keeps the name for later QSAVEs. The web app's Save/Save As (menu, close prompt) download under the drawing's name, as there is no file picker in the browser. Co-authored-by: Claude Opus 5.5 --- apps/cadcraft-web/Cargo.toml | 2 +- apps/cadcraft-web/src/download.rs | 59 ++++++++++++++++++++ apps/cadcraft-web/src/main.rs | 15 +++++- crates/engine/src/cmd/file.rs | 89 +++++++++++++++++++++++++++++-- 4 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 apps/cadcraft-web/src/download.rs diff --git a/apps/cadcraft-web/Cargo.toml b/apps/cadcraft-web/Cargo.toml index 0aad3b25..c3a8c2bc 100644 --- a/apps/cadcraft-web/Cargo.toml +++ b/apps/cadcraft-web/Cargo.toml @@ -29,4 +29,4 @@ web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"] eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url"] } diff --git a/apps/cadcraft-web/src/download.rs b/apps/cadcraft-web/src/download.rs new file mode 100644 index 00000000..68185e38 --- /dev/null +++ b/apps/cadcraft-web/src/download.rs @@ -0,0 +1,59 @@ +//! Browser downloads: hand bytes to the user as a file, through web-sys only (a `Blob`, an object +//! URL and a temporary `` link). This is the web's file route for everything that +//! produces a file: drawing saves today (installed as the engine's +//! [`cadcraft_engine::cmd::file::set_deliver`] hook), plots next. + +use wasm_bindgen::JsCast as _; +use web_sys::js_sys; + +/// How long the object URL stays valid after the click; browsers read it asynchronously. +const REVOKE_AFTER_MS: i32 = 60_000; + +/// Start a browser download of `bytes` as a file called `name` with MIME type `mime`. +/// +/// `Ok` once the download was triggered. Where it lands (or whether the user cancels a "save as" +/// prompt of the browser) is up to the browser and can't be observed from the page. +pub fn download(name: &str, mime: &str, bytes: &[u8]) -> Result<(), String> { + if name.trim().is_empty() { + return Err("a file name is required".into()); + } + let window = web_sys::window().ok_or("no browser window")?; + let document = window.document().ok_or("no browser document")?; + let body = document.body().ok_or("the page has no body")?; + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); + let options = web_sys::BlobPropertyBag::new(); + options.set_type(mime); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options).map_err(js_error)?; + let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js_error)?; + if let Err(e) = click_link(&document, &body, &url, name) { + let _ = web_sys::Url::revoke_object_url(&url); + return Err(e); + } + let later = url.clone(); + let revoke = wasm_bindgen::closure::Closure::once_into_js(move || { + let _ = web_sys::Url::revoke_object_url(&later); + }); + if window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), REVOKE_AFTER_MS).is_err() { + let _ = web_sys::Url::revoke_object_url(&url); + } + Ok(()) +} + +fn click_link(document: &web_sys::Document, body: &web_sys::HtmlElement, url: &str, name: &str) -> Result<(), String> { + let link = document + .create_element("a") + .map_err(js_error)? + .dyn_into::() + .map_err(|_| "could not create a download link".to_string())?; + link.set_href(url); + link.set_download(name); + link.set_attribute("style", "display:none").map_err(js_error)?; + body.append_child(&link).map_err(js_error)?; + link.click(); + link.remove(); + Ok(()) +} + +fn js_error(e: wasm_bindgen::JsValue) -> String { + e.as_string().unwrap_or_else(|| format!("{e:?}")) +} diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index b15e68a0..277faf6a 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -3,9 +3,12 @@ //! Runs the same [`cadcraft_ui_egui::CadApp`] as the desktop app through eframe's web runner //! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this //! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2, `?sample` opens the sample -//! drawing. +//! drawing. Saving downloads the drawing through the browser (`download.rs`). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(target_arch = "wasm32")] +pub mod download; + #[cfg(target_arch = "wasm32")] mod web { use cadcraft_engine::Session; @@ -44,6 +47,12 @@ mod web { web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() } + /// No file picker on the web: Save / Save As from the menu (and the close prompt) download the + /// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name. + fn services() -> Services { + Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), ..Services::default() } + } + pub fn start() { eframe::WebLogger::init(log::LevelFilter::Info).ok(); cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks { @@ -51,6 +60,8 @@ mod web { write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()), plot: Some(|d, space, opts| cadcraft_io::plot(d, space, opts).map_err(|e| e.to_string())), }); + // No file system here: saved files reach the user as browser downloads. + cadcraft_engine::cmd::file::set_deliver(crate::download::download); wasm_bindgen_futures::spawn_local(async { let Some(document) = web_sys::window().and_then(|w| w.document()) else { return }; let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::().ok()) else { @@ -68,7 +79,7 @@ mod web { canvas, options, Box::new(move |cc| { - let mut app = CadApp::new(Session::new(), Services::default()); + let mut app = CadApp::new(Session::new(), services()); app.system_languages = web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|v| v.as_string()).collect()).unwrap_or_default(); if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 025b0e14..f73e2579 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -1,5 +1,6 @@ //! File menu: new, close, switching drawings. Open/save of DWG/DXF go through the io layer, -//! registered by the host (desktop/CLI) via [`crate::cmd::file::set_io`]. +//! registered by the host (desktop/CLI) via [`crate::cmd::file::set_io`]. On the web, where there is +//! no file system, saved files reach the user through the host's [`crate::cmd::file::set_deliver`]. use std::sync::OnceLock; @@ -29,6 +30,21 @@ pub fn io() -> Option<&'static IoHooks> { IO.get() } +/// Hand finished file bytes to the user where there is no file system (the browser: a download). +/// Arguments: file name, MIME type, bytes. `Ok` only once the delivery was actually started. +pub type DeliverHook = fn(&str, &str, &[u8]) -> std::result::Result<(), String>; + +static DELIVER: OnceLock = OnceLock::new(); + +/// Install the host's file delivery (the web app: a browser download). Saving uses it on wasm; +/// anything else that produces a file on the web (plots) can use [`deliver`] too. +pub fn set_deliver(h: DeliverHook) { + let _ = DELIVER.set(h); +} +pub fn deliver() -> Option { + DELIVER.get().copied() +} + pub fn specs() -> Vec { vec![ CommandSpec::new("new", "New Drawing...", run_new) @@ -119,12 +135,34 @@ fn run_closeall(s: &mut Session, _p: &Value) -> Result { pub(crate) fn save_to(s: &mut Session, path: &str) -> Result { let hooks = io().ok_or_else(|| bad("save", "file formats are not available in this build"))?; let bytes = (hooks.write)(s.doc()?, path).map_err(|e| bad("save", e))?; + commit_save(s, path, &bytes, store) +} + +/// Put saved bytes where the user gets them: natively the file at `path` (written atomically); on +/// the web, which has no file system, a browser download named after `path` via [`deliver`]. +fn store(path: &str, bytes: &[u8]) -> Result<()> { #[cfg(not(target_arch = "wasm32"))] { let tmp = format!("{path}.cadcraft-tmp"); - std::fs::write(&tmp, &bytes).map_err(|e| bad("save", format!("{path}: {e}")))?; + std::fs::write(&tmp, bytes).map_err(|e| bad("save", format!("{path}: {e}")))?; std::fs::rename(&tmp, path).map_err(|e| bad("save", format!("{path}: {e}")))?; + Ok(()) } + #[cfg(target_arch = "wasm32")] + { + let name = file_name(path); + let deliver = deliver().ok_or_else(|| bad("save", "saving files is not available in this build; use document.bytes"))?; + deliver(&name, "application/octet-stream", bytes).map_err(|e| bad("save", format!("{name}: {e}"))) + } +} + +/// Store the bytes, then mark the drawing saved under `path`. If storing fails the drawing keeps +/// its unsaved changes, file name and title. On the web a started download counts as saved: the +/// browser owns the file from there (we can't see whether the user keeps it), and the name stays so +/// a later QSAVE downloads the drawing again under it. +fn commit_save(s: &mut Session, path: &str, bytes: &[u8], store: impl FnOnce(&str, &[u8]) -> Result<()>) -> Result { + s.state()?; + store(path, bytes)?; let st = s.state_mut()?; st.saved = st.doc.clone(); st.path = Some(path.to_string()); @@ -132,19 +170,28 @@ pub(crate) fn save_to(s: &mut Session, path: &str) -> Result { Ok(bytes.len()) } +/// The save result; on the web it also names the file the browser downloaded. +fn saved(path: &str, bytes: usize) -> Value { + let mut r = json!({ "path": path, "bytes": bytes }); + if cfg!(target_arch = "wasm32") { + r["download"] = json!(file_name(path)); + } + r +} + fn run_save(s: &mut Session, p: &Value) -> Result { let path = match str_param(p, "path") { Some(p) => p.to_string(), None => s.state()?.path.clone().ok_or_else(|| bad("qsave", "drawing has no file name yet; use saveas {path}"))?, }; let n = save_to(s, &path)?; - Ok(json!({ "path": path, "bytes": n })) + Ok(saved(&path, n)) } fn run_saveas(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("saveas", "`path` is required"))?.to_string(); let n = save_to(s, &path)?; - Ok(json!({ "path": path, "bytes": n })) + Ok(saved(&path, n)) } fn run_switch(s: &mut Session, p: &Value) -> Result { @@ -300,3 +347,37 @@ mod tests { assert_eq!(active_title(&s), "Drawing2"); } } + +#[cfg(test)] +mod pr193_tests { + use super::*; + + #[test] + fn failed_store_keeps_unsaved_changes_and_delivered_save_clears_them() { + let mut s = Session::new(); + s.cmdline("circle 4,4 1").unwrap(); + let st = s.state().unwrap(); + let title = st.title.clone(); + assert!(st.is_dirty() && st.path.is_none()); + + // The file route is unavailable or fails (the web without a download): no success, still dirty. + let failed = commit_save(&mut s, "a/Owned.dxf", b"0\nEOF\n", |_, _| Err(bad("save", "no download"))); + assert!(failed.is_err()); + let st = s.state().unwrap(); + assert!(st.is_dirty(), "a save that delivered nothing must keep the unsaved state"); + assert_eq!((st.path.as_deref(), st.title.as_str()), (None, title.as_str())); + + // The file route took the bytes (written, or a download started): saved under that name. + let mut got = None; + let n = commit_save(&mut s, "a/Owned.dxf", b"0\nEOF\n", |p, b| { + got = Some((p.to_string(), b.len())); + Ok(()) + }) + .unwrap(); + assert_eq!((n, got), (6, Some(("a/Owned.dxf".to_string(), 6)))); + let st = s.state().unwrap(); + assert!(!st.is_dirty()); + assert_eq!((st.path.as_deref(), st.title.as_str()), (Some("a/Owned.dxf"), "Owned.dxf")); + assert_eq!(saved("a/Owned.dxf", n)["bytes"], 6); + } +} From 7f84f9c87433a8c38e0ac6d20bc794fd4b233491 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:40:13 +0200 Subject: [PATCH 053/150] Linetype rename keeps layer, object and current-linetype references (#74) (#195) --- crates/engine/src/cmd/annotate_tests.rs | 31 ++++++++++++++++++++++ crates/engine/src/cmd/props.rs | 35 ++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/annotate_tests.rs b/crates/engine/src/cmd/annotate_tests.rs index 48dd9707..42481198 100644 --- a/crates/engine/src/cmd/annotate_tests.rs +++ b/crates/engine/src/cmd/annotate_tests.rs @@ -477,3 +477,34 @@ fn setvar_dim_variables_apply_to_new_dimensions() { let zero = linear(&mut s, -120.0); assert!((text_height(&s, zero) - base).abs() < 1e-12); } + +#[test] +fn linetype_rename_updates_references() { + fn line(s: &mut Session, y: f64) -> Handle { + let r = s.execute("line", &json!({ "points": [[0.0, y], [10.0, y]] })).unwrap(); + Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap() + } + let mut s = Session::new(); + s.execute("linetype", &json!({ "load": "DASHED" })).unwrap(); + s.execute("layer.new", &json!({ "name": "A", "linetype": "DASHED", "current": true })).unwrap(); + let by_layer = line(&mut s, 0.0); + s.execute("linetype", &json!({ "current": "DASHED" })).unwrap(); + let explicit = line(&mut s, 2.0); + let in_block = line(&mut s, 4.0); + s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [in_block.hex()], "keep": "delete" })).unwrap(); + s.execute("rename", &json!({ "table": "linetype", "from": "DASHED", "to": "MYDASH" })).unwrap(); + let d = s.doc().unwrap(); + assert!(d.linetype("DASHED").is_none()); + assert!(d.linetype("MYDASH").is_some()); + assert_eq!(d.layers.iter().find(|l| l.name == "A").unwrap().linetype, "MYDASH"); + assert_eq!(d.layers.iter().find(|l| l.name == "0").unwrap().linetype, "Continuous"); + assert_eq!(d.entity(by_layer).unwrap().common.linetype, "ByLayer"); + assert_eq!(d.entity(explicit).unwrap().common.linetype, "MYDASH"); + let block: Vec<&str> = d.blocks["B"].entities.iter().map(|e| e.common.linetype.as_str()).collect(); + assert_eq!(block, ["MYDASH"]); + assert_eq!(d.header.str("CELTYPE", ""), "MYDASH"); + // An existing target name and the reserved linetypes are refused. + assert!(s.execute("rename", &json!({ "table": "linetype", "from": "MYDASH", "to": "Continuous" })).is_err()); + assert!(s.execute("rename", &json!({ "table": "linetype", "from": "ByLayer", "to": "X" })).is_err()); + assert_eq!(s.doc().unwrap().entity(by_layer).unwrap().common.linetype, "ByLayer"); +} diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index ad81882f..c211351c 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -869,8 +869,41 @@ fn run_rename(s: &mut Session, p: &Value) -> Result { } "linetype" => { let d = s.doc_mut()?; + if ["ByBlock", "ByLayer", "Continuous"].iter().any(|n| from.eq_ignore_ascii_case(n)) { + return Err(bad("rename", format!("the {from} linetype cannot be renamed"))); + } + if d.linetype(&to).is_some() && !to.eq_ignore_ascii_case(&from) { + return Err(bad("rename", format!("a linetype `{to}` already exists"))); + } let st = d.linetypes.iter_mut().find(|t| t.name.eq_ignore_ascii_case(&from)).ok_or_else(|| bad("rename", "no such linetype"))?; - st.name = to; + st.name = to.clone(); + // References follow the new name. + let fix = |n: &mut String| { + if n.eq_ignore_ascii_case(&from) { + *n = to.clone(); + } + }; + d.layers.iter_mut().for_each(|l| fix(&mut l.linetype)); + if d.header.str("CELTYPE", "ByLayer").eq_ignore_ascii_case(&from) { + d.header.set_str("CELTYPE", &to); + } + let users = |st: &cadcraft_doc::EntityStore| -> Vec { + st.iter().filter(|e| e.common.linetype.eq_ignore_ascii_case(&from)).map(|e| e.handle).collect() + }; + let mut hs = users(&d.model); + hs.extend(d.layouts.iter().flat_map(|l| users(&l.entities))); + for h in hs { + d.modify_entity(h, |e| fix(&mut e.common.linetype))?; + } + for block in d.blocks.values_mut() { + let hs = users(&block.entities); + if !hs.is_empty() { + let b = std::sync::Arc::make_mut(block); + for h in hs { + b.entities.modify(h, |e| fix(&mut e.common.linetype)); + } + } + } } _ => return Err(bad("rename", "unsupported table")), } From 27d6bf2548673ebac7e271bead84dd55c7b75d53 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 20:40:28 +0200 Subject: [PATCH 054/150] DWG: keep true colours, transparency, table styles, layouts and viewport ids (fixes #223) (#225) DWG saves convert our R2000 DXF with acadrust, which wrote an R2000 DWG: no true colours, no entity transparency, no table styles, and its R2000 reader puts every layout's paper-space entities into the first layout. The bridge now writes at least AutoCAD 2004 (AC1018) DWG; the DXF itself stays R2000. acadrust goes to 0.6.3 (explicit transparency encoding). DWG doesn't store viewport ids: readers number a layout's viewports in order, and id 1 is the paper-space viewport (the sheet). CADCraft keeps that one implicit, so its first viewport came back as id 1 and wasn't drawn. The DXF writer now puts a paper-space viewport (id 1, marked with CADCraft xdata) ahead of a layout's viewports when it has none, and the reader drops it again. Viewports also get status 68 and the always-set bit 32768 in group 90, so other readers don't take them as "off". Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 4 +- crates/dwg/Cargo.toml | 2 +- crates/dwg/src/lib.rs | 14 ++- crates/io/src/dxf_ext.rs | 9 ++ crates/io/src/dxf_read.rs | 4 + crates/io/src/dxf_write.rs | 62 ++++++++++- crates/io/tests/dwg_roundtrip_losses.rs | 139 ++++++++++++++++++++++++ 7 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 crates/io/tests/dwg_roundtrip_losses.rs diff --git a/Cargo.lock b/Cargo.lock index 4337ad05..b945ae3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "acadrust" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1db2bd3da02ed787f3f5c8afba8779dd104b4459b78bb1431b1a0211cc3719f0" +checksum = "7df99ae4f5a6d0147907f9976fe1dd166d01e72ab1a93820b50242937c5797e6" dependencies = [ "anyhow", "bitflags 2.13.2", diff --git a/crates/dwg/Cargo.toml b/crates/dwg/Cargo.toml index 0f1d6aae..bf2ad34d 100644 --- a/crates/dwg/Cargo.toml +++ b/crates/dwg/Cargo.toml @@ -10,4 +10,4 @@ workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE). -acadrust = "0.6.1" +acadrust = "0.6.3" diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index 50d6bd42..d1902774 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -104,12 +104,22 @@ mod native { } } - /// Convert DXF bytes into a DWG file. + /// Oldest DWG version written; older DXF input is written as this version. + const MIN_DWG_VERSION: acadrust::DxfVersion = acadrust::DxfVersion::AC1018; + + /// Convert DXF bytes into a DWG file of the DXF's version, and at least AutoCAD 2004 + /// (AC1018). pub fn dxf_to_dwg(dxf: &[u8]) -> Result, String> { let data = dxf.to_vec(); - let doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read())) + let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read())) .map_err(|_| "the DXF→DWG conversion failed".to_string())? .map_err(|e| format!("DXF: {e}"))?; + // Write at least AutoCAD 2004 (AC1018) DWG: R2000 DWG has no true colours, transparency, + // table styles or gradients, and readers can't tell its layouts apart reliably. The DXF + // stays R2000; only the DWG is newer. + if doc.version < MIN_DWG_VERSION { + doc.version = MIN_DWG_VERSION; + } acadrust::DwgWriter::write_to_vec(&doc).map_err(|e| format!("DWG write: {e}")) } } diff --git a/crates/io/src/dxf_ext.rs b/crates/io/src/dxf_ext.rs index e973a01a..ac603a46 100644 --- a/crates/io/src/dxf_ext.rs +++ b/crates/io/src/dxf_ext.rs @@ -306,6 +306,15 @@ pub(crate) fn read_dstyle(tags: &[Tag], styles: &HashMap, blocks out } +/// Xdata marker of the paper-space viewport (id 1) the writer adds to layouts that have none; +/// CADCraft keeps that viewport implicit, so the reader drops it. +pub(crate) const PAPER_VIEW: &str = "PAPERVIEW"; + +/// True for a VIEWPORT record carrying the [`PAPER_VIEW`] marker. +pub(crate) fn is_paper_view(tags: &[Tag]) -> bool { + xdata(tags, APP).iter().any(|t| t.code == 1000 && t.str().trim().eq_ignore_ascii_case(PAPER_VIEW)) +} + /// Snap kinds in CADCraft's ASSOC xdata. fn snap_code(s: &AssocSnap) -> i64 { match s { diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 9d1b38cc..16c0a18c 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -122,6 +122,10 @@ fn text_from(t: &T) -> Text { /// Parse one entity record. POLYLINE/INSERT followers are handled by the caller. fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { + // The paper-space viewport our writer adds (dxf_write::paper_view) stays implicit. + if kind == "VIEWPORT" && crate::dxf_ext::is_paper_view(tags) { + return None; + } let t = T(tags); let c = common(&t); let k = match kind { diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 6a426c33..1ba51ba6 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -92,6 +92,9 @@ struct OsnapRef { /// Handles and names the entity writer needs from the rest of the file. #[derive(Default)] struct Ctx { + /// First viewport of each layout that has no paper-space viewport (id 1) → the layout's index; + /// [`paper_view`] writes that layout's paper-space viewport ahead of it. + paper_views: HashMap, /// Dimension → anonymous `*D` block name. dim_blocks: HashMap, /// Upper-case text style name → STYLE record handle. @@ -808,16 +811,19 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx hatch_xdata(w, h); } EntityKind::Viewport(v) => { + paper_view(w, d, e, owner, cx); w.s(0, "VIEWPORT"); common(w, e, owner, paper, "AcDbViewport"); w.p(10, v.center); w.f(40, v.width); w.f(41, v.height); + // Status 68: a positive value is "on" (the stacking order; 1 is the active viewport). + w.i(68, i64::from(v.id.clamp(1, 32767))); w.i(69, i64::from(v.id)); w.p2(12, v.view_center); w.f(45, v.view_height); - // Status flags: 16384 = display locked. - w.i(90, if v.locked { 16384 } else { 0 }); + // Status flags: 16384 = display locked, 32768 = always set (readers take it as "on"). + w.i(90, 32768 | if v.locked { 16384 } else { 0 }); // One CADCraft xdata group holds both lists: readers stop at the first group of an app. if !v.frozen_layers.is_empty() || !v.layer_colors.is_empty() { w.s(1001, dxf_ext::APP); @@ -1091,6 +1097,56 @@ fn std_assoc_refs(d: &Drawing, dm: &Dimension) -> (i64, Vec) { (flags, refs) } +/// Layouts with viewports but without the paper-space viewport (id 1, the sheet itself), keyed +/// by their first viewport. AutoCAD and DWG take a layout's first viewport as its paper-space +/// viewport (DWG does not store viewport ids; readers number them in order), so a layout made in +/// CADCraft, whose paper-space viewport is implicit, gets one written ahead of its viewports. +fn paper_views(d: &Drawing) -> HashMap { + let mut out = HashMap::new(); + 'layouts: for (i, l) in d.layouts.iter().enumerate() { + let mut first = None; + for e in l.entities.iter() { + if let EntityKind::Viewport(v) = &e.kind { + if v.id == 1 { + continue 'layouts; + } + first.get_or_insert(e.handle); + } + } + if let Some(h) = first { + out.insert(h, i); + } + } + out +} + +/// The paper-space viewport (id 1) of the layout whose first viewport is `e`, if it needs one +/// ([`paper_views`]): the whole sheet, viewed 1:1. Marked with CADCraft xdata so that our reader +/// leaves it implicit again. +fn paper_view(w: &mut W, d: &Drawing, e: &Entity, owner: &str, cx: &Ctx) { + let Some(l) = cx.paper_views.get(&e.handle).and_then(|i| d.layouts.get(*i)) else { return }; + let size = cadcraft_render::paper::sheet(d, &l.name).map(|s| s.size).unwrap_or(Vec2::new(12.0, 9.0)); + let center = size * 0.5; + let h = w.h(); + w.s(0, "VIEWPORT"); + w.s(5, h); + w.s(330, owner); + w.s(100, "AcDbEntity"); + w.i(67, 1); + w.s(8, "0"); + w.s(100, "AcDbViewport"); + w.p(10, center.to3(0.0)); + w.f(40, size.x); + w.f(41, size.y); + w.i(68, 1); + w.i(69, 1); + w.p2(12, center); + w.f(45, size.y); + w.i(90, 32768); + w.s(1001, dxf_ext::APP); + w.s(1000, dxf_ext::PAPER_VIEW); +} + fn table_head(w: &mut W, name: &str, count: usize) -> String { let h = w.h(); w.s(0, "TABLE"); @@ -1129,7 +1185,7 @@ pub fn write(d: &Drawing) -> String { let mut user_blocks: Vec<(String, String, &Block)> = d.blocks.values().map(|b| (b.name.clone(), String::new(), b.as_ref())).collect(); user_blocks.iter_mut().for_each(|b| b.1 = format!("{:X}", 0)); let user_blocks: Vec<(String, String, &Block)> = user_blocks.into_iter().map(|(n, _, b)| (n, w.h(), b)).collect(); - let mut cx = Ctx::default(); + let mut cx = Ctx { paper_views: paper_views(d), ..Ctx::default() }; // Dimension blocks. let mut dim_defs: Vec<(String, String, Vec)> = Vec::new(); // filled below let mut n = 1; diff --git a/crates/io/tests/dwg_roundtrip_losses.rs b/crates/io/tests/dwg_roundtrip_losses.rs new file mode 100644 index 00000000..a5112cfd --- /dev/null +++ b/crates/io/tests/dwg_roundtrip_losses.rs @@ -0,0 +1,139 @@ +//! Data that used to be lost only when a drawing is saved as DWG and reopened (the DWG bridge +//! converts our DXF with acadrust), and the paper-space viewport the DXF writer adds for it. +#![cfg(not(target_arch = "wasm32"))] + +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::*; +use cadcraft_geom::{Vec2, Vec3}; + +fn through_dwg(d: &Drawing) -> Drawing { + let dwg = cadcraft_io::write(d, "x.dwg").unwrap(); + cadcraft_io::read(&dwg, "x.dwg").unwrap() +} + +fn line(d: &mut Drawing, space: &Space, common: Common) -> Handle { + d.add(space, common, EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(10.0, 5.0, 0.0) })).unwrap() +} + +fn viewport(id: u32) -> EntityKind { + EntityKind::Viewport(Viewport { + center: Vec3::new(50.0, 40.0, 0.0), + width: 80.0, + height: 60.0, + view_center: Vec2::new(10.0, 10.0), + view_height: 50.0, + id, + locked: false, + frozen_layers: vec![], + layer_colors: vec![], + }) +} + +/// The (code, value) pairs of every `kind` record in DXF text. +fn records(text: &str, kind: &str) -> Vec> { + let lines: Vec<&str> = text.lines().map(str::trim).collect(); + let mut out: Vec> = Vec::new(); + let mut inside = false; + for pair in lines.chunks(2) { + let [code, value] = pair else { break }; + if *code == "0" { + inside = *value == kind; + if inside { + out.push(Vec::new()); + } + } else if inside && let Some(rec) = out.last_mut() { + rec.push((code.to_string(), value.to_string())); + } + } + out +} + +fn group<'a>(rec: &'a [(String, String)], code: &str) -> Option<&'a str> { + rec.iter().find(|(c, _)| c == code).map(|(_, v)| v.as_str()) +} + +fn viewport_ids(l: &Layout) -> Vec { + l.entities.iter().filter_map(|e| if let EntityKind::Viewport(v) = &e.kind { Some(v.id) } else { None }).collect() +} + +/// True colours (entity and layer) and entity transparency: the DWG is written as AutoCAD 2004 +/// (R2000 DWG has neither), while the DXF stays R2000. +#[test] +fn dwg_keeps_true_colours_and_transparency() { + let mut d = Drawing::new_metric(); + d.layers.push(Layer { name: "Fancy".into(), color: Color::True(Rgb(12, 200, 99)), ..Layer::default() }); + let h = line( + &mut d, + &Space::Model, + Common { layer: "Fancy".into(), color: Color::True(Rgb(1, 2, 3)), transparency: Transparency::Percent(40), ..Common::default() }, + ); + + let dwg = cadcraft_io::write(&d, "x.dwg").unwrap(); + assert_eq!(cadcraft_dwg::version(&dwg).as_deref(), Some("AC1018")); + let header = records(&cadcraft_io::write_dxf(&d), "SECTION"); + assert_eq!(header.first().and_then(|h| group(h, "1")), Some("AC1015"), "the DXF stays R2000"); + + let back = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + let e = back.entity(h).unwrap(); + assert_eq!(e.common.color, Color::True(Rgb(1, 2, 3))); + assert_eq!(e.common.transparency, Transparency::Percent(40)); + assert_eq!(back.layers.iter().find(|l| l.name == "Fancy").map(|l| l.color), Some(Color::True(Rgb(12, 200, 99)))); +} + +/// Entities of a layout other than the first stay in that layout. +#[test] +fn dwg_keeps_paper_space_entities_in_their_layout() { + let mut d = Drawing::new_metric(); + let second = Space::Paper(d.layouts[1].name.clone()); + let h = d.add(&second, Common::default(), EntityKind::Circle(Circle { center: Vec3::new(1.0, 1.0, 0.0), radius: 0.5 })).unwrap(); + let back = through_dwg(&d); + assert!(back.layouts[1].entities.contains(h), "{:?}", back.layouts.iter().map(|l| l.entities.len()).collect::>()); + assert!(!back.layouts[0].entities.contains(h)); +} + +/// Table styles (R2000 DWG has no TABLESTYLE objects). +#[test] +fn dwg_keeps_table_styles() { + let mut d = Drawing::new_metric(); + let sched = TableStyle { name: "Sched".into(), text_height: 2.5, margin: 1.0, title: false, header: true }; + d.table_styles.push(sched.clone()); + let back = through_dwg(&d); + assert_eq!(back.table_styles.iter().find(|s| s.name == "Sched"), Some(&sched)); +} + +/// DWG doesn't store viewport ids; readers number a layout's viewports in order, and id 1 is the +/// paper-space viewport (the sheet, not drawn). The writer puts that viewport ahead of a layout's +/// own viewports, so a viewport made in CADCraft (id 2) keeps its id instead of becoming the +/// sheet; our reader drops the added viewport again, so a DXF round trip is unchanged. +#[test] +fn viewport_ids_survive_dwg_and_the_paper_viewport_stays_implicit() { + let mut d = Drawing::new_metric(); + let first = Space::Paper(d.layouts[0].name.clone()); + let h = d.add(&first, Common::default(), viewport(2)).unwrap(); + d.add(&first, Common::default(), viewport(3)).unwrap(); + + let text = cadcraft_io::write_dxf(&d); + let vps = records(&text, "VIEWPORT"); + assert_eq!(vps.len(), 3, "{text}"); + assert_eq!(group(&vps[0], "69"), Some("1")); + assert_eq!(group(&vps[0], "1000"), Some("PAPERVIEW")); + // Viewports are written "on": status 68 positive, and 90 with the always-set bit 32768. + assert_eq!(group(&vps[1], "68"), Some("2")); + assert_eq!(group(&vps[1], "90"), Some("32768")); + + let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap(); + assert_eq!(viewport_ids(&back.layouts[0]), [2, 3]); + assert_eq!(back.layouts[0].entities.len(), 2); + + let back = through_dwg(&d); + assert_eq!(viewport_ids(&back.layouts[0]), [2, 3]); + assert!(back.layouts[0].entities.contains(h)); + + // A layout that has its paper-space viewport (from another program) gets no second one. + let mut d = Drawing::new_metric(); + d.add(&first, Common::default(), viewport(1)).unwrap(); + d.add(&first, Common::default(), viewport(2)).unwrap(); + let text = cadcraft_io::write_dxf(&d); + assert!(!text.contains("PAPERVIEW")); + assert_eq!(viewport_ids(&cadcraft_io::read_dxf(text.as_bytes()).unwrap().layouts[0]), [1, 2]); +} From 06f742c78746cbfae45dbb51d7e4fe466aa943b4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 14:35:57 -0400 Subject: [PATCH 055/150] Clippy (as_chunks) and rustfmt after the community PR batch Co-Authored-By: Claude Opus 5.5 --- crates/geom/src/curve.rs | 7 +------ crates/geom/src/tests.rs | 32 ++++---------------------------- crates/io/src/dxf_ext.rs | 2 +- 3 files changed, 6 insertions(+), 35 deletions(-) diff --git a/crates/geom/src/curve.rs b/crates/geom/src/curve.rs index 89984cea..fdd2cf55 100644 --- a/crates/geom/src/curve.rs +++ b/crates/geom/src/curve.rs @@ -261,12 +261,7 @@ impl Ellipse { // candidate parameters (opposite sides of the ellipse). let minor = self.minor(); let mut bounds = Bounds2::from_points([self.at_param(self.start), self.at_param(self.end)]); - for angle in [ - minor.x.atan2(self.major.x), - minor.x.atan2(self.major.x) + PI, - minor.y.atan2(self.major.y), - minor.y.atan2(self.major.y) + PI, - ] { + for angle in [minor.x.atan2(self.major.x), minor.x.atan2(self.major.x) + PI, minor.y.atan2(self.major.y), minor.y.atan2(self.major.y) + PI] { if angle_in_sweep(angle, self.start, self.end) { bounds.add(self.at_param(angle)); } diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 974647b5..52ae7539 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -187,12 +187,7 @@ fn circle_three_points_preserves_large_world_coordinates() { // A unit circle near a survey coordinate. The absolute-square formula suffers // catastrophic cancellation and returns a center tens of millions of units away. let origin = 1.0e12; - let c = Circle::from_3_points( - Vec2::new(origin + 1.0, origin), - Vec2::new(origin, origin + 1.0), - Vec2::new(origin - 1.0, origin), - ) - .unwrap(); + let c = Circle::from_3_points(Vec2::new(origin + 1.0, origin), Vec2::new(origin, origin + 1.0), Vec2::new(origin - 1.0, origin)).unwrap(); assert!(c.center.near(Vec2::new(origin, origin), 1e-3), "{c:?}"); assert!((c.radius - 1.0).abs() < 1e-3, "{c:?}"); } @@ -223,13 +218,7 @@ fn ellipse_bounds_find_analytic_extrema() { #[test] fn elliptical_arc_bounds_exclude_extrema_outside_the_sweep() { - let e = Ellipse { - center: Vec2::ZERO, - major: Vec2::new(3.0, 4.0), - ratio: 0.5, - start: 0.0, - end: PI / 4.0, - }; + let e = Ellipse { center: Vec2::ZERO, major: Vec2::new(3.0, 4.0), ratio: 0.5, start: 0.0, end: PI / 4.0 }; let bounds = e.bounds(); assert!((bounds.max.x - 3.0).abs() < 1e-12); assert!((bounds.min.x - e.at_param(e.end).x).abs() < 1e-12); @@ -250,15 +239,7 @@ fn polyline_area_is_exact_for_bulge_segments() { #[test] fn polyline_area_is_stable_far_from_the_origin() { let o = 1.0e12; - let p = Polyline::from_points( - &[ - Vec2::new(o, o), - Vec2::new(o + 10.0, o), - Vec2::new(o + 10.0, o + 10.0), - Vec2::new(o, o + 10.0), - ], - true, - ); + let p = Polyline::from_points(&[Vec2::new(o, o), Vec2::new(o + 10.0, o), Vec2::new(o + 10.0, o + 10.0), Vec2::new(o, o + 10.0)], true); assert!((p.area() - 100.0).abs() < 1e-12, "{}", p.area()); // An open polyline's measured area retains the implicit straight closing edge. let mut open = p; @@ -269,12 +250,7 @@ fn polyline_area_is_stable_far_from_the_origin() { #[test] fn shoelace_preserves_small_polygon_area_at_large_coordinates() { let o = 1.0e12; - let corners = [ - Vec2::new(o, o), - Vec2::new(o + 10.0, o), - Vec2::new(o + 10.0, o + 10.0), - Vec2::new(o, o + 10.0), - ]; + let corners = [Vec2::new(o, o), Vec2::new(o + 10.0, o), Vec2::new(o + 10.0, o + 10.0), Vec2::new(o, o + 10.0)]; assert_eq!(shoelace(&corners), 100.0); assert_eq!(shoelace(&corners.iter().rev().copied().collect::>()), -100.0); } diff --git a/crates/io/src/dxf_ext.rs b/crates/io/src/dxf_ext.rs index ac603a46..054dc325 100644 --- a/crates/io/src/dxf_ext.rs +++ b/crates/io/src/dxf_ext.rs @@ -393,7 +393,7 @@ pub(crate) fn layer_colors_xdata(colors: &[(String, Color)]) -> Vec { pub(crate) fn read_layer_colors(tags: &[Tag]) -> Vec<(String, Color)> { let list = xdata_list(xdata(tags, APP), "VPCOLORS"); let names: Vec = list.iter().take(MAX_XDATA_ITEMS * 2).filter(|t| t.code == 1000).map(Tag::str).collect(); - names.chunks_exact(2).filter_map(|p| Some((p.first()?.clone(), Color::parse(p.get(1)?)?))).collect() + names.as_chunks::<2>().0.iter().filter_map(|[name, color]| Some((name.clone(), Color::parse(color)?))).collect() } const POINT_NAMES: [&str; 5] = ["defpt", "p13", "p14", "p15", "p16"]; From 17a41184ca728b7bbcd6890e1b73301c677804f6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 15:02:46 -0400 Subject: [PATCH 056/150] Fix two tests after the community PR batch - entity_props_roundtrip: look for the Standard text style inside the STYLE table; #216 now also writes Standard multileader and table styles. - uk.tsv: translate the Accessibility menu and Show System Cursor (#187). Co-Authored-By: Claude Opus 5.5 --- crates/io/tests/entity_props_roundtrip.rs | 4 +++- crates/ui-egui/src/i18n/uk.tsv | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/io/tests/entity_props_roundtrip.rs b/crates/io/tests/entity_props_roundtrip.rs index 05617068..98108b34 100644 --- a/crates/io/tests/entity_props_roundtrip.rs +++ b/crates/io/tests/entity_props_roundtrip.rs @@ -159,7 +159,9 @@ fn builtin_font_name_roundtrips_and_other_readers_get_txt() { d.text_styles.push(TextStyle { name: "Notes".into(), font: "romans.shx".into(), ..TextStyle::default() }); let text = write_dxf(&d); let g = groups(&text); - let standard = g.iter().position(|(c, v)| *c == 2 && v == "Standard").expect("Standard style"); + // The STYLE table's "Standard" entry (other tables have a "Standard" too). + let table = g.iter().position(|(c, v)| *c == 2 && v == "STYLE").expect("STYLE table"); + let standard = table + g.iter().skip(table).position(|(c, v)| *c == 2 && v == "Standard").expect("Standard style"); let file = g.iter().skip(standard).find(|(c, _)| *c == 3).map(|(_, v)| v.as_str()); assert_eq!(file, Some("txt"), "font file group stays a file other readers have"); let r = read_dxf(text.as_bytes()).expect("reopen"); diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d8a3e613..0775093e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -940,3 +940,5 @@ Services Служби Hide CADCraft Приховати CADCraft Hide Others Приховати інші + Accessibility Спеціальні можливості + Show System Cursor Показувати системний курсор From 160b0fd644f9c4104aaa8671f80d06723002386a Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 21:33:25 +0200 Subject: [PATCH 057/150] Draw raster points as a filled path so debug builds don't panic (#271) tiny-skia's anti-aliased fill_rect hits a debug_assert when a rect under two pixels wide has both sides mid-pixel, so PNG export panicked in debug builds depending on where a point landed. Fill the same 1.5 px square through the path filler instead. Fixes #270. Co-authored-by: Claude Opus 5.5 --- crates/render/src/raster.rs | 4 ++- crates/render/tests/raster_points.rs | 48 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 crates/render/tests/raster_points.rs diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 7c428faa..c04905c9 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -109,8 +109,10 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option { if let Some(q) = pts.first() { let (x, y) = view.to_screen(*q); + // Filled as a path: tiny-skia's anti-aliased `fill_rect` trips a debug + // assertion on rects under two pixels wide whose sides both fall mid-pixel. if let Some(r) = tiny_skia::Rect::from_xywh(x - 0.5, y - 0.5, 1.5, 1.5) { - pm.fill_rect(r, &paint, Transform::identity(), None); + pm.fill_path(&PathBuilder::from_rect(r), &paint, FillRule::Winding, Transform::identity(), None); } } } diff --git a/crates/render/tests/raster_points.rs b/crates/render/tests/raster_points.rs new file mode 100644 index 00000000..97c52be7 --- /dev/null +++ b/crates/render/tests/raster_points.rs @@ -0,0 +1,48 @@ +//! PNG rasterising of POINT markers must never panic, wherever the point lands on the pixel grid +//! (issue #270). + +use cadcraft_doc::{Common, Drawing, EntityKind, Line, Point, Space}; +use cadcraft_geom::{Vec2, Vec3}; +use cadcraft_render::raster::{RasterOptions, View, render}; +use cadcraft_render::{Options, build}; + +fn add_point(d: &mut Drawing, x: f64, y: f64) { + d.add(&Space::Model, Common::default(), EntityKind::Point(Point { p: Vec3::new(x, y, 0.0), angle: 0.0 })).unwrap(); +} + +/// Points at every 1/16-pixel offset (and on the image border) used to trip a tiny-skia +/// `debug_assert!` in its anti-aliased rectangle filler (debug builds only). +#[test] +fn points_at_subpixel_positions_do_not_panic() { + let mut d = Drawing::new_imperial(); + for i in 0..16 { + for j in 0..16 { + add_point(&mut d, 10.0 + f64::from(i) * (1.0 + 1.0 / 16.0), 10.0 + f64::from(j) * (1.0 + 1.0 / 16.0)); + } + } + for (x, y) in [(0.0, 0.0), (0.3, 0.7), (-0.6, 5.0), (99.6, 99.6), (100.0, 100.0), (50.0, -0.4)] { + add_point(&mut d, x, y); + } + let list = build(&d, &Space::Model, &Options::default()); + let view = View { center: Vec2::new(50.0, 50.0), scale: 1.0, width: 100, height: 100 }; + for antialias in [true, false] { + let pm = render(&list, &view, &RasterOptions { antialias, ..Default::default() }).unwrap(); + // The marker at (10, 10) is drawn in the foreground colour. + let px = pm.pixel(10, 90).unwrap(); + assert!(px.red() > 128 && px.green() > 128, "{px:?}"); + } +} + +/// The reported case: a POINT at (3,0) in a drawing exported to a 2400 x 1600 PNG. +#[test] +fn point_in_large_png_export_does_not_panic() { + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(10.0, 7.0, 0.0) })).unwrap(); + add_point(&mut d, 3.0, 0.0); + let list = build(&d, &Space::Model, &Options::default()); + for (w, h) in [(2400, 1600), (1601, 999), (333, 77)] { + let view = View::fit(&list.bounds, w, h, 0.05); + let png = cadcraft_render::raster::render_png(&list, &view, &RasterOptions::default()).unwrap(); + assert!(png.starts_with(b"\x89PNG")); + } +} From 5eb7fd452a00bba1cfe211017cd84e76e893d5b1 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 21:33:31 +0200 Subject: [PATCH 058/150] Programmatic SPLINE limits the degree to 1..10 (fixes #268) (#269) spline {control, degree} took any degree up to the control-point count minus one; tessellation and extents grow with its square, so a few thousand control points with a high degree hung the app on every redraw. The Degree prompt, spline.cv and DXF input already limit it to 1..10. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/draw.rs | 4 +++- crates/engine/tests/spline_degree.rs | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/spline_degree.rs diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 99a7d231..885635bf 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -245,7 +245,9 @@ fn run_spline(s: &mut Session, p: &Value) -> Result { if c.len() < 2 { return Err(bad("spline", "need 2+ control points")); } - Spline::from_control(c, p.get("degree").and_then(Value::as_u64).unwrap_or(3) as usize) + // Degree 1..=10 as at the Degree prompt, `spline.cv` and DXF input (tessellation grows + // with its square). + Spline::from_control(c, p.get("degree").and_then(Value::as_u64).unwrap_or(3).clamp(1, 10) as usize) } else { return Err(bad("spline", "`fit` or `control` points are required")); }; diff --git a/crates/engine/tests/spline_degree.rs b/crates/engine/tests/spline_degree.rs new file mode 100644 index 00000000..a525d137 --- /dev/null +++ b/crates/engine/tests/spline_degree.rs @@ -0,0 +1,20 @@ +//! Programmatic SPLINE keeps the degree within 1..=10 like the Degree prompt (issue #268): a high +//! degree on many control points made tessellation and extents take minutes. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use serde_json::json; + +#[test] +fn programmatic_spline_degree_is_limited() { + let control: Vec<[f64; 2]> = (0..3000).map(|i| [f64::from(i), f64::from(i % 7)]).collect(); + for (degree, want) in [(json!(2_147_483_647), 10), (json!(2999), 10), (json!(11), 10), (json!(0), 1), (json!(4), 4)] { + let mut s = Session::new(); + s.execute("spline", &json!({ "control": control, "degree": degree })).unwrap(); + let d = s.doc().unwrap(); + let Some(EntityKind::Spline(sp)) = d.model.iter().last().map(|e| &e.kind) else { panic!("no spline") }; + assert_eq!(sp.degree, want); + let ext = d.extents(&s.space()); + assert!(ext.min.x <= 0.0 && ext.max.x >= 2999.0, "{ext:?}"); + } +} From 575e690cc51b2c132dfbfb5bc59ad090a146a01c Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 21:33:37 +0200 Subject: [PATCH 059/150] Bound block expansion in drawing and extents: nested, self-referencing or arrayed blocks no longer hang (fixes #255) (#267) * Bound block expansion in drawing and extents (fixes #255) MAX_BLOCK_DEPTH bounds only the depth of nested block references, so a small DXF with a self-referencing block, blocks that each insert the next several times, or one large MINSERT expanded to billions of copies and hung the app (drawing, PNG/SVG/PDF export, extents, saving). - render: a per-display-list budget (MAX_BLOCK_EXPANSION, 2 million block copies and entities drawn inside blocks); past it the remaining block contents are not drawn. - doc: entity_bounds computes each block's content bounds once per call and reuses them for every further reference. Co-Authored-By: Claude Opus 5.5 * Move Builder::expand below the drawing primitives Pure code motion so this branch merges cleanly with fix/272-aci7-only-flips (#272), which changes the Builder::polyline signature next to it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/doc/src/extents.rs | 27 ++++++++++--- crates/io/tests/block_expansion.rs | 65 ++++++++++++++++++++++++++++++ crates/render/src/lib.rs | 28 ++++++++++++- 3 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 crates/io/tests/block_expansion.rs diff --git a/crates/doc/src/extents.rs b/crates/doc/src/extents.rs index 41f25d0b..dc89e618 100644 --- a/crates/doc/src/extents.rs +++ b/crates/doc/src/extents.rs @@ -1,5 +1,7 @@ //! Approximate entity bounds (used for extents, zoom and selection pre-filtering). +use std::collections::HashMap; + use cadcraft_geom::{Arc, Bounds2, EPS, Polyline, Vec2, bulge_to_arc}; use crate::{Drawing, Entity, EntityKind, LwPolyline, Prim}; @@ -33,6 +35,14 @@ fn text_box(t: &crate::Text, len: usize) -> Bounds2 { } pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 { + bounds_in(d, e, depth, &mut HashMap::new()) +} + +/// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed, +/// by block name: every further reference to a block reuses them instead of expanding the block +/// again, so nested or self-referencing blocks cost one pass over each definition instead of one +/// per reference (which grows exponentially with the nesting depth). +fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap) -> Bounds2 { match &e.kind { EntityKind::Text(t) => text_box(t, t.value.chars().count()), EntityKind::AttDef(a) => text_box(&a.text, a.tag.chars().count()), @@ -62,10 +72,17 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 { } let Some(blk) = d.block(&ins.block) else { return Bounds2::from_points([ins.insert.xy()]) }; let m = ins.transform(blk.base.xy()); - let mut inner = Bounds2::EMPTY; - for be in blk.entities.iter() { - inner = inner.union(&entity_bounds(d, be, depth + 1)); - } + let inner = match blocks.get(&blk.name) { + Some(b) => *b, + None => { + let mut inner = Bounds2::EMPTY; + for be in blk.entities.iter() { + inner = inner.union(&bounds_in(d, be, depth + 1, blocks)); + } + blocks.insert(blk.name.clone(), inner); + inner + } + }; let mut b = if inner.is_empty() { Bounds2::from_points([ins.insert.xy()]) } else { Bounds2::from_points(inner.corners().map(|c| m.apply(c))) }; if ins.cols > 1 || ins.rows > 1 { @@ -106,7 +123,7 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 { && depth < MAX_BLOCK_DEPTH { for be in blk.entities.iter() { - b = b.union(&entity_bounds(d, be, depth + 1)); + b = b.union(&bounds_in(d, be, depth + 1, blocks)); } } b diff --git a/crates/io/tests/block_expansion.rs b/crates/io/tests/block_expansion.rs new file mode 100644 index 00000000..b190156f --- /dev/null +++ b/crates/io/tests/block_expansion.rs @@ -0,0 +1,65 @@ +//! Nested, self-referencing and arrayed block references in a hostile DXF must not hang drawing +//! or extents (issue #255): the expansion is bounded, not only its depth. + +use cadcraft_doc::{Drawing, EntityKind, Space}; +use cadcraft_render::{MAX_BLOCK_EXPANSION, Options, build}; + +fn read(text: &str) -> Drawing { + cadcraft_io::read(text.as_bytes(), "x.dxf").unwrap() +} + +/// Block `A` holds a line and `refs` references to itself; model space holds one `A`. +fn self_referencing(refs: usize) -> String { + let mut s = String::from("0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n2\nA\n0\nLINE\n10\n0\n20\n0\n11\n1\n21\n1\n"); + for k in 1..=refs { + s += &format!("0\nINSERT\n2\nA\n10\n{k}\n20\n0\n"); + } + s + "0\nENDBLK\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nINSERT\n2\nA\n10\n0\n20\n0\n0\nENDSEC\n0\nEOF\n" +} + +/// Blocks `F0`..`F14`, each holding four references to the next; `F15` holds a line (drawn +/// 4^15 times: its references sit exactly at `MAX_BLOCK_DEPTH`). +fn fan_out() -> String { + let mut s = String::from("0\nSECTION\n2\nBLOCKS\n"); + for k in 0..15 { + s += &format!("0\nBLOCK\n2\nF{k}\n"); + for x in 0..4 { + s += &format!("0\nINSERT\n2\nF{}\n10\n{x}\n20\n0\n", k + 1); + } + s += "0\nENDBLK\n"; + } + s + "0\nBLOCK\n2\nF15\n0\nLINE\n10\n0\n20\n0\n11\n1\n21\n1\n0\nENDBLK\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nINSERT\n2\nF0\n10\n0\n20\n0\n0\nENDSEC\n0\nEOF\n" +} + +/// One MINSERT of a one-line block with `cols` x `rows` copies 2 units apart. +fn minsert(cols: u32, rows: u32) -> String { + format!( + "0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n2\nA\n0\nLINE\n10\n0\n20\n0\n11\n1\n21\n1\n0\nENDBLK\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nINSERT\n2\nA\n10\n0\n20\n0\n70\n{cols}\n71\n{rows}\n44\n2\n45\n2\n0\nENDSEC\n0\nEOF\n" + ) +} + +#[test] +fn block_expansion_bombs_are_bounded() { + for text in [self_referencing(4), self_referencing(12), fan_out(), minsert(10_000, 10_000)] { + let d = read(&text); + let ext = d.extents(&Space::Model); + assert!(!ext.is_empty() && ext.min.is_finite() && ext.max.is_finite(), "{ext:?}"); + let list = build(&d, &Space::Model, &Options::default()); + assert!(!list.prims.is_empty() && list.prims.len() <= MAX_BLOCK_EXPANSION, "{}", list.prims.len()); + // Saving computes the extents again ($EXTMIN/$EXTMAX). + assert!(cadcraft_io::write_dxf(&d).contains("$EXTMAX")); + } +} + +#[test] +fn ordinary_nested_and_arrayed_blocks_are_drawn_whole() { + // 3 x 2 copies of a one-line block: six lines, extents spanning all copies. + let d = read(&minsert(3, 2)); + assert_eq!(build(&d, &Space::Model, &Options::default()).prims.len(), 6); + let ext = d.extents(&Space::Model); + assert_eq!((ext.min.x, ext.min.y, ext.max.x, ext.max.y), (0.0, 0.0, 5.0, 3.0)); + // A self-referencing block draws its line at every level down to MAX_BLOCK_DEPTH. + let d = read(&self_referencing(1)); + assert!(matches!(d.model.iter().next().map(|e| &e.kind), Some(EntityKind::Insert(_)))); + assert_eq!(build(&d, &Space::Model, &Options::default()).prims.len(), cadcraft_doc::MAX_BLOCK_DEPTH); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4f8f73ee..1b4db7b1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -61,6 +61,8 @@ pub struct DisplayList { /// The paper sheet when the list was built for a layout (paper space); the UI draws the /// white sheet, its shadow and the printable-area outline from it. pub sheet: Option, + /// Block contents expanded while building (see [`MAX_BLOCK_EXPANSION`]). + pub expanded: usize, } impl DisplayList { @@ -130,6 +132,12 @@ struct Builder<'a> { plotting: bool, } +/// Upper bound on the block contents drawn per display list (each block reference, MINSERT copy +/// and entity drawn inside a block counts one). Nested, self-referencing or arrayed block +/// references in a hostile file would otherwise expand exponentially (`MAX_BLOCK_DEPTH` only +/// bounds the depth); past the limit the remaining block contents are not drawn. +pub const MAX_BLOCK_EXPANSION: usize = 2_000_000; + impl Builder<'_> { fn polyline(&mut self, ctx: &Ctx, color: Rgb, lw: f32, pts: &[Vec2]) { if pts.len() < 2 { @@ -188,6 +196,11 @@ impl Builder<'_> { self.list.verts.push(ctx.xf.apply_vec(dir).normalized()); self.list.prims.push(DPrim { handle: ctx.top, color, lw: 0.0, kind: Kind::Infinite { ray }, start, len: 2 }); } + /// Take one unit of the block-expansion budget; false once it is spent. + fn expand(&mut self) -> bool { + self.list.expanded = self.list.expanded.saturating_add(1); + self.list.expanded <= MAX_BLOCK_EXPANSION + } } /// Build the display list for a space. For a layout (paper space) this includes the model-space @@ -249,6 +262,9 @@ fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewpor } // Border (on the viewport's layer; layer off hides only the border). entity(b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); + if b.list.expanded >= MAX_BLOCK_EXPANSION { + return; + } let center = vp.center.xy(); let ok = |v: f64| v.is_finite() && v > 0.0; if !(ok(vp.width) && ok(vp.height) && ok(vp.view_height) && center.is_finite() && vp.view_center.is_finite()) { @@ -277,6 +293,7 @@ fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewpor } entity(&mut sub, &top_ctx(d, xf, VIEWPORT_CONTENT, &vp.frozen_layers, &vp.layer_colors), me); } + b.list.expanded = b.list.expanded.saturating_add(sub.list.expanded); append_clipped(&mut b.list, &sub.list, &rect); } @@ -420,6 +437,9 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) { { let sub = sub_ctx(ctx, e, Mat3::IDENTITY); for be in blk.entities.iter() { + if !b.expand() { + break; + } entity(b, &sub, be); } } else { @@ -800,8 +820,11 @@ fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rg if let Some(blk) = ctx.d.block(&ins.block) { let cols = ins.cols.clamp(1, 10_000); let rows = ins.rows.clamp(1, 10_000); - for r in 0..rows { + 'copies: for r in 0..rows { for c in 0..cols { + if !b.expand() { + break 'copies; + } let off = Vec2::new(ins.col_spacing * f64::from(c), ins.row_spacing * f64::from(r)).rotate(ins.rotation); let m = Mat3::translate(off).then_before(ins.transform(blk.base.xy())); let sub = sub_ctx(ctx, e, m); @@ -810,6 +833,9 @@ fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rg if matches!(be.kind, EntityKind::AttDef(_)) { continue; } + if !b.expand() { + break 'copies; + } entity(b, &sub, be); } } From 4c056c54bd0586c5123125d76fa1e8a60db58864 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 21:33:43 +0200 Subject: [PATCH 060/150] Points draw with the point style (PDMODE/PDSIZE) (fixes #261) (#265) POINT objects were always a one-pixel dot. They now draw the figure PDMODE picks (nothing, plus, cross, tick, with an optional circle and/or square) at the size PDSIZE gives: drawing units when positive, a percentage of the view height when negative, 5 % when 0. The canvas passes its view height; exports and plots use the plotted window or the extents. Co-authored-by: Claude Opus 5.5 --- crates/io/src/pdf.rs | 6 ++- crates/render/src/lib.rs | 41 +++++++++++++++++- crates/render/src/point.rs | 58 +++++++++++++++++++++++++ crates/render/tests/point_style.rs | 69 ++++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 1 + 5 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 crates/render/src/point.rs create mode 100644 crates/render/tests/point_style.rs diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 3cb41848..75d638cb 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -130,7 +130,7 @@ pub fn pdf(d: &Drawing, space: &Space, o: &PdfOptions) -> Result> { Space::Paper(_) => page.lineweights, Space::Model => true, }); - let ropts = cadcraft_render::Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights }; + let ropts = cadcraft_render::Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights, view_height: 0.0 }; let k = unit_mm * PT_PER_MM; let window = o.window.filter(|w| matches!(space, Space::Model) && !w.is_empty()); let fit = window.is_some() || o.fit.unwrap_or(matches!(space, Space::Model)); @@ -138,7 +138,9 @@ pub fn pdf(d: &Drawing, space: &Space, o: &PdfOptions) -> Result> { let est = plot_scale(&window.unwrap_or_else(|| d.extents(space)), &sheet, fit, o.scale); let tol = 0.05 / unit_mm / est.max(1e-300); let tolerance = if tol.is_finite() && tol > 0.0 { tol } else { ropts.tolerance }; - let list = cadcraft_render::build_plot(d, space, &cadcraft_render::Options { tolerance, ..ropts }); + // Relative point sizes follow the plotted window (else the extents). + let view_height = window.map(|w| w.height()).unwrap_or(0.0); + let list = cadcraft_render::build_plot(d, space, &cadcraft_render::Options { tolerance, view_height, ..ropts }); let b = window.unwrap_or(list.bounds); let s = plot_scale(&b, &sheet, fit, o.scale); let map = if fit || matches!(space, Space::Model) { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1b4db7b1..33832533 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -13,6 +13,7 @@ mod fill; mod hatch; mod linetype; pub mod paper; +mod point; pub mod raster; pub mod units; @@ -90,11 +91,14 @@ pub struct Options { /// Highlighted (selected) handles are not special here; the canvas overlays them. pub fill: bool, pub lineweights: bool, + /// Height of the visible area in world units of the space being built. Relative point + /// sizes (`PDSIZE` <= 0) are a percentage of it; 0 = the height of the space's extents. + pub view_height: f64, } impl Default for Options { fn default() -> Self { - Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights: false } + Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights: false, view_height: 0.0 } } } @@ -230,6 +234,7 @@ fn top_ctx<'a>(d: &'a Drawing, xf: Mat3, top: Handle, frozen: &'a [String], vp_c } fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> DisplayList { + let opts = &with_point_view(d, space, opts); let mut b = Builder { list: DisplayList::default(), opts, plotting }; if let Space::Paper(name) = space { b.list.sheet = paper::sheet(d, name); @@ -251,6 +256,18 @@ fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> Di b.list } +/// `opts` with a view height for relative point sizes: the extents height when none is given +/// and points draw as figures sized relative to the view. +fn with_point_view(d: &Drawing, space: &Space, opts: &Options) -> Options { + let mut o = opts.clone(); + let relative = d.header.f64("PDSIZE", 0.0) <= 0.0 && point::sized(d.header.i64("PDMODE", 0)); + if !(o.view_height.is_finite() && o.view_height > 0.0) && relative { + let ext = d.extents(space); + o.view_height = if ext.is_empty() { 0.0 } else { ext.height().max(ext.width() * 1e-3) }; + } + o +} + /// Upper bound on viewports drawn per layout (hostile files). const MAX_VIEWPORTS: usize = 256; @@ -353,6 +370,15 @@ fn push_raw(dst: &mut DisplayList, p: &DPrim, kind: Kind, pts: &[Vec2]) { /// Build the display list for a set of loose entities (previews, rubber bands). pub fn build_entities<'a, I: IntoIterator>(d: &Drawing, ents: I, opts: &Options) -> DisplayList { + let ents: Vec<&Entity> = ents.into_iter().collect(); + // Only previews holding points need the model extents for relative point sizes. + let with_view; + let opts = if ents.iter().any(|e| matches!(e.kind, EntityKind::Point(_))) { + with_view = with_point_view(d, &Space::Model, opts); + &with_view + } else { + opts + }; let mut b = Builder { list: DisplayList::default(), opts, plotting: false }; for e in ents { entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); @@ -547,7 +573,18 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) { mtext(b, ctx, t, rgb, lw); } } - EntityKind::Point(p) => b.point(ctx, rgb, p.p.xy()), + EntityKind::Point(p) => { + // Relative sizes follow the view: undo the block / viewport scale. + let view = b.opts.view_height / ctx.xf.scale_factor().max(1e-12); + let size = point::size(ctx.d.header.f64("PDSIZE", 0.0), view); + let fig = point::figure(ctx.d.header.i64("PDMODE", 0), size, p.p.xy(), p.angle, tol); + if fig.dot { + b.point(ctx, rgb, p.p.xy()); + } + for l in &fig.lines { + b.polyline(ctx, rgb, lw, l); + } + } kind => { for prim in kind.prims() { match prim { diff --git a/crates/render/src/point.rs b/crates/render/src/point.rs new file mode 100644 index 00000000..be9906d3 --- /dev/null +++ b/crates/render/src/point.rs @@ -0,0 +1,58 @@ +//! POINT display figures: point style (`PDMODE`) and size (`PDSIZE`). + +use cadcraft_geom::{Circle, Vec2}; + +/// The figure a POINT draws. +#[derive(Debug, Default, PartialEq)] +pub struct Figure { + /// A one-pixel dot at the point. + pub dot: bool, + /// Line work (polylines). + pub lines: Vec>, +} + +/// Figure size in drawing units: a positive `PDSIZE` is absolute, a negative one a percentage +/// of `view_height`, and 0 is 5 % of `view_height`. +pub fn size(pdsize: f64, view_height: f64) -> f64 { + if pdsize.is_finite() && pdsize > 0.0 { + return pdsize; + } + let percent = if pdsize.is_finite() && pdsize < 0.0 { -pdsize } else { 5.0 }; + view_height * percent / 100.0 +} + +/// Whether `PDMODE` `mode` draws a figure with a size (anything but a dot or nothing). +pub fn sized(mode: i64) -> bool { + matches!(mode & 31, 2..=4) || mode & 96 != 0 +} + +/// The figure for `PDMODE` `mode`, `size` across, at `at`, turned by `angle` (radians). The low +/// bits pick dot (0), nothing (1), plus (2), cross (3) or a tick upwards (4); +32 adds a +/// circle and +64 a square. Without a usable size every visible figure falls back to a dot. +pub fn figure(mode: i64, size: f64, at: Vec2, angle: f64, tol: f64) -> Figure { + let base = mode & 31; + let mut f = Figure::default(); + let r = size / 2.0; + if !(r.is_finite() && r > 0.0) { + f.dot = base != 1; + return f; + } + let angle = if angle.is_finite() { angle } else { 0.0 }; + let p = |x: f64, y: f64| at + Vec2::new(x, y).rotate(angle); + match base { + 1 => {} + 2 => f.lines.extend([vec![p(-r, 0.0), p(r, 0.0)], vec![p(0.0, -r), p(0.0, r)]]), + 3 => f.lines.extend([vec![p(-r, -r), p(r, r)], vec![p(-r, r), p(r, -r)]]), + 4 => f.lines.push(vec![at, p(0.0, r)]), + _ => f.dot = true, + } + if mode & 32 != 0 { + let mut pts = Vec::new(); + Circle { center: at, radius: r }.tessellate(tol.max(r * 1e-4), &mut pts); + f.lines.push(pts); + } + if mode & 64 != 0 { + f.lines.push(vec![p(-r, -r), p(r, -r), p(r, r), p(-r, r), p(-r, -r)]); + } + f +} diff --git a/crates/render/tests/point_style.rs b/crates/render/tests/point_style.rs new file mode 100644 index 00000000..2385233d --- /dev/null +++ b/crates/render/tests/point_style.rs @@ -0,0 +1,69 @@ +//! POINT display: point style (PDMODE) and size (PDSIZE) (issue #261). + +use cadcraft_doc::{Common, Drawing, EntityKind, Line, Point, Space}; +use cadcraft_geom::{Bounds2, Vec3}; +use cadcraft_render::{DisplayList, Kind, Options, build}; + +/// A point at (5,5) and a vertical line 100 units long (the extents are 100 high). +fn drawing(pdmode: i64, pdsize: f64) -> Drawing { + let mut d = Drawing::new_imperial(); + d.header.set_i64("PDMODE", pdmode); + d.header.set_f64("PDSIZE", pdsize); + d.add(&Space::Model, Common::default(), EntityKind::Point(Point { p: Vec3::new(5.0, 5.0, 0.0), angle: 0.0 })).unwrap(); + d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(0.0, 100.0, 0.0) })).unwrap(); + d +} + +/// Dots and the bounds of the point's line work (the line along x = 0 is left out). +fn point_parts(l: &DisplayList) -> (usize, usize, Bounds2) { + let dots = l.prims.iter().filter(|p| p.kind == Kind::Point).count(); + let lines: Vec<_> = l.prims.iter().filter(|p| p.kind == Kind::Polyline && l.points(p).iter().any(|q| q.x > 1.0)).collect(); + let b = Bounds2::from_points(lines.iter().flat_map(|p| l.points(p).iter().copied())); + (dots, lines.len(), b) +} + +fn close(b: &Bounds2, x0: f64, y0: f64, x1: f64, y1: f64) -> bool { + [b.min.x - x0, b.min.y - y0, b.max.x - x1, b.max.y - y1].iter().all(|v| v.abs() < 1e-3) +} + +#[test] +fn cross_in_circle_with_absolute_size() { + let l = build(&drawing(35, 1.0), &Space::Model, &Options::default()); + let (dots, lines, b) = point_parts(&l); + assert_eq!((dots, lines), (0, 3), "an X (two lines) and a circle, no dot"); + assert!(close(&b, 4.5, 4.5, 5.5, 5.5), "{b:?}"); +} + +#[test] +fn dot_and_nothing() { + let (dots, lines, _) = point_parts(&build(&drawing(0, 1.0), &Space::Model, &Options::default())); + assert_eq!((dots, lines), (1, 0)); + let (dots, lines, _) = point_parts(&build(&drawing(1, 1.0), &Space::Model, &Options::default())); + assert_eq!((dots, lines), (0, 0)); + // 96 = square and circle around a dot. + let (dots, lines, b) = point_parts(&build(&drawing(96, 2.0), &Space::Model, &Options::default())); + assert_eq!((dots, lines), (1, 2)); + assert!(close(&b, 4.0, 4.0, 6.0, 6.0), "{b:?}"); +} + +#[test] +fn relative_sizes_follow_the_view_height() { + // PDSIZE -10: 10 % of a 50-unit view = 5 across. + let opts = Options { view_height: 50.0, ..Options::default() }; + let (_, lines, b) = point_parts(&build(&drawing(2, -10.0), &Space::Model, &opts)); + assert_eq!(lines, 2); + assert!(close(&b, 2.5, 2.5, 7.5, 7.5), "{b:?}"); + // PDSIZE 0 without a view height: 5 % of the 100-unit extents = 5 across. + let (_, _, b) = point_parts(&build(&drawing(3, 0.0), &Space::Model, &Options::default())); + assert!(close(&b, 2.5, 2.5, 7.5, 7.5), "{b:?}"); +} + +#[test] +fn hostile_point_settings_do_not_panic() { + for mode in [i64::MIN, -1, 5, 31, 127, i64::MAX] { + for size in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1e300, 1e300, 0.0, -5.0] { + let l = build(&drawing(mode, size), &Space::Model, &Options::default()); + assert!(l.verts.iter().all(|v| v.is_finite()), "PDMODE {mode} PDSIZE {size}"); + } + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c935ed0e..3517e4bb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -175,6 +175,7 @@ fn ensure_list(app: &mut CadApp, px: f64) { text: true, fill: true, lineweights: app.session.settings.lwdisplay, + view_height: st.paper_view().height, }; let opts = cadcraft_render::Options { tolerance: 2f64.powi(band) * 0.5, ..opts }; let space = st.space.clone(); From 3ef0edaed917532e5c10766b3d55dd6e65a8f1a3 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 21:33:50 +0200 Subject: [PATCH 061/150] Layout viewports scale model-space linetypes by PSLTSCALE (fixes #257) (#259) With PSLTSCALE on (the default), dashes of model-space objects seen through a viewport keep their paper-space length whatever the viewport scale; before, a 1:100 viewport drew them 100 times too short, so dashed lines looked solid in layouts and PDF plots. PSLTSCALE 0 keeps the old behaviour. Co-authored-by: Claude Opus 5.5 --- crates/render/src/lib.rs | 12 +++++- crates/render/tests/psltscale.rs | 63 ++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 crates/render/tests/psltscale.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 33832533..48cc500c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -117,6 +117,9 @@ struct Ctx<'a> { frozen: &'a [String], /// Per-viewport layer colour overrides of the viewport being drawn. vp_colors: &'a [(String, Color)], + /// Extra linetype scale for model space seen through a viewport: 1 / viewport scale when + /// `PSLTSCALE` is on (dashes keep their paper-space length at any viewport scale), else 1. + lt_factor: f64, } impl Ctx<'_> { @@ -230,6 +233,7 @@ fn top_ctx<'a>(d: &'a Drawing, xf: Mat3, top: Handle, frozen: &'a [String], vp_c top, frozen, vp_colors, + lt_factor: 1.0, } } @@ -298,6 +302,9 @@ fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewpor // Model window with slack: entity bounds are approximate (text, dimensions). let win = Bounds2::new(vp.view_center - mhalf, vp.view_center + mhalf).expand(mhalf.x.max(mhalf.y) * 0.1); let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting }; + // PSLTSCALE on: model-space dashes are scaled so they measure the same on paper as in + // paper space, whatever the viewport scale. + let lt_factor = if d.header.i64("PSLTSCALE", 1) != 0 { 1.0 / s } else { 1.0 }; for me in d.model.iter() { if !matches!(me.kind, EntityKind::Ray(_) | EntityKind::XLine(_) | EntityKind::Viewport(_)) { let eb = cadcraft_doc::entity_bounds(d, me, 0); @@ -308,7 +315,7 @@ fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewpor if matches!(me.kind, EntityKind::Viewport(_)) { continue; } - entity(&mut sub, &top_ctx(d, xf, VIEWPORT_CONTENT, &vp.frozen_layers, &vp.layer_colors), me); + entity(&mut sub, &Ctx { lt_factor, ..top_ctx(d, xf, VIEWPORT_CONTENT, &vp.frozen_layers, &vp.layer_colors) }, me); } b.list.expanded = b.list.expanded.saturating_add(sub.list.expanded); append_clipped(&mut b.list, &sub.list, &rect); @@ -411,7 +418,7 @@ fn resolve(ctx: &Ctx, e: &Entity, plotting: bool) -> (Rgb, f32, Option e.common.linetype.clone(), }; let lt = d.linetype(<_name).filter(|l| !l.pattern.is_empty()).cloned(); - let scale = d.header.f64("LTSCALE", 1.0) * e.common.ltscale; + let scale = d.header.f64("LTSCALE", 1.0) * e.common.ltscale * ctx.lt_factor; (rgb, lw_mm, lt, scale, visible) } @@ -847,6 +854,7 @@ fn sub_ctx<'a>(ctx: &Ctx<'a>, e: &Entity, m: Mat3) -> Ctx<'a> { top: ctx.top, frozen: ctx.frozen, vp_colors: ctx.vp_colors, + lt_factor: ctx.lt_factor, } } diff --git a/crates/render/tests/psltscale.rs b/crates/render/tests/psltscale.rs new file mode 100644 index 00000000..39df600e --- /dev/null +++ b/crates/render/tests/psltscale.rs @@ -0,0 +1,63 @@ +//! PSLTSCALE: model-space linetypes seen through a layout viewport (issue #257). + +use cadcraft_doc::{Common, Drawing, EntityKind, Line, Space, Viewport, library}; +use cadcraft_geom::{Vec2, Vec3}; +use cadcraft_render::{Kind, Options, VIEWPORT_CONTENT, build}; + +/// A metric drawing with a 1000-unit DASHED line (pattern 0.5 on, 0.25 off) in model space, +/// seen through a 1:100 viewport on Layout1. +fn drawing(psltscale: i64) -> Drawing { + let mut d = Drawing::new_metric(); + d.header.set_i64("PSLTSCALE", psltscale); + d.linetypes.extend(library::standard_linetypes().into_iter().filter(|l| l.name == "DASHED")); + let dashed = Common { linetype: "DASHED".into(), ..Default::default() }; + d.add(&Space::Model, dashed, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(1000.0, 0.0, 0.0) })).unwrap(); + // 190 x 90 paper units showing a model window 9000 high: scale 1:100. + let vp = Viewport { + center: Vec3::new(105.0, 55.0, 0.0), + width: 190.0, + height: 90.0, + view_center: Vec2::new(500.0, 0.0), + view_height: 9000.0, + id: 2, + locked: false, + frozen_layers: Vec::new(), + layer_colors: Vec::new(), + }; + d.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Viewport(vp)).unwrap(); + d +} + +/// Lengths (paper units) of the dashes drawn inside the viewport. +fn dash_lengths(d: &Drawing) -> Vec { + let l = build(d, &Space::Paper("Layout1".into()), &Options::default()); + l.prims + .iter() + .filter(|p| p.handle == VIEWPORT_CONTENT && p.kind == Kind::Polyline) + .map(|p| l.points(p).windows(2).map(|w| w[0].dist(w[1])).sum()) + .collect() +} + +#[test] +fn psltscale_on_keeps_paper_dash_length() { + let dashes = dash_lengths(&drawing(1)); + // 1000 model units = 10 paper units; 0.75-unit pattern on paper → about 13 dashes of 0.5. + assert!((12..=15).contains(&dashes.len()), "{} dashes", dashes.len()); + assert!(dashes.iter().take(dashes.len() - 1).all(|l| (l - 0.5).abs() < 1e-6), "{dashes:?}"); +} + +#[test] +fn psltscale_off_scales_dashes_with_the_viewport() { + let dashes = dash_lengths(&drawing(0)); + // Model-space dash length 0.5, shrunk 100x by the viewport. + assert!(dashes.len() > 1000, "{} dashes", dashes.len()); + assert!(dashes.iter().take(dashes.len() - 1).all(|l| (l - 0.005).abs() < 1e-6)); +} + +#[test] +fn model_space_ignores_psltscale() { + let d = drawing(1); + let l = build(&d, &Space::Model, &Options::default()); + let first = l.prims.iter().find(|p| p.kind == Kind::Polyline).map(|p| l.points(p).to_vec()).unwrap(); + assert!((first[0].dist(first[1]) - 0.5).abs() < 1e-9); +} From 1bd1c5cfa2e4e50dbb80a52ec1c2888320d56a53 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 21:33:56 +0200 Subject: [PATCH 062/150] io: only the first CADCraft xdata string marks the PAPERVIEW viewport (#253) dxf_ext::is_paper_view (#225) matched any 1000 string in the CADCraft xdata, but later strings are layer names (VPFROZEN, VPCOLORS). A real viewport with a frozen or recoloured layer named PAPERVIEW was therefore dropped on read. Only the first string, where the writer puts the marker, counts now. Regression test in tests/paperview_marker.rs. Co-authored-by: Claude Opus 5.5 --- crates/io/src/dxf_ext.rs | 6 +++-- crates/io/tests/paperview_marker.rs | 40 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 crates/io/tests/paperview_marker.rs diff --git a/crates/io/src/dxf_ext.rs b/crates/io/src/dxf_ext.rs index 054dc325..c6bc3dda 100644 --- a/crates/io/src/dxf_ext.rs +++ b/crates/io/src/dxf_ext.rs @@ -310,9 +310,11 @@ pub(crate) fn read_dstyle(tags: &[Tag], styles: &HashMap, blocks /// CADCraft keeps that viewport implicit, so the reader drops it. pub(crate) const PAPER_VIEW: &str = "PAPERVIEW"; -/// True for a VIEWPORT record carrying the [`PAPER_VIEW`] marker. +/// True for a VIEWPORT record carrying the [`PAPER_VIEW`] marker as the first string of its CADCraft +/// xdata, where the writer puts it. Later strings are layer names (VPFROZEN, VPCOLORS), so a layer +/// called PAPERVIEW does not count. pub(crate) fn is_paper_view(tags: &[Tag]) -> bool { - xdata(tags, APP).iter().any(|t| t.code == 1000 && t.str().trim().eq_ignore_ascii_case(PAPER_VIEW)) + xdata(tags, APP).iter().find(|t| t.code == 1000).is_some_and(|t| t.str().trim().eq_ignore_ascii_case(PAPER_VIEW)) } /// Snap kinds in CADCraft's ASSOC xdata. diff --git a/crates/io/tests/paperview_marker.rs b/crates/io/tests/paperview_marker.rs new file mode 100644 index 00000000..eecc3dc1 --- /dev/null +++ b/crates/io/tests/paperview_marker.rs @@ -0,0 +1,40 @@ +//! The DXF reader drops the paper-space viewport the writer adds (marked `1000 PAPERVIEW` in +//! CADCraft xdata) but keeps a real viewport whose frozen or recoloured layer is named PAPERVIEW. + +use cadcraft_color::Color; +use cadcraft_doc::*; +use cadcraft_geom::{Vec2, Vec3}; + +fn viewport(id: u32, frozen_layers: Vec, layer_colors: Vec<(String, Color)>) -> EntityKind { + EntityKind::Viewport(Viewport { + center: Vec3::new(50.0, 40.0, 0.0), + width: 80.0, + height: 60.0, + view_center: Vec2::new(10.0, 10.0), + view_height: 50.0, + id, + locked: false, + frozen_layers, + layer_colors, + }) +} + +fn viewports(l: &Layout) -> Vec { + l.entities.iter().filter_map(|e| if let EntityKind::Viewport(v) = &e.kind { Some(v.clone()) } else { None }).collect() +} + +#[test] +fn viewport_with_a_layer_named_paperview_survives_dxf_roundtrip() { + let mut d = Drawing::new_metric(); + d.ensure_layer("PAPERVIEW"); + let sheet = Space::Paper(d.layouts[0].name.clone()); + d.add(&sheet, Common::default(), viewport(2, vec!["PAPERVIEW".into()], vec![])).unwrap(); + d.add(&sheet, Common::default(), viewport(3, vec![], vec![("PAPERVIEW".into(), Color::Index(1))])).unwrap(); + + let text = cadcraft_io::write_dxf(&d); + let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap(); + let vps = viewports(&back.layouts[0]); + assert_eq!(vps.iter().map(|v| v.id).collect::>(), [2, 3], "only the added sheet viewport is dropped"); + assert_eq!(vps[0].frozen_layers, ["PAPERVIEW"]); + assert_eq!(vps[1].layer_colors, [("PAPERVIEW".to_string(), Color::Index(1))]); +} From 1d1fe4fc6d0730a63dafac641865ec2497979edb Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 21:34:01 +0200 Subject: [PATCH 063/150] BLOCK refuses a definition that references itself; names are case-insensitive (fixes #250) (#252) Redefining a block with a selection that holds a reference to that block, directly or through nested blocks, is now refused with "Block "B" references itself." and changes nothing. Before, the block ended up containing itself and lost its original geometry. At the command line the message ends -BLOCK, as for any definition that can't be made. Redefining with a name that differs only in case (Door, then DOOR) replaces the existing block instead of adding a second one. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/blocks.rs | 34 +++++++++++++++- crates/engine/tests/block_redefine.rs | 58 +++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/block_redefine.rs diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index ba82fc78..e1e3958f 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -47,6 +47,28 @@ fn valid_block_name(n: &str) -> bool { !n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c)) } +/// The block an entity references, when it is a block reference. +fn referenced_block(e: &Entity) -> Option { + if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None } +} + +/// Whether any of `ents` refers to block `name`, directly or through nested block references. +fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> bool { + let mut todo: Vec = ents.iter().filter_map(referenced_block).collect(); + let mut seen = std::collections::HashSet::new(); + while let Some(b) = todo.pop() { + if b.eq_ignore_ascii_case(name) { + return true; + } + if seen.insert(b.to_ascii_uppercase()) + && let Some(blk) = d.block(&b) + { + todo.extend(blk.entities.iter().filter_map(|e| referenced_block(e))); + } + } + false +} + /// Create a block from entities. Returns the insert handle when converting. pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle], keep: &str, description: &str) -> Result> { if !valid_block_name(name) { @@ -57,6 +79,12 @@ pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle], if ents.is_empty() { return Err(bad("block", "no objects selected")); } + if references_block(d, &ents, name) { + return Err(bad("block", format!("Block \"{name}\" references itself."))); + } + // Block names are case-insensitive: redefining keeps the existing definition's name. + let name = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string()); + let name = name.as_str(); let mut b = Block::new(name); b.base = v3(base); b.description = description.to_string(); @@ -374,7 +402,11 @@ impl Interactive for BlockM { impl BlockM { fn finish(&mut self, s: &mut Session, hs: &[Handle]) -> Result { let (Some(name), Some(base)) = (self.name.clone(), self.base) else { return Ok(Step::Cancel) }; - make_block(s, &name, base, hs, "convert", "")?; + // A definition that can't be made (e.g. one that would reference itself) ends the command. + if let Err(e) = make_block(s, &name, base, hs, "convert", "") { + s.echo(e.to_string()); + return Ok(Step::Done); + } s.echo(format!("Block \"{name}\" defined with {} object(s).", hs.len())); Ok(Step::Done) } diff --git a/crates/engine/tests/block_redefine.rs b/crates/engine/tests/block_redefine.rs new file mode 100644 index 00000000..227cdb3e --- /dev/null +++ b/crates/engine/tests/block_redefine.rs @@ -0,0 +1,58 @@ +//! BLOCK redefinition: no self-referencing blocks, names are case-insensitive (#250). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use serde_json::{Value, json}; + +fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String { + s.execute("line", &json!({ "points": [a, b] })).unwrap()["handles"][0].as_str().unwrap().to_string() +} + +/// Kinds of the entities in block `name`, as "line"/"insert:". +fn contents(s: &Session, name: &str) -> Vec { + let b = s.doc().unwrap().block(name).unwrap().clone(); + b.entities + .iter() + .map(|e| match &e.kind { + EntityKind::Insert(i) => format!("insert:{}", i.block), + EntityKind::Line(_) => "line".to_string(), + _ => "other".to_string(), + }) + .collect() +} + +#[test] +fn a_block_cannot_be_redefined_to_reference_itself() { + let mut s = Session::new(); + // Command line: the second -BLOCK selects the converted reference of B plus a new line. + s.script("LINE 0,0 10,0\n\n-BLOCK B 0,0 L\n\nLINE 0,0 0,10\n\n-BLOCK B 0,0 ALL\n\n").unwrap(); + assert!(s.running.is_none()); + assert!(s.log.iter().any(|l| l.contains("references itself")), "{:?}", s.log); + assert_eq!(contents(&s, "B"), ["line"], "B keeps its original definition"); + assert_eq!(s.doc().unwrap().model.len(), 2, "the reference and the new line stay in the drawing"); + + // Through a nested block: A holds B's reference, B holds A's. + let mut s = Session::new(); + let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + let a = s.execute("block", &json!({ "name": "A", "base": [0, 0], "handles": [l] })).unwrap()["insert"].clone(); + let l = line(&mut s, [0.0, 0.0], [0.0, 10.0]); + let b = s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [a, l] })).unwrap()["insert"].clone(); + let r = s.execute("block", &json!({ "name": "a", "base": [0, 0], "handles": [b] })); + assert!(r.is_err_and(|e| e.to_string().contains("references itself"))); + assert_eq!(contents(&s, "A"), ["line"]); +} + +#[test] +fn redefining_with_a_different_case_replaces_the_block() { + let mut s = Session::new(); + let l = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + s.execute("block", &json!({ "name": "Door", "base": [0, 0], "handles": [l] })).unwrap(); + let l = line(&mut s, [0.0, 0.0], [0.0, 10.0]); + s.execute("block", &json!({ "name": "DOOR", "base": [0, 0], "handles": [l], "keep": "delete" })).unwrap(); + let list = s.execute("blocks.list", &json!({})).unwrap(); + let names: Vec<&str> = list["blocks"].as_array().unwrap().iter().filter_map(|b| b["name"].as_str()).collect(); + assert_eq!(names, ["Door"]); + assert_eq!(s.doc().unwrap().blocks.values().map(|b| b.entities.len()).collect::>(), [1]); + let v: Vec = s.doc().unwrap().block("Door").unwrap().entities.iter().map(|e| serde_json::to_value(&e.kind).unwrap()).collect(); + assert_eq!(v[0]["b"]["y"], json!(10.0), "the new definition replaced the old one"); +} From a15d0e5b8ed9331f7675032a4c740f2026ccd83e Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 21:34:07 +0200 Subject: [PATCH 064/150] DIMCONTINUE/DIMBASELINE: one snap index per chain, cap the points (#279) (#281) With DIMASSOC on, every chained dimension looked up the objects under its definition points by scanning the whole space, and each point adds an entity, so a long `points` list took quadratic time (16,000 points: 2.7 s continue, 10 s baseline). The chain now builds one x-sorted SnapIndex up front. Dimensions are no snap targets, so the index stays valid while the chain adds them, and each lookup is a binary search. The index picks the same snap as find_snap: lowest rank first, then the first object in space order. A call with more than 100,000 points is refused. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/assoc.rs | 58 +++++++++++++++++++++++++++---- crates/engine/src/cmd/annotate.rs | 21 +++++++++-- crates/engine/tests/dim_chain.rs | 54 ++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 crates/engine/tests/dim_chain.rs diff --git a/crates/engine/src/assoc.rs b/crates/engine/src/assoc.rs index a48d2a35..0c29b47f 100644 --- a/crates/engine/src/assoc.rs +++ b/crates/engine/src/assoc.rs @@ -96,12 +96,8 @@ pub fn find_snap(d: &Drawing, space: &Space, p: Vec2) -> Option<(Handle, AssocSn let store = d.space(space)?; let mut best: Option<(usize, Handle, AssocSnap)> = None; for e in store.iter() { - for (rank, (snap, q)) in candidates(&e.kind).into_iter().enumerate() { - let rank = match snap { - AssocSnap::Mid => 100 + rank, - AssocSnap::Center => 50 + rank, - _ => rank.min(1), - }; + for (i, (snap, q)) in candidates(&e.kind).into_iter().enumerate() { + let rank = snap_rank(&snap, i); if close(p, q) && best.as_ref().is_none_or(|b| rank < b.0) { best = Some((rank, e.handle, snap)); } @@ -110,11 +106,61 @@ pub fn find_snap(d: &Drawing, space: &Space, p: Vec2) -> Option<(Handle, AssocSn best.map(|(_, h, s)| (h, s)) } +/// Preference of the `i`-th candidate snap of an object (lower wins): endpoints and vertices, +/// then centres, then midpoints. +fn snap_rank(snap: &AssocSnap, i: usize) -> usize { + match snap { + AssocSnap::Mid => 100 + i, + AssocSnap::Center => 50 + i, + _ => i.min(1), + } +} + /// Associations for named definition points found at their positions. pub fn auto_assoc(d: &Drawing, space: &Space, pts: &[(&str, Vec2)]) -> Vec { pts.iter().filter_map(|(name, p)| find_snap(d, space, *p).map(|(handle, snap)| DimAssoc { point: (*name).into(), handle, snap })).collect() } +/// The snap candidates of one space, sorted by x, for many [`find_snap`] lookups in a row: a +/// lookup is a binary search instead of a scan of the whole space. Dimensions add no candidates, +/// so the index stays valid while a command adds dimensions (DIMCONTINUE/DIMBASELINE chains). +pub struct SnapIndex { + /// (point, rank, position in space order, object, snap). + pts: Vec<(Vec2, usize, usize, Handle, AssocSnap)>, +} + +impl SnapIndex { + pub fn new(d: &Drawing, space: &Space) -> Self { + let mut pts = Vec::new(); + for e in d.space(space).into_iter().flat_map(|st| st.iter()) { + for (i, (snap, q)) in candidates(&e.kind).into_iter().enumerate() { + if q.is_finite() { + pts.push((q, snap_rank(&snap, i), pts.len(), e.handle, snap)); + } + } + } + pts.sort_by(|a, b| a.0.x.total_cmp(&b.0.x)); + SnapIndex { pts } + } + + /// The same snap as [`find_snap`] (lowest rank, then the first in space order). + pub fn find(&self, p: Vec2) -> Option<(Handle, AssocSnap)> { + if !p.is_finite() { + return None; + } + let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs())); + let lo = self.pts.partition_point(|c| c.0.x < p.x - tol); + let hi = self.pts.partition_point(|c| c.0.x <= p.x + tol); + let hit = self.pts.get(lo..hi)?.iter().filter(|c| close(p, c.0)).min_by_key(|c| (c.1, c.2))?; + Some((hit.3, hit.4.clone())) + } + + /// [`auto_assoc`] through the index. + pub fn auto_assoc(&self, pts: &[(&str, Vec2)]) -> Vec { + pts.iter().filter_map(|(name, p)| self.find(*p).map(|(handle, snap)| DimAssoc { point: (*name).into(), handle, snap })).collect() + } +} + /// A circle or arc (centre, radius) by handle. fn circle_of(d: &Drawing, h: Handle) -> Option<(Vec2, f64)> { match &d.entity(h)?.kind { diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index 68ee7ee5..c7c0dacf 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -341,14 +341,29 @@ fn chained(s: &Session, prev: &Dimension, next: Vec2, baseline: bool) -> EntityK }) } +/// Most dimensions one DIMCONTINUE/DIMBASELINE call adds. +const MAX_CHAIN: usize = 100_000; + fn chain_run(s: &mut Session, p: &Value, baseline: bool) -> Result { let id = if baseline { "dimbaseline" } else { "dimcontinue" }; - let pts = points_param(p, "points").ok_or_else(|| bad(id, "`points` is required"))?; + let pts = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(id, "`points` is required"))?; + if pts.len() > MAX_CHAIN { + return Err(bad(id, format!("at most {MAX_CHAIN} `points` per call"))); + } + let pts = points_param(p, "points").ok_or_else(|| bad(id, "`points` must be a list of points"))?; + // One snap index for the whole chain: looking up each new point's object by scanning the + // drawing made long chains quadratic (the added dimensions are no snap targets). + let snaps = if assoc_enabled(s) { Some(crate::assoc::SnapIndex::new(s.doc()?, &s.space())) } else { None }; let mut out = Vec::new(); for q in pts { let prev = last_linear(s).ok_or_else(|| bad(id, "no linear dimension to continue"))?; - let k = chained(s, &prev, q, baseline); - out.push(add_dim(s, k)?.hex()); + let mut k = chained(s, &prev, q, baseline); + if let (Some(ix), EntityKind::Dimension(dm)) = (&snaps, &mut k) { + dm.assoc = ix.auto_assoc(&[("p13", dm.p13.xy()), ("p14", dm.p14.xy())]); + } + let h = s.add_entity(k)?; + s.last_dim = Some(h); + out.push(h.hex()); } Ok(json!({ "handles": out })) } diff --git a/crates/engine/tests/dim_chain.rs b/crates/engine/tests/dim_chain.rs new file mode 100644 index 00000000..75c904f9 --- /dev/null +++ b/crates/engine/tests/dim_chain.rs @@ -0,0 +1,54 @@ +//! DIMCONTINUE / DIMBASELINE with many points: one snap index per chain (no rescan of the +//! drawing per point), the same associations as DIMREASSOCIATE, and a cap on the input size. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{DimAssoc, EntityKind, Handle}; +use serde_json::{Value, json}; + +fn assoc_of(s: &Session, handles: &[Value]) -> Vec> { + let d = s.doc().unwrap(); + handles + .iter() + .map(|h| match &d.entity(Handle::parse_hex(h.as_str().unwrap()).unwrap()).unwrap().kind { + EntityKind::Dimension(dm) => dm.assoc.clone(), + k => panic!("not a dimension: {k:?}"), + }) + .collect() +} + +#[test] +fn chains_attach_like_dimreassociate_and_long_chains_are_capped() { + // Lines end to end, a point on every joint (ties with the line start) and a circle on every + // midpoint (a centre outranks a line midpoint). + let mut s = Session::new(); + for i in 0..40 { + let x = f64::from(i); + s.execute("line", &json!({"points": [[x, 0], [x + 1.0, 0]]})).unwrap(); + s.execute("point", &json!({"at": [x, 0]})).unwrap(); + s.execute("circle", &json!({"center": [x + 0.5, 0], "radius": 0.1})).unwrap(); + } + for (cmd, at) in [("dimcontinue", 2), ("dimbaseline", 5)] { + s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [1, 0], "at": [0, at]})).unwrap(); + let pts: Vec<[f64; 2]> = (3..80).map(|i| [f64::from(i) * 0.5, 0.0]).collect(); + let r = s.execute(cmd, &json!({"points": pts})).unwrap(); + let hs = r["handles"].as_array().unwrap().clone(); + assert_eq!(hs.len(), pts.len()); + let chained = assoc_of(&s, &hs); + assert!(chained.iter().all(|a| a.len() == 2), "{cmd}: every point is on an object"); + // DIMREASSOCIATE scans the drawing per point; the indexed chain must agree with it. + s.execute("dimreassociate", &json!({"handles": hs})).unwrap(); + assert_eq!(assoc_of(&s, &hs), chained, "{cmd}"); + } + + // A long chain finishes (this was quadratic in the number of points); more than the cap is + // refused with nothing created. + let mut s = Session::new(); + s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [1, 0], "at": [0, 2]})).unwrap(); + let pts: Vec<[f64; 2]> = (2..20_002).map(|i| [f64::from(i), 0.0]).collect(); + assert_eq!(s.execute("dimcontinue", &json!({"points": pts})).unwrap()["handles"].as_array().unwrap().len(), 20_000); + let n = s.doc().unwrap().model.iter().count(); + let too_many: Vec<[f64; 2]> = (0..100_001).map(|i| [f64::from(i), 1.0]).collect(); + let e = s.execute("dimbaseline", &json!({"points": too_many})).unwrap_err(); + assert!(e.to_string().contains("100000"), "{e}"); + assert_eq!(s.doc().unwrap().model.iter().count(), n); +} From be8ae81c409fbe8bc0c3fee14e0bcac6b083f4b6 Mon Sep 17 00:00:00 2001 From: STE1200 <319441566+STE1200@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:34:12 +0200 Subject: [PATCH 065/150] DXF read: map mirrored OCS (extrusion 0,0,-1) of 2D entities to WCS (#277) AutoCAD and BricsCAD store a mirrored block reference, and many 2D entities drawn in a mirrored UCS, with extrusion (0,0,-1). Their points and angles are then in an OCS whose x axis runs the other way. The reader only honoured that for CIRCLE, ARC, LWPOLYLINE and SOLID/TRACE, so mirrored blocks, hatches and texts landed on the wrong side of the y axis (e.g. profile blocks at x -57618 instead of +57618). - INSERT: insertion point through the OCS, negative x scale, opposite rotation and negative column spacing. Only for extrusions parallel to -Z (x/y noise up to 1e-3); a tilted block plane needs a 3D transform that Insert cannot hold and is left as read. - HATCH and R12 2D POLYLINE: boundary x and bulges mirrored like LWPOLYLINE; the hatch pattern angle mirrors too. - TEXT/ATTDEF/ATTRIB and MTEXT: points through the OCS; drawn readable (MIRRTEXT = 0) with the mirrored footprint, so the direction turns and left/right justification or attachment swap. - ELLIPSE: centre and major axis are WCS, but the minor axis is extrusion x major, so the parameter range is negated. - DIMENSION: the text midpoint (11) is the one OCS point. Checked against an independent DXF library on private drawings: every INSERT transform (plain, mirrored, tilted) matches, as do the mirrored arcs, ellipses, polylines and hatches. The remaining extents outliers in those drawings come from references to empty blocks, which still add their insertion point to the extents. Co-authored-by: Claude Fable 5.1 --- crates/io/src/dxf_read.rs | 154 +++++++++++++++++++++++++++++++------- crates/io/src/tests.rs | 89 ++++++++++++++++++++++ 2 files changed, 214 insertions(+), 29 deletions(-) diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 16c0a18c..f1c7cbf4 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -83,11 +83,11 @@ fn common(t: &T) -> Common { /// Apply the OCS → WCS transform for 2D entities with a non-default extrusion. fn ocs(c: &Common, p: Vec3) -> Vec3 { - if (c.extrusion.z - 1.0).abs() < 1e-12 && c.extrusion.x.abs() < 1e-12 && c.extrusion.y.abs() < 1e-12 { - p - } else { - cadcraft_geom::Mat4::ocs(c.extrusion).apply(p) - } + ocs_n(c.extrusion, p) +} + +fn ocs_n(n: Vec3, p: Vec3) -> Vec3 { + if (n.z - 1.0).abs() < 1e-12 && n.x.abs() < 1e-12 && n.y.abs() < 1e-12 { p } else { cadcraft_geom::Mat4::ocs(n).apply(p) } } fn text_from(t: &T) -> Text { @@ -106,7 +106,7 @@ fn text_from(t: &T) -> Text { _ => VAlign::Baseline, }; let align = if t.f(11).is_some() { Some(t.p(11)) } else { None }; - Text { + let mut text = Text { insert: t.p(10), align_pt: if h == HAlign::Left && v == VAlign::Baseline { None } else { align }, height: t.fd(40, 0.2).max(1e-9), @@ -117,6 +117,40 @@ fn text_from(t: &T) -> Text { style: t.s(7).unwrap_or_else(|| "Standard".into()), halign: h, valign: v, + }; + let n = Vec3::new(t.fd(210, 0.0), t.fd(220, 0.0), t.fd(230, 1.0)); + if n.z < 0.0 { + mirror_text(&mut text, align.map(|a| ocs_n(n, a)), ocs_n(n, t.p(10))); + } + text +} + +/// TEXT / ATTRIB / ATTDEF with extrusion (0,0,-1): the points are OCS and the text runs from its +/// insertion point towards -x in the WCS, with mirrored glyphs. CADCraft draws text readable +/// (MIRRTEXT = 0), so it keeps the mirrored footprint: the direction turns to -rotation and +/// left/right justification swap ends (start ↔ end point). +fn mirror_text(t: &mut Text, align: Option, insert: Vec3) { + t.rotation = cadcraft_geom::norm_angle(-t.rotation); + let baseline_left = |t: &Text, p: Vec3| if t.valign == VAlign::Baseline { None } else { Some(p) }; + match t.halign { + HAlign::Left => { + t.halign = HAlign::Right; + t.insert = insert; + t.align_pt = Some(insert); + } + HAlign::Right => { + t.halign = HAlign::Left; + t.insert = align.unwrap_or(insert); + t.align_pt = baseline_left(t, t.insert); + } + HAlign::Aligned | HAlign::Fit => { + t.insert = align.unwrap_or(insert); + t.align_pt = Some(insert); + } + HAlign::Center | HAlign::Middle => { + t.insert = insert; + t.align_pt = align; + } } } @@ -143,13 +177,24 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { } EntityKind::Arc(a) } - "ELLIPSE" => EntityKind::Ellipse(Ellipse { - center: t.p(10), - major: t.p(11), - ratio: t.fd(40, 1.0).clamp(1e-9, 1.0), - start: t.fd(41, 0.0), - end: t.fd(42, std::f64::consts::TAU), - }), + "ELLIPSE" => { + let mut e = Ellipse { + center: t.p(10), + major: t.p(11), + ratio: t.fd(40, 1.0).clamp(1e-9, 1.0), + start: t.fd(41, 0.0), + end: t.fd(42, std::f64::consts::TAU), + }; + if c.extrusion.z < 0.0 && (cadcraft_geom::ccw_sweep(e.start, e.end) - std::f64::consts::TAU).abs() > 1e-9 { + // Centre and major axis are WCS, but the minor axis is extrusion × major: with (0,0,-1) + // it points the other way, so parameter t lands where -t does in the XY plane. A full + // ellipse is symmetric and stays as it is. + let (s, e2) = (-e.end, -e.start); + e.start = cadcraft_geom::norm_angle(s); + e.end = cadcraft_geom::norm_angle(e2); + } + EntityKind::Ellipse(e) + } "LWPOLYLINE" => { let mut verts = Vec::new(); let mut cur: Option = None; @@ -224,15 +269,27 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { contents.push_str(&tg.str()); } contents.push_str(&t.s(1).unwrap_or_default()); - let rotation = match (t.f(11), t.f(21)) { + let mut rotation = match (t.f(11), t.f(21)) { (Some(x), Some(y)) if x != 0.0 || y != 0.0 => Vec2::new(x, y).angle(), _ => t.fd(50, 0.0).to_radians(), }; + let mut attach = t.i(71).unwrap_or(1).clamp(1, 9) as u8; + if c.extrusion.z < 0.0 { + // Insertion point and direction are WCS, but the line height runs along extrusion × + // direction: under (0,0,-1) the glyphs are mirrored. Drawn readable (MIRRTEXT = 0) with + // the same footprint, the text turns round and left/right attachment swap. + rotation = cadcraft_geom::norm_angle(rotation + std::f64::consts::PI); + attach = match attach { + 1 | 4 | 7 => attach + 2, + 3 | 6 | 9 => attach - 2, + a => a, + }; + } EntityKind::MText(MText { insert: t.p(10), height: t.fd(40, 0.2).max(1e-9), width: t.fd(41, 0.0).max(0.0), - attach: t.i(71).unwrap_or(1).clamp(1, 9) as u8, + attach, rotation, style: t.s(7).unwrap_or_else(|| "Standard".into()), contents, @@ -246,17 +303,37 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { constant: t.i(70).unwrap_or(0) & 2 != 0, prompt: t.s(3).unwrap_or_default(), }), - "INSERT" => EntityKind::Insert(Insert { - block: t.s(2).unwrap_or_default(), - insert: t.p(10), - scale: Vec3::new(t.fd(41, 1.0), t.fd(42, 1.0), t.fd(43, 1.0)), - rotation: t.fd(50, 0.0).to_radians(), - attribs: Vec::new(), - cols: t.i(70).unwrap_or(1).clamp(1, 10_000) as u32, - rows: t.i(71).unwrap_or(1).clamp(1, 10_000) as u32, - col_spacing: t.fd(44, 0.0), - row_spacing: t.fd(45, 0.0), - }), + "INSERT" => { + // A mirrored block reference comes with extrusion (0,0,-1): its insertion point, scale, + // rotation and column spacing are in that OCS, where x runs the other way. In the WCS that + // is the mirrored point, a negative x scale, the opposite rotation and a negative column + // spacing. Noise of up to 1e-3 in the extrusion's x/y moves the projection by ~1e-7 of the + // size, so it still counts as mirrored. A truly tilted extrusion (block plane not parallel + // to XY) needs the full 3D block transform, which Insert cannot hold; it is left as read. + let n = c.extrusion; + let mirrored = n.z < 0.0 && n.x.hypot(n.y) <= 1e-3 * n.z.abs(); + let mut p = t.p(10); + let mut scale = Vec3::new(t.fd(41, 1.0), t.fd(42, 1.0), t.fd(43, 1.0)); + let mut rotation = t.fd(50, 0.0).to_radians(); + let mut col_spacing = t.fd(44, 0.0); + if mirrored { + p = ocs(&c, p); + scale.x = -scale.x; + rotation = -rotation; + col_spacing = -col_spacing; + } + EntityKind::Insert(Insert { + block: t.s(2).unwrap_or_default(), + insert: p, + scale, + rotation, + attribs: Vec::new(), + cols: t.i(70).unwrap_or(1).clamp(1, 10_000) as u32, + rows: t.i(71).unwrap_or(1).clamp(1, 10_000) as u32, + col_spacing, + row_spacing: t.fd(45, 0.0), + }) + } "DIMENSION" => { let ty = t.i(70).unwrap_or(0) & 0x0f; let kind = match ty { @@ -273,7 +350,8 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { EntityKind::Dimension(Dimension { kind, defpt: t.p(10), - text_mid: t.p(11), + // The text midpoint is the one OCS point of a DIMENSION. + text_mid: if c.extrusion.z < 0.0 { ocs(&c, t.p(11)) } else { t.p(11) }, p13: t.p(13), p14: t.p(14), p15: t.p(15), @@ -300,7 +378,20 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { if kind == "SOLID" { EntityKind::Solid(Solid { corners }) } else { EntityKind::Trace(Solid { corners }) } } "3DFACE" => EntityKind::Face3d(Face3d { corners: [t.p(10), t.p(11), t.p(12), t.p(13)], hidden_edges: t.i(70).unwrap_or(0) as u8 }), - "HATCH" => hatch(tags, &t)?, + "HATCH" => { + let mut k = hatch(tags, &t)?; + if c.extrusion.z < 0.0 + && let EntityKind::Hatch(h) = &mut k + { + // Boundary in the mirrored OCS, like LWPOLYLINE; the pattern direction mirrors too. + for v in h.loops.iter_mut().flat_map(|l| l.vertices.iter_mut()) { + v.p.x = -v.p.x; + v.bulge = -v.bulge; + } + h.angle = cadcraft_geom::norm_angle(std::f64::consts::PI - h.angle); + } + k + } "ACAD_TABLE" => EntityKind::Table(acad_table(tags)), "VIEWPORT" => EntityKind::Viewport(Viewport { center: t.p(10), @@ -713,8 +804,13 @@ fn parse_entities(recs: &[(String, Vec)], d: &mut Drawing, rx: &mut Rx) -> } else if flags & 8 != 0 { EntityKind::Polyline3d(Polyline3d { points: pts.iter().map(|p| p.0).collect(), closed: flags & 1 != 0 }) } else { + // A 2D polyline's vertices are in its OCS: mirrored like LWPOLYLINE. + let m = if c.extrusion.z < 0.0 { -1.0 } else { 1.0 }; EntityKind::LwPolyline(LwPolyline { - vertices: pts.iter().map(|p| PolyVertex { p: p.0.xy(), bulge: p.1, start_width: p.2, end_width: p.3 }).collect(), + vertices: pts + .iter() + .map(|p| PolyVertex { p: Vec2::new(m * p.0.x, p.0.y), bulge: m * p.1, start_width: p.2, end_width: p.3 }) + .collect(), closed: flags & 1 != 0, const_width: t.fd(40, 0.0).min(t.fd(41, 0.0)).max(0.0), elevation: t.p(10).z, diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 8e167796..20cd30ec 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -1656,3 +1656,92 @@ fn autocad_header_dim_variables_are_not_overrides() { let text = text.replacen("$DIMDEC\r\n70\r\n3\r\n", "$DIMDEC\r\n70\r\n5\r\n", 1); assert_eq!(read_dxf(text.as_bytes()).unwrap().dim_overrides().get("decimals"), Some(&serde_json::json!(5))); } + +#[test] +fn mirrored_insert_extrusion_maps_to_negative_x_scale() { + // A block reference mirrored in AutoCAD is stored with extrusion (0,0,-1); its insertion point, + // scale and rotation are in that OCS, where x runs the other way: OCS (-10,5) is WCS (10,5). + let text = "0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n2\nB\n10\n0\n20\n0\n0\nLINE\n8\n0\n10\n0\n20\n0\n11\n2\n21\n0\n0\nENDBLK\n0\nENDSEC\n\ + 0\nSECTION\n2\nENTITIES\n0\nINSERT\n8\n0\n2\nB\n10\n-10\n20\n5\n50\n90\n210\n0\n220\n0\n230\n-1\n0\nENDSEC\n0\nEOF\n"; + let d = read(text.as_bytes(), "m.dxf").unwrap(); + let ins = first(&d, |k| if let EntityKind::Insert(i) = k { Some(i.clone()) } else { None }); + assert!((ins.insert.x - 10.0).abs() < 1e-9 && (ins.insert.y - 5.0).abs() < 1e-9, "{:?}", ins.insert); + assert_eq!((ins.scale.x, ins.scale.y), (-1.0, 1.0)); + assert!((ins.rotation + std::f64::consts::FRAC_PI_2).abs() < 1e-12, "{}", ins.rotation); + // In the OCS the line (0,0)-(2,0) turned by 90° runs from (-10,5) to (-10,7); mirrored into the WCS + // that is (10,5)-(10,7). The same follows from T(10,5)·R(-90°)·S(-1,1): (2,0) → (-2,0) → (0,2) → (10,7). + let b = d.extents(&Space::Model); + for (got, want) in [(b.min.x, 10.0), (b.min.y, 5.0), (b.max.x, 10.0), (b.max.y, 7.0)] { + assert!((got - want).abs() < 1e-9, "extents {b:?}"); + } + // Written back in the WCS (negative x scale, no extrusion), it reads back in the same place. + let b2 = roundtrip(&d).extents(&Space::Model); + assert!((b2.min - b.min).len() < 1e-9 && (b2.max - b.max).len() < 1e-9, "{b2:?} vs {b:?}"); +} + +#[test] +fn mirrored_ellipse_extrusion_reverses_parameters() { + // Extrusion (0,0,-1) flips the minor axis (extrusion × major): parameters 0..90° of the ellipse + // centred at the origin with major (2,0) and ratio 0.5 run from (2,0) to (0,-1), not (0,1). + let text = "0\nSECTION\n2\nENTITIES\n0\nELLIPSE\n8\n0\n10\n0\n20\n0\n11\n2\n21\n0\n40\n0.5\n41\n0\n42\n1.5707963267948966\n\ + 210\n0\n220\n0\n230\n-1\n0\nENDSEC\n0\nEOF\n"; + let d = read(text.as_bytes(), "e.dxf").unwrap(); + let b = d.extents(&Space::Model); + for (got, want) in [(b.min.x, 0.0), (b.min.y, -1.0), (b.max.x, 2.0), (b.max.y, 0.0)] { + assert!((got - want).abs() < 1e-6, "extents {b:?}"); + } +} + +#[test] +fn mirrored_2d_polyline_extrusion_flips_x_and_bulge() { + // R12-style 2D POLYLINE vertices are OCS like LWPOLYLINE: with extrusion (0,0,-1), OCS (1,0) is WCS + // (-1,0) and a counter-clockwise bulge turns clockwise. + let text = "0\nSECTION\n2\nENTITIES\n0\nPOLYLINE\n8\n0\n66\n1\n70\n0\n210\n0\n220\n0\n230\n-1\n\ + 0\nVERTEX\n8\n0\n10\n1\n20\n0\n42\n1\n0\nVERTEX\n8\n0\n10\n3\n20\n0\n0\nSEQEND\n0\nENDSEC\n0\nEOF\n"; + let d = read(text.as_bytes(), "p.dxf").unwrap(); + let p = first(&d, |k| if let EntityKind::LwPolyline(p) = k { Some(p.clone()) } else { None }); + let got: Vec<(f64, f64, f64)> = p.vertices.iter().map(|v| (v.p.x, v.p.y, v.bulge)).collect(); + assert_eq!(got, vec![(-1.0, 0.0, -1.0), (-3.0, 0.0, 0.0)]); +} + +#[test] +fn mirrored_hatch_extrusion_flips_boundary() { + // HATCH boundaries are OCS: the square (1,0)..(3,2) under extrusion (0,0,-1) lies at (-3,0)..(-1,2). + let text = "0\nSECTION\n2\nENTITIES\n0\nHATCH\n8\n0\n10\n0\n20\n0\n30\n0\n210\n0\n220\n0\n230\n-1\n2\nSOLID\n70\n1\n71\n0\n91\n1\n\ + 92\n2\n72\n0\n73\n1\n93\n4\n10\n1\n20\n0\n10\n3\n20\n0\n10\n3\n20\n2\n10\n1\n20\n2\n97\n0\n75\n0\n76\n1\n0\nENDSEC\n0\nEOF\n"; + let d = read(text.as_bytes(), "h.dxf").unwrap(); + let b = d.extents(&Space::Model); + for (got, want) in [(b.min.x, -3.0), (b.min.y, 0.0), (b.max.x, -1.0), (b.max.y, 2.0)] { + assert!((got - want).abs() < 1e-9, "extents {b:?}"); + } +} + +#[test] +fn mirrored_text_extrusion_keeps_its_footprint() { + // TEXT at OCS (-10,5) under extrusion (0,0,-1) starts at WCS (10,5) and runs towards -x (with + // mirrored glyphs). Drawn readable, it becomes right-justified at (10,5) with the same footprint. + let text = "0\nSECTION\n2\nENTITIES\n0\nTEXT\n8\n0\n10\n-10\n20\n5\n40\n1\n1\nAB\n210\n0\n220\n0\n230\n-1\n0\nENDSEC\n0\nEOF\n"; + let d = read(text.as_bytes(), "t.dxf").unwrap(); + let t = first(&d, |k| if let EntityKind::Text(t) = k { Some(t.clone()) } else { None }); + assert_eq!(t.halign, HAlign::Right); + assert!(t.rotation.abs() < 1e-12, "{}", t.rotation); + let a = t.align_pt.expect("right-justified text has an alignment point"); + assert!((a.x - 10.0).abs() < 1e-9 && (a.y - 5.0).abs() < 1e-9, "{a:?}"); + let list = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()); + assert!((list.bounds.max.x - 10.0).abs() < 1e-6 && list.bounds.min.x < 9.5, "drawn left of (10,5): {:?}", list.bounds); +} + +#[test] +fn mirrored_mtext_extrusion_keeps_its_footprint() { + // MTEXT at (0,0) with direction (1,0), top-left attachment and extrusion (0,0,-1): the line height + // runs along extrusion × direction = (0,-1), so the text box lies above the insertion point + // (glyphs upside down). Drawn readable: turned round, top-right attachment, same box. + let text = + "0\nSECTION\n2\nENTITIES\n0\nMTEXT\n8\n0\n10\n0\n20\n0\n40\n1\n71\n1\n1\nAB\n11\n1\n21\n0\n210\n0\n220\n0\n230\n-1\n0\nENDSEC\n0\nEOF\n"; + let d = read(text.as_bytes(), "mt.dxf").unwrap(); + let t = first(&d, |k| if let EntityKind::MText(t) = k { Some(t.clone()) } else { None }); + assert_eq!(t.attach, 3); + assert!((t.rotation - std::f64::consts::PI).abs() < 1e-12, "{}", t.rotation); + let b = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()).bounds; + assert!(b.min.x > -0.2 && b.max.x > 0.5 && b.min.y > -0.2 && b.max.y > 0.5, "box right of and above (0,0): {b:?}"); +} From afe8b9325500642ea32875575a95ccf89e7646fb Mon Sep 17 00:00:00 2001 From: by Truitt <133776756+Tru10@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:34:18 -0500 Subject: [PATCH 066/150] Save As: offer DWG, with the drawing's format first (#198) The Save As dialog listed DXF, SVG, PNG and PDF, though CADCraft writes DWG. On macOS the dialog accepts only listed extensions, so a name typed as `part.dwg` was changed to .dxf and the drawing was saved as DXF. Save As now lists "Drawing (DWG)" too. The existing suggested-type-first ordering does the rest: a drawing opened from DWG defaults to DWG, while new drawings and DXF files keep DXF first. The list moves into `filters_for_save` with a unit test, and the label gets its Ukrainian translation following "Drawing (DXF)". Related to #14 (the Open half was fixed in #21). Claude-Session: https://claude.ai/code/session_019uMCkRAB8DMd8MEmoY9Vyr Co-authored-by: Claude Opus 5.5 --- apps/cadcraft/src/main.rs | 34 ++++++++++++++++++++++++++++------ crates/ui-egui/src/i18n/uk.tsv | 1 + 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 3ae72eb3..1e2fba34 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -77,11 +77,7 @@ fn services() -> Services { .map(|p| p.to_string_lossy().to_string()) })), pick_save: Some(Box::new(|name: &str| { - // The suggested name's type goes first: Windows adds the first filter's extension. - let mut filters = [("Drawing (DXF)", "dxf"), ("SVG", "svg"), ("PNG", "png"), ("PDF", "pdf")]; - let lower = name.to_ascii_lowercase(); - filters.sort_by_key(|(_, ext)| !lower.ends_with(&format!(".{ext}"))); - filters + filters_for_save(name) .iter() .fold(rfd::FileDialog::new().set_file_name(name), |d, (label, ext)| d.add_filter(i18n::t(label), &[*ext])) .save_file() @@ -90,6 +86,17 @@ fn services() -> Services { } } +/// File types for the Save dialog, the suggested name's type first: Windows adds the first +/// filter's extension, and macOS accepts only listed extensions, replacing any other with the +/// first one. DWG must be listed for a `.dwg` name to survive, and a drawing opened from DWG +/// then defaults to DWG; other names keep DXF first. +fn filters_for_save(name: &str) -> [(&'static str, &'static str); 5] { + let mut filters = [("Drawing (DXF)", "dxf"), ("Drawing (DWG)", "dwg"), ("SVG", "svg"), ("PNG", "png"), ("PDF", "pdf")]; + let lower = name.to_ascii_lowercase(); + filters.sort_by_key(|(_, ext)| !lower.ends_with(&format!(".{ext}"))); + filters +} + /// The per-user settings directory (today it holds `logs/`): `CADCRAFT_CONFIG_DIR` if set, else /// `$XDG_CONFIG_HOME/cadcraft` or `~/.config/cadcraft` on Linux and the BSDs, /// `~/Library/Application Support/CADCraft` on macOS, `%APPDATA%\CADCraft` on Windows. @@ -247,7 +254,7 @@ fn main() -> eframe::Result { #[cfg(test)] mod tests { - use super::{config_dir_from, control_port_from}; + use super::{config_dir_from, control_port_from, filters_for_save}; use std::ffi::OsString; use std::path::PathBuf; @@ -255,6 +262,21 @@ mod tests { move |k| vars.iter().find(|(n, _)| *n == k).map(|(_, v)| OsString::from(v)) } + #[test] + fn save_dialog_offers_dwg_with_the_drawing_format_first() { + let first = |name: &str| filters_for_save(name)[0].1; + assert_eq!(first("Waco Winnelson Floorplan.dwg"), "dwg"); + assert_eq!(first("FLOORPLAN.DWG"), "dwg"); + // New drawings and DXF files keep DXF first. + assert_eq!(first("Drawing1.dxf"), "dxf"); + assert_eq!(first("Drawing1"), "dxf"); + assert_eq!(first("plan.pdf"), "pdf"); + for name in ["a.dwg", "b.dxf", "c"] { + let exts: Vec<_> = filters_for_save(name).iter().map(|(_, e)| *e).collect(); + assert!(exts.contains(&"dwg") && exts.contains(&"dxf"), "{name}"); + } + } + #[test] fn control_port_values_that_are_not_ports_are_ignored() { assert_eq!(control_port_from("--control", Some("7979".into())), Some(7979)); diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0775093e..b442c499 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -184,6 +184,7 @@ Drafting Settings... Налаштування креслення... Draw Креслення Draw Order Порядок відображення + Drawing (DWG) Креслення (DWG) Drawing (DXF) Креслення (DXF) Drawing Limits Межі креслення Drawings (DWG, DXF) Креслення (DWG, DXF) From c55feb79daeaa4c1aa47e4db42606088533fa56f Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 21:34:24 +0200 Subject: [PATCH 067/150] SPLINE Close is periodic, options report; POLYGON typed radius has a flat bottom; CIRCLE rejects bad input (fixes #238) (#240) SPLINE Close now builds a smooth closed (periodic) curve through the fit points instead of repeating the first point. The geometry gets Spline::from_fit_points_closed: chord-length parameters including the closing chord, a periodic knot vector, and the first three control points repeated at the end. The fit data no longer duplicates the first point. DXF writes group 70 bit 2 (periodic) for it, and a file whose closed spline needs refitting is refitted closed. SPLINEDIT Close/refit/move and fit-point grips keep a closed spline periodic. SPLINE Method asks Fit/CV, and CV continues as SPLINE by control vertices. Knots, Object, start/end Tangency and toLerance report "not available yet" and stay at the prompt. Garbage text reports "Point or option keyword required.". POLYGON with a typed radius puts the bottom edge horizontal for Inscribed and for Circumscribed. The JSON form uses the same orientation when no angle is given. CIRCLE reports "Point or option keyword required." for an invalid token at the centre prompt, and "Invalid point." at the 3P/2P point prompts, then asks again. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/draw.rs | 79 ++++++++++++++++++++------ crates/engine/src/cmd/helpers.rs | 9 +++ crates/engine/src/cmd/modify2.rs | 12 +--- crates/engine/src/cmd/modify2_tests.rs | 2 +- crates/engine/src/grips.rs | 5 +- crates/engine/tests/draw_prompts.rs | 79 ++++++++++++++++++++++++++ crates/geom/src/spline.rs | 73 ++++++++++++++++++++++++ crates/geom/src/tests.rs | 23 ++++++++ crates/io/src/dxf_read.rs | 2 +- crates/io/src/dxf_write.rs | 2 +- crates/io/tests/closed_spline.rs | 25 ++++++++ 11 files changed, 278 insertions(+), 33 deletions(-) create mode 100644 crates/engine/tests/draw_prompts.rs create mode 100644 crates/io/tests/closed_spline.rs diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 885635bf..09c14718 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { CommandSpec::new("polygon", "Polygon", run_polygon) .menu(&["Draw", "Polygon"]) .alias(&["pol"]) - .params("{sides, center, radius, inscribed?: bool, angle?} | {sides, edge: [[x,y],[x,y]]}") + .params("{sides, center, radius, inscribed?: bool, angle?: deg to a vertex (inscribed) or edge midpoint (default: bottom edge horizontal)} | {sides, edge: [[x,y],[x,y]]}") .interactive(|_| Ok(Box::new(PolygonM::default()))), CommandSpec::new("ellipse", "Ellipse", run_ellipse) .menu(&["Draw", "Ellipse", "Center"]) @@ -189,14 +189,13 @@ fn run_polygon(s: &mut Session, p: &Value) -> Result { } else { let c = point_req("polygon", p, "center")?; let r = f64_req("polygon", p, "radius")?; - let ang = f64_or(p, "angle", 90.0).to_radians(); let inscribed = bool_or(p, "inscribed", true); - polygon_vertices( - c, - n, - Vec2::polar(c, r, if inscribed { ang } else { ang - std::f64::consts::PI / n as f64 + std::f64::consts::PI / n as f64 }), - inscribed, - ) + // Without an angle, the bottom edge is horizontal, as with a typed radius. + let rp = match p.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()) { + Some(a) => Vec2::polar(c, r, a.to_radians()), + None => polygon_typed_radius_point(c, n, r, inscribed), + }; + polygon_vertices(c, n, rp, inscribed) }; added(s.add_entity(lwpoly(vs, true))?) } @@ -737,6 +736,9 @@ impl Interactive for CircleM { } } Input::Enter => Ok(Step::Cancel), + // Garbage at a point prompt: report it and ask again. + Input::Text(_) if self.mode == 0 && self.pts.is_empty() => Err(crate::EngineError::Other("Point or option keyword required.".into())), + Input::Text(_) => Err(crate::EngineError::Other("Invalid point.".into())), _ => Ok(Step::Continue), } } @@ -833,7 +835,7 @@ impl Interactive for PolygonM { } (Some(c), Some(ins), Input::Text(t)) => { let r = number(&t).ok_or_else(|| crate::EngineError::Other("Requires numeric distance or point.".into()))?; - let rp = if ins { c + Vec2::Y * r } else { c - Vec2::Y * r }; + let rp = polygon_typed_radius_point(c, n, r, ins); s.add_entity(lwpoly(polygon_vertices(c, n, rp, ins), true))?; return Ok(Step::Done); } @@ -1128,13 +1130,26 @@ impl Interactive for XlineM { #[derive(Default)] struct SplineM { pts: Vec, + asking_method: bool, + /// `Method` > `CV` hands over to the control-vertex machine (`spline.cv`). + delegate: Option>, } impl Interactive for SplineM { fn name(&self) -> &'static str { "SPLINE" } - fn prompt(&self, _s: &Session) -> Prompt { + fn begin(&mut self, s: &mut Session) -> Result { + s.echo("Current settings: Method=Fit Knots=Chord"); + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + if let Some(d) = &self.delegate { + return d.prompt(s); + } + if self.asking_method { + return Prompt::new("Enter spline creation method", super::curves::KW).kw(&["Fit", "CV"]).default("Fit"); + } match self.pts.len() { 0 => Prompt::new("Specify first point", Accept::POINT).kw(&["Method", "Knots", "Object"]), 1 => Prompt::new("Enter next point", Accept::POINT).kw(&["start Tangency", "toLerance"]).base_opt(self.pts.last().copied()), @@ -1145,6 +1160,27 @@ impl Interactive for SplineM { } } fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(d) = &mut self.delegate { + return d.input(s, i); + } + if self.asking_method { + match i { + Input::Keyword(k) if k == "CV" => { + let Some(factory) = find_command("spline.cv").and_then(|c| c.interactive) else { + return Err(crate::EngineError::Other("CV: not available yet".into())); + }; + let mut m = factory(s)?; + self.asking_method = false; + let step = m.begin(s)?; + self.delegate = Some(m); + return Ok(step); + } + Input::Keyword(_) | Input::Enter => self.asking_method = false, + Input::Text(_) => return Err(crate::EngineError::Other("Invalid option keyword.".into())), + _ => {} + } + return Ok(Step::Continue); + } match i { Input::Point(p) => { self.pts.push(p); @@ -1155,24 +1191,33 @@ impl Interactive for SplineM { Ok(Step::Continue) } Input::Keyword(k) if k == "Close" => { - if let Some(f) = self.pts.first().copied() { - self.pts.push(f); - } - let mut sp = Spline::from_fit_points(&self.pts); - sp.closed = true; - s.add_entity(EntityKind::Spline(sp))?; + // A smooth closed (periodic) curve, not a repeat of the first point. + s.add_entity(EntityKind::Spline(Spline::from_fit_points_closed(&self.pts)))?; Ok(Step::Done) } + Input::Keyword(k) if k == "Method" => { + self.asking_method = true; + Ok(Step::Continue) + } + Input::Keyword(k) => { + // Knots, Object, start/end Tangency, toLerance: stay at this prompt. + s.echo(format!("{k}: not available yet")); + Ok(Step::Continue) + } Input::Enter => { if self.pts.len() >= 2 { s.add_entity(EntityKind::Spline(Spline::from_fit_points(&self.pts)))?; } Ok(Step::Done) } + Input::Text(_) => Err(crate::EngineError::Other("Point or option keyword required.".into())), _ => Ok(Step::Continue), } } - fn preview(&self, _s: &Session, c: Vec2) -> Vec { + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if let Some(d) = &self.delegate { + return d.preview(s, c); + } if self.pts.is_empty() { return Vec::new(); } diff --git a/crates/engine/src/cmd/helpers.rs b/crates/engine/src/cmd/helpers.rs index 1f64b8fe..42f0a467 100644 --- a/crates/engine/src/cmd/helpers.rs +++ b/crates/engine/src/cmd/helpers.rs @@ -38,6 +38,15 @@ pub fn polygon_vertices(center: Vec2, sides: usize, radius_pt: Vec2, inscribed: (0..n).map(|i| PolyVertex::new(Vec2::polar(center, r, a0 + step * i as f64))).collect() } +/// The radius point for a polygon whose radius is typed: the bottom edge is horizontal in both +/// modes (inscribed: a vertex half a side's angle past straight down; circumscribed: the bottom +/// edge's midpoint straight down). +pub fn polygon_typed_radius_point(center: Vec2, sides: usize, radius: f64, inscribed: bool) -> Vec2 { + let down = -std::f64::consts::FRAC_PI_2; + let a = if inscribed { down + std::f64::consts::PI / sides.clamp(3, 1024) as f64 } else { down }; + Vec2::polar(center, radius, a) +} + /// Polygon from an edge (first two vertices). pub fn polygon_from_edge(p1: Vec2, p2: Vec2, sides: usize) -> Vec { let n = sides.clamp(3, 1024); diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index ba21904c..70f8f3fd 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -649,9 +649,7 @@ fn spline_close(sp: &Spline) -> Spline { return sp.clone(); } let mut out = if sp.fit.len() >= 2 { - let mut f = sp.fit.clone(); - f.extend(sp.fit.first().copied()); - Spline::from_fit_points(&f) + Spline::from_fit_points_closed(&sp.fit) } else { let mut c = sp.control.clone(); c.extend(sp.control.first().copied()); @@ -704,9 +702,7 @@ fn run_splinedit(s: &mut Session, p: &Value) -> Result { if fit.len() < 2 || fit.len() > MAX_GEN { return Err(other("The spline has no fit data; give `fit` points.")); } - let mut n = Spline::from_fit_points(&fit); - n.closed = sp.closed; - EntityKind::Spline(n) + EntityKind::Spline(Spline::from_fit(&fit, sp.closed)) } "polyline" => EntityKind::LwPolyline(spline_to_poly(sp, p.get("precision").and_then(Value::as_u64).unwrap_or(10) as usize)), "move" => { @@ -716,9 +712,7 @@ fn run_splinedit(s: &mut Session, p: &Value) -> Result { if !n.fit.is_empty() { let f = n.fit.get_mut(i).ok_or_else(|| bad("splinedit", "index out of range"))?; *f = to; - let closed = n.closed; - n = Spline::from_fit_points(&n.fit); - n.closed = closed; + n = Spline::from_fit(&n.fit, n.closed); } else { let c = n.control.get_mut(i).ok_or_else(|| bad("splinedit", "index out of range"))?; *c = to; diff --git a/crates/engine/src/cmd/modify2_tests.rs b/crates/engine/src/cmd/modify2_tests.rs index c2b541f6..7b62572b 100644 --- a/crates/engine/src/cmd/modify2_tests.rs +++ b/crates/engine/src/cmd/modify2_tests.rs @@ -151,7 +151,7 @@ fn splinedit_json_and_command_line() { let mut s = Session::new(); let sp = h(&s.execute("spline", &json!({"fit": [[0, 0], [2, 2], [4, 0], [6, 2]]})).unwrap()); s.execute("splinedit", &json!({"handle": sp, "option": "close"})).unwrap(); - assert!(matches!(kind(&s, &sp), EntityKind::Spline(x) if x.closed && x.fit.len() == 5)); + assert!(matches!(kind(&s, &sp), EntityKind::Spline(x) if x.closed && x.is_periodic() && x.fit.len() == 4)); s.execute("splinedit", &json!({"handle": sp, "option": "open"})).unwrap(); assert!(matches!(kind(&s, &sp), EntityKind::Spline(x) if !x.closed && x.fit.len() == 4)); s.execute("splinedit", &json!({"handle": sp, "option": "reverse"})).unwrap(); diff --git a/crates/engine/src/grips.rs b/crates/engine/src/grips.rs index 46a42057..d08f47bc 100644 --- a/crates/engine/src/grips.rs +++ b/crates/engine/src/grips.rs @@ -181,10 +181,7 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option { diff --git a/crates/engine/tests/draw_prompts.rs b/crates/engine/tests/draw_prompts.rs new file mode 100644 index 00000000..9ed0fe5a --- /dev/null +++ b/crates/engine/tests/draw_prompts.rs @@ -0,0 +1,79 @@ +//! Command-line behaviour of the SPLINE, POLYGON and CIRCLE prompt machines. + +use cadcraft_doc::EntityKind; +use cadcraft_engine::Session; +use cadcraft_geom::Vec2; + +fn last(s: &Session) -> EntityKind { + s.doc().unwrap().model.iter().last().map(|e| e.kind.clone()).unwrap() +} + +fn logged(s: &Session, text: &str) -> bool { + s.log.iter().any(|l| l.contains(text)) +} + +#[test] +fn spline_close_makes_a_smooth_periodic_curve() { + let mut s = Session::new(); + s.cmdline("spline 0,0 4,0 4,3 0,3 c").unwrap(); + assert!(s.running.is_none()); + let EntityKind::Spline(sp) = last(&s) else { panic!("no spline") }; + assert!(sp.closed && sp.is_periodic()); + assert_eq!(sp.fit.len(), 4, "the first point is not repeated"); + let (lo, hi) = sp.domain(); + assert!(sp.eval(lo).near(Vec2::ZERO, 1e-9) && sp.eval(hi).near(Vec2::ZERO, 1e-9)); + let h = 1e-6; + let out = (sp.eval(lo + h) - sp.eval(lo)).normalized(); + let back = (sp.eval(hi) - sp.eval(hi - h)).normalized(); + assert!(out.near(back, 1e-4), "no corner at the seam"); +} + +#[test] +fn spline_options_report_and_stay_at_the_prompt() { + let mut s = Session::new(); + s.cmdline("spline k o 0,0 t 5,5 l 10,0 xyz").unwrap(); + for k in ["Knots: not available yet", "Object: not available yet", "start Tangency: not available yet", "toLerance: not available yet"] { + assert!(logged(&s, k), "{k}"); + } + assert!(logged(&s, "Point or option keyword required.")); + s.cmdline("").unwrap(); + let EntityKind::Spline(sp) = last(&s) else { panic!("no spline") }; + assert_eq!(sp.fit, [Vec2::ZERO, Vec2::new(5.0, 5.0), Vec2::new(10.0, 0.0)], "no point was swallowed"); + + // Method > CV switches to drawing by control vertices. + s.cmdline("spline m cv 0,0 1,1 2,0 3,1").unwrap(); + s.cmdline("").unwrap(); + let EntityKind::Spline(sp) = last(&s) else { panic!("no spline") }; + assert!(sp.fit.is_empty() && sp.control.len() == 4); +} + +/// The two lowest vertices of a closed polyline share their y (the bottom edge is horizontal). +fn bottom_y(k: &EntityKind) -> Option { + let EntityKind::LwPolyline(p) = k else { return None }; + let mut ys: Vec = p.vertices.iter().map(|v| v.p.y).collect(); + ys.sort_by(f64::total_cmp); + ((ys[0] - ys[1]).abs() < 1e-9).then_some(ys[0]) +} + +#[test] +fn polygon_typed_radius_has_a_horizontal_bottom_edge() { + let mut s = Session::new(); + let pentagon = -2.0 * (std::f64::consts::PI / 5.0).cos(); + for (input, low) in + [("polygon 6 0,0 i 2", -(3f64.sqrt())), ("polygon 6 0,0 c 2", -2.0), ("polygon 4 0,0 i 2", -(2f64.sqrt())), ("polygon 5 0,0 i 2", pentagon)] + { + s.cmdline(input).unwrap(); + let y = bottom_y(&last(&s)); + assert!(y.is_some_and(|y| (y - low).abs() < 1e-9), "{input}: bottom edge at {y:?}, want {low}"); + } +} + +#[test] +fn circle_center_prompt_rejects_garbage() { + let mut s = Session::new(); + s.cmdline("circle d").unwrap(); + assert!(logged(&s, "Point or option keyword required.")); + assert!(s.running.is_some(), "still at the centre prompt"); + s.cmdline("0,0 5").unwrap(); + assert!(matches!(last(&s), EntityKind::Circle(c) if (c.radius - 5.0).abs() < 1e-9)); +} diff --git a/crates/geom/src/spline.rs b/crates/geom/src/spline.rs index 8539f92b..e58a46b4 100644 --- a/crates/geom/src/spline.rs +++ b/crates/geom/src/spline.rs @@ -87,6 +87,79 @@ impl Spline { Spline { degree, knots, control, weights: Vec::new(), fit: fit.to_vec(), closed: false } } + /// Smooth closed (periodic) cubic interpolation through fit points. Chord-length parameters + /// include the closing chord; the knot vector is the periodic (unclamped) extension and the + /// first `degree` control points repeat at the end, so the curve has continuous tangent and + /// curvature at the seam. A repeated closing fit point is dropped. + pub fn from_fit_points_closed(fit: &[Vec2]) -> Spline { + let mut pts = fit.to_vec(); + if pts.len() > 3 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.near(*b, 1e-12)) { + pts.pop(); + } + let n = pts.len(); + let fallback = |pts: &[Vec2]| { + let mut f = pts.to_vec(); + f.extend(pts.first().copied()); + let mut s = Spline::from_fit_points(&f); + s.fit = pts.to_vec(); + s.closed = true; + s + }; + let p = 3; + if n < p { + return fallback(&pts); + } + // Parameters t_0 = 0 .. t_n = 1 (t_n is the return to the first point). + let mut t = vec![0.0; n + 1]; + let mut total = 0.0; + for i in 1..=n { + total += pts.get(i - 1).zip(pts.get(i % n)).map(|(a, b)| a.dist(*b)).unwrap_or(0.0).max(1e-12); + if let Some(x) = t.get_mut(i) { + *x = total; + } + } + for x in t.iter_mut() { + *x /= total; + } + // Periodic knots: knots[j] = t_(j-p), extended by whole periods on both sides. + let tk = |i: isize| { + let (q, r) = (i.div_euclid(n as isize), i.rem_euclid(n as isize) as usize); + t.get(r).copied().unwrap_or(0.0) + q as f64 + }; + let m = n + p; + let knots: Vec = (0..m + p + 1).map(|j| tk(j as isize - p as isize)).collect(); + // Interpolation at t_i; control index k and k + n are the same unknown. + let mut a = vec![vec![0.0; n]; n]; + for (i, row) in a.iter_mut().enumerate() { + let u = t.get(i).copied().unwrap_or(0.0); + let span = find_span(m - 1, p, u, &knots); + for (k, v) in basis(span, u, p, &knots).iter().enumerate() { + if let Some(cell) = row.get_mut((span + k - p) % n) { + *cell += *v; + } + } + } + let xs: Vec = pts.iter().map(|q| q.x).collect(); + let ys: Vec = pts.iter().map(|q| q.y).collect(); + let (Some(cx), Some(cy)) = (solve(a.clone(), xs), solve(a, ys)) else { return fallback(&pts) }; + let mut control: Vec = cx.into_iter().zip(cy).map(|(x, y)| Vec2::new(x, y)).collect(); + if control.iter().any(|c| !c.x.is_finite() || !c.y.is_finite()) { + return fallback(&pts); + } + control.extend_from_within(..p); + Spline { degree: p, knots, control, weights: Vec::new(), fit: pts, closed: true } + } + + /// Interpolation through fit points, open or closed (see `from_fit_points_closed`). + pub fn from_fit(fit: &[Vec2], closed: bool) -> Spline { + if closed { Spline::from_fit_points_closed(fit) } else { Spline::from_fit_points(fit) } + } + + /// Closed with an unclamped (periodic) knot vector, as DXF flags it (group 70 bit 2). + pub fn is_periodic(&self) -> bool { + self.closed && self.knots.first() != self.knots.get(self.degree) + } + pub fn is_valid(&self) -> bool { let n = self.control.len(); n > self.degree && self.degree >= 1 && self.knots.len() == n + self.degree + 1 && self.knots.windows(2).all(|w| w.first() <= w.get(1)) diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 52ae7539..b5c7fa3c 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -123,6 +123,29 @@ fn spline_interpolates_fit_points() { assert!(s.eval(1.0).near(fit[4], 1e-9)); } +#[test] +fn closed_spline_is_smooth_at_the_seam() { + let fit = [Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(4.0, 3.0), Vec2::new(0.0, 3.0)]; + let s = Spline::from_fit_points_closed(&fit); + assert!(s.is_valid() && s.closed && s.is_periodic()); + assert_eq!(s.fit.len(), 4, "the first fit point is not repeated"); + let (lo, hi) = s.domain(); + assert!(s.eval(lo).near(fit[0], 1e-9) && s.eval(hi).near(fit[0], 1e-9)); + let pts = s.tessellate(1e-4); + for f in fit { + assert!(pts.iter().any(|p| p.dist(f) < 0.05), "fit point {f:?} missed"); + } + // Same tangent leaving and arriving at the seam (an open fit through a repeated point has a corner there). + let h = 1e-6; + let out = (s.eval(lo + h) - s.eval(lo)).normalized(); + let back = (s.eval(hi) - s.eval(hi - h)).normalized(); + assert!(out.near(back, 1e-4), "kink at the seam: {out:?} vs {back:?}"); + // A repeated closing point is dropped. + let mut dup = fit.to_vec(); + dup.push(fit[0]); + assert_eq!(Spline::from_fit_points_closed(&dup).fit.len(), 4); +} + #[test] fn ellipse_points() { let e = Ellipse::full(Vec2::ZERO, Vec2::new(4.0, 0.0), 0.5); diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index f1c7cbf4..5c446d6e 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -253,7 +253,7 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { let fit = t.pts(11); let mut sp = Spline { degree, knots, control, weights, fit: fit.clone(), closed: t.i(70).unwrap_or(0) & 1 != 0 }; if !sp.is_valid() && fit.len() >= 2 { - sp = Spline::from_fit_points(&fit); + sp = Spline::from_fit(&fit, sp.closed); } else if !sp.is_valid() && sp.control.len() >= 2 { sp = Spline::from_control(sp.control.clone(), degree); } diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 1ba51ba6..165287ba 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -467,7 +467,7 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx EntityKind::Spline(s) => { w.s(0, "SPLINE"); common(w, e, owner, paper, "AcDbSpline"); - let flags = 8 | i64::from(s.closed) | if s.weights.is_empty() { 0 } else { 4 }; + let flags = 8 | i64::from(s.closed) | if s.is_periodic() { 2 } else { 0 } | if s.weights.is_empty() { 0 } else { 4 }; w.i(70, flags); w.i(71, s.degree as i64); w.i(72, s.knots.len() as i64); diff --git a/crates/io/tests/closed_spline.rs b/crates/io/tests/closed_spline.rs new file mode 100644 index 00000000..1d1bce76 --- /dev/null +++ b/crates/io/tests/closed_spline.rs @@ -0,0 +1,25 @@ +//! A closed fit-point spline is periodic: DXF flags it closed + periodic (group 70 bits 1 and 2) +//! and it reads back as the same smooth closed curve. + +use cadcraft_doc::{Common, Drawing, EntityKind, Space}; +use cadcraft_geom::{Spline, Vec2}; + +#[test] +fn closed_fit_spline_roundtrips_as_periodic() { + let fit = [Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(4.0, 3.0), Vec2::new(0.0, 3.0)]; + let sp = Spline::from_fit_points_closed(&fit); + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common::default(), EntityKind::Spline(sp.clone())).unwrap(); + let bytes = cadcraft_io::write(&d, "a.dxf").unwrap(); + let text = String::from_utf8(bytes.clone()).unwrap(); + let spline = &text[text.find("SPLINE").unwrap()..]; + assert!(spline.contains("70\r\n11\r\n"), "planar + closed + periodic"); + let back = cadcraft_io::read(&bytes, "a.dxf").unwrap(); + let Some(EntityKind::Spline(r)) = back.model.iter().map(|e| e.kind.clone()).next() else { panic!("no spline") }; + assert!(r.closed && r.is_periodic()); + assert_eq!(r.fit.len(), 4); + for i in 0..=20 { + let t = i as f64 / 20.0; + assert!(r.eval(t).near(sp.eval(t), 1e-9)); + } +} From fc1810f5d9a69bea7cff8c04f98cd29f3e39ece5 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 21:34:30 +0200 Subject: [PATCH 068/150] Array commands: ARRAY, and edit-array option loops for ARRAYRECT/ARRAYPOLAR (#230) (#232) * Array commands: ARRAY, and the edit-array option loops for ARRAYRECT/ARRAYPOLAR (#230) ARRAY (alias AR) selects objects, asks for the array type and continues as ARRAYRECT, ARRAYPATH or ARRAYPOLAR. ARRAYRECT no longer creates a fixed 4x3 grid right after selection: it previews the array and loops on ASsociative/Base point/COUnt/Spacing/COLumns/Rows/Levels/eXit. ARRAYPOLAR asks for the center (or Base point), then loops on Items/Angle between/ Fill angle/ROTate items/eXit. The copies are created on eXit or Enter. Associative arrays, levels and polar rows report "not available yet" and return to the loop without consuming the next input. The array commands move to cmd/array.rs. Arrays stay non-associative copies, and the JSON forms are unchanged. A full clockwise polar fill (-360) no longer puts the last item on top of the source. Co-Authored-By: Claude Opus 5.5 * docs/gaps.md: narrow F5 (array option loops, #230) Co-Authored-By: Claude Opus 5.5 * Arrays: refuse arrays over MAXARRAY instead of hanging or shrinking them (#230) `arrayrect {"rows":1000000,"cols":1000000}` was clamped to 1000x1000 and then created a million copies of every selected object, which hangs the app. ARRAYRECT, ARRAYPOLAR and ARRAYPATH (JSON and interactive, also via ARRAY) now count items x selected objects and refuse an array over the new MAXARRAY session variable (default 100000, settable 100..10000000) with a message naming the limit, instead of clamping the counts and silently creating fewer copies. The interactive count prompts accept at most what still fits under MAXARRAY. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/array.rs | 796 ++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/modify.rs | 86 +-- crates/engine/src/cmd/modify2.rs | 20 +- crates/engine/src/lib.rs | 3 + crates/engine/src/sysvars.rs | 3 + crates/engine/tests/array_commands.rs | 201 +++++++ docs/gaps.md | 2 +- 8 files changed, 1020 insertions(+), 93 deletions(-) create mode 100644 crates/engine/src/cmd/array.rs create mode 100644 crates/engine/tests/array_commands.rs diff --git a/crates/engine/src/cmd/array.rs b/crates/engine/src/cmd/array.rs new file mode 100644 index 00000000..e6fbb5e9 --- /dev/null +++ b/crates/engine/src/cmd/array.rs @@ -0,0 +1,796 @@ +//! Array commands: ARRAY, ARRAYRECT and ARRAYPOLAR (ARRAYPATH lives in `modify2`). +//! +//! Arrays are created as separate copies of the source objects (non-associative). The interactive +//! forms select objects, then edit the array in an option loop with a live preview, and create +//! the copies on eXit (or Enter). + +use cadcraft_doc::{Drawing, EntityKind, Handle}; +use cadcraft_geom::{Bounds2, Mat3, TAU, Vec2}; +use serde_json::{Value, json}; + +use super::helpers::line; +use super::machines::{SelOutcome, SelectPhase, number}; +use super::modify::transform_entities; +use super::*; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +/// Most preview entities drawn while the array is edited. +const PREVIEW_LIMIT: usize = 2000; +/// Keyword-only prompts: each typed token is matched on its own (no free text). +const KEYWORDS: Accept = Accept { point: false, number: false, text: false, select: false, enter: true }; + +const NOT_ASSOCIATIVE: &str = "Associative arrays are not available yet; the array is created as separate objects."; +const NO_LEVELS: &str = "Levels (3D arrays) are not available yet."; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("array", "Array", run_array) + .alias(&["ar"]) + .params("{type?: \"rectangular\" (default) | \"polar\" | \"path\", ...the parameters of arrayrect, arraypolar or arraypath}") + .interactive(|_| Ok(Box::new(ArrayM::default()))), + CommandSpec::new("arrayrect", "Rectangular Array", run_arrayrect) + .menu(&["Modify", "Array", "Rectangular Array"]) + .params("{handles?, rows, cols, rowSpacing, colSpacing}") + .interactive(|_| Ok(Box::new(RectM::default()))), + CommandSpec::new("arraypolar", "Polar Array", run_arraypolar) + .menu(&["Modify", "Array", "Polar Array"]) + .params("{handles?, center, count, angle? (degrees, default 360), rotate?: bool}") + .interactive(|_| Ok(Box::new(PolarM::default()))), + ] +} + +// ---------------- geometry ---------------- + +/// Translations of the copies in a rectangular array (the source cell is skipped). +fn rect_mats(rows: u64, cols: u64, rs: f64, cs: f64) -> impl Iterator { + (0..rows) + .flat_map(move |r| (0..cols).map(move |c| (r, c))) + .filter(|rc| *rc != (0, 0)) + .map(move |(r, c)| Mat3::translate(Vec2::new(cs * c as f64, rs * r as f64))) +} + +/// Transforms of the copies 1..n of a polar array filling `fill` degrees (positive = ccw). +/// Without `rotate` each copy is moved so that `reference` travels around the circle. +fn polar_mats(center: Vec2, n: u64, fill: f64, rotate: bool, reference: Vec2) -> impl Iterator { + let total = fill.to_radians(); + let full = (total.abs() - TAU).abs() < 1e-9; + let step = if full { total / n.max(1) as f64 } else { total / n.saturating_sub(1).max(1) as f64 }; + (1..n).map(move |k| { + let a = step * k as f64; + if rotate { Mat3::rotate_about(center, a) } else { Mat3::translate(reference.rotate_about(center, a) - reference) } + }) +} + +fn bounds(d: &Drawing, hs: &[Handle]) -> Bounds2 { + hs.iter().filter_map(|h| d.entity(*h).map(|e| cadcraft_doc::entity_bounds(d, e, 0))).fold(Bounds2::EMPTY, |a, b| a.union(&b)) +} + +/// The centre of the first object (the point a non-rotated polar copy is moved by). +fn first_center(d: &Drawing, hs: &[Handle]) -> Option { + hs.first().and_then(|h| d.entity(*h)).map(|e| cadcraft_doc::entity_bounds(d, e, 0).center()).filter(|c| c.is_finite()) +} + +/// MAXARRAY: refuse an array of `items` copies of `objs` objects that would exceed the limit +/// (instead of creating fewer copies than asked for). +pub(crate) fn check_size(s: &Session, items: u64, objs: usize) -> Result<()> { + let max = s.settings.maxarray; + let total = items.saturating_mul(objs.max(1) as u64); + if total > max { + return Err(other(format!( + "The array would create {total} objects; the limit is {max} (MAXARRAY). Use fewer items or select fewer objects." + ))); + } + Ok(()) +} + +/// The most items a count prompt accepts when the rest of the array is `others` items of `objs` objects. +fn max_count(s: &Session, others: u64, objs: usize) -> u64 { + (s.settings.maxarray / others.max(1).saturating_mul(objs.max(1) as u64)).max(1) +} + +fn copy_all(s: &mut Session, hs: &[Handle], mats: impl Iterator) -> Result { + let mut n = 0; + for m in mats { + n += transform_entities(s, hs, &m, true)?.len(); + } + Ok(n) +} + +/// The objects transformed by each matrix, for the preview. +fn preview_copies(s: &Session, hs: &[Handle], mats: impl Iterator) -> Vec { + let Ok(d) = s.doc() else { return Vec::new() }; + let src: Vec = hs.iter().take(PREVIEW_LIMIT).filter_map(|h| d.entity(*h)).map(|e| e.kind.clone()).collect(); + if src.is_empty() { + return Vec::new(); + } + let mut out = Vec::new(); + for m in mats { + for k in &src { + if out.len() >= PREVIEW_LIMIT { + return out; + } + let mut k = k.clone(); + k.transform(&m); + out.push(k); + } + } + out +} + +fn other(m: impl Into) -> EngineError { + EngineError::Other(m.into()) +} + +fn fmt(v: f64) -> String { + format!("{v:.4}") +} + +/// A count typed at a prompt (Enter keeps `cur`); `None` for inputs the prompt ignores. +fn count(i: &Input, cur: u64, max: u64) -> Result> { + match i { + Input::Enter => Ok(Some(cur)), + Input::Text(t) => t + .trim() + .parse::() + .ok() + .filter(|v| v.fract() == 0.0 && *v >= 1.0 && *v <= max as f64) + .map(|v| Some(v as u64)) + .ok_or_else(|| other(format!("Requires an integer between 1 and {max} (MAXARRAY limits the size of the array)."))), + _ => Ok(None), + } +} + +/// An angle in degrees typed at a prompt (Enter keeps `cur`); `None` for inputs the prompt ignores. +fn angle(i: &Input, cur: f64) -> Result> { + match i { + Input::Enter => Ok(Some(cur)), + Input::Text(t) => crate::units::parse_angle(t).map(|a| Some(a.to_degrees())).ok_or_else(|| other("Requires a valid angle in degrees.")), + _ => Ok(None), + } +} + +fn yes_no(i: &Input, cur: bool) -> Option { + match i { + Input::Enter => Some(cur), + Input::Keyword(k) | Input::Text(k) => match k.trim().to_ascii_lowercase().chars().next() { + Some('y') => Some(true), + Some('n') => Some(false), + _ => None, + }, + _ => None, + } +} + +// ---------------- JSON ---------------- + +fn run_array(s: &mut Session, p: &Value) -> Result { + let id = match str_param(p, "type").unwrap_or("rectangular").to_ascii_lowercase().as_str() { + "rectangular" | "rect" | "r" => "arrayrect", + "polar" | "po" => "arraypolar", + "path" | "pa" => "arraypath", + _ => return Err(bad("array", "`type` must be \"rectangular\", \"polar\" or \"path\"")), + }; + let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; + (spec.run)(s, p) +} + +fn run_arrayrect(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(3).max(1); + let cols = p.get("cols").and_then(Value::as_u64).unwrap_or(4).max(1); + check_size(s, rows.saturating_mul(cols), hs.len())?; + let rs = f64_or(p, "rowSpacing", 1.0); + let cs = f64_or(p, "colSpacing", 1.0); + let n = copy_all(s, &hs, rect_mats(rows, cols, rs, cs))?; + Ok(json!({ "created": n })) +} + +fn run_arraypolar(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let c = point_req("arraypolar", p, "center")?; + let n = p.get("count").and_then(Value::as_u64).unwrap_or(6).max(1); + check_size(s, n, hs.len())?; + let fill = f64_or(p, "angle", 360.0); + let rotate = bool_or(p, "rotate", true); + let reference = first_center(s.doc()?, &hs).unwrap_or(c); + let n = copy_all(s, &hs, polar_mats(c, n, fill, rotate, reference))?; + Ok(json!({ "created": n })) +} + +// ---------------- ARRAY ---------------- + +/// ARRAY: select objects, choose the array type, then continue as ARRAYRECT, ARRAYPATH or +/// ARRAYPOLAR with those objects. +#[derive(Default)] +struct ArrayM { + sel: SelectPhase, + objs: Vec, + inner: Option>, +} + +impl Interactive for ArrayM { + fn name(&self) -> &'static str { + "ARRAY" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = SelectPhase::begin(s); + if self.sel.done { + self.objs = self.sel.picked.clone(); + } + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + match &self.inner { + Some(m) => m.prompt(s), + None if !self.sel.done => self.sel.prompt(), + None => Prompt::new("Enter array type", KEYWORDS).kw(&["Rectangular", "PAth", "POlar"]).default("Rectangular"), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(m) = &mut self.inner { + return m.input(s, i); + } + if !self.sel.done { + return match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Done), + SelOutcome::Done(hs) => { + self.objs = hs; + Ok(Step::Continue) + } + }; + } + let kind = match &i { + Input::Enter => "Rectangular", + Input::Keyword(k) => k.as_str(), + _ => return Err(other("Invalid option keyword.")), + }; + let objs = self.objs.clone(); + let (m, step): (Box, Step) = match kind { + "PAth" => (Box::new(super::modify2::PathArrayM::with_objects(objs)), Step::Continue), + "POlar" => { + let mut m = PolarM::with_objects(objs); + let step = m.ready(s); + (Box::new(m), step) + } + _ => { + let mut m = RectM::with_objects(objs); + let step = m.ready(s)?; + (Box::new(m), step) + } + }; + self.inner = Some(m); + Ok(step) + } + fn preview(&self, s: &Session, cursor: Vec2) -> Vec { + self.inner.as_ref().map(|m| m.preview(s, cursor)).unwrap_or_default() + } +} + +// ---------------- ARRAYRECT ---------------- + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum RStage { + #[default] + Main, + Base, + /// COUnt: columns, then rows. + CountCols, + CountRows, + /// Spacing: between columns (or a unit cell), then between rows. + SpaceCols, + SpaceRows, + UnitCell, + /// COLumns: count, then spacing (or the total). + ColsCount, + ColsSpace, + ColsTotal, + /// Rows: count, then spacing (or the total), then the elevation increment. + RowsCount, + RowsSpace, + RowsTotal, + RowsElev, +} + +/// ARRAYRECT: select objects, then edit columns/rows/spacing in a loop; eXit creates the copies. +#[derive(Default)] +struct RectM { + sel: SelectPhase, + objs: Vec, + stage: RStage, + cols: u64, + rows: u64, + cs: f64, + rs: f64, + /// Array base point (distances picked with a point are measured from it). + base: Vec2, + centroid: Vec2, + /// Unit cell: the first corner, once picked. + corner: Option, +} + +impl RectM { + fn with_objects(objs: Vec) -> Self { + RectM { sel: SelectPhase { picked: objs.clone(), done: true, ..SelectPhase::default() }, objs, ..RectM::default() } + } + + /// Objects are selected: set the defaults (4 columns × 3 rows, 1.5 × the selection size apart). + fn ready(&mut self, s: &mut Session) -> Result { + let b = bounds(s.doc()?, &self.objs); + let (w, h) = (b.width(), b.height()); + let size = |v: f64, o: f64| match (v.is_finite() && v > 1e-9, o.is_finite() && o > 1e-9) { + (true, _) => v * 1.5, + (false, true) => o * 1.5, + _ => 1.0, + }; + (self.cols, self.rows, self.cs, self.rs) = (4, 3, size(w, h), size(h, w)); + self.centroid = if b.center().is_finite() { b.center() } else { Vec2::ZERO }; + self.base = self.centroid; + self.stage = RStage::Main; + s.echo("Type = Rectangular Associative = No"); + Ok(Step::Continue) + } + + /// A distance typed or picked (from the base point); Enter keeps `cur`. + fn distance(&self, i: &Input, cur: f64) -> Result> { + let d = match i { + Input::Enter => return Ok(Some(cur)), + Input::Text(t) => number(t), + Input::Point(p) => Some(self.base.dist(*p)), + _ => return Ok(None), + }; + d.filter(|d| d.is_finite() && d.abs() > 1e-12).map(Some).ok_or_else(|| other("Requires a nonzero distance.")) + } + + fn finish(&mut self, s: &mut Session) -> Result { + check_size(s, self.rows.saturating_mul(self.cols), self.objs.len())?; + copy_all(s, &self.objs, rect_mats(self.rows, self.cols, self.rs, self.cs))?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } + + fn main(&mut self, s: &mut Session, i: Input) -> Result { + let k = match i { + Input::Enter => return self.finish(s), + Input::Keyword(k) => k, + Input::Point(_) => return Err(other("Editing the array with grips is not available yet; choose an option.")), + _ => return Err(other("Invalid option keyword.")), + }; + self.stage = match k.as_str() { + "eXit" => return self.finish(s), + "Base point" => RStage::Base, + "COUnt" => RStage::CountCols, + "Spacing" => RStage::SpaceCols, + "COLumns" => RStage::ColsCount, + "Rows" => RStage::RowsCount, + "Levels" => { + s.echo(NO_LEVELS); + RStage::Main + } + _ => { + s.echo(NOT_ASSOCIATIVE); + RStage::Main + } + }; + Ok(Step::Continue) + } +} + +impl Interactive for RectM { + fn name(&self) -> &'static str { + "ARRAYRECT" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = SelectPhase::begin(s); + if self.sel.done { + self.objs = self.sel.picked.clone(); + return self.ready(s); + } + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + use RStage::*; + if !self.sel.done { + return self.sel.prompt(); + } + let cols = || Prompt::new("Enter the number of columns", Accept::NUMBER).default(self.cols.to_string()); + let rows = || Prompt::new("Enter the number of rows", Accept::NUMBER).default(self.rows.to_string()); + let dist = |m: &str, d: f64| Prompt::new(m, Accept::POINT_OR_NUMBER).default(fmt(d)).base(self.base); + match self.stage { + Main => Prompt::new("Select grip to edit array", Accept::POINT) + .kw(&["ASsociative", "Base point", "COUnt", "Spacing", "COLumns", "Rows", "Levels", "eXit"]) + .default("eXit"), + Base => Prompt::new("Specify base point", Accept::POINT).kw(&["Key point"]).default("centroid"), + CountCols | ColsCount => cols(), + CountRows | RowsCount => rows(), + SpaceCols => dist("Specify the distance between columns", self.cs).kw(&["Unit cell"]), + SpaceRows => dist("Specify the distance between rows", self.rs), + UnitCell => match self.corner { + None => Prompt::new("Specify first corner of the unit cell", Accept::POINT).base(self.base), + Some(c) => Prompt::new("Specify other corner", Accept::POINT).base(c), + }, + ColsSpace => dist("Specify the distance between columns", self.cs).kw(&["Total"]), + ColsTotal => dist("Specify the distance between the first and last column", self.cs * self.cols.saturating_sub(1) as f64), + RowsSpace => dist("Specify the distance between rows", self.rs).kw(&["Total"]), + RowsTotal => dist("Specify the distance between the first and last row", self.rs * self.rows.saturating_sub(1) as f64), + RowsElev => Prompt::new("Specify the incrementing elevation between rows", Accept::NUMBER).default("0"), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + use RStage::*; + if !self.sel.done { + return match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Done), + SelOutcome::Done(hs) => { + self.objs = hs; + self.ready(s) + } + }; + } + let stage = self.stage; + let next = match (stage, &i) { + (Main, _) => return self.main(s, i), + (Base, Input::Point(p)) => { + self.base = *p; + Main + } + (Base, Input::Enter) => { + self.base = self.centroid; + Main + } + (Base, Input::Keyword(_)) => { + s.echo("Key points are not available yet; specify a point."); + Base + } + (CountCols | ColsCount, _) => match count(&i, self.cols, max_count(s, self.rows, self.objs.len()))? { + Some(n) => { + self.cols = n; + if stage == CountCols { CountRows } else { ColsSpace } + } + None => stage, + }, + (CountRows | RowsCount, _) => match count(&i, self.rows, max_count(s, self.cols, self.objs.len()))? { + Some(n) => { + self.rows = n; + if stage == CountRows { Main } else { RowsSpace } + } + None => stage, + }, + (SpaceCols, Input::Keyword(_)) => { + self.corner = None; + UnitCell + } + (ColsSpace, Input::Keyword(_)) => ColsTotal, + (RowsSpace, Input::Keyword(_)) => RowsTotal, + (SpaceCols | ColsSpace, _) => match self.distance(&i, self.cs)? { + Some(d) => { + self.cs = d; + if stage == SpaceCols { SpaceRows } else { Main } + } + None => stage, + }, + (SpaceRows | RowsSpace, _) => match self.distance(&i, self.rs)? { + Some(d) => { + self.rs = d; + if stage == SpaceRows { Main } else { RowsElev } + } + None => stage, + }, + (ColsTotal, _) => match self.distance(&i, self.cs * self.cols.saturating_sub(1) as f64)? { + Some(t) => { + self.cs = t / self.cols.saturating_sub(1).max(1) as f64; + Main + } + None => stage, + }, + (RowsTotal, _) => match self.distance(&i, self.rs * self.rows.saturating_sub(1) as f64)? { + Some(t) => { + self.rs = t / self.rows.saturating_sub(1).max(1) as f64; + RowsElev + } + None => stage, + }, + (RowsElev, Input::Enter) => Main, + (RowsElev, Input::Text(t)) => match number(t) { + Some(z) if z.is_finite() => { + if z.abs() > 1e-12 { + s.echo("Row elevation increments (3D) are not available yet; the rows stay at the same elevation."); + } + Main + } + _ => return Err(other("Requires a number.")), + }, + (UnitCell, Input::Point(p)) => match self.corner { + None => { + self.corner = Some(*p); + UnitCell + } + Some(c) => { + let (dx, dy) = (p.x - c.x, p.y - c.y); + if dx.abs() <= 1e-12 || dy.abs() <= 1e-12 { + return Err(other("The unit cell must have a width and a height.")); + } + (self.cs, self.rs, self.corner) = (dx, dy, None); + Main + } + }, + (UnitCell, Input::Enter) => { + self.corner = None; + Main + } + _ => stage, + }; + self.stage = next; + Ok(Step::Continue) + } + fn preview(&self, s: &Session, cursor: Vec2) -> Vec { + if !self.sel.done { + return Vec::new(); + } + let mut out = preview_copies(s, &self.objs, rect_mats(self.rows, self.cols, self.rs, self.cs)); + match (self.stage, self.corner) { + (RStage::UnitCell, Some(c)) => out.push(line(c, cursor)), + (RStage::SpaceCols | RStage::SpaceRows | RStage::ColsSpace | RStage::ColsTotal | RStage::RowsSpace | RStage::RowsTotal, _) => { + out.push(line(self.base, cursor)) + } + _ => {} + } + out + } +} + +// ---------------- ARRAYPOLAR ---------------- + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum PStage { + #[default] + Center, + /// Base point asked from the center prompt (returns there). + CenterBase, + Main, + Base, + Items, + Between, + Fill, + Rotate, +} + +/// ARRAYPOLAR: select objects, specify the center, then edit items/angles in a loop; eXit creates +/// the copies. +struct PolarM { + sel: SelectPhase, + objs: Vec, + stage: PStage, + center: Vec2, + items: u64, + /// Fill angle in degrees (positive = counterclockwise). + fill: f64, + rotate: bool, + /// Base point: the point non-rotated copies are moved by (default: the first object's centre). + base: Option, +} + +impl Default for PolarM { + fn default() -> Self { + PolarM { + sel: SelectPhase::default(), + objs: Vec::new(), + stage: PStage::Center, + center: Vec2::ZERO, + items: 6, + fill: 360.0, + rotate: true, + base: None, + } + } +} + +impl PolarM { + fn with_objects(objs: Vec) -> Self { + PolarM { sel: SelectPhase { picked: objs.clone(), done: true, ..SelectPhase::default() }, objs, ..PolarM::default() } + } + + fn ready(&mut self, s: &mut Session) -> Step { + self.stage = PStage::Center; + s.echo("Type = Polar Associative = No"); + Step::Continue + } + + fn full(&self) -> bool { + (self.fill.abs() - 360.0).abs() < 1e-9 + } + + /// The angle between items, derived from the item count and the fill angle. + fn between(&self) -> f64 { + match self.items { + 0 | 1 => self.fill, + n if self.full() => self.fill / n as f64, + n => self.fill / (n - 1) as f64, + } + } + + fn mats(&self, s: &Session, center: Vec2) -> impl Iterator { + let reference = self.base.or_else(|| s.doc().ok().and_then(|d| first_center(d, &self.objs))).unwrap_or(center); + polar_mats(center, self.items, self.fill, self.rotate, reference) + } + + fn finish(&mut self, s: &mut Session) -> Result { + check_size(s, self.items, self.objs.len())?; + let mats: Vec = self.mats(s, self.center).collect(); + copy_all(s, &self.objs, mats.into_iter())?; + s.set_selection(Vec::new()); + Ok(Step::Done) + } + + fn main(&mut self, s: &mut Session, i: Input) -> Result { + let k = match i { + Input::Enter => return self.finish(s), + Input::Keyword(k) => k, + Input::Point(_) => return Err(other("Editing the array with grips is not available yet; choose an option.")), + _ => return Err(other("Invalid option keyword.")), + }; + self.stage = match k.as_str() { + "eXit" => return self.finish(s), + "Base point" => PStage::Base, + "Items" => PStage::Items, + "Angle between" => PStage::Between, + "Fill angle" => PStage::Fill, + "ROTate items" => PStage::Rotate, + "ROWs" => { + s.echo("Rows in polar arrays are not available yet."); + PStage::Main + } + "Levels" => { + s.echo(NO_LEVELS); + PStage::Main + } + _ => { + s.echo(NOT_ASSOCIATIVE); + PStage::Main + } + }; + Ok(Step::Continue) + } +} + +impl Interactive for PolarM { + fn name(&self) -> &'static str { + "ARRAYPOLAR" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = SelectPhase::begin(s); + if self.sel.done { + self.objs = self.sel.picked.clone(); + return Ok(self.ready(s)); + } + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + use PStage::*; + if !self.sel.done { + return self.sel.prompt(); + } + match self.stage { + Center => Prompt::new("Specify center point of array", Accept::POINT).kw(&["Base point", "Axis of rotation"]), + CenterBase | Base => Prompt::new("Specify base point", Accept::POINT).kw(&["Key point"]).default("centroid"), + Main => Prompt::new("Select grip to edit array", Accept::POINT) + .kw(&["ASsociative", "Base point", "Items", "Angle between", "Fill angle", "ROWs", "Levels", "ROTate items", "eXit"]) + .default("eXit"), + Items => Prompt::new("Enter number of items in array", Accept::NUMBER).default(self.items.to_string()), + Between => Prompt::new("Specify the angle between items", Accept::NUMBER).default(fmt(self.between())), + Fill => Prompt::new("Specify the angle to fill (+=ccw, -=cw)", Accept::NUMBER).default(fmt(self.fill)), + Rotate => Prompt::new("Rotate arrayed items?", KEYWORDS).kw(&["Yes", "No"]).default(if self.rotate { "Yes" } else { "No" }), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + use PStage::*; + if !self.sel.done { + return match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Done), + SelOutcome::Done(hs) => { + self.objs = hs; + Ok(self.ready(s)) + } + }; + } + let stage = self.stage; + let next = match (stage, &i) { + (Main, _) => return self.main(s, i), + (Center, Input::Point(p)) => { + self.center = *p; + Main + } + (Center, Input::Keyword(k)) if k == "Base point" => CenterBase, + (Center, Input::Keyword(_)) => { + s.echo("A rotation axis (3D) is not available yet; specify the center point."); + Center + } + (Center, _) => return Err(other("Requires a point or an option keyword.")), + (CenterBase | Base, Input::Point(p)) => { + self.base = Some(*p); + if stage == CenterBase { Center } else { Main } + } + (CenterBase | Base, Input::Enter) => { + self.base = None; + if stage == CenterBase { Center } else { Main } + } + (CenterBase | Base, Input::Keyword(_)) => { + s.echo("Key points are not available yet; specify a point."); + stage + } + (Items, _) => match count(&i, self.items, max_count(s, 1, self.objs.len()))? { + Some(n) => { + self.items = n; + Main + } + None => stage, + }, + (Between, Input::Enter) => Main, + (Between, _) => match angle(&i, self.between())? { + Some(b) => { + if b.abs() < 1e-9 { + return Err(other("Requires a nonzero angle.")); + } + let n = self.items as f64; + let fill = if (b.abs() * n - 360.0).abs() < 1e-9 { 360.0 * b.signum() } else { b * (n - 1.0).max(1.0) }; + if fill.abs() > 360.0 + 1e-9 { + return Err(other("The items would span more than 360 degrees; enter a smaller angle or fewer items.")); + } + self.fill = fill; + Main + } + None => stage, + }, + (Fill, _) => match angle(&i, self.fill)? { + Some(f) => { + if f.abs() < 1e-9 || f.abs() > 360.0 + 1e-9 { + return Err(other("Requires a fill angle between -360 and 360 degrees (not 0).")); + } + self.fill = f.clamp(-360.0, 360.0); + Main + } + None => stage, + }, + (Rotate, _) => match yes_no(&i, self.rotate) { + Some(r) => { + self.rotate = r; + Main + } + None => return Err(other("Requires Yes or No.")), + }, + _ => stage, + }; + self.stage = next; + Ok(Step::Continue) + } + fn preview(&self, s: &Session, cursor: Vec2) -> Vec { + if !self.sel.done { + return Vec::new(); + } + let center = match self.stage { + PStage::Center | PStage::CenterBase => cursor, + _ => self.center, + }; + preview_copies(s, &self.objs, self.mats(s, center)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn polar_angles() { + // Full circle: 4 items 90° apart; partial: the last item sits at the fill angle. + let a: Vec = polar_mats(Vec2::ZERO, 4, 360.0, true, Vec2::X).map(|m| m.apply(Vec2::X)).collect(); + assert_eq!(a.len(), 3); + assert!(a[0].near(Vec2::new(0.0, 1.0), 1e-9) && a[2].near(Vec2::new(0.0, -1.0), 1e-9)); + let b: Vec = polar_mats(Vec2::ZERO, 3, -180.0, true, Vec2::X).map(|m| m.apply(Vec2::X)).collect(); + assert!(b[0].near(Vec2::new(0.0, -1.0), 1e-9) && b[1].near(Vec2::new(-1.0, 0.0), 1e-9)); + // A full clockwise circle does not put the last item on top of the source. + let c: Vec = polar_mats(Vec2::ZERO, 4, -360.0, true, Vec2::X).map(|m| m.apply(Vec2::X)).collect(); + assert!(c[2].near(Vec2::new(0.0, 1.0), 1e-9)); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ec136ce1..7dad164d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -2,6 +2,7 @@ //! AutoCAD's menu bar so the catalog doubles as the parity metric. mod annotate; +mod array; mod blocks; pub mod constraints; mod draw; @@ -157,6 +158,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(hatch::specs()); v.extend(blocks::specs()); v.extend(modify::specs()); + v.extend(array::specs()); v.extend(modify2::specs()); v.extend(gripcmds::specs()); v.extend(layer::specs()); diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 32c11792..f76d2074 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1,5 +1,5 @@ //! Modify commands: ERASE, MOVE, COPY, ROTATE, SCALE, MIRROR, OFFSET, TRIM, EXTEND, FILLET, -//! CHAMFER, EXPLODE, STRETCH, ARRAY, DRAWORDER, BREAK, JOIN. +//! CHAMFER, EXPLODE, STRETCH, DRAWORDER, BREAK, JOIN (arrays: `array`). use cadcraft_doc::{Entity, EntityKind, Handle, Prim}; use cadcraft_geom::{ @@ -79,14 +79,6 @@ pub fn specs() -> Vec { .alias(&["x"]) .params("{handles?}") .interactive(|_| Ok(Box::new(SelectThen::new(Op::Explode)))), - CommandSpec::new("arrayrect", "Rectangular Array", run_arrayrect) - .menu(&["Modify", "Array", "Rectangular Array"]) - .params("{handles?, rows, cols, rowSpacing, colSpacing}") - .interactive(|_| Ok(Box::new(SelectThen::new(Op::ArrayRect)))), - CommandSpec::new("arraypolar", "Polar Array", run_arraypolar) - .menu(&["Modify", "Array", "Polar Array"]) - .params("{handles?, center, count, angle? (degrees, default 360), rotate?: bool}") - .interactive(|_| Ok(Box::new(SelectThen::new(Op::ArrayPolar)))), CommandSpec::new("draworder.front", "Bring to Front", run_front) .menu(&["Tools", "Draw Order", "Bring to Front"]) .params("{handles?}") @@ -1015,47 +1007,6 @@ fn run_explode(s: &mut Session, p: &Value) -> Result { Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) } -fn run_arrayrect(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(3).clamp(1, 1000); - let cols = p.get("cols").and_then(Value::as_u64).unwrap_or(4).clamp(1, 1000); - let rs = f64_or(p, "rowSpacing", 1.0); - let cs = f64_or(p, "colSpacing", 1.0); - let mut out = Vec::new(); - for r in 0..rows { - for c in 0..cols { - if r == 0 && c == 0 { - continue; - } - out.extend(transform_entities(s, &hs, &Mat3::translate(Vec2::new(cs * c as f64, rs * r as f64)), true)?); - } - } - Ok(json!({ "created": out.len() })) -} - -fn run_arraypolar(s: &mut Session, p: &Value) -> Result { - let hs = targets(s, p)?; - let c = point_req("arraypolar", p, "center")?; - let n = p.get("count").and_then(Value::as_u64).unwrap_or(6).clamp(1, 10_000); - let total = f64_or(p, "angle", 360.0).to_radians(); - let rotate = bool_or(p, "rotate", true); - let step = if (total - TAU).abs() < 1e-9 { total / n as f64 } else { total / (n.saturating_sub(1).max(1)) as f64 }; - let mut out = Vec::new(); - for k in 1..n { - let a = step * k as f64; - let m = if rotate { - Mat3::rotate_about(c, a) - } else { - // Translate only: move the reference point around the circle. - let d = s.doc()?; - let r = hs.first().and_then(|h| d.entity(*h)).map(|e| cadcraft_doc::entity_bounds(d, e, 0).center()).unwrap_or(c); - Mat3::translate(r.rotate_about(c, a) - r) - }; - out.extend(transform_entities(s, &hs, &m, true)?); - } - Ok(json!({ "created": out.len() })) -} - fn run_front(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; let space = s.space(); @@ -1298,8 +1249,6 @@ enum Op { Mirror, Stretch, Explode, - ArrayRect, - ArrayPolar, Join, } @@ -1364,22 +1313,6 @@ impl SelectThen { } Ok(Step::Done) } - Op::ArrayRect => { - let objs = self.objs.clone(); - let d = s.doc()?; - let b = objs - .iter() - .filter_map(|h| d.entity(*h).map(|e| cadcraft_doc::entity_bounds(d, e, 0))) - .fold(cadcraft_geom::Bounds2::EMPTY, |a, b| a.union(&b)); - let cs = b.width() * 1.5 + 1e-9; - let rs = b.height() * 1.5 + 1e-9; - run_arrayrect( - s, - &json!({ "handles": objs.iter().map(|h| h.hex()).collect::>(), "rows": 3, "cols": 4, "rowSpacing": rs, "colSpacing": cs }), - )?; - s.echo("Type = Rectangular Associative = No (3 rows × 4 columns)"); - Ok(Step::Done) - } _ => Ok(Step::Continue), } } @@ -1525,8 +1458,6 @@ impl Interactive for SelectThen { Op::Mirror => "MIRROR", Op::Stretch => "STRETCH", Op::Explode => "EXPLODE", - Op::ArrayRect => "ARRAYRECT", - Op::ArrayPolar => "ARRAYPOLAR", Op::Join => "JOIN", } } @@ -1580,8 +1511,6 @@ impl Interactive for SelectThen { (Op::Scale, _) => Prompt::new("Specify scale factor", Accept::POINT_OR_NUMBER).kw(&["Copy", "Reference"]).base_opt(base), (Op::Mirror, 0) => Prompt::new("Specify first point of mirror line", Accept::POINT), (Op::Mirror, _) => Prompt::new("Specify second point of mirror line", Accept::POINT).base_opt(base), - (Op::ArrayPolar, 0) => Prompt::new("Specify center point of array", Accept::POINT).kw(&["Base point", "Axis of rotation"]), - (Op::ArrayPolar, _) => Prompt::new("Enter number of items", Accept::NUMBER).default("6"), _ => Prompt::new("", Accept::POINT), } } @@ -1714,19 +1643,6 @@ impl Interactive for SelectThen { self.ask_erase = true; Ok(Step::Continue) } - (Op::ArrayPolar, 1, inp) => { - let n = match inp { - Input::Text(t) => { - t.trim().parse::().ok().filter(|n| *n >= 1).ok_or_else(|| EngineError::Other("Requires a positive integer.".into()))? - } - Input::Enter => 6, - _ => return Ok(Step::Continue), - }; - let objs: Vec = self.objs.iter().map(|h| h.hex()).collect(); - run_arraypolar(s, &json!({ "handles": objs, "center": [self.pts[0].x, self.pts[0].y], "count": n }))?; - s.set_selection(Vec::new()); - Ok(Step::Done) - } (_, _, Input::Enter) => Ok(Step::Done), _ => Ok(Step::Continue), } diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index 70f8f3fd..0ca53197 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -2126,13 +2126,14 @@ pub(crate) fn arraypath( return Err(other("Select objects to array.")); } let (n, step) = match (count, spacing) { - (_, Some(sp)) if sp.is_finite() && sp > 0.0 => (((l / sp).floor() as usize + 1).clamp(1, MAX_GEN), sp), + (_, Some(sp)) if sp.is_finite() && sp > 0.0 => (((l / sp).floor() as usize).saturating_add(1), sp), (Some(n), _) => { - let n = n.clamp(1, MAX_GEN); + let n = n.max(1); (n, if c.closed { l / n as f64 } else { l / (n.max(2) - 1) as f64 }) } _ => (6, if c.closed { l / 6.0 } else { l / 5.0 }), }; + super::array::check_size(s, n as u64, objs.len())?; let Some((p0, t0)) = c.at_length(0.0) else { return Ok(Vec::new()) }; let mut out = Vec::new(); for i in 1..n { @@ -2149,19 +2150,26 @@ pub(crate) fn arraypath( fn run_arraypath(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; let path = curves::handle_param(p, "path").ok_or_else(|| bad("arraypath", "`path` (handle) is required"))?; - let count = p.get("count").and_then(Value::as_u64).map(|n| n.min(MAX_GEN as u64) as usize); + let count = p.get("count").and_then(Value::as_u64).map(|n| usize::try_from(n).unwrap_or(usize::MAX)); let spacing = p.get("spacing").and_then(Value::as_f64); let r = arraypath(s, &hs, path, count, spacing, bool_or(p, "align", true))?; Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) } #[derive(Default)] -struct PathArrayM { +pub(super) struct PathArrayM { sel: SelectPhase, objs: Vec, path: Option, } +impl PathArrayM { + /// Start at the path prompt with the objects already selected (ARRAY > PAth). + pub(super) fn with_objects(objs: Vec) -> Self { + PathArrayM { sel: SelectPhase { picked: objs.clone(), done: true, ..SelectPhase::default() }, objs, path: None } + } +} + impl Interactive for PathArrayM { fn name(&self) -> &'static str { "ARRAYPATH" @@ -2202,9 +2210,7 @@ impl Interactive for PathArrayM { } (Some(path), inp @ (Input::Text(_) | Input::Enter)) => { let n = match &inp { - Input::Text(t) => { - number(t).filter(|n| *n >= 1.0 && *n <= MAX_GEN as f64).ok_or_else(|| other("Requires a count of 1 or more."))? as usize - } + Input::Text(t) => number(t).filter(|n| *n >= 1.0).ok_or_else(|| other("Requires a count of 1 or more."))? as usize, _ => 6, }; arraypath(s, &self.objs, path, Some(n), None, true)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0172c266..456e2b79 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -239,6 +239,8 @@ pub struct Settings { pub pickadd: bool, pub gripsize: f64, pub cursorsize: f64, + /// MAXARRAY: the most objects one array command creates (items × selected objects). + pub maxarray: u64, pub isodraft: bool, pub annoallvisible: bool, pub annoautoscale: bool, @@ -271,6 +273,7 @@ impl Default for Settings { pickadd: true, gripsize: 5.0, cursorsize: 5.0, + maxarray: 100_000, isodraft: false, annoallvisible: true, annoautoscale: false, diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index 70fdc916..8e1048a3 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -25,6 +25,7 @@ const SESSION_VARS: &[&str] = &[ "PICKADD", "GRIPSIZE", "CURSORSIZE", + "MAXARRAY", "LASTPOINT", "FONTALT", "FONTFALLBACK", @@ -54,6 +55,7 @@ pub fn get(s: &Session, name: &str) -> Option { "PICKADD" => json!(i32::from(st.pickadd)), "GRIPSIZE" => json!(st.gripsize), "CURSORSIZE" => json!(st.cursorsize), + "MAXARRAY" => json!(st.maxarray), "LASTPOINT" => json!([s.last_point.x, s.last_point.y, 0.0]), // Process-wide font substitution (profile settings, not saved in the drawing). "FONTALT" => json!(cadcraft_fonts::ttf::font_alt()), @@ -145,6 +147,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { "PICKADD" => st.pickadd = as_bool(v).ok_or_else(bad)?, "GRIPSIZE" => st.gripsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 255.0), "CURSORSIZE" => st.cursorsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 100.0), + "MAXARRAY" => st.maxarray = as_f64(v).ok_or_else(bad)?.clamp(100.0, 10_000_000.0) as u64, "FONTALT" => cadcraft_fonts::ttf::set_font_alt(v.as_str().ok_or_else(bad)?), "FONTFALLBACK" => cadcraft_fonts::ttf::set_fallback_fonts(v.as_str().ok_or_else(bad)?), _ => { diff --git a/crates/engine/tests/array_commands.rs b/crates/engine/tests/array_commands.rs new file mode 100644 index 00000000..9c84acea --- /dev/null +++ b/crates/engine/tests/array_commands.rs @@ -0,0 +1,201 @@ +//! ARRAY, ARRAYRECT and ARRAYPOLAR at the command line: the type prompt and the edit-array +//! option loops. Each input is its own `cmdline` call (one typed line each). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use cadcraft_engine::geom::Vec2; +use serde_json::json; + +fn typed(s: &mut Session, inputs: &[&str]) { + for t in inputs { + s.cmdline(t).unwrap(); + } +} + +fn circle_centers(s: &Session) -> Vec { + s.doc() + .unwrap() + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Circle(c) => Some(c.center.xy()), + _ => None, + }) + .collect() +} + +fn has(pts: &[Vec2], x: f64, y: f64) -> bool { + pts.iter().any(|p| p.near(Vec2::new(x, y), 1e-6)) +} + +fn prompt(s: &Session) -> String { + s.prompt_text() +} + +#[test] +fn array_asks_for_the_type_and_continues_as_that_array() { + // ARRAY ▸ POlar. + let mut s = Session::new(); + s.execute("circle", &json!({"center": [10, 0], "radius": 1})).unwrap(); + typed(&mut s, &["array", "l", ""]); + assert!(prompt(&s).contains("Enter array type or [Rectangular/PAth/POlar] "), "{}", prompt(&s)); + typed(&mut s, &["po", "0,0", "i", "4", "x"]); + assert!(s.current_prompt().is_none(), "{}", prompt(&s)); + let c = circle_centers(&s); + assert_eq!(c.len(), 4); + assert!(has(&c, 0.0, 10.0) && has(&c, -10.0, 0.0) && has(&c, 0.0, -10.0)); + + // AR (alias), Enter takes the default Rectangular. + let mut s = Session::new(); + s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap(); + typed(&mut s, &["ar", "l", "", ""]); + assert!(prompt(&s).contains("COUnt"), "{}", prompt(&s)); + typed(&mut s, &["cou", "2", "1", "s", "5", "5", ""]); + assert!(s.current_prompt().is_none(), "{}", prompt(&s)); + let c = circle_centers(&s); + assert_eq!(c.len(), 2); + assert!(has(&c, 5.0, 0.0)); + + // ARRAY ▸ PAth continues at the path prompt with the selected objects. + let mut s = Session::new(); + s.execute("circle", &json!({"center": [0, 1], "radius": 0.25})).unwrap(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + typed(&mut s, &["array", "0.25,1", "", "pa", "5,0", "3"]); + assert!(s.current_prompt().is_none(), "{}", prompt(&s)); + assert_eq!(circle_centers(&s).len(), 3); + + // The JSON form dispatches on `type`. + let mut s = Session::new(); + let r = s.execute("circle", &json!({"center": [10, 0], "radius": 1})).unwrap(); + let h = r["handle"].clone(); + let r = s.execute("array", &json!({"type": "polar", "handles": [h], "center": [0, 0], "count": 3})).unwrap(); + assert_eq!(r["created"], 2); + assert!(s.execute("array", &json!({"type": "spiral", "handles": [h]})).is_err()); +} + +#[test] +fn arrayrect_edits_the_array_in_a_loop_until_exit() { + let mut s = Session::new(); + s.execute("circle", &json!({"center": [0, 0], "radius": 0.5})).unwrap(); + typed(&mut s, &["arrayrect", "l", ""]); + let p = prompt(&s); + assert!(p.contains("Select grip to edit array or [ASsociative/Base point/COUnt/Spacing/COLumns/Rows/Levels/eXit] "), "{p}"); + // Nothing is created before eXit. + assert_eq!(circle_centers(&s).len(), 1); + typed(&mut s, &["cou", "2", "3", "s", "10", "20"]); + // Unavailable options report and return to the loop without taking the next input. + typed(&mut s, &["as", "l", "cou", "2", "3"]); + assert!(prompt(&s).contains("Select grip to edit array"), "{}", prompt(&s)); + typed(&mut s, &["x"]); + assert!(s.current_prompt().is_none(), "{}", prompt(&s)); + let c = circle_centers(&s); + assert_eq!(c.len(), 6, "{c:?}"); + for (x, y) in [(10.0, 0.0), (0.0, 20.0), (10.0, 40.0)] { + assert!(has(&c, x, y), "{c:?}"); + } + + // COLumns with a Total distance, Rows with spacing and elevation. + let mut s = Session::new(); + s.execute("circle", &json!({"center": [0, 0], "radius": 0.5})).unwrap(); + typed(&mut s, &["arrayrect", "l", "", "col", "3", "t", "20", "r", "2", "5", "0", "x"]); + assert!(s.current_prompt().is_none(), "{}", prompt(&s)); + let c = circle_centers(&s); + assert_eq!(c.len(), 6, "{c:?}"); + assert!(has(&c, 20.0, 0.0) && has(&c, 10.0, 5.0), "{c:?}"); + + // Invalid counts re-prompt. + let mut s = Session::new(); + s.execute("circle", &json!({"center": [0, 0], "radius": 0.5})).unwrap(); + typed(&mut s, &["arrayrect", "l", "", "cou", "0"]); + assert!(prompt(&s).contains("number of columns"), "{}", prompt(&s)); + typed(&mut s, &["2", "1", "x"]); + assert_eq!(circle_centers(&s).len(), 2); + + // The JSON form is unchanged. + let mut s = Session::new(); + let r = s.execute("circle", &json!({"center": [0, 0], "radius": 0.5})).unwrap(); + let r = s.execute("arrayrect", &json!({"handles": [r["handle"]], "rows": 2, "cols": 3, "rowSpacing": 4, "colSpacing": 2})).unwrap(); + assert_eq!(r["created"], 5); + assert!(has(&circle_centers(&s), 4.0, 4.0)); +} + +#[test] +fn arraypolar_asks_for_the_center_then_edits_items_and_angles() { + let mut s = Session::new(); + s.execute("circle", &json!({"center": [10, 0], "radius": 1})).unwrap(); + typed(&mut s, &["arraypolar", "l", ""]); + assert!(prompt(&s).contains("Specify center point of array or [Base point/Axis of rotation]"), "{}", prompt(&s)); + typed(&mut s, &["0,0"]); + let p = prompt(&s); + assert!( + p.contains("Select grip to edit array or [ASsociative/Base point/Items/Angle between/Fill angle/ROWs/Levels/ROTate items/eXit] "), + "{p}" + ); + typed(&mut s, &["i", "4", "x"]); + assert!(s.current_prompt().is_none(), "{}", prompt(&s)); + // X ended ARRAYPOLAR; it did not start EXPLODE. + assert!(!s.log.iter().any(|l| l.contains("EXPLODE"))); + let c = circle_centers(&s); + assert_eq!(c.len(), 4); + assert!(has(&c, 0.0, 10.0) && has(&c, -10.0, 0.0) && has(&c, 0.0, -10.0)); + + // Angle between with 4 items: 30° apart, filling 90°. + let mut s = Session::new(); + s.execute("circle", &json!({"center": [10, 0], "radius": 1})).unwrap(); + s.script("ARRAYPOLAR L\n\n0,0 I 4 A 30 X\n").unwrap(); + assert!(s.current_prompt().is_none(), "{}", prompt(&s)); + let c = circle_centers(&s); + assert_eq!(c.len(), 4); + assert!(has(&c, 0.0, 10.0) && !has(&c, -10.0, 0.0), "{c:?}"); + + // Fill angle 180 with 3 items, items not rotated: the copies only move. + let mut s = Session::new(); + s.execute("line", &json!({"points": [[10, 0], [12, 0]]})).unwrap(); + typed(&mut s, &["arraypolar", "l", "", "0,0", "i", "3", "f", "180", "rot", "n", "x"]); + assert!(s.current_prompt().is_none(), "{}", prompt(&s)); + let lines: Vec<(Vec2, Vec2)> = s + .doc() + .unwrap() + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Line(l) => Some((l.a.xy(), l.b.xy())), + _ => None, + }) + .collect(); + assert_eq!(lines.len(), 3); + // The middle copy: the line's centre (11,0) moved to (0,11), still horizontal. + assert!(lines.iter().any(|(a, b)| a.near(Vec2::new(-1.0, 11.0), 1e-6) && b.near(Vec2::new(1.0, 11.0), 1e-6)), "{lines:?}"); +} + +#[test] +fn arrays_over_maxarray_are_refused_not_shrunk() { + // JSON: a million × a million rows/cols (or items) is refused up front, nothing is created. + let mut s = Session::new(); + let c = s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap(); + let h = c["handle"].clone(); + let before = s.doc().unwrap().model.iter().count(); + let e = s.execute("arrayrect", &json!({"handles": [h], "rows": 1_000_000, "cols": 1_000_000})).unwrap_err(); + assert!(e.to_string().contains("MAXARRAY"), "{e}"); + assert!(s.execute("arraypolar", &json!({"handles": [h], "center": [5, 5], "count": 200_000})).unwrap_err().to_string().contains("MAXARRAY")); + let path = s.execute("line", &json!({"points": [[0, 5], [100, 5]]})).unwrap()["handles"][0].clone(); + let e = s.execute("arraypath", &json!({"handles": [h], "path": path, "spacing": 1e-6})).unwrap_err(); + assert!(e.to_string().contains("MAXARRAY"), "{e}"); + assert_eq!(s.doc().unwrap().model.iter().count(), before + 1); + + // The limit counts every selected object, and MAXARRAY is a settable system variable. + s.execute("setvar", &json!({"name": "MAXARRAY", "value": 100})).unwrap(); + let h2 = s.execute("circle", &json!({"center": [3, 0], "radius": 1})).unwrap()["handle"].clone(); + assert!(s.execute("arrayrect", &json!({"handles": [h, h2], "rows": 10, "cols": 6})).is_err()); + assert_eq!(s.execute("arrayrect", &json!({"handles": [h, h2], "rows": 10, "cols": 5})).unwrap()["created"], 98); + + // Interactive: a count over the limit is rejected at its prompt and the loop keeps going. + let mut s = Session::new(); + s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap(); + typed(&mut s, &["arrayrect", "l", "", "cou", "1000", "1000"]); + assert!(s.log.iter().any(|l| l.contains("MAXARRAY")), "{:?}", s.log); + assert!(prompt(&s).contains("rows"), "{}", prompt(&s)); + typed(&mut s, &["2", ""]); + assert!(s.current_prompt().is_none(), "{}", prompt(&s)); + assert_eq!(circle_centers(&s).len(), 2000); +} diff --git a/docs/gaps.md b/docs/gaps.md index c65b0c68..4af8bc88 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -32,7 +32,7 @@ do them first. | F2 | **[alpha blocker]** No xrefs (XATTACH, XREF palette, REFEDIT, BIND, XCLIP, overlay, paths); DXF xref blocks read with an empty path | `dxf_read.rs:923` | High: multi-file projects | 30–45 | | F3 | **[alpha blocker]** (block editor and REFEDIT; dynamic-block authoring is beta) No block editor (BEDIT) or dynamic blocks (parameters, actions, visibility, lookup); dynamic blocks from other files lose their behaviour | issue #185 offers help | High: standard libraries are dynamic | 50–75 | | F4 | No autosave (SAVETIME), crash recovery files, AUDIT, RECOVER, drawing recovery manager | no SAVETIME anywhere | High: lost work | 10–15 | -| F5 | Arrays are not associative: no ARRAYEDIT; the interactive rectangular array always makes 3×4 at 1.5× bounds | `modify.rs:1356-1360` | Medium-high | 10–15 | +| F5 | Arrays are not associative: no ARRAYEDIT; ARRAY/ARRAYRECT/ARRAYPOLAR edit the array in an option loop, then create plain copies (ASsociative, Levels, polar ROWs and grip editing print "not available yet") | `cmd/array.rs` | Medium-high | 8–12 | | F6 | FILLET/CHAMFER/JOIN/LENGTHEN/EXTEND don't handle ellipses and splines; FILLET Trim/Multiple and polyline segment pairs; CHAMFER Angle/Method | [geometry-parity](geometry-parity.md#trim-extend-fillet-chamfer-break-join-lengthen--45) | Medium | 10–15 | | F7 | Dimensions: no DIMSTYLE manager dialog; 43 of ~80 DIMSTYLE variables (DIMTOFL, DIMATFIT, DIMTMOVE, DIMLTYPE, DIMLWD, DIMFXL, DIMTOLJ, DIMALTU …); no smart DIM; no DIMJOGGED, DIMJOGLINE, DIMBREAK, oblique DIMEDIT, inspection; QDIM continuous only (SETVAR DIM* now become style overrides, #129) | `doc/src/tables.rs:223-266`, `annotate.rs:64,364` | High for detailers | 25–35 | | F8 | No TOLERANCE (GD&T feature control frames) | menu "Dimension > Tolerance..." uncovered | Medium (mechanical) | 4–6 | From f5073a301e4bdc6b3d64f495e9a3003544eea374 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:10:51 +0200 Subject: [PATCH 069/150] Guard every step of an interactive command against panics (#283) (#284) Session::execute and interactive input already ran under catch_unwind; the machine factory, begin (the first step of Session::start), prompt and preview (called by the UI every frame) did not, so a panic there took down the app. Session::start now wraps each machine in guard::Guarded, which runs all of its steps under catch_unwind. A panic in the factory, begin or input ends the command with an internal error and the session restores the drawing, as for input. prompt and preview can't end the command, so they fall back to an "Internal error" prompt and no preview, and the next input ends the command the same way. AGENTS.md/CLAUDE.md name the wider guard. Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 2 +- CLAUDE.md | 2 +- crates/engine/src/guard.rs | 176 +++++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 3 +- 4 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/guard.rs diff --git a/AGENTS.md b/AGENTS.md index c9c3596c..3f145f4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr - **Errors are `Result`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics. - **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts. - **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`). -- **Last-resort guard:** `Session::execute` and interactive command input run under `catch_unwind`; an escaped panic restores the drawing and reports an error. +- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt` and `preview`; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error. - **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`, `hostile_dxf_does_not_panic`). - Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. diff --git a/CLAUDE.md b/CLAUDE.md index 1ed44c1f..4ab0e3c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr - **Errors are `Result`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics. - **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts. - **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`). -- **Last-resort guard:** `Session::execute` and interactive command input run under `catch_unwind`; an escaped panic restores the drawing and reports an error. +- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt` and `preview`; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error. - **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`, `hostile_dxf_does_not_panic`). - Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs new file mode 100644 index 00000000..39a2e6c7 --- /dev/null +++ b/crates/engine/src/guard.rs @@ -0,0 +1,176 @@ +//! Last-resort panic guard around interactive commands. `Session::execute` and interactive input +//! already catch a panicking command; this wrapper does the same for the rest of a command +//! machine: its factory, `begin` (the first step of `Session::start`), `prompt` and `preview` +//! (called by the UI every frame). A panic ends the command with an internal error instead of +//! taking down the app: from `begin` or `input` at once (the session restores the drawing), from +//! `prompt` or `preview` (which can't end the command) at the next input, with a fallback prompt +//! and no preview until then. + +use std::any::Any; +use std::cell::Cell; +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use cadcraft_doc::EntityKind; +use cadcraft_geom::Vec2; + +use crate::cmd::Factory; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +pub(crate) struct Guarded { + inner: Box, + /// What panicked in `prompt` or `preview`; the command ends at the next input. + broken: Cell>, +} + +fn message(p: &(dyn Any + Send)) -> String { + p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()) +} + +impl Guarded { + /// Create the machine of command `id` and wrap it. + pub(crate) fn create(id: &str, factory: Factory, s: &Session) -> Result> { + let inner = catch_unwind(AssertUnwindSafe(|| factory(s))).map_err(|p| EngineError::Internal(id.into(), message(&*p)))??; + Ok(Box::new(Guarded { inner, broken: Cell::new(None) })) + } + + fn internal(&self, p: &(dyn Any + Send)) -> EngineError { + EngineError::Internal(self.inner.name().to_ascii_lowercase(), message(p)) + } +} + +impl Interactive for Guarded { + fn name(&self) -> &'static str { + self.inner.name() + } + fn prompt(&self, s: &Session) -> Prompt { + if self.broken.get().is_none() { + match catch_unwind(AssertUnwindSafe(|| self.inner.prompt(s))) { + Ok(p) => return p, + Err(_) => self.broken.set(Some("panic in prompt")), + } + } + Prompt::new("Internal error; press Enter to end the command", Accept::TEXT) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(m) = self.broken.get() { + return Err(EngineError::Internal(self.inner.name().to_ascii_lowercase(), m.into())); + } + catch_unwind(AssertUnwindSafe(|| self.inner.input(s, i))).unwrap_or_else(|p| Err(self.internal(&*p))) + } + fn begin(&mut self, s: &mut Session) -> Result { + catch_unwind(AssertUnwindSafe(|| self.inner.begin(s))).unwrap_or_else(|p| Err(self.internal(&*p))) + } + fn preview(&self, s: &Session, cursor: Vec2) -> Vec { + if self.broken.get().is_some() { + return Vec::new(); + } + catch_unwind(AssertUnwindSafe(|| self.inner.preview(s, cursor))).unwrap_or_else(|_| { + self.broken.set(Some("panic in preview")); + Vec::new() + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Running; + + fn dot(p: Vec2) -> EntityKind { + EntityKind::Point(cadcraft_doc::Point { p: p.to3(0.0), angle: 0.0 }) + } + + /// Panics in the step named by `at` ("factory", "begin", "prompt", "preview" or "input"). + struct PanicM(&'static str); + + impl Interactive for PanicM { + fn name(&self) -> &'static str { + "PANICTEST" + } + fn prompt(&self, _s: &Session) -> Prompt { + assert_ne!(self.0, "prompt", "prompt panicked"); + Prompt::new("Specify a point", Accept::POINT) + } + fn input(&mut self, s: &mut Session, _i: Input) -> Result { + s.add_entity(dot(Vec2::ZERO))?; + assert_ne!(self.0, "input", "input panicked"); + Ok(Step::Continue) + } + fn begin(&mut self, s: &mut Session) -> Result { + s.add_entity(dot(Vec2::ZERO))?; + assert_ne!(self.0, "begin", "begin panicked"); + Ok(Step::Continue) + } + fn preview(&self, _s: &Session, cursor: Vec2) -> Vec { + assert_ne!(self.0, "preview", "preview panicked"); + vec![dot(cursor)] + } + } + + /// Start a PanicM the way `Session::start` starts an interactive command. + fn start(s: &mut Session, at: &'static str) -> Result<()> { + fn factory_panics(_: &Session) -> Result> { + panic!("factory panicked") + } + let factory: Factory = match at { + "factory" => factory_panics, + "begin" => |_| Ok(Box::new(PanicM("begin"))), + "prompt" => |_| Ok(Box::new(PanicM("prompt"))), + "preview" => |_| Ok(Box::new(PanicM("preview"))), + _ => |_| Ok(Box::new(PanicM("input"))), + }; + let machine = Guarded::create("panictest", factory, s)?; + let st = s.state()?; + let (before, selection_before) = (st.doc.clone(), st.selection.clone()); + s.running = Some(Running { id: "panictest".into(), machine, before, selection_before }); + s.feed(None) + } + + fn points(s: &Session) -> usize { + s.doc().unwrap().model.iter().count() + } + + #[test] + fn panics_in_a_command_machine_end_the_command_not_the_app() { + // Factory and begin: an internal error, nothing running, the drawing as it was. + for at in ["factory", "begin"] { + let mut s = Session::new(); + let e = start(&mut s, at).unwrap_err(); + assert!(matches!(&e, EngineError::Internal(_, m) if m.contains("panicked")), "{at}: {e}"); + assert!(s.running.is_none() && s.current_prompt().is_none(), "{at}"); + assert_eq!(points(&s), 0, "{at}: the drawing is restored"); + s.cmdline("line 0,0 1,0").unwrap(); + assert!(s.running.is_some(), "{at}: the session still works"); + } + + // Prompt and preview: a fallback prompt and no preview from then on; the next input ends + // the command and restores the drawing. + let at = Vec2::new(1.0, 1.0); + let mut s = Session::new(); + start(&mut s, "prompt").unwrap(); + assert_eq!(points(&s), 1, "begin ran"); + assert_eq!(s.preview(at).len(), 1, "the preview works until the prompt panics"); + assert!(s.current_prompt().unwrap().message.contains("Internal error")); + assert!(s.prompt_text().starts_with("PANICTEST Internal error"), "{}", s.prompt_text()); + assert!(s.preview(at).is_empty()); + assert!(matches!(s.input(Input::Enter), Err(EngineError::Internal(_, m)) if m == "panic in prompt")); + assert!(s.running.is_none()); + assert_eq!(points(&s), 0, "the drawing is restored"); + + let mut s = Session::new(); + start(&mut s, "preview").unwrap(); + assert!(s.preview(at).is_empty()); + assert!(s.preview(at).is_empty(), "not called again"); + assert!(s.current_prompt().unwrap().message.contains("Internal error")); + assert!(matches!(s.input(Input::Point(at)), Err(EngineError::Internal(_, m)) if m == "panic in preview")); + assert!(s.running.is_none()); + assert_eq!(points(&s), 0, "the drawing is restored"); + + // Input (already guarded by the session; the wrapper agrees). + let mut s = Session::new(); + start(&mut s, "input").unwrap(); + assert!(matches!(s.input(Input::Point(Vec2::ZERO)), Err(EngineError::Internal(..)))); + assert!(s.running.is_none()); + assert_eq!(points(&s), 0); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 456e2b79..18400bd5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -11,6 +11,7 @@ pub mod assoc; pub mod cmd; pub mod grips; +mod guard; pub mod prompt; pub mod sample; pub mod select; @@ -526,7 +527,7 @@ impl Session { self.echo(format!("Command: {}", spec.id.to_ascii_uppercase())); match spec.interactive { Some(factory) => { - let machine = factory(self)?; + let machine = guard::Guarded::create(spec.id, factory, self)?; let st = self.state()?; let before = st.doc.clone(); let selection_before = st.selection.clone(); From 5f0390eb201b0833e9acc8e9a6bb4a62e6e0f021 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:10:56 +0200 Subject: [PATCH 070/150] Default lineweight follows LWDEFAULT (fixes #292) (#295) Objects whose lineweight resolves to Default were always drawn and plotted at 0.25 mm. They now use LWDEFAULT (hundredths of a mm, 0..211), falling back to 0.25 mm when it is unset or out of range. All backends (canvas, PNG, SVG, PDF) read the width from the display list. Co-authored-by: Claude Opus 5.5 --- crates/render/src/lib.rs | 11 ++++++++++- crates/render/tests/lwdefault.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 crates/render/tests/lwdefault.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 48cc500c..a3292d0b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -410,7 +410,7 @@ fn resolve(ctx: &Ctx, e: &Entity, plotting: bool) -> (Rgb, f32, Option f32::from(v) / 100.0, - _ => 0.25, + _ => default_lineweight(d), }; let lt_name = match e.common.linetype.to_ascii_lowercase().as_str() { "bylayer" => layer.map(|l| l.linetype.clone()).unwrap_or_else(|| "Continuous".into()), @@ -422,6 +422,15 @@ fn resolve(ctx: &Ctx, e: &Entity, plotting: bool) -> (Rgb, f32, Option f32 { + match d.header.i64("LWDEFAULT", 25) { + v @ 0..=211 => v as f32 / 100.0, + _ => 0.25, + } +} + fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) { let (rgb, lw, lt, ltscale, visible) = resolve(ctx, e, b.plotting); if !visible { diff --git a/crates/render/tests/lwdefault.rs b/crates/render/tests/lwdefault.rs new file mode 100644 index 00000000..ef7a74e5 --- /dev/null +++ b/crates/render/tests/lwdefault.rs @@ -0,0 +1,27 @@ +//! The "Default" lineweight is the width LWDEFAULT names (issue #292). + +use cadcraft_doc::{Common, Drawing, EntityKind, Line, Lineweight, Space}; +use cadcraft_geom::Vec3; +use cadcraft_render::{Kind, Options, build}; + +/// Lineweights (mm) of a ByLayer line on layer 0 (lineweight Default) and an explicit 0.50 mm line. +fn widths(lwdefault: Option) -> Vec { + let mut d = Drawing::new_metric(); + if let Some(v) = lwdefault { + d.header.set_i64("LWDEFAULT", v); + } + let line = |y: f64| EntityKind::Line(Line { a: Vec3::new(0.0, y, 0.0), b: Vec3::new(10.0, y, 0.0) }); + d.add(&Space::Model, Common::default(), line(0.0)).unwrap(); + d.add(&Space::Model, Common { lineweight: Lineweight::Mm100(50), ..Default::default() }, line(1.0)).unwrap(); + let l = build(&d, &Space::Model, &Options { lineweights: true, ..Default::default() }); + l.prims.iter().filter(|p| p.kind == Kind::Polyline).map(|p| p.lw).collect() +} + +#[test] +fn default_lineweight_follows_lwdefault() { + assert_eq!(widths(None), [0.25, 0.5], "unset: 0.25 mm"); + assert_eq!(widths(Some(70)), [0.7, 0.5]); + assert_eq!(widths(Some(0)), [0.0, 0.5]); + assert_eq!(widths(Some(-3)), [0.25, 0.5], "out of range: 0.25 mm"); + assert_eq!(widths(Some(5000)), [0.25, 0.5]); +} From b90d8cf644c055e087dad14357f0327410f52d6d Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:11:02 +0200 Subject: [PATCH 071/150] Metric drawings load library linetypes in millimetres (fixes #294) (#299) The linetype library is defined in inches, and LINETYPE loaded it unchanged into metric drawings: DASHED was 0.5 mm on, 0.25 mm off at LTSCALE 1, so dashed lines looked solid. CAD programs load ISO-scaled definitions when MEASUREMENT is 1 (the same patterns with lengths times 25.4). library::standard_linetypes_for(d) does the equivalent with our own definitions: in a metric drawing every length (dashes, gaps, text and shape size, offsets) is scaled by 25.4 as it is loaded. LINETYPE and the CENTER linetype that CENTERMARK/CENTERLINE load use it. Definitions already in a drawing are not changed. Co-authored-by: Claude Opus 5.5 --- crates/doc/src/library.rs | 30 ++++++++++++++- crates/engine/src/cmd/draw2.rs | 2 +- crates/engine/src/cmd/props.rs | 2 +- crates/engine/tests/metric_linetypes.rs | 50 +++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 crates/engine/tests/metric_linetypes.rs diff --git a/crates/doc/src/library.rs b/crates/doc/src/library.rs index 5e9eb64a..a280aca5 100644 --- a/crates/doc/src/library.rs +++ b/crates/doc/src/library.rs @@ -4,9 +4,10 @@ //! The names follow the industry-common names users expect (DASHED, CENTER, ANSI31…); the //! dash and spacing values are our own. -use crate::{DashElement, Linetype}; +use crate::{DashElement, Drawing, Linetype}; -/// The standard linetype library (inch-based lengths; metric drawings scale with LTSCALE). +/// The standard linetype library, defined in inches. Load it into a drawing with +/// [`standard_linetypes_for`], which scales it for metric drawings. pub fn standard_linetypes() -> Vec { let s = |name: &str, desc: &str, d: &[f64]| Linetype::simple(name, desc, d); vec![ @@ -41,6 +42,31 @@ pub fn standard_linetypes() -> Vec { ] } +/// The standard linetypes as loaded into `d`. In a metric drawing (`MEASUREMENT` 1) every length +/// (dashes, gaps, text and shape size, offsets) is multiplied by 25.4, so the patterns measure +/// in millimetres what they measure in inches in an imperial drawing (DASHED: 12.7 on, 6.35 +/// off) and LTSCALE 1 suits both, as CAD programs load ISO-scaled definitions for metric +/// drawings. Definitions already in a drawing are not changed. +pub fn standard_linetypes_for(d: &Drawing) -> Vec { + let lib = standard_linetypes(); + if d.header.i64("MEASUREMENT", 0) != 1 { + return lib; + } + lib.into_iter().map(|lt| scaled(lt, 25.4)).collect() +} + +/// `lt` with every length multiplied by `k`. +fn scaled(mut lt: Linetype, k: f64) -> Linetype { + for e in &mut lt.pattern { + e.length *= k; + e.offset = e.offset * k; + if e.text.is_some() || e.shape.is_some() { + e.scale *= k; + } + } + lt +} + /// A hatch pattern line family: angle (degrees), origin, offset (shift along, spacing), dashes. #[derive(Clone, Debug, PartialEq)] pub struct PatternLine { diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index f6813c8e..e7bc4e14 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -2352,7 +2352,7 @@ impl Interactive for SplineCvM { fn add_center_line(s: &mut Session, a: Vec2, b: Vec2) -> Result { let d = s.doc_mut()?; if d.linetype("CENTER").is_none() - && let Some(lt) = cadcraft_doc::library::standard_linetypes().into_iter().find(|l| l.name == "CENTER") + && let Some(lt) = cadcraft_doc::library::standard_linetypes_for(d).into_iter().find(|l| l.name == "CENTER") { d.linetypes.push(lt); } diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index c211351c..2f28df19 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -372,7 +372,7 @@ fn run_color(s: &mut Session, p: &Value) -> Result { fn run_linetype(s: &mut Session, p: &Value) -> Result { let d = s.doc_mut()?; if let Some(load) = str_param(p, "load") { - let lib = cadcraft_doc::library::standard_linetypes(); + let lib = cadcraft_doc::library::standard_linetypes_for(d); let mut n = 0; for lt in lib { if (load == "*" || lt.name.eq_ignore_ascii_case(load)) && d.linetype(<.name).is_none() { diff --git a/crates/engine/tests/metric_linetypes.rs b/crates/engine/tests/metric_linetypes.rs new file mode 100644 index 00000000..69786fdc --- /dev/null +++ b/crates/engine/tests/metric_linetypes.rs @@ -0,0 +1,50 @@ +//! Library linetypes load scaled for the drawing's units (issue #294): a metric drawing gets the +//! patterns in millimetres (inch lengths × 25.4), an imperial one keeps the inch lengths. + +use cadcraft_engine::Session; +use serde_json::json; + +/// Dash and gap lengths of a linetype loaded with the LINETYPE command. +fn loaded(metric: bool, name: &str) -> Vec { + let mut s = Session::empty(); + s.new_drawing(metric); + s.execute("linetype", &json!({ "load": name })).unwrap(); + let d = s.doc().unwrap(); + d.linetype(name).unwrap().pattern.iter().map(|e| e.length).collect() +} + +fn near(a: &[f64], b: &[f64]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9) +} + +#[test] +fn metric_drawings_load_millimetre_patterns() { + assert!(near(&loaded(false, "DASHED"), &[0.5, -0.25])); + let mm = loaded(true, "DASHED"); + assert!(near(&mm, &[12.7, -6.35]), "{mm:?}"); + assert!(near(&loaded(true, "CENTER"), &[31.75, -6.35, 6.35, -6.35])); +} + +#[test] +fn embedded_text_scales_with_the_pattern() { + let mut s = Session::empty(); + s.new_drawing(true); + s.execute("linetype", &json!({ "load": "*" })).unwrap(); + let d = s.doc().unwrap(); + let fence = d.linetype("FENCELINE1").unwrap(); + let text = fence.pattern.iter().find(|e| e.text.is_some()).unwrap(); + assert!((text.scale - 2.54).abs() < 1e-9 && (text.length + 2.54).abs() < 1e-9, "{text:?}"); +} + +#[test] +fn centerline_loads_the_metric_center_linetype() { + let mut s = Session::empty(); + s.new_drawing(true); + s.execute("line", &json!({ "points": [[0, 0], [100, 0]] })).unwrap(); + s.execute("line", &json!({ "points": [[0, 20], [100, 20]] })).unwrap(); + let hs: Vec = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect(); + let (h1, h2) = (&hs[0], &hs[1]); + s.execute("centerline", &json!({ "h1": h1, "h2": h2 })).unwrap(); + let lt = s.doc().unwrap().linetype("CENTER").unwrap().clone(); + assert!((lt.pattern[0].length - 31.75).abs() < 1e-9, "{lt:?}"); +} From dd8764a7b5971a587b06af8989a4d5de5af8d69b Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:11:07 +0200 Subject: [PATCH 072/150] WBLOCK honours base when writing a block (fixes #297) (#300) wblock {name, base} ignored base and always measured from the block's own base point. base (in block coordinates) now overrides it, the way it already worked for selected objects. In both modes the base point becomes the written drawing's origin, and $INSBASE is 0,0,0. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/blocks.rs | 9 ++++-- crates/engine/tests/wblock_base.rs | 45 ++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 crates/engine/tests/wblock_base.rs diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index e1e3958f..f538af28 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -224,11 +224,14 @@ fn run_wblock(s: &mut Session, p: &Value) -> Result { d.text_styles = src.text_styles.clone(); d.dim_styles = src.dim_styles.clone(); d.blocks = src.blocks.clone(); - let base = point_param(p, "base").unwrap_or(Vec2::ZERO); - let m = Mat3::translate(-base); + // The base point becomes the written drawing's origin, its insertion base: by default 0,0 for + // objects and the block's base point for a block (`base` is then in block coordinates). + let base = point_param(p, "base"); + d.header.set("INSBASE", cadcraft_doc::HVal::Point(Vec3::ZERO)); + let m = Mat3::translate(-base.unwrap_or(Vec2::ZERO)); let ents: Vec = if let Some(name) = str_param(p, "name") { let b = src.block(name).ok_or_else(|| bad("wblock", format!("no block `{name}`")))?; - let m = Mat3::translate(-b.base.xy()); + let m = Mat3::translate(-base.unwrap_or(b.base.xy())); b.entities .iter() .map(|e| { diff --git a/crates/engine/tests/wblock_base.rs b/crates/engine/tests/wblock_base.rs new file mode 100644 index 00000000..a18da8a0 --- /dev/null +++ b/crates/engine/tests/wblock_base.rs @@ -0,0 +1,45 @@ +//! WBLOCK writes the drawing with its base point at the origin (INSBASE 0,0,0), for selected +//! objects and for a block, where `base` overrides the block's own base point. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::{Drawing, EntityKind}; +use serde_json::json; + +/// Writes "INSBASE x,y;line ax,ay" for each line, so the test can read the written geometry. +fn write(d: &Drawing, _path: &str) -> Result, String> { + let ins = d.header.point("INSBASE").ok_or("no INSBASE")?; + let mut out = format!("{},{}", ins.x, ins.y); + for e in d.model.iter() { + if let EntityKind::Line(l) = &e.kind { + out.push_str(&format!(";{},{}", l.a.x, l.a.y)); + } + } + Ok(out.into_bytes()) +} + +fn written(s: &mut Session, p: serde_json::Value) -> String { + let path = std::env::temp_dir().join(format!("cadcraft-wblock-base-{}.dxf", std::process::id())); + let mut p = p; + p["path"] = json!(path.to_string_lossy()); + s.execute("wblock", &p).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_file(&path); + text +} + +#[test] +fn wblock_honours_the_base_point() { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write, plot: None }); + let mut s = Session::new(); + let h = s.execute("line", &json!({ "points": [[10, 20], [15, 20]] })).unwrap()["handles"][0].as_str().unwrap().to_string(); + + // Objects: the base point moves to the origin. + assert_eq!(written(&mut s, json!({ "handles": [h], "base": [10, 5] })), "0,0;0,15"); + assert_eq!(written(&mut s, json!({ "handles": [h] })), "0,0;10,20"); + + // A block: its base point by default, `base` (block coordinates) when given. + s.execute("block", &json!({ "name": "Part", "base": [4, 4], "handles": [h], "keep": "delete" })).unwrap(); + assert_eq!(written(&mut s, json!({ "name": "Part" })), "0,0;6,16"); + assert_eq!(written(&mut s, json!({ "name": "Part", "base": [10, 20] })), "0,0;0,0"); +} From f55559e5bb1a8770582639f3413f5272a943ab65 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:11:13 +0200 Subject: [PATCH 073/150] OFFSET keeps a closed spline closed (fixes #303) (#305) The offset copy of a spline was always rebuilt with the open fit-point interpolation, so a closed spline came out open with a corner at its start point. Use Spline::from_fit with the source's closed flag, which gives a periodic spline for closed input. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/modify.rs | 3 +- crates/engine/tests/offset_closed_spline.rs | 33 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/offset_closed_spline.rs diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index f76d2074..e52075bb 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -321,7 +321,8 @@ pub(crate) fn offset_kind(k: &EntityKind, dist: f64, side: Vec2) -> Option 0.0; let off = offset_points(&pts, if left { dist } else { -dist }, sp.closed); - Some(EntityKind::Spline(cadcraft_geom::Spline::from_fit_points(&decimate(&off, 40)))) + // A closed spline stays closed (periodic), smooth across its start point. + Some(EntityKind::Spline(cadcraft_geom::Spline::from_fit(&decimate(&off, 40), sp.closed))) } EntityKind::XLine(r) | EntityKind::Ray(r) => { let ln = Line::new(r.base.xy(), r.base.xy() + r.dir.xy()); diff --git a/crates/engine/tests/offset_closed_spline.rs b/crates/engine/tests/offset_closed_spline.rs new file mode 100644 index 00000000..b6294cdb --- /dev/null +++ b/crates/engine/tests/offset_closed_spline.rs @@ -0,0 +1,33 @@ +//! OFFSET keeps a closed (periodic) spline closed (issue #303): the offset copy was built with +//! the open-spline interpolation, so it came out open with a seam where the curve started. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use cadcraft_engine::geom::Spline; +use serde_json::json; + +fn last_spline(s: &Session) -> (String, Spline) { + let d = s.doc().unwrap(); + let Some(e) = d.model.iter().last() else { panic!("empty drawing") }; + let EntityKind::Spline(sp) = &e.kind else { panic!("not a spline: {:?}", e.kind) }; + (e.handle.hex(), sp.clone()) +} + +#[test] +fn offset_of_closed_spline_is_closed() { + let mut s = Session::new(); + s.cmdline("spline 0,0 10,0 10,10 0,10 c").unwrap(); + let (h, src) = last_spline(&s); + assert!(src.closed && src.is_periodic()); + for (side, outward) in [([5.0, 5.0], false), ([30.0, 5.0], true)] { + s.execute("offset", &json!({ "handle": h, "distance": 1, "side": side })).unwrap(); + let (_, off) = last_spline(&s); + assert!(off.closed && off.is_periodic(), "offset copy is open: {off:?}"); + let (lo, hi) = off.domain(); + assert!(off.eval(lo).near(off.eval(hi), 1e-9), "offset copy has a gap at the seam"); + // The copy lies 1 unit inside / outside the original everywhere. + let (sb, ob) = (src.bounds(), off.bounds()); + let grow = ob.max.x - sb.max.x; + assert!((grow - if outward { 1.0 } else { -1.0 }).abs() < 0.1, "{grow}"); + } +} From 3e5e5ac3ce7119cca10d030b0e35451441003de8 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:11:19 +0200 Subject: [PATCH 074/150] -BLOCK erases the selection and asks before redefining (fixes #307) (#310) -BLOCK was an alias of BLOCK, so the selected objects were always converted to a block reference; a script doing -BLOCK then INSERT ended up with an extra reference. -BLOCK is now its own command: the command line erases the selection, and its JSON form defaults to keep "delete". BLOCK (menu Make..., typed BLOCK) and the "block" JSON form still convert by default. Both forms ask 'Block "" already exists. Redefine it? [Yes/No] :' when the name is in use; No asks for the name again. Session::start resolves a dashed name to a command with that id (the same change as #280's -ATTEDIT). Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/blocks.rs | 40 +++++++++++++++-- crates/engine/src/lib.rs | 4 +- crates/engine/tests/block_redefine.rs | 4 +- crates/engine/tests/dash_block.rs | 64 +++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 crates/engine/tests/dash_block.rs diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index f538af28..1f9b7b19 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -15,9 +15,14 @@ pub fn specs() -> Vec { vec![ CommandSpec::new("block", "Make...", run_block) .menu(&["Draw", "Block", "Make..."]) - .alias(&["b", "-block", "bmake"]) - .params("{name, base: [x,y], handles?, keep?: \"convert\"|\"retain\"|\"delete\", description?}") + .alias(&["b", "bmake"]) + .params("{name, base: [x,y], handles?, keep?: \"convert\"|\"retain\"|\"delete\" (default \"convert\"), description?}") .interactive(|_| Ok(Box::new(BlockM::default()))), + // -BLOCK erases the selected objects once the block is defined. + CommandSpec::new("-block", "Create Block", run_dash_block) + .alias(&["-b"]) + .params("{name, base: [x,y], handles?, keep?: \"delete\"|\"retain\"|\"convert\" (default \"delete\"), description?}") + .interactive(|_| Ok(Box::new(BlockM { dash: true, ..BlockM::default() }))), CommandSpec::new("insert", "Block...", run_insert) .menu(&["Insert", "Block..."]) .alias(&["i", "-insert", "ddinsert"]) @@ -348,8 +353,23 @@ fn run_battman(s: &mut Session, p: &Value) -> Result { // ---------------- interactive ---------------- +/// -BLOCK's JSON form: BLOCK with the selection erased unless `keep` says otherwise. +fn run_dash_block(s: &mut Session, p: &Value) -> Result { + let mut p = p.clone(); + if let Some(o) = p.as_object_mut() { + o.entry("keep").or_insert_with(|| json!("delete")); + } + run_block(s, &p) +} + +/// BLOCK / -BLOCK at the command line. BLOCK converts the selection to a reference to the new +/// block (the dialog's default); -BLOCK erases it. #[derive(Default)] struct BlockM { + /// -BLOCK: erase the selection. + dash: bool, + /// A name already in use, waiting for the answer to "Redefine it?". + existing: Option, name: Option, base: Option, sel: SelectPhase, @@ -361,13 +381,22 @@ impl Interactive for BlockM { } fn prompt(&self, _s: &Session) -> Prompt { match (&self.name, self.base) { - (None, _) => Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]), + (None, _) => match &self.existing { + Some(n) => Prompt::new(format!("Block \"{n}\" already exists. Redefine it?"), super::curves::KW).kw(&["Yes", "No"]).default("N"), + None => Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]), + }, (Some(_), None) => Prompt::new("Specify insertion base point", Accept::POINT).kw(&["Annotative"]), _ => self.sel.prompt(), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { match (&self.name, self.base) { + (None, _) if self.existing.is_some() => { + // Yes redefines the block; No (the default) asks for another name. + let yes = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.trim().to_ascii_lowercase().starts_with('y')); + self.name = self.existing.take().filter(|_| yes); + Ok(Step::Continue) + } (None, _) => { if let Input::Text(t) = i { let n = t.trim().to_string(); @@ -376,6 +405,8 @@ impl Interactive for BlockM { s.echo(format!("Defined blocks: {}", if names.is_empty() { "(none)".into() } else { names.join(", ") })); } else if !valid_block_name(&n) { s.echo("Invalid block name."); + } else if s.doc()?.block(&n).is_some() { + self.existing = Some(n); } else { self.name = Some(n); } @@ -406,7 +437,8 @@ impl BlockM { fn finish(&mut self, s: &mut Session, hs: &[Handle]) -> Result { let (Some(name), Some(base)) = (self.name.clone(), self.base) else { return Ok(Step::Cancel) }; // A definition that can't be made (e.g. one that would reference itself) ends the command. - if let Err(e) = make_block(s, &name, base, hs, "convert", "") { + let keep = if self.dash { "delete" } else { "convert" }; + if let Err(e) = make_block(s, &name, base, hs, keep, "") { s.echo(e.to_string()); return Ok(Step::Done); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 18400bd5..74a3cbb4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -510,7 +510,9 @@ impl Session { None => (false, name), }; let lower = name.trim_start_matches(['_', '.', '-']).to_ascii_lowercase(); - let id = cmd::resolve_alias(&lower); + // A command-line form with an id of its own (-ATTEDIT) is not the command it prefixes. + let dashed = cmd::resolve_alias(&name.trim_start_matches(['_', '.']).to_ascii_lowercase()); + let id = if find_command(&dashed).is_some() { dashed } else { cmd::resolve_alias(&lower) }; let spec = find_command(&id).ok_or_else(|| EngineError::UnknownCommand(name.to_string()))?; if transparent && spec.transparent && self.running.is_some() { // Transparent commands (zoom, pan…) run without cancelling the active one. diff --git a/crates/engine/tests/block_redefine.rs b/crates/engine/tests/block_redefine.rs index 227cdb3e..e5a9cac1 100644 --- a/crates/engine/tests/block_redefine.rs +++ b/crates/engine/tests/block_redefine.rs @@ -24,8 +24,8 @@ fn contents(s: &Session, name: &str) -> Vec { #[test] fn a_block_cannot_be_redefined_to_reference_itself() { let mut s = Session::new(); - // Command line: the second -BLOCK selects the converted reference of B plus a new line. - s.script("LINE 0,0 10,0\n\n-BLOCK B 0,0 L\n\nLINE 0,0 0,10\n\n-BLOCK B 0,0 ALL\n\n").unwrap(); + // Command line: -BLOCK redefines B (Yes) from the reference BLOCK converted plus a new line. + s.script("LINE 0,0 10,0\n\nBLOCK B 0,0 L\n\nLINE 0,0 0,10\n\n-BLOCK B Y 0,0 ALL\n\n").unwrap(); assert!(s.running.is_none()); assert!(s.log.iter().any(|l| l.contains("references itself")), "{:?}", s.log); assert_eq!(contents(&s, "B"), ["line"], "B keeps its original definition"); diff --git a/crates/engine/tests/dash_block.rs b/crates/engine/tests/dash_block.rs new file mode 100644 index 00000000..27302562 --- /dev/null +++ b/crates/engine/tests/dash_block.rs @@ -0,0 +1,64 @@ +//! -BLOCK erases the selected objects, and asks before redefining a block (issue #307). BLOCK +//! (the menu's Make...) and the JSON form still convert the selection to a block reference. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use serde_json::json; + +fn prompt(s: &Session) -> String { + s.current_prompt().map(|p| p.message).unwrap_or_default() +} + +fn references(s: &Session) -> usize { + s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Insert(_))).count() +} + +#[test] +fn dash_block_erases_the_selection_and_asks_before_redefining() { + let mut s = Session::new(); + s.script("LINE 0,0 10,0\n\n-BLOCK B 0,0 L\n\n").unwrap(); + assert!(s.running.is_none()); + assert_eq!(s.doc().unwrap().model.len(), 0, "the line is erased, not converted"); + assert_eq!(s.doc().unwrap().block("B").unwrap().entities.len(), 1); + s.execute("insert", &json!({ "name": "B", "at": [20, 0] })).unwrap(); + assert_eq!(references(&s), 1, "INSERT after -BLOCK leaves one reference"); + + // An existing name (in any case): No, the default, asks for the name again. + s.script("LINE 0,0 0,10\n\n").unwrap(); + s.cmdline("-block").unwrap(); + s.cmdline("b").unwrap(); + assert_eq!(prompt(&s), "Block \"b\" already exists. Redefine it?"); + s.cmdline("").unwrap(); + assert_eq!(prompt(&s), "Enter block name"); + s.cmdline("b").unwrap(); + s.cmdline("y").unwrap(); + assert_eq!(prompt(&s), "Specify insertion base point"); + s.script("5,5\nL\n\n").unwrap(); + assert!(s.running.is_none()); + let d = s.doc().unwrap(); + assert_eq!(d.blocks.keys().collect::>(), ["B"]); + let b = d.block("B").unwrap(); + assert!((b.base.x - 5.0).abs() < 1e-12 && (b.base.y - 5.0).abs() < 1e-12); + assert!(matches!(b.entities.iter().next().map(|e| &e.kind), Some(EntityKind::Line(l)) if (l.b.y - 10.0).abs() < 1e-12), "B was redefined"); + assert_eq!(d.model.len(), 1, "the vertical line is erased; only the reference remains"); +} + +#[test] +fn block_and_the_json_forms_keep_their_defaults() { + let mut s = Session::new(); + // BLOCK converts the selection to a reference. + s.script("LINE 0,0 10,0\n\nBLOCK A 0,0 L\n\n").unwrap(); + assert_eq!(references(&s), 1); + // JSON: block converts unless `keep` says otherwise; -block deletes unless it says otherwise. + let l = |s: &mut Session| s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap()["handles"][0].clone(); + let h = l(&mut s); + let r = s.execute("block", &json!({ "name": "C", "base": [0, 0], "handles": [h] })).unwrap(); + assert!(r["insert"].is_string()); + let h = l(&mut s); + let r = s.execute("-block", &json!({ "name": "D", "base": [0, 0], "handles": [h] })).unwrap(); + assert!(r["insert"].is_null()); + let h = l(&mut s); + s.execute("-block", &json!({ "name": "E", "base": [0, 0], "handles": [h], "keep": "retain" })).unwrap(); + assert_eq!(references(&s), 2); + assert_eq!(s.doc().unwrap().model.len(), 3, "A and C references, E's retained line"); +} From f1743df2dd6ac6e35ef88595b7a713d493dd39df Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:11:24 +0200 Subject: [PATCH 075/150] Only colour 7 flips with the background (fixes #272) (#285) display_rgb flipped every near-white colour to black on paper and every near-black colour to white on the dark canvas, so ACI 51/255 and true-colour white printed black and dark index colours showed white. Display-list primitives now carry an aci7 flag set when the colour resolves to 7 (directly, by layer or by block); only those flip. Co-authored-by: Claude Opus 5.5 --- crates/color/src/lib.rs | 25 ++++---- crates/io/src/pdf.rs | 4 +- crates/io/src/svg.rs | 4 +- crates/render/src/lib.rs | 75 +++++++++++++++++------- crates/render/src/raster.rs | 4 +- crates/render/tests/aci7_display.rs | 90 +++++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 8 +-- crates/ui-egui/src/gpu.rs | 8 +-- 8 files changed, 171 insertions(+), 47 deletions(-) create mode 100644 crates/render/tests/aci7_display.rs diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 2975e693..5eea3ac9 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -208,16 +208,16 @@ pub fn nearest_aci(c: Rgb) -> u8 { best.1 } -/// Canvas display colour: ACI 7 (and any near-white/near-black) flips to contrast with the -/// background, as CAD programs do on dark or light model space. -pub fn display_rgb(c: Rgb, background: Rgb) -> Rgb { - let dark_bg = background.luma() < 0.5; - if dark_bg && c.luma() < 0.06 { - Rgb(255, 255, 255) - } else if !dark_bg && c.luma() > 0.94 { - Rgb(0, 0, 0) - } else { +/// Display colour of `c` on `background`. Colour 7 (`aci7`) flips to contrast with the +/// background, white on dark and black on light (paper), as CAD programs draw it; every other +/// colour, including a true-colour or index white or black, keeps its RGB. +pub fn display_rgb(c: Rgb, aci7: bool, background: Rgb) -> Rgb { + if !aci7 { c + } else if background.luma() < 0.5 { + Rgb(255, 255, 255) + } else { + Rgb(0, 0, 0) } } @@ -259,7 +259,10 @@ mod tests { #[test] fn white_flips_on_light_background() { - assert_eq!(display_rgb(Rgb(255, 255, 255), Rgb(255, 255, 255)), Rgb(0, 0, 0)); - assert_eq!(display_rgb(Rgb(255, 255, 255), Rgb(33, 40, 48)), Rgb(255, 255, 255)); + assert_eq!(display_rgb(Rgb(255, 255, 255), true, Rgb(255, 255, 255)), Rgb(0, 0, 0)); + assert_eq!(display_rgb(Rgb(255, 255, 255), true, Rgb(33, 40, 48)), Rgb(255, 255, 255)); + // Only colour 7 flips: ACI 255 / true-colour white and dark index colours keep their RGB. + assert_eq!(display_rgb(Rgb(255, 255, 255), false, Rgb(255, 255, 255)), Rgb(255, 255, 255)); + assert_eq!(display_rgb(aci_rgb(250), false, Rgb(33, 40, 48)), aci_rgb(250)); } } diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 75d638cb..8d3b281b 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -7,7 +7,7 @@ use std::fmt::Write as _; -use cadcraft_color::{Rgb, display_rgb}; +use cadcraft_color::Rgb; use cadcraft_doc::{Drawing, PageSetup, Space}; use cadcraft_geom::{Bounds2, Vec2}; use cadcraft_render::{Kind, Sheet, clip, paper}; @@ -238,7 +238,7 @@ fn content_stream(list: &cadcraft_render::DisplayList, map: &Map, clip_pt: &Boun } }; for p in &list.prims { - let rgb = display_rgb(p.color, white); + let rgb = p.display_rgb(white); let raw = list.points(p); if raw.iter().any(|q| !q.is_finite()) { continue; diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 01f465dc..04f6a390 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -2,7 +2,7 @@ use std::fmt::Write as _; -use cadcraft_color::{Rgb, display_rgb}; +use cadcraft_color::Rgb; use cadcraft_doc::{Drawing, Space}; use cadcraft_geom::Bounds2; use cadcraft_render::Kind; @@ -35,7 +35,7 @@ pub fn export_window(d: &Drawing, space: &Space, window: Option) -> Str let tx = |x: f64| x - x0; let ty = |y: f64| y1 - y; for p in &list.prims { - let c = display_rgb(p.color, white).hex(); + let c = p.display_rgb(white).hex(); let pts = list.points(p); match p.kind { Kind::Polyline => { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a3292d0b..326a1c9c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -46,6 +46,9 @@ pub enum Kind { pub struct DPrim { pub handle: Handle, pub color: Rgb, + /// Colour 7 (directly, by layer or by block): drawn white on a dark background and black on + /// a light one, see [`DPrim::display_rgb`]. Other colours keep their RGB. + pub aci7: bool, /// Lineweight in mm (0 = thinnest). pub lw: f32, pub kind: Kind, @@ -66,6 +69,32 @@ pub struct DisplayList { pub expanded: usize, } +impl DPrim { + /// The colour to draw with on `background`. + pub fn display_rgb(&self, background: Rgb) -> Rgb { + cadcraft_color::display_rgb(self.color, self.aci7, background) + } +} + +/// A resolved colour: its RGB and whether it is colour 7. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Ink { + rgb: Rgb, + aci7: bool, +} + +/// Resolve `c` like [`Color::resolve`], noting whether it comes out as colour 7. +fn ink(c: Color, layer: Color, block: Color) -> Ink { + // ByLayer / ByBlock left unresolved (layer or block colour itself logical) mean colour 7. + let is7 = |c: Color| matches!(c, Color::ByLayer | Color::ByBlock | Color::Index(7)); + let aci7 = match c { + Color::ByLayer => is7(layer), + Color::ByBlock => is7(block), + c => c == Color::Index(7), + }; + Ink { rgb: c.resolve(layer, block), aci7 } +} + impl DisplayList { pub fn segment_count(&self) -> usize { self.prims.iter().filter(|p| p.kind == Kind::Polyline).map(|p| p.len.saturating_sub(1) as usize).sum() @@ -146,7 +175,7 @@ struct Builder<'a> { pub const MAX_BLOCK_EXPANSION: usize = 2_000_000; impl Builder<'_> { - fn polyline(&mut self, ctx: &Ctx, color: Rgb, lw: f32, pts: &[Vec2]) { + fn polyline(&mut self, ctx: &Ctx, color: Ink, lw: f32, pts: &[Vec2]) { if pts.len() < 2 { return; } @@ -156,9 +185,9 @@ impl Builder<'_> { self.list.bounds.add(q); self.list.verts.push(q); } - self.list.prims.push(DPrim { handle: ctx.top, color, lw, kind: Kind::Polyline, start, len: pts.len() as u32 }); + self.list.prims.push(DPrim { handle: ctx.top, color: color.rgb, aci7: color.aci7, lw, kind: Kind::Polyline, start, len: pts.len() as u32 }); } - fn tris(&mut self, ctx: &Ctx, color: Rgb, tris: &[Vec2]) { + fn tris(&mut self, ctx: &Ctx, color: Ink, tris: &[Vec2]) { if tris.len() < 3 { return; } @@ -168,10 +197,10 @@ impl Builder<'_> { self.list.bounds.add(q); self.list.tris.push(q); } - self.list.prims.push(DPrim { handle: ctx.top, color, lw: 0.0, kind: Kind::Tris, start, len: tris.len() as u32 }); + self.list.prims.push(DPrim { handle: ctx.top, color: color.rgb, aci7: color.aci7, lw: 0.0, kind: Kind::Tris, start, len: tris.len() as u32 }); } /// Shaped text: strokes as polylines; TrueType glyphs filled (TEXTFILL) or outlined. - fn shaped(&mut self, ctx: &Ctx, color: Rgb, lw: f32, sh: &cadcraft_fonts::Shaped) { + fn shaped(&mut self, ctx: &Ctx, color: Ink, lw: f32, sh: &cadcraft_fonts::Shaped) { for s in &sh.strokes { self.polyline(ctx, color, lw, s); } @@ -190,18 +219,18 @@ impl Builder<'_> { } } } - fn point(&mut self, ctx: &Ctx, color: Rgb, p: Vec2) { + fn point(&mut self, ctx: &Ctx, color: Ink, p: Vec2) { let q = ctx.xf.apply(p); self.list.bounds.add(q); let start = self.list.verts.len() as u32; self.list.verts.push(q); - self.list.prims.push(DPrim { handle: ctx.top, color, lw: 0.0, kind: Kind::Point, start, len: 1 }); + self.list.prims.push(DPrim { handle: ctx.top, color: color.rgb, aci7: color.aci7, lw: 0.0, kind: Kind::Point, start, len: 1 }); } - fn infinite(&mut self, ctx: &Ctx, color: Rgb, base: Vec2, dir: Vec2, ray: bool) { + fn infinite(&mut self, ctx: &Ctx, color: Ink, base: Vec2, dir: Vec2, ray: bool) { let start = self.list.verts.len() as u32; self.list.verts.push(ctx.xf.apply(base)); self.list.verts.push(ctx.xf.apply_vec(dir).normalized()); - self.list.prims.push(DPrim { handle: ctx.top, color, lw: 0.0, kind: Kind::Infinite { ray }, start, len: 2 }); + self.list.prims.push(DPrim { handle: ctx.top, color: color.rgb, aci7: color.aci7, lw: 0.0, kind: Kind::Infinite { ray }, start, len: 2 }); } /// Take one unit of the block-expansion budget; false once it is spent. fn expand(&mut self) -> bool { @@ -372,7 +401,7 @@ fn push_raw(dst: &mut DisplayList, p: &DPrim, kind: Kind, pts: &[Vec2]) { } else { dst.verts.extend_from_slice(pts); } - dst.prims.push(DPrim { handle: p.handle, color: p.color, lw: p.lw, kind, start, len: pts.len() as u32 }); + dst.prims.push(DPrim { handle: p.handle, color: p.color, aci7: p.aci7, lw: p.lw, kind, start, len: pts.len() as u32 }); } /// Build the display list for a set of loose entities (previews, rubber bands). @@ -393,7 +422,7 @@ pub fn build_entities<'a, I: IntoIterator>(d: &Drawing, ents: b.list } -fn resolve(ctx: &Ctx, e: &Entity, plotting: bool) -> (Rgb, f32, Option, f64, bool) { +fn resolve(ctx: &Ctx, e: &Entity, plotting: bool) -> (Ink, f32, Option, f64, bool) { let d = ctx.d; // Layer "0" inside a block takes the insert's layer. let layer_name = if e.common.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { e.common.layer.as_str() }; @@ -402,7 +431,7 @@ fn resolve(ctx: &Ctx, e: &Entity, plotting: bool) -> (Rgb, f32, Option layer.map(|l| l.lineweight).unwrap_or(Lineweight::Default), Lineweight::ByBlock => ctx.block_lw, @@ -495,7 +524,7 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) { h.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate(tol)).collect(); let tris = fill::triangulate_evenodd(&loops); let c = match &h.gradient { - Some(g) => g.color1.resolve(Color::Index(7), ctx.block_color), + Some(g) => ink(g.color1, Color::Index(7), ctx.block_color), None => rgb, }; b.tris(ctx, c, &tris); @@ -674,17 +703,19 @@ pub fn layout_mtext_entity(d: &Drawing, t: &cadcraft_doc::MText) -> cadcraft_fon cadcraft_fonts::layout_mtext_with(&t.contents, ¶ms) } -fn mtext_color(c: Option, rgb: Rgb) -> Rgb { +fn mtext_color(c: Option, rgb: Ink) -> Ink { match c { Some(cadcraft_fonts::MTextColor::Aci(i)) => { - u8::try_from(i).ok().filter(|i| *i > 0).map(|i| Color::Index(i).resolve(Color::Index(7), Color::Index(7))).unwrap_or(rgb) + u8::try_from(i).ok().filter(|i| *i > 0).map(|i| ink(Color::Index(i), Color::Index(7), Color::Index(7))).unwrap_or(rgb) + } + Some(cadcraft_fonts::MTextColor::Rgb(v)) => { + Ink { rgb: Rgb(((v >> 16) & 0xFF) as u8, ((v >> 8) & 0xFF) as u8, (v & 0xFF) as u8), aci7: false } } - Some(cadcraft_fonts::MTextColor::Rgb(v)) => Rgb(((v >> 16) & 0xFF) as u8, ((v >> 8) & 0xFF) as u8, (v & 0xFF) as u8), None => rgb, } } -fn mtext(b: &mut Builder, ctx: &Ctx, t: &cadcraft_doc::MText, rgb: Rgb, lw: f32) { +fn mtext(b: &mut Builder, ctx: &Ctx, t: &cadcraft_doc::MText, rgb: Ink, lw: f32) { let l = layout_mtext_entity(ctx.d, t); for p in &l.pieces { b.shaped(ctx, mtext_color(p.color, rgb), lw, &p.shaped); @@ -716,9 +747,9 @@ fn dimension(b: &mut Builder, ctx: &Ctx, e: &Entity, dm: &cadcraft_doc::Dimensio Color::ByBlock => ctx.block_color, c => c, }; - let dim_rgb = style.dim_line_color.resolve(layer_color, own); - let ext_rgb = style.ext_line_color.resolve(layer_color, own); - let txt_rgb = style.text_color.resolve(layer_color, own); + let dim_rgb = ink(style.dim_line_color, layer_color, own); + let ext_rgb = ink(style.ext_line_color, layer_color, own); + let txt_rgb = ink(style.text_color, layer_color, own); for (l, role) in g.lines.iter().zip(g.line_roles.iter().chain(std::iter::repeat(&LineRole::Dim))) { b.polyline(ctx, if *role == LineRole::Ext { ext_rgb } else { dim_rgb }, lw, l); } @@ -754,7 +785,7 @@ pub fn table_covered(t: &cadcraft_doc::Table) -> Vec> { cov } -fn table(b: &mut Builder, ctx: &Ctx, t: &cadcraft_doc::Table, rgb: Rgb, lw: f32) { +fn table(b: &mut Builder, ctx: &Ctx, t: &cadcraft_doc::Table, rgb: Ink, lw: f32) { let o = t.insert.xy(); let rows = t.row_heights.len().min(10_000); let cols = t.col_widths.len().min(10_000); @@ -867,7 +898,7 @@ fn sub_ctx<'a>(ctx: &Ctx<'a>, e: &Entity, m: Mat3) -> Ctx<'a> { } } -fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rgb: Rgb) { +fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rgb: Ink) { if ctx.depth >= cadcraft_doc::MAX_BLOCK_DEPTH { return; } diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index c04905c9..b6ccccb9 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -1,6 +1,6 @@ //! CPU rasteriser: draws a display list with tiny-skia (PNG export, plot preview, tests). -use cadcraft_color::{Rgb, display_rgb}; +use cadcraft_color::Rgb; use cadcraft_geom::{Bounds2, Vec2}; use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform}; @@ -63,7 +63,7 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option EntityKind { + EntityKind::Line(Line { a: Vec3::new(0.0, y, 0.0), b: Vec3::new(10.0, y, 0.0) }) +} + +/// An insert of block "B" (one ByBlock line along y = 0) at height `y`. +fn insert(y: f64) -> EntityKind { + EntityKind::Insert(Insert { + block: "B".into(), + insert: Vec3::new(0.0, y, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: vec![], + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }) +} + +/// One horizontal line per case at y = 0, 1, 2, ... and, per case, its name and the expected +/// colour on paper (white) and on the dark canvas. +fn cases() -> (Drawing, Vec<(&'static str, Rgb, Rgb)>) { + let mut d = Drawing::new_imperial(); + let mut blk = Block::new("B"); + let mut by_block = Entity::new(Handle(1), line(0.0)); + by_block.common.color = Color::ByBlock; + blk.entities.push(by_block); + d.blocks.insert("B".into(), std::sync::Arc::new(blk)); + let light_yellow = aci_rgb(51); + let dark_grey = aci_rgb(250); + let c = |color| Common { color, ..Default::default() }; + let rows: Vec<(&str, Common, EntityKind, Rgb, Rgb)> = vec![ + ("ByLayer on layer 0 (colour 7)", Common::default(), line(0.0), BLACK, WHITE), + ("index 7", c(Color::Index(7)), line(1.0), BLACK, WHITE), + ("ByBlock in a colour-7 insert", c(Color::Index(7)), insert(2.0), BLACK, WHITE), + ("index 51 (light yellow)", c(Color::Index(51)), line(3.0), light_yellow, light_yellow), + ("index 255 (white)", c(Color::Index(255)), line(4.0), WHITE, WHITE), + ("true-colour white", c(Color::True(WHITE)), line(5.0), WHITE, WHITE), + ("index 250 (dark grey)", c(Color::Index(250)), line(6.0), dark_grey, dark_grey), + ("true-colour black", c(Color::True(BLACK)), line(7.0), BLACK, BLACK), + ("ByBlock in an index-255 insert", c(Color::Index(255)), insert(8.0), WHITE, WHITE), + ]; + let mut out = Vec::new(); + for (name, common, kind, paper, canvas) in rows { + d.add(&Space::Model, common, kind).unwrap(); + out.push((name, paper, canvas)); + } + (d, out) +} + +#[test] +fn only_colour_7_flips_with_the_background() { + let (d, cases) = cases(); + let l = build(&d, &Space::Model, &Options::default()); + assert_eq!(l.prims.len(), cases.len()); + for (i, (name, paper, canvas)) in cases.iter().enumerate() { + let p = l.prims.iter().find(|p| (l.points(p)[0].y - i as f64).abs() < 1e-9).unwrap(); + assert_eq!(p.display_rgb(WHITE), *paper, "{name} on paper"); + assert_eq!(p.display_rgb(DARK), *canvas, "{name} on the dark canvas"); + } +} + +#[test] +fn png_draws_true_colours() { + let (d, cases) = cases(); + let l = build(&d, &Space::Model, &Options::default()); + let view = View { center: Vec2::new(5.0, 4.0), scale: 10.0, width: 120, height: 120 }; + for (bg, paper) in [(WHITE, true), (DARK, false)] { + let pm = render(&l, &view, &RasterOptions { background: bg, antialias: false, ..Default::default() }).unwrap(); + for (i, (name, on_paper, on_canvas)) in cases.iter().enumerate() { + let (x, y) = view.to_screen(Vec2::new(5.0, i as f64)); + let px = pm.pixel(x as u32, y as u32).unwrap(); + let want = if paper { on_paper } else { on_canvas }; + assert_eq!(Rgb(px.red(), px.green(), px.blue()), *want, "{name} on {bg:?}"); + } + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3517e4bb..9a041670 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1,7 +1,7 @@ //! The drawing area: grid, entities, selection highlighting, grips, rubber bands, object-snap //! markers, crosshair cursor, ViewCube, UCS icon and mouse handling. -use cadcraft_color::{Rgb, display_rgb}; +use cadcraft_color::Rgb; use cadcraft_doc::Handle; use cadcraft_engine::Input; use cadcraft_engine::snap::{self, SnapHit}; @@ -255,7 +255,7 @@ fn draw_list(p: &egui::Painter, xf: &Xf, list: &DisplayList, bg: Rgb, lwdisplay: let mut shapes: Vec = Vec::with_capacity(list.prims.len()); let px = 1.0 / xf.scale; for prim in &list.prims { - let col = color32(display_rgb(prim.color, bg)); + let col = color32(prim.display_rgb(bg)); let pts = list.points(prim); match prim.kind { Kind::Polyline => { @@ -351,7 +351,7 @@ fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: Rgb, lwdis if let (Kind::Infinite { ray }, Some(b), Some(d)) = (prim.kind, pts.first(), pts.get(1)) && let Some(seg) = clip_infinite(xf, *b, *d, ray) { - shapes.push(Shape::line_segment(seg, Stroke::new(1.0, color32(display_rgb(prim.color, bg))))); + shapes.push(Shape::line_segment(seg, Stroke::new(1.0, color32(prim.display_rgb(bg))))); } } p.extend(shapes); @@ -1138,7 +1138,7 @@ mod tests { use cadcraft_render::DPrim; fn list(handles: &[u64]) -> DisplayList { - let prim = |h| DPrim { handle: Handle(h), color: Rgb(255, 255, 255), lw: 0.0, kind: Kind::Polyline, start: 0, len: 0 }; + let prim = |h| DPrim { handle: Handle(h), color: Rgb(255, 255, 255), aci7: true, lw: 0.0, kind: Kind::Polyline, start: 0, len: 0 }; DisplayList { prims: handles.iter().map(|&h| prim(h)).collect(), ..Default::default() } } diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index b52059fe..a6b6bac7 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -15,7 +15,7 @@ use std::sync::{Arc, Mutex}; -use cadcraft_color::{Rgb, display_rgb}; +use cadcraft_color::Rgb; use cadcraft_geom::{Bounds2, Vec2}; use cadcraft_render::{DisplayList, Kind}; use egui_wgpu::wgpu; @@ -147,7 +147,7 @@ pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) -> quads: Vec::new(), }; for prim in &list.prims { - let c = display_rgb(prim.color, bg); + let c = prim.display_rgb(bg); let col = [c.0, c.1, c.2, 255]; let pts = list.points(prim); match prim.kind { @@ -515,7 +515,7 @@ mod tests { tris: vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)], ..Default::default() }; - let p = |kind, start, len, lw| DPrim { handle: Handle(1), color: Rgb(255, 255, 255), lw, kind, start, len }; + let p = |kind, start, len, lw| DPrim { handle: Handle(1), color: Rgb(255, 255, 255), aci7: true, lw, kind, start, len }; l.prims = vec![p(Kind::Polyline, 0, 3, 0.0), p(Kind::Tris, 0, 3, 0.0), p(Kind::Point, 3, 1, 0.0), p(Kind::Infinite { ray: false }, 0, 2, 0.0)]; for v in l.verts.clone() { @@ -534,7 +534,7 @@ mod tests { assert_eq!(m.quad_instances(), 1, "the point marker"); // First vertex is stored relative to the origin, so it's exactly zero. assert_eq!(&m.lines[0..8], &[0u8; 8]); - // White on a white background inverts to black (display_rgb). + // Colour 7 on a white background inverts to black (display_rgb). assert_eq!(&m.lines[8..12], &[0, 0, 0, 255]); let x = f32::from_le_bytes([m.lines[12], m.lines[13], m.lines[14], m.lines[15]]); assert_eq!(x, 1.0); From 91c2471e2b94354e81581dd474a3daf7b1ff60ac Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:11:30 +0200 Subject: [PATCH 076/150] Wide polylines are drawn with their linetype (fixes #293) (#309) A polyline with width was always filled as one continuous band. Now, with a linetype, each dash is a filled piece of the band (tapers and arc segments included) and the gaps stay empty; dots are drawn as a line across the band. As for thin polylines, PLINEGEN runs the pattern along the whole polyline, otherwise it restarts at every vertex, and a pattern too small to see (min_dash) fills the whole band. linetype::ranges lays the pattern out along a length; wide::dashed cuts the band at those distances. Co-authored-by: Claude Opus 5.5 --- crates/render/src/lib.rs | 18 +++- crates/render/src/linetype.rs | 23 +++++ crates/render/src/wide.rs | 111 +++++++++++++++++++++ crates/render/tests/wide_pline_linetype.rs | 51 ++++++++++ 4 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 crates/render/src/wide.rs create mode 100644 crates/render/tests/wide_pline_linetype.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 326a1c9c..c82beedb 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -16,6 +16,7 @@ pub mod paper; mod point; pub mod raster; pub mod units; +mod wide; use cadcraft_color::{Color, Rgb}; use cadcraft_doc::{Drawing, Entity, EntityKind, Handle, Lineweight, Prim, Space}; @@ -543,8 +544,21 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) { } EntityKind::LwPolyline(p) if p.const_width > 0.0 || p.vertices.iter().any(|v| v.start_width > 0.0 || v.end_width > 0.0) => { if b.opts.fill && ctx.d.header.i64("FILLMODE", 1) != 0 { - let tris = fill::wide_polyline(p, tol); - b.tris(ctx, rgb, &tris); + match < { + // Dashed: each dash a filled piece of the band; dots a line across it. + Some(lt) => { + let min = b.opts.min_dash / ctx.xf.scale_factor().max(1e-12); + let (tris, ticks) = wide::dashed(p, tol, lt, ltscale, min); + b.tris(ctx, rgb, &tris); + for t in &ticks { + match t.as_slice() { + [q] => b.point(ctx, rgb, *q), + _ => b.polyline(ctx, rgb, lw, t), + } + } + } + None => b.tris(ctx, rgb, &fill::wide_polyline(p, tol)), + } } else { let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; stroke(b, &pl.tessellate(tol)); diff --git a/crates/render/src/linetype.rs b/crates/render/src/linetype.rs index ca3e266c..acd576dc 100644 --- a/crates/render/src/linetype.rs +++ b/crates/render/src/linetype.rs @@ -83,6 +83,29 @@ pub fn apply(pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern: f64) -> Vec Option> { + let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; + let total = lt.pattern_length() * scale; + if !(len.is_finite() && len > 0.0) || total <= 1e-12 || total < min_pattern || len / total > 50_000.0 { + return None; + } + let mut out = Vec::new(); + let mut pos = 0.0; + for d in lt.pattern.iter().map(|d| d.length * scale).cycle() { + if pos >= len || out.len() > 2_000_000 { + break; + } + if d >= 0.0 { + out.push((pos, (pos + d).min(len))); + } + pos += d.abs(); + } + Some(out) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/render/src/wide.rs b/crates/render/src/wide.rs new file mode 100644 index 00000000..c4c69caa --- /dev/null +++ b/crates/render/src/wide.rs @@ -0,0 +1,111 @@ +//! Wide polylines with a linetype: each dash is a filled piece of the band, gaps are empty. + +use cadcraft_doc::{Linetype, LwPolyline}; +use cadcraft_geom::{Polyline, Vec2}; + +use crate::linetype; + +/// A point on the centre line: position, unit direction, half width and distance along the run. +#[derive(Clone, Copy, Debug)] +struct Sample { + p: Vec2, + dir: Vec2, + hw: f64, + s: f64, +} + +/// Centre-line samples of each segment, widths interpolated as [`crate::fill::wide_polyline`] +/// does; distances start at 0 for each segment. +fn segments(p: &LwPolyline, tol: f64) -> Vec> { + let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; + let n = p.vertices.len(); + let mut out = Vec::new(); + for (i, seg) in pl.segments().iter().enumerate() { + let v = p.vertices.get(i % n.max(1)).copied().unwrap_or_default(); + let (w0, w1) = if p.const_width > 0.0 { (p.const_width, p.const_width) } else { (v.start_width, v.end_width) }; + let mut pts = Vec::new(); + seg.tessellate(tol, &mut pts); + let m = pts.len(); + if m < 2 { + continue; + } + let mut run = Vec::with_capacity(m); + let mut s = 0.0; + for (k, q) in pts.iter().enumerate() { + if let Some(prev) = k.checked_sub(1).and_then(|j| pts.get(j)) { + s += prev.dist(*q); + } + let t = k as f64 / (m - 1) as f64; + let dir = if k + 1 < m { pts.get(k + 1).map(|nx| *nx - *q) } else { pts.get(k.wrapping_sub(1)).map(|pv| *q - *pv) } + .unwrap_or(Vec2::X) + .normalized(); + run.push(Sample { p: *q, dir, hw: (w0 + (w1 - w0) * t) / 2.0, s }); + } + out.push(run); + } + out +} + +/// The sample at distance `s` along `run` (clamped to its ends). +fn at(run: &[Sample], s: f64) -> Option { + let i = run.partition_point(|x| x.s <= s).saturating_sub(1); + let a = *run.get(i)?; + let Some(b) = run.get(i + 1).copied() else { return Some(a) }; + let len = b.s - a.s; + if len <= 1e-15 { + return Some(a); + } + let t = ((s - a.s) / len).clamp(0.0, 1.0); + let dir = (b.p - a.p).normalized(); + Some(Sample { p: a.p + (b.p - a.p) * t, dir, hw: a.hw + (b.hw - a.hw) * t, s }) +} + +/// The two band edges at a sample. +fn edges(x: &Sample) -> (Vec2, Vec2) { + let nrm = x.dir.perp() * x.hw; + (x.p + nrm, x.p - nrm) +} + +/// Triangles for the dashes of a wide polyline drawn with `lt` at `scale`, plus a line across the +/// band for each dot. With PLINEGEN the pattern runs along the whole polyline; otherwise it +/// restarts at every vertex. A pattern shorter than `min_pattern` fills the whole band. +pub fn dashed(p: &LwPolyline, tol: f64, lt: &Linetype, scale: f64, min_pattern: f64) -> (Vec, Vec>) { + let mut runs = segments(p, tol); + if p.plinegen && runs.len() > 1 { + let mut whole: Vec = Vec::new(); + for run in runs { + let base = whole.last().map(|x| x.s).unwrap_or(0.0); + whole.extend(run.into_iter().map(|x| Sample { s: x.s + base, ..x })); + } + runs = vec![whole]; + } + let mut tris = Vec::new(); + let mut ticks = Vec::new(); + for run in &runs { + let len = run.last().map(|x| x.s).unwrap_or(0.0); + let dashes = linetype::ranges(len, lt, scale, min_pattern).unwrap_or_else(|| vec![(0.0, len)]); + for (a, b) in dashes { + if b - a <= 1e-12 { + if let Some(x) = at(run, a) { + let (l, r) = edges(&x); + ticks.push(if x.hw > 0.0 { vec![l, r] } else { vec![x.p] }); + } + continue; + } + let (Some(first), Some(last)) = (at(run, a), at(run, b)) else { continue }; + let inner = run.iter().filter(|x| x.s > a && x.s < b).copied(); + let pts: Vec = std::iter::once(first).chain(inner).chain(std::iter::once(last)).collect(); + for w in pts.windows(2) { + if let [x, y] = w { + let (a0, a1) = edges(x); + let (b0, b1) = edges(y); + tris.extend_from_slice(&[a0, a1, b1, a0, b1, b0]); + } + } + if tris.len() > 6_000_000 { + return (tris, ticks); + } + } + } + (tris, ticks) +} diff --git a/crates/render/tests/wide_pline_linetype.rs b/crates/render/tests/wide_pline_linetype.rs new file mode 100644 index 00000000..5ae853ec --- /dev/null +++ b/crates/render/tests/wide_pline_linetype.rs @@ -0,0 +1,51 @@ +//! Wide polylines are drawn with their linetype: each dash a filled piece of the band (issue #293). + +use cadcraft_doc::{Common, Drawing, EntityKind, LwPolyline, Space, library}; +use cadcraft_geom::{PolyVertex, Vec2}; +use cadcraft_render::{Kind, Options, build}; + +/// A 0.2-wide polyline through `xs` (on y = 0) with linetype `lt` (DASHED: 0.5 on, 0.25 off). +fn drawing(xs: &[f64], lt: &str, plinegen: bool) -> Drawing { + let mut d = Drawing::new_imperial(); + d.linetypes.extend(library::standard_linetypes().into_iter().filter(|l| l.name == "DASHED")); + let p = LwPolyline { + vertices: xs.iter().map(|&x| PolyVertex::new(Vec2::new(x, 0.0))).collect(), + closed: false, + const_width: 0.2, + elevation: 0.0, + plinegen, + }; + d.add(&Space::Model, Common { linetype: lt.into(), ..Default::default() }, EntityKind::LwPolyline(p)).unwrap(); + d +} + +/// Filled triangles of the drawing. +fn tris(d: &Drawing) -> Vec { + let l = build(d, &Space::Model, &Options::default()); + l.prims.iter().filter(|p| p.kind == Kind::Tris).flat_map(|p| l.points(p).to_vec()).collect() +} + +fn area(t: &[Vec2]) -> f64 { + t.chunks(3).map(|t| ((t[1] - t[0]).cross(t[2] - t[0]) / 2.0).abs()).sum() +} + +#[test] +fn wide_polyline_dashes_are_filled_bands() { + let full = tris(&drawing(&[0.0, 3.0], "Continuous", false)); + assert!((area(&full) - 0.6).abs() < 1e-9); + // 3.0 long = four 0.5 dashes with 0.25 gaps between. + let dashed = tris(&drawing(&[0.0, 3.0], "DASHED", false)); + assert!((area(&dashed) - 0.4).abs() < 1e-9, "{}", area(&dashed)); + assert!(dashed.iter().all(|q| !(q.x > 0.5 + 1e-9 && q.x < 0.75 - 1e-9)), "the first gap is empty"); + assert!(dashed.iter().all(|q| (q.y.abs() - 0.1).abs() < 1e-9), "dashes keep the full width"); +} + +#[test] +fn plinegen_runs_the_pattern_along_the_whole_polyline() { + // Two 0.6 segments: per segment each gets one 0.5 dash; along the whole 1.2 the pattern + // gives 0.5 + 0.45. + let per_segment = area(&tris(&drawing(&[0.0, 0.6, 1.2], "DASHED", false))); + let whole = area(&tris(&drawing(&[0.0, 0.6, 1.2], "DASHED", true))); + assert!((per_segment - 0.2).abs() < 1e-9, "{per_segment}"); + assert!((whole - 0.19).abs() < 1e-9, "{whole}"); +} From 7336a3f597e52008a46330c8f4bbc42942c10fd8 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:11:35 +0200 Subject: [PATCH 077/150] Wipeouts mask what is drawn before them; WIPEOUTFRAME controls the frame (fixes #291) (#308) A wipeout was drawn only as its outline, so objects behind it stayed visible on the canvas and in PNG, SVG and PDF output. The display list gets a new primitive kind, Kind::Mask: the wipeout polygon as triangles that every backend fills with its background colour (paper white for SVG/PDF and plots, the raster background for PNG, the canvas colour or the white sheet on screen), in draw order, so it hides what came before it (DRAWORDER decides). The frame follows WIPEOUTFRAME: 0 none, 1 shown and plotted (default), 2 shown but not plotted. The GPU canvas drew all fills before all lines; it now splits the mesh into draw-order groups at each mask (MeshData::breaks) and draws each group's fills, lines and quads in turn. Infinite lines stay on top as before. Co-authored-by: Claude Opus 5.5 --- crates/io/src/pdf.rs | 4 +- crates/io/src/svg.rs | 4 +- crates/render/src/lib.rs | 38 +++++++++--- crates/render/src/raster.rs | 5 +- crates/render/tests/wipeout_mask.rs | 76 +++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 6 +- crates/ui-egui/src/gpu.rs | 95 ++++++++++++++++++++++++----- 7 files changed, 198 insertions(+), 30 deletions(-) create mode 100644 crates/render/tests/wipeout_mask.rs diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 8d3b281b..1705c37c 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -282,8 +282,10 @@ fn content_stream(list: &cadcraft_render::DisplayList, map: &Map, clip_pt: &Boun } pending = true; } - Kind::Tris => { + Kind::Tris | Kind::Mask => { flush(&mut c, &mut pending); + // A wipeout's mask is paper white. + let rgb = if p.kind == Kind::Mask { white } else { rgb }; if fill != Some(rgb) { color(&mut c, rgb, "rg"); fill = Some(rgb); diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 04f6a390..d3640cda 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -48,7 +48,9 @@ pub fn export_window(d: &Drawing, space: &Space, window: Option) -> Str } s.push_str(r#""/>"#); } - Kind::Tris => { + Kind::Tris | Kind::Mask => { + // A wipeout's mask hides what lies under it with the white background. + let c = if p.kind == Kind::Mask { white.hex() } else { c }; let _ = write!(s, r#" &[Vec2] { match p.kind { - Kind::Tris => self.tris.get(p.start as usize..(p.start + p.len) as usize).unwrap_or(&[]), + Kind::Tris | Kind::Mask => self.tris.get(p.start as usize..(p.start + p.len) as usize).unwrap_or(&[]), _ => self.verts.get(p.start as usize..(p.start + p.len) as usize).unwrap_or(&[]), } } @@ -361,9 +365,9 @@ fn append_clipped(dst: &mut DisplayList, src: &DisplayList, rect: &Bounds2) { push_raw(dst, p, Kind::Polyline, &piece); } } - Kind::Tris => { + Kind::Tris | Kind::Mask => { let t = clip::clip_triangles(pts, rect); - push_raw(dst, p, Kind::Tris, &t); + push_raw(dst, p, p.kind, &t); } Kind::Point => { if let Some(q) = pts.first() @@ -386,13 +390,13 @@ fn append_clipped(dst: &mut DisplayList, src: &DisplayList, rect: &Bounds2) { fn push_raw(dst: &mut DisplayList, p: &DPrim, kind: Kind, pts: &[Vec2]) { let min = match kind { Kind::Polyline => 2, - Kind::Tris => 3, + Kind::Tris | Kind::Mask => 3, _ => 1, }; if pts.len() < min { return; } - let tris = kind == Kind::Tris; + let tris = matches!(kind, Kind::Tris | Kind::Mask); let start = if tris { dst.tris.len() } else { dst.verts.len() } as u32; for q in pts { dst.bounds.add(*q); @@ -586,11 +590,27 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) { } } EntityKind::Wipeout(w) => { - let mut pts = w.boundary.clone(); - if let Some(f) = pts.first().copied() { - pts.push(f); + // Hide what was drawn before it (draw order), then the frame per WIPEOUTFRAME: + // 0 = none, 1 = shown and plotted, 2 = shown but not plotted. + if b.opts.fill { + let n = b.list.prims.len(); + b.tris(ctx, rgb, &fill::triangulate_evenodd(std::slice::from_ref(&w.boundary))); + if let Some(p) = b.list.prims.get_mut(n) { + p.kind = Kind::Mask; + } + } + let frame = match ctx.d.header.i64("WIPEOUTFRAME", 1) { + 0 => false, + 2 => !b.plotting, + _ => 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, &pts); } EntityKind::Image(i) => { let o = i.insert.xy(); diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index b6ccccb9..8b45dbde 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -89,7 +89,10 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option { + Kind::Tris | Kind::Mask => { + if p.kind == Kind::Mask { + paint.set_color_rgba8(o.background.0, o.background.1, o.background.2, 255); + } let mut pb = PathBuilder::new(); for t in pts.chunks(3) { if let [a, b, c] = t { diff --git a/crates/render/tests/wipeout_mask.rs b/crates/render/tests/wipeout_mask.rs new file mode 100644 index 00000000..508b052b --- /dev/null +++ b/crates/render/tests/wipeout_mask.rs @@ -0,0 +1,76 @@ +//! Wipeouts hide what is drawn before them and show their frame per WIPEOUTFRAME (issue #291). + +use cadcraft_color::Rgb; +use cadcraft_doc::{Common, Drawing, EntityKind, Line, Space, Wipeout}; +use cadcraft_geom::{Vec2, Vec3}; +use cadcraft_render::raster::{RasterOptions, View, render}; +use cadcraft_render::{DisplayList, Kind, Options, build, build_plot}; + +fn line() -> EntityKind { + EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(10.0, 10.0, 0.0) }) +} + +fn wipeout() -> EntityKind { + let b = [(2.0, 2.0), (8.0, 2.0), (8.0, 8.0), (2.0, 8.0)]; + EntityKind::Wipeout(Wipeout { boundary: b.iter().map(|&(x, y)| Vec2::new(x, y)).collect() }) +} + +/// A diagonal line and a square wipeout over its middle, in the given draw order. +fn drawing(line_on_top: bool) -> Drawing { + let mut d = Drawing::new_imperial(); + let order = if line_on_top { [wipeout(), line()] } else { [line(), wipeout()] }; + for k in order { + d.add(&Space::Model, Common::default(), k).unwrap(); + } + d +} + +/// Dark (drawn) pixels inside and outside the wipeout, on white paper (colour 7 prints black). +fn dark_pixels(list: &DisplayList) -> (usize, usize) { + let view = View { center: Vec2::new(5.0, 5.0), scale: 10.0, width: 120, height: 120 }; + let o = RasterOptions { background: Rgb(255, 255, 255), antialias: false, ..Default::default() }; + let pm = render(list, &view, &o).unwrap(); + let (mut inside, mut outside) = (0, 0); + for y in 0..120u32 { + for x in 0..120u32 { + let c = pm.pixel(x, y).unwrap(); + if c.red() > 128 { + continue; + } + // Screen (60, 60) is world (5, 5); 10 px per unit. Inside = clear of the frame. + let (wx, wy) = (5.0 + (f64::from(x) + 0.5 - 60.0) / 10.0, 5.0 - (f64::from(y) + 0.5 - 60.0) / 10.0); + if (2.5..7.5).contains(&wx) && (2.5..7.5).contains(&wy) { + inside += 1; + } else { + outside += 1; + } + } + } + (inside, outside) +} + +#[test] +fn wipeout_hides_what_is_drawn_before_it() { + let (inside, outside) = dark_pixels(&build(&drawing(false), &Space::Model, &Options::default())); + assert_eq!(inside, 0, "the line is hidden under the wipeout"); + assert!(outside > 0, "the line shows outside it"); + // Brought to front (drawn after the wipeout), the line shows on top. + let (inside, _) = dark_pixels(&build(&drawing(true), &Space::Model, &Options::default())); + assert!(inside > 0); +} + +#[test] +fn wipeoutframe_controls_the_frame() { + // Polylines in the list: the line, plus the frame when it is drawn. + let polylines = |d: &Drawing, plot: bool| { + let l = if plot { build_plot(d, &Space::Model, &Options::default()) } else { build(d, &Space::Model, &Options::default()) }; + assert_eq!(l.prims.iter().filter(|p| p.kind == Kind::Mask).count(), 1); + l.prims.iter().filter(|p| p.kind == Kind::Polyline).count() + }; + let mut d = drawing(false); + assert_eq!((polylines(&d, false), polylines(&d, true)), (2, 2), "default 1: shown and plotted"); + d.header.set_i64("WIPEOUTFRAME", 0); + assert_eq!((polylines(&d, false), polylines(&d, true)), (1, 1), "0: no frame"); + d.header.set_i64("WIPEOUTFRAME", 2); + assert_eq!((polylines(&d, false), polylines(&d, true)), (2, 1), "2: shown, not plotted"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9a041670..4358e9a3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -278,7 +278,9 @@ fn draw_list(p: &egui::Painter, xf: &Xf, list: &DisplayList, bg: Rgb, lwdisplay: shapes.push(Shape::line(screen, Stroke::new(w, col))); } } - Kind::Tris => { + Kind::Tris | Kind::Mask => { + // A wipeout's mask hides what was drawn before it with the background colour. + let col = if prim.kind == Kind::Mask { color32(bg) } else { col }; let mut mesh = egui::Mesh::default(); for tri in pts.chunks(3) { if let [a, b, c] = tri { @@ -388,7 +390,7 @@ fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, prims: &[usize shapes.push(Shape::circle_stroke(xf.to_screen(*q), 3.0, Stroke::new(1.0, color))); } } - Kind::Tris => { + Kind::Tris | Kind::Mask => { // Outline triangles' bounding region lightly. let bb = Bounds2::from_points(pts.iter().copied()); let r = Rect::from_two_pos(xf.to_screen(bb.min), xf.to_screen(bb.max)); diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index a6b6bac7..b91f46e0 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -7,8 +7,10 @@ //! follows the view (see [`choose_origin`]). //! //! Batches: filled triangles (TriangleList), 1-px hairlines (LineList), and instanced screen-space -//! quads for wide lineweights and point markers. Fills are drawn first, then lines on top. The -//! shader only uses vertex buffers and one uniform buffer, so it runs on WebGPU and WebGL2. +//! quads for wide lineweights and point markers. Fills are drawn first, then lines on top. A +//! wipeout starts a new draw-order group (see [`MeshData::breaks`]): its background-coloured mask +//! is drawn after everything before it and under everything after it. The shader only uses +//! vertex buffers and one uniform buffer, so it runs on WebGPU and WebGL2. //! //! Infinite lines (xline/ray) and all overlays stay egui shapes (see `canvas.rs`), and the //! canvas falls back to the CPU path when no wgpu render state was handed to the app. @@ -98,6 +100,9 @@ pub struct MeshData { pub lines: Vec, pub tris: Vec, pub quads: Vec, + /// Draw-order breaks, one before each wipeout mask: the fill vertices, line vertices and + /// quad instances drawn before it. Each group is drawn fills, lines, quads in turn. + pub breaks: Vec<[usize; 3]>, } impl MeshData { @@ -145,6 +150,7 @@ pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) -> lines: Vec::with_capacity(list.segment_count().saturating_mul(2 * VERTEX_SIZE)), tris: Vec::with_capacity(list.tris.len().saturating_mul(VERTEX_SIZE)), quads: Vec::new(), + breaks: Vec::new(), }; for prim in &list.prims { let c = prim.display_rgb(bg); @@ -175,6 +181,14 @@ pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) -> } } } + Kind::Mask => { + m.breaks.push([m.tri_vertices(), m.line_vertices(), m.quad_instances()]); + for t in pts.as_chunks::<3>().0 { + for q in t { + push_vertex(&mut m.tris, rel(*q, origin), [bg.0, bg.1, bg.2, 255]); + } + } + } Kind::Point => { if let Some(q) = pts.first() { let p = rel(*q, origin); @@ -236,6 +250,7 @@ struct Resources { lines: Vec, tris: Vec, quads: Vec, + breaks: Vec<[usize; 3]>, } const UNIFORM_SIZE: u64 = 48; @@ -388,6 +403,7 @@ impl Resources { lines: Vec::new(), tris: Vec::new(), quads: Vec::new(), + breaks: Vec::new(), } } } @@ -430,6 +446,7 @@ impl egui_wgpu::CallbackTrait for CanvasCallback { res.lines = upload(device, "cad_lines", &m.lines, VERTEX_SIZE, usage); res.tris = upload(device, "cad_fills", &m.tris, VERTEX_SIZE, usage); res.quads = upload(device, "cad_quads", &m.quads, INSTANCE_SIZE, usage); + res.breaks = m.breaks; res.key = Some(self.key); } } @@ -455,24 +472,47 @@ impl egui_wgpu::CallbackTrait for CanvasCallback { // Whole-screen device pixels; egui already set the scissor to the canvas clip rect. pass.set_viewport(0.0, 0.0, sw as f32, sh as f32, 0.0, 1.0); pass.set_bind_group(0, &res.bind_group, &[]); - pass.set_pipeline(&res.tri_pipeline); - for b in &res.tris { - pass.set_vertex_buffer(0, b.buffer.slice(..)); - pass.draw(0..b.count, 0..1); - } - pass.set_pipeline(&res.line_pipeline); - for b in &res.lines { - pass.set_vertex_buffer(0, b.buffer.slice(..)); - pass.draw(0..b.count, 0..1); - } - pass.set_pipeline(&res.quad_pipeline); - for b in &res.quads { - pass.set_vertex_buffer(0, b.buffer.slice(..)); - pass.draw(0..6, 0..b.count); + // Draw-order groups split at each wipeout; without wipeouts there is one group. + let total = |bs: &[Batch]| bs.iter().map(|b| b.count as usize).sum::(); + let all = [total(&res.tris), total(&res.lines), total(&res.quads)]; + let mut from = [0usize; 3]; + for to in res.breaks.iter().copied().chain(std::iter::once(all)) { + for (i, (pipeline, batches)) in + [(&res.tri_pipeline, &res.tris), (&res.line_pipeline, &res.lines), (&res.quad_pipeline, &res.quads)].into_iter().enumerate() + { + let parts = batch_ranges(batches.iter().map(|b| b.count), from[i]..to[i]); + if parts.is_empty() { + continue; + } + pass.set_pipeline(pipeline); + for (j, r) in parts { + let Some(b) = batches.get(j) else { continue }; + pass.set_vertex_buffer(0, b.buffer.slice(..)); + // Quads are instanced: six vertices per instance. + if i == 2 { pass.draw(0..6, r) } else { pass.draw(r, 0..1) } + } + } + from = to; } } } +/// For batches holding `counts` vertices (or instances) each: the batch indices and the ranges +/// within them that cover `range` of the batches laid end to end. +fn batch_ranges(counts: impl IntoIterator, range: std::ops::Range) -> Vec<(usize, std::ops::Range)> { + let mut out = Vec::new(); + let mut off = 0usize; + for (j, n) in counts.into_iter().enumerate() { + let n = n as usize; + let (lo, hi) = (range.start.max(off), range.end.min(off + n)); + if lo < hi { + out.push((j, (lo - off) as u32..(hi - off) as u32)); + } + off += n; + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -580,4 +620,27 @@ mod tests { assert_eq!(choose_origin(&Bounds2::EMPTY, Vec2::new(1.0, 1.0), 1.0), Vec2::ZERO); assert_eq!(choose_origin(&b, Vec2::new(f64::NAN, 0.0), 1.0), b.center()); } + + #[test] + fn wipeout_masks_start_a_draw_order_group() { + let mut l = list(); + if let Some(p) = l.prims.get_mut(1) { + p.kind = Kind::Mask; + } + let bg = Rgb(10, 20, 30); + let m = build_mesh(&l, Vec2::ZERO, bg, false); + // The polyline's two segments come before the mask; the point marker after it. + assert_eq!(m.breaks, vec![[0, 4, 0]]); + assert_eq!(m.tri_vertices(), 3); + assert_eq!(&m.tris[8..12], &[10, 20, 30, 255], "the mask takes the background colour"); + assert_eq!(m.quad_instances(), 1); + } + + #[test] + fn batch_ranges_cover_a_range_across_batches() { + assert_eq!(batch_ranges([4, 4, 4], 2..9), vec![(0, 2..4), (1, 0..4), (2, 0..1)]); + assert_eq!(batch_ranges([4, 4], 4..8), vec![(1, 0..4)]); + assert!(batch_ranges([4, 4], 3..3).is_empty()); + assert!(batch_ranges([], 0..5).is_empty()); + } } From 253f0c8497350b8dd4a42a50968cd6aa02843f48 Mon Sep 17 00:00:00 2001 From: STE1200 <319441566+STE1200@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:11:41 +0200 Subject: [PATCH 078/150] Extents: ignore what draws nothing (rays, missing or empty blocks, hidden block geometry), as ZOOM Extents does (#288) * Extents: ignore rays and references to missing or empty blocks, as ZOOM Extents does Opening real drawings in CADCraft often showed a tiny plan in a huge empty canvas, because the extents included things that draw nothing: - the base point of a RAY (Autodesk's own reference says infinite construction lines and rays do not affect ZOOM Extents; an office plan in our corpus carries two rays 1.98 million units from the building); - the insertion point of an INSERT whose block is missing (a purged definition, a bound xref that never came along) or empty (an unloaded xref): a floor plan had such references 125 000 and 3.4 million units away. XLINEs were already ignored; rays now are too, and an insert contributes only what its block draws (attributes still count). Two regression tests use the measured coordinates. Co-Authored-By: Claude Fable 5.1 * Extents: skip invisible block entities, attdefs and blank texts - Entities in a block definition count for an insert's (and a dimension block's) extents only if they are not AttDefs, have common.visible set, and sit on layer "0" or on a layer that is on and thawed. Dynamic blocks keep hidden constraint geometry far from the visible part. - Text/MText that is empty, whitespace-only or only "\~" has empty bounds. - Tests cover each rule separately. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- crates/doc/src/extents.rs | 41 ++++-- crates/doc/src/tests.rs | 275 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 303 insertions(+), 13 deletions(-) diff --git a/crates/doc/src/extents.rs b/crates/doc/src/extents.rs index dc89e618..e5f5e340 100644 --- a/crates/doc/src/extents.rs +++ b/crates/doc/src/extents.rs @@ -34,6 +34,22 @@ fn text_box(t: &crate::Text, len: usize) -> Bounds2 { Bounds2::from_points(local.map(|p| origin.xy() + p.rotate(t.rotation))) } +/// Whether an entity inside a block definition shows: its own invisible flag and its layer count, +/// as when drawing. Dimensional constraints of dynamic blocks (layer `*ADSK_CONSTRAINTS`) and the +/// entities a visibility state hides are invisible this way, and they sit far from the block's +/// geometry in real drawings. Layer "0" takes the insert's layer, which the caller has checked. +fn in_block_visible(d: &Drawing, e: &Entity) -> bool { + // Attribute definitions are not drawn in an insert (its attribs are, see below). + !matches!(e.kind, EntityKind::AttDef(_)) + && e.common.visible + && (e.common.layer == "0" || d.layer(&e.common.layer).is_none_or(crate::Layer::visible)) +} + +/// A text that draws no glyph (empty, or only blanks and non-breaking spaces) takes no room. +fn blank(s: &str) -> bool { + s.replace("\\~", " ").chars().all(char::is_whitespace) +} + pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 { bounds_in(d, e, depth, &mut HashMap::new()) } @@ -44,6 +60,8 @@ pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 { /// per reference (which grows exponentially with the nesting depth). fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap) -> Bounds2 { match &e.kind { + EntityKind::Text(t) if blank(&t.value) => Bounds2::EMPTY, + EntityKind::MText(t) if blank(&t.contents) => Bounds2::EMPTY, EntityKind::Text(t) => text_box(t, t.value.chars().count()), EntityKind::AttDef(a) => text_box(&a.text, a.tag.chars().count()), EntityKind::MText(t) => { @@ -70,21 +88,23 @@ fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap= MAX_BLOCK_DEPTH { return Bounds2::EMPTY; } - let Some(blk) = d.block(&ins.block) else { return Bounds2::from_points([ins.insert.xy()]) }; + // A reference to a missing block (an unloaded xref, a purged definition) or to an empty + // one draws nothing, so it contributes nothing: ZOOM Extents must not fly off to its + // insertion point. Attributes below still count. + let Some(blk) = d.block(&ins.block) else { return Bounds2::EMPTY }; let m = ins.transform(blk.base.xy()); let inner = match blocks.get(&blk.name) { Some(b) => *b, None => { let mut inner = Bounds2::EMPTY; - for be in blk.entities.iter() { + for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) { inner = inner.union(&bounds_in(d, be, depth + 1, blocks)); } blocks.insert(blk.name.clone(), inner); inner } }; - let mut b = - if inner.is_empty() { Bounds2::from_points([ins.insert.xy()]) } else { Bounds2::from_points(inner.corners().map(|c| m.apply(c))) }; + let mut b = if inner.is_empty() { Bounds2::EMPTY } else { Bounds2::from_points(inner.corners().map(|c| m.apply(c))) }; if ins.cols > 1 || ins.rows > 1 { let off = Vec2::new(ins.col_spacing * f64::from(ins.cols.saturating_sub(1)), ins.row_spacing * f64::from(ins.rows.saturating_sub(1))) .rotate(ins.rotation); @@ -122,7 +142,7 @@ fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap e.bounds(), Prim::Spline(s) => s.bounds(), Prim::Point(p) => Bounds2::from_points([p]), - // Infinite lines don't contribute to extents (as in ZOOM Extents). - Prim::Infinite { base, ray, .. } => { - if ray { - Bounds2::from_points([base]) - } else { - Bounds2::EMPTY - } - } + // Infinite lines don't contribute to extents, rays included (as in ZOOM Extents; + // real drawings carry stray rays millions of units away). + Prim::Infinite { .. } => Bounds2::EMPTY, Prim::Fill(pts) => Bounds2::from_points(pts), }; b = b.union(&pb); diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 2b2f1fd6..2bf9b1f1 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -36,6 +36,49 @@ fn hidden_layers_excluded_from_extents() { assert_eq!(d.extents(&Space::Model).max, Vec2::new(1.0, 1.0)); } +#[test] +fn rays_and_xlines_do_not_stretch_extents() { + // ZOOM Extents ignores infinite lines; real drawings carry stray rays millions of units away. + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), line((0.0, 0.0), (1.0, 1.0))).unwrap(); + let far = Vec3::new(-1_977_989.0, -1_952_638.0, 0.0); + d.add(&Space::Model, Common::default(), EntityKind::Ray(RayLine { base: far, dir: Vec3::new(-0.7, -0.7, 0.0) })).unwrap(); + d.add(&Space::Model, Common::default(), EntityKind::XLine(RayLine { base: far, dir: Vec3::new(1.0, 0.0, 0.0) })).unwrap(); + let b = d.extents(&Space::Model); + assert_eq!((b.min, b.max), (Vec2::ZERO, Vec2::new(1.0, 1.0))); +} + +#[test] +fn inserts_of_missing_or_empty_blocks_do_not_stretch_extents() { + // An unloaded xref or a purged definition draws nothing, so its insertion point is not an extent. + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), line((0.0, 0.0), (1.0, 1.0))).unwrap(); + let ins = |block: &str, x: f64, y: f64| { + EntityKind::Insert(Insert { + block: block.into(), + insert: Vec3::new(x, y, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: vec![], + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }) + }; + d.blocks.insert("Empty".into(), std::sync::Arc::new(Block::new("Empty"))); + d.add(&Space::Model, Common::default(), ins("Missing", -125_319.0, -79_990.0)).unwrap(); + d.add(&Space::Model, Common::default(), ins("Empty", 55_652.0, -3_448_576.0)).unwrap(); + let b = d.extents(&Space::Model); + assert_eq!((b.min, b.max), (Vec2::ZERO, Vec2::new(1.0, 1.0))); + // A block with geometry still counts. + let mut full = Block::new("Full"); + full.entities.push(Entity::new(Handle(1), line((0.0, 0.0), (2.0, 2.0)))); + d.blocks.insert("Full".into(), std::sync::Arc::new(full)); + d.add(&Space::Model, Common::default(), ins("Full", 10.0, 10.0)).unwrap(); + assert_eq!(d.extents(&Space::Model).max, Vec2::new(12.0, 12.0)); +} + #[test] fn transform_arc_mirror_keeps_shape() { let mut k = EntityKind::Arc(Arc { center: Vec3::ZERO, radius: 1.0, start: 0.0, end: std::f64::consts::FRAC_PI_2 }); @@ -187,3 +230,235 @@ fn wide_arc_segment_extents_reach_the_outer_edge() { assert!(over > -1e-6 && over < 1e-3, "got {b:?}, exact {exact:?}"); } } + +fn plain_insert(block: &str) -> EntityKind { + EntityKind::Insert(Insert { + block: block.into(), + insert: Vec3::ZERO, + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: vec![], + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }) +} + +fn on_layer(layer: &str, kind: EntityKind, h: u64) -> Entity { + Entity { handle: Handle(h), common: Common { layer: layer.into(), ..Default::default() }, kind } +} + +/// Model space with one insert of a block holding a near line (0,0)-(1,1) plus `extra`. +fn drawing_with_block(extra: Entity) -> Drawing { + let mut d = Drawing::new_imperial(); + let mut blk = Block::new("B"); + blk.entities.push(Entity::new(Handle(1), line((0.0, 0.0), (1.0, 1.0)))); + blk.entities.push(extra); + d.blocks.insert("B".into(), std::sync::Arc::new(blk)); + d.add(&Space::Model, Common::default(), plain_insert("B")).unwrap(); + d +} + +fn far_line(layer: &str) -> Entity { + on_layer(layer, line((5000.0, 5000.0), (6000.0, 6000.0)), 2) +} + +fn extents_pair(d: &Drawing) -> (Vec2, Vec2) { + let b = d.extents(&Space::Model); + (b.min, b.max) +} + +#[test] +fn block_entity_with_invisible_flag_is_ignored_in_insert_extents() { + let mut far = far_line("0"); + far.common.visible = false; + let d = drawing_with_block(far); + assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0))); + // Control: the same entity visible does count. + let d = drawing_with_block(far_line("0")); + assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0)); +} + +#[test] +fn block_entity_on_off_or_frozen_layer_is_ignored() { + for frozen in [false, true] { + let mut d = drawing_with_block(far_line("Hidden")); + assert!(d.layer("Hidden").is_none(), "block contents do not create layers"); + let mut layer = Layer::new("Hidden"); + if frozen { + layer.frozen = true; + } else { + layer.on = false; + } + d.layers.push(layer); + assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0)), "frozen = {frozen}"); + // Turned back on, it counts again. + let l = d.layer_mut("Hidden").unwrap(); + l.on = true; + l.frozen = false; + assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0)); + } +} + +#[test] +fn block_entity_on_layer_zero_counts_even_if_layer_zero_is_off_or_missing() { + // Layer "0" takes the insert's layer, which the caller has already checked; so it counts here. + // The insert itself sits on layer "Vis". + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common { layer: "Vis".into(), ..Default::default() }, plain_insert("B")).unwrap(); + let mut blk = Block::new("B"); + blk.entities.push(far_line("0")); + d.blocks.insert("B".into(), std::sync::Arc::new(blk)); + // Layer 0 switched off. + d.layer_mut("0").unwrap().on = false; + assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0)); + // Layer 0 frozen. + let l = d.layer_mut("0").unwrap(); + l.on = true; + l.frozen = true; + assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0)); + // Layer 0 not defined at all. + d.layers.retain(|l| l.name != "0"); + assert!(d.layer("0").is_none()); + assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0)); +} + +#[test] +fn block_entity_on_undefined_layer_counts() { + // An unknown layer name is not hidden (is_none_or). + let d = drawing_with_block(far_line("Nowhere")); + assert!(d.layer("Nowhere").is_none()); + assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0)); +} + +#[test] +fn attdef_in_block_is_ignored_in_insert_extents() { + let attdef = Entity::new( + Handle(2), + EntityKind::AttDef(Attrib { + tag: "TAG".into(), + text: Text { + insert: Vec3::new(5000.0, 5000.0, 0.0), + align_pt: None, + height: 1.0, + value: "x".into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: HAlign::Left, + valign: VAlign::Baseline, + }, + invisible: false, + constant: false, + prompt: String::new(), + }), + ); + let d = drawing_with_block(attdef); + assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0))); +} + +fn text_kind(value: &str) -> EntityKind { + EntityKind::Text(Text { + insert: Vec3::new(10.0, 10.0, 0.0), + align_pt: None, + height: 2.0, + value: value.into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: HAlign::Left, + valign: VAlign::Baseline, + }) +} + +fn mtext_kind(contents: &str) -> EntityKind { + EntityKind::MText(MText { + insert: Vec3::new(10.0, 10.0, 0.0), + height: 2.0, + width: 0.0, + attach: 1, + rotation: 0.0, + style: "Standard".into(), + contents: contents.into(), + line_spacing: 1.0, + }) +} + +#[test] +fn blank_text_and_mtext_have_no_bounds() { + let d = Drawing::new_imperial(); + for s in ["", " ", " ", "\t \n", "\\~", " \\~ \\~", "\u{a0}"] { + for kind in [text_kind(s), mtext_kind(s)] { + let b = entity_bounds(&d, &Entity::new(Handle(1), kind), 0); + assert!(b.is_empty(), "{s:?} should have empty bounds"); + } + } + // Blank texts do not stretch the drawing extents either. + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), line((0.0, 0.0), (1.0, 1.0))).unwrap(); + d.add(&Space::Model, Common::default(), text_kind(" \\~")).unwrap(); + d.add(&Space::Model, Common::default(), mtext_kind("")).unwrap(); + assert_eq!(extents_pair(&d), (Vec2::ZERO, Vec2::new(1.0, 1.0))); +} + +#[test] +fn non_blank_text_and_mtext_have_bounds() { + let d = Drawing::new_imperial(); + for kind in [text_kind("Hi"), text_kind(" a "), mtext_kind("Hi"), mtext_kind("a\\Pb"), mtext_kind("\\~x")] { + let b = entity_bounds(&d, &Entity::new(Handle(1), kind.clone()), 0); + assert!(!b.is_empty(), "{kind:?} should have bounds"); + } +} + +#[test] +fn invisible_dimblock_entity_is_ignored_in_dimension_extents() { + // Dynamic blocks keep dimensional constraints on layer *ADSK_CONSTRAINTS (group 60 = 1). + let dim = |d: &mut Drawing, far: Entity| { + let mut blk = Block::new("*D1"); + blk.entities.push(Entity::new(Handle(1), line((0.0, 0.0), (1.0, 0.0)))); + blk.entities.push(far); + d.blocks.insert("*D1".into(), std::sync::Arc::new(blk)); + d.add( + &Space::Model, + Common::default(), + EntityKind::Dimension(Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(1.0, 1.0, 0.0), + text_mid: Vec3::new(0.5, 1.0, 0.0), + p13: Vec3::ZERO, + p14: Vec3::new(1.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 1.0, + text_rotation: 0.0, + user_text_pos: false, + block: Some("*D1".into()), + overrides: Default::default(), + assoc: vec![], + }), + ) + .unwrap(); + }; + // Invisible flag. + let mut d = Drawing::new_imperial(); + let mut hidden = on_layer("*ADSK_CONSTRAINTS", line((5000.0, 5000.0), (6000.0, 6000.0)), 2); + hidden.common.visible = false; + dim(&mut d, hidden); + assert!(d.extents(&Space::Model).max.x < 100.0); + // Hidden by its layer state. + let mut d = Drawing::new_imperial(); + let mut l = Layer::new("*ADSK_CONSTRAINTS"); + l.on = false; + d.layers.push(l); + dim(&mut d, on_layer("*ADSK_CONSTRAINTS", line((5000.0, 5000.0), (6000.0, 6000.0)), 2)); + assert!(d.extents(&Space::Model).max.x < 100.0); + // Control: a visible far entity does count. + let mut d = Drawing::new_imperial(); + dim(&mut d, far_line("0")); + assert_eq!(d.extents(&Space::Model).max, Vec2::new(6000.0, 6000.0)); +} From c87a38546cfa7b8400947cbb32e638aac11b3db6 Mon Sep 17 00:00:00 2001 From: by Truitt <133776756+Tru10@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:11:46 -0500 Subject: [PATCH 079/150] Default save format for new drawings (DXF or DWG) (#298) Save As suggested `.dxf` for every untitled drawing, so saving new work as DWG meant typing the extension each time. AutoCAD keeps this choice in Options > Open and Save > "Save as". - File > Default Save Format > DXF / DWG sets the format Save As suggests for drawings that have no file yet. Drawings opened from a file keep that file's format, as before. DXF stays the default. - The choice is saved across restarts with the other preferences (`saveFormat`), survives Reset Palettes, and is scriptable as `ui.saveformat {format}` or the `saveFormat` UiState field. - Ukrainian labels for the new menu entries. On macOS the suggested `.dwg` name needs DWG in the Save As filter list (#198) to survive the dialog. Claude-Session: https://claude.ai/code/session_019uMCkRAB8DMd8MEmoY9Vyr Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/i18n/uk.tsv | 3 ++ crates/ui-egui/src/lib.rs | 82 +++++++++++++++++++++++++++++++++- crates/ui-egui/src/menus.rs | 30 +++++++++++-- docs/control-protocol.md | 2 +- 4 files changed, 110 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b442c499..a31d4094 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -141,8 +141,11 @@ Cut Вирізати Cyan Блакитний Cylinder Циліндр + DWG DWG + DXF DXF Dark Темна Default Типово + Default Save Format Типовий формат збереження Define Attributes Визначити атрибути Define Attributes... Визначити атрибути... Defined width Задана ширина diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index da19a0b4..56fcad67 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -69,6 +69,45 @@ pub struct UiState { pub history_lines: usize, /// Interface theme: "system" (follow the OS light/dark appearance), "light" or "dark". pub theme: theme::ThemePref, + /// The format Save As suggests for a drawing that has no file yet: "dxf" or "dwg". + pub save_format: SaveFormat, +} + +/// The default save format for new drawings, like AutoCAD's Options > Open and Save > "Save as". +/// A drawing opened from a file keeps that file's format. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SaveFormat { + /// CADCraft's native format, and the default. + #[default] + Dxf, + Dwg, +} + +impl SaveFormat { + pub const ALL: [SaveFormat; 2] = [SaveFormat::Dxf, SaveFormat::Dwg]; + + /// The file extension, which is also the saved name ("dxf" or "dwg"). + pub fn as_str(self) -> &'static str { + match self { + SaveFormat::Dxf => "dxf", + SaveFormat::Dwg => "dwg", + } + } + + pub fn parse(s: &str) -> Option { + SaveFormat::ALL.into_iter().find(|f| f.as_str().eq_ignore_ascii_case(s.trim().trim_start_matches('.'))) + } + + /// The name Save As suggests: a title that already has an extension (a drawing opened from a + /// file) keeps it; an untitled drawing gets this format's extension. + pub fn suggested_name(self, title: Option<&str>) -> String { + match title.map(str::trim).filter(|t| !t.is_empty()) { + Some(t) if std::path::Path::new(t).extension().is_some() => t.to_owned(), + Some(t) => format!("{t}.{}", self.as_str()), + None => format!("Drawing.{}", self.as_str()), + } + } } impl Default for UiState { @@ -93,6 +132,7 @@ impl Default for UiState { dialog: None, history_lines: 3, theme: theme::ThemePref::default(), + save_format: SaveFormat::default(), } } } @@ -226,7 +266,12 @@ impl CadApp { /// Preferences kept across restarts, as JSON for the host's storage ([`PREFS_KEY`]). pub fn prefs_json(&self) -> String { - json!({ "theme": self.ui.theme.as_str(), "interfaceLanguage": self.ui.interface_language.code() }).to_string() + json!({ + "theme": self.ui.theme.as_str(), + "interfaceLanguage": self.ui.interface_language.code(), + "saveFormat": self.ui.save_format.as_str(), + }) + .to_string() } /// Restore preferences saved by [`Self::prefs_json`]; unknown or malformed values are ignored. @@ -238,6 +283,9 @@ impl CadApp { if let Some(l) = v.get("interfaceLanguage").and_then(Value::as_str).and_then(i18n::Preference::parse) { self.ui.interface_language = l; } + if let Some(f) = v.get("saveFormat").and_then(Value::as_str).and_then(SaveFormat::parse) { + self.ui.save_format = f; + } } pub fn set_status(&mut self, s: impl Into) { @@ -524,6 +572,36 @@ mod tests { frame(&mut a, &ctx, None); assert_eq!(Tokens::get(), Tokens::of(theme::SYSTEM_FALLBACK), "no OS appearance: the documented fallback"); assert_eq!(a.ui.theme, ThemePref::System); - assert_eq!(a.prefs_json(), r#"{"interfaceLanguage":"auto","theme":"system"}"#); + assert_eq!(a.prefs_json(), r#"{"interfaceLanguage":"auto","saveFormat":"dxf","theme":"system"}"#); + } + + #[test] + fn default_save_format_persists_and_names_new_drawings() { + let mut a = app(); + assert_eq!(a.ui.save_format, SaveFormat::Dxf, "default keeps DXF, CADCraft's native format"); + assert!(a.run("ui.saveformat", json!({ "format": "pdf" })).is_err()); + assert_eq!(a.run("ui.saveformat", json!({ "format": "DWG" })).ok(), Some(json!({ "saveFormat": "dwg" }))); + assert_eq!(a.run("ui.saveformat", json!({})).ok(), Some(json!({ "saveFormat": "dwg" })), "no format: report it"); + // Saved and restored by the host; bad values are ignored. + let mut b = app(); + b.load_prefs(&a.prefs_json()); + assert_eq!(b.ui.save_format, SaveFormat::Dwg); + b.load_prefs(r#"{"saveFormat": "svg"}"#); + assert_eq!(b.ui.save_format, SaveFormat::Dwg); + // The menu items reach the same setting, and resetting the palettes keeps it. + b.start("ui.saveformat.dxf"); + assert_eq!(b.ui.save_format, SaveFormat::Dxf); + b.start("ui.saveformat.dwg"); + b.start("ui.resetpalettes"); + assert_eq!(b.ui.save_format, SaveFormat::Dwg); + // Untitled drawings get the format's extension; files keep their own. + assert_eq!(SaveFormat::Dwg.suggested_name(Some("Drawing1")), "Drawing1.dwg"); + assert_eq!(SaveFormat::Dxf.suggested_name(Some("Drawing1")), "Drawing1.dxf"); + assert_eq!(SaveFormat::Dwg.suggested_name(Some("Floorplan.dxf")), "Floorplan.dxf"); + assert_eq!(SaveFormat::Dxf.suggested_name(Some("Floorplan.DWG")), "Floorplan.DWG"); + assert_eq!(SaveFormat::Dwg.suggested_name(None), "Drawing.dwg"); + assert_eq!(SaveFormat::Dwg.suggested_name(Some(" ")), "Drawing.dwg"); + let ui: UiState = serde_json::from_value(json!({ "saveFormat": "dwg" })).unwrap_or_default(); + assert_eq!(ui.save_format, SaveFormat::Dwg); } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e41d4d8e..0c614e67 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -26,6 +26,9 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("ui.theme.light", "Light", &["View", "Interface Theme", "Light"], None), ("ui.theme.dark", "Dark", &["View", "Interface Theme", "Dark"], None), ("ui.theme", "Interface Theme", &[], None), + ("ui.saveformat.dxf", "DXF", &["File", "Default Save Format", "DXF"], None), + ("ui.saveformat.dwg", "DWG", &["File", "Default Save Format", "DWG"], None), + ("ui.saveformat", "Default Save Format", &[], None), ("ui.toggle.menubar", "In-window Menu Bar", &["Window", "In-window Menu Bar"], None), ("ui.hidepalettes", "Hide Palettes", &["Window", "Hide Palettes"], None), ("ui.resetpalettes", "Reset Palettes", &["Window", "Reset Palettes"], None), @@ -82,8 +85,8 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option { - let name = app.session.state().map(|s| s.title.clone()).unwrap_or_else(|_| "Drawing.dxf".into()); - let name = if name.contains('.') { name } else { format!("{name}.dxf") }; + let title = app.session.state().map(|s| s.title.clone()).ok(); + let name = app.ui.save_format.suggested_name(title.as_deref()); if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name)) { return Some(app.session.execute("saveas", &json!({ "path": p })).map_err(|e| e.to_string())); } @@ -164,14 +167,27 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option Ok(theme_json(app)), }, + "ui.saveformat.dxf" | "ui.saveformat.dwg" => { + let format = crate::SaveFormat::parse(id.trim_start_matches("ui.saveformat.")).unwrap_or_default(); + Ok(set_save_format(app, format)) + } + // `{"format": "dxf"|"dwg"}` sets the default save format for new drawings; without it, + // reports it. + "ui.saveformat" => match params.get("format").and_then(Value::as_str) { + Some(name) => match crate::SaveFormat::parse(name) { + Some(format) => Ok(set_save_format(app, format)), + None => Err(format!("unknown save format \"{name}\" (dxf or dwg)")), + }, + None => Ok(json!({ "saveFormat": app.ui.save_format.as_str() })), + }, "ui.hidepalettes" => { app.ui.show_palettes = false; app.ui.show_toolsets = false; Ok(Value::Null) } "ui.resetpalettes" => { - let (menu, theme, interface_language) = (app.ui.in_window_menu, app.ui.theme, app.ui.interface_language); - app.ui = crate::UiState { in_window_menu: menu, theme, interface_language, ..Default::default() }; + let (menu, theme, interface_language, save_format) = (app.ui.in_window_menu, app.ui.theme, app.ui.interface_language, app.ui.save_format); + app.ui = crate::UiState { in_window_menu: menu, theme, interface_language, save_format, ..Default::default() }; Ok(Value::Null) } "ui.start" => { @@ -256,6 +272,12 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option Value { + app.ui.save_format = format; + app.session.echo(format!("New drawings will be saved as {}", format.as_str().to_ascii_uppercase())); + json!({ "saveFormat": format.as_str() }) +} + fn set_theme(app: &mut CadApp, pref: crate::theme::ThemePref) -> Result { app.ui.theme = pref; Ok(theme_json(app)) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 51926822..38378efe 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -25,7 +25,7 @@ Errors come back as `{"id": …, "ok": false, "error": "message"}`. | `ui.click` / `ui.move` | `{x, y, button?, shift?}` | Real egui pointer events in screen points (reach every widget). | | `ui.drag` | `{x, y, to: [x, y], button?}` | Real press, move and release (drag the command line's top edge, the ViewCube ring…). | | `ui.key` / `ui.text` | `{key, cmd?, shift?, alt?}` / `{text}` | Synthetic keyboard input. | -| `ui.set` | UiState fields | Show/hide palettes, toolbars, command line, ViewCube…; `theme: "system"\|"light"\|"dark"` (also `engine.execute` with `ui.theme {theme}`); `ui.inspect` reports the theme shown (`theme`: light/dark). | +| `ui.set` | UiState fields | Show/hide palettes, toolbars, command line, ViewCube…; `theme: "system"\|"light"\|"dark"` (also `engine.execute` with `ui.theme {theme}`); `ui.inspect` reports the theme shown (`theme`: light/dark). `saveFormat: "dxf"\|"dwg"` (also `ui.saveformat {format}`) is the format Save As suggests for new drawings. | | `ui.resize` | `{width, height}` | Resize the window. | | `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). | | `ui.render` | `{path?, width?, height?, fit?}` | Headless render. `fit` (default true) frames the whole drawing; `false` uses the current view. Replies `{pngBase64, width, height, bytes, path?}`. | From 461761359594149b6a75091f0064b3d498378a1a Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:11:52 +0200 Subject: [PATCH 080/150] Draw the text of complex linetypes along the line (fixes #312) (#316) Linetype text elements (FENCELINE1, GAS-style linetypes from DXF files) were drawn as part of the gap. Each text is now placed where the element that carries it ends, at every repeat of the pattern: in its text style's font, at the element's scale times the style's fixed height and the linetype scale, shifted by its X/Y offsets along and across the line, and rotated relative to the line direction or absolutely. Runs drawn continuous (pattern smaller than a pixel) draw no text, and coarse previews without text skip it. Shape elements need SHX shape files, which CADCraft doesn't read, so they still draw nothing. FENCELINE1 carries its circle on the first gap, centred between the two gaps, so it no longer sits on the start of the long dash. Co-authored-by: Claude Opus 5.5 --- crates/doc/src/library.rs | 9 +- crates/render/src/lib.rs | 7 + crates/render/src/linetype_text.rs | 193 +++++++++++++++++++++++++++ crates/render/tests/linetype_text.rs | 100 ++++++++++++++ 4 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 crates/render/src/linetype_text.rs create mode 100644 crates/render/tests/linetype_text.rs diff --git a/crates/doc/src/library.rs b/crates/doc/src/library.rs index a280aca5..0a455275 100644 --- a/crates/doc/src/library.rs +++ b/crates/doc/src/library.rs @@ -33,8 +33,15 @@ pub fn standard_linetypes() -> Vec { description: "Fenceline circle ----0-----0----".into(), pattern: vec![ DashElement::dash(0.25), + // The text goes where its element ends: centred between the two gaps. + DashElement { + text: Some("o".into()), + style: Some("Standard".into()), + scale: 0.1, + offset: cadcraft_geom::Vec2::new(-0.035, -0.035), + ..DashElement::dash(-0.1) + }, DashElement::dash(-0.1), - DashElement { text: Some("o".into()), style: Some("Standard".into()), scale: 0.1, ..DashElement::dash(-0.1) }, DashElement::dash(0.5), ], }, diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 505571ff..30873021 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -12,6 +12,7 @@ mod dim; mod fill; mod hatch; mod linetype; +mod linetype_text; pub mod paper; mod point; pub mod raster; @@ -485,6 +486,12 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) { b.polyline(ctx, rgb, lw, &dash); } } + // Text embedded in the linetype (shapes need SHX files and are not drawn). + if b.opts.text { + for sh in linetype_text::texts(ctx.d, pts, lt, ltscale, min) { + b.shaped(ctx, rgb, lw, &sh); + } + } } None => b.polyline(ctx, rgb, lw, pts), }; diff --git a/crates/render/src/linetype_text.rs b/crates/render/src/linetype_text.rs new file mode 100644 index 00000000..a012f0df --- /dev/null +++ b/crates/render/src/linetype_text.rs @@ -0,0 +1,193 @@ +//! Text embedded in complex linetypes (a gas line's `---- GAS ---- GAS ----`). +//! +//! A dash element may carry a text (DXF `74` bit 2, `9` string). As in a `.lin` definition, +//! where the bracketed text follows the dash or gap that carries it, the text is placed where +//! that element ends, at every repeat of the pattern (laid out from the start of the run like +//! [`crate::linetype::apply`]): +//! - height: the element's scale (`46`), times the text style's fixed height when it has one, +//! times the linetype scale; +//! - origin: the anchor shifted by the X/Y offsets (`44`/`45`, times the linetype scale) along +//! and across the line; +//! - rotation (`50`): relative to the line direction, or absolute (`74` bit 1); +//! - font, width factor, oblique angle and mirroring from the text style (`340`). +//! +//! Shape elements (`74` bit 4) need SHX shape files, which CADCraft does not read: they draw +//! nothing and leave their gap empty. + +use cadcraft_doc::{Drawing, Linetype}; +use cadcraft_fonts::{Shaped, TextFont, TextParams}; +use cadcraft_geom::Vec2; + +/// Upper bound on the texts drawn along one run (hostile scales; `apply` caps the dashes). +const MAX_TEXTS: usize = 10_000; + +/// One text element of the pattern, shaped once at the origin. +struct Element { + /// Distance from the start of a pattern repeat to the anchor (where the element ends). + at: f64, + local: Shaped, + rotation: f64, + absolute: bool, + offset: Vec2, +} + +/// The texts of `lt` along the polyline `pts`, in the coordinates of `pts`. Empty when the +/// linetype has no text or its scaled pattern is drawn continuous (shorter than `min_pattern`, +/// or repeating too often). +pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern: f64) -> Vec { + let mut out = Vec::new(); + if pts.len() < 2 || !lt.pattern.iter().any(|e| e.text.as_deref().is_some_and(|t| !t.trim().is_empty())) { + return out; + } + let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; + let total = lt.pattern_length() * scale; + let len: f64 = pts.windows(2).map(|w| w.first().zip(w.get(1)).map(|(a, b)| a.dist(*b)).unwrap_or(0.0)).sum(); + if !(total.is_finite() && total > 1e-12 && len.is_finite()) || total < min_pattern || len / total > 50_000.0 { + return out; + } + let elements = elements(d, lt, scale); + if elements.is_empty() { + return out; + } + let mut walk = Walk { pts, i: 0, start: 0.0 }; + let mut base = 0.0; + 'repeats: while base <= len { + for el in &elements { + let s = base + el.at; + if s > len + 1e-9 || out.len() >= MAX_TEXTS { + break 'repeats; + } + let Some((p, dir)) = walk.at(s) else { break 'repeats }; + let origin = p + dir * el.offset.x + dir.perp() * el.offset.y; + let rot = if el.absolute { el.rotation } else { dir.angle() + el.rotation }; + let mut sh = el.local.clone(); + sh.map(|q| origin + q.rotate(rot)); + out.push(sh); + } + base += total; + } + out +} + +/// The text elements of `lt`, scaled by `scale`, in pattern order. +fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec { + let mut out = Vec::new(); + let mut at = 0.0; + for el in <.pattern { + at += el.length.abs() * scale; + let Some(text) = el.text.as_deref().filter(|t| !t.trim().is_empty()) else { continue }; + let ts = d.text_style(el.style.as_deref().unwrap_or("Standard")).cloned().unwrap_or_default(); + let k = if el.scale.is_finite() && el.scale > 0.0 { el.scale } else { 1.0 }; + let fixed = if ts.height.is_finite() && ts.height > 0.0 { ts.height } else { 1.0 }; + let height = k * fixed * scale; + if !(height.is_finite() && height > 0.0) { + continue; + } + let font = TextFont::resolve(&ts.font); + let params = TextParams { + width_factor: if ts.width_factor.is_finite() && ts.width_factor > 0.0 { ts.width_factor } else { 1.0 }, + oblique: if ts.oblique.is_finite() { ts.oblique } else { 0.0 }, + backwards: ts.backwards, + upside_down: ts.upside_down, + ..TextParams::new(Vec2::ZERO, height) + }; + let (local, _) = cadcraft_fonts::place(&font, text, ¶ms); + let finite = |v: f64| if v.is_finite() { v } else { 0.0 }; + out.push(Element { + at, + local, + rotation: finite(el.rotation), + absolute: el.absolute, + offset: Vec2::new(finite(el.offset.x), finite(el.offset.y)) * scale, + }); + } + out +} + +/// Points along a polyline at increasing distances. +struct Walk<'a> { + pts: &'a [Vec2], + /// Current segment `pts[i]..pts[i + 1]`. + i: usize, + /// Distance from the start of the polyline to `pts[i]`. + start: f64, +} + +impl Walk<'_> { + /// The point at distance `s` (not less than the previous call's) and the unit direction of + /// its segment; the last segment is extended for `s` slightly past the end. + fn at(&mut self, s: f64) -> Option<(Vec2, Vec2)> { + loop { + let (a, b) = (*self.pts.get(self.i)?, *self.pts.get(self.i + 1)?); + let seg = a.dist(b); + let last = self.i + 2 >= self.pts.len(); + if seg > 1e-15 && (s <= self.start + seg || last) { + let dir = (b - a) / seg; + return Some((a + dir * (s - self.start), dir)); + } + if last { + return None; + } + self.start += seg; + self.i += 1; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cadcraft_doc::DashElement; + + fn gas() -> Linetype { + Linetype { + name: "GAS".into(), + description: String::new(), + pattern: vec![ + DashElement::dash(0.5), + DashElement { text: Some("G".into()), scale: 0.1, ..DashElement::dash(-0.2) }, + DashElement::dash(-0.25), + ], + } + } + + /// The leftmost x of each text. + fn starts(sh: &[Shaped]) -> Vec { + sh.iter().map(|s| s.strokes.iter().flatten().map(|p| p.x).fold(f64::INFINITY, f64::min)).collect() + } + + #[test] + fn one_text_per_repeat_at_the_end_of_its_element() { + let d = Drawing::new_imperial(); + let t = texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &gas(), 1.0, 0.0); + // Pattern 0.95 long: anchors at 0.7 and 1.65. + assert_eq!(t.len(), 2); + let x = starts(&t); + assert!(x[0] >= 0.69 && x[0] < 0.75, "{x:?}"); + assert!(x[1] >= 1.64 && x[1] < 1.70, "{x:?}"); + } + + #[test] + fn no_text_when_drawn_continuous() { + let d = Drawing::new_imperial(); + assert!(texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &gas(), 1.0, 5.0).is_empty()); + let plain = Linetype::simple("DASHED", "", &[0.5, -0.25]); + assert!(texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &plain, 1.0, 0.0).is_empty()); + } + + #[test] + fn hostile_values_never_panic() { + let d = Drawing::new_imperial(); + let mut lt = gas(); + for el in &mut lt.pattern { + el.scale = f64::NAN; + el.offset = Vec2::new(f64::INFINITY, f64::NAN); + el.rotation = f64::NEG_INFINITY; + } + let pts = [Vec2::ZERO, Vec2::ZERO, Vec2::new(1e6, 0.0), Vec2::new(1e6, 0.0)]; + for scale in [f64::NAN, 0.0, -1.0, 1e-9, 1e300] { + let t = texts(&d, &pts, <, scale, 0.0); + assert!(t.len() <= MAX_TEXTS); + } + } +} diff --git a/crates/render/tests/linetype_text.rs b/crates/render/tests/linetype_text.rs new file mode 100644 index 00000000..38cb4b4b --- /dev/null +++ b/crates/render/tests/linetype_text.rs @@ -0,0 +1,100 @@ +//! Text embedded in complex linetypes is drawn along the line (issue #312). + +use cadcraft_doc::{Common, DashElement, Drawing, EntityKind, Line, Linetype, Space, library}; +use cadcraft_geom::{Bounds2, Vec2, Vec3}; +use cadcraft_render::{Kind, Options, build}; + +fn drawing(lt: Linetype, a: Vec2, b: Vec2) -> Drawing { + let mut d = Drawing::new_imperial(); + let name = lt.name.clone(); + d.linetypes.push(lt); + let common = Common { linetype: name, ..Default::default() }; + d.add(&Space::Model, common, EntityKind::Line(Line { a: Vec3::new(a.x, a.y, 0.0), b: Vec3::new(b.x, b.y, 0.0) })).unwrap(); + d +} + +/// Bounds of the drawn polylines that leave the line through `a`-`b` (the text strokes). +fn off_line(d: &Drawing, opts: &Options, a: Vec2, b: Vec2) -> Vec { + let l = build(d, &Space::Model, opts); + let dir = (b - a).normalized(); + let dist = |p: Vec2| (p - a).x * dir.y - (p - a).y * dir.x; + l.prims + .iter() + .filter(|p| p.kind == Kind::Polyline) + .map(|p| l.points(p)) + .filter(|pts| pts.iter().any(|q| dist(*q).abs() > 1e-6)) + .map(|pts| Bounds2::from_points(pts.iter().copied())) + .collect() +} + +#[test] +fn fenceline_draws_a_circle_in_every_gap() { + let fence = library::standard_linetypes().into_iter().find(|l| l.name == "FENCELINE1").unwrap(); + let (a, b) = (Vec2::ZERO, Vec2::new(9.5, 0.0)); + let d = drawing(fence, a, b); + let marks = off_line(&d, &Options::default(), a, b); + // Pattern 0.25 on, 0.1 off, "o", 0.1 off, 0.5 on = 0.95: ten repeats along 9.5. + assert_eq!(marks.len(), 10, "{marks:?}"); + for (i, m) in marks.iter().enumerate() { + let gap = i as f64 * 0.95 + 0.25; + assert!(m.min.x > gap && m.max.x < gap + 0.2, "mark {i} at {m:?} outside its gap"); + // Centred on the line. + assert!((m.min.y + m.max.y).abs() < 0.01 && m.max.y > 0.02, "{m:?}"); + } + // No text at all when text is off (coarse previews). + assert!(off_line(&d, &Options { text: false, ..Options::default() }, a, b).is_empty()); +} + +fn gas(absolute: bool) -> Linetype { + Linetype { + name: "GAS".into(), + description: "Gas line ---- GAS ---- GAS ----".into(), + pattern: vec![ + DashElement::dash(0.5), + DashElement { + text: Some("GAS".into()), + style: Some("Standard".into()), + scale: 0.1, + absolute, + offset: Vec2::new(-0.1, -0.05), + ..DashElement::dash(-0.2) + }, + DashElement::dash(-0.25), + ], + } +} + +#[test] +fn text_follows_the_line_or_keeps_an_absolute_rotation() { + // A vertical line: relative text runs up the line, absolute text stays horizontal. + let (a, b) = (Vec2::ZERO, Vec2::new(0.0, 1.0)); + for absolute in [false, true] { + let marks = off_line(&drawing(gas(absolute), a, b), &Options::default(), a, b); + assert!(!marks.is_empty(), "absolute {absolute}: no text"); + let all = Bounds2::from_points(marks.iter().flat_map(|m| [m.min, m.max])); + // Anchor 0.7 up the line, shifted 0.1 back along it and 0.05 to its right: the text + // starts at (0.05, 0.6). + assert!((all.min.y - 0.6).abs() < 0.01, "absolute {absolute}: {all:?}"); + if absolute { + assert!(all.width() > all.height(), "{all:?}"); + assert!((all.min.x - 0.05).abs() < 0.01, "{all:?}"); + } else { + assert!(all.height() > all.width(), "{all:?}"); + assert!((all.max.x - 0.05).abs() < 0.01, "{all:?}"); + } + } +} + +#[test] +fn text_height_follows_the_linetype_scale() { + let (a, b) = (Vec2::ZERO, Vec2::new(10.0, 0.0)); + let height = |ltscale: f64| { + let mut d = drawing(gas(false), a, b); + d.header.set_f64("LTSCALE", ltscale); + let marks = off_line(&d, &Options::default(), a, b); + marks.iter().map(|m| m.max.y).fold(f64::MIN, f64::max) - marks.iter().map(|m| m.min.y).fold(f64::MAX, f64::min) + }; + let (h1, h2) = (height(1.0), height(2.0)); + assert!(h1 > 0.05 && h1 < 0.2, "{h1}"); + assert!((h2 / h1 - 2.0).abs() < 1e-6, "{h1} {h2}"); +} From 824ae58d913e5abc42672c44fb11839ecc6c2293 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:11:58 +0200 Subject: [PATCH 081/150] Object snaps find the geometry inside block references (fixes #314) (#318) Running snaps only saw a block reference's insertion point. Now the objects inside it snap where they are drawn: nested references and MINSERT arrays are expanded, placed exactly by the reference's transform (non-uniform scale turns circles and arcs into ellipses), and hidden objects or objects on layers that are off are left out. Insertion also snaps to visible attributes. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/snap.rs | 319 +++++++++++++++++++++-------- crates/engine/tests/snap_blocks.rs | 129 ++++++++++++ 2 files changed, 363 insertions(+), 85 deletions(-) create mode 100644 crates/engine/tests/snap_blocks.rs diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 3958ec74..e1f6e33a 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -1,7 +1,7 @@ //! Object snaps (OSNAP), polar tracking and ortho. -use cadcraft_doc::{Drawing, EntityKind, Prim, Space}; -use cadcraft_geom::{Bounds2, Circle, PI, Segment, Vec2, intersect_ext}; +use cadcraft_doc::{Drawing, EntityKind, Insert, MAX_BLOCK_DEPTH, Prim, Space}; +use cadcraft_geom::{Bounds2, Circle, Ellipse, Mat3, PI, Segment, Vec2, intersect_ext}; use serde::Serialize; /// OSMODE bits. @@ -149,6 +149,7 @@ pub(crate) fn osnap_with( let mut cands: Vec<(u32, Vec2)> = Vec::new(); let mut near_prims: Vec = Vec::new(); let mut count = 0usize; + let mut budget = MAX_BLOCK_LEAVES; for (e, known) in crate::select::candidates(d, space, ix, &probe.expand(aperture), true, false)? { if !d.is_visible(e) { continue; @@ -165,92 +166,18 @@ pub(crate) fn osnap_with( match &e.kind { EntityKind::Text(t) => cands.push((mode::INS, t.insert.xy())), EntityKind::MText(t) => cands.push((mode::INS, t.insert.xy())), - EntityKind::Insert(i) => cands.push((mode::INS, i.insert.xy())), + EntityKind::Insert(i) => { + cands.push((mode::INS, i.insert.xy())); + cands.extend(i.attribs.iter().filter(|a| !a.invisible).map(|a| (mode::INS, a.text.insert.xy()))); + } _ => {} } } - if osmode & mode::END != 0 - && let EntityKind::Solid(s) | EntityKind::Trace(s) = &e.kind - { - cands.extend(s.corners.iter().map(|c| (mode::END, c.xy()))); - } - for p in e.kind.prims() { - match &p { - Prim::Seg(s) => { - if osmode & mode::END != 0 { - cands.push((mode::END, s.start())); - cands.push((mode::END, s.end())); - } - if osmode & mode::MID != 0 { - cands.push((mode::MID, s.mid())); - } - if let Segment::Arc { arc, .. } = s { - if osmode & mode::CEN != 0 { - cands.push((mode::CEN, arc.center)); - } - if osmode & mode::QUA != 0 { - for k in 0..4 { - let a = k as f64 * PI / 2.0; - if arc.contains_angle(a) { - cands.push((mode::QUA, Vec2::polar(arc.center, arc.radius, a))); - } - } - } - } - } - Prim::Circle(c) => { - if osmode & mode::CEN != 0 { - cands.push((mode::CEN, c.center)); - } - if osmode & mode::QUA != 0 { - for k in 0..4 { - cands.push((mode::QUA, Vec2::polar(c.center, c.radius, k as f64 * PI / 2.0))); - } - } - } - Prim::Ellipse(el) => { - if osmode & mode::CEN != 0 { - cands.push((mode::CEN, el.center)); - } - if osmode & mode::QUA != 0 { - for k in 0..4 { - cands.push((mode::QUA, el.at_param(k as f64 * PI / 2.0))); - } - } - if osmode & mode::END != 0 && !el.is_full() { - cands.push((mode::END, el.at_param(el.start))); - cands.push((mode::END, el.at_param(el.end))); - } - } - Prim::Spline(s) => { - if osmode & mode::END != 0 && !s.closed { - let (lo, hi) = s.domain(); - cands.push((mode::END, s.eval(lo))); - cands.push((mode::END, s.eval(hi))); - } - } - Prim::Point(pt) => { - if osmode & mode::NOD != 0 && matches!(e.kind, EntityKind::Point(_)) { - cands.push((mode::NOD, *pt)); - } - } - Prim::Infinite { base, .. } => { - if osmode & mode::END != 0 && matches!(e.kind, EntityKind::Ray(_)) { - cands.push((mode::END, *base)); - } - } - Prim::Fill(_) => {} - } - near_prims.push(p); - } - if osmode & mode::GCEN != 0 - && let EntityKind::LwPolyline(pl) = &e.kind - && pl.closed - { - let pts = cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: true }.tessellate(aperture / 10.0); - if let Some(c) = centroid(&pts) { - cands.push((mode::GCEN, c)); - } + let mut sink = Sink { osmode, aperture, probe, cands: &mut cands, near: &mut near_prims }; + sink.object(&e.kind, None); + // A block reference snaps to the objects inside it, placed by its transform. + if let EntityKind::Insert(ins) = &e.kind { + block_leaves(d, ins, &Mat3::IDENTITY, 0, &mut budget, &mut |k, m| sink.object(k, Some(m))); } } // Intersections between nearby primitives. @@ -333,6 +260,228 @@ pub(crate) fn osnap_with( None } +/// Upper bound on the block-reference objects one snap query looks at (hostile files). +const MAX_BLOCK_LEAVES: usize = 20_000; + +/// Collects snap candidates and the primitives near the cursor. +struct Sink<'a> { + osmode: u32, + aperture: f64, + probe: Bounds2, + cands: &'a mut Vec<(u32, Vec2)>, + near: &'a mut Vec, +} + +impl Sink<'_> { + /// The snap points of one object; `xf` places an object from inside a block reference + /// (whose primitives far from the cursor are skipped). + fn object(&mut self, kind: &EntityKind, xf: Option<&Mat3>) { + let osmode = self.osmode; + let at = |p: Vec2| xf.map_or(p, |m| m.apply(p)); + if osmode & mode::END != 0 + && let EntityKind::Solid(s) | EntityKind::Trace(s) = kind + { + self.cands.extend(s.corners.iter().map(|c| (mode::END, at(c.xy())))); + } + for p in kind.prims() { + let p = match xf { + Some(m) => { + let p = xform_prim(&p, m); + if !prim_near(&p, &self.probe, self.aperture) { + continue; + } + p + } + None => p, + }; + let cands = &mut *self.cands; + match &p { + Prim::Seg(s) => { + if osmode & mode::END != 0 { + cands.push((mode::END, s.start())); + cands.push((mode::END, s.end())); + } + if osmode & mode::MID != 0 { + cands.push((mode::MID, s.mid())); + } + if let Segment::Arc { arc, .. } = s { + if osmode & mode::CEN != 0 { + cands.push((mode::CEN, arc.center)); + } + if osmode & mode::QUA != 0 { + for k in 0..4 { + let a = k as f64 * PI / 2.0; + if arc.contains_angle(a) { + cands.push((mode::QUA, Vec2::polar(arc.center, arc.radius, a))); + } + } + } + } + } + Prim::Circle(c) => { + if osmode & mode::CEN != 0 { + cands.push((mode::CEN, c.center)); + } + if osmode & mode::QUA != 0 { + for k in 0..4 { + cands.push((mode::QUA, Vec2::polar(c.center, c.radius, k as f64 * PI / 2.0))); + } + } + } + Prim::Ellipse(el) => { + if osmode & mode::CEN != 0 { + cands.push((mode::CEN, el.center)); + } + if osmode & mode::QUA != 0 { + for k in 0..4 { + cands.push((mode::QUA, el.at_param(k as f64 * PI / 2.0))); + } + } + if osmode & mode::END != 0 && !el.is_full() { + cands.push((mode::END, el.at_param(el.start))); + cands.push((mode::END, el.at_param(el.end))); + } + } + Prim::Spline(s) => { + if osmode & mode::END != 0 && !s.closed { + let (lo, hi) = s.domain(); + cands.push((mode::END, s.eval(lo))); + cands.push((mode::END, s.eval(hi))); + } + } + Prim::Point(pt) => { + if osmode & mode::NOD != 0 && matches!(kind, EntityKind::Point(_)) { + cands.push((mode::NOD, *pt)); + } + } + Prim::Infinite { base, .. } => { + if osmode & mode::END != 0 && matches!(kind, EntityKind::Ray(_)) { + cands.push((mode::END, *base)); + } + } + Prim::Fill(_) => {} + } + self.near.push(p); + } + if osmode & mode::GCEN != 0 + && let EntityKind::LwPolyline(pl) = kind + && pl.closed + { + let pts = cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: true }.tessellate(self.aperture / 10.0); + if let Some(c) = centroid(&pts) { + self.cands.push((mode::GCEN, at(c))); + } + } + } +} + +/// Calls `f` with each object drawn by a block reference and the transform placing it, nested +/// references expanded (within [`MAX_BLOCK_DEPTH`] levels and `budget` objects); objects that +/// aren't drawn (hidden, on a layer that is off or frozen, attribute definitions) are left out. +fn block_leaves(d: &Drawing, ins: &Insert, m: &Mat3, depth: usize, budget: &mut usize, f: &mut dyn FnMut(&EntityKind, &Mat3)) { + if depth >= MAX_BLOCK_DEPTH { + return; + } + let Some(blk) = d.block(&ins.block) else { return }; + let (cols, rows) = (ins.cols.clamp(1, 10_000), ins.rows.clamp(1, 10_000)); + for r in 0..rows { + for c in 0..cols { + let off = Vec2::new(ins.col_spacing * f64::from(c), ins.row_spacing * f64::from(r)).rotate(ins.rotation); + let mm = m.then_before(Mat3::translate(off).then_before(ins.transform(blk.base.xy()))); + for be in blk.entities.iter() { + if *budget == 0 { + return; + } + *budget -= 1; + // Objects on layer 0 take the reference's layer, which is visible. + if !be.common.visible || (be.common.layer != "0" && !d.is_visible(be)) { + continue; + } + match &be.kind { + EntityKind::AttDef(_) => {} + EntityKind::Insert(inner) => block_leaves(d, inner, &mm, depth + 1, budget, f), + k => f(k, &mm), + } + } + } + } +} + +/// Whether `p` comes within `aperture` of the box `probe`. +fn prim_near(p: &Prim, probe: &Bounds2, aperture: f64) -> bool { + let b = match p { + Prim::Seg(s) => s.bounds(), + Prim::Circle(c) => c.bounds(), + Prim::Ellipse(e) => e.bounds(), + Prim::Spline(s) => Bounds2::from_points(s.control.iter().copied()), + Prim::Point(q) => Bounds2::new(*q, *q), + Prim::Infinite { .. } => return true, + Prim::Fill(f) => Bounds2::from_points(f.iter().copied()), + }; + b.expand(aperture).intersects(probe) +} + +/// The rotation-and-uniform-scale factor of `m`'s linear part (mirroring allowed), or `None` +/// when it distorts shapes (non-uniform scale or shear). +fn similarity_scale(m: &Mat3) -> Option { + let (u, v) = (Vec2::new(m.a, m.b), Vec2::new(m.c, m.d)); + let (lu, lv) = (u.len(), v.len()); + let tol = 1e-9 * lu.max(lv); + ((lu - lv).abs() <= tol && u.dot(v).abs() <= tol * lu.max(lv) && lu > 0.0).then_some(lu) +} + +/// `p` placed by the affine transform `m`, exactly: circles and arcs under a non-uniform scale +/// become ellipses and elliptical arcs. +fn xform_prim(p: &Prim, m: &Mat3) -> Prim { + let mirror = m.is_mirroring(); + match p { + Prim::Seg(Segment::Line(l)) => Prim::Seg(Segment::Line(cadcraft_geom::Line::new(m.apply(l.a), m.apply(l.b)))), + Prim::Seg(Segment::Arc { arc, ccw }) => match similarity_scale(m) { + Some(s) => { + let c = m.apply(arc.center); + let (a0, a1) = (c.angle_to(m.apply(arc.start_point())), c.angle_to(m.apply(arc.end_point()))); + let (start, end) = if mirror { (a1, a0) } else { (a0, a1) }; + Prim::Seg(Segment::Arc { arc: cadcraft_geom::Arc::new(c, arc.radius * s, start, end), ccw: *ccw != mirror }) + } + None => { + let e = Ellipse { center: arc.center, major: Vec2::X * arc.radius, ratio: 1.0, start: arc.start, end: arc.end }; + Prim::Ellipse(xform_ellipse(&e, m)) + } + }, + Prim::Circle(c) => match similarity_scale(m) { + Some(s) => Prim::Circle(Circle::new(m.apply(c.center), c.radius * s)), + None => Prim::Ellipse(xform_ellipse(&Ellipse::full(c.center, Vec2::X * c.radius, 1.0), m)), + }, + Prim::Ellipse(e) => Prim::Ellipse(xform_ellipse(e, m)), + Prim::Spline(s) => { + let mut s = s.clone(); + s.control.iter_mut().chain(s.fit.iter_mut()).for_each(|q| *q = m.apply(*q)); + Prim::Spline(s) + } + Prim::Point(q) => Prim::Point(m.apply(*q)), + Prim::Infinite { base, dir, ray } => Prim::Infinite { base: m.apply(*base), dir: m.apply_vec(*dir).normalized(), ray: *ray }, + Prim::Fill(f) => Prim::Fill(f.iter().map(|q| m.apply(*q)).collect()), + } +} + +/// The image of an ellipse (or elliptical arc) under an affine transform: the conjugate +/// semi-diameters `A·major`, `A·minor` give the new principal axes; parameters shift with them +/// (and reverse under a mirroring transform). +fn xform_ellipse(e: &Ellipse, m: &Mat3) -> Ellipse { + let center = m.apply(e.center); + let (am, an) = (m.apply_vec(e.major), m.apply_vec(e.minor())); + // |am·cos t + an·sin t| is largest at t0: the new major axis. + let t0 = 0.5 * (2.0 * am.dot(an)).atan2(am.dot(am) - an.dot(an)); + let (s0, c0) = t0.sin_cos(); + let (u, v) = (am * c0 + an * s0, an * c0 - am * s0); + let ratio = if u.len() > 0.0 { (v.len() / u.len()).min(1.0) } else { 1.0 }; + if e.is_full() { + return Ellipse::full(center, u, ratio); + } + let (start, end) = if u.cross(v) >= 0.0 { (e.start - t0, e.end - t0) } else { (t0 - e.end, t0 - e.start) }; + Ellipse { center, major: u, ratio, start: cadcraft_geom::norm_angle(start), end: cadcraft_geom::norm_angle(end) } +} + /// The nearest deferred tangent/perpendicular within `aperture` of `cursor`. fn deferred_hit(prims: &[Prim], cursor: Vec2, aperture: f64, osmode: u32) -> Option { let mut best: Option<(f64, SnapHit)> = None; diff --git a/crates/engine/tests/snap_blocks.rs b/crates/engine/tests/snap_blocks.rs new file mode 100644 index 00000000..6d44893d --- /dev/null +++ b/crates/engine/tests/snap_blocks.rs @@ -0,0 +1,129 @@ +//! Object snaps find the geometry inside block references (#314). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Common, EntityKind, Insert, Space}; +use cadcraft_engine::geom::{Mat3, Vec2, Vec3}; +use cadcraft_engine::snap::{self, mode}; +use serde_json::json; + +fn handle(s: &mut Session, cmd: &str, p: serde_json::Value) -> String { + let r = s.execute(cmd, &p).unwrap(); + r.get("handle").unwrap_or(&r["handles"][0]).as_str().unwrap().to_string() +} + +/// Block "B": a line (0,0)-(10,0), a circle at (20,0) r2 and an arc at (0,10) r5 from 0° to 90°. +fn block_b(s: &mut Session) { + let l = handle(s, "line", json!({ "points": [[0, 0], [10, 0]] })); + let c = handle(s, "circle", json!({ "center": [20, 0], "radius": 2 })); + let a = handle(s, "arc", json!({ "center": [0, 10], "radius": 5, "start": 0, "end": 90 })); + s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [l, c, a], "keep": "delete" })).unwrap(); +} + +/// Adds a reference of `block`; `grid` is (columns, rows, column spacing, row spacing). +fn insert(s: &mut Session, block: &str, at: (f64, f64), scale: (f64, f64), rotation_deg: f64, grid: (u32, u32, f64, f64)) -> String { + let kind = EntityKind::Insert(Insert { + block: block.into(), + insert: Vec3::new(at.0, at.1, 0.0), + scale: Vec3::new(scale.0, scale.1, 1.0), + rotation: rotation_deg.to_radians(), + attribs: Vec::new(), + cols: grid.0, + rows: grid.1, + col_spacing: grid.2, + row_spacing: grid.3, + }); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), kind).unwrap().hex() +} + +const ONE: (u32, u32, f64, f64) = (1, 1, 0.0, 0.0); + +/// The snap of `modes` near `p` (a hair off it, inside a 0.5 aperture). +fn snap_at(s: &Session, p: Vec2, modes: u32, base: Option) -> Option<(Vec2, u32)> { + snap::osnap(s.doc().unwrap(), &Space::Model, p + Vec2::new(0.05, -0.04), 0.5, modes, base, false).map(|h| (h.point, h.mode)) +} + +fn assert_snaps(s: &Session, expect: Vec2, modes: u32) { + let (p, m) = snap_at(s, expect, modes, None).unwrap_or_else(|| panic!("no snap near {expect:?}")); + assert_eq!(m, modes, "snap mode near {expect:?}"); + assert!(p.near(expect, 1e-9), "snapped to {p:?}, expected {expect:?}"); +} + +#[test] +fn snaps_inside_rotated_scaled_and_mirrored_references() { + let mut s = Session::new(); + block_b(&mut s); + insert(&mut s, "B", (100.0, 100.0), (2.0, 2.0), 30.0, ONE); + let rot = 30f64.to_radians(); + let m = Mat3::translate(Vec2::new(100.0, 100.0)).then_before(Mat3::rotate(rot)).then_before(Mat3::scale(2.0, 2.0)); + let at = |x: f64, y: f64| m.apply(Vec2::new(x, y)); + assert_snaps(&s, at(10.0, 0.0), mode::END); + assert_snaps(&s, at(5.0, 0.0), mode::MID); + assert_snaps(&s, at(20.0, 0.0), mode::CEN); + // Quadrants stay on the world axes: the top of the circle (radius 4 once placed). + assert_snaps(&s, at(20.0, 0.0) + Vec2::new(0.0, 4.0), mode::QUA); + // The arc's start point. + assert_snaps(&s, at(5.0, 10.0), mode::END); + // Perpendicular from a point outside the block onto its line. + let (p, m2) = snap_at(&s, at(4.0, 0.0), mode::PER, Some(at(4.0, 3.0))).unwrap(); + assert!(m2 == mode::PER && p.near(at(4.0, 0.0), 1e-9), "{p:?}"); + // The insertion point still snaps. + assert_snaps(&s, Vec2::new(100.0, 100.0), mode::INS); + + // Mirrored in X and stretched 3x along Y: the circle becomes an ellipse. + let mut s = Session::new(); + block_b(&mut s); + insert(&mut s, "B", (0.0, 0.0), (-1.0, 3.0), 0.0, ONE); + assert_snaps(&s, Vec2::new(-10.0, 0.0), mode::END); + assert_snaps(&s, Vec2::new(-20.0, 0.0), mode::CEN); + for q in [Vec2::new(-20.0, 6.0), Vec2::new(-20.0, -6.0), Vec2::new(-18.0, 0.0), Vec2::new(-22.0, 0.0)] { + assert_snaps(&s, q, mode::QUA); + } + // The arc, now elliptical, ends where its ends were placed: (5,10) -> (-5,30), (0,15) -> (0,45). + assert_snaps(&s, Vec2::new(-5.0, 30.0), mode::END); + assert_snaps(&s, Vec2::new(0.0, 45.0), mode::END); + + // Mirrored in Y with a uniform scale of 2 the arc stays an arc. + let mut s = Session::new(); + block_b(&mut s); + insert(&mut s, "B", (0.0, 0.0), (2.0, -2.0), 0.0, ONE); + assert_snaps(&s, Vec2::new(10.0, -20.0), mode::END); + assert_snaps(&s, Vec2::new(0.0, -30.0), mode::END); + assert_snaps(&s, Vec2::new(0.0, -20.0), mode::CEN); + assert_snaps(&s, Vec2::new(0.0, -20.0) + Vec2::from_angle(-45f64.to_radians()) * 10.0, mode::MID); + assert_snaps(&s, Vec2::new(10.0, -20.0), mode::QUA); +} + +#[test] +fn snaps_inside_nested_and_arrayed_references_but_not_hidden_objects() { + let mut s = Session::new(); + block_b(&mut s); + // Block "N" holds a reference of B at (0,100); N is placed at (1000,0) as 3 columns 50 apart. + let inner = insert(&mut s, "B", (0.0, 100.0), (1.0, 1.0), 0.0, ONE); + s.execute("block", &json!({ "name": "N", "base": [0, 0], "handles": [inner], "keep": "delete" })).unwrap(); + insert(&mut s, "N", (1000.0, 0.0), (1.0, 1.0), 0.0, (3, 1, 50.0, 0.0)); + assert_snaps(&s, Vec2::new(1010.0, 100.0), mode::END); + assert_snaps(&s, Vec2::new(1120.0, 100.0), mode::CEN); + // A loose line crossing the nested line of the second column. + s.execute("line", &json!({ "points": [[1055, 90], [1055, 110]] })).unwrap(); + assert_snaps(&s, Vec2::new(1055.0, 100.0), mode::INT); + + // A reference rotated 30° inside one mirrored and stretched 3x in Y: the arc becomes an + // elliptical arc whose axes don't follow the stretch; its ends still snap where they are. + let inner = insert(&mut s, "B", (0.0, 0.0), (1.0, 1.0), 30.0, ONE); + s.execute("block", &json!({ "name": "S", "base": [0, 0], "handles": [inner], "keep": "delete" })).unwrap(); + insert(&mut s, "S", (0.0, 500.0), (1.0, -3.0), 0.0, ONE); + let m = Mat3::translate(Vec2::new(0.0, 500.0)).then_before(Mat3::scale(1.0, -3.0)).then_before(Mat3::rotate(30f64.to_radians())); + assert_snaps(&s, m.apply(Vec2::new(5.0, 10.0)), mode::END); + assert_snaps(&s, m.apply(Vec2::new(0.0, 15.0)), mode::END); + + // Objects on a layer that is off don't snap. + let mut s = Session::new(); + let line = EntityKind::Line(cadcraft_engine::doc::Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(10.0, 0.0, 0.0) }); + let common = Common { layer: "Hidden".into(), ..Common::default() }; + let l = s.doc_mut().unwrap().add(&Space::Model, common, line).unwrap().hex(); + s.execute("block", &json!({ "name": "H", "base": [0, 0], "handles": [l], "keep": "delete" })).unwrap(); + insert(&mut s, "H", (0.0, 0.0), (1.0, 1.0), 0.0, ONE); + assert_snaps(&s, Vec2::new(10.0, 0.0), mode::END); + s.doc_mut().unwrap().layer_mut("Hidden").unwrap().on = false; + assert_eq!(snap_at(&s, Vec2::new(10.0, 0.0), mode::END, None), None); +} From 9524f9a5c2dd852dad61f6db0bff79b8894004d4 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:12:03 +0200 Subject: [PATCH 082/150] -RENAME prompts; RENAME renames blocks (fixes #264) (#266) Typed RENAME / -RENAME (and REN) asks for the object type [Block/Dimstyle/LAyer/LType/Material/multileadeRstyle/Plotstyle/ textStyle/Tablestyle/Ucs/VIew/VPort], the old name and the new name, then runs the rename command body, so the checks and the single undo step match the JSON form. An unknown old name or a refused new name re-prompts. Material, Plotstyle and VPort read both names and report "not available yet". The JSON form now renames blocks, as its params doc promised. Every INSERT follows the new name, in model space, layouts and nested block definitions, including inserts that carry attributes. INSNAME and dimension-style arrowheads follow too. Duplicate names (any case), invalid names and anonymous blocks are refused. Multileader styles, table styles, named views and UCSs can be renamed as well. A layer rename no longer records two undo steps. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/props.rs | 21 ++- crates/engine/src/cmd/props/rename.rs | 247 ++++++++++++++++++++++++++ crates/engine/tests/rename_cmdline.rs | 88 +++++++++ 3 files changed, 351 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/cmd/props/rename.rs create mode 100644 crates/engine/tests/rename_cmdline.rs diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 2f28df19..2ad6f385 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -1,4 +1,6 @@ -//! Properties: per-object property edits, current properties, MATCHPROP, linetypes, units. +//! Properties: per-object property edits, current properties, MATCHPROP, linetypes, units, RENAME. + +mod rename; use cadcraft_color::Color; use cadcraft_doc::{EntityKind, Lineweight, Transparency}; @@ -47,7 +49,11 @@ pub fn specs() -> Vec { .alias(&["mls"]) .params("{name, arrowSize?, textHeight?, landingGap?, dogleg?, textStyle?, current?} → styles"), CommandSpec::new("ddptype", "Point Style...", run_ptype).menu(&["Format", "Point Style..."]).params("{pdmode, pdsize}"), - CommandSpec::new("rename", "Rename...", run_rename).menu(&["Format", "Rename..."]).params("{table: layer|linetype|style|dimstyle|block, from, to}"), + CommandSpec::new("rename", "Rename...", run_rename) + .menu(&["Format", "Rename..."]) + .alias(&["ren", "-rename"]) + .params("{table: layer|linetype|style|dimstyle|block|mleaderstyle|tablestyle|view|ucs, from, to}") + .interactive(|_| Ok(Box::new(rename::RenameM::default()))), ] } @@ -859,15 +865,20 @@ fn run_rename(s: &mut Session, p: &Value) -> Result { let to = str_param(p, "to").ok_or_else(|| bad("rename", "`to` is required"))?.to_string(); match table.as_str() { "layer" => { - s.execute("layer.set", &json!({ "name": from, "newName": to }))?; + // The command body, not `execute`: RENAME records the one undo step. + let set = find_command("layer.set").ok_or_else(|| bad("rename", "no layer.set command"))?; + (set.run)(s, &json!({ "name": from, "newName": to }))?; } - "style" => { + "block" => rename::block(s, &from, &to)?, + "mleaderstyle" | "multileaderstyle" => rename::simple(s, "mleaderstyle", &from, &to)?, + "tablestyle" | "view" | "ucs" => rename::simple(s, &table, &from, &to)?, + "style" | "textstyle" => { run_style_rename(s, &json!({ "from": from, "to": to }))?; } "dimstyle" => { run_dimstyle_rename(s, &json!({ "from": from, "to": to }))?; } - "linetype" => { + "linetype" | "ltype" => { let d = s.doc_mut()?; if ["ByBlock", "ByLayer", "Continuous"].iter().any(|n| from.eq_ignore_ascii_case(n)) { return Err(bad("rename", format!("the {from} linetype cannot be renamed"))); diff --git a/crates/engine/src/cmd/props/rename.rs b/crates/engine/src/cmd/props/rename.rs new file mode 100644 index 00000000..c7c2cf21 --- /dev/null +++ b/crates/engine/src/cmd/props/rename.rs @@ -0,0 +1,247 @@ +//! RENAME for blocks and the remaining named tables, and -RENAME's prompts. + +use std::sync::Arc; + +use cadcraft_doc::{Drawing, EntityKind, EntityStore, Handle}; +use serde_json::json; + +use super::super::bad; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +fn valid_name(n: &str) -> bool { + !n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c)) +} + +/// Handles in `store` whose entity `uses` the old name. +fn users(store: &EntityStore, uses: &dyn Fn(&EntityKind) -> bool) -> Vec { + store.iter().filter(|e| uses(&e.kind)).map(|e| e.handle).collect() +} + +/// Apply `fix` to every entity that `uses` the old name: model space, layouts and block definitions. +fn repoint(d: &mut Drawing, uses: &dyn Fn(&EntityKind) -> bool, fix: &dyn Fn(&mut EntityKind)) -> Result<()> { + let mut hs = users(&d.model, uses); + hs.extend(d.layouts.iter().flat_map(|l| users(&l.entities, uses))); + for h in hs { + d.modify_entity(h, |e| fix(&mut e.kind))?; + } + for block in d.blocks.values_mut() { + let hs = users(&block.entities, uses); + if !hs.is_empty() { + let b = Arc::make_mut(block); + for h in hs { + b.entities.modify(h, |e| fix(&mut e.kind)); + } + } + } + Ok(()) +} + +/// Rename a block definition; inserts everywhere (nested ones and those with attributes +/// included), INSNAME and dimension-style arrowheads follow. +pub(super) fn block(s: &mut Session, from: &str, to: &str) -> Result<()> { + let to = to.trim().to_string(); + if !valid_name(&to) { + return Err(bad("rename", "invalid block name")); + } + let d = s.doc_mut()?; + let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(from)).cloned().ok_or_else(|| bad("rename", "no such block"))?; + if key.starts_with('*') || d.blocks.get(&key).is_some_and(|b| b.anonymous) { + return Err(bad("rename", "an anonymous block cannot be renamed")); + } + if !to.eq_ignore_ascii_case(&key) && d.block(&to).is_some() { + return Err(bad("rename", format!("a block `{to}` already exists"))); + } + let mut b = d.blocks.remove(&key).ok_or_else(|| bad("rename", "no such block"))?; + Arc::make_mut(&mut b).name = to.clone(); + d.blocks.insert(to.clone(), b); + let uses = |k: &EntityKind| matches!(k, EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(&key)); + let fix = |k: &mut EntityKind| { + if let EntityKind::Insert(i) = k { + i.block = to.clone(); + } + }; + repoint(d, &uses, &fix)?; + if d.header.str("INSNAME", "").eq_ignore_ascii_case(&key) { + d.header.set_str("INSNAME", &to); + } + for ds in &mut d.dim_styles { + for a in [&mut ds.arrow_block, &mut ds.arrow_block1, &mut ds.arrow_block2] { + if a.eq_ignore_ascii_case(&key) { + *a = to.clone(); + } + } + } + Ok(()) +} + +/// Rename a multileader style, a table style, a named view or a named UCS. +pub(super) fn simple(s: &mut Session, table: &str, from: &str, to: &str) -> Result<()> { + let to = to.trim().to_string(); + if !valid_name(&to) { + return Err(bad("rename", "invalid name")); + } + if matches!(table, "mleaderstyle" | "tablestyle") && from.eq_ignore_ascii_case("Standard") { + return Err(bad("rename", "the Standard style cannot be renamed")); + } + let d = s.doc_mut()?; + let mut names: Vec<&mut String> = match table { + "mleaderstyle" => d.mleader_styles.iter_mut().map(|t| &mut t.name).collect(), + "tablestyle" => d.table_styles.iter_mut().map(|t| &mut t.name).collect(), + "view" => d.views.iter_mut().map(|t| &mut t.name).collect(), + "ucs" => d.ucss.iter_mut().map(|t| &mut t.name).collect(), + _ => return Err(bad("rename", "unsupported table")), + }; + if !to.eq_ignore_ascii_case(from) && names.iter().any(|n| n.eq_ignore_ascii_case(&to)) { + return Err(bad("rename", format!("`{to}` already exists"))); + } + let n = names.iter_mut().find(|n| n.eq_ignore_ascii_case(from)).ok_or_else(|| bad("rename", format!("no {table} `{from}`")))?; + let old = std::mem::replace(&mut **n, to.clone()); + let current = match table { + "mleaderstyle" => "CMLEADERSTYLE", + "tablestyle" => "CTABLESTYLE", + _ => return Ok(()), + }; + if d.header.str(current, "Standard").eq_ignore_ascii_case(&old) { + d.header.set_str(current, &to); + } + let uses = |k: &EntityKind| match k { + EntityKind::MLeader(m) => table == "mleaderstyle" && m.style.eq_ignore_ascii_case(&old), + EntityKind::Table(t) => table == "tablestyle" && t.style.eq_ignore_ascii_case(&old), + _ => false, + }; + let fix = |k: &mut EntityKind| match k { + EntityKind::MLeader(m) => m.style = to.clone(), + EntityKind::Table(t) => t.style = to.clone(), + _ => {} + }; + repoint(d, &uses, &fix) +} + +// ---------------- -RENAME prompts ---------------- + +/// Prompt keyword, `rename` table (None: not available yet), noun. +const TYPES: &[(&str, Option<&str>, &str)] = &[ + ("Block", Some("block"), "block"), + ("Dimstyle", Some("dimstyle"), "dimension style"), + ("LAyer", Some("layer"), "layer"), + ("LType", Some("linetype"), "linetype"), + ("Material", None, "material"), + ("multileadeRstyle", Some("mleaderstyle"), "multileader style"), + ("Plotstyle", None, "plot style"), + ("textStyle", Some("style"), "text style"), + ("Tablestyle", Some("tablestyle"), "table style"), + ("Ucs", Some("ucs"), "UCS"), + ("VIew", Some("view"), "view"), + ("VPort", None, "viewport configuration"), +]; + +/// Keyword-only: spaces separate inputs. +const KW: Accept = Accept { point: false, number: false, text: false, select: false, enter: true }; +/// A name: Space ends the input. +const NAME: Accept = Accept { number: true, ..Accept::TEXT }; + +fn any_named<'a>(mut names: impl Iterator, name: &str) -> bool { + names.any(|n| n.eq_ignore_ascii_case(name)) +} + +fn exists(d: &Drawing, table: &str, name: &str) -> bool { + match table { + "block" => d.block(name).is_some(), + "layer" => d.layer(name).is_some(), + "linetype" => d.linetype(name).is_some(), + "style" => d.text_style(name).is_some(), + "dimstyle" => d.dim_style(name).is_some(), + "mleaderstyle" => any_named(d.mleader_styles.iter().map(|t| &t.name), name), + "tablestyle" => any_named(d.table_styles.iter().map(|t| &t.name), name), + "view" => any_named(d.views.iter().map(|t| &t.name), name), + "ucs" => any_named(d.ucss.iter().map(|t| &t.name), name), + _ => true, + } +} + +#[derive(Default)] +enum Stage { + #[default] + Type, + Old(usize), + New(usize, String), +} + +#[derive(Default)] +pub(in crate::cmd) struct RenameM { + stage: Stage, +} + +fn text(i: &Input) -> Option { + match i { + Input::Text(t) | Input::Keyword(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()), + _ => None, + } +} + +impl Interactive for RenameM { + fn name(&self) -> &'static str { + "-RENAME" + } + fn prompt(&self, _s: &Session) -> Prompt { + let noun = |t: &usize| TYPES.get(*t).map_or("object", |(_, _, n)| *n); + match &self.stage { + Stage::Type => { + let kws: Vec<&str> = TYPES.iter().map(|(k, _, _)| *k).collect(); + Prompt::new("Enter object type to rename", KW).kw(&kws) + } + Stage::Old(t) => Prompt::new(format!("Enter old {} name", noun(t)), NAME), + Stage::New(t, _) => Prompt::new(format!("Enter new {} name", noun(t)), NAME), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if matches!(i, Input::Cancel | Input::Enter) { + return Ok(if i == Input::Cancel { Step::Cancel } else { Step::Done }); + } + match std::mem::take(&mut self.stage) { + Stage::Type => { + let t = match &i { + Input::Keyword(k) => TYPES.iter().position(|(kw, _, _)| kw == k), + _ => None, + }; + let Some(t) = t else { return Err(EngineError::Other("Invalid option keyword.".into())) }; + self.stage = Stage::Old(t); + Ok(Step::Continue) + } + Stage::Old(t) => { + let (Some(name), Some((_, table, noun))) = (text(&i), TYPES.get(t)) else { + self.stage = Stage::Old(t); + return Ok(Step::Continue); + }; + if let Some(table) = table + && !exists(s.doc()?, table, &name) + { + self.stage = Stage::Old(t); + return Err(EngineError::Other(format!("No {noun} named \"{name}\"."))); + } + self.stage = Stage::New(t, name); + Ok(Step::Continue) + } + Stage::New(t, old) => { + let (Some(name), Some((_, table, noun))) = (text(&i), TYPES.get(t)) else { + self.stage = Stage::New(t, old); + return Ok(Step::Continue); + }; + let Some(table) = table else { + s.echo(format!("Renaming a {noun} is not available yet.")); + return Ok(Step::Done); + }; + match super::run_rename(s, &json!({ "table": table, "from": old, "to": name })) { + Ok(_) => Ok(Step::Done), + Err(e) => { + self.stage = Stage::New(t, old); + Err(EngineError::Other(match e { + EngineError::BadParams { msg, .. } => msg, + other => other.to_string(), + })) + } + } + } + } + } +} diff --git a/crates/engine/tests/rename_cmdline.rs b/crates/engine/tests/rename_cmdline.rs new file mode 100644 index 00000000..b12ccc05 --- /dev/null +++ b/crates/engine/tests/rename_cmdline.rs @@ -0,0 +1,88 @@ +//! RENAME: blocks in the JSON form, and -RENAME's type / old name / new name prompts. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Drawing, EntityKind}; +use serde_json::json; + +/// Names of the blocks every INSERT (model, layouts and block definitions) refers to. +fn insert_targets(d: &Drawing) -> Vec { + let ents = d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).chain(d.blocks.values().flat_map(|b| b.entities.iter())); + let mut v: Vec = ents.filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }).collect(); + v.sort(); + v +} + +/// Valve (with an attribute) inserted in model space, in Layout1 and nested inside Assy. +fn drawing() -> Session { + let mut s = Session::new(); + s.execute("circle", &json!({ "center": [0, 0], "radius": 1 })).unwrap(); + s.execute("attdef", &json!({ "tag": "TAG", "at": [0, 2] })).unwrap(); + s.execute("selectall", &json!({})).unwrap(); + s.execute("block", &json!({ "name": "Valve", "base": [0, 0], "keep": "delete" })).unwrap(); + let h = + s.execute("insert", &json!({ "name": "Valve", "at": [0, 0], "attribs": { "TAG": "V-1" } })).unwrap()["handle"].as_str().unwrap().to_string(); + s.execute("block", &json!({ "name": "Assy", "base": [0, 0], "handles": [h], "keep": "convert" })).unwrap(); + s.execute("insert", &json!({ "name": "Valve", "at": [5, 0], "attribs": { "TAG": "V-2" } })).unwrap(); + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + s.execute("insert", &json!({ "name": "Valve", "at": [1, 1] })).unwrap(); + s.execute("layout.set", &json!({ "name": "Model" })).unwrap(); + s +} + +#[test] +fn json_block_rename_repoints_every_insert() { + let mut s = drawing(); + assert_eq!(insert_targets(s.doc().unwrap()), ["Assy", "Valve", "Valve", "Valve"]); + let undo = s.state().unwrap().undo.len(); + s.execute("rename", &json!({ "table": "block", "from": "valve", "to": "Pump" })).unwrap(); + assert_eq!(s.state().unwrap().undo.len(), undo + 1); + let d = s.doc().unwrap(); + assert!(d.block("Valve").is_none()); + assert_eq!(d.block("Pump").unwrap().name, "Pump"); + assert_eq!(insert_targets(d), ["Assy", "Pump", "Pump", "Pump"]); + // The attribute values stay on the renamed inserts. + assert!(d.model.iter().any(|e| matches!(&e.kind, EntityKind::Insert(i) if i.block == "Pump" && i.attribs.iter().any(|a| a.text.value == "V-2")))); + // Duplicates (any case), invalid names and unknown blocks are refused. + assert!(s.execute("rename", &json!({ "table": "block", "from": "Pump", "to": "ASSY" })).is_err()); + assert!(s.execute("rename", &json!({ "table": "block", "from": "Pump", "to": "a*b" })).is_err()); + assert!(s.execute("rename", &json!({ "table": "block", "from": "Nope", "to": "X" })).is_err()); + assert_eq!(insert_targets(s.doc().unwrap()), ["Assy", "Pump", "Pump", "Pump"]); + // Multileader styles: the current-style variable follows; Standard stays. + s.execute("mleaderstyle", &json!({ "name": "Callout", "current": true })).unwrap(); + s.execute("rename", &json!({ "table": "mleaderstyle", "from": "Callout", "to": "Notes" })).unwrap(); + assert_eq!(s.doc().unwrap().header.str("CMLEADERSTYLE", ""), "Notes"); + assert!(s.execute("rename", &json!({ "table": "mleaderstyle", "from": "Standard", "to": "X" })).is_err()); + // A layer rename is one undo step too. + s.execute("layer.new", &json!({ "name": "Old" })).unwrap(); + let undo = s.state().unwrap().undo.len(); + s.execute("rename", &json!({ "table": "layer", "from": "Old", "to": "New" })).unwrap(); + assert_eq!(s.state().unwrap().undo.len(), undo + 1); +} + +#[test] +fn rename_prompts_type_old_and_new_name() { + let mut s = drawing(); + s.cmdline("-RENAME").unwrap(); + assert!( + s.prompt_text().contains("[Block/Dimstyle/LAyer/LType/Material/multileadeRstyle/Plotstyle/textStyle/Tablestyle/Ucs/VIew/VPort]"), + "{}", + s.prompt_text() + ); + s.cancel(); + s.execute("layer.new", &json!({ "name": "Old" })).unwrap(); + s.script("-RENAME B Valve Pump\n-RENAME LA Old New\nLINE 0,0 1,0\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let d = s.doc().unwrap(); + assert!(d.block("Pump").is_some() && d.layer("New").is_some() && d.layer("Old").is_none(), "{:?}", s.log); + assert_eq!(insert_targets(d), ["Assy", "Pump", "Pump", "Pump"]); + assert_eq!(d.model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count(), 1); + // An unknown old name re-prompts; a type CADCraft lacks reads both names and reports. + s.script("-RENAME LA Missing\nNew\nNewer\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert!(s.doc().unwrap().layer("Newer").is_some()); + let n = s.log.len(); + s.script("-RENAME VP a b\nLINE 0,0 2,0\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert!(s.log[n..].iter().any(|l| l.contains("not available yet")), "{:?}", &s.log[n..]); + assert_eq!(s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count(), 2); +} From c04a769ef228e421163c131480e22a27e3155eda Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:12:09 +0200 Subject: [PATCH 083/150] -INSERT asks for X and Y scale; Basepoint/Scale/Rotate take effect (fixes #245) (#249) After the insertion point, the command-line INSERT now asks "Enter X scale factor, specify opposite corner, or [Corner] <1>", then "Enter Y scale factor ", then the rotation, so scripts like `-INSERT B1 20,20 1 1 0` place the block as written instead of rotating it by the Y scale and running the rotation as a command. A point (or Corner) at the X scale prompt gives both factors from the opposite corner. The Basepoint, Scale and Rotate options at the insertion point prompt now work: Scale and Rotate preset the value and skip that prompt later, Basepoint picks a new base point on the block at its definition position. The JSON form takes `scale: [x, y]` and `basePoint` too. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/blocks.rs | 237 +++++++++++++++++++++----- crates/engine/src/tests.rs | 4 +- crates/engine/tests/insert_prompts.rs | 55 ++++++ crates/ui-egui/src/dialogs.rs | 6 +- 4 files changed, 251 insertions(+), 51 deletions(-) create mode 100644 crates/engine/tests/insert_prompts.rs diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index 1f9b7b19..ece76abe 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -26,7 +26,7 @@ pub fn specs() -> Vec { CommandSpec::new("insert", "Block...", run_insert) .menu(&["Insert", "Block..."]) .alias(&["i", "-insert", "ddinsert"]) - .params("{name, at: [x,y], scale?, rotation? (degrees), attribs?: {TAG: value}, explode?: bool}") + .params("{name, at: [x,y], scale?: number | [x, y], rotation? (degrees), basePoint?: [x,y] (block coordinates), attribs?: {TAG: value}, explode?: bool}") .interactive(|_| Ok(Box::new(InsertM::default()))), CommandSpec::new("attdef", "Define Attributes...", run_attdef) .menu(&["Draw", "Block", "Define Attributes..."]) @@ -114,11 +114,16 @@ pub(crate) fn make_block(s: &mut Session, name: &str, base: Vec2, hs: &[Handle], /// Insert a block reference, filling attributes from `values` (else their defaults). pub(crate) fn insert(s: &mut Session, name: &str, at: Vec2, scale: f64, rotation: f64, values: &serde_json::Map) -> Result { - let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?; + insert_with(s, name, at, Vec2::new(scale, scale), rotation, None, values) +} + +/// The block reference INSERT places: X/Y scale factors, and an optional base point (in block +/// coordinates) that lands on `at` instead of the block's own base point. +fn insert_entity(blk: &Block, at: Vec2, scale: Vec2, rotation: f64, base: Option) -> Insert { let mut ins = Insert { block: blk.name.clone(), insert: v3(at), - scale: Vec3::new(scale, scale, scale), + scale: Vec3::new(scale.x, scale.y, scale.x), rotation, attribs: Vec::new(), cols: 1, @@ -126,6 +131,25 @@ pub(crate) fn insert(s: &mut Session, name: &str, at: Vec2, scale: f64, rotation col_spacing: 0.0, row_spacing: 0.0, }; + if let Some(b) = base { + let landed = ins.transform(blk.base.xy()).apply(b); + ins.insert = v3(at + (at - landed)); + } + ins +} + +/// [`insert`] with separate X/Y scale factors and an optional base point (see [`insert_entity`]). +pub(crate) fn insert_with( + s: &mut Session, + name: &str, + at: Vec2, + scale: Vec2, + rotation: f64, + base: Option, + values: &serde_json::Map, +) -> Result { + let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?; + let mut ins = insert_entity(&blk, at, scale, rotation, base); let m = ins.transform(blk.base.xy()); for e in blk.entities.iter() { if let EntityKind::AttDef(ad) = &e.kind { @@ -157,13 +181,20 @@ fn run_block(s: &mut Session, p: &Value) -> Result { fn run_insert(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("insert", "`name` is required"))?.to_string(); let at = point_req("insert", p, "at")?; - let scale = f64_or(p, "scale", 1.0); - if scale == 0.0 { + // `scale`: one factor, or [x, y] for different X and Y scale factors. + let scale = match p.get("scale") { + Some(v) if v.is_array() => point_value(v).ok_or_else(|| bad("insert", "`scale` must be a number or [x, y]"))?, + _ => { + let f = f64_or(p, "scale", 1.0); + Vec2::new(f, f) + } + }; + if scale.x == 0.0 || scale.y == 0.0 { return Err(bad("insert", "scale cannot be 0")); } let rot = f64_or(p, "rotation", 0.0).to_radians(); let vals = p.get("attribs").and_then(Value::as_object).cloned().unwrap_or_default(); - let h = insert(s, &name, at, scale, rot, &vals)?; + let h = insert_with(s, &name, at, scale, rot, point_param(p, "basePoint"), &vals)?; if bool_or(p, "explode", false) { return s.execute("explode", &json!({ "handles": [h.hex()] })); } @@ -447,40 +478,103 @@ impl BlockM { } } +/// Where the command-line INSERT is in its prompt sequence. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum InsStage { + #[default] + Name, + Point, + /// Basepoint: a new base point, picked on the block shown at its definition position. + BasePoint, + /// Scale preset before the insertion point. + PresetScale, + /// Rotate preset before the insertion point. + PresetRotation, + XScale, + Corner, + YScale, + Rotation, + Attribs, +} + #[derive(Default)] struct InsertM { + stage: InsStage, name: Option, at: Option, - scale: Option, + base: Option, + x_scale: Option, + scale: Option, rotation: Option, attdefs: Vec<(String, String, String)>, values: serde_json::Map, } +impl InsertM { + /// After the insertion point or a scale/rotation answer: the next prompt still needed. + fn advance(&mut self, s: &mut Session) -> Result { + self.stage = if self.scale.is_none() { + InsStage::XScale + } else if self.rotation.is_none() { + InsStage::Rotation + } else { + InsStage::Attribs + }; + if self.stage == InsStage::Attribs + && self.values.len() >= self.attdefs.len() + && let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation) + { + insert_with(s, &n, a, sc, r, self.base, &self.values)?; + return Ok(Step::Done); + } + Ok(Step::Continue) + } + + /// Scale factors from an opposite corner: the X and Y distances from the insertion point. + fn corner(a: Vec2, p: Vec2) -> Result { + let d = p - a; + if d.x.abs() < 1e-12 || d.y.abs() < 1e-12 { + return Err(EngineError::Other("The corner must not be level with the insertion point.".into())); + } + Ok(d) + } +} + +fn nonzero(t: &str) -> Result { + number(t).filter(|v| *v != 0.0).ok_or_else(|| EngineError::Other("Requires a non-zero number.".into())) +} + impl Interactive for InsertM { fn name(&self) -> &'static str { "INSERT" } fn prompt(&self, s: &Session) -> Prompt { - let last = s.doc().map(|d| d.header.str("INSNAME", "")).unwrap_or_default(); - match (&self.name, self.at, self.scale, self.rotation) { - (None, ..) => { + let at = self.at.unwrap_or_default(); + match self.stage { + InsStage::Name => { + let last = s.doc().map(|d| d.header.str("INSNAME", "")).unwrap_or_default(); let p = Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]); if last.is_empty() { p } else { p.default(last) } } - (Some(_), None, ..) => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "Rotate"]), - (Some(_), Some(a), None, _) => Prompt::new("Enter scale factor", Accept::POINT_OR_NUMBER).default("1").base(a), - (Some(_), Some(a), Some(_), None) => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(a), - _ => { - let (tag, prompt, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default(); - let _ = tag; + InsStage::Point => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "Rotate"]), + InsStage::BasePoint => Prompt::new("Specify base point", Accept::POINT), + InsStage::PresetScale => Prompt::new("Specify scale factor for XYZ axes", Accept::NUMBER).default("1"), + InsStage::PresetRotation => Prompt::new("Specify rotation angle", Accept::NUMBER).default("0"), + InsStage::XScale => { + Prompt::new("Enter X scale factor, specify opposite corner", Accept::POINT_OR_NUMBER).kw(&["Corner"]).default("1").base(at) + } + InsStage::Corner => Prompt::new("Specify opposite corner", Accept::POINT).base(at), + InsStage::YScale => Prompt::new("Enter Y scale factor", Accept::NUMBER).default("use X scale factor"), + InsStage::Rotation => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).default("0").base(at), + InsStage::Attribs => { + let (_, prompt, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default(); Prompt::new(if prompt.is_empty() { "Enter attribute value".into() } else { prompt }, Accept::TEXT).default(default) } } } fn input(&mut self, s: &mut Session, i: Input) -> Result { - match (&self.name, self.at, self.scale, self.rotation) { - (None, ..) => { + match self.stage { + InsStage::Name => { let n = match i { Input::Text(t) => t.trim().to_string(), Input::Enter => s.doc()?.header.str("INSNAME", ""), @@ -508,60 +602,109 @@ impl Interactive for InsertM { .collect(); s.doc_mut()?.header.set_str("INSNAME", &b.name); self.name = Some(b.name.clone()); + self.stage = InsStage::Point; } - (Some(_), None, ..) => { - if let Input::Point(p) = i { + InsStage::Point => match i { + Input::Point(p) => { self.at = Some(p); + return self.advance(s); + } + Input::Keyword(k) if k == "Basepoint" => self.stage = InsStage::BasePoint, + Input::Keyword(k) if k == "Scale" => self.stage = InsStage::PresetScale, + Input::Keyword(k) if k == "Rotate" => self.stage = InsStage::PresetRotation, + _ => {} + }, + InsStage::BasePoint => { + if let Input::Point(p) = i { + self.base = Some(p); + self.stage = InsStage::Point; } } - (Some(_), Some(a), None, _) => { - self.scale = Some(match i { - Input::Point(p) => a.dist(p).max(1e-9), - Input::Text(t) => number(&t).filter(|v| *v != 0.0).ok_or_else(|| EngineError::Other("Requires a non-zero number.".into()))?, - _ => 1.0, - }); + InsStage::PresetScale => { + let f = match i { + Input::Text(t) => nonzero(&t)?, + Input::Enter => 1.0, + _ => return Ok(Step::Continue), + }; + self.scale = Some(Vec2::new(f, f)); + self.stage = InsStage::Point; } - (Some(_), Some(a), Some(_), None) => { + InsStage::PresetRotation => { + self.rotation = Some(match i { + Input::Text(t) => crate::units::parse_angle(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?, + Input::Enter => 0.0, + _ => return Ok(Step::Continue), + }); + self.stage = InsStage::Point; + } + InsStage::XScale => match i { + Input::Keyword(k) if k == "Corner" => self.stage = InsStage::Corner, + Input::Point(p) => { + self.scale = Some(Self::corner(self.at.unwrap_or_default(), p)?); + return self.advance(s); + } + Input::Text(t) => { + self.x_scale = Some(nonzero(&t)?); + self.stage = InsStage::YScale; + } + Input::Enter => { + self.x_scale = Some(1.0); + self.stage = InsStage::YScale; + } + _ => {} + }, + InsStage::Corner => { + if let Input::Point(p) = i { + self.scale = Some(Self::corner(self.at.unwrap_or_default(), p)?); + return self.advance(s); + } + } + InsStage::YScale => { + let x = self.x_scale.unwrap_or(1.0); + let y = match i { + Input::Text(t) => nonzero(&t)?, + Input::Enter => x, + _ => return Ok(Step::Continue), + }; + self.scale = Some(Vec2::new(x, y)); + return self.advance(s); + } + InsStage::Rotation => { + let a = self.at.unwrap_or_default(); self.rotation = Some(match i { Input::Point(p) => a.angle_to(p), Input::Text(t) => crate::units::parse_angle(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?, _ => 0.0, }); + return self.advance(s); } - _ => { + InsStage::Attribs => { let (tag, _, default) = self.attdefs.get(self.values.len()).cloned().unwrap_or_default(); let v = match i { Input::Text(t) => t, _ => default, }; self.values.insert(tag, Value::String(v)); + return self.advance(s); } } - if let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation) - && self.values.len() >= self.attdefs.len() - { - insert(s, &n, a, sc, r, &self.values)?; - return Ok(Step::Done); - } Ok(Step::Continue) } fn preview(&self, s: &Session, c: Vec2) -> Vec { let (Some(n), Ok(d)) = (&self.name, s.doc()) else { return Vec::new() }; let Some(b) = d.block(n) else { return Vec::new() }; + // Picking a new base point: the block sits at its definition position. + if self.stage == InsStage::BasePoint { + return vec![EntityKind::Insert(insert_entity(b, b.base.xy(), Vec2::new(1.0, 1.0), 0.0, None))]; + } let at = self.at.unwrap_or(c); - let sc = self.scale.unwrap_or(if self.at.is_some() { at.dist(c).max(1e-9) } else { 1.0 }); - let rot = self.rotation.unwrap_or(if self.scale.is_some() { at.angle_to(c) } else { 0.0 }); - vec![EntityKind::Insert(Insert { - block: b.name.clone(), - insert: v3(at), - scale: Vec3::new(sc, sc, sc), - rotation: rot, - attribs: Vec::new(), - cols: 1, - rows: 1, - col_spacing: 0.0, - row_spacing: 0.0, - })] + let scale = self.scale.unwrap_or_else(|| match (self.stage, self.x_scale) { + (InsStage::XScale | InsStage::Corner, _) => Self::corner(at, c).unwrap_or(Vec2::new(1.0, 1.0)), + (_, Some(x)) => Vec2::new(x, x), + _ => Vec2::new(1.0, 1.0), + }); + let rot = self.rotation.unwrap_or(if self.stage == InsStage::Rotation { at.angle_to(c) } else { 0.0 }); + vec![EntityKind::Insert(insert_entity(b, at, scale, rot, self.base))] } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 247bca96..694cb4be 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -597,7 +597,7 @@ fn count_from_the_menu_shows_its_result() { s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap(); s.execute("selectall", &json!({})).unwrap(); s.cmdline("block Valve 0,0").unwrap(); - s.cmdline("insert Valve 10,0 1 0").unwrap(); + s.cmdline("insert Valve 10,0 1 1 0").unwrap(); s.start("count").unwrap(); let n = s.log.len(); assert_eq!(&s.log[n - 3..], ["Block references in model space:", " Valve: 2", " Total: 2"]); @@ -814,7 +814,7 @@ fn block_insert_with_attributes() { assert!(s.running.is_none()); assert!(s.doc().unwrap().block("Valve").is_some()); assert_eq!(s.doc().unwrap().model.len(), 1, "originals converted to one insert"); - s.cmdline("insert Valve 10,0 2 90 V-101").unwrap(); + s.cmdline("insert Valve 10,0 2 2 90 V-101").unwrap(); let ins: Vec<_> = s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.clone()) } else { None }).collect(); assert_eq!(ins.len(), 2); diff --git a/crates/engine/tests/insert_prompts.rs b/crates/engine/tests/insert_prompts.rs new file mode 100644 index 00000000..89ea729d --- /dev/null +++ b/crates/engine/tests/insert_prompts.rs @@ -0,0 +1,55 @@ +//! Command-line INSERT: X/Y scale prompts and the Basepoint/Scale/Rotate options (#245). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use serde_json::json; + +/// (x, y, x scale, y scale, rotation in degrees) of every block reference, in drawing order. +fn inserts(s: &Session) -> Vec<(f64, f64, f64, f64, f64)> { + let r = |v: f64| (v * 1e6).round() / 1e6; + s.doc() + .unwrap() + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Insert(i) => Some((r(i.insert.x), r(i.insert.y), r(i.scale.x), r(i.scale.y), r(i.rotation.to_degrees()))), + _ => None, + }) + .collect() +} + +#[test] +fn script_insert_reads_x_and_y_scale_then_rotation_and_honours_its_options() { + let mut s = Session::new(); + s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap(); + s.execute("block", &json!({ "name": "B1", "base": [0, 0], "keep": "delete", "handles": ["100"] })).unwrap(); + s.script( + "-INSERT B1 20,20 1 1 0\n\ + -INSERT B1 40,20 2 3 45\n\ + -INSERT B1 S 2 60,20 0\n\ + -INSERT B1 R 30 80,20 1 1\n\ + -INSERT B1 100,20 C 105,22 0\n\ + -INSERT B1 B 5,0 200,0 1 1 0\n", + ) + .unwrap(); + assert!(s.running.is_none(), "every INSERT finished: {}", s.prompt_text()); + assert_eq!( + inserts(&s), + vec![ + (20.0, 20.0, 1.0, 1.0, 0.0), + (40.0, 20.0, 2.0, 3.0, 45.0), + (60.0, 20.0, 2.0, 2.0, 0.0), + (80.0, 20.0, 1.0, 1.0, 30.0), + (100.0, 20.0, 5.0, 2.0, 0.0), + // Base point 5,0 of the block lands on 200,0. + (195.0, 0.0, 1.0, 1.0, 0.0), + ] + ); + // Enter at the Y scale prompt uses the X scale factor. + s.script("-INSERT B1 0,50 4\n\n0\n").unwrap(); + assert_eq!(inserts(&s).last(), Some(&(0.0, 50.0, 4.0, 4.0, 0.0))); + + // The JSON form takes [x, y] scale factors and a base point too. + s.execute("insert", &json!({ "name": "B1", "at": [0, 80], "scale": [2, 3], "basePoint": [10, 0] })).unwrap(); + assert_eq!(inserts(&s).last(), Some(&(-20.0, 80.0, 2.0, 3.0, 0.0))); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index cd2627e5..ee7daf9b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -437,8 +437,10 @@ mod tests { assert_eq!(app.session.current_prompt().map(|p| p.message), Some("Specify insertion point".to_string())); app.session.input(Input::Point(Vec2::new(5.0, 6.0))).unwrap(); - app.session.input(Input::Enter).unwrap(); - app.session.input(Input::Enter).unwrap(); + // X scale, Y scale and rotation take their defaults. + for _ in 0..3 { + app.session.input(Input::Enter).unwrap(); + } assert!(app.session.running.is_none()); let d = app.session.doc().unwrap(); let mut ins = Vec::new(); From 9d2ae09a79c9c846eebd46ea37f3c26ca162ceab Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:12:14 +0200 Subject: [PATCH 084/150] OFFSET Erase/Layer/Multiple/Undo and MOVE/COPY Displacement take effect (fixes #244) (#246) OFFSET's Erase and Layer keywords open their Yes/No and Current/Source prompts (remembered in OFFSETERASE/OFFSETLAYER and shown in the current settings line); Multiple keeps offsetting the newest copy until Enter and Undo takes back the last offset. The JSON form takes `erase` and `layer`. MOVE and COPY ask for a displacement after the Displacement keyword or Enter at the base point prompt. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/modify.rs | 216 ++++++++++++++++++++++++++--- crates/engine/tests/offset_move.rs | 99 +++++++++++++ 2 files changed, 297 insertions(+), 18 deletions(-) create mode 100644 crates/engine/tests/offset_move.rs diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index e52075bb..a22eef63 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -23,12 +23,12 @@ pub fn specs() -> Vec { .menu(&["Modify", "Move"]) .alias(&["m"]) .params("{handles?, from?: [x,y], to?: [x,y] | delta: [dx,dy]}") - .interactive(|_| Ok(Box::new(SelectThen::new(Op::Move)))), + .interactive(|_| Ok(Box::new(DisplaceM::new(Op::Move)))), CommandSpec::new("copy", "Copy", run_copy) .menu(&["Modify", "Copy"]) .alias(&["co", "cp"]) .params("{handles?, delta: [dx,dy] | from,to, count?: n}") - .interactive(|_| Ok(Box::new(SelectThen::new(Op::Copy)))), + .interactive(|_| Ok(Box::new(DisplaceM::new(Op::Copy)))), CommandSpec::new("rotate", "Rotate", run_rotate) .menu(&["Modify", "Rotate"]) .alias(&["ro"]) @@ -52,7 +52,7 @@ pub fn specs() -> Vec { CommandSpec::new("offset", "Offset", run_offset) .menu(&["Modify", "Offset"]) .alias(&["o"]) - .params("{handle, distance, side: [x,y]}") + .params("{handle, distance, side: [x,y], erase?: bool, layer?: \"source\"|\"current\"}") .interactive(|s| Ok(Box::new(OffsetM::new(s)))), CommandSpec::new("trim", "Trim", run_trim) .menu(&["Modify", "Trim"]) @@ -423,13 +423,33 @@ fn run_offset(s: &mut Session, p: &Value) -> Result { 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"))?; + let current_layer = match str_param(p, "layer").map(str::to_ascii_lowercase).as_deref() { + None | Some("source") => false, + Some("current") => true, + Some(_) => return Err(bad("offset", "`layer` is \"source\" or \"current\"")), + }; + let nh = add_offset(s, &e, k, current_layer, bool_or(p, "erase", false))?; + Ok(json!({ "handle": nh.hex() })) +} + +/// Add the offset copy `k` of `src` (on the current layer instead of the source's when +/// `current_layer`), then erase the source when `erase` and its layer is not locked. +fn add_offset(s: &mut Session, src: &Entity, k: EntityKind, current_layer: bool, erase: bool) -> Result { let space = s.space(); let d = s.doc_mut()?; + let mut common = src.common.clone(); + if current_layer { + common.layer = d.header.str("CLAYER", "0"); + } let nh = d.new_handle(); if let Some(st) = d.space_mut(&space) { - st.push(Entity { handle: nh, common: e.common.clone(), kind: k }); + st.push(Entity { handle: nh, common, kind: k }); } - Ok(json!({ "handle": nh.hex() })) + let locked = d.layer(&src.common.layer).is_some_and(|l| l.locked); + if erase && !locked { + d.remove_entity(src.handle); + } + Ok(nh) } // ---------------- trim / extend ---------------- @@ -1692,29 +1712,70 @@ impl Interactive for SelectThen { } } +/// An OFFSET option prompt opened from "Specify offset distance". +#[derive(Clone, Copy, PartialEq)] +enum OffsetAsk { + Erase, + Layer, +} + struct OffsetM { dist: Option, through: bool, picked: Option, + ask: Option, + /// Multiple mode: each side point offsets the newest copy again, until Enter. + multiple: bool, + /// Undo: the drawing before each offset, with the object picked then. + undo: Vec<(std::sync::Arc, Option)>, } impl OffsetM { fn new(s: &Session) -> Self { let _ = s; - OffsetM { dist: None, through: false, picked: None } + OffsetM { dist: None, through: false, picked: None, ask: None, multiple: false, undo: Vec::new() } } } +/// OFFSET's remembered options: erase the source (OFFSETERASE) and put copies on the current +/// layer instead of the source's (kept in the drawing header beside it). +fn offset_options(s: &Session) -> (bool, bool) { + s.doc().map(|d| (d.header.i64("OFFSETERASE", 0) != 0, d.header.i64("OFFSETLAYER", 0) != 0)).unwrap_or((false, false)) +} + impl Interactive for OffsetM { fn name(&self) -> &'static str { "OFFSET" } fn begin(&mut self, s: &mut Session) -> Result { s.set_selection(Vec::new()); - s.echo("Current settings: Erase source=No Layer=Source OFFSETGAPTYPE=0"); + let (erase, current) = offset_options(s); + s.echo(format!( + "Current settings: Erase source={} Layer={} OFFSETGAPTYPE=0", + if erase { "Yes" } else { "No" }, + if current { "Current" } else { "Source" } + )); Ok(Step::Continue) } fn prompt(&self, s: &Session) -> Prompt { + let (erase, current) = offset_options(s); + match self.ask { + Some(OffsetAsk::Erase) => { + return Prompt::new("Erase source object after offsetting?", Accept::TEXT).kw(&["Yes", "No"]).default(if erase { + "Yes" + } else { + "No" + }); + } + Some(OffsetAsk::Layer) => { + return Prompt::new("Enter layer option for offset objects", Accept::TEXT).kw(&["Current", "Source"]).default(if current { + "Current" + } else { + "Source" + }); + } + None => {} + } let last = s.doc().map(|d| d.header.f64("OFFSETDIST", -1.0)).unwrap_or(-1.0); match (self.dist.is_some() || self.through, self.picked) { (false, _) => Prompt::new("Specify offset distance", Accept::POINT_OR_NUMBER).kw(&["Through", "Erase", "Layer"]).default(if last < 0.0 { @@ -1722,12 +1783,41 @@ impl Interactive for OffsetM { } else { format!("{last:.4}") }), - (true, None) => Prompt::new("Select object to offset", Accept::POINT).kw(&["Exit", "Undo"]), - (true, Some(_)) if self.through => Prompt::new("Specify through point", Accept::POINT).kw(&["Exit", "Multiple", "Undo"]), - (true, Some(_)) => Prompt::new("Specify point on side to offset", Accept::POINT).kw(&["Exit", "Multiple", "Undo"]), + (true, None) => Prompt::new("Select object to offset", Accept::POINT).kw(&["Exit", "Undo"]).default("Exit"), + (true, Some(_)) if self.multiple => { + let msg = if self.through { "Specify through point" } else { "Specify point on side to offset" }; + Prompt::new(msg, Accept::POINT).kw(&["Exit", "Undo"]).default("next object") + } + (true, Some(_)) if self.through => Prompt::new("Specify through point", Accept::POINT).kw(&["Exit", "Multiple", "Undo"]).default("Exit"), + (true, Some(_)) => Prompt::new("Specify point on side to offset", Accept::POINT).kw(&["Exit", "Multiple", "Undo"]).default("Exit"), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { + if let Some(ask) = self.ask { + let word = match &i { + Input::Keyword(k) | Input::Text(k) => k.trim().to_ascii_lowercase(), + Input::Enter => String::new(), + _ => return Ok(Step::Continue), + }; + let (key, yes, no) = match ask { + OffsetAsk::Erase => ("OFFSETERASE", "y", "n"), + OffsetAsk::Layer => ("OFFSETLAYER", "c", "s"), + }; + let v = if word.is_empty() { + None + } else if word.starts_with(yes) { + Some(1) + } else if word.starts_with(no) { + Some(0) + } else { + return Err(EngineError::Other("Invalid option keyword.".into())); + }; + if let Some(v) = v { + s.doc_mut()?.header.set_i64(key, v); + } + self.ask = None; + return Ok(Step::Continue); + } if self.dist.is_none() && !self.through { match i { Input::Text(t) => { @@ -1736,6 +1826,8 @@ impl Interactive for OffsetM { s.doc_mut()?.header.set_f64("OFFSETDIST", d); } Input::Keyword(k) if k == "Through" => self.through = true, + Input::Keyword(k) if k == "Erase" => self.ask = Some(OffsetAsk::Erase), + Input::Keyword(k) if k == "Layer" => self.ask = Some(OffsetAsk::Layer), Input::Enter => { let last = s.doc()?.header.f64("OFFSETDIST", -1.0); if last > 0.0 { @@ -1753,6 +1845,34 @@ impl Interactive for OffsetM { s.set_selection(Vec::new()); Ok(Step::Done) } + (_, Input::Keyword(k)) if k == "Undo" => { + match self.undo.pop() { + Some((doc, picked)) => { + // Settings changed meanwhile stay. + let header = s.doc()?.header.clone(); + s.state_mut()?.doc = doc; + s.doc_mut()?.header = header; + self.picked = if self.multiple { picked } else { None }; + if self.picked.is_none() { + self.multiple = false; + } + s.set_selection(self.picked.into_iter().collect()); + } + None => s.echo("Nothing to undo."), + } + Ok(Step::Continue) + } + (Some(_), Input::Keyword(k)) if k == "Multiple" => { + self.multiple = true; + Ok(Step::Continue) + } + (Some(_), Input::Enter) if self.multiple => { + // : back to selecting. + self.multiple = false; + self.picked = None; + s.set_selection(Vec::new()); + Ok(Step::Continue) + } (_, Input::Enter) => { s.set_selection(Vec::new()); Ok(Step::Done) @@ -1771,6 +1891,7 @@ impl Interactive for OffsetM { } (Some(h), Input::Point(p)) => { let e = s.doc()?.entity(h).map(|e| (**e).clone()); + let mut made = None; if let Some(e) = e { let dist = match self.dist { Some(d) => d, @@ -1784,18 +1905,17 @@ impl Interactive for OffsetM { }; match offset_kind(&e.kind, dist, p) { Some(k) => { - let space = s.space(); - let d = s.doc_mut()?; - let nh = d.new_handle(); - if let Some(st) = d.space_mut(&space) { - st.push(Entity { handle: nh, common: e.common.clone(), kind: k }); - } + let before = s.state()?.doc.clone(); + let (erase, current) = offset_options(s); + made = Some(add_offset(s, &e, k, current, erase)?); + self.undo.push((before, Some(h))); } None => s.echo("Cannot offset that object."), } } - self.picked = None; - s.set_selection(Vec::new()); + // Multiple mode carries on from the new copy. + self.picked = if self.multiple { made.or(self.picked) } else { None }; + s.set_selection(self.picked.into_iter().collect()); Ok(Step::Continue) } _ => Ok(Step::Continue), @@ -2033,4 +2153,64 @@ impl Interactive for BreakM { } } +/// MOVE and COPY: [`SelectThen`], plus the Displacement option at the base point prompt +/// (the keyword, or Enter): the typed point is the displacement itself. +struct DisplaceM { + inner: SelectThen, + displacement: bool, +} + +impl DisplaceM { + fn new(op: Op) -> Self { + DisplaceM { inner: SelectThen::new(op), displacement: false } + } + /// At "Specify base point": objects selected, no point yet. + fn at_base(&self) -> bool { + self.inner.sel.done && self.inner.pts.is_empty() && !self.displacement + } +} + +impl Interactive for DisplaceM { + fn name(&self) -> &'static str { + self.inner.name() + } + fn begin(&mut self, s: &mut Session) -> Result { + self.inner.begin(s) + } + fn prompt(&self, s: &Session) -> Prompt { + if self.displacement { + return Prompt::new("Specify displacement", Accept::POINT).default("0,0"); + } + if self.at_base() { + return Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"); + } + self.inner.prompt(s) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.displacement { + let d = match i { + Input::Point(p) => p, + Input::Enter => Vec2::ZERO, + _ => return Ok(Step::Continue), + }; + if d != Vec2::ZERO { + transform_entities(s, &self.inner.objs, &Mat3::translate(d), self.inner.op == Op::Copy)?; + } + s.set_selection(Vec::new()); + return Ok(Step::Done); + } + if self.at_base() && (matches!(&i, Input::Enter) || matches!(&i, Input::Keyword(k) if k == "Displacement")) { + self.displacement = true; + return Ok(Step::Continue); + } + self.inner.input(s, i) + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if self.displacement { + return Vec::new(); + } + self.inner.preview(s, c) + } +} + pub(crate) fn _unused(_: Circle) {} diff --git a/crates/engine/tests/offset_move.rs b/crates/engine/tests/offset_move.rs new file mode 100644 index 00000000..72206a5b --- /dev/null +++ b/crates/engine/tests/offset_move.rs @@ -0,0 +1,99 @@ +//! OFFSET Erase/Layer/Multiple/Undo and the MOVE/COPY Displacement option at the command line. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use serde_json::json; + +/// Every line as ((ax, ay), (bx, by), layer), sorted by the first y. +fn lines(s: &Session) -> Vec<((f64, f64), (f64, f64), String)> { + let mut v: Vec<_> = s + .doc() + .unwrap() + .model + .iter() + .filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some(((l.a.x, l.a.y), (l.b.x, l.b.y), e.common.layer.clone())) } else { None }) + .collect(); + v.sort_by(|a, b| a.0.1.total_cmp(&b.0.1).then(a.0.0.total_cmp(&b.0.0))); + v +} + +fn one_line() -> Session { + let mut s = Session::new(); + s.cmdline("LINE 0,0 10,0").unwrap(); + s.cmdline("").unwrap(); + s +} + +fn l(a: (f64, f64), b: (f64, f64), layer: &str) -> ((f64, f64), (f64, f64), String) { + (a, b, layer.to_string()) +} + +#[test] +fn offset_erase_source() { + let mut s = one_line(); + s.script("OFFSET E Y 2 5,0 5,1\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![l((0.0, 2.0), (10.0, 2.0), "0")]); + // The choice is remembered: the next OFFSET erases the source too, until No. + s.script("OFFSET 2 5,2 5,3\n\n").unwrap(); + assert_eq!(lines(&s), vec![l((0.0, 4.0), (10.0, 4.0), "0")]); + s.script("OFFSET E N 2 5,4 5,5\n\n").unwrap(); + assert_eq!(lines(&s).len(), 2); +} + +#[test] +fn offset_layer_current() { + let mut s = one_line(); + s.execute("layer.new", &json!({ "name": "Other", "current": true })).unwrap(); + s.script("OFFSET L C 2 5,0 5,1\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![l((0.0, 0.0), (10.0, 0.0), "0"), l((0.0, 2.0), (10.0, 2.0), "Other")]); + // Source (the default) keeps the source object's layer. + s.script("OFFSET L S 2 5,0 5,-1\n\n").unwrap(); + assert_eq!(lines(&s).first(), Some(&l((0.0, -2.0), (10.0, -2.0), "0"))); +} + +#[test] +fn offset_multiple_and_undo() { + let mut s = one_line(); + // Multiple: each side point offsets the newest copy again; Undo takes the last one back. + s.script("OFFSET 2 5,0 M 5,1 5,3 5,5 U\n\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![l((0.0, 0.0), (10.0, 0.0), "0"), l((0.0, 2.0), (10.0, 2.0), "0"), l((0.0, 4.0), (10.0, 4.0), "0")]); +} + +#[test] +fn offset_json_erase_and_layer() { + let mut s = one_line(); + s.execute("layer.new", &json!({ "name": "Other", "current": true })).unwrap(); + let h = s.doc().unwrap().model.iter().next().unwrap().handle.hex(); + s.execute("offset", &json!({ "handle": h, "distance": 2, "side": [5, 1], "erase": true, "layer": "current" })).unwrap(); + assert_eq!(lines(&s), vec![l((0.0, 2.0), (10.0, 2.0), "Other")]); +} + +#[test] +fn move_displacement() { + // The Displacement keyword asks for the displacement directly. + let mut s = one_line(); + s.script("MOVE 5,0 D 3,4\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![l((3.0, 4.0), (13.0, 4.0), "0")]); + // Enter at the base point prompt does the same. + let mut s = one_line(); + s.script("MOVE 5,0\n\n\n3,4\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![l((3.0, 4.0), (13.0, 4.0), "0")]); + // Base and second point still work. + let mut s = one_line(); + s.script("MOVE 5,0 1,1 2,3\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![l((1.0, 2.0), (11.0, 2.0), "0")]); +} + +#[test] +fn copy_displacement() { + let mut s = one_line(); + s.script("COPY 5,0 D 0,3\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![l((0.0, 0.0), (10.0, 0.0), "0"), l((0.0, 3.0), (10.0, 3.0), "0")]); +} From 55531fe2303a842605f7ad5c37ee84c57ef59390 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 16:29:32 -0400 Subject: [PATCH 085/150] Snaps in block references: spend budget per array cell; skip empty blocks Follow-up to #318: a huge array of an empty or tiny block could spin up to 1e8 cells in the snap path without touching the entity budget. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/snap.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index e1f6e33a..25fb187d 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -383,9 +383,17 @@ fn block_leaves(d: &Drawing, ins: &Insert, m: &Mat3, depth: usize, budget: &mut return; } let Some(blk) = d.block(&ins.block) else { return }; + if blk.entities.is_empty() { + return; + } let (cols, rows) = (ins.cols.clamp(1, 10_000), ins.rows.clamp(1, 10_000)); for r in 0..rows { for c in 0..cols { + // Each array cell spends budget too, so a huge array of tiny blocks stays bounded. + if *budget == 0 { + return; + } + *budget -= 1; let off = Vec2::new(ins.col_spacing * f64::from(c), ins.row_spacing * f64::from(r)).rotate(ins.rotation); let mm = m.then_before(Mat3::translate(off).then_before(ins.transform(blk.base.xy()))); for be in blk.entities.iter() { From eee9de96f5081bbde27dd3209048f304276cfd67 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:54:33 +0200 Subject: [PATCH 086/150] JSON handles: reject a malformed handle instead of using the selection (fixes #330) (#331) targets() fell back to the current selection when `handle` wasn't a hex handle or `handles` wasn't an array, and dropped unreadable entries from `handles`. A typo such as {"handle": "#101"} made ERASE, MOVE and the other ~70 commands that take objects act on whatever was selected (in a bridged MCP session: the user's selection in the window). Such values are now an error naming the bad value; the selection is used only when neither key is given (or it is null). Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/mod.rs | 18 +++++++---- crates/engine/tests/malformed_handles.rs | 41 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 crates/engine/tests/malformed_handles.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 7dad164d..80953b9c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -228,16 +228,20 @@ pub(crate) fn point_req(cmd: &str, p: &Value, key: &str) -> Result { pub(crate) fn points_param(p: &Value, key: &str) -> Option> { p.get(key)?.as_array()?.iter().map(point_value).collect() } -/// Handles from `handles` (hex strings or numbers) or the current selection. +/// Handles from `handles` (hex strings or numbers), `handle`, or else the current selection. A +/// `handle`/`handles` that can't be read is an error, never a silent fall back to the selection. pub(crate) fn targets(s: &Session, p: &Value) -> Result> { - if let Some(a) = p.get("handles").and_then(Value::as_array) { - let hs: Vec = a.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))).collect(); - return Ok(hs); + let handle = |v: &Value| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle)); + let not_handle = |key: &str, v: &Value| EngineError::Other(format!("`{key}`: {v} is not an object handle (a hex string such as \"1A2\")")); + match p.get("handles") { + None | Some(Value::Null) => {} + Some(Value::Array(a)) => return a.iter().map(|v| handle(v).ok_or_else(|| not_handle("handles", v))).collect(), + Some(v) => return Err(EngineError::Other(format!("`handles` must be an array of object handles, got {v}"))), } - if let Some(h) = p.get("handle").and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))) { - return Ok(vec![h]); + match p.get("handle") { + None | Some(Value::Null) => Ok(s.selection()), + Some(v) => Ok(vec![handle(v).ok_or_else(|| not_handle("handle", v))?]), } - Ok(s.selection()) } pub(crate) fn ok() -> Result { Ok(Value::Null) diff --git a/crates/engine/tests/malformed_handles.rs b/crates/engine/tests/malformed_handles.rs new file mode 100644 index 00000000..332dec43 --- /dev/null +++ b/crates/engine/tests/malformed_handles.rs @@ -0,0 +1,41 @@ +//! A `handle`/`handles` param that can't be read is an error. It never falls back to the current +//! selection, and bad entries are never dropped without a word (#330). + +use cadcraft_engine::Session; +use serde_json::{Value, json}; + +fn circles(s: &mut Session) -> Vec { + s.execute("entities", &json!({ "type": "Circle" })).unwrap()["entities"].as_array().cloned().unwrap_or_default() +} + +#[test] +fn malformed_handles_are_rejected_instead_of_using_the_selection() { + let mut s = Session::new(); + let a = s.execute("circle", &json!({ "center": [0, 0], "radius": 1 })).unwrap()["handle"].as_str().unwrap().to_string(); + s.execute("circle", &json!({ "center": [10, 0], "radius": 1 })).unwrap(); + s.execute("qselect", &json!({ "type": "Circle" })).unwrap(); + assert_eq!(s.selection().len(), 2); + + for (cmd, params) in [ + ("erase", json!({ "handle": format!("#{a}") })), + ("erase", json!({ "handle": "" })), + ("erase", json!({ "handle": -1 })), + ("erase", json!({ "handles": a })), + ("erase", json!({ "handles": [a, "#102"] })), + ("move", json!({ "handles": a, "delta": [5, 5] })), + ("move", json!({ "handle": "0x101", "delta": [5, 5] })), + ] { + let e = s.execute(cmd, ¶ms).expect_err(&format!("{cmd} {params}")); + assert!(e.to_string().contains("handle"), "{cmd} {params}: {e}"); + let c = circles(&mut s); + assert_eq!(c.len(), 2, "{cmd} {params} must change nothing"); + assert!(c.iter().all(|e| e["geometry"]["center"]["y"] == json!(0.0)), "{cmd} {params} must move nothing"); + } + + // Well-formed handles act on exactly those objects; without either key the selection is used. + assert_eq!(s.execute("move", &json!({ "handle": a, "delta": [0, 5] })).unwrap()["moved"], 1); + assert_eq!(s.execute("erase", &json!({ "handles": [a] })).unwrap()["erased"], 1); + s.execute("qselect", &json!({ "type": "Circle" })).unwrap(); + assert_eq!(s.execute("erase", &json!({ "handles": null })).unwrap()["erased"], 1); + assert!(circles(&mut s).is_empty()); +} From ba026c484a1d806b4217419b657c51beebf77e29 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:54:37 +0200 Subject: [PATCH 087/150] Save As to an image or PDF exports without renaming the drawing (fixes #327) (#329) SAVEAS / QSAVE with a .svg, .png or .pdf path wrote the export and then marked the drawing saved under that file: the title became the image name, the unsaved changes were cleared (no "Save changes?" on close) and a later Save wrote the image again. Exports now only store the file; DXF/DWG saves are unchanged. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 6 +++- crates/engine/tests/save_as_export.rs | 43 +++++++++++++++++++++++++++ crates/mcp/src/tools.rs | 2 +- docs/mcp.md | 2 +- 4 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 crates/engine/tests/save_as_export.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f73e2579..7aa536e6 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -159,10 +159,14 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> { /// Store the bytes, then mark the drawing saved under `path`. If storing fails the drawing keeps /// its unsaved changes, file name and title. On the web a started download counts as saved: the /// browser owns the file from there (we can't see whether the user keeps it), and the name stays so -/// a later QSAVE downloads the drawing again under it. +/// a later QSAVE downloads the drawing again under it. An image or PDF export (SVG, PNG, PDF) only +/// stores the file: the drawing keeps its name, title and unsaved changes, so closing still asks. fn commit_save(s: &mut Session, path: &str, bytes: &[u8], store: impl FnOnce(&str, &[u8]) -> Result<()>) -> Result { s.state()?; store(path, bytes)?; + if !is_drawing_file(path) { + return Ok(bytes.len()); + } let st = s.state_mut()?; st.saved = st.doc.clone(); st.path = Some(path.to_string()); diff --git a/crates/engine/tests/save_as_export.rs b/crates/engine/tests/save_as_export.rs new file mode 100644 index 00000000..6aafdacf --- /dev/null +++ b/crates/engine/tests/save_as_export.rs @@ -0,0 +1,43 @@ +//! Saving to an image or PDF path exports the drawing; it isn't a save of the drawing (#327). The +//! drawing keeps its name and its unsaved changes, so closing still asks and QSAVE doesn't write +//! the image again. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use serde_json::json; + +#[test] +fn saveas_to_an_image_keeps_the_drawing_unsaved_under_its_name() { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None }); + let dir = std::env::temp_dir().join(format!("cadcraft-saveas-export-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Session::new(); + s.cmdline("circle 0,0 5").unwrap(); + let title = s.state().unwrap().title.clone(); + + for ext in ["png", "svg", "pdf"] { + let out = dir.join(format!("plate.{ext}")); + s.execute("saveas", &json!({ "path": out.to_string_lossy() })).unwrap(); + assert!(out.exists(), "{ext}: the export is written"); + let st = s.state().unwrap(); + assert!(st.is_dirty(), "{ext}: an export must not clear the unsaved changes"); + assert_eq!((st.path.as_deref(), st.title.as_str()), (None, title.as_str()), "{ext}: the drawing keeps its name"); + } + // QSAVE with a path is the same export. + s.execute("qsave", &json!({ "path": dir.join("again.png").to_string_lossy() })).unwrap(); + assert!(s.state().unwrap().is_dirty()); + // Still no file name: a plain QSAVE asks for one instead of exporting the image again. + assert!(s.execute("qsave", &json!({})).is_err()); + + // A drawing file is a save: name, title and saved state follow it. + let dxf = dir.join("plate.dxf"); + s.execute("saveas", &json!({ "path": dxf.to_string_lossy() })).unwrap(); + let st = s.state().unwrap(); + assert!(!st.is_dirty()); + assert_eq!((st.path.as_deref(), st.title.as_str()), (Some(dxf.to_string_lossy().as_ref()), "plate.dxf")); + // Exporting afterwards keeps that name, so QSAVE writes the DXF again. + s.execute("saveas", &json!({ "path": dir.join("plate.png").to_string_lossy() })).unwrap(); + let r = s.execute("qsave", &json!({})).unwrap(); + assert_eq!(r["path"], json!(dxf.to_string_lossy())); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index ada981d4..7673231d 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -23,7 +23,7 @@ pub fn tool_definitions(has_ui: bool) -> Value { json!({"name": "new_drawing", "description": "Create a new drawing (imperial by default, or metric).", "inputSchema": {"type": "object", "properties": {"metric": {"type": "boolean"}}}}), json!({"name": "open", "description": "Open a DXF file.", "inputSchema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}), - json!({"name": "save", "description": "Save the drawing (DXF; .svg/.png export by extension).", "inputSchema": {"type": "object", "properties": {"path": {"type": "string"}}}}), + json!({"name": "save", "description": "Save the drawing (DXF or DWG by extension). A .svg, .png or .pdf path exports instead: the drawing keeps its name and unsaved changes.", "inputSchema": {"type": "object", "properties": {"path": {"type": "string"}}}}), json!({"name": "cancel", "description": "Press Escape: cancel the running command (or clear the selection).", "inputSchema": {"type": "object", "properties": {}}}), ]; if has_ui { diff --git a/docs/mcp.md b/docs/mcp.md index c383854d..21836ad0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -34,7 +34,7 @@ Example client configuration (Claude Code / Claude Desktop): | `inspect_drawing {entities?, limit?}` | Counts, extents, layers, styles, blocks, layouts, selection, undo history and entities. | | `query_entities {type?, layer?, window?}` | Filtered entities with handles and geometry. | | `render {width?, height?, fit?}` | A PNG image of the drawing. | -| `new_drawing {metric?}`, `open {path}`, `save {path?}`, `cancel` | Files and Escape. | +| `new_drawing {metric?}`, `open {path}`, `save {path?}`, `cancel` | Files and Escape. `save` to a `.svg`, `.png` or `.pdf` path exports: the drawing keeps its name and unsaved changes. | | `screenshot`, `ui_inspect`, `ui_click {x, y}` | Only when connected to the app. | Resources: `cadcraft://drawing` (inspect JSON) and `cadcraft://commands` (catalog). From 049504a81e2d2013b0eb6faa61f76d1d963d57e2 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:54:42 +0200 Subject: [PATCH 088/150] Nearest snap picks the closest object in any drawing order (fixes #323) (#324) A circle in the aperture replaced the best Nearest candidate without comparing distances, so a closer line drawn earlier lost to it. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/snap.rs | 18 ++++++------- crates/engine/tests/snap_nearest.rs | 40 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 9 deletions(-) create mode 100644 crates/engine/tests/snap_nearest.rs diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 25fb187d..ccfbaf17 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -237,20 +237,20 @@ pub(crate) fn osnap_with( return Some(h); } if osmode & mode::NEA != 0 { + // The closest point of all nearby objects, whatever their drawing order. let mut nb: Option<(f64, Vec2)> = None; + let mut consider = |c: Vec2| { + let dd = c.dist(cursor); + if dd <= aperture && nb.is_none_or(|(bd, _)| dd < bd) { + nb = Some((dd, c)); + } + }; for p in &near_prims { for s in prim_segments(p) { - let c = fix_circle(&s).closest(cursor); - let dd = c.dist(cursor); - if dd <= aperture && nb.is_none_or(|(bd, _)| dd < bd) { - nb = Some((dd, c)); - } + consider(fix_circle(&s).closest(cursor)); } if let Prim::Circle(c) = p { - let q = c.closest(cursor); - if q.dist(cursor) <= aperture { - nb = Some((q.dist(cursor), q)); - } + consider(c.closest(cursor)); } } if let Some((_, p)) = nb { diff --git a/crates/engine/tests/snap_nearest.rs b/crates/engine/tests/snap_nearest.rs new file mode 100644 index 00000000..ad744914 --- /dev/null +++ b/crates/engine/tests/snap_nearest.rs @@ -0,0 +1,40 @@ +//! Nearest (NEA) object snap picks the closest point of all objects in the aperture, whatever +//! order they were drawn in. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::Space; +use cadcraft_engine::geom::Vec2; +use cadcraft_engine::snap::{self, mode}; +use serde_json::json; + +fn nearest(s: &Session, cursor: Vec2) -> Vec2 { + let hit = snap::osnap(s.doc().unwrap(), &Space::Model, cursor, 1.0, mode::NEA, None, false).expect("a nearest snap"); + assert_eq!(hit.mode, mode::NEA); + hit.point +} + +#[test] +fn nearest_prefers_the_closer_object_in_any_drawing_order() { + // A line along y = 0 and a circle whose edge passes y = 0.8 above the cursor at (0, 0.1): + // the line (0.1 away) is closer than the circle (0.7 away). + for circle_first in [false, true] { + let mut s = Session::new(); + let line = || json!({ "points": [[-5, 0], [5, 0]] }); + let circle = || json!({ "center": [0, 3.8], "radius": 3 }); + if circle_first { + s.execute("circle", &circle()).unwrap(); + s.execute("line", &line()).unwrap(); + } else { + s.execute("line", &line()).unwrap(); + s.execute("circle", &circle()).unwrap(); + } + let p = nearest(&s, Vec2::new(0.0, 0.1)); + assert!(p.near(Vec2::new(0.0, 0.0), 1e-6), "circle drawn first: {circle_first}: snapped to {p:?}"); + } + // And the circle wins where it is the closer one. + let mut s = Session::new(); + s.execute("circle", &json!({ "center": [0, 3.8], "radius": 3 })).unwrap(); + s.execute("line", &json!({ "points": [[-5, 0], [5, 0]] })).unwrap(); + let p = nearest(&s, Vec2::new(0.0, 0.7)); + assert!(p.near(Vec2::new(0.0, 0.8), 1e-6), "snapped to {p:?}"); +} From 0fef87950926e762c8aeec680293dbf94af1f76a Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:54:48 +0200 Subject: [PATCH 089/150] Grip edits act on the whole selection (fixes #320) (#322) Stretching a grip now drags the grips of the other selected objects that sit on the same point, so connected lines stay connected, and the Move, Rotate, Scale and Mirror grip modes transform every selected object about the hot grip, as in AutoCAD. grip.move/grip.stretch take the other objects in `handles`; with only `handle` they edit that object alone, as before. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/gripcmds.rs | 30 +++++++++++--- crates/engine/src/grips.rs | 21 +++++----- crates/engine/tests/grip_selection.rs | 60 +++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 16 deletions(-) create mode 100644 crates/engine/tests/grip_selection.rs diff --git a/crates/engine/src/cmd/gripcmds.rs b/crates/engine/src/cmd/gripcmds.rs index 74b91ee6..7065a8c6 100644 --- a/crates/engine/src/cmd/gripcmds.rs +++ b/crates/engine/src/cmd/gripcmds.rs @@ -11,9 +11,10 @@ use crate::{EngineError, Result, Session}; pub fn specs() -> Vec { vec![ - CommandSpec::new("grip.move", "Grip Stretch", run_grip_move) - .params("{handle, index, to} (stretch the grip) | {handles?, index, baseHandle?, to, mode: \"move\", copy?} (move about the grip)"), - CommandSpec::new("grip.stretch", "Grip Stretch", run_grip_stretch).params("{handle, index, to}"), + CommandSpec::new("grip.move", "Grip Stretch", run_grip_move).params( + "{handle?, handles?, index, to} (stretch the grip) | {handles?, index, baseHandle?, to, mode: \"move\", copy?} (move about the grip)", + ), + CommandSpec::new("grip.stretch", "Grip Stretch", run_grip_stretch).params("{handle?, handles?, index, to}"), CommandSpec::new("grip.rotate", "Grip Rotate", run_grip_rotate).params("{handles?, base | baseHandle+index, angle (degrees) | to, copy?}"), CommandSpec::new("grip.scale", "Grip Scale", run_grip_scale).params("{handles?, base | baseHandle+index, factor | to, copy?}"), CommandSpec::new("grip.mirror", "Grip Mirror", run_grip_mirror) @@ -37,8 +38,27 @@ fn stretch(s: &mut Session, cmd: &str, p: &Value) -> Result { } let e = curves::entity(s, h)?; let k = stretch_grip(&e.kind, i, to).ok_or_else(|| bad(cmd, "no such grip, or the result would be degenerate"))?; - s.doc_mut()?.modify_entity(h, |e| e.kind = k)?; - Ok(json!({ "handle": h.hex() })) + let mut edits = vec![(h, k)]; + // The other `handles` with a grip on the same point stretch with it (connected objects stay + // connected); locked ones and edits that would be degenerate are left alone. + let from = e.kind.grips().get(i).copied().unwrap_or(to); + let tol = 1e-9 * (1.0 + from.len()); + for o in targets(s, p)? { + if o == h || edits.iter().any(|(x, _)| *x == o) || curves::is_locked(s, o) { + continue; + } + let Ok(oe) = curves::entity(s, o) else { continue }; + if let Some(j) = oe.kind.grips().iter().position(|g| g.near(from, tol)) + && let Some(k) = stretch_grip(&oe.kind, j, to) + { + edits.push((o, k)); + } + } + let d = s.doc_mut()?; + for (o, k) in &edits { + d.modify_entity(*o, |e| e.kind = k.clone())?; + } + Ok(json!({ "handle": h.hex(), "handles": edits.iter().map(|(o, _)| o.hex()).collect::>() })) } /// Base point: `base`, or grip `index` of `baseHandle` (default: the first target). diff --git a/crates/engine/src/grips.rs b/crates/engine/src/grips.rs index d08f47bc..f0e2d2d4 100644 --- a/crates/engine/src/grips.rs +++ b/crates/engine/src/grips.rs @@ -291,21 +291,20 @@ pub fn mode_matrix(mode: GripMode, base: Vec2, to: Vec2) -> Option { impl Session { /// Drag grip `grip_index` of `handle` to `new_point` in `mode` (one undo step). - /// The base point of the rotate/scale/mirror modes is the grip itself. + /// The base point of the rotate/scale/mirror modes is the grip itself. As in AutoCAD the edit + /// covers the selection: a stretch drags the grips of other selected objects that sit on the + /// same point, and the other modes transform every selected object. pub fn grip_edit(&mut self, handle: Handle, grip_index: usize, new_point: Vec2, mode: GripMode) -> Result<()> { let to = [new_point.x, new_point.y]; + let hs: Vec = std::iter::once(handle).chain(self.selection().into_iter().filter(|h| *h != handle)).map(|h| h.hex()).collect(); let r = match mode { - GripMode::Stretch => self.execute("grip.move", &json!({ "handle": handle.hex(), "index": grip_index, "to": to })), - GripMode::Move => self.execute("grip.move", &json!({ "handles": [handle.hex()], "index": grip_index, "to": to, "mode": "move" })), - GripMode::Rotate => { - self.execute("grip.rotate", &json!({ "handles": [handle.hex()], "baseHandle": handle.hex(), "index": grip_index, "to": to })) - } - GripMode::Scale => { - self.execute("grip.scale", &json!({ "handles": [handle.hex()], "baseHandle": handle.hex(), "index": grip_index, "to": to })) - } - GripMode::Mirror => { - self.execute("grip.mirror", &json!({ "handles": [handle.hex()], "baseHandle": handle.hex(), "index": grip_index, "to": to })) + GripMode::Stretch => self.execute("grip.move", &json!({ "handle": handle.hex(), "handles": hs, "index": grip_index, "to": to })), + GripMode::Move => { + self.execute("grip.move", &json!({ "handles": hs, "baseHandle": handle.hex(), "index": grip_index, "to": to, "mode": "move" })) } + GripMode::Rotate => self.execute("grip.rotate", &json!({ "handles": hs, "baseHandle": handle.hex(), "index": grip_index, "to": to })), + GripMode::Scale => self.execute("grip.scale", &json!({ "handles": hs, "baseHandle": handle.hex(), "index": grip_index, "to": to })), + GripMode::Mirror => self.execute("grip.mirror", &json!({ "handles": hs, "baseHandle": handle.hex(), "index": grip_index, "to": to })), }; r.map(|_| ()) } diff --git a/crates/engine/tests/grip_selection.rs b/crates/engine/tests/grip_selection.rs new file mode 100644 index 00000000..09032d8b --- /dev/null +++ b/crates/engine/tests/grip_selection.rs @@ -0,0 +1,60 @@ +//! Grip editing acts on the whole selection, as in AutoCAD: a stretched grip drags the +//! coincident grips of the other selected objects with it, and Move/Rotate/Scale/Mirror +//! transform every selected object. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Handle}; +use cadcraft_engine::geom::Vec2; +use cadcraft_engine::grips::GripMode; +use serde_json::json; + +fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle { + let r = s.execute("line", &json!({ "points": [a, b] })).unwrap(); + Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap() +} + +fn ends(s: &Session, h: Handle) -> (Vec2, Vec2) { + match &s.doc().unwrap().entity(h).unwrap().kind { + EntityKind::Line(l) => (l.a.xy(), l.b.xy()), + k => panic!("not a line: {k:?}"), + } +} + +fn near(p: Vec2, x: f64, y: f64) -> bool { + p.near(Vec2::new(x, y), 1e-9) +} + +#[test] +fn grip_edits_act_on_the_selection() { + // Two connected lines and a third one elsewhere, all selected. + let mut s = Session::new(); + let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + let b = line(&mut s, [10.0, 0.0], [10.0, 10.0]); + let c = line(&mut s, [20.0, 0.0], [30.0, 0.0]); + s.set_selection(vec![a, b, c]); + let undo = s.state().unwrap().undo.len(); + + // Stretching the shared corner grip of `a` moves `b`'s start with it; `c` stays. + s.grip_edit(a, 2, Vec2::new(12.0, 3.0), GripMode::Stretch).unwrap(); + assert!(near(ends(&s, a).1, 12.0, 3.0)); + assert!(near(ends(&s, b).0, 12.0, 3.0) && near(ends(&s, b).1, 10.0, 10.0), "{:?}", ends(&s, b)); + assert!(near(ends(&s, c).0, 20.0, 0.0) && near(ends(&s, c).1, 30.0, 0.0)); + assert_eq!(s.state().unwrap().undo.len(), undo + 1, "one undo step"); + + // Move about a grip of `a`: every selected object moves. + s.grip_edit(a, 0, Vec2::new(1.0, 1.0), GripMode::Move).unwrap(); + assert!(near(ends(&s, a).0, 1.0, 1.0)); + assert!(near(ends(&s, b).1, 11.0, 11.0)); + assert!(near(ends(&s, c).0, 21.0, 1.0)); + + // Rotate 90° about the same grip: `c` turns too. + s.grip_edit(a, 0, Vec2::new(1.0, 5.0), GripMode::Rotate).unwrap(); + assert!(near(ends(&s, c).0, 1.0, 21.0), "{:?}", ends(&s, c)); + + // An object that isn't selected is only edited through its own grip. + s.set_selection(vec![a]); + let before = ends(&s, b); + let g = s.grips_of(a).unwrap()[2]; + s.grip_edit(a, 2, g + Vec2::new(1.0, 0.0), GripMode::Stretch).unwrap(); + assert_eq!(ends(&s, b), before); +} From 2e3da663809092c7b1b326d8693a9b3219d3c8ef Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:54:53 +0200 Subject: [PATCH 090/150] Refuse edits whose result has infinite or undefined coordinates (fixes #338) (#339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inputs to MOVE, SCALE, ROTATE, ARRAY, OFFSET and the rest are checked for being finite, but finite inputs can still overflow: moving by 1e308 twice put a circle at infinity, after which inspect showed null coordinates, the extents were null and a DXF save wrote it at 0,0. After a command changes the drawing (JSON `execute`, grip edits, and an interactive command when it ends), the objects it changed are checked: every number in their fields and their extents must be finite. If not, the drawing and selection are put back, no undo step is recorded and the command fails with "the result is too large: object … would get infinite or undefined coordinates. Nothing was changed". Co-authored-by: Claude Opus 5.5 --- crates/engine/src/finite.rs | 263 ++++++++++++++++++++++++ crates/engine/src/lib.rs | 10 + crates/engine/tests/non_finite_edits.rs | 56 +++++ 3 files changed, 329 insertions(+) create mode 100644 crates/engine/src/finite.rs create mode 100644 crates/engine/tests/non_finite_edits.rs diff --git a/crates/engine/src/finite.rs b/crates/engine/src/finite.rs new file mode 100644 index 00000000..cc81b5e5 --- /dev/null +++ b/crates/engine/src/finite.rs @@ -0,0 +1,263 @@ +//! Edits whose result has coordinates too large to represent (NaN or infinite) are refused. +//! +//! Finite inputs can still overflow: moving by `[1e308, 1e308]` twice, scaling by a huge factor or +//! offsetting by a huge distance. Infinite coordinates break extents, inspection and rendering, and +//! are silently changed by a DXF save and reopen, so the command fails and the drawing stays as it +//! was. Only objects the command changed are checked: every number in their serialized fields, and +//! their extents. + +use std::collections::HashSet; +use std::sync::Arc; + +use cadcraft_doc::{Drawing, Entity, EntityStore, Handle, entity_bounds}; +use serde::Serialize; +use serde::ser; + +use crate::{EngineError, Session}; + +impl Session { + /// After command `cmd` changed the drawing from `before` (the document `uid`, or the active + /// one): when an object it changed has NaN or infinite numbers, put the drawing and selection + /// back and return the error to report. + pub(crate) fn refuse_non_finite(&mut self, uid: Option, before: &Arc, selection: &[Handle], cmd: &str) -> crate::Result<()> { + let st = match uid { + Some(uid) => self.docs.iter_mut().find(|d| d.uid == uid), + None => self.state_mut().ok(), + }; + let Some(st) = st.filter(|st| !Arc::ptr_eq(before, &st.doc)) else { return Ok(()) }; + let Some(h) = first_non_finite(before, &st.doc) else { return Ok(()) }; + st.doc = before.clone(); + st.selection = selection.to_vec(); + Err(EngineError::BadParams { + cmd: cmd.into(), + msg: format!("the result is too large: object {} would get infinite or undefined coordinates. Nothing was changed", h.hex()), + }) + } +} + +/// The first object changed between `before` and `after` that has a NaN or infinite number, or +/// whose extents overflow. +pub(crate) fn first_non_finite(before: &Drawing, after: &Drawing) -> Option { + let layouts = after.layouts.iter().map(|l| (&l.entities, before.layout(&l.name).map(|b| &b.entities))); + let blocks = after + .blocks + .iter() + .filter(|(name, b)| before.blocks.get(*name).is_none_or(|old| !Arc::ptr_eq(old, b))) + .map(|(name, b)| (&b.entities, before.blocks.get(name).map(|old| &old.entities))); + std::iter::once((&after.model, Some(&before.model))).chain(layouts).chain(blocks).find_map(|(a, b)| changed_non_finite(after, b, a)) +} + +fn changed_non_finite(d: &Drawing, before: Option<&EntityStore>, after: &EntityStore) -> Option { + if before.is_some_and(|b| b.same_as(after)) { + return None; + } + // Chunks shared with `before` hold only unchanged objects. + let shared: HashSet = before.map(|b| b.chunk_slices().map(|(key, _)| key).collect()).unwrap_or_default(); + let finite = |e: &Entity| { + let b = entity_bounds(d, e, 0); + is_finite(e) && (b.is_empty() || (b.min.is_finite() && b.max.is_finite())) + }; + after + .chunk_slices() + .filter(|(key, _)| !shared.contains(key)) + .flat_map(|(_, items)| items.iter()) + .filter(|e| before.and_then(|b| b.get(e.handle)).is_none_or(|old| !Arc::ptr_eq(old, e))) + .find(|e| !finite(e)) + .map(|e| e.handle) +} + +/// Whether every number in `v` is finite. +pub(crate) fn is_finite(v: &T) -> bool { + !matches!(v.serialize(Check), Err(Stop::NonFinite)) +} + +/// Why the walk stopped: a non-finite number, or a value that can't be serialized (not ours to judge). +#[derive(Debug)] +enum Stop { + NonFinite, + Other, +} + +impl std::fmt::Display for Stop { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Stop::NonFinite => "non-finite number", + Stop::Other => "not serializable", + }) + } +} + +impl std::error::Error for Stop {} + +impl ser::Error for Stop { + fn custom(_: T) -> Self { + Stop::Other + } +} + +/// A serializer that only looks at floats. +#[derive(Clone, Copy)] +struct Check; + +type R = Result<(), Stop>; + +macro_rules! ignore { + ($($name:ident: $t:ty),*) => { $(fn $name(self, _: $t) -> R { Ok(()) })* }; +} + +impl ser::Serializer for Check { + type Ok = (); + type Error = Stop; + type SerializeSeq = Self; + type SerializeTuple = Self; + type SerializeTupleStruct = Self; + type SerializeTupleVariant = Self; + type SerializeMap = Self; + type SerializeStruct = Self; + type SerializeStructVariant = Self; + + ignore!(serialize_bool: bool, serialize_i8: i8, serialize_i16: i16, serialize_i32: i32, serialize_i64: i64, serialize_i128: i128, + serialize_u8: u8, serialize_u16: u16, serialize_u32: u32, serialize_u64: u64, serialize_u128: u128, serialize_char: char, + serialize_str: &str, serialize_bytes: &[u8], serialize_unit_struct: &'static str); + + fn serialize_f32(self, v: f32) -> R { + if v.is_finite() { Ok(()) } else { Err(Stop::NonFinite) } + } + fn serialize_f64(self, v: f64) -> R { + if v.is_finite() { Ok(()) } else { Err(Stop::NonFinite) } + } + fn serialize_none(self) -> R { + Ok(()) + } + fn serialize_some(self, v: &T) -> R { + v.serialize(self) + } + fn serialize_unit(self) -> R { + Ok(()) + } + fn serialize_unit_variant(self, _: &'static str, _: u32, _: &'static str) -> R { + Ok(()) + } + fn serialize_newtype_struct(self, _: &'static str, v: &T) -> R { + v.serialize(self) + } + fn serialize_newtype_variant(self, _: &'static str, _: u32, _: &'static str, v: &T) -> R { + v.serialize(self) + } + fn serialize_seq(self, _: Option) -> Result { + Ok(self) + } + fn serialize_tuple(self, _: usize) -> Result { + Ok(self) + } + fn serialize_tuple_struct(self, _: &'static str, _: usize) -> Result { + Ok(self) + } + fn serialize_tuple_variant(self, _: &'static str, _: u32, _: &'static str, _: usize) -> Result { + Ok(self) + } + fn serialize_map(self, _: Option) -> Result { + Ok(self) + } + fn serialize_struct(self, _: &'static str, _: usize) -> Result { + Ok(self) + } + fn serialize_struct_variant(self, _: &'static str, _: u32, _: &'static str, _: usize) -> Result { + Ok(self) + } +} + +impl ser::SerializeSeq for Check { + type Ok = (); + type Error = Stop; + fn serialize_element(&mut self, v: &T) -> R { + v.serialize(*self) + } + fn end(self) -> R { + Ok(()) + } +} + +impl ser::SerializeTuple for Check { + type Ok = (); + type Error = Stop; + fn serialize_element(&mut self, v: &T) -> R { + v.serialize(*self) + } + fn end(self) -> R { + Ok(()) + } +} + +impl ser::SerializeTupleStruct for Check { + type Ok = (); + type Error = Stop; + fn serialize_field(&mut self, v: &T) -> R { + v.serialize(*self) + } + fn end(self) -> R { + Ok(()) + } +} + +impl ser::SerializeTupleVariant for Check { + type Ok = (); + type Error = Stop; + fn serialize_field(&mut self, v: &T) -> R { + v.serialize(*self) + } + fn end(self) -> R { + Ok(()) + } +} + +impl ser::SerializeMap for Check { + type Ok = (); + type Error = Stop; + fn serialize_key(&mut self, k: &T) -> R { + k.serialize(*self) + } + fn serialize_value(&mut self, v: &T) -> R { + v.serialize(*self) + } + fn end(self) -> R { + Ok(()) + } +} + +impl ser::SerializeStruct for Check { + type Ok = (); + type Error = Stop; + fn serialize_field(&mut self, _: &'static str, v: &T) -> R { + v.serialize(*self) + } + fn end(self) -> R { + Ok(()) + } +} + +impl ser::SerializeStructVariant for Check { + type Ok = (); + type Error = Stop; + fn serialize_field(&mut self, _: &'static str, v: &T) -> R { + v.serialize(*self) + } + fn end(self) -> R { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_nan_and_infinity_anywhere() { + assert!(is_finite(&(1.0, vec![Some(2.0f32)], "inf"))); + assert!(!is_finite(&vec![(0.0, f64::INFINITY)])); + assert!(!is_finite(&Some(f32::NAN))); + let mut m = std::collections::BTreeMap::new(); + m.insert("x", f64::NEG_INFINITY); + assert!(!is_finite(&m)); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 74a3cbb4..3ba5e370 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -10,6 +10,7 @@ pub mod assoc; pub mod cmd; +mod finite; pub mod grips; mod guard; pub mod prompt; @@ -478,6 +479,10 @@ impl Session { }; cmd::constraints::after_command(self, before.as_ref().map(|b| &b.0), spec.undoable && result.is_ok()); assoc::after_command(self, before.as_ref().map(|b| &b.0), before.as_ref().map(|b| b.2)); + let result = match (result, &before) { + (Ok(v), Some((doc, sel, uid))) if spec.undoable => self.refuse_non_finite(Some(*uid), doc, sel, spec.id).map(|()| v), + (r, _) => r, + }; if spec.undoable && let Some((doc, sel, uid)) = before && let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid) @@ -679,6 +684,11 @@ impl Session { } fn finish(&mut self, run: Running, cancelled: bool) { + if find_command(&run.id).is_some_and(|c| c.undoable) + && let Err(e) = self.refuse_non_finite(None, &run.before, &run.selection_before, &run.id) + { + self.echo(e.to_string()); + } // A command outside undo that changed the drawing (UNDO, which swapped it for an earlier // one) records no undo step, as in `execute`. if find_command(&run.id).is_some_and(|c| !c.undoable) && self.state().is_ok_and(|st| !Arc::ptr_eq(&run.before, &st.doc)) { diff --git a/crates/engine/tests/non_finite_edits.rs b/crates/engine/tests/non_finite_edits.rs new file mode 100644 index 00000000..5b1c688e --- /dev/null +++ b/crates/engine/tests/non_finite_edits.rs @@ -0,0 +1,56 @@ +//! Edits whose result would have infinite or undefined coordinates fail and leave the drawing as it +//! was, whether they come as JSON (MCP, control channel) or are typed at the command line. + +use cadcraft_engine::Session; +use serde_json::{Value, json}; + +fn circle(s: &mut Session) -> String { + s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap()["handle"].as_str().unwrap().to_string() +} + +fn snapshot(s: &mut Session) -> (Value, usize) { + let i = s.execute("drawing.inspect", &json!({})).unwrap(); + let undo = i["undo"].as_array().map_or(0, Vec::len); + (i["entities"].clone(), undo) +} + +#[test] +fn json_transforms_that_overflow_are_refused() { + let huge = [1e308, 1e308]; + let cases: Vec<(&str, Value)> = vec![ + ("move", json!({"delta": huge})), + ("copy", json!({"delta": huge})), + ("scale", json!({"base": [0, 0], "factor": 1e308})), + ("rotate", json!({"base": [-1e308, -1e308], "angle": 180})), + ("mirror", json!({"p1": [-1e308, -1e308], "p2": [-1e308, 0]})), + ("arrayrect", json!({"rows": 3, "cols": 1, "rowSpacing": 1e308, "colSpacing": 1})), + ("stretch", json!({"window": [[-2, -2], [1.7e308, 1.7e308]], "delta": huge})), + ("offset", json!({"distance": 1e308, "side": [1.7e308, 1.7e308]})), + ]; + for (cmd, p) in cases { + let mut s = Session::new(); + let h = circle(&mut s); + // Put it where the edit overflows: far out, then once more. + s.execute("move", &json!({"handles": [h], "delta": huge})).unwrap(); + let before = snapshot(&mut s); + let mut p = p; + p["handles"] = json!([h]); + p["handle"] = json!(h); + let r = s.execute(cmd, &p); + let err = r.expect_err(&format!("{cmd} {p} must fail")); + assert!(err.to_string().contains("too large"), "{cmd}: {err}"); + assert_eq!(snapshot(&mut s), before, "{cmd} must leave the drawing and undo history unchanged"); + } +} + +#[test] +fn typed_move_that_overflows_is_refused() { + let mut s = Session::new(); + circle(&mut s); + s.script("MOVE ALL\n\n0,0 1e308,1e308\n").unwrap(); + let before = snapshot(&mut s); + s.script("MOVE ALL\n\n0,0 1e308,1e308\n").unwrap(); + assert_eq!(snapshot(&mut s), before); + assert!(s.log.iter().any(|l| l.contains("too large")), "{:?}", s.log); + assert!(s.running.is_none()); +} From 282487fbc532f427f7c9edc30929af5867018013 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:54:58 +0200 Subject: [PATCH 091/150] MCP: reject tool arguments that don't fit the schema; query_entities counts all matches and windows by geometry (fixes #335) (#336) tools/call checks the arguments against the tool's input schema: an unknown tool, non-object arguments, a missing required argument or one of the wrong type is a JSON-RPC -32602 error, as the MCP spec says, so `command_line {}` no longer presses Enter and `script {}` no longer succeeds. An empty batch `[]` gets one Invalid Request (-32600). The `entities` command behind query_entities rejects a malformed window, non-string type/layer and bad limit/offset instead of ignoring them. `count` is every match, `returned` the page (`offset` is now in the tool schema), and a window matches objects whose geometry is inside or crosses it (the window/crossing selection test), with `crossing: false` for entirely inside. list_commands' filter matches command ids, labels and aliases, not any text in the command's JSON. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/inquiry.rs | 47 ++++++++++----- crates/engine/src/select.rs | 57 +++++++++--------- crates/engine/tests/query_entities.rs | 28 +++++++++ crates/mcp/src/server.rs | 6 +- crates/mcp/src/tests.rs | 60 +++++++++++++++++++ crates/mcp/src/tools.rs | 83 ++++++++++++++++++++++----- docs/mcp.md | 9 ++- 7 files changed, 232 insertions(+), 58 deletions(-) create mode 100644 crates/engine/tests/query_entities.rs diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index 45cefcdc..e0536a2b 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -38,7 +38,9 @@ pub fn specs() -> Vec { CommandSpec::new("time", "Time", run_time).menu(&["Tools", "Inquiry", "Time"]).noundo(), CommandSpec::new("status", "Status", run_status).menu(&["Tools", "Inquiry", "Status"]).noundo(), CommandSpec::new("drawing.inspect", "Inspect Drawing", run_inspect).params("{entities?: bool, limit?: n}").noundo(), - CommandSpec::new("entities", "Query Entities", run_entities).params("{type?, layer?, window?: [[x,y],[x,y]], limit?, offset?}").noundo(), + CommandSpec::new("entities", "Query Entities", run_entities) + .params("{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}") + .noundo(), CommandSpec::new("count", "Count", run_count) .menu(&["Tools", "Count"]) .params("{block?} → {blocks: {name: n}} | {block, count}, plus a `message` line") @@ -289,24 +291,43 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result { } fn run_entities(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "entities"; + let given = |k: &str| p.get(k).filter(|v| !v.is_null()); + let text = |k: &str| -> Result> { + given(k).map(|v| v.as_str().map(str::to_ascii_lowercase).ok_or_else(|| bad(CMD, format!("`{k}` must be a string")))).transpose() + }; + let count = |k: &str, default: usize| -> Result { + given(k).map_or(Ok(default), |v| { + v.as_u64().map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| bad(CMD, format!("`{k}` must be a whole number, 0 or more"))) + }) + }; + let ty = text("type")?; + let layer = text("layer")?; + let window = given("window") + .map(|w| { + let corners: Option> = w.as_array().filter(|a| a.len() == 2).and_then(|a| a.iter().map(point_value).collect()); + match corners.as_deref() { + Some([a, b]) => Ok(cadcraft_geom::Bounds2::new(*a, *b)), + _ => Err(bad(CMD, "`window` must be two corner points [[x0, y0], [x1, y1]]")), + } + }) + .transpose()?; + let crossing = given("crossing").map_or(Ok(true), |v| v.as_bool().ok_or_else(|| bad(CMD, "`crossing` must be true or false")))?; + let limit = count("limit", 500)?; + let offset = count("offset", 0)?; let st = s.state()?; let d = &st.doc; - let ty = str_param(p, "type").map(str::to_ascii_lowercase); - let layer = str_param(p, "layer").map(str::to_ascii_lowercase); - let window = points_param(p, "window").and_then(|w| Some(cadcraft_geom::Bounds2::new(*w.first()?, *w.get(1)?))); - let limit = p.get("limit").and_then(Value::as_u64).unwrap_or(500) as usize; - let offset = p.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize; - let Some(store) = d.space(&st.space) else { return Ok(json!({ "entities": [] })) }; - let matches: Vec = store + let Some(store) = d.space(&st.space) else { return Ok(json!({ "count": 0, "returned": 0, "entities": [] })) }; + let tol = window.as_ref().map(crate::select::window_tol).unwrap_or(0.0); + let matches: Vec<&std::sync::Arc> = store .iter() .filter(|e| ty.as_ref().is_none_or(|t| e.kind.type_name().to_ascii_lowercase() == *t || e.kind.dxf_name().to_ascii_lowercase() == *t)) .filter(|e| layer.as_ref().is_none_or(|l| e.common.layer.to_ascii_lowercase() == *l)) - .filter(|e| window.is_none_or(|w| w.intersects(&cadcraft_doc::entity_bounds(d, e, 0)))) - .skip(offset) - .take(limit) - .map(|e| entity_summary(e)) + .filter(|e| window.as_ref().is_none_or(|w| crate::select::in_window(d, e, None, w, crossing, tol))) .collect(); - Ok(json!({ "count": matches.len(), "entities": matches })) + let page: Vec = matches.iter().skip(offset).take(limit).map(|e| entity_summary(e)).collect(); + // `count` is every match; `returned` is this page of them (`offset`, `limit`). + Ok(json!({ "count": matches.len(), "returned": page.len(), "offset": offset, "entities": page })) } fn run_count(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/select.rs b/crates/engine/src/select.rs index a7d45ca9..550dd163 100644 --- a/crates/engine/src/select.rs +++ b/crates/engine/src/select.rs @@ -165,35 +165,36 @@ pub fn select_window(d: &Drawing, space: &Space, bx: Bounds2, crossing: bool) -> pub(crate) fn select_window_with(d: &Drawing, space: &Space, ix: &Option>, bx: Bounds2, crossing: bool) -> Vec { let Some(cands) = candidates(d, space, ix, &bx, crossing, false) else { return Vec::new() }; - let tol = (bx.width() + bx.height()).max(1e-9) / 2000.0; - let mut out = Vec::new(); - for (e, known) in cands { - if !selectable(d, e) { - continue; - } - let infinite = is_infinite(e); - let eb = bounds_of(d, e, known); - if !infinite && bx.contains_box(&eb) { - out.push(e.handle); - continue; - } - if !crossing || (!infinite && !eb.intersects(&bx)) { - continue; - } - let polys = hit_polylines(d, e, tol); - let hit = polys.iter().any(|pl| { - if pl.len() == 1 { - pl.first().is_some_and(|p| bx.contains(*p)) - } else { - pl.windows(2).any(|w| w.first().zip(w.get(1)).is_some_and(|(a, b)| seg_hits_box(*a, *b, &bx))) - } - }); - // A crossing box entirely inside a filled/text object also selects it. - if hit || (eb.contains_box(&bx) && matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::Insert(_))) { - out.push(e.handle); - } + let tol = window_tol(&bx); + cands.filter(|(e, known)| selectable(d, e) && in_window(d, e, *known, &bx, crossing, tol)).map(|(e, _)| e.handle).collect() +} + +/// The tessellation tolerance window and crossing tests use for a box. +pub(crate) fn window_tol(bx: &Bounds2) -> f64 { + (bx.width() + bx.height()).max(1e-9) / 2000.0 +} + +/// Whether a window box (`crossing` false: entirely inside) or a crossing box (inside or +/// touching its geometry, not just its bounding box) selects `e`. +pub(crate) fn in_window(d: &Drawing, e: &Entity, known: Option, bx: &Bounds2, crossing: bool, tol: f64) -> bool { + let infinite = is_infinite(e); + let eb = bounds_of(d, e, known); + if !infinite && bx.contains_box(&eb) { + return true; } - out + if !crossing || (!infinite && !eb.intersects(bx)) { + return false; + } + let polys = hit_polylines(d, e, tol); + let hit = polys.iter().any(|pl| { + if pl.len() == 1 { + pl.first().is_some_and(|p| bx.contains(*p)) + } else { + pl.windows(2).any(|w| w.first().zip(w.get(1)).is_some_and(|(a, b)| seg_hits_box(*a, *b, bx))) + } + }); + // A crossing box entirely inside a filled/text object also selects it. + hit || (eb.contains_box(bx) && matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::Insert(_))) } /// Fence selection: entities crossed by the fence polyline. diff --git a/crates/engine/tests/query_entities.rs b/crates/engine/tests/query_entities.rs new file mode 100644 index 00000000..e6fab682 --- /dev/null +++ b/crates/engine/tests/query_entities.rs @@ -0,0 +1,28 @@ +//! The `entities` query (control channel `engine.execute`, MCP `query_entities`) rejects malformed +//! filters instead of ignoring them and returning everything. + +use cadcraft_engine::Session; +use serde_json::json; + +#[test] +fn malformed_filters_are_errors() { + let mut s = Session::new(); + s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap(); + s.execute("circle", &json!({"center": [100, 100], "radius": 1})).unwrap(); + for p in [ + json!({"window": [-5, -5, 5, 5]}), + json!({"window": [[-5, -5]]}), + json!({"window": [[-5, -5], [5, 5], [9, 9]]}), + json!({"window": {"min": [-5, -5], "max": [5, 5]}}), + json!({"window": "x"}), + json!({"type": 7}), + json!({"layer": ["0"]}), + json!({"limit": -1}), + json!({"offset": "1"}), + json!({"crossing": "yes"}), + ] { + assert!(s.execute("entities", &p).is_err(), "{p} must be rejected"); + } + let r = s.execute("entities", &json!({"window": [[-5, -5], [5, 5]]})).unwrap(); + assert_eq!(r["count"], 1, "{r}"); +} diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 760e32ef..ba6627c6 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -62,6 +62,8 @@ impl Server { return None; } let reply = match serde_json::from_str::(line) { + // JSON-RPC 2.0: an empty batch is one Invalid Request, not silence. + Ok(Value::Array(batch)) if batch.is_empty() => Some(error(Value::Null, INVALID_REQUEST, "empty batch")), Ok(Value::Array(batch)) => { let replies: Vec = batch.into_iter().filter_map(|m| self.handle(m)).collect(); (!replies.is_empty()).then_some(Value::Array(replies)) @@ -110,8 +112,8 @@ impl Server { "tools/list" => Ok(tool_definitions(self.backend.has_ui())), "tools/call" => { let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?; - let args = params.get("arguments").cloned().unwrap_or(json!({})); - Ok(call_tool(self.backend.as_mut(), name, &args)) + let args = params.get("arguments").unwrap_or(&Value::Null); + call_tool(self.backend.as_mut(), name, args).map_err(|m| (INVALID_PARAMS, m)) } "resources/list" => Ok(json!({"resources": [ {"uri": "cadcraft://drawing", "name": "Active drawing", "mimeType": "application/json"}, diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 7fc66072..1b98ead9 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -79,3 +79,63 @@ fn errors_are_tool_errors_not_crashes() { assert_eq!(r["result"]["isError"], true); assert!(s.handle_line("{not json").unwrap().contains("-32700")); } + +/// Unknown tools and arguments that don't fit a tool's schema are invalid params (-32602), never +/// guessed at; an empty batch is one Invalid Request. +#[test] +fn bad_tool_calls_are_invalid_params() { + let mut s = Server::new(Box::new(Headless::default())); + tool(&mut s, "command_line", json!({"text": "circle 0,0 1"})); + let bad = [ + json!({"name": "nope", "arguments": {}}), + json!({"name": "command_line", "arguments": {}}), + json!({"name": "command_line", "arguments": {"text": 5}}), + json!({"name": "command_line", "arguments": "circle 1,1 1"}), + json!({"name": "script", "arguments": {}}), + json!({"name": "query_entities", "arguments": {"window": [-5, -5, 5, 5]}}), + json!({"name": "query_entities", "arguments": {"window": [[-5, -5]]}}), + json!({"name": "query_entities", "arguments": {"window": {"min": [-5, -5], "max": [5, 5]}}}), + json!({"name": "query_entities", "arguments": {"type": 7}}), + json!({"name": "query_entities", "arguments": {"layer": ["0"]}}), + json!({"name": "query_entities", "arguments": {"offset": -1}}), + ]; + for (i, params) in bad.into_iter().enumerate() { + let r = call(&mut s, i as u64, "tools/call", params.clone()); + assert_eq!(r["error"]["code"], -32602, "{params} -> {r}"); + } + // Nothing was typed: the circle is still the only object and no command is running. + let d = tool(&mut s, "inspect_drawing", json!({"entities": false})); + assert_eq!(d["entityCount"], 1, "{d}"); + assert_eq!(s.handle_line("[]").map(|r| serde_json::from_str::(&r).unwrap()["error"]["code"].clone()), Some(json!(-32600))); +} + +/// `count` is every match while `returned` is the page; a window matches geometry, not bounding boxes. +#[test] +fn query_entities_pages_and_windows_by_geometry() { + let mut s = Server::new(Box::new(Headless::default())); + for c in ["circle 0,0 1", "circle 100,100 1", "circle 200,0 1"] { + tool(&mut s, "command_line", json!({"text": c})); + } + // An arc whose bounding box covers the window but whose curve stays outside it. + tool(&mut s, "execute", json!({"command": "arc", "params": {"p1": [40, 0], "p2": [50, 10], "p3": [60, 0]}})); + let r = tool(&mut s, "query_entities", json!({"type": "Circle", "limit": 1, "offset": 1})); + assert_eq!((r["count"].clone(), r["returned"].clone()), (json!(3), json!(1)), "{r}"); + assert_eq!(r["entities"][0]["geometry"]["center"]["x"], 100.0, "{r}"); + let r = tool(&mut s, "query_entities", json!({"window": [[45, 1], [55, 5]]})); + assert_eq!(r["count"], 0, "the arc passes above this window: {r}"); + let r = tool(&mut s, "query_entities", json!({"window": [[-5, -5], [55, 5]]})); + assert_eq!(r["count"], 2, "the circle at 0,0 and the arc crossing the window: {r}"); + let r = tool(&mut s, "query_entities", json!({"window": [[-5, -5], [55, 5]], "crossing": false})); + assert_eq!(r["count"], 1, "only the circle is entirely inside: {r}"); +} + +#[test] +fn list_commands_filter_matches_names_not_docs() { + let mut s = Server::new(Box::new(Headless::default())); + let r = tool(&mut s, "list_commands", json!({"filter": "circle"})); + let ids: Vec<&str> = r.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect(); + assert!(ids.contains(&"circle"), "{ids:?}"); + assert!(!ids.contains(&"qselect"), "QSELECT only mentions circles in its docs: {ids:?}"); + let r = tool(&mut s, "list_commands", json!({"filter": "pl"})); + assert!(r.as_array().unwrap().iter().any(|c| c["id"] == "pline"), "aliases and ids match"); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 7673231d..ed50d00c 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -12,12 +12,14 @@ pub fn tool_definitions(has_ui: bool) -> Value { "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}}), json!({"name": "execute", "description": "Run any command with JSON parameters (no prompts or dialogs). Use list_commands to find ids and parameter docs. Examples: line {points:[[0,0],[10,0]]}, circle {center:[0,0], radius:5}, layer.new {name:\"Walls\", color:\"red\", current:true}, move {handles:[\"10A\"], delta:[5,0]}, zoom {mode:\"extents\"}.", "inputSchema": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}, "required": ["command"]}}), - json!({"name": "list_commands", "description": "List commands with ids, labels, menu paths, aliases and parameter docs. Optional substring filter.", + json!({"name": "list_commands", "description": "List commands with ids, labels, menu paths, aliases and parameter docs. Optional filter: a substring of the command id, label or an alias.", "inputSchema": {"type": "object", "properties": {"filter": {"type": "string"}}}}), json!({"name": "inspect_drawing", "description": "Summary of the active drawing: entity counts, extents, layers, styles, blocks, layouts, selection, undo history and (optionally) entities with handles and geometry.", "inputSchema": {"type": "object", "properties": {"entities": {"type": "boolean"}, "limit": {"type": "integer"}}}}), - json!({"name": "query_entities", "description": "Entities filtered by type (Line, Circle, Polyline…), layer and/or a window [[x0,y0],[x1,y1]].", - "inputSchema": {"type": "object", "properties": {"type": {"type": "string"}, "layer": {"type": "string"}, "window": {"type": "array"}, "limit": {"type": "integer"}}}}), + json!({"name": "query_entities", "description": "Entities filtered by type (Line, Circle, Polyline…), layer and/or a window [[x0,y0],[x1,y1]] (objects whose geometry is inside or crosses it; crossing:false = entirely inside). Returns count (all matches), returned (this page) and entities; page with offset and limit (default 500).", + "inputSchema": {"type": "object", "properties": {"type": {"type": "string"}, "layer": {"type": "string"}, + "window": {"type": "array", "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, "minItems": 2, "maxItems": 2}, + "crossing": {"type": "boolean"}, "limit": {"type": "integer", "minimum": 0}, "offset": {"type": "integer", "minimum": 0}}}}), json!({"name": "render", "description": "Render the active drawing to a PNG (fitted to extents by default) and return it as an image.", "inputSchema": {"type": "object", "properties": {"width": {"type": "integer"}, "height": {"type": "integer"}, "fit": {"type": "boolean"}, "path": {"type": "string"}}}}), json!({"name": "new_drawing", "description": "Create a new drawing (imperial by default, or metric).", @@ -42,8 +44,65 @@ fn err_text(e: &str) -> Value { json!({"content": [{"type": "text", "text": e}], "isError": true}) } -pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> Value { - let a = if args.is_object() { args.clone() } else { json!({}) }; +/// Whether `v` fits the subset of JSON Schema the tool definitions use: `type`, `items`, +/// `minItems`, `maxItems` and `minimum`. +fn fits(v: &Value, schema: &Value) -> bool { + let typed = match schema["type"].as_str() { + Some("string") => v.is_string(), + // JSON Schema integers include numbers such as 2.0. + Some("integer") => v.as_f64().is_some_and(|f| f.fract() == 0.0), + Some("number") => v.is_number(), + Some("boolean") => v.is_boolean(), + Some("array") => v.is_array(), + Some("object") => v.is_object(), + _ => true, + }; + let len = v.as_array().map(Vec::len); + typed + && schema["minimum"].as_f64().is_none_or(|m| v.as_f64().is_none_or(|f| f >= m)) + && schema["minItems"].as_u64().is_none_or(|m| len.is_none_or(|n| n as u64 >= m)) + && schema["maxItems"].as_u64().is_none_or(|m| len.is_none_or(|n| n as u64 <= m)) + && (schema["items"].is_null() || v.as_array().is_none_or(|a| a.iter().all(|i| fits(i, &schema["items"])))) +} + +/// Arguments checked against the tool's input schema: an object, required properties present and +/// each given property fitting its schema (`null` counts as not given). `Err` is the +/// invalid-params message. +fn check_args(name: &str, args: &Value) -> Result { + let defs = tool_definitions(true); + let schema = defs["tools"].as_array().and_then(|t| t.iter().find(|t| t["name"] == name)).map(|t| t["inputSchema"].clone()); + let Some(schema) = schema else { return Err(format!("unknown tool `{name}`")) }; + let a = match args { + Value::Null => json!({}), + Value::Object(_) => args.clone(), + _ => return Err(format!("`arguments` of `{name}` must be an object")), + }; + for key in schema["required"].as_array().into_iter().flatten().filter_map(Value::as_str) { + if a.get(key).is_none_or(Value::is_null) { + return Err(format!("`{name}` needs `{key}`")); + } + } + for (key, prop) in schema["properties"].as_object().into_iter().flatten() { + if let Some(v) = a.get(key).filter(|v| !v.is_null()) + && !fits(v, prop) + { + return Err(format!("`{key}` of `{name}` must match {prop}")); + } + } + Ok(a) +} + +/// Whether a command-catalog entry's id, label or one of its aliases contains `f` (lower case). +fn command_matches(c: &Value, f: &str) -> bool { + let has = |s: &str| s.to_ascii_lowercase().contains(f); + ["id", "label"].iter().any(|k| c[*k].as_str().is_some_and(has)) + || c["aliases"].as_array().is_some_and(|a| a.iter().filter_map(Value::as_str).any(has)) +} + +/// Run a tool. `Err` is a protocol error (unknown tool, or arguments that don't fit its input +/// schema), answered with JSON-RPC invalid params; a failing tool is an `isError` result. +pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> Result { + let a = check_args(name, args)?; let r = match name { "command_line" => b.call("cmdline.input", a), "script" => b.call("cmdline.script", a), @@ -54,9 +113,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> Value { "list_commands" => b.call("engine.commands", json!({})).map(|v| { let f = a.get("filter").and_then(Value::as_str).map(str::to_ascii_lowercase); match (v, f) { - (Value::Array(items), Some(f)) => { - Value::Array(items.into_iter().filter(|c| c.to_string().to_ascii_lowercase().contains(&f)).collect()) - } + (Value::Array(items), Some(f)) => Value::Array(items.into_iter().filter(|c| command_matches(c, &f)).collect()), (v, _) => v, } }), @@ -70,18 +127,18 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> Value { "ui_inspect" => b.call("ui.inspect", json!({})), "ui_click" => b.call("ui.pointer", a), "render" => { - return match b.call("ui.render", a) { + return Ok(match b.call("ui.render", a) { Ok(v) => match v.get("pngBase64").and_then(Value::as_str) { Some(data) => json!({"content": [{"type": "image", "data": data, "mimeType": "image/png"}]}), None => text(&v), }, Err(e) => err_text(&e), - }; + }); } - other => return err_text(&format!("unknown tool `{other}`")), + other => return Err(format!("unknown tool `{other}`")), }; - match r { + Ok(match r { Ok(v) => text(&v), Err(e) => err_text(&e), - } + }) } diff --git a/docs/mcp.md b/docs/mcp.md index 21836ad0..7845f5a4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -30,15 +30,20 @@ Example client configuration (Claude Code / Claude Desktop): | `command_line {text}` | Type at the command line exactly like a person: `line 0,0 @10,0 @0,5 c`, `circle 5,2 1`, `offset 0.5`, `zoom e`. Spaces act as Enter; empty text is Enter. Returns the new prompt, keywords and output. | | `script {text}` | A multi-line script (like `.scr` files). | | `execute {command, params}` | Any command with JSON parameters, never a dialog. `list_commands` shows ids and parameter docs. | -| `list_commands {filter?}` | The command catalog. | +| `list_commands {filter?}` | The command catalog; `filter` matches part of a command's id, label or an alias. | | `inspect_drawing {entities?, limit?}` | Counts, extents, layers, styles, blocks, layouts, selection, undo history and entities. | -| `query_entities {type?, layer?, window?}` | Filtered entities with handles and geometry. | +| `query_entities {type?, layer?, window?, crossing?, limit?, offset?}` | Filtered entities with handles and geometry. `window` `[[x0,y0],[x1,y1]]` finds objects whose geometry is inside it or crosses it (`crossing: false`: entirely inside). `count` is every match, `returned` the page (`offset`, `limit`, default 500). | | `render {width?, height?, fit?}` | A PNG image of the drawing. | | `new_drawing {metric?}`, `open {path}`, `save {path?}`, `cancel` | Files and Escape. `save` to a `.svg`, `.png` or `.pdf` path exports: the drawing keeps its name and unsaved changes. | | `screenshot`, `ui_inspect`, `ui_click {x, y}` | Only when connected to the app. | Resources: `cadcraft://drawing` (inspect JSON) and `cadcraft://commands` (catalog). +Arguments are checked against each tool's input schema. An unknown tool, `arguments` that aren't an +object, a missing required argument or one of the wrong type is a JSON-RPC error `-32602` (invalid +params), so `command_line {}` never presses Enter by accident. A tool that runs and fails (a bad +point, an unknown command) returns a result with `isError: true` and the message. + ## A typical session 1. `command_line {"text": "rectang 0,0 100,60"}` From fe0eb45537033c8e23d73446f1db3f8c6d4734a1 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:55:02 +0200 Subject: [PATCH 092/150] Dimensions take viewport layer colours, leader arrows their style's DIMSCALE, attributes their own properties (fixes #313) (#319) - A dimension on a layer with a viewport colour override now takes the override inside that viewport (dimension and extension lines, arrows, text), like every other object. - A LEADER's arrowhead is DIMASZ x DIMSCALE of the leader's dimension style; the drawing's DIMSCALE only applies when the style's is 0 (as for dimensions), and both 0 no longer gives a zero-size arrow. - Attributes are drawn with the properties of their attribute definition, which AutoCAD copies to the attribute on insertion: ByLayer = the definition's layer (layer 0 = the block reference's layer), ByBlock = the block reference's colour and lineweight, explicit colours and lineweights as set. An attribute on an off or frozen layer is hidden. Attributes without a definition keep the block reference's colour and now its lineweight too, instead of 0. Co-authored-by: Claude Opus 5.5 --- crates/render/src/lib.rs | 31 +++++- crates/render/tests/render_props.rs | 165 ++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 crates/render/tests/render_props.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 30873021..29b54d72 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -635,7 +635,8 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) { && let (Some(a), Some(n)) = (pts.first(), pts.get(1)) { let st = ctx.d.dim_style(&l.style); - let size = st.map(|s| s.arrow_size).unwrap_or(0.18) * ctx.d.header.f64("DIMSCALE", 1.0); + // DIMASZ × DIMSCALE of the leader's style (the drawing's DIMSCALE when it is 0). + let size = st.map(|s| s.arrow_size).unwrap_or(0.18) * leader_scale(ctx.d, st); let kind = dim::Arrowhead::parse(st.map(|s| s.arrow_block.as_str()).unwrap_or("")); let g = dim::arrowhead(kind, *a, (*a - *n).normalized(), size); for l in &g.lines { @@ -782,7 +783,7 @@ fn dimension(b: &mut Builder, ctx: &Ctx, e: &Entity, dm: &cadcraft_doc::Dimensio let g = dimension_in(d, dm); // DIMCLRD / DIMCLRE / DIMCLRT: ByBlock = the dimension's own colour. let layer_name = if e.common.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { e.common.layer.as_str() }; - let layer_color = d.layer(layer_name).map(|l| l.color).unwrap_or(Color::Index(7)); + let layer_color = ctx.layer_color(layer_name).unwrap_or(Color::Index(7)); let own = match e.common.color { Color::ByLayer => layer_color, Color::ByBlock => ctx.block_color, @@ -973,10 +974,34 @@ fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rg continue; } let (sh, _) = place_text_entity(ctx.d, &a.text, &a.text.value); - b.shaped(ctx, rgb, 0.0, &sh); + // Properties of the attribute's definition (as AutoCAD copies them to the attribute + // on insertion): ByBlock = the reference's, layer 0 = the reference's layer. + let def = ctx.d.block(&ins.block).and_then(|blk| blk.entities.iter().find(|be| attdef_tag(be, &a.tag))); + let (ink, lw, shown) = match def { + Some(def) => { + let (ink, lw, _, _, shown) = resolve(&sub_ctx(ctx, e, Mat3::IDENTITY), def, b.plotting); + (ink, lw, shown) + } + None => (rgb, resolve(ctx, e, b.plotting).1, true), + }; + if shown { + b.shaped(ctx, ink, if b.opts.lineweights { lw } else { 0.0 }, &sh); + } } } } +/// The attribute definition with tag `tag` (any case). +fn attdef_tag(e: &Entity, tag: &str) -> bool { + matches!(&e.kind, EntityKind::AttDef(d) if d.tag.eq_ignore_ascii_case(tag)) +} + +/// The overall scale of a leader drawn with dimension style `st`: its DIMSCALE, else the +/// drawing's. +fn leader_scale(d: &Drawing, st: Option<&cadcraft_doc::DimStyle>) -> f64 { + let dimscale = d.header.f64("DIMSCALE", 1.0); + st.map(|s| s.effective_scale(dimscale)).unwrap_or_else(|| cadcraft_doc::DimStyle::default().effective_scale(dimscale)) +} + #[cfg(test)] mod tests; diff --git a/crates/render/tests/render_props.rs b/crates/render/tests/render_props.rs new file mode 100644 index 00000000..19ba7be1 --- /dev/null +++ b/crates/render/tests/render_props.rs @@ -0,0 +1,165 @@ +//! Dimensions take viewport layer colours, leader arrows their style's DIMSCALE, attributes +//! their own properties (issue #313). + +use std::sync::Arc; + +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::{ + Attrib, Block, Common, DimKind, DimStyle, Dimension, Drawing, Entity, EntityKind, HAlign, Handle, Insert, Leader, Line, Lineweight, Space, Text, + VAlign, Viewport, +}; +use cadcraft_geom::{Bounds2, Vec2, Vec3}; +use cadcraft_render::{Kind, Options, VIEWPORT_CONTENT, build}; + +fn rgb(i: u8) -> Rgb { + Color::Index(i).resolve(Color::Index(7), Color::Index(7)) +} + +fn layer(d: &mut Drawing, name: &str, color: u8, lw: Lineweight) { + d.ensure_layer(name); + let l = d.layer_mut(name).unwrap(); + l.color = Color::Index(color); + l.lineweight = lw; +} + +fn on(layer: &str) -> Common { + Common { layer: layer.into(), ..Default::default() } +} + +#[test] +fn dimensions_take_the_viewport_layer_colour() { + let mut d = Drawing::new_imperial(); + layer(&mut d, "A", 1, Lineweight::ByLayer); + let dim = Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(0.0, 2.0, 0.0), + text_mid: Vec3::ZERO, + p13: Vec3::ZERO, + p14: Vec3::new(10.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 0.0, + text_rotation: 0.0, + user_text_pos: false, + block: None, + overrides: Default::default(), + assoc: Vec::new(), + }; + d.add(&Space::Model, on("A"), EntityKind::Dimension(dim)).unwrap(); + d.add(&Space::Model, on("A"), EntityKind::Line(Line { a: Vec3::new(0.0, -1.0, 0.0), b: Vec3::new(10.0, -1.0, 0.0) })).unwrap(); + let vp = Viewport { + center: Vec3::new(10.0, 10.0, 0.0), + width: 20.0, + height: 10.0, + view_center: Vec2::new(5.0, 1.0), + view_height: 10.0, + id: 2, + locked: false, + frozen_layers: Vec::new(), + layer_colors: vec![("a".into(), Color::Index(3))], + }; + d.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Viewport(vp)).unwrap(); + let l = build(&d, &Space::Paper("Layout1".into()), &Options::default()); + let seen: Vec = l.prims.iter().filter(|p| p.handle == VIEWPORT_CONTENT).map(|p| p.color).collect(); + assert!(seen.iter().filter(|c| **c == rgb(3)).count() > 3, "dimension lines, arrows and text: {seen:?}"); + assert!(seen.iter().all(|c| *c == rgb(3)), "everything on layer A is green in the viewport: {seen:?}"); + // Model space keeps the layer's own colour. + let m = build(&d, &Space::Model, &Options::default()); + assert!(m.prims.iter().all(|p| p.color == rgb(1))); +} + +/// Length of the leader's arrowhead along the leader (x). +fn arrow_length(d: &Drawing) -> f64 { + let l = build(d, &Space::Model, &Options::default()); + let tris: Vec = l.prims.iter().filter(|p| p.kind == Kind::Tris).flat_map(|p| l.points(p).to_vec()).collect(); + Bounds2::from_points(tris).width() +} + +#[test] +fn leader_arrows_use_their_style_dimscale() { + let mut d = Drawing::new_imperial(); + d.dim_styles.push(DimStyle { name: "BIG".into(), scale: 10.0, arrow_size: 0.18, ..DimStyle::default() }); + let leader = Leader { vertices: vec![Vec3::ZERO, Vec3::new(5.0, 0.0, 0.0)], arrow: true, spline: false, style: "BIG".into() }; + d.add(&Space::Model, Common::default(), EntityKind::Leader(leader)).unwrap(); + d.header.set_f64("DIMSCALE", 1.0); + assert!((arrow_length(&d) - 1.8).abs() < 1e-6, "{}", arrow_length(&d)); + // A style DIMSCALE of 0 falls back to the drawing's. + d.dim_styles.iter_mut().filter(|s| s.name == "BIG").for_each(|s| s.scale = 0.0); + d.header.set_f64("DIMSCALE", 2.0); + assert!((arrow_length(&d) - 0.36).abs() < 1e-6, "{}", arrow_length(&d)); + // Both 0: never a zero-size arrow. + d.header.set_f64("DIMSCALE", 0.0); + assert!((arrow_length(&d) - 0.18).abs() < 1e-6, "{}", arrow_length(&d)); +} + +fn text(x: f64, value: &str) -> Text { + Text { + insert: Vec3::new(x, 0.0, 0.0), + align_pt: None, + height: 1.0, + value: value.into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: HAlign::Left, + valign: VAlign::Baseline, + } +} + +fn attdef(handle: u64, tag: &str, x: f64, common: Common) -> Entity { + let a = Attrib { tag: tag.into(), text: text(x, tag), invisible: false, constant: false, prompt: String::new() }; + Entity { common, ..Entity::new(Handle(handle), EntityKind::AttDef(a)) } +} + +/// Colour and lineweight of the attribute text drawn between x0 and x0 + 5 (none = not drawn). +fn attrib_at(d: &Drawing, x0: f64) -> Option<(Rgb, f32)> { + let l = build(d, &Space::Model, &Options { lineweights: true, ..Options::default() }); + let mut it = l.prims.iter().filter(|p| l.points(p).iter().all(|q| q.x >= x0 - 0.5 && q.x < x0 + 5.0)).map(|p| (p.color, p.lw)); + let first = it.next()?; + assert!(it.all(|x| x == first), "one colour and lineweight per attribute"); + Some(first) +} + +#[test] +fn attributes_draw_with_their_own_properties() { + let mut d = Drawing::new_imperial(); + layer(&mut d, "TXT", 2, Lineweight::Mm100(50)); + layer(&mut d, "INS", 5, Lineweight::Mm100(18)); + let mut blk = Block::new("TAGS"); + // ByLayer on its own layer; ByBlock; ByLayer on layer 0 (= the reference's layer); explicit. + blk.entities.push(attdef(0x10, "BYLAYER", 0.0, on("TXT"))); + blk.entities.push(attdef(0x11, "BYBLOCK", 10.0, Common { color: Color::ByBlock, lineweight: Lineweight::ByBlock, ..on("0") })); + blk.entities.push(attdef(0x12, "LAYER0", 20.0, on("0"))); + blk.entities.push(attdef(0x13, "OWN", 30.0, Common { color: Color::Index(6), lineweight: Lineweight::Mm100(70), ..on("TXT") })); + d.blocks.insert(blk.name.clone(), Arc::new(blk)); + let attribs = [("bylayer", 0.0), ("ByBlock", 10.0), ("LAYER0", 20.0), ("OWN", 30.0), ("NODEF", 40.0)] + .map(|(tag, x)| Attrib { tag: tag.into(), text: text(x, "X"), invisible: false, constant: false, prompt: String::new() }) + .to_vec(); + let ins = Insert { + block: "TAGS".into(), + insert: Vec3::ZERO, + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs, + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + let common = Common { color: Color::Index(1), lineweight: Lineweight::Mm100(35), ..on("INS") }; + d.add(&Space::Model, common, EntityKind::Insert(ins)).unwrap(); + + assert_eq!(attrib_at(&d, 0.0), Some((rgb(2), 0.5)), "ByLayer: the attribute's layer"); + assert_eq!(attrib_at(&d, 10.0), Some((rgb(1), 0.35)), "ByBlock: the block reference's"); + assert_eq!(attrib_at(&d, 20.0), Some((rgb(5), 0.18)), "layer 0: the block reference's layer"); + assert_eq!(attrib_at(&d, 30.0), Some((rgb(6), 0.7)), "explicit"); + assert_eq!(attrib_at(&d, 40.0), Some((rgb(1), 0.35)), "no definition: the block reference's"); + + // An attribute on a layer that is off is hidden; the others stay. + d.layer_mut("TXT").unwrap().on = false; + assert_eq!(attrib_at(&d, 0.0), None); + assert_eq!(attrib_at(&d, 10.0), Some((rgb(1), 0.35))); +} From b1a78fbb8f1dcbd507f1ab94005cd5f8f21a5fa3 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:55:07 +0200 Subject: [PATCH 093/150] Typed angles follow ANGBASE, ANGDIR and AUNITS (fixes #326) (#328) Polar points and angle prompts read bare numbers in AUNITS (explicit d/r/g, deg-min-sec and bearings still work) and measure directions from ANGBASE in the ANGDIR sense; `d< --- crates/engine/src/cmd/array.rs | 11 ++- crates/engine/src/cmd/blocks.rs | 4 +- crates/engine/src/cmd/draw.rs | 8 +- crates/engine/src/cmd/draw2.rs | 11 ++- crates/engine/src/cmd/modify.rs | 4 +- crates/engine/src/cmd/modify2.rs | 3 +- crates/engine/src/lib.rs | 2 +- crates/engine/src/prompt.rs | 12 ++- crates/engine/src/sysvars.rs | 15 ++- crates/engine/src/units.rs | 131 ++++++++++++++++++++------ crates/engine/tests/angle_settings.rs | 98 +++++++++++++++++++ crates/io/tests/angle_header.rs | 18 ++++ crates/ui-egui/src/cmdline.rs | 2 +- crates/ui-egui/src/dyninput.rs | 3 +- 14 files changed, 272 insertions(+), 50 deletions(-) create mode 100644 crates/engine/tests/angle_settings.rs create mode 100644 crates/io/tests/angle_header.rs diff --git a/crates/engine/src/cmd/array.rs b/crates/engine/src/cmd/array.rs index e6fbb5e9..e00624be 100644 --- a/crates/engine/src/cmd/array.rs +++ b/crates/engine/src/cmd/array.rs @@ -140,11 +140,12 @@ fn count(i: &Input, cur: u64, max: u64) -> Result> { } } -/// An angle in degrees typed at a prompt (Enter keeps `cur`); `None` for inputs the prompt ignores. -fn angle(i: &Input, cur: f64) -> Result> { +/// An angle in degrees typed at a prompt in AUNITS (Enter keeps `cur`); `None` for inputs the +/// prompt ignores. +fn angle(i: &Input, cur: f64, au: &crate::units::AngleSettings) -> Result> { match i { Input::Enter => Ok(Some(cur)), - Input::Text(t) => crate::units::parse_angle(t).map(|a| Some(a.to_degrees())).ok_or_else(|| other("Requires a valid angle in degrees.")), + Input::Text(t) => au.amount(t).map(|a| Some(a.to_degrees())).ok_or_else(|| other("Requires a valid angle in degrees.")), _ => Ok(None), } } @@ -728,7 +729,7 @@ impl Interactive for PolarM { None => stage, }, (Between, Input::Enter) => Main, - (Between, _) => match angle(&i, self.between())? { + (Between, _) => match angle(&i, self.between(), &s.angle_settings())? { Some(b) => { if b.abs() < 1e-9 { return Err(other("Requires a nonzero angle.")); @@ -743,7 +744,7 @@ impl Interactive for PolarM { } None => stage, }, - (Fill, _) => match angle(&i, self.fill)? { + (Fill, _) => match angle(&i, self.fill, &s.angle_settings())? { Some(f) => { if f.abs() < 1e-9 || f.abs() > 360.0 + 1e-9 { return Err(other("Requires a fill angle between -360 and 360 degrees (not 0).")); diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index ece76abe..9a184183 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -631,7 +631,7 @@ impl Interactive for InsertM { } InsStage::PresetRotation => { self.rotation = Some(match i { - Input::Text(t) => crate::units::parse_angle(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?, + Input::Text(t) => s.angle_settings().direction(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?, Input::Enter => 0.0, _ => return Ok(Step::Continue), }); @@ -673,7 +673,7 @@ impl Interactive for InsertM { let a = self.at.unwrap_or_default(); self.rotation = Some(match i { Input::Point(p) => a.angle_to(p), - Input::Text(t) => crate::units::parse_angle(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?, + Input::Text(t) => s.angle_settings().direction(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?, _ => 0.0, }); return self.advance(s); diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 09c14718..bdc5e788 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -956,7 +956,7 @@ impl Interactive for EllipseM { Input::Point(p) if self.param => cadcraft_geom::norm_angle((p - e.center).angle() - e.major.angle()), Input::Point(p) => ge.param_of(p), Input::Text(t) => { - let a = crate::units::parse_angle(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?; + let a = s.angle_settings().amount(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?; if self.param { cadcraft_geom::norm_angle(a) } else { @@ -999,7 +999,9 @@ impl Interactive for EllipseM { } } Input::Text(t) if self.pts.len() == 2 && self.rotation => { - let e = crate::units::parse_angle(&t) + let e = s + .angle_settings() + .amount(&t) .and_then(|a| self.rotated(a)) .ok_or_else(|| crate::EngineError::Other("Requires an angle between 0 and 89.4 degrees.".into()))?; return self.place(s, e); @@ -1343,7 +1345,7 @@ impl Interactive for TextM { (Some(_), Some(_), None, Input::Enter) => self.rotation = Some(0.0), (Some(a), Some(_), None, Input::Point(p)) => self.rotation = Some(a.angle_to(p)), (Some(_), Some(_), None, Input::Text(t)) => { - self.rotation = Some(crate::units::parse_angle(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?) + self.rotation = Some(s.angle_settings().direction(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?) } (Some(a), Some(h), Some(r), Input::Text(t)) => { let at = a + Vec2::from_angle(r - std::f64::consts::FRAC_PI_2) * (h * 5.0 / 3.0 * self.line as f64); diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index e7bc4e14..96bec285 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -367,7 +367,10 @@ impl Interactive for SeqM { let val = match (&i, ask) { (Input::Point(q), _) => self.value_from_point(ask, *q), (Input::Text(t), Ask::Angle(_) | Ask::Sweep(..)) => { - Some(Val::N(crate::units::parse_angle(t).ok_or_else(|| other("Requires a valid angle."))?)) + // The tangent direction is a direction; included angles are sizes. + let au = s.angle_settings(); + let a = if self.v == ArcV::Sed { au.direction(t) } else { au.amount(t) }; + Some(Val::N(a.ok_or_else(|| other("Requires a valid angle."))?)) } (Input::Text(t), Ask::Dist(_)) => Some(Val::N(number(t).ok_or_else(|| other("Requires numeric distance or a point."))?)), (Input::Enter, _) => return Ok(Step::Cancel), @@ -1449,7 +1452,7 @@ impl Interactive for XlineM2 { Ok(Step::Continue) } (XMode::AngAsk, Input::Text(t)) => { - let a = crate::units::parse_angle(&t).ok_or_else(|| other("Requires a valid angle."))?; + let a = s.angle_settings().direction(&t).ok_or_else(|| other("Requires a valid angle."))?; self.mode = XMode::Fixed(a); Ok(Step::Continue) } @@ -1460,7 +1463,7 @@ impl Interactive for XlineM2 { Ok(Step::Continue) } (XMode::RefAngle(base), Input::Text(t)) => { - let a = crate::units::parse_angle(&t).ok_or_else(|| other("Requires a valid angle."))?; + let a = s.angle_settings().rotation(&t).ok_or_else(|| other("Requires a valid angle."))?; self.mode = XMode::Fixed(base + a); Ok(Step::Continue) } @@ -1739,7 +1742,7 @@ impl Interactive for RectM2 { RAsk::Rotation => match i { Input::Keyword(_) => self.asking = Some(RAsk::RotPick1), Input::Point(p) => self.rotation = self.first.map(|f| f.angle_to(p)).unwrap_or(0.0), - Input::Text(t) => self.rotation = crate::units::parse_angle(&t).ok_or_else(|| other("Requires a valid angle."))?, + Input::Text(t) => self.rotation = s.angle_settings().direction(&t).ok_or_else(|| other("Requires a valid angle."))?, _ => {} }, RAsk::RotPick1 => { diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index a22eef63..013efd02 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1374,8 +1374,8 @@ impl SelectThen { /// new angle minus the reference angle. fn rotate_reference(&mut self, s: &mut Session, i: Input) -> Result { let Some(base) = self.pts.first().copied() else { return Ok(Step::Done) }; - let angle = - |t: &str| crate::units::parse_angle(t).filter(|a| a.is_finite()).ok_or_else(|| EngineError::Other("Requires an angle or point.".into())); + let au = s.angle_settings(); + let angle = |t: &str| au.direction(t).filter(|a| a.is_finite()).ok_or_else(|| EngineError::Other("Requires an angle or point.".into())); let two_points = |a: Vec2, p: Vec2| { if a.near(p, 1e-12) { Err(EngineError::Other("The two points must differ.".into())) } else { Ok(a.angle_to(p)) } }; diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index 0ca53197..f9e27d83 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -1001,7 +1001,8 @@ impl Interactive for LengthenM { } fn input(&mut self, s: &mut Session, i: Input) -> Result { let num = |i: &Input| -> Option { if let Input::Text(t) = i { number(t) } else { None } }; - let ang = |i: &Input| -> Option { if let Input::Text(t) = i { crate::units::parse_angle(t) } else { None } }; + let au = s.angle_settings(); + let ang = |i: &Input| -> Option { if let Input::Text(t) = i { au.amount(t) } else { None } }; match (self.phase, &i) { (LPhase::Main, Input::Point(p)) => { let h = curves::pick_at(s, *p).ok_or_else(|| other("*Invalid selection*"))?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3ba5e370..42e7515a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -806,7 +806,7 @@ impl Session { return self.input(Input::Keyword(k)); } if prompt.accept.point { - if let Some(p) = prompt::parse_point(tt, self.last_point) { + if let Some(p) = prompt::parse_point_with(tt, self.last_point, &self.angle_settings()) { return self.input(Input::Point(p)); } // Direct distance entry along the rubber band. diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs index d516e99b..374c1e45 100644 --- a/crates/engine/src/prompt.rs +++ b/crates/engine/src/prompt.rs @@ -143,8 +143,15 @@ pub trait Interactive: Send { } /// Parse a typed point: `x,y[,z]`, `@dx,dy`, `@dist Option { + parse_point_with(text, last, &crate::units::AngleSettings::default()) +} + +/// `parse_point` with polar angles read per `angles` (AUNITS, ANGBASE, ANGDIR); `d< Option { let t = text.trim(); if t.is_empty() { return None; @@ -161,7 +168,8 @@ pub fn parse_point(text: &str, last: Vec2) -> Option { } let p = if let Some((d, a)) = body.split_once('<') { let dist = crate::units::parse_distance(d)?; - let ang = crate::units::parse_angle(a)?; + // `d< = body.split(',').collect(); diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index 8e1048a3..17cdc97d 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -57,6 +57,8 @@ pub fn get(s: &Session, name: &str) -> Option { "CURSORSIZE" => json!(st.cursorsize), "MAXARRAY" => json!(st.maxarray), "LASTPOINT" => json!([s.last_point.x, s.last_point.y, 0.0]), + // Held in radians (as `$ANGBASE` in DXF); reported in degrees, like POLARANG. + "ANGBASE" => json!(s.doc().ok()?.header.f64("ANGBASE", 0.0).to_degrees()), // Process-wide font substitution (profile settings, not saved in the drawing). "FONTALT" => json!(cadcraft_fonts::ttf::font_alt()), "FONTFALLBACK" => json!(cadcraft_fonts::ttf::fallback_fonts()), @@ -150,6 +152,15 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { "MAXARRAY" => st.maxarray = as_f64(v).ok_or_else(bad)?.clamp(100.0, 10_000_000.0) as u64, "FONTALT" => cadcraft_fonts::ttf::set_font_alt(v.as_str().ok_or_else(bad)?), "FONTFALLBACK" => cadcraft_fonts::ttf::set_fallback_fonts(v.as_str().ok_or_else(bad)?), + // A number is in degrees; text is an angle typed in AUNITS (SETVAR at the command line). + "ANGBASE" => { + let a = match v { + Value::String(t) => s.angle_settings().amount(t), + _ => as_f64(v).map(f64::to_radians), + }; + let a = a.filter(|a| a.is_finite()).ok_or_else(bad)?; + s.doc_mut()?.header.set_f64("ANGBASE", cadcraft_geom::norm_angle(a)); + } _ => { let d = s.doc_mut()?; let val = match v { @@ -198,7 +209,9 @@ pub fn list(s: &Session) -> Value { } if let Ok(d) = s.doc() { for (k, v) in &d.header.vars { - if !k.starts_with("CADCRAFT_") { + if k == "ANGBASE" { + m.insert(k.clone(), get(s, k).unwrap_or(Value::Null)); + } else if !k.starts_with("CADCRAFT_") { m.insert(k.clone(), serde_json::to_value(v).unwrap_or(Value::Null)); } } diff --git a/crates/engine/src/units.rs b/crates/engine/src/units.rs index ceb8b163..0cf06f3f 100644 --- a/crates/engine/src/units.rs +++ b/crates/engine/src/units.rs @@ -137,42 +137,111 @@ fn parse_mixed(s: &str) -> Option { } } -/// Parse an angle typed by the user (degrees by default): `45`, `45d30'`, `0.5r`, `100g`, `N45dE`. +/// How typed angles are read: the drawing's angle settings (UNITS). Bare numbers are in AUNITS, +/// and directions are measured from ANGBASE, turning as ANGDIR says. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct AngleSettings { + /// AUNITS: 0 decimal degrees, 1 degrees/minutes/seconds, 2 grads, 3 radians, 4 surveyor's. + pub aunits: i64, + /// ANGBASE: the direction of angle 0, in radians counterclockwise from +X (as `$ANGBASE` in DXF). + pub angbase: f64, + /// ANGDIR 1: positive angles turn clockwise. + pub clockwise: bool, +} + +impl AngleSettings { + pub fn from_header(h: &cadcraft_doc::Header) -> Self { + AngleSettings { aunits: h.i64("AUNITS", 0), angbase: h.f64("ANGBASE", 0.0), clockwise: h.i64("ANGDIR", 0) != 0 } + } + + /// A typed angle size in radians (included angles, sweeps, angles from an axis): a bare + /// number in AUNITS, or with an explicit unit: `30d`, `45d30'15"`, `0.5r`, `100g`. + pub fn amount(&self, s: &str) -> Option { + parse_units(s, self.aunits).map(|(a, _)| a) + } + + /// A typed rotation in radians counterclockwise: the amount, turned clockwise when ANGDIR is 1. + pub fn rotation(&self, s: &str) -> Option { + self.amount(s).map(|a| if self.clockwise { -a } else { a }) + } + + /// A typed direction in radians counterclockwise from +X: the amount measured from ANGBASE in + /// the ANGDIR sense. Surveyor's bearings (`N45dE`) are absolute. The overrides measure from + /// +X counterclockwise whatever ANGBASE and ANGDIR say: `< Option { + let t = s.trim(); + if let Some(r) = t.strip_prefix("<<<") { + return parse_units(r, self.aunits).map(|(a, _)| a); + } + if let Some(r) = t.strip_prefix("<<") { + return parse_units(r, 0).map(|(a, _)| a); + } + let (a, absolute) = parse_units(t, self.aunits)?; + if absolute { + return Some(a); + } + let v = self.angbase + if self.clockwise { -a } else { a }; + v.is_finite().then_some(v) + } +} + +/// Parse a direction typed with the default angle settings (degrees, 0 = +X, counterclockwise): +/// `45`, `45d30'`, `0.5r`, `100g`, `N45dE`. Typed input reads the drawing's settings instead +/// (`Session::angle_settings`). pub fn parse_angle(s: &str) -> Option { + AngleSettings::default().direction(s) +} + +/// A typed angle in radians and whether it is absolute (a surveyor's bearing). +fn parse_units(s: &str, aunits: i64) -> Option<(f64, bool)> { let t = s.trim().to_ascii_lowercase(); if t.is_empty() { return None; } - if let Some(r) = t.strip_suffix('r') { - return r.trim().parse::().ok().filter(|v| v.is_finite()); - } - if let Some(g) = t.strip_suffix('g') { - return g.trim().parse::().ok().filter(|v| v.is_finite()).map(|v| (v * 0.9).to_radians()); - } - if let Some(rest) = t.strip_prefix('n').or_else(|| t.strip_prefix('s')) { - let north = t.starts_with('n'); - let (ang_s, east) = if let Some(a) = rest.strip_suffix('e') { - (a, true) - } else { - let a = rest.strip_suffix('w')?; - (a, false) + let num = |v: &str| v.trim().parse::().ok(); + let v = if let Some(r) = t.strip_suffix('r') { + (num(r)?, false) + } else if let Some(g) = t.strip_suffix('g') { + ((num(g)? * 0.9).to_radians(), false) + } else if let Some(b) = bearing(&t) { + (b, true) + } else if let Some(v) = num(&t) { + let a = match aunits { + 2 => (v * 0.9).to_radians(), + 3 => v, + _ => v.to_radians(), }; - let a = parse_dms(ang_s.trim())?; - let deg = match (north, east) { - (true, true) => 90.0 - a, - (true, false) => 90.0 + a, - (false, false) => 270.0 - a, - (false, true) => 270.0 + a, - }; - return Some(deg.to_radians()); - } - parse_dms(&t).map(f64::to_radians) + (a, false) + } else { + (parse_dms(&t)?.to_radians(), false) + }; + v.0.is_finite().then_some(v) } +/// A surveyor's bearing (`n45d30'e`, lower case) as radians counterclockwise from +X. +fn bearing(t: &str) -> Option { + let north = t.starts_with('n'); + let rest = t.strip_prefix('n').or_else(|| t.strip_prefix('s'))?; + let (ang_s, east) = match rest.strip_suffix('e') { + Some(a) => (a, true), + None => (rest.strip_suffix('w')?, false), + }; + let a = parse_dms(ang_s.trim())?; + let deg = match (north, east) { + (true, true) => 90.0 - a, + (true, false) => 90.0 + a, + (false, false) => 270.0 - a, + (false, true) => 270.0 + a, + }; + Some(deg.to_radians()) +} + +/// Degrees, `45d`, `45d30'` or `45d30'15"` (a leading `-` negates the whole angle). fn parse_dms(t: &str) -> Option { - if let Ok(v) = t.parse::() { - return v.is_finite().then_some(v); - } + let (neg, t) = match t.trim().strip_prefix('-') { + Some(r) => (true, r.trim()), + None => (false, t.trim()), + }; let (d, rest) = t.split_once('d').unwrap_or((t, "")); let mut deg: f64 = d.trim().parse().ok()?; let rest = rest.trim(); @@ -186,9 +255,17 @@ fn parse_dms(t: &str) -> Option { deg += s.parse::().ok()? / 3600.0; } } + let deg = if neg { -deg } else { deg }; deg.is_finite().then_some(deg) } +impl crate::Session { + /// How typed angles are read in the current drawing (the defaults without one). + pub fn angle_settings(&self) -> AngleSettings { + self.doc().map(|d| AngleSettings::from_header(&d.header)).unwrap_or_default() + } +} + pub const DEG: f64 = PI / 180.0; #[cfg(test)] diff --git a/crates/engine/tests/angle_settings.rs b/crates/engine/tests/angle_settings.rs new file mode 100644 index 00000000..59591df8 --- /dev/null +++ b/crates/engine/tests/angle_settings.rs @@ -0,0 +1,98 @@ +//! Typed angles follow the drawing's angle settings: AUNITS for bare numbers, directions +//! measured from ANGBASE in the ANGDIR sense, `<<` / `<<<` overrides. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use cadcraft_engine::geom::Vec2; +use cadcraft_engine::units::AngleSettings; +use serde_json::json; + +fn setvar(s: &mut Session, name: &str, value: serde_json::Value) { + s.execute("setvar", &json!({ "name": name, "value": value })).unwrap(); +} + +/// Draws LINE 0,0 `to` at the command line; returns the end point. +fn line_end(s: &mut Session, to: &str) -> Vec2 { + for t in ["line", "0,0", to, ""] { + s.cmdline(t).unwrap(); + } + match &s.doc().unwrap().model.last().unwrap().kind { + EntityKind::Line(l) => l.b.xy(), + k => panic!("expected a line, got {k:?}"), + } +} + +fn assert_near(p: Vec2, x: f64, y: f64, what: &str) { + assert!(p.near(Vec2::new(x, y), 1e-9), "{what}: got {p:?}, expected ({x}, {y})"); +} + +#[test] +fn polar_angles_measure_from_angbase_in_angdir() { + let mut s = Session::new(); + // 0 = north, clockwise (as in surveying). + setvar(&mut s, "ANGBASE", json!(90)); + setvar(&mut s, "ANGDIR", json!(1)); + assert!((s.doc().unwrap().header.f64("ANGBASE", 0.0) - std::f64::consts::FRAC_PI_2).abs() < 1e-12, "ANGBASE is held in radians"); + assert_near(line_end(&mut s, "@10<90"), 10.0, 0.0, "@10<90"); + assert_near(line_end(&mut s, "@10<0"), 0.0, 10.0, "@10<0"); + assert_near(line_end(&mut s, "10<180"), 0.0, -10.0, "10<180"); + // The overrides measure from +X counterclockwise: `<<` in degrees, `<<<` in AUNITS. + assert_near(line_end(&mut s, "@10<<90"), 0.0, 10.0, "@10<<90"); + setvar(&mut s, "AUNITS", json!(3)); + assert_near(line_end(&mut s, "@10<<<3.141592653589793"), -10.0, 0.0, "@10<< assert_near(l.b.xy(), 0.0, -10.0, "ROTATE Reference 0 to 90 with ANGDIR 1"), + k => panic!("expected a line, got {k:?}"), + } +} + +#[test] +fn json_and_default_parsing_keep_degrees_from_east() { + let mut s = Session::new(); + setvar(&mut s, "ANGBASE", json!(90)); + setvar(&mut s, "ANGDIR", json!(1)); + setvar(&mut s, "AUNITS", json!(3)); + // JSON params are unaffected by the drawing's angle settings. + let r = s.execute("line", &json!({ "points": ["0,0", "@10<90"] })).unwrap(); + assert!(r.is_object()); + match &s.doc().unwrap().model.last().unwrap().kind { + EntityKind::Line(l) => assert_near(l.b.xy(), 0.0, 10.0, "JSON @10<90"), + k => panic!("expected a line, got {k:?}"), + } + assert!((cadcraft_engine::units::parse_angle("90").unwrap() - std::f64::consts::FRAC_PI_2).abs() < 1e-12); + // SETVAR reports ANGBASE in degrees. + let v = cadcraft_engine::sysvars::get(&s, "ANGBASE").unwrap(); + assert!((v.as_f64().unwrap() - 90.0).abs() < 1e-9, "{v}"); + let a = AngleSettings { aunits: 0, angbase: 0.0, clockwise: false }; + assert_eq!(a.direction(""), None); + assert_eq!(a.direction("nan"), None); + assert_eq!(a.direction("1e400"), None); +} diff --git a/crates/io/tests/angle_header.rs b/crates/io/tests/angle_header.rs new file mode 100644 index 00000000..1b39f820 --- /dev/null +++ b/crates/io/tests/angle_header.rs @@ -0,0 +1,18 @@ +//! The angle settings round-trip through DXF: `$ANGBASE` (group 50) in radians, `$ANGDIR` and +//! `$AUNITS` (group 70) as integers. + +use cadcraft_doc::{Drawing, HVal}; + +#[test] +fn angbase_angdir_aunits_round_trip_through_dxf() { + let mut d = Drawing::new_metric(); + d.header.set_f64("ANGBASE", std::f64::consts::FRAC_PI_2); + d.header.set_i64("ANGDIR", 1); + d.header.set_i64("AUNITS", 3); + let text = cadcraft_io::write_dxf(&d); + let r = cadcraft_io::read_dxf(text.as_bytes()).unwrap(); + let base = r.header.f64("ANGBASE", 0.0); + assert!((base - std::f64::consts::FRAC_PI_2).abs() < 1e-12, "$ANGBASE read back as {base}"); + assert_eq!(r.header.get("ANGDIR"), Some(&HVal::Int(1))); + assert_eq!(r.header.get("AUNITS"), Some(&HVal::Int(3))); +} diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs index 51681180..75cddf1d 100644 --- a/crates/ui-egui/src/cmdline.rs +++ b/crates/ui-egui/src/cmdline.rs @@ -156,7 +156,7 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) { } } else { let base = app.canvas.hot_grip.map(|g| g.base).unwrap_or_default(); - match cadcraft_engine::prompt::parse_point(&typed, base) { + match cadcraft_engine::prompt::parse_point_with(&typed, base, &app.session.angle_settings()) { Some(p) => crate::canvas::apply_hot_grip(app, p), None => app.session.echo("Requires a point (x,y, @dx,dy or @d, frame: &Frame, cursor: Vec2, last: Vec2) -> O if frame.relative() && frame.base == Some(last) { return Some(match entry.format { Format::Cartesian => format!("@{first},{second}"), - Format::Polar => format!("@{first}<{}", second.to_degrees()), + // `<<`: degrees from +X counterclockwise, as previewed, whatever ANGBASE/ANGDIR say. + Format::Polar => format!("@{first}<<{}", second.to_degrees()), }); } let p = match entry.format { From 6a15ba2a7d2e4a5b116d7b678808c60fdcc17417 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:55:12 +0200 Subject: [PATCH 094/150] Dimensions snap; justified text snaps and grips at its alignment point; grid snap follows SNAPBASE/SNAPANG; OSNAPHATCH (fixes #334) (#337) - A dimension snaps to what it draws: the objects of its block (read from a file), expanded like block references, or else the lines generated from it (Endpoint on their ends, Midpoint, Intersection, Nearest); Node on its definition points. - TEXT justified other than left-baseline (as read from DXF, group 11) snaps (Insertion) and grips at its alignment point; attributes too. - The snap grid starts at SNAPBASE and turns by SNAPANG (drawing variables, defaults 0,0 and 0 degrees). SETVAR takes `x,y` for point variables. - Hatches are left out of object snaps unless OSNAPHATCH is 1 (new session variable, default 0 as in AutoCAD). Co-authored-by: Claude Opus 5.5 --- crates/doc/src/entity.rs | 17 +++- crates/doc/src/header.rs | 3 + crates/engine/src/lib.rs | 3 + crates/engine/src/snap.rs | 96 +++++++++++++++++++++-- crates/engine/src/sysvars.rs | 8 ++ crates/engine/tests/snap_annotations.rs | 100 ++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 6 +- 7 files changed, 220 insertions(+), 13 deletions(-) create mode 100644 crates/engine/tests/snap_annotations.rs diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index 6a791116..cb6733d5 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -216,6 +216,17 @@ pub struct Text { #[serde(default)] pub valign: VAlign, } +impl Text { + /// The point the text is placed by, as the Insertion snap and the grip show it: the alignment + /// point (DXF group 11) of justified text, the start point (group 10) of left-baseline, + /// aligned and fit text. + pub fn justify_point(&self) -> Vec2 { + match (self.halign, self.valign) { + (HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _) => self.insert.xy(), + _ => self.align_pt.unwrap_or(self.insert).xy(), + } + } +} fn one() -> f64 { 1.0 } @@ -977,10 +988,10 @@ impl EntityKind { } } EntityKind::Ray(r) | EntityKind::XLine(r) => vec![r.base.xy(), r.base.xy() + r.dir.xy()], - EntityKind::Text(t) => vec![t.insert.xy()], + EntityKind::Text(t) => vec![t.justify_point()], EntityKind::MText(t) => vec![t.insert.xy()], - EntityKind::AttDef(a) => vec![a.text.insert.xy()], - EntityKind::Insert(i) => std::iter::once(i.insert.xy()).chain(i.attribs.iter().map(|a| a.text.insert.xy())).collect(), + EntityKind::AttDef(a) => vec![a.text.justify_point()], + EntityKind::Insert(i) => std::iter::once(i.insert.xy()).chain(i.attribs.iter().map(|a| a.text.justify_point())).collect(), EntityKind::Dimension(d) => vec![d.p13.xy(), d.p14.xy(), d.defpt.xy(), d.text_mid.xy()], EntityKind::Leader(l) => l.vertices.iter().map(|v| v.xy()).collect(), EntityKind::MLeader(m) => m.leaders.iter().flat_map(|l| l.iter().map(|v| v.xy())).chain(std::iter::once(m.landing.xy())).collect(), diff --git a/crates/doc/src/header.rs b/crates/doc/src/header.rs index fb4702f7..fe3352fb 100644 --- a/crates/doc/src/header.rs +++ b/crates/doc/src/header.rs @@ -115,6 +115,9 @@ impl Header { h.set_f64("ELEVATION", 0.0); h.set_i64("LWDISPLAY", 0); h.set("INSBASE", HVal::Point(Vec3::ZERO)); + // Snap grid origin and rotation (SNAPANG in degrees). + h.set("SNAPBASE", HVal::Point(Vec3::ZERO)); + h.set_f64("SNAPANG", 0.0); h } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 42e7515a..4a325ee2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -219,6 +219,8 @@ pub struct Settings { pub gridmode: bool, pub snapmode: bool, pub snapunit: Vec2, + /// OSNAPHATCH: object snaps find hatch objects (off by default, as in AutoCAD). + pub osnaphatch: bool, pub gridunit: Vec2, /// Major grid line every N minor lines (GRIDMAJOR). pub gridmajor: u32, @@ -260,6 +262,7 @@ impl Default for Settings { gridmode: true, snapmode: false, snapunit: Vec2::new(0.5, 0.5), + osnaphatch: false, gridunit: Vec2::new(0.5, 0.5), gridmajor: 5, dynmode: true, diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index ccfbaf17..c1d917db 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -22,6 +22,8 @@ pub mod mode { pub const PAR: u32 = 8192; /// Running snaps temporarily off (F3). pub const OFF: u32 = 16384; + /// Not an OSMODE bit: snaps also find hatch objects (OSNAPHATCH = 1). + pub const HATCH: u32 = 1 << 16; pub const ALL: [(u32, &str); 14] = [ (END, "Endpoint"), (MID, "Midpoint"), @@ -164,11 +166,11 @@ pub(crate) fn osnap_with( } if osmode & mode::INS != 0 { match &e.kind { - EntityKind::Text(t) => cands.push((mode::INS, t.insert.xy())), + EntityKind::Text(t) => cands.push((mode::INS, t.justify_point())), EntityKind::MText(t) => cands.push((mode::INS, t.insert.xy())), EntityKind::Insert(i) => { cands.push((mode::INS, i.insert.xy())); - cands.extend(i.attribs.iter().filter(|a| !a.invisible).map(|a| (mode::INS, a.text.insert.xy()))); + cands.extend(i.attribs.iter().filter(|a| !a.invisible).map(|a| (mode::INS, a.text.justify_point()))); } _ => {} } @@ -179,6 +181,9 @@ pub(crate) fn osnap_with( if let EntityKind::Insert(ins) = &e.kind { block_leaves(d, ins, &Mat3::IDENTITY, 0, &mut budget, &mut |k, m| sink.object(k, Some(m))); } + if let EntityKind::Dimension(dm) = &e.kind { + sink.dimension(d, dm, &mut budget); + } } // Intersections between nearby primitives. if osmode & (mode::INT | mode::APP) != 0 { @@ -277,6 +282,10 @@ impl Sink<'_> { /// (whose primitives far from the cursor are skipped). fn object(&mut self, kind: &EntityKind, xf: Option<&Mat3>) { let osmode = self.osmode; + // OSNAPHATCH = 0: hatches are left out. + if osmode & mode::HATCH == 0 && matches!(kind, EntityKind::Hatch(_)) { + return; + } let at = |p: Vec2| xf.map_or(p, |m| m.apply(p)); if osmode & mode::END != 0 && let EntityKind::Solid(s) | EntityKind::Trace(s) = kind @@ -375,6 +384,67 @@ impl Sink<'_> { } } +impl Sink<'_> { + /// A dimension snaps to what it draws, as in AutoCAD (into its block): the objects of its + /// block when the drawing has one (read from a file), else the lines generated from it + /// (Endpoint on their ends); Node on its definition points. + fn dimension(&mut self, d: &Drawing, dm: &cadcraft_doc::Dimension, budget: &mut usize) { + match dm.block.as_ref().and_then(|n| d.block(n)) { + Some(blk) => { + // The block holds world geometry: a reference at its own base point places it as is. + let ins = Insert { + block: blk.name.clone(), + insert: blk.base, + scale: cadcraft_geom::Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + block_leaves(d, &ins, &Mat3::IDENTITY, 0, budget, &mut |k, m| self.object(k, Some(m))); + } + None => { + let g = cadcraft_render::dimension_in(d, dm); + for line in &g.lines { + let (Some(a), Some(b)) = (line.first(), line.last()) else { continue }; + if self.osmode & mode::END != 0 { + self.cands.extend([(mode::END, *a), (mode::END, *b)]); + } + if self.osmode & mode::MID != 0 && line.len() == 2 { + self.cands.push((mode::MID, (*a + *b) * 0.5)); + } + for w in line.windows(2) { + if let [p, q] = w { + let s = Prim::Seg(Segment::Line(cadcraft_geom::Line::new(*p, *q))); + if prim_near(&s, &self.probe, self.aperture) { + self.near.push(s); + } + } + } + } + } + } + if self.osmode & mode::NOD != 0 { + self.cands.extend(dim_def_points(dm).into_iter().map(|p| (mode::NOD, p))); + } + } +} + +/// The definition points of a dimension that its kind uses. +fn dim_def_points(dm: &cadcraft_doc::Dimension) -> Vec { + use cadcraft_doc::DimKind; + let pts = match dm.kind { + DimKind::Linear { .. } | DimKind::Aligned | DimKind::ArcLength => vec![dm.defpt, dm.p13, dm.p14], + DimKind::Radius | DimKind::Diameter => vec![dm.defpt, dm.p15], + DimKind::Angular3P => vec![dm.defpt, dm.p13, dm.p14, dm.p15], + DimKind::Angular => vec![dm.defpt, dm.p13, dm.p14, dm.p15, dm.p16], + DimKind::Ordinate { .. } => vec![dm.p13, dm.p14], + }; + pts.into_iter().map(|p| p.xy()).filter(|p| p.is_finite()).collect() +} + /// Calls `f` with each object drawn by a block reference and the transform placing it, nested /// references expanded (within [`MAX_BLOCK_DEPTH`] levels and `budget` objects); objects that /// aren't drawn (hidden, on a layer that is off or frozen, attribute definitions) are left out. @@ -684,11 +754,21 @@ pub fn polar(base: Vec2, p: Vec2, inc: f64, tol: f64) -> Option<(Vec2, f64)> { } } -/// Grid snap. -pub fn grid_snap(p: Vec2, unit: Vec2, origin: Vec2) -> Vec2 { - let sx = if unit.x > 1e-12 { ((p.x - origin.x) / unit.x).round() * unit.x + origin.x } else { p.x }; - let sy = if unit.y > 1e-12 { ((p.y - origin.y) / unit.y).round() * unit.y + origin.y } else { p.y }; - Vec2::new(sx, sy) +/// Grid snap: the nearest point of the grid of `unit` spacing through `origin`, turned by +/// `angle` (radians) about it. +pub fn grid_snap(p: Vec2, unit: Vec2, origin: Vec2, angle: f64) -> Vec2 { + let angle = if angle.is_finite() { angle } else { 0.0 }; + let q = (p - origin).rotate(-angle); + let sx = if unit.x > 1e-12 { (q.x / unit.x).round() * unit.x } else { q.x }; + let sy = if unit.y > 1e-12 { (q.y / unit.y).round() * unit.y } else { q.y }; + let s = origin + Vec2::new(sx, sy).rotate(angle); + if s.is_finite() { s } else { p } +} + +/// The snap grid's origin (SNAPBASE) and rotation in radians (SNAPANG, in degrees) of a drawing. +pub fn grid_frame(d: &Drawing) -> (Vec2, f64) { + let base = d.header.point("SNAPBASE").map(|p| p.xy()).filter(|p| p.is_finite()).unwrap_or(Vec2::ZERO); + (base, d.header.f64("SNAPANG", 0.0).to_radians()) } #[cfg(test)] @@ -746,7 +826,7 @@ mod tests { let (p, a) = polar(Vec2::ZERO, Vec2::new(5.0, 5.1), 45f64.to_radians(), 3f64.to_radians()).unwrap(); assert!((a - PI / 4.0).abs() < 1e-12); assert!((p.x - p.y).abs() < 1e-9); - assert_eq!(grid_snap(Vec2::new(0.74, 1.26), Vec2::new(0.5, 0.5), Vec2::ZERO), Vec2::new(0.5, 1.5)); + assert_eq!(grid_snap(Vec2::new(0.74, 1.26), Vec2::new(0.5, 0.5), Vec2::ZERO, 0.0), Vec2::new(0.5, 1.5)); } #[test] diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index 17cdc97d..e899d534 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -6,6 +6,7 @@ use crate::{EngineError, Result, Session}; const SESSION_VARS: &[&str] = &[ "OSMODE", + "OSNAPHATCH", "ORTHOMODE", "POLARMODE", "POLARANG", @@ -36,6 +37,7 @@ pub fn get(s: &Session, name: &str) -> Option { let st = &s.settings; let v = match n.as_str() { "OSMODE" => json!(st.osmode), + "OSNAPHATCH" => json!(i32::from(st.osnaphatch)), "ORTHOMODE" => json!(i32::from(st.orthomode)), "POLARMODE" => json!(i32::from(st.polarmode)), "POLARANG" => json!(st.polarang.to_degrees()), @@ -130,6 +132,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { let st = &mut s.settings; match n.as_str() { "OSMODE" => st.osmode = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).ok_or_else(bad)? as u32 & 0x7fff, + "OSNAPHATCH" => st.osnaphatch = as_bool(v).ok_or_else(bad)?, "ORTHOMODE" => st.orthomode = as_bool(v).ok_or_else(bad)?, "POLARMODE" => st.polarmode = as_bool(v).ok_or_else(bad)?, "POLARANG" => st.polarang = as_f64(v).filter(|a| *a > 0.0).ok_or_else(bad)?.to_radians(), @@ -192,6 +195,11 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { d.header.set_i64(&n, i); return Ok(()); } + // A point variable takes `x,y` typed at the command line too. + Some(cadcraft_doc::HVal::Point(_)) => { + d.header.set(&n, cadcraft_doc::HVal::Point(as_pt(v).ok_or_else(bad)?.to3(0.0))); + return Ok(()); + } _ => {} } d.header.set(&n, val); diff --git a/crates/engine/tests/snap_annotations.rs b/crates/engine/tests/snap_annotations.rs new file mode 100644 index 00000000..97bf0ac2 --- /dev/null +++ b/crates/engine/tests/snap_annotations.rs @@ -0,0 +1,100 @@ +//! Snaps on dimensions, justified text and hatches; the snap grid's base and angle (#334). + +use cadcraft_doc::{Common, EntityKind, HAlign, Space, Text, VAlign}; +use cadcraft_engine::Session; +use cadcraft_engine::snap::{self, mode}; +use cadcraft_geom::{Vec2, Vec3}; +use serde_json::json; + +/// The snap of `modes` near `p` (a hair off it, inside a 0.1 aperture). +fn snap_at(s: &Session, p: Vec2, modes: u32) -> Option<(Vec2, u32)> { + snap::osnap(s.doc().unwrap(), &Space::Model, p + Vec2::new(0.02, -0.01), 0.1, modes, None, false).map(|h| (h.point, h.mode)) +} + +fn handle(s: &mut Session, cmd: &str, p: serde_json::Value) -> String { + let r = s.execute(cmd, &p).unwrap(); + r.get("handle").unwrap_or(&r["handles"][0]).as_str().unwrap().to_string() +} + +#[test] +fn dimensions_snap_to_their_lines_and_definition_points() { + let mut s = Session::new(); + s.cmdline("dimlinear 0,0 10,0 5,3").unwrap(); + assert!(s.running.is_none()); + // Endpoint: the dimension line's end; Node: the extension line origins. + assert_eq!(snap_at(&s, Vec2::new(10.0, 3.0), mode::END), Some((Vec2::new(10.0, 3.0), mode::END))); + assert_eq!(snap_at(&s, Vec2::new(0.0, 0.0), mode::NOD), Some((Vec2::new(0.0, 0.0), mode::NOD))); + assert_eq!(snap_at(&s, Vec2::new(10.0, 0.0), mode::NOD), Some((Vec2::new(10.0, 0.0), mode::NOD))); + + // A dimension with a block (as read from a file) snaps to the block's objects. + let l = handle(&mut s, "line", json!({ "points": [[0, 7], [10, 7]] })); + s.execute("block", &json!({ "name": "DIMGEOM", "base": [0, 0], "handles": [l], "keep": "delete" })).unwrap(); + let d = s.doc_mut().unwrap(); + let dim = d.model.iter().find(|e| matches!(e.kind, EntityKind::Dimension(_))).unwrap().handle; + d.model.modify(dim, |e| { + if let EntityKind::Dimension(dm) = &mut e.kind { + dm.block = Some("DIMGEOM".into()); + } + }); + assert_eq!(snap_at(&s, Vec2::new(10.0, 7.0), mode::END), Some((Vec2::new(10.0, 7.0), mode::END))); +} + +#[test] +fn justified_text_snaps_and_grips_at_its_alignment_point() { + let mut s = Session::new(); + // As read from DXF: a right-justified text keeps group 10 (start) and group 11 (alignment). + let t = Text { + insert: Vec3::new(0.0, 0.0, 0.0), + align_pt: Some(Vec3::new(10.0, 0.0, 0.0)), + height: 1.0, + value: "ABCDEFGHIJ".into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: HAlign::Right, + valign: VAlign::Baseline, + }; + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Text(t.clone())).unwrap(); + assert_eq!(snap_at(&s, Vec2::new(10.0, 0.0), mode::INS), Some((Vec2::new(10.0, 0.0), mode::INS))); + assert_eq!(snap_at(&s, Vec2::new(0.0, 0.0), mode::INS), None); + assert_eq!(EntityKind::Text(t.clone()).grips(), vec![Vec2::new(10.0, 0.0)]); + // Left-baseline and fit text: the start point. + for (h, v) in [(HAlign::Left, VAlign::Baseline), (HAlign::Fit, VAlign::Baseline)] { + assert_eq!(Text { halign: h, valign: v, ..t.clone() }.justify_point(), Vec2::new(0.0, 0.0)); + } + assert_eq!(Text { halign: HAlign::Left, valign: VAlign::Top, ..t.clone() }.justify_point(), Vec2::new(10.0, 0.0)); +} + +#[test] +fn hatches_snap_only_with_osnaphatch() { + let mut s = Session::new(); + let r = handle(&mut s, "rectang", json!({ "p1": [0, 0], "p2": [10, 5] })); + s.execute("hatch", &json!({ "handles": [r] })).unwrap(); + s.execute("erase", &json!({ "handles": [r] })).unwrap(); + assert!(matches!(s.doc().unwrap().model.iter().last().map(|e| &e.kind), Some(EntityKind::Hatch(_)))); + assert_eq!(snap_at(&s, Vec2::new(10.0, 5.0), mode::END), None); + assert_eq!(s.execute("getvar", &json!({ "name": "OSNAPHATCH" })).unwrap()["value"], json!(0)); + s.cmdline("setvar osnaphatch 1").unwrap(); + assert!(s.settings.osnaphatch); + let modes = mode::END | mode::HATCH; + assert_eq!(snap_at(&s, Vec2::new(10.0, 5.0), modes).map(|h| h.0), Some(Vec2::new(10.0, 5.0))); +} + +#[test] +fn grid_snap_follows_snapbase_and_snapang() { + let mut s = Session::new(); + assert_eq!(snap::grid_frame(s.doc().unwrap()), (Vec2::ZERO, 0.0)); + s.cmdline("setvar snapbase 0.5,0.25").unwrap(); + s.cmdline("setvar snapang 30").unwrap(); + let (origin, angle) = snap::grid_frame(s.doc().unwrap()); + assert_eq!(origin, Vec2::new(0.5, 0.25)); + assert!((angle - 30f64.to_radians()).abs() < 1e-12); + // Two units along the turned x axis and one along its y axis, nudged off the grid. + let (u, v) = (Vec2::from_angle(angle), Vec2::from_angle(angle).perp()); + let on = origin + u * 2.0 + v * 1.0; + let p = snap::grid_snap(on + Vec2::new(0.1, -0.15), Vec2::new(1.0, 1.0), origin, angle); + assert!(p.near(on, 1e-12), "{p:?} vs {on:?}"); + // Unturned, from SNAPBASE. + assert_eq!(snap::grid_snap(Vec2::new(1.4, 1.1), Vec2::new(1.0, 1.0), origin, 0.0), Vec2::new(1.5, 1.25)); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4358e9a3..1ee8436a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -476,14 +476,16 @@ fn effective_point(app: &mut CadApp, raw: Vec2, xf: &Xf) -> Vec2 { } let ap = s.aperture / xf.scale; if let Ok(st) = app.session.state() - && let Some(hit) = snap::osnap(&st.doc, &st.edit_space(), raw, ap, s.osmode, base, deferred) + && let Some(hit) = + snap::osnap(&st.doc, &st.edit_space(), raw, ap, s.osmode | if s.osnaphatch { snap::mode::HATCH } else { 0 }, base, deferred) { app.canvas.snap = Some(hit); return hit.point; } let mut p = raw; if s.snapmode { - p = snap::grid_snap(p, s.snapunit, Vec2::ZERO); + let (origin, angle) = app.session.doc().map(snap::grid_frame).unwrap_or((Vec2::ZERO, 0.0)); + p = snap::grid_snap(p, s.snapunit, origin, angle); } if let Some(b) = base { if s.orthomode { From e9ec19b1388d34a596b57d36264989760dee348c Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:55:16 +0200 Subject: [PATCH 095/150] Midpoint, Perpendicular and Tangent snaps on elliptical arcs and splines; Quadrant stays on the arc (fixes #332) (#333) Midpoint snaps halfway along the length of an elliptical arc or open spline. Perpendicular and Tangent from a base point find every foot point and tangent point on ellipses, elliptical arcs and splines (the one nearest the cursor wins, as for circles): tangents to an ellipse exactly through the unit circle, the rest by sampling the parameter range and bisecting each sign change, with fixed iteration bounds. Quadrant on an elliptical arc offers only the quadrant points the arc passes through. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/snap.rs | 34 +++- crates/engine/src/snap/curves.rs | 248 +++++++++++++++++++++++++++++ crates/engine/tests/snap_curves.rs | 121 ++++++++++++++ 3 files changed, 395 insertions(+), 8 deletions(-) create mode 100644 crates/engine/src/snap/curves.rs create mode 100644 crates/engine/tests/snap_curves.rs diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index c1d917db..006bb5ac 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -1,5 +1,7 @@ //! Object snaps (OSNAP), polar tracking and ortho. +mod curves; + use cadcraft_doc::{Drawing, EntityKind, Insert, MAX_BLOCK_DEPTH, Prim, Space}; use cadcraft_geom::{Bounds2, Circle, Ellipse, Mat3, PI, Segment, Vec2, intersect_ext}; use serde::Serialize; @@ -211,18 +213,20 @@ pub(crate) fn osnap_with( Prim::Seg(Segment::Arc { arc, .. }) => cands.push((mode::PER, Circle::new(arc.center, arc.radius).closest(bp))), Prim::Circle(c) => cands.push((mode::PER, c.closest(bp))), Prim::Infinite { base: b0, dir, .. } => cands.push((mode::PER, *b0 + *dir * (bp - *b0).dot(*dir))), + Prim::Ellipse(e) => cands.extend(curves::ellipse_perpendiculars(e, bp).into_iter().map(|q| (mode::PER, q))), + Prim::Spline(s) => cands.extend(curves::spline_perpendiculars(s, bp).into_iter().map(|q| (mode::PER, q))), _ => {} } } if osmode & mode::TAN != 0 { - let circ = match p { - Prim::Seg(Segment::Arc { arc, .. }) => Some(Circle::new(arc.center, arc.radius)), - Prim::Circle(c) => Some(*c), - _ => None, + let pts = match p { + Prim::Seg(Segment::Arc { arc, .. }) => Circle::new(arc.center, arc.radius).tangent_points(bp), + Prim::Circle(c) => c.tangent_points(bp), + Prim::Ellipse(e) => curves::ellipse_tangents(e, bp), + Prim::Spline(s) => curves::spline_tangents(s, bp), + _ => Vec::new(), }; - if let Some(c) = circ { - cands.extend(c.tangent_points(bp).into_iter().map(|t| (mode::TAN, t))); - } + cands.extend(pts.into_iter().map(|t| (mode::TAN, t))); } } } @@ -342,14 +346,23 @@ impl Sink<'_> { cands.push((mode::CEN, el.center)); } if osmode & mode::QUA != 0 { + // Only the quadrant points the arc passes through. for k in 0..4 { - cands.push((mode::QUA, el.at_param(k as f64 * PI / 2.0))); + let t = k as f64 * PI / 2.0; + if cadcraft_geom::angle_in_sweep(t, el.start, el.end) { + cands.push((mode::QUA, el.at_param(t))); + } } } if osmode & mode::END != 0 && !el.is_full() { cands.push((mode::END, el.at_param(el.start))); cands.push((mode::END, el.at_param(el.end))); } + if osmode & mode::MID != 0 + && let Some(m) = curves::ellipse_mid(el) + { + cands.push((mode::MID, m)); + } } Prim::Spline(s) => { if osmode & mode::END != 0 && !s.closed { @@ -357,6 +370,11 @@ impl Sink<'_> { cands.push((mode::END, s.eval(lo))); cands.push((mode::END, s.eval(hi))); } + if osmode & mode::MID != 0 + && let Some(m) = curves::spline_mid(s) + { + cands.push((mode::MID, m)); + } } Prim::Point(pt) => { if osmode & mode::NOD != 0 && matches!(kind, EntityKind::Point(_)) { diff --git a/crates/engine/src/snap/curves.rs b/crates/engine/src/snap/curves.rs new file mode 100644 index 00000000..4e426b4c --- /dev/null +++ b/crates/engine/src/snap/curves.rs @@ -0,0 +1,248 @@ +//! Snap points on ellipses, elliptical arcs and splines: the midpoint by length, and the +//! perpendicular feet and tangent points from a base point. Splines (and perpendiculars on +//! ellipses) are solved numerically: the parameter range is sampled, each sign change of the +//! condition is narrowed by bisection, and every loop has a fixed bound. + +use cadcraft_geom::{Circle, Ellipse, Spline, Vec2, angle_in_sweep}; + +/// Parameter samples along a curve (sign changes and length). +const SAMPLES: usize = 512; +/// Bisection steps per root (the bracket shrinks to 2⁻⁶⁰ of a sample step). +const BISECT: usize = 60; +/// The most solutions one curve offers. +const MAX_ROOTS: usize = 16; + +/// A curve given by a parameter range, its points and its derivative. +struct Param<'a> { + lo: f64, + hi: f64, + at: &'a dyn Fn(f64) -> Vec2, + d: &'a dyn Fn(f64) -> Vec2, +} + +impl Param<'_> { + fn ellipse(e: &Ellipse, f: impl FnOnce(&Param) -> Vec) -> Vec { + let (u, v) = (e.major, e.minor()); + let lo = e.start; + let hi = e.start + e.sweep(); + f(&Param { lo, hi, at: &|t| e.at_param(t), d: &|t| v * t.cos() - u * t.sin() }) + } + + fn spline(s: &Spline, f: impl FnOnce(&Param) -> Vec) -> Vec { + let (lo, hi) = s.domain(); + if !s.is_valid() || !(lo.is_finite() && hi.is_finite() && hi > lo) { + return Vec::new(); + } + let h = (hi - lo) * 1e-7; + let d = |t: f64| { + let (a, b) = ((t - h).max(lo), (t + h).min(hi)); + if b > a { (s.eval(b) - s.eval(a)) / (b - a) } else { Vec2::ZERO } + }; + f(&Param { lo, hi, at: &|t| s.eval(t), d: &d }) + } + + fn param(&self, i: usize) -> f64 { + self.lo + (self.hi - self.lo) * i as f64 / SAMPLES as f64 + } + + /// The point halfway along the curve's length. + fn mid(&self) -> Option { + let pts: Vec = (0..=SAMPLES).map(|i| (self.at)(self.param(i))).collect(); + let lens: Vec = pts.windows(2).map(|w| if let [a, b] = w { a.dist(*b) } else { 0.0 }).collect(); + let total: f64 = lens.iter().sum(); + if !(total.is_finite() && total > 0.0) { + return None; + } + let mut run = 0.0; + for (i, l) in lens.iter().enumerate() { + if run + l >= total / 2.0 && *l > 0.0 { + let k = (total / 2.0 - run) / l; + let t = self.param(i) + (self.param(i + 1) - self.param(i)) * k; + return Some((self.at)(t)).filter(|p| p.is_finite()); + } + run += l; + } + None + } + + /// The points where `cond(point − base, derivative)` is zero: sign changes of the samples, + /// narrowed by bisection; kept when the normalised condition is (nearly) zero there, so a + /// sign flip at a kink or a pole is not taken for a solution. + fn solve(&self, base: Vec2, cond: fn(Vec2, Vec2) -> f64) -> Vec { + let g = |t: f64| cond((self.at)(t) - base, (self.d)(t)); + let samples: Vec<(Vec2, Vec2)> = (0..=SAMPLES).map(|i| ((self.at)(self.param(i)) - base, (self.d)(self.param(i)))).collect(); + let vals: Vec = samples.iter().map(|(r, d)| cond(*r, *d)).collect(); + // A curve on which every point qualifies (a circle seen from its centre) has no + // particular solution. + let scale = samples.iter().map(|(r, d)| r.len() * d.len()).fold(0.0, f64::max); + if !(scale.is_finite() && scale > 0.0) || vals.iter().all(|v| v.abs() <= scale * 1e-12) { + return Vec::new(); + } + let mut out: Vec = Vec::new(); + for i in 0..SAMPLES { + let (Some(&ga), Some(&gb)) = (vals.get(i), vals.get(i + 1)) else { break }; + if !(ga.is_finite() && gb.is_finite()) || (ga > 0.0) == (gb > 0.0) && ga != 0.0 { + continue; + } + let (mut a, mut b, mut fa) = (self.param(i), self.param(i + 1), ga); + for _ in 0..BISECT { + let m = 0.5 * (a + b); + let fm = g(m); + if fm == 0.0 { + (a, b) = (m, m); + break; + } + if (fm > 0.0) == (fa > 0.0) { + (a, fa) = (m, fm); + } else { + b = m; + } + } + let t = 0.5 * (a + b); + let (p, dv) = ((self.at)(t), (self.d)(t)); + let (r, dl) = ((p - base).len(), dv.len()); + if !(p.is_finite() && r > 1e-12 && dl > 0.0) || cond(p - base, dv).abs() > 1e-6 * r * dl { + continue; + } + if !out.iter().any(|q| q.near(p, 1e-9 * (1.0 + r))) { + out.push(p); + if out.len() >= MAX_ROOTS { + break; + } + } + } + out + } +} + +fn perpendicular(r: Vec2, d: Vec2) -> f64 { + r.dot(d) +} + +fn tangent(r: Vec2, d: Vec2) -> f64 { + r.cross(d) +} + +/// The midpoint (by length) of an elliptical arc; none for a full ellipse. +pub(super) fn ellipse_mid(e: &Ellipse) -> Option { + if e.is_full() { + return None; + } + Param::ellipse(e, |c| c.mid().into_iter().collect()).first().copied() +} + +/// The feet of the perpendiculars from `base` to an ellipse or elliptical arc. +pub(super) fn ellipse_perpendiculars(e: &Ellipse, base: Vec2) -> Vec { + Param::ellipse(e, |c| c.solve(base, perpendicular)) +} + +/// The points where lines from `base` touch an ellipse or elliptical arc: exact, by mapping the +/// ellipse onto the unit circle (an affine map keeps tangency). +pub(super) fn ellipse_tangents(e: &Ellipse, base: Vec2) -> Vec { + let (u, v) = (e.major, e.minor()); + let (a, b) = (u.len(), v.len()); + if !(a > 0.0 && b > 0.0 && a.is_finite() && b.is_finite()) { + return Vec::new(); + } + let r = base - e.center; + let local = Vec2::new(r.dot(u) / (a * a), r.dot(v) / (b * b)); + Circle::new(Vec2::ZERO, 1.0) + .tangent_points(local) + .into_iter() + .map(|q| q.angle()) + .filter(|t| t.is_finite() && angle_in_sweep(*t, e.start, e.end)) + .map(|t| e.at_param(t)) + .collect() +} + +/// The midpoint (by length) of an open spline. +pub(super) fn spline_mid(s: &Spline) -> Option { + if s.closed { + return None; + } + Param::spline(s, |c| c.mid().into_iter().collect()).first().copied() +} + +/// The feet of the perpendiculars from `base` to a spline. +pub(super) fn spline_perpendiculars(s: &Spline, base: Vec2) -> Vec { + Param::spline(s, |c| c.solve(base, perpendicular)) +} + +/// The points where lines from `base` touch a spline. +pub(super) fn spline_tangents(s: &Spline, base: Vec2) -> Vec { + Param::spline(s, |c| c.solve(base, tangent)) +} + +#[cfg(test)] +mod tests { + use super::*; + use cadcraft_geom::PI; + + fn length(f: impl Fn(f64) -> Vec2, lo: f64, hi: f64) -> f64 { + let n = 20_000; + (0..n).map(|i| f(lo + (hi - lo) * i as f64 / n as f64).dist(f(lo + (hi - lo) * (i + 1) as f64 / n as f64))).sum() + } + + #[test] + fn ellipse_mid_halves_the_length() { + let e = Ellipse { center: Vec2::new(1.0, 2.0), major: Vec2::new(4.0, 0.0), ratio: 0.5, start: 0.0, end: PI / 2.0 }; + let m = ellipse_mid(&e).unwrap(); + let t = e.param_of(m); + let (l1, l2) = (length(|t| e.at_param(t), 0.0, t), length(|t| e.at_param(t), t, PI / 2.0)); + assert!((l1 - l2).abs() < 1e-4, "{l1} vs {l2}"); + assert!(ellipse_mid(&Ellipse::full(Vec2::ZERO, Vec2::X, 0.5)).is_none()); + } + + #[test] + fn ellipse_tangents_touch_and_stay_on_the_arc() { + let full = Ellipse::full(Vec2::ZERO, Vec2::new(4.0, 0.0), 0.5); + let base = Vec2::new(0.0, 5.0); + let ts = ellipse_tangents(&full, base); + assert_eq!(ts.len(), 2); + for p in &ts { + // On the ellipse, and the line from the base touches it: (x/a)² + (y/b)² = 1 and + // the gradient is perpendicular to the line. + assert!(((p.x / 4.0).powi(2) + (p.y / 2.0).powi(2) - 1.0).abs() < 1e-9); + let grad = Vec2::new(p.x / 16.0, p.y / 4.0); + assert!(grad.dot(*p - base).abs() < 1e-9); + } + // Only the right half of the upper arc: one tangent point. + let arc = Ellipse { start: 0.0, end: PI / 2.0, ..full }; + assert_eq!(ellipse_tangents(&arc, base).len(), 1); + // From inside: none. + assert!(ellipse_tangents(&full, Vec2::new(1.0, 0.5)).is_empty()); + } + + #[test] + fn ellipse_perpendiculars_are_normals() { + let e = Ellipse::full(Vec2::ZERO, Vec2::new(4.0, 0.0), 0.5); + let base = Vec2::new(1.0, 0.5); + let ps = ellipse_perpendiculars(&e, base); + // From a point inside near the centre: the four normals of an ellipse. + assert_eq!(ps.len(), 4, "{ps:?}"); + for p in &ps { + let tan = Vec2::new(-p.y * 4.0 / 2.0, p.x * 2.0 / 4.0); + assert!(tan.dot(*p - base).abs() < 1e-6, "{p:?}"); + } + // A circle seen from its centre has no particular perpendicular. + assert!(ellipse_perpendiculars(&Ellipse::full(Vec2::ZERO, Vec2::X, 1.0), Vec2::ZERO).is_empty()); + } + + #[test] + fn spline_snaps() { + let s = Spline::from_control(vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 3.0), Vec2::new(3.0, 3.0), Vec2::new(4.0, 0.0)], 3); + // Symmetric about x = 2: the midpoint is on the axis, so is the perpendicular from + // straight below, and the tangents from far above are symmetric. + let m = spline_mid(&s).unwrap(); + assert!((m.x - 2.0).abs() < 1e-6 && (m.y - 2.25).abs() < 1e-6, "{m:?}"); + let ps = spline_perpendiculars(&s, Vec2::new(2.0, -5.0)); + assert!(ps.iter().any(|p| p.near(m, 1e-6)), "{ps:?}"); + // Every tangent of the arch passes x = 2 between y = 2.25 and 6: none from (2, 10) or + // from below the arch, two from (2, 4). + assert!(spline_tangents(&s, Vec2::new(2.0, 10.0)).is_empty()); + assert!(spline_tangents(&s, Vec2::new(2.0, -1.0)).is_empty()); + let ts = spline_tangents(&s, Vec2::new(2.0, 4.0)); + assert_eq!(ts.len(), 2, "{ts:?}"); + assert!((ts[0].x - 2.0 + ts[1].x - 2.0).abs() < 1e-6 && (ts[0].y - ts[1].y).abs() < 1e-6, "{ts:?}"); + } +} diff --git a/crates/engine/tests/snap_curves.rs b/crates/engine/tests/snap_curves.rs new file mode 100644 index 00000000..e8b9de03 --- /dev/null +++ b/crates/engine/tests/snap_curves.rs @@ -0,0 +1,121 @@ +//! Midpoint, Quadrant, Perpendicular and Tangent snaps on elliptical arcs and splines (#332). + +use cadcraft_doc::{EntityKind, Space}; +use cadcraft_engine::Session; +use cadcraft_engine::snap::{self, mode}; +use cadcraft_geom::Vec2; + +fn last(s: &Session) -> EntityKind { + s.doc().unwrap().model.iter().last().map(|e| e.kind.clone()).unwrap() +} + +/// The snap of `modes` near `p` (a hair off it, inside a 0.3 aperture). +fn snap_at(s: &Session, p: Vec2, modes: u32, base: Option) -> Option<(Vec2, u32)> { + snap::osnap(s.doc().unwrap(), &Space::Model, p + Vec2::new(0.03, -0.02), 0.3, modes, base, false).map(|h| (h.point, h.mode)) +} + +/// A quarter of an ellipse (semi-axes 4 and 2) from (4,0) to (0,2), typed at the command line. +fn quarter_ellipse() -> Session { + let mut s = Session::new(); + s.cmdline("ellipse a c 0,0 4,0 2 0 90").unwrap(); + assert!(s.running.is_none()); + let EntityKind::Ellipse(e) = last(&s) else { panic!("no elliptical arc") }; + assert!((e.end - e.start - std::f64::consts::FRAC_PI_2).abs() < 1e-9, "{e:?}"); + s +} + +#[test] +fn elliptical_arc_midpoint_and_quadrants() { + let s = quarter_ellipse(); + // The midpoint by length: both halves of the arc as long. + let (m, k) = along_quarter(&s, mode::MID, None).expect("a midpoint"); + assert_eq!(k, mode::MID); + assert!(((m.x / 4.0).powi(2) + (m.y / 2.0).powi(2) - 1.0).abs() < 1e-9, "on the ellipse: {m:?}"); + let len = |a: f64, b: f64| { + (0..10_000) + .map(|i| { + let t = |j: f64| a + (b - a) * j / 10_000.0; + let p = |t: f64| Vec2::new(4.0 * t.cos(), 2.0 * t.sin()); + p(t(i as f64)).dist(p(t(i as f64 + 1.0))) + }) + .sum::() + }; + let tm = (m.y / 2.0).atan2(m.x / 4.0); + assert!((len(0.0, tm) - len(tm, std::f64::consts::FRAC_PI_2)).abs() < 1e-4); + // Quadrants: the two the arc passes through, not the far ones. + assert_eq!(snap_at(&s, Vec2::new(4.0, 0.0), mode::QUA, None).map(|h| h.0), Some(Vec2::new(4.0, 0.0))); + assert!(snap_at(&s, Vec2::new(0.0, 2.0), mode::QUA, None).is_some_and(|h| h.0.near(Vec2::new(0.0, 2.0), 1e-9))); + assert_eq!(snap_at(&s, Vec2::new(-4.0, 0.0), mode::QUA, None), None); + assert_eq!(snap_at(&s, Vec2::new(0.0, -2.0), mode::QUA, None), None); +} + +/// The first snap of `modes` (from `base`) met while moving the cursor along the quarter ellipse. +fn along_quarter(s: &Session, modes: u32, base: Option) -> Option<(Vec2, u32)> { + (0..=200).find_map(|k| { + let a = std::f64::consts::FRAC_PI_2 * f64::from(k) / 200.0; + snap_at(s, Vec2::new(4.0 * a.cos(), 2.0 * a.sin()), modes, base) + }) +} + +fn on_quarter(p: Vec2) -> bool { + p.x >= -1e-9 && p.y >= -1e-9 && ((p.x / 4.0).powi(2) + (p.y / 2.0).powi(2) - 1.0).abs() < 1e-9 +} + +#[test] +fn elliptical_arc_perpendicular_and_tangent_from_a_base_point() { + let s = quarter_ellipse(); + // Tangent from (6,1): the line from the base is perpendicular to the ellipse's normal + // (the gradient of x²/16 + y²/4) where it touches. + let base = Vec2::new(6.0, 1.0); + let (q, m) = along_quarter(&s, mode::TAN, Some(base)).expect("a tangent point on the arc"); + assert_eq!(m, mode::TAN); + assert!(on_quarter(q) && Vec2::new(q.x / 16.0, q.y / 4.0).dot(q - base).abs() < 1e-9, "{q:?}"); + // Perpendicular from (10,10): the line from the base runs along the normal. + let base = Vec2::new(10.0, 10.0); + let (q, m) = along_quarter(&s, mode::PER, Some(base)).expect("a perpendicular foot on the arc"); + assert_eq!(m, mode::PER); + assert!(on_quarter(q) && Vec2::new(q.x / 16.0, q.y / 4.0).cross(q - base).abs() < 1e-6, "{q:?}"); + // The other tangent from (6,1) touches the ellipse below the axis, off the arc. + assert!((0..=200).all(|k| { + let a = -std::f64::consts::FRAC_PI_2 * f64::from(k) / 200.0; + snap_at(&s, Vec2::new(4.0 * a.cos(), 2.0 * a.sin()), mode::TAN, Some(Vec2::new(6.0, 1.0))).is_none_or(|(p, _)| on_quarter(p)) + })); +} + +#[test] +fn spline_midpoint_perpendicular_and_tangent() { + let mut s = Session::new(); + // A symmetric arch through (0,0), (2,2), (4,0). + s.cmdline("spline 0,0 2,2 4,0").unwrap(); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + let EntityKind::Spline(sp) = last(&s) else { panic!("no spline") }; + let top = sp.eval({ + let (lo, hi) = sp.domain(); + (lo + hi) / 2.0 + }); + assert!((top.x - 2.0).abs() < 1e-9, "{top:?}"); + let (m, k) = snap_at(&s, top, mode::MID, None).expect("a midpoint"); + assert_eq!(k, mode::MID); + assert!(m.near(top, 1e-6), "{m:?} vs {top:?}"); + // Perpendicular from straight below the top: the top. + let (p, k) = snap_at(&s, top, mode::PER, Some(Vec2::new(2.0, -5.0))).expect("a perpendicular foot"); + assert_eq!(k, mode::PER); + assert!(p.near(top, 1e-6), "{p:?}"); + // Tangent from above the top: the line touches the arch where it runs straight at the base. + let base = Vec2::new(2.0, top.y + 1.0); + let (lo, hi) = sp.domain(); + let mut hit = None; + for i in 0..=400 { + let q = sp.eval(lo + (hi - lo) * i as f64 / 400.0); + if let Some(h) = snap_at(&s, q, mode::TAN, Some(base)) { + hit = Some(h); + break; + } + } + let (q, k) = hit.expect("a tangent point"); + assert_eq!(k, mode::TAN); + let i = (0..=10_000).map(|i| lo + (hi - lo) * i as f64 / 10_000.0).min_by(|a, b| sp.eval(*a).dist(q).total_cmp(&sp.eval(*b).dist(q))).unwrap(); + let d = (sp.eval(i + 1e-6) - sp.eval(i - 1e-6)).normalized(); + assert!(d.cross((q - base).normalized()).abs() < 1e-3, "the line from the base runs along the curve at {q:?}"); +} From cf9fbb8bf5a531b98539414ada3c3e812092785c Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:55:21 +0200 Subject: [PATCH 096/150] LAYMRG and LAYDEL: merge layers, delete a layer with its objects (fixes #286) (#296) LAYMRG moves every object on the source layers (model space, layouts and block definitions) to the target layer, drops the sources from viewport freeze lists and colour overrides, and deletes the source layers. LAYDEL deletes the layers and every object on them the same way. Typed, both ask "Select object on layer to merge/delete or [Name]:" until Enter (Undo drops the last one), LAYMRG then asks for the target layer, and both warn and ask [Yes/No] before changing anything. Scripts answer Y. The JSON forms laymrg {from, to} and laydel {names} never prompt. Layer 0, Defpoints and xref-dependent layers are refused, and so is merging a layer into itself. LAYDEL refuses the current layer; LAYMRG merges it and makes the target current. Each run is one undo step. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/layer.rs | 11 + crates/engine/src/cmd/layer/merge.rs | 372 ++++++++++++++++++++++ crates/engine/tests/layer_merge_delete.rs | 150 +++++++++ crates/ui-egui/src/i18n/uk.tsv | 2 + 4 files changed, 535 insertions(+) create mode 100644 crates/engine/src/cmd/layer/merge.rs create mode 100644 crates/engine/tests/layer_merge_delete.rs diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 0b462abe..85d2e774 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -51,6 +51,14 @@ pub fn specs() -> Vec { CommandSpec::new("layerstate.list", "List Layer States", run_state_list).params("{} → states").noundo(), CommandSpec::new("layerstate.delete", "Delete Layer State", run_state_delete).params("{name}"), CommandSpec::new("layerstate.rename", "Rename Layer State", run_state_rename).params("{from, to}"), + CommandSpec::new("laymrg", "Layer Merge", merge::run_laymrg) + .menu(&["Format", "Layer Tools", "Layer Merge"]) + .params("{from: [names], to} (the from layers' objects move to `to`, then the from layers are deleted)") + .interactive(|_| Ok(Box::new(merge::LayerRemove::merge()))), + CommandSpec::new("laydel", "Layer Delete", merge::run_laydel) + .menu(&["Format", "Layer Tools", "Layer Delete"]) + .params("{names: [...]} (deletes the layers and every object on them)") + .interactive(|_| Ok(Box::new(merge::LayerRemove::delete()))), ] } @@ -405,6 +413,9 @@ fn run_state_rename(s: &mut Session, p: &Value) -> Result { ok() } +// LAYMRG and LAYDEL. +mod merge; + #[cfg(test)] mod tests { use serde_json::json; diff --git a/crates/engine/src/cmd/layer/merge.rs b/crates/engine/src/cmd/layer/merge.rs new file mode 100644 index 00000000..9ccfb1a3 --- /dev/null +++ b/crates/engine/src/cmd/layer/merge.rs @@ -0,0 +1,372 @@ +//! LAYMRG and LAYDEL: merge layers into another layer, and delete a layer together with every +//! object on it (model space, layouts and block definitions). +//! +//! The JSON forms (`laymrg {from, to}`, `laydel {names}`) never prompt. Typed, both pick layers by +//! selecting objects on them (or by Name), then ask for confirmation before changing anything. + +use std::collections::HashSet; + +use cadcraft_doc::{Drawing, EntityKind, EntityStore, Handle}; +use serde_json::{Value, json}; + +use super::super::{bad, find_command, str_param}; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +/// Layers whose names carry `|` belong to an attached xref and are managed by it. +fn xref_layer(name: &str) -> bool { + name.contains('|') +} + +fn protected(name: &str) -> bool { + name == "0" || name.eq_ignore_ascii_case("Defpoints") +} + +/// `key` as one name or a list of names. +fn names_param(p: &Value, key: &str) -> Vec { + match p.get(key) { + Some(Value::String(s)) => vec![s.clone()], + Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).take(10_000).map(str::to_string).collect(), + _ => Vec::new(), + } +} + +fn plural(n: usize, one: &str) -> String { + if n == 1 { format!("1 {one}") } else { format!("{n} {one}s") } +} + +/// Why `name` can't be merged away or deleted (`None`: it can). `delete`: LAYDEL's rules, which +/// also keep the current layer. +fn source_refusal(d: &Drawing, name: &str, delete: bool) -> Option { + let verb = if delete { "delete" } else { "merge" }; + if protected(name) { + return Some(format!("Cannot {verb} layer \"{name}\" (layer 0 and Defpoints always stay).")); + } + if xref_layer(name) { + return Some(format!("Cannot {verb} xref-dependent layer \"{name}\".")); + } + if delete && d.header.str("CLAYER", "0").eq_ignore_ascii_case(name) { + return Some(format!("Cannot delete the current layer \"{name}\".")); + } + None +} + +/// The drawing's spelling of each named layer, checked as sources. Duplicates collapse. +fn resolve_sources(d: &Drawing, cmd: &str, names: &[String], delete: bool) -> Result> { + let mut out: Vec = Vec::new(); + for n in names { + let l = d.layer(n.trim()).ok_or_else(|| bad(cmd, format!("no layer `{}`", n.trim())))?; + if let Some(why) = source_refusal(d, &l.name, delete) { + return Err(bad(cmd, why)); + } + if !out.iter().any(|o| o.eq_ignore_ascii_case(&l.name)) { + out.push(l.name.clone()); + } + } + if out.is_empty() { + return Err(bad(cmd, if delete { "`names` is required" } else { "`from` is required" })); + } + Ok(out) +} + +/// Every entity store whose objects belong to this drawing: model space, layouts and the block +/// definitions (an attached xref's blocks are left alone; they come from its file). +/// `f` gets each store with objects on the `set` layers and returns how many it changed. +fn for_each_store(d: &mut Drawing, set: &HashSet, mut f: impl FnMut(&mut EntityStore) -> usize) -> usize { + let mut n = f(&mut d.model); + for layout in &mut d.layouts { + n += f(&mut layout.entities); + } + for block in d.blocks.values_mut() { + // Copy-on-write: only definitions that use the layers are touched. + if block.xref_path.is_none() && on_layers(&block.entities, set).next().is_some() { + n += f(&mut std::sync::Arc::make_mut(block).entities); + } + } + n +} + +fn on_layers<'a>(store: &'a EntityStore, set: &'a HashSet) -> impl Iterator + 'a { + store.iter().filter(|e| set.contains(&e.common.layer.to_ascii_lowercase())).map(|e| e.handle) +} + +/// Drop the removed layers from every layout viewport's frozen list and colour overrides. +fn forget_in_viewports(d: &mut Drawing, set: &HashSet) { + for layout in &mut d.layouts { + let hs: Vec = layout + .entities + .iter() + .filter(|e| match &e.kind { + EntityKind::Viewport(v) => { + v.frozen_layers.iter().any(|l| set.contains(&l.to_ascii_lowercase())) + || v.layer_colors.iter().any(|(l, _)| set.contains(&l.to_ascii_lowercase())) + } + _ => false, + }) + .map(|e| e.handle) + .collect(); + for h in hs { + layout.entities.modify(h, |e| { + if let EntityKind::Viewport(v) = &mut e.kind { + v.frozen_layers.retain(|l| !set.contains(&l.to_ascii_lowercase())); + v.layer_colors.retain(|(l, _)| !set.contains(&l.to_ascii_lowercase())); + } + }); + } + } +} + +fn lower_set(names: &[String]) -> HashSet { + names.iter().map(|n| n.to_ascii_lowercase()).collect() +} + +/// `laymrg {from: [names] | name, to}`: move every object on the `from` layers to `to`, then +/// delete the `from` layers. Merging away the current layer makes the target current. +pub(super) fn run_laymrg(s: &mut Session, p: &Value) -> Result { + let to = str_param(p, "to").map(str::trim).ok_or_else(|| bad("laymrg", "`to` is required"))?; + let d = s.doc()?; + let target = d.layer(to).ok_or_else(|| bad("laymrg", format!("no layer `{to}`")))?.name.clone(); + if xref_layer(&target) { + return Err(bad("laymrg", format!("cannot merge into xref-dependent layer \"{target}\""))); + } + let sources = resolve_sources(d, "laymrg", &names_param(p, "from"), false)?; + if sources.iter().any(|n| n.eq_ignore_ascii_case(&target)) { + return Err(bad("laymrg", format!("cannot merge layer \"{target}\" into itself"))); + } + let set = lower_set(&sources); + let d = s.doc_mut()?; + let moved = for_each_store(d, &set, |store| { + let hs: Vec = on_layers(store, &set).collect(); + for h in &hs { + store.modify(*h, |e| e.common.layer = target.clone()); + } + hs.len() + }); + forget_in_viewports(d, &set); + d.layers.retain(|l| !set.contains(&l.name.to_ascii_lowercase())); + let mut msg = format!("Merged {} into \"{target}\": {} moved.", quoted(&sources), plural(moved, "object")); + if set.contains(&d.header.str("CLAYER", "0").to_ascii_lowercase()) { + if let Some(l) = d.layer_mut(&target) { + l.frozen = false; + } + d.header.set_str("CLAYER", &target); + msg.push_str(&format!("\nLayer \"{target}\" is now current.")); + } + Ok(json!({ "merged": sources, "into": target, "moved": moved, "message": msg })) +} + +/// `laydel {names: [...] | name}`: delete the layers and every object on them. +pub(super) fn run_laydel(s: &mut Session, p: &Value) -> Result { + let mut names = names_param(p, "names"); + names.extend(names_param(p, "name")); + let sources = resolve_sources(s.doc()?, "laydel", &names, true)?; + let set = lower_set(&sources); + let d = s.doc_mut()?; + let erased = for_each_store(d, &set, |store| { + let hs: Vec = on_layers(store, &set).collect(); + for h in &hs { + store.remove(*h); + } + hs.len() + }); + forget_in_viewports(d, &set); + d.layers.retain(|l| !set.contains(&l.name.to_ascii_lowercase())); + s.set_selection(Vec::new()); + let msg = format!("Deleted {} and {}.", quoted(&sources), plural(erased, "object")); + Ok(json!({ "deleted": sources, "erased": erased, "message": msg })) +} + +fn quoted(names: &[String]) -> String { + let list = names.iter().map(|n| format!("\"{n}\"")).collect::>().join(", "); + if names.len() == 1 { format!("layer {list}") } else { format!("layers {list}") } +} + +// ---------------- typed: LAYMRG / LAYDEL ---------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Stage { + /// Picking the layers to merge or delete. + Source, + SourceName, + Target, + TargetName, + Confirm, +} + +/// Keyword-only prompt. +const KW: Accept = Accept { point: false, number: false, text: false, select: false, enter: true }; +/// A layer name (may contain spaces); numbers are names too. +const NAME: Accept = Accept { number: true, ..Accept::TEXT }; + +/// The LAYMRG and LAYDEL prompt sequence. +pub(super) struct LayerRemove { + delete: bool, + stage: Stage, + sources: Vec, + target: Option, +} + +impl LayerRemove { + pub(super) fn merge() -> Self { + LayerRemove { delete: false, stage: Stage::Source, sources: Vec::new(), target: None } + } + pub(super) fn delete() -> Self { + LayerRemove { delete: true, ..LayerRemove::merge() } + } + + fn id(&self) -> &'static str { + if self.delete { "laydel" } else { "laymrg" } + } + + fn add_source(&mut self, s: &mut Session, name: &str) { + let Ok(d) = s.doc() else { return }; + let Some(l) = d.layer(name.trim()) else { + s.echo(format!("Layer \"{}\" not found.", name.trim())); + return; + }; + let name = l.name.clone(); + if let Some(why) = source_refusal(d, &name, self.delete) { + s.echo(why); + return; + } + if !self.sources.iter().any(|n| n.eq_ignore_ascii_case(&name)) { + self.sources.push(name); + } + s.echo(format!("Selected layers: {}.", self.sources.join(", "))); + } + + fn set_target(&mut self, s: &mut Session, name: &str) { + let Ok(d) = s.doc() else { return }; + let Some(l) = d.layer(name.trim()) else { + s.echo(format!("Layer \"{}\" not found.", name.trim())); + return; + }; + let name = l.name.clone(); + if xref_layer(&name) { + s.echo(format!("Cannot merge into xref-dependent layer \"{name}\".")); + } else if self.sources.iter().any(|n| n.eq_ignore_ascii_case(&name)) { + s.echo(format!("Cannot merge layer \"{name}\" into itself.")); + } else { + self.target = Some(name); + self.confirm(s); + } + } + + /// Show the warning and ask. + fn confirm(&mut self, s: &mut Session) { + s.echo("******** WARNING ********"); + match &self.target { + Some(t) if !self.delete => s.echo(format!("You are about to merge {} into layer \"{t}\".", quoted(&self.sources))), + _ => s.echo(format!("You are about to delete {} and every object on it from this drawing.", quoted(&self.sources))), + } + s.echo("Do you wish to continue?"); + self.stage = Stage::Confirm; + } + + fn layers_of(s: &Session, hs: &[Handle]) -> Vec { + let Ok(d) = s.doc() else { return Vec::new() }; + let mut out: Vec = Vec::new(); + for l in hs.iter().filter_map(|h| d.entity(*h)).map(|e| e.common.layer.clone()) { + if !out.iter().any(|o| o.eq_ignore_ascii_case(&l)) { + out.push(l); + } + } + out + } + + /// Run the JSON body directly: the running command records the single undo step. + fn apply(&self, s: &mut Session) -> Result { + let spec = find_command(self.id()).ok_or_else(|| EngineError::UnknownCommand(self.id().into()))?; + let p = match &self.target { + Some(t) if !self.delete => json!({ "from": self.sources, "to": t }), + _ => json!({ "names": self.sources }), + }; + let r = (spec.run)(s, &p)?; + if let Some(m) = r.get("message").and_then(Value::as_str) { + for l in m.lines() { + s.echo(l.to_string()); + } + } + Ok(Step::Done) + } +} + +impl Interactive for LayerRemove { + fn name(&self) -> &'static str { + if self.delete { "LAYDEL" } else { "LAYMRG" } + } + + fn prompt(&self, _s: &Session) -> Prompt { + match self.stage { + Stage::Source => { + let msg = if self.delete { "Select object on layer to delete" } else { "Select object on layer to merge" }; + let kw: &[&str] = if self.sources.is_empty() { &["Name"] } else { &["Name", "Undo"] }; + Prompt::new(msg, Accept::SELECT).kw(kw) + } + Stage::SourceName => Prompt::new("Enter layer name", NAME), + Stage::Target => Prompt::new("Select object on target layer", Accept::SELECT).kw(&["Name"]), + Stage::TargetName => Prompt::new("Enter target layer name", NAME), + // The question itself is echoed with the warning: "[Yes/No] :" follows it. + Stage::Confirm => Prompt::new("", KW).kw(&["Yes", "No"]).default("No"), + } + } + + fn input(&mut self, s: &mut Session, i: Input) -> Result { + // At "Select object" prompts the selection layer hands typed keywords over as text. + let i = match i { + Input::Text(t) if matches!(self.stage, Stage::Source | Stage::Target) => match self.prompt(s).match_keyword(&t) { + Some(k) => Input::Keyword(k), + None => Input::Text(t), + }, + other => other, + }; + match (self.stage, i) { + (_, Input::Cancel) => return Ok(Step::Cancel), + (Stage::Source, Input::Pick(hs)) => { + for l in Self::layers_of(s, &hs) { + self.add_source(s, &l); + } + } + (Stage::Source, Input::Keyword(k)) if k == "Name" => self.stage = Stage::SourceName, + (Stage::Source, Input::Keyword(k)) if k == "Undo" => { + self.sources.pop(); + s.echo(format!("Selected layers: {}.", self.sources.join(", "))); + } + (Stage::Source, Input::Enter) => { + if self.sources.is_empty() { + return Ok(Step::Done); + } + if self.delete { + self.confirm(s); + } else { + self.stage = Stage::Target; + } + } + (Stage::SourceName, Input::Text(t) | Input::Keyword(t)) => { + // Layer names never contain commas: a list adds each one. + for n in t.split(',').map(str::trim).filter(|n| !n.is_empty()).take(1000) { + self.add_source(s, n); + } + self.stage = Stage::Source; + } + (Stage::SourceName, Input::Enter) => self.stage = Stage::Source, + (Stage::Target, Input::Pick(hs)) => { + if let Some(l) = Self::layers_of(s, &hs).first() { + self.set_target(s, l); + } + } + (Stage::Target, Input::Keyword(k)) if k == "Name" => self.stage = Stage::TargetName, + (Stage::Target, Input::Enter) => return Ok(Step::Done), + (Stage::TargetName, Input::Text(t) | Input::Keyword(t)) => { + self.stage = Stage::Target; + self.set_target(s, &t); + } + (Stage::TargetName, Input::Enter) => self.stage = Stage::Target, + (Stage::Confirm, Input::Keyword(k)) if k == "Yes" => return self.apply(s), + (Stage::Confirm, Input::Keyword(_) | Input::Enter) => return Ok(Step::Done), + (Stage::Confirm, _) => s.echo("Enter Yes or No."), + (_, Input::Text(_) | Input::Keyword(_)) => s.echo("*Invalid selection*"), + _ => {} + } + Ok(Step::Continue) + } +} diff --git a/crates/engine/tests/layer_merge_delete.rs b/crates/engine/tests/layer_merge_delete.rs new file mode 100644 index 00000000..38a7a0bc --- /dev/null +++ b/crates/engine/tests/layer_merge_delete.rs @@ -0,0 +1,150 @@ +//! LAYMRG merges layers into another one and LAYDEL deletes a layer with its objects: model space, +//! layouts and block definitions, typed (with confirmation) and as JSON (#286). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use serde_json::json; + +fn line_on(s: &mut Session, layer: &str, y: f64) -> String { + s.execute("layer.current", &json!({ "name": layer })).unwrap(); + s.execute("line", &json!({ "points": [[0, y], [10, y]] })).unwrap()["handles"][0].as_str().unwrap().to_string() +} + +/// Layers A and B with a line each in model space, a block "Part" holding an A line, a paper-space +/// A line and a layout viewport that freezes A and colours B. +fn drawing() -> Session { + let mut s = Session::new(); + for n in ["A", "B", "C"] { + s.execute("layer.new", &json!({ "name": n })).unwrap(); + } + let a = line_on(&mut s, "A", 0.0); + s.execute("block", &json!({ "name": "Part", "base": [0, 0], "handles": [a], "keep": "delete" })).unwrap(); + line_on(&mut s, "A", 1.0); + line_on(&mut s, "B", 2.0); + s.execute("layer.current", &json!({ "name": "0" })).unwrap(); + s.execute("insert", &json!({ "name": "Part", "at": [5, 5] })).unwrap(); + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + line_on(&mut s, "A", 3.0); + s.execute("layer.current", &json!({ "name": "0" })).unwrap(); + let vp = s.execute("mview", &json!({ "layout": "Layout1" })).unwrap()["viewports"][0].as_str().unwrap().to_string(); + s.execute("viewport.set", &json!({ "handle": vp, "freeze": ["A", "C"], "colors": { "B": 1 } })).unwrap(); + s.execute("layout.set", &json!({ "name": "Model" })).unwrap(); + s +} + +/// Objects per layer name over model space, layouts and block definitions. +fn count(s: &Session, layer: &str) -> usize { + let d = s.doc().unwrap(); + let paper = d.layouts.iter().flat_map(|l| l.entities.iter()); + let blocks = d.blocks.values().flat_map(|b| b.entities.iter()); + d.model.iter().chain(paper).chain(blocks).filter(|e| e.common.layer.eq_ignore_ascii_case(layer)).count() +} + +fn viewport_lists(s: &Session) -> Vec<(Vec, Vec)> { + let d = s.doc().unwrap(); + d.layouts + .iter() + .flat_map(|l| l.entities.iter()) + .filter_map(|e| match &e.kind { + EntityKind::Viewport(v) => Some((v.frozen_layers.clone(), v.layer_colors.iter().map(|(n, _)| n.clone()).collect())), + _ => None, + }) + .collect() +} + +#[test] +fn laymrg_typed_moves_everything_and_deletes_the_source() { + let mut s = drawing(); + assert_eq!(count(&s, "A"), 3); + let undo = s.state().unwrap().undo.len(); + // A is current: merging it away makes the target current. + s.execute("layer.current", &json!({ "name": "A" })).unwrap(); + let undo_cur = s.state().unwrap().undo.len(); + assert_eq!(undo_cur, undo + 1); + s.script("LAYMRG\nN\nA\n\nN\nB\n").unwrap(); + assert!(s.prompt_text().ends_with(" [Yes/No] :"), "{}", s.prompt_text()); + assert_eq!(s.log.last().map(String::as_str), Some("Do you wish to continue?")); + assert!(s.log.iter().any(|l| l.contains("merge layer \"A\" into layer \"B\"")), "{:?}", s.log); + s.script("Y\n").unwrap(); + assert!(s.running.is_none()); + assert!(s.doc().unwrap().layer("A").is_none()); + assert_eq!(count(&s, "A"), 0); + assert_eq!(count(&s, "B"), 4, "model, paper space and the block definition"); + assert_eq!(s.doc().unwrap().header.str("CLAYER", "0"), "B"); + // The viewport forgets A's freeze; B keeps its colour override. + assert_eq!(viewport_lists(&s), vec![(vec!["C".to_string()], vec!["B".to_string()])]); + assert_eq!(s.state().unwrap().undo.len(), undo_cur + 1, "one undo step"); + s.undo().unwrap(); + assert_eq!(count(&s, "A"), 3); + + // Answering No (or Enter) changes nothing; picking an object on the layer works too. + let h = s.doc().unwrap().model.iter().find(|e| e.common.layer == "C" || e.common.layer == "A").map(|e| e.handle).unwrap(); + s.start("laymrg").unwrap(); + assert!(s.prompt_text().contains("Select object on layer to merge or [Name]:"), "{}", s.prompt_text()); + s.input(cadcraft_engine::Input::Pick(vec![h])).unwrap(); + assert!(s.prompt_text().contains("[Name/Undo]"), "{}", s.prompt_text()); + s.script("\nN\nC\n\n").unwrap(); + assert!(s.running.is_none()); + assert!(s.doc().unwrap().layer("A").is_some()); +} + +#[test] +fn laymrg_refusals() { + let mut s = drawing(); + for (p, why) in [ + (json!({ "from": ["A"], "to": "A" }), "itself"), + (json!({ "from": ["0"], "to": "A" }), "layer 0"), + (json!({ "from": ["Defpoints"], "to": "A" }), "Defpoints"), + (json!({ "from": ["Nope"], "to": "A" }), "no layer"), + (json!({ "from": ["A"], "to": "Nope" }), "no layer"), + (json!({ "from": [], "to": "A" }), "required"), + ] { + s.execute("layer.new", &json!({ "name": "Defpoints" })).ok(); + let e = s.execute("laymrg", &p).unwrap_err().to_string(); + assert!(e.contains(why), "{p}: {e}"); + } + assert_eq!(count(&s, "A"), 3); + // Typed: the target can't be a source, layer 0 can't be picked as a source. + s.script("LAYMRG\nN\n0\nN\nA\n\nN\nA\n").unwrap(); + assert!(s.log.iter().any(|l| l.contains("Cannot merge layer \"0\"")), "{:?}", s.log); + assert!(s.log.iter().any(|l| l.contains("into itself")), "{:?}", s.log); + assert!(s.prompt_text().contains("Select object on target layer"), "{}", s.prompt_text()); + s.cancel(); + // JSON merges several layers at once. + let r = s.execute("laymrg", &json!({ "from": ["A", "C"], "to": "0" })).unwrap(); + assert_eq!(r["moved"], 3); + assert!(s.doc().unwrap().layer("C").is_none()); +} + +#[test] +fn laydel_deletes_the_layer_and_its_objects() { + let mut s = drawing(); + let undo = s.state().unwrap().undo.len(); + s.script("LAYDEL\nN\nA\n\n").unwrap(); + assert!(s.prompt_text().ends_with(" [Yes/No] :"), "{}", s.prompt_text()); + assert_eq!(s.log.last().map(String::as_str), Some("Do you wish to continue?")); + s.script("Y\n").unwrap(); + assert!(s.running.is_none()); + assert!(s.doc().unwrap().layer("A").is_none()); + assert_eq!(count(&s, "A"), 0); + assert_eq!(count(&s, "B"), 1); + assert!(s.doc().unwrap().block("Part").is_some_and(|b| b.entities.is_empty()), "emptied, not removed"); + assert_eq!(viewport_lists(&s), vec![(vec!["C".to_string()], vec!["B".to_string()])]); + assert_eq!(s.state().unwrap().undo.len(), undo + 1, "one undo step"); + + // Refused: layer 0, Defpoints, the current layer, xref-dependent layers. + s.execute("layer.current", &json!({ "name": "B" })).unwrap(); + let d = std::sync::Arc::make_mut(&mut s.state_mut().unwrap().doc); + d.layers.push(cadcraft_engine::doc::Layer::new("Site|Walls")); + d.layers.push(cadcraft_engine::doc::Layer::new("Defpoints")); + for n in ["0", "Defpoints", "B", "Site|Walls"] { + assert!(s.execute("laydel", &json!({ "names": [n] })).is_err(), "{n}"); + assert!(s.doc().unwrap().layer(n).is_some(), "{n}"); + } + s.script("LAYDEL\nN\nB\n").unwrap(); + assert!(s.log.iter().any(|l| l.contains("Cannot delete the current layer")), "{:?}", s.log); + s.cancel(); + let r = s.execute("laydel", &json!({ "names": ["C"] })).unwrap(); + assert_eq!(r["erased"], 0); + assert!(viewport_lists(&s).iter().all(|(f, _)| f.is_empty())); +} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a31d4094..5d443f9c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -351,7 +351,9 @@ Last Останній Last commit Останній коміт Layer Шар + Layer Delete Видалити шар з об'єктами Layer Match Узгодити шар + Layer Merge Об'єднати шари Layer Off Вимкнути шар Layer Properties Властивості шару Layer Properties Manager Диспетчер властивостей шарів From 5b286119d2ec8c291374f2c7c3c06c0cd3304ae4 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:55:27 +0200 Subject: [PATCH 097/150] ATTDISP and ATTSYNC; the display follows ATTMODE (fixes #275, #276) (#282) ATTDISP asks "Enter attribute visibility setting [Normal/ON/OFF]" (default: the current setting) and sets ATTMODE to 1 / 2 / 0. The renderer now reads ATTMODE: OFF hides every attribute, ON also draws invisible ones, Normal draws the visible ones as before. ATTSYNC ("Enter an option [?/Name/Select] "), "{}", s.prompt_text()); + for l in ["n", "b"] { + s.cmdline(l).unwrap(); + } + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert!(s.log.iter().any(|l| l == "ATTSYNC block B: 3 references synchronized."), "{:?}", s.log); + + let a = attribs(&s, r1); + let tags: Vec<&str> = a.iter().map(|a| a.tag.as_str()).collect(); + assert_eq!(tags, ["A", "NEW"]); + assert_eq!((a[0].text.value.as_str(), a[1].text.value.as_str()), ("kept", "new")); + assert_eq!((a[0].text.insert.x, a[0].text.insert.y, a[0].text.height), (100.0, 5.0, 0.5)); + assert!(a[0].invisible); + assert_eq!(attribs(&s, r2)[0].text.value, "a"); + + let n = s.doc().unwrap().block("N").unwrap().clone(); + let inner: Vec = + n.entities.iter().find_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.attribs.clone()) } else { None }).unwrap(); + assert_eq!(inner.iter().map(|a| (a.tag.as_str(), a.text.value.as_str())).collect::>(), [("A", "inner"), ("NEW", "new")]); + + // Select option (the default), confirmed with Enter; one undo step per run. + s.execute("attedit", &json!({"handle": r2.hex(), "values": {"A": "changed"}})).unwrap(); + for l in ["attsync", "", "205,0", ""] { + s.cmdline(l).unwrap(); + } + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(attribs(&s, r2)[0].text.value, "changed"); + + // The JSON form, by a reference's handle. + assert_eq!(s.execute("attsync", &json!({"handle": r1.hex()})).unwrap(), json!({"block": "B", "references": 3})); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 29b54d72..a6f323df 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -969,8 +969,10 @@ fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rg } } if b.opts.text { + // ATTMODE (ATTDISP): 0 shows no attributes, 1 the visible ones, 2 all of them. + let attmode = ctx.d.header.i64("ATTMODE", 1); for a in &ins.attribs { - if a.invisible { + if attmode == 0 || (a.invisible && attmode != 2) { continue; } let (sh, _) = place_text_entity(ctx.d, &a.text, &a.text.value); diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5d443f9c..5625e065 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -52,6 +52,7 @@ Attach Приєднати Attribute Атрибут Attribute Definition Визначення атрибута + Attribute Display Відображення атрибутів Auto (system language) Авто (мова системи) Auto Constrain Автоматичні обмеження AutoConstrain Автоматичні обмеження @@ -846,6 +847,7 @@ Switch Layout Перемкнути аркуш Switch between model space in a viewport and paper space Перемкнути між простором моделі у видовому екрані та простором аркуша Symmetric Симетрія + Synchronize Attributes Синхронізувати атрибути Toggle PICKADD (off: each pick replaces the selection) Перемкнути PICKADD (вимкнено: кожен вибір замінює набір вибраних об'єктів) Toggle PICKADD (on: picks add to the selection) Перемкнути PICKADD (увімкнено: вибране додається до набору вибраних об'єктів) TOP ЗВЕРХУ From 7a876c74c9511aa70c5d5884993c2bc107f40131 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:55:33 +0200 Subject: [PATCH 098/150] ATTEDIT prompts at the command line; -ATTEDIT; tags in any case (fixes #273, #274) (#280) ATTEDIT / EATTEDIT typed at the command line ran the JSON form and failed with "`handle` is required". It now asks "Select block reference:" and then "Enter value for :" for each attribute (Enter keeps the value); the answers are applied together as one undo step. -ATTEDIT is a command of its own (Modify > Object > Attribute > Global): one at a time (Value: Change/Replace, Next) or global string replacement, over the attributes chosen by block name, tag and value specifications. Session::start now prefers an id that keeps its leading dash, so -ATTEDIT is not taken for ATTEDIT; dashed aliases (-INSERT...) resolve as before. INSERT `attribs` and ATTEDIT `values` match attribute tags in any case and report the keys no attribute has in `unknownTags`. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/blocks.rs | 29 +- crates/engine/src/cmd/blocks/attedit.rs | 453 ++++++++++++++++++++++++ crates/engine/tests/attedit_prompts.rs | 111 ++++++ crates/ui-egui/src/i18n/uk.tsv | 1 + 4 files changed, 571 insertions(+), 23 deletions(-) create mode 100644 crates/engine/src/cmd/blocks/attedit.rs create mode 100644 crates/engine/tests/attedit_prompts.rs diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index 62efea72..d1ced7ee 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -11,6 +11,8 @@ use super::machines::{SelOutcome, SelectPhase, number}; use super::*; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; +mod attedit; + pub fn specs() -> Vec { vec![ CommandSpec::new("block", "Make...", run_block) @@ -33,10 +35,8 @@ pub fn specs() -> Vec { .alias(&["att", "-attdef"]) .params("{tag, prompt?, default?, at, height?, invisible?}") .interactive(|_| Ok(Box::new(AttdefM::default()))), - CommandSpec::new("attedit", "Single...", run_attedit) - .menu(&["Modify", "Object", "Attribute", "Single..."]) - .alias(&["ate", "eattedit"]) - .params("{handle, values: {TAG: value}}"), + attedit::attedit_spec(), + attedit::dash_attedit_spec(), CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"), CommandSpec::new("base", "Base", run_base).menu(&["Draw", "Block", "Base"]).params("{at: [x,y]}"), CommandSpec::new("purge", "Purge", run_purge).alias(&["pu", "-purge"]).params("{} (unused blocks, layers, linetypes, styles)"), @@ -164,7 +164,7 @@ pub(crate) fn insert_with( if let EntityKind::Text(tt) = k { t = tt; } - t.value = values.get(&ad.tag).and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| ad.text.value.clone()); + t.value = attedit::value_for(values, &ad.tag).unwrap_or_else(|| ad.text.value.clone()); ins.attribs.push(Attrib { tag: ad.tag.clone(), text: t, invisible: ad.invisible, constant: false, prompt: String::new() }); } } @@ -200,7 +200,7 @@ fn run_insert(s: &mut Session, p: &Value) -> Result { if bool_or(p, "explode", false) { return s.execute("explode", &json!({ "handles": [h.hex()] })); } - Ok(json!({ "handle": h.hex() })) + Ok(attedit::with_unknown_tags(s, h, &vals, json!({ "handle": h.hex() }))) } fn attdef_kind(s: &Session, tag: &str, prompt: &str, default: &str, at: Vec2, height: f64, invisible: bool) -> EntityKind { @@ -236,23 +236,6 @@ fn run_attdef(s: &mut Session, p: &Value) -> Result { Ok(json!({ "handle": s.add_entity(k)?.hex() })) } -fn run_attedit(s: &mut Session, p: &Value) -> Result { - let h = targets(s, p)?.first().copied().ok_or_else(|| bad("attedit", "`handle` is required"))?; - let vals = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default(); - let mut n = 0; - s.doc_mut()?.modify_entity(h, |e| { - if let EntityKind::Insert(i) = &mut e.kind { - for a in &mut i.attribs { - if let Some(v) = vals.get(&a.tag).and_then(Value::as_str) { - a.text.value = v.to_string(); - n += 1; - } - } - } - })?; - Ok(json!({ "changed": n })) -} - fn run_wblock(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("wblock", "`path` is required"))?.to_string(); let src = s.doc()?; diff --git a/crates/engine/src/cmd/blocks/attedit.rs b/crates/engine/src/cmd/blocks/attedit.rs new file mode 100644 index 00000000..e4f0fbd0 --- /dev/null +++ b/crates/engine/src/cmd/blocks/attedit.rs @@ -0,0 +1,453 @@ +//! ATTEDIT / EATTEDIT: one block reference's attribute values, asked for one after another. +//! -ATTEDIT: the attributes picked by block name, tag and value specifications, edited one at a +//! time or by replacing a string in all of them. Attribute tags match in any case, as in AutoCAD. + +use cadcraft_doc::{EntityKind, Handle}; +use serde_json::{Map, Value, json}; + +use crate::cmd::curves::KW; +use crate::cmd::machines::{SelOutcome, SelectPhase}; +use crate::cmd::qselect::wildcard; +use crate::cmd::{CommandSpec, bad, str_param, targets}; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +/// Longest attribute value a string replacement may produce (bytes). +const MAX_VALUE: usize = 65_536; + +pub(super) fn attedit_spec() -> CommandSpec { + CommandSpec::new("attedit", "Single...", run) + .menu(&["Modify", "Object", "Attribute", "Single..."]) + .alias(&["ate", "eattedit"]) + .params("{handle, values: {TAG: value} (tags in any case)} → {changed, unknownTags?}") + .interactive(|_| Ok(Box::new(AtteditM::default()))) +} + +pub(super) fn dash_attedit_spec() -> CommandSpec { + CommandSpec::new("-attedit", "Global", run_global) + .menu(&["Modify", "Object", "Attribute", "Global"]) + .alias(&["-ate", "atte"]) + .params("{find, replace?, block?, tag?, value? (wildcard specifications, default *), handles? (default: every block reference in the current space)} → {changed}") + .interactive(|_| Ok(Box::new(DashAtteditM::default()))) +} + +/// The value `values` gives for `tag`, matching the tag in any case. Numbers and booleans count +/// as their text. +pub(super) fn value_for(values: &Map, tag: &str) -> Option { + let v = values.get(tag).or_else(|| values.iter().find(|(k, _)| k.eq_ignore_ascii_case(tag)).map(|(_, v)| v))?; + match v { + Value::String(t) => Some(t.clone()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + _ => None, + } +} + +/// `r` with `unknownTags`: the keys of `values` that block reference `h` has no attribute for +/// (left out when there are none). +pub(super) fn with_unknown_tags(s: &Session, h: Handle, values: &Map, mut r: Value) -> Value { + let tags: Vec<&str> = match s.doc().ok().and_then(|d| d.entity(h)).map(|e| &e.kind) { + Some(EntityKind::Insert(i)) => i.attribs.iter().map(|a| a.tag.as_str()).collect(), + _ => Vec::new(), + }; + let unknown: Vec<&String> = values.keys().filter(|k| !tags.iter().any(|t| t.eq_ignore_ascii_case(k))).collect(); + if !unknown.is_empty() + && let Some(o) = r.as_object_mut() + { + o.insert("unknownTags".into(), json!(unknown)); + } + r +} + +fn run(s: &mut Session, p: &Value) -> Result { + let h = targets(s, p)?.first().copied().ok_or_else(|| bad("attedit", "`handle` is required"))?; + let vals = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default(); + let mut n = 0; + s.doc_mut()?.modify_entity(h, |e| { + 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; + n += 1; + } + } + } + })?; + Ok(with_unknown_tags(s, h, &vals, json!({ "changed": n }))) +} + +/// A block name, tag or value specification: comma-separated wildcard patterns, any case. For +/// values, `\` matches an empty value. +fn spec_matches(spec: &str, text: &str) -> bool { + spec.split(',').any(|p| if p.trim() == "\\" { text.is_empty() } else { wildcard(p.trim(), text) }) +} + +/// Specifications choosing attributes: block name, tag, value. +#[derive(Clone, Debug)] +struct Specs { + block: String, + tag: String, + value: String, +} + +impl Default for Specs { + fn default() -> Self { + Specs { block: "*".into(), tag: "*".into(), value: "*".into() } + } +} + +/// The attributes of block references `hs` that match `specs`, as (block reference, attribute +/// index). Invisible attributes count only with `invisible` (or while ATTMODE shows them). +fn matching(s: &Session, hs: &[Handle], specs: &Specs, invisible: bool) -> Result> { + let d = s.doc()?; + let invisible = invisible || d.header.i64("ATTMODE", 1) == 2; + let mut out = Vec::new(); + for h in hs { + let Some(EntityKind::Insert(ins)) = d.entity(*h).map(|e| &e.kind) else { continue }; + if !spec_matches(&specs.block, &ins.block) { + continue; + } + for (k, a) in ins.attribs.iter().enumerate() { + if !a.constant && (invisible || !a.invisible) && spec_matches(&specs.tag, &a.tag) && spec_matches(&specs.value, &a.text.value) { + out.push((*h, k)); + } + } + } + Ok(out) +} + +/// Every object in the current space. +fn all_in_space(s: &Session) -> Result> { + let d = s.doc()?; + Ok(d.space(&s.space()).map(|st| st.iter().map(|e| e.handle).collect()).unwrap_or_default()) +} + +/// Change attribute `k` of block reference `h`; returns whether its value changed. +fn edit(s: &mut Session, (h, k): (Handle, usize), f: impl FnOnce(&str) -> Option) -> Result { + let mut changed = false; + s.doc_mut()?.modify_entity(h, |e| { + if let EntityKind::Insert(ins) = &mut e.kind + && 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; + changed = true; + } + })?; + Ok(changed) +} + +/// Replace every `find` in `value` with `with`, unless the result would be too long. +fn replaced(value: &str, find: &str, with: &str) -> Option { + if find.is_empty() || !value.contains(find) { + return None; + } + let n = value.matches(find).count(); + let len = value.len().checked_sub(n.checked_mul(find.len())?)?.checked_add(n.checked_mul(with.len())?)?; + (len <= MAX_VALUE).then(|| value.replace(find, with)) +} + +/// Replace `find` with `with` in the given attributes; returns how many changed. +fn replace_all(s: &mut Session, items: &[(Handle, usize)], find: &str, with: &str) -> Result { + let mut n = 0; + for it in items { + if edit(s, *it, |v| replaced(v, find, with))? { + n += 1; + } + } + Ok(n) +} + +fn run_global(s: &mut Session, p: &Value) -> Result { + let find = str_param(p, "find").filter(|f| !f.is_empty()).ok_or_else(|| bad("-attedit", "`find` (a non-empty string) is required"))?; + let with = str_param(p, "replace").unwrap_or(""); + let spec = |k| str_param(p, k).unwrap_or("*").to_string(); + let specs = Specs { block: spec("block"), tag: spec("tag"), value: spec("value") }; + let hs = if p.get("handles").is_some() || p.get("handle").is_some() { targets(s, p)? } else { all_in_space(s)? }; + let items = matching(s, &hs, &specs, true)?; + let (find, with) = (find.to_string(), with.to_string()); + Ok(json!({ "changed": replace_all(s, &items, &find, &with)? })) +} + +// ---------------- ATTEDIT / EATTEDIT ---------------- + +#[derive(Default)] +struct AtteditM { + block: Option, + /// Index, tag and current value of each editable attribute of the picked block reference. + attribs: Vec<(usize, String, String)>, + /// The values answered so far, in attribute order. + values: Vec, +} + +impl AtteditM { + fn pick(&mut self, s: &Session, hs: &[Handle]) -> Result<()> { + let d = s.doc()?; + let mut msg = "Select a block reference."; + for h in hs { + let Some(EntityKind::Insert(ins)) = d.entity(*h).map(|e| &e.kind) else { continue }; + let atts: Vec<(usize, String, String)> = + ins.attribs.iter().enumerate().filter(|(_, a)| !a.constant).map(|(k, a)| (k, a.tag.clone(), a.text.value.clone())).collect(); + if atts.is_empty() { + msg = "That block reference has no editable attributes."; + continue; + } + self.block = Some(*h); + self.attribs = atts; + return Ok(()); + } + Err(EngineError::Other(msg.into())) + } +} + +impl Interactive for AtteditM { + fn name(&self) -> &'static str { + "ATTEDIT" + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.attribs.get(self.values.len()) { + Some((_, tag, cur)) if self.block.is_some() => Prompt::new(format!("Enter value for {tag}"), Accept::TEXT).default(cur.clone()), + _ => Prompt::new("Select block reference", Accept::SELECT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let Some(h) = self.block else { + return match i { + Input::Pick(hs) => self.pick(s, &hs).map(|_| Step::Continue), + Input::Enter => Ok(Step::Done), + Input::Text(_) | Input::Keyword(_) => Err(EngineError::Other("Select a block reference.".into())), + _ => Ok(Step::Continue), + }; + }; + let cur = self.attribs.get(self.values.len()).map(|a| a.2.clone()).unwrap_or_default(); + self.values.push(match i { + Input::Text(t) => t, + Input::Enter => cur, + _ => return Ok(Step::Continue), + }); + if self.values.len() < self.attribs.len() { + return Ok(Step::Continue); + } + // All answered: apply them together (one undo step). + for ((k, _, _), v) in self.attribs.iter().zip(&self.values) { + edit(s, (h, *k), |_| Some(v.clone()))?; + } + Ok(Step::Done) + } +} + +// ---------------- -ATTEDIT ---------------- + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Stage { + #[default] + OneAtATime, + VisibleOnly, + BlockSpec, + TagSpec, + ValueSpec, + Select, + /// One at a time: what to do with the current attribute. + Option, + ValueKind, + NewValue, + Find, + Replace, +} + +#[derive(Default)] +struct DashAtteditM { + stage: Stage, + one_at_a_time: bool, + visible_only: bool, + specs: Specs, + sel: SelectPhase, + items: Vec<(Handle, usize)>, + /// The attribute being edited one at a time (index into `items`). + cur: usize, + find: String, +} + +fn invalid_keyword() -> EngineError { + EngineError::Other("Invalid option keyword.".into()) +} + +impl DashAtteditM { + fn yes(i: &Input) -> Result { + match i { + Input::Keyword(k) => Ok(k == "Yes"), + Input::Enter => Ok(true), + _ => Err(invalid_keyword()), + } + } + /// The attributes are chosen: start editing them. + fn chosen(&mut self, s: &mut Session, hs: &[Handle]) -> Result { + self.items = matching(s, hs, &self.specs, !self.visible_only && !self.one_at_a_time)?; + s.set_selection(Vec::new()); + let n = self.items.len(); + s.echo(format!("{n} attribute{} selected.", if n == 1 { "" } else { "s" })); + if n == 0 { + return Ok(Step::Done); + } + if self.one_at_a_time { + self.cur = 0; + self.show_current(s); + self.stage = Stage::Option; + } else { + self.stage = Stage::Find; + } + Ok(Step::Continue) + } + fn show_current(&self, s: &mut Session) { + let Some((h, k)) = self.items.get(self.cur).copied() else { return }; + if let Ok(d) = s.doc() + && let Some(EntityKind::Insert(ins)) = d.entity(h).map(|e| &e.kind) + && let Some(a) = ins.attribs.get(k) + { + let line = format!("{} = {}", a.tag, a.text.value); + s.echo(line); + } + } +} + +impl Interactive for DashAtteditM { + fn name(&self) -> &'static str { + "-ATTEDIT" + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.stage { + Stage::OneAtATime => Prompt::new("Edit attributes one at a time?", KW).kw(&["Yes", "No"]).default("Y"), + Stage::VisibleOnly => Prompt::new("Edit only attributes visible on screen?", KW).kw(&["Yes", "No"]).default("Y"), + Stage::BlockSpec => Prompt::new("Enter block name specification", Accept::TEXT).default("*"), + Stage::TagSpec => Prompt::new("Enter attribute tag specification", Accept::TEXT).default("*"), + Stage::ValueSpec => Prompt::new("Enter attribute value specification", Accept::TEXT).default("*"), + Stage::Select => Prompt::new("Select attributes", Accept::SELECT), + Stage::Option => Prompt::new("Enter an option", KW).kw(&["Value", "Next"]).default("N"), + Stage::ValueKind => Prompt::new("Enter type of value modification", KW).kw(&["Change", "Replace"]).default("R"), + Stage::NewValue => Prompt::new("Enter new attribute value", Accept::TEXT), + Stage::Find => Prompt::new("Enter string to change", Accept::TEXT), + Stage::Replace => Prompt::new("Enter new string", Accept::TEXT), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + // Free-text answers; Enter gives "" (the specifications then take their default "*"). + let text = match &i { + Input::Text(t) => Some(t.clone()), + Input::Enter => Some(String::new()), + _ => None, + }; + match self.stage { + Stage::OneAtATime => { + self.one_at_a_time = Self::yes(&i)?; + self.stage = if self.one_at_a_time { + Stage::BlockSpec + } else { + s.echo("Performing global editing of attribute values."); + Stage::VisibleOnly + }; + } + Stage::VisibleOnly => { + self.visible_only = Self::yes(&i)?; + self.stage = Stage::BlockSpec; + } + Stage::BlockSpec | Stage::TagSpec | Stage::ValueSpec => { + let Some(t) = text else { return Ok(Step::Continue) }; + let t = if t.trim().is_empty() { "*".to_string() } else { t.trim().to_string() }; + match self.stage { + Stage::BlockSpec => { + self.specs.block = t; + self.stage = Stage::TagSpec; + } + Stage::TagSpec => { + self.specs.tag = t; + self.stage = Stage::ValueSpec; + } + _ => { + self.specs.value = t; + if !self.one_at_a_time && !self.visible_only { + let hs = all_in_space(s)?; + return self.chosen(s, &hs); + } + self.stage = Stage::Select; + } + } + } + Stage::Select => { + return match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => self.chosen(s, &[]), + SelOutcome::Done(hs) => self.chosen(s, &hs), + }; + } + Stage::Option => match &i { + Input::Keyword(k) if k == "Value" => self.stage = Stage::ValueKind, + Input::Keyword(_) | Input::Enter => { + self.cur += 1; + if self.cur >= self.items.len() { + return Ok(Step::Done); + } + self.show_current(s); + } + Input::Text(t) => { + let tl = t.trim().to_ascii_lowercase(); + let later = ["Position", "Height", "Angle", "Style", "Layer", "Color"]; + return match later.iter().find(|k| !tl.is_empty() && k.to_ascii_lowercase().starts_with(&tl)) { + Some(k) => Err(EngineError::Other(format!("{k} is not available yet."))), + None => Err(invalid_keyword()), + }; + } + _ => {} + }, + Stage::ValueKind => match &i { + Input::Keyword(k) if k == "Change" => self.stage = Stage::Find, + Input::Keyword(_) | Input::Enter => self.stage = Stage::NewValue, + _ => return Err(invalid_keyword()), + }, + Stage::NewValue => { + let Some(v) = text else { return Ok(Step::Continue) }; + if let Some(it) = self.items.get(self.cur).copied() { + edit(s, it, |_| Some(v))?; + } + self.show_current(s); + self.stage = Stage::Option; + } + Stage::Find => match text { + Some(t) if !t.is_empty() => { + self.find = t; + self.stage = Stage::Replace; + } + Some(_) => return Err(EngineError::Other("Requires the string to change.".into())), + None => {} + }, + Stage::Replace => { + let Some(with) = text else { return Ok(Step::Continue) }; + let find = std::mem::take(&mut self.find); + if self.one_at_a_time { + let items: Vec<(Handle, usize)> = self.items.get(self.cur).copied().into_iter().collect(); + replace_all(s, &items, &find, &with)?; + self.show_current(s); + self.stage = Stage::Option; + } else { + let n = replace_all(s, &self.items, &find, &with)?; + s.echo(format!("{n} attribute{} changed.", if n == 1 { "" } else { "s" })); + return Ok(Step::Done); + } + } + } + Ok(Step::Continue) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn replacement_is_bounded_and_specs_take_lists() { + assert_eq!(replaced("a-b-c", "-", "+").as_deref(), Some("a+b+c")); + assert_eq!(replaced("abc", "x", "y"), None); + assert_eq!(replaced("aaaa", "a", &"x".repeat(MAX_VALUE)), None); + assert!(spec_matches("door,WIN*", "window")); + assert!(spec_matches("\\", "")); + assert!(!spec_matches("\\", "x")); + } +} diff --git a/crates/engine/tests/attedit_prompts.rs b/crates/engine/tests/attedit_prompts.rs new file mode 100644 index 00000000..843252bc --- /dev/null +++ b/crates/engine/tests/attedit_prompts.rs @@ -0,0 +1,111 @@ +//! ATTEDIT / -ATTEDIT typed at the command line, and attribute tags matched in any case. + +use cadcraft_doc::{EntityKind, Handle}; +use cadcraft_engine::Session; +use serde_json::{Value, json}; + +/// Block B: a line from 0,0 to 10,0 with attributes T (default "one") and U (default "u"). +fn with_block() -> Session { + let mut s = Session::new(); + s.execute("attdef", &json!({"tag": "T", "default": "one", "at": [0, 1]})).unwrap(); + s.execute("attdef", &json!({"tag": "U", "default": "u", "at": [0, 2]})).unwrap(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + let hs: Vec = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect(); + s.execute("block", &json!({"name": "B", "base": [0, 0], "handles": hs, "keep": "delete"})).unwrap(); + s +} + +fn insert(s: &mut Session, x: f64, attribs: Value) -> Value { + s.execute("insert", &json!({"name": "B", "at": [x, 0], "attribs": attribs})).unwrap() +} + +fn handle(r: &Value) -> Handle { + Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap() +} + +fn value(s: &Session, h: Handle, tag: &str) -> String { + let EntityKind::Insert(i) = &s.doc().unwrap().entity(h).unwrap().kind else { panic!("not an insert") }; + i.attribs.iter().find(|a| a.tag == tag).unwrap().text.value.clone() +} + +fn type_lines(s: &mut Session, lines: &[&str]) { + for l in lines { + s.cmdline(l).unwrap(); + } +} + +#[test] +fn attedit_asks_for_a_block_then_each_value() { + let mut s = with_block(); + let h = handle(&insert(&mut s, 100.0, json!({}))); + s.cmdline("attedit").unwrap(); + assert!(s.prompt_text().contains("Select block reference"), "{}", s.prompt_text()); + s.cmdline("105,0").unwrap(); + assert!(s.prompt_text().contains("Enter value for T "), "{}", s.prompt_text()); + // A value with spaces; Enter keeps U as it was. + type_lines(&mut s, &["new value", ""]); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(value(&s, h, "T"), "new value"); + assert_eq!(value(&s, h, "U"), "u"); + // One undo step. + s.undo().unwrap(); + assert_eq!(value(&s, h, "T"), "one"); + + // EATTEDIT is the same command; a pick on nothing editable re-prompts. + s.execute("line", &json!({"points": [[0, 50], [10, 50]]})).unwrap(); + type_lines(&mut s, &["eattedit", "5,50"]); + assert!(s.log.iter().any(|l| l.contains("Select a block reference.")), "{:?}", s.log); + type_lines(&mut s, &["105,0", "x", "y"]); + assert!(s.running.is_none()); + assert_eq!((value(&s, h, "T"), value(&s, h, "U")), ("x".into(), "y".into())); +} + +#[test] +fn dash_attedit_global_replaces_a_string_in_matching_attributes() { + let mut s = with_block(); + let a = handle(&insert(&mut s, 100.0, json!({"T": "A-1", "U": "A-9"}))); + let b = handle(&insert(&mut s, 200.0, json!({"T": "A-2"}))); + // Not one at a time, not only visible ones: every reference of B, tag T, any value. + s.script("-attedit\nn\nn\nb\nt\n\nA-\nX-\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!((value(&s, a, "T"), value(&s, b, "T")), ("X-1".into(), "X-2".into())); + assert_eq!(value(&s, a, "U"), "A-9", "tag specification filters"); + assert!(s.log.iter().any(|l| l == "2 attributes changed."), "{:?}", s.log); + + // The JSON form does the same. + let r = s.execute("-attedit", &json!({"tag": "u", "find": "9", "replace": "8"})).unwrap(); + assert_eq!(r["changed"], 1); + assert_eq!(value(&s, a, "U"), "A-8"); +} + +#[test] +fn dash_attedit_one_at_a_time_edits_the_selected_attributes() { + let mut s = with_block(); + let h = handle(&insert(&mut s, 100.0, json!({"T": "abc"}))); + type_lines(&mut s, &["-attedit", "y", "", "T", "", "105,0", ""]); + assert!(s.prompt_text().contains("[Value/Next] "), "{}", s.prompt_text()); + // Unsupported options say so and keep the prompt. + s.cmdline("p").unwrap(); + assert!(s.log.iter().any(|l| l.contains("Position is not available yet")), "{:?}", s.log); + type_lines(&mut s, &["v", "c", "b", "B", "v", "r", "replaced value", "n"]); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(value(&s, h, "T"), "replaced value"); + assert_eq!(value(&s, h, "U"), "u"); +} + +#[test] +fn attribute_tags_match_in_any_case_and_unknown_tags_are_reported() { + let mut s = with_block(); + let r = insert(&mut s, 100.0, json!({"t": "two", "nope": "x"})); + let h = handle(&r); + assert_eq!(value(&s, h, "T"), "two"); + assert_eq!(r["unknownTags"], json!(["nope"])); + + let r = s.execute("attedit", &json!({"handle": h.hex(), "values": {"t": "three", "u": 4, "W": "w"}})).unwrap(); + assert_eq!(r["changed"], 2); + assert_eq!(r["unknownTags"], json!(["W"])); + assert_eq!((value(&s, h, "T"), value(&s, h, "U")), ("three".into(), "4".into())); + + let r = s.execute("attedit", &json!({"handle": h.hex(), "values": {"T": "four"}})).unwrap(); + assert_eq!(r, json!({"changed": 1})); +} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5625e065..d7e08804 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -289,6 +289,7 @@ Geometry Геометрія Get Variable Отримати змінну Get View Отримати вид + Global Глобально Global width Загальна ширина Grab bag Список Gradient Градієнт From 2483676ba6493abbb11a26db62848bf53d5e5e79 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:55:39 +0200 Subject: [PATCH 099/150] -PURGE: type, names and verify prompts; purge by type (fixes #262) (#263) Typed -PURGE (PURGE has no dialog yet) now asks for the type of unused objects, the names to purge <*> and whether to verify each name, instead of purging everything at once and leaving the rest of the line to run as commands. Verify Yes confirms each name; No purges every match. Types CADCraft does not have read their answers and report nothing to purge; Zero-length geometry and Empty text objects purge at once. The JSON form takes {type, names}: LA purges only layers. All covers every named type, so unused text, dimension, multileader and table styles and empty groups are purged as the params doc promised. Layers and text styles go through layer.delete and style.delete. Linetypes used only inside block definitions, or set as CELTYPE, are kept. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/blocks.rs | 61 +--- crates/engine/src/cmd/blocks/purge.rs | 499 ++++++++++++++++++++++++++ crates/engine/src/cmd/props.rs | 2 +- crates/engine/tests/purge_prompts.rs | 102 ++++++ 4 files changed, 608 insertions(+), 56 deletions(-) create mode 100644 crates/engine/src/cmd/blocks/purge.rs create mode 100644 crates/engine/tests/purge_prompts.rs diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index d1ced7ee..56967434 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -1,5 +1,7 @@ //! Blocks: BLOCK, INSERT, ATTDEF, ATTEDIT (programmatic), WBLOCK, BASE, PURGE, block listing. +mod purge; + use std::sync::Arc; use cadcraft_doc::{Attrib, Block, Entity, EntityKind, HAlign, Handle, Insert, Text, VAlign}; @@ -39,7 +41,10 @@ pub fn specs() -> Vec { attedit::dash_attedit_spec(), CommandSpec::new("wblock", "Write Block", run_wblock).alias(&["w"]).params("{path, name? | handles?, base?}"), CommandSpec::new("base", "Base", run_base).menu(&["Draw", "Block", "Base"]).params("{at: [x,y]}"), - CommandSpec::new("purge", "Purge", run_purge).alias(&["pu", "-purge"]).params("{} (unused blocks, layers, linetypes, styles)"), + CommandSpec::new("purge", "Purge", purge::run) + .alias(&["pu", "-purge"]) + .params("{type?: all|blocks|dimstyles|groups|layers|linetypes|mleaderstyles|tablestyles|textstyles|zerolength|emptytext (default all: every named type), names?: \"A*,B\" (default *)} → purged") + .interactive(|_| Ok(Box::new(purge::PurgeM::default()))), CommandSpec::new("blocks.list", "List Blocks", run_list).enabled(has_doc).noundo(), CommandSpec::new("battman", "Block Attribute Manager...", run_battman) .menu(&["Modify", "Object", "Attribute", "Block Attribute Manager..."]) @@ -289,60 +294,6 @@ fn run_base(s: &mut Session, p: &Value) -> Result { ok() } -fn run_purge(s: &mut Session, _p: &Value) -> Result { - let d = s.doc_mut()?; - // Blocks referenced anywhere (including nested references), iterated to a fixed point. - let mut used: std::collections::HashSet = std::collections::HashSet::new(); - let all: Vec> = d.model.iter().cloned().chain(d.layouts.iter().flat_map(|l| l.entities.iter().cloned())).collect(); - let mut frontier: Vec = - all.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }).collect(); - for e in &all { - if let EntityKind::Dimension(dm) = &e.kind - && let Some(b) = &dm.block - { - frontier.push(b.clone()); - } - } - let mut guard = 0; - while let Some(n) = frontier.pop() { - guard += 1; - if guard > 100_000 || !used.insert(n.to_ascii_uppercase()) { - continue; - } - if let Some(b) = d.block(&n) { - for e in b.entities.iter() { - if let EntityKind::Insert(i) = &e.kind { - frontier.push(i.block.clone()); - } - } - } - } - let before_b = d.blocks.len(); - d.blocks.retain(|k, _| used.contains(&k.to_ascii_uppercase())); - let blocks = before_b - d.blocks.len(); - // Layers used by the model, layouts and the block definitions that survived above. - let used_layers = super::layer::used_layers(d); - let cur = d.header.str("CLAYER", "0").to_ascii_lowercase(); - let before_l = d.layers.len(); - d.layers.retain(|l| { - l.name == "0" - || l.name.eq_ignore_ascii_case("Defpoints") - || l.name.to_ascii_lowercase() == cur - || used_layers.contains(&l.name.to_ascii_lowercase()) - }); - let layers = before_l - d.layers.len(); - let used_lt: std::collections::HashSet = - all.iter().map(|e| e.common.linetype.to_ascii_lowercase()).chain(d.layers.iter().map(|l| l.linetype.to_ascii_lowercase())).collect(); - let before_t = d.linetypes.len(); - d.linetypes.retain(|l| { - ["byblock", "bylayer", "continuous"].contains(&l.name.to_ascii_lowercase().as_str()) || used_lt.contains(&l.name.to_ascii_lowercase()) - }); - let linetypes = before_t - d.linetypes.len(); - Ok( - json!({ "blocks": blocks, "layers": layers, "linetypes": linetypes, "message": format!("{blocks} blocks, {layers} layers, {linetypes} linetypes purged.") }), - ) -} - fn run_list(s: &mut Session, _p: &Value) -> Result { let d = s.doc()?; Ok( diff --git a/crates/engine/src/cmd/blocks/purge.rs b/crates/engine/src/cmd/blocks/purge.rs new file mode 100644 index 00000000..504e0cdb --- /dev/null +++ b/crates/engine/src/cmd/blocks/purge.rs @@ -0,0 +1,499 @@ +//! PURGE / -PURGE: remove unused named objects (blocks, layers, linetypes, styles, groups) and +//! zero-length geometry or empty text. +//! +//! The JSON form takes `{type?, names?}` and purges without asking. Typed, it asks for the type, +//! the names and whether to verify each one, as AutoCAD's -PURGE does. + +use std::collections::{HashSet, VecDeque}; + +use cadcraft_doc::{Drawing, EntityKind, Handle}; +use serde_json::{Map, Value, json}; + +use super::super::qselect::wildcard; +use super::super::{bad, find_command, str_param}; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Blocks, + Dimstyles, + Groups, + Layers, + Linetypes, + MleaderStyles, + TableStyles, + TextStyles, + ZeroLength, + EmptyText, + /// An object type CADCraft does not have: nothing to purge. + Absent, + All, +} + +/// Prompt keyword, JSON names (normalised: lower case, letters and digits only), kind. +const KINDS: &[(&str, &[&str], Kind)] = &[ + ("Blocks", &["blocks", "block"], Kind::Blocks), + ("DEtailviewstyles", &["detailviewstyles"], Kind::Absent), + ("Dimstyles", &["dimstyles", "dimstyle"], Kind::Dimstyles), + ("Groups", &["groups", "group"], Kind::Groups), + ("LAyers", &["layers", "layer"], Kind::Layers), + ("LTypes", &["ltypes", "linetypes", "linetype"], Kind::Linetypes), + ("MAterials", &["materials"], Kind::Absent), + ("MUltileaderstyles", &["multileaderstyles", "mleaderstyles", "mleaderstyle"], Kind::MleaderStyles), + ("Plotstyles", &["plotstyles"], Kind::Absent), + ("SHapes", &["shapes"], Kind::Absent), + ("textSTyles", &["textstyles", "textstyle", "styles", "style"], Kind::TextStyles), + ("Mlinestyles", &["mlinestyles"], Kind::Absent), + ("SEctionviewstyles", &["sectionviewstyles"], Kind::Absent), + ("Tablestyles", &["tablestyles", "tablestyle"], Kind::TableStyles), + ("Visualstyles", &["visualstyles"], Kind::Absent), + ("Regapps", &["regapps"], Kind::Absent), + ("Zero-length geometry", &["zerolengthgeometry", "zerolength"], Kind::ZeroLength), + ("Empty text objects", &["emptytextobjects", "emptytext"], Kind::EmptyText), + ("Orphaned data", &["orphaneddata", "orphaned"], Kind::Absent), + ("All", &["all"], Kind::All), +]; + +/// The named kinds `All` purges, in an order where purging one frees the next (blocks free +/// layers, layers free linetypes, dimension styles free text styles). +const ALL: &[Kind] = + &[Kind::Blocks, Kind::Groups, Kind::Dimstyles, Kind::MleaderStyles, Kind::TableStyles, Kind::Layers, Kind::Linetypes, Kind::TextStyles]; + +fn norm(s: &str) -> String { + s.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase() +} + +fn kind_of(name: &str) -> Option<(&'static str, Kind)> { + let n = norm(name); + KINDS.iter().find(|(k, alias, _)| norm(k) == n || alias.contains(&n.as_str())).map(|(k, _, kind)| (*k, *kind)) +} + +/// Singular noun for messages. +fn noun(kind: Kind) -> &'static str { + match kind { + Kind::Blocks => "block", + Kind::Dimstyles => "dimension style", + Kind::Groups => "group", + Kind::Layers => "layer", + Kind::Linetypes => "linetype", + Kind::MleaderStyles => "multileader style", + Kind::TableStyles => "table style", + Kind::TextStyles => "text style", + Kind::ZeroLength => "zero-length object", + Kind::EmptyText => "empty text object", + Kind::Absent | Kind::All => "object", + } +} + +fn json_key(kind: Kind) -> &'static str { + match kind { + Kind::Blocks => "blocks", + Kind::Dimstyles => "dimstyles", + Kind::Groups => "groups", + Kind::Layers => "layers", + Kind::Linetypes => "linetypes", + Kind::MleaderStyles => "mleaderstyles", + Kind::TableStyles => "tablestyles", + Kind::TextStyles => "textstyles", + Kind::ZeroLength => "zeroLength", + Kind::EmptyText => "emptyText", + Kind::Absent | Kind::All => "other", + } +} + +fn entities(d: &Drawing) -> impl Iterator> { + d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).chain(d.blocks.values().flat_map(|b| b.entities.iter())).take(5_000_000) +} + +/// Upper-case names of blocks reachable from model space, the layouts and dimensions. +fn used_blocks(d: &Drawing) -> HashSet { + let mut used = HashSet::new(); + let mut frontier: Vec = Vec::new(); + for e in d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())) { + match &e.kind { + EntityKind::Insert(i) => frontier.push(i.block.clone()), + EntityKind::Dimension(dm) => frontier.extend(dm.block.clone()), + _ => {} + } + } + let mut guard = 0; + while let Some(n) = frontier.pop() { + guard += 1; + if guard > 100_000 || !used.insert(n.to_ascii_uppercase()) { + continue; + } + if let Some(b) = d.block(&n) { + for e in b.entities.iter() { + match &e.kind { + EntityKind::Insert(i) => frontier.push(i.block.clone()), + EntityKind::Dimension(dm) => frontier.extend(dm.block.clone()), + _ => {} + } + } + } + } + used +} + +fn is(name: &str, list: &[&str]) -> bool { + list.iter().any(|n| n.eq_ignore_ascii_case(name)) +} + +/// Names of the unused objects of a named kind, in table order. +fn candidates(d: &Drawing, kind: Kind) -> Vec { + let lower = |it: &mut dyn Iterator| it.map(|n| n.to_ascii_lowercase()).collect::>(); + match kind { + Kind::Blocks => { + let used = used_blocks(d); + d.blocks.values().filter(|b| !used.contains(&b.name.to_ascii_uppercase())).map(|b| b.name.clone()).collect() + } + Kind::Layers => { + let used = super::super::layer::used_layers(d); + let cur = d.header.str("CLAYER", "0"); + d.layers + .iter() + .filter(|l| !is(&l.name, &["0", "Defpoints", &cur]) && !used.contains(&l.name.to_ascii_lowercase())) + .map(|l| l.name.clone()) + .collect() + } + Kind::Linetypes => { + let used = lower(&mut entities(d).map(|e| e.common.linetype.clone()).chain(d.layers.iter().map(|l| l.linetype.clone()))); + let cur = d.header.str("CELTYPE", "ByLayer"); + d.linetypes + .iter() + .filter(|t| !is(&t.name, &["ByBlock", "ByLayer", "Continuous", &cur]) && !used.contains(&t.name.to_ascii_lowercase())) + .map(|t| t.name.clone()) + .collect() + } + Kind::TextStyles => { + let used = lower(&mut super::super::props::text_styles_in_use(d).into_iter()); + let cur = d.header.str("TEXTSTYLE", "Standard"); + d.text_styles + .iter() + .filter(|t| !is(&t.name, &["Standard", &cur]) && !used.contains(&t.name.to_ascii_lowercase())) + .map(|t| t.name.clone()) + .collect() + } + Kind::Dimstyles => { + let used = lower(&mut entities(d).filter_map(|e| if let EntityKind::Dimension(dm) = &e.kind { Some(dm.style.clone()) } else { None })); + let cur = d.header.str("DIMSTYLE", "Standard"); + d.dim_styles + .iter() + .filter(|t| !is(&t.name, &["Standard", &cur]) && !used.contains(&t.name.to_ascii_lowercase())) + .map(|t| t.name.clone()) + .collect() + } + Kind::MleaderStyles => { + let used = lower(&mut entities(d).filter_map(|e| if let EntityKind::MLeader(m) = &e.kind { Some(m.style.clone()) } else { None })); + let cur = d.header.str("CMLEADERSTYLE", "Standard"); + d.mleader_styles + .iter() + .filter(|t| !is(&t.name, &["Standard", &cur]) && !used.contains(&t.name.to_ascii_lowercase())) + .map(|t| t.name.clone()) + .collect() + } + Kind::TableStyles => { + let used = lower(&mut entities(d).filter_map(|e| if let EntityKind::Table(t) = &e.kind { Some(t.style.clone()) } else { None })); + let cur = d.header.str("CTABLESTYLE", "Standard"); + d.table_styles + .iter() + .filter(|t| !is(&t.name, &["Standard", &cur]) && !used.contains(&t.name.to_ascii_lowercase())) + .map(|t| t.name.clone()) + .collect() + } + // Groups whose members are all gone. + Kind::Groups => d.groups.iter().filter(|g| !g.members.iter().any(|h| d.entity(*h).is_some())).map(|g| g.name.clone()).collect(), + Kind::ZeroLength | Kind::EmptyText | Kind::Absent | Kind::All => Vec::new(), + } +} + +fn matching(d: &Drawing, kind: Kind, names: &str) -> Vec { + let pats: Vec<&str> = names.split(',').map(str::trim).filter(|p| !p.is_empty()).take(1000).collect(); + candidates(d, kind).into_iter().filter(|n| pats.iter().any(|p| wildcard(p, n))).collect() +} + +/// Run a command body directly: the purge records a single undo step. +fn call(s: &mut Session, id: &str, p: &Value) -> Result { + let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; + (spec.run)(s, p) +} + +/// Purge one named object. `false` when it is in use after all (or gone). +fn purge_one(s: &mut Session, kind: Kind, name: &str) -> Result { + let d = s.doc_mut()?; + let n = name.to_string(); + let gone = match kind { + Kind::Layers => return Ok(call(s, "layer.delete", &json!({ "name": n })).is_ok()), + Kind::TextStyles => return Ok(call(s, "style.delete", &json!({ "name": n })).is_ok()), + Kind::Blocks => { + let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned(); + key.is_some_and(|k| d.blocks.remove(&k).is_some()) + } + Kind::Linetypes => retain(&mut d.linetypes, |t| &t.name, name), + Kind::Dimstyles => retain(&mut d.dim_styles, |t| &t.name, name), + Kind::MleaderStyles => retain(&mut d.mleader_styles, |t| &t.name, name), + Kind::TableStyles => retain(&mut d.table_styles, |t| &t.name, name), + Kind::Groups => retain(&mut d.groups, |g| &g.name, name), + Kind::ZeroLength | Kind::EmptyText | Kind::Absent | Kind::All => false, + }; + Ok(gone) +} + +fn retain(v: &mut Vec, name: impl Fn(&T) -> &String, n: &str) -> bool { + let before = v.len(); + v.retain(|t| !name(t).eq_ignore_ascii_case(n)); + v.len() != before +} + +/// Model-space and layout objects with no length, or text with no characters. +fn degenerate(d: &Drawing, kind: Kind) -> Vec { + let zero = |e: &EntityKind| match e { + EntityKind::Line(l) => l.a == l.b, + EntityKind::Circle(c) => c.radius == 0.0, + EntityKind::Arc(a) => a.radius == 0.0, + EntityKind::LwPolyline(p) => p.vertices.first().is_some_and(|f| p.vertices.iter().all(|v| v.p == f.p)), + EntityKind::Polyline3d(p) => p.points.first().is_some_and(|f| p.points.iter().all(|v| v == f)), + _ => false, + }; + let empty = |e: &EntityKind| match e { + EntityKind::Text(t) => t.value.trim().is_empty(), + EntityKind::MText(t) => t.contents.trim().is_empty(), + _ => false, + }; + d.model + .iter() + .chain(d.layouts.iter().flat_map(|l| l.entities.iter())) + .filter(|e| if kind == Kind::ZeroLength { zero(&e.kind) } else { empty(&e.kind) }) + .map(|e| e.handle) + .collect() +} + +/// Purges and the messages it produced, collected for the JSON result. +#[derive(Default)] +struct Report { + purged: Map, + lines: Vec, +} + +impl Report { + fn add(&mut self, kind: Kind, name: &str) { + self.lines.push(format!("Deleting {} \"{name}\".", noun(kind))); + if let Value::Array(a) = self.purged.entry(json_key(kind)).or_insert_with(|| Value::Array(Vec::new())) { + a.push(json!(name)); + } + } + fn count(&self, kind: Kind) -> usize { + self.purged.get(json_key(kind)).and_then(Value::as_array).map_or(0, Vec::len) + } + fn summary(&mut self, kind: Kind) { + let n = self.count(kind); + let noun = noun(kind); + self.lines.push(match n { + 0 => format!("No unreferenced {noun}s found."), + 1 => format!("1 {noun} deleted."), + n => format!("{n} {noun}s deleted."), + }); + } + fn json(self) -> Value { + let msg = self.lines.join("\n"); + let count = |k: &str| self.purged.get(k).and_then(Value::as_array).map_or(0, Vec::len); + json!({ + "blocks": count("blocks"), "layers": count("layers"), "linetypes": count("linetypes"), + "purged": Value::Object(self.purged.clone()), "message": msg, + }) + } +} + +/// Purge everything matching `names` for one kind (zero-length and empty text ignore names). +fn purge_kind(s: &mut Session, kind: Kind, names: &str, r: &mut Report) -> Result<()> { + match kind { + Kind::All => { + for k in ALL { + purge_kind(s, *k, names, r)?; + } + return Ok(()); + } + Kind::Absent => { + r.lines.push("No unreferenced objects of this type found.".into()); + return Ok(()); + } + Kind::ZeroLength | Kind::EmptyText => { + let d = s.doc_mut()?; + for h in degenerate(d, kind) { + if d.remove_entity(h).is_some() { + r.add(kind, &h.hex()); + } + } + } + _ => { + for n in matching(s.doc()?, kind, names) { + if purge_one(s, kind, &n)? { + r.add(kind, &n); + } + } + } + } + r.summary(kind); + Ok(()) +} + +pub(in crate::cmd) fn run(s: &mut Session, p: &Value) -> Result { + let ty = str_param(p, "type").unwrap_or("all"); + let (_, kind) = kind_of(ty).ok_or_else(|| bad("purge", format!("unknown type `{ty}`")))?; + let names = str_param(p, "names").unwrap_or("*"); + let mut r = Report::default(); + purge_kind(s, kind, names, &mut r)?; + s.set_selection(Vec::new()); + Ok(r.json()) +} + +// ---------------- -PURGE prompts ---------------- + +/// Keyword-only: spaces separate inputs. +const KW: Accept = Accept { point: false, number: false, text: false, select: false, enter: true }; +/// A name list: Space ends the input. +const NAME: Accept = Accept { number: true, ..Accept::TEXT }; + +#[derive(Default)] +enum Stage { + #[default] + Type, + Names(Kind), + Verify(Kind, String), + /// Confirm each name: the kind being asked about, its remaining names, the kinds still to + /// come (for All) and the name pattern. + Confirm { + kind: Kind, + queue: VecDeque, + rest: VecDeque, + names: String, + }, +} + +#[derive(Default)] +pub(in crate::cmd) struct PurgeM { + stage: Stage, + report: Report, +} + +impl PurgeM { + fn finish(&mut self, s: &mut Session) -> Result { + for l in std::mem::take(&mut self.report.lines) { + s.echo(l); + } + s.set_selection(Vec::new()); + Ok(Step::Done) + } + + /// Move to the next kind with candidates, echoing a summary for each finished one. + fn next_confirm(&mut self, s: &mut Session, mut rest: VecDeque, names: String) -> Result { + while let Some(kind) = rest.pop_front() { + let queue: VecDeque = matching(s.doc()?, kind, &names).into(); + if queue.is_empty() { + self.report.summary(kind); + continue; + } + self.stage = Stage::Confirm { kind, queue, rest, names }; + for l in std::mem::take(&mut self.report.lines) { + s.echo(l); + } + return Ok(Step::Continue); + } + self.finish(s) + } +} + +impl Interactive for PurgeM { + fn name(&self) -> &'static str { + "-PURGE" + } + fn prompt(&self, _s: &Session) -> Prompt { + match &self.stage { + Stage::Type => { + let kws: Vec<&str> = KINDS.iter().map(|(k, _, _)| *k).collect(); + Prompt::new("Enter type of unused objects to purge", KW).kw(&kws) + } + Stage::Names(_) => Prompt::new("Enter name(s) to purge", NAME).default("*"), + Stage::Verify(..) => Prompt::new("Verify each name to be purged?", KW).kw(&["Yes", "No"]).default("Y"), + Stage::Confirm { kind, queue, .. } => { + let n = queue.front().map(String::as_str).unwrap_or(""); + Prompt::new(format!("Purge {} \"{n}\"?", noun(*kind)), KW).kw(&["Yes", "No"]).default("N") + } + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if i == Input::Cancel { + return Ok(Step::Cancel); + } + match std::mem::take(&mut self.stage) { + Stage::Type => { + let Input::Keyword(k) = &i else { + if i == Input::Enter { + return Ok(Step::Done); + } + return Err(EngineError::Other("Invalid option keyword.".into())); + }; + let Some((_, kind)) = kind_of(k) else { return Err(EngineError::Other("Invalid option keyword.".into())) }; + // Zero-length geometry, empty text and orphaned data take no names. + match kind { + Kind::ZeroLength | Kind::EmptyText => { + purge_kind(s, kind, "*", &mut self.report)?; + self.finish(s) + } + Kind::Absent if k == "Orphaned data" => { + purge_kind(s, kind, "*", &mut self.report)?; + self.finish(s) + } + _ => { + self.stage = Stage::Names(kind); + Ok(Step::Continue) + } + } + } + Stage::Names(kind) => { + let names = match &i { + Input::Text(t) | Input::Keyword(t) if !t.trim().is_empty() => t.trim().to_string(), + _ => "*".into(), + }; + self.stage = Stage::Verify(kind, names); + Ok(Step::Continue) + } + Stage::Verify(kind, names) => match &i { + Input::Keyword(k) if k == "No" => { + purge_kind(s, kind, &names, &mut self.report)?; + self.finish(s) + } + Input::Keyword(_) | Input::Enter => { + let rest: VecDeque = if kind == Kind::All { ALL.iter().copied().collect() } else { VecDeque::from([kind]) }; + self.next_confirm(s, rest, names) + } + _ => { + self.stage = Stage::Verify(kind, names); + Err(EngineError::Other("Enter Yes or No.".into())) + } + }, + Stage::Confirm { kind, mut queue, rest, names } => { + let yes = matches!(&i, Input::Keyword(k) if k == "Yes"); + if !yes && !matches!(&i, Input::Keyword(_) | Input::Enter) { + self.stage = Stage::Confirm { kind, queue, rest, names }; + return Err(EngineError::Other("Enter Yes or No.".into())); + } + if let Some(n) = queue.pop_front() + && yes + && purge_one(s, kind, &n)? + { + self.report.add(kind, &n); + if let Some(l) = self.report.lines.pop() { + s.echo(l); + } + } + if queue.is_empty() { + self.report.summary(kind); + return self.next_confirm(s, rest, names); + } + self.stage = Stage::Confirm { kind, queue, rest, names }; + Ok(Step::Continue) + } + } + } +} diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 2ad6f385..4c0b9f66 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -526,7 +526,7 @@ fn run_style_current(s: &mut Session, p: &Value) -> Result { } /// Text style names used by entities (in model, paper spaces and blocks) and dimension styles. -fn text_styles_in_use(d: &cadcraft_doc::Drawing) -> Vec { +pub(super) fn text_styles_in_use(d: &cadcraft_doc::Drawing) -> Vec { let mut out: Vec = d.dim_styles.iter().map(|s| s.text_style.clone()).collect(); out.extend(d.mleader_styles.iter().map(|s| s.text_style.clone())); let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities)); diff --git a/crates/engine/tests/purge_prompts.rs b/crates/engine/tests/purge_prompts.rs new file mode 100644 index 00000000..9602c4f3 --- /dev/null +++ b/crates/engine/tests/purge_prompts.rs @@ -0,0 +1,102 @@ +//! -PURGE asks for the type, the names and whether to verify each; the JSON form takes +//! `{type, names}`. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use serde_json::json; + +/// Unused: blocks U1 and U2, layer Spare, linetype DASHED, text style Notes, dimension style Arch. +fn drawing() -> Session { + let mut s = Session::new(); + for name in ["U1", "U2"] { + s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap(); + s.execute("selectall", &json!({})).unwrap(); + s.execute("block", &json!({ "name": name, "base": [0, 0], "keep": "delete" })).unwrap(); + } + s.execute("layer.new", &json!({ "name": "Spare" })).unwrap(); + s.execute("linetype", &json!({ "load": "DASHED" })).unwrap(); + s.execute("style", &json!({ "name": "Notes", "current": false })).unwrap(); + s.execute("dimstyle", &json!({ "name": "Arch", "current": false })).unwrap(); + s +} + +fn kinds(s: &Session) -> (usize, bool, bool, bool, bool) { + let d = s.doc().unwrap(); + (d.blocks.len(), d.layer("Spare").is_some(), d.linetype("DASHED").is_some(), d.text_style("Notes").is_some(), d.dim_style("Arch").is_some()) +} + +fn count bool>(s: &Session, f: F) -> usize { + s.doc().unwrap().model.iter().filter(|e| f(&e.kind)).count() +} + +#[test] +fn layers_only_then_the_next_command_runs() { + let mut s = drawing(); + s.script("-PURGE LA * N\nLINE 0,0 5,0\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(kinds(&s), (2, false, true, true, true), "only the layer is purged: {:?}", s.log); + assert_eq!(count(&s, |k| matches!(k, EntityKind::Line(_))), 1); + assert_eq!(count(&s, |k| matches!(k, EntityKind::Arc(_))), 0); +} + +#[test] +fn all_without_verify_purges_every_named_type() { + let mut s = drawing(); + s.cmdline("-PURGE").unwrap(); + assert!( + s.prompt_text().contains( + "[Blocks/DEtailviewstyles/Dimstyles/Groups/LAyers/LTypes/MAterials/MUltileaderstyles/Plotstyles/SHapes/textSTyles/Mlinestyles/SEctionviewstyles/Tablestyles/Visualstyles/Regapps/Zero-length geometry/Empty text objects/Orphaned data/All]" + ), + "{}", + s.prompt_text() + ); + s.script("A * N\nCIRCLE 0,0 1\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(kinds(&s), (0, false, false, false, false), "{:?}", s.log); + assert_eq!(count(&s, |k| matches!(k, EntityKind::Circle(_))), 1); + assert_eq!(count(&s, |k| matches!(k, EntityKind::Arc(_))), 0); + // One undo step brings everything back. + s.undo().unwrap(); + s.undo().unwrap(); + assert_eq!(kinds(&s), (2, true, true, true, true)); +} + +#[test] +fn verify_asks_for_each_name_and_absent_types_take_their_tokens() { + let mut s = drawing(); + s.script("-PURGE B * Y\nY\nN\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let d = s.doc().unwrap(); + assert!(d.block("U1").is_none() && d.block("U2").is_some(), "{:?}", s.log); + // A type CADCraft does not have still reads its name and verify answers, then reports. + let n = s.log.len(); + s.script("-PURGE MA * N\nLINE 0,0 5,0\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert!(s.log[n..].iter().any(|l| l.contains("No unreferenced")), "{:?}", &s.log[n..]); + assert_eq!(count(&s, |k| matches!(k, EntityKind::Line(_))), 1); + // Zero-length geometry purges at once. + s.execute("line", &json!({ "points": [[3, 3], [3, 3]] })).unwrap(); + s.script("-PURGE Z\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(count(&s, |k| matches!(k, EntityKind::Line(_))), 1); +} + +#[test] +fn json_type_and_names_and_kept_linetypes() { + let mut s = drawing(); + let r = s.execute("purge", &json!({ "type": "blocks", "names": "U2" })).unwrap(); + assert_eq!(r["purged"]["blocks"], json!(["U2"])); + assert_eq!(kinds(&s), (1, true, true, true, true)); + assert!(s.execute("purge", &json!({ "type": "nonsense" })).is_err()); + // A linetype used only inside a block definition, or the current linetype, is kept. + s.execute("linetype", &json!({ "load": "HIDDEN" })).unwrap(); + s.execute("linetype", &json!({ "load": "CENTER" })).unwrap(); + let l = s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap()["handles"][0].as_str().unwrap().to_string(); + s.execute("properties.set", &json!({ "handles": [l], "linetype": "HIDDEN" })).unwrap(); + s.execute("block", &json!({ "name": "Part", "base": [0, 0], "handles": [l], "keep": "convert" })).unwrap(); + s.execute("linetype", &json!({ "current": "CENTER" })).unwrap(); + s.execute("purge", &json!({ "type": "ltypes" })).unwrap(); + let d = s.doc().unwrap(); + assert!(d.linetype("HIDDEN").is_some() && d.linetype("CENTER").is_some()); + assert!(d.linetype("DASHED").is_none()); +} From 3e4eb7a0e9c7d412f1366260fc0aa72905390a07 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:55:44 +0200 Subject: [PATCH 100/150] -LAYER: command-line option loop for layers (fixes #254) (#260) Typed -LAYER (and LAYER in scripts and headless sessions) now starts the option loop instead of listing the layers and ending: ?, Make, Set, New, Rename, ON, OFF, Color (with Truecolor), Ltype, LWeight, TRansparency, Plot, Freeze, Thaw, LOck, Unlock and Description, until Enter. MATerial, stAte and rEconcile report "not available yet" without taking the next token. Every option runs the layer.* command bodies, so the session is one undo step and refusals match the JSON form. Turning the current layer off asks first; freezing it is refused. layer.new and layer.set now reject a linetype that is not loaded (or ByLayer/ByBlock), and layer.set refuses to rename a layer onto the name of another existing layer. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/layer.rs | 32 +- crates/engine/src/cmd/layer/prompts.rs | 454 +++++++++++++++++++++++++ crates/engine/tests/layer_cmdline.rs | 73 ++++ 3 files changed, 557 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/cmd/layer/prompts.rs create mode 100644 crates/engine/tests/layer_cmdline.rs diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 85d2e774..ddaf33ce 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -1,4 +1,6 @@ -//! Layers: LAYER (programmatic sub-ids), layer tools (LAYMCUR, LAYISO, LAYOFF, LAYFRZ…). +//! Layers: LAYER (programmatic sub-ids, -LAYER prompts), layer tools (LAYMCUR, LAYISO, LAYOFF, LAYFRZ…). + +mod prompts; use cadcraft_color::Color; use cadcraft_doc::{Layer, Lineweight}; @@ -10,7 +12,11 @@ use crate::{Result, Session}; pub fn specs() -> Vec { vec![ - CommandSpec::new("layer", "Layers", run_list).menu(&["Format", "Layers"]).alias(&["la", "layers"]).noundo(), + CommandSpec::new("layer", "Layers", run_list) + .menu(&["Format", "Layers"]) + .alias(&["la", "layers", "-layer"]) + .params("{} → layers, states (typed -LAYER: option prompts)") + .interactive(|_| Ok(Box::new(prompts::LayerM::default()))), CommandSpec::new("layer.new", "New Layer", run_new).params("{name, color?, linetype?, lineweight? (mm), current?: bool}"), CommandSpec::new("layer.set", "Set Layer Properties", run_set) .params("{name, on?, frozen?, locked?, plot?, color?, linetype?, lineweight?, transparency?, description?, newVpFreeze?, newName?}"), @@ -155,6 +161,17 @@ fn apply(l: &mut Layer, p: &Value) -> Result<()> { Ok(()) } +/// The loaded linetype named by `linetype`, if given: a layer cannot use ByLayer, ByBlock or a +/// linetype the drawing does not have. +fn loaded_linetype(d: &cadcraft_doc::Drawing, cmd: &str, p: &Value) -> Result> { + let Some(lt) = str_param(p, "linetype") else { return Ok(None) }; + if lt.eq_ignore_ascii_case("ByLayer") || lt.eq_ignore_ascii_case("ByBlock") { + return Err(bad(cmd, "a layer linetype cannot be ByLayer or ByBlock")); + } + let t = d.linetype(lt).ok_or_else(|| bad(cmd, format!("linetype `{lt}` is not loaded (load it with LINETYPE)")))?; + Ok(Some(t.name.clone())) +} + fn valid_name(n: &str) -> bool { !n.trim().is_empty() && n.len() <= 255 && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c)) } @@ -168,8 +185,12 @@ fn run_new(s: &mut Session, p: &Value) -> Result { if d.layer(&name).is_some() { return Err(bad("layer.new", format!("layer `{name}` already exists"))); } + let lt = loaded_linetype(d, "layer.new", p)?; let mut l = Layer::new(&name); apply(&mut l, p)?; + if let Some(lt) = lt { + l.linetype = lt; + } d.layers.push(l); if bool_or(p, "current", false) { d.header.set_str("CLAYER", &name); @@ -186,7 +207,11 @@ fn run_set(s: &mut Session, p: &Value) -> Result { let cur = d.header.str("CLAYER", "0"); let mut updated = d.layer(&name).cloned().ok_or_else(|| bad("layer.set", format!("no layer `{name}`")))?; let old = updated.name.clone(); + let lt = loaded_linetype(d, "layer.set", p)?; apply(&mut updated, p)?; + if let Some(lt) = lt { + updated.linetype = lt; + } if p.get("frozen").and_then(Value::as_bool) == Some(true) && old.eq_ignore_ascii_case(&cur) { return Err(bad("layer.set", "cannot freeze the current layer")); } @@ -197,6 +222,9 @@ fn run_set(s: &mut Session, p: &Value) -> Result { if !valid_name(nn) { return Err(bad("layer.set", "invalid layer name")); } + if !nn.eq_ignore_ascii_case(&old) && d.layer(nn).is_some() { + return Err(bad("layer.set", format!("layer `{nn}` already exists"))); + } } let d = s.doc_mut()?; let l = d.layer_mut(&old).ok_or_else(|| bad("layer.set", format!("no layer `{name}`")))?; diff --git a/crates/engine/src/cmd/layer/prompts.rs b/crates/engine/src/cmd/layer/prompts.rs new file mode 100644 index 00000000..82b3d895 --- /dev/null +++ b/crates/engine/src/cmd/layer/prompts.rs @@ -0,0 +1,454 @@ +//! -LAYER: the command-line form of LAYER. An option loop that ends on Enter; every option runs +//! the `layer.*` commands, so validation matches the JSON form and the whole session is one undo +//! step. + +use cadcraft_color::Color; +use cadcraft_doc::Lineweight; +use serde_json::{Value, json}; + +use super::super::find_command; +use super::super::qselect::wildcard; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +const OPTIONS: &[&str] = &[ + "?", + "Make", + "Set", + "New", + "Rename", + "ON", + "OFF", + "Color", + "Ltype", + "LWeight", + "TRansparency", + "MATerial", + "Plot", + "Freeze", + "Thaw", + "LOck", + "Unlock", + "stAte", + "Description", + "rEconcile", +]; + +/// Keyword-only: spaces separate inputs. +const KW: Accept = Accept { point: false, number: false, text: false, select: false, enter: true }; +/// A name or value: Space ends the input, as it does in AutoCAD scripts. +const NAME: Accept = Accept { number: true, ..Accept::TEXT }; + +/// A per-layer change applied to a list of layer names. +#[derive(Clone, Debug)] +enum Change { + On, + Off, + Freeze, + Thaw, + Lock, + Unlock, + Color(String), + Ltype(String), + Lweight(f64), + Transparency(u64), + Plot(bool), + Description(String), +} + +impl Change { + fn params(&self) -> Value { + match self { + Change::On => json!({ "on": true }), + Change::Off => json!({ "on": false }), + Change::Freeze => json!({ "frozen": true }), + Change::Thaw => json!({ "frozen": false }), + Change::Lock => json!({ "locked": true }), + Change::Unlock => json!({ "locked": false }), + Change::Color(c) => json!({ "color": c }), + Change::Ltype(l) => json!({ "linetype": l }), + Change::Lweight(mm) => json!({ "lineweight": mm }), + Change::Transparency(t) => json!({ "transparency": t }), + Change::Plot(p) => json!({ "plot": p }), + Change::Description(d) => json!({ "description": d }), + } + } + /// The layer-list prompt for this change; `true` when Enter means the current layer. + fn prompt(&self) -> (String, bool) { + match self { + Change::On => ("Enter name list of layer(s) to turn on".into(), false), + Change::Off => ("Enter name list of layer(s) to turn off".into(), false), + Change::Freeze => ("Enter name list of layer(s) to freeze".into(), false), + Change::Thaw => ("Enter name list of layer(s) to thaw".into(), false), + Change::Lock => ("Enter name list of layer(s) to lock".into(), false), + Change::Unlock => ("Enter name list of layer(s) to unlock".into(), false), + Change::Color(c) => (format!("Enter name list of layer(s) for color {c}"), true), + Change::Ltype(l) => (format!("Enter name list of layer(s) for linetype \"{l}\""), true), + Change::Lweight(mm) => (format!("Enter name list of layer(s) for lineweight {mm:.2}mm"), true), + Change::Transparency(t) => (format!("Enter name list of layer(s) for transparency {t}%"), true), + Change::Plot(_) => ("Enter layer name(s) for this plot preference".into(), true), + Change::Description(_) => ("Enter name list of layer(s) to apply description".into(), true), + } + } +} + +#[derive(Clone, Debug, Default)] +enum Stage { + #[default] + Option, + List, + Make, + Set, + New, + RenameFrom, + RenameTo(String), + Color, + TrueColor, + Ltype, + Lweight, + Transparency, + Plot, + Description, + Layers(Change), + /// Turning the current layer off asks first. + ConfirmOff(Vec), +} + +#[derive(Default)] +pub struct LayerM { + stage: Stage, +} + +fn current_layer(s: &Session) -> String { + s.doc().map(|d| d.header.str("CLAYER", "0")).unwrap_or_else(|_| "0".into()) +} + +/// Run a layer command's body directly: the running command records the single undo step. +fn call(s: &mut Session, id: &str, p: &Value) -> Result { + let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; + (spec.run)(s, p) +} + +fn message(e: &EngineError) -> String { + match e { + EngineError::BadParams { msg, .. } => msg.clone(), + other => other.to_string(), + } +} + +fn retry(msg: impl Into) -> EngineError { + EngineError::Other(msg.into()) +} + +/// Layers matching a comma-separated name list (wildcards allowed), in drawing order. +fn matching(s: &mut Session, list: &str) -> Vec { + let Ok(d) = s.doc() else { return Vec::new() }; + let mut out: Vec = Vec::new(); + let mut missing = Vec::new(); + for pat in list.split(',').map(str::trim).filter(|p| !p.is_empty()).take(1000) { + let before = out.len(); + for l in &d.layers { + if wildcard(pat, &l.name) && !out.iter().any(|n| n.eq_ignore_ascii_case(&l.name)) { + out.push(l.name.clone()); + } + } + if out.len() == before && !d.layers.iter().any(|l| wildcard(pat, &l.name)) { + missing.push(pat.to_string()); + } + } + for m in missing { + s.echo(format!("Cannot find layer \"{m}\".")); + } + out +} + +/// Nearest standard lineweight to `mm`. +fn snap_lineweight(mm: f64) -> f64 { + let want = mm * 100.0; + let best = Lineweight::STANDARD.iter().copied().min_by(|a, b| (f64::from(*a) - want).abs().total_cmp(&(f64::from(*b) - want).abs())).unwrap_or(0); + f64::from(best) / 100.0 +} + +impl LayerM { + fn option(&mut self, s: &mut Session, k: &str) -> Result { + self.stage = match k { + "?" => Stage::List, + "Make" => Stage::Make, + "Set" => Stage::Set, + "New" => Stage::New, + "Rename" => Stage::RenameFrom, + "ON" => Stage::Layers(Change::On), + "OFF" => Stage::Layers(Change::Off), + "Color" => Stage::Color, + "Ltype" => Stage::Ltype, + "LWeight" => Stage::Lweight, + "TRansparency" => Stage::Transparency, + "Plot" => Stage::Plot, + "Freeze" => Stage::Layers(Change::Freeze), + "Thaw" => Stage::Layers(Change::Thaw), + "LOck" => Stage::Layers(Change::Lock), + "Unlock" => Stage::Layers(Change::Unlock), + "Description" => Stage::Description, + other => { + // MATerial, stAte, rEconcile: report and stay at the option prompt. + s.echo(format!("-LAYER {other} is not available yet.")); + Stage::Option + } + }; + Ok(Step::Continue) + } + + fn list(&mut self, s: &mut Session, pat: &str) { + let names = matching(s, if pat.trim().is_empty() { "*" } else { pat }); + let Ok(d) = s.doc() else { return }; + let cur = d.header.str("CLAYER", "0"); + let mut lines = vec![format!("{:<24} {:<26} {:<10} {:<14} {}", "Layer name", "State", "Color", "Linetype", "Lineweight")]; + for l in names.iter().filter_map(|n| d.layer(n)) { + let state = format!( + "{} {} {}{}", + if l.on { "On" } else { "Off" }, + if l.frozen { "Frozen" } else { "Thawed" }, + if l.locked { "Locked" } else { "Unlocked" }, + if l.plot { "" } else { " No plot" } + ); + lines.push(format!("{:<24} {:<26} {:<10} {:<14} {}", l.name, state, l.color.name(), l.linetype, l.lineweight.name())); + } + lines.push(format!("Current layer: \"{cur}\"")); + for l in lines { + s.echo(l); + } + } + + /// Apply `change` to the named layers through `layer.set`; refusals are reported per layer. + fn apply(&mut self, s: &mut Session, change: &Change, names: &[String]) { + for n in names { + let mut p = change.params(); + if let Some(o) = p.as_object_mut() { + o.insert("name".into(), json!(n)); + } + if let Err(e) = call(s, "layer.set", &p) { + s.echo(format!("Layer \"{n}\": {}", message(&e))); + } + } + } + + fn layers(&mut self, s: &mut Session, change: Change, text: Option<&str>) -> Result { + let (_, enter_is_current) = change.prompt(); + let names = match text { + Some(t) => matching(s, t), + None if enter_is_current => vec![current_layer(s)], + None => Vec::new(), + }; + let cur = current_layer(s); + if matches!(change, Change::Off) && names.iter().any(|n| n.eq_ignore_ascii_case(&cur)) { + self.stage = Stage::ConfirmOff(names); + return Ok(Step::Continue); + } + if matches!(change, Change::Freeze) && names.iter().any(|n| n.eq_ignore_ascii_case(&cur)) { + s.echo(format!("Cannot freeze layer \"{cur}\". It is the CURRENT layer.")); + } + let names: Vec = names.into_iter().filter(|n| !(matches!(change, Change::Freeze) && n.eq_ignore_ascii_case(&cur))).collect(); + self.apply(s, &change, &names); + Ok(Step::Continue) + } + + /// One input at a sub-prompt. Returns to the option prompt unless a value must be re-entered. + fn sub(&mut self, s: &mut Session, stage: Stage, i: Input) -> Result { + let text = match &i { + Input::Text(t) | Input::Keyword(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()), + _ => None, + }; + let back = |m: &mut LayerM, st: Stage| { + m.stage = st; + Ok(Step::Continue) + }; + match stage { + Stage::Option => Ok(Step::Continue), + Stage::List => { + self.list(s, text.as_deref().unwrap_or("*")); + Ok(Step::Continue) + } + Stage::Make => { + let Some(n) = text else { return Ok(Step::Continue) }; + let exists = s.doc()?.layer(&n).is_some(); + let r = if exists { + call(s, "layer.current", &json!({ "name": n })) + } else { + call(s, "layer.new", &json!({ "name": n, "current": true })) + }; + if let Err(e) = r { + s.echo(message(&e)); + } + Ok(Step::Continue) + } + Stage::Set => { + let Some(n) = text else { return Ok(Step::Continue) }; + if s.doc()?.layer(&n).is_none() { + s.echo(format!("Cannot find layer \"{n}\".")); + } else if let Err(e) = call(s, "layer.current", &json!({ "name": n })) { + s.echo(message(&e)); + } + Ok(Step::Continue) + } + Stage::New => { + for n in text.as_deref().unwrap_or("").split(',').map(str::trim).filter(|n| !n.is_empty()).take(1000) { + if let Err(e) = call(s, "layer.new", &json!({ "name": n })) { + s.echo(message(&e)); + } + } + Ok(Step::Continue) + } + Stage::RenameFrom => match text { + None => Ok(Step::Continue), + Some(n) => match s.doc()?.layer(&n).map(|l| l.name.clone()) { + Some(old) => back(self, Stage::RenameTo(old)), + None => { + s.echo(format!("Cannot find layer \"{n}\".")); + Ok(Step::Continue) + } + }, + }, + Stage::RenameTo(old) => { + if let Some(n) = text + && let Err(e) = call(s, "layer.set", &json!({ "name": old, "newName": n })) + { + s.echo(message(&e)); + } + Ok(Step::Continue) + } + Stage::Color => match (&i, text) { + (Input::Keyword(k), _) if k == "Truecolor" => back(self, Stage::TrueColor), + (Input::Keyword(_), _) => { + s.echo("Color books are not available yet."); + Ok(Step::Continue) + } + (_, None) => Ok(Step::Continue), + (_, Some(t)) => match Color::parse(&t) { + Some(Color::Index(c)) => back(self, Stage::Layers(Change::Color(c.to_string()))), + Some(Color::True(rgb)) => back(self, Stage::Layers(Change::Color(format!("{},{},{}", rgb.0, rgb.1, rgb.2)))), + _ => { + self.stage = Stage::Color; + Err(retry("Enter a color name or a number from 1 to 255.")) + } + }, + }, + Stage::TrueColor => match text.as_deref().map(Color::parse) { + None => Ok(Step::Continue), + Some(Some(Color::True(rgb))) => back(self, Stage::Layers(Change::Color(format!("{},{},{}", rgb.0, rgb.1, rgb.2)))), + Some(_) => { + self.stage = Stage::TrueColor; + Err(retry("Enter three values from 0 to 255, separated by commas.")) + } + }, + Stage::Ltype => match (&i, text) { + (Input::Keyword(_), _) => { + let names: Vec = s.doc()?.linetypes.iter().map(|l| l.name.clone()).collect(); + s.echo(format!("Loaded linetypes: {}", names.join(", "))); + back(self, Stage::Ltype) + } + (_, None) => back(self, Stage::Layers(Change::Ltype("Continuous".into()))), + (_, Some(t)) => { + let found = + s.doc()?.linetype(&t).map(|l| l.name.clone()).filter(|n| !["bylayer", "byblock"].contains(&n.to_ascii_lowercase().as_str())); + match found { + Some(n) => back(self, Stage::Layers(Change::Ltype(n))), + None => { + s.echo(format!("Linetype \"{t}\" is not loaded. Load it with the LINETYPE command.")); + Ok(Step::Continue) + } + } + } + }, + Stage::Lweight => match text { + None => Ok(Step::Continue), + Some(t) => match t.trim_end_matches("mm").trim().parse::().ok().filter(|v| v.is_finite() && (0.0..=2.11).contains(v)) { + Some(mm) => back(self, Stage::Layers(Change::Lweight(snap_lineweight(mm)))), + None => { + self.stage = Stage::Lweight; + Err(retry("Enter a lineweight from 0.0mm to 2.11mm.")) + } + }, + }, + Stage::Transparency => { + let v = match text { + None => Some(0), + Some(t) => t.parse::().ok().filter(|v| *v <= 90), + }; + match v { + Some(v) => back(self, Stage::Layers(Change::Transparency(v))), + None => { + self.stage = Stage::Transparency; + Err(retry("Enter a transparency value from 0 to 90.")) + } + } + } + Stage::Plot => match &i { + Input::Keyword(k) => back(self, Stage::Layers(Change::Plot(k != "No"))), + Input::Enter => back(self, Stage::Layers(Change::Plot(true))), + _ => { + self.stage = Stage::Plot; + Err(retry("Enter Plot or No.")) + } + }, + Stage::Description => back(self, Stage::Layers(Change::Description(text.unwrap_or_default()))), + Stage::Layers(change) => self.layers(s, change, text.as_deref()), + Stage::ConfirmOff(names) => { + let cur = current_layer(s); + let yes = matches!(&i, Input::Keyword(k) if k == "Yes"); + let names: Vec = names.into_iter().filter(|n| yes || !n.eq_ignore_ascii_case(&cur)).collect(); + self.apply(s, &Change::Off, &names); + Ok(Step::Continue) + } + } + } +} + +impl Interactive for LayerM { + fn name(&self) -> &'static str { + "-LAYER" + } + fn begin(&mut self, s: &mut Session) -> Result { + let cur = current_layer(s); + s.echo(format!("Current layer: \"{cur}\"")); + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + let cur = current_layer(s); + match &self.stage { + Stage::Option => Prompt::new("Enter an option", KW).kw(OPTIONS), + Stage::List => Prompt::new("Enter layer name(s) to list", NAME).default("*"), + Stage::Make => Prompt::new("Enter name for new layer (becomes the current layer)", NAME).default(cur), + Stage::Set => Prompt::new("Enter layer name to make current", NAME), + Stage::New => Prompt::new("Enter name list for new layer(s)", NAME), + Stage::RenameFrom => Prompt::new("Enter old layer name", NAME), + Stage::RenameTo(_) => Prompt::new("Enter new layer name", NAME), + Stage::Color => Prompt::new("Enter color name or number (1-255)", NAME).kw(&["Truecolor", "COlorbook"]), + Stage::TrueColor => Prompt::new("Red, Green, Blue", NAME), + Stage::Ltype => Prompt::new("Enter a loaded linetype name", NAME).kw(&["?"]).default("Continuous"), + Stage::Lweight => Prompt::new("Enter lineweight (0.0mm - 2.11mm)", NAME), + Stage::Transparency => Prompt::new("Enter transparency value (0-90)", NAME).default("0"), + Stage::Plot => Prompt::new("Enter a plotting preference", KW).kw(&["Plot", "No"]).default("Plot"), + Stage::Description => Prompt::new("Enter layer description", Accept::TEXT), + Stage::Layers(c) => { + let (msg, enter_is_current) = c.prompt(); + let p = Prompt::new(msg, NAME); + if enter_is_current { p.default(cur) } else { p } + } + Stage::ConfirmOff(_) => Prompt::new(format!("Really want layer \"{cur}\" (the CURRENT layer) off?"), KW).kw(&["Yes", "No"]).default("N"), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (&self.stage, &i) { + (_, Input::Cancel) => Ok(Step::Cancel), + (Stage::Option, Input::Enter) => Ok(Step::Done), + (Stage::Option, Input::Keyword(k)) => { + let k = k.clone(); + self.option(s, &k) + } + (Stage::Option, _) => Err(retry("Invalid option keyword.")), + _ => { + let stage = std::mem::take(&mut self.stage); + self.sub(s, stage, i) + } + } + } +} diff --git a/crates/engine/tests/layer_cmdline.rs b/crates/engine/tests/layer_cmdline.rs new file mode 100644 index 00000000..30c97d73 --- /dev/null +++ b/crates/engine/tests/layer_cmdline.rs @@ -0,0 +1,73 @@ +//! -LAYER: the command-line option loop runs the layer commands and ends on Enter. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Lineweight}; +use serde_json::json; + +#[test] +fn make_and_color_then_the_next_command_runs() { + let mut s = Session::new(); + s.script("-LAYER M Walls C 1 Walls\n\nLINE 0,0 10,0\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let d = s.doc().unwrap(); + let walls = d.layer("Walls").expect("Walls created"); + assert_eq!(walls.color, cadcraft_color::Color::Index(1)); + assert_eq!(d.header.str("CLAYER", "0"), "Walls"); + let lines: Vec<_> = d.model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).collect(); + assert_eq!(lines.len(), 1, "{:?}", s.log); + assert_eq!(lines[0].common.layer, "Walls"); + // The whole -LAYER session is one undo step. + assert_eq!(s.state().unwrap().undo.len(), 2); + s.undo().unwrap(); + s.undo().unwrap(); + assert!(s.doc().unwrap().layer("Walls").is_none()); +} + +#[test] +fn options_reach_every_layer_property() { + let mut s = Session::new(); + s.cmdline("linetype {\"load\": \"DASHED\"}").unwrap(); + s.cmdline("-LAYER").unwrap(); + assert!( + s.prompt_text().contains( + "[?/Make/Set/New/Rename/ON/OFF/Color/Ltype/LWeight/TRansparency/MATerial/Plot/Freeze/Thaw/LOck/Unlock/stAte/Description/rEconcile]" + ), + "{}", + s.prompt_text() + ); + // MATerial is not available yet and must not swallow the next option. + let script = + "N A,B\nMAT\nS A\nF A\nF B\nLO B\nOFF A\nY\nTR 50 B\nLW 0.3 B\nP N B\nL NOPE\nL DASHED B\nC T 10,20,30 B\nD\nnote here\nB\nR B C\n?\n\n\n"; + s.script(script).unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let d = s.doc().unwrap(); + assert_eq!(d.header.str("CLAYER", "0"), "A"); + let a = d.layer("A").unwrap(); + assert!(!a.on, "the current layer is turned off after Yes"); + assert!(!a.frozen, "the current layer cannot be frozen"); + assert!(d.layer("B").is_none(), "B was renamed"); + let c = d.layer("C").unwrap(); + assert!(c.frozen && c.locked && !c.plot); + assert_eq!(c.transparency, 50); + assert_eq!(c.lineweight, Lineweight::Mm100(30)); + assert_eq!(c.linetype, "DASHED", "NOPE is refused and DASHED applied: {:?}", s.log); + assert_eq!(c.color, cadcraft_color::Color::True(cadcraft_color::Rgb(10, 20, 30))); + assert_eq!(c.description, "note here"); + assert!(s.log.iter().any(|l| l.contains("not available yet"))); + assert!(s.log.iter().any(|l| l.contains("NOPE"))); +} + +#[test] +fn layer_commands_reject_unloaded_linetypes_and_duplicate_renames() { + let mut s = Session::new(); + assert!(s.execute("layer.new", &json!({ "name": "A", "linetype": "NOPE" })).is_err()); + assert!(s.doc().unwrap().layer("A").is_none()); + s.execute("layer.new", &json!({ "name": "A" })).unwrap(); + s.execute("layer.new", &json!({ "name": "B" })).unwrap(); + assert!(s.execute("layer.set", &json!({ "name": "A", "linetype": "ByLayer" })).is_err()); + assert!(s.execute("layer.set", &json!({ "name": "A", "newName": "b" })).is_err()); + assert_eq!(s.doc().unwrap().layers.iter().filter(|l| l.name.eq_ignore_ascii_case("b")).count(), 1); + // A case-only rename of the same layer is fine. + s.execute("layer.set", &json!({ "name": "A", "newName": "a", "linetype": "continuous" })).unwrap(); + assert_eq!(s.doc().unwrap().layer("a").unwrap().linetype, "Continuous"); +} From d433112f1ee1355fd1344ecc221b884bb1ba4987 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:55:49 +0200 Subject: [PATCH 101/150] Draw hatch island styles, backgrounds, two-colour gradients and file pattern definitions (fixes #289, fixes #290) (#302) - Island detection style (group 75) picks the loops that take part: outer keeps the outermost loops and their first islands, ignore only the outermost loops. - A pattern hatch's background colour is filled under its pattern lines. - Gradients blend colour 1 to colour 2 (LINEAR, CYLINDER, CURVED, SPHERICAL, HEMISPHERICAL and the INV forms) along their angle, shifted when not centred, drawn as bounded colour steps; ByLayer / ByBlock gradient colours resolve to the hatch's layer / block colour instead of colour 7. - DXF HATCH pattern definition lines (78, 53, 43-46, 79, 49) of a pattern the library doesn't define are kept on the hatch (new `patternLines` field), drawn and written back; hostile counts are capped. Co-authored-by: Claude Opus 5.5 --- crates/doc/src/entity.rs | 4 + crates/doc/src/library.rs | 2 +- crates/engine/src/cmd/hatch.rs | 1 + crates/engine/src/sample.rs | 1 + crates/io/src/dxf_read.rs | 65 +++++- crates/io/src/dxf_write.rs | 3 +- crates/io/src/tests.rs | 2 + crates/io/tests/entity_props_roundtrip.rs | 1 + crates/io/tests/hatch_pattern_def.rs | 130 ++++++++++++ crates/render/src/hatch.rs | 228 ++++++++++++++++++++-- crates/render/src/lib.rs | 25 +-- crates/render/src/tests.rs | 1 + crates/render/tests/hatch_fill.rs | 166 ++++++++++++++++ 13 files changed, 589 insertions(+), 40 deletions(-) create mode 100644 crates/io/tests/hatch_pattern_def.rs create mode 100644 crates/render/tests/hatch_fill.rs diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index cb6733d5..ad661a6b 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -452,6 +452,10 @@ pub struct Hatch { pub origin: Vec2, #[serde(default)] pub background: Option, + /// The pattern's line families as a file defined them, for a pattern that isn't in the + /// standard library (unit scale and angle, like the library's; empty otherwise). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pattern_lines: Vec, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/doc/src/library.rs b/crates/doc/src/library.rs index 0a455275..c2f63a54 100644 --- a/crates/doc/src/library.rs +++ b/crates/doc/src/library.rs @@ -75,7 +75,7 @@ fn scaled(mut lt: Linetype, k: f64) -> Linetype { } /// A hatch pattern line family: angle (degrees), origin, offset (shift along, spacing), dashes. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct PatternLine { pub angle: f64, pub origin: (f64, f64), diff --git a/crates/engine/src/cmd/hatch.rs b/crates/engine/src/cmd/hatch.rs index 0f466a78..4c49df2c 100644 --- a/crates/engine/src/cmd/hatch.rs +++ b/crates/engine/src/cmd/hatch.rs @@ -200,6 +200,7 @@ impl HatchSettings { gradient: self.gradient.clone(), origin: Vec2::ZERO, background: None, + pattern_lines: Vec::new(), }) } } diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 982365bc..772b4dc4 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -146,6 +146,7 @@ pub fn bracket() -> Drawing { gradient: None, origin: Vec2::ZERO, background: None, + pattern_lines: Vec::new(), }) }; add(on("Hatch"), hatch(Vec2::new(sx, c.y + 0.875), Vec2::new(sx + 0.5, y0 + h))); diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 5c446d6e..37249eed 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -611,12 +611,17 @@ fn hatch(tags: &[Tag], t: &T) -> Option { } loops.push(HatchLoop { vertices: verts, outer }); } + let solid = solid || pattern.eq_ignore_ascii_case("SOLID"); + let (scale, angle) = (t.fd(41, 1.0), t.fd(52, 0.0).to_radians()); + // Definition lines matter only for a pattern the library doesn't define. + let pattern_lines = + if solid || cadcraft_doc::library::pattern(&pattern).is_some() { Vec::new() } else { pattern_definition(tags, scale, angle, origin) }; Some(EntityKind::Hatch(Hatch { pattern: pattern.to_ascii_uppercase(), - solid: solid || pattern.eq_ignore_ascii_case("SOLID"), + solid, loops, - scale: t.fd(41, 1.0), - angle: t.fd(52, 0.0).to_radians(), + scale, + angle, associative: t.i(71).unwrap_or(0) != 0, style: t.i(75).unwrap_or(0) as u8, // The first 30 is the elevation point's z (boundary points are 2D). @@ -624,9 +629,63 @@ fn hatch(tags: &[Tag], t: &T) -> Option { gradient: gradient(tags), origin, background, + pattern_lines, })) } +/// Upper bounds on a HATCH's pattern definition lines and on the dashes of one line. +const MAX_PATTERN_LINES: usize = 1024; +const MAX_PATTERN_DASHES: usize = 256; + +/// The pattern definition lines of a HATCH (groups 78, then per line 53 angle, 43/44 base +/// point, 45/46 offset, 79 dash count, 49 dash lengths). A file holds them as drawn: scaled, +/// rotated with the hatch and the offset as a world vector. They are kept the library's way +/// (unit scale, no hatch rotation, base point relative to the hatch origin, offset as shift +/// along the line and spacing), which the writer and the renderer turn back. Lines with +/// non-finite values are skipped. +fn pattern_definition(tags: &[Tag], scale: f64, angle: f64, origin: Vec2) -> Vec { + let Some(at) = tags.iter().position(|x| x.code == 78) else { return Vec::new() }; + let scale = if scale.is_finite() && scale > 1e-9 { scale } else { 1.0 }; + let angle = if angle.is_finite() { angle } else { 0.0 }; + // Raw lines: angle (degrees), base, offset, dashes. + let mut raw: Vec<(f64, Vec2, Vec2, Vec)> = Vec::new(); + for x in tags.iter().skip(at + 1) { + let cur = raw.last_mut(); + match (x.code, cur) { + (53, _) => { + if raw.len() >= MAX_PATTERN_LINES { + break; + } + raw.push((x.f64(), Vec2::ZERO, Vec2::ZERO, Vec::new())); + } + (43, Some(l)) => l.1.x = x.f64(), + (44, Some(l)) => l.1.y = x.f64(), + (45, Some(l)) => l.2.x = x.f64(), + (46, Some(l)) => l.2.y = x.f64(), + (79, Some(_)) => {} + (49, Some(l)) => { + if l.3.len() < MAX_PATTERN_DASHES { + l.3.push(x.f64()); + } + } + _ => break, + } + } + raw.into_iter() + .filter(|(a, b, o, d)| a.is_finite() && b.is_finite() && o.is_finite() && d.iter().all(|v| v.is_finite())) + .map(|(a, base, offset, dashes)| { + let local = (base - origin).rotate(-angle) * (1.0 / scale); + let delta = offset.rotate(-a.to_radians()) * (1.0 / scale); + cadcraft_doc::library::PatternLine { + angle: a - angle.to_degrees(), + origin: (local.x, local.y), + delta: (delta.x, delta.y), + dashes: dashes.into_iter().map(|d| d / scale).collect(), + } + }) + .collect() +} + /// Hatch origin and background colour from `CADCRAFT` xdata: `1000 HATCH`, `1011` origin, /// `1000` background colour name (empty for none). Defaults when absent. fn hatch_xdata(tags: &[Tag]) -> (Vec2, Option) { diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 165287ba..e6ee22c8 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -787,7 +787,8 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx w.f(41, h.scale); w.i(77, 0); let pat = cadcraft_doc::library::pattern(&h.pattern); - let lines = pat.map(|p| p.lines).unwrap_or_default(); + // A pattern the library doesn't define keeps the lines read from its file. + let lines = pat.map(|p| p.lines).unwrap_or_else(|| h.pattern_lines.clone()); w.i(78, lines.len() as i64); for pl in lines { let ang = pl.angle.to_radians() + h.angle; diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 20cd30ec..c6516736 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -113,6 +113,7 @@ fn sample() -> Drawing { gradient: None, origin: Vec2::ZERO, background: None, + pattern_lines: Vec::new(), }), ) .unwrap(); @@ -1477,6 +1478,7 @@ fn gradient_sample() -> (Drawing, Vec>) { gradient: g.clone(), origin: Vec2::ZERO, background: None, + pattern_lines: Vec::new(), }; d.add(&Space::Model, Common::default(), EntityKind::Hatch(h)).unwrap(); } diff --git a/crates/io/tests/entity_props_roundtrip.rs b/crates/io/tests/entity_props_roundtrip.rs index 98108b34..dda88940 100644 --- a/crates/io/tests/entity_props_roundtrip.rs +++ b/crates/io/tests/entity_props_roundtrip.rs @@ -83,6 +83,7 @@ fn hatch(pattern: &str, gradient: Option) -> Hatch { gradient, origin: Vec2::new(1.0, 2.0), background: Some(Color::Index(2)), + pattern_lines: Vec::new(), } } diff --git a/crates/io/tests/hatch_pattern_def.rs b/crates/io/tests/hatch_pattern_def.rs new file mode 100644 index 00000000..c4108c64 --- /dev/null +++ b/crates/io/tests/hatch_pattern_def.rs @@ -0,0 +1,130 @@ +//! HATCH pattern definition lines (groups 78, 53, 43–46, 79, 49) of a pattern the library +//! doesn't define survive a DXF read and save (issue #290). + +use cadcraft_doc::library::PatternLine; +use cadcraft_doc::*; +use cadcraft_geom::{PolyVertex, Vec2}; +use cadcraft_io::{read_dxf, write_dxf}; + +fn square() -> HatchLoop { + let v = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)].into_iter().map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).collect(); + HatchLoop { vertices: v, outer: true } +} + +fn hatch(pattern: &str, scale: f64, angle: f64, origin: Vec2, lines: Vec) -> Hatch { + Hatch { + pattern: pattern.into(), + solid: false, + loops: vec![square()], + scale, + angle, + associative: false, + style: 0, + elevation: 0.0, + gradient: None, + origin, + background: None, + pattern_lines: lines, + } +} + +fn custom_lines() -> Vec { + vec![ + PatternLine { angle: 0.0, origin: (0.0, 0.0), delta: (0.5, 1.0), dashes: vec![0.75, -0.25, 0.0, -0.25] }, + PatternLine { angle: 90.0, origin: (0.25, 0.0), delta: (0.0, 1.0), dashes: vec![] }, + ] +} + +fn save(h: Hatch) -> String { + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), EntityKind::Hatch(h)).expect("add"); + write_dxf(&d) +} + +fn first_hatch(text: &str) -> Hatch { + let d = read_dxf(text.as_bytes()).expect("read"); + d.model.iter().find_map(|e| if let EntityKind::Hatch(h) = &e.kind { Some(h.clone()) } else { None }).expect("hatch") +} + +/// (group code, value) pairs of a DXF text. +fn pairs(text: &str) -> Vec<(i32, String)> { + let lines: Vec<&str> = text.lines().collect(); + lines.chunks(2).filter_map(|c| Some((c.first()?.trim().parse().ok()?, c.get(1)?.trim().to_string()))).collect() +} + +/// Index of the HATCH's 78 group. +fn pattern_data(p: &[(i32, String)]) -> usize { + let hatch = p.iter().position(|(c, v)| *c == 0 && v == "HATCH").expect("HATCH"); + p.iter().skip(hatch).position(|(c, _)| *c == 78).map(|k| hatch + k).expect("78") +} + +fn join(p: &[(i32, String)]) -> String { + p.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect() +} + +fn near(a: &[PatternLine], b: &[PatternLine]) -> bool { + let close = |x: f64, y: f64| (x - y).abs() < 1e-9; + a.len() == b.len() + && a.iter().zip(b).all(|(p, q)| { + close(p.angle, q.angle) + && close(p.origin.0, q.origin.0) + && close(p.origin.1, q.origin.1) + && close(p.delta.0, q.delta.0) + && close(p.delta.1, q.delta.1) + && p.dashes.len() == q.dashes.len() + && p.dashes.iter().zip(&q.dashes).all(|(x, y)| close(*x, *y)) + }) +} + +#[test] +fn custom_pattern_lines_roundtrip_scaled_and_rotated() { + let angle = 30f64.to_radians(); + let text = save(hatch("MYPAT", 2.0, angle, Vec2::new(1.0, 2.0), custom_lines())); + // In the file the lines are as drawn: rotated with the hatch, scaled, offset as a world vector. + let p = pairs(&text); + let at = pattern_data(&p); + assert_eq!(p[at].1, "2"); + let num = |k: usize| p[at + k].1.parse::().expect("number"); + assert!((num(1) - 30.0).abs() < 1e-9, "53 angle"); + let off = Vec2::new(0.5, 1.0).rotate(angle) * 2.0; + assert!((num(2) - 1.0).abs() < 1e-9 && (num(3) - 2.0).abs() < 1e-9, "43/44 base point at the origin"); + assert!((num(4) - off.x).abs() < 1e-9 && (num(5) - off.y).abs() < 1e-9, "45/46 offset"); + assert_eq!(p[at + 6], (79, "4".into())); + assert!((num(7) - 1.5).abs() < 1e-9, "49 dash scaled"); + // Read back to the same definition, and saved again unchanged. + let h = first_hatch(&text); + assert!(near(&h.pattern_lines, &custom_lines()), "{:?}", h.pattern_lines); + assert!(near(&first_hatch(&save(h)).pattern_lines, &custom_lines())); +} + +#[test] +fn library_patterns_keep_no_definition() { + let h = first_hatch(&save(hatch("ANSI31", 1.0, 0.0, Vec2::ZERO, vec![]))); + assert!(h.pattern_lines.is_empty()); +} + +#[test] +fn hostile_pattern_definitions_are_capped() { + let text = save(hatch("MYPAT", 1.0, 0.0, Vec2::ZERO, custom_lines())); + let p = pairs(&text); + let at = pattern_data(&p); + let end = p.iter().skip(at).position(|(c, _)| *c == 98).map(|k| at + k).expect("98"); + let mut hostile = vec![(78, "2000000000".to_string())]; + // A line with a NaN offset, a line with a million dashes, then far too many lines. + hostile.extend([(53, "0"), (43, "0"), (44, "0"), (45, "nan"), (46, "1"), (79, "1"), (49, "1")].map(|(c, v)| (c, v.to_string()))); + hostile.extend([(53, "0"), (43, "0"), (44, "0"), (45, "0"), (46, "1"), (79, "1000000")].map(|(c, v)| (c, v.to_string()))); + hostile.extend((0..1_000).map(|_| (49, "0.5".to_string()))); + for _ in 0..3_000 { + hostile.extend([(53, "45"), (43, "0"), (44, "0"), (45, "0"), (46, "1"), (79, "0")].map(|(c, v)| (c, v.to_string()))); + } + let mut q = p[..at].to_vec(); + q.extend(hostile); + q.extend_from_slice(&p[end..]); + let h = first_hatch(&join(&q)); + assert!(h.pattern_lines.len() <= 1024 && h.pattern_lines.len() > 100, "{}", h.pattern_lines.len()); + assert!(h.pattern_lines.iter().all(|l| l.delta.0.is_finite() && l.dashes.len() <= 256)); + // Drawing the capped definition stays bounded. + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), EntityKind::Hatch(h)).expect("add"); + let _ = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()); +} diff --git a/crates/render/src/hatch.rs b/crates/render/src/hatch.rs index 49781990..08917cc1 100644 --- a/crates/render/src/hatch.rs +++ b/crates/render/src/hatch.rs @@ -1,26 +1,232 @@ -//! Pattern hatch line generation: each pattern line family is swept across the boundary -//! (even-odd clipped), then dashed. +//! Hatch drawing: island detection picks the boundary loops that take part, then a solid, +//! gradient or background fill (even-odd triangles) and the pattern lines (each pattern line +//! family swept across the boundary, even-odd clipped, then dashed). -use cadcraft_doc::Hatch; -use cadcraft_doc::library::{HatchPattern, pattern}; -use cadcraft_geom::{Bounds2, Polyline, Vec2}; +use super::{Builder, Ctx, Ink, fill, ink}; +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::library::pattern; +use cadcraft_doc::{Entity, Gradient, Hatch}; +use cadcraft_geom::{Bounds2, Polyline, Vec2, point_in_polygon}; -/// Hatch line runs in world coordinates (single-point runs are dots). -pub fn pattern_lines(h: &Hatch, tol: f64) -> Vec> { - let pat = pattern(&h.pattern).unwrap_or_else(|| pattern("ANSI31").unwrap_or(HatchPattern { name: "ANSI31", description: "", lines: vec![] })); +/// Draw a hatch entity: `rgb` is its resolved colour, `tol` the chord tolerance. +pub(super) fn draw(b: &mut Builder, ctx: &Ctx, e: &Entity, h: &Hatch, rgb: Ink, lw: f32, tol: f64) { + let loops = fill_loops(h, tol); + // Gradient and background colours given ByLayer follow the hatch's own layer. + let layer = if e.common.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { e.common.layer.as_str() }; + let layer_color = ctx.layer_color(layer).unwrap_or(Color::Index(7)); + if h.solid || h.pattern.eq_ignore_ascii_case("SOLID") || h.gradient.is_some() { + if b.opts.fill { + let tris = fill::triangulate_evenodd(&loops); + match &h.gradient { + Some(g) => gradient(b, ctx, g, layer_color, &tris), + None => b.tris(ctx, rgb, &tris), + } + } + return; + } + if let Some(bg) = h.background + && b.opts.fill + { + b.tris(ctx, ink(bg, layer_color, ctx.block_color), &fill::triangulate_evenodd(&loops)); + } + for seg in pattern_lines(h, &loops) { + if seg.len() == 1 { + if let Some(p) = seg.first() { + b.point(ctx, rgb, *p); + } + } else { + b.polyline(ctx, rgb, lw, &seg); + } + } +} + +/// Upper bound on loop count × vertex count for island detection (the nesting test compares +/// every loop with every other); past it all loops are kept (odd parity). +const MAX_ISLAND_WORK: usize = 20_000_000; + +/// The tessellated boundary loops that take part in the fill under the hatch's island +/// detection style (DXF group 75), filled with odd parity: 0 normal keeps every loop (islands +/// within islands alternate), 1 outer keeps the outermost loops and their first islands, 2 +/// ignore keeps only the outermost loops. Nesting depth = how many other loops enclose a loop. +pub(crate) fn fill_loops(h: &Hatch, tol: f64) -> Vec> { let loops: Vec> = h.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate(tol)).collect(); + let max_depth = match h.style { + 1 => 1, + 2 => 0, + _ => return loops, + }; + let verts: usize = loops.iter().map(Vec::len).sum(); + if loops.len().saturating_mul(verts) > MAX_ISLAND_WORK { + return loops; + } + let depth = |i: usize, l: &[Vec2]| { + let Some(p) = probe(l) else { return 0 }; + loops.iter().enumerate().filter(|(j, o)| *j != i && point_in_polygon(o, p)).count() + }; + loops.iter().enumerate().filter(|(i, l)| depth(*i, l) <= max_depth).map(|(_, l)| l.clone()).collect() +} + +/// A point of a loop to test its nesting with: the middle of its first non-degenerate edge +/// (vertices may touch an enclosing loop; edge midpoints rarely do). +fn probe(l: &[Vec2]) -> Option { + let n = l.len(); + (0..n).find_map(|i| { + let (a, b) = (*l.get(i)?, *l.get((i + 1) % n)?); + (a.dist(b) > 1e-12).then(|| (a + b) * 0.5).filter(|m| m.is_finite()) + }) +} + +/// Colour steps a gradient is drawn with (one display-list primitive each). +const GRADIENT_LEVELS: usize = 64; +/// Gradient triangles are split until no edge is longer than the larger extent over this. +const GRADIENT_CELLS: f64 = 64.0; +/// Upper bound on the triangles of one gradient fill. +const MAX_GRADIENT_TRIS: usize = 200_000; + +/// A two-colour gradient over the fill triangles `tris`: a base fill in the middle colour (it +/// hides seams between steps), then the triangles split into small pieces, each drawn in the +/// colour step of the gradient value at its centroid. +fn gradient(b: &mut Builder, ctx: &Ctx, g: &Gradient, layer: Color, tris: &[Vec2]) { + let c1 = ink(g.color1, layer, ctx.block_color); + let c2 = ink(g.color2, layer, ctx.block_color); + b.tris(ctx, mix(c1, c2, 0.5), tris); + let Some(field) = Field::new(g, tris) else { return }; + let mut steps: Vec> = vec![Vec::new(); GRADIENT_LEVELS]; + let max2 = field.cell * field.cell; + let mut stack: Vec<[Vec2; 3]> = tris.as_chunks::<3>().0.to_vec(); + let mut drawn = 0usize; + while let Some([a, bb, c]) = stack.pop() { + // Split the longest edge while the budget allows. + let edges = [(a, bb, c), (bb, c, a), (c, a, bb)]; + let (p, q, r) = edges.into_iter().max_by(|x, y| (x.0 - x.1).len2().total_cmp(&(y.0 - y.1).len2())).unwrap_or((a, bb, c)); + if (p - q).len2() > max2 && drawn + stack.len() + 2 <= MAX_GRADIENT_TRIS { + let m = (p + q) * 0.5; + stack.push([p, m, r]); + stack.push([m, q, r]); + continue; + } + let t = field.value((a + bb + c) / 3.0); + let k = (t * (GRADIENT_LEVELS - 1) as f64).round() as usize; + if let Some(s) = steps.get_mut(k.min(GRADIENT_LEVELS - 1)) { + s.extend_from_slice(&[a, bb, c]); + } + drawn += 1; + } + for (k, s) in steps.iter().enumerate() { + b.tris(ctx, mix(c1, c2, k as f64 / (GRADIENT_LEVELS - 1) as f64), s); + } +} + +/// `a` blended towards `b` by `t`; colour 7 only when both ends are. +fn mix(a: Ink, b: Ink, t: f64) -> Ink { + let l = |x: u8, y: u8| (f64::from(x) + (f64::from(y) - f64::from(x)) * t).round().clamp(0.0, 255.0) as u8; + Ink { rgb: Rgb(l(a.rgb.0, b.rgb.0), l(a.rgb.1, b.rgb.1), l(a.rgb.2, b.rgb.2)), aci7: a.aci7 && b.aci7 } +} + +/// Gradient shapes by name (the `INV` forms swap the two colours). +#[derive(Clone, Copy, Debug, PartialEq)] +enum Shape { + /// Colour 1 to colour 2 along the gradient direction. + Linear, + /// Colour 1 at both sides, colour 2 along the middle. + Cylinder, + /// Like linear but bending quickly towards colour 2. + Curved, + /// Colour 2 at the centre, colour 1 from the farthest side outwards. + Spherical, + /// Like spherical, centred on the bottom edge (in the gradient direction's frame). + Hemispherical, +} + +/// The gradient value (0 = colour 1, 1 = colour 2) over the fill, in a frame whose x axis is +/// the gradient direction. +struct Field { + shape: Shape, + inverted: bool, + dir: Vec2, + /// Fill bounds in the gradient frame. + bb: Bounds2, + /// Centre in the gradient frame (moved up and back when the gradient isn't centred). + centre: Vec2, + /// Radius at which a radial gradient reaches colour 1: the centre's distance to the farthest + /// side of `bb` (the corners beyond it are colour 1). + reach: f64, + /// Largest edge length of the pieces drawn. + cell: f64, +} + +impl Field { + fn new(g: &Gradient, tris: &[Vec2]) -> Option { + let name = g.name.trim().to_ascii_uppercase(); + let (inverted, base) = match name.strip_prefix("INV") { + Some(rest) => (true, rest), + None => (false, name.as_str()), + }; + let shape = match base { + "CYLINDER" => Shape::Cylinder, + "CURVED" => Shape::Curved, + "SPHERICAL" => Shape::Spherical, + "HEMISPHERICAL" => Shape::Hemispherical, + _ => Shape::Linear, + }; + let angle = if g.angle.is_finite() { g.angle } else { 0.0 }; + let dir = Vec2::new(angle.cos(), angle.sin()); + let bb = Bounds2::from_points(tris.iter().filter(|p| p.is_finite()).map(|p| Vec2::new(p.dot(dir), p.dot(dir.perp())))); + let size = bb.width().max(bb.height()); + if bb.is_empty() || !(size.is_finite() && size > 1e-12) { + return None; + } + let (fx, fy) = if g.centered { (0.5, 0.5) } else { (0.3, 0.7) }; + let mut centre = Vec2::new(bb.min.x + bb.width() * fx, bb.min.y + bb.height() * fy); + if shape == Shape::Hemispherical { + centre.y = bb.min.y; + } + let reach = [centre.x - bb.min.x, bb.max.x - centre.x, centre.y - bb.min.y, bb.max.y - centre.y].into_iter().fold(0.0, f64::max).max(1e-12); + Some(Field { shape, inverted, dir, bb, centre, reach, cell: size / GRADIENT_CELLS }) + } + + /// The value at world point `p`, in 0..=1. + fn value(&self, p: Vec2) -> f64 { + let q = Vec2::new(p.dot(self.dir), p.dot(self.dir.perp())); + // Position along the direction, 0..1, with the centre mapped to 0.5. + let along = || { + let (lo, c, hi) = (self.bb.min.x, self.centre.x, self.bb.max.x); + if q.x < c { 0.5 * (q.x - lo) / (c - lo).max(1e-12) } else { 0.5 + 0.5 * (q.x - c) / (hi - c).max(1e-12) } + }; + let t = match self.shape { + Shape::Linear => along(), + Shape::Cylinder => 1.0 - (along() - 0.5).abs() * 2.0, + Shape::Curved => 1.0 - (1.0 - along().clamp(0.0, 1.0)).powi(2), + Shape::Spherical | Shape::Hemispherical => 1.0 - q.dist(self.centre) / self.reach, + }; + let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; + if self.inverted { 1.0 - t } else { t } + } +} + +/// Upper bound on the line families drawn from a pattern definition read from a file. +const MAX_PATTERN_LINES: usize = 1024; + +/// Hatch line runs in world coordinates (single-point runs are dots) for the fill `loops`. +pub fn pattern_lines(h: &Hatch, loops: &[Vec]) -> Vec> { + // A pattern the library doesn't define draws with the lines its file defined, else as ANSI31. + let lines = match pattern(&h.pattern) { + Some(p) => p.lines, + None if !h.pattern_lines.is_empty() => h.pattern_lines.iter().take(MAX_PATTERN_LINES).cloned().collect(), + None => pattern("ANSI31").map(|p| p.lines).unwrap_or_default(), + }; let scale = if h.scale.is_finite() && h.scale > 1e-9 { h.scale } else { 1.0 }; let mut out = Vec::new(); - for fam in &pat.lines { + for fam in &lines { let ang = fam.angle.to_radians() + h.angle; let origin = h.origin + Vec2::new(fam.origin.0, fam.origin.1).rotate(h.angle) * scale; let delta = Vec2::new(fam.delta.0, fam.delta.1).rotate(ang) * scale; let spacing = (Vec2::new(fam.delta.0, fam.delta.1).y * scale).abs(); - if spacing < 1e-9 { + if !spacing.is_finite() || spacing < 1e-9 { continue; } let dashes: Vec = fam.dashes.iter().map(|d| d * scale).collect(); - family(&loops, origin, ang, delta, spacing, &dashes, &mut out); + family(loops, origin, ang, delta, spacing, &dashes, &mut out); if out.len() > 500_000 { break; } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a6f323df..768bb540 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -529,30 +529,7 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) { dimension(b, ctx, e, dm, lw); } } - EntityKind::Hatch(h) => { - if h.solid || h.pattern.eq_ignore_ascii_case("SOLID") || h.gradient.is_some() { - if b.opts.fill { - let loops: Vec> = - h.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate(tol)).collect(); - let tris = fill::triangulate_evenodd(&loops); - let c = match &h.gradient { - Some(g) => ink(g.color1, Color::Index(7), ctx.block_color), - None => rgb, - }; - b.tris(ctx, c, &tris); - } - } else { - for seg in hatch::pattern_lines(h, tol) { - if seg.len() == 1 { - if let Some(p) = seg.first() { - b.point(ctx, rgb, *p); - } - } else { - b.polyline(ctx, rgb, lw, &seg); - } - } - } - } + EntityKind::Hatch(h) => hatch::draw(b, ctx, e, h, rgb, lw, tol), EntityKind::LwPolyline(p) if p.const_width > 0.0 || p.vertices.iter().any(|v| v.start_width > 0.0 || v.end_width > 0.0) => { if b.opts.fill && ctx.d.header.i64("FILLMODE", 1) != 0 { match < { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index b5e5fda5..9c72acdd 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -86,6 +86,7 @@ fn hatch_pattern_and_solid() { gradient: None, origin: Vec2::ZERO, background: None, + pattern_lines: Vec::new(), }; d.add(&Space::Model, Common::default(), EntityKind::Hatch(h.clone())).unwrap(); let l = build(&d, &Space::Model, &Options::default()); diff --git a/crates/render/tests/hatch_fill.rs b/crates/render/tests/hatch_fill.rs new file mode 100644 index 00000000..538f1c1f --- /dev/null +++ b/crates/render/tests/hatch_fill.rs @@ -0,0 +1,166 @@ +//! Hatch drawing: island detection styles, background colour, two-colour gradients and +//! pattern definitions kept from a file (issues #289, #290). + +use cadcraft_color::{Color, Rgb, aci_rgb}; +use cadcraft_doc::library::PatternLine; +use cadcraft_doc::{Common, Drawing, EntityKind, Gradient, Hatch, HatchLoop, Layer, Space}; +use cadcraft_geom::{PolyVertex, Vec2}; +use cadcraft_render::{DisplayList, Kind, Options, build}; + +fn square(lo: f64, hi: f64) -> HatchLoop { + let v = [(lo, lo), (hi, lo), (hi, hi), (lo, hi)].into_iter().map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).collect(); + HatchLoop { vertices: v, outer: lo == 0.0 } +} + +fn hatch(pattern: &str, loops: Vec) -> Hatch { + Hatch { + pattern: pattern.into(), + solid: pattern == "SOLID", + loops, + scale: 1.0, + angle: 0.0, + associative: false, + style: 0, + elevation: 0.0, + gradient: None, + origin: Vec2::ZERO, + background: None, + pattern_lines: Vec::new(), + } +} + +/// Square 0..10 with the island 2..8 and the island-in-an-island 4..6. +fn nested(pattern: &str, style: u8) -> Hatch { + Hatch { style, ..hatch(pattern, vec![square(0.0, 10.0), square(2.0, 8.0), square(4.0, 6.0)]) } +} + +fn draw(h: Hatch, layer: &str) -> DisplayList { + let mut d = Drawing::new_imperial(); + d.layers.push(Layer { color: Color::Index(1), ..Layer::new("RED") }); + let common = Common { layer: layer.into(), ..Common::default() }; + d.add(&Space::Model, common, EntityKind::Hatch(h)).expect("add"); + build(&d, &Space::Model, &Options::default()) +} + +fn tri_area(l: &DisplayList, color: Option) -> f64 { + l.prims + .iter() + .filter(|p| p.kind == Kind::Tris && color.is_none_or(|c| p.color == c)) + .flat_map(|p| l.points(p).chunks(3)) + .map(|t| ((t[1] - t[0]).cross(t[2] - t[0]) / 2.0).abs()) + .sum() +} + +/// Mid points of the pattern line runs. +fn line_mids(l: &DisplayList) -> Vec { + l.prims.iter().filter(|p| p.kind == Kind::Polyline).filter_map(|p| Some(l.points(p).first()?.mid(*l.points(p).last()?))).collect() +} + +#[test] +fn island_detection_styles() { + // Normal: odd parity (100 - 36 + 4); outer: the innermost island stays empty; ignore: all. + for (style, area) in [(0, 68.0), (1, 64.0), (2, 100.0)] { + let a = tri_area(&draw(nested("SOLID", style), "0"), None); + assert!((a - area).abs() < 1e-6, "style {style}: {a}"); + } + let inside = |p: &Vec2, lo: f64, hi: f64| p.x > lo && p.x < hi && p.y > lo && p.y < hi; + let island = |p: &Vec2| inside(p, 2.0, 8.0) && !inside(p, 4.0, 6.0); + let normal = line_mids(&draw(nested("ANSI31", 0), "0")); + assert!(!normal.iter().any(island) && normal.iter().any(|p| inside(p, 4.0, 6.0))); + let outer = line_mids(&draw(nested("ANSI31", 1), "0")); + assert!(!outer.is_empty() && !outer.iter().any(|p| inside(p, 2.0, 8.0)), "outer hatches only the outer band"); + let ignore = line_mids(&draw(nested("ANSI31", 2), "0")); + assert!(ignore.iter().any(island) && ignore.iter().any(|p| inside(p, 4.0, 6.0))); +} + +#[test] +fn pattern_background_is_filled_under_the_lines() { + let plain = draw(nested("ANSI31", 0), "0"); + assert!(plain.prims.iter().all(|p| p.kind != Kind::Tris)); + let l = draw(Hatch { background: Some(Color::Index(3)), ..nested("ANSI31", 0) }, "0"); + assert!((tri_area(&l, Some(aci_rgb(3))) - 68.0).abs() < 1e-6); + // The fill comes first so the pattern lines draw over it. + assert_eq!(l.prims.first().map(|p| p.kind), Some(Kind::Tris)); + assert!(l.prims.iter().any(|p| p.kind == Kind::Polyline)); +} + +fn gradient(name: &str, angle: f64, color1: Color) -> Hatch { + let g = Gradient { name: name.into(), color1, color2: Color::Index(3), angle, centered: true }; + Hatch { gradient: Some(g), ..hatch("SOLID", vec![square(0.0, 10.0)]) } +} + +/// Mean position of the triangles drawn in `c` (the base fill has the middle colour). +fn centre_of(l: &DisplayList, c: Rgb) -> Option { + let pts: Vec = l.prims.iter().filter(|p| p.kind == Kind::Tris && p.color == c).flat_map(|p| l.points(p).iter().copied()).collect(); + (!pts.is_empty()).then(|| pts.iter().fold(Vec2::ZERO, |a, p| a + *p) * (1.0 / pts.len() as f64)) +} + +#[test] +fn linear_gradient_blends_from_the_layer_colour_to_colour_2() { + let (red, green) = (aci_rgb(1), aci_rgb(3)); + let l = draw(gradient("LINEAR", 0.0, Color::ByLayer), "RED"); + assert!(!l.prims.iter().any(|p| p.color == Rgb(255, 255, 255)), "ByLayer colour 1 is the layer's red, not white"); + let (a, b) = (centre_of(&l, red).expect("red end"), centre_of(&l, green).expect("green end")); + assert!(a.x < 1.0 && b.x > 9.0, "{a:?} {b:?}"); + assert!(l.prims.iter().filter(|p| p.kind == Kind::Tris).count() > 20, "blended in steps"); + // Base fill plus the steps cover the square twice. + assert!((tri_area(&l, None) - 200.0).abs() < 1e-6); + // 90°: the blend runs bottom to top. + let l = draw(gradient("LINEAR", std::f64::consts::FRAC_PI_2, Color::ByLayer), "RED"); + let (a, b) = (centre_of(&l, red).expect("red end"), centre_of(&l, green).expect("green end")); + assert!(a.y < 1.0 && b.y > 9.0, "{a:?} {b:?}"); +} + +/// Mean position of the gradient step closest in colour to `c` (the first fill is the base). +fn nearest_step(l: &DisplayList, c: Rgb) -> Vec2 { + let d = |x: Rgb| (i32::from(x.0) - i32::from(c.0)).abs() + (i32::from(x.1) - i32::from(c.1)).abs() + (i32::from(x.2) - i32::from(c.2)).abs(); + let best = l.prims.iter().skip(1).filter(|p| p.kind == Kind::Tris).min_by_key(|p| d(p.color)).expect("steps"); + centre_of(l, best.color).expect("step") +} + +#[test] +fn radial_and_inverted_gradients() { + let (blue, green) = (aci_rgb(5), aci_rgb(3)); + let c = Vec2::new(5.0, 5.0); + let l = draw(gradient("SPHERICAL", 0.0, Color::Index(5)), "0"); + assert!(nearest_step(&l, green).dist(c) < 0.5); + let corners: Vec = l.prims.iter().filter(|p| p.kind == Kind::Tris && p.color == blue).flat_map(|p| l.points(p).iter().copied()).collect(); + assert!(!corners.is_empty() && corners.iter().all(|p| p.dist(c) > 4.5), "colour 1 only in the corners: {corners:?}"); + let l = draw(gradient("INVSPHERICAL", 0.0, Color::Index(5)), "0"); + assert!(nearest_step(&l, blue).dist(c) < 0.5); + let l = draw(gradient("CYLINDER", 0.0, Color::Index(5)), "0"); + assert!((nearest_step(&l, green).x - 5.0).abs() < 0.5); + let l = draw(gradient("HEMISPHERICAL", 0.0, Color::Index(5)), "0"); + assert!((nearest_step(&l, green) - Vec2::new(5.0, 0.0)).len() < 0.5); + // Shifted: the highlight moves off the centre. + let mut h = gradient("SPHERICAL", 0.0, Color::Index(5)); + if let Some(g) = h.gradient.as_mut() { + g.centered = false; + } + assert!(nearest_step(&draw(h, "0"), green).dist(c) > 1.0); +} + +#[test] +fn hostile_gradients_stay_bounded() { + let mut h = gradient("NOSUCHGRADIENT", f64::NAN, Color::ByBlock); + h.loops.push(square(-1e300, 1e300)); + let l = draw(h, "0"); + assert!(l.prims.len() <= 65); + let l = draw(Hatch { style: 200, ..gradient("CURVED", 1e308, Color::Index(5)) }, "0"); + assert!(l.prims.len() <= 65 && l.tris.len() <= 3 * 200_000 + 6); +} + +#[test] +fn pattern_lines_from_a_file_are_drawn_for_unknown_names() { + // A grid: horizontal and vertical lines one unit apart. + let fam = |angle| PatternLine { angle, origin: (0.0, 0.0), delta: (0.0, 1.0), dashes: vec![] }; + let h = Hatch { pattern_lines: vec![fam(0.0), fam(90.0)], ..hatch("MYGRID", vec![square(0.5, 9.5)]) }; + let l = draw(h, "0"); + let runs: Vec<&[Vec2]> = l.prims.iter().filter(|p| p.kind == Kind::Polyline).map(|p| l.points(p)).collect(); + assert_eq!(runs.len(), 18); + assert!(runs.iter().all(|r| { r.len() == 2 && ((r[0].x - r[1].x).abs() < 1e-9 || (r[0].y - r[1].y).abs() < 1e-9) })); + // Without a definition an unknown name still falls back to ANSI31 (diagonal lines). + let l = draw(hatch("MYGRID", vec![square(0.5, 9.5)]), "0"); + let p = l.prims.iter().find(|p| p.kind == Kind::Polyline).map(|p| l.points(p)).expect("lines"); + assert!(((p[1] - p[0]).angle().to_degrees().rem_euclid(180.0) - 45.0).abs() < 1e-6); +} From c26150ec07e836dd5084678503d16214b3108269 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:55:55 +0200 Subject: [PATCH 102/150] Dimension and multileader options take effect (fixes #243) (#247) DIMORDINATE Xdatum/Ydatum/Mtext/Text/Angle, DIMRADIUS/DIMDIAMETER Mtext/Text/Angle, DIMANGULAR Mtext/Text/Angle/Quadrant and DIMARC Mtext/Text/Angle/Partial were listed but ignored (any DIMORDINATE keyword other than Xdatum forced a Y datum). DIMLINEAR/DIMALIGNED Angle rotated the dimension line instead of the text. A shared DimText handles Mtext/Text (override text, <> = measurement) and Angle (text rotation) for every placement prompt. Quadrant locks the measured angle to a picked point's side; Partial dimensions the part of the arc between two picked points. After Horizontal/Vertical/ Rotated only the text options remain listed. MLEADER: leader Landing first / Content first switch the placement order, Enter or Options opens the options loop (Leader type, leader lAnding, Content type, Maxpoints, First/Second angle). Spline leaders, leaderless and block content say "not available yet". Angle prompts take numbers so scripts keep splitting the following tokens. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/annotate.rs | 522 ++++++++++++++++++++---- crates/engine/tests/annotate_options.rs | 137 +++++++ 2 files changed, 584 insertions(+), 75 deletions(-) create mode 100644 crates/engine/tests/annotate_options.rs diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index c7c0dacf..f52a2fc0 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -27,12 +27,12 @@ pub fn specs() -> Vec { .menu(&["Dimension", "Radius"]) .alias(&["dra", "dimrad"]) .params("{handle, at?} | {center, point}") - .interactive(|_| Ok(Box::new(RadialM { diameter: false, target: None }))), + .interactive(|_| Ok(Box::new(RadialM::new(false)))), CommandSpec::new("dimdiameter", "Diameter", run_diameter) .menu(&["Dimension", "Diameter"]) .alias(&["ddi", "dimdia"]) .params("{handle, at?} | {center, point}") - .interactive(|_| Ok(Box::new(RadialM { diameter: true, target: None }))), + .interactive(|_| Ok(Box::new(RadialM::new(true)))), CommandSpec::new("dimangular", "Angular", run_angular) .menu(&["Dimension", "Angular"]) .alias(&["dan", "dimang"]) @@ -411,20 +411,22 @@ fn run_qdim(s: &mut Session, p: &Value) -> Result { Ok(json!({ "handles": out })) } -fn mleader_kind(s: &Session, pts: &[Vec2], text: &str) -> Option { - let (landing, arrow_pts) = pts.split_last()?; +/// A multileader from arrowhead to landing; `landing` false leaves out the landing line (dogleg). +fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option { + let (landing_pt, arrow_pts) = pts.split_last()?; let d = s.doc().ok()?; let cur = d.header.str("CMLEADERSTYLE", "Standard"); let st = d.mleader_styles.iter().find(|m| m.name.eq_ignore_ascii_case(&cur)).or(d.mleader_styles.first()).cloned().unwrap_or_default(); let k = d.header.f64("DIMSCALE", 1.0); let th = st.text_height * k; - let dir = if arrow_pts.first().is_some_and(|a| a.x > landing.x) { -1.0 } else { 1.0 }; + let dir = if arrow_pts.first().is_some_and(|a| a.x > landing_pt.x) { -1.0 } else { 1.0 }; let attach = if dir > 0.0 { 4 } else { 6 }; - let tpos = *landing + Vec2::new((st.dogleg * k + st.landing_gap * k) * dir, 0.0); + let dogleg = if landing { st.dogleg * k } else { 0.0 }; + let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap * k) * dir, 0.0); Some(EntityKind::MLeader(MLeader { leaders: vec![arrow_pts.iter().map(|p| v3(*p)).collect()], - landing: v3(*landing), - dogleg: st.dogleg * k, + landing: v3(*landing_pt), + dogleg, text: (!text.is_empty()).then(|| MText { insert: v3(tpos), height: th, @@ -445,7 +447,7 @@ fn run_mleader(s: &mut Session, p: &Value) -> Result { if pts.len() < 2 { return Err(bad("mleader", "need an arrowhead and a landing point")); } - let k = mleader_kind(s, &pts, str_param(p, "text").unwrap_or("")).ok_or_else(|| bad("mleader", "bad points"))?; + let k = mleader_kind(s, &pts, str_param(p, "text").unwrap_or(""), true).ok_or_else(|| bad("mleader", "bad points"))?; Ok(json!({ "handle": s.add_entity(k)?.hex() })) } @@ -788,18 +790,78 @@ fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option { } } +/// What a dimension placement prompt asks after Mtext, Text or Angle. +#[derive(Clone, Copy, PartialEq)] +enum DimAsk { + Text, + Angle, +} + +/// The Mtext / Text / Angle options of the dimension placement prompts: replace the measured text +/// (`<>` stands for the measurement) and rotate the text. +#[derive(Default)] +struct DimText { + text: String, + angle: f64, + asking: Option, +} + +impl DimText { + /// Opens the sub-prompt for Mtext, Text or Angle; `false` for any other keyword. + fn keyword(&mut self, k: &str) -> bool { + self.asking = match k { + "Mtext" | "Text" => Some(DimAsk::Text), + "Angle" => Some(DimAsk::Angle), + _ => return false, + }; + true + } + fn prompt(&self) -> Option { + Some(match self.asking? { + DimAsk::Text => Prompt::new("Enter dimension text (<> = measurement)", Accept::TEXT), + DimAsk::Angle => Prompt::new("Specify angle of dimension text", Accept::NUMBER), + }) + } + /// Takes the answer to the open sub-prompt (Enter keeps the current value); `false` when none is open. + fn answer(&mut self, s: &mut Session, i: &Input) -> bool { + let Some(a) = self.asking else { return false }; + match (a, i) { + (DimAsk::Text, Input::Text(t)) => self.text = t.clone(), + (DimAsk::Angle, Input::Text(t)) => match crate::units::parse_angle(t) { + Some(r) => self.angle = r, + None => { + s.echo("Requires a valid angle."); + return true; + } + }, + _ => {} + } + self.asking = None; + true + } + /// `k` with this text and text angle. + fn apply(&self, mut k: EntityKind) -> EntityKind { + if let EntityKind::Dimension(d) = &mut k { + d.text = self.text.clone(); + d.text_rotation = self.angle; + } + k + } +} + struct LinearM { aligned: bool, pts: Vec, rotation: Option, - text: String, + text: DimText, selecting: bool, - asking: Option<&'static str>, + /// Asking for the dimension line angle (Rotated). + rotating: bool, } impl LinearM { fn new(aligned: bool) -> Self { - LinearM { aligned, pts: Vec::new(), rotation: None, text: String::new(), selecting: false, asking: None } + LinearM { aligned, pts: Vec::new(), rotation: None, text: DimText::default(), selecting: false, rotating: false } } } @@ -808,8 +870,11 @@ impl Interactive for LinearM { if self.aligned { "DIMALIGNED" } else { "DIMLINEAR" } } fn prompt(&self, _s: &Session) -> Prompt { - if let Some(a) = self.asking { - return Prompt::new(a, Accept::TEXT); + if self.rotating { + return Prompt::new("Specify angle of dimension line", Accept::NUMBER); + } + if let Some(p) = self.text.prompt() { + return p; } if self.selecting { return Prompt::new("Select object to dimension", Accept::POINT); @@ -817,19 +882,26 @@ impl Interactive for LinearM { match self.pts.len() { 0 => Prompt::new("Specify first extension line origin", Accept::POINT).default("select object"), 1 => Prompt::new("Specify second extension line origin", Accept::POINT).base_opt(self.pts.first().copied()), - _ if self.aligned => Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle"]), + // Once the orientation is forced, only the text options remain. + _ if self.aligned || self.rotation.is_some() => { + Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle"]) + } _ => Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Horizontal", "Vertical", "Rotated"]), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { - if let Some(a) = self.asking.take() { + if self.rotating { if let Input::Text(t) = &i { - if a.starts_with("Enter dimension text") { - self.text = t.clone(); - } else if let Some(r) = crate::units::parse_angle(t) { - self.rotation = Some(r); - } + let Some(r) = crate::units::parse_angle(t) else { + s.echo("Requires a valid angle."); + return Ok(Step::Continue); + }; + self.rotation = Some(r); } + self.rotating = false; + return Ok(Step::Continue); + } + if self.text.answer(s, &i) { return Ok(Step::Continue); } if self.selecting { @@ -868,7 +940,7 @@ impl Interactive for LinearM { } (_, Input::Point(at)) => { let (Some(&a), Some(&b)) = (self.pts.first(), self.pts.get(1)) else { return Ok(Step::Continue) }; - let k = dim(s, linear_kind(a, b, at, self.rotation, self.aligned), at, a, b, Vec2::ZERO, Vec2::ZERO, &self.text); + let k = self.text.apply(dim(s, linear_kind(a, b, at, self.rotation, self.aligned), at, a, b, Vec2::ZERO, Vec2::ZERO, "")); add_dim(s, k)?; if let Some(EntityKind::Dimension(d)) = s.last_dim.and_then(|h| s.doc().ok()?.entity(h).map(|e| e.kind.clone())) { let st = s.doc()?.dim_style(&d.style).cloned().unwrap_or_default(); @@ -881,9 +953,10 @@ impl Interactive for LinearM { match k.as_str() { "Horizontal" => self.rotation = Some(0.0), "Vertical" => self.rotation = Some(std::f64::consts::FRAC_PI_2), - "Rotated" => self.asking = Some("Specify angle of dimension line"), - "Text" | "Mtext" => self.asking = Some("Enter dimension text (<> = measurement)"), - _ => self.asking = Some("Specify angle of dimension text"), + "Rotated" => self.rotating = true, + other => { + self.text.keyword(other); + } } Ok(Step::Continue) } @@ -894,7 +967,7 @@ impl Interactive for LinearM { fn preview(&self, s: &Session, c: Vec2) -> Vec { match self.pts.as_slice() { [a] => vec![super::helpers::line(*a, c)], - [a, b] => vec![dim(s, linear_kind(*a, *b, c, self.rotation, self.aligned), c, *a, *b, Vec2::ZERO, Vec2::ZERO, &self.text)], + [a, b] => vec![self.text.apply(dim(s, linear_kind(*a, *b, c, self.rotation, self.aligned), c, *a, *b, Vec2::ZERO, Vec2::ZERO, ""))], _ => Vec::new(), } } @@ -903,16 +976,20 @@ impl Interactive for LinearM { struct RadialM { diameter: bool, target: Option<(Vec2, f64)>, + text: DimText, } impl RadialM { + fn new(diameter: bool) -> Self { + RadialM { diameter, target: None, text: DimText::default() } + } fn kind(&self, s: &Session, c: Vec2, r: f64, at: Vec2) -> EntityKind { let pt = c + (at - c).normalized() * r; - if self.diameter { + self.text.apply(if self.diameter { dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "") } else { dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "") - } + }) } } @@ -921,12 +998,18 @@ impl Interactive for RadialM { if self.diameter { "DIMDIAMETER" } else { "DIMRADIUS" } } fn prompt(&self, _s: &Session) -> Prompt { + if let Some(p) = self.text.prompt() { + return p; + } match self.target { None => Prompt::new("Select arc or circle", Accept::POINT), Some((c, _)) => Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle"]).base(c), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.text.answer(s, &i) { + return Ok(Step::Continue); + } match (self.target, i) { (None, Input::Point(p)) => { match pick_at(s, p).and_then(|h| circle_of(s, h)) { @@ -940,6 +1023,10 @@ impl Interactive for RadialM { add_dim(s, k)?; Ok(Step::Done) } + (Some(_), Input::Keyword(k)) => { + self.text.keyword(&k); + Ok(Step::Continue) + } (_, Input::Enter) => Ok(Step::Cancel), _ => Ok(Step::Continue), } @@ -957,6 +1044,10 @@ struct AngularM { vertex_mode: Vec, vertex: bool, arc: Option<(Vec2, Vec2, Vec2)>, + text: DimText, + /// Quadrant: a point inside the angle to measure; the arc location then only sets the radius. + quadrant: Option, + asking_quadrant: bool, } impl AngularM { @@ -973,6 +1064,13 @@ impl AngularM { let far = |p: Vec2, q: Vec2| if p.dist(v) > q.dist(v) { p } else { q }; Some((v, far(a1, a2), far(b1, b2))) } + /// The arc location `at`, turned into the locked quadrant (same radius) when one is set. + fn located(&self, v: Vec2, at: Vec2) -> Vec2 { + match self.quadrant { + Some(q) if q.dist(v) > 1e-12 && at.dist(v) > 1e-12 => v + (q - v).normalized() * at.dist(v), + _ => at, + } + } } impl Interactive for AngularM { @@ -980,6 +1078,12 @@ impl Interactive for AngularM { "DIMANGULAR" } fn prompt(&self, _s: &Session) -> Prompt { + if let Some(p) = self.text.prompt() { + return p; + } + if self.asking_quadrant { + return Prompt::new("Specify quadrant", Accept::POINT); + } if self.geometry().is_some() { return Prompt::new("Specify dimension arc line location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Quadrant"]); } @@ -998,17 +1102,36 @@ impl Interactive for AngularM { } } fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.text.answer(s, &i) { + return Ok(Step::Continue); + } + if self.asking_quadrant { + if let Input::Point(q) = i { + self.quadrant = Some(q); + } + self.asking_quadrant = false; + return Ok(Step::Continue); + } if let Some((v, a, b)) = self.geometry() { return match i { Input::Point(at) => { + let at = self.located(v, at); let k = match (self.handles.as_slice(), self.arc) { ([h1, h2], None) => angular_from_lines(s, *h1, *h2, at), _ => None, }; let k = k.unwrap_or_else(|| dim(s, DimKind::Angular3P, at, a, b, v, Vec2::ZERO, "")); - add_dim(s, k)?; + add_dim(s, self.text.apply(k))?; Ok(Step::Done) } + Input::Keyword(k) => { + if k == "Quadrant" { + self.asking_quadrant = true; + } else { + self.text.keyword(&k); + } + Ok(Step::Continue) + } Input::Enter => Ok(Step::Cancel), _ => Ok(Step::Continue), }; @@ -1063,13 +1186,37 @@ impl Interactive for AngularM { } } fn preview(&self, s: &Session, c: Vec2) -> Vec { - self.geometry().map(|(v, a, b)| vec![dim(s, DimKind::Angular3P, c, a, b, v, Vec2::ZERO, "")]).unwrap_or_default() + self.geometry() + .map(|(v, a, b)| vec![self.text.apply(dim(s, DimKind::Angular3P, self.located(v, c), a, b, v, Vec2::ZERO, ""))]) + .unwrap_or_default() } } #[derive(Default)] struct ArcLenM { arc: Option, + text: DimText, + /// Partial: the points picked so far on the arc (`None` when not choosing a part). + partial: Option>, +} + +/// The part of `arc` between the points nearest `p` and `q` (in the arc's direction). +fn arc_part(arc: &cadcraft_geom::Arc, p: Vec2, q: Vec2) -> Option { + let sweep = arc.sweep(); + // Offset along the arc from its start, clamped to the nearer end when the point is off the arc. + let along = |x: Vec2| { + let t = cadcraft_geom::ccw_sweep(arc.start, arc.center.angle_to(x)); + if t <= sweep { + t + } else if t - sweep < std::f64::consts::TAU - t { + sweep + } else { + 0.0 + } + }; + let (t1, t2) = (along(p), along(q)); + let (lo, hi) = (t1.min(t2), t1.max(t2)); + (hi - lo > 1e-9).then(|| cadcraft_geom::Arc::new(arc.center, arc.radius, arc.start + lo, arc.start + hi)) } impl Interactive for ArcLenM { @@ -1077,12 +1224,36 @@ impl Interactive for ArcLenM { "DIMARC" } fn prompt(&self, _s: &Session) -> Prompt { + if let Some(p) = self.text.prompt() { + return p; + } + if let Some(picked) = &self.partial { + return match picked.first() { + None => Prompt::new("Specify first point for arc length dimension", Accept::POINT), + Some(&f) => Prompt::new("Specify second point for arc length dimension", Accept::POINT).base(f), + }; + } match self.arc { None => Prompt::new("Select arc or polyline arc segment", Accept::POINT), Some(_) => Prompt::new("Specify arc length dimension location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Partial"]), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.text.answer(s, &i) { + return Ok(Step::Continue); + } + if let Some(picked) = self.partial.take() { + match (i, picked.first().copied(), self.arc) { + (Input::Point(p), None, _) => self.partial = Some(vec![p]), + (Input::Point(q), Some(p), Some(g)) => match arc_part(&g, p, q) { + Some(part) => self.arc = Some(part), + None => s.echo("The two points must be different."), + }, + (Input::Enter, ..) => {} + _ => self.partial = Some(picked), + } + return Ok(Step::Continue); + } match (self.arc, i) { (None, Input::Point(p)) => { if let Some(EntityKind::Arc(a)) = pick_at(s, p).and_then(|h| s.doc().ok()?.entity(h).map(|e| e.kind.clone())) { @@ -1093,22 +1264,44 @@ impl Interactive for ArcLenM { Ok(Step::Continue) } (Some(g), Input::Point(at)) => { - add_dim(s, dim(s, DimKind::ArcLength, at, g.start_point(), g.end_point(), g.center, Vec2::ZERO, ""))?; + add_dim(s, self.text.apply(dim(s, DimKind::ArcLength, at, g.start_point(), g.end_point(), g.center, Vec2::ZERO, "")))?; Ok(Step::Done) } + (Some(_), Input::Keyword(k)) => { + if k == "Partial" { + self.partial = Some(Vec::new()); + } else { + self.text.keyword(&k); + } + Ok(Step::Continue) + } (_, Input::Enter) => Ok(Step::Cancel), _ => Ok(Step::Continue), } } fn preview(&self, s: &Session, c: Vec2) -> Vec { - self.arc.map(|g| vec![dim(s, DimKind::ArcLength, c, g.start_point(), g.end_point(), g.center, Vec2::ZERO, "")]).unwrap_or_default() + if self.partial.is_some() { + return Vec::new(); + } + self.arc + .map(|g| vec![self.text.apply(dim(s, DimKind::ArcLength, c, g.start_point(), g.end_point(), g.center, Vec2::ZERO, ""))]) + .unwrap_or_default() } } #[derive(Default)] struct OrdinateM { feature: Option, + /// Xdatum / Ydatum: the forced ordinate type (`true` = X datum). xtype: Option, + text: DimText, +} + +impl OrdinateM { + fn kind(&self, s: &Session, f: Vec2, l: Vec2) -> EntityKind { + let x = self.xtype.unwrap_or((l - f).y.abs() > (l - f).x.abs()); + self.text.apply(dim(s, DimKind::Ordinate { x_type: x }, Vec2::ZERO, f, l, Vec2::ZERO, Vec2::ZERO, "")) + } } impl Interactive for OrdinateM { @@ -1116,24 +1309,36 @@ impl Interactive for OrdinateM { "DIMORDINATE" } fn prompt(&self, _s: &Session) -> Prompt { - match self.feature { - None => Prompt::new("Specify feature location", Accept::POINT), - Some(f) => Prompt::new("Specify leader endpoint", Accept::POINT).kw(&["Xdatum", "Ydatum", "Mtext", "Text", "Angle"]).base(f), + if let Some(p) = self.text.prompt() { + return p; + } + match (self.feature, self.xtype) { + (None, _) => Prompt::new("Specify feature location", Accept::POINT), + (Some(f), None) => Prompt::new("Specify leader endpoint", Accept::POINT).kw(&["Xdatum", "Ydatum", "Mtext", "Text", "Angle"]).base(f), + (Some(f), Some(_)) => Prompt::new("Specify leader endpoint", Accept::POINT).kw(&["Mtext", "Text", "Angle"]).base(f), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.text.answer(s, &i) { + return Ok(Step::Continue); + } match (self.feature, i) { (None, Input::Point(p)) => { self.feature = Some(p); Ok(Step::Continue) } (Some(_), Input::Keyword(k)) => { - self.xtype = Some(k == "Xdatum"); + match k.as_str() { + "Xdatum" => self.xtype = Some(true), + "Ydatum" => self.xtype = Some(false), + other => { + self.text.keyword(other); + } + } Ok(Step::Continue) } (Some(f), Input::Point(l)) => { - let x = self.xtype.unwrap_or((l - f).y.abs() > (l - f).x.abs()); - add_dim(s, dim(s, DimKind::Ordinate { x_type: x }, Vec2::ZERO, f, l, Vec2::ZERO, Vec2::ZERO, ""))?; + add_dim(s, self.kind(s, f, l))?; Ok(Step::Done) } (_, Input::Enter) => Ok(Step::Cancel), @@ -1141,20 +1346,7 @@ impl Interactive for OrdinateM { } } fn preview(&self, s: &Session, c: Vec2) -> Vec { - self.feature - .map(|f| { - vec![dim( - s, - DimKind::Ordinate { x_type: self.xtype.unwrap_or((c - f).y.abs() > (c - f).x.abs()) }, - Vec2::ZERO, - f, - c, - Vec2::ZERO, - Vec2::ZERO, - "", - )] - }) - .unwrap_or_default() + self.feature.map(|f| vec![self.kind(s, f, c)]).unwrap_or_default() } } @@ -1210,10 +1402,168 @@ impl Interactive for ChainM { } } -#[derive(Default)] +/// Which end of the multileader MLEADER places first. +#[derive(Clone, Copy, Default, PartialEq)] +enum LeaderOrder { + #[default] + Arrowhead, + Landing, + Content, +} + +/// The MLEADER Options prompt being answered. +#[derive(Clone, Copy, PartialEq)] +enum LeaderOpt { + Menu, + LeaderType, + Landing, + ContentType, + MaxPoints, + FirstAngle, + SecondAngle, +} + +/// Upper bound for MLEADER Maxpoints. +const MAX_LEADER_POINTS: usize = 64; + struct MLeaderM { + order: LeaderOrder, + /// Points in the order they were placed. pts: Vec, - landed: bool, + /// All leader points are placed. + leader_done: bool, + /// The leader text once entered (Content first enters it before the leader). + text: Option, + opt: Option, + max_points: usize, + /// First and second angle constraints (radians; 0 = free). + angles: [f64; 2], + landing: bool, + content: bool, +} + +impl Default for MLeaderM { + fn default() -> Self { + MLeaderM { + order: LeaderOrder::Arrowhead, + pts: Vec::new(), + leader_done: false, + text: None, + opt: None, + max_points: 2, + angles: [0.0; 2], + landing: true, + content: true, + } + } +} + +impl MLeaderM { + /// The leader from arrowhead to landing, with `extra` placed next. + fn leader(&self, extra: Option) -> Vec { + let mut pts = self.pts.clone(); + pts.extend(extra); + if self.order != LeaderOrder::Arrowhead { + pts.reverse(); + } + pts + } + /// `p` with the first/second angle constraint applied to the segment it ends (counted from the + /// first point placed). + fn constrained(&self, p: Vec2) -> Vec2 { + let (Some(&prev), Some(&inc)) = (self.pts.last(), self.angles.get(self.pts.len().wrapping_sub(1))) else { return p }; + let len = prev.dist(p); + if inc <= 1e-9 || !inc.is_finite() || len <= 1e-12 { + return p; + } + Vec2::polar(prev, len, ((p - prev).angle() / inc).round() * inc) + } + /// The text is asked now: right after the content location (Content first) or after the leader. + fn wants_text(&self) -> bool { + self.content && self.text.is_none() && (self.leader_done || (self.order == LeaderOrder::Content && self.pts.len() == 1)) + } + fn finish(&self, s: &mut Session) -> Result { + let text = self.text.clone().unwrap_or_default(); + let k = mleader_kind(s, &self.leader(None), &text, self.landing).ok_or_else(|| EngineError::Other("bad leader".into()))?; + s.add_entity(k)?; + Ok(Step::Done) + } + fn option_prompt(o: LeaderOpt) -> Prompt { + let kw = super::curves::KW; + match o { + LeaderOpt::Menu => Prompt::new("Enter an option", kw) + .kw(&["Leader type", "leader lAnding", "Content type", "Maxpoints", "First angle", "Second angle", "eXit options"]) + .default("eXit options"), + LeaderOpt::LeaderType => Prompt::new("Select a leader type", kw).kw(&["Straight", "sPline", "None"]).default("Straight"), + LeaderOpt::Landing => Prompt::new("Use landing", kw).kw(&["Yes", "No"]).default("Yes"), + LeaderOpt::ContentType => Prompt::new("Select a content type", kw).kw(&["Block", "Mtext", "None"]).default("Mtext"), + LeaderOpt::MaxPoints => Prompt::new("Enter the maximum points for leader line", Accept::NUMBER), + LeaderOpt::FirstAngle => Prompt::new("Enter first angle constraint", Accept::NUMBER), + LeaderOpt::SecondAngle => Prompt::new("Enter second angle constraint", Accept::NUMBER), + } + } + /// One answer at the Options prompts; Enter keeps the current value. + fn option(&mut self, s: &mut Session, o: LeaderOpt, i: Input) -> Option { + use LeaderOpt::*; + match (o, i) { + (Menu, Input::Keyword(k)) => match k.as_str() { + "Leader type" => Some(LeaderType), + "leader lAnding" => Some(Landing), + "Content type" => Some(ContentType), + "Maxpoints" => Some(MaxPoints), + "First angle" => Some(FirstAngle), + "Second angle" => Some(SecondAngle), + _ => None, + }, + (Menu, Input::Enter) => None, + (LeaderType, Input::Keyword(k)) => { + match k.as_str() { + "sPline" => s.echo("Spline leaders are not available yet; the leader stays straight."), + "None" => s.echo("Multileaders without a leader line are not available yet."), + _ => {} + } + Some(Menu) + } + (Landing, Input::Keyword(k)) => { + self.landing = k == "Yes"; + Some(Menu) + } + (ContentType, Input::Keyword(k)) => { + match k.as_str() { + "Block" => s.echo("Block content is not available yet; the content stays multiline text."), + "None" => self.content = false, + _ => self.content = true, + } + Some(Menu) + } + (MaxPoints, Input::Text(t)) => match t.trim().parse::() { + Ok(n) if (2..=MAX_LEADER_POINTS).contains(&n) => { + self.max_points = n; + Some(Menu) + } + _ => { + s.echo(format!("Requires an integer between 2 and {MAX_LEADER_POINTS}.")); + Some(MaxPoints) + } + }, + (FirstAngle | SecondAngle, Input::Text(t)) => match crate::units::parse_angle(&t).filter(|a| *a >= 0.0) { + Some(a) => { + self.angles[usize::from(o == SecondAngle)] = a; + Some(Menu) + } + None => { + s.echo("Requires a valid angle."); + Some(o) + } + }, + (Menu | LeaderType | Landing | ContentType, Input::Text(_)) => { + s.echo("Invalid option keyword."); + Some(o) + } + (_, Input::Enter) => Some(Menu), + _ => Some(o), + } + } } impl Interactive for MLeaderM { @@ -1221,43 +1571,65 @@ impl Interactive for MLeaderM { "MLEADER" } fn prompt(&self, _s: &Session) -> Prompt { - if self.landed { + if let Some(o) = self.opt { + return Self::option_prompt(o); + } + if self.wants_text() { return Prompt::new("Enter leader text", Accept::TEXT); } - match self.pts.len() { - 0 => Prompt::new("Specify leader arrowhead location", Accept::POINT).kw(&["leader Landing first", "Content first", "Options"]), - _ => Prompt::new("Specify leader landing location", Accept::POINT).base_opt(self.pts.last().copied()), + let last = self.pts.len() + 1 >= self.max_points; + match (self.pts.last(), self.order) { + (None, LeaderOrder::Arrowhead) => Prompt::new("Specify leader arrowhead location", Accept::POINT) + .kw(&["leader Landing first", "Content first", "Options"]) + .default("Options"), + (None, LeaderOrder::Landing) => Prompt::new("Specify leader landing location", Accept::POINT) + .kw(&["leader arrowHead first", "Content first", "Options"]) + .default("Options"), + (None, LeaderOrder::Content) => Prompt::new("Specify location of the text", Accept::POINT) + .kw(&["leader arrowHead first", "leader Landing first", "Options"]) + .default("Options"), + (Some(&b), _) if !last => Prompt::new("Specify next point", Accept::POINT).base(b), + (Some(&b), LeaderOrder::Arrowhead) => Prompt::new("Specify leader landing location", Accept::POINT).base(b), + (Some(&b), _) => Prompt::new("Specify leader arrowhead location", Accept::POINT).base(b), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { - if self.landed { - let t = match i { - Input::Text(t) => t, - _ => String::new(), - }; - let k = mleader_kind(s, &self.pts, &t).ok_or_else(|| EngineError::Other("bad leader".into()))?; - s.add_entity(k)?; - return Ok(Step::Done); + if let Some(o) = self.opt { + self.opt = self.option(s, o, i); + return Ok(Step::Continue); + } + if self.wants_text() { + self.text = Some(if let Input::Text(t) = i { t } else { String::new() }); + return if self.leader_done { self.finish(s) } else { Ok(Step::Continue) }; } match i { Input::Point(p) => { + let p = self.constrained(p); self.pts.push(p); - if self.pts.len() >= 2 { - self.landed = true; + if self.pts.len() >= self.max_points { + self.leader_done = true; } - Ok(Step::Continue) } - Input::Enter => Ok(Step::Cancel), - _ => Ok(Step::Continue), + Input::Keyword(k) if self.pts.is_empty() => match k.as_str() { + "leader Landing first" => self.order = LeaderOrder::Landing, + "leader arrowHead first" => self.order = LeaderOrder::Arrowhead, + "Content first" => self.order = LeaderOrder::Content, + _ => self.opt = Some(LeaderOpt::Menu), + }, + Input::Enter if self.pts.is_empty() => self.opt = Some(LeaderOpt::Menu), + // Enter at "next point" ends the leader at the last point. + Input::Enter if self.pts.len() >= 2 => self.leader_done = true, + Input::Enter => return Ok(Step::Cancel), + _ => {} } + if self.leader_done && !self.wants_text() { self.finish(s) } else { Ok(Step::Continue) } } fn preview(&self, s: &Session, c: Vec2) -> Vec { - if self.landed || self.pts.is_empty() { + if self.opt.is_some() || self.wants_text() || self.leader_done || self.pts.is_empty() { return Vec::new(); } - let mut pts = self.pts.clone(); - pts.push(c); - mleader_kind(s, &pts, "").into_iter().collect() + let text = self.text.as_deref().unwrap_or(""); + mleader_kind(s, &self.leader(Some(self.constrained(c))), text, self.landing).into_iter().collect() } } diff --git a/crates/engine/tests/annotate_options.rs b/crates/engine/tests/annotate_options.rs new file mode 100644 index 00000000..4910a8ad --- /dev/null +++ b/crates/engine/tests/annotate_options.rs @@ -0,0 +1,137 @@ +//! Options listed at the dimension and multileader prompts take effect (#243). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{DimKind, Dimension, EntityKind, MLeader}; +use cadcraft_engine::geom::Vec2; + +fn last(s: &Session) -> EntityKind { + s.doc().unwrap().model.last().unwrap().kind.clone() +} + +fn last_dim(s: &Session) -> Dimension { + match last(s) { + EntityKind::Dimension(d) => d, + other => panic!("not a dimension: {other:?}"), + } +} + +fn last_mleader(s: &Session) -> MLeader { + match last(s) { + EntityKind::MLeader(m) => m, + other => panic!("not a multileader: {other:?}"), + } +} + +fn value(s: &Session, d: &Dimension) -> String { + cadcraft_engine::render::dimension_in(s.doc().unwrap(), d).value +} + +fn near(a: Vec2, b: Vec2) -> bool { + a.dist(b) < 1e-6 +} + +#[test] +fn dimlinear_angle_rotates_the_text_and_text_replaces_it() { + let mut s = Session::new(); + s.script("dimlinear 0,0 10,0 a 30 t\nX=<>\n5,-3\n").unwrap(); + let d = last_dim(&s); + assert_eq!(d.kind, DimKind::Linear { rotation: 0.0 }, "Angle must not rotate the dimension line"); + assert!((d.text_rotation - 30f64.to_radians()).abs() < 1e-9, "{}", d.text_rotation); + assert_eq!(d.text, "X=<>"); + assert!(s.running.is_none()); +} + +#[test] +fn dimlinear_vertical_forces_the_orientation() { + let mut s = Session::new(); + s.script("dimlinear 0,0 10,5 v").unwrap(); + let p = s.prompt_text(); + assert!(p.contains("[Mtext/Text/Angle]"), "{p}"); + s.script("5,-3\n").unwrap(); + assert_eq!(last_dim(&s).kind, DimKind::Linear { rotation: std::f64::consts::FRAC_PI_2 }); +} + +#[test] +fn dimordinate_datum_and_text_options() { + let mut s = Session::new(); + // The leader endpoint alone would make a Y datum; Xdatum forces X, and Text is a text option. + s.script("dimordinate 0,0 x t\nA <>\n10,2\n").unwrap(); + let d = last_dim(&s); + assert_eq!(d.kind, DimKind::Ordinate { x_type: true }); + assert_eq!(d.text, "A <>"); + s.script("dimordinate 0,0 y a 90 2,10\n").unwrap(); + let d = last_dim(&s); + assert_eq!(d.kind, DimKind::Ordinate { x_type: false }); + assert!((d.text_rotation - std::f64::consts::FRAC_PI_2).abs() < 1e-9); +} + +#[test] +fn dimradius_text_options() { + let mut s = Session::new(); + s.script("circle 0,0 5\ndimradius 5,0 t\nR=<>\na 45 8,8\n").unwrap(); + let d = last_dim(&s); + assert_eq!(d.kind, DimKind::Radius); + assert_eq!(d.text, "R=<>"); + assert!((d.text_rotation - 45f64.to_radians()).abs() < 1e-9); +} + +#[test] +fn dimangular_quadrant_locks_the_measured_angle() { + let mut s = Session::new(); + // Placed at (-3,-4) the arc would measure the 270° side; the quadrant keeps the 90° one. + s.script("dimangular\n\n0,0 10,0 0,10 q 5,5 -3,-4\n").unwrap(); + let d = last_dim(&s); + assert_eq!(value(&s, &d), "90°"); + assert!(near(d.defpt.xy(), Vec2::new(5.0, 5.0).normalized() * 5.0), "{:?}", d.defpt); +} + +#[test] +fn dimarc_partial_measures_part_of_the_arc() { + let mut s = Session::new(); + s.script("arc c 0,0 -10,0 10,0\n").unwrap(); + assert!(matches!(last(&s), EntityKind::Arc(_)), "{:?}", last(&s)); + s.script("dimarc 0,-10 p 10,0 0,-10 5,-12\n").unwrap(); + let d = last_dim(&s); + assert_eq!(d.kind, DimKind::ArcLength); + assert!(near(d.p13.xy(), Vec2::new(0.0, -10.0)) && near(d.p14.xy(), Vec2::new(10.0, 0.0)), "{d:?}"); + assert_eq!(value(&s, &d), "⌒15.7080"); +} + +#[test] +fn mleader_landing_first_and_content_first() { + let mut s = Session::new(); + s.script("mleader l 10,5 0,0 note\n").unwrap(); + let m = last_mleader(&s); + assert_eq!(m.leaders.len(), 1); + assert!(near(m.leaders[0][0].xy(), Vec2::ZERO) && near(m.landing.xy(), Vec2::new(10.0, 5.0)), "{m:?}"); + assert_eq!(m.text.unwrap().contents, "note"); + + s.script("mleader c 10,5 hello\n0,0\n").unwrap(); + let m = last_mleader(&s); + assert!(near(m.leaders[0][0].xy(), Vec2::ZERO) && near(m.landing.xy(), Vec2::new(10.0, 5.0)), "{m:?}"); + assert_eq!(m.text.unwrap().contents, "hello"); +} + +#[test] +fn mleader_options_take_effect() { + let mut s = Session::new(); + // Enter opens Options; Maxpoints 3 and no landing line. + s.script("mleader\n\nm 3 a n x 0,0 5,5 10,5 txt\n").unwrap(); + let m = last_mleader(&s); + assert_eq!(m.leaders[0].len(), 2, "{m:?}"); + assert!(near(m.leaders[0][1].xy(), Vec2::new(5.0, 5.0)) && near(m.landing.xy(), Vec2::new(10.0, 5.0))); + assert_eq!(m.dogleg, 0.0); + assert_eq!(m.text.unwrap().contents, "txt"); + + // First angle constraint snaps the leader to 45° steps. + s.script("mleader o f 45 x 0,0 10,8 note\n").unwrap(); + let m = last_mleader(&s); + let r = Vec2::new(10.0, 8.0).len(); + assert!(near(m.landing.xy(), Vec2::new(r, r) / 2f64.sqrt()), "{:?}", m.landing); + + // Unmodelled choices say so and keep the following input. + s.script("mleader o l p x 0,0 4,4 hi\n").unwrap(); + assert!(s.log.iter().any(|l| l.contains("not available yet")), "{:?}", s.log); + assert_eq!(last_mleader(&s).text.unwrap().contents, "hi"); + assert!(s.running.is_none()); +} From 776d99a1e01ff3ab1a414c3f041c75823d1ad54d Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 22:56:01 +0200 Subject: [PATCH 103/150] FILLET/CHAMFER: Trim, Multiple, Undo; CHAMFER Angle, mEthod, Polyline (fixes #248) (#251) * Pick box at object-selection prompts; prompts remember the last option Part of #34. The crosshair showed the pick box only at "Select objects" prompts. The commands that pick one object themselves (FILLET, CHAMFER, OFFSET, TRIM, EXTEND, BREAK, the dimension and constraint picks...) ask with a plain point prompt, so the cursor stayed a bare crosshair there and hitting an object was guesswork. The idle crosshair also showed the box with PICKFIRST off. - Engine: Prompt::picks_objects() (a selection prompt, or a point prompt that starts with "Select") and Session::picking_objects() (that, or no command with PICKFIRST on). The canvas uses it for the pick box and the rollover highlight. - The box is sized from PICKBOX as the pick aperture the click uses (PICKBOX x 1.5 px half side), hidden at PICKBOX 0, and drawn with a new `crosshair` theme token. - HATCH/GRADIENT: "Select objects" mode is a real mode now ("Select objects or [picK internal point/...]", Enter creates the hatch) and the next HATCH starts in the mode used last. Pattern, scale and angle sub-prompts show the current value as . At select prompts P, L and ALL stay selection modes even when a keyword shares the letter. - POLYGON offers the last Inscribed/Circumscribed choice, ROTATE the last angle, SCALE the last factor (Session::last_used, session only). - FILLET shows the radius as ; CHAMFER asks for the second distance too, defaulting to the first (FILLETRAD, CHAMFERA/B). JSON calls are unchanged: explicit params and documented defaults win. Co-Authored-By: Claude Opus 5.5 * FILLET/CHAMFER Trim, Multiple, Undo; CHAMFER Angle, mEthod, Polyline (fixes #248) Trim asks for the trim mode (TRIMMODE); with No trim the fillet arc or chamfer line is added and both objects are kept. Multiple repeats until Enter and Undo takes back the last fillet/chamfer of the command. CHAMFER Angle asks for a length on the first line and an angle from it (CHAMFERC, CHAMFERD, CHAMMODE = 1), mEthod picks Distance or Angle, and Polyline chamfers every straight corner of a 2D polyline. The JSON forms take `trim`, and CHAMFER `length`/`angle` and `polyline`. Keyword matching now prefers a whole keyword or its capital-letter shortcut over a prefix of another keyword, so `M` is Multiple rather than the start of mEthod. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/draw.rs | 19 +- crates/engine/src/cmd/hatch.rs | 156 +++++---- crates/engine/src/cmd/machines.rs | 106 +++++- crates/engine/src/cmd/modify.rs | 457 ++++++++++++++++++++++---- crates/engine/src/lib.rs | 46 ++- crates/engine/src/prompt.rs | 22 +- crates/engine/tests/fillet_chamfer.rs | 123 +++++++ crates/ui-egui/src/canvas.rs | 20 +- crates/ui-egui/src/theme.rs | 3 + 9 files changed, 813 insertions(+), 139 deletions(-) create mode 100644 crates/engine/tests/fillet_chamfer.rs diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index bdc5e788..ea5266bb 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -790,9 +790,9 @@ impl Interactive for PolygonM { ) .base_opt(self.edge_pts.first().copied()), (Some(_), false, None, _) => Prompt::new("Specify center of polygon", Accept::POINT).kw(&["Edge"]), - (Some(_), false, Some(_), None) => { - Prompt::new("Enter an option", Accept::TEXT).kw(&["Inscribed in circle", "Circumscribed about circle"]).default("I") - } + (Some(_), false, Some(_), None) => Prompt::new("Enter an option", Accept::TEXT) + .kw(&["Inscribed in circle", "Circumscribed about circle"]) + .default(if s.last_used.polygon_circumscribed { "C" } else { "I" }), (Some(_), false, Some(c), Some(_)) => Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base(c), } } @@ -826,9 +826,16 @@ impl Interactive for PolygonM { match (self.center, self.inscribed, i) { (None, _, Input::Keyword(k)) if k == "Edge" => self.edge = true, (None, _, Input::Point(p)) => self.center = Some(p), - (Some(_), None, Input::Keyword(k)) => self.inscribed = Some(k.starts_with('I')), - (Some(_), None, Input::Enter) => self.inscribed = Some(true), - (Some(_), None, Input::Text(t)) => self.inscribed = Some(!t.trim().to_ascii_lowercase().starts_with('c')), + (Some(_), None, inp @ (Input::Keyword(_) | Input::Enter | Input::Text(_))) => { + // Enter takes the option used last time (the prompt's default). + let ins = match inp { + Input::Keyword(k) => k.starts_with('I'), + Input::Text(t) => !t.trim().to_ascii_lowercase().starts_with('c'), + _ => !s.last_used.polygon_circumscribed, + }; + self.inscribed = Some(ins); + s.last_used.polygon_circumscribed = !ins; + } (Some(c), Some(ins), Input::Point(p)) => { s.add_entity(lwpoly(polygon_vertices(c, n, p, ins), true))?; return Ok(Step::Done); diff --git a/crates/engine/src/cmd/hatch.rs b/crates/engine/src/cmd/hatch.rs index 4c49df2c..8c6f5380 100644 --- a/crates/engine/src/cmd/hatch.rs +++ b/crates/engine/src/cmd/hatch.rs @@ -283,17 +283,76 @@ fn run_hatchedit(s: &mut Session, p: &Value) -> Result { s.execute("properties.set", &q) } +/// What a HATCH sub-prompt asks for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HatchAsk { + Pattern, + Scale, + Angle, +} + struct HatchM { gradient: bool, settings: HatchSettings, loops: Vec, - asking: Option<&'static str>, + asking: Option, + /// "Select objects" mode instead of "Pick internal point"; the next HATCH starts in it again. selecting: bool, } impl HatchM { fn new(s: &Session, gradient: bool) -> Self { - HatchM { gradient, settings: HatchSettings::from(s, &Value::Null, gradient), loops: Vec::new(), asking: None, selecting: false } + HatchM { + gradient, + settings: HatchSettings::from(s, &Value::Null, gradient), + loops: Vec::new(), + asking: None, + selecting: s.last_used.hatch_select, + } + } + fn set_mode(&mut self, s: &mut Session, selecting: bool) { + self.selecting = selecting; + s.last_used.hatch_select = selecting; + } + fn finish(&mut self, s: &mut Session) -> Result { + if !self.loops.is_empty() { + let k = self.settings.hatch(std::mem::take(&mut self.loops)); + let h = s.add_entity(k)?; + let space = s.space(); + if let Some(store) = s.doc_mut()?.space_mut(&space) { + store.send_to_back(h); + } + } + Ok(Step::Done) + } + fn answer(&mut self, s: &mut Session, a: HatchAsk, t: &str) -> Result<()> { + match a { + HatchAsk::Pattern => { + let up = t.trim().to_ascii_uppercase(); + if cadcraft_doc::library::pattern(&up).is_some() { + self.settings.pattern = up.clone(); + s.doc_mut()?.header.set_str("HPNAME", &up); + } else { + s.echo(format!( + "Unknown pattern {up}. Available: {}", + cadcraft_doc::library::standard_patterns().iter().map(|p| p.name).collect::>().join(", ") + )); + } + } + HatchAsk::Scale => { + if let Some(v) = super::machines::number(t).filter(|v| v.is_finite() && *v > 0.0) { + self.settings.scale = v; + s.doc_mut()?.header.set_f64("HPSCALE", v); + } + } + HatchAsk::Angle => { + if let Some(a) = crate::units::parse_angle(t).filter(|a| a.is_finite()) { + self.settings.angle = a; + s.doc_mut()?.header.set_f64("HPANG", a); + } + } + } + Ok(()) } } @@ -310,60 +369,34 @@ impl Interactive for HatchM { )); Ok(Step::Continue) } - fn prompt(&self, _s: &Session) -> Prompt { - if let Some(a) = self.asking { - return Prompt::new(a, Accept::TEXT); + fn prompt(&self, s: &Session) -> Prompt { + // The sub-prompts offer the current value: Enter keeps it. + match self.asking { + Some(HatchAsk::Pattern) => return Prompt::new("Enter a pattern name", Accept::TEXT).default(self.settings.pattern.clone()), + Some(HatchAsk::Scale) => { + return Prompt::new("Specify a scale for the hatch pattern", Accept::NUMBER).default(format!("{:.4}", self.settings.scale)); + } + Some(HatchAsk::Angle) => { + let (au, ap) = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0)); + let d = crate::units::format_angle(self.settings.angle, au, ap); + return Prompt::new("Specify an angle for the hatch pattern", Accept::NUMBER).default(d); + } + None => {} } if self.selecting { - return Prompt::new("Select objects", Accept::SELECT); + return Prompt::new("Select objects", Accept::SELECT).kw(&["picK internal point", "Undo", "seTtings", "sCale", "Angle"]); } Prompt::new("Pick internal point", Accept::POINT).kw(&["Select objects", "Undo", "seTtings", "Pattern", "sCale", "Angle"]) } fn input(&mut self, s: &mut Session, i: Input) -> Result { if let Some(a) = self.asking.take() { if let Input::Text(t) | Input::Keyword(t) = &i { - match a { - "Enter a pattern name" => { - let up = t.trim().to_ascii_uppercase(); - if cadcraft_doc::library::pattern(&up).is_some() { - self.settings.pattern = up.clone(); - s.doc_mut()?.header.set_str("HPNAME", &up); - } else { - s.echo(format!( - "Unknown pattern {up}. Available: {}", - cadcraft_doc::library::standard_patterns().iter().map(|p| p.name).collect::>().join(", ") - )); - } - } - "Specify a scale for the hatch pattern" => { - if let Some(v) = super::machines::number(t).filter(|v| *v > 0.0) { - self.settings.scale = v; - s.doc_mut()?.header.set_f64("HPSCALE", v); - } - } - _ => { - if let Some(a) = crate::units::parse_angle(t) { - self.settings.angle = a; - s.doc_mut()?.header.set_f64("HPANG", a); - } - } - } - } - return Ok(Step::Continue); - } - if self.selecting { - match i { - Input::Pick(hs) => match loops_from_handles(s, &hs) { - Ok(l) => self.loops.extend(l), - Err(e) => s.echo(e.to_string()), - }, - Input::Enter => self.selecting = false, - _ => {} + self.answer(s, a, t)?; } return Ok(Step::Continue); } match i { - Input::Point(p) => { + Input::Point(p) if !self.selecting => { match loops_at(s, p) { Ok(l) => { s.echo(format!("Analyzing the selected data... {} loop(s)", l.len())); @@ -373,29 +406,30 @@ impl Interactive for HatchM { } Ok(Step::Continue) } - Input::Keyword(k) => { - match k.as_str() { - "Select objects" => self.selecting = true, - "Undo" => { - self.loops.clear(); + Input::Pick(hs) => { + match loops_from_handles(s, &hs) { + Ok(l) => { + s.echo(format!("{} found", hs.len())); + self.loops.extend(l); } - "Pattern" | "seTtings" => self.asking = Some("Enter a pattern name"), - "sCale" => self.asking = Some("Specify a scale for the hatch pattern"), - _ => self.asking = Some("Specify an angle for the hatch pattern"), + Err(e) => s.echo(e.to_string()), } Ok(Step::Continue) } - Input::Enter => { - if !self.loops.is_empty() { - let k = self.settings.hatch(std::mem::take(&mut self.loops)); - let h = s.add_entity(k)?; - let space = s.space(); - if let Some(store) = s.doc_mut()?.space_mut(&space) { - store.send_to_back(h); - } + Input::Keyword(k) => { + match k.as_str() { + "Select objects" => self.set_mode(s, true), + "picK internal point" => self.set_mode(s, false), + "Undo" => self.loops.clear(), + "Pattern" | "seTtings" => self.asking = Some(HatchAsk::Pattern), + "sCale" => self.asking = Some(HatchAsk::Scale), + "Angle" => self.asking = Some(HatchAsk::Angle), + _ => {} } - Ok(Step::Done) + Ok(Step::Continue) } + // Enter ends boundary picking in either mode and creates the hatch. + Input::Enter => self.finish(s), _ => Ok(Step::Continue), } } diff --git a/crates/engine/src/cmd/machines.rs b/crates/engine/src/cmd/machines.rs index c61c23c1..b3f017f0 100644 --- a/crates/engine/src/cmd/machines.rs +++ b/crates/engine/src/cmd/machines.rs @@ -165,7 +165,7 @@ pub fn number(t: &str) -> Option { #[cfg(test)] mod tests { - use cadcraft_doc::Handle; + use cadcraft_doc::{EntityKind, Handle}; use cadcraft_geom::Vec2; use serde_json::json; @@ -229,4 +229,108 @@ mod tests { assert_eq!(s.doc().unwrap().model.len(), 1); assert!(s.doc().unwrap().layer("0").is_some_and(|l| l.on)); } + + /// The cursor shows the pick box exactly where a click picks objects (#34): "Select objects", + /// single-object prompts, and the idle crosshair when PICKFIRST is on. + #[test] + fn pick_box_at_object_selection_prompts() { + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + assert!(s.picking_objects(), "idle with PICKFIRST on"); + s.settings.pickfirst = false; + assert!(!s.picking_objects(), "idle with PICKFIRST off"); + s.settings.pickfirst = true; + for (cmd, picks) in [("erase", true), ("fillet", true), ("offset", false), ("line", false), ("hatch", false)] { + s.cmdline(cmd).unwrap(); + assert_eq!(s.picking_objects(), picks, "{cmd}: {}", s.prompt_text()); + s.cancel(); + } + // OFFSET's object pick, FILLET's radius, HATCH's select mode. + s.cmdline("offset 2").unwrap(); + assert!(s.picking_objects(), "{}", s.prompt_text()); + s.cancel(); + s.cmdline("fillet r").unwrap(); + assert!(!s.picking_objects(), "{}", s.prompt_text()); + s.cancel(); + s.cmdline("hatch s").unwrap(); + assert!(s.picking_objects(), "{}", s.prompt_text()); + s.cancel(); + } + + /// Commands offer the option and values used last time as the ``, and Enter takes it + /// (#34). JSON calls keep their documented defaults. + #[test] + fn prompts_remember_last_options() { + let mut s = Session::new(); + s.execute("rectang", &json!({"p1": [0, 0], "p2": [10, 10]})).unwrap(); + let rect = s.doc().unwrap().model.iter().next().unwrap().handle; + + // HATCH: the "Select objects" mode sticks; `P` there is still Previous, not "picK". + s.cmdline("hatch s").unwrap(); + assert!(s.prompt_text().contains("Select objects or [picK internal point"), "{}", s.prompt_text()); + s.input(Input::Pick(vec![rect])).unwrap(); + s.cmdline("").unwrap(); + assert!(s.running.is_none(), "Enter creates the hatch"); + let kinds: Vec<&str> = s.doc().unwrap().model.iter().map(|e| e.kind.type_name()).collect(); + assert_eq!(kinds, vec!["Hatch", "Polyline"]); + s.cmdline("hatch").unwrap(); + assert!(s.prompt_text().contains("HATCH Select objects"), "{}", s.prompt_text()); + s.cmdline("p").unwrap(); + assert!(s.prompt_text().contains("HATCH Select objects"), "{}", s.prompt_text()); + // Pattern scale: the current value is the default, Enter keeps it. + s.cmdline("c").unwrap(); + assert!(s.prompt_text().ends_with("<1.0000>:"), "{}", s.prompt_text()); + s.cmdline("2").unwrap(); + s.cmdline("c").unwrap(); + assert!(s.prompt_text().ends_with("<2.0000>:"), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + s.cmdline("k").unwrap(); + s.cancel(); + s.cmdline("hatch").unwrap(); + assert!(s.prompt_text().contains("HATCH Pick internal point"), "{}", s.prompt_text()); + s.cancel(); + + // POLYGON: sides and Inscribed/Circumscribed. + s.cmdline("polygon 5 30,0 c 2").unwrap(); + s.cmdline("polygon").unwrap(); + assert!(s.prompt_text().ends_with("<5>:"), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + s.cmdline("40,0").unwrap(); + assert!(s.prompt_text().ends_with(":"), "{}", s.prompt_text()); + s.cancel(); + + // FILLET radius and CHAMFER distances (the second defaults to the first). + s.cmdline("fillet r 2").unwrap(); + s.cmdline("r").unwrap(); + assert!(s.prompt_text().ends_with("<2.0000>:"), "{}", s.prompt_text()); + s.cancel(); + s.cmdline("chamfer d 1.5").unwrap(); + assert!(s.prompt_text().contains("second chamfer distance <1.5000>:"), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + assert_eq!(s.doc().unwrap().header.f64("CHAMFERB", 0.0), 1.5); + s.cancel(); + + // ROTATE angle and SCALE factor. + s.set_selection(vec![rect]); + s.cmdline("rotate 0,0 90").unwrap(); + s.set_selection(vec![rect]); + s.cmdline("rotate 0,0").unwrap(); + assert!(s.prompt_text().ends_with("<90>:"), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + s.set_selection(vec![rect]); + s.cmdline("scale 0,0 2").unwrap(); + s.set_selection(vec![rect]); + s.cmdline("scale 0,0").unwrap(); + assert!(s.prompt_text().ends_with("<2.0000>:"), "{}", s.prompt_text()); + s.cancel(); + + // JSON calls are not affected by what was remembered. + let r = s.execute("polygon", &json!({"center": [60, 0], "radius": 1, "sides": 4})).unwrap(); + let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap(); + match &s.doc().unwrap().entity(h).unwrap().kind { + EntityKind::LwPolyline(p) => assert!((p.vertices[0].p.dist(Vec2::new(60.0, 0.0)) - 1.0).abs() < 1e-9, "inscribed"), + other => panic!("{other:?}"), + } + } } diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 013efd02..2fe21c89 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -67,12 +67,12 @@ pub fn specs() -> Vec { CommandSpec::new("fillet", "Fillet", run_fillet) .menu(&["Modify", "Fillet"]) .alias(&["f"]) - .params("{h1, p1, h2, p2, radius?} (lines, arcs, circles) | {handle, polyline: true, radius?}") + .params("{h1, p1, h2, p2, radius?, trim?: bool} (lines, arcs, circles) | {handle, polyline: true, radius?}") .interactive(|s| Ok(Box::new(FilletM::new(s, false)))), CommandSpec::new("chamfer", "Chamfer", run_chamfer) .menu(&["Modify", "Chamfer"]) .alias(&["cha"]) - .params("{h1, p1, h2, p2, d1?, d2?}") + .params("{h1, p1, h2, p2, d1?, d2? | length, angle (degrees), trim?: bool} | {handle, polyline: true, d1?, d2? | length, angle}") .interactive(|s| Ok(Box::new(FilletM::new(s, true)))), CommandSpec::new("explode", "Explode", run_explode) .menu(&["Modify", "Explode"]) @@ -863,18 +863,30 @@ fn run_fillet(s: &mut Session, p: &Value) -> Result { let r = f64_or(p, "radius", s.doc()?.header.f64("FILLETRAD", 0.0)); let p1 = point_param(p, "p1").unwrap_or_default(); let p2 = point_param(p, "p2").unwrap_or_default(); - let a = fillet_lines(s, h1, p1, h2, p2, r, None)?; + let a = fillet_corner(s, h1, p1, h2, p2, r, None, bool_or(p, "trim", true))?; Ok(json!({ "arc": a.map(|h| h.hex()) })) } fn run_chamfer(s: &mut Session, p: &Value) -> Result { + let cut = match (p.get("length").and_then(Value::as_f64), p.get("angle").and_then(Value::as_f64)) { + (Some(len), Some(deg)) if len.is_finite() && len >= 0.0 && deg.is_finite() && (0.0..180.0).contains(°) => { + ChamferCut::Angle(len, deg.to_radians()) + } + (None, None) => { + let d1 = f64_or(p, "d1", s.doc()?.header.f64("CHAMFERA", 0.0)); + ChamferCut::Dist(d1, f64_or(p, "d2", d1)) + } + _ => return Err(bad("chamfer", "`length` (>= 0) and `angle` (degrees, 0 to 180) go together")), + }; + if bool_or(p, "polyline", false) { + let h = h_param(p, "handle").or_else(|| h_param(p, "h1")).ok_or_else(|| bad("chamfer", "`handle` (polyline) is required"))?; + return Ok(json!({ "chamfered": chamfer_polyline(s, h, cut)? })); + } let h1 = h_param(p, "h1").ok_or_else(|| bad("chamfer", "`h1` is required"))?; let h2 = h_param(p, "h2").ok_or_else(|| bad("chamfer", "`h2` is required"))?; - let d1 = f64_or(p, "d1", s.doc()?.header.f64("CHAMFERA", 0.0)); - let d2 = f64_or(p, "d2", d1); let p1 = point_param(p, "p1").unwrap_or_default(); let p2 = point_param(p, "p2").unwrap_or_default(); - let a = fillet_lines(s, h1, p1, h2, p2, 0.0, Some((d1, d2)))?; + let a = fillet_corner(s, h1, p1, h2, p2, 0.0, Some(cut), bool_or(p, "trim", true))?; Ok(json!({ "line": a.map(|h| h.hex()) })) } @@ -1452,6 +1464,8 @@ impl SelectThen { fn scale_by(&mut self, s: &mut Session, base: Vec2, f: f64) -> Result { let f = require_length(Some(f))?; + // The next SCALE offers this factor. + s.last_used.scale_factor = f; transform_entities(s, &self.objs, &Mat3::scale_about(base, f), self.copy_mode)?; s.set_selection(Vec::new()); Ok(Step::Done) @@ -1495,7 +1509,7 @@ impl Interactive for SelectThen { } Ok(Step::Continue) } - fn prompt(&self, _s: &Session) -> Prompt { + fn prompt(&self, s: &Session) -> Prompt { if self.op == Op::Stretch && self.window.is_none() { return match self.pts.first() { None => Prompt::new("Select objects (crossing window first corner)", Accept::POINT), @@ -1528,8 +1542,18 @@ impl Interactive for SelectThen { (Op::Move | Op::Stretch, _) => Prompt::new("Specify second point or ", Accept::POINT).base_opt(bp), (Op::Copy, _) => Prompt::new("Specify second point", Accept::POINT).kw(&["Array", "Exit", "Undo"]).base_opt(base), (Op::Rotate | Op::Scale, 0) => Prompt::new("Specify base point", Accept::POINT), - (Op::Rotate, _) => Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER).kw(&["Copy", "Reference"]).default("0").base_opt(base), - (Op::Scale, _) => Prompt::new("Specify scale factor", Accept::POINT_OR_NUMBER).kw(&["Copy", "Reference"]).base_opt(base), + // ROTATE and SCALE offer the angle/factor used last time. + (Op::Rotate, _) => { + let (au, ap) = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0)); + Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER) + .kw(&["Copy", "Reference"]) + .default(crate::units::format_angle(s.last_used.rotate_angle, au, ap)) + .base_opt(base) + } + (Op::Scale, _) => Prompt::new("Specify scale factor", Accept::POINT_OR_NUMBER) + .kw(&["Copy", "Reference"]) + .default(format!("{:.4}", s.last_used.scale_factor)) + .base_opt(base), (Op::Mirror, 0) => Prompt::new("Specify first point of mirror line", Accept::POINT), (Op::Mirror, _) => Prompt::new("Specify second point of mirror line", Accept::POINT).base_opt(base), _ => Prompt::new("", Accept::POINT), @@ -1630,32 +1654,34 @@ impl Interactive for SelectThen { s.set_selection(Vec::new()); Ok(Step::Done) } - (Op::Rotate, 1, Input::Point(p)) => { - let a = self.pts[0].angle_to(p); + (Op::Rotate, 1, inp @ (Input::Point(_) | Input::Text(_) | Input::Enter)) => { + let a = match inp { + Input::Point(p) => self.pts[0].angle_to(p), + Input::Text(t) => crate::units::parse_angle(&t) + .filter(|a| a.is_finite()) + .ok_or_else(|| EngineError::Other("Requires an angle or point.".into()))?, + _ => s.last_used.rotate_angle, + }; + s.last_used.rotate_angle = a; transform_entities(s, &self.objs, &Mat3::rotate_about(self.pts[0], a), self.copy_mode)?; s.set_selection(Vec::new()); Ok(Step::Done) } - (Op::Rotate, 1, Input::Text(t)) => { - let a = crate::units::parse_angle(&t).ok_or_else(|| EngineError::Other("Requires an angle or point.".into()))?; - transform_entities(s, &self.objs, &Mat3::rotate_about(self.pts[0], a), self.copy_mode)?; - s.set_selection(Vec::new()); - Ok(Step::Done) - } - (Op::Scale, 1, Input::Point(p)) => { - let f = self.pts[0].dist(p); - if f > 1e-12 { + (Op::Scale, 1, inp @ (Input::Point(_) | Input::Text(_) | Input::Enter)) => { + let f = match inp { + Input::Point(p) => self.pts[0].dist(p), + Input::Text(t) => { + number(&t).filter(|f| f.is_finite() && *f > 0.0).ok_or_else(|| EngineError::Other("Requires a positive number.".into()))? + } + _ => s.last_used.scale_factor, + }; + if f > 1e-12 && f.is_finite() { + s.last_used.scale_factor = f; transform_entities(s, &self.objs, &Mat3::scale_about(self.pts[0], f), self.copy_mode)?; } s.set_selection(Vec::new()); Ok(Step::Done) } - (Op::Scale, 1, Input::Text(t)) => { - let f = number(&t).filter(|f| *f > 0.0).ok_or_else(|| EngineError::Other("Requires a positive number.".into()))?; - transform_entities(s, &self.objs, &Mat3::scale_about(self.pts[0], f), self.copy_mode)?; - s.set_selection(Vec::new()); - Ok(Step::Done) - } (Op::Mirror, 1, Input::Point(p)) => { if p.near(self.pts[0], 1e-12) { return Err(EngineError::Other("Mirror line points must differ.".into())); @@ -1991,17 +2017,59 @@ impl Interactive for TrimM { } } +/// A FILLET/CHAMFER option prompt opened from the select prompts. +#[derive(Clone, Copy, PartialEq)] +enum FilletAsk { + /// "Enter Trim mode option [Trim/No trim]" (TRIMMODE). + Trim, + /// CHAMFER "Enter trim method [Distance/Angle]" (CHAMMODE). + Method, + /// CHAMFER Angle: the length on the first line (CHAMFERC), then the angle (CHAMFERD). + Length, + Angle, +} + struct FilletM { chamfer: bool, first: Option<(Handle, Vec2)>, - asking: bool, + /// Asking for the radius (FILLET) or a chamfer distance: 1 = first, 2 = second. + asking: u8, polyline: bool, + ask: Option, + /// Multiple: keep asking for pairs until Enter. + multiple: bool, + /// Undo: the drawing before each fillet/chamfer made in this command. + undo: Vec>, } impl FilletM { fn new(_s: &Session, chamfer: bool) -> Self { - FilletM { chamfer, first: None, asking: false, polyline: false } + FilletM { chamfer, first: None, asking: 0, polyline: false, ask: None, multiple: false, undo: Vec::new() } } + + /// One fillet/chamfer done: carry on in Multiple mode, else finish. + fn next(&mut self, s: &mut Session, before: std::sync::Arc) -> Step { + self.undo.push(before); + self.first = None; + self.polyline = false; + s.set_selection(Vec::new()); + if self.multiple { Step::Continue } else { Step::Done } + } +} + +/// TRIMMODE: FILLET and CHAMFER trim or extend the selected objects to the new corner (default). +fn trim_mode(s: &Session) -> bool { + s.doc().map(|d| d.header.i64("TRIMMODE", 1) != 0).unwrap_or(true) +} + +/// The chamfer CHAMFER uses now: two distances, or length and angle when CHAMMODE = 1. +fn chamfer_cut(s: &Session) -> Result { + let h = &s.doc()?.header; + Ok(if h.i64("CHAMMODE", 0) == 1 { + ChamferCut::Angle(h.f64("CHAMFERC", 0.0), h.f64("CHAMFERD", 0.0)) + } else { + ChamferCut::Dist(h.f64("CHAMFERA", 0.0), h.f64("CHAMFERB", 0.0)) + }) } impl Interactive for FilletM { @@ -2010,18 +2078,55 @@ impl Interactive for FilletM { } fn begin(&mut self, s: &mut Session) -> Result { s.set_selection(Vec::new()); + let trim = trim_mode(s); let d = s.doc()?; let msg = if self.chamfer { - format!("(TRIM mode) Current chamfer Dist1 = {:.4}, Dist2 = {:.4}", d.header.f64("CHAMFERA", 0.0), d.header.f64("CHAMFERB", 0.0)) + let mode = if trim { "TRIM" } else { "NOTRIM" }; + if d.header.i64("CHAMMODE", 0) == 1 { + let (au, ap) = (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0)); + let a = crate::units::format_angle(d.header.f64("CHAMFERD", 0.0), au, ap); + format!("({mode} mode) Current chamfer Length = {:.4}, Angle = {a}", d.header.f64("CHAMFERC", 0.0)) + } else { + format!("({mode} mode) Current chamfer Dist1 = {:.4}, Dist2 = {:.4}", d.header.f64("CHAMFERA", 0.0), d.header.f64("CHAMFERB", 0.0)) + } } else { - format!("Current settings: Mode = TRIM, Radius = {:.4}", d.header.f64("FILLETRAD", 0.0)) + format!("Current settings: Mode = {}, Radius = {:.4}", if trim { "TRIM" } else { "NOTRIM" }, d.header.f64("FILLETRAD", 0.0)) }; s.echo(msg); Ok(Step::Continue) } - fn prompt(&self, _s: &Session) -> Prompt { - if self.asking { - return Prompt::new(if self.chamfer { "Specify first chamfer distance" } else { "Specify fillet radius" }, Accept::NUMBER); + fn prompt(&self, s: &Session) -> Prompt { + // The radius and distances are remembered in the drawing (FILLETRAD, CHAMFERA/B) and + // offered as defaults; the second chamfer distance defaults to the first. + if self.asking > 0 { + let hdr = |k: &str| s.doc().map(|d| d.header.f64(k, 0.0)).unwrap_or(0.0); + let (msg, v) = match (self.chamfer, self.asking) { + (false, _) => ("Specify fillet radius", hdr("FILLETRAD")), + (true, 1) => ("Specify first chamfer distance", hdr("CHAMFERA")), + (true, _) => ("Specify second chamfer distance", hdr("CHAMFERA")), + }; + return Prompt::new(msg, Accept::NUMBER).default(format!("{v:.4}")); + } + let hdr_i = |k: &str, dflt: i64| s.doc().map(|d| d.header.i64(k, dflt)).unwrap_or(dflt); + match self.ask { + Some(FilletAsk::Trim) => { + let cur = if trim_mode(s) { "Trim" } else { "No trim" }; + return Prompt::new("Enter Trim mode option", Accept::TEXT).kw(&["Trim", "No trim"]).default(cur); + } + Some(FilletAsk::Method) => { + let cur = if hdr_i("CHAMMODE", 0) == 1 { "Angle" } else { "Distance" }; + return Prompt::new("Enter trim method", Accept::TEXT).kw(&["Distance", "Angle"]).default(cur); + } + Some(FilletAsk::Length) => { + let v = s.doc().map(|d| d.header.f64("CHAMFERC", 0.0)).unwrap_or(0.0); + return Prompt::new("Specify chamfer length on the first line", Accept::NUMBER).default(format!("{v:.4}")); + } + Some(FilletAsk::Angle) => { + let (a, au, ap) = + s.doc().map(|d| (d.header.f64("CHAMFERD", 0.0), d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0.0, 0, 0)); + return Prompt::new("Specify chamfer angle from the first line", Accept::NUMBER).default(crate::units::format_angle(a, au, ap)); + } + None => {} } if self.polyline { return Prompt::new("Select 2D polyline", Accept::POINT); @@ -2036,29 +2141,83 @@ impl Interactive for FilletM { } } fn input(&mut self, s: &mut Session, i: Input) -> Result { - if self.asking { - if let Input::Text(t) = &i { - let v = number(t).filter(|v| *v >= 0.0).ok_or_else(|| EngineError::Other("Requires a non-negative distance.".into()))?; - let d = s.doc_mut()?; - if self.chamfer { - d.header.set_f64("CHAMFERA", v); + if self.asking > 0 { + // Enter keeps the default. + let v = match &i { + Input::Text(t) => Some( + number(t).filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| EngineError::Other("Requires a non-negative distance.".into()))?, + ), + Input::Enter => None, + _ => return Ok(Step::Continue), + }; + let d = s.doc_mut()?; + match (self.chamfer, self.asking) { + (false, _) => { + if let Some(v) = v { + d.header.set_f64("FILLETRAD", v); + } + self.asking = 0; + } + (true, 1) => { + if let Some(v) = v { + d.header.set_f64("CHAMFERA", v); + } + self.asking = 2; + } + (true, _) => { + let v = v.unwrap_or_else(|| d.header.f64("CHAMFERA", 0.0)); d.header.set_f64("CHAMFERB", v); - } else { - d.header.set_f64("FILLETRAD", v); + self.asking = 0; } } - self.asking = false; return Ok(Step::Continue); } + if let Some(ask) = self.ask { + return self.option(s, ask, i); + } match i { Input::Keyword(k) if k == "Radius" || k == "Distance" => { - self.asking = true; + if self.chamfer { + s.doc_mut()?.header.set_i64("CHAMMODE", 0); + } + self.asking = 1; Ok(Step::Continue) } - Input::Keyword(k) if k == "Polyline" && !self.chamfer => { + Input::Keyword(k) if k == "Angle" && self.chamfer => { + s.doc_mut()?.header.set_i64("CHAMMODE", 1); + self.ask = Some(FilletAsk::Length); + Ok(Step::Continue) + } + Input::Keyword(k) if k == "mEthod" || k == "Method" => { + self.ask = Some(FilletAsk::Method); + Ok(Step::Continue) + } + Input::Keyword(k) if k == "Trim" => { + self.ask = Some(FilletAsk::Trim); + Ok(Step::Continue) + } + Input::Keyword(k) if k == "Multiple" => { + self.multiple = true; + Ok(Step::Continue) + } + Input::Keyword(k) if k == "Polyline" => { self.polyline = true; Ok(Step::Continue) } + Input::Keyword(k) if k == "Undo" => { + match self.undo.pop() { + Some(doc) => { + // Settings changed meanwhile stay. + let header = s.doc()?.header.clone(); + s.state_mut()?.doc = doc; + s.doc_mut()?.header = header; + } + None => s.echo("Nothing to undo."), + } + self.first = None; + s.set_selection(Vec::new()); + Ok(Step::Continue) + } Input::Point(p) => { let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; let space = s.space(); @@ -2067,10 +2226,16 @@ impl Interactive for FilletM { return Ok(Step::Continue); }; if self.polyline { - let r = s.doc()?.header.f64("FILLETRAD", 0.0); - let n = super::modify2::fillet_polyline(s, h, r)?; - s.echo(format!("{n} lines were filleted")); - return Ok(Step::Done); + let before = s.state()?.doc.clone(); + if self.chamfer { + let n = chamfer_polyline(s, h, chamfer_cut(s)?)?; + s.echo(format!("{n} lines were chamfered")); + } else { + let r = s.doc()?.header.f64("FILLETRAD", 0.0); + let n = super::modify2::fillet_polyline(s, h, r)?; + s.echo(format!("{n} lines were filleted")); + } + return Ok(self.next(s, before)); } match self.first { None => { @@ -2079,16 +2244,16 @@ impl Interactive for FilletM { Ok(Step::Continue) } Some((h1, p1)) => { - let d = s.doc()?; - let r = d.header.f64("FILLETRAD", 0.0); - let ch = (d.header.f64("CHAMFERA", 0.0), d.header.f64("CHAMFERB", 0.0)); - let res = fillet_lines(s, h1, p1, h, p, r, if self.chamfer { Some(ch) } else { None }); - s.set_selection(Vec::new()); - match res { - Ok(_) => Ok(Step::Done), + let r = s.doc()?.header.f64("FILLETRAD", 0.0); + let cut = if self.chamfer { Some(chamfer_cut(s)?) } else { None }; + let before = s.state()?.doc.clone(); + let trim = trim_mode(s); + match fillet_corner(s, h1, p1, h, p, r, cut, trim) { + Ok(_) => Ok(self.next(s, before)), Err(e) => { s.echo(e.to_string()); self.first = None; + s.set_selection(Vec::new()); Ok(Step::Continue) } } @@ -2101,6 +2266,188 @@ impl Interactive for FilletM { } } +impl FilletM { + /// Input at a Trim / Method / Length / Angle option prompt; Enter keeps the current value. + fn option(&mut self, s: &mut Session, ask: FilletAsk, i: Input) -> Result { + let text = match &i { + Input::Keyword(k) | Input::Text(k) => k.trim().to_ascii_lowercase(), + Input::Enter => String::new(), + _ => return Ok(Step::Continue), + }; + let invalid = || EngineError::Other("Invalid option keyword.".into()); + let flag = |yes: &str, no: &str| -> Result> { + if text.is_empty() { + Ok(None) + } else if text.starts_with(yes) { + Ok(Some(1)) + } else if text.starts_with(no) { + Ok(Some(0)) + } else { + Err(invalid()) + } + }; + let d = s.doc_mut()?; + self.ask = None; + match ask { + FilletAsk::Trim => { + if let Some(v) = flag("t", "n")? { + d.header.set_i64("TRIMMODE", v); + } + } + FilletAsk::Method => { + if let Some(v) = flag("a", "d")? { + d.header.set_i64("CHAMMODE", v); + } + } + FilletAsk::Length => { + if !text.is_empty() { + let v = number(&text) + .filter(|v| v.is_finite() && *v >= 0.0) + .ok_or_else(|| EngineError::Other("Requires a non-negative distance.".into()))?; + d.header.set_f64("CHAMFERC", v); + } + self.ask = Some(FilletAsk::Angle); + } + FilletAsk::Angle => { + if !text.is_empty() { + let a = crate::units::parse_angle(&text) + .filter(|a| a.is_finite() && *a >= 0.0 && *a < std::f64::consts::PI) + .ok_or_else(|| EngineError::Other("Requires an angle from 0 to 180 degrees.".into()))?; + d.header.set_f64("CHAMFERD", a); + } + } + } + Ok(Step::Continue) + } +} + +/// How CHAMFER cuts a corner: two distances, or a length on the first line and an angle +/// (radians) from it. +#[derive(Clone, Copy, Debug)] +enum ChamferCut { + Dist(f64, f64), + Angle(f64, f64), +} + +impl ChamferCut { + /// The distances back from the corner along the first and second lines, which leave it + /// along the unit directions `u1` and `u2`. + fn distances(self, u1: Vec2, u2: Vec2) -> Result<(f64, f64)> { + match self { + ChamferCut::Dist(a, b) => Ok((a, b)), + ChamferCut::Angle(len, ang) => { + // Triangle corner–t1–t2: the corner angle and the chamfer angle at t1 give t2 by + // the law of sines. + let corner = u1.dot(u2).clamp(-1.0, 1.0).acos(); + let s = (corner + ang).sin(); + let d2 = len * ang.sin() / s; + if corner + ang >= std::f64::consts::PI - 1e-9 || s.abs() < 1e-12 || !d2.is_finite() || d2 < 0.0 { + return Err(EngineError::Other("The chamfer angle does not fit this corner.".into())); + } + Ok((len, d2)) + } + } + } +} + +/// FILLET (`cut` None) or CHAMFER two objects, honouring TRIMMODE: with `trim` false the new +/// arc or line is added and both objects are left as they were. +#[allow(clippy::too_many_arguments)] +fn fillet_corner(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, r: f64, cut: Option, trim: bool) -> Result> { + let d = s.doc()?; + let k1 = d.entity(h1).map(|e| e.kind.clone()); + let k2 = d.entity(h2).map(|e| e.kind.clone()); + let chamfer = match cut { + None => None, + Some(c) => { + let l = |h| d.entity(h).and_then(|e| as_line(e)).ok_or_else(|| EngineError::Other("Chamfer currently works on lines.".into())); + let (l1, l2) = (l(h1)?, l(h2)?); + let (x, _, _) = line_line_infinite(l1.a, l1.b, l2.a, l2.b).ok_or_else(|| EngineError::Other("Lines are parallel.".into()))?; + // The direction along each line towards its picked side. + let dir = |l: &Line, p: Vec2| if (l.a - x).dot(p - x) >= (l.b - x).dot(p - x) { (l.a - x).normalized() } else { (l.b - x).normalized() }; + Some(c.distances(dir(&l1, p1), dir(&l2, p2))?) + } + }; + let made = fillet_lines(s, h1, p1, h2, p2, r, chamfer)?; + if !trim { + let doc = s.doc_mut()?; + for (h, k) in [(h1, k1), (h2, k2)] { + if let Some(k) = k { + doc.modify_entity(h, |e| e.kind = k)?; + } + } + } + Ok(made) +} + +/// CHAMFER Polyline: cut every corner between two straight segments of a 2D polyline. Returns +/// how many corners were chamfered. +fn chamfer_polyline(s: &mut Session, h: Handle, cut: ChamferCut) -> Result { + let d = s.doc()?; + let e = d.entity(h).ok_or_else(|| EngineError::Other("no such object".into()))?; + if d.layer(&e.common.layer).is_some_and(|l| l.locked) { + return Err(EngineError::Other("The object is on a locked layer.".into())); + } + let EntityKind::LwPolyline(pl) = &e.kind else { return Err(EngineError::Other("Select a 2D polyline.".into())) }; + let vs = &pl.vertices; + let n = vs.len(); + if n < 3 { + return Ok(0); + } + let at = |i: usize| vs.get(i % n).copied().unwrap_or_default(); + // Distances cut back from each corner along the incoming and outgoing segments. + let mut cuts = vec![(0.0f64, 0.0f64); n]; + for (i, c) in cuts.iter_mut().enumerate() { + if !pl.closed && (i == 0 || i + 1 == n) { + continue; + } + let (prev, cur, next) = (at(i + n - 1), at(i), at(i + 1)); + if prev.bulge.abs() > 1e-12 || cur.bulge.abs() > 1e-12 { + continue; + } + let u1 = (prev.p - cur.p).normalized(); + let u2 = (next.p - cur.p).normalized(); + if u1 == Vec2::ZERO || u2 == Vec2::ZERO || u1.dot(u2) < -1.0 + 1e-9 { + continue; + } + if let Ok(ab) = cut.distances(u1, u2) { + *c = ab; + } + } + // Drop chamfers that do not fit their segments. + for i in 0..n { + let j = (i + 1) % n; + let seg = at(i).p.dist(at(j).p); + let (out_i, in_j) = (cuts.get(i).map_or(0.0, |c| c.1), cuts.get(j).map_or(0.0, |c| c.0)); + if out_i + in_j > seg + 1e-9 { + for k in [i, j] { + if let Some(c) = cuts.get_mut(k) { + *c = (0.0, 0.0); + } + } + } + } + let mut out: Vec = Vec::with_capacity(n * 2); + let mut count = 0; + for i in 0..n { + let v = at(i); + let (a, b) = cuts.get(i).copied().unwrap_or_default(); + if a <= 1e-12 && b <= 1e-12 { + out.push(v); + continue; + } + let t1 = v.p + (at(i + n - 1).p - v.p).normalized() * a; + let t2 = v.p + (at(i + 1).p - v.p).normalized() * b; + out.push(PolyVertex { p: t1, bulge: 0.0, ..v }); + out.push(PolyVertex { p: t2, bulge: 0.0, ..v }); + count += 1; + } + let mut npl = pl.clone(); + npl.vertices = out; + s.doc_mut()?.modify_entity(h, |e| e.kind = EntityKind::LwPolyline(npl))?; + Ok(count) +} + #[derive(Default)] struct BreakM { obj: Option<(Handle, Vec2)>, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4a325ee2..a68f468a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -302,6 +302,29 @@ pub struct PendingWindow { pub during_command: bool, } +/// Options and values the interactive commands remember for the rest of the session, offered as +/// the `` the next time they ask (as AutoCAD does). Values AutoCAD keeps in a drawing +/// system variable (HPNAME, OFFSETDIST, FILLETRAD, CHAMFERA, POLYSIDES…) live in the drawing +/// header instead. JSON calls never read these: their explicit parameters and documented defaults +/// stay authoritative. +#[derive(Clone, Debug, PartialEq)] +pub struct LastUsed { + /// HATCH/GRADIENT starts at "Select objects" instead of "Pick internal point". + pub hatch_select: bool, + /// POLYGON: circumscribed about the circle (the `C` option) instead of inscribed. + pub polygon_circumscribed: bool, + /// ROTATE angle (radians). + pub rotate_angle: f64, + /// SCALE factor. + pub scale_factor: f64, +} + +impl Default for LastUsed { + fn default() -> Self { + LastUsed { hatch_select: false, polygon_circumscribed: false, rotate_angle: 0.0, scale_factor: 1.0 } + } +} + /// The editor session: open drawings, settings, the running command and the command log. pub struct Session { pub docs: Vec, @@ -325,6 +348,8 @@ pub struct Session { pub untitled_counter: u32, /// The last dimension created (DIMCONTINUE / DIMBASELINE). pub last_dim: Option, + /// Options remembered between invocations of interactive commands. + pub last_used: LastUsed, } impl Default for Session { @@ -357,6 +382,7 @@ impl Session { pending_window: None, untitled_counter: 0, last_dim: None, + last_used: LastUsed::default(), } } pub fn new_drawing(&mut self, metric: bool) -> usize { @@ -610,7 +636,7 @@ impl Session { } } } - Input::Text(t) | Input::Keyword(t) => { + Input::Text(ref t) | Input::Keyword(ref t) => { let tl = t.trim().to_ascii_lowercase(); let d = self.doc()?; let store = d.space(&space); @@ -622,7 +648,7 @@ impl Session { }; match picked { Some(hs) => Ok(Some(Input::Pick(hs))), - None => Ok(Some(Input::Text(t))), + None => Ok(Some(input)), } } Input::Cancel => { @@ -726,6 +752,16 @@ impl Session { self.running.as_ref().map(|r| r.machine.prompt(self)) } + /// Whether the cursor picks objects right now, so the UI shows the pick box (PICKBOX) at the + /// crosshair: at prompts that select objects, and with no command running when PICKFIRST is on + /// (noun-verb selection). + pub fn picking_objects(&self) -> bool { + match self.current_prompt() { + Some(p) => p.picks_objects(), + None => self.settings.pickfirst, + } + } + /// The text shown on the command line: the active prompt, or "Command:". pub fn prompt_text(&self) -> String { match &self.running { @@ -803,7 +839,11 @@ impl Session { if tt.starts_with('\'') { return self.start(tt); } - if let Some(k) = prompt.match_keyword(tt) + // At "Select objects" prompts the selection modes (Previous, Last, ALL) win over a command + // keyword sharing the letter (HATCH's "picK internal point" against `P`). + let selection_mode = prompt.accept.select && matches!(tt.to_ascii_lowercase().as_str(), "all" | "l" | "last" | "p" | "previous"); + if !selection_mode + && let Some(k) = prompt.match_keyword(tt) && !(prompt.accept.number && tt.parse::().is_ok()) { return self.input(Input::Keyword(k)); diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs index 374c1e45..dd8ea770 100644 --- a/crates/engine/src/prompt.rs +++ b/crates/engine/src/prompt.rs @@ -68,6 +68,12 @@ impl Prompt { self.default = Some(d.into()); self } + /// The prompt picks objects: a "Select objects" prompt, or a point prompt whose click selects + /// one object ("Select object to offset", "Select first line"…, the commands that pick a + /// single object themselves). The UI shows the pick box there. + pub fn picks_objects(&self) -> bool { + self.accept.select || (self.accept.point && self.message.starts_with("Select")) + } /// "Specify next point or [Undo/Close] :". pub fn display(&self) -> String { let mut s = self.message.clone(); @@ -89,16 +95,18 @@ impl Prompt { return None; } let tl = t.to_ascii_lowercase(); - for k in &self.keywords { + let keys = self.keywords.iter().map(|k| { let shortcut: String = k.chars().filter(|c| c.is_ascii_uppercase() || c.is_ascii_digit()).collect::().to_ascii_lowercase(); let shortcut = if shortcut.is_empty() { k.chars().next().map(|c| c.to_ascii_lowercase().to_string()).unwrap_or_default() } else { shortcut }; - let kl = k.to_ascii_lowercase(); - if tl == kl || tl == shortcut || (tl.len() >= shortcut.len() && kl.starts_with(&tl)) { - return Some(k.clone()); - } - } - None + (k, k.to_ascii_lowercase(), shortcut) + }); + // A whole keyword or its capital-letter shortcut wins over a prefix of another keyword + // (`M` is Multiple, not a prefix of mEthod). + keys.clone() + .find(|(_, kl, sc)| tl == *kl || tl == *sc) + .or_else(|| keys.clone().find(|(_, kl, sc)| tl.len() >= sc.len() && kl.starts_with(&tl))) + .map(|(k, _, _)| k.clone()) } } diff --git a/crates/engine/tests/fillet_chamfer.rs b/crates/engine/tests/fillet_chamfer.rs new file mode 100644 index 00000000..4962aade --- /dev/null +++ b/crates/engine/tests/fillet_chamfer.rs @@ -0,0 +1,123 @@ +//! FILLET and CHAMFER options at the command line: Trim, Multiple, Undo, and CHAMFER's Angle, +//! mEthod and Polyline. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use serde_json::json; + +type Seg = ((f64, f64), (f64, f64)); + +fn r(v: f64) -> f64 { + (v * 1e6).round() / 1e6 +} + +/// Every line with its ends in a fixed order (rounded), sorted. +fn lines(s: &Session) -> Vec { + let mut v: Vec = s + .doc() + .unwrap() + .model + .iter() + .filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some(((r(l.a.x), r(l.a.y)), (r(l.b.x), r(l.b.y)))) } else { None }) + .map(|(a, b)| if (a.0, a.1) <= (b.0, b.1) { (a, b) } else { (b, a) }) + .collect(); + v.sort_by(|a, b| a.partial_cmp(b).unwrap()); + v +} + +fn arcs(s: &Session) -> usize { + s.doc().unwrap().model.iter().filter(|e| matches!(e.kind, EntityKind::Arc(_))).count() +} + +/// A horizontal line through a vertical one, crossing at (10,0); with `top`, a second horizontal +/// line crossing the vertical one at (10,10). +fn corner(top: bool) -> Session { + let mut s = Session::new(); + let mut ls = vec!["0,0 12,0", "10,-2 10,12"]; + if top { + ls.push("0,10 12,10"); + } + for l in ls { + s.cmdline(&format!("LINE {l}")).unwrap(); + s.cmdline("").unwrap(); + } + s +} + +#[test] +fn fillet_no_trim_keeps_the_lines() { + let mut s = corner(false); + s.script("FILLET T N R 1 5,0 10,5\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![((0.0, 0.0), (12.0, 0.0)), ((10.0, -2.0), (10.0, 12.0))]); + assert_eq!(arcs(&s), 1); + // The mode is remembered (TRIMMODE) and Trim switches it back. + assert_eq!(s.doc().unwrap().header.i64("TRIMMODE", 1), 0); + s.script("FILLET T T R 0 5,0 10,5\n").unwrap(); + assert_eq!(lines(&s), vec![((0.0, 0.0), (10.0, 0.0)), ((10.0, 0.0), (10.0, 12.0))]); +} + +#[test] +fn fillet_multiple_and_undo() { + let mut s = corner(true); + // Two corners in one command; Undo takes the second back, Enter ends. + s.script("FILLET M 5,0 10,5 5,10 10,5 U\n").unwrap(); + assert!(s.running.is_some(), "Multiple keeps asking"); + s.cmdline("").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![((0.0, 0.0), (10.0, 0.0)), ((0.0, 10.0), (12.0, 10.0)), ((10.0, 0.0), (10.0, 12.0))]); +} + +#[test] +fn chamfer_angle_and_method() { + let mut s = corner(false); + // Length 2 on the first line at 45 degrees from it. + s.script("CHAMFER A 2 45 5,0 10,5\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![((0.0, 0.0), (8.0, 0.0)), ((8.0, 0.0), (10.0, 2.0)), ((10.0, 2.0), (10.0, 12.0))]); + assert_eq!(s.doc().unwrap().header.i64("CHAMMODE", 0), 1); + // mEthod switches back to the two distances. + let mut s = corner(false); + s.script("CHAMFER A 2 45 E D D 1 3 5,0 10,5\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![((0.0, 0.0), (9.0, 0.0)), ((9.0, 0.0), (10.0, 3.0)), ((10.0, 3.0), (10.0, 12.0))]); +} + +#[test] +fn chamfer_no_trim_and_multiple() { + let mut s = corner(true); + s.script("CHAMFER T N D 1 1 M 5,0 10,5 5,10 10,5\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!( + lines(&s), + vec![ + ((0.0, 0.0), (12.0, 0.0)), + ((0.0, 10.0), (12.0, 10.0)), + ((9.0, 0.0), (10.0, 1.0)), + ((9.0, 10.0), (10.0, 9.0)), + ((10.0, -2.0), (10.0, 12.0)) + ] + ); +} + +#[test] +fn chamfer_polyline() { + let mut s = Session::new(); + s.script("PLINE 0,0 10,0 10,10 0,10 C\n").unwrap(); + s.script("CHAMFER D 1 2 P 5,0\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let d = s.doc().unwrap(); + let pl = d.model.iter().find_map(|e| if let EntityKind::LwPolyline(p) = &e.kind { Some(p.clone()) } else { None }).unwrap(); + let pts: Vec<(f64, f64)> = pl.vertices.iter().map(|v| (r(v.p.x), r(v.p.y))).collect(); + assert_eq!(pts.len(), 8, "{pts:?}"); + // The corner at (10,0): 1 back along the incoming edge, 2 along the outgoing one. + assert!(pts.contains(&(9.0, 0.0)) && pts.contains(&(10.0, 2.0)), "{pts:?}"); +} + +#[test] +fn json_chamfer_angle_and_trim() { + let mut s = corner(false); + let hs: Vec = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect(); + s.execute("chamfer", &json!({ "h1": hs[0], "p1": [5, 0], "h2": hs[1], "p2": [10, 5], "length": 2, "angle": 45, "trim": false })).unwrap(); + assert_eq!(lines(&s), vec![((0.0, 0.0), (12.0, 0.0)), ((8.0, 0.0), (10.0, 2.0)), ((10.0, -2.0), (10.0, 12.0))]); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1ee8436a..65f5ecbb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -708,7 +708,9 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { // Hover highlight (throttled to cursor movement). let prompt = app.session.current_prompt(); - let selecting = prompt.as_ref().is_none_or(|p| p.accept.select); + // The cursor picks objects (pick box, rollover highlight): "Select objects" and single-object + // prompts, or no command with PICKFIRST on. + let selecting = app.session.picking_objects(); if inside && selecting && app.session.pending_window.is_none() { if hover_pos != app.canvas.hover_at && let (Some(w), Ok(st)) = (raw_world, app.session.state()) @@ -934,17 +936,23 @@ fn tooltip(p: &egui::Painter, at: Pos2, text: &str) { p.galley(r.min + vec2(4.0, 2.0), galley, Color32::BLACK); } +/// Half the side of the pick box: the pick aperture (PICKBOX × 1.5 pixels, as `select::pick` is +/// called with), so the square shows what a click hits. `None` when PICKBOX is 0 (no box). +fn pickbox_half(pickbox: f64) -> Option { + let half = (pickbox * 1.5) as f32; + (half.is_finite() && half >= 1.0).then_some(half) +} + fn draw_crosshair(app: &CadApp, p: &egui::Painter, rect: Rect, at: Pos2, pickbox: bool) { - let c = Color32::from_rgb(0xe8, 0xe8, 0xe8); + let st = Stroke::new(1.0, Tokens::get().crosshair); let len = (rect.height() * app.session.settings.cursorsize as f32 / 100.0).max(12.0); - let pb = (app.session.settings.pickbox as f32 + 1.0).max(3.0); - let gap = if pickbox { pb } else { 0.0 }; - let st = Stroke::new(1.0, c); + let pb = if pickbox { pickbox_half(app.session.settings.pickbox) } else { None }; + let gap = pb.unwrap_or(0.0); p.line_segment([pos2(at.x - len, at.y), pos2(at.x - gap, at.y)], st); p.line_segment([pos2(at.x + gap, at.y), pos2(at.x + len, at.y)], st); p.line_segment([pos2(at.x, at.y - len), pos2(at.x, at.y - gap)], st); p.line_segment([pos2(at.x, at.y + gap), pos2(at.x, at.y + len)], st); - if pickbox { + if let Some(pb) = pb { p.rect_stroke(Rect::from_center_size(at, vec2(pb * 2.0, pb * 2.0)), 0.0, st, egui::StrokeKind::Middle); } } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 52b091d4..0c299bd5 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -116,6 +116,8 @@ pub struct Tokens { pub list_row: Color32, /// Transient warnings (status bar messages). pub warn: Color32, + /// The drawing-area crosshair cursor and its pick box. + pub crosshair: Color32, } impl Tokens { @@ -160,6 +162,7 @@ impl Tokens { list_bg: Color32::from_rgb(0x2b, 0x31, 0x3b), list_row: Color32::from_rgb(0x3a, 0x42, 0x50), warn: Color32::from_rgb(0xff, 0xd0, 0x80), + crosshair: Color32::from_rgb(0xe8, 0xe8, 0xe8), }; /// Light grey chrome with dark text. The drawing-area colours (canvas, grid, axes, selection, From 2f7a1e60d796bbf68286e4d1174fa99761d3aa07 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 23:31:50 +0200 Subject: [PATCH 104/150] QDIM: one snap index per call, cap the input (fixes #356) (#357) With DIMASSOC on, every dimension QDIM added looked up the objects under its definition points by scanning the whole space, and each dimension adds an entity, so QDIM over many objects took quadratic time (20,000 lines: 28 s). QDIM now builds the SnapIndex DIMCONTINUE/DIMBASELINE use (#281) once per call; dimensions are no snap targets, so it stays valid while QDIM adds them. A call naming more than 100,000 objects, or whose objects give more than 100,000 distinct points, is refused before anything is created. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/annotate.rs | 18 ++++++++- crates/engine/tests/qdim.rs | 65 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/qdim.rs diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index f52a2fc0..1f6f91f7 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -378,6 +378,9 @@ fn run_baseline(s: &mut Session, p: &Value) -> Result { /// QDIM: continuous dimensions across the endpoints of the selected objects. fn run_qdim(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; + if hs.len() > MAX_CHAIN { + return Err(bad("qdim", format!("at most {MAX_CHAIN} objects per call"))); + } let at = point_req("qdim", p, "at")?; let vertical = bool_or(p, "vertical", false); let d = s.doc()?; @@ -400,12 +403,23 @@ fn run_qdim(s: &mut Session, p: &Value) -> Result { if pts.len() < 2 { return Err(bad("qdim", "select objects with at least two distinct points")); } + if pts.len() > MAX_CHAIN { + return Err(bad("qdim", format!("at most {MAX_CHAIN} distinct points per call"))); + } let rot = if vertical { std::f64::consts::FRAC_PI_2 } else { 0.0 }; + // One snap index for all the dimensions, as DIMCONTINUE does: looking up each dimension's + // objects by scanning the drawing made QDIM quadratic (the added dimensions are no snap targets). + let snaps = if assoc_enabled(s) { Some(crate::assoc::SnapIndex::new(s.doc()?, &s.space())) } else { None }; let mut out = Vec::new(); for w in pts.windows(2) { if let [a, b] = w { - let k = dim(s, DimKind::Linear { rotation: rot }, at, *a, *b, Vec2::ZERO, Vec2::ZERO, ""); - out.push(add_dim(s, k)?.hex()); + let mut k = dim(s, DimKind::Linear { rotation: rot }, at, *a, *b, Vec2::ZERO, Vec2::ZERO, ""); + if let (Some(ix), EntityKind::Dimension(dm)) = (&snaps, &mut k) { + dm.assoc = ix.auto_assoc(&[("p13", dm.p13.xy()), ("p14", dm.p14.xy())]); + } + let h = s.add_entity(k)?; + s.last_dim = Some(h); + out.push(h.hex()); } } Ok(json!({ "handles": out })) diff --git a/crates/engine/tests/qdim.rs b/crates/engine/tests/qdim.rs new file mode 100644 index 00000000..94b8aa73 --- /dev/null +++ b/crates/engine/tests/qdim.rs @@ -0,0 +1,65 @@ +//! QDIM over many objects: one snap index per call (no rescan of the drawing per dimension), the +//! same associations as DIMREASSOCIATE, and a cap on the input size. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{DimAssoc, EntityKind, Handle}; +use serde_json::{Value, json}; + +fn assoc_of(s: &Session, handles: &[Value]) -> Vec> { + let d = s.doc().unwrap(); + handles + .iter() + .map(|h| match &d.entity(Handle::parse_hex(h.as_str().unwrap()).unwrap()).unwrap().kind { + EntityKind::Dimension(dm) => dm.assoc.clone(), + k => panic!("not a dimension: {k:?}"), + }) + .collect() +} + +fn handles(r: &Value) -> Vec { + r["handles"].as_array().unwrap().clone() +} + +#[test] +fn qdim_attaches_like_dimreassociate_and_large_input_is_capped() { + // Lines end to end with a point on every joint (ties with the line ends) and a circle around + // every midpoint (its centre is a QDIM point and outranks the line midpoint). + let mut s = Session::new(); + for i in 0..40 { + let x = f64::from(i); + s.execute("line", &json!({"points": [[x, 0], [x + 1.0, 0]]})).unwrap(); + s.execute("point", &json!({"at": [x, 0]})).unwrap(); + s.execute("circle", &json!({"center": [x + 0.5, 0], "radius": 0.1})).unwrap(); + } + let objs: Vec = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect(); + let r = s.execute("qdim", &json!({"handles": objs, "at": [0, 3]})).unwrap(); + let hs = handles(&r); + assert_eq!(hs.len(), 80, "one dimension between each pair of neighbouring points"); + let quick = assoc_of(&s, &hs); + assert!(quick.iter().all(|a| a.len() == 2), "every point is on an object"); + // DIMREASSOCIATE scans the drawing per point; the indexed QDIM must agree with it. + s.execute("dimreassociate", &json!({"handles": hs})).unwrap(); + assert_eq!(assoc_of(&s, &hs), quick); + + // Many objects finish (this was quadratic in the number of dimensions). + let mut s = Session::new(); + let pts: Vec<[f64; 2]> = (0..=20_000).map(|i| [f64::from(i), 0.0]).collect(); + let objs = handles(&s.execute("line", &json!({"points": pts})).unwrap()); + let r = s.execute("qdim", &json!({"handles": objs, "at": [0, 3]})).unwrap(); + let hs = handles(&r); + assert_eq!(hs.len(), 20_000); + assert!(assoc_of(&s, &hs[..10]).iter().all(|a| a.len() == 2)); + + // More than the cap (objects, or distinct points) is refused with nothing created. + let n = s.doc().unwrap().model.iter().count(); + let too_many: Vec = (0..100_001).map(|_| objs[0].clone()).collect(); + let e = s.execute("qdim", &json!({"handles": too_many, "at": [0, 3]})).unwrap_err(); + assert!(e.to_string().contains("100000"), "{e}"); + let vertices: Vec<[f64; 2]> = (0..100_002).map(|i| [f64::from(i), f64::from(i % 2)]).collect(); + s.execute("pline", &json!({"vertices": vertices})).unwrap(); + let pl = s.doc().unwrap().model.last().unwrap().handle.hex(); + let n = n + 1; + let e = s.execute("qdim", &json!({"handles": [pl], "at": [0, 3]})).unwrap_err(); + assert!(e.to_string().contains("100000"), "{e}"); + assert_eq!(s.doc().unwrap().model.iter().count(), n); +} From 71706f8e6a3f0d39c9489624ef9638211a81e23d Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 23:31:56 +0200 Subject: [PATCH 105/150] TL/ML/BL text is placed by its alignment point (fixes #342) (#346) For every justification but baseline-left (and aligned/fit) the DXF Reference makes group 11 the point the text is placed by; group 10 is a computed point on the baseline. Left-justified text with a vertical alignment was drawn, measured and picked from group 10 and then shifted by the alignment again: TL and ML too low, BL too high. The renderer (fonts::place) and text extents (doc text_box, used for picking) now place it from the alignment point; a mirrored (extrusion 0,0,-1) TL/ML/BL text keeps its alignment point when it turns right-justified; and the Properties position moves justified text by its alignment point. Co-authored-by: Claude Opus 5.5 --- crates/doc/src/extents.rs | 8 ++--- crates/engine/src/cmd/props.rs | 9 +++-- crates/engine/tests/text_left_justify.rs | 17 +++++++++ crates/fonts/src/lib.rs | 6 ++-- crates/io/src/dxf_read.rs | 3 +- crates/io/tests/text_left_justify.rs | 46 ++++++++++++++++++++++++ 6 files changed, 79 insertions(+), 10 deletions(-) create mode 100644 crates/engine/tests/text_left_justify.rs create mode 100644 crates/io/tests/text_left_justify.rs diff --git a/crates/doc/src/extents.rs b/crates/doc/src/extents.rs index e5f5e340..5f20e6c9 100644 --- a/crates/doc/src/extents.rs +++ b/crates/doc/src/extents.rs @@ -14,10 +14,8 @@ pub const MAX_BLOCK_DEPTH: usize = 16; fn text_box(t: &crate::Text, len: usize) -> Bounds2 { use crate::{HAlign, VAlign}; let w = t.height * 0.9 * t.width_factor.abs().max(0.01) * len.max(1) as f64; - let origin = match t.halign { - HAlign::Left | HAlign::Aligned | HAlign::Fit => t.insert, - _ => t.align_pt.unwrap_or(t.insert), - }; + // Justified text (TL, ML and BL too) hangs from its alignment point (DXF group 11). + let origin = t.justify_point(); let dx = match t.halign { HAlign::Left | HAlign::Aligned | HAlign::Fit => 0.0, HAlign::Center | HAlign::Middle => -w / 2.0, @@ -31,7 +29,7 @@ fn text_box(t: &crate::Text, len: usize) -> Bounds2 { (_, VAlign::Top) => -t.height, }; let local = [Vec2::new(dx, dy), Vec2::new(dx + w, dy), Vec2::new(dx + w, dy + t.height), Vec2::new(dx, dy + t.height)]; - Bounds2::from_points(local.map(|p| origin.xy() + p.rotate(t.rotation))) + Bounds2::from_points(local.map(|p| origin + p.rotate(t.rotation))) } /// Whether an entity inside a block definition shows: its own invisible flag and its layer count, diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 4c0b9f66..0cfb9da4 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -269,8 +269,13 @@ fn run_set(s: &mut Session, p: &Value) -> Result { if let Some(x) = num("widthFactor").filter(|x| *x > 0.0) { t.width_factor = x; } - if let Some(v) = p.get("position") { - set_xy(&mut t.insert, v); + // The position is the point the text is placed by: moving it moves the text. + if let Some(to) = p.get("position").and_then(point_value) { + let d = to - t.justify_point(); + for q in std::iter::once(&mut t.insert).chain(t.align_pt.as_mut()) { + q.x += d.x; + q.y += d.y; + } } } EntityKind::MText(t) => { diff --git a/crates/engine/tests/text_left_justify.rs b/crates/engine/tests/text_left_justify.rs new file mode 100644 index 00000000..0460e4a6 --- /dev/null +++ b/crates/engine/tests/text_left_justify.rs @@ -0,0 +1,17 @@ +//! Properties moves justified TEXT by its alignment point: a TL text (placed by its alignment +//! point, #342) lands where its new position says. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, entity_bounds}; +use serde_json::json; + +#[test] +fn moving_top_left_text_through_properties_moves_it() { + let mut s = Session::new(); + let r = s.execute("text", &json!({ "at": [5, 5], "text": "TL", "height": 1, "justify": "TL" })).unwrap(); + let h = r["handle"].as_str().unwrap().to_string(); + s.execute("properties.set", &json!({ "handles": [h], "position": [20, 20] })).unwrap(); + let d = s.doc().unwrap(); + let b = d.model.iter().find_map(|e| matches!(e.kind, EntityKind::Text(_)).then(|| entity_bounds(d, e, 0))).unwrap(); + assert!((b.max.y - 20.0).abs() < 1e-9 && (b.min.x - 20.0).abs() < 1e-9, "moved text {b:?}"); +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index aeb32063..92d012ec 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -381,8 +381,10 @@ pub fn place(font: &TextFont, s: &str, p: &TextParams) -> (Shaped, Bounds2) { } let mut sh = shape_line(font, s, height, wf, p.oblique); let width = sh.width; - let origin = match p.h { - Align::Left | Align::Aligned | Align::Fit => insert, + // Every justification but baseline-left (and aligned/fit, which span both points) is placed by + // its alignment point (DXF group 11); the insertion point (group 10) is then a computed point. + let origin = match (p.h, p.v) { + (Align::Left, VAlign::Baseline) | (Align::Aligned | Align::Fit, _) => insert, _ => p.align_pt.unwrap_or(insert), }; let dx = match p.h { diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 37249eed..6b7a354d 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -134,9 +134,10 @@ fn mirror_text(t: &mut Text, align: Option, insert: Vec3) { let baseline_left = |t: &Text, p: Vec3| if t.valign == VAlign::Baseline { None } else { Some(p) }; match t.halign { HAlign::Left => { + // TL/ML/BL are placed by their alignment point, baseline-left text by its start point. t.halign = HAlign::Right; t.insert = insert; - t.align_pt = Some(insert); + t.align_pt = Some(if t.valign == VAlign::Baseline { insert } else { align.unwrap_or(insert) }); } HAlign::Right => { t.halign = HAlign::Left; diff --git a/crates/io/tests/text_left_justify.rs b/crates/io/tests/text_left_justify.rs new file mode 100644 index 00000000..4b3f51e1 --- /dev/null +++ b/crates/io/tests/text_left_justify.rs @@ -0,0 +1,46 @@ +//! Left-justified TEXT with a vertical alignment (TL, ML, BL) is placed by its alignment point +//! (DXF group 11), like every justification but baseline-left; group 10 is then a computed point +//! on the baseline (#342). + +use cadcraft_doc::{EntityKind, Space, entity_bounds}; + +/// One TEXT, height 1, "AB", group 72 = 0 and group 73 = `v`, with group 10 at `p10`, group 11 at +/// `p11`, and `extra` group codes. +fn dxf(v: i32, p10: (f64, f64), p11: (f64, f64), extra: &str) -> String { + format!( + "0\nSECTION\n2\nENTITIES\n0\nTEXT\n8\n0\n10\n{}\n20\n{}\n40\n1\n1\nAB\n72\n0\n11\n{}\n21\n{}\n{extra}73\n{v}\n0\nENDSEC\n0\nEOF\n", + p10.0, p10.1, p11.0, p11.1 + ) +} + +/// (rendered box, extents box) of the drawing's only object, as (min x, min y, max x, max y). +fn boxes(text: &str) -> ([f64; 4], [f64; 4]) { + let d = cadcraft_io::read(text.as_bytes(), "t.dxf").unwrap(); + let r = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()).bounds; + let e = d.model.iter().find(|e| matches!(e.kind, EntityKind::Text(_))).map(|e| entity_bounds(&d, e, 0)).unwrap(); + ([r.min.x, r.min.y, r.max.x, r.max.y], [e.min.x, e.min.y, e.max.x, e.max.y]) +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +#[test] +fn left_justified_text_is_placed_by_its_alignment_point() { + // TL: the top-left of the text at the alignment point 0,0; the baseline one height lower. + let (r, e) = boxes(&dxf(3, (0.0, -1.0), (0.0, 0.0), "")); + assert!(near(r[0], 0.0) && near(r[3], 0.0), "TL drawn at {r:?}"); + assert!(near(e[0], 0.0) && near(e[3], 0.0) && near(e[1], -1.0), "TL extents {e:?}"); + // ML: the middle of the text height at the alignment point. + let (r, e) = boxes(&dxf(2, (0.0, -0.5), (0.0, 0.0), "")); + assert!(near(r[3], 0.5) && near(e[3], 0.5) && near(e[1], -0.5), "ML drawn at {r:?}, extents {e:?}"); + // BL: the descender line at the alignment point, so the baseline ("AB" has no descenders) a + // third of the height above it. + let (r, e) = boxes(&dxf(1, (0.0, 1.0 / 3.0), (0.0, 0.0), "")); + assert!(near(r[1], 1.0 / 3.0) && near(e[1], 1.0 / 3.0) && near(e[3], 4.0 / 3.0), "BL drawn at {r:?}, extents {e:?}"); + + // Mirrored (extrusion 0,0,-1): the OCS points (-10,4) and (-10,5) are WCS (10,4) and (10,5); + // drawn readable, the text runs left from its alignment point with its top there. + let (r, _) = boxes(&dxf(3, (-10.0, 4.0), (-10.0, 5.0), "210\n0\n220\n0\n230\n-1\n")); + assert!(near(r[2], 10.0) && near(r[3], 5.0), "mirrored TL drawn at {r:?}"); +} From 7bed82838d1e3fd6dbc00eefa9455d507a28d16e Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 23:32:01 +0200 Subject: [PATCH 106/150] JSON draw/annotation commands refuse invalid sizes and coincident points (fixes #343) (#344) CIRCLE/ARC no longer make a negative radius positive; POLYGON refuses a non-integer sides count and a zero/negative radius or zero-length edge; DONUT, RECTANG, PLINE, HATCH and TABLE refuse negative or zero sizes instead of clamping them; LINE drops repeated points like the interactive command; SPLINE needs two different fit points; DIMRADIUS/DIMDIAMETER/ DIMANGULAR refuse coincident points; DDPTYPE refuses an invalid PDMODE; DIMSTYLE/DIMOVERRIDE list a non-positive DIMTXT/DIMTFAC or a negative DIMASZ/DIMSCALE/DIMTSZ as ignored. Co-authored-by: Claude Opus 5.5 --- crates/doc/src/tables.rs | 11 ++ crates/engine/src/cmd/annotate.rs | 6 + crates/engine/src/cmd/draw.rs | 60 +++++++--- crates/engine/src/cmd/draw2.rs | 8 +- crates/engine/src/cmd/hatch.rs | 2 + crates/engine/src/cmd/mod.rs | 23 ++++ crates/engine/src/cmd/props.rs | 9 +- crates/engine/src/cmd/table.rs | 5 +- crates/engine/tests/json_param_validation.rs | 111 +++++++++++++++++++ 9 files changed, 212 insertions(+), 23 deletions(-) create mode 100644 crates/engine/tests/json_param_validation.rs diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index ce1a09f4..dad7b917 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -373,6 +373,17 @@ impl DimStyle { } _ => coerced, }; + // Sizes that can't be negative (text height and tolerance scale: not zero either). + let x = coerced.as_f64(); + let out_of_range = match field { + "textHeight" | "tolScale" => x.is_some_and(|x| x <= 0.0), + "scale" | "arrowSize" | "tickSize" => x.is_some_and(|x| x < 0.0), + _ => false, + }; + if out_of_range { + rejected.push(k.clone()); + continue; + } obj.insert(field.to_string(), coerced); match serde_json::from_value::(cur.clone()) { Ok(ns) => *self = ns, diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index 1f6f91f7..809d6e52 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -240,6 +240,9 @@ fn radial(s: &mut Session, p: &Value, diameter: bool) -> Result { let at = point_param(p, "at").unwrap_or(c + Vec2::from_angle(std::f64::consts::FRAC_PI_4)); (c, c + (at - c).normalized() * r) }; + if c.near(pt, 1e-12) { + return Err(bad(id, "`point` must differ from `center`")); + } let k = if diameter { dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "") } else { @@ -281,6 +284,9 @@ fn run_angular(s: &mut Session, p: &Value) -> Result { let a = point_req("dimangular", p, "p1")?; let b = point_req("dimangular", p, "p2")?; let at = point_req("dimangular", p, "at")?; + if v.near(a, 1e-12) || v.near(b, 1e-12) { + return Err(bad("dimangular", "`p1` and `p2` must differ from `vertex`")); + } let k = dim(s, DimKind::Angular3P, at, a, b, v, Vec2::ZERO, ""); Ok(json!({ "handle": add_dim(s, k)?.hex() })) } diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index ea5266bb..a4de16e2 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -99,6 +99,11 @@ fn run_line(s: &mut Session, p: &Value) -> Result { if pts.len() < 2 { return Err(bad("line", "need at least 2 points")); } + // A repeated point would make a zero-length line. + let pts = distinct_points(&pts); + if pts.len() < 2 { + return Err(bad("line", "need at least 2 different points")); + } let mut hs = Vec::new(); for w in pts.windows(2) { if let [a, b] = w { @@ -108,6 +113,7 @@ fn run_line(s: &mut Session, p: &Value) -> Result { if bool_or(p, "closed", false) && pts.len() > 2 && let (Some(a), Some(b)) = (pts.last(), pts.first()) + && !a.near(*b, 1e-12) { hs.push(s.add_entity(line(*a, *b))?.hex()); } @@ -123,29 +129,30 @@ fn vertex_value(v: &Value) -> Option { } fn run_pline(s: &mut Session, p: &Value) -> Result { - let vs: Vec = p - .get("vertices") - .or_else(|| p.get("points")) - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(vertex_value).collect()) - .unwrap_or_default(); + let raw = p.get("vertices").or_else(|| p.get("points")).and_then(Value::as_array); + for v in raw.into_iter().flatten() { + size_param("pline", v, "startWidth", true)?; + size_param("pline", v, "endWidth", true)?; + } + let vs: Vec = raw.map(|a| a.iter().filter_map(vertex_value).collect()).unwrap_or_default(); if vs.len() < 2 { return Err(bad("pline", "`vertices` needs at least 2 points")); } + let width = size_param("pline", p, "width", true)?.unwrap_or(0.0); let mut k = lwpoly(vs, bool_or(p, "closed", false)); if let EntityKind::LwPolyline(pl) = &mut k { - pl.const_width = f64_or(p, "width", 0.0).max(0.0); + pl.const_width = width; } added(s.add_entity(k)?) } fn run_circle(s: &mut Session, p: &Value) -> Result { let c = if let Some(center) = point_param(p, "center") { - let r = p - .get("radius") - .and_then(Value::as_f64) - .or_else(|| p.get("diameter").and_then(Value::as_f64).map(|d| d / 2.0)) - .ok_or_else(|| bad("circle", "`radius` or `diameter` is required"))?; + // Checked before `Circle::new`, which would turn a negative radius positive. + let r = match size_param("circle", p, "radius", false)? { + Some(r) => r, + None => size_param("circle", p, "diameter", false)?.ok_or_else(|| bad("circle", "`radius` or `diameter` is required"))? / 2.0, + }; Circle::new(center, r) } else if let (Some(a), Some(b)) = (point_param(p, "p1"), point_param(p, "p2")) { match point_param(p, "p3") { @@ -155,7 +162,7 @@ fn run_circle(s: &mut Session, p: &Value) -> Result { } else { return Err(bad("circle", "give {center, radius} or {p1, p2[, p3]}")); }; - if c.radius <= 0.0 || !c.radius.is_finite() { + if !(c.radius > 1e-12 && c.radius.is_finite()) { return Err(bad("circle", "radius must be positive")); } added(s.add_entity(circle(c.center, c.radius))?) @@ -168,6 +175,11 @@ fn run_arc(s: &mut Session, p: &Value) -> Result { Arc::from_start_center_end(st, c, e) } else if let Some(c) = point_param(p, "center") { let r = f64_req("arc", p, "radius")?; + // Checked before `Arc::new`, which would turn a negative radius positive (zero is + // refused below). + if r < 0.0 { + return Err(bad("arc", "`radius` must be positive")); + } Arc::new(c, r, f64_req("arc", p, "start")?.to_radians(), f64_req("arc", p, "end")?.to_radians()) } else { return Err(bad("arc", "give {p1,p2,p3}, {start,center,end} or {center,radius,start,end}")); @@ -179,16 +191,23 @@ fn run_arc(s: &mut Session, p: &Value) -> Result { } fn run_polygon(s: &mut Session, p: &Value) -> Result { - let n = p.get("sides").and_then(Value::as_u64).unwrap_or(4) as usize; + // A `sides` that is not a whole number (e.g. -3) is refused, not replaced by the default 4. + let n = match p.get("sides") { + None | Some(Value::Null) => 4, + Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).unwrap_or(0), + }; if !(3..=1024).contains(&n) { return Err(bad("polygon", "sides must be 3..1024")); } let vs = if let Some(e) = points_param(p, "edge") { let (Some(a), Some(b)) = (e.first(), e.get(1)) else { return Err(bad("polygon", "edge needs 2 points")) }; + if a.near(*b, 1e-12) { + return Err(bad("polygon", "edge endpoints must differ")); + } polygon_from_edge(*a, *b, n) } else { let c = point_req("polygon", p, "center")?; - let r = f64_req("polygon", p, "radius")?; + let r = size_param("polygon", p, "radius", false)?.ok_or_else(|| bad("polygon", "`radius` (number) is required"))?; let inscribed = bool_or(p, "inscribed", true); // Without an angle, the bottom edge is horizontal, as with a typed radius. let rp = match p.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()) { @@ -236,8 +255,8 @@ fn run_ray(s: &mut Session, p: &Value) -> Result { fn run_spline(s: &mut Session, p: &Value) -> Result { let sp = if let Some(f) = points_param(p, "fit") { - if f.len() < 2 { - return Err(bad("spline", "need 2+ fit points")); + if distinct_points(&f).len() < 2 { + return Err(bad("spline", "need 2+ different fit points")); } Spline::from_fit_points(&f) } else if let Some(c) = points_param(p, "control") { @@ -265,7 +284,12 @@ fn donut(center: Vec2, inside: f64, outside: f64) -> EntityKind { fn run_donut(s: &mut Session, p: &Value) -> Result { let c = point_req("donut", p, "center")?; - added(s.add_entity(donut(c, f64_or(p, "inside", 0.5), f64_or(p, "outside", 1.0)))?) + let inside = size_param("donut", p, "inside", true)?.unwrap_or(0.5); + let outside = size_param("donut", p, "outside", false)?.unwrap_or(1.0); + if inside > outside { + return Err(bad("donut", "`inside` must not be larger than `outside`")); + } + added(s.add_entity(donut(c, inside, outside))?) } /// Justification code → (halign, valign). diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 96bec285..9f062baa 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -1560,7 +1560,11 @@ pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result { .map(|c| (c.first().and_then(Value::as_f64).unwrap_or(0.0), c.get(1).and_then(Value::as_f64).unwrap_or(0.0))) .map(|(x, y)| (if x.is_finite() { x } else { 0.0 }, if y.is_finite() { y } else { 0.0 })) .unwrap_or((0.0, 0.0)); - let fillet = f64_or(p, "fillet", 0.0); + if ch.0 < 0.0 || ch.1 < 0.0 { + return Err(bad("rectang", "`chamfer` distances must be zero or positive")); + } + let fillet = size_param("rectang", p, "fillet", true)?.unwrap_or(0.0); + let width = size_param("rectang", p, "width", true)?.unwrap_or(0.0); let (w, h) = if let Some(d) = p.get("dimensions").and_then(Value::as_array) { let l = d.first().and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("rectang", "dimensions: [length, width]"))?; let wd = d.get(1).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("rectang", "dimensions: [length, width]"))?; @@ -1589,7 +1593,7 @@ pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result { if w.abs() < 1e-12 || h.abs() < 1e-12 || !w.is_finite() || !h.is_finite() { return Err(bad("rectang", "rectangle has no area")); } - let k = rect_kind(a, w, h, rot, fillet, ch, f64_or(p, "width", 0.0), f64_or(p, "elevation", 0.0)); + let k = rect_kind(a, w, h, rot, fillet, ch, width, f64_or(p, "elevation", 0.0)); let hd = s.add_entity(k)?; let th = f64_or(p, "thickness", 0.0); if th != 0.0 { diff --git a/crates/engine/src/cmd/hatch.rs b/crates/engine/src/cmd/hatch.rs index 8c6f5380..b016efbd 100644 --- a/crates/engine/src/cmd/hatch.rs +++ b/crates/engine/src/cmd/hatch.rs @@ -206,6 +206,8 @@ impl HatchSettings { } fn make(s: &mut Session, p: &Value, gradient: bool) -> Result { + // A zero or negative pattern scale would be clamped to a near-zero spacing. + size_param(if gradient { "gradient" } else { "hatch" }, p, "scale", false)?; let st = HatchSettings::from(s, p, gradient); let mut loops = Vec::new(); if let Some(pts) = points_param(p, "points") { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 80953b9c..174d5c6b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -198,6 +198,29 @@ pub(crate) fn f64_or(p: &Value, key: &str, default: f64) -> f64 { pub(crate) fn f64_req(cmd: &str, p: &Value, key: &str) -> Result { p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` (number) is required"))) } +/// An optional size: `None` when absent or null, else a finite number above zero (or zero too +/// with `zero_ok`). Anything else is refused, not clamped or made absolute, so a bad value never +/// makes a degenerate object. +pub(crate) fn size_param(cmd: &str, p: &Value, key: &str, zero_ok: bool) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_f64().filter(|x| x.is_finite()) { + Some(x) if x > 0.0 || (zero_ok && x == 0.0) => Ok(Some(x)), + _ => Err(bad(cmd, format!("`{key}` must be {}", if zero_ok { "zero or positive" } else { "positive" }))), + }, + } +} +/// `pts` without consecutive repeats (closer than `1e-12`), as the interactive LINE ignores a +/// point picked twice. +pub(crate) fn distinct_points(pts: &[Vec2]) -> Vec { + let mut out: Vec = Vec::with_capacity(pts.len()); + for p in pts { + if !out.last().is_some_and(|l| l.near(*p, 1e-12)) { + out.push(*p); + } + } + out +} pub(crate) fn bool_or(p: &Value, key: &str, default: bool) -> bool { p.get(key).and_then(Value::as_bool).unwrap_or(default) } diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 0cfb9da4..ea8dd569 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -854,8 +854,15 @@ fn run_mleaderstyle(s: &mut Session, p: &Value) -> Result { } fn run_ptype(s: &mut Session, p: &Value) -> Result { + // PDMODE is a shape 0..=4 plus 32 (circle) and/or 64 (square). + let mode = match p.get("pdmode") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_i64().filter(|m| (0..=100).contains(m) && m & !96 <= 4).ok_or_else(|| bad("ddptype", "`pdmode` must be 0..4, plus 32 and/or 64"))?, + ), + }; let d = s.doc_mut()?; - if let Some(m) = p.get("pdmode").and_then(Value::as_i64) { + if let Some(m) = mode { d.header.set_i64("PDMODE", m); } if let Some(z) = p.get("pdsize").and_then(Value::as_f64) { diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 29d43885..ae8bb3fe 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -118,11 +118,12 @@ fn run_table(s: &mut Session, p: &Value) -> Result { if rows > MAX_ROWS || cols > MAX_COLS || cols == 0 { return Err(bad("table", format!("rows must be ≤ {MAX_ROWS} and columns 1..={MAX_COLS}"))); } - let pos = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); + let row_height = size_param("table", p, "rowHeight", false)?; + let col_width = size_param("table", p, "colWidth", false)?; let header: Option> = p.get("header").and_then(Value::as_array).map(|a| a.iter().map(|v| v.as_str().unwrap_or("").to_string()).collect()); let st = style_of(s); - let t = make_table(&st, at, rows, cols, pos("rowHeight"), pos("colWidth"), str_param(p, "title"), header, &cells); + let t = make_table(&st, at, rows, cols, row_height, col_width, str_param(p, "title"), header, &cells); let h = s.add_entity(EntityKind::Table(t))?; Ok(json!({ "handle": h.hex() })) } diff --git a/crates/engine/tests/json_param_validation.rs b/crates/engine/tests/json_param_validation.rs new file mode 100644 index 00000000..4f3b07e9 --- /dev/null +++ b/crates/engine/tests/json_param_validation.rs @@ -0,0 +1,111 @@ +//! The JSON forms of the draw and annotation commands refuse sizes and points that would make a +//! degenerate or invalid object (a negative radius made positive, a zero hatch scale, a +//! zero-length line …) instead of adding it (#343). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; +use serde_json::{Value, json}; + +fn count(s: &Session) -> usize { + s.doc().map(|d| d.model.iter().count()).unwrap_or(0) +} + +/// A session with a 10×10 square (for HATCH's internal point) and a circle (for DIMRADIUS). +fn session() -> Session { + let mut s = Session::new(); + s.execute("rectang", &json!({"p1": [0, 0], "p2": [10, 10]})).unwrap(); + s +} + +#[test] +fn invalid_sizes_and_points_are_refused() { + let cases: &[(&str, Value, &str)] = &[ + ("circle", json!({"center": [0, 0], "radius": -5}), "`radius` must be positive"), + ("circle", json!({"center": [0, 0], "radius": 1e-300}), "radius must be positive"), + ("circle", json!({"center": [0, 0], "diameter": -2}), "`diameter` must be positive"), + ("arc", json!({"center": [0, 0], "radius": -5, "start": 0, "end": 90}), "`radius` must be positive"), + ("polygon", json!({"sides": -3, "center": [0, 0], "radius": 5}), "sides must be 3..1024"), + ("polygon", json!({"sides": 6, "center": [0, 0], "radius": -5}), "`radius` must be positive"), + ("polygon", json!({"sides": 6, "center": [0, 0], "radius": 0}), "`radius` must be positive"), + ("polygon", json!({"sides": 6, "edge": [[1, 1], [1, 1]]}), "edge endpoints must differ"), + ("donut", json!({"center": [0, 0], "inside": -1, "outside": 2}), "`inside` must be zero or positive"), + ("donut", json!({"center": [0, 0], "inside": 0, "outside": 0}), "`outside` must be positive"), + ("donut", json!({"center": [0, 0], "inside": 5, "outside": 2}), "must not be larger"), + ("rectang", json!({"p1": [0, 0], "p2": [10, 5], "fillet": -1}), "`fillet` must be zero or positive"), + ("rectang", json!({"p1": [0, 0], "p2": [10, 5], "chamfer": [-1, 1]}), "`chamfer` distances"), + ("rectang", json!({"p1": [0, 0], "p2": [10, 5], "width": -1}), "`width` must be zero or positive"), + ("pline", json!({"vertices": [[0, 0], [1, 0]], "width": -1}), "`width` must be zero or positive"), + ("pline", json!({"vertices": [{"p": [0, 0], "startWidth": -1}, [1, 0]]}), "`startWidth` must be zero or positive"), + ("line", json!({"points": [[2, 2], [2, 2]]}), "2 different points"), + ("spline", json!({"fit": [[0, 0], [0, 0], [0, 0]]}), "different fit points"), + ("hatch", json!({"points": [[1, 1]], "scale": 0}), "`scale` must be positive"), + ("hatch", json!({"points": [[1, 1]], "scale": -1}), "`scale` must be positive"), + ("table", json!({"at": [0, 0], "rows": 2, "cols": 2, "rowHeight": -1}), "`rowHeight` must be positive"), + ("table", json!({"at": [0, 0], "rows": 2, "cols": 2, "colWidth": 0}), "`colWidth` must be positive"), + ("dimradius", json!({"center": [3, 3], "point": [3, 3]}), "must differ from `center`"), + ("dimdiameter", json!({"center": [3, 3], "point": [3, 3]}), "must differ from `center`"), + ("dimangular", json!({"vertex": [0, 0], "p1": [0, 0], "p2": [5, 0], "at": [1, 1]}), "must differ from `vertex`"), + ("ddptype", json!({"pdmode": 999}), "`pdmode` must be"), + ("ddptype", json!({"pdmode": 5}), "`pdmode` must be"), + ]; + for (cmd, p, want) in cases { + let mut s = session(); + let before = count(&s); + let err = s.execute(cmd, p).expect_err(&format!("{cmd} {p} should be refused")).to_string(); + assert!(err.contains(want), "{cmd} {p}: {err}"); + assert_eq!(count(&s), before, "{cmd} {p} added an object"); + } +} + +#[test] +fn valid_values_still_work() { + let cases: &[(&str, Value)] = &[ + ("circle", json!({"center": [0, 0], "radius": 5})), + ("circle", json!({"center": [0, 0], "diameter": 4})), + ("arc", json!({"center": [0, 0], "radius": 5, "start": 0, "end": 90})), + ("polygon", json!({"center": [0, 0], "radius": 5})), + ("polygon", json!({"sides": 6, "edge": [[0, 0], [1, 0]]})), + ("donut", json!({"center": [0, 0], "inside": 0, "outside": 2})), + ("donut", json!({"center": [0, 0], "inside": 2, "outside": 2})), + ("rectang", json!({"p1": [0, 0], "p2": [10, 5], "fillet": 0, "width": 0, "chamfer": [0, 0]})), + ("pline", json!({"vertices": [{"p": [0, 0], "startWidth": 0, "endWidth": 1}, [1, 0]], "width": 0.5})), + ("hatch", json!({"points": [[1, 1]], "scale": 2})), + ("table", json!({"at": [20, 0], "rows": 2, "cols": 2, "rowHeight": 1, "colWidth": 3})), + ("dimradius", json!({"center": [3, 3], "point": [5, 3]})), + ]; + for (cmd, p) in cases { + let mut s = session(); + let before = count(&s); + s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")); + assert_eq!(count(&s), before + 1, "{cmd} {p}"); + } + let mut s = Session::new(); + for m in [0, 3, 35, 66, 100] { + s.execute("ddptype", &json!({"pdmode": m, "pdsize": -5})).unwrap(); + } +} + +#[test] +fn repeated_line_points_make_no_zero_length_line() { + let mut s = Session::new(); + let r = s.execute("line", &json!({"points": [[0, 0], [0, 0], [5, 0], [5, 0], [5, 5]], "closed": true})).unwrap(); + assert_eq!(r["handles"].as_array().map(Vec::len), Some(3), "{r}"); + let d = s.doc().unwrap(); + assert!(d.model.iter().all(|e| matches!(&e.kind, EntityKind::Line(l) if l.a != l.b))); +} + +#[test] +fn dimension_style_sizes_out_of_range_are_ignored() { + let mut s = Session::new(); + let r = s.execute("dimstyle", &json!({"name": "D", "DIMTXT": -1, "DIMASZ": -1, "DIMSCALE": -2, "DIMGAP": 0.1})).unwrap(); + let ignored: Vec<&str> = r["ignored"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + for k in ["DIMTXT", "DIMASZ", "DIMSCALE"] { + assert!(ignored.contains(&k), "{r}"); + } + let st = s.doc().unwrap().dim_style("D").unwrap(); + assert!(st.text_height > 0.0 && st.arrow_size >= 0.0 && st.scale >= 0.0); + assert!((st.text_gap - 0.1).abs() < 1e-12); + // Zero arrows and DIMSCALE 0 stay valid. + let r = s.execute("dimstyle", &json!({"name": "D", "DIMASZ": 0, "DIMSCALE": 0})).unwrap(); + assert!(r.get("ignored").is_none(), "{r}"); +} From 937dca65907beafff6a827a960271f39b17180f2 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 23:32:07 +0200 Subject: [PATCH 107/150] DWG and DXF keep ANGBASE/ANGDIR, fillet/chamfer, elevation and the snap grid (fixes #349) (#350) acadrust's DXF writer leaves ANGBASE, ANGDIR, FILLETRAD, CHAMFERA/B, ELEVATION and THICKNESS out of the HEADER although its DWG reader fills them in; the DWG bridge now adds them to the DXF it hands our reader. SNAPBASE/SNAPANG are written to and read from the *ACTIVE VPORT record (groups 13/23 and 50), where R13+ files keep them. Co-authored-by: Claude Opus 5.5 --- crates/dwg/src/lib.rs | 38 +++++++++++++++++- crates/io/src/dxf_read.rs | 14 +++++++ crates/io/src/dxf_write.rs | 4 +- crates/io/tests/dwg_header_vars.rs | 62 ++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 crates/io/tests/dwg_header_vars.rs diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index d1902774..86871938 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -79,7 +79,43 @@ mod native { )); } written.map_err(|e| format!("DWG→DXF: {e}"))?; - Ok(out.buf) + Ok(add_header_vars(out.buf, &doc.header)) + } + + /// Header variables the DWG reader fills in but acadrust's DXF writer (0.6.3) leaves out of + /// the HEADER section: (name, group code, value). + fn unwritten_header_vars(h: &acadrust::document::HeaderVariables) -> [(&'static str, i32, String); 7] { + [ + ("$ANGBASE", 50, h.angle_base.to_string()), + ("$ANGDIR", 70, h.angle_direction.to_string()), + ("$FILLETRAD", 40, h.fillet_radius.to_string()), + ("$CHAMFERA", 40, h.chamfer_distance_a.to_string()), + ("$CHAMFERB", 40, h.chamfer_distance_b.to_string()), + ("$ELEVATION", 40, h.elevation.to_string()), + ("$THICKNESS", 40, h.thickness.to_string()), + ] + } + + /// Add the [`unwritten_header_vars`] to the HEADER section of acadrust's DXF text, before its + /// ENDSEC; a variable the writer already wrote is left alone. Text of another layout than + /// acadrust's (` 0\r\nSECTION\r\n 2\r\nHEADER\r\n` … ` 0\r\nENDSEC\r\n`) is returned unchanged. + fn add_header_vars(mut dxf: Vec, h: &acadrust::document::HeaderVariables) -> Vec { + const START: &[u8] = b" 0\r\nSECTION\r\n 2\r\nHEADER\r\n"; + const END: &[u8] = b"\r\n 0\r\nENDSEC\r\n"; + if !dxf.starts_with(START) { + return dxf; + } + let Some(end) = dxf.windows(END.len()).position(|w| w == END).map(|p| p + 2) else { return dxf }; + let section = dxf.get(..end).unwrap_or_default(); + let mut add = String::new(); + for (name, code, value) in unwritten_header_vars(h) { + let written = section.windows(name.len() + 2).any(|w| w.starts_with(name.as_bytes()) && w.ends_with(b"\r\n")); + if !written && value.parse::().is_ok_and(f64::is_finite) { + add.push_str(&format!(" 9\r\n{name}\r\n{code:>3}\r\n{value}\r\n")); + } + } + dxf.splice(end..end, add.into_bytes()); + dxf } /// Collects the DXF text and fails the write once it would grow past `limit`. diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 6b7a354d..1fd444b1 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -1076,6 +1076,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { _ => {} } } + let mut active_vport = false; for (kind, tg) in recs { let t = T(&tg); match kind.as_str() { @@ -1145,6 +1146,19 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { None => d.text_styles.push(st), } } + // The first *ACTIVE viewport holds the snap grid's origin and rotation (degrees). + "VPORT" if !active_vport && t.s(2).is_some_and(|n| n.trim().eq_ignore_ascii_case("*ACTIVE")) => { + active_vport = true; + if let (Some(x), Some(y)) = (t.f(13), t.f(23)) + && x.is_finite() + && y.is_finite() + { + d.header.set("SNAPBASE", HVal::Point(Vec3::new(x, y, 0.0))); + } + if let Some(a) = t.f(50).filter(|a| a.is_finite()) { + d.header.set_f64("SNAPANG", a); + } + } "VIEW" => { let name = t.s(2).unwrap_or_default(); let (h, w) = (t.fd(40, 0.0), t.fd(41, 0.0)); diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index e6ee22c8..35a6a798 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -1367,7 +1367,8 @@ pub fn write(d: &Drawing) -> String { w.p2(10, Vec2::ZERO); w.p2(11, Vec2::new(1.0, 1.0)); w.p2(12, if ext.is_empty() { Vec2::new(6.0, 4.5) } else { ext.center() }); - w.p2(13, Vec2::ZERO); + // Snap grid origin and rotation (SNAPBASE, SNAPANG in degrees): R13+ files keep them here. + w.p2(13, d.header.point("SNAPBASE").map(|p| p.xy()).filter(|p| p.is_finite()).unwrap_or(Vec2::ZERO)); w.p2(14, Vec2::new(0.5, 0.5)); w.p2(15, Vec2::new(0.5, 0.5)); w.p(16, Vec3::Z); @@ -1375,6 +1376,7 @@ pub fn write(d: &Drawing) -> String { w.f(40, if ext.is_empty() { 9.0 } else { (ext.height() * 1.1).max(1e-6) }); w.f(41, 1.6); w.f(42, 50.0); + w.f(50, d.header.f64("SNAPANG", 0.0)); w.s(0, "ENDTAB"); // LTYPE. Embedded text and shapes point at STYLE records (340), which get their handles // in the STYLE table below: (tag position, style name, is a shape) are filled in there. diff --git a/crates/io/tests/dwg_header_vars.rs b/crates/io/tests/dwg_header_vars.rs new file mode 100644 index 00000000..178a5d27 --- /dev/null +++ b/crates/io/tests/dwg_header_vars.rs @@ -0,0 +1,62 @@ +//! Unit, angle and drafting settings in the drawing header survive saving as DXF or DWG and +//! reopening. The DWG bridge converts with acadrust, whose DXF writer leaves some header +//! variables out; the snap grid's origin and rotation live in the `*ACTIVE` VPORT record. +#![cfg(not(target_arch = "wasm32"))] + +use cadcraft_doc::{Drawing, HVal}; +use cadcraft_geom::Vec3; + +fn drawing() -> Drawing { + let mut d = Drawing::new_metric(); + let h = &mut d.header; + h.set_i64("AUNITS", 3); + h.set_i64("AUPREC", 5); + h.set_i64("LUNITS", 4); + h.set_i64("LUPREC", 6); + h.set_f64("ANGBASE", 0.5); + h.set_i64("ANGDIR", 1); + h.set_i64("INSUNITS", 6); + h.set_i64("MEASUREMENT", 0); + h.set_f64("LTSCALE", 2.5); + h.set_f64("DIMSCALE", 4.0); + h.set_i64("PDMODE", 34); + h.set_f64("PDSIZE", 1.5); + h.set_f64("FILLETRAD", 2.0); + h.set_f64("CHAMFERA", 1.25); + h.set_f64("CHAMFERB", 0.75); + h.set_f64("ELEVATION", 3.0); + h.set_f64("THICKNESS", 0.25); + h.set("SNAPBASE", HVal::Point(Vec3::new(3.0, 4.0, 0.0))); + h.set_f64("SNAPANG", 30.0); + d +} + +fn check(name: &str, r: &Drawing) { + let d = drawing(); + let ints = ["AUNITS", "AUPREC", "LUNITS", "LUPREC", "ANGDIR", "INSUNITS", "MEASUREMENT", "PDMODE"]; + for v in ints { + assert_eq!(r.header.i64(v, -99), d.header.i64(v, -99), "{name}: ${v}"); + } + let reals = ["ANGBASE", "LTSCALE", "DIMSCALE", "PDSIZE", "FILLETRAD", "CHAMFERA", "CHAMFERB", "ELEVATION", "THICKNESS", "SNAPANG"]; + for v in reals { + let (got, want) = (r.header.f64(v, -99.0), d.header.f64(v, -99.0)); + assert!((got - want).abs() < 1e-9, "{name}: ${v} read back as {got}, not {want}"); + } + let base = r.header.point("SNAPBASE").unwrap_or(Vec3::new(-99.0, -99.0, 0.0)); + assert!((base.x - 3.0).abs() < 1e-9 && (base.y - 4.0).abs() < 1e-9, "{name}: $SNAPBASE read back as {base:?}"); +} + +#[test] +fn header_settings_round_trip_through_dxf() { + let d = drawing(); + let r = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap(); + check("DXF", &r); +} + +#[test] +fn header_settings_round_trip_through_dwg() { + let d = drawing(); + let dwg = cadcraft_io::write(&d, "x.dwg").unwrap(); + let r = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + check("DWG", &r); +} From 45755697b64e75a87c002e6a368374031d4695b7 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 23:32:13 +0200 Subject: [PATCH 108/150] Angle prompts read AUNITS, ANGBASE and ANGDIR (fixes #353) (#354) ROTATE's angle and the hatch pattern angle are rotations (AUNITS, clockwise when ANGDIR is 1). The dimension line (Rotated) and dimension text angles are directions from ANGBASE. CHAMFER's angle and the MLEADER angle constraints are amounts in AUNITS. The ROTATE and HATCH defaults are offered as they would be typed. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/annotate.rs | 6 +- crates/engine/src/cmd/hatch.rs | 7 +- crates/engine/src/cmd/modify.rs | 12 +++- crates/engine/tests/angle_prompts.rs | 102 +++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 8 deletions(-) create mode 100644 crates/engine/tests/angle_prompts.rs diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index 809d6e52..b3596812 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -847,7 +847,7 @@ impl DimText { let Some(a) = self.asking else { return false }; match (a, i) { (DimAsk::Text, Input::Text(t)) => self.text = t.clone(), - (DimAsk::Angle, Input::Text(t)) => match crate::units::parse_angle(t) { + (DimAsk::Angle, Input::Text(t)) => match s.angle_settings().direction(t) { Some(r) => self.angle = r, None => { s.echo("Requires a valid angle."); @@ -912,7 +912,7 @@ impl Interactive for LinearM { fn input(&mut self, s: &mut Session, i: Input) -> Result { if self.rotating { if let Input::Text(t) = &i { - let Some(r) = crate::units::parse_angle(t) else { + let Some(r) = s.angle_settings().direction(t) else { s.echo("Requires a valid angle."); return Ok(Step::Continue); }; @@ -1566,7 +1566,7 @@ impl MLeaderM { Some(MaxPoints) } }, - (FirstAngle | SecondAngle, Input::Text(t)) => match crate::units::parse_angle(&t).filter(|a| *a >= 0.0) { + (FirstAngle | SecondAngle, Input::Text(t)) => match s.angle_settings().amount(&t).filter(|a| *a >= 0.0) { Some(a) => { self.angles[usize::from(o == SecondAngle)] = a; Some(Menu) diff --git a/crates/engine/src/cmd/hatch.rs b/crates/engine/src/cmd/hatch.rs index b016efbd..85632541 100644 --- a/crates/engine/src/cmd/hatch.rs +++ b/crates/engine/src/cmd/hatch.rs @@ -348,7 +348,8 @@ impl HatchM { } } HatchAsk::Angle => { - if let Some(a) = crate::units::parse_angle(t).filter(|a| a.is_finite()) { + // Turns the pattern from the X axis (not from ANGBASE), clockwise when ANGDIR is 1. + if let Some(a) = s.angle_settings().rotation(t).filter(|a| a.is_finite()) { self.settings.angle = a; s.doc_mut()?.header.set_f64("HPANG", a); } @@ -380,7 +381,9 @@ impl Interactive for HatchM { } Some(HatchAsk::Angle) => { let (au, ap) = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0)); - let d = crate::units::format_angle(self.settings.angle, au, ap); + // Offered as it would be typed: clockwise is positive when ANGDIR is 1. + let a = if s.angle_settings().clockwise { -self.settings.angle } else { self.settings.angle }; + let d = crate::units::format_angle(a, au, ap); return Prompt::new("Specify an angle for the hatch pattern", Accept::NUMBER).default(d); } None => {} diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 2fe21c89..ad7ec2dd 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1545,9 +1545,11 @@ impl Interactive for SelectThen { // ROTATE and SCALE offer the angle/factor used last time. (Op::Rotate, _) => { let (au, ap) = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0)); + // Offered as it would be typed: clockwise is positive when ANGDIR is 1. + let a = if s.angle_settings().clockwise { -s.last_used.rotate_angle } else { s.last_used.rotate_angle }; Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER) .kw(&["Copy", "Reference"]) - .default(crate::units::format_angle(s.last_used.rotate_angle, au, ap)) + .default(crate::units::format_angle(a, au, ap)) .base_opt(base) } (Op::Scale, _) => Prompt::new("Specify scale factor", Accept::POINT_OR_NUMBER) @@ -1657,7 +1659,9 @@ impl Interactive for SelectThen { (Op::Rotate, 1, inp @ (Input::Point(_) | Input::Text(_) | Input::Enter)) => { let a = match inp { Input::Point(p) => self.pts[0].angle_to(p), - Input::Text(t) => crate::units::parse_angle(&t) + Input::Text(t) => s + .angle_settings() + .rotation(&t) .filter(|a| a.is_finite()) .ok_or_else(|| EngineError::Other("Requires an angle or point.".into()))?, _ => s.last_used.rotate_angle, @@ -2286,6 +2290,7 @@ impl FilletM { Err(invalid()) } }; + let au = s.angle_settings(); let d = s.doc_mut()?; self.ask = None; match ask { @@ -2310,7 +2315,8 @@ impl FilletM { } FilletAsk::Angle => { if !text.is_empty() { - let a = crate::units::parse_angle(&text) + let a = au + .amount(&text) .filter(|a| a.is_finite() && *a >= 0.0 && *a < std::f64::consts::PI) .ok_or_else(|| EngineError::Other("Requires an angle from 0 to 180 degrees.".into()))?; d.header.set_f64("CHAMFERD", a); diff --git a/crates/engine/tests/angle_prompts.rs b/crates/engine/tests/angle_prompts.rs new file mode 100644 index 00000000..bbe29a64 --- /dev/null +++ b/crates/engine/tests/angle_prompts.rs @@ -0,0 +1,102 @@ +//! Angle prompts read typed angles with the drawing's settings (#353): AUNITS for bare numbers, +//! rotations turn clockwise when ANGDIR is 1, directions are measured from ANGBASE. + +use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI}; + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{DimKind, EntityKind}; +use cadcraft_engine::geom::Vec2; +use serde_json::json; + +const QUARTER: &str = "1.5707963267948966"; +const EIGHTH: &str = "0.7853981633974483"; + +/// Radians, clockwise angles, 0 = north. +fn session() -> Session { + let mut s = Session::new(); + for (name, value) in [("AUNITS", json!(3)), ("ANGDIR", json!(1)), ("ANGBASE", json!(90))] { + s.execute("setvar", &json!({ "name": name, "value": value })).unwrap(); + } + s +} + +fn last(s: &Session) -> EntityKind { + s.doc().unwrap().model.last().unwrap().kind.clone() +} + +/// `a - b` turned into (-π, π]. +fn diff(a: f64, b: f64) -> f64 { + let d = (a - b).rem_euclid(2.0 * PI); + if d > PI { d - 2.0 * PI } else { d } +} + +#[test] +fn rotate_angle_is_a_rotation_in_aunits() { + let mut s = session(); + s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap(); + s.script(&format!("ROTATE ALL\n\n0,0 {QUARTER}\n")).unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + // A quarter turn clockwise (ANGDIR 1), not 1.57 degrees counterclockwise. + match last(&s) { + EntityKind::Line(l) => assert!(l.b.xy().near(Vec2::new(0.0, -10.0), 1e-9), "{:?}", l.b), + k => panic!("expected a line, got {k:?}"), + } +} + +#[test] +fn hatch_angle_is_a_rotation_in_aunits() { + let mut s = session(); + s.script(&format!("HATCH A {QUARTER}\n\n")).unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let a = s.doc().unwrap().header.f64("HPANG", 0.0); + assert!(diff(a, -FRAC_PI_2).abs() < 1e-9, "HPANG {a}"); +} + +#[test] +fn dimension_line_and_text_angles_are_directions() { + let mut s = session(); + // Rotated 0 points at ANGBASE (north); the text angle a quarter turn clockwise from north is east. + s.script(&format!("DIMLINEAR 0,0 10,0 R 0 A {QUARTER} 5,5\n")).unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + match last(&s) { + EntityKind::Dimension(d) => { + let DimKind::Linear { rotation } = d.kind else { panic!("expected a linear dimension, got {:?}", d.kind) }; + assert!(diff(rotation, FRAC_PI_2).abs() < 1e-9, "rotation {rotation}"); + assert!(diff(d.text_rotation, 0.0).abs() < 1e-9, "text rotation {}", d.text_rotation); + } + k => panic!("expected a dimension, got {k:?}"), + } +} + +#[test] +fn chamfer_and_mleader_angles_are_amounts_in_aunits() { + let mut s = session(); + for l in ["0,0 12,0", "10,-2 10,12"] { + s.cmdline(&format!("LINE {l}")).unwrap(); + s.cmdline("").unwrap(); + } + // Length 2 on the first line at an eighth turn from it, whatever ANGBASE and ANGDIR say. + s.script(&format!("CHAMFER A 2 {EIGHTH} 5,0 10,5\n")).unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let d = s.doc().unwrap().header.f64("CHAMFERD", 0.0); + assert!((d - FRAC_PI_4).abs() < 1e-9, "CHAMFERD {d}"); + let chamfer = s.doc().unwrap().model.iter().any(|e| match &e.kind { + EntityKind::Line(l) => { + let (a, b) = (l.a.xy(), l.b.xy()); + (a.near(Vec2::new(8.0, 0.0), 1e-6) && b.near(Vec2::new(10.0, 2.0), 1e-6)) + || (b.near(Vec2::new(8.0, 0.0), 1e-6) && a.near(Vec2::new(10.0, 2.0), 1e-6)) + } + _ => false, + }); + assert!(chamfer, "{:?}", s.doc().unwrap().model.iter().map(|e| &e.kind).collect::>()); + + // The first angle constraint snaps the leader to eighth-turn steps. + s.script(&format!("MLEADER O F {EIGHTH} X 0,0 10,8 note\n")).unwrap(); + match last(&s) { + EntityKind::MLeader(m) => { + let r = Vec2::new(10.0, 8.0).len(); + assert!(m.landing.xy().near(Vec2::new(r, r) / 2f64.sqrt(), 1e-6), "{:?}", m.landing); + } + k => panic!("expected a multileader, got {k:?}"), + } +} From cdd25840cc41b01ee4ee5f2801a9ec2ba7b21caa Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 23:32:19 +0200 Subject: [PATCH 109/150] Control channel: a timed-out request is cancelled, and the reply says whether it ran (fixes #345) (#348) The control server gave up after 60 s with "timeout" but left the request queued, so the app ran it later and a client retry applied it twice. Requests now carry a shared state: on timeout the server cancels a request the app has not started (reply state "not-run", safe to retry) and the app skips it; one already started replies state "may-have-run". The MCP bridge passes that on in its error. Co-authored-by: Claude Opus 5.5 --- apps/cadcraft/src/control_server.rs | 6 +- crates/mcp/src/backend.rs | 28 +++++++++- crates/ui-egui/src/control.rs | 87 ++++++++++++++++++++++++++++- crates/ui-egui/src/lib.rs | 6 +- docs/control-protocol.md | 9 +++ 5 files changed, 129 insertions(+), 7 deletions(-) diff --git a/apps/cadcraft/src/control_server.rs b/apps/cadcraft/src/control_server.rs index b83c4914..181630bc 100644 --- a/apps/cadcraft/src/control_server.rs +++ b/apps/cadcraft/src/control_server.rs @@ -42,12 +42,14 @@ fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { let id = msg.get("id").cloned().unwrap_or(Value::Null); let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string(); let params = msg.get("params").cloned().unwrap_or(json!({})); - let (req, rrx) = ControlRequest::new(method, params); + let (req, pending) = ControlRequest::new(method, params); if tx.send(req).is_err() { break; } ctx.request_repaint(); - let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})); + // On timeout the request is cancelled if the app hasn't started it, and the reply's + // `state` says whether it can still have run. + let mut r = pending.wait(Duration::from_secs(60)); if let Some(o) = r.as_object_mut() { o.insert("id".into(), id); } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 4ed4ac7e..6a2089af 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -98,7 +98,13 @@ impl Backend for Remote { if v.get("ok").and_then(Value::as_bool) == Some(true) { Ok(v.get("result").cloned().unwrap_or(Value::Null)) } else { - Err(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string()) + let e = v.get("error").and_then(Value::as_str).unwrap_or("error"); + // A timeout says whether the request can still have run (#345). + Err(match v.get("state").and_then(Value::as_str) { + Some("not-run") => format!("{e}: CADCraft did not start `{method}` and won't; it is safe to send again"), + Some("may-have-run") => format!("{e}: `{method}` may or may not have been applied. Inspect the drawing first"), + _ => e.to_string(), + }) } } fn has_ui(&self) -> bool { @@ -236,4 +242,24 @@ mod tests { assert!(err.contains("may or may not have been applied"), "{err}"); assert_eq!(server.join().unwrap(), 1); } + + /// The app's timeout reply says whether the request can still run; the error passes that on (#345). + #[test] + fn timeout_reply_says_whether_a_retry_is_safe() { + for (state, want) in [("not-run", "safe to send again"), ("may-have-run", "Inspect the drawing first")] { + 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 (conn, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(conn.try_clone().unwrap()).read_line(&mut line).unwrap(); + let mut out = conn; + writeln!(out, "{}", json!({"id": 1, "ok": false, "error": "timeout", "state": state})).unwrap(); + }); + let mut remote = Remote::connect(&addr).unwrap(); + let err = remote.call("engine.execute", json!({"command": "circle", "params": {}})).unwrap_err(); + assert!(err.starts_with("timeout") && err.contains(want), "{err}"); + server.join().unwrap(); + } + } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1e322649..ea55ed32 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -18,7 +18,10 @@ //! render of the drawing (no window needed; fits the drawing unless `fit` is false; replies `pngBase64`) //! - `app.open {path}`, `app.save {path?}`, `app.quit` -use std::sync::mpsc::Sender; +use std::sync::Arc; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::mpsc::{Receiver, Sender}; +use std::time::Duration; use cadcraft_engine::Input; use cadcraft_geom::Vec2; @@ -32,12 +35,52 @@ pub struct ControlRequest { pub method: String, pub params: Value, pub reply: Sender, + state: Arc, } +/// [`ControlRequest`] states, shared by the app and the transport waiting for the reply. +const QUEUED: u8 = 0; +const STARTED: u8 = 1; +const CANCELLED: u8 = 2; + impl ControlRequest { - pub fn new(method: impl Into, params: Value) -> (Self, std::sync::mpsc::Receiver) { + pub fn new(method: impl Into, params: Value) -> (Self, PendingReply) { let (tx, rx) = std::sync::mpsc::channel(); - (Self { method: method.into(), params, reply: tx }, rx) + let state = Arc::new(AtomicU8::new(QUEUED)); + (Self { method: method.into(), params, reply: tx, state: state.clone() }, PendingReply { rx, state }) + } + + /// Mark the request as started. False when the transport already gave up on it + /// ([`PendingReply::wait`] timed out): the app must then skip it, since the client was told it + /// did not run and may send it again. + pub fn begin(&self) -> bool { + self.state.compare_exchange(QUEUED, STARTED, Ordering::AcqRel, Ordering::Acquire).is_ok() + } +} + +/// The transport's side of a [`ControlRequest`]: waits for the reply. +pub struct PendingReply { + rx: Receiver, + state: Arc, +} + +impl PendingReply { + /// The reply, or after `timeout` an error that says whether the request can still have + /// changed anything: + /// - `{"ok": false, "error": "timeout", "state": "not-run"}`: the app had not started it, and + /// now never will. Sending it again is safe. + /// - `{"ok": false, "error": "timeout", "state": "may-have-run"}`: the app had started it, so + /// it may still finish and change the drawing (its late reply is discarded). Inspect before + /// sending it again. + pub fn wait(self, timeout: Duration) -> ControlResponse { + if let Ok(r) = self.rx.recv_timeout(timeout) { + return r; + } + if self.state.compare_exchange(QUEUED, CANCELLED, Ordering::AcqRel, Ordering::Acquire).is_ok() { + return json!({"ok": false, "error": "timeout", "state": "not-run"}); + } + // Started: a reply that arrived just now still counts. + self.rx.try_recv().unwrap_or_else(|_| json!({"ok": false, "error": "timeout", "state": "may-have-run"})) } } @@ -356,6 +399,44 @@ mod tests { assert!((kept.scale - 5.0).abs() < 1e-9); } + #[test] + fn timed_out_request_that_never_started_is_skipped() { + let (req, pending) = ControlRequest::new("engine.execute", json!({})); + let r = pending.wait(Duration::from_millis(1)); + assert_eq!(r, json!({"ok": false, "error": "timeout", "state": "not-run"})); + // The app reaches the request after the timeout: it must not run it. + assert!(!req.begin()); + } + + #[test] + fn timed_out_request_already_running_may_have_run() { + let (req, pending) = ControlRequest::new("engine.execute", json!({})); + assert!(req.begin()); + let r = pending.wait(Duration::from_millis(1)); + assert_eq!(r, json!({"ok": false, "error": "timeout", "state": "may-have-run"})); + // The late result goes nowhere (the receiver is gone); sending it doesn't fail the app. + assert!(req.reply.send(json!({"ok": true})).is_err()); + } + + #[test] + fn reply_in_time_is_returned() { + let (req, pending) = ControlRequest::new("engine.execute", json!({})); + let app = std::thread::spawn(move || { + assert!(req.begin()); + let _ = req.reply.send(json!({"ok": true, "result": 1})); + }); + assert_eq!(pending.wait(Duration::from_secs(30)), json!({"ok": true, "result": 1})); + let _ = app.join(); + } + + #[test] + fn reply_sent_right_at_the_timeout_still_counts() { + let (req, pending) = ControlRequest::new("engine.execute", json!({})); + assert!(req.begin()); + let _ = req.reply.send(json!({"ok": true})); + assert_eq!(pending.wait(Duration::ZERO), json!({"ok": true})); + } + #[test] fn render_reply_has_base64_width_height_and_keeps_old_keys() { let r = render_reply(b"abc", 7, 5, Some("x.png")); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 56fcad67..4b818d9d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -34,7 +34,7 @@ use cadcraft_engine::Session; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -pub use control::{ControlRequest, ControlResponse}; +pub use control::{ControlRequest, ControlResponse, PendingReply}; /// The host storage key for [`CadApp::prefs_json`]. pub const PREFS_KEY: &str = "cadcraft.prefs"; @@ -436,6 +436,10 @@ impl CadApp { fn drain_control(&mut self, ctx: &egui::Context) { let Some(rx) = self.control_rx.take() else { return }; while let Ok(req) = rx.try_recv() { + // Timed out while queued: the client was told it did not run (#345). + if !req.begin() { + continue; + } let reply = req.reply.clone(); match control::handle(self, ctx, &req) { control::Outcome::Done(v) => { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 38378efe..aca718b5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -10,6 +10,15 @@ Start the desktop app with `--control PORT` (or `CADCRAFT_CONTROL_PORT=PORT`). I Errors come back as `{"id": …, "ok": false, "error": "message"}`. +The server waits up to 60 s for the app to answer a request. After that it replies with a timeout, and +`state` tells whether sending the request again is safe: + +- `{"id": …, "ok": false, "error": "timeout", "state": "not-run"}`: the app had not started the request. + It is cancelled and will never run, so sending it again is safe. +- `{"id": …, "ok": false, "error": "timeout", "state": "may-have-run"}`: the app had already started it. + It may still finish and change the drawing, and its late result is discarded. Inspect the drawing + (`drawing.inspect`) before sending it again. + | Method | Params | What it does | |---|---|---| | `cmdline.input` | `{text}` | Type a line at the command line exactly like a user. Starts commands by name/alias and answers prompts (points `x,y`, `@dx,dy`, `@d Date: Sat, 10 Oct 2026 23:32:24 +0200 Subject: [PATCH 110/150] Control channel: headless and app reply alike to cmdline.input, state and app.open (fixes #351) (#352) Both backends now return the same command-line state (prompt, running, keywords, accept, buffer, history, historyExpanded). cmdline.input echoes a refused line to the history and `output` and also reports it in `error` on both sides. Headless app.open without a path answers "missing path" like the app instead of reaching OPEN. Documented in control-protocol.md. Co-authored-by: Claude Opus 5.5 --- crates/mcp/src/backend.rs | 15 +++++- crates/mcp/src/tools.rs | 2 +- crates/mcp/tests/cmdline_parity.rs | 49 +++++++++++++++++++ crates/ui-egui/src/control.rs | 5 +- crates/ui-egui/src/lib.rs | 13 ++--- crates/ui-egui/tests/control_cmdline.rs | 64 +++++++++++++++++++++++++ docs/control-protocol.md | 25 ++++++++++ 7 files changed, 164 insertions(+), 9 deletions(-) create mode 100644 crates/mcp/tests/cmdline_parity.rs create mode 100644 crates/ui-egui/tests/control_cmdline.rs diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 6a2089af..15f4a1fa 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -126,13 +126,18 @@ impl Default for Headless { } } +/// The command-line state, with the same fields as the app's (`cadcraft_ui_egui::control`). There +/// is no command-line widget here: nothing is typed (`buffer`) and the history isn't expanded. fn state(s: &Session) -> Value { let p = s.current_prompt(); json!({ "prompt": s.prompt_text(), "running": s.running.as_ref().map(|r| r.id.clone()), "keywords": p.as_ref().map(|p| p.keywords.clone()).unwrap_or_default(), + "accept": p.as_ref().map(|p| p.accept), + "buffer": "", "history": s.log.iter().rev().take(20).rev().cloned().collect::>(), + "historyExpanded": false, }) } @@ -149,6 +154,11 @@ impl Backend for Headless { "cmdline.input" => { let before = s.log.len(); let r = s.cmdline(&str_p("text").unwrap_or_default()); + // Like the app: a refused line is echoed to the history (and `output`), and also + // reported in `error`. + if let Err(e) = &r { + s.echo(e.to_string()); + } let out: Vec = s.log.iter().skip(before).cloned().collect(); let mut st = state(s); if let Some(o) = st.as_object_mut() { @@ -197,7 +207,10 @@ impl Backend for Headless { } Ok(json!({"pngBase64": cadcraft_engine::cmd::file::base64_encode(&png), "width": w, "height": h})) } - "app.open" => s.execute("open", &json!({"path": str_p("path")})).map_err(|e| e.to_string()), + "app.open" => { + let path = str_p("path").ok_or("missing path")?; + s.execute("open", &json!({ "path": path })).map_err(|e| e.to_string()) + } "app.save" => { let id = if p.get("path").is_some() { "saveas" } else { "qsave" }; s.execute(id, &p).map_err(|e| e.to_string()) diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index ed50d00c..ceedbc85 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -6,7 +6,7 @@ use crate::backend::Backend; pub fn tool_definitions(has_ui: bool) -> Value { let mut tools = vec![ - json!({"name": "command_line", "description": "Type one line at the CADCraft command line, exactly as a user would at an AutoCAD-style prompt. Starts commands by name or alias (LINE, L, CIRCLE, C, PLINE, RECTANG, MOVE, TRIM, OFFSET, ZOOM…) and answers the active prompt: points `x,y`, relative `@dx,dy`, polar `@dist Vec { + let mut k: Vec = v.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default(); + k.sort(); + k +} + +fn want(extra: &[&str]) -> Vec { + let mut k: Vec = STATE_KEYS.iter().chain(extra).map(|s| s.to_string()).collect(); + k.sort(); + k +} + +#[test] +fn refused_input_is_echoed_to_output_and_reported_in_error() { + let mut b = Headless::default(); + let r = b.call("cmdline.input", json!({"text": "nosuchcommand"})).unwrap(); + assert_eq!(keys(&r), want(&["output", "error"]), "{r}"); + let e = r["error"].as_str().unwrap(); + assert!(!e.is_empty()); + assert!(r["output"].as_array().unwrap().iter().any(|l| l.as_str() == Some(e)), "{r}"); + assert!(r["history"].as_array().unwrap().iter().any(|l| l.as_str() == Some(e)), "{r}"); +} + +#[test] +fn accepted_input_has_no_error_and_reports_the_prompt() { + let mut b = Headless::default(); + let r = b.call("cmdline.input", json!({"text": "circle"})).unwrap(); + assert_eq!(keys(&r), want(&["output"]), "{r}"); + assert_eq!(r["running"], "circle"); + assert!(!r["accept"].is_null(), "{r}"); + assert_eq!(r["buffer"], ""); + assert_eq!(r["historyExpanded"], false); + assert_eq!(keys(&b.call("cmdline.state", json!({})).unwrap()), want(&[])); +} + +#[test] +fn open_without_a_path_is_refused_before_the_engine() { + let mut b = Headless::default(); + assert_eq!(b.call("app.open", json!({})).unwrap_err(), "missing path"); + assert_eq!(b.call("app.open", json!({"path": 3})).unwrap_err(), "missing path"); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ea55ed32..b9bb4cec 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -180,11 +180,14 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou "cmdline.input" => { let text = s("text").unwrap_or(""); let before = app.session.log.len(); - app.cmdline(text); + let error = app.cmdline(text); let out: Vec = app.session.log.iter().skip(before).cloned().collect(); let mut st = cmdline_state(app); if let Some(o) = st.as_object_mut() { o.insert("output".into(), json!(out)); + if let Some(e) = error { + o.insert("error".into(), json!(e)); + } } ok(st) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4b818d9d..c10e5efa 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -244,19 +244,20 @@ impl CadApp { } } - /// Submit a line of command-line text. - pub fn cmdline(&mut self, text: &str) { + /// Submit a line of command-line text. A refused line is echoed to the history; the error is + /// also returned. + pub fn cmdline(&mut self, text: &str) -> Option { if self.session.running.is_none() { let first = text.split_whitespace().next().unwrap_or(""); // `cmd {json}` is a programmatic call: the engine runs it with those parameters. let json_form = text.trim_start().get(first.len()..).is_some_and(|rest| rest.trim_start().starts_with('{')); if !first.is_empty() && !json_form && menus::run_ui_command(self, &first.to_ascii_lowercase(), &Value::Null).is_some() { - return; + return None; } } - if let Err(e) = self.session.cmdline(text) { - self.session.echo(e.to_string()); - } + let e = self.session.cmdline(text).err()?.to_string(); + self.session.echo(e.clone()); + Some(e) } /// The theme the interface shows now (the choice resolved against the OS appearance). diff --git a/crates/ui-egui/tests/control_cmdline.rs b/crates/ui-egui/tests/control_cmdline.rs new file mode 100644 index 00000000..0c723cf1 --- /dev/null +++ b/crates/ui-egui/tests/control_cmdline.rs @@ -0,0 +1,64 @@ +//! The app's control channel answers `cmdline.input` and `app.open` with the same reply shape as +//! the headless MCP backend (`cadcraft_mcp::Headless`, tested in `crates/mcp/tests/cmdline_parity.rs`). + +use cadcraft_engine::Session; +use cadcraft_ui_egui::control::{ControlRequest, Outcome, handle}; +use cadcraft_ui_egui::{CadApp, Services}; +use serde_json::{Value, json}; + +const STATE_KEYS: [&str; 7] = ["prompt", "running", "keywords", "accept", "buffer", "history", "historyExpanded"]; + +fn call(app: &mut CadApp, method: &str, params: Value) -> Value { + let (req, _reply) = ControlRequest::new(method, params); + match handle(app, &egui::Context::default(), &req) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => Value::Null, + } +} + +fn keys(v: &Value) -> Vec { + let mut k: Vec = v.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default(); + k.sort(); + k +} + +fn want(extra: &[&str]) -> Vec { + let mut k: Vec = STATE_KEYS.iter().chain(extra).map(|s| s.to_string()).collect(); + k.sort(); + k +} + +fn app() -> CadApp { + CadApp::new(Session::new(), Services::default()) +} + +#[test] +fn refused_input_is_echoed_to_output_and_reported_in_error() { + let mut app = app(); + let v = call(&mut app, "cmdline.input", json!({"text": "nosuchcommand"})); + assert_eq!(v["ok"], true, "{v}"); + let r = &v["result"]; + assert_eq!(keys(r), want(&["output", "error"]), "{r}"); + let e = r["error"].as_str().unwrap(); + assert!(!e.is_empty()); + assert!(r["output"].as_array().unwrap().iter().any(|l| l.as_str() == Some(e)), "{r}"); + assert!(r["history"].as_array().unwrap().iter().any(|l| l.as_str() == Some(e)), "{r}"); +} + +#[test] +fn accepted_input_has_no_error_and_reports_the_prompt() { + let mut app = app(); + let v = call(&mut app, "cmdline.input", json!({"text": "circle"})); + let r = &v["result"]; + assert_eq!(keys(r), want(&["output"]), "{r}"); + assert_eq!(r["running"], "circle"); + assert!(!r["accept"].is_null(), "{r}"); + assert_eq!(keys(&call(&mut app, "cmdline.state", json!({}))["result"]), want(&[])); +} + +#[test] +fn open_without_a_path_is_refused_before_the_engine() { + let mut app = app(); + assert_eq!(call(&mut app, "app.open", json!({})), json!({"ok": false, "error": "missing path"})); + assert_eq!(call(&mut app, "app.open", json!({"path": 3})), json!({"ok": false, "error": "missing path"})); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index aca718b5..9dcb84de 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -43,6 +43,31 @@ The server waits up to 60 s for the app to answer a request. After that it repli The MCP server (`cadcraft-cli mcp --connect 127.0.0.1:PORT`) wraps this protocol; headless (`cadcraft-cli mcp`) it serves the same methods from an in-process session. +## Command-line replies + +The app and the headless session reply with the same fields. + +`cmdline.state`, `cmdline.key` and `cmdline.input` return the command-line state: + +| Field | Value | +|---|---| +| `prompt` | The prompt text (`"Command:"` when idle). | +| `running` | The id of the running command, or `null`. | +| `keywords` | The current prompt's options. | +| `accept` | The kinds of input the current prompt accepts, or `null` when idle. | +| `buffer` | Text typed in the command line but not yet submitted (always `""` headless). | +| `history` | The last 20 history lines. | +| `historyExpanded` | Whether the history is expanded (always `false` headless). | + +`cmdline.input` adds: + +- `output`: the history lines the input added. This includes the error message when the line was refused. +- `error`: the error message, only when the line was refused (unknown command, invalid input…). + +A refused line still replies `"ok": true`, because the text was typed. Check `error`. + +`app.open` without a string `path` fails with `"missing path"` before any command runs. + ## Interface language Select a language with `engine.execute` and the UI command `ui.language`: From 4010b949191424077e1d218db6d9c1048da0f614 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 23:32:29 +0200 Subject: [PATCH 111/150] INSERT inserts exploded (*NAME) and drawing files (fixes #340) (#341) At the block name prompt, *NAME inserts the block's objects: after the insertion point INSERT asks one scale factor and the rotation, and no attribute values. A path (C:\parts\bolt.dxf), NAME=path or NAME= reads the drawing through the io hooks and defines a block named after the file (or NAME) from its model space, with the file's $INSBASE as the base point, adding the layers, linetypes, styles and blocks it needs and keeping this drawing's definitions. A plain path whose block already exists asks whether to redefine it; NAME=path redefines without asking. A file that can't be read is reported at the name prompt. The JSON form takes `file` (optional `name`) and "*NAME". Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/blocks.rs | 102 ++++++-- crates/engine/src/cmd/blocks/insert_file.rs | 258 ++++++++++++++++++++ crates/engine/tests/insert_file.rs | 152 ++++++++++++ 3 files changed, 491 insertions(+), 21 deletions(-) create mode 100644 crates/engine/src/cmd/blocks/insert_file.rs create mode 100644 crates/engine/tests/insert_file.rs diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index 56967434..8a330ec8 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { CommandSpec::new("insert", "Block...", run_insert) .menu(&["Insert", "Block..."]) .alias(&["i", "-insert", "ddinsert"]) - .params("{name, at: [x,y], scale?: number | [x, y], rotation? (degrees), basePoint?: [x,y] (block coordinates), attribs?: {TAG: value}, explode?: bool}") + .params("{name (\"*NAME\": exploded), file?: DXF/DWG path (defines or redefines block `name`, default the file name, from its model space; base point $INSBASE), at: [x,y], scale?: number | [x, y], rotation? (degrees), basePoint?: [x,y] (block coordinates), attribs?: {TAG: value}, explode?: bool}") .interactive(|_| Ok(Box::new(InsertM::default()))), CommandSpec::new("attdef", "Define Attributes...", run_attdef) .menu(&["Draw", "Block", "Define Attributes..."]) @@ -186,6 +186,7 @@ fn run_block(s: &mut Session, p: &Value) -> Result { } fn run_insert(s: &mut Session, p: &Value) -> Result { + let p = &insert_file::json_params(s, p)?; let name = str_param(p, "name").ok_or_else(|| bad("insert", "`name` is required"))?.to_string(); let at = point_req("insert", p, "at")?; // `scale`: one factor, or [x, y] for different X and Y scale factors. @@ -414,11 +415,15 @@ impl BlockM { } } +mod insert_file; + /// Where the command-line INSERT is in its prompt sequence. #[derive(Clone, Copy, Debug, Default, PartialEq)] enum InsStage { #[default] Name, + /// A file whose block already exists: redefine it from the file? + Redefine, Point, /// Basepoint: a new base point, picked on the block shown at its definition position. BasePoint, @@ -444,13 +449,17 @@ struct InsertM { rotation: Option, attdefs: Vec<(String, String, String)>, values: serde_json::Map, + /// `*name`: insert the block's objects (one uniform scale factor, no attribute values). + explode: bool, + /// The file waiting for the Redefine answer. + file: Option, } impl InsertM { /// After the insertion point or a scale/rotation answer: the next prompt still needed. fn advance(&mut self, s: &mut Session) -> Result { self.stage = if self.scale.is_none() { - InsStage::XScale + if self.explode { InsStage::PresetScale } else { InsStage::XScale } } else if self.rotation.is_none() { InsStage::Rotation } else { @@ -460,12 +469,52 @@ impl InsertM { && self.values.len() >= self.attdefs.len() && let (Some(n), Some(a), Some(sc), Some(r)) = (self.name.clone(), self.at, self.scale, self.rotation) { - insert_with(s, &n, a, sc, r, self.base, &self.values)?; + let h = insert_with(s, &n, a, sc, r, self.base, &self.values)?; + if self.explode { + insert_file::explode_insert(s, h)?; + } return Ok(Step::Done); } Ok(Step::Continue) } + /// Block `name` chosen: collect its attribute prompts and ask for the insertion point. + fn choose(&mut self, s: &mut Session, name: &str) -> Result<()> { + let Some(b) = s.doc()?.block(name).cloned() else { + s.echo(format!("Block \"{name}\" not found.")); + return Ok(()); + }; + self.attdefs = if self.explode { + Vec::new() + } else { + b.entities + .iter() + .filter_map(|e| { + if let EntityKind::AttDef(a) = &e.kind { + (!a.constant).then(|| (a.tag.clone(), a.prompt.clone(), a.text.value.clone())) + } else { + None + } + }) + .collect() + }; + s.doc_mut()?.header.set_str("INSNAME", &b.name); + self.name = Some(b.name.clone()); + self.stage = InsStage::Point; + Ok(()) + } + + /// Define the block from a file, then choose it; a file that can't be read is reported. + fn choose_file(&mut self, s: &mut Session, f: &insert_file::FileRef) -> Result<()> { + match f.define(s) { + Ok(name) => self.choose(s, &name), + Err(e) => { + s.echo(e.to_string()); + Ok(()) + } + } + } + /// Scale factors from an opposite corner: the X and Y distances from the insertion point. fn corner(a: Vec2, p: Vec2) -> Result { let d = p - a; @@ -492,6 +541,10 @@ impl Interactive for InsertM { let p = Prompt::new("Enter block name", Accept::TEXT).kw(&["?"]); if last.is_empty() { p } else { p.default(last) } } + InsStage::Redefine => { + let name = self.file.as_ref().map(|f| f.name.clone()).unwrap_or_default(); + Prompt::new(format!("Block \"{name}\" already exists. Redefine it?"), super::curves::KW).kw(&["Yes", "No"]).default("N") + } InsStage::Point => Prompt::new("Specify insertion point", Accept::POINT).kw(&["Basepoint", "Scale", "Rotate"]), InsStage::BasePoint => Prompt::new("Specify base point", Accept::POINT), InsStage::PresetScale => Prompt::new("Specify scale factor for XYZ axes", Accept::NUMBER).default("1"), @@ -521,24 +574,27 @@ impl Interactive for InsertM { s.echo(format!("Defined blocks: {}", names.join(", "))); return Ok(Step::Continue); } - let Some(b) = s.doc()?.block(&n).cloned() else { - s.echo(format!("Block \"{n}\" not found.")); - return Ok(Step::Continue); - }; - self.attdefs = b - .entities - .iter() - .filter_map(|e| { - if let EntityKind::AttDef(a) = &e.kind { - (!a.constant).then(|| (a.tag.clone(), a.prompt.clone(), a.text.value.clone())) - } else { - None - } - }) - .collect(); - s.doc_mut()?.header.set_str("INSNAME", &b.name); - self.name = Some(b.name.clone()); - self.stage = InsStage::Point; + // `*name`: insert exploded. + self.explode = n.starts_with('*'); + let n = n.strip_prefix('*').map_or(n.as_str(), str::trim).to_string(); + match insert_file::file_ref(s, &n) { + Some(f) if f.ask_redefine(s) => { + self.file = Some(f); + self.stage = InsStage::Redefine; + } + Some(f) => self.choose_file(s, &f)?, + None => self.choose(s, &n)?, + } + } + InsStage::Redefine => { + // Yes defines the block from the file; No (the default) inserts the existing block. + let Some(f) = self.file.take() else { return Ok(Step::Continue) }; + let yes = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.trim().to_ascii_lowercase().starts_with('y')); + if yes { + self.choose_file(s, &f)?; + } else { + self.choose(s, &f.name)?; + } } InsStage::Point => match i { Input::Point(p) => { @@ -563,6 +619,10 @@ impl Interactive for InsertM { _ => return Ok(Step::Continue), }; self.scale = Some(Vec2::new(f, f)); + // Exploded insertion asks its one scale factor after the insertion point. + if self.at.is_some() { + return self.advance(s); + } self.stage = InsStage::Point; } InsStage::PresetRotation => { diff --git a/crates/engine/src/cmd/blocks/insert_file.rs b/crates/engine/src/cmd/blocks/insert_file.rs new file mode 100644 index 00000000..9e0621b1 --- /dev/null +++ b/crates/engine/src/cmd/blocks/insert_file.rs @@ -0,0 +1,258 @@ +//! INSERT of a drawing file (`-INSERT C:\parts\bolt.dxf`, `bolt=C:\parts\bolt.dwg`, JSON `file`) +//! and exploded insertion (`*name`). +//! +//! A file becomes a block named after it (or after the part before `=`): its model space, with +//! the file's `$INSBASE` as the base point, plus the layers, linetypes, styles and blocks it needs +//! (names this drawing already has keep this drawing's definitions). The file is read through the +//! host's io hooks like OPEN, so the engine stays I/O-agnostic. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use cadcraft_doc::{Block, Drawing, Entity, EntityKind, Handle}; +use serde_json::{Value, json}; + +use super::super::file::io; +use super::super::modify::explode_kind; +use super::{references_block, valid_block_name}; +use crate::cmd::{bad, str_param}; +use crate::{EngineError, Result, Session}; + +/// A drawing file named at the block name prompt. +#[derive(Clone, Debug)] +pub(super) struct FileRef { + /// The block to define. + pub name: String, + pub path: String, + /// `name=path`: redefine the block without asking. + explicit: bool, +} + +impl FileRef { + /// Whether to ask before redefining: a plain path whose block already exists. + pub fn ask_redefine(&self, s: &Session) -> bool { + !self.explicit && s.doc().is_ok_and(|d| d.block(&self.name).is_some()) + } + + /// Read the file and (re)define its block. Returns the block's name. + pub fn define(&self, s: &mut Session) -> Result { + let src = read_drawing(&self.path)?; + define_from(s, &src, &self.name) + } +} + +/// The file `t` names at the block name prompt: `name=path` (`name=` alone: the file `name`), or a +/// path (it has a folder separator or a .dwg/.dxf extension) that isn't the name of a block. +pub(super) fn file_ref(s: &Session, t: &str) -> Option { + let t = t.trim(); + if let Some((name, path)) = t.split_once('=') { + let name = name.trim(); + let path = if path.trim().is_empty() { name } else { path.trim() }; + return (!name.is_empty()).then(|| FileRef { name: name.to_string(), path: path.to_string(), explicit: true }); + } + let lower = t.to_ascii_lowercase(); + let pathlike = t.contains(['/', '\\']) || lower.ends_with(".dwg") || lower.ends_with(".dxf"); + if !pathlike || s.doc().is_ok_and(|d| d.block(t).is_some()) { + return None; + } + Some(FileRef { name: stem(t), path: t.to_string(), explicit: false }) +} + +/// The file name without folders and extension (either separator, so Windows paths work anywhere). +fn stem(path: &str) -> String { + let file = path.rsplit(['/', '\\']).next().unwrap_or(path); + match file.rsplit_once('.') { + Some((stem, _)) if !stem.is_empty() => stem.to_string(), + _ => file.to_string(), + } +} + +/// Read a drawing file through the io hooks; a path without extension tries .dwg, then .dxf. +fn read_drawing(path: &str) -> Result { + let hooks = io().ok_or_else(|| bad("insert", "file formats are not available in this build"))?; + let has_ext = stem(path) != path.rsplit(['/', '\\']).next().unwrap_or(path); + let candidates = if has_ext { vec![path.to_string()] } else { vec![path.to_string(), format!("{path}.dwg"), format!("{path}.dxf")] }; + let mut last = None; + for p in candidates { + match read_file(&p) { + Ok(bytes) => return (hooks.read)(&bytes, &p).map_err(|e| bad("insert", format!("{p}: {e}"))), + Err(e) => last = Some(e), + } + } + Err(last.unwrap_or_else(|| bad("insert", format!("{path}: file not found")))) +} + +fn read_file(_path: &str) -> Result> { + #[cfg(not(target_arch = "wasm32"))] + { + std::fs::read(_path).map_err(|e| bad("insert", format!("{_path}: {e}"))) + } + #[cfg(target_arch = "wasm32")] + Err(bad("insert", "paths are not available on the web")) +} + +/// (Re)define block `name` from `src`'s model space with base point `$INSBASE`, adding the +/// layers, linetypes, styles and blocks of `src` this drawing doesn't have. Nothing changes when +/// the definition fails (an invalid name, or a block that would contain itself). +fn define_from(s: &mut Session, src: &Drawing, name: &str) -> Result { + if !valid_block_name(name) { + return Err(bad("insert", format!("\"{name}\" is not a valid block name"))); + } + let mut d = s.doc()?.clone(); + // Redefining keeps the existing definition's name (block names are case-insensitive). + let name = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| name.to_string()); + merge_tables(&mut d, src); + let renames = block_names(&d, src, &name); + for (from, to) in &renames { + let Some(b) = src.blocks.get(from) else { continue }; + let mut nb = (**b).clone(); + nb.name = to.clone(); + nb.entities = Default::default(); + for e in b.entities.iter() { + let e = adopt(&mut d, e, &renames); + nb.entities.push(e); + } + d.blocks.insert(to.clone(), Arc::new(nb)); + } + let mut b = Block::new(&name); + b.base = src.header.point("INSBASE").filter(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()).unwrap_or_default(); + let mut ents = Vec::new(); + for e in src.model.iter() { + let e = adopt(&mut d, e, &renames); + d.ensure_layer(&e.common.layer); + ents.push(e); + } + if references_block(&d, &ents, &name) { + return Err(bad("insert", format!("Block \"{name}\" references itself."))); + } + for e in ents { + b.entities.push(e); + } + d.blocks.insert(name.clone(), Arc::new(b)); + *s.doc_mut()? = d; + Ok(name) +} + +/// Add the source's layers, linetypes and styles that this drawing doesn't have (a name that +/// exists keeps this drawing's definition). +fn merge_tables(d: &mut Drawing, src: &Drawing) { + fn merge(ours: &mut Vec, theirs: &[T], name: fn(&T) -> &str) { + for t in theirs { + if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) { + ours.push(t.clone()); + } + } + } + merge(&mut d.layers, &src.layers, |l| &l.name); + merge(&mut d.linetypes, &src.linetypes, |l| &l.name); + merge(&mut d.text_styles, &src.text_styles, |t| &t.name); + merge(&mut d.dim_styles, &src.dim_styles, |t| &t.name); + merge(&mut d.mleader_styles, &src.mleader_styles, |t| &t.name); + merge(&mut d.table_styles, &src.table_styles, |t| &t.name); +} + +/// Which source blocks to copy, and under which name: new names keep theirs, generated +/// (anonymous `*…`) blocks that collide get a fresh generated name, and a named block this drawing +/// already has (or the block being defined) keeps this drawing's definition (not copied). +fn block_names(d: &Drawing, src: &Drawing, defining: &str) -> BTreeMap { + let mut out = BTreeMap::new(); + let mut taken: BTreeSet = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect(); + taken.insert(defining.to_ascii_uppercase()); + for (name, b) in &src.blocks { + let upper = name.to_ascii_uppercase(); + if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") { + continue; + } + let to = if !taken.contains(&upper) { + name.clone() + } else if b.anonymous || name.starts_with('*') { + let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect(); + match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) { + Some(n) => n, + None => continue, + } + } else { + continue; + }; + taken.insert(to.to_ascii_uppercase()); + out.insert(name.clone(), to); + } + out +} + +/// A copy of a source entity for this drawing: a fresh handle, block references renamed, and +/// links to other source objects (associative dimension points) dropped. +fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap) -> Entity { + let mut e = e.clone(); + e.handle = d.new_handle(); + match &mut e.kind { + EntityKind::Insert(i) => { + if let Some(n) = renames.get(&i.block) { + i.block = n.clone(); + } + } + EntityKind::Dimension(dm) => { + dm.assoc.clear(); + if let Some(n) = dm.block.as_ref().and_then(|b| renames.get(b)) { + dm.block = Some(n.clone()); + } + } + _ => {} + } + e +} + +/// Replace block reference `h` by the objects of its block (an exploded insertion). +pub(super) fn explode_insert(s: &mut Session, h: Handle) -> Result> { + let space = s.space(); + let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; + let parts = explode_kind(s.doc()?, &e).unwrap_or_default(); + let d = s.doc_mut()?; + d.remove_entity(h); + let mut out = Vec::new(); + for mut p in parts { + p.handle = d.new_handle(); + out.push(p.handle); + if let Some(st) = d.space_mut(&space) { + st.push(p); + } + } + Ok(out) +} + +/// The JSON INSERT parameters with `file` defined as a block and `*name` as `explode: true`. +pub(super) fn json_params(s: &mut Session, p: &Value) -> Result { + let mut p = p.clone(); + let mut name = str_param(&p, "name").map(str::trim).map(str::to_string); + if let Some(n) = name.as_deref().and_then(|n| n.strip_prefix('*')) { + name = Some(n.trim().to_string()); + p["explode"] = json!(true); + } + if let Some(path) = str_param(&p, "file").map(str::to_string) { + let n = name.clone().filter(|n| !n.is_empty()).unwrap_or_else(|| stem(&path)); + name = Some(FileRef { name: n, path, explicit: true }.define(s)?); + } + if let Some(n) = name { + p["name"] = json!(n); + } + Ok(p) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_names_become_block_names() { + assert_eq!(stem("C:\\parts\\bolt.dxf"), "bolt"); + assert_eq!(stem("/parts/nut.v2.DWG"), "nut.v2"); + assert_eq!(stem("washer"), "washer"); + let s = Session::new(); + let f = file_ref(&s, "M8=parts/bolt.dwg").unwrap(); + assert_eq!((f.name.as_str(), f.path.as_str(), f.explicit), ("M8", "parts/bolt.dwg", true)); + let f = file_ref(&s, "bolt=").unwrap(); + assert_eq!((f.name.as_str(), f.path.as_str()), ("bolt", "bolt")); + assert_eq!(file_ref(&s, "C:\\parts\\bolt.dxf").map(|f| f.name), Some("bolt".into())); + assert!(file_ref(&s, "BOLT").is_none()); + } +} diff --git a/crates/engine/tests/insert_file.rs b/crates/engine/tests/insert_file.rs new file mode 100644 index 00000000..545d58d0 --- /dev/null +++ b/crates/engine/tests/insert_file.rs @@ -0,0 +1,152 @@ +//! INSERT of exploded blocks (`*NAME`) and of drawing files (`-INSERT path`, `NAME=path`, JSON +//! `file`), read through the io hooks (#340). + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::color::Color; +use cadcraft_engine::doc::geom::Vec3; +use cadcraft_engine::doc::{Block, Circle, Drawing, Entity, EntityKind, HVal, Handle, Insert, Layer, Line}; +use serde_json::json; + +/// 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 { + let text = String::from_utf8_lossy(bytes); + let (x, y) = text.trim().split_once(',').ok_or("bad test file")?; + let (x, y): (f64, f64) = (x.parse().map_err(|_| "x")?, y.parse().map_err(|_| "y")?); + let mut d = Drawing::new_imperial(); + d.header.set("INSBASE", HVal::Point(Vec3::new(x, y, 0.0))); + let mut parts = Layer::new("PARTS"); + parts.color = Color::Index(1); + d.layers.push(parts); + let mut nut = Block::new("NUT"); + nut.entities.push(Entity::new(Handle(0x50), EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 1.0 }))); + d.blocks.insert("NUT".into(), std::sync::Arc::new(nut)); + let mut line = Entity::new(Handle(0x60), EntityKind::Line(Line { a: Vec3::new(10.0, 10.0, 0.0), b: Vec3::new(20.0, 10.0, 0.0) })); + line.common.layer = "PARTS".into(); + d.model.push(line); + let ins = Insert { + block: "NUT".into(), + insert: Vec3::new(10.0, 10.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + d.model.push(Entity::new(Handle(0x61), EntityKind::Insert(ins))); + Ok(d) +} + +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; + s.doc() + .unwrap() + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Line(l) => Some((r(l.a.x), r(l.a.y), r(l.b.x), r(l.b.y))), + _ => None, + }) + .collect() +} + +/// Block references in model space as (block, x, y). +fn inserts(s: &Session) -> Vec<(String, f64, f64)> { + s.doc() + .unwrap() + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Insert(i) => Some((i.block.clone(), i.insert.x, i.insert.y)), + _ => None, + }) + .collect() +} + +#[test] +fn star_name_inserts_the_blocks_objects_with_one_scale_factor() { + let mut s = Session::new(); + s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap(); + s.execute("block", &json!({ "name": "B1", "base": [0, 0], "keep": "delete", "handles": ["100"] })).unwrap(); + + // Insertion point, then one scale factor and the rotation; no block reference is left. + s.script("-INSERT *B1 5,5 2 90\n").unwrap(); + assert!(s.running.is_none(), "INSERT finished: {}", s.prompt_text()); + assert!(inserts(&s).is_empty()); + assert_eq!(lines(&s), vec![(5.0, 5.0, 5.0, 25.0)]); + + // A plain name after an exploded one inserts a block reference again. + s.script("-INSERT B1 0,0 1 1 0\n").unwrap(); + assert_eq!(inserts(&s).len(), 1); + + // JSON: "*NAME" explodes too. + s.execute("insert", &json!({ "name": "*B1", "at": [0, 50] })).unwrap(); + assert_eq!(inserts(&s).len(), 1); + assert_eq!(lines(&s).last(), Some(&(0.0, 50.0, 10.0, 50.0))); +} + +#[test] +fn a_drawing_file_becomes_a_block_with_its_insbase_and_tables() { + hooks(); + let bolt = 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(); + + s.script(&format!("-INSERT {bolt} 100,100 1 1 0\n")).unwrap(); + assert!(s.running.is_none(), "INSERT finished: {}", s.prompt_text()); + let d = s.doc().unwrap(); + let b = d.block("bolt").expect("block named after the file"); + assert_eq!(b.base, Vec3::new(10.0, 10.0, 0.0), "$INSBASE is the base point"); + assert_eq!(b.entities.len(), 2); + assert!(d.block("NUT").is_some(), "nested blocks come along"); + assert_eq!(d.layer("PARTS").map(|l| l.color), Some(Color::Index(3)), "an existing layer keeps its definition"); + assert_eq!(inserts(&s), vec![("bolt".into(), 100.0, 100.0)]); + + // The file again: its block exists, so INSERT asks; No (the default) keeps the definition. + s.cmdline(&format!("-INSERT {bolt}")).unwrap(); + assert!(s.prompt_text().contains("Redefine"), "{}", s.prompt_text()); + s.script("N 0,0 1 1 0\n").unwrap(); + assert!(s.running.is_none(), "INSERT finished: {}", s.prompt_text()); + 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"); + 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()); + assert_eq!(inserts(&s).iter().filter(|i| i.0 == "M8").count(), 1); + // The exploded line, moved from the base point 10,10 to 50,0; the nested NUT stays a reference. + assert_eq!(lines(&s), vec![(50.0, 0.0, 60.0, 0.0)]); + 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(); + assert!(r["handle"].is_string(), "{r}"); + assert!(inserts(&s).iter().any(|i| i.0 == "washer")); + + // A file that can't be read is reported; nothing is inserted. + let n = inserts(&s).len(); + assert!(s.execute("insert", &json!({ "file": "/no/such/part.dxf", "at": [0, 0] })).is_err()); + s.cmdline("-INSERT /no/such/part.dxf").unwrap(); + assert!(s.log.iter().any(|m| m.contains("/no/such/part.dxf")), "{:?}", s.log.last()); + assert!(s.prompt_text().contains("block name"), "{}", s.prompt_text()); + assert_eq!(inserts(&s).len(), n); +} From 93152afa8e574426378284dc147ffd3256d9e0d2 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 23:32:34 +0200 Subject: [PATCH 112/150] Copy and paste between drawings; PASTEORIG, PASTEBLOCK; right-click menu (part of #325) (#355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copying in one drawing and pasting in another lost what the objects refer to: a line on a layer the target lacked landed on a new default layer (colour and linetype gone), and a block reference pointed at a block the target didn't have. Cmd/Ctrl+C, X and V did nothing on the desktop, because egui delivers them as clipboard events, not key presses. PASTEORIG had no checks, and the canvas had no right-click menu. - Engine (cmd/clipboard.rs): the session clipboard keeps, with the copied objects, the layers, linetypes, text/dimension/multileader/ table styles and blocks (nested too) they use. Pasting adds the ones the target lacks; names it has keep the target's definitions, as in AutoCAD; generated (*U, *D) blocks whose name is taken get a fresh one; objects get new handles. - PASTEORIG pastes at the original coordinates, only into a drawing other than the one copied from (greyed and refused there, as in AutoCAD). PASTECLIP and PASTEBLOCK are greyed with an empty clipboard. - PASTEBLOCK (Edit > Paste as Block, Cmd+Shift+V): the clipboard becomes an A$C… block inserted at a point. AI_DESELECT. - UI: Cut/Copy/Paste events run the clipboard commands (Shift: Copy with Base Point / Paste as Block). Each copy is also put on the system clipboard as DXF text, and pasted DXF text that isn't our own last copy becomes the clipboard, so another CADCraft window can paste it. - Right-click on the canvas with no command running opens a shortcut menu: Repeat , Clipboard (Cut, Copy, Copy with Base Point, Paste, Paste as Block, Paste to Original Coordinates), the selection's editing commands and Deselect All, Undo/Redo, Pan/Zoom, Quick Select, Find. During a command it is still Enter. - Ukrainian labels for the new items. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/clipboard.rs | 355 +++++++++++++++++++++++++++ crates/engine/src/cmd/edit.rs | 82 +++++-- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/lib.rs | 3 + crates/engine/src/tests.rs | 5 + crates/engine/tests/clipboard.rs | 214 ++++++++++++++++ crates/ui-egui/src/canvas.rs | 17 +- crates/ui-egui/src/clipboard.rs | 66 +++++ crates/ui-egui/src/context_menu.rs | 69 ++++++ crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 9 +- crates/ui-egui/tests/context_menu.rs | 96 ++++++++ docs/ui-parity.md | 7 +- 14 files changed, 891 insertions(+), 40 deletions(-) create mode 100644 crates/engine/src/cmd/clipboard.rs create mode 100644 crates/engine/tests/clipboard.rs create mode 100644 crates/ui-egui/src/clipboard.rs create mode 100644 crates/ui-egui/src/context_menu.rs create mode 100644 crates/ui-egui/tests/context_menu.rs diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs new file mode 100644 index 00000000..eb188bb7 --- /dev/null +++ b/crates/engine/src/cmd/clipboard.rs @@ -0,0 +1,355 @@ +//! The clipboard every open drawing shares (COPYCLIP, CUTCLIP, COPYBASE; PASTECLIP, PASTEORIG, +//! PASTEBLOCK). +//! +//! Like AutoCAD's, it holds the copied objects together with what they refer to in their drawing: +//! layers, linetypes, text, dimension, multileader and table styles, and the blocks they reference +//! (nested ones too). Pasting adds the ones the target drawing doesn't have; a name the target +//! already has keeps the target's definition, and a generated (anonymous `*…`) block whose name is +//! taken gets a fresh one. Pasted objects get new handles. +//! +//! The clipboard can also travel as DXF text ([`system_text`], [`load_system_text`]), so a front +//! end can put it on the system clipboard and paste it into another CADCraft window. + +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::sync::Arc; + +use cadcraft_doc::{Block, Drawing, Entity, EntityKind, HVal, Handle}; +use cadcraft_geom::{Mat3, Vec2}; + +use super::{bad, has_doc}; +use crate::{Result, Session}; + +/// More objects than this aren't mirrored to (or read from) the system clipboard. +const MAX_SYSTEM_OBJECTS: usize = 50_000; +/// Bounds the walk through nested block references. +const MAX_WALK: usize = 5_000_000; + +/// Where the clipboard's objects came from, and the definitions they need. +#[derive(Clone, Debug)] +pub struct ClipSource { + /// `DocState::uid` of the drawing they were copied from; `None` when they came from the system + /// clipboard. + pub uid: Option, + /// The layers, linetypes, styles and blocks the objects reference (and the source's header). + pub defs: Arc, + /// Counts copies, so a front end mirrors each new clipboard to the system clipboard once. + pub serial: u64, +} + +/// Put copies of `ents` from the active drawing on the clipboard, with `base` as base point. +pub(crate) fn copy(s: &mut Session, ents: Vec, base: Vec2) -> Result<()> { + let uid = s.state()?.uid; + let defs = defs_for(s.doc()?, &ents); + set(s, ents, base, defs, Some(uid)); + Ok(()) +} + +fn set(s: &mut Session, ents: Vec, base: Vec2, defs: Drawing, uid: Option) { + let serial = s.clipboard_source.as_ref().map_or(0, |c| c.serial).wrapping_add(1); + s.clipboard = ents; + s.clipboard_base = base; + s.clipboard_source = Some(ClipSource { uid, defs: Arc::new(defs), serial }); +} + +/// Enabled check of PASTECLIP and PASTEBLOCK: something to paste. +pub(crate) fn has_clip(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.clipboard.is_empty() { Err("the clipboard is empty".into()) } else { Ok(()) } +} + +/// Enabled check of PASTEORIG: as in AutoCAD, it pastes objects copied from another drawing. +pub(crate) fn can_paste_orig(s: &Session) -> std::result::Result<(), String> { + has_clip(s)?; + if from_active(s) { + return Err("the clipboard holds objects from this drawing; Paste to Original Coordinates pastes into another drawing".into()); + } + Ok(()) +} + +/// Whether the clipboard was copied from the active drawing. +pub fn from_active(s: &Session) -> bool { + let active = s.state().ok().map(|st| st.uid); + s.clipboard_source.as_ref().is_some_and(|c| c.uid.is_some() && c.uid == active) +} + +/// Paste the clipboard into the active drawing's current space, moved by `m`; selects and +/// returns the new objects. +pub(crate) fn paste(s: &mut Session, m: &Mat3) -> Result> { + let ents = s.clipboard.clone(); + let defs = s.clipboard_source.as_ref().map(|c| c.defs.clone()); + let space = s.space(); + let d = s.doc_mut()?; + let renames = defs.map(|x| merge(d, &x)).unwrap_or_default(); + let mut out = Vec::new(); + for e in &ents { + let mut e = adopt(d, e, &renames); + e.kind.transform(m); + d.ensure_layer(&e.common.layer); + out.push(e.handle); + if let Some(st) = d.space_mut(&space) { + st.push(e); + } + } + s.set_selection(out.clone()); + Ok(out) +} + +/// PASTEBLOCK: the clipboard becomes a new block (named `A$C…`, base point = the clipboard's), and +/// a reference to it is inserted at `at`. Returns the block reference. +pub(crate) fn paste_block(s: &mut Session, at: Vec2) -> Result { + let ents = s.clipboard.clone(); + if ents.is_empty() { + return Err(bad("pasteblock", "the clipboard is empty")); + } + let defs = s.clipboard_source.as_ref().map(|c| c.defs.clone()); + let base = s.clipboard_base; + let d = s.doc_mut()?; + let renames = defs.map(|x| merge(d, &x)).unwrap_or_default(); + let seed = d.handseed; + let name = (0..1_000_000u64) + .map(|i| format!("A$C{:08X}", seed.wrapping_add(i) & 0xFFFF_FFFF)) + .find(|n| d.block(n).is_none()) + .ok_or_else(|| bad("pasteblock", "no free block name"))?; + let mut b = Block::new(&name); + b.base = base.to3(0.0); + for e in &ents { + let e = adopt(d, e, &renames); + d.ensure_layer(&e.common.layer); + b.entities.push(e); + } + d.blocks.insert(name.clone(), Arc::new(b)); + let h = super::blocks::insert(s, &name, at, 1.0, 0.0, &serde_json::Map::new())?; + s.set_selection(vec![h]); + Ok(h) +} + +/// The clipboard as DXF text (base point in `$INSBASE`), for the system clipboard. `None` when +/// it is empty, very large, or this build has no DXF writer. +pub fn system_text(s: &Session) -> Option { + if s.clipboard.is_empty() || s.clipboard.len() > MAX_SYSTEM_OBJECTS { + return None; + } + let hooks = super::file::io()?; + let mut d = Drawing::new_imperial(); + let renames = match &s.clipboard_source { + Some(c) => { + d.header = c.defs.header.clone(); + merge(&mut d, &c.defs) + } + None => BTreeMap::new(), + }; + d.header.set("INSBASE", HVal::Point(s.clipboard_base.to3(0.0))); + for e in &s.clipboard { + let e = adopt(&mut d, e, &renames); + d.ensure_layer(&e.common.layer); + d.model.push(e); + } + let bytes = (hooks.write)(&d, "clipboard.dxf").ok()?; + String::from_utf8(bytes).ok() +} + +/// Make DXF text (from the system clipboard) the clipboard: its model space objects with what +/// they reference, the base point from `$INSBASE`. `false` (clipboard unchanged) when the text +/// isn't a DXF drawing with objects. +pub fn load_system_text(s: &mut Session, text: &str) -> bool { + let mut lines = text.lines().map(str::trim); + if lines.next() != Some("0") || lines.next() != Some("SECTION") || text.len() > 256 << 20 { + return false; + } + let Some(hooks) = super::file::io() else { return false }; + let Ok(src) = (hooks.read)(text.as_bytes(), "clipboard.dxf") else { return false }; + if src.model.is_empty() || src.model.len() > MAX_SYSTEM_OBJECTS { + return false; + } + let ents: Vec = src.model.iter().map(|e| (**e).clone()).collect(); + let base = src.header.point("INSBASE").map(|p| p.xy()).filter(|p| p.is_finite()).unwrap_or(Vec2::ZERO); + let defs = defs_for(&src, &ents); + set(s, ents, base, defs, None); + true +} + +/// Upper-case names of the table records and blocks some objects use. +#[derive(Default)] +struct Used { + layers: HashSet, + linetypes: HashSet, + text_styles: HashSet, + dim_styles: HashSet, + mleader_styles: HashSet, + table_styles: HashSet, + blocks: HashSet, +} + +fn up(n: &str) -> String { + n.to_ascii_uppercase() +} + +impl Used { + /// Note what `e` uses; returns the blocks it references. + fn note(&mut self, e: &Entity) -> Vec { + self.layers.insert(up(&e.common.layer)); + self.linetypes.insert(up(&e.common.linetype)); + let mut blocks = Vec::new(); + match &e.kind { + EntityKind::Text(t) => { + self.text_styles.insert(up(&t.style)); + } + EntityKind::MText(t) => { + self.text_styles.insert(up(&t.style)); + } + EntityKind::AttDef(a) => { + self.text_styles.insert(up(&a.text.style)); + } + EntityKind::Insert(i) => { + self.text_styles.extend(i.attribs.iter().map(|a| up(&a.text.style))); + blocks.push(i.block.clone()); + } + EntityKind::Dimension(dm) => { + self.dim_styles.insert(up(&dm.style)); + blocks.extend(dm.block.clone()); + } + EntityKind::Leader(l) => { + self.dim_styles.insert(up(&l.style)); + } + EntityKind::MLeader(m) => { + self.mleader_styles.insert(up(&m.style)); + self.text_styles.extend(m.text.iter().map(|t| up(&t.style))); + } + EntityKind::Table(t) => { + self.table_styles.insert(up(&t.style)); + } + _ => {} + } + blocks + } +} + +/// The definitions `ents` need from `src`: the layers, linetypes, styles and blocks they (and the +/// blocks they reference, nested ones too) use, and `src`'s header. +fn defs_for(src: &Drawing, ents: &[Entity]) -> Drawing { + let mut used = Used::default(); + let mut frontier: Vec = ents.iter().flat_map(|e| used.note(e)).collect(); + let mut walked = 0usize; + while let Some(name) = frontier.pop() { + if !used.blocks.insert(up(&name)) { + continue; + } + let Some(b) = src.block(&name) else { continue }; + for e in b.entities.iter() { + walked += 1; + if walked > MAX_WALK { + break; + } + frontier.extend(used.note(e)); + } + } + // Records referenced by other records. + for l in src.layers.iter().filter(|l| used.layers.contains(&up(&l.name))) { + used.linetypes.insert(up(&l.linetype)); + } + for t in src.linetypes.iter().filter(|t| used.linetypes.contains(&up(&t.name))) { + used.text_styles.extend(t.pattern.iter().filter_map(|p| p.style.as_deref()).map(up)); + } + for ds in src.dim_styles.iter().filter(|t| used.dim_styles.contains(&up(&t.name))) { + used.text_styles.insert(up(&ds.text_style)); + } + for ms in src.mleader_styles.iter().filter(|t| used.mleader_styles.contains(&up(&t.name))) { + used.text_styles.insert(up(&ms.text_style)); + } + fn pick(all: &[T], used: &HashSet, name: fn(&T) -> &str) -> Vec { + all.iter().filter(|t| used.contains(&up(name(t)))).cloned().collect() + } + let mut d = Drawing::new_imperial(); + d.header = src.header.clone(); + d.layers = pick(&src.layers, &used.layers, |l| &l.name); + d.linetypes = pick(&src.linetypes, &used.linetypes, |l| &l.name); + d.text_styles = pick(&src.text_styles, &used.text_styles, |t| &t.name); + d.dim_styles = pick(&src.dim_styles, &used.dim_styles, |t| &t.name); + d.mleader_styles = pick(&src.mleader_styles, &used.mleader_styles, |t| &t.name); + d.table_styles = pick(&src.table_styles, &used.table_styles, |t| &t.name); + d.blocks = src.blocks.iter().filter(|(k, _)| used.blocks.contains(&up(k))).map(|(k, b)| (k.clone(), b.clone())).collect(); + d.layouts.clear(); + d +} + +/// Add `defs`' table records and blocks that `d` doesn't have. Returns the blocks copied, from +/// their name in `defs` to their name in `d`. +fn merge(d: &mut Drawing, defs: &Drawing) -> BTreeMap { + fn add(ours: &mut Vec, theirs: &[T], name: fn(&T) -> &str) { + for t in theirs { + if !ours.iter().any(|o| name(o).eq_ignore_ascii_case(name(t))) { + ours.push(t.clone()); + } + } + } + add(&mut d.layers, &defs.layers, |l| &l.name); + add(&mut d.linetypes, &defs.linetypes, |l| &l.name); + add(&mut d.text_styles, &defs.text_styles, |t| &t.name); + add(&mut d.dim_styles, &defs.dim_styles, |t| &t.name); + add(&mut d.mleader_styles, &defs.mleader_styles, |t| &t.name); + add(&mut d.table_styles, &defs.table_styles, |t| &t.name); + let renames = block_names(d, defs); + for (from, to) in &renames { + let Some(b) = defs.blocks.get(from) else { continue }; + let mut nb = (**b).clone(); + nb.name = to.clone(); + nb.entities = Default::default(); + for e in b.entities.iter() { + let e = adopt(d, e, &renames); + d.ensure_layer(&e.common.layer); + nb.entities.push(e); + } + d.blocks.insert(to.clone(), Arc::new(nb)); + } + renames +} + +/// Which blocks of `defs` to copy, and under which name: a new name keeps its own, a generated +/// (anonymous `*…`) block whose name is taken gets a fresh generated name, and a named block `d` +/// already has keeps `d`'s definition (not copied). +fn block_names(d: &Drawing, defs: &Drawing) -> BTreeMap { + let mut out = BTreeMap::new(); + let mut taken: BTreeSet = d.blocks.keys().map(|k| up(k)).collect(); + for (name, b) in &defs.blocks { + let upper = up(name); + if upper.starts_with("*MODEL_SPACE") || upper.starts_with("*PAPER_SPACE") { + continue; + } + let to = if !taken.contains(&upper) { + name.clone() + } else if b.anonymous || name.starts_with('*') { + let prefix: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect(); + match (1..=1_000_000).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&up(n))) { + Some(n) => n, + None => continue, + } + } else { + continue; + }; + taken.insert(up(&to)); + out.insert(name.clone(), to); + } + out +} + +/// A copy of a clipboard object for `d`: a fresh handle, block references renamed, and links to +/// objects of the source drawing (associative dimension points) dropped. +fn adopt(d: &mut Drawing, e: &Entity, renames: &BTreeMap) -> Entity { + let mut e = e.clone(); + e.handle = d.new_handle(); + match &mut e.kind { + EntityKind::Insert(i) => { + if let Some(n) = renames.get(&i.block) { + i.block = n.clone(); + } + } + EntityKind::Dimension(dm) => { + dm.assoc.clear(); + if let Some(n) = dm.block.as_ref().and_then(|b| renames.get(b)) { + dm.block = Some(n.clone()); + } + } + _ => {} + } + e +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 996bc4d7..709421ae 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -34,11 +34,23 @@ pub fn specs() -> Vec { CommandSpec::new("pasteclip", "Paste", run_paste) .menu(&["Edit", "Paste"]) .key("Cmd+V") - .params("{at?: [x,y]} (default: same offset as copied)") - .interactive(|_| Ok(Box::new(PasteM { orig: false }))), - CommandSpec::new("pasteorig", "Paste to Original Coordinates", run_pasteorig).menu(&["Edit", "Paste to Original Coordinates"]).params("{}"), + .params("{at?: [x,y]} (where the base point lands; default: the original coordinates)") + .enabled(clipboard::has_clip) + .interactive(|_| Ok(Box::new(PasteM { mode: PasteMode::Clip }))), + CommandSpec::new("pasteblock", "Paste as Block", run_pasteblock) + .menu(&["Edit", "Paste as Block"]) + .key("Cmd+Shift+V") + .params("{at?: [x,y]} (insertion point; default: the original coordinates)") + .enabled(clipboard::has_clip) + .interactive(|_| Ok(Box::new(PasteM { mode: PasteMode::Block }))), + CommandSpec::new("pasteorig", "Paste to Original Coordinates", run_pasteorig) + .menu(&["Edit", "Paste to Original Coordinates"]) + .params("{} (only into a drawing other than the one copied from)") + .enabled(clipboard::can_paste_orig) + .interactive(|_| Ok(Box::new(PasteM { mode: PasteMode::Orig }))), CommandSpec::new("erase.selection", "Clear", run_clear).menu(&["Edit", "Clear"]).key("Delete").enabled(has_selection).params("{}"), CommandSpec::new("selectall", "Select All", run_selectall).menu(&["Edit", "Select All"]).key("Cmd+A").alias(&["ai_selall"]).noundo(), + CommandSpec::new("ai_deselect", "Deselect All", run_deselect).params("{}").noundo(), CommandSpec::new("select", "Select", run_select) .params("{handles: [hex]} | {window: [[x,y],[x,y]], crossing?: bool} | {at: [x,y]} | {clear: true} | {add?: bool}") .noundo() @@ -145,9 +157,9 @@ fn copy_to_clip(s: &mut Session, hs: &[Handle], base: Option) -> Result = hs.iter().filter_map(|h| d.entity(*h).map(|e| (**e).clone())).collect(); let b = ents.iter().fold(Bounds2::EMPTY, |acc, e| acc.union(&entity_bounds(d, e, 0))); - s.clipboard_base = base.unwrap_or(b.min); + let base = base.unwrap_or(if b.min.is_finite() { b.min } else { Vec2::ZERO }); let n = ents.len(); - s.clipboard = ents; + clipboard::copy(s, ents, base)?; Ok(n) } @@ -176,23 +188,10 @@ fn run_cut(s: &mut Session, p: &Value) -> Result { Ok(json!({ "cut": n })) } -pub(crate) fn paste(s: &mut Session, offset: Vec2) -> Result> { - let clip = s.clipboard.clone(); - let space = s.space(); - let m = Mat3::translate(offset); - let d = s.doc_mut()?; - let mut out = Vec::new(); - for mut e in clip { - e.handle = d.new_handle(); - e.kind.transform(&m); - d.ensure_layer(&e.common.layer); - out.push(e.handle); - if let Some(st) = d.space_mut(&space) { - st.push(e); - } - } - s.set_selection(out.clone()); - Ok(out) +/// Paste the clipboard moved by `offset` (the clipboard brings the layers, styles and blocks its +/// objects need). +fn paste(s: &mut Session, offset: Vec2) -> Result> { + clipboard::paste(s, &Mat3::translate(offset)) } fn run_paste(s: &mut Session, p: &Value) -> Result { @@ -207,6 +206,16 @@ fn run_paste(s: &mut Session, p: &Value) -> Result { Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) } +fn run_pasteblock(s: &mut Session, p: &Value) -> Result { + let at = point_param(p, "at").unwrap_or(s.clipboard_base); + let h = clipboard::paste_block(s, at)?; + let block = match s.doc()?.entity(h).map(|e| &e.kind) { + Some(cadcraft_doc::EntityKind::Insert(i)) => i.block.clone(), + _ => String::new(), + }; + Ok(json!({ "handle": h.hex(), "block": block })) +} + fn run_pasteorig(s: &mut Session, _p: &Value) -> Result { let r = paste(s, Vec2::ZERO)?; Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) @@ -238,6 +247,11 @@ fn run_selectall(s: &mut Session, _p: &Value) -> Result { Ok(json!({ "selected": n })) } +fn run_deselect(s: &mut Session, _p: &Value) -> Result { + s.set_selection(Vec::new()); + Ok(json!({ "selected": 0 })) +} + fn run_select(s: &mut Session, p: &Value) -> Result { if bool_or(p, "clear", false) { s.set_selection(Vec::new()); @@ -263,20 +277,34 @@ fn run_select(s: &mut Session, p: &Value) -> Result { Ok(json!({ "selected": sel.len(), "handles": sel.iter().map(|h| h.hex()).collect::>() })) } +#[derive(Clone, Copy, PartialEq)] +enum PasteMode { + /// PASTECLIP: at an insertion point. + Clip, + /// PASTEBLOCK: as a block, at an insertion point. + Block, + /// PASTEORIG: at the original coordinates, without asking. + Orig, +} + struct PasteM { - orig: bool, + mode: PasteMode, } impl Interactive for PasteM { fn name(&self) -> &'static str { - "PASTECLIP" + match self.mode { + PasteMode::Clip => "PASTECLIP", + PasteMode::Block => "PASTEBLOCK", + PasteMode::Orig => "PASTEORIG", + } } fn begin(&mut self, s: &mut Session) -> Result { if s.clipboard.is_empty() { s.echo("Clipboard is empty."); return Ok(Step::Done); } - if self.orig { + if self.mode == PasteMode::Orig { paste(s, Vec2::ZERO)?; return Ok(Step::Done); } @@ -287,6 +315,10 @@ impl Interactive for PasteM { } fn input(&mut self, s: &mut Session, i: Input) -> Result { match i { + Input::Point(p) if self.mode == PasteMode::Block => { + clipboard::paste_block(s, p)?; + Ok(Step::Done) + } Input::Point(p) => { let base = s.clipboard_base; paste(s, p - base)?; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 174d5c6b..578b56b5 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -4,6 +4,7 @@ mod annotate; mod array; mod blocks; +pub mod clipboard; pub mod constraints; mod draw; mod draw2; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a68f468a..00c65223 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -350,6 +350,8 @@ pub struct Session { pub last_dim: Option, /// Options remembered between invocations of interactive commands. pub last_used: LastUsed, + /// Where `clipboard` came from and the layers, styles and blocks its objects need. + pub clipboard_source: Option, } impl Default for Session { @@ -383,6 +385,7 @@ impl Session { untitled_counter: 0, last_dim: None, last_used: LastUsed::default(), + clipboard_source: None, } } pub fn new_drawing(&mut self, metric: bool) -> usize { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 694cb4be..bc2f2914 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -571,6 +571,11 @@ fn hostile_params_never_panic() { fn every_interactive_command_starts_and_cancels() { for c in command_specs().iter().filter(|c| c.interactive.is_some()) { let mut s = Session::new(); + if (c.enabled)(&s).is_err() { + // Unavailable in a new drawing (e.g. pasting with an empty clipboard): refused. + assert!(s.start(c.id).is_err() && s.running.is_none(), "{}", c.id); + continue; + } s.start(c.id).unwrap(); let _ = s.prompt_text(); let _ = s.preview(Vec2::new(1.0, 1.0)); diff --git a/crates/engine/tests/clipboard.rs b/crates/engine/tests/clipboard.rs new file mode 100644 index 00000000..03ec4084 --- /dev/null +++ b/crates/engine/tests/clipboard.rs @@ -0,0 +1,214 @@ +//! The clipboard between open drawings (#325): pasted objects bring the layers and blocks they +//! use, PASTEORIG pastes into another drawing at the same coordinates, and the clipboard can +//! travel as text through the system clipboard. + +use std::sync::{Arc, Mutex}; + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::clipboard; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::color::Color; +use cadcraft_engine::doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Insert, Layer, Line}; +use cadcraft_engine::geom::{Vec2, Vec3}; +use serde_json::json; + +fn line(d: &mut Drawing, layer: &str, a: (f64, f64), b: (f64, f64)) -> Entity { + let kind = EntityKind::Line(Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) }); + Entity { handle: d.new_handle(), common: Common { layer: layer.into(), ..Common::default() }, kind } +} + +fn insert(d: &mut Drawing, block: &str, at: (f64, f64)) -> Entity { + let kind = EntityKind::Insert(Insert { + block: block.into(), + insert: Vec3::new(at.0, at.1, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }); + Entity { handle: d.new_handle(), common: Common::default(), kind } +} + +fn block(d: &mut Drawing, name: &str, ents: Vec) { + let mut b = Block::new(name); + b.anonymous = name.starts_with('*'); + for e in ents { + b.entities.push(e); + } + d.blocks.insert(name.into(), Arc::new(b)); +} + +/// Drawing A: a line on WALLS and a DOOR block reference; DOOR holds a line on HINGE and a +/// reference to the generated block *U1. +fn drawing_a() -> Drawing { + let mut d = Drawing::new_metric(); + d.layers.push(Layer { color: Color::Index(1), ..Layer::new("WALLS") }); + d.layers.push(Layer { color: Color::Index(5), ..Layer::new("HINGE") }); + let u = line(&mut d, "0", (0.0, 0.0), (1.0, 1.0)); + block(&mut d, "*U1", vec![u]); + let leaf = line(&mut d, "HINGE", (0.0, 0.0), (0.0, 2.0)); + let nested = insert(&mut d, "*U1", (0.0, 0.0)); + block(&mut d, "DOOR", vec![leaf, nested]); + let wall = line(&mut d, "WALLS", (10.0, 20.0), (30.0, 20.0)); + let door = insert(&mut d, "DOOR", (15.0, 20.0)); + d.model.push(wall); + d.model.push(door); + d +} + +/// Drawing B already has a WALLS layer (green) and a different *U1. +fn drawing_b() -> Drawing { + let mut d = Drawing::new_metric(); + d.layers.push(Layer { color: Color::Index(3), ..Layer::new("WALLS") }); + let other = line(&mut d, "0", (5.0, 5.0), (6.0, 6.0)); + block(&mut d, "*U1", vec![other]); + d +} + +fn handles(s: &Session) -> Vec { + s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect() +} + +fn model(s: &Session) -> Vec { + s.doc().unwrap().model.iter().map(|e| (**e).clone()).collect() +} + +#[test] +fn paste_into_another_drawing_brings_layers_and_blocks() { + let mut s = Session::empty(); + let a = s.open_drawing(drawing_a(), "A", None); + let b = s.open_drawing(drawing_b(), "B", None); + s.active = a; + s.execute("copyclip", &json!({ "handles": handles(&s) })).unwrap(); + assert_eq!(s.clipboard.len(), 2); + // The base point is the lower-left corner of the copied objects (the wall starts at 10,20). + assert_eq!(s.clipboard_base, Vec2::new(10.0, 20.0)); + + s.active = b; + let before: Vec = s.doc().unwrap().model.iter().map(|e| e.handle).collect(); + let r = s.execute("pasteclip", &json!({ "at": [110, 20] })).unwrap(); + assert_eq!(r["handles"].as_array().map(Vec::len), Some(2)); + let d = s.doc().unwrap(); + // HINGE comes from A; WALLS keeps B's definition. + assert_eq!(d.layer("HINGE").map(|l| l.color), Some(Color::Index(5))); + assert_eq!(d.layer("WALLS").map(|l| l.color), Some(Color::Index(3))); + // DOOR comes along, its nested *U1 under a fresh name because B has a *U1 of its own. + let door = d.block("DOOR").expect("DOOR copied"); + let nested: Vec = + door.entities.iter().filter_map(|e| if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None }).collect(); + assert_eq!(nested, ["*U2"]); + assert_eq!(d.block("*U2").map(|b| b.entities.len()), Some(1)); + let theirs = d.block("*U1").and_then(|b| b.entities.iter().next().cloned()).unwrap(); + assert!(matches!(&theirs.kind, EntityKind::Line(l) if l.a.x == 5.0), "B's *U1 is unchanged"); + // The objects moved by 100 in X, with new handles. + let pasted = model(&s); + assert_eq!(pasted.len(), 2); + assert!(pasted.iter().all(|e| !before.contains(&e.handle))); + assert!(matches!(&pasted[0].kind, EntityKind::Line(l) if l.a.x == 110.0 && l.a.y == 20.0)); + assert_eq!(pasted[0].common.layer, "WALLS"); + assert!(matches!(&pasted[1].kind, EntityKind::Insert(i) if i.block == "DOOR" && i.insert.x == 115.0)); + assert_eq!(s.selection().len(), 2, "the pasted objects are selected"); + + // Undo removes the paste. + s.execute("u", &json!({})).unwrap(); + assert!(s.doc().unwrap().model.is_empty()); +} + +#[test] +fn pasteorig_pastes_only_into_another_drawing() { + let mut s = Session::empty(); + let a = s.open_drawing(drawing_a(), "A", None); + let b = s.open_drawing(drawing_b(), "B", None); + s.active = a; + s.execute("copyclip", &json!({ "handles": handles(&s) })).unwrap(); + + // In the drawing it came from, PASTEORIG is unavailable (greyed in menus). + assert!(clipboard::from_active(&s)); + assert!(s.execute("pasteorig", &json!({})).is_err()); + assert!(s.start("pasteorig").is_err()); + assert_eq!(s.doc().unwrap().model.len(), 2); + let info = cadcraft_engine::find_command("pasteorig").unwrap().info(&s); + assert!(!info.enabled); + + // In another drawing it pastes at the same coordinates without asking. + s.active = b; + s.start("pasteorig").unwrap(); + assert!(s.running.is_none()); + let pasted = model(&s); + assert!(matches!(&pasted[0].kind, EntityKind::Line(l) if l.a.x == 10.0 && l.b.x == 30.0 && l.a.y == 20.0)); + assert!(s.doc().unwrap().block("DOOR").is_some()); + + // PASTECLIP asks for the insertion point and places the base point there. + s.start("pasteclip").unwrap(); + assert!(s.prompt_text().contains("insertion point"), "{}", s.prompt_text()); + s.input(cadcraft_engine::Input::Point(Vec2::new(0.0, 0.0))).unwrap(); + assert!(s.running.is_none()); + let pasted = model(&s); + assert_eq!(pasted.len(), 4); + assert!(matches!(&pasted[2].kind, EntityKind::Line(l) if l.a.x == 0.0 && l.a.y == 0.0)); +} + +#[test] +fn pasteblock_makes_a_block_of_the_clipboard() { + let mut s = Session::empty(); + let a = s.open_drawing(drawing_a(), "A", None); + let b = s.open_drawing(drawing_b(), "B", None); + s.active = a; + s.execute("copyclip", &json!({ "handles": handles(&s) })).unwrap(); + s.active = b; + let r = s.execute("pasteblock", &json!({ "at": [0, 0] })).unwrap(); + let name = r["block"].as_str().unwrap().to_string(); + assert!(name.starts_with("A$C"), "{name}"); + let d = s.doc().unwrap(); + assert_eq!(d.model.len(), 1); + let blk = d.block(&name).unwrap(); + assert_eq!(blk.entities.len(), 2); + assert_eq!(blk.base, Vec3::new(10.0, 20.0, 0.0), "the clipboard's base point"); + assert!(d.block("DOOR").is_some() && d.layer("HINGE").is_some()); +} + +/// A stand-in for the DXF writer and reader: "writes" the drawing to memory. +static WRITTEN: Mutex> = Mutex::new(None); + +#[test] +fn clipboard_travels_as_text() { + set_io(IoHooks { + read: |bytes, _| { + if bytes.starts_with(b"0\nSECTION") { + WRITTEN.lock().unwrap().clone().ok_or_else(|| "nothing written".into()) + } else { + Err("not DXF".into()) + } + }, + write: |d, name| { + assert!(name.ends_with(".dxf")); + *WRITTEN.lock().unwrap() = Some(d.clone()); + Ok(b"0\nSECTION\n2\nHEADER\n0\nENDSEC\n0\nEOF\n".to_vec()) + }, + plot: None, + }); + let mut s = Session::empty(); + let a = s.open_drawing(drawing_a(), "A", None); + s.execute("copybase", &json!({ "handles": handles(&s), "base": [10, 0] })).unwrap(); + let text = clipboard::system_text(&s).expect("DXF text"); + let written = WRITTEN.lock().unwrap().clone().unwrap(); + assert_eq!(written.model.len(), 2); + assert!(written.block("DOOR").is_some() && written.layer("HINGE").is_some()); + assert_eq!(written.header.point("INSBASE"), Some(Vec3::new(10.0, 0.0, 0.0))); + + // Another CADCraft (here: a fresh session) pastes it, with its layers and blocks. + let mut t = Session::empty(); + t.open_drawing(drawing_b(), "B", None); + assert!(!clipboard::load_system_text(&mut t, "just some words")); + assert!(t.clipboard.is_empty()); + assert!(clipboard::load_system_text(&mut t, &text)); + assert_eq!(t.clipboard.len(), 2); + assert_eq!(t.clipboard_base, Vec2::new(10.0, 0.0)); + t.execute("pasteorig", &json!({})).unwrap(); + assert_eq!(t.doc().unwrap().model.len(), 2); + assert!(t.doc().unwrap().block("DOOR").is_some() && t.doc().unwrap().layer("HINGE").is_some()); + let _ = a; +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 65f5ecbb..c5e54e84 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -695,15 +695,14 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { } } } - if inside && pressed_secondary { - // Right-click acts as Enter while a command runs (the classic CAD default). - if app.session.running.is_some() { - let _ = app.session.input(Input::Enter); - } else if !app.session.selection().is_empty() { - app.session.set_selection(Vec::new()); - } else if let Some(last) = app.session.last_command.clone() { - app.start(&last); - } + // Right-click acts as Enter while a command runs (the classic CAD default); otherwise it opens + // the shortcut menu (Repeat, Clipboard, …). + let idle = app.session.running.is_none(); + if inside && pressed_secondary && !idle { + let _ = app.session.input(Input::Enter); + } + if idle { + crate::context_menu::show(app, &resp); } // Hover highlight (throttled to cursor movement). diff --git a/crates/ui-egui/src/clipboard.rs b/crates/ui-egui/src/clipboard.rs new file mode 100644 index 00000000..af612aba --- /dev/null +++ b/crates/ui-egui/src/clipboard.rs @@ -0,0 +1,66 @@ +//! The system clipboard: Cut/Copy/Paste key presses, and the drawing clipboard mirrored as DXF +//! text so it can be pasted into another CADCraft window. +//! +//! egui turns Cmd/Ctrl+C, X and V into `Copy`, `Cut` and `Paste(text)` events instead of key +//! presses, so the clipboard shortcuts are read from those. A `Paste` arrives only when the system +//! clipboard holds text; every copy therefore puts its DXF text there. + +use std::hash::{DefaultHasher, Hash, Hasher}; + +use cadcraft_engine::cmd::clipboard; + +use crate::CadApp; + +/// egui memory: the clipboard copy last mirrored, and the hash of the text it became. +fn serial_id() -> egui::Id { + egui::Id::new("cc_clipboard_serial") +} +fn text_id() -> egui::Id { + egui::Id::new("cc_clipboard_text") +} + +/// Line endings normalised (pasted text arrives with `\n`), so our own text is recognised. +fn hash(text: &str) -> u64 { + let mut h = DefaultHasher::new(); + text.replace("\r\n", "\n").hash(&mut h); + h.finish() +} + +/// Put a new copy (COPYCLIP, CUTCLIP, COPYBASE from anywhere) on the system clipboard, once. +pub fn mirror(app: &CadApp, ctx: &egui::Context) { + let Some(src) = &app.session.clipboard_source else { return }; + if src.uid.is_none() || ctx.data(|d| d.get_temp::(serial_id())) == Some(src.serial) { + return; + } + ctx.data_mut(|d| d.insert_temp(serial_id(), src.serial)); + if let Some(text) = clipboard::system_text(&app.session) { + ctx.data_mut(|d| d.insert_temp(text_id(), hash(&text))); + ctx.copy_text(text); + } +} + +/// The commands the Cut/Copy/Paste events of this frame ask for. Pasted DXF text that isn't our +/// own last copy (another CADCraft's) becomes the clipboard first. +pub fn shortcut_commands(app: &mut CadApp, ctx: &egui::Context) -> Vec<&'static str> { + let (events, shift) = ctx.input(|i| { + let ev: Vec = + i.events.iter().filter(|e| matches!(e, egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_))).cloned().collect(); + (ev, i.modifiers.shift) + }); + let mut out = Vec::new(); + for e in events { + match e { + egui::Event::Copy => out.push(if shift { "copybase" } else { "copyclip" }), + egui::Event::Cut => out.push("cutclip"), + egui::Event::Paste(text) => { + let h = hash(&text); + if ctx.data(|d| d.get_temp::(text_id())) != Some(h) && clipboard::load_system_text(&mut app.session, &text) { + ctx.data_mut(|d| d.insert_temp(text_id(), h)); + } + out.push(if shift { "pasteblock" } else { "pasteclip" }); + } + _ => {} + } + } + out +} diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs new file mode 100644 index 00000000..3e6c39fd --- /dev/null +++ b/crates/ui-egui/src/context_menu.rs @@ -0,0 +1,69 @@ +//! The canvas's right-click menu while no command runs, after AutoCAD's default and edit-mode +//! shortcut menus: Repeat the last command, the Clipboard commands, editing commands for a +//! selection, Undo/Redo, Pan/Zoom, Quick Select and Find. (While a command runs, a right-click +//! is Enter.) Every item starts a command, like choosing it from the menu bar. + +use cadcraft_engine::find_command; + +use crate::CadApp; +use crate::menus::{Entry, activate, entry_ui}; + +/// Clipboard ▸ (AutoCAD's order). +const CLIPBOARD: &[&str] = &["cutclip", "copyclip", "copybase", "pasteclip", "pasteblock", "pasteorig"]; +/// With a selection: commands that act on it, and its label when it differs from the command's. +const SELECTION: &[(&str, Option<&str>)] = &[ + ("erase", None), + ("move", None), + ("copy", Some("Copy Selection")), + ("scale", None), + ("rotate", None), + ("ai_deselect", None), + ("properties", None), +]; +const VIEW: &[&str] = &["u", "redo", "pan", "zoom"]; +const TOOLS: &[&str] = &["qselect", "find"]; + +fn item(app: &CadApp, id: &str, label: Option<&str>) -> Option { + let spec = find_command(id)?; + Some(Entry::Item { + label: label.unwrap_or(spec.label).to_string(), + id: id.to_string(), + shortcut: spec.shortcut.map(str::to_string), + enabled: (spec.enabled)(&app.session).is_ok(), + }) +} + +/// The menu's groups of entries (separated by lines). +pub fn entries(app: &CadApp) -> Vec> { + let mut groups = Vec::new(); + if let Some(last) = app.session.last_command.as_deref().and_then(find_command) { + let label = crate::tf!("Repeat {command}", command = last.id.to_ascii_uppercase()); + groups.push(vec![Entry::Item { label, id: last.id.to_string(), shortcut: None, enabled: (last.enabled)(&app.session).is_ok() }]); + } + let clip = CLIPBOARD.iter().filter_map(|id| item(app, id, None)).collect(); + groups.push(vec![Entry::Sub { label: "Clipboard".into(), children: clip }]); + if !app.session.selection().is_empty() { + groups.push(SELECTION.iter().filter_map(|(id, label)| item(app, id, *label)).collect()); + } + groups.push(VIEW.iter().filter_map(|id| item(app, id, None)).collect()); + groups.push(TOOLS.iter().filter_map(|id| item(app, id, None)).collect()); + groups +} + +/// Attach the menu to the canvas response: it opens on a right-click. +pub fn show(app: &mut CadApp, resp: &egui::Response) { + let mut clicked = None; + resp.context_menu(|ui| { + for (i, group) in entries(app).iter().enumerate() { + if i > 0 { + ui.separator(); + } + for e in group { + entry_ui(ui, e, &mut clicked); + } + } + }); + if let Some(id) = clicked { + activate(app, &id); + } +} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d7e08804..6ea081dc 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -101,6 +101,7 @@ Circle Коло Circumference Довжина кола Clear Очистити + Clipboard Буфер обміну Close Закрити Close All Закрити всі Closed Замкнений @@ -131,6 +132,7 @@ Copy Копіювати Copy Layout Копіювати аркуш Copy Nested Objects Копіювати вкладені об'єкти + Copy Selection Копіювати вибране Copy with Base Point Копіювати з базовою точкою Count Кількість Create Block Створити блок @@ -164,6 +166,7 @@ Delta X Різниця X Delta Y Різниця Y Description Опис + Deselect All Скасувати вибір Diameter Діаметр Dim style Розмірний стиль Dimension Розміри @@ -502,6 +505,7 @@ Parameters Manager Диспетчер параметрів Parametric Параметризація Paste Вставити + Paste as Block Вставити як блок Paste to Original Coordinates Вставити у вихідні координати Path Array Масив уздовж траєкторії Pattern Візерунок @@ -576,6 +580,7 @@ Rename Layout Перейменувати аркуш Rename Text Style Перейменувати текстовий стиль Rename... Перейменувати... + Repeat {command} Повторити {command} Requires a point (x,y, @dx,dy or @d String { format!("{shift}{cmd}{}", s.replace("Cmd+", "").replace("Shift+", "")) } -fn entry_ui(ui: &mut egui::Ui, e: &Entry, clicked: &mut Option) { +pub(crate) fn entry_ui(ui: &mut egui::Ui, e: &Entry, clicked: &mut Option) { match e { Entry::Item { label, id, shortcut, enabled } => { let mut b = egui::Button::new(crate::i18n::t(label)); @@ -424,6 +424,7 @@ pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) { (sc(cmd_shift, Key::C), "copybase"), (sc(cmd, Key::C), "copyclip"), (sc(cmd, Key::X), "cutclip"), + (sc(cmd_shift, Key::V), "pasteblock"), (sc(cmd, Key::V), "pasteclip"), (sc(cmd, Key::Num1), "ui.toggle.palettes"), (sc(cmd, Key::Num3), "ui.toggle.toolsets"), @@ -438,13 +439,15 @@ pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) { (sc(Modifiers::NONE, Key::F11), "otrack"), (sc(Modifiers::NONE, Key::F12), "dynmode"), ]; + crate::clipboard::mirror(app, ctx); if ctx.egui_wants_keyboard_input() || app.closing.is_some() { return; } - let mut fire = Vec::new(); + // Cmd/Ctrl+C, X and V arrive as clipboard events, not key presses. + let mut fire = crate::clipboard::shortcut_commands(app, ctx); ctx.input_mut(|i| { for (s, id) in pairs { - if i.consume_shortcut(s) { + if i.consume_shortcut(s) && !fire.contains(id) { fire.push(*id); } } diff --git a/crates/ui-egui/tests/context_menu.rs b/crates/ui-egui/tests/context_menu.rs new file mode 100644 index 00000000..feb570cb --- /dev/null +++ b/crates/ui-egui/tests/context_menu.rs @@ -0,0 +1,96 @@ +//! The canvas's right-click menu (#325): Repeat, the Clipboard commands with AutoCAD's rules for +//! what is available, and the selection commands. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::menus::Entry; +use cadcraft_ui_egui::{CadApp, Services, context_menu}; +use serde_json::json; + +/// (label, id, enabled) of the items, with submenu items prefixed by "Sub/". +fn items(app: &CadApp) -> Vec<(String, String, bool)> { + fn walk(prefix: &str, es: &[Entry], out: &mut Vec<(String, String, bool)>) { + for e in es { + match e { + Entry::Item { label, id, enabled, .. } => out.push((format!("{prefix}{label}"), id.clone(), *enabled)), + Entry::Sub { label, children } => walk(&format!("{prefix}{label}/"), children, out), + } + } + } + let mut out = Vec::new(); + for g in context_menu::entries(app) { + walk("", &g, &mut out); + } + out +} + +fn enabled(app: &CadApp, id: &str) -> bool { + items(app).iter().find(|(_, i, _)| i == id).is_some_and(|(_, _, e)| *e) +} + +#[test] +fn context_menu_offers_the_clipboard() { + let mut app = CadApp::new(Session::new(), Services::default()); + let labels: Vec = items(&app).into_iter().map(|(l, _, _)| l).collect(); + for l in [ + "Clipboard/Cut", + "Clipboard/Copy", + "Clipboard/Copy with Base Point", + "Clipboard/Paste", + "Clipboard/Paste as Block", + "Clipboard/Paste to Original Coordinates", + "Undo", + "Redo", + "Pan", + "Zoom", + ] { + assert!(labels.iter().any(|x| x == l), "{l} missing from {labels:?}"); + } + assert!(!labels.iter().any(|l| l.starts_with("Repeat")), "no command yet"); + assert!(!enabled(&app, "pasteclip"), "nothing to paste"); + + app.cmdline("LINE 0,0 10,0"); + app.cmdline(""); + app.run("copyclip", json!({ "handles": app.session.doc().unwrap().model.handles().iter().map(|h| h.hex()).collect::>() })).unwrap(); + let repeat = items(&app).into_iter().next().unwrap(); + assert_eq!((repeat.0.as_str(), repeat.1.as_str()), ("Repeat LINE", "line")); + assert!(enabled(&app, "pasteclip") && enabled(&app, "pasteblock")); + assert!(!enabled(&app, "pasteorig"), "Paste to Original Coordinates pastes into another drawing"); + + app.run("new", json!({})).unwrap(); + assert!(enabled(&app, "pasteorig")); + context_menu_selection(&mut app); +} + +/// With a selection, the editing commands and Deselect All. +fn context_menu_selection(app: &mut CadApp) { + assert!(!items(app).iter().any(|(_, id, _)| id == "ai_deselect")); + app.run("pasteorig", json!({})).unwrap(); + assert_eq!(app.session.selection().len(), 1, "pasted objects are selected"); + let ids: Vec = items(app).into_iter().map(|(_, id, _)| id).collect(); + for id in ["erase", "move", "copy", "ai_deselect", "properties"] { + assert!(ids.iter().any(|x| x == id), "{id} missing from {ids:?}"); + } + app.run("ai_deselect", json!({})).unwrap(); + assert!(app.session.selection().is_empty()); +} + +/// egui delivers Cmd/Ctrl+C, X and V as clipboard events, not key presses: they run the +/// clipboard commands. +#[test] +fn clipboard_events_run_the_clipboard_commands() { + let mut app = CadApp::new(Session::new(), Services::default()); + app.run("line", json!({ "points": [[0, 0], [10, 0]] })).unwrap(); + let h = app.session.doc().unwrap().model.handles(); + app.session.set_selection(h); + let ctx = egui::Context::default(); + let frame = |app: &mut CadApp, events: Vec| { + ctx.begin_pass(egui::RawInput { events, ..Default::default() }); + cadcraft_ui_egui::menus::shortcuts(app, &ctx); + ctx.end_pass().textures_delta.clear(); + }; + frame(&mut app, vec![egui::Event::Copy]); + assert_eq!(app.session.clipboard.len(), 1, "Copy with a selection copies it"); + frame(&mut app, vec![egui::Event::Paste("text from another program".into())]); + assert_eq!(app.session.running.as_ref().map(|r| r.id.as_str()), Some("pasteclip")); + assert!(app.session.prompt_text().contains("insertion point")); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b9d65a1b..3f6f6e29 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -187,9 +187,10 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. shortcuts. AutoCAD for Mac's defaults are similar in number; F4 (3D osnap), F5 (isoplane), F6 (dynamic UCS) are missing. - Delete erases the selection on macOS (#64); Escape cancels. -- **No right-click context menus** anywhere: right-click is Enter, clear selection or repeat - (`canvas.rs:651-660`). AutoCAD has context menus on the canvas (per selection, per command), - the command line, palettes and tabs. +- Right-click on the canvas is Enter while a command runs; otherwise it opens a shortcut menu + (Repeat, Clipboard ▸ Cut/Copy/Copy with Base Point/Paste/Paste as Block/Paste to Original + Coordinates, the selection's editing commands, Undo/Redo, Pan/Zoom; #325). Still missing: + AutoCAD's per-command menus and the menus of the command line, palettes and tabs. ## Revision history From 9b7d7bd090ee5ba7c50bc483d523984fb80b5d98 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 23:32:39 +0200 Subject: [PATCH 113/150] STRETCH: repeat Select objects until Enter, C/W windows, Displacement (fixes #228) (#231) STRETCH gets its own prompt machine instead of a special case inside the shared MOVE/ROTATE/SCALE one. "Select objects" now repeats until Enter: implied windows (crossing right to left), `C` and `W` windows, single picks and All/Last/Previous (moved whole), Remove/Add. Vertices inside any of the windows move. Enter or `Displacement` at the base point asks for a displacement, and Enter at the second point uses the base point as the displacement, as in AutoCAD. Polygon and fence selection say they are not available yet instead of being misread. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/modify.rs | 304 ++++++++++++++++++++++++-------- crates/engine/tests/stretch.rs | 69 ++++++++ 2 files changed, 302 insertions(+), 71 deletions(-) create mode 100644 crates/engine/tests/stretch.rs diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index ad7ec2dd..7be2db3e 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { .menu(&["Modify", "Stretch"]) .alias(&["s"]) .params("{window: [[x,y],[x,y]], delta: [dx,dy]}") - .interactive(|_| Ok(Box::new(SelectThen::new(Op::Stretch)))), + .interactive(|_| Ok(Box::new(StretchM::default()))), CommandSpec::new("offset", "Offset", run_offset) .menu(&["Modify", "Offset"]) .alias(&["o"]) @@ -207,10 +207,11 @@ fn run_mirror(s: &mut Session, p: &Value) -> Result { Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) } -/// Move the vertices of `e` that lie inside `bx` by `d` (STRETCH). -fn stretch_entity(e: &mut Entity, bx: &cadcraft_geom::Bounds2, d: Vec2) { +/// Move the vertices of `e` that lie inside any of the windows `wins` by `d` (STRETCH). +fn stretch_entity(e: &mut Entity, wins: &[cadcraft_geom::Bounds2], d: Vec2) { + let inside = |p: Vec2| wins.iter().any(|bx| bx.contains(p)); let mv = |p: &mut cadcraft_geom::Vec3| { - if bx.contains(p.xy()) { + if inside(p.xy()) { p.x += d.x; p.y += d.y; } @@ -222,14 +223,14 @@ fn stretch_entity(e: &mut Entity, bx: &cadcraft_geom::Bounds2, d: Vec2) { } EntityKind::LwPolyline(pl) => { for v in &mut pl.vertices { - if bx.contains(v.p) { + if inside(v.p) { v.p += d; } } } EntityKind::Arc(a) => { // Arcs keep their shape: move when the centre is inside. - if bx.contains(a.center.xy()) { + if inside(a.center.xy()) { a.center.x += d.x; a.center.y += d.y; } @@ -243,7 +244,7 @@ fn stretch_entity(e: &mut Entity, bx: &cadcraft_geom::Bounds2, d: Vec2) { } k => { let g = k.grips(); - if g.first().is_some_and(|p| bx.contains(*p)) { + if g.first().is_some_and(|p| inside(*p)) { k.transform(&Mat3::translate(d)); } } @@ -260,11 +261,228 @@ fn run_stretch(s: &mut Session, p: &Value) -> Result { hs.retain(|h| !super::curves::is_locked(s, *h)); let doc = s.doc_mut()?; for h in &hs { - doc.modify_entity(*h, |e| stretch_entity(e, &bx, d))?; + doc.modify_entity(*h, |e| stretch_entity(e, &[bx], d))?; } Ok(json!({ "stretched": hs.len() })) } +#[derive(Clone, Copy, Default, PartialEq)] +enum StretchPhase { + #[default] + Select, + Base, + Displacement, + Second, +} + +/// STRETCH: "Select objects" repeats until Enter. Crossing windows (implied right to left, or after +/// `C`) and windows (left to right, or after `W`) select objects and mark the area whose vertices +/// move; objects picked one at a time (or by All/Last/Previous) move whole. Then the base point or +/// Displacement, and the second point. +#[derive(Default)] +struct StretchM { + phase: StretchPhase, + objs: Vec, + /// Objects selected without a window: they move whole. + whole: Vec, + /// The selection windows: vertices inside any of them move. + windows: Vec, + removing: bool, + /// `C` (true) or `W` (false) was typed: the next two points are that kind of window. + mode: Option, + /// First corner of the window being drawn. + corner: Option, + base: Option, +} + +impl StretchM { + fn add(&mut self, s: &mut Session, hs: Vec, whole: bool) { + let n = hs.len(); + for h in hs { + if self.removing { + self.objs.retain(|o| *o != h); + self.whole.retain(|o| *o != h); + } else { + if !self.objs.contains(&h) { + self.objs.push(h); + } + if whole && !self.whole.contains(&h) { + self.whole.push(h); + } + } + } + if n > 0 { + s.echo(format!("{n} found, {} total", self.objs.len())); + } else { + s.echo("0 found"); + } + s.set_selection(self.objs.clone()); + } + + fn select(&mut self, s: &mut Session, i: Input) -> Result { + match i { + Input::Point(p) => match self.corner.take() { + Some(c) => { + let bx = cadcraft_geom::Bounds2::new(c, p); + let crossing = self.mode.take().unwrap_or(p.x < c.x); + let space = s.space(); + let hs = crate::select::select_window(s.doc()?, &space, bx, crossing); + if !self.removing { + self.windows.push(bx); + } + self.add(s, hs, false); + } + None if self.mode.is_some() => self.corner = Some(p), + None => { + let aperture = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; + let space = s.space(); + match crate::select::pick(s.doc()?, &space, p, aperture) { + Some(h) => self.add(s, vec![h], true), + None => self.corner = Some(p), + } + } + }, + Input::Pick(hs) => self.add(s, hs, true), + Input::Text(t) | Input::Keyword(t) => { + self.corner = None; + let d = s.doc()?; + let store = d.space(&s.space()); + let picked = match t.trim().to_ascii_lowercase().as_str() { + "c" | "crossing" => { + self.mode = Some(true); + None + } + "w" | "window" => { + self.mode = Some(false); + None + } + "r" | "remove" => { + self.removing = true; + None + } + "a" | "add" => { + self.removing = false; + None + } + "all" => Some(store.map(|st| st.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()).unwrap_or_default()), + "l" | "last" => Some(store.and_then(|st| st.last()).map(|e| vec![e.handle]).unwrap_or_default()), + "p" | "previous" => Some(s.state()?.previous_selection.clone()), + "cp" | "wp" | "f" | "fence" => { + s.echo("Polygon and fence selection are not available yet in STRETCH; use a crossing window."); + None + } + _ => { + s.echo("*Invalid selection*"); + None + } + }; + if let Some(hs) = picked { + self.add(s, hs, true); + } + } + Input::Enter if self.corner.is_some() || self.mode.is_some() => { + self.corner = None; + self.mode = None; + } + Input::Enter if self.objs.is_empty() => return Ok(Step::Done), + Input::Enter => { + s.remember_selection(&self.objs); + self.phase = StretchPhase::Base; + } + _ => {} + } + Ok(Step::Continue) + } + + fn apply(&self, s: &mut Session, d: Vec2) -> Result { + let whole: Vec = self.objs.iter().filter(|h| self.whole.contains(h)).copied().collect(); + transform_entities(s, &whole, &Mat3::translate(d), false)?; + let doc = s.doc_mut()?; + for h in self.objs.iter().filter(|h| !self.whole.contains(h)) { + let locked = doc.entity(*h).and_then(|e| doc.layer(&e.common.layer)).is_some_and(|l| l.locked); + if !locked { + doc.modify_entity(*h, |e| stretch_entity(e, &self.windows, d))?; + } + } + s.set_selection(Vec::new()); + Ok(Step::Done) + } +} + +impl Interactive for StretchM { + fn name(&self) -> &'static str { + "STRETCH" + } + fn begin(&mut self, s: &mut Session) -> Result { + s.set_selection(Vec::new()); + s.echo("Select objects to stretch by crossing-window or crossing-polygon..."); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + match (self.phase, self.corner) { + (StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).base(c), + (StretchPhase::Select, None) if self.mode.is_some() => Prompt::new("Specify first corner", Accept::POINT), + (StretchPhase::Select, None) => Prompt::new(if self.removing { "Remove objects" } else { "Select objects" }, Accept::POINT), + (StretchPhase::Base, _) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"), + (StretchPhase::Displacement, _) => Prompt::new("Specify displacement", Accept::POINT).default("0,0"), + (StretchPhase::Second, _) => Prompt::new("Specify second point or ", Accept::POINT).base_opt(self.base), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (self.phase, i) { + (StretchPhase::Select, i) => self.select(s, i), + (StretchPhase::Base, Input::Point(p)) => { + self.base = Some(p); + self.phase = StretchPhase::Second; + Ok(Step::Continue) + } + (StretchPhase::Base, Input::Enter) => { + self.phase = StretchPhase::Displacement; + Ok(Step::Continue) + } + (StretchPhase::Base, Input::Keyword(k)) if k == "Displacement" => { + self.phase = StretchPhase::Displacement; + Ok(Step::Continue) + } + (StretchPhase::Displacement, Input::Point(p)) => self.apply(s, p), + (StretchPhase::Displacement, Input::Enter) => Ok(Step::Done), + (StretchPhase::Second, Input::Point(p)) => self.apply(s, p - self.base.unwrap_or(p)), + (StretchPhase::Second, Input::Enter) => self.apply(s, self.base.unwrap_or(Vec2::ZERO)), + _ => Ok(Step::Continue), + } + } + fn preview(&self, s: &Session, c: Vec2) -> Vec { + match (self.phase, self.corner, self.base) { + (StretchPhase::Select, Some(a), _) => { + let (b, d) = (Vec2::new(c.x, a.y), Vec2::new(a.x, c.y)); + vec![line(a, b), line(b, c), line(c, d), line(d, a)] + } + (StretchPhase::Second, _, Some(base)) => { + let Ok(doc) = s.doc() else { return Vec::new() }; + let m = Mat3::translate(c - base); + let mut out: Vec = self + .objs + .iter() + .take(500) + .filter_map(|h| doc.entity(*h)) + .map(|e| { + let mut e = (**e).clone(); + if self.whole.contains(&e.handle) { + e.kind.transform(&m); + } else { + stretch_entity(&mut e, &self.windows, c - base); + } + e.kind + }) + .collect(); + out.push(line(base, c)); + out + } + _ => Vec::new(), + } + } +} + /// Offset an entity by `dist` towards `side`. Returns the new geometry. pub(crate) fn offset_kind(k: &EntityKind, dist: f64, side: Vec2) -> Option { match k { @@ -1280,7 +1498,6 @@ enum Op { Rotate, Scale, Mirror, - Stretch, Explode, Join, } @@ -1302,7 +1519,6 @@ struct SelectThen { /// SCALE Reference: the reference length, once known. ref_len: Option, copy_mode: bool, - window: Option, ask_erase: bool, } @@ -1319,7 +1535,6 @@ impl SelectThen { ref_from: None, ref_len: None, copy_mode: false, - window: None, ask_erase: false, } } @@ -1491,17 +1706,11 @@ impl Interactive for SelectThen { Op::Rotate => "ROTATE", Op::Scale => "SCALE", Op::Mirror => "MIRROR", - Op::Stretch => "STRETCH", Op::Explode => "EXPLODE", Op::Join => "JOIN", } } fn begin(&mut self, s: &mut Session) -> Result { - if self.op == Op::Stretch { - s.set_selection(Vec::new()); - s.echo("Select objects to stretch by crossing-window or crossing-polygon..."); - return Ok(Step::Continue); - } self.sel = SelectPhase::begin(s); if self.sel.done { self.objs = self.sel.picked.clone(); @@ -1510,23 +1719,14 @@ impl Interactive for SelectThen { Ok(Step::Continue) } fn prompt(&self, s: &Session) -> Prompt { - if self.op == Op::Stretch && self.window.is_none() { - return match self.pts.first() { - None => Prompt::new("Select objects (crossing window first corner)", Accept::POINT), - Some(p) => Prompt::new("Specify opposite corner", Accept::POINT).base(*p), - }; - } - if !self.sel.done && self.op != Op::Stretch { + if !self.sel.done { return self.sel.prompt(); } if self.ask_erase { return Prompt::new("Erase source objects?", Accept::TEXT).kw(&["Yes", "No"]).default("No"); } - let n = self.pts.len(); + let k = self.pts.len(); let base = self.pts.first().copied(); - let pts_from = if self.op == Op::Stretch { 2 } else { 0 }; - let k = n.saturating_sub(pts_from); - let bp = if self.op == Op::Stretch { self.pts.get(2).copied() } else { base }; if self.op == Op::Scale && self.reference { return match (self.ref_len, self.ref_from) { (Some(_), _) => Prompt::new("Specify new length", Accept::POINT_OR_NUMBER).base_opt(base), @@ -1538,8 +1738,8 @@ impl Interactive for SelectThen { return self.rotate_reference_prompt(); } match (self.op, k) { - (Op::Move | Op::Copy | Op::Stretch, 0) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]), - (Op::Move | Op::Stretch, _) => Prompt::new("Specify second point or ", Accept::POINT).base_opt(bp), + (Op::Move | Op::Copy, 0) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]), + (Op::Move, _) => Prompt::new("Specify second point or ", Accept::POINT).base_opt(base), (Op::Copy, _) => Prompt::new("Specify second point", Accept::POINT).kw(&["Array", "Exit", "Undo"]).base_opt(base), (Op::Rotate | Op::Scale, 0) => Prompt::new("Specify base point", Accept::POINT), // ROTATE and SCALE offer the angle/factor used last time. @@ -1562,25 +1762,6 @@ impl Interactive for SelectThen { } } fn input(&mut self, s: &mut Session, i: Input) -> Result { - if self.op == Op::Stretch && self.window.is_none() { - if let Input::Point(p) = i { - self.pts.push(p); - if self.pts.len() == 2 { - let bx = cadcraft_geom::Bounds2::new(self.pts[0], self.pts[1]); - self.window = Some(bx); - let space = s.space(); - self.objs = crate::select::select_window(s.doc()?, &space, bx, true); - self.objs.retain(|h| !super::curves::is_locked(s, *h)); - s.echo(format!("{} found", self.objs.len())); - s.set_selection(self.objs.clone()); - self.sel.done = true; - if self.objs.is_empty() { - return Ok(Step::Done); - } - } - } - return Ok(Step::Continue); - } if !self.sel.done { match self.sel.feed(s, &i)? { SelOutcome::More => return Ok(Step::Continue), @@ -1598,8 +1779,7 @@ impl Interactive for SelectThen { s.set_selection(Vec::new()); return Ok(Step::Done); } - let off = if self.op == Op::Stretch { 2 } else { 0 }; - let k = self.pts.len() - off; + let k = self.pts.len(); if self.op == Op::Scale && self.reference { return self.scale_reference(s, i); } @@ -1636,17 +1816,6 @@ impl Interactive for SelectThen { s.set_selection(Vec::new()); Ok(Step::Done) } - (Op::Stretch, 1, Input::Point(p)) => { - let d = p - self.pts[2]; - if let Some(bx) = self.window { - let doc = s.doc_mut()?; - for h in &self.objs { - doc.modify_entity(*h, |e| stretch_entity(e, &bx, d))?; - } - } - s.set_selection(Vec::new()); - Ok(Step::Done) - } (Op::Copy, _, Input::Point(p)) => { let d = p - self.pts[0]; transform_entities(s, &self.objs, &Mat3::translate(d), true)?; @@ -1705,9 +1874,9 @@ impl Interactive for SelectThen { if !self.sel.done { return Vec::new(); } - let Some(base) = (if self.op == Op::Stretch { self.pts.get(2) } else { self.pts.first() }).copied() else { return Vec::new() }; + let Some(base) = self.pts.first().copied() else { return Vec::new() }; let m = match self.op { - Op::Move | Op::Copy | Op::Stretch => Mat3::translate(c - base), + Op::Move | Op::Copy => Mat3::translate(c - base), Op::Rotate => Mat3::rotate_about(base, base.angle_to(c)), Op::Scale if !self.reference => Mat3::scale_about(base, base.dist(c).max(1e-9)), Op::Scale => match self.ref_len.and_then(|r| positive_length(Some(base.dist(c) / r))) { @@ -1726,13 +1895,6 @@ impl Interactive for SelectThen { .filter_map(|h| d.entity(*h)) .map(|e| { let mut k = e.kind.clone(); - if self.op == Op::Stretch { - let mut ee = (**e).clone(); - if let Some(bx) = self.window { - stretch_entity(&mut ee, &bx, c - base); - } - return ee.kind; - } k.transform(&m); k }) diff --git a/crates/engine/tests/stretch.rs b/crates/engine/tests/stretch.rs new file mode 100644 index 00000000..7b1018f6 --- /dev/null +++ b/crates/engine/tests/stretch.rs @@ -0,0 +1,69 @@ +//! STRETCH: repeated crossing selections until Enter, the `C`/`W` selection keywords, and the +//! Displacement option (typed, or Enter at the base point prompt). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::EntityKind; + +/// Every line as ((ax, ay), (bx, by)), sorted by the first x. +fn lines(s: &Session) -> Vec<((f64, f64), (f64, f64))> { + let mut v: Vec<_> = s + .doc() + .unwrap() + .model + .iter() + .filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some(((l.a.x, l.a.y), (l.b.x, l.b.y))) } else { None }) + .collect(); + v.sort_by(|a, b| a.0.0.total_cmp(&b.0.0).then(a.0.1.total_cmp(&b.0.1))); + v +} + +fn session_with_lines(lines: &[&str]) -> Session { + let mut s = Session::new(); + for l in lines { + s.cmdline(&format!("LINE {l}")).unwrap(); + s.cmdline("").unwrap(); + } + s +} + +#[test] +fn stretch_script_with_crossing_keyword() { + let mut s = session_with_lines(&["0,0 10,0"]); + s.script("STRETCH C 12,1 8,-1 0,0 5,0\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![((0.0, 0.0), (15.0, 0.0))]); +} + +#[test] +fn stretch_keeps_selecting_until_enter() { + let mut s = session_with_lines(&["0,0 10,0", "0,5 10,5"]); + // Two implied crossing windows (right to left), each around one right-hand end. + s.cmdline("STRETCH").unwrap(); + for t in ["12,1", "8,-1", "12,6", "8,4", ""] { + s.cmdline(t).unwrap(); + } + assert!(s.prompt_text().contains("base point"), "{}", s.prompt_text()); + s.cmdline("0,0").unwrap(); + s.cmdline("0,2").unwrap(); + assert!(s.running.is_none()); + assert_eq!(lines(&s), vec![((0.0, 0.0), (10.0, 2.0)), ((0.0, 5.0), (10.0, 7.0))]); +} + +#[test] +fn stretch_displacement_option() { + // Enter at the base point prompt asks for a displacement. + let mut s = session_with_lines(&["0,0 10,0"]); + s.script("STRETCH C 12,1 8,-1\n\n\n3,4\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![((0.0, 0.0), (13.0, 4.0))]); + // The Displacement keyword does the same. + let mut s = session_with_lines(&["0,0 10,0"]); + s.script("STRETCH C 12,1 8,-1 D 3,4\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![((0.0, 0.0), (13.0, 4.0))]); + // Enter at the second point uses the base point as the displacement. + let mut s = session_with_lines(&["0,0 10,0"]); + s.script("STRETCH C 12,1 8,-1 2,0\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(lines(&s), vec![((0.0, 0.0), (12.0, 0.0))]); +} From 1ff426dbe7006ea30f741406b62df144b2741a90 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 17:45:43 -0400 Subject: [PATCH 114/150] PURGE test: add the zero-length line directly #344 made JSON LINE refuse coincident points; files can still hold them, so the test that checks PURGE Zero-length adds one to the drawing directly. Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/purge_prompts.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/tests/purge_prompts.rs b/crates/engine/tests/purge_prompts.rs index 9602c4f3..408cd2ca 100644 --- a/crates/engine/tests/purge_prompts.rs +++ b/crates/engine/tests/purge_prompts.rs @@ -3,6 +3,7 @@ use cadcraft_engine::Session; use cadcraft_engine::doc::EntityKind; +use cadcraft_engine::geom::Vec3; use serde_json::json; /// Unused: blocks U1 and U2, layer Spare, linetype DASHED, text style Notes, dimension style Arch. @@ -75,7 +76,9 @@ fn verify_asks_for_each_name_and_absent_types_take_their_tokens() { assert!(s.log[n..].iter().any(|l| l.contains("No unreferenced")), "{:?}", &s.log[n..]); assert_eq!(count(&s, |k| matches!(k, EntityKind::Line(_))), 1); // Zero-length geometry purges at once. - s.execute("line", &json!({ "points": [[3, 3], [3, 3]] })).unwrap(); + // (Drawn directly: the LINE command refuses coincident points, but files can hold them.) + let zero = EntityKind::Line(cadcraft_engine::doc::Line { a: Vec3::new(3.0, 3.0, 0.0), b: Vec3::new(3.0, 3.0, 0.0) }); + s.doc_mut().unwrap().add(&cadcraft_engine::doc::Space::Model, cadcraft_engine::doc::Common::default(), zero).unwrap(); s.script("-PURGE Z\n").unwrap(); assert!(s.running.is_none(), "{}", s.prompt_text()); assert_eq!(count(&s, |k| matches!(k, EntityKind::Line(_))), 1); From 7bb64e5fcec38c7c6c838003cfd3d3e88b792654 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:08:32 +0700 Subject: [PATCH 115/150] fix(geom): preserve boundary vertices far from the origin (#386) --- crates/geom/src/region.rs | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/geom/src/region.rs b/crates/geom/src/region.rs index 3fa3b580..429d10c5 100644 --- a/crates/geom/src/region.rs +++ b/crates/geom/src/region.rs @@ -38,8 +38,11 @@ pub struct Island { pub sources: Vec, } -fn key(p: Vec2, q: f64) -> (i64, i64) { - ((p.x / q).round() as i64, (p.y / q).round() as i64) +fn key(p: Vec2, origin: Vec2, q: f64) -> (i64, i64) { + // The drawing may be georeferenced far from (0, 0). Quantizing absolute + // coordinates saturates i64 and aliases distinct vertices into one key. + // Shift to the bounds origin before converting to tolerance-sized bins. + (((p.x - origin.x) / q).round() as i64, ((p.y - origin.y) / q).round() as i64) } struct Graph { @@ -129,7 +132,7 @@ fn build(polys: &[Vec], tol: f64) -> Option { let mut g = Graph { pts: Vec::new(), adj: Vec::new(), src: Vec::with_capacity(n) }; let mut index: HashMap<(i64, i64), usize> = HashMap::new(); let mut vid = |p: Vec2, g: &mut Graph| -> usize { - let k = key(p, tol); + let k = key(p, bb.min, tol); *index.entry(k).or_insert_with(|| { g.pts.push(p); g.adj.push(Vec::new()); @@ -383,6 +386,23 @@ mod tests { assert!((shoelace(&l) - 100.0).abs() < 1e-9); } + #[test] + fn boundaries_at_large_world_coordinates_keep_distinct_vertices() { + // Absolute-position / tolerance is beyond i64::MAX for this normal + // georeferenced drawing; the old key collapsed every square corner. + let origin = 1e12; + let outline = square(origin, origin, 100.0); + let picked = Vec2::new(origin + 50.0, origin + 50.0); + let found = enclosing_loop(&[outline], picked, 1e-9).expect("large-coordinate boundary"); + assert_eq!(found.len(), 4); + assert!((shoelace(&found) - 10_000.0).abs() < 1e-5); + + // Island detection uses the same graph and must preserve its loop. + let result = boundary_at(&[square(origin, origin, 100.0)], picked, 1e-9).expect("boundary with islands"); + assert_eq!(result.outer.len(), 4); + assert!(result.islands.is_empty()); + } + #[test] fn crossing_lines_make_faces() { // A square cut by a diagonal: the face is a triangle. From 35a8617855def23d30f4c1c5b7ab7e4d20e8abf4 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:08:37 +0700 Subject: [PATCH 116/150] fix(geom): use scale-aware circle-circle intersections (#384) --- crates/geom/src/intersect.rs | 36 ++++++++++++++++++++++++++++-------- crates/geom/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/crates/geom/src/intersect.rs b/crates/geom/src/intersect.rs index 59d4d97f..1451a94b 100644 --- a/crates/geom/src/intersect.rs +++ b/crates/geom/src/intersect.rs @@ -53,19 +53,39 @@ pub fn line_circle(l: &Line, c: &Circle) -> Vec<(Vec2, f64)> { /// Intersections of two full circles. pub fn circle_circle(c1: &Circle, c2: &Circle) -> Vec { - let d = c1.center.dist(c2.center); - if d < EPS || d > c1.radius + c2.radius + 1e-9 || d < (c1.radius - c2.radius).abs() - 1e-9 { + let delta = c2.center - c1.center; + let d = delta.len(); + let (r1, r2) = (c1.radius, c2.radius); + if !(d.is_finite() && r1.is_finite() && r2.is_finite()) || d == 0.0 || r1 < 0.0 || r2 < 0.0 { + return Vec::new(); + } + + // Work at the scale of the two circles: fixed absolute tolerances classify + // small secants as tangencies, and raw radius squares overflow on large drawings. + let scale = d.max(r1).max(r2); + let (ds, r1s, r2s) = (d / scale, r1 / scale, r2 / scale); + if ds > r1s + r2s || ds < (r1s - r2s).abs() { + return Vec::new(); + } + let a = ((r1s - r2s) * (r1s + r2s) + ds * ds) / (2.0 * ds); + let h2 = (r1s - a) * (r1s + a); + if h2 < -1e-12 { + return Vec::new(); + } + let dir = delta / d; + let p = c1.center + dir * (a * scale); + if !p.is_finite() { return Vec::new(); } - let a = (c1.radius * c1.radius - c2.radius * c2.radius + d * d) / (2.0 * d); - let h2 = c1.radius * c1.radius - a * a; - let dir = (c2.center - c1.center) / d; - let p = c1.center + dir * a; if h2 <= 1e-12 { return vec![p]; } - let h = h2.sqrt(); - vec![p + dir.perp() * h, p - dir.perp() * h] + let step = dir.perp() * (h2.sqrt() * scale); + let (p1, p2) = (p + step, p - step); + if !p1.is_finite() || !p2.is_finite() { + return Vec::new(); + } + vec![p1, p2] } fn on_arc(a: &Arc, p: Vec2) -> bool { diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index b5c7fa3c..0f80e154 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -109,6 +109,25 @@ fn circle_circle_hits() { assert!(h.iter().all(|p| close(p.x, 4.0) && close(p.y.abs(), 3.0))); } +#[test] +fn tiny_circle_secants_and_separated_circles() { + let first = Circle::new(Vec2::ZERO, 1e-9); + // Both radii and center separation are below the old absolute tolerance. + let hits = circle_circle(&first, &Circle::new(Vec2::new(5e-10, 0.0), 1e-9)); + assert_eq!(hits.len(), 2); + let y = (1e-18_f64 - 2.5e-10_f64.powi(2)).sqrt(); + assert!(hits.iter().all(|p| (p.x - 2.5e-10).abs() < 1e-20 && (p.y.abs() - y).abs() < 1e-20), "{hits:?}"); + // A half-nanometre gap is significant relative to these circles. + assert!(circle_circle(&first, &Circle::new(Vec2::new(2.5e-9, 0.0), 1e-9)).is_empty()); +} + +#[test] +fn huge_circle_secants_remain_finite() { + let hits = circle_circle(&Circle::new(Vec2::ZERO, 1e160), &Circle::new(Vec2::new(1e160, 0.0), 1e160)); + assert_eq!(hits.len(), 2); + assert!(hits.iter().all(|p| p.is_finite() && (p.x / 1e160 - 0.5).abs() < 1e-12 && (p.y.abs() / 1e160 - 3.0_f64.sqrt() / 2.0).abs() < 1e-12), "{hits:?}"); +} + #[test] fn spline_interpolates_fit_points() { let fit = [Vec2::new(0.0, 0.0), Vec2::new(1.0, 2.0), Vec2::new(3.0, 1.0), Vec2::new(4.0, 4.0), Vec2::new(6.0, 0.0)]; From 6d8af2e5a4dbf740aef8fe17391c371115889577 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:08:41 +0200 Subject: [PATCH 117/150] Keep egui on the System theme preference so the OS appearance is followed live (#301) (#363) --- crates/ui-egui/src/lib.rs | 25 +++++++++++++++++++++++-- crates/ui-egui/src/theme.rs | 9 ++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index aae82559..8f863193 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -166,6 +166,8 @@ pub struct CadApp { styled: bool, /// The theme last installed into egui (`None` until the first frame). shown_theme: Option, + /// Whether the last applied theme was handed to egui as the System preference. + shown_follows_system: bool, pub frame_ms: f64, pub quit_requested: bool, /// The app may close although drawings have unsaved changes (they were dealt with, or the @@ -195,6 +197,7 @@ impl CadApp { frame_cap: None, styled: false, shown_theme: None, + shown_follows_system: false, frame_ms: 0.0, quit_requested: false, quit_confirmed: false, @@ -359,9 +362,11 @@ impl CadApp { // browser report changes as input); the saved choice itself never changes here. let shown = self.ui.theme.resolve(ctx.system_theme()); theme::set_active(shown); - if self.shown_theme != Some(shown) { - theme::apply(ctx, shown); + let follows_system = self.ui.theme == theme::ThemePref::System; + if self.shown_theme != Some(shown) || self.shown_follows_system != follows_system { + theme::apply(ctx, shown, follows_system); self.shown_theme = Some(shown); + self.shown_follows_system = follows_system; } self.drain_control(ctx); if !self.synthetic.is_empty() { @@ -582,6 +587,22 @@ mod tests { assert_eq!(a.prefs_json(), r#"{"interfaceLanguage":"auto","saveFormat":"dxf","theme":"system"}"#); } + #[test] + fn system_theme_keeps_egui_on_the_system_preference() { + let mut a = app(); + a.start("ui.theme.system"); + let ctx = egui::Context::default(); + frame(&mut a, &ctx, Some(egui::Theme::Light)); + // A concrete Light would pin the native window appearance and hide later OS changes. + assert_eq!(ctx.options(|o| o.theme_preference), egui::ThemePreference::System); + a.start("ui.theme.dark"); + frame(&mut a, &ctx, Some(egui::Theme::Light)); + assert_eq!(ctx.options(|o| o.theme_preference), egui::ThemePreference::Dark); + a.start("ui.theme.system"); + frame(&mut a, &ctx, Some(egui::Theme::Light)); + assert_eq!(ctx.options(|o| o.theme_preference), egui::ThemePreference::System); + } + #[test] fn default_save_format_persists_and_names_new_drawings() { let mut a = app(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 0c299bd5..00c541c4 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -284,12 +284,15 @@ fn visuals(theme: Theme) -> Visuals { } /// Install the styles of both themes and show `theme` (egui's own widgets, popups and menus -/// follow it; our own painting follows [`Tokens::get`]). -pub fn apply(ctx: &egui::Context, theme: Theme) { +/// follow it; our own painting follows [`Tokens::get`]). `follow_system` is true for the +/// [`ThemePref::System`] choice, whose `theme` was resolved from the OS report. +pub fn apply(ctx: &egui::Context, theme: Theme, follow_system: bool) { set_active(theme); ctx.set_visuals_of(Theme::Dark, visuals(Theme::Dark)); ctx.set_visuals_of(Theme::Light, visuals(Theme::Light)); - ctx.set_theme(theme); + // A System choice keeps egui's own System preference: a concrete Light/Dark would pin the native + // window appearance (winit then stops reporting OS changes) and the app would never follow again. + ctx.set_theme(if follow_system { egui::ThemePreference::System } else { egui::ThemePreference::from(theme) }); ctx.all_styles_mut(|s| { s.spacing.item_spacing = egui::vec2(6.0, 4.0); s.spacing.button_padding = egui::vec2(6.0, 3.0); From 7f5b39fb257004067c3d8b8558beca289c916ea5 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 00:08:45 +0200 Subject: [PATCH 118/150] Undo/redo of LAYOUT Rename/New keeps the session on a layout that exists (fixes #371) (#375) Undo and redo swapped the drawing but left the session on the current layout's name. After undoing a rename or the creation of the current layout, that layout no longer existed: nothing was drawn and commands failed with "no such space". Now the session follows a renamed layout to its other name (same tab order), keeping its paper view, and returns to the Model tab when the layout is gone. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/lib.rs | 31 ++++++++++++++ crates/engine/tests/undo_layout_tab.rs | 58 ++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 crates/engine/tests/undo_layout_tab.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 00c65223..0c316364 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -124,6 +124,35 @@ impl DocState { pub fn is_dirty(&self) -> bool { !Arc::ptr_eq(&self.doc, &self.saved) } + /// After undo/redo replaced the drawing (`before` is the one shown until then): if the current + /// layout no longer exists, follow it to its other name when it was renamed (same tab order, + /// a name `before` did not have), else go to the Model tab. + fn follow_layout(&mut self, before: &Drawing) { + let Space::Paper(name) = &self.space else { return }; + if self.doc.layouts.iter().any(|l| l.name == *name) { + return; + } + let renamed = before + .layouts + .iter() + .find(|l| l.name == *name) + .and_then(|old| self.doc.layouts.iter().find(|l| l.tab_order == old.tab_order && !before.layouts.iter().any(|b| b.name == l.name))); + match renamed.map(|l| Space::Paper(l.name.clone())) { + Some(to) => { + let from = self.space.clone(); + for (sp, _) in self.views.iter_mut() { + if *sp == from { + *sp = to.clone(); + } + } + self.space = to; + } + None => { + self.space = Space::Model; + self.mspace = None; + } + } + } /// The space edits, picks and snaps act on: model space inside an active viewport. pub fn edit_space(&self) -> Space { if self.active_viewport().is_some() { Space::Model } else { self.space.clone() } @@ -921,6 +950,7 @@ impl Session { let Some(snap) = st.undo.pop() else { return Ok(None) }; let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() }; st.doc = snap.doc; + st.follow_layout(&cur.doc); st.selection = Vec::new(); st.redo.push(cur); st.revision += 1; @@ -931,6 +961,7 @@ impl Session { let Some(snap) = st.redo.pop() else { return Ok(None) }; let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() }; st.doc = snap.doc; + st.follow_layout(&cur.doc); st.selection = Vec::new(); st.undo.push(cur); st.revision += 1; diff --git a/crates/engine/tests/undo_layout_tab.rs b/crates/engine/tests/undo_layout_tab.rs new file mode 100644 index 00000000..f1fd04fa --- /dev/null +++ b/crates/engine/tests/undo_layout_tab.rs @@ -0,0 +1,58 @@ +//! Undo and redo keep the session on a layout that exists (#371): undoing a rename of the current +//! layout stays on that tab under its old name; undoing its creation returns to the Model tab. + +use cadcraft_doc::Space; +use cadcraft_engine::Session; +use serde_json::json; + +fn paper(n: &str) -> Space { + Space::Paper(n.into()) +} + +#[test] +fn undo_and_redo_of_a_rename_follow_the_current_layout() { + let mut s = Session::new(); + s.execute("layout.set", &json!({"name": "Layout1"})).unwrap(); + s.execute("mview", &json!({"p1": [1, 1], "p2": [5, 4]})).unwrap(); + let paper_view = s.state().unwrap().paper_view(); + s.execute("layout.rename", &json!({"to": "Sheet"})).unwrap(); + assert_eq!(s.layout_space(), paper("Sheet")); + + s.cmdline("u").unwrap(); + assert_eq!(s.layout_space(), paper("Layout1")); + assert_eq!(s.state().unwrap().paper_view(), paper_view, "the sheet view is kept"); + // The layout is shown again: its sheet is drawn. + let list = cadcraft_render::build(s.doc().unwrap(), &s.layout_space(), &cadcraft_render::Options::default()); + assert!(list.sheet.is_some()); + + s.redo().unwrap(); + assert_eq!(s.layout_space(), paper("Sheet")); + s.execute("line", &json!({"points": [[1, 1], [2, 2]]})).unwrap(); + s.undo().unwrap(); + s.undo().unwrap(); + assert_eq!(s.layout_space(), paper("Layout1")); + s.execute("mview", &json!({"p1": [6, 1], "p2": [8, 4]})).unwrap(); +} + +#[test] +fn undoing_the_current_layouts_creation_returns_to_model() { + let mut s = Session::new(); + s.execute("layout.new", &json!({"name": "New"})).unwrap(); + s.execute("layout.set", &json!({"name": "New"})).unwrap(); + s.execute("mspace", &json!({})).unwrap(); + s.execute("line", &json!({"points": [[1, 1], [5, 5]]})).unwrap(); + s.undo().unwrap(); + assert_eq!(s.layout_space(), paper("New"), "the layout still exists"); + s.undo().unwrap(); + assert_eq!(s.layout_space(), Space::Model); + assert_eq!(s.space(), Space::Model); + assert!(s.state().unwrap().mspace.is_none()); + s.execute("line", &json!({"points": [[1, 1], [5, 5]]})).unwrap(); + + // Other layouts are left alone: undoing a rename of a layout that is not current. + let mut s = Session::new(); + s.execute("layout.set", &json!({"name": "Layout2"})).unwrap(); + s.execute("layout.rename", &json!({"from": "Layout1", "to": "A"})).unwrap(); + s.undo().unwrap(); + assert_eq!(s.layout_space(), paper("Layout2")); +} From 179ecc1f0b37ab4b8422ca542334174f4ba88c6e Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 00:08:50 +0200 Subject: [PATCH 119/150] DIMBASELINE spacing follows DIM* overrides and DIMSCALE 0 (fixes #364) (#366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Baseline dimensions were spaced by the bare style's DIMDLI × DIMSCALE, so SETVAR DIMSCALE/DIMDLI (stored as overrides since #129) had no effect and a style DIMSCALE of 0 collapsed the spacing to ~0. Use the continued dimension's style with its overrides and the effective scale (the drawing's DIMSCALE when the style's is 0), as drawing does; DIMSPACE's default spacing uses the same scale. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/annotate.rs | 19 ++++++-- crates/engine/tests/dim_baseline_spacing.rs | 48 +++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 crates/engine/tests/dim_baseline_spacing.rs diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index b3596812..9b235d4e 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -322,7 +322,17 @@ fn last_linear(s: &Session) -> Option { /// Next dimension in a chain from `prev` to a new second origin. fn chained(s: &Session, prev: &Dimension, next: Vec2, baseline: bool) -> EntityKind { - let spacing = s.doc().ok().and_then(|d| d.dim_style(&prev.style).map(|st| st.baseline_spacing * st.scale.max(1e-9))).unwrap_or(0.38); + // DIMDLI × the overall scale of the dimension being continued: its overrides (SETVAR DIM*) + // included, and DIMSCALE 0 falling back to the drawing's DIMSCALE as when it is drawn. + let spacing = s + .doc() + .ok() + .map(|d| { + let st = d.dim_style(&prev.style).cloned().unwrap_or_default().with_overrides(&prev.overrides); + st.baseline_spacing * st.effective_scale(d.header.f64("DIMSCALE", 1.0)) + }) + .filter(|v| v.is_finite()) + .unwrap_or(0.38); let dir = match prev.kind { DimKind::Linear { rotation } => Vec2::from_angle(rotation), _ => (prev.p14.xy() - prev.p13.xy()).normalized(), @@ -671,8 +681,11 @@ fn run_dimspace(s: &mut Session, p: &Value) -> Result { let d = s.doc()?; let Some(EntityKind::Dimension(b)) = d.entity(base).map(|e| e.kind.clone()) else { return Err(bad("dimspace", "`base` is not a dimension")) }; let st = d.dim_style(&b.style).cloned().unwrap_or_default().with_overrides(&b.overrides); - let spacing = - p.get("spacing").and_then(Value::as_f64).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(st.baseline_spacing * st.scale.max(1e-9) * 2.0); + let spacing = p + .get("spacing") + .and_then(Value::as_f64) + .filter(|v| v.is_finite() && *v >= 0.0) + .unwrap_or(st.baseline_spacing * st.effective_scale(d.header.f64("DIMSCALE", 1.0)) * 2.0); let dir = match b.kind { DimKind::Linear { rotation } => Vec2::from_angle(rotation), DimKind::Aligned => (b.p14.xy() - b.p13.xy()).normalized(), diff --git a/crates/engine/tests/dim_baseline_spacing.rs b/crates/engine/tests/dim_baseline_spacing.rs new file mode 100644 index 00000000..78fb14c9 --- /dev/null +++ b/crates/engine/tests/dim_baseline_spacing.rs @@ -0,0 +1,48 @@ +//! DIMBASELINE spaces dimension lines by DIMDLI × the overall scale of the dimension it +//! continues: SETVAR DIM* overrides count, and DIMSCALE 0 falls back to the drawing's DIMSCALE +//! (issue #364). DIMSPACE's default spacing uses the same scale. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Handle}; +use serde_json::{Value, json}; + +fn dim_line_y(s: &Session, h: &Value) -> f64 { + let h = Handle::parse_hex(h.as_str().unwrap()).unwrap(); + match &s.doc().unwrap().entity(h).unwrap().kind { + EntityKind::Dimension(dm) => dm.defpt.y, + k => panic!("not a dimension: {k:?}"), + } +} + +/// Spacing between a horizontal dimension with its line at y = 5 and the baseline one after it. +fn baseline_spacing(setup: &[(&str, Value)]) -> f64 { + let mut s = Session::new(); + for (cmd, p) in setup { + s.execute(cmd, p).unwrap(); + } + let first = s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [10, 0], "at": [5, 5]})).unwrap(); + let next = s.execute("dimbaseline", &json!({"points": [[20, 0]]})).unwrap(); + dim_line_y(&s, &next["handles"][0]) - dim_line_y(&s, &first["handle"]) +} + +#[test] +fn baseline_spacing_follows_overrides_and_dimscale() { + let near = |a: f64, b: f64| (a - b).abs() < 1e-9; + let plain = baseline_spacing(&[]); + assert!(near(plain, 0.38), "{plain}"); + let scaled = baseline_spacing(&[("setvar", json!({"name": "DIMSCALE", "value": 10}))]); + assert!(near(scaled, 3.8), "SETVAR DIMSCALE scales the spacing: {scaled}"); + let dli = baseline_spacing(&[("setvar", json!({"name": "DIMDLI", "value": 1}))]); + assert!(near(dli, 1.0), "SETVAR DIMDLI sets the spacing: {dli}"); + let zero = baseline_spacing(&[("dimstyle", json!({"name": "Standard", "DIMSCALE": 0}))]); + assert!(near(zero, 0.38), "DIMSCALE 0 in model space is scale 1, not a collapsed spacing: {zero}"); + + // DIMSPACE's default spacing (twice DIMDLI) uses the same scale. + let mut s = Session::new(); + s.execute("dimstyle", &json!({"name": "Standard", "DIMSCALE": 0})).unwrap(); + let a = s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [30, 0], "at": [15, 5]})).unwrap(); + let b = s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [50, 0], "at": [25, 6]})).unwrap(); + s.execute("dimspace", &json!({"base": a["handle"], "handles": [b["handle"]]})).unwrap(); + let gap = dim_line_y(&s, &b["handle"]) - dim_line_y(&s, &a["handle"]); + assert!(near(gap, 0.76), "{gap}"); +} From a119873e208e6c3de23ba4ee335873ed13fb970b Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 00:08:55 +0200 Subject: [PATCH 120/150] Angular dimensions keep each leg's extension line; centre mark only outside (fixes #374) (#377) When the arc location lies outside the sweep from the first leg to the second, the arc runs from leg 2 to leg 1. The extension lines, DIMSE1/DIMSE2 and DIMBLK1/DIMBLK2 still took the arc start as leg 1, so each extension line was sized from the other leg. A short leg got no extension line, a leg already crossing the arc got one drawn along it, and DIMSE1/DIMSE2 suppressed the wrong line. The arc's ends are now paired with their legs. Radius and diameter dimensions drew the DIMCEN mark even when the dimension line is inside the circle. As in AutoCAD, the mark is now drawn only when the line is outside: a radius led out to its text. A diameter line runs through the centre, so it gets no mark. Co-authored-by: Claude Opus 5.5 --- crates/render/src/dim.rs | 19 ++++-- crates/render/tests/dim_angular_centre.rs | 77 +++++++++++++++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 crates/render/tests/dim_angular_centre.rs diff --git a/crates/render/src/dim.rs b/crates/render/src/dim.rs index 809abd2c..8799ca4c 100644 --- a/crates/render/src/dim.rs +++ b/crates/render/src/dim.rs @@ -558,7 +558,10 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font text_mid = tpos + u * (tw / 2.0 + gap); } } - if st.center_mark != 0.0 && st.center_mark.is_finite() { + // The DIMCEN mark is drawn only when the dimension line is outside the circle: a + // radius led out to its text, never a diameter line through the centre. + let line_outside = radius && tpos.dist(center) >= r; + if line_outside && st.center_mark != 0.0 && st.center_mark.is_finite() { let c = st.center_mark.abs() * k; g.ext(vec![center - Vec2::X * c, center + Vec2::X * c]); g.ext(vec![center - Vec2::Y * c, center + Vec2::Y * c]); @@ -592,15 +595,21 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font g.dim(pts); let sp = arc.start_point(); let ep = arc.end_point(); + // The arc's ends with the outward tangents there, paired with leg 1 and leg 2: when + // the arc location is outside the leg 1 → leg 2 sweep the arc runs from leg 2 to + // leg 1, and each leg keeps its own extension line, DIMSE1/2 and DIMBLK1/2. + let (start, finish) = ((sp, -(sp - vertex).perp().normalized()), (ep, (ep - vertex).perp().normalized())); + let ((p1, dir1), (p2, dir2)) = + if cadcraft_geom::angle_in_sweep(vertex.angle_to(arc_pt), s, e) { (start, finish) } else { (finish, start) }; if !st.suppress_ext1 { - g.lines.extend(ext_line(vertex + (sp - vertex).normalized() * vertex.dist(a1).min(r), sp, st, k)); + g.lines.extend(ext_line(vertex + (p1 - vertex).normalized() * vertex.dist(a1).min(r), p1, st, k)); } if !st.suppress_ext2 { - g.lines.extend(ext_line(vertex + (ep - vertex).normalized() * vertex.dist(a2).min(r), ep, st, k)); + g.lines.extend(ext_line(vertex + (p2 - vertex).normalized() * vertex.dist(a2).min(r), p2, st, k)); } g.line_roles.resize(g.lines.len(), LineRole::Ext); - end(&mut g, blk1, sp, -(sp - vertex).perp().normalized()); - end(&mut g, blk2, ep, (ep - vertex).perp().normalized()); + end(&mut g, blk1, p1, dir1); + end(&mut g, blk2, p2, dir2); let (tw, tht) = measure(&g.mtext); let mid = arc.mid_point(); let radial = (mid - vertex).normalized(); diff --git a/crates/render/tests/dim_angular_centre.rs b/crates/render/tests/dim_angular_centre.rs new file mode 100644 index 00000000..8981df63 --- /dev/null +++ b/crates/render/tests/dim_angular_centre.rs @@ -0,0 +1,77 @@ +//! Angular extension lines stay with their legs when the arc is on the other side, and the +//! DIMCEN centre mark is drawn only when the dimension line is outside the circle (issue #374). + +use cadcraft_doc::{DimKind, DimStyle, Dimension}; +use cadcraft_geom::{Vec2, Vec3}; +use cadcraft_render::{LineRole, dimension_geometry}; + +fn dim(kind: DimKind) -> Dimension { + Dimension { + kind, + defpt: Vec3::ZERO, + text_mid: Vec3::ZERO, + p13: Vec3::ZERO, + p14: Vec3::ZERO, + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 0.0, + text_rotation: 0.0, + user_text_pos: false, + block: None, + overrides: Default::default(), + assoc: Vec::new(), + } +} + +fn ext_lines(d: &Dimension, st: &DimStyle) -> Vec> { + dimension_geometry(d, st, 1.0).lines_of(LineRole::Ext).cloned().collect() +} + +fn near(a: Vec2, b: Vec2) -> bool { + a.dist(b) < 1e-9 +} + +#[test] +fn angular_extension_lines_follow_their_legs_when_the_arc_is_swapped() { + // Vertex 0,0; leg 1 to 10,0 (reaches past the arc), leg 2 to 0,2 (short of it); the arc + // location -3,-3 is outside the 0°→90° sweep, so the 270° arc runs from leg 2 to leg 1. + let mut a = dim(DimKind::Angular3P); + a.p13 = Vec3::new(10.0, 0.0, 0.0); + a.p14 = Vec3::new(0.0, 2.0, 0.0); + a.defpt = Vec3::new(-3.0, -3.0, 0.0); + let st = DimStyle::default(); + assert_eq!(dimension_geometry(&a, &st, 1.0).value, "270°"); + let r = 18f64.sqrt(); + // Only leg 2 needs an extension line: from DIMEXO past 0,2 to DIMEXE past the arc. + let lines = ext_lines(&a, &st); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert!(near(lines[0][0], Vec2::new(0.0, 2.0 + st.ext_offset)) && near(lines[0][1], Vec2::new(0.0, r + st.ext_extend)), "{lines:?}"); + // DIMSE1 belongs to leg 1 (which has none), DIMSE2 to leg 2. + assert_eq!(ext_lines(&a, &DimStyle { suppress_ext1: true, ..DimStyle::default() }), lines); + assert!(ext_lines(&a, &DimStyle { suppress_ext2: true, ..DimStyle::default() }).is_empty()); + // DIMBLK1 sits on leg 1's end of the arc. + let dot1 = DimStyle { arrow_block1: "_DOT".into(), arrow_block2: "_NONE".into(), ..DimStyle::default() }; + let g = dimension_geometry(&a, &dot1, 1.0); + assert!(!g.fills.is_empty() && g.fills.iter().flatten().all(|p| p.dist(Vec2::new(r, 0.0)) < st.arrow_size), "{:?}", g.fills.first()); +} + +#[test] +fn centre_mark_only_when_the_dimension_line_is_outside_the_circle() { + let st = DimStyle::default(); + // Radius led out to its text: the mark is drawn. + let mut rad = dim(DimKind::Radius); + rad.p15 = Vec3::new(5.0, 0.0, 0.0); + assert_eq!(ext_lines(&rad, &st).len(), 2, "centre mark of an outside radius dimension"); + // Text moved inside the circle: the dimension line is inside, no mark. + rad.user_text_pos = true; + rad.text_mid = Vec3::new(2.0, 0.0, 0.0); + assert!(ext_lines(&rad, &st).is_empty()); + // A diameter line runs through the centre: no mark, nor centre lines (negative DIMCEN). + let mut dia = dim(DimKind::Diameter); + dia.defpt = Vec3::new(-5.0, 0.0, 0.0); + dia.p15 = Vec3::new(5.0, 0.0, 0.0); + assert!(ext_lines(&dia, &st).is_empty()); + assert!(ext_lines(&dia, &DimStyle { center_mark: -0.5, ..DimStyle::default() }).is_empty()); +} From 2f4ceea7a99a86778c4a4f0845d67aade59b1f36 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 00:08:59 +0200 Subject: [PATCH 121/150] Dimension text: DIMRND rounds only the measurement; no DIMPOST on angles (fixes #373) (#376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tolerances and alternate units copied the primary number format, so they were rounded by DIMRND and took DIMZIN's zero suppression: DIMRND 0.5 turned a ±0.1 tolerance into ±0.0000, and 10.3 in showed as [261.50] mm. Tolerances are no longer rounded and use DIMTZIN. Alternate units use the new DIMALTRND (rounding), DIMALTU (unit format) and DIMALTZ (zero suppression). The new variables can be set with SETVAR, DIMSTYLE and overrides. A DXF keeps them as DIMSTYLE group codes 148, 273, 284 and 285. DIMPOST is a prefix/suffix for linear dimensions, so angular dimensions no longer get it ("90°mm"). A text override still wraps the angle. Co-authored-by: Claude Opus 5.5 --- crates/doc/src/tables.rs | 16 ++++++ crates/io/src/dxf_ext.rs | 4 ++ crates/io/src/tests.rs | 4 ++ crates/render/src/dim.rs | 21 ++++++-- crates/render/tests/dim_text_format.rs | 73 ++++++++++++++++++++++++++ 5 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 crates/render/tests/dim_text_format.rs diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index dad7b917..89b4e871 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -212,11 +212,19 @@ pub struct DimStyle { pub tol_decimals: u8, /// DIMTFAC tolerance text height relative to the dimension text. pub tol_scale: f64, + /// DIMTZIN zero suppression of tolerance values (DIMZIN bits). + pub tol_zero_suppression: u8, /// DIMALT alternate units, DIMALTF factor, DIMALTD decimals, DIMAPOST template. pub alt: bool, pub alt_factor: f64, pub alt_decimals: u8, pub alt_post: String, + /// DIMALTRND rounding increment of alternate units (0 = none); DIMRND doesn't apply to them. + pub alt_round: f64, + /// DIMALTU: 1 sci, 2 dec, 3 eng, 4 arch stacked, 5 frac stacked, 6 arch, 7 frac, 8 windows. + pub alt_unit: u8, + /// DIMALTZ zero suppression of alternate units (DIMZIN bits). + pub alt_zero_suppression: u8, /// DIMAUNIT: 0 decimal degrees, 1 deg/min/sec, 2 grads, 3 radians. pub angular_unit: u8, /// DIMSE1 / DIMSE2 suppress extension lines. @@ -262,10 +270,14 @@ pub const DIMVARS: &[(&str, &str)] = &[ ("DIMLIM", "limits"), ("DIMTDEC", "tolDecimals"), ("DIMTFAC", "tolScale"), + ("DIMTZIN", "tolZeroSuppression"), ("DIMALT", "alt"), ("DIMALTF", "altFactor"), ("DIMALTD", "altDecimals"), ("DIMAPOST", "altPost"), + ("DIMALTRND", "altRound"), + ("DIMALTU", "altUnit"), + ("DIMALTZ", "altZeroSuppression"), ("DIMAUNIT", "angularUnit"), ("DIMSE1", "suppressExt1"), ("DIMSE2", "suppressExt2"), @@ -312,10 +324,14 @@ impl Default for DimStyle { limits: false, tol_decimals: 4, tol_scale: 1.0, + tol_zero_suppression: 0, alt: false, alt_factor: 25.4, alt_decimals: 2, alt_post: String::new(), + alt_round: 0.0, + alt_unit: 2, + alt_zero_suppression: 0, angular_unit: 0, suppress_ext1: false, suppress_ext2: false, diff --git a/crates/io/src/dxf_ext.rs b/crates/io/src/dxf_ext.rs index c6bc3dda..2bb1831c 100644 --- a/crates/io/src/dxf_ext.rs +++ b/crates/io/src/dxf_ext.rs @@ -68,6 +68,7 @@ pub(crate) const DIM_CODES: &[(&str, i32, K)] = &[ ("linearFactor", 144, K::Real), ("tolScale", 146, K::Real), ("textGap", 147, K::Real), + ("altRound", 148, K::Real), ("tolerance", 71, K::Bool), ("limits", 72, K::Bool), ("textInsideHorizontal", 73, K::Bool), @@ -84,11 +85,14 @@ pub(crate) const DIM_CODES: &[(&str, i32, K)] = &[ ("angularDecimals", 179, K::Int), ("decimals", 271, K::Int), ("tolDecimals", 272, K::Int), + ("altUnit", 273, K::Int), ("angularUnit", 275, K::Int), ("fractionFormat", 276, K::Int), ("linearUnit", 277, K::Int), ("decimalSeparator", 278, K::Char), ("textJust", 280, K::Int), + ("tolZeroSuppression", 284, K::Int), + ("altZeroSuppression", 285, K::Int), ("textStyle", 340, K::TextStyle), ("arrowBlock", 342, K::Block), ("arrowBlock1", 343, K::Block), diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index c6516736..fc865d08 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -687,10 +687,14 @@ fn full_dim_style() -> DimStyle { limits: true, tol_decimals: 2, tol_scale: 0.75, + tol_zero_suppression: 8, alt: true, alt_factor: 0.03937, alt_decimals: 3, alt_post: "[<>]".into(), + alt_round: 0.005, + alt_unit: 6, + alt_zero_suppression: 4, angular_unit: 1, suppress_ext1: true, suppress_ext2: true, diff --git a/crates/render/src/dim.rs b/crates/render/src/dim.rs index 8799ca4c..de7d1b93 100644 --- a/crates/render/src/dim.rs +++ b/crates/render/src/dim.rs @@ -1,7 +1,8 @@ //! Dimension geometry generation from definition points and a dimension style: extension //! lines (DIMEXO/DIMEXE, DIMSE1/2), arrowheads (DIMBLK/DIMBLK1/DIMBLK2, drawn in code) or ticks //! (DIMTSZ, DIMDLE), text placement (DIMTAD, DIMJUST, DIMTIH/DIMTOH, DIMGAP) and text -//! formatting (DIMLUNIT, DIMDEC, DIMZIN, DIMRND, DIMPOST, DIMTOL/DIMLIM, DIMALT). +//! formatting (DIMLUNIT, DIMDEC, DIMZIN, DIMRND, DIMPOST, DIMTOL/DIMLIM with DIMTZIN, DIMALT with +//! DIMALTU/DIMALTZ/DIMALTRND). use cadcraft_doc::{DimKind, DimStyle, Dimension}; use cadcraft_fonts::{MTextParams, TextFont}; @@ -273,7 +274,9 @@ pub fn format_linear_value(v: f64, st: &DimStyle) -> String { } /// The text of a linear measurement: (plain, MTEXT) with DIMPOST, tolerances/limits and -/// alternate units, then the user's override text (`<>` = the measurement). +/// alternate units, then the user's override text (`<>` = the measurement). DIMRND rounds the +/// measurement (and so the limits) only: tolerances are never rounded and alternate units +/// round by DIMALTRND. fn linear_text(meas: f64, d: &Dimension, st: &DimStyle, prefix: &str) -> String { let v = meas * st.linear_factor; let nf = NumFormat::from_style(st); @@ -286,7 +289,7 @@ fn linear_text(meas: f64, d: &Dimension, st: &DimStyle, prefix: &str) -> String }; let mut s = post(&st.post, &main); if st.tolerance && !st.limits { - let tf = NumFormat { decimals: st.tol_decimals, ..nf.clone() }; + let tf = NumFormat { decimals: st.tol_decimals, zin: st.tol_zero_suppression, round: 0.0, ..nf.clone() }; if (st.tol_plus - st.tol_minus).abs() < 1e-12 { s += &format!("%%p{}", format_linear(st.tol_plus, &tf).1); } else { @@ -299,7 +302,14 @@ fn linear_text(meas: f64, d: &Dimension, st: &DimStyle, prefix: &str) -> String } } if st.alt { - let af = NumFormat { decimals: st.alt_decimals, unit: 2, ..nf }; + // DIMALTU 6 and 7 are architectural and fractional without stacking, 8 is decimal. + let (unit, frac) = match st.alt_unit { + u @ 1..=5 => (u, nf.frac), + 6 => (4, 2), + 7 => (5, 2), + _ => (2, nf.frac), + }; + let af = NumFormat { unit, decimals: st.alt_decimals, zin: st.alt_zero_suppression, round: st.alt_round, frac, ..nf }; let alt = format_linear(v * st.alt_factor, &af).1; s += &format!(" [{}]", post(&st.alt_post, &alt)); } @@ -589,7 +599,8 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font if cadcraft_geom::angle_in_sweep(vertex.angle_to(arc_pt), s, e) { Arc::new(vertex, r, s, e) } else { Arc::new(vertex, r, e, s) }; let sweep = ccw_sweep(arc.start, arc.end); let txt = format_angle(sweep, st.angular_unit, st.angular_decimals, st.zero_suppression & 12); - g.mtext = apply_override(&post(&st.post, &txt), d); + // DIMPOST is a linear-dimension prefix/suffix; angles have their own format only. + g.mtext = apply_override(&txt, d); let mut pts = Vec::new(); arc.tessellate(r * 1e-3, &mut pts); g.dim(pts); diff --git a/crates/render/tests/dim_text_format.rs b/crates/render/tests/dim_text_format.rs new file mode 100644 index 00000000..79d03027 --- /dev/null +++ b/crates/render/tests/dim_text_format.rs @@ -0,0 +1,73 @@ +//! Dimension text formatting (issue #373): DIMRND rounds the measurement only, tolerances use +//! DIMTZIN, alternate units DIMALTRND/DIMALTU/DIMALTZ, and DIMPOST is not added to angles. + +use cadcraft_doc::{DimKind, DimStyle, Dimension}; +use cadcraft_geom::Vec3; +use cadcraft_render::dimension_geometry; + +fn linear(len: f64) -> Dimension { + Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(0.0, 5.0, 0.0), + text_mid: Vec3::ZERO, + p13: Vec3::ZERO, + p14: Vec3::new(len, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 0.0, + text_rotation: 0.0, + user_text_pos: false, + block: None, + overrides: Default::default(), + assoc: Vec::new(), + } +} + +fn text(d: &Dimension, vars: serde_json::Value) -> String { + let mut st = DimStyle::default(); + let rejected = st.apply_fields(vars.as_object().unwrap()); + assert!(rejected.is_empty(), "unknown variables {rejected:?}"); + dimension_geometry(d, &st, 1.0).value +} + +#[test] +fn dimrnd_rounds_neither_tolerances_nor_alternate_units() { + let d = linear(10.3); + let tol = serde_json::json!({"DIMRND": 0.5, "DIMDEC": 2, "DIMTOL": 1, "DIMTP": 0.1, "DIMTM": 0.1, "DIMTDEC": 2}); + assert_eq!(text(&d, tol), "10.50±0.10"); + let dev = serde_json::json!({"DIMRND": 0.5, "DIMDEC": 2, "DIMTOL": 1, "DIMTP": 0.1, "DIMTM": 0.2, "DIMTDEC": 2}); + assert_eq!(text(&d, dev), "10.50+0.10/-0.20"); + // Tolerance zeros follow DIMTZIN, not DIMZIN. + let tzin = serde_json::json!({"DIMZIN": 8, "DIMDEC": 2, "DIMTOL": 1, "DIMTP": 0.1, "DIMTM": 0.1, "DIMTDEC": 2}); + assert_eq!(text(&d, tzin.clone()), "10.3±0.10"); + let mut tzin8 = tzin; + tzin8["DIMTZIN"] = 8.into(); + assert_eq!(text(&d, tzin8), "10.3±0.1"); + + // 10.3 in = 261.62 mm: DIMRND 0.5 rounds the primary value only. + assert_eq!(text(&d, serde_json::json!({"DIMRND": 0.5, "DIMDEC": 1, "DIMALT": 1})), "10.5 [261.62]"); + // DIMALTRND rounds the alternate value. + assert_eq!(text(&d, serde_json::json!({"DIMDEC": 1, "DIMALT": 1, "DIMALTRND": 0.5})), "10.3 [261.50]"); + // DIMALTZ, not DIMZIN, suppresses alternate zeros. + assert_eq!(text(&linear(10.0), serde_json::json!({"DIMZIN": 8, "DIMALT": 1})), "10 [254.00]"); + assert_eq!(text(&linear(10.0), serde_json::json!({"DIMALT": 1, "DIMALTZ": 8})), "10.0000 [254]"); + // DIMALTU: alternate units in their own format (7 = fractional, not stacked). + assert_eq!(text(&linear(2.0), serde_json::json!({"DIMALT": 1, "DIMALTF": 0.25, "DIMALTU": 7, "DIMALTD": 2})), "2.0000 [1/2]"); +} + +#[test] +fn dimpost_is_not_added_to_angular_dimensions() { + let mut a = linear(0.0); + a.kind = DimKind::Angular3P; + a.p15 = Vec3::ZERO; + a.p13 = Vec3::new(10.0, 0.0, 0.0); + a.p14 = Vec3::new(0.0, 10.0, 0.0); + a.defpt = Vec3::new(3.0, 3.0, 0.0); + assert_eq!(text(&a, serde_json::json!({"DIMPOST": "<>mm"})), "90°"); + // Linear dimensions keep it, and an angular text override still wraps the angle. + assert_eq!(text(&linear(10.0), serde_json::json!({"DIMPOST": "<>mm", "DIMDEC": 1})), "10.0mm"); + a.text = "<> TYP".into(); + assert_eq!(text(&a, serde_json::json!({"DIMPOST": "<>mm"})), "90° TYP"); +} From 1418130d45bad333ff1efc18a8b713ae342b76ff Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 00:09:05 +0200 Subject: [PATCH 122/150] DIMRADIUS/DIMDIAMETER: a location inside the circle puts the text inside (fixes #385) (#389) Only the direction of the picked dimension line location was used, so the text always went outside beyond the arrowhead. A location inside the circle (typed, picked or JSON `at`) now places the text there: the dimension stores it as its text position, and the dimension line runs from the text out to the arrowhead; a diameter's spans the circle, broken around the text. DIMTIH (not DIMTOH) decides whether inside text is horizontal. Outside locations keep the default placement. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/annotate.rs | 30 +++++++++++--- crates/engine/tests/dim_radial_inside.rs | 52 ++++++++++++++++++++++++ crates/render/src/dim.rs | 35 ++++++++++++---- 3 files changed, 104 insertions(+), 13 deletions(-) create mode 100644 crates/engine/tests/dim_radial_inside.rs diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index 9b235d4e..f69ed7ff 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -232,13 +232,14 @@ fn circle_of(s: &Session, h: Handle) -> Option<(Vec2, f64)> { fn radial(s: &mut Session, p: &Value, diameter: bool) -> Result { let id = if diameter { "dimdiameter" } else { "dimradius" }; - let (c, pt) = if let (Some(c), Some(pt)) = (point_param(p, "center"), point_param(p, "point")) { - (c, pt) + let (c, pt, at) = if let (Some(c), Some(pt)) = (point_param(p, "center"), point_param(p, "point")) { + (c, pt, None) } else { let h = targets(s, p)?.first().copied().ok_or_else(|| bad(id, "`handle` of a circle/arc, or {center, point}"))?; let (c, r) = circle_of(s, h).ok_or_else(|| bad(id, "object is not a circle or arc"))?; - let at = point_param(p, "at").unwrap_or(c + Vec2::from_angle(std::f64::consts::FRAC_PI_4)); - (c, c + (at - c).normalized() * r) + let at = point_param(p, "at"); + let dir = at.unwrap_or(c + Vec2::from_angle(std::f64::consts::FRAC_PI_4)); + (c, c + (dir - c).normalized() * r, at) }; if c.near(pt, 1e-12) { return Err(bad(id, "`point` must differ from `center`")); @@ -248,9 +249,25 @@ fn radial(s: &mut Session, p: &Value, diameter: bool) -> Result { } else { dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "") }; + let k = match at { + Some(at) => text_inside(k, c, c.dist(pt), at), + None => k, + }; Ok(json!({ "handle": add_dim(s, k)?.hex() })) } +/// A radius/diameter dimension line location picked inside the circle puts the text there, on +/// the dimension line (outside, the text keeps its default place beyond the arrowhead). +fn text_inside(mut k: EntityKind, c: Vec2, r: f64, at: Vec2) -> EntityKind { + if let EntityKind::Dimension(d) = &mut k + && at.dist(c) < r + { + d.text_mid = v3(at); + d.user_text_pos = true; + } + k +} + fn run_radius(s: &mut Session, p: &Value) -> Result { radial(s, p, false) } @@ -1018,11 +1035,12 @@ impl RadialM { } fn kind(&self, s: &Session, c: Vec2, r: f64, at: Vec2) -> EntityKind { let pt = c + (at - c).normalized() * r; - self.text.apply(if self.diameter { + let k = self.text.apply(if self.diameter { dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "") } else { dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, Vec2::ZERO, "") - }) + }); + text_inside(k, c, r, at) } } diff --git a/crates/engine/tests/dim_radial_inside.rs b/crates/engine/tests/dim_radial_inside.rs new file mode 100644 index 00000000..d16dac03 --- /dev/null +++ b/crates/engine/tests/dim_radial_inside.rs @@ -0,0 +1,52 @@ +//! DIMRADIUS / DIMDIAMETER: a dimension line location picked inside the circle puts the text +//! there, on the dimension line; outside, the text stays beyond the arrowhead. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Dimension, EntityKind}; +use cadcraft_engine::geom::Vec2; +use cadcraft_engine::render::{DimGeometry, LineRole}; +use serde_json::json; + +fn last_dim(s: &Session) -> (Dimension, DimGeometry) { + let d = s.doc().unwrap(); + match &d.model.last().unwrap().kind { + EntityKind::Dimension(dm) => (dm.clone(), cadcraft_engine::render::dimension_in(d, dm)), + other => panic!("not a dimension: {other:?}"), + } +} + +/// Whether a dimension line passes over `p` (on the x axis). +fn covers(g: &DimGeometry, x: f64) -> bool { + g.lines_of(LineRole::Dim) + .any(|l| l.windows(2).any(|w| w[0].y.abs() < 1e-9 && w[1].y.abs() < 1e-9 && w[0].x.min(w[1].x) < x && x < w[0].x.max(w[1].x))) +} + +#[test] +fn radial_text_goes_inside_when_the_location_is_inside() { + let mut s = Session::new(); + s.execute("circle", &json!({"center": [0, 0], "radius": 10})).unwrap(); + + // Typed: pick the circle, then a location inside. + s.script("dimradius 10,0 4,0\n").unwrap(); + assert!(s.running.is_none()); + let (dm, g) = last_dim(&s); + assert!(dm.user_text_pos); + assert!(g.text_pos.dist(Vec2::new(4.0, 0.0)) < 1e-9, "{:?}", g.text_pos); + // The dimension line runs from beyond the text to the arrowhead and stays inside the circle. + assert!(covers(&g, 9.0) && !covers(&g, 4.0) && !covers(&g, 1.0)); + assert!(g.lines_of(LineRole::Dim).flatten().all(|p| p.len() <= 10.0 + 1e-9)); + + // Outside: the default placement beyond the arrowhead. + s.script("dimradius 0,10 0,15\n").unwrap(); + assert!(s.running.is_none()); + let (dm, g) = last_dim(&s); + assert!(!dm.user_text_pos); + assert!(g.text_pos.y > 10.0, "{:?}", g.text_pos); + + // JSON form, diameter: the line spans the circle, broken around the text. + let c = s.doc().unwrap().model.iter().next().unwrap().handle.hex(); + s.execute("dimdiameter", &json!({"handle": c, "at": [-3, 0]})).unwrap(); + let (_, g) = last_dim(&s); + assert!(g.text_pos.dist(Vec2::new(-3.0, 0.0)) < 1e-9, "{:?}", g.text_pos); + assert!(covers(&g, -9.5) && covers(&g, 9.5) && !covers(&g, -3.0)); +} diff --git a/crates/render/src/dim.rs b/crates/render/src/dim.rs index de7d1b93..4d02c3b0 100644 --- a/crates/render/src/dim.rs +++ b/crates/render/src/dim.rs @@ -544,19 +544,40 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font let (center, r) = if radius { (a, a.dist(b)) } else { (a.mid(b), a.dist(b) / 2.0) }; let meas = if radius { r } else { 2.0 * r }; g.mtext = linear_text(meas, d, st, if radius { "R" } else { "%%c" }); - let (tw, _) = measure(&g.mtext); + let (tw, tht) = measure(&g.mtext); let u = (b - center).normalized(); let u = if u == Vec2::ZERO || !u.is_finite() { Vec2::X } else { u }; let tip = center + u * r; let tpos = if d.user_text_pos { text_mid } else { tip + u * (asz.max(tsz) * 3.0) }; - if !radius { - let start = center - u * r; - g.dim(vec![start, tip]); - end(&mut g, blk1, start, -u); + // Text placed inside the circle (a dimension line location picked inside): the text + // sits on the dimension line, which runs from the text out to the arrowhead (for a + // diameter it spans the circle, broken around the text). DIMTIH turns it horizontal. + let inside = d.user_text_pos && text_mid.dist(center) < r; + let horizontal = if inside { st.text_inside_horizontal } else { st.text_outside_horizontal }; + if inside { + let tr = Vec2::from_angle(if horizontal { 0.0 } else { readable(u.angle()) }); + let half = if tw > 0.0 { (tw * tr.dot(u).abs() + tht * tr.perp().dot(u).abs()) / 2.0 + gap } else { 0.0 }; + let start = if radius { center } else { center - u * r }; + let (t0, t1) = ((text_mid - start).dot(u) - half, (text_mid - start).dot(u) + half); + if !radius { + if t0 > 0.0 { + g.dim(vec![start, start + u * t0]); + } + end(&mut g, blk1, start, -u); + } + if t1 < tip.dist(start) { + g.dim(vec![start + u * t1.max(0.0), tip]); + } + } else { + if !radius { + let start = center - u * r; + g.dim(vec![start, tip]); + end(&mut g, blk1, start, -u); + } + g.dim(vec![tip, tpos]); } - g.dim(vec![tip, tpos]); end(&mut g, blk2, tip, u); - if st.text_outside_horizontal { + if horizontal { text_angle = 0.0; let sx = if u.x >= 0.0 { 1.0 } else { -1.0 }; if !d.user_text_pos { From 409968ecbb4d052225a4fc85948de2c15b6862f2 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 00:09:10 +0200 Subject: [PATCH 123/150] DIMTEDIT: command-line prompts (fixes #382) (#383) Typed at the command line or run from a script, DIMTEDIT ran the JSON form at once and failed with "select dimensions". It now asks "Select dimension", then "Specify new location for dimension text or [Left/Right/Center/Home/Angle]": a point moves the text there (the text follows the cursor in the preview), Left/Right/Center justify it, Home restores the default position and Angle asks for the text angle. A single preselected dimension is taken at once. Each answer runs the existing JSON form on the picked dimension. DIMTED is its alias. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/annotate.rs | 7 +- crates/engine/src/cmd/annotate_tedit.rs | 100 ++++++++++++++++++++++++ crates/engine/tests/dimtedit_prompts.rs | 49 ++++++++++++ 3 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/cmd/annotate_tedit.rs create mode 100644 crates/engine/tests/dimtedit_prompts.rs diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index f69ed7ff..683c2e7e 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -99,7 +99,9 @@ pub fn specs() -> Vec { .params("{handles?}"), CommandSpec::new("dimtedit.right", "Right", |s, p| run_tedit(s, p, "right")).menu(&["Dimension", "Align Text", "Right"]).params("{handles?}"), CommandSpec::new("dimtedit", "Dimension Text Edit", run_dimtedit) - .params("{handles?, at?: [x,y], mode?: home|angle|left|center|right, angle?}"), + .alias(&["dimted"]) + .params("{handles?, at?: [x,y], mode?: home|angle|left|center|right, angle?}") + .interactive(|_| Ok(Box::new(tedit::TEditM::default()))), CommandSpec::new("dimspace", "Dimension Space", run_dimspace) .menu(&["Dimension", "Dimension Space"]) .params("{base: hex, handles: [hex], spacing?: number (default DIMDLI)}"), @@ -1684,6 +1686,9 @@ impl Interactive for MLeaderM { } } +#[path = "annotate_tedit.rs"] +mod tedit; + #[cfg(test)] #[path = "annotate_tests.rs"] mod tests; diff --git a/crates/engine/src/cmd/annotate_tedit.rs b/crates/engine/src/cmd/annotate_tedit.rs new file mode 100644 index 00000000..8d3c02fc --- /dev/null +++ b/crates/engine/src/cmd/annotate_tedit.rs @@ -0,0 +1,100 @@ +//! DIMTEDIT at the command line: "Select dimension", then a new text location or +//! Left/Right/Center/Home/Angle. Each answer runs the JSON form on the picked dimension. + +use cadcraft_doc::{EntityKind, Handle}; +use cadcraft_geom::Vec2; +use serde_json::{Value, json}; + +use super::{dims_of, run_dimtedit, v3}; +use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; + +#[derive(Default)] +pub(super) struct TEditM { + dim: Option, + asking_angle: bool, +} + +impl TEditM { + fn run(&self, s: &mut Session, mut p: Value) -> Result { + let Some(h) = self.dim else { return Ok(Step::Cancel) }; + if let Some(o) = p.as_object_mut() { + o.insert("handles".into(), json!([h.hex()])); + } + run_dimtedit(s, &p)?; + Ok(Step::Done) + } +} + +impl Interactive for TEditM { + fn name(&self) -> &'static str { + "DIMTEDIT" + } + fn begin(&mut self, s: &mut Session) -> Result { + // A single preselected dimension is taken at once. + if s.settings.pickfirst + && let [h] = dims_of(s, &s.selection())[..] + { + self.dim = Some(h); + } + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + if self.dim.is_none() { + Prompt::new("Select dimension", Accept::SELECT) + } else if self.asking_angle { + Prompt::new("Specify angle for dimension text", Accept::NUMBER) + } else { + Prompt::new("Specify new location for dimension text", Accept::POINT).kw(&["Left", "Right", "Center", "Home", "Angle"]) + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.dim.is_none() { + return Ok(match i { + Input::Pick(hs) => { + match dims_of(s, &hs).first() { + Some(h) => self.dim = Some(*h), + None if hs.is_empty() => {} + None => s.echo("Object selected is not a dimension."), + } + Step::Continue + } + Input::Enter | Input::Cancel => Step::Cancel, + _ => Step::Continue, + }); + } + if self.asking_angle { + return match i { + Input::Text(t) => match s.angle_settings().direction(&t) { + Some(a) => self.run(s, json!({ "mode": "angle", "angle": a.to_degrees() })), + None => { + s.echo("Requires a valid angle."); + Ok(Step::Continue) + } + }, + Input::Enter | Input::Cancel => Ok(Step::Cancel), + _ => Ok(Step::Continue), + }; + } + match i { + Input::Point(p) => self.run(s, json!({ "at": [p.x, p.y] })), + Input::Keyword(k) if k == "Angle" => { + self.asking_angle = true; + Ok(Step::Continue) + } + Input::Keyword(k) => self.run(s, json!({ "mode": k.to_ascii_lowercase() })), + Input::Enter | Input::Cancel => Ok(Step::Cancel), + _ => Ok(Step::Continue), + } + } + /// The text follows the cursor. + fn preview(&self, s: &Session, c: Vec2) -> Vec { + if self.asking_angle { + return Vec::new(); + } + let Some(EntityKind::Dimension(mut d)) = self.dim.and_then(|h| s.doc().ok()?.entity(h).map(|e| e.kind.clone())) else { return Vec::new() }; + d.text_mid = v3(c); + d.user_text_pos = true; + d.block = None; + vec![EntityKind::Dimension(d)] + } +} diff --git a/crates/engine/tests/dimtedit_prompts.rs b/crates/engine/tests/dimtedit_prompts.rs new file mode 100644 index 00000000..38cbae7c --- /dev/null +++ b/crates/engine/tests/dimtedit_prompts.rs @@ -0,0 +1,49 @@ +//! DIMTEDIT at the command line and in scripts: "Select dimension", then a new text location or +//! Left/Right/Center/Home/Angle. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Dimension, EntityKind}; +use cadcraft_engine::geom::Vec2; + +fn the_dim(s: &Session) -> Dimension { + s.doc() + .unwrap() + .model + .iter() + .find_map(|e| match &e.kind { + EntityKind::Dimension(d) => Some(d.clone()), + _ => None, + }) + .unwrap() +} + +#[test] +fn dimtedit_moves_justifies_rotates_and_homes_typed_text() { + let mut s = Session::new(); + s.script("dimlinear 0,0 10,0 5,3\n").unwrap(); + // Pick the dimension on its dimension line, then a new text location. + s.script("dimtedit 2,3\n").unwrap(); + let p = s.prompt_text(); + assert!(p.contains("Specify new location for dimension text") && p.contains("[Left/Right/Center/Home/Angle]"), "{p}"); + s.script("7,6\n").unwrap(); + assert!(s.running.is_none()); + let d = the_dim(&s); + assert!(d.user_text_pos && d.text_mid.xy().dist(Vec2::new(7.0, 6.0)) < 1e-9, "{:?}", d.text_mid); + + s.script("dimtedit 2,3 h\n").unwrap(); + assert!(!the_dim(&s).user_text_pos, "Home puts the text back"); + + s.script("dimtedit 2,3 l\n").unwrap(); + assert_eq!(the_dim(&s).overrides.get("textJust").and_then(|v| v.as_i64()), Some(1)); + + s.script("dimtedit 2,3 a 30\n").unwrap(); + assert!(s.running.is_none()); + assert!((the_dim(&s).text_rotation - 30f64.to_radians()).abs() < 1e-9); + + // Something other than a dimension is refused and the prompt stays. + s.script("line 20,20 30,20\n\ndimtedit 25,20\n").unwrap(); + assert!(s.prompt_text().contains("Select dimension"), "{}", s.prompt_text()); + s.script("2,3 c\n").unwrap(); + assert!(s.running.is_none()); + assert_eq!(the_dim(&s).overrides.get("textJust").and_then(|v| v.as_i64()), Some(0)); +} From 8db51c27664f82b844589c17f2f26a5e2ad15021 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 00:09:15 +0200 Subject: [PATCH 124/150] Exploded dimension text and DXF dimension blocks match the drawn text (fixes #367) (#369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EXPLODE and the DXF *D block took the text height from the bare style's DIMTXT × DIMSCALE, ignoring the dimension's overrides (SETVAR DIM*, DIMOVERRIDE), a fixed-height text style and the DIMSCALE 0 fallback (text ~1e-10 high), and EXPLODE always wrote rotation 0, so aligned text turned horizontal. Both now use the geometry the renderer draws (dimension_in): its text height, angle and the overridden text style; EXPLODE adds no MTEXT for suppressed text. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/modify.rs | 36 +++++++++-------- crates/engine/tests/dim_explode_text.rs | 45 +++++++++++++++++++++ crates/io/src/dxf_write.rs | 7 ++-- crates/io/tests/dim_block_text.rs | 52 +++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 20 deletions(-) create mode 100644 crates/engine/tests/dim_explode_text.rs create mode 100644 crates/io/tests/dim_block_text.rs diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 7be2db3e..79662ff6 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1182,8 +1182,9 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option { - 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)); + // The dimension as drawn: its overrides, DIMSCALE (0 = the drawing's) and text font. + let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides); + let g = cadcraft_render::dimension_in(d, dm); let mut v: Vec = g .lines .iter() @@ -1208,21 +1209,22 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option None, diff --git a/crates/engine/tests/dim_explode_text.rs b/crates/engine/tests/dim_explode_text.rs new file mode 100644 index 00000000..4a8a8ab4 --- /dev/null +++ b/crates/engine/tests/dim_explode_text.rs @@ -0,0 +1,45 @@ +//! EXPLODE turns a dimension's text into MTEXT that looks like the text that was drawn: the +//! dimension's overrides and DIMSCALE (0 = the drawing's) set its height, and aligned text keeps +//! its angle (issue #367). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Handle, MText}; +use serde_json::{Value, json}; + +/// Draw a vertical dimension after `setup`, explode it, and return (drawn text height, drawn +/// text angle, the exploded MTEXT). +fn explode_vertical(setup: &[(&str, Value)]) -> (f64, f64, MText) { + let mut s = Session::new(); + for (cmd, p) in setup { + s.execute(cmd, p).unwrap(); + } + let r = s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [0, 30], "at": [5, 15]})).unwrap(); + let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap(); + let drawn = match &s.doc().unwrap().entity(h).unwrap().kind { + EntityKind::Dimension(dm) => cadcraft_engine::render::dimension_in(s.doc().unwrap(), dm), + k => panic!("not a dimension: {k:?}"), + }; + s.execute("explode", &json!({"handles": [h.hex()]})).unwrap(); + let mut texts = s.doc().unwrap().model.iter().filter_map(|e| match &e.kind { + EntityKind::MText(m) => Some(m.clone()), + _ => None, + }); + let m = texts.next().expect("the exploded dimension has its text"); + assert!(texts.next().is_none()); + (drawn.text_height, drawn.text_angle, m) +} + +#[test] +fn exploded_dimension_text_matches_the_drawn_text() { + let near = |a: f64, b: f64| (a - b).abs() < 1e-9; + // SETVAR DIMSCALE is an override on the dimension: 10 × DIMTXT 0.18. + let (h, _, m) = explode_vertical(&[("setvar", json!({"name": "DIMSCALE", "value": 10}))]); + assert!(near(h, 1.8) && near(m.height, h), "drawn {h}, exploded {}", m.height); + // A style DIMSCALE of 0 draws at scale 1 in model space; the text must not shrink to ~0. + let (h, _, m) = explode_vertical(&[("dimstyle", json!({"name": "Standard", "DIMSCALE": 0}))]); + assert!(near(h, 0.18) && near(m.height, h), "drawn {h}, exploded {}", m.height); + // Aligned text (DIMTIH off) on a vertical dimension is drawn at 90° and stays there. + let (_, a, m) = explode_vertical(&[("dimstyle", json!({"name": "Standard", "DIMTIH": 0}))]); + assert!(near(a, std::f64::consts::FRAC_PI_2) && near(m.rotation, a), "drawn {a}, exploded {}", m.rotation); + assert_eq!(m.contents, "30.0000"); +} diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 35a6a798..dd361d20 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -935,8 +935,9 @@ fn hatch_xdata(w: &mut W, h: &Hatch) { /// Anonymous dimension blocks (`*D1`…) with the rendered geometry, as consumers expect. fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec { - 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)); + // The dimension as drawn: its overrides, DIMSCALE (0 = the drawing's) and text font. + let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides); + let g = cadcraft_render::dimension_in(d, dm); let mut out = Vec::new(); let c = Common { layer: layer.into(), color: Color::ByBlock, ..Common::default() }; for l in &g.lines { @@ -961,7 +962,7 @@ fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec { common: c, kind: EntityKind::MText(MText { insert: g.text_pos.to3(0.0), - height: style.text_height * style.scale.max(1e-9), + height: g.text_height, width: 0.0, attach: 5, rotation: g.text_angle, diff --git a/crates/io/tests/dim_block_text.rs b/crates/io/tests/dim_block_text.rs new file mode 100644 index 00000000..517cc6a4 --- /dev/null +++ b/crates/io/tests/dim_block_text.rs @@ -0,0 +1,52 @@ +//! The anonymous `*D` block written for a dimension (what other programs draw) holds its text at +//! the height it is drawn at: the dimension's overrides and DIMSCALE (0 = the drawing's) count +//! (issue #367). + +use cadcraft_doc::{Common, DimKind, Dimension, Drawing, EntityKind, Space}; +use cadcraft_geom::Vec3; + +/// The group 40 (height) of the first MTEXT in the BLOCKS section. +fn block_mtext_height(dxf: &str) -> f64 { + let lines: Vec<&str> = dxf.lines().map(str::trim).collect(); + let blocks = lines.windows(2).position(|w| w == ["2", "BLOCKS"]).unwrap(); + let mtext = blocks + lines[blocks..].windows(2).position(|w| w == ["0", "MTEXT"]).unwrap(); + let h = mtext + lines[mtext..].chunks(2).position(|w| w[0] == "40").unwrap() * 2; + lines[h + 1].parse().unwrap() +} + +fn height_written(style_scale: f64, overrides: serde_json::Value) -> f64 { + let mut d = Drawing::new_imperial(); + d.dim_styles.iter_mut().for_each(|s| s.scale = style_scale); + let dm = Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(15.0, 5.0, 0.0), + text_mid: Vec3::ZERO, + p13: Vec3::ZERO, + p14: Vec3::new(30.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 0.0, + text_rotation: 0.0, + user_text_pos: false, + block: None, + overrides: overrides.as_object().cloned().unwrap_or_default(), + assoc: Vec::new(), + }; + d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap(); + block_mtext_height(&cadcraft_io::write_dxf(&d)) +} + +#[test] +fn dimension_block_text_has_the_drawn_height() { + let near = |a: f64, b: f64| (a - b).abs() < 1e-9; + let plain = height_written(1.0, serde_json::json!({})); + assert!(near(plain, 0.18), "{plain}"); + let scaled = height_written(1.0, serde_json::json!({"scale": 10.0})); + assert!(near(scaled, 1.8), "a DIMSCALE override scales the block text: {scaled}"); + let txt = height_written(1.0, serde_json::json!({"DIMTXT": 0.5})); + assert!(near(txt, 0.5), "a DIMTXT override sets the block text height: {txt}"); + let zero = height_written(0.0, serde_json::json!({})); + assert!(near(zero, 0.18), "DIMSCALE 0 in model space is scale 1, not ~0: {zero}"); +} From de8ed87a6e41694040696348fc01c4607921743a Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 00:09:20 +0200 Subject: [PATCH 125/150] MLEADER remembers the placement order and Options (fixes #362) (#365) The next MLEADER started again with "arrowhead first", Maxpoints 2 and a landing line. As in AutoCAD, the order (arrowhead, landing or content first) and the Options values (Maxpoints, leader landing, content type, first and second angle) are now kept in Session::last_used for the rest of the session and are where the next MLEADER starts. JSON calls keep their documented defaults. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/annotate.rs | 54 ++++++++++++------------- crates/engine/src/lib.rs | 33 ++++++++++++++- crates/engine/tests/annotate_options.rs | 4 +- crates/engine/tests/mleader_remember.rs | 48 ++++++++++++++++++++++ 4 files changed, 110 insertions(+), 29 deletions(-) create mode 100644 crates/engine/tests/mleader_remember.rs diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index 683c2e7e..715513ca 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -9,7 +9,7 @@ use serde_json::{Value, json}; use super::helpers::v3; use super::machines::SelectRun; use super::*; -use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; +use crate::{Accept, EngineError, Input, Interactive, LeaderOrder, MLeaderOptions, Prompt, Result, Session, Step}; pub fn specs() -> Vec { vec![ @@ -69,7 +69,7 @@ pub fn specs() -> Vec { .menu(&["Dimension", "Multileader"]) .alias(&["mld"]) .params("{points: [[arrow], ..., [landing]], text}") - .interactive(|_| Ok(Box::new(MLeaderM::default()))), + .interactive(|s| Ok(Box::new(MLeaderM::new(s)))), CommandSpec::new("leader", "Leader", run_leader).alias(&["lead"]).params("{points: [[x,y]...], text?}"), CommandSpec::new("dimstyle.update", "Update", run_update) .menu(&["Dimension", "Update"]) @@ -1455,15 +1455,6 @@ impl Interactive for ChainM { } } -/// Which end of the multileader MLEADER places first. -#[derive(Clone, Copy, Default, PartialEq)] -enum LeaderOrder { - #[default] - Arrowhead, - Landing, - Content, -} - /// The MLEADER Options prompt being answered. #[derive(Clone, Copy, PartialEq)] enum LeaderOpt { @@ -1495,23 +1486,28 @@ struct MLeaderM { content: bool, } -impl Default for MLeaderM { - fn default() -> Self { +impl MLeaderM { + /// Starts with the order and Options chosen the last time (as AutoCAD does). + fn new(s: &Session) -> Self { + let o = s.last_used.mleader; + let max_points = o.max_points.clamp(2, MAX_LEADER_POINTS); MLeaderM { - order: LeaderOrder::Arrowhead, + order: o.order, pts: Vec::new(), leader_done: false, text: None, opt: None, - max_points: 2, - angles: [0.0; 2], - landing: true, - content: true, + max_points, + angles: o.angles, + landing: o.landing, + content: o.content, } } -} - -impl MLeaderM { + /// Keep the order and Options for the next MLEADER. + fn remember(&self, s: &mut Session) { + s.last_used.mleader = + MLeaderOptions { order: self.order, max_points: self.max_points, angles: self.angles, landing: self.landing, content: self.content }; + } /// The leader from arrowhead to landing, with `extra` placed next. fn leader(&self, extra: Option) -> Vec { let mut pts = self.pts.clone(); @@ -1649,6 +1645,7 @@ impl Interactive for MLeaderM { fn input(&mut self, s: &mut Session, i: Input) -> Result { if let Some(o) = self.opt { self.opt = self.option(s, o, i); + self.remember(s); return Ok(Step::Continue); } if self.wants_text() { @@ -1663,12 +1660,15 @@ impl Interactive for MLeaderM { self.leader_done = true; } } - Input::Keyword(k) if self.pts.is_empty() => match k.as_str() { - "leader Landing first" => self.order = LeaderOrder::Landing, - "leader arrowHead first" => self.order = LeaderOrder::Arrowhead, - "Content first" => self.order = LeaderOrder::Content, - _ => self.opt = Some(LeaderOpt::Menu), - }, + Input::Keyword(k) if self.pts.is_empty() => { + match k.as_str() { + "leader Landing first" => self.order = LeaderOrder::Landing, + "leader arrowHead first" => self.order = LeaderOrder::Arrowhead, + "Content first" => self.order = LeaderOrder::Content, + _ => self.opt = Some(LeaderOpt::Menu), + } + self.remember(s); + } Input::Enter if self.pts.is_empty() => self.opt = Some(LeaderOpt::Menu), // Enter at "next point" ends the leader at the last point. Input::Enter if self.pts.len() >= 2 => self.leader_done = true, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0c316364..6328697d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -346,11 +346,42 @@ pub struct LastUsed { pub rotate_angle: f64, /// SCALE factor. pub scale_factor: f64, + /// MLEADER placement order and Options. + pub mleader: MLeaderOptions, } impl Default for LastUsed { fn default() -> Self { - LastUsed { hatch_select: false, polygon_circumscribed: false, rotate_angle: 0.0, scale_factor: 1.0 } + LastUsed { hatch_select: false, polygon_circumscribed: false, rotate_angle: 0.0, scale_factor: 1.0, mleader: MLeaderOptions::default() } + } +} + +/// Which part of a multileader MLEADER places first. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum LeaderOrder { + #[default] + Arrowhead, + Landing, + Content, +} + +/// The MLEADER placement order and the values set at its Options prompt. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MLeaderOptions { + pub order: LeaderOrder, + /// Maximum points of the leader line (2..=64). + pub max_points: usize, + /// First and second angle constraints (radians; 0 = free). + pub angles: [f64; 2], + /// Draw the landing line (dogleg). + pub landing: bool, + /// Ask for multiline text content (Content type None turns it off). + pub content: bool, +} + +impl Default for MLeaderOptions { + fn default() -> Self { + MLeaderOptions { order: LeaderOrder::Arrowhead, max_points: 2, angles: [0.0; 2], landing: true, content: true } } } diff --git a/crates/engine/tests/annotate_options.rs b/crates/engine/tests/annotate_options.rs index 4910a8ad..70256c65 100644 --- a/crates/engine/tests/annotate_options.rs +++ b/crates/engine/tests/annotate_options.rs @@ -123,7 +123,9 @@ fn mleader_options_take_effect() { assert_eq!(m.dogleg, 0.0); assert_eq!(m.text.unwrap().contents, "txt"); - // First angle constraint snaps the leader to 45° steps. + // First angle constraint snaps the leader to 45° steps (a new session: MLEADER would keep the + // Maxpoints and landing chosen above). + let mut s = Session::new(); s.script("mleader o f 45 x 0,0 10,8 note\n").unwrap(); let m = last_mleader(&s); let r = Vec2::new(10.0, 8.0).len(); diff --git a/crates/engine/tests/mleader_remember.rs b/crates/engine/tests/mleader_remember.rs new file mode 100644 index 00000000..e54d3753 --- /dev/null +++ b/crates/engine/tests/mleader_remember.rs @@ -0,0 +1,48 @@ +//! MLEADER starts with the placement order and Options chosen the last time (#247 follow-up). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, MLeader}; +use cadcraft_engine::geom::Vec2; +use serde_json::json; + +fn last_mleader(s: &Session) -> MLeader { + match &s.doc().unwrap().model.last().unwrap().kind { + EntityKind::MLeader(m) => m.clone(), + other => panic!("not a multileader: {other:?}"), + } +} + +fn near(a: Vec2, b: Vec2) -> bool { + a.dist(b) < 1e-6 +} + +#[test] +fn mleader_remembers_order_and_options() { + let mut s = Session::new(); + // Landing first, then Options: Maxpoints 3, no landing line. + s.script("mleader l o m 3 a n x 10,5 5,5 0,0 one\n").unwrap(); + let m = last_mleader(&s); + assert!(near(m.leaders[0][0].xy(), Vec2::ZERO) && near(m.landing.xy(), Vec2::new(10.0, 5.0)), "{m:?}"); + assert_eq!(m.dogleg, 0.0); + + // The next MLEADER asks for the landing first and keeps both options. + s.cmdline("mleader").unwrap(); + assert!(s.prompt_text().contains("landing location"), "{}", s.prompt_text()); + s.script("20,5 15,5 10,0 two\n").unwrap(); + let m = last_mleader(&s); + assert_eq!(m.leaders[0].len(), 2, "three points (Maxpoints 3): {m:?}"); + assert!(near(m.leaders[0][0].xy(), Vec2::new(10.0, 0.0)) && near(m.landing.xy(), Vec2::new(20.0, 5.0)), "{m:?}"); + assert_eq!(m.dogleg, 0.0); + assert_eq!(m.text.unwrap().contents, "two"); + + // Switching back to arrowhead first is remembered too, also when the command is cancelled. + s.script("mleader h\n").unwrap(); + s.cancel(); + s.cmdline("mleader").unwrap(); + assert!(s.prompt_text().contains("arrowhead location"), "{}", s.prompt_text()); + s.cancel(); + + // JSON calls keep their documented defaults (landing line drawn). + s.execute("mleader", &json!({"points": [[0, 0], [5, 5]], "text": "json"})).unwrap(); + assert!(last_mleader(&s).dogleg > 0.0); +} From 54d75a1623043b7c8ccf99e839a9b4ca03ecc369 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 00:09:26 +0200 Subject: [PATCH 126/150] Control channel: cmdline.key replies like cmdline.input (fixes #360) (#361) When Enter was refused (Enter repeats a command that isn't available), headless failed the cmdline.key call while the app dropped the error, and pressing Enter in the app's command line echoes it. Both now echo the error to the history and reply with the command-line state, the `output` lines the key added and `error` only when Enter was refused, the shape #351 gives cmdline.input. Co-authored-by: Claude Opus 5.5 --- crates/mcp/src/backend.rs | 21 ++++++-- crates/mcp/tests/cmdline_key.rs | 45 ++++++++++++++++ crates/ui-egui/src/control.rs | 24 +++++++-- crates/ui-egui/tests/control_cmdline_key.rs | 57 +++++++++++++++++++++ docs/control-protocol.md | 2 +- 5 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 crates/mcp/tests/cmdline_key.rs create mode 100644 crates/ui-egui/tests/control_cmdline_key.rs diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 15f4a1fa..5754dc08 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -176,12 +176,27 @@ impl Backend for Headless { Ok(json!({"output": out, "state": state(s)})) } "cmdline.key" => { - if str_p("key").is_some_and(|k| k.eq_ignore_ascii_case("escape") || k.eq_ignore_ascii_case("esc")) { + let before = s.log.len(); + let r = if str_p("key").is_some_and(|k| k.eq_ignore_ascii_case("escape") || k.eq_ignore_ascii_case("esc")) { s.cancel(); + Ok(()) } else { - s.input(cadcraft_engine::Input::Enter).map_err(|e| e.to_string())?; + s.input(cadcraft_engine::Input::Enter) + }; + // Like the app: a refused Enter is echoed to the history (and `output`), and also + // reported in `error`; the call itself succeeds. + if let Err(e) = &r { + s.echo(e.to_string()); } - Ok(state(s)) + let out: Vec = s.log.iter().skip(before).cloned().collect(); + let mut st = state(s); + if let Some(o) = st.as_object_mut() { + o.insert("output".into(), json!(out)); + if let Err(e) = r { + o.insert("error".into(), json!(e.to_string())); + } + } + Ok(st) } "cmdline.state" => Ok(state(s)), "engine.commands" => { diff --git a/crates/mcp/tests/cmdline_key.rs b/crates/mcp/tests/cmdline_key.rs new file mode 100644 index 00000000..77486e66 --- /dev/null +++ b/crates/mcp/tests/cmdline_key.rs @@ -0,0 +1,45 @@ +//! The headless backend answers `cmdline.key` like `cmdline.input` and like the app's control +//! channel (`crates/ui-egui/tests/control_cmdline_key.rs`): a refused Enter is echoed and reported +//! in `error`, and the call itself succeeds. + +use cadcraft_mcp::{Backend, Headless}; +use serde_json::{Value, json}; + +fn keys(v: &Value) -> Vec { + let mut k: Vec = v.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default(); + k.sort(); + k +} + +/// The `cmdline.state` fields plus `extra`. +fn want(b: &mut Headless, extra: &[&str]) -> Vec { + let mut k = keys(&b.call("cmdline.state", json!({})).unwrap()); + k.extend(extra.iter().map(|s| s.to_string())); + k.sort(); + k +} + +#[test] +fn refused_enter_is_echoed_and_reported_in_error() { + // Enter repeats CLOSE, which needs an open drawing. + let mut b = Headless::default(); + b.call("cmdline.input", json!({"text": "close"})).unwrap(); + let r = b.call("cmdline.key", json!({"key": "enter"})).unwrap(); + assert_eq!(keys(&r), want(&mut b, &["output", "error"]), "{r}"); + let e = r["error"].as_str().unwrap(); + assert!(!e.is_empty()); + assert!(r["output"].as_array().unwrap().iter().any(|l| l.as_str() == Some(e)), "{r}"); + assert!(r["history"].as_array().unwrap().iter().any(|l| l.as_str() == Some(e)), "{r}"); +} + +#[test] +fn accepted_keys_have_output_and_no_error() { + let mut b = Headless::default(); + for key in ["enter", "escape"] { + b.call("cmdline.input", json!({"text": "circle"})).unwrap(); + let r = b.call("cmdline.key", json!({ "key": key })).unwrap(); + assert_eq!(keys(&r), want(&mut b, &["output"]), "{key}: {r}"); + assert!(r["running"].is_null(), "{key}: {r}"); + assert!(r["output"].as_array().unwrap().iter().any(|l| l.as_str() == Some("*Cancel*")), "{key}: {r}"); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b9bb4cec..e86b7e3f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -201,13 +201,27 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou } } "cmdline.key" => { - match s("key").unwrap_or("enter").to_ascii_lowercase().as_str() { - "escape" | "esc" => app.session.cancel(), - _ => { - let _ = app.session.input(Input::Enter); + let before = app.session.log.len(); + let error = match s("key").unwrap_or("enter").to_ascii_lowercase().as_str() { + "escape" | "esc" => { + app.session.cancel(); + None + } + // As pressing Enter in the command line: a refused Enter is echoed to the history. + _ => app.session.input(Input::Enter).err().map(|e| e.to_string()), + }; + if let Some(e) = &error { + app.session.echo(e.clone()); + } + let out: Vec = app.session.log.iter().skip(before).cloned().collect(); + let mut st = cmdline_state(app); + if let Some(o) = st.as_object_mut() { + o.insert("output".into(), json!(out)); + if let Some(e) = error { + o.insert("error".into(), json!(e)); } } - ok(cmdline_state(app)) + ok(st) } "cmdline.state" => ok(cmdline_state(app)), "engine.commands" => ok(all_commands(app)), diff --git a/crates/ui-egui/tests/control_cmdline_key.rs b/crates/ui-egui/tests/control_cmdline_key.rs new file mode 100644 index 00000000..de02816d --- /dev/null +++ b/crates/ui-egui/tests/control_cmdline_key.rs @@ -0,0 +1,57 @@ +//! The app's control channel answers `cmdline.key` like `cmdline.input` and like the headless MCP +//! backend (`crates/mcp/tests/cmdline_key.rs`): a refused Enter is echoed and reported in `error`. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::control::{ControlRequest, Outcome, handle}; +use cadcraft_ui_egui::{CadApp, Services}; +use serde_json::{Value, json}; + +fn call(app: &mut CadApp, method: &str, params: Value) -> Value { + let (req, _reply) = ControlRequest::new(method, params); + match handle(app, &egui::Context::default(), &req) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => Value::Null, + } +} + +fn keys(v: &Value) -> Vec { + let mut k: Vec = v.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default(); + k.sort(); + k +} + +/// The `cmdline.state` fields plus `extra`. +fn want(app: &mut CadApp, extra: &[&str]) -> Vec { + let mut k = keys(&call(app, "cmdline.state", json!({}))["result"]); + k.extend(extra.iter().map(|s| s.to_string())); + k.sort(); + k +} + +#[test] +fn refused_enter_is_echoed_and_reported_in_error() { + // Enter repeats CLOSE, which needs an open drawing. + let mut app = CadApp::new(Session::new(), Services::default()); + app.session.cmdline("close").unwrap(); + let v = call(&mut app, "cmdline.key", json!({"key": "enter"})); + assert_eq!(v["ok"], true, "{v}"); + let r = &v["result"]; + assert_eq!(keys(r), want(&mut app, &["output", "error"]), "{r}"); + let e = r["error"].as_str().unwrap(); + assert!(!e.is_empty()); + assert!(r["output"].as_array().unwrap().iter().any(|l| l.as_str() == Some(e)), "{r}"); + assert!(r["history"].as_array().unwrap().iter().any(|l| l.as_str() == Some(e)), "{r}"); +} + +#[test] +fn accepted_keys_have_output_and_no_error() { + let mut app = CadApp::new(Session::new(), Services::default()); + for key in ["enter", "escape"] { + app.session.cmdline("circle").unwrap(); + let v = call(&mut app, "cmdline.key", json!({ "key": key })); + let r = &v["result"]; + assert_eq!(keys(r), want(&mut app, &["output"]), "{key}: {r}"); + assert!(r["running"].is_null(), "{key}: {r}"); + assert!(r["output"].as_array().unwrap().iter().any(|l| l.as_str() == Some("*Cancel*")), "{key}: {r}"); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9dcb84de..18754d66 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -23,7 +23,7 @@ The server waits up to 60 s for the app to answer a request. After that it repli |---|---|---| | `cmdline.input` | `{text}` | Type a line at the command line exactly like a user. Starts commands by name/alias and answers prompts (points `x,y`, `@dx,dy`, `@d Date: Sun, 11 Oct 2026 00:09:31 +0200 Subject: [PATCH 127/150] ZOOM inside a layout viewport acts on the viewport; nXP sets its scale (fixes #370) (#372) In MSPACE, Extents/All/Window/Object fitted the drawing window's aspect and paper-view height, In/Out/nX zoomed about the screen centre, and nXP was read as nX. Now ZOOM works on the active (unlocked) viewport's model view: fits use the viewport's aspect, zooms keep its centre, and nXP sets the viewport scale to n paper units per model unit. Locked viewports and the Model tab are unchanged. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/view.rs | 33 +++++----- crates/engine/src/lib.rs | 35 ++++++++++- crates/engine/tests/zoom_viewport.rs | 91 ++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 17 deletions(-) create mode 100644 crates/engine/tests/zoom_viewport.rs diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index e70b3bc8..9e43c830 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec { CommandSpec::new("zoom", "Zoom", run_zoom) .menu(&["View", "Zoom", "Realtime"]) .alias(&["z"]) - .params("{mode: extents|all|window|previous|in|out|center|scale|object, p1?, p2?, center?, height?, factor?}") + .params("{mode: extents|all|window|previous|in|out|center|scale|object, p1?, p2?, center?, height?, factor?, xp?: bool (scale relative to paper space: in a viewport, `factor` paper units per model unit)}") .noundo() .transparent() .interactive(|_| Ok(Box::new(ZoomM::default()))), @@ -95,9 +95,10 @@ fn fit(s: &mut Session, b: Bounds2) -> Result<()> { return Ok(()); } let (w, h) = s.viewport_px; - let aspect = w / h.max(1.0); + let st = s.state_mut()?; + let aspect = st.zoom_aspect(w / h.max(1.0)); let height = b.height().max(b.width() / aspect.max(1e-6)).max(1e-9); - s.state_mut()?.set_view(View { center: b.center(), height }); + st.set_zoom_frame(View { center: b.center(), height }); Ok(()) } @@ -128,14 +129,14 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result { "in" | "out" => { let f = if mode == "in" { 2.0 } else { 0.5 }; let st = s.state_mut()?; - let v = st.view(); - st.set_view(View { center: v.center, height: v.height / f }); + let v = st.zoom_frame(); + st.set_zoom_frame(View { center: v.center, height: v.height / f }); } "center" | "c" => { let c = point_req("zoom", p, "center")?; let st = s.state_mut()?; - let h = f64_or(p, "height", st.view().height); - st.set_view(View { center: c, height: h.max(1e-9) }); + let h = f64_or(p, "height", st.zoom_frame().height); + st.set_zoom_frame(View { center: c, height: h.max(1e-9) }); } "scale" | "s" => { let f = f64_req("zoom", p, "factor")?; @@ -143,8 +144,13 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result { return Err(bad("zoom", "factor must be positive")); } let st = s.state_mut()?; - let v = st.view(); - st.set_view(View { center: v.center, height: v.height / f }); + let v = st.zoom_frame(); + // nXP in a viewport sets its scale: `f` paper units per model unit. + let height = match st.zoom_viewport() { + Some(vp) if bool_or(p, "xp", false) => vp.height / f, + _ => v.height / f, + }; + st.set_zoom_frame(View { center: v.center, height }); } "object" | "o" => { let hs = targets(s, p)?; @@ -274,12 +280,9 @@ impl Interactive for ZoomM { let f = number(num) .filter(|f| *f > 0.0) .ok_or_else(|| crate::EngineError::Other("Requires a point, scale factor or option keyword.".into()))?; - if tl.ends_with('x') { - zoom(s, &json!({ "mode": "scale", "factor": f }))?; - } else { - // Plain number: scale relative to limits (approximation: like nX). - zoom(s, &json!({ "mode": "scale", "factor": f }))?; - } + // nXP: relative to paper space; nX and a plain number (approximation: like nX) + // relative to the current view. + zoom(s, &json!({ "mode": "scale", "factor": f, "xp": tl.ends_with("xp") }))?; Ok(Step::Done) } Input::Enter => Ok(Step::Done), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6328697d..b368afa4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -227,6 +227,36 @@ impl DocState { } self.store_view(v); } + /// The active MSPACE viewport when ZOOM works on its own view, as in AutoCAD: extents and + /// windows fit the viewport, and zooms keep its centre. A locked viewport keeps its scale, so + /// zooming it moves the sheet instead (see `store_view`). + pub fn zoom_viewport(&self) -> Option { + self.active_viewport().map(|(_, v)| v).filter(|v| !v.locked && v.width > 1e-12) + } + /// The view ZOOM starts from: the model view of the active unlocked viewport, else [`Self::view`]. + pub fn zoom_frame(&self) -> View { + match self.zoom_viewport() { + Some(v) => View { center: v.view_center, height: v.view_height }, + None => self.view(), + } + } + /// Width / height of the area ZOOM fits into: the active unlocked viewport, else `screen`. + pub fn zoom_aspect(&self, screen: f64) -> f64 { + self.zoom_viewport().map_or(screen, |v| v.width / v.height) + } + /// Set the view ZOOM works on (see [`Self::zoom_frame`]), recording view history. + pub fn set_zoom_frame(&mut self, f: View) { + let v = match self.zoom_viewport() { + // The screen view that `store_view` maps to this model view of the viewport. + Some(vp) => { + let pv = self.paper_view(); + let k = f.height / vp.height; + View { center: f.center + (pv.center - vp.center.xy()) * k, height: pv.height * k } + } + None => f, + }; + self.set_view(v); + } /// Set without recording view history (realtime pan/zoom frames). pub fn set_view_quiet(&mut self, v: View) { if !(v.center.is_finite() && v.height.is_finite() && v.height > 1e-12) { @@ -1071,9 +1101,10 @@ impl Session { } else { ext }; - let aspect = w / h.max(1.0); + let st = self.state_mut()?; + let aspect = st.zoom_aspect(w / h.max(1.0)); let height = ext.height().max(ext.width() / aspect.max(1e-6)).max(1e-6) * 1.05; - self.state_mut()?.set_view(View { center: ext.center(), height }); + st.set_zoom_frame(View { center: ext.center(), height }); Ok(()) } diff --git a/crates/engine/tests/zoom_viewport.rs b/crates/engine/tests/zoom_viewport.rs new file mode 100644 index 00000000..f33758e9 --- /dev/null +++ b/crates/engine/tests/zoom_viewport.rs @@ -0,0 +1,91 @@ +//! ZOOM inside a layout viewport (MSPACE) works on that viewport, as in AutoCAD: Extents and +//! Window fit the viewport, nX keeps its centre, nXP sets its scale (#370). + +use cadcraft_doc::{EntityKind, Viewport}; +use cadcraft_engine::Session; +use serde_json::json; + +fn viewport(s: &Session, h: &str) -> Viewport { + let d = s.doc().unwrap(); + let e = d.layout("Layout1").unwrap().entities.iter().find(|e| e.handle.hex() == h).unwrap(); + match &e.kind { + EntityKind::Viewport(v) => v.clone(), + _ => panic!("not a viewport"), + } +} + +/// A 1000 × 500 model and a 100 × 80 viewport on Layout1, entered with MSPACE. +fn session() -> (Session, String) { + let mut s = Session::new(); + s.new_drawing(true); + s.viewport_px = (1600.0, 900.0); + s.execute("line", &json!({"points": [[0, 0], [1000, 0]]})).unwrap(); + s.execute("line", &json!({"points": [[0, 0], [0, 500]]})).unwrap(); + s.execute("layout.set", &json!({"name": "Layout1"})).unwrap(); + let r = s.execute("mview", &json!({"p1": [20, 20], "p2": [120, 100]})).unwrap(); + let h = r["viewports"][0].as_str().unwrap().to_string(); + s.execute("viewport.set", &json!({"handle": h, "scale": 1, "center": [0, 0]})).unwrap(); + s.execute("mspace", &json!({"handle": h})).unwrap(); + (s, h) +} + +#[test] +fn zoom_inside_a_viewport_acts_on_the_viewport() { + let (mut s, h) = session(); + let paper = s.state().unwrap().paper_view(); + + // Extents fit the viewport (aspect 1.25): 1000 wide needs a view height of 800. + s.cmdline("zoom e").unwrap(); + let v = viewport(&s, &h); + assert!(v.view_center.near(cadcraft_geom::Vec2::new(500.0, 250.0), 1e-6), "{:?}", v.view_center); + assert!(v.view_height >= 800.0 && v.view_height < 900.0, "{}", v.view_height); + + // nX zooms about the viewport centre. + s.cmdline("zoom 2x").unwrap(); + let v2 = viewport(&s, &h); + assert!(v2.view_center.near(v.view_center, 1e-6), "{:?}", v2.view_center); + assert!((v2.view_height - v.view_height / 2.0).abs() < 1e-9); + + // nXP sets the scale (paper units per model unit), whatever the zoom before. + for (t, scale) in [("zoom 0.1xp", 0.1), ("zoom 1/50xp", 0.02), ("zoom 1/50xp", 0.02)] { + s.cmdline(t).unwrap(); + let v = viewport(&s, &h); + assert!((v.height / v.view_height - scale).abs() < 1e-12, "{t}: {}", v.height / v.view_height); + assert!(v.view_center.near(cadcraft_geom::Vec2::new(500.0, 250.0), 1e-6), "{t}: {:?}", v.view_center); + } + + // A window fills the viewport: 200 × 100 at aspect 1.25 → height 160. + s.cmdline("zoom w 400,200 600,300").unwrap(); + let v = viewport(&s, &h); + assert!(v.view_center.near(cadcraft_geom::Vec2::new(500.0, 250.0), 1e-6), "{:?}", v.view_center); + assert!((v.view_height - 160.0).abs() < 1e-9, "{}", v.view_height); + + // The JSON form agrees, and the sheet itself never moves. + s.execute("zoom", &json!({"mode": "scale", "factor": 0.25, "xp": true})).unwrap(); + assert!((viewport(&s, &h).view_height - 320.0).abs() < 1e-9); + assert_eq!(s.state().unwrap().paper_view(), paper); +} + +#[test] +fn zoom_in_a_locked_viewport_or_on_the_model_tab_is_unchanged() { + // A locked viewport keeps its view: ZOOM moves the sheet instead. + let (mut s, h) = session(); + s.execute("viewport.set", &json!({"handle": h, "locked": true})).unwrap(); + let before = viewport(&s, &h); + let paper = s.state().unwrap().paper_view(); + s.cmdline("zoom e").unwrap(); + s.cmdline("zoom 0.1xp").unwrap(); + assert_eq!(viewport(&s, &h), before); + assert_ne!(s.state().unwrap().paper_view(), paper); + + // The Model tab still fits the drawing window; nXP there acts like nX. + let mut s = Session::new(); + s.new_drawing(true); + s.viewport_px = (1600.0, 900.0); + s.execute("line", &json!({"points": [[0, 0], [1000, 0]]})).unwrap(); + s.cmdline("zoom e").unwrap(); + let v = s.state().unwrap().view(); + assert!((v.height - 1000.0 / (1600.0 / 900.0) * 1.05).abs() < 1e-6, "{}", v.height); + s.cmdline("zoom 2xp").unwrap(); + assert!((s.state().unwrap().view().height - v.height / 2.0).abs() < 1e-9); +} From 157ea6290d354597b19e3be8d519cae1aad86580 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 00:10:30 +0200 Subject: [PATCH 128/150] LAYOUT / -LAYOUT: option prompts at the command line (fixes #378) (#380) Typed LAYOUT ran its JSON default and ended, so the option and the layout names typed after it ran as commands. It now asks for an option (Copy/Delete/New/Template/Rename/SAveas/Set/?, Enter = Set) and the layout name(s) the option needs, with the current layout as the default. The options run the layout.* command bodies, so a refused name re-prompts and the command is one undo step. Template and SAveas report that they are not available yet and end. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/layout.rs | 5 +- crates/engine/src/cmd/layout/prompts.rs | 217 ++++++++++++++++++++++++ crates/engine/tests/layout_prompts.rs | 57 +++++++ 3 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/cmd/layout/prompts.rs create mode 100644 crates/engine/tests/layout_prompts.rs diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index f7be52b5..e618c90d 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -12,11 +12,14 @@ use super::file::{base64_encode, io}; use super::*; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; +mod prompts; + pub fn specs() -> Vec { vec![ CommandSpec::new("layout", "Layout", run_layout) .alias(&["lo"]) - .params("{option: new|copy|delete|rename|set|list, name?, to?}"), + .params("{option: new|copy|delete|rename|set|list, name?, to?} (typed LAYOUT / -LAYOUT: option prompts)") + .interactive(|_| Ok(Box::new(prompts::LayoutM::default()))), CommandSpec::new("layout.new", "New Layout", run_new) .menu(&["Insert", "Layout", "New Layout"]) .params("{name?, viewport?: bool (default true)} → {name, viewport}") diff --git a/crates/engine/src/cmd/layout/prompts.rs b/crates/engine/src/cmd/layout/prompts.rs new file mode 100644 index 00000000..a7b2e7fa --- /dev/null +++ b/crates/engine/src/cmd/layout/prompts.rs @@ -0,0 +1,217 @@ +//! LAYOUT / -LAYOUT typed at the command line: one option, then the layout name(s) it needs. +//! Every option runs the `layout.*` command bodies, so validation matches the JSON form and the +//! command is one undo step. + +use cadcraft_doc::Space; +use serde_json::{Value, json}; + +use super::super::find_command; +use super::super::qselect::wildcard; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +const OPTIONS: &[&str] = &["Copy", "Delete", "New", "Template", "Rename", "SAveas", "Set", "?"]; + +/// Keyword-only: spaces separate inputs. +const KW: Accept = Accept { point: false, number: false, text: false, select: false, enter: true }; + +#[derive(Clone, Debug, Default)] +enum Stage { + #[default] + Option, + CopyFrom, + CopyTo(String), + Delete, + New, + RenameFrom, + RenameTo(String), + Set, + List, +} + +#[derive(Default)] +pub struct LayoutM { + stage: Stage, +} + +/// The layout the name prompts default to: the current one, else the first tab. +fn current_layout(s: &Session) -> Option { + if let Space::Paper(n) = s.layout_space() { + return Some(n); + } + let d = s.doc().ok()?; + d.layouts.iter().min_by_key(|l| l.tab_order).map(|l| l.name.clone()) +} + +/// The name typed at a name prompt (`None` for Enter, which takes the default). +fn typed_name(i: &Input) -> Option { + match i { + Input::Text(t) | Input::Keyword(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()), + _ => None, + } +} + +/// An engine refusal as a re-prompt message (no `invalid parameters for …` prefix). +fn retry(e: EngineError) -> EngineError { + match e { + EngineError::BadParams { msg, .. } => EngineError::Other(msg), + other => other, + } +} + +fn need(name: Option) -> Result { + name.ok_or_else(|| EngineError::Other("Requires a layout name.".into())) +} + +impl LayoutM { + fn option(&mut self, s: &mut Session, k: &str) -> Result { + self.stage = match k { + "Copy" => Stage::CopyFrom, + "Delete" => Stage::Delete, + "New" => Stage::New, + "Rename" => Stage::RenameFrom, + "Set" => Stage::Set, + "?" => Stage::List, + other => { + // Template, SAveas: report and end, leaving the next input to the command line. + s.echo(format!("LAYOUT {other} is not available yet.")); + return Ok(Step::Done); + } + }; + Ok(Step::Continue) + } + + fn list(s: &mut Session, pat: &str) -> Result<()> { + let d = s.doc()?; + let mut ls: Vec<_> = d.layouts.iter().collect(); + ls.sort_by_key(|l| l.tab_order); + let pats: Vec<&str> = pat.split(',').map(str::trim).filter(|p| !p.is_empty()).take(1000).collect(); + let pats = if pats.is_empty() { vec!["*"] } else { pats }; + let names: Vec = ls.iter().filter(|l| pats.iter().any(|p| wildcard(p, &l.name))).map(|l| l.name.clone()).collect(); + let current = match s.layout_space() { + Space::Paper(n) => n, + Space::Model => "Model".into(), + }; + s.echo(format!("Active Layout: {current}")); + if names.is_empty() { + s.echo(format!("No layout matches \"{pat}\".")); + } + for n in names { + s.echo(format!("Layout: {n}")); + } + Ok(()) + } +} + +impl Interactive for LayoutM { + fn name(&self) -> &'static str { + "LAYOUT" + } + fn prompt(&self, s: &Session) -> Prompt { + let cur = current_layout(s); + let with_cur = |p: Prompt| match &cur { + Some(c) => p.default(c.clone()), + None => p, + }; + match &self.stage { + Stage::Option => Prompt::new("Enter layout option", KW).kw(OPTIONS).default("set"), + Stage::CopyFrom => with_cur(Prompt::new("Enter name of layout to copy", Accept::TEXT)), + Stage::CopyTo(from) => { + let next = s.doc().ok().and_then(|d| (2..=10_000).map(|i| format!("{from} ({i})")).find(|n| d.layout(n).is_none())); + let p = Prompt::new("Enter layout name for copy", Accept::TEXT); + match next { + Some(n) => p.default(n), + None => p, + } + } + Stage::Delete => with_cur(Prompt::new("Enter name of layout to delete", Accept::TEXT)), + Stage::New => { + let next = s.doc().map(super::next_layout_name).unwrap_or_else(|_| "Layout1".into()); + Prompt::new("Enter name of new layout", Accept::TEXT).default(next) + } + Stage::RenameFrom => with_cur(Prompt::new("Enter layout to rename", Accept::TEXT)), + Stage::RenameTo(_) => Prompt::new("Enter new layout name", Accept::TEXT), + Stage::Set => { + let tab = match s.layout_space() { + Space::Paper(n) => n, + Space::Model => "Model".into(), + }; + Prompt::new("Enter layout to make current", Accept::TEXT).default(tab) + } + Stage::List => Prompt::new("Enter layout name(s) to list", Accept::TEXT).default("*"), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if i == Input::Cancel { + return Ok(Step::Cancel); + } + let name = typed_name(&i); + match self.stage.clone() { + Stage::Option => match i { + Input::Enter => { + self.stage = Stage::Set; + Ok(Step::Continue) + } + Input::Keyword(k) => self.option(s, &k), + _ => Err(EngineError::Other("Invalid option keyword.".into())), + }, + Stage::CopyFrom => { + let from = need(name.or_else(|| current_layout(s)))?; + let from = super::layout_name("layout.copy", s.doc()?, &from).map_err(retry)?; + self.stage = Stage::CopyTo(from); + Ok(Step::Continue) + } + Stage::CopyTo(from) => { + let mut p = json!({ "from": from }); + if let (Some(n), Some(o)) = (name, p.as_object_mut()) { + o.insert("to".into(), json!(n)); + } + let r = super::run_copy(s, &p).map_err(retry)?; + let to = r.get("name").and_then(Value::as_str).unwrap_or_default(); + s.echo(format!("Layout \"{from}\" copied to \"{to}\".")); + Ok(Step::Done) + } + Stage::Delete => { + let n = need(name.or_else(|| current_layout(s)))?; + let r = super::run_delete(s, &json!({ "name": n })).map_err(retry)?; + let gone = r.get("deleted").and_then(Value::as_str).unwrap_or_default(); + s.echo(format!("Layout \"{gone}\" deleted.")); + Ok(Step::Done) + } + Stage::New => { + let p = match name { + Some(n) => json!({ "name": n }), + None => json!({}), + }; + super::run_new(s, &p).map_err(retry)?; + Ok(Step::Done) + } + Stage::RenameFrom => { + let from = need(name.or_else(|| current_layout(s)))?; + let from = super::layout_name("layout.rename", s.doc()?, &from).map_err(retry)?; + self.stage = Stage::RenameTo(from); + Ok(Step::Continue) + } + Stage::RenameTo(from) => { + let to = need(name)?; + super::run_rename(s, &json!({ "from": from, "to": to })).map_err(retry)?; + Ok(Step::Done) + } + Stage::Set => { + let n = match name { + Some(n) => n, + None => match s.layout_space() { + Space::Paper(n) => n, + Space::Model => "Model".into(), + }, + }; + let set = find_command("layout.set").ok_or_else(|| EngineError::UnknownCommand("layout.set".into()))?; + (set.run)(s, &json!({ "name": n })).map_err(retry)?; + Ok(Step::Done) + } + Stage::List => { + Self::list(s, name.as_deref().unwrap_or("*"))?; + Ok(Step::Done) + } + } + } +} diff --git a/crates/engine/tests/layout_prompts.rs b/crates/engine/tests/layout_prompts.rs new file mode 100644 index 00000000..77523054 --- /dev/null +++ b/crates/engine/tests/layout_prompts.rs @@ -0,0 +1,57 @@ +//! LAYOUT / -LAYOUT typed at the command line asks for an option and the layout names it needs +//! instead of running the next inputs as commands. + +use cadcraft_doc::Space; +use cadcraft_engine::Session; + +fn layouts(s: &Session) -> Vec { + let mut ls: Vec<_> = s.doc().unwrap().layouts.iter().map(|l| (l.tab_order, l.name.clone())).collect(); + ls.sort(); + ls.into_iter().map(|(_, n)| n).collect() +} + +#[test] +fn typed_layout_options_prompt_for_names() { + let mut s = Session::new(); + assert_eq!(layouts(&s), ["Layout1", "Layout2"]); + + s.cmdline("LAYOUT").unwrap(); + let p = s.current_prompt().expect("LAYOUT prompts").display(); + assert_eq!(p, "Enter layout option or [Copy/Delete/New/Template/Rename/SAveas/Set/?] :"); + s.cmdline("n").unwrap(); + assert_eq!(s.current_prompt().unwrap().display(), "Enter name of new layout :"); + s.cmdline("Sheet9").unwrap(); + assert!(s.current_prompt().is_none()); + assert_eq!(layouts(&s), ["Layout1", "Layout2", "Sheet9"]); + + // Script form, names with spaces, and the current layout as the default. + s.script("-LAYOUT S Layout2\n-LAYOUT R\nLayout2\nSheet A\n-LAYOUT C\n\n\n").unwrap(); + assert_eq!(s.layout_space(), Space::Paper("Sheet A".into())); + assert_eq!(layouts(&s), ["Layout1", "Sheet A", "Sheet9", "Sheet A (2)"]); + s.script("-LAYOUT D Sheet9\n").unwrap(); + assert_eq!(layouts(&s), ["Layout1", "Sheet A", "Sheet A (2)"]); + + // An invalid name re-prompts; Enter at the option prompt is Set. + s.cmdline("LAYOUT N").unwrap(); + s.cmdline("Layout1").unwrap(); + assert!(s.current_prompt().is_some_and(|p| p.message == "Enter name of new layout")); + s.input(cadcraft_engine::Input::Cancel).unwrap(); + s.script("LAYOUT\n\nModel\n").unwrap(); + assert_eq!(s.layout_space(), Space::Model); + + // ? lists; Template and SAveas report and end without taking the next input. + let n = s.log.len(); + s.cmdline("LAYOUT ? Sheet*").unwrap(); + assert!(s.log[n..].iter().any(|l| l == "Layout: Sheet A (2)"), "{:?}", &s.log[n..]); + assert!(!s.log[n..].iter().any(|l| l == "Layout: Layout1")); + let n = s.log.len(); + s.cmdline("LAYOUT T").unwrap(); + assert!(s.current_prompt().is_none()); + assert!(s.log[n..].iter().any(|l| l.contains("not available yet")), "{:?}", &s.log[n..]); + assert_eq!(layouts(&s), ["Layout1", "Sheet A", "Sheet A (2)"]); + + // The whole command is one undo step. + s.cmdline("LAYOUT N Sheet7").unwrap(); + s.undo().unwrap(); + assert_eq!(layouts(&s), ["Layout1", "Sheet A", "Sheet A (2)"]); +} From 19f7bc39edb62b0f6878dfa2a08e8026d616ac23 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 00:10:35 +0200 Subject: [PATCH 129/150] VPLAYER: option prompts at the command line (fixes #381) (#387) Typed VPLAYER ran its JSON form with no parameters and ended, so its options ran as commands (F started FILLET). It now loops on ?/Color/Freeze/Thaw/Reset/Newfrz/Vpvisdflt until Enter, asks for layer names (wildcards, comma lists) and then which viewports to change: All/Select/Current/Except current (Current by default inside a viewport, Select by point on the sheet in paper space). Changes go through viewport.set, layer.new and layer.set; the session is one undo step. The Model tab refuses it. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/layout.rs | 6 +- crates/engine/src/cmd/layout/vplayer.rs | 388 ++++++++++++++++++++++++ crates/engine/tests/vplayer_prompts.rs | 67 ++++ 3 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/cmd/layout/vplayer.rs create mode 100644 crates/engine/tests/vplayer_prompts.rs diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index e618c90d..99e7fe10 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -56,7 +56,8 @@ pub fn specs() -> Vec { "{handle? (default: selected viewports), scale?: paper units per model unit | \"1:50\", viewHeight?, center?: [x,y], locked?, freeze?: [layer], thaw?: [layer], colors?: {layer: color | null}}", ), CommandSpec::new("vplayer", "Viewport Layer Freeze", run_viewport_set) - .params("{handle?, freeze?: [layer], thaw?: [layer], colors?: {layer: color (\"red\" | 1..255 | \"r,g,b\") | null to clear}}"), + .params("{handle?, freeze?: [layer], thaw?: [layer], colors?: {layer: color (\"red\" | 1..255 | \"r,g,b\") | null to clear}} (typed: option prompts)") + .interactive(|_| Ok(Box::new(vplayer::VplayerM::default()))), CommandSpec::new("pagesetup", "Page Setup Manager...", run_pagesetup) .menu(&["File", "Page Setup Manager..."]) .params( @@ -590,6 +591,9 @@ fn viewport_json(h: Handle, v: &Viewport) -> Value { }) } +/// VPLAYER's command-line prompts. +mod vplayer; + fn run_viewport_set(s: &mut Session, p: &Value) -> Result { let cmd = "viewport.set"; let d = s.doc()?; diff --git a/crates/engine/src/cmd/layout/vplayer.rs b/crates/engine/src/cmd/layout/vplayer.rs new file mode 100644 index 00000000..eaba2282 --- /dev/null +++ b/crates/engine/src/cmd/layout/vplayer.rs @@ -0,0 +1,388 @@ +//! VPLAYER typed at the command line: an option loop (ends on Enter) that freezes, thaws, resets +//! or recolours layers in chosen viewports, and sets layers' default visibility in new +//! viewports. Changes go through `viewport.set` / `layer.new` / `layer.set`, so validation +//! matches the JSON forms and the whole session is one undo step. + +use cadcraft_color::Color; +use cadcraft_doc::{Handle, Space}; +use cadcraft_geom::Vec2; +use serde_json::{Value, json}; + +use super::super::find_command; +use super::super::qselect::wildcard; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +const OPTIONS: &[&str] = &["?", "Color", "Freeze", "Thaw", "Reset", "Newfrz", "Vpvisdflt"]; + +/// Keyword-only: spaces separate inputs. +const KW: Accept = Accept { point: false, number: false, text: false, select: false, enter: true }; +/// A name or value: Space ends the input, as in AutoCAD scripts. +const NAME: Accept = Accept { number: true, ..Accept::TEXT }; + +/// A per-viewport layer change. +#[derive(Clone, Debug)] +enum Change { + Freeze, + Thaw, + /// Back to the layer's default for new viewports (its "VP freeze in new viewports" flag). + Reset, + Color(Color), +} + +#[derive(Clone, Debug, Default)] +enum Stage { + #[default] + Option, + /// `?`: the viewport whose frozen layers to list. + List, + Color, + TrueColor, + Layers(Change), + Apply(Change, Vec), + Select(Change, Vec, Vec), + Newfrz, + VisLayers, + VisDefault(Vec), +} + +#[derive(Default)] +pub struct VplayerM { + stage: Stage, +} + +fn retry(msg: impl Into) -> EngineError { + EngineError::Other(msg.into()) +} + +fn message(e: &EngineError) -> String { + match e { + EngineError::BadParams { msg, .. } => msg.clone(), + other => other.to_string(), + } +} + +/// Run an engine command's body directly: the running command records the single undo step. +fn call(s: &mut Session, id: &str, p: &Value) -> Result { + let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; + (spec.run)(s, p) +} + +/// Layers matching a comma-separated name list (wildcards allowed), in drawing order. +fn matching(s: &mut Session, list: &str) -> Vec { + let Ok(d) = s.doc() else { return Vec::new() }; + let mut out: Vec = Vec::new(); + let mut missing = Vec::new(); + for pat in list.split(',').map(str::trim).filter(|p| !p.is_empty()).take(1000) { + let before = out.len(); + for l in &d.layers { + if wildcard(pat, &l.name) && !out.iter().any(|n| n.eq_ignore_ascii_case(&l.name)) { + out.push(l.name.clone()); + } + } + if out.len() == before && !d.layers.iter().any(|l| wildcard(pat, &l.name)) { + missing.push(pat.to_string()); + } + } + for m in missing { + s.echo(format!("Cannot find layer \"{m}\".")); + } + out +} + +fn color_value(c: Color) -> Value { + match c { + Color::Index(i) => json!(i), + Color::True(rgb) => json!(format!("{},{},{}", rgb.0, rgb.1, rgb.2)), + Color::ByLayer | Color::ByBlock => Value::Null, + } +} + +fn parse_color(t: &str) -> Result { + match Color::parse(t) { + Some(c @ (Color::Index(_) | Color::True(_))) => Ok(c), + _ => Err(retry("Requires a color name, a number from 1 to 255 or Red,Green,Blue.")), + } +} + +/// The viewport under a picked point: the point is on the sheet in paper space, and in the +/// active viewport's model space inside it (mapped back to the sheet). +fn viewport_at(s: &Session, p: Vec2) -> Option { + let st = s.state().ok()?; + let at = match st.active_viewport() { + Some((_, vp)) => vp.center.xy() + (p - vp.view_center) * (vp.height / vp.view_height), + None => p, + }; + super::layout_viewports(s) + .ok()? + .into_iter() + .rev() + .find(|(_, v)| (at.x - v.center.x).abs() <= v.width / 2.0 && (at.y - v.center.y).abs() <= v.height / 2.0) + .map(|(h, _)| h) +} + +fn active(s: &Session) -> Option { + s.state().ok()?.active_viewport().map(|(h, _)| h) +} + +impl VplayerM { + fn option(&mut self, s: &mut Session, k: &str) -> Result { + self.stage = match k { + "?" => match active(s) { + Some(h) => { + Self::list(s, h); + Stage::Option + } + None => Stage::List, + }, + "Color" => Stage::Color, + "Freeze" => Stage::Layers(Change::Freeze), + "Thaw" => Stage::Layers(Change::Thaw), + "Reset" => Stage::Layers(Change::Reset), + "Newfrz" => Stage::Newfrz, + "Vpvisdflt" => Stage::VisLayers, + _ => return Err(retry("Invalid option keyword.")), + }; + Ok(Step::Continue) + } + + fn list(s: &mut Session, h: Handle) { + let Some(v) = super::layout_viewports(s).ok().and_then(|vs| vs.into_iter().find(|(x, _)| *x == h)).map(|(_, v)| v) else { return }; + s.echo(format!("Layers currently frozen in viewport {}:", v.id)); + if v.frozen_layers.is_empty() { + s.echo(" (none)"); + } + for l in v.frozen_layers { + s.echo(format!(" {l}")); + } + } + + /// Apply `change` to `layers` in the viewports `hs`; refusals are reported. + fn apply(s: &mut Session, change: &Change, layers: &[String], hs: &[Handle]) { + if hs.is_empty() || layers.is_empty() { + return; + } + let handles: Vec = hs.iter().map(|h| h.hex()).collect(); + let mut calls = Vec::new(); + match change { + Change::Freeze => calls.push(json!({ "handles": handles, "freeze": layers })), + Change::Thaw => calls.push(json!({ "handles": handles, "thaw": layers })), + Change::Reset => { + let Ok(d) = s.doc() else { return }; + let (frz, thaw): (Vec, Vec) = layers.iter().cloned().partition(|n| d.layer(n).is_some_and(|l| l.vp_freeze_new)); + calls.push(json!({ "handles": handles, "freeze": frz, "thaw": thaw })); + } + Change::Color(c) => { + let colors: serde_json::Map = layers.iter().map(|l| (l.clone(), color_value(*c))).collect(); + calls.push(json!({ "handles": handles, "colors": colors })); + } + } + for p in calls { + if let Err(e) = super::run_viewport_set(s, &p) { + s.echo(message(&e)); + } + } + } + + fn sub(&mut self, s: &mut Session, stage: Stage, i: Input) -> Result { + let text = match &i { + Input::Text(t) | Input::Keyword(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()), + _ => None, + }; + self.stage = match stage { + Stage::Option => Stage::Option, + Stage::List => match i { + Input::Point(p) => { + match viewport_at(s, p) { + Some(h) => Self::list(s, h), + None => s.echo("No viewport found there."), + } + Stage::Option + } + Input::Enter => Stage::Option, + _ => { + self.stage = Stage::List; + return Err(retry("Requires a point inside a viewport.")); + } + }, + Stage::Color => match (&i, text) { + (Input::Keyword(k), _) if k == "Truecolor" => Stage::TrueColor, + (_, Some(t)) => match parse_color(&t) { + Ok(c) => Stage::Layers(Change::Color(c)), + Err(e) => { + self.stage = Stage::Color; + return Err(e); + } + }, + _ => Stage::Option, + }, + Stage::TrueColor => match text { + Some(t) => match parse_color(&t) { + Ok(c) => Stage::Layers(Change::Color(c)), + Err(e) => { + self.stage = Stage::TrueColor; + return Err(e); + } + }, + None => Stage::Option, + }, + Stage::Layers(change) => match text { + Some(t) => { + let layers = matching(s, &t); + if layers.is_empty() { Stage::Option } else { Stage::Apply(change, layers) } + } + None => Stage::Option, + }, + Stage::Apply(change, layers) => { + let in_vp = active(s); + let k = match (&i, in_vp) { + (Input::Keyword(k), _) => k.as_str(), + (Input::Enter, Some(_)) => "Current", + (Input::Enter, None) => "Select", + _ => { + self.stage = Stage::Apply(change, layers); + return Err(retry("Invalid option keyword.")); + } + }; + let all: Vec = super::layout_viewports(s).map(|v| v.into_iter().map(|(h, _)| h).collect()).unwrap_or_default(); + match k { + "Select" => Stage::Select(change, layers, Vec::new()), + "All" => { + Self::apply(s, &change, &layers, &all); + Stage::Option + } + "Current" => { + Self::apply(s, &change, &layers, &in_vp.into_iter().collect::>()); + Stage::Option + } + _ => { + let others: Vec = all.into_iter().filter(|h| Some(*h) != in_vp).collect(); + Self::apply(s, &change, &layers, &others); + Stage::Option + } + } + } + Stage::Select(change, layers, mut picked) => match i { + Input::Point(p) => { + match viewport_at(s, p) { + Some(h) if !picked.contains(&h) => { + picked.push(h); + s.echo(format!("{} viewport(s) selected.", picked.len())); + } + Some(_) => {} + None => s.echo("No viewport found there."), + } + Stage::Select(change, layers, picked) + } + Input::Enter => { + Self::apply(s, &change, &layers, &picked); + Stage::Option + } + _ => { + self.stage = Stage::Select(change, layers, picked); + return Err(retry("Requires a point inside a viewport.")); + } + }, + Stage::Newfrz => { + if let Some(t) = text { + let all: Vec = s + .doc()? + .layouts + .iter() + .flat_map(|l| l.entities.iter()) + .filter(|e| matches!(&e.kind, cadcraft_doc::EntityKind::Viewport(v) if v.id != 1)) + .map(|e| e.handle) + .collect(); + let mut made = Vec::new(); + for n in t.split(',').map(str::trim).filter(|n| !n.is_empty()).take(1000) { + match call(s, "layer.new", &json!({ "name": n, "newVpFreeze": true })) { + Ok(_) => made.push(n.to_string()), + Err(e) => s.echo(format!("Layer \"{n}\": {}", message(&e))), + } + } + Self::apply(s, &Change::Freeze, &made, &all); + } + Stage::Option + } + Stage::VisLayers => match text { + Some(t) => { + let layers = matching(s, &t); + if layers.is_empty() { Stage::Option } else { Stage::VisDefault(layers) } + } + None => Stage::Option, + }, + Stage::VisDefault(layers) => { + let frozen = match &i { + Input::Keyword(k) => k == "Frozen", + Input::Enter => false, + _ => { + self.stage = Stage::VisDefault(layers); + return Err(retry("Invalid option keyword.")); + } + }; + for n in &layers { + if let Err(e) = call(s, "layer.set", &json!({ "name": n, "newVpFreeze": frozen })) { + s.echo(format!("Layer \"{n}\": {}", message(&e))); + } + } + Stage::Option + } + }; + Ok(Step::Continue) + } +} + +impl Interactive for VplayerM { + fn name(&self) -> &'static str { + "VPLAYER" + } + fn begin(&mut self, s: &mut Session) -> Result { + if s.layout_space() == Space::Model { + s.echo("** Command not allowed in Model Tab ** (switch to a layout first)"); + return Ok(Step::Done); + } + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + match &self.stage { + Stage::Option => Prompt::new("Enter an option", KW).kw(OPTIONS), + Stage::List => Prompt::new("Select a viewport", Accept::POINT), + Stage::Color => Prompt::new("Enter color name or number (1-255)", NAME).kw(&["Truecolor"]), + Stage::TrueColor => Prompt::new("Red, Green, Blue", NAME), + Stage::Layers(c) => { + let what = match c { + Change::Freeze => "to freeze", + Change::Thaw => "to thaw", + Change::Reset => "to reset", + Change::Color(_) => "to change color", + }; + Prompt::new(format!("Enter layer name(s) {what}"), NAME) + } + Stage::Apply(..) => { + if active(s).is_some() { + Prompt::new("Enter an option", KW).kw(&["All", "Select", "Current", "Except current"]).default("Current") + } else { + Prompt::new("Enter an option", KW).kw(&["All", "Select"]).default("Select") + } + } + Stage::Select(..) => Prompt::new("Select viewports", Accept::POINT), + Stage::Newfrz => Prompt::new("Enter name(s) of new layers frozen in all viewports", NAME), + Stage::VisLayers => Prompt::new("Enter layer name(s) to change default viewport visibility", NAME), + Stage::VisDefault(_) => Prompt::new("Enter a default viewport visibility option", KW).kw(&["Frozen", "Thawed"]).default("Thawed"), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match (&self.stage, &i) { + (_, Input::Cancel) => Ok(Step::Cancel), + (Stage::Option, Input::Enter) => Ok(Step::Done), + (Stage::Option, Input::Keyword(k)) => { + let k = k.clone(); + self.option(s, &k) + } + (Stage::Option, _) => Err(retry("Invalid option keyword.")), + _ => { + let stage = std::mem::take(&mut self.stage); + self.sub(s, stage, i) + } + } + } +} diff --git a/crates/engine/tests/vplayer_prompts.rs b/crates/engine/tests/vplayer_prompts.rs new file mode 100644 index 00000000..f7f7e4a1 --- /dev/null +++ b/crates/engine/tests/vplayer_prompts.rs @@ -0,0 +1,67 @@ +//! VPLAYER typed at the command line asks for an option, layer names and the viewports to change +//! instead of running the next inputs as commands. + +use cadcraft_doc::{EntityKind, Handle, Viewport}; +use cadcraft_engine::Session; +use serde_json::json; + +fn vp(s: &Session, h: &str) -> Viewport { + let h = Handle::parse_hex(h).unwrap(); + match &s.doc().unwrap().entity(h).unwrap().kind { + EntityKind::Viewport(v) => v.clone(), + _ => panic!("not a viewport"), + } +} + +fn frozen(s: &Session, h: &str) -> Vec { + let mut f = vp(s, h).frozen_layers; + f.sort(); + f +} + +#[test] +fn typed_vplayer_changes_layers_in_chosen_viewports() { + let mut s = Session::new(); + s.cmdline("line 0,0 100,0").unwrap(); + s.cmdline("").unwrap(); + s.execute("layer.new", &json!({ "name": "Walls" })).unwrap(); + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + let r = s.execute("mview", &json!({ "count": 2, "p1": [20, 20], "p2": [220, 120] })).unwrap(); + let (a, b) = (r["viewports"][0].as_str().unwrap().to_string(), r["viewports"][1].as_str().unwrap().to_string()); + s.execute("mspace", &json!({ "handle": a })).unwrap(); + let undo_depth = s.state().unwrap().undo.len(); + + // Freeze in the current viewport (the default inside a viewport). + s.cmdline("VPLAYER").unwrap(); + assert_eq!(s.current_prompt().unwrap().display(), "Enter an option or [?/Color/Freeze/Thaw/Reset/Newfrz/Vpvisdflt]:"); + s.script("F\n0\n").unwrap(); + assert_eq!(s.current_prompt().unwrap().display(), "Enter an option or [All/Select/Current/Except current] :"); + s.script("\n").unwrap(); + assert_eq!(frozen(&s, &a), ["0"]); + assert!(frozen(&s, &b).is_empty()); + // Still at the option prompt: Thaw everything everywhere, colour Walls in the others. + s.script("T * A\nC 1 Walls E\n").unwrap(); + assert!(frozen(&s, &a).is_empty()); + assert_eq!(vp(&s, &b).layer_colors.len(), 1); + assert!(vp(&s, &a).layer_colors.is_empty()); + // Default visibility, Reset to it, and a new layer frozen in all viewports. + s.script("V Walls F\nR Wal* A\nN Doors\n\n").unwrap(); + assert!(s.current_prompt().is_none(), "Enter ends VPLAYER"); + assert!(s.doc().unwrap().layer("Walls").unwrap().vp_freeze_new); + assert_eq!(frozen(&s, &a), ["Doors", "Walls"]); + assert_eq!(frozen(&s, &b), ["Doors", "Walls"]); + // One undo step for the whole command. + assert_eq!(s.state().unwrap().undo.len(), undo_depth + 1); + + // Paper space: Select (the default there) picks viewports by a point on the sheet; ? lists. + s.cmdline("pspace").unwrap(); + s.script("VPLAYER F 0\n\n170,70\n\n?\n170,70\n\n").unwrap(); + assert_eq!(frozen(&s, &b), ["0", "Doors", "Walls"]); + assert_eq!(frozen(&s, &a), ["Doors", "Walls"]); + assert!(s.log.iter().any(|l| l == " 0"), "? lists the frozen layers"); + + // The Model tab refuses it without taking the next input. + s.execute("layout.set", &json!({ "name": "Model" })).unwrap(); + s.cmdline("VPLAYER").unwrap(); + assert!(s.current_prompt().is_none()); +} From 304e56ba84f0c0a431d5cfa2827c180d2e28e1c8 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 00:10:40 +0200 Subject: [PATCH 130/150] Attributes keep their own layer, colour, linetype and lineweight (fixes #358) (#359) `Attrib` gains optional per-attribute properties (`props`), each overriding the attribute definition's. DXF reading takes them from the ATTRIB's groups 8/6/62/420/370; writing gives each ATTRIB the properties it is drawn with. The renderer prefers them over the definition's. -ATTEDIT's Layer and Color options set them; ATTSYNC resets them to the definitions, as in AutoCAD. Co-authored-by: Claude Opus 5.5 --- crates/doc/src/entity.rs | 36 ++++++++ crates/doc/src/extents.rs | 2 +- crates/doc/src/tests.rs | 1 + crates/engine/src/cmd/blocks.rs | 10 ++- crates/engine/src/cmd/blocks/attedit.rs | 94 +++++++++++++++++++- crates/engine/src/cmd/blocks/attribs.rs | 3 +- crates/engine/tests/attedit_prompts.rs | 2 +- crates/engine/tests/attrib_props.rs | 77 ++++++++++++++++ crates/io/src/dxf_read.rs | 16 ++++ crates/io/src/dxf_write.rs | 56 ++++++++---- crates/io/src/tests.rs | 27 ++++-- crates/io/tests/attrib_props.rs | 112 ++++++++++++++++++++++++ crates/render/src/lib.rs | 39 ++++++--- crates/render/tests/render_props.rs | 11 ++- 14 files changed, 441 insertions(+), 45 deletions(-) create mode 100644 crates/engine/tests/attrib_props.rs create mode 100644 crates/io/tests/attrib_props.rs diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index ad661a6b..a78332ff 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -267,7 +267,43 @@ pub struct Attrib { pub constant: bool, #[serde(default)] pub prompt: String, + /// An attribute's own properties; each one left unset follows the attribute definition. + #[serde(default, skip_serializing_if = "AttribProps::is_empty")] + pub props: AttribProps, } + +/// Properties set on one attribute of a block reference (an ATTRIB's layer, colour, linetype and +/// lineweight), overriding those of its definition. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct AttribProps { + #[serde(skip_serializing_if = "Option::is_none")] + pub layer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub linetype: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lineweight: Option, +} + +impl AttribProps { + /// Nothing set: the attribute is drawn with its definition's properties. + pub fn is_empty(&self) -> bool { + *self == AttribProps::default() + } + /// `base` (the definition's properties) with the ones set here in place. + pub fn over(&self, base: &Common) -> Common { + Common { + layer: self.layer.clone().unwrap_or_else(|| base.layer.clone()), + color: self.color.unwrap_or(base.color), + linetype: self.linetype.clone().unwrap_or_else(|| base.linetype.clone()), + lineweight: self.lineweight.unwrap_or(base.lineweight), + ..base.clone() + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Insert { diff --git a/crates/doc/src/extents.rs b/crates/doc/src/extents.rs index 5f20e6c9..3ba03e2f 100644 --- a/crates/doc/src/extents.rs +++ b/crates/doc/src/extents.rs @@ -283,7 +283,7 @@ mod tests { #[test] fn right_justified_attdef_is_left_of_alignment_point() { let text = text(HAlign::Right, VAlign::Baseline); - let a = crate::Attrib { tag: "HELLO".into(), text, invisible: false, constant: false, prompt: String::new() }; + let a = crate::Attrib { tag: "HELLO".into(), text, invisible: false, constant: false, prompt: String::new(), props: Default::default() }; let b = bounds(EntityKind::AttDef(a)); assert!(b.min.x <= -4.4 && b.max.x < 1.0); } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 2bf9b1f1..0f8d9f04 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -353,6 +353,7 @@ fn attdef_in_block_is_ignored_in_insert_extents() { invisible: false, constant: false, prompt: String::new(), + props: Default::default(), }), ); let d = drawing_with_block(attdef); diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index 8a330ec8..78ab0983 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -170,7 +170,14 @@ pub(crate) fn insert_with( t = tt; } t.value = attedit::value_for(values, &ad.tag).unwrap_or_else(|| ad.text.value.clone()); - ins.attribs.push(Attrib { tag: ad.tag.clone(), text: t, invisible: ad.invisible, constant: false, prompt: String::new() }); + ins.attribs.push(Attrib { + tag: ad.tag.clone(), + text: t, + invisible: ad.invisible, + constant: false, + prompt: String::new(), + props: Default::default(), + }); } } s.add_entity(EntityKind::Insert(ins)) @@ -228,6 +235,7 @@ fn attdef_kind(s: &Session, tag: &str, prompt: &str, default: &str, at: Vec2, he invisible, constant: false, prompt: prompt.into(), + props: Default::default(), }) } diff --git a/crates/engine/src/cmd/blocks/attedit.rs b/crates/engine/src/cmd/blocks/attedit.rs index e4f0fbd0..ad123f0b 100644 --- a/crates/engine/src/cmd/blocks/attedit.rs +++ b/crates/engine/src/cmd/blocks/attedit.rs @@ -2,7 +2,8 @@ //! -ATTEDIT: the attributes picked by block name, tag and value specifications, edited one at a //! time or by replacing a string in all of them. Attribute tags match in any case, as in AutoCAD. -use cadcraft_doc::{EntityKind, Handle}; +use cadcraft_doc::color::Color; +use cadcraft_doc::{AttribProps, Common, EntityKind, Handle}; use serde_json::{Map, Value, json}; use crate::cmd::curves::KW; @@ -136,6 +137,33 @@ fn edit(s: &mut Session, (h, k): (Handle, usize), f: impl FnOnce(&str) -> Option Ok(changed) } +/// 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| { + if let EntityKind::Insert(ins) = &mut e.kind + && let Some(a) = ins.attribs.get_mut(k) + { + f(&mut a.props); + } + })?; + Ok(()) +} + +/// The layer and colour attribute `k` of block reference `h` is drawn with: its own, else its +/// definition's (layer 0 is the reference's layer). +fn current_props(s: &Session, (h, k): (Handle, usize)) -> Option<(String, Color)> { + let d = s.doc().ok()?; + let e = d.entity(h)?; + let EntityKind::Insert(ins) = &e.kind else { return None }; + let a = ins.attribs.get(k)?; + let def = d + .block(&ins.block) + .and_then(|b| b.entities.iter().find(|be| matches!(&be.kind, EntityKind::AttDef(ad) if ad.tag.eq_ignore_ascii_case(&a.tag)))); + let c = a.props.over(&def.map(|de| de.common.clone()).unwrap_or_else(Common::default)); + let layer = if c.layer == "0" { e.common.layer.clone() } else { c.layer }; + Some((layer, c.color)) +} + /// Replace every `find` in `value` with `with`, unless the result would be too long. fn replaced(value: &str, find: &str, with: &str) -> Option { if find.is_empty() || !value.contains(find) { @@ -250,6 +278,9 @@ enum Stage { Option, ValueKind, NewValue, + Layer, + Color, + TrueColor, Find, Replace, } @@ -267,6 +298,9 @@ struct DashAtteditM { find: String, } +/// A colour name, number or `r,g,b` (typed numbers are answers, not distances). +const NAME: Accept = Accept { number: true, ..Accept::TEXT }; + fn invalid_keyword() -> EngineError { EngineError::Other("Invalid option keyword.".into()) } @@ -313,7 +347,8 @@ impl Interactive for DashAtteditM { fn name(&self) -> &'static str { "-ATTEDIT" } - fn prompt(&self, _s: &Session) -> Prompt { + fn prompt(&self, s: &Session) -> Prompt { + let cur = self.items.get(self.cur).and_then(|it| current_props(s, *it)); match self.stage { Stage::OneAtATime => Prompt::new("Edit attributes one at a time?", KW).kw(&["Yes", "No"]).default("Y"), Stage::VisibleOnly => Prompt::new("Edit only attributes visible on screen?", KW).kw(&["Yes", "No"]).default("Y"), @@ -321,9 +356,24 @@ impl Interactive for DashAtteditM { Stage::TagSpec => Prompt::new("Enter attribute tag specification", Accept::TEXT).default("*"), Stage::ValueSpec => Prompt::new("Enter attribute value specification", Accept::TEXT).default("*"), Stage::Select => Prompt::new("Select attributes", Accept::SELECT), - Stage::Option => Prompt::new("Enter an option", KW).kw(&["Value", "Next"]).default("N"), + Stage::Option => Prompt::new("Enter an option", KW).kw(&["Value", "Layer", "Color", "Next"]).default("N"), Stage::ValueKind => Prompt::new("Enter type of value modification", KW).kw(&["Change", "Replace"]).default("R"), Stage::NewValue => Prompt::new("Enter new attribute value", Accept::TEXT), + Stage::Layer => { + let p = Prompt::new("Enter new layer name", Accept::TEXT); + match cur { + Some((layer, _)) => p.default(layer), + None => p, + } + } + Stage::Color => { + let p = Prompt::new("Enter new color", NAME).kw(&["Truecolor", "COlorbook"]); + match cur { + Some((_, c)) => p.default(c.name()), + None => p, + } + } + Stage::TrueColor => Prompt::new("Red, Green, Blue", NAME), Stage::Find => Prompt::new("Enter string to change", Accept::TEXT), Stage::Replace => Prompt::new("Enter new string", Accept::TEXT), } @@ -380,6 +430,8 @@ impl Interactive for DashAtteditM { } Stage::Option => match &i { Input::Keyword(k) if k == "Value" => self.stage = Stage::ValueKind, + Input::Keyword(k) if k == "Layer" => self.stage = Stage::Layer, + Input::Keyword(k) if k == "Color" => self.stage = Stage::Color, Input::Keyword(_) | Input::Enter => { self.cur += 1; if self.cur >= self.items.len() { @@ -389,7 +441,7 @@ impl Interactive for DashAtteditM { } Input::Text(t) => { let tl = t.trim().to_ascii_lowercase(); - let later = ["Position", "Height", "Angle", "Style", "Layer", "Color"]; + let later = ["Position", "Height", "Angle", "Style"]; return match later.iter().find(|k| !tl.is_empty() && k.to_ascii_lowercase().starts_with(&tl)) { Some(k) => Err(EngineError::Other(format!("{k} is not available yet."))), None => Err(invalid_keyword()), @@ -410,6 +462,40 @@ impl Interactive for DashAtteditM { self.show_current(s); self.stage = Stage::Option; } + Stage::Layer => { + let Some(t) = text else { return Ok(Step::Continue) }; + let t = t.trim(); + if !t.is_empty() { + let name = s.doc()?.layer(t).map(|l| l.name.clone()).ok_or_else(|| EngineError::Other(format!("Cannot find layer \"{t}\".")))?; + if let Some(it) = self.items.get(self.cur).copied() { + set_props(s, it, |p| p.layer = Some(name))?; + } + } + self.stage = Stage::Option; + } + Stage::Color | Stage::TrueColor => { + if self.stage == Stage::Color + && let Input::Keyword(k) = &i + { + if k == "Truecolor" { + self.stage = Stage::TrueColor; + return Ok(Step::Continue); + } + return Err(EngineError::Other("Color books are not available yet.".into())); + } + let Some(t) = text else { return Ok(Step::Continue) }; + if !t.trim().is_empty() { + let c = match (self.stage, Color::parse(&t)) { + (Stage::TrueColor, Some(c @ Color::True(_))) | (Stage::Color, Some(c)) => c, + (Stage::TrueColor, _) => return Err(EngineError::Other("Enter three values from 0 to 255, separated by commas.".into())), + _ => return Err(EngineError::Other("Enter a color name or a number from 0 to 256.".into())), + }; + if let Some(it) = self.items.get(self.cur).copied() { + set_props(s, it, |p| p.color = Some(c))?; + } + } + self.stage = Stage::Option; + } Stage::Find => match text { Some(t) if !t.is_empty() => { self.find = t; diff --git a/crates/engine/src/cmd/blocks/attribs.rs b/crates/engine/src/cmd/blocks/attribs.rs index 6c6c5fa3..1cb28a44 100644 --- a/crates/engine/src/cmd/blocks/attribs.rs +++ b/crates/engine/src/cmd/blocks/attribs.rs @@ -83,6 +83,7 @@ impl Interactive for AttdispM { /// The attributes `ins` gets from the definitions `defs` of its block `blk`: placed, styled and /// shown as defined, keeping the values of attributes whose tag (any case) is still defined. +/// Properties set on single attributes (layer, colour, …) go back to the definitions', as in AutoCAD. fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec { let m = ins.transform(blk.base.xy()); defs.iter() @@ -96,7 +97,7 @@ fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec { .find(|a| a.tag.eq_ignore_ascii_case(&ad.tag)) .map(|a| a.text.value.clone()) .unwrap_or_else(|| ad.text.value.clone()); - Attrib { tag: ad.tag.clone(), text: t, invisible: ad.invisible, constant: false, prompt: String::new() } + Attrib { tag: ad.tag.clone(), text: t, invisible: ad.invisible, constant: false, prompt: String::new(), props: Default::default() } }) .collect() } diff --git a/crates/engine/tests/attedit_prompts.rs b/crates/engine/tests/attedit_prompts.rs index 843252bc..61452cbd 100644 --- a/crates/engine/tests/attedit_prompts.rs +++ b/crates/engine/tests/attedit_prompts.rs @@ -83,7 +83,7 @@ fn dash_attedit_one_at_a_time_edits_the_selected_attributes() { let mut s = with_block(); let h = handle(&insert(&mut s, 100.0, json!({"T": "abc"}))); type_lines(&mut s, &["-attedit", "y", "", "T", "", "105,0", ""]); - assert!(s.prompt_text().contains("[Value/Next] "), "{}", s.prompt_text()); + assert!(s.prompt_text().contains("[Value/Layer/Color/Next] "), "{}", s.prompt_text()); // Unsupported options say so and keep the prompt. s.cmdline("p").unwrap(); assert!(s.log.iter().any(|l| l.contains("Position is not available yet")), "{:?}", s.log); diff --git a/crates/engine/tests/attrib_props.rs b/crates/engine/tests/attrib_props.rs new file mode 100644 index 00000000..63745ee3 --- /dev/null +++ b/crates/engine/tests/attrib_props.rs @@ -0,0 +1,77 @@ +//! Layer and colour set on one attribute of a block reference: -ATTEDIT Layer/Color, drawn in +//! place of the definition's, reset by ATTSYNC. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::color::{Color, Rgb}; +use cadcraft_engine::doc::{AttribProps, EntityKind, Handle, Space}; +use cadcraft_engine::render::{Options, build}; +use serde_json::json; + +/// Block B (attributes T and U on layer 0) inserted at 100,0; layer A exists. +fn setup() -> (Session, Handle) { + let mut s = Session::new(); + s.execute("attdef", &json!({"tag": "T", "default": "one", "at": [0, 1]})).unwrap(); + s.execute("attdef", &json!({"tag": "U", "default": "u", "at": [0, 2]})).unwrap(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + let hs: Vec = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect(); + s.execute("block", &json!({"name": "B", "base": [0, 0], "handles": hs, "keep": "delete"})).unwrap(); + let r = s.execute("insert", &json!({"name": "B", "at": [100, 0]})).unwrap(); + s.doc_mut().unwrap().ensure_layer("A"); + (s, Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()) +} + +fn props(s: &Session, h: Handle, tag: &str) -> AttribProps { + let EntityKind::Insert(i) = &s.doc().unwrap().entity(h).unwrap().kind else { panic!("not an insert") }; + i.attribs.iter().find(|a| a.tag == tag).unwrap().props.clone() +} + +fn drawn_in(s: &Session, rgb: Rgb) -> bool { + let l = build(s.doc().unwrap(), &Space::Model, &Options::default()); + l.prims.iter().any(|p| p.color == rgb) +} + +#[test] +fn dash_attedit_sets_layer_and_colour_of_one_attribute() { + let (mut s, h) = setup(); + let blue = Rgb(0, 128, 255); + assert!(!drawn_in(&s, blue)); + for t in ["-attedit", "y", "", "T", "", "105,0", ""] { + s.cmdline(t).unwrap(); + } + assert!(s.prompt_text().contains("[Value/Layer/Color/Next] "), "{}", s.prompt_text()); + s.cmdline("l").unwrap(); + assert!(s.prompt_text().contains("Enter new layer name <0>"), "{}", s.prompt_text()); + // An unknown layer is refused and asked again. + s.cmdline("nope").unwrap(); + assert!(s.log.iter().any(|l| l.contains("Cannot find layer \"nope\"")), "{:?}", s.log); + for t in ["a", "c", "1", "c"] { + s.cmdline(t).unwrap(); + } + assert!(s.prompt_text().contains(""), "{}", s.prompt_text()); + for t in ["t", "0,128,255"] { + s.cmdline(t).unwrap(); + } + assert!(s.prompt_text().contains("Enter an option"), "{}", s.prompt_text()); + s.cmdline("n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + + let p = props(&s, h, "T"); + assert_eq!((p.layer.as_deref(), p.color), (Some("A"), Some(Color::True(blue)))); + assert!(props(&s, h, "U").is_empty(), "only the edited attribute changes"); + assert!(drawn_in(&s, blue), "the attribute's own colour is drawn"); + + // ATTSYNC brings the attributes back to their definitions, as in AutoCAD. + s.execute("attsync", &json!({"name": "B"})).unwrap(); + assert!(props(&s, h, "T").is_empty()); + assert!(!drawn_in(&s, blue)); +} + +#[test] +fn hostile_answers_do_not_panic() { + let (mut s, h) = setup(); + for t in ["-attedit", "y", "", "", "", "105,0", "", "c", "999", "co", "t", "1,2", "300,0,0", "", "l", "", "n", "n"] { + let _ = s.cmdline(t); + } + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert!(props(&s, h, "T").is_empty() && props(&s, h, "U").is_empty()); +} diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 1fd444b1..1c771d6e 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -90,6 +90,19 @@ fn ocs_n(n: Vec3, p: Vec3) -> Vec3 { if (n.z - 1.0).abs() < 1e-12 && n.x.abs() < 1e-12 && n.y.abs() < 1e-12 { p } else { cadcraft_geom::Mat4::ocs(n).apply(p) } } +/// The properties an ATTRIB carries itself: those it has groups for. Its layer counts only when +/// it differs from its block reference's (an attribute defined on layer 0 goes on the reference's +/// layer, as it is drawn anyway). +fn attrib_props(t: &T, insert_layer: &str) -> AttribProps { + let c = common(t); + AttribProps { + layer: t.s(8).filter(|l| !l.is_empty() && !l.eq_ignore_ascii_case(insert_layer)), + color: (t.i(62).is_some() || t.i(420).is_some()).then_some(c.color), + linetype: t.s(6).filter(|l| !l.is_empty()), + lineweight: t.i(370).is_some().then_some(c.lineweight), + } +} + fn text_from(t: &T) -> Text { let h = match t.i(72).unwrap_or(0) { 1 => HAlign::Center, @@ -303,6 +316,7 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { invisible: t.i(70).unwrap_or(0) & 1 != 0, constant: t.i(70).unwrap_or(0) & 2 != 0, prompt: t.s(3).unwrap_or_default(), + props: Default::default(), }), "INSERT" => { // A mirrored block reference comes with extrusion (0,0,-1): its insertion point, scale, @@ -882,6 +896,7 @@ fn parse_entities(recs: &[(String, Vec)], d: &mut Drawing, rx: &mut Rx) -> "INSERT" => { if t.i(66) == Some(1) { let mut attribs = Vec::new(); + let ins_layer = kindent.as_ref().map(|(c, _)| c.layer.clone()).unwrap_or_default(); while let Some((k2, t2)) = recs.get(i) { if k2 == "ATTRIB" { let tt = T(t2); @@ -891,6 +906,7 @@ fn parse_entities(recs: &[(String, Vec)], d: &mut Drawing, rx: &mut Rx) -> invisible: tt.i(70).unwrap_or(0) & 1 != 0, constant: false, prompt: String::new(), + props: attrib_props(&tt, &ins_layer), }); i += 1; } else { diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index dd361d20..44c83254 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -282,21 +282,7 @@ fn common_x(w: &mut W, e: &Entity, owner: &str, paper: bool, subclass: &str, ass if paper { w.i(67, 1); } - w.s(8, &e.common.layer); - if !e.common.linetype.eq_ignore_ascii_case("bylayer") { - w.s(6, &e.common.linetype); - } - match e.common.color { - Color::ByLayer => {} - Color::True(rgb) => { - w.i(62, i64::from(cadcraft_color::nearest_aci(rgb))); - w.i(420, i64::from(rgb.to_u32())); - } - c => w.i(62, i64::from(c.to_aci())), - } - if e.common.lineweight != Lineweight::ByLayer { - w.i(370, i64::from(e.common.lineweight.to_dxf())); - } + layer_and_props(w, &e.common, None); if (e.common.ltscale - 1.0).abs() > 1e-12 { w.f(48, e.common.ltscale); } @@ -311,6 +297,43 @@ fn common_x(w: &mut W, e: &Entity, owner: &str, paper: bool, subclass: &str, ass } } +/// Layer, linetype, colour and lineweight groups (8, 6, 62/420, 370). ByLayer is left out, unless +/// it replaces something else in `base` (an attribute's definition). +fn layer_and_props(w: &mut W, c: &Common, base: Option<&Common>) { + let write = |by_layer: bool, base_by_layer: fn(&Common) -> bool| !by_layer || base.is_some_and(|b| !base_by_layer(b)); + w.s(8, &c.layer); + if write(c.linetype.eq_ignore_ascii_case("bylayer"), |b| b.linetype.eq_ignore_ascii_case("bylayer")) { + w.s(6, &c.linetype); + } + match c.color { + Color::ByLayer if write(true, |b| b.color == Color::ByLayer) => w.i(62, 256), + Color::ByLayer => {} + Color::True(rgb) => { + w.i(62, i64::from(cadcraft_color::nearest_aci(rgb))); + w.i(420, i64::from(rgb.to_u32())); + } + c => w.i(62, i64::from(c.to_aci())), + } + if write(c.lineweight == Lineweight::ByLayer, |b| b.lineweight == Lineweight::ByLayer) { + w.i(370, i64::from(c.lineweight.to_dxf())); + } +} + +/// The properties attribute `a` of block reference `e` (of `ins`) is drawn with: its own, else its +/// definition's; an attribute defined on layer 0 is on the reference's layer. Also the +/// definition's properties. +fn attrib_common(d: &Drawing, e: &Entity, ins: &Insert, a: &Attrib) -> (Common, Common) { + let def = d + .block(&ins.block) + .and_then(|b| b.entities.iter().find(|be| matches!(&be.kind, EntityKind::AttDef(ad) if ad.tag.eq_ignore_ascii_case(&a.tag)))); + let base = def.map(|de| de.common.clone()).unwrap_or_default(); + let mut c = a.props.over(&base); + if c.layer == "0" { + c.layer.clone_from(&e.common.layer); + } + (c, base) +} + /// ATTRIB/ATTDEF flags (group 70): 1 invisible, 2 constant. fn attrib_flags(a: &Attrib) -> i64 { i64::from(a.invisible) | if a.constant { 2 } else { 0 } @@ -553,7 +576,8 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx w.s(5, ah); w.s(330, e.handle.hex()); w.s(100, "AcDbEntity"); - w.s(8, &e.common.layer); + let (c, base) = attrib_common(d, e, i, a); + layer_and_props(w, &c, Some(&base)); w.s(100, "AcDbText"); text_tags(w, &a.text, Some((&a.tag, attrib_flags(a))), None); } diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index fc865d08..cdd7f69b 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -262,8 +262,14 @@ fn block_definition_properties_and_constant_attdefs_roundtrip() { halign: HAlign::Left, valign: VAlign::Baseline, }; - let attdef = - |tag: &str, invisible: bool, constant: bool| Attrib { tag: tag.into(), text: text.clone(), invisible, constant, prompt: String::new() }; + let attdef = |tag: &str, invisible: bool, constant: bool| Attrib { + tag: tag.into(), + text: text.clone(), + invisible, + constant, + prompt: String::new(), + props: Default::default(), + }; for (i, a) in [attdef("COMPANY", false, true), attdef("SECRET", true, true), attdef("SHEET", false, false)].into_iter().enumerate() { b.entities.push(Entity::new(Handle(0x500 + i as u64), EntityKind::AttDef(a))); } @@ -1124,11 +1130,15 @@ fn intl_sample() -> Drawing { }), ) .unwrap(); - let def = Attrib { tag: "图号".into(), text: intl_text("默认"), invisible: false, constant: false, prompt: String::new() }; + let def = + Attrib { + tag: "图号".into(), text: intl_text("默认"), invisible: false, constant: false, prompt: String::new(), props: Default::default() + }; let mut b = Block::new(&block); b.entities.push(Entity::new(Handle(0x50), EntityKind::AttDef(def))); d.blocks.insert(block.clone(), std::sync::Arc::new(b)); - let att = Attrib { tag: "图号".into(), text: intl_text(INTL), invisible: false, constant: false, prompt: String::new() }; + let att = + Attrib { tag: "图号".into(), text: intl_text(INTL), invisible: false, constant: false, prompt: String::new(), props: Default::default() }; d.add( &Space::Model, on, @@ -1369,7 +1379,14 @@ fn attdef_prompt_survives_dxf_roundtrip() { valign: VAlign::Baseline, }; let attdef = |prompt: &str| { - EntityKind::AttDef(Attrib { tag: "TAG1".into(), text: text.clone(), invisible: false, constant: false, prompt: prompt.into() }) + EntityKind::AttDef(Attrib { + tag: "TAG1".into(), + text: text.clone(), + invisible: false, + constant: false, + prompt: prompt.into(), + props: Default::default(), + }) }; d.add(&Space::Model, Default::default(), attdef("Enter value")).unwrap(); d.add(&Space::Model, Default::default(), attdef("")).unwrap(); diff --git a/crates/io/tests/attrib_props.rs b/crates/io/tests/attrib_props.rs new file mode 100644 index 00000000..82266c24 --- /dev/null +++ b/crates/io/tests/attrib_props.rs @@ -0,0 +1,112 @@ +//! Properties set on one attribute of a block reference (an ATTRIB's layer, colour, linetype and +//! lineweight) survive a DXF save and reopen; untouched attributes stay untouched. + +use std::sync::Arc; + +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::*; +use cadcraft_geom::Vec3; +use cadcraft_io::{read_dxf, write_dxf}; + +fn text(x: f64, value: &str) -> Text { + Text { + insert: Vec3::new(x, 0.0, 0.0), + align_pt: None, + height: 1.0, + value: value.into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: HAlign::Left, + valign: VAlign::Baseline, + } +} + +fn attrib(tag: &str, x: f64, props: AttribProps) -> Attrib { + Attrib { tag: tag.into(), text: text(x, tag), invisible: false, constant: false, prompt: String::new(), props } +} + +/// Block TAGS with definitions OWN (layer 0), DEFLAYER (layer TXT) and RED (layer 0, red), +/// inserted on layer INS with the given attributes. +fn drawing(attribs: Vec) -> (Drawing, Handle) { + let mut d = Drawing::new_metric(); + for l in ["TXT", "INS", "A"] { + d.ensure_layer(l); + } + let mut blk = Block::new("TAGS"); + for (i, (tag, common)) in [ + ("OWN", Common::default()), + ("DEFLAYER", Common { layer: "TXT".into(), ..Common::default() }), + ("RED", Common { color: Color::Index(1), ..Common::default() }), + ] + .into_iter() + .enumerate() + { + let e = Entity::new(Handle(0x100 + i as u64), EntityKind::AttDef(attrib(tag, 0.0, AttribProps::default()))); + blk.entities.push(Entity { common, ..e }); + } + d.blocks.insert(blk.name.clone(), Arc::new(blk)); + let ins = Insert { + block: "TAGS".into(), + insert: Vec3::ZERO, + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs, + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + let h = d.add(&Space::Model, Common { layer: "INS".into(), ..Common::default() }, EntityKind::Insert(ins)).expect("add"); + (d, h) +} + +fn reopened_attribs(d: &Drawing, h: Handle) -> Vec { + let r = read_dxf(write_dxf(d).as_bytes()).expect("reopen"); + match &r.model.get(h).expect("insert kept").kind { + EntityKind::Insert(i) => i.attribs.clone(), + k => panic!("not an insert: {k:?}"), + } +} + +#[test] +fn attribute_properties_roundtrip() { + let own = AttribProps { + layer: Some("A".into()), + color: Some(Color::True(Rgb(10, 20, 30))), + linetype: Some("DASHED".into()), + lineweight: Some(Lineweight::Mm100(50)), + }; + let red = AttribProps { color: Some(Color::ByLayer), ..AttribProps::default() }; + let (d, h) = drawing(vec![attrib("OWN", 0.0, own.clone()), attrib("DEFLAYER", 10.0, AttribProps::default()), attrib("RED", 20.0, red.clone())]); + let a = reopened_attribs(&d, h); + assert_eq!(a.len(), 3); + assert_eq!(a[0].props, own); + // Without properties of its own an ATTRIB is written with its definition's, so other readers + // show it the same; reading those back gives the same drawing. + assert_eq!(a[1].props.layer.as_deref(), Some("TXT")); + assert_eq!((a[1].props.color, a[1].props.linetype.as_deref(), a[1].props.lineweight), (None, None, None)); + // ByLayer on an attribute whose definition is red stays ByLayer. + assert_eq!(a[2].props.color, Some(Color::ByLayer)); +} + +#[test] +fn attributes_without_properties_stay_without() { + let (d, h) = drawing(vec![attrib("OWN", 0.0, AttribProps::default()), attrib("NODEF", 10.0, AttribProps::default())]); + let a = reopened_attribs(&d, h); + assert_eq!(a.len(), 2); + assert!(a.iter().all(|a| a.props.is_empty()), "{a:?}"); +} + +#[test] +fn hostile_attrib_groups_do_not_panic() { + let dxf = "0\nSECTION\n2\nENTITIES\n0\nINSERT\n5\n20\n8\n0\n66\n1\n2\nNOPE\n10\n0\n20\n0\n30\n0\n\ +0\nATTRIB\n5\n21\n8\n\n6\n\n62\n99999\n420\n-5\n370\n-99999\n10\n0\n20\n0\n30\n0\n40\n1\n1\nv\n2\nT\n70\n0\n\ +0\nSEQEND\n5\n22\n0\nENDSEC\n0\nEOF\n"; + let d = read_dxf(dxf.as_bytes()).expect("read"); + let EntityKind::Insert(i) = &d.model.iter().next().expect("insert").kind else { panic!("not an insert") }; + let p = &i.attribs.first().expect("attrib").props; + assert_eq!((p.layer.as_deref(), p.linetype.as_deref()), (None, None), "empty names are not properties"); + let _ = write_dxf(&d); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 768bb540..b499aa60 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -429,16 +429,20 @@ pub fn build_entities<'a, I: IntoIterator>(d: &Drawing, ents: } fn resolve(ctx: &Ctx, e: &Entity, plotting: bool) -> (Ink, f32, Option, f64, bool) { + resolve_common(ctx, &e.common, plotting) +} + +/// [`resolve`] for an object's properties `c`. +fn resolve_common(ctx: &Ctx, c: &cadcraft_doc::Common, plotting: bool) -> (Ink, f32, Option, f64, bool) { let d = ctx.d; // Layer "0" inside a block takes the insert's layer. - let layer_name = if e.common.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { e.common.layer.as_str() }; + let layer_name = if c.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { c.layer.as_str() }; let layer = d.layer(layer_name); - let visible = e.common.visible - && layer.is_none_or(|l| l.visible() && (!plotting || l.plot)) - && !ctx.frozen.iter().any(|f| f.eq_ignore_ascii_case(layer_name)); + let visible = + c.visible && layer.is_none_or(|l| l.visible() && (!plotting || l.plot)) && !ctx.frozen.iter().any(|f| f.eq_ignore_ascii_case(layer_name)); let layer_color = ctx.layer_color(layer_name).unwrap_or(Color::Index(7)); - let rgb = ink(e.common.color, layer_color, ctx.block_color); - let lw = match e.common.lineweight { + let rgb = ink(c.color, layer_color, ctx.block_color); + let lw = match c.lineweight { Lineweight::ByLayer => layer.map(|l| l.lineweight).unwrap_or(Lineweight::Default), Lineweight::ByBlock => ctx.block_lw, x => x, @@ -447,13 +451,13 @@ fn resolve(ctx: &Ctx, e: &Entity, plotting: bool) -> (Ink, f32, Option f32::from(v) / 100.0, _ => default_lineweight(d), }; - let lt_name = match e.common.linetype.to_ascii_lowercase().as_str() { + let lt_name = match c.linetype.to_ascii_lowercase().as_str() { "bylayer" => layer.map(|l| l.linetype.clone()).unwrap_or_else(|| "Continuous".into()), "byblock" => ctx.block_ltype.clone(), - _ => e.common.linetype.clone(), + _ => c.linetype.clone(), }; let lt = d.linetype(<_name).filter(|l| !l.pattern.is_empty()).cloned(); - let scale = d.header.f64("LTSCALE", 1.0) * e.common.ltscale * ctx.lt_factor; + let scale = d.header.f64("LTSCALE", 1.0) * c.ltscale * ctx.lt_factor; (rgb, lw_mm, lt, scale, visible) } @@ -954,14 +958,16 @@ fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rg } let (sh, _) = place_text_entity(ctx.d, &a.text, &a.text.value); // Properties of the attribute's definition (as AutoCAD copies them to the attribute - // on insertion): ByBlock = the reference's, layer 0 = the reference's layer. + // on insertion), except those set on the attribute itself: ByBlock = the reference's, + // layer 0 = the reference's layer. let def = ctx.d.block(&ins.block).and_then(|blk| blk.entities.iter().find(|be| attdef_tag(be, &a.tag))); - let (ink, lw, shown) = match def { - Some(def) => { - let (ink, lw, _, _, shown) = resolve(&sub_ctx(ctx, e, Mat3::IDENTITY), def, b.plotting); + let (ink, lw, shown) = match (def, a.props.is_empty()) { + (None, true) => (rgb, resolve(ctx, e, b.plotting).1, true), + (def, _) => { + let c = a.props.over(&def.map(|d| d.common.clone()).unwrap_or_else(by_block)); + let (ink, lw, _, _, shown) = resolve_common(&sub_ctx(ctx, e, Mat3::IDENTITY), &c, b.plotting); (ink, lw, shown) } - None => (rgb, resolve(ctx, e, b.plotting).1, true), }; if shown { b.shaped(ctx, ink, if b.opts.lineweights { lw } else { 0.0 }, &sh); @@ -970,6 +976,11 @@ fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rg } } +/// Properties that follow the block reference's (an attribute without a definition). +fn by_block() -> cadcraft_doc::Common { + cadcraft_doc::Common { color: Color::ByBlock, linetype: "ByBlock".into(), lineweight: Lineweight::ByBlock, ..cadcraft_doc::Common::default() } +} + /// The attribute definition with tag `tag` (any case). fn attdef_tag(e: &Entity, tag: &str) -> bool { matches!(&e.kind, EntityKind::AttDef(d) if d.tag.eq_ignore_ascii_case(tag)) diff --git a/crates/render/tests/render_props.rs b/crates/render/tests/render_props.rs index 19ba7be1..13e22221 100644 --- a/crates/render/tests/render_props.rs +++ b/crates/render/tests/render_props.rs @@ -110,7 +110,7 @@ fn text(x: f64, value: &str) -> Text { } fn attdef(handle: u64, tag: &str, x: f64, common: Common) -> Entity { - let a = Attrib { tag: tag.into(), text: text(x, tag), invisible: false, constant: false, prompt: String::new() }; + let a = Attrib { tag: tag.into(), text: text(x, tag), invisible: false, constant: false, prompt: String::new(), props: Default::default() }; Entity { common, ..Entity::new(Handle(handle), EntityKind::AttDef(a)) } } @@ -136,7 +136,14 @@ fn attributes_draw_with_their_own_properties() { blk.entities.push(attdef(0x13, "OWN", 30.0, Common { color: Color::Index(6), lineweight: Lineweight::Mm100(70), ..on("TXT") })); d.blocks.insert(blk.name.clone(), Arc::new(blk)); let attribs = [("bylayer", 0.0), ("ByBlock", 10.0), ("LAYER0", 20.0), ("OWN", 30.0), ("NODEF", 40.0)] - .map(|(tag, x)| Attrib { tag: tag.into(), text: text(x, "X"), invisible: false, constant: false, prompt: String::new() }) + .map(|(tag, x)| Attrib { + tag: tag.into(), + text: text(x, "X"), + invisible: false, + constant: false, + prompt: String::new(), + props: Default::default(), + }) .to_vec(); let ins = Insert { block: "TAGS".into(), From d461b9587220a1db92e67c5e57c2fd5bcf9cfa03 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 00:11:05 +0200 Subject: [PATCH 131/150] ZOOM: Center, Scale, Dynamic and a plain n; All/Extents fit the sheet in paper space (fixes #388) (#391) Center, Scale and Dynamic all zoomed to the extents, so the point or factor typed after them ran as a command. Center now asks for the centre point and a magnification or height (a height, nX or nXP), Scale for a factor, and Dynamic, which needs a drag, asks for a window. A plain n (at the first prompt and at Scale) is relative to the limits, as in AutoCAD: 1 fits them. ZOOM All and Extents with nothing drawn used the model limits in paper space too; Session::zoom_limits gives the sheet there. Builds on #372 (ZOOM inside a viewport). Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/view.rs | 156 +++++++++++++++++++++------ crates/engine/src/lib.rs | 22 ++-- crates/engine/tests/zoom_keywords.rs | 59 ++++++++++ 3 files changed, 199 insertions(+), 38 deletions(-) create mode 100644 crates/engine/tests/zoom_keywords.rs diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 9e43c830..b52ec17f 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec { CommandSpec::new("zoom", "Zoom", run_zoom) .menu(&["View", "Zoom", "Realtime"]) .alias(&["z"]) - .params("{mode: extents|all|window|previous|in|out|center|scale|object, p1?, p2?, center?, height?, factor?, xp?: bool (scale relative to paper space: in a viewport, `factor` paper units per model unit)}") + .params("{mode: extents|all|window|previous|in|out|center|scale|object, p1?, p2?, center?, height?, factor?, xp?: bool (scale relative to paper space: in a viewport, `factor` paper units per model unit), limits?: bool (scale relative to the limits)} (center: `height`, or `factor` with `xp`/`limits`)") .noundo() .transparent() .interactive(|_| Ok(Box::new(ZoomM::default()))), @@ -107,10 +107,7 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result { match mode.as_str() { "extents" | "e" => s.zoom_extents()?, "all" | "a" => { - let d = s.doc()?; - let lo = d.header.point("LIMMIN").map(|p| p.xy()).unwrap_or(Vec2::ZERO); - let hi = d.header.point("LIMMAX").map(|p| p.xy()).unwrap_or(Vec2::new(12.0, 9.0)); - let b = ext_bounds(s)?.union(&Bounds2::new(lo, hi)); + let b = ext_bounds(s)?.union(&s.zoom_limits()?); fit(s, b.expand(b.height().max(b.width()) * 0.02))?; } "window" | "w" => { @@ -134,23 +131,14 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result { } "center" | "c" => { let c = point_req("zoom", p, "center")?; - let st = s.state_mut()?; - let h = f64_or(p, "height", st.zoom_frame().height); - st.set_zoom_frame(View { center: c, height: h.max(1e-9) }); + let cur = s.state()?.zoom_frame().height; + let h = if p.get("factor").is_some() { factor_height(s, p, cur)? } else { f64_or(p, "height", cur) }; + s.state_mut()?.set_zoom_frame(View { center: c, height: h.max(1e-9) }); } "scale" | "s" => { - let f = f64_req("zoom", p, "factor")?; - if f <= 0.0 { - return Err(bad("zoom", "factor must be positive")); - } - let st = s.state_mut()?; - let v = st.zoom_frame(); - // nXP in a viewport sets its scale: `f` paper units per model unit. - let height = match st.zoom_viewport() { - Some(vp) if bool_or(p, "xp", false) => vp.height / f, - _ => v.height / f, - }; - st.set_zoom_frame(View { center: v.center, height }); + let v = s.state()?.zoom_frame(); + let height = factor_height(s, p, v.height)?; + s.state_mut()?.set_zoom_frame(View { center: v.center, height }); } "object" | "o" => { let hs = targets(s, p)?; @@ -164,6 +152,54 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// The view height for a zoom `factor`: relative to the current height `cur` (nX), to paper space +/// with `xp` (nXP; in a viewport `factor` paper units per model unit, elsewhere like nX), or to +/// the limits with `limits` (a plain n: 1 fits the limits). +fn factor_height(s: &Session, p: &Value, cur: f64) -> Result { + let f = f64_req("zoom", p, "factor")?; + if f <= 0.0 { + return Err(bad("zoom", "factor must be positive")); + } + let st = s.state()?; + if bool_or(p, "xp", false) + && let Some(vp) = st.zoom_viewport() + { + return Ok(vp.height / f); + } + if bool_or(p, "limits", false) { + let lim = s.zoom_limits()?; + let (w, h) = s.viewport_px; + let aspect = st.zoom_aspect(w / h.max(1.0)).max(1e-6); + return Ok(lim.height().max(lim.width() / aspect).max(1e-9) / f); + } + Ok(cur / f) +} + +/// A typed zoom factor: `n` (relative to the limits), `nX` (to the current view) or `nXP` (to +/// paper space), as `zoom` scale parameters. +fn typed_factor(t: &str) -> Option { + let tl = t.trim().to_ascii_lowercase(); + let (num, rel) = if let Some(n) = tl.strip_suffix("xp") { + (n, "xp") + } else if let Some(n) = tl.strip_suffix('x') { + (n, "x") + } else { + (tl.as_str(), "limits") + }; + let f = number(num.trim()).filter(|f| *f > 0.0)?; + Some(json!({ "factor": f, "xp": rel == "xp", "limits": rel == "limits" })) +} + +fn with_mode(mut p: Value, mode: &str, center: Option) -> Value { + if let Some(o) = p.as_object_mut() { + o.insert("mode".into(), json!(mode)); + if let Some(c) = center { + o.insert("center".into(), json!([c.x, c.y])); + } + } + p +} + fn run_zoom(s: &mut Session, p: &Value) -> Result { zoom(s, if p.is_null() { &Value::Null } else { p }) } @@ -232,13 +268,33 @@ fn run_layout_set(s: &mut Session, p: &Value) -> Result { struct ZoomM { window: bool, first: Option, + stage: ZoomStage, +} + +/// The Center and Scale options' own prompts. +#[derive(Clone, Copy, Default)] +enum ZoomStage { + #[default] + Main, + Center, + CenterHeight(Vec2), + Scale, } impl Interactive for ZoomM { fn name(&self) -> &'static str { "ZOOM" } - fn prompt(&self, _s: &Session) -> Prompt { + fn prompt(&self, s: &Session) -> Prompt { + match self.stage { + ZoomStage::Main => {} + ZoomStage::Center => return Prompt::new("Specify center point", Accept::POINT), + ZoomStage::CenterHeight(_) => { + let h = s.state().map(|st| st.zoom_frame().height).unwrap_or(1.0); + return Prompt::new("Enter magnification or height", Accept::NUMBER).default(format!("{h:.4}")); + } + ZoomStage::Scale => return Prompt::new("Enter a scale factor (nX or nXP)", Accept::NUMBER), + } match (self.window, self.first) { (false, None) => Prompt::new("Specify corner of window, enter a scale factor (nX or nXP)", Accept::POINT_OR_NUMBER) .kw(&["All", "Center", "Dynamic", "Extents", "Previous", "Scale", "Window", "Object"]) @@ -248,18 +304,63 @@ impl Interactive for ZoomM { } } fn input(&mut self, s: &mut Session, i: Input) -> Result { + let bad_factor = || crate::EngineError::Other("Requires a scale factor (n, nX or nXP).".into()); + match (self.stage, &i) { + (_, Input::Cancel) | (ZoomStage::Main, _) => {} + (ZoomStage::Center, Input::Point(c)) => { + self.stage = ZoomStage::CenterHeight(*c); + return Ok(Step::Continue); + } + (ZoomStage::Center, Input::Enter) => return Ok(Step::Done), + (ZoomStage::Center, _) => return Err(crate::EngineError::Other("Requires a point.".into())), + (ZoomStage::CenterHeight(c), Input::Enter) => { + zoom(s, &json!({ "mode": "center", "center": [c.x, c.y] }))?; + return Ok(Step::Done); + } + (ZoomStage::CenterHeight(c), Input::Text(t)) => { + let tl = t.trim().to_ascii_lowercase(); + // A plain number is the new view height; nX / nXP a magnification. + let p = if tl.ends_with('x') || tl.ends_with("xp") { + typed_factor(&tl).ok_or_else(bad_factor)? + } else { + json!({ "height": number(&tl).filter(|h| *h > 0.0).ok_or_else(bad_factor)? }) + }; + zoom(s, &with_mode(p, "center", Some(c)))?; + return Ok(Step::Done); + } + (ZoomStage::Scale, Input::Text(t)) => { + zoom(s, &with_mode(typed_factor(t).ok_or_else(bad_factor)?, "scale", None))?; + return Ok(Step::Done); + } + (ZoomStage::Scale, Input::Enter) => return Ok(Step::Done), + (_, _) => return Err(bad_factor()), + } match i { Input::Keyword(k) => { let mode = match k.as_str() { "All" => "all", "Extents" => "extents", "Previous" => "previous", + "Dynamic" => { + // Dynamic zoom drags a view box; without one it asks for a window. + s.echo("Dynamic zoom: specify the new view as a window."); + self.window = true; + return Ok(Step::Continue); + } "Window" => { self.window = true; return Ok(Step::Continue); } + "Center" => { + self.stage = ZoomStage::Center; + return Ok(Step::Continue); + } + "Scale" => { + self.stage = ZoomStage::Scale; + return Ok(Step::Continue); + } "Object" => "object", - _ => "extents", + _ => return Err(crate::EngineError::Other("Invalid option keyword.".into())), }; zoom(s, &json!({ "mode": mode }))?; Ok(Step::Done) @@ -275,14 +376,9 @@ impl Interactive for ZoomM { } }, Input::Text(t) => { - let tl = t.trim().to_ascii_lowercase(); - let num = tl.trim_end_matches("xp").trim_end_matches('x'); - let f = number(num) - .filter(|f| *f > 0.0) - .ok_or_else(|| crate::EngineError::Other("Requires a point, scale factor or option keyword.".into()))?; - // nXP: relative to paper space; nX and a plain number (approximation: like nX) - // relative to the current view. - zoom(s, &json!({ "mode": "scale", "factor": f, "xp": tl.ends_with("xp") }))?; + // nX: relative to the current view; nXP: to paper space; n: to the limits. + let p = typed_factor(&t).ok_or_else(|| crate::EngineError::Other("Requires a point, scale factor or option keyword.".into()))?; + zoom(s, &with_mode(p, "scale", None))?; Ok(Step::Done) } Input::Enter => Ok(Step::Done), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b368afa4..3020d349 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1089,18 +1089,24 @@ impl Session { // ---------------- views ---------------- + /// The limits of the space being edited: the sheet in paper space, else LIMMIN/LIMMAX. + pub fn zoom_limits(&self) -> Result { + let d = self.doc()?; + if let Space::Paper(name) = self.space() + && let Some(sheet) = cadcraft_render::sheet(d, &name) + { + return Ok(sheet.bounds()); + } + let lo = d.header.point("LIMMIN").map(|p| p.xy()).unwrap_or(Vec2::ZERO); + let hi = d.header.point("LIMMAX").map(|p| p.xy()).unwrap_or(Vec2::new(12.0, 9.0)); + Ok(Bounds2::new(lo, hi)) + } + pub fn zoom_extents(&mut self) -> Result<()> { let space = self.space(); let ext = self.doc()?.extents(&space); let (w, h) = self.viewport_px; - let ext = if ext.is_empty() { - let d = self.doc()?; - let lo = d.header.point("LIMMIN").map(|p| p.xy()).unwrap_or(Vec2::ZERO); - let hi = d.header.point("LIMMAX").map(|p| p.xy()).unwrap_or(Vec2::new(12.0, 9.0)); - Bounds2::new(lo, hi) - } else { - ext - }; + let ext = if ext.is_empty() { self.zoom_limits()? } else { ext }; let st = self.state_mut()?; let aspect = st.zoom_aspect(w / h.max(1.0)); let height = ext.height().max(ext.width() / aspect.max(1e-6)).max(1e-6) * 1.05; diff --git a/crates/engine/tests/zoom_keywords.rs b/crates/engine/tests/zoom_keywords.rs new file mode 100644 index 00000000..f06fde8f --- /dev/null +++ b/crates/engine/tests/zoom_keywords.rs @@ -0,0 +1,59 @@ +//! Typed ZOOM Center, Scale, Dynamic and a plain scale factor do what they say, and ZOOM All / +//! Extents in paper space fit the sheet (#388). + +use cadcraft_engine::{Session, View}; +use cadcraft_geom::Vec2; +use serde_json::json; + +fn view(s: &Session) -> View { + s.state().unwrap().view() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 * b.abs().max(1.0) +} + +#[test] +fn zoom_center_scale_dynamic_and_paper_space_all() { + let mut s = Session::new(); + s.viewport_px = (1600.0, 900.0); + s.execute("limits", &json!({ "min": [0, 0], "max": [420, 297] })).unwrap(); + s.execute("line", &json!({ "points": [[0, 0], [1000, 0]] })).unwrap(); + + // Center: point, then a height; nX is a magnification of the current height. + s.cmdline("ZOOM C").unwrap(); + assert_eq!(s.current_prompt().unwrap().display(), "Specify center point:"); + s.cmdline("500,0").unwrap(); + assert!(s.current_prompt().unwrap().display().starts_with("Enter magnification or height <")); + s.cmdline("100").unwrap(); + assert!(s.current_prompt().is_none()); + let v = view(&s); + assert!(v.center.near(Vec2::new(500.0, 0.0), 1e-9) && near(v.height, 100.0), "{v:?}"); + s.script("ZOOM C 0,0 2X\n").unwrap(); + let v = view(&s); + assert!(v.center.near(Vec2::ZERO, 1e-9) && near(v.height, 50.0), "{v:?}"); + + // Scale: nX relative to the view; a plain n relative to the limits (1 fits them). + s.script("ZOOM S 0.5X\n").unwrap(); + assert!(near(view(&s).height, 100.0), "{:?}", view(&s)); + s.cmdline("ZOOM 1").unwrap(); + // 420 × 297 at 16:9 is limited by the height. + assert!(near(view(&s).height, 297.0) && view(&s).center.near(Vec2::ZERO, 1e-9), "{:?}", view(&s)); + s.cmdline("ZOOM S 2").unwrap(); + assert!(near(view(&s).height, 148.5), "{:?}", view(&s)); + + // Dynamic asks for a window instead of zooming to the extents. + s.script("ZOOM D 0,0 160,90\n").unwrap(); + let v = view(&s); + assert!(v.center.near(Vec2::new(80.0, 45.0), 1e-9) && near(v.height, 90.0), "{v:?}"); + + // Paper space: All and Extents (no paper objects) fit the sheet, not the model limits. + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + let sheet = s.zoom_limits().unwrap(); + assert!(sheet.min == Vec2::ZERO && !near(sheet.max.x, 420.0), "the sheet, not the limits: {sheet:?}"); + s.cmdline("ZOOM E").unwrap(); + assert!(view(&s).center.near(sheet.center(), 1e-9), "{:?} vs {sheet:?}", view(&s)); + s.cmdline("ZOOM A").unwrap(); + let v = view(&s); + assert!(v.center.near(sheet.center(), 1e-9) && v.height < 297.0 && v.height >= sheet.height(), "{v:?} vs {sheet:?}"); +} From 964eb0fb40c624e2981a7480894c482bb3de772f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 18:11:18 -0400 Subject: [PATCH 132/150] cargo fmt after the community PR batch Co-Authored-By: Claude Opus 5.5 --- crates/geom/src/tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 0f80e154..dd7b806c 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -125,7 +125,10 @@ fn tiny_circle_secants_and_separated_circles() { fn huge_circle_secants_remain_finite() { let hits = circle_circle(&Circle::new(Vec2::ZERO, 1e160), &Circle::new(Vec2::new(1e160, 0.0), 1e160)); assert_eq!(hits.len(), 2); - assert!(hits.iter().all(|p| p.is_finite() && (p.x / 1e160 - 0.5).abs() < 1e-12 && (p.y.abs() / 1e160 - 3.0_f64.sqrt() / 2.0).abs() < 1e-12), "{hits:?}"); + assert!( + hits.iter().all(|p| p.is_finite() && (p.x / 1e160 - 0.5).abs() < 1e-12 && (p.y.abs() / 1e160 - 3.0_f64.sqrt() / 2.0).abs() < 1e-12), + "{hits:?}" + ); } #[test] From bc2c80289ca0cf450d7c483a1b28fe1791e2c673 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:43:06 +0700 Subject: [PATCH 133/150] fix(select): include solid interiors in crossing-window selection (#401) * fix(select): include solid interiors in crossing-window selection * test(select): keep solid crossing regressions in an isolated module --- crates/engine/src/select.rs | 55 +++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/select.rs b/crates/engine/src/select.rs index 550dd163..421265ae 100644 --- a/crates/engine/src/select.rs +++ b/crates/engine/src/select.rs @@ -193,8 +193,21 @@ pub(crate) fn in_window(d: &Drawing, e: &Entity, known: Option, bx: &Bo pl.windows(2).any(|w| w.first().zip(w.get(1)).is_some_and(|(a, b)| seg_hits_box(*a, *b, bx))) } }); - // A crossing box entirely inside a filled/text object also selects it. - hit || (eb.contains_box(bx) && matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::Insert(_))) + // A crossing box entirely inside a text/block bounding rectangle also selects it. + if hit || (eb.contains_box(bx) && matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::Insert(_))) { + return true; + } + // SOLID/TRACE are filled polygons. A crossing window wholly within the + // fill must select them even when it does not touch their outline. Check + // the polygon, not just its bounding rectangle (which can cover empty + // space around triangular solids). + if matches!(e.kind, EntityKind::Solid(_) | EntityKind::Trace(_)) && eb.contains_box(bx) { + return e.kind.prims().iter().any(|prim| match prim { + Prim::Fill(poly) => bx.corners().iter().all(|p| cadcraft_geom::point_in_polygon(poly, *p)), + _ => false, + }); + } + false } /// Fence selection: entities crossed by the fence polyline. @@ -255,3 +268,41 @@ mod tests { assert_eq!(fence_hits(EntityKind::Ray(line), [(5.0, 1.0), (5.0, 5.0)]), 0); } } + +#[cfg(test)] +mod solid_interior_tests { + use super::*; + + fn face(kind: bool, triangular: bool) -> Entity { + let a = Vec2::new(0.0, 0.0).to3(0.0); + let b = Vec2::new(10.0, 0.0).to3(0.0); + let c = Vec2::new(0.0, 10.0).to3(0.0); + let d = if triangular { c } else { Vec2::new(10.0, 10.0).to3(0.0) }; + let solid = cadcraft_doc::Solid { corners: [a, b, c, d] }; + Entity::new(Handle(256), if kind { EntityKind::Solid(solid) } else { EntityKind::Trace(solid) }) + } + + #[test] + fn crossing_window_inside_solid_and_trace_selects_fill() { + let inside = Bounds2::new(Vec2::new(4.0, 4.0), Vec2::new(6.0, 6.0)); + for solid in [true, false] { + let mut d = Drawing::default(); + let e = face(solid, false); + assert!(in_window(&d, &e, None, &inside, true, 0.01)); + assert!(!in_window(&d, &e, None, &inside, false, 0.01)); + d.model.push(e); + assert_eq!(select_window(&d, &Space::Model, inside, true), vec![Handle(256)]); + assert!(select_window(&d, &Space::Model, inside, false).is_empty()); + } + } + + #[test] + fn crossing_window_in_triangular_bbox_but_outside_fill_does_not_select() { + let d = Drawing::default(); + let e = face(true, true); + let empty = Bounds2::new(Vec2::new(7.0, 7.0), Vec2::new(8.0, 8.0)); + assert!(!in_window(&d, &e, None, &empty, true, 0.01)); + let inside = Bounds2::new(Vec2::new(3.0, 3.0), Vec2::new(4.0, 4.0)); + assert!(in_window(&d, &e, None, &inside, true, 0.01)); + } +} From 95dc3f22a7e8732ac40f45256549840a053e1706 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:43:10 +0700 Subject: [PATCH 134/150] fix(geom): keep finite-scale tolerance and midpoint math stable (#400) --- crates/geom/src/bounds.rs | 30 +++++++++++++++++++++++++++++- crates/geom/src/vec.rs | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/crates/geom/src/bounds.rs b/crates/geom/src/bounds.rs index 3946b240..83233d10 100644 --- a/crates/geom/src/bounds.rs +++ b/crates/geom/src/bounds.rs @@ -53,7 +53,19 @@ impl Bounds2 { if self.is_empty() { 0.0 } else { self.max.y - self.min.y } } pub fn center(&self) -> Vec2 { - if self.is_empty() { Vec2::ZERO } else { self.min.mid(self.max) } + if self.is_empty() { + return Vec2::ZERO; + } + // (max - min) can overflow even when both endpoints and their midpoint + // are finite. Halve first when the endpoints have opposite signs. + let mid = |a: f64, b: f64| { + if a.is_sign_negative() != b.is_sign_negative() { + a * 0.5 + b * 0.5 + } else { + a + (b - a) * 0.5 + } + }; + Vec2::new(mid(self.min.x, self.max.x), mid(self.min.y, self.max.y)) } pub fn contains(&self, p: Vec2) -> bool { p.x >= self.min.x && p.x <= self.max.x && p.y >= self.min.y && p.y <= self.max.y @@ -75,3 +87,19 @@ impl Bounds2 { [self.min, Vec2::new(self.max.x, self.min.y), self.max, Vec2::new(self.min.x, self.max.y)] } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn center_stays_finite_for_opposite_large_coordinates() { + let b = Bounds2::new(Vec2::new(-1e308, -1e308), Vec2::new(1e308, 1e308)); + assert_eq!(b.center(), Vec2::ZERO); + let b = Bounds2::new(Vec2::new(1e308, 0.0), Vec2::new(1.6e308, 10.0)); + assert!(b.center().x.is_finite()); + assert!((b.center().x / 1e308 - 1.3).abs() < 1e-12); + assert_eq!(b.center().y, 5.0); + assert_eq!(Bounds2::EMPTY.center(), Vec2::ZERO); + } +} diff --git a/crates/geom/src/vec.rs b/crates/geom/src/vec.rs index 438ae330..a75492c6 100644 --- a/crates/geom/src/vec.rs +++ b/crates/geom/src/vec.rs @@ -72,7 +72,9 @@ impl Vec2 { self.x.is_finite() && self.y.is_finite() } pub fn near(self, o: Vec2, tol: f64) -> bool { - (self - o).len2() <= tol * tol + // Squaring very large finite values can overflow on both sides and make + // unrelated points compare equal (infinity <= infinity). + tol.is_finite() && tol >= 0.0 && self.dist(o) <= tol } pub fn to3(self, z: f64) -> Vec3 { Vec3::new(self.x, self.y, z) @@ -162,7 +164,8 @@ impl Vec3 { Vec3::new(self.y * o.z - self.z * o.y, self.z * o.x - self.x * o.z, self.x * o.y - self.y * o.x) } pub fn len(self) -> f64 { - self.dot(self).sqrt() + // Avoid overflowing the squared components of large finite vectors. + self.x.hypot(self.y).hypot(self.z) } pub fn normalized(self) -> Vec3 { let l = self.len(); @@ -197,3 +200,29 @@ impl Neg for Vec3 { Vec3::new(-self.x, -self.y, -self.z) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn near_uses_finite_nonnegative_tolerance_without_squaring() { + let origin = Vec2::ZERO; + let distant = Vec2::new(1e200, 0.0); + assert!(!distant.near(origin, 1e100), "overflowed squares must not compare equal"); + assert!(distant.near(origin, 1.1e200)); + assert!(origin.near(origin, 0.0)); + assert!(!origin.near(origin, -1.0)); + assert!(!origin.near(origin, f64::INFINITY)); + } + + #[test] + fn large_finite_vec3_has_finite_length_and_unit_direction() { + let v = Vec3::new(3e200, 4e200, 0.0); + assert!((v.len() / 1e200 - 5.0).abs() < 1e-12); + let unit = v.normalized(); + assert!((unit.x - 0.6).abs() < 1e-12); + assert!((unit.y - 0.8).abs() < 1e-12); + assert!((unit.len() - 1.0).abs() < 1e-12); + } +} From 9e19a10b298d4d5e81ecf6d6aa161b6b66059440 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 00:43:15 +0200 Subject: [PATCH 135/150] Layouts: a viewport on a frozen layer hides its contents (fixes #395) (#396) Freezing a viewport's own layer hid only its border; the model space seen through it still showed on screen and in plots. As in AutoCAD, a frozen viewport layer now hides the contents too, while turning the layer off still hides only the border. The check is in the shared layout path, so display, PDF/SVG/PNG export and plotting agree. Co-authored-by: Claude Opus 5.5 --- crates/render/src/lib.rs | 4 ++ crates/render/tests/viewport_frozen_layer.rs | 44 ++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 crates/render/tests/viewport_frozen_layer.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b499aa60..84e2bd7f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -316,6 +316,10 @@ fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewpor if vp.id == 1 || !e.common.visible { return; } + // The viewport's layer frozen hides its contents too (layer off hides only the border). + if d.layer(&e.common.layer).is_some_and(|l| l.frozen) { + return; + } // Border (on the viewport's layer; layer off hides only the border). entity(b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); if b.list.expanded >= MAX_BLOCK_EXPANSION { diff --git a/crates/render/tests/viewport_frozen_layer.rs b/crates/render/tests/viewport_frozen_layer.rs new file mode 100644 index 00000000..9198b26f --- /dev/null +++ b/crates/render/tests/viewport_frozen_layer.rs @@ -0,0 +1,44 @@ +//! A layout viewport whose own layer is frozen shows neither its border nor its contents, on +//! screen and when plotting; turned off, the layer hides only the border (issue #395). + +use cadcraft_doc::{Circle, Common, Drawing, EntityKind, Space, Viewport}; +use cadcraft_geom::{Vec2, Vec3}; +use cadcraft_render::{DisplayList, Options, VIEWPORT_CONTENT, build, build_plot}; + +fn drawing() -> Drawing { + let mut d = Drawing::new_imperial(); + d.add(&Space::Model, Common::default(), EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 5.0 })).unwrap(); + d.ensure_layer("VPORTS"); + let vp = Viewport { + center: Vec3::new(5.0, 4.0, 0.0), + width: 8.0, + height: 6.0, + view_center: Vec2::ZERO, + view_height: 12.0, + id: 2, + locked: false, + frozen_layers: Vec::new(), + layer_colors: Vec::new(), + }; + d.add(&Space::Paper("Layout1".into()), Common { layer: "VPORTS".into(), ..Default::default() }, EntityKind::Viewport(vp)).unwrap(); + d +} + +/// (contents drawn, border drawn) on screen and in a plot. +fn shown(d: &Drawing) -> [(bool, bool); 2] { + let space = Space::Paper("Layout1".into()); + let parts = |l: DisplayList| (l.prims.iter().any(|p| p.handle == VIEWPORT_CONTENT), l.prims.iter().any(|p| p.handle != VIEWPORT_CONTENT)); + [parts(build(d, &space, &Options::default())), parts(build_plot(d, &space, &Options::default()))] +} + +#[test] +fn frozen_viewport_layer_hides_the_contents_off_only_the_border() { + let mut d = drawing(); + assert_eq!(shown(&d), [(true, true); 2]); + d.layer_mut("VPORTS").unwrap().on = false; + assert_eq!(shown(&d), [(true, false); 2], "off: contents stay, border hidden"); + let l = d.layer_mut("VPORTS").unwrap(); + l.on = true; + l.frozen = true; + assert_eq!(shown(&d), [(false, false); 2], "frozen: contents hidden too"); +} From 156bd0304ea9a7042ebb1d284c32b0b60343e722 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 00:43:20 +0200 Subject: [PATCH 136/150] Dimension text: DIMPOST's suffix also goes on tolerances (fixes #390) (#394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With DIMTOL on, the DIMPOST suffix was added to the measurement only. As in AutoCAD, the suffix (what follows `<>`, or the whole value when there is no `<>`) now also follows each tolerance value: symmetric (10.0mm±0.1mm) and stacked deviations alike. The prefix stays on the measurement; limits are unchanged. Co-authored-by: Claude Opus 5.5 --- crates/render/src/dim.rs | 8 +++-- crates/render/tests/dim_post_tolerance.rs | 44 +++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 crates/render/tests/dim_post_tolerance.rs diff --git a/crates/render/src/dim.rs b/crates/render/src/dim.rs index 4d02c3b0..c8bae882 100644 --- a/crates/render/src/dim.rs +++ b/crates/render/src/dim.rs @@ -290,11 +290,13 @@ fn linear_text(meas: f64, d: &Dimension, st: &DimStyle, prefix: &str) -> String let mut s = post(&st.post, &main); if st.tolerance && !st.limits { let tf = NumFormat { decimals: st.tol_decimals, zin: st.tol_zero_suppression, round: 0.0, ..nf.clone() }; + // DIMPOST's suffix (what follows `<>`, or all of it) also goes on the tolerance values. + let suffix = st.post.split_once("<>").map_or(st.post.as_str(), |(_, after)| after); if (st.tol_plus - st.tol_minus).abs() < 1e-12 { - s += &format!("%%p{}", format_linear(st.tol_plus, &tf).1); + s += &format!("%%p{}{suffix}", format_linear(st.tol_plus, &tf).1); } else { - let plus = format_linear(st.tol_plus, &tf).1; - let minus = format_linear(st.tol_minus, &tf).1; + let plus = format_linear(st.tol_plus, &tf).1 + suffix; + let minus = format_linear(st.tol_minus, &tf).1 + suffix; let sign = |x: &str, pos: char| if x.trim_start_matches(['0', '.', ',']).is_empty() { x.to_string() } else { format!("{pos}{x}") }; let (plus, minus) = (sign(&plus, '+'), if st.tol_minus < 0.0 { format!("+{}", minus.trim_start_matches('-')) } else { sign(&minus, '-') }); diff --git a/crates/render/tests/dim_post_tolerance.rs b/crates/render/tests/dim_post_tolerance.rs new file mode 100644 index 00000000..24ee2885 --- /dev/null +++ b/crates/render/tests/dim_post_tolerance.rs @@ -0,0 +1,44 @@ +//! DIMPOST's suffix goes on the tolerance values as well as on the measurement (issue #390). + +use cadcraft_doc::{DimKind, DimStyle, Dimension}; +use cadcraft_geom::Vec3; +use cadcraft_render::dimension_geometry; + +fn text(vars: serde_json::Value) -> (String, String) { + let d = Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(0.0, 5.0, 0.0), + text_mid: Vec3::ZERO, + p13: Vec3::ZERO, + p14: Vec3::new(10.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 0.0, + text_rotation: 0.0, + user_text_pos: false, + block: None, + overrides: Default::default(), + assoc: Vec::new(), + }; + let mut st = DimStyle::default(); + let rejected = st.apply_fields(vars.as_object().unwrap()); + assert!(rejected.is_empty(), "unknown variables {rejected:?}"); + let g = dimension_geometry(&d, &st, 1.0); + (g.value, g.mtext) +} + +#[test] +fn dimpost_suffix_applies_to_tolerances() { + let sym = serde_json::json!({"DIMPOST": "mm", "DIMDEC": 1, "DIMTOL": 1, "DIMTP": 0.1, "DIMTM": 0.1, "DIMTDEC": 1}); + assert_eq!(text(sym).0, "10.0mm±0.1mm"); + // With `<>` only the part after it is the suffix; the prefix stays on the measurement. + let dev = serde_json::json!({"DIMPOST": "L=<> mm", "DIMDEC": 1, "DIMTOL": 1, "DIMTP": 0.1, "DIMTM": 0.2, "DIMTDEC": 1}); + let (_, mtext) = text(dev); + assert!(mtext.starts_with("L=10.0 mm{"), "{mtext}"); + assert!(mtext.contains("\\S+0.1 mm^-0.2 mm;"), "{mtext}"); + // No DIMPOST: unchanged. + let plain = serde_json::json!({"DIMDEC": 1, "DIMTOL": 1, "DIMTP": 0.1, "DIMTM": 0.1, "DIMTDEC": 1}); + assert_eq!(text(plain).0, "10.0±0.1"); +} From 65db0c2f6af7b244762fcc3d3e944a2501cb3672 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 00:43:24 +0200 Subject: [PATCH 137/150] Point prompts: FROM, M2P/MTP, point filters, * prefix and :", "First point of mid:", ".X of", "(need YZ):") and hands one point to the command, so every command gets them from the command line and in scripts, and they nest (FROM then M2P). `*x,y`, `*@` and `@*` parse as world coordinates (the same as the plain forms: no UCS yet). ` --- crates/engine/src/guard.rs | 3 +- crates/engine/src/lib.rs | 18 +- crates/engine/src/pointmod.rs | 243 +++++++++++++++++++++++++ crates/engine/src/prompt.rs | 12 +- crates/engine/tests/point_modifiers.rs | 133 ++++++++++++++ docs/gaps.md | 4 +- docs/ui-parity.md | 10 +- 7 files changed, 412 insertions(+), 11 deletions(-) create mode 100644 crates/engine/src/pointmod.rs create mode 100644 crates/engine/tests/point_modifiers.rs diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 39a2e6c7..01b647be 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -45,7 +45,8 @@ impl Interactive for Guarded { fn prompt(&self, s: &Session) -> Prompt { if self.broken.get().is_none() { match catch_unwind(AssertUnwindSafe(|| self.inner.prompt(s))) { - Ok(p) => return p, + // Pending point modifiers (FROM, M2P…) ask for their points in its place. + Ok(p) => return s.overlay_prompt(p), Err(_) => self.broken.set(Some("panic in prompt")), } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3020d349..6059bec5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,6 +13,7 @@ pub mod cmd; mod finite; pub mod grips; mod guard; +pub mod pointmod; pub mod prompt; pub mod sample; pub mod select; @@ -425,6 +426,8 @@ pub struct Session { pub last_command: Option, /// LASTPOINT. pub last_point: Vec2, + /// Point modifiers pending at the current point prompt (`FROM`, `M2P`, `.x`, `, /// Cursor position in world coordinates (from the UI; used for direct distance entry). pub cursor: Vec2, /// The deferred tangent/perpendicular snap under the cursor, if any (from the UI; lets the @@ -466,6 +469,7 @@ impl Session { log: Vec::new(), last_command: None, last_point: Vec2::ZERO, + point_mods: Vec::new(), cursor: Vec2::ZERO, cursor_deferred: None, viewport_px: (1200.0, 800.0), @@ -693,6 +697,9 @@ impl Session { } return Ok(()); } + if !self.point_mods.is_empty() { + return self.modifier_input(input); + } // A deferred snap is only meaningful where the prompt resolves it; elsewhere it is the // point it was picked at. let input = match input { @@ -766,7 +773,11 @@ impl Session { Err(_) => Err(EngineError::Internal(run.id.clone(), "panic".into())), } } - None => run.machine.begin(self), + // A command starts with no modifiers pending. + None => { + self.point_mods.clear(); + run.machine.begin(self) + } }; // Redraw what the command has added so far (LINE adds a segment per point), not only when it ends. if let Ok(st) = self.state_mut() @@ -833,6 +844,7 @@ impl Session { /// Cancel the running command (Esc). With no command, clears the selection. pub fn cancel(&mut self) { + self.point_mods.clear(); if self.running.is_some() { let _ = self.feed(Some(Input::Cancel)); } else { @@ -928,6 +940,10 @@ impl Session { if tt.is_empty() { return self.input(Input::Enter); } + // FROM, M2P, point filters, `:", "First point of mid:", ".X of", +//! "(need YZ):") instead of the command's, collects the points they need and finally hands one +//! point to the command. Every point prompt of every command gets them this way, from the command +//! line and in scripts alike, and they nest (`FROM` then `M2P`). + +use cadcraft_geom::Vec2; + +use crate::prompt::{Accept, Input, Prompt}; +use crate::{Result, Session}; + +/// The most modifiers pending at once (bounds hostile scripts such as `FROM FROM FROM …`). +const MAX_FRAMES: usize = 32; + +/// The coordinates a point filter takes from its first point. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Axes { + pub x: bool, + pub y: bool, + pub z: bool, +} + +impl Axes { + /// `.x`, `.y`, `.z`, `.xy`, `.xz` or `.yz` (case-insensitive). + pub fn parse(t: &str) -> Option { + let a = match t.to_ascii_lowercase().as_str() { + ".x" => (true, false, false), + ".y" => (false, true, false), + ".z" => (false, false, true), + ".xy" => (true, true, false), + ".xz" => (true, false, true), + ".yz" => (false, true, true), + _ => return None, + }; + Some(Axes { x: a.0, y: a.1, z: a.2 }) + } + + fn letters(self, keep: bool) -> String { + [(self.x, 'X'), (self.y, 'Y'), (self.z, 'Z')].iter().filter(|(on, _)| *on == keep).map(|(_, c)| *c).collect() + } + + /// The filter as typed: ".XY". + pub fn name(self) -> String { + format!(".{}", self.letters(true)) + } + + /// The coordinates still needed: "YZ". + pub fn need(self) -> String { + self.letters(false) + } + + /// The filtered coordinates of `first` with the rest from `second` (Z is not stored: there + /// is no 3D yet, so a Z taken from either point is dropped). + pub fn combine(self, first: Vec2, second: Vec2) -> Vec2 { + Vec2::new(if self.x { first.x } else { second.x }, if self.y { first.y } else { second.y }) + } +} + +/// A pending point modifier. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Frame { + /// `FROM`: the base point once given, then the offset point. + From { base: Option }, + /// `M2P`/`MTP`: the first point once given, then the second. + Mid { first: Option }, + /// A point filter: the point to take `keep` from once given, then the rest. + Filter { keep: Axes, first: Option }, + /// ` &str { + t.trim().trim_start_matches(['\'', '_']) +} + +/// Whether `t` is exactly one of the prompt's keywords (the whole word or its capital-letter +/// shortcut): a command's own keyword wins over a modifier spelled the same. +fn exact_keyword(prompt: &Prompt, t: &str) -> bool { + prompt.match_keyword(t).is_some_and(|k| { + let short: String = k.chars().filter(|c| c.is_ascii_uppercase() || c.is_ascii_digit()).collect(); + k.eq_ignore_ascii_case(t) || short.eq_ignore_ascii_case(t) + }) +} + +/// Whether point modifiers apply at this prompt: it asks for a point that is not an object pick. +pub fn accepts(prompt: &Prompt) -> bool { + prompt.accept.point && !prompt.picks_objects() +} + +impl Session { + /// The prompt shown while modifiers are pending, built over the command's own prompt. + pub(crate) fn overlay_prompt(&self, cmd: Prompt) -> Prompt { + let point = Accept::POINT; + self.point_mods.iter().fold(cmd, |p, f| match *f { + Frame::From { base: None } => Prompt::new("Base point", point), + Frame::From { base: Some(b) } => Prompt::new("", point).base(b), + Frame::Mid { first: None } => Prompt::new("First point of mid", point), + Frame::Mid { first: Some(f) } => Prompt::new("Second point of mid", point).base(f), + Frame::Filter { keep, first: None } => Prompt::new(format!("{} of", keep.name()), point).base_opt(p.base), + Frame::Filter { keep, first: Some(_) } => Prompt::new(format!("(need {})", keep.need()), point).base_opt(p.base), + Frame::Angle { .. } => p, + }) + } + + /// The modifier `t` names at `prompt`, if any. + fn parse_modifier(&self, prompt: &Prompt, t: &str) -> Option { + if exact_keyword(prompt, t) { + return None; + } + let b = bare(t); + if let Some(a) = b.strip_prefix('<') { + // ` Some(Frame::From { base: None }), + "m2p" | "mtp" => Some(Frame::Mid { first: None }), + _ => None, + } + } + + /// A token typed at a point prompt that a modifier handles: a modifier name, or a distance + /// along an angle override. `None` leaves the token to the usual parsing. + pub(crate) fn typed_modifier(&mut self, prompt: &Prompt, t: &str) -> Option> { + if !accepts(prompt) { + return None; + } + if let Some(f) = self.parse_modifier(prompt, t) { + if self.point_mods.len() >= MAX_FRAMES { + self.echo("Too many point modifiers."); + return Some(Ok(())); + } + if let Frame::Angle { .. } = f { + self.echo(format!("Angle Override: {}", bare(t).trim_start_matches('<'))); + } + self.point_mods.push(f); + return Some(Ok(())); + } + if let Some(Frame::Angle { base, dir }) = self.point_mods.last().copied() { + // Typed coordinates are explicit: they win over the override. + if crate::prompt::parse_point_with(t, self.last_point, &self.angle_settings()).is_some() { + self.point_mods.pop(); + return None; + } + if let Some(d) = crate::units::parse_distance(t) { + self.point_mods.pop(); + return Some(self.modifier_point(base + dir * d)); + } + } + None + } + + /// An input while modifiers are pending. + pub(crate) fn modifier_input(&mut self, input: Input) -> Result<()> { + // An angle override only constrains a point: anything else goes to the prompt below it. + if let Some(Frame::Angle { .. }) = self.point_mods.last() + && !matches!(input, Input::Point(_) | Input::Deferred(_)) + { + self.point_mods.pop(); + return self.input(input); + } + match input { + Input::Point(p) => self.modifier_point(p), + Input::Deferred(d) => self.modifier_point(d.at), + // Enter drops the modifiers and returns to the command's prompt. + Input::Enter => { + self.point_mods.clear(); + Ok(()) + } + Input::Cancel => { + self.point_mods.clear(); + self.input(Input::Cancel) + } + Input::Keyword(_) | Input::Text(_) | Input::Pick(_) => { + self.echo("Invalid point."); + Ok(()) + } + } + } + + /// A point given while modifiers are pending: complete the top frame, and when it has its + /// point, hand that to the frame (or command) below. + pub(crate) fn modifier_point(&mut self, mut p: Vec2) -> Result<()> { + if !p.is_finite() { + self.echo("Invalid point."); + return Ok(()); + } + while let Some(top) = self.point_mods.pop() { + match top { + Frame::From { base: None } => { + self.point_mods.push(Frame::From { base: Some(p) }); + // `@dx,dy` at ":" is measured from the base point. + self.last_point = p; + return Ok(()); + } + Frame::Mid { first: None } => { + self.point_mods.push(Frame::Mid { first: Some(p) }); + self.last_point = p; + return Ok(()); + } + Frame::Filter { keep, first: None } => { + self.point_mods.push(Frame::Filter { keep, first: Some(p) }); + return Ok(()); + } + // The offset point is already measured from the base (`@` reads the last point). + Frame::From { base: Some(_) } => {} + Frame::Mid { first: Some(f) } => p = (f + p) * 0.5, + Frame::Filter { keep, first: Some(f) } => p = keep.combine(f, p), + Frame::Angle { base, dir } => p = base + dir * (p - base).dot(dir), + } + } + self.point_mods.clear(); + self.feed(Some(Input::Point(p))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filters() { + let a = Axes::parse(".X").unwrap(); + assert_eq!((a.name().as_str(), a.need().as_str()), (".X", "YZ")); + assert_eq!(a.combine(Vec2::new(1.0, 2.0), Vec2::new(3.0, 4.0)), Vec2::new(1.0, 4.0)); + let a = Axes::parse(".yz").unwrap(); + assert_eq!((a.name().as_str(), a.need().as_str()), (".YZ", "X")); + assert_eq!(a.combine(Vec2::new(1.0, 2.0), Vec2::new(3.0, 4.0)), Vec2::new(3.0, 2.0)); + assert!(Axes::parse(".5").is_none() && Axes::parse(".w").is_none()); + } +} diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs index dd8ea770..112c1807 100644 --- a/crates/engine/src/prompt.rs +++ b/crates/engine/src/prompt.rs @@ -150,7 +150,9 @@ pub trait Interactive: Send { } } -/// Parse a typed point: `x,y[,z]`, `@dx,dy`, `@dist Option { @@ -164,8 +166,10 @@ pub fn parse_point_with(text: &str, last: Vec2, angles: &crate::units::AngleSett if t.is_empty() { return None; } + let t = t.strip_prefix('*').map_or(t, str::trim_start); let (rel, body) = if let Some(b) = t.strip_prefix('@') { - (true, b.trim()) + let b = b.trim(); + (true, b.strip_prefix('*').map_or(b, str::trim_start)) } else if let Some(b) = t.strip_prefix('#') { (false, b.trim()) } else { @@ -209,6 +213,10 @@ mod tests { assert!(parse_point("5<0", last).unwrap().near(Vec2::new(5.0, 0.0), 1e-9)); assert_eq!(parse_point("#1,1", last), Some(Vec2::new(1.0, 1.0))); assert_eq!(parse_point("@", last), Some(last)); + assert_eq!(parse_point("*3,4", last), Some(Vec2::new(3.0, 4.0))); + assert_eq!(parse_point("*@1,2", last), Some(Vec2::new(11.0, 12.0))); + assert_eq!(parse_point("@*1,2", last), Some(Vec2::new(11.0, 12.0))); + assert_eq!(parse_point("*", last), None); assert_eq!(parse_point("1", last), None); assert_eq!(parse_point("a,b", last), None); assert_eq!(parse_point("1,2,3,4", last), None); diff --git a/crates/engine/tests/point_modifiers.rs b/crates/engine/tests/point_modifiers.rs new file mode 100644 index 00000000..8155bffe --- /dev/null +++ b/crates/engine/tests/point_modifiers.rs @@ -0,0 +1,133 @@ +//! Point modifiers at point prompts (#392): FROM, M2P/MTP, point filters, the `*` prefix and the +//! angle override ` Vec<(Vec2, Vec2)> { + s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some((l.a.xy(), l.b.xy())) } else { None }).collect() +} + +fn line_is(l: Option<&(Vec2, Vec2)>, a: (f64, f64), b: (f64, f64)) -> bool { + l.is_some_and(|(p, q)| p.near(Vec2::new(a.0, a.1), 1e-9) && q.near(Vec2::new(b.0, b.1), 1e-9)) +} + +#[test] +fn from_takes_a_base_point_and_an_offset() { + let mut s = Session::new(); + s.cmdline("line from").unwrap(); + assert!(s.prompt_text().ends_with("Base point:"), "{}", s.prompt_text()); + s.cmdline("10,10").unwrap(); + assert!(s.prompt_text().ends_with(":"), "{}", s.prompt_text()); + assert_eq!(s.current_prompt().unwrap().base, Some(Vec2::new(10.0, 10.0)), "rubber band from the base point"); + s.cmdline("@5,0").unwrap(); + assert!(s.prompt_text().contains("Specify next point"), "back at the command's prompt: {}", s.prompt_text()); + s.cmdline("@0,5").unwrap(); + s.cmdline("").unwrap(); + assert!(line_is(lines(&s).first(), (15.0, 10.0), (15.0, 15.0)), "{:?}", lines(&s)); + + // Any command's point prompt takes it: the centre of a circle 3,4 from the origin. + s.cmdline("circle from 0,0 @3,4 2").unwrap(); + let Some(EntityKind::Circle(c)) = s.doc().unwrap().model.iter().last().map(|e| e.kind.clone()) else { panic!("no circle") }; + assert!(c.center.xy().near(Vec2::new(3.0, 4.0), 1e-9) && (c.radius - 2.0).abs() < 1e-9); +} + +#[test] +fn m2p_and_mtp_give_the_midpoint_between_two_points() { + let mut s = Session::new(); + s.cmdline("line m2p").unwrap(); + assert!(s.prompt_text().ends_with("First point of mid:"), "{}", s.prompt_text()); + s.cmdline("0,0").unwrap(); + assert!(s.prompt_text().ends_with("Second point of mid:"), "{}", s.prompt_text()); + s.cmdline("10,4").unwrap(); + s.cmdline("_mtp 20,0 20,20").unwrap(); + s.cmdline("").unwrap(); + assert!(line_is(lines(&s).first(), (5.0, 2.0), (20.0, 10.0)), "{:?}", lines(&s)); +} + +#[test] +fn point_filters_take_coordinates_from_two_points() { + let mut s = Session::new(); + s.cmdline("line 0,0 .x").unwrap(); + assert!(s.prompt_text().ends_with(".X of:"), "{}", s.prompt_text()); + s.cmdline("7,99").unwrap(); + assert!(s.prompt_text().ends_with("(need YZ):"), "{}", s.prompt_text()); + s.cmdline("3,4").unwrap(); + s.cmdline(".Y 99,6").unwrap(); + s.input(Input::Point(Vec2::new(1.0, 99.0))).unwrap(); + s.cmdline(".xy 8,9 0,0,5").unwrap(); + s.cmdline("").unwrap(); + let l = lines(&s); + assert!(line_is(l.first(), (0.0, 0.0), (7.0, 4.0)), "{l:?}"); + assert!(line_is(l.get(1), (7.0, 4.0), (1.0, 6.0)), "{l:?}"); + assert!(line_is(l.get(2), (1.0, 6.0), (8.0, 9.0)), "{l:?}"); +} + +#[test] +fn star_prefix_reads_world_coordinates() { + let mut s = Session::new(); + s.cmdline("line *1,1 *@2,0 @*0,3").unwrap(); + s.cmdline("").unwrap(); + let l = lines(&s); + assert!(line_is(l.first(), (1.0, 1.0), (3.0, 1.0)) && line_is(l.get(1), (3.0, 1.0), (3.0, 4.0)), "{l:?}"); +} + +#[test] +fn angle_override_locks_the_direction_of_the_next_point() { + let mut s = Session::new(); + s.cmdline("line 1,1 <90").unwrap(); + assert!(s.log.iter().any(|l| l == "Angle Override: 90"), "{:?}", s.log); + assert!(s.prompt_text().contains("Specify next point"), "the command's prompt stays: {}", s.prompt_text()); + // A typed distance runs along the locked angle. + s.cmdline("5").unwrap(); + // A picked point is brought onto the locked line. + s.cmdline("<0").unwrap(); + s.input(Input::Point(Vec2::new(4.0, 9.0))).unwrap(); + // The lock lasts one point only. + s.input(Input::Point(Vec2::new(4.0, 9.0))).unwrap(); + s.cmdline("").unwrap(); + let l = lines(&s); + assert!(line_is(l.first(), (1.0, 1.0), (1.0, 6.0)), "{l:?}"); + assert!(line_is(l.get(1), (1.0, 6.0), (4.0, 6.0)), "{l:?}"); + assert!(line_is(l.get(2), (4.0, 6.0), (4.0, 9.0)), "{l:?}"); + + // A keyword after an override goes to the command (Undo the last segment). + s.cmdline("line 0,0 5,0 <45 u").unwrap(); + s.cmdline("").unwrap(); + assert_eq!(lines(&s).len(), 3, "the segment was undone"); +} + +#[test] +fn modifiers_nest_and_work_in_scripts() { + let mut s = Session::new(); + // FROM whose base point is the midpoint of 0,0 and 10,0, then an offset of @0,3. + s.script("LINE FROM M2P 0,0 10,0 @0,3\n20,3\n\n").unwrap(); + assert!(s.running.is_none()); + assert!(line_is(lines(&s).first(), (5.0, 3.0), (20.0, 3.0)), "{:?}", lines(&s)); + + // One token per line as well. + s.script("LINE\n_from\n0,0\n@1,1\n.y\n0,5\n2,0\n\n").unwrap(); + assert!(line_is(lines(&s).get(1), (1.0, 1.0), (2.0, 5.0)), "{:?}", lines(&s)); +} + +#[test] +fn enter_drops_the_modifiers_and_text_is_not_a_point() { + let mut s = Session::new(); + s.cmdline("line from").unwrap(); + s.cmdline("hello").unwrap(); + assert!(s.log.iter().any(|l| l == "Invalid point.")); + assert!(s.prompt_text().ends_with("Base point:")); + s.cmdline("").unwrap(); + assert!(s.running.is_some(), "LINE still runs"); + assert!(s.prompt_text().contains("Specify first point"), "{}", s.prompt_text()); + assert!(s.point_mods.is_empty()); + // Esc cancels the command and the modifiers with it. + s.cmdline("m2p").unwrap(); + s.cancel(); + assert!(s.running.is_none() && s.point_mods.is_empty()); + // A runaway script can't pile up modifiers without bound. + s.cmdline(&format!("line{}", " from".repeat(100))).unwrap(); + assert!(s.point_mods.len() <= 32); +} diff --git a/docs/gaps.md b/docs/gaps.md index 4af8bc88..67f816e5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -21,7 +21,7 @@ do them first. | 6 | **Ellipse and spline geometry is tessellated** (intersections, offsets, closest points; polyline offset ignores bulges) | Coordinates are subtly wrong; offsets of ellipses come out as polylines | 30–50 | [geometry](geometry-parity.md) | | 7 | **[alpha blocker]** **No xrefs, block editor, dynamic blocks or groups** | Most production drawings use them | 80–120 | [features](#features) | | 8 | **No autosave, crash recovery, AUDIT or RECOVER** | Work is lost if anything goes wrong; damaged files can't be repaired | 10–15 | [features](#features) | -| 9 | **Dynamic input lacks grip input; no FROM/M2P/TK/point filters; grip options ignored** | Precision entry at the cursor is how AutoCAD is taught today | 15–25 | [ui](ui-parity.md) | +| 9 | **Dynamic input lacks grip input; no TK; grip options ignored** (FROM, M2P/MTP, point filters, `*` and `:" with `@` measured from the base; nests with M2P and filters | `pointmod.rs` | +| `M2P` / `MTP` (midpoint between two points) | yes | yes | `pointmod.rs` | | `TK` (tracking) | yes | **no** | | -| Point filters `.x`, `.y`, `.xy`, `.z` | yes | **no** | | -| Angle override `<45` | yes | not found | | +| Point filters `.x`, `.y`, `.xy`, `.z` | yes | yes (`.x` … `.yz`, "(need YZ):"); Z is asked for and dropped (no 3D) | `pointmod.rs` | +| Angle override `<45` | yes | yes: ` Date: Sun, 11 Oct 2026 00:43:28 +0200 Subject: [PATCH 138/150] TRIM/EXTEND: cutting edges, Fence, Crossing, mOde, Project, Edge, eRase, Undo (part of #397) (#403) The options TRIM and EXTEND list at their prompt all printed "not available yet". Quick and Standard modes follow TRIMEXTENDMODE, Edge extends lines and arcs virtually (EDGEMODE), Project is kept in PROJMODE. The JSON forms take fence, crossing, edgeMode and (TRIM) mode. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/modify.rs | 147 +++--- crates/engine/src/cmd/trimextend.rs | 532 +++++++++++++++++++++ crates/engine/tests/trim_extend_options.rs | 203 ++++++++ docs/gaps.md | 4 +- 5 files changed, 796 insertions(+), 91 deletions(-) create mode 100644 crates/engine/src/cmd/trimextend.rs create mode 100644 crates/engine/tests/trim_extend_options.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 578b56b5..136a803e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -21,6 +21,7 @@ mod props; mod qselect; mod settings; mod table; +mod trimextend; mod utility; mod view; diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 79662ff6..faf0d412 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -9,6 +9,7 @@ use serde_json::{Value, json}; use super::helpers::*; use super::machines::{SelOutcome, SelectPhase, SelectRun, number}; +use super::trimextend::{self, TrimM}; use super::*; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; @@ -54,16 +55,18 @@ pub fn specs() -> Vec { .alias(&["o"]) .params("{handle, distance, side: [x,y], erase?: bool, layer?: \"source\"|\"current\"}") .interactive(|s| Ok(Box::new(OffsetM::new(s)))), - CommandSpec::new("trim", "Trim", run_trim) + CommandSpec::new("trim", "Trim", trimextend::run_trim) .menu(&["Modify", "Trim"]) .alias(&["tr"]) - .params("{handle, pick: [x,y], edges?: [hex]}") - .interactive(|_| Ok(Box::new(TrimM { extend: false }))), - CommandSpec::new("extend", "Extend", run_extend) + .params( + "{handle, pick: [x,y] | fence: [[x,y],...] | crossing: [[x,y],[x,y]], edges?: [hex], edgeMode?: \"extend\"|\"none\", mode?: \"quick\"|\"standard\"}", + ) + .interactive(|_| Ok(Box::new(TrimM::new(false)))), + CommandSpec::new("extend", "Extend", trimextend::run_extend) .menu(&["Modify", "Extend"]) .alias(&["ex"]) - .params("{handle, pick: [x,y], edges?: [hex]}") - .interactive(|_| Ok(Box::new(TrimM { extend: true }))), + .params("{handle, pick: [x,y] | fence: [[x,y],...] | crossing: [[x,y],[x,y]], edges?: [hex], edgeMode?: \"extend\"|\"none\"}") + .interactive(|_| Ok(Box::new(TrimM::new(true)))), CommandSpec::new("fillet", "Fillet", run_fillet) .menu(&["Modify", "Fillet"]) .alias(&["f"]) @@ -672,8 +675,18 @@ fn add_offset(s: &mut Session, src: &Entity, k: EntityKind, current_layer: bool, // ---------------- trim / extend ---------------- +/// The cutting (TRIM) or boundary (EXTEND) edges. +#[derive(Clone, Copy, Default)] +pub(crate) struct Edges<'a> { + /// Only these objects; `None`: every visible object. + pub only: Option<&'a [Handle]>, + /// Implied edges (EDGEMODE 1): lines and the end segments of open polylines count as running + /// on without end, arcs as whole circles. + pub implied: bool, +} + /// All primitives of the cutting edges (excluding `exclude`). -fn edge_segments(s: &Session, edges: Option<&[Handle]>, exclude: Handle) -> Result> { +fn edge_segments(s: &Session, edges: Edges, exclude: Handle) -> Result> { let d = s.doc()?; let space = s.space(); let store = d.space(&space).ok_or(EngineError::NoDocument)?; @@ -682,11 +695,17 @@ fn edge_segments(s: &Session, edges: Option<&[Handle]>, exclude: Handle) -> Resu if e.handle == exclude || !d.is_visible(e) { continue; } - if let Some(es) = edges + if let Some(es) = edges.only && !es.contains(&e.handle) { continue; } + if edges.implied + && let Some(segs) = implied_edge(&e.kind) + { + out.extend(segs); + continue; + } for p in e.kind.prims() { out.extend(prim_to_segments(&p)); } @@ -694,6 +713,35 @@ fn edge_segments(s: &Session, edges: Option<&[Handle]>, exclude: Handle) -> Resu Ok(out) } +/// `k` as an implied edge (EDGEMODE 1), for lines, arcs and open polylines. +fn implied_edge(k: &EntityKind) -> Option> { + const FAR: f64 = 1e8; + let whole = |a: &Arc| Segment::Arc { arc: Arc { start: 0.0, end: TAU - 1e-12, ..*a }, ccw: true }; + // Run a segment on past its start and/or its end. + let run_on = |sg: Segment, back: bool, fwd: bool| match sg { + Segment::Line(l) => { + let d = (l.b - l.a).normalized(); + Segment::Line(Line::new(if back { l.a - d * FAR } else { l.a }, if fwd { l.b + d * FAR } else { l.b })) + } + Segment::Arc { arc, .. } => whole(&arc), + }; + match k { + EntityKind::Line(l) => Some(vec![run_on(Segment::Line(Line::new(l.a.xy(), l.b.xy())), true, true)]), + EntityKind::Arc(a) => Some(vec![whole(&Arc::new(a.center.xy(), a.radius, a.start, a.end))]), + EntityKind::LwPolyline(p) if !p.closed => { + let mut segs = Polyline { vertices: p.vertices.clone(), closed: false }.segments(); + let last = segs.len().checked_sub(1)?; + for (i, sg) in segs.iter_mut().enumerate() { + if i == 0 || i == last { + *sg = run_on(*sg, i == 0, i == last); + } + } + Some(segs) + } + _ => None, + } +} + fn prim_to_segments(p: &Prim) -> Vec { match p { Prim::Seg(s) => vec![*s], @@ -714,7 +762,7 @@ fn prim_to_segments(p: &Prim) -> Vec { } /// Trim `h` at the piece containing `pick`. Returns the handles that replace it. -pub(crate) fn trim(s: &mut Session, h: Handle, pick: Vec2, edges: Option<&[Handle]>) -> Result> { +pub(crate) fn trim(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Result> { if super::curves::is_locked(s, h) { return Err(EngineError::Other("The object is on a locked layer.".into())); } @@ -900,7 +948,7 @@ fn param_on(s: &Segment, p: Vec2) -> f64 { } /// Extend the end of `h` nearest `pick` to the nearest boundary edge. -pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Option<&[Handle]>) -> Result<()> { +pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Result<()> { if super::curves::is_locked(s, h) { return Err(EngineError::Other("The object is on a locked layer.".into())); } @@ -961,26 +1009,6 @@ pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Option<&[Han Ok(()) } -fn edges_param(p: &Value) -> Option> { - p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex)).collect()) -} - -fn run_trim(s: &mut Session, p: &Value) -> Result { - let h = targets(s, p)?.first().copied().ok_or_else(|| bad("trim", "`handle` is required"))?; - let pick = point_req("trim", p, "pick")?; - let edges = edges_param(p); - let r = trim(s, h, pick, edges.as_deref())?; - Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) -} - -fn run_extend(s: &mut Session, p: &Value) -> Result { - let h = targets(s, p)?.first().copied().ok_or_else(|| bad("extend", "`handle` is required"))?; - let pick = point_req("extend", p, "pick")?; - let edges = edges_param(p); - extend(s, h, pick, edges.as_deref())?; - ok() -} - // ---------------- fillet / chamfer ---------------- fn as_line(e: &Entity) -> Option { @@ -2126,65 +2154,6 @@ impl Interactive for OffsetM { } } -struct TrimM { - extend: bool, -} - -impl Interactive for TrimM { - fn name(&self) -> &'static str { - if self.extend { "EXTEND" } else { "TRIM" } - } - fn begin(&mut self, s: &mut Session) -> Result { - s.set_selection(Vec::new()); - s.echo("Current settings: Projection=UCS, Edge=None, Mode=Quick"); - Ok(Step::Continue) - } - fn prompt(&self, _s: &Session) -> Prompt { - if self.extend { - Prompt::new("Select object to extend or shift-select to trim", Accept::POINT).kw(&[ - "Boundary edges", - "Fence", - "Crossing", - "mOde", - "Project", - "Undo", - ]) - } else { - Prompt::new("Select object to trim or shift-select to extend", Accept::POINT).kw(&[ - "cuTting edges", - "Fence", - "Crossing", - "mOde", - "Project", - "eRase", - ]) - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - match i { - Input::Point(p) => { - let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; - let space = s.space(); - let Some(h) = crate::select::pick(s.doc()?, &space, p, ap) else { - s.echo("*Invalid selection*"); - return Ok(Step::Continue); - }; - let r = if self.extend { extend(s, h, p, None).map(|_| ()) } else { trim(s, h, p, None).map(|_| ()) }; - if let Err(e) = r { - s.echo(e.to_string()); - } - Ok(Step::Continue) - } - Input::Enter => Ok(Step::Done), - Input::Keyword(k) => { - s.echo(format!("{k}: not available yet")); - Ok(Step::Continue) - } - _ => Ok(Step::Continue), - } - } -} - /// A FILLET/CHAMFER option prompt opened from the select prompts. #[derive(Clone, Copy, PartialEq)] enum FilletAsk { diff --git a/crates/engine/src/cmd/trimextend.rs b/crates/engine/src/cmd/trimextend.rs new file mode 100644 index 00000000..4640c58a --- /dev/null +++ b/crates/engine/src/cmd/trimextend.rs @@ -0,0 +1,532 @@ +//! TRIM and EXTEND: the prompt sequence and the JSON forms. Quick and Standard modes +//! (TRIMEXTENDMODE), cutting/boundary edges, Fence, Crossing, Project (PROJMODE), Edge (EDGEMODE), +//! eRase and Undo. The geometry of one trim or extend is in modify.rs. + +use std::sync::Arc; + +use cadcraft_doc::{Drawing, EntityKind, Handle}; +use cadcraft_geom::{Bounds2, Line, Vec2, line_line}; +use serde_json::{Value, json}; + +use super::helpers::{line, lwpoly, rect_vertices}; +use super::modify::{Edges, extend, trim}; +use super::{bad, point_param, points_param}; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +/// Most fence points the JSON form takes. +const MAX_FENCE: usize = 10_000; + +/// TRIMEXTENDMODE: 1 (default) Quick, 0 Standard. +fn quick_mode(s: &Session) -> bool { + s.doc().map(|d| d.header.i64("TRIMEXTENDMODE", 1) != 0).unwrap_or(true) +} +/// EDGEMODE: 1 extends cutting and boundary edges virtually (Standard mode). +fn edge_mode(s: &Session) -> bool { + s.doc().map(|d| d.header.i64("EDGEMODE", 0) != 0).unwrap_or(false) +} +/// PROJMODE: 0 None, 1 UCS (default), 2 View. Drawings are 2D (every object lies in the XY +/// plane), so the three give the same result; the setting is kept for scripts and the DWG. +fn proj_mode(s: &Session) -> i64 { + s.doc().map(|d| d.header.i64("PROJMODE", 1)).unwrap_or(1) +} + +/// Whether a trim error says the object meets no cutting edge (a Quick-mode trim then erases it). +fn misses_edges(e: &EngineError) -> bool { + e.to_string().contains("intersect") +} + +/// Trim or extend `h` at `at`. A Quick-mode (`quick`) trim of an object no cutting edge crosses +/// erases it. Returns the handles that replace `h` (none when it was erased). +fn apply(s: &mut Session, h: Handle, at: Vec2, extending: bool, edges: Edges, quick: bool) -> Result> { + if extending { + extend(s, h, at, edges)?; + return Ok(vec![h]); + } + match trim(s, h, at, edges) { + Err(e) if quick && misses_edges(&e) => { + s.doc_mut()?.remove_entity(h); + Ok(Vec::new()) + } + r => r, + } +} + +/// Trim (or extend) every object the fence `pts` crosses, at each crossing (extend: at the first). +/// Returns how many trims or extends were made. +fn fence(s: &mut Session, pts: &[Vec2], extending: bool, edges: Edges, quick: bool) -> Result { + let space = s.space(); + let d = s.doc()?; + let fb = Bounds2::from_points(pts.iter().copied()); + let tol = crate::select::window_tol(&fb); + let mut work: Vec<(Handle, Vec)> = Vec::new(); + for h in crate::select::select_fence(d, &space, pts) { + let Some(e) = d.entity(h) else { continue }; + let polys = crate::select::hit_polylines(d, e, tol); + let mut xs: Vec = Vec::new(); + for f in pts.windows(2) { + let (Some(a), Some(b)) = (f.first(), f.get(1)) else { continue }; + let fl = Line::new(*a, *b); + // Crossings along this fence segment, in fence order. + let mut here: Vec = polys + .iter() + .flat_map(|pl| pl.windows(2).filter_map(|w| line_line(&fl, &Line::new(*w.first()?, *w.get(1)?)))) + .filter(|x| !xs.iter().any(|y| y.near(*x, tol))) + .collect(); + here.sort_by(|x, y| x.dist(*a).total_cmp(&y.dist(*a))); + here.dedup_by(|x, y| x.near(*y, tol)); + xs.extend(here); + } + work.push((h, xs)); + } + run_picks(s, work, extending, edges, quick, tol) +} + +/// Trim (or extend) every object a crossing window from `a` to `b` touches, picked at its point +/// in the window nearest the first corner `a`. +fn crossing(s: &mut Session, a: Vec2, b: Vec2, extending: bool, edges: Edges, quick: bool) -> Result { + let space = s.space(); + let d = s.doc()?; + let bx = Bounds2::new(a, b); + let tol = crate::select::window_tol(&bx); + let sides = [ + Line::new(bx.min, Vec2::new(bx.max.x, bx.min.y)), + Line::new(Vec2::new(bx.max.x, bx.min.y), bx.max), + Line::new(bx.max, Vec2::new(bx.min.x, bx.max.y)), + Line::new(Vec2::new(bx.min.x, bx.max.y), bx.min), + ]; + let inside = |p: Vec2| bx.expand(tol).contains(p); + let mut work: Vec<(Handle, Vec)> = Vec::new(); + for h in crate::select::select_window(d, &space, bx, true) { + let Some(e) = d.entity(h) else { continue }; + let mut best: Option = None; + for pl in crate::select::hit_polylines(d, e, tol) { + for w in pl.windows(2) { + let (Some(p), Some(q)) = (w.first(), w.get(1)) else { continue }; + let seg = Line::new(*p, *q); + let cands = [*p, *q, seg.closest(a)].into_iter().chain(sides.iter().filter_map(|sd| line_line(sd, &seg))); + for c in cands.filter(|c| inside(*c)) { + if best.is_none_or(|b| c.dist(a) < b.dist(a)) { + best = Some(c); + } + } + } + } + if let Some(p) = best { + work.push((h, vec![p])); + } + } + run_picks(s, work, extending, edges, quick, tol) +} + +/// Trim (or extend) each object at its pick points. A trim replaces the object with pieces, so +/// later picks go to the piece within `tol` of the point (none: that part is already gone). +fn run_picks(s: &mut Session, work: Vec<(Handle, Vec)>, extending: bool, edges: Edges, quick: bool, tol: f64) -> Result { + let mut n = 0; + let mut last_err = None; + for (h, picks) in work { + let mut live = vec![h]; + for x in picks { + let target = { + let d = s.doc()?; + live.iter() + .filter_map(|l| d.entity(*l).map(|e| (*l, crate::select::entity_distance(d, e, x, tol)))) + .filter(|(_, dist)| *dist <= 3.0 * tol) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(l, _)| l) + }; + let Some(t) = target else { continue }; + match apply(s, t, x, extending, edges, quick) { + Ok(new) => { + live.retain(|l| *l != t); + live.extend(new); + n += 1; + } + Err(e) => last_err = Some(e), + } + if extending { + break; + } + } + } + match (n, last_err) { + (0, Some(e)) => Err(e), + (0, None) => Err(EngineError::Other("No objects found.".into())), + _ => Ok(n), + } +} + +/// The JSON forms' `edgeMode`: "extend" / "none" (or true / false). +fn edge_mode_param(cmd: &str, p: &Value) -> Result { + match p.get("edgeMode") { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(b)) => Ok(*b), + Some(Value::String(t)) => match t.trim().to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + "extend" => Ok(true), + "none" | "noextend" => Ok(false), + _ => Err(bad(cmd, "`edgeMode` must be \"extend\" or \"none\"")), + }, + Some(_) => Err(bad(cmd, "`edgeMode` must be \"extend\" or \"none\"")), + } +} + +fn run(s: &mut Session, p: &Value, extending: bool) -> Result { + let cmd = if extending { "extend" } else { "trim" }; + let only: Option> = + p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex)).collect()); + let edges = Edges { only: only.as_deref(), implied: edge_mode_param(cmd, p)? }; + let quick = match p.get("mode") { + None | Some(Value::Null) => false, + Some(v) => match v.as_str().map(|m| m.trim().to_ascii_lowercase()).as_deref() { + Some("quick") => true, + Some("standard") => false, + _ => return Err(bad(cmd, "`mode` must be \"quick\" or \"standard\"")), + }, + }; + let key = if extending { "extended" } else { "trimmed" }; + if p.get("fence").is_some_and(|v| !v.is_null()) { + let pts = points_param(p, "fence") + .filter(|v| (2..=MAX_FENCE).contains(&v.len())) + .ok_or_else(|| bad(cmd, "`fence` needs two or more points [[x,y], ...]"))?; + let n = fence(s, &pts, extending, edges, quick)?; + return Ok(json!({ key: n })); + } + if p.get("crossing").is_some_and(|v| !v.is_null()) { + let c = points_param(p, "crossing").filter(|v| v.len() == 2).ok_or_else(|| bad(cmd, "`crossing` needs two corners [[x,y], [x,y]]"))?; + let (Some(a), Some(b)) = (c.first(), c.get(1)) else { return Err(bad(cmd, "`crossing` needs two corners")) }; + let n = crossing(s, *a, *b, extending, edges, quick)?; + return Ok(json!({ key: n })); + } + let h = super::targets(s, p)?.first().copied().ok_or_else(|| bad(cmd, "`handle` is required"))?; + let pick = point_param(p, "pick").ok_or_else(|| bad(cmd, "`pick` ([x, y]) is required"))?; + let r = apply(s, h, pick, extending, edges, quick)?; + if extending { Ok(Value::Null) } else { Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) } +} + +pub(crate) fn run_trim(s: &mut Session, p: &Value) -> Result { + run(s, p, false) +} + +pub(crate) fn run_extend(s: &mut Session, p: &Value) -> Result { + run(s, p, true) +} + +// ---------------- the prompt sequence ---------------- + +#[derive(Clone, PartialEq)] +enum Stage { + /// "Select object to trim or shift-select to extend". + Pick, + /// "Select objects or