Skip to content

Instantly share code, notes, and snippets.

@epologee
Last active August 17, 2023 14:44
Show Gist options
  • Save epologee/7373ea371812e8facd5a7a00e6b80d70 to your computer and use it in GitHub Desktop.
Save epologee/7373ea371812e8facd5a7a00e6b80d70 to your computer and use it in GitHub Desktop.
Create SceneKit geometry from Binary STL files in Swift 4
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)
@epologee
Copy link
Author

epologee commented Nov 8, 2017

@Benjoyo
Copy link

Benjoyo commented May 5, 2020

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)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment