diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index bc975c26..c4acffab 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -34,7 +34,7 @@ pub fn specs() -> Vec { CommandSpec::new("mview", "Viewports", run_mview) .alias(&["mv"]) .params( - "{option?: fit|polygonal|object|on|off|lock|restore, p1?, p2? (default: printable area), count?: 1-4, arrangement?: vertical|horizontal|left|right|above|below, points?: [[x,y]…] + bulges? (polygonal), object? (closed object to clip by), handles? + locked? (on/off/lock), layout?}", + "{option?: fit|polygonal|object|on|off|lock|restore, p1?, p2? (default: printable area), count?: 1-4, arrangement?: vertical|horizontal|left|right|above|below, points?: [[x,y]…] + bulges? (polygonal), object? (closed object to clip by), handles? + locked? (on/off/lock), name? (restore: a model space viewport configuration, default *Active; its viewports fill p1-p2 or the printable area), layout?}", ) .interactive(|_| Ok(Box::new(MviewM::new(1, true)))), CommandSpec::new("mview.polygonal", "Polygonal Viewport", |s, p| run_mview(s, &viewports::with(p.clone(), "option", json!("polygonal")))) diff --git a/crates/engine/src/cmd/layout/viewports.rs b/crates/engine/src/cmd/layout/viewports.rs index aad72be9..7f47c4e3 100644 --- a/crates/engine/src/cmd/layout/viewports.rs +++ b/crates/engine/src/cmd/layout/viewports.rs @@ -1,22 +1,25 @@ //! Clipped viewports and MVIEW's options: polygonal and object viewports (MVIEW Polygonal / -//! Object), VPCLIP, ON/OFF, Lock and Restore, and the typed MVIEW prompts. +//! Object), VPCLIP, ON/OFF, Lock and Restore (a model space viewport configuration as layout +//! viewports), and the typed MVIEW prompts. //! //! A clipped viewport keeps its rectangle as the boundary's extents and points at a paper-space //! entity (a closed polyline, circle, ellipse or spline) whose outline clips what it shows //! ([`Viewport::clip`], DXF group 340). -use cadcraft_doc::{Drawing, EntityKind, Handle, Space, Viewport}; +use cadcraft_doc::{Drawing, EntityKind, Handle, Space, Viewport, VportConfig, VportTile}; use cadcraft_geom::{Bounds2, PolyVertex, Vec2}; use serde_json::{Value, json}; use super::super::curves::{self, handle_value}; use super::super::helpers::{lwpoly, rect_vertices}; -use super::super::{bad, bool_or, points_param, str_param, targets}; -use super::{fit_view, layout_viewports, mview, split, target_layout}; +use super::super::{bad, bool_or, point_param, points_param, str_param, targets}; +use super::{fit_view, layout_viewports, mview, printable, split, target_layout}; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; /// Most vertices a typed or JSON polygonal boundary may have. const MAX_VERTICES: usize = 10_000; +/// Most viewports MVIEW Restore creates (a configuration read from a file may hold more tiles). +const MAX_RESTORED: usize = 64; /// The polygon outline's extents of a closed paper-space entity usable as a clip boundary. fn boundary_bounds(k: &EntityKind) -> Option { @@ -210,6 +213,66 @@ fn mview_lock(s: &mut Session, p: &Value) -> Result { Ok(json!({ "viewports": hs.iter().map(|h| h.hex()).collect::>(), "locked": locked })) } +/// Where tile `t` of a viewport configuration lands when the whole configuration fills `r`; `None` +/// for a tile with no usable area (configurations come from files). +fn tile_rect(t: &VportTile, r: Bounds2) -> Option { + let f = |v: f64| v.is_finite().then(|| v.clamp(0.0, 1.0)); + let (x0, y0, x1, y1) = (f(t.min.x)?, f(t.min.y)?, f(t.max.x)?, f(t.max.y)?); + if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 { + return None; + } + let at = |x: f64, y: f64| Vec2::new(r.min.x + x * r.width(), r.min.y + y * r.height()); + Some(Bounds2::new(at(x0, y0), at(x1, y1))) +} + +/// The view a restored tile shows in a viewport of size `r`: its saved centre, and its height +/// grown when the viewport is narrower than the tile was, so the whole saved view still shows. +fn tile_view(t: &VportTile, r: Bounds2) -> Option<(Vec2, f64)> { + if !(t.center.is_finite() && t.height.is_finite() && t.height > 0.0) { + return None; + } + let aspect = r.width() / r.height().max(1e-12); + let h = if t.aspect.is_finite() && t.aspect > 0.0 && aspect > 0.0 { t.height.max(t.height * t.aspect / aspect) } else { t.height }; + (h.is_finite() && h > 0.0).then_some((t.center, h)) +} + +/// MVIEW Restore: `{name? (default *Active), p1?, p2? (default: the printable area), layout?}`. +/// One viewport per tile of the model space viewport configuration, arranged as the tiles are +/// over the window, each showing its tile's view. +fn mview_restore(s: &mut Session, p: &Value) -> Result { + let space = target_layout(s, "mview", p)?; + let Space::Paper(layout) = &space else { return Err(bad("mview", "not a layout")) }; + let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or(VportConfig::ACTIVE).to_string(); + let tiles = super::super::view::config_tiles(s, &name) + .ok_or_else(|| EngineError::Other(format!("Could not find viewport configuration \"{}\".", name.chars().take(64).collect::())))?; + let rect = match (point_param(p, "p1"), point_param(p, "p2")) { + (Some(a), Some(b)) => Bounds2::new(a, b), + _ => printable(s, layout)?, + }; + if rect.is_empty() || !rect.min.is_finite() || !rect.max.is_finite() || rect.width() < 1e-6 || rect.height() < 1e-6 { + return Err(bad("mview", "the viewport has no area")); + } + if rect.width() > 1e7 || rect.height() > 1e7 { + return Err(bad("mview", "the viewport is too large")); + } + let common = s.current_common(); + let d = s.doc_mut()?; + let mut out = Vec::new(); + for t in tiles.iter().take(MAX_RESTORED) { + let Some(r) = tile_rect(t, rect) else { continue }; + let id = next_viewport_id(d); + let mut vp = new_viewport(d, r, id); + if let Some((c, h)) = tile_view(t, r) { + (vp.view_center, vp.view_height) = (c, h); + } + out.push(d.add(&space, common.clone(), EntityKind::Viewport(vp))?); + } + if out.is_empty() { + return Err(EngineError::Other(format!("Viewport configuration \"{name}\" has no viewports to restore."))); + } + Ok(json!({ "configuration": name, "viewports": handles_json(&out) })) +} + /// MVIEW's options other than corners and Fit; `None` when `option` is not one of them. pub(super) fn mview_option(s: &mut Session, p: &Value) -> Option> { let opt = str_param(p, "option")?.trim().to_ascii_lowercase(); @@ -219,7 +282,7 @@ pub(super) fn mview_option(s: &mut Session, p: &Value) -> Option> "on" => mview_on(s, p, true), "off" => mview_on(s, p, false), "lock" | "l" => mview_lock(s, p), - "restore" | "r" => Err(bad("mview", "named viewport configurations are not available yet")), + "restore" | "r" => mview_restore(s, p), "fit" | "f" | "corners" | "" => return None, _ => Err(bad("mview", "option must be fit, polygonal, object, on, off, lock or restore")), }) @@ -471,6 +534,10 @@ enum Stage { Polygon(PolygonInput), Object, Lock, + /// Restore: the configuration's name, then where its viewports go. + RestoreName, + RestoreCorner(String), + RestoreOpposite(String, Vec2), /// ON/OFF (Some(on)) or Lock (None, with the chosen state) on picked viewports. Select { on: Option, @@ -507,6 +574,18 @@ impl MviewM { s.echo(format!("{n} viewport(s) created, zoomed to the model extents.")); Ok(Step::Done) } + /// MVIEW Restore with the typed name, over `rect` (None: Fit). + fn restore(s: &mut Session, name: &str, rect: Option) -> Result { + let mut p = json!({ "option": "restore", "name": name }); + if let Some(r) = rect { + p = with(p, "p1", json!([r.min.x, r.min.y])); + p = with(p, "p2", json!([r.max.x, r.max.y])); + } + let r = mview_restore(s, &p)?; + let n = r.get("viewports").and_then(Value::as_array).map(Vec::len).unwrap_or(0); + s.echo(format!("{n} viewport(s) restored from \"{name}\".")); + Ok(Step::Done) + } fn corner_keyword(&mut self, s: &mut Session, k: &str) -> Result { if k == "fit" || k == "f" { return self.create(s, None); @@ -524,7 +603,7 @@ impl MviewM { "lock" | "l" => Stage::Lock, "object" | "o" => Stage::Object, "polygonal" | "p" => Stage::Polygon(PolygonInput::default()), - "restore" | "r" => return Err(EngineError::Other("Named viewport configurations are not available yet.".into())), + "restore" | "r" => Stage::RestoreName, _ => return Err(EngineError::Other("Requires a point or option keyword.".into())), }; Ok(Step::Continue) @@ -562,6 +641,9 @@ impl Interactive for MviewM { Stage::Polygon(pi) => pi.prompt(), Stage::Object => Prompt::new("Select object to clip viewport", Accept::POINT), Stage::Lock => Prompt::new("Lock the view of viewports?", curves::KW).kw(&["ON", "OFF"]), + Stage::RestoreName => Prompt::new("Enter viewport configuration name", Accept::TEXT).kw(&["?"]).default(VportConfig::ACTIVE), + Stage::RestoreCorner(_) => Prompt::new("Specify first corner", Accept::POINT).kw(&["Fit"]).default("Fit"), + Stage::RestoreOpposite(_, a) => Prompt::new("Specify opposite corner", Accept::POINT).base(*a), Stage::Select { .. } => Prompt::new("Select viewports", Accept::POINT), } } @@ -653,6 +735,53 @@ impl Interactive for MviewM { }; Ok(Step::Continue) } + Stage::RestoreName => { + let name = match &i { + Input::Enter => VportConfig::ACTIVE.to_string(), + Input::Text(t) | Input::Keyword(t) if !t.trim().is_empty() => t.trim().to_string(), + _ => { + self.stage = Stage::RestoreName; + return Ok(Step::Continue); + } + }; + self.stage = Stage::RestoreName; + if name == "?" { + let names = super::super::view::config_names(s); + s.echo(if names.is_empty() { "No saved viewport configurations.".to_string() } else { names.join(", ") }); + return Ok(Step::Continue); + } + if super::super::view::config_tiles(s, &name).is_none() { + return Err(EngineError::Other(format!( + "Could not find viewport configuration \"{}\".", + name.chars().take(64).collect::() + ))); + } + self.stage = Stage::RestoreCorner(name); + Ok(Step::Continue) + } + Stage::RestoreCorner(name) => match i { + Input::Enter => Self::restore(s, &name, None), + Input::Keyword(k) | Input::Text(k) if matches!(k.trim().to_ascii_lowercase().as_str(), "fit" | "f") => Self::restore(s, &name, None), + Input::Point(a) => { + self.stage = Stage::RestoreOpposite(name, a); + Ok(Step::Continue) + } + Input::Keyword(_) | Input::Text(_) => { + self.stage = Stage::RestoreCorner(name); + Err(EngineError::Other("Requires a point or option keyword.".into())) + } + _ => { + self.stage = Stage::RestoreCorner(name); + Ok(Step::Continue) + } + }, + Stage::RestoreOpposite(name, a) => match i { + Input::Point(b) => Self::restore(s, &name, Some(Bounds2::new(a, b))), + _ => { + self.stage = Stage::RestoreOpposite(name, a); + Ok(Step::Continue) + } + }, Stage::Select { on, locked, mut picked } => match i { Input::Enter => { if picked.is_empty() { @@ -673,12 +802,22 @@ impl Interactive for MviewM { }, } } - fn preview(&self, _s: &Session, c: Vec2) -> Vec { + fn preview(&self, s: &Session, c: Vec2) -> Vec { match &self.stage { Stage::Opposite(a) => { split(Bounds2::new(*a, c), self.count, &self.arrangement).into_iter().map(|r| lwpoly(rect_vertices(r.min, r.max), true)).collect() } Stage::Polygon(pi) => pi.preview(c), + Stage::RestoreOpposite(name, a) => { + let r = Bounds2::new(*a, c); + super::super::view::config_tiles(s, name) + .unwrap_or_default() + .iter() + .take(MAX_RESTORED) + .filter_map(|t| tile_rect(t, r)) + .map(|r| lwpoly(rect_vertices(r.min, r.max), true)) + .collect() + } _ => Vec::new(), } } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 973188a9..36bef26e 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -12,7 +12,7 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, View}; mod named_views; mod vports; -pub(super) use vports::{model_vports, tile_model}; +pub(super) use vports::{config_names, config_tiles, model_vports, tile_model}; pub fn specs() -> Vec { let mut v = vec![ diff --git a/crates/engine/src/cmd/view/vports.rs b/crates/engine/src/cmd/view/vports.rs index c80b1b4c..88ee0a6e 100644 --- a/crates/engine/src/cmd/view/vports.rs +++ b/crates/engine/src/cmd/view/vports.rs @@ -71,6 +71,22 @@ fn with_option(p: &Value, option: &str) -> Value { p } +/// The tiles of viewport configuration `name` (any case), for MVIEW Restore: `*Active` is the +/// model space as shown now (one full tile with the model view when it isn't tiled), any other +/// name a saved configuration. `None` when there is no such configuration. +pub(in crate::cmd) fn config_tiles(s: &Session, name: &str) -> Option> { + let name = name.trim(); + if name.eq_ignore_ascii_case(VportConfig::ACTIVE) { + return Some(live(s.state().ok()?, s.viewport_px).0); + } + s.doc().ok()?.vports.iter().find(|c| !c.is_active() && c.name.eq_ignore_ascii_case(name)).map(|c| c.tiles.clone()) +} + +/// The saved viewport configurations' names. +pub(in crate::cmd) fn config_names(s: &Session) -> Vec { + s.doc().map(|d| d.vports.iter().filter(|c| !c.is_active()).map(|c| c.name.clone()).collect()).unwrap_or_default() +} + // ---------------- tile state ---------------- type Rect = (Vec2, Vec2); diff --git a/crates/engine/src/snap/iso.rs b/crates/engine/src/snap/iso.rs index b4ee7836..9f1688b9 100644 --- a/crates/engine/src/snap/iso.rs +++ b/crates/engine/src/snap/iso.rs @@ -116,9 +116,10 @@ pub fn lattice_snap(p: Vec2, d: f64, origin: Vec2, angle: f64) -> Vec2 { /// Grid snap for a cursor update: with snap on and isometric style, the cursor snapped to the /// isometric lattice, and the settings with grid snap off so the rectangular grid leaves it be. -/// Otherwise both unchanged. +/// Otherwise both unchanged, also when PolarSnap (SNAPTYPE 1) is the snap type: snap mode then +/// steps along the tracking paths instead. pub(crate) fn grid_snap(doc: &cadcraft_doc::Drawing, mut s: Settings, p: Vec2) -> (Vec2, Settings) { - if !(s.snapmode && s.isodraft) { + if !(s.snapmode && s.isodraft) || s.polarsnap { return (p, s); } let (origin, angle) = super::grid_frame(doc); diff --git a/crates/engine/tests/followups_626.rs b/crates/engine/tests/followups_626.rs new file mode 100644 index 00000000..9afcac17 --- /dev/null +++ b/crates/engine/tests/followups_626.rs @@ -0,0 +1,150 @@ +//! Drafting follow-ups (#626): isometric snap gives way to PolarSnap, and MVIEW Restore puts a +//! model space viewport configuration on a layout. + +use cadcraft_doc::{EntityKind, Space, Viewport, VportTile}; +use cadcraft_engine::Session; +use cadcraft_engine::geom::{Bounds2, Vec2}; +use cadcraft_engine::snap::tracking::CursorQuery; +use serde_json::{Value, json}; + +fn v(x: f64, y: f64) -> Vec2 { + Vec2::new(x, y) +} + +fn setvar(s: &mut Session, name: &str, value: Value) { + s.execute("setvar", &json!({ "name": name, "value": value })).unwrap(); +} + +/// Move the cursor to `raw` as the canvas does. +fn hover(s: &mut Session, raw: Vec2, base: Option) -> Vec2 { + let q = CursorQuery { base, deferred: false, aperture: 0.5, tol: 0.1, now: 100.0, shift: false }; + let r = s.snap_cursor(raw, &q); + s.cursor = r.point; + r.point +} + +#[test] +fn isometric_snap_gives_way_to_polarsnap() { + let mut s = Session::new(); + setvar(&mut s, "OSMODE", json!(0)); + s.execute("isodraft", &json!({ "plane": "left" })).unwrap(); + setvar(&mut s, "SNAPMODE", json!(1)); + setvar(&mut s, "SNAPUNIT", json!([1, 1])); + s.cmdline("line 0,0").unwrap(); + let base = Some(v(0.0, 0.0)); + // Grid snap (SNAPTYPE 0): the isometric lattice. + let off = v(0.3, 2.2); + let lattice = hover(&mut s, off, base); + assert!(!lattice.near(off, 1e-6), "snapped to the lattice: {lattice:?}"); + // PolarSnap: steps of POLARDIST along the polar paths, and the cursor is free off them. + setvar(&mut s, "SNAPTYPE", json!(1)); + setvar(&mut s, "POLARDIST", json!(2.5)); + let p = hover(&mut s, v(0.02, 6.1), base); + assert!(p.near(v(0.0, 5.0), 1e-9), "PolarSnap along 90°: {p:?}"); + let q = hover(&mut s, off, base); + assert!(q.near(off, 1e-9), "off the paths the isometric lattice no longer snaps: {q:?}"); + // Back to grid snap: the lattice again. + setvar(&mut s, "SNAPTYPE", json!(0)); + assert!(hover(&mut s, off, base).near(lattice, 1e-9)); +} + +/// The layout viewports with the given handles, in order. +fn viewports(s: &Session, layout: &str, handles: &Value) -> Vec { + let d = s.doc().unwrap(); + let st = d.space(&Space::Paper(layout.into())).unwrap(); + handles + .as_array() + .unwrap() + .iter() + .map(|h| { + let h = cadcraft_doc::Handle::parse_hex(h.as_str().unwrap()).unwrap(); + match &st.get(h).unwrap().kind { + EntityKind::Viewport(v) => v.clone(), + other => panic!("not a viewport: {other:?}"), + } + }) + .collect() +} + +/// A model space with two side-by-side tiles saved as "Two" (the left tile zoomed in). +fn two_tiles() -> (Session, Vec) { + let mut s = Session::new(); + s.execute("line", &json!({ "points": [[0, 0], [100, 50]] })).unwrap(); + s.execute("zoom", &json!({ "mode": "extents" })).unwrap(); + s.execute("-vports", &json!({ "option": "2", "arrangement": "vertical" })).unwrap(); + s.execute("zoom", &json!({ "mode": "center", "center": [10, 10], "height": 8 })).unwrap(); + s.execute("-vports", &json!({ "option": "save", "name": "Two" })).unwrap(); + let tiles = s.doc().unwrap().vports.iter().find(|c| c.name == "Two").unwrap().tiles.clone(); + assert_eq!(tiles.len(), 2); + (s, tiles) +} + +fn check_restored(vps: &[Viewport], tiles: &[VportTile], window: Bounds2) { + assert_eq!(vps.len(), tiles.len()); + for (vp, t) in vps.iter().zip(tiles) { + let at = |f: Vec2| v(window.min.x + f.x * window.width(), window.min.y + f.y * window.height()); + let (lo, hi) = (at(t.min), at(t.max)); + assert!(vp.center.xy().near((lo + hi) * 0.5, 1e-9), "{vp:?} {t:?}"); + assert!((vp.width - (hi.x - lo.x)).abs() < 1e-9 && (vp.height - (hi.y - lo.y)).abs() < 1e-9); + assert!(vp.view_center.near(t.center, 1e-9), "each viewport shows its tile's view"); + assert!(vp.view_height >= t.height - 1e-9); + } +} + +#[test] +fn mview_restore_puts_a_saved_configuration_on_a_layout() { + let (mut s, tiles) = two_tiles(); + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + let window = Bounds2::new(v(10.0, 10.0), v(210.0, 110.0)); + let r = s.execute("mview", &json!({ "option": "restore", "name": "two", "p1": [10, 10], "p2": [210, 110] })).unwrap(); + assert_eq!(r["configuration"], "two"); + check_restored(&viewports(&s, "Layout1", &r["viewports"]), &tiles, window); + + // Typed: name (? lists the saved ones), then two corners. + s.cmdline("mview").unwrap(); + s.cmdline("r").unwrap(); + assert!(s.prompt_text().contains("Enter viewport configuration name"), "{}", s.prompt_text()); + s.cmdline("?").unwrap(); + assert!(s.log.iter().any(|l| l.contains("Two")), "{:?}", s.log); + assert!(s.cmdline("nope").is_err() || s.running.is_some(), "an unknown name asks again"); + assert!(s.prompt_text().contains("configuration name"), "{}", s.prompt_text()); + s.cmdline("Two").unwrap(); + s.cmdline("10,120").unwrap(); + s.cmdline("110,170").unwrap(); + assert!(s.running.is_none()); + assert!(s.log.iter().any(|l| l.contains("2 viewport(s) restored")), "{:?}", s.log); + + // Enter takes *Active (the tiles on show) and Fit. + let before = s.doc().unwrap().space(&Space::Paper("Layout1".into())).unwrap().iter().count(); + s.cmdline("mview").unwrap(); + s.cmdline("r").unwrap(); + s.cmdline("").unwrap(); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + let after = s.doc().unwrap().space(&Space::Paper("Layout1".into())).unwrap().iter().count(); + assert_eq!(after, before + 2, "the model space shows two tiles"); + + // Unknown names, model space and hostile windows are refused. + assert!(s.execute("mview", &json!({ "option": "restore", "name": "nope" })).is_err()); + assert!(s.execute("mview", &json!({ "option": "restore", "p1": [0, 0], "p2": [0, 5] })).is_err()); + assert!(s.execute("mview", &json!({ "option": "restore", "p1": [0, 0], "p2": [1e300, 5] })).is_err()); + assert!(s.execute("mview", &json!({ "option": "restore", "layout": "Model" })).is_err()); +} + +#[test] +fn mview_restore_ignores_unusable_tiles_from_files() { + let (mut s, _) = two_tiles(); + let bad = [ + VportTile { min: v(f64::NAN, 0.0), max: v(1.0, 1.0), center: v(0.0, 0.0), height: 1.0, aspect: 1.0 }, + VportTile { min: v(0.5, 0.5), max: v(0.5, 0.9), center: v(0.0, 0.0), height: 1.0, aspect: 1.0 }, + VportTile { min: v(-3.0, -3.0), max: v(9.0, 9.0), center: v(f64::INFINITY, 0.0), height: -1.0, aspect: f64::NAN }, + ]; + if let Some(c) = s.doc_mut().unwrap().vports.iter_mut().find(|c| c.name == "Two") { + c.tiles = bad.to_vec(); + } + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + let r = s.execute("mview", &json!({ "option": "restore", "name": "Two", "p1": [0, 0], "p2": [100, 50] })).unwrap(); + let vps = viewports(&s, "Layout1", &r["viewports"]); + assert_eq!(vps.len(), 1, "only the third tile has an area (clamped to the window)"); + assert!((vps[0].width - 100.0).abs() < 1e-9 && vps[0].view_height.is_finite() && vps[0].view_height > 0.0); +} diff --git a/crates/engine/tests/viewport_clip.rs b/crates/engine/tests/viewport_clip.rs index 36a5285d..eaeb4606 100644 --- a/crates/engine/tests/viewport_clip.rs +++ b/crates/engine/tests/viewport_clip.rs @@ -162,8 +162,8 @@ fn on_off_lock_restore_and_arrangements() { assert!(viewport(&s, &vh).locked, "{:?}", s.log); s.execute("mview", &json!({"option": "lock", "locked": false, "handles": [vh]})).unwrap(); assert!(!viewport(&s, &vh).locked); - // Restore needs named viewport configurations. - assert!(s.execute("mview", &json!({"option": "restore"})).is_err()); + // Restore needs a viewport configuration that exists (followups_626.rs covers the rest). + assert!(s.execute("mview", &json!({"option": "restore", "name": "none saved"})).is_err()); // 3 viewports arranged Above: the large one on top. s.cmdline("mview 3 a 200,0 300,100").unwrap(); let vps = layout_viewports(&s); diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 3e2f5143..2173d95d 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -70,6 +70,12 @@ pub struct PdfOptions { /// embedded font subset (like AutoCAD's PDFSHX for its own fonts). Stroke-font text is /// always geometry. pub text_as_geometry: bool, + /// Margins (left, bottom, right, top; mm) of the plot device's paper, bounding the printable + /// area of a model-space plot. `None` keeps the page setup's (none for model space). + pub margins: Option<[f64; 4]>, + /// Entity hyperlinks read from a drawing file (`PE_URL` extended data) become link + /// annotations too, after [`PdfOptions::links`]. Default on. + pub hyperlinks: bool, } impl Default for PdfOptions { @@ -89,13 +95,15 @@ impl Default for PdfOptions { include_off_layers: false, links: Vec::new(), text_as_geometry: false, + margins: None, + hyperlinks: true, } } } impl PdfOptions { /// Parse `{paper?, width?, height?, landscape?, fit?, scale?, lineweights?, compress?, title?, - /// window?: [[x1, y1], [x2, y2]]}`. + /// window?: [[x1, y1], [x2, y2]], margins?: [l, b, r, t] (mm), hyperlinks?: bool}`. pub fn from_json(v: &Value) -> PdfOptions { let num = |k: &str| v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()); let paper_mm = match (num("width"), num("height")) { @@ -119,10 +127,21 @@ impl PdfOptions { compress: v.get("compress").and_then(Value::as_bool).unwrap_or(true), title: v.get("title").and_then(Value::as_str).unwrap_or("").to_string(), window: window(v.get("window")), + margins: margins(v.get("margins")), + hyperlinks: v.get("hyperlinks").and_then(Value::as_bool).unwrap_or(true), } } } +/// `[l, b, r, t]` margins in mm (finite, 0–1000), else `None`. +pub(crate) fn margins(v: Option<&Value>) -> Option<[f64; 4]> { + let a: Vec = v?.as_array()?.iter().take(5).filter_map(Value::as_f64).collect(); + match a[..] { + [l, b, r, t] if a.iter().all(|x| x.is_finite() && (0.0..=1000.0).contains(x)) => Some([l, b, r, t]), + _ => None, + } +} + /// A plot window from `[[x1, y1], [x2, y2]]`: finite corners and a positive size, else `None`. fn window(v: Option<&Value>) -> Option { let corner = |i: usize| { @@ -214,6 +233,10 @@ pub fn page_for(d: &Drawing, space: &Space, o: &PdfOptions) -> Result if let Some(t) = &o.plot_style_table { page.plot_style_table = t.clone(); } + // The plot device's margins bound a model-space plot (a layout's come with its page setup). + if let (Space::Model, Some(m)) = (space, o.margins) { + page.margins_mm = m; + } page.width_mm = page.width_mm.clamp(1.0, MAX_SHEET_MM); page.height_mm = page.height_mm.clamp(1.0, MAX_SHEET_MM); Ok(page) @@ -269,7 +292,8 @@ pub fn pdf_sheets(d: &Drawing, spaces: &[Space], o: &PdfOptions) -> Result>>()?; + let links = all_links(d, o); + let pages = spaces.iter().map(|s| sheet(drawn, s, o, &links)).collect::>>()?; let title = if o.title.is_empty() { pages.first().map(|p| p.label.clone()).unwrap_or_default() } else { o.title.clone() }; let fonts = if o.text_as_geometry { fonts::Fonts::default() } else { fonts::Fonts::collect(pages.iter().map(|p| &p.list)) }; Ok(assemble(d, &pages, &fonts, o, &title)) @@ -292,7 +316,7 @@ struct SheetPage { } /// Plot one space. -fn sheet(d: &Drawing, space: &Space, o: &PdfOptions) -> Result { +fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String)]) -> Result { let page = page_for(d, space, o)?; let unit_mm = paper::paper_unit_mm(d); let sheet = Sheet::from_page(&page, unit_mm); @@ -348,10 +372,40 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions) -> Result { } } } - let links = links(&list, &map, &clip_pt, &o.links); + let links = links(&list, &map, &clip_pt, links_to); Ok(SheetPage { media, list, map, clip_pt, layers, links, label: sheet_name(space) }) } +/// The hyperlinks to plot: the options' own, then (with [`PdfOptions::hyperlinks`]) those the +/// drawing file gave its entities as `PE_URL` extended data, at most [`MAX_LINKS`] in all. +fn all_links(d: &Drawing, o: &PdfOptions) -> Vec<(Handle, String)> { + let mut out: Vec<(Handle, String)> = o.links.iter().take(MAX_LINKS).cloned().collect(); + if !o.hyperlinks { + return out; + } + let given: std::collections::HashSet = out.iter().map(|(h, _)| *h).collect(); + for (h, extra) in &d.foreign.entities { + if out.len() >= MAX_LINKS { + break; + } + if given.contains(h) { + continue; + } + if let Some(url) = pe_url(&extra.xdata) { + out.push((*h, url)); + } + } + out +} + +/// The link target of `PE_URL` extended data: the first string (group 1000) after +/// `1001 PE_URL`, before the next application's data. +fn pe_url(xdata: &[cadcraft_doc::RawTag]) -> Option { + let start = xdata.iter().position(|t| t.code == 1001 && t.value.trim().eq_ignore_ascii_case("PE_URL"))?; + let url = xdata.get(start + 1..)?.iter().take_while(|t| t.code != 1001).find(|t| t.code == 1000)?.value.trim(); + (!url.is_empty() && url.len() <= MAX_URL).then(|| url.to_string()) +} + /// Link annotation rectangles: the area (in points) each linked entity covers on the sheet, /// clipped to the plot area. fn links(list: &cadcraft_render::DisplayList, map: &Map, clip_pt: &Bounds2, links: &[(Handle, String)]) -> Vec<(Bounds2, String)> { diff --git a/crates/io/src/plotter.rs b/crates/io/src/plotter.rs index c77c3802..e65710c1 100644 --- a/crates/io/src/plotter.rs +++ b/crates/io/src/plotter.rs @@ -19,9 +19,10 @@ use crate::{IoError, PdfOptions, Result, pdf}; pub const MAX_PNG_PIXELS: f64 = 40.0e6; /// Plot `space` with JSON options (the engine's `plot` hook): PDF unless `output` is `png` or -/// `svg`. `margins: [l, b, r, t]` (mm) replaces a layout's printable area for this plot. +/// `svg`. `margins: [l, b, r, t]` (mm) bound the printable area of this plot (model space or a +/// layout). pub fn plot(d: &Drawing, space: &Space, opts: &Value) -> Result> { - let margins = margins(opts.get("margins")); + let margins = pdf::margins(opts.get("margins")); let with_margins; let d = match (space, margins) { (Space::Paper(n), Some(m)) => { @@ -36,28 +37,15 @@ pub fn plot(d: &Drawing, space: &Space, opts: &Value) -> Result> { }; match opts.get("output").and_then(Value::as_str).map(str::to_ascii_lowercase).as_deref() { None | Some("pdf") => pdf::plot(d, space, opts), - Some("png") => png(d, space, opts, margins), + Some("png") => png(d, space, opts), Some("svg") => svg(d, space, opts), Some(other) => Err(IoError::Unsupported(other.to_string())), } } -/// `[l, b, r, t]` margins in mm (finite, 0–1000), else `None`. -fn margins(v: Option<&Value>) -> Option<[f64; 4]> { - let a: Vec = v?.as_array()?.iter().filter_map(Value::as_f64).collect(); - match a[..] { - [l, b, r, t] if a.iter().all(|x| x.is_finite() && (0.0..=1000.0).contains(x)) => Some([l, b, r, t]), - _ => None, - } -} - -/// The sheet a plot draws on, and whether the drawing is fitted to its printable area (model -/// space, or a layout asked to `fit`). -fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, margins: Option<[f64; 4]>) -> Result<(Sheet, cadcraft_doc::PageSetup)> { - let mut page = pdf::page_for(d, space, o)?; - if let (Space::Model, Some(m)) = (space, margins) { - page.margins_mm = m; - } +/// The sheet a plot draws on (a model-space plot's printable area inside the device margins). +fn sheet(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<(Sheet, cadcraft_doc::PageSetup)> { + let page = pdf::page_for(d, space, o)?; Ok((Sheet::from_page(&page, paper::paper_unit_mm(d)), page)) } @@ -75,9 +63,9 @@ fn fit_scale(b: &Bounds2, sheet: &Sheet, o: &PdfOptions) -> f64 { if s.is_finite() && s > 0.0 { s } else { 1.0 } } -fn png(d: &Drawing, space: &Space, opts: &Value, margins: Option<[f64; 4]>) -> Result> { +fn png(d: &Drawing, space: &Space, opts: &Value) -> Result> { let o = PdfOptions::from_json(opts); - let (sheet, page) = sheet(d, space, &o, margins)?; + let (sheet, page) = sheet(d, space, &o)?; let dpi = opts.get("resolution").and_then(Value::as_f64).filter(|r| r.is_finite()).unwrap_or(300.0).clamp(50.0, 2400.0); // Pixels per paper unit, lowered so the image stays within the caps. let mut k = sheet.unit_mm / 25.4 * dpi; @@ -101,11 +89,14 @@ fn png(d: &Drawing, space: &Space, opts: &Value, margins: Option<[f64; 4]>) -> R // Chord tolerance: a fifth of a pixel. let tolerance = (0.2 / (k * est)).max(1e-9); let ropts = cadcraft_render::Options { tolerance, lineweights, plot_style_table: table, ..cadcraft_render::Options::default() }; - let list = cadcraft_render::build_plot(d, space, &ropts); + let mut list = cadcraft_render::build_plot(d, space, &ropts); let half = Vec2::new(f64::from(width), f64::from(height)) / (2.0 * k); + let b = window.unwrap_or(list.bounds); + let s = if fit { fit_scale(&b, &sheet, &o) } else { 1.0 }; + // Plot style ends, joins and fill patterns at the plot scale, as PDF draws them (a layout's + // are already drawn). + cadcraft_render::pen::apply(&mut list, s * sheet.unit_mm); let view = if fit { - let b = window.unwrap_or(list.bounds); - let s = fit_scale(&b, &sheet, &o); let from = if b.is_empty() { Vec2::ZERO } else { b.center() }; // The world point at the image centre, with `from` on the printable area's centre. let center = from + (half - sheet.printable.center()) / s; @@ -120,11 +111,25 @@ fn png(d: &Drawing, space: &Space, opts: &Value, margins: Option<[f64; 4]>) -> R fn svg(d: &Drawing, space: &Space, opts: &Value) -> Result> { let o = PdfOptions::from_json(opts); - let window = match space { - Space::Paper(_) => Some(sheet(d, space, &o, None)?.0.bounds()), - Space::Model => o.window.filter(|w| !w.is_empty()), + let Space::Model = space else { + let window = sheet(d, space, &o)?.0.bounds(); + return Ok(crate::svg::export_window(d, space, Some(window)).into_bytes()); }; - Ok(crate::svg::export_window(d, space, window).into_bytes()) + // Model space as plotted: the plot style table, lineweights, and the table's ends, joins and + // fills at the scale that fits the window (or the extents) on the device's paper. + let (sheet, page) = sheet(d, space, &o)?; + let window = o.window.filter(|w| !w.is_empty()); + let table = cadcraft_doc::plot_style_table(d, &page.plot_style_table).map(Arc::new); + let lineweights = o.lineweights.unwrap_or(true); + let est = fit_scale(&window.unwrap_or_else(|| d.extents(space)), &sheet, &o); + // Chord tolerance: about 0.05 mm on paper. + let tolerance = (0.05 / (sheet.unit_mm * est)).max(1e-9); + let tolerance = if tolerance.is_finite() { tolerance } else { cadcraft_render::Options::default().tolerance }; + let ropts = cadcraft_render::Options { tolerance, lineweights, plot_style_table: table, ..cadcraft_render::Options::default() }; + let mut list = cadcraft_render::build_plot(d, space, &ropts); + let s = fit_scale(&window.unwrap_or(list.bounds), &sheet, &o); + cadcraft_render::pen::apply(&mut list, s * sheet.unit_mm); + Ok(crate::svg::export_list(&list, window).into_bytes()) } #[cfg(test)] diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index d3640cda..7c97c51e 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -16,6 +16,11 @@ pub fn export(d: &Drawing, space: &Space) -> String { /// or the extents with a 2% margin when `window` is `None`. pub fn export_window(d: &Drawing, space: &Space, window: Option) -> String { let list = cadcraft_render::build(d, space, &cadcraft_render::Options::default()); + export_list(&list, window) +} + +/// Write a display list (as [`export_window`] does for a space's own). +pub fn export_list(list: &cadcraft_render::DisplayList, window: Option) -> String { let window = window.filter(|w| !w.is_empty()); let b = window.unwrap_or(list.bounds); let (w, h) = if b.is_empty() { (1.0, 1.0) } else { (b.width().max(1e-9), b.height().max(1e-9)) }; diff --git a/crates/io/tests/pdf_followups.rs b/crates/io/tests/pdf_followups.rs new file mode 100644 index 00000000..198e6f05 --- /dev/null +++ b/crates/io/tests/pdf_followups.rs @@ -0,0 +1,119 @@ +//! Plot follow-ups (#626): model-space plots use the plot device's paper margins, PNG and SVG +//! plots draw plot style ends, joins and fills as PDF does, and hyperlinks a drawing file gives +//! its entities (`PE_URL` extended data) become PDF link annotations. + +use cadcraft_doc::{Common, Drawing, EndStyle, EntityKind, Line, PlotStyle, PlotStyleTable, Space}; +use cadcraft_geom::Vec3; +use cadcraft_io::{plotter, read_dxf, write_dxf}; +use serde_json::{Value, json}; + +fn line(d: &mut Drawing, a: (f64, f64), b: (f64, f64)) { + let k = EntityKind::Line(Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) }); + d.add(&Space::Model, Common::default(), k).unwrap(); +} + +fn pdf_text(d: &Drawing, opts: Value) -> String { + let mut o = json!({"compress": false}); + if let (Some(m), Some(e)) = (o.as_object_mut(), opts.as_object()) { + m.extend(e.clone()); + } + String::from_utf8_lossy(&plotter::plot(d, &Space::Model, &o).unwrap()).into_owned() +} + +/// The plot area's clip rectangle (`x y w h re W n`) of the first page. +fn clip(pdf: &str) -> Vec { + let line = pdf.lines().find(|l| l.ends_with(" re W n")).unwrap_or(""); + line.split_whitespace().take(4).filter_map(|t| t.parse().ok()).collect() +} + +#[test] +fn model_space_pdf_uses_the_device_margins() { + let mut d = Drawing::new_metric(); + line(&mut d, (0.0, 0.0), (100.0, 50.0)); + let mm = 72.0 / 25.4; + // No device: the page setup's printable area, as before. + let plain = clip(&pdf_text(&d, json!({}))); + assert!(plain.first().is_some_and(|x| (x - 20.0 * mm).abs() > 1.0), "{plain:?}"); + // A device paper with 20 mm margins: the printable area starts 20 mm in. + let r = clip(&pdf_text(&d, json!({"width": 210, "height": 297, "margins": [20, 20, 20, 20]}))); + assert_eq!(r.len(), 4, "{r:?}"); + assert!((r[0] - 20.0 * mm).abs() < 0.01 && (r[1] - 20.0 * mm).abs() < 0.01, "{r:?}"); + let (w, h) = (r[2].min(r[3]), r[2].max(r[3])); + assert!((w - 170.0 * mm).abs() < 0.01 && (h - 257.0 * mm).abs() < 0.01, "{r:?}"); + // Hostile margins are ignored. + for bad in [json!([1e9, 0, 0, 0]), json!([1, 2, 3]), json!("wide"), json!([f64::NAN, 1, 1, 1])] { + assert_eq!(clip(&pdf_text(&d, json!({"margins": bad}))), plain, "{bad}"); + } +} + +/// A drawing with two lines in a 2 mm wide style with round ends, and a plot style table holding it. +fn styled() -> Drawing { + let mut d = Drawing::new_metric(); + line(&mut d, (0.0, 0.0), (100.0, 0.0)); + line(&mut d, (0.0, 40.0), (100.0, 60.0)); + let round = PlotStyle { name: "Round".into(), lineweight: Some(200), end: EndStyle::Round, ..PlotStyle::default() }; + d.plot_style_tables.push(PlotStyleTable::named("round.stb", "", vec![round])); + let hs: Vec<_> = d.model.iter().map(|e| e.handle).collect(); + for h in hs { + d.modify_entity(h, |e| e.common.plot_style = "Round".into()).unwrap(); + } + d +} + +#[test] +fn png_and_svg_plots_draw_plot_style_ends() { + let d = styled(); + let png = |table: &str| plotter::plot(&d, &Space::Model, &json!({"output": "png", "resolution": 72, "plotStyleTable": table})).unwrap(); + assert_ne!(png("round.stb"), png("None"), "round ends change the image"); + + let svg = |table: &str| String::from_utf8(plotter::plot(&d, &Space::Model, &json!({"output": "svg", "plotStyleTable": table})).unwrap()).unwrap(); + let plain = svg("None"); + assert!(plain.contains(" String { + let text = write_dxf(d); + let lines: Vec<&str> = text.lines().map(str::trim).collect(); + let mut out: Vec = Vec::new(); + let mut urls = urls.iter(); + let mut in_line = false; + // Extended data goes last in an entity, before the next `0` group. + for pair in lines.chunks(2) { + let [code, value] = pair else { continue }; + if *code == "0" { + if in_line && let Some(url) = urls.next() { + for (c, v) in [("1001", "PE_URL"), ("1000", url), ("1002", "{"), ("1000", "A link"), ("1002", "}")] { + out.push(c.to_string()); + out.push(v.to_string()); + } + } + in_line = *value == "LINE"; + } + out.push(code.to_string()); + out.push(value.to_string()); + } + out.join("\n") + "\n" +} + +#[test] +fn pe_url_hyperlinks_become_link_annotations() { + let mut d = Drawing::new_metric(); + line(&mut d, (0.0, 0.0), (100.0, 0.0)); + line(&mut d, (0.0, 20.0), (100.0, 20.0)); + line(&mut d, (0.0, 40.0), (100.0, 40.0)); + let text = with_pe_urls(&d, &["https://example.com/plan", "javascript:alert(1)", "data:text/html,x"]); + assert_eq!(text.matches("PE_URL").count(), 3, "the test drawing carries the links"); + let read = read_dxf(text.as_bytes()).unwrap(); + let pdf = pdf_text(&read, json!({})); + assert_eq!(pdf.matches("/Subtype /Link").count(), 1, "only the web link: {pdf}"); + assert!(pdf.contains("https://example.com/plan")); + assert!(!pdf.contains("javascript") && !pdf.contains("data:text")); + // Turned off, and given explicitly for the same object (the explicit one wins). + assert_eq!(pdf_text(&read, json!({"hyperlinks": false})).matches("/Subtype /Link").count(), 0); + let first = read.model.iter().next().unwrap().handle.hex(); + let own = pdf_text(&read, json!({"links": [{"handle": first, "url": "https://example.org/own"}]})); + assert!(own.contains("https://example.org/own") && !own.contains("example.com/plan"), "{own}"); +} diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 1bb980b8..5a8ef8f5 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -6,7 +6,8 @@ //! Pan/Zoom, Quick Select, Find, Properties and Options. //! - **While a command runs** ([`command_entries`]): a quick right-click is Enter; holding the //! button for [`HOLD_SECS`] opens Enter, Cancel, Recent Input, the prompt's options, Snap -//! Overrides and Pan/Zoom instead (AutoCAD's time-sensitive right-click). +//! Overrides and Pan/Zoom instead (AutoCAD's time-sensitive right-click). With that turned off +//! in OPTIONS (`timeSensitiveRightClick`), every right-click opens the menu. //! - **Shift/Ctrl+right-click** ([`snap_entries`]): the object snap menu. Its items type the point //! modifiers and snap overrides (`_from`, `_endp`, `_non`…) at the current point prompt. //! @@ -302,7 +303,8 @@ struct Press(f64, egui::Pos2); /// The canvas's right button while a command runs (Shift/Ctrl+right-click is the snap menu's): a /// quick click is Enter — returns `true` when the caller should send it — and a click held for -/// [`HOLD_SECS`] opens the command menu ([`command_entries`]) at the pointer. Call it every frame +/// [`HOLD_SECS`] opens the command menu ([`command_entries`]) at the pointer (any click does with +/// the time-sensitive right-click turned off). Call it every frame /// with whether the pointer is on the canvas and whether a right-click ended there; it also shows /// the menu while it is open. pub fn in_command(app: &mut CadApp, ui: &egui::Ui, inside: bool, right_clicked: bool) -> bool { @@ -323,9 +325,10 @@ pub fn in_command(app: &mut CadApp, ui: &egui::Ui, inside: bool, right_clicked: if released && let Some(Press(t0, p0)) = ctx.data_mut(|d| d.remove_temp::(press_id)) { // A drag isn't a click; a click held long enough asks for the menu. let still = pos.is_some_and(|p| p.distance(p0) <= 6.0); - open = running && still && now - t0 >= HOLD_SECS; + let held = now - t0 >= HOLD_SECS || !app.options.time_sensitive_right_click; + open = running && still && held; } - let enter = running && inside && right_clicked && !open; + let enter = running && inside && right_clicked && !open && app.options.time_sensitive_right_click; if !running { return false; } diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index fe9114d0..2d6171a2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1202,3 +1202,10 @@ Multiline... Мультилінія... Multiline Style... Стиль мультилінії... Solid Editing Редагування тіл + Default save version Типова версія збереження + Automatic save every Автозбереження кожні + min хв + Automatic save file location Папка файлів автозбереження + Drawings with no file yet are saved in the default version. 0 minutes turns automatic save off. Креслення, що ще не мають файлу, зберігаються в типовій версії. 0 хвилин вимикає автозбереження. + Time-sensitive right-click: a quick click is Enter, holding opens the menu Клік правою кнопкою з урахуванням часу: швидкий клік — Enter, утримання відкриває меню + Turned off, a right-click while a command runs always opens the shortcut menu. Якщо вимкнено, клік правою кнопкою під час команди завжди відкриває контекстне меню. diff --git a/crates/ui-egui/src/options/dialog.rs b/crates/ui-egui/src/options/dialog.rs index 7f45a996..ead7a5a5 100644 --- a/crates/ui-egui/src/options/dialog.rs +++ b/crates/ui-egui/src/options/dialog.rs @@ -185,8 +185,47 @@ fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { } }); ui.end_row(); + + ui.label(crate::tl!("Default save version")); + let current = crate::SaveVersion::parse(&app.ui.save_version).unwrap_or_default(); + let mut version = current; + egui::ComboBox::from_id_salt("options_save_version").selected_text(version.year()).show_ui(ui, |ui| { + for v in crate::SaveVersion::ALL { + ui.selectable_value(&mut version, v, v.year()); + } + }); + if version != current { + changes.insert("saveVersion".into(), json!(version.year())); + } + ui.end_row(); + + ui.label(crate::tl!("Automatic save every")); + let mut minutes = cadcraft_engine::autosave::savetime(); + let max = cadcraft_engine::autosave::MAX_SAVETIME; + if ui.add(egui::DragValue::new(&mut minutes).range(0..=max).suffix(format!(" {}", crate::tl!("min")))).changed() { + changes.insert("SAVETIME".into(), json!(minutes)); + } + ui.end_row(); + + ui.label(crate::tl!("Automatic save file location")); + // Edited as text; the folder changes when the field loses focus. + let id = egui::Id::new("cc_options_savefilepath"); + let current = cadcraft_engine::autosave::savefilepath(); + let mut path = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| current.clone()); + let r = ui.add(egui::TextEdit::singleline(&mut path).desired_width(330.0)); + if r.changed() { + ui.data_mut(|d| d.insert_temp(id, path.clone())); + } + if r.lost_focus() { + ui.data_mut(|d| d.remove::(id)); + if path.trim() != current { + changes.insert("SAVEFILEPATH".into(), json!(path.trim())); + } + } + ui.end_row(); }); hint(ui, crate::tl!("A drawing opened from a file keeps that file's format.")); + hint(ui, crate::tl!("Drawings with no file yet are saved in the default version. 0 minutes turns automatic save off.")); } fn drafting(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map, start: &mut Option<&'static str>) { @@ -283,6 +322,12 @@ fn preferences(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map ui.end_row(); }); hint(ui, crate::tl!("An array that would create more objects than this is refused.")); + ui.add_space(6.0); + let mut timed = app.options.time_sensitive_right_click; + if ui.checkbox(&mut timed, crate::tl!("Time-sensitive right-click: a quick click is Enter, holding opens the menu")).changed() { + changes.insert("timeSensitiveRightClick".into(), json!(timed)); + } + hint(ui, crate::tl!("Turned off, a right-click while a command runs always opens the shortcut menu.")); } fn profiles(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { diff --git a/crates/ui-egui/src/options/mod.rs b/crates/ui-egui/src/options/mod.rs index d164344f..cb9a7b88 100644 --- a/crates/ui-egui/src/options/mod.rs +++ b/crates/ui-egui/src/options/mod.rs @@ -11,11 +11,18 @@ //! | `theme` | `"system"`, `"light"` or `"dark"` | //! | `interfaceLanguage` | `"auto"`, `"en"` or `"uk"` | //! | `saveFormat` | `"dxf"` or `"dwg"`: the format Save As suggests for new drawings | +//! | `saveVersion` | `"2000"` … `"2018"`: the version drawings with no file yet are saved in | +//! | `SAVETIME` | minutes between automatic saves, 0–600 (0: off) | +//! | `SAVEFILEPATH` | the folder automatic saves go to | +//! | `timeSensitiveRightClick` | while a command runs, a quick right-click is Enter and holding the button opens the shortcut menu (default); `false`: a right-click always opens the menu | //! | `systemCursor` | keep the system cursor visible over the drawing area | //! | `historyLines` | command-line history lines, 0–12 | //! | `modelBackground`, `paperBackground` | `"#rrggbb"`, or `null` for the default | //! | `snapMarkerSize` | object snap marker size in points, 5–31 | //! +//! SAVETIME and SAVEFILEPATH are the engine's autosave settings, kept with the autosave state +//! rather than in the profile. +//! //! The profile variables and the interface preferences survive restarts: the host saves //! [`CadApp::profile_json`] under [`PROFILE_KEY`] next to the interface prefs and restores it with //! [`CadApp::load_profile`]. @@ -36,6 +43,9 @@ pub const PROFILE_KEY: &str = "cadcraft.profile"; /// The largest saved profile [`CadApp::load_profile`] reads. const MAX_PROFILE_BYTES: usize = 64 * 1024; +/// The longest SAVEFILEPATH the JSON form accepts. +const MAX_PATH_CHARS: usize = 1024; + /// Object snap marker sizes the Options dialog offers, in points. pub const SNAP_MARKER_SIZES: std::ops::RangeInclusive = 5.0..=31.0; @@ -48,11 +58,14 @@ pub struct UiOptions { pub paper_background: Option, /// Object snap marker size in points. pub snap_marker_size: f32, + /// While a command runs: a quick right-click is Enter and a held one opens the shortcut menu + /// (AutoCAD's time-sensitive right-click). Off: a right-click always opens the menu. + pub time_sensitive_right_click: bool, } impl Default for UiOptions { fn default() -> Self { - UiOptions { model_background: None, paper_background: None, snap_marker_size: theme::SNAP_MARKER } + UiOptions { model_background: None, paper_background: None, snap_marker_size: theme::SNAP_MARKER, time_sensitive_right_click: true } } } @@ -106,6 +119,10 @@ struct UiChanges { model_background: Option>, paper_background: Option>, snap_marker_size: Option, + save_version: Option, + savetime: Option, + savefilepath: Option, + time_sensitive_right_click: Option, } fn color_param(key: &str, v: &Value) -> Result, String> { @@ -137,6 +154,23 @@ fn parse_ui(key: &str, v: &Value, c: &mut UiChanges) -> Result { let n = v.as_f64().filter(|n| SNAP_MARKER_SIZES.contains(&(*n as f32)) && n.is_finite()).ok_or("snapMarkerSize must be from 5 to 31")?; c.snap_marker_size = Some(n as f32); } + "saveVersion" => { + let name = v.as_str().map_or_else(|| v.to_string(), str::to_string); + c.save_version = Some(crate::SaveVersion::parse(name.trim()).ok_or("saveVersion must be 2000, 2004, 2007, 2010, 2013 or 2018")?); + } + "timeSensitiveRightClick" => c.time_sensitive_right_click = Some(v.as_bool().ok_or("timeSensitiveRightClick must be true or false")?), + k if k.eq_ignore_ascii_case("SAVETIME") => { + let max = cadcraft_engine::autosave::MAX_SAVETIME; + let n = v + .as_f64() + .filter(|n| n.is_finite() && (0.0..=f64::from(max)).contains(n)) + .ok_or_else(|| format!("SAVETIME must be from 0 to {max} minutes"))?; + c.savetime = Some(n.round() as u32); + } + k if k.eq_ignore_ascii_case("SAVEFILEPATH") => { + let p = v.as_str().map(str::trim).filter(|p| p.chars().count() <= MAX_PATH_CHARS && !p.chars().any(char::is_control)); + c.savefilepath = Some(p.ok_or("SAVEFILEPATH must be a folder path")?.to_string()); + } _ => return Ok(false), } Ok(true) @@ -154,6 +188,10 @@ fn ui_json(app: &CadApp) -> Map { m.insert("modelBackground".into(), color_json(o.model_background)); m.insert("paperBackground".into(), color_json(o.paper_background)); m.insert("snapMarkerSize".into(), json!(o.snap_marker_size)); + m.insert("saveVersion".into(), json!(app.ui.save_version)); + m.insert("SAVETIME".into(), json!(cadcraft_engine::autosave::savetime())); + m.insert("SAVEFILEPATH".into(), json!(cadcraft_engine::autosave::savefilepath())); + m.insert("timeSensitiveRightClick".into(), json!(o.time_sensitive_right_click)); m } @@ -206,6 +244,18 @@ fn run_json(app: &mut CadApp, params: &Value) -> Result { if let Some(n) = ui.snap_marker_size { app.options.snap_marker_size = n; } + if let Some(v) = ui.save_version { + app.ui.save_version = v.year().to_string(); + } + if let Some(m) = ui.savetime { + cadcraft_engine::autosave::set_savetime(m); + } + if let Some(p) = &ui.savefilepath { + cadcraft_engine::autosave::set_savefilepath(p); + } + if let Some(b) = ui.time_sensitive_right_click { + app.options.time_sensitive_right_click = b; + } Ok(report(app)) } @@ -214,6 +264,7 @@ fn reset_ui(app: &mut CadApp) { let d = crate::UiState::default(); app.ui.theme = d.theme; app.ui.save_format = d.save_format; + app.ui.save_version = d.save_version; app.ui.system_cursor = d.system_cursor; app.ui.history_lines = d.history_lines; app.options = UiOptions::default(); @@ -232,6 +283,7 @@ impl CadApp { "modelBackground": color_json(self.options.model_background), "paperBackground": color_json(self.options.paper_background), "snapMarkerSize": self.options.snap_marker_size, + "timeSensitiveRightClick": self.options.time_sensitive_right_click, }) .to_string() } @@ -247,7 +299,7 @@ impl CadApp { cadcraft_engine::cmd::options::restore(&mut self.session, vars); } let mut c = UiChanges::default(); - for key in ["systemCursor", "modelBackground", "paperBackground", "snapMarkerSize"] { + for key in ["systemCursor", "modelBackground", "paperBackground", "snapMarkerSize", "timeSensitiveRightClick"] { if let Some(value) = v.get(key) { let _ = parse_ui(key, value, &mut c); } @@ -265,6 +317,9 @@ impl CadApp { if let Some(n) = c.snap_marker_size { self.options.snap_marker_size = n; } + if let Some(b) = c.time_sensitive_right_click { + self.options.time_sensitive_right_click = b; + } } } diff --git a/crates/ui-egui/tests/options_followups.rs b/crates/ui-egui/tests/options_followups.rs new file mode 100644 index 00000000..d8b8c212 --- /dev/null +++ b/crates/ui-egui/tests/options_followups.rs @@ -0,0 +1,109 @@ +//! OPTIONS follow-ups (#626): Open and Save shows automatic save (SAVETIME, SAVEFILEPATH) and the +//! default save version, User Preferences the time-sensitive right-click, all in the JSON form. +//! (Its own test binary: SAVETIME and SAVEFILEPATH are process-wide.) + +use cadcraft_engine::Session; +use cadcraft_engine::autosave; +use cadcraft_ui_egui::{CadApp, Services}; +use serde_json::json; + +fn app() -> CadApp { + CadApp::new(Session::new(), Services::default()) +} + +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) => shapes.iter().for_each(|s| collect(s, text)), + _ => {} + } + } + let mut text = String::new(); + shapes.iter().for_each(|s| collect(&s.shape, &mut text)); + text +} + +/// One frame at `time` with `events`, returning the painted text. +fn frame(app: &mut CadApp, ctx: &egui::Context, events: Vec, time: f64) -> String { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), + time: Some(time), + events, + ..Default::default() + }; + app.logic(ctx); + let mut output = ctx.run_ui(input, |ui| app.ui(ui)); + output.textures_delta.clear(); + painted_text(&output.shapes) +} + +#[test] +fn autosave_save_version_and_right_click_in_options() { + let mut a = app(); + let all = a.run("options", json!({})).unwrap(); + for key in ["SAVETIME", "SAVEFILEPATH", "saveVersion", "timeSensitiveRightClick"] { + assert!(all.get(key).is_some(), "{key} is reported"); + } + assert_eq!(all["timeSensitiveRightClick"], json!(true)); + + let r = a + .run("options", json!({"SAVETIME": 25, "savefilepath": " /tmp/cc-autosave ", "saveVersion": 2010, "timeSensitiveRightClick": false})) + .unwrap(); + assert_eq!((r["SAVETIME"].clone(), r["SAVEFILEPATH"].clone()), (json!(25), json!("/tmp/cc-autosave"))); + assert_eq!((r["saveVersion"].clone(), r["timeSensitiveRightClick"].clone()), (json!("2010"), json!(false))); + assert_eq!((autosave::savetime(), autosave::savefilepath()), (25, "/tmp/cc-autosave".to_string())); + assert_eq!(a.run("ui.saveversion", json!({})).unwrap()["saveVersion"], "2010", "the same setting as File ▸ Default Save Format"); + + // All or nothing. + let before = a.run("options", json!({})).unwrap(); + for bad in [ + json!({"SAVETIME": 601, "PICKBOX": 9}), + json!({"SAVETIME": -1}), + json!({"SAVETIME": "often"}), + json!({"SAVEFILEPATH": "a\nb", "SAVETIME": 5}), + json!({"SAVEFILEPATH": "x".repeat(2000)}), + json!({"saveVersion": "2005", "SAVETIME": 5}), + json!({"timeSensitiveRightClick": "yes", "SAVETIME": 5}), + json!({"SAVETIME": 5, "LUNITS": 4}), + ] { + assert!(a.run("options", bad.clone()).is_err(), "{bad}"); + assert_eq!(a.run("options", json!({})).unwrap(), before, "{bad} changed nothing"); + } + + // The right-click setting survives a restart; reset puts it and the save version back. + let mut b = app(); + b.load_profile(&a.profile_json()); + assert!(!b.options.time_sensitive_right_click); + let r = b.run("options", json!({"reset": true})).unwrap(); + assert_eq!((r["timeSensitiveRightClick"].clone(), r["saveVersion"].clone()), (json!(true), json!("2018"))); + + // The tabs show the settings. + let ctx = egui::Context::default(); + a.ui.dialog = Some("options:files".into()); + frame(&mut a, &ctx, Vec::new(), 0.0); + let text = frame(&mut a, &ctx, Vec::new(), 0.1); + for l in ["Default save version", "2010", "Automatic save every", "\n25\n", "Automatic save file location"] { + assert!(text.contains(l), "{l} missing:\n{text}"); + } + a.ui.dialog = Some("options:preferences".into()); + frame(&mut a, &ctx, Vec::new(), 0.2); + assert!(frame(&mut a, &ctx, Vec::new(), 0.3).contains("Time-sensitive right-click")); + + // Turned off, a quick right-click during a command opens the menu instead of acting as Enter. + a.ui.dialog = None; + let ctx = egui::Context::default(); + frame(&mut a, &ctx, Vec::new(), 0.0); + let at = egui::pos2(800.0, 500.0); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }; + frame(&mut a, &ctx, vec![egui::Event::PointerMoved(at)], 0.1); + a.cmdline("LINE 0,0 10,0"); + frame(&mut a, &ctx, vec![button(true)], 1.0); + frame(&mut a, &ctx, vec![button(false)], 1.05); + let text = frame(&mut a, &ctx, Vec::new(), 1.1); + assert!(text.contains("Cancel") && text.contains("Snap Overrides"), "the menu opens:\n{text}"); + assert!(a.session.prompt_text().contains("next point"), "no Enter: {}", a.session.prompt_text()); +}