app.cpp split: the App class declared in app.h, what it does in app.cpp (document, files, keys, main), app_model, app_paint, app_print, app_view, app_panels, app_scripts (done by a script: the bodies moved as they were); the print check's wall thickness kept while moving; two clang warnings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-21 08:51:21 +02:00
commit 830876a18e
10 changed files with 9477 additions and 8697 deletions

View file

@ -379,7 +379,8 @@ int runClient(Game& game) {
} }
const bool focused = SDL_GetWindowFlags(window.handle()) & SDL_WINDOW_INPUT_FOCUS; const bool focused = SDL_GetWindowFlags(window.handle()) & SDL_WINDOW_INPUT_FOCUS;
audio::setFocused(focused); audio::setFocused(focused);
if (vid_mode.consumeModified() | vid_width.consumeModified() | vid_height.consumeModified()) const bool mode = vid_mode.consumeModified(), width = vid_width.consumeModified(), height = vid_height.consumeModified(); // (each taken, none skipped)
if (mode || width || height)
window.setMode(vid_mode.i(), std::max(vid_width.i(), 640), std::max(vid_height.i(), 480)); window.setMode(vid_mode.i(), std::max(vid_width.i(), 640), std::max(vid_height.i(), 480));
// Menus need no more than fps_max_menu (like Source's): high frame rates there only heat // Menus need no more than fps_max_menu (like Source's): high frame rates there only heat
// the GPU and make coil whine (often heard in headphones on the onboard jack). // the GPU and make coil whine (often heard in headphones on the onboard jack).

View file

@ -22,12 +22,14 @@ target_compile_definitions(fry_core PUBLIC GLM_ENABLE_EXPERIMENTAL)
add_library(fry_script STATIC script.cpp) add_library(fry_script STATIC script.cpp)
target_link_libraries(fry_script PUBLIC fry_core PRIVATE lua) target_link_libraries(fry_script PUBLIC fry_core PRIVATE lua)
# The app: its class declared in app.h, what it does in app*.cpp by part.
set(FRY_APP_SOURCES app.cpp app_model.cpp app_paint.cpp app_panels.cpp app_print.cpp app_scripts.cpp app_view.cpp commands.cpp icons.cpp)
if(ANDROID) # SDL's Java side loads the app as libmain.so and calls its (SDL_)main if(ANDROID) # SDL's Java side loads the app as libmain.so and calls its (SDL_)main
add_library(fry3dx SHARED app.cpp commands.cpp icons.cpp) add_library(fry3dx SHARED ${FRY_APP_SOURCES})
set_target_properties(fry3dx PROPERTIES OUTPUT_NAME main LIBRARY_OUTPUT_DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}) set_target_properties(fry3dx PROPERTIES OUTPUT_NAME main LIBRARY_OUTPUT_DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY})
elseif(APPLE) # a Mac app: Fry3DX.app, content in Contents/Resources, MoltenVK in Contents/Frameworks elseif(APPLE) # a Mac app: Fry3DX.app, content in Contents/Resources, MoltenVK in Contents/Frameworks
set(icns ${PROJECT_SOURCE_DIR}/branding/fry3dx.icns) set(icns ${PROJECT_SOURCE_DIR}/branding/fry3dx.icns)
add_executable(fry3dx MACOSX_BUNDLE app.cpp commands.cpp icons.cpp ${icns}) add_executable(fry3dx MACOSX_BUNDLE ${FRY_APP_SOURCES} ${icns})
set_source_files_properties(${icns} PROPERTIES MACOSX_PACKAGE_LOCATION Resources) set_source_files_properties(${icns} PROPERTIES MACOSX_PACKAGE_LOCATION Resources)
set_target_properties(fry3dx PROPERTIES OUTPUT_NAME Fry3DX MACOSX_BUNDLE_INFO_PLIST ${PROJECT_SOURCE_DIR}/cmake/Info.plist.in) set_target_properties(fry3dx PROPERTIES OUTPUT_NAME Fry3DX MACOSX_BUNDLE_INFO_PLIST ${PROJECT_SOURCE_DIR}/cmake/Info.plist.in)
# Vulkan on a Mac is MoltenVK (Vulkan on Metal): FRY_MOLTENVK=/path/to/libMoltenVK.dylib, or # Vulkan on a Mac is MoltenVK (Vulkan on Metal): FRY_MOLTENVK=/path/to/libMoltenVK.dylib, or
@ -42,7 +44,7 @@ elseif(APPLE) # a Mac app: Fry3DX.app, content in Contents/Resources, MoltenVK i
message(WARNING "No MoltenVK found (set FRY_MOLTENVK): Fry3DX.app will only have OpenGL 4.1") message(WARNING "No MoltenVK found (set FRY_MOLTENVK): Fry3DX.app will only have OpenGL 4.1")
endif() endif()
else() else()
add_executable(fry3dx app.cpp commands.cpp icons.cpp) add_executable(fry3dx ${FRY_APP_SOURCES})
endif() endif()
if(WIN32) # a window program (no console window), with its icon if(WIN32) # a window program (no console window), with its icon
target_sources(fry3dx PRIVATE fry3dx.rc) target_sources(fry3dx PRIVATE fry3dx.rc)

File diff suppressed because it is too large Load diff

1378
src/app.h Normal file

File diff suppressed because it is too large Load diff

809
src/app_model.cpp Normal file
View file

@ -0,0 +1,809 @@
// Fry3DX: modelling: the knife and holes, combining and copies, spin and text, drawing a
// shape, and the mouse in the view.
#include "app.h"
using namespace fry;
namespace stdfs = std::filesystem;
namespace fryapp {
// ───────────────────────────── the knife and holes ─────────────────────────────
// Snapped to steps of 45 degrees from the last point while Ctrl is held.
auto App::knifeEnd(const ImGuiIO& io) const -> ImVec2 {
ImVec2 m = io.MousePos;
const bool have = !knifePts_.empty() || knifeDown_;
if (!io.KeyCtrl || !have) return m;
const ImVec2 from = knifePts_.empty() ? knifePress_ : knifePts_.back();
const float dx = m.x - from.x, dy = m.y - from.y, len = std::hypot(dx, dy);
const float a = std::round(std::atan2(dy, dx) / (kPi / 4)) * (kPi / 4);
return ImVec2(from.x + std::cos(a) * len, from.y + std::sin(a) * len);
}
auto App::subMesh(const Mesh& m, const std::set<int>& faces) -> Mesh {
Mesh p;
p.verts = m.verts;
for (int f : faces) p.faces.push_back(m.faces[size_t(f)]);
removeUnused(p);
return p;
}
// A new object like `from` (its look, place and finish), with another shape.
auto App::objectLike(uint32_t from, Mesh mesh, const std::string& name) -> uint32_t {
const Object copy = *find(from);
Object& n = addObject(name, std::move(mesh));
const uint32_t id = n.id;
const std::string unique = n.name;
Mesh m = std::move(n.mesh);
n = copy, n.id = id, n.name = unique, n.mesh = std::move(m), n.locked = false;
n.recipe.clear(), n.settings.clear(), n.uvs.clear();
touched(id);
return id;
}
auto App::knifeApply(uint32_t id, std::vector<ImVec2> pts) -> void {
Object* o = find(id);
if (!o || pts.size() < 2) return;
if (knifeThrough_) { // (on past its ends, so a line cuts right across)
auto past = [](ImVec2 a, ImVec2 b) {
const float dx = a.x - b.x, dy = a.y - b.y, l = std::max(std::hypot(dx, dy), 1e-3f);
return ImVec2(a.x + dx / l * 4000.0f, a.y + dy / l * 4000.0f);
};
pts.front() = past(pts[0], pts[1]);
pts.back() = past(pts[pts.size() - 1], pts[pts.size() - 2]);
}
const glm::mat4 inv = glm::inverse(objectMatrix(*o));
const glm::vec3 eye(inv * glm::vec4(eye_, 1.0f));
std::vector<glm::vec3> path;
for (ImVec2 p : pts) path.push_back(glm::vec3(inv * glm::vec4(eye_ + mouseRay(p).dir * dist_, 1.0f)));
pushUndo();
const auto made = knifeCut(o->mesh, eye, path, knifeThrough_);
if (made.empty()) {
dropUndo();
return say(knifeThrough_ ? "The line didn't cross the shape" : "The line didn't cross any face you can see (Tool options: cut right through)");
}
clearElements();
handMade(*o);
touched(id);
if (!knifeThrough_ || knifeAfter_ == 0) {
elem_ = Elem::Edge, selEdges_ = made;
return say(std::format("Cut: {} new edge{} (selected)", made.size(), made.size() == 1 ? "" : "s"));
}
auto pieces = splitAlong(o->mesh, made, knifeFill_);
if (pieces.size() < 2) {
elem_ = Elem::Edge, selEdges_ = made;
return say("Cut, but not all the way round, so it's still one piece");
}
auto size = [&](const std::set<int>& f) {
if (knifeFill_) return volumeOf(o->mesh, f);
const Mesh p = subMesh(o->mesh, f);
const glm::vec3 d = boundsMax(p) - boundsMin(p);
return d.x * d.y * d.z + 1e-9f * float(f.size());
};
std::sort(pieces.begin(), pieces.end(), [&](const std::set<int>& a, const std::set<int>& b) { return size(a) > size(b); });
const size_t n = pieces.size();
if (knifeAfter_ == 1) {
selFaces_ = pieces[1], elem_ = Elem::Face;
return say(std::format("Cut into {} pieces (the smaller selected: W moves it, P makes it its own object, Del deletes it)", n));
}
std::set<int> others;
for (size_t i = 1; i < n; ++i) others.insert(pieces[i].begin(), pieces[i].end());
std::vector<Mesh> parts;
if (knifeAfter_ == 2)
for (size_t i = 1; i < n; ++i) parts.push_back(subMesh(o->mesh, pieces[i]));
deleteFaces(o->mesh, others);
removeUnused(o->mesh);
const std::string base = o->name;
for (Mesh& part : parts) objectLike(id, std::move(part), base + " piece");
touched(id);
say(knifeAfter_ == 2 ? std::format("Cut into {} objects (see Layers)", n) : "Cut off: the smaller part is gone");
}
// The outline, `across` wide in the object's own size (a rectangle keeps its proportions).
auto App::holeShapeOutline(const Object& o, int face, const glm::vec3& centre, float across) const -> std::vector<glm::vec3> {
const float tall = across * holeTall_ / std::max(holeSize_, 1e-6f);
return holeOutline(o.mesh, face, centre, HoleShape(holeShape_), across, tall, holeSides_, holeTurn_);
}
auto App::applyHole(uint32_t id, int face, const glm::vec3& centre, float across) -> void {
Object* o = find(id);
if (!o || face < 0 || face >= int(o->mesh.faces.size()) || across <= 0) return;
const float scale = objectScale(*o);
static const char* names[] = {"A round", "A rectangular", "A triangular", "A hexagonal"};
const auto outline = holeShapeOutline(*o, face, centre, across);
pushUndo();
holeTall_ *= across * scale / std::max(holeSize_, 1e-6f), holeSize_ = across * scale;
if (holeMode_ == 2) { // just the outline: a new face, picked to extrude, bevel or paint
const int made = imprintOutline(o->mesh, face, outline);
if (made < 0) return dropUndo(), say("The outline has to fit inside the face");
changedShape(*o);
selected_ = {id}, active_ = id;
if (mode_ != Mode::Edit) enterEdit();
elem_ = Elem::Face, selFaces_ = {made};
return say(std::format("{} outline cut, {:.1f} mm across: its new face is picked (Extrude, Bevel, or paint it)", names[holeShape_], holeSize_ * 1000));
}
if (!cutHole(o->mesh, face, outline, holeMode_ == 0 ? 0.0f : holeDepth_ / scale)) {
dropUndo();
return say(holeMode_ == 0 ? "The hole has to fit inside the face, and inside the face behind it (it goes through)" : "The hole has to fit inside the face");
}
changedShape(*o);
say(std::format("{} hole, {:.1f} mm across{}", names[holeShape_], holeSize_ * 1000,
holeMode_ == 0 ? ", all the way through" : std::format(", {:.1f} mm deep", holeDepth_ * 1000)));
}
// The hole tool's outline under the mouse (or being dragged out), in the world.
auto App::holePreview(const ImGuiIO& io) -> std::vector<glm::vec3> {
uint32_t id = hole_.object;
int face = hole_.face;
glm::vec3 centre = hole_.centre;
float across = holeAcrossFor(hole_.radius);
if (!hole_.active) {
const Ray ray = mouseRay(io.MousePos);
const Hit h = pickObject(ray, mode_ == Mode::Edit ? active_ : 0, true);
if (!h.object) return {};
const Object* o = find(h.object);
id = h.object, face = h.face;
centre = glm::vec3(glm::inverse(objectMatrix(*o)) * glm::vec4(ray.origin + ray.dir * h.t, 1.0f));
across = holeSize_ / objectScale(*o);
}
const Object* o = find(id);
if (!o || face < 0 || face >= int(o->mesh.faces.size())) return {};
const glm::mat4 m = objectMatrix(*o);
std::vector<glm::vec3> out;
for (const glm::vec3& p : holeShapeOutline(*o, face, centre, across)) out.push_back(glm::vec3(m * glm::vec4(p, 1.0f)));
return out;
}
// Where the mouse is on a face's plane (the object's own space).
auto App::onFacePlane(const Object& o, int face, ImVec2 mouse) -> std::optional<glm::vec3> {
const glm::mat4 inv = glm::inverse(objectMatrix(o));
const Ray r = mouseRay(mouse);
const Ray local{glm::vec3(inv * glm::vec4(r.origin, 1.0f)), glm::normalize(glm::vec3(inv * glm::vec4(r.dir, 0.0f)))};
return hitPlane(local, faceCenter(o.mesh, face), faceNormal(o.mesh, face));
}
// ───────────────────────────── combining, copies, thickness ─────────────────────────────
// The last picked object with the others added, taken out of it, or only where they overlap
// kept; the others go.
auto App::combineSelected(Combine op) -> void {
if (mode_ == Mode::Edit) leaveEdit();
Object* a = find(active_);
if (!a || selected_.size() < 2) return say("Pick two or more objects: the last picked is kept, the others are added to it / taken out of it");
pushUndo();
Mesh result = a->hasFinish() ? finishedMesh(*a) : a->mesh;
const glm::mat4 into = glm::inverse(objectMatrix(*a));
int used = 0;
for (const Object& b : scene_.objects) {
if (b.id == a->id || !selected_.count(b.id)) continue;
Mesh m = b.hasFinish() ? finishedMesh(b) : b.mesh;
transform(m, into * objectMatrix(b));
if (!combine(result, m, op)) {
takeBack();
return say(op == Combine::Overlap ? "They don't overlap: nothing would be left" : "That didn't leave anything");
}
++used;
}
a = find(active_);
a->mesh = std::move(result);
a->mirror = 0, a->round = 0, a->smoothLevels = 0;
const std::set<uint32_t> others = [&] {
std::set<uint32_t> s = selected_;
s.erase(a->id);
return s;
}();
changedShape(*a);
std::erase_if(scene_.objects, [&](const Object& o) { return others.count(o.id) > 0; });
selected_ = {active_};
static const char* done[] = {"added to it", "taken out of it", "overlapping kept"};
say(std::format("{}: {} other{} {} ({} faces)", find(active_)->name, used, used == 1 ? "" : "s", done[int(op)], find(active_)->mesh.faces.size()));
}
auto App::makeCopies() -> void {
if (mode_ == Mode::Edit) leaveEdit();
if (selected_.empty()) return say("Pick what to copy first");
const CopiesSetting& c = copies_;
// Along a path: the last one picked is the path (its outline, or its line), the others go along it.
std::vector<fry::PathPoint> along;
int pathSkip = 0;
if (c.path) {
const Object* path = find(active_);
if (!path || selected_.size() < 2) return say("Pick what to copy, then (Shift) the path last: a drawn shape, a line, a ring...");
bool loop = false;
Mesh m = path->mesh;
transform(m, objectMatrix(*path));
const std::vector<glm::vec3> line = fry::pathOf(m, loop);
along = fry::alongPath(line, loop, c.count);
if (along.empty()) return say("The path has no length to go along");
// (one already on the path's start: that's the first, the copies after it)
const float near = glm::distance(boundsMin(m), boundsMax(m)) * 1e-3f + 1e-6f;
for (uint32_t id : selected_)
if (const Object* o = find(id); o && id != active_ && glm::distance(o->position, along[0].at) < near) {
along = fry::alongPath(line, loop, c.count + 1);
pathSkip = 1;
break;
}
}
pushUndo();
std::set<uint32_t> made = selected_;
const std::vector<Object> originals = [&] {
std::vector<Object> v;
for (const Object& o : scene_.objects)
if (selected_.count(o.id) && !(c.path && o.id == active_)) v.push_back(o);
return v;
}();
const glm::vec3 axis = c.axis == 0 ? glm::vec3(1, 0, 0) : c.axis == 1 ? glm::vec3(0, 1, 0) : glm::vec3(0, 0, 1);
for (const Object& o : originals) {
Object* joinInto = c.joined ? find(o.id) : nullptr;
for (int k = c.path ? pathSkip : 1; k <= (c.path ? int(along.size()) - 1 : c.count); ++k) {
glm::mat4 T(1.0f);
if (c.path) { // (its middle onto the point; turned as the path turns from its start)
const glm::quat turn = c.follow ? glm::rotation(along[0].along, along[size_t(k)].along) : glm::quat(1, 0, 0, 0);
T = glm::translate(glm::mat4(1.0f), along[size_t(k)].at) * glm::mat4_cast(turn) * glm::translate(glm::mat4(1.0f), -o.position);
} else if (c.round) {
const float a = glm::radians(c.angle) * float(k) / float(c.angle >= 359.99f ? c.count + 1 : c.count);
T = glm::translate(glm::mat4(1.0f), c.centre) * glm::rotate(glm::mat4(1.0f), a, axis) * glm::translate(glm::mat4(1.0f), -c.centre);
} else
T = glm::translate(glm::mat4(1.0f), c.step * float(k));
if (joinInto) { // (into the original's own shape)
Mesh m = o.mesh;
transform(m, glm::inverse(objectMatrix(o)) * T * objectMatrix(o));
append(joinInto->mesh, m);
continue;
}
Object& n = addObject(o.name, o.mesh);
const uint32_t id = n.id;
const std::string name = n.name;
n = o, n.id = id, n.name = name, n.locked = false;
n.position = glm::vec3(T * glm::vec4(o.position, 1.0f));
if (c.round || c.path) n.rotation = glm::normalize(glm::quat_cast(glm::mat3(T)) * o.rotation);
made.insert(id);
}
if (joinInto) changedShape(*joinInto);
}
selected_ = made;
touchAll();
if (c.path) selected_.erase(active_), active_ = selected_.empty() ? 0 : *selected_.rbegin(); // (the path isn't one of them)
const size_t n = c.path ? along.size() - size_t(pathSkip) : size_t(c.count);
say(std::format("{} cop{} of each made{}{}", n, n == 1 ? "y" : "ies", c.path ? " along the path" : "", c.joined ? ", joined into it" : ""));
}
auto App::copiesWindow() -> void {
if (!showCopies_) return;
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
if (!ImGui::Begin("Make copies", &showCopies_, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End();
CopiesSetting& c = copies_;
const float w = 280;
ImGui::SetNextItemWidth(w);
ImGui::SliderInt("##count", &c.count, 1, 100, "%d copies");
int kind = c.path ? 2 : c.round ? 1 : 0;
ImGui::SetNextItemWidth(w);
if (ImGui::Combo("##kind", &kind, "In a row\0Round in a circle\0Along a path\0")) c.round = kind == 1, c.path = kind == 2;
if (c.path) {
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + w);
ImGui::TextDisabled("Pick what to copy, then the path last (Shift+click): a drawn shape's outline, a line, a ring. The copies are spread evenly along it.");
ImGui::PopTextWrapPos();
ImGui::Checkbox("Turn them as the path turns", &c.follow);
if (const Object* p = selected_.size() >= 2 ? find(active_) : nullptr) ImGui::TextColored(accentText(), "Path: %s", p->name.c_str());
else ImGui::TextColored(roleColor(keel::ui::Role::DangerText), "No path picked yet");
} else if (!c.round) {
glm::vec3 mm = c.step * 1000.0f;
ImGui::SetNextItemWidth(w);
if (ImGui::DragFloat3("##step", &mm.x, 1.0f, -1e6f, 1e6f, "%.1f mm")) c.step = mm / 1000.0f;
ImGui::TextDisabled("each one this far on from the last (X Y Z)");
} else {
ImGui::SetNextItemWidth(w);
ImGui::Combo("##axis", &c.axis, "Round the X axis\0Round the Y axis\0Round the Z axis (up)\0");
ImGui::SetNextItemWidth(w);
ImGui::SliderFloat("##angle", &c.angle, 1.0f, 360.0f, "over %.0f degrees");
glm::vec3 mm = c.centre * 1000.0f;
ImGui::SetNextItemWidth(w);
if (ImGui::DragFloat3("##centre", &mm.x, 1.0f, -1e6f, 1e6f, "%.1f mm")) c.centre = mm / 1000.0f;
ImGui::TextDisabled("round this point (X Y Z)");
if (ImGui::Button("Round the picked one's middle", ImVec2(w, 0)))
if (const Object* o = find(active_)) c.centre = o->position;
}
ImGui::Checkbox("Joined into one object", &c.joined);
ImGui::Spacing();
pushAccent();
if (ImGui::Button("Make them", ImVec2(w, 0))) makeCopies(), showCopies_ = false;
popAccent();
ImGui::End();
}
auto App::thickenActive() -> void {
Object* o = mode_ == Mode::Edit ? editing() : find(active_);
if (!o || selected_.empty()) return say("Pick the object to make thick first");
pushUndo();
if (o->hasFinish()) o->mesh = finishedMesh(*o), o->mirror = 0, o->round = 0, o->smoothLevels = 0;
const float t = thickness_ / objectScale(*o);
thicken(o->mesh, thicknessOut_ ? -t : t);
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.
auto App::importFile(const std::string& file) -> void {
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();
}
auto App::spinWindow() -> void {
if (!showSpin_) return;
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
if (!ImGui::Begin("Spin", &showSpin_, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End();
const float w = 300;
ImGui::PushTextWrapPos(w);
ImGui::TextUnformatted("The picked shape turned round an axis: draw half a vase's outline beside it, spin it all the way round. Its open edges become the walls.");
ImGui::PopTextWrapPos();
ImGui::SetNextItemWidth(w);
ImGui::Combo("##axis", &spinAxis_, "Round X (across)\0Round Y (deep)\0Round Z (up)\0");
ImGui::SetNextItemWidth(w);
int where = spinWorld_;
if (ImGui::Combo("##where", &where, "Through the shape's own origin\0Through the middle of the world\0")) spinWorld_ = where;
ImGui::SetNextItemWidth(w);
ImGui::DragFloat("##deg", &spinDegrees_, 1.0f, -3600, 3600, "%.0f degrees");
ImGui::SetNextItemWidth(w);
ImGui::DragInt("##steps", &spinSteps_, 0.5f, 3, 512, "%d steps");
float mm = spinRise_ * 1000;
ImGui::SetNextItemWidth(w);
if (ImGui::DragFloat("##rise", &mm, 0.5f, -1e5f, 1e5f, mm == 0 ? "no rise (a spin)" : "rises %.1f mm (a screw)")) spinRise_ = mm / 1000;
ImGui::Checkbox("Keep the outline too", &spinKeep_);
pushAccent();
if (ImGui::Button("Spin it", ImVec2(w, 0))) spinSelected();
popAccent();
ImGui::End();
}
auto App::spinSelected() -> void {
Object* o = find(active_);
if (!o || !selected_.count(o->id)) return say("Pick the shape to spin first");
if (o->locked) return say(o->name + " is locked");
if (checkForPrint(shownMesh(*o)).openEdges == 0)
return say("Spin turns an outline or an open surface (a drawn shape, half a vase's side): this one is closed all round");
if (mode_ == Mode::Edit) leaveEdit();
pushUndo();
Mesh world = shownMesh(*o);
transform(world, objectMatrix(*o));
glm::vec3 axis(0.0f);
axis[spinAxis_] = 1.0f;
Mesh m = fry::spin(world, spinWorld_ ? glm::vec3(0.0f) : o->position, axis, spinDegrees_, spinSteps_, spinRise_);
if (m.faces.empty()) return takeBack(), say("Nothing to spin");
transform(m, glm::translate(glm::mat4(1.0f), -o->position));
Object& made = spinKeep_ ? addObject(o->name + " spun", Mesh{}) : *o;
if (spinKeep_) made.position = find(active_)->position, made.color = find(active_)->color;
made.mesh = std::move(m), made.rotation = glm::quat(1, 0, 0, 0), made.scale = glm::vec3(1.0f);
made.mirror = 0, made.round = 0, made.smoothLevels = 0; // (spun as it showed: its finish is in the shape now)
handMade(made), touched(made.id);
selected_ = {made.id}, active_ = made.id;
say(std::format("Spun {:.0f} degrees{}: {} faces", spinDegrees_, spinRise_ != 0 ? std::format(", rising {:.1f} mm", spinRise_ * 1000) : "", made.mesh.faces.size()));
}
auto App::textWindow() -> void {
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();
}
auto App::addText() -> void {
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()));
}
auto App::thickenWindow() -> void {
if (!showThicken_) return;
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
if (!ImGui::Begin("Make it thick", &showThicken_, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End();
const float w = 260;
float mm = thickness_ * 1000;
ImGui::SetNextItemWidth(w);
if (ImGui::DragFloat("##t", &mm, 0.1f, 0.1f, 1e5f, "%.2f mm thick")) thickness_ = mm / 1000;
int out = thicknessOut_ ? 1 : 0;
ImGui::SetNextItemWidth(w);
if (ImGui::Combo("##dir", &out, "Inwards (a closed shape: hollow)\0Outwards\0")) thicknessOut_ = out == 1;
ImGui::TextWrapped("A flat or open surface gets a back and edges: a solid you can print. A closed shape becomes hollow, walls this thick (to save material or make a box).");
pushAccent();
if (ImGui::Button("Make it thick", ImVec2(w, 0))) thickenActive(), showThicken_ = false;
popAccent();
ImGui::End();
}
auto App::cleanFlatFaces() -> void {
Object* o = mode_ == Mode::Edit ? editing() : find(active_);
if (!o || selected_.empty()) return say("Pick an object first");
pushUndo();
const int n = mergeFlatFaces(o->mesh);
changedShape(*o);
say(n ? std::format("{} faces joined into the flat ones beside them", n) : "Nothing to join: no flat faces side by side");
}
// ───────────────────────────── drawing a shape ─────────────────────────────
// The plane shapes are drawn on: the one the view looks straight at, or the ground.
auto App::workPlane(glm::vec3& point, glm::vec3& normal) const -> void {
point = glm::vec3(0.0f);
normal = view_ == View::Front || view_ == View::Back ? glm::vec3(1, 0, 0) : view_ == View::Left || view_ == View::Right ? glm::vec3(0, 1, 0) : glm::vec3(0, 0, 1);
}
// The plane drawn on: the face under the first point if it was on an object, else the view's.
auto App::drawPlane(glm::vec3& p, glm::vec3& n) -> void {
if (drawOnto_ && find(drawOnto_)) p = drawOnP_, n = drawOnN_;
else workPlane(p, n);
}
auto App::drawPoint(const ImGuiIO& io) -> std::optional<glm::vec3> {
const Ray ray = mouseRay(io.MousePos);
if (drawPts_.empty()) { // (the first point: on an object, its face is where it's drawn)
drawOnto_ = 0;
const Hit h = pickObject(ray, 0, true);
if (const Object* o = h.object ? find(h.object) : nullptr) {
const glm::mat4 m = objectMatrix(*o);
drawOnto_ = o->id;
drawOnP_ = ray.origin + ray.dir * h.t;
drawOnN_ = glm::normalize(glm::transpose(glm::inverse(glm::mat3(m))) * faceNormal(o->mesh, h.face));
}
}
glm::vec3 p, n;
drawPlane(p, n);
auto hit = hitPlane(ray, p, n);
if (!hit) return std::nullopt;
if (snap_ != io.KeyCtrl) { // (on the grid, then back onto the plane)
const float step = gridStep() / 2.0f;
*hit = glm::vec3(snapTo(hit->x, step), snapTo(hit->y, step), snapTo(hit->z, step));
*hit -= n * glm::dot(*hit - p, n);
}
// Exact sizes: Shift holds the line to 15 degree steps; a typed number is its length (m).
if (!drawPts_.empty() && (io.KeyShift || !sketchTyped_.empty())) {
const glm::vec3 last = drawPts_.back(), u = glm::normalize(std::abs(n.z) < 0.9f ? glm::cross(glm::vec3(0, 0, 1), n) : glm::cross(glm::vec3(1, 0, 0), n)), v = glm::cross(n, u);
glm::vec3 d = *hit - last;
float len = glm::length(d);
float a = std::atan2(glm::dot(d, v), glm::dot(d, u));
if (io.KeyShift) a = std::round(a / glm::radians(15.0f)) * glm::radians(15.0f);
if (!sketchTyped_.empty() && sketchTyped_ != "." && sketchTyped_ != "-") len = std::abs(std::strtof(sketchTyped_.c_str(), nullptr));
*hit = last + (u * std::cos(a) + v * std::sin(a)) * len;
}
return hit;
}
// (drawing a shape: the next line's length, typed)
// Drawing a shape: numbers typed go to the next line's length, Enter puts the point there.
auto App::sketchKeys(const ImGuiIO& io) -> bool {
bool used = false;
for (ImWchar c : io.InputQueueCharacters)
if ((c >= '0' && c <= '9') || (c == '.' && sketchTyped_.find('.') == std::string::npos)) sketchTyped_ += char(c), used = true;
if (ImGui::IsKeyPressed(ImGuiKey_Backspace) && !sketchTyped_.empty()) sketchTyped_.pop_back(), used = true;
if (!sketchTyped_.empty() && (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter))) {
if (auto p = drawPoint(io)) drawPts_.push_back(*p);
sketchTyped_.clear(), used = true;
}
return used;
}
auto App::finishShape() -> void {
sketchTyped_.clear();
if (drawPts_.size() < 3) {
drawPts_.clear();
return say("A shape needs 3 points or more");
}
glm::vec3 p, n;
drawPlane(p, n);
const uint32_t onto = drawOnto_ && find(drawOnto_) ? drawOnto_ : 0;
drawOnto_ = 0;
glm::vec3 c(0.0f);
for (const glm::vec3& q : drawPts_) c += q;
c /= float(drawPts_.size());
std::vector<glm::vec3> local;
for (const glm::vec3& q : drawPts_) local.push_back(q - c);
// On an object it stands on the face (a hair into it, so they join), or goes down into it.
const float t = drawThickness_, e = 0.0001f;
float thick = t;
if (onto && drawOnMode_ == 2) thick = t + 2 * e, c += n * (e - thick / 2);
else if (onto) thick = t + e, c += n * (t - thick / 2);
Mesh m = extrudeOutline(local, n, thick);
drawPts_.clear();
if (m.faces.empty()) return say("That shape doesn't work (points on top of each other?)");
if (onto && drawOnMode_ > 0 && find(onto)->locked) return say("That object is locked");
pushUndo();
if (onto && drawOnMode_ > 0) {
Object* o = find(onto);
if (o->hasFinish()) o->mesh = finishedMesh(*o), o->mirror = 0, o->round = 0, o->smoothLevels = 0;
Mesh shape = m;
transform(shape, glm::inverse(objectMatrix(*o)) * glm::translate(glm::mat4(1.0f), c));
Mesh result = o->mesh;
if (!combine(result, shape, drawOnMode_ == 2 ? Combine::Subtract : Combine::Add)) {
takeBack();
return say("That would leave nothing");
}
o = find(onto);
o->mesh = std::move(result);
changedShape(*o);
selected_ = {o->id}, active_ = o->id;
return say(std::format("{} {:.0f} mm {} {}", drawOnMode_ == 2 ? "Cut" : "Added", t * 1000, drawOnMode_ == 2 ? "deep into" : "high onto", o->name));
}
Object& o = addObject("Shape", std::move(m));
o.position = c;
selected_ = {o.id}, active_ = o.id;
say(std::format("Shape made, {:.0f} mm thick{}. Tab edits its shape; Thickness is in Tool options.", t * 1000, onto ? ", standing on " + find(onto)->name : ""));
}
// ───────────────────────────── the viewport's mouse ─────────────────────────────
auto App::viewport(ImGuiIO& io) -> void {
cutHover_ = {-1, -1};
if (slide_.active) return slideFrame(io);
if (tab_ == Tab::Paint) return paintViewport(io);
if (tab_ == Tab::Layout) return layoutViewport(io);
if ((tab_ == Tab::Print && printTool_ != PrintTool::None) || printTool_ == PrintTool::Measure) return printViewport(io); // (measuring: the Model tab too)
if (knifeDown_ && !ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
knifeDown_ = false;
const ImVec2 end = knifeEnd(io);
if (knifePts_.empty() && std::hypot(end.x - knifePress_.x, end.y - knifePress_.y) > 8) {
if (Object* o = editing()) knifeApply(o->id, {knifePress_, end});
} else
knifePts_.push_back(end);
return;
}
if (hole_.active) {
Object* o = find(hole_.object);
if (!o) hole_.active = false;
else if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
if (auto p = onFacePlane(*o, hole_.face, io.MousePos)) hole_.radius = glm::distance(*p, hole_.centre);
message_ = std::format("{:.1f} mm across", holeAcrossFor(hole_.radius) * objectScale(*o) * 1000), messageAt_ = now();
} else {
hole_.active = false;
const bool dragged = std::hypot(io.MousePos.x - hole_.press.x, io.MousePos.y - hole_.press.y) > 4;
applyHole(hole_.object, hole_.face, hole_.centre, dragged ? holeAcrossFor(hole_.radius) : holeSize_ / objectScale(*o));
}
return;
}
if (drag_.active) {
if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) updateDrag(io);
else {
drag_.active = false;
if (drag_.tool == Tool::Bevel) clearElements(); // (renumbered)
}
return;
}
if (boxing_) {
const bool moved = std::hypot(io.MousePos.x - boxStart_.x, io.MousePos.y - boxStart_.y) > 6;
if (touchOrbit_ && moved && ImGui::IsMouseDown(ImGuiMouseButton_Left)) orbitBy(io.MouseDelta);
if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
boxing_ = false;
if (std::exchange(touchOrbit_, false) && moved) return void(tapDraw_ = false);
if (std::exchange(tapDraw_, false)) return addDrawPoint(io);
if (std::hypot(io.MousePos.x - boxStart_.x, io.MousePos.y - boxStart_.y) > 4) {
const ImVec2 a(std::min(boxStart_.x, io.MousePos.x), std::min(boxStart_.y, io.MousePos.y)), b(std::max(boxStart_.x, io.MousePos.x), std::max(boxStart_.y, io.MousePos.y));
regionSelect([&](ImVec2 s) { return s.x >= a.x && s.x <= b.x && s.y >= a.y && s.y <= b.y; }, io.KeyShift, io.KeyCtrl, !io.KeyShift && !io.KeyCtrl);
} else
clickSelect(io);
}
return;
}
if (lassoing_) {
if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
if (std::hypot(io.MousePos.x - lasso_.back().x, io.MousePos.y - lasso_.back().y) > 3) lasso_.push_back(io.MousePos);
} else {
lassoing_ = false;
if (lasso_.size() > 4) regionSelect([&](ImVec2 s) { return insidePolygon(s, lasso_); }, io.KeyShift, io.KeyCtrl, !io.KeyShift && !io.KeyCtrl);
else clickSelect(io);
}
return;
}
if (painting_) {
if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) painting_ = false;
else paint(io);
return;
}
if (!mouseInView(io)) return;
if (!pluginTool_.first.empty() && mode_ == Mode::Object) {
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && hoverHandle(io.MousePos) < 0) clickPluginTool(io.MousePos);
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) || !pluginToolFound()) return;
}
if (tool_ == Tool::Draw) {
if (!ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return;
if (touch()) return void((boxing_ = touchOrbit_ = tapDraw_ = true, boxStart_ = io.MousePos)); // (a finger: a tap puts a point down when it lifts, a drag turns the view)
addDrawPoint(io);
return;
}
if (tool_ == Tool::Knife) {
if (!editing()) return;
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) && knifePts_.size() >= 2) {
std::vector<ImVec2> pts = knifePts_;
knifePts_.clear();
return knifeApply(active_, pts);
}
knifeDown_ = true, knifePress_ = io.MousePos;
}
return;
}
if (tool_ == Tool::Hole) {
if (!ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return;
const Ray ray = mouseRay(io.MousePos);
const Hit h = pickObject(ray, mode_ == Mode::Edit ? active_ : 0, true);
if (!h.object) return;
Object* o = find(h.object);
if (o->locked) return say("That object is locked");
if (mode_ == Mode::Object) selected_ = {h.object}, active_ = h.object;
hole_ = {true, h.object, h.face, glm::vec3(glm::inverse(objectMatrix(*o)) * glm::vec4(ray.origin + ray.dir * h.t, 1.0f)), 0.0f, io.MousePos};
return;
}
if (tool_ == Tool::LoopCut) {
if (Object* o = editing()) {
cutHover_ = nearestEdge(*o, io.MousePos, 30.0f);
if (cutHover_.first >= 0 && ringQuads(o->mesh, cutHover_).empty()) cutHover_ = {-1, -1};
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && cutHover_.first >= 0) cutAt(*o, cutHover_);
}
return;
}
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
if (const int h = hoverHandle(io.MousePos); h >= 0) return startDrag(io, h);
if (mode_ == Mode::Edit && (io.KeyAlt || ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left))) return loopSelect(io);
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) && mode_ == Mode::Object) { // double click: edit its shape
const Hit hit = pickObject(mouseRay(io.MousePos));
if (hit.object) {
selected_ = {hit.object}, active_ = hit.object;
enterEdit();
return;
}
}
if (selShape_ == SelShape::Brush) {
painting_ = true;
if (!io.KeyShift && !io.KeyCtrl) selectNone();
paint(io);
} else if (selShape_ == SelShape::Lasso) lassoing_ = true, lasso_ = {io.MousePos};
else boxing_ = true, boxStart_ = io.MousePos, touchOrbit_ = touch() && tool_ != Tool::Select; // (a finger: dragging turns the view, but Select's box)
}
}
auto App::addDrawPoint(const ImGuiIO& io) -> void {
if (auto p = drawPoint(io)) {
ImVec2 s0, s1;
if (drawPts_.size() >= 3 && project(drawPts_[0], s0) && project(*p, s1) && std::hypot(s0.x - s1.x, s0.y - s1.y) < (touch() ? 24 : 10)) finishShape();
else drawPts_.push_back(*p);
}
}
// Painting: on the model under the mouse.
auto App::paintViewport(ImGuiIO& io) -> void {
if (sculpting_) return sculptViewport(io);
if (drawingStencil_) { // (drawing the stencil: a drag round the area)
if (ImGui::IsMouseDown(ImGuiMouseButton_Left) && mouseInView(io)) {
if (stencil_.empty() || std::hypot(stencil_.back().x - io.MousePos.x, stencil_.back().y - io.MousePos.y) > 3) stencil_.push_back(io.MousePos);
} else if (!stencil_.empty()) {
drawingStencil_ = false, stencilOn_ = stencil_.size() >= 3;
if (!stencilOn_) stencil_.clear();
say(stencilOn_ ? "Stencil on: paint goes only inside it" : "Too small for a stencil: drag round an area");
}
return;
}
if (paint_.waiting) {
if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) paint_.waiting = false;
else if (pickObject(mouseRay(io.MousePos), 0, true).object) paint_.waiting = false, paintAt(io.MousePos, true);
return;
}
if (paint_.active) {
if (paint_.in2D) return;
if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) return endStroke();
return paintAt(io.MousePos, false);
}
if (PictureLayer* l = editLayer()) { // (a picture being moved: on the model if it's a sticker)
if (l->sticker) stickerMouse(io);
return;
}
if (!mouseInView(io) || !ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return;
const Ray ray = mouseRay(io.MousePos);
const Hit h = pickObject(ray, 0, true);
if (!h.object) {
paint_.waiting = paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Eraser;
return;
}
const bool picking = paintTool_ == PaintTool::Picker || io.KeyAlt;
if (paintTool_ == PaintTool::Picture) {
selected_ = {h.object}, active_ = h.object;
return;
}
if (picking) {
const Object* o = find(h.object);
if (o->picture) {
if (auto uv = uvUnder(*o, ray)) pickColor(*o, *uv);
} else
paintColor_ = glm::vec4(o->color, paintColor_.a);
return;
}
if (paintTool_ == PaintTool::Fill) {
selected_ = {h.object}, active_ = h.object;
Object* o = readyToPaint(h.object);
if (!o) return;
if (auto uv = uvUnder(*o, ray)) fillAt(*o, *uv);
return;
}
paintAt(io.MousePos, true);
}
// The brush: whatever passes under it is selected (or, with Ctrl, let go).
auto App::paint(const ImGuiIO& io) -> void {
if (mode_ == Mode::Object) {
const Hit h = pickObject(mouseRay(io.MousePos));
if (h.object) io.KeyCtrl ? (void)selected_.erase(h.object) : (void)(selected_.insert(h.object), active_ = h.object);
return;
}
const ImVec2 m = io.MousePos;
const float r = brushRadius_;
regionSelect([&](ImVec2 s) { return std::hypot(s.x - m.x, s.y - m.y) < r; }, true, io.KeyCtrl, false);
}
} // namespace fryapp

757
src/app_paint.cpp Normal file
View file

@ -0,0 +1,757 @@
// Fry3DX: painting and sculpting, picture layers, and the Layout tab.
#include "app.h"
using namespace fry;
namespace stdfs = std::filesystem;
namespace fryapp {
// ───────────────────────────── painting ─────────────────────────────
// The object ready to be painted: its rounding and smoothing made part of it, its surface laid
// out on a picture (made the plain colour of it at first).
auto App::readyToPaint(uint32_t id) -> Object* {
Object* o = find(id);
if (!o) return nullptr;
if (o->locked) return say("That object is locked"), nullptr;
if (o->picture && o->uvsFit() && !o->hasFinish()) return o;
pushUndo();
if (o->hasFinish()) {
o->mesh = finishedMesh(*o);
o->mirror = 0, o->round = 0, o->smoothLevels = 0;
handMade(*o);
}
o->uvs = autoUnwrap(o->mesh);
if (!o->picture) o->picture = std::make_shared<Image>(makeImage(pictureSize_, pictureSize_, glm::vec4(o->color, 1.0f)));
touched(o->id);
picZoom_ = 0;
say(std::format("{} is ready to paint: its surface is laid out flat on a {}x{} picture", o->name, o->picture->w, o->picture->h));
return o;
}
// The object's picture, copied so this change can be undone; the copy is now the object's.
auto App::changePicture(Object& o) -> Image& {
pushUndo();
auto img = std::make_shared<Image>(*o.picture);
o.picture = img;
paint_.image = img;
uploaded_[o.id].dirty = true;
return *img;
}
auto App::beginStroke(Object& o, bool in2D) -> void {
Image& img = changePicture(o);
paint_.active = true, paint_.in2D = in2D, paint_.haveLast = false, paint_.object = o.id;
paint_.stroke.begin(img, paintTool_ == PaintTool::Eraser ? glm::vec4(o.color, 1.0f) : paintColor_);
}
auto App::endStroke() -> void {
if (!paint_.active) return;
paint_.active = false;
uploaded_[paint_.object].dirty = true;
paint_.image.reset();
paint_.stroke = {};
}
// Where on its picture a ray hits an object (0..1), or nothing.
auto App::uvUnder(const Object& o, const Ray& ray) -> std::optional<glm::vec2> {
if (!o.uvsFit()) return std::nullopt;
const glm::mat4 m = objectMatrix(o);
float best = 1e30f;
std::optional<glm::vec2> out;
for (size_t f = 0; f < o.mesh.faces.size(); ++f) {
const auto& fv = o.mesh.faces[f];
for (const auto& t : triangulate(o.mesh, int(f))) {
glm::vec3 w[3];
glm::vec2 uv[3];
for (int k = 0; k < 3; ++k) {
w[k] = glm::vec3(m * glm::vec4(o.mesh.verts[size_t(t[size_t(k)])], 1.0f));
uv[k] = o.uvs[f][size_t(std::find(fv.begin(), fv.end(), t[size_t(k)]) - fv.begin())];
}
const glm::vec3 e1 = w[1] - w[0], e2 = w[2] - w[0], p = glm::cross(ray.dir, e2);
const float det = glm::dot(e1, p);
if (std::abs(det) < 1e-12f) continue;
const glm::vec3 sv = ray.origin - w[0], q = glm::cross(sv, e1);
const float u = glm::dot(sv, p) / det, v = glm::dot(ray.dir, q) / det, tt = glm::dot(e2, q) / det;
if (u < 0 || v < 0 || u + v > 1 || tt <= 0 || tt >= best) continue;
best = tt, out = uv[0] * (1 - u - v) + uv[1] * u + uv[2] * v;
}
}
return out;
}
// One dab of the brush on the model: on every face near it, into the picture where that face
// lies (so it carries on across the seams between pieces of the layout).
// One dab of paint at a point of the object (the world), on the faces towards `eye` (the
// mirrored dab: towards the eye mirrored). The stencil: only where the surface shows inside
// it on the screen (the mirrored dab: where its mirror image does, `back` taking it there).
auto App::dab3D(Object& o, const glm::vec3& at, float radius, const glm::vec3* eyeAt, const glm::mat4* back) -> void {
Image& im = *paint_.image;
const glm::mat4 m = objectMatrix(o);
const glm::vec3 eye = eyeAt ? *eyeAt : eye_;
const bool stencil = stencilOn_ && stencil_.size() >= 3;
const glm::vec2 size(float(im.w), float(im.h));
const float inner = std::clamp(hardness_, 0.0f, 0.999f) * radius;
for (size_t f = 0; f < o.mesh.faces.size(); ++f) {
const auto& fv = o.mesh.faces[f];
for (const auto& t : triangulate(o.mesh, int(f))) {
glm::vec3 w[3];
glm::vec2 px[3];
for (int k = 0; k < 3; ++k) {
w[k] = glm::vec3(m * glm::vec4(o.mesh.verts[size_t(t[size_t(k)])], 1.0f));
px[k] = o.uvs[f][size_t(std::find(fv.begin(), fv.end(), t[size_t(k)]) - fv.begin())] * size;
}
const glm::vec3 lo = glm::min(w[0], glm::min(w[1], w[2])) - radius, hi = glm::max(w[0], glm::max(w[1], w[2])) + radius;
if (glm::any(glm::lessThan(at, lo)) || glm::any(glm::greaterThan(at, hi))) continue;
const glm::vec3 n = glm::cross(w[1] - w[0], w[2] - w[0]);
const float area3 = glm::length(n) * 0.5f;
if (area3 < 1e-12f || glm::dot(n, eye - w[0]) <= 0 || std::abs(glm::dot(glm::normalize(n), at - w[0])) > radius) continue;
const float areaPx = std::abs((px[1].x - px[0].x) * (px[2].y - px[0].y) - (px[2].x - px[0].x) * (px[1].y - px[0].y)) * 0.5f;
if (areaPx < 1e-6f) continue;
// The dab's middle on the picture, and how far it reaches there.
const glm::vec3 nn = glm::normalize(n), c = at - nn * glm::dot(nn, at - w[0]);
const glm::vec3 v0 = w[1] - w[0], v1 = w[2] - w[0], v2 = c - w[0];
const float d00 = glm::dot(v0, v0), d01 = glm::dot(v0, v1), d11 = glm::dot(v1, v1), d20 = glm::dot(v2, v0), d21 = glm::dot(v2, v1), den = d00 * d11 - d01 * d01;
const float bv = (d11 * d20 - d01 * d21) / den, bw = (d00 * d21 - d01 * d20) / den;
const glm::vec2 mid = px[0] * (1 - bv - bw) + px[1] * bv + px[2] * bw;
const float reach = radius * std::sqrt(areaPx / area3) * 1.5f + 2.0f;
glm::vec2 plo = glm::max(glm::min(px[0], glm::min(px[1], px[2])) - 2.0f, mid - reach), phi = glm::min(glm::max(px[0], glm::max(px[1], px[2])) + 2.0f, mid + reach);
plo = glm::max(plo, glm::vec2(0.0f)), phi = glm::min(phi, size - 1.0f);
const float twice = areaPx * 2.0f * ((px[1].x - px[0].x) * (px[2].y - px[0].y) - (px[2].x - px[0].x) * (px[1].y - px[0].y) > 0 ? 1.0f : -1.0f);
for (int y = int(plo.y); y <= int(phi.y); ++y)
for (int x = int(plo.x); x <= int(phi.x); ++x) {
const glm::vec2 q(float(x) + 0.5f, float(y) + 0.5f);
float l[3];
bool ok = true;
for (int k = 0; k < 3; ++k) {
const glm::vec2 a = px[(k + 1) % 3], b = px[(k + 2) % 3];
l[k] = ((b.x - a.x) * (q.y - a.y) - (q.x - a.x) * (b.y - a.y)) / twice;
ok &= l[k] * std::abs(twice) / std::max(glm::length(b - a), 1e-6f) >= -1.5f; // (a pixel and a half past the edge: no seams)
}
if (!ok) continue;
const glm::vec3 p = w[0] * l[0] + w[1] * l[1] + w[2] * l[2];
const float d = glm::length(p - at);
if (d >= radius) continue;
if (stencil) {
ImVec2 sp;
if (!project(back ? glm::vec3(*back * glm::vec4(p, 1.0f)) : p, sp) || !insidePolygon(sp, stencil_)) continue;
}
const float s = d <= inner ? 1.0f : 1.0f - (d - inner) / (radius - inner);
paint_.stroke.put(im, x, y, s * s * (3 - 2 * s));
}
}
}
uploaded_[o.id].dirty = true;
}
// The brush at a point of the view: starts a stroke on what's there, or carries one on.
auto App::paintAt(ImVec2 mouse, bool start) -> void {
const Ray ray = mouseRay(mouse);
if (start) {
const Hit h = pickObject(ray, 0, true);
if (!h.object) return;
selected_ = {h.object}, active_ = h.object;
Object* o = readyToPaint(h.object);
if (!o) return;
beginStroke(*o, false);
}
if (!paint_.active || paint_.in2D) return;
Object* o = find(paint_.object);
if (!o) return;
const Hit h = pickObject(ray, o->id, true);
if (!h.object) {
paint_.haveLast = false;
return;
}
const glm::vec3 at = ray.origin + ray.dir * h.t;
const float radius = brushPx_ * pixelSize(at);
const float gap = glm::distance(at, paint_.last);
// (painting both sides: the same dab mirrored across the object's own Y, its left and right)
const glm::mat4 m = objectMatrix(*o), mirror = m * glm::scale(glm::mat4(1.0f), glm::vec3(1, -1, 1)) * glm::inverse(m);
const glm::vec3 eyeMirrored = glm::vec3(mirror * glm::vec4(eye_, 1.0f));
auto dab = [&](const glm::vec3& p) {
dab3D(*o, p, radius);
if (paintMirror_) dab3D(*o, glm::vec3(mirror * glm::vec4(p, 1.0f)), radius, &eyeMirrored, &mirror);
};
if (paint_.haveLast && gap < radius * 8) {
const int steps = std::max(1, int(std::ceil(gap / (radius * 0.3f))));
for (int i = 1; i <= steps; ++i) dab(glm::mix(paint_.last, at, float(i) / float(steps)));
} else
dab(at);
paint_.last = at, paint_.haveLast = true;
}
auto App::pickColor(const Object& o, glm::vec2 uv) -> void {
const glm::vec4 c = sample(*o.picture, uv * glm::vec2(o.picture->w, o.picture->h));
paintColor_ = glm::vec4(glm::vec3(c), paintColor_.a);
}
auto App::fillAt(Object& o, glm::vec2 uv) -> void {
Image& img = changePicture(o);
floodFill(img, glm::ivec2(uv * glm::vec2(img.w, img.h)), glm::vec4(glm::vec3(paintColor_), 1.0f), fillTolerance_);
paint_.image.reset();
}
// ── Sculpting ──
// The object ready to sculpt: its finishing touches made part of it, and made finer (the same
// shape) if it has too few points to push about.
auto App::readyToSculpt(uint32_t id) -> Object* {
Object* o = find(id);
if (!o) return nullptr;
if (o->locked) return say("That object is locked"), nullptr;
pushUndo();
if (o->hasFinish()) {
o->mesh = finishedMesh(*o);
o->mirror = 0, o->round = 0, o->smoothLevels = 0;
}
handMade(*o);
if (finerForSculpting(o->mesh)) {
o->smooth = true;
say(std::format("{} was made finer to sculpt: {} points now, the same shape", o->name, o->mesh.verts.size()));
}
touched(o->id);
return o;
}
auto App::endSculpt() -> void {
if (!sculpt_.active) return;
sculpt_ = {};
}
// The brush at a point of the view: starts a stroke on what's there, or carries one on.
auto App::sculptAt(ImVec2 mouse, bool start, bool ctrl, bool shift) -> void {
const Ray ray = mouseRay(mouse);
const auto toLocal = [](const Object& o, float radius) { return radius * 3.0f / glm::max(glm::abs(o.scale.x) + glm::abs(o.scale.y) + glm::abs(o.scale.z), 1e-6f); };
const glm::vec3 other(1, -1, 1); // (the other side: the object's Y turned round)
if (start) {
const Hit h = pickObject(ray, 0, true);
if (!h.object) return;
selected_ = {h.object}, active_ = h.object;
Object* o = readyToSculpt(h.object);
if (!o) return;
sculpt_.active = true, sculpt_.object = o->id;
sculpt_.mesh = std::make_unique<SculptMesh>(o->mesh);
if (sculptBrush_ == SculptBrush::Grab && !shift) { // (made finer: the same shape, so the same spot)
sculpt_.start = ray.origin + ray.dir * h.t;
const glm::vec3 at = glm::vec3(glm::inverse(objectMatrix(*o)) * glm::vec4(sculpt_.start, 1.0f));
const float radius = toLocal(*o, sculptPx_ * pixelSize(sculpt_.start));
for (int side = 0; side < (sculptMirror_ ? 2 : 1); ++side) {
sculpt_.held[side] = grabbed(o->mesh, side ? at * other : at, radius);
for (const auto& [i, w] : sculpt_.held[side]) sculpt_.from[side].push_back(o->mesh.verts[size_t(i)]);
}
}
}
if (!sculpt_.active) return;
Object* o = find(sculpt_.object);
if (!o) return endSculpt();
const glm::mat4 inv = glm::inverse(objectMatrix(*o));
if (!sculpt_.from[0].empty()) { // grabbing: it follows the mouse across the view
const auto p = hitPlane(ray, sculpt_.start, forwardOf(yaw_, pitch_));
if (!p) return;
const glm::vec3 d = glm::vec3(inv * glm::vec4(*p - sculpt_.start, 0.0f));
moveGrabbed(o->mesh, sculpt_.held[0], sculpt_.from[0], d);
if (sculptMirror_) moveGrabbed(o->mesh, sculpt_.held[1], sculpt_.from[1], d * other);
return touched(o->id);
}
const Hit h = pickObject(ray, o->id, true);
if (!h.object) {
sculpt_.haveLast = false;
return;
}
const glm::vec3 at = ray.origin + ray.dir * h.t;
const float radius = sculptPx_ * pixelSize(at);
const SculptBrush brush = shift ? SculptBrush::Smooth : sculptBrush_;
const auto dab = [&](const glm::vec3& world) {
Dab d{glm::vec3(inv * glm::vec4(world, 1.0f)), toLocal(*o, radius), sculptStrength_, ctrl};
sculptDab(o->mesh, *sculpt_.mesh, brush, d);
if (sculptMirror_) d.at *= other, sculptDab(o->mesh, *sculpt_.mesh, brush, d);
};
// A dab every quarter of the brush along the way, not one a frame (fast strokes stay even).
const float gap = glm::distance(at, sculpt_.last);
if (!sculpt_.haveLast) dab(at);
else if (gap >= radius * 0.25f && gap < radius * 8) {
const int steps = int(gap / (radius * 0.25f));
for (int i = 1; i <= steps; ++i) dab(glm::mix(sculpt_.last, at, float(i) / float(steps)));
} else if (gap < radius * 0.25f)
return; // (not far enough for the next dab yet)
else
dab(at);
sculpt_.last = at, sculpt_.haveLast = true;
touched(o->id);
}
auto App::sculptViewport(const ImGuiIO& io) -> void {
if (sculpt_.waiting) {
if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) sculpt_.waiting = false;
else if (pickObject(mouseRay(io.MousePos), 0, true).object) sculpt_.waiting = false, sculptAt(io.MousePos, true, io.KeyCtrl, io.KeyShift);
return;
}
if (sculpt_.active) {
if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) return endSculpt();
return sculptAt(io.MousePos, false, io.KeyCtrl, io.KeyShift);
}
if (!mouseInView(io) || !ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return;
if (pickObject(mouseRay(io.MousePos), 0, true).object) sculptAt(io.MousePos, true, io.KeyCtrl, io.KeyShift);
else sculpt_.waiting = sculptBrush_ != SculptBrush::Grab;
}
// ── Picture layers ──
auto App::editLayer() -> PictureLayer* {
Object* o = find(ledit_.object);
return o && ledit_.index >= 0 && ledit_.index < int(o->layers.size()) ? &o->layers[size_t(ledit_.index)] : nullptr;
}
// A picture put on the selected object as a new layer, unlocked to be moved: a sticker on the
// surface in the middle of the view, or pinned onto the flat picture.
auto App::startFloating(Image img) -> void {
Object* o = selected_.empty() ? nullptr : readyToPaint(active_);
if (!o) return say("Select the object to put the picture on first");
if (img.empty()) return;
lockLayer();
pushUndo();
PictureLayer l;
l.name = std::format("Picture {}", o->layers.size() + 1);
l.image = std::make_shared<Image>(std::move(img));
l.sticker = pasteOnSurface_;
const float aspect = float(l.image->h) / float(std::max(l.image->w, 1));
if (l.sticker) {
const glm::mat4 m = objectMatrix(*o), inv = glm::inverse(m);
const Ray ray = mouseRay(ImVec2((viewMin_.x + viewMax_.x) * 0.5f, (viewMin_.y + viewMax_.y) * 0.5f));
const Hit h = pickObject(ray, o->id, true);
if (h.object) l.face = h.face, l.centre = glm::vec3(inv * glm::vec4(ray.origin + ray.dir * h.t, 1.0f));
else { // (the face looking most at you)
float best = -2;
const glm::vec3 toEye = glm::normalize(glm::vec3(inv * glm::vec4(-viewDir(), 0.0f)));
for (size_t f = 0; f < o->mesh.faces.size(); ++f)
if (const float d = glm::dot(faceNormal(o->mesh, int(f)), toEye); d > best) best = d, l.face = int(f), l.centre = faceCenter(o->mesh, int(f));
}
// Upright as you see it, a third as big as the object.
const glm::vec3 f = viewDir(), right = glm::normalize(glm::cross(f, std::abs(f.z) > 0.99f ? glm::vec3(std::cos(glm::radians(yaw_)), std::sin(glm::radians(yaw_)), 0) : glm::vec3(0, 0, 1)));
l.up = glm::normalize(glm::vec3(inv * glm::vec4(glm::cross(right, f), 0.0f)));
const glm::vec3 d = boundsMax(o->mesh) - boundsMin(o->mesh);
l.width = std::max({d.x, d.y, d.z}) * 0.35f, l.height = l.width * aspect;
} else {
const float W = float(o->picture->w), H = float(o->picture->h);
const float k = std::min(W * 0.6f / float(l.image->w), H * 0.6f / float(l.image->h));
const glm::vec2 half(float(l.image->w) * k * 0.5f, float(l.image->h) * k * 0.5f), c(W * 0.5f, H * 0.5f);
l.corners = {c - half, glm::vec2(c.x + half.x, c.y - half.y), c + half, glm::vec2(c.x - half.x, c.y + half.y)};
}
o->layers.push_back(std::move(l));
ledit_ = {};
ledit_.object = o->id, ledit_.index = int(o->layers.size()) - 1, ledit_.fresh = true;
paintTool_ = PaintTool::Picture;
if (tab_ != Tab::Paint) setTab(Tab::Paint);
say(pasteOnSurface_ ? "Drag it round the model (it bends over edges); corners size it (Shift: stretch), the round handle turns it. Enter locks it."
: "Drag its corners in the Picture panel to fit (Shift: keep its shape), inside to move it. Enter locks it.");
}
auto App::floatFromFile(const std::string& path) -> void {
Image img;
if (!loadImageFile(path, img)) return say("Couldn't load " + path);
startFloating(std::move(img));
}
auto App::pasteFromClipboard() -> void {
for (const char* mime : {"image/png", "image/bmp", "image/jpeg"}) {
if (!SDL_HasClipboardData(mime)) continue;
size_t n = 0;
void* data = SDL_GetClipboardData(mime, &n);
if (!data) continue;
int w = 0, h = 0, c = 0;
stbi_uc* px = stbi_load_from_memory(static_cast<const stbi_uc*>(data), int(n), &w, &h, &c, 4);
SDL_free(data);
if (!px) continue;
Image img;
img.w = w, img.h = h, img.px.assign(px, px + size_t(w) * size_t(h) * 4);
stbi_image_free(px);
return startFloating(std::move(img));
}
say("Nothing to paste: copy a picture in another app first (or use Add a picture from a file)");
}
// Enter: it stays where it is, a layer (click it in the Picture panel to move it again).
auto App::lockLayer() -> void {
if (!editLayer()) return void(ledit_ = {});
ledit_ = {};
say("Locked in place, as a layer (see Properties). Click it in the Picture panel to move it again.");
}
auto App::unlockLayer(uint32_t object, int index) -> void {
lockLayer();
ledit_.object = object, ledit_.index = index;
paintTool_ = PaintTool::Picture;
if (const PictureLayer* l = editLayer()) say(std::format("{} unlocked: {}. Enter locks it again.", l->name, l->sticker ? "drag it on the model" : "drag its corners in the Picture panel"));
}
// Esc: a picture just pasted goes again; one unlocked is locked again.
auto App::cancelFloating() -> void {
Object* o = find(ledit_.object);
if (o && editLayer() && ledit_.fresh) {
o->layers.erase(o->layers.begin() + ledit_.index);
ledit_ = {};
return say("Taken away");
}
lockLayer();
}
auto App::deleteLayer(Object& o, int i) -> void {
if (i < 0 || i >= int(o.layers.size())) return;
pushUndo();
o.layers.erase(o.layers.begin() + i);
ledit_ = {};
}
// The layer painted into the painting for good (it can't be moved after).
auto App::mergeLayer(Object& o, int i) -> void {
if (i < 0 || i >= int(o.layers.size()) || !o.picture) return;
pushUndo();
Object only = o;
only.layers = {o.layers[size_t(i)]};
o.picture = std::make_shared<Image>(composePicture(only));
o.layers.erase(o.layers.begin() + i);
ledit_ = {};
}
// The handles of a sticker being moved, in the world: its corners (top-left, top-right,
// bottom-right, bottom-left), its turning handle, its middle — on its face's plane.
auto App::stickerHandles(const Object& o, const PictureLayer& l, std::array<glm::vec3, 6>& out) -> bool {
glm::vec3 right, up, n;
if (!stickerFrame(o.mesh, l, right, up, n)) return false;
const int f = stickerFace(o.mesh, l);
const glm::vec3 c = l.centre - n * glm::dot(l.centre - faceCenter(o.mesh, f), n);
const float hw = l.width * 0.5f, hh = l.height * 0.5f;
const glm::mat4 m = objectMatrix(o);
auto w = [&](const glm::vec3& p) { return glm::vec3(m * glm::vec4(p, 1.0f)); };
out = {w(c - right * hw + up * hh), w(c + right * hw + up * hh), w(c + right * hw - up * hh), w(c - right * hw - up * hh), w(c + up * (hh + std::max(hw, hh) * 0.4f)), w(c)};
return true;
}
auto App::stickerHandle(const Object& o, const PictureLayer& l, ImVec2 mouse) -> int {
std::array<glm::vec3, 6> h;
if (!stickerHandles(o, l, h)) return -1;
ImVec2 s[6];
for (int i = 0; i < 6; ++i)
if (!project(h[size_t(i)], s[i])) return -1;
for (int i = 0; i < 4; ++i)
if (std::abs(mouse.x - s[i].x) < 10 && std::abs(mouse.y - s[i].y) < 10) return i;
if (std::hypot(mouse.x - s[4].x, mouse.y - s[4].y) < 11) return 5;
return insidePolygon(mouse, {s[0], s[1], s[2], s[3]}) ? 4 : -1;
}
// Moving a sticker on the model: it slides over the surface (onto other faces too), its
// corners size it, its handle turns it.
auto App::stickerMouse(const ImGuiIO& io) -> void {
Object* o = find(ledit_.object);
PictureLayer* l = editLayer();
if (!o || !l) return;
const glm::mat4 inv = glm::inverse(objectMatrix(*o));
const Ray ray = mouseRay(io.MousePos);
if (ledit_.drag >= 0) {
if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) return void(ledit_.drag = -1);
const PictureLayer& s = ledit_.start;
glm::vec3 right, up, n;
stickerFrame(o->mesh, s, right, up, n);
if (ledit_.drag == 4) {
const Hit h = pickObject(ray, o->id, true);
if (!h.object) return;
const glm::vec3 p(inv * glm::vec4(ray.origin + ray.dir * h.t, 1.0f));
l->face = h.face, l->up = s.up;
glm::vec3 r2, u2, n2;
stickerFrame(o->mesh, *l, r2, u2, n2);
l->up = u2; // (its top carried over onto the new face)
l->centre = p - r2 * ledit_.offset.x - u2 * ledit_.offset.y;
settleSticker(o->mesh, *l);
return;
}
const Ray local{glm::vec3(inv * glm::vec4(ray.origin, 1.0f)), glm::normalize(glm::vec3(inv * glm::vec4(ray.dir, 0.0f)))};
const auto hp = hitPlane(local, s.centre, n);
if (!hp) return;
const glm::vec3 d = *hp - s.centre;
const float dx = glm::dot(d, right), dy = glm::dot(d, up);
if (ledit_.drag < 4) {
if (io.KeyShift) l->width = std::max(2 * std::abs(dx), 0.002f), l->height = std::max(2 * std::abs(dy), 0.002f);
else {
const float k = std::max(std::abs(dx) / (s.width * 0.5f), std::abs(dy) / (s.height * 0.5f));
l->width = std::max(s.width * k, 0.002f), l->height = std::max(s.height * k, 0.002f);
}
message_ = std::format("{:.0f} x {:.0f} mm", l->width * 1000, l->height * 1000), messageAt_ = now();
} else {
float a = std::atan2(dx, dy);
if (snap_ != io.KeyCtrl) a = glm::radians(snapTo(glm::degrees(a), 15.0f));
l->up = up * std::cos(a) + right * std::sin(a);
message_ = std::format("Turned {:.0f} degrees", glm::degrees(a)), messageAt_ = now();
}
return;
}
if (!mouseInView(io) || !ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return;
const int h = stickerHandle(*o, *l, io.MousePos);
if (h < 0 && !pickObject(ray, o->id, true).object) return;
pushUndo();
ledit_.start = *l, ledit_.drag = h < 0 ? 4 : h, ledit_.offset = {0, 0};
if (h == 4) { // (held where it was grabbed)
glm::vec3 right, up, n;
stickerFrame(o->mesh, *l, right, up, n);
const Ray local{glm::vec3(inv * glm::vec4(ray.origin, 1.0f)), glm::normalize(glm::vec3(inv * glm::vec4(ray.dir, 0.0f)))};
if (auto hp = hitPlane(local, l->centre, n)) ledit_.offset = {glm::dot(*hp - l->centre, right), glm::dot(*hp - l->centre, up)};
}
}
// A cheap fingerprint of what the object's picture shows (its painting, its layers, its shape).
auto App::pictureStamp(const Object& o) -> uint64_t {
uint64_t h = 1469598103934665603ull;
auto mix = [&](const void* p, size_t n) {
for (size_t i = 0; i < n; ++i) h = (h ^ static_cast<const uint8_t*>(p)[i]) * 1099511628211ull;
};
const void* base = o.picture.get();
mix(&base, sizeof base);
const uint64_t version = versions_[o.id];
mix(&version, sizeof version);
for (const PictureLayer& l : o.layers) {
const void* im = l.image.get();
mix(&im, sizeof im), mix(&l.sticker, 1), mix(&l.visible, 1), mix(&l.opacity, 4), mix(l.corners.data(), 32);
mix(&l.face, 4), mix(&l.centre, 12), mix(&l.up, 12), mix(&l.width, 4), mix(&l.height, 4);
}
return h;
}
// The object's picture on the graphics card (its painting with its layers on top): sent again
// when it changes — while painting or dragging, at most 20 times a second.
auto App::uploadPicture(const Object& o, bool now_) -> void {
if (!o.picture) return;
Uploaded& u = uploaded_[o.id];
const bool checker = tab_ == Tab::Layout && layChecker_;
const uint64_t stamp = pictureStamp(o) ^ (checker ? 0x9E3779B97F4A7C15ull : 0);
const bool first = !paintTex_.count(o.id);
if (!first && u.stamp == stamp && !u.dirty) return;
if (!first && !now_ && now() - u.at < 0.05) return;
if (checker) { // (squares that show where the layout stretches or squashes the picture)
static Image board;
if (board.empty()) {
board = makeImage(512, 512, {1, 1, 1, 1});
for (int y = 0; y < 512; ++y)
for (int x = 0; x < 512; ++x) {
const bool dark = ((x / 32) + (y / 32)) % 2;
const glm::vec3 c = glm::mix(glm::vec3(0.95f, 0.55f, 0.25f), glm::vec3(0.3f, 0.55f, 0.9f), float(y) / 511.0f) * (dark ? 0.55f : 1.0f);
uint8_t* p = board.at(x, y);
p[0] = uint8_t(c.r * 255), p[1] = uint8_t(c.g * 255), p[2] = uint8_t(c.b * 255);
}
}
paintTex_[o.id] = r_->createTexture(std::format("fry:paint:{}", o.id), 512, 512, board.px.data());
} else if (o.layers.empty()) {
owner_.erase(o.id);
paintTex_[o.id] = r_->createTexture(std::format("fry:paint:{}", o.id), uint32_t(o.picture->w), uint32_t(o.picture->h), o.picture->px.data());
} else {
const Image shown = composePicture(o, &owner_[o.id]);
paintTex_[o.id] = r_->createTexture(std::format("fry:paint:{}", o.id), uint32_t(shown.w), uint32_t(shown.h), shown.px.data());
}
u = {stamp, now(), false};
}
// ───────────────────────────── the Layout tab ─────────────────────────────
// The frames an object's faces are laid out in on its picture, moved, turned and sized by
// hand (whole frames, or their corners); what's painted goes with them unless you say not.
// What's picked, as (face, corner) pairs of its layout.
auto App::layoutPicked(const Object& o, const std::vector<std::vector<int>>& islands) const -> std::vector<std::pair<int, int>> {
std::vector<std::pair<int, int>> out;
if (lay_.object != o.id) return out;
if (lay_.corners) return {lay_.points.begin(), lay_.points.end()};
for (int i : lay_.frames)
if (i < int(islands.size()))
for (int f : islands[size_t(i)])
for (size_t c = 0; c < o.mesh.faces[size_t(f)].size(); ++c) out.push_back({f, int(c)});
return out;
}
// A corner and every other on the same point of the model at the same place on the picture.
auto App::layoutCorner(const Object& o, int f, int c) const -> std::set<std::pair<int, int>> {
std::set<std::pair<int, int>> out;
const int v = o.mesh.faces[size_t(f)][size_t(c)];
const glm::vec2 at = o.uvs[size_t(f)][size_t(c)];
for (size_t g = 0; g < o.mesh.faces.size(); ++g)
for (size_t k = 0; k < o.mesh.faces[g].size(); ++k)
if (o.mesh.faces[g][k] == v && glm::distance(o.uvs[g][k], at) < 1e-5f) out.insert({int(g), int(k)});
return out;
}
auto App::layoutSelectAll() -> void {
Object* o = selected_.empty() ? nullptr : find(active_);
if (!o || !o->uvsFit()) return;
if (lay_.object != o->id) lay_ = {}, lay_.object = o->id;
lay_.frames.clear(), lay_.points.clear();
if (lay_.corners)
for (size_t f = 0; f < o->uvs.size(); ++f)
for (size_t c = 0; c < o->uvs[f].size(); ++c) lay_.points.insert({int(f), int(c)});
else
for (size_t i = 0; i < uvIslands(o->mesh, o->uvs).size(); ++i) lay_.frames.insert(int(i));
}
// The picked corners moved by `to` (picture pixels), painting carried along if wanted.
auto App::layoutChange(Object& o, const std::vector<std::vector<glm::vec2>>& from, const std::shared_ptr<const Image>& picture, const std::vector<std::pair<int, int>>& picked, const std::function<glm::vec2(glm::vec2)>& to, bool final) -> void {
const glm::vec2 size(float(o.picture->w), float(o.picture->h));
o.uvs = from;
std::set<int> faces;
for (auto [f, c] : picked) o.uvs[size_t(f)][size_t(c)] = to(from[size_t(f)][size_t(c)] * size) / size, faces.insert(f);
touched(o.id);
if (layKeepPaint_ && picture && (final || now() - lay_.remapAt > 0.06)) {
Image img = *picture;
remapPicture(img, o.mesh, from, o.uvs, {faces.begin(), faces.end()});
o.picture = std::make_shared<Image>(std::move(img));
lay_.remapAt = now();
}
}
// Buttons' changes (turn, flip, straighten): about the middle of what's picked.
auto App::layoutApply(const std::function<glm::vec2(glm::vec2, glm::vec2)>& to) -> void {
Object* o = selected_.empty() ? nullptr : find(active_);
if (!o || !o->picture || !o->uvsFit()) return;
const auto picked = layoutPicked(*o, uvIslands(o->mesh, o->uvs));
if (picked.empty()) return say("Pick some frames first (click them in the Picture panel or on the model)");
pushUndo();
const glm::vec2 size(float(o->picture->w), float(o->picture->h));
glm::vec2 lo(1e30f), hi(-1e30f);
for (auto [f, c] : picked) lo = glm::min(lo, o->uvs[size_t(f)][size_t(c)] * size), hi = glm::max(hi, o->uvs[size_t(f)][size_t(c)] * size);
const glm::vec2 mid = (lo + hi) * 0.5f;
layoutChange(*o, o->uvs, o->picture, picked, [&](glm::vec2 p) { return to(p, mid); }, true);
}
auto App::layoutStraighten() -> void {
Object* o = selected_.empty() ? nullptr : find(active_);
if (!o || !o->picture || !o->uvsFit()) return;
const auto picked = layoutPicked(*o, uvIslands(o->mesh, o->uvs));
std::set<int> faces;
for (auto [f, c] : picked) faces.insert(f);
float best = -1, angle = 0; // (its longest edge, laid level or upright)
for (int f : faces)
for (size_t i = 0; i < o->uvs[size_t(f)].size(); ++i) {
const glm::vec2 d = (o->uvs[size_t(f)][(i + 1) % o->uvs[size_t(f)].size()] - o->uvs[size_t(f)][i]) * glm::vec2(o->picture->w, o->picture->h);
if (const float l = glm::length(d); l > best) best = l, angle = std::atan2(d.y, d.x);
}
const float quarter = glm::half_pi<float>(), turn = std::round(angle / quarter) * quarter - angle;
layoutApply([&](glm::vec2 p, glm::vec2 m) { return m + glm::vec2(std::cos(turn) * (p - m).x - std::sin(turn) * (p - m).y, std::sin(turn) * (p - m).x + std::cos(turn) * (p - m).y); });
}
// Laid out again by itself, what's painted carried over onto the new layout.
auto App::layoutAgain(Object& o) -> void {
pushUndo();
const auto before = o.uvs;
o.uvs = autoUnwrap(o.mesh);
if (layKeepPaint_ && o.picture && before.size() == o.uvs.size()) {
Image img = *o.picture;
std::vector<int> all(o.mesh.faces.size());
for (size_t f = 0; f < all.size(); ++f) all[f] = int(f);
Image fresh = makeImage(img.w, img.h, glm::vec4(glm::vec3(o.color), 1.0f));
fresh.px = img.px;
remapPicture(fresh, o.mesh, before, o.uvs, all);
o.picture = std::make_shared<Image>(std::move(fresh));
}
lay_.frames.clear(), lay_.points.clear();
touched(o.id);
say("Laid out again" + std::string(layKeepPaint_ ? ", what's painted carried over" : ""));
}
// In the 3D view: clicking the model picks the frame its face is in (Shift: add).
auto App::layoutViewport(const ImGuiIO& io) -> void {
if (!mouseInView(io) || !ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return;
const Hit h = pickObject(mouseRay(io.MousePos), 0, true);
if (!h.object) {
if (!io.KeyShift) lay_.frames.clear(), lay_.points.clear();
return;
}
if (h.object != active_ || selected_.empty()) selected_ = {h.object}, active_ = h.object, lay_ = {}, lay_.object = h.object;
Object* o = find(h.object);
if (!o || !o->uvsFit()) return;
const auto islands = uvIslands(o->mesh, o->uvs);
for (size_t i = 0; i < islands.size(); ++i)
if (std::find(islands[i].begin(), islands[i].end(), h.face) != islands[i].end()) {
if (lay_.corners) lay_.corners = false, lay_.points.clear();
if (!io.KeyShift) lay_.frames.clear();
if (io.KeyShift && lay_.frames.count(int(i))) lay_.frames.erase(int(i));
else lay_.frames.insert(int(i));
}
}
auto App::layoutOverlay(ImDrawList* dl) -> void {
Object* o = selected_.empty() ? nullptr : find(active_);
if (!o || !o->uvsFit() || lay_.object != o->id) return;
const auto picked = layoutPicked(*o, uvIslands(o->mesh, o->uvs));
std::set<int> faces;
for (auto [f, c] : picked) faces.insert(f);
const glm::mat4 m = objectMatrix(*o);
for (int f : faces) {
std::vector<ImVec2> pts;
bool ok = true;
for (int v : o->mesh.faces[size_t(f)]) {
ImVec2 q;
ok &= project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)), q);
pts.push_back(q);
}
if (ok) dl->AddPolyline(pts.data(), int(pts.size()), kAccent, ImDrawFlags_Closed, 2.0f);
}
}
auto App::layoutToolbox(float w) -> void {
grid(int(std::size(kLayTools)), 38.0f, [&](int i) {
const LayToolInfo& t = kLayTools[i];
const std::string id = std::format("##{}", t.name), tip = std::format("{} ({})\n{}", t.name, t.key, t.hint);
if (iconButton(id.c_str(), t.icon, layTool_ == t.tool, tip.c_str())) layTool_ = t.tool;
});
ImGui::Spacing();
ImGui::TextDisabled("Pick");
if (wideButton("##frames", Icon::Faces, "Frames", !lay_.corners, w, "Whole frames: faces laid out together (Tab)")) lay_.corners = false, lay_.points.clear();
if (wideButton("##corners", Icon::Points, "Corners", lay_.corners, w, "Corners of the frames, to bend them (Tab)")) lay_.corners = true, lay_.frames.clear();
}
auto App::layoutOptions(float w) -> void {
for (const LayToolInfo& t : kLayTools)
if (t.tool == layTool_) ImGui::SeparatorText(t.name);
ImGui::TextDisabled("What's painted");
int keep = layKeepPaint_ ? 0 : 1;
ImGui::SetNextItemWidth(w);
if (ImGui::Combo("##keep", &keep, "Stays on the model (moves too)\0Stays on the picture\0")) layKeepPaint_ = keep == 0;
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Stays on the model: the model looks the same, frames just get more or less of the picture.\n"
"Stays on the picture: the model shows whatever the frame is moved over (to fit a frame onto something drawn).");
ImGui::Checkbox("Show squares on the model", &layChecker_);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Squares instead of the painting: where they're stretched or squashed, so is the picture");
ImGui::SeparatorText("What's picked");
const float half = (w - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
auto turn = [&](float deg) {
const float a = glm::radians(deg);
layoutApply([a](glm::vec2 p, glm::vec2 m) { return m + glm::vec2(std::cos(a) * (p - m).x - std::sin(a) * (p - m).y, std::sin(a) * (p - m).x + std::cos(a) * (p - m).y); });
};
if (ImGui::Button("Turn left", ImVec2(half, 0))) turn(-90);
ImGui::SameLine();
if (ImGui::Button("Turn right", ImVec2(half, 0))) turn(90);
if (ImGui::Button("Flip across", ImVec2(half, 0))) layoutApply([](glm::vec2 p, glm::vec2 m) { return glm::vec2(2 * m.x - p.x, p.y); });
ImGui::SameLine();
if (ImGui::Button("Flip up-down", ImVec2(half, 0))) layoutApply([](glm::vec2 p, glm::vec2 m) { return glm::vec2(p.x, 2 * m.y - p.y); });
if (ImGui::Button("Bigger", ImVec2(half, 0))) layoutApply([](glm::vec2 p, glm::vec2 m) { return m + (p - m) * 1.25f; });
ImGui::SameLine();
if (ImGui::Button("Smaller", ImVec2(half, 0))) layoutApply([](glm::vec2 p, glm::vec2 m) { return m + (p - m) * 0.8f; });
if (ImGui::Button("Straighten", ImVec2(w, 0))) layoutStraighten();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Turns it so its longest edge is level or upright");
}
auto App::layoutProperties(float w) -> void {
Object* o = selected_.empty() ? nullptr : find(active_);
if (!o) return void(ImGui::TextWrapped("Click an object: its picture's layout shows in the Picture panel."));
ImGui::TextColored(accentText(), "%s", o->name.c_str());
if (!o->picture || !o->uvsFit()) {
if (ImGui::Button("Lay it out", ImVec2(w, 0))) readyToPaint(o->id);
return;
}
const auto islands = uvIslands(o->mesh, o->uvs);
ImGui::TextDisabled("%zu frames, %zu faces, picture %d x %d", islands.size(), o->mesh.faces.size(), o->picture->w, o->picture->h);
if (ImGui::Button("Pick all (Ctrl+A)", ImVec2(w, 0))) layoutSelectAll();
if (ImGui::Button("Lay it all out again", ImVec2(w, 0))) layoutAgain(*o);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Fry3DX lays it out by itself again (what's painted carried over, if it stays on the model)");
ImGui::Spacing();
ImGui::TextWrapped("Each frame is a part of the model laid flat on its picture. Make a frame bigger to give that part more detail, "
"turn it to run a pattern the way you want, or move corners to bend it.");
}
} // namespace fryapp

2017
src/app_panels.cpp Normal file

File diff suppressed because it is too large Load diff

489
src/app_print.cpp Normal file
View file

@ -0,0 +1,489 @@
// Fry3DX: the Print tab: the plate, checks (overhangs, thin walls), the slice preview, sizes.
#include "app.h"
using namespace fry;
namespace stdfs = std::filesystem;
namespace fryapp {
// ───────────────────────────── the Print tab ─────────────────────────────
auto App::bedSize() const -> glm::vec3 { // (metres)
const int i = std::clamp(fry_printer.i(), 0, int(std::size(kPrinters)) - 1);
const PrinterInfo& p = kPrinters[i];
return p.x > 0 ? glm::vec3(p.x, p.y, p.z) * 0.001f : glm::vec3(fry_bed_x.f(), fry_bed_y.f(), fry_bed_z.f()) * 0.001f;
}
auto App::printInfo(const Object& o) -> const PrintInfo& {
PrintInfo& p = printInfos_[o.id];
uint64_t key = versions_[o.id] * 1000003ull + uint64_t(overhangDeg_ * 10) + uint64_t(minWall_ * 1e5f) * 7919ull;
for (float f : {o.position.x, o.position.y, o.position.z, o.rotation.x, o.rotation.y, o.rotation.z, o.rotation.w, o.scale.x, o.scale.y, o.scale.z}) {
uint32_t b;
std::memcpy(&b, &f, 4);
key = (key ^ b) * 1099511628211ull;
}
if (p.key == key) return p;
p.key = key;
p.world = cutKeys_.count(o.id) ? fry::cutMesh(scene_, o) : o.hasFinish() ? finishedMesh(o) : o.mesh;
transform(p.world, objectMatrix(o));
p.lo = boundsMin(p.world), p.hi = boundsMax(p.world);
p.check = checkForPrint(p.world, overhangDeg_, 0.0f);
// Overhangs: those faces again a hair above the surface, to show red.
keel::KmdlModel km;
keel::KmdlMaterial mat{};
mat.baseColor[0] = mat.baseColor[1] = mat.baseColor[2] = mat.baseColor[3] = 1.0f;
km.materials.push_back(mat);
for (size_t f = 0; f < p.world.faces.size(); ++f) {
if (!overhangs(p.world, int(f), overhangDeg_, 0.0f)) continue;
const glm::vec3 n = faceNormal(p.world, int(f));
for (const auto& t : triangulate(p.world, int(f)))
for (int k : t) {
const glm::vec3 q = p.world.verts[size_t(k)] + n * 0.0004f;
km.indices.push_back(uint32_t(km.vertices.size()));
km.vertices.push_back({{q.x, q.y, q.z}, {n.x, n.y, n.z}, {0, 0}, {0, 0}});
}
}
p.hasOverhang = !km.indices.empty();
if (p.hasOverhang) {
km.submeshes.push_back({0, uint32_t(km.indices.size()), 0});
p.overhang = r_->updateModel(std::format("fry:overhang:{}", o.id), km);
}
// Thin walls (only of a closed shape: an open one has no inside to be thick).
uint64_t thickKey = shapeKey(o) * 1000003ull;
for (float f : {o.scale.x, o.scale.y, o.scale.z}) {
uint32_t b;
std::memcpy(&b, &f, 4);
thickKey = (thickKey ^ b) * 1099511628211ull;
}
if (p.thickKey != thickKey || p.thick.size() != p.world.faces.size())
p.thickKey = thickKey, p.thick = p.check.closed() ? fry::wallThickness(p.world) : std::vector<float>(p.world.faces.size(), 1e30f);
p.thinFaces = 0, p.thinnest = 1e30f;
km.vertices.clear(), km.indices.clear(), km.submeshes.clear();
for (size_t f = 0; f < p.world.faces.size(); ++f) {
p.thinnest = std::min(p.thinnest, p.thick[f]);
if (p.thick[f] >= minWall_) continue;
++p.thinFaces;
const glm::vec3 n = faceNormal(p.world, int(f));
for (const auto& t : triangulate(p.world, int(f)))
for (int k : t) {
const glm::vec3 q = p.world.verts[size_t(k)] + n * 0.0005f;
km.indices.push_back(uint32_t(km.vertices.size()));
km.vertices.push_back({{q.x, q.y, q.z}, {n.x, n.y, n.z}, {0, 0}, {0, 0}});
}
}
if (p.thinFaces) {
km.submeshes.push_back({0, uint32_t(km.indices.size()), 0});
p.thin = r_->updateModel(std::format("fry:thin:{}", o.id), km);
}
return p;
}
auto App::printTargets() -> std::vector<Object*> { // (what's picked, or everything shown)
std::vector<Object*> out;
for (Object& o : scene_.objects)
if (o.visible && !isCutter(o) && (selected_.empty() || selected_.count(o.id))) out.push_back(&o);
return out;
}
auto App::drawPrintBed(keel::Renderer& r, const keel::ModelLighting& light) -> void {
const glm::vec3 b = bedSize();
const glm::vec3 lo(-b.x * 0.5f, -b.y * 0.5f, 0.0f), hi(b.x * 0.5f, b.y * 0.5f, b.z);
const glm::vec4 plate(0.1f, 0.1f, 0.12f, 1.0f);
if (bedFor_ != b) {
bedFor_ = b;
keel::KmdlModel km;
keel::KmdlMaterial mat{};
mat.baseColor[0] = mat.baseColor[1] = mat.baseColor[2] = mat.baseColor[3] = 1.0f;
km.materials.push_back(mat);
for (glm::vec2 c : {glm::vec2(lo.x, lo.y), glm::vec2(hi.x, lo.y), glm::vec2(hi.x, hi.y), glm::vec2(lo.x, hi.y)}) km.vertices.push_back({{c.x, c.y, -0.0006f}, {0, 0, 1}, {0, 0}, {0, 0}});
km.indices = {0, 1, 2, 0, 2, 3};
km.submeshes.push_back({0, 6, 0});
bed_ = r.updateModel("fry:bed", km);
}
r.drawModel(bed_, glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)), plate, nullptr, &light, false);
// Its outline, and the space the printer reaches (fainter).
const glm::vec3 c[8] = {{lo.x, lo.y, 0}, {hi.x, lo.y, 0}, {hi.x, hi.y, 0}, {lo.x, hi.y, 0}, {lo.x, lo.y, hi.z}, {hi.x, lo.y, hi.z}, {hi.x, hi.y, hi.z}, {lo.x, hi.y, hi.z}};
const int e[12][2] = {{0, 1}, {1, 2}, {2, 3}, {3, 0}, {4, 5}, {5, 6}, {6, 7}, {7, 4}, {0, 4}, {1, 5}, {2, 6}, {3, 7}};
bool fits = true;
for (Object* o : printTargets()) {
const PrintInfo& p = printInfo(*o);
fits &= glm::all(glm::greaterThanEqual(p.lo, lo - 1e-4f)) && glm::all(glm::lessThanEqual(p.hi, hi + 1e-4f));
}
const glm::vec4 col = fits ? glm::vec4(0.9f, 0.9f, 0.95f, 0.5f) : glm::vec4(1.0f, 0.3f, 0.2f, 0.9f);
for (int i = 0; i < 12; ++i)
r.drawStreak(c[e[i][0]] * kUnits, c[e[i][1]] * kUnits, pixelSize(target_) * (i < 4 ? 2.0f : 1.0f) * kUnits, i < 4 ? col : col * glm::vec4(1, 1, 1, 0.5f),
keel::kWhiteTexture);
if (showOverhangs_) {
const keel::ModelLighting flat{{glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f)}, 0.0f};
for (const Object& o : scene_.objects)
if (o.visible && !isCutter(o))
if (const PrintInfo& p = printInfo(o); p.hasOverhang)
r.drawModel(p.overhang, glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)), glm::vec4(0.95f, 0.15f, 0.1f, 0.55f), nullptr, &flat, false);
}
if (showThin_) {
const keel::ModelLighting flat{{glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f), glm::vec3(1.0f)}, 0.0f};
for (const Object& o : scene_.objects)
if (o.visible && !isCutter(o))
if (const PrintInfo& p = printInfo(o); p.thinFaces)
r.drawModel(p.thin, glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)), glm::vec4(1.0f, 0.8f, 0.1f, 0.7f), nullptr, &flat, false);
}
}
auto App::putOnBed() -> void {
const auto targets = printTargets();
if (targets.empty()) return;
pushUndo();
for (Object* o : targets) o->position.z -= printInfo(*o).lo.z, touched(o->id);
say("Put on the bed");
}
auto App::middleOfBed() -> void {
const auto targets = printTargets();
if (targets.empty()) return;
glm::vec3 lo(1e30f), hi(-1e30f);
for (Object* o : targets) lo = glm::min(lo, printInfo(*o).lo), hi = glm::max(hi, printInfo(*o).hi);
pushUndo();
const glm::vec2 d = -(glm::vec2(lo) + glm::vec2(hi)) * 0.5f;
for (Object* o : targets) o->position += glm::vec3(d, 0.0f), touched(o->id);
}
// Everything picked made `mm` big along `axis`, keeping its shape, standing where it stood.
auto App::printResize(int axis, float mm) -> void {
const auto targets = printTargets();
glm::vec3 lo(1e30f), hi(-1e30f);
for (Object* o : targets) lo = glm::min(lo, printInfo(*o).lo), hi = glm::max(hi, printInfo(*o).hi);
const float now_ = (hi - lo)[axis] * 1000.0f;
if (targets.empty() || now_ < 1e-3f || mm <= 0.01f) return;
const float k = mm / now_;
const glm::vec3 pivot((lo.x + hi.x) * 0.5f, (lo.y + hi.y) * 0.5f, lo.z);
for (Object* o : targets) o->position = pivot + (o->position - pivot) * k, o->scale *= k, touched(o->id);
}
// Everything picked made `k` times as big, standing where it stood.
auto App::printScale(float k) -> void {
const auto targets = printTargets();
if (targets.empty() || !(k > 0) || std::abs(k - 1) < 1e-6f) return;
glm::vec3 lo(1e30f), hi(-1e30f);
for (Object* o : targets) lo = glm::min(lo, printInfo(*o).lo), hi = glm::max(hi, printInfo(*o).hi);
const glm::vec3 pivot((lo.x + hi.x) * 0.5f, (lo.y + hi.y) * 0.5f, lo.z);
for (Object* o : targets) o->position = pivot + (o->position - pivot) * k, o->scale *= k, touched(o->id);
}
auto App::printScaler(float w) -> void {
const auto targets = printTargets();
if (targets.empty()) return;
glm::vec3 lo(1e30f), hi(-1e30f);
for (Object* o : targets) lo = glm::min(lo, printInfo(*o).lo), hi = glm::max(hi, printInfo(*o).hi);
const glm::vec3 size = (hi - lo) * 1000.0f, bed = bedSize() * 1000.0f;
auto scale = [&](float k, const char* what) {
pushUndo();
printScale(k);
say(std::format("{}: now {:.1f} x {:.1f} x {:.1f} mm", what, size.x * k, size.y * k, size.z * k));
};
const float bw = (w - ImGui::GetStyle().ItemSpacing.x * 3) / 4;
ImGui::SetNextItemWidth(w - bw - ImGui::GetStyle().ItemSpacing.x);
ImGui::InputFloat("##pct", &scalePercent_, 0, 0, "%.1f %%");
scalePercent_ = std::clamp(scalePercent_, 0.1f, 100000.0f);
ImGui::SameLine();
if (ImGui::Button("Scale", ImVec2(bw, 0))) scale(scalePercent_ / 100, std::format("Scaled to {:g}%", scalePercent_).c_str()), scalePercent_ = 100;
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Make it this share of the size it is now (50%%: half, 200%%: twice)");
const struct {
const char* label;
float k;
const char* tip;
} quick[] = {{"x 0.5", 0.5f, "Half the size"}, {"x 2", 2.0f, "Twice the size"}, {"in > mm", 25.4f, "It was made in inches: 1 became 1 mm, should be 25.4"},
{"cm > mm", 10.0f, "It was made in centimetres: 1 became 1 mm, should be 10"}};
for (int i = 0; i < 4; ++i) {
if (i) ImGui::SameLine();
if (ImGui::Button(quick[i].label, ImVec2(bw, 0))) scale(quick[i].k, quick[i].tip);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", quick[i].tip);
}
const float fit = std::min({bed.x / std::max(size.x, 1e-3f), bed.y / std::max(size.y, 1e-3f), bed.z / std::max(size.z, 1e-3f)}) * 0.95f;
if (ImGui::Button(std::format("Fit the plate ({:.0f}%)", fit * 100).c_str(), ImVec2(w * 0.5f - ImGui::GetStyle().ItemSpacing.x * 0.5f, 0))) {
scale(fit, "Made as big as fits the plate");
middleOfBed();
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("As big as fits on this printer (a little room left round it), in the middle");
ImGui::SameLine();
if (ImGui::Button("m > mm", ImVec2(w * 0.5f - ImGui::GetStyle().ItemSpacing.x * 0.5f, 0))) scale(1000.0f, "It was made in metres: 1 became 1 mm");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("It came in a thousand times too small (made in metres, read as millimetres)");
}
auto App::printSizeWindow() -> void {
if (!showPrintSize_) return;
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
if (!ImGui::Begin("Print size", &showPrintSize_, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End();
const float w = 320;
const auto targets = printTargets();
if (targets.empty()) ImGui::TextWrapped("Nothing to size: add something in the Model tab.");
else {
glm::vec3 lo(1e30f), hi(-1e30f);
for (Object* o : targets) lo = glm::min(lo, printInfo(*o).lo), hi = glm::max(hi, printInfo(*o).hi);
const glm::vec3 size = (hi - lo) * 1000.0f;
ImGui::TextDisabled("%s", selected_.empty() ? "Everything" : targets.size() == 1 ? targets[0]->name.c_str() : "What's picked");
const char* axes[] = {"across", "deep", "high"};
for (int a = 0; a < 3; ++a) {
float v = size[a];
ImGui::SetNextItemWidth(w * 0.7f);
ImGui::PushID(a);
if (ImGui::InputFloat("##size", &v, 0, 0, "%.2f mm", ImGuiInputTextFlags_EnterReturnsTrue)) pushUndo(), printResize(a, v);
ImGui::PopID();
ImGui::SameLine();
ImGui::TextDisabled("%s", axes[a]);
}
ImGui::Spacing();
printScaler(w);
}
ImGui::End();
}
auto App::printToolbox(float w) -> void {
ImGui::Spacing();
if (wideButton("##measure", Icon::Ruler, "Measure", printTool_ == PrintTool::Measure, w, "Click two points to see how far apart they are (M)"))
printTool_ = printTool_ == PrintTool::Measure ? PrintTool::None : PrintTool::Measure, measure_.clear();
if (wideButton("##layflat", Icon::LayFlat, "Lay flat", printTool_ == PrintTool::LayFlat, w, "Click the face it should stand on (L)"))
printTool_ = printTool_ == PrintTool::LayFlat ? PrintTool::None : PrintTool::LayFlat;
if (wideButton("##bed", Icon::Move, "Put on the bed", false, w, "Down (or up) so it stands on the printer's plate (B)")) putOnBed();
if (wideButton("##mid", Icon::Box, "Middle of the bed", false, w, "To the middle of the printer's plate")) middleOfBed();
ImGui::Spacing();
if (wideButton("##3mf", Icon::Print, "Save for the slicer", false, w, "3MF: opens in PrusaSlicer, Bambu Studio, OrcaSlicer, Cura")) dialog(8);
if (wideButton("##stl", Icon::Print, "Save as STL", false, w, "STL, in millimetres")) dialog(9);
}
auto App::printOptions(float w) -> void {
if (printTool_ == PrintTool::Measure) {
ImGui::SeparatorText("Measure");
ImGui::Checkbox("Catch corners", &measureSnap_);
if (measure_.size() == 2) {
const glm::vec3 d = (measure_[1] - measure_[0]) * 1000.0f;
ImGui::Text("%.2f mm", glm::length(d));
ImGui::TextDisabled("across %.2f deep %.2f up %.2f", std::abs(d.x), std::abs(d.y), std::abs(d.z));
} else
ImGui::TextWrapped("Click a point on the model, then another.");
if (ImGui::Button("Clear", ImVec2(w, 0))) measure_.clear();
} else {
ImGui::SeparatorText("Lay flat");
ImGui::TextWrapped("Click the face it should stand on: it turns so that face is down, and sits on the bed. A big flat face down prints best, with least support.");
}
}
auto App::printViewport(const ImGuiIO& io) -> void {
if (!mouseInView(io) || !ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return;
if (printTool_ == PrintTool::Measure) {
if (const auto p = measurePoint(io.MousePos)) {
if (measure_.size() >= 2) measure_.clear();
measure_.push_back(*p);
if (measure_.size() == 2) say(std::format("{:.2f} mm", glm::distance(measure_[0], measure_[1]) * 1000.0f));
}
return;
}
const Hit h = pickObject(mouseRay(io.MousePos));
Object* o = h.object ? find(h.object) : nullptr;
if (!o) return;
const Mesh& m = printInfo(*o).world;
if (h.face < 0 || h.face >= int(m.faces.size())) return;
pushUndo();
const glm::vec3 n = faceNormal(m, h.face);
o->rotation = glm::normalize(glm::rotation(n, glm::vec3(0, 0, -1)) * o->rotation);
touched(o->id);
o->position.z -= printInfo(*o).lo.z;
touched(o->id);
selected_ = {o->id}, active_ = o->id;
say("Laid flat on that face");
}
// Where the mouse is on the model (a corner if near one), or on the bed.
auto App::measurePoint(ImVec2 mouse) -> std::optional<glm::vec3> {
const Ray ray = mouseRay(mouse);
const Hit h = pickObject(ray);
if (h.object) {
glm::vec3 p = ray.origin + ray.dir * h.t;
if (measureSnap_)
if (const Object* o = find(h.object)) {
float best = 12.0f;
for (const glm::vec3& v : printInfo(*o).world.verts) {
ImVec2 q;
if (project(v, q))
if (const float d = std::hypot(q.x - mouse.x, q.y - mouse.y); d < best) best = d, p = v;
}
}
return p;
}
if (std::abs(ray.dir.z) > 1e-5f)
if (const float t = -ray.origin.z / ray.dir.z; t > 0) return ray.origin + ray.dir * t;
return std::nullopt;
}
// The slice preview: where the printer goes round at the chosen layer, over the model.
auto App::layerOverlay(ImDrawList* dl) -> void {
const float z = (float(layer_) - 0.5f) * layerHeight_;
for (Object* o : printTargets()) {
const PrintInfo& p = printInfo(*o);
if (p.sliceKey != p.key || p.sliceZ != z) p.sliceKey = p.key, p.sliceZ = z, p.slice = fry::sliceAt(p.world, z);
for (const auto& [a, b] : p.slice) {
ImVec2 sa, sb;
if (project(a, sa) && project(b, sb)) dl->AddLine(sa, sb, IM_COL32(60, 220, 255, 255), 2.5f);
}
}
}
auto App::printOverlay(ImDrawList* dl, const ImGuiIO& io) -> void {
if (printTool_ != PrintTool::Measure) return;
std::vector<glm::vec3> pts = measure_;
if (pts.size() == 1 && mouseInView(io))
if (auto p = measurePoint(io.MousePos)) pts.push_back(*p);
ImVec2 q[2];
for (size_t i = 0; i < pts.size(); ++i)
if (project(pts[i], q[i])) dl->AddCircleFilled(q[i], 5, kAccent);
if (pts.size() == 2 && project(pts[0], q[0]) && project(pts[1], q[1])) {
dl->AddLine(q[0], q[1], kAccent, 2.0f);
const std::string t = std::format("{:.2f} mm", glm::distance(pts[0], pts[1]) * 1000.0f);
const ImVec2 mid((q[0].x + q[1].x) * 0.5f, (q[0].y + q[1].y) * 0.5f), ts = ImGui::CalcTextSize(t.c_str());
dl->AddRectFilled(ImVec2(mid.x - ts.x * 0.5f - 6, mid.y - ts.y - 10), ImVec2(mid.x + ts.x * 0.5f + 6, mid.y - 4), IM_COL32(20, 20, 24, 230), 4.0f);
dl->AddText(ImVec2(mid.x - ts.x * 0.5f, mid.y - ts.y - 7), IM_COL32(255, 255, 255, 255), t.c_str());
}
}
auto App::printProperties(float w) -> void {
auto good = [](const char* t) { ImGui::TextColored(roleColor(keel::ui::Role::SuccessText), "%s", t); };
auto bad = [](const std::string& t) { ImGui::PushStyleColor(ImGuiCol_Text, roleColor(keel::ui::Role::DangerText)), ImGui::TextWrapped("%s", t.c_str()), ImGui::PopStyleColor(); };
ImGui::SeparatorText("Printer");
int printer = std::clamp(fry_printer.i(), 0, int(std::size(kPrinters)) - 1);
ImGui::SetNextItemWidth(w);
if (ImGui::BeginCombo("##printer", kPrinters[printer].name)) {
for (int i = 0; i < int(std::size(kPrinters)); ++i)
if (ImGui::Selectable(kPrinters[i].name, i == printer)) keel::execute(std::format("fry_printer {}", i));
ImGui::EndCombo();
}
const glm::vec3 bed = bedSize() * 1000.0f;
if (kPrinters[printer].x <= 0) {
float b[3] = {bed.x, bed.y, bed.z};
ImGui::SetNextItemWidth(w);
if (ImGui::DragFloat3("##bed", b, 1.0f, 50.0f, 2000.0f, "%.0f mm"))
keel::execute(std::format("fry_bed_x {}; fry_bed_y {}; fry_bed_z {}", b[0], b[1], b[2]));
} else
ImGui::TextDisabled("Plate %.0f x %.0f mm, %.0f mm high", bed.x, bed.y, bed.z);
const auto targets = printTargets();
if (targets.empty()) return void(ImGui::TextWrapped("Nothing to print: add something in the Model tab."));
glm::vec3 lo(1e30f), hi(-1e30f);
PrintCheck all;
for (Object* o : targets) {
const PrintInfo& p = printInfo(*o);
lo = glm::min(lo, p.lo), hi = glm::max(hi, p.hi);
all.openEdges += p.check.openEdges, all.crowdedEdges += p.check.crowdedEdges, all.flippedEdges += p.check.flippedEdges;
all.tinyFaces += p.check.tinyFaces, all.pieces += p.check.pieces, all.insideOut |= p.check.insideOut;
all.volume += std::abs(p.check.volume), all.area += p.check.area, all.overhangArea += p.check.overhangArea;
}
const glm::vec3 size = (hi - lo) * 1000.0f;
ImGui::SeparatorText(selected_.empty() ? "Everything" : targets.size() == 1 ? targets[0]->name.c_str() : "What's picked");
// Its size, typed in millimetres (keeps its shape).
const char* axes[] = {"across", "deep", "high"};
for (int a = 0; a < 3; ++a) {
float v = size[a];
ImGui::SetNextItemWidth(w * 0.62f);
ImGui::PushID(a);
if (ImGui::DragFloat("##size", &v, 0.5f, 0.1f, 100000.0f, "%.2f mm")) {
if (ImGui::IsItemActivated() || !sizing_) pushUndo(), sizing_ = true;
printResize(a, v);
}
if (ImGui::IsItemDeactivated()) sizing_ = false;
ImGui::PopID();
ImGui::SameLine();
ImGui::TextDisabled("%s", axes[a]);
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Type a size: it keeps its shape");
printScaler(w);
const glm::vec3 b = bedSize();
const bool fits = size.x <= bed.x + 0.05f && size.y <= bed.y + 0.05f && size.z <= bed.z + 0.05f;
const bool onPlate = lo.x >= -b.x * 0.5f - 1e-4f && hi.x <= b.x * 0.5f + 1e-4f && lo.y >= -b.y * 0.5f - 1e-4f && hi.y <= b.y * 0.5f + 1e-4f && hi.z <= b.z + 1e-4f;
if (!fits) {
const float k = std::min({bed.x / std::max(size.x, 1e-3f), bed.y / std::max(size.y, 1e-3f), bed.z / std::max(size.z, 1e-3f)});
bad(std::format("Too big for this printer: it would fit at {:.0f}% of this size (or in parts).", std::floor(k * 1000) / 10));
} else if (!onPlate) {
bad("It fits, but it's off the plate.");
if (ImGui::Button("Middle of the bed", ImVec2(w, 0))) middleOfBed();
} else
good("Fits on the plate");
if (std::abs(lo.z) > 1e-4f) {
bad(lo.z > 0 ? std::format("It floats {:.2f} mm above the bed.", lo.z * 1000) : std::format("It goes {:.2f} mm into the bed (that part won't print).", -lo.z * 1000));
if (ImGui::Button("Put it on the bed (B)", ImVec2(w, 0))) putOnBed();
}
ImGui::SeparatorText("Ready to print?");
if (!all.closed() || all.insideOut || all.tinyFaces) {
pushAccent();
if (ImGui::Button("Repair it (all at once)", ImVec2(w, 0))) repairSelected();
popAccent();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Joins loose corners, takes away empty and doubled faces, turns faces outwards, closes holes");
}
if (all.openEdges) {
bad(std::format("Not closed: {} edges have a face on one side only (holes). A slicer may fill them wrongly or not print it.", all.openEdges));
if (ImGui::Button("Close the holes", ImVec2(w, 0))) {
pushUndo();
int n = 0;
for (Object* o : targets) n += closeHoles(o->mesh), touched(o->id);
say(std::format("Closed {} hole{}", n, n == 1 ? "" : "s"));
}
} else
good("Closed all round");
if (all.flippedEdges || all.insideOut) {
bad("Some faces face inwards: the slicer can't tell inside from out.");
if (ImGui::Button("Turn them the right way", ImVec2(w, 0))) {
pushUndo();
int n = 0;
for (Object* o : targets) n += faceOutward(o->mesh), touched(o->id);
say(std::format("Turned {} face{}", n, n == 1 ? "" : "s"));
}
} else
good("Faces all face outwards");
if (all.crowdedEdges) bad(std::format("{} edges have more than two faces on them (shapes stuck together there): separate or rebuild that part.", all.crowdedEdges));
if (all.tinyFaces) bad(std::format("{} faces have no size: Merge by distance in the Model tab cleans them up.", all.tinyFaces));
if (all.pieces > int(targets.size())) ImGui::TextDisabled("%d separate pieces", all.pieces);
const float hang = all.overhangArea * 1e4f;
if (hang > 0.01f) bad(std::format("{:.1f} cm2 hangs over the air (red): it needs supports, or turn it (Lay flat).", hang));
else good("Nothing hangs over the air");
ImGui::Checkbox("Show overhangs in red", &showOverhangs_);
ImGui::SetNextItemWidth(w);
ImGui::SliderFloat("##hang", &overhangDeg_, 20.0f, 80.0f, "steeper than %.0f degrees");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("How far from upright a face can lean over before it needs support (45 for most printers)");
int thin = 0;
float thinnest = 1e30f;
for (Object* o : targets) thin += printInfo(*o).thinFaces, thinnest = std::min(thinnest, printInfo(*o).thinnest);
if (thin) bad(std::format("{} face{} on walls thinner than {:.1f} mm (yellow), the thinnest {:.2f} mm: it may not print there, or break.", thin, thin == 1 ? "" : "s", minWall_ * 1000, thinnest * 1000));
else if (all.openEdges == 0) good(std::format("Walls {:.1f} mm thick or more", minWall_ * 1000).c_str());
ImGui::Checkbox("Show thin walls in yellow", &showThin_);
float mm = minWall_ * 1000;
ImGui::SetNextItemWidth(w);
if (ImGui::SliderFloat("##minwall", &mm, 0.2f, 5.0f, "thinner than %.1f mm")) minWall_ = mm / 1000;
if (ImGui::IsItemHovered()) ImGui::SetTooltip("The thinnest wall your printer makes well: about twice the nozzle (0.8 mm for a 0.4 mm nozzle)");
ImGui::SeparatorText("Layers");
{
const int layers = std::max(1, int(std::ceil((hi.z - std::max(lo.z, 0.0f)) / layerHeight_ - 1e-4f)));
ImGui::Checkbox("Show one layer (as the printer makes it)", &showLayer_);
if (showLayer_) {
layer_ = std::clamp(layer_, 1, layers);
ImGui::SetNextItemWidth(w);
ImGui::SliderInt("##layer", &layer_, 1, layers, std::format("layer %d of {}", layers).c_str());
float mm = layerHeight_ * 1000;
ImGui::SetNextItemWidth(w);
if (ImGui::SliderFloat("##lh", &mm, 0.04f, 0.6f, "%.2f mm a layer")) layerHeight_ = std::max(mm, 0.01f) / 1000;
ImGui::TextDisabled("At %.2f mm up", (float(layer_) - 0.5f) * layerHeight_ * 1000);
} else
ImGui::TextDisabled("%d layers of %.2f mm", layers, layerHeight_ * 1000);
}
ImGui::SeparatorText("Material");
ImGui::SetNextItemWidth(w);
ImGui::Combo("##material", &material_, [](void*, int i) { return kMaterials[i].name; }, nullptr, int(std::size(kMaterials)));
ImGui::SetNextItemWidth(w);
ImGui::SliderFloat("##infill", &infill_, 0.0f, 100.0f, "infill %.0f%%");
// (walls about 1.2 mm thick all round, the inside filled as much as the infill says)
const float vol = all.volume * 1e6f, walls = std::min(vol, all.area * 1e4f * 0.12f);
const float used = walls + (vol - walls) * infill_ / 100.0f, grams = used * kMaterials[material_].density;
ImGui::Text("Holds %.1f cm3", vol);
ImGui::Text("About %.0f g, %.1f m of 1.75 mm filament", grams, used / (3.14159f * 0.0875f * 0.0875f) / 100.0f);
ImGui::TextDisabled("(a guess: the slicer knows best)");
}
} // namespace fryapp

799
src/app_scripts.cpp Normal file
View file

@ -0,0 +1,799 @@
// Fry3DX: scripts, your own materials, plug-ins, the windows that go with them.
#include "app.h"
using namespace fry;
namespace stdfs = std::filesystem;
namespace fryapp {
// ───────────────────────────── scripts ─────────────────────────────
auto App::scriptLine(std::string text, bool error) -> void {
size_t at = 0;
for (size_t nl; (nl = text.find('\n', at)) != std::string::npos; at = nl + 1) scriptLog_.push_back({text.substr(at, nl - at), error});
scriptLog_.push_back({text.substr(at), error});
if (scriptLog_.size() > 2000) scriptLog_.erase(scriptLog_.begin(), scriptLog_.begin() + 500);
scriptLogToBottom_ = true;
}
// "little_town.lua" -> "little town" (a script's name in the list).
auto App::scriptName(const std::string& path) -> std::string {
std::string n = stdfs::path(fileName(path)).stem().string();
std::replace(n.begin(), n.end(), '_', ' ');
return n.empty() ? "Script" : n;
}
// Adds a script to the file's list and shows it (the same one again: just shows it).
auto App::addScript(std::string name, std::string code) -> void {
for (size_t i = 0; i < scene_.scripts.size(); ++i)
if (scene_.scripts[i].name == name && scene_.scripts[i].code == code) return void((script_ = i, showScript_ = true));
const std::string base = name;
for (int n = 2; std::any_of(scene_.scripts.begin(), scene_.scripts.end(), [&](const fry::SceneScript& s) { return s.name == name; }); ++n)
name = std::format("{} {}", base, n);
scene_.scripts.push_back({name, std::move(code)});
script_ = scene_.scripts.size() - 1;
showScript_ = true, dirty_ = true;
}
// Runs the Script panel's script: one undo step; if it stops with an error, everything it did
// is taken back.
auto App::runScriptNow() -> void {
const fry::SceneScript* sc = currentScript();
if (!sc) return say("No script to run: Script > New script, or open one");
if (mode_ == Mode::Edit) leaveEdit();
const std::string name = sc->name, code = sc->code; // (the list itself may change while it runs)
pushUndo();
bool frame = false;
fry::ScriptHost host{&scene_, &selected_, &active_, [this](const std::string& t) { scriptLine(t); }, [&] { frame = true; }};
const fry::ScriptResult r = fry::runScript(host, code, name);
finishRun(r, name, frame);
}
// After a script or plug-in command: kept as one undo step, or all taken back on an error.
auto App::finishRun(const fry::ScriptResult& r, const std::string& name, bool frame) -> void {
touchAll();
if (r.ok && frame) frameAll();
if (!r.ok) {
takeBack();
scriptLine(r.error, true);
showScript_ = true;
return say(name + " stopped (nothing it did was kept): " + r.error.substr(0, r.error.find('\n')));
}
dirty_ = true;
say(r.objectsMade ? std::format("{} ran: {} object{} made (Ctrl+Z takes it all back)", name, r.objectsMade, r.objectsMade == 1 ? "" : "s")
: std::format("{} ran (Ctrl+Z takes it back)", name));
}
auto App::openScript(const std::string& path, const std::string& shownName) -> void {
std::ifstream in(path, std::ios::binary);
if (!in) return say("Couldn't open " + shownName);
addScript(scriptName(shownName), std::string(std::istreambuf_iterator<char>(in), {}));
scriptPath_ = isUri(path) ? std::string() : path;
say("Opened " + shownName + ": it's kept in this file now. F5 runs it");
}
auto App::saveScript(std::string file) -> void {
const fry::SceneScript* sc = currentScript();
if (!sc) return;
if (isUri(file)) {
const std::string temp = tempFile("script.lua");
std::string error;
const bool ok = writeFileSafely(temp, sc->code.data(), sc->code.size(), &error) && toUri(temp, file);
return say(ok ? "Saved " + fileName(file) : "Couldn't write the file you picked");
}
if (stdfs::path(file).extension() != ".lua") file += ".lua";
std::string error;
if (!writeFileSafely(file, sc->code.data(), sc->code.size(), &error)) return say("Couldn't save the script: " + error);
scriptPath_ = file;
say("Saved " + fileName(file));
}
// The scripts that come with it (content/Fry3DX/scripts), as menu items that add one.
auto App::exampleScripts() -> void {
if (!ImGui::BeginMenu("Examples")) return;
exampleItems(false);
ImGui::EndMenu();
}
auto App::exampleItems(bool selectable) -> void {
for (const std::string& f : keel::fs::list("scripts", ".lua")) {
const std::string name = scriptName(f);
if (selectable ? ImGui::Selectable(name.c_str()) : ImGui::MenuItem(name.c_str()))
if (auto text = keel::fs::readText(f)) addScript(name, *text);
}
}
auto App::loadOwnMaterials() -> void {
std::ifstream in(ownMaterialsPath(), std::ios::binary);
if (in) parseOwnMaterials(std::string(std::istreambuf_iterator<char>(in), {}));
}
auto App::saveOwnMaterials() -> void {
const std::string text = ownMaterialsText();
std::string error;
if (!writeFileSafely(ownMaterialsPath(), text.data(), text.size(), &error)) say("Couldn't keep your materials: " + error);
}
auto App::pluginsOff() const -> std::set<std::string> {
std::set<std::string> off;
std::istringstream in(fry_pluginsoff.str());
for (std::string f; std::getline(in, f, '|');)
if (!f.empty()) off.insert(f);
return off;
}
auto App::setPluginOn(const std::string& file, bool on) -> void {
std::set<std::string> off = pluginsOff();
if (on) off.erase(file);
else off.insert(file);
std::string text;
for (const std::string& f : off) text += (text.empty() ? "" : "|") + f;
fry_pluginsoff.set(text);
loadPlugins();
}
auto App::loadPlugins() -> void {
for (const PluginEntry& e : plugins_) fry::clearOwnMaterials(e.file);
plugins_.clear();
std::error_code ec;
stdfs::create_directories(pluginsFolder(), ec);
std::vector<std::pair<PluginEntry, std::string>> found; // (and its code)
for (const std::string& f : keel::fs::list("plugins", ".lua"))
if (auto text = keel::fs::readText(f)) found.push_back({PluginEntry{f, fileName(f), "", true}, *text});
std::vector<stdfs::path> mine;
for (const auto& d : stdfs::directory_iterator(pluginsFolder(), ec))
if (d.path().extension() == ".lua") mine.push_back(d.path());
std::sort(mine.begin(), mine.end());
for (const stdfs::path& f : mine) {
std::ifstream in(f, std::ios::binary);
found.push_back({PluginEntry{f.string(), f.filename().string()}, std::string(std::istreambuf_iterator<char>(in), {})});
}
const std::set<std::string> off = pluginsOff();
fry::Scene scratch; // (loading a plug-in can't touch the open file: only its commands, when run, do)
std::set<uint32_t> none;
uint32_t noActive = 0;
for (auto& [e, code] : found) {
e.on = !off.count(e.file);
if (e.on) {
e.p = std::make_unique<fry::Plugin>(e.file);
fry::ScriptHost host{&scratch, &none, &noActive, [this, f = e.file](const std::string& t) { scriptLine(f + ": " + t); }, {}};
if (!e.p->load(host, code, &e.error)) {
fry::clearOwnMaterials(e.file);
scriptLine(e.error, true);
keel::log::warn("Plug-in {} didn't load: {}", e.file, e.error.substr(0, e.error.find('\n')));
}
}
plugins_.push_back(std::move(e));
}
size_t loaded = 0;
for (const PluginEntry& e : plugins_) loaded += e.p && e.error.empty();
if (loaded) keel::log::info("Plug-ins: {} loaded", loaded);
}
auto App::copyPlugin(const PluginEntry& e) -> void {
const auto text = keel::fs::readText(e.path);
if (!text) return say("Couldn't read " + e.file);
std::error_code ec;
stdfs::create_directories(pluginsFolder(), ec);
const stdfs::path to = stdfs::path(pluginsFolder()) / ("my_" + e.file);
if (!stdfs::exists(to)) std::ofstream(to, std::ios::binary) << *text;
setPluginOn(e.file, false); // (loads them again)
say("Your copy: " + to.string() + " (Open the plug-ins folder, change it, Load them again)");
}
auto App::openPluginsFolder() -> void {
std::error_code ec;
stdfs::create_directories(pluginsFolder(), ec);
SDL_OpenURL(("file://" + pluginsFolder()).c_str());
}
auto App::pluginMenuItems(const char* menu) -> void {
bool first = true;
for (size_t i = 0; i < plugins_.size(); ++i)
if (pluginUsable(i))
for (size_t c = 0; c < plugins_[i].p->commands.size(); ++c)
if (plugins_[i].p->commands[c].menu == menu) {
if (std::exchange(first, false)) ImGui::SeparatorText("Plug-ins");
pluginMenuItem(i, c);
}
}
auto App::pluginMenuItem(size_t i, size_t c) -> void {
const fry::PluginCommand& cmd = plugins_[i].p->commands[c];
if (ImGui::MenuItem((cmd.name + (cmd.settings.empty() ? "" : "...")).c_str())) {
if (cmd.settings.empty()) runPluginCommand(i, c, {});
else {
pendingCommand_ = PendingCommand{i, c, {}};
for (const auto& [n, v] : cmd.settings) pendingCommand_->values.push_back(v);
}
}
if (!cmd.help.empty() && ImGui::IsItemHovered()) ImGui::SetTooltip("%s (%s)", cmd.help.c_str(), plugins_[i].p->name.c_str());
}
auto App::runPluginCommand(size_t i, size_t c, const std::vector<double>& values) -> void {
if (!pluginUsable(i) || c >= plugins_[i].p->commands.size()) return;
if (mode_ == Mode::Edit) leaveEdit();
pushUndo();
bool frame = false;
fry::ScriptHost host{&scene_, &selected_, &active_, [this](const std::string& t) { scriptLine(t); }, [&] { frame = true; }};
const fry::ScriptResult r = plugins_[i].p->run(host, c, values);
finishRun(r, plugins_[i].p->commands[c].name, frame);
}
// A plug-in command's settings, asked for before it runs.
auto App::commandForm() -> void {
if (!pendingCommand_) return;
if (!pluginUsable(pendingCommand_->plugin)) return pendingCommand_.reset();
const fry::PluginCommand& cmd = plugins_[pendingCommand_->plugin].p->commands[pendingCommand_->command];
const std::string title = cmd.name + "###plugincommand";
if (!ImGui::IsPopupOpen("###plugincommand")) ImGui::OpenPopup(title.c_str());
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
ImGui::SetNextWindowSize(ImVec2(340, 0));
if (!ImGui::BeginPopupModal(title.c_str(), nullptr, ImGuiWindowFlags_NoResize)) return;
if (!cmd.help.empty()) ImGui::TextWrapped("%s", cmd.help.c_str());
ImGui::TextDisabled("From %s", plugins_[pendingCommand_->plugin].p->name.c_str());
for (size_t k = 0; k < cmd.settings.size(); ++k) {
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * 0.55f);
std::string label = cmd.settings[k].first;
if (!label.empty()) label[0] = char(std::toupper(static_cast<unsigned char>(label[0])));
std::replace(label.begin(), label.end(), '_', ' ');
ImGui::InputDouble(label.c_str(), &pendingCommand_->values[k], 0, 0, "%.4g");
}
ImGui::Spacing();
pushAccent();
const bool go = ImGui::Button("Make it", ImVec2(120, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter, false);
popAccent();
ImGui::SameLine();
const bool cancel = ImGui::Button("Cancel", ImVec2(120, 0));
if (go || cancel) {
ImGui::CloseCurrentPopup();
const PendingCommand p = *pendingCommand_;
pendingCommand_.reset();
if (go) runPluginCommand(p.plugin, p.command, p.values);
}
ImGui::EndPopup();
}
auto App::pluginsWindow() -> void {
using keel::ui::Role;
ImGui::SetNextWindowSize(ImVec2(520, 520), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_FirstUseEver, ImVec2(0.5f, 0.5f));
if (!ImGui::Begin("Plug-ins", &showPlugins_, ImGuiWindowFlags_NoDocking)) return ImGui::End();
ImGui::PushStyleColor(ImGuiCol_Text, roleColor(Role::TextSecondary));
ImGui::TextWrapped("Plug-ins are scripts that stay loaded: they add commands to the menus and materials to the picker. "
"Put a .lua file in the plug-ins folder and load them again.");
ImGui::PopStyleColor();
if (ImGui::Button("Open the plug-ins folder")) openPluginsFolder();
ImGui::SameLine();
if (ImGui::Button("Load them again")) loadPlugins();
ImGui::SameLine();
if (ImGui::Button("How to write one")) showScript_ = showScriptHelp_ = true;
ImGui::Spacing();
if (plugins_.empty()) ImGui::TextDisabled("None yet.");
else {
ImGui::SetNextItemWidth(-1);
inputString("##filter", pluginFilter_, "Look for a plug-in...");
}
std::optional<std::pair<std::string, bool>> toggle;
for (size_t i = 0; i < plugins_.size(); ++i) {
const PluginEntry& e = plugins_[i];
if (!pluginFilter_.empty()) {
std::string all = lower(e.file + " " + (e.p ? e.p->name + " " + e.p->about : std::string()));
if (e.p)
for (const fry::PluginCommand& c : e.p->commands) all += " " + lower(c.name + " " + c.help);
if (e.p)
for (const fry::PluginTool& t : e.p->tools) all += " " + lower(t.name + " " + t.help);
if (all.find(lower(pluginFilter_)) == std::string::npos) continue;
}
ImGui::PushID(int(i));
ImGui::BeginChild("plugin", ImVec2(0, 0), ImGuiChildFlags_FrameStyle | ImGuiChildFlags_AutoResizeY);
bool on = e.on;
if (ImGui::Checkbox("##on", &on)) toggle = {e.file, on};
if (ImGui::IsItemHovered()) ImGui::SetTooltip(on ? "On: turn it off" : "Off: turn it on");
ImGui::SameLine();
ImGui::PushFont(keel::ui::font(keel::ui::Font::Bold), 0.0f);
ImGui::TextUnformatted(e.p ? e.p->name.c_str() : scriptName(e.file).c_str());
ImGui::PopFont();
ImGui::SameLine();
ImGui::TextDisabled("%s%s", e.file.c_str(), e.bundled ? " (comes with Fry3DX)" : "");
if (e.p && !e.p->about.empty()) ImGui::TextWrapped("%s", e.p->about.c_str());
if (!e.error.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, roleColor(Role::DangerText));
ImGui::TextWrapped("Didn't load: %s", e.error.c_str());
ImGui::PopStyleColor();
} else if (e.p) {
std::string adds;
for (const fry::PluginCommand& c : e.p->commands) adds += std::format("{}{} ({} menu)", adds.empty() ? "" : ", ", c.name, c.menu == "add" ? "Add" : c.menu == "object" ? "Object" : "Script > Plug-ins");
for (const fry::PluginTool& t : e.p->tools) adds += std::format("{}{} (a tool in the Toolbox)", adds.empty() ? "" : ", ", t.name);
size_t mats = 0;
for (const MaterialPreset& m : ownMaterials()) mats += m.from == e.file;
if (mats) adds += std::format("{}{} material{}", adds.empty() ? "" : ", ", mats, mats == 1 ? "" : "s");
ImGui::TextColored(roleColor(Role::TextTertiary), "Adds: %s", adds.empty() ? "nothing yet" : adds.c_str());
} else ImGui::TextDisabled("Turned off");
if (e.bundled) { // (to change one that comes with Fry3DX: your own copy, in your folder)
if (ImGui::SmallButton("Make my own copy to change")) copyPlugin(e);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Copies it into your plug-ins folder (as my_%s) and turns this one off", e.file.c_str());
}
ImGui::EndChild();
ImGui::PopID();
}
ImGui::End();
if (toggle) setPluginOn(toggle->first, toggle->second);
}
auto App::growString(ImGuiInputTextCallbackData* d) -> int {
if (d->EventFlag == ImGuiInputTextFlags_CallbackResize) {
auto* str = static_cast<std::string*>(d->UserData);
str->resize(size_t(d->BufTextLen));
d->Buf = str->data();
}
return 0;
}
auto App::scriptPanel() -> void {
using keel::ui::Role;
const ImGuiViewport* vp = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x + vp->WorkSize.x * 0.5f, vp->WorkPos.y + vp->WorkSize.y * 0.5f), ImGuiCond_FirstUseEver, ImVec2(0.5f, 0.5f));
ImGui::SetNextWindowSize(ImVec2(std::min(780.0f, vp->WorkSize.x * 0.55f), vp->WorkSize.y * 0.7f), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSizeConstraints(panelMin("Script"), ImVec2(FLT_MAX, FLT_MAX));
if (!ImGui::Begin("Script", &showScript_)) return ImGui::End();
// The file's scripts (saved in the .fry with it).
const float listW = std::min(180.0f, ImGui::GetContentRegionAvail().x * 0.3f);
ImGui::BeginChild("scripts", ImVec2(listW, 0));
ImGui::TextColored(roleColor(Role::TextTertiary), "In this file");
std::optional<size_t> remove, copy;
for (size_t i = 0; i < scene_.scripts.size(); ++i) {
ImGui::PushID(int(i));
if (ImGui::Selectable(scene_.scripts[i].name.c_str(), i == script_)) script_ = i;
if (ImGui::BeginPopupContextItem()) {
if (ImGui::MenuItem("Run it")) script_ = i, runScriptNow();
if (ImGui::MenuItem("Duplicate")) copy = i;
if (ImGui::MenuItem("Remove from the file")) remove = i;
ImGui::EndPopup();
}
ImGui::PopID();
}
if (scene_.scripts.empty()) ImGui::TextDisabled("None yet");
ImGui::Spacing();
if (ImGui::Button("+ New", ImVec2(-FLT_MIN, 0))) addScript("Script", kFirstScript);
if (ImGui::Button("Examples", ImVec2(-FLT_MIN, 0))) ImGui::OpenPopup("examples");
if (ImGui::BeginPopup("examples")) {
exampleItems(true);
ImGui::EndPopup();
}
if (ImGui::Button("Open...", ImVec2(-FLT_MIN, 0))) dialog(10);
ImGui::EndChild();
if (copy) addScript(scene_.scripts[*copy].name, scene_.scripts[*copy].code + "\n");
if (remove) {
scene_.scripts.erase(scene_.scripts.begin() + long(*remove));
if (script_ >= scene_.scripts.size()) script_ = scene_.scripts.empty() ? 0 : scene_.scripts.size() - 1;
dirty_ = true;
}
ImGui::SameLine();
ImGui::BeginGroup();
fry::SceneScript* sc = currentScript();
if (!sc) {
ImGui::Dummy(ImVec2(0, 40));
ImGui::TextWrapped("This file has no scripts yet. A script is Lua that builds or changes the scene; it's saved in the .fry "
"with the model.");
pushAccent();
if (ImGui::Button("+ New script")) addScript("Script", kFirstScript);
popAccent();
ImGui::SameLine();
if (ImGui::Button("What scripts can do")) addScript("Script", kFirstScript), showScriptHelp_ = true;
ImGui::EndGroup();
return ImGui::End();
}
pushAccent();
if (ImGui::Button("Run (F5)")) runScriptNow();
popAccent();
ImGui::SameLine();
ImGui::SetNextItemWidth(std::max(80.0f, ImGui::GetContentRegionAvail().x - 190));
if (inputString("##name", sc->name)) dirty_ = true;
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Its name (click to change)");
ImGui::SameLine();
if (ImGui::Button("Save as .lua")) dialog(11);
ImGui::SameLine();
if (ImGui::Button(showScriptHelp_ ? "Hide help" : "?")) showScriptHelp_ = !showScriptHelp_;
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Everything a script can use");
const float logH = std::max(90.0f, ImGui::GetContentRegionAvail().y * 0.22f);
ImGui::PushFont(keel::ui::font(keel::ui::Font::Mono), 0.0f);
if (showScriptHelp_) {
ImGui::BeginChild("help", ImVec2(0, ImGui::GetContentRegionAvail().y * 0.45f), ImGuiChildFlags_FrameStyle);
ImGui::TextUnformatted(fry::scriptHelp());
ImGui::EndChild();
}
const ImVec2 editor(-FLT_MIN, std::max(120.0f, ImGui::GetContentRegionAvail().y - logH - ImGui::GetStyle().ItemSpacing.y));
if (ImGui::InputTextMultiline("##code", sc->code.data(), sc->code.capacity() + 1, editor,
ImGuiInputTextFlags_AllowTabInput | ImGuiInputTextFlags_CallbackResize, growString, &sc->code))
dirty_ = true;
const bool typing = ImGui::IsItemActive();
ImGui::BeginChild("log", ImVec2(-FLT_MIN, 0), ImGuiChildFlags_FrameStyle);
if (scriptLog_.empty()) ImGui::TextDisabled("What it prints shows here.");
for (const auto& [line, error] : scriptLog_) {
if (error) ImGui::PushStyleColor(ImGuiCol_Text, roleColor(keel::ui::Role::DangerText));
ImGui::TextUnformatted(line.c_str());
if (error) ImGui::PopStyleColor();
}
if (std::exchange(scriptLogToBottom_, false)) ImGui::SetScrollHereY(1.0f);
ImGui::EndChild();
ImGui::PopFont();
ImGui::EndGroup();
if (typing && ImGui::GetIO().KeyCtrl && (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false))) runScriptNow();
if (typing && ImGui::IsKeyPressed(ImGuiKey_F5, false)) runScriptNow();
ImGui::End();
}
auto App::helpWindow() -> void {
ImGui::SetNextWindowSize(ImVec2(580, 620), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Keys and mouse", &showHelp_)) return ImGui::End();
auto rows = [](const char* title, std::initializer_list<std::pair<const char*, const char*>> list) {
ImGui::SeparatorText(title);
if (!ImGui::BeginTable(title, 2)) return;
ImGui::TableSetupColumn("k", ImGuiTableColumnFlags_WidthFixed, 180);
for (auto [k, what] : list) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextColored(accentText(), "%s", k);
ImGui::TableNextColumn();
ImGui::TextWrapped("%s", what);
}
ImGui::EndTable();
};
rows("Finding things", {{"F3 or Ctrl+Space", "Search for any command by name and run it"}, {"Space (held)", "The tools round the mouse: point at one, let go"},
{"Shift+Space (held)", "The views round the mouse"}});
myKeysTable();
rows("Looking around", {{"Right drag", "Orbit round what you look at"}, {"Middle drag / Shift+Right drag", "Pan"}, {"Wheel", "Zoom (towards the mouse)"},
{"Orbit / Pan / Zoom buttons", "Drag them (touchpads)"}, {"F", "Frame the selection"}, {"Home", "Frame everything"},
{"Numpad 1 / 3 / 7", "Front / side / top (Shift: the opposite)"}, {"Numpad 5", "Flat view / 3D"},
{"Z / Shift+Z", "Solid or Rendered / Wireframe"}, {"Alt+Z", "X-ray: see and pick through"}});
rows("Tools", {{"Q", "Select"}, {"W", "Move"}, {"E", "Rotate"}, {"R", "Scale"}, {"D", "Draw shape"}, {"X", "Extrude (shape editing)"}, {"I", "Inset (shape editing)"},
{"B", "Round edges (shape editing)"}, {"C", "Loop cut (shape editing)"}, {"K", "Knife (shape editing)"}, {"O", "Hole"},
{"Ctrl (held)", "Snap while dragging (or don't, if Snap is on)"}});
rows("Tabs", {{"Ctrl+1", "Model"}, {"Ctrl+2", "Paint"}, {"Ctrl+3", "Layout"}, {"Ctrl+4", "Render"}, {"Ctrl+5", "Print"}});
rows("Layout", {{"Q W E R", "select, move, turn, size frames"}, {"Tab", "frames or corners"}, {"Ctrl+A", "all frames"}});
rows("Print", {{"M", "measure"}, {"L", "lay a face flat on the bed"}, {"B", "put it on the bed"}});
rows("Painting", {{"B / E / G / I", "Brush / eraser / fill / pick a colour"}, {"Alt+click", "Pick a colour"}, {"[ / ]", "Smaller / bigger brush"},
{"Ctrl+V", "Paste a picture onto it"}, {"Enter / Esc", "Place the picture / cancel"}, {"Shift + corner drag", "Resize keeping its shape"},
{"Wheel, middle or right drag", "Zoom and move the Picture panel"}});
rows("Sculpting", {{"Tab (Paint tab)", "Colour or Sculpt"}, {"1 - 6", "Raise, puff out, smooth, flatten, pinch, grab"}, {"Ctrl + brush", "The other way (dig, shrink, push apart)"},
{"Shift + brush", "Smooth"}, {"X", "The same on both sides"}, {"[ / ]", "Smaller / bigger brush"}});
rows("Selecting", {{"Click", "Select"}, {"Drag", "Select with a box, brush or lasso (Tool options)"}, {"Shift + click", "Objects: add to the selection. Shape: a whole loop"},
{"Ctrl + click", "Objects: take away. Shape: add or take one"}, {"Shift+Ctrl + click an edge", "The ring across it (shape)"}, {"Alt+click / double-click", "A whole loop (shape editing)"}, {"Ctrl+A / Alt+A", "All / none"},
{"Ctrl+I", "Invert"}, {"Ctrl + / Ctrl -", "More / less"}, {"L", "Everything joined to it"}, {"Esc", "Let go / cancel / leave shape editing"}});
rows("Objects and shape", {{"Tab / double-click", "Edit an object's shape"}, {"1 / 2 / 3", "Points / edges / faces (shape editing)"}, {"M", "Merge points"},
{"P", "Selected faces into their own object"}, {"Shift + Scale drag", "Scale both sides (from the middle)"}, {"Ctrl+G", "Group into a folder"},
{"Ctrl+Shift + / -", "Add together / take the others out (last picked kept)"}, {"Del", "Delete"}, {"Ctrl+D", "Duplicate"}, {"Ctrl+J", "Join objects into one"},
{"H / Alt+H", "Hide / show all"}, {"/ or Shift+H", "Show only the selected (again: all)"},
{"Shift+O", "Soft selection: near points follow (wheel while dragging: how far)"}, {"Alt+S", "Push points out / pull in (shape)"}});
rows("Files", {{"Ctrl+N / O / S", "New / open / save"}, {"Ctrl+Shift+S", "Save as"}, {"Ctrl+E", "Export (GLB, glTF, FBX, OBJ, 3MF, STL, PLY)"}, {"F12", "Save a picture of the view"},
{"Ctrl+Z / Ctrl+Y", "Undo / redo"}});
ImGui::End();
}
// Keys of your own: any command on any key (they come before the usual ones).
auto App::myKeysTable() -> void {
static char filter[64] = {};
if (!ImGui::CollapsingHeader("Your own keys")) return;
if (commands_.empty()) buildCommands();
ImGui::TextWrapped("Give any command a key: click Set, then press the key (with Ctrl, Shift or Alt if you like). Your keys come before the usual ones.");
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##kf", "Find a command", filter, sizeof filter);
if (!settingKey_.empty()) {
ImGui::TextColored(accentText(), "Press the key for it now (Esc: never mind)");
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) settingKey_.clear();
else if (const auto c = fry::pressedChord()) {
std::erase_if(myKeys_, [&](const auto& kv) { return kv.second == *c; }); // (a key does one thing)
myKeys_[settingKey_] = *c;
saveMyKeys();
settingKey_.clear();
}
}
if (!ImGui::BeginTable("mykeys", 3, ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY, ImVec2(0, 260))) return;
ImGui::TableSetupColumn("what");
ImGui::TableSetupColumn("key", ImGuiTableColumnFlags_WidthFixed, 130);
ImGui::TableSetupColumn("do", ImGuiTableColumnFlags_WidthFixed, 96);
for (const fry::Command& c : commands_) {
if (filter[0] && !fry::commandScore(c, filter)) continue;
ImGui::PushID(c.id.c_str());
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextUnformatted(c.name.c_str());
ImGui::TableNextColumn();
const auto mine = myKeys_.find(c.id);
if (mine != myKeys_.end()) ImGui::TextColored(accentText(), "%s", fry::chordName(mine->second).c_str());
else ImGui::TextDisabled("%s", c.keys.c_str());
ImGui::TableNextColumn();
if (ImGui::SmallButton(settingKey_ == c.id ? "..." : "Set")) settingKey_ = c.id;
if (mine != myKeys_.end()) {
ImGui::SameLine();
if (ImGui::SmallButton("Clear")) myKeys_.erase(c.id), saveMyKeys();
}
ImGui::PopID();
}
ImGui::EndTable();
}
// Each picked object (or all, in the Print tab with none picked) made ready to print.
auto App::repairSelected() -> void {
std::vector<Object*> targets;
if (tab_ == Tab::Print) targets = printTargets();
else
for (Object& o : scene_.objects)
if (selected_.count(o.id)) targets.push_back(&o);
std::erase_if(targets, [](const Object* o) { return o->locked; });
if (targets.empty()) return say("Pick what to repair first");
if (mode_ == Mode::Edit) leaveEdit();
pushUndo();
fry::Repair all;
int still = 0;
for (Object* o : targets) {
const fry::Repair r = fry::repairForPrint(o->mesh);
all.joinedPoints += r.joinedPoints, all.emptyFaces += r.emptyFaces, all.doubledFaces += r.doubledFaces, all.turnedFaces += r.turnedFaces, all.closedHoles += r.closedHoles;
if (r.total()) handMade(*o), touched(o->id);
still += !checkForPrint(o->mesh).closed();
}
if (!all.total()) return takeBack(), say(still ? "Nothing it can repair by itself: look at what the Print tab says" : "Nothing to repair: ready to print");
std::vector<std::string> did;
if (all.joinedPoints) did.push_back(std::format("{} points joined", all.joinedPoints));
if (all.emptyFaces) did.push_back(std::format("{} empty faces gone", all.emptyFaces));
if (all.doubledFaces) did.push_back(std::format("{} doubled faces gone", all.doubledFaces));
if (all.turnedFaces) did.push_back(std::format("{} faces turned out", all.turnedFaces));
if (all.closedHoles) did.push_back(std::format("{} holes closed", all.closedHoles));
std::string text;
for (const std::string& d : did) text += (text.empty() ? "" : ", ") + d;
say("Repaired: " + text + (still ? std::format(". {} still not closed (see the Print tab)", still) : ". Ready to print"));
}
// Crease (Shift+E): the picked edges (or those between picked points / round picked faces)
// kept sharp when the shape's smoothed; again: not.
auto App::creaseSelected() -> void {
Object* o = editing();
if (!o) return say("Crease works on a shape being edited (Tab)");
std::set<std::pair<int, int>> picked;
if (elem_ == Elem::Edge)
for (auto [a, b] : selEdges_) picked.insert({std::min(a, b), std::max(a, b)});
else {
const std::set<int> pts = selectedPoints();
for (const auto& e : adjacencyOf(*o, o->mesh).edges)
if (pts.count(e[0]) && pts.count(e[1])) picked.insert({e[0], e[1]});
}
if (picked.empty()) return say("Pick the edges to crease first (Edges, 2)");
pushUndo();
const bool all = std::all_of(picked.begin(), picked.end(), [&](const auto& e) { return o->creases.count(e) > 0; });
for (const auto& e : picked) all ? (void)o->creases.erase(e) : (void)o->creases.insert(e);
touched(o->id);
say(all ? std::format("{} edge{} smooth again", picked.size(), picked.size() == 1 ? "" : "s")
: std::format("{} edge{} creased: smoothing keeps {} sharp{}", picked.size(), picked.size() == 1 ? "" : "s", picked.size() == 1 ? "it" : "them",
o->smoothLevels ? "" : " (turn on Smooth out to see)"));
}
auto App::startSlide() -> void {
Object* o = editing();
if (!o) return say("Slide works on a shape being edited (Tab)");
const std::set<int> pts = selectedPoints();
if (pts.empty()) return say("Pick points, edges or faces to slide first");
std::vector<std::set<int>> near(o->mesh.verts.size());
for (const auto& e : adjacencyOf(*o, o->mesh).edges)
if (pts.count(e[0]) != pts.count(e[1])) near[size_t(e[0])].insert(e[1]), near[size_t(e[1])].insert(e[0]);
slide_ = {};
for (int v : pts)
if (!near[size_t(v)].empty()) slide_.pts.push_back(v), slide_.rails.emplace_back(near[size_t(v)].begin(), near[size_t(v)].end());
if (slide_.pts.empty()) return say("Nothing for those to slide along (every edge of theirs is picked too)");
pushUndo();
slide_.active = true, slide_.object = o->id, slide_.from = o->mesh, slide_.start = ImGui::GetIO().MousePos;
say("Sliding: move the mouse along the edges, click to keep it (Esc: as it was)");
}
auto App::cancelSlide() -> void {
if (!slide_.active) return;
slide_.active = false;
if (Object* o = find(slide_.object)) o->mesh = slide_.from, touched(o->id);
takeBack();
say("Slide put back");
}
auto App::slideFrame(const ImGuiIO& io) -> void {
Object* o = find(slide_.object);
if (!o || mode_ != Mode::Edit) {
if (o) o->mesh = slide_.from, touched(o->id);
return void(slide_.active = false);
}
if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) return cancelSlide(); // (Esc: onEscape)
const glm::mat4 m = objectMatrix(*o);
const ImVec2 d(io.MousePos.x - slide_.start.x, io.MousePos.y - slide_.start.y);
const float dl = std::hypot(d.x, d.y);
float shown = 0;
o->mesh.verts = slide_.from.verts;
if (dl > 1) {
for (size_t i = 0; i < slide_.pts.size(); ++i) {
const glm::vec3 p = slide_.from.verts[size_t(slide_.pts[i])];
ImVec2 sp;
if (!project(glm::vec3(m * glm::vec4(p, 1.0f)), sp)) continue;
// (the edge whose way on the screen is most the mouse's, and how far along it the mouse has gone)
float best = 0.2f, t = 0;
int to = -1;
for (int r : slide_.rails[i]) {
ImVec2 sr;
if (!project(glm::vec3(m * glm::vec4(slide_.from.verts[size_t(r)], 1.0f)), sr)) continue;
const ImVec2 e(sr.x - sp.x, sr.y - sp.y);
const float el = std::hypot(e.x, e.y);
if (el < 1) continue;
const float along = (e.x * d.x + e.y * d.y) / el; // (pixels the mouse went that way)
if (const float k = along / dl; k > best) best = k, to = r, t = std::clamp(along / el, 0.0f, 0.98f);
}
if (to >= 0) o->mesh.verts[size_t(slide_.pts[i])] = glm::mix(p, slide_.from.verts[size_t(to)], t), shown = std::max(shown, t);
}
}
if (liveMirror_) mirrorEdits(*o, slide_.from);
touched(o->id);
message_ = std::format("Sliding {:.0f}% of the way along (click: keep, Esc: as it was)", shown * 100), messageAt_ = now();
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
slide_.active = false;
changedShape(*o, true);
say(std::format("Slid {} point{}", slide_.pts.size(), slide_.pts.size() == 1 ? "" : "s"));
}
}
// Looking straight at a reference picture's plane (the front picture from the front or the back...).
auto App::lookingAt(Axis a) const -> bool {
switch (a) {
case Axis::Front: return view_ == View::Front || view_ == View::Back;
case Axis::Top: return view_ == View::Top || view_ == View::Bottom;
default: return view_ == View::Left || view_ == View::Right;
}
}
auto App::resetLayout() -> void {
layoutAgain_ = true, layoutDone_ = false;
say("The panels are back where they started");
}
auto App::workspacesWindow() -> void {
ImGui::SetNextWindowSize(ImVec2(360, 0), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Workspaces", &showWorkspaces_, ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_AlwaysAutoResize)) return ImGui::End();
ImGui::TextWrapped("The panels as they are now, kept under a name: one for modelling, one for painting, one for a small screen...");
ImGui::SetNextItemWidth(220);
ImGui::InputText("##wname", workspaceName_, sizeof workspaceName_);
ImGui::SameLine();
if (ImGui::Button("Keep") && workspaceName_[0]) {
std::error_code ec;
stdfs::create_directories(workspaceDir(), ec);
size_t size = 0;
const char* ini = ImGui::SaveIniSettingsToMemory(&size);
std::ofstream(workspaceDir() + fileSafe(workspaceName_) + ".ini", std::ios::binary).write(ini, std::streamsize(size));
say(std::format("Workspace \"{}\" kept", workspaceName_));
}
ImGui::SeparatorText("Kept");
if (ImGui::Selectable("As it started (the usual)")) resetLayout();
std::error_code ec;
std::vector<stdfs::path> kept;
for (const auto& e : stdfs::directory_iterator(workspaceDir(), ec))
if (e.path().extension() == ".ini") kept.push_back(e.path());
std::sort(kept.begin(), kept.end());
for (const stdfs::path& f : kept) {
ImGui::PushID(f.string().c_str());
if (ImGui::Selectable(f.stem().string().c_str(), false, ImGuiSelectableFlags_AllowOverlap, ImVec2(250, 0))) {
std::ifstream in(f, std::ios::binary);
pendingLayout_.assign(std::istreambuf_iterator<char>(in), {});
say("Workspace: " + f.stem().string());
}
ImGui::SameLine(270);
if (ImGui::SmallButton("Delete")) stdfs::remove(f, ec);
ImGui::PopID();
}
ImGui::End();
}
auto App::fileSafe(std::string n) -> std::string {
for (char& c : n)
if (std::strchr("\\/:*?\"<>|", c)) c = '_';
return n;
}
// The steps that can be undone, oldest first: a click goes back (or on) to just after it.
auto App::historyWindow() -> void {
ImGui::SetNextWindowSize(ImVec2(340, 420), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Undo history", &showHistory_)) return ImGui::End();
int back = 0, on = 0; // (how many undos / redos the click asks for)
if (ImGui::Selectable("The start", undo_.empty())) back = int(undo_.size());
for (size_t i = 0; i < undo_.size(); ++i) {
const std::string& l = undo_[i].label;
ImGui::PushID(int(i));
if (ImGui::Selectable(l.empty() ? "A change" : l.c_str(), i + 1 == undo_.size())) back = int(undo_.size() - 1 - i);
ImGui::PopID();
}
for (size_t i = redo_.size(); i-- > 0;) { // (what redo would bring back, greyed)
ImGui::PushID(int(10000 + i));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled));
const std::string& l = redo_[i].label;
if (ImGui::Selectable(l.empty() ? "A change (undone)" : (l + " (undone)").c_str())) on = int(redo_.size() - i);
ImGui::PopStyleColor();
ImGui::PopID();
}
if (undo_.size() >= 200) ImGui::TextDisabled("(the 200 latest steps are kept)");
ImGui::End();
for (int i = 0; i < back; ++i) undo();
for (int i = 0; i < on; ++i) redo();
}
auto App::aboutWindow() -> void {
using keel::ui::Role;
const float w = 460.0f;
ImGui::SetNextWindowSize(ImVec2(w, 0), ImGuiCond_Always);
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(28, 24));
const bool open = ImGui::Begin("About Fry3DX", &showAbout_, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking);
ImGui::PopStyleVar();
if (!open) return ImGui::End();
auto centred = [](const char* text, ImVec4 colour) {
ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ImGui::CalcTextSize(text).x) * 0.5f);
ImGui::TextColored(colour, "%s", text);
};
// The logo, warmed from behind by the stove.
const float logo = 128.0f;
const ImVec2 at(ImGui::GetWindowPos().x + (ImGui::GetWindowWidth() - logo) * 0.5f, ImGui::GetCursorScreenPos().y + 4);
ImDrawList* dl = ImGui::GetWindowDrawList();
const ImVec2 mid(at.x + logo * 0.5f, at.y + logo * 0.62f);
for (int i = 16; i >= 1; --i) dl->AddCircleFilled(mid, logo * (0.3f + 0.04f * float(i)), keel::ui::accent(0.018f), 64);
ImGui::SetCursorScreenPos(at);
logoImage(logo);
ImGui::Dummy(ImVec2(0, 6));
ImGui::PushFont(keel::ui::font(keel::ui::Font::Title), 30.0f);
centred("Fry3DX", roleColor(Role::Text));
ImGui::PopFont();
centred("Frying 3D Extreme", accentText());
centred("Version " FRY_VERSION, roleColor(Role::TextSecondary));
ImGui::Dummy(ImVec2(0, 8));
ImGui::PushStyleColor(ImGuiCol_Text, roleColor(Role::TextSecondary));
ImGui::TextWrapped("A 3D modeller that does what you'd expect: pick a tool, click and drag. Model, sculpt, paint, render and get it ready "
"to print, and write a script when clicking gets repetitive.");
ImGui::PopStyleColor();
ImGui::Dummy(ImVec2(0, 4));
// What it's running on (for a bug report: Copy).
const keel::GpuInfo gpu = r_->gpuInfo();
const auto& machine = keel::sys::machine();
const std::vector<std::pair<const char*, std::string>> rows = {
{"Version", FRY_VERSION}, {"Drawn with", r_->apiName()}, {"Graphics", gpu.name.empty() ? "?" : gpu.name}, {"System", machine.os}, {"Processor", machine.cpu},
{"Plug-ins", std::format("{} loaded", std::count_if(plugins_.begin(), plugins_.end(), [](const PluginEntry& e) { return e.p && e.error.empty(); }))}};
ImGui::BeginChild("details", ImVec2(0, 0), ImGuiChildFlags_FrameStyle | ImGuiChildFlags_AutoResizeY);
if (ImGui::BeginTable("details", 2, ImGuiTableFlags_SizingFixedFit)) {
ImGui::TableSetupColumn("k", ImGuiTableColumnFlags_WidthFixed, 90);
ImGui::TableSetupColumn("v", ImGuiTableColumnFlags_WidthStretch);
for (const auto& [k, v] : rows) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextColored(roleColor(Role::TextTertiary), "%s", k);
ImGui::TableNextColumn();
ImGui::TextWrapped("%s", v.c_str());
}
ImGui::EndTable();
}
ImGui::EndChild();
ImGui::Dummy(ImVec2(0, 4));
const float bw = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x * 2) / 3;
pushAccent();
if (ImGui::Button("Website", ImVec2(bw, 0))) SDL_OpenURL("https://nexoniarz.github.io/Fry3DX/");
popAccent();
ImGui::SameLine();
if (ImGui::Button("Source on GitHub", ImVec2(bw, 0))) SDL_OpenURL("https://github.com/Nexoniarz/Fry3DX");
ImGui::SameLine();
if (ImGui::Button("Copy details", ImVec2(bw, 0))) {
std::string text = "Fry3DX";
for (const auto& [k, v] : rows) text += std::format("\n{}: {}", k, v);
SDL_SetClipboardText(text.c_str());
say("The details are copied: paste them into a bug report");
}
ImGui::Dummy(ImVec2(0, 6));
ImGui::PushStyleColor(ImGuiCol_Text, roleColor(Role::TextTertiary));
ImGui::TextWrapped("Made by Nexoniarz, open source (Apache License 2.0). Built on SDL 3, Dear ImGui, Lua 5.4, GLM, zstd, stb, volk and VMA; "
"the look is Niveus.");
ImGui::PopStyleColor();
ImGui::End();
}
} // namespace fryapp

1620
src/app_view.cpp Normal file

File diff suppressed because it is too large Load diff