ZOOM: Center, Scale, Dynamic and a plain n; All/Extents fit the sheet in paper space (fixes #388) (#391)
Center, Scale and Dynamic all zoomed to the extents, so the point or factor typed after them ran as a command. Center now asks for the centre point and a magnification or height (a height, nX or nXP), Scale for a factor, and Dynamic, which needs a drag, asks for a window. A plain n (at the first prompt and at Scale) is relative to the limits, as in AutoCAD: 1 fits them. ZOOM All and Extents with nothing drawn used the model limits in paper space too; Session::zoom_limits gives the sheet there. Builds on #372 (ZOOM inside a viewport). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
304e56ba84
commit
d461b95872
|
|
@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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)}")
|
||||
.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`)")
|
||||
.noundo()
|
||||
.transparent()
|
||||
.interactive(|_| Ok(Box::new(ZoomM::default()))),
|
||||
|
|
@ -107,10 +107,7 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
match mode.as_str() {
|
||||
"extents" | "e" => s.zoom_extents()?,
|
||||
"all" | "a" => {
|
||||
let d = s.doc()?;
|
||||
let lo = d.header.point("LIMMIN").map(|p| p.xy()).unwrap_or(Vec2::ZERO);
|
||||
let hi = d.header.point("LIMMAX").map(|p| p.xy()).unwrap_or(Vec2::new(12.0, 9.0));
|
||||
let b = ext_bounds(s)?.union(&Bounds2::new(lo, hi));
|
||||
let b = ext_bounds(s)?.union(&s.zoom_limits()?);
|
||||
fit(s, b.expand(b.height().max(b.width()) * 0.02))?;
|
||||
}
|
||||
"window" | "w" => {
|
||||
|
|
@ -134,23 +131,14 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
"center" | "c" => {
|
||||
let c = point_req("zoom", p, "center")?;
|
||||
let st = s.state_mut()?;
|
||||
let h = f64_or(p, "height", st.zoom_frame().height);
|
||||
st.set_zoom_frame(View { center: c, height: h.max(1e-9) });
|
||||
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) };
|
||||
s.state_mut()?.set_zoom_frame(View { center: c, height: h.max(1e-9) });
|
||||
}
|
||||
"scale" | "s" => {
|
||||
let f = f64_req("zoom", p, "factor")?;
|
||||
if f <= 0.0 {
|
||||
return Err(bad("zoom", "factor must be positive"));
|
||||
}
|
||||
let st = s.state_mut()?;
|
||||
let v = st.zoom_frame();
|
||||
// nXP in a viewport sets its scale: `f` paper units per model unit.
|
||||
let height = match st.zoom_viewport() {
|
||||
Some(vp) if bool_or(p, "xp", false) => vp.height / f,
|
||||
_ => v.height / f,
|
||||
};
|
||||
st.set_zoom_frame(View { center: v.center, height });
|
||||
let v = s.state()?.zoom_frame();
|
||||
let height = factor_height(s, p, v.height)?;
|
||||
s.state_mut()?.set_zoom_frame(View { center: v.center, height });
|
||||
}
|
||||
"object" | "o" => {
|
||||
let hs = targets(s, p)?;
|
||||
|
|
@ -164,6 +152,54 @@ pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// The view height for a zoom `factor`: relative to the current height `cur` (nX), to paper space
|
||||
/// with `xp` (nXP; in a viewport `factor` paper units per model unit, elsewhere like nX), or to
|
||||
/// the limits with `limits` (a plain n: 1 fits the limits).
|
||||
fn factor_height(s: &Session, p: &Value, cur: f64) -> Result<f64> {
|
||||
let f = f64_req("zoom", p, "factor")?;
|
||||
if f <= 0.0 {
|
||||
return Err(bad("zoom", "factor must be positive"));
|
||||
}
|
||||
let st = s.state()?;
|
||||
if bool_or(p, "xp", false)
|
||||
&& let Some(vp) = st.zoom_viewport()
|
||||
{
|
||||
return Ok(vp.height / f);
|
||||
}
|
||||
if bool_or(p, "limits", false) {
|
||||
let lim = s.zoom_limits()?;
|
||||
let (w, h) = s.viewport_px;
|
||||
let aspect = st.zoom_aspect(w / h.max(1.0)).max(1e-6);
|
||||
return Ok(lim.height().max(lim.width() / aspect).max(1e-9) / f);
|
||||
}
|
||||
Ok(cur / f)
|
||||
}
|
||||
|
||||
/// A typed zoom factor: `n` (relative to the limits), `nX` (to the current view) or `nXP` (to
|
||||
/// paper space), as `zoom` scale parameters.
|
||||
fn typed_factor(t: &str) -> Option<Value> {
|
||||
let tl = t.trim().to_ascii_lowercase();
|
||||
let (num, rel) = if let Some(n) = tl.strip_suffix("xp") {
|
||||
(n, "xp")
|
||||
} else if let Some(n) = tl.strip_suffix('x') {
|
||||
(n, "x")
|
||||
} else {
|
||||
(tl.as_str(), "limits")
|
||||
};
|
||||
let f = number(num.trim()).filter(|f| *f > 0.0)?;
|
||||
Some(json!({ "factor": f, "xp": rel == "xp", "limits": rel == "limits" }))
|
||||
}
|
||||
|
||||
fn with_mode(mut p: Value, mode: &str, center: Option<Vec2>) -> Value {
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
o.insert("mode".into(), json!(mode));
|
||||
if let Some(c) = center {
|
||||
o.insert("center".into(), json!([c.x, c.y]));
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn run_zoom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
zoom(s, if p.is_null() { &Value::Null } else { p })
|
||||
}
|
||||
|
|
@ -232,13 +268,33 @@ fn run_layout_set(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
struct ZoomM {
|
||||
window: bool,
|
||||
first: Option<Vec2>,
|
||||
stage: ZoomStage,
|
||||
}
|
||||
|
||||
/// The Center and Scale options' own prompts.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
enum ZoomStage {
|
||||
#[default]
|
||||
Main,
|
||||
Center,
|
||||
CenterHeight(Vec2),
|
||||
Scale,
|
||||
}
|
||||
|
||||
impl Interactive for ZoomM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ZOOM"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
match self.stage {
|
||||
ZoomStage::Main => {}
|
||||
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);
|
||||
return Prompt::new("Enter magnification or height", Accept::NUMBER).default(format!("{h:.4}"));
|
||||
}
|
||||
ZoomStage::Scale => return Prompt::new("Enter a scale factor (nX or nXP)", Accept::NUMBER),
|
||||
}
|
||||
match (self.window, self.first) {
|
||||
(false, None) => Prompt::new("Specify corner of window, enter a scale factor (nX or nXP)", Accept::POINT_OR_NUMBER)
|
||||
.kw(&["All", "Center", "Dynamic", "Extents", "Previous", "Scale", "Window", "Object"])
|
||||
|
|
@ -248,18 +304,63 @@ impl Interactive for ZoomM {
|
|||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let bad_factor = || crate::EngineError::Other("Requires a scale factor (n, nX or nXP).".into());
|
||||
match (self.stage, &i) {
|
||||
(_, Input::Cancel) | (ZoomStage::Main, _) => {}
|
||||
(ZoomStage::Center, Input::Point(c)) => {
|
||||
self.stage = ZoomStage::CenterHeight(*c);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
(ZoomStage::Center, Input::Enter) => return Ok(Step::Done),
|
||||
(ZoomStage::Center, _) => return Err(crate::EngineError::Other("Requires a point.".into())),
|
||||
(ZoomStage::CenterHeight(c), Input::Enter) => {
|
||||
zoom(s, &json!({ "mode": "center", "center": [c.x, c.y] }))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
(ZoomStage::CenterHeight(c), Input::Text(t)) => {
|
||||
let tl = t.trim().to_ascii_lowercase();
|
||||
// A plain number is the new view height; nX / nXP a magnification.
|
||||
let p = if tl.ends_with('x') || tl.ends_with("xp") {
|
||||
typed_factor(&tl).ok_or_else(bad_factor)?
|
||||
} else {
|
||||
json!({ "height": number(&tl).filter(|h| *h > 0.0).ok_or_else(bad_factor)? })
|
||||
};
|
||||
zoom(s, &with_mode(p, "center", Some(c)))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
(ZoomStage::Scale, Input::Text(t)) => {
|
||||
zoom(s, &with_mode(typed_factor(t).ok_or_else(bad_factor)?, "scale", None))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
(ZoomStage::Scale, Input::Enter) => return Ok(Step::Done),
|
||||
(_, _) => return Err(bad_factor()),
|
||||
}
|
||||
match i {
|
||||
Input::Keyword(k) => {
|
||||
let mode = match k.as_str() {
|
||||
"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);
|
||||
}
|
||||
"Window" => {
|
||||
self.window = true;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
"Center" => {
|
||||
self.stage = ZoomStage::Center;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
"Scale" => {
|
||||
self.stage = ZoomStage::Scale;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
"Object" => "object",
|
||||
_ => "extents",
|
||||
_ => return Err(crate::EngineError::Other("Invalid option keyword.".into())),
|
||||
};
|
||||
zoom(s, &json!({ "mode": mode }))?;
|
||||
Ok(Step::Done)
|
||||
|
|
@ -275,14 +376,9 @@ impl Interactive for ZoomM {
|
|||
}
|
||||
},
|
||||
Input::Text(t) => {
|
||||
let tl = t.trim().to_ascii_lowercase();
|
||||
let num = tl.trim_end_matches("xp").trim_end_matches('x');
|
||||
let f = number(num)
|
||||
.filter(|f| *f > 0.0)
|
||||
.ok_or_else(|| crate::EngineError::Other("Requires a point, scale factor or option keyword.".into()))?;
|
||||
// nXP: relative to paper space; nX and a plain number (approximation: like nX)
|
||||
// relative to the current view.
|
||||
zoom(s, &json!({ "mode": "scale", "factor": f, "xp": tl.ends_with("xp") }))?;
|
||||
// nX: relative to the current view; nXP: to paper space; n: to the limits.
|
||||
let p = typed_factor(&t).ok_or_else(|| crate::EngineError::Other("Requires a point, scale factor or option keyword.".into()))?;
|
||||
zoom(s, &with_mode(p, "scale", None))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Input::Enter => Ok(Step::Done),
|
||||
|
|
|
|||
|
|
@ -1089,18 +1089,24 @@ impl Session {
|
|||
|
||||
// ---------------- views ----------------
|
||||
|
||||
/// The limits of the space being edited: the sheet in paper space, else LIMMIN/LIMMAX.
|
||||
pub fn zoom_limits(&self) -> Result<Bounds2> {
|
||||
let d = self.doc()?;
|
||||
if let Space::Paper(name) = self.space()
|
||||
&& let Some(sheet) = cadcraft_render::sheet(d, &name)
|
||||
{
|
||||
return Ok(sheet.bounds());
|
||||
}
|
||||
let lo = d.header.point("LIMMIN").map(|p| p.xy()).unwrap_or(Vec2::ZERO);
|
||||
let hi = d.header.point("LIMMAX").map(|p| p.xy()).unwrap_or(Vec2::new(12.0, 9.0));
|
||||
Ok(Bounds2::new(lo, hi))
|
||||
}
|
||||
|
||||
pub fn zoom_extents(&mut self) -> Result<()> {
|
||||
let space = self.space();
|
||||
let ext = self.doc()?.extents(&space);
|
||||
let (w, h) = self.viewport_px;
|
||||
let ext = if ext.is_empty() {
|
||||
let d = self.doc()?;
|
||||
let lo = d.header.point("LIMMIN").map(|p| p.xy()).unwrap_or(Vec2::ZERO);
|
||||
let hi = d.header.point("LIMMAX").map(|p| p.xy()).unwrap_or(Vec2::new(12.0, 9.0));
|
||||
Bounds2::new(lo, hi)
|
||||
} else {
|
||||
ext
|
||||
};
|
||||
let ext = if ext.is_empty() { self.zoom_limits()? } else { ext };
|
||||
let st = self.state_mut()?;
|
||||
let aspect = st.zoom_aspect(w / h.max(1.0));
|
||||
let height = ext.height().max(ext.width() / aspect.max(1e-6)).max(1e-6) * 1.05;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
//! Typed ZOOM Center, Scale, Dynamic and a plain scale factor do what they say, and ZOOM All /
|
||||
//! Extents in paper space fit the sheet (#388).
|
||||
|
||||
use cadcraft_engine::{Session, View};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::json;
|
||||
|
||||
fn view(s: &Session) -> View {
|
||||
s.state().unwrap().view()
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6 * b.abs().max(1.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_center_scale_dynamic_and_paper_space_all() {
|
||||
let mut s = Session::new();
|
||||
s.viewport_px = (1600.0, 900.0);
|
||||
s.execute("limits", &json!({ "min": [0, 0], "max": [420, 297] })).unwrap();
|
||||
s.execute("line", &json!({ "points": [[0, 0], [1000, 0]] })).unwrap();
|
||||
|
||||
// Center: point, then a height; nX is a magnification of the current height.
|
||||
s.cmdline("ZOOM C").unwrap();
|
||||
assert_eq!(s.current_prompt().unwrap().display(), "Specify center point:");
|
||||
s.cmdline("500,0").unwrap();
|
||||
assert!(s.current_prompt().unwrap().display().starts_with("Enter magnification or height <"));
|
||||
s.cmdline("100").unwrap();
|
||||
assert!(s.current_prompt().is_none());
|
||||
let v = view(&s);
|
||||
assert!(v.center.near(Vec2::new(500.0, 0.0), 1e-9) && near(v.height, 100.0), "{v:?}");
|
||||
s.script("ZOOM C 0,0 2X\n").unwrap();
|
||||
let v = view(&s);
|
||||
assert!(v.center.near(Vec2::ZERO, 1e-9) && near(v.height, 50.0), "{v:?}");
|
||||
|
||||
// Scale: nX relative to the view; a plain n relative to the limits (1 fits them).
|
||||
s.script("ZOOM S 0.5X\n").unwrap();
|
||||
assert!(near(view(&s).height, 100.0), "{:?}", view(&s));
|
||||
s.cmdline("ZOOM 1").unwrap();
|
||||
// 420 × 297 at 16:9 is limited by the height.
|
||||
assert!(near(view(&s).height, 297.0) && view(&s).center.near(Vec2::ZERO, 1e-9), "{:?}", view(&s));
|
||||
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();
|
||||
let v = view(&s);
|
||||
assert!(v.center.near(Vec2::new(80.0, 45.0), 1e-9) && near(v.height, 90.0), "{v:?}");
|
||||
|
||||
// Paper space: All and Extents (no paper objects) fit the sheet, not the model limits.
|
||||
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
|
||||
let sheet = s.zoom_limits().unwrap();
|
||||
assert!(sheet.min == Vec2::ZERO && !near(sheet.max.x, 420.0), "the sheet, not the limits: {sheet:?}");
|
||||
s.cmdline("ZOOM E").unwrap();
|
||||
assert!(view(&s).center.near(sheet.center(), 1e-9), "{:?} vs {sheet:?}", view(&s));
|
||||
s.cmdline("ZOOM A").unwrap();
|
||||
let v = view(&s);
|
||||
assert!(v.center.near(sheet.center(), 1e-9) && v.height < 297.0 && v.height >= sheet.height(), "{v:?} vs {sheet:?}");
|
||||
}
|
||||
Loading…
Reference in New Issue