mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-24 10:56:51 -06:00
Squash of PR #526 by @vangel76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bc7744b27b
commit
afae36cb60
@@ -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);
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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")),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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") }))
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(§ions(&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());
|
||||
}
|
||||
@@ -1115,3 +1115,4 @@
|
||||
Close Array Editing Завершити редагування масиву
|
||||
Multiline... Мультилінія...
|
||||
Multiline Style... Стиль мультилінії...
|
||||
Solid Editing Редагування тіл
|
||||
|
||||
|
+1
-1
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user