mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-11 16:32:22 -06:00
Squash of PR #633 by @vangel76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
954 lines
40 KiB
Rust
954 lines
40 KiB
Rust
//! Clipped viewports and MVIEW's options: polygonal and object viewports (MVIEW Polygonal /
|
|
//! Object), VPCLIP, ON/OFF, Lock and Restore (a model space viewport configuration as layout
|
|
//! viewports), and the typed MVIEW prompts.
|
|
//!
|
|
//! A clipped viewport keeps its rectangle as the boundary's extents and points at a paper-space
|
|
//! entity (a closed polyline, circle, ellipse or spline) whose outline clips what it shows
|
|
//! ([`Viewport::clip`], DXF group 340).
|
|
|
|
use cadcraft_doc::{Drawing, EntityKind, Handle, Space, Viewport, VportConfig, VportTile};
|
|
use cadcraft_geom::{Bounds2, PolyVertex, Vec2};
|
|
use serde_json::{Value, json};
|
|
|
|
use super::super::curves::{self, handle_value};
|
|
use super::super::helpers::{lwpoly, rect_vertices};
|
|
use super::super::{bad, bool_or, point_param, points_param, str_param, targets};
|
|
use super::{fit_view, layout_viewports, mview, printable, split, target_layout};
|
|
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
|
|
|
|
/// Most vertices a typed or JSON polygonal boundary may have.
|
|
const MAX_VERTICES: usize = 10_000;
|
|
/// Most viewports MVIEW Restore creates (a configuration read from a file may hold more tiles).
|
|
const MAX_RESTORED: usize = 64;
|
|
|
|
/// The polygon outline's extents of a closed paper-space entity usable as a clip boundary.
|
|
fn boundary_bounds(k: &EntityKind) -> Option<Bounds2> {
|
|
cadcraft_render::clip::boundary_polygon(k).map(Bounds2::from_points)
|
|
}
|
|
|
|
/// The next free viewport id (ids are unique across layouts; 1 is a sheet's own view).
|
|
pub(super) fn next_viewport_id(d: &Drawing) -> u32 {
|
|
d.layouts
|
|
.iter()
|
|
.flat_map(|l| l.entities.iter())
|
|
.filter_map(|e| if let EntityKind::Viewport(v) = &e.kind { Some(v.id) } else { None })
|
|
.max()
|
|
.unwrap_or(1)
|
|
.max(1)
|
|
.saturating_add(1)
|
|
}
|
|
|
|
/// Layers marked "New VP Freeze": frozen in new viewports.
|
|
pub(super) fn new_vp_frozen(d: &Drawing) -> Vec<String> {
|
|
d.layers.iter().filter(|l| l.vp_freeze_new).map(|l| l.name.clone()).collect()
|
|
}
|
|
|
|
/// A new viewport over `r`, zoomed to the model extents.
|
|
pub(super) fn new_viewport(d: &Drawing, r: Bounds2, id: u32) -> Viewport {
|
|
let (view_center, view_height) = fit_view(d, r.width(), r.height());
|
|
Viewport {
|
|
center: r.center().to3(0.0),
|
|
width: r.width(),
|
|
height: r.height(),
|
|
view_center,
|
|
view_height,
|
|
id,
|
|
frozen_layers: new_vp_frozen(d),
|
|
..Viewport::default()
|
|
}
|
|
}
|
|
|
|
/// Polygon vertices from `points` (+ optional `bulges`, one per vertex: the arc to the next).
|
|
fn vertices_param(cmd: &str, p: &Value) -> Result<Vec<PolyVertex>> {
|
|
let raw = p.get("points").ok_or_else(|| bad(cmd, "`points` ([[x, y], …], at least three) is required"))?;
|
|
if raw.as_array().is_some_and(|a| a.len() > MAX_VERTICES) {
|
|
return Err(bad(cmd, format!("at most {MAX_VERTICES} points")));
|
|
}
|
|
let pts = points_param(p, "points").ok_or_else(|| bad(cmd, "`points` must be [[x, y], …]"))?;
|
|
let bulges: Vec<f64> = match p.get("bulges") {
|
|
None | Some(Value::Null) => Vec::new(),
|
|
Some(Value::Array(a)) => a
|
|
.iter()
|
|
.take(MAX_VERTICES)
|
|
.map(|v| v.as_f64().filter(|b| b.is_finite() && b.abs() <= 1e6).ok_or_else(|| bad(cmd, "`bulges` must be finite numbers")))
|
|
.collect::<Result<_>>()?,
|
|
Some(_) => return Err(bad(cmd, "`bulges` must be an array of numbers")),
|
|
};
|
|
let mut vs: Vec<PolyVertex> = Vec::new();
|
|
for (i, q) in pts.into_iter().enumerate() {
|
|
if vs.last().is_some_and(|l: &PolyVertex| l.p.near(q, 1e-12)) {
|
|
continue;
|
|
}
|
|
vs.push(PolyVertex { bulge: bulges.get(i).copied().unwrap_or(0.0), ..PolyVertex::new(q) });
|
|
}
|
|
if vs.len() > 1 && vs.first().zip(vs.last()).is_some_and(|(a, b)| a.p.near(b.p, 1e-12)) {
|
|
vs.pop();
|
|
}
|
|
if vs.len() < 3 {
|
|
return Err(bad(cmd, "a polygonal viewport needs at least three distinct points"));
|
|
}
|
|
Ok(vs)
|
|
}
|
|
|
|
/// Check that `h` is a usable clip boundary in `space`; returns its extents.
|
|
fn check_boundary(cmd: &str, d: &Drawing, space: &Space, h: Handle) -> Result<Bounds2> {
|
|
let e = d.space(space).and_then(|st| st.get(h)).ok_or_else(|| bad(cmd, format!("object {} is not in this layout", h.hex())))?;
|
|
if matches!(e.kind, EntityKind::Viewport(_)) {
|
|
return Err(bad(cmd, "a viewport can't be a clip boundary"));
|
|
}
|
|
if viewport_of_boundary(d, space, h).is_some() {
|
|
return Err(bad(cmd, "the object already clips a viewport"));
|
|
}
|
|
let b = boundary_bounds(&e.kind).ok_or_else(|| bad(cmd, "the object must be a closed polyline, circle, ellipse or spline"))?;
|
|
// The exact extents (the tessellated outline runs just inside curves).
|
|
let eb = cadcraft_doc::entity_bounds(d, e, 0);
|
|
let b = if eb.is_empty() || !eb.min.is_finite() || !eb.max.is_finite() { b } else { b.union(&eb) };
|
|
if b.width() > 1e7 || b.height() > 1e7 {
|
|
return Err(bad(cmd, "the viewport is too large"));
|
|
}
|
|
Ok(b)
|
|
}
|
|
|
|
/// The viewport in `space` clipped by `h`.
|
|
fn viewport_of_boundary(d: &Drawing, space: &Space, h: Handle) -> Option<Handle> {
|
|
d.space(space)?.iter().find(|e| matches!(&e.kind, EntityKind::Viewport(v) if v.clip == Some(h))).map(|e| e.handle)
|
|
}
|
|
|
|
/// Add a closed polyline (a polygonal viewport's boundary) to `space`.
|
|
fn add_boundary(s: &mut Session, space: &Space, vs: Vec<PolyVertex>) -> Result<Handle> {
|
|
let common = s.current_common();
|
|
Ok(s.doc_mut()?.add(space, common, lwpoly(vs, true))?)
|
|
}
|
|
|
|
/// A new viewport clipped by `boundary` (an entity in `space`), zoomed to the model extents.
|
|
fn add_clipped(s: &mut Session, cmd: &str, space: &Space, boundary: Handle) -> Result<Handle> {
|
|
let b = check_boundary(cmd, s.doc()?, space, boundary)?;
|
|
let common = s.current_common();
|
|
let d = s.doc_mut()?;
|
|
let id = next_viewport_id(d);
|
|
let vp = Viewport { clip: Some(boundary), ..new_viewport(d, b, id) };
|
|
Ok(d.add(space, common, EntityKind::Viewport(vp))?)
|
|
}
|
|
|
|
/// MVIEW Polygonal: `{points, bulges?, layout?}`.
|
|
fn mview_polygonal(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let space = target_layout(s, "mview", p)?;
|
|
let vs = vertices_param("mview", p)?;
|
|
let poly = lwpoly(vs.clone(), true);
|
|
if boundary_bounds(&poly).is_none() {
|
|
return Err(bad("mview", "the polygon has no area"));
|
|
}
|
|
let boundary = add_boundary(s, &space, vs)?;
|
|
let vp = add_clipped(s, "mview", &space, boundary)?;
|
|
Ok(json!({ "viewport": vp.hex(), "boundary": boundary.hex() }))
|
|
}
|
|
|
|
/// MVIEW Object: `{object, layout?}`.
|
|
fn mview_object(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let space = target_layout(s, "mview", p)?;
|
|
let h = p.get("object").and_then(handle_value).ok_or_else(|| bad("mview", "`object` (a closed object's handle) is required"))?;
|
|
let vp = add_clipped(s, "mview", &space, h)?;
|
|
Ok(json!({ "viewport": vp.hex(), "boundary": h.hex() }))
|
|
}
|
|
|
|
/// Viewports among `hs` in `space`; a clip boundary stands for its viewport.
|
|
fn viewports_in(d: &Drawing, space: &Space, hs: &[Handle]) -> Vec<Handle> {
|
|
let mut out = Vec::new();
|
|
for h in hs {
|
|
let vp = match d.space(space).and_then(|st| st.get(*h)).map(|e| &e.kind) {
|
|
Some(EntityKind::Viewport(v)) if v.id != 1 => Some(*h),
|
|
Some(_) => viewport_of_boundary(d, space, *h),
|
|
None => None,
|
|
};
|
|
if let Some(v) = vp.filter(|v| !out.contains(v)) {
|
|
out.push(v);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The viewports an option acts on: `handle`/`handles` or the selection (boundaries count).
|
|
fn chosen(s: &Session, cmd: &str, p: &Value) -> Result<(Space, Vec<Handle>)> {
|
|
let space = target_layout(s, cmd, p)?;
|
|
let hs = viewports_in(s.doc()?, &space, &targets(s, p)?);
|
|
if hs.is_empty() {
|
|
return Err(bad(cmd, "no viewport given (pass `handles` or select viewports)"));
|
|
}
|
|
Ok((space, hs))
|
|
}
|
|
|
|
fn set_viewports(s: &mut Session, hs: &[Handle], f: impl Fn(&mut Viewport)) -> Result<()> {
|
|
let d = s.doc_mut()?;
|
|
for h in hs {
|
|
d.modify_entity(*h, |e| {
|
|
if let EntityKind::Viewport(v) = &mut e.kind {
|
|
f(v);
|
|
}
|
|
})?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// MVIEW ON / OFF: `{handles?}`.
|
|
fn mview_on(s: &mut Session, p: &Value, on: bool) -> Result<Value> {
|
|
let (_, hs) = chosen(s, "mview", p)?;
|
|
set_viewports(s, &hs, |v| v.on = on)?;
|
|
// A viewport turned off can't stay the active model space viewport.
|
|
if !on && s.state()?.mspace.is_some_and(|m| hs.contains(&m)) {
|
|
s.state_mut()?.mspace = None;
|
|
}
|
|
Ok(json!({ "viewports": hs.iter().map(|h| h.hex()).collect::<Vec<_>>(), "on": on }))
|
|
}
|
|
|
|
/// MVIEW Lock: `{locked: bool, handles?}`.
|
|
fn mview_lock(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let locked = match p.get("locked") {
|
|
Some(Value::Bool(b)) => *b,
|
|
Some(Value::String(t)) if t.eq_ignore_ascii_case("on") => true,
|
|
Some(Value::String(t)) if t.eq_ignore_ascii_case("off") => false,
|
|
_ => return Err(bad("mview", "`locked` (true/false) is required")),
|
|
};
|
|
let (_, hs) = chosen(s, "mview", p)?;
|
|
set_viewports(s, &hs, |v| v.locked = locked)?;
|
|
Ok(json!({ "viewports": hs.iter().map(|h| h.hex()).collect::<Vec<_>>(), "locked": locked }))
|
|
}
|
|
|
|
/// Where tile `t` of a viewport configuration lands when the whole configuration fills `r`; `None`
|
|
/// for a tile with no usable area (configurations come from files).
|
|
fn tile_rect(t: &VportTile, r: Bounds2) -> Option<Bounds2> {
|
|
let f = |v: f64| v.is_finite().then(|| v.clamp(0.0, 1.0));
|
|
let (x0, y0, x1, y1) = (f(t.min.x)?, f(t.min.y)?, f(t.max.x)?, f(t.max.y)?);
|
|
if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 {
|
|
return None;
|
|
}
|
|
let at = |x: f64, y: f64| Vec2::new(r.min.x + x * r.width(), r.min.y + y * r.height());
|
|
Some(Bounds2::new(at(x0, y0), at(x1, y1)))
|
|
}
|
|
|
|
/// The view a restored tile shows in a viewport of size `r`: its saved centre, and its height
|
|
/// grown when the viewport is narrower than the tile was, so the whole saved view still shows.
|
|
fn tile_view(t: &VportTile, r: Bounds2) -> Option<(Vec2, f64)> {
|
|
if !(t.center.is_finite() && t.height.is_finite() && t.height > 0.0) {
|
|
return None;
|
|
}
|
|
let aspect = r.width() / r.height().max(1e-12);
|
|
let h = if t.aspect.is_finite() && t.aspect > 0.0 && aspect > 0.0 { t.height.max(t.height * t.aspect / aspect) } else { t.height };
|
|
(h.is_finite() && h > 0.0).then_some((t.center, h))
|
|
}
|
|
|
|
/// MVIEW Restore: `{name? (default *Active), p1?, p2? (default: the printable area), layout?}`.
|
|
/// One viewport per tile of the model space viewport configuration, arranged as the tiles are
|
|
/// over the window, each showing its tile's view.
|
|
fn mview_restore(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let space = target_layout(s, "mview", p)?;
|
|
let Space::Paper(layout) = &space else { return Err(bad("mview", "not a layout")) };
|
|
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or(VportConfig::ACTIVE).to_string();
|
|
let tiles = super::super::view::config_tiles(s, &name)
|
|
.ok_or_else(|| EngineError::Other(format!("Could not find viewport configuration \"{}\".", name.chars().take(64).collect::<String>())))?;
|
|
let rect = match (point_param(p, "p1"), point_param(p, "p2")) {
|
|
(Some(a), Some(b)) => Bounds2::new(a, b),
|
|
_ => printable(s, layout)?,
|
|
};
|
|
if rect.is_empty() || !rect.min.is_finite() || !rect.max.is_finite() || rect.width() < 1e-6 || rect.height() < 1e-6 {
|
|
return Err(bad("mview", "the viewport has no area"));
|
|
}
|
|
if rect.width() > 1e7 || rect.height() > 1e7 {
|
|
return Err(bad("mview", "the viewport is too large"));
|
|
}
|
|
let common = s.current_common();
|
|
let d = s.doc_mut()?;
|
|
let mut out = Vec::new();
|
|
for t in tiles.iter().take(MAX_RESTORED) {
|
|
let Some(r) = tile_rect(t, rect) else { continue };
|
|
let id = next_viewport_id(d);
|
|
let mut vp = new_viewport(d, r, id);
|
|
if let Some((c, h)) = tile_view(t, r) {
|
|
(vp.view_center, vp.view_height) = (c, h);
|
|
}
|
|
out.push(d.add(&space, common.clone(), EntityKind::Viewport(vp))?);
|
|
}
|
|
if out.is_empty() {
|
|
return Err(EngineError::Other(format!("Viewport configuration \"{name}\" has no viewports to restore.")));
|
|
}
|
|
Ok(json!({ "configuration": name, "viewports": handles_json(&out) }))
|
|
}
|
|
|
|
/// MVIEW's options other than corners and Fit; `None` when `option` is not one of them.
|
|
pub(super) fn mview_option(s: &mut Session, p: &Value) -> Option<Result<Value>> {
|
|
let opt = str_param(p, "option")?.trim().to_ascii_lowercase();
|
|
Some(match opt.as_str() {
|
|
"polygonal" | "p" => mview_polygonal(s, p),
|
|
"object" | "o" => mview_object(s, p),
|
|
"on" => mview_on(s, p, true),
|
|
"off" => mview_on(s, p, false),
|
|
"lock" | "l" => mview_lock(s, p),
|
|
"restore" | "r" => mview_restore(s, p),
|
|
"fit" | "f" | "corners" | "" => return None,
|
|
_ => Err(bad("mview", "option must be fit, polygonal, object, on, off, lock or restore")),
|
|
})
|
|
}
|
|
|
|
/// VPCLIP: `{handle? (viewport; default: the selected or active one), object? | points? (+ bulges?), delete?: bool}`.
|
|
pub(super) fn run_vpclip(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "vpclip";
|
|
let space = target_layout(s, cmd, p)?;
|
|
let d = s.doc()?;
|
|
let mut hs = viewports_in(d, &space, &targets(s, p)?);
|
|
if hs.is_empty()
|
|
&& p.get("handle").is_none()
|
|
&& let Some((h, _)) = s.state()?.active_viewport()
|
|
{
|
|
hs.push(h);
|
|
}
|
|
let vh = *hs.first().ok_or_else(|| bad(cmd, "no viewport given (pass `handle` or select a viewport)"))?;
|
|
let Some(EntityKind::Viewport(cur)) = d.entity(vh).map(|e| e.kind.clone()) else { return Err(bad(cmd, "not a viewport")) };
|
|
let old = cur.clip.filter(|b| *b != vh);
|
|
if bool_or(p, "delete", false) {
|
|
let Some(b) = old else { return Err(bad(cmd, "the viewport is not clipped")) };
|
|
set_viewports(s, &[vh], |v| v.clip = None)?;
|
|
s.doc_mut()?.remove_entity(b);
|
|
return Ok(json!({ "viewport": vh.hex(), "clip": Value::Null }));
|
|
}
|
|
let boundary = match (p.get("object"), p.get("points")) {
|
|
(Some(o), _) => {
|
|
let h = handle_value(o).ok_or_else(|| bad(cmd, "`object` must be an object handle"))?;
|
|
check_boundary(cmd, s.doc()?, &space, h)?;
|
|
h
|
|
}
|
|
(None, Some(_)) => {
|
|
let vs = vertices_param(cmd, p)?;
|
|
if boundary_bounds(&lwpoly(vs.clone(), true)).is_none() {
|
|
return Err(bad(cmd, "the polygon has no area"));
|
|
}
|
|
add_boundary(s, &space, vs)?
|
|
}
|
|
(None, None) => return Err(bad(cmd, "pass `object`, `points` or `delete`")),
|
|
};
|
|
let b = check_boundary(cmd, s.doc()?, &space, boundary)?;
|
|
// The viewport takes the boundary's extents; model space stays where it was on the sheet.
|
|
let k = cur.view_height / cur.height.max(1e-12);
|
|
let mut v = cur;
|
|
let c = b.center();
|
|
let view_center = v.view_center + (c - v.center.xy()) * k;
|
|
let view_height = b.height() * k;
|
|
if view_center.is_finite() && view_height.is_finite() && view_height > 0.0 {
|
|
v.view_center = view_center;
|
|
v.view_height = view_height;
|
|
}
|
|
v.center = c.to3(v.center.z);
|
|
v.width = b.width();
|
|
v.height = b.height();
|
|
v.clip = Some(boundary);
|
|
s.doc_mut()?.modify_entity(vh, |e| e.kind = EntityKind::Viewport(v))?;
|
|
if let Some(o) = old.filter(|o| *o != boundary) {
|
|
s.doc_mut()?.remove_entity(o);
|
|
}
|
|
Ok(json!({ "viewport": vh.hex(), "clip": boundary.hex() }))
|
|
}
|
|
|
|
/// The viewport (turned on) under a paper-space point, topmost first, honouring clip boundaries.
|
|
pub(super) fn viewport_at(s: &Session, at: Vec2) -> Option<Handle> {
|
|
let d = s.doc().ok()?;
|
|
layout_viewports(s).ok()?.into_iter().rev().find(|(h, v)| contains(d, *h, v, at)).map(|(h, _)| h)
|
|
}
|
|
|
|
/// Like [`viewport_at`], also finding viewports that are turned off (MVIEW ON picks them).
|
|
fn any_viewport_at(s: &Session, at: Vec2) -> Option<Handle> {
|
|
let d = s.doc().ok()?;
|
|
let st = d.space(&s.layout_space())?;
|
|
st.iter().filter(|e| matches!(&e.kind, EntityKind::Viewport(v) if v.id != 1 && contains(d, e.handle, v, at))).last().map(|e| e.handle)
|
|
}
|
|
|
|
/// Is the paper point `at` inside the viewport (its clip outline when clipped)?
|
|
pub(super) fn contains(d: &Drawing, h: Handle, v: &Viewport, at: Vec2) -> bool {
|
|
match cadcraft_render::viewport_clip(d, h, v) {
|
|
Some(c) => c.contains(at),
|
|
None => (at.x - v.center.x).abs() <= v.width / 2.0 && (at.y - v.center.y).abs() <= v.height / 2.0,
|
|
}
|
|
}
|
|
|
|
// ---------- typed MVIEW / VPCLIP ----------
|
|
|
|
/// Bulge of an arc leaving `from` along `tangent` and ending at `to` (a polyline arc segment).
|
|
fn tangent_bulge(from: Vec2, tangent: Vec2, to: Vec2) -> Option<f64> {
|
|
let c = to - from;
|
|
let theta = tangent.cross(c).atan2(tangent.dot(c));
|
|
let b = (theta / 2.0).tan();
|
|
(theta.abs() < std::f64::consts::PI - 1e-6 && b.is_finite()).then_some(b)
|
|
}
|
|
|
|
/// Direction at the end of the polygon drawn so far (for a tangent arc).
|
|
fn end_tangent(vs: &[PolyVertex]) -> Vec2 {
|
|
match vs {
|
|
[.., a, b] => {
|
|
let chord = b.p - a.p;
|
|
let ang = chord.y.atan2(chord.x) + 2.0 * a.bulge.atan();
|
|
Vec2::new(ang.cos(), ang.sin())
|
|
}
|
|
_ => Vec2::new(1.0, 0.0),
|
|
}
|
|
}
|
|
|
|
/// Prompts for a polygon boundary: start point, then points with Arc/Line/Close/Undo.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub(super) struct PolygonInput {
|
|
pub vs: Vec<PolyVertex>,
|
|
arc: bool,
|
|
}
|
|
|
|
pub(super) enum PolyStep {
|
|
More,
|
|
Closed(Vec<PolyVertex>),
|
|
}
|
|
|
|
impl PolygonInput {
|
|
pub fn prompt(&self) -> Prompt {
|
|
let base = self.vs.last().map(|v| v.p);
|
|
match (self.vs.len(), self.arc) {
|
|
(0, _) => Prompt::new("Specify start point", Accept::POINT),
|
|
(_, false) => Prompt::new("Specify next point", Accept::POINT).kw(&["Arc", "Close", "Undo"]).base_opt(base),
|
|
(_, true) => Prompt::new("Specify endpoint of arc", Accept::POINT).kw(&["Line", "Close", "Undo"]).base_opt(base),
|
|
}
|
|
}
|
|
pub fn input(&mut self, i: Input) -> Result<PolyStep> {
|
|
match i {
|
|
Input::Point(q) => {
|
|
if self.vs.last().is_some_and(|l| l.p.near(q, 1e-12)) {
|
|
return Ok(PolyStep::More);
|
|
}
|
|
if self.vs.len() >= MAX_VERTICES {
|
|
return Err(EngineError::Other(format!("At most {MAX_VERTICES} points.")));
|
|
}
|
|
if self.arc
|
|
&& let Some(last) = self.vs.last().map(|v| v.p)
|
|
{
|
|
let b = tangent_bulge(last, end_tangent(&self.vs), q).ok_or_else(|| EngineError::Other("Invalid arc endpoint.".into()))?;
|
|
if let Some(v) = self.vs.last_mut() {
|
|
v.bulge = b;
|
|
}
|
|
}
|
|
self.vs.push(PolyVertex::new(q));
|
|
Ok(PolyStep::More)
|
|
}
|
|
Input::Keyword(k) | Input::Text(k) => match k.trim().to_ascii_lowercase().as_str() {
|
|
"arc" | "a" if !self.vs.is_empty() => {
|
|
self.arc = true;
|
|
Ok(PolyStep::More)
|
|
}
|
|
"line" | "l" => {
|
|
self.arc = false;
|
|
Ok(PolyStep::More)
|
|
}
|
|
"undo" | "u" => {
|
|
self.vs.pop();
|
|
if let Some(v) = self.vs.last_mut() {
|
|
v.bulge = 0.0;
|
|
}
|
|
Ok(PolyStep::More)
|
|
}
|
|
"close" | "c" => self.close(),
|
|
_ => Err(EngineError::Other("Requires a point or option keyword.".into())),
|
|
},
|
|
Input::Enter => self.close(),
|
|
_ => Ok(PolyStep::More),
|
|
}
|
|
}
|
|
fn close(&mut self) -> Result<PolyStep> {
|
|
if self.vs.len() < 3 {
|
|
return Err(EngineError::Other("A polygonal boundary needs at least three points.".into()));
|
|
}
|
|
if self.arc
|
|
&& let (Some(first), Some(last)) = (self.vs.first().map(|v| v.p), self.vs.last().map(|v| v.p))
|
|
{
|
|
let b = tangent_bulge(last, end_tangent(&self.vs), first).unwrap_or(0.0);
|
|
if let Some(v) = self.vs.last_mut() {
|
|
v.bulge = b;
|
|
}
|
|
}
|
|
Ok(PolyStep::Closed(std::mem::take(&mut self.vs)))
|
|
}
|
|
pub fn preview(&self, c: Vec2) -> Vec<EntityKind> {
|
|
if self.vs.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
let mut vs = self.vs.clone();
|
|
if self.arc
|
|
&& let (Some(last), Some(b)) = (vs.last().map(|v| v.p), vs.last().map(|v| v.p).and_then(|l| tangent_bulge(l, end_tangent(&self.vs), c)))
|
|
&& !last.near(c, 1e-12)
|
|
&& let Some(v) = vs.last_mut()
|
|
{
|
|
v.bulge = b;
|
|
}
|
|
vs.push(PolyVertex::new(c));
|
|
vec![lwpoly(vs, true)]
|
|
}
|
|
}
|
|
|
|
fn points_json(vs: &[PolyVertex]) -> Value {
|
|
json!({
|
|
"points": vs.iter().map(|v| [v.p.x, v.p.y]).collect::<Vec<_>>(),
|
|
"bulges": vs.iter().map(|v| v.bulge).collect::<Vec<_>>(),
|
|
})
|
|
}
|
|
|
|
pub(super) fn with(p: Value, key: &str, v: Value) -> Value {
|
|
let mut p = p;
|
|
if let Some(o) = p.as_object_mut() {
|
|
o.insert(key.into(), v);
|
|
}
|
|
p
|
|
}
|
|
|
|
fn handles_json(hs: &[Handle]) -> Value {
|
|
Value::Array(hs.iter().map(|h| json!(h.hex())).collect())
|
|
}
|
|
|
|
/// Viewports picked at a "Select viewports" prompt.
|
|
fn pick_viewports(s: &mut Session, i: &Input, picked: &mut Vec<Handle>) {
|
|
let found: Vec<Handle> = match i {
|
|
Input::Pick(hs) => s.doc().map(|d| viewports_in(d, &s.space(), hs)).unwrap_or_default(),
|
|
Input::Point(p) => any_viewport_at(s, *p).into_iter().collect(),
|
|
_ => Vec::new(),
|
|
};
|
|
let before = picked.len();
|
|
for h in found {
|
|
if !picked.contains(&h) {
|
|
picked.push(h);
|
|
}
|
|
}
|
|
if picked.len() > before {
|
|
s.echo(format!("{} viewport(s) selected.", picked.len()));
|
|
} else {
|
|
s.echo("No viewport found there.");
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
enum Stage {
|
|
/// "Specify corner of viewport or [ON/OFF/Fit/Lock/Object/Polygonal/Restore/2/3/4]".
|
|
#[default]
|
|
Corner,
|
|
/// 2 or 3 viewports: their arrangement.
|
|
Arrange,
|
|
Opposite(Vec2),
|
|
Polygon(PolygonInput),
|
|
Object,
|
|
Lock,
|
|
/// Restore: the configuration's name, then where its viewports go.
|
|
RestoreName,
|
|
RestoreCorner(String),
|
|
RestoreOpposite(String, Vec2),
|
|
/// ON/OFF (Some(on)) or Lock (None, with the chosen state) on picked viewports.
|
|
Select {
|
|
on: Option<bool>,
|
|
locked: bool,
|
|
picked: Vec<Handle>,
|
|
},
|
|
}
|
|
|
|
/// MVIEW / VPORTS prompts.
|
|
pub(super) struct MviewM {
|
|
count: usize,
|
|
/// Offer MVIEW's options (MVIEW) rather than a fixed count (View > Viewports > n).
|
|
choose: bool,
|
|
arrangement: String,
|
|
stage: Stage,
|
|
}
|
|
|
|
impl MviewM {
|
|
pub fn new(count: usize, choose: bool) -> Self {
|
|
MviewM { count, choose, arrangement: String::new(), stage: Stage::default() }
|
|
}
|
|
/// View > Viewports > Polygonal Viewport: straight to the polygon's points.
|
|
pub fn polygonal() -> Self {
|
|
MviewM { stage: Stage::Polygon(PolygonInput::default()), ..MviewM::new(1, true) }
|
|
}
|
|
fn create(&self, s: &mut Session, rect: Option<Bounds2>) -> Result<Step> {
|
|
let mut p = json!({ "count": self.count, "arrangement": self.arrangement });
|
|
if let Some(r) = rect {
|
|
p = with(p, "p1", json!([r.min.x, r.min.y]));
|
|
p = with(p, "p2", json!([r.max.x, r.max.y]));
|
|
}
|
|
let r = mview(s, &p, self.count, "mview")?;
|
|
let n = r.get("viewports").and_then(Value::as_array).map(Vec::len).unwrap_or(0);
|
|
s.echo(format!("{n} viewport(s) created, zoomed to the model extents."));
|
|
Ok(Step::Done)
|
|
}
|
|
/// MVIEW Restore with the typed name, over `rect` (None: Fit).
|
|
fn restore(s: &mut Session, name: &str, rect: Option<Bounds2>) -> Result<Step> {
|
|
let mut p = json!({ "option": "restore", "name": name });
|
|
if let Some(r) = rect {
|
|
p = with(p, "p1", json!([r.min.x, r.min.y]));
|
|
p = with(p, "p2", json!([r.max.x, r.max.y]));
|
|
}
|
|
let r = mview_restore(s, &p)?;
|
|
let n = r.get("viewports").and_then(Value::as_array).map(Vec::len).unwrap_or(0);
|
|
s.echo(format!("{n} viewport(s) restored from \"{name}\"."));
|
|
Ok(Step::Done)
|
|
}
|
|
fn corner_keyword(&mut self, s: &mut Session, k: &str) -> Result<Step> {
|
|
if k == "fit" || k == "f" {
|
|
return self.create(s, None);
|
|
}
|
|
if !self.choose {
|
|
return Err(EngineError::Other("Requires a point or option keyword.".into()));
|
|
}
|
|
self.stage = match k {
|
|
n @ ("2" | "3" | "4") => {
|
|
self.count = n.parse().unwrap_or(1);
|
|
if self.count == 4 { Stage::Corner } else { Stage::Arrange }
|
|
}
|
|
"on" => Stage::Select { on: Some(true), locked: false, picked: Vec::new() },
|
|
"off" => Stage::Select { on: Some(false), locked: false, picked: Vec::new() },
|
|
"lock" | "l" => Stage::Lock,
|
|
"object" | "o" => Stage::Object,
|
|
"polygonal" | "p" => Stage::Polygon(PolygonInput::default()),
|
|
"restore" | "r" => Stage::RestoreName,
|
|
_ => return Err(EngineError::Other("Requires a point or option keyword.".into())),
|
|
};
|
|
Ok(Step::Continue)
|
|
}
|
|
}
|
|
|
|
impl Interactive for MviewM {
|
|
fn name(&self) -> &'static str {
|
|
"MVIEW"
|
|
}
|
|
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
|
if s.space() == Space::Model && s.state()?.mspace.is_none() {
|
|
s.echo("** Command not allowed in Model Tab ** (switch to a layout first)");
|
|
return Ok(Step::Done);
|
|
}
|
|
// MVIEW works on the sheet.
|
|
if s.state()?.mspace.is_some() {
|
|
s.state_mut()?.mspace = None;
|
|
}
|
|
Ok(Step::Continue)
|
|
}
|
|
fn prompt(&self, _s: &Session) -> Prompt {
|
|
match &self.stage {
|
|
Stage::Corner if self.choose && self.count == 1 => Prompt::new("Specify corner of viewport", Accept::POINT)
|
|
.kw(&["ON", "OFF", "Fit", "Lock", "Object", "Polygonal", "Restore", "2", "3", "4"])
|
|
.default("Fit"),
|
|
Stage::Corner => Prompt::new("Specify first corner", Accept::POINT).kw(&["Fit"]).default("Fit"),
|
|
Stage::Arrange if self.count == 2 => {
|
|
Prompt::new("Enter viewport arrangement", curves::KW).kw(&["Horizontal", "Vertical"]).default("Vertical")
|
|
}
|
|
Stage::Arrange => Prompt::new("Enter viewport arrangement", curves::KW)
|
|
.kw(&["Horizontal", "Vertical", "Above", "Below", "Left", "Right"])
|
|
.default("Right"),
|
|
Stage::Opposite(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(*a),
|
|
Stage::Polygon(pi) => pi.prompt(),
|
|
Stage::Object => Prompt::new("Select object to clip viewport", Accept::POINT),
|
|
Stage::Lock => Prompt::new("Lock the view of viewports?", curves::KW).kw(&["ON", "OFF"]),
|
|
Stage::RestoreName => Prompt::new("Enter viewport configuration name", Accept::TEXT).kw(&["?"]).default(VportConfig::ACTIVE),
|
|
Stage::RestoreCorner(_) => Prompt::new("Specify first corner", Accept::POINT).kw(&["Fit"]).default("Fit"),
|
|
Stage::RestoreOpposite(_, a) => Prompt::new("Specify opposite corner", Accept::POINT).base(*a),
|
|
Stage::Select { .. } => Prompt::new("Select viewports", Accept::POINT),
|
|
}
|
|
}
|
|
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
|
match std::mem::take(&mut self.stage) {
|
|
Stage::Corner => match i {
|
|
Input::Enter => self.create(s, None),
|
|
Input::Keyword(k) | Input::Text(k) => self.corner_keyword(s, &k.trim().to_ascii_lowercase()),
|
|
Input::Point(p) => {
|
|
self.stage = Stage::Opposite(p);
|
|
Ok(Step::Continue)
|
|
}
|
|
_ => Ok(Step::Continue),
|
|
},
|
|
Stage::Arrange => {
|
|
let a = match &i {
|
|
Input::Keyword(k) | Input::Text(k) => k.trim().to_ascii_lowercase(),
|
|
_ => String::new(),
|
|
};
|
|
let ok = match self.count {
|
|
2 => ["", "h", "horizontal", "v", "vertical"].contains(&a.as_str()),
|
|
_ => ["", "h", "horizontal", "v", "vertical", "a", "above", "b", "below", "l", "left", "r", "right"].contains(&a.as_str()),
|
|
};
|
|
if !ok || !matches!(i, Input::Keyword(_) | Input::Text(_) | Input::Enter) {
|
|
self.stage = Stage::Arrange;
|
|
return Err(EngineError::Other("Invalid option keyword.".into()));
|
|
}
|
|
self.arrangement = a;
|
|
Ok(Step::Continue)
|
|
}
|
|
Stage::Opposite(a) => match i {
|
|
Input::Point(b) => self.create(s, Some(Bounds2::new(a, b))),
|
|
_ => {
|
|
self.stage = Stage::Opposite(a);
|
|
Ok(Step::Continue)
|
|
}
|
|
},
|
|
Stage::Polygon(mut pi) => match pi.input(i) {
|
|
Ok(PolyStep::More) => {
|
|
self.stage = Stage::Polygon(pi);
|
|
Ok(Step::Continue)
|
|
}
|
|
Ok(PolyStep::Closed(vs)) => {
|
|
mview_polygonal(s, &points_json(&vs))?;
|
|
s.echo("Viewport created, zoomed to the model extents.");
|
|
Ok(Step::Done)
|
|
}
|
|
Err(e) => {
|
|
self.stage = Stage::Polygon(pi);
|
|
Err(e)
|
|
}
|
|
},
|
|
Stage::Object => match i {
|
|
Input::Point(p) => {
|
|
let Some(h) = curves::pick_at(s, p) else {
|
|
self.stage = Stage::Object;
|
|
s.echo("No object found.");
|
|
return Ok(Step::Continue);
|
|
};
|
|
if let Err(e) = mview_object(s, &json!({ "object": h.hex() })) {
|
|
self.stage = Stage::Object;
|
|
return Err(e);
|
|
}
|
|
s.echo("Viewport created, zoomed to the model extents.");
|
|
Ok(Step::Done)
|
|
}
|
|
Input::Pick(hs) if hs.len() == 1 => {
|
|
let h = hs.first().copied().unwrap_or_default();
|
|
mview_object(s, &json!({ "object": h.hex() }))?;
|
|
Ok(Step::Done)
|
|
}
|
|
_ => {
|
|
self.stage = Stage::Object;
|
|
Ok(Step::Continue)
|
|
}
|
|
},
|
|
Stage::Lock => {
|
|
let k = match &i {
|
|
Input::Keyword(k) | Input::Text(k) => k.trim().to_ascii_lowercase(),
|
|
_ => String::new(),
|
|
};
|
|
self.stage = match k.as_str() {
|
|
"on" => Stage::Select { on: None, locked: true, picked: Vec::new() },
|
|
"off" => Stage::Select { on: None, locked: false, picked: Vec::new() },
|
|
_ => {
|
|
self.stage = Stage::Lock;
|
|
return Err(EngineError::Other("Invalid option keyword.".into()));
|
|
}
|
|
};
|
|
Ok(Step::Continue)
|
|
}
|
|
Stage::RestoreName => {
|
|
let name = match &i {
|
|
Input::Enter => VportConfig::ACTIVE.to_string(),
|
|
Input::Text(t) | Input::Keyword(t) if !t.trim().is_empty() => t.trim().to_string(),
|
|
_ => {
|
|
self.stage = Stage::RestoreName;
|
|
return Ok(Step::Continue);
|
|
}
|
|
};
|
|
self.stage = Stage::RestoreName;
|
|
if name == "?" {
|
|
let names = super::super::view::config_names(s);
|
|
s.echo(if names.is_empty() { "No saved viewport configurations.".to_string() } else { names.join(", ") });
|
|
return Ok(Step::Continue);
|
|
}
|
|
if super::super::view::config_tiles(s, &name).is_none() {
|
|
return Err(EngineError::Other(format!(
|
|
"Could not find viewport configuration \"{}\".",
|
|
name.chars().take(64).collect::<String>()
|
|
)));
|
|
}
|
|
self.stage = Stage::RestoreCorner(name);
|
|
Ok(Step::Continue)
|
|
}
|
|
Stage::RestoreCorner(name) => match i {
|
|
Input::Enter => Self::restore(s, &name, None),
|
|
Input::Keyword(k) | Input::Text(k) if matches!(k.trim().to_ascii_lowercase().as_str(), "fit" | "f") => Self::restore(s, &name, None),
|
|
Input::Point(a) => {
|
|
self.stage = Stage::RestoreOpposite(name, a);
|
|
Ok(Step::Continue)
|
|
}
|
|
Input::Keyword(_) | Input::Text(_) => {
|
|
self.stage = Stage::RestoreCorner(name);
|
|
Err(EngineError::Other("Requires a point or option keyword.".into()))
|
|
}
|
|
_ => {
|
|
self.stage = Stage::RestoreCorner(name);
|
|
Ok(Step::Continue)
|
|
}
|
|
},
|
|
Stage::RestoreOpposite(name, a) => match i {
|
|
Input::Point(b) => Self::restore(s, &name, Some(Bounds2::new(a, b))),
|
|
_ => {
|
|
self.stage = Stage::RestoreOpposite(name, a);
|
|
Ok(Step::Continue)
|
|
}
|
|
},
|
|
Stage::Select { on, locked, mut picked } => match i {
|
|
Input::Enter => {
|
|
if picked.is_empty() {
|
|
return Ok(Step::Done);
|
|
}
|
|
let p = json!({ "handles": handles_json(&picked) });
|
|
match on {
|
|
Some(on) => mview_on(s, &p, on)?,
|
|
None => mview_lock(s, &with(p, "locked", json!(locked)))?,
|
|
};
|
|
Ok(Step::Done)
|
|
}
|
|
other => {
|
|
pick_viewports(s, &other, &mut picked);
|
|
self.stage = Stage::Select { on, locked, picked };
|
|
Ok(Step::Continue)
|
|
}
|
|
},
|
|
}
|
|
}
|
|
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
|
match &self.stage {
|
|
Stage::Opposite(a) => {
|
|
split(Bounds2::new(*a, c), self.count, &self.arrangement).into_iter().map(|r| lwpoly(rect_vertices(r.min, r.max), true)).collect()
|
|
}
|
|
Stage::Polygon(pi) => pi.preview(c),
|
|
Stage::RestoreOpposite(name, a) => {
|
|
let r = Bounds2::new(*a, c);
|
|
super::super::view::config_tiles(s, name)
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.take(MAX_RESTORED)
|
|
.filter_map(|t| tile_rect(t, r))
|
|
.map(|r| lwpoly(rect_vertices(r.min, r.max), true))
|
|
.collect()
|
|
}
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
enum ClipStage {
|
|
#[default]
|
|
Viewport,
|
|
/// "Select clipping object or [Polygonal/Delete] <Polygonal>".
|
|
Clip(Handle),
|
|
Polygon(Handle, PolygonInput),
|
|
}
|
|
|
|
/// VPCLIP typed at the command line.
|
|
#[derive(Default)]
|
|
pub(super) struct VpclipM {
|
|
stage: ClipStage,
|
|
}
|
|
|
|
impl Interactive for VpclipM {
|
|
fn name(&self) -> &'static str {
|
|
"VPCLIP"
|
|
}
|
|
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
|
if s.layout_space() == Space::Model {
|
|
s.echo("** Command not allowed in Model Tab ** (switch to a layout first)");
|
|
return Ok(Step::Done);
|
|
}
|
|
// A selected viewport (or the active one) is the one to clip.
|
|
let pre = s.doc().map(|d| viewports_in(d, &s.layout_space(), &s.selection())).unwrap_or_default();
|
|
if let Some(h) = pre.first().copied().or_else(|| s.state().ok().and_then(|st| st.active_viewport()).map(|(h, _)| h)) {
|
|
self.stage = ClipStage::Clip(h);
|
|
s.state_mut()?.mspace = None;
|
|
}
|
|
Ok(Step::Continue)
|
|
}
|
|
fn prompt(&self, s: &Session) -> Prompt {
|
|
match &self.stage {
|
|
ClipStage::Viewport => Prompt::new("Select viewport to clip", Accept::POINT),
|
|
ClipStage::Clip(h) => {
|
|
let clipped = s.doc().ok().and_then(|d| d.entity(*h)).is_some_and(|e| matches!(&e.kind, EntityKind::Viewport(v) if v.clip.is_some()));
|
|
let kws: &[&str] = if clipped { &["Polygonal", "Delete"] } else { &["Polygonal"] };
|
|
Prompt::new("Select clipping object", Accept::POINT).kw(kws).default("Polygonal")
|
|
}
|
|
ClipStage::Polygon(_, pi) => pi.prompt(),
|
|
}
|
|
}
|
|
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
|
match std::mem::take(&mut self.stage) {
|
|
ClipStage::Viewport => {
|
|
let h = match &i {
|
|
Input::Point(p) => viewport_at(s, *p),
|
|
Input::Pick(hs) => s.doc().ok().and_then(|d| viewports_in(d, &s.layout_space(), hs).first().copied()),
|
|
_ => None,
|
|
};
|
|
match h {
|
|
Some(h) => self.stage = ClipStage::Clip(h),
|
|
None => {
|
|
if matches!(i, Input::Point(_) | Input::Pick(_)) {
|
|
s.echo("No viewport found there.");
|
|
}
|
|
if matches!(i, Input::Enter) {
|
|
return Ok(Step::Done);
|
|
}
|
|
}
|
|
}
|
|
Ok(Step::Continue)
|
|
}
|
|
ClipStage::Clip(vh) => {
|
|
let object = match &i {
|
|
Input::Point(p) => {
|
|
let Some(o) = curves::pick_at(s, *p) else {
|
|
self.stage = ClipStage::Clip(vh);
|
|
s.echo("No object found.");
|
|
return Ok(Step::Continue);
|
|
};
|
|
o
|
|
}
|
|
Input::Pick(hs) if hs.len() == 1 => hs.first().copied().unwrap_or_default(),
|
|
Input::Enter => {
|
|
self.stage = ClipStage::Polygon(vh, PolygonInput::default());
|
|
return Ok(Step::Continue);
|
|
}
|
|
Input::Keyword(k) | Input::Text(k) => match k.trim().to_ascii_lowercase().as_str() {
|
|
"polygonal" | "p" => {
|
|
self.stage = ClipStage::Polygon(vh, PolygonInput::default());
|
|
return Ok(Step::Continue);
|
|
}
|
|
"delete" | "d" => {
|
|
run_vpclip(s, &json!({ "handle": vh.hex(), "delete": true }))?;
|
|
s.echo("Clipping boundary deleted.");
|
|
return Ok(Step::Done);
|
|
}
|
|
_ => {
|
|
self.stage = ClipStage::Clip(vh);
|
|
return Err(EngineError::Other("Requires a point or option keyword.".into()));
|
|
}
|
|
},
|
|
_ => {
|
|
self.stage = ClipStage::Clip(vh);
|
|
return Ok(Step::Continue);
|
|
}
|
|
};
|
|
if let Err(e) = run_vpclip(s, &json!({ "handle": vh.hex(), "object": object.hex() })) {
|
|
self.stage = ClipStage::Clip(vh);
|
|
return Err(e);
|
|
}
|
|
Ok(Step::Done)
|
|
}
|
|
ClipStage::Polygon(vh, mut pi) => match pi.input(i) {
|
|
Ok(PolyStep::More) => {
|
|
self.stage = ClipStage::Polygon(vh, pi);
|
|
Ok(Step::Continue)
|
|
}
|
|
Ok(PolyStep::Closed(vs)) => {
|
|
run_vpclip(s, &with(points_json(&vs), "handle", json!(vh.hex())))?;
|
|
Ok(Step::Done)
|
|
}
|
|
Err(e) => {
|
|
self.stage = ClipStage::Polygon(vh, pi);
|
|
Err(e)
|
|
}
|
|
},
|
|
}
|
|
}
|
|
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
|
match &self.stage {
|
|
ClipStage::Polygon(_, pi) => pi.preview(c),
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
}
|