Named views (VIEW), Zoom Realtime and Dynamic, ZOOMFACTOR (part of #463) (#503)

Squash of PR #503 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 11:42:56 -04:00
1 parent b4b62e4cf0
commit c9c3031de9
13 files changed
+890 -32

No files matched your search

+10 -1
View File
@@ -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
View File
@@ -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()
}
}
+383
View File
@@ -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(),
}
}
}
+6
View File
@@ -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,
+3
View File
@@ -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(
+182
View File
@@ -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);
}
+4 -3
View File
@@ -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();
+7 -7
View File
@@ -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),
+10 -5
View File
@@ -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");
+8 -1
View File
@@ -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.
+74
View File
@@ -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);
}
+5 -1
View File
@@ -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);
+4
View File
@@ -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}
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
207
208
209
210
211
212
213
471
472
473
474
475
476
477
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025