3D text (fonts' overlapping strokes joined, holes by winding), import STL / OBJ / 3MF / glTF (own DEFLATE for 3MF), sturdier face triangles; tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-21 00:36:54 +02:00
commit c2bbafd40c
9 changed files with 889 additions and 7 deletions

View file

@ -1,8 +1,8 @@
# Fry3DX: the app (app.cpp) on the engine, and its modelling core (mesh, scene) as a library the # Fry3DX: the app (app.cpp) on the engine, and its modelling core (mesh, scene) as a library the
# tests use too. # 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_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) target_compile_definitions(fry_core PUBLIC GLM_ENABLE_EXPERIMENTAL)
# Scripts (Lua) that build and change the scene: also without a window, for the tests. # Scripts (Lua) that build and change the scene: also without a window, for the tests.
add_library(fry_script STATIC script.cpp) add_library(fry_script STATIC script.cpp)

View file

@ -11,6 +11,8 @@
#include "script.h" #include "script.h"
#include "sculpt.h" #include "sculpt.h"
#include "shapes.h" #include "shapes.h"
#include "import.h"
#include "text.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#if defined(__ANDROID__) #if defined(__ANDROID__)
@ -595,6 +597,10 @@ public:
self->pie_ = a.size() > 1 && a[1] == "views" ? 2 : 1, self->pieStays_ = true; 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); self->pieAt_ = ImVec2((self->viewMin_.x + self->viewMax_.x) / 2, (self->viewMin_.y + self->viewMax_.y) / 2);
}, "Fry3DX: show a pie menu (tools | views)"); }, "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 about("fry_about", [](const keel::Args&) { self->showAbout_ = true; }, "Fry3DX: show the About window");
static keel::Cmd plugins("fry_plugins", [](const keel::Args& a) { static keel::Cmd plugins("fry_plugins", [](const keel::Args& a) {
if (a.size() > 1 && a[1] == "reload") self->loadPlugins(); if (a.size() > 1 && a[1] == "reload") self->loadPlugins();
@ -722,6 +728,7 @@ public:
commandForm(); commandForm();
copiesWindow(); copiesWindow();
thickenWindow(); thickenWindow();
textWindow();
pushWindow(); pushWindow();
saveQuestion(); saveQuestion();
recoverWindow(); recoverWindow();
@ -1546,6 +1553,14 @@ private:
if (kind == 10) SDL_ShowOpenFileDialog(done, this, w, lua, 1, at, false); 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 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); else SDL_ShowOpenFileDialog(done, this, w, pics, 1, at, false);
} }
void takeDialogResult() { void takeDialogResult() {
@ -1567,6 +1582,11 @@ private:
return; return;
} }
if (isUri(file) && (kind == 8 || kind == 9)) return exportFile(file, -1, kind == 8 ? ".3mf" : ".stl"); 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 == 10) return openScript(isUri(file) ? fromUri(file, "opened.lua") : file, fileName(file));
if (kind == 11) return saveScript(file); if (kind == 11) return saveScript(file);
if (kind == 1) openFile(file); if (kind == 1) openFile(file);
@ -3692,6 +3712,104 @@ private:
changedShape(*o); changedShape(*o);
say(std::format("{} made {:.1f} mm thick{}", o->name, thickness_ * 1000, checkForPrint(o->mesh).closed() ? " (closed all round)" : "")); 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<fry::ImportedMesh> 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<uint8_t> font;
if (!textFontFile_.empty()) {
std::ifstream in(textFontFile_, std::ios::binary);
font.assign(std::istreambuf_iterator<char>(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() { void thickenWindow() {
if (!showThicken_) return; if (!showThicken_) return;
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); 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_Z)) shift ? redo() : undo();
if (key(ImGuiKey_Y)) redo(); if (key(ImGuiKey_Y)) redo();
if (key(ImGuiKey_S)) shift || path_.empty() ? dialog(2) : void(saveFile(path_)); 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_N)) whenSaved(2);
if (key(ImGuiKey_E)) dialog(3); if (key(ImGuiKey_E)) dialog(3);
if (key(ImGuiKey_Q)) whenSaved(1); if (key(ImGuiKey_Q)) whenSaved(1);
@ -5561,7 +5679,7 @@ private:
auto model = [this] { return tab_ == Tab::Model; }; auto model = [this] { return tab_ == Tab::Model; };
add("file.new", "New", "File", "Ctrl+N", [this] { whenSaved(2); }, {}, "empty start"); 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.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.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.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"); 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", "Ctrl+S")) path_.empty() ? dialog(2) : void(saveFile(path_));
if (ImGui::MenuItem("Save as...", "Ctrl+Shift+S")) dialog(2); if (ImGui::MenuItem("Save as...", "Ctrl+Shift+S")) dialog(2);
ImGui::Separator(); 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::MenuItem("Export (GLB, glTF, OBJ, STL, PLY)...", "Ctrl+E")) dialog(3);
if (ImGui::BeginMenu("Autosave")) { if (ImGui::BeginMenu("Autosave")) {
static const int every[] = {1, 2, 5, 10, 0}; static const int every[] = {1, 2, 5, 10, 0};
@ -7522,7 +7641,7 @@ private:
} }
// ── (being made) ── // ── (being made) ──
bool showText_ = false, showSpin_ = false, showPrintSize_ = false, liveMirror_ = false; bool showSpin_ = false, showPrintSize_ = false, liveMirror_ = false;
void repairSelected() {} void repairSelected() {}
void startSlide() {} void startSlide() {}

454
src/import.cpp Normal file
View file

@ -0,0 +1,454 @@
#include "import.h"
#define CGLTF_IMPLEMENTATION
#include <cgltf.h>
#include <glm/gtc/type_ptr.hpp>
#include <algorithm>
#include <charconv>
#include <cmath>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <functional>
#include <map>
#include <set>
#include <sstream>
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<uint8_t>& 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<uint8_t> inflate(const uint8_t* data, size_t size, size_t expect) {
Bits b{data, size};
std::vector<uint8_t> 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<unsigned char>(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<int>& f) { return f.size() < 3 || std::set<int>(f.begin(), f.end()).size() < 3; });
}
std::vector<ImportedMesh> readStl(const std::vector<uint8_t>& 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<const char*>(d.data()), std::min<size_t>(d.size(), 600)).find("facet") != std::string::npos;
if (!ascii) {
if (d.size() < 84) return *error = "That STL is too short", std::vector<ImportedMesh>{};
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<ImportedMesh>{};
for (uint32_t i = 0; i < n; ++i) {
const uint8_t* t = d.data() + 84 + size_t(i) * 50 + 12;
std::vector<int> 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<int> 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<ImportedMesh> readObj(const std::vector<uint8_t>& d, const std::string& name) {
std::vector<glm::vec3> all;
std::vector<ImportedMesh> out{{name, {}}};
std::map<int, int> 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<int> 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<uint8_t> zipFile(const std::vector<uint8_t>& z, const std::function<bool(const std::string&)>& 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<uint8_t>(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<unsigned char>(tag[at - 1]))) continue;
size_t q = at + a.size();
while (q < tag.size() && std::isspace(static_cast<unsigned char>(tag[q]))) ++q;
if (q >= tag.size() || tag[q] != '=') continue;
++q;
while (q < tag.size() && std::isspace(static_cast<unsigned char>(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 &amp; &lt; &gt; &quot; &apos; 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<ImportedMesh> read3mf(const std::vector<uint8_t>& d, const std::string& name, std::string* error) {
const std::vector<uint8_t> 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<ImportedMesh>{};
const std::string_view s(reinterpret_cast<const char*>(xml.data()), xml.size());
float unit = 0.001f;
if (const size_t m = s.find("<model"); m != std::string_view::npos) {
const std::string u(attr(s.substr(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<std::pair<int, glm::mat4>> parts;
};
std::map<int, Obj> objs;
std::vector<std::pair<int, glm::mat4>> 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<unsigned char>(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<void(int, const glm::mat4&, Mesh&, int)> 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<ImportedMesh> 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<ImportedMesh> 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<ImportedMesh> 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<int> 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<std::string>& importFormats() {
static const std::vector<std::string> f = {".stl", ".obj", ".3mf", ".glb", ".gltf"};
return f;
}
std::vector<ImportedMesh> 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<ImportedMesh>{};
const std::vector<uint8_t> d{std::istreambuf_iterator<char>(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

24
src/import.h Normal file
View file

@ -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 <string>
#include <vector>
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<ImportedMesh> importModel(const std::string& path, std::string* error = nullptr);
// The file types it reads, for the file picker (".stl", ...).
const std::vector<std::string>& importFormats();
// A raw DEFLATE stream unpacked (what a zip, so a 3MF, holds); empty if it's broken.
std::vector<uint8_t> inflate(const uint8_t* data, size_t size, size_t expect = 0);
} // namespace fry

View file

@ -182,6 +182,10 @@ std::vector<std::array<int, 3>> triangulate(const Mesh& m, int face) {
const glm::vec3 v = glm::cross(n, u); const glm::vec3 v = glm::cross(n, u);
std::vector<glm::vec2> p; std::vector<glm::vec2> p;
for (int i : f) p.emplace_back(glm::dot(m.verts[size_t(i)], u), glm::dot(m.verts[size_t(i)], v)); 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); }; 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<int> left(f.size()); std::vector<int> left(f.size());
std::iota(left.begin(), left.end(), 0); std::iota(left.begin(), left.end(), 0);
@ -189,12 +193,16 @@ std::vector<std::array<int, 3>> triangulate(const Mesh& m, int face) {
bool cut = false; bool cut = false;
for (size_t i = 0; i < left.size() && !cut; ++i) { 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()]; 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; bool inside = false;
for (int o : left) { for (int o : left) {
if (o == a || o == b || o == c) continue; if (o == a || o == b || o == c) continue;
const glm::vec2 q = p[size_t(o)]; 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; inside = true;
break; break;
} }
@ -204,6 +212,11 @@ std::vector<std::array<int, 3>> triangulate(const Mesh& m, int face) {
left.erase(left.begin() + std::ptrdiff_t(i)); left.erase(left.begin() + std::ptrdiff_t(i));
cut = true; 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) 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])]}); 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; return out;

202
src/text.cpp Normal file
View file

@ -0,0 +1,202 @@
#include "text.h"
#define STBTT_STATIC
#define STB_TRUETYPE_IMPLEMENTATION
#include <stb_truetype.h>
#include <glm/gtc/matrix_transform.hpp>
#include <algorithm>
#include <cmath>
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<glm::vec2>;
// A glyph's outlines (font units), its curves cut into `detail` straight pieces each.
std::vector<Contour> glyphContours(const stbtt_fontinfo& f, int glyph, int detail) {
stbtt_vertex* v = nullptr;
const int n = stbtt_GetGlyphShape(&f, glyph, &v);
std::vector<Contour> 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<Contour>& 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<glm::vec3> 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> 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<size_t> 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<int> group(cs.size(), -1);
int groups = 0;
for (size_t i : body) {
if (group[i] >= 0) continue;
std::vector<size_t> 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<uint8_t>& 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<std::string> 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

24
src/text.h Normal file
View file

@ -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 <cstdint>
#include <string>
#include <vector>
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<uint8_t>& font, const std::string& text, const TextStyle& style, std::string* error = nullptr);
} // namespace fry

View file

@ -1,5 +1,6 @@
add_executable(mesh_test mesh_test.cpp) add_executable(mesh_test mesh_test.cpp)
target_link_libraries(mesh_test PRIVATE fry_core) 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_test(NAME mesh_test COMMAND mesh_test WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
add_executable(script_test script_test.cpp) add_executable(script_test script_test.cpp)
target_link_libraries(script_test PRIVATE fry_script) target_link_libraries(script_test PRIVATE fry_script)

View file

@ -1,6 +1,8 @@
// The modelling core without a window: shapes are closed and face out, faces with dents // 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. // triangulate right, extrude / inset / merge keep the mesh whole, and files round-trip.
#include "bvh.h" #include "bvh.h"
#include "import.h"
#include "text.h"
#include "mesh.h" #include "mesh.h"
#include "image.h" #include "image.h"
#include "scene.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, 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); "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<uint8_t> font{std::istreambuf_iterator<char>(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"). // 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); 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"); CHECK(exportModel((dir / "a.gltf").string(), s, {}) && std::filesystem::file_size(dir / "a.bin") > 0, "exports glTF");
for (const auto& [ext, what] : exportFormats()) 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()); 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<uint8_t> 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. // Its own file: one packed file with its pictures and layers, back the same; old text ones open too.
{ {