mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
Drafting/plot/UI follow-ups: PolarSnap over isometric snap, MVIEW Restore, plot margins, PNG/SVG pens, PE_URL links, OPTIONS settings (part of #626) (#633)
Squash of PR #633 by @vangel76. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
1e1f68bf40
commit
89064fd82a
16 files changed
+759
-51
No files matched your search
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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"))))
|
||||
|
||||
@@ -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<Bounds2> {
|
||||
@@ -210,6 +213,66 @@ fn mview_lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "viewports": hs.iter().map(|h| h.hex()).collect::<Vec<_>>(), "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<Bounds2> {
|
||||
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<Value> {
|
||||
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::<String>())))?;
|
||||
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<Result<Value>> {
|
||||
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<Result<Value>>
|
||||
"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<bool>,
|
||||
@@ -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<Bounds2>) -> Result<Step> {
|
||||
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<Step> {
|
||||
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::<String>()
|
||||
)));
|
||||
}
|
||||
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<EntityKind> {
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
let mut v = vec![
|
||||
|
||||
@@ -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<Vec<VportTile>> {
|
||||
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<String> {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>) -> 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<Viewport> {
|
||||
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<VportTile>) {
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+58
-4
@@ -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<f64> = 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<Bounds2> {
|
||||
let corner = |i: usize| {
|
||||
@@ -214,6 +233,10 @@ pub fn page_for(d: &Drawing, space: &Space, o: &PdfOptions) -> Result<PageSetup>
|
||||
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<Vec<u
|
||||
} else {
|
||||
d
|
||||
};
|
||||
let pages = spaces.iter().map(|s| sheet(drawn, s, o)).collect::<Result<Vec<_>>>()?;
|
||||
let links = all_links(d, o);
|
||||
let pages = spaces.iter().map(|s| sheet(drawn, s, o, &links)).collect::<Result<Vec<_>>>()?;
|
||||
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<SheetPage> {
|
||||
fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String)]) -> Result<SheetPage> {
|
||||
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<SheetPage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
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<Handle> = 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<String> {
|
||||
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)> {
|
||||
|
||||
+33
-28
@@ -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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
};
|
||||
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<f64> = 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<Vec<u8>> {
|
||||
fn png(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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)]
|
||||
|
||||
@@ -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<Bounds2>) -> 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<Bounds2>) -> 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)) };
|
||||
|
||||
@@ -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<f64> {
|
||||
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("<polyline") && !plain.contains("<path"), "{plain}");
|
||||
let round = svg("round.stb");
|
||||
assert!(round.contains("<path") && !round.contains("<polyline"), "wide lines with round ends are outlines: {round}");
|
||||
}
|
||||
|
||||
/// DXF text of `d` with `PE_URL` extended data on each LINE, one URL per line in order.
|
||||
fn with_pe_urls(d: &Drawing, urls: &[&str]) -> String {
|
||||
let text = write_dxf(d);
|
||||
let lines: Vec<&str> = text.lines().map(str::trim).collect();
|
||||
let mut out: Vec<String> = 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}");
|
||||
}
|
||||
@@ -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>(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;
|
||||
}
|
||||
|
||||
@@ -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. Якщо вимкнено, клік правою кнопкою під час команди завжди відкриває контекстне меню.
|
||||
|
@@ -185,8 +185,47 @@ fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
|
||||
}
|
||||
});
|
||||
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::<String>(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::<String>(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<String, Value>, start: &mut Option<&'static str>) {
|
||||
@@ -283,6 +322,12 @@ fn preferences(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>
|
||||
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<String, Value>) {
|
||||
|
||||
@@ -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<f32> = 5.0..=31.0;
|
||||
|
||||
@@ -48,11 +58,14 @@ pub struct UiOptions {
|
||||
pub paper_background: Option<Color32>,
|
||||
/// 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<Option<Color32>>,
|
||||
paper_background: Option<Option<Color32>>,
|
||||
snap_marker_size: Option<f32>,
|
||||
save_version: Option<crate::SaveVersion>,
|
||||
savetime: Option<u32>,
|
||||
savefilepath: Option<String>,
|
||||
time_sensitive_right_click: Option<bool>,
|
||||
}
|
||||
|
||||
fn color_param(key: &str, v: &Value) -> Result<Option<Color32>, String> {
|
||||
@@ -137,6 +154,23 @@ fn parse_ui(key: &str, v: &Value, c: &mut UiChanges) -> Result<bool, String> {
|
||||
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<String, Value> {
|
||||
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<Value, String> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<egui::Event>, 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());
|
||||
}
|
||||
Reference in new issue
Block a user