diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml new file mode 100644 index 0000000..209b476 --- /dev/null +++ b/.github/workflows/code-style.yml @@ -0,0 +1,55 @@ +name: code-style + +# Trial of the ecosystem's code-style policy (OCCTSwiftIO#36, following the +# reference rollout at OCCTSwiftScripts#114/#115); see +# docs/code-style-policy-proposal-2026-08.md in the `ecosystem` repo for the +# full rationale. Full sweep rather than a gradual exemption manifest because +# this repo is small enough to bring fully into compliance in one PR and go +# straight to a blocking gate. +# +# 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..ab00d57 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,27 @@ +# 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). +# identifier_name's default 3-char minimum in particular is not a fit for +# this codebase's short, conventional locals in dense numerical/graph code +# (i, db, g, sv, ev): 548 hits on first run in OCCTSwiftScripts, the sibling +# repo this config is ported from; none of them real problems. +# +# 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. + +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..eaf8391 --- /dev/null +++ b/Scripts/comment-ratio-check.sh @@ -0,0 +1,59 @@ +#!/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 trial (OCCTSwiftIO#36, following +# the reference rollout at OCCTSwiftScripts#114); see +# docs/code-style-policy-proposal-2026-08.md in the `ecosystem` repo for the +# full rationale. 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 +# (see the proposal's own CurveAdaptors.md finding for what that looks like +# once it's had years to drift): this script surfaces the number, it doesn't +# judge which case it is. Report-only: always exits 0, same as +# check-docs-existence.py's own `--coverage` mode in OCCTSwift/Scripts, and +# for the same reason: 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/MeshIO/GLTFAdapter.swift b/Sources/MeshIO/GLTFAdapter.swift index e79b95b..e1f029a 100644 --- a/Sources/MeshIO/GLTFAdapter.swift +++ b/Sources/MeshIO/GLTFAdapter.swift @@ -1,14 +1,16 @@ import Foundation -import simd import SwiftGLTF +import simd extension MeshIO { // MARK: read (via SwiftGLTF) - /// Read **glTF** / **GLB** into a ``Mesh``. SwiftGLTF handles the hard decoding (JSON/GLB container, - /// buffers, external `.bin`, accessors); we walk the scene graph, decode POSITION + index accessors - /// ourselves (avoiding SwiftGLTF's RealityKit-importing convenience helpers), and bake node transforms. + /// Read **glTF** / **GLB** into a ``Mesh``. + /// + /// SwiftGLTF handles the hard decoding (JSON/GLB container, buffers, external `.bin`, + /// accessors); we walk the scene graph, decode POSITION + index accessors ourselves (avoiding + /// SwiftGLTF's RealityKit-importing convenience helpers), and bake node transforms. static func readGLTF(url: URL, weldEpsilon: Float) throws -> Mesh { let container = try SwiftGLTF.Container(url: url) let doc = container.document @@ -16,26 +18,33 @@ extension MeshIO { func positions(_ acc: SwiftGLTF.Accessor) throws -> [SIMD3] { let d = try container.data(for: acc) - var out = [SIMD3](); out.reserveCapacity(acc.count) + var out = [SIMD3]() + out.reserveCapacity(acc.count) d.withUnsafeBytes { raw in for i in 0.. [Int] { let d = try container.data(for: acc) - var out = [Int](); out.reserveCapacity(acc.count) + var out = [Int]() + out.reserveCapacity(acc.count) d.withUnsafeBytes { raw in for i in 0.. simd_float4x4 { - if let m = n.matrix, m != matrix_identity_float4x4 { return m } // matrix XOR TRS per spec + // matrix XOR TRS per spec + if let m = n.matrix, m != matrix_identity_float4x4 { return m } var t = matrix_identity_float4x4 if let s = n.scale { t = simd_float4x4(diagonal: SIMD4(s, 1)) } - if let r = n.rotation { t = simd_float4x4(simd_quatf(ix: r.x, iy: r.y, iz: r.z, r: r.w)) * t } - if let tr = n.translation { var tm = matrix_identity_float4x4; tm.columns.3 = SIMD4(tr, 1); t = tm * t } + if let r = n.rotation { + t = simd_float4x4(simd_quatf(ix: r.x, iy: r.y, iz: r.z, r: r.w)) * t + } + if let tr = n.translation { + var tm = matrix_identity_float4x4 + tm.columns.3 = SIMD4(tr, 1) + t = tm * t + } return t } func emit(_ prim: SwiftGLTF.Mesh.Primitive, _ world: simd_float4x4) throws { guard prim.mode == .TRIANGLES, let posIdx = prim.attributes[.POSITION] else { return } let pos = try positions(posIdx.resolve(in: doc)) - let wp = pos.map { p -> SIMD3 in let v = world * SIMD4(p, 1); return SIMD3(v.x, v.y, v.z) } - let idx = try prim.indices.map { try indices($0.resolve(in: doc)) } ?? Array(0.. SIMD3 in + let v = world * SIMD4(p, 1) + return SIMD3(v.x, v.y, v.z) + } + let idx = + try prim.indices.map { try indices($0.resolve(in: doc)) } ?? Array(0..] = (try doc.scene?.resolve(in: doc).nodes) ?? doc.scenes.first?.nodes ?? [] for r in roots { try walk(r.resolve(in: doc), matrix_identity_float4x4) } - if soup.isEmpty { // no scene graph → meshes untransformed - for mesh in doc.meshes { for prim in mesh.primitives { try emit(prim, matrix_identity_float4x4) } } + if soup.isEmpty { // no scene graph → meshes untransformed + for mesh in doc.meshes { + for prim in mesh.primitives { try emit(prim, matrix_identity_float4x4) } + } } guard !soup.isEmpty else { throw MeshError.notRecognized } return weldEpsilon > 0 ? Mesh.welded(soup, epsilon: weldEpsilon) : Mesh.indexedSoup(soup) @@ -83,20 +112,41 @@ extension MeshIO { /// Pack a mesh into a single glTF buffer (positions float32 VEC3, then indices uint32 SCALAR) and /// produce the JSON manifest. `bufferURI` is nil for GLB (BIN chunk) or a data URI for `.gltf`. - private static func gltfBin(_ mesh: Mesh) -> (bin: Data, posLen: Int, idxLen: Int, lo: SIMD3, hi: SIMD3) { + private static func gltfBin(_ mesh: Mesh) -> ( + bin: Data, posLen: Int, idxLen: Int, lo: SIMD3, hi: SIMD3 + ) { var bin = [UInt8]() - func f32(_ v: Float) { var x = v.bitPattern.littleEndian; withUnsafeBytes(of: &x) { bin.append(contentsOf: $0) } } - func u32(_ v: UInt32) { var x = v.littleEndian; withUnsafeBytes(of: &x) { bin.append(contentsOf: $0) } } - var lo = SIMD3(repeating: .greatestFiniteMagnitude), hi = -lo - for p in mesh.positions { f32(p.x); f32(p.y); f32(p.z); lo = simd_min(lo, p); hi = simd_max(hi, p) } + func f32(_ v: Float) { + var x = v.bitPattern.littleEndian + withUnsafeBytes(of: &x) { bin.append(contentsOf: $0) } + } + func u32(_ v: UInt32) { + var x = v.littleEndian + withUnsafeBytes(of: &x) { bin.append(contentsOf: $0) } + } + var lo = SIMD3(repeating: .greatestFiniteMagnitude) + var hi = -lo + for p in mesh.positions { + f32(p.x) + f32(p.y) + f32(p.z) + lo = simd_min(lo, p) + hi = simd_max(hi, p) + } let posLen = bin.count for i in mesh.indices { u32(i) } return (Data(bin), posLen, bin.count - posLen, lo, hi) } - private static func gltfManifest(_ mesh: Mesh, _ b: (bin: Data, posLen: Int, idxLen: Int, lo: SIMD3, hi: SIMD3), bufferURI: String?) -> String { + private static func gltfManifest( + _ mesh: Mesh, + _ b: (bin: Data, posLen: Int, idxLen: Int, lo: SIMD3, hi: SIMD3), + bufferURI: String? + ) -> String { func n(_ v: Float) -> String { String(v) } - let buffer = bufferURI.map { "{\"uri\":\"\($0)\",\"byteLength\":\(b.bin.count)}" } ?? "{\"byteLength\":\(b.bin.count)}" + let buffer = + bufferURI.map { "{\"uri\":\"\($0)\",\"byteLength\":\(b.bin.count)}" } + ?? "{\"byteLength\":\(b.bin.count)}" return "{\"asset\":{\"version\":\"2.0\",\"generator\":\"MeshIO\"}," + "\"buffers\":[\(buffer)]," + "\"bufferViews\":[{\"buffer\":0,\"byteOffset\":0,\"byteLength\":\(b.posLen),\"target\":34962}," @@ -108,25 +158,36 @@ extension MeshIO { + "\"nodes\":[{\"mesh\":0}],\"scenes\":[{\"nodes\":[0]}],\"scene\":0}" } - /// `.gltf` — JSON with the buffer embedded as a base64 data URI (self-contained, no sidecar `.bin`). + /// `.gltf`: JSON with the buffer embedded as a base64 data URI (self-contained, no sidecar `.bin`). static func writeGLTF(_ mesh: Mesh) -> Data { let b = gltfBin(mesh) - let json = gltfManifest(mesh, b, bufferURI: "data:application/octet-stream;base64,\(b.bin.base64EncodedString())") + let json = gltfManifest( + mesh, b, + bufferURI: "data:application/octet-stream;base64,\(b.bin.base64EncodedString())") return Data(json.utf8) } - /// `.glb` — binary container: 12-byte header + JSON chunk + BIN chunk (each 4-byte aligned). + /// `.glb`: binary container, 12-byte header + JSON chunk + BIN chunk (each 4-byte aligned). static func writeGLB(_ mesh: Mesh) -> Data { let b = gltfBin(mesh) var json = [UInt8](gltfManifest(mesh, b, bufferURI: nil).utf8) - while json.count % 4 != 0 { json.append(0x20) } // pad JSON with spaces + while json.count % 4 != 0 { json.append(0x20) } // pad JSON with spaces var bin = [UInt8](b.bin) - while bin.count % 4 != 0 { bin.append(0x00) } // pad BIN with zeros + while bin.count % 4 != 0 { bin.append(0x00) } // pad BIN with zeros var d = Data() - func u32(_ v: Int) { var x = UInt32(v).littleEndian; withUnsafeBytes(of: &x) { d.append(contentsOf: $0) } } - u32(0x46546C67); u32(2); u32(12 + 8 + json.count + 8 + bin.count) // "glTF", version 2, total length - u32(json.count); u32(0x4E4F534A); d.append(contentsOf: json) // JSON chunk ("JSON") - u32(bin.count); u32(0x004E4942); d.append(contentsOf: bin) // BIN chunk ("BIN\0") + func u32(_ v: Int) { + var x = UInt32(v).littleEndian + withUnsafeBytes(of: &x) { d.append(contentsOf: $0) } + } + u32(0x4654_6C67) + u32(2) + u32(12 + 8 + json.count + 8 + bin.count) // "glTF", version 2, total length + u32(json.count) + u32(0x4E4F_534A) + d.append(contentsOf: json) // JSON chunk ("JSON") + u32(bin.count) + u32(0x004E_4942) + d.append(contentsOf: bin) // BIN chunk ("BIN\0") return d } } diff --git a/Sources/MeshIO/Mesh.swift b/Sources/MeshIO/Mesh.swift index ab32de0..dab46ac 100644 --- a/Sources/MeshIO/Mesh.swift +++ b/Sources/MeshIO/Mesh.swift @@ -1,8 +1,10 @@ import Foundation -/// One contiguous run of `Mesh.indices` belonging to a single source-format material — the source +/// One contiguous run of `Mesh.indices` belonging to a single source-format material: the source /// format's own segmentation of the face buffer, so a single part can be isolated from a whole-model -/// mesh. Empty for formats that carry no such grouping (STL, OBJ, PLY, ...). +/// mesh. +/// +/// Empty for formats that carry no such grouping (STL, OBJ, PLY, ...). public struct Submesh: Equatable, Sendable { /// Start of this run in `Mesh.indices`. public var indexOffset: Int @@ -18,12 +20,16 @@ public struct Submesh: Equatable, Sendable { } } -/// A welded, indexed triangle mesh — the neutral currency of ``MeshIO``. Positions are unique; -/// `indices` holds three vertex indices per triangle. Pure value type, no OCCT. +/// A welded, indexed triangle mesh: the neutral currency of ``MeshIO``. +/// +/// Positions are unique; `indices` holds three vertex indices per triangle. Pure value type, no +/// OCCT. public struct Mesh: Equatable, Sendable { public var positions: [SIMD3] public var indices: [UInt32] - /// Per-material index ranges, in source file order. Empty for formats/files with no such grouping. + /// Per-material index ranges, in source file order. + /// + /// Empty for formats/files with no such grouping. public var submeshes: [Submesh] public init(positions: [SIMD3] = [], indices: [UInt32] = [], submeshes: [Submesh] = []) { @@ -53,17 +59,29 @@ public struct Mesh: Equatable, Sendable { return (lo, hi) } - /// Merge coincident vertices by quantizing positions to a grid of size `epsilon`. Used to restore - /// connectivity in formats that split vertices at seams (STL has none; OBJ/PLY/source formats may). + /// Merge coincident vertices by quantizing positions to a grid of size `epsilon`. + /// + /// Used to restore connectivity in formats that split vertices at seams (STL has none; + /// OBJ/PLY/source formats may). public static func welded(_ soup: [SIMD3], epsilon: Float) -> Mesh { let inv = 1.0 / Swift.max(epsilon, .leastNormalMagnitude) var map = [SIMD3: UInt32](minimumCapacity: soup.count / 2) - var positions = [SIMD3](); positions.reserveCapacity(soup.count / 2) - var indices = [UInt32](); indices.reserveCapacity(soup.count) + var positions = [SIMD3]() + positions.reserveCapacity(soup.count / 2) + var indices = [UInt32]() + indices.reserveCapacity(soup.count) for v in soup { - let key = SIMD3(Int32((v.x * inv).rounded()), Int32((v.y * inv).rounded()), Int32((v.z * inv).rounded())) - if let i = map[key] { indices.append(i) } - else { let i = UInt32(positions.count); map[key] = i; positions.append(v); indices.append(i) } + let key = SIMD3( + Int32((v.x * inv).rounded()), Int32((v.y * inv).rounded()), + Int32((v.z * inv).rounded())) + if let i = map[key] { + indices.append(i) + } else { + let i = UInt32(positions.count) + map[key] = i + positions.append(v) + indices.append(i) + } } return Mesh(positions: positions, indices: indices) } diff --git a/Sources/MeshIO/MeshIO.swift b/Sources/MeshIO/MeshIO.swift index 7d9abfe..92d5ce9 100644 --- a/Sources/MeshIO/MeshIO.swift +++ b/Sources/MeshIO/MeshIO.swift @@ -9,10 +9,12 @@ public enum MeshError: Error, Equatable, Sendable { case unsupported(String) } -/// The 3D mesh file formats MeshIO handles. Mesh-only — 2D vector formats (JWW/DXF) and CAD B-Rep -/// (STEP/IGES/BREP) live in the OCCT-backed `OCCTSwiftIO` target, not here. +/// The 3D mesh file formats MeshIO handles. +/// +/// Mesh-only: 2D vector formats (JWW/DXF) and CAD B-Rep (STEP/IGES/BREP) live in the OCCT-backed +/// `OCCTSwiftIO` target, not here. public enum MeshFormat: String, Sendable, CaseIterable { - case stl, obj, ply, pmx, x // x = DirectX .x + case stl, obj, ply, pmx, x // x = DirectX .x case threeMF = "3mf" case gltf, glb @@ -22,7 +24,7 @@ public enum MeshFormat: String, Sendable, CaseIterable { case "obj": self = .obj case "ply": self = .ply case "pmx": self = .pmx - case "x": self = .x + case "x": self = .x case "3mf": self = .threeMF case "gltf": self = .gltf case "glb": self = .glb @@ -31,11 +33,14 @@ public enum MeshFormat: String, Sendable, CaseIterable { } public var canRead: Bool { true } - public var canWrite: Bool { self != .pmx && self != .x } // pmx/.x are source-only; rest read+write + // pmx/.x are source-only; rest read+write + public var canWrite: Bool { self != .pmx && self != .x } } -/// Pure-Swift mesh file I/O — no OCCT. Reads STL/OBJ/PLY natively and PMX/.x via the standalone -/// SwiftPMX / SwiftX packages, into a neutral ``Mesh``. +/// Pure-Swift mesh file I/O, no OCCT. +/// +/// Reads STL/OBJ/PLY natively and PMX/.x via the standalone SwiftPMX / SwiftX packages, into a +/// neutral ``Mesh``. public enum MeshIO { /// All formats with a reader. @@ -53,24 +58,31 @@ public enum MeshIO { case .stl: return try STL.read(data: data, weldEpsilon: weldEpsilon) case .obj: return try OBJ.read(data: data, weldEpsilon: weldEpsilon) case .ply: return try PLY.read(data: data, weldEpsilon: weldEpsilon) - case .pmx: return adapt(try SwiftPMX.PMX.read(data: data, options: .init(weldEpsilon: weldEpsilon))) - case .x: return adapt(try SwiftX.X.read(data: data, options: .init(weldEpsilon: weldEpsilon))) + case .pmx: + return adapt( + try SwiftPMX.PMX.read(data: data, options: .init(weldEpsilon: weldEpsilon))) + case .x: + return adapt(try SwiftX.X.read(data: data, options: .init(weldEpsilon: weldEpsilon))) case .threeMF: return try readThreeMF(data: data, weldEpsilon: weldEpsilon) case .gltf, .glb: fatalError("handled above") } } /// Write a mesh, choosing the writer by `format` (or the file extension if `format` is nil). - public static func write(_ mesh: Mesh, to url: URL, format: MeshFormat? = nil, asciiSTL: Bool = false) throws { + public static func write( + _ mesh: Mesh, to url: URL, format: MeshFormat? = nil, asciiSTL: Bool = false + ) throws { let fmt = format ?? MeshFormat(fileExtension: url.pathExtension) switch fmt { - case .stl: try (asciiSTL ? Data(STL.asciiString(mesh).utf8) : STL.binaryData(mesh)).write(to: url) + case .stl: + try (asciiSTL ? Data(STL.asciiString(mesh).utf8) : STL.binaryData(mesh)).write(to: url) case .obj: try Data(OBJ.string(mesh).utf8).write(to: url) case .ply: try Data(PLY.string(mesh).utf8).write(to: url) case .threeMF: try writeThreeMF(mesh).write(to: url) case .gltf: try writeGLTF(mesh).write(to: url) case .glb: try writeGLB(mesh).write(to: url) - case .pmx, .x, nil: throw MeshError.unsupported("write \(fmt?.rawValue ?? url.pathExtension)") + case .pmx, .x, nil: + throw MeshError.unsupported("write \(fmt?.rawValue ?? url.pathExtension)") } } @@ -79,8 +91,14 @@ public enum MeshIO { Mesh( positions: m.positions, indices: m.indices, - submeshes: m.submeshes.map { Submesh(indexOffset: $0.indexOffset, indexCount: $0.indexCount, materialIndex: $0.materialIndex) } + submeshes: m.submeshes.map { + Submesh( + indexOffset: $0.indexOffset, indexCount: $0.indexCount, + materialIndex: $0.materialIndex) + } ) } - static func adapt(_ m: SwiftX.X.Mesh) -> Mesh { Mesh(positions: m.positions, indices: m.indices) } + static func adapt(_ m: SwiftX.X.Mesh) -> Mesh { + Mesh(positions: m.positions, indices: m.indices) + } } diff --git a/Sources/MeshIO/OBJ.swift b/Sources/MeshIO/OBJ.swift index 0799082..b22f636 100644 --- a/Sources/MeshIO/OBJ.swift +++ b/Sources/MeshIO/OBJ.swift @@ -1,7 +1,9 @@ import Foundation -/// Native Wavefront OBJ reader/writer (geometry only: `v` + `f`). Faces of any vertex-ref form -/// (`v`, `v/vt`, `v/vt/vn`, `v//vn`; negative-relative indices) are fan-triangulated. No OCCT. +/// Native Wavefront OBJ reader/writer (geometry only: `v` + `f`). +/// +/// Faces of any vertex-ref form (`v`, `v/vt`, `v/vt/vn`, `v//vn`; negative-relative indices) are +/// fan-triangulated. No OCCT. public enum OBJ { public static func read(data: Data, weldEpsilon: Float = 1e-4) throws -> Mesh { @@ -9,51 +11,81 @@ public enum OBJ { var positions: [SIMD3] = [] var indices: [UInt32] = [] data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in - let b = raw.bindMemory(to: UInt8.self); let n = b.count; var i = 0 + let b = raw.bindMemory(to: UInt8.self) + let n = b.count + var i = 0 while i < n { while i < n, b[i] == 32 || b[i] == 9 { i += 1 } - let s = i; while i < n, b[i] != 10 { i += 1 }; let e = i; i += 1 + let s = i + while i < n, b[i] != 10 { i += 1 } + let e = i + i += 1 guard e > s else { continue } let c0 = b[s] let sep = s + 1 < e && (b[s + 1] == 32 || b[s + 1] == 9) - if c0 == UInt8(ascii: "v"), sep { parseVertex(b, s + 1, e, &positions) } - else if c0 == UInt8(ascii: "f"), sep { parseFace(b, s + 1, e, positions.count, &indices) } + if c0 == UInt8(ascii: "v"), sep { + parseVertex(b, s + 1, e, &positions) + } else if c0 == UInt8(ascii: "f"), sep { + parseFace(b, s + 1, e, positions.count, &indices) + } } } guard !positions.isEmpty, !indices.isEmpty else { throw MeshError.notRecognized } if weldEpsilon > 0 { - var soup = [SIMD3](); soup.reserveCapacity(indices.count) + var soup = [SIMD3]() + soup.reserveCapacity(indices.count) for idx in indices { soup.append(positions[Int(idx)]) } return Mesh.welded(soup, epsilon: weldEpsilon) } return Mesh(positions: positions, indices: indices) } - private static func parseVertex(_ b: UnsafeBufferPointer, _ from: Int, _ to: Int, _ out: inout [SIMD3]) { - var p = from, v = [Float]() + private static func parseVertex( + _ b: UnsafeBufferPointer, _ from: Int, _ to: Int, _ out: inout [SIMD3] + ) { + var p = from + var v = [Float]() while p < to, v.count < 3 { while p < to, b[p] == 32 || b[p] == 9 { p += 1 } - let s = p; while p < to, b[p] != 32, b[p] != 9, b[p] != 13 { p += 1 } - if p > s, let f = Float(String(decoding: UnsafeBufferPointer(rebasing: b[s.. s, + let f = Float( + String(decoding: UnsafeBufferPointer(rebasing: b[s.., _ from: Int, _ to: Int, _ vcount: Int, _ out: inout [UInt32]) { - var p = from; var verts = [UInt32]() + private static func parseFace( + _ b: UnsafeBufferPointer, _ from: Int, _ to: Int, _ vcount: Int, + _ out: inout [UInt32] + ) { + var p = from + var verts = [UInt32]() while p < to { while p < to, b[p] == 32 || b[p] == 9 { p += 1 } - let s = p; while p < to, b[p] != 32, b[p] != 9, b[p] != 13 { p += 1 } + let s = p + while p < to, b[p] != 32, b[p] != 9, b[p] != 13 { p += 1 } if p > s { - var e = s; while e < p, b[e] != UInt8(ascii: "/") { e += 1 } - if let raw = Int(String(decoding: UnsafeBufferPointer(rebasing: b[s.. 0 ? raw - 1 : vcount + raw if idx >= 0, idx < vcount { verts.append(UInt32(idx)) } } } } guard verts.count >= 3 else { return } - for k in 1..<(verts.count - 1) { out.append(verts[0]); out.append(verts[k]); out.append(verts[k + 1]) } + for k in 1..<(verts.count - 1) { + out.append(verts[0]) + out.append(verts[k]) + out.append(verts[k + 1]) + } } // MARK: write diff --git a/Sources/MeshIO/PLY.swift b/Sources/MeshIO/PLY.swift index 200f703..dbf1774 100644 --- a/Sources/MeshIO/PLY.swift +++ b/Sources/MeshIO/PLY.swift @@ -1,35 +1,57 @@ import Foundation -/// Native PLY reader/writer. Reads ASCII and binary-little-endian PLY: the `vertex` element's -/// x/y/z and the `face` element's index lists (fan-triangulated). No OCCT. +/// Native PLY reader/writer. +/// +/// Reads ASCII and binary-little-endian PLY: the `vertex` element's x/y/z and the `face` element's +/// index lists (fan-triangulated). No OCCT. public enum PLY { public static func read(data: Data, weldEpsilon: Float = 1e-4) throws -> Mesh { guard !data.isEmpty else { throw MeshError.empty } let bytes = [UInt8](data) - guard bytes.count >= 3, bytes[0] == UInt8(ascii: "p"), bytes[1] == UInt8(ascii: "l"), bytes[2] == UInt8(ascii: "y") + guard bytes.count >= 3, bytes[0] == UInt8(ascii: "p"), bytes[1] == UInt8(ascii: "l"), + bytes[2] == UInt8(ascii: "y") else { throw MeshError.notRecognized } // --- header --- var headerEnd = 0 var lines: [String] = [] do { - var line = [UInt8](); var i = 0 + var line = [UInt8]() + var i = 0 while i < bytes.count { - let ch = bytes[i]; i += 1 + let ch = bytes[i] + i += 1 if ch == 10 { - let s = String(decoding: line, as: UTF8.self).trimmingCharacters(in: .whitespaces) - lines.append(s); line.removeAll(keepingCapacity: true) - if s == "end_header" { headerEnd = i; break } - } else if ch != 13 { line.append(ch) } + let s = String(decoding: line, as: UTF8.self).trimmingCharacters( + in: .whitespaces) + lines.append(s) + line.removeAll(keepingCapacity: true) + if s == "end_header" { + headerEnd = i + break + } + } else if ch != 13 { + line.append(ch) + } } } - var binary = false, bigEndian = false - struct Prop { var name: String; var size: Int; var isFloat: Bool } - struct ListProp { var countSize: Int; var indexSize: Int } - var vertexCount = 0, faceCount = 0 - var vProps: [Prop] = []; var faceList: ListProp? - var current = "" // which element we're describing + var binary = false + var bigEndian = false + struct Prop { + var name: String + var size: Int + var isFloat: Bool + } + struct ListProp { + var countSize: Int + var indexSize: Int + } + var vertexCount = 0 + var faceCount = 0 + var vProps: [Prop] = [] + var faceList: ListProp? + var current = "" // which element we're describing func tsize(_ t: String) -> (Int, Bool) { switch t { case "char", "uchar", "int8", "uint8": return (1, false) @@ -44,16 +66,22 @@ public enum PLY { let f = l.split(separator: " ").map(String.init) guard let kw = f.first else { continue } switch kw { - case "format": binary = l.contains("binary"); bigEndian = l.contains("big_endian") + case "format": + binary = l.contains("binary") + bigEndian = l.contains("big_endian") case "element": current = f[1] - if f[1] == "vertex" { vertexCount = Int(f[2]) ?? 0 } - else if f[1] == "face" { faceCount = Int(f[2]) ?? 0 } + if f[1] == "vertex" { + vertexCount = Int(f[2]) ?? 0 + } else if f[1] == "face" { + faceCount = Int(f[2]) ?? 0 + } case "property": if f[1] == "list", current == "face" { faceList = ListProp(countSize: tsize(f[2]).0, indexSize: tsize(f[3]).0) } else if current == "vertex" { - let (sz, isF) = tsize(f[1]); vProps.append(Prop(name: f.last ?? "", size: sz, isFloat: isF)) + let (sz, isF) = tsize(f[1]) + vProps.append(Prop(name: f.last ?? "", size: sz, isFloat: isF)) } default: break } @@ -62,8 +90,10 @@ public enum PLY { let yi = vProps.firstIndex { $0.name == "y" } ?? 1 let zi = vProps.firstIndex { $0.name == "z" } ?? 2 - var positions: [SIMD3] = []; positions.reserveCapacity(vertexCount) - var faces: [[Int]] = []; faces.reserveCapacity(faceCount) + var positions: [SIMD3] = [] + positions.reserveCapacity(vertexCount) + var faces: [[Int]] = [] + faces.reserveCapacity(faceCount) if !binary { // ASCII body @@ -84,26 +114,43 @@ public enum PLY { guard !bigEndian else { throw MeshError.unsupported("PLY big-endian") } var p = headerEnd let stride = vProps.reduce(0) { $0 + $1.size } - let offs = vProps.reduce(into: (run: 0, arr: [Int]())) { acc, pr in acc.arr.append(acc.run); acc.run += pr.size }.arr + let offs = vProps.reduce(into: (run: 0, arr: [Int]())) { acc, pr in + acc.arr.append(acc.run) + acc.run += pr.size + }.arr func f(_ at: Int, _ pr: Prop) -> Float { if pr.isFloat { return pr.size == 8 - ? Float(bytes.withUnsafeBytes { $0.loadUnaligned(fromByteOffset: at, as: Double.self) }) - : bytes.withUnsafeBytes { $0.loadUnaligned(fromByteOffset: at, as: Float.self) } + ? Float( + bytes.withUnsafeBytes { + $0.loadUnaligned(fromByteOffset: at, as: Double.self) + }) + : bytes.withUnsafeBytes { + $0.loadUnaligned(fromByteOffset: at, as: Float.self) + } } return Float(readInt(bytes, at, pr.size)) } for _ in 0..= 3 { faces.append(idx) } } } @@ -111,13 +158,19 @@ public enum PLY { var indices: [UInt32] = [] for face in faces where face.count >= 3 { - for k in 1..<(face.count - 1) where face[0] < positions.count && face[k] < positions.count && face[k + 1] < positions.count { - indices.append(UInt32(face[0])); indices.append(UInt32(face[k])); indices.append(UInt32(face[k + 1])) + for k in 1..<(face.count - 1) + where face[0] < positions.count && face[k] < positions.count + && face[k + 1] < positions.count + { + indices.append(UInt32(face[0])) + indices.append(UInt32(face[k])) + indices.append(UInt32(face[k + 1])) } } guard !positions.isEmpty, !indices.isEmpty else { throw MeshError.notRecognized } if weldEpsilon > 0 { - var soup = [SIMD3](); soup.reserveCapacity(indices.count) + var soup = [SIMD3]() + soup.reserveCapacity(indices.count) for i in indices { soup.append(positions[Int(i)]) } return Mesh.welded(soup, epsilon: weldEpsilon) } @@ -125,18 +178,25 @@ public enum PLY { } private static func readInt(_ b: [UInt8], _ at: Int, _ size: Int) -> Int { - var v = 0; for k in 0.. String { var s = "ply\nformat ascii 1.0\ncomment MeshIO\n" - s += "element vertex \(mesh.vertexCount)\nproperty float x\nproperty float y\nproperty float z\n" - s += "element face \(mesh.triangleCount)\nproperty list uchar int vertex_indices\nend_header\n" + s += + "element vertex \(mesh.vertexCount)\nproperty float x\nproperty float y\nproperty float z\n" + s += + "element face \(mesh.triangleCount)\nproperty list uchar int vertex_indices\nend_header\n" s.reserveCapacity(mesh.vertexCount * 24 + mesh.triangleCount * 16) for p in mesh.positions { s += "\(p.x) \(p.y) \(p.z)\n" } - for t in 0.. Mesh { @@ -20,16 +22,18 @@ public enum STL { static func readBinary(_ data: Data) -> [SIMD3] { let n = Int(data.withUnsafeBytes { $0.loadUnaligned(fromByteOffset: 80, as: UInt32.self) }) guard data.count >= 84 + 50 * n else { return [] } - var out = [SIMD3](); out.reserveCapacity(n * 3) + var out = [SIMD3]() + out.reserveCapacity(n * 3) data.withUnsafeBytes { raw in let base = raw.baseAddress! for i in 0.. [SIMD3] { var out = [SIMD3]() data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in - let b = raw.bindMemory(to: UInt8.self); let n = b.count + let b = raw.bindMemory(to: UInt8.self) + let n = b.count var i = 0 while i < n { while i < n, b[i] == 32 || b[i] == 9 { i += 1 } - let s = i; while i < n, b[i] != 10 { i += 1 }; let e = i; i += 1 - let kw: [UInt8] = [118, 101, 114, 116, 101, 120] // "vertex" + let s = i + while i < n, b[i] != 10 { i += 1 } + let e = i + i += 1 + let kw: [UInt8] = [118, 101, 114, 116, 101, 120] // "vertex" guard e - s >= 7 else { continue } - var ok = true; for k in 0..<6 where b[s + k] != kw[k] { ok = false } + var ok = true + for k in 0..<6 where b[s + k] != kw[k] { ok = false } guard ok else { continue } - var p = s + 6; var c = [Float]() + var p = s + 6 + var c = [Float]() while p < e, c.count < 3 { while p < e, b[p] == 32 || b[p] == 9 { p += 1 } - let t = p; while p < e, b[p] != 32, b[p] != 9, b[p] != 13 { p += 1 } - if p > t, let f = Float(String(decoding: UnsafeBufferPointer(rebasing: b[t.. t, + let f = Float( + String(decoding: UnsafeBufferPointer(rebasing: b[t.. Data { var data = Data(capacity: 84 + mesh.triangleCount * 50) data.append(Data(count: 80)) - var nn = UInt32(mesh.triangleCount); withUnsafeBytes(of: &nn) { data.append(contentsOf: $0) } - func f(_ v: Float) { var x = v; withUnsafeBytes(of: &x) { data.append(contentsOf: $0) } } + var nn = UInt32(mesh.triangleCount) + withUnsafeBytes(of: &nn) { data.append(contentsOf: $0) } + func f(_ v: Float) { + var x = v + withUnsafeBytes(of: &x) { data.append(contentsOf: $0) } + } for t in 0.. String { - var s = "solid \(name)\n"; s.reserveCapacity(mesh.triangleCount * 160) + var s = "solid \(name)\n" + s.reserveCapacity(mesh.triangleCount * 160) for t in 0.., _ b: SIMD3, _ c: SIMD3) -> SIMD3 { - let u = b - a, v = c - a + let u = b - a + let v = c - a let n = SIMD3(u.y * v.z - u.z * v.y, u.z * v.x - u.x * v.z, u.x * v.y - u.y * v.x) let len = (n.x * n.x + n.y * n.y + n.z * n.z).squareRoot() return len > 1e-12 ? n / len : SIMD3(0, 0, 1) diff --git a/Sources/MeshIO/ThreeMFAdapter.swift b/Sources/MeshIO/ThreeMFAdapter.swift index 903acd2..59626cf 100644 --- a/Sources/MeshIO/ThreeMFAdapter.swift +++ b/Sources/MeshIO/ThreeMFAdapter.swift @@ -3,17 +3,22 @@ import ThreeMF extension MeshIO { - /// Read a **3MF** package into a ``Mesh`` via the ThreeMF package. Uses ThreeMF's flattened - /// `LoadedModel` — build items expanded into mesh instances with accumulated transforms — so - /// multi-object / instanced models are placed correctly. + /// Read a **3MF** package into a ``Mesh`` via the ThreeMF package. + /// + /// Uses ThreeMF's flattened `LoadedModel` (build items expanded into mesh instances with + /// accumulated transforms), so multi-object / instanced models are placed correctly. static func readThreeMF(data: Data, weldEpsilon: Float) throws -> Mesh { let loaded = try runBlocking { try await ThreeMF.ModelLoader(data: data).load() } var soup: [SIMD3] = [] func emit(_ mesh: ThreeMF.Mesh, _ transforms: [Matrix3D]) { - let vs = mesh.vertices.map { v -> SIMD3 in placed(SIMD3(v.x, v.y, v.z), transforms) } + let vs = mesh.vertices.map { v -> SIMD3 in + placed(SIMD3(v.x, v.y, v.z), transforms) + } for t in mesh.triangles { - let a = vs[t.v1], b = vs[t.v2], c = vs[t.v3] + let a = vs[t.v1] + let b = vs[t.v2] + let c = vs[t.v3] soup.append(SIMD3(Float(a.x), Float(a.y), Float(a.z))) soup.append(SIMD3(Float(b.x), Float(b.y), Float(b.z))) soup.append(SIMD3(Float(c.x), Float(c.y), Float(c.z))) @@ -25,7 +30,7 @@ extension MeshIO { emit(loaded.meshes[comp.meshIndex].mesh, comp.transforms) } } - // Fallback: a model with meshes but no resolved build items — emit them untransformed. + // Fallback: a model with meshes but no resolved build items, emit them untransformed. if soup.isEmpty { for lm in loaded.meshes { emit(lm.mesh, []) } } @@ -35,20 +40,26 @@ extension MeshIO { /// Write a ``Mesh`` as a single-object **3MF** package via ThreeMF. static func writeThreeMF(_ mesh: Mesh) throws -> Data { - let verts = mesh.positions.map { ThreeMF.Mesh.Vertex(x: Double($0.x), y: Double($0.y), z: Double($0.z)) } + let verts = mesh.positions.map { + ThreeMF.Mesh.Vertex(x: Double($0.x), y: Double($0.y), z: Double($0.z)) + } let tris = (0.. ThreeMF.Mesh.Triangle in let (a, b, c) = mesh.triangle(t) return ThreeMF.Mesh.Triangle(v1: Int(a), v2: Int(b), v3: Int(c), propertyIndex: nil) } - let object = ThreeMF.Object(id: 1, content: .mesh(ThreeMF.Mesh(vertices: verts, triangles: tris))) - let model = ThreeMF.Model(resources: [object], build: ThreeMF.Build(items: [ThreeMF.Item(objectID: 1)])) + let object = ThreeMF.Object( + id: 1, content: .mesh(ThreeMF.Mesh(vertices: verts, triangles: tris))) + let model = ThreeMF.Model( + resources: [object], build: ThreeMF.Build(items: [ThreeMF.Item(objectID: 1)])) let writer = ThreeMF.PackageWriter() writer.model = model return try writer.finalize() } - /// Apply a 3MF transform chain (parent→child) to a point. Each `Matrix3D` is a 4×3 row-major - /// affine (rows 0–2 = linear part, row 3 = translation); child-most transform applies first. + /// Apply a 3MF transform chain (parent→child) to a point. + /// + /// Each `Matrix3D` is a 4×3 row-major affine (rows 0–2 = linear part, row 3 = translation); + /// child-most transform applies first. private static func placed(_ p0: SIMD3, _ transforms: [Matrix3D]) -> SIMD3 { var p = p0 for m in transforms.reversed() { @@ -62,9 +73,13 @@ extension MeshIO { return p } - /// Run an async operation to completion from a synchronous context. ThreeMF's loader is async - /// (it unzips + resolves referenced model parts); MeshIO's `load` API is synchronous. - private static func runBlocking(_ op: @Sendable @escaping () async throws -> T) throws -> T { + /// Run an async operation to completion from a synchronous context. + /// + /// ThreeMF's loader is async (it unzips + resolves referenced model parts); MeshIO's `load` + /// API is synchronous. + private static func runBlocking(_ op: @Sendable @escaping () async throws -> T) + throws -> T + { let box = ResultBox() let sem = DispatchSemaphore(value: 0) Task.detached { diff --git a/Sources/OCCTSwiftIO/CADBodyMetadata.swift b/Sources/OCCTSwiftIO/CADBodyMetadata.swift index a39da54..42995fa 100644 --- a/Sources/OCCTSwiftIO/CADBodyMetadata.swift +++ b/Sources/OCCTSwiftIO/CADBodyMetadata.swift @@ -3,11 +3,11 @@ // // Pure-data record produced by the bridge layer (OCCTSwiftTools) for sub-body // selection (face / edge / vertex). Lives here so the type itself doesn't pull -// in OCCTSwiftViewport — the bridge consumes it, doesn't need Viewport types +// in OCCTSwiftViewport: the bridge consumes it, doesn't need Viewport types // to express it. -import simd import OCCTSwift +import simd /// Metadata extracted from OCCTSwift for sub-body selection (face, edge, vertex). /// @@ -26,9 +26,10 @@ public struct CADBodyMetadata: Sendable { /// Source-shape vertex positions, indexed parallel to `shape.vertices()`. public let vertices: [SIMD3] - /// Optional per-face area + per-edge length report. Populated only when - /// the bridge call passes `includeMeasurements: true`. Used by AIS' dimension - /// widget to label picked faces/edges with their scalar measurement. + /// Optional per-face area + per-edge length report. + /// + /// Populated only when the bridge call passes `includeMeasurements: true`. Used by AIS' + /// dimension widget to label picked faces/edges with their scalar measurement. public let measurements: ShapeMeasurements? public init( diff --git a/Sources/OCCTSwiftIO/DXFLoader.swift b/Sources/OCCTSwiftIO/DXFLoader.swift index 6cf5511..6720cf6 100644 --- a/Sources/OCCTSwiftIO/DXFLoader.swift +++ b/Sources/OCCTSwiftIO/DXFLoader.swift @@ -1,24 +1,26 @@ // DXFLoader.swift // OCCTSwiftIO // -// Loads DXF (AutoCAD Drawing Interchange Format) — a 2D vector drawing — into OCCT geometry. Like JWW, +// Loads DXF (AutoCAD Drawing Interchange Format), a 2D vector drawing, into OCCT geometry. Like JWW, // DXF is not a B-Rep solid or a mesh; it's lines, arcs/circles, ellipses, points and text in a plane. // We map the drawable curve entities to OCCT edges (in the Z=0 plane) and return them as one compound // `Shape`. Text is not converted to geometry (it would need font outlines). Reading is delegated to // SwiftDXF, which is validated bit-exact against the ezdxf reference reader. import Foundation -import simd import OCCTSwift // Re-export so `import OCCTSwiftIO` brings the DXF entity model (DXF.Drawing / DXF.Entity, with TEXT -// strings, per-entity layers, $INSUNITS and extents) into scope — that entity model is the primary +// strings, per-entity layers, $INSUNITS and extents) into scope: that entity model is the primary // deliverable; the OCCT `Shape` compound below is the optional convenience. @_exported import SwiftDXF +import simd public enum DXFLoader { /// Read a DXF file into the neutral SwiftDXF entity model (geometry + TEXT + layers + header). - /// This is the entity-level surface; use ``load(from:)`` (or `ShapeLoader`) for the OCCT compound. + /// + /// This is the entity-level surface; use ``load(from:)`` (or `ShapeLoader`) for the OCCT + /// compound. public static func readEntities(from url: URL) throws -> DXF.Drawing { try DXF.read(contentsOf: url) } @@ -33,49 +35,61 @@ public enum DXFLoader { for entity in drawing.entities { switch entity { - case let .line(a, b, _, _): - if a != b, let s = Shape.edgeFromPoints(p3(a.x, a.y), p3(b.x, b.y)) { shapes.append(s) } + case .line(let a, let b, _, _): + if a != b, let s = Shape.edgeFromPoints(p3(a.x, a.y), p3(b.x, b.y)) { + shapes.append(s) + } - case let .circle(c, r, _, _): + case .circle(let c, let r, _, _): guard r > 0 else { continue } - if let s = Shape.edgeFromCircle(center: p3(c.x, c.y), axis: axis, radius: r, p1: 0, p2: 2 * .pi) { + if let s = Shape.edgeFromCircle( + center: p3(c.x, c.y), axis: axis, radius: r, p1: 0, p2: 2 * .pi) + { shapes.append(s) } - case let .arc(c, r, startDeg, endDeg, _, _): + case .arc(let c, let r, let startDeg, let endDeg, _, _): guard r > 0 else { continue } // DXF arcs sweep CCW from start to end (degrees); unwrap so the end exceeds the start. let p1 = startDeg * deg var p2 = endDeg * deg if p2 <= p1 { p2 += 2 * .pi } - if let s = Shape.edgeFromCircle(center: p3(c.x, c.y), axis: axis, radius: r, p1: p1, p2: p2) { + if let s = Shape.edgeFromCircle( + center: p3(c.x, c.y), axis: axis, radius: r, p1: p1, p2: p2) + { shapes.append(s) } - case let .ellipse(c, major, ratio, startParam, endParam, _, _): + case .ellipse(let c, let major, let ratio, let startParam, let endParam, _, _): // OCCT's edgeFromEllipse can't express a rotated major axis, so polyline it (carrying the // tilt), matching JWWLoader's treatment of elliptical arcs. let majorR = (major.x * major.x + major.y * major.y).squareRoot() guard majorR > 0 else { continue } - emitEllipsePolyline(cx: c.x, cy: c.y, majorR: majorR, ratio: ratio, - tilt: atan2(major.y, major.x), - start: startParam, end: endParam, into: &shapes) + emitEllipsePolyline( + cx: c.x, cy: c.y, majorR: majorR, ratio: ratio, + tilt: atan2(major.y, major.x), + start: startParam, end: endParam, into: &shapes) - case let .point(p, _, _): + case .point(let p, _, _): if let v = Shape.vertex(at: p3(p.x, p.y)) { shapes.append(v) } - case let .polyline(verts, closed, _, _): + case .polyline(let verts, let closed, _, _): guard verts.count >= 2 else { - if let v = verts.first, let vx = Shape.vertex(at: p3(v.point.x, v.point.y)) { shapes.append(vx) } + if let v = verts.first, let vx = Shape.vertex(at: p3(v.point.x, v.point.y)) { + shapes.append(vx) + } continue } let n = verts.count let segs = closed ? n : n - 1 for i in 0.., into shapes: inout [Shape]) { + /// Emit one circular-arc edge for a bulged polyline segment. + /// + /// `bulge = tan(θ/4)` where θ is the included angle, swept CCW from `a` to `b` (the + /// AutoCAD/ezdxf convention). Center derived as `h = (1/b − b)/2`; the arc is added over its + /// angular interval. + private static func emitBulgeArc( + _ a: DXF.Point, _ b: DXF.Point, bulge: Double, + axis: SIMD3, into shapes: inout [Shape] + ) { let h = (1 / bulge - bulge) / 2 let cx = (a.x + b.x) / 2 + h * (a.y - b.y) / 2 let cy = (a.y + b.y) / 2 + h * (b.x - a.x) / 2 @@ -107,22 +128,31 @@ public enum DXFLoader { guard r > 0 else { return } let phi1 = atan2(a.y - cy, a.x - cx) let theta = 4 * atan(bulge) - let lo = min(phi1, phi1 + theta), hi = max(phi1, phi1 + theta) - if let s = Shape.edgeFromCircle(center: SIMD3(cx, cy, 0), axis: axis, radius: r, p1: lo, p2: hi) { + let lo = min(phi1, phi1 + theta) + let hi = max(phi1, phi1 + theta) + if let s = Shape.edgeFromCircle( + center: SIMD3(cx, cy, 0), axis: axis, radius: r, p1: lo, p2: hi) + { shapes.append(s) } } - /// Sample an elliptical arc into edges. `start`/`end` are the DXF ellipse parameters (radians); - /// `tilt` rotates the major axis. Mirrors JWWLoader.emitEllipsePolyline. - private static func emitEllipsePolyline(cx: Double, cy: Double, majorR: Double, ratio: Double, - tilt: Double, start: Double, end: Double, into shapes: inout [Shape]) { + /// Sample an elliptical arc into edges. + /// + /// `start`/`end` are the DXF ellipse parameters (radians); `tilt` rotates the major axis. + /// Mirrors JWWLoader.emitEllipsePolyline. + private static func emitEllipsePolyline( + cx: Double, cy: Double, majorR: Double, ratio: Double, + tilt: Double, start: Double, end: Double, into shapes: inout [Shape] + ) { let n = 48 var a1 = end if a1 <= start { a1 += 2 * .pi } - let ct = cos(tilt), st = sin(tilt) + let ct = cos(tilt) + let st = sin(tilt) func pt(_ a: Double) -> SIMD3 { - let ex = majorR * cos(a), ey = majorR * ratio * sin(a) + let ex = majorR * cos(a) + let ey = majorR * ratio * sin(a) return SIMD3(cx + ex * ct - ey * st, cy + ex * st + ey * ct, 0) } var prev = pt(start) diff --git a/Sources/OCCTSwiftIO/JWWLoader.swift b/Sources/OCCTSwiftIO/JWWLoader.swift index c18c80e..221cd02 100644 --- a/Sources/OCCTSwiftIO/JWWLoader.swift +++ b/Sources/OCCTSwiftIO/JWWLoader.swift @@ -1,15 +1,15 @@ // JWWLoader.swift // OCCTSwiftIO // -// Loads JWW (Jw_cad) — a 2D vector drawing — into OCCT geometry. JWW is not a B-Rep solid or a mesh; +// Loads JWW (Jw_cad), a 2D vector drawing, into OCCT geometry. JWW is not a B-Rep solid or a mesh; // it's lines, arcs/circles, points and text in a plane. We map the drawable curve entities to OCCT // edges (in the Z=0 plane) and return them as one compound `Shape`. Text is not converted to geometry // (it would need font outlines); block inserts are not yet expanded. Reading is delegated to SwiftJWW. import Foundation -import simd import OCCTSwift import SwiftJWW +import simd enum JWWLoader { @@ -23,45 +23,62 @@ enum JWWLoader { func emit(_ entity: JWW.Entity) { switch entity { - case let .line(a, b, _, _): - if a != b, let s = Shape.edgeFromPoints(p3(a.x, a.y), p3(b.x, b.y)) { shapes.append(s) } - case let .arc(c, r, start, sweep, tilt, ratio, full, _, _): - let cx = c.x, cy = c.y + case .line(let a, let b, _, _): + if a != b, let s = Shape.edgeFromPoints(p3(a.x, a.y), p3(b.x, b.y)) { + shapes.append(s) + } + case .arc(let c, let r, let start, let sweep, let tilt, let ratio, let full, _, _): + let cx = c.x + let cy = c.y guard r > 0 else { return } - if abs(ratio - 1) < 1e-9 { // circle / circular arc + if abs(ratio - 1) < 1e-9 { // circle / circular arc let (p1, p2): (Double, Double) - if full || abs(abs(sweep) - 2 * .pi) < 1e-6 { (p1, p2) = (0, 2 * .pi) } - else { + if full || abs(abs(sweep) - 2 * .pi) < 1e-6 { + (p1, p2) = (0, 2 * .pi) + } else { // start/sweep are measured from the tilt axis (matches the DXF mapping); span CCW. - let a = tilt + start, b = tilt + start + sweep + let a = tilt + start + let b = tilt + start + sweep (p1, p2) = (min(a, b), max(a, b)) } - if let s = Shape.edgeFromCircle(center: SIMD3(cx, cy, 0), axis: axis, radius: r, p1: p1, p2: p2) { shapes.append(s) } - } else { // ellipse → polyline (carries tilt) - emitEllipsePolyline(cx: cx, cy: cy, r: r, ratio: ratio, tilt: tilt, - start: start, sweep: sweep, full: full, into: &shapes) + if let s = Shape.edgeFromCircle( + center: SIMD3(cx, cy, 0), axis: axis, radius: r, p1: p1, p2: p2) + { + shapes.append(s) + } + } else { // ellipse → polyline (carries tilt) + emitEllipsePolyline( + cx: cx, cy: cy, r: r, ratio: ratio, tilt: tilt, + start: start, sweep: sweep, full: full, into: &shapes) } - case let .point(pt, _, _): + case .point(let pt, _, _): if let v = Shape.vertex(at: p3(pt.x, pt.y)) { shapes.append(v) } - case let .dimension(parts, _): - for part in parts { emit(part) } // dimension line + witness lines + text(skipped) + case .dimension(let parts, _): + for part in parts { emit(part) } // dimension line + witness lines + text(skipped) case .text, .insert: - break // text → no geometry; inserts not yet expanded + break // text → no geometry; inserts not yet expanded } } for entity in drawing.entities { emit(entity) } - guard let compound = Shape.compound(shapes) else { return ShapeLoadResult(shapesWithColors: []) } + guard let compound = Shape.compound(shapes) else { + return ShapeLoadResult(shapesWithColors: []) + } return ShapeLoadResult(shapesWithColors: [(shape: compound, color: nil)]) } - private static func emitEllipsePolyline(cx: Double, cy: Double, r: Double, ratio: Double, tilt: Double, - start: Double, sweep: Double, full: Bool, into shapes: inout [Shape]) { + private static func emitEllipsePolyline( + cx: Double, cy: Double, r: Double, ratio: Double, tilt: Double, + start: Double, sweep: Double, full: Bool, into shapes: inout [Shape] + ) { let n = 48 - let a0 = full ? 0 : start, a1 = full ? 2 * .pi : start + sweep - let ct = cos(tilt), st = sin(tilt) + let a0 = full ? 0 : start + let a1 = full ? 2 * .pi : start + sweep + let ct = cos(tilt) + let st = sin(tilt) func pt(_ a: Double) -> SIMD3 { - let ex = r * cos(a), ey = r * ratio * sin(a) + let ex = r * cos(a) + let ey = r * ratio * sin(a) return SIMD3(cx + ex * ct - ey * st, cy + ex * st + ey * ct, 0) } var prev = pt(a0) diff --git a/Sources/OCCTSwiftIO/MLExport.swift b/Sources/OCCTSwiftIO/MLExport.swift index 34158b7..3a59288 100644 --- a/Sources/OCCTSwiftIO/MLExport.swift +++ b/Sources/OCCTSwiftIO/MLExport.swift @@ -4,12 +4,12 @@ // Consumption-side ML repacking of `BRepGraph` data: flat vertex // positions, per-edge boundary/manifold flags, COO-format adjacency for // face/edge/vertex incidence. Lifted from OCCTSwift per OCCTSwiftIO#1 -// (supersedes OCCTSwift#71) — fits this package's headless charter. +// (supersedes OCCTSwift#71); fits this package's headless charter. // // `FaceGridSample` / `sampleFaceUVGrid` (and `sampleEdgeCurve`) intentionally // stay in OCCTSwift: they call `OCCTBRepGraphSampleFaceUVGrid` / `*SampleEdgeCurve` // on `BRepGraph.handle`, which is `internal` to the OCCTSwift module. -// Lifting them would require widening kernel visibility — out of scope per +// Lifting them would require widening kernel visibility: out of scope per // the partial-lift decision recorded on the issue. import Foundation @@ -119,18 +119,18 @@ extension BRepGraph { /// Export graph as JSON data for ML pipelines. public func exportJSON() -> Data? { - let export_ = exportForML() + let exported = exportForML() let codable = CodableGraphExport( - vertexPositions: export_.vertexPositions, - edgeBoundaryFlags: export_.edgeBoundaryFlags, - edgeManifoldFlags: export_.edgeManifoldFlags, - faceAdjacentFaces: export_.faceAdjacentFaces, - faceToEdgeSources: export_.faceToEdge.sources, - faceToEdgeTargets: export_.faceToEdge.targets, - edgeToVertexSources: export_.edgeToVertex.sources, - edgeToVertexTargets: export_.edgeToVertex.targets, - faceToFaceSources: export_.faceToFace.sources, - faceToFaceTargets: export_.faceToFace.targets + vertexPositions: exported.vertexPositions, + edgeBoundaryFlags: exported.edgeBoundaryFlags, + edgeManifoldFlags: exported.edgeManifoldFlags, + faceAdjacentFaces: exported.faceAdjacentFaces, + faceToEdgeSources: exported.faceToEdge.sources, + faceToEdgeTargets: exported.faceToEdge.targets, + edgeToVertexSources: exported.edgeToVertex.sources, + edgeToVertexTargets: exported.edgeToVertex.targets, + faceToFaceSources: exported.faceToFace.sources, + faceToFaceTargets: exported.faceToFace.targets ) return try? JSONEncoder().encode(codable) } diff --git a/Sources/OCCTSwiftIO/ShapeLoader.swift b/Sources/OCCTSwiftIO/ShapeLoader.swift index 1fecfbf..fb25c71 100644 --- a/Sources/OCCTSwiftIO/ShapeLoader.swift +++ b/Sources/OCCTSwiftIO/ShapeLoader.swift @@ -1,32 +1,37 @@ // ShapeLoader.swift // OCCTSwiftIO // -// Headless CAD file loader. Returns shapes + colors + AP242 metadata — +// Headless CAD file loader. Returns shapes + colors + AP242 metadata, // no `ViewportBody`, no Viewport dep. The bridge layer (OCCTSwiftTools) // wraps this with `ViewportBody` production for renderable consumers. import Foundation -import simd import OCCTSwift +import simd -/// Result of loading a CAD file via `ShapeLoader`. Pure shape + document data. +/// Result of loading a CAD file via `ShapeLoader`. /// -/// Renderable bodies live in `OCCTSwiftTools.CADLoadResult` — this type is -/// what headless consumers (CLIs, batch tools, server-side pipelines) use -/// when they don't need a Metal-renderable representation. +/// Pure shape + document data. Renderable bodies live in `OCCTSwiftTools.CADLoadResult`: this +/// type is what headless consumers (CLIs, batch tools, server-side pipelines) use when they don't +/// need a Metal-renderable representation. public struct ShapeLoadResult: @unchecked Sendable { /// Source shapes paired with their per-shape color (nil when the format /// carries no color information, e.g. STL / OBJ / BREP / IGES). public var shapesWithColors: [(shape: Shape, color: SIMD4?)] - /// AP242 GD&T dimensions extracted from the document. Empty for non-STEP - /// formats and for STEP files without GD&T annotations. + /// AP242 GD&T dimensions extracted from the document. + /// + /// Empty for non-STEP formats and for STEP files without GD&T annotations. public var dimensions: [DimensionInfo] - /// AP242 geometric tolerances. Empty for non-STEP formats. + /// AP242 geometric tolerances. + /// + /// Empty for non-STEP formats. public var geomTolerances: [GeomToleranceInfo] - /// AP242 datum references. Empty for non-STEP formats. + /// AP242 datum references. + /// + /// Empty for non-STEP formats. public var datums: [DatumInfo] /// The decoded manifest, when this result came from `loadFromManifest`. @@ -53,9 +58,11 @@ public struct ShapeLoadResult: @unchecked Sendable { /// Loads CAD files via OCCTSwift, returning shapes + document metadata. public enum ShapeLoader { - /// Loads a CAD file. STEP and IGES honor the `progress` observer; STL / - /// OBJ / BREP loaders are single-call upstream and don't surface progress. - /// If `progress.shouldCancel()` returns `true`, throws `ImportError.cancelled`. + /// Loads a CAD file. + /// + /// STEP and IGES honor the `progress` observer; STL / OBJ / BREP loaders are single-call + /// upstream and don't surface progress. If `progress.shouldCancel()` returns `true`, throws + /// `ImportError.cancelled`. public static func load( from url: URL, format: CADFileFormat, @@ -66,9 +73,10 @@ public enum ShapeLoader { }.value } - /// Robust variant — uses the sewing/healing path for STL and IGES (which - /// commonly ship with gaps OCCT's basic importer can't close). For STEP / - /// OBJ / BREP this is identical to `load(from:format:progress:)`. + /// Robust variant: uses the sewing/healing path for STL and IGES (which commonly ship with + /// gaps OCCT's basic importer can't close). + /// + /// For STEP / OBJ / BREP this is identical to `load(from:format:progress:)`. public static func loadRobust( from url: URL, format: CADFileFormat, @@ -80,8 +88,9 @@ public enum ShapeLoader { } /// Loads bodies from a script manifest (manifest.json + sibling BREP files). - /// Resolves each `BodyDescriptor.file` relative to the manifest's directory. - /// Skips entries whose file is missing. + /// + /// Resolves each `BodyDescriptor.file` relative to the manifest's directory. Skips entries + /// whose file is missing. public static func loadFromManifest(at url: URL) throws -> ShapeLoadResult { let data = try Data(contentsOf: url) let decoder = JSONDecoder() @@ -125,7 +134,8 @@ public enum ShapeLoader { } } - private static func loadSTEP(from url: URL, progress: ImportProgress?) throws -> ShapeLoadResult { + private static func loadSTEP(from url: URL, progress: ImportProgress?) throws -> ShapeLoadResult + { let doc = try Document.load(from: url, progress: progress) let pairs = doc.shapesWithColors() let shapesWithColors: [(shape: Shape, color: SIMD4?)] = pairs.map { pair in @@ -160,8 +170,12 @@ public enum ShapeLoader { return ShapeLoadResult(shapesWithColors: bodyEntries(from: shape)) } - private static func loadIGES(from url: URL, progress: ImportProgress?, robust: Bool) throws -> ShapeLoadResult { - let shape = try (robust + private static func loadIGES(from url: URL, progress: ImportProgress?, robust: Bool) throws + -> ShapeLoadResult + { + let shape = + try + (robust ? Shape.loadIGESRobust(from: url, progress: progress) : Shape.loadIGES(from: url, progress: progress)) return ShapeLoadResult(shapesWithColors: bodyEntries(from: shape)) @@ -171,10 +185,10 @@ public enum ShapeLoader { /// per-body granularity the STEP path already gives via `Document.shapesWithColors()`. /// /// Since OCCTSwift v1.11.3 the robust importers return a `Compound` of solids - /// for a multibody file (before then they silently dropped all but the first — + /// for a multibody file (before then they silently dropped all but the first, /// SecondMouseAU/OCCTSwift#302). A plain `Solid`, or a compound that carries no /// solids (e.g. a raw-mesh STL that came back as loose faces), stays a single - /// entry — the caller still gets the whole shape, just not split. These formats + /// entry: the caller still gets the whole shape, just not split. These formats /// carry no color, so every entry is `nil`. private static func bodyEntries(from shape: Shape) -> [(shape: Shape, color: SIMD4?)] { let bodies = shape.shapeType == .solid ? [shape] : shape.subShapes(ofType: .solid) diff --git a/Tests/MeshIOTests/MeshIOTests.swift b/Tests/MeshIOTests/MeshIOTests.swift index 4093e82..da53e3f 100644 --- a/Tests/MeshIOTests/MeshIOTests.swift +++ b/Tests/MeshIOTests/MeshIOTests.swift @@ -1,5 +1,6 @@ -import Testing import Foundation +import Testing + @testable import MeshIO @Suite("MeshIO round-trips") @@ -76,55 +77,68 @@ struct MeshIOTests { #expect(MeshFormat(fileExtension: "PLY") == .ply) #expect(MeshFormat(fileExtension: "3mf") == .threeMF) #expect(MeshFormat(fileExtension: "dwg") == nil) - #expect(MeshFormat.pmx.canWrite == false) // source-only + #expect(MeshFormat.pmx.canWrite == false) // source-only #expect(MeshFormat.threeMF.canWrite == true) } /// Builds a minimal valid PMX 2.0 byte buffer for `Self.quad` (2 triangles), split across - /// `materials` (per-material index-buffer counts, must sum to `indices.count`). Encoding UTF-8, - /// all index widths 1 byte, no additional UVs, BDEF1 skinning, no textures. + /// `materials` (per-material index-buffer counts, must sum to `indices.count`). + /// + /// Encoding UTF-8, all index widths 1 byte, no additional UVs, BDEF1 skinning, no textures. static func makePMX(materials: [Int]) -> Data { var d = Data() func u8(_ v: UInt8) { d.append(v) } - func i32(_ v: Int32) { var x = v; withUnsafeBytes(of: &x) { d.append(contentsOf: $0) } } - func f32(_ v: Float) { var x = v; withUnsafeBytes(of: &x) { d.append(contentsOf: $0) } } + func i32(_ v: Int32) { + var x = v + withUnsafeBytes(of: &x) { d.append(contentsOf: $0) } + } + func f32(_ v: Float) { + var x = v + withUnsafeBytes(of: &x) { d.append(contentsOf: $0) } + } - d.append(contentsOf: [0x50, 0x4D, 0x58, 0x20]) // "PMX " + d.append(contentsOf: [0x50, 0x4D, 0x58, 0x20]) // "PMX " f32(2.0) - u8(8) // setting count - u8(1) // encoding = UTF-8 - u8(0) // additional UV = 0 - for _ in 0..<6 { u8(1) } // index sizes = 1 - for _ in 0..<4 { i32(0) } // 4 empty model-info strings + u8(8) // setting count + u8(1) // encoding = UTF-8 + u8(0) // additional UV = 0 + for _ in 0..<6 { u8(1) } // index sizes = 1 + for _ in 0..<4 { i32(0) } // 4 empty model-info strings i32(Int32(quad.positions.count)) for v in quad.positions { - f32(v.x); f32(v.y); f32(v.z) // position - f32(0); f32(0); f32(0) // normal - f32(0); f32(0) // uv - u8(0) // skinning type BDEF1 - u8(0) // bone index (1 byte) - f32(0) // edge scale + f32(v.x) + f32(v.y) + f32(v.z) // position + f32(0) + f32(0) + f32(0) // normal + f32(0) + f32(0) // uv + u8(0) // skinning type BDEF1 + u8(0) // bone index (1 byte) + f32(0) // edge scale } i32(Int32(quad.indices.count)) d.append(contentsOf: quad.indices.map { UInt8($0) }) - i32(0) // texture count = 0 + i32(0) // texture count = 0 i32(Int32(materials.count)) for surfaceCount in materials { - i32(0); i32(0) // name, english name (empty) - for _ in 0..<11 { f32(0) } // diffuse(4) + specular(3) + specularity(1) + ambient(3) - u8(0) // draw flags - for _ in 0..<4 { f32(0) } // edge color - f32(0) // edge scale - u8(0) // texture index (1 byte) - u8(0) // sphere texture index (1 byte) - u8(0) // sphere mode - u8(1) // shared toon flag = internal ref - u8(0) // toon value (1 byte, internal ref) - i32(0) // memo (empty) + i32(0) + i32(0) // name, english name (empty) + for _ in 0..<11 { f32(0) } // diffuse(4) + specular(3) + specularity(1) + ambient(3) + u8(0) // draw flags + for _ in 0..<4 { f32(0) } // edge color + f32(0) // edge scale + u8(0) // texture index (1 byte) + u8(0) // sphere texture index (1 byte) + u8(0) // sphere mode + u8(1) // shared toon flag = internal ref + u8(0) // toon value (1 byte, internal ref) + i32(0) // memo (empty) i32(Int32(surfaceCount)) } return d @@ -138,10 +152,11 @@ struct MeshIOTests { let m = try MeshIO.load(contentsOf: url) #expect(m.triangleCount == 2) - #expect(m.submeshes == [ - Submesh(indexOffset: 0, indexCount: 3, materialIndex: 0), - Submesh(indexOffset: 3, indexCount: 3, materialIndex: 1), - ]) + #expect( + m.submeshes == [ + Submesh(indexOffset: 0, indexCount: 3, materialIndex: 0), + Submesh(indexOffset: 3, indexCount: 3, materialIndex: 1), + ]) // The invariant worth re-checking downstream of any future welding/filtering: offsets are // contiguous and the runs fully cover the index buffer. #expect(m.submeshes.reduce(0) { $0 + $1.indexCount } == m.indices.count) diff --git a/Tests/OCCTSwiftIOTests/DXFLoaderTests.swift b/Tests/OCCTSwiftIOTests/DXFLoaderTests.swift index 14534f2..5e59a3d 100644 --- a/Tests/OCCTSwiftIOTests/DXFLoaderTests.swift +++ b/Tests/OCCTSwiftIOTests/DXFLoaderTests.swift @@ -1,17 +1,24 @@ -import Testing import Foundation import OCCTSwift +import Testing + @testable import OCCTSwiftIO @Suite("DXF vector loader") struct DXFLoaderTests { /// Loads a real DXF drawing if available locally and checks it maps to a non-empty OCCT compound - /// whose in-plane extent matches the drawing. (Hermetic synthetic + ezdxf-oracle coverage lives in - /// SwiftDXF; the per-file entity-count oracle lives there too.) + /// whose in-plane extent matches the drawing. + /// + /// (Hermetic synthetic + ezdxf-oracle coverage lives in SwiftDXF; the per-file entity-count + /// oracle lives there too). @Test func realDrawingExtent() throws { - let url = URL(fileURLWithPath: NSString(string: "~/Documents/Modelling/2DFiles/dd12.dxf").expandingTildeInPath) + let url = URL( + fileURLWithPath: NSString(string: "~/Documents/Modelling/2DFiles/dd12.dxf") + .expandingTildeInPath) try withKnownIssue("dd12.dxf not present in CI", isIntermittent: true) { - guard FileManager.default.fileExists(atPath: url.path) else { throw CancellationError() } + guard FileManager.default.fileExists(atPath: url.path) else { + throw CancellationError() + } let result = try DXFLoader.load(from: url) let shape = try #require(result.shapes.first) let b = shape.bounds @@ -19,32 +26,39 @@ struct DXFLoaderTests { // compound's tight bounds sit within those conservative extents. #expect(b.min.x > -170 && b.min.x < -100) #expect(b.max.x < 180 && b.max.x > 100) - #expect(b.max.x - b.min.x > 200) // a real, non-empty drawing - #expect(abs(b.min.z) < 1e-6 && abs(b.max.z) < 1e-6) // flat: all geometry in the Z=0 plane + #expect(b.max.x - b.min.x > 200) // a real, non-empty drawing + // flat: all geometry in the Z=0 plane + #expect(abs(b.min.z) < 1e-6 && abs(b.max.z) < 1e-6) } } /// The #11 primary deliverable: the entity model (TEXT strings + per-entity layers) reachable /// through the OCCTSwiftIO import surface, not just the lossy Shape compound. @Test func entityLevelReadKeepsTextAndLayers() throws { - let url = URL(fileURLWithPath: NSString(string: "~/Documents/Modelling/2DFiles/dd12.dxf").expandingTildeInPath) + let url = URL( + fileURLWithPath: NSString(string: "~/Documents/Modelling/2DFiles/dd12.dxf") + .expandingTildeInPath) try withKnownIssue("dd12.dxf not present in CI", isIntermittent: true) { - guard FileManager.default.fileExists(atPath: url.path) else { throw CancellationError() } - let dwg = try DXFLoader.readEntities(from: url) // DXF.* in scope via OCCTSwiftIO + guard FileManager.default.fileExists(atPath: url.path) else { + throw CancellationError() + } + let dwg = try DXFLoader.readEntities(from: url) // DXF.* in scope via OCCTSwiftIO var texts: [String] = [] var layers = Set() for e in dwg.entities { switch e { - case let .text(_, _, _, s, layer, _): texts.append(s); layers.insert(layer) - case let .line(_, _, layer, _): layers.insert(layer) - case let .arc(_, _, _, _, layer, _): layers.insert(layer) - case let .circle(_, _, layer, _): layers.insert(layer) + case .text(_, _, _, let s, let layer, _): + texts.append(s) + layers.insert(layer) + case .line(_, _, let layer, _): layers.insert(layer) + case .arc(_, _, _, _, let layer, _): layers.insert(layer) + case .circle(_, _, let layer, _): layers.insert(layer) default: break } } - #expect(texts.count > 10) // TEXT entities preserved... - #expect(texts.allSatisfy { !$0.isEmpty }) // ...with their (CP932-decoded) strings - #expect(layers.count > 1) // entities span multiple layers + #expect(texts.count > 10) // TEXT entities preserved... + #expect(texts.allSatisfy { !$0.isEmpty }) // ...with their (CP932-decoded) strings + #expect(layers.count > 1) // entities span multiple layers } } @@ -52,41 +66,42 @@ struct DXFLoaderTests { /// (0,0)→(2,0) chord is a CCW semicircle dipping to apex (1,−1)). @Test func bulgePolylineBecomesArc() throws { let dxf = """ - 0 - SECTION - 2 - ENTITIES - 0 - LWPOLYLINE - 90 - 2 - 70 - 0 - 10 - 0.0 - 20 - 0.0 - 42 - 1.0 - 10 - 2.0 - 20 - 0.0 - 0 - ENDSEC - 0 - EOF - """ - let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("bulge-\(UUID().uuidString).dxf") + 0 + SECTION + 2 + ENTITIES + 0 + LWPOLYLINE + 90 + 2 + 70 + 0 + 10 + 0.0 + 20 + 0.0 + 42 + 1.0 + 10 + 2.0 + 20 + 0.0 + 0 + ENDSEC + 0 + EOF + """ + let tmp = FileManager.default.temporaryDirectory.appendingPathComponent( + "bulge-\(UUID().uuidString).dxf") try dxf.write(to: tmp, atomically: true, encoding: .utf8) defer { try? FileManager.default.removeItem(at: tmp) } let result = try DXFLoader.load(from: tmp) let shape = try #require(result.shapes.first) let b = shape.bounds - #expect(abs(b.min.x) < 1e-6 && abs(b.max.x - 2) < 1e-6) // chord endpoints - #expect(b.min.y < -0.99 && b.min.y > -1.01) // semicircle apex at y = −1 - #expect(abs(b.max.y) < 1e-6) // arc stays on/below the chord + #expect(abs(b.min.x) < 1e-6 && abs(b.max.x - 2) < 1e-6) // chord endpoints + #expect(b.min.y < -0.99 && b.min.y > -1.01) // semicircle apex at y = −1 + #expect(abs(b.max.y) < 1e-6) // arc stays on/below the chord } @Test func formatDetection() { diff --git a/Tests/OCCTSwiftIOTests/ExportManagerTests.swift b/Tests/OCCTSwiftIOTests/ExportManagerTests.swift index eaf79f4..4376845 100644 --- a/Tests/OCCTSwiftIOTests/ExportManagerTests.swift +++ b/Tests/OCCTSwiftIOTests/ExportManagerTests.swift @@ -1,12 +1,13 @@ -import Testing import Foundation import OCCTSwift +import Testing + @testable import OCCTSwiftIO @Suite("ExportManager") struct ExportManagerTests { - /// Stage temp files under /tmp per OCCTSwift convention — never under Tests/. + /// Stage temp files under /tmp per OCCTSwift convention, never under Tests/. private static func tempURL(suffix: String) -> URL { URL(fileURLWithPath: "/tmp/occtswiftio-\(UUID().uuidString)-\(suffix)") } @@ -92,7 +93,7 @@ struct ExportManagerTests { @Test func t_exportMultipleShapesGetsNumberedFilenames() async throws { guard let a = Shape.box(width: 1, height: 1, depth: 1), - let b = Shape.cylinder(radius: 1, height: 2) + let b = Shape.cylinder(radius: 1, height: 2) else { Issue.record("primitive constructors returned nil") return @@ -103,7 +104,8 @@ struct ExportManagerTests { let prefix = baseURL.deletingPathExtension().lastPathComponent if let entries = try? FileManager.default.contentsOfDirectory(atPath: dir.path) { for entry in entries where entry.hasPrefix(prefix) { - try? FileManager.default.removeItem(atPath: dir.appendingPathComponent(entry).path) + try? FileManager.default.removeItem( + atPath: dir.appendingPathComponent(entry).path) } } } @@ -124,7 +126,8 @@ struct ExportManagerTests { try await ExportManager.export(shapes: [], format: .obj, to: url) - #expect(!FileManager.default.fileExists(atPath: url.path), - "empty input should not write a file") + #expect( + !FileManager.default.fileExists(atPath: url.path), + "empty input should not write a file") } } diff --git a/Tests/OCCTSwiftIOTests/ImportProgressTests.swift b/Tests/OCCTSwiftIOTests/ImportProgressTests.swift index 37ed24c..bf381d1 100644 --- a/Tests/OCCTSwiftIOTests/ImportProgressTests.swift +++ b/Tests/OCCTSwiftIOTests/ImportProgressTests.swift @@ -1,6 +1,7 @@ -import Testing import Foundation import OCCTSwift +import Testing + @testable import OCCTSwiftIO @Suite("ImportProgress") @@ -45,8 +46,9 @@ struct ImportProgressTests { // MARK: - Recorder helper for end-to-end tests - /// Test-only progress observer. Records every callback so we can assert - /// after the import returns. + /// Test-only progress observer. + /// + /// Records every callback so we can assert after the import returns. final class Recorder: ImportProgress, @unchecked Sendable { let lock = NSLock() private var _fractions: [Double] = [] @@ -54,26 +56,31 @@ struct ImportProgressTests { var cancelOnFraction: Double? = nil var fractions: [Double] { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } return _fractions } var lastStep: String? { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } return _steps.last } func progress(fraction: Double, step: String) { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } _fractions.append(fraction) _steps.append(step) } func shouldCancel() -> Bool { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } if let threshold = cancelOnFraction, - let latest = _fractions.last, - latest >= threshold { + let latest = _fractions.last, + latest >= threshold + { return true } return false @@ -85,7 +92,7 @@ struct ImportProgressTests { @Test func t_stepLoadFiresProgressCallback() async throws { // Round-trip: build a box, export to STEP, re-import via ShapeLoader // with a recorder. We don't assert specific fraction values (OCCT's - // progress granularity isn't documented) — just that progress fired. + // progress granularity isn't documented), just that progress fired. guard let box = Shape.box(width: 10, height: 10, depth: 10) else { Issue.record("Shape.box returned nil") return @@ -101,8 +108,9 @@ struct ImportProgressTests { #expect(result.shapes.count >= 1, "import produced at least one shape") // Even a small box should yield at least one progress call from the // OCCT reader; a complete one usually finishes near 1.0. - #expect(!recorder.fractions.isEmpty, - "progress observer should fire at least once during STEP import") + #expect( + !recorder.fractions.isEmpty, + "progress observer should fire at least once during STEP import") if let last = recorder.fractions.last { #expect(last <= 1.0 + 1e-6, "fraction stays in [0, 1]") #expect(last >= 0.0) @@ -123,13 +131,13 @@ struct ImportProgressTests { // Recorder cancels as soon as it sees any progress callback. let recorder = Recorder() - recorder.cancelOnFraction = 0.0 // fire on the very first callback + recorder.cancelOnFraction = 0.0 // fire on the very first callback do { _ = try await ShapeLoader.load(from: url, format: .step, progress: recorder) // If the import is so fast it completes before any callback has // a chance to flip the cancel flag, the test box was too small. - // Accept that — what we're really verifying is "cancellation + // Accept that: what we're really verifying is "cancellation // path doesn't crash and produces ImportError.cancelled when honored". } catch ImportError.cancelled { // Expected outcome on cancel. diff --git a/Tests/OCCTSwiftIOTests/JWWLoaderTests.swift b/Tests/OCCTSwiftIOTests/JWWLoaderTests.swift index c6f7897..8d9f64b 100644 --- a/Tests/OCCTSwiftIOTests/JWWLoaderTests.swift +++ b/Tests/OCCTSwiftIOTests/JWWLoaderTests.swift @@ -1,22 +1,29 @@ -import Testing import Foundation import OCCTSwift +import Testing + @testable import OCCTSwiftIO @Suite("JWW vector loader") struct JWWLoaderTests { /// Loads a real JWW drawing if available locally and checks it maps to a non-empty OCCT compound - /// whose in-plane (X) extent matches the drawing. (Hermetic synthetic-JWW coverage lives in SwiftJWW.) + /// whose in-plane (X) extent matches the drawing. + /// + /// (Hermetic synthetic-JWW coverage lives in SwiftJWW). @Test func realDrawingExtent() throws { - let url = URL(fileURLWithPath: NSString(string: "~/Documents/Modelling/2DFiles/dd12.jww").expandingTildeInPath) + let url = URL( + fileURLWithPath: NSString(string: "~/Documents/Modelling/2DFiles/dd12.jww") + .expandingTildeInPath) try withKnownIssue("dd12.jww not present in CI", isIntermittent: true) { - guard FileManager.default.fileExists(atPath: url.path) else { throw CancellationError() } + guard FileManager.default.fileExists(atPath: url.path) else { + throw CancellationError() + } let result = try JWWLoader.load(from: url) let shape = try #require(result.shapes.first) let b = shape.bounds - #expect(abs(b.min.x - (-124.59)) < 1.0) // geometry placed correctly in plane + #expect(abs(b.min.x - (-124.59)) < 1.0) // geometry placed correctly in plane #expect(abs(b.max.x - 122.28) < 1.0) - #expect(b.max.x - b.min.x > 100) // a real, non-empty drawing + #expect(b.max.x - b.min.x > 100) // a real, non-empty drawing } } diff --git a/Tests/OCCTSwiftIOTests/MLExportTests.swift b/Tests/OCCTSwiftIOTests/MLExportTests.swift index b138b69..bedec57 100644 --- a/Tests/OCCTSwiftIOTests/MLExportTests.swift +++ b/Tests/OCCTSwiftIOTests/MLExportTests.swift @@ -4,11 +4,12 @@ // Lifted from OCCTSwift/Tests/OCCTSwiftTests/ShapeTests.swift, suite // "BRepGraph ML Export". The third test was renamed `t_exportJSON` // to avoid shadowing the API method per local CLAUDE.md convention. -// "BRepGraph UV Grid" stays in OCCTSwift — see MLExport.swift header. +// "BRepGraph UV Grid" stays in OCCTSwift, see MLExport.swift header. -import Testing import Foundation import OCCTSwift +import Testing + @testable import OCCTSwiftIO @Suite("BRepGraph ML Export") @@ -16,19 +17,19 @@ struct BRepGraphMLExportTests { @Test func exportBoxGraph() { if let box = Shape.box(width: 10, height: 10, depth: 10) { if let graph = BRepGraph(shape: box) { - let export_ = graph.exportForML() - #expect(export_.vertexPositions.count == 8) - #expect(export_.edgeBoundaryFlags.count == 12) - #expect(export_.edgeManifoldFlags.count == 12) - #expect(export_.faceAdjacentFaces.count == 6) - for pos in export_.vertexPositions { + let exported = graph.exportForML() + #expect(exported.vertexPositions.count == 8) + #expect(exported.edgeBoundaryFlags.count == 12) + #expect(exported.edgeManifoldFlags.count == 12) + #expect(exported.faceAdjacentFaces.count == 6) + for pos in exported.vertexPositions { #expect(pos.count == 3) } for i in 0..<12 { - #expect(export_.edgeManifoldFlags[i]) - #expect(!export_.edgeBoundaryFlags[i]) + #expect(exported.edgeManifoldFlags[i]) + #expect(!exported.edgeBoundaryFlags[i]) } - for adj in export_.faceAdjacentFaces { + for adj in exported.faceAdjacentFaces { #expect(adj.count == 4) } } @@ -38,13 +39,13 @@ struct BRepGraphMLExportTests { @Test func exportCOOFormat() { if let box = Shape.box(width: 10, height: 10, depth: 10) { if let graph = BRepGraph(shape: box) { - let export_ = graph.exportForML() - #expect(export_.edgeToVertex.sources.count == export_.edgeToVertex.targets.count) - #expect(export_.edgeToVertex.sources.count == 24) - #expect(export_.faceToEdge.sources.count == export_.faceToEdge.targets.count) - #expect(export_.faceToEdge.sources.count > 0) - #expect(export_.faceToFace.sources.count == export_.faceToFace.targets.count) - #expect(export_.faceToFace.sources.count == 24) + let exported = graph.exportForML() + #expect(exported.edgeToVertex.sources.count == exported.edgeToVertex.targets.count) + #expect(exported.edgeToVertex.sources.count == 24) + #expect(exported.faceToEdge.sources.count == exported.faceToEdge.targets.count) + #expect(exported.faceToEdge.sources.count > 0) + #expect(exported.faceToFace.sources.count == exported.faceToFace.targets.count) + #expect(exported.faceToFace.sources.count == 24) } } } @@ -71,10 +72,10 @@ struct BRepGraphMLExportTests { @Test func exportSphere() { if let sphere = Shape.sphere(radius: 5) { if let graph = BRepGraph(shape: sphere) { - let export_ = graph.exportForML() - #expect(export_.vertexPositions.count == graph.vertexCount) - #expect(export_.edgeBoundaryFlags.count == graph.edgeCount) - #expect(export_.faceAdjacentFaces.count == graph.faceCount) + let exported = graph.exportForML() + #expect(exported.vertexPositions.count == graph.vertexCount) + #expect(exported.edgeBoundaryFlags.count == graph.edgeCount) + #expect(exported.faceAdjacentFaces.count == graph.faceCount) } } } diff --git a/Tests/OCCTSwiftIOTests/ScriptManifestTests.swift b/Tests/OCCTSwiftIOTests/ScriptManifestTests.swift index 3502c46..c5a3bc5 100644 --- a/Tests/OCCTSwiftIOTests/ScriptManifestTests.swift +++ b/Tests/OCCTSwiftIOTests/ScriptManifestTests.swift @@ -1,6 +1,7 @@ -import Testing import Foundation +import Testing import simd + @testable import OCCTSwiftIO @Suite("ScriptManifest") @@ -8,23 +9,23 @@ struct ScriptManifestTests { @Test func t_decodeRoundTripWithColorArray() throws { let json = """ - { - "version": 1, - "timestamp": "2026-05-03T12:00:00Z", - "description": "test", - "bodies": [ { - "id": "body0", - "file": "body0.brep", - "format": "brep", - "name": "Box", - "color": [0.2, 0.4, 0.6, 1.0], - "roughness": 0.5, - "metallic": 0.0 + "version": 1, + "timestamp": "2026-05-03T12:00:00Z", + "description": "test", + "bodies": [ + { + "id": "body0", + "file": "body0.brep", + "format": "brep", + "name": "Box", + "color": [0.2, 0.4, 0.6, 1.0], + "roughness": 0.5, + "metallic": 0.0 + } + ] } - ] - } - """.data(using: .utf8)! + """.data(using: .utf8)! let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 @@ -49,14 +50,14 @@ struct ScriptManifestTests { @Test func t_missingColorDecodesAsNil() throws { let json = """ - { - "version": 1, - "timestamp": "2026-05-03T12:00:00Z", - "bodies": [ - { "file": "x.brep", "format": "brep" } - ] - } - """.data(using: .utf8)! + { + "version": 1, + "timestamp": "2026-05-03T12:00:00Z", + "bodies": [ + { "file": "x.brep", "format": "brep" } + ] + } + """.data(using: .utf8)! let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 @@ -67,17 +68,17 @@ struct ScriptManifestTests { @Test func t_metadataDecodes() throws { let json = """ - { - "version": 1, - "timestamp": "2026-05-03T12:00:00Z", - "bodies": [], - "metadata": { - "name": "Sample Assembly", - "revision": "A", - "tags": ["mech", "demo"] - } - } - """.data(using: .utf8)! + { + "version": 1, + "timestamp": "2026-05-03T12:00:00Z", + "bodies": [], + "metadata": { + "name": "Sample Assembly", + "revision": "A", + "tags": ["mech", "demo"] + } + } + """.data(using: .utf8)! let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 diff --git a/Tests/OCCTSwiftIOTests/ShapeLoaderTests.swift b/Tests/OCCTSwiftIOTests/ShapeLoaderTests.swift index 8e577c3..ba5ef31 100644 --- a/Tests/OCCTSwiftIOTests/ShapeLoaderTests.swift +++ b/Tests/OCCTSwiftIOTests/ShapeLoaderTests.swift @@ -1,7 +1,8 @@ -import Testing import Foundation -import simd import OCCTSwift +import Testing +import simd + @testable import OCCTSwiftIO @Suite("ShapeLoader") @@ -38,7 +39,7 @@ struct ShapeLoaderTests { let result = try await ShapeLoader.load(from: url, format: .step) #expect(result.shapes.count >= 1, "STEP round-trip should produce at least one shape") - // No GD&T metadata for a plain box export — just verifying the fields + // No GD&T metadata for a plain box export, just verifying the fields // exist and default to empty. #expect(result.dimensions.isEmpty) #expect(result.geomTolerances.isEmpty) @@ -63,7 +64,8 @@ struct ShapeLoaderTests { // Two spatially separated unit boxes, as one compound shape. private static func twoBodyCompound() -> Shape? { guard let a = Shape.box(origin: SIMD3(0, 0, 0), width: 1, height: 1, depth: 1), - let b = Shape.box(origin: SIMD3(5, 0, 0), width: 1, height: 1, depth: 1) else { + let b = Shape.box(origin: SIMD3(5, 0, 0), width: 1, height: 1, depth: 1) + else { return nil } return Shape.compound([a, b]) @@ -73,29 +75,33 @@ struct ShapeLoaderTests { // entry. BREP is exact (no meshing/sewing), so the count is deterministic. @Test func t_brepMultibodySplitsIntoPerBodyEntries() async throws { guard let compound = Self.twoBodyCompound() else { - Issue.record("failed to build two-body compound"); return + Issue.record("failed to build two-body compound") + return } let url = Self.tempURL(suffix: "twobody.brep") defer { try? FileManager.default.removeItem(at: url) } // shapes: [compound] hits ExportManager's single-file branch, so this is - // one BREP holding a compound of two solids — not two numbered files. + // one BREP holding a compound of two solids, not two numbered files. try await ExportManager.export(shapes: [compound], format: .brep, to: url) let result = try await ShapeLoader.load(from: url, format: .brep) - #expect(result.shapesWithColors.count == 2, - "two-body BREP should split into two entries, got \(result.shapesWithColors.count)") - #expect(result.shapesWithColors.allSatisfy { $0.shape.shapeType == .solid }, - "each entry should be a single solid, not the compound") + #expect( + result.shapesWithColors.count == 2, + "two-body BREP should split into two entries, got \(result.shapesWithColors.count)") + #expect( + result.shapesWithColors.allSatisfy { $0.shape.shapeType == .solid }, + "each entry should be a single solid, not the compound") } - // #21: the issue's actual path — robust STL of a multibody file. Since + // #21: the issue's actual path, robust STL of a multibody file. Since // OCCTSwift v1.11.3 this returns a compound of solids (SecondMouseAU/OCCTSwift#302); // the loader must split it. Meshing/sewing makes the exact count less certain // than BREP, so assert the property under test: more than one body entry. @Test func t_stlRobustMultibodySplitsIntoPerBodyEntries() async throws { guard let compound = Self.twoBodyCompound() else { - Issue.record("failed to build two-body compound"); return + Issue.record("failed to build two-body compound") + return } let url = Self.tempURL(suffix: "twobody.stl") defer { try? FileManager.default.removeItem(at: url) } @@ -103,15 +109,17 @@ struct ShapeLoaderTests { try Exporter.writeSTL(shape: compound, to: url) let result = try await ShapeLoader.loadRobust(from: url, format: .stl) - #expect(result.shapesWithColors.count >= 2, - "robust STL of a two-body file should split, got \(result.shapesWithColors.count)") + #expect( + result.shapesWithColors.count >= 2, + "robust STL of a two-body file should split, got \(result.shapesWithColors.count)") } // Regression guard on the fallback: a shape with no solids (a raw-mesh STL // comes back as loose faces) must stay a single entry, never collapse to zero. @Test func t_nonRobustStlStaysOneEntry() async throws { guard let box = Shape.box(width: 2, height: 2, depth: 2) else { - Issue.record("Shape.box returned nil"); return + Issue.record("Shape.box returned nil") + return } let url = Self.tempURL(suffix: "one.stl") defer { try? FileManager.default.removeItem(at: url) } @@ -119,26 +127,28 @@ struct ShapeLoaderTests { try Exporter.writeSTL(shape: box, to: url) let result = try await ShapeLoader.load(from: url, format: .stl) - #expect(result.shapesWithColors.count == 1, - "a non-robust single-body STL stays one entry, got \(result.shapesWithColors.count)") + #expect( + result.shapesWithColors.count == 1, + "a non-robust single-body STL stays one entry, got \(result.shapesWithColors.count)") } @Test func t_loadFromManifestSkipsMissingFiles() throws { - // Temp dir with manifest only — no body files. Loader should skip the + // Temp dir with manifest only, no body files. Loader should skip the // descriptor (file missing) and return empty shapesWithColors. - let tempDir = URL(fileURLWithPath: "/tmp/occtswiftio-manifest-\(UUID().uuidString)", isDirectory: true) + let tempDir = URL( + fileURLWithPath: "/tmp/occtswiftio-manifest-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } let manifestJSON = """ - { - "version": 1, - "timestamp": "2026-05-06T12:00:00Z", - "bodies": [ - { "id": "missing", "file": "does-not-exist.brep", "format": "brep" } - ] - } - """ + { + "version": 1, + "timestamp": "2026-05-06T12:00:00Z", + "bodies": [ + { "id": "missing", "file": "does-not-exist.brep", "format": "brep" } + ] + } + """ let manifestURL = tempDir.appendingPathComponent("manifest.json") try manifestJSON.write(to: manifestURL, atomically: true, encoding: .utf8) diff --git a/okf/index.md b/okf/index.md index 07d6fdb..ad1bf25 100644 --- a/okf/index.md +++ b/okf/index.md @@ -3,7 +3,7 @@ type: repo title: OCCTSwiftIO resource: https://github.com/SecondMouseAU/OCCTSwiftIO tags: [cad, occt, io, step, gltf, headless, swift, kernel] -description: Headless CAD file I/O for OCCTSwift — STEP/IGES/STL/OBJ/BREP loaders plus glTF/GLB/OBJ/PLY/STEP/BREP exporters, with no Viewport dependency. +description: Headless CAD file I/O for OCCTSwift, STEP/IGES/STL/OBJ/BREP loaders plus glTF/GLB/OBJ/PLY/STEP/BREP exporters, with no Viewport dependency. timestamp: 2026-06-22 --- @@ -11,14 +11,14 @@ timestamp: 2026-06-22 > Headless CAD file import/export for the OCCTSwift ecosystem. Loads STEP (with AP242 > dimensions/datums and per-shape colors), IGES, STL, OBJ, and BREP; exports glTF, GLB, OBJ, PLY, -> STEP, and BREP. It pulls in **OCCTSwift only** — no Metal renderer — so it is safe to use from +> STEP, and BREP. It pulls in **OCCTSwift only**, no Metal renderer, so it is safe to use from > CLIs, batch pipelines, and server-side workflows. Spun out of OCCTSwiftTools so headless > consumers don't drag in OCCTSwiftViewport transitively. ## Role in the ecosystem - **Cluster:** kernel -- **Depends on:** [OCCTSwift](https://github.com/SecondMouseAU/OCCTSwift) — the B-Rep modelling +- **Depends on:** [OCCTSwift](https://github.com/SecondMouseAU/OCCTSwift), the B-Rep modelling kernel (floored at v1.7.1). No transitive Viewport dependency. - **Feeds:** [OCCTSwiftTools](https://github.com/SecondMouseAU/OCCTSwiftTools), which wraps this package's loaders with the bridge layer to produce viewport-ready bodies + pick metadata. @@ -46,5 +46,6 @@ OpenCASCADE upstream. - [Documentation updates are mandatory](policies/docs-current.md) - [No em-dashes, banned words in prose](policies/writing-style.md) - [Search before building](policies/search-before-building.md) +- [Code style](policies/code-style.md) - [Code structure](policies/code-structure.md) - [Issue labels and project-board tracking](policies/issue-tracking.md) diff --git a/okf/policies/code-style.md b/okf/policies/code-style.md new file mode 100644 index 0000000..1c3b1b2 --- /dev/null +++ b/okf/policies/code-style.md @@ -0,0 +1,56 @@ +--- +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 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 carries zero first-party C++ bridge files (confirmed), so the +proposal's C++/OCCT-style half doesn't apply. + +Why: piloting the ecosystem's proposed code-style policy here, following the same shape already +proven in [OCCTSwiftScripts#114](https://github.com/SecondMouseAU/OCCTSwiftScripts/issues/114) / +[#115](https://github.com/SecondMouseAU/OCCTSwiftScripts/pull/115): a full sweep rather than a +gradual exemption manifest, because this repo is small enough (~2,459 Swift lines across +~25 files) to bring fully into compliance in one PR and go straight to a blocking gate. 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 [OCCTSwiftIO#36](https://github.com/SecondMouseAU/OCCTSwiftIO/issues/36). + +Ecosystem standard: see +[OKF-STANDARD.md](https://github.com/SecondMouseAU/ecosystem/blob/main/OKF-STANDARD.md).