diff --git a/apps/cadcraft-cli/tests/drafting_spacing.rs b/apps/cadcraft-cli/tests/drafting_spacing.rs new file mode 100644 index 000000000..cfba692f1 --- /dev/null +++ b/apps/cadcraft-cli/tests/drafting_spacing.rs @@ -0,0 +1,83 @@ +//! Drawing snap/grid spacing survives saving and opening in a fresh CLI process (#607). + +use std::path::PathBuf; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde_json::{Value, json}; + +static NEXT_FILE: AtomicU64 = AtomicU64::new(0); + +struct DxfFile(PathBuf); + +impl DxfFile { + fn new() -> Self { + let path = + std::env::temp_dir().join(format!("cadcraft-drafting-spacing-{}-{}.dxf", std::process::id(), NEXT_FILE.fetch_add(1, Ordering::Relaxed))); + std::fs::File::options().write(true).create_new(true).open(&path).expect("reserve test DXF"); + Self(path) + } +} + +impl Drop for DxfFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +fn cli(args: &[&str]) -> Vec { + let out = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().expect("run cadcraft-cli"); + assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8_lossy(&out.stdout).lines().map(|line| serde_json::from_str(line).expect("CLI JSON reply")).collect() +} + +#[test] +fn quarter_unit_spacing_survives_save_and_reopen() { + let file = DxfFile::new(); + let path = file.0.to_str().expect("test file path"); + cli(&[ + "run", + "--sample", + "--cmd", + r#"setvar {"name":"SNAPUNIT","value":[0.25,0.25]}"#, + "--cmd", + r#"setvar {"name":"GRIDUNIT","value":[0.25,0.25]}"#, + "--save", + path, + ]); + let replies = cli(&["run", path, "--cmd", r#"getvar {"name":"SNAPUNIT"}"#, "--cmd", r#"getvar {"name":"GRIDUNIT"}"#]); + for name in ["SNAPUNIT", "GRIDUNIT"] { + let reply = replies.iter().find(|r| r["name"] == name).expect("getvar reply"); + assert_eq!(reply["value"], json!([0.25, 0.25]), "{name} reset on reopen"); + } +} + +#[test] +fn drafting_settings_spacing_roundtrips_without_persisting_osmode() { + let file = DxfFile::new(); + let path = file.0.to_str().expect("test file path"); + cli(&[ + "run", + "--sample", + "--cmd", + r#"dsettings {"snapunit":[0.25,0.125],"gridunit":[0.75,1.5]}"#, + "--cmd", + r#"setvar {"name":"OSMODE","value":0}"#, + "--save", + path, + ]); + let replies = cli(&[ + "run", + path, + "--cmd", + r#"getvar {"name":"SNAPUNIT"}"#, + "--cmd", + r#"getvar {"name":"GRIDUNIT"}"#, + "--cmd", + r#"getvar {"name":"OSMODE"}"#, + ]); + for (name, expected) in [("SNAPUNIT", json!([0.25, 0.125])), ("GRIDUNIT", json!([0.75, 1.5])), ("OSMODE", json!(4133))] { + let reply = replies.iter().find(|r| r["name"] == name).expect("getvar reply"); + assert_eq!(reply["value"], expected, "{name} after reopening"); + } +} diff --git a/crates/doc/src/header.rs b/crates/doc/src/header.rs index 5a533510a..c6f17fd7e 100644 --- a/crates/doc/src/header.rs +++ b/crates/doc/src/header.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; -use cadcraft_geom::Vec3; +use cadcraft_geom::{Vec2, Vec3}; use serde::{Deserialize, Serialize}; /// A header variable value. @@ -76,6 +76,20 @@ impl Header { _ => None, } } + /// Drawing snap spacing; malformed or missing values use the drafting default. + pub fn snap_unit(&self) -> Vec2 { + self.spacing("SNAPUNIT", false) + } + /// Drawing grid spacing. A zero component follows the corresponding snap spacing. + pub fn grid_unit(&self) -> Vec2 { + self.spacing("GRIDUNIT", true) + } + fn spacing(&self, name: &str, allow_zero: bool) -> Vec2 { + self.point(name) + .map(|p| p.xy()) + .filter(|p| p.is_finite() && if allow_zero { p.x >= 0.0 && p.y >= 0.0 } else { p.x > 0.0 && p.y > 0.0 }) + .unwrap_or(Vec2::new(0.5, 0.5)) + } pub fn set_f64(&mut self, name: &str, v: f64) { self.set(name, HVal::Real(v)); } @@ -118,6 +132,8 @@ impl Header { // Snap grid origin and rotation (SNAPANG in degrees). h.set("SNAPBASE", HVal::Point(Vec3::ZERO)); h.set_f64("SNAPANG", 0.0); + h.set("SNAPUNIT", HVal::Point(Vec3::new(0.5, 0.5, 0.0))); + h.set("GRIDUNIT", HVal::Point(Vec3::new(0.5, 0.5, 0.0))); // Annotation scale of model space; annotative objects of other scales show (1) or not. h.set_str("CANNOSCALE", "1:1"); h.set_i64("ANNOALLVISIBLE", 1); diff --git a/crates/engine/src/cmd/options.rs b/crates/engine/src/cmd/options.rs index 10d585b2e..5ef5b3b15 100644 --- a/crates/engine/src/cmd/options.rs +++ b/crates/engine/src/cmd/options.rs @@ -116,9 +116,7 @@ pub fn reset(s: &mut Session) { st.polaraddang = d.polaraddang; st.orthomode = d.orthomode; st.snapmode = d.snapmode; - st.snapunit = d.snapunit; st.gridmode = d.gridmode; - st.gridunit = d.gridunit; st.gridmajor = d.gridmajor; st.dynmode = d.dynmode; st.dynpi_cartesian = d.dynpi_cartesian; diff --git a/crates/engine/src/cmd/settings.rs b/crates/engine/src/cmd/settings.rs index d90c6b94f..014e130eb 100644 --- a/crates/engine/src/cmd/settings.rs +++ b/crates/engine/src/cmd/settings.rs @@ -95,8 +95,7 @@ pub fn specs() -> Vec { .menu(&["Tools", "Drafting Settings..."]) .alias(&["ds", "se"]) .params("{snapunit?: [x,y], gridunit?: [x,y], polarang? (degrees), gridmajor?}") - .enabled(always) - .noundo(), + .enabled(always), CommandSpec::new("setvar", "Set Variable", run_setvar) .menu(&["Tools", "Inquiry", "Set Variable"]) .alias(&["set"]) @@ -153,10 +152,10 @@ fn run_osnap(s: &mut Session, p: &Value) -> Result { fn run_dsettings(s: &mut Session, p: &Value) -> Result { if let Some(v) = point_param(p, "snapunit").filter(|v| v.x > 0.0 && v.y > 0.0) { - s.settings.snapunit = v; + crate::sysvars::set(s, "SNAPUNIT", &json!([v.x, v.y]))?; } - if let Some(v) = point_param(p, "gridunit").filter(|v| v.x > 0.0 && v.y > 0.0) { - s.settings.gridunit = v; + if let Some(v) = point_param(p, "gridunit").filter(|v| v.x >= 0.0 && v.y >= 0.0) { + crate::sysvars::set(s, "GRIDUNIT", &json!([v.x, v.y]))?; } if let Some(a) = p.get("polarang").and_then(Value::as_f64).filter(|a| *a > 0.0) { s.settings.polarang = a.to_radians(); @@ -165,7 +164,7 @@ fn run_dsettings(s: &mut Session, p: &Value) -> Result { s.settings.gridmajor = (m as u32).clamp(1, 100); } s.touch(); - Ok(serde_json::to_value(&s.settings).unwrap_or(Value::Null)) + Ok(serde_json::to_value(s.drafting_settings()).unwrap_or(Value::Null)) } fn run_setvar(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3e46e21e1..c3e5951fb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -303,7 +303,8 @@ impl DocState { } } -/// Session-level drafting settings (the AutoCAD system variables stored in the registry/profile). +/// Session-level drafting settings. [`Session::drafting_settings`] supplies the active drawing's +/// snap/grid spacing in the corresponding fields for UI and JSON consumers. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Settings { @@ -314,9 +315,11 @@ pub struct Settings { pub polarang: f64, pub gridmode: bool, pub snapmode: bool, + /// SNAPUNIT in a drafting settings snapshot; stored in the drawing header. pub snapunit: Vec2, /// OSNAPHATCH: object snaps find hatch objects (off by default, as in AutoCAD). pub osnaphatch: bool, + /// GRIDUNIT in a drafting settings snapshot; zero components follow SNAPUNIT. pub gridunit: Vec2, /// Major grid line every N minor lines (GRIDMAJOR). pub gridmajor: u32, @@ -659,6 +662,15 @@ impl Session { pub fn doc_mut(&mut self) -> Result<&mut Drawing> { Ok(Arc::make_mut(&mut self.state_mut()?.doc)) } + /// Settings for the active drawing, without caching drawing variables in the profile. + pub fn drafting_settings(&self) -> Settings { + let mut settings = self.settings.clone(); + if let Ok(d) = self.doc() { + settings.snapunit = d.header.snap_unit(); + settings.gridunit = d.header.grid_unit(); + } + settings + } /// The space commands act on (model space while working inside a layout viewport). pub fn space(&self) -> Space { self.state().map(|s| s.edit_space()).unwrap_or_default() @@ -1275,7 +1287,7 @@ impl Session { { let mut c = self.cursor; if self.settings.orthomode { - c = snap::iso::constrain(&self.settings, base, c); + c = snap::iso::constrain(&self.drafting_settings(), base, c); } let dir = (c - base).normalized(); let dir = if dir == Vec2::ZERO { Vec2::X } else { dir }; @@ -1445,7 +1457,7 @@ impl Session { "prompt": self.prompt_text(), "running": self.running.as_ref().map(|r| r.id.clone()), "selection": self.selection().iter().map(|h| h.hex()).collect::>(), - "settings": self.settings, + "settings": self.drafting_settings(), }) } } diff --git a/crates/engine/src/snap/tracking.rs b/crates/engine/src/snap/tracking.rs index 38d4d7d9f..fcae0f1f1 100644 --- a/crates/engine/src/snap/tracking.rs +++ b/crates/engine/src/snap/tracking.rs @@ -749,7 +749,8 @@ impl crate::Session { /// acquires it for tracking), then grid snap, then tracking paths (polar, object snap /// tracking, Extension, Parallel, Apparent Intersection), then ortho. pub fn snap_cursor(&mut self, raw: Vec2, q: &CursorQuery) -> CursorSnap { - let s = self.settings.clone(); + // Snap and grid spacing are the drawing's (SNAPUNIT/GRIDUNIT live in its header). + let s = self.drafting_settings(); let angbase = self.angle_settings().angbase; let Ok(st) = self.state() else { return CursorSnap { point: raw, ..Default::default() } }; let (doc, space) = (st.doc.clone(), st.edit_space()); @@ -772,7 +773,7 @@ impl crate::Session { let mut p = raw; if s.snapmode && !s.polarsnap { let (origin, angle) = super::grid_frame(&doc); - p = super::grid_snap(p, s.snapunit, origin, angle); + p = super::grid_snap(p, doc.header.snap_unit(), origin, angle); } if let Some(mut t) = self.tracking.track(&doc, &space, p, q.aperture, q.tol, &cfg, q.base) { // PolarSnap (SNAPTYPE 1): steps of POLARDIST, or of the snap X spacing when it is 0. diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index 89b58b929..7001c5956 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -75,8 +75,11 @@ pub fn get(s: &Session, name: &str) -> Option { "SNAPMODE" => json!(i32::from(st.snapmode)), "SNAPSTYL" => json!(i32::from(st.isodraft)), "SNAPISOPAIR" => json!(st.isoplane.index()), - "SNAPUNIT" => json!([st.snapunit.x, st.snapunit.y]), - "GRIDUNIT" => json!([st.gridunit.x, st.gridunit.y]), + "SNAPUNIT" | "GRIDUNIT" => { + let settings = s.drafting_settings(); + let unit = if n == "SNAPUNIT" { settings.snapunit } else { settings.gridunit }; + json!([unit.x, unit.y]) + } "GRIDMAJOR" => json!(st.gridmajor), "DYNDIGRIP" => json!(st.dyndigrip), "DYNDIVIS" => json!(st.dyndivis), @@ -221,8 +224,12 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { "SNAPMODE" => st.snapmode = as_bool(v).ok_or_else(bad)?, "SNAPSTYL" => st.isodraft = as_i(v).filter(|b| (0..=1).contains(b)).ok_or_else(bad)? == 1, "SNAPISOPAIR" => st.isoplane = as_i(v).and_then(crate::snap::iso::IsoPlane::from_index).ok_or_else(bad)?, - "SNAPUNIT" => st.snapunit = as_pt(v).filter(|p| p.x > 0.0 && p.y > 0.0).ok_or_else(bad)?, - "GRIDUNIT" => st.gridunit = as_pt(v).filter(|p| p.x > 0.0 && p.y > 0.0).ok_or_else(bad)?, + "SNAPUNIT" | "GRIDUNIT" => { + let unit = as_pt(v) + .filter(|p| p.is_finite() && if n == "GRIDUNIT" { p.x >= 0.0 && p.y >= 0.0 } else { p.x > 0.0 && p.y > 0.0 }) + .ok_or_else(bad)?; + s.doc_mut()?.header.set(&n, cadcraft_doc::HVal::Point(unit.to3(0.0))); + } "GRIDMAJOR" => st.gridmajor = as_f64(v).ok_or_else(bad)?.clamp(1.0, 100.0) as u32, "DYNDIGRIP" => st.dyndigrip = as_i(v).filter(|i| (0..=31).contains(i)).and_then(|i| u32::try_from(i).ok()).ok_or_else(bad)?, "DYNDIVIS" => st.dyndivis = as_i(v).filter(|i| (0..=2).contains(i)).and_then(|i| u8::try_from(i).ok()).ok_or_else(bad)?, @@ -329,7 +336,7 @@ pub fn list(s: &Session) -> Value { } if let Ok(d) = s.doc() { for (k, v) in &d.header.vars { - if k == "ANGBASE" { + if matches!(k.as_str(), "ANGBASE" | "SNAPUNIT" | "GRIDUNIT") { 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/tests/drafting_spacing.rs b/crates/engine/tests/drafting_spacing.rs new file mode 100644 index 000000000..db168f792 --- /dev/null +++ b/crates/engine/tests/drafting_spacing.rs @@ -0,0 +1,81 @@ +//! Snap/grid spacing belongs to each drawing, including command edits and undo (#607). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Drawing, HVal}; +use cadcraft_engine::geom::Vec2; +use cadcraft_engine::snap::tracking::CursorQuery; +use serde_json::{Value, json}; + +fn get(s: &mut Session, name: &str) -> Value { + s.execute("getvar", &json!({"name": name})).unwrap()["value"].clone() +} + +#[test] +fn spacing_stays_with_each_drawing_and_new_drawings_use_defaults() { + let mut s = Session::new(); + let first = s.active; + s.execute("setvar", &json!({"name": "SNAPUNIT", "value": [0.25, 0.125]})).unwrap(); + s.execute("setvar", &json!({"name": "GRIDUNIT", "value": [0.75, 1.5]})).unwrap(); + s.execute("setvar", &json!({"name": "OSMODE", "value": 0})).unwrap(); + assert!(s.state().unwrap().is_dirty(), "spacing changes must be saved"); + let second = s.new_drawing(true); + assert_eq!(get(&mut s, "SNAPUNIT"), json!([0.5, 0.5])); + assert_eq!(get(&mut s, "GRIDUNIT"), json!([0.5, 0.5])); + assert_eq!(get(&mut s, "OSMODE"), json!(0), "profile settings stay in the session"); + s.execute("document.switch", &json!({"index": first})).unwrap(); + assert_eq!(get(&mut s, "SNAPUNIT"), json!([0.25, 0.125])); + assert_eq!(get(&mut s, "GRIDUNIT"), json!([0.75, 1.5])); + s.execute("close", &json!({"index": first})).unwrap(); + assert_eq!(s.active, second - 1); + assert_eq!(get(&mut s, "SNAPUNIT"), json!([0.5, 0.5])); +} + +#[test] +fn loaded_spacing_is_used_by_getvar_settings_snapshots_and_grid_snap() { + let mut d = Drawing::new_imperial(); + d.header.set("SNAPUNIT", HVal::Point(Vec2::new(0.25, 0.125).to3(0.0))); + d.header.set("GRIDUNIT", HVal::Point(Vec2::new(0.75, 1.5).to3(0.0))); + let mut s = Session::empty(); + s.open_drawing(d, "loaded", None); + assert!(!s.state().unwrap().is_dirty()); + assert_eq!(get(&mut s, "SNAPUNIT"), json!([0.25, 0.125])); + let listed = s.execute("sysvars", &json!({})).unwrap(); + assert_eq!(listed["SNAPUNIT"], json!([0.25, 0.125])); + assert_eq!(listed["GRIDUNIT"], json!([0.75, 1.5])); + assert_eq!(s.summary()["settings"]["snapunit"], serde_json::to_value(Vec2::new(0.25, 0.125)).unwrap()); + s.settings.snapmode = true; + s.settings.osmode = 0; + s.settings.polarmode = false; + let q = CursorQuery { base: None, deferred: false, aperture: 0.1, tol: 0.1, now: 0.0, shift: false }; + assert_eq!(s.snap_cursor(Vec2::new(0.27, 0.15), &q).point, Vec2::new(0.25, 0.125)); +} + +#[test] +fn drafting_settings_edits_are_drawing_changes_with_undo_and_redo() { + let mut s = Session::new(); + let response = s.execute("dsettings", &json!({"snapunit": [0.25, 0.125], "gridunit": [0.75, 1.5]})).unwrap(); + assert_eq!(response["snapunit"], serde_json::to_value(Vec2::new(0.25, 0.125)).unwrap()); + assert_eq!(response["gridunit"], serde_json::to_value(Vec2::new(0.75, 1.5)).unwrap()); + assert!(s.state().unwrap().is_dirty()); + s.execute("undo", &json!({})).unwrap(); + assert_eq!(get(&mut s, "SNAPUNIT"), json!([0.5, 0.5])); + assert_eq!(get(&mut s, "GRIDUNIT"), json!([0.5, 0.5])); + assert!(!s.state().unwrap().is_dirty()); + s.execute("redo", &json!({})).unwrap(); + assert_eq!(get(&mut s, "SNAPUNIT"), json!([0.25, 0.125])); + assert_eq!(get(&mut s, "GRIDUNIT"), json!([0.75, 1.5])); +} + +#[test] +fn invalid_spacing_is_refused_without_changing_the_drawing() { + let mut s = Session::new(); + for name in ["SNAPUNIT", "GRIDUNIT"] { + for value in [json!([-1, 0.25]), json!([0.25, -1]), json!("NaN"), json!("inf"), Value::Null] { + assert!(s.execute("setvar", &json!({"name": name, "value": value})).is_err(), "{name}: {value}"); + } + } + assert!(s.execute("setvar", &json!({"name": "SNAPUNIT", "value": [0, 0.25]})).is_err()); + assert!(!s.state().unwrap().is_dirty()); + s.execute("setvar", &json!({"name": "GRIDUNIT", "value": [0, 0]})).unwrap(); + assert_eq!(get(&mut s, "GRIDUNIT"), json!([0.0, 0.0])); +} diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index a2e6a0d78..2dbca973f 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -1218,6 +1218,15 @@ fn header(tags: &[Tag], d: &mut Drawing) { vals.push(v); i += 1; } + // Legacy files keep spacing in HEADER; modern *ACTIVE VPORT entries override it. + if matches!(name.as_str(), "SNAPUNIT" | "GRIDUNIT") { + let (Some(x), Some(y)) = (vals.iter().find(|t| t.code == 10), vals.iter().find(|t| t.code == 20)) else { continue }; + let p = Vec2::new(x.f64(), y.f64()); + if p.is_finite() && if name == "GRIDUNIT" { p.x >= 0.0 && p.y >= 0.0 } else { p.x > 0.0 && p.y > 0.0 } { + d.header.set(&name, HVal::Point(p.to3(0.0))); + } + continue; + } let hv = match vals.as_slice() { [x, y, z] if x.code == 10 => HVal::Point(Vec3::new(x.f64(), y.f64(), z.f64())), [x, y] if x.code == 10 => HVal::Point(Vec3::new(x.f64(), y.f64(), 0.0)), @@ -1374,10 +1383,18 @@ 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). + // The first *ACTIVE viewport holds the current snap/grid settings. "VPORT" if !active_vport && t.s(2).is_some_and(|n| n.trim().eq_ignore_ascii_case("*ACTIVE")) => { active_vport = true; vport_record(d, &t); + for (name, code) in [("SNAPUNIT", 14), ("GRIDUNIT", 15)] { + if let (Some(x), Some(y)) = (t.f(code), t.f(code + 10)) { + let p = Vec2::new(x, y); + if p.is_finite() && if name == "GRIDUNIT" { p.x >= 0.0 && p.y >= 0.0 } else { p.x > 0.0 && p.y > 0.0 } { + d.header.set(name, HVal::Point(p.to3(0.0))); + } + } + } if let (Some(x), Some(y)) = (t.f(13), t.f(23)) && x.is_finite() && y.is_finite() diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index a2022fbd1..ab9537eb7 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -211,6 +211,8 @@ const HEADER_VARS: &[(&str, i32)] = &[ ("INSBASE", -3), ("LIMMIN", -2), ("LIMMAX", -2), + ("SNAPUNIT", -2), + ("GRIDUNIT", -2), ("ORTHOMODE", 70), ("FILLMODE", 70), ("MIRRTEXT", 70), @@ -254,6 +256,11 @@ fn header_vars(w: &mut W, d: &Drawing) { let Some(v) = d.header.get(name) else { continue }; match (*code, v) { (-2 | -3, HVal::Point(p)) => { + let p = match *name { + "SNAPUNIT" => d.header.snap_unit().to3(0.0), + "GRIDUNIT" => d.header.grid_unit().to3(0.0), + _ => *p, + }; w.s(9, format!("${name}")); w.f(10, p.x); w.f(20, p.y); @@ -1358,8 +1365,8 @@ fn vport_table(w: &mut W, d: &Drawing, ext: Bounds2) { w.p2(12, finite(t.center, 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.p2(14, d.header.snap_unit()); + w.p2(15, d.header.grid_unit()); w.p(16, Vec3::Z); w.p(17, Vec3::ZERO); w.f(40, if t.height.is_finite() && t.height > 0.0 { t.height } else { 1.0 }); diff --git a/crates/io/tests/drafting_spacing.rs b/crates/io/tests/drafting_spacing.rs new file mode 100644 index 000000000..d1016bcf0 --- /dev/null +++ b/crates/io/tests/drafting_spacing.rs @@ -0,0 +1,111 @@ +//! Snap/grid spacing uses legacy HEADER variables and the current *ACTIVE VPORT (#607). + +use cadcraft_doc::{Drawing, HVal}; +use cadcraft_dxf::{Tag, records, sections}; +use cadcraft_geom::{Vec2, Vec3}; + +fn header(snap: Vec2, grid: Vec2) -> Vec { + let mut tags = Vec::new(); + for (name, unit) in [("$SNAPUNIT", snap), ("$GRIDUNIT", grid)] { + tags.extend([Tag::s(9, name), Tag::f(10, unit.x), Tag::f(20, unit.y)]); + } + tags +} + +fn vport(name: &str, snap: Vec2, grid: Vec2) -> Vec { + vec![Tag::s(0, "VPORT"), Tag::s(2, name), Tag::i(70, 0), Tag::f(14, snap.x), Tag::f(24, snap.y), Tag::f(15, grid.x), Tag::f(25, grid.y)] +} + +fn read(header: &[Tag], vports: &[Vec]) -> Drawing { + let mut tags = vec![Tag::s(0, "SECTION"), Tag::s(2, "HEADER"), Tag::s(9, "$ACADVER"), Tag::s(1, "AC1015")]; + tags.extend_from_slice(header); + tags.extend([Tag::s(0, "ENDSEC"), Tag::s(0, "SECTION"), Tag::s(2, "TABLES"), Tag::s(0, "TABLE"), Tag::s(2, "VPORT")]); + for vp in vports { + tags.extend_from_slice(vp); + } + tags.extend([Tag::s(0, "ENDTAB"), Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")]); + cadcraft_io::read_dxf(cadcraft_dxf::write_ascii(&tags).as_bytes()).unwrap() +} + +fn assert_units(d: &Drawing, snap: Vec2, grid: Vec2) { + assert_eq!(d.header.point("SNAPUNIT").unwrap().xy(), snap); + assert_eq!(d.header.point("GRIDUNIT").unwrap().xy(), grid); +} + +fn number(tags: &[Tag], code: i32) -> f64 { + tags.iter().find(|t| t.code == code).expect("spacing component").f64() +} + +#[test] +fn writes_spacing_to_header_and_active_vport_and_roundtrips() { + let (snap, grid) = (Vec2::new(0.25, 0.125), Vec2::new(1.0, 2.0)); + let mut d = Drawing::new_imperial(); + d.header.set("SNAPUNIT", HVal::Point(snap.to3(0.0))); + d.header.set("GRIDUNIT", HVal::Point(grid.to3(0.0))); + let text = cadcraft_io::write_dxf(&d); + let tags = cadcraft_dxf::parse_ascii(&text).unwrap(); + let sections = sections(&tags); + let header = §ions.iter().find(|s| s.name == "HEADER").unwrap().tags; + for (name, unit) in [("$SNAPUNIT", snap), ("$GRIDUNIT", grid)] { + let index = header.iter().position(|t| t.code == 9 && t.str() == name).expect("spacing header variable"); + let components = &header[index + 1..index + 3]; + assert_eq!(components.iter().map(|t| t.code).collect::>(), [10, 20]); + assert_eq!((number(components, 10), number(components, 20)), (unit.x, unit.y)); + } + let table = §ions.iter().find(|s| s.name == "TABLES").unwrap().tags; + let records = records(table); + let (_, active) = + records.iter().find(|(kind, t)| kind == "VPORT" && t.iter().any(|t| t.code == 2 && t.str().eq_ignore_ascii_case("*ACTIVE"))).unwrap(); + assert_eq!((number(active, 14), number(active, 24)), (snap.x, snap.y)); + assert_eq!((number(active, 15), number(active, 25)), (grid.x, grid.y)); + assert_units(&cadcraft_io::read_dxf(text.as_bytes()).unwrap(), snap, grid); +} + +#[test] +fn reads_legacy_spacing_without_an_active_vport() { + let (snap, grid) = (Vec2::new(0.25, 0.125), Vec2::new(0.75, 1.0)); + assert_units(&read(&header(snap, grid), &[]), snap, grid); +} + +#[test] +fn first_active_vport_overrides_header_and_named_viewports() { + let (snap, grid) = (Vec2::new(0.25, 0.125), Vec2::new(0.75, 1.5)); + let other = Vec2::new(2.0, 3.0); + let ports = [vport("Saved", other, other), vport(" *active ", snap, grid), vport("*ACTIVE", other, other)]; + assert_units(&read(&header(other, other), &ports), snap, grid); +} + +#[test] +fn incomplete_or_invalid_active_spacing_preserves_legacy_values() { + let (snap, grid) = (Vec2::new(0.25, 0.125), Vec2::new(0.75, 1.5)); + let mut incomplete = vport("*ACTIVE", Vec2::new(2.0, 3.0), Vec2::new(-1.0, 0.5)); + incomplete.retain(|t| t.code != 24); + assert_units(&read(&header(snap, grid), &[incomplete]), snap, grid); + let invalid = vport("*ACTIVE", Vec2::new(0.0, 0.5), Vec2::new(0.5, -1.0)); + assert_units(&read(&header(snap, grid), &[invalid]), snap, grid); +} + +#[test] +fn missing_or_invalid_legacy_spacing_uses_the_default() { + let default = Vec2::new(0.5, 0.5); + assert_units(&read(&[], &[]), default, default); + assert_units(&read(&header(Vec2::ZERO, Vec2::new(-1.0, 0.5)), &[]), default, default); + assert_units(&read(&[Tag::s(9, "$SNAPUNIT"), Tag::f(10, 0.25), Tag::s(9, "$GRIDUNIT"), Tag::f(20, 0.75)], &[]), default, default); +} + +#[test] +fn zero_grid_components_roundtrip_as_follow_snap_spacing() { + let mut d = Drawing::new_imperial(); + d.header.set("GRIDUNIT", HVal::Point(Vec3::new(0.0, 0.75, 0.0))); + let text = cadcraft_io::write_dxf(&d); + assert_units(&cadcraft_io::read_dxf(text.as_bytes()).unwrap(), Vec2::new(0.5, 0.5), Vec2::new(0.0, 0.75)); +} + +#[test] +fn invalid_in_memory_spacing_exports_safe_defaults() { + let mut d = Drawing::new_imperial(); + d.header.set("SNAPUNIT", HVal::Point(Vec3::new(f64::NAN, f64::INFINITY, 0.0))); + d.header.set("GRIDUNIT", HVal::Point(Vec3::new(-1.0, 0.75, 0.0))); + let text = cadcraft_io::write_dxf(&d); + assert_units(&cadcraft_io::read_dxf(text.as_bytes()).unwrap(), Vec2::new(0.5, 0.5), Vec2::new(0.5, 0.5)); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 34e99c7ce..b8ae0ebf6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -181,13 +181,14 @@ fn adaptive(unit: f64, scale: f64, min_px: f64) -> f64 { fn draw_grid(app: &CadApp, p: &egui::Painter, xf: &Xf) { let t = Tokens::get(); - let s = &app.session.settings; + let s = app.session.drafting_settings(); let wb = xf.world_bounds(); if s.gridmode && s.isodraft { draw_iso_grid(app, p, xf); } else if s.gridmode { let major_n = f64::from(s.gridmajor.max(1)); - let minor = adaptive(s.gridunit.x, xf.scale, 8.0); + let unit = if s.gridunit.x == 0.0 { s.snapunit.x } else { s.gridunit.x }; + let minor = adaptive(unit, xf.scale, 8.0); let major = minor * major_n; let mut lines = Vec::new(); let x0 = (wb.min.x / minor).floor() as i64; @@ -231,7 +232,7 @@ fn draw_grid(app: &CadApp, p: &egui::Painter, xf: &Xf) { fn draw_iso_grid(app: &CadApp, p: &egui::Painter, xf: &Xf) { use cadcraft_engine::snap::iso; let t = Tokens::get(); - let s = &app.session.settings; + let s = app.session.drafting_settings(); let mut d = s.gridunit.y.max(1e-12); for _ in 0..40 { if d * 0.866 * xf.scale >= 8.0 { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ace0e14f9..f464ea710 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -34,6 +34,8 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { } fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { + let settings = app.session.drafting_settings(); + let (mut snapunit, mut gridunit) = (settings.snapunit, settings.gridunit); 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(crate::tl!("Snap and Grid")); @@ -45,9 +47,9 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { }); ui.horizontal(|ui| { ui.label(crate::tl!("Snap X spacing")); - ui.add(egui::DragValue::new(&mut s.snapunit.x).speed(0.05).range(0.0001..=1e6)); + ui.add(egui::DragValue::new(&mut snapunit.x).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false)); ui.label(crate::tl!("Y")); - ui.add(egui::DragValue::new(&mut s.snapunit.y).speed(0.05).range(0.0001..=1e6)); + ui.add(egui::DragValue::new(&mut snapunit.y).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false)); }); // SNAPTYPE and POLARDIST. ui.horizontal(|ui| { @@ -64,7 +66,7 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { ui.checkbox(&mut s.gridmode, crate::tl!("Grid On (F7)")); ui.horizontal(|ui| { ui.label(crate::tl!("Grid spacing")); - ui.add(egui::DragValue::new(&mut s.gridunit.x).speed(0.05).range(0.0001..=1e6)); + ui.add(egui::DragValue::new(&mut gridunit.x).speed(0.05).range(0.0..=1e6).clamp_existing_to_range(false)); ui.label(crate::tl!("Major line every")); ui.add(egui::DragValue::new(&mut s.gridmajor).range(1..=100)); }); @@ -127,6 +129,12 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { }); ui.checkbox(&mut s.orthomode, crate::tl!("Ortho (F8)")); }); + // Drawing edits go through the same command as the CLI, including dirty state and undo. + for (name, before, after) in [("SNAPUNIT", settings.snapunit, snapunit), ("GRIDUNIT", settings.gridunit, gridunit)] { + if before != after { + let _ = app.run("setvar", json!({ "name": name, "value": [after.x, after.y] })); + } + } } /// The polar and object snap tracking options of Drafting Settings: additional angles diff --git a/crates/ui-egui/tests/drafting_spacing.rs b/crates/ui-egui/tests/drafting_spacing.rs new file mode 100644 index 000000000..3a24dcead --- /dev/null +++ b/crates/ui-egui/tests/drafting_spacing.rs @@ -0,0 +1,70 @@ +//! Drafting Settings reads drawing spacing and commits edits through SETVAR (#607). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Drawing, HVal}; +use cadcraft_engine::geom::Vec3; +use cadcraft_ui_egui::{CadApp, Services, dialogs, theme}; +use egui::accesskit::{Action, ActionData, ActionRequest, Role}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +fn harness(d: Drawing, render: bool) -> Harness<'static, CadApp> { + let mut session = Session::empty(); + session.open_drawing(d, "Drawing", None); + let mut app = CadApp::new(session, Services::default()); + app.ui.dialog = Some("dsettings".into()); + let builder = Harness::builder().with_size(egui::vec2(800.0, 600.0)); + let builder = if render { builder.wgpu() } else { builder }; + let mut h = builder.build_ui_state( + |ui, app: &mut CadApp| { + theme::apply(ui.ctx(), egui::Theme::Dark, false); + dialogs::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(5); + h +} + +fn set_spacing(h: &mut Harness<'_, CadApp>, index: usize, value: f64) { + let (target_node, target_tree) = h.get_all_by_role(Role::SpinButton).nth(index).expect("spacing input").accesskit_node().locate(); + h.event(egui::Event::AccessKitActionRequest(ActionRequest { + action: Action::SetValue, + target_node, + target_tree, + data: Some(ActionData::NumericValue(value)), + })); + h.run_steps(3); +} + +#[test] +fn spacing_controls_update_the_drawing_and_mark_it_dirty() { + let directory = std::env::var_os("CADCRAFT_DRAFTING_SHOTS").map(std::path::PathBuf::from); + let mut h = harness(Drawing::new_imperial(), directory.is_some()); + assert!(!h.state().session.state().unwrap().is_dirty()); + set_spacing(&mut h, 0, 0.25); + set_spacing(&mut h, 1, 0.125); + // The fourth spin box: Polar distance (#450) sits between snap and grid spacing. + set_spacing(&mut h, 3, 0.75); + let app = h.state_mut(); + assert_eq!(app.run("getvar", json!({"name": "SNAPUNIT"})).unwrap()["value"], json!([0.25, 0.125])); + assert_eq!(app.run("getvar", json!({"name": "GRIDUNIT"})).unwrap()["value"], json!([0.75, 0.5])); + assert!(app.session.state().unwrap().is_dirty()); + if let Some(directory) = directory { + std::fs::create_dir_all(&directory).unwrap(); + h.render().unwrap().save(directory.join("drafting-settings-spacing.png")).unwrap(); + } +} + +#[test] +fn opening_the_dialog_preserves_loaded_spacing_outside_drag_ranges() { + let mut d = Drawing::new_imperial(); + d.header.set("SNAPUNIT", HVal::Point(Vec3::new(1e-6, 2e-6, 0.0))); + d.header.set("GRIDUNIT", HVal::Point(Vec3::new(1e8, 1e8, 0.0))); + let h = harness(d, false); + let header = &h.state().session.doc().unwrap().header; + assert_eq!(header.point("SNAPUNIT"), Some(Vec3::new(1e-6, 2e-6, 0.0))); + assert_eq!(header.point("GRIDUNIT"), Some(Vec3::new(1e8, 1e8, 0.0))); + assert!(!h.state().session.state().unwrap().is_dirty(), "opening a dialog must not change the drawing"); +}