diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 2d9dd15..711154d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,8 +1,8 @@ # Fry3DX: the app (app.cpp) on the engine, and its modelling core (mesh, scene) as a library the # tests use too. -add_library(fry_core STATIC mesh.cpp bvh.cpp tools.cpp cut.cpp uv.cpp image.cpp sticker.cpp shapes.cpp scene.cpp package.cpp export.cpp print.cpp boolean.cpp modify.cpp sculpt.cpp) +add_library(fry_core STATIC mesh.cpp bvh.cpp text.cpp import.cpp tools.cpp cut.cpp uv.cpp image.cpp sticker.cpp shapes.cpp scene.cpp package.cpp export.cpp print.cpp boolean.cpp modify.cpp sculpt.cpp) target_include_directories(fry_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(fry_core PUBLIC glm::glm libzstd_static) +target_link_libraries(fry_core PUBLIC glm::glm libzstd_static PRIVATE stb cgltf) target_compile_definitions(fry_core PUBLIC GLM_ENABLE_EXPERIMENTAL) # Scripts (Lua) that build and change the scene: also without a window, for the tests. add_library(fry_script STATIC script.cpp) diff --git a/src/app.cpp b/src/app.cpp index ecfd7da..eb43ad9 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -11,6 +11,8 @@ #include "script.h" #include "sculpt.h" #include "shapes.h" +#include "import.h" +#include "text.h" #include #if defined(__ANDROID__) @@ -595,6 +597,10 @@ public: self->pie_ = a.size() > 1 && a[1] == "views" ? 2 : 1, self->pieStays_ = true; self->pieAt_ = ImVec2((self->viewMin_.x + self->viewMax_.x) / 2, (self->viewMin_.y + self->viewMax_.y) / 2); }, "Fry3DX: show a pie menu (tools | views)"); + static keel::Cmd import("fry_import", [](const keel::Args& a) { if (a.size() > 1) self->importFile(a[1]); }, "Fry3DX: import a model (STL, OBJ, 3MF, glTF)"); + static keel::Cmd text("fry_text", [](const keel::Args& a) { + if (a.size() > 1) std::snprintf(self->textBuf_, sizeof self->textBuf_, "%s", a[1].c_str()), self->addText(); + }, "Fry3DX: add text as a shape"); static keel::Cmd about("fry_about", [](const keel::Args&) { self->showAbout_ = true; }, "Fry3DX: show the About window"); static keel::Cmd plugins("fry_plugins", [](const keel::Args& a) { if (a.size() > 1 && a[1] == "reload") self->loadPlugins(); @@ -722,6 +728,7 @@ public: commandForm(); copiesWindow(); thickenWindow(); + textWindow(); pushWindow(); saveQuestion(); recoverWindow(); @@ -1546,6 +1553,14 @@ private: if (kind == 10) SDL_ShowOpenFileDialog(done, this, w, lua, 1, at, false); else SDL_ShowSaveFileDialog(done, this, w, lua, 1, scriptPath_.empty() || isUri(scriptPath_) ? at : scriptPath_.c_str()); // (at: the last folder) } + else if (kind == 12) { + static const SDL_DialogFileFilter in[] = {{"3D models (STL, OBJ, 3MF, glTF)", "stl;obj;3mf;glb;gltf"}}; + SDL_ShowOpenFileDialog(done, this, w, in, 1, at, true); + } + else if (kind == 13) { + static const SDL_DialogFileFilter fonts[] = {{"Fonts", "ttf;otf"}}; + SDL_ShowOpenFileDialog(done, this, w, fonts, 1, at, false); + } else SDL_ShowOpenFileDialog(done, this, w, pics, 1, at, false); } void takeDialogResult() { @@ -1567,6 +1582,11 @@ private: return; } if (isUri(file) && (kind == 8 || kind == 9)) return exportFile(file, -1, kind == 8 ? ".3mf" : ".stl"); + if (kind == 12) return importFile(isUri(file) ? fromUri(file, "imported-" + fileName(file)) : file); + if (kind == 13) { + textFontFile_ = isUri(file) ? fromUri(file, "font-" + fileName(file)) : file; + return; + } if (kind == 10) return openScript(isUri(file) ? fromUri(file, "opened.lua") : file, fileName(file)); if (kind == 11) return saveScript(file); if (kind == 1) openFile(file); @@ -3692,6 +3712,104 @@ private: changedShape(*o); say(std::format("{} made {:.1f} mm thick{}", o->name, thickness_ * 1000, checkForPrint(o->mesh).closed() ? " (closed all round)" : "")); } + // A model made elsewhere: each of its objects, standing on the ground in the middle of the view. + void importFile(const std::string& file) { + std::string err; + std::vector got = fry::importModel(file, &err); + if (got.empty()) return say(err.empty() ? "Nothing in " + fileName(file) : err); + pushUndo(); + if (mode_ == Mode::Edit) leaveEdit(); + glm::vec3 lo(1e30f), hi(-1e30f); + for (const auto& g : got) lo = glm::min(lo, boundsMin(g.mesh)), hi = glm::max(hi, boundsMax(g.mesh)); + const glm::vec3 shift(-(lo.x + hi.x) / 2, -(lo.y + hi.y) / 2, -lo.z), at(std::round(target_.x * 10) / 10, std::round(target_.y * 10) / 10, 0.0f); + selected_.clear(); + size_t faces = 0; + for (auto& g : got) { + // (each object's own middle as its place, so it turns about itself) + const glm::vec3 lo1 = boundsMin(g.mesh) + shift, hi1 = boundsMax(g.mesh) + shift, mid((lo1.x + hi1.x) / 2, (lo1.y + hi1.y) / 2, lo1.z); + transform(g.mesh, glm::translate(glm::mat4(1.0f), shift - mid)); + faces += g.mesh.faces.size(); + Object& o = addObject(g.name.empty() ? "Imported" : g.name, std::move(g.mesh)); + o.position = at + mid; + selected_.insert(o.id), active_ = o.id; + } + const glm::vec3 size = (hi - lo) * 1000.0f; + say(std::format("{}: {} object{}, {} faces, {:.1f} x {:.1f} x {:.1f} mm", fileName(file), got.size(), got.size() == 1 ? "" : "s", faces, size.x, size.y, size.z)); + if (glm::length(hi - lo) > 0) frameSelected(); + } + + // ── Text as a shape ── + bool showText_ = false; + char textBuf_[512] = "Fry3DX"; + int textFont_ = 2, textAlign_ = 0; + float textHeight_ = 0.05f, textDepth_ = 0.01f, textSpacing_ = 0.0f; + bool textStanding_ = false; + std::string textFontFile_; // (a font of your own, picked from a file) + static constexpr const char* kFonts[] = {"Instrument Sans", "Instrument Sans (semi-bold)", "Instrument Sans (bold)", "JetBrains Mono"}; + static constexpr const char* kFontFiles[] = {"fonts/InstrumentSans-Regular.ttf", "fonts/InstrumentSans-SemiBold.ttf", "fonts/InstrumentSans-Bold.ttf", "fonts/JetBrainsMono-Regular.ttf"}; + void textWindow() { + if (!showText_) return; + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + if (!ImGui::Begin("Add text", &showText_, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End(); + const float w = 300; + ImGui::InputTextMultiline("##text", textBuf_, sizeof textBuf_, ImVec2(w, ImGui::GetTextLineHeight() * 3.6f)); + ImGui::SetNextItemWidth(w); + const std::string fontName = textFontFile_.empty() ? kFonts[textFont_] : stdfs::path(textFontFile_).filename().string(); + if (ImGui::BeginCombo("##font", fontName.c_str())) { + for (int i = 0; i < int(std::size(kFonts)); ++i) + if (ImGui::Selectable(kFonts[i], textFontFile_.empty() && textFont_ == i)) textFont_ = i, textFontFile_.clear(); + if (ImGui::Selectable("A font file of your own (.ttf, .otf)...")) dialog(13); + ImGui::EndCombo(); + } + float mm[3] = {textHeight_ * 1000, textDepth_ * 1000, textSpacing_ * 100}; + ImGui::SetNextItemWidth(w); + if (ImGui::DragFloat("##h", &mm[0], 0.2f, 0.5f, 1e5f, "letters %.1f mm tall")) textHeight_ = std::max(mm[0], 0.1f) / 1000; + ImGui::SetNextItemWidth(w); + if (ImGui::DragFloat("##d", &mm[1], 0.1f, 0.1f, 1e5f, "%.1f mm thick")) textDepth_ = std::max(mm[1], 0.05f) / 1000; + ImGui::SetNextItemWidth(w); + if (ImGui::DragFloat("##s", &mm[2], 0.2f, -30, 100, "letters %+.0f%% further apart")) textSpacing_ = mm[2] / 100; + ImGui::SetNextItemWidth(w); + ImGui::Combo("##al", &textAlign_, "Lines to the left\0Lines in the middle\0Lines to the right\0"); + ImGui::Checkbox("Standing up (a sign), not lying (a label to print)", &textStanding_); + pushAccent(); + if (ImGui::Button("Add it", ImVec2(w, 0))) addText(); + popAccent(); + ImGui::End(); + } + void addText() { + std::vector font; + if (!textFontFile_.empty()) { + std::ifstream in(textFontFile_, std::ios::binary); + font.assign(std::istreambuf_iterator(in), {}); + } else if (auto f = keel::fs::read(kFontFiles[textFont_])) + font = std::move(*f); + fry::TextStyle st; + st.height = textHeight_, st.depth = textDepth_, st.spacing = textSpacing_, st.align = textAlign_; + std::string err; + Mesh m = fry::makeText(font, textBuf_, st, &err); + if (m.faces.empty()) return say(err.empty() ? "No text to make" : err); + // (the letters' middle on the ground at the origin; standing up: facing the front) + const glm::vec3 lo = boundsMin(m), hi = boundsMax(m); + transform(m, glm::translate(glm::mat4(1.0f), glm::vec3(-(lo.x + hi.x) / 2, -(lo.y + hi.y) / 2, 0.0f))); + if (textStanding_) transform(m, glm::translate(glm::mat4(1.0f), glm::vec3(0, 0, (hi.y - lo.y) / 2)) * glm::rotate(glm::mat4(1.0f), kPi / 2, glm::vec3(1, 0, 0))); + pushUndo(); + if (mode_ == Mode::Edit) leaveEdit(); + std::string name = textBuf_; + if (const size_t nl = name.find('\n'); nl != std::string::npos) name.resize(nl); + if (name.size() > 24) name = name.substr(0, 24) + "..."; + Object& o = addObject("Text: " + name, std::move(m)); + o.position = glm::vec3(std::round(target_.x * 10) / 10, std::round(target_.y * 10) / 10, 0.0f); + // (reading left to right as the view looks: turned to the nearest quarter) + const glm::vec3 f = viewDir(); + if (std::abs(f.x) + std::abs(f.y) > 1e-4f) { + const float turn = std::round(std::atan2(-f.x, f.y) / (kPi / 2)) * (kPi / 2); + o.rotation = glm::angleAxis(turn, glm::vec3(0, 0, 1)); + } + selected_ = {o.id}, active_ = o.id; + showText_ = false; + say(std::format("Text added, {:.0f} mm tall letters, {} faces", textHeight_ * 1000, o.mesh.faces.size())); + } + void thickenWindow() { if (!showThicken_) return; ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); @@ -4904,7 +5022,7 @@ private: if (key(ImGuiKey_Z)) shift ? redo() : undo(); if (key(ImGuiKey_Y)) redo(); if (key(ImGuiKey_S)) shift || path_.empty() ? dialog(2) : void(saveFile(path_)); - if (key(ImGuiKey_O)) whenSaved(3); + if (key(ImGuiKey_O)) shift ? dialog(12) : whenSaved(3); if (key(ImGuiKey_N)) whenSaved(2); if (key(ImGuiKey_E)) dialog(3); if (key(ImGuiKey_Q)) whenSaved(1); @@ -5561,7 +5679,7 @@ private: auto model = [this] { return tab_ == Tab::Model; }; add("file.new", "New", "File", "Ctrl+N", [this] { whenSaved(2); }, {}, "empty start"); add("file.open", "Open...", "File", "Ctrl+O", [this] { whenSaved(3); }, {}, "load fry"); - add("file.import", "Import a model (STL, OBJ, 3MF)...", "File", "Ctrl+Shift+O", [this] { dialog(12); }, {}, "load mesh stl obj 3mf"); + add("file.import", "Import a model (STL, OBJ, 3MF, glTF)...", "File", "Ctrl+Shift+O", [this] { dialog(12); }, {}, "load mesh stl obj 3mf"); add("file.save", "Save", "File", "Ctrl+S", [this] { path_.empty() ? dialog(2) : void(saveFile(path_)); }); add("file.saveas", "Save as...", "File", "Ctrl+Shift+S", [this] { dialog(2); }); add("file.export", "Export (GLB, glTF, OBJ, STL, PLY, 3MF, FBX)...", "File", "Ctrl+E", [this] { dialog(3); }, {}, "stl obj gltf glb ply fbx 3mf print"); @@ -5728,6 +5846,7 @@ private: if (ImGui::MenuItem("Save", "Ctrl+S")) path_.empty() ? dialog(2) : void(saveFile(path_)); if (ImGui::MenuItem("Save as...", "Ctrl+Shift+S")) dialog(2); ImGui::Separator(); + if (ImGui::MenuItem("Import a model (STL, OBJ, 3MF, glTF)...", "Ctrl+Shift+O")) dialog(12); if (ImGui::MenuItem("Export (GLB, glTF, OBJ, STL, PLY)...", "Ctrl+E")) dialog(3); if (ImGui::BeginMenu("Autosave")) { static const int every[] = {1, 2, 5, 10, 0}; @@ -7522,7 +7641,7 @@ private: } // ── (being made) ── - bool showText_ = false, showSpin_ = false, showPrintSize_ = false, liveMirror_ = false; + bool showSpin_ = false, showPrintSize_ = false, liveMirror_ = false; void repairSelected() {} void startSlide() {} diff --git a/src/import.cpp b/src/import.cpp new file mode 100644 index 0000000..3e0a0c4 --- /dev/null +++ b/src/import.cpp @@ -0,0 +1,454 @@ +#include "import.h" + +#define CGLTF_IMPLEMENTATION +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fry { + +// ───────────────────────────── DEFLATE (as zlib's puff: small, not fast) ───────────────────────────── + +namespace { + +struct Bits { + const uint8_t* in; + size_t size, at = 0; + uint32_t buf = 0; + int count = 0; + bool bad = false; + int need(int n) { + while (count < n) { + if (at >= size) return bad = true, 0; + buf |= uint32_t(in[at++]) << count, count += 8; + } + const int v = int(buf & ((1u << n) - 1)); + buf >>= n, count -= n; + return v; + } +}; + +struct Huffman { + short count[16] = {}, symbol[288] = {}; + // From each symbol's code length; false if they don't make a code. + bool make(const short* lengths, int n) { + std::fill(std::begin(count), std::end(count), 0); + for (int s = 0; s < n; ++s) ++count[lengths[s]]; + if (count[0] == n) return true; + int left = 1; + for (int len = 1; len < 16; ++len) + if ((left = left * 2 - count[len]) < 0) return false; + short offs[16] = {}; + for (int len = 1; len < 15; ++len) offs[len + 1] = short(offs[len] + count[len]); + for (int s = 0; s < n; ++s) + if (lengths[s]) symbol[offs[lengths[s]]++] = short(s); + return true; + } + int decode(Bits& b) const { + int code = 0, first = 0, index = 0; + for (int len = 1; len < 16; ++len) { + code |= b.need(1); + if (b.bad) return -1; + const int c = count[len]; + if (code - c < first) return symbol[index + (code - first)]; + index += c, first += c, first <<= 1, code <<= 1; + } + return -1; + } +}; + +const short kLenBase[] = {3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258}; +const short kLenExtra[] = {0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0}; +const short kDistBase[] = {1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577}; +const short kDistExtra[] = {0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13}; + +bool codes(Bits& b, std::vector& out, const Huffman& lit, const Huffman& dist) { + for (;;) { + int s = lit.decode(b); + if (s < 0) return false; + if (s < 256) out.push_back(uint8_t(s)); + else if (s == 256) return true; + else { + s -= 257; + if (s >= 29) return false; + const int len = kLenBase[s] + b.need(kLenExtra[s]); + const int d = dist.decode(b); + if (d < 0 || d >= 30) return false; + const size_t back = size_t(kDistBase[d] + b.need(kDistExtra[d])); + if (b.bad || back > out.size()) return false; + for (int i = 0; i < len; ++i) out.push_back(out[out.size() - back]); + } + } +} + +} // namespace + +std::vector inflate(const uint8_t* data, size_t size, size_t expect) { + Bits b{data, size}; + std::vector out; + out.reserve(expect); + for (bool last = false; !last;) { + last = b.need(1); + const int type = b.need(2); + if (b.bad) return {}; + if (type == 0) { // stored + b.buf = 0, b.count = 0; + if (b.at + 4 > size) return {}; + const size_t len = size_t(data[b.at] | data[b.at + 1] << 8); + b.at += 4; + if (b.at + len > size) return {}; + out.insert(out.end(), data + b.at, data + b.at + len), b.at += len; + } else if (type == 1) { // the fixed codes + static Huffman lit, dist; + static bool made = false; + if (!made) { + short l[288]; + for (int i = 0; i < 288; ++i) l[i] = short(i < 144 ? 8 : i < 256 ? 9 : i < 280 ? 7 : 8); + lit.make(l, 288); + for (int i = 0; i < 30; ++i) l[i] = 5; + dist.make(l, 30), made = true; + } + if (!codes(b, out, lit, dist)) return {}; + } else if (type == 2) { // codes of its own + const int nlen = b.need(5) + 257, ndist = b.need(5) + 1, ncode = b.need(4) + 4; + static const short order[19] = {16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15}; + short lengths[320] = {}; + for (int i = 0; i < ncode; ++i) lengths[order[i]] = short(b.need(3)); + Huffman lc; + if (b.bad || !lc.make(lengths, 19)) return {}; + for (int i = 0; i < nlen + ndist;) { + int s = lc.decode(b); + if (s < 0) return {}; + if (s < 16) lengths[i++] = short(s); + else { + short len = 0; + int rep; + if (s == 16) { + if (!i) return {}; + len = lengths[i - 1], rep = 3 + b.need(2); + } else rep = s == 17 ? 3 + b.need(3) : 11 + b.need(7); + if (i + rep > nlen + ndist) return {}; + while (rep--) lengths[i++] = len; + } + } + Huffman lit, dist; + if (!lit.make(lengths, nlen) || !dist.make(lengths + nlen, ndist) || !codes(b, out, lit, dist)) return {}; + } else + return {}; + } + return out; +} + +// ───────────────────────────── the formats ───────────────────────────── + +namespace { + +std::string lower(std::string s) { + for (char& c : s) c = char(std::tolower(static_cast(c))); + return s; +} +float toFloat(std::string_view s) { return std::strtof(std::string(s).c_str(), nullptr); } + +void tidy(Mesh& m) { + mergeByDistance(m, 1e-7f); + removeUnused(m); + std::erase_if(m.faces, [](const std::vector& f) { return f.size() < 3 || std::set(f.begin(), f.end()).size() < 3; }); +} + +std::vector readStl(const std::vector& d, const std::string& name, std::string* error) { + Mesh m; + const bool ascii = d.size() >= 5 && std::memcmp(d.data(), "solid", 5) == 0 && std::string_view(reinterpret_cast(d.data()), std::min(d.size(), 600)).find("facet") != std::string::npos; + if (!ascii) { + if (d.size() < 84) return *error = "That STL is too short", std::vector{}; + uint32_t n; + std::memcpy(&n, d.data() + 80, 4); + if (d.size() < 84 + size_t(n) * 50) return *error = "That STL is cut short", std::vector{}; + for (uint32_t i = 0; i < n; ++i) { + const uint8_t* t = d.data() + 84 + size_t(i) * 50 + 12; + std::vector f; + for (int k = 0; k < 3; ++k) { + float v[3]; + std::memcpy(v, t + k * 12, 12); + f.push_back(int(m.verts.size())), m.verts.push_back(glm::vec3(v[0], v[1], v[2]) * 0.001f); + } + m.faces.push_back(f); + } + } else { + std::istringstream in(std::string(d.begin(), d.end())); + std::string word; + std::vector f; + while (in >> word) { + if (word == "vertex") { + glm::vec3 v; + in >> v.x >> v.y >> v.z; + f.push_back(int(m.verts.size())), m.verts.push_back(v * 0.001f); + } else if (word == "endfacet") { + if (f.size() >= 3) m.faces.push_back(f); + f.clear(); + } + } + } + tidy(m); + return {{name, std::move(m)}}; +} + +std::vector readObj(const std::vector& d, const std::string& name) { + std::vector all; + std::vector out{{name, {}}}; + std::map local; // (the file's point number: the one in this object's mesh) + std::istringstream in(std::string(d.begin(), d.end())); + for (std::string line; std::getline(in, line);) { + if (line.size() < 2) continue; + std::istringstream l(line); + std::string what; + l >> what; + if (what == "v") { + glm::vec3 v; + l >> v.x >> v.y >> v.z; + all.push_back(glm::vec3(v.x, -v.z, v.y)); // (OBJ's up is Y; here it's Z) + } else if ((what == "o" || what == "g") && !out.back().mesh.faces.empty()) { + std::string n; + std::getline(l >> std::ws, n); + out.push_back({n.empty() ? name : n, {}}), local.clear(); + } else if (what == "o" || what == "g") { + std::string n; + std::getline(l >> std::ws, n); + if (!n.empty()) out.back().name = n; + } else if (what == "f") { + std::vector f; + for (std::string c; l >> c;) { + int i = std::atoi(c.c_str()); + i = i < 0 ? int(all.size()) + i : i - 1; + if (i < 0 || i >= int(all.size())) continue; + auto [it, fresh] = local.try_emplace(i, int(out.back().mesh.verts.size())); + if (fresh) out.back().mesh.verts.push_back(all[size_t(i)]); + f.push_back(it->second); + } + if (f.size() >= 3) out.back().mesh.faces.push_back(f); + } + } + std::erase_if(out, [](const ImportedMesh& m) { return m.mesh.faces.empty(); }); + // (no units in an OBJ: one this big is millimetres, as most made for printing are) + float big = 0; + for (const ImportedMesh& m : out) + if (!m.mesh.verts.empty()) big = std::max(big, glm::length(boundsMax(m.mesh) - boundsMin(m.mesh))); + for (ImportedMesh& m : out) { + if (big > 20) transform(m.mesh, glm::scale(glm::mat4(1.0f), glm::vec3(0.001f))); + tidy(m.mesh); + } + return out; +} + +// A zip's file (stored or deflated), or empty. +std::vector zipFile(const std::vector& z, const std::function& wanted, std::string* found = nullptr) { + auto u16 = [&](size_t at) { return at + 2 <= z.size() ? uint32_t(z[at] | z[at + 1] << 8) : 0u; }; + auto u32 = [&](size_t at) { return at + 4 <= z.size() ? u16(at) | u16(at + 2) << 16 : 0u; }; + // The central directory, from its end record (sizes are right there even when the local headers leave them out). + size_t end = z.size() >= 22 ? z.size() - 22 : 0; + while (end > 0 && u32(end) != 0x06054b50) --end; + if (u32(end) != 0x06054b50) return {}; + size_t at = u32(end + 16); + for (uint32_t n = u16(end + 10); n-- && u32(at) == 0x02014b50;) { + const uint32_t method = u16(at + 10), packed = u32(at + 20), size = u32(at + 24), nameLen = u16(at + 28), extra = u16(at + 30), comment = u16(at + 32), local = u32(at + 42); + const std::string name(z.begin() + long(at + 46), z.begin() + long(std::min(z.size(), at + 46 + nameLen))); + at += 46 + nameLen + extra + comment; + if (!wanted(name)) continue; + const size_t data = local + 30 + u16(local + 26) + u16(local + 28); + if (data + packed > z.size()) return {}; + if (found) *found = name; + if (method == 0) return std::vector(z.begin() + long(data), z.begin() + long(data + packed)); + if (method == 8) return inflate(z.data() + data, packed, size); + return {}; + } + return {}; +} + +// The value of attribute `a` in a tag (as text). +std::string_view attr(std::string_view tag, std::string_view a) { + for (size_t at = 0; (at = tag.find(a, at)) != std::string_view::npos; at += a.size()) { + if (at > 0 && !std::isspace(static_cast(tag[at - 1]))) continue; + size_t q = at + a.size(); + while (q < tag.size() && std::isspace(static_cast(tag[q]))) ++q; + if (q >= tag.size() || tag[q] != '=') continue; + ++q; + while (q < tag.size() && std::isspace(static_cast(tag[q]))) ++q; + if (q >= tag.size()) break; + const char quote = tag[q]; + const size_t close = tag.find(quote, q + 1); + if (close == std::string_view::npos) break; + return tag.substr(q + 1, close - q - 1); + } + return {}; +} +// XML's & < > " ' and &#...; made the characters again. +std::string unescape(std::string_view s) { + std::string out; + for (size_t i = 0; i < s.size(); ++i) { + const size_t semi = s[i] == '&' ? s.find(';', i) : std::string_view::npos; + if (semi == std::string_view::npos || semi - i > 10) { + out += s[i]; + continue; + } + const std::string_view e = s.substr(i + 1, semi - i - 1); + uint32_t c = e == "amp" ? '&' : e == "lt" ? '<' : e == "gt" ? '>' : e == "quot" ? '"' : e == "apos" ? '\'' : 0; + if (!c && e.starts_with("#x")) c = uint32_t(std::strtoul(std::string(e.substr(2)).c_str(), nullptr, 16)); + else if (!c && e.starts_with("#")) c = uint32_t(std::strtoul(std::string(e.substr(1)).c_str(), nullptr, 10)); + if (!c) { + out += s[i]; + continue; + } + if (c < 0x80) out += char(c); + else if (c < 0x800) out += char(0xC0 | c >> 6), out += char(0x80 | (c & 0x3F)); + else if (c < 0x10000) out += char(0xE0 | c >> 12), out += char(0x80 | (c >> 6 & 0x3F)), out += char(0x80 | (c & 0x3F)); + else out += char(0xF0 | c >> 18), out += char(0x80 | (c >> 12 & 0x3F)), out += char(0x80 | (c >> 6 & 0x3F)), out += char(0x80 | (c & 0x3F)); + i = semi; + } + return out; +} +glm::mat4 transform3mf(std::string_view t) { + if (t.empty()) return glm::mat4(1.0f); + float v[12] = {1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0}; + std::istringstream in{std::string(t)}; + for (float& f : v) in >> f; + glm::mat4 m(1.0f); + for (int r = 0; r < 4; ++r) + for (int c = 0; c < 3; ++c) m[r][c] = v[r * 3 + c]; + return m; +} + +std::vector read3mf(const std::vector& d, const std::string& name, std::string* error) { + const std::vector xml = zipFile(d, [](const std::string& n) { return lower(n).ends_with(".model"); }); + if (xml.empty()) return *error = "No model in that 3MF (or it's packed a way that can't be read)", std::vector{}; + const std::string_view s(reinterpret_cast(xml.data()), xml.size()); + float unit = 0.001f; + if (const size_t m = s.find("', m) - m), "unit")); + unit = u == "micron" ? 1e-6f : u == "centimeter" ? 0.01f : u == "meter" ? 1.0f : u == "inch" ? 0.0254f : u == "foot" ? 0.3048f : 0.001f; + } + struct Obj { + std::string name; + Mesh mesh; + std::vector> parts; + }; + std::map objs; + std::vector> items; + Obj* cur = nullptr; + for (size_t at = 0; (at = s.find('<', at)) != std::string_view::npos;) { + const size_t close = s.find('>', at); + if (close == std::string_view::npos) break; + const std::string_view tag = s.substr(at + 1, close - at - 1); + at = close + 1; + auto is = [&](std::string_view t) { return tag.starts_with(t) && tag.size() > t.size() && (std::isspace(static_cast(tag[t.size()])) || tag[t.size()] == '/'); }; + if (is("object")) { + cur = &objs[std::atoi(std::string(attr(tag, "id")).c_str())]; + cur->name = unescape(attr(tag, "name")); + } else if (is("vertex") && cur) + cur->mesh.verts.push_back(glm::vec3(toFloat(attr(tag, "x")), toFloat(attr(tag, "y")), toFloat(attr(tag, "z")))); + else if (is("triangle") && cur) + cur->mesh.faces.push_back({std::atoi(std::string(attr(tag, "v1")).c_str()), std::atoi(std::string(attr(tag, "v2")).c_str()), std::atoi(std::string(attr(tag, "v3")).c_str())}); + else if (is("component") && cur) + cur->parts.push_back({std::atoi(std::string(attr(tag, "objectid")).c_str()), transform3mf(attr(tag, "transform"))}); + else if (is("item")) + items.push_back({std::atoi(std::string(attr(tag, "objectid")).c_str()), transform3mf(attr(tag, "transform"))}); + } + std::function place = [&](int id, const glm::mat4& m, Mesh& into, int depth) { + auto it = objs.find(id); + if (it == objs.end() || depth > 8) return; + for (const auto& f : it->second.mesh.faces) + for (int v : f) + if (v < 0 || v >= int(it->second.mesh.verts.size())) return; + append(into, it->second.mesh, m); + for (const auto& [part, pm] : it->second.parts) place(part, m * pm, into, depth + 1); + }; + std::vector out; + if (items.empty()) + for (const auto& [id, o] : objs) items.push_back({id, glm::mat4(1.0f)}); + for (const auto& [id, m] : items) { + ImportedMesh im{objs.count(id) && !objs[id].name.empty() ? objs[id].name : name, {}}; + place(id, glm::scale(glm::mat4(1.0f), glm::vec3(unit)) * m, im.mesh, 0); + tidy(im.mesh); + if (!im.mesh.faces.empty()) out.push_back(std::move(im)); + } + if (out.empty()) *error = "No shapes in that 3MF"; + return out; +} + +std::vector readGltf(const std::string& path, const std::string& name, std::string* error) { + cgltf_options opt{}; + cgltf_data* data = nullptr; + if (cgltf_parse_file(&opt, path.c_str(), &data) != cgltf_result_success || cgltf_load_buffers(&opt, data, path.c_str()) != cgltf_result_success) { + if (data) cgltf_free(data); + *error = "That glTF can't be read"; + return {}; + } + std::vector out; + for (size_t n = 0; n < data->nodes_count; ++n) { + const cgltf_node& node = data->nodes[n]; + if (!node.mesh) continue; + float w[16]; + cgltf_node_transform_world(&node, w); + const glm::mat4 m = glm::mat4(1, 0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1) * glm::make_mat4(w); // (glTF's up is Y; here it's Z) + ImportedMesh im{node.name ? node.name : node.mesh->name ? node.mesh->name : name, {}}; + for (size_t p = 0; p < node.mesh->primitives_count; ++p) { + const cgltf_primitive& prim = node.mesh->primitives[p]; + if (prim.type != cgltf_primitive_type_triangles) continue; + const cgltf_accessor* pos = nullptr; + for (size_t a = 0; a < prim.attributes_count; ++a) + if (prim.attributes[a].type == cgltf_attribute_type_position) pos = prim.attributes[a].data; + if (!pos) continue; + const int base = int(im.mesh.verts.size()); + for (size_t i = 0; i < pos->count; ++i) { + float v[3] = {}; + cgltf_accessor_read_float(pos, i, v, 3); + im.mesh.verts.push_back(glm::vec3(m * glm::vec4(v[0], v[1], v[2], 1))); + } + const size_t count = prim.indices ? prim.indices->count : pos->count; + for (size_t i = 0; i + 2 < count; i += 3) { + std::vector f(3); + for (int k = 0; k < 3; ++k) f[size_t(k)] = base + int(prim.indices ? cgltf_accessor_read_index(prim.indices, i + size_t(k)) : i + size_t(k)); + im.mesh.faces.push_back(f); + } + } + tidy(im.mesh); + if (!im.mesh.faces.empty()) out.push_back(std::move(im)); + } + cgltf_free(data); + if (out.empty()) *error = "No shapes in that glTF"; + return out; +} + +} // namespace + +const std::vector& importFormats() { + static const std::vector f = {".stl", ".obj", ".3mf", ".glb", ".gltf"}; + return f; +} + +std::vector importModel(const std::string& path, std::string* error) { + std::string dummy; + if (!error) error = &dummy; + const std::filesystem::path p(path); + const std::string ext = lower(p.extension().string()), name = p.stem().string(); + if (ext == ".glb" || ext == ".gltf") return readGltf(path, name, error); + std::ifstream in(path, std::ios::binary); + if (!in) return *error = "Couldn't read " + p.filename().string(), std::vector{}; + const std::vector d{std::istreambuf_iterator(in), {}}; + if (ext == ".stl") return readStl(d, name, error); + if (ext == ".obj") return readObj(d, name); + if (ext == ".3mf") return read3mf(d, name, error); + *error = "Fry3DX can't read " + ext + " files (STL, OBJ, 3MF, glTF and GLB it can)"; + return {}; +} + +} // namespace fry diff --git a/src/import.h b/src/import.h new file mode 100644 index 0000000..1a9f381 --- /dev/null +++ b/src/import.h @@ -0,0 +1,24 @@ +#pragma once +// Models made elsewhere, read in: STL, OBJ, 3MF, glTF / GLB. Each object in the file comes back +// as its own mesh (in metres; points that share a place, one). +#include "mesh.h" + +#include +#include + +namespace fry { + +struct ImportedMesh { + std::string name; + Mesh mesh; +}; + +// The file's objects, or empty with `error` set. STL and 3MF are in millimetres: made metres. +std::vector importModel(const std::string& path, std::string* error = nullptr); +// The file types it reads, for the file picker (".stl", ...). +const std::vector& importFormats(); + +// A raw DEFLATE stream unpacked (what a zip, so a 3MF, holds); empty if it's broken. +std::vector inflate(const uint8_t* data, size_t size, size_t expect = 0); + +} // namespace fry diff --git a/src/mesh.cpp b/src/mesh.cpp index c79cfdc..3c845c7 100644 --- a/src/mesh.cpp +++ b/src/mesh.cpp @@ -182,6 +182,10 @@ std::vector> triangulate(const Mesh& m, int face) { const glm::vec3 v = glm::cross(n, u); std::vector p; for (int i : f) p.emplace_back(glm::dot(m.verts[size_t(i)], u), glm::dot(m.verts[size_t(i)], v)); + // (the tests go by the face's own size: a letter 5 mm tall the same as a wall 5 m wide) + glm::vec2 lo(1e30f), hi(-1e30f); + for (const glm::vec2& q : p) lo = glm::min(lo, q), hi = glm::max(hi, q); + const float span = std::max(hi.x - lo.x, hi.y - lo.y), eps = std::max(span * span * 1e-9f, 1e-30f); auto cross2 = [](glm::vec2 a, glm::vec2 b, glm::vec2 c) { return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); }; std::vector left(f.size()); std::iota(left.begin(), left.end(), 0); @@ -189,12 +193,16 @@ std::vector> triangulate(const Mesh& m, int face) { bool cut = false; for (size_t i = 0; i < left.size() && !cut; ++i) { const int a = left[(i + left.size() - 1) % left.size()], b = left[i], c = left[(i + 1) % left.size()]; - if (cross2(p[size_t(a)], p[size_t(b)], p[size_t(c)]) <= 1e-12f) continue; // dents in, or flat + const glm::vec2 pa = p[size_t(a)], pb = p[size_t(b)], pc = p[size_t(c)]; + if (cross2(pa, pb, pc) <= eps) continue; // dents in, or flat bool inside = false; for (int o : left) { if (o == a || o == b || o == c) continue; const glm::vec2 q = p[size_t(o)]; - if (cross2(p[size_t(a)], p[size_t(b)], q) >= 0 && cross2(p[size_t(b)], p[size_t(c)], q) >= 0 && cross2(p[size_t(c)], p[size_t(a)], q) >= 0) { + if (q == pa || q == pb || q == pc) continue; // (the same place: where a hole's cut joins) + // (strictly inside, or on the new edge a-c: a point on the outline's own edges a-b, b-c is no harm) + const float ab = cross2(pa, pb, q), bc = cross2(pb, pc, q), ca = cross2(pc, pa, q); + if (ab > eps && bc > eps && ca >= -eps) { inside = true; break; } @@ -204,6 +212,11 @@ std::vector> triangulate(const Mesh& m, int face) { left.erase(left.begin() + std::ptrdiff_t(i)); cut = true; } + if (!cut) // (no ear: a point in a straight line between its neighbours goes, it covers nothing) + for (size_t i = 0; i < left.size() && !cut; ++i) { + const int a = left[(i + left.size() - 1) % left.size()], b = left[i], c = left[(i + 1) % left.size()]; + if (std::abs(cross2(p[size_t(a)], p[size_t(b)], p[size_t(c)])) <= eps) left.erase(left.begin() + std::ptrdiff_t(i)), cut = true; + } if (!cut) { // (a tangled face: a fan of what's left) for (size_t i = 1; i + 1 < left.size(); ++i) out.push_back({f[size_t(left[0])], f[size_t(left[i])], f[size_t(left[i + 1])]}); return out; diff --git a/src/text.cpp b/src/text.cpp new file mode 100644 index 0000000..da4f4ed --- /dev/null +++ b/src/text.cpp @@ -0,0 +1,202 @@ +#include "text.h" + +#define STBTT_STATIC +#define STB_TRUETYPE_IMPLEMENTATION +#include + +#include + +#include +#include + +namespace fry { +namespace { + +// The next character of UTF-8 text (a bad byte: itself). +uint32_t nextChar(const std::string& s, size_t& i) { + const auto b = [&](size_t k) { return k < s.size() ? uint32_t(uint8_t(s[k])) : 0u; }; + const uint32_t c = b(i); + if (c < 0x80) return ++i, c; + if ((c & 0xE0) == 0xC0) return i += 2, ((c & 0x1F) << 6) | (b(i - 1) & 0x3F); + if ((c & 0xF0) == 0xE0) return i += 3, ((c & 0x0F) << 12) | ((b(i - 2) & 0x3F) << 6) | (b(i - 1) & 0x3F); + if ((c & 0xF8) == 0xF0) return i += 4, ((c & 0x07) << 18) | ((b(i - 3) & 0x3F) << 12) | ((b(i - 2) & 0x3F) << 6) | (b(i - 1) & 0x3F); + return ++i, c; +} + +using Contour = std::vector; + +// A glyph's outlines (font units), its curves cut into `detail` straight pieces each. +std::vector glyphContours(const stbtt_fontinfo& f, int glyph, int detail) { + stbtt_vertex* v = nullptr; + const int n = stbtt_GetGlyphShape(&f, glyph, &v); + std::vector out; + glm::vec2 at(0.0f); + for (int i = 0; i < n; ++i) { + const glm::vec2 to(v[i].x, v[i].y); + switch (v[i].type) { + case STBTT_vmove: + out.emplace_back(); + out.back().push_back(to); + break; + case STBTT_vline: + if (!out.empty()) out.back().push_back(to); + break; + case STBTT_vcurve: { + const glm::vec2 c(v[i].cx, v[i].cy); + for (int k = 1; k <= detail && !out.empty(); ++k) { + const float t = float(k) / float(detail); + out.back().push_back((1 - t) * (1 - t) * at + 2 * (1 - t) * t * c + t * t * to); + } + break; + } + case STBTT_vcubic: { + const glm::vec2 c1(v[i].cx, v[i].cy), c2(v[i].cx1, v[i].cy1); + for (int k = 1; k <= detail + 2 && !out.empty(); ++k) { + const float t = float(k) / float(detail + 2), u = 1 - t; + out.back().push_back(u * u * u * at + 3 * u * u * t * c1 + 3 * u * t * t * c2 + t * t * t * to); + } + break; + } + default: break; + } + at = to; + } + stbtt_FreeShape(&f, v); + for (Contour& c : out) { + while (c.size() > 1 && glm::distance(c.front(), c.back()) < 1e-3f) c.pop_back(); + Contour clean; // (no point twice in a row, none in a straight line between its neighbours) + for (const glm::vec2& p : c) + if (clean.empty() || glm::distance(clean.back(), p) > 1e-3f) clean.push_back(p); + c = clean; + } + std::erase_if(out, [](const Contour& c) { return c.size() < 3; }); + return out; +} + +// Its area: positive when it turns counter-clockwise. +float area(const Contour& c) { + float a = 0; + for (size_t i = 0; i < c.size(); ++i) { + const glm::vec2 p = c[i], q = c[(i + 1) % c.size()]; + a += p.x * q.y - q.x * p.y; + } + return a * 0.5f; +} + +// A glyph made solid. Fonts tell holes by the way an outline turns: the other way from the +// letter's body. Pieces that turn the body's way are joined (a letter can be drawn as overlapping +// strokes), then the holes cut out of what they're in. +Mesh solidGlyph(const std::vector& cs, float scale, glm::vec2 at, float depth) { + if (cs.empty()) return {}; + size_t biggest = 0; + for (size_t i = 1; i < cs.size(); ++i) + if (std::abs(area(cs[i])) > std::abs(area(cs[biggest]))) biggest = i; + const bool bodyTurn = area(cs[biggest]) > 0; + auto prism = [&](const Contour& c, float thick) { + std::vector pts; + for (const glm::vec2& p : c) pts.push_back(glm::vec3(at + p * scale, 0.0f)); + Mesh m = extrudeOutline(pts, glm::vec3(0, 0, 1), thick); + transform(m, glm::translate(glm::mat4(1.0f), glm::vec3(0, 0, depth * 0.5f))); + return m; + }; + struct Box { + glm::vec2 lo{1e30f}, hi{-1e30f}; + bool meets(const Box& o) const { return lo.x <= o.hi.x && o.lo.x <= hi.x && lo.y <= o.hi.y && o.lo.y <= hi.y; } + }; + std::vector box(cs.size()); + for (size_t i = 0; i < cs.size(); ++i) + for (const glm::vec2& p : cs[i]) box[i].lo = glm::min(box[i].lo, p), box[i].hi = glm::max(box[i].hi, p); + // The body's pieces in groups that touch (each group one solid). + std::vector body, holes; + for (size_t i = 0; i < cs.size(); ++i) (area(cs[i]) > 0) == bodyTurn ? body.push_back(i) : holes.push_back(i); + std::vector group(cs.size(), -1); + int groups = 0; + for (size_t i : body) { + if (group[i] >= 0) continue; + std::vector todo{i}; + group[i] = groups; + while (!todo.empty()) { + const size_t k = todo.back(); + todo.pop_back(); + for (size_t j : body) + if (group[j] < 0 && box[k].meets(box[j])) group[j] = groups, todo.push_back(j); + } + ++groups; + } + Mesh out; + for (int g = 0; g < groups; ++g) { + Mesh solid; + Box gb; + for (size_t i : body) { + if (group[i] != g) continue; + gb.lo = glm::min(gb.lo, box[i].lo), gb.hi = glm::max(gb.hi, box[i].hi); + if (solid.faces.empty()) solid = prism(cs[i], depth); + else if (Mesh joined = solid; combine(joined, prism(cs[i], depth), Combine::Add)) solid = std::move(joined); + else append(solid, prism(cs[i], depth)); // (couldn't join: overlapping, but there) + } + for (size_t h : holes) + if (gb.meets(box[h])) + if (Mesh cut = solid; combine(cut, prism(cs[h], depth * 1.5f), Combine::Subtract)) solid = std::move(cut); + append(out, solid); + } + return out; +} + +} // namespace + +Mesh makeText(const std::vector& font, const std::string& text, const TextStyle& style, std::string* error) { + stbtt_fontinfo f; + if (font.empty() || !stbtt_InitFont(&f, font.data(), stbtt_GetFontOffsetForIndex(font.data(), 0))) { + if (error) *error = "That isn't a font that can be read (TrueType or OpenType)"; + return {}; + } + // The size: a capital H as tall as asked. + int x0, y0, x1, y1; + float capital = 0; + if (stbtt_GetCodepointBox(&f, 'H', &x0, &y0, &x1, &y1)) capital = float(y1); + if (capital <= 0) { + int ascent, descent, gap; + stbtt_GetFontVMetrics(&f, &ascent, &descent, &gap); + capital = float(ascent) * 0.7f; + } + const float scale = style.height / std::max(capital, 1.0f); + // The lines, each as wide as its letters. + std::vector lines(1); + for (char c : text) c == '\n' ? (void)lines.emplace_back() : lines.back().push_back(c); + auto width = [&](const std::string& line) { + float x = 0; + int prev = 0; + for (size_t i = 0; i < line.size();) { + const int g = stbtt_FindGlyphIndex(&f, int(nextChar(line, i))); + int adv, lsb; + stbtt_GetGlyphHMetrics(&f, g, &adv, &lsb); + if (prev) x += float(stbtt_GetGlyphKernAdvance(&f, prev, g)) * scale; + x += float(adv) * scale + style.spacing * style.height; + prev = g; + } + return x; + }; + float widest = 0; + for (const std::string& l : lines) widest = std::max(widest, width(l)); + Mesh out; + float y = 0; + for (const std::string& line : lines) { + const float w = width(line); + float x = style.align == 1 ? (widest - w) * 0.5f : style.align == 2 ? widest - w : 0.0f; + int prev = 0; + for (size_t i = 0; i < line.size();) { + const int g = stbtt_FindGlyphIndex(&f, int(nextChar(line, i))); + if (prev) x += float(stbtt_GetGlyphKernAdvance(&f, prev, g)) * scale; + append(out, solidGlyph(glyphContours(f, g, std::max(1, style.detail)), scale, glm::vec2(x, y), style.depth)); + int adv, lsb; + stbtt_GetGlyphHMetrics(&f, g, &adv, &lsb); + x += float(adv) * scale + style.spacing * style.height; + prev = g; + } + y -= style.height * (1.0f + std::max(0.0f, style.lineGap) + 0.4f); // (a capital, what hangs below, and the gap) + } + if (out.faces.empty() && error) *error = "No letters to make (only spaces?)"; + return out; +} + +} // namespace fry diff --git a/src/text.h b/src/text.h new file mode 100644 index 0000000..cb4de83 --- /dev/null +++ b/src/text.h @@ -0,0 +1,24 @@ +#pragma once +// Text as a 3D shape: the letters' outlines from a font (TrueType / OpenType), made solid. +#include "mesh.h" + +#include +#include +#include + +namespace fry { + +struct TextStyle { + float height = 0.1f; // a capital letter's height (m) + float depth = 0.02f; // how thick the letters are (m) + float spacing = 0.0f; // more (or less) room between letters, times the height + float lineGap = 0.25f; // room between lines, times the height + int align = 0; // 0 left, 1 middle, 2 right + int detail = 6; // points along each curve of a letter +}; + +// The letters lying on the ground (XY), the first line's base along X from 0, going up out of the +// ground to `depth`. Lines split at '\n'. Empty, with `error` set, if the font can't be read. +Mesh makeText(const std::vector& font, const std::string& text, const TextStyle& style, std::string* error = nullptr); + +} // namespace fry diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6d12a6e..f6c49ab 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,5 +1,6 @@ add_executable(mesh_test mesh_test.cpp) target_link_libraries(mesh_test PRIVATE fry_core) +target_compile_definitions(mesh_test PRIVATE FRY_FONTS_DIR="${PROJECT_SOURCE_DIR}/content/engine/fonts") add_test(NAME mesh_test COMMAND mesh_test WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) add_executable(script_test script_test.cpp) target_link_libraries(script_test PRIVATE fry_script) diff --git a/tests/mesh_test.cpp b/tests/mesh_test.cpp index a623715..afd79f5 100644 --- a/tests/mesh_test.cpp +++ b/tests/mesh_test.cpp @@ -1,6 +1,8 @@ // The modelling core without a window: shapes are closed and face out, faces with dents // triangulate right, extrude / inset / merge keep the mesh whole, and files round-trip. #include "bvh.h" +#include "import.h" +#include "text.h" #include "mesh.h" #include "image.h" #include "scene.h" @@ -363,6 +365,32 @@ int main() { CHECK(combine(a, drill, Combine::Subtract) && checkForPrint(a).closed() && checkForPrint(a).volume < 1.0f && checkForPrint(a).volume > 0.85f, "a cylinder drilled through a cube: closed, %g m3", checkForPrint(a).volume); } + // Text as a shape: letters with holes (O, A, B) come out closed, as tall as asked. + { + std::ifstream in(FRY_FONTS_DIR "/InstrumentSans-Bold.ttf", std::ios::binary); + const std::vector font{std::istreambuf_iterator(in), {}}; + TextStyle st; + st.height = 0.05f, st.depth = 0.01f; + std::string err; + const Mesh t = makeText(font, "Fry 3D", st, &err); + const PrintCheck c = checkForPrint(t); + CHECK(!t.faces.empty() && c.closed() && std::abs(boundsMax(t).z - 0.01f) < 1e-4f && boundsMax(t).y > 0.045f && boundsMax(t).y < 0.06f, + "text \"Fry 3D\": %zu faces, closed %d, %.3f m tall %s", t.faces.size(), int(c.closed()), boundsMax(t).y, err.c_str()); + // (every face's triangles cover just the face: letters are dented, with points in straight lines) + int covered = 0; + for (size_t f = 0; f < t.faces.size(); ++f) { + float a = 0; + for (const auto& tr : triangulate(t, int(f))) a += 0.5f * glm::length(glm::cross(t.verts[size_t(tr[1])] - t.verts[size_t(tr[0])], t.verts[size_t(tr[2])] - t.verts[size_t(tr[0])])); + covered += std::abs(a - faceArea(t, int(f))) <= faceArea(t, int(f)) * 1e-2f + 1e-12f; // (a sliver far out: float rounding) + } + CHECK(covered == int(t.faces.size()), "letters' faces made into triangles that cover them (%d of %zu)", covered, t.faces.size()); + // (letters drawn as overlapping strokes are whole: an F is as wide as an E, not just its stem) + const float fw = boundsMax(makeText(font, "F", st)).x - boundsMin(makeText(font, "F", st)).x, ew = boundsMax(makeText(font, "E", st)).x - boundsMin(makeText(font, "E", st)).x; + CHECK(fw > ew * 0.8f && checkForPrint(makeText(font, "F", st)).closed(), "an F is whole (%.1f mm wide, an E %.1f)", fw * 1000, ew * 1000); + const Mesh o = makeText(font, "O", st); + CHECK(checkForPrint(o).closed() && checkForPrint(o).volume < 0.05f * 0.04f * 0.01f, "an O has its hole (%g m3)", checkForPrint(o).volume); + CHECK(makeText({}, "x", st, &err).faces.empty() && !err.empty(), "no font: nothing, and why"); + } // The tree of boxes finds the same nearest hit as trying every triangle (and the same "in the way"). { Mesh m = makeSphere(32, 16, 1.0f); @@ -449,6 +477,23 @@ int main() { CHECK(exportModel((dir / "a.gltf").string(), s, {}) && std::filesystem::file_size(dir / "a.bin") > 0, "exports glTF"); for (const auto& [ext, what] : exportFormats()) CHECK(exportModel((dir / ("b" + ext)).string(), s, {}) && std::filesystem::file_size(dir / ("b" + ext)) > 100, "exports %s", ext.c_str()); + // Read back in: the same shape, the same size (STL and 3MF go by millimetres, OBJ and glTF by Y up). + for (const std::string ext : {".stl", ".obj", ".3mf", ".glb", ".gltf"}) { + std::string err; + const auto in = importModel((dir / ((ext == ".gltf" ? "a" : "b") + ext)).string(), &err); + const float want = checkForPrint(cube).volume, got = in.empty() ? 0 : checkForPrint(in[0].mesh).volume; + if (ext == ".3mf") CHECK(!in.empty() && in[0].name == o.name, "a 3MF's name, as it was (%s)", in.empty() ? "" : in[0].name.c_str()); + CHECK(in.size() == 1 && checkForPrint(in[0].mesh).closed() && std::abs(got - want) < want * 1e-3f, "imports %s back (%g m3, %s)", ext.c_str(), got, err.c_str()); + } + { + const uint8_t packed[] = {115,43,170,52,118,137,80,40,74,77,76,41,86,48,246,117,83,72,203,204,73,45,214,83,112,27,21,31,21,31,21,31,244,226,12,140,76,204,44,172,108,236,28,156,92,220,60,188,124,252,2,130,66,194,34,162,98,226,18,146,82,210,50,178,114,242,10,138,74,202,42,170,106,234,26,154,90,218,58,186,122,250,6,134,70,198,38,166,102,230,22,150,86,214,54,182,118,246,14,142,78,206,46,174,110,238,30,158,94,222,62,190,126,254,1,129,65,193,33,161,97,225,17,145,81,209,49,177,113,241,9,137,73,201,41,169,105,233,25,153,89,217,57,185,121,249,5,133,69,197,37,165,101,229,21,149,85,213,53,181,117,245,13,141,77,205,45,173,109,237,29,157,93,221,61,189,125,253,19,38,78,154,60,101,234,180,233,51,102,206,154,61,103,238,188,249,11,22,46,90,188,100,233,178,229,43,86,174,90,189,102,237,186,245,27,54,110,218,188,101,235,182,237,59,118,238,218,189,103,239,190,253,7,14,30,58,124,228,232,177,227,39,78,158,58,125,230,236,185,243,23,46,94,186,124,229,234,181,235,55,110,222,186,125,231,238,189,251,15,30,62,122,252,228,233,179,231,47,94,190,122,253,230,237,187,247,31,62,126,250,252,229,235,183,239,63,126,254,250,253,231,239,191,255,0}; + std::string want; + for (int i = 0; i < 40; ++i) want += "Fry3DX reads 3MF files. "; + for (int i = 0; i < 256; ++i) want += char(i); + const std::vector out = inflate(packed, sizeof packed); + CHECK(std::string(out.begin(), out.end()) == want && out.size() == 1216, "unpacks DEFLATE (%zu bytes)", out.size()); + CHECK(inflate(packed, sizeof packed / 2).empty(), "a cut-short DEFLATE: nothing"); + } // Its own file: one packed file with its pictures and layers, back the same; old text ones open too. {