diff --git a/doc/irr_obj_spec.md b/doc/irr_obj_spec.md new file mode 100644 index 0000000..f2bb1d5 --- /dev/null +++ b/doc/irr_obj_spec.md @@ -0,0 +1,76 @@ +# Minetest Wavefront `.obj` file format specification + +Minetest Wavefront `.obj` is a subset of [Wavefront `.obj`](http://paulbourke.net/dataformats/obj/). + +It is inferred from the [Minetest Irrlicht `.obj` reader](https://github.com/minetest/irrlicht/blob/master/source/Irrlicht/COBJMeshFileLoader.cpp). + +`.mtl` files are not supported since Minetest's media loading process ignores them due to the extension. + +## Lines / "Commands" + +Irrlicht only looks at the first characters needed to tell commands apart (imagine a prefix tree of commands). + +Superfluous parameters are ignored. + +Numbers are formatted as either: + +* Float: An optional minus sign (`-`), one or more decimal digits, followed by the decimal dot (`.`) then again one or more digits +* Integer: An optional minus sign (`-`) followed by one or more decimal digits + +Indexing starts at one. Indices are formatted as integers. Negative indices relative to the end of a buffer are supported. + +* Comments: `# ...`; unsupported commands are silently ignored as well +* Groups: `g ` or `usemtl ` + * Subsequent faces belong to a new group / material, no matter the supplied names + * Each group gets their own material (texture); indices are determined by order of appearance + * Empty groups (groups without faces) are ignored +* Vertices (all numbers): `v `, global to the model +* Texture Coordinates (all numbers): `vt `, global to the model +* Normals (all numbers): `vn `, global to the model +* Faces (all vertex/texcoord/normal indices); always local to the current group: + * `f ` + * `f / / ... /` + * `f // / ... /` + * `f // // ... //` + +## Coordinate system orientation ("handedness") + +Vertex & normal X-coordinates are inverted ($x' = -x$); +texture Y-coordinates are inverted as well ($y' = 1 - y$). + +## Example + +```obj +# A simple 2³ cube centered at the origin; each face receives a separate texture / tile +# no care was taken to ensure "proper" texture orientation +v -1 -1 -1 +v -1 -1 1 +v -1 1 -1 +v -1 1 1 +v 1 -1 -1 +v 1 -1 1 +v 1 1 -1 +v 1 1 1 +vn -1 0 0 +vn 0 -1 0 +vn 0 0 -1 +vn 1 0 0 +vn 0 1 0 +vn 0 0 1 +vt 0 0 +vt 1 0 +vt 0 1 +vt 1 1 +g negative_x +f 1/1/1 3/3/1 2/2/1 4/4/1 +g negative_y +f 1/1/2 5/3/2 2/2/2 6/4/2 +g negative_z +f 1/1/3 5/3/3 3/2/3 7/4/3 +g positive_x +f 5/1/4 7/3/4 2/2/4 8/4/4 +g positive_y +f 3/1/5 7/3/5 4/2/5 8/4/5 +g positive_z +f 2/1/6 6/3/6 4/2/6 8/4/6 +``` diff --git a/minetest/obj.lua b/minetest/obj.lua new file mode 100644 index 0000000..8266510 --- /dev/null +++ b/minetest/obj.lua @@ -0,0 +1,190 @@ +local assert, tonumber, type, setmetatable, ipairs, unpack + = assert, tonumber, type, setmetatable, ipairs, unpack + +local math_floor, table_insert, table_concat + = math.floor, table.insert, table.concat + +local obj = {} + +local metatable = {__index = obj} + +local function read_floats(next_word, n) + if n == 0 then return end + local num = next_word() + assert(num:find"^%-?%d+$" or num:find"^%-?%d+%.%d+$") + return tonumber(num), read_floats(next_word, n - 1) +end + +local function read_index(list, index) + if not index then return end + index = tonumber(index) + if index < 0 then + index = index + #list + 1 + end + assert(list[index]) + return index +end + +local function read_indices(self, next_word) + local word = next_word() + if not word then return end + -- TODO optimize this (ideally using a vararg-ish split by `/`) + local vertex, texcoord, normal + vertex = word:match"^%-?%d+$" + if not vertex then + vertex, texcoord = word:match"^(%-?%d+)/(%-?%d+)$" + if not vertex then + vertex, normal = word:match"^(%-?%d+)//(%-?%d+)$" + if not vertex then + vertex, texcoord, normal = word:match"^(%-?%d+)/(%-?%d+)/(%-?%d+)$" + end + end + end + return { + vertex = read_index(self.vertices, vertex), + texcoord = read_index(self.texcoords, texcoord), + normal = read_index(self.normals, normal) + }, read_indices(self, next_word) +end + +function obj.read_lines( + ... -- line iterator such as `modlib.text.lines"str"` or `io.lines"filename"` +) + local self = { + vertices = {}, + texcoords = {}, + normals = {}, + groups = {} + } + local groups = {} + local active_group = {name = "default"} + groups[1] = active_group + groups.default = active_group + for line in ... do + if line:byte() ~= ("#"):byte() then + local next_word = line:gmatch"%S+" + local command = next_word() + if command == "v" or command == "vn" then + local x, y, z = read_floats(next_word, 3) + x = -x + table_insert(self[command == "v" and "vertices" or "normals"], {x, y, z}) + elseif command == "vt" then + local x, y = read_floats(next_word, 2) + y = 1 - y + table_insert(self.texcoords, {x, y}) + elseif command == "f" then + table_insert(active_group, {read_indices(self, next_word)}) + elseif command == "g" or command == "usemtl" then + -- TODO consider distinguishing between materials & groups + local name = next_word() or "default" + if groups[name] then + active_group = groups[name] + else + active_group = {name = name} + table_insert(groups, active_group) + groups[name] = active_group + end + assert(not next_word(), "only a single group/material name is supported") + end + end + end + -- Keep only nonempty groups + for _, group in ipairs(groups) do + if group[1] ~= nil then + table_insert(self.groups, group) + end + end + return setmetatable(self, metatable) -- obj object +end + +-- Does not close a file handle if passed +--> obj object +function obj.read_file(file_or_name) + if type(file_or_name) == "string" then + return obj.read_lines(io.lines(file_or_name)) + end + local handle = file_or_name + -- `handle.read, handle` can be used as a line iterator + return obj.read_lines(assert(handle.read), handle) +end + +--> obj object +function obj.read_string(str) + -- Empty lines can be ignored + return obj.read_lines(str:gmatch"[^\r\n]+") +end + +local function write_float(float) + if math_floor(float) == float then + return ("%d"):format(float) + end + return ("%f"):format(float):match"^(.-)0*$" -- strip trailing zeros +end + +local function write_index(index) + if index.texcoord then + if index.normal then + return("%d/%d/%d"):format(index.vertex, index.texcoord, index.normal) + end + return ("%d/%d"):format(index.vertex, index.texcoord) + end if index.normal then + return ("%d//%d"):format(index.vertex, index.normal) + end + return ("%d"):format(index.vertex) +end + +-- Callback/"caller"-style iterator; use `iterator.for_generator` to turn this into a callee-style iterator +function obj:write_lines( + write_line -- function(line: string) to write a line +) + local function write_v3f(type, v3f) + local x, y, z = unpack(v3f) + x = -x + write_line(("%s %s %s %s"):format(type, write_float(x), write_float(y), write_float(z))) + end + for _, vertex in ipairs(self.vertices) do + write_v3f("v", vertex) + end + for _, normal in ipairs(self.normals) do + write_v3f("vn", normal) + end + for _, texcoord in ipairs(self.texcoords) do + local x, y = texcoord[1], texcoord[2] + y = 1 - y + write_line(("vt %s %s"):format(write_float(x), write_float(y))) + end + for _, group in ipairs(self.groups) do + write_line("g " .. group.name) -- this will convert `usemtl` into `g` but that shouldn't matter + for _, face in ipairs(group) do + local command = {"f"} + for i, index in ipairs(face) do + command[i + 1] = write_index(index) + end + write_line(table_concat(command, " ")) + end + end +end + +-- Write `self` to a file +-- Does not close or flush a file handle if passed +function obj:write_file(file_or_name) + if type(file_or_name) == "string" then + file_or_name = io.open(file_or_name) + end + self:write_lines(function(line) + file_or_name:write(line) + file_or_name:write"\n" + end) +end + +-- Write `self` to a string +function obj:write_string() + local rope = {} + self:write_lines(function(line) + table_insert(rope, line) + end) + table_insert(rope, "") -- trailing newline for good measure + return table_concat(rope, "\n") -- string representation of `self` +end + +return obj \ No newline at end of file