mirror of
https://github.com/Nexoniarz/Fry3DX.git
synced 2026-11-03 04:24:26 +00:00
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:
parent
1cda87b19a
commit
830876a18e
10 changed files with 9477 additions and 8697 deletions
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
10284
src/app.cpp
10284
src/app.cpp
File diff suppressed because it is too large
Load diff
809
src/app_model.cpp
Normal file
809
src/app_model.cpp
Normal 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
757
src/app_paint.cpp
Normal 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
2017
src/app_panels.cpp
Normal file
File diff suppressed because it is too large
Load diff
489
src/app_print.cpp
Normal file
489
src/app_print.cpp
Normal 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
799
src/app_scripts.cpp
Normal 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
1620
src/app_view.cpp
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue