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:
bherbruckandClaude Opus 5.5 committed 2026-10-08 05:46:32 +00:00
1 parent 7757e6d9c7
commit 1db841aa76
2 files changed
+213 -16

No files matched your search

+27 -10
View File
@@ -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
View File
@@ -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());
}
}