mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-24 10:56:51 -06:00
Viewports: polygonal, object and clipped viewports, VPCLIP, MVIEW ON/OFF/Lock/Restore (part of #453) (#458)
Squash of PR #458 by @vangel76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d6aa802c75
commit
61a8a5f984
@@ -569,6 +569,30 @@ pub struct Viewport {
|
||||
/// Per-viewport layer colour overrides (VP Color in the Layer Properties Manager).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub layer_colors: Vec<(String, Color)>,
|
||||
/// The paper-space entity whose outline clips the viewport (a closed polyline, circle,
|
||||
/// ellipse or spline; DXF group 340). `None`: the rectangle `center`/`width`/`height`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub clip: Option<Handle>,
|
||||
/// Display on (MVIEW ON/OFF). An off viewport shows its border but no model space.
|
||||
#[serde(default = "yes")]
|
||||
pub on: bool,
|
||||
}
|
||||
impl Default for Viewport {
|
||||
fn default() -> Self {
|
||||
Viewport {
|
||||
center: Vec3::ZERO,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
view_center: Vec2::ZERO,
|
||||
view_height: 1.0,
|
||||
id: 0,
|
||||
locked: false,
|
||||
frozen_layers: Vec::new(),
|
||||
layer_colors: Vec::new(),
|
||||
clip: None,
|
||||
on: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -508,6 +508,18 @@ fn references(d: &mut Drawing, out: &mut Vec<Issue>) {
|
||||
"those entries removed",
|
||||
));
|
||||
}
|
||||
if let Some(b) = vp.clip
|
||||
&& (b == e.handle || !all.contains(&b))
|
||||
{
|
||||
vp.clip = None;
|
||||
out.push(issue(
|
||||
"danglingReference",
|
||||
&label,
|
||||
Some(e.handle),
|
||||
"viewport's clip boundary doesn't exist".into(),
|
||||
"the viewport is rectangular again",
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
+24
-104
@@ -11,9 +11,11 @@ use serde_json::{Value, json};
|
||||
use super::file::{base64_encode, io};
|
||||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
use viewports::MviewM;
|
||||
|
||||
mod plotstyle;
|
||||
mod prompts;
|
||||
mod viewports;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -31,8 +33,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("layout.list", "List Layouts", run_list).params("→ [{name, tabOrder, page, viewports}]").noundo(),
|
||||
CommandSpec::new("mview", "Viewports", run_mview)
|
||||
.alias(&["mv"])
|
||||
.params("{p1?, p2? (default: printable area), count?: 1-4, arrangement?: vertical|horizontal|left|right|above|below, layout?}")
|
||||
.params(
|
||||
"{option?: fit|polygonal|object|on|off|lock|restore, p1?, p2? (default: printable area), count?: 1-4, arrangement?: vertical|horizontal|left|right|above|below, points?: [[x,y]…] + bulges? (polygonal), object? (closed object to clip by), handles? + locked? (on/off/lock), layout?}",
|
||||
)
|
||||
.interactive(|_| Ok(Box::new(MviewM::new(1, true)))),
|
||||
CommandSpec::new("mview.polygonal", "Polygonal Viewport", |s, p| run_mview(s, &viewports::with(p.clone(), "option", json!("polygonal"))))
|
||||
.menu(&["View", "Viewports", "Polygonal Viewport"])
|
||||
.params("{points: [[x,y]…] (at least three), bulges?, layout?}")
|
||||
.interactive(|_| Ok(Box::new(MviewM::polygonal()))),
|
||||
CommandSpec::new("vpclip", "Clip Viewport", viewports::run_vpclip)
|
||||
.menu(&["Modify", "Clip", "Viewport"])
|
||||
.params("{handle?: viewport (default: selected or active), object?: closed object | points?: [[x,y]…] + bulges?, delete?: bool, layout?}")
|
||||
.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?}")
|
||||
@@ -97,7 +109,7 @@ fn layout_viewports(s: &Session) -> Result<Vec<(Handle, Viewport)>> {
|
||||
.map(|st| {
|
||||
st.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Viewport(v) if v.id != 1 && v.height > 1e-12 && v.view_height > 1e-12 => Some((e.handle, v.clone())),
|
||||
EntityKind::Viewport(v) if v.id != 1 && v.on && v.height > 1e-12 && v.view_height > 1e-12 => Some((e.handle, v.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
@@ -112,11 +124,7 @@ fn run_mspace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pick = if let Some(h) = want {
|
||||
vps.iter().find(|(x, _)| *x == h).map(|(h, _)| *h).ok_or_else(|| bad("mspace", "`handle` is not a viewport in this layout"))?
|
||||
} else if let Some(a) = at {
|
||||
vps.iter()
|
||||
.rev()
|
||||
.find(|(_, v)| (a.x - v.center.x).abs() <= v.width / 2.0 && (a.y - v.center.y).abs() <= v.height / 2.0)
|
||||
.map(|(h, _)| *h)
|
||||
.ok_or_else(|| bad("mspace", "no viewport at that point"))?
|
||||
viewports::viewport_at(s, a).ok_or_else(|| bad("mspace", "no viewport at that point"))?
|
||||
} else {
|
||||
let last = s.state()?.mspace;
|
||||
match last.filter(|h| vps.iter().any(|(x, _)| x == h)).or_else(|| vps.first().map(|(h, _)| *h)) {
|
||||
@@ -424,31 +432,10 @@ fn add_viewports(s: &mut Session, space: &Space, rect: Bounds2, count: usize, ar
|
||||
}
|
||||
let common = s.current_common();
|
||||
let d = s.doc_mut()?;
|
||||
let mut id = 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);
|
||||
let mut out = Vec::new();
|
||||
// Layers marked "New VP Freeze" start frozen in new viewports.
|
||||
let vp_frozen: Vec<String> = d.layers.iter().filter(|l| l.vp_freeze_new).map(|l| l.name.clone()).collect();
|
||||
for r in split(rect, count, arrangement) {
|
||||
let (view_center, view_height) = fit_view(d, r.width(), r.height());
|
||||
id = id.saturating_add(1);
|
||||
let vp = Viewport {
|
||||
center: r.center().to3(0.0),
|
||||
width: r.width(),
|
||||
height: r.height(),
|
||||
view_center,
|
||||
view_height,
|
||||
id,
|
||||
locked: false,
|
||||
frozen_layers: vp_frozen.clone(),
|
||||
layer_colors: Vec::new(),
|
||||
};
|
||||
let id = viewports::next_viewport_id(d);
|
||||
let vp = viewports::new_viewport(d, r, id);
|
||||
out.push(d.add(space, common.clone(), EntityKind::Viewport(vp))?);
|
||||
}
|
||||
Ok(out)
|
||||
@@ -467,6 +454,9 @@ fn mview(s: &mut Session, p: &Value, count: usize, cmd: &str) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn run_mview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(r) = viewports::mview_option(s, p) {
|
||||
return r;
|
||||
}
|
||||
let count = match p.get("count") {
|
||||
None | Some(Value::Null) => 1,
|
||||
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad("mview", "count must be 1, 2, 3 or 4"))?,
|
||||
@@ -478,79 +468,6 @@ fn run_mview_n(s: &mut Session, p: &Value, n: usize) -> Result<Value> {
|
||||
mview(s, p, n, "vports")
|
||||
}
|
||||
|
||||
/// MVIEW / VPORTS prompts: first corner (or Fit, or a viewport count), opposite corner.
|
||||
struct MviewM {
|
||||
count: usize,
|
||||
/// Offer the 2/3/4 keywords (MVIEW) rather than a fixed count (View > Viewports > n).
|
||||
choose: bool,
|
||||
first: Option<Vec2>,
|
||||
}
|
||||
|
||||
impl MviewM {
|
||||
fn new(count: usize, choose: bool) -> Self {
|
||||
MviewM { count, choose, first: None }
|
||||
}
|
||||
fn create(&self, s: &mut Session, rect: Option<Bounds2>) -> Result<Step> {
|
||||
let mut p = json!({ "count": self.count });
|
||||
if let (Some(r), Some(o)) = (rect, p.as_object_mut()) {
|
||||
o.insert("p1".into(), json!([r.min.x, r.min.y]));
|
||||
o.insert("p2".into(), 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)
|
||||
}
|
||||
}
|
||||
|
||||
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.echo("** Command not allowed in Model Tab ** (switch to a layout first)");
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.first {
|
||||
None if self.choose && self.count == 1 => {
|
||||
Prompt::new("Specify corner of viewport", Accept::POINT).kw(&["Fit", "2", "3", "4"]).default("Fit")
|
||||
}
|
||||
None => Prompt::new("Specify first corner", Accept::POINT).kw(&["Fit"]).default("Fit"),
|
||||
Some(a) => Prompt::new("Specify opposite corner", Accept::POINT).base(a),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (self.first, i) {
|
||||
(None, Input::Enter) => self.create(s, None),
|
||||
(None, Input::Keyword(k)) | (None, Input::Text(k)) => match k.trim().to_ascii_lowercase().as_str() {
|
||||
"fit" | "f" => self.create(s, None),
|
||||
n @ ("2" | "3" | "4") if self.choose => {
|
||||
self.count = n.parse().unwrap_or(1);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
_ => Err(crate::EngineError::Other("Requires a point or option keyword.".into())),
|
||||
},
|
||||
(None, Input::Point(p)) => {
|
||||
self.first = Some(p);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(a), Input::Point(b)) => self.create(s, Some(Bounds2::new(a, b))),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
let Some(a) = self.first else { return Vec::new() };
|
||||
split(Bounds2::new(a, c), self.count, "")
|
||||
.into_iter()
|
||||
.map(|r| super::helpers::lwpoly(super::helpers::rect_vertices(r.min, r.max), true))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A plot scale: a number (paper units per model unit) or `"a:b"` / `"a/b"`.
|
||||
fn parse_scale(v: &Value) -> Option<f64> {
|
||||
let s = match v {
|
||||
@@ -590,6 +507,8 @@ fn viewport_json(h: Handle, v: &Viewport) -> Value {
|
||||
"viewHeight": v.view_height,
|
||||
"scale": if v.view_height > 0.0 { v.height / v.view_height } else { 0.0 },
|
||||
"locked": v.locked,
|
||||
"on": v.on,
|
||||
"clip": v.clip.map(|h| h.hex()),
|
||||
"frozenLayers": v.frozen_layers,
|
||||
"layerColors": v.layer_colors.iter().map(|(l, c)| json!({ "layer": l, "color": c.name() })).collect::<Vec<_>>(),
|
||||
})
|
||||
@@ -982,10 +901,11 @@ mod tests {
|
||||
let v = viewports(&s, "Layout1");
|
||||
assert_eq!(v.len(), 1);
|
||||
assert!((v[0].1.width - 5.0).abs() < 1e-9 && (v[0].1.height - 3.0).abs() < 1e-9);
|
||||
// MVIEW 2 → Fit (Enter).
|
||||
// MVIEW 2 → arrangement (Enter: Vertical) → Fit (Enter).
|
||||
s.cmdline("mview").unwrap();
|
||||
s.cmdline("2").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(viewports(&s, "Layout1").len(), 3);
|
||||
// One undo step per MVIEW.
|
||||
|
||||
@@ -0,0 +1,814 @@
|
||||
//! Clipped viewports and MVIEW's options: polygonal and object viewports (MVIEW Polygonal /
|
||||
//! Object), VPCLIP, ON/OFF, Lock and Restore, 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};
|
||||
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, points_param, str_param, targets};
|
||||
use super::{fit_view, layout_viewports, mview, 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;
|
||||
|
||||
/// 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 }))
|
||||
}
|
||||
|
||||
/// 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" => Err(bad("mview", "named viewport configurations are not available yet")),
|
||||
"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,
|
||||
/// 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)
|
||||
}
|
||||
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" => return Err(EngineError::Other("Named viewport configurations are not available yet.".into())),
|
||||
_ => 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::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::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),
|
||||
_ => 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,12 +112,7 @@ fn viewport_at(s: &Session, p: Vec2) -> Option<Handle> {
|
||||
Some((_, vp)) => vp.center.xy() + (p - vp.view_center) * (vp.height / vp.view_height),
|
||||
None => p,
|
||||
};
|
||||
super::layout_viewports(s)
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|(_, v)| (at.x - v.center.x).abs() <= v.width / 2.0 && (at.y - v.center.y).abs() <= v.height / 2.0)
|
||||
.map(|(h, _)| h)
|
||||
super::viewports::viewport_at(s, at)
|
||||
}
|
||||
|
||||
fn active(s: &Session) -> Option<Handle> {
|
||||
|
||||
@@ -369,6 +369,8 @@ fn chspace_json_and_command_line() {
|
||||
locked: false,
|
||||
frozen_layers: Vec::new(),
|
||||
layer_colors: Vec::new(),
|
||||
clip: None,
|
||||
on: true,
|
||||
};
|
||||
s.doc_mut().unwrap().add(&Space::Paper("Layout1".into()), Default::default(), EntityKind::Viewport(vp)).unwrap();
|
||||
let r = s.execute("chspace", &json!({"handles": [l]})).unwrap();
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Clipped layout viewports and MVIEW's options (#453): MVIEW Polygonal / Object / ON / OFF /
|
||||
//! Lock / Restore / 2 / 3 / 4, VPCLIP, and model space drawn through a clip outline.
|
||||
|
||||
use cadcraft_doc::{EntityKind, Handle, Space, Viewport};
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn layout_viewports(s: &Session) -> Vec<(Handle, Viewport)> {
|
||||
let d = s.doc().unwrap();
|
||||
d.layout("Layout1")
|
||||
.unwrap()
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Viewport(v) if v.id != 1 => Some((e.handle, v.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn viewport(s: &Session, h: &str) -> Viewport {
|
||||
layout_viewports(s).into_iter().find(|(x, _)| x.hex() == h).map(|(_, v)| v).expect("viewport")
|
||||
}
|
||||
|
||||
/// A model of horizontal lines y = 0, 10, … 100 from x = 0 to 100, with Layout1 current and
|
||||
/// its default viewport erased.
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_drawing(true);
|
||||
s.viewport_px = (1600.0, 900.0);
|
||||
for i in 0..=10 {
|
||||
let y = f64::from(i) * 10.0;
|
||||
s.execute("line", &json!({"points": [[0, y], [100, y]]})).unwrap();
|
||||
}
|
||||
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
|
||||
let old: Vec<String> = layout_viewports(&s).iter().map(|(h, _)| h.hex()).collect();
|
||||
if !old.is_empty() {
|
||||
s.execute("erase", &json!({"handles": old})).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Model-space vertices drawn on Layout1 (through viewports).
|
||||
fn content(s: &Session) -> Vec<Vec2> {
|
||||
let d = s.doc().unwrap();
|
||||
let l = cadcraft_render::build(d, &Space::Paper("Layout1".into()), &cadcraft_render::Options::default());
|
||||
l.prims.iter().filter(|p| p.handle == cadcraft_render::VIEWPORT_CONTENT).flat_map(|p| l.points(p).to_vec()).collect()
|
||||
}
|
||||
|
||||
/// An L-shaped outline: 10..110 × 10..110 without the top-right quarter.
|
||||
const L: [[f64; 2]; 6] = [[10.0, 10.0], [110.0, 10.0], [110.0, 60.0], [60.0, 60.0], [60.0, 110.0], [10.0, 110.0]];
|
||||
|
||||
#[test]
|
||||
fn polygonal_viewport_clips_model_space() {
|
||||
let mut s = session();
|
||||
let r = s.execute("mview", &json!({"option": "polygonal", "points": L})).unwrap();
|
||||
let vh = r["viewport"].as_str().unwrap().to_string();
|
||||
let v = viewport(&s, &vh);
|
||||
// The rectangle is the outline's extents; the boundary is a closed polyline on the sheet.
|
||||
assert!(v.center.xy().near(Vec2::new(60.0, 60.0), 1e-9) && (v.width - 100.0).abs() < 1e-9 && (v.height - 100.0).abs() < 1e-9);
|
||||
let b = v.clip.expect("clipped");
|
||||
assert_eq!(b.hex(), r["boundary"].as_str().unwrap());
|
||||
assert!(matches!(s.doc().unwrap().entity(b).map(|e| &e.kind), Some(EntityKind::LwPolyline(p)) if p.closed));
|
||||
// Model space shows through the L only: nothing in the cut-out quarter.
|
||||
let pts = content(&s);
|
||||
assert!(!pts.is_empty());
|
||||
assert!(pts.iter().all(|p| !(p.x > 60.0 + 1e-6 && p.y > 60.0 + 1e-6)), "content in the notch");
|
||||
assert!(pts.iter().any(|p| p.x > 100.0 && p.y < 60.0), "content in the lower right");
|
||||
// No rectangle border is drawn for a clipped viewport (its boundary draws itself).
|
||||
let d = s.doc().unwrap();
|
||||
let l = cadcraft_render::build(d, &Space::Paper("Layout1".into()), &cadcraft_render::Options::default());
|
||||
assert!(l.prims.iter().all(|p| p.handle.hex() != vh));
|
||||
// MSPACE at a point in the notch finds no viewport; inside the L it does.
|
||||
assert!(s.execute("mspace", &json!({"at": [100, 100]})).is_err());
|
||||
s.execute("mspace", &json!({"at": [20, 100]})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_polygonal_with_arc_and_object() {
|
||||
let mut s = session();
|
||||
s.cmdline("mview p 10,10 100,10 a 100,60 l 10,60 c").unwrap();
|
||||
let vps = layout_viewports(&s);
|
||||
assert_eq!(vps.len(), 1, "{:?}", s.log);
|
||||
let (_, v) = &vps[0];
|
||||
let Some(EntityKind::LwPolyline(p)) = s.doc().unwrap().entity(v.clip.unwrap()).map(|e| e.kind.clone()) else { panic!() };
|
||||
assert_eq!(p.vertices.len(), 4);
|
||||
// The arc from 100,10 to 100,60 leaves along +x: a half circle bulging right.
|
||||
assert!((p.vertices[1].bulge.abs() - 1.0).abs() < 1e-9, "{:?}", p.vertices[1]);
|
||||
assert!(v.width > 110.0, "the arc widens the extents: {}", v.width);
|
||||
|
||||
// Object: a circle on the sheet becomes a circular viewport.
|
||||
s.execute("circle", &json!({"center": [200, 60], "radius": 30})).unwrap();
|
||||
let c = s.state().unwrap().doc.layout("Layout1").unwrap().entities.iter().last().unwrap().handle;
|
||||
s.cmdline("mview o 230,60").unwrap();
|
||||
let vps = layout_viewports(&s);
|
||||
assert_eq!(vps.len(), 2);
|
||||
let v = &vps[1].1;
|
||||
assert_eq!(v.clip, Some(c));
|
||||
assert!(v.center.xy().near(Vec2::new(200.0, 60.0), 1e-6) && (v.width - 60.0).abs() < 1e-6);
|
||||
// An open object or one already used is refused.
|
||||
s.execute("line", &json!({"points": [[0, 150], [50, 150]]})).unwrap();
|
||||
let line = s.state().unwrap().doc.layout("Layout1").unwrap().entities.iter().last().unwrap().handle;
|
||||
assert!(s.execute("mview", &json!({"option": "object", "object": line.hex()})).is_err());
|
||||
assert!(s.execute("mview", &json!({"option": "object", "object": c.hex()})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vpclip_clips_keeps_the_view_and_deletes() {
|
||||
let mut s = session();
|
||||
let r = s.execute("mview", &json!({"p1": [0, 0], "p2": [200, 100]})).unwrap();
|
||||
let vh = r["viewports"][0].as_str().unwrap().to_string();
|
||||
s.execute("viewport.set", &json!({"handle": vh, "scale": 1, "center": [100, 50]})).unwrap();
|
||||
// Model 0,0 is at paper 0,0 before clipping.
|
||||
let before = viewport(&s, &vh);
|
||||
let paper_of = |v: &Viewport, m: Vec2| v.center.xy() + (m - v.view_center) * (v.height / v.view_height);
|
||||
let r = s.execute("vpclip", &json!({"handle": vh, "points": [[20, 20], [80, 20], [50, 90]]})).unwrap();
|
||||
let after = viewport(&s, &vh);
|
||||
assert_eq!(after.clip.unwrap().hex(), r["clip"].as_str().unwrap());
|
||||
for m in [Vec2::ZERO, Vec2::new(50.0, 50.0)] {
|
||||
assert!(paper_of(&before, m).near(paper_of(&after, m), 1e-9), "the model stays put on the sheet");
|
||||
}
|
||||
assert!((after.width - 60.0).abs() < 1e-9 && (after.height - 70.0).abs() < 1e-9);
|
||||
let pts = content(&s);
|
||||
assert!(!pts.is_empty() && pts.iter().all(|p| p.y >= 20.0 - 1e-6 && p.y <= 90.0 + 1e-6 && p.x >= 20.0 - 1e-6 && p.x <= 80.0 + 1e-6));
|
||||
|
||||
// Re-clipping with an object replaces (and erases) the old outline.
|
||||
let old = after.clip.unwrap();
|
||||
s.execute("circle", &json!({"center": [50, 45], "radius": 15})).unwrap();
|
||||
let c = s.state().unwrap().doc.layout("Layout1").unwrap().entities.iter().last().unwrap().handle;
|
||||
// Typed: pick the viewport (inside its outline), then the circle.
|
||||
s.cmdline("vpclip 50,50 35,45").unwrap();
|
||||
assert_eq!(viewport(&s, &vh).clip, Some(c), "{:?}", s.log);
|
||||
assert!(s.doc().unwrap().entity(old).is_none());
|
||||
// Delete: back to a rectangle; the outline goes.
|
||||
let b = viewport(&s, &vh).clip.unwrap();
|
||||
s.execute("vpclip", &json!({"handle": vh, "delete": true})).unwrap();
|
||||
assert_eq!(viewport(&s, &vh).clip, None);
|
||||
assert!(s.doc().unwrap().entity(b).is_none());
|
||||
assert!(s.execute("vpclip", &json!({"handle": vh, "delete": true})).is_err());
|
||||
// Typed: select the viewport, then Polygonal points.
|
||||
s.cmdline("vpclip 50,45 p 10,10 90,10 90,90 c").unwrap();
|
||||
assert!(viewport(&s, &vh).clip.is_some(), "{:?}", s.log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_off_lock_restore_and_arrangements() {
|
||||
let mut s = session();
|
||||
let r = s.execute("mview", &json!({"p1": [0, 0], "p2": [100, 100]})).unwrap();
|
||||
let vh = r["viewports"][0].as_str().unwrap().to_string();
|
||||
assert!(!content(&s).is_empty());
|
||||
s.execute("mview", &json!({"option": "off", "handles": [vh]})).unwrap();
|
||||
assert!(!viewport(&s, &vh).on);
|
||||
assert!(content(&s).is_empty(), "an off viewport shows no model space");
|
||||
assert!(s.execute("mspace", &json!({"handle": vh})).is_err());
|
||||
s.cmdline("mview on 0,50").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(viewport(&s, &vh).on, "{:?}", s.log);
|
||||
// Lock, typed and JSON.
|
||||
s.cmdline("mview l on 50,50").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(viewport(&s, &vh).locked, "{:?}", s.log);
|
||||
s.execute("mview", &json!({"option": "lock", "locked": false, "handles": [vh]})).unwrap();
|
||||
assert!(!viewport(&s, &vh).locked);
|
||||
// Restore needs named viewport configurations.
|
||||
assert!(s.execute("mview", &json!({"option": "restore"})).is_err());
|
||||
// 3 viewports arranged Above: the large one on top.
|
||||
s.cmdline("mview 3 a 200,0 300,100").unwrap();
|
||||
let vps = layout_viewports(&s);
|
||||
assert_eq!(vps.len(), 4, "{:?}", s.log);
|
||||
let top = &vps[1].1;
|
||||
assert!((top.width - 100.0).abs() < 1e-9 && (top.center.y - 75.0).abs() < 1e-9, "{top:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_viewport_params_never_panic() {
|
||||
let mut s = session();
|
||||
let r = s.execute("mview", &json!({"p1": [0, 0], "p2": [100, 100]})).unwrap();
|
||||
let vh = r["viewports"][0].clone();
|
||||
let big: Vec<[f64; 2]> = (0..20_000).map(|i| [f64::from(i), 0.0]).collect();
|
||||
let cases: Vec<Value> = vec![
|
||||
json!({"option": "polygonal"}),
|
||||
json!({"option": "polygonal", "points": [[0, 0], [1, 1]]}),
|
||||
json!({"option": "polygonal", "points": [[0, 0], [1, 1], [2, 2]]}),
|
||||
json!({"option": "polygonal", "points": [[0, 0], [1, "x"], [2, 2]]}),
|
||||
json!({"option": "polygonal", "points": [[0, 0], [10, 0], [0, 10]], "bulges": [1e308, "x"]}),
|
||||
json!({"option": "polygonal", "points": [[0, 0], [1e300, 0], [0, 1e300]]}),
|
||||
json!({"option": "polygonal", "points": big}),
|
||||
json!({"option": "object"}),
|
||||
json!({"option": "object", "object": vh}),
|
||||
json!({"option": "object", "object": "FFFFFFFFFFFFFFFF"}),
|
||||
json!({"option": "object", "object": -1}),
|
||||
json!({"option": "on"}),
|
||||
json!({"option": "lock", "handles": [vh]}),
|
||||
json!({"option": "bogus"}),
|
||||
];
|
||||
for p in cases {
|
||||
assert!(s.execute("mview", &p).is_err(), "{p}");
|
||||
}
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"handle": vh}),
|
||||
json!({"handle": vh, "object": vh}),
|
||||
json!({"handle": vh, "points": [[0, 0]]}),
|
||||
json!({"handle": "0", "points": [[0, 0], [10, 0], [0, 10]]}),
|
||||
json!({"handle": vh, "delete": true}),
|
||||
] {
|
||||
assert!(s.execute("vpclip", &p).is_err(), "{p}");
|
||||
}
|
||||
// Hostile typed input.
|
||||
for t in ["mview p 0,0 c", "mview p 0,0 a 0,0 u u u c", "vpclip 5000,5000", "mview o 9999,9999"] {
|
||||
let _ = s.cmdline(t);
|
||||
let _ = s.cmdline("\u{1b}");
|
||||
}
|
||||
// A clip handle pointing at nothing (or at the viewport itself) renders as a rectangle.
|
||||
let h = Handle::parse_hex(vh.as_str().unwrap()).unwrap();
|
||||
for bad in [Handle(0xDEAD_BEEF), h] {
|
||||
s.doc_mut()
|
||||
.unwrap()
|
||||
.modify_entity(h, |e| {
|
||||
if let EntityKind::Viewport(v) = &mut e.kind {
|
||||
v.clip = Some(bad);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!content(&s).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygonal_viewport_menu_item_asks_for_points() {
|
||||
let mut s = session();
|
||||
s.start("mview.polygonal").unwrap();
|
||||
assert!(s.prompt_text().contains("Specify start point"), "{}", s.prompt_text());
|
||||
for t in ["10,10", "100,10", "60,80", "c"] {
|
||||
s.cmdline(t).unwrap();
|
||||
}
|
||||
assert!(s.running.is_none());
|
||||
assert!(layout_viewports(&s)[0].1.clip.is_some());
|
||||
// The JSON form takes the points.
|
||||
s.execute("mview.polygonal", &json!({"points": [[120, 10], [200, 10], [160, 80]]})).unwrap();
|
||||
assert_eq!(layout_viewports(&s).len(), 2);
|
||||
}
|
||||
@@ -431,6 +431,9 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
|
||||
locked: t.i(90).unwrap_or(0) & 16384 != 0,
|
||||
frozen_layers: crate::dxf_ext::read_frozen(tags),
|
||||
layer_colors: crate::dxf_ext::read_layer_colors(tags),
|
||||
// Status flags: 65536 = non-rectangular clipping (by the 340 entity), 131072 = off.
|
||||
clip: t.s(340).and_then(|h| Handle::parse_hex(&h)).filter(|h| h.0 != 0 && t.i(90).unwrap_or(0) & 65536 != 0),
|
||||
on: t.i(90).unwrap_or(0) & 131072 == 0,
|
||||
}),
|
||||
"WIPEOUT" => {
|
||||
let o = t.p(10);
|
||||
|
||||
@@ -878,13 +878,19 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx
|
||||
w.p(10, v.center);
|
||||
w.f(40, v.width);
|
||||
w.f(41, v.height);
|
||||
// Status 68: a positive value is "on" (the stacking order; 1 is the active viewport).
|
||||
w.i(68, i64::from(v.id.clamp(1, 32767)));
|
||||
// Status 68: a positive value is "on" (the stacking order; 1 is the active viewport), 0 off.
|
||||
w.i(68, if v.on { i64::from(v.id.clamp(1, 32767)) } else { 0 });
|
||||
w.i(69, i64::from(v.id));
|
||||
w.p2(12, v.view_center);
|
||||
w.f(45, v.view_height);
|
||||
// Status flags: 16384 = display locked, 32768 = always set (readers take it as "on").
|
||||
w.i(90, 32768 | if v.locked { 16384 } else { 0 });
|
||||
// Status flags: 16384 = display locked, 32768 = always set, 65536 = clipped by the
|
||||
// 340 entity, 131072 = off.
|
||||
let clip = v.clip.filter(|h| *h != e.handle && d.entity(*h).is_some());
|
||||
let flags = 32768 | if v.locked { 16384 } else { 0 } | if clip.is_some() { 65536 } else { 0 } | if v.on { 0 } else { 131072 };
|
||||
w.i(90, flags);
|
||||
if let Some(h) = clip {
|
||||
w.s(340, h.hex());
|
||||
}
|
||||
// One CADCraft xdata group holds both lists: readers stop at the first group of an app.
|
||||
if !v.frozen_layers.is_empty() || !v.layer_colors.is_empty() {
|
||||
w.s(1001, dxf_ext::APP);
|
||||
|
||||
@@ -499,6 +499,8 @@ fn pdf_layout_one_to_one_with_viewport() {
|
||||
locked: false,
|
||||
frozen_layers: Vec::new(),
|
||||
layer_colors: Vec::new(),
|
||||
clip: None,
|
||||
on: true,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -600,6 +602,8 @@ fn viewport_frozen_layers_roundtrip() {
|
||||
locked: true,
|
||||
frozen_layers: frozen,
|
||||
layer_colors: Vec::new(),
|
||||
clip: None,
|
||||
on: true,
|
||||
})
|
||||
};
|
||||
d.add(&paper, Common::default(), vp(2, vec!["Walls".into(), "A B".into()])).unwrap();
|
||||
@@ -633,6 +637,8 @@ fn viewport_layer_colors_roundtrip() {
|
||||
locked: false,
|
||||
frozen_layers: frozen,
|
||||
layer_colors: colors,
|
||||
clip: None,
|
||||
on: true,
|
||||
})
|
||||
};
|
||||
let colors = vec![("Walls".to_string(), Color::Index(5)), ("A B".to_string(), Color::True(cadcraft_color::Rgb(255, 128, 0)))];
|
||||
|
||||
@@ -26,6 +26,8 @@ fn viewport(id: u32) -> EntityKind {
|
||||
locked: false,
|
||||
frozen_layers: vec![],
|
||||
layer_colors: vec![],
|
||||
clip: None,
|
||||
on: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ fn viewport(id: u32, frozen_layers: Vec<String>, layer_colors: Vec<(String, Colo
|
||||
locked: false,
|
||||
frozen_layers,
|
||||
layer_colors,
|
||||
clip: None,
|
||||
on: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Clipped, locked and turned-off layout viewports survive a DXF round trip (#453): VIEWPORT
|
||||
//! status flags (group 90: 16384 locked, 65536 non-rectangular clipping, 131072 off), status 68
|
||||
//! (0 = off) and the clip boundary entity (group 340), per the public DXF Reference.
|
||||
|
||||
use cadcraft_doc::*;
|
||||
use cadcraft_geom::{PolyVertex, Vec2, Vec3};
|
||||
|
||||
/// A metric drawing with model lines and, on its first layout, an L-shaped closed polyline
|
||||
/// clipping a viewport.
|
||||
fn drawing(locked: bool, on: bool) -> (Drawing, Handle, Handle) {
|
||||
let mut d = Drawing::new_metric();
|
||||
for i in 0..=10 {
|
||||
let y = f64::from(i) * 10.0;
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(0.0, y, 0.0), b: Vec3::new(100.0, y, 0.0) })).unwrap();
|
||||
}
|
||||
let sheet = Space::Paper(d.layouts[0].name.clone());
|
||||
let l = [[10.0, 10.0], [110.0, 10.0], [110.0, 60.0], [60.0, 60.0], [60.0, 110.0], [10.0, 110.0]];
|
||||
let poly = LwPolyline {
|
||||
vertices: l.iter().map(|p| PolyVertex::new(Vec2::new(p[0], p[1]))).collect(),
|
||||
closed: true,
|
||||
const_width: 0.0,
|
||||
elevation: 0.0,
|
||||
plinegen: false,
|
||||
};
|
||||
let b = d.add(&sheet, Common::default(), EntityKind::LwPolyline(poly)).unwrap();
|
||||
let vp = Viewport {
|
||||
center: Vec3::new(60.0, 60.0, 0.0),
|
||||
width: 100.0,
|
||||
height: 100.0,
|
||||
view_center: Vec2::new(50.0, 50.0),
|
||||
view_height: 100.0,
|
||||
id: 2,
|
||||
locked,
|
||||
clip: Some(b),
|
||||
on,
|
||||
..Viewport::default()
|
||||
};
|
||||
let v = d.add(&sheet, Common::default(), EntityKind::Viewport(vp)).unwrap();
|
||||
(d, v, b)
|
||||
}
|
||||
|
||||
/// The DXF text as (code, value) pairs, and back.
|
||||
fn groups(text: &str) -> Vec<(String, String)> {
|
||||
let lines: Vec<&str> = text.lines().map(str::trim).collect();
|
||||
lines.chunks(2).filter_map(|c| Some((c.first()?.to_string(), c.get(1)?.to_string()))).collect()
|
||||
}
|
||||
fn text_of(gs: &[(String, String)]) -> String {
|
||||
gs.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect()
|
||||
}
|
||||
|
||||
fn viewport(d: &Drawing, h: Handle) -> Viewport {
|
||||
match d.entity(h).map(|e| e.kind.clone()) {
|
||||
Some(EntityKind::Viewport(v)) => v,
|
||||
other => panic!("not a viewport: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_boundary_lock_and_off_roundtrip() {
|
||||
for (locked, on) in [(false, true), (true, false), (true, true)] {
|
||||
let (d, v, b) = drawing(locked, on);
|
||||
let text = cadcraft_io::write_dxf(&d);
|
||||
let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
|
||||
let got = viewport(&back, v);
|
||||
assert_eq!(got.clip, Some(b));
|
||||
assert_eq!((got.locked, got.on), (locked, on));
|
||||
assert!(matches!(back.entity(b).map(|e| &e.kind), Some(EntityKind::LwPolyline(p)) if p.closed && p.vertices.len() == 6));
|
||||
// The flags as the DXF Reference has them.
|
||||
let flags = 32768 + 65536 + if locked { 16384 } else { 0 } + if on { 0 } else { 131072 };
|
||||
let gs = groups(&text);
|
||||
assert!(gs.contains(&("90".into(), flags.to_string())), "flags {flags}");
|
||||
assert!(gs.contains(&("340".into(), b.hex())));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipped_viewport_plots_through_its_outline() {
|
||||
let (d, _, _) = drawing(false, true);
|
||||
let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap();
|
||||
let sheet = Space::Paper(back.layouts[0].name.clone());
|
||||
// PNG, SVG and PDF all draw from this list.
|
||||
let l = cadcraft_render::build_plot(&back, &sheet, &cadcraft_render::Options::default());
|
||||
let pts: Vec<Vec2> = l.prims.iter().filter(|p| p.handle == cadcraft_render::VIEWPORT_CONTENT).flat_map(|p| l.points(p).to_vec()).collect();
|
||||
assert!(!pts.is_empty());
|
||||
assert!(pts.iter().all(|p| !(p.x > 60.0 + 1e-6 && p.y > 60.0 + 1e-6)), "content in the notch");
|
||||
for name in ["out.svg", "out.pdf", "out.png"] {
|
||||
assert!(!cadcraft_io::write(&back, name).unwrap().is_empty(), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_clip_handles_do_not_panic() {
|
||||
let (d, v, b) = drawing(false, true);
|
||||
let gs = groups(&cadcraft_io::write_dxf(&d));
|
||||
let at = gs.iter().position(|g| *g == ("340".to_string(), b.hex())).unwrap();
|
||||
for bad in ["0", "ZZZ", "", "FFFFFFFFFFFFFFFFFFFF", "-1", "7FFFFFFFFFFFFFFF", &v.hex()] {
|
||||
let mut g = gs.clone();
|
||||
g[at].1 = bad.to_string();
|
||||
let back = cadcraft_io::read_dxf(text_of(&g).as_bytes()).unwrap();
|
||||
let got = viewport(&back, v);
|
||||
let sheet = Space::Paper(back.layouts[0].name.clone());
|
||||
// A boundary that isn't there clips nothing: the rectangle shows model space.
|
||||
let l = cadcraft_render::build(&back, &sheet, &cadcraft_render::Options::default());
|
||||
assert!(l.prims.iter().any(|p| p.handle == cadcraft_render::VIEWPORT_CONTENT), "{bad}");
|
||||
// And it writes again without a dangling 340.
|
||||
if got.clip.is_none_or(|h| back.entity(h).is_none() || h == v) {
|
||||
let again = groups(&cadcraft_io::write_dxf(&back));
|
||||
let start = again.iter().position(|g| *g == ("5".to_string(), v.hex())).unwrap();
|
||||
let record = again[start..].iter().take_while(|(c, _)| c != "0");
|
||||
assert!(!record.into_iter().any(|(c, _)| c == "340"), "{bad}");
|
||||
}
|
||||
}
|
||||
// The clipping flag without a 340 group: a rectangular viewport.
|
||||
let mut g = gs.clone();
|
||||
g.remove(at);
|
||||
assert_eq!(viewport(&cadcraft_io::read_dxf(text_of(&g).as_bytes()).unwrap(), v).clip, None);
|
||||
}
|
||||
+343
-2
@@ -1,6 +1,8 @@
|
||||
//! Clipping against axis-aligned rectangles (viewport contents, plot sheets).
|
||||
//! Clipping against axis-aligned rectangles (viewport contents, plot sheets) and against
|
||||
//! arbitrary polygons (clipped viewports, [`PolyClip`]).
|
||||
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
use cadcraft_doc::EntityKind;
|
||||
use cadcraft_geom::{Bounds2, Polyline, Vec2};
|
||||
|
||||
/// Clip the segment `a`→`b` to `r` (Liang–Barsky). Returns the parameter range kept.
|
||||
fn clip_params(a: Vec2, d: Vec2, r: &Bounds2, mut t0: f64, mut t1: f64) -> Option<(f64, f64)> {
|
||||
@@ -165,6 +167,296 @@ pub fn clip_triangles(tris: &[Vec2], r: &Bounds2) -> Vec<Vec2> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Most vertices a clip boundary keeps (denser outlines are thinned; hostile files).
|
||||
pub const MAX_CLIP_POINTS: usize = 4096;
|
||||
/// Horizontal bands the clip polygon's edges are bucketed into.
|
||||
const BANDS: usize = 64;
|
||||
|
||||
/// The outline of a closed paper-space entity as a polygon, for a viewport clip boundary:
|
||||
/// a closed polyline (arcs tessellated), circle, full ellipse or closed spline. `None` for
|
||||
/// anything else, an open shape or one without area. At most [`MAX_CLIP_POINTS`] vertices.
|
||||
pub fn boundary_polygon(k: &EntityKind) -> Option<Vec<Vec2>> {
|
||||
let closed_ends = |a: Option<&Vec2>, b: Option<&Vec2>| a.zip(b).is_some_and(|(a, b)| a.near(*b, 1e-9));
|
||||
let mut pts: Vec<Vec2> = Vec::new();
|
||||
// Chords within about a thousandth of the shape's size.
|
||||
let tol_for = |b: Bounds2| (b.width().max(b.height()) * 1e-3).max(1e-9);
|
||||
match k {
|
||||
EntityKind::LwPolyline(p) => {
|
||||
if p.vertices.len() > 1_000_000 {
|
||||
return None;
|
||||
}
|
||||
let pl = Polyline { vertices: p.vertices.clone(), closed: true };
|
||||
let ends = closed_ends(p.vertices.first().map(|v| &v.p), p.vertices.last().map(|v| &v.p));
|
||||
if !p.closed && !ends {
|
||||
return None;
|
||||
}
|
||||
pts = pl.tessellate(tol_for(pl.bounds()));
|
||||
}
|
||||
EntityKind::Polyline3d(p) => {
|
||||
let ends = closed_ends(p.points.first().map(|v| v.xy()).as_ref(), p.points.last().map(|v| v.xy()).as_ref());
|
||||
if !p.closed && !ends {
|
||||
return None;
|
||||
}
|
||||
pts.extend(p.points.iter().take(1_000_000).map(|v| v.xy()));
|
||||
}
|
||||
EntityKind::Circle(c) => {
|
||||
let g = cadcraft_geom::Circle::new(c.center.xy(), c.radius);
|
||||
if !(c.radius.is_finite() && c.radius > 0.0) {
|
||||
return None;
|
||||
}
|
||||
g.tessellate(c.radius * 2e-3, &mut pts);
|
||||
}
|
||||
EntityKind::Ellipse(e) => {
|
||||
let g = cadcraft_geom::Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end };
|
||||
let r = g.major.len();
|
||||
if !g.is_full() || !(r.is_finite() && r > 0.0 && e.ratio.is_finite() && e.ratio > 0.0) {
|
||||
return None;
|
||||
}
|
||||
g.tessellate(r * e.ratio.min(1.0) * 2e-3, &mut pts);
|
||||
}
|
||||
EntityKind::Spline(s) => {
|
||||
if s.control.len() > 100_000 || s.knots.len() > 100_000 {
|
||||
return None;
|
||||
}
|
||||
pts = s.tessellate(tol_for(Bounds2::from_points(s.control.iter().copied())));
|
||||
if !s.closed && !closed_ends(pts.first(), pts.last()) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
clean_polygon(pts)
|
||||
}
|
||||
|
||||
/// Finite points without repeats or the closing duplicate, thinned to [`MAX_CLIP_POINTS`];
|
||||
/// `None` without at least three points and some area.
|
||||
fn clean_polygon(pts: Vec<Vec2>) -> Option<Vec<Vec2>> {
|
||||
let mut out: Vec<Vec2> = Vec::with_capacity(pts.len().min(MAX_CLIP_POINTS));
|
||||
let stride = pts.len().div_ceil(MAX_CLIP_POINTS).max(1);
|
||||
for p in pts.into_iter().step_by(stride) {
|
||||
if p.is_finite() && !out.last().is_some_and(|l| l.near(p, 1e-12)) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
while out.len() > 1 && out.first().zip(out.last()).is_some_and(|(a, b)| a.near(*b, 1e-12)) {
|
||||
out.pop();
|
||||
}
|
||||
let b = Bounds2::from_points(out.iter().copied());
|
||||
(out.len() >= 3 && b.width() > 1e-12 && b.height() > 1e-12 && cadcraft_geom::shoelace(&out).abs() > 1e-18).then_some(out)
|
||||
}
|
||||
|
||||
/// A clip region bounded by a polygon (even-odd, so self-touching outlines work), with its
|
||||
/// edges bucketed into horizontal bands to keep tests fast on large display lists.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PolyClip {
|
||||
poly: Vec<Vec2>,
|
||||
bounds: Bounds2,
|
||||
/// Edge indices whose y range meets each band.
|
||||
bands: Vec<Vec<u32>>,
|
||||
band_h: f64,
|
||||
/// The region as triangles (for clipping fills), with each triangle's bounds.
|
||||
tris: Vec<([Vec2; 3], Bounds2)>,
|
||||
}
|
||||
|
||||
impl PolyClip {
|
||||
/// A clip region for `poly` (see [`clean_polygon`]); `None` for a degenerate outline.
|
||||
pub fn new(poly: &[Vec2]) -> Option<PolyClip> {
|
||||
let poly = clean_polygon(poly.to_vec())?;
|
||||
let bounds = Bounds2::from_points(poly.iter().copied());
|
||||
let band_h = bounds.height() / BANDS as f64;
|
||||
let mut bands: Vec<Vec<u32>> = vec![Vec::new(); BANDS];
|
||||
let n = poly.len();
|
||||
let mut clip = PolyClip { poly: Vec::new(), bounds, bands: Vec::new(), band_h, tris: Vec::new() };
|
||||
for i in 0..n {
|
||||
let (Some(a), Some(b)) = (poly.get(i), poly.get((i + 1) % n)) else { continue };
|
||||
let (lo, hi) = (clip.band(a.y.min(b.y)), clip.band(a.y.max(b.y)));
|
||||
for band in bands.iter_mut().take(hi + 1).skip(lo) {
|
||||
band.push(i as u32);
|
||||
}
|
||||
}
|
||||
let tris = crate::fill::triangulate_evenodd(std::slice::from_ref(&poly));
|
||||
clip.tris = tris
|
||||
.as_chunks::<3>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|t| (*t, Bounds2::from_points(t.iter().copied())))
|
||||
.filter(|(_, b)| b.width() > 0.0 && b.height() > 0.0)
|
||||
.collect();
|
||||
clip.poly = poly;
|
||||
clip.bands = bands;
|
||||
Some(clip)
|
||||
}
|
||||
/// The outline's vertices.
|
||||
pub fn polygon(&self) -> &[Vec2] {
|
||||
&self.poly
|
||||
}
|
||||
pub fn bounds(&self) -> Bounds2 {
|
||||
self.bounds
|
||||
}
|
||||
fn band(&self, y: f64) -> usize {
|
||||
let k = (y - self.bounds.min.y) / self.band_h;
|
||||
if k.is_finite() && k > 0.0 { (k as usize).min(BANDS - 1) } else { 0 }
|
||||
}
|
||||
fn edge(&self, i: u32) -> Option<(Vec2, Vec2)> {
|
||||
let i = i as usize;
|
||||
Some((*self.poly.get(i)?, *self.poly.get((i + 1) % self.poly.len().max(1))?))
|
||||
}
|
||||
/// Even-odd point-in-polygon test.
|
||||
pub fn contains(&self, p: Vec2) -> bool {
|
||||
if !p.is_finite() || !self.bounds.contains(p) {
|
||||
return false;
|
||||
}
|
||||
let mut inside = false;
|
||||
for &i in self.bands.get(self.band(p.y)).map(Vec::as_slice).unwrap_or(&[]) {
|
||||
let Some((a, b)) = self.edge(i) else { continue };
|
||||
if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
inside
|
||||
}
|
||||
/// Parameters along `a`→`b` (0..1) where the segment crosses the outline, sorted.
|
||||
fn crossings(&self, a: Vec2, b: Vec2) -> Vec<f64> {
|
||||
let d = b - a;
|
||||
let (lo, hi) = (self.band(a.y.min(b.y)), self.band(a.y.max(b.y)));
|
||||
let mut ts = Vec::new();
|
||||
for band in self.bands.iter().take(hi + 1).skip(lo) {
|
||||
for &i in band {
|
||||
let Some((p, q)) = self.edge(i) else { continue };
|
||||
let e = q - p;
|
||||
let den = d.cross(e);
|
||||
if den.abs() < 1e-300 {
|
||||
continue;
|
||||
}
|
||||
let t = (p - a).cross(e) / den;
|
||||
let u = (p - a).cross(d) / den;
|
||||
if t > 0.0 && t < 1.0 && (-1e-12..=1.0 + 1e-12).contains(&u) {
|
||||
ts.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.sort_by(f64::total_cmp);
|
||||
ts.dedup_by(|x, y| (*x - *y).abs() < 1e-12);
|
||||
ts
|
||||
}
|
||||
/// Clip a segment: the pieces inside the region.
|
||||
fn clip_segment(&self, a: Vec2, b: Vec2, out: &mut Vec<(Vec2, Vec2)>) {
|
||||
if !a.is_finite() || !b.is_finite() || clip_segment(a, b, &self.bounds).is_none() {
|
||||
return;
|
||||
}
|
||||
let d = b - a;
|
||||
let mut prev = 0.0;
|
||||
for t in self.crossings(a, b).into_iter().chain(std::iter::once(1.0)) {
|
||||
if t - prev > 1e-12 && self.contains(a + d * ((prev + t) / 2.0)) {
|
||||
out.push((a + d * prev, a + d * t));
|
||||
}
|
||||
prev = t;
|
||||
}
|
||||
}
|
||||
/// Clip a polyline; returns the visible pieces (each with at least two points).
|
||||
pub fn clip_polyline(&self, pts: &[Vec2]) -> Vec<Vec<Vec2>> {
|
||||
let mut out: Vec<Vec<Vec2>> = Vec::new();
|
||||
let mut cur: Vec<Vec2> = Vec::new();
|
||||
let mut pieces = Vec::new();
|
||||
for w in pts.windows(2) {
|
||||
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue };
|
||||
pieces.clear();
|
||||
self.clip_segment(*a, *b, &mut pieces);
|
||||
if pieces.is_empty() {
|
||||
if cur.len() >= 2 {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
cur.clear();
|
||||
}
|
||||
for (p, q) in pieces.drain(..) {
|
||||
if !cur.last().is_some_and(|l| l.near(p, 1e-12)) {
|
||||
if cur.len() >= 2 {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
cur.clear();
|
||||
cur.push(p);
|
||||
}
|
||||
cur.push(q);
|
||||
}
|
||||
}
|
||||
if cur.len() >= 2 {
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Clip an infinite line (or a ray when `ray`) to the region.
|
||||
pub fn clip_infinite(&self, base: Vec2, dir: Vec2, ray: bool) -> Vec<Vec<Vec2>> {
|
||||
match clip_infinite(base, dir, ray, &self.bounds) {
|
||||
Some((a, b)) => self.clip_polyline(&[a, b]),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// Clip a triangle list (three vertices per triangle) to the region, re-triangulating as fans.
|
||||
pub fn clip_triangles(&self, tris: &[Vec2]) -> Vec<Vec2> {
|
||||
let mut out = Vec::new();
|
||||
for t in tris.as_chunks::<3>().0 {
|
||||
let tb = Bounds2::from_points(t.iter().copied());
|
||||
if !tb.intersects(&self.bounds) {
|
||||
continue;
|
||||
}
|
||||
// Wholly inside: no outline edge crosses it.
|
||||
if t.iter().all(|p| self.contains(*p)) && (0..3).all(|i| self.crossings(t[i], t[(i + 1) % 3]).is_empty()) {
|
||||
out.extend_from_slice(t);
|
||||
continue;
|
||||
}
|
||||
for (c, cb) in &self.tris {
|
||||
if !cb.intersects(&tb) {
|
||||
continue;
|
||||
}
|
||||
let poly = clip_convex(t, c);
|
||||
if let Some(first) = poly.first().copied() {
|
||||
for w in poly.get(1..).unwrap_or(&[]).windows(2) {
|
||||
if let (Some(b), Some(c)) = (w.first(), w.get(1)) {
|
||||
out.extend_from_slice(&[first, *b, *c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.len() > 6_000_000 {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip a polygon to a triangle (Sutherland–Hodgman; the triangle is convex).
|
||||
fn clip_convex(poly: &[Vec2], tri: &[Vec2; 3]) -> Vec<Vec2> {
|
||||
let [a, b, c] = *tri;
|
||||
let ccw = (b - a).cross(c - a) >= 0.0;
|
||||
let mut cur: Vec<Vec2> = poly.to_vec();
|
||||
for (p, q) in [(a, b), (b, c), (c, a)] {
|
||||
let (p, q) = if ccw { (p, q) } else { (q, p) };
|
||||
let e = q - p;
|
||||
let side = |x: Vec2| e.cross(x - p);
|
||||
let mut next = Vec::with_capacity(cur.len() + 2);
|
||||
let n = cur.len();
|
||||
for i in 0..n {
|
||||
let (Some(s), Some(t)) = (cur.get(i).copied(), cur.get((i + 1) % n).copied()) else { continue };
|
||||
let (ds, dt) = (side(s), side(t));
|
||||
if ds >= 0.0 {
|
||||
next.push(s);
|
||||
}
|
||||
if (ds >= 0.0) != (dt >= 0.0) {
|
||||
let k = ds / (ds - dt);
|
||||
if k.is_finite() {
|
||||
next.push(s + (t - s) * k);
|
||||
}
|
||||
}
|
||||
}
|
||||
cur = next;
|
||||
if cur.len() < 3 {
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
cur
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -188,6 +480,55 @@ mod tests {
|
||||
assert!(clip_segment(Vec2::new(f64::NAN, 0.0), Vec2::new(1.0, 1.0), &r()).is_none());
|
||||
}
|
||||
|
||||
fn notched() -> PolyClip {
|
||||
// A concave "L": the square 0..10 without its top-right quarter.
|
||||
PolyClip::new(&[
|
||||
Vec2::new(0.0, 0.0),
|
||||
Vec2::new(10.0, 0.0),
|
||||
Vec2::new(10.0, 5.0),
|
||||
Vec2::new(5.0, 5.0),
|
||||
Vec2::new(5.0, 10.0),
|
||||
Vec2::new(0.0, 10.0),
|
||||
])
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_clip_lines_points_and_fills() {
|
||||
let c = notched();
|
||||
assert!(c.contains(Vec2::new(2.0, 8.0)) && !c.contains(Vec2::new(8.0, 8.0)));
|
||||
// A horizontal line through the notch keeps only the part left of x = 5.
|
||||
let pieces = c.clip_polyline(&[Vec2::new(-5.0, 8.0), Vec2::new(15.0, 8.0)]);
|
||||
assert_eq!(pieces.len(), 1);
|
||||
let p = &pieces[0];
|
||||
assert!(p[0].near(Vec2::new(0.0, 8.0), 1e-9) && p[1].near(Vec2::new(5.0, 8.0), 1e-9), "{p:?}");
|
||||
// Through the lower part: the whole width.
|
||||
let pieces = c.clip_polyline(&[Vec2::new(-5.0, 2.0), Vec2::new(15.0, 2.0)]);
|
||||
assert!(pieces[0][1].near(Vec2::new(10.0, 2.0), 1e-9));
|
||||
// An infinite line along the diagonal crosses the notch corner.
|
||||
assert!(!c.clip_infinite(Vec2::new(0.0, 0.0), Vec2::new(1.0, 1.0), false).is_empty());
|
||||
// A fill over the whole square loses the notch: three quarters of the area.
|
||||
let fill =
|
||||
[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)];
|
||||
let t = c.clip_triangles(&fill);
|
||||
let area: f64 = t.as_chunks::<3>().0.iter().map(|t| cadcraft_geom::shoelace(t).abs()).sum();
|
||||
assert!((area - 75.0).abs() < 1e-6, "{area}");
|
||||
assert!(t.iter().all(|p| !(p.x > 5.0 + 1e-9 && p.y > 5.0 + 1e-9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_and_hostile_outlines() {
|
||||
assert!(PolyClip::new(&[Vec2::new(0.0, 0.0), Vec2::new(1.0, 1.0)]).is_none());
|
||||
assert!(PolyClip::new(&[Vec2::new(0.0, 0.0), Vec2::new(1.0, 1.0), Vec2::new(2.0, 2.0)]).is_none());
|
||||
assert!(PolyClip::new(&[Vec2::new(f64::NAN, 0.0), Vec2::new(1.0, f64::INFINITY), Vec2::new(2.0, 2.0)]).is_none());
|
||||
// Very dense outlines are thinned.
|
||||
let many: Vec<Vec2> = (0..100_000).map(|i| Vec2::polar(Vec2::ZERO, 5.0, i as f64 * std::f64::consts::TAU / 100_000.0)).collect();
|
||||
let c = PolyClip::new(&many).unwrap();
|
||||
assert!(c.polygon().len() <= MAX_CLIP_POINTS);
|
||||
assert!(c.contains(Vec2::ZERO));
|
||||
assert!(c.clip_polyline(&[Vec2::new(f64::NAN, 0.0), Vec2::new(1.0, 1.0)]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangles() {
|
||||
let t = clip_triangles(&[Vec2::new(-5.0, 0.0), Vec2::new(5.0, 0.0), Vec2::new(5.0, 10.0)], &r());
|
||||
|
||||
+64
-15
@@ -356,7 +356,9 @@ fn with_point_view(d: &Drawing, space: &Space, opts: &Options) -> Options {
|
||||
const MAX_VIEWPORTS: usize = 256;
|
||||
|
||||
/// A paper-space viewport: its border (as an ordinary entity) and model space seen through it.
|
||||
/// Viewport id 1 is the paper-space view itself and is not drawn.
|
||||
/// Viewport id 1 is the paper-space view itself and is not drawn. A clipped viewport shows
|
||||
/// model space through its clip boundary's outline (the boundary entity draws itself, so the
|
||||
/// rectangle is not drawn); a viewport turned off shows only its border.
|
||||
fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewport) {
|
||||
if vp.id == 1 || !e.common.visible || d.is_hidden_object(e.handle) {
|
||||
return;
|
||||
@@ -365,9 +367,12 @@ fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewpor
|
||||
if d.layer(&e.common.layer).is_some_and(|l| l.frozen) {
|
||||
return;
|
||||
}
|
||||
let poly = viewport_clip(d, e.handle, vp);
|
||||
// Border (on the viewport's layer; layer off hides only the border).
|
||||
entity(b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e);
|
||||
if b.list.expanded >= MAX_BLOCK_EXPANSION {
|
||||
if poly.is_none() {
|
||||
entity(b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e);
|
||||
}
|
||||
if b.list.expanded >= MAX_BLOCK_EXPANSION || !vp.on {
|
||||
return;
|
||||
}
|
||||
let center = vp.center.xy();
|
||||
@@ -380,11 +385,15 @@ fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewpor
|
||||
return;
|
||||
}
|
||||
let half = Vec2::new(vp.width / 2.0, vp.height / 2.0);
|
||||
let rect = Bounds2::new(center - half, center + half);
|
||||
let rect = match &poly {
|
||||
Some(p) => p.bounds(),
|
||||
None => Bounds2::new(center - half, center + half),
|
||||
};
|
||||
let xf = Mat3::translate(center).then_before(Mat3::scale(s, s)).then_before(Mat3::translate(-vp.view_center));
|
||||
let mhalf = half / s;
|
||||
let mhalf = Vec2::new(rect.width(), rect.height()) / (2.0 * s);
|
||||
let mcenter = vp.view_center + (rect.center() - center) / s;
|
||||
// Model window with slack: entity bounds are approximate (text, dimensions).
|
||||
let win = Bounds2::new(vp.view_center - mhalf, vp.view_center + mhalf).expand(mhalf.x.max(mhalf.y) * 0.1);
|
||||
let win = Bounds2::new(mcenter - mhalf, mcenter + mhalf).expand(mhalf.x.max(mhalf.y) * 0.1);
|
||||
let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting, styles: b.styles, named: None };
|
||||
// PSLTSCALE on: model-space dashes are scaled so they measure the same on paper as in
|
||||
// paper space, whatever the viewport scale.
|
||||
@@ -402,37 +411,77 @@ fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewpor
|
||||
entity(&mut sub, &Ctx { lt_factor, ..top_ctx(d, xf, VIEWPORT_CONTENT, &vp.frozen_layers, &vp.layer_colors) }, me);
|
||||
}
|
||||
b.list.expanded = b.list.expanded.saturating_add(sub.list.expanded);
|
||||
append_clipped(&mut b.list, &sub.list, &rect);
|
||||
match &poly {
|
||||
Some(p) => append_clipped(&mut b.list, &sub.list, &Clip::Poly(p)),
|
||||
None => append_clipped(&mut b.list, &sub.list, &Clip::Rect(rect)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Append `src` to `dst`, clipped to `rect`.
|
||||
fn append_clipped(dst: &mut DisplayList, src: &DisplayList, rect: &Bounds2) {
|
||||
/// The clip region of a clipped viewport: the outline of its clip boundary entity, when that
|
||||
/// is a closed shape in the drawing. `None` for a rectangular viewport (or a dangling,
|
||||
/// unusable or self-referencing boundary, which falls back to the rectangle).
|
||||
pub fn viewport_clip(d: &Drawing, handle: Handle, vp: &cadcraft_doc::Viewport) -> Option<clip::PolyClip> {
|
||||
let h = vp.clip.filter(|h| *h != handle)?;
|
||||
let be = d.entity(h)?;
|
||||
clip::PolyClip::new(&clip::boundary_polygon(&be.kind)?)
|
||||
}
|
||||
|
||||
/// What viewport contents are clipped to.
|
||||
enum Clip<'a> {
|
||||
/// The viewport rectangle (the fast path).
|
||||
Rect(Bounds2),
|
||||
/// A clip boundary's outline.
|
||||
Poly(&'a clip::PolyClip),
|
||||
}
|
||||
|
||||
/// Append `src` to `dst`, clipped to `clip`.
|
||||
fn append_clipped(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) {
|
||||
for p in &src.prims {
|
||||
let pts = src.points(p);
|
||||
match p.kind {
|
||||
Kind::Polyline => {
|
||||
match (p.kind, clip) {
|
||||
(Kind::Polyline, Clip::Rect(rect)) => {
|
||||
for piece in clip::clip_polyline(pts, rect) {
|
||||
push_raw(dst, p, Kind::Polyline, &piece);
|
||||
}
|
||||
}
|
||||
Kind::Tris | Kind::Mask => {
|
||||
(Kind::Polyline, Clip::Poly(c)) => {
|
||||
for piece in c.clip_polyline(pts) {
|
||||
push_raw(dst, p, Kind::Polyline, &piece);
|
||||
}
|
||||
}
|
||||
(Kind::Tris | Kind::Mask, Clip::Rect(rect)) => {
|
||||
let t = clip::clip_triangles(pts, rect);
|
||||
push_raw(dst, p, p.kind, &t);
|
||||
}
|
||||
Kind::Point => {
|
||||
(Kind::Tris | Kind::Mask, Clip::Poly(c)) => {
|
||||
let t = c.clip_triangles(pts);
|
||||
push_raw(dst, p, p.kind, &t);
|
||||
}
|
||||
(Kind::Point, _) => {
|
||||
let inside = |q: Vec2| match clip {
|
||||
Clip::Rect(rect) => rect.contains(q),
|
||||
Clip::Poly(c) => c.contains(q),
|
||||
};
|
||||
if let Some(q) = pts.first()
|
||||
&& rect.contains(*q)
|
||||
&& inside(*q)
|
||||
{
|
||||
push_raw(dst, p, Kind::Point, &[*q]);
|
||||
}
|
||||
}
|
||||
Kind::Infinite { ray } => {
|
||||
(Kind::Infinite { ray }, Clip::Rect(rect)) => {
|
||||
if let (Some(base), Some(dir)) = (pts.first(), pts.get(1))
|
||||
&& let Some((a, c)) = clip::clip_infinite(*base, *dir, ray, rect)
|
||||
{
|
||||
push_raw(dst, p, Kind::Polyline, &[a, c]);
|
||||
}
|
||||
}
|
||||
(Kind::Infinite { ray }, Clip::Poly(c)) => {
|
||||
if let (Some(base), Some(dir)) = (pts.first(), pts.get(1)) {
|
||||
for piece in c.clip_infinite(*base, *dir, ray) {
|
||||
push_raw(dst, p, Kind::Polyline, &piece);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,8 @@ fn vp(center: Vec2, w: f64, h: f64, view_center: Vec2, view_height: f64, id: u32
|
||||
locked: false,
|
||||
frozen_layers: Vec::new(),
|
||||
layer_colors: Vec::new(),
|
||||
clip: None,
|
||||
on: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ fn drawing(psltscale: i64) -> Drawing {
|
||||
locked: false,
|
||||
frozen_layers: Vec::new(),
|
||||
layer_colors: Vec::new(),
|
||||
clip: None,
|
||||
on: true,
|
||||
};
|
||||
d.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Viewport(vp)).unwrap();
|
||||
d
|
||||
|
||||
@@ -59,6 +59,8 @@ fn dimensions_take_the_viewport_layer_colour() {
|
||||
locked: false,
|
||||
frozen_layers: Vec::new(),
|
||||
layer_colors: vec![("a".into(), Color::Index(3))],
|
||||
clip: None,
|
||||
on: true,
|
||||
};
|
||||
d.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Viewport(vp)).unwrap();
|
||||
let l = build(&d, &Space::Paper("Layout1".into()), &Options::default());
|
||||
|
||||
@@ -19,6 +19,8 @@ fn drawing() -> Drawing {
|
||||
locked: false,
|
||||
frozen_layers: Vec::new(),
|
||||
layer_colors: Vec::new(),
|
||||
clip: None,
|
||||
on: true,
|
||||
};
|
||||
d.add(&Space::Paper("Layout1".into()), Common { layer: "VPORTS".into(), ..Default::default() }, EntityKind::Viewport(vp)).unwrap();
|
||||
d
|
||||
|
||||
@@ -637,6 +637,8 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
Err(_) => (xf, None),
|
||||
};
|
||||
// A clipped active viewport's outline (picks and its heavy border follow it).
|
||||
let active_clip = app.session.state().ok().and_then(|st| st.active_viewport().and_then(|(h, v)| cadcraft_render::viewport_clip(&st.doc, h, &v)));
|
||||
// Double-click a viewport to work inside it; double-click the sheet outside it to return.
|
||||
if inside
|
||||
&& dbl_primary
|
||||
@@ -645,7 +647,8 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
&& let Some(hp) = hover_pos
|
||||
{
|
||||
let pp = xf_paper.to_world(hp);
|
||||
let in_active = active_vp.as_ref().is_some_and(|v| (pp.x - v.center.x).abs() <= v.width / 2.0 && (pp.y - v.center.y).abs() <= v.height / 2.0);
|
||||
let in_rect = |v: &cadcraft_doc::Viewport| (pp.x - v.center.x).abs() <= v.width / 2.0 && (pp.y - v.center.y).abs() <= v.height / 2.0;
|
||||
let in_active = active_vp.as_ref().is_some_and(|v| active_clip.as_ref().map_or(in_rect(v), |c| c.contains(pp)));
|
||||
if !in_active {
|
||||
app.canvas.hot_grips.clear();
|
||||
if app.run("mspace", serde_json::json!({"at": [pp.x, pp.y]})).is_err() && active_vp.is_some() {
|
||||
@@ -808,7 +811,17 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
|
||||
xf_paper.to_screen(Vec2::new(vp.center.x - vp.width / 2.0, vp.center.y + vp.height / 2.0)),
|
||||
xf_paper.to_screen(Vec2::new(vp.center.x + vp.width / 2.0, vp.center.y - vp.height / 2.0)),
|
||||
);
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(3.0, Color32::from_rgb(0x2a, 0x2a, 0x2a)), egui::StrokeKind::Middle);
|
||||
let heavy = Stroke::new(3.0, Color32::from_rgb(0x2a, 0x2a, 0x2a));
|
||||
match &active_clip {
|
||||
Some(c) => {
|
||||
let mut pts: Vec<egui::Pos2> = c.polygon().iter().map(|q| xf_paper.to_screen(*q)).collect();
|
||||
pts.extend(pts.first().copied());
|
||||
painter.add(egui::Shape::line(pts, heavy));
|
||||
}
|
||||
None => {
|
||||
painter.rect_stroke(r, 0.0, heavy, egui::StrokeKind::Middle);
|
||||
}
|
||||
}
|
||||
if let Ok(st) = app.session.state() {
|
||||
let mut hs = sel.clone();
|
||||
hs.extend(app.canvas.hover.filter(|h| !sel.contains(h)));
|
||||
|
||||
@@ -107,6 +107,8 @@
|
||||
Circle Коло
|
||||
Circumference Довжина кола
|
||||
Clear Очистити
|
||||
Clip Обрізати
|
||||
Clip Viewport Обрізати видовий екран
|
||||
Clipboard Буфер обміну
|
||||
Close Закрити
|
||||
Close All Закрити всі
|
||||
@@ -584,6 +586,7 @@
|
||||
PolarSnap Полярна прив'язка
|
||||
Polygon Правильний багатокутник
|
||||
Polygonal Багатокутний
|
||||
Polygonal Viewport Багатокутний видовий екран
|
||||
Polyline Полілінія
|
||||
Position X Позиція X
|
||||
Position Y Позиція Y
|
||||
|
||||
|
Reference in New Issue
Block a user