mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
Squash of PR #503 by @vangel76. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b4b62e4cf0
commit
c9c3031de9
13 files changed
+890
-32
No files matched your search
@@ -600,15 +600,24 @@ impl Layout {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
/// A named view (VIEW table): the centre and size of the area shown, in model space or a layout.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NamedView {
|
||||
pub name: String,
|
||||
pub center: Vec2,
|
||||
pub height: f64,
|
||||
pub width: f64,
|
||||
/// The layer state restored with the view (its layer snapshot).
|
||||
#[serde(default)]
|
||||
pub layer_state: Option<String>,
|
||||
/// View twist (radians, counterclockwise); kept for files, not applied to the 2D display.
|
||||
#[serde(default)]
|
||||
pub twist: f64,
|
||||
/// The layout a paper space view belongs to (`None`: a model space view; an empty name: a
|
||||
/// paper space view whose layout is not known).
|
||||
#[serde(default)]
|
||||
pub layout: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
+194
-14
@@ -1,4 +1,5 @@
|
||||
//! View menu: ZOOM, PAN, REGEN, REDRAW, layout switching.
|
||||
//! View menu: ZOOM (with Realtime and Dynamic), PAN, REGEN, REDRAW, named views (VIEW), layout
|
||||
//! switching.
|
||||
|
||||
use cadcraft_doc::{Space, entity_bounds};
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
@@ -8,15 +9,28 @@ use super::machines::{SelectRun, number};
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, View};
|
||||
|
||||
mod named_views;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
let mut v = vec![
|
||||
CommandSpec::new("zoom", "Zoom", run_zoom)
|
||||
.menu(&["View", "Zoom", "Realtime"])
|
||||
.alias(&["z"])
|
||||
.params("{mode: extents|all|window|previous|in|out|center|scale|object, p1?, p2?, center?, height?, factor?, xp?: bool (scale relative to paper space: in a viewport, `factor` paper units per model unit), limits?: bool (scale relative to the limits)} (center: `height`, or `factor` with `xp`/`limits`)")
|
||||
.params("{mode: extents|all|window|previous|in|out|center|dynamic|scale|realtime|object, p1?, p2?, center?, height?, factor?, xp?: bool (scale relative to paper space: in a viewport, `factor` paper units per model unit), limits?: bool (scale relative to the limits)} (center/dynamic: `center` + `height`, or `factor` with `xp`/`limits`; realtime: `factor` about the view centre; typed: Enter at the first prompt starts Realtime)")
|
||||
.noundo()
|
||||
.transparent()
|
||||
.interactive(|_| Ok(Box::new(ZoomM::default()))),
|
||||
CommandSpec::new("zoom.realtime", "Zoom Realtime", |s, p| zoom(s, &with_mode(p.clone(), "realtime", None)))
|
||||
.menu(&["View", "Zoom", "Realtime"])
|
||||
.params("{factor} (> 1 zooms in, about the view centre); interactive: drag up to zoom in, down to zoom out, Esc or Enter ends")
|
||||
.noundo()
|
||||
.transparent()
|
||||
.interactive(|_| Ok(Box::new(ZoomM { start: ZoomStart::Realtime, ..Default::default() }))),
|
||||
CommandSpec::new("zoom.dynamic", "Zoom Dynamic", |s, p| zoom(s, &with_mode(p.clone(), "dynamic", None)))
|
||||
.menu(&["View", "Zoom", "Dynamic"])
|
||||
.params("{center, height}; interactive: a view box over the drawing extents, a point places it, the next sizes it, Enter accepts")
|
||||
.noundo()
|
||||
.transparent()
|
||||
.interactive(|_| Ok(Box::new(ZoomM { start: ZoomStart::Dynamic, ..Default::default() }))),
|
||||
CommandSpec::new("zoom.previous", "Zoom Previous", |s, _| zoom(s, &json!({"mode":"previous"})))
|
||||
.menu(&["View", "Zoom", "Previous"])
|
||||
.noundo()
|
||||
@@ -82,7 +96,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.noundo(),
|
||||
CommandSpec::new("layout.set", "Switch Layout", run_layout_set).params("{name: \"Model\" | layout name}").noundo(),
|
||||
]
|
||||
];
|
||||
v.extend(named_views::specs());
|
||||
v
|
||||
}
|
||||
|
||||
fn ext_bounds(s: &Session) -> Result<Bounds2> {
|
||||
@@ -129,7 +145,7 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = st.zoom_frame();
|
||||
st.set_zoom_frame(View { center: v.center, height: v.height / f });
|
||||
}
|
||||
"center" | "c" => {
|
||||
"center" | "c" | "dynamic" | "d" => {
|
||||
let c = point_req("zoom", p, "center")?;
|
||||
let cur = s.state()?.zoom_frame().height;
|
||||
let h = if p.get("factor").is_some() { factor_height(s, p, cur)? } else { f64_or(p, "height", cur) };
|
||||
@@ -140,6 +156,14 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let height = factor_height(s, p, v.height)?;
|
||||
s.state_mut()?.set_zoom_frame(View { center: v.center, height });
|
||||
}
|
||||
"realtime" => {
|
||||
let v = s.state()?.zoom_frame();
|
||||
let f = f64_req("zoom", p, "factor")?;
|
||||
if f <= 0.0 {
|
||||
return Err(bad("zoom", "factor must be positive"));
|
||||
}
|
||||
s.state_mut()?.set_zoom_frame(View { center: v.center, height: (v.height / f).clamp(1e-9, 1e12) });
|
||||
}
|
||||
"object" | "o" => {
|
||||
let hs = targets(s, p)?;
|
||||
let d = s.doc()?;
|
||||
@@ -269,9 +293,19 @@ struct ZoomM {
|
||||
window: bool,
|
||||
first: Option<Vec2>,
|
||||
stage: ZoomStage,
|
||||
/// Where the command starts (View > Zoom > Realtime / Dynamic skip the first prompt).
|
||||
start: ZoomStart,
|
||||
}
|
||||
|
||||
/// The Center and Scale options' own prompts.
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq)]
|
||||
enum ZoomStart {
|
||||
#[default]
|
||||
Prompt,
|
||||
Realtime,
|
||||
Dynamic,
|
||||
}
|
||||
|
||||
/// The Center, Scale, Realtime and Dynamic options' own prompts.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
enum ZoomStage {
|
||||
#[default]
|
||||
@@ -279,15 +313,95 @@ enum ZoomStage {
|
||||
Center,
|
||||
CenterHeight(Vec2),
|
||||
Scale,
|
||||
Realtime,
|
||||
Dynamic(DynBox),
|
||||
}
|
||||
|
||||
/// The prompt of Zoom Realtime; [`Session::realtime_zoom_active`] recognises the mode by it.
|
||||
const REALTIME_PROMPT: &str = "Drag up to zoom in or down to zoom out; press Esc or Enter to exit";
|
||||
|
||||
/// Zoom Dynamic: the view box shown over the drawing extents. A point places the box, the next
|
||||
/// one sizes it (its left edge stays), and so on; Enter shows what the box holds.
|
||||
#[derive(Clone, Copy)]
|
||||
struct DynBox {
|
||||
/// The screen view and the zoom frame before the overview.
|
||||
screen: View,
|
||||
frame: View,
|
||||
/// The drawing extents (the limits when empty).
|
||||
extents: Bounds2,
|
||||
center: Vec2,
|
||||
height: f64,
|
||||
/// Width / height of the area being zoomed.
|
||||
aspect: f64,
|
||||
/// The next point sizes the box (else it places it).
|
||||
sizing: bool,
|
||||
}
|
||||
|
||||
impl DynBox {
|
||||
fn rect(c: Vec2, h: f64, aspect: f64) -> Bounds2 {
|
||||
let half = Vec2::new(h * aspect / 2.0, h / 2.0);
|
||||
Bounds2::new(c - half, c + half)
|
||||
}
|
||||
/// The box a point `p` makes: centred on it, or (sizing) reaching it from the left edge.
|
||||
fn moved(&self, p: Vec2) -> (Vec2, f64) {
|
||||
if !self.sizing {
|
||||
return (p, self.height);
|
||||
}
|
||||
let left = self.center.x - self.height * self.aspect / 2.0;
|
||||
let w = (p.x - left).max(self.height * self.aspect * 1e-3).max(1e-9);
|
||||
(Vec2::new(left + w / 2.0, self.center.y), w / self.aspect)
|
||||
}
|
||||
}
|
||||
|
||||
impl ZoomM {
|
||||
/// Start Realtime: one view history entry for the whole drag session (Zoom Previous).
|
||||
fn realtime(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let st = s.state_mut()?;
|
||||
let v = st.view();
|
||||
st.view_history.push(v);
|
||||
if st.view_history.len() > 50 {
|
||||
st.view_history.remove(0);
|
||||
}
|
||||
self.stage = ZoomStage::Realtime;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
/// Start Dynamic: zoom out to show the extents and the current view, and put the view box
|
||||
/// where the view was. (Esc leaves the overview; Zoom Previous returns.)
|
||||
fn dynamic(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let (w, h) = s.viewport_px;
|
||||
let st = s.state()?;
|
||||
let (screen, frame) = (st.view(), st.zoom_frame());
|
||||
let aspect = st.zoom_aspect(w / h.max(1.0)).max(1e-6);
|
||||
let ext = ext_bounds(s)?;
|
||||
let extents = if ext.is_empty() { s.zoom_limits()? } else { ext };
|
||||
let all = extents.union(&DynBox::rect(frame.center, frame.height, aspect));
|
||||
fit(s, all.expand(all.height().max(all.width()) * 0.05))?;
|
||||
s.touch();
|
||||
self.stage = ZoomStage::Dynamic(DynBox { screen, frame, extents, center: frame.center, height: frame.height, aspect, sizing: false });
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for ZoomM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ZOOM"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
match self.start {
|
||||
ZoomStart::Prompt => Ok(Step::Continue),
|
||||
ZoomStart::Realtime => self.realtime(s),
|
||||
ZoomStart::Dynamic => self.dynamic(s),
|
||||
}
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
ZoomStage::Main => {}
|
||||
ZoomStage::Realtime => return Prompt::new(REALTIME_PROMPT, Accept { enter: true, ..Accept::default() }),
|
||||
ZoomStage::Dynamic(b) if b.sizing => {
|
||||
let left = Vec2::new(b.center.x - b.height * b.aspect / 2.0, b.center.y);
|
||||
return Prompt::new("Specify size of view box", Accept::POINT).default("accept").base(left);
|
||||
}
|
||||
ZoomStage::Dynamic(_) => return Prompt::new("Specify center of view box", Accept::POINT).default("accept"),
|
||||
ZoomStage::Center => return Prompt::new("Specify center point", Accept::POINT),
|
||||
ZoomStage::CenterHeight(_) => {
|
||||
let h = s.state().map(|st| st.zoom_frame().height).unwrap_or(1.0);
|
||||
@@ -307,6 +421,26 @@ impl Interactive for ZoomM {
|
||||
let bad_factor = || crate::EngineError::Other("Requires a scale factor (n, nX or nXP).".into());
|
||||
match (self.stage, &i) {
|
||||
(_, Input::Cancel) | (ZoomStage::Main, _) => {}
|
||||
(ZoomStage::Realtime, Input::Enter) => return Ok(Step::Done),
|
||||
(ZoomStage::Realtime, _) => return Ok(Step::Continue),
|
||||
(ZoomStage::Dynamic(b), Input::Enter) => {
|
||||
// Back to the view before the overview without a history entry, then zoom from it.
|
||||
let st = s.state_mut()?;
|
||||
st.set_view_quiet(b.screen);
|
||||
if st.view_history.last() == Some(&b.screen) {
|
||||
st.view_history.pop();
|
||||
}
|
||||
st.set_zoom_frame(View { center: b.center, height: b.height });
|
||||
s.touch();
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
(ZoomStage::Dynamic(mut b), Input::Point(p)) => {
|
||||
(b.center, b.height) = b.moved(*p);
|
||||
b.sizing = !b.sizing;
|
||||
self.stage = ZoomStage::Dynamic(b);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
(ZoomStage::Dynamic(_), _) => return Err(crate::EngineError::Other("Requires a point, or Enter to accept the view.".into())),
|
||||
(ZoomStage::Center, Input::Point(c)) => {
|
||||
self.stage = ZoomStage::CenterHeight(*c);
|
||||
return Ok(Step::Continue);
|
||||
@@ -341,12 +475,7 @@ impl Interactive for ZoomM {
|
||||
"All" => "all",
|
||||
"Extents" => "extents",
|
||||
"Previous" => "previous",
|
||||
"Dynamic" => {
|
||||
// Dynamic zoom drags a view box; without one it asks for a window.
|
||||
s.echo("Dynamic zoom: specify the new view as a window.");
|
||||
self.window = true;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
"Dynamic" => return self.dynamic(s),
|
||||
"Window" => {
|
||||
self.window = true;
|
||||
return Ok(Step::Continue);
|
||||
@@ -381,12 +510,63 @@ impl Interactive for ZoomM {
|
||||
zoom(s, &with_mode(p, "scale", None))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
// Enter at the first prompt: Realtime (the default), as in AutoCAD.
|
||||
Input::Enter if !self.window && self.first.is_none() => self.realtime(s),
|
||||
Input::Enter => Ok(Step::Done),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<cadcraft_doc::EntityKind> {
|
||||
self.first.map(|a| vec![super::helpers::lwpoly(super::helpers::rect_vertices(a, c), true)]).unwrap_or_default()
|
||||
use super::helpers::{line, lwpoly, rect_vertices};
|
||||
let rect = |r: Bounds2| lwpoly(rect_vertices(r.min, r.max), true);
|
||||
if let ZoomStage::Dynamic(b) = self.stage {
|
||||
// The extents, the view before, and the box: where it is and where the cursor puts it.
|
||||
let (center, height) = b.moved(c);
|
||||
let next = DynBox::rect(center, height, b.aspect);
|
||||
let mut out = vec![
|
||||
rect(b.extents),
|
||||
rect(DynBox::rect(b.frame.center, b.frame.height, b.aspect)),
|
||||
rect(DynBox::rect(b.center, b.height, b.aspect)),
|
||||
rect(next),
|
||||
];
|
||||
if !b.sizing {
|
||||
out.push(line(next.min, next.max));
|
||||
out.push(line(Vec2::new(next.min.x, next.max.y), Vec2::new(next.max.x, next.min.y)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
self.first.map(|a| vec![rect(Bounds2::new(a, c))]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// ZOOM is in its Realtime mode: the UI turns vertical drags into [`Session::realtime_zoom`].
|
||||
pub fn realtime_zoom_active(&self) -> bool {
|
||||
self.running.as_ref().is_some_and(|r| r.id.starts_with("zoom")) && self.current_prompt().is_some_and(|p| p.message == REALTIME_PROMPT)
|
||||
}
|
||||
/// One step of a Realtime zoom drag of `dy` pixels (down is positive): dragging up half the
|
||||
/// drawing area's height zooms in 2x, down zooms out, about the centre of the view. Does
|
||||
/// nothing outside Realtime.
|
||||
pub fn realtime_zoom(&mut self, dy: f64) -> Result<()> {
|
||||
if !self.realtime_zoom_active() || !dy.is_finite() {
|
||||
return Ok(());
|
||||
}
|
||||
let half = (self.viewport_px.1 / 2.0).max(1.0);
|
||||
let f = 2f64.powf((-dy / half).clamp(-16.0, 16.0));
|
||||
let st = self.state_mut()?;
|
||||
let v = st.view();
|
||||
st.set_view_quiet(View { center: v.center, height: (v.height / f).clamp(1e-9, 1e12) });
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
/// The zoom of a mouse wheel scroll by `delta` points (up zooms in): ZOOMFACTOR scales it
|
||||
/// (60, the default, zooms about 18% per wheel step).
|
||||
pub fn wheel_zoom_factor(&self, delta: f64) -> f64 {
|
||||
if !delta.is_finite() {
|
||||
return 1.0;
|
||||
}
|
||||
let k = f64::from(self.settings.zoomfactor.clamp(3, 100)) / 60.0;
|
||||
(delta / 300.0 * k).clamp(-40.0, 40.0).exp()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
//! Named views: VIEW / -VIEW (Save, Window, Restore, Delete, ? list, sEttings > Layer snapshot).
|
||||
//!
|
||||
//! A named view keeps the centre and size of what was shown, in model space or a layout, and
|
||||
//! optionally a layer snapshot (a layer state restored with it). Views are stored in the drawing
|
||||
//! and round-trip through the DXF VIEW table. Restoring one is a view change like ZOOM: it goes
|
||||
//! into the view history, not the undo stack (a layer snapshot it restores is an undo step).
|
||||
|
||||
use cadcraft_doc::{LayerState, NamedView, Space};
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::qselect::wildcard;
|
||||
use super::super::*;
|
||||
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step, View};
|
||||
|
||||
/// Most names one Delete or list takes.
|
||||
const MAX_NAMES: usize = 1000;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("view", "Named Views...", run_view)
|
||||
.menu(&["View", "Named Views..."])
|
||||
.alias(&["v"])
|
||||
.params(
|
||||
"{option: save|window|restore|delete|list|settings, name? (save/window/restore), names?: \"a,b*\" | [names] (delete/list; wildcards), p1?, p2? (window), layerSnapshot?: bool (save: default the Layer snapshot setting; settings: change it)} (typed VIEW / -VIEW: option prompts)",
|
||||
)
|
||||
.interactive(|_| Ok(Box::new(ViewM::default()))),
|
||||
]
|
||||
}
|
||||
|
||||
/// A valid view name (the characters symbol table names can't hold are refused).
|
||||
fn valid_name(n: &str) -> bool {
|
||||
!n.is_empty() && n.chars().count() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c))
|
||||
}
|
||||
|
||||
/// The layer state holding view `name`'s layer snapshot.
|
||||
fn snapshot_name(name: &str) -> String {
|
||||
format!("*VIEW {name}")
|
||||
}
|
||||
|
||||
fn find<'a>(views: &'a [NamedView], name: &str) -> Option<&'a NamedView> {
|
||||
views.iter().find(|v| v.name.eq_ignore_ascii_case(name.trim()))
|
||||
}
|
||||
|
||||
/// Names from `names` (a comma-separated string or an array; `*` when absent), as patterns.
|
||||
fn name_patterns(p: &Value) -> Vec<String> {
|
||||
let raw: Vec<String> = match p.get("names").or_else(|| p.get("name")) {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
|
||||
Some(Value::String(t)) => t.split(',').map(str::to_string).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let pats: Vec<String> = raw.into_iter().map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).take(MAX_NAMES).collect();
|
||||
if pats.is_empty() { vec!["*".into()] } else { pats }
|
||||
}
|
||||
|
||||
fn space_label(v: &NamedView) -> String {
|
||||
match &v.layout {
|
||||
None => "Model".into(),
|
||||
Some(l) if l.is_empty() => "Paper space".into(),
|
||||
Some(l) => l.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let option = str_param(p, "option").unwrap_or("list").trim().to_ascii_lowercase();
|
||||
match option.as_str() {
|
||||
"save" | "s" | "window" | "w" => {
|
||||
let name = str_param(p, "name").map(str::trim).ok_or_else(|| bad("view", "`name` is required"))?;
|
||||
let window = match (point_param(p, "p1"), point_param(p, "p2")) {
|
||||
(Some(a), Some(b)) => Some(Bounds2::new(a, b)),
|
||||
_ if option.starts_with('w') => return Err(bad("view", "`p1` and `p2` (the window) are required")),
|
||||
_ => None,
|
||||
};
|
||||
let snapshot = p.get("layerSnapshot").and_then(Value::as_bool).unwrap_or(s.settings.view_layer_snapshot);
|
||||
save(s, name, window, snapshot)
|
||||
}
|
||||
"restore" | "r" => {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("view", "`name` is required"))?;
|
||||
restore(s, name)
|
||||
}
|
||||
"delete" | "d" => {
|
||||
if p.get("names").or_else(|| p.get("name")).is_none() {
|
||||
return Err(bad("view", "`names` is required"));
|
||||
}
|
||||
delete(s, &name_patterns(p))
|
||||
}
|
||||
"list" | "?" => list(s, &name_patterns(p)),
|
||||
"settings" | "e" => {
|
||||
if let Some(b) = p.get("layerSnapshot").and_then(Value::as_bool) {
|
||||
s.settings.view_layer_snapshot = b;
|
||||
}
|
||||
Ok(json!({ "layerSnapshot": s.settings.view_layer_snapshot }))
|
||||
}
|
||||
"orthographic" | "o" => Err(EngineError::Other("Orthographic views are not available yet: CADCraft views are 2D.".into())),
|
||||
_ => Err(bad("view", "option must be save, window, restore, delete, list or settings")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the current view (or `window`) as `name`, replacing a view of that name.
|
||||
fn save(s: &mut Session, name: &str, window: Option<Bounds2>, snapshot: bool) -> Result<Value> {
|
||||
if !valid_name(name) {
|
||||
return Err(EngineError::Other(format!("\"{name}\" is not a valid view name.")));
|
||||
}
|
||||
let (w, h) = s.viewport_px;
|
||||
let st = s.state()?;
|
||||
// Inside an MSPACE viewport the view is a model space view of that viewport.
|
||||
let in_viewport = st.active_viewport().is_some();
|
||||
let layout = match (&st.space, in_viewport) {
|
||||
(Space::Paper(l), false) => Some(l.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let (center, height, width) = match window {
|
||||
Some(b) => {
|
||||
if !(b.width() > 1e-12 && b.height() > 1e-12 && b.min.is_finite() && b.max.is_finite()) {
|
||||
return Err(EngineError::Other("The window has no area.".into()));
|
||||
}
|
||||
(b.center(), b.height(), b.width())
|
||||
}
|
||||
None => {
|
||||
let f = if in_viewport { st.zoom_frame() } else { st.view() };
|
||||
let aspect = if in_viewport { st.zoom_aspect(w / h.max(1.0)) } else { w / h.max(1.0) };
|
||||
(f.center, f.height, f.height * aspect)
|
||||
}
|
||||
};
|
||||
let replaced = find(&st.doc.views, name).map(|v| v.name.clone());
|
||||
let d = s.doc_mut()?;
|
||||
let layer_state = snapshot.then(|| {
|
||||
let state = snapshot_name(name);
|
||||
d.layer_states.retain(|l| !l.name.eq_ignore_ascii_case(&state));
|
||||
d.layer_states.push(LayerState { name: state.clone(), layers: d.layers.clone() });
|
||||
state
|
||||
});
|
||||
if let Some(old) = &replaced {
|
||||
// A replaced view's own snapshot goes with it unless the new one took its place.
|
||||
let old_state = snapshot_name(old);
|
||||
if layer_state.as_ref().is_none_or(|n| !n.eq_ignore_ascii_case(&old_state)) {
|
||||
d.layer_states.retain(|l| !l.name.eq_ignore_ascii_case(&old_state));
|
||||
}
|
||||
d.views.retain(|v| !v.name.eq_ignore_ascii_case(old));
|
||||
}
|
||||
d.views.push(NamedView { name: name.to_string(), center, height, width, layer_state, twist: 0.0, layout });
|
||||
let msg = if replaced.is_some() { format!("View \"{name}\" replaced.") } else { format!("View \"{name}\" saved.") };
|
||||
Ok(json!({ "name": name, "center": [center.x, center.y], "height": height, "width": width, "message": msg }))
|
||||
}
|
||||
|
||||
/// Restore view `name`: switch to its space, show its area, and restore its layer snapshot.
|
||||
fn restore(s: &mut Session, name: &str) -> Result<Value> {
|
||||
let v = find(&s.doc()?.views, name).cloned().ok_or_else(|| EngineError::Other(format!("Could not find view \"{}\".", name.trim())))?;
|
||||
if !(v.center.is_finite() && v.height.is_finite() && v.width.is_finite() && v.height > 0.0) {
|
||||
return Err(EngineError::Other(format!("View \"{}\" has no area.", v.name)));
|
||||
}
|
||||
match &v.layout {
|
||||
Some(l) => {
|
||||
// A paper space view: its layout (an unknown one: the current or first layout).
|
||||
let target = match (l.is_empty(), s.layout_space()) {
|
||||
(false, _) => l.clone(),
|
||||
(true, Space::Paper(cur)) => cur,
|
||||
(true, Space::Model) => s.doc()?.layouts.iter().min_by_key(|x| x.tab_order).map(|x| x.name.clone()).unwrap_or_default(),
|
||||
};
|
||||
if s.doc()?.layout(&target).is_none() {
|
||||
return Err(EngineError::Other(format!("View \"{}\" belongs to layout \"{target}\", which does not exist.", v.name)));
|
||||
}
|
||||
if s.layout_space() != Space::Paper(target.clone()) {
|
||||
super::run_layout_set(s, &json!({ "name": target }))?;
|
||||
}
|
||||
let (w, h) = s.viewport_px;
|
||||
let st = s.state_mut()?;
|
||||
st.mspace = None;
|
||||
let height = v.height.max(v.width / (w / h.max(1.0)).max(1e-9));
|
||||
st.set_view(View { center: v.center, height });
|
||||
}
|
||||
None => {
|
||||
// A model space view: into the active MSPACE viewport, else on the Model tab.
|
||||
if s.layout_space() != Space::Model && s.state()?.active_viewport().is_none() {
|
||||
super::run_layout_set(s, &json!({ "name": "Model" }))?;
|
||||
}
|
||||
let (w, h) = s.viewport_px;
|
||||
let st = s.state_mut()?;
|
||||
let aspect = st.zoom_aspect(w / h.max(1.0)).max(1e-9);
|
||||
st.set_zoom_frame(View { center: v.center, height: v.height.max(v.width / aspect) });
|
||||
}
|
||||
}
|
||||
let mut msg = format!("View \"{}\" restored.", v.name);
|
||||
if let Some(state) = &v.layer_state {
|
||||
let d = s.doc_mut()?;
|
||||
match d.layer_states.iter().find(|l| l.name.eq_ignore_ascii_case(state)).cloned() {
|
||||
Some(saved) => {
|
||||
for layer in saved.layers {
|
||||
if let Some(l) = d.layer_mut(&layer.name) {
|
||||
*l = layer;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => msg.push_str(&format!(" Its layer snapshot \"{state}\" is missing.")),
|
||||
}
|
||||
}
|
||||
s.touch();
|
||||
Ok(json!({ "name": v.name, "message": msg }))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, pats: &[String]) -> Result<Value> {
|
||||
let d = s.doc_mut()?;
|
||||
let gone: Vec<String> = d.views.iter().filter(|v| pats.iter().any(|p| wildcard(p, &v.name))).map(|v| v.name.clone()).collect();
|
||||
if gone.is_empty() {
|
||||
return Err(EngineError::Other("No matching views found.".into()));
|
||||
}
|
||||
d.views.retain(|v| !gone.contains(&v.name));
|
||||
let states: Vec<String> = gone.iter().map(|n| snapshot_name(n)).collect();
|
||||
d.layer_states.retain(|l| !states.iter().any(|n| n.eq_ignore_ascii_case(&l.name)));
|
||||
Ok(json!({ "deleted": gone, "message": format!("{} view(s) deleted.", gone.len()) }))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, pats: &[String]) -> Result<Value> {
|
||||
let d = s.doc()?;
|
||||
let views: Vec<&NamedView> = d.views.iter().filter(|v| pats.iter().any(|p| wildcard(p, &v.name))).collect();
|
||||
let mut lines = vec!["Saved views:".to_string()];
|
||||
if views.is_empty() {
|
||||
lines.push(" (none)".into());
|
||||
}
|
||||
for v in &views {
|
||||
lines.push(format!(" {:<31} {}", v.name, space_label(v)));
|
||||
}
|
||||
let rows: Vec<Value> = views
|
||||
.iter()
|
||||
.map(|v| {
|
||||
json!({
|
||||
"name": v.name, "space": space_label(v), "center": [v.center.x, v.center.y], "height": v.height,
|
||||
"width": v.width, "layerSnapshot": v.layer_state.is_some(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "views": rows, "message": lines.join("\n") }))
|
||||
}
|
||||
|
||||
// ---------------- prompts ----------------
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
Main,
|
||||
List,
|
||||
Delete,
|
||||
Restore,
|
||||
Save,
|
||||
WindowName,
|
||||
WindowFirst,
|
||||
WindowSecond(Vec2),
|
||||
Settings,
|
||||
Snapshot,
|
||||
}
|
||||
|
||||
/// -VIEW: "Enter an option [?/Delete/Orthographic/Restore/Save/sEttings/Window]:".
|
||||
#[derive(Default)]
|
||||
struct ViewM {
|
||||
stage: Stage,
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Run `f` and echo its message (an error re-prompts), ending the command.
|
||||
fn report(s: &mut Session, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Step> {
|
||||
let v = f(s)?;
|
||||
if let Some(m) = v.get("message").and_then(Value::as_str) {
|
||||
for l in m.lines() {
|
||||
s.echo(l.to_string());
|
||||
}
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
|
||||
impl Interactive for ViewM {
|
||||
fn name(&self) -> &'static str {
|
||||
"VIEW"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
Stage::Main => Prompt::new("Enter an option", Accept::TEXT).kw(&["?", "Delete", "Orthographic", "Restore", "Save", "sEttings", "Window"]),
|
||||
Stage::List => Prompt::new("Enter view name(s) to list", Accept::TEXT).default("*"),
|
||||
Stage::Delete => Prompt::new("Enter view name(s) to delete", Accept::TEXT),
|
||||
Stage::Restore => Prompt::new("Enter view name to restore", Accept::TEXT),
|
||||
Stage::Save | Stage::WindowName => Prompt::new("Enter view name to save", Accept::TEXT),
|
||||
Stage::WindowFirst => Prompt::new("Specify first corner", Accept::POINT),
|
||||
Stage::WindowSecond(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a),
|
||||
Stage::Settings => Prompt::new("Enter an option", Accept::TEXT).kw(&["Layer snapshot"]),
|
||||
Stage::Snapshot => {
|
||||
let on = if s.settings.view_layer_snapshot { "Yes" } else { "No" };
|
||||
Prompt::new("Save layer settings with views?", Accept::TEXT).kw(&["Yes", "No"]).default(on)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let text = match &i {
|
||||
Input::Text(t) | Input::Keyword(t) => Some(t.trim().to_string()),
|
||||
_ => None,
|
||||
};
|
||||
match self.stage {
|
||||
Stage::Main => {
|
||||
let Some(t) = text else {
|
||||
return if i == Input::Enter { Ok(Step::Done) } else { Err(EngineError::Other("Invalid option keyword.".into())) };
|
||||
};
|
||||
self.stage = match t.to_ascii_lowercase().as_str() {
|
||||
"?" => Stage::List,
|
||||
"delete" | "d" => Stage::Delete,
|
||||
"restore" | "r" => Stage::Restore,
|
||||
"save" | "s" => Stage::Save,
|
||||
"window" | "w" => Stage::WindowName,
|
||||
"settings" | "e" => Stage::Settings,
|
||||
"orthographic" | "o" => {
|
||||
s.echo("Orthographic views are not available yet: CADCraft views are 2D.");
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
_ => return Err(EngineError::Other("Invalid option keyword.".into())),
|
||||
};
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Stage::List => {
|
||||
let names = text.filter(|t| !t.is_empty()).unwrap_or_else(|| "*".into());
|
||||
report(s, |s| list(s, &name_patterns(&json!({ "names": names }))))
|
||||
}
|
||||
Stage::Delete => match text.filter(|t| !t.is_empty()) {
|
||||
Some(names) => report(s, |s| delete(s, &name_patterns(&json!({ "names": names })))),
|
||||
None => Ok(Step::Done),
|
||||
},
|
||||
Stage::Restore => match text.filter(|t| !t.is_empty()) {
|
||||
Some(name) => report(s, |s| restore(s, &name)),
|
||||
None => Ok(Step::Done),
|
||||
},
|
||||
Stage::Save | Stage::WindowName => {
|
||||
let Some(name) = text.filter(|t| !t.is_empty()) else { return Ok(Step::Done) };
|
||||
if !valid_name(&name) {
|
||||
return Err(EngineError::Other(format!("\"{name}\" is not a valid view name.")));
|
||||
}
|
||||
if matches!(self.stage, Stage::WindowName) {
|
||||
self.name = name;
|
||||
self.stage = Stage::WindowFirst;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
let snapshot = s.settings.view_layer_snapshot;
|
||||
report(s, |s| save(s, &name, None, snapshot))
|
||||
}
|
||||
Stage::WindowFirst => match i {
|
||||
Input::Point(a) => {
|
||||
self.stage = Stage::WindowSecond(a);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => Ok(Step::Done),
|
||||
_ => Err(EngineError::Other("Requires a point.".into())),
|
||||
},
|
||||
Stage::WindowSecond(a) => match i {
|
||||
Input::Point(b) => {
|
||||
let snapshot = s.settings.view_layer_snapshot;
|
||||
let name = std::mem::take(&mut self.name);
|
||||
report(s, |s| save(s, &name, Some(Bounds2::new(a, b)), snapshot))
|
||||
}
|
||||
Input::Enter => Ok(Step::Done),
|
||||
_ => Err(EngineError::Other("Requires a point.".into())),
|
||||
},
|
||||
Stage::Settings => match text.map(|t| t.to_ascii_lowercase()) {
|
||||
Some(t) if t == "l" || "layer snapshot".starts_with(t.as_str()) && !t.is_empty() => {
|
||||
self.stage = Stage::Snapshot;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
None if i == Input::Enter => Ok(Step::Done),
|
||||
_ => Err(EngineError::Other("Invalid option keyword.".into())),
|
||||
},
|
||||
Stage::Snapshot => {
|
||||
let on = match text.map(|t| t.to_ascii_lowercase()) {
|
||||
None if i == Input::Enter => s.settings.view_layer_snapshot,
|
||||
Some(t) if t == "y" || t == "yes" => true,
|
||||
Some(t) if t == "n" || t == "no" => false,
|
||||
_ => return Err(EngineError::Other("Requires Yes or No.".into())),
|
||||
};
|
||||
s.settings.view_layer_snapshot = on;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<cadcraft_doc::EntityKind> {
|
||||
match self.stage {
|
||||
Stage::WindowSecond(a) => vec![super::super::helpers::lwpoly(super::super::helpers::rect_vertices(a, c), true)],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,6 +293,10 @@ pub struct Settings {
|
||||
pub gridunit: Vec2,
|
||||
/// Major grid line every N minor lines (GRIDMAJOR).
|
||||
pub gridmajor: u32,
|
||||
/// ZOOMFACTOR: how far one mouse wheel step zooms (3..=100).
|
||||
pub zoomfactor: u32,
|
||||
/// -VIEW sEttings > Layer snapshot: named views save the layer settings with them.
|
||||
pub view_layer_snapshot: bool,
|
||||
pub dynmode: bool,
|
||||
/// DYNPIFORMAT = 1: Dynamic Input shows second and next points as Cartesian `x,y` instead of
|
||||
/// polar `distance<angle` (the default).
|
||||
@@ -351,6 +355,8 @@ impl Default for Settings {
|
||||
osnaphatch: false,
|
||||
gridunit: Vec2::new(0.5, 0.5),
|
||||
gridmajor: 5,
|
||||
zoomfactor: 60,
|
||||
view_layer_snapshot: true,
|
||||
dynmode: true,
|
||||
dynpi_cartesian: false,
|
||||
dynpi_absolute: false,
|
||||
|
||||
@@ -39,6 +39,7 @@ const SESSION_VARS: &[&str] = &[
|
||||
"GRIPSIZE",
|
||||
"CURSORSIZE",
|
||||
"MAXARRAY",
|
||||
"ZOOMFACTOR",
|
||||
"LASTPOINT",
|
||||
"FONTALT",
|
||||
"FONTFALLBACK",
|
||||
@@ -85,6 +86,7 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
|
||||
"GRIPSIZE" => json!(st.gripsize),
|
||||
"CURSORSIZE" => json!(st.cursorsize),
|
||||
"MAXARRAY" => json!(st.maxarray),
|
||||
"ZOOMFACTOR" => json!(st.zoomfactor),
|
||||
"LASTPOINT" => json!([s.last_point.x, s.last_point.y, 0.0]),
|
||||
// Held in radians (as `$ANGBASE` in DXF); reported in degrees, like POLARANG.
|
||||
"ANGBASE" => json!(s.doc().ok()?.header.f64("ANGBASE", 0.0).to_degrees()),
|
||||
@@ -224,6 +226,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
|
||||
"GRIPSIZE" => st.gripsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 255.0),
|
||||
"CURSORSIZE" => st.cursorsize = as_f64(v).ok_or_else(bad)?.clamp(1.0, 100.0),
|
||||
"MAXARRAY" => st.maxarray = as_f64(v).ok_or_else(bad)?.clamp(100.0, 10_000_000.0) as u64,
|
||||
"ZOOMFACTOR" => st.zoomfactor = as_i(v).filter(|z| (3..=100).contains(z)).ok_or_else(bad)? as u32,
|
||||
"FONTALT" => cadcraft_fonts::ttf::set_font_alt(v.as_str().ok_or_else(bad)?),
|
||||
"FONTFALLBACK" => cadcraft_fonts::ttf::set_fallback_fonts(v.as_str().ok_or_else(bad)?),
|
||||
"SAVETIME" => crate::autosave::set_savetime(
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Named views (#463): VIEW / -VIEW Save, Window, Restore, Delete, ? and the layer snapshot
|
||||
//! setting; Zoom Realtime and Dynamic; ZOOMFACTOR. (The DXF round trip of views is tested in
|
||||
//! `cadcraft-io`'s `view_tables_roundtrip`.)
|
||||
|
||||
use cadcraft_engine::doc::Space;
|
||||
use cadcraft_engine::geom::Vec2;
|
||||
use cadcraft_engine::{Session, View};
|
||||
use serde_json::json;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.viewport_px = (1200.0, 800.0);
|
||||
s.execute("line", &json!({ "points": [[0, 0], [100, 50]] })).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Type each line at the command line.
|
||||
fn typed(s: &mut Session, lines: &[&str]) {
|
||||
for l in lines {
|
||||
s.cmdline(l).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn view(s: &Session) -> View {
|
||||
s.state().unwrap().view()
|
||||
}
|
||||
|
||||
fn set_view(s: &mut Session, cx: f64, cy: f64, h: f64) {
|
||||
s.execute("view.set", &json!({ "center": [cx, cy], "height": h })).unwrap();
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9 * (1.0 + a.abs().max(b.abs()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_save_restore_list_delete() {
|
||||
let mut s = session();
|
||||
set_view(&mut s, 10.0, 20.0, 30.0);
|
||||
typed(&mut s, &["-view", "s", "Front"]);
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let v = s.doc().unwrap().views.first().cloned().unwrap();
|
||||
assert_eq!((v.name.as_str(), v.center, v.height, v.layout.as_deref()), ("Front", Vec2::new(10.0, 20.0), 30.0, None));
|
||||
assert!(close(v.width, 45.0), "width {}", v.width);
|
||||
assert!(v.layer_state.is_some(), "a layer snapshot by default");
|
||||
// Saving is an undo step; a view change is not.
|
||||
set_view(&mut s, 0.0, 0.0, 500.0);
|
||||
typed(&mut s, &["view", "r", "front"]);
|
||||
assert_eq!(view(&s), View { center: Vec2::new(10.0, 20.0), height: 30.0 });
|
||||
// Zoom Previous goes back to the view before the restore.
|
||||
s.execute("zoom.previous", &json!({})).unwrap();
|
||||
assert_eq!(view(&s).height, 500.0);
|
||||
typed(&mut s, &["-view", "?", ""]);
|
||||
assert!(s.log.iter().any(|l| l.contains("Front") && l.contains("Model")), "{:?}", s.log);
|
||||
typed(&mut s, &["-view", "d", "fr*"]);
|
||||
assert!(s.doc().unwrap().views.is_empty());
|
||||
assert!(s.doc().unwrap().layer_states.is_empty(), "the snapshot goes with the view");
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap().views.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_option_and_json_forms() {
|
||||
let mut s = session();
|
||||
typed(&mut s, &["-view", "w", "Plan", "0,0", "40,10"]);
|
||||
let v = s.doc().unwrap().views.first().cloned().unwrap();
|
||||
assert_eq!((v.center, v.height, v.width), (Vec2::new(20.0, 5.0), 10.0, 40.0));
|
||||
// Restoring fits the whole window: the width decides here (40 / 1.5 > 10).
|
||||
s.execute("view", &json!({ "option": "restore", "name": "PLAN" })).unwrap();
|
||||
let r = view(&s);
|
||||
assert_eq!(r.center, Vec2::new(20.0, 5.0));
|
||||
assert!(close(r.height, 40.0 / 1.5), "{}", r.height);
|
||||
// JSON save replaces a view of the same name; list reports it.
|
||||
s.execute("view", &json!({ "option": "save", "name": "plan", "layerSnapshot": false })).unwrap();
|
||||
let list = s.execute("view", &json!({ "option": "list" })).unwrap();
|
||||
let rows = list["views"].as_array().unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!((rows[0]["name"].as_str(), rows[0]["layerSnapshot"].as_bool()), (Some("plan"), Some(false)));
|
||||
// Bad names and missing views are errors, not panics.
|
||||
for bad in [json!({ "option": "save", "name": "a*b" }), json!({ "option": "save" }), json!({ "option": "restore", "name": "nope" })] {
|
||||
assert!(s.execute("view", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute("view", &json!({ "option": "window", "name": "Flat", "p1": [0, 0], "p2": [10, 0] })).is_err());
|
||||
assert!(s.execute("view", &json!({ "option": "orthographic" })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_snapshot_restores_layers_and_the_setting_turns_it_off() {
|
||||
let mut s = session();
|
||||
s.doc_mut().unwrap().layers.push(cadcraft_engine::doc::Layer { name: "Walls".into(), ..Default::default() });
|
||||
let walls_on = |s: &Session| s.doc().unwrap().layer("Walls").map(|l| l.on);
|
||||
typed(&mut s, &["-view", "s", "All"]);
|
||||
s.doc_mut().unwrap().layer_mut("Walls").unwrap().on = false;
|
||||
typed(&mut s, &["-view", "r", "All"]);
|
||||
assert_eq!(walls_on(&s), Some(true));
|
||||
// sEttings > Layer snapshot > No: views are saved without one.
|
||||
typed(&mut s, &["-view", "e", "l", "n"]);
|
||||
assert!(!s.settings.view_layer_snapshot);
|
||||
typed(&mut s, &["-view", "s", "Bare"]);
|
||||
let bare = s.doc().unwrap().views.iter().find(|v| v.name == "Bare").cloned().unwrap();
|
||||
assert_eq!(bare.layer_state, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paper_space_view_switches_to_its_layout() {
|
||||
let mut s = session();
|
||||
let layout = s.doc().unwrap().layouts.first().map(|l| l.name.clone()).unwrap();
|
||||
s.execute("layout.set", &json!({ "name": layout })).unwrap();
|
||||
set_view(&mut s, 5.0, 4.0, 6.0);
|
||||
s.execute("view", &json!({ "option": "save", "name": "Sheet" })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().views.first().and_then(|v| v.layout.clone()), Some(layout.clone()));
|
||||
s.execute("layout.set", &json!({ "name": "Model" })).unwrap();
|
||||
s.execute("view", &json!({ "option": "restore", "name": "Sheet" })).unwrap();
|
||||
assert_eq!(s.layout_space(), Space::Paper(layout));
|
||||
assert_eq!(view(&s), View { center: Vec2::new(5.0, 4.0), height: 6.0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_realtime_drags_and_enter_ends() {
|
||||
let mut s = session();
|
||||
set_view(&mut s, 0.0, 0.0, 100.0);
|
||||
s.state_mut().unwrap().view_history.clear();
|
||||
// Enter at ZOOM's first prompt starts Realtime.
|
||||
typed(&mut s, &["zoom", ""]);
|
||||
assert!(s.realtime_zoom_active(), "{}", s.prompt_text());
|
||||
// Up half the height (400 of 800 px): 2x in; then down a quarter: back by √2.
|
||||
s.realtime_zoom(-400.0).unwrap();
|
||||
assert!(close(view(&s).height, 50.0), "{}", view(&s).height);
|
||||
s.realtime_zoom(200.0).unwrap();
|
||||
assert!(close(view(&s).height, 50.0 * 2f64.sqrt()), "{}", view(&s).height);
|
||||
s.realtime_zoom(f64::NAN).unwrap();
|
||||
typed(&mut s, &[""]);
|
||||
assert!(s.running.is_none() && !s.realtime_zoom_active());
|
||||
// Outside Realtime a drag does nothing; Zoom Previous returns to the view before the drags.
|
||||
s.realtime_zoom(-400.0).unwrap();
|
||||
assert!(close(view(&s).height, 50.0 * 2f64.sqrt()));
|
||||
s.execute("zoom.previous", &json!({})).unwrap();
|
||||
assert_eq!(view(&s).height, 100.0);
|
||||
// The JSON form zooms by a factor about the centre.
|
||||
s.execute("zoom.realtime", &json!({ "factor": 4 })).unwrap();
|
||||
assert_eq!(view(&s), View { center: Vec2::ZERO, height: 25.0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_dynamic_places_and_sizes_the_view_box() {
|
||||
let mut s = session();
|
||||
set_view(&mut s, 10.0, 10.0, 20.0);
|
||||
typed(&mut s, &["zoom", "d"]);
|
||||
// The overview shows the extents (0,0 to 100,50) and the old view.
|
||||
let over = view(&s);
|
||||
assert!(over.height > 50.0, "{over:?}");
|
||||
assert!(!s.preview(Vec2::new(50.0, 25.0)).is_empty());
|
||||
// Place the box at 60,30, size it to 60 wide (left edge 60 - 15 = 45 → 105), accept.
|
||||
typed(&mut s, &["60,30", "105,0", ""]);
|
||||
assert!(s.running.is_none(), "{}", s.prompt_text());
|
||||
let v = view(&s);
|
||||
assert!(close(v.center.x, 75.0) && close(v.center.y, 30.0), "{v:?}");
|
||||
assert!(close(v.height, 40.0), "{v:?}");
|
||||
// Zoom Previous skips the overview.
|
||||
s.execute("zoom.previous", &json!({})).unwrap();
|
||||
assert_eq!(view(&s), View { center: Vec2::new(10.0, 10.0), height: 20.0 });
|
||||
// JSON: like Center.
|
||||
s.execute("zoom.dynamic", &json!({ "center": [1, 2], "height": 3 })).unwrap();
|
||||
assert_eq!(view(&s), View { center: Vec2::new(1.0, 2.0), height: 3.0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoomfactor_scales_the_wheel() {
|
||||
let mut s = session();
|
||||
assert_eq!(s.execute("getvar", &json!({ "name": "ZOOMFACTOR" })).unwrap()["value"], json!(60));
|
||||
let at60 = s.wheel_zoom_factor(50.0);
|
||||
for bad in [json!(2), json!(101), json!("x")] {
|
||||
assert!(s.execute("setvar", &json!({ "name": "ZOOMFACTOR", "value": bad })).is_err());
|
||||
}
|
||||
s.execute("setvar", &json!({ "name": "ZOOMFACTOR", "value": 100 })).unwrap();
|
||||
let at100 = s.wheel_zoom_factor(50.0);
|
||||
s.execute("setvar", &json!({ "name": "ZOOMFACTOR", "value": 3 })).unwrap();
|
||||
let at3 = s.wheel_zoom_factor(50.0);
|
||||
assert!(at100 > at60 && at60 > at3 && at3 > 1.0, "{at3} {at60} {at100}");
|
||||
assert!(close(s.wheel_zoom_factor(-50.0), 1.0 / at3));
|
||||
assert_eq!(s.wheel_zoom_factor(f64::INFINITY), 1.0);
|
||||
}
|
||||
@@ -42,10 +42,11 @@ fn zoom_center_scale_dynamic_and_paper_space_all() {
|
||||
s.cmdline("ZOOM S 2").unwrap();
|
||||
assert!(near(view(&s).height, 148.5), "{:?}", view(&s));
|
||||
|
||||
// Dynamic asks for a window instead of zooming to the extents.
|
||||
s.script("ZOOM D 0,0 160,90\n").unwrap();
|
||||
// Dynamic: a point places the view box (the size of the view before), Enter shows it (#463).
|
||||
s.script("ZOOM D 80,45\n\n").unwrap();
|
||||
assert!(s.current_prompt().is_none());
|
||||
let v = view(&s);
|
||||
assert!(v.center.near(Vec2::new(80.0, 45.0), 1e-9) && near(v.height, 90.0), "{v:?}");
|
||||
assert!(v.center.near(Vec2::new(80.0, 45.0), 1e-9) && near(v.height, 148.5), "{v:?}");
|
||||
|
||||
// Paper space: All and Extents (no paper objects) fit the sheet, not the model limits.
|
||||
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
|
||||
@@ -1318,13 +1318,13 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
||||
continue;
|
||||
}
|
||||
let x = crate::dxf_ext::xdata(&tg, crate::dxf_ext::APP);
|
||||
let layer_state = x
|
||||
.iter()
|
||||
.position(|t| t.code == 1000 && t.str() == "LAYERSTATE")
|
||||
.and_then(|i| x.get(i + 1))
|
||||
.filter(|t| t.code == 1000)
|
||||
.map(Tag::str);
|
||||
let v = NamedView { name: name.clone(), center, height: h, width: w, layer_state };
|
||||
let field = |key: &str| {
|
||||
x.iter().position(|t| t.code == 1000 && t.str() == key).and_then(|i| x.get(i + 1)).filter(|t| t.code == 1000).map(Tag::str)
|
||||
};
|
||||
// 70 bit 1: a paper space view; its layout is CADCraft xdata.
|
||||
let layout = (t.i(70).unwrap_or(0) & 1 != 0).then(|| field("LAYOUT").unwrap_or_default());
|
||||
let twist = t.f(50).filter(|a| a.is_finite()).map_or(0.0, |a| a.rem_euclid(360.0).to_radians());
|
||||
let v = NamedView { name: name.clone(), center, height: h, width: w, layer_state: field("LAYERSTATE"), twist, layout };
|
||||
match d.views.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
|
||||
Some(x) => *x = v,
|
||||
None => d.views.push(v),
|
||||
|
||||
@@ -1620,7 +1620,7 @@ pub fn tags(d: &Drawing, version: Version) -> Vec<Tag> {
|
||||
for v in &d.views {
|
||||
record_head(&mut w, "VIEW", &th, "AcDbViewTableRecord");
|
||||
w.s(2, &v.name);
|
||||
w.i(70, 0);
|
||||
w.i(70, i64::from(v.layout.is_some()));
|
||||
w.f(40, v.height);
|
||||
w.p2(10, v.center);
|
||||
w.f(41, v.width);
|
||||
@@ -1629,14 +1629,19 @@ pub fn tags(d: &Drawing, version: Version) -> Vec<Tag> {
|
||||
w.f(42, 50.0);
|
||||
w.f(43, 0.0);
|
||||
w.f(44, 0.0);
|
||||
w.f(50, 0.0);
|
||||
w.f(50, if v.twist.is_finite() { v.twist.to_degrees() } else { 0.0 });
|
||||
w.i(71, 0);
|
||||
w.i(281, 0);
|
||||
w.i(72, 0);
|
||||
if let Some(ls) = &v.layer_state {
|
||||
let xdata = [("LAYERSTATE", v.layer_state.as_deref()), ("LAYOUT", v.layout.as_deref().filter(|l| !l.is_empty()))];
|
||||
if xdata.iter().any(|(_, x)| x.is_some()) {
|
||||
w.s(1001, dxf_ext::APP);
|
||||
w.s(1000, "LAYERSTATE");
|
||||
w.s(1000, dxf_ext::xdata_str(ls));
|
||||
for (key, value) in xdata {
|
||||
if let Some(value) = value {
|
||||
w.s(1000, key);
|
||||
w.s(1000, dxf_ext::xdata_str(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
w.s(0, "ENDTAB");
|
||||
|
||||
@@ -106,7 +106,14 @@ fn layout_page_setup_and_paper_view_round_trip() {
|
||||
fn named_views_ucss_and_groups_round_trip() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
let line = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(10.0, 0.0, 0.0) })).expect("line");
|
||||
d.views.push(NamedView { name: "Front".into(), center: Vec2::new(5.0, 5.0), height: 20.0, width: 30.0, layer_state: Some("Plan".into()) });
|
||||
d.views.push(NamedView {
|
||||
name: "Front".into(),
|
||||
center: Vec2::new(5.0, 5.0),
|
||||
height: 20.0,
|
||||
width: 30.0,
|
||||
layer_state: Some("Plan".into()),
|
||||
..Default::default()
|
||||
});
|
||||
d.ucss.push(Ucs { name: "Tilted".into(), origin: Vec3::new(1.0, 2.0, 0.0), x_axis: Vec3::new(0.6, 0.8, 0.0), y_axis: Vec3::new(-0.8, 0.6, 0.0) });
|
||||
d.groups.push(Group { name: "G1".into(), description: "grp".into(), selectable: false, members: vec![line] });
|
||||
// A member that no longer exists is not written.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! DXF save → reopen keeps the VIEW table's paper space flag (with the view's layout), the view
|
||||
//! twist and the layer snapshot (#463).
|
||||
|
||||
use cadcraft_doc::geom::Vec2;
|
||||
use cadcraft_doc::{Drawing, NamedView};
|
||||
use cadcraft_io::{read_dxf, write_dxf};
|
||||
|
||||
fn round_trip(d: &Drawing) -> Drawing {
|
||||
read_dxf(write_dxf(d).as_bytes()).expect("reopen")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_and_paper_views_round_trip() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
let layout = d.layouts.first().map(|l| l.name.clone()).expect("a layout");
|
||||
d.views.push(NamedView { name: "Plan".into(), center: Vec2::new(5.0, 5.0), height: 20.0, width: 30.0, twist: 0.5, ..Default::default() });
|
||||
d.views.push(NamedView {
|
||||
name: "Sheet".into(),
|
||||
center: Vec2::new(4.0, 3.0),
|
||||
height: 8.0,
|
||||
width: 11.0,
|
||||
layer_state: Some("*VIEW Sheet".into()),
|
||||
layout: Some(layout),
|
||||
..Default::default()
|
||||
});
|
||||
// A paper space view of an unknown layout stays a paper space view.
|
||||
d.views.push(NamedView { name: "Loose".into(), center: Vec2::ZERO, height: 1.0, width: 1.0, layout: Some(String::new()), ..Default::default() });
|
||||
let r = round_trip(&d);
|
||||
assert_eq!(r.views.len(), 3);
|
||||
for (a, b) in d.views.iter().zip(&r.views) {
|
||||
assert_eq!(
|
||||
(&a.name, a.center, a.height, a.width, &a.layer_state, &a.layout),
|
||||
(&b.name, b.center, b.height, b.width, &b.layer_state, &b.layout)
|
||||
);
|
||||
assert!((a.twist - b.twist).abs() < 1e-12, "{}: {} vs {}", a.name, a.twist, b.twist);
|
||||
}
|
||||
}
|
||||
|
||||
/// Another program's paper space view (flag 70 = 1, no CADCraft xdata) and a hostile twist.
|
||||
#[test]
|
||||
fn foreign_paper_view_and_hostile_twist() {
|
||||
let tags: &[(i32, &str)] = &[
|
||||
(0, "SECTION"),
|
||||
(2, "TABLES"),
|
||||
(0, "TABLE"),
|
||||
(2, "VIEW"),
|
||||
(0, "VIEW"),
|
||||
(2, "PaperOne"),
|
||||
(70, "1"),
|
||||
(40, "10"),
|
||||
(41, "16"),
|
||||
(10, "3"),
|
||||
(20, "4"),
|
||||
(50, "90"),
|
||||
(0, "VIEW"),
|
||||
(2, "Odd"),
|
||||
(70, "0"),
|
||||
(40, "2"),
|
||||
(41, "2"),
|
||||
(50, "nan"),
|
||||
(0, "ENDTAB"),
|
||||
(0, "ENDSEC"),
|
||||
(0, "EOF"),
|
||||
];
|
||||
let dxf: String = tags.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect();
|
||||
let d = read_dxf(dxf.as_bytes()).expect("read");
|
||||
let paper = d.views.iter().find(|v| v.name == "PaperOne").expect("PaperOne");
|
||||
assert_eq!(paper.layout.as_deref(), Some(""));
|
||||
assert!((paper.twist - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
|
||||
let odd = d.views.iter().find(|v| v.name == "Odd").expect("Odd");
|
||||
assert_eq!((odd.twist, odd.layout.as_deref()), (0.0, None));
|
||||
let again = round_trip(&d);
|
||||
assert_eq!(again.views, d.views);
|
||||
}
|
||||
@@ -586,7 +586,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
&& let Some(hp) = hover_pos
|
||||
{
|
||||
// Zoom with the mouse wheel about the cursor.
|
||||
let f = (f64::from(scroll.y) / 300.0).exp();
|
||||
let f = app.session.wheel_zoom_factor(f64::from(scroll.y));
|
||||
let about = xf.to_world(hp);
|
||||
let _ = app.session.zoom_about(f, about);
|
||||
}
|
||||
@@ -623,6 +623,10 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
if dbl_middle && inside {
|
||||
let _ = app.session.zoom_extents();
|
||||
}
|
||||
// ZOOM Realtime: dragging up zooms in, down zooms out.
|
||||
if resp.dragged_by(egui::PointerButton::Primary) && app.session.realtime_zoom_active() {
|
||||
let _ = app.session.realtime_zoom(f64::from(resp.drag_delta().y));
|
||||
}
|
||||
// Re-read the view after navigation.
|
||||
let view = app.session.state().map(|s| s.view()).unwrap_or(view);
|
||||
let scale = f64::from(rect.height()) / view.height.max(1e-12);
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
Drawing Limits Межі креслення
|
||||
Drawing Utilities Утиліти креслення
|
||||
Drawings (DWG, DXF) Креслення (DWG, DXF)
|
||||
Dynamic Динамічно
|
||||
Dynamic Dimensions Динамічні розміри
|
||||
Dynamic Input Динамічне введення
|
||||
Dynamic Input (F12) Динамічне введення (F12)
|
||||
@@ -470,6 +471,7 @@
|
||||
N Пн
|
||||
Name Назва
|
||||
Name (A–Z) Назва (А–Я)
|
||||
Named Views... Іменовані види...
|
||||
Nearest Найближча точка
|
||||
New Створити
|
||||
New (metric) Створити (метричне)
|
||||
@@ -1011,11 +1013,13 @@
|
||||
Yes Так
|
||||
Zoom Масштабування виду
|
||||
Zoom All Показати все
|
||||
Zoom Dynamic Динамічне масштабування
|
||||
Zoom Extents Показати межі об'єктів
|
||||
Zoom In Наблизити
|
||||
Zoom Object Показати об'єкт
|
||||
Zoom Out Віддалити
|
||||
Zoom Previous Попередній масштаб
|
||||
Zoom Realtime Масштабування в реальному часі
|
||||
Zoom Window Масштабувати рамкою
|
||||
error помилка
|
||||
{contributors} · {commits} {contributors} · {commits}
|
||||
|
||||
|
Reference in new issue
Block a user