diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index 8ca4ad9a..3b2229aa 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -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, + /// 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")] diff --git a/crates/engine/src/audit.rs b/crates/engine/src/audit.rs index 74b47ae2..89df8c0c 100644 --- a/crates/engine/src/audit.rs +++ b/crates/engine/src/audit.rs @@ -508,6 +508,18 @@ fn references(d: &mut Drawing, out: &mut Vec) { "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", + )); + } } _ => {} } diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index a3ff35ff..3a89e43c 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -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 { vec![ @@ -31,8 +33,18 @@ pub fn specs() -> Vec { 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> { .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 { 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 = 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 { } fn run_mview(s: &mut Session, p: &Value) -> Result { + 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 { 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, -} - -impl MviewM { - fn new(count: usize, choose: bool) -> Self { - MviewM { count, choose, first: None } - } - fn create(&self, s: &mut Session, rect: Option) -> Result { - 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 { - 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 { - 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 { - 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 { 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::>(), }) @@ -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. diff --git a/crates/engine/src/cmd/layout/viewports.rs b/crates/engine/src/cmd/layout/viewports.rs new file mode 100644 index 00000000..aad72be9 --- /dev/null +++ b/crates/engine/src/cmd/layout/viewports.rs @@ -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 { + 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 { + 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> { + 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 = 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::>()?, + Some(_) => return Err(bad(cmd, "`bulges` must be an array of numbers")), + }; + let mut vs: Vec = 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 { + 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 { + 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) -> Result { + 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 { + 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 { + 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 { + 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 { + 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)> { + 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 { + 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::>(), "on": on })) +} + +/// MVIEW Lock: `{locked: bool, handles?}`. +fn mview_lock(s: &mut Session, p: &Value) -> Result { + 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::>(), "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> { + 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 { + 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 { + 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 { + 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 { + 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, + arc: bool, +} + +pub(super) enum PolyStep { + More, + Closed(Vec), +} + +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 { + 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 { + 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 { + 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::>(), + "bulges": vs.iter().map(|v| v.bulge).collect::>(), + }) +} + +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) { + let found: Vec = 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, + locked: bool, + picked: Vec, + }, +} + +/// 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) -> Result { + 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 { + 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 { + 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 { + 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 { + 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] ". + 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 { + 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 { + 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 { + match &self.stage { + ClipStage::Polygon(_, pi) => pi.preview(c), + _ => Vec::new(), + } + } +} diff --git a/crates/engine/src/cmd/layout/vplayer.rs b/crates/engine/src/cmd/layout/vplayer.rs index eaba2282..0d2e7d9e 100644 --- a/crates/engine/src/cmd/layout/vplayer.rs +++ b/crates/engine/src/cmd/layout/vplayer.rs @@ -112,12 +112,7 @@ fn viewport_at(s: &Session, p: Vec2) -> Option { 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 { diff --git a/crates/engine/src/cmd/modify2_tests.rs b/crates/engine/src/cmd/modify2_tests.rs index 7b62572b..af2649d0 100644 --- a/crates/engine/src/cmd/modify2_tests.rs +++ b/crates/engine/src/cmd/modify2_tests.rs @@ -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(); diff --git a/crates/engine/tests/viewport_clip.rs b/crates/engine/tests/viewport_clip.rs new file mode 100644 index 00000000..36a5285d --- /dev/null +++ b/crates/engine/tests/viewport_clip.rs @@ -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 = 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 { + 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 = 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); +} diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index cbfbbe36..db959adc 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -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); diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 59f49957..4836e6c9 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -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); diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 2ec428d7..a6936805 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -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)))]; diff --git a/crates/io/tests/dwg_roundtrip_losses.rs b/crates/io/tests/dwg_roundtrip_losses.rs index 06597ddb..45c0d0d4 100644 --- a/crates/io/tests/dwg_roundtrip_losses.rs +++ b/crates/io/tests/dwg_roundtrip_losses.rs @@ -26,6 +26,8 @@ fn viewport(id: u32) -> EntityKind { locked: false, frozen_layers: vec![], layer_colors: vec![], + clip: None, + on: true, }) } diff --git a/crates/io/tests/paperview_marker.rs b/crates/io/tests/paperview_marker.rs index eecc3dc1..4159e14c 100644 --- a/crates/io/tests/paperview_marker.rs +++ b/crates/io/tests/paperview_marker.rs @@ -16,6 +16,8 @@ fn viewport(id: u32, frozen_layers: Vec, layer_colors: Vec<(String, Colo locked: false, frozen_layers, layer_colors, + clip: None, + on: true, }) } diff --git a/crates/io/tests/viewport_clip_roundtrip.rs b/crates/io/tests/viewport_clip_roundtrip.rs new file mode 100644 index 00000000..b27c3122 --- /dev/null +++ b/crates/io/tests/viewport_clip_roundtrip.rs @@ -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 = 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); +} diff --git a/crates/render/src/clip.rs b/crates/render/src/clip.rs index dbee9cf0..0b4c215d 100644 --- a/crates/render/src/clip.rs +++ b/crates/render/src/clip.rs @@ -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 { 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> { + 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 = 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) -> Option> { + let mut out: Vec = 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, + bounds: Bounds2, + /// Edge indices whose y range meets each band. + bands: Vec>, + 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 { + 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![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 { + 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> { + let mut out: Vec> = Vec::new(); + let mut cur: Vec = 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> { + 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 { + 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 { + let [a, b, c] = *tri; + let ccw = (b - a).cross(c - a) >= 0.0; + let mut cur: Vec = 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 = (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()); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 867fb8eb..94cd328f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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 { + 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); + } + } + } } } } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 972ff42d..bd3ee219 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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, } } diff --git a/crates/render/tests/psltscale.rs b/crates/render/tests/psltscale.rs index 39df600e..dab134ec 100644 --- a/crates/render/tests/psltscale.rs +++ b/crates/render/tests/psltscale.rs @@ -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 diff --git a/crates/render/tests/render_props.rs b/crates/render/tests/render_props.rs index 13e22221..b03aa482 100644 --- a/crates/render/tests/render_props.rs +++ b/crates/render/tests/render_props.rs @@ -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()); diff --git a/crates/render/tests/viewport_frozen_layer.rs b/crates/render/tests/viewport_frozen_layer.rs index 9198b26f..bce29163 100644 --- a/crates/render/tests/viewport_frozen_layer.rs +++ b/crates/render/tests/viewport_frozen_layer.rs @@ -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 diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f8eee921..5abb71ce 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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 = 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))); diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 63d89d19..e77e624c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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