Insert DXF: block references expanded (INSERT with scale, rotation, column/row arrays, nested blocks with a depth cap; non-uniform scale turns circles into ellipses), paper-space entities skipped; DXF/SVG insert joins ends within a tolerance (default 0.0001 mm, tolerance param) so nearly-closed drawings make profiles
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
7757e6d9c7
commit
1db841aa76
2 files changed
+213
-16
No files matched your search
@@ -16,11 +16,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("ImportDxfFileCommand", "Insert DXF", insert_dxf)
|
||||
.at("SKETCH", "INSERT")
|
||||
.icon("dxf")
|
||||
.params("path | text: ASCII DXF; at?: [x,y] offset, scale?; plane?: (when no sketch is active, a new sketch on it; default XY)"),
|
||||
.params("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)"),
|
||||
CommandSpec::new("SketchImportSVG", "Insert SVG", insert_svg)
|
||||
.at("SKETCH", "INSERT")
|
||||
.icon("svg")
|
||||
.params("path | text: SVG; at?: [x,y] offset, scale?; plane?: (when no sketch is active, a new sketch on it; default XY)"),
|
||||
.params("path | text: SVG; at?: [x,y] offset, scale?, tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)"),
|
||||
CommandSpec::new("sketch.export_dxf", "Save As DXF", export_dxf)
|
||||
.noundo()
|
||||
.params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned)"),
|
||||
@@ -43,17 +43,24 @@ fn source_text(p: &Value, cmd: &str) -> Result<String> {
|
||||
}
|
||||
|
||||
/// Add imported geometry to a sketch, sharing end points that meet.
|
||||
fn add_geometry(sk: &mut Sketch, geom: &[Geom2], f: &dyn Fn(Vec2) -> Vec2, k: f64) -> Result<(Vec<usize>, Vec<Geom2>)> {
|
||||
let q = 1e-6;
|
||||
let mut map: HashMap<(i64, i64), usize> = HashMap::new();
|
||||
/// Adds the geometry; end points closer than `q` (mm) become one point, so drawings whose
|
||||
/// ends almost meet still make closed profiles.
|
||||
fn add_geometry(sk: &mut Sketch, geom: &[Geom2], f: &dyn Fn(Vec2) -> Vec2, k: f64, q: f64) -> Result<(Vec<usize>, Vec<Geom2>)> {
|
||||
let mut map: HashMap<(i64, i64), Vec<(Vec2, usize)>> = HashMap::new();
|
||||
let mut pt = |sk: &mut Sketch, p: Vec2| -> Result<usize> {
|
||||
let p = f(p);
|
||||
let key = ((p.x / q).round() as i64, (p.y / q).round() as i64);
|
||||
if let Some(i) = map.get(&key) {
|
||||
return Ok(*i);
|
||||
let key = ((p.x / q).floor() as i64, (p.y / q).floor() as i64);
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
if let Some(v) = map.get(&(key.0 + dx, key.1 + dy))
|
||||
&& let Some((_, i)) = v.iter().find(|(o, _)| o.dist(p) <= q)
|
||||
{
|
||||
return Ok(*i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let i = sk.add_point(p, None)?;
|
||||
map.insert(key, i);
|
||||
map.entry(key).or_default().push((p, i));
|
||||
Ok(i)
|
||||
};
|
||||
let mut curves = Vec::new();
|
||||
@@ -119,9 +126,10 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
|
||||
let r = (super::find_command("SketchCreate").ok_or_else(|| bad(cmd, "SketchCreate"))?.run)(s, &cp)?;
|
||||
created = r.get("sketch").and_then(Value::as_u64);
|
||||
}
|
||||
let tol = num(p, "tolerance").filter(|t| t.is_finite() && *t > 0.0).unwrap_or(1e-4).clamp(1e-9, 10.0);
|
||||
let f = move |q: Vec2| q * k + off;
|
||||
let ((curves, texts), info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (cs, texts) = add_geometry(sk, &geom, &f, k)?;
|
||||
let (cs, texts) = add_geometry(sk, &geom, &f, k, tol)?;
|
||||
Ok((ids_of(sk, &cs), texts))
|
||||
})?;
|
||||
// Text becomes sketch text.
|
||||
@@ -216,5 +224,14 @@ mod tests {
|
||||
assert_eq!(r["texts"], 1, "{r}");
|
||||
assert!(s.execute("SketchImportSVG", &json!({"text": "<svg></svg>"})).is_err());
|
||||
assert!(s.execute("ImportDxfFileCommand", &json!({"path": "/nonexistent.dxf"})).is_err());
|
||||
s.execute("SketchStop", &json!({})).unwrap();
|
||||
// Ends that miss by 0.00005 still close the square; with a tighter tolerance they don't.
|
||||
let gappy = "0\nSECTION\n2\nENTITIES\n0\nLINE\n10\n0\n20\n0\n11\n10\n21\n0\n0\nLINE\n10\n10.00005\n20\n0\n11\n10\n21\n10\n0\nLINE\n10\n10\n20\n10\n11\n0\n21\n10\n0\nLINE\n10\n0\n20\n10.00003\n11\n0\n21\n0.00004\n0\nENDSEC\n0\nEOF\n";
|
||||
for (tol, n) in [(1e-4, 1), (1e-6, 0)] {
|
||||
let r = s.execute("ImportDxfFileCommand", &json!({"text": gappy, "tolerance": tol})).unwrap();
|
||||
let ss = s.model.state().sketch(r["sketch"].as_u64().unwrap()).unwrap().clone();
|
||||
assert_eq!(ss.profiles.len(), n, "tolerance {tol}");
|
||||
s.execute("SketchStop", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
+186
-6
@@ -1,8 +1,11 @@
|
||||
//! ASCII DXF (the public AutoCAD DXF reference): the ENTITIES section's LINE, CIRCLE, ARC,
|
||||
//! LWPOLYLINE, POLYLINE/VERTEX, POINT, ELLIPSE, SPLINE, TEXT and MTEXT, scaled to millimetres by
|
||||
//! `$INSUNITS`. Blocks are not expanded. The writer emits an R12-style file of lines, circles,
|
||||
//! `$INSUNITS`. Block references (INSERT, with scale, rotation and arrays) are expanded,
|
||||
//! nested blocks too; paper-space entities are skipped. The writer emits an R12-style file of lines, circles,
|
||||
//! arcs and points (free-form curves as polylines).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use solvecraft_geom::Vec2;
|
||||
|
||||
use crate::sketch2d::{Geom2, MAX_DRAWING_BYTES, MAX_ENTITIES, bulge_arc};
|
||||
@@ -60,8 +63,39 @@ pub fn read_dxf(text: &str) -> Result<Vec<Geom2>> {
|
||||
}
|
||||
let p = pairs(text);
|
||||
let k = units_scale(&p);
|
||||
// Entities of the ENTITIES section.
|
||||
let start = p.iter().position(|(c, v)| *c == 2 && v == "ENTITIES").ok_or_else(|| IoError::Invalid("no ENTITIES section".into()))?;
|
||||
let ents = section(&p, start)?;
|
||||
// Blocks: name → (base point, entities).
|
||||
let mut blocks: HashMap<String, (Vec2, Vec<Ent>)> = HashMap::new();
|
||||
if let Some(bs) = p.iter().position(|(c, v)| *c == 2 && v == "BLOCKS") {
|
||||
let all = section(&p, bs)?;
|
||||
let mut cur: Option<(String, Vec2, Vec<Ent>)> = None;
|
||||
for e in all {
|
||||
match e.kind.as_str() {
|
||||
"BLOCK" => {
|
||||
let base = Vec2::new(e.get(10).unwrap_or(0.0) * k, e.get(20).unwrap_or(0.0) * k);
|
||||
cur = Some((e.text(2).unwrap_or("").to_string(), base, Vec::new()));
|
||||
}
|
||||
"ENDBLK" => {
|
||||
if let Some((name, base, list)) = cur.take() {
|
||||
blocks.insert(name, (base, list));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(c) = cur.as_mut() {
|
||||
c.2.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
convert(&ents, k, &blocks, 0, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The entities of the section whose name is at `start`.
|
||||
fn section(p: &[(i32, String)], start: usize) -> Result<Vec<Ent>> {
|
||||
let mut ents: Vec<Ent> = Vec::new();
|
||||
for (c, v) in p.iter().skip(start + 1) {
|
||||
if *c == 0 {
|
||||
@@ -81,13 +115,53 @@ pub fn read_dxf(text: &str) -> Result<Vec<Geom2>> {
|
||||
_ => e.s.push((*c, v.clone())),
|
||||
}
|
||||
}
|
||||
Ok(ents)
|
||||
}
|
||||
|
||||
/// Deepest block nesting expanded.
|
||||
const MAX_BLOCK_DEPTH: usize = 16;
|
||||
|
||||
/// Entities to geometry (millimetres), expanding block references.
|
||||
fn convert(ents: &[Ent], k: f64, blocks: &HashMap<String, (Vec2, Vec<Ent>)>, depth: usize, out: &mut Vec<Geom2>) -> Result<()> {
|
||||
let pt = |x: f64, y: f64| Vec2::new(x * k, y * k);
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < ents.len() {
|
||||
let Some(e) = ents.get(i) else { break };
|
||||
i += 1;
|
||||
if out.len() > MAX_ENTITIES {
|
||||
return Err(IoError::Invalid("too many entities".into()));
|
||||
}
|
||||
// Paper space (layouts) is not the drawing.
|
||||
if e.get(67).is_some_and(|v| v != 0.0) {
|
||||
continue;
|
||||
}
|
||||
match e.kind.as_str() {
|
||||
"INSERT" => {
|
||||
let Some((base, list)) = e.text(2).and_then(|n| blocks.get(n)) else { continue };
|
||||
if depth >= MAX_BLOCK_DEPTH {
|
||||
continue;
|
||||
}
|
||||
let mut inner = Vec::new();
|
||||
convert(list, k, blocks, depth + 1, &mut inner)?;
|
||||
let (sx, sy) = (e.get(41).unwrap_or(1.0), e.get(42).unwrap_or(1.0));
|
||||
let rot = e.get(50).unwrap_or(0.0).to_radians();
|
||||
let ins = pt(e.get(10).unwrap_or(0.0), e.get(20).unwrap_or(0.0));
|
||||
let (cols, rows) = (e.get(70).unwrap_or(1.0).clamp(1.0, 10_000.0) as usize, e.get(71).unwrap_or(1.0).clamp(1.0, 10_000.0) as usize);
|
||||
if cols.saturating_mul(rows).saturating_mul(inner.len()) > MAX_ENTITIES {
|
||||
return Err(IoError::Invalid("too many entities".into()));
|
||||
}
|
||||
let (dc, dr) = (e.get(44).unwrap_or(0.0) * k, e.get(45).unwrap_or(0.0) * k);
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
// Array offsets run along the rotated block axes.
|
||||
let off = Vec2::from_angle(rot) * (dc * c as f64) + Vec2::from_angle(rot).perp() * (dr * r as f64);
|
||||
let t = Affine::insert(ins + off, *base, sx, sy, rot);
|
||||
for g in &inner {
|
||||
t.apply(g, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"LINE" => {
|
||||
if let (Some(x0), Some(y0), Some(x1), Some(y1)) = (e.get(10), e.get(20), e.get(11), e.get(21)) {
|
||||
out.push(Geom2::Line(pt(x0, y0), pt(x1, y1)));
|
||||
@@ -132,7 +206,7 @@ pub fn read_dxf(text: &str) -> Result<Vec<Geom2>> {
|
||||
}
|
||||
}
|
||||
let closed = e.get(70).is_some_and(|f| (f as i64) & 1 == 1);
|
||||
polyline(&verts, closed, &mut out);
|
||||
polyline(&verts, closed, out);
|
||||
}
|
||||
"POLYLINE" => {
|
||||
let closed = e.get(70).is_some_and(|f| (f as i64) & 1 == 1);
|
||||
@@ -149,7 +223,7 @@ pub fn read_dxf(text: &str) -> Result<Vec<Geom2>> {
|
||||
if ents.get(i).is_some_and(|x| x.kind == "SEQEND") {
|
||||
i += 1;
|
||||
}
|
||||
polyline(&verts, closed, &mut out);
|
||||
polyline(&verts, closed, out);
|
||||
}
|
||||
"ELLIPSE" => {
|
||||
if let (Some(x), Some(y), Some(mx), Some(my), Some(ratio)) = (e.get(10), e.get(20), e.get(11), e.get(21), e.get(40)) {
|
||||
@@ -198,7 +272,94 @@ pub fn read_dxf(text: &str) -> Result<Vec<Geom2>> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// p ↦ m·p + t.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Affine {
|
||||
m: [f64; 4],
|
||||
t: Vec2,
|
||||
}
|
||||
|
||||
impl Affine {
|
||||
/// A block placed at `ins`: its base point moves there, scaled then rotated.
|
||||
fn insert(ins: Vec2, base: Vec2, sx: f64, sy: f64, rot: f64) -> Affine {
|
||||
let (s, c) = rot.sin_cos();
|
||||
let m = [c * sx, -s * sy, s * sx, c * sy];
|
||||
let mb = Vec2::new(m[0] * base.x + m[1] * base.y, m[2] * base.x + m[3] * base.y);
|
||||
Affine { m, t: ins - mb }
|
||||
}
|
||||
fn p(&self, p: Vec2) -> Vec2 {
|
||||
Vec2::new(self.m[0] * p.x + self.m[1] * p.y, self.m[2] * p.x + self.m[3] * p.y) + self.t
|
||||
}
|
||||
fn v(&self, v: Vec2) -> Vec2 {
|
||||
Vec2::new(self.m[0] * v.x + self.m[1] * v.y, self.m[2] * v.x + self.m[3] * v.y)
|
||||
}
|
||||
fn det(&self) -> f64 {
|
||||
self.m[0] * self.m[3] - self.m[1] * self.m[2]
|
||||
}
|
||||
/// The scale when it is the same in every direction.
|
||||
fn uniform(&self) -> Option<f64> {
|
||||
let (a, b) = (self.v(Vec2::new(1.0, 0.0)), self.v(Vec2::new(0.0, 1.0)));
|
||||
((a.len() - b.len()).abs() <= 1e-9 * a.len().max(b.len()) && a.dot(b).abs() <= 1e-9 * a.len() * b.len()).then_some(a.len())
|
||||
}
|
||||
fn apply(&self, g: &Geom2, out: &mut Vec<Geom2>) {
|
||||
let flip = self.det() < 0.0;
|
||||
let sampled = |f: &dyn Fn(f64) -> Vec2, closed: bool| {
|
||||
let n = 32;
|
||||
let mut pts: Vec<Vec2> = (0..=n).map(|j| self.p(f(j as f64 / n as f64))).collect();
|
||||
if closed {
|
||||
pts.pop();
|
||||
if let Some(first) = pts.first().copied() {
|
||||
pts.push(first);
|
||||
}
|
||||
}
|
||||
Geom2::Spline { pts, control: false, degree: 3 }
|
||||
};
|
||||
out.push(match g {
|
||||
Geom2::Point(p) => Geom2::Point(self.p(*p)),
|
||||
Geom2::Line(a, b) => Geom2::Line(self.p(*a), self.p(*b)),
|
||||
Geom2::Circle(c, r) => match self.uniform() {
|
||||
Some(s) => Geom2::Circle(self.p(*c), r * s),
|
||||
None => {
|
||||
// The scaled axes are the ellipse's axes (scale comes before rotation).
|
||||
let (u, w) = (self.v(Vec2::new(*r, 0.0)), self.v(Vec2::new(0.0, *r)));
|
||||
let (maj, min) = if u.len() >= w.len() { (u, w) } else { (w, u) };
|
||||
Geom2::Ellipse { c: self.p(*c), major: self.p(*c) + maj, minor: min.len() }
|
||||
}
|
||||
},
|
||||
Geom2::Arc { c, a, b } => match self.uniform() {
|
||||
Some(_) if flip => Geom2::Arc { c: self.p(*c), a: self.p(*b), b: self.p(*a) },
|
||||
Some(_) => Geom2::Arc { c: self.p(*c), a: self.p(*a), b: self.p(*b) },
|
||||
None => {
|
||||
let (r, a0) = ((*a - *c).len(), (*a - *c).angle());
|
||||
let mut sweep = (*b - *c).angle() - a0;
|
||||
while sweep <= 0.0 {
|
||||
sweep += std::f64::consts::TAU;
|
||||
}
|
||||
sampled(&|t| *c + Vec2::from_angle(a0 + sweep * t) * r, false)
|
||||
}
|
||||
},
|
||||
Geom2::Ellipse { c, major, minor } => {
|
||||
let u = *major - *c;
|
||||
let w = u.perp() * (minor / u.len().max(1e-300));
|
||||
match self.uniform() {
|
||||
Some(s) => Geom2::Ellipse { c: self.p(*c), major: self.p(*major), minor: minor * s },
|
||||
None => sampled(&|t| *c + u * (t * std::f64::consts::TAU).cos() + w * (t * std::f64::consts::TAU).sin(), true),
|
||||
}
|
||||
}
|
||||
Geom2::Spline { pts, control, degree } => {
|
||||
Geom2::Spline { pts: pts.iter().map(|p| self.p(*p)).collect(), control: *control, degree: *degree }
|
||||
}
|
||||
Geom2::Conic { a, apex, b, rho } => Geom2::Conic { a: self.p(*a), apex: self.p(*apex), b: self.p(*b), rho: *rho },
|
||||
Geom2::Text { text, at, height, angle } => {
|
||||
let dir = self.v(Vec2::from_angle(*angle));
|
||||
let up = self.v(Vec2::from_angle(*angle).perp());
|
||||
Geom2::Text { text: text.clone(), at: self.p(*at), height: height * up.len(), angle: dir.angle() }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn polyline(verts: &[(Vec2, f64)], closed: bool, out: &mut Vec<Geom2>) {
|
||||
@@ -310,6 +471,25 @@ mod tests {
|
||||
x => panic!("{x:?}"),
|
||||
}
|
||||
assert!(read_dxf("garbage").is_err());
|
||||
// A block of a unit square and a circle, inserted twice: rotated 90° at (10, 0), and as a
|
||||
// 2 × 1 array scaled 2 × 1 at (0, 20) (the circle becomes an ellipse). Nested once more.
|
||||
let dxf = "0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n2\nSQ\n10\n0\n20\n0\n0\nLINE\n10\n0\n20\n0\n11\n1\n21\n0\n0\nCIRCLE\n10\n0\n20\n0\n40\n1\n0\nENDBLK\n0\nBLOCK\n2\nOUTER\n10\n0\n20\n0\n0\nINSERT\n2\nSQ\n10\n5\n20\n5\n0\nENDBLK\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nINSERT\n2\nSQ\n10\n10\n20\n0\n50\n90\n0\nINSERT\n2\nSQ\n10\n0\n20\n20\n41\n2\n42\n1\n70\n2\n44\n10\n0\nINSERT\n2\nOUTER\n10\n100\n20\n0\n0\nINSERT\n2\nNOPE\n0\nLINE\n67\n1\n10\n0\n20\n0\n11\n1\n21\n1\n0\nENDSEC\n0\nEOF\n";
|
||||
let g = read_dxf(dxf).unwrap();
|
||||
assert_eq!(g.len(), 8, "{g:?}");
|
||||
match &g[0] {
|
||||
Geom2::Line(a, b) => assert!(a.dist(Vec2::new(10.0, 0.0)) < 1e-9 && b.dist(Vec2::new(10.0, 1.0)) < 1e-9, "{a:?} {b:?}"),
|
||||
x => panic!("{x:?}"),
|
||||
}
|
||||
match &g[5] {
|
||||
Geom2::Ellipse { c, major, minor } => {
|
||||
assert!(c.dist(Vec2::new(10.0, 20.0)) < 1e-9 && major.dist(Vec2::new(12.0, 20.0)) < 1e-9 && (minor - 1.0).abs() < 1e-9)
|
||||
}
|
||||
x => panic!("{x:?}"),
|
||||
}
|
||||
assert!(matches!(&g[7], Geom2::Circle(c, r) if c.dist(Vec2::new(105.0, 5.0)) < 1e-9 && (*r - 1.0).abs() < 1e-12));
|
||||
// A block that inserts itself stops at the depth limit.
|
||||
let selfref = "0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n2\nA\n0\nINSERT\n2\nA\n0\nLINE\n10\n0\n20\n0\n11\n1\n21\n0\n0\nENDBLK\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nINSERT\n2\nA\n0\nENDSEC\n0\nEOF\n";
|
||||
assert_eq!(read_dxf(selfref).unwrap().len(), 16);
|
||||
assert!(read_dxf("AutoCAD Binary DXF\r\n").is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user