From 1db841aa76cbf56c5a5994d753335c7e31c4615d Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 05:46:31 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/engine/src/cmd/sketch_import.rs | 37 +++-- crates/io/src/dxf.rs | 192 ++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/sketch_import.rs b/crates/engine/src/cmd/sketch_import.rs index 80952625..58c4362f 100644 --- a/crates/engine/src/cmd/sketch_import.rs +++ b/crates/engine/src/cmd/sketch_import.rs @@ -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 { } /// 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, Vec)> { - 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, Vec)> { + let mut map: HashMap<(i64, i64), Vec<(Vec2, usize)>> = HashMap::new(); let mut pt = |sk: &mut Sketch, p: Vec2| -> Result { 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 { 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": ""})).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(); + } } } diff --git a/crates/io/src/dxf.rs b/crates/io/src/dxf.rs index f9d62c54..2217220f 100644 --- a/crates/io/src/dxf.rs +++ b/crates/io/src/dxf.rs @@ -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> { } 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)> = 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)> = 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> { let mut ents: Vec = 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> { _ => 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)>, depth: usize, out: &mut Vec) -> 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> { } } 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> { 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> { _ => {} } } - 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 { + 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) { + let flip = self.det() < 0.0; + let sampled = |f: &dyn Fn(f64) -> Vec2, closed: bool| { + let n = 32; + let mut pts: Vec = (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) { @@ -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()); } }