-
-
Save epologee/7373ea371812e8facd5a7a00e6b80d70 to your computer and use it in GitHub Desktop.
import Foundation | |
import SceneKit | |
public enum BinarySTLParser { | |
public enum STLError: Error { | |
case fileTooSmall(size: Int) | |
case unexpectedFileSize(expected: Int, actual: Int) | |
case triangleCountMismatch(diff: Int) | |
} | |
public enum UnitScale: Float { | |
case meter = 1.0 | |
case millimeter = 0.001 | |
} | |
public static func createNodeFromSTL(at url: URL, | |
unit scale: UnitScale = .meter, | |
correctFor3DPrint: Bool = true) throws -> SCNNode | |
{ | |
let fileData = try Data(contentsOf: url, options: .alwaysMapped) // can cause rethrow | |
guard fileData.count > 84 else { | |
throw STLError.fileTooSmall(size: fileData.count) | |
} | |
let name = String(data: fileData.subdata(in: 0..<80), encoding: .ascii) | |
let triangleTarget: UInt32 = fileData.scanValue(start: 80, length: 4) | |
let triangleBytes = MemoryLayout<Triangle>.size | |
let expectedFileSize = 84 + triangleBytes * Int(triangleTarget) | |
guard fileData.count == expectedFileSize else { | |
throw STLError.unexpectedFileSize(expected: expectedFileSize, actual: fileData.count) | |
} | |
var normals = Data() | |
var vertices = Data() | |
var trianglesCounted: Int = 0 | |
for index in stride(from: 84, to: fileData.count, by: triangleBytes) { | |
trianglesCounted += 1 | |
var triangleData = fileData.subdata(in: index..<index+triangleBytes) | |
var triangle: Triangle = triangleData.withUnsafeMutableBytes { $0.pointee } | |
let normalData = triangle.normal.unsafeData() | |
normals.append(normalData) | |
normals.append(normalData) | |
normals.append(normalData) | |
vertices.append(triangle.v1.unsafeData()) | |
vertices.append(triangle.v2.unsafeData()) | |
vertices.append(triangle.v3.unsafeData()) | |
} | |
guard triangleTarget == trianglesCounted else { | |
throw STLError.triangleCountMismatch(diff: Int(triangleTarget) - trianglesCounted) | |
} | |
let vertexSource = SCNGeometrySource(data: vertices, | |
semantic: .vertex, | |
vectorCount: trianglesCounted * 3, | |
usesFloatComponents: true, | |
componentsPerVector: 3, | |
bytesPerComponent: MemoryLayout<Float>.size, | |
dataOffset: 0, | |
dataStride: MemoryLayout<SCNVector3>.size) | |
let normalSource = SCNGeometrySource(data: normals, | |
semantic: .normal, | |
vectorCount: trianglesCounted * 3, | |
usesFloatComponents: true, | |
componentsPerVector: 3, | |
bytesPerComponent: MemoryLayout<Float>.size, | |
dataOffset: 0, | |
dataStride: MemoryLayout<SCNVector3>.size) | |
// The SCNGeometryElement accepts `nil` as a value for the index-data, and will then generate a list | |
// of auto incrementing indices. It still requires a number of bytes used for the index, whether it | |
// is actually used is unknown to me. | |
let use8BitIndices = MemoryLayout<UInt8>.size | |
let countedTriangles = SCNGeometryElement(data: nil, | |
primitiveType: .triangles, | |
primitiveCount: trianglesCounted, | |
bytesPerIndex: use8BitIndices) | |
let geometry = SCNGeometry(sources: [vertexSource, normalSource], elements: [countedTriangles]) | |
let geometryNode = SCNNode(geometry: geometry) | |
var geometryTransform = SCNMatrix4Identity | |
if correctFor3DPrint { | |
// Rotates the x-axis by 90º to correct for how STLs are (typically) used in 3D printing: | |
geometryTransform = SCNMatrix4Rotate(geometryTransform, Float.pi / 2, -1, 0, 0) | |
} | |
let scaleFactor = scale.rawValue | |
if scaleFactor != 1.0 { | |
// ARKit interprets a SCNVector3's units as corresponding to 'meters', where regular SceneKit | |
// visualizations 'feel' a lot smaller, and a model of 25 units high easily fits a default view. | |
// In 3D printing, it's more common to interpret the units as millimeters, so STLs made for 3D | |
// printing need to be scaled down to appear 'right' in an augmented reality context: | |
geometryTransform = SCNMatrix4Scale(geometryTransform, scaleFactor, scaleFactor, scaleFactor) | |
} | |
geometryNode.transform = geometryTransform | |
let modelNode = SCNNode() | |
modelNode.addChildNode(geometryNode) | |
modelNode.name = name | |
return modelNode | |
} | |
} | |
// The layout of this Triangle struct corresponds with the layout of bytes in the STL spec, | |
// as described at: http://www.fabbers.com/tech/STL_Format#Sct_binary | |
private struct Triangle { | |
var normal: SCNVector3 | |
var v1: SCNVector3 | |
var v2: SCNVector3 | |
var v3: SCNVector3 | |
var attributes: UInt16 | |
} | |
private extension SCNVector3 { | |
mutating func unsafeData() -> Data { | |
return Data(buffer: UnsafeBufferPointer(start: &self, count: 1)) | |
} | |
} | |
private extension Data { | |
func scanValue<T>(start: Int, length: Int) -> T { | |
return self.subdata(in: start..<start+length).withUnsafeBytes { $0.pointee } | |
} | |
} |
// Add the created node to a SceneKit node, for example in combination with Apple's sample code for ARKit: | |
// https://developer.apple.com/documentation/arkit/building_your_first_ar_experience | |
let url = Bundle.main.url(forResource: "Robot.stl", withExtension: nil)! | |
let node = try! BinarySTLParser.createNodeFromSTL(at: url, unit: .millimeter) | |
scene.rootNode.addChildNode(node) |
Thanks @epologee for the useful snippet.
I want to add something that came across me while using it. You yourself outlined some unclarities about the indices in the SCNGeometryElement. I think it is bad design by Apple to allow the data property to be nil in the first place, as some parts of the framework actually rely on the index data being populated. One could assume that the contructor would do that, but this is not the case. The result is that e.g. if you try to re-export the mesh to STL or similar, you will have all the vertices - but they are connected arbitrarily resulting in a real mess and a broken mesh. So I suggest to use the second available constructor for convenience like so:
let indices = [UInt32](0..<UInt32(trianglesCounted * 3))
let countedTriangles = SCNGeometryElement(indices: indices, primitiveType: .triangles)
One further note: It is possible to import STL files using ModelIO as well:
let asset = MDLAsset(url: url)
guard let object = asset.object(at: 0) as? MDLMesh
else { fatalError("Failed to get mesh from file.") }
let node = SCNNode(mdlObject: object)
Thanks @VictorGama for providing a working example of parsing ASCII STL files into SceneKit nodes.