mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-24 10:56:51 -06:00
Squash of PR #633 by @vangel76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
609 lines
25 KiB
Rust
609 lines
25 KiB
Rust
//! 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};
|
|
use serde_json::{Value, json};
|
|
|
|
use super::machines::{SelectRun, number};
|
|
use super::*;
|
|
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, View};
|
|
|
|
mod named_views;
|
|
mod vports;
|
|
|
|
pub(super) use vports::{config_names, config_tiles, model_vports, tile_model};
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
let mut v = vec![
|
|
CommandSpec::new("zoom", "Zoom", run_zoom)
|
|
.alias(&["z"])
|
|
.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()
|
|
.transparent(),
|
|
CommandSpec::new("zoom.window", "Zoom Window", run_zoom)
|
|
.menu(&["View", "Zoom", "Window"])
|
|
.params("{p1, p2}")
|
|
.noundo()
|
|
.transparent()
|
|
.interactive(|_| Ok(Box::new(ZoomM { window: true, ..Default::default() }))),
|
|
CommandSpec::new("zoom.in", "Zoom In", |s, _| zoom(s, &json!({"mode":"in"}))).menu(&["View", "Zoom", "In"]).noundo().transparent(),
|
|
CommandSpec::new("zoom.out", "Zoom Out", |s, _| zoom(s, &json!({"mode":"out"}))).menu(&["View", "Zoom", "Out"]).noundo().transparent(),
|
|
CommandSpec::new("zoom.all", "Zoom All", |s, _| zoom(s, &json!({"mode":"all"}))).menu(&["View", "Zoom", "All"]).noundo().transparent(),
|
|
CommandSpec::new("zoom.extents", "Zoom Extents", |s, _| zoom(s, &json!({"mode":"extents"})))
|
|
.menu(&["View", "Zoom", "Extents"])
|
|
.noundo()
|
|
.transparent(),
|
|
CommandSpec::new("zoom.object", "Zoom Object", |s, p| {
|
|
zoom(s, &json!({"mode":"object", "handles": p.get("handles").cloned().unwrap_or(Value::Null)}))
|
|
})
|
|
.menu(&["View", "Zoom", "Object"])
|
|
.params("{handles?}")
|
|
.noundo()
|
|
.transparent()
|
|
.interactive(|_| Ok(Box::new(SelectRun::new("zoom.object", "ZOOM")))),
|
|
CommandSpec::new("pan", "Pan", run_pan)
|
|
.menu(&["View", "Pan", "Realtime"])
|
|
.alias(&["p"])
|
|
.params("{delta: [dx,dy]} | {from, to} | {center}")
|
|
.noundo()
|
|
.transparent()
|
|
.interactive(|_| Ok(Box::new(PanM::default()))),
|
|
CommandSpec::new("pan.left", "Pan Left", |s, _| pan_frac(s, Vec2::new(-0.25, 0.0))).menu(&["View", "Pan", "Left"]).noundo().transparent(),
|
|
CommandSpec::new("pan.right", "Pan Right", |s, _| pan_frac(s, Vec2::new(0.25, 0.0))).menu(&["View", "Pan", "Right"]).noundo().transparent(),
|
|
CommandSpec::new("pan.up", "Pan Up", |s, _| pan_frac(s, Vec2::new(0.0, 0.25))).menu(&["View", "Pan", "Up"]).noundo().transparent(),
|
|
CommandSpec::new("pan.down", "Pan Down", |s, _| pan_frac(s, Vec2::new(0.0, -0.25))).menu(&["View", "Pan", "Down"]).noundo().transparent(),
|
|
CommandSpec::new("regen", "Regen", |s, _| {
|
|
// Table formulas are recalculated (as on UPDATEFIELD).
|
|
super::table::recalc(s, None)?;
|
|
s.touch();
|
|
Ok(json!({"message": "Regenerating model."}))
|
|
})
|
|
.menu(&["View", "Regen"])
|
|
.alias(&["re"])
|
|
.noundo(),
|
|
CommandSpec::new("regenall", "Regen All", |s, _| {
|
|
s.touch();
|
|
Ok(json!({"message": "Regenerating all viewports."}))
|
|
})
|
|
.menu(&["View", "Regen All"])
|
|
.alias(&["rea"])
|
|
.noundo(),
|
|
CommandSpec::new("redraw", "Redraw", |s, _| {
|
|
s.touch();
|
|
ok()
|
|
})
|
|
.menu(&["View", "Redraw"])
|
|
.alias(&["r"])
|
|
.noundo()
|
|
.transparent(),
|
|
CommandSpec::new("view.set", "Set View", run_view_set).params("{center: [x,y], height}").noundo(),
|
|
CommandSpec::new("view.get", "Get View", |s, _| {
|
|
let v = s.state()?.view();
|
|
Ok(json!({"center": [v.center.x, v.center.y], "height": v.height, "viewportPx": [s.viewport_px.0, s.viewport_px.1]}))
|
|
})
|
|
.noundo(),
|
|
CommandSpec::new("layout.set", "Switch Layout", run_layout_set).params("{name: \"Model\" | layout name}").noundo(),
|
|
];
|
|
v.extend(named_views::specs());
|
|
v.extend(vports::specs());
|
|
v
|
|
}
|
|
|
|
fn ext_bounds(s: &Session) -> Result<Bounds2> {
|
|
let space = s.space();
|
|
Ok(s.doc()?.extents(&space))
|
|
}
|
|
|
|
fn fit(s: &mut Session, b: Bounds2) -> Result<()> {
|
|
if b.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let (w, h) = s.viewport_px;
|
|
let st = s.state_mut()?;
|
|
let aspect = st.zoom_aspect(w / h.max(1.0));
|
|
let height = b.height().max(b.width() / aspect.max(1e-6)).max(1e-9);
|
|
st.set_zoom_frame(View { center: b.center(), height });
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn zoom(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let mode = str_param(p, "mode").unwrap_or("extents").to_ascii_lowercase();
|
|
match mode.as_str() {
|
|
"extents" | "e" => s.zoom_extents()?,
|
|
"all" | "a" => {
|
|
let b = ext_bounds(s)?.union(&s.zoom_limits()?);
|
|
fit(s, b.expand(b.height().max(b.width()) * 0.02))?;
|
|
}
|
|
"window" | "w" => {
|
|
let a = point_req("zoom", p, "p1")?;
|
|
let b = point_req("zoom", p, "p2")?;
|
|
fit(s, Bounds2::new(a, b))?;
|
|
}
|
|
"previous" | "p" => {
|
|
let st = s.state_mut()?;
|
|
if let Some(v) = st.view_history.pop() {
|
|
st.set_view_quiet(v);
|
|
} else {
|
|
return Ok(json!({"message": "No previous view saved."}));
|
|
}
|
|
}
|
|
"in" | "out" => {
|
|
let f = if mode == "in" { 2.0 } else { 0.5 };
|
|
let st = s.state_mut()?;
|
|
let v = st.zoom_frame();
|
|
st.set_zoom_frame(View { center: v.center, height: v.height / f });
|
|
}
|
|
"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) };
|
|
s.state_mut()?.set_zoom_frame(View { center: c, height: h.max(1e-9) });
|
|
}
|
|
"scale" | "s" => {
|
|
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 });
|
|
}
|
|
"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()?;
|
|
let b = hs.iter().filter_map(|h| d.entity(*h)).fold(Bounds2::EMPTY, |a, e| a.union(&entity_bounds(d, e, 0)));
|
|
fit(s, b.expand(b.height().max(b.width()) * 0.05))?;
|
|
}
|
|
_ => return Err(bad("zoom", "unknown mode")),
|
|
}
|
|
s.touch();
|
|
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 })
|
|
}
|
|
|
|
fn pan_frac(s: &mut Session, f: Vec2) -> Result<Value> {
|
|
let (w, h) = s.viewport_px;
|
|
let st = s.state_mut()?;
|
|
let v = st.view();
|
|
let ww = v.height * w / h.max(1.0);
|
|
st.set_view(View { center: v.center + Vec2::new(f.x * ww, f.y * v.height), height: v.height });
|
|
ok()
|
|
}
|
|
|
|
fn run_pan(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let st = s.state_mut()?;
|
|
let v = st.view();
|
|
let c = if let Some(c) = point_param(p, "center") {
|
|
c
|
|
} else if let Some(d) = point_param(p, "delta") {
|
|
v.center - d
|
|
} else {
|
|
let a = point_req("pan", p, "from")?;
|
|
let b = point_req("pan", p, "to")?;
|
|
v.center - (b - a)
|
|
};
|
|
st.set_view(View { center: c, height: v.height });
|
|
ok()
|
|
}
|
|
|
|
fn run_view_set(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let c = point_req("view.set", p, "center")?;
|
|
let h = f64_req("view.set", p, "height")?;
|
|
if h <= 0.0 {
|
|
return Err(bad("view.set", "height must be positive"));
|
|
}
|
|
s.state_mut()?.set_view(View { center: c, height: h });
|
|
ok()
|
|
}
|
|
|
|
fn run_layout_set(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let name = str_param(p, "name").unwrap_or("Model");
|
|
let space = if name.eq_ignore_ascii_case("model") {
|
|
Space::Model
|
|
} else {
|
|
let l = s.doc()?.layout(name).ok_or_else(|| bad("layout.set", format!("no layout `{name}`")))?;
|
|
Space::Paper(l.name.clone())
|
|
};
|
|
let st = s.state_mut()?;
|
|
st.space = space.clone();
|
|
st.mspace = None;
|
|
st.selection.clear();
|
|
if let Space::Paper(n) = &space
|
|
&& !st.views.iter().any(|(sp, _)| sp == &space)
|
|
{
|
|
// Default paper view: the sheet.
|
|
let page = st.doc.layout(n).map(|l| l.page.clone()).unwrap_or_default();
|
|
let (w, h) = if page.landscape { (page.height_mm, page.width_mm) } else { (page.width_mm, page.height_mm) };
|
|
let unit = if st.doc.header.i64("MEASUREMENT", 0) == 1 { 1.0 } else { 1.0 / 25.4 };
|
|
st.set_view_quiet(View { center: Vec2::new(w * unit / 2.0, h * unit / 2.0), height: h * unit * 1.15 });
|
|
}
|
|
s.touch();
|
|
ok()
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct ZoomM {
|
|
window: bool,
|
|
first: Option<Vec2>,
|
|
stage: ZoomStage,
|
|
/// Where the command starts (View > Zoom > Realtime / Dynamic skip the first prompt).
|
|
start: ZoomStart,
|
|
}
|
|
|
|
#[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]
|
|
Main,
|
|
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);
|
|
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"])
|
|
.default("real time"),
|
|
(true, None) => Prompt::new("Specify first corner", Accept::POINT),
|
|
(_, Some(a)) => Prompt::new("Specify opposite corner", Accept::POINT).base(a),
|
|
}
|
|
}
|
|
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::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);
|
|
}
|
|
(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" => return self.dynamic(s),
|
|
"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",
|
|
_ => return Err(crate::EngineError::Other("Invalid option keyword.".into())),
|
|
};
|
|
zoom(s, &json!({ "mode": mode }))?;
|
|
Ok(Step::Done)
|
|
}
|
|
Input::Point(p) => match self.first {
|
|
None => {
|
|
self.first = Some(p);
|
|
Ok(Step::Continue)
|
|
}
|
|
Some(a) => {
|
|
fit(s, Bounds2::new(a, p))?;
|
|
Ok(Step::Done)
|
|
}
|
|
},
|
|
Input::Text(t) => {
|
|
// 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)
|
|
}
|
|
// 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> {
|
|
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()
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct PanM {
|
|
first: Option<Vec2>,
|
|
}
|
|
|
|
impl Interactive for PanM {
|
|
fn name(&self) -> &'static str {
|
|
"PAN"
|
|
}
|
|
fn prompt(&self, _s: &Session) -> Prompt {
|
|
match self.first {
|
|
None => Prompt::new("Specify base point or displacement (drag with the middle mouse button for realtime pan)", Accept::POINT),
|
|
Some(a) => Prompt::new("Specify second point", Accept::POINT).base(a),
|
|
}
|
|
}
|
|
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
|
match (self.first, i) {
|
|
(None, Input::Point(p)) => {
|
|
self.first = Some(p);
|
|
Ok(Step::Continue)
|
|
}
|
|
(Some(a), Input::Point(b)) => {
|
|
run_pan(s, &json!({ "from": [a.x, a.y], "to": [b.x, b.y] }))?;
|
|
Ok(Step::Done)
|
|
}
|
|
(_, Input::Enter) => Ok(Step::Done),
|
|
_ => Ok(Step::Continue),
|
|
}
|
|
}
|
|
}
|