Tiled model viewports: VPORTS, saved viewport configurations, DXF VPORT table (part of #463) (#504)

Squash of PR #504 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-11 17:43:11 +02:00 committed by GitHub
parent c9c3031de9
commit 7ecac5fb66
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1004 additions and 31 deletions

View File

@ -67,6 +67,8 @@ pub struct Drawing {
pub model: EntityStore,
pub layouts: Vec<Layout>,
pub views: Vec<NamedView>,
/// Model space viewport configurations (VPORTS): the tiles shown and named ones.
pub vports: Vec<VportConfig>,
pub ucss: Vec<Ucs>,
pub layer_states: Vec<LayerState>,
pub groups: Vec<Group>,
@ -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(),

View File

@ -620,6 +620,36 @@ pub struct NamedView {
pub layout: Option<String>,
}
/// 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<VportTile>,
}
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 {

View File

@ -47,24 +47,24 @@ pub fn specs() -> Vec<CommandSpec> {
.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<Value> {
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")) {

View File

@ -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<CommandSpec> {
let mut v = vec![
@ -98,6 +101,7 @@ pub fn specs() -> Vec<CommandSpec> {
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
}

View File

@ -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<CommandSpec> {
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<Box<dyn Interactive>> {
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<usize>) -> Option<Box<dyn Interactive>> {
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<Value> {
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<VportTile>, 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<VportTile>, 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<Value> {
let (tiles, active) = live(s.state()?, s.viewport_px);
let rows: Vec<Value> = 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<Vec<Rect>> {
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<Rect> {
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<Option<usize>> {
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<String> {
let raw: Vec<String> = 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<Value> {
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::<u64>().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::<usize>().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<VportTile> =
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<VportTile> = 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<String> =
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<Value> = 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<Value> {
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<ModelTile> {
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<usize>),
}
/// -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<usize>,
}
impl VportsM {
fn run(s: &mut Session, p: Value) -> Result<Step> {
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<Step> {
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<Step> {
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<Step> {
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<Option<usize>> {
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())),
}
}
}

View File

@ -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<View>,
/// 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<Drawing>,
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<Handle>,
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(),

View File

@ -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<ModelTile> {
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");
}

View File

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

View File

@ -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<Tag> {
// ---------------- 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<String, String> = HashMap::new();

View File

@ -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::<Vec<_>>(), [4.0, 8.0]);
assert_eq!(round_trip(&d).vports, d.vports);
}

View File

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

View File

@ -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 Задати комірку таблиці

Can't render this file because it has a wrong number of fields in line 7.

View File

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