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:
Valentin Klein
2026-10-11 11:35:46 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d6aa802c75
commit 61a8a5f984
21 changed files with 1693 additions and 133 deletions
+24
View File
@@ -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")]
+12
View File
@@ -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
View File
@@ -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.
+814
View File
@@ -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(),
}
}
}
+1 -6
View File
@@ -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> {
+2
View File
@@ -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();
+243
View File
@@ -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);
}
+3
View File
@@ -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);
+10 -4
View File
@@ -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);
+6
View File
@@ -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)))];
+2
View File
@@ -26,6 +26,8 @@ fn viewport(id: u32) -> EntityKind {
locked: false,
frozen_layers: vec![],
layer_colors: vec![],
clip: None,
on: true,
})
}
+2
View File
@@ -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,
})
}
+117
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
}
}
}
}
+2
View File
@@ -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,
}
}
+2
View File
@@ -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
+2
View File
@@ -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
+15 -2
View File
@@ -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)));
+3
View File
@@ -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
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
107
108
109
110
111
112
113
114
586
587
588
589
590
591
592