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:
Valentin Klein 2026-10-11 00:11:05 +02:00 committed by GitHub
parent 304e56ba84
commit d461b95872
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 199 additions and 38 deletions

View File

@ -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),

View File

@ -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;

View File

@ -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:?}");
}