REGION: region entity, UNION/SUBTRACT/INTERSECT, MASSPROP (fixes #510) (#526)

Squash of PR #526 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein
2026-10-11 12:12:09 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent bc7744b27b
commit afae36cb60
16 changed files with 888 additions and 7 deletions
+16
View File
@@ -786,6 +786,9 @@ pub struct Table {
fn default_text_h() -> f64 {
0.18
}
mod region;
pub use region::*;
/// A raw DXF group: kept for round-tripping entities we do not model.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RawTag {
@@ -824,6 +827,7 @@ pub enum EntityKind {
Solid(Solid),
Trace(Solid),
Face3d(Face3d),
Region(Region),
Viewport(Viewport),
Image(Image),
Wipeout(Wipeout),
@@ -877,6 +881,7 @@ impl EntityKind {
EntityKind::Solid(_) => "2D Solid",
EntityKind::Trace(_) => "Trace",
EntityKind::Face3d(_) => "3D Face",
EntityKind::Region(_) => "Region",
EntityKind::Viewport(_) => "Viewport",
EntityKind::Image(_) => "Raster Image",
EntityKind::Wipeout(_) => "Wipeout",
@@ -911,6 +916,7 @@ impl EntityKind {
EntityKind::Solid(_) => "SOLID",
EntityKind::Trace(_) => "TRACE",
EntityKind::Face3d(_) => "3DFACE",
EntityKind::Region(_) => "REGION",
EntityKind::Viewport(_) => "VIEWPORT",
EntityKind::Image(_) => "IMAGE",
EntityKind::Wipeout(_) => "WIPEOUT",
@@ -958,6 +964,7 @@ impl EntityKind {
let pts = [c[0].xy(), c[1].xy(), c[2].xy(), c[3].xy(), c[0].xy()];
pts.windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(*w.first()?, *w.get(1)?))))).collect()
}
EntityKind::Region(r) => r.segments().into_iter().map(Prim::Seg).collect(),
EntityKind::Hatch(h) => {
h.loops.iter().flat_map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.segments()).map(Prim::Seg).collect()
}
@@ -1151,6 +1158,14 @@ impl EntityKind {
}
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(t3),
EntityKind::Face3d(f) => f.corners.iter_mut().for_each(t3),
EntityKind::Region(r) => {
for v in r.loops.iter_mut().flat_map(|l| l.vertices.iter_mut()) {
v.p = m.apply(v.p);
if mirror {
v.bulge = -v.bulge;
}
}
}
EntityKind::Viewport(v) => {
t3(&mut v.center);
v.width *= s;
@@ -1232,6 +1247,7 @@ impl EntityKind {
}
EntityKind::Solid(s) | EntityKind::Trace(s) => s.corners.iter().map(|c| c.xy()).collect(),
EntityKind::Face3d(f) => f.corners.iter().map(|c| c.xy()).collect(),
EntityKind::Region(r) => vec![r.bounds().center()],
EntityKind::Viewport(v) => {
let c = v.center.xy();
let h = Vec2::new(v.width / 2.0, v.height / 2.0);
+130
View File
@@ -0,0 +1,130 @@
//! Regions (REGION): a closed planar area bounded by loops of lines and arcs. Outer loops add
//! area, island loops (holes) take it away; loops don't cross.
use cadcraft_geom::{Bounds2, PolyVertex, Polyline, Segment, Vec2, point_in_polygon};
use serde::{Deserialize, Serialize};
use super::HatchLoop;
/// Most loops one region has (hostile files and edits).
pub const MAX_REGION_LOOPS: usize = 10_000;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Region {
/// Closed loops with bulges; `outer` false for islands.
pub loops: Vec<HatchLoop>,
}
/// Area properties of a region (MASSPROP), in drawing units.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct RegionProps {
pub area: f64,
pub perimeter: f64,
pub bounds: Bounds2,
pub centroid: Vec2,
/// ∫y² dA and ∫x² dA about the origin.
pub ixx: f64,
pub iyy: f64,
/// ∫xy dA about the origin.
pub ixy: f64,
/// The same three about the centroid.
pub centroid_moments: [f64; 3],
}
impl Region {
/// A region bounded by one closed loop.
pub fn from_loop(vertices: Vec<PolyVertex>) -> Region {
Region { loops: vec![HatchLoop { vertices, outer: true, sources: Vec::new() }] }
}
pub fn polylines(&self) -> Vec<Polyline> {
self.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }).collect()
}
/// The boundary: every loop's lines and arcs.
pub fn segments(&self) -> Vec<Segment> {
self.polylines().iter().flat_map(Polyline::segments).collect()
}
/// Each loop as points, arcs within `tol`.
pub fn tessellate(&self, tol: f64) -> Vec<Vec<Vec2>> {
self.polylines().iter().map(|p| p.tessellate(tol)).collect()
}
pub fn bounds(&self) -> Bounds2 {
self.polylines().iter().fold(Bounds2::EMPTY, |b, p| b.union(&p.bounds()))
}
/// Outer loops' area less the islands'.
pub fn area(&self) -> f64 {
self.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.area().abs() * if l.outer { 1.0 } else { -1.0 }).sum()
}
pub fn perimeter(&self) -> f64 {
self.polylines().iter().map(Polyline::len).sum()
}
/// A chord tolerance fine enough for measuring: 1e-5 of the region's size.
fn fine_tol(&self) -> f64 {
let b = self.bounds();
if b.is_empty() { 1e-6 } else { ((b.width() + b.height()) * 1e-5).max(1e-12) }
}
/// Whether `p` lies inside the region (odd parity over its loops).
pub fn contains(&self, p: Vec2) -> bool {
self.tessellate(self.fine_tol()).iter().filter(|l| point_in_polygon(l, p)).count() % 2 == 1
}
/// Set each loop's `outer` flag from how deeply other loops enclose it: an even depth is
/// an outer loop, odd an island.
pub fn renest(&mut self) {
let polys = self.tessellate(self.fine_tol());
let depth: Vec<usize> = polys
.iter()
.enumerate()
.map(|(i, l)| {
let Some(p) = probe(l) else { return 0 };
polys.iter().enumerate().filter(|(j, o)| *j != i && point_in_polygon(o, p)).count()
})
.collect();
for (l, d) in self.loops.iter_mut().zip(depth) {
l.outer = d % 2 == 0;
}
}
/// Area, perimeter, bounding box, centroid and second moments (outer loops counted
/// counter-clockwise, islands clockwise).
pub fn props(&self) -> RegionProps {
let tol = self.fine_tol();
let (mut a, mut cx, mut cy, mut ixx, mut iyy, mut ixy) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
for (l, pts) in self.loops.iter().zip(self.tessellate(tol)) {
let signed = cadcraft_geom::shoelace(&pts);
// Outer loops add, islands subtract, whatever way they run.
let flip = if (signed >= 0.0) == l.outer { 1.0 } else { -1.0 };
let n = pts.len();
for i in 0..n {
let (Some(p), Some(q)) = (pts.get(i), pts.get((i + 1) % n)) else { continue };
let c = (p.x * q.y - q.x * p.y) * flip;
a += c / 2.0;
cx += (p.x + q.x) * c / 6.0;
cy += (p.y + q.y) * c / 6.0;
ixx += (p.y * p.y + p.y * q.y + q.y * q.y) * c / 12.0;
iyy += (p.x * p.x + p.x * q.x + q.x * q.x) * c / 12.0;
ixy += (p.x * q.y + 2.0 * p.x * p.y + 2.0 * q.x * q.y + q.x * p.y) * c / 24.0;
}
}
let c = if a.abs() > 1e-300 { Vec2::new(cx / a, cy / a) } else { Vec2::ZERO };
// Shifted with the area of the same outline the moments were taken from.
let centroid_moments = [ixx - a * c.y * c.y, iyy - a * c.x * c.x, ixy - a * c.x * c.y];
RegionProps { area: self.area(), perimeter: self.perimeter(), bounds: self.bounds(), centroid: c, ixx, iyy, ixy, centroid_moments }
}
}
/// A point of a loop to test its nesting with: the middle of its first non-degenerate edge.
fn probe(l: &[Vec2]) -> Option<Vec2> {
let n = l.len();
(0..n).find_map(|i| {
let (a, b) = (*l.get(i)?, *l.get((i + 1) % n)?);
(a.dist(b) > 1e-12).then(|| (a + b) * 0.5).filter(|m| m.is_finite())
})
}
+2 -2
View File
@@ -393,7 +393,7 @@ fn run_boundary(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "handles": out, "message": format!("BOUNDARY created {} polyline(s)", out.len()) }))
}
/// REGION: closed objects become closed polylines (our region representation for now).
/// REGION: each closed object becomes a region bounded by it (and replaces it).
fn run_region(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let mut n = 0;
@@ -405,7 +405,7 @@ fn run_region(s: &mut Session, p: &Value) -> Result<Value> {
if let Some((vs, common)) = vs {
let d = s.doc_mut()?;
d.modify_entity(h, |e| {
e.kind = lwpoly(vs, true);
e.kind = EntityKind::Region(cadcraft_doc::Region::from_loop(vs));
e.common = common;
})?;
n += 1;
+7 -2
View File
@@ -8,8 +8,10 @@ use super::*;
use crate::units::{format_angle, format_distance};
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
mod regions;
pub fn specs() -> Vec<CommandSpec> {
vec![
let mut v = vec![
CommandSpec::new("dist", "Distance", run_dist)
.menu(&["Tools", "Inquiry", "Distance"])
.alias(&["di"])
@@ -45,7 +47,9 @@ pub fn specs() -> Vec<CommandSpec> {
.menu(&["Tools", "Count"])
.params("{block?} → {blocks: {name: n}} | {block, count}, plus a `message` line")
.noundo(),
]
];
v.extend(regions::specs());
v
}
fn fmt_d(s: &Session, v: f64) -> String {
@@ -218,6 +222,7 @@ fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
}
total
}
EntityKind::Region(r) => (r.area(), r.perimeter()),
_ => return Err(bad("area", "object has no area")),
})
}
+296
View File
@@ -0,0 +1,296 @@
//! Region booleans (UNION, SUBTRACT, INTERSECT) and MASSPROP.
//!
//! The booleans handle regions whose boundaries don't cross or touch: each loop then lies wholly
//! inside or outside the other region, and the result keeps the loops that bound it (union: the
//! loops outside the other region; subtract: the first region's loops outside the second and the
//! second's inside the first; intersect: the loops inside the other region). Overlapping
//! boundaries need curve splitting and report "not available yet".
use cadcraft_doc::{EntityKind, Handle, HatchLoop, MAX_REGION_LOOPS, Region};
use cadcraft_geom::{Line, Vec2, line_line, point_in_polygon};
use serde_json::{Value, json};
use super::super::machines::{SelOutcome, SelectPhase, SelectRun};
use super::super::*;
use crate::{EngineError, Input, Interactive, Prompt, Result, Session, Step};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("union", "Union", run_union)
.menu(&["Modify", "Solid Editing", "Union"])
.alias(&["uni"])
.params("{handles?} (regions)")
.interactive(|_| Ok(Box::new(SelectRun::new("union", "UNION")))),
CommandSpec::new("subtract", "Subtract", run_subtract)
.menu(&["Modify", "Solid Editing", "Subtract"])
.alias(&["su"])
.params("{from: [handles], remove: [handles]} (regions)")
.interactive(|_| Ok(Box::new(SubtractM::default()))),
CommandSpec::new("intersect", "Intersect", run_intersect)
.menu(&["Modify", "Solid Editing", "Intersect"])
.alias(&["in"])
.params("{handles?} (regions)")
.interactive(|_| Ok(Box::new(SelectRun::new("intersect", "INTERSECT")))),
CommandSpec::new("massprop", "Region/Mass Properties", run_massprop)
.menu(&["Tools", "Inquiry", "Region/Mass Properties"])
.params("{handles?} (regions) → properties of each")
.noundo()
.interactive(|_| Ok(Box::new(SelectRun::new("massprop", "MASSPROP")))),
]
}
fn other(m: impl Into<String>) -> EngineError {
EngineError::Other(m.into())
}
/// Most segment pairs compared when checking two regions for crossing boundaries.
const MAX_PAIRS: usize = 50_000_000;
/// The regions among `hs` (others are left alone).
fn regions(s: &Session, hs: &[Handle]) -> Result<Vec<(Handle, Region)>> {
let d = s.doc()?;
Ok(hs
.iter()
.filter_map(|h| match &d.entity(*h)?.kind {
EntityKind::Region(r) => Some((*h, r.clone())),
_ => None,
})
.collect())
}
/// Each loop of a region as points, with a chord tolerance for both regions' size.
fn tess(r: &Region, tol: f64) -> Vec<Vec<Vec2>> {
r.tessellate(tol)
}
/// A point on a loop's boundary (its first edge's middle).
fn probe(l: &[Vec2]) -> Option<Vec2> {
let n = l.len();
(0..n).find_map(|i| {
let (a, b) = (*l.get(i)?, *l.get((i + 1) % n)?);
(a.dist(b) > 1e-12).then(|| (a + b) * 0.5)
})
}
/// Whether two sets of loops have boundaries that cross, touch or overlap.
fn boundaries_meet(a: &[Vec<Vec2>], b: &[Vec<Vec2>], tol: f64) -> Result<bool> {
let edges = |ls: &[Vec<Vec2>]| -> Vec<Line> {
ls.iter().flat_map(|l| (0..l.len()).filter_map(move |i| Some(Line::new(*l.get(i)?, *l.get((i + 1) % l.len())?)))).collect()
};
let (ea, eb) = (edges(a), edges(b));
if ea.len().saturating_mul(eb.len()) > MAX_PAIRS {
return Err(other("These regions are too complex to combine."));
}
for x in &ea {
for y in &eb {
if line_line(x, y).is_some() || x.dist(y.a) < tol || y.dist(x.a) < tol {
return Ok(true);
}
}
}
Ok(false)
}
/// Inside the area of loops `ls` (odd parity).
fn inside(ls: &[Vec<Vec2>], p: Vec2) -> bool {
ls.iter().filter(|l| point_in_polygon(l, p)).count() % 2 == 1
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Op {
Union,
Subtract,
Intersect,
}
/// `a` op `b` for regions whose boundaries don't meet; `None` when they do (overlapping
/// regions are not available yet).
fn combine(a: &Region, b: &Region, op: Op) -> Result<Option<Region>> {
let size = a.bounds().union(&b.bounds());
let tol = ((size.width() + size.height()) * 1e-6).max(1e-12);
let (ta, tb) = (tess(a, tol), tess(b, tol));
if boundaries_meet(&ta, &tb, tol * 10.0)? {
return Ok(None);
}
let keep = |loops: &[HatchLoop], pts: &[Vec<Vec2>], other: &[Vec<Vec2>], want_inside: bool| -> Vec<HatchLoop> {
loops.iter().zip(pts).filter(|(_, p)| probe(p).is_some_and(|q| inside(other, q) == want_inside)).map(|(l, _)| l.clone()).collect()
};
let mut loops = match op {
Op::Union => [keep(&a.loops, &ta, &tb, false), keep(&b.loops, &tb, &ta, false)].concat(),
Op::Subtract => [keep(&a.loops, &ta, &tb, false), keep(&b.loops, &tb, &ta, true)].concat(),
Op::Intersect => [keep(&a.loops, &ta, &tb, true), keep(&b.loops, &tb, &ta, true)].concat(),
};
if loops.len() > MAX_REGION_LOOPS {
return Err(other("Too many loops."));
}
loops.retain(|l| l.vertices.len() >= 2);
let mut r = Region { loops };
r.renest();
Ok(Some(r))
}
const OVERLAP: &str = "Combining regions whose boundaries overlap is not available yet.";
/// Replace the first region with `r` (or erase it when empty) and erase the others.
fn apply(s: &mut Session, keep: Handle, r: Region, erase: &[Handle]) -> Result<()> {
let d = s.doc_mut()?;
for h in erase.iter().filter(|h| **h != keep) {
d.remove_entity(*h);
}
if r.loops.is_empty() {
d.remove_entity(keep);
} else {
d.modify_entity(keep, |e| e.kind = EntityKind::Region(r))?;
}
Ok(())
}
fn fold(s: &mut Session, hs: &[Handle], op: Op) -> Result<Value> {
let rs = regions(s, hs)?;
let Some(((h0, first), rest)) = rs.split_first() else { return Err(other("Select at least one region.")) };
let mut acc = first.clone();
for (_, r) in rest {
acc = combine(&acc, r, op)?.ok_or_else(|| other(OVERLAP))?;
}
let all: Vec<Handle> = rs.iter().map(|(h, _)| *h).collect();
let empty = acc.loops.is_empty();
apply(s, *h0, acc, &all)?;
Ok(
json!({ "handle": if empty { Value::Null } else { json!(h0.hex()) }, "message": if empty { "The result is empty: the regions were erased." } else { "" } }),
)
}
fn run_union(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
fold(s, &hs, Op::Union)
}
fn run_intersect(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
fold(s, &hs, Op::Intersect)
}
fn handles(p: &Value, key: &str) -> Result<Vec<Handle>> {
let a = p.get(key).and_then(Value::as_array).ok_or_else(|| bad("subtract", format!("`{key}` (array of handles) is required")))?;
a.iter().map(|v| super::super::curves::handle_value(v).ok_or_else(|| bad("subtract", format!("`{key}`: {v} is not a handle")))).collect()
}
fn subtract(s: &mut Session, from: &[Handle], remove: &[Handle]) -> Result<Value> {
let (a, b) = (regions(s, from)?, regions(s, remove)?);
let Some(((h0, first), rest)) = a.split_first() else { return Err(other("Select regions to subtract from.")) };
let mut acc = first.clone();
for (_, r) in rest {
acc = combine(&acc, r, Op::Union)?.ok_or_else(|| other(OVERLAP))?;
}
for (h, r) in &b {
if a.iter().any(|(x, _)| x == h) {
return Err(other("A region can't be subtracted from itself."));
}
acc = combine(&acc, r, Op::Subtract)?.ok_or_else(|| other(OVERLAP))?;
}
let all: Vec<Handle> = a.iter().chain(&b).map(|(h, _)| *h).collect();
let empty = acc.loops.is_empty();
apply(s, *h0, acc, &all)?;
Ok(
json!({ "handle": if empty { Value::Null } else { json!(h0.hex()) }, "message": if empty { "The result is empty: the regions were erased." } else { "" } }),
)
}
fn run_subtract(s: &mut Session, p: &Value) -> Result<Value> {
let (from, remove) = (handles(p, "from")?, handles(p, "remove")?);
subtract(s, &from, &remove)
}
#[derive(Default)]
struct SubtractM {
sel: SelectPhase,
from: Option<Vec<Handle>>,
}
impl Interactive for SubtractM {
fn name(&self) -> &'static str {
"SUBTRACT"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
s.echo("Select regions to subtract from ..");
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
self.sel.prompt()
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => match self.from.take() {
None => {
self.from = Some(hs);
self.sel = SelectPhase::default();
s.set_selection(Vec::new());
s.echo("Select regions to subtract ..");
Ok(Step::Continue)
}
Some(from) => {
let r = subtract(s, &from, &hs)?;
if let Some(m) = r.get("message").and_then(Value::as_str).filter(|m| !m.is_empty()) {
s.echo(m.to_string());
}
s.set_selection(Vec::new());
Ok(Step::Done)
}
},
}
}
}
fn run_massprop(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let rs = regions(s, &hs)?;
if rs.is_empty() {
return Err(other("Select one or more regions."));
}
let f = |v: f64| super::fmt_d(s, v);
let mut out = Vec::new();
let mut lines = Vec::new();
for (h, r) in &rs {
let m = r.props();
let c = m.centroid;
// Moments about the centroid, and the principal moments and directions.
let [ixc, iyc, ixyc] = m.centroid_moments;
let mid = (ixc + iyc) / 2.0;
let rad = (((ixc - iyc) / 2.0).powi(2) + ixyc * ixyc).sqrt();
let (i1, i2) = (mid - rad, mid + rad);
let ang = 0.5 * (-2.0 * ixyc).atan2(ixc - iyc);
let gyr = |i: f64| if m.area > 0.0 { (i / m.area).abs().sqrt() } else { 0.0 };
out.push(json!({
"handle": h.hex(), "area": m.area, "perimeter": m.perimeter,
"boundingBox": [[m.bounds.min.x, m.bounds.min.y], [m.bounds.max.x, m.bounds.max.y]],
"centroid": [c.x, c.y], "momentsOfInertia": [m.ixx, m.iyy], "productOfInertia": m.ixy,
"radiiOfGyration": [gyr(m.ixx), gyr(m.iyy)], "principalMoments": [i1, i2],
"principalDirections": [[ang.cos(), ang.sin()], [-ang.sin(), ang.cos()]],
}));
lines.push(format!(
"---------------- REGIONS ----------------\nArea: {}\nPerimeter: {}\nBounding box: X: {} -- {}\n Y: {} -- {}\nCentroid: X: {}\n Y: {}\nMoments of inertia: X: {}\n Y: {}\nProduct of inertia: XY: {}\nRadii of gyration: X: {}\n Y: {}\nPrincipal moments and X-Y directions about centroid:\n I: {} along [{:.4} {:.4}]\n J: {} along [{:.4} {:.4}]",
f(m.area),
f(m.perimeter),
f(m.bounds.min.x),
f(m.bounds.max.x),
f(m.bounds.min.y),
f(m.bounds.max.y),
f(c.x),
f(c.y),
f(m.ixx),
f(m.iyy),
f(m.ixy),
f(gyr(m.ixx)),
f(gyr(m.iyy)),
f(i1),
ang.cos(),
ang.sin(),
f(i2),
-ang.sin(),
ang.cos(),
));
}
Ok(json!({ "regions": out, "message": lines.join("\n") }))
}
+13
View File
@@ -1219,6 +1219,19 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
})
.collect(),
),
// A region explodes into its boundary's lines and arcs.
EntityKind::Region(r) => Some(
r.segments()
.into_iter()
.map(|s| {
let k = match s {
Segment::Line(l) => line(l.a, l.b),
Segment::Arc { arc: a, .. } => arc(&a),
};
Entity { handle: Handle(0), common: e.common.clone(), kind: k }
})
.collect(),
),
EntityKind::Dimension(dm) => {
// The dimension as drawn: its overrides, DIMSCALE (0 = the drawing's) and text font.
let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides);
+1
View File
@@ -1444,6 +1444,7 @@ pub(crate) fn flatten_kind(k: &mut EntityKind) {
EntityKind::Hatch(h) => h.elevation = 0.0,
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z),
EntityKind::Face3d(f) => f.corners.iter_mut().for_each(z),
EntityKind::Region(_) => {}
EntityKind::Image(i) => {
z(&mut i.insert);
i.u.z = 0.0;
+1
View File
@@ -267,6 +267,7 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option<EntityK
}
EntityKind::Solid(s) | EntityKind::Trace(s) => set_xy(s.corners.get_mut(index)?, to),
EntityKind::Face3d(f) => set_xy(f.corners.get_mut(index)?, to),
EntityKind::Region(_) => return Some(translated(kind, delta)),
EntityKind::Viewport(v) => {
let opposite = *grips.get((index + 2) % 4)?;
let w = (to.x - opposite.x).abs();
+182
View File
@@ -0,0 +1,182 @@
//! Regions (#510): REGION turns closed objects into region objects; AREA and MASSPROP measure
//! them; UNION, SUBTRACT and INTERSECT combine regions whose boundaries don't meet; they move,
//! explode and snap like other objects.
use cadcraft_doc::{EntityKind, Handle, Region, Space};
use cadcraft_engine::Session;
use cadcraft_geom::{PI, Vec2};
use serde_json::{Value, json};
fn h(v: &Value) -> Handle {
Handle::parse_hex(v["handle"].as_str().unwrap()).unwrap()
}
fn region(s: &Session, hd: Handle) -> Region {
match &s.doc().unwrap().entity(hd).unwrap().kind {
EntityKind::Region(r) => r.clone(),
k => panic!("not a region: {k:?}"),
}
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6 * b.abs().max(1.0)
}
/// A region from a rectangle, and one from a circle.
fn rect_region(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle {
let r = h(&s.execute("rectang", &json!({"p1": a, "p2": b})).unwrap());
s.execute("region", &json!({"handles": [r.hex()]})).unwrap();
r
}
fn circle_region(s: &mut Session, c: [f64; 2], r: f64) -> Handle {
let hd = h(&s.execute("circle", &json!({"center": c, "radius": r})).unwrap());
s.execute("region", &json!({"handles": [hd.hex()]})).unwrap();
hd
}
#[test]
fn region_from_closed_objects_measures() {
let mut s = Session::new();
let a = rect_region(&mut s, [0.0, 0.0], [10.0, 4.0]);
let c = circle_region(&mut s, [20.0, 0.0], 2.0);
s.execute("line", &json!({"points": [[0, 10], [5, 10]]})).unwrap();
let line = s.doc().unwrap().model.iter().last().unwrap().handle;
let r = s.execute("region", &json!({"handles": [line.hex()]})).unwrap();
assert!(r["message"].as_str().unwrap().starts_with('0'), "an open line makes no region");
assert_eq!(region(&s, a).loops.len(), 1);
assert_eq!(s.doc().unwrap().entity(a).unwrap().kind.type_name(), "Region");
let area = s.execute("area", &json!({"handle": a.hex()})).unwrap();
assert!(near(area["area"].as_f64().unwrap(), 40.0) && near(area["perimeter"].as_f64().unwrap(), 28.0), "{area}");
let area = s.execute("area", &json!({"handle": c.hex()})).unwrap();
assert!(near(area["area"].as_f64().unwrap(), 4.0 * PI), "{area}");
let m = s.execute("massprop", &json!({"handles": [a.hex()]})).unwrap();
let r0 = &m["regions"][0];
assert!(near(r0["area"].as_f64().unwrap(), 40.0));
assert!(near(r0["centroid"][0].as_f64().unwrap(), 5.0) && near(r0["centroid"][1].as_f64().unwrap(), 2.0), "{r0}");
// ∫y² dA over 0..10 × 0..4 = 10 · 4³/3; ∫x² dA = 4 · 10³/3; ∫xy dA = 100/2 · 16/2.
assert!(near(r0["momentsOfInertia"][0].as_f64().unwrap(), 640.0 / 3.0), "{r0}");
assert!(near(r0["momentsOfInertia"][1].as_f64().unwrap(), 4000.0 / 3.0), "{r0}");
assert!(near(r0["productOfInertia"].as_f64().unwrap(), 400.0), "{r0}");
assert!(m["message"].as_str().unwrap().contains("Centroid"));
// The circle's moments about its centre come out as π r⁴ / 4 (principal, both).
let m = s.execute("massprop", &json!({"handles": [c.hex()]})).unwrap();
let pm = &m["regions"][0]["principalMoments"];
assert!((pm[0].as_f64().unwrap() - 4.0 * PI).abs() < 3e-3 && (pm[1].as_f64().unwrap() - 4.0 * PI).abs() < 3e-3, "{pm}");
assert!(s.execute("massprop", &json!({"handles": [line.hex()]})).is_err());
}
#[test]
fn booleans_on_separate_and_nested_regions() {
// Subtract a circle inside a rectangle: a hole.
let mut s = Session::new();
let a = rect_region(&mut s, [0.0, 0.0], [10.0, 10.0]);
let c = circle_region(&mut s, [5.0, 5.0], 2.0);
s.execute("subtract", &json!({"from": [a.hex()], "remove": [c.hex()]})).unwrap();
let r = region(&s, a);
assert_eq!(r.loops.iter().map(|l| l.outer).collect::<Vec<_>>(), vec![true, false]);
assert!(near(r.area(), 100.0 - 4.0 * PI));
assert!(s.doc().unwrap().entity(c).is_none());
assert!(!r.contains(Vec2::new(5.0, 5.0)) && r.contains(Vec2::new(1.0, 1.0)));
// Union with a region inside the hole: an island in the hole.
let d = circle_region(&mut s, [5.0, 5.0], 1.0);
s.execute("union", &json!({"handles": [a.hex(), d.hex()]})).unwrap();
let r = region(&s, a);
assert_eq!(r.loops.len(), 3);
assert!(near(r.area(), 100.0 - 4.0 * PI + PI));
// Union with a separate region: one region of two pieces.
let e = rect_region(&mut s, [20.0, 0.0], [22.0, 2.0]);
s.execute("union", &json!({"handles": [a.hex(), e.hex()]})).unwrap();
assert!(near(region(&s, a).area(), 100.0 - 3.0 * PI + 4.0));
// Union with a region inside the filled part: absorbed.
let f = rect_region(&mut s, [0.5, 0.5], [1.5, 1.5]);
s.execute("union", &json!({"handles": [a.hex(), f.hex()]})).unwrap();
assert_eq!(region(&s, a).loops.len(), 4);
// Intersect: the inner one; separate regions leave nothing.
let mut s = Session::new();
let a = rect_region(&mut s, [0.0, 0.0], [10.0, 10.0]);
let b = rect_region(&mut s, [2.0, 2.0], [3.0, 3.0]);
s.execute("intersect", &json!({"handles": [a.hex(), b.hex()]})).unwrap();
assert!(near(region(&s, a).area(), 1.0));
let c = rect_region(&mut s, [20.0, 0.0], [21.0, 1.0]);
let r = s.execute("intersect", &json!({"handles": [a.hex(), c.hex()]})).unwrap();
assert!(r["handle"].is_null());
assert_eq!(s.doc().unwrap().model.len(), 0);
// Overlapping boundaries: not available yet, nothing changes.
let mut s = Session::new();
let a = rect_region(&mut s, [0.0, 0.0], [10.0, 10.0]);
let b = rect_region(&mut s, [5.0, 5.0], [15.0, 15.0]);
let err = s.execute("union", &json!({"handles": [a.hex(), b.hex()]})).unwrap_err().to_string();
assert!(err.contains("not available yet"), "{err}");
assert_eq!(s.doc().unwrap().model.len(), 2);
assert!(s.execute("subtract", &json!({"from": [a.hex()], "remove": [a.hex()]})).is_err());
}
#[test]
fn subtract_on_the_command_line() {
let mut s = Session::new();
let a = rect_region(&mut s, [0.0, 0.0], [10.0, 10.0]);
let _c = circle_region(&mut s, [5.0, 5.0], 2.0);
s.set_selection(Vec::new());
for t in ["subtract", "0,5", "", "3,5", ""] {
s.cmdline(t).unwrap();
}
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(region(&s, a).loops.len(), 2);
}
#[test]
fn regions_move_grip_explode_and_snap() {
let mut s = Session::new();
let a = rect_region(&mut s, [0.0, 0.0], [10.0, 4.0]);
let c = circle_region(&mut s, [5.0, 2.0], 1.0);
s.execute("subtract", &json!({"from": [a.hex()], "remove": [c.hex()]})).unwrap();
s.execute("move", &json!({"handles": [a.hex()], "delta": [1, 1]})).unwrap();
s.execute("rotate", &json!({"handles": [a.hex()], "base": [1, 1], "angle": 90})).unwrap();
s.execute("mirror", &json!({"handles": [a.hex()], "p1": [1, 0], "p2": [1, 1], "erase": true})).unwrap();
let a = s.doc().unwrap().model.iter().last().unwrap().handle;
let r = region(&s, a);
assert!(near(r.area(), 40.0 - PI));
let b = r.bounds();
assert!(b.min.near(Vec2::new(1.0, 1.0), 1e-9) && b.max.near(Vec2::new(5.0, 11.0), 1e-9), "{b:?}");
// One grip in the middle; dragging it moves the region.
let k = s.doc().unwrap().entity(a).unwrap().kind.clone();
assert_eq!(k.grips(), vec![Vec2::new(3.0, 6.0)]);
let EntityKind::Region(m) = cadcraft_engine::grips::stretch_grip(&k, 0, Vec2::new(4.0, 6.0)).unwrap() else { panic!() };
assert!(m.bounds().min.near(Vec2::new(2.0, 1.0), 1e-9));
// A corner snaps; a pick on the edge picks it.
let d = s.doc().unwrap();
let hit = cadcraft_engine::snap::osnap(d, &Space::Model, Vec2::new(5.05, 11.05), 0.3, cadcraft_engine::snap::mode::END, None, false).unwrap();
assert!(hit.point.near(Vec2::new(5.0, 11.0), 1e-9));
assert_eq!(cadcraft_engine::select::pick(d, &Space::Model, Vec2::new(3.0, 1.0), 0.1), Some(a));
// EXPLODE: the boundary's lines and arcs.
s.execute("explode", &json!({"handles": [a.hex()]})).unwrap();
let d = s.doc().unwrap();
assert_eq!(d.model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count(), 4);
assert_eq!(d.model.iter().filter(|e| matches!(e.kind, EntityKind::Arc(_))).count(), 2);
}
#[test]
fn hostile_region_params_never_panic() {
let mut s = Session::new();
let a = rect_region(&mut s, [0.0, 0.0], [1e300, 1e300]);
let b = circle_region(&mut s, [1.0, 1.0], 1e-9);
for (id, p) in [
("union", json!({"handles": [a.hex(), b.hex(), "zz"]})),
("subtract", json!({"from": [a.hex()], "remove": "x"})),
("subtract", json!({"from": [], "remove": [b.hex()]})),
("intersect", json!({"handles": []})),
("massprop", json!({"handles": [a.hex(), b.hex()]})),
("region", json!({"handles": ["ffffff"]})),
] {
let _ = s.execute(id, &p);
}
for t in ["union", "", "subtract", "", "massprop", ""] {
let _ = s.cmdline(t);
}
}
+4 -2
View File
@@ -251,13 +251,15 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
v.bulge = -v.bulge;
}
}
EntityKind::LwPolyline(LwPolyline {
let pl = LwPolyline {
vertices: verts,
closed: flags & 1 != 0,
const_width: t.fd(43, 0.0),
elevation: t.fd(38, 0.0),
plinegen: flags & 128 != 0,
})
};
// A region CADCraft wrote as polylines (`dxf_region`).
crate::dxf_region::from_polyline(tags, pl)?
}
"SPLINE" => {
let degree = t.i(71).unwrap_or(3).clamp(1, 10) as usize;
+120
View File
@@ -0,0 +1,120 @@
//! Regions in DXF. A REGION entity holds ACIS solid-modeler data, which no public specification
//! describes in enough detail, so CADCraft writes a region as closed LWPOLYLINEs, one per loop,
//! which every DXF reader understands. The first carries `CADCRAFT` xdata naming it a region
//! and holding its other loops (`1000 REGION`); the others are marked `1000 REGIONPART`. Reading
//! our own file builds the region back from the first and drops the others. Foreign REGIONs are
//! kept as unknown objects.
use cadcraft_doc::{EntityKind, HatchLoop, LwPolyline, MAX_REGION_LOOPS, Region};
use cadcraft_dxf::Tag;
use cadcraft_geom::{PolyVertex, Vec2};
use crate::dxf_ext::{APP, xdata, xdata_list};
/// Most vertices of a region's other loops kept in xdata (its size is limited in other
/// programs); past it those loops are written as plain polylines.
const MAX_XDATA_VERTICES: usize = 300;
/// Most vertices read back per region.
const MAX_VERTICES: usize = 1_000_000;
/// One closed LWPOLYLINE: the groups after the common entity groups.
fn polyline_tags(l: &HatchLoop) -> Vec<Tag> {
let mut t = vec![Tag::i(90, l.vertices.len() as i64), Tag::i(70, 1)];
for v in &l.vertices {
t.push(Tag::f(10, v.p.x));
t.push(Tag::f(20, v.p.y));
if v.bulge != 0.0 && v.bulge.is_finite() {
t.push(Tag::f(42, v.bulge));
}
}
t
}
/// The polylines a region is written as (each the groups after the common entity groups,
/// xdata last). The first takes the region's handle.
pub(crate) fn polylines(r: &Region) -> Vec<Vec<Tag>> {
let Some((first, rest)) = r.loops.split_first() else { return Vec::new() };
let in_xdata = rest.iter().map(|l| l.vertices.len()).sum::<usize>() <= MAX_XDATA_VERTICES;
let mut main = polyline_tags(first);
main.extend([Tag::s(1001, APP), Tag::s(1000, "REGION"), Tag::s(1002, "{"), Tag::i(1070, i64::from(first.outer))]);
if in_xdata {
for l in rest {
main.extend([Tag::s(1000, "LOOP"), Tag::i(1070, i64::from(l.outer)), Tag::i(1071, l.vertices.len() as i64)]);
for v in &l.vertices {
main.extend([Tag::f(1010, v.p.x), Tag::f(1020, v.p.y), Tag::f(1030, 0.0), Tag::f(1040, v.bulge)]);
}
}
}
main.push(Tag::s(1002, "}"));
let mut out = vec![main];
for l in rest {
let mut t = polyline_tags(l);
if in_xdata {
t.extend([Tag::s(1001, APP), Tag::s(1000, "REGIONPART")]);
}
out.push(t);
}
out
}
/// What a closed LWPOLYLINE read as `pl` stands for: a region CADCraft wrote, nothing (a loop
/// of such a region, read with it), or the polyline itself.
pub(crate) fn from_polyline(tags: &[Tag], pl: LwPolyline) -> Option<EntityKind> {
let x = xdata(tags, APP);
if x.iter().any(|t| t.code == 1000 && t.str().trim() == "REGIONPART") {
return None;
}
if !x.iter().any(|t| t.code == 1000 && t.str().trim() == "REGION") {
return Some(EntityKind::LwPolyline(pl));
}
let list = xdata_list(x, "REGION");
let fin = |v: &PolyVertex| v.p.is_finite() && v.bulge.is_finite();
let first_outer = list.iter().find(|t| t.code == 1070).is_none_or(|t| t.i64() != 0);
let mut loops = vec![HatchLoop { vertices: pl.vertices.into_iter().filter(fin).collect(), outer: first_outer, sources: Vec::new() }];
let mut total = 0usize;
let mut cur: Option<HatchLoop> = None;
let mut expect_flag = false;
for t in list {
match t.code {
1000 if t.str().trim() == "LOOP" => {
loops.extend(cur.take());
if loops.len() >= MAX_REGION_LOOPS {
break;
}
cur = Some(HatchLoop { vertices: Vec::new(), outer: false, sources: Vec::new() });
expect_flag = true;
}
1070 if expect_flag => {
if let Some(l) = cur.as_mut() {
l.outer = t.i64() != 0;
}
expect_flag = false;
}
1010 => {
if let Some(l) = cur.as_mut()
&& total < MAX_VERTICES
{
l.vertices.push(PolyVertex::new(Vec2::new(t.f64(), 0.0)));
total += 1;
}
}
1020 => {
if let Some(v) = cur.as_mut().and_then(|l| l.vertices.last_mut()) {
v.p.y = t.f64();
}
}
1040 => {
if let Some(v) = cur.as_mut().and_then(|l| l.vertices.last_mut()) {
v.bulge = t.f64();
}
}
_ => {}
}
}
loops.extend(cur);
for l in &mut loops {
l.vertices.retain(fin);
}
loops.retain(|l| l.vertices.len() >= 2);
Some(EntityKind::Region(Region { loops }))
}
+15
View File
@@ -601,6 +601,21 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx
thickness(w, e);
text_tags(w, t, None, None);
}
// Closed polylines, one per loop (`dxf_region`): REGION's own data is ACIS.
EntityKind::Region(r) => {
for (i, tags) in crate::dxf_region::polylines(r).into_iter().enumerate() {
w.s(0, "LWPOLYLINE");
if i == 0 {
common(w, e, owner, paper, "AcDbPolyline");
} else {
// The other loops get handles of their own (only the handle and properties are written).
let h = Handle::parse_hex(&w.h()).unwrap_or(e.handle);
let part = Entity { handle: h, common: e.common.clone(), kind: EntityKind::Point(Point { p: Vec3::ZERO, angle: 0.0 }) };
common(w, &part, owner, paper, "AcDbPolyline");
}
w.t.extend(tags);
}
}
EntityKind::AttDef(a) => {
w.s(0, "ATTDEF");
common(w, e, owner, paper, "AcDbText");
+3
View File
@@ -171,5 +171,8 @@ pub fn write_dxf(d: &Drawing) -> String {
dxf_write::write(d)
}
/// Regions, written as closed polylines.
mod dxf_region;
#[cfg(test)]
mod tests;
+96
View File
@@ -0,0 +1,96 @@
//! Regions survive DXF save and reopen (#510). REGION's own data is ACIS, which no public
//! specification covers, so a region is written as closed polylines (one per loop) that every
//! DXF reader shows; `CADCRAFT` xdata on the first lets CADCraft read the region back whole.
//! Foreign REGIONs stay unknown objects as before; hostile xdata doesn't panic.
use cadcraft_doc::{Common, Drawing, EntityKind, HatchLoop, Region, Space};
use cadcraft_dxf::{records, sections};
use cadcraft_geom::{PolyVertex, Vec2};
fn regions(d: &Drawing) -> Vec<Region> {
d.model
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Region(r) => Some(r.clone()),
_ => None,
})
.collect()
}
fn square(x: f64, y: f64, s: f64, outer: bool) -> HatchLoop {
let v = |a: f64, b: f64| PolyVertex::new(Vec2::new(a, b));
HatchLoop { vertices: vec![v(x, y), v(x + s, y), v(x + s, y + s), v(x, y + s)], outer, sources: Vec::new() }
}
#[test]
fn region_with_islands_survives_save_and_reopen() {
let mut d = Drawing::new_imperial();
// A square with a round hole and a square island in it, and a second square piece.
let hole = HatchLoop {
vertices: vec![PolyVertex::with_bulge(Vec2::new(3.0, 5.0), 1.0), PolyVertex::with_bulge(Vec2::new(7.0, 5.0), 1.0)],
outer: false,
sources: Vec::new(),
};
let r = Region { loops: vec![square(0.0, 0.0, 10.0, true), hole, square(4.5, 4.5, 1.0, true), square(20.0, 0.0, 2.0, true)] };
let h = d.add(&Space::Model, Common { layer: "Areas".into(), ..Common::default() }, EntityKind::Region(r.clone())).unwrap();
let plain = d
.add(
&Space::Model,
Common::default(),
EntityKind::LwPolyline(cadcraft_doc::LwPolyline {
vertices: square(50.0, 0.0, 1.0, true).vertices,
closed: true,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
}),
)
.unwrap();
let saved = cadcraft_io::write_dxf(&d);
let back = cadcraft_io::read_dxf(saved.as_bytes()).unwrap();
assert_eq!(regions(&back), vec![r.clone()]);
assert_eq!(back.model.len(), 2, "the loops come back as one region, the plain polyline stays");
let e = back.entity(h).unwrap();
assert_eq!(e.common.layer, "Areas");
assert!(matches!(back.entity(plain).unwrap().kind, EntityKind::LwPolyline(_)));
// Other readers see four closed polylines on the region's layer.
let tags = cadcraft_dxf::parse(saved.as_bytes()).unwrap();
let ents = records(&sections(&tags).into_iter().find(|s| s.name == "ENTITIES").unwrap().tags);
let polys: Vec<_> = ents.iter().filter(|(k, _)| k == "LWPOLYLINE").collect();
assert_eq!(polys.len(), 5);
assert_eq!(polys.iter().filter(|(_, t)| t.iter().any(|g| g.code == 8 && g.str() == "Areas")).count(), 4);
assert!(!ents.iter().any(|(k, _)| k == "REGION"));
// Saving again is stable.
let again = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&back).as_bytes()).unwrap();
assert_eq!(regions(&again), vec![r]);
}
#[test]
fn foreign_region_stays_unknown() {
let text = "0\nSECTION\n2\nENTITIES\n0\nREGION\n5\n2B\n100\nAcDbEntity\n8\n0\n100\nAcDbModelerGeometry\n70\n1\n1\nabc\n0\nENDSEC\n0\nEOF\n";
let d = cadcraft_io::read_dxf(text.as_bytes()).unwrap();
assert!(matches!(&d.model.iter().next().unwrap().kind, EntityKind::Unknown(u) if u.dxf_type == "REGION"));
}
#[test]
fn hostile_region_xdata_does_not_panic() {
let mut t = String::from("0\nSECTION\n2\nENTITIES\n");
t.push_str("0\nLWPOLYLINE\n5\n30\n8\n0\n90\n2\n70\n1\n10\n0\n20\n0\n10\n1\n20\n1\n1001\nCADCRAFT\n1000\nREGION\n1002\n{\n");
for _ in 0..12_000 {
t.push_str("1000\nLOOP\n1070\n7\n1071\n-1\n1010\nnan\n1020\ninf\n1040\n1e308\n1010\n1\n1020\n2\n");
}
t.push_str("0\nLWPOLYLINE\n5\n31\n8\n0\n90\n1\n10\nnan\n1001\nCADCRAFT\n1000\nREGION\n");
t.push_str("0\nLWPOLYLINE\n5\n32\n8\n0\n90\n1\n10\n0\n1001\nCADCRAFT\n1000\nREGIONPART\n");
t.push_str("0\nENDSEC\n0\nEOF\n");
let d = cadcraft_io::read_dxf(t.as_bytes()).unwrap();
for r in regions(&d) {
assert!(r.loops.len() <= cadcraft_doc::MAX_REGION_LOOPS);
let _ = r.area();
let _ = r.props();
}
let _ = cadcraft_io::write_dxf(&d);
let _ = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default());
}
+1
View File
@@ -1115,3 +1115,4 @@
Close Array Editing Завершити редагування масиву
Multiline... Мультилінія...
Multiline Style... Стиль мультилінії...
Solid Editing Редагування тіл
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
1115
1116
1117
1118
+1 -1
View File
@@ -47,7 +47,7 @@ do them first.
| F18 | Layer bugs: edits change geometry on locked layers (#87, PR #196); delete/purge removes layers used by blocks (#72, PR #189); rename misses paper space and blocks (#75, PR #191); linetype rename breaks assignments (#74, PR #195); rejected freeze changes colour (#71, PR #188) | issues | High (correctness) | 3–5 (PRs open) |
| F19 | MATCHPROP Settings is a keyword prompt (no dialog) and has no material, shadow or centre-object settings; TEXTTOFRONT and HATCHTOBACK act on the current space only; OVERKILL doesn't compare polyline segments with other objects (picker and Settings, DRAWORDER Above/Under, TEXTTOFRONT, HATCHTOBACK, OVERKILL tolerance/ignore/overlap/end-to-end options landed, #439) | `props/matchprop.rs`, `draworder.rs`, `overkill.rs` | Low | 2–4 |
| F20 | OPTIONS/Preferences dialog missing (#23) (UNITS has a dialog since #38) | — | Medium | 6–10 |
| F21 | MLINE is two polylines (no MLINE entity, MLSTYLE); REGION makes polylines (no region entity); HELIX is a polyline | `draw2.rs:116,123`, `hatch.rs:267` | Medium-low | 8–12 |
| F21 | MLINE, REGION and HELIX are objects since #510, with gaps: MLEDIT has no Merged Tee and tees trim square; UNION/SUBTRACT/INTERSECT only combine regions whose boundaries don't meet; regions are saved as closed polylines (no ACIS data); PURGE skips multiline styles | `draw2/mline.rs`, `inquiry/regions.rs`, `dxf_region.rs` | Low | 6–10 |
| F22 | Inquiry: MASSPROP approximate; COUNT counts model-space inserts only; no QuickCalc palette (CAL is an evaluator); no DWG Compare | `utility.rs:203,310-352`, `inquiry.rs:305` | Low-medium | 15–25 |
| F23 | No Sheet Set Manager, eTransmit, Share, Traces, Markup Import, Activity Insights | menu items uncovered | Low-medium (team workflows) | 40–70 |
| F24 | No 3D: UCS, 3D views, orbit, solids, surfaces, meshes, sections, visual styles, materials, lights, rendering (150 menu items) | [parity-checklist](parity-checklist.md) | Medium (3D users) | 300–450 |