373 lines
12 KiB
Swift
373 lines
12 KiB
Swift
import Foundation
|
|
|
|
|
|
public struct ObjReader
|
|
{
|
|
public static func read(url: URL) throws -> ObjModel
|
|
{
|
|
var file = try ObjDocumentReader(filePath: url)
|
|
|
|
var model = ObjModel()
|
|
var materials = Dictionary<String, ObjMaterial>()
|
|
var name: String? = nil
|
|
var object = ObjModel.Object()
|
|
|
|
file.string(tag: "mtllib") { s in
|
|
let mats = try ObjMtlLoader.read(url: url.deletingLastPathComponent().appendingPathComponent(s[0]))
|
|
materials.merge(mats, uniquingKeysWith: { (_, new) in new } )
|
|
}
|
|
file.string(tag: "o", count: 1) { s in
|
|
if !object.faces.isEmpty
|
|
{
|
|
model.objects[name!] = object
|
|
object = .init()
|
|
}
|
|
name = String(s[0])
|
|
}
|
|
file.float(tag: "v", count: 3) { v in model.positions.append(Vec3f(v[0], v[1], v[2])) }
|
|
file.float(tag: "vn", count: 3) { v in model.normals.append(Vec3f(v[0], v[1], v[2])) }
|
|
file.float(tag: "vt", count: 2) { v in model.texCoords.append(Vec2f(v[0], v[1])) }
|
|
file.raw(tag: "f") { raw in
|
|
let readIndex =
|
|
{ (raw: Substring) in
|
|
let face = raw.split(separator: "/")
|
|
guard face.count == 3,
|
|
let posIdx = Int(face[0]), let coordIdx = Int(face[1]), let normIdx = Int(face[2])
|
|
else { throw ObjLoaderError.badTagParameters }
|
|
return ObjModel.Index(p: posIdx - 1, n: normIdx - 1, t: coordIdx - 1)
|
|
}
|
|
|
|
if raw.count == 3
|
|
{
|
|
for raw in raw { _ = try readIndex(raw) }
|
|
object.faces.append(.triangle(
|
|
v1: try readIndex(raw[raw.startIndex]),
|
|
v2: try readIndex(raw[raw.startIndex + 1]),
|
|
v3: try readIndex(raw[raw.startIndex + 2])))
|
|
}
|
|
else if raw.count == 4
|
|
{
|
|
object.faces.append(.quad(
|
|
v1: try readIndex(raw[raw.startIndex]),
|
|
v2: try readIndex(raw[raw.startIndex + 1]),
|
|
v3: try readIndex(raw[raw.startIndex + 2]),
|
|
v4: try readIndex(raw[raw.startIndex + 3])))
|
|
}
|
|
else if raw.count >= 5
|
|
{
|
|
object.faces.append(.ngon(try raw.map { try readIndex($0) }))
|
|
}
|
|
else { throw ObjLoaderError.badTagParameters }
|
|
}
|
|
file.int(tag: "l") { i in object.faces.append(.line(p1: i[0], p2: i[1])) }
|
|
try file.read()
|
|
|
|
if !object.faces.isEmpty
|
|
{
|
|
model.objects[name!] = object
|
|
}
|
|
return model
|
|
}
|
|
}
|
|
|
|
fileprivate struct ObjMtlLoader
|
|
{
|
|
static func read(url: URL) throws -> Dictionary<String, ObjMaterial>
|
|
{
|
|
var file = try ObjDocumentReader(filePath: url)
|
|
|
|
var materials = Dictionary<String, ObjMaterial>()
|
|
var name: String = ""
|
|
var mat: ObjMaterial? = nil
|
|
|
|
file.string(tag: "newmtl", count: 1)
|
|
{ s in
|
|
if mat != nil
|
|
{
|
|
materials[name] = mat!
|
|
}
|
|
mat = .init()
|
|
name = String(s[0])
|
|
}
|
|
|
|
file.preHandle { s in if s != "newmtl" && mat == nil { throw ObjLoaderError.unexpectedTag } }
|
|
file.float(tag: "Ka", count: 3) { f in mat!.ambient = Colour(r: f[0], g: f[1], b: f[2]) } // "Ambient colour"
|
|
file.float(tag: "Kd", count: 3) { f in mat!.diffuse = Colour(r: f[0], g: f[1], b: f[2]) } // "Diffuse colour"
|
|
file.float(tag: "Ks", count: 3) { f in mat!.specular = Colour(r: f[0], g: f[1], b: f[2]) } // "Specular colour"
|
|
file.float(tag: "Ns", count: 1) { f in mat!.specularExp = f[0] } // "Specular exponent/shininess"
|
|
file.float(tag: "Ni", count: 1) { f in mat!.refraction = f[0] } // "Optical density/refraction index"
|
|
file.float(tag: "d", count: 1) { f in mat!.alpha = f[0] } // "Dissolve" 0.0 = transparent, 1.0 = opaque
|
|
file.float(tag: "Tr", count: 1) { f in mat!.alpha = 1.0 - f[0] } // "Transparent" 0.0 = opaque, 1.0 = transparent
|
|
file.int(tag: "illum", count: 1) { i in mat!.model = try .resolve(i[0]) } // "Illumination model"
|
|
file.textureMap(tag: "map_Ka") { s in } // "Ambient colourmap"
|
|
file.textureMap(tag: "map_Kd") { t in mat!.diffuseMap = t } // "Albedo map"
|
|
file.textureMap(tag: "map_Ks") { t in mat!.specularMap = t } // "Specular colourmap"
|
|
file.textureMap(tag: "map_Ns") { t in mat!.specularExpMap = t } // "Specular exponent map"
|
|
file.textureMap(tag: "map_d") { t in mat!.dissolveMap = t } // "Dissolve map"
|
|
//file.string(tag: "map_Tr") { t in } // "Translucency map"
|
|
for t in ["map_Bump", "bump"] { file.textureMap(tag: t) { t in mat!.bumpMap = t } } // "Bump map"
|
|
file.textureMap(tag: "disp") { t in mat!.displacementMap = t } // "Displacement map"
|
|
file.textureMap(tag: "decal") { t in mat!.decalMap = t } // ?
|
|
// PBR extensions
|
|
file.float(tag: "Pr", count: 1) { f in mat!.roughness = f[0] } // "Roughness"
|
|
file.float(tag: "Pm", count: 1) { f in mat!.metallic = f[0] } // "Metallic"
|
|
file.float(tag: "Ps", count: 1) { f in mat!.sheen = f[0] } // "Sheen"
|
|
file.float(tag: "Pc", count: 1) { f in } // "Clearcoat thickness"
|
|
file.float(tag: "Pcr", count: 1) { f in } // "Clearcoat roughness"
|
|
file.float(tag: "Ke", count: 3) { f in } // "Emmission colour"
|
|
file.float(tag: "aniso", count: 1) { f in } // "Anisotropy"
|
|
file.float(tag: "anisor", count: 1) { f in } // "Anisotropy rotation"
|
|
file.textureMap(tag: "map_Pr") { t in mat!.roughnessMap = t } // "Roughness texturemap"
|
|
file.textureMap(tag: "map_Pm") { t in mat!.metallicMap = t } // "Metallic texturemap"
|
|
file.textureMap(tag: "map_Ps") { t in mat!.sheenMap = t } // "Sheen texturemap"
|
|
file.textureMap(tag: "map_Ke") { t in mat!.emmisionMap = t } // "Emmision texturemap"
|
|
file.textureMap(tag: "norm") { t in mat!.normalMap = t } // "Normal map"
|
|
|
|
try file.read()
|
|
|
|
if let material = mat
|
|
{
|
|
materials[name] = material
|
|
}
|
|
return materials
|
|
}
|
|
}
|
|
|
|
fileprivate extension ObjMaterial.IlluminationModel
|
|
{
|
|
static func resolve(_ id: Int) throws -> Self
|
|
{
|
|
switch id
|
|
{
|
|
case 0: .colour
|
|
case 1: .lambert
|
|
case 2: .blinnPhong
|
|
case 3: .reflectionRaytrace
|
|
case 4: .transparentGlassReflectionRaytrace
|
|
case 5: .reflectionRaytraceFresnel
|
|
case 6: .transparentRefractionReflectionRaytrace
|
|
case 7: .transparentRefractionReflectionRaytraceFresnel
|
|
case 8: .reflection
|
|
case 9: .transparentGlassReflection
|
|
case 10: .shadowOnly
|
|
default: throw ObjLoaderError.badTagParameters
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate extension ObjMaterial.TextureMap
|
|
{
|
|
static func parse(_ argstr: Substring) throws -> Self
|
|
{
|
|
var map = Self()
|
|
var args = [Substring]()
|
|
|
|
let parseFlag =
|
|
{ (flag: Flags) in
|
|
let arg = args[0].lowercased()
|
|
if arg == "on" { map.flags.insert(flag) }
|
|
else if arg == "off" { map.flags.remove(flag) }
|
|
else { throw ObjLoaderError.badTagParameters }
|
|
}
|
|
let parseInt =
|
|
{ (arg: Int) in
|
|
guard let result = Int(args[arg]) else { throw ObjLoaderError.badTagParameters }
|
|
return result
|
|
}
|
|
let parseFloat =
|
|
{ (arg: Int) in
|
|
guard let result = Float(args[arg]) else { throw ObjLoaderError.badTagParameters }
|
|
return result
|
|
}
|
|
let parseVector = { Vec3f(try parseFloat(0), try parseFloat(1), try parseFloat(2)) }
|
|
|
|
typealias Option = () throws -> Void
|
|
let options: Dictionary<String, (Int, Option)> =
|
|
[
|
|
"blendu": (1, { try parseFlag(.blendHoriz) }),
|
|
"blendv": (1, { try parseFlag(.blendVert) }),
|
|
"bm": (1, { map.blendMul = try parseFloat(0) }),
|
|
"cc": (1, { try parseFlag(.colourCorrection) }),
|
|
"clamp": (1, { try parseFlag(.clamp) }),
|
|
"imfchan": (1, { map.imfChan = try .resolve(args[0]) }),
|
|
"mm": (2, { map.mmBaseGain = (try parseFloat(0), try parseFloat(1)) }),
|
|
"o": (3, { map.offset = try parseVector() }),
|
|
"s": (3, { map.scale = try parseVector() }),
|
|
"t": (3, { map.turbulence = try parseVector() }),
|
|
"texres": (1, { map.textureResolution = try parseInt(0) })
|
|
]
|
|
|
|
var expectArgs = 0, option: Option? = nil
|
|
var index = argstr.startIndex
|
|
repeat
|
|
{
|
|
if expectArgs > 0, let callback = option
|
|
{
|
|
let start = index
|
|
index = argstr[start...].firstIndex(where: \.isWhitespace) ?? argstr.endIndex
|
|
args.append(argstr[start..<index])
|
|
expectArgs -= 1
|
|
if expectArgs == 0
|
|
{
|
|
try callback()
|
|
option = nil
|
|
}
|
|
}
|
|
else if argstr[index] == "-"
|
|
{
|
|
let start = argstr.index(after: index)
|
|
index = argstr[start...].firstIndex(where: \.isWhitespace) ?? argstr.endIndex
|
|
let name = String(argstr[start..<index])
|
|
guard let params = options[name] else { throw ObjLoaderError.badTagParameters }
|
|
(expectArgs, option) = params
|
|
}
|
|
else
|
|
{
|
|
break
|
|
}
|
|
index = argstr[index...].firstIndex { !$0.isWhitespace } ?? argstr.endIndex
|
|
}
|
|
while index < argstr.endIndex
|
|
if index >= argstr.endIndex || expectArgs > 0 { throw ObjLoaderError.badTagParameters }
|
|
|
|
map.path = String(argstr[index...])
|
|
return map
|
|
}
|
|
}
|
|
|
|
fileprivate extension ObjMaterial.TextureMap.ImfChan
|
|
{
|
|
static func resolve(_ token: Substring) throws -> Self
|
|
{
|
|
switch token
|
|
{
|
|
case "r": .r
|
|
case "g": .g
|
|
case "b": .b
|
|
case "m": .m
|
|
case "l": .l
|
|
case "z": .z
|
|
default: throw ObjLoaderError.badTagParameters
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate struct ObjDocumentReader
|
|
{
|
|
private enum Handler
|
|
{
|
|
case string(closure: ([String]) throws -> Void, count: Int? = nil)
|
|
case float(closure: ([Float]) throws -> Void, count: Int? = nil)
|
|
case int(closure: ([Int]) throws -> Void, count: Int? = nil)
|
|
case textureMap(closure: (ObjMaterial.TextureMap) throws -> Void)
|
|
case raw(closure: ([Substring]) throws -> Void)
|
|
}
|
|
|
|
private var file: TextFile
|
|
typealias Callback = (Substring) throws -> Void
|
|
private var _prehandler: Callback? = nil
|
|
private var handlers = Dictionary<String, Handler>()
|
|
|
|
init(filePath: URL) throws
|
|
{
|
|
file = try TextFile(fileURL: filePath)
|
|
}
|
|
|
|
mutating func string(tag: String, count: Int? = nil, closure: @escaping ([String]) throws -> Void)
|
|
{
|
|
handlers[tag] = .string(closure: closure, count: count)
|
|
}
|
|
|
|
mutating func float(tag: String, count: Int? = nil, closure: @escaping ([Float]) throws -> Void)
|
|
{
|
|
handlers[tag] = .float(closure: closure, count: count)
|
|
}
|
|
|
|
mutating func int(tag: String, count: Int? = nil, closure: @escaping ([Int]) throws -> Void)
|
|
{
|
|
handlers[tag] = .int(closure: closure, count: count)
|
|
}
|
|
|
|
mutating func textureMap(tag: String, closure: @escaping (ObjMaterial.TextureMap) throws -> Void)
|
|
{
|
|
handlers[tag] = .textureMap(closure: closure)
|
|
}
|
|
|
|
mutating func raw(tag: String, closure: @escaping ([Substring]) throws -> Void)
|
|
{
|
|
handlers[tag] = .raw(closure: closure)
|
|
}
|
|
|
|
mutating func preHandle(closure: @escaping Callback) { _prehandler = closure }
|
|
|
|
private func handle(_ handler: Handler, command: Substring, arg: Substring) throws
|
|
{
|
|
switch handler
|
|
{
|
|
case .string(let closure, let count):
|
|
let args = arg.split(separator: " ")
|
|
if count != nil && args.count != count! { throw ObjLoaderError.badTagParameters }
|
|
try closure(args.map({ String($0) }))
|
|
case .float(let closure, let count):
|
|
let args = arg.split(separator: " ")
|
|
if count != nil && args.count != count! { throw ObjLoaderError.badTagParameters }
|
|
try closure(args.map(
|
|
{
|
|
if let value = Float($0) { return value }
|
|
else { throw ObjLoaderError.badTagParameters }
|
|
}))
|
|
case .int(let closure, let count):
|
|
let args = arg.split(separator: " ")
|
|
if count != nil && args.count != count! { throw ObjLoaderError.badTagParameters }
|
|
try closure(args.map(
|
|
{
|
|
if let value = Int($0) { return value }
|
|
else { throw ObjLoaderError.badTagParameters }
|
|
}))
|
|
case .textureMap(let closure):
|
|
try closure(ObjMaterial.TextureMap.parse(arg))
|
|
case .raw(let closure):
|
|
try closure(arg.split(separator: " "))
|
|
}
|
|
}
|
|
|
|
private func parseLine(_ line: String) throws -> (Substring, Substring)?
|
|
{
|
|
// Trim comment if present
|
|
var trimmed = if let index = line.firstIndex(of: "#") { line[..<index] } else { line[...] }
|
|
|
|
// Trim leading and trailing whitespace
|
|
guard let start = trimmed.firstIndex(where: { !$0.isWhitespace }),
|
|
let end = trimmed.lastIndex(where: { !$0.isWhitespace })
|
|
else { return nil }
|
|
trimmed = trimmed[start...end]
|
|
|
|
// Split command & rest of string as arguments
|
|
guard let cmdEnd = trimmed.firstIndex(where: \.isWhitespace),
|
|
let argStart = trimmed.suffix(from: trimmed.index(after: cmdEnd)).firstIndex(where: { !$0.isWhitespace })
|
|
else { throw ObjLoaderError.badTagParameters }
|
|
return (trimmed.prefix(upTo: cmdEnd), trimmed.suffix(from: argStart))
|
|
}
|
|
|
|
func read() throws
|
|
{
|
|
for line in file.lines
|
|
{
|
|
if !line.isEmpty,
|
|
let (cmd, argstr) = try parseLine(line),
|
|
let handler = handlers[String(cmd)]
|
|
{
|
|
if let prehandler = _prehandler { try prehandler(cmd) }
|
|
try handle(handler, command: cmd, arg: argstr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
enum ObjLoaderError: Error
|
|
{
|
|
case badTagParameters
|
|
case unexpectedTag
|
|
}
|