Squash of PR #504 by @vangel76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c9c3031de9
commit
7ecac5fb66
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue