Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for materials in obj files #98

Merged
merged 9 commits into from
Oct 17, 2024
363 changes: 322 additions & 41 deletions src/io/obj.jl
Original file line number Diff line number Diff line change
Expand Up @@ -4,59 +4,104 @@
#
##############################

function load(io::Stream{format"OBJ"}; facetype=GLTriangleFace,
function load(fn::File{format"OBJ"}; facetype=GLTriangleFace,
pointtype=Point3f, normaltype=Vec3f, uvtype=Any)

function parse_bool(x)
if lowercase(x) == "off" || x == "0"
return false
elseif lowercase(x) == "on" || x == "1"
return true
else
error("Failed to parse $x as Bool.")
end
end

points, v_normals, uv, faces = pointtype[], normaltype[], uvtype[], facetype[]
f_uv_n_faces = (faces, facetype[], facetype[])

for full_line in eachline(stream(io))
# read a line, remove newline and leading/trailing whitespaces
line = strip(chomp(full_line))
!isascii(line) && error("non valid ascii in obj")

if !startswith(line, "#") && !isempty(line) && !all(iscntrl, line) #ignore comments
lines = split(line)
command = popfirst!(lines) #first is the command, rest the data

if "v" == command # mesh always has vertices
push!(points, pointtype(parse.(eltype(pointtype), lines)))
elseif "vn" == command
push!(v_normals, normaltype(parse.(eltype(normaltype), lines)))
elseif "vt" == command
if length(lines) == 2
if uvtype == Any
uvtype = Vec2f
uv = uvtype[]
# name => (first_face, value)
group_meta = Dict{Symbol, Dict{Int, T} where T}()
mtllibs = String[]

open(fn) do io
skipmagic(io)

for full_line in eachline(stream(io))
# read a line, remove newline and leading/trailing whitespaces
line = strip(chomp(full_line))
!isascii(line) && error("non valid ascii in obj")

if !startswith(line, "#") && !isempty(line) && !all(iscntrl, line) #ignore comments
lines = split(line)
command = popfirst!(lines) #first is the command, rest the data

if "v" == command # mesh always has vertices
push!(points, pointtype(parse.(eltype(pointtype), lines)))

elseif "vn" == command
push!(v_normals, normaltype(parse.(eltype(normaltype), lines)))

elseif "vt" == command
if length(lines) == 2
if uvtype == Any
uvtype = Vec2f
uv = uvtype[]
end
push!(uv, Vec{2,eltype(uvtype)}(parse.(eltype(uvtype), lines)))
elseif length(lines) == 3
if uvtype == Any
uvtype = Vec3f
uv = uvtype[]
end
push!(uv, Vec{3,eltype(uvtype)}(parse.(eltype(uvtype), lines)))
else
error("Unknown UVW coordinate: $lines")
end
push!(uv, Vec{2,eltype(uvtype)}(parse.(eltype(uvtype), lines)))
elseif length(lines) == 3
if uvtype == Any
uvtype = Vec3f
uv = uvtype[]

elseif "f" == command # mesh always has faces

if any(x-> occursin("//", x), lines)
fs = process_face_normal(lines)
elseif any(x-> occursin("/", x), lines)
fs = process_face_uv_or_normal(lines)
else
append!(faces, triangulated_faces(facetype, lines))
continue
end
push!(uv, Vec{3,eltype(uvtype)}(parse.(eltype(uvtype), lines)))
else
error("Unknown UVW coordinate: $lines")
end
elseif "f" == command # mesh always has faces
if any(x-> occursin("//", x), lines)
fs = process_face_normal(lines)
elseif any(x-> occursin("/", x), lines)
fs = process_face_uv_or_normal(lines)
for i = 1:length(first(fs))
append!(f_uv_n_faces[i], triangulated_faces(facetype, getindex.(fs, i)))
end

elseif "s" == command # Blender sets this just before faces
shadings = get!(() -> Dict{Int, Bool}(), group_meta, :shading)
shadings[length(faces)+1] = parse_bool(lines[1])

elseif "o" == command # Blender sets this before vertices
objects = get!(() -> Dict{Int, String}(), group_meta, :object)
objects[length(faces)+1] = join(lines, ' ')

elseif "g" == command
groups = get!(() -> Dict{Int, String}(), group_meta, :groups)
groups[length(faces)+1] = join(lines, ' ')

elseif "mtllib" == command
push!(mtllibs, join(lines, ' '))

elseif "usemtl" == command # Blender sets this just before faces
materials = get!(() -> Dict{Int, String}(), group_meta, :material_names)
materials[length(faces)+1] = join(lines, ' ')
else
append!(faces, triangulated_faces(facetype, lines))
continue
# TODO:
# parameter space vertices
# line elements?
end
for i = 1:length(first(fs))
append!(f_uv_n_faces[i], triangulated_faces(facetype, getindex.(fs, i)))
end
else
#TODO
end
end

end

# Generate base mesh
if !isempty(f_uv_n_faces[2]) && (f_uv_n_faces[2] != faces)
uv = FaceView(uv, f_uv_n_faces[2])
end
Expand All @@ -65,11 +110,62 @@ function load(io::Stream{format"OBJ"}; facetype=GLTriangleFace,
v_normals = FaceView(v_normals, f_uv_n_faces[3])
end

return GeometryBasics.mesh(
mesh = GeometryBasics.mesh(
points, faces, facetype = facetype;
uv = isempty(uv) ? nothing : uv,
normal = isempty(v_normals) ? nothing : v_normals
)

if !isempty(group_meta)

# Find all the starting indices used across objects, groups, shadings, materials
starts_set = Set{Int}()
for meta in values(group_meta)
union!(starts_set, keys(meta))
end
starts_vec = sort!(collect(starts_set))

# generate views
resize!(mesh.views, length(starts_vec))
for i in 1:length(starts_vec)-1
mesh.views[i] = starts_vec[i] : starts_vec[i+1]-1
end
mesh.views[end] = starts_vec[end] : length(faces)

# generate metadata dict matching the views with nothing as the gap filler
N = length(starts_vec)
metadata = Dict{Symbol, Any}()
for (name, dict) in group_meta
if length(dict) == N
metadata[name] = getindex.(Ref(dict), starts_vec)
else
metadata[name] = get.(Ref(dict), starts_vec, nothing)
end
end

# Load material files
materials = Dict{String, Dict{String, Any}}()
path = joinpath(splitpath(FileIO.filename(fn))[1:end-1])
for filename in mtllibs
try
_load_mtl!(materials, joinpath(path, filename))
catch e
@error "While parsing $(joinpath(path, filename)):" exception = e
end
end
metadata[:materials] = materials

return MetaMesh(mesh, metadata)

else
# TODO: Should we have different output types here?
return mesh

# views = UnitRange{Int}[]
# metadata = Dict{Symbol, Any}()
end

return MetaMesh(mesh, metadata)
end

# of form "faces v1 v2 v3 ....""
Expand Down Expand Up @@ -116,3 +212,188 @@ function save(f::Stream{format"OBJ"}, mesh::AbstractMesh)
println(io, "f ", join(convert.(Int, f), " "))
end
end


function _load_mtl!(materials::Dict{String, Dict{String, Any}}, filename::String)
endswith(filename, ".mtl") || error("Material Template Library $filename must be a .mtl file.")


name_lookup = Dict(
"Ka" => "ambient", "Kd" => "diffuse", "Ks" => "specular",
"Ns" => "shininess", "d" => "alpha", "Tr" => "transmission", # 1 - alpha
"Ni" => "refractive index", "illum" => "illumination model",
# PBR
"Pr" => "roughness", "Pm" => "metallic", "Ps" => "sheen",
"Pc" => "clearcoat thickness", "Pcr" => "clearcoat roughness",
"Ke" => "emissive", "aniso" => "anisotropy",
"anisor" => "anisotropy rotation",
"Tf" => "transmission filter",
# texture maps
"map_Ka" => "ambient map", "map_Kd" => "diffuse map",
"map_Ks" => "specular map", "map_Ns" => "shininess map",
"map_d" => "alpha map", "map_Tr" => "transmission map",
"map_bump" => "bump map", "bump" => "bump map",
"disp" => "displacement map", "decal" => "decal map",
"refl" => "reflection map", "norm" => "normal map",
"map_Pr" => "roughness map", "map_Pm" => "metallic map",
"map_Ps" => "sheen map", "map_Ke" => "emissive map",
"map_RMA" => "roughness metalness occlusion map",
"map_ORM" => "occlusion roughness metalness map",
)

path = joinpath(splitpath(filename)[1:end-1])
open(filename, "r") do file

# Just so the variable is defined
material = Dict{String, Any}()

for full_line in eachline(file)
# read a line, remove newline and leading/trailing whitespaces
line = strip(chomp(full_line))
!isascii(line) && error("non valid ascii in obj")

if !startswith(line, "#") && !isempty(line) && !all(iscntrl, line) #ignore comments
lines = split(line)
command = popfirst!(lines) #first is the command, rest the data

if command == "newmtl"
name = join(lines, ' ')
materials[name] = material = Dict{String, Any}()

elseif command == "Ka" || command == "Kd" || command == "Ks"
material[name_lookup[command]] = Vec3f(parse.(Float32, lines)...)

elseif command == "Ns" || command == "Ni" || command == "Pr" ||
command == "Pm" || command == "Ps" || command == "Pc" ||
command == "Pcr" || command == "Ke" || command == "aniso" ||
command == "anisor" || command == "Tf"

material[name_lookup[command]] = parse.(Float32, lines[1])

elseif command == "d"
alpha = parse.(Float32, lines[1])
if haskey(material, "alpha") && !(material["alpha"] ≈ alpha)
@error("Material alpha doubly defined. Overwriting $(material["alpha"]) with $alpha.")
end
material[name_lookup[command]] = alpha

elseif command == "Tr"
alpha = 1f0 - parse.(Float32, lines[1])
if haskey(material, "alpha") && !(material["alpha"] ≈ alpha)
@error("Material alpha doubly defined. Overwriting $(material["alpha"]) with $alpha")
end
material[name_lookup["d"]] = alpha

# elseif Tf # transmission filter

elseif command == "illum"
# See https://en.wikipedia.org/wiki/Wavefront_.obj_file#Basic_materials
material[name_lookup[command]] = parse.(Int, lines[1])

elseif startswith(command, "map") || command == "bump" || command == "norm" ||
command == "refl" || command == "disp" || command == "decal"

# TODO: treat all the texture options
material[get(name_lookup, command, command)] = parse_texture_info(path, lines)

else
material[command] = lines
end
end
end

end

return materials
end

# TODO: Consider generating a ShaderAbstractions Sampler?
function parse_texture_info(parent_path::String, lines::Vector{SubString{String}})
idx = 1
output = Dict{String, Any}()
name_lookup = Dict(
"o" => "offset", "s" => "scale", "t" => "turbulence",
"blendu" => "blend horizontal", "blendv" => "blend vertical",
"boost" => "mipmap sharpness", "bm" => "bump multiplier"
)

function parse_bool(x, default)
if lowercase(x) == "off" || x == "0"
return false
elseif lowercase(x) == "on" || x == "1"
return true
else
error("Failed to parse $x as Bool.")
end
end

while idx < length(lines) + 1
if startswith(lines[idx], '-')
command = lines[idx][2:end]

if command == "blendu" || command == "blendv"
name = name_lookup[command]
output[name] = parse_bool(lines[idx+1], true)
idx += 2

elseif command == "boost" || command == "bm"
output[name_lookup[command]] = parse(Float32, lines[idx+1])
idx += 2

elseif command == "mm"
output["brightness"] = parse(Float32, lines[idx+1])
output["contrast"] = parse(Float32, lines[idx+2])
idx += 3

elseif command == "o" || command == "s" || command == "t"
default = command == "s" ? 1f0 : 0f0
x = parse(Float32, lines[idx+1])
y = length(lines) >= idx+2 ? tryparse(Float32, lines[idx+2]) : nothing
z = length(lines) >= idx+3 ? tryparse(Float32, lines[idx+3]) : nothing
output[name_lookup[command]] = Vec3f(
x, something(y, default), something(z, default)
)
idx += 2 + (y !== nothing) + (z !== nothing)

elseif command == "texres" # is this only one value?
output["resolution"] = parse(Float32, lines[idx+1])
idx += 2

elseif command == "clamp"
output["clamp"] = parse_bool(lines[idx+1])
idx += 2

elseif command == "imfchan"
output["channel"] = lines[idx+1]
idx += 2

elseif command == "type"
output[command] = lines[idx+1]
idx += 2

# TODO: PBR tags

else
@warn "Failed to parse -$command"
idx += 1
end
else
filepath = joinpath(parent_path, lines[idx])
i = idx+1
while i <= length(lines) && !startswith(lines[i], '-')
filepath = filepath * ' ' * lines[i]
i += 1
end
filepath = replace(filepath, "\\\\" => "/")
filepath = replace(filepath, "\\" => "/")
if isfile(filepath) || endswith(lowercase(filepath), r"\.(png|jpg|jpeg|tiff|bmp)")
output["filename"] = filepath
idx = i
else
idx += 1
end
end
end

return output
end
Loading