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:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 20:11:06 -04:00
1 parent 1e1f68bf40
commit 89064fd82a
16 files changed
+759 -51

No files matched your search

+1 -1
View File
@@ -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"))))
+146 -7
View File
@@ -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(),
}
}
+1 -1
View File
@@ -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![
+16
View File
@@ -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);
+3 -2
View File
@@ -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);
+150
View File
@@ -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);
}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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)]
+5
View File
@@ -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)) };
+119
View File
@@ -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}");
}
+7 -4
View File
@@ -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;
}
+7
View File
@@ -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. Якщо вимкнено, клік правою кнопкою під час команди завжди відкриває контекстне меню.
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
+45
View File
@@ -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>) {
+57 -2
View File
@@ -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;
}
}
}
+109
View File
@@ -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());
}