secondmouseau/swiftdxf
A small, dependency-free Swift reader for ASCII DXF: AutoCAD's Drawing Interchange Format,
What it reads
The 2D entity set that dominates real-world drawings, from the ENTITIES (model-space) section:
| DXF entity | DXF.Entity case | |---|---| | LINE | .line(a, b, layer, color) | | CIRCLE | .circle(center, radius, …) | | ARC | .arc(center, radius, startDeg, endDeg, …) | | ELLIPSE | .ellipse(center, majorAxis, ratio, startParam, endParam, …) | | POINT | .point(at, …) | | TEXT, MTEXT | .text(at, height, rotationDeg, string, …) | | LWPOLYLINE, POLYLINE/VERTEX | .polyline(vertices, closed, …): each vertex carries its bulge | | DIMENSION | .dimension(Dimension): semantic measurement only, not the rendered arrow/text glyphs | | SPLINE | .spline(Spline): degree, flags, control points, knots, weights, fit points; flattens to a polyline |
Entities it does not model (e.g. INSERT, HATCH, SOLID) are skipped, not fatal, and reported on the drawing (see below).
Skipped entities
let dwg = try DXF.read(contentsOf: url)
dwg.skipped // ["INSERT": 3, "HATCH": 1] type name -> count
dwg.skippedByLayer // ["INSERT": ["PARTS": 3], "HATCH": ["0": 1]] type -> layer -> countAn empty skipped means nothing in ENTITIES was dropped. dxfdump prints the same table to stderr and includes both dictionaries in its JSON. The counts match ezdxf's per-type counts for every type SwiftDXF does not model.
Splines
for case .spline(let s) in dwg.entities {
s.degree, s.isClosed, s.controlPoints, s.knots, s.weights, s.fitPoints
let pts = s.flattened(tolerance: 0.01) // [DXF.Point], within 0.01 of the exact curve
let line = s.asPolyline(tolerance: 0.01) // DXF.Entity.polyline, same layer and colour
}flattened(tolerance:) evaluates the rational B-spline exactly (clamped or periodic knots, weights honoured) and subdivides each knot span until its sampled deviation from the chord is within the tolerance. Fallbacks, so malformed data never traps:
- Degree is clamped to
1...(controlPoints.count - 1). - A knot vector that is not
count + degree + 1finite, non-decreasing values with a non-empty
domain is replaced by a clamped uniform one.
- Weights that are not exactly one positive finite value per control point are ignored.
- With no control points, a uniform Catmull-Rom curve through the fit points is used. It passes
through every fit point but does not reproduce a file's own tangent data.
- A closed spline whose flattened end differs from its start gets the start appended.
- Fewer than two control points returns what is there;
point(at:)returnsnil.
Drawing also exposes header essentials: version ($ACADVER), insUnits ($INSUNITS), and the declared extMin / extMax extents ($EXTMIN / $EXTMAX), plus a computed bounds over the entities actually read.
Behaviours worth knowing
- Encoding. Files are decoded as UTF-8 when valid, otherwise CP932 / Shift-JIS: the common
code page for DXF exported by Japanese CAD tools (e.g. Jw_cad). Because CP932 is an ASCII superset, the group-code structure is unaffected either way. AutoCAD \U+XXXX text escapes are decoded.
- Line endings & padding. Handles LF / CRLF / CR and R12-style space-padded group codes (
0). - Ellipse normalisation. DXF requires
ratio = minor/major ≤ 1. Files that emitratio > 1
(a swapped major axis) are normalised to the canonical form (same curve, valid axis), so consumers like OCCT's Geom_Ellipse (which demands majorRadius ≥ minorRadius) get correct geometry. This matches ezdxf's normalisation exactly.
- Binary DXF is detected and rejected (
Error.binaryUnsupported): convert to ASCII DXF first. - Model space only. Mirrors what
ezdxf'smodelspace()iterates;BLOCKSdefinitions are not
expanded.
Contours
drawing.contours(_:) turns the loose entities of a drawing into closed regions: the OCCT-free work every consumer otherwise repeats (joining lines and arcs into loops, nesting islands and holes, applying units, approximating ellipses and splines). It adds no dependencies and returns plain Sendable values in millimetres.
let result = try DXF.read(contentsOf: url).contours(.init(layers: ["CUT"]))
for shape in result.shapes { // largest outer area first
shape.outer // counter-clockwise DXF.Contour.Loop
shape.holes // clockwise loops
shape.area, shape.bounds // outer minus holes; exact bounds
}
result.openChains // segments that never closed
result.warnings // see belowA DXF.Contour.Segment is a line or a circular arc stored as start, end and a DXF bulge (tan(sweep / 4), positive counter-clockwise), with center, radius, startAngle, sweep, length and exact bounds (arc extrema included) derived from it. Arcs never sweep more than a half turn, so a circle is two segments and a 270 degree arc is two. A Loop is a closed chain with signedArea, area, bounds, length and the source layers.
| Entity | Becomes | |---|---| | LINE, ARC, CIRCLE | exact lines and arcs | | LWPOLYLINE, POLYLINE | exact lines and arcs from the bulges (closed adds the closing segment) | | ELLIPSE with ratio 1 | exact arcs | | other ELLIPSE, SPLINE | sampled, then fitted with lines and circular arcs | | TEXT, DIMENSION, POINT | ignored, with a warning | | INSERT and unmodelled types | not read, with a warning |
Options (DXF.Contour.Options), all tolerances in millimetres:
layers,excludedLayers: filter by layer name, case-insensitively.joinTolerance(default0.01): endpoints closer than this are one point.fitTolerance(default0.01): how far a fitted line or arc may stray from the curve.units: force the drawing's units; otherwise$INSUNITSis used.unitlessFallback(default
millimetres) applies when the file declares none, and a warning says so.
nesting:.evenOdd(default) or.outermostOnly.
How it works:
- Chaining. Endpoints are merged through a grid hash, so the cost is linear in the segment
count. Order and direction of the input do not matter, repeated segments collapse, a branch hanging off a loop is split off as an open chain, and loops that touch at a point stay separate.
- Nesting. A loop is inside another when probe points taken from it lie inside the other by the
even-odd rule (a loop inside one loop is a hole, inside two is an island, and so on). The test handles arcs, skips probes that sit on the other loop's boundary, and uses a per-loop index so a 30,000 segment outline is not rescanned for every hole.
- Fitting. Ellipses and splines are sampled so every chord is within a quarter of
fitTolerance, then covered greedily by the longest line or arc that stays within half of it. result.maxFitDeviation is an upper bound on the distance to the true curve and never exceeds fitTolerance; the tests measure the true deviation against the exact curve.
- Warnings (
result.warnings):unitlessAssumed,unknownUnitCode,skippedEntities,
blockReferencesNotExpanded, ignoredText, ignoredDimensions, ignoredPoints, fittedCurves, openChains, degenerateGeometry, coincidentLoops, crossingLoops, nestedLoopsDropped.
Limits:
- Geometry is projected onto the XY plane;
zis ignored. - The extrusion direction (OCS) is not read, so entities on a mirrored plane such as
(0, 0, -1) come out un-mirrored (#21).
INSERTblocks are not expanded (#5).- Loops are assumed not to cross. Loops that do are reported (
crossingLoops) and left unnested.
Collinear segments that overlap only in part are not merged.
- Nesting compares bounding boxes pairwise, so it is quadratic in the number of loops (tens of
thousands of loops are fine; millions are not).
dxfdump --contours [--layers A,B] [--units inch|mm] file.dxf prints the same summary as JSON.
Correctness: tested against an oracle
tools/oracle.py runs ezdxf over the same files and emits per-file entity counts and a geometry digest (the sum of every defining coordinate scalar, in document order). The dxfdump executable emits the identical shape from SwiftDXF:
swift run dxfdump drawing.dxf | python3 -m json.toolSPLINE control data and NURBS evaluation are checked against ezdxf on fixtures it wrote (Tests/SwiftDXFTests/Fixtures/, generated by tools/spline_fixtures.py): clamped, rational, 3D and periodic cases. Flattening deviation is asserted against dense exact evaluation. The contour layer is tested on fixtures from tools/contour_fixtures.py with analytic areas and bounds, and its fitted ellipses and splines are measured against the exact curves.
Across an 11-file corpus (~62,000 entities, mixed R12 drawings incl. Jw_cad exports), SwiftDXF matches ezdxf exactly: every entity count and every coordinate scalar, to the bit. The Tests/SwiftDXFTests/CorpusOracleTests.swift suite pins those ezdxf-derived counts as a regression gate (skipped when the corpus is absent; point DXF_CORPUS at a folder of .dxf files to run).
Install
.package(url: "https://github.com/SecondMouseAU/SwiftDXF.git", from: "0.1.0"),
// target dependency: .product(name: "SwiftDXF", package: "SwiftDXF")Scope
A focused 2D reader, not a full DXF/DWG toolkit. Out of scope for now: DWG, binary DXF, block/insert expansion, hatches, OCS/extrusion, and writing. (DXF writing for the JWW format lives in SwiftJWW.)
License
MIT: see LICENSE.
Package Metadata
Repository: secondmouseau/swiftdxf
Default branch: main
README: README.md