From 14296dc4f512db28055b002a2fd4ebff16fff8a1 Mon Sep 17 00:00:00 2001 From: Francesco BV Date: Tue, 27 Oct 2026 18:40:12 +0100 Subject: [PATCH] blocks: XCLIP and XCLIPFRAME, clipped references kept through DXF and DWG XCLIP clips block and xref references to a rectangular, polygonal or closed-polyline boundary (Invert clip, ON/OFF, Delete, generate Polyline; Clipdepth is ignored in 2D). The boundary is Insert::clip, in block coordinates, so it follows its reference; other references stay whole. Clipped references are drawn, plotted and framed (XCLIPFRAME 0/1/2) cut to the boundary, attributes included, and their extents are cut to it. DXF and DWG read and write it as the reference's ACAD_FILTER/SPATIAL SPATIAL_FILTER; filters the model can't hold stay foreign data. Gap F2. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/constraints/src/model.rs | 1 + crates/constraints/tests/dof.rs | 2 + crates/doc/src/array.rs | 1 + crates/doc/src/entity/block.rs | 19 + crates/doc/src/extents.rs | 7 + crates/doc/src/lib.rs | 2 + crates/doc/src/tests.rs | 5 + crates/doc/src/xclip.rs | 85 ++++ crates/engine/src/cmd/array.rs | 1 + crates/engine/src/cmd/blocks.rs | 3 + crates/engine/src/cmd/blocks/xclip.rs | 462 ++++++++++++++++++ crates/engine/src/cmd/exchange.rs | 1 + crates/engine/src/cmd/mleader_content.rs | 1 + crates/engine/src/snap.rs | 1 + crates/engine/src/spatial.rs | 2 + crates/engine/src/sysvars/table.rs | 2 + crates/engine/tests/audit.rs | 1 + crates/engine/tests/autosave.rs | 1 + crates/engine/tests/clipboard.rs | 1 + crates/engine/tests/compare.rs | 2 + crates/engine/tests/insert_file.rs | 1 + crates/engine/tests/it/blockreplace.rs | 1 + crates/engine/tests/it/compare_export.rs | 1 + crates/engine/tests/it/count.rs | 1 + crates/engine/tests/it/main.rs | 1 + .../tests/it/measure_distance_snapping.rs | 1 + crates/engine/tests/it/purge_mlinestyles.rs | 1 + crates/engine/tests/it/refedit.rs | 2 + crates/engine/tests/it/snap_blocks.rs | 1 + crates/engine/tests/it/snap_tracking.rs | 1 + crates/engine/tests/it/xclip.rs | 214 ++++++++ crates/engine/tests/usage.rs | 1 + crates/engine/tests/xref.rs | 1 + crates/io/src/dxf_clip.rs | 374 ++++++++++++++ crates/io/src/dxf_mleader.rs | 1 + crates/io/src/dxf_read.rs | 1 + crates/io/src/dxf_read/entities.rs | 1 + crates/io/src/dxf_write/sections.rs | 3 + crates/io/src/lib.rs | 2 + crates/io/src/tests.rs | 3 + crates/io/tests/it/annotative_roundtrip.rs | 1 + crates/io/tests/it/assoc_array_roundtrip.rs | 1 + crates/io/tests/it/attrib_modes.rs | 1 + crates/io/tests/it/attrib_props.rs | 1 + .../io/tests/it/dim_block_resave_roundtrip.rs | 1 + crates/io/tests/it/dynblock_read.rs | 2 + crates/io/tests/it/entity_props_roundtrip.rs | 1 + crates/io/tests/it/main.rs | 1 + crates/io/tests/it/mleader_content.rs | 1 + crates/io/tests/it/mleader_roundtrip.rs | 1 + crates/io/tests/it/pdf_layers.rs | 1 + crates/io/tests/it/xclip_roundtrip.rs | 164 +++++++ crates/io/tests/it/xref_roundtrip.rs | 1 + crates/render/src/blocks.rs | 56 ++- crates/render/src/clip.rs | 74 +++ crates/render/src/lib.rs | 18 +- crates/render/src/tests.rs | 2 + crates/render/src/viewports.rs | 55 ++- crates/render/tests/block_ltscale.rs | 1 + crates/render/tests/it/aci7_display.rs | 1 + crates/render/tests/it/plot_styles.rs | 1 + crates/render/tests/it/render_props.rs | 1 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + docs/gaps.md | 7 +- docs/target-app-parity.md | 5 +- 66 files changed, 1603 insertions(+), 11 deletions(-) create mode 100644 crates/doc/src/xclip.rs create mode 100644 crates/engine/src/cmd/blocks/xclip.rs create mode 100644 crates/engine/tests/it/xclip.rs create mode 100644 crates/io/src/dxf_clip.rs create mode 100644 crates/io/tests/it/xclip_roundtrip.rs diff --git a/crates/constraints/src/model.rs b/crates/constraints/src/model.rs index d18646c32..f05d5cb13 100644 --- a/crates/constraints/src/model.rs +++ b/crates/constraints/src/model.rs @@ -735,6 +735,7 @@ mod budget_tests { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap(); let mut budget = 2; diff --git a/crates/constraints/tests/dof.rs b/crates/constraints/tests/dof.rs index 02fd935c0..59dede1d5 100644 --- a/crates/constraints/tests/dof.rs +++ b/crates/constraints/tests/dof.rs @@ -302,6 +302,7 @@ fn block_reference_moves_rigidly() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), ); assert_eq!(obj_dof(&d, ins), Some(3)); @@ -343,6 +344,7 @@ fn hostile_new_geometry_never_panics() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), ); let l = line(&mut d, (0.0, 0.0), (0.0, 0.0)); diff --git a/crates/doc/src/array.rs b/crates/doc/src/array.rs index b663785ed..1514bc055 100644 --- a/crates/doc/src/array.rs +++ b/crates/doc/src/array.rs @@ -228,6 +228,7 @@ impl ArrayParams { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; out.push(Entity { handle: d.new_handle(), common: common.clone(), kind: EntityKind::Insert(ins) }); } diff --git a/crates/doc/src/entity/block.rs b/crates/doc/src/entity/block.rs index 3d6665e0b..a3f2e76b4 100644 --- a/crates/doc/src/entity/block.rs +++ b/crates/doc/src/entity/block.rs @@ -161,6 +161,25 @@ pub struct Insert { pub col_spacing: f64, #[serde(default)] pub row_spacing: f64, + /// XCLIP boundary of this reference (other references to the block are not clipped). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub clip: Option, +} +impl Default for Insert { + fn default() -> Self { + Insert { + block: String::new(), + insert: Vec3::ZERO, + scale: unit_scale(), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + clip: None, + } + } } fn unit_scale() -> Vec3 { diff --git a/crates/doc/src/extents.rs b/crates/doc/src/extents.rs index d7c7c7a8a..a2941ba19 100644 --- a/crates/doc/src/extents.rs +++ b/crates/doc/src/extents.rs @@ -123,6 +123,13 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap< } }; b = if inner.is_empty() { Bounds2::EMPTY } else { Bounds2::from_points(inner.corners().map(|c| m.apply(c))) }; + // XCLIP: what shows is within the boundary (an inverted clip can show anything). + if let Some(c) = ins.clip.as_ref().filter(|c| c.active() && !c.inverted) + && !b.is_empty() + { + let cb = Bounds2::from_points(c.polygon().into_iter().map(|p| m.apply(p))); + b = if b.intersects(&cb) { Bounds2 { min: b.min.max(cb.min), max: b.max.min(cb.max) } } else { Bounds2::EMPTY }; + } if ins.cols > 1 || ins.rows > 1 { let off = Vec2::new(ins.col_spacing * f64::from(ins.cols.saturating_sub(1)), ins.row_spacing * f64::from(ins.rows.saturating_sub(1))) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d970a6a56..50138eadd 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -19,6 +19,7 @@ mod plotter; mod refedit; mod store; mod tables; +mod xclip; use std::collections::{BTreeMap, BTreeSet}; @@ -36,6 +37,7 @@ pub use plotter::*; pub use refedit::{RefEdit, WorkItem}; pub use store::EntityStore; pub use tables::*; +pub use xclip::{ClipBoundary, MAX_CLIP_VERTICES}; /// File data CADCraft doesn't model, kept for saving (#528). pub mod dynblock; diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 067d42ac0..6df62e1b1 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -81,6 +81,7 @@ fn inserts_of_missing_or_empty_blocks_do_not_stretch_extents() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }) }; d.blocks.insert("Empty".into(), std::sync::Arc::new(Block::new("Empty"))); @@ -134,6 +135,7 @@ fn visible_attributes_of_a_missing_block_still_count() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), ) }; @@ -177,6 +179,7 @@ fn insert_bounds_with_cyclic_block_terminate() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), )); b.entities.push(Entity::new(Handle(2), line((0.0, 0.0), (1.0, 1.0)))); @@ -195,6 +198,7 @@ fn insert_bounds_with_cyclic_block_terminate() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), ) .unwrap(); @@ -312,6 +316,7 @@ fn plain_insert(block: &str) -> EntityKind { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }) } diff --git a/crates/doc/src/xclip.rs b/crates/doc/src/xclip.rs new file mode 100644 index 000000000..b73be0ca0 --- /dev/null +++ b/crates/doc/src/xclip.rs @@ -0,0 +1,85 @@ +//! XCLIP: the clipping boundary of one block or xref reference. +//! +//! The boundary is kept in the referenced block's coordinates, so it moves, rotates and scales +//! with its reference; the block definition and other references to it are untouched. In a file +//! it is the reference's `ACAD_FILTER` / `SPATIAL` extension-dictionary entry (a SPATIAL_FILTER +//! object, DXF Reference), read and written by the io crate. + +use cadcraft_geom::Vec2; +use serde::{Deserialize, Serialize}; + +/// Most boundary vertices a clip keeps (hostile files and parameters are capped to this). +pub const MAX_CLIP_VERTICES: usize = 4096; + +/// A reference's clipping boundary. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClipBoundary { + /// Vertices in the block's coordinates: two opposite corners for a rectangle, three or more + /// for a closed polygon (not repeating the first vertex). + pub points: Vec, + /// Whether clipping is on (XCLIP ON/OFF); an off boundary is kept but nothing is clipped. + #[serde(default = "on")] + pub enabled: bool, + /// Inverted clip: what is inside the boundary is hidden, what is outside shows. + #[serde(default)] + pub inverted: bool, +} + +fn on() -> bool { + true +} + +impl ClipBoundary { + /// A boundary from `points` (block coordinates), on and not inverted. + pub fn new(points: Vec) -> Self { + ClipBoundary { points, enabled: true, inverted: false } + } + + /// The closed outline in block coordinates: a rectangle's four corners or the polygon's + /// vertices. Empty when the boundary has fewer than two vertices, more than + /// [`MAX_CLIP_VERTICES`] or a non-finite one. + pub fn polygon(&self) -> Vec { + if self.points.len() > MAX_CLIP_VERTICES || !self.points.iter().all(|p| p.is_finite()) { + return Vec::new(); + } + match self.points.as_slice() { + [a, b] => vec![ + Vec2::new(a.x.min(b.x), a.y.min(b.y)), + Vec2::new(a.x.max(b.x), a.y.min(b.y)), + Vec2::new(a.x.max(b.x), a.y.max(b.y)), + Vec2::new(a.x.min(b.x), a.y.max(b.y)), + ], + [_, _, _, ..] => self.points.clone(), + _ => Vec::new(), + } + } + + /// Whether the boundary is a rectangle given by two corners. + pub fn is_rectangle(&self) -> bool { + self.points.len() == 2 + } + + /// Whether drawing the reference is clipped by it now (on, with a usable outline). + pub fn active(&self) -> bool { + self.enabled && self.polygon().len() >= 3 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rectangle_and_polygon_outlines() { + let r = ClipBoundary::new(vec![Vec2::new(2.0, 3.0), Vec2::new(0.0, 0.0)]); + assert_eq!(r.polygon(), vec![Vec2::new(0.0, 0.0), Vec2::new(2.0, 0.0), Vec2::new(2.0, 3.0), Vec2::new(0.0, 3.0)]); + assert!(r.active() && r.is_rectangle()); + let bad = ClipBoundary::new(vec![Vec2::new(f64::NAN, 0.0), Vec2::new(1.0, 1.0)]); + assert!(bad.polygon().is_empty() && !bad.active()); + let one = ClipBoundary::new(vec![Vec2::ZERO]); + assert!(!one.active()); + let off = ClipBoundary { enabled: false, ..r }; + assert!(!off.active()); + } +} diff --git a/crates/engine/src/cmd/array.rs b/crates/engine/src/cmd/array.rs index 479b7873a..d65a41046 100644 --- a/crates/engine/src/cmd/array.rs +++ b/crates/engine/src/cmd/array.rs @@ -154,6 +154,7 @@ pub(crate) fn make_assoc(s: &mut Session, objs: &[Handle], base: Vec2, mut p: Ar rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, })) } diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index ca4cfffec..0758ac6b5 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -23,6 +23,7 @@ mod description; mod dyngrip; mod extract; pub(crate) mod refedit; +mod xclip; pub fn specs() -> Vec { vec![ @@ -86,6 +87,7 @@ pub fn specs() -> Vec { .chain(attribs::attdisp_menu_specs()) .chain(xref::specs()) .chain(refedit::specs()) + .chain(xclip::specs()) .chain(bedit::specs()) .collect() } @@ -215,6 +217,7 @@ fn insert_entity(blk: &Block, at: Vec2, scale: Vec2, rotation: f64, base: Option rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; if let Some(b) = base { let landed = ins.transform(blk.base.xy()).apply(b); diff --git a/crates/engine/src/cmd/blocks/xclip.rs b/crates/engine/src/cmd/blocks/xclip.rs new file mode 100644 index 000000000..b9e1c0e4f --- /dev/null +++ b/crates/engine/src/cmd/blocks/xclip.rs @@ -0,0 +1,462 @@ +//! XCLIP and XCLIPFRAME: clip block and xref references to a boundary. +//! +//! A boundary belongs to one reference ([`cadcraft_doc::Insert::clip`]), in the block's own +//! coordinates, so it moves, rotates and scales with the reference and other references of the +//! block stay whole. It is a rectangle, a polygon or the outline of a closed polyline, can hide +//! what is inside instead of what is outside (Invert clip), and can be turned off, deleted or +//! turned into a polyline. Clip depth (front and back clipping planes) has no meaning in a 2D +//! drawing and is ignored. XCLIPFRAME says whether the boundary is drawn and plotted. + +use cadcraft_doc::{ClipBoundary, EntityKind, Handle, MAX_CLIP_VERTICES}; +use cadcraft_geom::{PolyVertex, Vec2}; +use serde_json::{Value, json}; + +use super::super::machines::{SelOutcome, SelectPhase}; +use crate::cmd::{CommandSpec, bad, bool_or, has_doc, point_value, str_param, targets}; +use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; + +pub(super) fn specs() -> Vec { + vec![ + CommandSpec::new("xclip", "Clip Xref", run) + .menu(&["Modify", "Clip", "Xref"]) + .alias(&["xc"]) + .params("{handles? | handle? (block or xref references, default the selection), option?: new|on|off|delete|polyline|clipdepth|query (default new with a boundary, else query), rectangle?: [[x,y],[x,y]] | points?: [[x,y],…] (3 to 4096, drawing coordinates) | polyline?: handle of a closed polyline, invert?: bool (hide what is inside)} → {handles, option} (query: {clips: [{handle, boundary, enabled, inverted}]}; polyline: {polylines})") + .enabled(has_doc) + .interactive(|_| Ok(Box::new(XclipM::default()))), + CommandSpec::new("xclipframe", "Xref Clip Frame", run_frame) + .params("{value?: 0 (frames hidden) | 1 (shown and plotted) | 2 (shown, not plotted; the default)} → {XCLIPFRAME}") + .enabled(has_doc) + .interactive(|_| Ok(Box::new(FrameM))), + ] +} + +/// The block references among `hs` (other objects are ignored, as XCLIP's selection does). +fn references(s: &Session, hs: &[Handle]) -> Vec { + let Ok(d) = s.doc() else { return Vec::new() }; + hs.iter().copied().filter(|h| d.entity(*h).is_some_and(|e| matches!(e.kind, EntityKind::Insert(_)))).collect() +} + +/// A rectangle's two corners as a closed outline. +fn rectangle(a: Vec2, b: Vec2) -> Vec { + vec![ + Vec2::new(a.x.min(b.x), a.y.min(b.y)), + Vec2::new(a.x.max(b.x), a.y.min(b.y)), + Vec2::new(a.x.max(b.x), a.y.max(b.y)), + Vec2::new(a.x.min(b.x), a.y.max(b.y)), + ] +} + +/// The boundary a JSON call gives, in drawing coordinates, and whether it is a rectangle. +fn world_boundary(s: &Session, p: &Value) -> Result<(Vec, bool)> { + if let Some(r) = p.get("rectangle") { + let pts: Vec = r.as_array().map(|a| a.iter().filter_map(point_value).collect()).unwrap_or_default(); + let [a, b] = pts.as_slice() else { return Err(bad("xclip", "`rectangle` needs two finite corners [[x,y],[x,y]]")) }; + if (a.x - b.x).abs() <= 1e-12 || (a.y - b.y).abs() <= 1e-12 { + return Err(bad("xclip", "the clipping rectangle must have a width and a height")); + } + return Ok((vec![*a, *b], true)); + } + if let Some(v) = p.get("polyline") { + let h = v + .as_str() + .and_then(Handle::parse_hex) + .or_else(|| v.as_u64().map(Handle)) + .ok_or_else(|| bad("xclip", "`polyline` must be an object handle"))?; + let d = s.doc()?; + let e = d.entity(h).ok_or_else(|| bad("xclip", format!("no object {}", h.hex())))?; + let closed = matches!(&e.kind, EntityKind::LwPolyline(pl) if pl.closed) || matches!(&e.kind, EntityKind::Polyline3d(pl) if pl.closed); + if !closed { + return Err(bad("xclip", "select a closed polyline")); + } + let pts = cadcraft_render::clip::boundary_polygon(&e.kind).ok_or_else(|| bad("xclip", "the polyline does not enclose an area"))?; + return Ok((pts, false)); + } + let pts = p.get("points").and_then(Value::as_array).ok_or_else(|| bad("xclip", "a boundary is required: `rectangle`, `points` or `polyline`"))?; + if !(3..=MAX_CLIP_VERTICES).contains(&pts.len()) { + return Err(bad("xclip", format!("a polygonal boundary needs 3 to {MAX_CLIP_VERTICES} points"))); + } + let pts: Vec = + pts.iter().map(|v| point_value(v).ok_or_else(|| bad("xclip", "every boundary point must be a finite [x, y]"))).collect::>()?; + Ok((pts, false)) +} + +fn run(s: &mut Session, p: &Value) -> Result { + let hs = references(s, &targets(s, p)?); + if hs.is_empty() { + return Err(bad("xclip", "select block or xref references")); + } + let has_boundary = ["rectangle", "points", "polyline"].iter().any(|k| p.get(*k).is_some()); + let option = str_param(p, "option").map(str::to_ascii_lowercase).unwrap_or_else(|| if has_boundary { "new".into() } else { "query".into() }); + match option.as_str() { + "new" | "n" => { + let (world, rect) = world_boundary(s, p)?; + let outline = if rect { world.first().zip(world.get(1)).map(|(a, b)| rectangle(*a, *b)).unwrap_or_default() } else { world.clone() }; + if cadcraft_render::clip::PolyClip::new(&outline).is_none() { + return Err(bad("xclip", "the clipping boundary encloses no area")); + } + let invert = bool_or(p, "invert", false); + let mut clips = Vec::with_capacity(hs.len()); + { + let d = s.doc()?; + for h in &hs { + let Some(EntityKind::Insert(ins)) = d.entity(*h).map(|e| &e.kind) else { continue }; + let base = d.block(&ins.block).map(|b| b.base.xy()).ok_or_else(|| bad("xclip", format!("block \"{}\" is missing", ins.block)))?; + let inv = ins.transform(base).inverse().ok_or_else(|| bad("xclip", "a reference with a zero scale can't be clipped"))?; + let points: Vec = world.iter().map(|q| inv.apply(*q)).collect(); + // A rotated or skewed reference sees the rectangle as a polygon. + let r = ins.rotation.rem_euclid(std::f64::consts::FRAC_PI_2); + let square = r < 1e-9 || std::f64::consts::FRAC_PI_2 - r < 1e-9; + let points = if rect && !square { outline.iter().map(|q| inv.apply(*q)).collect() } else { points }; + clips.push((*h, ClipBoundary { points, enabled: true, inverted: invert })); + } + } + for (h, c) in clips { + set_clip(s, h, Some(c))?; + } + } + "on" | "off" => { + let on = option == "on"; + for h in &hs { + let mut c = clip_of(s, *h).ok_or_else(|| bad("xclip", "no clipping boundary on the selected reference"))?; + c.enabled = on; + set_clip(s, *h, Some(c))?; + } + } + "delete" | "d" => { + for h in &hs { + set_clip(s, *h, None)?; + } + } + "polyline" | "p" | "generate" => { + let mut made = Vec::new(); + for h in &hs { + let Some(world) = world_outline(s, *h) else { continue }; + let vertices = world.into_iter().map(|p| PolyVertex { p, bulge: 0.0, start_width: 0.0, end_width: 0.0 }).collect(); + let pl = cadcraft_doc::LwPolyline { vertices, closed: true, const_width: 0.0, elevation: 0.0, plinegen: false }; + made.push(s.add_entity(EntityKind::LwPolyline(pl))?.hex()); + } + if made.is_empty() { + return Err(bad("xclip", "no clipping boundary on the selected references")); + } + return Ok(json!({ "handles": hexes(&hs), "option": "polyline", "polylines": made })); + } + // Front and back clipping planes have no meaning in 2D: nothing changes. + "clipdepth" | "c" => return Ok(json!({ "handles": hexes(&hs), "option": "clipdepth", "ignored": true })), + "query" | "?" => { + let clips: Vec = hs + .iter() + .map(|h| { + let c = clip_of(s, *h); + json!({ + "handle": h.hex(), + "boundary": world_outline(s, *h).map(|w| w.iter().map(|p| [p.x, p.y]).collect::>()), + "enabled": c.as_ref().is_some_and(|c| c.enabled), + "inverted": c.as_ref().is_some_and(|c| c.inverted), + }) + }) + .collect(); + return Ok(json!({ "clips": clips })); + } + o => return Err(bad("xclip", format!("unknown option \"{o}\" (new, on, off, delete, polyline, clipdepth or query)"))), + } + Ok(json!({ "handles": hexes(&hs), "option": option })) +} + +fn hexes(hs: &[Handle]) -> Vec { + hs.iter().map(|h| h.hex()).collect() +} + +fn clip_of(s: &Session, h: Handle) -> Option { + match &s.doc().ok()?.entity(h)?.kind { + EntityKind::Insert(i) => i.clip.clone(), + _ => None, + } +} + +/// Reference `h`'s boundary outline in drawing coordinates. +fn world_outline(s: &Session, h: Handle) -> Option> { + let d = s.doc().ok()?; + let EntityKind::Insert(i) = &d.entity(h)?.kind else { return None }; + let c = i.clip.as_ref()?; + let m = i.transform(d.block(&i.block)?.base.xy()); + let poly = c.polygon(); + (poly.len() >= 3).then(|| poly.into_iter().map(|p| m.apply(p)).collect()) +} + +fn set_clip(s: &mut Session, h: Handle, c: Option) -> Result<()> { + s.doc_mut()? + .modify_entity(h, |e| { + if let EntityKind::Insert(i) = &mut e.kind { + i.clip = c; + } + }) + .map_err(|e| bad("xclip", e.to_string())) +} + +// ---------------- XCLIPFRAME ---------------- + +fn run_frame(s: &mut Session, p: &Value) -> Result { + let v = match p.get("value").or_else(|| p.get("XCLIPFRAME")) { + None | Some(Value::Null) => return Ok(json!({ "XCLIPFRAME": s.doc()?.header.i64("XCLIPFRAME", 2) })), + Some(v) => v.as_i64().or_else(|| v.as_str().and_then(|t| t.trim().parse().ok())), + }; + let v = v.filter(|v| (0..=2).contains(v)).ok_or_else(|| bad("xclipframe", "`value` must be 0, 1 or 2"))?; + s.doc_mut()?.header.set_i64("XCLIPFRAME", v); + Ok(json!({ "XCLIPFRAME": v })) +} + +struct FrameM; + +impl Interactive for FrameM { + fn name(&self) -> &'static str { + "XCLIPFRAME" + } + fn prompt(&self, s: &Session) -> Prompt { + let cur = s.doc().map(|d| d.header.i64("XCLIPFRAME", 2)).unwrap_or(2); + Prompt::new("Enter new value for XCLIPFRAME", Accept::TEXT).kw(&["0", "1", "2"]).default(cur.to_string()) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let t = match i { + Input::Cancel => return Ok(Step::Cancel), + Input::Enter => return Ok(Step::Done), + Input::Text(t) | Input::Keyword(t) => t, + _ => return Ok(Step::Continue), + }; + match run_frame(s, &json!({ "value": t.trim() })) { + Ok(_) => Ok(Step::Done), + Err(_) => { + s.echo("Requires an integer between 0 and 2."); + Ok(Step::Continue) + } + } + } +} + +// ---------------- interactive XCLIP ---------------- + +#[derive(Default, Clone, Copy, PartialEq, Eq)] +enum Stage { + #[default] + Select, + Option, + DeleteOld, + Boundary, + Corner1, + Corner2, + Polygon, + Polyline, +} + +#[derive(Default)] +struct XclipM { + stage: Stage, + sel: SelectPhase, + refs: Vec, + invert: bool, + pts: Vec, +} + +impl XclipM { + fn selected(&mut self, s: &mut Session, hs: &[Handle]) -> Result { + self.refs = references(s, hs); + if self.refs.is_empty() { + s.echo("No block or xref references selected."); + return Ok(Step::Done); + } + self.stage = Stage::Option; + Ok(Step::Continue) + } + + fn apply(&mut self, s: &mut Session, mut p: Value) -> Result { + p["handles"] = json!(hexes(&self.refs)); + let r = run(s, &p); + s.set_selection(Vec::new()); + match r { + Ok(_) => Ok(Step::Done), + Err(e) => { + s.echo(e.to_string()); + Ok(Step::Done) + } + } + } + + fn any_clipped(&self, s: &Session) -> bool { + self.refs.iter().any(|h| clip_of(s, *h).is_some()) + } +} + +fn kw(i: &Input) -> Option { + match i { + Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_ascii_lowercase()), + _ => None, + } +} + +impl Interactive for XclipM { + fn name(&self) -> &'static str { + "XCLIP" + } + + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = SelectPhase::begin(s); + if self.sel.done { + let hs = self.sel.picked.clone(); + return self.selected(s, &hs); + } + Ok(Step::Continue) + } + + fn prompt(&self, _s: &Session) -> Prompt { + match self.stage { + Stage::Select => self.sel.prompt(), + Stage::Option => Prompt::new("Enter clipping option", Accept::TEXT) + .kw(&["ON", "OFF", "Clipdepth", "Delete", "generate Polyline", "New boundary"]) + .default("New"), + Stage::DeleteOld => Prompt::new("Delete old boundary(s)?", Accept::TEXT).kw(&["Yes", "No"]).default("Yes"), + Stage::Boundary => Prompt::new("Specify clipping boundary or select invert option", Accept::POINT) + .kw(&["Select polyline", "Polygonal", "Rectangular", "Invert clip"]) + .default("Rectangular"), + Stage::Corner1 => Prompt::new("Specify first corner", Accept::POINT), + Stage::Corner2 => { + let p = Prompt::new("Specify opposite corner", Accept::POINT); + match self.pts.first() { + Some(a) => p.corner(*a), + None => p, + } + } + Stage::Polygon => { + let msg = if self.pts.is_empty() { "Specify first point" } else { "Specify next point" }; + let p = Prompt::new(msg, Accept::POINT).base_opt(self.pts.last().copied()); + if self.pts.is_empty() { p } else { p.kw(&["Undo"]) } + } + Stage::Polyline => Prompt::new("Select polyline", Accept::SELECT), + } + } + + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if matches!(i, Input::Cancel) { + return Ok(Step::Cancel); + } + match self.stage { + Stage::Select => match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Done), + SelOutcome::Done(hs) => self.selected(s, &hs), + }, + Stage::Option => { + let k = if matches!(i, Input::Enter) { Some("new".to_string()) } else { kw(&i) }; + match k.as_deref() { + Some("on") => self.apply(s, json!({ "option": "on" })), + Some("off") => self.apply(s, json!({ "option": "off" })), + Some(k) if k.starts_with('c') => { + s.echo("Clip depth does not apply to a 2D drawing: front and back clipping planes are ignored."); + Ok(Step::Done) + } + Some(k) if k.starts_with('d') => self.apply(s, json!({ "option": "delete" })), + Some(k) if k.starts_with('p') || k.starts_with('g') => self.apply(s, json!({ "option": "polyline" })), + Some(k) if k.starts_with('n') => { + self.stage = if self.any_clipped(s) { Stage::DeleteOld } else { Stage::Boundary }; + Ok(Step::Continue) + } + _ => { + s.echo("Invalid option keyword."); + Ok(Step::Continue) + } + } + } + Stage::DeleteOld => match kw(&i).as_deref() { + None if matches!(i, Input::Enter) => { + self.stage = Stage::Boundary; + Ok(Step::Continue) + } + Some(k) if k.starts_with('y') => { + self.stage = Stage::Boundary; + Ok(Step::Continue) + } + Some(k) if k.starts_with('n') => Ok(Step::Done), + _ => Ok(Step::Continue), + }, + Stage::Boundary => match (&i, kw(&i).as_deref()) { + (Input::Enter, _) => { + self.stage = Stage::Corner1; + Ok(Step::Continue) + } + (Input::Point(p), _) => { + // A point starts the default rectangle. + self.pts = vec![*p]; + self.stage = Stage::Corner2; + Ok(Step::Continue) + } + (_, Some(k)) if k.starts_with('i') => { + self.invert = !self.invert; + s.echo(if self.invert { + "Inside mode - Objects inside boundary will be hidden." + } else { + "Outside mode - Objects outside boundary will be hidden." + }); + Ok(Step::Continue) + } + (_, Some(k)) if k.starts_with('s') => { + self.stage = Stage::Polyline; + Ok(Step::Continue) + } + (_, Some(k)) if k.starts_with('p') => { + self.pts.clear(); + self.stage = Stage::Polygon; + Ok(Step::Continue) + } + (_, Some(k)) if k.starts_with('r') => { + self.stage = Stage::Corner1; + Ok(Step::Continue) + } + _ => Ok(Step::Continue), + }, + Stage::Corner1 => match i { + Input::Point(p) => { + self.pts = vec![p]; + self.stage = Stage::Corner2; + Ok(Step::Continue) + } + _ => Ok(Step::Continue), + }, + Stage::Corner2 => match (i, self.pts.first().copied()) { + (Input::Point(b), Some(a)) => self.apply(s, json!({ "option": "new", "rectangle": [[a.x, a.y], [b.x, b.y]], "invert": self.invert })), + _ => Ok(Step::Continue), + }, + Stage::Polygon => match i { + Input::Point(p) if self.pts.len() < MAX_CLIP_VERTICES => { + self.pts.push(p); + Ok(Step::Continue) + } + Input::Point(_) => { + s.echo(format!("A boundary has at most {MAX_CLIP_VERTICES} points.")); + Ok(Step::Continue) + } + Input::Keyword(_) | Input::Text(_) => { + self.pts.pop(); + Ok(Step::Continue) + } + Input::Enter if self.pts.len() >= 3 => { + let pts: Vec<[f64; 2]> = self.pts.iter().map(|p| [p.x, p.y]).collect(); + self.apply(s, json!({ "option": "new", "points": pts, "invert": self.invert })) + } + Input::Enter => { + s.echo("A polygonal boundary needs at least three points."); + Ok(Step::Continue) + } + _ => Ok(Step::Continue), + }, + Stage::Polyline => match i { + Input::Pick(hs) => match hs.first() { + Some(h) => { + let h = *h; + self.apply(s, json!({ "option": "new", "polyline": h.hex(), "invert": self.invert })) + } + None => Ok(Step::Continue), + }, + Input::Enter => Ok(Step::Done), + _ => Ok(Step::Continue), + }, + } + } +} diff --git a/crates/engine/src/cmd/exchange.rs b/crates/engine/src/cmd/exchange.rs index f9acc909e..a5cc553d5 100644 --- a/crates/engine/src/cmd/exchange.rs +++ b/crates/engine/src/cmd/exchange.rs @@ -652,6 +652,7 @@ mod tests { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }) } diff --git a/crates/engine/src/cmd/mleader_content.rs b/crates/engine/src/cmd/mleader_content.rs index 3d96c9baa..3b413724e 100644 --- a/crates/engine/src/cmd/mleader_content.rs +++ b/crates/engine/src/cmd/mleader_content.rs @@ -54,6 +54,7 @@ pub(super) fn apply(s: &Session, k: EntityKind, c: &Content) -> Result { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; block_leaves(d, &ins, &Mat3::IDENTITY, 0, budget, frozen, &mut |k, m| self.object(k, Some(m))); } diff --git a/crates/engine/src/spatial.rs b/crates/engine/src/spatial.rs index ca15bd4c4..616f69e11 100644 --- a/crates/engine/src/spatial.rs +++ b/crates/engine/src/spatial.rs @@ -352,6 +352,7 @@ mod tests { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), 8 if i % 40 == 8 => EntityKind::XLine(cadcraft_doc::RayLine { base: v3(x, y), dir: v3(r.r(-1.0, 1.0), 1.0) }), 8 if i % 40 == 18 => EntityKind::Ray(cadcraft_doc::RayLine { base: v3(x, y), dir: v3(1.0, r.r(-1.0, 1.0)) }), @@ -500,6 +501,7 @@ mod tests { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).unwrap(); let small = Bounds2::new(Vec2::new(-2.0, -2.0), Vec2::new(2.0, 2.0)); diff --git a/crates/engine/src/sysvars/table.rs b/crates/engine/src/sysvars/table.rs index fa319d3f1..e7eb62620 100644 --- a/crates/engine/src/sysvars/table.rs +++ b/crates/engine/src/sysvars/table.rs @@ -74,6 +74,8 @@ const HEADER: &[(&str, Kind, Def)] = &[ ("TRIMEXTENDMODE", Kind::Choice(&[0, 1]), Def::Int(1)), ("TRIMMODE", Kind::Choice(&[0, 1]), Def::Int(1)), ("WIPEOUTFRAME", Kind::Choice(&[0, 1, 2]), Def::Int(1)), + // XCLIP boundaries: 0 hidden, 1 shown and plotted, 2 shown but not plotted. + ("XCLIPFRAME", Kind::Choice(&[0, 1, 2]), Def::Int(2)), ]; /// The standard lineweights in hundredths of a millimetre. diff --git a/crates/engine/tests/audit.rs b/crates/engine/tests/audit.rs index 8c1ce9c4a..4084a7b1b 100644 --- a/crates/engine/tests/audit.rs +++ b/crates/engine/tests/audit.rs @@ -24,6 +24,7 @@ fn insert(block: &str) -> EntityKind { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }) } diff --git a/crates/engine/tests/autosave.rs b/crates/engine/tests/autosave.rs index da03c0d7d..c45d69c38 100644 --- a/crates/engine/tests/autosave.rs +++ b/crates/engine/tests/autosave.rs @@ -345,6 +345,7 @@ fn a_block_editor_that_cannot_be_composed_is_not_autosaved_as_the_old_host() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }); d.model.push(Entity::new(h, kind)); assert!(s.state().unwrap().recovery_drawing().is_err()); diff --git a/crates/engine/tests/clipboard.rs b/crates/engine/tests/clipboard.rs index 650d63092..b9eeff317 100644 --- a/crates/engine/tests/clipboard.rs +++ b/crates/engine/tests/clipboard.rs @@ -28,6 +28,7 @@ fn insert(d: &mut Drawing, block: &str, at: (f64, f64)) -> Entity { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }); Entity { handle: d.new_handle(), common: Common::default(), kind } } diff --git a/crates/engine/tests/compare.rs b/crates/engine/tests/compare.rs index 0e4486ccb..ed8c9a0b7 100644 --- a/crates/engine/tests/compare.rs +++ b/crates/engine/tests/compare.rs @@ -124,6 +124,7 @@ fn hooks() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).map_err(|e| e.to_string())?; return Ok(d); @@ -175,6 +176,7 @@ fn hostile_inputs_are_errors() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; b.entities.push(Entity::new(cadcraft_engine::doc::Handle(0x900), EntityKind::Insert(ins.clone()))); let d = s.doc_mut().unwrap(); diff --git a/crates/engine/tests/insert_file.rs b/crates/engine/tests/insert_file.rs index 77bfd9d85..57b7f30c9 100644 --- a/crates/engine/tests/insert_file.rs +++ b/crates/engine/tests/insert_file.rs @@ -35,6 +35,7 @@ fn read(bytes: &[u8], _name: &str) -> Result { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.model.push(Entity::new(Handle(0x61), EntityKind::Insert(ins))); Ok(d) diff --git a/crates/engine/tests/it/blockreplace.rs b/crates/engine/tests/it/blockreplace.rs index 86b8c42e3..51e7b9654 100644 --- a/crates/engine/tests/it/blockreplace.rs +++ b/crates/engine/tests/it/blockreplace.rs @@ -40,6 +40,7 @@ fn drawing() -> (Session, Handle) { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; s.doc_mut().unwrap().add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Insert(ins)).unwrap(); (s, Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()) diff --git a/crates/engine/tests/it/compare_export.rs b/crates/engine/tests/it/compare_export.rs index 1aee1b9db..6a9d58873 100644 --- a/crates/engine/tests/it/compare_export.rs +++ b/crates/engine/tests/it/compare_export.rs @@ -21,6 +21,7 @@ fn insert(block: &str) -> Insert { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, } } diff --git a/crates/engine/tests/it/count.rs b/crates/engine/tests/it/count.rs index c654814aa..d28d28ecf 100644 --- a/crates/engine/tests/it/count.rs +++ b/crates/engine/tests/it/count.rs @@ -20,6 +20,7 @@ fn insert(block: &str, x: f64, cols: u32, rows: u32) -> EntityKind { rows, col_spacing: 5.0, row_spacing: 5.0, + clip: None, }) } diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index c4c4a1ce6..3944acb94 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -142,6 +142,7 @@ mod undo_layout_tab; mod viewport_clip; mod vplayer_prompts; mod vports; +mod xclip; mod xdata_follows_entity; mod zoom_keywords; mod zoom_viewport; diff --git a/crates/engine/tests/it/measure_distance_snapping.rs b/crates/engine/tests/it/measure_distance_snapping.rs index 9842b65e0..8fc73fb23 100644 --- a/crates/engine/tests/it/measure_distance_snapping.rs +++ b/crates/engine/tests/it/measure_distance_snapping.rs @@ -310,6 +310,7 @@ fn viewport_frozen_layers_apply_inside_nested_block_references() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }) }; let mut outer = Block::new("Outer"); diff --git a/crates/engine/tests/it/purge_mlinestyles.rs b/crates/engine/tests/it/purge_mlinestyles.rs index a037096f5..a0f955c96 100644 --- a/crates/engine/tests/it/purge_mlinestyles.rs +++ b/crates/engine/tests/it/purge_mlinestyles.rs @@ -41,6 +41,7 @@ fn session() -> Session { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap(); s diff --git a/crates/engine/tests/it/refedit.rs b/crates/engine/tests/it/refedit.rs index df3aa5ea1..0eb63c29f 100644 --- a/crates/engine/tests/it/refedit.rs +++ b/crates/engine/tests/it/refedit.rs @@ -20,6 +20,7 @@ fn insert(s: &mut Session, block: &str, at: (f64, f64), scale: f64, rot_deg: f64 rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }); s.doc_mut().unwrap().add(&Space::Model, Common::default(), kind).unwrap().hex() } @@ -246,6 +247,7 @@ fn a_block_that_references_itself_in_a_file_does_not_hang() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }); let d = s.doc_mut().unwrap(); let hh = d.new_handle(); diff --git a/crates/engine/tests/it/snap_blocks.rs b/crates/engine/tests/it/snap_blocks.rs index 6d44893d7..6bd3fd09c 100644 --- a/crates/engine/tests/it/snap_blocks.rs +++ b/crates/engine/tests/it/snap_blocks.rs @@ -31,6 +31,7 @@ fn insert(s: &mut Session, block: &str, at: (f64, f64), scale: (f64, f64), rotat rows: grid.1, col_spacing: grid.2, row_spacing: grid.3, + clip: None, }); s.doc_mut().unwrap().add(&Space::Model, Common::default(), kind).unwrap().hex() } diff --git a/crates/engine/tests/it/snap_tracking.rs b/crates/engine/tests/it/snap_tracking.rs index 3843002df..de62e482c 100644 --- a/crates/engine/tests/it/snap_tracking.rs +++ b/crates/engine/tests/it/snap_tracking.rs @@ -41,6 +41,7 @@ fn insert_block(s: &mut Session, name: &str, at: Vec2) -> String { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), ) .unwrap() diff --git a/crates/engine/tests/it/xclip.rs b/crates/engine/tests/it/xclip.rs new file mode 100644 index 000000000..0b1d64443 --- /dev/null +++ b/crates/engine/tests/it/xclip.rs @@ -0,0 +1,214 @@ +//! XCLIP and XCLIPFRAME: clipping block and xref references (gap F2). + +use cadcraft_engine::doc::{ClipBoundary, Common, EntityKind, Handle, Insert, Space}; +use cadcraft_engine::geom::{Vec2, Vec3}; +use cadcraft_engine::render::{self, Options}; +use cadcraft_engine::{Input, Session}; +use serde_json::{Value, json}; + +fn h(v: &Value) -> String { + v.get("handle").unwrap_or(&v["handles"][0]).as_str().unwrap().to_string() +} + +fn insert(s: &mut Session, block: &str, at: (f64, f64), scale: f64, rot_deg: f64) -> String { + let kind = EntityKind::Insert(Insert { + block: block.into(), + insert: Vec3::new(at.0, at.1, 0.0), + scale: Vec3::new(scale, scale, scale), + rotation: rot_deg.to_radians(), + ..Insert::default() + }); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), kind).unwrap().hex() +} + +/// Block B: a horizontal line (0,0)-(100,0) and a vertical one (50,-50)-(50,50); two references. +fn setup() -> (Session, String, String) { + let mut s = Session::new(); + let a = h(&s.execute("line", &json!({ "points": [[0, 0], [100, 0]] })).unwrap()); + let b = h(&s.execute("line", &json!({ "points": [[50, -50], [50, 50]] })).unwrap()); + s.execute("block", &json!({ "name": "B", "base": [0, 0], "handles": [a, b], "keep": "delete" })).unwrap(); + let r1 = insert(&mut s, "B", (0.0, 0.0), 1.0, 0.0); + let r2 = insert(&mut s, "B", (1000.0, 0.0), 2.0, 90.0); + (s, r1, r2) +} + +fn clip(s: &Session, hex: &str) -> Option { + match &s.doc().unwrap().entity(Handle::parse_hex(hex).unwrap()).unwrap().kind { + EntityKind::Insert(i) => i.clip.clone(), + _ => None, + } +} + +/// The x extent of what the drawing shows around reference 1 (x < 500). +fn shown_x(s: &Session) -> (f64, f64) { + let list = render::build(s.doc().unwrap(), &Space::Model, &Options::default()); + let xs: Vec = list.verts.iter().filter(|v| v.x < 500.0).map(|v| v.x).collect(); + (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max)) +} + +#[test] +fn rectangular_clip_cuts_only_that_reference() { + let (mut s, r1, r2) = setup(); + assert_eq!(shown_x(&s), (0.0, 100.0)); + s.execute("setvar", &json!({ "name": "XCLIPFRAME", "value": 0 })).unwrap(); + let out = s.execute("xclip", &json!({ "handle": r1, "rectangle": [[20, -10], [70, 10]] })).unwrap(); + assert_eq!(out["option"], "new"); + let c = clip(&s, &r1).unwrap(); + assert!(c.enabled && !c.inverted && c.points.len() == 2); + let (lo, hi) = shown_x(&s); + assert!((lo - 20.0).abs() < 1e-9 && (hi - 70.0).abs() < 1e-9, "{lo} {hi}"); + assert!(clip(&s, &r2).is_none(), "the other reference stays whole"); + // The boundary follows its reference. + s.execute("move", &json!({ "handles": [r1], "delta": [5, 0] })).unwrap(); + let (lo, hi) = shown_x(&s); + assert!((lo - 25.0).abs() < 1e-9 && (hi - 75.0).abs() < 1e-9, "{lo} {hi}"); + // Its extents are the clipped part (ZOOM Extents). + let d = s.doc().unwrap(); + let b = cadcraft_engine::doc::entity_bounds(d, d.entity(Handle::parse_hex(&r1).unwrap()).unwrap(), 0); + assert!((b.min.x - 25.0).abs() < 1e-9 && (b.max.x - 75.0).abs() < 1e-9 && b.max.y <= 10.0 + 1e-9, "{b:?}"); + // Query reports it in drawing coordinates. + let q = s.execute("xclip", &json!({ "handle": r1, "option": "query" })).unwrap(); + assert_eq!(q["clips"][0]["boundary"][0], json!([25.0, -10.0])); + // OFF shows everything, ON clips again, Delete removes it; undo brings it back. + s.execute("xclip", &json!({ "handle": r1, "option": "off" })).unwrap(); + assert_eq!(shown_x(&s), (5.0, 105.0)); + s.execute("xclip", &json!({ "handle": r1, "option": "on" })).unwrap(); + assert!((shown_x(&s).0 - 25.0).abs() < 1e-9); + s.execute("xclip", &json!({ "handle": r1, "option": "delete" })).unwrap(); + assert!(clip(&s, &r1).is_none()); + s.execute("undo", &json!({})).unwrap(); + assert!(clip(&s, &r1).is_some()); +} + +#[test] +fn rotated_and_scaled_reference_gets_a_polygon_in_block_coordinates() { + let (mut s, _, r2) = setup(); + // r2: scale 2, rotated 90 degrees at (1000, 0): the block's x axis points up. + s.execute("xclip", &json!({ "handle": r2, "rectangle": [[990, 10], [1010, 50]] })).unwrap(); + let c = clip(&s, &r2).unwrap(); + // A 90 degree rotation keeps the rectangle a rectangle: (5..25, -5..5) in the block. + let poly = c.polygon(); + let xs: Vec = poly.iter().map(|p| p.x).collect(); + assert!(xs.iter().any(|x| (x - 5.0).abs() < 1e-9) && xs.iter().any(|x| (x - 25.0).abs() < 1e-9), "{poly:?}"); + // 30 degrees: kept as the four corners. + let r3 = insert(&mut s, "B", (2000.0, 0.0), 1.0, 30.0); + s.execute("xclip", &json!({ "handle": r3, "rectangle": [[1990, -10], [2010, 10]] })).unwrap(); + assert_eq!(clip(&s, &r3).unwrap().points.len(), 4); +} + +#[test] +fn inverted_polygonal_and_polyline_boundaries() { + let (mut s, r1, _) = setup(); + s.execute("setvar", &json!({ "name": "XCLIPFRAME", "value": 0 })).unwrap(); + s.execute("xclip", &json!({ "handle": r1, "points": [[40, -10], [60, -10], [60, 10], [40, 10]], "invert": true })).unwrap(); + assert!(clip(&s, &r1).unwrap().inverted); + let list = render::build(s.doc().unwrap(), &Space::Model, &Options::default()); + // The middle of the horizontal line is hidden, both ends show. + assert!(!list.verts.iter().any(|v| v.x > 40.5 && v.x < 59.5 && v.y.abs() < 1e-9)); + assert!(list.verts.iter().any(|v| v.x == 0.0) && list.verts.iter().any(|v| v.x == 100.0)); + // A closed polyline as the boundary. + let pl = h(&s.execute("pline", &json!({ "vertices": [[10, -5], [30, -5], [30, 5], [10, 5]], "closed": true })).unwrap()); + s.execute("xclip", &json!({ "handle": r1, "polyline": pl })).unwrap(); + let c = clip(&s, &r1).unwrap(); + assert!(!c.inverted && c.points.len() == 4); + // An open polyline is refused, a bad boundary changes nothing. + let open = h(&s.execute("pline", &json!({ "vertices": [[0, 0], [5, 5], [9, 0]] })).unwrap()); + assert!(s.execute("xclip", &json!({ "handle": r1, "polyline": open })).is_err()); + assert!(s.execute("xclip", &json!({ "handle": r1, "points": [[0, 0], [1, 1]] })).is_err()); + assert!(s.execute("xclip", &json!({ "handle": r1, "rectangle": [[0, 0], [0, 10]] })).is_err()); + assert_eq!(clip(&s, &r1), Some(c)); + // Generate polyline makes the boundary an object. + let n = s.doc().unwrap().model.len(); + let out = s.execute("xclip", &json!({ "handle": r1, "option": "polyline" })).unwrap(); + assert_eq!(out["polylines"].as_array().unwrap().len(), 1); + assert_eq!(s.doc().unwrap().model.len(), n + 1); + // Clip depth has no effect in 2D. + assert_eq!(s.execute("xclip", &json!({ "handle": r1, "option": "clipdepth" })).unwrap()["ignored"], true); +} + +#[test] +fn hostile_params_never_panic() { + let (mut s, r1, _) = setup(); + for p in [ + json!({ "handle": r1, "points": "x" }), + json!({ "handle": r1, "points": [[1e308, 0], [-1e308, 0], [0, 1e308]] }), + json!({ "handle": r1, "rectangle": [[f64::MAX, 0]] }), + json!({ "handle": r1, "rectangle": 5 }), + json!({ "handle": r1, "polyline": "ZZZZ" }), + json!({ "handle": r1, "option": "bogus" }), + json!({ "handle": "nothex" }), + json!({ "handles": [] }), + json!({ "handle": r1, "points": vec![[0.0, 0.0]; 5000] }), + ] { + let _ = s.execute("xclip", &p); + } + assert!(s.execute("xclipframe", &json!({ "value": 7 })).is_err()); + assert!(s.execute("xclip", &json!({ "handle": r1, "option": "on" })).is_err(), "no boundary to turn on"); +} + +#[test] +fn typed_xclip_follows_the_prompts() { + let (mut s, r1, r2) = setup(); + let pick = |hex: &str| Input::Pick(vec![Handle::parse_hex(hex).unwrap()]); + s.start("xclip").unwrap(); + s.input(pick(&r1)).unwrap(); + s.input(Input::Enter).unwrap(); + assert!(s.prompt_text().contains("Enter clipping option"), "{}", s.prompt_text()); + s.input(Input::Enter).unwrap(); // New + assert!(s.prompt_text().contains("Invert clip"), "{}", s.prompt_text()); + s.input(Input::Keyword("Invert clip".into())).unwrap(); + assert!(s.log.iter().any(|l| l.contains("Inside mode"))); + s.input(Input::Keyword("Rectangular".into())).unwrap(); + s.input(Input::Point(Vec2::new(10.0, -10.0))).unwrap(); + s.input(Input::Point(Vec2::new(30.0, 10.0))).unwrap(); + let c = clip(&s, &r1).unwrap(); + assert!(c.inverted && c.points.len() == 2); + // Again on the clipped reference: "Delete old boundary(s)?", then a polygon. + s.cmdline("xclip").unwrap(); + s.input(pick(&r1)).unwrap(); + s.input(Input::Enter).unwrap(); + s.cmdline("n").unwrap(); + assert!(s.prompt_text().contains("Delete old boundary"), "{}", s.prompt_text()); + s.cmdline("y").unwrap(); + s.cmdline("p").unwrap(); + for p in ["0,-5", "20,-5", "20,5"] { + s.cmdline(p).unwrap(); + } + s.input(Input::Enter).unwrap(); + let c = clip(&s, &r1).unwrap(); + assert!(!c.inverted && c.points.len() == 3); + // OFF, then Delete on two references at once. + s.start("xclip").unwrap(); + s.input(pick(&r1)).unwrap(); + s.input(Input::Enter).unwrap(); + s.cmdline("off").unwrap(); + assert!(!clip(&s, &r1).unwrap().enabled); + s.execute("xclip", &json!({ "handle": r2, "rectangle": [[0, 0], [1, 1]] })).unwrap(); + s.start("xclip").unwrap(); + s.input(Input::Pick(vec![Handle::parse_hex(&r1).unwrap(), Handle::parse_hex(&r2).unwrap()])).unwrap(); + s.input(Input::Enter).unwrap(); + s.cmdline("d").unwrap(); + assert!(clip(&s, &r1).is_none() && clip(&s, &r2).is_none()); + // XCLIPFRAME typed. + s.cmdline("xclipframe").unwrap(); + s.cmdline("1").unwrap(); + assert_eq!(s.execute("getvar", &json!({ "name": "XCLIPFRAME" })).unwrap()["value"], 1); +} + +#[test] +fn the_frame_shows_and_plots_as_xclipframe_says() { + let (mut s, r1, _) = setup(); + s.execute("xclip", &json!({ "handle": r1, "rectangle": [[20, -10], [70, 10]] })).unwrap(); + let frame_at = |s: &Session, plot: bool| { + let d = s.doc().unwrap(); + let list = + if plot { render::build_plot(d, &Space::Model, &Options::default()) } else { render::build(d, &Space::Model, &Options::default()) }; + list.verts.iter().any(|v| (v.y - 10.0).abs() < 1e-9 && (v.x - 20.0).abs() < 1e-9) + }; + // Default 2: shown, not plotted. + assert!(frame_at(&s, false) && !frame_at(&s, true)); + s.execute("xclipframe", &json!({ "value": 1 })).unwrap(); + assert!(frame_at(&s, false) && frame_at(&s, true)); + s.execute("xclipframe", &json!({ "value": 0 })).unwrap(); + assert!(!frame_at(&s, false) && !frame_at(&s, true)); +} diff --git a/crates/engine/tests/usage.rs b/crates/engine/tests/usage.rs index 9754ce766..e70d9d638 100644 --- a/crates/engine/tests/usage.rs +++ b/crates/engine/tests/usage.rs @@ -50,6 +50,7 @@ fn insert(handle: u64, block: &str, layer: &str) -> Entity { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), ); e.common = Common { layer: layer.into(), ..Common::default() }; diff --git a/crates/engine/tests/xref.rs b/crates/engine/tests/xref.rs index d53e37886..06ee79226 100644 --- a/crates/engine/tests/xref.rs +++ b/crates/engine/tests/xref.rs @@ -50,6 +50,7 @@ fn insert(block: &str, x: f64) -> EntityKind { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }) } diff --git a/crates/io/src/dxf_clip.rs b/crates/io/src/dxf_clip.rs new file mode 100644 index 000000000..fc3d56316 --- /dev/null +++ b/crates/io/src/dxf_clip.rs @@ -0,0 +1,374 @@ +//! XCLIP in a file (DXF Reference, SPATIAL_FILTER): a clipped block reference has an extension +//! dictionary with an `ACAD_FILTER` dictionary whose `SPATIAL` entry is a SPATIAL_FILTER object. +//! The filter holds the boundary vertices in the reference's coordinates (group 70 the count, +//! 10/20 the vertices), whether clipping is on (71), the front and back clip (72/73, 40/41) and +//! two 3×4 matrices (group 40, row by row): the inverse of the reference's transform, which takes +//! the boundary into the block's coordinates, and the boundary's own transform (identity here). +//! +//! An inverted clip is CADCraft's own entry `CADCRAFT_XCLIP` beside `ACAD_FILTER` in the same +//! extension dictionary: an XRECORD with group 70 = 1. Other programs keep it as unknown data and +//! clip normally. +//! +//! Reading takes the planar filters the model can hold out of the kept foreign data and puts them +//! on the references ([`read`]); others (front/back clipping, a skewed boundary transform) stay +//! foreign and are written back untouched. Writing adds the dictionaries and the filter of every +//! clipped reference to a copy of the drawing's foreign data ([`prepare`]), which the raw-data +//! writer (#528) then places on the reference. + +use std::sync::Arc; + +use cadcraft_doc::foreign::{Foreign, ForeignObject}; +use cadcraft_doc::{ClipBoundary, Drawing, EntityKind, Handle, MAX_CLIP_VERTICES, RawTag}; +use cadcraft_geom::{Mat3, Vec2}; + +/// The extension-dictionary entry of CADCraft's inverted-clip flag. +const INVERT_KEY: &str = "CADCRAFT_XCLIP"; +/// Handles above this are not handed out (the DXF handle space is 64-bit, keep a margin). +const MAX_HANDLE: u64 = 1 << 48; + +fn raw(code: i32, value: impl ToString) -> RawTag { + RawTag { code, value: value.to_string() } +} + +/// The handle of dictionary entry `name` (group 3 followed by 350/360). +fn entry(tags: &[RawTag], name: &str) -> Option { + tags.windows(2) + .find(|w| matches!(w, [k, v] if k.code == 3 && k.value.eq_ignore_ascii_case(name) && matches!(v.code, 350 | 360))) + .and_then(|w| w.get(1)) + .and_then(|v| Handle::parse_hex(&v.value)) +} + +/// Remove dictionary entry `name`; returns the handle it named. +fn remove_entry(tags: &mut Vec, name: &str) -> Option { + let mut old = None; + let mut i = 0; + while let (Some(k), Some(v)) = (tags.get(i), tags.get(i + 1)) { + if k.code == 3 && k.value.eq_ignore_ascii_case(name) && matches!(v.code, 350 | 360) { + old = old.or_else(|| Handle::parse_hex(&v.value)); + tags.drain(i..i + 2); + } else { + i += 1; + } + } + old +} + +fn number(tags: &[RawTag], code: i32) -> Option { + tags.iter().find(|t| t.code == code)?.value.trim().parse::().ok().filter(|n| n.is_finite()) +} + +fn object(f: &Foreign, h: Handle) -> Option<&ForeignObject> { + f.objects.iter().find(|o| o.handle == h) +} + +/// The extension dictionary, ACAD_FILTER dictionary and SPATIAL_FILTER of entity `h`. +fn chain(f: &Foreign, h: Handle) -> Option<(Handle, Handle, &ForeignObject)> { + let xd = f.entities.get(&h)?.xdict?; + let fd = entry(&object(f, xd)?.tags, "ACAD_FILTER")?; + let sf = entry(&object(f, fd)?.tags, "SPATIAL")?; + Some((xd, fd, object(f, sf).filter(|o| o.kind.eq_ignore_ascii_case("SPATIAL_FILTER"))?)) +} + +/// A 3×4 matrix (12 values row by row) as a 2D transform, when it is one (no Z mixing). +fn matrix_2d(m: &[f64]) -> Option { + let [a, c, z1, e, b, d, z2, f, z3, z4, one, z5] = <[f64; 12]>::try_from(m).ok()?; + let flat = [z1, z2, z3, z4, z5].iter().all(|v| v.abs() <= 1e-9) && (one - 1.0).abs() <= 1e-9; + (flat && [a, b, c, d, e, f].iter().all(|v| v.is_finite())).then_some(Mat3 { a, b, c, d, e, f }) +} + +fn matrix_values(m: &Mat3) -> [f64; 12] { + [m.a, m.c, 0.0, m.e, m.b, m.d, 0.0, m.f, 0.0, 0.0, 1.0, 0.0] +} + +/// The boundary a planar SPATIAL_FILTER holds, in the block's coordinates. `None` for anything +/// the model can't hold as it is (it then stays foreign). +fn parse_filter(o: &ForeignObject) -> Option { + let count = usize::try_from(number(&o.tags, 70)? as i64).ok()?; + if !(2..=MAX_CLIP_VERTICES).contains(&count) { + return None; + } + let normal_up = number(&o.tags, 210).unwrap_or(0.0).abs() <= 1e-9 + && number(&o.tags, 220).unwrap_or(0.0).abs() <= 1e-9 + && (number(&o.tags, 230).unwrap_or(1.0) - 1.0).abs() <= 1e-9; + let no_depth = number(&o.tags, 72).unwrap_or(0.0) == 0.0 && number(&o.tags, 73).unwrap_or(0.0) == 0.0; + if !normal_up || !no_depth { + return None; + } + let m: Vec = o.tags.iter().filter(|t| t.code == 40).map(|t| t.value.trim().parse::().unwrap_or(f64::NAN)).collect(); + let (inverse, own) = match m.len() { + 24 => (matrix_2d(m.get(..12)?)?, matrix_2d(m.get(12..)?)?), + // Without matrices the vertices are taken as the block's own. + 0 => (Mat3::IDENTITY, Mat3::IDENTITY), + _ => return None, + }; + if (own.a - 1.0).abs() > 1e-9 + || own.b.abs() > 1e-9 + || own.c.abs() > 1e-9 + || (own.d - 1.0).abs() > 1e-9 + || own.e.abs() > 1e-9 + || own.f.abs() > 1e-9 + { + return None; + } + let mut points = Vec::with_capacity(count); + for w in o.tags.windows(2) { + let [x, y] = w else { continue }; + if x.code != 10 { + continue; + } + if y.code != 20 || points.len() >= count { + return None; + } + let p = inverse.apply(Vec2::new(x.value.trim().parse().ok()?, y.value.trim().parse().ok()?)); + if !p.is_finite() { + return None; + } + points.push(p); + } + let clip = ClipBoundary { points, enabled: number(&o.tags, 71).unwrap_or(1.0) != 0.0, inverted: false }; + (clip.points.len() == count && cadcraft_render::clip::PolyClip::new(&clip.polygon()).is_some()).then_some(clip) +} + +/// Whether the extension dictionary `xd` marks the clip inverted. +fn inverted(f: &Foreign, xd: Handle) -> bool { + object(f, xd) + .and_then(|o| entry(&o.tags, INVERT_KEY)) + .and_then(|h| object(f, h)) + .is_some_and(|o| o.kind.eq_ignore_ascii_case("XRECORD") && number(&o.tags, 70).is_some_and(|v| v != 0.0)) +} + +/// Put the planar filters of the file read into `d` on their references, taking them out of +/// the kept foreign data (the model writes them from now on). +pub(crate) fn read(d: &mut Drawing) { + if d.foreign.objects.is_empty() { + return; + } + let found: Vec<(Handle, ClipBoundary)> = d + .foreign + .entities + .keys() + .filter_map(|h| { + let (xd, _, o) = chain(&d.foreign, *h)?; + let mut clip = parse_filter(o)?; + clip.inverted = inverted(&d.foreign, xd); + Some((*h, clip)) + }) + .collect(); + if found.is_empty() { + return; + } + let mut taken = Vec::new(); + for (h, clip) in found { + let set = |e: &mut cadcraft_doc::Entity| { + if let EntityKind::Insert(i) = &mut e.kind { + i.clip = Some(clip.clone()); + } + }; + let is_insert = |e: &cadcraft_doc::Entity| matches!(e.kind, EntityKind::Insert(_)); + let done = if d.entity(h).is_some_and(|e| is_insert(e)) { + d.modify_entity(h, set).is_ok() + } else { + let mut done = false; + for b in d.blocks.values_mut() { + if b.entities.get(h).is_some_and(|e| is_insert(e)) { + done = Arc::make_mut(b).entities.modify(h, set); + break; + } + } + done + }; + if done { + taken.push(h); + } + } + let f = Arc::make_mut(&mut d.foreign); + for h in taken { + forget(f, h); + } +} + +/// Take the clip of entity `h` out of the foreign data: the ACAD_FILTER entry and what it holds, +/// CADCraft's inverted flag, and the extension dictionary when nothing else is left in it. +fn forget(f: &mut Foreign, h: Handle) { + let Some(xd) = f.entities.get(&h).and_then(|x| x.xdict) else { return }; + let mut gone: Vec = Vec::new(); + let mut empty = false; + if let Some(o) = f.objects.iter_mut().find(|o| o.handle == xd) { + gone.extend(remove_entry(&mut o.tags, "ACAD_FILTER")); + gone.extend(remove_entry(&mut o.tags, INVERT_KEY)); + empty = !o.tags.iter().any(|t| t.code == 3); + } + // The filter dictionary's entries go with it. + let mut i = 0; + while let Some(g) = gone.get(i).copied() { + if let Some(o) = object(f, g) + && o.kind.eq_ignore_ascii_case("DICTIONARY") + { + gone.extend(o.tags.windows(2).filter_map(|w| match w { + [k, v] if k.code == 3 && matches!(v.code, 350 | 360) => Handle::parse_hex(&v.value), + _ => None, + })); + } + i += 1; + if i > MAX_CLIP_VERTICES { + break; + } + } + if empty { + gone.push(xd); + if let Some(x) = f.entities.get_mut(&h) { + x.xdict = None; + } + if f.entities.get(&h).is_some_and(|x| x.is_empty()) { + f.entities.remove(&h); + } + } + f.objects.retain(|o| !gone.contains(&o.handle)); +} + +fn dictionary(h: Handle, owner: Handle) -> ForeignObject { + ForeignObject { + handle: h, + kind: "DICTIONARY".into(), + tags: vec![ + raw(5, h.hex()), + raw(102, "{ACAD_REACTORS"), + raw(330, owner.hex()), + raw(102, "}"), + raw(330, owner.hex()), + raw(100, "AcDbDictionary"), + raw(280, 1), + raw(281, 1), + ], + } +} + +fn spatial_filter(h: Handle, owner: Handle, clip: &ClipBoundary, to_world: &Mat3, inverse: &Mat3) -> ForeignObject { + let mut tags = vec![ + raw(5, h.hex()), + raw(102, "{ACAD_REACTORS"), + raw(330, owner.hex()), + raw(102, "}"), + raw(330, owner.hex()), + raw(100, "AcDbFilter"), + raw(100, "AcDbSpatialFilter"), + raw(70, clip.points.len()), + ]; + for p in &clip.points { + let q = to_world.apply(*p); + tags.push(raw(10, q.x)); + tags.push(raw(20, q.y)); + } + tags.extend([raw(210, 0.0), raw(220, 0.0), raw(230, 1.0), raw(11, 0.0), raw(21, 0.0), raw(31, 0.0)]); + tags.extend([raw(71, i32::from(clip.enabled)), raw(72, 0), raw(73, 0)]); + for v in matrix_values(inverse).into_iter().chain(matrix_values(&Mat3::IDENTITY)) { + tags.push(raw(40, v)); + } + ForeignObject { handle: h, kind: "SPATIAL_FILTER".into(), tags } +} + +fn invert_record(h: Handle, owner: Handle) -> ForeignObject { + ForeignObject { + handle: h, + kind: "XRECORD".into(), + tags: vec![ + raw(5, h.hex()), + raw(102, "{ACAD_REACTORS"), + raw(330, owner.hex()), + raw(102, "}"), + raw(330, owner.hex()), + raw(100, "AcDbXrecord"), + raw(280, 1), + raw(70, 1), + ], + } +} + +/// A copy of `d` whose foreign data holds the SPATIAL_FILTER of every clipped reference (any +/// filter kept from the file for it is replaced). `None` when no reference is clipped. +pub(crate) fn prepare(d: &Drawing) -> Option { + let mut clips: Vec<(Handle, ClipBoundary, Mat3)> = Vec::new(); + let mut take = |e: &cadcraft_doc::Entity| { + if let EntityKind::Insert(i) = &e.kind + && let Some(c) = &i.clip + && c.polygon().len() >= 3 + && let Some(base) = d.block(&i.block).map(|b| b.base.xy()) + { + clips.push((e.handle, c.clone(), i.transform(base))); + } + }; + d.model.iter().for_each(|e| take(e)); + d.layouts.iter().flat_map(|l| l.entities.iter()).for_each(|e| take(e)); + d.blocks.values().filter(|b| b.xref_path.is_none()).flat_map(|b| b.entities.iter()).for_each(|e| take(e)); + if clips.is_empty() { + return None; + } + let mut out = d.clone(); + let f = Arc::make_mut(&mut out.foreign); + let mut next = d.handseed.max(f.objects.iter().map(|o| o.handle.0).max().unwrap_or(0)).saturating_add(1); + fn fresh(next: &mut u64) -> Handle { + let h = Handle(*next); + *next = next.saturating_add(1); + h + } + for (insert, clip, to_world) in clips { + let Some(inverse) = to_world.inverse() else { continue }; + if next.saturating_add(5) >= MAX_HANDLE { + break; + } + // A filter kept from the file for this reference is replaced. + forget(f, insert); + let xd = match f.entities.get(&insert).and_then(|x| x.xdict).filter(|h| object(f, *h).is_some()) { + Some(h) => h, + None => { + let h = fresh(&mut next); + f.objects.push(dictionary(h, insert)); + f.entities.entry(insert).or_default().xdict = Some(h); + h + } + }; + let (fd, sf) = (fresh(&mut next), fresh(&mut next)); + let flag = clip.inverted.then(|| fresh(&mut next)); + if let Some(o) = f.objects.iter_mut().find(|o| o.handle == xd) { + o.tags.extend([raw(3, "ACAD_FILTER"), raw(360, fd.hex())]); + if let Some(x) = flag { + o.tags.extend([raw(3, INVERT_KEY), raw(360, x.hex())]); + } + } + let mut filters = dictionary(fd, xd); + filters.tags.extend([raw(3, "SPATIAL"), raw(360, sf.hex())]); + f.objects.push(filters); + f.objects.push(spatial_filter(sf, fd, &clip, &to_world, &inverse)); + if let Some(x) = flag { + f.objects.push(invert_record(x, xd)); + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matrices_round_trip_row_by_row() { + let m = Mat3::translate(Vec2::new(10.0, 20.0)).then_before(Mat3::rotate(0.5)).then_before(Mat3::scale(2.0, 2.0)); + let back = matrix_2d(&matrix_values(&m)).unwrap(); + assert!((back.a - m.a).abs() < 1e-12 && (back.c - m.c).abs() < 1e-12 && (back.f - m.f).abs() < 1e-12); + // Translation is the fourth value of each row. + let t = matrix_values(&Mat3::translate(Vec2::new(-5.0, 7.0))); + assert_eq!((t[3], t[7]), (-5.0, 7.0)); + assert!(matrix_2d(&[1.0; 11]).is_none()); + } + + #[test] + fn hostile_filters_stay_foreign() { + let o = |tags: Vec| ForeignObject { handle: Handle(1), kind: "SPATIAL_FILTER".into(), tags }; + assert!(parse_filter(&o(vec![raw(70, 1_000_000_000)])).is_none()); + assert!(parse_filter(&o(vec![raw(70, 3), raw(10, 0), raw(20, 0)])).is_none()); + assert!(parse_filter(&o(vec![raw(70, 2), raw(10, "nan"), raw(20, 0), raw(10, 1), raw(20, 1)])).is_none()); + assert!(parse_filter(&o(vec![raw(70, 2), raw(10, 0), raw(20, 0), raw(10, 1), raw(20, 1), raw(72, 1)])).is_none()); + let ok = parse_filter(&o(vec![raw(70, 2), raw(10, 0), raw(20, 0), raw(10, 1), raw(20, 1)])).unwrap(); + assert_eq!(ok.points.len(), 2); + } +} diff --git a/crates/io/src/dxf_mleader.rs b/crates/io/src/dxf_mleader.rs index 04ce90a01..f6c88d41b 100644 --- a/crates/io/src/dxf_mleader.rs +++ b/crates/io/src/dxf_mleader.rs @@ -325,6 +325,7 @@ pub(crate) fn read(tags: &[Tag]) -> MLeader { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, } }); MLeader { diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 1489f4def..5e3bc3362 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -138,6 +138,7 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { d.layers.insert(0, Layer::default()); } crate::dxf_raw::read(&secs, &mut d); + crate::dxf_clip::read(&mut d); d.skipped_records = skipped; Ok(d) } diff --git a/crates/io/src/dxf_read/entities.rs b/crates/io/src/dxf_read/entities.rs index aba1bdc4e..003834c6b 100644 --- a/crates/io/src/dxf_read/entities.rs +++ b/crates/io/src/dxf_read/entities.rs @@ -165,6 +165,7 @@ pub(super) fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_lef rows: t.i(71).unwrap_or(1).clamp(1, 10_000) as u32, col_spacing, row_spacing: t.fd(45, 0.0), + clip: None, }) } "DIMENSION" => { diff --git a/crates/io/src/dxf_write/sections.rs b/crates/io/src/dxf_write/sections.rs index fe865515b..e1a6d34b9 100644 --- a/crates/io/src/dxf_write/sections.rs +++ b/crates/io/src/dxf_write/sections.rs @@ -886,6 +886,9 @@ pub(super) fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec Drawing { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), ) .unwrap(); @@ -1506,6 +1507,7 @@ fn intl_sample() -> Drawing { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), ) .unwrap(); @@ -2564,6 +2566,7 @@ fn pdf_strokes_each_transparent_primitive_inside_a_block() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&Space::Model, Common { transparency: Transparency::Percent(t), ..Common::default() }, EntityKind::Insert(ins)).unwrap(); d diff --git a/crates/io/tests/it/annotative_roundtrip.rs b/crates/io/tests/it/annotative_roundtrip.rs index b7fb62384..52cd6e762 100644 --- a/crates/io/tests/it/annotative_roundtrip.rs +++ b/crates/io/tests/it/annotative_roundtrip.rs @@ -45,6 +45,7 @@ fn drawing() -> Drawing { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; let on_paper = Common { anno_scales: vec![AnnoScale::unit()], ..Default::default() }; d.add(&Space::Paper("Layout1".into()), on_paper, EntityKind::Insert(ins)).unwrap(); diff --git a/crates/io/tests/it/assoc_array_roundtrip.rs b/crates/io/tests/it/assoc_array_roundtrip.rs index 2b869d9e9..9500671b2 100644 --- a/crates/io/tests/it/assoc_array_roundtrip.rs +++ b/crates/io/tests/it/assoc_array_roundtrip.rs @@ -56,6 +56,7 @@ fn drawing() -> Drawing { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap(); d diff --git a/crates/io/tests/it/attrib_modes.rs b/crates/io/tests/it/attrib_modes.rs index 625ead939..6efa479ff 100644 --- a/crates/io/tests/it/attrib_modes.rs +++ b/crates/io/tests/it/attrib_modes.rs @@ -45,6 +45,7 @@ fn drawing() -> Drawing { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).expect("add"); d diff --git a/crates/io/tests/it/attrib_props.rs b/crates/io/tests/it/attrib_props.rs index 32dac1101..3b750fa7a 100644 --- a/crates/io/tests/it/attrib_props.rs +++ b/crates/io/tests/it/attrib_props.rs @@ -66,6 +66,7 @@ fn drawing(attribs: Vec) -> (Drawing, Handle) { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; let h = d.add(&Space::Model, Common { layer: "INS".into(), ..Common::default() }, EntityKind::Insert(ins)).expect("add"); (d, h) diff --git a/crates/io/tests/it/dim_block_resave_roundtrip.rs b/crates/io/tests/it/dim_block_resave_roundtrip.rs index f0705d6fd..1879b976e 100644 --- a/crates/io/tests/it/dim_block_resave_roundtrip.rs +++ b/crates/io/tests/it/dim_block_resave_roundtrip.rs @@ -41,6 +41,7 @@ fn insert(block: &str) -> Insert { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, } } diff --git a/crates/io/tests/it/dynblock_read.rs b/crates/io/tests/it/dynblock_read.rs index d265f9e70..6c039e2af 100644 --- a/crates/io/tests/it/dynblock_read.rs +++ b/crates/io/tests/it/dynblock_read.rs @@ -380,6 +380,7 @@ fn parameters_in_a_stretch_selection_are_carried_along() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; let r = d.add(&cadcraft_doc::Space::Model, cadcraft_doc::Common::default(), cadcraft_doc::EntityKind::Insert(ins)).unwrap(); cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); @@ -481,6 +482,7 @@ fn door_at_100(d: &mut cadcraft_doc::Drawing) -> Handle { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&cadcraft_doc::Space::Model, cadcraft_doc::Common::default(), cadcraft_doc::EntityKind::Insert(ins)).unwrap() } diff --git a/crates/io/tests/it/entity_props_roundtrip.rs b/crates/io/tests/it/entity_props_roundtrip.rs index d86d0b4b8..f51a5b1e9 100644 --- a/crates/io/tests/it/entity_props_roundtrip.rs +++ b/crates/io/tests/it/entity_props_roundtrip.rs @@ -144,6 +144,7 @@ fn xref_block_flag_and_path_roundtrip() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; let h = add(&mut d, 0.0, EntityKind::Insert(ins)); diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 51e7f1965..f3fe05e86 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -67,4 +67,5 @@ mod tolerance_roundtrip; mod user_lin_pat_roundtrip; mod view_tables_roundtrip; mod viewport_clip_roundtrip; +mod xclip_roundtrip; mod xref_roundtrip; diff --git a/crates/io/tests/it/mleader_content.rs b/crates/io/tests/it/mleader_content.rs index 07f4b16bc..f2fb6f1db 100644 --- a/crates/io/tests/it/mleader_content.rs +++ b/crates/io/tests/it/mleader_content.rs @@ -28,6 +28,7 @@ fn spline_leader_and_block_content_are_written() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }), }; d.add(&Space::Model, Common::default(), EntityKind::MLeader(m)).unwrap(); diff --git a/crates/io/tests/it/mleader_roundtrip.rs b/crates/io/tests/it/mleader_roundtrip.rs index 4cb1bff34..f55da8925 100644 --- a/crates/io/tests/it/mleader_roundtrip.rs +++ b/crates/io/tests/it/mleader_roundtrip.rs @@ -51,6 +51,7 @@ fn insert(block: &str, at: Vec3) -> Insert { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, } } diff --git a/crates/io/tests/it/pdf_layers.rs b/crates/io/tests/it/pdf_layers.rs index f6da233ca..41c9c47b5 100644 --- a/crates/io/tests/it/pdf_layers.rs +++ b/crates/io/tests/it/pdf_layers.rs @@ -55,6 +55,7 @@ fn layered() -> Drawing { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&Space::Model, Common { layer: "Doors".into(), ..Default::default() }, EntityKind::Insert(ins)).unwrap(); d diff --git a/crates/io/tests/it/xclip_roundtrip.rs b/crates/io/tests/it/xclip_roundtrip.rs new file mode 100644 index 000000000..1e07ecf23 --- /dev/null +++ b/crates/io/tests/it/xclip_roundtrip.rs @@ -0,0 +1,164 @@ +//! XCLIP boundaries (gap F2) through DXF and DWG: SPATIAL_FILTER objects in the references' +//! ACAD_FILTER extension dictionaries. + +use std::sync::Arc; + +use cadcraft_doc::{Block, ClipBoundary, Common, Drawing, Entity, EntityKind, Handle, Insert, Line, Space}; +use cadcraft_dxf::Version; +use cadcraft_geom::{Vec2, Vec3}; +use cadcraft_io::{SaveOptions, read, write_with}; + +fn drawing() -> Drawing { + let mut d = Drawing::new_metric(); + let mut block = Block::new("Plan"); + block.entities.push(Entity::new(d.new_handle(), EntityKind::Line(Line { a: Vec3::new(-10.0, 0.0, 0.0), b: Vec3::new(10.0, 0.0, 0.0) }))); + block.entities.push(Entity::new(d.new_handle(), EntityKind::Line(Line { a: Vec3::new(0.0, -10.0, 0.0), b: Vec3::new(0.0, 10.0, 0.0) }))); + d.blocks.insert(block.name.clone(), Arc::new(block)); + let clips = [ + // A rectangle on a moved, scaled, rotated reference. + (Vec3::new(10.0, 20.0, 0.0), 2.0, 0.3, ClipBoundary::new(vec![Vec2::new(-2.0, -1.0), Vec2::new(2.0, 1.0)])), + // A triangle, inverted. + ( + Vec3::new(100.0, 0.0, 0.0), + 1.0, + 0.0, + ClipBoundary { inverted: true, ..ClipBoundary::new(vec![Vec2::new(-5.0, -5.0), Vec2::new(5.0, -5.0), Vec2::new(0.0, 5.0)]) }, + ), + // Turned off. + ( + Vec3::new(200.0, 0.0, 0.0), + 0.5, + 1.0, + ClipBoundary { enabled: false, ..ClipBoundary::new(vec![Vec2::new(-3.0, -3.0), Vec2::new(3.0, 3.0)]) }, + ), + ]; + for (at, scale, rotation, clip) in clips { + let ins = Insert { block: "Plan".into(), insert: at, scale: Vec3::new(scale, scale, 1.0), rotation, clip: Some(clip), ..Insert::default() }; + d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap(); + } + // An unclipped reference stays unclipped. + d.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { block: "Plan".into(), ..Insert::default() })).unwrap(); + d +} + +fn clips(d: &Drawing) -> Vec> { + d.model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Insert(i) => Some(i.clip.clone()), + _ => None, + }) + .collect() +} + +fn same(a: &[Option], b: &[Option]) { + assert_eq!(a.len(), b.len()); + for (x, y) in a.iter().zip(b) { + match (x, y) { + (None, None) => {} + (Some(x), Some(y)) => { + assert_eq!((x.enabled, x.inverted, x.points.len()), (y.enabled, y.inverted, y.points.len())); + for (p, q) in x.points.iter().zip(&y.points) { + assert!(p.near(*q, 1e-9), "{p:?} {q:?}"); + } + } + _ => panic!("{x:?} != {y:?}"), + } + } +} + +fn save(d: &Drawing, name: &str, version: Version) -> Vec { + write_with(d, name, &SaveOptions { version: Some(version), ..Default::default() }).unwrap() +} + +#[test] +fn clips_round_trip_through_dxf() { + let d = drawing(); + let bytes = save(&d, "a.dxf", Version::R2018); + let text = String::from_utf8(bytes.clone()).unwrap(); + assert_eq!(text.matches("SPATIAL_FILTER").count(), 3, "one filter per clipped reference"); + assert!(text.contains("ACAD_FILTER") && text.contains("CADCRAFT_XCLIP")); + let first = read(&bytes, "a.dxf").unwrap(); + same(&clips(&first), &clips(&d)); + // The model owns the filters now: nothing of them is left as foreign data. + assert!(!first.foreign.objects.iter().any(|o| o.kind == "SPATIAL_FILTER")); + for v in [Version::R2000, Version::R2004, Version::R2010, Version::R2018] { + let back = read(&save(&first, "a.dxf", v), "a.dxf").unwrap(); + same(&clips(&back), &clips(&d)); + assert_eq!(back.model.len(), first.model.len(), "{}", v.year()); + assert!(!back.foreign.objects.iter().any(|o| o.kind == "SPATIAL_FILTER"), "{}", v.year()); + let again = String::from_utf8(save(&back, "a.dxf", v)).unwrap(); + assert_eq!(again.matches("SPATIAL_FILTER").count(), 3, "no duplicate filters on resave"); + } +} + +#[test] +fn clips_round_trip_through_dwg() { + let d = drawing(); + let first = read(&save(&d, "a.dxf", Version::R2018), "a.dxf").unwrap(); + for v in [Version::R2004, Version::R2010, Version::R2018] { + let back = read(&save(&first, "a.dwg", v), "a.dwg").unwrap(); + same(&clips(&back), &clips(&d)); + assert_eq!(back.model.len(), first.model.len(), "{}", v.year()); + let blk = |d: &Drawing| d.block("Plan").map(|b| b.entities.len()); + assert_eq!(blk(&back), blk(&first), "{}", v.year()); + } +} + +#[test] +fn deleting_a_clip_read_from_a_file_removes_its_filter() { + let mut d = read(&save(&drawing(), "a.dxf", Version::R2018), "a.dxf").unwrap(); + let hs: Vec = d.model.iter().filter(|e| matches!(&e.kind, EntityKind::Insert(i) if i.clip.is_some())).map(|e| e.handle).collect(); + for h in &hs { + d.modify_entity(*h, |e| { + if let EntityKind::Insert(i) = &mut e.kind { + i.clip = None; + } + }) + .unwrap(); + } + let text = String::from_utf8(save(&d, "a.dxf", Version::R2018)).unwrap(); + assert!(!text.contains("SPATIAL_FILTER") && !text.contains("CADCRAFT_XCLIP")); + assert!(clips(&read(text.as_bytes(), "a.dxf").unwrap()).iter().all(Option::is_none)); +} + +#[test] +fn filter_matrices_are_the_inverse_reference_transform() { + // A reference at (150, 0), unrotated: the inverse moves by -150; vertices in drawing units. + let mut d = Drawing::new_metric(); + let mut block = Block::new("P"); + block.entities.push(Entity::new(d.new_handle(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 1.0, 0.0) }))); + d.blocks.insert("P".into(), Arc::new(block)); + let clip = ClipBoundary::new(vec![Vec2::new(20.0, 20.0), Vec2::new(80.0, 20.0), Vec2::new(80.0, 80.0), Vec2::new(20.0, 80.0)]); + let h = d + .add( + &Space::Model, + Common::default(), + EntityKind::Insert(Insert { block: "P".into(), insert: Vec3::new(150.0, 0.0, 0.0), clip: Some(clip), ..Insert::default() }), + ) + .unwrap(); + // Read back without CADCraft taking the filter: look at the DXF groups. + let text = String::from_utf8(save(&d, "a.dxf", Version::R2018)).unwrap(); + let lines: Vec<&str> = text.lines().map(str::trim).collect(); + let at = lines.windows(2).position(|w| w[0] == "0" && w[1] == "SPATIAL_FILTER").unwrap(); + let end = (at + 2..lines.len()).step_by(2).find(|i| lines[*i] == "0").unwrap(); + let rec = &lines[at..end]; + let values = |code: &str| -> Vec { rec.chunks(2).filter(|w| w[0] == code).map(|w| w[1].parse().unwrap()).collect() }; + assert_eq!(values("70"), vec![4.0]); + assert_eq!(values("10"), vec![170.0, 230.0, 230.0, 170.0]); + assert_eq!( + values("40"), + vec![1.0, 0.0, 0.0, -150.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0] + ); + let _ = h; +} + +#[test] +fn hostile_spatial_filters_do_not_panic() { + let text = String::from_utf8(save(&drawing(), "a.dxf", Version::R2018)).unwrap(); + // Vertex counts, coordinates and matrices replaced by nonsense. + for (from, to) in [(" 70\n3\n", " 70\n999999999\n"), ("\n 40\n1.0\n", "\n 40\nnan\n"), ("\n 10\n", "\n 10\n1e999\n 10\n")] { + let bad = text.replacen(from, to, 1); + let _ = read(bad.as_bytes(), "a.dxf"); + } +} diff --git a/crates/io/tests/it/xref_roundtrip.rs b/crates/io/tests/it/xref_roundtrip.rs index 815d71afa..58869c0d9 100644 --- a/crates/io/tests/it/xref_roundtrip.rs +++ b/crates/io/tests/it/xref_roundtrip.rs @@ -52,6 +52,7 @@ fn host() -> Drawing { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).expect("add"); } diff --git a/crates/render/src/blocks.rs b/crates/render/src/blocks.rs index ad4689682..9782ae177 100644 --- a/crates/render/src/blocks.rs +++ b/crates/render/src/blocks.rs @@ -55,19 +55,69 @@ pub(crate) fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc: let off = Vec2::new(ins.col_spacing * f64::from(c), ins.row_spacing * f64::from(r)).rotate(ins.rotation); let m = Mat3::translate(off).then_before(ins.transform(blk.base.xy())); let sub = Ctx { block_pstyle: b.named, block_transparency: b.transparency, ..sub_ctx(ctx, e, m) }; + // XCLIP: the copy is drawn on its own, then cut to the boundary. + let clip = xclip_region(ins, &sub.xf); + let mut part = clip.as_ref().map(|_| b.part()); + let target = part.as_mut().unwrap_or(&mut *b); + let mut stop = false; for be in blk.entities.iter() { // Constant/visible attribute definitions inside blocks are not drawn; attribs are. if matches!(be.kind, EntityKind::AttDef(_)) { continue; } - if !b.expand() { - break 'copies; + if !target.expand() { + stop = true; + break; } - entity(b, &sub, be); + entity(target, &sub, be); + } + if let (Some(part), Some((region, inverted))) = (part, &clip) { + b.list.expanded = part.list.expanded; + append_clipped_keep(&mut b.list, &part.list, &if *inverted { Clip::Outside(region) } else { Clip::Poly(region) }); + xclip_frame(b, ctx, region, rgb); + } + if stop { + break 'copies; } } } } + // Attributes are cut by the reference's clip too. + let attr_clip = ctx.d.block(&ins.block).and_then(|blk| xclip_region(ins, &ctx.xf.then_before(ins.transform(blk.base.xy())))); + let mut part = attr_clip.as_ref().map(|_| b.part()); + attributes(part.as_mut().unwrap_or(&mut *b), ctx, e, ins, rgb); + if let (Some(part), Some((region, inverted))) = (part, &attr_clip) { + append_clipped_keep(&mut b.list, &part.list, &if *inverted { Clip::Outside(region) } else { Clip::Poly(region) }); + } +} + +/// The XCLIP region of reference `ins` drawn through `xf` (block to drawing coordinates), and +/// whether it is inverted. `None` when the reference isn't clipped (no boundary, clipping off, a +/// degenerate outline). +fn xclip_region(ins: &cadcraft_doc::Insert, xf: &Mat3) -> Option<(clip::PolyClip, bool)> { + let c = ins.clip.as_ref().filter(|c| c.active())?; + let world: Vec = c.polygon().into_iter().map(|p| xf.apply(p)).collect(); + Some((clip::PolyClip::new(&world)?, c.inverted)) +} + +/// The clipping boundary's frame as XCLIPFRAME says: 0 hidden, 1 shown and plotted, 2 shown but +/// not plotted (the default). +fn xclip_frame(b: &mut Builder, ctx: &Ctx, region: &clip::PolyClip, rgb: Ink) { + let mode = ctx.d.header.i64("XCLIPFRAME", 2); + if mode == 0 || (b.plotting && mode != 1) { + return; + } + let mut frame = region.polygon().to_vec(); + if let Some(first) = frame.first().copied() { + frame.push(first); + } + // The outline is in drawing coordinates already. + let top = Ctx { xf: Mat3::IDENTITY, ..ctx.clone() }; + b.polyline(&top, rgb, 0.0, &frame); +} + +/// The attributes of block reference `e` (`ins`). +fn attributes(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rgb: Ink) { if b.opts.text { // ATTMODE (ATTDISP): 0 shows no attributes, 1 the visible ones, 2 all of them. let attmode = ctx.d.header.i64("ATTMODE", 1); diff --git a/crates/render/src/clip.rs b/crates/render/src/clip.rs index 73d89fdc3..a95455d3d 100644 --- a/crates/render/src/clip.rs +++ b/crates/render/src/clip.rs @@ -423,6 +423,80 @@ impl PolyClip { } out } + + /// Clip a segment the other way round (an inverted XCLIP): the pieces outside the region. + fn clip_segment_outside(&self, a: Vec2, b: Vec2, out: &mut Vec<(Vec2, Vec2)>) { + if !a.is_finite() || !b.is_finite() { + return; + } + if clip_segment(a, b, &self.bounds).is_none() { + out.push((a, b)); + 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 to outside the region (an inverted XCLIP); the visible pieces. + pub fn clip_polyline_outside(&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_outside(*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) to outside the region: the line cut where it crosses. + pub fn clip_infinite_outside(&self, base: Vec2, dir: Vec2, ray: bool) -> Vec> { + let len = dir.len(); + if !base.is_finite() || !dir.is_finite() || len < 1e-300 { + return Vec::new(); + } + let u = dir * (1.0 / len); + // Far enough past the region and the base point that the cut pieces reach the view. + let far = (self.bounds.width() + self.bounds.height() + (base - self.bounds.center()).len()) * 4.0 + 1e6; + let a = if ray { base } else { base - u * far }; + self.clip_polyline_outside(&[a, base + u * far]) + } + /// Fill triangles outside the region (an inverted XCLIP). A triangle wholly inside goes; one + /// the outline crosses stays whole (fills are not cut against the outside). + pub fn clip_triangles_outside(&self, tris: &[Vec2]) -> Vec { + let mut out = Vec::new(); + for t in tris.as_chunks::<3>().0 { + let inside = t.iter().all(|p| self.contains(*p)) && (0..3).all(|i| self.crossings(t[i], t[(i + 1) % 3]).is_empty()); + if !inside { + out.extend_from_slice(t); + } + } + out + } } /// Clip a polygon to a triangle (Sutherland–Hodgman; the triangle is convex). diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5b9dc83a1..c905992c8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -133,7 +133,7 @@ struct Builder<'a> { /// bounds the depth); past the limit the remaining block contents are not drawn. pub const MAX_BLOCK_EXPANSION: usize = 2_000_000; -impl Builder<'_> { +impl<'a> Builder<'a> { /// `color` and `lw` as the plot style table prints them. fn styled(&self, color: Ink, lw: f32) -> (Ink, f32) { match self.styles { @@ -288,6 +288,21 @@ impl Builder<'_> { len: 2, }); } + /// An empty builder drawing like this one (for contents cut afterwards, an XCLIP), with the + /// block-expansion count carried over. + fn part(&self) -> Builder<'a> { + let mut list = DisplayList::default(); + list.expanded = self.list.expanded; + Builder { + list, + opts: self.opts, + plotting: self.plotting, + styles: self.styles, + named: self.named, + shared_budget: self.shared_budget, + transparency: self.transparency, + } + } /// Take one unit of the block-expansion budget; false once it is spent. fn expand(&mut self) -> bool { self.list.expanded = self.list.expanded.saturating_add(1); @@ -316,6 +331,7 @@ pub use pipeline::{Options, build, build_entities, build_plot}; use props::{default_lineweight, resolve_common}; use text_entity::text_style; pub use text_entity::{layout_mtext_entity, place_text_entity, text_align}; +use viewports::{Clip, append_clipped_keep}; pub use viewports::{viewport_clip, viewport_content}; #[cfg(test)] diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ae35f27f9..98b71e2bf 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -632,6 +632,7 @@ fn hatch_in_a_squashed_block_keeps_lines_that_show() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap(); // One pixel = 1 unit: 0.125 spacing × 100 along x is far more than a pixel. @@ -676,6 +677,7 @@ fn insert_of(block: &str) -> EntityKind { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }) } diff --git a/crates/render/src/viewports.rs b/crates/render/src/viewports.rs index 6f1e57517..a3b585601 100644 --- a/crates/render/src/viewports.rs +++ b/crates/render/src/viewports.rs @@ -187,16 +187,50 @@ pub fn viewport_clip(d: &Drawing, handle: Handle, vp: &cadcraft_doc::Viewport) - } /// What viewport contents are clipped to. -enum Clip<'a> { +pub(crate) enum Clip<'a> { /// The viewport rectangle (the fast path). Rect(Bounds2), /// A clip boundary's outline. Poly(&'a clip::PolyClip), + /// Outside a clip boundary's outline (an inverted XCLIP). + Outside(&'a clip::PolyClip), } /// Append `src` to `dst`, clipped to `clip`. -fn append_clipped(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) { +pub(crate) fn append_clipped(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) { for p in &src.prims { + clip_prim(dst, src, p, clip); + } +} + +/// Append `src` to `dst` clipped to `clip`, keeping each primitive's plot style pen and, for a +/// glyph that the clip leaves whole, its plotted text (an XCLIP-clipped block reference). +pub(crate) fn append_clipped_keep(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) { + let texts: std::collections::HashMap = + src.texts.iter().filter(|t| t.prim != PlotText::NONE && t.prim != PlotText::CLIPPED).map(|t| (t.prim as usize, t)).collect(); + for (i, p) in src.prims.iter().enumerate() { + let first = dst.prims.len(); + clip_prim(dst, src, p, clip); + if let Some(pen) = src.pens.get(i).copied().filter(|pen| !pen.is_object()) { + for k in first..dst.prims.len() { + dst.pens.resize(k, Pen::default()); + dst.pens.push(pen); + } + } + if dst.prims.len() == first + 1 + && let (Some(q), Some(t)) = (dst.prims.get(first), texts.get(&i)) + && q.kind == p.kind + && dst.points(q) == src.points(p) + && let Ok(prim) = u32::try_from(first) + { + dst.texts.push(PlotText { prim, ..(**t).clone() }); + } + } +} + +/// Append primitive `p` of `src` to `dst`, clipped to `clip`. +fn clip_prim(dst: &mut DisplayList, src: &DisplayList, p: &DPrim, clip: &Clip) { + { let pts = src.points(p); match (p.kind, clip) { (Kind::Polyline, Clip::Rect(rect)) => { @@ -209,6 +243,11 @@ fn append_clipped(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) { push_raw(dst, p, Kind::Polyline, &piece); } } + (Kind::Polyline, Clip::Outside(c)) => { + for piece in c.clip_polyline_outside(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); @@ -217,10 +256,15 @@ fn append_clipped(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) { let t = c.clip_triangles(pts); push_raw(dst, p, p.kind, &t); } + (Kind::Tris | Kind::Mask, Clip::Outside(c)) => { + let t = c.clip_triangles_outside(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), + Clip::Outside(c) => !c.contains(q), }; if let Some(q) = pts.first() && inside(*q) @@ -242,6 +286,13 @@ fn append_clipped(dst: &mut DisplayList, src: &DisplayList, clip: &Clip) { } } } + (Kind::Infinite { ray }, Clip::Outside(c)) => { + if let (Some(base), Some(dir)) = (pts.first(), pts.get(1)) { + for piece in c.clip_infinite_outside(*base, *dir, ray) { + push_raw(dst, p, Kind::Polyline, &piece); + } + } + } } } } diff --git a/crates/render/tests/block_ltscale.rs b/crates/render/tests/block_ltscale.rs index 4e5863752..3f8334e48 100644 --- a/crates/render/tests/block_ltscale.rs +++ b/crates/render/tests/block_ltscale.rs @@ -29,6 +29,7 @@ fn drawing(ltscale: f64, own: &str) -> Drawing { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; d.add(&Space::Model, reference, EntityKind::Insert(ins)).unwrap(); d diff --git a/crates/render/tests/it/aci7_display.rs b/crates/render/tests/it/aci7_display.rs index 5881c6bce..943d1ba7b 100644 --- a/crates/render/tests/it/aci7_display.rs +++ b/crates/render/tests/it/aci7_display.rs @@ -27,6 +27,7 @@ fn insert(y: f64) -> EntityKind { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }) } diff --git a/crates/render/tests/it/plot_styles.rs b/crates/render/tests/it/plot_styles.rs index 75134911d..4e027506c 100644 --- a/crates/render/tests/it/plot_styles.rs +++ b/crates/render/tests/it/plot_styles.rs @@ -143,6 +143,7 @@ fn named_styles_by_layer_by_block_and_name() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }); d.add(&Space::Model, Common { plot_style: "Thick".into(), ..Default::default() }, ins).unwrap(); let l = build_plot(&d, &Space::Model, &with_table(PlotStyleTable::builtin("default.stb").unwrap())); diff --git a/crates/render/tests/it/render_props.rs b/crates/render/tests/it/render_props.rs index f210dc4e1..7591d6747 100644 --- a/crates/render/tests/it/render_props.rs +++ b/crates/render/tests/it/render_props.rs @@ -169,6 +169,7 @@ fn attributes_draw_with_their_own_properties() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; let common = Common { color: Color::Index(1), lineweight: Lineweight::Mm100(35), ..on("INS") }; d.add(&Space::Model, common, EntityKind::Insert(ins)).unwrap(); diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 634249093..f4fd969b6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -142,6 +142,8 @@ menu Modify Edita Clear Cancella Clip Delimita Clip Viewport Delimita finestra + Clip Xref Ritaglia xrif + Xref Clip Frame Cornice ritaglio xrif Clipboard Appunti Close Chiudi Close All Chiudi tutto diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d71b3ccd7..399e66ddf 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -139,6 +139,8 @@ Clear Очистити Clip Обрізати Clip Viewport Обрізати видовий екран + Clip Xref Обрізати зовнішнє посилання + Xref Clip Frame Рамка обрізки зовнішнього посилання Clipboard Буфер обміну Close Закрити Close All Закрити всі diff --git a/docs/gaps.md b/docs/gaps.md index 9c0bf3630..8e7bf311a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (Named Viewports dialog: U11 narrowed; MVIEW Restore and PURGE of multiline styles had landed: F15 and F21 corrected) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (XCLIP and XCLIPFRAME: F2 and top-10 row 4 narrowed) · **Target:** Autodesk AutoCAD 2027 Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR @@ -16,7 +16,7 @@ do them first. | 1 | **[alpha blocker]** **Saving DWG drops what we don't model** (DXF now writes back unknown entities and objects, foreign xdata, extension dictionaries, reactors, layer states and colour books, #528, and native MULTILEADERs, #459 (not yet tested through DWG); through DWG, acadrust drops custom-class entities and objects, xdata on table records and blocks, entity colour books and reactors) | Opening a colleague's DWG and saving it still deletes some content: the one thing a CAD user can't forgive | 6–10 | [file formats](file-format-parity.md) | | 2 | **[alpha blocker]** **No real-file test corpus and no check in AutoCAD** | All 36 file tests are synthetic; nobody has verified a CADCraft DWG/DXF opens cleanly in AutoCAD | 10–15 + human | [file formats](file-format-parity.md#tests) | | 3 | **Ellipse and spline geometry is tessellated** (intersections, offsets, closest points; polyline offset ignores bulges) | Coordinates are subtly wrong; offsets of ellipses come out as polylines | 30–50 | [geometry](geometry-parity.md) | -| 4 | **[alpha blocker]** **No dynamic blocks or REFEDIT; xrefs can't be edited in place or clipped** (xrefs, #525, groups, #417, and the Block Editor for static blocks landed) | Most production drawings use them | 55–90 | [features](#features) | +| 4 | **[alpha blocker]** **No dynamic blocks or REFEDIT; xrefs can't be edited in place** (xrefs, #525, XCLIP, groups, #417, and the Block Editor for static blocks landed) | Most production drawings use them | 55–90 | [features](#features) | | 5 | **No autosave in the web build** (the desktop app has SAVETIME autosave, Drawing Recovery, AUDIT and RECOVER since #406) | Browser users lose unsaved work when the tab or the browser goes down | 3–5 | [features](#features) | | 6 | **No CI runs the Rust tests on pull requests** (`cargo fmt` on main is fixed) | open PRs land without a gate; regressions go unseen | 2–3 | [stability](#stability) | @@ -25,7 +25,7 @@ do them first. | # | Gap | Evidence | User impact | Est. | |---|---|---|---|---| | F1 | **[alpha blocker]** Prompt options that still print "not available yet": -ATTEDIT one-at-a-time Position, Height, Angle and Style, colour books in -ATTEDIT Color, -RENAME of materials, plot styles and viewport configurations. SPLINEDIT Join, Edit vertex Add and Elevate order (#559), PEDIT Edit vertex and the other SPLINEDIT options (#558) done: Edit vertex Tangent directions last for the PEDIT session only (LWPOLYLINE stores none). MTEXT Dynamic/Static columns done (#547). SPLINE, MTEXT and MLEADER options done (#408): SPLINE Object fits the polyline's vertices (no spline frame is kept) (STRETCH polygon, CP and WP, and fence selection done; TRIM/EXTEND, PLINE arc mode and Length, XLINE done (#397); OFFSET, ROTATE and FILLET options done earlier) | `cmd/blocks/attedit.rs`, `cmd/props/rename.rs` | Low: the most-used commands are done | 1–3 | -| F2 | XREF editing in place and clipping: XCLIP is missing. REFEDIT, REFSET and REFCLOSE edit a block reference in place (working set, faded rest of the block, everything else locked, save or discard; #813); left: editing an xref in place (its source drawing is not written back; XOPEN opens it), picking nested references, a dimmed look for the locked drawing and a banner with Save and Discard, attribute sync of other references (ATTSYNC). XATTACH, the External References palette, -XREF (Bind/Detach/Path/Unload/Reload/Overlay), XBIND, XOPEN, overlays, relative paths and loading at open are done (#525). Unloaded state and xref-layer overrides last only for the session | `cmd/blocks/xref.rs` | Medium: xrefs can't be edited in place or clipped | 15–25 | +| F2 | XREF editing in place: REFEDIT, REFSET and REFCLOSE edit a block reference in place (working set, faded rest of the block, everything else locked, save or discard; #813); left: editing an xref in place (its source drawing is not written back; XOPEN opens it), picking nested references, a dimmed look for the locked drawing and a banner with Save and Discard, attribute sync of other references (ATTSYNC). XCLIP clips a block or xref reference to a rectangular, polygonal or closed-polyline boundary (Invert clip, ON/OFF, Delete, generate Polyline; Clipdepth is ignored in 2D), drawn, plotted and framed as XCLIPFRAME says, with the extents cut to it, and kept through DXF and DWG as the reference's SPATIAL_FILTER (2026-10-27). XATTACH, the External References palette, -XREF (Bind/Detach/Path/Unload/Reload/Overlay), XBIND, XOPEN, overlays, relative paths and loading at open are done (#525). Unloaded state and xref-layer overrides last only for the session | `cmd/blocks/xref.rs` | Medium: xrefs can't be edited in place | 12–20 | | F3 | **[alpha blocker]** (dynamic-block authoring is beta) No dynamic-block authoring: parameters and actions can't be added in the Block Editor. Dynamic blocks from other files are read and their references edited (visibility, distances, stretch and move, lookups, grips). BEDIT (with the Edit Block Definition dialog, which also starts new blocks), BSAVE, BSAVEAS and BCLOSE edit a static block definition, and every reference updates; REFEDIT edits a reference in place; no authoring palettes yet | issue #185 offers help | High: standard libraries are dynamic | 40–65 | | F4 | Autosave leftovers: no autosave in the web build (browser storage); autosave files are DXF, not DWG; no separate RECOVER mode in the file readers (SAVETIME, Drawing Recovery, AUDIT and RECOVER landed with #406; AUDITCTL `.adt` log landed) | `engine/src/autosave.rs`, `engine/src/audit.rs` | Low-medium (web users) | 2–4 | | F5 | Associative arrays (ASsociative, Levels, polar ROWs, ARRAYEDIT, ARRAYCLOSE) landed (#448); left: grip editing of arrays (count/spacing grips), ARRAYPATH is not associative, ARRAYEDIT has no item deletion (CTRL+click) and no Key point base points, levels only shift Z (no 3D view) | `cmd/array.rs`, `cmd/array/edit.rs` | Medium | 4–6 | @@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | XCLIP (rectangular, polygonal and polyline boundaries, Invert clip, ON/OFF, Delete, generate Polyline) and XCLIPFRAME: a boundary per block or xref reference (`Insert::clip`, block coordinates), drawn, plotted and framed clipped, the extents cut to it, and read and written as the reference's ACAD_FILTER/SPATIAL SPATIAL_FILTER in DXF and DWG: F2 and top-10 row 4 narrowed | | 2026-10-25 | minor | Named Viewports (View ▸ Viewports ▸ Named Viewports..., `vports.named`) lists the saved viewport configurations with a preview and the current one's name, and restores or deletes one; in a layout Restore places it as MVIEW Restore does: U11 narrowed. F15 and U11 no longer list MVIEW Restore (#633), and F21 no longer says PURGE skips multiline styles | | 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated | | 2026-10-24 | minor | COMPAREEXPORT (typed, or Export on the comparison bar) opens the comparison as a new untitled drawing, `compare_ vs .dwg`: this drawing's objects in one group and the other drawing's differing objects in a second group, in the comparison colors (green, red, gray for objects in both), block contents and style colors included: F22 narrowed | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6b5407d60..14a235579 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: CADCraft vs AutoCAD 2027 -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (COMPAREEXPORT; numbers not re-measured) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (XCLIP; numbers not re-measured) · **Target:** Autodesk AutoCAD 2027 The authoritative assessment of how close CADCraft is to AutoCAD, and how much Opus 5.5 work remains. [ROADMAP.md](../ROADMAP.md) summarises it, [gaps.md](gaps.md) lists every shortfall, @@ -154,7 +154,7 @@ presence: an option that prints "not available yet" doesn't count. | Text and MTEXT | 6% | 35% | 30–45 | TrueType, formatting codes, stacking; MTEXT prompt options (#408), indents, tabs, bullets, numbering and columns (#547), annotative scaling (#468); editor is a dialog, not in place; no fields or SPELL (open PR #551), no SHX | [gaps](gaps.md#features) | | Leaders, tables, fields | 4% | 25% | 25–35 | MLEADER options and block content (#408), native MULTILEADER in DXF (#459); tables with cell styles, formulas, breaking and CSV data links (#534); no MLEADEREDIT/ALIGN/COLLECT, no FIELD | [gaps](gaps.md#features) | | Hatch and gradients | 5% | 35% | 15–25 | pick points with islands; 23 patterns of ~85; gradients render flat; origin, gap tolerance, separate hatches, island styles and HATCHEDIT options (#451); Hatch and Gradient / Hatch Edit dialogs (no Preview or Inherit Properties) | [gaps](gaps.md#features) | -| Blocks, attributes, xrefs, groups | 9% | 25% | 80–120 | static blocks and attributes; ATTSYNC, BATTMAN editing, DATAEXTRACTION/EATTEXT (#433); xrefs (#525); groups (#417); Block Editor for static blocks (BEDIT, BSAVE, BSAVEAS, BCLOSE); REFEDIT; dynamic block references read and edited; no dynamic-block authoring, XCLIP, DesignCenter | [gaps](gaps.md#features) | +| Blocks, attributes, xrefs, groups | 9% | 25% | 80–120 | static blocks and attributes; ATTSYNC, BATTMAN editing, DATAEXTRACTION/EATTEXT (#433); xrefs (#525); groups (#417); Block Editor for static blocks (BEDIT, BSAVE, BSAVEAS, BCLOSE); REFEDIT; dynamic block references read and edited; XCLIP; no dynamic-block authoring, DesignCenter | [gaps](gaps.md#features) | | Layouts, viewports, plotting | 9% | 35% | 35–50 | layouts, per-VP layers, page setups, plot styles (CTB/STB), Plot dialog (#160), Plot Preview and PUBLISH (#186), plotter devices (#564), PDF with text, layers and sheets (#567, #571), polygonal/object/clipped viewports (#453), annotative scales (#468); no MVIEW Restore, no system printers | [gaps](gaps.md#features) | | Inquiry and utilities | 4% | 30% | 30–45 | DIST, AREA, ID, LIST, MEASUREGEOM, CAL, FIND, COUNT across layouts, QuickCalc, COMPARE (#563) and COMPAREEXPORT, MASSPROP on regions (#510), autosave/recovery, AUDIT, RECOVER (#406), OPTIONS (#23); no SPELL (open PR #551), ETRANSMIT | [gaps](gaps.md#features) | | Parametric constraints | 2% | 50% | 20–30 | all 12 geometric and the dimensional constraints, own solver; DOF report, G2 Smooth, ellipse/spline/block geometry and CONSTRAINTINFER landed (#546) | [geometry-parity](geometry-parity.md#constraint-solver--50) | @@ -223,6 +223,7 @@ DWG/DXF corpus (files from people who own them); black-box checks in AutoCAD of | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | Blocks, attributes, xrefs, groups row: XCLIP and XCLIPFRAME clip block and xref references; parity percentages and hours not re-measured | | 2026-10-24 | minor | Inquiry and utilities row: COMPAREEXPORT exports a comparison to a new drawing; parity percentages and hours not re-measured | | 2026-10-22 | minor | Draw row: RECTANG gained a centre construction mode (`Center` keyword, `center: true`) beside its corner mode (#1127); parity percentages and hours not re-measured | | 2026-10-12 | minor | Hatch row evidence: Hatch and Gradient / Hatch Edit dialogs; percentages and hours not re-measured |