diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml new file mode 100644 index 0000000..583f724 --- /dev/null +++ b/.github/workflows/code-style.yml @@ -0,0 +1,57 @@ +name: code-style + +# Rollout of the ecosystem's code-style policy (SwiftDXF#13); see +# docs/code-style-policy-proposal-2026-08.md in the `ecosystem` repo for the +# full rationale, and OCCTSwiftScripts#114/#115 for the reference +# implementation this repo follows. Full sweep, not a gradual rollout: this +# repo is small enough (~1,125 Swift lines, 6 files) to fully sweep into +# compliance in one PR and go straight to a blocking gate, rather than +# needing OCCTSwift's gradual "if you touch it, you fix it" exemption +# manifest. +# +# Pure text analysis (formatting + lint), no compiled output needed by any of +# the three checks, so this stays a macOS runner only because that's where +# swift-format and swiftlint are both readily available via Homebrew. +# +# swift-format and SwiftLint are blocking: formatting has no judgment call in +# it. The comment-ratio check is report-only by design (see the script's own +# header) and is not a gate; it always exits 0. + +on: + push: + paths: + - 'Sources/**' + - 'Tests/**' + - '.swift-format' + - '.swiftlint.yml' + - 'Scripts/comment-ratio-check.sh' + - '.github/workflows/code-style.yml' + pull_request: + paths: + - 'Sources/**' + - 'Tests/**' + - '.swift-format' + - '.swiftlint.yml' + - 'Scripts/comment-ratio-check.sh' + - '.github/workflows/code-style.yml' + workflow_dispatch: + +jobs: + code-style: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: Install swift-format and SwiftLint + run: brew install swift-format swiftlint + + - name: swift-format lint --strict + run: | + find Sources Tests -name '*.swift' -print0 \ + | xargs -0 swift-format lint --strict --configuration .swift-format + + - name: swiftlint lint --strict + run: swiftlint lint --strict --config .swiftlint.yml + + - name: Comment:code ratio (report-only, never fails) + run: Scripts/comment-ratio-check.sh diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..481e5cc --- /dev/null +++ b/.swift-format @@ -0,0 +1,75 @@ +{ + "version" : 1, + "lineLength" : 100, + "indentation" : { + "spaces" : 4 + }, + "tabWidth" : 4, + "maximumBlankLines" : 1, + "respectsExistingLineBreaks" : true, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineBreakBetweenDeclarationAttributes" : false, + "multiElementCollectionTrailingCommas" : true, + "prioritizeKeepingFunctionOutputTogether" : false, + "reflowMultilineStringLiterals" : "never", + "spacesAroundRangeFormationOperators" : false, + "spacesBeforeEndOfLineComments" : 2, + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "private" + }, + "orderedImports" : { + "includeConditionalImports" : false + }, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : false, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "AvoidRetroactiveConformances" : true, + "BeginDocumentationCommentWithOneLineSummary" : true, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : true, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : false, + "NeverUseForceTry" : false, + "NeverUseImplicitlyUnwrappedOptionals" : false, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyLinesOpeningClosingBraces" : false, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : false, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : false, + "ValidateDocumentationComments" : true + } +} diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..0b92e8f --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,24 @@ +# Deliberately narrow: `only_rules`, not `disabled_rules`. SwiftLint's default +# rule set covers two kinds of territory this repo doesn't want it opining on: +# +# - Layout (colon/comma/opening_brace/line_length/...): swift-format already +# owns this; running both fights, since they can disagree on the same line. +# - Code-quality/complexity (identifier_name, cyclomatic_complexity, +# function_body_length, nesting, type_body_length, file_length, ...), a +# separate concern from code style, and one that overlaps the ecosystem's +# own code-structure policy (a repo that needs a structural pass runs one +# as its own scoped initiative, not as a side effect of a style-lint gate). +# +# What's left is the one rule that catches something swift-format has no +# equivalent for: a doc comment not attached to any declaration. +# +# See docs/code-style-policy-proposal-2026-08.md in the ecosystem repo for +# the full rationale. Pattern matches OCCTSwiftScripts's own .swiftlint.yml, +# the reference implementation for this policy (OCCTSwiftScripts#115). + +only_rules: + - orphaned_doc_comment + +excluded: + - .build + - Tests diff --git a/Scripts/comment-ratio-check.sh b/Scripts/comment-ratio-check.sh new file mode 100755 index 0000000..c7b8596 --- /dev/null +++ b/Scripts/comment-ratio-check.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# comment-ratio-check.sh: flag Swift files where comment lines outnumber code +# lines, as a signal for a human to look, not a fail. +# +# Part of the ecosystem's code-style policy rollout (SwiftDXF#13); see +# docs/code-style-policy-proposal-2026-08.md in the `ecosystem` repo for the +# full rationale, and OCCTSwiftScripts#114/#115 for the reference +# implementation this script is copied from. A high ratio is sometimes +# legitimate (a small function with several real caveats worth spelling out) +# and sometimes means a doc comment has drifted into restating design +# rationale that belongs in docs/ instead: this script surfaces the number, +# it doesn't judge which case it is. Report-only: always exits 0, since +# failing every file above a threshold on day one, in a codebase nobody has +# swept yet, punishes the report instead of the problem. +# +# Usage: +# Scripts/comment-ratio-check.sh # default threshold: 1.0 +# Scripts/comment-ratio-check.sh 1.5 # custom threshold +set -euo pipefail + +threshold="${1:-1.0}" + +[ -d Sources ] || { echo "FAIL: Sources/ not found; run from the repo root" >&2; exit 1; } + +flagged=0 + +while IFS= read -r -d '' file; do + comment=0 + code=0 + while IFS= read -r line; do + trimmed="${line#"${line%%[![:space:]]*}"}" + [ -z "$trimmed" ] && continue + case "$trimmed" in + //*) comment=$((comment + 1)) ;; + *) code=$((code + 1)) ;; + esac + done < "$file" + + [ "$code" -eq 0 ] && continue + + # POSIX sh has no float math; compare as comment*100 >= threshold*code so + # a fractional threshold (the common case: 1.0, 1.5) still works exactly. + threshold_x100=$(awk -v t="$threshold" 'BEGIN { printf "%d", t * 100 }') + if [ $((comment * 100)) -ge $((threshold_x100 * code)) ]; then + ratio=$(awk -v c="$comment" -v d="$code" 'BEGIN { printf "%.2f", c / d }') + printf ' %-55s comment=%-5d code=%-5d ratio=%s\n' "$file" "$comment" "$code" "$ratio" + flagged=$((flagged + 1)) + fi +done < <(find Sources -name '*.swift' -print0 | sort -z) + +if [ "$flagged" -eq 0 ]; then + echo "comment-ratio-check: no files at or above ${threshold}x comment:code (Sources/)" +else + echo "comment-ratio-check: $flagged file(s) at or above ${threshold}x comment:code (Sources/): not a failure, a signal to look" +fi + +exit 0 diff --git a/Sources/SwiftDXF/DXF.swift b/Sources/SwiftDXF/DXF.swift index c732537..5a916de 100644 --- a/Sources/SwiftDXF/DXF.swift +++ b/Sources/SwiftDXF/DXF.swift @@ -1,12 +1,12 @@ import Foundation -/// A native-Swift reader for **DXF** — AutoCAD's *Drawing Interchange Format*, the de-facto +/// A native-Swift reader for **DXF**, AutoCAD's *Drawing Interchange Format*, the de-facto /// portable 2D/3D CAD exchange format. `SwiftDXF` reads the **ASCII** DXF variant (group-code / /// value line pairs) and lifts the model-space geometry into a neutral ``Drawing``. /// /// The reader is a clean-room implementation of the public DXF group-code reference. It targets the -/// 2D entity set that dominates real-world drawings — `LINE`, `CIRCLE`, `ARC`, `ELLIPSE`, `POINT`, -/// `TEXT` / `MTEXT`, `LWPOLYLINE` / `POLYLINE`, and `DIMENSION` — and skips entities it does not +/// 2D entity set that dominates real-world drawings: `LINE`, `CIRCLE`, `ARC`, `ELLIPSE`, `POINT`, +/// `TEXT` / `MTEXT`, `LWPOLYLINE` / `POLYLINE`, and `DIMENSION`, and skips entities it does not /// model rather than failing. Only the `ENTITIES` (model-space) section is read, matching what a /// tool like *ezdxf*'s `modelspace()` iterates. /// @@ -22,42 +22,60 @@ public enum DXF { // MARK: Model - /// A 3D point in the drawing's own units. Most 2D DXF geometry sits on the `z == 0` plane. + /// A 3D point in the drawing's own units. + /// + /// Most 2D DXF geometry sits on the `z == 0` plane. public struct Point: Equatable, Sendable { - public var x: Double; public var y: Double; public var z: Double - public init(_ x: Double, _ y: Double, _ z: Double = 0) { self.x = x; self.y = y; self.z = z } + public var x: Double + public var y: Double + public var z: Double + public init(_ x: Double, _ y: Double, _ z: Double = 0) { + self.x = x + self.y = y + self.z = z + } } - /// One model-space drawing entity. Angles are in **degrees** where DXF stores degrees (`ARC`), - /// and **radians** where DXF stores radians (`ELLIPSE` parameters); the doc comments say which. - /// `layer` is the layer name (DXF group 8); `color` is the AutoCAD Color Index (group 62), or - /// `256` for *BYLAYER* / `0` for *BYBLOCK* when not explicitly set. + /// One model-space drawing entity. + /// + /// Angles are in **degrees** where DXF stores degrees (`ARC`), and **radians** where DXF + /// stores radians (`ELLIPSE` parameters); the doc comments say which. `layer` is the layer + /// name (DXF group 8); `color` is the AutoCAD Color Index (group 62), or `256` for *BYLAYER* + /// / `0` for *BYBLOCK* when not explicitly set. public enum Entity: Sendable { case line(a: Point, b: Point, layer: String, color: Int) case circle(center: Point, radius: Double, layer: String, color: Int) /// Circular arc. `startDeg`/`endDeg` are absolute CCW angles in degrees (DXF groups 50/51); /// the arc sweeps CCW from start to end. - case arc(center: Point, radius: Double, startDeg: Double, endDeg: Double, layer: String, color: Int) + case arc( + center: Point, radius: Double, startDeg: Double, endDeg: Double, layer: String, + color: Int) /// Ellipse / elliptical arc. `majorAxis` is the major-axis endpoint **relative to** `center` /// (DXF groups 11/21/31); `ratio` is minor/major (group 40); `startParam`/`endParam` are the /// arc's parametric bounds in **radians** (groups 41/42; `0…2π` for a full ellipse). - case ellipse(center: Point, majorAxis: Point, ratio: Double, startParam: Double, endParam: Double, layer: String, color: Int) + case ellipse( + center: Point, majorAxis: Point, ratio: Double, startParam: Double, endParam: Double, + layer: String, color: Int) case point(at: Point, layer: String, color: Int) /// `TEXT` or `MTEXT`. `height` is the text height (group 40); `rotationDeg` the rotation in /// degrees (group 50); `string` is decoded to Unicode at read time. - case text(at: Point, height: Double, rotationDeg: Double, string: String, layer: String, color: Int) + case text( + at: Point, height: Double, rotationDeg: Double, string: String, layer: String, + color: Int) /// `LWPOLYLINE` or an old-style `POLYLINE`/`VERTEX` run. `closed` reflects the closed flag - /// (group 70 bit 1). Each vertex carries its **bulge** (group 42) — `tan(θ/4)` of the arc to the - /// next vertex, `0` for a straight segment — so curved polylines survive the read. + /// (group 70 bit 1). Each vertex carries its **bulge** (group 42), `tan(θ/4)` of the arc to + /// the next vertex, `0` for a straight segment, so curved polylines survive the read. case polyline(vertices: [PolyVertex], closed: Bool, layer: String, color: Int) - /// `DIMENSION`. Only the semantic measurement is modelled — the rendered arrow/text-block - /// glyph geometry is skipped, matching what a downstream drawing→3D pipeline needs to bind - /// a dimension to geometry, not draw it. + /// `DIMENSION`. Only the semantic measurement is modelled; the rendered arrow/text-block + /// glyph geometry is skipped, matching what a downstream drawing-to-3D pipeline needs to + /// bind a dimension to geometry, not draw it. case dimension(Dimension) } - /// Base `DIMENSION` type, decoded from group 70's low 4 bits (`raw & 15`) — the same masking - /// `ezdxf`'s `Dimension.dimtype` applies to strip the independent flag bits (32/64/128). + /// Base `DIMENSION` type, decoded from group 70's low 4 bits (`raw & 15`). + /// + /// Uses the same masking `ezdxf`'s `Dimension.dimtype` applies to strip the independent flag + /// bits (32/64/128). public enum DimensionType: Sendable, Equatable { /// Group 70 value `0`: a rotated, horizontal, or vertical linear dimension. case linear @@ -95,13 +113,15 @@ public enum DXF { public var kind: DimensionType /// Actual measurement (group 42), in drawing units. `nil` when the file omits it. public var measurement: Double? - /// User-entered text override (group 1). `nil` when the field is absent or empty (the DXF - /// convention for "use the default formatted measurement"). A literal single space is the - /// convention for "suppress the dimension text" and is passed through as `" "` verbatim. + /// User-entered text override (group 1). + /// + /// `nil` when the field is absent or empty (the DXF convention for "use the default + /// formatted measurement"). A literal single space is the convention for "suppress the + /// dimension text" and is passed through as `" "` verbatim. public var textOverride: String? /// Midpoint of the dimension text (group 11/21/31). public var textPosition: Point - /// Group 10/20/30 — always present; meaning depends on ``kind`` (see above). + /// Group 10/20/30, always present; meaning depends on ``kind`` (see above). public var defPoint: Point /// Group 13/23/33. `nil` when absent (e.g. radius/diameter dimensions). public var defPoint2: Point? @@ -109,20 +129,28 @@ public enum DXF { public var defPoint3: Point? /// Group 15/25/35. `nil` when absent (e.g. plain linear/aligned dimensions). public var defPoint4: Point? - /// Group 16/26/36 — dimension-arc location (angular dimensions only). + /// Group 16/26/36, dimension-arc location (angular dimensions only). public var defPoint5: Point? public var layer: String public var color: Int - public init(kind: DimensionType, measurement: Double?, textOverride: String?, - textPosition: Point, defPoint: Point, defPoint2: Point? = nil, - defPoint3: Point? = nil, defPoint4: Point? = nil, defPoint5: Point? = nil, - layer: String, color: Int) { - self.kind = kind; self.measurement = measurement; self.textOverride = textOverride - self.textPosition = textPosition; self.defPoint = defPoint - self.defPoint2 = defPoint2; self.defPoint3 = defPoint3 - self.defPoint4 = defPoint4; self.defPoint5 = defPoint5 - self.layer = layer; self.color = color + public init( + kind: DimensionType, measurement: Double?, textOverride: String?, + textPosition: Point, defPoint: Point, defPoint2: Point? = nil, + defPoint3: Point? = nil, defPoint4: Point? = nil, defPoint5: Point? = nil, + layer: String, color: Int + ) { + self.kind = kind + self.measurement = measurement + self.textOverride = textOverride + self.textPosition = textPosition + self.defPoint = defPoint + self.defPoint2 = defPoint2 + self.defPoint3 = defPoint3 + self.defPoint4 = defPoint4 + self.defPoint5 = defPoint5 + self.layer = layer + self.color = color } } @@ -130,7 +158,10 @@ public enum DXF { public struct PolyVertex: Equatable, Sendable { public var point: Point public var bulge: Double - public init(_ point: Point, bulge: Double = 0) { self.point = point; self.bulge = bulge } + public init(_ point: Point, bulge: Double = 0) { + self.point = point + self.bulge = bulge + } } public struct Drawing: Sendable { @@ -142,15 +173,21 @@ public enum DXF { /// `$INSUNITS` header code (0 = unitless, 1 = inches, 2 = feet, 4 = mm, 5 = cm, 6 = m, …), /// or `nil` when the file declares none. public var insUnits: Int? - /// `$EXTMIN` / `$EXTMAX` — the drawing extents declared in the header (may differ from the + /// `$EXTMIN` / `$EXTMAX`: the drawing extents declared in the header (may differ from the /// computed ``bounds`` of the entities actually read). `nil` when the header omits them. public var extMin: Point? public var extMax: Point? - public init(version: String = "", entities: [Entity] = [], counts: Counts = .init(), - insUnits: Int? = nil, extMin: Point? = nil, extMax: Point? = nil) { - self.version = version; self.entities = entities; self.counts = counts - self.insUnits = insUnits; self.extMin = extMin; self.extMax = extMax + public init( + version: String = "", entities: [Entity] = [], counts: Counts = .init(), + insUnits: Int? = nil, extMin: Point? = nil, extMax: Point? = nil + ) { + self.version = version + self.entities = entities + self.counts = counts + self.insUnits = insUnits + self.extMin = extMin + self.extMax = extMax } public struct Counts: Sendable, Equatable { @@ -158,33 +195,54 @@ public enum DXF { public var dimension = 0 public init() {} /// Total modelled entities. - public var total: Int { line + circle + arc + ellipse + point + text + polyline + dimension } + public var total: Int { + line + circle + arc + ellipse + point + text + polyline + dimension + } } - /// Axis-aligned bounds over all entities, or `nil` if empty. Curved entities contribute a - /// conservative box (centre ± radius / major-axis length), not a tight arc extent. + /// Axis-aligned bounds over all entities, or `nil` if empty. + /// + /// Curved entities contribute a conservative box (centre ± radius / major-axis length), + /// not a tight arc extent. public var bounds: (min: Point, max: Point)? { - var lo = Point(.greatestFiniteMagnitude, .greatestFiniteMagnitude, .greatestFiniteMagnitude) - var hi = Point(-.greatestFiniteMagnitude, -.greatestFiniteMagnitude, -.greatestFiniteMagnitude) + var lo = Point( + .greatestFiniteMagnitude, .greatestFiniteMagnitude, .greatestFiniteMagnitude) + var hi = Point( + -.greatestFiniteMagnitude, -.greatestFiniteMagnitude, -.greatestFiniteMagnitude) var any = false func acc(_ p: Point) { any = true - lo.x = min(lo.x, p.x); lo.y = min(lo.y, p.y); lo.z = min(lo.z, p.z) - hi.x = max(hi.x, p.x); hi.y = max(hi.y, p.y); hi.z = max(hi.z, p.z) + lo.x = min(lo.x, p.x) + lo.y = min(lo.y, p.y) + lo.z = min(lo.z, p.z) + hi.x = max(hi.x, p.x) + hi.y = max(hi.y, p.y) + hi.z = max(hi.z, p.z) + } + func box(_ c: Point, _ r: Double) { + acc(Point(c.x - r, c.y - r, c.z)) + acc(Point(c.x + r, c.y + r, c.z)) } - func box(_ c: Point, _ r: Double) { acc(Point(c.x - r, c.y - r, c.z)); acc(Point(c.x + r, c.y + r, c.z)) } for e in entities { switch e { - case let .line(a, b, _, _): acc(a); acc(b) - case let .circle(c, r, _, _): box(c, r) - case let .arc(c, r, _, _, _, _): box(c, r) - case let .ellipse(c, m, _, _, _, _, _): let r = (m.x * m.x + m.y * m.y).squareRoot(); box(c, r) - case let .point(p, _, _): acc(p) - case let .text(p, _, _, _, _, _): acc(p) - case let .polyline(verts, _, _, _): verts.forEach { acc($0.point) } - case let .dimension(d): - acc(d.textPosition); acc(d.defPoint) - [d.defPoint2, d.defPoint3, d.defPoint4, d.defPoint5].forEach { $0.map(acc) } + case .line(let a, let b, _, _): + acc(a) + acc(b) + case .circle(let c, let r, _, _): box(c, r) + case .arc(let c, let r, _, _, _, _): box(c, r) + case .ellipse(let c, let m, _, _, _, _, _): + let r = (m.x * m.x + m.y * m.y).squareRoot() + box(c, r) + case .point(let p, _, _): acc(p) + case .text(let p, _, _, _, _, _): acc(p) + case .polyline(let verts, _, _, _): + for v in verts { acc(v.point) } + case .dimension(let d): + acc(d.textPosition) + acc(d.defPoint) + for p in [d.defPoint2, d.defPoint3, d.defPoint4, d.defPoint5] { + if let p { acc(p) } + } } } return any ? (lo, hi) : nil @@ -195,7 +253,7 @@ public enum DXF { case empty /// Bytes are not recognisable as a DXF document. case notDXF - /// Binary DXF (the `AutoCAD Binary DXF` sentinel) is not yet supported — convert to ASCII DXF. + /// Binary DXF (the `AutoCAD Binary DXF` sentinel) is not yet supported; convert to ASCII DXF. case binaryUnsupported } @@ -208,13 +266,15 @@ public enum DXF { public static func read(data: Data) throws -> Drawing { guard !data.isEmpty else { throw Error.empty } if isBinary(data) { throw Error.binaryUnsupported } - var r = Reader(decode(data)); return try r.parse() + var r = Reader(decode(data)) + return try r.parse() } /// Parse an already-decoded DXF string. public static func read(text: String) throws -> Drawing { guard !text.isEmpty else { throw Error.empty } - var r = Reader(text); return try r.parse() + var r = Reader(text) + return try r.parse() } // MARK: Sniffing / decoding @@ -235,9 +295,11 @@ public enum DXF { || head.contains("\n0\nSECTION") || head.hasPrefix("0") } - /// Decode DXF bytes to text. ASCII and UTF-8 pass through; otherwise the bytes are treated as - /// **CP932 / Shift-JIS** (the common code page for DXF exported by Japanese CAD tools). Because - /// CP932 is an ASCII superset, the group-code/value structure is unaffected either way. + /// Decode DXF bytes to text. + /// + /// ASCII and UTF-8 pass through; otherwise the bytes are treated as **CP932 / Shift-JIS** (the + /// common code page for DXF exported by Japanese CAD tools). Because CP932 is an ASCII + /// superset, the group-code/value structure is unaffected either way. static func decode(_ data: Data) -> String { if let s = String(data: data, encoding: .utf8) { return s } return decodeCP932([UInt8](data)) @@ -248,8 +310,10 @@ public enum DXF { public static func decodeCP932(_ bytes: [UInt8]) -> String { let data = Data(bytes) #if canImport(CoreFoundation) - let cp932 = String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.dosJapanese.rawValue))) - if let s = String(data: data, encoding: cp932) { return s } + let cp932 = String.Encoding( + rawValue: CFStringConvertEncodingToNSStringEncoding( + CFStringEncoding(CFStringEncodings.dosJapanese.rawValue))) + if let s = String(data: data, encoding: cp932) { return s } #endif return String(data: data, encoding: .shiftJIS) ?? String(decoding: bytes, as: UTF8.self) } diff --git a/Sources/SwiftDXF/Reader.swift b/Sources/SwiftDXF/Reader.swift index 97a9f96..4e8a778 100644 --- a/Sources/SwiftDXF/Reader.swift +++ b/Sources/SwiftDXF/Reader.swift @@ -17,7 +17,10 @@ extension DXF { var i = 0 while i + 1 < lines.count { let codeField = lines[i].trimmingCharacters(in: .whitespaces) - guard let code = Int(codeField) else { i += 1; continue } // resync on a stray line + guard let code = Int(codeField) else { + i += 1 + continue + } // resync on a stray line out.append((code, String(lines[i + 1]))) i += 2 } @@ -29,7 +32,9 @@ extension DXF { private func first(_ code: Int, _ f: [(code: Int, value: String)]) -> String? { f.first { $0.code == code }?.value } - private func dbl(_ code: Int, _ f: [(code: Int, value: String)], _ fallback: Double = 0) -> Double { + private func dbl(_ code: Int, _ f: [(code: Int, value: String)], _ fallback: Double = 0) + -> Double + { guard let s = first(code, f) else { return fallback } return Double(s.trimmingCharacters(in: .whitespaces)) ?? fallback } @@ -41,14 +46,16 @@ extension DXF { guard let s = first(code, f) else { return nil } return Double(s.trimmingCharacters(in: .whitespaces)) } - /// A point whose base group code (and `+10`/`+20` for y/z) may be entirely absent — e.g. + /// A point whose base group code (and `+10`/`+20` for y/z) may be entirely absent, e.g. /// DIMENSION's per-type definition points (13/14/15/16). `nil`, not a zero point, when unset. private func pointOpt(_ base: Int, _ f: [(code: Int, value: String)]) -> Point? { guard first(base, f) != nil else { return nil } return Point(dbl(base, f), dbl(base + 10, f), dbl(base + 20, f)) } private func allDbl(_ code: Int, _ f: [(code: Int, value: String)]) -> [Double] { - f.compactMap { $0.code == code ? Double($0.value.trimmingCharacters(in: .whitespaces)) : nil } + f.compactMap { + $0.code == code ? Double($0.value.trimmingCharacters(in: .whitespaces)) : nil + } } private func layer(_ f: [(code: Int, value: String)]) -> String { first(8, f).map { $0.trimmingCharacters(in: .whitespaces) } ?? "0" @@ -61,12 +68,21 @@ extension DXF { /// LWPOLYLINE vertices in field order: each `10` opens a vertex, `20` is its y, `42` its bulge. private func lwVertices(_ f: [(code: Int, value: String)]) -> [PolyVertex] { var verts: [PolyVertex] = [] - var x: Double?, y: Double = 0, bulge: Double = 0 - func flush() { if let x { verts.append(PolyVertex(Point(x, y), bulge: bulge)) }; x = nil; y = 0; bulge = 0 } + var x: Double? + var y: Double = 0 + var bulge: Double = 0 + func flush() { + if let x { verts.append(PolyVertex(Point(x, y), bulge: bulge)) } + x = nil + y = 0 + bulge = 0 + } for (code, value) in f { let v = Double(value.trimmingCharacters(in: .whitespaces)) switch code { - case 10: if x != nil { flush() }; x = v + case 10: + if x != nil { flush() } + x = v case 20: y = v ?? 0 case 42: bulge = v ?? 0 default: break @@ -80,58 +96,75 @@ extension DXF { mutating func parse() throws -> Drawing { guard !pairs.isEmpty else { throw Error.notDXF } - guard pairs.contains(where: { $0.code == 0 && trimmed($0.value) == "SECTION" }) - || pairs.contains(where: { $0.code == 0 && trimmed($0.value) == "EOF" }) + guard + pairs.contains(where: { $0.code == 0 && trimmed($0.value) == "SECTION" }) + || pairs.contains(where: { $0.code == 0 && trimmed($0.value) == "EOF" }) else { throw Error.notDXF } // $ACADVER from the HEADER section, if present. - for k in pairs.indices.dropLast() where pairs[k].code == 9 && trimmed(pairs[k].value) == "$ACADVER" { - version = trimmed(pairs[k + 1].value); break + for k in pairs.indices.dropLast() + where pairs[k].code == 9 && trimmed(pairs[k].value) == "$ACADVER" { + version = trimmed(pairs[k + 1].value) + break } var dwg = Drawing(version: version) dwg.insUnits = ints(70, headerVar("$INSUNITS")) - let mn = headerVar("$EXTMIN"); if mn.contains(where: { $0.code == 10 }) { dwg.extMin = point(mn) } - let mx = headerVar("$EXTMAX"); if mx.contains(where: { $0.code == 10 }) { dwg.extMax = point(mx) } - guard let start = entitiesStart() else { return dwg } // valid DXF, just no model space + let mn = headerVar("$EXTMIN") + if mn.contains(where: { $0.code == 10 }) { dwg.extMin = point(mn) } + let mx = headerVar("$EXTMAX") + if mx.contains(where: { $0.code == 10 }) { dwg.extMax = point(mx) } + guard let start = entitiesStart() else { return dwg } // valid DXF, just no model space var i = start while i < pairs.count { let p = pairs[i] - guard p.code == 0 else { i += 1; continue } + guard p.code == 0 else { + i += 1 + continue + } let type = trimmed(p.value) if type == "ENDSEC" || type == "EOF" { break } // Collect this entity's fields up to the next 0-tag. var j = i + 1 var fields: [(code: Int, value: String)] = [] - while j < pairs.count && pairs[j].code != 0 { fields.append(pairs[j]); j += 1 } + while j < pairs.count && pairs[j].code != 0 { + fields.append(pairs[j]) + j += 1 + } switch type { case "LINE": let a = point(fields) let b = Point(dbl(11, fields), dbl(21, fields), dbl(31, fields)) - dwg.entities.append(.line(a: a, b: b, layer: layer(fields), color: color(fields))) + dwg.entities.append( + .line(a: a, b: b, layer: layer(fields), color: color(fields))) dwg.counts.line += 1 case "CIRCLE": - dwg.entities.append(.circle(center: point(fields), radius: dbl(40, fields), - layer: layer(fields), color: color(fields))) + dwg.entities.append( + .circle( + center: point(fields), radius: dbl(40, fields), + layer: layer(fields), color: color(fields))) dwg.counts.circle += 1 case "ARC": - dwg.entities.append(.arc(center: point(fields), radius: dbl(40, fields), - startDeg: dbl(50, fields), endDeg: dbl(51, fields), - layer: layer(fields), color: color(fields))) + dwg.entities.append( + .arc( + center: point(fields), radius: dbl(40, fields), + startDeg: dbl(50, fields), endDeg: dbl(51, fields), + layer: layer(fields), color: color(fields))) dwg.counts.arc += 1 case "ELLIPSE": var major = Point(dbl(11, fields), dbl(21, fields), dbl(31, fields)) var ratio = dbl(40, fields, 1) - var startP = dbl(41, fields, 0), endP = dbl(42, fields, 2 * .pi) + var startP = dbl(41, fields, 0) + var endP = dbl(42, fields, 2 * .pi) // The DXF spec requires ratio = minor/major ≤ 1. Some writers emit ratio > 1 - // (a swapped major axis). Normalise to the canonical form — rotate the major axis - // +90° and scale by ratio, invert the ratio, shift the params by −π/2 — so the curve + // (a swapped major axis). Normalise to the canonical form: rotate the major axis + // +90° and scale by ratio, invert the ratio, shift the params by −π/2, so the curve // is unchanged but downstream consumers (e.g. OCCT's Geom_Ellipse, which demands // majorRadius ≥ minorRadius) get a valid axis. Matches ezdxf's normalisation. if ratio > 1 { @@ -141,49 +174,65 @@ extension DXF { let m = (a - .pi / 2).truncatingRemainder(dividingBy: 2 * .pi) return m < 0 ? m + 2 * .pi : m } - startP = shift(startP); endP = shift(endP) + startP = shift(startP) + endP = shift(endP) } - dwg.entities.append(.ellipse(center: point(fields), majorAxis: major, - ratio: ratio, startParam: startP, endParam: endP, - layer: layer(fields), color: color(fields))) + dwg.entities.append( + .ellipse( + center: point(fields), majorAxis: major, + ratio: ratio, startParam: startP, endParam: endP, + layer: layer(fields), color: color(fields))) dwg.counts.ellipse += 1 case "POINT": - dwg.entities.append(.point(at: point(fields), layer: layer(fields), color: color(fields))) + dwg.entities.append( + .point(at: point(fields), layer: layer(fields), color: color(fields))) dwg.counts.point += 1 case "TEXT", "MTEXT": - dwg.entities.append(.text(at: point(fields), height: dbl(40, fields, 0), - rotationDeg: dbl(50, fields, 0), string: textValue(fields), - layer: layer(fields), color: color(fields))) + dwg.entities.append( + .text( + at: point(fields), height: dbl(40, fields, 0), + rotationDeg: dbl(50, fields, 0), string: textValue(fields), + layer: layer(fields), color: color(fields))) dwg.counts.text += 1 case "LWPOLYLINE": - // Vertices interleave in order: 10 x, 20 y, optional 42 bulge — a new 10 starts the + // Vertices interleave in order: 10 x, 20 y, optional 42 bulge; a new 10 starts the // next vertex. Walk in field order so each bulge binds to its own vertex. let closed = (ints(70, fields) ?? 0) & 1 == 1 - dwg.entities.append(.polyline(vertices: lwVertices(fields), closed: closed, - layer: layer(fields), color: color(fields))) + dwg.entities.append( + .polyline( + vertices: lwVertices(fields), closed: closed, + layer: layer(fields), color: color(fields))) dwg.counts.polyline += 1 case "POLYLINE": // Old-style: a POLYLINE header followed by VERTEX entities and a terminating SEQEND. let closed = (ints(70, fields) ?? 0) & 1 == 1 - let lay = layer(fields), col = color(fields) + let lay = layer(fields) + let col = color(fields) var verts: [PolyVertex] = [] - while j < pairs.count && pairs[j].code == 0 && trimmed(pairs[j].value) == "VERTEX" { + while j < pairs.count && pairs[j].code == 0 + && trimmed(pairs[j].value) == "VERTEX" + { var m = j + 1 var vf: [(code: Int, value: String)] = [] - while m < pairs.count && pairs[m].code != 0 { vf.append(pairs[m]); m += 1 } + while m < pairs.count && pairs[m].code != 0 { + vf.append(pairs[m]) + m += 1 + } verts.append(PolyVertex(point(vf), bulge: dbl(42, vf, 0))) j = m } - if j < pairs.count && pairs[j].code == 0 && trimmed(pairs[j].value) == "SEQEND" { + if j < pairs.count && pairs[j].code == 0 && trimmed(pairs[j].value) == "SEQEND" + { var m = j + 1 while m < pairs.count && pairs[m].code != 0 { m += 1 } j = m } - dwg.entities.append(.polyline(vertices: verts, closed: closed, layer: lay, color: col)) + dwg.entities.append( + .polyline(vertices: verts, closed: closed, layer: lay, color: col)) dwg.counts.polyline += 1 case "DIMENSION": @@ -202,7 +251,7 @@ extension DXF { dwg.counts.dimension += 1 default: - break // unmodelled entity (INSERT, SPLINE, HATCH, …) — skip + break // unmodelled entity (INSERT, SPLINE, HATCH, …); skip } i = j } @@ -215,7 +264,10 @@ extension DXF { else { return [] } var out: [(code: Int, value: String)] = [] var k = start + 1 - while k < pairs.count, pairs[k].code != 9, pairs[k].code != 0 { out.append(pairs[k]); k += 1 } + while k < pairs.count, pairs[k].code != 9, pairs[k].code != 0 { + out.append(pairs[k]) + k += 1 + } return out } @@ -224,7 +276,8 @@ extension DXF { var k = 0 while k + 1 < pairs.count { if pairs[k].code == 0, trimmed(pairs[k].value) == "SECTION", - pairs[k + 1].code == 2, trimmed(pairs[k + 1].value) == "ENTITIES" { + pairs[k + 1].code == 2, trimmed(pairs[k + 1].value) == "ENTITIES" + { return k + 2 } k += 1 @@ -248,18 +301,34 @@ extension DXF { var out = "" var it = s.makeIterator() var pending: Character? = nil - func next() -> Character? { if let p = pending { pending = nil; return p }; return it.next() } + func next() -> Character? { + if let p = pending { + pending = nil + return p + } + return it.next() + } while let c = next() { - guard c == "\\" else { out.append(c); continue } - guard let n = next() else { out.append(c); break } + guard c == "\\" else { + out.append(c) + continue + } + guard let n = next() else { + out.append(c) + break + } if n == "U", let plus = next(), plus == "+" { var hex = "" - for _ in 0..<4 { if let h = next(), h.isHexDigit { hex.append(h) } else { break } } - if let v = UInt32(hex, radix: 16), let u = Unicode.Scalar(v) { out.unicodeScalars.append(u) } + for _ in 0..<4 { + if let h = next(), h.isHexDigit { hex.append(h) } else { break } + } + if let v = UInt32(hex, radix: 16), let u = Unicode.Scalar(v) { + out.unicodeScalars.append(u) + } } else if n == "P" || n == "p" { out.append("\n") } else { - out.append(n) // drop the backslash, keep the escaped char + out.append(n) // drop the backslash, keep the escaped char } } return out diff --git a/Sources/dxfdump/main.swift b/Sources/dxfdump/main.swift index 50b9583..d63611a 100644 --- a/Sources/dxfdump/main.swift +++ b/Sources/dxfdump/main.swift @@ -1,7 +1,7 @@ import Foundation import SwiftDXF -// dxfdump — read each DXF file and print a compact JSON summary (one object per file), for diffing +// dxfdump: read each DXF file and print a compact JSON summary (one object per file), for diffing // against a reference tool (e.g. ezdxf). Shape is kept identical to tools/oracle.py. // // dxfdump file1.dxf [file2.dxf ...] @@ -28,26 +28,31 @@ func summary(for path: String) -> [String: Any] { // Geometry digest: sum + count of every defining scalar (rounded), so a coordinate-level diff // against the oracle is one number, not a per-entity walk. Scalar lists mirror tools/oracle.py. // Full-precision sum in document order; the oracle sums the identical scalars in the same - // order, so a faithful parse matches to within float rounding. (No decimal rounding here — + // order, so a faithful parse matches to within float rounding. (No decimal rounding here; // that would inject a tie-break mismatch vs Python's banker's rounding.) - var sum = 0.0, n = 0 - var byType: [String: [Double]] = [:] // type -> [sum, count] + var sum = 0.0 + var n = 0 + var byType: [String: [Double]] = [:] // type -> [sum, count] func add(_ t: String, _ vs: Double...) { for v in vs { - sum += v; n += 1 - byType[t, default: [0, 0]][0] += v; byType[t, default: [0, 0]][1] += 1 + sum += v + n += 1 + byType[t, default: [0, 0]][0] += v + byType[t, default: [0, 0]][1] += 1 } } for e in dwg.entities { switch e { - case let .line(a, b, _, _): add("LINE", a.x, a.y, b.x, b.y) - case let .circle(c, r, _, _): add("CIRCLE", c.x, c.y, r) - case let .arc(c, r, s, en, _, _): add("ARC", c.x, c.y, r, s, en) - case let .ellipse(c, m, ratio, s, en, _, _): add("ELLIPSE", c.x, c.y, m.x, m.y, ratio, s, en) - case let .point(p, _, _): add("POINT", p.x, p.y) - case let .text(p, h, _, _, _, _): add("TEXT", p.x, p.y, h) - case let .polyline(verts, _, _, _): for v in verts { add("POLYLINE", v.point.x, v.point.y) } - case let .dimension(d): + case .line(let a, let b, _, _): add("LINE", a.x, a.y, b.x, b.y) + case .circle(let c, let r, _, _): add("CIRCLE", c.x, c.y, r) + case .arc(let c, let r, let s, let en, _, _): add("ARC", c.x, c.y, r, s, en) + case .ellipse(let c, let m, let ratio, let s, let en, _, _): + add("ELLIPSE", c.x, c.y, m.x, m.y, ratio, s, en) + case .point(let p, _, _): add("POINT", p.x, p.y) + case .text(let p, let h, _, _, _, _): add("TEXT", p.x, p.y, h) + case .polyline(let verts, _, _, _): + for v in verts { add("POLYLINE", v.point.x, v.point.y) } + case .dimension(let d): add("DIMENSION", d.textPosition.x, d.textPosition.y, d.defPoint.x, d.defPoint.y) if let m = d.measurement { add("DIMENSION", m) } for p in [d.defPoint2, d.defPoint3, d.defPoint4, d.defPoint5] { @@ -70,6 +75,7 @@ guard !paths.isEmpty else { } for path in paths { - let data = try JSONSerialization.data(withJSONObject: summary(for: path), options: [.sortedKeys]) + let data = try JSONSerialization.data( + withJSONObject: summary(for: path), options: [.sortedKeys]) print(String(decoding: data, as: UTF8.self)) } diff --git a/Tests/SwiftDXFTests/CorpusOracleTests.swift b/Tests/SwiftDXFTests/CorpusOracleTests.swift index 97b95d0..fc74d62 100644 --- a/Tests/SwiftDXFTests/CorpusOracleTests.swift +++ b/Tests/SwiftDXFTests/CorpusOracleTests.swift @@ -1,43 +1,85 @@ -import Testing import Foundation +import Testing + @testable import SwiftDXF /// Regression tests over real DXF drawings, with **ground-truth entity counts produced by the -/// MIT-licensed `ezdxf` reader** (see `tools/oracle.py`). SwiftDXF was verified to match ezdxf -/// bit-for-bit on entity counts *and* every coordinate scalar across this corpus; these tests pin -/// the counts so a regression can't slip in silently. +/// MIT-licensed `ezdxf` reader** (see `tools/oracle.py`). +/// +/// SwiftDXF was verified to match ezdxf bit-for-bit on entity counts *and* every coordinate scalar +/// across this corpus; these tests pin the counts so a regression can't slip in silently. /// /// The corpus lives outside the repo (large, third-party drawings). Tests are skipped when it is -/// absent — e.g. in CI — via `withKnownIssue`, mirroring SwiftJWW. Point `DXF_CORPUS` at a directory +/// absent (e.g. in CI) via `withKnownIssue`, mirroring SwiftJWW. Point `DXF_CORPUS` at a directory /// of `.dxf` files (defaults to `~/Documents/Modelling/2DFiles`) to run them. @Suite("DXF corpus vs ezdxf oracle") struct CorpusOracleTests { static var corpusDir: URL { - if let env = ProcessInfo.processInfo.environment["DXF_CORPUS"] { return URL(fileURLWithPath: env) } - return URL(fileURLWithPath: NSString(string: "~/Documents/Modelling/2DFiles").expandingTildeInPath) + if let env = ProcessInfo.processInfo.environment["DXF_CORPUS"] { + return URL(fileURLWithPath: env) + } + return URL( + fileURLWithPath: NSString(string: "~/Documents/Modelling/2DFiles").expandingTildeInPath) } /// Expected per-file model-space counts, captured from `ezdxf` (`tools/oracle.py`). static let expected: [(file: String, counts: DXF.Drawing.Counts)] = [ - ("2120_from_2120j.dxf", .init(line: 4351, circle: 438, arc: 1471, ellipse: 12, point: 42, text: 34, polyline: 0)), - ("dd12_from_dd12j.dxf", .init(line: 2428, circle: 131, arc: 668, ellipse: 0, point: 25, text: 26, polyline: 0)), - ("dd12.dxf", .init(line: 2428, circle: 131, arc: 668, ellipse: 0, point: 25, text: 26, polyline: 0)), - ("eitakyouta_3.dxf", .init(line: 30094, circle: 11, arc: 182, ellipse: 12, point: 27, text: 20, polyline: 0)), - ("ka2000_from_ka2000j.dxf", .init(line: 2307, circle: 482, arc: 524, ellipse: 24, point: 26, text: 36, polyline: 0)), - ("ka2000.dxf", .init(line: 2899, circle: 482, arc: 524, ellipse: 0, point: 26, text: 36, polyline: 0)), - ("rail1.dxf", .init(line: 274, circle: 0, arc: 112, ellipse: 0, point: 96, text: 55, polyline: 0)), - ("rail2.dxf", .init(line: 204, circle: 0, arc: 90, ellipse: 0, point: 84, text: 55, polyline: 0)), - ("tmf1_from_tmf1j.dxf", .init(line: 2378, circle: 377, arc: 504, ellipse: 23, point: 39, text: 47, polyline: 0)), - ("to1_from_to1j.dxf", .init(line: 1952, circle: 332, arc: 418, ellipse: 14, point: 35, text: 43, polyline: 0)), - ("wm3500k.dxf", .init(line: 3124, circle: 515, arc: 493, ellipse: 0, point: 28, text: 38, polyline: 0)), + ( + "2120_from_2120j.dxf", + .init(line: 4351, circle: 438, arc: 1471, ellipse: 12, point: 42, text: 34, polyline: 0) + ), + ( + "dd12_from_dd12j.dxf", + .init(line: 2428, circle: 131, arc: 668, ellipse: 0, point: 25, text: 26, polyline: 0) + ), + ( + "dd12.dxf", + .init(line: 2428, circle: 131, arc: 668, ellipse: 0, point: 25, text: 26, polyline: 0) + ), + ( + "eitakyouta_3.dxf", + .init(line: 30094, circle: 11, arc: 182, ellipse: 12, point: 27, text: 20, polyline: 0) + ), + ( + "ka2000_from_ka2000j.dxf", + .init(line: 2307, circle: 482, arc: 524, ellipse: 24, point: 26, text: 36, polyline: 0) + ), + ( + "ka2000.dxf", + .init(line: 2899, circle: 482, arc: 524, ellipse: 0, point: 26, text: 36, polyline: 0) + ), + ( + "rail1.dxf", + .init(line: 274, circle: 0, arc: 112, ellipse: 0, point: 96, text: 55, polyline: 0) + ), + ( + "rail2.dxf", + .init(line: 204, circle: 0, arc: 90, ellipse: 0, point: 84, text: 55, polyline: 0) + ), + ( + "tmf1_from_tmf1j.dxf", + .init(line: 2378, circle: 377, arc: 504, ellipse: 23, point: 39, text: 47, polyline: 0) + ), + ( + "to1_from_to1j.dxf", + .init(line: 1952, circle: 332, arc: 418, ellipse: 14, point: 35, text: 43, polyline: 0) + ), + ( + "wm3500k.dxf", + .init(line: 3124, circle: 515, arc: 493, ellipse: 0, point: 28, text: 38, polyline: 0) + ), ] @Test("entity counts match the ezdxf oracle for every corpus file", arguments: expected) func matchesOracle(_ entry: (file: String, counts: DXF.Drawing.Counts)) throws { let url = Self.corpusDir.appendingPathComponent(entry.file) - try withKnownIssue("\(entry.file) not present (set DXF_CORPUS to run)", isIntermittent: true) { - guard FileManager.default.fileExists(atPath: url.path) else { throw CancellationError() } + try withKnownIssue( + "\(entry.file) not present (set DXF_CORPUS to run)", isIntermittent: true + ) { + guard FileManager.default.fileExists(atPath: url.path) else { + throw CancellationError() + } let dwg = try DXF.read(contentsOf: url) #expect(dwg.counts == entry.counts, "\(entry.file): \(dwg.counts) != \(entry.counts)") // Every modelled entity carried geometry into the bounds. @@ -46,10 +88,17 @@ struct CorpusOracleTests { } } -private extension DXF.Drawing.Counts { - init(line: Int, circle: Int, arc: Int, ellipse: Int, point: Int, text: Int, polyline: Int) { +extension DXF.Drawing.Counts { + fileprivate init( + line: Int, circle: Int, arc: Int, ellipse: Int, point: Int, text: Int, polyline: Int + ) { self.init() - self.line = line; self.circle = circle; self.arc = arc; self.ellipse = ellipse - self.point = point; self.text = text; self.polyline = polyline + self.line = line + self.circle = circle + self.arc = arc + self.ellipse = ellipse + self.point = point + self.text = text + self.polyline = polyline } } diff --git a/Tests/SwiftDXFTests/SwiftDXFTests.swift b/Tests/SwiftDXFTests/SwiftDXFTests.swift index 4ec4b38..05efe5c 100644 --- a/Tests/SwiftDXFTests/SwiftDXFTests.swift +++ b/Tests/SwiftDXFTests/SwiftDXFTests.swift @@ -1,5 +1,6 @@ -import Testing import Foundation +import Testing + @testable import SwiftDXF @Suite("DXF reading") @@ -33,338 +34,384 @@ struct SwiftDXFTests { @Test("reads each core entity type with coordinates and counts") func coreEntities() throws { let body = """ - 0 - LINE - 8 - L1 - 62 - 2 - 10 - 0.0 - 20 - 0.0 - 11 - 10.0 - 21 - 5.0 - 0 - CIRCLE - 8 - 0 - 10 - 3.0 - 20 - 4.0 - 40 - 2.0 - 0 - ARC - 10 - 0.0 - 20 - 0.0 - 40 - 5.0 - 50 - 0.0 - 51 - 90.0 - 0 - ELLIPSE - 10 - 1.0 - 20 - 1.0 - 11 - 4.0 - 21 - 0.0 - 40 - 0.5 - 41 - 0.0 - 42 - 6.2831853 - 0 - POINT - 10 - 2.0 - 20 - 3.0 - 0 - TEXT - 40 - 2.5 - 10 - 0.0 - 20 - 0.0 - 1 - AB - """ + 0 + LINE + 8 + L1 + 62 + 2 + 10 + 0.0 + 20 + 0.0 + 11 + 10.0 + 21 + 5.0 + 0 + CIRCLE + 8 + 0 + 10 + 3.0 + 20 + 4.0 + 40 + 2.0 + 0 + ARC + 10 + 0.0 + 20 + 0.0 + 40 + 5.0 + 50 + 0.0 + 51 + 90.0 + 0 + ELLIPSE + 10 + 1.0 + 20 + 1.0 + 11 + 4.0 + 21 + 0.0 + 40 + 0.5 + 41 + 0.0 + 42 + 6.2831853 + 0 + POINT + 10 + 2.0 + 20 + 3.0 + 0 + TEXT + 40 + 2.5 + 10 + 0.0 + 20 + 0.0 + 1 + AB + """ let dwg = try DXF.read(text: Self.doc(body)) #expect(dwg.version == "AC1009") #expect(dwg.counts.line == 1 && dwg.counts.circle == 1 && dwg.counts.arc == 1) #expect(dwg.counts.ellipse == 1 && dwg.counts.point == 1 && dwg.counts.text == 1) #expect(dwg.counts.total == 6) - guard case let .line(a, b, layer, color) = dwg.entities[0] else { Issue.record("not a line"); return } + guard case .line(let a, let b, let layer, let color) = dwg.entities[0] else { + Issue.record("not a line") + return + } #expect(a == DXF.Point(0, 0) && b == DXF.Point(10, 5) && layer == "L1" && color == 2) - guard case let .circle(c, r, _, col) = dwg.entities[1] else { Issue.record("not a circle"); return } - #expect(c == DXF.Point(3, 4) && r == 2 && col == 256) // no group 62 → BYLAYER + guard case .circle(let c, let r, _, let col) = dwg.entities[1] else { + Issue.record("not a circle") + return + } + #expect(c == DXF.Point(3, 4) && r == 2 && col == 256) // no group 62 → BYLAYER - guard case let .arc(ac, ar, s, e, _, _) = dwg.entities[2] else { Issue.record("not an arc"); return } + guard case .arc(let ac, let ar, let s, let e, _, _) = dwg.entities[2] else { + Issue.record("not an arc") + return + } #expect(ac == DXF.Point(0, 0) && ar == 5 && s == 0 && e == 90) - guard case let .ellipse(ec, major, ratio, _, end, _, _) = dwg.entities[3] else { Issue.record("not an ellipse"); return } - #expect(ec == DXF.Point(1, 1) && major == DXF.Point(4, 0) && ratio == 0.5 && abs(end - 2 * .pi) < 1e-4) + guard case .ellipse(let ec, let major, let ratio, _, let end, _, _) = dwg.entities[3] else { + Issue.record("not an ellipse") + return + } + #expect( + ec == DXF.Point(1, 1) && major == DXF.Point(4, 0) && ratio == 0.5 + && abs(end - 2 * .pi) < 1e-4) - guard case let .point(p, _, _) = dwg.entities[4] else { Issue.record("not a point"); return } + guard case .point(let p, _, _) = dwg.entities[4] else { + Issue.record("not a point") + return + } #expect(p == DXF.Point(2, 3)) - guard case let .text(tp, h, _, str, _, _) = dwg.entities[5] else { Issue.record("not text"); return } + guard case .text(let tp, let h, _, let str, _, _) = dwg.entities[5] else { + Issue.record("not text") + return + } #expect(tp == DXF.Point(0, 0) && h == 2.5 && str == "AB") } @Test("bounds span all geometry") func bounds() throws { - let dwg = try DXF.read(text: Self.doc(""" - 0 - LINE - 10 - -5.0 - 20 - 0.0 - 11 - 20.0 - 21 - 7.0 - """)) + let dwg = try DXF.read( + text: Self.doc( + """ + 0 + LINE + 10 + -5.0 + 20 + 0.0 + 11 + 20.0 + 21 + 7.0 + """)) let b = try #require(dwg.bounds) #expect(b.min.x == -5 && b.max.x == 20 && b.max.y == 7) } @Test("LWPOLYLINE flattens to vertices with closed flag") func lwpolyline() throws { - let dwg = try DXF.read(text: Self.doc(""" - 0 - LWPOLYLINE - 90 - 3 - 70 - 1 - 10 - 0.0 - 20 - 0.0 - 10 - 4.0 - 20 - 0.0 - 10 - 4.0 - 20 - 3.0 - """)) - guard case let .polyline(verts, closed, _, _) = dwg.entities.first else { Issue.record("not a polyline"); return } + let dwg = try DXF.read( + text: Self.doc( + """ + 0 + LWPOLYLINE + 90 + 3 + 70 + 1 + 10 + 0.0 + 20 + 0.0 + 10 + 4.0 + 20 + 0.0 + 10 + 4.0 + 20 + 3.0 + """)) + guard case .polyline(let verts, let closed, _, _) = dwg.entities.first else { + Issue.record("not a polyline") + return + } #expect(verts.count == 3 && closed) #expect(verts[1].point == DXF.Point(4, 0) && verts[2].point == DXF.Point(4, 3)) } @Test("LWPOLYLINE binds bulge to the right vertex") func lwpolylineBulge() throws { - let dwg = try DXF.read(text: Self.doc(""" - 0 - LWPOLYLINE - 90 - 3 - 70 - 0 - 10 - 0.0 - 20 - 0.0 - 42 - 0.5 - 10 - 4.0 - 20 - 0.0 - 10 - 4.0 - 20 - 3.0 - """)) - guard case let .polyline(verts, _, _, _) = dwg.entities.first else { Issue.record("not a polyline"); return } + let dwg = try DXF.read( + text: Self.doc( + """ + 0 + LWPOLYLINE + 90 + 3 + 70 + 0 + 10 + 0.0 + 20 + 0.0 + 42 + 0.5 + 10 + 4.0 + 20 + 0.0 + 10 + 4.0 + 20 + 3.0 + """)) + guard case .polyline(let verts, _, _, _) = dwg.entities.first else { + Issue.record("not a polyline") + return + } #expect(verts.count == 3) - #expect(verts[0].bulge == 0.5 && verts[1].bulge == 0 && verts[2].bulge == 0) // bulge stays on vertex 0 + // bulge stays on vertex 0 + #expect(verts[0].bulge == 0.5 && verts[1].bulge == 0 && verts[2].bulge == 0) } @Test("header $INSUNITS and $EXTMIN/$EXTMAX are read") func headerVars() throws { let text = """ - 0 - SECTION - 2 - HEADER - 9 - $INSUNITS - 70 - 4 - 9 - $EXTMIN - 10 - -5.0 - 20 - -7.0 - 30 - 0.0 - 9 - $EXTMAX - 10 - 100.0 - 20 - 50.0 - 30 - 0.0 - 0 - ENDSEC - 0 - SECTION - 2 - ENTITIES - 0 - ENDSEC - 0 - EOF - """ + 0 + SECTION + 2 + HEADER + 9 + $INSUNITS + 70 + 4 + 9 + $EXTMIN + 10 + -5.0 + 20 + -7.0 + 30 + 0.0 + 9 + $EXTMAX + 10 + 100.0 + 20 + 50.0 + 30 + 0.0 + 0 + ENDSEC + 0 + SECTION + 2 + ENTITIES + 0 + ENDSEC + 0 + EOF + """ let dwg = try DXF.read(text: text) - #expect(dwg.insUnits == 4) // millimetres + #expect(dwg.insUnits == 4) // millimetres #expect(dwg.extMin == DXF.Point(-5, -7) && dwg.extMax == DXF.Point(100, 50)) } @Test("old-style POLYLINE consumes VERTEX run and SEQEND") func polylineVertices() throws { - let dwg = try DXF.read(text: Self.doc(""" - 0 - POLYLINE - 66 - 1 - 70 - 0 - 0 - VERTEX - 10 - 0.0 - 20 - 0.0 - 0 - VERTEX - 10 - 5.0 - 20 - 5.0 - 0 - SEQEND - 0 - LINE - 10 - 0.0 - 20 - 0.0 - 11 - 1.0 - 21 - 1.0 - """)) + let dwg = try DXF.read( + text: Self.doc( + """ + 0 + POLYLINE + 66 + 1 + 70 + 0 + 0 + VERTEX + 10 + 0.0 + 20 + 0.0 + 0 + VERTEX + 10 + 5.0 + 20 + 5.0 + 0 + SEQEND + 0 + LINE + 10 + 0.0 + 20 + 0.0 + 11 + 1.0 + 21 + 1.0 + """)) // The VERTEX/SEQEND run must not be mistaken for extra entities. #expect(dwg.counts.polyline == 1 && dwg.counts.line == 1 && dwg.counts.total == 2) - guard case let .polyline(verts, _, _, _) = dwg.entities[0] else { Issue.record("not a polyline"); return } + guard case .polyline(let verts, _, _, _) = dwg.entities[0] else { + Issue.record("not a polyline") + return + } #expect(verts.map(\.point) == [DXF.Point(0, 0), DXF.Point(5, 5)]) } @Test("DIMENSION: linear and radius entities expose their defining points") func dimensionEntities() throws { - let dwg = try DXF.read(text: Self.doc(""" - 0 - DIMENSION - 8 - DIMS - 10 - 5.0 - 20 - 0.0 - 30 - 0.0 - 11 - 5.0 - 21 - 1.0 - 31 - 0.0 - 70 - 0 - 42 - 10.0 - 1 + let dwg = try DXF.read( + text: Self.doc( + """ + 0 + DIMENSION + 8 + DIMS + 10 + 5.0 + 20 + 0.0 + 30 + 0.0 + 11 + 5.0 + 21 + 1.0 + 31 + 0.0 + 70 + 0 + 42 + 10.0 + 1 - 13 - 0.0 - 23 - 0.0 - 33 - 0.0 - 14 - 10.0 - 24 - 0.0 - 34 - 0.0 - 50 - 0.0 - 0 - DIMENSION - 8 - DIMS - 10 - 20.0 - 20 - 20.0 - 30 - 0.0 - 11 - 27.5 - 21 - 20.0 - 31 - 0.0 - 70 - 4 - 42 - 7.5 - 1 - R7.5 - 15 - 27.5 - 25 - 20.0 - 35 - 0.0 - 40 - 3.0 - """)) + 13 + 0.0 + 23 + 0.0 + 33 + 0.0 + 14 + 10.0 + 24 + 0.0 + 34 + 0.0 + 50 + 0.0 + 0 + DIMENSION + 8 + DIMS + 10 + 20.0 + 20 + 20.0 + 30 + 0.0 + 11 + 27.5 + 21 + 20.0 + 31 + 0.0 + 70 + 4 + 42 + 7.5 + 1 + R7.5 + 15 + 27.5 + 25 + 20.0 + 35 + 0.0 + 40 + 3.0 + """)) #expect(dwg.counts.dimension == 2 && dwg.counts.total == 2) - guard case let .dimension(linear) = dwg.entities[0] else { Issue.record("not a dimension"); return } + guard case .dimension(let linear) = dwg.entities[0] else { + Issue.record("not a dimension") + return + } #expect(linear.kind == .linear) - #expect(linear.measurement == 10 && linear.textOverride == nil) // blank group 1 → nil + #expect(linear.measurement == 10 && linear.textOverride == nil) // blank group 1 → nil #expect(linear.textPosition == DXF.Point(5, 1) && linear.defPoint == DXF.Point(5, 0)) #expect(linear.defPoint2 == DXF.Point(0, 0) && linear.defPoint3 == DXF.Point(10, 0)) #expect(linear.defPoint4 == nil && linear.defPoint5 == nil) #expect(linear.layer == "DIMS") - guard case let .dimension(radius) = dwg.entities[1] else { Issue.record("not a dimension"); return } + guard case .dimension(let radius) = dwg.entities[1] else { + Issue.record("not a dimension") + return + } #expect(radius.kind == .radius) #expect(radius.measurement == 7.5 && radius.textOverride == "R7.5") #expect(radius.defPoint == DXF.Point(20, 20) && radius.defPoint4 == DXF.Point(27.5, 20)) @@ -373,72 +420,88 @@ struct SwiftDXFTests { @Test("DIMENSION: unrecognised group-70 base type decodes to .unknown") func dimensionUnknownType() throws { - let dwg = try DXF.read(text: Self.doc(""" - 0 - DIMENSION - 10 - 0.0 - 20 - 0.0 - 11 - 0.0 - 21 - 0.0 - 70 - 9 - """)) - guard case let .dimension(d) = dwg.entities.first else { Issue.record("not a dimension"); return } + let dwg = try DXF.read( + text: Self.doc( + """ + 0 + DIMENSION + 10 + 0.0 + 20 + 0.0 + 11 + 0.0 + 21 + 0.0 + 70 + 9 + """)) + guard case .dimension(let d) = dwg.entities.first else { + Issue.record("not a dimension") + return + } #expect(d.kind == .unknown(9) && d.measurement == nil) } @Test("unmodelled entities are skipped, not fatal") func skipsUnknown() throws { - let dwg = try DXF.read(text: Self.doc(""" - 0 - SPLINE - 10 - 0.0 - 20 - 0.0 - 0 - LINE - 10 - 0.0 - 20 - 0.0 - 11 - 1.0 - 21 - 1.0 - """)) + let dwg = try DXF.read( + text: Self.doc( + """ + 0 + SPLINE + 10 + 0.0 + 20 + 0.0 + 0 + LINE + 10 + 0.0 + 20 + 0.0 + 11 + 1.0 + 21 + 1.0 + """)) #expect(dwg.counts.total == 1 && dwg.counts.line == 1) } @Test("decodes \\U+XXXX unicode escapes in text") func unicodeEscape() throws { - let dwg = try DXF.read(text: Self.doc(""" - 0 - TEXT - 10 - 0.0 - 20 - 0.0 - 40 - 2.5 - 1 - x\\U+00B1y - """)) - guard case let .text(_, _, _, str, _, _) = dwg.entities.first else { Issue.record("not text"); return } + let dwg = try DXF.read( + text: Self.doc( + """ + 0 + TEXT + 10 + 0.0 + 20 + 0.0 + 40 + 2.5 + 1 + x\\U+00B1y + """)) + guard case .text(_, _, _, let str, _, _) = dwg.entities.first else { + Issue.record("not text") + return + } #expect(str == "x±y") } @Test("CRLF line endings and leading-space group codes parse") func crlfAndPadding() throws { // R12 writers pad group codes (" 0", " 10") and use CRLF. - let text = " 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nLINE\r\n 10\r\n0.0\r\n 20\r\n0.0\r\n 11\r\n2.0\r\n 21\r\n0.0\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n" + let text = + " 0\r\nSECTION\r\n 2\r\nENTITIES\r\n 0\r\nLINE\r\n 10\r\n0.0\r\n 20\r\n0.0\r\n 11\r\n2.0\r\n 21\r\n0.0\r\n 0\r\nENDSEC\r\n 0\r\nEOF\r\n" let dwg = try DXF.read(text: text) #expect(dwg.counts.line == 1) - guard case let .line(_, b, _, _) = dwg.entities.first else { Issue.record("not a line"); return } + guard case .line(_, let b, _, _) = dwg.entities.first else { + Issue.record("not a line") + return + } #expect(b == DXF.Point(2, 0)) } diff --git a/okf/index.md b/okf/index.md index d07c754..c079eff 100644 --- a/okf/index.md +++ b/okf/index.md @@ -37,3 +37,4 @@ See [`references/`](references/index.md) for the DXF format and reference reader - [Search before building](policies/search-before-building.md) - [Code structure](policies/code-structure.md) - [Issue labels and project-board tracking](policies/issue-tracking.md) +- [Code style](policies/code-style.md) diff --git a/okf/policies/code-style.md b/okf/policies/code-style.md new file mode 100644 index 0000000..89f8289 --- /dev/null +++ b/okf/policies/code-style.md @@ -0,0 +1,57 @@ +--- +type: policy +title: Code style +description: Swift naming/API shape follows the Swift API Design Guidelines, formatting follows Google's Swift Style Guide via swift-format, and doc comments stay terse; docs/ is the single source of truth for design rationale, not a second copy of it. +tags: [policy, style, swift, docs, agents] +timestamp: 2026-08-12 +--- + +# Code style + +**Naming and API shape** follow the +[Swift API Design Guidelines](https://www.swift.org/documentation/api-design-guidelines/) as-is: +clarity at the point of use over brevity, methods without side effects read as noun phrases, +methods with side effects as imperative verbs, boolean properties/methods read as assertions. + +**Formatting and file layout** follow +[Google's Swift Style Guide](https://google.github.io/swift/), enforced by `swift-format` +(configured in `.swift-format`: 100-column limit, 4-space indent, the ecosystem's one deliberate +divergence from Google's own 2-space default, chosen to avoid a repo-wide reformat diff with no +readability gain). `swift-format lint --strict` is a blocking CI check +(`.github/workflows/code-style.yml`): formatting has no judgment call in it. + +**SwiftLint is scoped to `orphaned_doc_comment` only** (`.swiftlint.yml`), not its full default +rule set. SwiftLint's defaults duplicate `swift-format`'s formatting opinions (and can disagree +with them on the same line) and add a large, separate surface of code-quality/complexity opinions +(`identifier_name`, `cyclomatic_complexity`, `function_body_length`, `nesting`, ...) that overlap +the ecosystem's own [code-structure](code-structure.md) policy rather than this one; a repo that +needs a structural pass runs one as its own scoped initiative, not as a side effect of a style-lint +gate. `orphaned_doc_comment` is the one rule left that catches something `swift-format` has no +equivalent for. + +**Doc comments stay terse.** A `///` comment is a single-sentence summary plus only the +`Parameter`/`Returns`/`Throws` tags that add something the summary doesn't already say. +Design rationale, extended examples, and cross-references to prior issues belong in `docs/`, not +duplicated in source: `docs/` is the single source of truth for *why* and *how*, per +[GitLab's documentation style guide](https://docs.gitlab.com/development/documentation/styleguide/) +("share the link to the documentation instead of rephrasing the information"). + +**The comment:code ratio check is a nudge, not a gate.** `Scripts/comment-ratio-check.sh` flags +(never fails) a file whose comment lines outnumber its code lines: a high ratio is sometimes +legitimate, so it's a signal for review, not an automatic failure. + +**No `clang-format` here.** This repo has no first-party C++; the ecosystem proposal's C++ half +(OCCT's own `.clang-format`, verbatim) applies only to repos with an `OCCTBridge`-style layer. + +Why: this repo is small enough (6 files, ~1,125 Swift lines) to sweep into full compliance in one +PR and go straight to a blocking gate, rather than needing OCCTSwift's gradual "if you touch it, +you fix it" exemption manifest, the same shape OCCTSwiftScripts used as this policy's reference +implementation. Full research and rationale: +[`ecosystem` docs/code-style-policy-proposal-2026-08.md](https://github.com/SecondMouseAU/ecosystem/blob/main/docs/code-style-policy-proposal-2026-08.md). +Filed and tracked as [SwiftDXF#13](https://github.com/SecondMouseAU/SwiftDXF/issues/13), following +the pattern proven in +[OCCTSwiftScripts#114](https://github.com/SecondMouseAU/OCCTSwiftScripts/issues/114) / +[#115](https://github.com/SecondMouseAU/OCCTSwiftScripts/pull/115). + +Ecosystem standard: see +[OKF-STANDARD.md](https://github.com/SecondMouseAU/ecosystem/blob/main/OKF-STANDARD.md).