diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 464fd72..047c565 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -67,6 +67,8 @@ pub struct Drawing { pub model: EntityStore, pub layouts: Vec, pub views: Vec, + /// Model space viewport configurations (VPORTS): the tiles shown and named ones. + pub vports: Vec, pub ucss: Vec, pub layer_states: Vec, pub groups: Vec, @@ -125,6 +127,7 @@ impl Drawing { model: EntityStore::new(), layouts: vec![Layout::new("Layout1", 1), Layout::new("Layout2", 2)], views: Vec::new(), + vports: Vec::new(), ucss: Vec::new(), layer_states: Vec::new(), groups: Vec::new(), diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index fa7bbb8..eb5b91a 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -620,6 +620,36 @@ pub struct NamedView { pub layout: Option, } +/// One tiled model space viewport of a [`VportConfig`] (a VPORT table record): its corners as +/// fractions of the drawing area (0,0 lower left to 1,1 upper right) and its view. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VportTile { + pub min: Vec2, + pub max: Vec2, + pub center: Vec2, + pub height: f64, + /// Width / height of the tile on screen when it was saved (0: unknown). + #[serde(default)] + pub aspect: f64, +} + +/// A model space viewport configuration (VPORTS): `*Active` is the one shown, other names are +/// saved configurations. The first tile is the current one. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct VportConfig { + pub name: String, + pub tiles: Vec, +} + +impl VportConfig { + /// The name of the configuration shown. + pub const ACTIVE: &'static str = "*Active"; + pub fn is_active(&self) -> bool { + self.name.eq_ignore_ascii_case(Self::ACTIVE) + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Ucs { diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 3a89e43..849a816 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -47,24 +47,24 @@ pub fn specs() -> Vec { .interactive(|_| Ok(Box::new(viewports::VpclipM::default()))), CommandSpec::new("vports", "New Viewports...", run_mview) .menu(&["View", "Viewports", "New Viewports..."]) - .params("{count?: 1-4, arrangement?, p1?, p2?, layout?}") - .interactive(|_| Ok(Box::new(MviewM::new(1, true)))), + .params("{count?: 1-4, arrangement?, p1?, p2?, layout?} (typed in the Model tab: tiled viewports; their JSON form is -vports)") + .interactive(|s| Ok(super::view::model_vports(s, None).unwrap_or_else(|| Box::new(MviewM::new(1, true))))), CommandSpec::new("vports.1", "1 Viewport", |s, p| run_mview_n(s, p, 1)) .menu(&["View", "Viewports", "1 Viewport"]) .params("{p1?, p2?, layout?}") - .interactive(|_| Ok(Box::new(MviewM::new(1, false)))), + .interactive(|s| Ok(super::view::model_vports(s, Some(1)).unwrap_or_else(|| Box::new(MviewM::new(1, false))))), CommandSpec::new("vports.2", "2 Viewports", |s, p| run_mview_n(s, p, 2)) .menu(&["View", "Viewports", "2 Viewports"]) .params("{p1?, p2?, arrangement?: vertical|horizontal, layout?}") - .interactive(|_| Ok(Box::new(MviewM::new(2, false)))), + .interactive(|s| Ok(super::view::model_vports(s, Some(2)).unwrap_or_else(|| Box::new(MviewM::new(2, false))))), CommandSpec::new("vports.3", "3 Viewports", |s, p| run_mview_n(s, p, 3)) .menu(&["View", "Viewports", "3 Viewports"]) .params("{p1?, p2?, arrangement?: right|left|above|below|vertical|horizontal, layout?}") - .interactive(|_| Ok(Box::new(MviewM::new(3, false)))), + .interactive(|s| Ok(super::view::model_vports(s, Some(3)).unwrap_or_else(|| Box::new(MviewM::new(3, false))))), CommandSpec::new("vports.4", "4 Viewports", |s, p| run_mview_n(s, p, 4)) .menu(&["View", "Viewports", "4 Viewports"]) .params("{p1?, p2?, layout?}") - .interactive(|_| Ok(Box::new(MviewM::new(4, false)))), + .interactive(|s| Ok(super::view::model_vports(s, Some(4)).unwrap_or_else(|| Box::new(MviewM::new(4, false))))), CommandSpec::new("viewport.set", "Viewport Properties", run_viewport_set).params( "{handle? (default: selected viewports), scale?: paper units per model unit | \"1:50\", viewHeight?, center?: [x,y], locked?, freeze?: [layer], thaw?: [layer], colors?: {layer: color | null}}", ), @@ -442,6 +442,10 @@ fn add_viewports(s: &mut Session, space: &Space, rect: Bounds2, count: usize, ar } fn mview(s: &mut Session, p: &Value, count: usize, cmd: &str) -> Result { + if cmd == "vports" && s.layout_space() == Space::Model && p.get("layout").is_none() { + // View > Viewports > n in the Model tab: tiled viewports. + return super::view::tile_model(s, p, count); + } let space = target_layout(s, cmd, p)?; let Space::Paper(name) = &space else { return Err(bad(cmd, "not a layout")) }; let rect = match (point_param(p, "p1"), point_param(p, "p2")) { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 98022d0..d12d376 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -10,6 +10,9 @@ use super::*; use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step, View}; mod named_views; +mod vports; + +pub(super) use vports::{model_vports, tile_model}; pub fn specs() -> Vec { let mut v = vec![ @@ -98,6 +101,7 @@ pub fn specs() -> Vec { 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 } diff --git a/crates/engine/src/cmd/view/vports.rs b/crates/engine/src/cmd/view/vports.rs new file mode 100644 index 0000000..c80b1b4 --- /dev/null +++ b/crates/engine/src/cmd/view/vports.rs @@ -0,0 +1,583 @@ +//! Tiled model space viewports: -VPORTS (SIngle, 2/3/4 with an arrangement, Join, Save, Restore, +//! Delete, ?), VPORTS and View > Viewports in the Model tab, and the current tile. +//! +//! The tiles shown are the drawing's `*Active` viewport configuration (saved in the DXF VPORT +//! table), the first tile being the current one; named configurations sit next to it. Arranging +//! tiles is an undo step. The session keeps each tile's view ([`crate::ModelTiles`]); the current +//! tile shows the model space view itself, so ZOOM, PAN and every pick work in it unchanged. The +//! UI draws the tiles from [`Session::model_tiles`] and hands clicks in other tiles to +//! [`Session::click_tile`]. In a layout -VPORTS is MVIEW. + +use std::sync::Arc; + +use cadcraft_doc::{Drawing, Space, VportConfig, VportTile}; +use cadcraft_geom::Vec2; +use serde_json::{Value, json}; + +use super::super::qselect::wildcard; +use super::super::*; +use crate::{Accept, DocState, EngineError, Input, Interactive, ModelTile, ModelTiles, Prompt, Result, Session, Step, View}; + +/// Most tiles on screen at once. +const MAX_TILES: usize = 64; +/// The smallest tile side, as a fraction of the drawing area. +const MIN_SIDE: f64 = 1.0 / 64.0; +const EPS: f64 = 1e-6; + +/// The -VPORTS Join prompts; a click in a tile answers them ([`Session::click_tile`]). +const DOMINANT_PROMPT: &str = "Select dominant viewport"; +const JOIN_PROMPT: &str = "Select viewport to join"; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("-vports", "Viewports", run_vports) + .params( + "{option: single|2|3|4|join|save|restore|delete|list, arrangement?: horizontal|vertical (2) | horizontal|vertical|above|below|left|right (3; default right), applyTo?: current|display (default current: split the current tile), name? (save/restore), names? (delete/list; wildcards), dominant?, join? (tile numbers from 1; dominant defaults to the current tile)} (model space; in a layout: MVIEW)", + ) + .interactive(vports_factory), + CommandSpec::new("vports.activate", "Set Current Viewport", run_activate).params("{index: tile number from 1}").noundo(), + ] +} + +/// -VPORTS: the tile prompts in the Model tab, MVIEW's in a layout. +fn vports_factory(s: &Session) -> Result> { + if let Some(m) = model_vports(s, None) { + return Ok(m); + } + let mview = find_command("mview").and_then(|c| c.interactive).ok_or_else(|| EngineError::UnknownCommand("mview".into()))?; + mview(s) +} + +/// The tile machine for the Model tab (`count`: View > Viewports > n), else `None` (the caller +/// runs its layout form). +pub(in crate::cmd) fn model_vports(s: &Session, count: Option) -> Option> { + if s.layout_space() != Space::Model { + return None; + } + Some(Box::new(VportsM { count, ..Default::default() })) +} + +/// The JSON form of VPORTS / View > Viewports > n in the Model tab: `count` tiles (1: single). +pub(in crate::cmd) fn tile_model(s: &mut Session, p: &Value, count: usize) -> Result { + let option = if count <= 1 { "single".to_string() } else { count.to_string() }; + run_vports(s, &with_option(p, &option)) +} + +fn with_option(p: &Value, option: &str) -> Value { + let mut p = if p.is_object() { p.clone() } else { json!({}) }; + if let Some(o) = p.as_object_mut() { + o.insert("option".into(), json!(option)); + } + p +} + +// ---------------- tile state ---------------- + +type Rect = (Vec2, Vec2); + +fn rect(t: &VportTile) -> Rect { + (t.min, t.max) +} + +fn view_of(t: &VportTile) -> View { + View { center: t.center, height: t.height } +} + +fn active_config(d: &Drawing) -> Option<&VportConfig> { + d.vports.iter().find(|c| c.is_active() && c.tiles.len() > 1) +} + +/// The tiles on show with their views as they are now (the current one's is the model view), and +/// the current tile. One full tile when there is no tiling. +fn live(st: &DocState, px: (f64, f64)) -> (Vec, usize) { + let now = st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v).unwrap_or_default(); + let Some(cfg) = active_config(&st.doc) else { + let aspect = px.0 / px.1.max(1.0); + return (vec![VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), center: now.center, height: now.height, aspect }], 0); + }; + let mut tiles = cfg.tiles.clone(); + let synced = + st.tiles.rects.len() == tiles.len() && st.tiles.views.len() == tiles.len() && st.tiles.rects.iter().zip(&tiles).all(|(r, t)| *r == rect(t)); + let active = if synced { st.tiles.active.min(tiles.len() - 1) } else { 0 }; + if synced { + for (t, v) in tiles.iter_mut().zip(&st.tiles.views) { + (t.center, t.height) = (v.center, v.height); + } + } + if let Some(t) = tiles.get_mut(active) { + (t.center, t.height) = (now.center, now.height); + } + // Screen aspects from the current tile's size in pixels. + if let Some(cur) = tiles.get(active).copied() { + let (fw, fh) = ((cur.max.x - cur.min.x).max(EPS), (cur.max.y - cur.min.y).max(EPS)); + let (w, h) = (px.0 / fw, px.1.max(1.0) / fh); + for t in tiles.iter_mut() { + t.aspect = ((t.max.x - t.min.x) * w / ((t.max.y - t.min.y) * h).max(EPS)).max(EPS); + } + } + (tiles, active) +} + +/// Show `tiles` with tile `current` current: the drawing's `*Active` configuration (one undo +/// step with the command) and the session's views. +fn apply(s: &mut Session, mut tiles: Vec, current: usize) -> Result<()> { + if tiles.is_empty() { + return Err(EngineError::Other("A viewport configuration needs a viewport.".into())); + } + let cur = tiles.remove(current.min(tiles.len() - 1)); + tiles.insert(0, cur); + let st = s.state_mut()?; + let d = Arc::make_mut(&mut st.doc); + d.vports.retain(|c| !c.is_active()); + if tiles.len() > 1 { + d.vports.insert(0, VportConfig { name: VportConfig::ACTIVE.into(), tiles: tiles.clone() }); + st.tiles = ModelTiles { rects: tiles.iter().map(rect).collect(), views: tiles.iter().map(view_of).collect(), active: 0 }; + } else { + st.tiles = ModelTiles::default(); + } + if st.space == Space::Model && st.mspace.is_none() { + st.set_view_quiet(view_of(&cur)); + } + s.touch(); + Ok(()) +} + +fn summary(s: &Session) -> Result { + let (tiles, active) = live(s.state()?, s.viewport_px); + let rows: Vec = tiles + .iter() + .enumerate() + .map(|(i, t)| json!({ "index": i + 1, "min": [t.min.x, t.min.y], "max": [t.max.x, t.max.y], "center": [t.center.x, t.center.y], "height": t.height, "current": i == active })) + .collect(); + Ok(json!({ "tiles": rows })) +} + +/// Split rectangle `r` into `n` tiles (2 to 4) by `arrangement`; the first is the main one. +fn split(r: Rect, n: usize, arrangement: &str) -> Option> { + let ((x0, y0), (x1, y1)) = ((r.0.x, r.0.y), (r.1.x, r.1.y)); + let (xm, ym) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let b = |a: f64, b: f64, c: f64, d: f64| (Vec2::new(a, b), Vec2::new(c, d)); + let third = |a: f64, z: f64, k: f64| a + (z - a) * k / 3.0; + let a = arrangement.trim().to_ascii_lowercase(); + let a = a.as_str(); + let out = match (n, a) { + (2, "h" | "horizontal") => vec![b(x0, ym, x1, y1), b(x0, y0, x1, ym)], + (2, "" | "v" | "vertical") => vec![b(x0, y0, xm, y1), b(xm, y0, x1, y1)], + (3, "h" | "horizontal") => { + let (ya, yb) = (third(y0, y1, 2.0), third(y0, y1, 1.0)); + vec![b(x0, ya, x1, y1), b(x0, yb, x1, ya), b(x0, y0, x1, yb)] + } + (3, "v" | "vertical") => { + let (xa, xb) = (third(x0, x1, 1.0), third(x0, x1, 2.0)); + vec![b(x0, y0, xa, y1), b(xa, y0, xb, y1), b(xb, y0, x1, y1)] + } + (3, "a" | "above") => vec![b(x0, ym, x1, y1), b(x0, y0, xm, ym), b(xm, y0, x1, ym)], + (3, "b" | "below") => vec![b(x0, y0, x1, ym), b(x0, ym, xm, y1), b(xm, ym, x1, y1)], + (3, "l" | "left") => vec![b(x0, y0, xm, y1), b(xm, ym, x1, y1), b(xm, y0, x1, ym)], + (3, "" | "r" | "right") => vec![b(xm, y0, x1, y1), b(x0, ym, xm, y1), b(x0, y0, xm, ym)], + (4, _) => vec![b(x0, ym, xm, y1), b(xm, ym, x1, y1), b(x0, y0, xm, ym), b(xm, y0, x1, ym)], + _ => return None, + }; + Some(out) +} + +/// The rectangle two tiles make together, if they share a whole side. +fn joined(a: Rect, b: Rect) -> Option { + let near = |p: f64, q: f64| (p - q).abs() < EPS; + let rows = near(a.0.y, b.0.y) && near(a.1.y, b.1.y) && (near(a.1.x, b.0.x) || near(b.1.x, a.0.x)); + let cols = near(a.0.x, b.0.x) && near(a.1.x, b.1.x) && (near(a.1.y, b.0.y) || near(b.1.y, a.0.y)); + (rows || cols).then(|| (Vec2::new(a.0.x.min(b.0.x), a.0.y.min(b.0.y)), Vec2::new(a.1.x.max(b.1.x), a.1.y.max(b.1.y)))) +} + +fn valid_name(n: &str) -> bool { + !n.is_empty() && n.chars().count() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c)) +} + +fn model_only(s: &Session) -> Result<()> { + if s.layout_space() == Space::Model { + Ok(()) + } else { + Err(EngineError::Other("Tiled viewports are for the Model tab; use MVIEW in a layout.".into())) + } +} + +/// A tile number (from 1) in `p[key]`. +fn tile_param(p: &Value, key: &str, n: usize) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => { + let i = v.as_u64().or_else(|| v.as_str().and_then(|t| t.trim().parse().ok())).and_then(|i| usize::try_from(i).ok()); + match i { + Some(i) if (1..=n).contains(&i) => Ok(Some(i - 1)), + _ => Err(bad("-vports", format!("`{key}` must be a viewport number from 1 to {n}"))), + } + } + } +} + +fn names(p: &Value) -> Vec { + let raw: Vec = 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(), + }; + raw.into_iter().map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).take(1000).collect() +} + +fn run_vports(s: &mut Session, p: &Value) -> Result { + let option = match p.get("option") { + Some(Value::Number(n)) => n.to_string(), + Some(Value::String(t)) => t.trim().to_ascii_lowercase(), + _ => "list".into(), + }; + let count = match option.as_str() { + "single" | "si" | "1" => Some(1), + "2" | "3" | "4" => option.parse::().ok(), + _ => None, + }; + if let Some(count) = count + && (s.layout_space() != Space::Model || p.get("layout").is_some()) + { + // In a layout, -VPORTS makes layout viewports: MVIEW. + let mview = find_command("mview").ok_or_else(|| EngineError::UnknownCommand("mview".into()))?; + let mut p = p.clone(); + if let Some(o) = p.as_object_mut() { + o.remove("option"); + o.insert("count".into(), json!(count)); + } + return (mview.run)(s, &p); + } + let (tiles, current) = live(s.state()?, s.viewport_px); + let n = tiles.len(); + match option.as_str() { + "single" | "si" | "1" => { + model_only(s)?; + let cur = tiles.get(current).copied().ok_or(EngineError::NoDocument)?; + apply(s, vec![VportTile { min: Vec2::ZERO, max: Vec2::new(1.0, 1.0), ..cur }], 0)?; + } + "2" | "3" | "4" => { + model_only(s)?; + let count = option.parse::().unwrap_or(2); + let display = str_param(p, "applyTo").is_some_and(|a| a.eq_ignore_ascii_case("display")); + let cur = tiles.get(current).copied().ok_or(EngineError::NoDocument)?; + let area = if display { (Vec2::ZERO, Vec2::new(1.0, 1.0)) } else { rect(&cur) }; + let arrangement = str_param(p, "arrangement").unwrap_or(""); + let parts = split(area, count, arrangement) + .ok_or_else(|| bad("-vports", format!("unknown arrangement `{arrangement}` for {count} viewports")))?; + if parts.iter().any(|r| r.1.x - r.0.x < MIN_SIDE - EPS || r.1.y - r.0.y < MIN_SIDE - EPS) { + return Err(EngineError::Other("The current viewport is too small to divide.".into())); + } + let mut out: Vec = + if display { Vec::new() } else { tiles.iter().enumerate().filter(|(i, _)| *i != current).map(|(_, t)| *t).collect() }; + if out.len() + parts.len() > MAX_TILES { + return Err(EngineError::Other(format!("At most {MAX_TILES} viewports can be shown."))); + } + let first = out.len(); + out.extend(parts.into_iter().map(|(min, max)| VportTile { min, max, ..cur })); + apply(s, out, first)?; + } + "join" | "j" => { + model_only(s)?; + let dominant = tile_param(p, "dominant", n)?.unwrap_or(current); + let other = tile_param(p, "join", n)?.ok_or_else(|| bad("-vports", "`join` (the viewport to join) is required"))?; + let (Some(a), Some(b)) = (tiles.get(dominant).copied(), tiles.get(other).copied()) else { + return Err(EngineError::Other("There is only one viewport.".into())); + }; + if dominant == other { + return Err(EngineError::Other("Select a different viewport to join.".into())); + } + let (min, max) = + joined(rect(&a), rect(&b)).ok_or_else(|| EngineError::Other("The selected viewports do not form a rectangle.".into()))?; + let mut out: Vec = tiles.iter().enumerate().filter(|(i, _)| *i != dominant && *i != other).map(|(_, t)| *t).collect(); + out.push(VportTile { min, max, ..a }); + let current = out.len() - 1; + apply(s, out, current)?; + } + "save" | "s" => { + model_only(s)?; + let name = str_param(p, "name").map(str::trim).unwrap_or_default().to_string(); + if !valid_name(&name) { + return Err(EngineError::Other(format!("\"{name}\" is not a valid viewport configuration name."))); + } + // The current tile first, as in the shown configuration. + let mut saved = tiles.clone(); + let cur = saved.remove(current); + saved.insert(0, cur); + let d = s.doc_mut()?; + d.vports.retain(|c| !c.name.eq_ignore_ascii_case(&name)); + d.vports.push(VportConfig { name: name.clone(), tiles: saved }); + return Ok(json!({ "name": name, "message": format!("Viewport configuration \"{name}\" saved.") })); + } + "restore" | "r" => { + model_only(s)?; + let name = str_param(p, "name").map(str::trim).unwrap_or_default(); + let cfg = s + .doc()? + .vports + .iter() + .find(|c| !c.is_active() && c.name.eq_ignore_ascii_case(name)) + .cloned() + .ok_or_else(|| EngineError::Other(format!("Could not find viewport configuration \"{name}\".")))?; + apply(s, cfg.tiles, 0)?; + } + "delete" | "d" => { + let pats = names(p); + if pats.is_empty() { + return Err(bad("-vports", "`names` is required")); + } + let d = s.doc_mut()?; + let gone: Vec = + d.vports.iter().filter(|c| !c.is_active() && pats.iter().any(|q| wildcard(q, &c.name))).map(|c| c.name.clone()).collect(); + if gone.is_empty() { + return Err(EngineError::Other("No matching viewport configurations found.".into())); + } + d.vports.retain(|c| !gone.contains(&c.name)); + return Ok(json!({ "deleted": gone, "message": format!("{} viewport configuration(s) deleted.", gone.len()) })); + } + "list" | "?" => { + let pats = names(p); + let pats = if pats.is_empty() { vec!["*".to_string()] } else { pats }; + let mut lines = vec!["Current configuration:".to_string()]; + let corners = |t: &VportTile| format!("{:.4},{:.4} {:.4},{:.4}", t.min.x, t.min.y, t.max.x, t.max.y); + for (i, t) in tiles.iter().enumerate() { + lines.push(format!(" {}{} {}", i + 1, if i == current { "*" } else { " " }, corners(t))); + } + let saved: Vec<&VportConfig> = s.doc()?.vports.iter().filter(|c| !c.is_active() && pats.iter().any(|q| wildcard(q, &c.name))).collect(); + lines.push("Saved configurations:".into()); + if saved.is_empty() { + lines.push(" (none)".into()); + } + for c in &saved { + lines.push(format!(" {}: {} viewport(s)", c.name, c.tiles.len())); + } + let configs: Vec = saved.iter().map(|c| json!({ "name": c.name, "viewports": c.tiles.len() })).collect(); + let mut r = summary(s)?; + if let Some(o) = r.as_object_mut() { + o.insert("configurations".into(), json!(configs)); + o.insert("message".into(), json!(lines.join("\n"))); + } + return Ok(r); + } + _ => return Err(bad("-vports", "option must be single, 2, 3, 4, join, save, restore, delete or list")), + } + summary(s) +} + +fn run_activate(s: &mut Session, p: &Value) -> Result { + let st = s.state()?; + let n = active_config(&st.doc).map_or(1, |c| c.tiles.len()); + let i = tile_param(p, "index", n)?.ok_or_else(|| bad("vports.activate", "`index` is required"))?; + s.activate_tile(i)?; + summary(s) +} + +impl Session { + /// The model space tiles to draw (empty: one view, or not in the Model tab). + pub fn model_tiles(&self) -> Vec { + let Ok(st) = self.state() else { return Vec::new() }; + if st.space != Space::Model || active_config(&st.doc).is_none() { + return Vec::new(); + } + let (tiles, active) = live(st, self.viewport_px); + tiles.iter().enumerate().map(|(i, t)| ModelTile { min: t.min, max: t.max, view: view_of(t), active: i == active }).collect() + } + + /// Make tile `index` (from 0, in [`Session::model_tiles`] order) the current one: the views + /// trade places; nothing in the drawing changes. + pub fn activate_tile(&mut self, index: usize) -> Result<()> { + let (tiles, active) = live(self.state()?, self.viewport_px); + if tiles.len() < 2 || index >= tiles.len() { + return Err(EngineError::Other("No such viewport.".into())); + } + if index == active { + return Ok(()); + } + let st = self.state_mut()?; + st.tiles = ModelTiles { rects: tiles.iter().map(rect).collect(), views: tiles.iter().map(view_of).collect(), active: index }; + if let Some(t) = tiles.get(index) { + st.set_view_quiet(view_of(t)); + } + self.touch(); + Ok(()) + } + + /// A click in tile `index` outside the current one: it answers a -VPORTS Join prompt, else + /// makes the tile current (also in the middle of a command, as in AutoCAD). + pub fn click_tile(&mut self, index: usize) -> Result<()> { + if self.current_prompt().is_some_and(|p| p.message == DOMINANT_PROMPT || p.message == JOIN_PROMPT) { + return self.input(Input::Text((index + 1).to_string())); + } + self.activate_tile(index) + } +} + +// ---------------- prompts ---------------- + +#[derive(Clone, Copy, Default, PartialEq, Eq)] +enum Stage { + #[default] + Main, + Arrange(usize), + Save, + Restore, + Delete, + List, + Dominant, + Join(Option), +} + +/// -VPORTS in the Model tab: "Enter an option [Save/Restore/Delete/Join/SIngle/?/2/3/4] <3>:". +#[derive(Default)] +struct VportsM { + stage: Stage, + /// View > Viewports > n: start at that count. + count: Option, +} + +impl VportsM { + fn run(s: &mut Session, p: Value) -> Result { + let r = run_vports(s, &p)?; + if let Some(m) = r.get("message").and_then(Value::as_str) { + for l in m.lines() { + s.echo(l.to_string()); + } + } + Ok(Step::Done) + } + fn count(&mut self, s: &mut Session, n: usize) -> Result { + if n == 4 { + return Self::run(s, json!({ "option": "4" })); + } + if n <= 1 { + return Self::run(s, json!({ "option": "single" })); + } + self.stage = Stage::Arrange(n); + Ok(Step::Continue) + } +} + +impl Interactive for VportsM { + fn name(&self) -> &'static str { + "VPORTS" + } + fn begin(&mut self, s: &mut Session) -> Result { + match self.count { + Some(n) => self.count(s, n), + None => Ok(Step::Continue), + } + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.stage { + Stage::Main => { + Prompt::new("Enter an option", Accept::TEXT).kw(&["Save", "Restore", "Delete", "Join", "SIngle", "?", "2", "3", "4"]).default("3") + } + Stage::Arrange(2) => Prompt::new("Enter a configuration option", Accept::TEXT).kw(&["Horizontal", "Vertical"]).default("Vertical"), + Stage::Arrange(_) => Prompt::new("Enter a configuration option", Accept::TEXT) + .kw(&["Horizontal", "Vertical", "Above", "Below", "Left", "Right"]) + .default("Right"), + Stage::Save => Prompt::new("Enter name for new viewport configuration", Accept::TEXT).kw(&["?"]), + Stage::Restore => Prompt::new("Enter name of viewport configuration to restore", Accept::TEXT).kw(&["?"]), + Stage::Delete => Prompt::new("Enter name(s) of viewport configurations to delete", Accept::TEXT).default("none"), + Stage::List => Prompt::new("Enter name(s) of viewport configurations to list", Accept::TEXT).default("*"), + Stage::Dominant => Prompt::new(DOMINANT_PROMPT, Accept::POINT_OR_NUMBER).default("current viewport"), + Stage::Join(_) => Prompt::new(JOIN_PROMPT, Accept::POINT_OR_NUMBER), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let text = match &i { + Input::Text(t) | Input::Keyword(t) => Some(t.trim().to_ascii_lowercase()), + _ => None, + }; + let bad_kw = || EngineError::Other("Invalid option keyword.".into()); + match self.stage { + Stage::Main => { + let t = match (&i, text) { + (Input::Enter, _) => "3".to_string(), + (_, Some(t)) => t, + _ => return Err(bad_kw()), + }; + match t.as_str() { + "2" | "3" | "4" => self.count(s, t.parse().unwrap_or(3)), + "si" | "single" => Self::run(s, json!({ "option": "single" })), + "s" | "save" => Ok(self.go(Stage::Save)), + "r" | "restore" => Ok(self.go(Stage::Restore)), + "d" | "delete" => Ok(self.go(Stage::Delete)), + "?" => Ok(self.go(Stage::List)), + "j" | "join" => { + model_only(s)?; + Ok(self.go(Stage::Dominant)) + } + _ => Err(bad_kw()), + } + } + Stage::Arrange(n) => { + let arrangement = match (&i, text) { + (Input::Enter, _) => if n == 2 { "vertical" } else { "right" }.to_string(), + (_, Some(t)) => t, + _ => return Err(bad_kw()), + }; + Self::run(s, json!({ "option": n.to_string(), "arrangement": arrangement })) + } + Stage::Save | Stage::Restore => { + let Some(name) = text.filter(|t| !t.is_empty()) else { return Ok(Step::Done) }; + if name == "?" { + let r = run_vports(s, &json!({ "option": "list" }))?; + if let Some(m) = r.get("message").and_then(Value::as_str) { + m.lines().for_each(|l| s.echo(l.to_string())); + } + return Ok(Step::Continue); + } + // The name as typed (keywords and text arrive in lower case above). + let typed = match &i { + Input::Text(t) | Input::Keyword(t) => t.trim().to_string(), + _ => name, + }; + let option = if self.stage == Stage::Save { "save" } else { "restore" }; + Self::run(s, json!({ "option": option, "name": typed })) + } + Stage::Delete => match &i { + Input::Text(t) if !t.trim().is_empty() => Self::run(s, json!({ "option": "delete", "names": t.trim() })), + _ => Ok(Step::Done), + }, + Stage::List => { + let names = match &i { + Input::Text(t) if !t.trim().is_empty() => t.trim().to_string(), + _ => "*".into(), + }; + Self::run(s, json!({ "option": "list", "names": names })) + } + Stage::Dominant => { + let d = self.tile(s, &i)?; + self.stage = Stage::Join(d); + Ok(Step::Continue) + } + Stage::Join(d) => { + let other = self.tile(s, &i)?.ok_or_else(|| EngineError::Other("Select a viewport.".into()))?; + let mut p = json!({ "option": "join", "join": other + 1 }); + if let (Some(d), Some(o)) = (d, p.as_object_mut()) { + o.insert("dominant".into(), json!(d + 1)); + } + Self::run(s, p) + } + } + } +} + +impl VportsM { + fn go(&mut self, stage: Stage) -> Step { + self.stage = stage; + Step::Continue + } + /// A tile answer: a number from 1, a point (in the current tile) or Enter (the current tile). + fn tile(&self, s: &Session, i: &Input) -> Result> { + let (_, current) = live(s.state()?, s.viewport_px); + match i { + Input::Enter | Input::Point(_) => Ok(Some(current)), + Input::Text(t) => { + let n = active_config(s.doc()?).map_or(1, |c| c.tiles.len()); + tile_param(&json!({ "tile": t.trim() }), "tile", n) + } + _ => Err(EngineError::Other("Requires a viewport.".into())), + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cafb09e..0c32037 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -76,6 +76,28 @@ impl Default for View { } } +/// Tiled model viewports (VPORTS): the session side of the drawing's `*Active` configuration. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ModelTiles { + /// The tile corners these views belong to (when the drawing's tiles change, by VPORTS or + /// undo, its views take over). + pub rects: Vec<(Vec2, Vec2)>, + /// Each tile's view; the current tile shows the model space view itself. + pub views: Vec, + /// The current tile. + pub active: usize, +} + +/// One tile of the model space drawing area, for the UI ([`Session::model_tiles`]): its corners +/// as fractions of the drawing area (0,0 lower left) and its view. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ModelTile { + pub min: Vec2, + pub max: Vec2, + pub view: View, + pub active: bool, +} + #[derive(Clone, Debug)] pub struct Snapshot { pub label: String, @@ -98,6 +120,8 @@ pub struct DocState { pub saved: Arc, pub revision: u64, pub space: Space, + /// Tiled model viewports (VPORTS). + pub tiles: ModelTiles, /// MSPACE: the layout viewport whose model space is being edited (None = paper space). pub mspace: Option, pub views: Vec<(Space, View)>, @@ -124,6 +148,7 @@ impl DocState { title: title.into(), revision: 1, space: Space::Model, + tiles: ModelTiles::default(), mspace: None, views: Vec::new(), view_history: Vec::new(), diff --git a/crates/engine/tests/vports.rs b/crates/engine/tests/vports.rs new file mode 100644 index 0000000..5438402 --- /dev/null +++ b/crates/engine/tests/vports.rs @@ -0,0 +1,155 @@ +//! Tiled model space viewports (#463): -VPORTS SIngle, 2/3/4, Join, Save, Restore, Delete and ?, +//! View > Viewports > n in the Model tab, the current tile and its own view. (The DXF VPORT table +//! round trip is tested in `cadcraft-io`'s `view_tables_roundtrip`.) + +use cadcraft_engine::doc::{EntityKind, Space}; +use cadcraft_engine::geom::Vec2; +use cadcraft_engine::{ModelTile, 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.execute("view.set", &json!({ "center": [10, 10], "height": 20 })).unwrap(); + s +} + +fn typed(s: &mut Session, lines: &[&str]) { + for l in lines { + s.cmdline(l).unwrap(); + } +} + +fn tiles(s: &Session) -> Vec { + s.model_tiles() +} + +fn current(s: &Session) -> usize { + tiles(s).iter().position(|t| t.active).unwrap() +} + +fn rects(s: &Session) -> Vec<[f64; 4]> { + let mut r: Vec<[f64; 4]> = tiles(s).iter().map(|t| [t.min.x, t.min.y, t.max.x, t.max.y]).collect(); + r.sort_by(|a, b| a.partial_cmp(b).unwrap()); + r +} + +fn view(s: &Session) -> View { + s.state().unwrap().view() +} + +#[test] +fn split_activate_and_each_tile_keeps_its_view() { + let mut s = session(); + assert!(tiles(&s).is_empty(), "one view: no tiles"); + s.execute("-vports", &json!({ "option": "2", "arrangement": "vertical" })).unwrap(); + assert_eq!(rects(&s), vec![[0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]]); + // Every new tile starts with the view that was split. + assert!(tiles(&s).iter().all(|t| t.view == View { center: Vec2::new(10.0, 10.0), height: 20.0 })); + let first = current(&s); + let other = 1 - first; + s.activate_tile(other).unwrap(); + assert_eq!(current(&s), other); + s.execute("view.set", &json!({ "center": [50, 25], "height": 5 })).unwrap(); + s.execute("vports.activate", &json!({ "index": first + 1 })).unwrap(); + assert_eq!(view(&s), View { center: Vec2::new(10.0, 10.0), height: 20.0 }); + assert_eq!(tiles(&s)[other].view, View { center: Vec2::new(50.0, 25.0), height: 5.0 }); + // Switching tiles changes no drawing data; arranging them is an undo step. + assert_eq!(s.state().unwrap().undo.len(), 2, "line + VPORTS"); + s.undo().unwrap(); + assert!(tiles(&s).is_empty()); + assert!(s.execute("vports.activate", &json!({ "index": 2 })).is_err()); +} + +#[test] +fn typed_three_right_then_join_and_single() { + let mut s = session(); + // Enter takes 3, Enter takes Right: a big tile on the right (current) and two on the left. + typed(&mut s, &["-vports", "", ""]); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(rects(&s), vec![[0.0, 0.0, 0.5, 0.5], [0.0, 0.5, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]]); + let cur = tiles(&s)[current(&s)]; + assert_eq!((cur.min, cur.max), (Vec2::new(0.5, 0.0), Vec2::new(1.0, 1.0))); + // Join the two left tiles: the dominant one's view wins. + let t = tiles(&s); + let top = t.iter().position(|t| t.min == Vec2::new(0.0, 0.5)).unwrap(); + let bottom = t.iter().position(|t| t.min == Vec2::ZERO).unwrap(); + s.activate_tile(top).unwrap(); + s.execute("view.set", &json!({ "center": [1, 2], "height": 3 })).unwrap(); + typed(&mut s, &["-vports", "j", ""]); + // A click in the other tile answers "Select viewport to join". + s.click_tile(bottom).unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(rects(&s), vec![[0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]]); + assert_eq!(view(&s), View { center: Vec2::new(1.0, 2.0), height: 3.0 }); + // Tiles that don't make a rectangle can't be joined. + s.execute("-vports", &json!({ "option": "3", "arrangement": "above" })).unwrap(); + let t = tiles(&s); + // The left tile split: bottom left (0,0)-(0.25,0.5) and the right tile don't touch. + let (a, b) = (t.iter().position(|t| t.max.x <= 0.25).unwrap(), t.iter().position(|t| t.min.x >= 0.5).unwrap()); + assert!(s.execute("-vports", &json!({ "option": "join", "dominant": a + 1, "join": b + 1 })).is_err()); + // SIngle keeps the current tile's view. + s.execute("view.set", &json!({ "center": [7, 7], "height": 7 })).unwrap(); + typed(&mut s, &["-vports", "si"]); + assert!(tiles(&s).is_empty()); + assert_eq!(view(&s), View { center: Vec2::new(7.0, 7.0), height: 7.0 }); +} + +#[test] +fn named_configurations_save_restore_list_delete() { + let mut s = session(); + s.execute("-vports", &json!({ "option": "4" })).unwrap(); + assert_eq!(tiles(&s).len(), 4); + typed(&mut s, &["-vports", "s", "Quad"]); + s.execute("-vports", &json!({ "option": "single" })).unwrap(); + assert!(tiles(&s).is_empty()); + typed(&mut s, &["-vports", "r", "quad"]); + assert_eq!(tiles(&s).len(), 4); + let list = s.execute("-vports", &json!({ "option": "list" })).unwrap(); + assert_eq!(list["configurations"][0]["name"], json!("Quad")); + assert_eq!(list["tiles"].as_array().unwrap().len(), 4); + typed(&mut s, &["-vports", "d", "Q*"]); + assert!(s.doc().unwrap().vports.iter().all(|c| c.name != "Quad")); + for bad in [ + json!({ "option": "save", "name": "*Active" }), + json!({ "option": "restore", "name": "nope" }), + json!({ "option": "join", "join": 99 }), + json!({ "option": "3", "arrangement": "diagonal" }), + json!({ "option": "7" }), + ] { + assert!(s.execute("-vports", &bad).is_err(), "{bad}"); + } +} + +#[test] +fn menu_viewports_in_the_model_tab_and_limits() { + let mut s = session(); + // View > Viewports > 2 Viewports asks for the arrangement in the Model tab. + s.start("vports.2").unwrap(); + typed(&mut s, &["h"]); + assert_eq!(rects(&s), vec![[0.0, 0.0, 1.0, 0.5], [0.0, 0.5, 1.0, 1.0]]); + s.execute("vports.1", &json!({})).unwrap(); + assert!(tiles(&s).is_empty()); + s.execute("vports.3", &json!({ "arrangement": "vertical" })).unwrap(); + // MVIEW / VPORTS JSON stay layout commands. + assert!(s.execute("vports", &json!({ "count": 2 })).is_err()); + assert_eq!(tiles(&s).len(), 3); + // Splitting the current tile again and again stops at a bound, without a panic. + let mut errors = 0; + for _ in 0..40 { + if s.execute("-vports", &json!({ "option": "4" })).is_err() { + errors += 1; + } + } + assert!(errors > 0 && tiles(&s).len() <= 64, "{} tiles", tiles(&s).len()); + // A layout has no tiles; -VPORTS there makes layout viewports (MVIEW). + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + assert!(tiles(&s).is_empty()); + s.execute("-vports", &json!({ "option": "2" })).unwrap(); + let vps = s.doc().unwrap().layout("Layout1").unwrap().entities.iter().filter(|e| matches!(e.kind, EntityKind::Viewport(_))).count(); + assert!(vps >= 2, "{vps}"); + s.execute("layout.set", &json!({ "name": "Model" })).unwrap(); + assert_eq!(s.layout_space(), Space::Model); + assert!(!tiles(&s).is_empty(), "the tiles come back with the Model tab"); +} diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 289e5c4..016e5d1 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -1297,6 +1297,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { // The first *ACTIVE viewport holds the snap grid's origin and rotation (degrees). "VPORT" if !active_vport && t.s(2).is_some_and(|n| n.trim().eq_ignore_ascii_case("*ACTIVE")) => { active_vport = true; + vport_record(d, &t); if let (Some(x), Some(y)) = (t.f(13), t.f(23)) && x.is_finite() && y.is_finite() @@ -1364,9 +1365,44 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) { None => d.dim_styles.push(st), } } + "VPORT" => vport_record(d, &t), _ => {} } } + // One *Active viewport is no tiling: the view is the editor's (it zooms to the extents). + d.vports.retain(|c| c.tiles.len() > usize::from(c.is_active())); +} + +/// Most tiles a viewport configuration keeps. +const MAX_VPORT_TILES: usize = 64; + +/// A VPORT record: one tile of the configuration it names (`*ACTIVE` the one shown). Corners +/// are fractions of the drawing area; unusable tiles are skipped. +fn vport_record(d: &mut Drawing, t: &T) { + let name = t.s(2).map(|n| n.trim().to_string()).unwrap_or_default(); + let (min, max, center) = (t.p(10).xy(), t.p(11).xy(), t.p(12).xy()); + let height = t.fd(40, 0.0); + let unit = |v: f64| v.is_finite() && (-1e-9..=1.0 + 1e-9).contains(&v); + let ok = [min.x, min.y, max.x, max.y].into_iter().all(unit) + && max.x > min.x + 1e-6 + && max.y > min.y + 1e-6 + && center.is_finite() + && height.is_finite() + && height > 0.0; + if name.is_empty() || !ok { + return; + } + let aspect = t.f(41).filter(|a| a.is_finite() && *a > 0.0).unwrap_or(0.0); + let clamp = |p: Vec2| Vec2::new(p.x.clamp(0.0, 1.0), p.y.clamp(0.0, 1.0)); + let tile = VportTile { min: clamp(min), max: clamp(max), center, height, aspect }; + let name = if name.eq_ignore_ascii_case(VportConfig::ACTIVE) { VportConfig::ACTIVE.to_string() } else { name }; + let room = d.vports.len() < MAX_OBJECTS; + match d.vports.iter_mut().find(|c| c.name.eq_ignore_ascii_case(&name)) { + Some(c) if c.tiles.len() < MAX_VPORT_TILES => c.tiles.push(tile), + Some(_) => {} + None if room => d.vports.push(VportConfig { name, tiles: vec![tile] }), + None => {} + } } /// The dash elements of an LTYPE record with their embedded text and shapes (DXF Reference, diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 3030343..4d597e4 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -1210,6 +1210,45 @@ fn paper_view(w: &mut W, d: &Drawing, e: &Entity, owner: &str, cx: &Ctx) { w.s(1000, dxf_ext::PAPER_VIEW); } +/// The VPORT table: the `*Active` model space tiles (one viewport on the extents when there is no +/// tiled configuration) and the saved configurations, each tile a record, the current one first. +fn vport_table(w: &mut W, d: &Drawing, ext: Bounds2) { + let single = VportTile { + min: Vec2::ZERO, + max: Vec2::new(1.0, 1.0), + center: if ext.is_empty() { Vec2::new(6.0, 4.5) } else { ext.center() }, + height: if ext.is_empty() { 9.0 } else { (ext.height() * 1.1).max(1e-6) }, + aspect: 1.6, + }; + let tiled = d.vports.iter().find(|c| c.is_active() && c.tiles.len() > 1); + let mut records: Vec<(&str, &VportTile)> = match tiled { + Some(c) => c.tiles.iter().map(|t| (VportConfig::ACTIVE, t)).collect(), + None => vec![(VportConfig::ACTIVE, &single)], + }; + records.extend(d.vports.iter().filter(|c| !c.is_active() && !c.name.is_empty()).flat_map(|c| c.tiles.iter().map(|t| (c.name.as_str(), t)))); + let th = table_head(w, "VPORT", records.len()); + let finite = |p: Vec2, or: Vec2| if p.is_finite() { p } else { or }; + for (name, t) in records { + record_head(w, "VPORT", &th, "AcDbViewportTableRecord"); + w.s(2, name); + w.i(70, 0); + w.p2(10, finite(t.min, Vec2::ZERO)); + w.p2(11, finite(t.max, Vec2::new(1.0, 1.0))); + w.p2(12, finite(t.center, Vec2::ZERO)); + // Snap grid origin and rotation (SNAPBASE, SNAPANG in degrees): R13+ files keep them here. + w.p2(13, d.header.point("SNAPBASE").map(|p| p.xy()).filter(|p| p.is_finite()).unwrap_or(Vec2::ZERO)); + w.p2(14, Vec2::new(0.5, 0.5)); + w.p2(15, Vec2::new(0.5, 0.5)); + w.p(16, Vec3::Z); + w.p(17, Vec3::ZERO); + w.f(40, if t.height.is_finite() && t.height > 0.0 { t.height } else { 1.0 }); + w.f(41, if t.aspect.is_finite() && t.aspect > 0.0 { t.aspect } else { 1.0 }); + w.f(42, 50.0); + w.f(50, d.header.f64("SNAPANG", 0.0)); + } + w.s(0, "ENDTAB"); +} + fn table_head(w: &mut W, name: &str, count: usize) -> String { let h = w.h(); w.s(0, "TABLE"); @@ -1462,25 +1501,7 @@ pub fn tags(d: &Drawing, version: Version) -> Vec { // ---------------- TABLES ---------------- w.s(0, "SECTION"); w.s(2, "TABLES"); - // VPORT - let th = table_head(&mut w, "VPORT", 1); - record_head(&mut w, "VPORT", &th, "AcDbViewportTableRecord"); - w.s(2, "*Active"); - w.i(70, 0); - w.p2(10, Vec2::ZERO); - w.p2(11, Vec2::new(1.0, 1.0)); - w.p2(12, if ext.is_empty() { Vec2::new(6.0, 4.5) } else { ext.center() }); - // Snap grid origin and rotation (SNAPBASE, SNAPANG in degrees): R13+ files keep them here. - w.p2(13, d.header.point("SNAPBASE").map(|p| p.xy()).filter(|p| p.is_finite()).unwrap_or(Vec2::ZERO)); - w.p2(14, Vec2::new(0.5, 0.5)); - w.p2(15, Vec2::new(0.5, 0.5)); - w.p(16, Vec3::Z); - w.p(17, Vec3::ZERO); - w.f(40, if ext.is_empty() { 9.0 } else { (ext.height() * 1.1).max(1e-6) }); - w.f(41, 1.6); - w.f(42, 50.0); - w.f(50, d.header.f64("SNAPANG", 0.0)); - w.s(0, "ENDTAB"); + vport_table(&mut w, d, ext); // LTYPE. Embedded text and shapes point at STYLE records (340), which get their handles // in the STYLE table below: (tag position, style name, is a shape) are filled in there. let mut ltype_handles: HashMap = HashMap::new(); diff --git a/crates/io/tests/view_tables_roundtrip.rs b/crates/io/tests/view_tables_roundtrip.rs index c0e242e..b7ec71a 100644 --- a/crates/io/tests/view_tables_roundtrip.rs +++ b/crates/io/tests/view_tables_roundtrip.rs @@ -1,8 +1,9 @@ //! DXF save → reopen keeps the VIEW table's paper space flag (with the view's layout), the view -//! twist and the layer snapshot (#463). +//! twist and the layer snapshot, and the VPORT table's tiled model viewports and saved viewport +//! configurations (#463). use cadcraft_doc::geom::Vec2; -use cadcraft_doc::{Drawing, NamedView}; +use cadcraft_doc::{Drawing, NamedView, VportConfig, VportTile}; use cadcraft_io::{read_dxf, write_dxf}; fn round_trip(d: &Drawing) -> Drawing { @@ -72,3 +73,63 @@ fn foreign_paper_view_and_hostile_twist() { let again = round_trip(&d); assert_eq!(again.views, d.views); } + +fn tile(min: (f64, f64), max: (f64, f64), center: (f64, f64), height: f64) -> VportTile { + VportTile { min: Vec2::new(min.0, min.1), max: Vec2::new(max.0, max.1), center: Vec2::new(center.0, center.1), height, aspect: 0.75 } +} + +#[test] +fn tiled_viewports_and_saved_configurations_round_trip() { + let mut d = Drawing::new_imperial(); + d.header.set("SNAPBASE", cadcraft_doc::HVal::Point(cadcraft_doc::geom::Vec3::new(1.0, 2.0, 0.0))); + let active = VportConfig { + name: VportConfig::ACTIVE.into(), + tiles: vec![tile((0.5, 0.0), (1.0, 1.0), (5.0, 5.0), 10.0), tile((0.0, 0.0), (0.5, 1.0), (-3.0, 4.0), 2.5)], + }; + let quad = VportConfig { + name: "Quad".into(), + tiles: vec![ + tile((0.0, 0.5), (0.5, 1.0), (1.0, 1.0), 1.0), + tile((0.5, 0.5), (1.0, 1.0), (2.0, 2.0), 2.0), + tile((0.0, 0.0), (0.5, 0.5), (3.0, 3.0), 3.0), + tile((0.5, 0.0), (1.0, 0.5), (4.0, 4.0), 4.0), + ], + }; + d.vports = vec![active, quad]; + let r = round_trip(&d); + assert_eq!(r.vports, d.vports); + // The first *ACTIVE record still carries the snap base. + assert_eq!(r.header.point("SNAPBASE").map(|p| p.xy()), Some(Vec2::new(1.0, 2.0))); + // No tiling: one *ACTIVE record, read back as no configuration. + let mut single = Drawing::new_imperial(); + single.vports = vec![VportConfig { name: VportConfig::ACTIVE.into(), tiles: vec![tile((0.0, 0.0), (1.0, 1.0), (0.0, 0.0), 1.0)] }]; + assert!(round_trip(&single).vports.is_empty()); +} + +/// VPORT records with corners outside 0..1, no area (or turned around), or an unusable view +/// height are skipped. +#[test] +fn hostile_vport_records_are_skipped() { + let rec = |name: &str, x0: &str, x1: &str, h: &str| -> Vec<(i32, String)> { + [(0, "VPORT"), (2, name), (10, x0), (20, "0"), (11, x1), (21, "1"), (12, "1"), (22, "1"), (40, h), (41, "1.5")] + .iter() + .map(|(c, v)| (*c, v.to_string())) + .collect() + }; + let mut tags: Vec<(i32, String)> = vec![(0, "SECTION".into()), (2, "TABLES".into()), (0, "TABLE".into()), (2, "VPORT".into())]; + tags.extend(rec("*ACTIVE", "0", "0.5", "4")); + tags.extend(rec("*ACTIVE", "0.5", "1", "8")); + tags.extend(rec("*ACTIVE", "-3", "0.5", "4")); + tags.extend(rec("*ACTIVE", "0.2", "0.2", "4")); + tags.extend(rec("*ACTIVE", "0.9", "0.1", "nan")); + tags.extend(rec("Saved", "0", "1", "0")); + tags.extend(rec("Saved", "0", "1", "inf")); + tags.extend([(0, "ENDTAB".into()), (0, "ENDSEC".into()), (0, "EOF".into())]); + let dxf: String = tags.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect(); + let d = read_dxf(dxf.as_bytes()).expect("read"); + assert_eq!(d.vports.len(), 1, "{:?}", d.vports); + let active = d.vports.first().expect("*Active"); + assert!(active.is_active()); + assert_eq!(active.tiles.iter().map(|t| t.height).collect::>(), [4.0, 8.0]); + assert_eq!(round_trip(&d).vports, d.vports); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ce18b6b..6fcd19f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -531,8 +531,12 @@ fn effective_point(app: &mut CadApp, raw: Vec2, xf: &Xf, ctx: &egui::Context) -> pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); - let rect = ui.available_rect_before_wrap(); - let resp = ui.interact(rect, ui.id().with("canvas"), Sense::click_and_drag()); + let full = ui.available_rect_before_wrap(); + // Tiled model viewports (VPORTS): the current tile is the drawing area below; `draw_tiles` + // draws the others. + let tiles = tile_rects(app, full); + let rect = tiles.iter().find(|(_, tile)| tile.active).map_or(full, |(r, _)| *r); + let resp = ui.interact(full, ui.id().with("canvas"), Sense::click_and_drag()); let painter = ui.painter_at(rect); app.canvas.rect = Some(rect); app.session.viewport_px = (f64::from(rect.width()).max(1.0), f64::from(rect.height()).max(1.0)); @@ -627,6 +631,18 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { if resp.dragged_by(egui::PointerButton::Primary) && app.session.realtime_zoom_active() { let _ = app.session.realtime_zoom(f64::from(resp.drag_delta().y)); } + // A click in another tile makes it current (or answers -VPORTS Join's prompts). + if pressed_primary + && let Some(hp) = hover_pos + && !rect.contains(hp) + && let Some(i) = tiles.iter().position(|(r, _)| r.contains(hp)) + { + if let Err(e) = app.session.click_tile(i) { + app.session.echo(e.to_string()); + } + ui.ctx().request_repaint(); + return; + } // 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); @@ -934,6 +950,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { if let (Some(hit), Some(_)) = (app.canvas.snap, app.canvas.cursor) { draw_snap_marker(&painter, xf.to_screen(hit.point), &hit); } + draw_tiles(app, ui, &tiles); // UCS icon, ViewCube and viewport label. if app.ui.show_ucs_icon { draw_ucs_icon(&painter, rect); @@ -945,7 +962,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { viewport_label(&painter, rect); // Command line overlay. if app.ui.show_command_line { - crate::cmdline::show(app, ui, rect); + crate::cmdline::show(app, ui, full); } // Crosshair cursor. if inside { @@ -965,6 +982,39 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { // spin the CPU with the low-latency present mode (`gpu::surface_config`). } +/// The model space tiles on screen (none without tiling): each tile's rectangle in `full`. +fn tile_rects(app: &CadApp, full: Rect) -> Vec<(Rect, cadcraft_engine::ModelTile)> { + let x = |f: f64| full.left() + full.width() * f.clamp(0.0, 1.0) as f32; + let y = |f: f64| full.bottom() - full.height() * f.clamp(0.0, 1.0) as f32; + app.session + .model_tiles() + .into_iter() + .map(|t| (Rect::from_min_max(pos2(x(t.min.x), y(t.max.y)), pos2(x(t.max.x), y(t.min.y))).shrink(1.0), t)) + .collect() +} + +/// The tiles other than the current one (each with its own view, clipped to it), then the +/// tile borders: thin, and heavy around the current tile. +fn draw_tiles(app: &CadApp, ui: &egui::Ui, tiles: &[(Rect, cadcraft_engine::ModelTile)]) { + let t = Tokens::get(); + let bg = Rgb(t.canvas.r(), t.canvas.g(), t.canvas.b()); + for (r, tile) in tiles.iter().filter(|(r, tile)| !tile.active && r.width() >= 1.0 && r.height() >= 1.0) { + let p = ui.painter_at(*r); + let xf = Xf { rect: *r, center: tile.view.center, scale: f64::from(r.height()) / tile.view.height.max(1e-12) }; + p.rect_filled(*r, 0.0, t.canvas); + draw_grid(app, &p, &xf); + if let Some(list) = &app.canvas.list { + draw_list(&p, &xf, list, bg, app.session.settings.lwdisplay); + } + viewport_label(&p, *r); + } + let p = ui.painter(); + for (r, tile) in tiles { + let (w, c) = if tile.active { (2.0, t.accent) } else { (1.0, t.border) }; + p.rect_stroke(*r, 0.0, Stroke::new(w, c), egui::StrokeKind::Outside); + } +} + fn tooltip(p: &egui::Painter, at: Pos2, text: &str) { let galley = p.layout_no_wrap(text.to_string(), crate::theme::small(), Color32::from_rgb(0x20, 0x20, 0x20)); let r = Rect::from_min_size(at, galley.size() + vec2(8.0, 4.0)); diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4d8479a..d372cdf 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -742,6 +742,7 @@ Set Current Зробити поточним Set Current Dimension Style Зробити розмірний стиль поточним Set Current Text Style Зробити текстовий стиль поточним + Set Current Viewport Зробити видовий екран поточним Set Layer Properties Задати властивості шару Set Properties Задати властивості Set Table Cell Задати комірку таблиці diff --git a/docs/gaps.md b/docs/gaps.md index 919a1dd..4a9d3cd 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -67,7 +67,7 @@ Detail and evidence: [ui-parity.md](ui-parity.md). | U8 | Snaps missing on some objects: MID on circles/ellipses/splines; NOD on dimension points; INS on ATTDEF/MLEADER; TAN on ellipses/splines; GCEN on splines | Medium | 4–6 | | U9 | Isometric snap/grid and ISODRAFT (dead toggle). Polar additional angles, angles relative to the last segment and PolarSnap landed (#441) | Medium | 2–4 | | U10 | UNDO Mark/Back/BEgin/End/Auto; SETVAR coverage (~60 of ~1,000 variables) | Medium | 6–10 | -| U11 | Zoom Realtime and Dynamic, ZOOMFACTOR; named views (VIEW); tiled model viewports | Medium | 6–8 | +| U11 | MVIEW Restore (a saved viewport configuration into a layout); VIEW Orthographic, UCS and background settings; view twist is kept but not shown (Zoom Realtime and Dynamic, ZOOMFACTOR, named views (VIEW) and tiled model viewports with saved configurations (VPORTS) landed in #463) | Low | 1–2 | | U12 | Workspaces, navigation bar, CUI customisation, alias editor, ViewCube drag (#7) | Low-medium | 15–25 | | U13 | Window controls misaligned on macOS (#161); `.dxf` from Finder fails to open (#158) | Medium | 2–4 |