diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 9e43c83..b52ec17 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -13,7 +13,7 @@ pub fn specs() -> 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)}") + .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 { 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 { } "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 { 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 { + 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 { + 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) -> 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 { zoom(s, if p.is_null() { &Value::Null } else { p }) } @@ -232,13 +268,33 @@ fn run_layout_set(s: &mut Session, p: &Value) -> Result { struct ZoomM { window: bool, first: Option, + 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 { + 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), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b368afa..3020d34 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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 { + 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; diff --git a/crates/engine/tests/zoom_keywords.rs b/crates/engine/tests/zoom_keywords.rs new file mode 100644 index 0000000..f06fde8 --- /dev/null +++ b/crates/engine/tests/zoom_keywords.rs @@ -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:?}"); +}