From ff62e98edc72c0735cd15f94890c2a7244a19bb9 Mon Sep 17 00:00:00 2001 From: random-geek <35757396+random-geek@users.noreply.github.com> Date: Mon, 5 Jul 2021 01:05:06 -0700 Subject: [PATCH] Add numerous features, refactor extensively --- .luacheckrc | 15 +- README.md | 82 +++-- api.lua | 189 ---------- export.lua | 761 +++++++++++++++++++++++++++++++++++---- init.lua | 57 ++- mesh.lua | 338 +++++++++++++++++ nodebox.lua | 142 +++++--- parse_obj.lua | 126 ++++--- helpers.lua => utils.lua | 298 +++++++++------ 9 files changed, 1518 insertions(+), 490 deletions(-) delete mode 100644 api.lua create mode 100644 mesh.lua rename helpers.lua => utils.lua (51%) diff --git a/.luacheckrc b/.luacheckrc index 14397b2..11428e7 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -2,14 +2,17 @@ unused_args = false allow_defined_top = true max_line_length = 999 +globals = { + "meshport", +} + read_globals = { string = {fields = {"split", "trim"}}, table = {fields = {"copy", "getn", "indexof"}}, - "minetest", "DIR_DELIM", - "vector", "VoxelArea", -} - -ignore = { - "vertList", + "minetest", + "DIR_DELIM", + "PseudoRandom", + "vector", + "VoxelArea", } diff --git a/README.md b/README.md index 73a9a27..a48560b 100644 --- a/README.md +++ b/README.md @@ -5,52 +5,90 @@ ![screenshot](screenshot.png) -Meshport is a mod which allows easy exporting of scenes from Minetest to `.obj` files, complete with materials and textures. These models can be imported directly into Blender or another 3D program for rendering and animation. +Meshport is a mod which allows easy exporting of scenes from Minetest to `.obj` +files, complete with materials and textures. These models can be imported +directly into Blender or another 3D program for rendering and animation. -This mod is still in the "alpha" phase; as such, many types of nodes are not yet able to be exported. See below for more details. +This mod is still in the beta phase; certain texturing features and node +drawtypes are not yet supported. ## Usage -Use `/mesh1` and `/mesh2` to set the corners of the area you want exported, then use `/meshport [filename]` to export the mesh (filename is optional). The saved `.obj` and `.mtl` files will be located in the `meshport` folder of the world directory, within a subfolder. +Use `/mesh1` and `/mesh2` to set the corners of the area you want exported, +then use `/meshport [filename]` to export the mesh (filename is optional). + +Folders containing exported meshes, including `.obj` and `.mtl` files, are +saved in the `meshport` folder of the world directory. ### Importing into Blender -Once the model is exported, you should be able to import the `.obj` file with default settings. Make sure "Image Search" in the import settings is selected to ensure the textures are imported as well. Texture modifiers are ignored, so some materials will likely have to be fixed by hand. +Once the model is exported, you can import the `.obj` file into Blender with +default settings. Make sure "Image Search" in the import settings is selected +to ensure textures from the `.mtl` file are imported as well. #### Fixing materials -Blender's packaged material assigned to OBJ textures are not effective or easy to use. By default, textures will also appear blurry and lack alpha. The `materials.py` script is included in the mod to simplify the materials, change interpolation, and add transparency. Open the script in Blender's text editor and run the script with the mesh selected. +Upon importing the file, Blender assigns basic materials to the model which are +inaccurate and not very usable. By default, these materials appear blurry and +lack transparency. The `materials.py` script is included in the mod to fix +these issues. Open the script in Blender's text editor and run the script with +the mesh selected. -#### Fixing vertex normals +Meshport does not handle texture modifiers or node coloring, so some materials +will probably still need to be fixed by hand after running the script. -Some mesh nodes may not have any vertex normals, which can lead to lighing problems. To fix this, what I have found to work is to first select the all the problematic nodes, either manually or by selecting by material in edit mode; then, mark the selected edges as sharp, and then average the normals by face area. +#### Other fixes -Additional tip: Use an HDRI sky texture (such as one from [here](https://hdrihaven.com)) for awesome-looking renders. ;) +Some mesh nodes may not have any vertex normals, which can lead to lighting +problems. To fix this, what I have found to work is to select the all the +problematic nodes (either manually or by selecting by material in edit mode), +mark the selected edges as sharp, and average the normals by face area. + +Some animated textures may also appear incorrect. Meshport tries to scale +texture coordinates of animated textures to fit within one frame, but some +nodes (especially flowing liquids) can exceed this boundary. If this is an +issue, switch to a non-animated texture and scale up the affected UV maps to +match the new texture. + +Additional tip: Use an HDRI sky texture (such as one from [here][1]) for +awesome-looking renders. ;) + +[1]: https://hdrihaven.com ## Supported features -At the moment, only the following node drawtypes are supported: +The following node drawtypes are currently supported: -- Cubic drawtypes, including `normal`, `glasslike`, `allfaces`, and their variants (see below) +- Cubic drawtypes, including `normal`, `allfaces`, `glasslike`, and their + variants (see below) +- `glasslike_framed` +- `liquid` and `flowingliquid` - `nodebox` - `mesh` (only `.obj` meshes are exported) +- `plantlike` and `plantlike_rooted` -Many special rendering features are not yet supported. +Meshport also supports many of Minetest's relevant features, including: -### A note on cubic nodes +- Most `paramtype2`s (note that color is ignored for colored types) +- `visual_scale` +- World-aligned textures +- Animated textures (only one frame is used) -Due to the differences between Minetest's rendering engine and 3D programs such as Blender, it is not possible to exactly replicate how certain cubic nodes are rendered in Minetest. Instead, to avoid duplicate faces, a face priority system is used as follows: +Some special rendering features are unsupported, including texture modifiers, +overlay textures, and node coloring. -| Priority level | Drawtypes | -|----------------|----------------------------------------------------| -| 4 | `normal` | -| 3 | `glasslike` | -| 2 | `glasslike_framed` and `glasslike_framed_optional` | -| 1 | `allfaces` and `allfaces_optional` | -| 0 | All other nodes | +### Notes on cubic nodes -In places where two nodes of different drawtypes touch, only the face of the node with the higher priority drawtype will be drawn. For `allfaces` type nodes (such as leaves), interior faces will be drawn only when facing X+, Y+, or Z+ in the Minetest coordinate space. +Drawtypes `allfaces_optional` and `glasslike_framed_optional` are output the +same as `allfaces` and `glasslike`, respectively. + +Due to the differences between Minetest's rendering engine and 3D programs such +as Blender, it is impossible to exactly replicate how certain cubic nodes are +rendered in Minetest. Instead, Meshport aims for a compromise between accuracy +and simplicity of geometry. In certain cases where two cubic nodes are +touching, one face may be offset slightly to avoid duplicate faces while still +allowing both faces to be visible. ## License -All code is licensed under the GNU LGPL v3.0. +Meshport is licensed under the GNU LGPL v3.0. diff --git a/api.lua b/api.lua deleted file mode 100644 index 32e322b..0000000 --- a/api.lua +++ /dev/null @@ -1,189 +0,0 @@ -meshport.Faces = {} - -function meshport.Faces:new() - local o = { - faces = {}, - } - - self.__index = self - setmetatable(o, self) - return o -end - -function meshport.Faces:insert_face(face) - table.insert(self.faces, face) -end - -function meshport.Faces:copy() - local newFaces = meshport.Faces:new() - - -- Using `table.copy` on all of `self.faces` does not work here. - newFaces.faces = table.copy(self.faces) - -- for _, face in ipairs(self.faces) do - -- table.insert(newFaces.faces, table.copy(face)) - -- end - - return newFaces -end - -function meshport.Faces:translate(vec) - for _, face in ipairs(self.faces) do - for i, vert in ipairs(face.verts) do - face.verts[i] = vector.add(vert, vec) - end - end -end - -function meshport.Faces:rotate_by_facedir(facedir) - if facedir == 0 then - return - end - - for _, face in ipairs(self.faces) do - -- Rotate vertices. - for i = 1, #face.verts do - face.verts[i] = meshport.rotate_vector_by_facedir(face.verts[i], facedir) - end - - -- Rotate vertex normals. - for i = 1, #face.vert_norms do - face.vert_norms[i] = meshport.rotate_vector_by_facedir(face.vert_norms[i], facedir) - end - end -end - -function meshport.Faces:apply_tiles(nodeDef) - local tile - - for _, face in ipairs(self.faces) do - tile = meshport.get_tile(nodeDef.tiles, face.tile_idx) - face.texture = tile.name or tile - end -end - -meshport.Mesh = {} - -function meshport.Mesh:new() - local o = { - verts = {}, - vert_norms = {}, - tex_coords = {}, - faces = {}, - } - - setmetatable(o, self) - self.__index = self - return o -end - -function meshport.Mesh:insert_face(face) - local indices = { - verts = {}, - vert_norms = {}, - tex_coords = {}, - } - - local elementStr, vec - - -- Add vertices to mesh. - for i, vert in ipairs(face.verts) do - -- Invert Z axis to comply with Blender's coordinate system. - vec = meshport.clean_vector({x = vert.x, y = vert.y, z = -vert.z}) - elementStr = string.format("v %f %f %f\n", vec.x, vec.y, vec.z) - indices.verts[i] = meshport.find_or_insert(self.verts, elementStr) - end - - -- Add texture coordinates (UV map). - for i, texCoord in ipairs(face.tex_coords) do - elementStr = string.format("vt %f %f\n", texCoord.x, texCoord.y) - indices.tex_coords[i] = meshport.find_or_insert(self.tex_coords, elementStr) - end - - -- Add vertex normals. - for i, vertNorm in ipairs(face.vert_norms) do - -- Invert Z axis. - vec = meshport.clean_vector({x = vertNorm.x, y = vertNorm.y, z = -vertNorm.z}) - elementStr = string.format("vn %f %f %f\n", vec.x, vec.y, vec.z) - indices.vert_norms[i] = meshport.find_or_insert(self.vert_norms, elementStr) - end - - -- Add faces to mesh. - local vertStrs = {} - local vertList = {} - - for i = 1, #indices.verts do - vertList = table.insert(vertStrs, table.concat({ - indices.verts[i], - -- If there is a vertex normal but not a texture coordinate, insert a blank string here. - indices.tex_coords[i] or (indices.vert_norms[i] and ""), - indices.vert_norms[i], - }, "/")) - end - - self.faces[face.texture] = self.faces[face.texture] or {} - table.insert(self.faces[face.texture], string.format("f %s\n", table.concat(vertStrs, " "))) -end - -function meshport.Mesh:write_obj(path) - local objFile = io.open(path .. DIR_DELIM .. "/model.obj", "w") - - objFile:write("# Created using meshport (https://github.com/random-geek/meshport).\n") - objFile:write("mtllib materials.mtl\n") - - -- Write vertices. - for _, vert in ipairs(self.verts) do - objFile:write(vert) - end - - -- Write texture coordinates. - for _, texCoord in ipairs(self.tex_coords) do - objFile:write(texCoord) - end - - -- Write vertex normals. - for _, vertNorm in ipairs(self.vert_norms) do - objFile:write(vertNorm) - end - - -- Write faces, sorted in order of material. - for mat, faces in pairs(self.faces) do - objFile:write(string.format("usemtl %s\n", mat)) - - for _, face in ipairs(faces) do - objFile:write(face) - end - end - - objFile:close() -end - -function meshport.Mesh:write_mtl(path, playerName) - local textures = meshport.get_asset_paths("textures") - local matFile = io.open(path .. "/materials.mtl", "w") - - matFile:write("# Created using meshport (https://github.com/random-geek/meshport).\n\n") - - -- Write material information. - for mat, _ in pairs(self.faces) do - matFile:write(string.format("newmtl %s\n", mat)) - - -- Attempt to get the base texture, ignoring texture modifiers. - local texName = string.match(mat, "[%w%s%-_%.]+%.png") or mat - - if textures[texName] then - if texName ~= mat then - meshport.print(playerName, "warning", string.format("Ignoring texture modifers in material %q.", mat)) - end - - matFile:write(string.format("map_Kd %s\n\n", textures[texName])) - else - meshport.print(playerName, "warning", - string.format("Could not find texture %q. Using a dummy material instead.", texName)) - matFile:write(string.format("Kd %f %f %f\n\n", math.random(), math.random(), math.random())) - end - - matFile:write("\n\n") - end - - matFile:close() -end diff --git a/export.lua b/export.lua index 58aa7e2..326b326 100644 --- a/export.lua +++ b/export.lua @@ -1,67 +1,564 @@ -meshport.nodebox_cache = {} -meshport.mesh_cache = {} +--[[ + Copyright (C) 2021 random-geek (https://github.com/random-geek) + Minetest: Copyright (C) 2010-2021 celeron55, Perttu Ahola -meshport.cube_face_priority = { + This file is part of Meshport. + + Meshport is free software: you can redistribute it and/or modify it under + the terms of the GNU Lesser General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) + any later version. + + Meshport is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for + more details. + + You should have received a copy of the GNU Lesser General Public License + along with Meshport. If not, see . +]] + +-- Much of the mesh generation code in this file is derived from Minetest's +-- MapblockMeshGenerator class. See minetest/src/client/content_mapblock.cpp. + +--[[ + THE CUBIC NODE PRIORITY SYSTEM + + For each face on each cubic node, Meshport decides whether or not to draw + that face based on a combination of the current node's drawtype (show in + the top row of the table below), the neighboring node's drawtype (shown in + the leftmost column), the direction of the face, and both nodes' + visual_scale. + + A "YES" combination means the face is drawn, "no" means the face is not + drawn, and "Offset" means the face is drawn, but slightly inset to avoid + duplication of faces. + + | This node => | allfaces (1) | glasslike | liquid | normal (2) | + |--------------:|:------------:|:---------:|:-------:|:----------:| + | air/non-cubic | YES | YES | YES (3) | YES | + | allfaces | (4) | YES | YES | YES | + | glasslike | Offset | (5) | YES | YES | + | liquid | Offset | Offset | no | YES | + | normal (2) | no | no | no | no | + + 1. Allfaces faces are always drawn if `visual_scale` is not 1. + 2. The base of `plantlike_rooted` is treated as a normal node. + 3. Liquid faces are not drawn bordering a corresponding flowing liquid. + 4. Only drawn if facing X+, Y+, or Z+, or if either node's `visual_scale` + is not 1. + 5. Only drawn if the nodes are different. X-, Z-, and Y- faces are offset. +]] + +local CUBIC_FACE_PRIORITY = { allfaces = 1, - glasslike_framed = 2, - glasslike = 3, + glasslike = 2, + liquid = 3, normal = 4, + plantlike_rooted = 4, -- base of plantlike_rooted is equivalent to `normal`. } -function meshport.create_cube_node(nodeDrawtype, nodeTiles, pos, facedir, neighbors) - local sideFaces = { - {{x = 0.5, y = 0.5, z =-0.5}, {x = 0.5, y = 0.5, z = 0.5}, {x =-0.5, y = 0.5, z = 0.5}, {x =-0.5, y = 0.5, z =-0.5}}, -- Y+ - {{x = 0.5, y =-0.5, z = 0.5}, {x = 0.5, y =-0.5, z =-0.5}, {x =-0.5, y =-0.5, z =-0.5}, {x =-0.5, y =-0.5, z = 0.5}}, -- Y- - {{x = 0.5, y =-0.5, z = 0.5}, {x = 0.5, y = 0.5, z = 0.5}, {x = 0.5, y = 0.5, z =-0.5}, {x = 0.5, y =-0.5, z =-0.5}}, -- X+ - {{x =-0.5, y =-0.5, z =-0.5}, {x =-0.5, y = 0.5, z =-0.5}, {x =-0.5, y = 0.5, z = 0.5}, {x =-0.5, y =-0.5, z = 0.5}}, -- X- - {{x =-0.5, y =-0.5, z = 0.5}, {x =-0.5, y = 0.5, z = 0.5}, {x = 0.5, y = 0.5, z = 0.5}, {x = 0.5, y =-0.5, z = 0.5}}, -- Z+ - {{x = 0.5, y =-0.5, z =-0.5}, {x = 0.5, y = 0.5, z =-0.5}, {x =-0.5, y = 0.5, z =-0.5}, {x =-0.5, y =-0.5, z =-0.5}}, -- Z- - } +local vec = vector.new -- Makes defining tables of vertices a little less painful. - local texCoords = {{x = 1, y = 0}, {x = 1, y = 1}, {x = 0, y = 1}, {x = 0, y = 0}} +local CUBIC_SIDE_FACES = { + {vec(-0.5, 0.5, -0.5), vec( 0.5, 0.5, -0.5), vec( 0.5, 0.5, 0.5), vec(-0.5, 0.5, 0.5)}, -- Y+ + {vec(-0.5, -0.5, 0.5), vec( 0.5, -0.5, 0.5), vec( 0.5, -0.5, -0.5), vec(-0.5, -0.5, -0.5)}, -- Y- + {vec( 0.5, -0.5, -0.5), vec( 0.5, -0.5, 0.5), vec( 0.5, 0.5, 0.5), vec( 0.5, 0.5, -0.5)}, -- X+ + {vec(-0.5, -0.5, 0.5), vec(-0.5, -0.5, -0.5), vec(-0.5, 0.5, -0.5), vec(-0.5, 0.5, 0.5)}, -- X- + {vec( 0.5, -0.5, 0.5), vec(-0.5, -0.5, 0.5), vec(-0.5, 0.5, 0.5), vec( 0.5, 0.5, 0.5)}, -- Z+ + {vec(-0.5, -0.5, -0.5), vec( 0.5, -0.5, -0.5), vec( 0.5, 0.5, -0.5), vec(-0.5, 0.5, -0.5)}, -- Z- +} + + +-- For normal, plantlike_rooted, and liquid drawtypes +local function create_cubic_node(pos, content, param2, nodeDef, drawtype, neighbors) + local facedir = meshport.get_facedir(nodeDef.paramtype2, param2) + local selfPriority = CUBIC_FACE_PRIORITY[drawtype] + -- If the current node is a liquid, get the flowing version of it. + local flowingLiquid = drawtype == "liquid" + and meshport.get_content_id_or_nil(nodeDef.liquid_alternative_flowing) or nil local faces = meshport.Faces:new() - -- For glasslike_framed nodes, only the first tile is used. - local tileIdx = nodeDrawtype == "glasslike_framed" and 1 or nil - local neighborDrawtype, vertNorm for i = 1, 6 do - neighborDrawtype = meshport.get_aliased_drawtype(meshport.get_def_from_id(neighbors[i]).drawtype) + local drawFace - if meshport.cube_face_priority[nodeDrawtype] > (meshport.cube_face_priority[neighborDrawtype] or 0) - -- For allfaces nodes (such are leaves), interior faces are drawn only when facing X+, Y+, or Z+. - or (nodeDrawtype == "allfaces" and neighborDrawtype == "allfaces" and i % 2 == 1) then - vertNorm = meshport.neighbor_dirs[i] + if neighbors[i] == minetest.CONTENT_AIR then + drawFace = true + elseif neighbors[i] == minetest.CONTENT_IGNORE + -- Don't draw faces between identical nodes + or neighbors[i] == content + -- Don't draw liquid faces bordering a corresponding flowing liquid + or neighbors[i] == flowingLiquid then + drawFace = false + else + local neighborDef = meshport.get_def_from_id(neighbors[i]) + local neighborDrawtype = meshport.get_aliased_drawtype(neighborDef.drawtype) + drawFace = selfPriority > (CUBIC_FACE_PRIORITY[neighborDrawtype] or 0) + end + + if drawFace then + local norm = meshport.NEIGHBOR_DIRS[i] faces:insert_face(meshport.prepare_cuboid_face({ - verts = sideFaces[i], - tex_coords = texCoords, - vert_norms = {vertNorm, vertNorm, vertNorm, vertNorm}, - tile_idx = tileIdx, - }, nodeTiles, pos, facedir, i)) + verts = table.copy(CUBIC_SIDE_FACES[i]), + vert_norms = {norm, norm, norm, norm}, + tex_coords = {{x = 0, y = 0}, {x = 1, y = 0}, {x = 1, y = 1}, {x = 0, y = 1}}, + }, nodeDef.tiles, pos, facedir, i)) end end return faces end -function meshport.create_nodebox_node(nodeName, pos, facedir, param2, neighbors) + +-- For allfaces and glasslike drawtypes, and equivalent variants. +local function create_special_cubic_node(pos, content, nodeDef, drawtype, neighbors) + local selfPriority = CUBIC_FACE_PRIORITY[drawtype] + local isAllfaces = drawtype == "allfaces" + local allfacesScale = isAllfaces and nodeDef.visual_scale or 1 + + local faces = meshport.Faces:new() + + for i = 1, 6 do + local drawFace + local inset = false + + if allfacesScale ~= 1 or neighbors[i] == minetest.CONTENT_AIR or neighbors[i] == minetest.CONTENT_IGNORE then + drawFace = true + elseif neighbors[i] == content then + drawFace = isAllfaces and i % 2 == 1 + else + local neighborDef = meshport.get_def_from_id(neighbors[i]) + local neighborDrawtype = meshport.get_aliased_drawtype(neighborDef.drawtype) + local neighborPriority = CUBIC_FACE_PRIORITY[neighborDrawtype] or 0 + + if neighborPriority < selfPriority then + drawFace = true + elseif neighborPriority >= 4 then + -- Don't draw faces bordering normal nodes. + drawFace = false + elseif neighborPriority > selfPriority then + drawFace = true + inset = true + elseif isAllfaces then -- neighborPriority == selfPriority + drawFace = i % 2 == 1 or neighborDef.visual_scale ~= 1 + else -- neighborPriority == selfPriority + drawFace = true + inset = i % 2 == 0 + end + end + + if drawFace then + local verts = table.copy(CUBIC_SIDE_FACES[i]) + + if inset then + local offset = vector.multiply(meshport.NEIGHBOR_DIRS[i], -0.003) + for j, vert in ipairs(verts) do + verts[j] = vector.add(vert, offset) + end + end + + local norm = meshport.NEIGHBOR_DIRS[i] + faces:insert_face(meshport.prepare_cuboid_face({ + verts = verts, + vert_norms = {norm, norm, norm, norm}, + tex_coords = {{x = 0, y = 0}, {x = 1, y = 0}, {x = 1, y = 1}, {x = 0, y = 1}}, + tile_idx = 1, -- Only the first tile is used. + }, nodeDef.tiles, pos, 0, i)) + end + end + + faces:scale(allfacesScale) + return faces +end + + +local GLASSLIKE_FRAMED_CONSTANTS = (function() + local a = 0.5 + local g = 0.5 - 0.003 + local b = 0.876 * 0.5 + + return { + G = g, + B = b, + FRAME_EDGES = { + { b, b, -a, a, a, a}, -- Y+ / X+ + {-a, b, -a, -b, a, a}, -- Y+ / X- + { b, -a, -a, a, -b, a}, -- Y- / X+ + {-a, -a, -a, -b, -b, a}, -- Y- / X- + { b, -a, b, a, a, a}, -- X+ / Z+ + { b, -a, -a, a, a, -b}, -- X+ / Z- + {-a, -a, b, -b, a, a}, -- X- / Z+ + {-a, -a, -a, -b, a, -b}, -- X- / Z- + {-a, b, b, a, a, a}, -- Z+ / Y+ + {-a, -a, b, a, -b, a}, -- Z+ / Y- + {-a, b, -a, a, a, -b}, -- Z- / Y+ + {-a, -a, -a, a, -b, -b}, -- Z- / Y- + }, + GLASS_FACES = { + {vec(-a, g, -a), vec( a, g, -a), vec( a, g, a), vec(-a, g, a)}, -- Y+ + {vec(-a, -g, a), vec( a, -g, a), vec( a, -g, -a), vec(-a, -g, -a)}, -- Y- + {vec( g, -a, -a), vec( g, -a, a), vec( g, a, a), vec( g, a, -a)}, -- X+ + {vec(-g, -a, a), vec(-g, -a, -a), vec(-g, a, -a), vec(-g, a, a)}, -- X- + {vec( a, -a, g), vec(-a, -a, g), vec(-a, a, g), vec( a, a, g)}, -- Z+ + {vec(-a, -a, -g), vec( a, -a, -g), vec( a, a, -g), vec(-a, a, -g)}, -- Z- + }, + EDGE_NEIGHBORS = { + {1, 3, 8}, {1, 4, 7}, {2, 3, 16}, {2, 4, 15}, + {3, 5, 12}, {3, 6, 14}, {4, 5, 11}, {4, 6, 13}, + {5, 1, 9}, {5, 2, 17}, {6, 1, 10}, {6, 2, 18}, + }, + } +end)() + + +local function create_glasslike_framed_node(pos, param2, nodeDef, area, vContent) + local idx = area:indexp(pos) + local llParam2 = nodeDef.paramtype2 == "glasslikeliquidlevel" and param2 or 0 + local hMerge = llParam2 < 128 -- !(param2 & 128) + local vMerge = llParam2 % 128 < 64 -- !(param2 & 64) + local intLevel = llParam2 % 64 + + -- Localize constants + local G, B, FRAME_EDGES, GLASS_FACES, EDGE_NEIGHBORS = (function(c) + return c.G, c.B, c.FRAME_EDGES, c.GLASS_FACES, c.EDGE_NEIGHBORS + end)(GLASSLIKE_FRAMED_CONSTANTS) + + local neighbors = { + false, false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, false + } + + if hMerge or vMerge then + for i = 1, 18 do + local dir = meshport.NEIGHBOR_DIRS[i] + if (hMerge or (dir.x == 0 and dir.z == 0)) and (vMerge or dir.y == 0) then + local nIdx = area:indexp(vector.add(pos, dir)) + neighbors[i] = vContent[nIdx] == vContent[idx] + end + end + end + + local boxes = meshport.Boxes:new() + + for i = 1, 12 do + local edgeVisible + local touching = EDGE_NEIGHBORS[i] + + if neighbors[touching[3]] then + edgeVisible = not (neighbors[touching[1]] and neighbors[touching[2]]) + else + edgeVisible = neighbors[touching[1]] == neighbors[touching[2]] + end + + if edgeVisible then + boxes:insert_box(FRAME_EDGES[i]) + end + end + + local faces = boxes:to_faces(nodeDef, pos, 0, 1) + + for i = 1, 6 do + if not neighbors[i] then + local norm = meshport.NEIGHBOR_DIRS[i] + + faces:insert_face({ + verts = table.copy(GLASS_FACES[i]), + vert_norms = {norm, norm, norm, norm}, + tex_coords = {{x = 0, y = 0}, {x = 1, y = 0}, {x = 1, y = 1}, {x = 0, y = 1}}, + tile_idx = 2, + }) + end + end + + if intLevel > 0 and nodeDef.special_tiles and nodeDef.special_tiles[1] then + local level = intLevel / 63 * 2 - 1 + local liquidBoxes = meshport.Boxes:new() + liquidBoxes:insert_box({ + -(neighbors[4] and G or B), + -(neighbors[2] and G or B), + -(neighbors[6] and G or B), + (neighbors[3] and G or B), + (neighbors[1] and G or B) * level, + (neighbors[5] and G or B) + }) + faces:insert_all(liquidBoxes:to_faces(nodeDef, pos, 0, 1, true)) + end + + return faces +end + + +local FLOWING_LIQUID_CONSTANTS = { + SIDE_DIRS = {vec(1, 0, 0), vec(-1, 0, 0), vec(0, 0, 1), vec(0, 0, -1)}, + SIDE_CORNERS = { + {{x = 1, z = 1}, {x = 1, z = 0}}, -- X+ + {{x = 0, z = 0}, {x = 0, z = 1}}, -- X- + {{x = 0, z = 1}, {x = 1, z = 1}}, -- Z+ + {{x = 1, z = 0}, {x = 0, z = 0}}, -- Z- + }, +} + + +local function create_flowing_liquid_node(pos, nodeDef, area, vContent, vParam2) + local cSource = meshport.get_content_id_or_nil(nodeDef.liquid_alternative_source) + local cFlowing = meshport.get_content_id_or_nil(nodeDef.liquid_alternative_flowing) + local range = math.min(math.max(meshport.get_def_from_id(cFlowing).liquid_range or 8, 1), 8) + + --[[ Step 1: Gather neighbor data ]] + local neighbors = {[-1] = {}, [0] = {}, [1] = {}} + + for dz = -1, 1 do + for dx = -1, 1 do + local nPos = vector.add(pos, vector.new(dx, 0, dz)) + local nIdx = area:indexp(nPos) + + neighbors[dz][dx] = { + content = vContent[nIdx], + level = -0.5, + is_same_liquid = false, + top_is_same_liquid = false, + } + local nData = neighbors[dz][dx] + + if vContent[nIdx] ~= minetest.CONTENT_IGNORE then + if vContent[nIdx] == cSource then + nData.is_same_liquid = true + nData.level = 0.5 + elseif vContent[nIdx] == cFlowing then + nData.is_same_liquid = true + local intLevel = math.max(vParam2[nIdx] % 8 - 8 + range, 0) + nData.level = -0.5 + (intLevel + 0.5) / range + end + + local tPos = vector.add(nPos, vector.new(0, 1, 0)) + local tIdx = area:indexp(tPos) + if vContent[tIdx] == cSource or vContent[tIdx] == cFlowing then + nData.top_is_same_liquid = true + end + end + end + end + + --[[ Step 2: Determine level at each corner ]] + local cornerLevels = {[0] = {[0] = 0, 0}, {[0] = 0, 0}} + + local function get_corner_level(cx, cz) + local sum = 0 + local count = 0 + local airCount = 0 + + for dz = -1, 0 do + for dx = -1, 0 do + local nData = neighbors[cz + dz][cx + dx] + + if nData.top_is_same_liquid or nData.content == cSource then + return 0.5 + elseif nData.content == cFlowing then + sum = sum + nData.level + count = count + 1 + elseif nData.content == minetest.CONTENT_AIR then + airCount = airCount + 1 + + if airCount >= 2 then + return -0.5 + 0.02 + end + end + end + end + + if count > 0 then + return sum / count + end + + return 0 + end + + for cz = 0, 1 do + for cx = 0, 1 do + cornerLevels[cz][cx] = get_corner_level(cx, cz) + end + end + + --[[ Step 3: Actually create the liquid mesh ]] + local faces = meshport.Faces:new() + + -- Localize constants + local SIDE_DIRS, SIDE_CORNERS = (function(c) + return c.SIDE_DIRS, c.SIDE_CORNERS + end)(FLOWING_LIQUID_CONSTANTS) + + -- Add side faces + local sideVerts = { + {vec( 0.5, 0.5, 0.5), vec( 0.5, 0.5, -0.5), vec( 0.5, -0.5, -0.5), vec( 0.5, -0.5, 0.5)}, -- X+ + {vec(-0.5, 0.5, -0.5), vec(-0.5, 0.5, 0.5), vec(-0.5, -0.5, 0.5), vec(-0.5, -0.5, -0.5)}, -- X- + {vec(-0.5, 0.5, 0.5), vec( 0.5, 0.5, 0.5), vec( 0.5, -0.5, 0.5), vec(-0.5, -0.5, 0.5)}, -- Z+ + {vec( 0.5, 0.5, -0.5), vec(-0.5, 0.5, -0.5), vec(-0.5, -0.5, -0.5), vec( 0.5, -0.5, -0.5)}, -- Z- + } + + local function need_side(dir) + local neighbor = neighbors[dir.z][dir.x] + if neighbor.is_same_liquid + and (not neighbors[0][0].top_is_same_liquid or neighbor.top_is_same_liquid) then + return false + end + + local nContent = neighbors[dir.z][dir.x].content + local drawtype = meshport.get_aliased_drawtype(meshport.get_def_from_id(nContent).drawtype) + if (CUBIC_FACE_PRIORITY[drawtype] or 0) >= 4 then + return false -- Don't draw bordering normal nodes + end + + return true + end + + for i = 1, 4 do + local dir = SIDE_DIRS[i] + + if need_side(dir) then + local verts = sideVerts[i] + local sideTexCoords = {{x = 1, y = 1}, {x = 0, y = 1}, {x = 0, y = 0}, {x = 1, y = 0}} + + if not neighbors[0][0].top_is_same_liquid then -- If there's liquid above, default to a full block. + local corners = SIDE_CORNERS[i] + + for j = 1, 2 do + local corner = cornerLevels[corners[j].z][corners[j].x] + verts[j].y = corner + sideTexCoords[j].y = corner + 0.5 + end + end + + faces:insert_face({ + verts = verts, + vert_norms = {dir, dir, dir, dir}, + tex_coords = sideTexCoords, + tile_idx = 2, + use_special_tiles = true, + }) + end + end + + -- Add top faces + if not neighbors[0][0].top_is_same_liquid then -- Check node above the current node + local verts = { + vec( 0.5, cornerLevels[0][1], -0.5), + vec( 0.5, cornerLevels[1][1], 0.5), + vec(-0.5, cornerLevels[1][0], 0.5), + vec(-0.5, cornerLevels[0][0], -0.5), + } + + local norm1 = vector.normalize(vector.cross( + vector.subtract(verts[1], verts[2]), + vector.subtract(verts[3], verts[2]) + )) + local norm2 = vector.normalize(vector.cross( + vector.subtract(verts[3], verts[4]), + vector.subtract(verts[1], verts[4]) + )) + + local dz = (cornerLevels[0][0] + cornerLevels[0][1]) - + (cornerLevels[1][0] + cornerLevels[1][1]) + local dx = (cornerLevels[0][0] + cornerLevels[1][0]) - + (cornerLevels[0][1] + cornerLevels[1][1]) + local textureAngle = -math.atan2(dz, dx) + + -- Get texture coordinate offset based on position. + local tx, ty = pos.z, -pos.x + -- Rotate offset around (0, 0) by textureAngle. + -- Then isolate the fractional part, since the texture is tiled anyway. + local sinTA = math.sin(textureAngle) + local cosTA = math.cos(textureAngle) + local textureOffset = { + x = (tx * cosTA - ty * sinTA) % 1, + y = (tx * sinTA + ty * cosTA) % 1 + } + + faces:insert_face({ + verts = {verts[1], verts[2], verts[3]}, + vert_norms = {norm1, norm1, norm1}, + tex_coords = meshport.translate_texture_coordinates( + meshport.rotate_texture_coordinates_rad( + {{x = 0, y = 0}, {x = 1, y = 0}, {x = 1, y = 1}}, + textureAngle + ), + textureOffset + ), + tile_idx = 1, + use_special_tiles = true, + }) + faces:insert_face({ + verts = {verts[3], verts[4], verts[1]}, + vert_norms = {norm2, norm2, norm2}, + tex_coords = meshport.translate_texture_coordinates( + meshport.rotate_texture_coordinates_rad( + {{x = 1, y = 1}, {x = 0, y = 1}, {x = 0, y = 0}}, + textureAngle + ), + textureOffset + ), + tile_idx = 1, + use_special_tiles = true, + }) + end + + -- Add bottom face + local function need_liquid_bottom() + local bContent = vContent[area:indexp(vector.add(pos, vector.new(0, -1, 0)))] + if bContent == cSource or bContent == cFlowing then + return false + end + + local drawtype = meshport.get_aliased_drawtype(meshport.get_def_from_id(bContent).drawtype) + if (CUBIC_FACE_PRIORITY[drawtype] or 0) >= 4 then + return false -- Don't draw bordering normal nodes + end + + return true + end + + if need_liquid_bottom() then + local norm = vector.new(0, -1, 0) + + faces:insert_face({ + verts = { + vec(-0.5, -0.5, 0.5), + vec( 0.5, -0.5, 0.5), + vec( 0.5, -0.5, -0.5), + vec(-0.5, -0.5, -0.5), + }, + vert_norms = {norm, norm, norm, norm}, + tex_coords = {{x = 0, y = 0}, {x = 1, y = 0}, {x = 1, y = 1}, {x = 0, y = 1}}, + tile_idx = 1, + use_special_tiles = true, + }) + end + + return faces +end + + +local function create_nodebox_node(pos, content, param2, neighbors) + local nodeName = minetest.get_name_from_content_id(content) local nodeDef = minetest.registered_nodes[nodeName] if not meshport.nodebox_cache[nodeName] then meshport.nodebox_cache[nodeName] = meshport.prepare_nodebox(nodeDef.node_box) end - local boxes = meshport.collect_boxes(meshport.nodebox_cache[nodeName], nodeDef, facedir, param2, neighbors) + local facedir = meshport.get_facedir(nodeDef.paramtype2, param2) + local boxes = meshport.collect_boxes(meshport.nodebox_cache[nodeName], nodeDef, param2, facedir, neighbors) if meshport.nodebox_cache[nodeName].type ~= "connected" then boxes:rotate_by_facedir(facedir) end - return boxes:to_faces(nodeDef.tiles, pos, facedir) + return boxes:to_faces(nodeDef, pos, facedir) end -function meshport.create_mesh_node(nodeDef, playerName) + +local function create_mesh_node(nodeDef, param2, playerName) local meshName = nodeDef.mesh if not meshName then @@ -76,97 +573,227 @@ function meshport.create_mesh_node(nodeDef, playerName) if not meshport.obj_paths[meshName] then if string.lower(string.sub(meshName, -4)) ~= ".obj" then - meshport.print(playerName, "warning", string.format("Mesh %q is not supported.", meshName)) + meshport.log(playerName, "warning", string.format("Mesh %q is not supported.", meshName)) else - meshport.print(playerName, "warning", string.format("Mesh %q could not be found.", meshName)) + meshport.log(playerName, "warning", string.format("Mesh %q could not be found.", meshName)) end -- Cache a blank faces object so the player isn't warned again. meshport.mesh_cache[meshName] = meshport.Faces:new() else - meshport.mesh_cache[meshName] = meshport.parse_obj(meshport.obj_paths[meshName]) + -- TODO: pcall this in case of failure + local meshFaces = meshport.parse_obj(meshport.obj_paths[meshName]) + meshFaces:scale(nodeDef.visual_scale) + meshport.mesh_cache[meshName] = meshFaces end end - return meshport.mesh_cache[meshName]:copy() + local faces = meshport.mesh_cache[meshName]:copy() + + local facedir = meshport.get_facedir(nodeDef.paramtype2, param2) + faces:rotate_by_facedir(facedir) + + local rotation = meshport.get_degrotate(nodeDef.paramtype2, param2) + faces:rotate_xz_degrees(rotation) + + return faces end -function meshport.create_node(idx, area, content, param2, playerName) - if content[idx] == minetest.CONTENT_AIR or content[idx] == minetest.CONTENT_IGNORE then + +local function create_plantlike_node(pos, param2, nodeDef) + local style = 0 + local height = 1.0 + local scale = 0.5 * nodeDef.visual_scale + local rotation = meshport.get_degrotate(nodeDef.paramtype2, param2) + local offset = vector.new(0, 0, 0) + local randomOffsetY = false + local faceNum = 0 + + if nodeDef.paramtype2 == "meshoptions" then + style = param2 % 8 + + if param2 % 16 >= 8 then -- param2 % 8 + -- TODO: Use MT's seed generators + local seed = (pos.x % 0xFF) * 0x100 + (pos.z % 0xFF) + (pos.y % 0xFF) * 0x10000 + local rng = PseudoRandom(seed) + offset.x = (rng:next() % 16 / 16) * 0.29 - 0.145 + offset.z = (rng:next() % 16 / 16) * 0.29 - 0.145 + end + + if param2 % 32 >= 16 then -- param2 & 16 + scale = scale * 1.41421 + end + + if param2 % 64 >= 32 then -- param2 & 32 + randomOffsetY = true + end + elseif nodeDef.paramtype2 == "leveled" then + height = param2 / 16 + end + + local function create_plantlike_quad(faceRotation, topOffset, bottomOffset) + local faces = meshport.Faces:new() + local plantHeight = 2.0 * scale * height + local norm = vector.normalize(vector.new(0, bottomOffset - topOffset, plantHeight)) + + faces:insert_face({ + verts = { + vec(-scale, -0.5 + plantHeight, topOffset), + vec( scale, -0.5 + plantHeight, topOffset), + vec( scale, -0.5, bottomOffset), + vec(-scale, -0.5, bottomOffset), + }, + tex_coords = {{x = 0, y = height}, {x = 1, y = height}, {x = 1, y = 0}, {x = 0, y = 0}}, + vert_norms = {norm, norm, norm, norm}, + tile_idx = 0, + use_special_tiles = nodeDef.drawtype == "plantlike_rooted" + }) + + if randomOffsetY then + local seed = faceNum + (pos.x % 0xFF) * 0x10000 + (pos.z % 0xFF) * 0x100 + (pos.y % 0xFF) * 0x1000000 + local yRng = PseudoRandom(seed) + faces:translate(vector.new(0, (yRng:next() % 16) / 16.0 * -0.125, 0)) + faceNum = faceNum + 1 + end + + faces:rotate_xz_degrees(faceRotation + rotation) + return faces + end + + local faces = meshport.Faces:new() + + if style == 0 then + faces:insert_all(create_plantlike_quad(46, 0, 0)) + faces:insert_all(create_plantlike_quad(-44, 0, 0)) + elseif style == 1 then + faces:insert_all(create_plantlike_quad(91, 0, 0)) + faces:insert_all(create_plantlike_quad(1, 0, 0)) + elseif style == 2 then + faces:insert_all(create_plantlike_quad(121, 0, 0)) + faces:insert_all(create_plantlike_quad(241, 0, 0)) + faces:insert_all(create_plantlike_quad(1, 0, 0)) + elseif style == 3 then + faces:insert_all(create_plantlike_quad(1, 0.25, 0.25)) + faces:insert_all(create_plantlike_quad(91, 0.25, 0.25)) + faces:insert_all(create_plantlike_quad(181, 0.25, 0.25)) + faces:insert_all(create_plantlike_quad(271, 0.25, 0.25)) + elseif style == 4 then + faces:insert_all(create_plantlike_quad(1, -0.5, 0)) + faces:insert_all(create_plantlike_quad(91, -0.5, 0)) + faces:insert_all(create_plantlike_quad(181, -0.5, 0)) + faces:insert_all(create_plantlike_quad(271, -0.5, 0)) + end + + faces:translate(offset) + return faces +end + + +local function create_node(idx, area, vContent, vParam2, playerName) + if vContent[idx] == minetest.CONTENT_AIR + or vContent[idx] == minetest.CONTENT_IGNORE + or vContent[idx] == minetest.CONTENT_UNKNOWN then -- TODO: Export unknown nodes? return end - local nodeDef = meshport.get_def_from_id(content[idx]) - - if not nodeDef.drawtype or nodeDef.drawtype == "airlike" then + local nodeDef = meshport.get_def_from_id(vContent[idx]) + if nodeDef.drawtype == "airlike" then return end + local pos = area:position(idx) local nodeDrawtype = meshport.get_aliased_drawtype(nodeDef.drawtype) - local facedir = meshport.get_facedir(param2[idx], nodeDef.paramtype2) - local isCubicType = meshport.cube_face_priority[nodeDrawtype] or nodeDrawtype == "nodebox" local neighbors, faces - if isCubicType then - neighbors = meshport.get_node_neighbors(content, area, idx) + if CUBIC_FACE_PRIORITY[nodeDrawtype] or nodeDrawtype == "nodebox" then + neighbors = meshport.get_node_neighbors(vContent, area, idx) end - if meshport.cube_face_priority[nodeDrawtype] then - faces = meshport.create_cube_node(nodeDrawtype, nodeDef.tiles, area:position(idx), facedir, neighbors) + if (CUBIC_FACE_PRIORITY[nodeDrawtype] or 0) >= 3 then -- liquid, normal, plantlike_rooted + faces = create_cubic_node(pos, vContent[idx], vParam2[idx], nodeDef, nodeDrawtype, neighbors) + + if nodeDrawtype == "plantlike_rooted" then + local plantPos = vector.add(pos, vector.new(0, 1, 0)) + local plantFaces = create_plantlike_node(nodeDef, plantPos, vParam2[idx]) + plantFaces:translate(vector.new(0, 1, 0)) + faces:insert_all(plantFaces) + end + elseif CUBIC_FACE_PRIORITY[nodeDrawtype] then -- Any other cubic nodes (allfaces, glasslike) + faces = create_special_cubic_node(pos, vContent[idx], nodeDef, nodeDrawtype, neighbors) + elseif nodeDrawtype == "glasslike_framed" then + faces = create_glasslike_framed_node(pos, vParam2[idx], nodeDef, area, vContent) + elseif nodeDrawtype == "flowingliquid" then + faces = create_flowing_liquid_node(pos, nodeDef, area, vContent, vParam2) elseif nodeDrawtype == "nodebox" then - faces = meshport.create_nodebox_node( - minetest.get_name_from_content_id(content[idx]), area:position(idx), facedir, param2[idx], neighbors) + faces = create_nodebox_node(pos, vContent[idx], vParam2[idx], neighbors) elseif nodeDrawtype == "mesh" then - faces = meshport.create_mesh_node(nodeDef, playerName) + faces = create_mesh_node(nodeDef, vParam2[idx], playerName) + elseif nodeDrawtype == "plantlike" then + faces = create_plantlike_node(pos, vParam2[idx], nodeDef) end if not faces then return end - if not isCubicType then - faces:rotate_by_facedir(facedir) - end - faces:apply_tiles(nodeDef) return faces end + +local function initialize_resources() + meshport.texture_paths = meshport.get_asset_paths("textures") + meshport.texture_dimension_cache = {} + -- meshport.obj_paths is only loaded if needed + meshport.nodebox_cache = {} + meshport.mesh_cache = {} +end + + +local function cleanup_resources() + meshport.texture_paths = nil + meshport.texture_dimension_cache = nil + meshport.obj_paths = nil + meshport.nodebox_cache = nil + meshport.mesh_cache = nil +end + + function meshport.create_mesh(playerName, p1, p2, path) - meshport.print(playerName, "info", "Generating mesh...") + meshport.log(playerName, "info", "Generating mesh...") + initialize_resources() + p1, p2 = vector.sort(p1, p2) local vm = minetest.get_voxel_manip() -- Add one node of padding to area so we can read neighbor blocks. local vp1, vp2 = vm:read_from_map(vector.subtract(p1, 1), vector.add(p2, 1)) - local content = vm:get_data() - local param2 = vm:get_param2_data() + local vContent = vm:get_data() + local vParam2 = vm:get_param2_data() -- Create a VoxelArea for converting from flat array indices to position vectors. local vArea = VoxelArea:new{MinEdge = vp1, MaxEdge = vp2} + local meshOrigin = vector.subtract(p1, 0.5) local mesh = meshport.Mesh:new() - local faces -- Loop through all positions in the desired area. for idx in vArea:iterp(p1, p2) do -- Generate a mesh for the node. - faces = meshport.create_node(idx, vArea, content, param2, playerName) + local faces = create_node(idx, vArea, vContent, vParam2, playerName) if faces then - -- Move the node to its proper position in the mesh. - faces:translate(vector.add(vector.subtract(vArea:position(idx), p1), 0.5)) + -- Move the node to its proper position. + faces:translate(vector.subtract(vArea:position(idx), meshOrigin)) - for _, face in ipairs(faces.faces) do - -- Add each face to our final mesh. - mesh:insert_face(face) - end + -- Add faces to our final mesh. + mesh:insert_faces(faces) end end minetest.mkdir(path) - mesh:write_obj(path) mesh:write_mtl(path, playerName) - meshport.print(playerName, "info", "Finished. Saved to " .. path) + cleanup_resources() + meshport.log(playerName, "info", "Finished. Saved to " .. path) end diff --git a/init.lua b/init.lua index 63a6aa8..4ba057c 100644 --- a/init.lua +++ b/init.lua @@ -1,11 +1,29 @@ +--[[ + Copyright (C) 2021 random-geek (https://github.com/random-geek) + + This file is part of Meshport. + + Meshport is free software: you can redistribute it and/or modify it under + the terms of the GNU Lesser General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) + any later version. + + Meshport is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for + more details. + + You should have received a copy of the GNU Lesser General Public License + along with Meshport. If not, see . +]] + meshport = { - p1 = {}, - p2 = {}, + player_data = {}, } modpath = minetest.get_modpath("meshport") -dofile(modpath .. "/helpers.lua") -dofile(modpath .. "/api.lua") +dofile(modpath .. "/utils.lua") +dofile(modpath .. "/mesh.lua") dofile(modpath .. "/parse_obj.lua") dofile(modpath .. "/nodebox.lua") dofile(modpath .. "/export.lua") @@ -14,8 +32,7 @@ minetest.register_privilege("meshport", "Can save meshes with meshport.") minetest.register_on_leaveplayer(function(player, timed_out) local name = player:get_player_name() - meshport.p1[name] = nil - meshport.p2[name] = nil + meshport.player_data[name] = nil end) for i = 1, 2 do @@ -35,19 +52,23 @@ for i = 1, 2 do end if not pos then - meshport.print(name, "error", "Not a valid position.") + meshport.log(name, "error", "Not a valid position.") return end pos = vector.round(pos) - if i == 1 then - meshport.p1[name] = pos - elseif i == 2 then - meshport.p2[name] = pos + if not meshport.player_data[name] then + meshport.player_data[name] = {} end - meshport.print(name, "info", string.format("Position %i set to %s.", i, minetest.pos_to_string(pos))) + if i == 1 then + meshport.player_data[name].p1 = pos + elseif i == 2 then + meshport.player_data[name].p2 = pos + end + + meshport.log(name, "info", string.format("Position %i set to %s.", i, minetest.pos_to_string(pos))) end, }) end @@ -58,13 +79,15 @@ minetest.register_chatcommand("meshport", { privs = {meshport = true}, func = function(name, filename) - if not meshport.p1[name] or not meshport.p2[name] then - meshport.print(name, "error", "No area selected. Use /mesh1 and /mesh2 to select an area.") + local playerData = meshport.player_data[name] or {} + + if not playerData.p1 or not playerData.p2 then + meshport.log(name, "error", "No area selected. Use /mesh1 and /mesh2 to select an area.") return end if filename:find("[^%w-_]") then - meshport.print(name, "error", "Invalid name supplied. Please use valid characters ([A-Z][a-z][0-9][-_]).") + meshport.log(name, "error", "Invalid name supplied. Please use valid characters ([A-Z][a-z][0-9][-_]).") return elseif filename == "" then filename = os.date("%Y-%m-%d_%H-%M-%S") @@ -74,12 +97,12 @@ minetest.register_chatcommand("meshport", { local folderName = name .. "_" .. filename if table.indexof(minetest.get_dir_list(mpPath, true), folderName) > 0 then - meshport.print(name, "error", + meshport.log(name, "error", string.format("Folder %q already exists. Try using a different name.", folderName)) return end local path = mpPath .. DIR_DELIM .. folderName - meshport.create_mesh(name, meshport.p1[name], meshport.p2[name], path) + meshport.create_mesh(name, playerData.p1, playerData.p2, path) end, }) diff --git a/mesh.lua b/mesh.lua new file mode 100644 index 0000000..b86623c --- /dev/null +++ b/mesh.lua @@ -0,0 +1,338 @@ +--[[ + Copyright (C) 2021 random-geek (https://github.com/random-geek) + + This file is part of Meshport. + + Meshport is free software: you can redistribute it and/or modify it under + the terms of the GNU Lesser General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) + any later version. + + Meshport is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for + more details. + + You should have received a copy of the GNU Lesser General Public License + along with Meshport. If not, see . +]] + +--[[ + A buffer of faces. + + Faces are expected to be in this format: + { + verts = table, -- list of vertices (as vectors) + vert_norms = table, -- list of vertex normals (as vectors) + tex_coords = table, -- list of texture coordinates, e.g. {x = 0.5, y = 1} + tile_idx = int, -- index of tile to use + use_special_tiles = bool, -- if true, use tiles from special_tiles field of nodedef + texture = string, -- name of actual texture to use) + } + + Note to self/contributors--to avoid weird bugs, please follow these rules + regarding table fields: + + 1. Each table of vertices, vertex normals, or texture coordinates should be + unique to its parent face. That is, multiple faces or Faces objects + should not share references to the same table. + 2. Values within these tables are allowed to be duplicated. For example, one + face can reference the same vertex normal four times, and other faces or + Faces objects can also reference the same vertex normal. +]] +meshport.Faces = {} + +function meshport.Faces:new() + local o = { -- TODO: Separate tables for vertices/indices. + faces = {}, + } + + self.__index = self + setmetatable(o, self) + return o +end + +function meshport.Faces:insert_face(face) + table.insert(self.faces, face) +end + +function meshport.Faces:insert_all(faces) + for _, face in ipairs(faces.faces) do + table.insert(self.faces, face) + end +end + +function meshport.Faces:copy() + local newFaces = meshport.Faces:new() + newFaces.faces = table.copy(self.faces) + return newFaces +end + +function meshport.Faces:translate(vec) + if vec.x == 0 and vec.y == 0 and vec.z == 0 then + return + end + + for _, face in ipairs(self.faces) do + for i, vert in ipairs(face.verts) do + face.verts[i] = vector.add(vert, vec) + end + end +end + +function meshport.Faces:scale(scale) + if scale == 1 then + return + end + + for _, face in ipairs(self.faces) do + for i, vert in ipairs(face.verts) do + face.verts[i] = vector.multiply(vert, scale) + end + end +end + +function meshport.Faces:rotate_by_facedir(facedir) + if facedir == 0 then + return + end + + for _, face in ipairs(self.faces) do + for i, vert in ipairs(face.verts) do + face.verts[i] = meshport.rotate_vector_by_facedir(vert, facedir) + end + + for i, norm in ipairs(face.vert_norms) do + face.vert_norms[i] = meshport.rotate_vector_by_facedir(norm, facedir) + end + end +end + +function meshport.Faces:rotate_xz_degrees(degrees) + if degrees == 0 then + return + end + + local rad = math.rad(degrees) + local sinRad = math.sin(rad) + local cosRad = math.cos(rad) + + for _, face in ipairs(self.faces) do + for i, vert in ipairs(face.verts) do + face.verts[i] = vector.new( + vert.x * cosRad - vert.z * sinRad, + vert.y, + vert.x * sinRad + vert.z * cosRad + ) + end + + for i, norm in ipairs(face.vert_norms) do + face.vert_norms[i] = vector.new( + norm.x * cosRad - norm.z * sinRad, + norm.y, + norm.x * sinRad + norm.z * cosRad + ) + end + end +end + +function meshport.Faces:apply_tiles(nodeDef) + for _, face in ipairs(self.faces) do + local tiles + if face.use_special_tiles then + tiles = nodeDef.special_tiles + else + tiles = nodeDef.tiles + end + + local tile = meshport.get_tile(tiles, face.tile_idx) + face.texture = tile.name or tile + + -- If an animated texture is used, scale texture coordinates so only the first image is used. + local animation = tile.animation + if type(animation) == "table" then + local xScale, yScale = 1, 1 + + if animation.type == "vertical_frames" then + local texW, texH = meshport.get_texture_dimensions(tile.name) + if texW and texH then + xScale = (animation.aspect_w or 16) / texW + yScale = (animation.aspect_h or 16) / texH + end + elseif animation.type == "sheet_2d" then + xScale = 1 / (animation.frames_w or 1) + yScale = 1 / (animation.frames_h or 1) + end + + if xScale ~= 1 or yScale ~= 1 then + for i, coord in ipairs(face.tex_coords) do + face.tex_coords[i] = {x = coord.x * xScale, y = coord.y * yScale} + end + end + end + end +end + + +local function clean_vector(vec) + -- Prevents an issue involving negative zero values, which are not handled properly by `string.format`. + return vector.new( + vec.x == 0 and 0 or vec.x, + vec.y == 0 and 0 or vec.y, + vec.z == 0 and 0 or vec.z + ) +end + + +local function bimap_find_or_insert(forward, reverse, item) + local idx = reverse[item] + if not idx then + idx = #forward + 1 + forward[idx] = item + reverse[item] = idx + end + return idx +end + + +-- Stores a mesh in a form which is easily convertible to an .OBJ file. +meshport.Mesh = {} + +function meshport.Mesh:new() + local o = { + -- Using two tables for elements makes insert_face() significantly faster. + -- verts[1] = "0 -1 0" + -- verts_reverse["0 -1 0"] = 1 + -- etc... + verts = {}, + verts_reverse = {}, + vert_norms = {}, + vert_norms_reverse = {}, + tex_coords = {}, + tex_coords_reverse = {}, + faces = {}, + } + + setmetatable(o, self) + self.__index = self + return o +end + +function meshport.Mesh:insert_face(face) + local indices = { + verts = {}, + vert_norms = {}, + tex_coords = {}, + } + + local elementStr, vec + + -- Add vertices to mesh. + for i, vert in ipairs(face.verts) do + -- Invert Z axis to comply with Blender's coordinate system. + vec = clean_vector(vector.new(vert.x, vert.y, -vert.z)) + elementStr = string.format("%f %f %f", vec.x, vec.y, vec.z) + indices.verts[i] = bimap_find_or_insert(self.verts, self.verts_reverse, elementStr) + end + + -- Add texture coordinates (UV map). + for i, texCoord in ipairs(face.tex_coords) do + elementStr = string.format("%f %f", texCoord.x, texCoord.y) + indices.tex_coords[i] = bimap_find_or_insert(self.tex_coords, self.tex_coords_reverse, elementStr) + end + + -- Add vertex normals. + for i, vertNorm in ipairs(face.vert_norms) do + -- Invert Z axis to comply with Blender's coordinate system. + vec = clean_vector(vector.new(vertNorm.x, vertNorm.y, -vertNorm.z)) + elementStr = string.format("%f %f %f", vec.x, vec.y, vec.z) + indices.vert_norms[i] = bimap_find_or_insert(self.vert_norms, self.vert_norms_reverse, elementStr) + end + + -- Add faces to mesh. + if not self.faces[face.texture] then + self.faces[face.texture] = {} + end + + local vertStrs = {} + + for i = 1, #indices.verts do + table.insert(vertStrs, + table.concat({ + indices.verts[i], + -- If there is a vertex normal but not a texture coordinate, insert a blank string here. + indices.tex_coords[i] or (indices.vert_norms[i] and ""), + indices.vert_norms[i], + }, "/") + ) + end + + table.insert(self.faces[face.texture], table.concat(vertStrs, " ")) +end + +function meshport.Mesh:insert_faces(faces) + for _, face in ipairs(faces.faces) do + self:insert_face(face) + end +end + +function meshport.Mesh:write_obj(path) + local objFile = io.open(path .. "/model.obj", "w") + + objFile:write("# Created using meshport (https://github.com/random-geek/meshport).\n") + objFile:write("mtllib materials.mtl\n") + + -- Write vertices. + for _, vert in ipairs(self.verts) do + objFile:write(string.format("v %s\n", vert)) + end + + -- Write texture coordinates. + for _, texCoord in ipairs(self.tex_coords) do + objFile:write(string.format("vt %s\n", texCoord)) + end + + -- Write vertex normals. + for _, vertNorm in ipairs(self.vert_norms) do + objFile:write(string.format("vn %s\n", vertNorm)) + end + + -- Write faces, sorted in order of material. + for mat, faces in pairs(self.faces) do + objFile:write(string.format("usemtl %s\n", mat)) + + for _, face in ipairs(faces) do + objFile:write(string.format("f %s\n", face)) + end + end + + objFile:close() +end + +function meshport.Mesh:write_mtl(path, playerName) + local matFile = io.open(path .. "/materials.mtl", "w") + + matFile:write("# Created using meshport (https://github.com/random-geek/meshport).\n") + + -- Write material information. + for mat, _ in pairs(self.faces) do + matFile:write(string.format("\nnewmtl %s\n", mat)) + + -- Attempt to get the base texture, ignoring texture modifiers. + local texName = string.match(mat, "[%w%s%-_%.]+%.png") or mat + + if meshport.texture_paths[texName] then + if texName ~= mat then + meshport.log(playerName, "warning", string.format("Ignoring texture modifers in material %q.", mat)) + end + + matFile:write(string.format("map_Kd %s\n", meshport.texture_paths[texName])) + else + meshport.log(playerName, "warning", + string.format("Could not find texture %q. Using a dummy material instead.", texName)) + matFile:write(string.format("Kd %f %f %f\n", math.random(), math.random(), math.random())) + end + end + + matFile:close() +end diff --git a/nodebox.lua b/nodebox.lua index b566a00..93086c0 100644 --- a/nodebox.lua +++ b/nodebox.lua @@ -1,4 +1,24 @@ -meshport.side_box_names = { +--[[ + Copyright (C) 2021 random-geek (https://github.com/random-geek) + + This file is part of Meshport. + + Meshport is free software: you can redistribute it and/or modify it under + the terms of the GNU Lesser General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) + any later version. + + Meshport is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for + more details. + + You should have received a copy of the GNU Lesser General Public License + along with Meshport. If not, see . +]] + + +local SIDE_BOX_NAMES = { "top", -- Y+ "bottom", -- Y- "right", -- X+ @@ -7,7 +27,8 @@ meshport.side_box_names = { "front", -- Z- } -function meshport.sort_box(box) + +local function sort_box(box) return { math.min(box[1], box[4]), math.min(box[2], box[5]), @@ -18,15 +39,41 @@ function meshport.sort_box(box) } end + +local function node_connects_to(nodeName, connectsTo) + -- If `connectsTo` is a string or nil, turn it into a table for iteration. + if type(connectsTo) ~= "table" then + connectsTo = {connectsTo} + end + + for _, connectName in ipairs(connectsTo) do + if connectName == nodeName + or string.sub(connectName, 1, 6) == "group:" + and minetest.get_item_group(nodeName, string.sub(connectName, 7)) ~= 0 then + return true + end + end + + return false +end + + +-- A list of node boxes, in the format used by Minetest: +-- {a.x, a.y, a.z, b.x, b.y, b.z} +-- Individual boxes inside the `boxes` array are not mutated. meshport.Boxes = {} function meshport.Boxes:new(boxes) local o = {} - if type(boxes) ~= "table" or type(boxes[1]) == "number" then - o.boxes = {boxes} + if type(boxes) == "table" and type(boxes[1]) == "table" then + -- Copy boxes individually to avoid mutating the argument. + o.boxes = {} + for i, box in ipairs(boxes) do + o.boxes[i] = box + end else - o.boxes = boxes + o.boxes = {boxes} end setmetatable(o, self) @@ -34,9 +81,13 @@ function meshport.Boxes:new(boxes) return o end +function meshport.Boxes:insert_box(box) + table.insert(self.boxes, box) +end + function meshport.Boxes:insert_all(boxes) for _, box in ipairs(boxes.boxes) do - table.insert(self.boxes, table.copy(box)) + table.insert(self.boxes, box) end end @@ -63,62 +114,66 @@ function meshport.Boxes:rotate_by_facedir(facedir) end function meshport.Boxes:get_leveled(level) - local newBoxes = meshport.Boxes:new(table.copy(self.boxes)) + local newBoxes = meshport.Boxes:new() - for i, box in ipairs(newBoxes.boxes) do - box = meshport.sort_box(box) - box[5] = level / 64 - 0.5 - newBoxes.boxes[i] = box + for i, box in ipairs(self.boxes) do + local newBox = sort_box(box) + newBox[5] = level / 64 - 0.5 + newBoxes.boxes[i] = newBox end return newBoxes end -function meshport.Boxes:to_faces(nodeTiles, pos, facedir) +function meshport.Boxes:to_faces(nodeDef, pos, facedir, tileIdx, useSpecial) + local tiles = useSpecial and nodeDef.special_tiles or nodeDef.tiles + local vec = vector.new + local faces = meshport.Faces:new() - for _, b in ipairs(self.boxes) do - b = meshport.sort_box(b) + for _, box in ipairs(self.boxes) do + local b = sort_box(box) local sideFaces = { - {{x = b[4], y = b[5], z = b[3]}, {x = b[4], y = b[5], z = b[6]}, {x = b[1], y = b[5], z = b[6]}, {x = b[1], y = b[5], z = b[3]}}, -- Y+ - {{x = b[4], y = b[2], z = b[6]}, {x = b[4], y = b[2], z = b[3]}, {x = b[1], y = b[2], z = b[3]}, {x = b[1], y = b[2], z = b[6]}}, -- Y- - {{x = b[4], y = b[2], z = b[6]}, {x = b[4], y = b[5], z = b[6]}, {x = b[4], y = b[5], z = b[3]}, {x = b[4], y = b[2], z = b[3]}}, -- X+ - {{x = b[1], y = b[2], z = b[3]}, {x = b[1], y = b[5], z = b[3]}, {x = b[1], y = b[5], z = b[6]}, {x = b[1], y = b[2], z = b[6]}}, -- X- - {{x = b[1], y = b[2], z = b[6]}, {x = b[1], y = b[5], z = b[6]}, {x = b[4], y = b[5], z = b[6]}, {x = b[4], y = b[2], z = b[6]}}, -- Z+ - {{x = b[4], y = b[2], z = b[3]}, {x = b[4], y = b[5], z = b[3]}, {x = b[1], y = b[5], z = b[3]}, {x = b[1], y = b[2], z = b[3]}}, -- Z- + {vec(b[1], b[5], b[3]), vec(b[4], b[5], b[3]), vec(b[4], b[5], b[6]), vec(b[1], b[5], b[6])}, -- Y+ + {vec(b[1], b[2], b[6]), vec(b[4], b[2], b[6]), vec(b[4], b[2], b[3]), vec(b[1], b[2], b[3])}, -- Y- + {vec(b[4], b[2], b[3]), vec(b[4], b[2], b[6]), vec(b[4], b[5], b[6]), vec(b[4], b[5], b[3])}, -- X+ + {vec(b[1], b[2], b[6]), vec(b[1], b[2], b[3]), vec(b[1], b[5], b[3]), vec(b[1], b[5], b[6])}, -- X- + {vec(b[4], b[2], b[6]), vec(b[1], b[2], b[6]), vec(b[1], b[5], b[6]), vec(b[4], b[5], b[6])}, -- Z+ + {vec(b[1], b[2], b[3]), vec(b[4], b[2], b[3]), vec(b[4], b[5], b[3]), vec(b[1], b[5], b[3])}, -- Z- } + local t = {} + for i = 1, 6 do + t[i] = b[i] + 0.5 -- Texture coordinates range from 0 to 1 + end + local sideTexCoords = { - {{x = b[4], y = b[3]}, {x = b[4], y = b[6]}, {x = b[1], y = b[6]}, {x = b[1], y = b[3]}}, -- Y+ - {{x = b[4], y =-b[6]}, {x = b[4], y =-b[3]}, {x = b[1], y =-b[3]}, {x = b[1], y =-b[6]}}, -- Y- - {{x = b[6], y = b[2]}, {x = b[6], y = b[5]}, {x = b[3], y = b[5]}, {x = b[3], y = b[2]}}, -- X+ - {{x =-b[3], y = b[2]}, {x =-b[3], y = b[5]}, {x =-b[6], y = b[5]}, {x =-b[6], y = b[2]}}, -- X- - {{x =-b[1], y = b[2]}, {x =-b[1], y = b[5]}, {x =-b[4], y = b[5]}, {x =-b[4], y = b[2]}}, -- Z+ - {{x = b[4], y = b[2]}, {x = b[4], y = b[5]}, {x = b[1], y = b[5]}, {x = b[1], y = b[2]}}, -- Z- + {{x = t[1], y = t[3]}, {x = t[4], y = t[3]}, {x = t[4], y = t[6]}, {x = t[1], y = t[6]}}, -- Y+ + {{x = t[1], y =1-t[6]}, {x = t[4], y =1-t[6]}, {x = t[4], y =1-t[3]}, {x = t[1], y =1-t[3]}}, -- Y- + {{x = t[3], y = t[2]}, {x = t[6], y = t[2]}, {x = t[6], y = t[5]}, {x = t[3], y = t[5]}}, -- X+ + {{x =1-t[6], y = t[2]}, {x =1-t[3], y = t[2]}, {x =1-t[3], y = t[5]}, {x =1-t[6], y = t[5]}}, -- X- + {{x =1-t[4], y = t[2]}, {x =1-t[1], y = t[2]}, {x =1-t[1], y = t[5]}, {x =1-t[4], y = t[5]}}, -- Z+ + {{x = t[1], y = t[2]}, {x = t[4], y = t[2]}, {x = t[4], y = t[5]}, {x = t[1], y = t[5]}}, -- Z- } - local vertNorm - for i = 1, 6 do - -- Fix offset texture coordinates. - for v = 1, 4 do - sideTexCoords[i][v] = {x = sideTexCoords[i][v].x + 0.5, y = sideTexCoords[i][v].y + 0.5} - end - - vertNorm = meshport.neighbor_dirs[i] + local norm = meshport.NEIGHBOR_DIRS[i] faces:insert_face(meshport.prepare_cuboid_face({ verts = sideFaces[i], tex_coords = sideTexCoords[i], - vert_norms = {vertNorm, vertNorm, vertNorm, vertNorm}, - }, nodeTiles, pos, facedir, i)) + vert_norms = {norm, norm, norm, norm}, + tile_idx = tileIdx, + use_special_tiles = useSpecial, + }, tiles, pos, facedir, i)) end end return faces end + function meshport.prepare_nodebox(nodebox) local prepNodebox = {} prepNodebox.type = nodebox.type @@ -132,7 +187,7 @@ function meshport.prepare_nodebox(nodebox) prepNodebox.connected = {} prepNodebox.disconnected = {} - for i, name in ipairs(meshport.side_box_names) do + for i, name in ipairs(SIDE_BOX_NAMES) do prepNodebox.connected[i] = meshport.Boxes:new(nodebox["connect_" .. name]) prepNodebox.disconnected[i] = meshport.Boxes:new(nodebox["disconnected_" .. name]) end @@ -145,20 +200,21 @@ function meshport.prepare_nodebox(nodebox) prepNodebox.wall_side = meshport.Boxes:new(nodebox.wall_side) -- Rotate the boxes so they are in the correct orientation after rotation by facedir. - prepNodebox.wall_top:transform(function(v) return {x = -v.x, y = -v.y, z = v.z} end) - prepNodebox.wall_side:transform(function(v) return {x = -v.z, y = v.x, z = v.y} end) + prepNodebox.wall_top:transform(function(v) return vector.new(-v.x, -v.y, v.z) end) + prepNodebox.wall_side:transform(function(v) return vector.new(-v.z, v.x, v.y) end) end return prepNodebox end -function meshport.collect_boxes(prepNodebox, nodeDef, facedir, param2, neighbors) + +function meshport.collect_boxes(prepNodebox, nodeDef, param2, facedir, neighbors) local boxes = meshport.Boxes:new() if prepNodebox.fixed then if prepNodebox.type == "leveled" then - boxes:insert_all(prepNodebox.fixed:get_leveled( - nodeDef.paramtype2 == "leveled" and param2 or nodeDef.leveled or 0)) + local level = nodeDef.paramtype2 == "leveled" and param2 or nodeDef.leveled or 0 + boxes:insert_all(prepNodebox.fixed:get_leveled(level)) else boxes:insert_all(prepNodebox.fixed) end @@ -170,7 +226,7 @@ function meshport.collect_boxes(prepNodebox, nodeDef, facedir, param2, neighbors for i = 1, 6 do neighborName = minetest.get_name_from_content_id(neighbors[i]) - if meshport.node_connects_to(neighborName, nodeDef.connects_to) then + if node_connects_to(neighborName, nodeDef.connects_to) then boxes:insert_all(prepNodebox.connected[i]) else boxes:insert_all(prepNodebox.disconnected[i]) diff --git a/parse_obj.lua b/parse_obj.lua index 933a641..e206fe2 100644 --- a/parse_obj.lua +++ b/parse_obj.lua @@ -1,31 +1,50 @@ -function meshport.parse_vector_element(elementStr) - local elementType - local vec = {} +--[[ + Copyright (C) 2021 random-geek (https://github.com/random-geek) - -- Get the element type and vector. `vec.z` will be left `nil` for two-dimensional vectors. - elementType, vec.x, vec.y, vec.z = string.match(elementStr, "^(%a+)%s([%d%.%-]+)%s([%d%.%-]+)%s?([%d%.%-]*)") + This file is part of Meshport. - for k, v in pairs(vec) do - vec[k] = tonumber(v) - end + Meshport is free software: you can redistribute it and/or modify it under + the terms of the GNU Lesser General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) + any later version. - -- Return the element type and value. - if elementType == "v" then - -- Invert X axis to match the Minetest coordinate system. - vec.x = -vec.x - return "verts", vec + Meshport is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for + more details. + + You should have received a copy of the GNU Lesser General Public License + along with Meshport. If not, see . +]] + +-- See the OBJ file specification: http://www.martinreddy.net/gfx/3d/OBJ.spec +-- Also, the Irrlicht implementation: irrlicht/source/Irrlicht/COBJMeshFileLoader.cpp + + +local function parse_vector_element(elementType, elementStr) + if elementType == "v" or elementType == "vn" then + -- Note that there may be an optional weight value after z, which is ignored. + local xs, ys, zs = string.match(elementStr, "^([%d%.%-]+)%s+([%d%.%-]+)%s+([%d%.%-]+)") + -- The X axis of vectors is inverted to match the Minetest coordinate system. + local vec = vector.new(-tonumber(xs), tonumber(ys), tonumber(zs)) + + if elementType == "v" then + return "verts", vec + else + return "vert_norms", vec + end elseif elementType == "vt" then - return "tex_coords", vec - elseif elementType == "vn" then - vec.x = -vec.x - return "vert_norms", vec + local xs, ys = string.match(elementStr, "^([%d%.%-]+)%s+([%d%.%-]+)") + local coords = {x = tonumber(xs), y = tonumber(ys)} + assert(coords.x and coords.y, "Invalid texture coordinate element") + return "tex_coords", coords end end -function meshport.parse_face_element(elements, elementStr) + +local function parse_face_element(elements, faceStr) -- Split the face element into strings containing the indices of elements associated with each vertex. - local vertStrs = string.split(string.match(elementStr, "^f%s([%d/%s]+)"), " ") - local elementIndices + local vertStrs = string.split(faceStr, " ") local face = { verts = {}, @@ -34,58 +53,77 @@ function meshport.parse_face_element(elements, elementStr) } for i, vertStr in ipairs(vertStrs) do - -- Split the string into a table of indices for position, texture coordinate, and/or vertex normal elements. - elementIndices = string.split(vertStr, "/", true) + -- Split the string into indices for vertex, texture coordinate, and/or vertex normal elements. + local vs, vts, vns = string.match(vertStr, "^(%d*)/?(%d*)/?(%d*)$") + local vi, vti, vni = tonumber(vs), tonumber(vts), tonumber(vns) + assert(vi, "Invalid face element") - for k, v in pairs(elementIndices) do - elementIndices[k] = tonumber(v) - end - - -- Set the position, texture coordinate, and vertex normal of the face. `or 0` prevents a nil index error. - face.verts[i] = elements.verts[elementIndices[1] or 0] - face.tex_coords[i] = elements.tex_coords[elementIndices[2] or 0] - face.vert_norms[i] = elements.vert_norms[elementIndices[3] or 0] + -- Set the position, texture coordinate, and vertex normal of the vertex. + -- Note that vti or vni are allowed to be nil + face.verts[i] = elements.verts[vi] + face.tex_coords[i] = elements.tex_coords[vti] + face.vert_norms[i] = elements.vert_norms[vni] end return face end + +local function handle_group(groups, elementStr) + -- Note: Minetest ignores usemtl; see `OBJ_LOADER_IGNORE_MATERIAL_FILES`. + -- The format allows multiple group names; get only the first one. + local groupName = string.match(elementStr, "^(%S+)") + if not groupName then + -- "default" is the default group name if no name is specified. + groupName = "default" + end + local groupIdx = table.indexof(groups, groupName) + + -- If this group has not been used yet, add it to the list. + if groupIdx < 0 then + table.insert(groups, groupName) + groupIdx = #groups + end + + return groupIdx +end + + function meshport.parse_obj(path) - local faces = meshport.Faces:new() local file = io.open(path, "r") + local faces = meshport.Faces:new() local elements = { verts = {}, tex_coords = {}, vert_norms = {}, } + -- Tiles are assigned according to groups, in the order in which groups are defined. local groups = {} - local curGroup - local elementType + local currentTileIdx for line in file:lines() do - elementType = string.sub(line, 1, 1) + -- elementStr may be an empty string, e.g. "g" with no group name. + local elementType, elementStr = string.match(line, "^(%a+)%s*(.*)") - if elementType == "v" then - -- Parse the vector element. Used for "v", "vt", and "vn". - local type, value = meshport.parse_vector_element(line) - table.insert(elements[type], value) + if elementType == "v" or elementType == "vt" or elementType == "vn" then + local dest, value = parse_vector_element(elementType, elementStr) + table.insert(elements[dest], value) elseif elementType == "f" then -- If the face is not part of any group, use the placeholder group `0`. - if not curGroup then + if not currentTileIdx then table.insert(groups, 0) - curGroup = table.indexof(groups, 0) + currentTileIdx = #groups end -- Parse the face element. - local face = meshport.parse_face_element(elements, line) + local face = parse_face_element(elements, elementStr) -- Assign materials according to the group. - face.tile_idx = curGroup + face.tile_idx = currentTileIdx faces:insert_face(face) elseif elementType == "g" then - -- If this group has not been used yet, then add it to the list. - curGroup = meshport.find_or_insert(groups, string.match(line, "^g%s(.+)")) + currentTileIdx = handle_group(groups, elementStr) end end diff --git a/helpers.lua b/utils.lua similarity index 51% rename from helpers.lua rename to utils.lua index 66ac638..9116f97 100644 --- a/helpers.lua +++ b/utils.lua @@ -1,13 +1,47 @@ -meshport.neighbor_dirs = { - {x = 0, y = 1, z = 0}, -- Y+ - {x = 0, y = -1, z = 0}, -- Y- - {x = 1, y = 0, z = 0}, -- X+ - {x = -1, y = 0, z = 0}, -- X- - {x = 0, y = 0, z = 1}, -- Z+ - {x = 0, y = 0, z = -1}, -- Z- +--[[ + Copyright (C) 2021 random-geek (https://github.com/random-geek) + + This file is part of Meshport. + + Meshport is free software: you can redistribute it and/or modify it under + the terms of the GNU Lesser General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) + any later version. + + Meshport is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for + more details. + + You should have received a copy of the GNU Lesser General Public License + along with Meshport. If not, see . +]] + +meshport.NEIGHBOR_DIRS = { + -- face neighbors + vector.new( 0, 1, 0), -- 1 + vector.new( 0,-1, 0), + vector.new( 1, 0, 0), + vector.new(-1, 0, 0), + vector.new( 0, 0, 1), + vector.new( 0, 0,-1), + + -- edge neighbors + vector.new(-1, 1, 0), -- 7 + vector.new( 1, 1, 0), + vector.new( 0, 1, 1), + vector.new( 0, 1,-1), + vector.new(-1, 0, 1), + vector.new( 1, 0, 1), + vector.new(-1, 0,-1), + vector.new( 1, 0,-1), + vector.new(-1,-1, 0), + vector.new( 1,-1, 0), + vector.new( 0,-1, 1), + vector.new( 0,-1,-1), } -meshport.facedir_to_tile_indices = { +local FACEDIR_TO_TILE_INDICES = { [0] = {1, 2, 3, 4, 5, 6}, {1, 2, 5, 6, 4, 3}, @@ -35,42 +69,43 @@ meshport.facedir_to_tile_indices = { {2, 1, 5, 6, 3, 4}, } -meshport.facedir_to_tile_rotations = { +local FACEDIR_TO_TILE_ROTATIONS = { [0] = {0, 0, 0, 0, 0, 0}, - {3, 1, 0, 0, 0, 0}, - {2, 2, 0, 0, 0, 0}, {1, 3, 0, 0, 0, 0}, - {0, 2, 3, 1, 2, 0}, - {0, 2, 3, 1, 1, 1}, - {0, 2, 3, 1, 0, 2}, - {0, 2, 3, 1, 3, 3}, - {2, 0, 1, 3, 2, 0}, - {2, 0, 1, 3, 3, 3}, - {2, 0, 1, 3, 0, 2}, - {2, 0, 1, 3, 1, 1}, - {3, 3, 3, 3, 1, 3}, - {3, 3, 2, 0, 1, 3}, - {3, 3, 1, 1, 1, 3}, - {3, 3, 0, 2, 1, 3}, + {2, 2, 0, 0, 0, 0}, + {3, 1, 0, 0, 0, 0}, + {0, 2, 1, 3, 2, 0}, + {0, 2, 1, 3, 3, 3}, + {0, 2, 1, 3, 0, 2}, + {0, 2, 1, 3, 1, 1}, + {2, 0, 3, 1, 2, 0}, + {2, 0, 3, 1, 1, 1}, + {2, 0, 3, 1, 0, 2}, + {2, 0, 3, 1, 3, 3}, {1, 1, 1, 1, 3, 1}, {1, 1, 2, 0, 3, 1}, {1, 1, 3, 3, 3, 1}, {1, 1, 0, 2, 3, 1}, + {3, 3, 3, 3, 1, 3}, + {3, 3, 2, 0, 1, 3}, + {3, 3, 1, 1, 1, 3}, + {3, 3, 0, 2, 1, 3}, {2, 2, 2, 2, 2, 2}, - {3, 1, 2, 2, 2, 2}, - {0, 0, 2, 2, 2, 2}, {1, 3, 2, 2, 2, 2}, + {0, 0, 2, 2, 2, 2}, + {3, 1, 2, 2, 2, 2}, } -meshport.wallmounted_to_facedir = {[0] = 20, 0, 17, 15, 8, 6} +local WALLMOUNTED_TO_FACEDIR = {[0] = 20, 0, 17, 15, 8, 6} -meshport.drawtype_aliases = { +local DRAWTYPE_ALIASES = { allfaces_optional = "allfaces", - glasslike_framed_optional = "glasslike_framed", + glasslike_framed_optional = "glasslike", } -function meshport.print(name, level, s) + +function meshport.log(name, level, s) local message if level == "info" then @@ -84,27 +119,6 @@ function meshport.print(name, level, s) minetest.chat_send_player(name, "[meshport] " .. message) end -function meshport.find_or_insert(list, value) - local idx = table.indexof(list, value) - - -- If the element does not exist, create it. - if idx < 0 then - table.insert(list, value) - idx = #list - end - - -- Return the index of the element. - return idx -end - -function meshport.clean_vector(vec) - -- Prevents an issue involving negative zero values, which are not handled properly by `string.format`. - return { - x = vec.x == 0 and 0 or vec.x, - y = vec.y == 0 and 0 or vec.y, - z = vec.z == 0 and 0 or vec.z, - } -end function meshport.rotate_vector_by_facedir(vec, facedir) local v = vector.new(vec) @@ -135,36 +149,68 @@ function meshport.rotate_vector_by_facedir(vec, facedir) return v end -function meshport.rotate_texture_coordinate(texCoord, rot) - local vt = table.copy(texCoord) - -- Rotate the vector. Values of components range from 0 to 1, so adding 1 when inverting is necessary. - if rot == 1 then - vt.x, vt.y = vt.y, 1 - vt.x -- 90 degrees clockwise - elseif rot == 2 then - vt.x, vt.y = 1 - vt.x, 1 - vt.y -- 180 degrees clockwise - elseif rot == 3 then - vt.x, vt.y = 1 - vt.y, vt.x -- 270 degrees clockwise - end - - return vt -end - -function meshport.rotate_texture_coordinates(texCoords, rot) - if rot == 0 then +function meshport.translate_texture_coordinates(texCoords, offset) + if offset.x == 0 and offset.y == 0 then return texCoords end local newTexCoords = {} - for _, texCoord in ipairs(texCoords) do - table.insert(newTexCoords, meshport.rotate_texture_coordinate(texCoord, rot)) + for _, tc in ipairs(texCoords) do + table.insert(newTexCoords, {x = tc.x + offset.x, y = tc.y + offset.y}) end return newTexCoords end -function meshport.scale_global_texture_coordinates(texCoords, pos, sideIdx, scale) + +function meshport.rotate_texture_coordinates_rad(texCoords, rad) + if rad == 0 then + return texCoords + end + + local sinRad = math.sin(rad) + local cosRad = math.cos(rad) + local newTexCoords = {} + + for _, texCoord in ipairs(texCoords) do + -- Coordinates are rotated around (0.5, 0.5). + local x = texCoord.x - 0.5 + local y = texCoord.y - 0.5 + table.insert(newTexCoords, { + x = x * cosRad - y * sinRad + 0.5, + y = x * sinRad + y * cosRad + 0.5 + }) + end + + return newTexCoords +end + + +local function rotate_texture_coordinates(texCoords, rot) + if rot == 0 then + return + end + + for i, tc in ipairs(texCoords) do + local x, y + + -- Rotate the vector. Values of components range from 0 to 1, so adding 1 when inverting is necessary. + if rot == 1 then + x, y = 1 - tc.y, tc.x -- 90 degrees counterclockwise + elseif rot == 2 then + x, y = 1 - tc.x, 1 - tc.y -- 180 degrees counterclockwise + elseif rot == 3 then + x, y = tc.y, 1 - tc.x -- 270 degrees counterclockwise + end + + texCoords[i] = {x = x, y = y} + end +end + + +local function scale_global_texture_coordinates(texCoords, pos, sideIdx, scale) -- Get the offset of the tile relative to the lower left corner of the texture. local texPos = {} @@ -189,57 +235,77 @@ function meshport.scale_global_texture_coordinates(texCoords, pos, sideIdx, scal end -- Scale and move the texture coordinates. - local newTexCoords = {} - - for _, texCoord in ipairs(texCoords) do - table.insert(newTexCoords, { + for i, texCoord in ipairs(texCoords) do + texCoords[i] = { x = (texCoord.x + texPos.x) / scale, y = (texCoord.y + texPos.y) / scale, - }) + } end - - return newTexCoords end + +-- WARNING: This function mutates tables! +-- Please follow the table rules used by Faces. function meshport.prepare_cuboid_face(face, tiles, pos, facedir, sideIdx) -- If the tile index has not been set manually, assign a tile to the face based on the facedir value. - face.tile_idx = face.tile_idx or meshport.facedir_to_tile_indices[facedir][sideIdx] + face.tile_idx = face.tile_idx or FACEDIR_TO_TILE_INDICES[facedir][sideIdx] local tile = meshport.get_tile(tiles, face.tile_idx) if tile.align_style == "world" or tile.align_style == "user" then -- For scaled, world-aligned tiles, scale and reposition the texture coordinates as needed. if tile.scale and tile.scale ~= 1 then - face.tex_coords = meshport.scale_global_texture_coordinates(face.tex_coords, pos, sideIdx, tile.scale) + scale_global_texture_coordinates(face.tex_coords, pos, sideIdx, tile.scale) end else -- If the tile isn't world-aligned, rotate it according to the facedir. - face.tex_coords = meshport.rotate_texture_coordinates(face.tex_coords, - meshport.facedir_to_tile_rotations[facedir][sideIdx]) + rotate_texture_coordinates(face.tex_coords, FACEDIR_TO_TILE_ROTATIONS[facedir][sideIdx]) end return face end + +function meshport.get_content_id_or_nil(nodeName) + if minetest.registered_nodes[nodeName] then + return minetest.get_content_id(nodeName) + end +end + + function meshport.get_def_from_id(contentId) return minetest.registered_nodes[minetest.get_name_from_content_id(contentId)] or {} end + function meshport.get_aliased_drawtype(drawtype) - return meshport.drawtype_aliases[drawtype or ""] or drawtype + return DRAWTYPE_ALIASES[drawtype or ""] or drawtype end -function meshport.get_facedir(param2, type) + +function meshport.get_facedir(type, param2) if type == "facedir" or type == "colorfacedir" then -- For colorfacedir, only the first 5 bits are needed. return param2 % 32 elseif type == "wallmounted" or type == "colorwallmounted" then -- For colorwallmounted, only the first 3 bits are needed. If the wallmounted direction is invalid, return 0. - return meshport.wallmounted_to_facedir[param2 % 8] or 0 + return WALLMOUNTED_TO_FACEDIR[param2 % 8] or 0 else return 0 end end + +function meshport.get_degrotate(type, param2) + if type == "degrotate" then + return 1.5 * (param2 % 240) + elseif type == "colordegrotate" then + return 15 * ((param2 % 32) % 24) + else + return 0 + end +end + + function meshport.get_node_neighbors(array, area, idx) -- Get the node's absolute position from the flat array index. local pos = area:position(idx) @@ -247,37 +313,65 @@ function meshport.get_node_neighbors(array, area, idx) -- Get the content/param2 value for each neighboring node. for i = 1, 6 do - neighbors[i] = array[area:indexp(vector.add(pos, meshport.neighbor_dirs[i]))] + neighbors[i] = array[area:indexp(vector.add(pos, meshport.NEIGHBOR_DIRS[i]))] end return neighbors end -function meshport.node_connects_to(nodeName, connectsTo) - -- If `connectsTo` is a string or nil, turn it into a table for iteration. - if type(connectsTo) ~= "table" then - connectsTo = {connectsTo} - end - - for _, connectName in ipairs(connectsTo) do - if connectName == nodeName - or string.sub(connectName, 1, 6) == "group:" - and minetest.get_item_group(nodeName, string.sub(connectName, 7)) ~= 0 then - return true - end - end - - return false -end function meshport.get_tile(tiles, n) - if type(tiles) == "table" then + if type(tiles) == "table" and #tiles > 0 then return tiles[n] or tiles[#tiles] else return "unknown" end end + +local function get_png_dimensions(path) + -- Luckily, reading the dimensions of a PNG file is a trivial task + local file = io.open(path, "rb") + if not file then + return + end + file:seek("set", 1) + if file:read(3) ~= "PNG" then -- Verify it's a PNG file + return + end + file:seek("set", 16) + + local function read_u32(b) + return (b:byte(1) * 0x1000000 + + b:byte(2) * 0x10000 + + b:byte(3) * 0x100 + + b:byte(4)) + end + + local w = read_u32(file:read(4)) + local h = read_u32(file:read(4)) + file:close() + + return w, h +end + + +-- In case of failure, this should return nil, nil +function meshport.get_texture_dimensions(textureName) + local dims = meshport.texture_dimension_cache[textureName] + if dims then + return dims[1], dims[2] + end + + local path = meshport.texture_paths[textureName] + if path then + local w, h = get_png_dimensions(path) + meshport.texture_dimension_cache[path] = {w, h} -- Will be an empty table if the file isn't found. + return w, h + end +end + + function meshport.get_asset_paths(assetFolderName, extension) local modAssetPath local assets = {} @@ -288,7 +382,7 @@ function meshport.get_asset_paths(assetFolderName, extension) -- Iterate through all the files in the requested folder of the mod. for _, fileName in ipairs(minetest.get_dir_list(modAssetPath, false)) do - -- Add files to the table. If an extendion is specified, only add files with that extension. + -- Add files to the table. If an extension is specified, only add files with that extension. if not extension or string.lower(string.sub(fileName, -string.len(extension))) == extension then assets[fileName] = modAssetPath .. DIR_DELIM .. fileName end