commit 20189796fc653cc81900d6d80e59a7c2ae11e5a1 Author: entuland Date: Wed May 16 00:31:13 2018 +0200 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/README.md b/README.md new file mode 100644 index 0000000..7577a63 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# Woolen Meshes (wesh) +An in-game mesh creator for Minetest + +Developed and tested on Minetest 0.4.16 - try in other versions at your own risk :) + +If you like my contributions you may consider reading http://entuland.com/en/support-entuland + +# Recipe for the Canvas block + + wesh:canvas + + W = any wool block + B = bronze ingot + + WWW + WBW + WWW + +[Canvas recipe](/screenshots/canvas-recipe.png) + +# How to use +Place down a Canvas block, you'll see that it extends beyond its node space marking a 16x16x16 space. + +[Empty canvas](/screenshots/canvas-empty.png) + +In this space you can build anthing you like by using colored wool blocks. + +[Building inside the canvas](/screenshots/canvas-build.png) + +Once you're done with your build, go to the Canvas block and right click it: you'll be asked to provide a name for your mesh (you can type any text in there, with uppercases and any symbol). + +[Request for name](/screenshots/prompt-name.png) + +When you confirm such name (you can cancel it by hitting the ESC key) you'll likely get a confirmation like this: + +[Save confirmation](/screenshots/save-confirm.png) + +If you confirm the name by hitting ENTER you may not be presented with the above confirmation. It will appear in the chat as well just in case. + +Upon saving a few temporary files will be created in the "/modstorage/wesh" subfolder in your world's folder: +- the .obj file will contain a model with your build scaled down 16 times (so that it will occupy only one block) +- the .dat file will contain the original name you have chosen for your mesh, along with some other data (read the section about using custom textures below) +- the .matrix.dat file will contain a serialized version of your build, that may eventually get used to rebuild / reimport it in the game allowing you to alter it (right now you can't import them, so make sure you don't dismantle your build if you want to alter and capture it again) + +The above files are saved there only temporarily because mods don't have writing permission in their own folder while the world is running. In order to use your new meshes in the game you need to restart the world. + +During world startup the mod will move all the temporary files to the "/models" folder and will load them. + +By default, two versions of each mesh will be available: +- plain version: it uses flat colors averaged from the colors of each wool block +[Plain version](/screenshots/version-plain.png) + +- wool version: it will use the actual textures used by the wool blocks +[Wool version](/screenshots/version-wool.png) + +Such new blocks can't be crafted (I plan to make sort of a crafting station where you put some material and chose the model you want to craft), so you either need to give them to yourself or to find them in the Creative inventory. All such meshes show up if you filter for either "wesh" or "mesh". + +[Creative search](/screenshots/creative-search.png) + +Looking at the filename (or knowing how the name gets converted) you can also work out the actual nodename to be used in your "/give" or "/giveme" chat command, for example: +- chosen name: "Test One!" +- resulting filename: "mesh_test_one.obj" +- resulting nodename: "wesh:mesh_test_one" + +# Using custom textures +In the .dat file of each mesh you'll find something like this: + return { + description = "Your mesh name", + variants = { + wool = "wool-72.png", + plain = "wool-16.png", + }, + } + +In order to add a new variant simply add a line with your texture name and make sure you save such texture file in the "/textures" folder of the mod. You can also remove the lines you're not interested in and the mod will not generate those variants. + +For example, here we remove the "wool" version and add a custom one: + + return { + description = "Your mesh name", + variants = { + plain = "wool-16.png", + my_texture_plain_name = "my-texture-file-name.png", + }, + } + +Have a look at "wool-72.png" to see where each color goes. + +A couple considerations: +- the bottom-right transparent area never gets used +- the used texture for each face will actually be a bit smaller (in the "wool-72.png" file the squares are 18 pixels in side, but the texture will only use a 16x16 square inside of it) +- you're not forced to use any particular size for your texture as long as it's square (I guess, let me know if you find any problems) diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..d9ffacd --- /dev/null +++ b/init.lua @@ -0,0 +1,479 @@ + +wesh = { + name = "wesh", + modpath = minetest.get_modpath(minetest.get_current_modname()), + vt_size = 72, + player_canvas = {} +} + +-- ======================================================================== +-- initialization functions +-- ======================================================================== + +function wesh._init() + wesh.temp_path = minetest.get_worldpath() .. "/mod_storage/" .. wesh.name + wesh.gen_prefix = "mesh_" + + if not minetest.mkdir(wesh.temp_path) then + error("[" .. wesh.name .. "] Unable to create folder " .. wesh.temp_path) + end + wesh._init_vertex_textures() + wesh._init_colors() + wesh._init_geometry() + wesh._move_temp_files() + wesh._load_mod_meshes() + wesh._main_bindings() +end + +function wesh._main_bindings() + minetest.register_on_player_receive_fields(wesh.on_receive_fields) + + minetest.register_craft({ + output = "wesh:canvas", + recipe = { + {'group:wool', 'group:wool', 'group:wool'}, + {'group:wool', 'default:bronze_ingot', 'group:wool'}, + {'group:wool', 'group:wool', 'group:wool'}, + } + }) + + minetest.register_node("wesh:canvas", { + drawtype = "mesh", + mesh = "zzz_wesh_canvas.obj", + tiles = { "wool-72.png" }, + paramtype2 = "facedir", + on_rightclick = wesh.canvas_interaction, + description = "Woolen Mesh Canvas", + walkable = true, + groups = { dig_immediate = 3}, + }) +end + +-- creates a 4x4 grid of UV mappings, each with a margin of one pixel +function wesh._init_vertex_textures() + local vt = "" + local space = wesh.vt_size / 4 + local tile = space - 2 + local offset = tile / 2 + local start = offset + 1 + local stop = start + 3 * space + local mult = 1 / wesh.vt_size + for y = start, stop, space do + for x = start, stop, space do + vt = vt .. "vt " .. ((x + offset) * mult) .. " " .. ((y + offset) * mult) .. "\n" -- top right + vt = vt .. "vt " .. ((x + offset) * mult) .. " " .. ((y - offset) * mult) .. "\n" -- bottom right + vt = vt .. "vt " .. ((x - offset) * mult) .. " " .. ((y - offset) * mult) .. "\n" -- bottom left + vt = vt .. "vt " .. ((x - offset) * mult) .. " " .. ((y + offset) * mult) .. "\n" -- top left + end + end + wesh.vertex_textures = vt +end + +function wesh._init_colors() + wesh.colors = { + "violet", + "white", + "yellow", + "air", + "magenta", + "orange", + "pink", + "red", + "dark_green", + "dark_grey", + "green", + "grey", + "black", + "blue", + "brown", + "cyan", + } + +-- The following loop populates the color_vertices table with data like this... +-- +-- wesh.color_vertices = { +-- violet = { 1, 2, 3, 4 }, +-- white = { 5, 6, 7, 8 }, +-- +-- ...and so forth, in a boring sequence. +-- +-- Such indices will refer to the vt sequence generated by _init_vertex_textures() + + wesh.color_vertices = {} + for i, color in ipairs(wesh.colors) do + local t = {} + local j = (i - 1) * 4 + 1 + for k = j, j + 3 do + table.insert(t, k) + end + wesh.color_vertices[color] = t + end +end + +function wesh._init_geometry() + + -- helper table to build the six faces + wesh.cube_vertices = { + { x = 1, y = -1, z = -1 }, -- 1 + { x = -1, y = -1, z = -1 }, -- 2 + { x = -1, y = -1, z = 1 }, -- 3 + { x = 1, y = -1, z = 1 }, -- 4 + { x = 1, y = 1, z = -1 }, -- 5 + { x = 1, y = 1, z = 1 }, -- 6 + { x = -1, y = 1, z = 1 }, -- 7 + { x = -1, y = 1, z = -1 }, -- 8 + } + + -- vertices refer to the above cube_vertices table + wesh.face_construction = { + bottom = { vertices = { 1, 2, 3, 4}, hider_offset = {x = 0, y = -1, z = 0 } }, + top = { vertices = { 5, 6, 7, 8}, hider_offset = {x = 0, y = 1, z = 0 } }, + back = { vertices = { 1, 5, 8, 2}, hider_offset = {x = 0, y = 0, z = -1 } }, + front = { vertices = { 3, 7, 6, 4}, hider_offset = {x = 0, y = 0, z = 1 } }, + left = { vertices = { 5, 1, 4, 6}, hider_offset = {x = -1, y = 0, z = 0 } }, + right = { vertices = { 2, 8, 7, 3}, hider_offset = {x = 1, y = 0, z = 0 } }, + } + + -- helper mapper for transformation functions + -- only upright canvases supported + wesh._transfunc = { + -- facedir 0, +Y, no rotation + function(p) return p end, + -- facedir 1, +Y, 90 deg + function(p) p.x, p.z = p.z, -p.x return p end, + -- facedir 2, +Y, 180 deg + function(p) p.x, p.z = -p.x, -p.z return p end, + -- facedir 3, +Y, 270 deg + function(p) p.x, p.z = -p.z, p.x return p end, + } +end + +function wesh._reset_geometry() + wesh.matrix = {} + wesh.vertices = {} + wesh.vertices_indices = {} + wesh.faces = {} + wesh.traverse_matrix(function(p) + if not wesh.matrix[p.x] then wesh.matrix[p.x] = {} end + if not wesh.matrix[p.x][p.y] then wesh.matrix[p.x][p.y] = {} end + wesh.matrix[p.x][p.y][p.z] = "air" + end) +end + +-- ======================================================================== +-- core functions +-- ======================================================================== + +-- called when the player right-clicks on a canvas block +function wesh.canvas_interaction(clicked_pos, node, clicker) + wesh.player_canvas[clicker:get_player_name()] = { pos = clicked_pos, facedir = node.param2 }; + local formspec = "field[meshname;Enter the name for your mesh;]field_close_on_enter[meshname;false]" + minetest.show_formspec(clicker:get_player_name(), "save_mesh", formspec) +end + +function wesh.on_receive_fields(player, formname, fields) + if formname == "save_mesh" then + local canvas = wesh.player_canvas[player:get_player_name()] + wesh.save_new_mesh(canvas.pos, canvas.facedir, player, fields.meshname) + end +end + +function wesh.save_new_mesh(canvas_pos, facedir, player, description) + -- empty all helper variables + wesh._reset_geometry() + + -- read all nodes from the canvas space in the world + -- extract the colors and put them into a helper matrix of color voxels + wesh.traverse_matrix(wesh.node_to_voxel, canvas_pos, facedir) + + -- generate faces according to voxels + wesh.traverse_matrix(wesh.voxel_to_faces) + + -- this will be the actual content of the .obj file + local vt_section = wesh.vertex_textures + local v_section = wesh.vertices_to_string() + local f_section = table.concat(wesh.faces, "\n") + local meshdata = vt_section .. v_section .. f_section + + wesh.save_mesh_to_file(meshdata, description, player) +end + +-- ======================================================================== +-- mesh management helpers +-- ======================================================================== + +function wesh.save_mesh_to_file(meshdata, description, player) + local sanitized_meshname = wesh.check_plain(description) + if sanitized_meshname:len() < 3 then + wesh.notify(player, "Mesh name too short, try again (min. 3 chars)") + return + end + + local obj_filename = wesh.gen_prefix .. sanitized_meshname .. ".obj" + for _, entry in ipairs(wesh.get_all_files()) do + if entry == obj_filename then + wesh.notify(player, "Mesh name '" .. description .. "' already taken, pick a new one") + return + end + end + + -- save .obj file + local full_filename = wesh.temp_path .. "/" .. obj_filename + local file, errmsg = io.open(full_filename, "wb") + if not file then + wesh.notify(player, "Unable to write to file '" .. obj_filename .. "' from '" .. wesh.temp_path .. "' - error: " .. errmsg) + return + end + file:write(meshdata) + file:close() + + -- save .dat file + local data_filename = obj_filename .. ".dat" + local full_data_filename = wesh.temp_path .. "/" .. data_filename + local file, errmsg = io.open(full_data_filename, "wb") + if not file then + wesh.notify(player, "Unable to write to file '" .. data_filename .. "' from '" .. wesh.temp_path .. "' - error: " .. errmsg) + return + end + file:write(wesh.prepare_data_file(description)) + file:close() + + -- save .matrix.dat file + local matrix_data_filename = obj_filename .. ".matrix.dat" + local full_matrix_data_filename = wesh.temp_path .. "/" .. matrix_data_filename + local file, errmsg = io.open(full_matrix_data_filename, "wb") + if not file then + wesh.notify(player, "Unable to write to file '" .. matrix_data_filename .. "' from '" .. wesh.temp_path .. "' - error: " .. errmsg) + return + end + file:write(minetest.serialize(wesh.matrix)) + file:close() + + + wesh.notify(player, "Mesh saved to '" .. obj_filename .. "' in '" .. wesh.temp_path .. "', reload the world to move them to the mod folder and enable them") +end + +function wesh.get_temp_files() + return minetest.get_dir_list(wesh.temp_path, false) +end + +function wesh.get_stored_files() + return minetest.get_dir_list(wesh.modpath .. "/models", false) +end + +function wesh.get_all_files() + local all = wesh.get_temp_files() + for _, entry in pairs(wesh.get_stored_files()) do + table.insert(all, entry) + end + return all +end + +function wesh.prepare_data_file(description) + local output = [[ +return { + description = ]] .. ("%q"):format(description) .. [[, + variants = { + wool = "wool-72.png", + plain = "wool-16.png", + }, +} +]] + return output +end + +function wesh._move_temp_files() + local meshes = wesh.get_temp_files() + for _, filename in ipairs(meshes) do + os.rename(wesh.temp_path .. "/" .. filename, wesh.modpath .. "/models/" .. filename) + end +end + +function wesh._load_mod_meshes() + local meshes = wesh.get_stored_files() + for _, filename in ipairs(meshes) do + if filename:match("^" .. wesh.gen_prefix .. ".-%.obj$") then + wesh._load_mesh(filename) + end + end +end + +function wesh._load_mesh(obj_filename) + local full_data_filename = wesh.modpath .. "/models/" .. obj_filename .. ".dat" + + local file = io.open(full_data_filename, "rb") + + local data = {} + if file then + data = minetest.deserialize(file:read("*all")) or {} + file:close() + end + + local description = data.description or "Custom Woolen Mesh" + local variants = data.variants or { plain = "wool-16.png" } + + local nodename = obj_filename:gsub("[^%w]+", "_"):gsub("_obj", "") + + for variant, tile in pairs(variants) do + minetest.register_node("wesh:" .. nodename .. "_" .. variant, { + drawtype = "mesh", + mesh = obj_filename, + paramtype2 = "facedir", + description = description .. " (" .. variant .. ")", + tiles = { tile }, + walkable = true, + groups = { dig_immediate = 3 }, + }) + end +end + +-- ======================================================================== +-- mesh generation helpers +-- ======================================================================== + +function wesh.construct_face(rel_pos, texture_vertices, facename, vertices, hider_offset) + local hider_pos = vector.add(rel_pos, hider_offset) + if not wesh.out_of_bounds(hider_pos) and wesh.get_voxel_color(hider_pos) ~= "air" then return end + local face_line = "f " + for i, vertex in ipairs(vertices) do + local index = wesh.get_vertex_index(rel_pos, vertex) + face_line = face_line .. index .. "/" .. texture_vertices[i] .. " " + end + table.insert(wesh.faces, face_line) +end + +function wesh.get_texture_vertices(color) + if not wesh.color_vertices[color] then + return wesh.color_vertices.air + end + return wesh.color_vertices[color] +end + +function wesh.set_voxel_color(pos, color) + if not wesh.color_vertices[color] then color = "air" end + wesh.matrix[pos.x][pos.y][pos.z] = color +end + +function wesh.get_voxel_color(pos) + return wesh.matrix[pos.x][pos.y][pos.z] +end + +function wesh.get_node_color(pos) + local node = minetest.get_node_or_nil(pos) + if not node then return "trasparent" end + local parts = string.split(node.name, ":") + return parts[#parts] +end + +function wesh.make_absolute(canvas_pos, facedir, relative_pos) + -- relative positions range from (1, 1, 1) to (16, 16, 16) + + -- shift relative to canvas node within canvas space + local shifted_pos = {} + shifted_pos.y = relative_pos.y - 1 + shifted_pos.x = relative_pos.x - 8 + shifted_pos.z = relative_pos.z + + -- transform according to canvas facedir + local transformed_pos = wesh.transform(facedir, shifted_pos) + + -- translate to absolute according to canvas position + local absolute_pos = vector.add(canvas_pos, transformed_pos) + + return absolute_pos +end + +function wesh.transform(facedir, pos) + return (wesh._transfunc[facedir + 1] or wesh._transfunc[1])(pos) +end + +function wesh.node_to_voxel(rel_pos, canvas_pos, facedir) + local abs_pos = wesh.make_absolute(canvas_pos, facedir, rel_pos) + local color = wesh.get_node_color(abs_pos) + wesh.set_voxel_color(rel_pos, color) +end + +function wesh.voxel_to_faces(rel_pos) + local color = wesh.get_voxel_color(rel_pos) + if color == "air" then return end + for facename, facedata in pairs(wesh.face_construction) do + local texture_vertices = wesh.get_texture_vertices(color) + wesh.construct_face(rel_pos, texture_vertices, facename, facedata.vertices, facedata.hider_offset) + end +end + +function wesh.get_vertex_index(pos, vertex_number) + -- get integral offset of vertices related to voxel center + local offset = wesh.cube_vertices[vertex_number] + + -- convert integral offset to real offset + offset = vector.multiply(offset, 1/32) + + -- scale voxel center from range 1~16 to range 1/16 ~ 1 + pos = vector.divide(pos, 16) + + -- center whole mesh around zero and shift it to make room for offsets + pos = vector.subtract(pos, 1/2 + 1/32) + + -- not really sure whether this should be done here, + -- but if I don't do this the resulting mesh will be wrongly mirrored + pos.x = -pos.x + + -- combine voxel center and offset to get final real vertex coordinate + pos = vector.add(pos, offset) + + -- bail out if this vertex already exists + local lookup = pos.x .. "," .. pos.y .. "," .. pos.z + if wesh.vertices_indices[lookup] then return wesh.vertices_indices[lookup] end + + -- add the vertex to the list of needed ones + table.insert(wesh.vertices, pos) + wesh.vertices_indices[lookup] = #wesh.vertices + + return #wesh.vertices +end + +function wesh.vertices_to_string() + local output = "" + for i, vertex in ipairs(wesh.vertices) do + output = output .. "v " .. vertex.x .. " " .. vertex.y .. " " .. vertex.z .. "\n" + end + return output +end + +-- ======================================================================== +-- generic helpers +-- ======================================================================== + +function wesh.out_of_bounds(pos) + return pos.x < 1 or pos.x > 16 + or pos.y < 1 or pos.y > 16 + or pos.z < 1 or pos.z > 16 +end + +function wesh.check_plain(text) + if type(text) ~= "string" then return "" end + text = text:gsub("^[^%w]*(.-)[^%w]*$", "%1") + return text:gsub("[^%w]+", "_"):lower() +end + +function wesh.traverse_matrix(callback, ...) + for x = 1, 16 do + for y = 1, 16 do + for z = 1, 16 do + callback({x = x, y = y, z = z}, ...) + end + end + end +end + +function wesh.notify(player, message) + local formspec = "size[10,5]textarea[1,1;8,3;notice;Notice;" .. minetest.formspec_escape(message) .. "]" + .. "button_exit[6,4;3,0;exit;Okay]" + local playername = player:get_player_name() + minetest.show_formspec(playername, "notice_form", formspec) + minetest.chat_send_player(playername, "[" .. wesh.name .. "] " .. message) +end + +wesh._init() + diff --git a/mod.conf b/mod.conf new file mode 100644 index 0000000..fb74560 --- /dev/null +++ b/mod.conf @@ -0,0 +1,2 @@ +name = wesh + diff --git a/models/README.txt b/models/README.txt new file mode 100644 index 0000000..afce456 --- /dev/null +++ b/models/README.txt @@ -0,0 +1,3 @@ +This folder will contain your generated meshes, if you want to get rid of any of them you need to delete them from here + +IMPORTANT: do NOT delete the file "zzz_wesh_canvas.obj" \ No newline at end of file diff --git a/models/zzz_wesh_canvas.obj b/models/zzz_wesh_canvas.obj new file mode 100644 index 0000000..4dcd775 --- /dev/null +++ b/models/zzz_wesh_canvas.obj @@ -0,0 +1,227 @@ +v 7.437500 15.437500 16.437500 +v 7.437500 15.500000 16.437500 +v -8.437500 15.437500 16.437500 +v -8.437500 15.500000 16.437500 +v 7.437499 15.437500 0.562499 +v 7.437499 15.500000 0.562499 +v -8.437501 15.437500 0.562499 +v -8.437501 15.500000 0.562499 +v -8.500000 15.437500 16.500000 +v 7.500000 15.437500 16.500000 +v 7.500000 15.500000 16.500000 +v -8.500000 15.500000 16.500000 +v -8.500001 15.437500 0.499999 +v -8.500001 15.500000 0.499999 +v 7.499999 15.437500 0.499999 +v 7.499999 15.500000 0.499999 +v 7.437500 -0.500001 16.437498 +v 7.437500 -0.437501 16.437498 +v -8.437500 -0.500001 16.437498 +v -8.437500 -0.437501 16.437498 +v 7.437499 -0.499999 0.562500 +v 7.437499 -0.437499 0.562500 +v -8.437501 -0.499999 0.562500 +v -8.437501 -0.437499 0.562500 +v -8.500000 -0.500001 16.499998 +v 7.500000 -0.500001 16.499998 +v 7.500000 -0.437501 16.499998 +v -8.500000 -0.437501 16.499998 +v -8.500001 -0.499999 0.500000 +v -8.500001 -0.437499 0.500000 +v 7.499999 -0.499999 0.500000 +v 7.499999 -0.437499 0.500000 +v -0.500001 -0.499999 -0.500000 +v 0.499999 -0.499999 -0.500000 +v 0.499999 -0.499999 0.500000 +v -0.500001 -0.499999 0.500000 +v -0.500000 0.500001 -0.500000 +v 0.499999 0.500001 -0.499999 +v 0.499999 0.500001 0.500000 +v -0.500001 0.500001 0.500000 +vt 0.765625 0.421875 +vt 0.765625 0.406250 +vt 0.984375 0.406250 +vt 0.984375 0.421875 +vt 0.984375 0.359375 +vt 0.984375 0.375000 +vt 0.765625 0.375000 +vt 0.765625 0.359375 +vt 0.984375 0.343750 +vt 0.984375 0.359375 +vt 0.765625 0.359375 +vt 0.765625 0.343750 +vt 0.765625 0.390625 +vt 0.765625 0.375000 +vt 0.984375 0.375000 +vt 0.984375 0.390625 +vt 0.984375 0.390625 +vt 0.765625 0.390625 +vt 0.765625 0.375000 +vt 0.984375 0.375000 +vt 0.765625 0.406250 +vt 0.984375 0.406250 +vt 0.984375 0.421875 +vt 0.765625 0.421875 +vt 0.765625 0.390625 +vt 0.984375 0.406250 +vt 0.765625 0.406250 +vt 0.984375 0.484375 +vt 0.765625 0.484375 +vt 0.765625 0.468750 +vt 0.984375 0.468750 +vt 0.765625 0.296875 +vt 0.984375 0.296875 +vt 0.984375 0.312500 +vt 0.765625 0.312500 +vt 0.984375 0.296875 +vt 0.765625 0.296875 +vt 0.765625 0.281250 +vt 0.984375 0.281250 +vt 0.984375 0.281250 +vt 0.765625 0.281250 +vt 0.765625 0.265625 +vt 0.984375 0.265625 +vt 0.765625 0.421875 +vt 0.984375 0.421875 +vt 0.984375 0.437500 +vt 0.765625 0.437500 +vt 0.765625 0.453125 +vt 0.765625 0.437500 +vt 0.984375 0.437500 +vt 0.984375 0.453125 +vt 0.765625 0.437500 +vt 0.765625 0.421875 +vt 0.984375 0.421875 +vt 0.984375 0.437500 +vt 0.984375 0.453125 +vt 0.984375 0.468750 +vt 0.765625 0.468750 +vt 0.765625 0.453125 +vt 0.984375 0.468750 +vt 0.984375 0.484375 +vt 0.765625 0.484375 +vt 0.765625 0.468750 +vt 0.765625 0.437500 +vt 0.765625 0.421875 +vt 0.984375 0.421875 +vt 0.984375 0.437500 +vt 0.984375 0.281250 +vt 0.984375 0.296875 +vt 0.765625 0.296875 +vt 0.765625 0.281250 +vt 0.984375 0.328125 +vt 0.984375 0.343750 +vt 0.765625 0.343750 +vt 0.765625 0.328125 +vt 0.765625 0.312500 +vt 0.984375 0.312500 +vt 0.984375 0.328125 +vt 0.984375 0.359375 +vt 0.765625 0.359375 +vt 0.765625 0.343750 +vt 0.984375 0.343750 +vt 0.765625 0.328125 +vt 0.984375 0.328125 +vt 0.984375 0.343750 +vt 0.765625 0.343750 +vt 0.765625 0.453125 +vt 0.984375 0.453125 +vt 0.984375 0.468750 +vt 0.765625 0.468750 +vt 0.984375 0.375000 +vt 0.765625 0.375000 +vt 0.765625 0.359375 +vt 0.984375 0.359375 +vt 0.765625 0.312500 +vt 0.984375 0.312500 +vt 0.984375 0.281250 +vt 0.765625 0.281250 +vt 0.765625 0.265625 +vt 0.984375 0.265625 +vt 0.984375 0.453125 +vt 0.765625 0.453125 +vt 0.765625 0.437500 +vt 0.984375 0.437500 +vt 0.765625 0.390625 +vt 0.984375 0.390625 +vt 0.984375 0.406250 +vt 0.765625 0.406250 +vt 0.765625 0.421875 +vt 0.765625 0.406250 +vt 0.984375 0.406250 +vt 0.984375 0.421875 +vt 0.765625 0.468750 +vt 0.765625 0.453125 +vt 0.984375 0.453125 +vt 0.984375 0.468750 +vt 0.984375 0.296875 +vt 0.984375 0.312500 +vt 0.765625 0.312500 +vt 0.765625 0.296875 +vt 0.984375 0.437500 +vt 0.984375 0.453125 +vt 0.765625 0.453125 +vt 0.765625 0.437500 +vt 0.015625 0.984375 +vt 0.015625 0.265625 +vt 0.734375 0.265625 +vt 0.734375 0.984375 +vt 0.015625 0.984375 +vt 0.015625 0.265625 +vt 0.734375 0.265625 +vt 0.734375 0.984375 +vt 0.734375 0.265625 +vt 0.734375 0.984375 +vt 0.015625 0.984375 +vt 0.734375 0.265625 +vt 0.734375 0.984375 +vt 0.015625 0.984375 +vt 0.015625 0.984375 +vt 0.015625 0.265625 +vt 0.015625 0.265625 +vt 0.734375 0.265625 +vt 0.734375 0.984375 +vn 0.0000 0.0000 1.0000 +vn -1.0000 0.0000 0.0000 +vn -0.0000 0.0000 -1.0000 +vn 1.0000 0.0000 -0.0000 +vn 0.0000 -1.0000 0.0000 +vn 0.0000 1.0000 0.0000 +f 10/1/1 11/2/1 12/3/1 9/4/1 +f 9/5/2 12/6/2 14/7/2 13/8/2 +f 13/9/3 14/10/3 16/11/3 15/12/3 +f 15/13/4 16/14/4 11/15/4 10/16/4 +f 3/17/5 1/18/5 10/19/5 9/20/5 +f 2/21/6 4/22/6 12/23/6 11/24/6 +f 7/25/5 3/17/5 9/26/5 13/27/5 +f 4/28/6 8/29/6 14/30/6 12/31/6 +f 5/32/5 7/33/5 13/34/5 15/35/5 +f 8/36/6 6/37/6 16/38/6 14/39/6 +f 1/40/5 5/41/5 15/42/5 10/43/5 +f 6/44/6 2/45/6 11/46/6 16/47/6 +f 5/48/1 6/49/1 8/50/1 7/51/1 +f 7/52/4 8/53/4 4/54/4 3/55/4 +f 3/56/3 4/57/3 2/58/3 1/59/3 +f 1/60/2 2/61/2 6/62/2 5/63/2 +f 26/64/1 27/65/1 28/66/1 25/67/1 +f 25/68/2 28/69/2 30/70/2 29/71/2 +f 29/72/3 30/73/3 32/74/3 31/75/3 +f 31/75/4 32/76/4 27/77/4 26/78/4 +f 19/79/5 17/80/5 26/81/5 25/82/5 +f 18/83/6 20/84/6 28/85/6 27/86/6 +f 23/87/5 19/88/5 25/89/5 29/90/5 +f 20/91/6 24/92/6 30/93/6 28/94/6 +f 21/95/5 23/96/5 29/72/5 31/75/5 +f 24/97/6 22/98/6 32/99/6 30/100/6 +f 17/101/5 21/102/5 31/103/5 26/104/5 +f 22/105/6 18/106/6 27/107/6 32/108/6 +f 21/109/1 22/110/1 24/111/1 23/112/1 +f 23/113/4 24/114/4 20/115/4 19/116/4 +f 19/117/3 20/118/3 18/119/3 17/120/3 +f 17/121/2 18/122/2 22/123/2 21/124/2 +f 33/125/5 34/126/5 35/127/5 36/128/5 +f 37/129/6 40/130/6 39/131/6 38/132/6 +f 33/133/3 37/134/3 38/135/3 34/126/3 +f 34/126/4 38/136/4 39/137/4 35/138/4 +f 35/127/1 39/137/1 40/139/1 36/140/1 +f 37/129/2 33/141/2 36/142/2 40/143/2 diff --git a/screenshots/canvas-build.png b/screenshots/canvas-build.png new file mode 100644 index 0000000..ed1403a Binary files /dev/null and b/screenshots/canvas-build.png differ diff --git a/screenshots/canvas-empty.png b/screenshots/canvas-empty.png new file mode 100644 index 0000000..78a2eec Binary files /dev/null and b/screenshots/canvas-empty.png differ diff --git a/screenshots/canvas-recipe.png b/screenshots/canvas-recipe.png new file mode 100644 index 0000000..0dabda5 Binary files /dev/null and b/screenshots/canvas-recipe.png differ diff --git a/screenshots/creative-search.png b/screenshots/creative-search.png new file mode 100644 index 0000000..6385da9 Binary files /dev/null and b/screenshots/creative-search.png differ diff --git a/screenshots/prompt-name.png b/screenshots/prompt-name.png new file mode 100644 index 0000000..689fa95 Binary files /dev/null and b/screenshots/prompt-name.png differ diff --git a/screenshots/save-confirm.png b/screenshots/save-confirm.png new file mode 100644 index 0000000..aae9a4a Binary files /dev/null and b/screenshots/save-confirm.png differ diff --git a/screenshots/version-plain.png b/screenshots/version-plain.png new file mode 100644 index 0000000..90ddab7 Binary files /dev/null and b/screenshots/version-plain.png differ diff --git a/screenshots/version-wool.png b/screenshots/version-wool.png new file mode 100644 index 0000000..74d2a0d Binary files /dev/null and b/screenshots/version-wool.png differ diff --git a/textures/wool-16.png b/textures/wool-16.png new file mode 100644 index 0000000..5793f54 Binary files /dev/null and b/textures/wool-16.png differ diff --git a/textures/wool-72.png b/textures/wool-72.png new file mode 100644 index 0000000..10c4b94 Binary files /dev/null and b/textures/wool-72.png differ