From 830876a18e5faf7bccd2f2e7669cc3a9f63c1892 Mon Sep 17 00:00:00 2001 From: Nexoniarz Date: Wed, 21 Oct 2026 08:51:21 +0200 Subject: [PATCH] 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 --- engine/engine.cpp | 3 +- src/CMakeLists.txt | 8 +- src/app.cpp | 10284 +++++++----------------------------------- src/app.h | 1378 ++++++ src/app_model.cpp | 809 ++++ src/app_paint.cpp | 757 ++++ src/app_panels.cpp | 2017 +++++++++ src/app_print.cpp | 489 ++ src/app_scripts.cpp | 799 ++++ src/app_view.cpp | 1620 +++++++ 10 files changed, 9472 insertions(+), 8692 deletions(-) create mode 100644 src/app.h create mode 100644 src/app_model.cpp create mode 100644 src/app_paint.cpp create mode 100644 src/app_panels.cpp create mode 100644 src/app_print.cpp create mode 100644 src/app_scripts.cpp create mode 100644 src/app_view.cpp diff --git a/engine/engine.cpp b/engine/engine.cpp index 4b6b6d5..bdff3ca 100644 --- a/engine/engine.cpp +++ b/engine/engine.cpp @@ -379,7 +379,8 @@ int runClient(Game& game) { } const bool focused = SDL_GetWindowFlags(window.handle()) & SDL_WINDOW_INPUT_FOCUS; 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)); // 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). diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3c3d0ca..4dd7e33 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -22,12 +22,14 @@ target_compile_definitions(fry_core PUBLIC GLM_ENABLE_EXPERIMENTAL) add_library(fry_script STATIC script.cpp) 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 - 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}) elseif(APPLE) # a Mac app: Fry3DX.app, content in Contents/Resources, MoltenVK in Contents/Frameworks 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_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 @@ -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") endif() else() - add_executable(fry3dx app.cpp commands.cpp icons.cpp) + add_executable(fry3dx ${FRY_APP_SOURCES}) endif() if(WIN32) # a window program (no console window), with its icon target_sources(fry3dx PRIVATE fry3dx.rc) diff --git a/src/app.cpp b/src/app.cpp index 6f34ae3..c5c2986 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -1,271 +1,14 @@ -// Fry3DX — Frying 3D Extreme. A 3D modeller that works the way you'd expect: pick a tool from the -// toolbox, click and drag. The view in the middle is drawn by the engine's renderer: plain -// and clear while you model (Solid), or properly lit (Rendered). The panels are Dear ImGui. -// -// fry3dx [file.fry] -#include "bvh.h" -#include "commands.h" -#include "icons.h" -#include "mesh.h" -#include "scene.h" -#include "script.h" -#include "sculpt.h" -#include "shapes.h" -#include "import.h" -#include "text.h" +// Fry3DX: the document (opening, saving, undo), files, modes and tools, keys; and main(). +#include "app.h" -#include #if defined(__ANDROID__) #include // (main becomes the SDL_main SDL's Java side calls) #endif -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include using namespace fry; namespace stdfs = std::filesystem; -namespace { - -double now() { return double(SDL_GetTicks()) / 1000.0; } // (seconds; works before the UI exists) - -constexpr float kUnits = 39.37f; // the engine's units (inches) in a metre: the view renders in them -constexpr float kPi = 3.14159265358979f; - -enum class Mode { Object, Edit }; -enum class Tool { Select, Move, Rotate, Scale, Draw, Extrude, Inset, Bevel, LoopCut, Knife, Hole }; -enum class Tab { Model, Paint, Layout, Render, Print }; -enum class LayTool { Select, Move, Rotate, Scale }; -enum class PrintTool { None, Measure, LayFlat }; -enum class PaintTool { Brush, Eraser, Fill, Picker, Picture }; -enum class Elem { Vertex, Edge, Face }; -enum class View { Perspective, Front, Back, Left, Right, Top, Bottom }; -enum class Orient { Local, World, Normal }; // which way the gizmo's arrows point -enum class Pivot { Middle, Origin, Opposite, Bottom }; // what stays put when turning / sizing -enum class Shading { Solid, Rendered, Wire }; -enum class SelShape { Box, Brush, Lasso }; - -struct ToolInfo { - Tool tool; - Icon icon; - const char* name; - const char* key; - const char* hint; - bool objectMode, editMode; -}; -const ToolInfo kTools[] = { - {Tool::Select, Icon::Select, "Select", "Q", "Click to select; drag round things. Objects: Shift adds, Ctrl takes away. Shape: Ctrl adds/takes one, Shift+click a whole loop, Shift+Ctrl+click an edge ring.", true, true}, - {Tool::Move, Icon::Move, "Move", "W", "Drag an arrow to move along it, the square to move freely. Hold Ctrl to snap.", true, true}, - {Tool::Rotate, Icon::Rotate, "Rotate", "E", "Drag a ring to turn about it; the outer ring turns it as you see it. Ctrl: 15 degree steps.", true, true}, - {Tool::Scale, Icon::Scale, "Scale", "R", "Drag a side's handle to stretch it (the other side stays, or see Tool options); hold Shift to stretch both sides from the middle. Middle: all ways.", true, true}, - {Tool::Draw, Icon::Draw, "Draw shape", "D", "Click points round a shape (on the ground, or on any face of an object); click the first point (or press Enter) to finish. Esc cancels.", true, false}, - {Tool::Extrude, Icon::Extrude, "Extrude", "X", "Select faces, then drag the arrow to pull them out (along the way they face).", false, true}, - {Tool::Inset, Icon::Inset, "Inset", "I", "Select faces, then drag the handle to make a smaller face inside each.", false, true}, - {Tool::Bevel, Icon::Bevel, "Round edges", "B", "Select edges (or faces), then drag the round handle to round them off.", false, true}, - {Tool::LoopCut, Icon::LoopCut, "Loop cut", "C", "Point at an edge: the cut shows across. Click to cut there.", false, true}, - {Tool::Knife, Icon::Knife, "Knife", "K", "Click points along where to cut, or drag a straight line. Enter or double-click cuts. Cutting right through: Tool options.", false, true}, - {Tool::Hole, Icon::Hole, "Hole", "O", "Press on a face and drag out the size: round, rectangle, triangle or hexagon (nuts); all the way through, a pocket, or only its outline (a new face).", true, true}, -}; - -struct PaintToolInfo { - PaintTool tool; - Icon icon; - const char* name; - const char* key; - const char* hint; -}; -struct LayToolInfo { - LayTool tool; - Icon icon; - const char* name; - const char* key; - const char* hint; -}; -const LayToolInfo kLayTools[] = { - {LayTool::Select, Icon::Select, "Select", "Q", "Click frames (or corners) to pick them, Shift adds; drag round them to pick many. Clicking the model picks its frame too."}, - {LayTool::Move, Icon::Move, "Move", "W", "Drag frames (or corners) about the picture."}, - {LayTool::Rotate, Icon::Rotate, "Turn", "E", "Drag round to turn what's picked (Ctrl: 15 degree steps)."}, - {LayTool::Scale, Icon::Scale, "Size", "R", "Drag out or in to make what's picked bigger or smaller (Shift: only across or only up)."}, -}; -// Printers' build plates (mm, across x deep x high) and what's printed with. -struct PrinterInfo { - const char* name; - float x, y, z; -}; -const PrinterInfo kPrinters[] = { - {"Prusa MK4 / MK4S", 250, 210, 220}, {"Prusa Core One", 250, 220, 270}, {"Prusa MINI+", 180, 180, 180}, {"Bambu Lab X1 / P1 / P2", 256, 256, 256}, - {"Bambu Lab A1", 256, 256, 256}, {"Bambu Lab A1 mini", 180, 180, 180}, {"Creality Ender 3 (V2, V3)", 220, 220, 250}, {"Creality K1", 220, 220, 250}, - {"Elegoo Neptune 4", 225, 225, 265}, {"Anycubic Kobra 3", 250, 250, 260}, {"Your own size", 0, 0, 0}, -}; -struct MaterialInfo { - const char* name; - float density; // g/cm3 -}; -// What the Script panel starts with. -const char* kFirstScript = R"(-- A script builds or changes the scene. F5 or Ctrl+Enter runs it; --- Ctrl+Z takes back everything it did. The ? button lists what there is. - -local legs = 6 -for i = 1, legs do - local a = math.rad(i * 360 / legs) - local leg = fry.add("cylinder", {radius = 0.03, height = 0.7, color = "#c47a3c"}) - leg.position = {math.cos(a) * 0.45, math.sin(a) * 0.45, 0} -end -local top = fry.add("cylinder", {radius = 0.6, height = 0.05, sides = 48, color = "#d9a066"}) -top.position = {0, 0, 0.7} -fry.frame() -)"; -const MaterialInfo kMaterials[] = {{"PLA", 1.24f}, {"PETG", 1.27f}, {"ABS", 1.04f}, {"ASA", 1.07f}, {"TPU", 1.21f}, {"Nylon", 1.14f}, {"Resin", 1.12f}}; - -struct SculptToolInfo { - SculptBrush brush; - Icon icon; - const char* name; - const char* key; - const char* hint; -}; -const SculptToolInfo kSculptTools[] = { - {SculptBrush::Draw, Icon::Sculpt, "Raise", "1", "Builds the surface up where you brush. Ctrl digs in instead."}, - {SculptBrush::Inflate, Icon::Inflate, "Puff out", "2", "Swells it out all round, like blowing it up. Ctrl shrinks it."}, - {SculptBrush::Smooth, Icon::Smooth, "Smooth", "3", "Evens out bumps. Hold Shift with any brush to smooth."}, - {SculptBrush::Flatten, Icon::Flatten, "Flatten", "4", "Presses it flat, like a palette knife."}, - {SculptBrush::Pinch, Icon::Pinch, "Pinch", "5", "Pulls it in towards the middle of the brush: sharp creases and ridges. Ctrl pushes apart."}, - {SculptBrush::Grab, Icon::Grab, "Grab", "6", "Take hold of a patch and drag it where it should be."}, -}; - -const PaintToolInfo kPaintTools[] = { - {PaintTool::Brush, Icon::PaintBrush, "Brush", "B", "Paint on the model or on the flat picture. Alt+click picks a colour; [ and ] change the size."}, - {PaintTool::Eraser, Icon::Eraser, "Eraser", "E", "Paints its plain colour back."}, - {PaintTool::Fill, Icon::Fill, "Fill", "G", "Fills the patch of about the same colour where you click."}, - {PaintTool::Picker, Icon::Picker, "Pick colour", "I", "Click to take a colour from the model or the picture."}, - {PaintTool::Picture, Icon::Picture, "Place a picture", "Ctrl+V", "Paste a picture (copied in any app) or add one from a file, then drag its corners to fit. Enter places it."}, -}; - -glm::vec3 forwardOf(float yaw, float pitch) { - const float y = glm::radians(yaw), p = glm::radians(pitch); - return {std::cos(p) * std::cos(y), std::cos(p) * std::sin(y), -std::sin(p)}; -} - -// The engine's view-projection (render/renderer.cpp), in metres. -glm::mat4 viewProjection(const glm::vec3& eye, float yaw, float pitch, float fov, float aspect, glm::vec2 shift, glm::vec2 nearFar) { - const glm::vec3 f = forwardOf(yaw, pitch); - glm::mat4 view = glm::lookAt(eye * kUnits, eye * kUnits + f, glm::vec3(0, 0, 1)); - glm::mat4 proj = glm::perspective(glm::radians(fov), aspect, nearFar.x, nearFar.y); - proj[1][1] *= -1.0f; - proj[2][0] -= shift.x, proj[2][1] -= shift.y; - return proj * view * glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)); -} - -struct Ray { - glm::vec3 origin, dir; -}; - -// Möller–Trumbore; t along the ray, or nothing. -std::optional hitPlane(const Ray& r, const glm::vec3& point, const glm::vec3& normal) { - const float d = glm::dot(r.dir, normal); - if (std::abs(d) < 1e-8f) return std::nullopt; - const float t = glm::dot(point - r.origin, normal) / d; - return t > 0 ? std::optional(r.origin + r.dir * t) : std::nullopt; -} - -// Where along the line (from `p`, along unit `axis`) the ray passes closest. -float closestOnAxis(const Ray& r, const glm::vec3& p, const glm::vec3& axis) { - const glm::vec3 w = p - r.origin; - const float b = glm::dot(axis, r.dir), d = glm::dot(axis, w), e = glm::dot(r.dir, w); - const float den = 1.0f - b * b; - return den < 1e-6f ? 0.0f : (b * e - d) / den; -} - -float distToSegment(ImVec2 p, ImVec2 a, ImVec2 b) { - const float vx = b.x - a.x, vy = b.y - a.y, len = vx * vx + vy * vy; - const float t = len > 0 ? std::clamp(((p.x - a.x) * vx + (p.y - a.y) * vy) / len, 0.0f, 1.0f) : 0.0f; - const float dx = a.x + vx * t - p.x, dy = a.y + vy * t - p.y; - return std::sqrt(dx * dx + dy * dy); -} - -std::string lower(std::string s) { - std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return char(std::tolower(c)); }); - return s; -} -bool insidePolygon(ImVec2 p, const std::vector& poly) { - bool in = false; - for (size_t i = 0, j = poly.size() - 1; i < poly.size(); j = i++) - if ((poly[i].y > p.y) != (poly[j].y > p.y) && p.x < (poly[j].x - poly[i].x) * (p.y - poly[i].y) / (poly[j].y - poly[i].y) + poly[i].x) in = !in; - return in; -} - -float snapTo(float v, float step) { return step > 0 ? std::round(v / step) * step : v; } - -using Axes = std::array; -glm::vec3 inFrame(const Axes& a, const glm::vec3& p) { return {glm::dot(p, a[0]), glm::dot(p, a[1]), glm::dot(p, a[2])}; } -glm::vec3 fromFrame(const Axes& a, const glm::vec3& c) { return a[0] * c.x + a[1] * c.y + a[2] * c.z; } - -const ImU32 kAxisCol[3] = {IM_COL32(235, 70, 70, 255), IM_COL32(110, 210, 80, 255), IM_COL32(70, 130, 245, 255)}; -const ImU32 kHot = IM_COL32(255, 225, 90, 255); -constexpr ImU32 kAccent = IM_COL32(255, 120, 40, 255); -// The UI's colours: Niveus's roles, in the appearance that's showing (keel/ui). -ImVec4 roleColor(keel::ui::Role r) { return ImGui::ColorConvertU32ToFloat4(keel::ui::color(r)); } -ImVec4 accentText() { return roleColor(keel::ui::Role::AccentText); } -// The one button that does it, filled with the accent (until popAccent). -void pushAccent() { - using keel::ui::Role; - ImGui::PushStyleColor(ImGuiCol_Button, roleColor(Role::Accent)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, roleColor(Role::AccentHover)); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, roleColor(Role::AccentActive)); - ImGui::PushStyleColor(ImGuiCol_Text, roleColor(Role::OnAccent)); -} -void popAccent() { ImGui::PopStyleColor(4); } - -// An object as it'll be printed: where it stands, with its finish, checked (kept until it changes). -struct PrintInfo { - uint64_t key = ~0ull; - Mesh world; - PrintCheck check; - glm::vec3 lo{0.0f}, hi{0.0f}; - keel::ModelId overhang{}; - bool hasOverhang = false; - // Walls too thin to print: how thick at each face (worked out again only when its shape or - // size changes: moving or turning it doesn't), how many under the least, the thinnest. - uint64_t thickKey = ~0ull; - std::vector thick; - int thinFaces = 0; - float thinnest = 1e30f; - keel::ModelId thin{}; - // The slice preview's layer: where it was cut, and for which shape (kept beside what it's of). - mutable uint64_t sliceKey = ~0ull; - mutable float sliceZ = -1; - mutable std::vector> slice; -}; +namespace fryapp { keel::Cvar fry_printer("fry_printer", "0", keel::CVAR_ARCHIVE, "Fry3DX: the 3D printer (a number in the Print tab's list)"); keel::Cvar fry_bed_x("fry_bed_x", "220", keel::CVAR_ARCHIVE, "Fry3DX: your own printer's build plate across (mm)"); @@ -282,8527 +25,1692 @@ keel::Cvar fry_theme("fry_theme", "0", keel::CVAR_ARCHIVE, "Fry3DX: how it looks keel::Cvar fry_pluginsoff("fry_pluginsoff", "", keel::CVAR_ARCHIVE, "Fry3DX: plug-ins turned off (their file names, with | between)"); keel::Cvar fry_autosave("fry_autosave", "2", keel::CVAR_ARCHIVE, "Fry3DX: autosave every this many minutes while there are changes (0: off)"); -class App : public keel::Game { -public: - explicit App(std::string path) : startPath_(std::move(path)) {} - const char* name() const override { return "Fry3DX"; } // (its settings folder, and bin/Fry3DX/) - const char* title() const override { return "Fry3DX"; } - bool wantsCursor() const override { return true; } - bool dockingUi() const override { return true; } +// "Spiral_stairs" is "Spiral stairs" (typed in the console). +auto App::sameCommand(const std::string& name, std::string typed) -> bool { + std::replace(typed.begin(), typed.end(), '_', ' '); + return materialId(name) == materialId(typed); +} - // "Spiral_stairs" is "Spiral stairs" (typed in the console). - static bool sameCommand(const std::string& name, std::string typed) { - std::replace(typed.begin(), typed.end(), '_', ' '); - return materialId(name) == materialId(typed); - } - void init() override { - static App* self = this; - // Console commands: for scripts and tests (the ` key opens the console). - static keel::Cmd open("fry_open", [](const keel::Args& a) { if (a.size() > 1) self->openFile(a[1]); }, "Fry3DX: open a .fry file"); - static keel::Cmd save("fry_save", [](const keel::Args& a) { if (a.size() > 1) self->saveFile(a[1]); }, "Fry3DX: save as"); - static keel::Cmd exp("fry_export", [](const keel::Args& a) { if (a.size() > 1) self->exportFile(a[1]); }, "Fry3DX: export glTF"); - static keel::Cmd run("fry_run", [](const keel::Args& a) { - if (a.size() < 2) return; - std::ifstream in(a[1], std::ios::binary); - if (!in) return keel::log::warn("Couldn't open {}", a[1]); - self->addScript(scriptName(a[1]), std::string(std::istreambuf_iterator(in), {})); - self->scriptPath_ = a[1]; - self->runScriptNow(); - }, "Fry3DX: run a Lua script file (fry_run path/to/script.lua)"); - static keel::Cmd add("fry_add", [](const keel::Args& a) { if (a.size() > 1) self->addShape(a[1]); }, "Fry3DX: add a shape (cube, rounded_cube, table...)"); - static keel::Cmd view("fry_view", [](const keel::Args& a) { - static const char* names[] = {"persp", "front", "back", "left", "right", "top", "bottom"}; - for (int i = 0; i < 7; ++i) - if (a.size() > 1 && a[1] == names[i]) self->setView(View(i)); - }, "Fry3DX: fry_view persp|front|back|left|right|top|bottom"); - static keel::Cmd orbit("fry_orbit", [](const keel::Args& a) { - if (a.size() > 2) self->view_ = View::Perspective, self->fov_ = 50, self->yaw_ = std::stof(a[1]), self->pitch_ = std::stof(a[2]); - if (a.size() > 3) self->dist_ = std::stof(a[3]); - }, "Fry3DX: fry_orbit yaw pitch [distance]"); - // A sculpt stroke across the view (0..1 of it), for pictures of it: brush 1-6, then from and to. - static keel::Cmd sculptStroke("fry_sculpt", [](const keel::Args& a) { - if (!self->viewReady()) return self->say("fry_sculpt: the view isn't up yet (+wait 1 first)"); - if (a.size() < 6) return; - const int brush = std::clamp(std::stoi(a[1]) - 1, 0, int(std::size(kSculptTools)) - 1); - self->sculptBrush_ = kSculptTools[brush].brush; - const bool other = a.size() > 6 && a[6] == "invert"; - const auto at = [&](float t) { - const ImVec2 lo = self->viewMin_, hi = self->viewMax_; - return ImVec2(lo.x + (hi.x - lo.x) * glm::mix(std::stof(a[2]), std::stof(a[4]), t), lo.y + (hi.y - lo.y) * glm::mix(std::stof(a[3]), std::stof(a[5]), t)); - }; - self->sculptAt(at(0), true, other, false); - for (int i = 1; i <= 60; ++i) self->sculptAt(at(float(i) / 60.0f), false, other, false); - self->endSculpt(); - }, "Fry3DX: fry_sculpt brush x0 y0 x1 y1 [invert] (0..1 of the view)"); - static keel::Cmd sel("fry_select", [](const keel::Args& a) { - self->selected_.clear(); +auto App::init() -> void { + static App* self = this; + // Console commands: for scripts and tests (the ` key opens the console). + static keel::Cmd open("fry_open", [](const keel::Args& a) { if (a.size() > 1) self->openFile(a[1]); }, "Fry3DX: open a .fry file"); + static keel::Cmd save("fry_save", [](const keel::Args& a) { if (a.size() > 1) self->saveFile(a[1]); }, "Fry3DX: save as"); + static keel::Cmd exp("fry_export", [](const keel::Args& a) { if (a.size() > 1) self->exportFile(a[1]); }, "Fry3DX: export glTF"); + static keel::Cmd run("fry_run", [](const keel::Args& a) { + if (a.size() < 2) return; + std::ifstream in(a[1], std::ios::binary); + if (!in) return keel::log::warn("Couldn't open {}", a[1]); + self->addScript(scriptName(a[1]), std::string(std::istreambuf_iterator(in), {})); + self->scriptPath_ = a[1]; + self->runScriptNow(); + }, "Fry3DX: run a Lua script file (fry_run path/to/script.lua)"); + static keel::Cmd add("fry_add", [](const keel::Args& a) { if (a.size() > 1) self->addShape(a[1]); }, "Fry3DX: add a shape (cube, rounded_cube, table...)"); + static keel::Cmd view("fry_view", [](const keel::Args& a) { + static const char* names[] = {"persp", "front", "back", "left", "right", "top", "bottom"}; + for (int i = 0; i < 7; ++i) + if (a.size() > 1 && a[1] == names[i]) self->setView(View(i)); + }, "Fry3DX: fry_view persp|front|back|left|right|top|bottom"); + static keel::Cmd orbit("fry_orbit", [](const keel::Args& a) { + if (a.size() > 2) self->view_ = View::Perspective, self->fov_ = 50, self->yaw_ = std::stof(a[1]), self->pitch_ = std::stof(a[2]); + if (a.size() > 3) self->dist_ = std::stof(a[3]); + }, "Fry3DX: fry_orbit yaw pitch [distance]"); + // A sculpt stroke across the view (0..1 of it), for pictures of it: brush 1-6, then from and to. + static keel::Cmd sculptStroke("fry_sculpt", [](const keel::Args& a) { + if (!self->viewReady()) return self->say("fry_sculpt: the view isn't up yet (+wait 1 first)"); + if (a.size() < 6) return; + const int brush = std::clamp(std::stoi(a[1]) - 1, 0, int(std::size(kSculptTools)) - 1); + self->sculptBrush_ = kSculptTools[brush].brush; + const bool other = a.size() > 6 && a[6] == "invert"; + const auto at = [&](float t) { + const ImVec2 lo = self->viewMin_, hi = self->viewMax_; + return ImVec2(lo.x + (hi.x - lo.x) * glm::mix(std::stof(a[2]), std::stof(a[4]), t), lo.y + (hi.y - lo.y) * glm::mix(std::stof(a[3]), std::stof(a[5]), t)); + }; + self->sculptAt(at(0), true, other, false); + for (int i = 1; i <= 60; ++i) self->sculptAt(at(float(i) / 60.0f), false, other, false); + self->endSculpt(); + }, "Fry3DX: fry_sculpt brush x0 y0 x1 y1 [invert] (0..1 of the view)"); + static keel::Cmd sel("fry_select", [](const keel::Args& a) { + self->selected_.clear(); + for (const Object& o : self->scene_.objects) + if (a.size() > 1 && o.name == a[1]) self->selected_.insert(o.id), self->active_ = o.id; + }, "Fry3DX: select an object by name"); + static keel::Cmd place("fry_place", [](const keel::Args& a) { + if (a.size() < 4) return; + for (Object& o : self->scene_.objects) + if (self->selected_.count(o.id)) o.position = {std::stof(a[1]), std::stof(a[2]), std::stof(a[3])}, self->touched(o.id); + }, "Fry3DX: fry_place x y z (metres) for the selection"); + static keel::Cmd turn("fry_turn", [](const keel::Args& a) { + if (a.size() < 4) return; + for (Object& o : self->scene_.objects) + if (self->selected_.count(o.id)) o.rotation = fromEuler({std::stof(a[1]), std::stof(a[2]), std::stof(a[3])}), self->touched(o.id); + }, "Fry3DX: fry_turn x y z (degrees) for the selection"); + static keel::Cmd edit("fry_edit", [](const keel::Args& a) { + self->enterEdit(); + if (a.size() > 1) self->elem_ = a[1] == "vertex" ? Elem::Vertex : a[1] == "edge" ? Elem::Edge : Elem::Face; + if (a.size() > 2 && a[2] == "all") self->selectAllElements(); + if (a.size() > 2 && a[2] != "all") { + self->selFaces_.clear(); + for (size_t i = 2; i < a.size(); ++i) self->selFaces_.insert(std::stoi(a[i])); + } + }, "Fry3DX: fry_edit [vertex|edge|face] [all|face numbers...]"); + static keel::Cmd tool("fry_tool", [](const keel::Args& a) { + for (const ToolInfo& t : kTools) + if (a.size() > 1 && SDL_strcasecmp(a[1].c_str(), t.name) == 0) self->setTool(t.tool); + }, "Fry3DX: pick a tool by name"); + static keel::Cmd shading("fry_shading", [](const keel::Args& a) { + if (a.size() > 1) self->shading_ = a[1] == "rendered" ? Shading::Rendered : a[1] == "wire" ? Shading::Wire : Shading::Solid; + }, "Fry3DX: fry_shading solid|rendered|wire"); + static keel::Cmd finish("fry_finish", [](const keel::Args& a) { + for (Object& o : self->scene_.objects) + if (self->selected_.count(o.id)) { + if (a.size() > 1) o.round = std::stof(a[1]); + if (a.size() > 2) o.roundSteps = std::stoi(a[2]); + if (a.size() > 3) o.smoothLevels = std::stoi(a[3]); + if (a.size() > 4) o.mirror = std::stoi(a[4]); + self->touched(o.id); + } + }, "Fry3DX: fry_finish round steps smooth mirror (for the selection)"); + static keel::Cmd bevel("fry_bevel", [](const keel::Args& a) { + if (a.size() > 1) self->bevelWidth_ = std::stof(a[1]); + if (a.size() > 2) self->bevelSteps_ = std::stoi(a[2]); + self->bevelNow(); + }, "Fry3DX: round the selected edges: fry_bevel width steps"); + static keel::Cmd cut("fry_cut", [](const keel::Args& a) { + if (Object* o = self->editing(); o && a.size() > 2) self->cutAt(*o, {std::stoi(a[1]), std::stoi(a[2])}); + }, "Fry3DX: loop cut through the edge between two points"); + static keel::Cmd pic("fry_picture", [](const keel::Args& a) { if (a.size() > 1) self->addPicture(a[1]); }, "Fry3DX: add a reference picture"); + static keel::Cmd frame("fry_frame", [](const keel::Args&) { self->frameAll(); }, "Fry3DX: frame everything"); + static keel::Cmd tab("fry_tab", [](const keel::Args& a) { + if (a.size() > 1) + self->setTab(a[1] == "paint" || a[1] == "sculpt" ? Tab::Paint : a[1] == "layout" ? Tab::Layout : a[1] == "render" ? Tab::Render : a[1] == "print" ? Tab::Print : Tab::Model); + if (a.size() > 1) self->sculpting_ = a[1] == "sculpt"; + }, "Fry3DX: fry_tab model|paint|sculpt|layout|render|print"); + // (points as parts of the view: 0..1 across, 0..1 down) + auto viewPoint = [](const std::string& x, const std::string& y) { + return ImVec2(self->viewMin_.x + std::stof(x) * (self->viewMax_.x - self->viewMin_.x), self->viewMin_.y + std::stof(y) * (self->viewMax_.y - self->viewMin_.y)); + }; + static auto vp = viewPoint; + static keel::Cmd knife("fry_knife", [](const keel::Args& a) { + if (!self->viewReady()) return self->say("fry_knife: the view isn't up yet (+wait 1 first)"); + if (a.size() < 5) return; + if (a.size() > 5) self->knifeThrough_ = a[5] != "0"; + if (a.size() > 6) self->knifeAfter_ = std::stoi(a[6]); + if (a.size() > 7) self->knifeFill_ = a[7] != "0"; + std::vector pts; + for (size_t i = 1; i + 1 < a.size() && i < 5; i += 2) pts.push_back(vp(a[i], a[i + 1])); + if (Object* o = self->editing()) self->knifeApply(o->id, pts); + }, "Fry3DX: fry_knife x0 y0 x1 y1 [through after fill] (view 0..1)"); + static keel::Cmd combineCmd("fry_combine", [](const keel::Args& a) { + const std::string w = a.size() > 1 ? a[1] : "add"; + self->combineSelected(w == "subtract" ? Combine::Subtract : w == "overlap" ? Combine::Overlap : Combine::Add); + }, "Fry3DX: fry_combine add|subtract|overlap (the picked, the last kept)"); + static keel::Cmd selectCmd("fry_selectall", [](const keel::Args&) { + self->selected_.clear(); + for (const Object& o : self->scene_.objects) self->selected_.insert(o.id), self->active_ = o.id; + }, "Fry3DX: pick every object (the first added last)"); + static keel::Cmd pickCmd("fry_pick", [](const keel::Args& a) { // fry_pick name...: those picked, the first the active one + self->selected_.clear(); + for (size_t i = a.size(); i-- > 1;) for (const Object& o : self->scene_.objects) - if (a.size() > 1 && o.name == a[1]) self->selected_.insert(o.id), self->active_ = o.id; - }, "Fry3DX: select an object by name"); - static keel::Cmd place("fry_place", [](const keel::Args& a) { - if (a.size() < 4) return; - for (Object& o : self->scene_.objects) - if (self->selected_.count(o.id)) o.position = {std::stof(a[1]), std::stof(a[2]), std::stof(a[3])}, self->touched(o.id); - }, "Fry3DX: fry_place x y z (metres) for the selection"); - static keel::Cmd turn("fry_turn", [](const keel::Args& a) { - if (a.size() < 4) return; - for (Object& o : self->scene_.objects) - if (self->selected_.count(o.id)) o.rotation = fromEuler({std::stof(a[1]), std::stof(a[2]), std::stof(a[3])}), self->touched(o.id); - }, "Fry3DX: fry_turn x y z (degrees) for the selection"); - static keel::Cmd edit("fry_edit", [](const keel::Args& a) { - self->enterEdit(); - if (a.size() > 1) self->elem_ = a[1] == "vertex" ? Elem::Vertex : a[1] == "edge" ? Elem::Edge : Elem::Face; - if (a.size() > 2 && a[2] == "all") self->selectAllElements(); - if (a.size() > 2 && a[2] != "all") { - self->selFaces_.clear(); - for (size_t i = 2; i < a.size(); ++i) self->selFaces_.insert(std::stoi(a[i])); - } - }, "Fry3DX: fry_edit [vertex|edge|face] [all|face numbers...]"); - static keel::Cmd tool("fry_tool", [](const keel::Args& a) { - for (const ToolInfo& t : kTools) - if (a.size() > 1 && SDL_strcasecmp(a[1].c_str(), t.name) == 0) self->setTool(t.tool); - }, "Fry3DX: pick a tool by name"); - static keel::Cmd shading("fry_shading", [](const keel::Args& a) { - if (a.size() > 1) self->shading_ = a[1] == "rendered" ? Shading::Rendered : a[1] == "wire" ? Shading::Wire : Shading::Solid; - }, "Fry3DX: fry_shading solid|rendered|wire"); - static keel::Cmd finish("fry_finish", [](const keel::Args& a) { - for (Object& o : self->scene_.objects) - if (self->selected_.count(o.id)) { - if (a.size() > 1) o.round = std::stof(a[1]); - if (a.size() > 2) o.roundSteps = std::stoi(a[2]); - if (a.size() > 3) o.smoothLevels = std::stoi(a[3]); - if (a.size() > 4) o.mirror = std::stoi(a[4]); - self->touched(o.id); - } - }, "Fry3DX: fry_finish round steps smooth mirror (for the selection)"); - static keel::Cmd bevel("fry_bevel", [](const keel::Args& a) { - if (a.size() > 1) self->bevelWidth_ = std::stof(a[1]); - if (a.size() > 2) self->bevelSteps_ = std::stoi(a[2]); - self->bevelNow(); - }, "Fry3DX: round the selected edges: fry_bevel width steps"); - static keel::Cmd cut("fry_cut", [](const keel::Args& a) { - if (Object* o = self->editing(); o && a.size() > 2) self->cutAt(*o, {std::stoi(a[1]), std::stoi(a[2])}); - }, "Fry3DX: loop cut through the edge between two points"); - static keel::Cmd pic("fry_picture", [](const keel::Args& a) { if (a.size() > 1) self->addPicture(a[1]); }, "Fry3DX: add a reference picture"); - static keel::Cmd frame("fry_frame", [](const keel::Args&) { self->frameAll(); }, "Fry3DX: frame everything"); - static keel::Cmd tab("fry_tab", [](const keel::Args& a) { - if (a.size() > 1) - self->setTab(a[1] == "paint" || a[1] == "sculpt" ? Tab::Paint : a[1] == "layout" ? Tab::Layout : a[1] == "render" ? Tab::Render : a[1] == "print" ? Tab::Print : Tab::Model); - if (a.size() > 1) self->sculpting_ = a[1] == "sculpt"; - }, "Fry3DX: fry_tab model|paint|sculpt|layout|render|print"); - // (points as parts of the view: 0..1 across, 0..1 down) - auto viewPoint = [](const std::string& x, const std::string& y) { - return ImVec2(self->viewMin_.x + std::stof(x) * (self->viewMax_.x - self->viewMin_.x), self->viewMin_.y + std::stof(y) * (self->viewMax_.y - self->viewMin_.y)); - }; - static auto vp = viewPoint; - static keel::Cmd knife("fry_knife", [](const keel::Args& a) { - if (!self->viewReady()) return self->say("fry_knife: the view isn't up yet (+wait 1 first)"); - if (a.size() < 5) return; - if (a.size() > 5) self->knifeThrough_ = a[5] != "0"; - if (a.size() > 6) self->knifeAfter_ = std::stoi(a[6]); - if (a.size() > 7) self->knifeFill_ = a[7] != "0"; - std::vector pts; - for (size_t i = 1; i + 1 < a.size() && i < 5; i += 2) pts.push_back(vp(a[i], a[i + 1])); - if (Object* o = self->editing()) self->knifeApply(o->id, pts); - }, "Fry3DX: fry_knife x0 y0 x1 y1 [through after fill] (view 0..1)"); - static keel::Cmd combineCmd("fry_combine", [](const keel::Args& a) { - const std::string w = a.size() > 1 ? a[1] : "add"; - self->combineSelected(w == "subtract" ? Combine::Subtract : w == "overlap" ? Combine::Overlap : Combine::Add); - }, "Fry3DX: fry_combine add|subtract|overlap (the picked, the last kept)"); - static keel::Cmd selectCmd("fry_selectall", [](const keel::Args&) { - self->selected_.clear(); - for (const Object& o : self->scene_.objects) self->selected_.insert(o.id), self->active_ = o.id; - }, "Fry3DX: pick every object (the first added last)"); - static keel::Cmd pickCmd("fry_pick", [](const keel::Args& a) { // fry_pick name...: those picked, the first the active one - self->selected_.clear(); - for (size_t i = a.size(); i-- > 1;) - for (const Object& o : self->scene_.objects) - if (o.name == a[i]) self->selected_.insert(o.id), self->active_ = o.id; - }, "Fry3DX: fry_pick name... (the first kept when combining)"); - static keel::Cmd copiesCmd("fry_copies", [](const keel::Args& a) { // fry_copies n row dx dy dz | n round angle [joined] - if (a.size() < 3) return; - auto& c = self->copies_; - c.count = std::stoi(a[1]), c.round = a[2] == "round", c.path = a[2] == "path"; - c.follow = std::find(a.begin(), a.end(), "still") == a.end(); - if (c.round && a.size() > 3) c.angle = std::stof(a[3]), c.centre = glm::vec3(0.0f); - if (!c.round && a.size() > 5) c.step = {std::stof(a[3]), std::stof(a[4]), std::stof(a[5])}; - c.joined = a.back() == "joined"; - self->makeCopies(); - }, "Fry3DX: fry_copies n row dx dy dz [joined] | n round angle [joined]"); - static keel::Cmd thickCmd("fry_thicken", [](const keel::Args& a) { - if (a.size() > 1) self->thickness_ = std::abs(std::stof(a[1])), self->thicknessOut_ = std::stof(a[1]) < 0; - self->thickenActive(); - }, "Fry3DX: fry_thicken metres (negative: outwards)"); - static keel::Cmd groupCmd("fry_group", [](const keel::Args& a) { - self->groupSelected(); - if (a.size() > 1 && !self->scene_.folders.empty()) self->scene_.folders.back().name = a[1], self->renamingFolder_ = 0; - }, "Fry3DX: fry_group [name]: the picked into a new folder"); - static keel::Cmd panelsCmd("fry_panels", [](const keel::Args&) { self->panelsOpen_ = !self->panelsOpen_; }, "Fry3DX: phone layout: show / hide the panels"); - static keel::Cmd goCmd("fry_goto", [](const keel::Args&) { self->goTo(self->selected_); }, "Fry3DX: the camera to the picked"); - static keel::Cmd drawCmd("fry_draw", [](const keel::Args& a) { // fry_draw mode thick x y x y x y ... (view 0..1) - if (!self->viewReady()) return self->say("fry_draw: the view isn't up yet (+wait 1 first)"); - if (a.size() < 9) return; - self->setTool(Tool::Draw); - self->drawOnMode_ = std::stoi(a[1]), self->drawThickness_ = std::stof(a[2]); - self->drawPts_.clear(); - const bool snap = self->snap_; - self->snap_ = false; - for (size_t i = 3; i + 1 < a.size(); i += 2) { - ImGuiIO io = ImGui::GetIO(); - io.MousePos = ImVec2(self->viewMin_.x + std::stof(a[i]) * (self->viewMax_.x - self->viewMin_.x), self->viewMin_.y + std::stof(a[i + 1]) * (self->viewMax_.y - self->viewMin_.y)); - if (auto p = self->drawPoint(io)) self->drawPts_.push_back(*p); - } - self->snap_ = snap; - self->finishShape(); - }, "Fry3DX: fry_draw mode(0 new,1 add,2 cut) thickness x y x y x y..."); - static keel::Cmd extrudeCmd("fry_extrude", [](const keel::Args& a) { if (a.size() > 1) self->extrudeNow(std::stof(a[1])); }, "Fry3DX: extrude the picked faces this far (m)"); - static keel::Cmd holeCmd("fry_hole", [](const keel::Args& a) { - Object* o = self->editing() ? self->editing() : self->find(self->active_); - if (!o || a.size() < 3) return; - const int face = std::stoi(a[1]); - if (a.size() > 3) { - const float d = std::stof(a[3]); - self->holeMode_ = d < 0 ? 2 : d == 0 ? 0 : 1; - if (d > 0) self->holeDepth_ = d; - } - if (a.size() > 4) { - static const char* names[] = {"round", "rect", "triangle", "hex"}; - for (int i = 0; i < 4; ++i) - if (a[4] == names[i]) self->holeShape_ = i; - } - const float across = std::stof(a[2]); - if (a.size() > 5) self->holeTall_ = std::stof(a[5]), self->holeSize_ = across; - if (face >= 0 && face < int(o->mesh.faces.size())) self->applyHole(o->id, face, faceCenter(o->mesh, face), across); - }, "Fry3DX: fry_hole face across [depth: 0 through, -1 outline only] [round|rect|triangle|hex] [tall] (metres, the object's own)"); - static keel::Cmd color("fry_color", [](const keel::Args& a) { - if (a.size() > 3) self->paintColor_ = {std::stof(a[1]), std::stof(a[2]), std::stof(a[3]), 1.0f}; - }, "Fry3DX: fry_color r g b (painting, 0..1)"); - static keel::Cmd stroke("fry_stroke", [](const keel::Args& a) { - if (!self->viewReady()) return self->say("fry_stroke: the view isn't up yet (+wait 1 first)"); - if (a.size() < 5) return; - const ImVec2 p0 = vp(a[1], a[2]), p1 = vp(a[3], a[4]); - for (int i = 0; i <= 40; ++i) self->paintAt(ImVec2(p0.x + (p1.x - p0.x) * float(i) / 40, p0.y + (p1.y - p0.y) * float(i) / 40), i == 0); - self->endStroke(); - }, "Fry3DX: fry_stroke x0 y0 x1 y1: a brush stroke across the view (0..1)"); - static keel::Cmd ptool("fry_tooluse", [](const keel::Args& a) { // a plug-in tool by name, clicked at x y (0..1 of the view) - if (a.size() < 4 || !self->viewReady()) return; - for (const PluginEntry& e : self->plugins_) - if (e.p) - for (const fry::PluginTool& t : e.p->tools) - if (t.name == a[1]) self->pluginTool_ = {e.file, t.name}; - self->clickPluginTool(vp(a[2], a[3])); - }, "Fry3DX: fry_tooluse \"name\" x y: click with a plug-in tool (0..1 of the view)"); - static keel::Cmd stencil("fry_stencil", [](const keel::Args& a) { // a box stencil, 0..1 of the view - if (a.size() < 5 || !self->viewReady()) return; - const ImVec2 p0 = vp(a[1], a[2]), p1 = vp(a[3], a[4]); - self->stencil_ = {p0, ImVec2(p1.x, p0.y), p1, ImVec2(p0.x, p1.y)}, self->stencilOn_ = true; - }, "Fry3DX: fry_stencil x0 y0 x1 y1: a box stencil for painting (0..1 of the view)"); - static keel::Cmd paste("fry_pastefile", [](const keel::Args& a) { if (a.size() > 1) self->floatFromFile(a[1]); }, "Fry3DX: place a picture file onto the selected object"); - static keel::Cmd corner("fry_corner", [](const keel::Args& a) { - if (PictureLayer* l = self->editLayer(); l && a.size() > 3) l->corners[size_t(std::clamp(std::stoi(a[1]), 0, 3))] = {std::stof(a[2]), std::stof(a[3])}; - }, "Fry3DX: fry_corner i x y: move a placed picture's corner (picture pixels)"); - static keel::Cmd placePic("fry_lock", [](const keel::Args&) { self->lockLayer(); }, "Fry3DX: lock the picture being moved (Enter)"); - static keel::Cmd flat("fry_pasteflat", [](const keel::Args& a) { if (a.size() > 1) self->pasteOnSurface_ = a[1] == "0"; }, "Fry3DX: fry_pasteflat 1: new pictures go on the flat picture"); - static keel::Cmd unlock("fry_unlock", [](const keel::Args& a) { - Object* o = self->find(self->active_); - if (!o || a.size() < 3 || !self->owner_.count(o->id) || !o->picture) return; - const int x = std::stoi(a[1]), y = std::stoi(a[2]); - if (x < 0 || y < 0 || x >= o->picture->w || y >= o->picture->h) return; - if (const int i = self->owner_[o->id][size_t(y) * size_t(o->picture->w) + size_t(x)]; i > 0) self->unlockLayer(o->id, i - 1); - }, "Fry3DX: fry_unlock x y: click the Picture panel there (picture pixels)"); - static keel::Cmd moveSticker("fry_sticker", [](const keel::Args& a) { - if (!self->viewReady()) return self->say("fry_sticker: the view isn't up yet (+wait 1 first)"); - Object* o = self->find(self->ledit_.object); - PictureLayer* l = self->editLayer(); - if (!o || !l || a.size() < 3) return; - const Ray ray = self->mouseRay(vp(a[1], a[2])); - const Hit h = self->pickObject(ray, o->id, true); - if (!h.object) return self->say("fry_sticker: not on the object there"); - l->face = h.face, l->centre = glm::vec3(glm::inverse(objectMatrix(*o)) * glm::vec4(ray.origin + ray.dir * h.t, 1.0f)); - if (a.size() > 4) l->width = std::stof(a[3]), l->height = std::stof(a[4]); - settleSticker(o->mesh, *l); - }, "Fry3DX: fry_sticker x y [w h]: move the sticker being moved to that point of the view"); - static keel::Cmd capture("fry_capture", [](const keel::Args& a) { if (a.size() > 1) self->savePicture(a[1]); }, "Fry3DX: save a picture of the view"); - static keel::Cmd layout("fry_layout", [](const keel::Args& a) { - Object* o = self->find(self->active_); - if (!o || a.size() < 2) return; - if (a[1] == "start") self->readyToPaint(o->id); - if (a[1] == "all") self->layoutSelectAll(); - if (a[1] == "frame" && a.size() > 2) self->lay_.object = o->id, self->lay_.corners = false, self->lay_.frames = {std::stoi(a[2])}; - if (a[1] == "checker" && a.size() > 2) self->layChecker_ = a[2] == "1"; - if (a[1] == "keep" && a.size() > 2) self->layKeepPaint_ = a[2] == "1"; - if (a[1] == "again") self->layoutAgain(*o); - if (a[1] == "turn" && a.size() > 2) { - const float r = glm::radians(std::stof(a[2])); - self->layoutApply([r](glm::vec2 p, glm::vec2 m) { return m + glm::vec2(std::cos(r) * (p - m).x - std::sin(r) * (p - m).y, std::sin(r) * (p - m).x + std::cos(r) * (p - m).y); }); - } - if (a[1] == "move" && a.size() > 3) { - const glm::vec2 d(std::stof(a[2]), std::stof(a[3])); - self->layoutApply([d](glm::vec2 p, glm::vec2) { return p + d; }); - } - if (a[1] == "scale" && a.size() > 2) { - const float k = std::stof(a[2]); - self->layoutApply([k](glm::vec2 p, glm::vec2 m) { return m + (p - m) * k; }); - } - }, "Fry3DX: fry_layout start|all|frame N|turn deg|move x y|scale k|checker 0/1|keep 0/1|again"); - static keel::Cmd print("fry_print", [](const keel::Args& a) { - if (a.size() < 2) return; - if (a[1] == "bed") self->putOnBed(); - if (a[1] == "layer" && a.size() > 2) self->showLayer_ = true, self->layer_ = std::stoi(a[2]); - if (a[1] == "middle") self->middleOfBed(); - if (a[1] == "printer" && a.size() > 2) keel::execute("fry_printer " + a[2]); - if (a[1] == "size" && a.size() > 3) self->pushUndo(), self->printResize(std::stoi(a[2]), std::stof(a[3])); - if ((a[1] == "measure" || a[1] == "flat") && !self->viewReady()) return self->say("fry_print: the view isn't up yet (+wait 1 first)"); - if (a[1] == "measure" && a.size() > 5) { - self->printTool_ = PrintTool::Measure, self->measure_.clear(); - for (int i = 0; i < 2; ++i) - if (auto p = self->measurePoint(vp(a[size_t(2 + i * 2)], a[size_t(3 + i * 2)]))) self->measure_.push_back(*p); - } - if (a[1] == "flat" && a.size() > 3) { // (as clicking with Lay flat) - self->printTool_ = PrintTool::LayFlat; - ImGuiIO& io = ImGui::GetIO(); - const ImVec2 keep = io.MousePos; - io.MousePos = vp(a[2], a[3]); - const Hit h = self->pickObject(self->mouseRay(io.MousePos)); - io.MousePos = keep; - if (Object* o = h.object ? self->find(h.object) : nullptr) { - const glm::vec3 n = faceNormal(self->printInfo(*o).world, h.face); - self->pushUndo(); - o->rotation = glm::normalize(glm::rotation(n, glm::vec3(0, 0, -1)) * o->rotation), self->touched(o->id); - o->position.z -= self->printInfo(*o).lo.z, self->touched(o->id); - } - self->printTool_ = PrintTool::None; - } - }, "Fry3DX: fry_print bed|middle|printer N|size axis mm|measure x0 y0 x1 y1|flat x y"); - static keel::Cmd autosaveNow("fry_autosavenow", [](const keel::Args&) { self->autosave(true); }, "Fry3DX: autosave now"); - static keel::Cmd closeAsk("fry_close", [](const keel::Args&) { if (self->allowQuit()) keel::execute("quit"); }, "Fry3DX: as the window's close button"); - static keel::Cmd recover("fry_recover", [](const keel::Args&) { self->findAutosaves(), self->showRecover_ = !self->recover_.empty(); }, "Fry3DX: show the autosaves to recover"); - static keel::Cmd scriptPanel("fry_scriptpanel", [](const keel::Args& a) { self->showScript_ = a.size() < 2 || a[1] != "0"; }, "Fry3DX: show (1) or hide (0) the Script panel"); - static keel::Cmd shapeOp("fry_shapeop", [](const keel::Args& a) { - if (a.size() < 2) return; - if (a[1] == "smooth") self->smoothPointsNow(); - else if (a[1] == "bridge") self->bridgeNow(); - else if (a[1] == "push" && a.size() > 2) { - self->startPush(); - if (Object* o = self->find(self->pushObject_); o && self->showPush_) { - self->pushBy_ = std::stof(a[2]); - pushPoints(o->mesh, self->pushPts_, self->pushBy_); - self->changedShape(*o, true); - self->showPush_ = false; - } - } else if (a[1] == "move" && a.size() > 4) { // the selection (and with soft selection, what's near) moved, world metres - Object* o = self->editing(); - if (!o) return; + if (o.name == a[i]) self->selected_.insert(o.id), self->active_ = o.id; + }, "Fry3DX: fry_pick name... (the first kept when combining)"); + static keel::Cmd copiesCmd("fry_copies", [](const keel::Args& a) { // fry_copies n row dx dy dz | n round angle [joined] + if (a.size() < 3) return; + auto& c = self->copies_; + c.count = std::stoi(a[1]), c.round = a[2] == "round", c.path = a[2] == "path"; + c.follow = std::find(a.begin(), a.end(), "still") == a.end(); + if (c.round && a.size() > 3) c.angle = std::stof(a[3]), c.centre = glm::vec3(0.0f); + if (!c.round && a.size() > 5) c.step = {std::stof(a[3]), std::stof(a[4]), std::stof(a[5])}; + c.joined = a.back() == "joined"; + self->makeCopies(); + }, "Fry3DX: fry_copies n row dx dy dz [joined] | n round angle [joined]"); + static keel::Cmd thickCmd("fry_thicken", [](const keel::Args& a) { + if (a.size() > 1) self->thickness_ = std::abs(std::stof(a[1])), self->thicknessOut_ = std::stof(a[1]) < 0; + self->thickenActive(); + }, "Fry3DX: fry_thicken metres (negative: outwards)"); + static keel::Cmd groupCmd("fry_group", [](const keel::Args& a) { + self->groupSelected(); + if (a.size() > 1 && !self->scene_.folders.empty()) self->scene_.folders.back().name = a[1], self->renamingFolder_ = 0; + }, "Fry3DX: fry_group [name]: the picked into a new folder"); + static keel::Cmd panelsCmd("fry_panels", [](const keel::Args&) { self->panelsOpen_ = !self->panelsOpen_; }, "Fry3DX: phone layout: show / hide the panels"); + static keel::Cmd goCmd("fry_goto", [](const keel::Args&) { self->goTo(self->selected_); }, "Fry3DX: the camera to the picked"); + static keel::Cmd drawCmd("fry_draw", [](const keel::Args& a) { // fry_draw mode thick x y x y x y ... (view 0..1) + if (!self->viewReady()) return self->say("fry_draw: the view isn't up yet (+wait 1 first)"); + if (a.size() < 9) return; + self->setTool(Tool::Draw); + self->drawOnMode_ = std::stoi(a[1]), self->drawThickness_ = std::stof(a[2]); + self->drawPts_.clear(); + const bool snap = self->snap_; + self->snap_ = false; + for (size_t i = 3; i + 1 < a.size(); i += 2) { + ImGuiIO io = ImGui::GetIO(); + io.MousePos = ImVec2(self->viewMin_.x + std::stof(a[i]) * (self->viewMax_.x - self->viewMin_.x), self->viewMin_.y + std::stof(a[i + 1]) * (self->viewMax_.y - self->viewMin_.y)); + if (auto p = self->drawPoint(io)) self->drawPts_.push_back(*p); + } + self->snap_ = snap; + self->finishShape(); + }, "Fry3DX: fry_draw mode(0 new,1 add,2 cut) thickness x y x y x y..."); + static keel::Cmd extrudeCmd("fry_extrude", [](const keel::Args& a) { if (a.size() > 1) self->extrudeNow(std::stof(a[1])); }, "Fry3DX: extrude the picked faces this far (m)"); + static keel::Cmd holeCmd("fry_hole", [](const keel::Args& a) { + Object* o = self->editing() ? self->editing() : self->find(self->active_); + if (!o || a.size() < 3) return; + const int face = std::stoi(a[1]); + if (a.size() > 3) { + const float d = std::stof(a[3]); + self->holeMode_ = d < 0 ? 2 : d == 0 ? 0 : 1; + if (d > 0) self->holeDepth_ = d; + } + if (a.size() > 4) { + static const char* names[] = {"round", "rect", "triangle", "hex"}; + for (int i = 0; i < 4; ++i) + if (a[4] == names[i]) self->holeShape_ = i; + } + const float across = std::stof(a[2]); + if (a.size() > 5) self->holeTall_ = std::stof(a[5]), self->holeSize_ = across; + if (face >= 0 && face < int(o->mesh.faces.size())) self->applyHole(o->id, face, faceCenter(o->mesh, face), across); + }, "Fry3DX: fry_hole face across [depth: 0 through, -1 outline only] [round|rect|triangle|hex] [tall] (metres, the object's own)"); + static keel::Cmd color("fry_color", [](const keel::Args& a) { + if (a.size() > 3) self->paintColor_ = {std::stof(a[1]), std::stof(a[2]), std::stof(a[3]), 1.0f}; + }, "Fry3DX: fry_color r g b (painting, 0..1)"); + static keel::Cmd stroke("fry_stroke", [](const keel::Args& a) { + if (!self->viewReady()) return self->say("fry_stroke: the view isn't up yet (+wait 1 first)"); + if (a.size() < 5) return; + const ImVec2 p0 = vp(a[1], a[2]), p1 = vp(a[3], a[4]); + for (int i = 0; i <= 40; ++i) self->paintAt(ImVec2(p0.x + (p1.x - p0.x) * float(i) / 40, p0.y + (p1.y - p0.y) * float(i) / 40), i == 0); + self->endStroke(); + }, "Fry3DX: fry_stroke x0 y0 x1 y1: a brush stroke across the view (0..1)"); + static keel::Cmd ptool("fry_tooluse", [](const keel::Args& a) { // a plug-in tool by name, clicked at x y (0..1 of the view) + if (a.size() < 4 || !self->viewReady()) return; + for (const PluginEntry& e : self->plugins_) + if (e.p) + for (const fry::PluginTool& t : e.p->tools) + if (t.name == a[1]) self->pluginTool_ = {e.file, t.name}; + self->clickPluginTool(vp(a[2], a[3])); + }, "Fry3DX: fry_tooluse \"name\" x y: click with a plug-in tool (0..1 of the view)"); + static keel::Cmd stencil("fry_stencil", [](const keel::Args& a) { // a box stencil, 0..1 of the view + if (a.size() < 5 || !self->viewReady()) return; + const ImVec2 p0 = vp(a[1], a[2]), p1 = vp(a[3], a[4]); + self->stencil_ = {p0, ImVec2(p1.x, p0.y), p1, ImVec2(p0.x, p1.y)}, self->stencilOn_ = true; + }, "Fry3DX: fry_stencil x0 y0 x1 y1: a box stencil for painting (0..1 of the view)"); + static keel::Cmd paste("fry_pastefile", [](const keel::Args& a) { if (a.size() > 1) self->floatFromFile(a[1]); }, "Fry3DX: place a picture file onto the selected object"); + static keel::Cmd corner("fry_corner", [](const keel::Args& a) { + if (PictureLayer* l = self->editLayer(); l && a.size() > 3) l->corners[size_t(std::clamp(std::stoi(a[1]), 0, 3))] = {std::stof(a[2]), std::stof(a[3])}; + }, "Fry3DX: fry_corner i x y: move a placed picture's corner (picture pixels)"); + static keel::Cmd placePic("fry_lock", [](const keel::Args&) { self->lockLayer(); }, "Fry3DX: lock the picture being moved (Enter)"); + static keel::Cmd flat("fry_pasteflat", [](const keel::Args& a) { if (a.size() > 1) self->pasteOnSurface_ = a[1] == "0"; }, "Fry3DX: fry_pasteflat 1: new pictures go on the flat picture"); + static keel::Cmd unlock("fry_unlock", [](const keel::Args& a) { + Object* o = self->find(self->active_); + if (!o || a.size() < 3 || !self->owner_.count(o->id) || !o->picture) return; + const int x = std::stoi(a[1]), y = std::stoi(a[2]); + if (x < 0 || y < 0 || x >= o->picture->w || y >= o->picture->h) return; + if (const int i = self->owner_[o->id][size_t(y) * size_t(o->picture->w) + size_t(x)]; i > 0) self->unlockLayer(o->id, i - 1); + }, "Fry3DX: fry_unlock x y: click the Picture panel there (picture pixels)"); + static keel::Cmd moveSticker("fry_sticker", [](const keel::Args& a) { + if (!self->viewReady()) return self->say("fry_sticker: the view isn't up yet (+wait 1 first)"); + Object* o = self->find(self->ledit_.object); + PictureLayer* l = self->editLayer(); + if (!o || !l || a.size() < 3) return; + const Ray ray = self->mouseRay(vp(a[1], a[2])); + const Hit h = self->pickObject(ray, o->id, true); + if (!h.object) return self->say("fry_sticker: not on the object there"); + l->face = h.face, l->centre = glm::vec3(glm::inverse(objectMatrix(*o)) * glm::vec4(ray.origin + ray.dir * h.t, 1.0f)); + if (a.size() > 4) l->width = std::stof(a[3]), l->height = std::stof(a[4]); + settleSticker(o->mesh, *l); + }, "Fry3DX: fry_sticker x y [w h]: move the sticker being moved to that point of the view"); + static keel::Cmd capture("fry_capture", [](const keel::Args& a) { if (a.size() > 1) self->savePicture(a[1]); }, "Fry3DX: save a picture of the view"); + static keel::Cmd layout("fry_layout", [](const keel::Args& a) { + Object* o = self->find(self->active_); + if (!o || a.size() < 2) return; + if (a[1] == "start") self->readyToPaint(o->id); + if (a[1] == "all") self->layoutSelectAll(); + if (a[1] == "frame" && a.size() > 2) self->lay_.object = o->id, self->lay_.corners = false, self->lay_.frames = {std::stoi(a[2])}; + if (a[1] == "checker" && a.size() > 2) self->layChecker_ = a[2] == "1"; + if (a[1] == "keep" && a.size() > 2) self->layKeepPaint_ = a[2] == "1"; + if (a[1] == "again") self->layoutAgain(*o); + if (a[1] == "turn" && a.size() > 2) { + const float r = glm::radians(std::stof(a[2])); + self->layoutApply([r](glm::vec2 p, glm::vec2 m) { return m + glm::vec2(std::cos(r) * (p - m).x - std::sin(r) * (p - m).y, std::sin(r) * (p - m).x + std::cos(r) * (p - m).y); }); + } + if (a[1] == "move" && a.size() > 3) { + const glm::vec2 d(std::stof(a[2]), std::stof(a[3])); + self->layoutApply([d](glm::vec2 p, glm::vec2) { return p + d; }); + } + if (a[1] == "scale" && a.size() > 2) { + const float k = std::stof(a[2]); + self->layoutApply([k](glm::vec2 p, glm::vec2 m) { return m + (p - m) * k; }); + } + }, "Fry3DX: fry_layout start|all|frame N|turn deg|move x y|scale k|checker 0/1|keep 0/1|again"); + static keel::Cmd print("fry_print", [](const keel::Args& a) { + if (a.size() < 2) return; + if (a[1] == "bed") self->putOnBed(); + if (a[1] == "layer" && a.size() > 2) self->showLayer_ = true, self->layer_ = std::stoi(a[2]); + if (a[1] == "middle") self->middleOfBed(); + if (a[1] == "printer" && a.size() > 2) keel::execute("fry_printer " + a[2]); + if (a[1] == "size" && a.size() > 3) self->pushUndo(), self->printResize(std::stoi(a[2]), std::stof(a[3])); + if ((a[1] == "measure" || a[1] == "flat") && !self->viewReady()) return self->say("fry_print: the view isn't up yet (+wait 1 first)"); + if (a[1] == "measure" && a.size() > 5) { + self->printTool_ = PrintTool::Measure, self->measure_.clear(); + for (int i = 0; i < 2; ++i) + if (auto p = self->measurePoint(vp(a[size_t(2 + i * 2)], a[size_t(3 + i * 2)]))) self->measure_.push_back(*p); + } + if (a[1] == "flat" && a.size() > 3) { // (as clicking with Lay flat) + self->printTool_ = PrintTool::LayFlat; + ImGuiIO& io = ImGui::GetIO(); + const ImVec2 keep = io.MousePos; + io.MousePos = vp(a[2], a[3]); + const Hit h = self->pickObject(self->mouseRay(io.MousePos)); + io.MousePos = keep; + if (Object* o = h.object ? self->find(h.object) : nullptr) { + const glm::vec3 n = faceNormal(self->printInfo(*o).world, h.face); self->pushUndo(); - const Mesh before = o->mesh; - self->drag_.soft.clear(); - if (self->softSel_) self->findSoft(); - const glm::mat4 m = objectMatrix(*o); - const glm::mat4 local = glm::inverse(m) * glm::translate(glm::mat4(1.0f), glm::vec3(std::stof(a[2]), std::stof(a[3]), std::stof(a[4]))) * m; - for (int v : self->selectedPoints()) o->mesh.verts[size_t(v)] = glm::vec3(local * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)); - for (const auto& [v, w] : self->drag_.soft) o->mesh.verts[size_t(v)] = glm::mix(o->mesh.verts[size_t(v)], glm::vec3(local * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)), w); - self->drag_.soft.clear(); - if (self->liveMirror_) self->mirrorEdits(*o, before); + o->rotation = glm::normalize(glm::rotation(n, glm::vec3(0, 0, -1)) * o->rotation), self->touched(o->id); + o->position.z -= self->printInfo(*o).lo.z, self->touched(o->id); + } + self->printTool_ = PrintTool::None; + } + }, "Fry3DX: fry_print bed|middle|printer N|size axis mm|measure x0 y0 x1 y1|flat x y"); + static keel::Cmd autosaveNow("fry_autosavenow", [](const keel::Args&) { self->autosave(true); }, "Fry3DX: autosave now"); + static keel::Cmd closeAsk("fry_close", [](const keel::Args&) { if (self->allowQuit()) keel::execute("quit"); }, "Fry3DX: as the window's close button"); + static keel::Cmd recover("fry_recover", [](const keel::Args&) { self->findAutosaves(), self->showRecover_ = !self->recover_.empty(); }, "Fry3DX: show the autosaves to recover"); + static keel::Cmd scriptPanel("fry_scriptpanel", [](const keel::Args& a) { self->showScript_ = a.size() < 2 || a[1] != "0"; }, "Fry3DX: show (1) or hide (0) the Script panel"); + static keel::Cmd shapeOp("fry_shapeop", [](const keel::Args& a) { + if (a.size() < 2) return; + if (a[1] == "smooth") self->smoothPointsNow(); + else if (a[1] == "bridge") self->bridgeNow(); + else if (a[1] == "push" && a.size() > 2) { + self->startPush(); + if (Object* o = self->find(self->pushObject_); o && self->showPush_) { + self->pushBy_ = std::stof(a[2]); + pushPoints(o->mesh, self->pushPts_, self->pushBy_); self->changedShape(*o, true); - } else if (a[1] == "soft") self->softSel_ = a.size() < 3 || a[2] != "0", self->softReach_ = a.size() > 3 ? std::stof(a[3]) : self->softReach_; - }, "Fry3DX: shape editing: fry_shapeop smooth | bridge | push metres | soft 1 [reach] | move x y z"); - static keel::Cmd search("fry_search", [](const keel::Args& a) { // fry_search [words]: the command search, typed in - self->openSearch(); - if (a.size() > 1) std::snprintf(self->search_.text, sizeof self->search_.text, "%s", a[1].c_str()); - }, "Fry3DX: open the command search (F3), with words typed in"); - static keel::Cmd runCmd("fry_command", [](const keel::Args& a) { if (a.size() > 1) self->buildCommands(), self->runCommand(a[1]); }, "Fry3DX: run a command by its id (file.save, view.top...)"); - static keel::Cmd pie("fry_pie", [](const keel::Args& a) { - self->pie_ = a.size() > 1 && a[1] == "views" ? 2 : 1, self->pieStays_ = true; - self->pieAt_ = ImVec2((self->viewMin_.x + self->viewMax_.x) / 2, (self->viewMin_.y + self->viewMax_.y) / 2); - }, "Fry3DX: show a pie menu (tools | views)"); - static keel::Cmd import("fry_import", [](const keel::Args& a) { if (a.size() > 1) self->importFile(a[1]); }, "Fry3DX: import a model (STL, OBJ, 3MF, glTF)"); - static keel::Cmd text("fry_text", [](const keel::Args& a) { - if (a.size() > 1) std::snprintf(self->textBuf_, sizeof self->textBuf_, "%s", a[1].c_str()), self->addText(); - }, "Fry3DX: add text as a shape"); - static keel::Cmd spinCmd("fry_spin", [](const keel::Args& a) { // fry_spin degrees steps [rise_mm] [world] - if (a.size() > 2) self->spinDegrees_ = std::stof(a[1]), self->spinSteps_ = std::stoi(a[2]); - self->spinRise_ = a.size() > 3 ? std::stof(a[3]) / 1000 : 0.0f, self->spinWorld_ = a.size() > 4 && a[4] == "world"; - self->spinSelected(); - }, "Fry3DX: spin the picked shape round Z: degrees steps [rise mm] [world]"); - static keel::Cmd slideCmd("fry_slide", [](const keel::Args& a) { // (as though the mouse had gone dx, dy since) - self->startSlide(); - if (a.size() > 2) self->slide_.start = ImVec2(ImGui::GetIO().MousePos.x - std::stof(a[1]), ImGui::GetIO().MousePos.y - std::stof(a[2])); - }, "Fry3DX: start sliding the picked points, as though the mouse had moved dx dy"); - static keel::Cmd about("fry_about", [](const keel::Args&) { self->showAbout_ = true; }, "Fry3DX: show the About window"); - static keel::Cmd plugins("fry_plugins", [](const keel::Args& a) { - if (a.size() > 1 && a[1] == "reload") self->loadPlugins(); - else self->showPlugins_ = true; - }, "Fry3DX: the Plug-ins window (fry_plugins reload: load them again)"); - static keel::Cmd plugin("fry_plugin", [](const keel::Args& a) { // fry_plugin "Command name" [settings...] - if (a.size() < 2) return; - for (size_t i = 0; i < self->plugins_.size(); ++i) - if (self->plugins_[i].p) - for (size_t c = 0; c < self->plugins_[i].p->commands.size(); ++c) - if (sameCommand(self->plugins_[i].p->commands[c].name, a[1])) { - std::vector v; - for (size_t k = 2; k < a.size(); ++k) v.push_back(std::stod(a[k])); - return self->runPluginCommand(i, c, v); - } - keel::log::warn("No plug-in command called {}", a[1]); - }, "Fry3DX: run a plug-in's command: fry_plugin Spiral_stairs 12 0.18 (_ for spaces, any case)"); - keel::execute("r_autoexposure 0; r_ssr 0; r_volumetric 0; r_shafts 0; r_dynres 0; r_renderscale 1; fps_max 144"); + self->showPush_ = false; + } + } else if (a[1] == "move" && a.size() > 4) { // the selection (and with soft selection, what's near) moved, world metres + Object* o = self->editing(); + if (!o) return; + self->pushUndo(); + const Mesh before = o->mesh; + self->drag_.soft.clear(); + if (self->softSel_) self->findSoft(); + const glm::mat4 m = objectMatrix(*o); + const glm::mat4 local = glm::inverse(m) * glm::translate(glm::mat4(1.0f), glm::vec3(std::stof(a[2]), std::stof(a[3]), std::stof(a[4]))) * m; + for (int v : self->selectedPoints()) o->mesh.verts[size_t(v)] = glm::vec3(local * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)); + for (const auto& [v, w] : self->drag_.soft) o->mesh.verts[size_t(v)] = glm::mix(o->mesh.verts[size_t(v)], glm::vec3(local * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)), w); + self->drag_.soft.clear(); + if (self->liveMirror_) self->mirrorEdits(*o, before); + self->changedShape(*o, true); + } else if (a[1] == "soft") self->softSel_ = a.size() < 3 || a[2] != "0", self->softReach_ = a.size() > 3 ? std::stof(a[3]) : self->softReach_; + }, "Fry3DX: shape editing: fry_shapeop smooth | bridge | push metres | soft 1 [reach] | move x y z"); + static keel::Cmd search("fry_search", [](const keel::Args& a) { // fry_search [words]: the command search, typed in + self->openSearch(); + if (a.size() > 1) std::snprintf(self->search_.text, sizeof self->search_.text, "%s", a[1].c_str()); + }, "Fry3DX: open the command search (F3), with words typed in"); + static keel::Cmd runCmd("fry_command", [](const keel::Args& a) { if (a.size() > 1) self->buildCommands(), self->runCommand(a[1]); }, "Fry3DX: run a command by its id (file.save, view.top...)"); + static keel::Cmd pie("fry_pie", [](const keel::Args& a) { + self->pie_ = a.size() > 1 && a[1] == "views" ? 2 : 1, self->pieStays_ = true; + self->pieAt_ = ImVec2((self->viewMin_.x + self->viewMax_.x) / 2, (self->viewMin_.y + self->viewMax_.y) / 2); + }, "Fry3DX: show a pie menu (tools | views)"); + static keel::Cmd import("fry_import", [](const keel::Args& a) { if (a.size() > 1) self->importFile(a[1]); }, "Fry3DX: import a model (STL, OBJ, 3MF, glTF)"); + static keel::Cmd text("fry_text", [](const keel::Args& a) { + if (a.size() > 1) std::snprintf(self->textBuf_, sizeof self->textBuf_, "%s", a[1].c_str()), self->addText(); + }, "Fry3DX: add text as a shape"); + static keel::Cmd spinCmd("fry_spin", [](const keel::Args& a) { // fry_spin degrees steps [rise_mm] [world] + if (a.size() > 2) self->spinDegrees_ = std::stof(a[1]), self->spinSteps_ = std::stoi(a[2]); + self->spinRise_ = a.size() > 3 ? std::stof(a[3]) / 1000 : 0.0f, self->spinWorld_ = a.size() > 4 && a[4] == "world"; + self->spinSelected(); + }, "Fry3DX: spin the picked shape round Z: degrees steps [rise mm] [world]"); + static keel::Cmd slideCmd("fry_slide", [](const keel::Args& a) { // (as though the mouse had gone dx, dy since) + self->startSlide(); + if (a.size() > 2) self->slide_.start = ImVec2(ImGui::GetIO().MousePos.x - std::stof(a[1]), ImGui::GetIO().MousePos.y - std::stof(a[2])); + }, "Fry3DX: start sliding the picked points, as though the mouse had moved dx dy"); + static keel::Cmd about("fry_about", [](const keel::Args&) { self->showAbout_ = true; }, "Fry3DX: show the About window"); + static keel::Cmd plugins("fry_plugins", [](const keel::Args& a) { + if (a.size() > 1 && a[1] == "reload") self->loadPlugins(); + else self->showPlugins_ = true; + }, "Fry3DX: the Plug-ins window (fry_plugins reload: load them again)"); + static keel::Cmd plugin("fry_plugin", [](const keel::Args& a) { // fry_plugin "Command name" [settings...] + if (a.size() < 2) return; + for (size_t i = 0; i < self->plugins_.size(); ++i) + if (self->plugins_[i].p) + for (size_t c = 0; c < self->plugins_[i].p->commands.size(); ++c) + if (sameCommand(self->plugins_[i].p->commands[c].name, a[1])) { + std::vector v; + for (size_t k = 2; k < a.size(); ++k) v.push_back(std::stod(a[k])); + return self->runPluginCommand(i, c, v); + } + keel::log::warn("No plug-in command called {}", a[1]); + }, "Fry3DX: run a plug-in's command: fry_plugin Spiral_stairs 12 0.18 (_ for spaces, any case)"); + keel::execute("r_autoexposure 0; r_ssr 0; r_volumetric 0; r_shafts 0; r_dynres 0; r_renderscale 1; fps_max 144"); #if defined(__ANDROID__) // a phone: none of the heavy parts (and its GPU's driver is kept to well-trodden ground) - keel::execute("r_gpuculling 0; r_occlusion 0; r_volumetricfog 0; r_mirrors 0; r_taa 0; r_shadowres 2048; r_lightshadows 0; fps_max 60"); + keel::execute("r_gpuculling 0; r_occlusion 0; r_volumetricfog 0; r_mirrors 0; r_taa 0; r_shadowres 2048; r_lightshadows 0; fps_max 60"); #endif - newScene(); - loadOwnMaterials(); - loadPlugins(); - loadMyKeys(); - buildCommands(); - if (!startPath_.empty()) openFile(startPath_); - findAutosaves(); - if (startPath_.empty()) resumeWork(); - } + newScene(); + loadOwnMaterials(); + loadPlugins(); + loadMyKeys(); + buildCommands(); + if (!startPath_.empty()) openFile(startPath_); + findAutosaves(); + if (startPath_.empty()) resumeWork(); +} - // Going into the background: the work is saved now (a phone may close the app while it's - // away, to free memory), and a phone's next start carries on with it, view and all. - std::string resumeFile() const { return keel::prefPath() + "resume.txt"; } - void onBackground() override { - if (dirty_) autosave(true); - if (autosaveJob_.valid()) autosaveJob_.wait(); +auto App::onBackground() -> void { + if (dirty_) autosave(true); + if (autosaveJob_.valid()) autosaveJob_.wait(); #if defined(__ANDROID__) - std::ofstream(resumeFile()) << (dirty_ ? autosavePath_ : std::string()) << '\n' - << path_ << '\n' - << int(tab_) << ' ' << target_.x << ' ' << target_.y << ' ' << target_.z << ' ' << yaw_ << ' ' << pitch_ << ' ' << dist_ << '\n'; + std::ofstream(resumeFile()) << (dirty_ ? autosavePath_ : std::string()) << '\n' + << path_ << '\n' + << int(tab_) << ' ' << target_.x << ' ' << target_.y << ' ' << target_.z << ' ' << yaw_ << ' ' << pitch_ << ' ' << dist_ << '\n'; #endif - } - void resumeWork() { +} + +auto App::resumeWork() -> void { #if defined(__ANDROID__) - std::ifstream in(resumeFile()); - if (!in) return; - std::string saved, from; - std::getline(in, saved), std::getline(in, from); - int tab = 0; - glm::vec3 target = target_; - float yaw = yaw_, pitch = pitch_, dist = dist_; - const bool view = bool(in >> tab >> target.x >> target.y >> target.z >> yaw >> pitch >> dist); - in.close(); - std::error_code ec; - stdfs::remove(resumeFile(), ec); - if (!saved.empty() && stdfs::exists(saved, ec)) { // (unsaved work: as recovering it, without asking) - openFile(saved); - path_ = from, dirty_ = true, autosavePath_ = saved; - showRecover_ = false; - } else if (!from.empty()) - openFile(from); - else - return; - if (view) setTab(Tab(std::clamp(tab, 0, 4))), target_ = target, yaw_ = yaw, pitch_ = std::clamp(pitch, -89.0f, 89.0f), dist_ = std::max(dist, 0.01f); - say("Carried on where you left off"); + std::ifstream in(resumeFile()); + if (!in) return; + std::string saved, from; + std::getline(in, saved), std::getline(in, from); + int tab = 0; + glm::vec3 target = target_; + float yaw = yaw_, pitch = pitch_, dist = dist_; + const bool view = bool(in >> tab >> target.x >> target.y >> target.z >> yaw >> pitch >> dist); + in.close(); + std::error_code ec; + stdfs::remove(resumeFile(), ec); + if (!saved.empty() && stdfs::exists(saved, ec)) { // (unsaved work: as recovering it, without asking) + openFile(saved); + path_ = from, dirty_ = true, autosavePath_ = saved; + showRecover_ = false; + } else if (!from.empty()) + openFile(from); + else + return; + if (view) setTab(Tab(std::clamp(tab, 0, 4))), target_ = target, yaw_ = yaw, pitch_ = std::clamp(pitch, -89.0f, 89.0f), dist_ = std::max(dist, 0.01f); + say("Carried on where you left off"); #endif - } +} - void frame(double) override { autosave(); } - bool allowQuit() override { - std::error_code ec; - stdfs::remove(resumeFile(), ec); // (closed on purpose: the next start is a fresh one) - if (!dirty_) return dropAutosave(), true; - askSave_ = 1; - return false; - } - void shutdown() override { - if (autosaveJob_.valid()) autosaveJob_.wait(); - } - void onEscape() override { - if (askSave_) askSave_ = 0; - else if (showRecover_) showRecover_ = false; - else if (editLayer()) cancelFloating(); - else if (!knifePts_.empty()) knifePts_.clear(); - else if (!drawPts_.empty()) drawPts_.clear(), sketchTyped_.clear(); - else if (drag_.active) cancelDrag(); - else if (slide_.active) cancelSlide(); - else if (printTool_ == PrintTool::Measure) printTool_ = PrintTool::None, measure_.clear(); - else if (!pluginTool_.first.empty()) pluginTool_ = {}; - else if (drawingStencil_ || stencilOn_) drawingStencil_ = stencilOn_ = false, stencil_.clear(); - else if (mode_ == Mode::Edit) leaveEdit(); - else selected_.clear(); - } +auto App::allowQuit() -> bool { + std::error_code ec; + stdfs::remove(resumeFile(), ec); // (closed on purpose: the next start is a fresh one) + if (!dirty_) return dropAutosave(), true; + askSave_ = 1; + return false; +} - void render(keel::Renderer& r) override { - r_ = &r; - captureStep(); - setupUi(); - loadLogo(); - applyShading(); - takeDialogResult(); - ImGuiIO& io = ImGui::GetIO(); - if (touch()) touchFrame(io); - if (!pendingLayout_.empty()) ImGui::LoadIniSettingsFromMemory(pendingLayout_.data(), pendingLayout_.size()), pendingLayout_.clear(); // (a workspace: before any panel) - const ImGuiID dock = ImGui::DockSpaceOverViewport(0, nullptr, ImGuiDockNodeFlags_PassthruCentralNode); - if (touch() && (io.DisplaySize.y > io.DisplaySize.x) != portrait_) portrait_ = !portrait_, layoutDone_ = false; // (the phone turned: its layout) - if (!layoutDone_) defaultLayout(dock); - if (!touch()) keepMinSizes(ImGui::DockBuilderGetNode(dock)); - if (ImGuiDockNode* c = ImGui::DockBuilderGetCentralNode(dock)) viewMin_ = c->Pos, viewMax_ = ImVec2(c->Pos.x + c->Size.x, c->Pos.y + c->Size.y); - updateCamera(io); - menuBar(); - toolbox(); - toolOptions(); - if (!touch() || panelsOpen_) { - layersPanel(); - propertiesPanel(); - if (tab_ == Tab::Paint || tab_ == Tab::Layout) picturePanel(); +auto App::onEscape() -> void { + if (askSave_) askSave_ = 0; + else if (showRecover_) showRecover_ = false; + else if (editLayer()) cancelFloating(); + else if (!knifePts_.empty()) knifePts_.clear(); + else if (!drawPts_.empty()) drawPts_.clear(), sketchTyped_.clear(); + else if (drag_.active) cancelDrag(); + else if (slide_.active) cancelSlide(); + else if (printTool_ == PrintTool::Measure) printTool_ = PrintTool::None, measure_.clear(); + else if (!pluginTool_.first.empty()) pluginTool_ = {}; + else if (drawingStencil_ || stencilOn_) drawingStencil_ = stencilOn_ = false, stencil_.clear(); + else if (mode_ == Mode::Edit) leaveEdit(); + else selected_.clear(); +} + +auto App::render(keel::Renderer& r) -> void { + r_ = &r; + captureStep(); + setupUi(); + loadLogo(); + applyShading(); + takeDialogResult(); + ImGuiIO& io = ImGui::GetIO(); + if (touch()) touchFrame(io); + if (!pendingLayout_.empty()) ImGui::LoadIniSettingsFromMemory(pendingLayout_.data(), pendingLayout_.size()), pendingLayout_.clear(); // (a workspace: before any panel) + const ImGuiID dock = ImGui::DockSpaceOverViewport(0, nullptr, ImGuiDockNodeFlags_PassthruCentralNode); + if (touch() && (io.DisplaySize.y > io.DisplaySize.x) != portrait_) portrait_ = !portrait_, layoutDone_ = false; // (the phone turned: its layout) + if (!layoutDone_) defaultLayout(dock); + if (!touch()) keepMinSizes(ImGui::DockBuilderGetNode(dock)); + if (ImGuiDockNode* c = ImGui::DockBuilderGetCentralNode(dock)) viewMin_ = c->Pos, viewMax_ = ImVec2(c->Pos.x + c->Size.x, c->Pos.y + c->Size.y); + updateCamera(io); + menuBar(); + toolbox(); + toolOptions(); + if (!touch() || panelsOpen_) { + layersPanel(); + propertiesPanel(); + if (tab_ == Tab::Paint || tab_ == Tab::Layout) picturePanel(); + } + if (!touch()) statusBar(); + if (!capturing() && (!touch() || viewsOpen_)) viewButtons(); + if (!capturing() && touch()) touchBar(); + if (showScript_) scriptPanel(); + if (showHelp_) helpWindow(); + if (showAbout_) aboutWindow(); + if (showHistory_) historyWindow(); + if (showWorkspaces_) workspacesWindow(); + if (showPlugins_) pluginsWindow(); + commandForm(); + copiesWindow(); + thickenWindow(); + textWindow(); + printSizeWindow(); + spinWindow(); + pushWindow(); + saveQuestion(); + recoverWindow(); + commandSearch(); + if (!askSave_ && !showRecover_ && !search_.open) shortcuts(io); + if (pie_) return draw3D(r), overlays(), pieFrame(io); + viewport(io); + draw3D(r); + overlays(); +} + +// (each object's shape as last kept: the next step shares it if it's the same) +auto App::snapshot(std::string label, double at) -> Snapshot { + Snapshot s; + s.scene.look = scene_.look, s.scene.folders = scene_.folders, s.scene.references = scene_.references, s.scene.nextId = scene_.nextId; // (not the scripts: not undone) + s.scene.objects.reserve(scene_.objects.size()); + std::map kept; + for (Object& o : scene_.objects) { + Mesh mesh = std::move(o.mesh); + auto uvs = std::move(o.uvs); + s.scene.objects.push_back(o); // (all but its shape) + o.mesh = std::move(mesh), o.uvs = std::move(uvs); + Shape k = lastShapes_.count(o.id) ? lastShapes_[o.id] : Shape{}; + if (!k.mesh || k.mesh->verts != o.mesh.verts || k.mesh->faces != o.mesh.faces) k.mesh = std::make_shared(o.mesh); + if (!k.uvs || *k.uvs != o.uvs) k.uvs = std::make_shared>>(o.uvs); + s.shapes[o.id] = kept[o.id] = k; + } + lastShapes_ = std::move(kept); // (gone objects' shapes: only in the steps that have them) + s.selected = selected_, s.active = active_, s.mode = mode_, s.verts = selVerts_, s.faces = selFaces_, s.edges = selEdges_; + s.label = std::move(label), s.at = at; + return s; +} + +// ───────────────────────────── document ───────────────────────────── + +auto App::find(uint32_t id) -> Object* { + for (Object& o : scene_.objects) + if (o.id == id) return &o; + return nullptr; +} + +auto App::findRef(uint32_t id) -> Reference* { + for (Reference& r : scene_.references) + if (r.id == id) return &r; + return nullptr; +} + +auto App::shapeKey(const Object& o) -> uint64_t { + ShapeKey& k = shapeKeys_[o.id]; + const uint64_t v = versions_[o.id]; + if (k.version == v && v) return k.key; + uint64_t h = 1469598103934665603ull; + auto mix = [&](const void* p, size_t n) { + const auto* b = static_cast(p); + size_t i = 0; + for (; i + 8 <= n; i += 8) { // (8 bytes at a time: a big mesh in a few ms) + uint64_t w; + std::memcpy(&w, b + i, 8); + h = (h ^ w) * 1099511628211ull; } - if (!touch()) statusBar(); - if (!capturing() && (!touch() || viewsOpen_)) viewButtons(); - if (!capturing() && touch()) touchBar(); - if (showScript_) scriptPanel(); - if (showHelp_) helpWindow(); - if (showAbout_) aboutWindow(); - if (showHistory_) historyWindow(); - if (showWorkspaces_) workspacesWindow(); - if (showPlugins_) pluginsWindow(); - commandForm(); - copiesWindow(); - thickenWindow(); - textWindow(); - printSizeWindow(); - spinWindow(); - pushWindow(); - saveQuestion(); - recoverWindow(); - commandSearch(); - if (!askSave_ && !showRecover_ && !search_.open) shortcuts(io); - if (pie_) return draw3D(r), overlays(), pieFrame(io); - viewport(io); - draw3D(r); - overlays(); - } - -private: - // ── The document ── - Scene scene_; - std::string startPath_, path_; - bool dirty_ = false; - // An undo step. Its objects without their shapes (points, faces, painting's layout): those are - // kept apart and shared with the steps before and after where they're the same, so a step - // that moved one thing doesn't copy every shape in the scene again. - struct Shape { - std::shared_ptr mesh; - std::shared_ptr>> uvs; + for (; i < n; ++i) h = (h ^ b[i]) * 1099511628211ull; }; - struct Snapshot { - Scene scene; - std::map shapes; - std::set selected; - uint32_t active; - Mode mode; - std::set verts, faces; - std::set> edges; - std::string label; // (what was done after it: the message said then) - double at = 0; - }; - std::vector undo_, redo_; - std::map lastShapes_; // (each object's shape as last kept: the next step shares it if it's the same) - Snapshot snapshot(std::string label, double at) { - Snapshot s; - s.scene.look = scene_.look, s.scene.folders = scene_.folders, s.scene.references = scene_.references, s.scene.nextId = scene_.nextId; // (not the scripts: not undone) - s.scene.objects.reserve(scene_.objects.size()); - std::map kept; - for (Object& o : scene_.objects) { - Mesh mesh = std::move(o.mesh); - auto uvs = std::move(o.uvs); - s.scene.objects.push_back(o); // (all but its shape) - o.mesh = std::move(mesh), o.uvs = std::move(uvs); - Shape k = lastShapes_.count(o.id) ? lastShapes_[o.id] : Shape{}; - if (!k.mesh || k.mesh->verts != o.mesh.verts || k.mesh->faces != o.mesh.faces) k.mesh = std::make_shared(o.mesh); - if (!k.uvs || *k.uvs != o.uvs) k.uvs = std::make_shared>>(o.uvs); - s.shapes[o.id] = kept[o.id] = k; - } - lastShapes_ = std::move(kept); // (gone objects' shapes: only in the steps that have them) - s.selected = selected_, s.active = active_, s.mode = mode_, s.verts = selVerts_, s.faces = selFaces_, s.edges = selEdges_; - s.label = std::move(label), s.at = at; - return s; + mix(o.mesh.verts.data(), o.mesh.verts.size() * sizeof(glm::vec3)); + for (const auto& f : o.mesh.faces) { + const uint64_t n = f.size(); + mix(&n, 8), mix(f.data(), f.size() * sizeof(int)); } - std::vector redoKept_; // (what redo had before the last undo point: back if that step is dropped) + for (const auto& [a, b] : o.creases) mix(&a, 4), mix(&b, 4); + for (const auto& f : o.uvs) mix(f.data(), f.size() * sizeof(glm::vec2)); + const uint64_t cut = cutKeys_.count(o.id) ? cutKeys_[o.id] : 0; + const void* pic = o.picture.get(); + mix(&cut, 8), mix(&pic, sizeof pic); + mix(&o.mirror, 4), mix(&o.round, 4), mix(&o.roundAngle, 4), mix(&o.roundSteps, 4), mix(&o.smoothLevels, 4), mix(&o.smoothKeepSharp, 1), mix(&o.smooth, 1), mix(&o.smoothAngle, 4); + k = {v, h ? h : 1}; + return k.key; +} - Mode mode_ = Mode::Object; - Tool tool_ = Tool::Move; - Elem elem_ = Elem::Face; - std::set selected_; - uint32_t active_ = 0; // the object last picked (edited in shape editing) - uint32_t activeRef_ = 0; // a reference picture picked in the Layers panel - std::set selVerts_, selFaces_; - std::set> selEdges_; +auto App::say(std::string text) -> void { + message_ = std::move(text), messageAt_ = now(); + if (!undo_.empty() && undo_.back().label.empty() && now() - undo_.back().at < 1.0) undo_.back().label = message_; // (names the step just done) + keel::log::info("{}", message_); +} - // ── Tabs: modelling, painting pictures onto it, making a picture of it ── - Tab tab_ = Tab::Model; +auto App::pushUndo() -> void { + undo_.push_back(snapshot("", now())); + if (undo_.size() > 200) undo_.erase(undo_.begin()); + redoKept_ = std::move(redo_), redo_.clear(); + dirty_ = true, ++edits_; +} - // ── The view ── - keel::Renderer* r_ = nullptr; - glm::vec2 shift_{0.0f}; // (the view's middle on the screen: it sits between the panels) - View view_ = View::Perspective; - glm::vec3 target_{0, 0, 0.4f}; - float yaw_ = -135.0f, pitch_ = 25.0f, dist_ = 4.0f, fov_ = 50.0f; - glm::vec2 nearFar_{2.0f, 16384.0f}; - std::map> radii_; // (each object's mesh: how far it reaches from its place) - ImVec2 viewMin_{0, 0}, viewMax_{1, 1}; - glm::mat4 vp_{1.0f}; - glm::vec3 eye_{0.0f}; - bool orbiting_ = false, panning_ = false; - bool xray_ = false, showGrid_ = true, renderGrid_ = false; - std::string appliedLook_; - keel::ModelId ground_{}; - bool haveGround_ = false; - Shading shading_ = Shading::Solid; - int appliedShading_ = -1; +// The undo point just made, dropped (what it was for didn't happen): redo as it was before. +auto App::dropUndo() -> void { + if (!undo_.empty()) undo_.pop_back(); + redo_ = std::move(redoKept_), redoKept_.clear(); +} - // ── Tools ── - bool snap_ = false; - int snapKind_ = 0; // (moving: 0 steps, 1 points, 2 edges' middles, 3 faces' middles) - float snapMove_ = 0.05f, snapAngle_ = 15.0f, snapScale_ = 0.1f; - float drawThickness_ = 0.05f, extrudeAmount_ = 0.1f, insetAmount_ = 0.2f, mergeDistance_ = 0.001f; - float bevelWidth_ = 0.05f; - int bevelSteps_ = 3, cuts_ = 1; - Orient orientObject_ = Orient::Local, orientEdit_ = Orient::Normal; - Pivot rotatePivot_ = Pivot::Middle, scalePivot_ = Pivot::Opposite; - SelShape selShape_ = SelShape::Box; - float brushRadius_ = 30.0f; - std::vector drawPts_; - // Drawing on an object: the plane of the face the first point went on, and what's made with it - // (0 a new object standing on it, 1 added to it, 2 cut into it). - glm::vec3 drawOnP_{0.0f}, drawOnN_{0.0f, 0.0f, 1.0f}; - uint32_t drawOnto_ = 0; - int drawOnMode_ = 0; - struct Drag { - bool active = false; - int handle = -1; // 0-2 an axis, 3 free / even / about the view, 4-6 an axis's other end - Tool tool = Tool::Select; - Axes axes{}; - glm::vec3 pivot{0.0f}, anchor{0.0f}, handlePoint{0.0f}; - ImVec2 startMouse{0, 0}; - float startT = 0.0f, startDist = 0.0f, startMid = 0.0f; // (the handle's way from what stays put; from the middle) - glm::vec3 startHit{0.0f}; - Scene before; - std::set> bevelEdges; - std::vector> soft; // points near the selection that follow it, and how much - std::string typed; // (a number typed while dragging: exactly that much) - } drag_; - // Soft selection (shape editing): points within `softReach_` of the selection follow it, less - // the further they are (Blender's proportional editing, Maya's soft select). - bool softSel_ = false; - float softReach_ = 0.3f; - // Push out / pull in (Alt+S): the points and the shape as it was when it started. - bool showPush_ = false; - float pushBy_ = 0.0f; - std::set pushPts_; - Mesh pushFrom_; - uint32_t pushObject_ = 0; - bool boxing_ = false, painting_ = false, lassoing_ = false, tapDraw_ = false; - ImVec2 boxStart_{0, 0}; - std::vector lasso_; - std::pair cutHover_{-1, -1}; - // The knife: points clicked on the screen (or a straight drag). - std::vector knifePts_; - bool knifeDown_ = false; - ImVec2 knifePress_{0, 0}; - bool knifeThrough_ = false, knifeFill_ = true; - int knifeAfter_ = 1; // cut right through, then: 0 just the lines, 1 pieces of the one object, 2 objects of their own, 3 the smaller ones gone - // Holes: pressed on a face, dragged out to size. - // (sizes in metres where it stands: across = a round's width, a rectangle's width, a triangle's - // side, a hexagon's flats; tall: a rectangle's height) - float holeSize_ = 0.2f, holeTall_ = 0.1f, holeDepth_ = 0.1f, holeTurn_ = 0.0f, holeRoom_ = 0.0003f; - int holeShape_ = 0, holeSides_ = 32; // a HoleShape - int holeMode_ = 0; // 0 all the way through, 1 a pocket, 2 the outline only (a new face) - struct HoleDrag { - bool active = false; - uint32_t object = 0; - int face = -1; - glm::vec3 centre{0.0f}; // (the object's own space) - float radius = 0.0f; - ImVec2 press{0, 0}; - } hole_; +// Back to the undo point just made, and it dropped. +auto App::takeBack() -> void { + if (undo_.empty()) return; + restore(undo_.back()); + dropUndo(); +} - // ── Painting ── - PaintTool paintTool_ = PaintTool::Brush; - glm::vec4 paintColor_{0.85f, 0.25f, 0.1f, 1.0f}; // a: how strong - float brushPx_ = 16.0f, hardness_ = 0.5f, fillTolerance_ = 0.15f; - int pictureSize_ = 1024; - bool showUvLines_ = true; - struct PaintStroke { - bool active = false, in2D = false, haveLast = false, waiting = false; // waiting: pressed off the object, starts when the brush gets to it - uint32_t object = 0; - std::shared_ptr image; // (the object's picture: a copy made for this stroke) - Stroke stroke; - glm::vec3 last{0.0f}; - glm::vec2 last2D{0.0f}; - } paint_; - // Sculpting (the Paint tab's Shape side): brushes that push the surface about like clay. - bool sculpting_ = false; - SculptBrush sculptBrush_ = SculptBrush::Draw; - float sculptStrength_ = 0.5f, sculptPx_ = 60.0f; // (its own brush size: sculpting wants a bigger one than painting) - bool paintMirror_ = false, stencilOn_ = false, drawingStencil_ = false; - std::vector stencil_; // (on the screen) - bool sculptMirror_ = false; // the same on the other side (left and right: the object's Y) - struct SculptStroke { - bool active = false, haveLast = false, waiting = false; - uint32_t object = 0; - std::unique_ptr mesh; - glm::vec3 last{0.0f}; - // Grab: where it was taken hold of (the world), and what (the object's own space). - glm::vec3 start{0.0f}; - std::vector> held[2]; - std::vector from[2]; - } sculpt_; - // A picture layer unlocked to be moved: which, and what's being dragged (a corner 0-3, 4 the - // whole of it, 5 the turning handle) from where. - struct LayerEdit { - uint32_t object = 0; - int index = -1, drag = -1; - bool fresh = false; // (just pasted: Esc takes it away again) - glm::vec2 grab{0.0f}, offset{0.0f}; - std::array startCorners{}; - PictureLayer start; - } ledit_; - bool pasteOnSurface_ = true; - uint32_t renamingFolder_ = 0; - // Touch (set when it starts: fry_touch). - bool touchMode_ = false, portrait_ = false, showToolbox_ = false, panelsOpen_ = false, viewsOpen_ = false, shiftLatch_ = false, ctrlLatch_ = false, touchOrbit_ = false; - struct Gesture { - bool two = false, longDone = false, rightUp = false; - ImVec2 mid{}, downPos{}; - float spread = 1; - double downAt = -1; - } gest_; - std::string captureUri_; // (a picture of the view going into a picked file) - struct CopiesSetting { - int count = 3, axis = 2; - bool round = false, joined = false, path = false, follow = true; // (path: along the last picked object) - glm::vec3 step{0.3f, 0.0f, 0.0f}, centre{0.0f}; - float angle = 360.0f; - } copies_; - bool showCopies_ = false, showThicken_ = false, thicknessOut_ = false; - float thickness_ = 0.002f; - // The Layout tab: what's picked (frames, or corners as face and corner) and what's dragged. - struct LayoutState { - uint32_t object = 0; - bool corners = false; - std::set frames; - std::set> points; - int drag = 0; // 1 moving / turning / sizing, 2 a box - glm::vec2 grab{0.0f}, centre{0.0f}; - ImVec2 boxFrom{}; - std::vector> start; - std::shared_ptr startPicture; - double remapAt = 0; - } lay_; - LayTool layTool_ = LayTool::Move; - bool layKeepPaint_ = true, layChecker_ = false; - // The Print tab. - PrintTool printTool_ = PrintTool::None; - std::vector measure_; - bool measureSnap_ = true, showOverhangs_ = true, sizing_ = false, showThin_ = true, showLayer_ = false; - int layer_ = 1; - float layerHeight_ = 0.0002f; // (m) - float minWall_ = 0.0008f; // (m: the thinnest wall that prints well, twice a 0.4 mm nozzle) - float overhangDeg_ = 45.0f, infill_ = 15.0f; - int material_ = 0; - std::map printInfos_; - keel::ModelId bed_{}; - glm::vec3 bedFor_{0.0f}; - // Closing, New or Open with changes not saved: asked first (askSave_), then done; afterSave_: - // what to do once the Save it chose has finished (0 nothing, 1 quit, 2 new, 3 open). - int askSave_ = 0, afterSave_ = 0; // (what to do then: 1 quit, 2 new, 3 open..., 4 open dropped_) - std::string dropped_; - bool dialogCancelled_ = false; - // Autosave: a copy packed small in the background every fry_autosave minutes while there are - // changes, kept until saved or closed properly; offered back after a crash. - std::string autosavePath_; - uint64_t edits_ = 0, autosavedEdits_ = 0; - double lastAutosave_ = 0; - std::future autosaveJob_; - std::vector recover_; - bool showRecover_ = false; - std::map> owner_; // which layer shows at each pixel - struct Uploaded { - uint64_t stamp = 0; - double at = 0; - bool dirty = false; - }; - std::map uploaded_; - std::map paintTex_; - float picZoom_ = 0.0f; // the Picture panel: screen pixels a picture pixel, and where its corner is - ImVec2 picPan_{0, 0}; - uint32_t picFor_ = 0; +auto App::restore(Snapshot& s) -> void { + // (Not the scripts: they're edited like text, not undone with the model.) + std::vector scripts = std::move(scene_.scripts); + scene_ = s.scene, selected_ = s.selected, active_ = s.active, mode_ = s.mode, followAt_.clear(); + scene_.scripts = std::move(scripts); + for (Object& o : scene_.objects) + if (auto it = s.shapes.find(o.id); it != s.shapes.end()) o.mesh = *it->second.mesh, o.uvs = *it->second.uvs; + lastShapes_ = s.shapes; + selVerts_ = s.verts, selFaces_ = s.faces, selEdges_ = s.edges; + if (mode_ == Mode::Edit && !find(active_)) mode_ = Mode::Object; + touchAll(); + drawPts_.clear(); +} - // ── Saving a picture of the view (cropped to it, without the gizmos) ── - int captureFrames_ = 0; - std::string capturePath_; +auto App::undo() -> void { + if (undo_.empty()) return say("Nothing to undo"); + redo_.push_back(snapshot(undo_.back().label, now())); // (its name: the step it brings back) + restore(undo_.back()); + undo_.pop_back(); + redoKept_.clear(); +} - // ── GPU side ── - std::map> models_; // object id → its model, the state built - std::map versions_; - uint64_t versionClock_ = 1; - struct Shown { - uint64_t version = 0; // (the shape key it was made for) - bool mirrorOnly = false; - Mesh mesh; - std::future coming; // (a big one being made on another thread) - uint64_t comingVersion = 0; - bool comingMirror = false; - }; - std::map shown_; - struct Picture { - bool ok = false; - float aspect = 1.0f; - }; - std::map pictures_; - keel::TextureId logo_{}; - bool logoTried_ = false, haveLogo_ = false; +auto App::redo() -> void { + if (redo_.empty()) return say("Nothing to redo"); + undo_.push_back(snapshot(redo_.back().label, now() - 10)); + restore(redo_.back()); + redo_.pop_back(); + redoKept_.clear(); +} - // ── UI ── - int appliedTheme_ = -1; - // The Script panel: the script being written, where it's from, what it printed. - bool showScript_ = false, showScriptHelp_ = false; - size_t script_ = 0; // the file's script shown in the Script panel - std::string scriptPath_; // where it was last saved or opened from (Save as starts there) - // Plug-ins (Script > Plug-ins): each file, whether it's on, and how loading it went. - struct PluginEntry { - std::string path, file, error; - bool bundled = false, on = true; - std::unique_ptr p; - }; - std::vector plugins_; - bool showPlugins_ = false; - std::pair pluginTool_; // (the plug-in tool that's on: its file and name) - std::string pluginFilter_; - // ── Commands: found by typing, on keys of your own, round the mouse ── - std::vector commands_; - fry::CommandSearch search_; - std::vector recentCommands_; - std::map myKeys_; // (a command's id: the key you gave it) - int pie_ = 0; // 1: the tools round the mouse (Space held), 2: the views (Shift+Space) - ImVec2 pieAt_{0, 0}; - bool pieStays_ = false; // (opened from the menu or console: until a click) - std::string settingKey_; - bool showHistory_ = false; - // ── Workspaces: the panels as they were, kept under a name ── - bool showWorkspaces_ = false, layoutAgain_ = false; - std::string pendingLayout_; - char workspaceName_[64] = "My workspace"; // (the command waiting for its new key, in Keys and mouse) - // A plug-in's command waiting for its settings (the form), and a material being named. - struct PendingCommand { - size_t plugin = 0, command = 0; - std::vector values; - }; - std::optional pendingCommand_; - bool nameMaterial_ = false; - std::string materialName_; - bool materialKeepColor_ = false; - std::vector> scriptLog_; // (line, an error) - bool scriptLogToBottom_ = false; - bool styled_ = false, layoutDone_ = false, showHelp_ = false, showAbout_ = false; - std::string iniPath_, message_; - double messageAt_ = -100.0; - char renameBuf_[128] = ""; - uint32_t renaming_ = 0; - std::mutex dialogMutex_; - std::string dialogResult_; - int dialogKind_ = 0; // 1 open, 2 save, 3 export, 4 reference picture, 5 picture of the view, 6 a picture to paint on, 7 save the painted picture - int pendingKind_ = 0, dialogFilter_ = -1; +auto App::newScene() -> void { + scene_ = {}, followAt_.clear(); + script_ = 0, scriptPath_.clear(); + path_.clear(); + undo_.clear(), redo_.clear(), selected_.clear(), lastShapes_.clear(); + mode_ = Mode::Object, active_ = 0; + addShape("cube", false); + dirty_ = false; + target_ = {0, 0, 0.5f}, dist_ = 4.5f, view_ = View::Perspective, fov_ = 50, yaw_ = -135, pitch_ = 25; +} - // ───────────────────────────── document ───────────────────────────── +auto App::addObject(const std::string& base, Mesh mesh) -> Object& { + Object o; + o.id = scene_.nextId++; + o.name = uniqueName(base); + o.mesh = std::move(mesh); + scene_.objects.push_back(std::move(o)); + touched(scene_.objects.back().id); + return scene_.objects.back(); +} - Object* find(uint32_t id) { - for (Object& o : scene_.objects) - if (o.id == id) return &o; - return nullptr; - } - Reference* findRef(uint32_t id) { - for (Reference& r : scene_.references) - if (r.id == id) return &r; - return nullptr; - } - Object* editing() { return mode_ == Mode::Edit ? find(active_) : nullptr; } - void touched(uint32_t id) { versions_[id] = ++versionClock_, dirty_ = true, ++edits_; } - // What decides the shape an object shows, as a number: its points and faces, its finishing - // touches, creases, cuts, shading and painting's layout (not where it is). Worked out again - // only when it's been touched: moving a smoothed object doesn't smooth it again each frame. - struct ShapeKey { - uint64_t version = 0, key = 0; - }; - std::map shapeKeys_; - uint64_t shapeKey(const Object& o) { - ShapeKey& k = shapeKeys_[o.id]; - const uint64_t v = versions_[o.id]; - if (k.version == v && v) return k.key; - uint64_t h = 1469598103934665603ull; - auto mix = [&](const void* p, size_t n) { - const auto* b = static_cast(p); - size_t i = 0; - for (; i + 8 <= n; i += 8) { // (8 bytes at a time: a big mesh in a few ms) - uint64_t w; - std::memcpy(&w, b + i, 8); - h = (h ^ w) * 1099511628211ull; - } - for (; i < n; ++i) h = (h ^ b[i]) * 1099511628211ull; - }; - mix(o.mesh.verts.data(), o.mesh.verts.size() * sizeof(glm::vec3)); - for (const auto& f : o.mesh.faces) { - const uint64_t n = f.size(); - mix(&n, 8), mix(f.data(), f.size() * sizeof(int)); - } - for (const auto& [a, b] : o.creases) mix(&a, 4), mix(&b, 4); - for (const auto& f : o.uvs) mix(f.data(), f.size() * sizeof(glm::vec2)); - const uint64_t cut = cutKeys_.count(o.id) ? cutKeys_[o.id] : 0; - const void* pic = o.picture.get(); - mix(&cut, 8), mix(&pic, sizeof pic); - mix(&o.mirror, 4), mix(&o.round, 4), mix(&o.roundAngle, 4), mix(&o.roundSteps, 4), mix(&o.smoothLevels, 4), mix(&o.smoothKeepSharp, 1), mix(&o.smooth, 1), mix(&o.smoothAngle, 4); - k = {v, h ? h : 1}; - return k.key; - } - void touchAll() { - for (const Object& o : scene_.objects) versions_[o.id] = ++versionClock_; - } - // Its shape has been changed by hand: the library settings no longer apply. - void handMade(Object& o) { o.recipe.clear(), o.settings.clear(); } - void say(std::string text) { - message_ = std::move(text), messageAt_ = now(); - if (!undo_.empty() && undo_.back().label.empty() && now() - undo_.back().at < 1.0) undo_.back().label = message_; // (names the step just done) - keel::log::info("{}", message_); - } +auto App::uniqueName(const std::string& base) -> std::string { + auto taken = [&](const std::string& n) { return std::any_of(scene_.objects.begin(), scene_.objects.end(), [&](const Object& o) { return o.name == n; }); }; + if (!taken(base)) return base; + for (int i = 2;; ++i) + if (!taken(std::format("{} {}", base, i))) return std::format("{} {}", base, i); +} - void pushUndo() { - undo_.push_back(snapshot("", now())); - if (undo_.size() > 200) undo_.erase(undo_.begin()); - redoKept_ = std::move(redo_), redo_.clear(); - dirty_ = true, ++edits_; - } - // The undo point just made, dropped (what it was for didn't happen): redo as it was before. - void dropUndo() { - if (!undo_.empty()) undo_.pop_back(); - redo_ = std::move(redoKept_), redoKept_.clear(); - } - // Back to the undo point just made, and it dropped. - void takeBack() { - if (undo_.empty()) return; - restore(undo_.back()); - dropUndo(); - } - void restore(Snapshot& s) { - // (Not the scripts: they're edited like text, not undone with the model.) - std::vector scripts = std::move(scene_.scripts); - scene_ = s.scene, selected_ = s.selected, active_ = s.active, mode_ = s.mode, followAt_.clear(); - scene_.scripts = std::move(scripts); - for (Object& o : scene_.objects) - if (auto it = s.shapes.find(o.id); it != s.shapes.end()) o.mesh = *it->second.mesh, o.uvs = *it->second.uvs; - lastShapes_ = s.shapes; - selVerts_ = s.verts, selFaces_ = s.faces, selEdges_ = s.edges; - if (mode_ == Mode::Edit && !find(active_)) mode_ = Mode::Object; - touchAll(); - drawPts_.clear(); - } - void undo() { - if (undo_.empty()) return say("Nothing to undo"); - redo_.push_back(snapshot(undo_.back().label, now())); // (its name: the step it brings back) - restore(undo_.back()); - undo_.pop_back(); - redoKept_.clear(); - } - void redo() { - if (redo_.empty()) return say("Nothing to redo"); - undo_.push_back(snapshot(redo_.back().label, now() - 10)); - restore(redo_.back()); - redo_.pop_back(); - redoKept_.clear(); - } +// A shape from the library, standing on the ground under the middle of the view. +auto App::addShape(const std::string& id, bool undoable) -> void { + const ShapeRecipe* r = findRecipe(id); + if (!r) return say("No shape called " + id); + if (undoable) pushUndo(); + if (mode_ == Mode::Edit) leaveEdit(); + Object& o = addObject(r->name, makeFromRecipe(*r, defaultSettings(*r))); + o.recipe = r->id, o.settings = defaultSettings(*r), o.smooth = r->smooth; + o.position = glm::vec3(std::round(target_.x * 10) / 10, std::round(target_.y * 10) / 10, 0.0f); + if (view_ == View::Front || view_ == View::Back) o.position.x = 0; + if (view_ == View::Left || view_ == View::Right) o.position.y = 0; + selected_ = {o.id}, active_ = o.id; + if (undoable) say(std::format("{} added: its sizes are in Properties", r->name)); +} - void newScene() { - scene_ = {}, followAt_.clear(); - script_ = 0, scriptPath_.clear(); - path_.clear(); - undo_.clear(), redo_.clear(), selected_.clear(), lastShapes_.clear(); - mode_ = Mode::Object, active_ = 0; - addShape("cube", false); - dirty_ = false; - target_ = {0, 0, 0.5f}, dist_ = 4.5f, view_ = View::Perspective, fov_ = 50, yaw_ = -135, pitch_ = 25; - } - Object& addObject(const std::string& base, Mesh mesh) { - Object o; - o.id = scene_.nextId++; - o.name = uniqueName(base); - o.mesh = std::move(mesh); - scene_.objects.push_back(std::move(o)); - touched(scene_.objects.back().id); - return scene_.objects.back(); - } - std::string uniqueName(const std::string& base) { - auto taken = [&](const std::string& n) { return std::any_of(scene_.objects.begin(), scene_.objects.end(), [&](const Object& o) { return o.name == n; }); }; - if (!taken(base)) return base; - for (int i = 2;; ++i) - if (!taken(std::format("{} {}", base, i))) return std::format("{} {}", base, i); - } - - // A shape from the library, standing on the ground under the middle of the view. - void addShape(const std::string& id, bool undoable = true) { - const ShapeRecipe* r = findRecipe(id); - if (!r) return say("No shape called " + id); - if (undoable) pushUndo(); - if (mode_ == Mode::Edit) leaveEdit(); - Object& o = addObject(r->name, makeFromRecipe(*r, defaultSettings(*r))); - o.recipe = r->id, o.settings = defaultSettings(*r), o.smooth = r->smooth; - o.position = glm::vec3(std::round(target_.x * 10) / 10, std::round(target_.y * 10) / 10, 0.0f); - if (view_ == View::Front || view_ == View::Back) o.position.x = 0; - if (view_ == View::Left || view_ == View::Right) o.position.y = 0; - selected_ = {o.id}, active_ = o.id; - if (undoable) say(std::format("{} added: its sizes are in Properties", r->name)); - } - - void deleteSelected() { - if (mode_ == Mode::Edit) return deleteElements(); - if (activeRef_ && selected_.empty()) { - pushUndo(); - std::erase_if(scene_.references, [&](const Reference& r) { return r.id == activeRef_; }); - activeRef_ = 0; - return; - } - if (selected_.empty()) return; +auto App::deleteSelected() -> void { + if (mode_ == Mode::Edit) return deleteElements(); + if (activeRef_ && selected_.empty()) { pushUndo(); - std::erase_if(scene_.objects, [&](const Object& o) { return selected_.count(o.id) && !o.locked; }); - selected_.clear(); + std::erase_if(scene_.references, [&](const Reference& r) { return r.id == activeRef_; }); + activeRef_ = 0; + return; } + if (selected_.empty()) return; + pushUndo(); + std::erase_if(scene_.objects, [&](const Object& o) { return selected_.count(o.id) && !o.locked; }); + selected_.clear(); +} - void duplicateSelected() { - if (mode_ != Mode::Object || selected_.empty()) return; - pushUndo(); - std::set made; - const std::vector copy = scene_.objects; - for (const Object& o : copy) - if (selected_.count(o.id)) { - 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; - made.insert(id); - active_ = id; - } - selected_ = made; - touchAll(); - setTool(Tool::Move); - say("Duplicated: drag the arrows to move the copy"); - } - - // The selected objects become one (the last picked keeps its place, look and name). - void joinSelected() { - Object* into = find(active_); - if (mode_ != Mode::Object || selected_.size() < 2 || !into) return say("Select two or more objects to join (the last picked keeps its name)"); - pushUndo(); - const glm::mat4 toLocal = glm::inverse(objectMatrix(*into)); - for (const Object& o : scene_.objects) - if (selected_.count(o.id) && o.id != into->id) append(into->mesh, o.hasFinish() ? finishedMesh(o) : o.mesh, toLocal * objectMatrix(o)); - handMade(*into); - const uint32_t keep = into->id; - std::erase_if(scene_.objects, [&](const Object& o) { return selected_.count(o.id) && o.id != keep; }); - selected_ = {keep}, active_ = keep; - touched(keep); - } - - // The selected faces become an object of their own. - void separateSelected() { - Object* o = editing(); - if (!o || elem_ != Elem::Face || selFaces_.empty()) return say("Select faces to make into their own object"); - pushUndo(); - Mesh part; - part.verts = o->mesh.verts; - for (int f : selFaces_) part.faces.push_back(o->mesh.faces[size_t(f)]); - removeUnused(part); - deleteFaces(o->mesh, selFaces_); - removeUnused(o->mesh); - handMade(*o); - touched(o->id); - Object copy = *o; - Object& n = addObject(o->name + " part", std::move(part)); - const uint32_t id = n.id; - const std::string name = n.name; - Mesh m = std::move(n.mesh); - n = copy, n.id = id, n.name = name, n.mesh = std::move(m); - clearElements(); - leaveEdit(); - selected_ = {id}, active_ = id; - say("Separated into " + name); - } - - // ───────────────────────────── files ───────────────────────────── - - // ── Files a phone's file picker gives (content://...): read and written through SDL ── - static bool isUri(const std::string& p) { return p.starts_with("content://"); } - // A file's name as shown (a picked file's: from the end of its address, decoded). - static std::string fileName(const std::string& p) { - if (!isUri(p)) return stdfs::path(p).filename().string(); - std::string d; - for (size_t i = 0; i < p.size(); ++i) - if (p[i] == '%' && i + 2 < p.size()) d += char(std::stoi(p.substr(i + 1, 2), nullptr, 16)), i += 2; - else d += p[i]; - return d.substr(d.find_last_of("/:") + 1); - } - // A picked file copied to a file of our own to read ("" if it couldn't be). - static std::string fromUri(const std::string& uri, const std::string& name) { - size_t n = 0; - void* data = SDL_LoadFile(uri.c_str(), &n); - if (!data) return ""; - std::error_code ec; - const std::string local = (stdfs::temp_directory_path(ec) / name).string(); - std::ofstream(local, std::ios::binary).write(static_cast(data), std::streamsize(n)); - SDL_free(data); - return local; - } - // A file of ours written into the picked one. - static bool toUri(const std::string& local, const std::string& uri) { - std::ifstream in(local, std::ios::binary); - const std::string bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); - SDL_IOStream* io = SDL_IOFromFile(uri.c_str(), "wb"); - if (!io) return false; - const bool ok = SDL_WriteIO(io, bytes.data(), bytes.size()) == bytes.size(); - return SDL_CloseIO(io) && ok; - } - static std::string tempFile(const std::string& name) { - std::error_code ec; - return (stdfs::temp_directory_path(ec) / name).string(); - } - - void openFile(const std::string& picked) { - std::string err; - const std::string path = isUri(picked) ? fromUri(picked, "opened.fry") : picked; - if (path.empty()) return say("Couldn't read that file"); - auto s = loadPackage(path, &err); - if (!s) return say("Couldn't open: " + err); - dropAutosave(); - scene_ = std::move(*s), followAt_.clear(); - // (an old plain-text .fry: its pictures are PNGs beside it) - const stdfs::path here = stdfs::path(path).parent_path(); - for (Object& o : scene_.objects) { - if (!o.pictureFile.empty()) { - Image im; - if (loadImageFile((here / o.pictureFile).string(), im)) o.picture = std::make_shared(std::move(im)); - else say("Couldn't load the painted picture " + o.pictureFile); - } - for (PictureLayer& l : o.layers) { - Image im; - if (!l.image && !l.file.empty() && loadImageFile((here / l.file).string(), im)) l.image = std::make_shared(std::move(im)); - } - std::erase_if(o.layers, [](const PictureLayer& l) { return !l.image; }); +auto App::duplicateSelected() -> void { + if (mode_ != Mode::Object || selected_.empty()) return; + pushUndo(); + std::set made; + const std::vector copy = scene_.objects; + for (const Object& o : copy) + if (selected_.count(o.id)) { + 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; + made.insert(id); + active_ = id; } - path_ = picked; - undo_.clear(), redo_.clear(), selected_.clear(), lastShapes_.clear(); - mode_ = Mode::Object, active_ = 0, dirty_ = false, ledit_ = {}; - touchAll(); - frameAll(); - say("Opened " + fileName(picked)); - } - bool saveFile(std::string path) { - if (path.empty()) return false; - if (!isUri(path) && stdfs::path(path).extension() != ".fry") path += ".fry"; - const std::string local = isUri(path) ? tempFile("saving.fry") : path; - std::string err; - if (!savePackage(local, scene_, 12, &err)) return afterSave_ = 0, say("Couldn't save: " + err), false; - if (isUri(path) && !toUri(local, path)) return afterSave_ = 0, say("Couldn't write the file you picked"), false; - dropAutosave(); - path_ = path, dirty_ = false; - say(std::format("Saved {} ({})", fileName(path), sizeText(stdfs::file_size(local)))); - if (const int then = std::exchange(afterSave_, 0)) doAfterAsking(then); - return true; - } - static std::string sizeText(uintmax_t n) { - return n < 10240 ? std::format("{} bytes", n) : n < 10 << 20 ? std::format("{} KB", n >> 10) : std::format("{:.1f} MB", double(n) / 1048576.0); - } - static std::vector pngBytes(const Image& im) { - std::vector out; - stbi_write_png_to_func([](void* ctx, void* data, int n) { auto* v = static_cast*>(ctx); v->insert(v->end(), (uint8_t*)data, (uint8_t*)data + n); }, - &out, im.w, im.h, 4, im.px.data(), im.w * 4); - return out; - } - void exportFile(std::string path, int filter = -1, const std::string& asExt = "") { - if (path.empty()) return; - if (isUri(path)) { // (a picked file: made here, then written into it; one file only) - const auto& formats = exportFormats(); - const std::string ext = !asExt.empty() ? asExt : formats[size_t(filter >= 0 && filter < int(formats.size()) ? filter : 0)].first; - const std::string local = tempFile("export" + ext); - exportFile(local); - if (!toUri(local, path)) say("Couldn't write the file you picked"); - return; + selected_ = made; + touchAll(); + setTool(Tool::Move); + say("Duplicated: drag the arrows to move the copy"); +} + +// The selected objects become one (the last picked keeps its place, look and name). +auto App::joinSelected() -> void { + Object* into = find(active_); + if (mode_ != Mode::Object || selected_.size() < 2 || !into) return say("Select two or more objects to join (the last picked keeps its name)"); + pushUndo(); + const glm::mat4 toLocal = glm::inverse(objectMatrix(*into)); + for (const Object& o : scene_.objects) + if (selected_.count(o.id) && o.id != into->id) append(into->mesh, o.hasFinish() ? finishedMesh(o) : o.mesh, toLocal * objectMatrix(o)); + handMade(*into); + const uint32_t keep = into->id; + std::erase_if(scene_.objects, [&](const Object& o) { return selected_.count(o.id) && o.id != keep; }); + selected_ = {keep}, active_ = keep; + touched(keep); +} + +// The selected faces become an object of their own. +auto App::separateSelected() -> void { + Object* o = editing(); + if (!o || elem_ != Elem::Face || selFaces_.empty()) return say("Select faces to make into their own object"); + pushUndo(); + Mesh part; + part.verts = o->mesh.verts; + for (int f : selFaces_) part.faces.push_back(o->mesh.faces[size_t(f)]); + removeUnused(part); + deleteFaces(o->mesh, selFaces_); + removeUnused(o->mesh); + handMade(*o); + touched(o->id); + Object copy = *o; + Object& n = addObject(o->name + " part", std::move(part)); + const uint32_t id = n.id; + const std::string name = n.name; + Mesh m = std::move(n.mesh); + n = copy, n.id = id, n.name = name, n.mesh = std::move(m); + clearElements(); + leaveEdit(); + selected_ = {id}, active_ = id; + say("Separated into " + name); +} + +// ───────────────────────────── files ───────────────────────────── + +// A file's name as shown (a picked file's: from the end of its address, decoded). +auto App::fileName(const std::string& p) -> std::string { + if (!isUri(p)) return stdfs::path(p).filename().string(); + std::string d; + for (size_t i = 0; i < p.size(); ++i) + if (p[i] == '%' && i + 2 < p.size()) d += char(std::stoi(p.substr(i + 1, 2), nullptr, 16)), i += 2; + else d += p[i]; + return d.substr(d.find_last_of("/:") + 1); +} + +// A picked file copied to a file of our own to read ("" if it couldn't be). +auto App::fromUri(const std::string& uri, const std::string& name) -> std::string { + size_t n = 0; + void* data = SDL_LoadFile(uri.c_str(), &n); + if (!data) return ""; + std::error_code ec; + const std::string local = (stdfs::temp_directory_path(ec) / name).string(); + std::ofstream(local, std::ios::binary).write(static_cast(data), std::streamsize(n)); + SDL_free(data); + return local; +} + +// A file of ours written into the picked one. +auto App::toUri(const std::string& local, const std::string& uri) -> bool { + std::ifstream in(local, std::ios::binary); + const std::string bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + SDL_IOStream* io = SDL_IOFromFile(uri.c_str(), "wb"); + if (!io) return false; + const bool ok = SDL_WriteIO(io, bytes.data(), bytes.size()) == bytes.size(); + return SDL_CloseIO(io) && ok; +} + +auto App::tempFile(const std::string& name) -> std::string { + std::error_code ec; + return (stdfs::temp_directory_path(ec) / name).string(); +} + +auto App::openFile(const std::string& picked) -> void { + std::string err; + const std::string path = isUri(picked) ? fromUri(picked, "opened.fry") : picked; + if (path.empty()) return say("Couldn't read that file"); + auto s = loadPackage(path, &err); + if (!s) return say("Couldn't open: " + err); + dropAutosave(); + scene_ = std::move(*s), followAt_.clear(); + // (an old plain-text .fry: its pictures are PNGs beside it) + const stdfs::path here = stdfs::path(path).parent_path(); + for (Object& o : scene_.objects) { + if (!o.pictureFile.empty()) { + Image im; + if (loadImageFile((here / o.pictureFile).string(), im)) o.picture = std::make_shared(std::move(im)); + else say("Couldn't load the painted picture " + o.pictureFile); } - std::string ext = stdfs::path(path).extension().string(); - std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return char(std::tolower(c)); }); + for (PictureLayer& l : o.layers) { + Image im; + if (!l.image && !l.file.empty() && loadImageFile((here / l.file).string(), im)) l.image = std::make_shared(std::move(im)); + } + std::erase_if(o.layers, [](const PictureLayer& l) { return !l.image; }); + } + path_ = picked; + undo_.clear(), redo_.clear(), selected_.clear(), lastShapes_.clear(); + mode_ = Mode::Object, active_ = 0, dirty_ = false, ledit_ = {}; + touchAll(); + frameAll(); + say("Opened " + fileName(picked)); +} + +auto App::saveFile(std::string path) -> bool { + if (path.empty()) return false; + if (!isUri(path) && stdfs::path(path).extension() != ".fry") path += ".fry"; + const std::string local = isUri(path) ? tempFile("saving.fry") : path; + std::string err; + if (!savePackage(local, scene_, 12, &err)) return afterSave_ = 0, say("Couldn't save: " + err), false; + if (isUri(path) && !toUri(local, path)) return afterSave_ = 0, say("Couldn't write the file you picked"), false; + dropAutosave(); + path_ = path, dirty_ = false; + say(std::format("Saved {} ({})", fileName(path), sizeText(stdfs::file_size(local)))); + if (const int then = std::exchange(afterSave_, 0)) doAfterAsking(then); + return true; +} + +auto App::pngBytes(const Image& im) -> std::vector { + std::vector out; + stbi_write_png_to_func([](void* ctx, void* data, int n) { auto* v = static_cast*>(ctx); v->insert(v->end(), (uint8_t*)data, (uint8_t*)data + n); }, + &out, im.w, im.h, 4, im.px.data(), im.w * 4); + return out; +} + +auto App::exportFile(std::string path, int filter, const std::string& asExt) -> void { + if (path.empty()) return; + if (isUri(path)) { // (a picked file: made here, then written into it; one file only) const auto& formats = exportFormats(); - if (std::none_of(formats.begin(), formats.end(), [&](const auto& f) { return f.first == ext; })) - path += formats[size_t(filter >= 0 && filter < int(formats.size()) ? filter : 0)].first; - std::vector> pngs(scene_.objects.size()); - for (size_t i = 0; i < scene_.objects.size(); ++i) - if (const Object& o = scene_.objects[i]; o.picture && o.uvsFit()) pngs[i] = pngBytes(o.layers.empty() ? *o.picture : composePicture(o)); - std::string err; - if (!exportModel(path, fry::withCutsMade(scene_), pngs, &err)) return say("Couldn't export: " + err); - say("Exported " + stdfs::path(path).filename().string()); + const std::string ext = !asExt.empty() ? asExt : formats[size_t(filter >= 0 && filter < int(formats.size()) ? filter : 0)].first; + const std::string local = tempFile("export" + ext); + exportFile(local); + if (!toUri(local, path)) say("Couldn't write the file you picked"); + return; } + std::string ext = stdfs::path(path).extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return char(std::tolower(c)); }); + const auto& formats = exportFormats(); + if (std::none_of(formats.begin(), formats.end(), [&](const auto& f) { return f.first == ext; })) + path += formats[size_t(filter >= 0 && filter < int(formats.size()) ? filter : 0)].first; + std::vector> pngs(scene_.objects.size()); + for (size_t i = 0; i < scene_.objects.size(); ++i) + if (const Object& o = scene_.objects[i]; o.picture && o.uvsFit()) pngs[i] = pngBytes(o.layers.empty() ? *o.picture : composePicture(o)); + std::string err; + if (!exportModel(path, fry::withCutsMade(scene_), pngs, &err)) return say("Couldn't export: " + err); + say("Exported " + stdfs::path(path).filename().string()); +} - // ── Closing with changes, autosave ── +// ── Closing with changes, autosave ── - // New, Open, Quit: asks about saving first if there are changes. - void whenSaved(int action) { - if (dirty_) askSave_ = action; - else doAfterAsking(action); +// New, Open, Quit: asks about saving first if there are changes. +auto App::whenSaved(int action) -> void { + if (dirty_) askSave_ = action; + else doAfterAsking(action); +} + +auto App::doAfterAsking(int action) -> void { + if (action == 1) dropAutosave(), keel::execute("quit"); + else if (action == 2) newScene(); + else if (action == 3) dialog(1); + else if (action == 4) openFile(std::exchange(dropped_, std::string())); +} + +// A file dropped on the window (or opened with Fry3DX from the desktop): a .fry opens (after +// asking about changes), a script goes in the Script panel, a picture goes on as a reference +// (or, in the Paint tab, as a sticker on the model). +auto App::onFileDropped(const std::string& path) -> void { + std::string ext = stdfs::path(path).extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return char(std::tolower(c)); }); + if (ext == ".fry") return (void)(dropped_ = path, whenSaved(4)); + if (ext == ".lua") return openScript(path, fileName(path)); + if (ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp" || ext == ".tga") return tab_ == Tab::Paint ? floatFromFile(path) : addPicture(path); + say("Fry3DX opens .fry files, scripts (.lua) and pictures"); +} + +auto App::saveQuestion() -> void { + if (!askSave_) return; + if (!ImGui::IsPopupOpen("Save changes?")) ImGui::OpenPopup("Save changes?"); + const ImVec2 c = ImGui::GetMainViewport()->GetCenter(); + ImGui::SetNextWindowPos(c, ImGuiCond_Always, ImVec2(0.5f, 0.5f)); + if (!ImGui::BeginPopupModal("Save changes?", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) return; + const std::string name = path_.empty() ? "Untitled" : fileName(path_); + ImGui::Text("%s has changes that aren't saved.", name.c_str()); + ImGui::TextDisabled(askSave_ == 1 ? "Save them before closing?" : askSave_ == 2 ? "Save them before starting a new one?" : "Save them before opening another?"); + ImGui::Spacing(); + const float bw = 150.0f; + const int action = askSave_; + pushAccent(); + const bool save = ImGui::Button("Save", ImVec2(bw, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter); + popAccent(); + ImGui::SameLine(); + const bool drop = ImGui::Button(action == 1 ? "Don't save, close" : "Don't save", ImVec2(bw, 0)); + ImGui::SameLine(); + const bool cancel = ImGui::Button("Cancel", ImVec2(bw, 0)); + if (save || drop || cancel || !askSave_) askSave_ = 0, ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (save) { // (saveFile goes on with it once saved) + afterSave_ = action; + path_.empty() ? dialog(2) : void(saveFile(path_)); + } else if (drop) { + dropAutosave(); + dirty_ = false; + doAfterAsking(action); } - void doAfterAsking(int action) { - if (action == 1) dropAutosave(), keel::execute("quit"); - else if (action == 2) newScene(); - else if (action == 3) dialog(1); - else if (action == 4) openFile(std::exchange(dropped_, std::string())); +} + +// Every fry_autosave minutes (when there are changes and nothing's being dragged): a copy +// packed as small as it goes, in the background. +auto App::autosave(bool force) -> void { + const float minutes = fry_autosave.f(); + if (!force && (minutes <= 0 || keel::isTestMode())) return; + if (!dirty_ || (!force && (edits_ == autosavedEdits_ || now() - lastAutosave_ < minutes * 60.0))) return; + if (!force && (ImGui::IsMouseDown(ImGuiMouseButton_Left) || drag_.active || paint_.active)) return; + if (autosaveJob_.valid() && autosaveJob_.wait_for(std::chrono::seconds(0)) != std::future_status::ready) return; + if (autosaveJob_.valid() && !autosaveJob_.get()) say("Couldn't autosave (is the disk full?)"); + std::error_code ec; + stdfs::create_directories(autosaveDir(), ec); + const std::string stem = path_.empty() ? "Untitled" : stdfs::path(path_).stem().string(); + if (autosavePath_.empty()) autosavePath_ = autosaveDir() + std::format("{}-{}.fry", stem, std::time(nullptr)); + std::ofstream(autosavePath_ + ".from") << path_; // (where it belongs) + lastAutosave_ = now(), autosavedEdits_ = edits_; + autosaveJob_ = std::async(std::launch::async, [copy = scene_, to = autosavePath_] { return savePackage(to, copy, 19); }); +} + +// Its autosave isn't needed (saved, or closed on purpose). +auto App::dropAutosave() -> void { + if (autosaveJob_.valid()) autosaveJob_.wait(); + if (autosavePath_.empty()) return; + std::error_code ec; + stdfs::remove(autosavePath_, ec), stdfs::remove(autosavePath_ + ".from", ec); + autosavePath_.clear(), lastAutosave_ = now(); +} + +// Autosaves left behind (it didn't close properly): offered back. Only the newest 15 are +// kept; older ones are deleted. +auto App::findAutosaves() -> void { + static constexpr size_t kKeep = 15; + recover_.clear(); + std::error_code ec; + std::vector> found; + for (const auto& e : stdfs::directory_iterator(autosaveDir(), ec)) + if (e.path().extension() == ".fry") found.emplace_back(e.last_write_time(ec), e.path().string()); + std::sort(found.begin(), found.end(), [](const auto& a, const auto& b) { return a.first > b.first; }); // newest first + for (size_t i = 0; i < found.size(); ++i) { + if (i < kKeep || found[i].second == autosavePath_) recover_.push_back(found[i].second); + else stdfs::remove(found[i].second, ec), stdfs::remove(found[i].second + ".from", ec); } - // A file dropped on the window (or opened with Fry3DX from the desktop): a .fry opens (after - // asking about changes), a script goes in the Script panel, a picture goes on as a reference - // (or, in the Paint tab, as a sticker on the model). - void onFileDropped(const std::string& path) override { - std::string ext = stdfs::path(path).extension().string(); - std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return char(std::tolower(c)); }); - if (ext == ".fry") return (void)(dropped_ = path, whenSaved(4)); - if (ext == ".lua") return openScript(path, fileName(path)); - if (ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp" || ext == ".tga") return tab_ == Tab::Paint ? floatFromFile(path) : addPicture(path); - say("Fry3DX opens .fry files, scripts (.lua) and pictures"); - } - void saveQuestion() { - if (!askSave_) return; - if (!ImGui::IsPopupOpen("Save changes?")) ImGui::OpenPopup("Save changes?"); - const ImVec2 c = ImGui::GetMainViewport()->GetCenter(); - ImGui::SetNextWindowPos(c, ImGuiCond_Always, ImVec2(0.5f, 0.5f)); - if (!ImGui::BeginPopupModal("Save changes?", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) return; - const std::string name = path_.empty() ? "Untitled" : fileName(path_); - ImGui::Text("%s has changes that aren't saved.", name.c_str()); - ImGui::TextDisabled(askSave_ == 1 ? "Save them before closing?" : askSave_ == 2 ? "Save them before starting a new one?" : "Save them before opening another?"); - ImGui::Spacing(); - const float bw = 150.0f; - const int action = askSave_; - pushAccent(); - const bool save = ImGui::Button("Save", ImVec2(bw, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter); - popAccent(); - ImGui::SameLine(); - const bool drop = ImGui::Button(action == 1 ? "Don't save, close" : "Don't save", ImVec2(bw, 0)); - ImGui::SameLine(); - const bool cancel = ImGui::Button("Cancel", ImVec2(bw, 0)); - if (save || drop || cancel || !askSave_) askSave_ = 0, ImGui::CloseCurrentPopup(); - ImGui::EndPopup(); - if (save) { // (saveFile goes on with it once saved) - afterSave_ = action; - path_.empty() ? dialog(2) : void(saveFile(path_)); - } else if (drop) { - dropAutosave(); - dirty_ = false; - doAfterAsking(action); - } - } - std::string autosaveDir() const { return keel::prefPath() + "autosave/"; } - // Every fry_autosave minutes (when there are changes and nothing's being dragged): a copy - // packed as small as it goes, in the background. - void autosave(bool force = false) { - const float minutes = fry_autosave.f(); - if (!force && (minutes <= 0 || keel::isTestMode())) return; - if (!dirty_ || (!force && (edits_ == autosavedEdits_ || now() - lastAutosave_ < minutes * 60.0))) return; - if (!force && (ImGui::IsMouseDown(ImGuiMouseButton_Left) || drag_.active || paint_.active)) return; - if (autosaveJob_.valid() && autosaveJob_.wait_for(std::chrono::seconds(0)) != std::future_status::ready) return; - if (autosaveJob_.valid() && !autosaveJob_.get()) say("Couldn't autosave (is the disk full?)"); + showRecover_ = !recover_.empty() && !keel::isTestMode(); +} + +auto App::recoverWindow() -> void { + if (!showRecover_) return; + if (!ImGui::IsPopupOpen("Recover your work?")) ImGui::OpenPopup("Recover your work?"); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Always, ImVec2(0.5f, 0.5f)); + if (!ImGui::BeginPopupModal("Recover your work?", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) return; + ImGui::TextUnformatted("Fry3DX didn't close properly last time. These were autosaved:"); + ImGui::Spacing(); + std::string open; + for (const std::string& f : recover_) { + std::ifstream in(f + ".from"); + std::string from; + std::getline(in, from); std::error_code ec; - stdfs::create_directories(autosaveDir(), ec); - const std::string stem = path_.empty() ? "Untitled" : stdfs::path(path_).stem().string(); - if (autosavePath_.empty()) autosavePath_ = autosaveDir() + std::format("{}-{}.fry", stem, std::time(nullptr)); - std::ofstream(autosavePath_ + ".from") << path_; // (where it belongs) - lastAutosave_ = now(), autosavedEdits_ = edits_; - autosaveJob_ = std::async(std::launch::async, [copy = scene_, to = autosavePath_] { return savePackage(to, copy, 19); }); - } - // Its autosave isn't needed (saved, or closed on purpose). - void dropAutosave() { - if (autosaveJob_.valid()) autosaveJob_.wait(); - if (autosavePath_.empty()) return; - std::error_code ec; - stdfs::remove(autosavePath_, ec), stdfs::remove(autosavePath_ + ".from", ec); - autosavePath_.clear(), lastAutosave_ = now(); - } - // Autosaves left behind (it didn't close properly): offered back. Only the newest 15 are - // kept; older ones are deleted. - void findAutosaves() { - static constexpr size_t kKeep = 15; - recover_.clear(); - std::error_code ec; - std::vector> found; - for (const auto& e : stdfs::directory_iterator(autosaveDir(), ec)) - if (e.path().extension() == ".fry") found.emplace_back(e.last_write_time(ec), e.path().string()); - std::sort(found.begin(), found.end(), [](const auto& a, const auto& b) { return a.first > b.first; }); // newest first - for (size_t i = 0; i < found.size(); ++i) { - if (i < kKeep || found[i].second == autosavePath_) recover_.push_back(found[i].second); - else stdfs::remove(found[i].second, ec), stdfs::remove(found[i].second + ".from", ec); - } - showRecover_ = !recover_.empty() && !keel::isTestMode(); - } - void recoverWindow() { - if (!showRecover_) return; - if (!ImGui::IsPopupOpen("Recover your work?")) ImGui::OpenPopup("Recover your work?"); - ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Always, ImVec2(0.5f, 0.5f)); - if (!ImGui::BeginPopupModal("Recover your work?", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) return; - ImGui::TextUnformatted("Fry3DX didn't close properly last time. These were autosaved:"); - ImGui::Spacing(); - std::string open; - for (const std::string& f : recover_) { - std::ifstream in(f + ".from"); - std::string from; - std::getline(in, from); - std::error_code ec; - // (in local time: through time_t, as not every C++ library has time zones yet) - const auto age = stdfs::file_time_type::clock::now() - stdfs::last_write_time(f, ec); // (file clocks differ by library: by age) - const std::time_t t = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now() - std::chrono::duration_cast(age)); - std::tm local{}; + // (in local time: through time_t, as not every C++ library has time zones yet) + const auto age = stdfs::file_time_type::clock::now() - stdfs::last_write_time(f, ec); // (file clocks differ by library: by age) + const std::time_t t = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now() - std::chrono::duration_cast(age)); + std::tm local{}; #if defined(_WIN32) - localtime_s(&local, &t); + localtime_s(&local, &t); #else - localtime_r(&t, &local); + localtime_r(&t, &local); #endif - char when[32]; - std::strftime(when, sizeof when, "%Y-%m-%d %H:%M", &local); - const std::string label = std::format("{} {} {}", from.empty() ? "Untitled" : stdfs::path(from).filename().string(), when, sizeText(stdfs::file_size(f, ec))); - ImGui::PushID(f.c_str()); - if (ImGui::Button("Open", ImVec2(70, 0))) open = f; - ImGui::SameLine(); - ImGui::TextUnformatted(label.c_str()); - ImGui::PopID(); - } - ImGui::Spacing(); - if (ImGui::Button("Throw them all away", ImVec2(200, 0))) { - std::error_code ec; - for (const std::string& f : recover_) stdfs::remove(f, ec), stdfs::remove(f + ".from", ec); - recover_.clear(); - } + char when[32]; + std::strftime(when, sizeof when, "%Y-%m-%d %H:%M", &local); + const std::string label = std::format("{} {} {}", from.empty() ? "Untitled" : stdfs::path(from).filename().string(), when, sizeText(stdfs::file_size(f, ec))); + ImGui::PushID(f.c_str()); + if (ImGui::Button("Open", ImVec2(70, 0))) open = f; ImGui::SameLine(); - if (ImGui::Button("Later", ImVec2(120, 0))) showRecover_ = false; - if (recover_.empty() || !showRecover_ || !open.empty()) showRecover_ = false, ImGui::CloseCurrentPopup(); - ImGui::EndPopup(); - if (!open.empty()) { - std::ifstream in(open + ".from"); - std::string from; - std::getline(in, from); - openFile(open); - path_ = from, dirty_ = true, autosavePath_ = open; // (it goes on autosaving there until saved) - say("Recovered" + (from.empty() ? std::string() : " " + stdfs::path(from).filename().string()) + ": save it to keep it (Ctrl+S)"); - } + ImGui::TextUnformatted(label.c_str()); + ImGui::PopID(); } - - // A picture of the 3D view only (no panels, gizmos or outlines): the screen is saved a few - // frames on, with those hidden, then cut down to the view. - void savePicture(std::string path) { - if (path.empty()) return; - if (stdfs::path(path).extension() != ".png") path += ".png"; - capturePath_ = path, captureFrames_ = 5; - } - bool capturing() const { return captureFrames_ > 0; } - void captureStep() { - if (!captureFrames_) return; - const std::string tmp = keel::prefPath() + "fry_capture.png"; - if (--captureFrames_ == 3) keel::execute("screenshot \"" + tmp + "\""); - if (captureFrames_) return; - keel::waitForScreenshots(); - int w = 0, h = 0, n = 0; - stbi_uc* px = stbi_load(tmp.c_str(), &w, &h, &n, 4); - if (!px) return say("Couldn't save the picture"); - const ImVec2 size = ImGui::GetIO().DisplaySize; - const float sx = float(w) / std::max(size.x, 1.0f), sy = float(h) / std::max(size.y, 1.0f); - const int x0 = std::clamp(int(viewMin_.x * sx), 0, w - 1), y0 = std::clamp(int(viewMin_.y * sy), 0, h - 1); - const int x1 = std::clamp(int(viewMax_.x * sx), x0 + 1, w), y1 = std::clamp(int(viewMax_.y * sy), y0 + 1, h); - const bool ok = stbi_write_png(capturePath_.c_str(), x1 - x0, y1 - y0, 4, px + (size_t(y0) * size_t(w) + size_t(x0)) * 4, w * 4); - stbi_image_free(px); + ImGui::Spacing(); + if (ImGui::Button("Throw them all away", ImVec2(200, 0))) { std::error_code ec; - stdfs::remove(tmp, ec); - if (const std::string uri = std::exchange(captureUri_, {}); ok && !uri.empty()) - return say(toUri(capturePath_, uri) ? "Saved a picture of the view: " + fileName(uri) : "Couldn't write the file you picked"); - say(ok ? "Saved a picture of the view: " + stdfs::path(capturePath_).filename().string() : "Couldn't write " + capturePath_); + for (const std::string& f : recover_) stdfs::remove(f, ec), stdfs::remove(f + ".from", ec); + recover_.clear(); } - static bool loadImageFile(const std::string& path, Image& out) { - int w = 0, h = 0, n = 0; - stbi_uc* px = stbi_load(path.c_str(), &w, &h, &n, 4); - if (!px) return false; - out.w = w, out.h = h, out.px.assign(px, px + size_t(w) * size_t(h) * 4); - stbi_image_free(px); - return true; - } - static bool writePng(const std::string& path, const Image& im) { return stbi_write_png(path.c_str(), im.w, im.h, 4, im.px.data(), im.w * 4) != 0; } - void addPicture(const std::string& path) { - pushUndo(); - Reference ref; - ref.id = scene_.nextId++; - ref.path = path; - ref.axis = view_ == View::Front || view_ == View::Back ? Axis::Front : view_ == View::Top || view_ == View::Bottom ? Axis::Top : Axis::Side; - ref.width = 2.0f; - scene_.references.push_back(ref); - activeRef_ = ref.id; - if (view_ == View::Perspective) setView(ref.axis == Axis::Front ? View::Front : ref.axis == Axis::Top ? View::Top : View::Left); - say("Picture added: set its width in Properties to the real size"); + ImGui::SameLine(); + if (ImGui::Button("Later", ImVec2(120, 0))) showRecover_ = false; + if (recover_.empty() || !showRecover_ || !open.empty()) showRecover_ = false, ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (!open.empty()) { + std::ifstream in(open + ".from"); + std::string from; + std::getline(in, from); + openFile(open); + path_ = from, dirty_ = true, autosavePath_ = open; // (it goes on autosaving there until saved) + say("Recovered" + (from.empty() ? std::string() : " " + stdfs::path(from).filename().string()) + ": save it to keep it (Ctrl+S)"); } +} - // SDL's own file dialogs (the desktop's): the answer comes back later, maybe on another thread. - void dialog(int kind) { - static const SDL_DialogFileFilter fry[] = {{"Fry3DX files", "fry"}}, pics[] = {{"Pictures", "png;jpg;jpeg;bmp;tga"}}, png[] = {{"PNG picture", "png"}}; - static std::vector names; - static std::vector models; - if (models.empty()) { - for (const auto& [ext, what] : exportFormats()) names.push_back(what + " (" + ext + ")"), names.push_back(ext.substr(1)); - for (size_t i = 0; i < names.size(); i += 2) models.push_back({names[i].c_str(), names[i + 1].c_str()}); - } - pendingKind_ = kind; - auto done = [](void* user, const char* const* files, int filter) { - App* self = static_cast(user); - std::lock_guard lock(self->dialogMutex_); - if (files && files[0]) self->dialogResult_ = files[0], self->dialogKind_ = self->pendingKind_, self->dialogFilter_ = filter; - else self->dialogCancelled_ = true; - }; - SDL_Window* w = SDL_GetKeyboardFocus(); - const std::string here = path_.empty() || isUri(path_) ? std::string() : stdfs::path(path_).parent_path().string(); - const char* at = here.empty() ? nullptr : here.c_str(); - if (kind == 1) SDL_ShowOpenFileDialog(done, this, w, fry, 1, at, false); - else if (kind == 2) SDL_ShowSaveFileDialog(done, this, w, fry, 1, path_.empty() || isUri(path_) ? at : path_.c_str()); - else if (kind == 3) SDL_ShowSaveFileDialog(done, this, w, models.data(), int(models.size()), at); - else if (kind == 8 || kind == 9) { - static const SDL_DialogFileFilter mf[] = {{"3MF for slicers", "3mf"}}, stl[] = {{"STL", "stl"}}; - SDL_ShowSaveFileDialog(done, this, w, kind == 8 ? mf : stl, 1, at); - } - else if (kind == 5 || kind == 7) SDL_ShowSaveFileDialog(done, this, w, png, 1, at); - else if (kind == 10 || kind == 11) { - static const SDL_DialogFileFilter lua[] = {{"Lua scripts", "lua"}}; - if (kind == 10) SDL_ShowOpenFileDialog(done, this, w, lua, 1, at, false); - else SDL_ShowSaveFileDialog(done, this, w, lua, 1, scriptPath_.empty() || isUri(scriptPath_) ? at : scriptPath_.c_str()); // (at: the last folder) - } - else if (kind == 12) { - static const SDL_DialogFileFilter in[] = {{"3D models (STL, OBJ, 3MF, glTF)", "stl;obj;3mf;glb;gltf"}}; - SDL_ShowOpenFileDialog(done, this, w, in, 1, at, true); - } - else if (kind == 13) { - static const SDL_DialogFileFilter fonts[] = {{"Fonts", "ttf;otf"}}; - SDL_ShowOpenFileDialog(done, this, w, fonts, 1, at, false); - } - else SDL_ShowOpenFileDialog(done, this, w, pics, 1, at, false); - } - void takeDialogResult() { - std::string file; - int kind, filter; - { - std::lock_guard lock(dialogMutex_); - file.swap(dialogResult_), kind = dialogKind_, filter = dialogFilter_; - if (std::exchange(dialogCancelled_, false)) afterSave_ = 0; // (Save, then quit: but the saving was cancelled) - } - if (file.empty()) return; - if (isUri(file) && (kind == 4 || kind == 6)) file = fromUri(file, std::format("picked-{}.img", SDL_GetTicks())); // (pictures: read from a copy) - if (isUri(file) && (kind == 5 || kind == 7)) { // (pictures saved: made here, then written into the picked file) - const std::string uri = file; - file = tempFile("picture.png"); - if (kind == 5) return savePicture(file), void(captureUri_ = uri); // (written into it once it's made) - if (Object* o = find(active_); o && o->picture) writePng(file, composePicture(*o)); - say(toUri(file, uri) ? "Saved " + fileName(uri) : "Couldn't write the file you picked"); - return; - } - if (isUri(file) && (kind == 8 || kind == 9)) return exportFile(file, -1, kind == 8 ? ".3mf" : ".stl"); - if (kind == 12) return importFile(isUri(file) ? fromUri(file, "imported-" + fileName(file)) : file); - if (kind == 13) { - textFontFile_ = isUri(file) ? fromUri(file, "font-" + fileName(file)) : file; - return; - } - if (kind == 10) return openScript(isUri(file) ? fromUri(file, "opened.lua") : file, fileName(file)); - if (kind == 11) return saveScript(file); - if (kind == 1) openFile(file); - else if (kind == 2) saveFile(file); - else if (kind == 3) exportFile(file, filter); - else if (kind == 8 || kind == 9) { - if (stdfs::path(file).extension() != (kind == 8 ? ".3mf" : ".stl")) file += kind == 8 ? ".3mf" : ".stl"; - exportFile(file); - } - else if (kind == 4) addPicture(file); - else if (kind == 5) savePicture(file); - else if (kind == 6) floatFromFile(file); - else if (kind == 7) { - if (stdfs::path(file).extension() != ".png") file += ".png"; - if (Object* o = find(active_); o && o->picture) say(writePng(file, composePicture(*o)) ? "Saved " + stdfs::path(file).filename().string() : "Couldn't write " + file); - } - } +// A picture of the 3D view only (no panels, gizmos or outlines): the screen is saved a few +// frames on, with those hidden, then cut down to the view. +auto App::savePicture(std::string path) -> void { + if (path.empty()) return; + if (stdfs::path(path).extension() != ".png") path += ".png"; + capturePath_ = path, captureFrames_ = 5; +} - // ───────────────────────────── modes and tools ───────────────────────────── +auto App::captureStep() -> void { + if (!captureFrames_) return; + const std::string tmp = keel::prefPath() + "fry_capture.png"; + if (--captureFrames_ == 3) keel::execute("screenshot \"" + tmp + "\""); + if (captureFrames_) return; + keel::waitForScreenshots(); + int w = 0, h = 0, n = 0; + stbi_uc* px = stbi_load(tmp.c_str(), &w, &h, &n, 4); + if (!px) return say("Couldn't save the picture"); + const ImVec2 size = ImGui::GetIO().DisplaySize; + const float sx = float(w) / std::max(size.x, 1.0f), sy = float(h) / std::max(size.y, 1.0f); + const int x0 = std::clamp(int(viewMin_.x * sx), 0, w - 1), y0 = std::clamp(int(viewMin_.y * sy), 0, h - 1); + const int x1 = std::clamp(int(viewMax_.x * sx), x0 + 1, w), y1 = std::clamp(int(viewMax_.y * sy), y0 + 1, h); + const bool ok = stbi_write_png(capturePath_.c_str(), x1 - x0, y1 - y0, 4, px + (size_t(y0) * size_t(w) + size_t(x0)) * 4, w * 4); + stbi_image_free(px); + std::error_code ec; + stdfs::remove(tmp, ec); + if (const std::string uri = std::exchange(captureUri_, {}); ok && !uri.empty()) + return say(toUri(capturePath_, uri) ? "Saved a picture of the view: " + fileName(uri) : "Couldn't write the file you picked"); + say(ok ? "Saved a picture of the view: " + stdfs::path(capturePath_).filename().string() : "Couldn't write " + capturePath_); +} - void setTool(Tool t) { - for (const ToolInfo& i : kTools) - if (i.tool == t && ((mode_ == Mode::Object && !i.objectMode) || (mode_ == Mode::Edit && !i.editMode))) { - if (mode_ == Mode::Object && i.editMode) { // (a shape tool: go and edit the shape) - enterEdit(); - if (mode_ != Mode::Edit) return; - break; - } - return say(std::format("{} works on whole objects (Tab leaves shape editing)", i.name)); - } - tool_ = t, pluginTool_ = {}; - drawPts_.clear(), knifePts_.clear(); - if ((t == Tool::Extrude || t == Tool::Inset) && elem_ != Elem::Face) elem_ = Elem::Face, clearElements(); - } - void enterEdit() { - if (mode_ == Mode::Edit) return; - Object* o = find(active_); - if (!o || !selected_.count(active_)) { - if (selected_.empty()) return say("Select an object first, then edit its shape"); - active_ = *selected_.begin(); - o = find(active_); - } - if (!o || o->locked) return say("That object is locked"); - mode_ = Mode::Edit; - selected_ = {active_}; - clearElements(); - if (tool_ == Tool::Draw) tool_ = Tool::Move; - drawPts_.clear(); - touched(o->id); // (shown without its rounding and smoothing while you edit) - } - void leaveEdit() { - if (mode_ != Mode::Edit) return; - mode_ = Mode::Object; - if (tool_ == Tool::Extrude || tool_ == Tool::Inset || tool_ == Tool::Bevel || tool_ == Tool::LoopCut || tool_ == Tool::Knife) tool_ = Tool::Move; - knifePts_.clear(); - if (Object* o = find(active_)) touched(o->id); - } - void clearElements() { selVerts_.clear(), selFaces_.clear(), selEdges_.clear(); } - void selectAllElements() { - Object* o = editing(); - if (!o) return; - clearElements(); - for (size_t v = 0; v < o->mesh.verts.size(); ++v) selVerts_.insert(int(v)); - for (size_t f = 0; f < o->mesh.faces.size(); ++f) selFaces_.insert(int(f)); - for (auto e : edges(o->mesh)) selEdges_.insert(e); - } - void selectAll() { - if (mode_ == Mode::Edit) return selectAllElements(); - selected_.clear(); - for (const Object& o : scene_.objects) - if (o.visible) selected_.insert(o.id), active_ = o.id; - } - void selectNone() { - if (mode_ == Mode::Edit) clearElements(); - else selected_.clear(); - } - // The points the selection covers, whichever kind is picked. - std::set selectedPoints() { - std::set pts; - Object* o = editing(); - if (!o) return pts; - if (elem_ == Elem::Vertex) pts = selVerts_; - else if (elem_ == Elem::Edge) - for (auto [a, b] : selEdges_) pts.insert(a), pts.insert(b); - else - for (int f : selFaces_) - if (f < int(o->mesh.faces.size())) - for (int v : o->mesh.faces[size_t(f)]) pts.insert(v); - return pts; - } - // Selects (in the current kind) what these points cover. - void selectPoints(const std::set& pts) { - Object* o = editing(); - if (!o) return; - clearElements(); - if (elem_ == Elem::Vertex) selVerts_ = pts; - else if (elem_ == Elem::Edge) { - for (auto [a, b] : edges(o->mesh)) - if (pts.count(a) && pts.count(b)) selEdges_.insert({a, b}); - } else - for (size_t f = 0; f < o->mesh.faces.size(); ++f) - if (std::all_of(o->mesh.faces[f].begin(), o->mesh.faces[f].end(), [&](int v) { return pts.count(v); })) selFaces_.insert(int(f)); - } - void growSelection() { - if (Object* o = editing()) selectPoints(growVerts(o->mesh, selectedPoints())); - } - void shrinkSelection() { - Object* o = editing(); - if (!o) return; - const std::set sel = selectedPoints(); - std::set rest; - for (size_t v = 0; v < o->mesh.verts.size(); ++v) - if (!sel.count(int(v))) rest.insert(int(v)); - const std::set border = growVerts(o->mesh, rest); - std::set keep; - for (int v : sel) - if (!border.count(v)) keep.insert(v); - selectPoints(keep); - } - // Like the picked: faces that face the same way, edges as long, points with as many edges. - void selectSimilar() { - Object* o = editing(); - if (!o) return; - const Mesh& m = o->mesh; - if (elem_ == Elem::Face) { - std::vector ns; - for (int f : selFaces_) ns.push_back(faceNormal(m, f)); - if (ns.empty()) return say("Pick a face first"); - for (size_t f = 0; f < m.faces.size(); ++f) - for (const glm::vec3& n : ns) - if (glm::dot(faceNormal(m, int(f)), n) > 0.996f) selFaces_.insert(int(f)); - } else if (elem_ == Elem::Edge) { - std::vector lens; - for (auto [a, b] : selEdges_) lens.push_back(glm::distance(m.verts[size_t(a)], m.verts[size_t(b)])); - if (lens.empty()) return say("Pick an edge first"); - for (auto [a, b] : edges(m)) { - const float l = glm::distance(m.verts[size_t(a)], m.verts[size_t(b)]); - for (float k : lens) - if (std::abs(l - k) <= k * 0.02f + 1e-6f) selEdges_.insert({a, b}); - } - } else { - std::vector count(m.verts.size(), 0); - for (auto [a, b] : edges(m)) ++count[size_t(a)], ++count[size_t(b)]; - std::set want; - for (int v : selVerts_) want.insert(count[size_t(v)]); - if (want.empty()) return say("Pick a point first"); - for (size_t v = 0; v < m.verts.size(); ++v) - if (want.count(count[size_t(v)])) selVerts_.insert(int(v)); - } - say("Picked the similar ones"); - } - // The picked edges' whole loops (or the rings across them). - void loopsOfSelected(bool ring) { - Object* o = editing(); - if (!o) return; - if (elem_ != Elem::Edge || selEdges_.empty()) return say("Pick an edge or two first (Edges, 2)"); - std::set> add; - for (auto e : selEdges_) - for (auto f : ring ? edgeRing(o->mesh, e) : edgeLoop(o->mesh, e)) add.insert(f); - selEdges_.insert(add.begin(), add.end()); - } - void selectLinked() { - if (Object* o = editing()) selectPoints(linkedVerts(o->mesh, selectedPoints())); - } - void invertSelection() { - if (mode_ == Mode::Object) { - std::set inv; - for (const Object& o : scene_.objects) - if (o.visible && !selected_.count(o.id)) inv.insert(o.id), active_ = o.id; - selected_ = inv; - return; - } - Object* o = editing(); - if (!o) return; - if (elem_ == Elem::Vertex) { - std::set inv; - for (size_t v = 0; v < o->mesh.verts.size(); ++v) - if (!selVerts_.count(int(v))) inv.insert(int(v)); - selVerts_ = inv; - } else if (elem_ == Elem::Edge) { - std::set> inv; - for (auto e : edges(o->mesh)) - if (!selEdges_.count(e)) inv.insert(e); - selEdges_ = inv; - } else { - std::set inv; - for (size_t f = 0; f < o->mesh.faces.size(); ++f) - if (!selFaces_.count(int(f))) inv.insert(int(f)); - selFaces_ = inv; - } - } - // The edges a rounding would work on: those selected, or round the selected faces, or between - // selected points. - std::set> edgesToRound() { - std::set> out; - Object* o = editing(); - if (!o) return out; - if (elem_ == Elem::Edge) return selEdges_; - if (elem_ == Elem::Face) { - for (int f : selFaces_) { - const auto& fv = o->mesh.faces[size_t(f)]; - for (size_t i = 0; i < fv.size(); ++i) out.insert({std::min(fv[i], fv[(i + 1) % fv.size()]), std::max(fv[i], fv[(i + 1) % fv.size()])}); - } - return out; - } - for (auto [a, b] : edges(o->mesh)) - if (selVerts_.count(a) && selVerts_.count(b)) out.insert({a, b}); - return out; - } +auto App::loadImageFile(const std::string& path, Image& out) -> bool { + int w = 0, h = 0, n = 0; + stbi_uc* px = stbi_load(path.c_str(), &w, &h, &n, 4); + if (!px) return false; + out.w = w, out.h = h, out.px.assign(px, px + size_t(w) * size_t(h) * 4); + stbi_image_free(px); + return true; +} - void deleteElements() { - Object* o = editing(); - if (!o) return; - pushUndo(); - if (elem_ == Elem::Face) deleteFaces(o->mesh, selFaces_); - else deleteVerts(o->mesh, selectedPoints()); - clearElements(); - handMade(*o); - touched(o->id); - } - // After a change that renumbers things: nothing selected, the shape is hand-made now. - void changedShape(Object& o, bool keepSelection = false) { - if (!keepSelection) clearElements(); - handMade(o); - touched(o.id); - } - void extrudeNow(float amount) { - Object* o = editing(); - if (!o || elem_ != Elem::Face || selFaces_.empty()) return say("Select faces to extrude (Faces, 3)"); - pushUndo(); - extrudeFaces(o->mesh, selFaces_, amount); - changedShape(*o, true); - } - void insetNow(float amount) { - Object* o = editing(); - if (!o || elem_ != Elem::Face || selFaces_.empty()) return say("Select faces to inset (Faces, 3)"); - pushUndo(); - insetFaces(o->mesh, selFaces_, amount); - changedShape(*o, true); - } - void bevelNow() { - Object* o = editing(); - const auto es = edgesToRound(); - if (!o || es.empty()) return say("Select edges (or faces) to round"); - pushUndo(); - bevelEdges(o->mesh, es, bevelWidth_, bevelSteps_); - changedShape(*o); - if (bevelSteps_ > 1) o->smooth = true; - } - void subdivideNow(bool smooth) { - Object* o = editing(); - if (!o) return; - pushUndo(); - subdivide(o->mesh, smooth); - if (smooth) o->smooth = true; - changedShape(*o); - } - void cutAt(Object& o, std::pair edge) { - pushUndo(); - const auto made = loopCut(o.mesh, edge, cuts_); - if (made.empty()) { - dropUndo(); - return say("Nothing to cut there: loop cuts go across four-sided faces"); - } - changedShape(o); - // The new edges are selected, ready to move (along the faces, with the gizmo). - const std::set pts(made.begin(), made.end()); - elem_ = Elem::Edge; - for (auto [a, b] : edges(o.mesh)) - if (pts.count(a) && pts.count(b)) selEdges_.insert({a, b}); - say(std::format("Cut {} time{} round. W moves the new edges.", cuts_, cuts_ == 1 ? "" : "s")); - } - void makeFaceNow() { - Object* o = editing(); - if (!o) return; - const std::set pts = selectedPoints(); - pushUndo(); - const glm::vec3 towards = glm::inverse(glm::mat3(objectMatrix(*o))) * (eye_ - pivotOfSelection()); - if (!makeFace(o->mesh, std::vector(pts.begin(), pts.end()), towards)) { - dropUndo(); - return say("Pick 3 or more points (not in a line) to make a face"); - } - changedShape(*o); - selFaces_ = {int(o->mesh.faces.size()) - 1}; - elem_ = Elem::Face; - } - void mergeNow() { - Object* o = editing(); - if (!o) return; - const std::set pts = selectedPoints(); - if (pts.size() < 2) return say("Select 2 or more points to merge"); - pushUndo(); - mergeToCenter(o->mesh, pts); - changedShape(*o); - } - void smoothPointsNow() { - Object* o = editing(); - const std::set pts = selectedPoints(); - if (!o || pts.empty()) return say("Select points (or faces, edges) to smooth"); - pushUndo(); - smoothPoints(o->mesh, pts, 0.5f, 2); - changedShape(*o, true); - say("Smoothed (again for more)"); - } - void startPush() { - Object* o = editing(); - if (!o || selectedPoints().empty()) return say("Select points (or faces) to push out or pull in"); - pushUndo(); - pushPts_ = selectedPoints(), pushFrom_ = o->mesh, pushObject_ = o->id, pushBy_ = 0.0f, showPush_ = true; - } - void pushWindow() { - if (!showPush_) return; - Object* o = find(pushObject_); - if (!o || mode_ != Mode::Edit) return void(showPush_ = false); - ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); - bool open = true; - if (!ImGui::Begin("Push out, pull in", &open, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End(); - const float w = 260; - float mm = pushBy_ * 1000; - ImGui::SetNextItemWidth(w); - if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); - if (ImGui::DragFloat("##by", &mm, 0.5f, -1e5f, 1e5f, "%.1f mm (- pulls in)")) { - pushBy_ = mm / 1000; - o->mesh = pushFrom_; - pushPoints(o->mesh, pushPts_, pushBy_); - changedShape(*o, true); - } - ImGui::TextWrapped("Each point along the surface's normal there: fatter or thinner, the same shape."); - pushAccent(); - const bool done = ImGui::Button("Done", ImVec2(w, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter, false); - popAccent(); - ImGui::End(); - if (done) showPush_ = false; - if (!open) o->mesh = pushFrom_, changedShape(*o, true), showPush_ = false; // (closed: as it was) - } - void bridgeNow() { - Object* o = editing(); - if (!o || selFaces_.size() != 2) return say("Select two faces to bridge (Faces, 3; Join two objects first if they're apart)"); - const int a = *selFaces_.begin(), b = *selFaces_.rbegin(); - if (o->mesh.faces[size_t(a)].size() != o->mesh.faces[size_t(b)].size()) - return say(std::format("Those faces have {} and {} corners: bridging needs as many on each", o->mesh.faces[size_t(a)].size(), o->mesh.faces[size_t(b)].size())); - pushUndo(); - const std::vector made = bridgeFaces(o->mesh, a, b); - if (made.empty()) return takeBack(), say("Couldn't bridge those (they share a point)"); - changedShape(*o); - selFaces_.insert(made.begin(), made.end()); - say(std::format("Bridged: {} faces between them", made.size())); - } - void flipNow() { - Object* o = editing(); - if (!o || selFaces_.empty()) return say("Select faces to flip"); - pushUndo(); - flipFaces(o->mesh, selFaces_); - changedShape(*o, true); - } - // Rotation and scale baked into the points (so the object's turn is none and its size is 1). - void applyTransform(Object& o) { - pushUndo(); - transform(o.mesh, glm::mat4_cast(o.rotation) * glm::scale(glm::mat4(1.0f), o.scale)); - o.rotation = glm::quat(1, 0, 0, 0), o.scale = glm::vec3(1.0f); - handMade(o); - touched(o.id); - } - void applyFinish(Object& o) { - pushUndo(); - o.mesh = finishedMesh(o); - o.mirror = 0, o.round = 0, o.smoothLevels = 0; - handMade(o); - touched(o.id); - } - void originToMiddle(Object& o) { - pushUndo(); - const glm::vec3 c = (boundsMin(o.mesh) + boundsMax(o.mesh)) * 0.5f; - for (glm::vec3& v : o.mesh.verts) v -= c; - o.position += glm::vec3(objectMatrix(o) * glm::vec4(c, 0.0f)); - handMade(o); - touched(o.id); - } - void originToBottom(Object& o) { - pushUndo(); - const glm::vec3 c((boundsMin(o.mesh).x + boundsMax(o.mesh).x) * 0.5f, (boundsMin(o.mesh).y + boundsMax(o.mesh).y) * 0.5f, boundsMin(o.mesh).z); - for (glm::vec3& v : o.mesh.verts) v -= c; - o.position += glm::vec3(objectMatrix(o) * glm::vec4(c, 0.0f)); - handMade(o); - touched(o.id); - } - void mirror(Object& o, int axis) { - pushUndo(); - glm::vec3 s(1.0f); - s[axis] = -1.0f; - transform(o.mesh, glm::scale(glm::mat4(1.0f), s)); - handMade(o); - touched(o.id); - } +auto App::addPicture(const std::string& path) -> void { + pushUndo(); + Reference ref; + ref.id = scene_.nextId++; + ref.path = path; + ref.axis = view_ == View::Front || view_ == View::Back ? Axis::Front : view_ == View::Top || view_ == View::Bottom ? Axis::Top : Axis::Side; + ref.width = 2.0f; + scene_.references.push_back(ref); + activeRef_ = ref.id; + if (view_ == View::Perspective) setView(ref.axis == Axis::Front ? View::Front : ref.axis == Axis::Top ? View::Top : View::Left); + say("Picture added: set its width in Properties to the real size"); +} - - // ───────────────────────────── tabs ───────────────────────────── - - void setTab(Tab t) { - if (t == tab_) return; - if (drag_.active) cancelDrag(); - lockLayer(); - endStroke(), endSculpt(); - knifePts_.clear(), drawPts_.clear(); - if (t != Tab::Model) leaveEdit(); - if ((t == Tab::Render || t == Tab::Print) && tool_ != Tool::Select && tool_ != Tool::Move && tool_ != Tool::Rotate && tool_ != Tool::Scale) tool_ = Tool::Move; - if ((t == Tab::Paint || t == Tab::Layout) && tab_ != Tab::Paint && tab_ != Tab::Layout) picZoom_ = 0; // (fit the picture again) - lay_.drag = 0, printTool_ = PrintTool::None, measure_.clear(); - tab_ = t; - } - Shading shading() const { return tab_ == Tab::Render ? Shading::Rendered : shading_; } - - // ───────────────────────────── the knife and holes ───────────────────────────── - - // Snapped to steps of 45 degrees from the last point while Ctrl is held. - ImVec2 knifeEnd(const ImGuiIO& io) const { - 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); - } - static Mesh subMesh(const Mesh& m, const std::set& faces) { - 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. - uint32_t objectLike(uint32_t from, Mesh mesh, const std::string& name) { - 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; - } - void knifeApply(uint32_t id, std::vector pts) { - 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 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& 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& a, const std::set& 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 others; - for (size_t i = 1; i < n; ++i) others.insert(pieces[i].begin(), pieces[i].end()); - std::vector 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"); - } - static float objectScale(const Object& o) { return std::max(std::cbrt(std::abs(glm::determinant(glm::mat3(objectMatrix(o))))), 1e-6f); } - // How wide (across) a shape is when it reaches `reach` from its middle (dragging it out). - float holeAcrossFor(float reach) const { - return HoleShape(holeShape_) == HoleShape::Triangle || HoleShape(holeShape_) == HoleShape::Hexagon ? reach * std::sqrt(3.0f) : reach * 2.0f; - } - // The outline, `across` wide in the object's own size (a rectangle keeps its proportions). - std::vector holeShapeOutline(const Object& o, int face, const glm::vec3& centre, float across) const { - const float tall = across * holeTall_ / std::max(holeSize_, 1e-6f); - return holeOutline(o.mesh, face, centre, HoleShape(holeShape_), across, tall, holeSides_, holeTurn_); - } - void applyHole(uint32_t id, int face, const glm::vec3& centre, float across) { - 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. - std::vector holePreview(const ImGuiIO& io) { - 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 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). - std::optional onFacePlane(const Object& o, int face, ImVec2 mouse) { - 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)); - } - - // ───────────────────────────── 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). - Object* readyToPaint(uint32_t id) { - 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(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. - Image& changePicture(Object& o) { - pushUndo(); - auto img = std::make_shared(*o.picture); - o.picture = img; - paint_.image = img; - uploaded_[o.id].dirty = true; - return *img; - } - void beginStroke(Object& o, bool in2D) { - 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_); - } - void endStroke() { - 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. - std::optional uvUnder(const Object& o, const Ray& ray) { - if (!o.uvsFit()) return std::nullopt; - const glm::mat4 m = objectMatrix(o); - float best = 1e30f; - std::optional 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). - void dab3D(Object& o, const glm::vec3& at, float radius, const glm::vec3* eyeAt = nullptr, const glm::mat4* back = nullptr) { - 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. - void paintAt(ImVec2 mouse, bool start) { - 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; - } - void pickColor(const Object& o, glm::vec2 uv) { - const glm::vec4 c = sample(*o.picture, uv * glm::vec2(o.picture->w, o.picture->h)); - paintColor_ = glm::vec4(glm::vec3(c), paintColor_.a); - } - void fillAt(Object& o, glm::vec2 uv) { - 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. - Object* readyToSculpt(uint32_t id) { - 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; - } - void endSculpt() { - if (!sculpt_.active) return; - sculpt_ = {}; - } - // The brush at a point of the view: starts a stroke on what's there, or carries one on. - void sculptAt(ImVec2 mouse, bool start, bool ctrl, bool shift) { - 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(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); - } - void sculptViewport(const ImGuiIO& io) { - 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 ── - - PictureLayer* editLayer() { - 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. - void startFloating(Image img) { - 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(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."); - } - void floatFromFile(const std::string& path) { - Image img; - if (!loadImageFile(path, img)) return say("Couldn't load " + path); - startFloating(std::move(img)); - } - void pasteFromClipboard() { - 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(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). - void lockLayer() { - 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."); - } - void unlockLayer(uint32_t object, int index) { - 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. - void cancelFloating() { - Object* o = find(ledit_.object); - if (o && editLayer() && ledit_.fresh) { - o->layers.erase(o->layers.begin() + ledit_.index); - ledit_ = {}; - return say("Taken away"); - } - lockLayer(); - } - void deleteLayer(Object& o, int i) { - 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). - void mergeLayer(Object& o, int i) { - 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(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. - bool stickerHandles(const Object& o, const PictureLayer& l, std::array& out) { - 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; - } - int stickerHandle(const Object& o, const PictureLayer& l, ImVec2 mouse) { - std::array 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. - void stickerMouse(const ImGuiIO& io) { - 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). - uint64_t pictureStamp(const Object& o) { - uint64_t h = 1469598103934665603ull; - auto mix = [&](const void* p, size_t n) { - for (size_t i = 0; i < n; ++i) h = (h ^ static_cast(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. - void uploadPicture(const Object& o, bool now_ = false) { - 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. - std::vector> layoutPicked(const Object& o, const std::vector>& islands) const { - std::vector> 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. - std::set> layoutCorner(const Object& o, int f, int c) const { - std::set> 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; - } - void layoutSelectAll() { - 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. - void layoutChange(Object& o, const std::vector>& from, const std::shared_ptr& picture, - const std::vector>& picked, const std::function& to, bool final) { - const glm::vec2 size(float(o.picture->w), float(o.picture->h)); - o.uvs = from; - std::set 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(std::move(img)); - lay_.remapAt = now(); - } - } - // Buttons' changes (turn, flip, straighten): about the middle of what's picked. - void layoutApply(const std::function& to) { - 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); - } - void layoutStraighten() { - 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 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(), 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. - void layoutAgain(Object& o) { - 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 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(std::move(fresh)); - } - lay_.frames.clear(), lay_.points.clear(); - touched(o.id); - say("Laid out again" + std::string(layKeepPaint_ ? ", what's painted carried over" : "")); - } - - template - void layoutCanvas(Object& o, ImDrawList* dl, F toScreen, glm::vec2 mouse, bool hovered, int W, int H) { - const ImGuiIO& io = ImGui::GetIO(); - if (lay_.object != o.id) lay_ = {}, lay_.object = o.id; - const glm::vec2 size{float(W), float(H)}; - const auto islands = uvIslands(o.mesh, o.uvs); - std::vector islandOf(o.mesh.faces.size(), -1); - for (size_t i = 0; i < islands.size(); ++i) - for (int f : islands[i]) islandOf[size_t(f)] = int(i); - auto screen = [&](int f, int c) { return toScreen(o.uvs[size_t(f)][size_t(c)] * size); }; - auto faceUnder = [&]() { - for (size_t f = 0; f < o.mesh.faces.size(); ++f) { - std::vector poly; - for (size_t c = 0; c < o.uvs[f].size(); ++c) poly.push_back(screen(int(f), int(c))); - if (insidePolygon(io.MousePos, poly)) return int(f); - } - return -1; - }; - auto cornerUnder = [&]() { - std::pair best{-1, -1}; - float d = 9.0f; - for (size_t f = 0; f < o.uvs.size(); ++f) - for (size_t c = 0; c < o.uvs[f].size(); ++c) { - const ImVec2 q = screen(int(f), int(c)); - if (const float e = std::hypot(q.x - io.MousePos.x, q.y - io.MousePos.y); e < d) d = e, best = {int(f), int(c)}; - } - return best; - }; - const auto picked = layoutPicked(o, islands); - std::set pickedFaces; - for (auto [f, c] : picked) pickedFaces.insert(f); - // Pressing: pick (what's under it, or a box), then move / turn / size what's picked. - if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !lay_.drag) { - bool hit = false, wasPicked = false; - if (lay_.corners) { - const auto [f, c] = cornerUnder(); - if (f >= 0) { - const auto same = layoutCorner(o, f, c); - hit = true, wasPicked = lay_.points.count({f, c}) > 0; - if (io.KeyShift && wasPicked) - for (auto p : same) lay_.points.erase(p); - else { - if (!io.KeyShift && !wasPicked) lay_.points.clear(); - lay_.points.insert(same.begin(), same.end()); - } - } - } else if (const int f = faceUnder(); f >= 0) { - const int i = islandOf[size_t(f)]; - hit = true, wasPicked = lay_.frames.count(i) > 0; - if (io.KeyShift && wasPicked) lay_.frames.erase(i); - else { - if (!io.KeyShift && !wasPicked) lay_.frames.clear(); - lay_.frames.insert(i); - } - } - const bool anything = !lay_.frames.empty() || !lay_.points.empty(); - const bool transform = layTool_ != LayTool::Select && anything && !(io.KeyShift && hit) && (hit || layTool_ != LayTool::Move); - if (transform) { - pushUndo(); - lay_.drag = 1, lay_.grab = mouse, lay_.start = o.uvs, lay_.startPicture = o.picture; - glm::vec2 lo(1e30f), hi(-1e30f); - for (auto [f, c] : layoutPicked(o, islands)) 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); - lay_.centre = (lo + hi) * 0.5f; - } else if (!hit) { - if (!io.KeyShift) lay_.frames.clear(), lay_.points.clear(); - lay_.drag = 2, lay_.boxFrom = io.MousePos; - } - } - if (lay_.drag == 1) { - const glm::vec2 g = lay_.grab - lay_.centre, m = mouse - lay_.centre; - std::function to; - if (layTool_ == LayTool::Move || layTool_ == LayTool::Select) { - const glm::vec2 d = mouse - lay_.grab; - to = [d](glm::vec2 p) { return p + d; }; - message_ = std::format("Moved {:.0f}, {:.0f} px", d.x, d.y); - } else if (layTool_ == LayTool::Rotate) { - float a = std::atan2(m.y, m.x) - std::atan2(g.y, g.x); - if (io.KeyCtrl) a = glm::radians(snapTo(glm::degrees(a), 15.0f)); - const glm::vec2 c = lay_.centre; - to = [a, c](glm::vec2 p) { return c + glm::vec2(std::cos(a) * (p - c).x - std::sin(a) * (p - c).y, std::sin(a) * (p - c).x + std::cos(a) * (p - c).y); }; - message_ = std::format("Turned {:.0f} degrees", glm::degrees(a)); - } else { - glm::vec2 k(glm::length(m) / std::max(glm::length(g), 1.0f)); - if (io.KeyShift) // (only across or only up: whichever the drag goes along more) - k = std::abs(g.x) > std::abs(g.y) ? glm::vec2(m.x / (std::abs(g.x) > 1 ? g.x : 1.0f), 1.0f) : glm::vec2(1.0f, m.y / (std::abs(g.y) > 1 ? g.y : 1.0f)); - const glm::vec2 c = lay_.centre; - to = [k, c](glm::vec2 p) { return c + (p - c) * k; }; - message_ = std::format("Size {:.0f}% x {:.0f}%", k.x * 100, k.y * 100); - } - messageAt_ = now(); - const bool done = !ImGui::IsMouseDown(ImGuiMouseButton_Left); - layoutChange(o, lay_.start, lay_.startPicture, layoutPicked(o, islands), to, done); - if (done) lay_.drag = 0, lay_.startPicture.reset(); - } - // Drawing: every face's outline, what's picked filled; corners as dots. - const ImU32 fill = (kAccent & 0x00FFFFFF) | 0x38000000; - for (size_t f = 0; f < o.mesh.faces.size(); ++f) { - std::vector pts; - for (size_t c = 0; c < o.uvs[f].size(); ++c) pts.push_back(screen(int(f), int(c))); - const bool on = pickedFaces.count(int(f)) && !lay_.corners; - if (on) - for (const auto& t : triangulate(o.mesh, int(f))) { - ImVec2 q[3]; - for (int k = 0; k < 3; ++k) q[k] = pts[size_t(std::find(o.mesh.faces[f].begin(), o.mesh.faces[f].end(), t[size_t(k)]) - o.mesh.faces[f].begin())]; - dl->AddTriangleFilled(q[0], q[1], q[2], fill); - } - dl->AddPolyline(pts.data(), int(pts.size()), on ? kAccent : IM_COL32(255, 255, 255, 150), ImDrawFlags_Closed, on ? 2.0f : 1.2f); - } - if (lay_.corners) { - const auto hot = cornerUnder(); - for (size_t f = 0; f < o.uvs.size(); ++f) - for (size_t c = 0; c < o.uvs[f].size(); ++c) { - const ImVec2 q = screen(int(f), int(c)); - const bool on = lay_.points.count({int(f), int(c)}) > 0; - dl->AddCircleFilled(q, on ? 4.5f : 3.0f, on ? kAccent : hot == std::pair{int(f), int(c)} ? kHot : IM_COL32(255, 255, 255, 200)); - } - } else if (hovered && !lay_.drag) - if (const int f = faceUnder(); f >= 0 && !lay_.frames.count(islandOf[size_t(f)])) - for (int g : islands[size_t(islandOf[size_t(f)])]) { - std::vector pts; - for (size_t c = 0; c < o.uvs[size_t(g)].size(); ++c) pts.push_back(screen(g, int(c))); - dl->AddPolyline(pts.data(), int(pts.size()), kHot, ImDrawFlags_Closed, 1.5f); - } - if (lay_.drag == 1 && layTool_ != LayTool::Move && layTool_ != LayTool::Select) { // (the middle it turns or sizes about) - const ImVec2 c = toScreen(lay_.centre); - dl->AddCircle(c, 6, kAccent, 16, 2.0f); - dl->AddLine(c, io.MousePos, (kAccent & 0x00FFFFFF) | 0x90000000, 1.0f); - } - if (lay_.drag == 2) { // a box to pick with - const ImVec2 a = lay_.boxFrom, b = io.MousePos; - dl->AddRectFilled(ImVec2(std::min(a.x, b.x), std::min(a.y, b.y)), ImVec2(std::max(a.x, b.x), std::max(a.y, b.y)), IM_COL32(255, 120, 40, 30)); - dl->AddRect(ImVec2(std::min(a.x, b.x), std::min(a.y, b.y)), ImVec2(std::max(a.x, b.x), std::max(a.y, b.y)), kAccent); - if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) { - auto in = [&](ImVec2 q) { return q.x >= std::min(a.x, b.x) && q.x <= std::max(a.x, b.x) && q.y >= std::min(a.y, b.y) && q.y <= std::max(a.y, b.y); }; - for (size_t f = 0; f < o.uvs.size(); ++f) - for (size_t c = 0; c < o.uvs[f].size(); ++c) - if (in(screen(int(f), int(c)))) { - if (lay_.corners) lay_.points.insert({int(f), int(c)}); - else lay_.frames.insert(islandOf[f]); - } - lay_.drag = 0; - } - } - } - // In the 3D view: clicking the model picks the frame its face is in (Shift: add). - void layoutViewport(const ImGuiIO& io) { - 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)); - } - } - void layoutOverlay(ImDrawList* dl) { - 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 faces; - for (auto [f, c] : picked) faces.insert(f); - const glm::mat4 m = objectMatrix(*o); - for (int f : faces) { - std::vector 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); - } - } - void layoutToolbox(float w) { - 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(); - } - void layoutOptions(float w) { - 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"); - } - void layoutProperties(float w) { - 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."); - } - - // ───────────────────────────── the Print tab ───────────────────────────── - - glm::vec3 bedSize() const { // (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; - } - const PrintInfo& printInfo(const Object& o) { - 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 = versions_[o.id] * 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(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; - } - std::vector printTargets() { // (what's picked, or everything shown) - std::vector out; - for (Object& o : scene_.objects) - if (o.visible && !isCutter(o) && (selected_.empty() || selected_.count(o.id))) out.push_back(&o); - return out; - } - void drawPrintBed(keel::Renderer& r, const keel::ModelLighting& light) { - 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); - } - } - void putOnBed() { - 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"); - } - void middleOfBed() { - 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. - void printResize(int axis, float mm) { - 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. - void printScale(float k) { - 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); - } - // The scaler: by a share, to fit the plate, or from the wrong units (a model made in inches, - // centimetres or metres comes in 25.4, 10 or 1000 times off). - float scalePercent_ = 100.0f; - void printScaler(float w) { - 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)"); - } - void printSizeWindow() { - 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(); - } - void printToolbox(float w) { - 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); - } - void printOptions(float w) { - 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."); - } - } - void printViewport(const ImGuiIO& io) { - 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. - std::optional measurePoint(ImVec2 mouse) { - 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. - void layerOverlay(ImDrawList* dl) { - 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); - } - } - } - void printOverlay(ImDrawList* dl, const ImGuiIO& io) { - if (printTool_ != PrintTool::Measure) return; - std::vector 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()); - } - } - void printProperties(float w) { - 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)"); - } - - // ───────────────────────────── layers: folders and going to things ───────────────────────────── - - Folder* findFolder(uint32_t id) { - for (Folder& f : scene_.folders) - if (f.id == id) return &f; - return nullptr; - } - bool inFolder(uint32_t folder, uint32_t within) { // (folder is within, or inside it somewhere) - for (int n = 0; folder && n < 256; ++n) { - if (folder == within) return true; - const Folder* f = findFolder(folder); - folder = f ? f->parent : 0; - } - return false; - } - std::set objectsIn(uint32_t folder) { - std::set out; - for (const Object& o : scene_.objects) - if (inFolder(o.folder, folder)) out.insert(o.id); - return out; - } - // The camera brought round to these (as framing them, but keeping the way it looks). - void goTo(const std::set& ids) { - glm::vec3 lo(1e30f), hi(-1e30f); - for (const Object& o : scene_.objects) - if (ids.count(o.id)) { - const glm::mat4 m = objectMatrix(o); - for (const glm::vec3& v : o.mesh.verts) { - const glm::vec3 w(m * glm::vec4(v, 1.0f)); - lo = glm::min(lo, w), hi = glm::max(hi, w); - } - } - if (lo.x > hi.x) return; - frameBox(lo, hi); - } - // The picked objects into a new folder (Ctrl+G): a group, picked and moved together. - void groupSelected() { - if (mode_ == Mode::Edit) leaveEdit(); - if (selected_.empty()) return say("Pick the objects to put in a folder first"); - pushUndo(); - Folder f; - f.id = scene_.nextId++; - int n = 1; - while (std::any_of(scene_.folders.begin(), scene_.folders.end(), [&](const Folder& g) { return g.name == std::format("Group {}", n); })) ++n; - f.name = std::format("Group {}", n); - if (const Object* a = find(active_)) f.parent = a->folder; // (where the last picked was) - for (Object& o : scene_.objects) - if (selected_.count(o.id)) o.folder = f.id; - scene_.folders.push_back(f); - renamingFolder_ = f.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", f.name.c_str()); - say(f.name + ": click it to pick all of it; type a name"); - } - // Ungrouped: what's inside goes up a level, the folder goes. - void ungroup(uint32_t id) { - const Folder* f = findFolder(id); - if (!f) return; - pushUndo(); - const uint32_t up = f->parent; - for (Object& o : scene_.objects) - if (o.folder == id) o.folder = up; - for (Folder& g : scene_.folders) - if (g.parent == id) g.parent = up; - std::erase_if(scene_.folders, [&](const Folder& g) { return g.id == id; }); - } - void deleteFolder(uint32_t id) { - pushUndo(); - const auto ids = objectsIn(id); - std::erase_if(scene_.objects, [&](const Object& o) { return ids.count(o.id) > 0; }); - std::erase_if(scene_.folders, [&](const Folder& g) { return inFolder(g.id, id); }); - for (uint32_t i : ids) selected_.erase(i); - } - // A folder's eye or lock: all in it the same. - void showFolder(Folder& f, bool on) { - pushUndo(); - for (Folder& g : scene_.folders) - if (inFolder(g.id, f.id)) g.visible = on; - for (Object& o : scene_.objects) - if (inFolder(o.folder, f.id)) o.visible = on; - } - void lockFolder(Folder& f, bool on) { - pushUndo(); - for (Folder& g : scene_.folders) - if (inFolder(g.id, f.id)) g.locked = on; - for (Object& o : scene_.objects) - if (inFolder(o.folder, f.id)) o.locked = on; - } - void moveToFolder(const std::set& ids, uint32_t folder) { - pushUndo(); - for (Object& o : scene_.objects) - if (ids.count(o.id)) o.folder = folder; - } - // Only these shown (again: everything shown). - void showOnly(const std::set& ids) { - pushUndo(); - const bool already = std::all_of(scene_.objects.begin(), scene_.objects.end(), [&](const Object& o) { return o.visible == (ids.count(o.id) > 0); }); - for (Object& o : scene_.objects) o.visible = already || ids.count(o.id); - for (Folder& f : scene_.folders) f.visible = true; - say(already ? "Everything shown again" : "Only that shown (do it again to show everything)"); - } - void folderMenu(uint32_t into, const std::set& ids, int depth = 0) { // (Move to folder: the folders inside `into`) - for (const Folder& f : scene_.folders) - if (f.parent == into) { - const std::string label = std::string(size_t(depth) * 3, ' ') + f.name; - if (ImGui::MenuItem(label.c_str())) moveToFolder(ids, f.id); - folderMenu(f.id, ids, depth + 1); - } - } - // What a right-click on an object (or the picked ones) offers. - void objectMenu(Object& o) { - const std::set ids = selected_.count(o.id) ? selected_ : std::set{o.id}; - if (ImGui::MenuItem("Go to it", "F")) goTo(ids); - if (ImGui::MenuItem("Rename")) renaming_ = o.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", o.name.c_str()); - if (ImGui::MenuItem("Duplicate", "Ctrl+D")) selected_ = ids, active_ = o.id, duplicateSelected(); - if (ImGui::MenuItem("Show only this (again: all)")) showOnly(ids); - if (ImGui::BeginMenu("Move to folder")) { - if (ImGui::MenuItem("(the top, no folder)")) moveToFolder(ids, 0); - folderMenu(0, ids); - ImGui::Separator(); - if (ImGui::MenuItem("A new folder", "Ctrl+G")) selected_ = ids, active_ = o.id, groupSelected(); - ImGui::EndMenu(); - } - ImGui::Separator(); - if (ImGui::MenuItem("Delete", "Del")) selected_ = ids, deleteSelected(); - } - // A row's show / hide or lock button: the picture of how it is now, dim when it's the usual way. - bool rowToggle(const char* id, Icon icon, bool usual, const char* tip) { - const ImVec2 at = ImGui::GetCursorScreenPos(); - const float h = ImGui::GetFrameHeight() - 4; - const bool pressed = ImGui::InvisibleButton(id, ImVec2(h, h)); - const ImU32 col = ImGui::IsItemHovered() ? kHot : usual ? IM_COL32(200, 200, 205, 150) : ImGui::GetColorU32(accentText()); - drawIcon(ImGui::GetWindowDrawList(), icon, ImVec2(at.x + h * 0.1f, at.y + h * 0.1f), h * 0.8f, col); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip); - return pressed; - } - // The ⌖ button at the end of a row: brings the camera to it. - bool goButton(const char* tip) { - const ImVec2 at = ImGui::GetCursorScreenPos(); - const float h = ImGui::GetFrameHeight() - 4; - const bool pressed = ImGui::InvisibleButton("##go", ImVec2(h, h)); - const ImU32 col = ImGui::IsItemHovered() ? kHot : IM_COL32(200, 200, 205, 200); - const ImVec2 c(at.x + h * 0.5f, at.y + h * 0.5f); - ImDrawList* dl = ImGui::GetWindowDrawList(); - dl->AddCircle(c, h * 0.3f, col, 16, 1.5f); - dl->AddLine(ImVec2(c.x - h * 0.48f, c.y), ImVec2(c.x - h * 0.16f, c.y), col, 1.5f), dl->AddLine(ImVec2(c.x + h * 0.16f, c.y), ImVec2(c.x + h * 0.48f, c.y), col, 1.5f); - dl->AddLine(ImVec2(c.x, c.y - h * 0.48f), ImVec2(c.x, c.y - h * 0.16f), col, 1.5f), dl->AddLine(ImVec2(c.x, c.y + h * 0.16f), ImVec2(c.x, c.y + h * 0.48f), col, 1.5f); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip); - return pressed; - } - // Rows for what's in a folder (0: the top): its folders, then its objects. - void layerRows(uint32_t parent, int depth) { - const float indent = float(depth) * 14.0f; - // (copies of the ids: rows may change the lists) - std::vector folders, objects; - for (const Folder& f : scene_.folders) - if (f.parent == parent) folders.push_back(f.id); - for (const Object& o : scene_.objects) - if (o.folder == parent) objects.push_back(o.id); - for (uint32_t id : folders) { - Folder* f = findFolder(id); - if (!f) continue; - const auto inside = objectsIn(id); - ImGui::PushID(int(id) + 1000000); - ImGui::TableNextRow(); - ImGui::TableNextColumn(); - if (rowToggle("##eye", f->visible ? Icon::Eye : Icon::EyeShut, f->visible, f->visible ? "Hide all in it" : "Show all in it")) showFolder(*f, !f->visible); - ImGui::TableNextColumn(); - if (rowToggle("##lock", f->locked ? Icon::Lock : Icon::Unlock, !f->locked, f->locked ? "Unlock all in it" : "Lock all in it")) lockFolder(*f, !f->locked); - ImGui::TableNextColumn(); - ImGui::Indent(indent + 1); - if (ImGui::ArrowButton("##open", f->open ? ImGuiDir_Down : ImGuiDir_Right)) f->open = !f->open; - ImGui::SameLine(0, 4); - const bool picked = !inside.empty() && std::all_of(inside.begin(), inside.end(), [&](uint32_t i) { return selected_.count(i) > 0; }); - if (renamingFolder_ == id) { - ImGui::SetNextItemWidth(-1); - if (!ImGui::IsAnyItemActive()) ImGui::SetKeyboardFocusHere(); - if (ImGui::InputText("##rename", renameBuf_, sizeof renameBuf_, ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_AutoSelectAll)) { - if (renameBuf_[0]) f->name = renameBuf_; - renamingFolder_ = 0, dirty_ = true; - } - if (ImGui::IsItemDeactivated()) renamingFolder_ = 0; - } else { - ImGui::PushStyleColor(ImGuiCol_Text, accentText()); - const std::string label = std::format("{} ({})", f->name, inside.size()); - if (ImGui::Selectable(label.c_str(), picked, ImGuiSelectableFlags_AllowDoubleClick)) { // a folder is a group: all of it picked - if (mode_ == Mode::Edit) leaveEdit(); - if (ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeyShift) selected_.insert(inside.begin(), inside.end()); - else selected_ = inside; - if (!inside.empty()) active_ = *inside.rbegin(); - activeRef_ = 0; - if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) renamingFolder_ = id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", f->name.c_str()); - } - ImGui::PopStyleColor(); - if (ImGui::BeginDragDropSource()) { - ImGui::SetDragDropPayload("FRY_FOLDER", &id, sizeof id); - ImGui::Text("%s", f->name.c_str()); - ImGui::EndDragDropSource(); - } - if (ImGui::BeginDragDropTarget()) { - if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_OBJECT")) { - const uint32_t oid = *static_cast(p->Data); - moveToFolder(selected_.count(oid) ? selected_ : std::set{oid}, id); - } - if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_FOLDER")) { - const uint32_t fid = *static_cast(p->Data); - if (Folder* g = findFolder(fid); g && !inFolder(id, fid)) pushUndo(), g->parent = id; // (not into itself) - } - ImGui::EndDragDropTarget(); - } - if (ImGui::IsItemHovered() && !ImGui::IsMouseDragging(ImGuiMouseButton_Left)) ImGui::SetTooltip("Click: pick all of it (a group). Double-click: rename. Drag things onto it."); - if (ImGui::BeginPopupContextItem("folder")) { - if (ImGui::MenuItem("Go to it")) goTo(inside); - if (ImGui::MenuItem("Pick all of it")) selected_ = inside; - if (ImGui::MenuItem("Rename")) renamingFolder_ = id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", f->name.c_str()); - if (ImGui::MenuItem("Show only this (again: all)")) showOnly(inside); - if (ImGui::MenuItem("A folder inside it")) { - pushUndo(); - Folder g; - g.id = scene_.nextId++, g.name = "Folder", g.parent = id; - scene_.folders.push_back(g); - f = findFolder(id), f->open = true; - renamingFolder_ = g.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", g.name.c_str()); - } - if (ImGui::MenuItem("Take it to the top") && f->parent) pushUndo(), f->parent = 0; - ImGui::Separator(); - if (ImGui::MenuItem("Ungroup (keep what's in it)")) ungroup(id); - if (ImGui::MenuItem("Delete it and all in it")) deleteFolder(id); - ImGui::EndPopup(); - } - } - ImGui::Unindent(indent + 1); - ImGui::TableNextColumn(); - if (goButton("Go to it: the camera comes round to all of it")) goTo(inside); - ImGui::PopID(); - if ((f = findFolder(id)) && f->open) layerRows(id, depth + 1); - } - for (uint32_t id : objects) { - Object* op = find(id); - if (!op) continue; - Object& o = *op; - ImGui::PushID(int(o.id)); - ImGui::TableNextRow(); - ImGui::TableNextColumn(); - if (rowToggle("##eye", o.visible ? Icon::Eye : Icon::EyeShut, o.visible, o.visible ? "Shown (click to hide)" : "Hidden (click to show)")) o.visible = !o.visible, dirty_ = true; - ImGui::TableNextColumn(); - if (rowToggle("##lock", o.locked ? Icon::Lock : Icon::Unlock, !o.locked, o.locked ? "Locked: can't be moved or changed (click to unlock)" : "Click to lock")) o.locked = !o.locked, dirty_ = true; - ImGui::TableNextColumn(); - ImGui::Indent(indent + 1); - if (renaming_ == o.id) { - ImGui::SetNextItemWidth(-1); - if (!ImGui::IsAnyItemActive()) ImGui::SetKeyboardFocusHere(); - if (ImGui::InputText("##rename", renameBuf_, sizeof renameBuf_, ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_AutoSelectAll)) { - pushUndo(); - if (renameBuf_[0]) o.name = renameBuf_; - renaming_ = 0; - } - if (ImGui::IsItemDeactivated()) renaming_ = 0; - } else { - ImGui::PushStyleColor(ImGuiCol_Text, o.visible ? ImGui::GetStyleColorVec4(ImGuiCol_Text) : ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); - if (ImGui::Selectable(o.name.c_str(), selected_.count(o.id) > 0, ImGuiSelectableFlags_AllowDoubleClick)) { - if (mode_ == Mode::Edit && o.id != active_) leaveEdit(); - if (ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeyShift) selected_.count(o.id) ? (void)selected_.erase(o.id) : (void)selected_.insert(o.id); - else selected_ = {o.id}; - active_ = o.id, activeRef_ = 0; - if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) renaming_ = o.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", o.name.c_str()); - } - ImGui::PopStyleColor(); - if (ImGui::BeginDragDropSource()) { - ImGui::SetDragDropPayload("FRY_OBJECT", &id, sizeof id); - ImGui::Text("%s", selected_.count(id) && selected_.size() > 1 ? std::format("{} objects", selected_.size()).c_str() : o.name.c_str()); - ImGui::EndDragDropSource(); - } - if (ImGui::IsItemHovered() && !ImGui::IsMouseDragging(ImGuiMouseButton_Left)) ImGui::SetTooltip("Double-click to rename; drag onto a folder; right-click for more"); - if (ImGui::BeginPopupContextItem("object")) { - objectMenu(o); - ImGui::EndPopup(); - } - } - ImGui::Unindent(indent + 1); - ImGui::TableNextColumn(); - if (goButton("Go to it: the camera comes round to it")) goTo({id}); - ImGui::PopID(); - } - } - - // ───────────────────────────── combining, copies, thickness ───────────────────────────── - - // The last picked object with the others added, taken out of it, or only where they overlap - // kept; the others go. - void combineSelected(Combine op) { - 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 others = [&] { - std::set 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())); - } - void makeCopies() { - 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 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 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 made = selected_; - const std::vector originals = [&] { - std::vector 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" : "")); - } - void copiesWindow() { - 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(); - } - void thickenActive() { - 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. - void importFile(const std::string& file) { - std::string err; - std::vector got = fry::importModel(file, &err); - if (got.empty()) return say(err.empty() ? "Nothing in " + fileName(file) : err); - pushUndo(); - if (mode_ == Mode::Edit) leaveEdit(); - glm::vec3 lo(1e30f), hi(-1e30f); - for (const auto& g : got) lo = glm::min(lo, boundsMin(g.mesh)), hi = glm::max(hi, boundsMax(g.mesh)); - const glm::vec3 shift(-(lo.x + hi.x) / 2, -(lo.y + hi.y) / 2, -lo.z), at(std::round(target_.x * 10) / 10, std::round(target_.y * 10) / 10, 0.0f); - selected_.clear(); - size_t faces = 0; - for (auto& g : got) { - // (each object's own middle as its place, so it turns about itself) - const glm::vec3 lo1 = boundsMin(g.mesh) + shift, hi1 = boundsMax(g.mesh) + shift, mid((lo1.x + hi1.x) / 2, (lo1.y + hi1.y) / 2, lo1.z); - transform(g.mesh, glm::translate(glm::mat4(1.0f), shift - mid)); - faces += g.mesh.faces.size(); - Object& o = addObject(g.name.empty() ? "Imported" : g.name, std::move(g.mesh)); - o.position = at + mid; - selected_.insert(o.id), active_ = o.id; - } - const glm::vec3 size = (hi - lo) * 1000.0f; - say(std::format("{}: {} object{}, {} faces, {:.1f} x {:.1f} x {:.1f} mm", fileName(file), got.size(), got.size() == 1 ? "" : "s", faces, size.x, size.y, size.z)); - if (glm::length(hi - lo) > 0) frameSelected(); - } - - // ── Spin: an outline turned round an axis (a vase, a wheel, a ring), or a screw ── - bool showSpin_ = false, spinWorld_ = false, spinKeep_ = false; - int spinAxis_ = 2, spinSteps_ = 48; - float spinDegrees_ = 360.0f, spinRise_ = 0.0f; - void spinWindow() { - 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(); - } - void spinSelected() { - 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())); - } - - // ── Text as a shape ── - bool showText_ = false; - char textBuf_[512] = "Fry3DX"; - int textFont_ = 2, textAlign_ = 0; - float textHeight_ = 0.05f, textDepth_ = 0.01f, textSpacing_ = 0.0f; - bool textStanding_ = false; - std::string textFontFile_; // (a font of your own, picked from a file) - static constexpr const char* kFonts[] = {"Instrument Sans", "Instrument Sans (semi-bold)", "Instrument Sans (bold)", "JetBrains Mono"}; - static constexpr const char* kFontFiles[] = {"fonts/InstrumentSans-Regular.ttf", "fonts/InstrumentSans-SemiBold.ttf", "fonts/InstrumentSans-Bold.ttf", "fonts/JetBrainsMono-Regular.ttf"}; - void textWindow() { - if (!showText_) return; - ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); - if (!ImGui::Begin("Add text", &showText_, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End(); - const float w = 300; - ImGui::InputTextMultiline("##text", textBuf_, sizeof textBuf_, ImVec2(w, ImGui::GetTextLineHeight() * 3.6f)); - ImGui::SetNextItemWidth(w); - const std::string fontName = textFontFile_.empty() ? kFonts[textFont_] : stdfs::path(textFontFile_).filename().string(); - if (ImGui::BeginCombo("##font", fontName.c_str())) { - for (int i = 0; i < int(std::size(kFonts)); ++i) - if (ImGui::Selectable(kFonts[i], textFontFile_.empty() && textFont_ == i)) textFont_ = i, textFontFile_.clear(); - if (ImGui::Selectable("A font file of your own (.ttf, .otf)...")) dialog(13); - ImGui::EndCombo(); - } - float mm[3] = {textHeight_ * 1000, textDepth_ * 1000, textSpacing_ * 100}; - ImGui::SetNextItemWidth(w); - if (ImGui::DragFloat("##h", &mm[0], 0.2f, 0.5f, 1e5f, "letters %.1f mm tall")) textHeight_ = std::max(mm[0], 0.1f) / 1000; - ImGui::SetNextItemWidth(w); - if (ImGui::DragFloat("##d", &mm[1], 0.1f, 0.1f, 1e5f, "%.1f mm thick")) textDepth_ = std::max(mm[1], 0.05f) / 1000; - ImGui::SetNextItemWidth(w); - if (ImGui::DragFloat("##s", &mm[2], 0.2f, -30, 100, "letters %+.0f%% further apart")) textSpacing_ = mm[2] / 100; - ImGui::SetNextItemWidth(w); - ImGui::Combo("##al", &textAlign_, "Lines to the left\0Lines in the middle\0Lines to the right\0"); - ImGui::Checkbox("Standing up (a sign), not lying (a label to print)", &textStanding_); - pushAccent(); - if (ImGui::Button("Add it", ImVec2(w, 0))) addText(); - popAccent(); - ImGui::End(); - } - void addText() { - std::vector font; - if (!textFontFile_.empty()) { - std::ifstream in(textFontFile_, std::ios::binary); - font.assign(std::istreambuf_iterator(in), {}); - } else if (auto f = keel::fs::read(kFontFiles[textFont_])) - font = std::move(*f); - fry::TextStyle st; - st.height = textHeight_, st.depth = textDepth_, st.spacing = textSpacing_, st.align = textAlign_; - std::string err; - Mesh m = fry::makeText(font, textBuf_, st, &err); - if (m.faces.empty()) return say(err.empty() ? "No text to make" : err); - // (the letters' middle on the ground at the origin; standing up: facing the front) - const glm::vec3 lo = boundsMin(m), hi = boundsMax(m); - transform(m, glm::translate(glm::mat4(1.0f), glm::vec3(-(lo.x + hi.x) / 2, -(lo.y + hi.y) / 2, 0.0f))); - if (textStanding_) transform(m, glm::translate(glm::mat4(1.0f), glm::vec3(0, 0, (hi.y - lo.y) / 2)) * glm::rotate(glm::mat4(1.0f), kPi / 2, glm::vec3(1, 0, 0))); - pushUndo(); - if (mode_ == Mode::Edit) leaveEdit(); - std::string name = textBuf_; - if (const size_t nl = name.find('\n'); nl != std::string::npos) name.resize(nl); - if (name.size() > 24) name = name.substr(0, 24) + "..."; - Object& o = addObject("Text: " + name, std::move(m)); - o.position = glm::vec3(std::round(target_.x * 10) / 10, std::round(target_.y * 10) / 10, 0.0f); - // (reading left to right as the view looks: turned to the nearest quarter) - const glm::vec3 f = viewDir(); - if (std::abs(f.x) + std::abs(f.y) > 1e-4f) { - const float turn = std::round(std::atan2(-f.x, f.y) / (kPi / 2)) * (kPi / 2); - o.rotation = glm::angleAxis(turn, glm::vec3(0, 0, 1)); - } - selected_ = {o.id}, active_ = o.id; - showText_ = false; - say(std::format("Text added, {:.0f} mm tall letters, {} faces", textHeight_ * 1000, o.mesh.faces.size())); - } - - void thickenWindow() { - if (!showThicken_) return; - ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); - 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(); - } - void cleanFlatFaces() { - 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"); - } - - // ───────────────────────────── touch: phones and tablets ───────────────────────────── - // A phone: everything drawn bigger, the side panels only when asked for, a bar of big buttons - // at the bottom (undo, Shift and Ctrl that stay down, editing the shape...), and fingers: - // one drags the view round (or a tool's arrows), two move it and pinch to zoom, holding one - // still is a right-click. - - bool touch() const { return touchMode_; } - // How much bigger than its pixels the UI is drawn. - float touchScale() const { - if (fry_uiscale.f() > 0) return fry_uiscale.f(); - if (!touch()) return 1.0f; - const float density = SDL_GetDisplayContentScale(SDL_GetPrimaryDisplay()); - return std::clamp((density > 0 ? density : 2.0f) * 0.9f, 1.0f, 4.0f) * std::clamp(fry_uisize.f(), 0.5f, 2.0f); - } - void touchFrame(ImGuiIO& io) { - if (shiftLatch_) io.KeyShift = true; - if (ctrlLatch_) io.KeyCtrl = true; - ImGui::GetStyle().FontScaleMain = 1.0f; // (the sizes come from touchScale) - if (gest_.rightUp) { // (the long press's right-click: let go the frame after) - io.AddMouseButtonEvent(ImGuiMouseButton_Right, false); - gest_.rightUp = false; - } - // The fingers on the screen now (where, in the UI's units). - std::vector fingers; - int devices = 0; - if (SDL_TouchID* ids = SDL_GetTouchDevices(&devices)) { - for (int d = 0; d < devices; ++d) { - int n = 0; - if (SDL_Finger** f = SDL_GetTouchFingers(ids[d], &n)) { - for (int i = 0; i < n; ++i) fingers.push_back(ImVec2(f[i]->x * io.DisplaySize.x, f[i]->y * io.DisplaySize.y)); - SDL_free(f); - } - } - SDL_free(ids); - } - auto inView = [&](ImVec2 p) { return p.x >= viewMin_.x && p.y >= viewMin_.y && p.x < viewMax_.x && p.y < viewMax_.y; }; - if (fingers.size() >= 2) { - const ImVec2 mid((fingers[0].x + fingers[1].x) * 0.5f, (fingers[0].y + fingers[1].y) * 0.5f); - const float spread = std::max(std::hypot(fingers[0].x - fingers[1].x, fingers[0].y - fingers[1].y), 1.0f); - if (!gest_.two && !drag_.active && inView(mid) && !ImGui::IsAnyItemActive()) { // two fingers: the first's tap or drag is off - gest_.two = true, gest_.mid = mid, gest_.spread = spread; - boxing_ = lassoing_ = touchOrbit_ = tapDraw_ = false; - io.AddMouseButtonEvent(ImGuiMouseButton_Left, false); - } - if (gest_.two) { - panBy(ImVec2(mid.x - gest_.mid.x, mid.y - gest_.mid.y)); - dist_ = std::clamp(dist_ * gest_.spread / spread, 0.01f, 5000.0f); - gest_.mid = mid, gest_.spread = spread; - } - gest_.downAt = -1; - return; - } - if (fingers.empty()) gest_.two = false; - if (fingers.size() != 1 || gest_.two) return void(gest_.downAt = -1); - // One finger held still: a right-click there (menus in the panels, orbit in the view). - const ImVec2 p = fingers[0]; - if (gest_.downAt < 0) gest_.downAt = now(), gest_.downPos = p, gest_.longDone = false; - if (std::hypot(p.x - gest_.downPos.x, p.y - gest_.downPos.y) > 12) gest_.downAt = now() + 1e9; // (moved: not a long press) - if (!gest_.longDone && now() - gest_.downAt > 0.55 && !drag_.active) { - gest_.longDone = true; - boxing_ = lassoing_ = touchOrbit_ = tapDraw_ = false; - io.AddMouseButtonEvent(ImGuiMouseButton_Left, false); - io.AddMouseButtonEvent(ImGuiMouseButton_Right, true); - gest_.rightUp = true; - } - } - // The bar along the bottom of the view: big buttons, in rows if the view's narrow. - void touchBar() { - const float maxW = std::max(viewMax_.x - viewMin_.x - 16, 120.0f); - ImGui::SetNextWindowPos(ImVec2((viewMin_.x + viewMax_.x) * 0.5f, viewMax_.y - 6), ImGuiCond_Always, ImVec2(0.5f, 1.0f)); - ImGui::SetNextWindowBgAlpha(0.8f); - ImGui::Begin("##touchbar", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | - ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDocking); - if (now() - messageAt_ < 4.0) ImGui::PushTextWrapPos(maxW), ImGui::TextColored(accentText(), "%s", message_.c_str()), ImGui::PopTextWrapPos(); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(12, 8)); - const ImGuiStyle& st = ImGui::GetStyle(); - float x = 0; // (where the row's got to) - auto button = [&](const char* label, bool on = false, const char* tip = nullptr) { - const float w = ImGui::CalcTextSize(label).x + st.FramePadding.x * 2; - if (x > 0 && x + st.ItemSpacing.x + w <= maxW) ImGui::SameLine(), x += st.ItemSpacing.x; - else x = 0; - x += w; - if (on) pushAccent(); - const bool pressed = ImGui::Button(label); - if (on) popAccent(); - if (tip && ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip); - return pressed; - }; - if (button("Undo")) undo(); - if (button("Redo")) redo(); - if (button("Shift", shiftLatch_, "Held down until tapped again: adds to the selection, picks loops, scales both sides")) shiftLatch_ = !shiftLatch_; - if (button("Ctrl", ctrlLatch_, "Held down until tapped again: takes from the selection, snaps")) ctrlLatch_ = !ctrlLatch_; - if (tab_ == Tab::Model) { - if (button(mode_ == Mode::Edit ? "Done" : "Edit shape", mode_ == Mode::Edit)) mode_ == Mode::Edit ? leaveEdit() : enterEdit(); - if (button("Delete")) deleteSelected(); - } - if (button("Frame")) frameSelected(); - if (button("Views", viewsOpen_)) viewsOpen_ = !viewsOpen_; - if (button("Panels", panelsOpen_, "Layers, Properties (and the Picture when painting)")) panelsOpen_ = !panelsOpen_; - ImGui::PopStyleVar(); - ImGui::End(); - } - - // ───────────────────────────── the camera ───────────────────────────── - - glm::vec3 viewDir() const { return forwardOf(yaw_, pitch_); } - float viewHeight() const { return 2.0f * dist_ * std::tan(glm::radians(fov_) * 0.5f); } // metres across the view's height at the target - void setView(View v) { - const float size = viewHeight(); - view_ = v; - switch (v) { - case View::Front: yaw_ = 180, pitch_ = 0; break; // from +X, looking back at the front - case View::Back: yaw_ = 0, pitch_ = 0; break; - case View::Left: yaw_ = -90, pitch_ = 0; break; // from +Y (the model's left side) - case View::Right: yaw_ = 90, pitch_ = 0; break; - case View::Top: yaw_ = 0, pitch_ = 89.9f; break; // front up the screen - case View::Bottom: yaw_ = 0, pitch_ = -89.9f; break; - default: break; - } - // Side views: nearly flat (a narrow lens from far away), the same size on screen. - fov_ = v == View::Perspective ? 50.0f : 2.0f; - dist_ = size / (2.0f * std::tan(glm::radians(fov_) * 0.5f)); - } - void leaveFlatView() { - if (view_ == View::Perspective) return; - const float size = viewHeight(); - view_ = View::Perspective, fov_ = 50.0f; - dist_ = size / (2.0f * std::tan(glm::radians(fov_) * 0.5f)); - } - void frameBox(glm::vec3 lo, glm::vec3 hi) { - if (lo.x > hi.x) return; - target_ = (lo + hi) * 0.5f; - const float radius = std::max(glm::length(hi - lo) * 0.5f, 0.05f); - dist_ = radius * 1.4f / std::tan(glm::radians(fov_) * 0.5f); - } - void frameAll() { - glm::vec3 lo(1e30f), hi(-1e30f); - for (const Object& o : scene_.objects) { - if (!o.visible) continue; - const glm::mat4 m = objectMatrix(o); - for (const glm::vec3& v : o.mesh.verts) { - const glm::vec3 w(m * glm::vec4(v, 1.0f)); - lo = glm::min(lo, w), hi = glm::max(hi, w); - } - } - frameBox(lo, hi); - } - void frameSelected() { - glm::vec3 lo(1e30f), hi(-1e30f); - for (const glm::vec3& w : selectionPoints()) lo = glm::min(lo, w), hi = glm::max(hi, w); - if (lo.x > hi.x) return frameAll(); - frameBox(lo, hi); - } - - bool mouseInView(const ImGuiIO& io) const { - const ImVec2 m = io.MousePos; - return m.x >= viewMin_.x && m.y >= viewMin_.y && m.x < viewMax_.x && m.y < viewMax_.y && !io.WantCaptureMouse; - } - - void orbitBy(ImVec2 d) { - if (std::abs(d.x) + std::abs(d.y) > 0.5f) leaveFlatView(); - yaw_ -= d.x * 0.3f; - pitch_ = std::clamp(pitch_ + d.y * 0.3f, -89.9f, 89.9f); - } - void panBy(ImVec2 d) { - 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))); - const glm::vec3 up = glm::cross(right, f); - const float perPixel = viewHeight() / std::max(ImGui::GetIO().DisplaySize.y, 1.0f); - target_ += (-right * d.x + up * d.y) * perPixel; - } - - void updateCamera(ImGuiIO& io) { - const bool inView = mouseInView(io); - // Orbit: right drag. Pan: middle drag (or Shift + right). Zoom: the wheel. - const bool rightDown = ImGui::IsMouseDown(ImGuiMouseButton_Right), midDown = ImGui::IsMouseDown(ImGuiMouseButton_Middle); - if (inView && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) orbiting_ = !io.KeyShift; - if (inView && (ImGui::IsMouseClicked(ImGuiMouseButton_Middle) || (io.KeyShift && ImGui::IsMouseClicked(ImGuiMouseButton_Right)))) panning_ = true, orbiting_ = false; - if (!rightDown) orbiting_ = false; - if (!midDown && !rightDown) panning_ = false; - const ImVec2 d = io.MouseDelta; - if (orbiting_ && (d.x != 0 || d.y != 0)) orbitBy(d); - if (panning_ && (d.x != 0 || d.y != 0)) panBy(d); - if (inView && io.MouseWheel != 0 && drag_.active && softSel_ && mode_ == Mode::Edit) { // the soft selection's reach, not zoom - softReach_ = std::clamp(softReach_ * std::pow(1.15f, io.MouseWheel), 0.001f, 1000.0f); - findSoft(); - message_ = std::format("Soft selection reaches {:.3f} m (wheel)", softReach_), messageAt_ = now(); - } else if (inView && io.MouseWheel != 0) { - // Towards what's under the mouse: the target slides that way a little. - const float k = std::pow(0.85f, io.MouseWheel); - const Ray ray = mouseRay(io.MousePos); - if (auto p = hitPlane(ray, target_, -viewDir())) target_ += (*p - target_) * (1.0f - k); - dist_ = std::clamp(dist_ * k, 0.01f, 5000.0f); - } - settleView(); - } - // Where the eye is and how the view maps to the screen, from the camera as it is now: again - // just before drawing, so the model and the lines over it agree (a touch can turn it between). - void settleView() { - const ImGuiIO& io = ImGui::GetIO(); - const float aspect = io.DisplaySize.x / std::max(io.DisplaySize.y, 1.0f); - shift_ = glm::vec2((viewMin_.x + viewMax_.x) / std::max(io.DisplaySize.x, 1.0f) - 1.0f, (viewMin_.y + viewMax_.y) / std::max(io.DisplaySize.y, 1.0f) - 1.0f); - eye_ = target_ - viewDir() * dist_; - nearFar_ = nearFar(); - vp_ = viewProjection(eye_, yaw_, pitch_, fov_, aspect, shift_, nearFar_); - } - // How near and far the view draws (units), going by how far away it looks: close up to a - // 5 mm part, or out to see a whole town, the same share of the depth's precision. - glm::vec2 nearFar() { - float reach = 1.0f; // (how far from the middle the furthest object reaches, m) - for (const Object& o : scene_.objects) { - auto& [version, radius] = radii_[o.id]; - if (version != shapeKey(o) || !version) { - version = shapeKey(o), radius = 0; - for (const glm::vec3& v : o.mesh.verts) radius = std::max(radius, glm::length(v)); - } - reach = std::max(reach, glm::length(o.position) + radius * std::max({std::abs(o.scale.x), std::abs(o.scale.y), std::abs(o.scale.z)})); - } - const float nearM = std::clamp(dist_ * 0.002f, 1e-5f, 50.0f); - const float farM = std::max({dist_ * 60.0f, (glm::length(eye_) + reach) * 2.0f, 100.0f}); - return glm::vec2(nearM, farM) * kUnits; - } - - // World point → screen (false behind the eye). - // The window and its view are up (commands given at the start run before the first frame). - bool viewReady() const { return ImGui::GetCurrentContext() && viewMax_.x - viewMin_.x > 1 && viewMax_.y - viewMin_.y > 1; } - bool project(const glm::vec3& p, ImVec2& out) const { - const glm::vec4 c = vp_ * glm::vec4(p, 1.0f); - if (c.w < 1e-4f) return false; - const ImVec2 size = ImGui::GetIO().DisplaySize; - out = ImVec2((c.x / c.w * 0.5f + 0.5f) * size.x, (c.y / c.w * 0.5f + 0.5f) * size.y); - return true; - } - Ray mouseRay(ImVec2 m) const { - const ImVec2 size = ImGui::GetIO().DisplaySize; - const glm::vec2 ndc(m.x / size.x * 2.0f - 1.0f, m.y / size.y * 2.0f - 1.0f); - const glm::mat4 inv = glm::inverse(vp_); - glm::vec4 a = inv * glm::vec4(ndc, 0.0f, 1.0f), b = inv * glm::vec4(ndc, 1.0f, 1.0f); - a /= a.w, b /= b.w; - return {glm::vec3(a), glm::normalize(glm::vec3(b - a))}; - } - // How many metres a pixel is, at a point. - float pixelSize(const glm::vec3& at) const { - const float d = std::max(glm::dot(at - eye_, viewDir()), 0.001f); - return 2.0f * d * std::tan(glm::radians(fov_) * 0.5f) / std::max(ImGui::GetIO().DisplaySize.y, 1.0f); - } - - // ───────────────────────────── what's shown ───────────────────────────── - - // The mesh as seen: with its rounding, smoothing and mirror — but while its shape is being - // edited, only the mirror (the rest would hide the points you're moving). - // Editable cuts show (but not while its own shape's being edited: the points you move). - // A big one (smoothed into many faces, cut) is made on another thread: what was shown stays - // till it's ready, so the window doesn't stop. - const Mesh& shownMesh(const Object& o) { - const bool editingThis = mode_ == Mode::Edit && o.id == active_; - const bool cut = !editingThis && cutKeys_.count(o.id); - if ((!o.hasFinish() && !cut) || (editingThis && !o.mirror)) return o.mesh; - Shown& s = shown_[o.id]; - const uint64_t v = shapeKey(o); - if (s.coming.valid() && s.coming.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { - Mesh got = s.coming.get(); - if (s.comingVersion == v && s.comingMirror == editingThis) s.mesh = std::move(got), s.version = v, s.mirrorOnly = editingThis; - } - if (s.version == v && v && s.mirrorOnly == editingThis) return s.mesh; - // (what it'll come to: its faces four times over for each level of smoothing) - const size_t faces = o.mesh.faces.size() * (size_t(1) << (2 * std::clamp(editingThis ? 0 : o.smoothLevels, 0, 6))) * (o.mirror ? 2 : 1); - const bool big = faces > 40000 || (cut && o.mesh.faces.size() > 20000); - if (!big || s.mesh.verts.empty()) { // (small: now; the first time: now too, there's nothing to show yet) - s.mesh = cut ? fry::cutMesh(scene_, o) : finishedMesh(o, editingThis), s.version = v, s.mirrorOnly = editingThis; - return s.mesh; - } - if (!s.coming.valid() || s.comingVersion != v || s.comingMirror != editingThis) { - if (s.coming.valid()) s.coming.wait(); // (one at a time for each object: the old one's thrown away) - Scene part; // (just it and what cuts it: the thread has its own copies) - part.objects.push_back(o); - if (cut) - for (const Object& c : scene_.objects) - if (c.cuts == o.id && c.id != o.id) part.objects.push_back(c); - s.comingVersion = v, s.comingMirror = editingThis; - s.coming = std::async(std::launch::async, [part = std::move(part), cut, editingThis] { - return cut ? fry::cutMesh(part, part.objects[0]) : finishedMesh(part.objects[0], editingThis); - }); - message_ = "Working it out...", messageAt_ = now(); - } - return s.mesh; - } - // The key of the mesh actually shown (the one before, while a new one's being made). - uint64_t meshKey(const Object& o, const Mesh& mesh) { - if (&mesh == &o.mesh) return shapeKey(o); - auto it = shown_.find(o.id); - return it != shown_.end() && &it->second.mesh == &mesh ? it->second.version * 2 + it->second.mirrorOnly : shapeKey(o); - } - // Each object something's cut out of, and what its cutters are (their shapes, where they are): - // a change, and it's made again (the caches follow its version). - std::map cutKeys_; - std::map followAt_; // (where each cut object and cutter was last frame) - void followCuts() { - // What's cut moved, turned or sized (not by a drag: that takes them itself), and its cutter - // didn't: the cutter goes the same way, so the hole stays where it was on it. - std::map at; - for (Object& c : scene_.objects) { - const Object* t = fry::cutTarget(scene_, c); - if (!t) continue; - const glm::mat4 mt = objectMatrix(*t); - const auto was = followAt_.find(t->id), cWas = followAt_.find(c.id); - if (!drag_.active && was != followAt_.end() && cWas != followAt_.end() && was->second != mt && cWas->second == objectMatrix(c) && !c.locked) { - const glm::mat4 m = mt * glm::inverse(was->second) * objectMatrix(c); - glm::vec3 skew; - glm::vec4 persp; - glm::vec3 scale, pos; - glm::quat rot; - if (glm::decompose(m, scale, rot, pos, skew, persp)) c.position = pos, c.rotation = glm::normalize(rot), c.scale = scale, touched(c.id); - } - at[t->id] = mt, at[c.id] = objectMatrix(c); - } - followAt_ = std::move(at); - std::map now; - for (const Object& c : scene_.objects) { - const Object* t = fry::cutTarget(scene_, c); - if (!t || c.mesh.faces.empty()) continue; - uint64_t& key = now.emplace(t->id, 1469598103934665603ull).first->second; - key = (key ^ versions_[c.id] ^ (uint64_t(c.id) << 40)) * 1099511628211ull; - const glm::mat4 m = objectMatrix(c); - for (int i = 0; i < 16; ++i) { - uint32_t b; - std::memcpy(&b, &m[i / 4][i % 4], 4); - key = (key ^ b) * 1099511628211ull; - } - } - for (const auto& [id, key] : now) - if (auto it = cutKeys_.find(id); it == cutKeys_.end() || it->second != key) versions_[id] = ++versionClock_; - for (const auto& [id, key] : cutKeys_) - if (!now.count(id)) versions_[id] = ++versionClock_; - cutKeys_ = std::move(now); - } - bool isCutter(const Object& o) const { return fry::cutTarget(scene_, o) != nullptr; } - // Editable cut: the last picked has the others cut out of it, and they stay objects to move. - void cutEditable() { - if (mode_ == Mode::Edit) leaveEdit(); - Object* a = find(active_); - if (!a || selected_.size() < 2) return say("Pick the cutters, then (Shift) what they cut last: they stay, to move and change, and the hole follows"); - pushUndo(); - int n = 0; - for (Object& o : scene_.objects) - if (selected_.count(o.id) && o.id != a->id) o.cuts = a->id, ++n; - for (Object& o : scene_.objects) // (one that was cutting the cutters: not a loop of cuts) - if (o.id == a->id && o.cuts && selected_.count(o.cuts)) o.cuts = 0; - selected_.erase(a->id), active_ = *selected_.rbegin(); - dirty_ = true; - say(std::format("{} cut out of {} (editable: move or change {}; Apply makes it for good)", n == 1 ? "1 object" : std::format("{} objects", n), find(a->id)->name, n == 1 ? "it" : "them")); - } - // The cut made for good: one cutter (or all of what's cut out of it) into the shape, gone. - void applyCuts(uint32_t target, uint32_t only = 0) { - Object* t = find(target); - if (!t) return; - pushUndo(); - Scene part = scene_; // (just the cutters wanted) - for (Object& o : part.objects) - if (o.cuts == target && only && o.id != only) o.cuts = 0; - Object* pt = &*std::find_if(part.objects.begin(), part.objects.end(), [&](const Object& o) { return o.id == target; }); - t->mesh = fry::cutMesh(part, *pt); - t->mirror = 0, t->round = 0, t->smoothLevels = 0, t->creases.clear(); - std::set gone; - for (const Object& o : scene_.objects) - if (o.cuts == target && (!only || o.id == only)) gone.insert(o.id); - std::erase_if(scene_.objects, [&](const Object& o) { return gone.count(o.id) > 0; }); - for (uint32_t id : gone) selected_.erase(id); - selected_.insert(target), active_ = target; - changedShape(*find(target)); - say(std::format("Cut made for good in {} ({} faces)", find(target)->name, find(target)->mesh.faces.size())); - } - - // ───────────────────────────── picking ───────────────────────────── - - struct Hit { - uint32_t object = 0; - int face = -1; - float t = 1e30f; +// SDL's own file dialogs (the desktop's): the answer comes back later, maybe on another thread. +auto App::dialog(int kind) -> void { + static const SDL_DialogFileFilter fry[] = {{"Fry3DX files", "fry"}}, pics[] = {{"Pictures", "png;jpg;jpeg;bmp;tga"}}, png[] = {{"PNG picture", "png"}}; + static std::vector names; + static std::vector models; + if (models.empty()) { + for (const auto& [ext, what] : exportFormats()) names.push_back(what + " (" + ext + ")"), names.push_back(ext.substr(1)); + for (size_t i = 0; i < names.size(); i += 2) models.push_back({names[i].c_str(), names[i + 1].c_str()}); + } + pendingKind_ = kind; + auto done = [](void* user, const char* const* files, int filter) { + App* self = static_cast(user); + std::lock_guard lock(self->dialogMutex_); + if (files && files[0]) self->dialogResult_ = files[0], self->dialogKind_ = self->pendingKind_, self->dialogFilter_ = filter; + else self->dialogCancelled_ = true; }; - // base: against its own mesh (face numbers of that), not the rounded or smoothed one shown. - Hit pickObject(const Ray& ray, uint32_t only = 0, bool base = false) { - Hit best; - for (const Object& o : scene_.objects) { - if (!o.visible || (only && o.id != only)) continue; - const Mesh& mesh = base || (mode_ == Mode::Edit && o.id == active_) ? o.mesh : shownMesh(o); - if (auto h = treeOf(o, mesh).nearest(ray.origin, ray.dir, best.t)) best = {o.id, h->face, h->t}; - } - return best; + SDL_Window* w = SDL_GetKeyboardFocus(); + const std::string here = path_.empty() || isUri(path_) ? std::string() : stdfs::path(path_).parent_path().string(); + const char* at = here.empty() ? nullptr : here.c_str(); + if (kind == 1) SDL_ShowOpenFileDialog(done, this, w, fry, 1, at, false); + else if (kind == 2) SDL_ShowSaveFileDialog(done, this, w, fry, 1, path_.empty() || isUri(path_) ? at : path_.c_str()); + else if (kind == 3) SDL_ShowSaveFileDialog(done, this, w, models.data(), int(models.size()), at); + else if (kind == 8 || kind == 9) { + static const SDL_DialogFileFilter mf[] = {{"3MF for slicers", "3mf"}}, stl[] = {{"STL", "stl"}}; + SDL_ShowSaveFileDialog(done, this, w, kind == 8 ? mf : stl, 1, at); } - // Each object's triangles in the world, in a tree of boxes: made again only when it changes - // (its shape, where it is), not each time something's picked. - struct Tree { - uint64_t version = 0; - glm::mat4 matrix{0.0f}; - size_t faces = 0; - fry::TriangleTree tree; - }; - std::map, Tree> trees_; // (an object's own mesh, or the one shown) - const fry::TriangleTree& treeOf(const Object& o, const Mesh& mesh) { - Tree& t = trees_[{o.id, &mesh != &o.mesh}]; - const glm::mat4 m = objectMatrix(o); - const uint64_t version = meshKey(o, mesh); - if (t.version != version || !version || t.matrix != m || t.faces != mesh.faces.size() || t.tree.points().size() != mesh.verts.size()) - t.version = version, t.matrix = m, t.faces = mesh.faces.size(), t.tree = fry::TriangleTree(mesh, m); - return t.tree; + else if (kind == 5 || kind == 7) SDL_ShowSaveFileDialog(done, this, w, png, 1, at); + else if (kind == 10 || kind == 11) { + static const SDL_DialogFileFilter lua[] = {{"Lua scripts", "lua"}}; + if (kind == 10) SDL_ShowOpenFileDialog(done, this, w, lua, 1, at, false); + else SDL_ShowSaveFileDialog(done, this, w, lua, 1, scriptPath_.empty() || isUri(scriptPath_) ? at : scriptPath_.c_str()); // (at: the last folder) } - // Can the eye see this point of the object (nothing of it in the way)? - bool visible(const Object& o, const glm::vec3& world) { - if (xray_) return true; - const glm::vec3 d = world - eye_; - const float len = glm::length(d); - const Hit h = pickObject({eye_, d / len}, o.id); - return h.t > len - std::max(len * 0.002f, 0.0005f); + else if (kind == 12) { + static const SDL_DialogFileFilter in[] = {{"3D models (STL, OBJ, 3MF, glTF)", "stl;obj;3mf;glb;gltf"}}; + SDL_ShowOpenFileDialog(done, this, w, in, 1, at, true); } - // Whether the eye can see an object's point (nothing of it in between): each tried when it's - // first asked about, kept till the object or the view changes. (Only what's drawn is asked: - // the outline's points, not every point of a big mesh, as the view turns.) - struct Visibility { - uint64_t version = 0; - glm::vec3 eye{1e30f}; - glm::mat4 vp{0.0f}; - std::vector point; // 0 not tried yet, 1 seen, 2 hidden - }; - std::map, Visibility> visibility_; // (an object's own mesh, or the one shown) - bool seen(const Object& o, const Mesh& mesh, int i) { - Visibility& v = visibility_[{o.id, &mesh != &o.mesh}]; - const uint64_t version = versions_[o.id]; - if (v.version != version || glm::distance(v.eye, eye_) >= 1e-6f || v.vp != vp_ || v.point.size() != mesh.verts.size()) - v.version = version, v.eye = eye_, v.vp = vp_, v.point.assign(mesh.verts.size(), 0); - char& got = v.point[size_t(i)]; - if (got) return got == 1; - const fry::TriangleTree& tree = treeOf(o, mesh); - const glm::vec3 w = tree.points()[size_t(i)]; - const glm::vec4 c = vp_ * glm::vec4(w, 1.0f); - const glm::vec3 d = w - eye_; - const float len = glm::length(d); - if (c.w < 1e-5f || std::abs(c.x) > c.w || std::abs(c.y) > c.w || len < 1e-6f) return (got = 1); // (off the screen: not drawn anyway) - got = tree.blocked(eye_, d / len, len - std::max(len * 0.002f, 0.0005f), i) ? 2 : 1; - return got == 1; + else if (kind == 13) { + static const SDL_DialogFileFilter fonts[] = {{"Fonts", "ttf;otf"}}; + SDL_ShowOpenFileDialog(done, this, w, fonts, 1, at, false); } - // Each edge of a mesh and the (first two) faces it's on, with the faces' normals: worked out - // again only when the mesh changes. - struct Adjacency { - uint64_t version = 0; - size_t verts = 0, faces = 0; - std::vector> edges; // a, b, a face, the other (or -1) - std::vector plane; // each face's normal, and its distance along it - }; - std::map, Adjacency> adjacency_; - const Adjacency& adjacencyOf(const Object& o, const Mesh& mesh) { - Adjacency& a = adjacency_[{o.id, &mesh != &o.mesh}]; - const uint64_t version = meshKey(o, mesh); - if (a.version == version && version && a.verts == mesh.verts.size() && a.faces == mesh.faces.size()) return a; - a.version = version, a.verts = mesh.verts.size(), a.faces = mesh.faces.size(); - a.edges.clear(), a.plane.resize(mesh.faces.size()); - std::unordered_map at; - at.reserve(mesh.faces.size() * 2); - for (size_t f = 0; f < mesh.faces.size(); ++f) { - const auto& fv = mesh.faces[f]; - const glm::vec3 n = faceNormal(mesh, int(f)); - a.plane[f] = glm::vec4(n, fv.empty() ? 0.0f : glm::dot(n, mesh.verts[size_t(fv[0])])); - for (size_t i = 0; i < fv.size(); ++i) { - const int p = std::min(fv[i], fv[(i + 1) % fv.size()]), q = std::max(fv[i], fv[(i + 1) % fv.size()]); - auto [it, fresh] = at.try_emplace(uint64_t(uint32_t(p)) << 32 | uint32_t(q), int(a.edges.size())); - if (fresh) a.edges.push_back({p, q, int(f), -1}); - else if (a.edges[size_t(it->second)][3] < 0) a.edges[size_t(it->second)][3] = int(f); - } - } - return a; - } - bool pointSeen(const Object& o, int v) { return xray_ || seen(o, o.mesh, v); } + else SDL_ShowOpenFileDialog(done, this, w, pics, 1, at, false); +} - // The edge of the edited object nearest the mouse on screen (that can be seen), or (-1, -1). - std::pair nearestEdge(Object& o, ImVec2 mouse, float within) { - const glm::mat4 m = objectMatrix(o); - std::pair best{-1, -1}; - for (const auto& e : adjacencyOf(o, o.mesh).edges) { - const int a = e[0], b = e[1]; - ImVec2 sa, sb; - if (!project(glm::vec3(m * glm::vec4(o.mesh.verts[size_t(a)], 1.0f)), sa) || !project(glm::vec3(m * glm::vec4(o.mesh.verts[size_t(b)], 1.0f)), sb)) continue; - const float d = distToSegment(mouse, sa, sb); - if (d < within && (pointSeen(o, a) || pointSeen(o, b))) within = d, best = {a, b}; - } - return best; +auto App::takeDialogResult() -> void { + std::string file; + int kind, filter; + { + std::lock_guard lock(dialogMutex_); + file.swap(dialogResult_), kind = dialogKind_, filter = dialogFilter_; + if (std::exchange(dialogCancelled_, false)) afterSave_ = 0; // (Save, then quit: but the saving was cancelled) } + if (file.empty()) return; + if (isUri(file) && (kind == 4 || kind == 6)) file = fromUri(file, std::format("picked-{}.img", SDL_GetTicks())); // (pictures: read from a copy) + if (isUri(file) && (kind == 5 || kind == 7)) { // (pictures saved: made here, then written into the picked file) + const std::string uri = file; + file = tempFile("picture.png"); + if (kind == 5) return savePicture(file), void(captureUri_ = uri); // (written into it once it's made) + if (Object* o = find(active_); o && o->picture) writePng(file, composePicture(*o)); + say(toUri(file, uri) ? "Saved " + fileName(uri) : "Couldn't write the file you picked"); + return; + } + if (isUri(file) && (kind == 8 || kind == 9)) return exportFile(file, -1, kind == 8 ? ".3mf" : ".stl"); + if (kind == 12) return importFile(isUri(file) ? fromUri(file, "imported-" + fileName(file)) : file); + if (kind == 13) { + textFontFile_ = isUri(file) ? fromUri(file, "font-" + fileName(file)) : file; + return; + } + if (kind == 10) return openScript(isUri(file) ? fromUri(file, "opened.lua") : file, fileName(file)); + if (kind == 11) return saveScript(file); + if (kind == 1) openFile(file); + else if (kind == 2) saveFile(file); + else if (kind == 3) exportFile(file, filter); + else if (kind == 8 || kind == 9) { + if (stdfs::path(file).extension() != (kind == 8 ? ".3mf" : ".stl")) file += kind == 8 ? ".3mf" : ".stl"; + exportFile(file); + } + else if (kind == 4) addPicture(file); + else if (kind == 5) savePicture(file); + else if (kind == 6) floatFromFile(file); + else if (kind == 7) { + if (stdfs::path(file).extension() != ".png") file += ".png"; + if (Object* o = find(active_); o && o->picture) say(writePng(file, composePicture(*o)) ? "Saved " + stdfs::path(file).filename().string() : "Couldn't write " + file); + } +} - void clickSelect(const ImGuiIO& io) { - const Ray ray = mouseRay(io.MousePos); - bool add = io.KeyShift, remove = io.KeyCtrl; - if (Object* o = editing()) { - // Shapes: Shift picks the whole loop (Shift+Ctrl on an edge: the ring across it), Ctrl - // adds or takes away one. - if (io.KeyShift) return loopSelect(io, io.KeyCtrl); - add = io.KeyCtrl, remove = false; - const glm::mat4 m = objectMatrix(*o); - auto toggle = [&](auto& set, auto key) { - if (remove) set.erase(key); - else if (add && set.count(key)) set.erase(key); - else set.insert(key); - }; - if (!add && !remove) clearElements(); - if (elem_ == Elem::Vertex) { - int best = -1; - float bestD = 14.0f; - for (size_t v = 0; v < o->mesh.verts.size(); ++v) { - ImVec2 s; - if (!project(glm::vec3(m * glm::vec4(o->mesh.verts[v], 1.0f)), s)) continue; - const float d = std::hypot(s.x - io.MousePos.x, s.y - io.MousePos.y); - if (d < bestD && pointSeen(*o, int(v))) bestD = d, best = int(v); - } - if (best >= 0) toggle(selVerts_, best); - } else if (elem_ == Elem::Edge) { - const auto e = nearestEdge(*o, io.MousePos, 10.0f); - if (e.first >= 0) toggle(selEdges_, e); - } else { - const Hit h = pickObject(ray, o->id); - if (h.face >= 0) toggle(selFaces_, h.face); - } - return; - } - const Hit h = pickObject(ray); - if (!add && !remove) selected_.clear(); - if (h.object) { - if (remove || (add && selected_.count(h.object))) selected_.erase(h.object); - else selected_.insert(h.object), active_ = h.object; - activeRef_ = 0; - } - } +// ───────────────────────────── modes and tools ───────────────────────────── - // A whole loop: of edges (points), or a ring of faces, through the edge under the mouse. - void loopSelect(const ImGuiIO& io, bool ring = false) { - Object* o = editing(); - if (!o) return; - if (!io.KeyShift) clearElements(); - if (elem_ == Elem::Face) { - const Hit h = pickObject(mouseRay(io.MousePos), o->id); - if (h.face < 0) return; - // The face's edge nearest the mouse: the ring runs across it. - const auto& fv = o->mesh.faces[size_t(h.face)]; - const glm::mat4 m = objectMatrix(*o); - std::pair best{-1, -1}; - float bestD = 1e30f; - for (size_t i = 0; i < fv.size(); ++i) { - ImVec2 sa, sb; - if (project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(fv[i])], 1.0f)), sa) && - project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(fv[(i + 1) % fv.size()])], 1.0f)), sb)) - if (const float d = distToSegment(io.MousePos, sa, sb); d < bestD) bestD = d, best = {fv[i], fv[(i + 1) % fv.size()]}; - } - if (best.first < 0) return; - const auto ring = faceRing(o->mesh, best); - selFaces_.insert(ring.begin(), ring.end()); - if (ring.empty()) selFaces_.insert(h.face); - return; - } - const auto e = nearestEdge(*o, io.MousePos, 14.0f); - if (e.first < 0) return; - const auto loop = ring ? edgeRing(o->mesh, e) : edgeLoop(o->mesh, e); - if (ring) say(std::format("A ring of {} edges", loop.size())); - if (elem_ == Elem::Edge) selEdges_.insert(loop.begin(), loop.end()); - else - for (auto [a, b] : loop) selVerts_.insert(a), selVerts_.insert(b); - } - - // Selects what's on screen inside a shape (a box, a lasso, the brush). - void regionSelect(const std::function& inside, bool add, bool remove, bool clearFirst) { - auto in = [&](const glm::vec3& w) { - ImVec2 s; - return project(w, s) && inside(s); - }; - if (Object* o = editing()) { - if (clearFirst) clearElements(); - const glm::mat4 m = objectMatrix(*o); - std::vector hit(o->mesh.verts.size()); - for (size_t v = 0; v < hit.size(); ++v) hit[v] = in(glm::vec3(m * glm::vec4(o->mesh.verts[v], 1.0f))) && pointSeen(*o, int(v)); - auto apply = [&](auto& set, auto key, bool yes) { - if (!yes) return; - if (remove) set.erase(key); - else set.insert(key); - }; - if (elem_ == Elem::Vertex) - for (size_t v = 0; v < hit.size(); ++v) apply(selVerts_, int(v), bool(hit[v])); - else if (elem_ == Elem::Edge) - for (auto e : edges(o->mesh)) { - const glm::vec3 mid(m * glm::vec4((o->mesh.verts[size_t(e.first)] + o->mesh.verts[size_t(e.second)]) * 0.5f, 1.0f)); - apply(selEdges_, e, (hit[size_t(e.first)] && hit[size_t(e.second)]) || (in(mid) && pointSeen(*o, e.first) && pointSeen(*o, e.second))); - } - else - for (size_t f = 0; f < o->mesh.faces.size(); ++f) { - const glm::vec3 c(m * glm::vec4(faceCenter(o->mesh, int(f)), 1.0f)); - if (!in(c)) continue; - const glm::vec3 n = glm::transpose(glm::inverse(glm::mat3(m))) * faceNormal(o->mesh, int(f)); - apply(selFaces_, int(f), xray_ || (glm::dot(n, eye_ - c) > 0 && visible(*o, c))); - } - return; - } - if (clearFirst) selected_.clear(); - (void)add; - for (const Object& o : scene_.objects) { - if (!o.visible) continue; - const glm::vec3 c(objectMatrix(o) * glm::vec4((boundsMin(o.mesh) + boundsMax(o.mesh)) * 0.5f, 1.0f)); - if (!in(c)) continue; - if (remove) selected_.erase(o.id); - else selected_.insert(o.id), active_ = o.id; - } - } - - // ───────────────────────────── the gizmo ───────────────────────────── - - bool hasSelection() { - if (mode_ == Mode::Edit) return !selectedPoints().empty(); - return std::any_of(selected_.begin(), selected_.end(), [&](uint32_t id) { const Object* o = find(id); return o && !o->locked; }); - } - // Every point of the selection, in the world. - std::vector selectionPoints() { - std::vector out; - if (Object* o = editing()) { - const glm::mat4 m = objectMatrix(*o); - for (int v : selectedPoints()) out.push_back(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(v)], 1.0f))); - return out; - } - for (const Object& o : scene_.objects) { - if (!selected_.count(o.id) || o.locked) continue; - const glm::mat4 m = objectMatrix(o); - for (const glm::vec3& v : shownMesh(o).verts) out.push_back(glm::vec3(m * glm::vec4(v, 1.0f))); - } - return out; - } - glm::vec3 pivotOfSelection() { - glm::vec3 c(0.0f); - int n = 0; - if (Object* o = editing()) { - const glm::mat4 m = objectMatrix(*o); - for (int v : selectedPoints()) c += glm::vec3(m * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)), ++n; - } else { - for (const Object& o : scene_.objects) - if (selected_.count(o.id)) c += o.position, ++n; - } - return n ? c / float(n) : glm::vec3(0.0f); - } - // The selection's box, in a gizmo's axes. - std::pair selectionBox(const Axes& a) { - glm::vec3 lo(1e30f), hi(-1e30f); - for (const glm::vec3& p : selectionPoints()) { - const glm::vec3 c = inFrame(a, p); - lo = glm::min(lo, c), hi = glm::max(hi, c); - } - if (lo.x > hi.x) lo = hi = inFrame(a, pivotOfSelection()); - return {lo, hi}; - } - glm::vec3 selectionMiddle(const Axes& a) { - const auto [lo, hi] = selectionBox(a); - return fromFrame(a, (lo + hi) * 0.5f); - } - Orient& orient() { return mode_ == Mode::Edit ? orientEdit_ : orientObject_; } - // The gizmo's axes: the object's own (turning with it), the world's, or (shape editing) the - // way the selected faces face — so "up" on a side face is out of that face. - Axes gizmoAxes() { - Orient o = orient(); - if (mode_ == Mode::Object && tool_ == Tool::Scale) o = Orient::Local; // (sizes are along an object's own axes) - const Object* a = find(active_); - if (!a && !selected_.empty()) a = find(*selected_.begin()); - const glm::quat q = a && o != Orient::World ? a->rotation : glm::quat(1, 0, 0, 0); - Axes local{q * glm::vec3(1, 0, 0), q * glm::vec3(0, 1, 0), q * glm::vec3(0, 0, 1)}; - if (o != Orient::Normal || mode_ != Mode::Edit) return local; - Object* e = editing(); - if (!e) return local; - const glm::mat4 m = objectMatrix(*e); - const glm::mat3 nm = glm::transpose(glm::inverse(glm::mat3(m))); - const std::set pts = selectedPoints(); - glm::vec3 n(0.0f), along(0.0f); - for (size_t f = 0; f < e->mesh.faces.size(); ++f) { - const auto& fv = e->mesh.faces[f]; - const bool whole = elem_ == Elem::Face ? selFaces_.count(int(f)) > 0 : std::any_of(fv.begin(), fv.end(), [&](int v) { return pts.count(v); }); - if (!whole) continue; - n += nm * faceNormal(e->mesh, int(f)) * std::max(faceArea(e->mesh, int(f)), 1e-6f); - if (glm::length(along) < 1e-9f) // the first face's longest edge: "across" it - for (size_t i = 0; i < fv.size(); ++i) { - const glm::vec3 d = glm::mat3(m) * (e->mesh.verts[size_t(fv[(i + 1) % fv.size()])] - e->mesh.verts[size_t(fv[i])]); - if (glm::length(d) > glm::length(along)) along = d; - } - } - if (elem_ == Elem::Edge && !selEdges_.empty()) { - const auto [a0, b0] = *selEdges_.begin(); - along = glm::mat3(m) * (e->mesh.verts[size_t(b0)] - e->mesh.verts[size_t(a0)]); - } - if (glm::length(n) < 1e-9f) return local; - const glm::vec3 z = glm::normalize(n); - glm::vec3 x = along - z * glm::dot(along, z); - if (glm::length(x) < 1e-6f) x = glm::cross(std::abs(z.z) < 0.9f ? glm::vec3(0, 0, 1) : glm::vec3(1, 0, 0), z); - x = glm::normalize(x); - return {x, glm::cross(z, x), z}; - } - // The extrude / inset handle: the selected faces' middle, their normal. - bool faceHandle(glm::vec3& at, glm::vec3& normal) { - Object* o = editing(); - if (!o || elem_ != Elem::Face || selFaces_.empty()) return false; - const glm::mat4 m = objectMatrix(*o); - glm::vec3 c(0.0f), n(0.0f); - for (int f : selFaces_) { - if (f >= int(o->mesh.faces.size())) return false; - c += glm::vec3(m * glm::vec4(faceCenter(o->mesh, f), 1.0f)); - n += glm::normalize(glm::transpose(glm::inverse(glm::mat3(m))) * faceNormal(o->mesh, f)); - } - at = c / float(selFaces_.size()); - normal = glm::length(n) > 1e-6f ? glm::normalize(n) : glm::vec3(0, 0, 1); - return true; - } - // The round-edges handle: off to the right of the selection's middle. - bool bevelHandle(ImVec2& centre, ImVec2& handle) { - if (!editing() || edgesToRound().empty()) return false; - if (!project(drag_.active ? drag_.pivot : pivotOfSelection(), centre)) return false; - handle = ImVec2(centre.x + 80.0f + (drag_.active ? ImGui::GetIO().MousePos.x - drag_.startMouse.x : 0.0f), centre.y); - return true; - } - // The scale box's handles: 0-2 the far side along each axis, 4-6 the near side, 3 the middle. - // (Pushed out a little where the box is flat, so they don't sit on top of each other.) - glm::vec3 scaleHandle(const Axes& a, glm::vec3 lo, glm::vec3 hi, int h) { - const glm::vec3 c = (lo + hi) * 0.5f; - if (h == 3) return fromFrame(a, c); - const int k = h % 4 == 3 ? 0 : (h < 3 ? h : h - 4); - glm::vec3 q = c; - const float minHalf = pixelSize(fromFrame(a, c)) * 22.0f; - const float half = std::max((hi[k] - lo[k]) * 0.5f, minHalf); - q[k] = c[k] + (h < 3 ? half : -half); - return fromFrame(a, q); - } - - // Which handle is under the mouse (-1 none). - int hoverHandle(ImVec2 mouse) { - if (drag_.active) return drag_.handle; - if (tool_ == Tool::Extrude || tool_ == Tool::Inset) { - glm::vec3 at, n; - if (!faceHandle(at, n)) return -1; - ImVec2 a, b; - if (!project(at, a) || !project(at + n * pixelSize(at) * 90.0f, b)) return -1; - return distToSegment(mouse, a, b) < 10.0f ? 0 : -1; - } - if (tool_ == Tool::Bevel) { - ImVec2 c, h; - return bevelHandle(c, h) && std::hypot(mouse.x - h.x, mouse.y - h.y) < 12.0f ? 0 : -1; - } - if (tool_ != Tool::Move && tool_ != Tool::Rotate && tool_ != Tool::Scale) return -1; - if (!hasSelection()) return -1; - const Axes axes = gizmoAxes(); - if (tool_ == Tool::Scale) { - const auto [lo, hi] = selectionBox(axes); - for (int h : {3, 0, 1, 2, 4, 5, 6}) { - ImVec2 s; - if (project(scaleHandle(axes, lo, hi, h), s) && std::abs(mouse.x - s.x) < 9 && std::abs(mouse.y - s.y) < 9) return h; - } - return -1; - } - const glm::vec3 p = tool_ == Tool::Rotate ? rotatePivot(axes) : selectionMiddle(axes); - ImVec2 c; - if (!project(p, c)) return -1; - const float len = pixelSize(p) * 90.0f; - if (tool_ == Tool::Rotate) { - int best = -1; - float bestD = 9.0f; - for (int k = 0; k < 3; ++k) { - const glm::vec3 u = glm::normalize(glm::cross(axes[size_t(k)], std::abs(axes[size_t(k)].z) < 0.9f ? glm::vec3(0, 0, 1) : glm::vec3(1, 0, 0))); - const glm::vec3 v = glm::cross(axes[size_t(k)], u); - ImVec2 prev; - bool havePrev = false; - for (int i = 0; i <= 48; ++i) { - const float a = 2 * kPi * float(i) / 48.0f; - ImVec2 s; - const glm::vec3 w = p + (u * std::cos(a) + v * std::sin(a)) * len; - if (!project(w, s)) { - havePrev = false; - continue; - } - if (havePrev && glm::dot(w - p, eye_ - p) > -0.2f * len * glm::length(eye_ - p)) // (the front half only) - if (const float d = distToSegment(mouse, prev, s); d < bestD) bestD = d, best = k; - prev = s, havePrev = true; - } - } - const float r = std::hypot(mouse.x - c.x, mouse.y - c.y); - if (best < 0 && r > 95.0f && r < 110.0f) best = 3; // the outer ring: about the view - return best; - } - if (std::abs(mouse.x - c.x) < 9 && std::abs(mouse.y - c.y) < 9) return 3; - for (int k = 0; k < 3; ++k) { - ImVec2 e; - if (project(p + axes[size_t(k)] * len, e) && distToSegment(mouse, c, e) < 9.0f) return k; - } - return -1; - } - - glm::vec3 originPoint() { - if (Object* o = editing()) return o->position; - glm::vec3 c(0.0f); - int n = 0; - for (const Object& o : scene_.objects) - if (selected_.count(o.id)) c += o.position, ++n; - return n ? c / float(n) : c; - } - glm::vec3 rotatePivot(const Axes& a) { return rotatePivot_ == Pivot::Origin ? originPoint() : selectionMiddle(a); } - - void startDrag(const ImGuiIO& io, int handle) { - drag_ = {}; - drag_.active = true, drag_.handle = handle, drag_.tool = tool_; - drag_.startMouse = io.MousePos; - drag_.before = scene_; - pushUndo(); - if (softSel_ && (tool_ == Tool::Move || tool_ == Tool::Rotate || tool_ == Tool::Scale)) findSoft(); - const Ray ray = mouseRay(io.MousePos); - if (tool_ == Tool::Extrude || tool_ == Tool::Inset) { - glm::vec3 at, n; - faceHandle(at, n); - drag_.pivot = at, drag_.startT = closestOnAxis(ray, at, n); - drag_.startHit = n; - return; - } - if (tool_ == Tool::Bevel) { - drag_.pivot = pivotOfSelection(); - drag_.bevelEdges = edgesToRound(); - return; - } - drag_.axes = gizmoAxes(); - const Axes& a = drag_.axes; - if (tool_ == Tool::Scale) { - const auto [lo, hi] = selectionBox(a); - const glm::vec3 c = (lo + hi) * 0.5f; - drag_.handlePoint = scaleHandle(a, lo, hi, handle); - const int k = handle < 3 ? handle : handle - 4; - glm::vec3 q = c; - switch (scalePivot_) { - case Pivot::Opposite: - if (handle == 3) q[2] = lo[2]; - else q[k] = handle < 3 ? lo[k] : hi[k]; - drag_.anchor = fromFrame(a, q); +auto App::setTool(Tool t) -> void { + for (const ToolInfo& i : kTools) + if (i.tool == t && ((mode_ == Mode::Object && !i.objectMode) || (mode_ == Mode::Edit && !i.editMode))) { + if (mode_ == Mode::Object && i.editMode) { // (a shape tool: go and edit the shape) + enterEdit(); + if (mode_ != Mode::Edit) return; break; - case Pivot::Bottom: q[2] = lo[2], drag_.anchor = fromFrame(a, q); break; - case Pivot::Origin: drag_.anchor = originPoint(); break; - default: drag_.anchor = fromFrame(a, c); break; } - drag_.pivot = fromFrame(a, c); - if (handle != 3) drag_.startDist = glm::dot(drag_.handlePoint - drag_.anchor, a[size_t(k)]), drag_.startMid = glm::dot(drag_.handlePoint - drag_.pivot, a[size_t(k)]); - return; + return say(std::format("{} works on whole objects (Tab leaves shape editing)", i.name)); } - drag_.pivot = tool_ == Tool::Rotate ? rotatePivot(a) : selectionMiddle(a); - if (handle < 3) drag_.startT = closestOnAxis(ray, drag_.pivot, a[size_t(handle)]); - if (auto hit = hitPlane(ray, drag_.pivot, -viewDir())) drag_.startHit = *hit; + tool_ = t, pluginTool_ = {}; + drawPts_.clear(), knifePts_.clear(); + if ((t == Tool::Extrude || t == Tool::Inset) && elem_ != Elem::Face) elem_ = Elem::Face, clearElements(); +} + +auto App::enterEdit() -> void { + if (mode_ == Mode::Edit) return; + Object* o = find(active_); + if (!o || !selected_.count(active_)) { + if (selected_.empty()) return say("Select an object first, then edit its shape"); + active_ = *selected_.begin(); + o = find(active_); } - // The points within reach of the selection (in the world: the object's size counts), each with - // how much it follows: a grid of the selected points, so a big mesh stays quick. - void findSoft() { - drag_.soft.clear(); - const Object* o = editing(); - if (!o) return; - const std::set pts = selectedPoints(); - if (pts.empty()) return; - const glm::mat4 m = objectMatrix(*o); - const float r = std::max(softReach_, 1e-5f); - auto cell = [&](const glm::vec3& p) { return glm::ivec3(glm::floor(p / r)); }; - auto keyOf = [](glm::ivec3 c) { return (int64_t(c.x & 0x1FFFFF) << 42) | (int64_t(c.y & 0x1FFFFF) << 21) | int64_t(c.z & 0x1FFFFF); }; - std::unordered_map> grid; - for (int v : pts) { - const glm::vec3 w(m * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)); - grid[keyOf(cell(w))].push_back(w); - } - for (size_t v = 0; v < o->mesh.verts.size(); ++v) { - if (pts.count(int(v))) continue; - const glm::vec3 w(m * glm::vec4(o->mesh.verts[v], 1.0f)); - const glm::ivec3 c = cell(w); - float best = r; - for (int dx = -1; dx <= 1; ++dx) - for (int dy = -1; dy <= 1; ++dy) - for (int dz = -1; dz <= 1; ++dz) - if (auto it = grid.find(keyOf(c + glm::ivec3(dx, dy, dz))); it != grid.end()) - for (const glm::vec3& q : it->second) best = std::min(best, glm::distance(w, q)); - if (best < r) drag_.soft.push_back({int(v), sculptFalloff(best, r)}); + if (!o || o->locked) return say("That object is locked"); + mode_ = Mode::Edit; + selected_ = {active_}; + clearElements(); + if (tool_ == Tool::Draw) tool_ = Tool::Move; + drawPts_.clear(); + touched(o->id); // (shown without its rounding and smoothing while you edit) +} + +auto App::leaveEdit() -> void { + if (mode_ != Mode::Edit) return; + mode_ = Mode::Object; + if (tool_ == Tool::Extrude || tool_ == Tool::Inset || tool_ == Tool::Bevel || tool_ == Tool::LoopCut || tool_ == Tool::Knife) tool_ = Tool::Move; + knifePts_.clear(); + if (Object* o = find(active_)) touched(o->id); +} + +auto App::selectAllElements() -> void { + Object* o = editing(); + if (!o) return; + clearElements(); + for (size_t v = 0; v < o->mesh.verts.size(); ++v) selVerts_.insert(int(v)); + for (size_t f = 0; f < o->mesh.faces.size(); ++f) selFaces_.insert(int(f)); + for (auto e : edges(o->mesh)) selEdges_.insert(e); +} + +auto App::selectAll() -> void { + if (mode_ == Mode::Edit) return selectAllElements(); + selected_.clear(); + for (const Object& o : scene_.objects) + if (o.visible) selected_.insert(o.id), active_ = o.id; +} + +auto App::selectNone() -> void { + if (mode_ == Mode::Edit) clearElements(); + else selected_.clear(); +} + +// The points the selection covers, whichever kind is picked. +auto App::selectedPoints() -> std::set { + std::set pts; + Object* o = editing(); + if (!o) return pts; + if (elem_ == Elem::Vertex) pts = selVerts_; + else if (elem_ == Elem::Edge) + for (auto [a, b] : selEdges_) pts.insert(a), pts.insert(b); + else + for (int f : selFaces_) + if (f < int(o->mesh.faces.size())) + for (int v : o->mesh.faces[size_t(f)]) pts.insert(v); + return pts; +} + +// Selects (in the current kind) what these points cover. +auto App::selectPoints(const std::set& pts) -> void { + Object* o = editing(); + if (!o) return; + clearElements(); + if (elem_ == Elem::Vertex) selVerts_ = pts; + else if (elem_ == Elem::Edge) { + for (auto [a, b] : edges(o->mesh)) + if (pts.count(a) && pts.count(b)) selEdges_.insert({a, b}); + } else + for (size_t f = 0; f < o->mesh.faces.size(); ++f) + if (std::all_of(o->mesh.faces[f].begin(), o->mesh.faces[f].end(), [&](int v) { return pts.count(v); })) selFaces_.insert(int(f)); +} + +auto App::shrinkSelection() -> void { + Object* o = editing(); + if (!o) return; + const std::set sel = selectedPoints(); + std::set rest; + for (size_t v = 0; v < o->mesh.verts.size(); ++v) + if (!sel.count(int(v))) rest.insert(int(v)); + const std::set border = growVerts(o->mesh, rest); + std::set keep; + for (int v : sel) + if (!border.count(v)) keep.insert(v); + selectPoints(keep); +} + +// Like the picked: faces that face the same way, edges as long, points with as many edges. +auto App::selectSimilar() -> void { + Object* o = editing(); + if (!o) return; + const Mesh& m = o->mesh; + if (elem_ == Elem::Face) { + std::vector ns; + for (int f : selFaces_) ns.push_back(faceNormal(m, f)); + if (ns.empty()) return say("Pick a face first"); + for (size_t f = 0; f < m.faces.size(); ++f) + for (const glm::vec3& n : ns) + if (glm::dot(faceNormal(m, int(f)), n) > 0.996f) selFaces_.insert(int(f)); + } else if (elem_ == Elem::Edge) { + std::vector lens; + for (auto [a, b] : selEdges_) lens.push_back(glm::distance(m.verts[size_t(a)], m.verts[size_t(b)])); + if (lens.empty()) return say("Pick an edge first"); + for (auto [a, b] : edges(m)) { + const float l = glm::distance(m.verts[size_t(a)], m.verts[size_t(b)]); + for (float k : lens) + if (std::abs(l - k) <= k * 0.02f + 1e-6f) selEdges_.insert({a, b}); } + } else { + std::vector count(m.verts.size(), 0); + for (auto [a, b] : edges(m)) ++count[size_t(a)], ++count[size_t(b)]; + std::set want; + for (int v : selVerts_) want.insert(count[size_t(v)]); + if (want.empty()) return say("Pick a point first"); + for (size_t v = 0; v < m.verts.size(); ++v) + if (want.count(count[size_t(v)])) selVerts_.insert(int(v)); } - void cancelDrag() { - if (!drag_.active) return; - scene_ = drag_.before; + say("Picked the similar ones"); +} + +// The picked edges' whole loops (or the rings across them). +auto App::loopsOfSelected(bool ring) -> void { + Object* o = editing(); + if (!o) return; + if (elem_ != Elem::Edge || selEdges_.empty()) return say("Pick an edge or two first (Edges, 2)"); + std::set> add; + for (auto e : selEdges_) + for (auto f : ring ? edgeRing(o->mesh, e) : edgeLoop(o->mesh, e)) add.insert(f); + selEdges_.insert(add.begin(), add.end()); +} + +auto App::invertSelection() -> void { + if (mode_ == Mode::Object) { + std::set inv; + for (const Object& o : scene_.objects) + if (o.visible && !selected_.count(o.id)) inv.insert(o.id), active_ = o.id; + selected_ = inv; + return; + } + Object* o = editing(); + if (!o) return; + if (elem_ == Elem::Vertex) { + std::set inv; + for (size_t v = 0; v < o->mesh.verts.size(); ++v) + if (!selVerts_.count(int(v))) inv.insert(int(v)); + selVerts_ = inv; + } else if (elem_ == Elem::Edge) { + std::set> inv; + for (auto e : edges(o->mesh)) + if (!selEdges_.count(e)) inv.insert(e); + selEdges_ = inv; + } else { + std::set inv; + for (size_t f = 0; f < o->mesh.faces.size(); ++f) + if (!selFaces_.count(int(f))) inv.insert(int(f)); + selFaces_ = inv; + } +} + +// The edges a rounding would work on: those selected, or round the selected faces, or between +// selected points. +auto App::edgesToRound() -> std::set> { + std::set> out; + Object* o = editing(); + if (!o) return out; + if (elem_ == Elem::Edge) return selEdges_; + if (elem_ == Elem::Face) { + for (int f : selFaces_) { + const auto& fv = o->mesh.faces[size_t(f)]; + for (size_t i = 0; i < fv.size(); ++i) out.insert({std::min(fv[i], fv[(i + 1) % fv.size()]), std::max(fv[i], fv[(i + 1) % fv.size()])}); + } + return out; + } + for (auto [a, b] : edges(o->mesh)) + if (selVerts_.count(a) && selVerts_.count(b)) out.insert({a, b}); + return out; +} + +auto App::deleteElements() -> void { + Object* o = editing(); + if (!o) return; + pushUndo(); + if (elem_ == Elem::Face) deleteFaces(o->mesh, selFaces_); + else deleteVerts(o->mesh, selectedPoints()); + clearElements(); + handMade(*o); + touched(o->id); +} + +// After a change that renumbers things: nothing selected, the shape is hand-made now. +auto App::changedShape(Object& o, bool keepSelection) -> void { + if (!keepSelection) clearElements(); + handMade(o); + touched(o.id); +} + +auto App::extrudeNow(float amount) -> void { + Object* o = editing(); + if (!o || elem_ != Elem::Face || selFaces_.empty()) return say("Select faces to extrude (Faces, 3)"); + pushUndo(); + extrudeFaces(o->mesh, selFaces_, amount); + changedShape(*o, true); +} + +auto App::insetNow(float amount) -> void { + Object* o = editing(); + if (!o || elem_ != Elem::Face || selFaces_.empty()) return say("Select faces to inset (Faces, 3)"); + pushUndo(); + insetFaces(o->mesh, selFaces_, amount); + changedShape(*o, true); +} + +auto App::bevelNow() -> void { + Object* o = editing(); + const auto es = edgesToRound(); + if (!o || es.empty()) return say("Select edges (or faces) to round"); + pushUndo(); + bevelEdges(o->mesh, es, bevelWidth_, bevelSteps_); + changedShape(*o); + if (bevelSteps_ > 1) o->smooth = true; +} + +auto App::subdivideNow(bool smooth) -> void { + Object* o = editing(); + if (!o) return; + pushUndo(); + subdivide(o->mesh, smooth); + if (smooth) o->smooth = true; + changedShape(*o); +} + +auto App::cutAt(Object& o, std::pair edge) -> void { + pushUndo(); + const auto made = loopCut(o.mesh, edge, cuts_); + if (made.empty()) { dropUndo(); - touchAll(); - drag_.active = false; + return say("Nothing to cut there: loop cuts go across four-sided faces"); } + changedShape(o); + // The new edges are selected, ready to move (along the faces, with the gizmo). + const std::set pts(made.begin(), made.end()); + elem_ = Elem::Edge; + for (auto [a, b] : edges(o.mesh)) + if (pts.count(a) && pts.count(b)) selEdges_.insert({a, b}); + say(std::format("Cut {} time{} round. W moves the new edges.", cuts_, cuts_ == 1 ? "" : "s")); +} - void updateDrag(const ImGuiIO& io) { - for (ImWchar c : io.InputQueueCharacters) { - if ((c >= '0' && c <= '9') || (c == '.' && drag_.typed.find('.') == std::string::npos)) drag_.typed += char(c); - else if (c == '-') drag_.typed = drag_.typed.starts_with('-') ? drag_.typed.substr(1) : "-" + drag_.typed; - } - if (ImGui::IsKeyPressed(ImGuiKey_Backspace) && !drag_.typed.empty()) drag_.typed.pop_back(); - dragNow(io); - // (the number by the mouse, as it goes: what you'd read in the status line, where you look) - if (!message_.empty()) { - const std::string label = drag_.typed.empty() ? message_ : message_ + " (typed)"; - const ImVec2 at(io.MousePos.x + 18, io.MousePos.y + 14), size = ImGui::CalcTextSize(label.c_str()); - ImDrawList* dl = ImGui::GetForegroundDrawList(); - dl->AddRectFilled(ImVec2(at.x - 6, at.y - 3), ImVec2(at.x + size.x + 6, at.y + size.y + 3), IM_COL32(24, 24, 28, 225), 4.0f); - dl->AddText(at, drag_.typed.empty() ? IM_COL32(235, 235, 240, 255) : kAccent, label.c_str()); - } - if (liveMirror_ && mode_ == Mode::Edit) - if (Object* o = editing()) - for (const Object& b : drag_.before.objects) - if (b.id == o->id) mirrorEdits(*o, b.mesh); - } - // Mirror editing: each point that moved, its twin across the shape's own X (the point that was - // where it is, mirrored) moved the mirrored way. Points on the middle stay on it. - void mirrorEdits(Object& o, const Mesh& before) { - if (before.verts.size() != o.mesh.verts.size() || before.verts.empty()) return; // (points made or gone: nothing to match) - const float tol = std::max(glm::length(boundsMax(before) - boundsMin(before)) * 1e-5f, 1e-7f); - auto key = [&](const glm::vec3& p) { - const glm::ivec3 c(glm::round(p / tol)); - return (uint64_t(uint32_t(c.x) & 0x1FFFFF) << 42) | (uint64_t(uint32_t(c.y) & 0x1FFFFF) << 21) | uint64_t(uint32_t(c.z) & 0x1FFFFF); - }; - std::unordered_map at; - for (size_t i = 0; i < before.verts.size(); ++i) at.emplace(key(before.verts[i]), int(i)); - std::vector moved(before.verts.size()); - for (size_t i = 0; i < moved.size(); ++i) moved[i] = glm::distance(before.verts[i], o.mesh.verts[i]) > tol * 0.01f; - for (size_t i = 0; i < moved.size(); ++i) { - if (!moved[i]) continue; - const glm::vec3 b = before.verts[i]; - if (std::abs(b.x) <= tol) { // (on the middle) - o.mesh.verts[i].x = 0; - continue; - } - const auto it = at.find(key(glm::vec3(-b.x, b.y, b.z))); - if (it == at.end() || moved[size_t(it->second)]) continue; // (no twin, or it's moved by hand too) - const glm::vec3 n = o.mesh.verts[i]; - o.mesh.verts[size_t(it->second)] = glm::vec3(-n.x, n.y, n.z); - } - } - // What moving snaps to near the mouse (points, edges' or faces' middles), in the world: not - // what's being moved. Marked with a ring. - std::optional snapTarget(ImVec2 mouse, float within = 22.0f) { - std::optional best; - float bestD = within; - const std::set moving = mode_ == Mode::Edit ? selectedPoints() : std::set{}; - for (const Object& o : scene_.objects) { - if (!o.visible) continue; - const bool edited = mode_ == Mode::Edit && o.id == active_; - if (mode_ != Mode::Edit && selected_.count(o.id)) continue; - const Mesh& mesh = edited ? o.mesh : shownMesh(o); - const glm::mat4 m = objectMatrix(o); - auto tryPoint = [&](const glm::vec3& local) { - const glm::vec3 w = glm::vec3(m * glm::vec4(local, 1.0f)); - ImVec2 sp; - if (!project(w, sp)) return; - if (const float d = std::hypot(sp.x - mouse.x, sp.y - mouse.y); d < bestD) bestD = d, best = w; - }; - if (snapKind_ == 1) { - for (size_t v = 0; v < mesh.verts.size(); ++v) - if (!(edited && moving.count(int(v)))) tryPoint(mesh.verts[v]); - } else if (snapKind_ == 2) { - for (const auto& e : adjacencyOf(o, mesh).edges) - if (!(edited && (moving.count(e[0]) || moving.count(e[1])))) tryPoint((mesh.verts[size_t(e[0])] + mesh.verts[size_t(e[1])]) * 0.5f); - } else if (snapKind_ == 3) { - for (const auto& f : mesh.faces) { - glm::vec3 c(0.0f); - bool mine = false; - for (int v : f) c += mesh.verts[size_t(v)], mine |= edited && moving.count(v) > 0; - if (!mine && !f.empty()) tryPoint(c / float(f.size())); - } - } - } - if (best) { - ImVec2 sp; - if (project(*best, sp)) ImGui::GetForegroundDrawList()->AddCircle(sp, 7.0f, kAccent, 0, 2.0f); - } - return best; - } - // The number typed while dragging, if any (just "-" or "." is none yet). - std::optional typed() const { - if (drag_.typed.empty() || drag_.typed == "-" || drag_.typed == "." || drag_.typed == "-.") return std::nullopt; - return std::strtof(drag_.typed.c_str(), nullptr); - } - void dragNow(const ImGuiIO& io) { - const Ray ray = mouseRay(io.MousePos); - const bool snapping = snap_ != io.KeyCtrl; // Ctrl flips snapping while held - scene_ = drag_.before; // every frame from where it started: no drift - const Axes& axes = drag_.axes; - const glm::vec3 p = drag_.pivot; - glm::mat4 T(1.0f); // what happens to the selection, in the world - if (drag_.tool == Tool::Extrude || drag_.tool == Tool::Inset) { - Object* o = editing(); - if (!o) return; - const glm::vec3 n = drag_.startHit; - float d = closestOnAxis(ray, p, n) - drag_.startT; - if (drag_.tool == Tool::Extrude) { - if (snapping) d = snapTo(d, snapMove_); - if (auto v = typed()) d = *v; - // (the pull in the object's own size, so it follows the mouse whatever its scale) - const float scale = std::cbrt(std::abs(glm::determinant(glm::mat3(objectMatrix(*o))))); - extrudeFaces(o->mesh, selFaces_, d / std::max(scale, 1e-6f)); - extrudeAmount_ = d; - message_ = std::format("Extrude {:.3f} m", d), messageAt_ = now(); - } else { - float t = std::clamp(-d / (pixelSize(p) * 120.0f), 0.0f, 0.99f); - if (snapping) t = snapTo(t, 0.05f); - if (auto v = typed()) t = std::clamp(*v / 100.0f, 0.0f, 0.99f); - insetFaces(o->mesh, selFaces_, t); - insetAmount_ = t; - message_ = std::format("Inset {:.0f}%", t * 100), messageAt_ = now(); - } - changedShape(*o, true); - return; - } - if (drag_.tool == Tool::Bevel) { - Object* o = editing(); - if (!o) return; - float w = std::max(0.0f, io.MousePos.x - drag_.startMouse.x) * pixelSize(p) * 0.5f; - if (snapping) w = snapTo(w, 0.005f); - if (auto v = typed()) w = std::max(0.0f, *v); - if (w > 0) bevelEdges(o->mesh, drag_.bevelEdges, w, bevelSteps_); - bevelWidth_ = w; - handMade(*o); - touched(o->id); - message_ = std::format("Round edges {:.3f} m, {} step{}", w, bevelSteps_, bevelSteps_ == 1 ? "" : "s"), messageAt_ = now(); - return; - } - glm::vec3 objectScale(1.0f); - float even = 1.0f; - if (drag_.tool == Tool::Move) { - glm::vec3 delta(0.0f); - if (drag_.handle < 3) { - float t = closestOnAxis(ray, p, axes[size_t(drag_.handle)]) - drag_.startT; - if (snapping && !snapKind_) t = snapTo(t, snapMove_); - if (snapping && snapKind_) - if (auto at = snapTarget(io.MousePos)) t = glm::dot(*at - p, axes[size_t(drag_.handle)]); // (as far along the arrow as it is) - if (auto v = typed()) t = *v; - delta = axes[size_t(drag_.handle)] * t; - } else if (auto hit = hitPlane(ray, p, -viewDir())) { - delta = *hit - drag_.startHit; - if (snapping && !snapKind_) delta = glm::vec3(snapTo(delta.x, snapMove_), snapTo(delta.y, snapMove_), snapTo(delta.z, snapMove_)); - if (snapping && snapKind_) - if (auto at = snapTarget(io.MousePos)) delta = *at - p; // (the arrows' point onto it) - } - T = glm::translate(glm::mat4(1.0f), delta); - message_ = std::format("Move {:.3f}, {:.3f}, {:.3f} m", delta.x, delta.y, delta.z), messageAt_ = now(); - } else if (drag_.tool == Tool::Rotate) { - ImVec2 c; - if (!project(p, c)) return; - const float a0 = std::atan2(drag_.startMouse.y - c.y, drag_.startMouse.x - c.x), a1 = std::atan2(io.MousePos.y - c.y, io.MousePos.x - c.x); - float deg = glm::degrees(std::remainder(a1 - a0, 2 * kPi)); - const glm::vec3 axis = drag_.handle < 3 ? axes[size_t(drag_.handle)] : -viewDir(); - if (glm::dot(axis, viewDir()) < 0) deg = -deg; // (turning the way the mouse goes round, as seen) - if (snapping) deg = snapTo(deg, snapAngle_); - if (auto v = typed()) deg = *v; - T = glm::translate(glm::mat4(1.0f), p) * glm::rotate(glm::mat4(1.0f), glm::radians(deg), axis) * glm::translate(glm::mat4(1.0f), -p); - message_ = std::format("Rotate {:.1f} degrees", deg), messageAt_ = now(); - } else if (drag_.tool == Tool::Scale) { - glm::vec3 s(1.0f); - // Shift (or Alt) held: from both sides, the middle staying put. - const bool both = (io.KeyShift || io.KeyAlt) && drag_.handle != 3; - if (drag_.handle != 3) { - const int k = drag_.handle < 3 ? drag_.handle : drag_.handle - 4; - const float from = both ? drag_.startMid : drag_.startDist; - float dist = from + closestOnAxis(ray, drag_.handlePoint, axes[size_t(k)]); - if (snapping) dist = both ? snapTo(dist * 2, snapMove_) / 2 : snapTo(dist, snapMove_); - if (auto v = typed()) dist = (from < 0 ? -1.0f : 1.0f) * std::abs(*v) / (both ? 2.0f : 1.0f); // (the size it's to be, m) - const float f = std::abs(from) > 1e-6f ? dist / from : 1.0f; - s[k] = f; - message_ = std::format("Scale {:.2f} ({:.3f} m){}", f, std::abs(dist) * (both ? 2 : 1), both ? " from both sides" : " (Shift: from both sides)"), - messageAt_ = now(); - } else { - even = std::max(0.01f, 1.0f + ((io.MousePos.x - drag_.startMouse.x) - (io.MousePos.y - drag_.startMouse.y)) / 200.0f); - if (snapping) even = std::max(snapScale_, snapTo(even, snapScale_)); - if (auto v = typed()) even = std::max(0.001f, *v); - s = glm::vec3(even); - message_ = std::format("Scale {:.2f} (drag right or up to grow)", even), messageAt_ = now(); - } - const glm::mat3 basis(axes[0], axes[1], axes[2]); - const glm::vec3 a = both ? drag_.pivot : drag_.anchor; - T = glm::translate(glm::mat4(1.0f), a) * glm::mat4(basis) * glm::scale(glm::mat4(1.0f), s) * glm::mat4(glm::transpose(basis)) * glm::translate(glm::mat4(1.0f), -a); - objectScale = s; - } - if (Object* o = editing()) { - const glm::mat4 m = objectMatrix(*o), local = glm::inverse(m) * T * m; - for (int v : selectedPoints()) o->mesh.verts[size_t(v)] = glm::vec3(local * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)); - for (const auto& [v, w] : drag_.soft) { // (part of the way, by how near) - glm::vec3& p = o->mesh.verts[size_t(v)]; - p = glm::mix(p, glm::vec3(local * glm::vec4(p, 1.0f)), w); - } - handMade(*o); - touched(o->id); - return; - } - for (Object& o : scene_.objects) { - const bool along = !selected_.count(o.id) && o.cuts && selected_.count(o.cuts) && isCutter(o); // (an editable cutter goes with what it cuts) - if ((!selected_.count(o.id) && !along) || o.locked) continue; - o.position = glm::vec3(T * glm::vec4(o.position, 1.0f)); - if (drag_.tool == Tool::Rotate) o.rotation = glm::normalize(glm::quat_cast(glm::mat3(T)) * o.rotation); - if (drag_.tool == Tool::Scale) { - // On its own axes: the gizmo's where they're the same, else how much each of its - // axes lies along each of the gizmo's. - for (int j = 0; j < 3; ++j) { - const glm::vec3 own = o.rotation * glm::vec3(j == 0, j == 1, j == 2); - float f = 1.0f; - for (int k = 0; k < 3; ++k) f += (objectScale[k] - 1.0f) * glm::dot(own, axes[size_t(k)]) * glm::dot(own, axes[size_t(k)]); - o.scale[j] *= f; - } - } - touched(o.id); - } +auto App::makeFaceNow() -> void { + Object* o = editing(); + if (!o) return; + const std::set pts = selectedPoints(); + pushUndo(); + const glm::vec3 towards = glm::inverse(glm::mat3(objectMatrix(*o))) * (eye_ - pivotOfSelection()); + if (!makeFace(o->mesh, std::vector(pts.begin(), pts.end()), towards)) { + dropUndo(); + return say("Pick 3 or more points (not in a line) to make a face"); } + changedShape(*o); + selFaces_ = {int(o->mesh.faces.size()) - 1}; + elem_ = Elem::Face; +} - // ───────────────────────────── drawing a shape ───────────────────────────── +auto App::mergeNow() -> void { + Object* o = editing(); + if (!o) return; + const std::set pts = selectedPoints(); + if (pts.size() < 2) return say("Select 2 or more points to merge"); + pushUndo(); + mergeToCenter(o->mesh, pts); + changedShape(*o); +} - // The plane shapes are drawn on: the one the view looks straight at, or the ground. - void workPlane(glm::vec3& point, glm::vec3& normal) const { - 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. - void drawPlane(glm::vec3& p, glm::vec3& n) { - if (drawOnto_ && find(drawOnto_)) p = drawOnP_, n = drawOnN_; - else workPlane(p, n); - } - std::optional drawPoint(const ImGuiIO& io) { - 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; - } - std::string sketchTyped_; // (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. - bool sketchKeys(const ImGuiIO& io) { - 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; - } - void finishShape() { - 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 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 : "")); - } +auto App::smoothPointsNow() -> void { + Object* o = editing(); + const std::set pts = selectedPoints(); + if (!o || pts.empty()) return say("Select points (or faces, edges) to smooth"); + pushUndo(); + smoothPoints(o->mesh, pts, 0.5f, 2); + changedShape(*o, true); + say("Smoothed (again for more)"); +} - // ───────────────────────────── the viewport's mouse ───────────────────────────── +auto App::startPush() -> void { + Object* o = editing(); + if (!o || selectedPoints().empty()) return say("Select points (or faces) to push out or pull in"); + pushUndo(); + pushPts_ = selectedPoints(), pushFrom_ = o->mesh, pushObject_ = o->id, pushBy_ = 0.0f, showPush_ = true; +} - void viewport(ImGuiIO& io) { - 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 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) - } - } - void addDrawPoint(const ImGuiIO& io) { - 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. - void paintViewport(ImGuiIO& io) { - 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); +auto App::pushWindow() -> void { + if (!showPush_) return; + Object* o = find(pushObject_); + if (!o || mode_ != Mode::Edit) return void(showPush_ = false); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + bool open = true; + if (!ImGui::Begin("Push out, pull in", &open, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoDocking)) return ImGui::End(); + const float w = 260; + float mm = pushBy_ * 1000; + ImGui::SetNextItemWidth(w); + if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); + if (ImGui::DragFloat("##by", &mm, 0.5f, -1e5f, 1e5f, "%.1f mm (- pulls in)")) { + pushBy_ = mm / 1000; + o->mesh = pushFrom_; + pushPoints(o->mesh, pushPts_, pushBy_); + changedShape(*o, true); } + ImGui::TextWrapped("Each point along the surface's normal there: fatter or thinner, the same shape."); + pushAccent(); + const bool done = ImGui::Button("Done", ImVec2(w, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter, false); + popAccent(); + ImGui::End(); + if (done) showPush_ = false; + if (!open) o->mesh = pushFrom_, changedShape(*o, true), showPush_ = false; // (closed: as it was) +} - // The brush: whatever passes under it is selected (or, with Ctrl, let go). - void paint(const ImGuiIO& io) { - 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); - } +auto App::bridgeNow() -> void { + Object* o = editing(); + if (!o || selFaces_.size() != 2) return say("Select two faces to bridge (Faces, 3; Join two objects first if they're apart)"); + const int a = *selFaces_.begin(), b = *selFaces_.rbegin(); + if (o->mesh.faces[size_t(a)].size() != o->mesh.faces[size_t(b)].size()) + return say(std::format("Those faces have {} and {} corners: bridging needs as many on each", o->mesh.faces[size_t(a)].size(), o->mesh.faces[size_t(b)].size())); + pushUndo(); + const std::vector made = bridgeFaces(o->mesh, a, b); + if (made.empty()) return takeBack(), say("Couldn't bridge those (they share a point)"); + changedShape(*o); + selFaces_.insert(made.begin(), made.end()); + say(std::format("Bridged: {} faces between them", made.size())); +} - // ───────────────────────────── keys ───────────────────────────── +auto App::flipNow() -> void { + Object* o = editing(); + if (!o || selFaces_.empty()) return say("Select faces to flip"); + pushUndo(); + flipFaces(o->mesh, selFaces_); + changedShape(*o, true); +} - void shortcuts(ImGuiIO& io) { - if (io.WantTextInput || slide_.active || drag_.active) return; // (a slide, a drag: their own keys) - if (tool_ == Tool::Draw && !drawPts_.empty() && sketchKeys(io)) return; - const bool ctrl = io.KeyCtrl, shift = io.KeyShift, alt = io.KeyAlt; - auto key = [](ImGuiKey k) { return ImGui::IsKeyPressed(k, false); }; - if (key(ImGuiKey_F3) || (ctrl && key(ImGuiKey_Space))) return openSearch(); - if (!settingKey_.empty()) return; // (Keys and mouse is waiting for a key) - if (const auto chord = fry::pressedChord()) - for (const auto& [id, c] : myKeys_) - if (c == *chord) return void(runCommand(id)); - if (key(ImGuiKey_Space) && !ctrl && !alt && mouseInView(io) && (tab_ == Tab::Model || tab_ == Tab::Render || tab_ == Tab::Print)) - return void((pie_ = shift ? 2 : 1, pieAt_ = io.MousePos)); - if (key(ImGuiKey_F5)) return runScriptNow(); - if (ctrl && key(ImGuiKey_1)) setTab(Tab::Model); - if (ctrl && key(ImGuiKey_2)) setTab(Tab::Paint); - if (ctrl && key(ImGuiKey_3)) setTab(Tab::Layout); - if (tab_ == Tab::Paint && !ctrl && !alt && key(ImGuiKey_Tab)) return (void)(sculpting_ = !sculpting_, endStroke(), endSculpt()); - if (tab_ == Tab::Paint && sculpting_ && !ctrl && !alt) - for (int i = 0; i < int(std::size(kSculptTools)); ++i) - if (key(ImGuiKey(int(ImGuiKey_1) + i))) sculptBrush_ = kSculptTools[i].brush; - if (tab_ == Tab::Paint && sculpting_ && !ctrl && !alt && key(ImGuiKey_X)) sculptMirror_ = !sculptMirror_; - if (tab_ == Tab::Paint && !ctrl && !alt) { - if (key(ImGuiKey_B)) paintTool_ = PaintTool::Brush; - if (key(ImGuiKey_E)) paintTool_ = PaintTool::Eraser; - if (key(ImGuiKey_G)) paintTool_ = PaintTool::Fill; - if (key(ImGuiKey_I)) paintTool_ = PaintTool::Picker; - float& size = sculpting_ ? sculptPx_ : brushPx_; - if (ImGui::IsKeyPressed(ImGuiKey_LeftBracket)) size = std::max(1.0f, size / 1.2f); - if (ImGui::IsKeyPressed(ImGuiKey_RightBracket)) size = std::min(500.0f, size * 1.2f); - if (key(ImGuiKey_Enter) || key(ImGuiKey_KeypadEnter)) lockLayer(); - if (key(ImGuiKey_Delete) && editLayer()) deleteLayer(*find(ledit_.object), ledit_.index); - if (key(ImGuiKey_F1)) showHelp_ = !showHelp_; - if (key(ImGuiKey_F12)) dialog(5); - if (key(ImGuiKey_F)) frameSelected(); - if (key(ImGuiKey_Home)) frameAll(); - if (key(ImGuiKey_Z)) shading_ = shading_ == Shading::Solid ? Shading::Rendered : Shading::Solid; - return; - } - if (tab_ == Tab::Paint && ctrl && key(ImGuiKey_V)) return pasteFromClipboard(); - if (tab_ == Tab::Paint && ctrl && key(ImGuiKey_Z) && editLayer() && ledit_.fresh) return cancelFloating(); - if (ctrl && key(ImGuiKey_4)) setTab(Tab::Render); - if (ctrl && key(ImGuiKey_5)) setTab(Tab::Print); - if (tab_ == Tab::Layout && !ctrl && !alt) { - if (key(ImGuiKey_Q)) layTool_ = LayTool::Select; - if (key(ImGuiKey_W)) layTool_ = LayTool::Move; - if (key(ImGuiKey_E)) layTool_ = LayTool::Rotate; - if (key(ImGuiKey_R)) layTool_ = LayTool::Scale; - if (key(ImGuiKey_Tab)) lay_.corners = !lay_.corners, lay_.frames.clear(), lay_.points.clear(); - if (key(ImGuiKey_F1)) showHelp_ = !showHelp_; - if (key(ImGuiKey_F)) frameSelected(); - if (key(ImGuiKey_Home)) frameAll(); - return; - } - if (tab_ == Tab::Layout && ctrl && key(ImGuiKey_A)) return layoutSelectAll(); - if (tab_ == Tab::Print && !ctrl && !alt) { - if (key(ImGuiKey_M)) printTool_ = printTool_ == PrintTool::Measure ? PrintTool::None : PrintTool::Measure, measure_.clear(); - if (key(ImGuiKey_L)) printTool_ = printTool_ == PrintTool::LayFlat ? PrintTool::None : PrintTool::LayFlat; - if (key(ImGuiKey_B)) putOnBed(); - if (key(ImGuiKey_Q) || key(ImGuiKey_W) || key(ImGuiKey_E) || key(ImGuiKey_R)) printTool_ = PrintTool::None; - } - if ((tab_ == Tab::Render || tab_ == Tab::Print) && !ctrl && !alt) { - if (key(ImGuiKey_Q)) setTool(Tool::Select); - if (key(ImGuiKey_W)) setTool(Tool::Move); - if (key(ImGuiKey_E)) setTool(Tool::Rotate); - if (key(ImGuiKey_R)) setTool(Tool::Scale); - if (key(ImGuiKey_F1)) showHelp_ = !showHelp_; - if (key(ImGuiKey_F12)) dialog(5); - if (key(ImGuiKey_F)) frameSelected(); - if (key(ImGuiKey_Home)) frameAll(); - if (key(ImGuiKey_Delete)) deleteSelected(); - return; - } - if (ctrl) { - if (key(ImGuiKey_Z)) shift ? redo() : undo(); - if (key(ImGuiKey_Y)) redo(); - if (key(ImGuiKey_S)) shift || path_.empty() ? dialog(2) : void(saveFile(path_)); - if (key(ImGuiKey_O)) shift ? dialog(12) : whenSaved(3); - if (key(ImGuiKey_N)) whenSaved(2); - if (key(ImGuiKey_E)) dialog(3); - if (key(ImGuiKey_Q)) whenSaved(1); - if (key(ImGuiKey_D)) duplicateSelected(); - if (key(ImGuiKey_A)) selectAll(); - if (key(ImGuiKey_I)) invertSelection(); - if (key(ImGuiKey_J)) joinSelected(); - if (key(ImGuiKey_G)) groupSelected(); - if (shift && (key(ImGuiKey_Equal) || key(ImGuiKey_KeypadAdd))) return combineSelected(Combine::Add); - if (shift && (key(ImGuiKey_Minus) || key(ImGuiKey_KeypadSubtract))) return combineSelected(Combine::Subtract); - if (key(ImGuiKey_Equal) || key(ImGuiKey_KeypadAdd)) growSelection(); - if (key(ImGuiKey_Minus) || key(ImGuiKey_KeypadSubtract)) shrinkSelection(); - return; - } - if (alt) { - if (key(ImGuiKey_S) && mode_ == Mode::Edit) startPush(); - if (key(ImGuiKey_G) && mode_ == Mode::Edit) return startSlide(); - if (key(ImGuiKey_A)) selectNone(); - if (key(ImGuiKey_Z)) xray_ = !xray_; - if (key(ImGuiKey_H)) - for (Object& o : scene_.objects) o.visible = true; - return; - } +// Rotation and scale baked into the points (so the object's turn is none and its size is 1). +auto App::applyTransform(Object& o) -> void { + pushUndo(); + transform(o.mesh, glm::mat4_cast(o.rotation) * glm::scale(glm::mat4(1.0f), o.scale)); + o.rotation = glm::quat(1, 0, 0, 0), o.scale = glm::vec3(1.0f); + handMade(o); + touched(o.id); +} + +auto App::applyFinish(Object& o) -> void { + pushUndo(); + o.mesh = finishedMesh(o); + o.mirror = 0, o.round = 0, o.smoothLevels = 0; + handMade(o); + touched(o.id); +} + +auto App::originToMiddle(Object& o) -> void { + pushUndo(); + const glm::vec3 c = (boundsMin(o.mesh) + boundsMax(o.mesh)) * 0.5f; + for (glm::vec3& v : o.mesh.verts) v -= c; + o.position += glm::vec3(objectMatrix(o) * glm::vec4(c, 0.0f)); + handMade(o); + touched(o.id); +} + +auto App::originToBottom(Object& o) -> void { + pushUndo(); + const glm::vec3 c((boundsMin(o.mesh).x + boundsMax(o.mesh).x) * 0.5f, (boundsMin(o.mesh).y + boundsMax(o.mesh).y) * 0.5f, boundsMin(o.mesh).z); + for (glm::vec3& v : o.mesh.verts) v -= c; + o.position += glm::vec3(objectMatrix(o) * glm::vec4(c, 0.0f)); + handMade(o); + touched(o.id); +} + +auto App::mirror(Object& o, int axis) -> void { + pushUndo(); + glm::vec3 s(1.0f); + s[axis] = -1.0f; + transform(o.mesh, glm::scale(glm::mat4(1.0f), s)); + handMade(o); + touched(o.id); +} + +// ───────────────────────────── tabs ───────────────────────────── + +auto App::setTab(Tab t) -> void { + if (t == tab_) return; + if (drag_.active) cancelDrag(); + lockLayer(); + endStroke(), endSculpt(); + knifePts_.clear(), drawPts_.clear(); + if (t != Tab::Model) leaveEdit(); + if ((t == Tab::Render || t == Tab::Print) && tool_ != Tool::Select && tool_ != Tool::Move && tool_ != Tool::Rotate && tool_ != Tool::Scale) tool_ = Tool::Move; + if ((t == Tab::Paint || t == Tab::Layout) && tab_ != Tab::Paint && tab_ != Tab::Layout) picZoom_ = 0; // (fit the picture again) + lay_.drag = 0, printTool_ = PrintTool::None, measure_.clear(); + tab_ = t; +} + +// ───────────────────────────── keys ───────────────────────────── + +auto App::shortcuts(ImGuiIO& io) -> void { + if (io.WantTextInput || slide_.active || drag_.active) return; // (a slide, a drag: their own keys) + if (tool_ == Tool::Draw && !drawPts_.empty() && sketchKeys(io)) return; + const bool ctrl = io.KeyCtrl, shift = io.KeyShift, alt = io.KeyAlt; + auto key = [](ImGuiKey k) { return ImGui::IsKeyPressed(k, false); }; + if (key(ImGuiKey_F3) || (ctrl && key(ImGuiKey_Space))) return openSearch(); + if (!settingKey_.empty()) return; // (Keys and mouse is waiting for a key) + if (const auto chord = fry::pressedChord()) + for (const auto& [id, c] : myKeys_) + if (c == *chord) return void(runCommand(id)); + if (key(ImGuiKey_Space) && !ctrl && !alt && mouseInView(io) && (tab_ == Tab::Model || tab_ == Tab::Render || tab_ == Tab::Print)) + return void((pie_ = shift ? 2 : 1, pieAt_ = io.MousePos)); + if (key(ImGuiKey_F5)) return runScriptNow(); + if (ctrl && key(ImGuiKey_1)) setTab(Tab::Model); + if (ctrl && key(ImGuiKey_2)) setTab(Tab::Paint); + if (ctrl && key(ImGuiKey_3)) setTab(Tab::Layout); + if (tab_ == Tab::Paint && !ctrl && !alt && key(ImGuiKey_Tab)) return (void)(sculpting_ = !sculpting_, endStroke(), endSculpt()); + if (tab_ == Tab::Paint && sculpting_ && !ctrl && !alt) + for (int i = 0; i < int(std::size(kSculptTools)); ++i) + if (key(ImGuiKey(int(ImGuiKey_1) + i))) sculptBrush_ = kSculptTools[i].brush; + if (tab_ == Tab::Paint && sculpting_ && !ctrl && !alt && key(ImGuiKey_X)) sculptMirror_ = !sculptMirror_; + if (tab_ == Tab::Paint && !ctrl && !alt) { + if (key(ImGuiKey_B)) paintTool_ = PaintTool::Brush; + if (key(ImGuiKey_E)) paintTool_ = PaintTool::Eraser; + if (key(ImGuiKey_G)) paintTool_ = PaintTool::Fill; + if (key(ImGuiKey_I)) paintTool_ = PaintTool::Picker; + float& size = sculpting_ ? sculptPx_ : brushPx_; + if (ImGui::IsKeyPressed(ImGuiKey_LeftBracket)) size = std::max(1.0f, size / 1.2f); + if (ImGui::IsKeyPressed(ImGuiKey_RightBracket)) size = std::min(500.0f, size * 1.2f); + if (key(ImGuiKey_Enter) || key(ImGuiKey_KeypadEnter)) lockLayer(); + if (key(ImGuiKey_Delete) && editLayer()) deleteLayer(*find(ledit_.object), ledit_.index); if (key(ImGuiKey_F1)) showHelp_ = !showHelp_; if (key(ImGuiKey_F12)) dialog(5); - if (key(ImGuiKey_Q)) setTool(Tool::Select); - if (key(ImGuiKey_W)) setTool(Tool::Move); - if (key(ImGuiKey_E) && shift && mode_ == Mode::Edit) return creaseSelected(); - if (key(ImGuiKey_E)) setTool(Tool::Rotate); - if (key(ImGuiKey_R)) setTool(Tool::Scale); - if (key(ImGuiKey_D)) setTool(Tool::Draw); - if (key(ImGuiKey_X)) setTool(Tool::Extrude); - if (key(ImGuiKey_I)) setTool(Tool::Inset); - if (key(ImGuiKey_B)) setTool(Tool::Bevel); - if (key(ImGuiKey_C)) setTool(Tool::LoopCut); - if (key(ImGuiKey_K)) setTool(Tool::Knife); - if (key(ImGuiKey_O) && !shift) setTool(Tool::Hole); - if (key(ImGuiKey_Tab)) mode_ == Mode::Edit ? leaveEdit() : enterEdit(); - if (key(ImGuiKey_Delete) || key(ImGuiKey_Backspace)) deleteSelected(); if (key(ImGuiKey_F)) frameSelected(); if (key(ImGuiKey_Home)) frameAll(); - if (key(ImGuiKey_Z)) shading_ = shift ? Shading::Wire : shading_ == Shading::Solid ? Shading::Rendered : Shading::Solid; - if (key(ImGuiKey_Enter) || key(ImGuiKey_KeypadEnter)) { - if (tool_ == Tool::Draw) finishShape(); - if (tool_ == Tool::Knife && knifePts_.size() >= 2) { - std::vector pts = knifePts_; - knifePts_.clear(); - knifeApply(active_, pts); - } - } - if (mode_ == Mode::Edit) { - if (key(ImGuiKey_M)) mergeNow(); - if (key(ImGuiKey_L)) selectLinked(); - if (key(ImGuiKey_P)) separateSelected(); - if (key(ImGuiKey_1)) elem_ = Elem::Vertex, clearElements(); - if (key(ImGuiKey_2)) elem_ = Elem::Edge, clearElements(); - if (key(ImGuiKey_3)) elem_ = Elem::Face, clearElements(); - } - if (key(ImGuiKey_H) && shift && !selected_.empty()) showOnly(selected_); // (the rest hidden) - else if (key(ImGuiKey_H)) - for (Object& o : scene_.objects) - if (selected_.count(o.id) && mode_ == Mode::Object) o.visible = false; - if ((key(ImGuiKey_Slash) || key(ImGuiKey_KeypadDivide)) && mode_ == Mode::Object && !selected_.empty()) showOnly(selected_); - if (key(ImGuiKey_O) && shift) softSel_ = !softSel_, say(softSel_ ? "Soft selection on: points near follow (the wheel while dragging: how far)" : "Soft selection off"); - if (key(ImGuiKey_Keypad1)) setView(shift ? View::Back : View::Front); - if (key(ImGuiKey_Keypad3)) setView(shift ? View::Right : View::Left); - if (key(ImGuiKey_Keypad7)) setView(shift ? View::Bottom : View::Top); - if (key(ImGuiKey_Keypad5)) view_ == View::Perspective ? setView(View::Front) : leaveFlatView(); + if (key(ImGuiKey_Z)) shading_ = shading_ == Shading::Solid ? Shading::Rendered : Shading::Solid; + return; } - - // ───────────────────────────── 3D ───────────────────────────── - -#if defined(__ANDROID__) - static constexpr int kRayLight = 0; // (light bounced by ray tracing: not on phones) -#else - static constexpr int kRayLight = 1; -#endif - // Solid: a plain grey room and soft studio light, no shadows or effects — for seeing shapes. - // Rendered: sky, sun, shadows and effects. - void applyShading() { - const Look& k = scene_.look; - const std::string want = - shading() == Shading::Rendered - ? std::format("r_ssao {}; r_bloom {}; r_vignette {}; r_exposure {}; r_saturation {}; r_sharpen {}; r_shadowsoft {}; r_gi {}; r_fog 0; r_skycolor \"{} {} {}\"", k.ao, - k.bloom, k.vignette, k.exposure, k.saturation, k.sharpen, k.shadowSoft, kRayLight, k.background.r, k.background.g, k.background.b) - : std::string("r_ssao 0; r_bloom 0; r_vignette 0; r_exposure 1; r_saturation 1; r_sharpen 0; r_gi 0; r_fog 0; r_skycolor \"0.2 0.2 0.215\""); - if (want == appliedLook_) return; - appliedLook_ = want; - keel::execute(want); + if (tab_ == Tab::Paint && ctrl && key(ImGuiKey_V)) return pasteFromClipboard(); + if (tab_ == Tab::Paint && ctrl && key(ImGuiKey_Z) && editLayer() && ledit_.fresh) return cancelFloating(); + if (ctrl && key(ImGuiKey_4)) setTab(Tab::Render); + if (ctrl && key(ImGuiKey_5)) setTab(Tab::Print); + if (tab_ == Tab::Layout && !ctrl && !alt) { + if (key(ImGuiKey_Q)) layTool_ = LayTool::Select; + if (key(ImGuiKey_W)) layTool_ = LayTool::Move; + if (key(ImGuiKey_E)) layTool_ = LayTool::Rotate; + if (key(ImGuiKey_R)) layTool_ = LayTool::Scale; + if (key(ImGuiKey_Tab)) lay_.corners = !lay_.corners, lay_.frames.clear(), lay_.points.clear(); + if (key(ImGuiKey_F1)) showHelp_ = !showHelp_; + if (key(ImGuiKey_F)) frameSelected(); + if (key(ImGuiKey_Home)) frameAll(); + return; } - // Painted, and shown as painted (with rounding or smoothing added since, it isn't). - bool painted(const Object& o) const { return o.picture && o.uvsFit() && !o.hasFinish() && !cutKeys_.count(o.id); } - - keel::ModelId modelOf(const Object& o) { - const bool editingThis = mode_ == Mode::Edit && o.id == active_; - const Mesh& mesh = shownMesh(o); - const uint64_t want = (meshKey(o, mesh) * 2 + editingThis) * 2 + painted(o); - auto it = models_.find(o.id); - if (it != models_.end() && it->second.second == want) return it->second.first; - keel::KmdlModel km; - keel::KmdlMaterial mat{}; - mat.baseColor[0] = mat.baseColor[1] = mat.baseColor[2] = mat.baseColor[3] = 1.0f; - const bool withPicture = painted(o); - if (withPicture) std::snprintf(mat.texture, sizeof mat.texture, "fry:paint:%u", o.id); - km.materials.push_back(mat); - const auto normals = cornerNormals(mesh, o.smooth, o.smoothAngle); - for (size_t f = 0; f < mesh.faces.size(); ++f) { - const auto& face = mesh.faces[f]; - for (const auto& tri : triangulate(mesh, int(f))) - for (int k = 0; k < 3; ++k) { - const size_t corner = size_t(std::find(face.begin(), face.end(), tri[size_t(k)]) - face.begin()); - const glm::vec3 p = mesh.verts[size_t(tri[size_t(k)])], n = normals[f][corner]; - km.indices.push_back(uint32_t(km.vertices.size())); - const glm::vec2 uv = withPicture ? o.uvs[f][corner] : glm::vec2(0.0f); - km.vertices.push_back({{p.x, p.y, p.z}, {n.x, n.y, n.z}, {uv.x, uv.y}, {0, 0}}); - } - } - if (km.vertices.empty()) km.vertices.push_back({}), km.indices = {0, 0, 0}; - km.submeshes.push_back({0, uint32_t(km.indices.size()), 0}); - const glm::vec3 lo = boundsMin(mesh), hi = boundsMax(mesh); - std::copy_n(&lo.x, 3, km.header.boundsMin); - std::copy_n(&hi.x, 3, km.header.boundsMax); - const keel::ModelId id = r_->updateModel(std::format("fry:object{}", o.id), km); - models_[o.id] = {id, want}; - return id; + if (tab_ == Tab::Layout && ctrl && key(ImGuiKey_A)) return layoutSelectAll(); + if (tab_ == Tab::Print && !ctrl && !alt) { + if (key(ImGuiKey_M)) printTool_ = printTool_ == PrintTool::Measure ? PrintTool::None : PrintTool::Measure, measure_.clear(); + if (key(ImGuiKey_L)) printTool_ = printTool_ == PrintTool::LayFlat ? PrintTool::None : PrintTool::LayFlat; + if (key(ImGuiKey_B)) putOnBed(); + if (key(ImGuiKey_Q) || key(ImGuiKey_W) || key(ImGuiKey_E) || key(ImGuiKey_R)) printTool_ = PrintTool::None; } - - const Picture& picture(const Reference& ref) { - auto it = pictures_.find(ref.path); - if (it != pictures_.end()) return it->second; - Picture& p = pictures_[ref.path]; - int w = 0, h = 0, n = 0; - if (stbi_uc* px = stbi_load(ref.path.c_str(), &w, &h, &n, 4)) { - r_->createTexture("fry:picture:" + ref.path, uint32_t(w), uint32_t(h), px); - stbi_image_free(px); - p.ok = true, p.aspect = float(h) / float(std::max(w, 1)); - } else { - say("Couldn't load the picture " + ref.path); - } - return p; + if ((tab_ == Tab::Render || tab_ == Tab::Print) && !ctrl && !alt) { + if (key(ImGuiKey_Q)) setTool(Tool::Select); + if (key(ImGuiKey_W)) setTool(Tool::Move); + if (key(ImGuiKey_E)) setTool(Tool::Rotate); + if (key(ImGuiKey_R)) setTool(Tool::Scale); + if (key(ImGuiKey_F1)) showHelp_ = !showHelp_; + if (key(ImGuiKey_F12)) dialog(5); + if (key(ImGuiKey_F)) frameSelected(); + if (key(ImGuiKey_Home)) frameAll(); + if (key(ImGuiKey_Delete)) deleteSelected(); + return; } - - void loadLogo() { - if (logoTried_) return; - logoTried_ = true; - if (auto png = keel::fs::read("branding/logo.png")) { - int w = 0, h = 0, n = 0; - if (stbi_uc* px = stbi_load_from_memory(png->data(), int(png->size()), &w, &h, &n, 4)) { - logo_ = r_->createTexture("fry:logo", uint32_t(w), uint32_t(h), px); - stbi_image_free(px); - haveLogo_ = true; - } - } + if (ctrl) { + if (key(ImGuiKey_Z)) shift ? redo() : undo(); + if (key(ImGuiKey_Y)) redo(); + if (key(ImGuiKey_S)) shift || path_.empty() ? dialog(2) : void(saveFile(path_)); + if (key(ImGuiKey_O)) shift ? dialog(12) : whenSaved(3); + if (key(ImGuiKey_N)) whenSaved(2); + if (key(ImGuiKey_E)) dialog(3); + if (key(ImGuiKey_Q)) whenSaved(1); + if (key(ImGuiKey_D)) duplicateSelected(); + if (key(ImGuiKey_A)) selectAll(); + if (key(ImGuiKey_I)) invertSelection(); + if (key(ImGuiKey_J)) joinSelected(); + if (key(ImGuiKey_G)) groupSelected(); + if (shift && (key(ImGuiKey_Equal) || key(ImGuiKey_KeypadAdd))) return combineSelected(Combine::Add); + if (shift && (key(ImGuiKey_Minus) || key(ImGuiKey_KeypadSubtract))) return combineSelected(Combine::Subtract); + if (key(ImGuiKey_Equal) || key(ImGuiKey_KeypadAdd)) growSelection(); + if (key(ImGuiKey_Minus) || key(ImGuiKey_KeypadSubtract)) shrinkSelection(); + return; } - void logoImage(float size) { - if (!haveLogo_) return; - const ImVec2 at = ImGui::GetCursorScreenPos(); - ImGui::Dummy(ImVec2(size, size)); - uiImage(ImGui::GetWindowDrawList(), logo_, at, ImVec2(at.x + size, at.y + size)); + if (alt) { + if (key(ImGuiKey_S) && mode_ == Mode::Edit) startPush(); + if (key(ImGuiKey_G) && mode_ == Mode::Edit) return startSlide(); + if (key(ImGuiKey_A)) selectNone(); + if (key(ImGuiKey_Z)) xray_ = !xray_; + if (key(ImGuiKey_H)) + for (Object& o : scene_.objects) o.visible = true; + return; } - // A picture of ours in the UI, read the way ImGui's colours are (not turned linear: too dark). - void uiImage(ImDrawList* dl, keel::TextureId t, ImVec2 a, ImVec2 b) { - const keel::UiRawRead raw = r_->uiRawRead(t); - if (raw.begin) dl->AddCallback(raw.begin, raw.data); - dl->AddImage(ImTextureID(r_->uiTexture(t)), a, b); - if (raw.end) dl->AddCallback(raw.end, raw.data); - } - - float gridStep() const { return std::pow(10.0f, std::floor(std::log10(std::max(viewHeight() / 4.0f, 1e-4f)))); } - - void draw3D(keel::Renderer& r) { - settleView(); - followCuts(); - keel::Camera cam; - cam.origin = eye_ * kUnits, cam.yaw = yaw_, cam.pitch = pitch_, cam.fovY = fov_, cam.shift = shift_, cam.zNear = nearFar_.x, cam.zFar = nearFar_.y; - r.setCamera(cam); - const Look& look = scene_.look; - const bool rendered = shading() == Shading::Rendered; - const float turn = glm::radians(look.sunTurn), up = glm::radians(look.sunHeight); - r.setSun(rendered && look.sunStrength > 0, glm::vec3(std::cos(up) * std::cos(turn), std::cos(up) * std::sin(turn), std::sin(up)), look.sunColor * look.sunStrength, true); - keel::Renderer::Atmosphere atm; - atm.sky = rendered && look.sky ? "skies/day.ksky" : ""; - atm.fogDensity = 0.0f; - r.setAtmosphere(atm); - // Rendered: light from the sky all round. Solid: a studio's — bright from above and the - // front-left, darker below, so every side of a shape reads differently. - const keel::ModelLighting light = - rendered ? keel::ModelLighting{{{0.22f, 0.22f, 0.24f}, {0.13f, 0.13f, 0.15f}, {0.2f, 0.2f, 0.22f}, {0.16f, 0.16f, 0.18f}, {0.3f, 0.31f, 0.35f}, {0.06f, 0.06f, 0.06f}}, 1.0f} - : keel::ModelLighting{{{0.5f, 0.49f, 0.47f}, {0.2f, 0.21f, 0.24f}, {0.72f, 0.71f, 0.69f}, {0.3f, 0.31f, 0.34f}, {0.95f, 0.95f, 0.95f}, {0.08f, 0.08f, 0.09f}}, 0.0f}; - const glm::mat4 toUnits = glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)); - // Painted pictures: laid out again if the shape's faces changed, and sent to the card. - for (Object& o : scene_.objects) - if (o.picture && !o.uvsFit() && !o.hasFinish()) o.uvs = autoUnwrap(o.mesh), touched(o.id); - for (const Object& o : scene_.objects) - if (o.picture) uploadPicture(o, !(paint_.active && paint_.object == o.id)); - if (shading() != Shading::Wire) - for (const Object& o : scene_.objects) { - if (!o.visible || isCutter(o)) continue; - const glm::vec4 tint = painted(o) ? glm::vec4(1.0f) : glm::vec4(glm::pow(o.color, glm::vec3(2.2f)), 1.0f); // (colours are picked as seen: sRGB) - const keel::Surface surface{o.metal, o.rough}; - r.drawModel(modelOf(o), toUnits * objectMatrix(o), tint, nullptr, &light, rendered, nullptr, keel::LiquidStyle::None, &surface); - } - // Editable cutters: see-through, so the hole they make shows (not in a saved picture). - if (shading() != Shading::Wire && !capturing()) - for (const Object& o : scene_.objects) - if (o.visible && isCutter(o)) r.drawModel(modelOf(o), toUnits * objectMatrix(o), glm::vec4(0.9f, 0.35f, 0.3f, 0.3f), nullptr, &light, false); - // Rendered: a ground to stand on and catch the shadows (not part of what you make). - if (rendered && look.ground) { - if (!haveGround_) { - haveGround_ = true; - 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); - const float h = 500.0f; - for (glm::vec2 c : {glm::vec2(-h, -h), glm::vec2(h, -h), glm::vec2(h, h), glm::vec2(-h, h)}) km.vertices.push_back({{c.x, c.y, -0.0005f}, {0, 0, 1}, {0, 0}, {0, 0}}); - km.indices = {0, 1, 2, 0, 2, 3}; - km.submeshes.push_back({0, 6, 0}); - ground_ = r.updateModel("fry:ground", km); - } - r.drawModel(ground_, toUnits, glm::vec4(glm::pow(look.groundColor, glm::vec3(2.2f)), 1.0f), nullptr, &light, false); - } - // Reference pictures: flat, see-through, a little behind the plane through the middle. - for (const Reference& ref : scene_.references) { - if (!ref.visible || (ref.ownViewOnly && !lookingAt(ref.axis))) continue; - const Picture& pic = picture(ref); - if (!pic.ok) continue; - const auto c = referenceCorners(ref, pic.aspect); - const glm::vec3 n = glm::normalize(glm::cross(c[1] - c[0], c[3] - c[0])); - keel::KmdlModel km; - keel::KmdlMaterial mat{}; - std::snprintf(mat.texture, sizeof mat.texture, "fry:picture:%s", ref.path.c_str()); - mat.baseColor[0] = mat.baseColor[1] = mat.baseColor[2] = 1.0f, mat.baseColor[3] = 1.0f; - km.materials.push_back(mat); - const glm::vec2 uv[4] = {{0, 1}, {1, 1}, {1, 0}, {0, 0}}; - for (int side = 0; side < 2; ++side) // (both sides: seen from behind too) - for (int k : {0, 1, 2, 0, 2, 3}) { - const int i = side ? (3 - k + 1) % 4 : k; - const glm::vec3 p = c[size_t(i)] - n * 0.001f, nn = side ? -n : n; - km.indices.push_back(uint32_t(km.vertices.size())); - km.vertices.push_back({{p.x, p.y, p.z}, {nn.x, nn.y, nn.z}, {uv[i].x, uv[i].y}, {0, 0}}); - } - km.submeshes.push_back({0, uint32_t(km.indices.size()), 0}); - const keel::ModelId id = r.updateModel(std::format("fry:ref{}", ref.id), km); - 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}; - r.drawModel(id, toUnits, glm::vec4(1.0f, 1.0f, 1.0f, ref.opacity), nullptr, &flat, false); - } - // The grid on the work plane (the ground, or what a flat view looks at), and the axes. - if (tab_ == Tab::Render ? renderGrid_ : showGrid_) { - glm::vec3 p, n; - workPlane(p, n); - const glm::vec3 u = n.z > 0.5f ? glm::vec3(1, 0, 0) : n.x > 0.5f ? glm::vec3(0, 1, 0) : glm::vec3(1, 0, 0); - const glm::vec3 v = glm::cross(n, u); - const float step = gridStep(); - const int count = 20; - const glm::vec3 centre = target_ - n * glm::dot(target_, n); - const float shade = rendered ? 0.0f : 1.0f; // (dark lines on the sunny ground, light ones in the grey room) - // Two rings: the fine lines round where you look, then lines ten times apart out to - // the horizon (they leave out the middle, so no line is drawn twice). - for (int level = 0; level < 2; ++level) { - const float s = step * (level ? 10.0f : 1.0f), inner = count * step; // (the fine ring's reach) - const float cu = snapTo(glm::dot(centre, u), step * 10), cv = snapTo(glm::dot(centre, v), step * 10); - const float fade = level ? 0.6f : 1.0f; - for (int i = -count; i <= count; ++i) - for (int dir = 0; dir < 2; ++dir) { - const glm::vec3 along = dir ? u : v, across = dir ? v : u; - const float at = (dir ? cv : cu) + float(i) * s, mid = dir ? cu : cv; - const bool major = std::abs(snapTo(at, s * 10) - at) < s * 0.01f; - const bool axis = std::abs(at) < s * 0.01f; - glm::vec4 col = glm::vec4(glm::vec3(shade), (major ? (rendered ? 0.35f : 0.13f) : (rendered ? 0.16f : 0.06f)) * fade); - if (axis) col = dir ? (n.x > 0.5f ? glm::vec4(0.3f, 0.75f, 0.25f, 0.9f) : glm::vec4(0.9f, 0.25f, 0.25f, 0.9f)) - : (n.z > 0.5f ? glm::vec4(0.3f, 0.75f, 0.25f, 0.9f) : glm::vec4(0.25f, 0.45f, 0.95f, 0.9f)); - const float w = pixelSize(target_) * (major || axis ? 1.6f : 1.0f) * kUnits; - const float from = mid - count * s, to = mid + count * s; - const glm::vec3 a = across * at + along * from, b = across * at + along * to; - if (level == 0 || std::abs(at - (dir ? cv : cu)) > inner * 1.0001f) r.drawStreak(a * kUnits, b * kUnits, w, col, keel::kWhiteTexture); - else { // (a far line through the fine ring, which has it already: its two ends) - r.drawStreak(a * kUnits, (across * at + along * (mid - inner)) * kUnits, w, col, keel::kWhiteTexture); - r.drawStreak((across * at + along * (mid + inner)) * kUnits, b * kUnits, w, col, keel::kWhiteTexture); - } - } - } - } - if (tab_ == Tab::Print) drawPrintBed(r, light); - } - - // (the Print tab's: the printer's plate and overhangs) - // On top of the scene: outlines and wires, points, the gizmo, the selection shapes. - // An empty scene: how to start, in the middle of the view (gone once there's anything). - void emptyHints(ImDrawList* dl) { - if (!scene_.objects.empty() || capturing() || tab_ != Tab::Model) return; - const char* lines[] = {touch() ? "Nothing here yet: tap + to add a shape" : "Nothing here yet: Add (menu) for a shape, or draw one (D)", - touch() ? "Hold a finger on the view for more" : "F3 or Ctrl+Space: find any command by name", - touch() ? "" : "Space: tools round the mouse Shift+Space: views", - touch() ? "" : "File > Import: STL, OBJ, 3MF, glTF from elsewhere"}; - const float lh = ImGui::GetTextLineHeightWithSpacing() * 1.25f; - float y = (viewMin_.y + viewMax_.y) * 0.5f - lh * 2, widest = 0; - int shown = 0; - for (const char* l : lines) widest = std::max(widest, ImGui::CalcTextSize(l).x), shown += *l != 0; - const float cx = (viewMin_.x + viewMax_.x) * 0.5f; - dl->AddRectFilled(ImVec2(cx - widest * 0.5f - 18, y - 12), ImVec2(cx + widest * 0.5f + 18, y + lh * float(shown) + 6), IM_COL32(28, 28, 32, 200), 8.0f); - for (int i = 0; i < 4; ++i, y += lh) { - if (!*lines[i]) continue; - const ImVec2 size = ImGui::CalcTextSize(lines[i]); - dl->AddText(ImVec2((viewMin_.x + viewMax_.x - size.x) * 0.5f, y), i == 0 ? IM_COL32(230, 230, 235, 200) : IM_COL32(200, 200, 205, 130), lines[i]); - } - } - void overlays() { - ImDrawList* dl = ImGui::GetBackgroundDrawList(); - dl->PushClipRect(viewMin_, viewMax_); - const ImGuiIO& io = ImGui::GetIO(); - emptyHints(dl); - // Every edge of a mesh (X-ray, wireframe), or just the edges the eye can see. - auto lines = [&](const Object& o, const Mesh& mesh, ImU32 col, float width, bool seenOnly) { - const glm::mat4 m = objectMatrix(o); - const bool hide = seenOnly && !xray_; - std::vector s(mesh.verts.size()); - std::vector ok(mesh.verts.size()); - for (size_t i = 0; i < s.size(); ++i) ok[i] = project(glm::vec3(m * glm::vec4(mesh.verts[i], 1.0f)), s[i]); - for (const auto& e : adjacencyOf(o, mesh).edges) { - if (!ok[size_t(e[0])] || !ok[size_t(e[1])] || (hide && !(seen(o, mesh, e[0]) && seen(o, mesh, e[1])))) continue; - dl->AddLine(s[size_t(e[0])], s[size_t(e[1])], col, width); - } - }; - // The outline round a selected object (where it turns away from you, and open edges). - auto outline = [&](const Object& o, ImU32 col, float width) { - const Mesh& mesh = shownMesh(o); - const glm::mat4 m = objectMatrix(o); - const Adjacency& adj = adjacencyOf(o, mesh); - // (which faces look at the eye: the eye taken into the object's own space, no point moved) - const glm::vec3 eye = glm::vec3(glm::inverse(m) * glm::vec4(eye_, 1.0f)); - std::vector facing(adj.plane.size()); - for (size_t f = 0; f < facing.size(); ++f) facing[f] = glm::dot(glm::vec3(adj.plane[f]), eye) > adj.plane[f].w; - for (const auto& e : adj.edges) { - const bool silhouette = e[3] >= 0 && facing[size_t(e[2])] != facing[size_t(e[3])], open = e[3] < 0 && facing[size_t(e[2])]; - if (!silhouette && !open) continue; - if (!xray_ && !(seen(o, mesh, e[0]) && seen(o, mesh, e[1]))) continue; // (not the edges of a pocket behind its rim) - ImVec2 a, b; - if (project(glm::vec3(m * glm::vec4(mesh.verts[size_t(e[0])], 1.0f)), a) && project(glm::vec3(m * glm::vec4(mesh.verts[size_t(e[1])], 1.0f)), b)) dl->AddLine(a, b, col, width); - } - }; - const bool clean = capturing(); // (a picture being saved: no outlines or gizmos) - Object* ed = editing(); - for (const Object& o : scene_.objects) { - if (!o.visible) continue; - const bool sel = selected_.count(o.id) > 0 && !clean && tab_ != Tab::Paint; - if (ed && o.id == ed->id) continue; - if (isCutter(o)) { - if (!clean) lines(o, shownMesh(o), sel ? kAccent : IM_COL32(235, 95, 85, 150), sel ? 1.6f : 1.0f, false); - continue; - } - if (shading() == Shading::Wire || xray_) lines(o, shownMesh(o), sel ? kAccent : IM_COL32(200, 200, 205, 150), sel ? 1.6f : 1.0f, false); - else if (sel) outline(o, kAccent, 2.5f); - } - if (tab_ == Tab::Paint) { - if (Object* so = find(ledit_.object); so && editLayer() && editLayer()->sticker) { // the sticker's frame and handles - std::array hw; - ImVec2 q[6]; - bool ok = stickerHandles(*so, *editLayer(), hw); - for (int i = 0; ok && i < 6; ++i) ok = project(hw[size_t(i)], q[i]); - if (ok) { - const int hot = ledit_.drag >= 0 ? ledit_.drag : stickerHandle(*so, *editLayer(), io.MousePos); - dl->AddPolyline(q, 4, hot == 4 ? kHot : kAccent, ImDrawFlags_Closed, 2.0f); - const ImVec2 top((q[0].x + q[1].x) * 0.5f, (q[0].y + q[1].y) * 0.5f); - dl->AddLine(top, q[4], kAccent, 1.5f); - dl->AddCircleFilled(q[4], 7.0f, hot == 5 ? kHot : IM_COL32(255, 255, 255, 240)); - dl->AddCircle(q[4], 7.0f, IM_COL32(20, 20, 25, 255)); - for (int i = 0; i < 4; ++i) { - dl->AddRectFilled(ImVec2(q[i].x - 6, q[i].y - 6), ImVec2(q[i].x + 6, q[i].y + 6), hot == i ? kHot : IM_COL32(255, 255, 255, 240)); - dl->AddRect(ImVec2(q[i].x - 6, q[i].y - 6), ImVec2(q[i].x + 6, q[i].y + 6), IM_COL32(20, 20, 25, 255)); - } - } - } - if (stencil_.size() >= 2) { // (the stencil's edge; where paint can't go, shaded) - dl->AddPolyline(stencil_.data(), int(stencil_.size()), stencilOn_ || drawingStencil_ ? kAccent : IM_COL32(200, 200, 205, 120), drawingStencil_ ? 0 : ImDrawFlags_Closed, 2.0f); - if (stencilOn_ && !drawingStencil_ && stencil_.size() >= 3) dl->AddConcavePolyFilled(stencil_.data(), int(stencil_.size()), IM_COL32(255, 160, 90, 28)); - } - if (sculpting_) { - if (mouseInView(io)) dl->AddCircle(io.MousePos, sculptPx_, io.KeyCtrl ? IM_COL32(120, 180, 255, 220) : IM_COL32(255, 255, 255, 200), 40, 1.5f); - } else if (mouseInView(io) && !editLayer() && paintTool_ != PaintTool::Picture) - dl->AddCircle(io.MousePos, brushPx_, paintTool_ == PaintTool::Fill || paintTool_ == PaintTool::Picker ? IM_COL32(255, 255, 255, 90) : IM_COL32(255, 255, 255, 200), 40, 1.5f); - dl->PopClipRect(); - return; - } - if (tab_ == Tab::Layout) { - layoutOverlay(dl); - dl->PopClipRect(); - return; - } - if ((tab_ == Tab::Print || printTool_ == PrintTool::Measure) && !clean) printOverlay(dl, io); - if (tab_ == Tab::Print && showLayer_ && !clean) layerOverlay(dl); - if (clean) { - dl->PopClipRect(); - return; - } - if (ed) { - if (ed->hasFinish() && ed->mirror && shading() != Shading::Wire) // (the mirrored half: faint) - lines(*ed, shownMesh(*ed), IM_COL32(20, 20, 25, 70), 1.0f, false); - lines(*ed, ed->mesh, shading() == Shading::Wire ? IM_COL32(230, 230, 235, 230) : IM_COL32(12, 12, 15, 235), 1.3f, true); - } - if (ed && !ed->creases.empty()) { // (creased edges: smoothing keeps them sharp) - const glm::mat4 m = objectMatrix(*ed); - const int n = int(ed->mesh.verts.size()); - for (auto [a, b] : ed->creases) { - ImVec2 sa, sb; - if (a < n && b < n && (xray_ || (pointSeen(*ed, a) && pointSeen(*ed, b))) && project(glm::vec3(m * glm::vec4(ed->mesh.verts[size_t(a)], 1.0f)), sa) && - project(glm::vec3(m * glm::vec4(ed->mesh.verts[size_t(b)], 1.0f)), sb)) - dl->AddLine(sa, sb, IM_COL32(230, 70, 200, 255), 2.5f); - } - } - if (Object* o = ed) { - const glm::mat4 m = objectMatrix(*o); - if (elem_ == Elem::Vertex) { - for (size_t v = 0; v < o->mesh.verts.size(); ++v) { - ImVec2 s; - const bool sel = selVerts_.count(int(v)); - if (!project(glm::vec3(m * glm::vec4(o->mesh.verts[v], 1.0f)), s) || (!sel && !pointSeen(*o, int(v)))) continue; - dl->AddRectFilled(ImVec2(s.x - 3, s.y - 3), ImVec2(s.x + 3, s.y + 3), sel ? kAccent : IM_COL32(15, 15, 18, 255)); - } - } else if (elem_ == Elem::Edge) { - for (auto [a, b] : selEdges_) { - ImVec2 sa, sb; - if (a < int(o->mesh.verts.size()) && b < int(o->mesh.verts.size()) && project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(a)], 1.0f)), sa) && - project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(b)], 1.0f)), sb)) - dl->AddLine(sa, sb, kAccent, 3.0f); - } - } else { // the selected faces the eye can see: an orange skin, a dot in the middle - const glm::mat3 nm = glm::transpose(glm::inverse(glm::mat3(m))); - for (int f : selFaces_) { - if (f >= int(o->mesh.faces.size())) continue; - const glm::vec3 c(m * glm::vec4(faceCenter(o->mesh, f), 1.0f)); - const bool front = glm::dot(nm * faceNormal(o->mesh, f), eye_ - c) > 0; - if (!xray_ && !front) continue; - const auto& fv = o->mesh.faces[size_t(f)]; - if (!xray_ && !std::all_of(fv.begin(), fv.end(), [&](int v) { return pointSeen(*o, v); }) && !visible(*o, c)) continue; - std::vector poly(fv.size()); - bool ok = true; - for (size_t k = 0; k < fv.size(); ++k) ok &= project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(fv[k])], 1.0f)), poly[k]); - if (ok) dl->AddConcavePolyFilled(poly.data(), int(poly.size()), IM_COL32(255, 120, 40, 70)); - ImVec2 s; - if (project(c, s)) dl->AddCircleFilled(s, 3.5f, kAccent); - } - } - // The loop cut to be: across each face of the ring. - if (tool_ == Tool::LoopCut && cutHover_.first >= 0) - for (const auto& q : ringQuads(o->mesh, cutHover_)) - for (int c = 1; c <= cuts_; ++c) { - const float t = float(c) / float(cuts_ + 1); - const glm::vec3 a = glm::mix(o->mesh.verts[size_t(q[0])], o->mesh.verts[size_t(q[1])], t), b = glm::mix(o->mesh.verts[size_t(q[3])], o->mesh.verts[size_t(q[2])], t); - ImVec2 sa, sb; - if (project(glm::vec3(m * glm::vec4(a, 1.0f)), sa) && project(glm::vec3(m * glm::vec4(b, 1.0f)), sb)) dl->AddLine(sa, sb, kHot, 2.5f); - } - } - // The knife's line: its points, on to the mouse; cutting through, on past the ends. - if (tool_ == Tool::Knife && (!knifePts_.empty() || knifeDown_)) { + if (key(ImGuiKey_F1)) showHelp_ = !showHelp_; + if (key(ImGuiKey_F12)) dialog(5); + if (key(ImGuiKey_Q)) setTool(Tool::Select); + if (key(ImGuiKey_W)) setTool(Tool::Move); + if (key(ImGuiKey_E) && shift && mode_ == Mode::Edit) return creaseSelected(); + if (key(ImGuiKey_E)) setTool(Tool::Rotate); + if (key(ImGuiKey_R)) setTool(Tool::Scale); + if (key(ImGuiKey_D)) setTool(Tool::Draw); + if (key(ImGuiKey_X)) setTool(Tool::Extrude); + if (key(ImGuiKey_I)) setTool(Tool::Inset); + if (key(ImGuiKey_B)) setTool(Tool::Bevel); + if (key(ImGuiKey_C)) setTool(Tool::LoopCut); + if (key(ImGuiKey_K)) setTool(Tool::Knife); + if (key(ImGuiKey_O) && !shift) setTool(Tool::Hole); + if (key(ImGuiKey_Tab)) mode_ == Mode::Edit ? leaveEdit() : enterEdit(); + if (key(ImGuiKey_Delete) || key(ImGuiKey_Backspace)) deleteSelected(); + if (key(ImGuiKey_F)) frameSelected(); + if (key(ImGuiKey_Home)) frameAll(); + if (key(ImGuiKey_Z)) shading_ = shift ? Shading::Wire : shading_ == Shading::Solid ? Shading::Rendered : Shading::Solid; + if (key(ImGuiKey_Enter) || key(ImGuiKey_KeypadEnter)) { + if (tool_ == Tool::Draw) finishShape(); + if (tool_ == Tool::Knife && knifePts_.size() >= 2) { std::vector pts = knifePts_; - if (knifePts_.empty()) pts.push_back(knifePress_); - if (mouseInView(io) || knifeDown_) pts.push_back(knifeEnd(io)); - for (size_t i = 0; i + 1 < pts.size(); ++i) dl->AddLine(pts[i], pts[i + 1], kHot, 2.0f); - if (knifeThrough_ && pts.size() >= 2) - for (int end = 0; end < 2; ++end) { - const ImVec2 a = end ? pts.back() : pts.front(), b = end ? pts[pts.size() - 2] : pts[1]; - const float dx = a.x - b.x, dy = a.y - b.y, l = std::max(std::hypot(dx, dy), 1e-3f); - for (int k = 0; k < 60; k += 2) - dl->AddLine(ImVec2(a.x + dx / l * float(k) * 12, a.y + dy / l * float(k) * 12), ImVec2(a.x + dx / l * float(k + 1) * 12, a.y + dy / l * float(k + 1) * 12), - (kHot & 0x00FFFFFF) | 0x90000000, 1.5f); - } - for (const ImVec2& p : knifePts_) dl->AddCircleFilled(p, 4.0f, kHot); + knifePts_.clear(); + knifeApply(active_, pts); } - // The hole to be. - if (tool_ == Tool::Hole && (mouseInView(io) || hole_.active)) { - const auto ring = holePreview(io); - std::vector pts; - for (const glm::vec3& w : ring) { - ImVec2 sp; - if (project(w, sp)) pts.push_back(sp); - } - if (pts.size() >= 3) dl->AddPolyline(pts.data(), int(pts.size()), hole_.active ? kHot : IM_COL32(255, 255, 255, 200), ImDrawFlags_Closed, 2.0f); - } - if (boxing_ && !touchOrbit_ && std::hypot(io.MousePos.x - boxStart_.x, io.MousePos.y - boxStart_.y) > 4) { - dl->AddRectFilled(boxStart_, io.MousePos, IM_COL32(255, 140, 60, 30)); - dl->AddRect(boxStart_, io.MousePos, kAccent); - } - if (lassoing_ && lasso_.size() > 1) dl->AddPolyline(lasso_.data(), int(lasso_.size()), kAccent, ImDrawFlags_Closed, 1.5f); - if (selShape_ == SelShape::Brush && tool_ == Tool::Select && mode_ == Mode::Edit && mouseInView(io)) - dl->AddCircle(io.MousePos, brushRadius_, painting_ ? kAccent : IM_COL32(255, 255, 255, 160), 32, 1.5f); - if (tool_ == Tool::Draw) { - ImVec2 prev, s; - for (size_t i = 0; i < drawPts_.size(); ++i) { - if (!project(drawPts_[i], s)) continue; - if (i) dl->AddLine(prev, s, kAccent, 2.0f); - dl->AddCircleFilled(s, i == 0 ? 6.0f : 4.0f, i == 0 ? IM_COL32(255, 255, 255, 255) : kAccent); - prev = s; - } - if (mouseInView(io) && !touch()) // (a finger isn't hovering anywhere) - if (auto p = drawPoint(io); p && project(*p, s)) { - if (!drawPts_.empty()) dl->AddLine(prev, s, IM_COL32(255, 160, 90, 180), 1.5f); - dl->AddCircle(s, 5.0f, IM_COL32(255, 255, 255, 220), 0, 1.5f); - if (!drawPts_.empty()) { // (the line's length by the mouse, and what's typed) - const std::string label = std::format("{:.4g} m{}", glm::distance(drawPts_.back(), *p), sketchTyped_.empty() ? " (type a length, Enter; Shift: 15 deg steps)" : " typed " + sketchTyped_); - const ImVec2 at(io.MousePos.x + 16, io.MousePos.y + 14), size = ImGui::CalcTextSize(label.c_str()); - dl->AddRectFilled(ImVec2(at.x - 5, at.y - 3), ImVec2(at.x + size.x + 5, at.y + size.y + 3), IM_COL32(24, 24, 28, 225), 4.0f); - dl->AddText(at, sketchTyped_.empty() ? IM_COL32(235, 235, 240, 255) : kAccent, label.c_str()); - } - } - } - gizmo(dl, io); - dl->PopClipRect(); } + if (mode_ == Mode::Edit) { + if (key(ImGuiKey_M)) mergeNow(); + if (key(ImGuiKey_L)) selectLinked(); + if (key(ImGuiKey_P)) separateSelected(); + if (key(ImGuiKey_1)) elem_ = Elem::Vertex, clearElements(); + if (key(ImGuiKey_2)) elem_ = Elem::Edge, clearElements(); + if (key(ImGuiKey_3)) elem_ = Elem::Face, clearElements(); + } + if (key(ImGuiKey_H) && shift && !selected_.empty()) showOnly(selected_); // (the rest hidden) + else if (key(ImGuiKey_H)) + for (Object& o : scene_.objects) + if (selected_.count(o.id) && mode_ == Mode::Object) o.visible = false; + if ((key(ImGuiKey_Slash) || key(ImGuiKey_KeypadDivide)) && mode_ == Mode::Object && !selected_.empty()) showOnly(selected_); + if (key(ImGuiKey_O) && shift) softSel_ = !softSel_, say(softSel_ ? "Soft selection on: points near follow (the wheel while dragging: how far)" : "Soft selection off"); + if (key(ImGuiKey_Keypad1)) setView(shift ? View::Back : View::Front); + if (key(ImGuiKey_Keypad3)) setView(shift ? View::Right : View::Left); + if (key(ImGuiKey_Keypad7)) setView(shift ? View::Bottom : View::Top); + if (key(ImGuiKey_Keypad5)) view_ == View::Perspective ? setView(View::Front) : leaveFlatView(); +} - void gizmo(ImDrawList* dl, const ImGuiIO& io) { - const int hot = mouseInView(io) || drag_.active ? hoverHandle(io.MousePos) : -1; - if (softSel_ && mode_ == Mode::Edit && (tool_ == Tool::Move || tool_ == Tool::Rotate || tool_ == Tool::Scale) && !selectedPoints().empty()) { - const glm::vec3 at = drag_.active ? drag_.pivot : pivotOfSelection(); - ImVec2 c; - if (project(at, c)) dl->AddCircle(c, softReach_ / std::max(pixelSize(at), 1e-9f), keel::ui::accent(0.8f), 64, 1.5f); - } - if (tool_ == Tool::Extrude || tool_ == Tool::Inset) { - glm::vec3 at, n; - if (!faceHandle(at, n)) return; - ImVec2 a, b; - if (!project(at, a) || !project(at + n * pixelSize(at) * 90.0f, b)) return; - const ImU32 col = hot == 0 ? kHot : IM_COL32(255, 255, 255, 255); - dl->AddLine(a, b, col, 3.0f); - if (tool_ == Tool::Extrude) dl->AddCircleFilled(b, 7.0f, col); - else dl->AddRectFilled(ImVec2(b.x - 6, b.y - 6), ImVec2(b.x + 6, b.y + 6), col); - return; - } - if (tool_ == Tool::Bevel) { - ImVec2 c, h; - if (!bevelHandle(c, h)) return; - const ImU32 col = hot == 0 ? kHot : IM_COL32(255, 255, 255, 255); - dl->AddLine(c, h, (col & 0x00FFFFFF) | 0x90000000, 2.0f); - dl->AddCircleFilled(h, 9.0f, col); - drawIcon(dl, Icon::Bevel, ImVec2(h.x - 7, h.y - 7), 14.0f, IM_COL32(30, 30, 35, 255)); - return; - } - if (tool_ != Tool::Move && tool_ != Tool::Rotate && tool_ != Tool::Scale) return; - if (!hasSelection()) return; - const Axes axes = drag_.active ? drag_.axes : gizmoAxes(); - if (tool_ == Tool::Scale) { // the box round the selection, a handle on each side - const auto [lo, hi] = selectionBox(axes); - for (int e = 0; e < 12; ++e) { - const int k = e / 4, u = (k + 1) % 3, v = (k + 2) % 3; - glm::vec3 a, b; - a[k] = lo[k], b[k] = hi[k]; - a[u] = b[u] = e & 1 ? hi[u] : lo[u]; - a[v] = b[v] = e & 2 ? hi[v] : lo[v]; - ImVec2 sa, sb; - if (project(fromFrame(axes, a), sa) && project(fromFrame(axes, b), sb)) dl->AddLine(sa, sb, IM_COL32(255, 255, 255, 110), 1.0f); - } - for (int h : {0, 1, 2, 4, 5, 6, 3}) { - ImVec2 s; - if (!project(scaleHandle(axes, lo, hi, h), s)) continue; - const ImU32 col = hot == h ? kHot : h == 3 ? IM_COL32(255, 255, 255, 235) : kAxisCol[h < 3 ? h : h - 4]; - dl->AddRectFilled(ImVec2(s.x - 6, s.y - 6), ImVec2(s.x + 6, s.y + 6), col); - dl->AddRect(ImVec2(s.x - 6, s.y - 6), ImVec2(s.x + 6, s.y + 6), IM_COL32(20, 20, 25, 200)); - } - return; - } - const glm::vec3 p = drag_.active ? (tool_ == Tool::Rotate ? drag_.pivot : selectionMiddle(axes)) : (tool_ == Tool::Rotate ? rotatePivot(axes) : selectionMiddle(axes)); - ImVec2 c; - if (!project(p, c)) return; - const float len = pixelSize(p) * 90.0f; - if (tool_ == Tool::Rotate) { - for (int k = 0; k < 3; ++k) { - const glm::vec3 u = glm::normalize(glm::cross(axes[size_t(k)], std::abs(axes[size_t(k)].z) < 0.9f ? glm::vec3(0, 0, 1) : glm::vec3(1, 0, 0))); - const glm::vec3 v = glm::cross(axes[size_t(k)], u); - ImVec2 prev; - bool havePrev = false; - for (int i = 0; i <= 64; ++i) { - const float a = 2 * kPi * float(i) / 64.0f; - const glm::vec3 w = p + (u * std::cos(a) + v * std::sin(a)) * len; - ImVec2 s; - if (!project(w, s)) { - havePrev = false; - continue; - } - const bool front = glm::dot(w - p, eye_ - p) > -0.2f * len * glm::length(eye_ - p); - if (havePrev) dl->AddLine(prev, s, hot == k ? kHot : (front ? kAxisCol[k] : (kAxisCol[k] & 0x00FFFFFF) | 0x50000000), hot == k ? 3.5f : 2.5f); - prev = s, havePrev = true; - } - } - dl->AddCircle(c, 102.0f, hot == 3 ? kHot : IM_COL32(255, 255, 255, 160), 64, 2.0f); - return; - } - for (int k = 0; k < 3; ++k) { - ImVec2 e; - if (!project(p + axes[size_t(k)] * len, e)) continue; - const ImU32 col = hot == k ? kHot : kAxisCol[k]; - dl->AddLine(c, e, col, 3.0f); - const float dx = e.x - c.x, dy = e.y - c.y, l = std::max(std::hypot(dx, dy), 1.0f); - const ImVec2 dir(dx / l, dy / l), side(-dir.y, dir.x); - dl->AddTriangleFilled(ImVec2(e.x + dir.x * 14, e.y + dir.y * 14), ImVec2(e.x + side.x * 6, e.y + side.y * 6), ImVec2(e.x - side.x * 6, e.y - side.y * 6), col); - } - dl->AddRect(ImVec2(c.x - 8, c.y - 8), ImVec2(c.x + 8, c.y + 8), hot == 3 ? kHot : IM_COL32(255, 255, 255, 230), 0, 0, 2.0f); - } - - // ───────────────────────────── panels ───────────────────────────── - - void setupUi() { - keel::ui::setPixelScale(touchScale()); - if (iniPath_.empty()) { - touchMode_ = fry_touch.b(); - iniPath_ = keel::prefPath() + (touchMode_ ? "layout_phone.ini" : "layout_v3.ini"); - ImGui::GetIO().IniFilename = iniPath_.c_str(); - if (stdfs::exists(iniPath_)) ImGui::LoadIniSettingsFromDisk(iniPath_.c_str()); - } - if (fry_theme.i() != appliedTheme_) appliedTheme_ = fry_theme.i(), keel::ui::setAppearance(keel::ui::Appearance(std::clamp(appliedTheme_, 0, 2))); - if (styled_) return; - styled_ = true; - ImGui::GetStyle().FontScaleDpi = 0.92f; // (the look itself: keel/ui, Niveus) - } - - // The least a panel can be and still read right (its words, its rows), by the font's size. - static ImVec2 panelMin(std::string_view name) { - const float f = ImGui::GetFontSize(); - if (name == "Toolbox") return {15.5f * f, 9 * f}; - if (name == "Tool options") return {15.5f * f, 8 * f}; - if (name == "Properties") return {23 * f, 12 * f}; - if (name == "Layers") return {20 * f, 8 * f}; - if (name == "Picture") return {18 * f, 14 * f}; - if (name == "Script") return {30 * f, 16 * f}; - return {0, 0}; - } - // Docked panels kept at their least size: where a split leaves one side smaller, the split - // moves (when the other side has room to give). ImGui's own splitters know only one least - // size for everything. Gives what the node needs. - ImVec2 keepMinSizes(ImGuiDockNode* n) { - if (!n) return {0, 0}; - if (n->IsLeafNode()) { - const float f = ImGui::GetFontSize(); - ImVec2 need = n->IsCentralNode() ? ImVec2(16 * f, 12 * f) : ImVec2(0, 0); - for (ImGuiWindow* w : n->Windows) need = ImMax(need, panelMin(w->Name)); - if (need.y > 0 && !n->IsCentralNode()) need.y += ImGui::GetFrameHeight(); // (its tab) - return need; - } - ImGuiDockNode *a = n->ChildNodes[0], *b = n->ChildNodes[1]; - const ImVec2 na = keepMinSizes(a), nb = keepMinSizes(b); - if (!a->IsVisible) return nb; - if (!b->IsVisible) return na; - const int axis = n->SplitAxis; - const float gap = ImGui::GetStyle().DockingSeparatorSize, room = n->Size[axis] - gap; - float sa = a->Size[axis]; - if (sa < na[axis] && room - na[axis] >= nb[axis]) sa = na[axis]; - else if (room - sa < nb[axis] && room - nb[axis] >= na[axis]) sa = room - nb[axis]; - if (sa != a->Size[axis]) a->SizeRef[axis] = sa, b->SizeRef[axis] = room - sa; - ImVec2 need = ImMax(na, nb); - need[axis] = na[axis] + nb[axis] + gap; - return need; - } - - void defaultLayout(ImGuiID dock) { - layoutDone_ = true; - ImGuiDockNode* node = ImGui::DockBuilderGetNode(dock); - if (!touch() && !std::exchange(layoutAgain_, false) && node && (node->IsSplitNode() || node->Windows.Size)) return; // (the user's own, from the .ini) - ImGui::DockBuilderRemoveNodeChildNodes(dock); - ImGui::DockBuilderSetNodeSize(dock, ImGui::GetMainViewport()->WorkSize); // (splits are shares of this) - ImGuiID center = dock; - if (touch() && portrait_) { // a phone held upright: the view on top, the panels as tabs under it - const ImGuiID bottom = ImGui::DockBuilderSplitNode(center, ImGuiDir_Down, 0.36f, nullptr, ¢er); - const ImVec2 size = ImGui::GetMainViewport()->WorkSize; - ImGui::DockBuilderSetNodeSize(bottom, ImVec2(size.x, size.y * 0.36f)); - for (const char* w : {"Toolbox", "Tool options", "Properties", "Layers", "Picture"}) ImGui::DockBuilderDockWindow(w, bottom); - ImGui::DockBuilderFinish(dock); - showToolbox_ = true; // (its tab first) - return; - } - if (touch()) { // a phone: tools down the left, the panels on the right (shown when asked for) - ImGuiID left = ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.2f, nullptr, ¢er); - const ImGuiID leftBottom = ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.45f, nullptr, &left); - const ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.36f, nullptr, ¢er); - ImGui::DockBuilderDockWindow("Toolbox", left); - ImGui::DockBuilderDockWindow("Tool options", leftBottom); - ImGui::DockBuilderDockWindow("Properties", right); - ImGui::DockBuilderDockWindow("Layers", right); - ImGui::DockBuilderDockWindow("Picture", right); - return ImGui::DockBuilderFinish(dock); - } - // (shares of the window, but never less than the panels' least sizes) - const float wide = ImGui::GetMainViewport()->WorkSize.x, leftShare = std::min(0.3f, std::max(0.14f, (panelMin("Toolbox").x + 8) / wide)); - const float rightShare = std::min(0.45f, std::max(0.22f, (panelMin("Properties").x + 8) / (wide * (1 - leftShare)))); - ImGuiID left = ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, leftShare, nullptr, ¢er); - const ImGuiID leftBottom = ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.5f, nullptr, &left); - ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, rightShare, nullptr, ¢er); - const ImGuiID rightBottom = ImGui::DockBuilderSplitNode(right, ImGuiDir_Down, 0.66f, nullptr, &right); - const ImGuiID bottom = ImGui::DockBuilderSplitNode(center, ImGuiDir_Down, 0.04f, nullptr, ¢er); - const ImGuiID picture = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.45f, nullptr, ¢er); // (the Paint tab's) - ImGui::DockBuilderDockWindow("Picture", picture); - ImGui::DockBuilderDockWindow("Script", picture); - ImGui::DockBuilderDockWindow("Toolbox", left); - ImGui::DockBuilderDockWindow("Tool options", leftBottom); - ImGui::DockBuilderDockWindow("Layers", right); - ImGui::DockBuilderDockWindow("Properties", rightBottom); - ImGui::DockBuilderDockWindow("Status", bottom); - if (ImGuiDockNode* b = ImGui::DockBuilderGetNode(bottom)) b->SetLocalFlags(ImGuiDockNodeFlags_NoTabBar); - ImGui::DockBuilderFinish(dock); - } - - // ───────────────────────────── commands ───────────────────────────── - - // Everything that can be done, by name: for the command search, keys of your own, the pies. - void buildCommands() { - commands_.clear(); - auto add = [&](std::string id, std::string name, std::string group, std::string keys, std::function run, std::function ok = {}, std::string words = {}) { - commands_.push_back({std::move(id), std::move(name), std::move(group), std::move(keys), std::move(run), std::move(ok), std::move(words)}); - }; - auto editing = [this] { return mode_ == Mode::Edit; }; - auto picked = [this] { return !selected_.empty(); }; - auto many = [this] { return selected_.size() >= 2 && mode_ == Mode::Object; }; - auto model = [this] { return tab_ == Tab::Model; }; - add("file.new", "New", "File", "Ctrl+N", [this] { whenSaved(2); }, {}, "empty start"); - add("file.open", "Open...", "File", "Ctrl+O", [this] { whenSaved(3); }, {}, "load fry"); - add("file.import", "Import a model (STL, OBJ, 3MF, glTF)...", "File", "Ctrl+Shift+O", [this] { dialog(12); }, {}, "load mesh stl obj 3mf"); - add("file.save", "Save", "File", "Ctrl+S", [this] { path_.empty() ? dialog(2) : void(saveFile(path_)); }); - add("file.saveas", "Save as...", "File", "Ctrl+Shift+S", [this] { dialog(2); }); - add("file.export", "Export (GLB, glTF, OBJ, STL, PLY, 3MF, FBX)...", "File", "Ctrl+E", [this] { dialog(3); }, {}, "stl obj gltf glb ply fbx 3mf print"); - add("file.picture", "Save a picture of the view...", "File", "F12", [this] { dialog(5); }, {}, "screenshot render png"); - add("file.recover", "Recover autosaved work...", "File", "", [this] { findAutosaves(), showRecover_ = !recover_.empty(); }); - add("file.quit", "Quit", "File", "Ctrl+Q", [this] { whenSaved(1); }, {}, "exit close"); - add("edit.undo", "Undo", "Edit", "Ctrl+Z", [this] { undo(); }, [this] { return !undo_.empty(); }); - add("edit.redo", "Redo", "Edit", "Ctrl+Y", [this] { redo(); }, [this] { return !redo_.empty(); }); - add("edit.history", "Undo history", "Edit", "", [this] { showHistory_ = true; }, {}, "steps back"); - add("edit.duplicate", "Duplicate", "Edit", "Ctrl+D", [this] { duplicateSelected(); }, [=, this] { return picked() && mode_ == Mode::Object; }, "copy clone"); - add("edit.join", "Join into one", "Edit", "Ctrl+J", [this] { joinSelected(); }, many, "merge objects"); - add("edit.delete", "Delete", "Edit", "Del", [this] { deleteSelected(); }, {}, "remove erase"); - add("edit.shape", "Edit the shape / stop", "Edit", "Tab", [this] { mode_ == Mode::Edit ? leaveEdit() : enterEdit(); }, [=, this] { return editing() || picked(); }, "edit mode points faces"); - add("select.all", "Select all", "Select", "Ctrl+A", [this] { selectAll(); }); - add("select.none", "Select none", "Select", "Alt+A", [this] { selectNone(); }, {}, "deselect"); - add("select.invert", "Invert the selection", "Select", "Ctrl+I", [this] { invertSelection(); }); - add("select.grow", "Select more (grow)", "Select", "Ctrl +", [this] { growSelection(); }, editing); - add("select.shrink", "Select less (shrink)", "Select", "Ctrl -", [this] { shrinkSelection(); }, editing); - add("select.linked", "Select everything joined to it", "Select", "L", [this] { selectLinked(); }, editing, "linked connected"); - add("select.similar", "Select similar faces (facing the same way)", "Select", "Shift+G", [this] { selectSimilar(); }, editing, "same normal size"); - add("select.loop", "Select whole loops (of the picked edges)", "Select", "", [this] { loopsOfSelected(false); }, editing, "edge loop"); - add("select.ring", "Select rings (across the picked edges)", "Select", "", [this] { loopsOfSelected(true); }, editing, "edge ring"); - for (const auto& [t, n] : std::initializer_list>{{SelShape::Box, "box"}, {SelShape::Brush, "brush"}, {SelShape::Lasso, "lasso"}}) - add(std::string("select.with.") + n, std::string("Select with a ") + n, "Select", "", [this, t] { selShape_ = t; }); - for (const ShapeRecipe& r : shapeRecipes()) - add(std::string("add.") + r.id, std::string("Add ") + (r.starter ? "a starter " : "a ") + r.name, "Add", "", [this, id = std::string(r.id)] { setTab(Tab::Model), addShape(id); }, {}, - r.starter ? "starter model" : "shape primitive"); - add("add.text", "Add text (3D letters)...", "Add", "", [this] { setTab(Tab::Model), showText_ = true; }, {}, "font letters words label name"); - add("add.draw", "Draw a shape", "Add", "D", [this] { if (mode_ == Mode::Edit) leaveEdit(); setTool(Tool::Draw); }, {}, "sketch outline polygon"); - add("add.picture", "Add a reference picture...", "Add", "", [this] { dialog(4); }, {}, "image background blueprint"); - add("object.goto", "Go to it (the camera)", "Object", "F", [this] { goTo(selected_); }, picked, "frame focus"); - add("object.copies", "Make copies (in a row, round, along a path)...", "Object", "", [this] { showCopies_ = true; }, picked, "array repeat pattern circular curve"); - add("object.group", "Group into a folder", "Object", "Ctrl+G", [this] { groupSelected(); }, picked); - add("object.add", "Add them together", "Object", "Ctrl+Shift++", [this] { combineSelected(Combine::Add); }, many, "boolean union"); - add("object.subtract", "Take the others out of it", "Object", "Ctrl+Shift+-", [this] { combineSelected(Combine::Subtract); }, many, "boolean difference cut"); - add("object.overlap", "Keep only where they overlap", "Object", "", [this] { combineSelected(Combine::Overlap); }, many, "boolean intersect"); - add("object.thicken", "Make it thick (or hollow)...", "Object", "", [this] { showThicken_ = true; }, picked, "shell solidify wall"); - add("object.cleanflat", "Join flat faces side by side", "Object", "", [this] { cleanFlatFaces(); }, picked, "dissolve tidy"); - add("object.spin", "Spin (turn its outline round an axis)...", "Object", "", [this] { showSpin_ = true; }, [this] { return !selected_.empty(); }, "lathe screw revolve"); - add("tools.measure", "Measure between two points", "Tools", "", [this] { - if (tab_ != Tab::Model && tab_ != Tab::Print) setTab(Tab::Model); - if (mode_ == Mode::Edit) leaveEdit(); - printTool_ = PrintTool::Measure, measure_.clear(); - say("Measure: click two points (corners catch the mouse); Esc: done"); - }, {}, "ruler distance length dimension how far"); - add("object.repair", "Repair for printing (close holes, fix faces)", "Print", "", [this] { repairSelected(); }, picked, "fix manifold watertight holes"); - add("object.scale", "Print size: scale, fit the plate, fix units...", "Print", "", [this] { setTab(Tab::Print), showPrintSize_ = true; }, picked, "resize fit dimensions percent scaler"); - for (const ToolInfo& t : kTools) - add(std::string("tool.") + t.name, std::string("Tool: ") + t.name, "Tools", t.key, [this, tool = t.tool, obj = t.objectMode] { - if (tab_ != Tab::Model) setTab(Tab::Model); - if (!obj && mode_ != Mode::Edit) enterEdit(); - setTool(tool); - }); - add("shape.subdivide", "Cut every face in four", "Shape", "", [this] { subdivideNow(false); }, editing, "subdivide"); - add("shape.smoothall", "Smooth it all (cut and round)", "Shape", "", [this] { subdivideNow(true); }, editing, "subdivision surface"); - add("shape.merge", "Merge points", "Shape", "M", [this] { mergeNow(); }, editing, "weld collapse"); - add("shape.face", "Make a face", "Shape", "", [this] { makeFaceNow(); }, editing, "fill"); - add("shape.flip", "Flip faces", "Shape", "", [this] { flipNow(); }, editing, "normals reverse"); - add("shape.smooth", "Smooth points", "Shape", "", [this] { smoothPointsNow(); }, editing, "relax"); - add("shape.push", "Push out / pull in...", "Shape", "Alt+S", [this] { startPush(); }, editing, "shrink fatten inflate"); - add("shape.bridge", "Bridge two faces", "Shape", "", [this] { bridgeNow(); }, editing, "tunnel connect"); - add("shape.soft", "Soft selection on / off", "Shape", "Shift+O", [this] { softSel_ = !softSel_; }, {}, "proportional falloff"); - add("shape.separate", "Make into its own object", "Shape", "P", [this] { separateSelected(); }, editing, "separate split"); - add("paint.mirror", "Paint both sides (mirrored)", "Paint", "", [this] { paintMirror_ = !paintMirror_, say(paintMirror_ ? "Painting both sides" : "Painting one side"); }, {}, "symmetry symmetric"); - add("paint.stencil", "Draw a paint stencil (paint only inside it)", "Paint", "", [this] { setTab(Tab::Paint), drawingStencil_ = true, stencil_.clear(); }, {}, "mask masking tape area"); - add("object.cutEditable", "Cut out, editable (the cutters stay, the hole follows them)", "Object", "", [this] { cutEditable(); }, many, "boolean subtract difference hole non-destructive live"); - add("shape.crease", "Crease the picked edges (stay sharp when smoothed)", "Shape", "Shift+E", [this] { creaseSelected(); }, editing, "sharp hard edge subdivision smooth"); - add("shape.slide", "Slide the points along their edges...", "Shape", "Alt+G", [this] { startSlide(); }, editing, "edge slide vertex slide"); - add("shape.mirror", "Mirror editing (both sides change together)", "Shape", "", [this] { liveMirror_ = !liveMirror_, say(liveMirror_ ? "Mirror editing on: both sides change together" : "Mirror editing off"); }, {}, "symmetry symmetric x"); - add("view.solid", "Show: solid", "View", "Z", [this] { shading_ = Shading::Solid; }); - add("view.rendered", "Show: rendered (sky, sun, shadows)", "View", "Z", [this] { shading_ = Shading::Rendered; }); - add("view.wire", "Show: wireframe", "View", "Shift+Z", [this] { shading_ = Shading::Wire; }); - add("view.xray", "X-ray on / off", "View", "Alt+Z", [this] { xray_ = !xray_; }, {}, "see through"); - add("view.grid", "Grid on / off", "View", "", [this] { showGrid_ = !showGrid_; }); - add("view.persp", "View: perspective", "View", "Numpad 5", [this] { leaveFlatView(); }); - for (const auto& [v, n, k] : std::initializer_list>{ - {View::Front, "front", "Numpad 1"}, {View::Back, "back", "Shift+Numpad 1"}, {View::Left, "left side", "Numpad 3"}, {View::Right, "right side", "Shift+Numpad 3"}, - {View::Top, "top", "Numpad 7"}, {View::Bottom, "bottom", "Shift+Numpad 7"}}) - add(std::string("view.") + n, std::string("View: ") + n, "View", k, [this, v] { setView(v); }, {}, "orthographic camera"); - add("view.frame", "Frame the selected", "View", "F", [this] { frameSelected(); }, {}, "zoom focus"); - add("view.all", "Frame everything", "View", "Home", [this] { frameAll(); }, {}, "zoom extents"); - add("view.only", "Show only the selected (again: all)", "View", "/", [this] { showOnly(selected_); }, picked, "isolate local view hide"); - add("view.unhide", "Show everything", "View", "Alt+H", [this] { for (Object& o : scene_.objects) o.visible = true; }, {}, "unhide"); - add("view.layout", "Put the panels back as they were", "View", "", [this] { resetLayout(); }, {}, "reset layout workspace default panels"); - add("view.workspace", "Workspaces (save the panels as they are)...", "View", "", [this] { showWorkspaces_ = true; }, {}, "layout save panels"); - for (int i = 0; i < 3; ++i) - add(std::string("view.look") + char('0' + i), std::string("Appearance: ") + (i == 0 ? "like the desktop" : i == 1 ? "dark" : "light"), "View", "", - [i] { keel::execute(std::format("fry_theme {}", i)); }, {}, "theme colours"); - const char* tabs[] = {"Model", "Paint", "Layout", "Render", "Print"}; - for (int i = 0; i < 5; ++i) - add(std::string("tab.") + tabs[i], std::string("Go to ") + tabs[i], "Tabs", std::format("Ctrl+{}", i + 1), [this, i] { setTab(Tab(i)); }, {}, i == 1 ? "sculpt texture" : ""); - add("script.panel", "The Script panel", "Script", "", [this] { showScript_ = !showScript_; }, {}, "lua code"); - add("script.run", "Run the script", "Script", "F5", [this] { runScriptNow(); }, [this] { return currentScript() != nullptr; }); - add("script.new", "New script", "Script", "", [this] { addScript("Script", kFirstScript); }); - add("script.plugins", "Plug-ins...", "Script", "", [this] { showPlugins_ = true; }, {}, "addons extensions"); - add("help.keys", "Keys and mouse (and your own keys)", "Help", "F1", [this] { showHelp_ = true; }, {}, "shortcuts hotkeys keymap bind"); - add("help.about", "About Fry3DX", "Help", "", [this] { showAbout_ = true; }, {}, "version"); - add("help.search", "Search for a command", "Help", "F3", [this] { openSearch(); }, {}, "find palette"); - for (size_t i = 0; i < plugins_.size(); ++i) - if (pluginUsable(i)) - for (size_t c = 0; c < plugins_[i].p->commands.size(); ++c) { - const fry::PluginCommand& cmd = plugins_[i].p->commands[c]; - add("plugin." + plugins_[i].file + "." + cmd.name, cmd.name + (cmd.settings.empty() ? "" : "..."), "Plug-in: " + plugins_[i].p->name, "", [this, i, c] { - if (i >= plugins_.size() || !pluginUsable(i) || c >= plugins_[i].p->commands.size()) return; - const fry::PluginCommand& cm = plugins_[i].p->commands[c]; - if (cm.settings.empty()) return runPluginCommand(i, c, {}); - pendingCommand_ = PendingCommand{i, c, {}}; - for (const auto& [n, v] : cm.settings) pendingCommand_->values.push_back(v); - }, {}, cmd.help); - } - (void)model; - } - void openSearch() { - buildCommands(); - search_.show(); - } - void runCommand(const std::string& id) { - for (const fry::Command& c : commands_) - if (c.id == id) { - if (!c.usable()) return say(c.name + ": not now (" + (c.group == "Shape" ? "edit a shape first, Tab" : "pick something first") + ")"); - std::erase(recentCommands_, id); - recentCommands_.insert(recentCommands_.begin(), id); - if (recentCommands_.size() > 8) recentCommands_.pop_back(); - return c.run(); - } - } - void commandSearch() { - if (!search_.open) return; - if (const int i = search_.draw(commands_, recentCommands_); i >= 0) runCommand(commands_[size_t(i)].id); - } - // The pies: the tools (Space), or the views (Shift+Space), round the mouse while it's held. - void pieFrame(ImGuiIO& io) { - const bool released = !pieStays_ && !ImGui::IsKeyDown(ImGuiKey_Space); - std::vector items; - std::vector> runs; - if (pie_ == 1) { - for (const ToolInfo& t : kTools) - if (mode_ == Mode::Edit ? t.editMode : t.objectMode) - items.push_back({t.name, t.icon, true, tool_ == t.tool}), runs.push_back([this, tool = t.tool] { setTool(tool); }); - items.push_back({mode_ == Mode::Edit ? "Done (objects)" : "Edit the shape", mode_ == Mode::Edit ? Icon::Objects : Icon::Shape, true, false}); - runs.push_back([this] { mode_ == Mode::Edit ? leaveEdit() : enterEdit(); }); - } else { - const std::pair views[] = {{View::Top, "Top"}, {View::Right, "Right"}, {View::Front, "Front"}, {View::Perspective, "3D"}, - {View::Bottom, "Bottom"}, {View::Back, "Back"}, {View::Left, "Left"}}; - for (const auto& [v, n] : views) { - items.push_back({n, Icon::Select, false, view_ == v}); - runs.push_back([this, v] { v == View::Perspective ? leaveFlatView() : setView(v); }); - } - items.push_back({"Frame", Icon::Select, false, false}), runs.push_back([this] { frameSelected(); }); - } - const int got = fry::pieMenu(pie_ == 1 ? "Tools" : "Views", pieAt_, items, released); - if (got == -1) return; - if (got >= 0) runs[size_t(got)](); - pie_ = 0, pieStays_ = false; - (void)io; - } - // Keys of your own: kept in keys.txt (a line each: the command's id, a tab, the key). - void loadMyKeys() { - myKeys_.clear(); - std::ifstream in(keel::prefPath() + "keys.txt"); - for (std::string line; std::getline(in, line);) - if (const size_t tab = line.find('\t'); tab != std::string::npos) - if (const auto c = fry::parseChord(line.substr(tab + 1))) myKeys_[line.substr(0, tab)] = *c; - } - void saveMyKeys() { - std::ofstream out(keel::prefPath() + "keys.txt"); - for (const auto& [id, c] : myKeys_) out << id << '\t' << fry::chordName(c) << '\n'; - } - - void menus() { - if (ImGui::BeginMenu("File")) { - if (ImGui::MenuItem("New", "Ctrl+N")) whenSaved(2); - if (ImGui::MenuItem("Open...", "Ctrl+O")) whenSaved(3); - if (ImGui::MenuItem("Save", "Ctrl+S")) path_.empty() ? dialog(2) : void(saveFile(path_)); - if (ImGui::MenuItem("Save as...", "Ctrl+Shift+S")) dialog(2); - ImGui::Separator(); - if (ImGui::MenuItem("Import a model (STL, OBJ, 3MF, glTF)...", "Ctrl+Shift+O")) dialog(12); - if (ImGui::MenuItem("Export (GLB, glTF, OBJ, STL, PLY)...", "Ctrl+E")) dialog(3); - if (ImGui::BeginMenu("Autosave")) { - static const int every[] = {1, 2, 5, 10, 0}; - for (int m : every) - if (ImGui::MenuItem(m ? std::format("Every {} minute{}", m, m > 1 ? "s" : "").c_str() : "Off", nullptr, fry_autosave.i() == m)) - keel::execute(std::format("fry_autosave {}", m)); - ImGui::Separator(); - if (ImGui::MenuItem("Recover autosaved work...")) findAutosaves(), showRecover_ = !recover_.empty(); - ImGui::EndMenu(); - } - if (ImGui::MenuItem("Save a picture of the view...", "F12")) dialog(5); - ImGui::Separator(); - if (ImGui::MenuItem("Quit", "Ctrl+Q")) whenSaved(1); - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("Edit")) { - if (ImGui::MenuItem("Undo", "Ctrl+Z", false, !undo_.empty())) undo(); - if (ImGui::MenuItem("Redo", "Ctrl+Y", false, !redo_.empty())) redo(); - ImGui::Separator(); - if (ImGui::MenuItem("Duplicate", "Ctrl+D", false, mode_ == Mode::Object)) duplicateSelected(); - if (ImGui::MenuItem("Join into one", "Ctrl+J", false, mode_ == Mode::Object && selected_.size() > 1)) joinSelected(); - if (ImGui::MenuItem("Delete", "Del")) deleteSelected(); - ImGui::Separator(); - if (ImGui::MenuItem(mode_ == Mode::Edit ? "Stop editing the shape" : "Edit the shape", "Tab")) mode_ == Mode::Edit ? leaveEdit() : enterEdit(); - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("Select")) { - if (ImGui::MenuItem("All", "Ctrl+A")) selectAll(); - if (ImGui::MenuItem("None", "Alt+A")) selectNone(); - if (ImGui::MenuItem("Invert", "Ctrl+I")) invertSelection(); - ImGui::Separator(); - ImGui::BeginDisabled(mode_ != Mode::Edit); - if (ImGui::MenuItem("More (grow)", "Ctrl +")) growSelection(); - if (ImGui::MenuItem("Less (shrink)", "Ctrl -")) shrinkSelection(); - if (ImGui::MenuItem("Everything joined to it", "L")) selectLinked(); - ImGui::MenuItem("A loop: Alt+click or double-click", nullptr, false, false); - ImGui::EndDisabled(); - ImGui::Separator(); - if (ImGui::MenuItem("Select with a box", nullptr, selShape_ == SelShape::Box)) selShape_ = SelShape::Box; - if (ImGui::MenuItem("Select with a brush", nullptr, selShape_ == SelShape::Brush)) selShape_ = SelShape::Brush; - if (ImGui::MenuItem("Select with a lasso", nullptr, selShape_ == SelShape::Lasso)) selShape_ = SelShape::Lasso; - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("Add")) { - addMenuItems(); - pluginMenuItems("add"); - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("Object")) { - const bool many = selected_.size() >= 2, any = !selected_.empty(); - if (ImGui::MenuItem("Go to it", "F", false, any)) goTo(selected_); - if (ImGui::MenuItem("Duplicate", "Ctrl+D", false, any)) duplicateSelected(); - if (ImGui::MenuItem("Make copies (in a row, round)...", nullptr, false, any)) showCopies_ = true; - if (ImGui::MenuItem("Group into a folder", "Ctrl+G", false, any)) groupSelected(); - ImGui::Separator(); - ImGui::TextDisabled("Two or more: the last picked is kept"); - if (ImGui::MenuItem("Add them together", "Ctrl+Shift++", false, many)) combineSelected(Combine::Add); - if (ImGui::MenuItem("Take the others out of it", "Ctrl+Shift+-", false, many)) combineSelected(Combine::Subtract); - if (ImGui::MenuItem("Cut out, editable", nullptr, false, many)) cutEditable(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("The others cut into the last picked but stay objects: move or change them and the hole follows (Apply makes it for good)"); - if (ImGui::MenuItem("Keep only where they overlap", nullptr, false, many)) combineSelected(Combine::Overlap); - if (ImGui::MenuItem("Join (just as they are)", "Ctrl+J", false, many)) joinSelected(); - ImGui::Separator(); - if (ImGui::MenuItem("Make it thick (or hollow)...", nullptr, false, any)) showThicken_ = true; - if (ImGui::MenuItem("Join flat faces side by side", nullptr, false, any)) cleanFlatFaces(); - pluginMenuItems("object"); - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("Shape")) { - const bool ed = mode_ == Mode::Edit; - if (!ed) ImGui::TextDisabled("Edit an object's shape first (Tab)"); - ImGui::BeginDisabled(!ed); - if (ImGui::MenuItem("Extrude", "X")) setTool(Tool::Extrude); - if (ImGui::MenuItem("Inset", "I")) setTool(Tool::Inset); - if (ImGui::MenuItem("Round edges", "B")) setTool(Tool::Bevel); - if (ImGui::MenuItem("Loop cut", "C")) setTool(Tool::LoopCut); - ImGui::Separator(); - if (ImGui::MenuItem("Cut every face in four")) subdivideNow(false); - if (ImGui::MenuItem("Smooth it all (cut and round)")) subdivideNow(true); - if (ImGui::MenuItem("Merge points", "M")) mergeNow(); - if (ImGui::MenuItem("Make a face")) makeFaceNow(); - if (ImGui::MenuItem("Flip faces")) flipNow(); - ImGui::Separator(); - if (ImGui::MenuItem("Smooth points")) smoothPointsNow(); - if (ImGui::MenuItem("Push out / pull in...", "Alt+S")) startPush(); - if (ImGui::MenuItem("Bridge two faces")) bridgeNow(); - ImGui::MenuItem("Soft selection", "Shift+O", &softSel_); - if (ImGui::MenuItem("Make into its own object", "P")) separateSelected(); - if (ImGui::MenuItem("Delete", "Del")) deleteElements(); - ImGui::EndDisabled(); - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("View")) { - if (ImGui::MenuItem("Solid (for modelling)", "Z", shading_ == Shading::Solid)) shading_ = Shading::Solid; - if (ImGui::MenuItem("Rendered (sky, sun, shadows)", "Z", shading_ == Shading::Rendered)) shading_ = Shading::Rendered; - if (ImGui::MenuItem("Wireframe", "Shift+Z", shading_ == Shading::Wire)) shading_ = Shading::Wire; - ImGui::MenuItem("X-ray (see and pick through)", "Alt+Z", &xray_); - ImGui::MenuItem("Grid", nullptr, &showGrid_); - ImGui::MenuItem("Ground (in Rendered)", nullptr, &scene_.look.ground); - if (ImGui::BeginMenu("Appearance")) { - const char* looks[] = {"Like the desktop", "Dark", "Light"}; - for (int i = 0; i < 3; ++i) - if (ImGui::MenuItem(looks[i], nullptr, fry_theme.i() == i)) keel::execute(std::format("fry_theme {}", i)); - ImGui::EndMenu(); - } - ImGui::Separator(); - if (ImGui::MenuItem("Perspective", "Numpad 5", view_ == View::Perspective)) leaveFlatView(); - if (ImGui::MenuItem("Front", "Numpad 1", view_ == View::Front)) setView(View::Front); - if (ImGui::MenuItem("Back", "Shift+Numpad 1", view_ == View::Back)) setView(View::Back); - if (ImGui::MenuItem("Left side", "Numpad 3", view_ == View::Left)) setView(View::Left); - if (ImGui::MenuItem("Right side", "Shift+Numpad 3", view_ == View::Right)) setView(View::Right); - if (ImGui::MenuItem("Top", "Numpad 7", view_ == View::Top)) setView(View::Top); - if (ImGui::MenuItem("Bottom", "Shift+Numpad 7", view_ == View::Bottom)) setView(View::Bottom); - ImGui::Separator(); - if (ImGui::MenuItem("Frame selected", "F")) frameSelected(); - if (ImGui::MenuItem("Frame everything", "Home")) frameAll(); - if (ImGui::MenuItem("Show only the selected (again: all)", "/", false, !selected_.empty())) showOnly(selected_); - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("Script")) { - ImGui::MenuItem("The Script panel", nullptr, &showScript_); - if (ImGui::MenuItem("Run it", "F5", false, currentScript())) runScriptNow(); - if (!scene_.scripts.empty() && ImGui::BeginMenu("This file's scripts")) { - for (size_t i = 0; i < scene_.scripts.size(); ++i) - if (ImGui::MenuItem(scene_.scripts[i].name.c_str(), nullptr, i == script_)) script_ = i, showScript_ = true; - ImGui::EndMenu(); - } - ImGui::Separator(); - if (ImGui::MenuItem("New script")) addScript("Script", kFirstScript); - if (ImGui::MenuItem("Open a script...")) showScript_ = true, dialog(10); - if (ImGui::MenuItem("Save the script as...", nullptr, false, currentScript())) dialog(11); - exampleScripts(); - ImGui::Separator(); - if (ImGui::BeginMenu("Plug-ins")) { - bool any = false; - for (size_t i = 0; i < plugins_.size(); ++i) - if (plugins_[i].p) - for (size_t c = 0; c < plugins_[i].p->commands.size(); ++c) - if (plugins_[i].p->commands[c].menu == "script") pluginMenuItem(i, c), any = true; - if (any) ImGui::Separator(); - if (ImGui::MenuItem("Plug-ins...")) showPlugins_ = true; - if (ImGui::MenuItem("Open the plug-ins folder")) openPluginsFolder(); - if (ImGui::MenuItem("Load them again")) loadPlugins(); - ImGui::EndMenu(); - } - if (ImGui::MenuItem("What scripts can do")) showScript_ = showScriptHelp_ = true; - ImGui::EndMenu(); - } - if (ImGui::BeginMenu("Help")) { - if (ImGui::MenuItem("Keys and mouse", "F1")) showHelp_ = true; - if (ImGui::MenuItem("About Fry3DX")) showAbout_ = true; - ImGui::EndMenu(); - } - } - void menuBar() { - if (!ImGui::BeginMainMenuBar()) return; - if (haveLogo_) { - const float h = ImGui::GetFrameHeight() - 4; - ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 1); - logoImage(h); - } - ImGui::PushFont(keel::ui::font(keel::ui::Font::Bold), 0.0f), ImGui::TextColored(accentText(), "Fry3DX"), ImGui::PopFont(); - if (!touch()) menus(); - else if (ImGui::BeginMenu("Menu")) { // (a phone: one menu, the others inside it) - menus(); - ImGui::EndMenu(); - } - const std::string file = (path_.empty() ? "Untitled" : fileName(path_)) + (dirty_ ? " *" : ""); - // The tabs: what you're doing (as wide as there's room for between the menus and the file's - // name; the pictures alone when it's tight). - { - const char* names[] = {"Model", "Paint", "Layout", "Render", "Print"}; - const Icon icons[] = {Icon::Objects, Icon::PaintBrush, Icon::Layout, Icon::Render, Icon::Print}; - const char* tips[] = {"Make the shape (Ctrl+1)", "Paint pictures onto it (Ctrl+2)", "Change how its picture is laid out: move, turn and size its frames (Ctrl+3)", - "Light it and save pictures of it (Ctrl+4)", "Get it ready for a 3D printer: size, checks, the printer's plate (Ctrl+5)"}; - const float h = ImGui::GetFrameHeight(), from = ImGui::GetCursorPosX() + 20.0f; - const float room = ImGui::GetWindowWidth() - from - (touch() ? 8.0f : ImGui::CalcTextSize(file.c_str()).x + 32.0f); - float tw = std::min(touch() ? 72.0f : 92.0f, room / 5 - 4); - const bool words = tw >= h + 2 + ImGui::CalcTextSize("Layout").x + 6; - if (!words) tw = h + 4; - ImGui::SetCursorPosX(std::max(from, std::min((ImGui::GetWindowWidth() - (tw + 4) * 5) * 0.5f, from + room - (tw + 4) * 5))); - for (int i = 0; i < 5; ++i) { - if (i) ImGui::SameLine(0, 4); - ImGui::PushID(i); - if (wideButton("##tab", icons[i], words ? names[i] : "", int(tab_) == i, tw, words ? tips[i] : std::format("{}: {}", names[i], tips[i]).c_str(), h)) setTab(Tab(i)); - ImGui::PopID(); - } - } - if (touch()) return ImGui::EndMainMenuBar(); // (no room on a phone) - ImGui::SameLine(ImGui::GetWindowWidth() - ImGui::CalcTextSize(file.c_str()).x - 16); - ImGui::TextDisabled("%s", file.c_str()); - ImGui::EndMainMenuBar(); - } - - void addMenuItems() { - ImGui::SeparatorText("Shapes"); - for (const ShapeRecipe& r : shapeRecipes()) - if (!r.starter && ImGui::MenuItem(r.name)) addShape(r.id); - ImGui::SeparatorText("Starter models"); - for (const ShapeRecipe& r : shapeRecipes()) - if (r.starter && ImGui::MenuItem(r.name)) addShape(r.id); - ImGui::Separator(); - if (ImGui::MenuItem("Draw a shape", "D")) { - if (mode_ == Mode::Edit) leaveEdit(); - setTool(Tool::Draw); - } - if (ImGui::MenuItem("Reference picture...")) dialog(4); - } - - // How the last item's key looks: filled when it's the one in use, sunk while held. - static keel::ui::KeyState keyState(bool on) { - using keel::ui::KeyState; - return on ? KeyState::On : ImGui::IsItemActive() ? KeyState::Pressed : ImGui::IsItemHovered() ? KeyState::Hovered : KeyState::Normal; - } - // A square button with a picture on it (the accent when it's the one in use). - bool iconButton(const char* id, Icon icon, bool on, const char* tip, float size = 38.0f) { - const ImVec2 p = ImGui::GetCursorScreenPos(); - const bool hit = ImGui::InvisibleButton(id, ImVec2(size, size)); - const keel::ui::KeyState k = keyState(on); - ImDrawList* dl = ImGui::GetWindowDrawList(); - keel::ui::drawKey(dl, p, ImVec2(p.x + size, p.y + size), 7.0f, k); - const float sink = k == keel::ui::KeyState::Pressed ? 1.0f : 0.0f; - drawIcon(dl, icon, ImVec2(p.x + size * 0.14f, p.y + size * 0.14f + sink), size * 0.72f, keel::ui::keyText(k)); - const bool hov = ImGui::IsItemHovered(); - if (hov && tip) ImGui::SetTooltip("%s", tip); - return hit; - } - // A wide one, with words beside the picture. - bool wideButton(const char* id, Icon icon, const char* label, bool on, float width, const char* tip = nullptr, float h = 30.0f) { - const ImVec2 p = ImGui::GetCursorScreenPos(); - const bool hit = ImGui::InvisibleButton(id, ImVec2(width, h)); - const keel::ui::KeyState k = keyState(on); - ImDrawList* dl = ImGui::GetWindowDrawList(); - keel::ui::drawKey(dl, p, ImVec2(p.x + width, p.y + h), 7.0f, k); - const float sink = k == keel::ui::KeyState::Pressed ? 1.0f : 0.0f; - drawIcon(dl, icon, ImVec2(p.x + h * 0.15f, p.y + h * 0.13f + sink), h * 0.74f, keel::ui::keyText(k)); - dl->AddText(ImVec2(p.x + h + 2, p.y + (h - ImGui::GetFontSize()) * 0.5f + sink), keel::ui::keyText(k), label); - const bool hov = ImGui::IsItemHovered(); - if (hov && tip) ImGui::SetTooltip("%s", tip); - return hit; - } - // Buttons in rows, as many as fit. - template - void grid(int count, float size, F each) { - const float gap = 5.0f; - const int perRow = std::max(1, int((ImGui::GetContentRegionAvail().x + gap) / (size + gap))); - for (int i = 0; i < count; ++i) { - if (i % perRow) ImGui::SameLine(0, gap); - each(i); - } - } - - void toolbox() { - if (std::exchange(showToolbox_, false)) ImGui::SetNextWindowFocus(); - ImGui::SetNextWindowSizeConstraints(panelMin("Toolbox"), ImVec2(FLT_MAX, FLT_MAX)); - ImGui::Begin("Toolbox", nullptr, ImGuiWindowFlags_NoCollapse); - const float w = ImGui::GetContentRegionAvail().x; - if (tab_ == Tab::Paint) { - paintToolbox(w); - return ImGui::End(); - } - if (tab_ == Tab::Layout) { - layoutToolbox(w); - return ImGui::End(); - } - if (tab_ == Tab::Render || tab_ == Tab::Print) { - const Tool tools[] = {Tool::Select, Tool::Move, Tool::Rotate, Tool::Scale}; - grid(4, 38.0f, [&](int i) { - const ToolInfo& t = kTools[int(tools[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, tool_ == t.tool, tip.c_str())) setTool(t.tool); - }); - ImGui::Spacing(); - if (tab_ == Tab::Print) printToolbox(w); - else if (wideButton("##save", Icon::Picture, "Save a picture", false, w, "Saves what the view shows, without the panels or gizmos (F12)")) dialog(5); - return ImGui::End(); - } - // The mode: whole objects, or the shape of one. - if (wideButton("##objects", Icon::Objects, "Objects", mode_ == Mode::Object, (w - 5) / 2, "Work with whole objects")) leaveEdit(); - ImGui::SameLine(0, 5); - if (wideButton("##shape", Icon::Shape, "Shape", mode_ == Mode::Edit, (w - 5) / 2, "Edit the selected object's shape: its points, edges and faces (Tab, or double-click it)")) enterEdit(); - if (mode_ == Mode::Edit) { - const Icon icons[] = {Icon::Points, Icon::Edges, Icon::Faces}; - const char* tips[] = {"Points (1)", "Edges (2)", "Faces (3)"}; - const char* names[] = {"Points", "Edges", "Faces"}; - const float third = (w - 10) / 3; - for (int i = 0; i < 3; ++i) { - if (i) ImGui::SameLine(0, 5); - ImGui::PushID(i); - if (wideButton("##elem", icons[i], names[i], int(elem_) == i, third, tips[i])) elem_ = Elem(i), clearElements(); - ImGui::PopID(); - } - } - ImGui::Spacing(); - // The tools, as pictures (hover for the name and key). - std::vector tools; - for (const ToolInfo& t : kTools) - if ((mode_ == Mode::Object && t.objectMode) || (mode_ == Mode::Edit && t.editMode)) tools.push_back(&t); - grid(int(tools.size()) + 1, 38.0f, [&](int i) { - if (i == int(tools.size())) { - if (iconButton("##add", Icon::Add, false, "Add a shape or a starter model")) ImGui::OpenPopup("add"); - return; - } - const ToolInfo& t = *tools[size_t(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, tool_ == t.tool, tip.c_str())) setTool(t.tool); - }); - if (ImGui::BeginPopup("add")) { - addMenuItems(); - ImGui::EndPopup(); - } - // Mouse tools from plug-ins (fry.tool): by name, as plug-ins load again. - if (mode_ == Mode::Object) { - bool first = true; - for (size_t i = 0; i < plugins_.size(); ++i) - if (pluginUsable(i)) - for (const fry::PluginTool& t : plugins_[i].p->tools) { - if (std::exchange(first, false)) ImGui::SeparatorText("Plug-in tools"); - const bool on = pluginTool_ == std::pair(plugins_[i].file, t.name); - if (ImGui::Selectable(std::format("{}##{}", t.name, plugins_[i].file).c_str(), on, 0, ImVec2(w, 0))) - on ? setTool(Tool::Select) : void((pluginTool_ = {plugins_[i].file, t.name}, tool_ = Tool::Select, say(t.name + ": click in the view (Esc stops)"))); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s%s(%s)", t.help.c_str(), t.help.empty() ? "" : "\n", plugins_[i].p->name.c_str()); - } - } - ImGui::Spacing(); - if (pluginTool_.first.empty()) // (a plug-in tool: its row shows it's on) - for (const ToolInfo& t : kTools) - if (t.tool == tool_) ImGui::TextColored(accentText(), "%s", t.name); - ImGui::End(); - } - // The plug-in tool that's on: which plug-in and which of its tools (none: -1). - std::pair pluginToolAt() const { - for (size_t i = 0; i < plugins_.size(); ++i) - if (plugins_[i].file == pluginTool_.first && pluginUsable(i)) - for (size_t k = 0; k < plugins_[i].p->tools.size(); ++k) - if (plugins_[i].p->tools[k].name == pluginTool_.second) return {int(i), int(k)}; - return {-1, -1}; - } - bool pluginToolFound() const { return pluginToolAt().first >= 0; } - // A plug-in tool's click: the spot on what's under the mouse, or on the ground. - void clickPluginTool(ImVec2 mouse) { - const auto [i, k] = pluginToolAt(); - if (i < 0) return pluginTool_ = {}, say("That plug-in tool isn't loaded any more"); - const Ray ray = mouseRay(mouse); - glm::vec3 at, n(0, 0, 1); - uint32_t onto = 0; - const Hit h = pickObject(ray, 0, true); - if (const Object* o = h.object ? find(h.object) : nullptr) { - at = ray.origin + ray.dir * h.t, onto = o->id; - n = glm::normalize(glm::transpose(glm::inverse(glm::mat3(objectMatrix(*o)))) * faceNormal(o->mesh, h.face)); - } else if (auto g = hitPlane(ray, glm::vec3(0.0f), n)) at = *g; - else return; - pushUndo(); - bool frame = false; - fry::ScriptHost host{&scene_, &selected_, &active_, [this](const std::string& t) { scriptLine(t); }, [&] { frame = true; }}; - const fry::ScriptResult r = plugins_[size_t(i)].p->click(host, size_t(k), at, n, onto); - finishRun(r, pluginTool_.second, frame); - } - - void paintToolbox(float w) { - const float half = (w - ImGui::GetStyle().ItemSpacing.x) * 0.5f; - if (wideButton("##colour-side", Icon::PaintBrush, "Colour", !sculpting_, half, "Paint colours on it (Tab)")) sculpting_ = false, endSculpt(); - ImGui::SameLine(); - if (wideButton("##shape-side", Icon::Sculpt, "Sculpt", sculpting_, half, "Push its surface about like clay (Tab)")) sculpting_ = true, endStroke(); - ImGui::Spacing(); - if (sculpting_) { - grid(int(std::size(kSculptTools)), 38.0f, [&](int i) { - const SculptToolInfo& t = kSculptTools[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, sculptBrush_ == t.brush, tip.c_str())) sculptBrush_ = t.brush; - }); - for (const SculptToolInfo& t : kSculptTools) - if (t.brush == sculptBrush_) ImGui::TextColored(accentText(), "%s", t.name); - return; - } - grid(int(std::size(kPaintTools)), 38.0f, [&](int i) { - const PaintToolInfo& t = kPaintTools[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, paintTool_ == t.tool, tip.c_str())) paintTool_ = t.tool; - }); - ImGui::Spacing(); - // The colour, big, and a few to pick from. - ImGui::ColorButton("##colour", ImVec4(paintColor_.r, paintColor_.g, paintColor_.b, 1.0f), ImGuiColorEditFlags_NoTooltip, ImVec2(w, 34)); - static const glm::vec3 palette[] = {{0.08f, 0.08f, 0.09f}, {0.95f, 0.95f, 0.95f}, {0.55f, 0.55f, 0.57f}, {0.85f, 0.2f, 0.12f}, {1.0f, 0.55f, 0.1f}, {0.98f, 0.85f, 0.2f}, - {0.3f, 0.7f, 0.25f}, {0.15f, 0.45f, 0.85f}, {0.45f, 0.25f, 0.7f}, {0.45f, 0.3f, 0.18f}, {0.75f, 0.6f, 0.45f}, {0.95f, 0.6f, 0.7f}}; - grid(int(std::size(palette)), 20.0f, [&](int i) { - ImGui::PushID(i); - if (ImGui::ColorButton("##p", ImVec4(palette[i].r, palette[i].g, palette[i].b, 1.0f), 0, ImVec2(20, 20))) paintColor_ = glm::vec4(palette[i], paintColor_.a); - ImGui::PopID(); - }); - for (const PaintToolInfo& t : kPaintTools) - if (t.tool == paintTool_) ImGui::TextColored(accentText(), "%s", t.name); - } - - void sculptOptions(float w) { - for (const SculptToolInfo& t : kSculptTools) - if (t.brush == sculptBrush_) ImGui::SeparatorText(t.name), ImGui::TextWrapped("%s", t.hint); - ImGui::Spacing(); - ImGui::SetNextItemWidth(w); - ImGui::SliderFloat("##size", &sculptPx_, 4.0f, 400.0f, "size %.0f px", ImGuiSliderFlags_Logarithmic); - if (sculptBrush_ != SculptBrush::Grab) { - ImGui::SetNextItemWidth(w); - ImGui::SliderFloat("##strength", &sculptStrength_, 0.02f, 1.0f, "strength %.2f"); - } - ImGui::Checkbox("Same on both sides (X)", &sculptMirror_); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("What you do on its left is done on its right too"); - if (Object* o = selected_.size() == 1 ? find(active_) : nullptr) { - ImGui::SeparatorText(o->name.c_str()); - ImGui::TextDisabled("%zu points", o->mesh.verts.size()); - size_t after = 0; - for (const auto& f : o->mesh.faces) after += f.size(); - ImGui::BeginDisabled(after > 400000 || o->locked); - if (ImGui::Button("Finer, for small details", ImVec2(w, 0))) { - pushUndo(); - if (o->hasFinish()) o->mesh = finishedMesh(*o), o->mirror = 0, o->round = 0, o->smoothLevels = 0; - handMade(*o); - subdivide(o->mesh, false); - touched(o->id); - } - ImGui::EndDisabled(); - if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("Each face split in four: the same shape, more points to push about"); - if (ImGui::Button("Smooth all of it", ImVec2(w, 0)) && !o->locked) { - pushUndo(); - handMade(*o); - const SculptMesh sm(o->mesh); - const glm::vec3 mid = (boundsMin(o->mesh) + boundsMax(o->mesh)) * 0.5f; - sculptDab(o->mesh, sm, SculptBrush::Smooth, {mid, glm::distance(boundsMin(o->mesh), boundsMax(o->mesh)) * 2.0f, 0.5f}); - touched(o->id); - } - } - ImGui::Spacing(); - ImGui::TextDisabled("Ctrl: the other way Shift: smooth\n[ and ]: brush size 1-6: brushes"); - } - - void paintOptions(float w) { - if (sculpting_) return sculptOptions(w); - for (const PaintToolInfo& t : kPaintTools) - if (t.tool == paintTool_) ImGui::SeparatorText(t.name); - if (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Eraser) { - ImGui::SetNextItemWidth(w); - ImGui::SliderFloat("##size", &brushPx_, 1.0f, 300.0f, "size %.0f px", ImGuiSliderFlags_Logarithmic); - ImGui::SetNextItemWidth(w); - ImGui::SliderFloat("##hard", &hardness_, 0.0f, 1.0f, "hard edge %.2f"); - ImGui::SetNextItemWidth(w); - ImGui::SliderFloat("##strength", &paintColor_.a, 0.02f, 1.0f, "strength %.2f"); - ImGui::Checkbox("Both sides (mirrored, X)", &paintMirror_); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("What you paint on one side goes on the other too, mirrored (left and right: the object's own Y)"); - ImGui::SeparatorText("Stencil"); - if (stencil_.size() >= 3) { - ImGui::Checkbox("Paint only inside it", &stencilOn_); - if (ImGui::Button("Draw it again", ImVec2(w * 0.5f - 2, 0))) drawingStencil_ = true, stencil_.clear(); - ImGui::SameLine(0, 4); - if (ImGui::Button("Take it off", ImVec2(w * 0.5f - 2, 0))) stencil_.clear(), stencilOn_ = false; - } else { - if (ImGui::Button(drawingStencil_ ? "Drawing: drag round the area..." : "Draw a stencil", ImVec2(w, 0))) drawingStencil_ = !drawingStencil_, stencil_.clear(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Drag round an area on the screen: paint goes only inside it (it stays put on the screen as you turn the view)"); - } - } - if (paintTool_ == PaintTool::Fill) { - ImGui::SetNextItemWidth(w); - ImGui::SliderFloat("##tol", &fillTolerance_, 0.0f, 1.0f, "how alike %.2f"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("How different a colour can be and still be filled"); - } - if (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Fill || paintTool_ == PaintTool::Picker) { - ImGui::SetNextItemWidth(w); - ImGui::ColorPicker3("##picker", &paintColor_.x, ImGuiColorEditFlags_NoSidePreview | ImGuiColorEditFlags_NoSmallPreview | ImGuiColorEditFlags_PickerHueBar); - } - if (paintTool_ == PaintTool::Eraser) ImGui::TextWrapped("Paints the object's plain colour back (its colour in the Model tab)."); - if (paintTool_ == PaintTool::Picture) { - if (ImGui::Button("Paste (Ctrl+V)", ImVec2(w, 0))) pasteFromClipboard(); - if (ImGui::Button("Add a picture from a file...", ImVec2(w, 0))) dialog(6); - if (PictureLayer* l = editLayer()) { - Object* o = find(ledit_.object); - ImGui::SeparatorText(l->name.c_str()); - if (ImGui::Button("Lock it in place (Enter)", ImVec2(w, 0))) lockLayer(); - if (ImGui::Button(ledit_.fresh ? "Take it away (Esc)" : "Delete it (Del)", ImVec2(w, 0))) ledit_.fresh ? cancelFloating() : deleteLayer(*o, ledit_.index); - ImGui::SetNextItemWidth(w); - undoable([&] { return ImGui::SliderFloat("##op", &l->opacity, 0.0f, 1.0f, "see-through %.2f"); }); - if (l->sticker) { - float size[2] = {l->width, l->height}; - ImGui::SetNextItemWidth(w); - if (undoable([&] { return ImGui::DragFloat2("##wh", size, 0.002f, 0.002f, 100.0f, "%.3f m"); })) l->width = size[0], l->height = size[1]; - } else if (o && o->picture) { - if (ImGui::Button("Cover the whole picture", ImVec2(w, 0))) { - pushUndo(); - const float W = float(o->picture->w), H = float(o->picture->h); - l->corners = {glm::vec2(0, 0), glm::vec2(W, 0), glm::vec2(W, H), glm::vec2(0, H)}; - } - if (ImGui::Button("Square it up again", ImVec2(w, 0))) { - pushUndo(); - glm::vec2 c(0.0f); - for (const glm::vec2& p : l->corners) c += p * 0.25f; - const float across = (glm::distance(l->corners[0], l->corners[1]) + glm::distance(l->corners[3], l->corners[2])) * 0.5f; - const glm::vec2 half(across * 0.5f, across * 0.5f * float(l->image->h) / float(std::max(l->image->w, 1))); - 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)}; - } - } - ImGui::Separator(); - } - ImGui::TextDisabled("New pictures go"); - int where = pasteOnSurface_ ? 0 : 1; - ImGui::SetNextItemWidth(w); - if (ImGui::Combo("##where", &where, "On the model (a sticker)\0On the flat picture\0")) pasteOnSurface_ = where == 0; - ImGui::TextWrapped(pasteOnSurface_ ? "Copy a picture in any app (GIMP, a browser...) and paste it. Drag it round the model: over an edge it bends like a sticker. " - "Corners size it (Shift: stretch), the round handle turns it (Ctrl: steps). Enter locks it as a layer; click it in the Picture panel to move it again." - : "Copy a picture in any app and paste it. In the Picture panel drag its corners where you want them: it bends to fit " - "(Shift keeps its shape; drag inside to move it). Enter locks it as a layer; click it there to move it again."); - } - } - - void softChoice(float w) { - if (mode_ != Mode::Edit) return; - ImGui::Checkbox("Soft selection (Shift+O)", &softSel_); - ImGui::Checkbox("Mirror editing (across X)", &liveMirror_); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("A symmetric shape stays so: move a point, its twin on the other side moves too"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Points near the selection follow it, less the further they are"); - if (!softSel_) return; - ImGui::SetNextItemWidth(w); - ImGui::DragFloat("##reach", &softReach_, 0.002f, 0.001f, 1000.0f, "reaches %.3f m"); - ImGui::TextDisabled("(the wheel while dragging too)"); - } - void toolOptions() { - ImGui::SetNextWindowSizeConstraints(panelMin("Tool options"), ImVec2(FLT_MAX, FLT_MAX)); - ImGui::Begin("Tool options", nullptr, ImGuiWindowFlags_NoCollapse); - const float w = ImGui::GetContentRegionAvail().x; - if (tab_ == Tab::Paint) { - paintOptions(w); - return ImGui::End(); - } - if (tab_ == Tab::Layout) { - layoutOptions(w); - return ImGui::End(); - } - if ((tab_ == Tab::Print && printTool_ != PrintTool::None) || printTool_ == PrintTool::Measure) { - printOptions(w); - return ImGui::End(); - } - const char* name = ""; - for (const ToolInfo& t : kTools) - if (t.tool == tool_) name = t.name; - ImGui::SeparatorText(name); - auto orientChoice = [&] { - ImGui::TextDisabled("Arrows point"); - int o = int(orient()); - const char* items = mode_ == Mode::Edit ? "The object's way\0The world's way\0Out of the faces\0" : "The object's way\0The world's way\0"; - ImGui::SetNextItemWidth(w); - if (ImGui::Combo("##orient", &o, items)) orient() = Orient(o); - }; - auto snapChoice = [&](bool angle) { - ImGui::Checkbox("Snap", &snap_); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("In steps (holding Ctrl does the opposite)"); - if (!snap_) return; - ImGui::SetNextItemWidth(w); - if (angle) return void(ImGui::DragFloat("##snapangle", &snapAngle_, 0.5f, 1.0f, 90.0f, "every %.0f deg")); - if (tool_ == Tool::Move) { - ImGui::SetNextItemWidth(w); - ImGui::Combo("##snapkind", &snapKind_, "To steps\0To points\0To edges' middles\0To faces' middles\0"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Points, middles: the arrows' point goes to the one nearest the mouse (on other shapes, or what isn't picked)"); - if (snapKind_) return; - ImGui::SetNextItemWidth(w); - } - ImGui::DragFloat("##snapmove", &snapMove_, 0.001f, 0.001f, 10.0f, "every %.3f m"); - }; - switch (tool_) { - case Tool::Select: { - ImGui::TextDisabled("Drag to select with"); - const Icon icons[] = {Icon::Box, Icon::Brush, Icon::Lasso}; - const char* tips[] = {"A box", "A brush: paint over what you want", "A lasso: draw round it"}; - for (int i = 0; i < 3; ++i) { - if (i) ImGui::SameLine(0, 5); - ImGui::PushID(i); - if (iconButton("##shape", icons[i], int(selShape_) == i, tips[i], 34.0f)) selShape_ = SelShape(i); - ImGui::PopID(); - } - if (selShape_ == SelShape::Brush) { - ImGui::SetNextItemWidth(w); - ImGui::SliderFloat("##brush", &brushRadius_, 4.0f, 200.0f, "brush %.0f px"); - } - ImGui::Checkbox("See through (X-ray)", &xray_); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Select things behind others too (Alt+Z)"); - break; - } - case Tool::Move: - orientChoice(); - snapChoice(false); - softChoice(w); - break; - case Tool::Rotate: { - orientChoice(); - ImGui::TextDisabled("Turn around"); - int p = rotatePivot_ == Pivot::Origin ? 1 : 0; - ImGui::SetNextItemWidth(w); - if (ImGui::Combo("##rpivot", &p, "The middle\0The origin\0")) rotatePivot_ = p ? Pivot::Origin : Pivot::Middle; - snapChoice(true); - softChoice(w); - break; - } - case Tool::Scale: { - if (mode_ == Mode::Edit) orientChoice(); - ImGui::TextDisabled("What stays put"); - int p = scalePivot_ == Pivot::Opposite ? 0 : scalePivot_ == Pivot::Middle ? 1 : scalePivot_ == Pivot::Bottom ? 2 : 3; - ImGui::SetNextItemWidth(w); - if (ImGui::Combo("##spivot", &p, "The other side\0The middle\0The bottom\0The origin\0")) - scalePivot_ = p == 0 ? Pivot::Opposite : p == 1 ? Pivot::Middle : p == 2 ? Pivot::Bottom : Pivot::Origin; - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("The other side: drag a side, the opposite side stays (like stretching in a paint program).\nThe middle: both sides move.\nThe bottom: it stays standing on the ground."); - snapChoice(false); - softChoice(w); - break; - } - case Tool::Draw: - ImGui::SetNextItemWidth(w); - ImGui::DragFloat("##thick", &drawThickness_, 0.001f, 0.0f, 10.0f, "thickness %.3f m"); - ImGui::TextWrapped("Drawn on the face you start on, or on %s.", - view_ == View::Perspective || view_ == View::Top || view_ == View::Bottom ? "the ground (a side view draws upright)" : "the plane you look at"); - ImGui::TextDisabled("Drawn on an object:"); - ImGui::SetNextItemWidth(w); - ImGui::Combo("##drawon", &drawOnMode_, "A new object on it\0Added to it (raised)\0Cut into it\0"); - snapChoice(false); - if (!drawPts_.empty() && ImGui::Button("Finish (Enter)", ImVec2(w, 0))) finishShape(); - break; - case Tool::Extrude: - ImGui::SetNextItemWidth(w); - ImGui::DragFloat("##ext", &extrudeAmount_, 0.001f, -10, 10, "%.3f m"); - if (ImGui::Button("Extrude by that", ImVec2(w, 0))) extrudeNow(extrudeAmount_); - snapChoice(false); - break; - case Tool::Inset: - ImGui::SetNextItemWidth(w); - ImGui::SliderFloat("##ins", &insetAmount_, 0.01f, 0.99f, "%.2f of the way in"); - if (ImGui::Button("Inset by that", ImVec2(w, 0))) insetNow(insetAmount_); - break; - case Tool::Bevel: - ImGui::SetNextItemWidth(w); - ImGui::DragFloat("##bw", &bevelWidth_, 0.001f, 0.0f, 10.0f, "width %.3f m"); - ImGui::SetNextItemWidth(w); - ImGui::SliderInt("##bs", &bevelSteps_, 1, 10, bevelSteps_ == 1 ? "1 step (a flat cut)" : "%d steps round"); - if (ImGui::Button("Round them", ImVec2(w, 0))) bevelNow(); - break; - case Tool::LoopCut: - ImGui::SetNextItemWidth(w); - ImGui::SliderInt("##cuts", &cuts_, 1, 16, cuts_ == 1 ? "1 cut" : "%d cuts"); - ImGui::TextWrapped("Cuts go across four-sided faces, all the way round."); - break; - case Tool::Knife: { - ImGui::Checkbox("Cut right through", &knifeThrough_); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Through everything in the line's way, the back too (else only what you can see).\nThe line goes on past its ends."); - if (knifeThrough_) { - ImGui::TextDisabled("Then"); - ImGui::SetNextItemWidth(w); - ImGui::Combo("##after", &knifeAfter_, "Just add the edges\0Pieces of the one object\0Each piece its own object\0Delete the smaller piece\0"); - if (knifeAfter_ > 0) { - ImGui::TextDisabled("Inside, where it's cut"); - int fill = knifeFill_ ? 0 : 1; - ImGui::SetNextItemWidth(w); - if (ImGui::Combo("##fill", &fill, "Filled (solid)\0Left open (hollow)\0")) knifeFill_ = fill == 0; - } - } - ImGui::TextWrapped("Click points along the cut, or drag a straight line. Ctrl: at 45 degree steps. Enter or double-click cuts, Esc lets go. " - "Each face it crosses is cut from edge to edge."); - if (knifePts_.size() >= 2 && ImGui::Button("Cut (Enter)", ImVec2(w, 0))) { - std::vector pts = knifePts_; - knifePts_.clear(); - knifeApply(active_, pts); - } - break; - } - case Tool::Hole: { - ImGui::SetNextItemWidth(w); - ImGui::Combo("##shape", &holeShape_, "Round\0Rectangle\0Triangle\0Hexagon (for nuts)\0"); - const HoleShape shape = HoleShape(holeShape_); - // Ready-made sizes for 3D prints: screws go through round holes, nuts sit in hexagons - // (a little room added, as prints come out a touch tight). - struct Fit { - const char* name; - float across, deep; // mm - }; - static const Fit screws[] = {{"M2 screw (2.4 mm)", 2.4f, 0}, {"M2.5 screw (2.9 mm)", 2.9f, 0}, {"M3 screw (3.4 mm)", 3.4f, 0}, {"M4 screw (4.5 mm)", 4.5f, 0}, - {"M5 screw (5.5 mm)", 5.5f, 0}, {"M6 screw (6.6 mm)", 6.6f, 0}, {"M8 screw (9 mm)", 9.0f, 0}}; - static const Fit nuts[] = {{"M2 nut (4 mm, 1.6 thick)", 4.0f, 1.6f}, {"M2.5 nut (5 mm, 2 thick)", 5.0f, 2.0f}, {"M3 nut (5.5 mm, 2.4 thick)", 5.5f, 2.4f}, - {"M4 nut (7 mm, 3.2 thick)", 7.0f, 3.2f}, {"M5 nut (8 mm, 4 thick)", 8.0f, 4.0f}, {"M6 nut (10 mm, 5 thick)", 10.0f, 5.0f}, - {"M8 nut (13 mm, 6.5 thick)", 13.0f, 6.5f}}; - if (shape == HoleShape::Round || shape == HoleShape::Hexagon) { - const bool nut = shape == HoleShape::Hexagon; - ImGui::SetNextItemWidth(w); - if (ImGui::BeginCombo("##fit", nut ? "Size for a nut..." : "Size for a screw...")) { - for (const Fit& f : nut ? std::span(nuts) : std::span(screws)) - if (ImGui::Selectable(f.name)) { - holeSize_ = (f.across + (nut ? holeRoom_ * 1000 : 0)) / 1000; - if (nut) holeMode_ = 1, holeDepth_ = (f.deep + 0.2f) / 1000; - else holeMode_ = 0; - } - ImGui::EndCombo(); - } - if (nut) { - float room = holeRoom_ * 1000; - ImGui::SetNextItemWidth(w); - if (ImGui::SliderFloat("##room", &room, 0.0f, 0.8f, "+ %.2f mm room")) holeRoom_ = room / 1000; - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Added to a nut's size so it pushes in (0.2 to 0.4 mm suits most printers)"); - } - } - if (shape == HoleShape::Round) { - ImGui::SetNextItemWidth(w); - ImGui::SliderInt("##sides", &holeSides_, 6, 96, "%d sides round"); - } - const char* acrossName = shape == HoleShape::Round ? "%.2f mm across" : shape == HoleShape::Rectangle ? "%.2f mm wide" : shape == HoleShape::Triangle ? "%.2f mm sides" : "%.2f mm across flats"; - float across = holeSize_ * 1000, tall = holeTall_ * 1000; - ImGui::SetNextItemWidth(w); - if (ImGui::DragFloat("##size", &across, 0.1f, 0.1f, 100000.0f, acrossName)) holeSize_ = across / 1000; - if (shape == HoleShape::Rectangle) { - ImGui::SetNextItemWidth(w); - if (ImGui::DragFloat("##tall", &tall, 0.1f, 0.1f, 100000.0f, "%.2f mm high")) holeTall_ = tall / 1000; - } - if (shape != HoleShape::Round) { - ImGui::SetNextItemWidth(w); - ImGui::SliderFloat("##turn", &holeTurn_, -180.0f, 180.0f, "turned %.0f degrees"); - } - ImGui::TextDisabled("Cut"); - ImGui::SetNextItemWidth(w); - ImGui::Combo("##mode", &holeMode_, "All the way through\0A pocket\0Only the outline (a new face)\0"); - if (holeMode_ == 1) { - float deep = holeDepth_ * 1000; - ImGui::SetNextItemWidth(w); - if (ImGui::DragFloat("##depth", &deep, 0.1f, 0.1f, 100000.0f, "%.2f mm deep")) holeDepth_ = deep / 1000; - } - ImGui::TextWrapped(holeMode_ == 2 ? "Press on a face and drag out the size, or just click. Nothing's taken away: the outline is cut into the face, " - "and the new face inside it is picked, ready to Extrude, Bevel or paint." - : "Press on a face and drag out the size, or just click (the size above). It has to fit inside the face%s. " - "Round edges in Properties rounds its rim too.", - holeMode_ == 0 ? ", and the face behind" : ""); - break; - } - } - ImGui::End(); - } - - void layersPanel() { - ImGui::SetNextWindowSizeConstraints(panelMin("Layers"), ImVec2(FLT_MAX, FLT_MAX)); - ImGui::Begin("Layers", nullptr, ImGuiWindowFlags_NoCollapse); - // Along the top: a new folder, the picked ones into one. - if (ImGui::SmallButton("+ Folder")) { - pushUndo(); - Folder f; - f.id = scene_.nextId++, f.name = "Folder"; - scene_.folders.push_back(f); - renamingFolder_ = f.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", f.name.c_str()); - } - if (ImGui::IsItemHovered()) ImGui::SetTooltip("A new folder: drag things onto it"); - ImGui::SameLine(); - ImGui::BeginDisabled(selected_.empty()); - if (ImGui::SmallButton("Group (Ctrl+G)")) groupSelected(); - ImGui::EndDisabled(); - if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("The picked objects into a new folder: picked and moved together"); - if (ImGui::BeginTable("objects", 4, ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { - ImGui::TableSetupColumn("eye", ImGuiTableColumnFlags_WidthFixed, 22); - ImGui::TableSetupColumn("lock", ImGuiTableColumnFlags_WidthFixed, 22); - ImGui::TableSetupColumn("name"); - ImGui::TableSetupColumn("go", ImGuiTableColumnFlags_WidthFixed, 22); - layerRows(0, 0); - for (Reference& ref : scene_.references) { - ImGui::PushID(int(ref.id)); - ImGui::TableNextRow(); - ImGui::TableNextColumn(); - if (rowToggle("##eye", ref.visible ? Icon::Eye : Icon::EyeShut, ref.visible, ref.visible ? "Shown (click to hide)" : "Hidden (click to show)")) ref.visible = !ref.visible; - ImGui::TableNextColumn(); - ImGui::TableNextColumn(); - const std::string label = "Picture: " + stdfs::path(ref.path).filename().string(); - if (ImGui::Selectable(label.c_str(), activeRef_ == ref.id)) activeRef_ = ref.id, selected_.clear(), leaveEdit(); - ImGui::TableNextColumn(); - ImGui::PopID(); - } - ImGui::EndTable(); - } - // (dropped below the list: out of its folder, to the top) - ImGui::InvisibleButton("##top", ImVec2(std::max(ImGui::GetContentRegionAvail().x, 1.0f), std::max(ImGui::GetContentRegionAvail().y, 8.0f))); - if (ImGui::BeginDragDropTarget()) { - if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_OBJECT")) { - const uint32_t oid = *static_cast(p->Data); - moveToFolder(selected_.count(oid) ? selected_ : std::set{oid}, 0); - } - if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_FOLDER")) - if (Folder* g = findFolder(*static_cast(p->Data))) pushUndo(), g->parent = 0; - ImGui::EndDragDropTarget(); - } - ImGui::End(); - } - - // A value that's undoable: the undo point is taken when you start dragging or typing. - template - bool undoable(F widget) { - const bool changed = widget(); - if (ImGui::IsItemActivated()) pushUndo(); - return changed; - } - - void paintProperties(float w) { - Object* o = selected_.empty() ? nullptr : find(active_); - if (sculpting_) { // (its look, while you shape it: what it's made of shows how the light runs over it) - if (!o) return ImGui::TextWrapped("Brush over an object to sculpt it.\n\nA shape with few points is made finer first, keeping its shape. Tab goes back to painting colours."); - ImGui::TextColored(accentText(), "%s", o->name.c_str()); - ImGui::TextDisabled("%zu points, %zu faces", o->mesh.verts.size(), o->mesh.faces.size()); - ImGui::SeparatorText("Look"); - materialPicker(*o); - undoable([&] { return ImGui::ColorEdit3("Colour", &o->color.x); }); - if (ImGui::Checkbox("Smooth shading", &o->smooth)) pushUndo(), touched(o->id); - return; - } - if (!o) { - ImGui::TextWrapped("Click an object to paint on it.\n\nIts surface gets laid out flat on a picture (the Picture panel), which you can paint on too, " - "or put pictures onto: copy one in any app and press Ctrl+V."); - return; - } - ImGui::TextColored(accentText(), "%s", o->name.c_str()); - if (!o->picture) { - int size = pictureSize_ <= 512 ? 0 : pictureSize_ <= 1024 ? 1 : pictureSize_ <= 2048 ? 2 : 3; - ImGui::SetNextItemWidth(w); - if (ImGui::Combo("##psize", &size, "512 x 512\0001024 x 1024\0002048 x 2048\0004096 x 4096\0")) pictureSize_ = 512 << size; - if (ImGui::Button("Start painting it", ImVec2(w, 0))) readyToPaint(o->id); - ImGui::TextDisabled("(or just paint on it)"); - return; - } - ImGui::TextDisabled("Picture %d x %d", o->picture->w, o->picture->h); - if (o->hasFinish()) ImGui::TextWrapped("It's had rounding or smoothing added since: the picture shows again when you paint (they become part of the shape)."); - ImGui::Checkbox("Show the layout lines", &showUvLines_); - // The pictures put on it, top first: click one to move it (again), the eye hides it. - ImGui::SeparatorText("Pictures on it (layers)"); - if (o->layers.empty()) ImGui::TextDisabled("None yet: paste one (Ctrl+V)"); - int move = -1, by = 0, drop = -1, merge = -1; - for (int i = int(o->layers.size()) - 1; i >= 0; --i) { - PictureLayer& l = o->layers[size_t(i)]; - const bool on = ledit_.object == o->id && ledit_.index == i; - ImGui::PushID(i); - if (rowToggle("##eye", l.visible ? Icon::Eye : Icon::EyeShut, l.visible, l.visible ? "Hide it" : "Show it")) pushUndo(), l.visible = !l.visible; - ImGui::SameLine(); - if (ImGui::Selectable(std::format("{}{}", l.name, on ? " (moving)" : l.sticker ? "" : " (flat)").c_str(), on, 0, ImVec2(w - 92, 0))) - on ? lockLayer() : unlockLayer(o->id, i); - ImGui::SameLine(w - 62); - ImGui::BeginDisabled(i == int(o->layers.size()) - 1); - if (ImGui::ArrowButton("up", ImGuiDir_Up)) move = i, by = 1; - ImGui::EndDisabled(); - ImGui::SameLine(0, 2); - ImGui::BeginDisabled(i == 0); - if (ImGui::ArrowButton("down", ImGuiDir_Down)) move = i, by = -1; - ImGui::EndDisabled(); - ImGui::SameLine(0, 2); - if (ImGui::SmallButton("x")) drop = i; - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Delete it"); - if (ImGui::BeginPopupContextItem("menu")) { - ImGui::SetNextItemWidth(180); - undoable([&] { return ImGui::SliderFloat("##op", &l.opacity, 0.0f, 1.0f, "see-through %.2f"); }); - if (ImGui::MenuItem("Paint it in for good")) merge = i; - if (ImGui::MenuItem("Delete")) drop = i; - ImGui::EndPopup(); - } - ImGui::PopID(); - } - if (move >= 0) pushUndo(), std::swap(o->layers[size_t(move)], o->layers[size_t(move + by)]), ledit_ = {}; - if (drop >= 0) deleteLayer(*o, drop); - if (merge >= 0) mergeLayer(*o, merge); - ImGui::Spacing(); - if (ImGui::Button("Fill it all with the colour", ImVec2(w, 0))) { - Image& img = changePicture(*o); - img = makeImage(img.w, img.h, glm::vec4(glm::vec3(paintColor_), 1.0f)); - paint_.image.reset(); - } - if (ImGui::Button("Save the picture as PNG...", ImVec2(w, 0))) dialog(7); - if (ImGui::Button("Lay it out again", ImVec2(w, 0))) { - pushUndo(); - o->uvs = autoUnwrap(o->mesh); - touched(o->id); - } - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Lays its surface out flat on the picture again (what's painted won't fit it any more)"); - if (ImGui::Button("Remove the picture", ImVec2(w, 0))) { - pushUndo(); - o->picture.reset(), o->uvs.clear(); - touched(o->id); - } - ImGui::Spacing(); - ImGui::TextWrapped("Tip: paint last. Changing the shape's faces (extrude, knife...) lays it out again, and what's painted moves."); - } - - void renderProperties(float w) { - Look& k = scene_.look; - auto slider = [&](const char* label, float* v, float lo, float hi, const char* fmt) { - if (undoable([&] { return ImGui::SliderFloat(label, v, lo, hi, fmt); })) dirty_ = true; - }; - ImGui::SeparatorText("Sun"); - slider("Round", &k.sunTurn, 0, 360, "%.0f deg"); - slider("Up", &k.sunHeight, 2, 90, "%.0f deg"); - slider("Strength", &k.sunStrength, 0, 2, "%.2f"); - undoable([&] { return ImGui::ColorEdit3("Sun colour", &k.sunColor.x); }); - ImGui::SeparatorText("Round it"); - if (ImGui::Checkbox("Sky", &k.sky)) dirty_ = true; - if (!k.sky) undoable([&] { return ImGui::ColorEdit3("Background", &k.background.x); }); - if (ImGui::Checkbox("Ground", &k.ground)) dirty_ = true; - if (k.ground) undoable([&] { return ImGui::ColorEdit3("Ground colour", &k.groundColor.x); }); - ImGui::Checkbox("Grid", &renderGrid_); - ImGui::SeparatorText("Look"); - slider("Brightness", &k.exposure, 0.2f, 3.0f, "%.2f"); - slider("Shade in corners", &k.ao, 0, 1, "%.2f"); - slider("Soft shadows", &k.shadowSoft, 0, 4, "%.2f"); - slider("Glow", &k.bloom, 0, 0.3f, "%.3f"); - slider("Colour", &k.saturation, 0, 2, "%.2f"); - slider("Dark edges", &k.vignette, 0, 1, "%.2f"); - slider("Sharpen", &k.sharpen, 0, 1, "%.2f"); - if (ImGui::Button("Back to how it was", ImVec2(w, 0))) pushUndo(), k = Look{}; - if (ImGui::Button("Save a picture (F12)", ImVec2(w, 0))) dialog(5); - if (Object* o = selected_.size() == 1 ? find(active_) : nullptr) { - ImGui::SeparatorText(o->name.c_str()); - materialPicker(*o); - undoable([&] { return ImGui::ColorEdit3("Colour", &o->color.x); }); - undoable([&] { return ImGui::SliderFloat("Metal", &o->metal, 0, 1); }); - undoable([&] { return ImGui::SliderFloat("Rough", &o->rough, 0, 1); }); - ImGui::TextDisabled("(metal and rough go with it into games)"); - } - } - - // The Paint tab's picture: the selected object's, flat, to paint on and place pictures on. - void picturePanel() { - ImGui::SetNextWindowSizeConstraints(panelMin("Picture"), ImVec2(FLT_MAX, FLT_MAX)); - ImGui::Begin("Picture", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); - Object* o = selected_.empty() ? nullptr : find(active_); - if (!o || !o->picture) { - if (tab_ == Tab::Layout) { - ImGui::TextWrapped(o ? "It has no picture yet, so nothing's laid out." : "Click an object: how its picture is laid out shows here."); - if (o && ImGui::Button("Lay it out (and start its picture)")) readyToPaint(o->id); - return ImGui::End(); - } - ImGui::TextWrapped(sculpting_ ? "Sculpting changes its shape, not its picture. Tab goes back to painting colours." - : o ? "Paint on it (or press Start painting it in Properties) and its picture shows here." : "Click an object to paint it: its picture shows here."); - return ImGui::End(); - } - uploadPicture(*o, !paint_.active); - const int W = o->picture->w, H = o->picture->h; - const ImVec2 origin = ImGui::GetCursorScreenPos(), avail = ImGui::GetContentRegionAvail(); - if (avail.x < 16 || avail.y < 16) return ImGui::End(); - if (picZoom_ <= 0 || picFor_ != o->id) { - picZoom_ = std::min(avail.x / float(W), avail.y / float(H)) * 0.96f; - picPan_ = ImVec2((avail.x - float(W) * picZoom_) * 0.5f, (avail.y - float(H) * picZoom_) * 0.5f), picFor_ = o->id; - } - ImGui::InvisibleButton("canvas", avail, ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonMiddle | ImGuiButtonFlags_MouseButtonRight); - const bool hovered = ImGui::IsItemHovered(); - ImGuiIO& io = ImGui::GetIO(); - auto toScreen = [&](glm::vec2 p) { return ImVec2(origin.x + picPan_.x + p.x * picZoom_, origin.y + picPan_.y + p.y * picZoom_); }; - const glm::vec2 mouse((io.MousePos.x - origin.x - picPan_.x) / picZoom_, (io.MousePos.y - origin.y - picPan_.y) / picZoom_); - if (hovered && io.MouseWheel != 0) { // zoom about the mouse - const float z = std::clamp(picZoom_ * std::pow(1.2f, io.MouseWheel), 0.05f, 64.0f); - picPan_ = ImVec2(io.MousePos.x - origin.x - mouse.x * z, io.MousePos.y - origin.y - mouse.y * z), picZoom_ = z; - } - if (ImGui::IsItemActive() && (ImGui::IsMouseDown(ImGuiMouseButton_Middle) || ImGui::IsMouseDown(ImGuiMouseButton_Right))) - picPan_.x += io.MouseDelta.x, picPan_.y += io.MouseDelta.y; - ImDrawList* dl = ImGui::GetWindowDrawList(); - dl->PushClipRect(origin, ImVec2(origin.x + avail.x, origin.y + avail.y), true); - dl->AddRectFilled(origin, ImVec2(origin.x + avail.x, origin.y + avail.y), IM_COL32(24, 24, 28, 255)); - uiImage(dl, paintTex_[o->id], toScreen({0, 0}), toScreen({float(W), float(H)})); - dl->AddRect(toScreen({0, 0}), toScreen({float(W), float(H)}), IM_COL32(0, 0, 0, 255)); - if (tab_ == Tab::Layout) { - if (o->uvsFit()) layoutCanvas(*o, dl, toScreen, mouse, hovered, W, H); - dl->PopClipRect(); - return ImGui::End(); - } - if (showUvLines_ && o->uvsFit()) - for (const auto& face : o->uvs) { - std::vector pts; - for (const glm::vec2& q : face) pts.push_back(toScreen(q * glm::vec2(W, H))); - dl->AddPolyline(pts.data(), int(pts.size()), IM_COL32(255, 255, 255, 60), ImDrawFlags_Closed, 1.0f); - } - PictureLayer* el = ledit_.object == o->id ? editLayer() : nullptr; - if (el && !el->sticker) { - ImVec2 q[4]; - for (int i = 0; i < 4; ++i) q[i] = toScreen(el->corners[size_t(i)]); - if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { - ledit_.drag = -1; - for (int i = 0; i < 4; ++i) - if (std::abs(io.MousePos.x - q[i].x) < 10 && std::abs(io.MousePos.y - q[i].y) < 10) ledit_.drag = i; - if (ledit_.drag < 0 && insidePolygon(io.MousePos, {q[0], q[1], q[2], q[3]})) ledit_.drag = 4; - if (ledit_.drag >= 0) pushUndo(); - else lockLayer(); - ledit_.grab = mouse, ledit_.startCorners = el->corners; - } - if (ledit_.drag >= 0 && ImGui::IsMouseDown(ImGuiMouseButton_Left)) { - const glm::vec2 d = mouse - ledit_.grab; - const int i = ledit_.drag; - if (i == 4) - for (int k = 0; k < 4; ++k) el->corners[size_t(k)] = ledit_.startCorners[size_t(k)] + d; - else if (io.KeyShift) { // bigger or smaller, the opposite corner staying - const glm::vec2 opp = ledit_.startCorners[size_t((i + 2) % 4)]; - const float k = glm::length(mouse - opp) / std::max(glm::length(ledit_.startCorners[size_t(i)] - opp), 1e-3f); - for (int j = 0; j < 4; ++j) el->corners[size_t(j)] = opp + (ledit_.startCorners[size_t(j)] - opp) * k; - } else - el->corners[size_t(i)] = ledit_.startCorners[size_t(i)] + d; - } - if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) ledit_.drag = -1; - for (int i = 0; i < 4; ++i) q[i] = toScreen(el->corners[size_t(i)]); - dl->AddPolyline(q, 4, kAccent, ImDrawFlags_Closed, 2.0f); - for (int i = 0; i < 4; ++i) { - const bool hot = ledit_.drag == i || (std::abs(io.MousePos.x - q[i].x) < 10 && std::abs(io.MousePos.y - q[i].y) < 10); - dl->AddRectFilled(ImVec2(q[i].x - 6, q[i].y - 6), ImVec2(q[i].x + 6, q[i].y + 6), hot ? kHot : IM_COL32(255, 255, 255, 240)); - dl->AddRect(ImVec2(q[i].x - 6, q[i].y - 6), ImVec2(q[i].x + 6, q[i].y + 6), IM_COL32(20, 20, 25, 255)); - } - } else { - const bool onPicture = mouse.x >= 0 && mouse.y >= 0 && mouse.x < float(W) && mouse.y < float(H); - const glm::vec2 uv = mouse / glm::vec2(W, H); - // Which layer is on top under the mouse (0: the painting). - int under = 0; - if (const auto it = owner_.find(o->id); onPicture && it != owner_.end() && it->second.size() == size_t(W) * size_t(H)) - under = it->second[size_t(int(mouse.y)) * size_t(W) + size_t(int(mouse.x))]; - if (hovered && under && !paint_.active) ImGui::SetTooltip("%s: click to move it", o->layers[size_t(under - 1)].name.c_str()); - if (hovered && onPicture && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && (under || el)) { - if (under) unlockLayer(o->id, under - 1); // (clicking a placed picture here unlocks it) - else lockLayer(); - } else if (hovered && onPicture && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { - if (paintTool_ == PaintTool::Picker || io.KeyAlt) pickColor(*o, uv); - else if (paintTool_ == PaintTool::Fill) fillAt(*o, uv); - else if (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Eraser) { - beginStroke(*o, true); - paint_.stroke.dab(*paint_.image, mouse, brushPx_ / picZoom_, hardness_); - paint_.last2D = mouse, paint_.haveLast = true; - } - } - if (paint_.active && paint_.in2D) { - if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) endStroke(); - else { - const float r = brushPx_ / picZoom_, gap = glm::distance(mouse, paint_.last2D); - const int steps = std::max(1, int(std::ceil(gap / std::max(r * 0.3f, 0.5f)))); - for (int i = 1; i <= steps; ++i) paint_.stroke.dab(*paint_.image, glm::mix(paint_.last2D, mouse, float(i) / float(steps)), r, hardness_); - paint_.last2D = mouse; - uploaded_[o->id].dirty = true; - } - } - if (hovered && (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Eraser)) dl->AddCircle(io.MousePos, brushPx_, IM_COL32(255, 255, 255, 200), 40, 1.5f); - } - dl->PopClipRect(); - ImGui::End(); - } - - void propertiesPanel() { - ImGui::SetNextWindowSizeConstraints(panelMin("Properties"), ImVec2(FLT_MAX, FLT_MAX)); - ImGui::Begin("Properties", nullptr, ImGuiWindowFlags_NoCollapse); - const float w = ImGui::GetContentRegionAvail().x; - if (tab_ == Tab::Paint) { - paintProperties(w); - return ImGui::End(); - } - if (tab_ == Tab::Render) { - renderProperties(w); - return ImGui::End(); - } - if (tab_ == Tab::Layout) { - layoutProperties(w); - return ImGui::End(); - } - if (tab_ == Tab::Print) { - printProperties(w); - return ImGui::End(); - } - if (Reference* ref = activeRef_ && selected_.empty() ? findRef(activeRef_) : nullptr) { - ImGui::TextWrapped("%s", ref->path.c_str()); - int axis = int(ref->axis); - if (ImGui::Combo("Facing", &axis, "Front view\0Side view\0Top view\0")) pushUndo(), ref->axis = Axis(axis); - undoable([&] { return ImGui::DragFloat2("Middle (m)", &ref->offset.x, 0.005f); }); - undoable([&] { return ImGui::DragFloat("Width (m)", &ref->width, 0.005f, -100.0f, 100.0f, "%.3f"); }); - ImGui::TextDisabled("A minus width flips it."); - undoable([&] { return ImGui::SliderFloat("See-through", &ref->opacity, 0.05f, 1.0f); }); - if (ImGui::Checkbox("Only in its own view", &ref->ownViewOnly)) dirty_ = true; - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Shown only looking straight at it (front, side or top): out of the way in the 3D view and the others"); - if (ImGui::Button("Remove picture", ImVec2(w, 0))) deleteSelected(); - ImGui::End(); - return; - } - if (Object* o = editing()) { - editProperties(*o, w); - ImGui::End(); - return; - } - if (selected_.empty()) { - ImGui::TextWrapped("Nothing selected.\n\nClick an object to select it, or add one with + in the toolbox (or the Add menu): " - "shapes and starter models, with sizes you can change. Draw shape (D) turns an outline you click into a solid."); - ImGui::End(); - return; - } - if (selected_.size() > 1) { - ImGui::Text("%zu objects selected", selected_.size()); - glm::vec3 col = find(active_) ? find(active_)->color : glm::vec3(0.8f); - if (undoable([&] { return ImGui::ColorEdit3("Colour (all)", &col.x); })) - for (uint32_t id : selected_) - if (Object* o = find(id)) o->color = col, dirty_ = true; - if (ImGui::Button("Join into one (Ctrl+J)", ImVec2(w, 0))) joinSelected(); - ImGui::End(); - return; - } - Object* o = find(*selected_.begin()); - if (!o) { - ImGui::End(); - return; - } - ImGui::BeginDisabled(o->locked); - char name[128]; - std::snprintf(name, sizeof name, "%s", o->name.c_str()); - if (undoable([&] { return ImGui::InputText("Name", name, sizeof name); }) && name[0]) o->name = name; - // A library shape: its own sizes, until its shape's edited by hand. - if (const ShapeRecipe* r = findRecipe(o->recipe)) { - ImGui::SeparatorText(std::format("{} settings", r->name).c_str()); - bool changed = false; - o->settings.resize(r->settings.size()); - for (size_t i = 0; i < r->settings.size(); ++i) { - const ShapeSetting& s = r->settings[i]; - const float speed = s.whole ? 0.1f : std::max(0.001f, (s.max - s.min) * 0.0005f); - changed |= undoable([&] { return ImGui::DragFloat(s.name, &o->settings[i], speed, s.min, s.max, s.whole ? "%.0f" : "%.3f"); }); - } - if (changed) o->mesh = makeFromRecipe(*r, o->settings), touched(o->id); - ImGui::TextDisabled("(These go once you edit the shape by hand.)"); - } - ImGui::SeparatorText("Place"); - undoable([&] { return ImGui::DragFloat3("Position (m)", &o->position.x, 0.005f, 0, 0, "%.3f"); }); - glm::vec3 euler = toEuler(o->rotation); - if (undoable([&] { return ImGui::DragFloat3("Turn (deg)", &euler.x, 0.5f, 0, 0, "%.1f"); })) o->rotation = fromEuler(euler); - if (undoable([&] { return ImGui::DragFloat3("Size", &o->scale.x, 0.005f, 0, 0, "%.3f"); })) touched(o->id); - const glm::vec3 lo = boundsMin(shownMesh(*o)) * glm::abs(o->scale), hi = boundsMax(shownMesh(*o)) * glm::abs(o->scale); - // Its size itself, typed (the scale worked out from it): 0.25 makes it 25 cm, whatever it was. - glm::vec3 dims = hi - lo; - const glm::vec3 was = dims; - if (undoable([&] { return ImGui::DragFloat3("Dimensions (m)", &dims.x, 0.002f, 0.0001f, 1e5f, "%.4g"); })) { - int k = -1; - for (int i = 0; i < 3; ++i) - if (std::abs(dims[i] - was[i]) > 1e-9f) k = i; - if (k >= 0 && was[k] > 1e-7f && dims[k] > 1e-7f) { - const float f = dims[k] / was[k]; - if (keepProportions_) o->scale *= f; - else o->scale[k] *= f; - touched(o->id); - } - } - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Across, deep, high: type a size and it's made that big"); - ImGui::Checkbox("Keep its proportions", &keepProportions_); - ImGui::SeparatorText("Look"); - materialPicker(*o); - undoable([&] { return ImGui::ColorEdit3("Colour", &o->color.x); }); - undoable([&] { return ImGui::SliderFloat("Metal", &o->metal, 0, 1); }); - undoable([&] { return ImGui::SliderFloat("Rough", &o->rough, 0, 1); }); - if (ImGui::Checkbox("Smooth shading", &o->smooth)) pushUndo(), touched(o->id); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Round surfaces look round; edges sharper than the angle stay sharp"); - if (o->smooth) { - ImGui::SameLine(); - ImGui::SetNextItemWidth(-1); - if (undoable([&] { return ImGui::SliderFloat("##angle", &o->smoothAngle, 1, 180, "creases > %.0f deg"); })) touched(o->id); - } - // Finishing touches that stay changeable: rounded edges, smoothing, a mirrored half. - ImGui::SeparatorText("Round, smooth, mirror"); - if (undoable([&] { return ImGui::DragFloat("Round edges", &o->round, 0.001f, 0.0f, 10.0f, o->round > 0 ? "%.3f m" : "off"); })) { - if (o->round > 0 && o->roundSteps > 1) o->smooth = true; - touched(o->id); - } - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rounds off every sharp edge and corner (drag right)"); - if (o->round > 0) { - if (undoable([&] { return ImGui::SliderInt("Steps round", &o->roundSteps, 1, 8); })) touched(o->id); - if (undoable([&] { return ImGui::SliderFloat("Edges sharper than", &o->roundAngle, 1, 179, "%.0f deg"); })) touched(o->id); - } - if (undoable([&] { return ImGui::SliderInt("Smooth out", &o->smoothLevels, 0, 3, o->smoothLevels ? "%d times" : "off"); })) { - if (o->smoothLevels) o->smooth = true; - touched(o->id); - } - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Cuts every face into smaller ones and rounds the whole shape (like clay)"); - if (o->smoothLevels && ImGui::Checkbox("Keep sharp edges sharp", &o->smoothKeepSharp)) pushUndo(), touched(o->id); - ImGui::TextDisabled("Mirror across"); - for (int a = 0; a < 3; ++a) { - ImGui::SameLine(); - bool on = o->mirror & (1 << a); - if (ImGui::Checkbox(a == 0 ? "X" : a == 1 ? "Y" : "Z", &on)) pushUndo(), o->mirror ^= 1 << a, touched(o->id); - } - if (o->hasFinish() && ImGui::Button("Make it part of the shape", ImVec2(w, 0))) applyFinish(*o); - if (o->hasFinish() && ImGui::IsItemHovered()) ImGui::SetTooltip("Bake the rounding, smoothing and mirror into the points (so you can edit them)"); - if (const Object* t = fry::cutTarget(scene_, *o)) { // (an editable cutter) - ImGui::SeparatorText("Cut"); - ImGui::TextColored(accentText(), "Cuts into %s", t->name.c_str()); - ImGui::TextDisabled("Move it, turn it, change its shape: the hole follows."); - const uint32_t target = t->id, me = o->id; - if (ImGui::Button("Apply: make the cut for good", ImVec2(w, 0))) return applyCuts(target, me); - if (ImGui::Button("Stop cutting (keep it as an object)", ImVec2(w, 0))) pushUndo(), o->cuts = 0, dirty_ = true; - } else if (cutKeys_.count(o->id)) { - int n = 0; - for (const Object& c : scene_.objects) n += c.cuts == o->id && isCutter(c); - ImGui::SeparatorText("Cut"); - ImGui::TextDisabled("%d editable cut%s in it", n, n == 1 ? "" : "s"); - if (ImGui::Button(n == 1 ? "Apply: make the cut for good" : "Apply: make them all for good", ImVec2(w, 0))) return applyCuts(o->id); - if (ImGui::Button("Pick the cutters", ImVec2(w, 0))) { - selected_.clear(); - for (const Object& c : scene_.objects) - if (c.cuts == o->id) selected_.insert(c.id), active_ = c.id; - } - } - ImGui::SeparatorText("Do"); - if (ImGui::Button("Edit shape (Tab)", ImVec2(w, 0))) enterEdit(); - if (ImGui::Button("Apply turn and size", ImVec2(w, 0))) applyTransform(*o); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Bake them into the shape: turn becomes 0, size 1"); - if (ImGui::Button("Origin to middle", ImVec2((w - 6) / 2, 0))) originToMiddle(*o); - ImGui::SameLine(0, 6); - if (ImGui::Button("Origin to bottom", ImVec2(-1, 0))) originToBottom(*o); - if (ImGui::Button("Flip X", ImVec2((w - 12) / 3, 0))) mirror(*o, 0); - ImGui::SameLine(0, 6); - if (ImGui::Button("Flip Y", ImVec2((w - 12) / 3, 0))) mirror(*o, 1); - ImGui::SameLine(0, 6); - if (ImGui::Button("Flip Z", ImVec2(-1, 0))) mirror(*o, 2); - ImGui::EndDisabled(); - if (ImGui::Button(o->locked ? "Unlock" : "Lock", ImVec2((w - 6) / 2, 0))) o->locked = !o->locked; - ImGui::SameLine(0, 6); - if (ImGui::Button("Delete", ImVec2(-1, 0))) deleteSelected(); - ImGui::End(); - } - - // A material to start from: sets colour, metal and rough in one go (each a little ball of it). - // Yours (saved here, or from plug-ins and scripts) first; "Save its look as a material" adds one. - void materialPicker(Object& o) { - auto matches = [&](const MaterialPreset& m) { - return std::abs(m.metal - o.metal) < 0.01f && std::abs(m.rough - o.rough) < 0.01f && - (m.keepColor || glm::all(glm::lessThan(glm::abs(m.color - o.color), glm::vec3(0.01f)))); - }; - const MaterialPreset* now = nullptr; - for (const MaterialPreset& m : materialPresets()) - if (matches(m)) now = &m; - for (const MaterialPreset& m : ownMaterials()) - if (matches(m)) now = &m; - ImGui::SetNextItemWidth(ImGui::CalcItemWidth()); - if (ImGui::BeginCombo("Material", now ? now->name.c_str() : "(its own)", ImGuiComboFlags_HeightLarge)) { - std::optional remove; - auto item = [&](const MaterialPreset& m, bool own) { - const ImVec2 p = ImGui::GetCursorScreenPos(); - const float h = ImGui::GetTextLineHeight(); - ImGui::PushID(m.id.c_str()); - ImGui::PushID(own); - if (ImGui::Selectable("##m", now == &m, 0, ImVec2(0, h))) pushUndo(), applyMaterial(o, m), touched(o.id); - if (own && m.from.empty() && ImGui::BeginPopupContextItem()) { - if (ImGui::MenuItem("Remove it")) remove = m.id; - ImGui::EndPopup(); - } - if (own && ImGui::IsItemHovered()) - ImGui::SetTooltip("%s", m.from.empty() ? "Yours (right-click to remove)" : ("From " + m.from).c_str()); - ImGui::PopID(); - ImGui::PopID(); - // A ball: its colour, darker round the edge, a highlight as sharp as it's smooth. - const glm::vec3 c = m.keepColor ? o.color : m.color; - const ImVec2 mid(p.x + h * 0.5f, p.y + h * 0.5f); - ImDrawList* dl = ImGui::GetWindowDrawList(); - dl->AddCircleFilled(mid, h * 0.45f, ImGui::ColorConvertFloat4ToU32(ImVec4(c.r * 0.55f, c.g * 0.55f, c.b * 0.55f, 1))); - dl->AddCircleFilled(ImVec2(mid.x - h * 0.05f, mid.y - h * 0.05f), h * 0.36f, ImGui::ColorConvertFloat4ToU32(ImVec4(c.r, c.g, c.b, 1))); - dl->AddCircleFilled(ImVec2(mid.x - h * 0.14f, mid.y - h * 0.14f), h * (0.06f + 0.1f * m.rough), IM_COL32(255, 255, 255, int(230 * (1.0f - m.rough * 0.8f)))); - dl->AddText(ImVec2(p.x + h * 1.4f, p.y), ImGui::GetColorU32(ImGuiCol_Text), m.name.c_str()); - }; - if (!ownMaterials().empty()) { - ImGui::SeparatorText("Yours"); - for (const MaterialPreset& m : ownMaterials()) item(m, true); - ImGui::SeparatorText("Fry3DX's"); - } - for (const MaterialPreset& m : materialPresets()) item(m, false); - ImGui::Separator(); - if (ImGui::Selectable("Save its look as a material...")) nameMaterial_ = true, materialName_.clear(), materialKeepColor_ = false; - ImGui::EndCombo(); - if (remove && removeOwnMaterial(*remove)) saveOwnMaterials(); - } - if (std::exchange(nameMaterial_, false)) ImGui::OpenPopup("Save as a material"); - ImGui::SetNextWindowSize(ImVec2(340, 0)); - if (ImGui::BeginPopupModal("Save as a material", nullptr, ImGuiWindowFlags_NoResize)) { - ImGui::TextWrapped("Its colour, metal and rough as one of your materials, in the picker for every file."); - if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); - ImGui::SetNextItemWidth(-FLT_MIN); - inputString("##name", materialName_, "Its name (\"Teal plastic\")"); - ImGui::Checkbox("Only how metal and rough (keep each object's colour)", &materialKeepColor_); - const bool taken = std::any_of(materialPresets().begin(), materialPresets().end(), [&](const MaterialPreset& m) { return m.id == materialId(materialName_); }); - if (taken) ImGui::TextColored(roleColor(keel::ui::Role::DangerText), "That's one of Fry3DX's own: another name"); - ImGui::BeginDisabled(materialId(materialName_).empty() || taken); - pushAccent(); - const bool save = ImGui::Button("Save", ImVec2(120, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter, false); - popAccent(); - ImGui::EndDisabled(); - ImGui::SameLine(); - if (save && !materialId(materialName_).empty() && !taken) { - addOwnMaterial({materialId(materialName_), materialName_, o.color, o.metal, o.rough, materialKeepColor_, ""}); - saveOwnMaterials(); - say("Saved \"" + materialName_ + "\": it's in the Material picker now"); - ImGui::CloseCurrentPopup(); - } - if (ImGui::Button("Cancel", ImVec2(120, 0))) ImGui::CloseCurrentPopup(); - ImGui::EndPopup(); - } - } - - void editProperties(Object& o, float w) { - ImGui::Text("Editing the shape of %s", o.name.c_str()); - ImGui::TextDisabled("%zu points, %zu faces", o.mesh.verts.size(), o.mesh.faces.size()); - if (o.hasFinish()) ImGui::TextDisabled("(its rounding and smoothing show again when you're done)"); - const std::set pts = selectedPoints(); - if (!pts.empty()) { - // Where the selection's middle is (in the world); typing moves it there. - const glm::vec3 c = pivotOfSelection(); - glm::vec3 at = c; - if (undoable([&] { return ImGui::DragFloat3("Middle (m)", &at.x, 0.002f, 0, 0, "%.3f"); })) { - const glm::mat4 m = objectMatrix(o), local = glm::inverse(m) * glm::translate(glm::mat4(1.0f), at - c) * m; - for (int v : pts) o.mesh.verts[size_t(v)] = glm::vec3(local * glm::vec4(o.mesh.verts[size_t(v)], 1.0f)); - handMade(o); - touched(o.id); - } - } - ImGui::SeparatorText("Faces"); - ImGui::SetNextItemWidth(w * 0.45f); - ImGui::DragFloat("##ext", &extrudeAmount_, 0.001f, -10, 10, "%.3f m"); - ImGui::SameLine(); - if (ImGui::Button("Extrude", ImVec2(-1, 0))) extrudeNow(extrudeAmount_); - ImGui::SetNextItemWidth(w * 0.45f); - ImGui::SliderFloat("##ins", &insetAmount_, 0.01f, 0.99f, "%.2f"); - ImGui::SameLine(); - if (ImGui::Button("Inset", ImVec2(-1, 0))) insetNow(insetAmount_); - if (ImGui::Button("Flip", ImVec2((w - 6) / 2, 0))) flipNow(); - ImGui::SameLine(0, 6); - if (ImGui::Button("Make face", ImVec2(-1, 0))) makeFaceNow(); - if (ImGui::Button("Bridge two faces", ImVec2(w, 0))) bridgeNow(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Two faces with as many corners: taken out and joined by a tunnel"); - if (ImGui::Button("Make into its own object (P)", ImVec2(w, 0))) separateSelected(); - ImGui::SeparatorText("Edges"); - ImGui::SetNextItemWidth(w * 0.45f); - ImGui::DragFloat("##bw", &bevelWidth_, 0.001f, 0.0f, 10.0f, "%.3f m"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(w * 0.2f); - ImGui::DragInt("##bs", &bevelSteps_, 0.1f, 1, 10); - ImGui::SameLine(); - if (ImGui::Button("Round", ImVec2(-1, 0))) bevelNow(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Round the selected edges (or the edges round the selected faces): width, steps"); - ImGui::SeparatorText("Points"); - if (ImGui::Button("Merge to middle (M)", ImVec2(w, 0))) mergeNow(); - if (ImGui::Button("Smooth", ImVec2((w - 6) / 2, 0))) smoothPointsNow(); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Even out bumps: each point towards its neighbours"); - ImGui::SameLine(0, 6); - if (ImGui::Button("Push / pull (Alt+S)", ImVec2(-1, 0))) startPush(); - ImGui::SetNextItemWidth(w * 0.45f); - ImGui::DragFloat("##md", &mergeDistance_, 0.0001f, 0.0f, 1.0f, "%.4f m"); - ImGui::SameLine(); - if (ImGui::Button("Merge close", ImVec2(-1, 0))) { - pushUndo(); - const int gone = mergeByDistance(o.mesh, mergeDistance_); - changedShape(o); - say(std::format("{} points merged", gone)); - } - ImGui::SeparatorText("Whole shape"); - if (ImGui::Button("Cut in four", ImVec2((w - 6) / 2, 0))) subdivideNow(false); - ImGui::SameLine(0, 6); - if (ImGui::Button("Smooth it", ImVec2(-1, 0))) subdivideNow(true); - ImGui::SeparatorText("Select"); - if (ImGui::Button("All", ImVec2((w - 12) / 3, 0))) selectAllElements(); - ImGui::SameLine(0, 6); - if (ImGui::Button("None", ImVec2((w - 12) / 3, 0))) clearElements(); - ImGui::SameLine(0, 6); - if (ImGui::Button("Invert", ImVec2(-1, 0))) invertSelection(); - if (ImGui::Button("More", ImVec2((w - 12) / 3, 0))) growSelection(); - ImGui::SameLine(0, 6); - if (ImGui::Button("Less", ImVec2((w - 12) / 3, 0))) shrinkSelection(); - ImGui::SameLine(0, 6); - if (ImGui::Button("Joined", ImVec2(-1, 0))) selectLinked(); - ImGui::Separator(); - if (ImGui::Button("Delete (Del)", ImVec2(w, 0))) deleteElements(); - if (ImGui::Button("Done (Tab)", ImVec2(w, 0))) leaveEdit(); - } - - void statusBar() { - ImGui::Begin("Status", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoScrollbar); - if (now() - messageAt_ < 4.0) ImGui::TextColored(accentText(), "%s", message_.c_str()); - else if (tab_ == Tab::Layout) { - for (const LayToolInfo& t : kLayTools) - if (t.tool == layTool_) - ImGui::TextDisabled("%s: %s · Tab: frames or corners Right drag: orbit (pan in the Picture panel) F1: all keys", t.name, t.hint); - } else if ((tab_ == Tab::Print && printTool_ != PrintTool::None) || printTool_ == PrintTool::Measure) { - ImGui::TextDisabled(printTool_ == PrintTool::Measure ? "Measure: click two points on the model (corners catch the mouse); a third click starts again · M (Print) or Esc: done" - : "Lay flat: click the face it should stand on: it turns so that face is down, on the bed · L: done"); - } else if (tab_ == Tab::Paint && sculpting_) { - for (const SculptToolInfo& t : kSculptTools) - if (t.brush == sculptBrush_) ImGui::TextDisabled("%s: %s · Shift: smooth X: both sides Tab: colour Right drag: orbit F1: all keys", t.name, t.hint); - } else if (tab_ == Tab::Paint) { - for (const PaintToolInfo& t : kPaintTools) - if (t.tool == paintTool_) ImGui::TextDisabled("%s: %s · Right drag: orbit Wheel: zoom (in the Picture panel too) F1: all keys", t.name, t.hint); - } else - for (const ToolInfo& t : kTools) - if (t.tool == tool_) ImGui::TextDisabled("%s: %s · Right drag: orbit Middle drag: pan Wheel: zoom F1: all keys", t.name, t.hint); - ImGui::End(); - } - - // The view's corner: which way you're looking, buttons to look straight at a side, and - // handles to drag to orbit, pan and zoom (for a touchpad or a pen). - void viewButtons() { - const ImVec2 at(viewMax_.x - 10, viewMin_.y + 10); - ImGui::SetNextWindowPos(at, ImGuiCond_Always, ImVec2(1, 0)); - ImGui::SetNextWindowBgAlpha(0.55f); - ImGui::Begin("##views", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | - ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDocking); - static const char* names[] = {"Perspective", "Front", "Back", "Left", "Right", "Top", "Bottom"}; - static const char* shadings[] = {"Solid", "Rendered", "Wireframe"}; - ImGui::TextColored(accentText(), "%s", names[int(view_)]); - ImGui::SameLine(); - ImGui::TextDisabled("%s%s", shadings[int(shading_)], xray_ ? ", X-ray" : ""); - for (int i = 1; i < 7; ++i) { - if (i != 1 && i != 4) ImGui::SameLine(0, 3); - if (ImGui::Button(names[i], ImVec2(56, 0))) setView(View(i)); - } - if (ImGui::Button("3D", ImVec2(56, 0))) leaveFlatView(); - ImGui::SameLine(0, 3); - if (ImGui::Button("Frame", ImVec2(56, 0))) frameSelected(); - ImGui::SameLine(0, 3); - if (ImGui::Button(xray_ ? "Solid" : "X-ray", ImVec2(56, 0))) xray_ = !xray_; - const ImVec2 d = ImGui::GetIO().MouseDelta; - ImGui::Button("Orbit", ImVec2(56, 0)); - if (ImGui::IsItemActive()) orbitBy(d); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Drag to orbit"); - ImGui::SameLine(0, 3); - ImGui::Button("Pan", ImVec2(56, 0)); - if (ImGui::IsItemActive()) panBy(d); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Drag to pan"); - ImGui::SameLine(0, 3); - ImGui::Button("Zoom", ImVec2(56, 0)); - if (ImGui::IsItemActive()) dist_ = std::clamp(dist_ * std::pow(1.01f, d.y), 0.01f, 5000.0f); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Drag up and down to zoom"); - if (tab_ != Tab::Render && ImGui::Button(shading_ == Shading::Rendered ? "Show: Solid" : "Show: Rendered", ImVec2(56 * 3 + 6, 0))) - shading_ = shading_ == Shading::Rendered ? Shading::Solid : Shading::Rendered; - ImGui::End(); - } - - // ───────────────────────────── scripts ───────────────────────────── - - void scriptLine(std::string text, bool error = false) { - 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; - } - fry::SceneScript* currentScript() { return script_ < scene_.scripts.size() ? &scene_.scripts[script_] : nullptr; } - // "little_town.lua" -> "little town" (a script's name in the list). - static std::string scriptName(const std::string& path) { - 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). - void addScript(std::string name, std::string code) { - 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. - void runScriptNow() { - 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. - void finishRun(const fry::ScriptResult& r, const std::string& name, bool frame) { - 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)); - } - void openScript(const std::string& path, const std::string& shownName) { - std::ifstream in(path, std::ios::binary); - if (!in) return say("Couldn't open " + shownName); - addScript(scriptName(shownName), std::string(std::istreambuf_iterator(in), {})); - scriptPath_ = isUri(path) ? std::string() : path; - say("Opened " + shownName + ": it's kept in this file now. F5 runs it"); - } - void saveScript(std::string file) { - 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. - void exampleScripts() { - if (!ImGui::BeginMenu("Examples")) return; - exampleItems(false); - ImGui::EndMenu(); - } - void exampleItems(bool selectable) { - 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); - } - } - - // ── Own materials (materials.txt in the settings folder) ── - static std::string ownMaterialsPath() { return keel::prefPath() + "materials.txt"; } - void loadOwnMaterials() { - std::ifstream in(ownMaterialsPath(), std::ios::binary); - if (in) parseOwnMaterials(std::string(std::istreambuf_iterator(in), {})); - } - void saveOwnMaterials() { - const std::string text = ownMaterialsText(); - std::string error; - if (!writeFileSafely(ownMaterialsPath(), text.data(), text.size(), &error)) say("Couldn't keep your materials: " + error); - } - - // ── Plug-ins: content/Fry3DX/plugins (they come with it) and the settings folder's plugins ── - static std::string pluginsFolder() { return keel::prefPath() + "plugins/"; } - std::set pluginsOff() const { - std::set off; - std::istringstream in(fry_pluginsoff.str()); - for (std::string f; std::getline(in, f, '|');) - if (!f.empty()) off.insert(f); - return off; - } - void setPluginOn(const std::string& file, bool on) { - std::set 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(); - } - void loadPlugins() { - for (const PluginEntry& e : plugins_) fry::clearOwnMaterials(e.file); - plugins_.clear(); - std::error_code ec; - stdfs::create_directories(pluginsFolder(), ec); - std::vector> 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 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(in), {})}); - } - const std::set off = pluginsOff(); - fry::Scene scratch; // (loading a plug-in can't touch the open file: only its commands, when run, do) - std::set none; - uint32_t noActive = 0; - for (auto& [e, code] : found) { - e.on = !off.count(e.file); - if (e.on) { - e.p = std::make_unique(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); - } - void copyPlugin(const PluginEntry& e) { - 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)"); - } - void openPluginsFolder() { - std::error_code ec; - stdfs::create_directories(pluginsFolder(), ec); - SDL_OpenURL(("file://" + pluginsFolder()).c_str()); - } - bool pluginUsable(size_t i) const { return i < plugins_.size() && plugins_[i].p && plugins_[i].error.empty(); } - void pluginMenuItems(const char* menu) { - 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); - } - } - void pluginMenuItem(size_t i, size_t c) { - 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()); - } - void runPluginCommand(size_t i, size_t c, const std::vector& values) { - 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. - void commandForm() { - 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(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(); - } - void pluginsWindow() { - 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> 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); - } - static int growString(ImGuiInputTextCallbackData* d) { - if (d->EventFlag == ImGuiInputTextFlags_CallbackResize) { - auto* str = static_cast(d->UserData); - str->resize(size_t(d->BufTextLen)); - d->Buf = str->data(); - } - return 0; - } - // A one-line field for a std::string (it grows as it's typed into). - static bool inputString(const char* label, std::string& text, const char* hint = nullptr) { - return ImGui::InputTextWithHint(label, hint ? hint : "", text.data(), text.capacity() + 1, ImGuiInputTextFlags_CallbackResize, growString, &text); - } - void scriptPanel() { - 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 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(); - } - - void helpWindow() { - 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> 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). - void myKeysTable() { - 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(); - } - - // ── (being made) ── - bool showPrintSize_ = false, liveMirror_ = false, keepProportions_ = false; - // Each picked object (or all, in the Print tab with none picked) made ready to print. - void repairSelected() { - std::vector 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 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. - void creaseSelected() { - Object* o = editing(); - if (!o) return say("Crease works on a shape being edited (Tab)"); - std::set> picked; - if (elem_ == Elem::Edge) - for (auto [a, b] : selEdges_) picked.insert({std::min(a, b), std::max(a, b)}); - else { - const std::set 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)")); - } - // Slide (Alt+G): the picked points glide along their edges, each along the edge that goes - // most the way the mouse goes, never past its other end. Click: done. Esc / right click: as it was. - struct Slide { - bool active = false; - uint32_t object = 0; - Mesh from; - std::vector pts; - std::vector> rails; // (each point's neighbours that aren't picked: where it can go) - ImVec2 start{0, 0}; - } slide_; - void startSlide() { - Object* o = editing(); - if (!o) return say("Slide works on a shape being edited (Tab)"); - const std::set pts = selectedPoints(); - if (pts.empty()) return say("Pick points, edges or faces to slide first"); - std::vector> 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)"); - } - void cancelSlide() { - 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"); - } - void slideFrame(const ImGuiIO& io) { - 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...). - bool lookingAt(Axis a) const { - 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; - } - } - std::string workspaceDir() const { return keel::prefPath() + "workspaces/"; } - void resetLayout() { - layoutAgain_ = true, layoutDone_ = false; - say("The panels are back where they started"); - } - void workspacesWindow() { - 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 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(in), {}); - say("Workspace: " + f.stem().string()); - } - ImGui::SameLine(270); - if (ImGui::SmallButton("Delete")) stdfs::remove(f, ec); - ImGui::PopID(); - } - ImGui::End(); - } - static std::string fileSafe(std::string n) { - 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. - void historyWindow() { - 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(); - } - - void aboutWindow() { - 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> 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 +} // namespace fryapp int main(int argc, char** argv) { std::string path; @@ -8814,6 +1722,6 @@ int main(int argc, char** argv) { #if defined(__ANDROID__) // (a phone: sideways only; SDL would otherwise let a resizable window turn upright) SDL_SetHint(SDL_HINT_ORIENTATIONS, "LandscapeLeft LandscapeRight"); #endif - App app(path); + fryapp::App app(path); return keel::run(app, int(rest.size()), rest.data()); } diff --git a/src/app.h b/src/app.h new file mode 100644 index 0000000..baf6141 --- /dev/null +++ b/src/app.h @@ -0,0 +1,1378 @@ +// Fry3DX — Frying 3D Extreme (the app: its state and what it does, declared; defined in app*.cpp). A 3D modeller that works the way you'd expect: pick a tool from the +// toolbox, click and drag. The view in the middle is drawn by the engine's renderer: plain +// and clear while you model (Solid), or properly lit (Rendered). The panels are Dear ImGui. +// +// fry3dx [file.fry] +#pragma once +#include "bvh.h" +#include "commands.h" +#include "icons.h" +#include "mesh.h" +#include "scene.h" +#include "script.h" +#include "sculpt.h" +#include "shapes.h" +#include "import.h" +#include "text.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace fry; +namespace stdfs = std::filesystem; + +namespace fryapp { + +inline double now() { return double(SDL_GetTicks()) / 1000.0; } // (seconds; works before the UI exists) + +constexpr float kUnits = 39.37f; // the engine's units (inches) in a metre: the view renders in them +constexpr float kPi = 3.14159265358979f; + +enum class Mode { Object, Edit }; +enum class Tool { Select, Move, Rotate, Scale, Draw, Extrude, Inset, Bevel, LoopCut, Knife, Hole }; +enum class Tab { Model, Paint, Layout, Render, Print }; +enum class LayTool { Select, Move, Rotate, Scale }; +enum class PrintTool { None, Measure, LayFlat }; +enum class PaintTool { Brush, Eraser, Fill, Picker, Picture }; +enum class Elem { Vertex, Edge, Face }; +enum class View { Perspective, Front, Back, Left, Right, Top, Bottom }; +enum class Orient { Local, World, Normal }; // which way the gizmo's arrows point +enum class Pivot { Middle, Origin, Opposite, Bottom }; // what stays put when turning / sizing +enum class Shading { Solid, Rendered, Wire }; +enum class SelShape { Box, Brush, Lasso }; + +struct ToolInfo { + Tool tool; + Icon icon; + const char* name; + const char* key; + const char* hint; + bool objectMode, editMode; +}; +const ToolInfo kTools[] = { + {Tool::Select, Icon::Select, "Select", "Q", "Click to select; drag round things. Objects: Shift adds, Ctrl takes away. Shape: Ctrl adds/takes one, Shift+click a whole loop, Shift+Ctrl+click an edge ring.", true, true}, + {Tool::Move, Icon::Move, "Move", "W", "Drag an arrow to move along it, the square to move freely. Hold Ctrl to snap.", true, true}, + {Tool::Rotate, Icon::Rotate, "Rotate", "E", "Drag a ring to turn about it; the outer ring turns it as you see it. Ctrl: 15 degree steps.", true, true}, + {Tool::Scale, Icon::Scale, "Scale", "R", "Drag a side's handle to stretch it (the other side stays, or see Tool options); hold Shift to stretch both sides from the middle. Middle: all ways.", true, true}, + {Tool::Draw, Icon::Draw, "Draw shape", "D", "Click points round a shape (on the ground, or on any face of an object); click the first point (or press Enter) to finish. Esc cancels.", true, false}, + {Tool::Extrude, Icon::Extrude, "Extrude", "X", "Select faces, then drag the arrow to pull them out (along the way they face).", false, true}, + {Tool::Inset, Icon::Inset, "Inset", "I", "Select faces, then drag the handle to make a smaller face inside each.", false, true}, + {Tool::Bevel, Icon::Bevel, "Round edges", "B", "Select edges (or faces), then drag the round handle to round them off.", false, true}, + {Tool::LoopCut, Icon::LoopCut, "Loop cut", "C", "Point at an edge: the cut shows across. Click to cut there.", false, true}, + {Tool::Knife, Icon::Knife, "Knife", "K", "Click points along where to cut, or drag a straight line. Enter or double-click cuts. Cutting right through: Tool options.", false, true}, + {Tool::Hole, Icon::Hole, "Hole", "O", "Press on a face and drag out the size: round, rectangle, triangle or hexagon (nuts); all the way through, a pocket, or only its outline (a new face).", true, true}, +}; + +struct PaintToolInfo { + PaintTool tool; + Icon icon; + const char* name; + const char* key; + const char* hint; +}; +struct LayToolInfo { + LayTool tool; + Icon icon; + const char* name; + const char* key; + const char* hint; +}; +const LayToolInfo kLayTools[] = { + {LayTool::Select, Icon::Select, "Select", "Q", "Click frames (or corners) to pick them, Shift adds; drag round them to pick many. Clicking the model picks its frame too."}, + {LayTool::Move, Icon::Move, "Move", "W", "Drag frames (or corners) about the picture."}, + {LayTool::Rotate, Icon::Rotate, "Turn", "E", "Drag round to turn what's picked (Ctrl: 15 degree steps)."}, + {LayTool::Scale, Icon::Scale, "Size", "R", "Drag out or in to make what's picked bigger or smaller (Shift: only across or only up)."}, +}; +// Printers' build plates (mm, across x deep x high) and what's printed with. +struct PrinterInfo { + const char* name; + float x, y, z; +}; +const PrinterInfo kPrinters[] = { + {"Prusa MK4 / MK4S", 250, 210, 220}, {"Prusa Core One", 250, 220, 270}, {"Prusa MINI+", 180, 180, 180}, {"Bambu Lab X1 / P1 / P2", 256, 256, 256}, + {"Bambu Lab A1", 256, 256, 256}, {"Bambu Lab A1 mini", 180, 180, 180}, {"Creality Ender 3 (V2, V3)", 220, 220, 250}, {"Creality K1", 220, 220, 250}, + {"Elegoo Neptune 4", 225, 225, 265}, {"Anycubic Kobra 3", 250, 250, 260}, {"Your own size", 0, 0, 0}, +}; +struct MaterialInfo { + const char* name; + float density; // g/cm3 +}; +// What the Script panel starts with. +inline const char* kFirstScript = R"(-- A script builds or changes the scene. F5 or Ctrl+Enter runs it; +-- Ctrl+Z takes back everything it did. The ? button lists what there is. + +local legs = 6 +for i = 1, legs do + local a = math.rad(i * 360 / legs) + local leg = fry.add("cylinder", {radius = 0.03, height = 0.7, color = "#c47a3c"}) + leg.position = {math.cos(a) * 0.45, math.sin(a) * 0.45, 0} +end +local top = fry.add("cylinder", {radius = 0.6, height = 0.05, sides = 48, color = "#d9a066"}) +top.position = {0, 0, 0.7} +fry.frame() +)"; +const MaterialInfo kMaterials[] = {{"PLA", 1.24f}, {"PETG", 1.27f}, {"ABS", 1.04f}, {"ASA", 1.07f}, {"TPU", 1.21f}, {"Nylon", 1.14f}, {"Resin", 1.12f}}; + +struct SculptToolInfo { + SculptBrush brush; + Icon icon; + const char* name; + const char* key; + const char* hint; +}; +const SculptToolInfo kSculptTools[] = { + {SculptBrush::Draw, Icon::Sculpt, "Raise", "1", "Builds the surface up where you brush. Ctrl digs in instead."}, + {SculptBrush::Inflate, Icon::Inflate, "Puff out", "2", "Swells it out all round, like blowing it up. Ctrl shrinks it."}, + {SculptBrush::Smooth, Icon::Smooth, "Smooth", "3", "Evens out bumps. Hold Shift with any brush to smooth."}, + {SculptBrush::Flatten, Icon::Flatten, "Flatten", "4", "Presses it flat, like a palette knife."}, + {SculptBrush::Pinch, Icon::Pinch, "Pinch", "5", "Pulls it in towards the middle of the brush: sharp creases and ridges. Ctrl pushes apart."}, + {SculptBrush::Grab, Icon::Grab, "Grab", "6", "Take hold of a patch and drag it where it should be."}, +}; + +const PaintToolInfo kPaintTools[] = { + {PaintTool::Brush, Icon::PaintBrush, "Brush", "B", "Paint on the model or on the flat picture. Alt+click picks a colour; [ and ] change the size."}, + {PaintTool::Eraser, Icon::Eraser, "Eraser", "E", "Paints its plain colour back."}, + {PaintTool::Fill, Icon::Fill, "Fill", "G", "Fills the patch of about the same colour where you click."}, + {PaintTool::Picker, Icon::Picker, "Pick colour", "I", "Click to take a colour from the model or the picture."}, + {PaintTool::Picture, Icon::Picture, "Place a picture", "Ctrl+V", "Paste a picture (copied in any app) or add one from a file, then drag its corners to fit. Enter places it."}, +}; + +inline glm::vec3 forwardOf(float yaw, float pitch) { + const float y = glm::radians(yaw), p = glm::radians(pitch); + return {std::cos(p) * std::cos(y), std::cos(p) * std::sin(y), -std::sin(p)}; +} + +// The engine's view-projection (render/renderer.cpp), in metres. +inline glm::mat4 viewProjection(const glm::vec3& eye, float yaw, float pitch, float fov, float aspect, glm::vec2 shift, glm::vec2 nearFar) { + const glm::vec3 f = forwardOf(yaw, pitch); + glm::mat4 view = glm::lookAt(eye * kUnits, eye * kUnits + f, glm::vec3(0, 0, 1)); + glm::mat4 proj = glm::perspective(glm::radians(fov), aspect, nearFar.x, nearFar.y); + proj[1][1] *= -1.0f; + proj[2][0] -= shift.x, proj[2][1] -= shift.y; + return proj * view * glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)); +} + +struct Ray { + glm::vec3 origin, dir; +}; + +// Möller–Trumbore; t along the ray, or nothing. +inline std::optional hitPlane(const Ray& r, const glm::vec3& point, const glm::vec3& normal) { + const float d = glm::dot(r.dir, normal); + if (std::abs(d) < 1e-8f) return std::nullopt; + const float t = glm::dot(point - r.origin, normal) / d; + return t > 0 ? std::optional(r.origin + r.dir * t) : std::nullopt; +} + +// Where along the line (from `p`, along unit `axis`) the ray passes closest. +inline float closestOnAxis(const Ray& r, const glm::vec3& p, const glm::vec3& axis) { + const glm::vec3 w = p - r.origin; + const float b = glm::dot(axis, r.dir), d = glm::dot(axis, w), e = glm::dot(r.dir, w); + const float den = 1.0f - b * b; + return den < 1e-6f ? 0.0f : (b * e - d) / den; +} + +inline float distToSegment(ImVec2 p, ImVec2 a, ImVec2 b) { + const float vx = b.x - a.x, vy = b.y - a.y, len = vx * vx + vy * vy; + const float t = len > 0 ? std::clamp(((p.x - a.x) * vx + (p.y - a.y) * vy) / len, 0.0f, 1.0f) : 0.0f; + const float dx = a.x + vx * t - p.x, dy = a.y + vy * t - p.y; + return std::sqrt(dx * dx + dy * dy); +} + +inline std::string lower(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return char(std::tolower(c)); }); + return s; +} +inline bool insidePolygon(ImVec2 p, const std::vector& poly) { + bool in = false; + for (size_t i = 0, j = poly.size() - 1; i < poly.size(); j = i++) + if ((poly[i].y > p.y) != (poly[j].y > p.y) && p.x < (poly[j].x - poly[i].x) * (p.y - poly[i].y) / (poly[j].y - poly[i].y) + poly[i].x) in = !in; + return in; +} + +inline float snapTo(float v, float step) { return step > 0 ? std::round(v / step) * step : v; } + +using Axes = std::array; +inline glm::vec3 inFrame(const Axes& a, const glm::vec3& p) { return {glm::dot(p, a[0]), glm::dot(p, a[1]), glm::dot(p, a[2])}; } +inline glm::vec3 fromFrame(const Axes& a, const glm::vec3& c) { return a[0] * c.x + a[1] * c.y + a[2] * c.z; } + +const ImU32 kAxisCol[3] = {IM_COL32(235, 70, 70, 255), IM_COL32(110, 210, 80, 255), IM_COL32(70, 130, 245, 255)}; +const ImU32 kHot = IM_COL32(255, 225, 90, 255); +constexpr ImU32 kAccent = IM_COL32(255, 120, 40, 255); +// The UI's colours: Niveus's roles, in the appearance that's showing (keel/ui). +inline ImVec4 roleColor(keel::ui::Role r) { return ImGui::ColorConvertU32ToFloat4(keel::ui::color(r)); } +inline ImVec4 accentText() { return roleColor(keel::ui::Role::AccentText); } +// The one button that does it, filled with the accent (until popAccent). +inline void pushAccent() { + using keel::ui::Role; + ImGui::PushStyleColor(ImGuiCol_Button, roleColor(Role::Accent)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, roleColor(Role::AccentHover)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, roleColor(Role::AccentActive)); + ImGui::PushStyleColor(ImGuiCol_Text, roleColor(Role::OnAccent)); +} +inline void popAccent() { ImGui::PopStyleColor(4); } + +// An object as it'll be printed: where it stands, with its finish, checked (kept until it changes). +struct PrintInfo { + uint64_t key = ~0ull; + Mesh world; + PrintCheck check; + glm::vec3 lo{0.0f}, hi{0.0f}; + keel::ModelId overhang{}; + bool hasOverhang = false; + // Walls too thin to print: how thick at each face (worked out again only when its shape or + // size changes: moving or turning it doesn't), how many under the least, the thinnest. + uint64_t thickKey = ~0ull; + std::vector thick; + int thinFaces = 0; + float thinnest = 1e30f; + keel::ModelId thin{}; + // The slice preview's layer: where it was cut, and for which shape (kept beside what it's of). + mutable uint64_t sliceKey = ~0ull; + mutable float sliceZ = -1; + mutable std::vector> slice; +}; + +extern keel::Cvar fry_printer; +extern keel::Cvar fry_bed_x; +extern keel::Cvar fry_bed_y; +extern keel::Cvar fry_bed_z; +extern keel::Cvar fry_touch; +extern keel::Cvar fry_uiscale; +extern keel::Cvar fry_uisize; +extern keel::Cvar fry_theme; +extern keel::Cvar fry_pluginsoff; +extern keel::Cvar fry_autosave; + +class App : public keel::Game {public: + explicit App(std::string path) : startPath_(std::move(path)) {} + const char* name() const override { return "Fry3DX"; } // (its settings folder, and bin/Fry3DX/) + const char* title() const override { return "Fry3DX"; } + bool wantsCursor() const override { return true; } + bool dockingUi() const override { return true; } + + // "Spiral_stairs" is "Spiral stairs" (typed in the console). + static bool sameCommand(const std::string& name, std::string typed); void init() override; + + // Going into the background: the work is saved now (a phone may close the app while it's + // away, to free memory), and a phone's next start carries on with it, view and all. + std::string resumeFile() const { return keel::prefPath() + "resume.txt"; } void onBackground() override; void resumeWork(); + + void frame(double) override { autosave(); } bool allowQuit() override; + void shutdown() override { + if (autosaveJob_.valid()) autosaveJob_.wait(); + } void onEscape() override; + + void render(keel::Renderer& r) override; + +private: + // ── The document ── + Scene scene_; + std::string startPath_, path_; + bool dirty_ = false; + // An undo step. Its objects without their shapes (points, faces, painting's layout): those are + // kept apart and shared with the steps before and after where they're the same, so a step + // that moved one thing doesn't copy every shape in the scene again. + struct Shape { + std::shared_ptr mesh; + std::shared_ptr>> uvs; + }; + struct Snapshot { + Scene scene; + std::map shapes; + std::set selected; + uint32_t active; + Mode mode; + std::set verts, faces; + std::set> edges; + std::string label; // (what was done after it: the message said then) + double at = 0; + }; + std::vector undo_, redo_; + std::map lastShapes_; // (each object's shape as last kept: the next step shares it if it's the same) + Snapshot snapshot(std::string label, double at); + std::vector redoKept_; // (what redo had before the last undo point: back if that step is dropped) + + Mode mode_ = Mode::Object; + Tool tool_ = Tool::Move; + Elem elem_ = Elem::Face; + std::set selected_; + uint32_t active_ = 0; // the object last picked (edited in shape editing) + uint32_t activeRef_ = 0; // a reference picture picked in the Layers panel + std::set selVerts_, selFaces_; + std::set> selEdges_; + + // ── Tabs: modelling, painting pictures onto it, making a picture of it ── + Tab tab_ = Tab::Model; + + // ── The view ── + keel::Renderer* r_ = nullptr; + glm::vec2 shift_{0.0f}; // (the view's middle on the screen: it sits between the panels) + View view_ = View::Perspective; + glm::vec3 target_{0, 0, 0.4f}; + float yaw_ = -135.0f, pitch_ = 25.0f, dist_ = 4.0f, fov_ = 50.0f; + glm::vec2 nearFar_{2.0f, 16384.0f}; + std::map> radii_; // (each object's mesh: how far it reaches from its place) + ImVec2 viewMin_{0, 0}, viewMax_{1, 1}; + glm::mat4 vp_{1.0f}; + glm::vec3 eye_{0.0f}; + bool orbiting_ = false, panning_ = false; + bool xray_ = false, showGrid_ = true, renderGrid_ = false; + std::string appliedLook_; + keel::ModelId ground_{}; + bool haveGround_ = false; + Shading shading_ = Shading::Solid; + + // ── Tools ── + bool snap_ = false; + int snapKind_ = 0; // (moving: 0 steps, 1 points, 2 edges' middles, 3 faces' middles) + float snapMove_ = 0.05f, snapAngle_ = 15.0f, snapScale_ = 0.1f; + float drawThickness_ = 0.05f, extrudeAmount_ = 0.1f, insetAmount_ = 0.2f, mergeDistance_ = 0.001f; + float bevelWidth_ = 0.05f; + int bevelSteps_ = 3, cuts_ = 1; + Orient orientObject_ = Orient::Local, orientEdit_ = Orient::Normal; + Pivot rotatePivot_ = Pivot::Middle, scalePivot_ = Pivot::Opposite; + SelShape selShape_ = SelShape::Box; + float brushRadius_ = 30.0f; + std::vector drawPts_; + // Drawing on an object: the plane of the face the first point went on, and what's made with it + // (0 a new object standing on it, 1 added to it, 2 cut into it). + glm::vec3 drawOnP_{0.0f}, drawOnN_{0.0f, 0.0f, 1.0f}; + uint32_t drawOnto_ = 0; + int drawOnMode_ = 0; + struct Drag { + bool active = false; + int handle = -1; // 0-2 an axis, 3 free / even / about the view, 4-6 an axis's other end + Tool tool = Tool::Select; + Axes axes{}; + glm::vec3 pivot{0.0f}, anchor{0.0f}, handlePoint{0.0f}; + ImVec2 startMouse{0, 0}; + float startT = 0.0f, startDist = 0.0f, startMid = 0.0f; // (the handle's way from what stays put; from the middle) + glm::vec3 startHit{0.0f}; + Scene before; + std::set> bevelEdges; + std::vector> soft; // points near the selection that follow it, and how much + std::string typed; // (a number typed while dragging: exactly that much) + } drag_; + // Soft selection (shape editing): points within `softReach_` of the selection follow it, less + // the further they are (Blender's proportional editing, Maya's soft select). + bool softSel_ = false; + float softReach_ = 0.3f; + // Push out / pull in (Alt+S): the points and the shape as it was when it started. + bool showPush_ = false; + float pushBy_ = 0.0f; + std::set pushPts_; + Mesh pushFrom_; + uint32_t pushObject_ = 0; + bool boxing_ = false, painting_ = false, lassoing_ = false, tapDraw_ = false; + ImVec2 boxStart_{0, 0}; + std::vector lasso_; + std::pair cutHover_{-1, -1}; + // The knife: points clicked on the screen (or a straight drag). + std::vector knifePts_; + bool knifeDown_ = false; + ImVec2 knifePress_{0, 0}; + bool knifeThrough_ = false, knifeFill_ = true; + int knifeAfter_ = 1; // cut right through, then: 0 just the lines, 1 pieces of the one object, 2 objects of their own, 3 the smaller ones gone + // Holes: pressed on a face, dragged out to size. + // (sizes in metres where it stands: across = a round's width, a rectangle's width, a triangle's + // side, a hexagon's flats; tall: a rectangle's height) + float holeSize_ = 0.2f, holeTall_ = 0.1f, holeDepth_ = 0.1f, holeTurn_ = 0.0f, holeRoom_ = 0.0003f; + int holeShape_ = 0, holeSides_ = 32; // a HoleShape + int holeMode_ = 0; // 0 all the way through, 1 a pocket, 2 the outline only (a new face) + struct HoleDrag { + bool active = false; + uint32_t object = 0; + int face = -1; + glm::vec3 centre{0.0f}; // (the object's own space) + float radius = 0.0f; + ImVec2 press{0, 0}; + } hole_; + + // ── Painting ── + PaintTool paintTool_ = PaintTool::Brush; + glm::vec4 paintColor_{0.85f, 0.25f, 0.1f, 1.0f}; // a: how strong + float brushPx_ = 16.0f, hardness_ = 0.5f, fillTolerance_ = 0.15f; + int pictureSize_ = 1024; + bool showUvLines_ = true; + struct PaintStroke { + bool active = false, in2D = false, haveLast = false, waiting = false; // waiting: pressed off the object, starts when the brush gets to it + uint32_t object = 0; + std::shared_ptr image; // (the object's picture: a copy made for this stroke) + Stroke stroke; + glm::vec3 last{0.0f}; + glm::vec2 last2D{0.0f}; + } paint_; + // Sculpting (the Paint tab's Shape side): brushes that push the surface about like clay. + bool sculpting_ = false; + SculptBrush sculptBrush_ = SculptBrush::Draw; + float sculptStrength_ = 0.5f, sculptPx_ = 60.0f; // (its own brush size: sculpting wants a bigger one than painting) + bool paintMirror_ = false, stencilOn_ = false, drawingStencil_ = false; + std::vector stencil_; // (on the screen) + bool sculptMirror_ = false; // the same on the other side (left and right: the object's Y) + struct SculptStroke { + bool active = false, haveLast = false, waiting = false; + uint32_t object = 0; + std::unique_ptr mesh; + glm::vec3 last{0.0f}; + // Grab: where it was taken hold of (the world), and what (the object's own space). + glm::vec3 start{0.0f}; + std::vector> held[2]; + std::vector from[2]; + } sculpt_; + // A picture layer unlocked to be moved: which, and what's being dragged (a corner 0-3, 4 the + // whole of it, 5 the turning handle) from where. + struct LayerEdit { + uint32_t object = 0; + int index = -1, drag = -1; + bool fresh = false; // (just pasted: Esc takes it away again) + glm::vec2 grab{0.0f}, offset{0.0f}; + std::array startCorners{}; + PictureLayer start; + } ledit_; + bool pasteOnSurface_ = true; + uint32_t renamingFolder_ = 0; + // Touch (set when it starts: fry_touch). + bool touchMode_ = false, portrait_ = false, showToolbox_ = false, panelsOpen_ = false, viewsOpen_ = false, shiftLatch_ = false, ctrlLatch_ = false, touchOrbit_ = false; + struct Gesture { + bool two = false, longDone = false, rightUp = false; + ImVec2 mid{}, downPos{}; + float spread = 1; + double downAt = -1; + } gest_; + std::string captureUri_; // (a picture of the view going into a picked file) + struct CopiesSetting { + int count = 3, axis = 2; + bool round = false, joined = false, path = false, follow = true; // (path: along the last picked object) + glm::vec3 step{0.3f, 0.0f, 0.0f}, centre{0.0f}; + float angle = 360.0f; + } copies_; + bool showCopies_ = false, showThicken_ = false, thicknessOut_ = false; + float thickness_ = 0.002f; + // The Layout tab: what's picked (frames, or corners as face and corner) and what's dragged. + struct LayoutState { + uint32_t object = 0; + bool corners = false; + std::set frames; + std::set> points; + int drag = 0; // 1 moving / turning / sizing, 2 a box + glm::vec2 grab{0.0f}, centre{0.0f}; + ImVec2 boxFrom{}; + std::vector> start; + std::shared_ptr startPicture; + double remapAt = 0; + } lay_; + LayTool layTool_ = LayTool::Move; + bool layKeepPaint_ = true, layChecker_ = false; + // The Print tab. + PrintTool printTool_ = PrintTool::None; + std::vector measure_; + bool measureSnap_ = true, showOverhangs_ = true, sizing_ = false, showThin_ = true, showLayer_ = false; + int layer_ = 1; + float layerHeight_ = 0.0002f; // (m) + float minWall_ = 0.0008f; // (m: the thinnest wall that prints well, twice a 0.4 mm nozzle) + float overhangDeg_ = 45.0f, infill_ = 15.0f; + int material_ = 0; + std::map printInfos_; + keel::ModelId bed_{}; + glm::vec3 bedFor_{0.0f}; + // Closing, New or Open with changes not saved: asked first (askSave_), then done; afterSave_: + // what to do once the Save it chose has finished (0 nothing, 1 quit, 2 new, 3 open). + int askSave_ = 0, afterSave_ = 0; // (what to do then: 1 quit, 2 new, 3 open..., 4 open dropped_) + std::string dropped_; + bool dialogCancelled_ = false; + // Autosave: a copy packed small in the background every fry_autosave minutes while there are + // changes, kept until saved or closed properly; offered back after a crash. + std::string autosavePath_; + uint64_t edits_ = 0, autosavedEdits_ = 0; + double lastAutosave_ = 0; + std::future autosaveJob_; + std::vector recover_; + bool showRecover_ = false; + std::map> owner_; // which layer shows at each pixel + struct Uploaded { + uint64_t stamp = 0; + double at = 0; + bool dirty = false; + }; + std::map uploaded_; + std::map paintTex_; + float picZoom_ = 0.0f; // the Picture panel: screen pixels a picture pixel, and where its corner is + ImVec2 picPan_{0, 0}; + uint32_t picFor_ = 0; + + // ── Saving a picture of the view (cropped to it, without the gizmos) ── + int captureFrames_ = 0; + std::string capturePath_; + + // ── GPU side ── + std::map> models_; // object id → its model, the state built + std::map versions_; + uint64_t versionClock_ = 1; + struct Shown { + uint64_t version = 0; // (the shape key it was made for) + bool mirrorOnly = false; + Mesh mesh; + std::future coming; // (a big one being made on another thread) + uint64_t comingVersion = 0; + bool comingMirror = false; + }; + std::map shown_; + struct Picture { + bool ok = false; + float aspect = 1.0f; + }; + std::map pictures_; + keel::TextureId logo_{}; + bool logoTried_ = false, haveLogo_ = false; + + // ── UI ── + int appliedTheme_ = -1; + // The Script panel: the script being written, where it's from, what it printed. + bool showScript_ = false, showScriptHelp_ = false; + size_t script_ = 0; // the file's script shown in the Script panel + std::string scriptPath_; // where it was last saved or opened from (Save as starts there) + // Plug-ins (Script > Plug-ins): each file, whether it's on, and how loading it went. + struct PluginEntry { + std::string path, file, error; + bool bundled = false, on = true; + std::unique_ptr p; + }; + std::vector plugins_; + bool showPlugins_ = false; + std::pair pluginTool_; // (the plug-in tool that's on: its file and name) + std::string pluginFilter_; + // ── Commands: found by typing, on keys of your own, round the mouse ── + std::vector commands_; + fry::CommandSearch search_; + std::vector recentCommands_; + std::map myKeys_; // (a command's id: the key you gave it) + int pie_ = 0; // 1: the tools round the mouse (Space held), 2: the views (Shift+Space) + ImVec2 pieAt_{0, 0}; + bool pieStays_ = false; // (opened from the menu or console: until a click) + std::string settingKey_; + bool showHistory_ = false; + // ── Workspaces: the panels as they were, kept under a name ── + bool showWorkspaces_ = false, layoutAgain_ = false; + std::string pendingLayout_; + char workspaceName_[64] = "My workspace"; // (the command waiting for its new key, in Keys and mouse) + // A plug-in's command waiting for its settings (the form), and a material being named. + struct PendingCommand { + size_t plugin = 0, command = 0; + std::vector values; + }; + std::optional pendingCommand_; + bool nameMaterial_ = false; + std::string materialName_; + bool materialKeepColor_ = false; + std::vector> scriptLog_; // (line, an error) + bool scriptLogToBottom_ = false; + bool styled_ = false, layoutDone_ = false, showHelp_ = false, showAbout_ = false; + std::string iniPath_, message_; + double messageAt_ = -100.0; + char renameBuf_[128] = ""; + uint32_t renaming_ = 0; + std::mutex dialogMutex_; + std::string dialogResult_; + int dialogKind_ = 0; // 1 open, 2 save, 3 export, 4 reference picture, 5 picture of the view, 6 a picture to paint on, 7 save the painted picture + int pendingKind_ = 0, dialogFilter_ = -1; + + // ───────────────────────────── document ───────────────────────────── + + Object* find(uint32_t id); Reference* findRef(uint32_t id); + Object* editing() { return mode_ == Mode::Edit ? find(active_) : nullptr; } + void touched(uint32_t id) { versions_[id] = ++versionClock_, dirty_ = true, ++edits_; } + // What decides the shape an object shows, as a number: its points and faces, its finishing + // touches, creases, cuts, shading and painting's layout (not where it is). Worked out again + // only when it's been touched: moving a smoothed object doesn't smooth it again each frame. + struct ShapeKey { + uint64_t version = 0, key = 0; + }; + std::map shapeKeys_; uint64_t shapeKey(const Object& o); + void touchAll() { + for (const Object& o : scene_.objects) versions_[o.id] = ++versionClock_; + } + // Its shape has been changed by hand: the library settings no longer apply. + void handMade(Object& o) { o.recipe.clear(), o.settings.clear(); } void say(std::string text); + + void pushUndo(); + // The undo point just made, dropped (what it was for didn't happen): redo as it was before. + void dropUndo(); + // Back to the undo point just made, and it dropped. + void takeBack(); void restore(Snapshot& s); void undo(); void redo(); + + void newScene(); Object& addObject(const std::string& base, Mesh mesh); std::string uniqueName(const std::string& base); + + // A shape from the library, standing on the ground under the middle of the view. + void addShape(const std::string& id, bool undoable = true); + + void deleteSelected(); + + void duplicateSelected(); + + // The selected objects become one (the last picked keeps its place, look and name). + void joinSelected(); + + // The selected faces become an object of their own. + void separateSelected(); + + // ───────────────────────────── files ───────────────────────────── + + // ── Files a phone's file picker gives (content://...): read and written through SDL ── + static bool isUri(const std::string& p) { return p.starts_with("content://"); } + // A file's name as shown (a picked file's: from the end of its address, decoded). + static std::string fileName(const std::string& p); + // A picked file copied to a file of our own to read ("" if it couldn't be). + static std::string fromUri(const std::string& uri, const std::string& name); + // A file of ours written into the picked one. + static bool toUri(const std::string& local, const std::string& uri); static std::string tempFile(const std::string& name); + + void openFile(const std::string& picked); bool saveFile(std::string path); + static std::string sizeText(uintmax_t n) { + return n < 10240 ? std::format("{} bytes", n) : n < 10 << 20 ? std::format("{} KB", n >> 10) : std::format("{:.1f} MB", double(n) / 1048576.0); + } static std::vector pngBytes(const Image& im); void exportFile(std::string path, int filter = -1, const std::string& asExt = ""); + + // ── Closing with changes, autosave ── + + // New, Open, Quit: asks about saving first if there are changes. + void whenSaved(int action); void doAfterAsking(int action); + // A file dropped on the window (or opened with Fry3DX from the desktop): a .fry opens (after + // asking about changes), a script goes in the Script panel, a picture goes on as a reference + // (or, in the Paint tab, as a sticker on the model). + void onFileDropped(const std::string& path) override; void saveQuestion(); + std::string autosaveDir() const { return keel::prefPath() + "autosave/"; } + // Every fry_autosave minutes (when there are changes and nothing's being dragged): a copy + // packed as small as it goes, in the background. + void autosave(bool force = false); + // Its autosave isn't needed (saved, or closed on purpose). + void dropAutosave(); + // Autosaves left behind (it didn't close properly): offered back. Only the newest 15 are + // kept; older ones are deleted. + void findAutosaves(); void recoverWindow(); + + // A picture of the 3D view only (no panels, gizmos or outlines): the screen is saved a few + // frames on, with those hidden, then cut down to the view. + void savePicture(std::string path); + bool capturing() const { return captureFrames_ > 0; } void captureStep(); static bool loadImageFile(const std::string& path, Image& out); + static bool writePng(const std::string& path, const Image& im) { return stbi_write_png(path.c_str(), im.w, im.h, 4, im.px.data(), im.w * 4) != 0; } void addPicture(const std::string& path); + + // SDL's own file dialogs (the desktop's): the answer comes back later, maybe on another thread. + void dialog(int kind); void takeDialogResult(); + + // ───────────────────────────── modes and tools ───────────────────────────── + + void setTool(Tool t); void enterEdit(); void leaveEdit(); + void clearElements() { selVerts_.clear(), selFaces_.clear(), selEdges_.clear(); } void selectAllElements(); void selectAll(); void selectNone(); + // The points the selection covers, whichever kind is picked. + std::set selectedPoints(); + // Selects (in the current kind) what these points cover. + void selectPoints(const std::set& pts); + void growSelection() { + if (Object* o = editing()) selectPoints(growVerts(o->mesh, selectedPoints())); + } void shrinkSelection(); + // Like the picked: faces that face the same way, edges as long, points with as many edges. + void selectSimilar(); + // The picked edges' whole loops (or the rings across them). + void loopsOfSelected(bool ring); + void selectLinked() { + if (Object* o = editing()) selectPoints(linkedVerts(o->mesh, selectedPoints())); + } void invertSelection(); + // The edges a rounding would work on: those selected, or round the selected faces, or between + // selected points. + std::set> edgesToRound(); + + void deleteElements(); + // After a change that renumbers things: nothing selected, the shape is hand-made now. + void changedShape(Object& o, bool keepSelection = false); void extrudeNow(float amount); void insetNow(float amount); void bevelNow(); void subdivideNow(bool smooth); void cutAt(Object& o, std::pair edge); void makeFaceNow(); void mergeNow(); void smoothPointsNow(); void startPush(); void pushWindow(); void bridgeNow(); void flipNow(); + // Rotation and scale baked into the points (so the object's turn is none and its size is 1). + void applyTransform(Object& o); void applyFinish(Object& o); void originToMiddle(Object& o); void originToBottom(Object& o); void mirror(Object& o, int axis); + + + // ───────────────────────────── tabs ───────────────────────────── + + void setTab(Tab t); + Shading shading() const { return tab_ == Tab::Render ? Shading::Rendered : shading_; } + + // ───────────────────────────── the knife and holes ───────────────────────────── + + // Snapped to steps of 45 degrees from the last point while Ctrl is held. + ImVec2 knifeEnd(const ImGuiIO& io) const; static Mesh subMesh(const Mesh& m, const std::set& faces); + // A new object like `from` (its look, place and finish), with another shape. + uint32_t objectLike(uint32_t from, Mesh mesh, const std::string& name); void knifeApply(uint32_t id, std::vector pts); + static float objectScale(const Object& o) { return std::max(std::cbrt(std::abs(glm::determinant(glm::mat3(objectMatrix(o))))), 1e-6f); } + // How wide (across) a shape is when it reaches `reach` from its middle (dragging it out). + float holeAcrossFor(float reach) const { + return HoleShape(holeShape_) == HoleShape::Triangle || HoleShape(holeShape_) == HoleShape::Hexagon ? reach * std::sqrt(3.0f) : reach * 2.0f; + } + // The outline, `across` wide in the object's own size (a rectangle keeps its proportions). + std::vector holeShapeOutline(const Object& o, int face, const glm::vec3& centre, float across) const; void applyHole(uint32_t id, int face, const glm::vec3& centre, float across); + // The hole tool's outline under the mouse (or being dragged out), in the world. + std::vector holePreview(const ImGuiIO& io); + // Where the mouse is on a face's plane (the object's own space). + std::optional onFacePlane(const Object& o, int face, ImVec2 mouse); + + // ───────────────────────────── 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). + Object* readyToPaint(uint32_t id); + // The object's picture, copied so this change can be undone; the copy is now the object's. + Image& changePicture(Object& o); void beginStroke(Object& o, bool in2D); void endStroke(); + // Where on its picture a ray hits an object (0..1), or nothing. + std::optional uvUnder(const Object& o, const Ray& ray); + // 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). + void dab3D(Object& o, const glm::vec3& at, float radius, const glm::vec3* eyeAt = nullptr, const glm::mat4* back = nullptr); + // The brush at a point of the view: starts a stroke on what's there, or carries one on. + void paintAt(ImVec2 mouse, bool start); void pickColor(const Object& o, glm::vec2 uv); void fillAt(Object& o, glm::vec2 uv); + + // ── 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. + Object* readyToSculpt(uint32_t id); void endSculpt(); + // The brush at a point of the view: starts a stroke on what's there, or carries one on. + void sculptAt(ImVec2 mouse, bool start, bool ctrl, bool shift); void sculptViewport(const ImGuiIO& io); + + // ── Picture layers ── + + PictureLayer* editLayer(); + // 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. + void startFloating(Image img); void floatFromFile(const std::string& path); void pasteFromClipboard(); + // Enter: it stays where it is, a layer (click it in the Picture panel to move it again). + void lockLayer(); void unlockLayer(uint32_t object, int index); + // Esc: a picture just pasted goes again; one unlocked is locked again. + void cancelFloating(); void deleteLayer(Object& o, int i); + // The layer painted into the painting for good (it can't be moved after). + void mergeLayer(Object& o, int i); + // 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. + bool stickerHandles(const Object& o, const PictureLayer& l, std::array& out); int stickerHandle(const Object& o, const PictureLayer& l, ImVec2 mouse); + // Moving a sticker on the model: it slides over the surface (onto other faces too), its + // corners size it, its handle turns it. + void stickerMouse(const ImGuiIO& io); + // A cheap fingerprint of what the object's picture shows (its painting, its layers, its shape). + uint64_t pictureStamp(const Object& o); + // 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. + void uploadPicture(const Object& o, bool 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. + std::vector> layoutPicked(const Object& o, const std::vector>& islands) const; + // A corner and every other on the same point of the model at the same place on the picture. + std::set> layoutCorner(const Object& o, int f, int c) const; void layoutSelectAll(); + // The picked corners moved by `to` (picture pixels), painting carried along if wanted. + void layoutChange(Object& o, const std::vector>& from, const std::shared_ptr& picture, + const std::vector>& picked, const std::function& to, bool final); + // Buttons' changes (turn, flip, straighten): about the middle of what's picked. + void layoutApply(const std::function& to); void layoutStraighten(); + // Laid out again by itself, what's painted carried over onto the new layout. + void layoutAgain(Object& o); + + template + void layoutCanvas(Object& o, ImDrawList* dl, F toScreen, glm::vec2 mouse, bool hovered, int W, int H) { + const ImGuiIO& io = ImGui::GetIO(); + if (lay_.object != o.id) lay_ = {}, lay_.object = o.id; + const glm::vec2 size{float(W), float(H)}; + const auto islands = uvIslands(o.mesh, o.uvs); + std::vector islandOf(o.mesh.faces.size(), -1); + for (size_t i = 0; i < islands.size(); ++i) + for (int f : islands[i]) islandOf[size_t(f)] = int(i); + auto screen = [&](int f, int c) { return toScreen(o.uvs[size_t(f)][size_t(c)] * size); }; + auto faceUnder = [&]() { + for (size_t f = 0; f < o.mesh.faces.size(); ++f) { + std::vector poly; + for (size_t c = 0; c < o.uvs[f].size(); ++c) poly.push_back(screen(int(f), int(c))); + if (insidePolygon(io.MousePos, poly)) return int(f); + } + return -1; + }; + auto cornerUnder = [&]() { + std::pair best{-1, -1}; + float d = 9.0f; + for (size_t f = 0; f < o.uvs.size(); ++f) + for (size_t c = 0; c < o.uvs[f].size(); ++c) { + const ImVec2 q = screen(int(f), int(c)); + if (const float e = std::hypot(q.x - io.MousePos.x, q.y - io.MousePos.y); e < d) d = e, best = {int(f), int(c)}; + } + return best; + }; + const auto picked = layoutPicked(o, islands); + std::set pickedFaces; + for (auto [f, c] : picked) pickedFaces.insert(f); + // Pressing: pick (what's under it, or a box), then move / turn / size what's picked. + if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !lay_.drag) { + bool hit = false, wasPicked = false; + if (lay_.corners) { + const auto [f, c] = cornerUnder(); + if (f >= 0) { + const auto same = layoutCorner(o, f, c); + hit = true, wasPicked = lay_.points.count({f, c}) > 0; + if (io.KeyShift && wasPicked) + for (auto p : same) lay_.points.erase(p); + else { + if (!io.KeyShift && !wasPicked) lay_.points.clear(); + lay_.points.insert(same.begin(), same.end()); + } + } + } else if (const int f = faceUnder(); f >= 0) { + const int i = islandOf[size_t(f)]; + hit = true, wasPicked = lay_.frames.count(i) > 0; + if (io.KeyShift && wasPicked) lay_.frames.erase(i); + else { + if (!io.KeyShift && !wasPicked) lay_.frames.clear(); + lay_.frames.insert(i); + } + } + const bool anything = !lay_.frames.empty() || !lay_.points.empty(); + const bool transform = layTool_ != LayTool::Select && anything && !(io.KeyShift && hit) && (hit || layTool_ != LayTool::Move); + if (transform) { + pushUndo(); + lay_.drag = 1, lay_.grab = mouse, lay_.start = o.uvs, lay_.startPicture = o.picture; + glm::vec2 lo(1e30f), hi(-1e30f); + for (auto [f, c] : layoutPicked(o, islands)) 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); + lay_.centre = (lo + hi) * 0.5f; + } else if (!hit) { + if (!io.KeyShift) lay_.frames.clear(), lay_.points.clear(); + lay_.drag = 2, lay_.boxFrom = io.MousePos; + } + } + if (lay_.drag == 1) { + const glm::vec2 g = lay_.grab - lay_.centre, m = mouse - lay_.centre; + std::function to; + if (layTool_ == LayTool::Move || layTool_ == LayTool::Select) { + const glm::vec2 d = mouse - lay_.grab; + to = [d](glm::vec2 p) { return p + d; }; + message_ = std::format("Moved {:.0f}, {:.0f} px", d.x, d.y); + } else if (layTool_ == LayTool::Rotate) { + float a = std::atan2(m.y, m.x) - std::atan2(g.y, g.x); + if (io.KeyCtrl) a = glm::radians(snapTo(glm::degrees(a), 15.0f)); + const glm::vec2 c = lay_.centre; + to = [a, c](glm::vec2 p) { return c + glm::vec2(std::cos(a) * (p - c).x - std::sin(a) * (p - c).y, std::sin(a) * (p - c).x + std::cos(a) * (p - c).y); }; + message_ = std::format("Turned {:.0f} degrees", glm::degrees(a)); + } else { + glm::vec2 k(glm::length(m) / std::max(glm::length(g), 1.0f)); + if (io.KeyShift) // (only across or only up: whichever the drag goes along more) + k = std::abs(g.x) > std::abs(g.y) ? glm::vec2(m.x / (std::abs(g.x) > 1 ? g.x : 1.0f), 1.0f) : glm::vec2(1.0f, m.y / (std::abs(g.y) > 1 ? g.y : 1.0f)); + const glm::vec2 c = lay_.centre; + to = [k, c](glm::vec2 p) { return c + (p - c) * k; }; + message_ = std::format("Size {:.0f}% x {:.0f}%", k.x * 100, k.y * 100); + } + messageAt_ = now(); + const bool done = !ImGui::IsMouseDown(ImGuiMouseButton_Left); + layoutChange(o, lay_.start, lay_.startPicture, layoutPicked(o, islands), to, done); + if (done) lay_.drag = 0, lay_.startPicture.reset(); + } + // Drawing: every face's outline, what's picked filled; corners as dots. + const ImU32 fill = (kAccent & 0x00FFFFFF) | 0x38000000; + for (size_t f = 0; f < o.mesh.faces.size(); ++f) { + std::vector pts; + for (size_t c = 0; c < o.uvs[f].size(); ++c) pts.push_back(screen(int(f), int(c))); + const bool on = pickedFaces.count(int(f)) && !lay_.corners; + if (on) + for (const auto& t : triangulate(o.mesh, int(f))) { + ImVec2 q[3]; + for (int k = 0; k < 3; ++k) q[k] = pts[size_t(std::find(o.mesh.faces[f].begin(), o.mesh.faces[f].end(), t[size_t(k)]) - o.mesh.faces[f].begin())]; + dl->AddTriangleFilled(q[0], q[1], q[2], fill); + } + dl->AddPolyline(pts.data(), int(pts.size()), on ? kAccent : IM_COL32(255, 255, 255, 150), ImDrawFlags_Closed, on ? 2.0f : 1.2f); + } + if (lay_.corners) { + const auto hot = cornerUnder(); + for (size_t f = 0; f < o.uvs.size(); ++f) + for (size_t c = 0; c < o.uvs[f].size(); ++c) { + const ImVec2 q = screen(int(f), int(c)); + const bool on = lay_.points.count({int(f), int(c)}) > 0; + dl->AddCircleFilled(q, on ? 4.5f : 3.0f, on ? kAccent : hot == std::pair{int(f), int(c)} ? kHot : IM_COL32(255, 255, 255, 200)); + } + } else if (hovered && !lay_.drag) + if (const int f = faceUnder(); f >= 0 && !lay_.frames.count(islandOf[size_t(f)])) + for (int g : islands[size_t(islandOf[size_t(f)])]) { + std::vector pts; + for (size_t c = 0; c < o.uvs[size_t(g)].size(); ++c) pts.push_back(screen(g, int(c))); + dl->AddPolyline(pts.data(), int(pts.size()), kHot, ImDrawFlags_Closed, 1.5f); + } + if (lay_.drag == 1 && layTool_ != LayTool::Move && layTool_ != LayTool::Select) { // (the middle it turns or sizes about) + const ImVec2 c = toScreen(lay_.centre); + dl->AddCircle(c, 6, kAccent, 16, 2.0f); + dl->AddLine(c, io.MousePos, (kAccent & 0x00FFFFFF) | 0x90000000, 1.0f); + } + if (lay_.drag == 2) { // a box to pick with + const ImVec2 a = lay_.boxFrom, b = io.MousePos; + dl->AddRectFilled(ImVec2(std::min(a.x, b.x), std::min(a.y, b.y)), ImVec2(std::max(a.x, b.x), std::max(a.y, b.y)), IM_COL32(255, 120, 40, 30)); + dl->AddRect(ImVec2(std::min(a.x, b.x), std::min(a.y, b.y)), ImVec2(std::max(a.x, b.x), std::max(a.y, b.y)), kAccent); + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + auto in = [&](ImVec2 q) { return q.x >= std::min(a.x, b.x) && q.x <= std::max(a.x, b.x) && q.y >= std::min(a.y, b.y) && q.y <= std::max(a.y, b.y); }; + for (size_t f = 0; f < o.uvs.size(); ++f) + for (size_t c = 0; c < o.uvs[f].size(); ++c) + if (in(screen(int(f), int(c)))) { + if (lay_.corners) lay_.points.insert({int(f), int(c)}); + else lay_.frames.insert(islandOf[f]); + } + lay_.drag = 0; + } + } + } + // In the 3D view: clicking the model picks the frame its face is in (Shift: add). + void layoutViewport(const ImGuiIO& io); void layoutOverlay(ImDrawList* dl); void layoutToolbox(float w); void layoutOptions(float w); void layoutProperties(float w); + + // ───────────────────────────── the Print tab ───────────────────────────── + + glm::vec3 bedSize() const; const PrintInfo& printInfo(const Object& o); std::vector printTargets(); void drawPrintBed(keel::Renderer& r, const keel::ModelLighting& light); void putOnBed(); void middleOfBed(); + // Everything picked made `mm` big along `axis`, keeping its shape, standing where it stood. + void printResize(int axis, float mm); + // Everything picked made `k` times as big, standing where it stood. + void printScale(float k); + // The scaler: by a share, to fit the plate, or from the wrong units (a model made in inches, + // centimetres or metres comes in 25.4, 10 or 1000 times off). + float scalePercent_ = 100.0f; void printScaler(float w); void printSizeWindow(); void printToolbox(float w); void printOptions(float w); void printViewport(const ImGuiIO& io); + // Where the mouse is on the model (a corner if near one), or on the bed. + std::optional measurePoint(ImVec2 mouse); + // The slice preview: where the printer goes round at the chosen layer, over the model. + void layerOverlay(ImDrawList* dl); void printOverlay(ImDrawList* dl, const ImGuiIO& io); void printProperties(float w); + + // ───────────────────────────── layers: folders and going to things ───────────────────────────── + + Folder* findFolder(uint32_t id); bool inFolder(uint32_t folder, uint32_t within); std::set objectsIn(uint32_t folder); + // The camera brought round to these (as framing them, but keeping the way it looks). + void goTo(const std::set& ids); + // The picked objects into a new folder (Ctrl+G): a group, picked and moved together. + void groupSelected(); + // Ungrouped: what's inside goes up a level, the folder goes. + void ungroup(uint32_t id); void deleteFolder(uint32_t id); + // A folder's eye or lock: all in it the same. + void showFolder(Folder& f, bool on); void lockFolder(Folder& f, bool on); void moveToFolder(const std::set& ids, uint32_t folder); + // Only these shown (again: everything shown). + void showOnly(const std::set& ids); void folderMenu(uint32_t into, const std::set& ids, int depth = 0); + // What a right-click on an object (or the picked ones) offers. + void objectMenu(Object& o); + // A row's show / hide or lock button: the picture of how it is now, dim when it's the usual way. + bool rowToggle(const char* id, Icon icon, bool usual, const char* tip); + // The ⌖ button at the end of a row: brings the camera to it. + bool goButton(const char* tip); + // Rows for what's in a folder (0: the top): its folders, then its objects. + void layerRows(uint32_t parent, int depth); + + // ───────────────────────────── combining, copies, thickness ───────────────────────────── + + // The last picked object with the others added, taken out of it, or only where they overlap + // kept; the others go. + void combineSelected(Combine op); void makeCopies(); void copiesWindow(); void thickenActive(); + // A model made elsewhere: each of its objects, standing on the ground in the middle of the view. + void importFile(const std::string& file); + + // ── Spin: an outline turned round an axis (a vase, a wheel, a ring), or a screw ── + bool showSpin_ = false, spinWorld_ = false, spinKeep_ = false; + int spinAxis_ = 2, spinSteps_ = 48; + float spinDegrees_ = 360.0f, spinRise_ = 0.0f; void spinWindow(); void spinSelected(); + + // ── Text as a shape ── + bool showText_ = false; + char textBuf_[512] = "Fry3DX"; + int textFont_ = 2, textAlign_ = 0; + float textHeight_ = 0.05f, textDepth_ = 0.01f, textSpacing_ = 0.0f; + bool textStanding_ = false; + std::string textFontFile_; // (a font of your own, picked from a file) + static constexpr const char* kFonts[] = {"Instrument Sans", "Instrument Sans (semi-bold)", "Instrument Sans (bold)", "JetBrains Mono"}; + static constexpr const char* kFontFiles[] = {"fonts/InstrumentSans-Regular.ttf", "fonts/InstrumentSans-SemiBold.ttf", "fonts/InstrumentSans-Bold.ttf", "fonts/JetBrainsMono-Regular.ttf"}; void textWindow(); void addText(); + + void thickenWindow(); void cleanFlatFaces(); + + // ───────────────────────────── touch: phones and tablets ───────────────────────────── + // A phone: everything drawn bigger, the side panels only when asked for, a bar of big buttons + // at the bottom (undo, Shift and Ctrl that stay down, editing the shape...), and fingers: + // one drags the view round (or a tool's arrows), two move it and pinch to zoom, holding one + // still is a right-click. + + bool touch() const { return touchMode_; } + // How much bigger than its pixels the UI is drawn. + float touchScale() const; void touchFrame(ImGuiIO& io); + // The bar along the bottom of the view: big buttons, in rows if the view's narrow. + void touchBar(); + + // ───────────────────────────── the camera ───────────────────────────── + + glm::vec3 viewDir() const { return forwardOf(yaw_, pitch_); } + float viewHeight() const { return 2.0f * dist_ * std::tan(glm::radians(fov_) * 0.5f); } // metres across the view's height at the target + void setView(View v); void leaveFlatView(); void frameBox(glm::vec3 lo, glm::vec3 hi); void frameAll(); void frameSelected(); + + bool mouseInView(const ImGuiIO& io) const; + + void orbitBy(ImVec2 d); void panBy(ImVec2 d); + + void updateCamera(ImGuiIO& io); + // Where the eye is and how the view maps to the screen, from the camera as it is now: again + // just before drawing, so the model and the lines over it agree (a touch can turn it between). + void settleView(); + // How near and far the view draws (units), going by how far away it looks: close up to a + // 5 mm part, or out to see a whole town, the same share of the depth's precision. + glm::vec2 nearFar(); + + // World point → screen (false behind the eye). + // The window and its view are up (commands given at the start run before the first frame). + bool viewReady() const { return ImGui::GetCurrentContext() && viewMax_.x - viewMin_.x > 1 && viewMax_.y - viewMin_.y > 1; } bool project(const glm::vec3& p, ImVec2& out) const; Ray mouseRay(ImVec2 m) const; + // How many metres a pixel is, at a point. + float pixelSize(const glm::vec3& at) const; + + // ───────────────────────────── what's shown ───────────────────────────── + + // The mesh as seen: with its rounding, smoothing and mirror — but while its shape is being + // edited, only the mirror (the rest would hide the points you're moving). + // Editable cuts show (but not while its own shape's being edited: the points you move). + // A big one (smoothed into many faces, cut) is made on another thread: what was shown stays + // till it's ready, so the window doesn't stop. + const Mesh& shownMesh(const Object& o); + // The key of the mesh actually shown (the one before, while a new one's being made). + uint64_t meshKey(const Object& o, const Mesh& mesh); + // Each object something's cut out of, and what its cutters are (their shapes, where they are): + // a change, and it's made again (the caches follow its version). + std::map cutKeys_; + std::map followAt_; // (where each cut object and cutter was last frame) + void followCuts(); + bool isCutter(const Object& o) const { return fry::cutTarget(scene_, o) != nullptr; } + // Editable cut: the last picked has the others cut out of it, and they stay objects to move. + void cutEditable(); + // The cut made for good: one cutter (or all of what's cut out of it) into the shape, gone. + void applyCuts(uint32_t target, uint32_t only = 0); + + // ───────────────────────────── picking ───────────────────────────── + + struct Hit { + uint32_t object = 0; + int face = -1; + float t = 1e30f; + }; + // base: against its own mesh (face numbers of that), not the rounded or smoothed one shown. + Hit pickObject(const Ray& ray, uint32_t only = 0, bool base = false); + // Each object's triangles in the world, in a tree of boxes: made again only when it changes + // (its shape, where it is), not each time something's picked. + struct Tree { + uint64_t version = 0; + glm::mat4 matrix{0.0f}; + size_t faces = 0; + fry::TriangleTree tree; + }; + std::map, Tree> trees_; // (an object's own mesh, or the one shown) + const fry::TriangleTree& treeOf(const Object& o, const Mesh& mesh); + // Can the eye see this point of the object (nothing of it in the way)? + bool visible(const Object& o, const glm::vec3& world); + // Whether the eye can see an object's point (nothing of it in between): each tried when it's + // first asked about, kept till the object or the view changes. (Only what's drawn is asked: + // the outline's points, not every point of a big mesh, as the view turns.) + struct Visibility { + uint64_t version = 0; + glm::vec3 eye{1e30f}; + glm::mat4 vp{0.0f}; + std::vector point; // 0 not tried yet, 1 seen, 2 hidden + }; + std::map, Visibility> visibility_; // (an object's own mesh, or the one shown) + bool seen(const Object& o, const Mesh& mesh, int i); + // Each edge of a mesh and the (first two) faces it's on, with the faces' normals: worked out + // again only when the mesh changes. + struct Adjacency { + uint64_t version = 0; + size_t verts = 0, faces = 0; + std::vector> edges; // a, b, a face, the other (or -1) + std::vector plane; // each face's normal, and its distance along it + }; + std::map, Adjacency> adjacency_; const Adjacency& adjacencyOf(const Object& o, const Mesh& mesh); + bool pointSeen(const Object& o, int v) { return xray_ || seen(o, o.mesh, v); } + + // The edge of the edited object nearest the mouse on screen (that can be seen), or (-1, -1). + std::pair nearestEdge(Object& o, ImVec2 mouse, float within); + + void clickSelect(const ImGuiIO& io); + + // A whole loop: of edges (points), or a ring of faces, through the edge under the mouse. + void loopSelect(const ImGuiIO& io, bool ring = false); + + // Selects what's on screen inside a shape (a box, a lasso, the brush). + void regionSelect(const std::function& inside, bool add, bool remove, bool clearFirst); + + // ───────────────────────────── the gizmo ───────────────────────────── + + bool hasSelection(); + // Every point of the selection, in the world. + std::vector selectionPoints(); glm::vec3 pivotOfSelection(); + // The selection's box, in a gizmo's axes. + std::pair selectionBox(const Axes& a); glm::vec3 selectionMiddle(const Axes& a); + Orient& orient() { return mode_ == Mode::Edit ? orientEdit_ : orientObject_; } + // The gizmo's axes: the object's own (turning with it), the world's, or (shape editing) the + // way the selected faces face — so "up" on a side face is out of that face. + Axes gizmoAxes(); + // The extrude / inset handle: the selected faces' middle, their normal. + bool faceHandle(glm::vec3& at, glm::vec3& normal); + // The round-edges handle: off to the right of the selection's middle. + bool bevelHandle(ImVec2& centre, ImVec2& handle); + // The scale box's handles: 0-2 the far side along each axis, 4-6 the near side, 3 the middle. + // (Pushed out a little where the box is flat, so they don't sit on top of each other.) + glm::vec3 scaleHandle(const Axes& a, glm::vec3 lo, glm::vec3 hi, int h); + + // Which handle is under the mouse (-1 none). + int hoverHandle(ImVec2 mouse); + + glm::vec3 originPoint(); + glm::vec3 rotatePivot(const Axes& a) { return rotatePivot_ == Pivot::Origin ? originPoint() : selectionMiddle(a); } + + void startDrag(const ImGuiIO& io, int handle); + // The points within reach of the selection (in the world: the object's size counts), each with + // how much it follows: a grid of the selected points, so a big mesh stays quick. + void findSoft(); void cancelDrag(); + + void updateDrag(const ImGuiIO& io); + // Mirror editing: each point that moved, its twin across the shape's own X (the point that was + // where it is, mirrored) moved the mirrored way. Points on the middle stay on it. + void mirrorEdits(Object& o, const Mesh& before); + // What moving snaps to near the mouse (points, edges' or faces' middles), in the world: not + // what's being moved. Marked with a ring. + std::optional snapTarget(ImVec2 mouse, float within = 22.0f); + // The number typed while dragging, if any (just "-" or "." is none yet). + std::optional typed() const; void dragNow(const ImGuiIO& io); + + // ───────────────────────────── drawing a shape ───────────────────────────── + + // The plane shapes are drawn on: the one the view looks straight at, or the ground. + void workPlane(glm::vec3& point, glm::vec3& normal) const; + // The plane drawn on: the face under the first point if it was on an object, else the view's. + void drawPlane(glm::vec3& p, glm::vec3& n); std::optional drawPoint(const ImGuiIO& io); + std::string sketchTyped_; // (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. + bool sketchKeys(const ImGuiIO& io); void finishShape(); + + // ───────────────────────────── the viewport's mouse ───────────────────────────── + + void viewport(ImGuiIO& io); void addDrawPoint(const ImGuiIO& io); + // Painting: on the model under the mouse. + void paintViewport(ImGuiIO& io); + + // The brush: whatever passes under it is selected (or, with Ctrl, let go). + void paint(const ImGuiIO& io); + + // ───────────────────────────── keys ───────────────────────────── + + void shortcuts(ImGuiIO& io); + + // ───────────────────────────── 3D ───────────────────────────── + +#if defined(__ANDROID__) + static constexpr int kRayLight = 0; // (light bounced by ray tracing: not on phones) +#else + static constexpr int kRayLight = 1; +#endif + // Solid: a plain grey room and soft studio light, no shadows or effects — for seeing shapes. + // Rendered: sky, sun, shadows and effects. + void applyShading(); + // Painted, and shown as painted (with rounding or smoothing added since, it isn't). + bool painted(const Object& o) const { return o.picture && o.uvsFit() && !o.hasFinish() && !cutKeys_.count(o.id); } + + keel::ModelId modelOf(const Object& o); + + const Picture& picture(const Reference& ref); + + void loadLogo(); void logoImage(float size); + // A picture of ours in the UI, read the way ImGui's colours are (not turned linear: too dark). + void uiImage(ImDrawList* dl, keel::TextureId t, ImVec2 a, ImVec2 b); + + float gridStep() const { return std::pow(10.0f, std::floor(std::log10(std::max(viewHeight() / 4.0f, 1e-4f)))); } + + void draw3D(keel::Renderer& r); + + // (the Print tab's: the printer's plate and overhangs) + // On top of the scene: outlines and wires, points, the gizmo, the selection shapes. + // An empty scene: how to start, in the middle of the view (gone once there's anything). + void emptyHints(ImDrawList* dl); void overlays(); + + void gizmo(ImDrawList* dl, const ImGuiIO& io); + + // ───────────────────────────── panels ───────────────────────────── + + void setupUi(); + + // The least a panel can be and still read right (its words, its rows), by the font's size. + static ImVec2 panelMin(std::string_view name); + // Docked panels kept at their least size: where a split leaves one side smaller, the split + // moves (when the other side has room to give). ImGui's own splitters know only one least + // size for everything. Gives what the node needs. + ImVec2 keepMinSizes(ImGuiDockNode* n); + + void defaultLayout(ImGuiID dock); + + // ───────────────────────────── commands ───────────────────────────── + + // Everything that can be done, by name: for the command search, keys of your own, the pies. + void buildCommands(); void openSearch(); void runCommand(const std::string& id); void commandSearch(); + // The pies: the tools (Space), or the views (Shift+Space), round the mouse while it's held. + void pieFrame(ImGuiIO& io); + // Keys of your own: kept in keys.txt (a line each: the command's id, a tab, the key). + void loadMyKeys(); void saveMyKeys(); + + void menus(); void menuBar(); + + void addMenuItems(); + + // How the last item's key looks: filled when it's the one in use, sunk while held. + static keel::ui::KeyState keyState(bool on); + // A square button with a picture on it (the accent when it's the one in use). + bool iconButton(const char* id, Icon icon, bool on, const char* tip, float size = 38.0f); + // A wide one, with words beside the picture. + bool wideButton(const char* id, Icon icon, const char* label, bool on, float width, const char* tip = nullptr, float h = 30.0f); + // Buttons in rows, as many as fit. + template + void grid(int count, float size, F each) { + const float gap = 5.0f; + const int perRow = std::max(1, int((ImGui::GetContentRegionAvail().x + gap) / (size + gap))); + for (int i = 0; i < count; ++i) { + if (i % perRow) ImGui::SameLine(0, gap); + each(i); + } + } + + void toolbox(); + // The plug-in tool that's on: which plug-in and which of its tools (none: -1). + std::pair pluginToolAt() const; + bool pluginToolFound() const { return pluginToolAt().first >= 0; } + // A plug-in tool's click: the spot on what's under the mouse, or on the ground. + void clickPluginTool(ImVec2 mouse); + + void paintToolbox(float w); + + void sculptOptions(float w); + + void paintOptions(float w); + + void softChoice(float w); void toolOptions(); + + void layersPanel(); + + // A value that's undoable: the undo point is taken when you start dragging or typing. + template + bool undoable(F widget) { + const bool changed = widget(); + if (ImGui::IsItemActivated()) pushUndo(); + return changed; + } + + void paintProperties(float w); + + void renderProperties(float w); + + // The Paint tab's picture: the selected object's, flat, to paint on and place pictures on. + void picturePanel(); + + void propertiesPanel(); + + // A material to start from: sets colour, metal and rough in one go (each a little ball of it). + // Yours (saved here, or from plug-ins and scripts) first; "Save its look as a material" adds one. + void materialPicker(Object& o); + + void editProperties(Object& o, float w); + + void statusBar(); + + // The view's corner: which way you're looking, buttons to look straight at a side, and + // handles to drag to orbit, pan and zoom (for a touchpad or a pen). + void viewButtons(); + + // ───────────────────────────── scripts ───────────────────────────── + + void scriptLine(std::string text, bool error = false); + fry::SceneScript* currentScript() { return script_ < scene_.scripts.size() ? &scene_.scripts[script_] : nullptr; } + // "little_town.lua" -> "little town" (a script's name in the list). + static std::string scriptName(const std::string& path); + // Adds a script to the file's list and shows it (the same one again: just shows it). + void addScript(std::string name, std::string code); + // Runs the Script panel's script: one undo step; if it stops with an error, everything it did + // is taken back. + void runScriptNow(); + // After a script or plug-in command: kept as one undo step, or all taken back on an error. + void finishRun(const fry::ScriptResult& r, const std::string& name, bool frame); void openScript(const std::string& path, const std::string& shownName); void saveScript(std::string file); + // The scripts that come with it (content/Fry3DX/scripts), as menu items that add one. + void exampleScripts(); void exampleItems(bool selectable); + + // ── Own materials (materials.txt in the settings folder) ── + static std::string ownMaterialsPath() { return keel::prefPath() + "materials.txt"; } void loadOwnMaterials(); void saveOwnMaterials(); + + // ── Plug-ins: content/Fry3DX/plugins (they come with it) and the settings folder's plugins ── + static std::string pluginsFolder() { return keel::prefPath() + "plugins/"; } std::set pluginsOff() const; void setPluginOn(const std::string& file, bool on); void loadPlugins(); void copyPlugin(const PluginEntry& e); void openPluginsFolder(); + bool pluginUsable(size_t i) const { return i < plugins_.size() && plugins_[i].p && plugins_[i].error.empty(); } void pluginMenuItems(const char* menu); void pluginMenuItem(size_t i, size_t c); void runPluginCommand(size_t i, size_t c, const std::vector& values); + // A plug-in command's settings, asked for before it runs. + void commandForm(); void pluginsWindow(); static int growString(ImGuiInputTextCallbackData* d); + // A one-line field for a std::string (it grows as it's typed into). + static bool inputString(const char* label, std::string& text, const char* hint = nullptr) { + return ImGui::InputTextWithHint(label, hint ? hint : "", text.data(), text.capacity() + 1, ImGuiInputTextFlags_CallbackResize, growString, &text); + } void scriptPanel(); + + void helpWindow(); + + // Keys of your own: any command on any key (they come before the usual ones). + void myKeysTable(); + + // ── (being made) ── + bool showPrintSize_ = false, liveMirror_ = false, keepProportions_ = false; + // Each picked object (or all, in the Print tab with none picked) made ready to print. + void repairSelected(); + // Crease (Shift+E): the picked edges (or those between picked points / round picked faces) + // kept sharp when the shape's smoothed; again: not. + void creaseSelected(); + // Slide (Alt+G): the picked points glide along their edges, each along the edge that goes + // most the way the mouse goes, never past its other end. Click: done. Esc / right click: as it was. + struct Slide { + bool active = false; + uint32_t object = 0; + Mesh from; + std::vector pts; + std::vector> rails; // (each point's neighbours that aren't picked: where it can go) + ImVec2 start{0, 0}; + } slide_; void startSlide(); void cancelSlide(); void slideFrame(const ImGuiIO& io); + + // Looking straight at a reference picture's plane (the front picture from the front or the back...). + bool lookingAt(Axis a) const; + std::string workspaceDir() const { return keel::prefPath() + "workspaces/"; } void resetLayout(); void workspacesWindow(); static std::string fileSafe(std::string n); + + // The steps that can be undone, oldest first: a click goes back (or on) to just after it. + void historyWindow(); + + void aboutWindow(); +}; + +} // namespace fryapp diff --git a/src/app_model.cpp b/src/app_model.cpp new file mode 100644 index 0000000..733ab0e --- /dev/null +++ b/src/app_model.cpp @@ -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& 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 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 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& 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& a, const std::set& 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 others; + for (size_t i = 1; i < n; ++i) others.insert(pieces[i].begin(), pieces[i].end()); + std::vector 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 { + 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 { + 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 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 { + 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 others = [&] { + std::set 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 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 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 made = selected_; + const std::vector originals = [&] { + std::vector 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 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 font; + if (!textFontFile_.empty()) { + std::ifstream in(textFontFile_, std::ios::binary); + font.assign(std::istreambuf_iterator(in), {}); + } else if (auto f = keel::fs::read(kFontFiles[textFont_])) + font = std::move(*f); + fry::TextStyle st; + st.height = textHeight_, st.depth = textDepth_, st.spacing = textSpacing_, st.align = textAlign_; + std::string err; + Mesh m = fry::makeText(font, textBuf_, st, &err); + if (m.faces.empty()) return say(err.empty() ? "No text to make" : err); + // (the letters' middle on the ground at the origin; standing up: facing the front) + const glm::vec3 lo = boundsMin(m), hi = boundsMax(m); + transform(m, glm::translate(glm::mat4(1.0f), glm::vec3(-(lo.x + hi.x) / 2, -(lo.y + hi.y) / 2, 0.0f))); + if (textStanding_) transform(m, glm::translate(glm::mat4(1.0f), glm::vec3(0, 0, (hi.y - lo.y) / 2)) * glm::rotate(glm::mat4(1.0f), kPi / 2, glm::vec3(1, 0, 0))); + pushUndo(); + if (mode_ == Mode::Edit) leaveEdit(); + std::string name = textBuf_; + if (const size_t nl = name.find('\n'); nl != std::string::npos) name.resize(nl); + if (name.size() > 24) name = name.substr(0, 24) + "..."; + Object& o = addObject("Text: " + name, std::move(m)); + o.position = glm::vec3(std::round(target_.x * 10) / 10, std::round(target_.y * 10) / 10, 0.0f); + // (reading left to right as the view looks: turned to the nearest quarter) + const glm::vec3 f = viewDir(); + if (std::abs(f.x) + std::abs(f.y) > 1e-4f) { + const float turn = std::round(std::atan2(-f.x, f.y) / (kPi / 2)) * (kPi / 2); + o.rotation = glm::angleAxis(turn, glm::vec3(0, 0, 1)); + } + selected_ = {o.id}, active_ = o.id; + showText_ = false; + say(std::format("Text added, {:.0f} mm tall letters, {} faces", textHeight_ * 1000, o.mesh.faces.size())); +} + +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 { + 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 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 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 diff --git a/src/app_paint.cpp b/src/app_paint.cpp new file mode 100644 index 0000000..36f6901 --- /dev/null +++ b/src/app_paint.cpp @@ -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(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(*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 { + if (!o.uvsFit()) return std::nullopt; + const glm::mat4 m = objectMatrix(o); + float best = 1e30f; + std::optional 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(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(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(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(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& 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 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(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>& islands) const -> std::vector> { + std::vector> 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::set> 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>& from, const std::shared_ptr& picture, const std::vector>& picked, const std::function& to, bool final) -> void { + const glm::vec2 size(float(o.picture->w), float(o.picture->h)); + o.uvs = from; + std::set 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(std::move(img)); + lay_.remapAt = now(); + } +} + +// Buttons' changes (turn, flip, straighten): about the middle of what's picked. +auto App::layoutApply(const std::function& 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 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(), 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 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(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 faces; + for (auto [f, c] : picked) faces.insert(f); + const glm::mat4 m = objectMatrix(*o); + for (int f : faces) { + std::vector 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 diff --git a/src/app_panels.cpp b/src/app_panels.cpp new file mode 100644 index 0000000..c36d4ad --- /dev/null +++ b/src/app_panels.cpp @@ -0,0 +1,2017 @@ +// Fry3DX: the panels: layers, properties, menus, the commands and their keys. +#include "app.h" + +using namespace fry; +namespace stdfs = std::filesystem; + +namespace fryapp { + +// ───────────────────────────── layers: folders and going to things ───────────────────────────── + +auto App::findFolder(uint32_t id) -> Folder* { + for (Folder& f : scene_.folders) + if (f.id == id) return &f; + return nullptr; +} + +auto App::inFolder(uint32_t folder, uint32_t within) -> bool { // (folder is within, or inside it somewhere) + for (int n = 0; folder && n < 256; ++n) { + if (folder == within) return true; + const Folder* f = findFolder(folder); + folder = f ? f->parent : 0; + } + return false; +} + +auto App::objectsIn(uint32_t folder) -> std::set { + std::set out; + for (const Object& o : scene_.objects) + if (inFolder(o.folder, folder)) out.insert(o.id); + return out; +} + +// The camera brought round to these (as framing them, but keeping the way it looks). +auto App::goTo(const std::set& ids) -> void { + glm::vec3 lo(1e30f), hi(-1e30f); + for (const Object& o : scene_.objects) + if (ids.count(o.id)) { + const glm::mat4 m = objectMatrix(o); + for (const glm::vec3& v : o.mesh.verts) { + const glm::vec3 w(m * glm::vec4(v, 1.0f)); + lo = glm::min(lo, w), hi = glm::max(hi, w); + } + } + if (lo.x > hi.x) return; + frameBox(lo, hi); +} + +// The picked objects into a new folder (Ctrl+G): a group, picked and moved together. +auto App::groupSelected() -> void { + if (mode_ == Mode::Edit) leaveEdit(); + if (selected_.empty()) return say("Pick the objects to put in a folder first"); + pushUndo(); + Folder f; + f.id = scene_.nextId++; + int n = 1; + while (std::any_of(scene_.folders.begin(), scene_.folders.end(), [&](const Folder& g) { return g.name == std::format("Group {}", n); })) ++n; + f.name = std::format("Group {}", n); + if (const Object* a = find(active_)) f.parent = a->folder; // (where the last picked was) + for (Object& o : scene_.objects) + if (selected_.count(o.id)) o.folder = f.id; + scene_.folders.push_back(f); + renamingFolder_ = f.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", f.name.c_str()); + say(f.name + ": click it to pick all of it; type a name"); +} + +// Ungrouped: what's inside goes up a level, the folder goes. +auto App::ungroup(uint32_t id) -> void { + const Folder* f = findFolder(id); + if (!f) return; + pushUndo(); + const uint32_t up = f->parent; + for (Object& o : scene_.objects) + if (o.folder == id) o.folder = up; + for (Folder& g : scene_.folders) + if (g.parent == id) g.parent = up; + std::erase_if(scene_.folders, [&](const Folder& g) { return g.id == id; }); +} + +auto App::deleteFolder(uint32_t id) -> void { + pushUndo(); + const auto ids = objectsIn(id); + std::erase_if(scene_.objects, [&](const Object& o) { return ids.count(o.id) > 0; }); + std::erase_if(scene_.folders, [&](const Folder& g) { return inFolder(g.id, id); }); + for (uint32_t i : ids) selected_.erase(i); +} + +// A folder's eye or lock: all in it the same. +auto App::showFolder(Folder& f, bool on) -> void { + pushUndo(); + for (Folder& g : scene_.folders) + if (inFolder(g.id, f.id)) g.visible = on; + for (Object& o : scene_.objects) + if (inFolder(o.folder, f.id)) o.visible = on; +} + +auto App::lockFolder(Folder& f, bool on) -> void { + pushUndo(); + for (Folder& g : scene_.folders) + if (inFolder(g.id, f.id)) g.locked = on; + for (Object& o : scene_.objects) + if (inFolder(o.folder, f.id)) o.locked = on; +} + +auto App::moveToFolder(const std::set& ids, uint32_t folder) -> void { + pushUndo(); + for (Object& o : scene_.objects) + if (ids.count(o.id)) o.folder = folder; +} + +// Only these shown (again: everything shown). +auto App::showOnly(const std::set& ids) -> void { + pushUndo(); + const bool already = std::all_of(scene_.objects.begin(), scene_.objects.end(), [&](const Object& o) { return o.visible == (ids.count(o.id) > 0); }); + for (Object& o : scene_.objects) o.visible = already || ids.count(o.id); + for (Folder& f : scene_.folders) f.visible = true; + say(already ? "Everything shown again" : "Only that shown (do it again to show everything)"); +} + +auto App::folderMenu(uint32_t into, const std::set& ids, int depth) -> void { // (Move to folder: the folders inside `into`) + for (const Folder& f : scene_.folders) + if (f.parent == into) { + const std::string label = std::string(size_t(depth) * 3, ' ') + f.name; + if (ImGui::MenuItem(label.c_str())) moveToFolder(ids, f.id); + folderMenu(f.id, ids, depth + 1); + } +} + +// What a right-click on an object (or the picked ones) offers. +auto App::objectMenu(Object& o) -> void { + const std::set ids = selected_.count(o.id) ? selected_ : std::set{o.id}; + if (ImGui::MenuItem("Go to it", "F")) goTo(ids); + if (ImGui::MenuItem("Rename")) renaming_ = o.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", o.name.c_str()); + if (ImGui::MenuItem("Duplicate", "Ctrl+D")) selected_ = ids, active_ = o.id, duplicateSelected(); + if (ImGui::MenuItem("Show only this (again: all)")) showOnly(ids); + if (ImGui::BeginMenu("Move to folder")) { + if (ImGui::MenuItem("(the top, no folder)")) moveToFolder(ids, 0); + folderMenu(0, ids); + ImGui::Separator(); + if (ImGui::MenuItem("A new folder", "Ctrl+G")) selected_ = ids, active_ = o.id, groupSelected(); + ImGui::EndMenu(); + } + ImGui::Separator(); + if (ImGui::MenuItem("Delete", "Del")) selected_ = ids, deleteSelected(); +} + +// A row's show / hide or lock button: the picture of how it is now, dim when it's the usual way. +auto App::rowToggle(const char* id, Icon icon, bool usual, const char* tip) -> bool { + const ImVec2 at = ImGui::GetCursorScreenPos(); + const float h = ImGui::GetFrameHeight() - 4; + const bool pressed = ImGui::InvisibleButton(id, ImVec2(h, h)); + const ImU32 col = ImGui::IsItemHovered() ? kHot : usual ? IM_COL32(200, 200, 205, 150) : ImGui::GetColorU32(accentText()); + drawIcon(ImGui::GetWindowDrawList(), icon, ImVec2(at.x + h * 0.1f, at.y + h * 0.1f), h * 0.8f, col); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip); + return pressed; +} + +// The ⌖ button at the end of a row: brings the camera to it. +auto App::goButton(const char* tip) -> bool { + const ImVec2 at = ImGui::GetCursorScreenPos(); + const float h = ImGui::GetFrameHeight() - 4; + const bool pressed = ImGui::InvisibleButton("##go", ImVec2(h, h)); + const ImU32 col = ImGui::IsItemHovered() ? kHot : IM_COL32(200, 200, 205, 200); + const ImVec2 c(at.x + h * 0.5f, at.y + h * 0.5f); + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddCircle(c, h * 0.3f, col, 16, 1.5f); + dl->AddLine(ImVec2(c.x - h * 0.48f, c.y), ImVec2(c.x - h * 0.16f, c.y), col, 1.5f), dl->AddLine(ImVec2(c.x + h * 0.16f, c.y), ImVec2(c.x + h * 0.48f, c.y), col, 1.5f); + dl->AddLine(ImVec2(c.x, c.y - h * 0.48f), ImVec2(c.x, c.y - h * 0.16f), col, 1.5f), dl->AddLine(ImVec2(c.x, c.y + h * 0.16f), ImVec2(c.x, c.y + h * 0.48f), col, 1.5f); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip); + return pressed; +} + +// Rows for what's in a folder (0: the top): its folders, then its objects. +auto App::layerRows(uint32_t parent, int depth) -> void { + const float indent = float(depth) * 14.0f; + // (copies of the ids: rows may change the lists) + std::vector folders, objects; + for (const Folder& f : scene_.folders) + if (f.parent == parent) folders.push_back(f.id); + for (const Object& o : scene_.objects) + if (o.folder == parent) objects.push_back(o.id); + for (uint32_t id : folders) { + Folder* f = findFolder(id); + if (!f) continue; + const auto inside = objectsIn(id); + ImGui::PushID(int(id) + 1000000); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + if (rowToggle("##eye", f->visible ? Icon::Eye : Icon::EyeShut, f->visible, f->visible ? "Hide all in it" : "Show all in it")) showFolder(*f, !f->visible); + ImGui::TableNextColumn(); + if (rowToggle("##lock", f->locked ? Icon::Lock : Icon::Unlock, !f->locked, f->locked ? "Unlock all in it" : "Lock all in it")) lockFolder(*f, !f->locked); + ImGui::TableNextColumn(); + ImGui::Indent(indent + 1); + if (ImGui::ArrowButton("##open", f->open ? ImGuiDir_Down : ImGuiDir_Right)) f->open = !f->open; + ImGui::SameLine(0, 4); + const bool picked = !inside.empty() && std::all_of(inside.begin(), inside.end(), [&](uint32_t i) { return selected_.count(i) > 0; }); + if (renamingFolder_ == id) { + ImGui::SetNextItemWidth(-1); + if (!ImGui::IsAnyItemActive()) ImGui::SetKeyboardFocusHere(); + if (ImGui::InputText("##rename", renameBuf_, sizeof renameBuf_, ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_AutoSelectAll)) { + if (renameBuf_[0]) f->name = renameBuf_; + renamingFolder_ = 0, dirty_ = true; + } + if (ImGui::IsItemDeactivated()) renamingFolder_ = 0; + } else { + ImGui::PushStyleColor(ImGuiCol_Text, accentText()); + const std::string label = std::format("{} ({})", f->name, inside.size()); + if (ImGui::Selectable(label.c_str(), picked, ImGuiSelectableFlags_AllowDoubleClick)) { // a folder is a group: all of it picked + if (mode_ == Mode::Edit) leaveEdit(); + if (ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeyShift) selected_.insert(inside.begin(), inside.end()); + else selected_ = inside; + if (!inside.empty()) active_ = *inside.rbegin(); + activeRef_ = 0; + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) renamingFolder_ = id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", f->name.c_str()); + } + ImGui::PopStyleColor(); + if (ImGui::BeginDragDropSource()) { + ImGui::SetDragDropPayload("FRY_FOLDER", &id, sizeof id); + ImGui::Text("%s", f->name.c_str()); + ImGui::EndDragDropSource(); + } + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_OBJECT")) { + const uint32_t oid = *static_cast(p->Data); + moveToFolder(selected_.count(oid) ? selected_ : std::set{oid}, id); + } + if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_FOLDER")) { + const uint32_t fid = *static_cast(p->Data); + if (Folder* g = findFolder(fid); g && !inFolder(id, fid)) pushUndo(), g->parent = id; // (not into itself) + } + ImGui::EndDragDropTarget(); + } + if (ImGui::IsItemHovered() && !ImGui::IsMouseDragging(ImGuiMouseButton_Left)) ImGui::SetTooltip("Click: pick all of it (a group). Double-click: rename. Drag things onto it."); + if (ImGui::BeginPopupContextItem("folder")) { + if (ImGui::MenuItem("Go to it")) goTo(inside); + if (ImGui::MenuItem("Pick all of it")) selected_ = inside; + if (ImGui::MenuItem("Rename")) renamingFolder_ = id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", f->name.c_str()); + if (ImGui::MenuItem("Show only this (again: all)")) showOnly(inside); + if (ImGui::MenuItem("A folder inside it")) { + pushUndo(); + Folder g; + g.id = scene_.nextId++, g.name = "Folder", g.parent = id; + scene_.folders.push_back(g); + f = findFolder(id), f->open = true; + renamingFolder_ = g.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", g.name.c_str()); + } + if (ImGui::MenuItem("Take it to the top") && f->parent) pushUndo(), f->parent = 0; + ImGui::Separator(); + if (ImGui::MenuItem("Ungroup (keep what's in it)")) ungroup(id); + if (ImGui::MenuItem("Delete it and all in it")) deleteFolder(id); + ImGui::EndPopup(); + } + } + ImGui::Unindent(indent + 1); + ImGui::TableNextColumn(); + if (goButton("Go to it: the camera comes round to all of it")) goTo(inside); + ImGui::PopID(); + if ((f = findFolder(id)) && f->open) layerRows(id, depth + 1); + } + for (uint32_t id : objects) { + Object* op = find(id); + if (!op) continue; + Object& o = *op; + ImGui::PushID(int(o.id)); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + if (rowToggle("##eye", o.visible ? Icon::Eye : Icon::EyeShut, o.visible, o.visible ? "Shown (click to hide)" : "Hidden (click to show)")) o.visible = !o.visible, dirty_ = true; + ImGui::TableNextColumn(); + if (rowToggle("##lock", o.locked ? Icon::Lock : Icon::Unlock, !o.locked, o.locked ? "Locked: can't be moved or changed (click to unlock)" : "Click to lock")) o.locked = !o.locked, dirty_ = true; + ImGui::TableNextColumn(); + ImGui::Indent(indent + 1); + if (renaming_ == o.id) { + ImGui::SetNextItemWidth(-1); + if (!ImGui::IsAnyItemActive()) ImGui::SetKeyboardFocusHere(); + if (ImGui::InputText("##rename", renameBuf_, sizeof renameBuf_, ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_AutoSelectAll)) { + pushUndo(); + if (renameBuf_[0]) o.name = renameBuf_; + renaming_ = 0; + } + if (ImGui::IsItemDeactivated()) renaming_ = 0; + } else { + ImGui::PushStyleColor(ImGuiCol_Text, o.visible ? ImGui::GetStyleColorVec4(ImGuiCol_Text) : ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); + if (ImGui::Selectable(o.name.c_str(), selected_.count(o.id) > 0, ImGuiSelectableFlags_AllowDoubleClick)) { + if (mode_ == Mode::Edit && o.id != active_) leaveEdit(); + if (ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeyShift) selected_.count(o.id) ? (void)selected_.erase(o.id) : (void)selected_.insert(o.id); + else selected_ = {o.id}; + active_ = o.id, activeRef_ = 0; + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) renaming_ = o.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", o.name.c_str()); + } + ImGui::PopStyleColor(); + if (ImGui::BeginDragDropSource()) { + ImGui::SetDragDropPayload("FRY_OBJECT", &id, sizeof id); + ImGui::Text("%s", selected_.count(id) && selected_.size() > 1 ? std::format("{} objects", selected_.size()).c_str() : o.name.c_str()); + ImGui::EndDragDropSource(); + } + if (ImGui::IsItemHovered() && !ImGui::IsMouseDragging(ImGuiMouseButton_Left)) ImGui::SetTooltip("Double-click to rename; drag onto a folder; right-click for more"); + if (ImGui::BeginPopupContextItem("object")) { + objectMenu(o); + ImGui::EndPopup(); + } + } + ImGui::Unindent(indent + 1); + ImGui::TableNextColumn(); + if (goButton("Go to it: the camera comes round to it")) goTo({id}); + ImGui::PopID(); + } +} + +// ───────────────────────────── panels ───────────────────────────── + +auto App::setupUi() -> void { + keel::ui::setPixelScale(touchScale()); + if (iniPath_.empty()) { + touchMode_ = fry_touch.b(); + iniPath_ = keel::prefPath() + (touchMode_ ? "layout_phone.ini" : "layout_v3.ini"); + ImGui::GetIO().IniFilename = iniPath_.c_str(); + if (stdfs::exists(iniPath_)) ImGui::LoadIniSettingsFromDisk(iniPath_.c_str()); + } + if (fry_theme.i() != appliedTheme_) appliedTheme_ = fry_theme.i(), keel::ui::setAppearance(keel::ui::Appearance(std::clamp(appliedTheme_, 0, 2))); + if (styled_) return; + styled_ = true; + ImGui::GetStyle().FontScaleDpi = 0.92f; // (the look itself: keel/ui, Niveus) +} + +// The least a panel can be and still read right (its words, its rows), by the font's size. +auto App::panelMin(std::string_view name) -> ImVec2 { + const float f = ImGui::GetFontSize(); + if (name == "Toolbox") return {15.5f * f, 9 * f}; + if (name == "Tool options") return {15.5f * f, 8 * f}; + if (name == "Properties") return {23 * f, 12 * f}; + if (name == "Layers") return {20 * f, 8 * f}; + if (name == "Picture") return {18 * f, 14 * f}; + if (name == "Script") return {30 * f, 16 * f}; + return {0, 0}; +} + +// Docked panels kept at their least size: where a split leaves one side smaller, the split +// moves (when the other side has room to give). ImGui's own splitters know only one least +// size for everything. Gives what the node needs. +auto App::keepMinSizes(ImGuiDockNode* n) -> ImVec2 { + if (!n) return {0, 0}; + if (n->IsLeafNode()) { + const float f = ImGui::GetFontSize(); + ImVec2 need = n->IsCentralNode() ? ImVec2(16 * f, 12 * f) : ImVec2(0, 0); + for (ImGuiWindow* w : n->Windows) need = ImMax(need, panelMin(w->Name)); + if (need.y > 0 && !n->IsCentralNode()) need.y += ImGui::GetFrameHeight(); // (its tab) + return need; + } + ImGuiDockNode *a = n->ChildNodes[0], *b = n->ChildNodes[1]; + const ImVec2 na = keepMinSizes(a), nb = keepMinSizes(b); + if (!a->IsVisible) return nb; + if (!b->IsVisible) return na; + const int axis = n->SplitAxis; + const float gap = ImGui::GetStyle().DockingSeparatorSize, room = n->Size[axis] - gap; + float sa = a->Size[axis]; + if (sa < na[axis] && room - na[axis] >= nb[axis]) sa = na[axis]; + else if (room - sa < nb[axis] && room - nb[axis] >= na[axis]) sa = room - nb[axis]; + if (sa != a->Size[axis]) a->SizeRef[axis] = sa, b->SizeRef[axis] = room - sa; + ImVec2 need = ImMax(na, nb); + need[axis] = na[axis] + nb[axis] + gap; + return need; +} + +auto App::defaultLayout(ImGuiID dock) -> void { + layoutDone_ = true; + ImGuiDockNode* node = ImGui::DockBuilderGetNode(dock); + if (!touch() && !std::exchange(layoutAgain_, false) && node && (node->IsSplitNode() || node->Windows.Size)) return; // (the user's own, from the .ini) + ImGui::DockBuilderRemoveNodeChildNodes(dock); + ImGui::DockBuilderSetNodeSize(dock, ImGui::GetMainViewport()->WorkSize); // (splits are shares of this) + ImGuiID center = dock; + if (touch() && portrait_) { // a phone held upright: the view on top, the panels as tabs under it + const ImGuiID bottom = ImGui::DockBuilderSplitNode(center, ImGuiDir_Down, 0.36f, nullptr, ¢er); + const ImVec2 size = ImGui::GetMainViewport()->WorkSize; + ImGui::DockBuilderSetNodeSize(bottom, ImVec2(size.x, size.y * 0.36f)); + for (const char* w : {"Toolbox", "Tool options", "Properties", "Layers", "Picture"}) ImGui::DockBuilderDockWindow(w, bottom); + ImGui::DockBuilderFinish(dock); + showToolbox_ = true; // (its tab first) + return; + } + if (touch()) { // a phone: tools down the left, the panels on the right (shown when asked for) + ImGuiID left = ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.2f, nullptr, ¢er); + const ImGuiID leftBottom = ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.45f, nullptr, &left); + const ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.36f, nullptr, ¢er); + ImGui::DockBuilderDockWindow("Toolbox", left); + ImGui::DockBuilderDockWindow("Tool options", leftBottom); + ImGui::DockBuilderDockWindow("Properties", right); + ImGui::DockBuilderDockWindow("Layers", right); + ImGui::DockBuilderDockWindow("Picture", right); + return ImGui::DockBuilderFinish(dock); + } + // (shares of the window, but never less than the panels' least sizes) + const float wide = ImGui::GetMainViewport()->WorkSize.x, leftShare = std::min(0.3f, std::max(0.14f, (panelMin("Toolbox").x + 8) / wide)); + const float rightShare = std::min(0.45f, std::max(0.22f, (panelMin("Properties").x + 8) / (wide * (1 - leftShare)))); + ImGuiID left = ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, leftShare, nullptr, ¢er); + const ImGuiID leftBottom = ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.5f, nullptr, &left); + ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, rightShare, nullptr, ¢er); + const ImGuiID rightBottom = ImGui::DockBuilderSplitNode(right, ImGuiDir_Down, 0.66f, nullptr, &right); + const ImGuiID bottom = ImGui::DockBuilderSplitNode(center, ImGuiDir_Down, 0.04f, nullptr, ¢er); + const ImGuiID picture = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.45f, nullptr, ¢er); // (the Paint tab's) + ImGui::DockBuilderDockWindow("Picture", picture); + ImGui::DockBuilderDockWindow("Script", picture); + ImGui::DockBuilderDockWindow("Toolbox", left); + ImGui::DockBuilderDockWindow("Tool options", leftBottom); + ImGui::DockBuilderDockWindow("Layers", right); + ImGui::DockBuilderDockWindow("Properties", rightBottom); + ImGui::DockBuilderDockWindow("Status", bottom); + if (ImGuiDockNode* b = ImGui::DockBuilderGetNode(bottom)) b->SetLocalFlags(ImGuiDockNodeFlags_NoTabBar); + ImGui::DockBuilderFinish(dock); +} + +// ───────────────────────────── commands ───────────────────────────── + +// Everything that can be done, by name: for the command search, keys of your own, the pies. +auto App::buildCommands() -> void { + commands_.clear(); + auto add = [&](std::string id, std::string name, std::string group, std::string keys, std::function run, std::function ok = {}, std::string words = {}) { + commands_.push_back({std::move(id), std::move(name), std::move(group), std::move(keys), std::move(run), std::move(ok), std::move(words)}); + }; + auto editing = [this] { return mode_ == Mode::Edit; }; + auto picked = [this] { return !selected_.empty(); }; + auto many = [this] { return selected_.size() >= 2 && mode_ == Mode::Object; }; + auto model = [this] { return tab_ == Tab::Model; }; + add("file.new", "New", "File", "Ctrl+N", [this] { whenSaved(2); }, {}, "empty start"); + add("file.open", "Open...", "File", "Ctrl+O", [this] { whenSaved(3); }, {}, "load fry"); + add("file.import", "Import a model (STL, OBJ, 3MF, glTF)...", "File", "Ctrl+Shift+O", [this] { dialog(12); }, {}, "load mesh stl obj 3mf"); + add("file.save", "Save", "File", "Ctrl+S", [this] { path_.empty() ? dialog(2) : void(saveFile(path_)); }); + add("file.saveas", "Save as...", "File", "Ctrl+Shift+S", [this] { dialog(2); }); + add("file.export", "Export (GLB, glTF, OBJ, STL, PLY, 3MF, FBX)...", "File", "Ctrl+E", [this] { dialog(3); }, {}, "stl obj gltf glb ply fbx 3mf print"); + add("file.picture", "Save a picture of the view...", "File", "F12", [this] { dialog(5); }, {}, "screenshot render png"); + add("file.recover", "Recover autosaved work...", "File", "", [this] { findAutosaves(), showRecover_ = !recover_.empty(); }); + add("file.quit", "Quit", "File", "Ctrl+Q", [this] { whenSaved(1); }, {}, "exit close"); + add("edit.undo", "Undo", "Edit", "Ctrl+Z", [this] { undo(); }, [this] { return !undo_.empty(); }); + add("edit.redo", "Redo", "Edit", "Ctrl+Y", [this] { redo(); }, [this] { return !redo_.empty(); }); + add("edit.history", "Undo history", "Edit", "", [this] { showHistory_ = true; }, {}, "steps back"); + add("edit.duplicate", "Duplicate", "Edit", "Ctrl+D", [this] { duplicateSelected(); }, [=, this] { return picked() && mode_ == Mode::Object; }, "copy clone"); + add("edit.join", "Join into one", "Edit", "Ctrl+J", [this] { joinSelected(); }, many, "merge objects"); + add("edit.delete", "Delete", "Edit", "Del", [this] { deleteSelected(); }, {}, "remove erase"); + add("edit.shape", "Edit the shape / stop", "Edit", "Tab", [this] { mode_ == Mode::Edit ? leaveEdit() : enterEdit(); }, [=] { return editing() || picked(); }, "edit mode points faces"); + add("select.all", "Select all", "Select", "Ctrl+A", [this] { selectAll(); }); + add("select.none", "Select none", "Select", "Alt+A", [this] { selectNone(); }, {}, "deselect"); + add("select.invert", "Invert the selection", "Select", "Ctrl+I", [this] { invertSelection(); }); + add("select.grow", "Select more (grow)", "Select", "Ctrl +", [this] { growSelection(); }, editing); + add("select.shrink", "Select less (shrink)", "Select", "Ctrl -", [this] { shrinkSelection(); }, editing); + add("select.linked", "Select everything joined to it", "Select", "L", [this] { selectLinked(); }, editing, "linked connected"); + add("select.similar", "Select similar faces (facing the same way)", "Select", "Shift+G", [this] { selectSimilar(); }, editing, "same normal size"); + add("select.loop", "Select whole loops (of the picked edges)", "Select", "", [this] { loopsOfSelected(false); }, editing, "edge loop"); + add("select.ring", "Select rings (across the picked edges)", "Select", "", [this] { loopsOfSelected(true); }, editing, "edge ring"); + for (const auto& [t, n] : std::initializer_list>{{SelShape::Box, "box"}, {SelShape::Brush, "brush"}, {SelShape::Lasso, "lasso"}}) + add(std::string("select.with.") + n, std::string("Select with a ") + n, "Select", "", [this, t] { selShape_ = t; }); + for (const ShapeRecipe& r : shapeRecipes()) + add(std::string("add.") + r.id, std::string("Add ") + (r.starter ? "a starter " : "a ") + r.name, "Add", "", [this, id = std::string(r.id)] { setTab(Tab::Model), addShape(id); }, {}, + r.starter ? "starter model" : "shape primitive"); + add("add.text", "Add text (3D letters)...", "Add", "", [this] { setTab(Tab::Model), showText_ = true; }, {}, "font letters words label name"); + add("add.draw", "Draw a shape", "Add", "D", [this] { if (mode_ == Mode::Edit) leaveEdit(); setTool(Tool::Draw); }, {}, "sketch outline polygon"); + add("add.picture", "Add a reference picture...", "Add", "", [this] { dialog(4); }, {}, "image background blueprint"); + add("object.goto", "Go to it (the camera)", "Object", "F", [this] { goTo(selected_); }, picked, "frame focus"); + add("object.copies", "Make copies (in a row, round, along a path)...", "Object", "", [this] { showCopies_ = true; }, picked, "array repeat pattern circular curve"); + add("object.group", "Group into a folder", "Object", "Ctrl+G", [this] { groupSelected(); }, picked); + add("object.add", "Add them together", "Object", "Ctrl+Shift++", [this] { combineSelected(Combine::Add); }, many, "boolean union"); + add("object.subtract", "Take the others out of it", "Object", "Ctrl+Shift+-", [this] { combineSelected(Combine::Subtract); }, many, "boolean difference cut"); + add("object.overlap", "Keep only where they overlap", "Object", "", [this] { combineSelected(Combine::Overlap); }, many, "boolean intersect"); + add("object.thicken", "Make it thick (or hollow)...", "Object", "", [this] { showThicken_ = true; }, picked, "shell solidify wall"); + add("object.cleanflat", "Join flat faces side by side", "Object", "", [this] { cleanFlatFaces(); }, picked, "dissolve tidy"); + add("object.spin", "Spin (turn its outline round an axis)...", "Object", "", [this] { showSpin_ = true; }, [this] { return !selected_.empty(); }, "lathe screw revolve"); + add("tools.measure", "Measure between two points", "Tools", "", [this] { + if (tab_ != Tab::Model && tab_ != Tab::Print) setTab(Tab::Model); + if (mode_ == Mode::Edit) leaveEdit(); + printTool_ = PrintTool::Measure, measure_.clear(); + say("Measure: click two points (corners catch the mouse); Esc: done"); + }, {}, "ruler distance length dimension how far"); + add("object.repair", "Repair for printing (close holes, fix faces)", "Print", "", [this] { repairSelected(); }, picked, "fix manifold watertight holes"); + add("object.scale", "Print size: scale, fit the plate, fix units...", "Print", "", [this] { setTab(Tab::Print), showPrintSize_ = true; }, picked, "resize fit dimensions percent scaler"); + for (const ToolInfo& t : kTools) + add(std::string("tool.") + t.name, std::string("Tool: ") + t.name, "Tools", t.key, [this, tool = t.tool, obj = t.objectMode] { + if (tab_ != Tab::Model) setTab(Tab::Model); + if (!obj && mode_ != Mode::Edit) enterEdit(); + setTool(tool); + }); + add("shape.subdivide", "Cut every face in four", "Shape", "", [this] { subdivideNow(false); }, editing, "subdivide"); + add("shape.smoothall", "Smooth it all (cut and round)", "Shape", "", [this] { subdivideNow(true); }, editing, "subdivision surface"); + add("shape.merge", "Merge points", "Shape", "M", [this] { mergeNow(); }, editing, "weld collapse"); + add("shape.face", "Make a face", "Shape", "", [this] { makeFaceNow(); }, editing, "fill"); + add("shape.flip", "Flip faces", "Shape", "", [this] { flipNow(); }, editing, "normals reverse"); + add("shape.smooth", "Smooth points", "Shape", "", [this] { smoothPointsNow(); }, editing, "relax"); + add("shape.push", "Push out / pull in...", "Shape", "Alt+S", [this] { startPush(); }, editing, "shrink fatten inflate"); + add("shape.bridge", "Bridge two faces", "Shape", "", [this] { bridgeNow(); }, editing, "tunnel connect"); + add("shape.soft", "Soft selection on / off", "Shape", "Shift+O", [this] { softSel_ = !softSel_; }, {}, "proportional falloff"); + add("shape.separate", "Make into its own object", "Shape", "P", [this] { separateSelected(); }, editing, "separate split"); + add("paint.mirror", "Paint both sides (mirrored)", "Paint", "", [this] { paintMirror_ = !paintMirror_, say(paintMirror_ ? "Painting both sides" : "Painting one side"); }, {}, "symmetry symmetric"); + add("paint.stencil", "Draw a paint stencil (paint only inside it)", "Paint", "", [this] { setTab(Tab::Paint), drawingStencil_ = true, stencil_.clear(); }, {}, "mask masking tape area"); + add("object.cutEditable", "Cut out, editable (the cutters stay, the hole follows them)", "Object", "", [this] { cutEditable(); }, many, "boolean subtract difference hole non-destructive live"); + add("shape.crease", "Crease the picked edges (stay sharp when smoothed)", "Shape", "Shift+E", [this] { creaseSelected(); }, editing, "sharp hard edge subdivision smooth"); + add("shape.slide", "Slide the points along their edges...", "Shape", "Alt+G", [this] { startSlide(); }, editing, "edge slide vertex slide"); + add("shape.mirror", "Mirror editing (both sides change together)", "Shape", "", [this] { liveMirror_ = !liveMirror_, say(liveMirror_ ? "Mirror editing on: both sides change together" : "Mirror editing off"); }, {}, "symmetry symmetric x"); + add("view.solid", "Show: solid", "View", "Z", [this] { shading_ = Shading::Solid; }); + add("view.rendered", "Show: rendered (sky, sun, shadows)", "View", "Z", [this] { shading_ = Shading::Rendered; }); + add("view.wire", "Show: wireframe", "View", "Shift+Z", [this] { shading_ = Shading::Wire; }); + add("view.xray", "X-ray on / off", "View", "Alt+Z", [this] { xray_ = !xray_; }, {}, "see through"); + add("view.grid", "Grid on / off", "View", "", [this] { showGrid_ = !showGrid_; }); + add("view.persp", "View: perspective", "View", "Numpad 5", [this] { leaveFlatView(); }); + for (const auto& [v, n, k] : std::initializer_list>{ + {View::Front, "front", "Numpad 1"}, {View::Back, "back", "Shift+Numpad 1"}, {View::Left, "left side", "Numpad 3"}, {View::Right, "right side", "Shift+Numpad 3"}, + {View::Top, "top", "Numpad 7"}, {View::Bottom, "bottom", "Shift+Numpad 7"}}) + add(std::string("view.") + n, std::string("View: ") + n, "View", k, [this, v] { setView(v); }, {}, "orthographic camera"); + add("view.frame", "Frame the selected", "View", "F", [this] { frameSelected(); }, {}, "zoom focus"); + add("view.all", "Frame everything", "View", "Home", [this] { frameAll(); }, {}, "zoom extents"); + add("view.only", "Show only the selected (again: all)", "View", "/", [this] { showOnly(selected_); }, picked, "isolate local view hide"); + add("view.unhide", "Show everything", "View", "Alt+H", [this] { for (Object& o : scene_.objects) o.visible = true; }, {}, "unhide"); + add("view.layout", "Put the panels back as they were", "View", "", [this] { resetLayout(); }, {}, "reset layout workspace default panels"); + add("view.workspace", "Workspaces (save the panels as they are)...", "View", "", [this] { showWorkspaces_ = true; }, {}, "layout save panels"); + for (int i = 0; i < 3; ++i) + add(std::string("view.look") + char('0' + i), std::string("Appearance: ") + (i == 0 ? "like the desktop" : i == 1 ? "dark" : "light"), "View", "", + [i] { keel::execute(std::format("fry_theme {}", i)); }, {}, "theme colours"); + const char* tabs[] = {"Model", "Paint", "Layout", "Render", "Print"}; + for (int i = 0; i < 5; ++i) + add(std::string("tab.") + tabs[i], std::string("Go to ") + tabs[i], "Tabs", std::format("Ctrl+{}", i + 1), [this, i] { setTab(Tab(i)); }, {}, i == 1 ? "sculpt texture" : ""); + add("script.panel", "The Script panel", "Script", "", [this] { showScript_ = !showScript_; }, {}, "lua code"); + add("script.run", "Run the script", "Script", "F5", [this] { runScriptNow(); }, [this] { return currentScript() != nullptr; }); + add("script.new", "New script", "Script", "", [this] { addScript("Script", kFirstScript); }); + add("script.plugins", "Plug-ins...", "Script", "", [this] { showPlugins_ = true; }, {}, "addons extensions"); + add("help.keys", "Keys and mouse (and your own keys)", "Help", "F1", [this] { showHelp_ = true; }, {}, "shortcuts hotkeys keymap bind"); + add("help.about", "About Fry3DX", "Help", "", [this] { showAbout_ = true; }, {}, "version"); + add("help.search", "Search for a command", "Help", "F3", [this] { openSearch(); }, {}, "find palette"); + for (size_t i = 0; i < plugins_.size(); ++i) + if (pluginUsable(i)) + for (size_t c = 0; c < plugins_[i].p->commands.size(); ++c) { + const fry::PluginCommand& cmd = plugins_[i].p->commands[c]; + add("plugin." + plugins_[i].file + "." + cmd.name, cmd.name + (cmd.settings.empty() ? "" : "..."), "Plug-in: " + plugins_[i].p->name, "", [this, i, c] { + if (i >= plugins_.size() || !pluginUsable(i) || c >= plugins_[i].p->commands.size()) return; + const fry::PluginCommand& cm = plugins_[i].p->commands[c]; + if (cm.settings.empty()) return runPluginCommand(i, c, {}); + pendingCommand_ = PendingCommand{i, c, {}}; + for (const auto& [n, v] : cm.settings) pendingCommand_->values.push_back(v); + }, {}, cmd.help); + } + (void)model; +} + +auto App::openSearch() -> void { + buildCommands(); + search_.show(); +} + +auto App::runCommand(const std::string& id) -> void { + for (const fry::Command& c : commands_) + if (c.id == id) { + if (!c.usable()) return say(c.name + ": not now (" + (c.group == "Shape" ? "edit a shape first, Tab" : "pick something first") + ")"); + std::erase(recentCommands_, id); + recentCommands_.insert(recentCommands_.begin(), id); + if (recentCommands_.size() > 8) recentCommands_.pop_back(); + return c.run(); + } +} + +auto App::commandSearch() -> void { + if (!search_.open) return; + if (const int i = search_.draw(commands_, recentCommands_); i >= 0) runCommand(commands_[size_t(i)].id); +} + +// The pies: the tools (Space), or the views (Shift+Space), round the mouse while it's held. +auto App::pieFrame(ImGuiIO& io) -> void { + const bool released = !pieStays_ && !ImGui::IsKeyDown(ImGuiKey_Space); + std::vector items; + std::vector> runs; + if (pie_ == 1) { + for (const ToolInfo& t : kTools) + if (mode_ == Mode::Edit ? t.editMode : t.objectMode) + items.push_back({t.name, t.icon, true, tool_ == t.tool}), runs.push_back([this, tool = t.tool] { setTool(tool); }); + items.push_back({mode_ == Mode::Edit ? "Done (objects)" : "Edit the shape", mode_ == Mode::Edit ? Icon::Objects : Icon::Shape, true, false}); + runs.push_back([this] { mode_ == Mode::Edit ? leaveEdit() : enterEdit(); }); + } else { + const std::pair views[] = {{View::Top, "Top"}, {View::Right, "Right"}, {View::Front, "Front"}, {View::Perspective, "3D"}, + {View::Bottom, "Bottom"}, {View::Back, "Back"}, {View::Left, "Left"}}; + for (const auto& [v, n] : views) { + items.push_back({n, Icon::Select, false, view_ == v}); + runs.push_back([this, v] { v == View::Perspective ? leaveFlatView() : setView(v); }); + } + items.push_back({"Frame", Icon::Select, false, false}), runs.push_back([this] { frameSelected(); }); + } + const int got = fry::pieMenu(pie_ == 1 ? "Tools" : "Views", pieAt_, items, released); + if (got == -1) return; + if (got >= 0) runs[size_t(got)](); + pie_ = 0, pieStays_ = false; + (void)io; +} + +// Keys of your own: kept in keys.txt (a line each: the command's id, a tab, the key). +auto App::loadMyKeys() -> void { + myKeys_.clear(); + std::ifstream in(keel::prefPath() + "keys.txt"); + for (std::string line; std::getline(in, line);) + if (const size_t tab = line.find('\t'); tab != std::string::npos) + if (const auto c = fry::parseChord(line.substr(tab + 1))) myKeys_[line.substr(0, tab)] = *c; +} + +auto App::saveMyKeys() -> void { + std::ofstream out(keel::prefPath() + "keys.txt"); + for (const auto& [id, c] : myKeys_) out << id << '\t' << fry::chordName(c) << '\n'; +} + +auto App::menus() -> void { + if (ImGui::BeginMenu("File")) { + if (ImGui::MenuItem("New", "Ctrl+N")) whenSaved(2); + if (ImGui::MenuItem("Open...", "Ctrl+O")) whenSaved(3); + if (ImGui::MenuItem("Save", "Ctrl+S")) path_.empty() ? dialog(2) : void(saveFile(path_)); + if (ImGui::MenuItem("Save as...", "Ctrl+Shift+S")) dialog(2); + ImGui::Separator(); + if (ImGui::MenuItem("Import a model (STL, OBJ, 3MF, glTF)...", "Ctrl+Shift+O")) dialog(12); + if (ImGui::MenuItem("Export (GLB, glTF, OBJ, STL, PLY)...", "Ctrl+E")) dialog(3); + if (ImGui::BeginMenu("Autosave")) { + static const int every[] = {1, 2, 5, 10, 0}; + for (int m : every) + if (ImGui::MenuItem(m ? std::format("Every {} minute{}", m, m > 1 ? "s" : "").c_str() : "Off", nullptr, fry_autosave.i() == m)) + keel::execute(std::format("fry_autosave {}", m)); + ImGui::Separator(); + if (ImGui::MenuItem("Recover autosaved work...")) findAutosaves(), showRecover_ = !recover_.empty(); + ImGui::EndMenu(); + } + if (ImGui::MenuItem("Save a picture of the view...", "F12")) dialog(5); + ImGui::Separator(); + if (ImGui::MenuItem("Quit", "Ctrl+Q")) whenSaved(1); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Edit")) { + if (ImGui::MenuItem("Undo", "Ctrl+Z", false, !undo_.empty())) undo(); + if (ImGui::MenuItem("Redo", "Ctrl+Y", false, !redo_.empty())) redo(); + ImGui::Separator(); + if (ImGui::MenuItem("Duplicate", "Ctrl+D", false, mode_ == Mode::Object)) duplicateSelected(); + if (ImGui::MenuItem("Join into one", "Ctrl+J", false, mode_ == Mode::Object && selected_.size() > 1)) joinSelected(); + if (ImGui::MenuItem("Delete", "Del")) deleteSelected(); + ImGui::Separator(); + if (ImGui::MenuItem(mode_ == Mode::Edit ? "Stop editing the shape" : "Edit the shape", "Tab")) mode_ == Mode::Edit ? leaveEdit() : enterEdit(); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Select")) { + if (ImGui::MenuItem("All", "Ctrl+A")) selectAll(); + if (ImGui::MenuItem("None", "Alt+A")) selectNone(); + if (ImGui::MenuItem("Invert", "Ctrl+I")) invertSelection(); + ImGui::Separator(); + ImGui::BeginDisabled(mode_ != Mode::Edit); + if (ImGui::MenuItem("More (grow)", "Ctrl +")) growSelection(); + if (ImGui::MenuItem("Less (shrink)", "Ctrl -")) shrinkSelection(); + if (ImGui::MenuItem("Everything joined to it", "L")) selectLinked(); + ImGui::MenuItem("A loop: Alt+click or double-click", nullptr, false, false); + ImGui::EndDisabled(); + ImGui::Separator(); + if (ImGui::MenuItem("Select with a box", nullptr, selShape_ == SelShape::Box)) selShape_ = SelShape::Box; + if (ImGui::MenuItem("Select with a brush", nullptr, selShape_ == SelShape::Brush)) selShape_ = SelShape::Brush; + if (ImGui::MenuItem("Select with a lasso", nullptr, selShape_ == SelShape::Lasso)) selShape_ = SelShape::Lasso; + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Add")) { + addMenuItems(); + pluginMenuItems("add"); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Object")) { + const bool many = selected_.size() >= 2, any = !selected_.empty(); + if (ImGui::MenuItem("Go to it", "F", false, any)) goTo(selected_); + if (ImGui::MenuItem("Duplicate", "Ctrl+D", false, any)) duplicateSelected(); + if (ImGui::MenuItem("Make copies (in a row, round)...", nullptr, false, any)) showCopies_ = true; + if (ImGui::MenuItem("Group into a folder", "Ctrl+G", false, any)) groupSelected(); + ImGui::Separator(); + ImGui::TextDisabled("Two or more: the last picked is kept"); + if (ImGui::MenuItem("Add them together", "Ctrl+Shift++", false, many)) combineSelected(Combine::Add); + if (ImGui::MenuItem("Take the others out of it", "Ctrl+Shift+-", false, many)) combineSelected(Combine::Subtract); + if (ImGui::MenuItem("Cut out, editable", nullptr, false, many)) cutEditable(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("The others cut into the last picked but stay objects: move or change them and the hole follows (Apply makes it for good)"); + if (ImGui::MenuItem("Keep only where they overlap", nullptr, false, many)) combineSelected(Combine::Overlap); + if (ImGui::MenuItem("Join (just as they are)", "Ctrl+J", false, many)) joinSelected(); + ImGui::Separator(); + if (ImGui::MenuItem("Make it thick (or hollow)...", nullptr, false, any)) showThicken_ = true; + if (ImGui::MenuItem("Join flat faces side by side", nullptr, false, any)) cleanFlatFaces(); + pluginMenuItems("object"); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Shape")) { + const bool ed = mode_ == Mode::Edit; + if (!ed) ImGui::TextDisabled("Edit an object's shape first (Tab)"); + ImGui::BeginDisabled(!ed); + if (ImGui::MenuItem("Extrude", "X")) setTool(Tool::Extrude); + if (ImGui::MenuItem("Inset", "I")) setTool(Tool::Inset); + if (ImGui::MenuItem("Round edges", "B")) setTool(Tool::Bevel); + if (ImGui::MenuItem("Loop cut", "C")) setTool(Tool::LoopCut); + ImGui::Separator(); + if (ImGui::MenuItem("Cut every face in four")) subdivideNow(false); + if (ImGui::MenuItem("Smooth it all (cut and round)")) subdivideNow(true); + if (ImGui::MenuItem("Merge points", "M")) mergeNow(); + if (ImGui::MenuItem("Make a face")) makeFaceNow(); + if (ImGui::MenuItem("Flip faces")) flipNow(); + ImGui::Separator(); + if (ImGui::MenuItem("Smooth points")) smoothPointsNow(); + if (ImGui::MenuItem("Push out / pull in...", "Alt+S")) startPush(); + if (ImGui::MenuItem("Bridge two faces")) bridgeNow(); + ImGui::MenuItem("Soft selection", "Shift+O", &softSel_); + if (ImGui::MenuItem("Make into its own object", "P")) separateSelected(); + if (ImGui::MenuItem("Delete", "Del")) deleteElements(); + ImGui::EndDisabled(); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("View")) { + if (ImGui::MenuItem("Solid (for modelling)", "Z", shading_ == Shading::Solid)) shading_ = Shading::Solid; + if (ImGui::MenuItem("Rendered (sky, sun, shadows)", "Z", shading_ == Shading::Rendered)) shading_ = Shading::Rendered; + if (ImGui::MenuItem("Wireframe", "Shift+Z", shading_ == Shading::Wire)) shading_ = Shading::Wire; + ImGui::MenuItem("X-ray (see and pick through)", "Alt+Z", &xray_); + ImGui::MenuItem("Grid", nullptr, &showGrid_); + ImGui::MenuItem("Ground (in Rendered)", nullptr, &scene_.look.ground); + if (ImGui::BeginMenu("Appearance")) { + const char* looks[] = {"Like the desktop", "Dark", "Light"}; + for (int i = 0; i < 3; ++i) + if (ImGui::MenuItem(looks[i], nullptr, fry_theme.i() == i)) keel::execute(std::format("fry_theme {}", i)); + ImGui::EndMenu(); + } + ImGui::Separator(); + if (ImGui::MenuItem("Perspective", "Numpad 5", view_ == View::Perspective)) leaveFlatView(); + if (ImGui::MenuItem("Front", "Numpad 1", view_ == View::Front)) setView(View::Front); + if (ImGui::MenuItem("Back", "Shift+Numpad 1", view_ == View::Back)) setView(View::Back); + if (ImGui::MenuItem("Left side", "Numpad 3", view_ == View::Left)) setView(View::Left); + if (ImGui::MenuItem("Right side", "Shift+Numpad 3", view_ == View::Right)) setView(View::Right); + if (ImGui::MenuItem("Top", "Numpad 7", view_ == View::Top)) setView(View::Top); + if (ImGui::MenuItem("Bottom", "Shift+Numpad 7", view_ == View::Bottom)) setView(View::Bottom); + ImGui::Separator(); + if (ImGui::MenuItem("Frame selected", "F")) frameSelected(); + if (ImGui::MenuItem("Frame everything", "Home")) frameAll(); + if (ImGui::MenuItem("Show only the selected (again: all)", "/", false, !selected_.empty())) showOnly(selected_); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Script")) { + ImGui::MenuItem("The Script panel", nullptr, &showScript_); + if (ImGui::MenuItem("Run it", "F5", false, currentScript())) runScriptNow(); + if (!scene_.scripts.empty() && ImGui::BeginMenu("This file's scripts")) { + for (size_t i = 0; i < scene_.scripts.size(); ++i) + if (ImGui::MenuItem(scene_.scripts[i].name.c_str(), nullptr, i == script_)) script_ = i, showScript_ = true; + ImGui::EndMenu(); + } + ImGui::Separator(); + if (ImGui::MenuItem("New script")) addScript("Script", kFirstScript); + if (ImGui::MenuItem("Open a script...")) showScript_ = true, dialog(10); + if (ImGui::MenuItem("Save the script as...", nullptr, false, currentScript())) dialog(11); + exampleScripts(); + ImGui::Separator(); + if (ImGui::BeginMenu("Plug-ins")) { + bool any = false; + for (size_t i = 0; i < plugins_.size(); ++i) + if (plugins_[i].p) + for (size_t c = 0; c < plugins_[i].p->commands.size(); ++c) + if (plugins_[i].p->commands[c].menu == "script") pluginMenuItem(i, c), any = true; + if (any) ImGui::Separator(); + if (ImGui::MenuItem("Plug-ins...")) showPlugins_ = true; + if (ImGui::MenuItem("Open the plug-ins folder")) openPluginsFolder(); + if (ImGui::MenuItem("Load them again")) loadPlugins(); + ImGui::EndMenu(); + } + if (ImGui::MenuItem("What scripts can do")) showScript_ = showScriptHelp_ = true; + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Help")) { + if (ImGui::MenuItem("Keys and mouse", "F1")) showHelp_ = true; + if (ImGui::MenuItem("About Fry3DX")) showAbout_ = true; + ImGui::EndMenu(); + } +} + +auto App::menuBar() -> void { + if (!ImGui::BeginMainMenuBar()) return; + if (haveLogo_) { + const float h = ImGui::GetFrameHeight() - 4; + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 1); + logoImage(h); + } + ImGui::PushFont(keel::ui::font(keel::ui::Font::Bold), 0.0f), ImGui::TextColored(accentText(), "Fry3DX"), ImGui::PopFont(); + if (!touch()) menus(); + else if (ImGui::BeginMenu("Menu")) { // (a phone: one menu, the others inside it) + menus(); + ImGui::EndMenu(); + } + const std::string file = (path_.empty() ? "Untitled" : fileName(path_)) + (dirty_ ? " *" : ""); + // The tabs: what you're doing (as wide as there's room for between the menus and the file's + // name; the pictures alone when it's tight). + { + const char* names[] = {"Model", "Paint", "Layout", "Render", "Print"}; + const Icon icons[] = {Icon::Objects, Icon::PaintBrush, Icon::Layout, Icon::Render, Icon::Print}; + const char* tips[] = {"Make the shape (Ctrl+1)", "Paint pictures onto it (Ctrl+2)", "Change how its picture is laid out: move, turn and size its frames (Ctrl+3)", + "Light it and save pictures of it (Ctrl+4)", "Get it ready for a 3D printer: size, checks, the printer's plate (Ctrl+5)"}; + const float h = ImGui::GetFrameHeight(), from = ImGui::GetCursorPosX() + 20.0f; + const float room = ImGui::GetWindowWidth() - from - (touch() ? 8.0f : ImGui::CalcTextSize(file.c_str()).x + 32.0f); + float tw = std::min(touch() ? 72.0f : 92.0f, room / 5 - 4); + const bool words = tw >= h + 2 + ImGui::CalcTextSize("Layout").x + 6; + if (!words) tw = h + 4; + ImGui::SetCursorPosX(std::max(from, std::min((ImGui::GetWindowWidth() - (tw + 4) * 5) * 0.5f, from + room - (tw + 4) * 5))); + for (int i = 0; i < 5; ++i) { + if (i) ImGui::SameLine(0, 4); + ImGui::PushID(i); + if (wideButton("##tab", icons[i], words ? names[i] : "", int(tab_) == i, tw, words ? tips[i] : std::format("{}: {}", names[i], tips[i]).c_str(), h)) setTab(Tab(i)); + ImGui::PopID(); + } + } + if (touch()) return ImGui::EndMainMenuBar(); // (no room on a phone) + ImGui::SameLine(ImGui::GetWindowWidth() - ImGui::CalcTextSize(file.c_str()).x - 16); + ImGui::TextDisabled("%s", file.c_str()); + ImGui::EndMainMenuBar(); +} + +auto App::addMenuItems() -> void { + ImGui::SeparatorText("Shapes"); + for (const ShapeRecipe& r : shapeRecipes()) + if (!r.starter && ImGui::MenuItem(r.name)) addShape(r.id); + ImGui::SeparatorText("Starter models"); + for (const ShapeRecipe& r : shapeRecipes()) + if (r.starter && ImGui::MenuItem(r.name)) addShape(r.id); + ImGui::Separator(); + if (ImGui::MenuItem("Draw a shape", "D")) { + if (mode_ == Mode::Edit) leaveEdit(); + setTool(Tool::Draw); + } + if (ImGui::MenuItem("Reference picture...")) dialog(4); +} + +// How the last item's key looks: filled when it's the one in use, sunk while held. +auto App::keyState(bool on) -> keel::ui::KeyState { + using keel::ui::KeyState; + return on ? KeyState::On : ImGui::IsItemActive() ? KeyState::Pressed : ImGui::IsItemHovered() ? KeyState::Hovered : KeyState::Normal; +} + +// A square button with a picture on it (the accent when it's the one in use). +auto App::iconButton(const char* id, Icon icon, bool on, const char* tip, float size) -> bool { + const ImVec2 p = ImGui::GetCursorScreenPos(); + const bool hit = ImGui::InvisibleButton(id, ImVec2(size, size)); + const keel::ui::KeyState k = keyState(on); + ImDrawList* dl = ImGui::GetWindowDrawList(); + keel::ui::drawKey(dl, p, ImVec2(p.x + size, p.y + size), 7.0f, k); + const float sink = k == keel::ui::KeyState::Pressed ? 1.0f : 0.0f; + drawIcon(dl, icon, ImVec2(p.x + size * 0.14f, p.y + size * 0.14f + sink), size * 0.72f, keel::ui::keyText(k)); + const bool hov = ImGui::IsItemHovered(); + if (hov && tip) ImGui::SetTooltip("%s", tip); + return hit; +} + +// A wide one, with words beside the picture. +auto App::wideButton(const char* id, Icon icon, const char* label, bool on, float width, const char* tip, float h) -> bool { + const ImVec2 p = ImGui::GetCursorScreenPos(); + const bool hit = ImGui::InvisibleButton(id, ImVec2(width, h)); + const keel::ui::KeyState k = keyState(on); + ImDrawList* dl = ImGui::GetWindowDrawList(); + keel::ui::drawKey(dl, p, ImVec2(p.x + width, p.y + h), 7.0f, k); + const float sink = k == keel::ui::KeyState::Pressed ? 1.0f : 0.0f; + drawIcon(dl, icon, ImVec2(p.x + h * 0.15f, p.y + h * 0.13f + sink), h * 0.74f, keel::ui::keyText(k)); + dl->AddText(ImVec2(p.x + h + 2, p.y + (h - ImGui::GetFontSize()) * 0.5f + sink), keel::ui::keyText(k), label); + const bool hov = ImGui::IsItemHovered(); + if (hov && tip) ImGui::SetTooltip("%s", tip); + return hit; +} + +auto App::toolbox() -> void { + if (std::exchange(showToolbox_, false)) ImGui::SetNextWindowFocus(); + ImGui::SetNextWindowSizeConstraints(panelMin("Toolbox"), ImVec2(FLT_MAX, FLT_MAX)); + ImGui::Begin("Toolbox", nullptr, ImGuiWindowFlags_NoCollapse); + const float w = ImGui::GetContentRegionAvail().x; + if (tab_ == Tab::Paint) { + paintToolbox(w); + return ImGui::End(); + } + if (tab_ == Tab::Layout) { + layoutToolbox(w); + return ImGui::End(); + } + if (tab_ == Tab::Render || tab_ == Tab::Print) { + const Tool tools[] = {Tool::Select, Tool::Move, Tool::Rotate, Tool::Scale}; + grid(4, 38.0f, [&](int i) { + const ToolInfo& t = kTools[int(tools[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, tool_ == t.tool, tip.c_str())) setTool(t.tool); + }); + ImGui::Spacing(); + if (tab_ == Tab::Print) printToolbox(w); + else if (wideButton("##save", Icon::Picture, "Save a picture", false, w, "Saves what the view shows, without the panels or gizmos (F12)")) dialog(5); + return ImGui::End(); + } + // The mode: whole objects, or the shape of one. + if (wideButton("##objects", Icon::Objects, "Objects", mode_ == Mode::Object, (w - 5) / 2, "Work with whole objects")) leaveEdit(); + ImGui::SameLine(0, 5); + if (wideButton("##shape", Icon::Shape, "Shape", mode_ == Mode::Edit, (w - 5) / 2, "Edit the selected object's shape: its points, edges and faces (Tab, or double-click it)")) enterEdit(); + if (mode_ == Mode::Edit) { + const Icon icons[] = {Icon::Points, Icon::Edges, Icon::Faces}; + const char* tips[] = {"Points (1)", "Edges (2)", "Faces (3)"}; + const char* names[] = {"Points", "Edges", "Faces"}; + const float third = (w - 10) / 3; + for (int i = 0; i < 3; ++i) { + if (i) ImGui::SameLine(0, 5); + ImGui::PushID(i); + if (wideButton("##elem", icons[i], names[i], int(elem_) == i, third, tips[i])) elem_ = Elem(i), clearElements(); + ImGui::PopID(); + } + } + ImGui::Spacing(); + // The tools, as pictures (hover for the name and key). + std::vector tools; + for (const ToolInfo& t : kTools) + if ((mode_ == Mode::Object && t.objectMode) || (mode_ == Mode::Edit && t.editMode)) tools.push_back(&t); + grid(int(tools.size()) + 1, 38.0f, [&](int i) { + if (i == int(tools.size())) { + if (iconButton("##add", Icon::Add, false, "Add a shape or a starter model")) ImGui::OpenPopup("add"); + return; + } + const ToolInfo& t = *tools[size_t(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, tool_ == t.tool, tip.c_str())) setTool(t.tool); + }); + if (ImGui::BeginPopup("add")) { + addMenuItems(); + ImGui::EndPopup(); + } + // Mouse tools from plug-ins (fry.tool): by name, as plug-ins load again. + if (mode_ == Mode::Object) { + bool first = true; + for (size_t i = 0; i < plugins_.size(); ++i) + if (pluginUsable(i)) + for (const fry::PluginTool& t : plugins_[i].p->tools) { + if (std::exchange(first, false)) ImGui::SeparatorText("Plug-in tools"); + const bool on = pluginTool_ == std::pair(plugins_[i].file, t.name); + if (ImGui::Selectable(std::format("{}##{}", t.name, plugins_[i].file).c_str(), on, 0, ImVec2(w, 0))) + on ? setTool(Tool::Select) : void((pluginTool_ = {plugins_[i].file, t.name}, tool_ = Tool::Select, say(t.name + ": click in the view (Esc stops)"))); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s%s(%s)", t.help.c_str(), t.help.empty() ? "" : "\n", plugins_[i].p->name.c_str()); + } + } + ImGui::Spacing(); + if (pluginTool_.first.empty()) // (a plug-in tool: its row shows it's on) + for (const ToolInfo& t : kTools) + if (t.tool == tool_) ImGui::TextColored(accentText(), "%s", t.name); + ImGui::End(); +} + +// The plug-in tool that's on: which plug-in and which of its tools (none: -1). +auto App::pluginToolAt() const -> std::pair { + for (size_t i = 0; i < plugins_.size(); ++i) + if (plugins_[i].file == pluginTool_.first && pluginUsable(i)) + for (size_t k = 0; k < plugins_[i].p->tools.size(); ++k) + if (plugins_[i].p->tools[k].name == pluginTool_.second) return {int(i), int(k)}; + return {-1, -1}; +} + +// A plug-in tool's click: the spot on what's under the mouse, or on the ground. +auto App::clickPluginTool(ImVec2 mouse) -> void { + const auto [i, k] = pluginToolAt(); + if (i < 0) return pluginTool_ = {}, say("That plug-in tool isn't loaded any more"); + const Ray ray = mouseRay(mouse); + glm::vec3 at, n(0, 0, 1); + uint32_t onto = 0; + const Hit h = pickObject(ray, 0, true); + if (const Object* o = h.object ? find(h.object) : nullptr) { + at = ray.origin + ray.dir * h.t, onto = o->id; + n = glm::normalize(glm::transpose(glm::inverse(glm::mat3(objectMatrix(*o)))) * faceNormal(o->mesh, h.face)); + } else if (auto g = hitPlane(ray, glm::vec3(0.0f), n)) at = *g; + else return; + pushUndo(); + bool frame = false; + fry::ScriptHost host{&scene_, &selected_, &active_, [this](const std::string& t) { scriptLine(t); }, [&] { frame = true; }}; + const fry::ScriptResult r = plugins_[size_t(i)].p->click(host, size_t(k), at, n, onto); + finishRun(r, pluginTool_.second, frame); +} + +auto App::paintToolbox(float w) -> void { + const float half = (w - ImGui::GetStyle().ItemSpacing.x) * 0.5f; + if (wideButton("##colour-side", Icon::PaintBrush, "Colour", !sculpting_, half, "Paint colours on it (Tab)")) sculpting_ = false, endSculpt(); + ImGui::SameLine(); + if (wideButton("##shape-side", Icon::Sculpt, "Sculpt", sculpting_, half, "Push its surface about like clay (Tab)")) sculpting_ = true, endStroke(); + ImGui::Spacing(); + if (sculpting_) { + grid(int(std::size(kSculptTools)), 38.0f, [&](int i) { + const SculptToolInfo& t = kSculptTools[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, sculptBrush_ == t.brush, tip.c_str())) sculptBrush_ = t.brush; + }); + for (const SculptToolInfo& t : kSculptTools) + if (t.brush == sculptBrush_) ImGui::TextColored(accentText(), "%s", t.name); + return; + } + grid(int(std::size(kPaintTools)), 38.0f, [&](int i) { + const PaintToolInfo& t = kPaintTools[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, paintTool_ == t.tool, tip.c_str())) paintTool_ = t.tool; + }); + ImGui::Spacing(); + // The colour, big, and a few to pick from. + ImGui::ColorButton("##colour", ImVec4(paintColor_.r, paintColor_.g, paintColor_.b, 1.0f), ImGuiColorEditFlags_NoTooltip, ImVec2(w, 34)); + static const glm::vec3 palette[] = {{0.08f, 0.08f, 0.09f}, {0.95f, 0.95f, 0.95f}, {0.55f, 0.55f, 0.57f}, {0.85f, 0.2f, 0.12f}, {1.0f, 0.55f, 0.1f}, {0.98f, 0.85f, 0.2f}, + {0.3f, 0.7f, 0.25f}, {0.15f, 0.45f, 0.85f}, {0.45f, 0.25f, 0.7f}, {0.45f, 0.3f, 0.18f}, {0.75f, 0.6f, 0.45f}, {0.95f, 0.6f, 0.7f}}; + grid(int(std::size(palette)), 20.0f, [&](int i) { + ImGui::PushID(i); + if (ImGui::ColorButton("##p", ImVec4(palette[i].r, palette[i].g, palette[i].b, 1.0f), 0, ImVec2(20, 20))) paintColor_ = glm::vec4(palette[i], paintColor_.a); + ImGui::PopID(); + }); + for (const PaintToolInfo& t : kPaintTools) + if (t.tool == paintTool_) ImGui::TextColored(accentText(), "%s", t.name); +} + +auto App::sculptOptions(float w) -> void { + for (const SculptToolInfo& t : kSculptTools) + if (t.brush == sculptBrush_) ImGui::SeparatorText(t.name), ImGui::TextWrapped("%s", t.hint); + ImGui::Spacing(); + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##size", &sculptPx_, 4.0f, 400.0f, "size %.0f px", ImGuiSliderFlags_Logarithmic); + if (sculptBrush_ != SculptBrush::Grab) { + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##strength", &sculptStrength_, 0.02f, 1.0f, "strength %.2f"); + } + ImGui::Checkbox("Same on both sides (X)", &sculptMirror_); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("What you do on its left is done on its right too"); + if (Object* o = selected_.size() == 1 ? find(active_) : nullptr) { + ImGui::SeparatorText(o->name.c_str()); + ImGui::TextDisabled("%zu points", o->mesh.verts.size()); + size_t after = 0; + for (const auto& f : o->mesh.faces) after += f.size(); + ImGui::BeginDisabled(after > 400000 || o->locked); + if (ImGui::Button("Finer, for small details", ImVec2(w, 0))) { + pushUndo(); + if (o->hasFinish()) o->mesh = finishedMesh(*o), o->mirror = 0, o->round = 0, o->smoothLevels = 0; + handMade(*o); + subdivide(o->mesh, false); + touched(o->id); + } + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("Each face split in four: the same shape, more points to push about"); + if (ImGui::Button("Smooth all of it", ImVec2(w, 0)) && !o->locked) { + pushUndo(); + handMade(*o); + const SculptMesh sm(o->mesh); + const glm::vec3 mid = (boundsMin(o->mesh) + boundsMax(o->mesh)) * 0.5f; + sculptDab(o->mesh, sm, SculptBrush::Smooth, {mid, glm::distance(boundsMin(o->mesh), boundsMax(o->mesh)) * 2.0f, 0.5f}); + touched(o->id); + } + } + ImGui::Spacing(); + ImGui::TextDisabled("Ctrl: the other way Shift: smooth\n[ and ]: brush size 1-6: brushes"); +} + +auto App::paintOptions(float w) -> void { + if (sculpting_) return sculptOptions(w); + for (const PaintToolInfo& t : kPaintTools) + if (t.tool == paintTool_) ImGui::SeparatorText(t.name); + if (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Eraser) { + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##size", &brushPx_, 1.0f, 300.0f, "size %.0f px", ImGuiSliderFlags_Logarithmic); + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##hard", &hardness_, 0.0f, 1.0f, "hard edge %.2f"); + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##strength", &paintColor_.a, 0.02f, 1.0f, "strength %.2f"); + ImGui::Checkbox("Both sides (mirrored, X)", &paintMirror_); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("What you paint on one side goes on the other too, mirrored (left and right: the object's own Y)"); + ImGui::SeparatorText("Stencil"); + if (stencil_.size() >= 3) { + ImGui::Checkbox("Paint only inside it", &stencilOn_); + if (ImGui::Button("Draw it again", ImVec2(w * 0.5f - 2, 0))) drawingStencil_ = true, stencil_.clear(); + ImGui::SameLine(0, 4); + if (ImGui::Button("Take it off", ImVec2(w * 0.5f - 2, 0))) stencil_.clear(), stencilOn_ = false; + } else { + if (ImGui::Button(drawingStencil_ ? "Drawing: drag round the area..." : "Draw a stencil", ImVec2(w, 0))) drawingStencil_ = !drawingStencil_, stencil_.clear(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Drag round an area on the screen: paint goes only inside it (it stays put on the screen as you turn the view)"); + } + } + if (paintTool_ == PaintTool::Fill) { + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##tol", &fillTolerance_, 0.0f, 1.0f, "how alike %.2f"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("How different a colour can be and still be filled"); + } + if (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Fill || paintTool_ == PaintTool::Picker) { + ImGui::SetNextItemWidth(w); + ImGui::ColorPicker3("##picker", &paintColor_.x, ImGuiColorEditFlags_NoSidePreview | ImGuiColorEditFlags_NoSmallPreview | ImGuiColorEditFlags_PickerHueBar); + } + if (paintTool_ == PaintTool::Eraser) ImGui::TextWrapped("Paints the object's plain colour back (its colour in the Model tab)."); + if (paintTool_ == PaintTool::Picture) { + if (ImGui::Button("Paste (Ctrl+V)", ImVec2(w, 0))) pasteFromClipboard(); + if (ImGui::Button("Add a picture from a file...", ImVec2(w, 0))) dialog(6); + if (PictureLayer* l = editLayer()) { + Object* o = find(ledit_.object); + ImGui::SeparatorText(l->name.c_str()); + if (ImGui::Button("Lock it in place (Enter)", ImVec2(w, 0))) lockLayer(); + if (ImGui::Button(ledit_.fresh ? "Take it away (Esc)" : "Delete it (Del)", ImVec2(w, 0))) ledit_.fresh ? cancelFloating() : deleteLayer(*o, ledit_.index); + ImGui::SetNextItemWidth(w); + undoable([&] { return ImGui::SliderFloat("##op", &l->opacity, 0.0f, 1.0f, "see-through %.2f"); }); + if (l->sticker) { + float size[2] = {l->width, l->height}; + ImGui::SetNextItemWidth(w); + if (undoable([&] { return ImGui::DragFloat2("##wh", size, 0.002f, 0.002f, 100.0f, "%.3f m"); })) l->width = size[0], l->height = size[1]; + } else if (o && o->picture) { + if (ImGui::Button("Cover the whole picture", ImVec2(w, 0))) { + pushUndo(); + const float W = float(o->picture->w), H = float(o->picture->h); + l->corners = {glm::vec2(0, 0), glm::vec2(W, 0), glm::vec2(W, H), glm::vec2(0, H)}; + } + if (ImGui::Button("Square it up again", ImVec2(w, 0))) { + pushUndo(); + glm::vec2 c(0.0f); + for (const glm::vec2& p : l->corners) c += p * 0.25f; + const float across = (glm::distance(l->corners[0], l->corners[1]) + glm::distance(l->corners[3], l->corners[2])) * 0.5f; + const glm::vec2 half(across * 0.5f, across * 0.5f * float(l->image->h) / float(std::max(l->image->w, 1))); + 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)}; + } + } + ImGui::Separator(); + } + ImGui::TextDisabled("New pictures go"); + int where = pasteOnSurface_ ? 0 : 1; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##where", &where, "On the model (a sticker)\0On the flat picture\0")) pasteOnSurface_ = where == 0; + ImGui::TextWrapped(pasteOnSurface_ ? "Copy a picture in any app (GIMP, a browser...) and paste it. Drag it round the model: over an edge it bends like a sticker. " + "Corners size it (Shift: stretch), the round handle turns it (Ctrl: steps). Enter locks it as a layer; click it in the Picture panel to move it again." + : "Copy a picture in any app and paste it. In the Picture panel drag its corners where you want them: it bends to fit " + "(Shift keeps its shape; drag inside to move it). Enter locks it as a layer; click it there to move it again."); + } +} + +auto App::softChoice(float w) -> void { + if (mode_ != Mode::Edit) return; + ImGui::Checkbox("Soft selection (Shift+O)", &softSel_); + ImGui::Checkbox("Mirror editing (across X)", &liveMirror_); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("A symmetric shape stays so: move a point, its twin on the other side moves too"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Points near the selection follow it, less the further they are"); + if (!softSel_) return; + ImGui::SetNextItemWidth(w); + ImGui::DragFloat("##reach", &softReach_, 0.002f, 0.001f, 1000.0f, "reaches %.3f m"); + ImGui::TextDisabled("(the wheel while dragging too)"); +} + +auto App::toolOptions() -> void { + ImGui::SetNextWindowSizeConstraints(panelMin("Tool options"), ImVec2(FLT_MAX, FLT_MAX)); + ImGui::Begin("Tool options", nullptr, ImGuiWindowFlags_NoCollapse); + const float w = ImGui::GetContentRegionAvail().x; + if (tab_ == Tab::Paint) { + paintOptions(w); + return ImGui::End(); + } + if (tab_ == Tab::Layout) { + layoutOptions(w); + return ImGui::End(); + } + if ((tab_ == Tab::Print && printTool_ != PrintTool::None) || printTool_ == PrintTool::Measure) { + printOptions(w); + return ImGui::End(); + } + const char* name = ""; + for (const ToolInfo& t : kTools) + if (t.tool == tool_) name = t.name; + ImGui::SeparatorText(name); + auto orientChoice = [&] { + ImGui::TextDisabled("Arrows point"); + int o = int(orient()); + const char* items = mode_ == Mode::Edit ? "The object's way\0The world's way\0Out of the faces\0" : "The object's way\0The world's way\0"; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##orient", &o, items)) orient() = Orient(o); + }; + auto snapChoice = [&](bool angle) { + ImGui::Checkbox("Snap", &snap_); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("In steps (holding Ctrl does the opposite)"); + if (!snap_) return; + ImGui::SetNextItemWidth(w); + if (angle) return void(ImGui::DragFloat("##snapangle", &snapAngle_, 0.5f, 1.0f, 90.0f, "every %.0f deg")); + if (tool_ == Tool::Move) { + ImGui::SetNextItemWidth(w); + ImGui::Combo("##snapkind", &snapKind_, "To steps\0To points\0To edges' middles\0To faces' middles\0"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Points, middles: the arrows' point goes to the one nearest the mouse (on other shapes, or what isn't picked)"); + if (snapKind_) return; + ImGui::SetNextItemWidth(w); + } + ImGui::DragFloat("##snapmove", &snapMove_, 0.001f, 0.001f, 10.0f, "every %.3f m"); + }; + switch (tool_) { + case Tool::Select: { + ImGui::TextDisabled("Drag to select with"); + const Icon icons[] = {Icon::Box, Icon::Brush, Icon::Lasso}; + const char* tips[] = {"A box", "A brush: paint over what you want", "A lasso: draw round it"}; + for (int i = 0; i < 3; ++i) { + if (i) ImGui::SameLine(0, 5); + ImGui::PushID(i); + if (iconButton("##shape", icons[i], int(selShape_) == i, tips[i], 34.0f)) selShape_ = SelShape(i); + ImGui::PopID(); + } + if (selShape_ == SelShape::Brush) { + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##brush", &brushRadius_, 4.0f, 200.0f, "brush %.0f px"); + } + ImGui::Checkbox("See through (X-ray)", &xray_); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Select things behind others too (Alt+Z)"); + break; + } + case Tool::Move: + orientChoice(); + snapChoice(false); + softChoice(w); + break; + case Tool::Rotate: { + orientChoice(); + ImGui::TextDisabled("Turn around"); + int p = rotatePivot_ == Pivot::Origin ? 1 : 0; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##rpivot", &p, "The middle\0The origin\0")) rotatePivot_ = p ? Pivot::Origin : Pivot::Middle; + snapChoice(true); + softChoice(w); + break; + } + case Tool::Scale: { + if (mode_ == Mode::Edit) orientChoice(); + ImGui::TextDisabled("What stays put"); + int p = scalePivot_ == Pivot::Opposite ? 0 : scalePivot_ == Pivot::Middle ? 1 : scalePivot_ == Pivot::Bottom ? 2 : 3; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##spivot", &p, "The other side\0The middle\0The bottom\0The origin\0")) + scalePivot_ = p == 0 ? Pivot::Opposite : p == 1 ? Pivot::Middle : p == 2 ? Pivot::Bottom : Pivot::Origin; + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("The other side: drag a side, the opposite side stays (like stretching in a paint program).\nThe middle: both sides move.\nThe bottom: it stays standing on the ground."); + snapChoice(false); + softChoice(w); + break; + } + case Tool::Draw: + ImGui::SetNextItemWidth(w); + ImGui::DragFloat("##thick", &drawThickness_, 0.001f, 0.0f, 10.0f, "thickness %.3f m"); + ImGui::TextWrapped("Drawn on the face you start on, or on %s.", + view_ == View::Perspective || view_ == View::Top || view_ == View::Bottom ? "the ground (a side view draws upright)" : "the plane you look at"); + ImGui::TextDisabled("Drawn on an object:"); + ImGui::SetNextItemWidth(w); + ImGui::Combo("##drawon", &drawOnMode_, "A new object on it\0Added to it (raised)\0Cut into it\0"); + snapChoice(false); + if (!drawPts_.empty() && ImGui::Button("Finish (Enter)", ImVec2(w, 0))) finishShape(); + break; + case Tool::Extrude: + ImGui::SetNextItemWidth(w); + ImGui::DragFloat("##ext", &extrudeAmount_, 0.001f, -10, 10, "%.3f m"); + if (ImGui::Button("Extrude by that", ImVec2(w, 0))) extrudeNow(extrudeAmount_); + snapChoice(false); + break; + case Tool::Inset: + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##ins", &insetAmount_, 0.01f, 0.99f, "%.2f of the way in"); + if (ImGui::Button("Inset by that", ImVec2(w, 0))) insetNow(insetAmount_); + break; + case Tool::Bevel: + ImGui::SetNextItemWidth(w); + ImGui::DragFloat("##bw", &bevelWidth_, 0.001f, 0.0f, 10.0f, "width %.3f m"); + ImGui::SetNextItemWidth(w); + ImGui::SliderInt("##bs", &bevelSteps_, 1, 10, bevelSteps_ == 1 ? "1 step (a flat cut)" : "%d steps round"); + if (ImGui::Button("Round them", ImVec2(w, 0))) bevelNow(); + break; + case Tool::LoopCut: + ImGui::SetNextItemWidth(w); + ImGui::SliderInt("##cuts", &cuts_, 1, 16, cuts_ == 1 ? "1 cut" : "%d cuts"); + ImGui::TextWrapped("Cuts go across four-sided faces, all the way round."); + break; + case Tool::Knife: { + ImGui::Checkbox("Cut right through", &knifeThrough_); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Through everything in the line's way, the back too (else only what you can see).\nThe line goes on past its ends."); + if (knifeThrough_) { + ImGui::TextDisabled("Then"); + ImGui::SetNextItemWidth(w); + ImGui::Combo("##after", &knifeAfter_, "Just add the edges\0Pieces of the one object\0Each piece its own object\0Delete the smaller piece\0"); + if (knifeAfter_ > 0) { + ImGui::TextDisabled("Inside, where it's cut"); + int fill = knifeFill_ ? 0 : 1; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##fill", &fill, "Filled (solid)\0Left open (hollow)\0")) knifeFill_ = fill == 0; + } + } + ImGui::TextWrapped("Click points along the cut, or drag a straight line. Ctrl: at 45 degree steps. Enter or double-click cuts, Esc lets go. " + "Each face it crosses is cut from edge to edge."); + if (knifePts_.size() >= 2 && ImGui::Button("Cut (Enter)", ImVec2(w, 0))) { + std::vector pts = knifePts_; + knifePts_.clear(); + knifeApply(active_, pts); + } + break; + } + case Tool::Hole: { + ImGui::SetNextItemWidth(w); + ImGui::Combo("##shape", &holeShape_, "Round\0Rectangle\0Triangle\0Hexagon (for nuts)\0"); + const HoleShape shape = HoleShape(holeShape_); + // Ready-made sizes for 3D prints: screws go through round holes, nuts sit in hexagons + // (a little room added, as prints come out a touch tight). + struct Fit { + const char* name; + float across, deep; // mm + }; + static const Fit screws[] = {{"M2 screw (2.4 mm)", 2.4f, 0}, {"M2.5 screw (2.9 mm)", 2.9f, 0}, {"M3 screw (3.4 mm)", 3.4f, 0}, {"M4 screw (4.5 mm)", 4.5f, 0}, + {"M5 screw (5.5 mm)", 5.5f, 0}, {"M6 screw (6.6 mm)", 6.6f, 0}, {"M8 screw (9 mm)", 9.0f, 0}}; + static const Fit nuts[] = {{"M2 nut (4 mm, 1.6 thick)", 4.0f, 1.6f}, {"M2.5 nut (5 mm, 2 thick)", 5.0f, 2.0f}, {"M3 nut (5.5 mm, 2.4 thick)", 5.5f, 2.4f}, + {"M4 nut (7 mm, 3.2 thick)", 7.0f, 3.2f}, {"M5 nut (8 mm, 4 thick)", 8.0f, 4.0f}, {"M6 nut (10 mm, 5 thick)", 10.0f, 5.0f}, + {"M8 nut (13 mm, 6.5 thick)", 13.0f, 6.5f}}; + if (shape == HoleShape::Round || shape == HoleShape::Hexagon) { + const bool nut = shape == HoleShape::Hexagon; + ImGui::SetNextItemWidth(w); + if (ImGui::BeginCombo("##fit", nut ? "Size for a nut..." : "Size for a screw...")) { + for (const Fit& f : nut ? std::span(nuts) : std::span(screws)) + if (ImGui::Selectable(f.name)) { + holeSize_ = (f.across + (nut ? holeRoom_ * 1000 : 0)) / 1000; + if (nut) holeMode_ = 1, holeDepth_ = (f.deep + 0.2f) / 1000; + else holeMode_ = 0; + } + ImGui::EndCombo(); + } + if (nut) { + float room = holeRoom_ * 1000; + ImGui::SetNextItemWidth(w); + if (ImGui::SliderFloat("##room", &room, 0.0f, 0.8f, "+ %.2f mm room")) holeRoom_ = room / 1000; + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Added to a nut's size so it pushes in (0.2 to 0.4 mm suits most printers)"); + } + } + if (shape == HoleShape::Round) { + ImGui::SetNextItemWidth(w); + ImGui::SliderInt("##sides", &holeSides_, 6, 96, "%d sides round"); + } + const char* acrossName = shape == HoleShape::Round ? "%.2f mm across" : shape == HoleShape::Rectangle ? "%.2f mm wide" : shape == HoleShape::Triangle ? "%.2f mm sides" : "%.2f mm across flats"; + float across = holeSize_ * 1000, tall = holeTall_ * 1000; + ImGui::SetNextItemWidth(w); + if (ImGui::DragFloat("##size", &across, 0.1f, 0.1f, 100000.0f, acrossName)) holeSize_ = across / 1000; + if (shape == HoleShape::Rectangle) { + ImGui::SetNextItemWidth(w); + if (ImGui::DragFloat("##tall", &tall, 0.1f, 0.1f, 100000.0f, "%.2f mm high")) holeTall_ = tall / 1000; + } + if (shape != HoleShape::Round) { + ImGui::SetNextItemWidth(w); + ImGui::SliderFloat("##turn", &holeTurn_, -180.0f, 180.0f, "turned %.0f degrees"); + } + ImGui::TextDisabled("Cut"); + ImGui::SetNextItemWidth(w); + ImGui::Combo("##mode", &holeMode_, "All the way through\0A pocket\0Only the outline (a new face)\0"); + if (holeMode_ == 1) { + float deep = holeDepth_ * 1000; + ImGui::SetNextItemWidth(w); + if (ImGui::DragFloat("##depth", &deep, 0.1f, 0.1f, 100000.0f, "%.2f mm deep")) holeDepth_ = deep / 1000; + } + ImGui::TextWrapped(holeMode_ == 2 ? "Press on a face and drag out the size, or just click. Nothing's taken away: the outline is cut into the face, " + "and the new face inside it is picked, ready to Extrude, Bevel or paint." + : "Press on a face and drag out the size, or just click (the size above). It has to fit inside the face%s. " + "Round edges in Properties rounds its rim too.", + holeMode_ == 0 ? ", and the face behind" : ""); + break; + } + } + ImGui::End(); +} + +auto App::layersPanel() -> void { + ImGui::SetNextWindowSizeConstraints(panelMin("Layers"), ImVec2(FLT_MAX, FLT_MAX)); + ImGui::Begin("Layers", nullptr, ImGuiWindowFlags_NoCollapse); + // Along the top: a new folder, the picked ones into one. + if (ImGui::SmallButton("+ Folder")) { + pushUndo(); + Folder f; + f.id = scene_.nextId++, f.name = "Folder"; + scene_.folders.push_back(f); + renamingFolder_ = f.id, std::snprintf(renameBuf_, sizeof renameBuf_, "%s", f.name.c_str()); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("A new folder: drag things onto it"); + ImGui::SameLine(); + ImGui::BeginDisabled(selected_.empty()); + if (ImGui::SmallButton("Group (Ctrl+G)")) groupSelected(); + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("The picked objects into a new folder: picked and moved together"); + if (ImGui::BeginTable("objects", 4, ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { + ImGui::TableSetupColumn("eye", ImGuiTableColumnFlags_WidthFixed, 22); + ImGui::TableSetupColumn("lock", ImGuiTableColumnFlags_WidthFixed, 22); + ImGui::TableSetupColumn("name"); + ImGui::TableSetupColumn("go", ImGuiTableColumnFlags_WidthFixed, 22); + layerRows(0, 0); + for (Reference& ref : scene_.references) { + ImGui::PushID(int(ref.id)); + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + if (rowToggle("##eye", ref.visible ? Icon::Eye : Icon::EyeShut, ref.visible, ref.visible ? "Shown (click to hide)" : "Hidden (click to show)")) ref.visible = !ref.visible; + ImGui::TableNextColumn(); + ImGui::TableNextColumn(); + const std::string label = "Picture: " + stdfs::path(ref.path).filename().string(); + if (ImGui::Selectable(label.c_str(), activeRef_ == ref.id)) activeRef_ = ref.id, selected_.clear(), leaveEdit(); + ImGui::TableNextColumn(); + ImGui::PopID(); + } + ImGui::EndTable(); + } + // (dropped below the list: out of its folder, to the top) + ImGui::InvisibleButton("##top", ImVec2(std::max(ImGui::GetContentRegionAvail().x, 1.0f), std::max(ImGui::GetContentRegionAvail().y, 8.0f))); + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_OBJECT")) { + const uint32_t oid = *static_cast(p->Data); + moveToFolder(selected_.count(oid) ? selected_ : std::set{oid}, 0); + } + if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_FOLDER")) + if (Folder* g = findFolder(*static_cast(p->Data))) pushUndo(), g->parent = 0; + ImGui::EndDragDropTarget(); + } + ImGui::End(); +} + +auto App::paintProperties(float w) -> void { + Object* o = selected_.empty() ? nullptr : find(active_); + if (sculpting_) { // (its look, while you shape it: what it's made of shows how the light runs over it) + if (!o) return ImGui::TextWrapped("Brush over an object to sculpt it.\n\nA shape with few points is made finer first, keeping its shape. Tab goes back to painting colours."); + ImGui::TextColored(accentText(), "%s", o->name.c_str()); + ImGui::TextDisabled("%zu points, %zu faces", o->mesh.verts.size(), o->mesh.faces.size()); + ImGui::SeparatorText("Look"); + materialPicker(*o); + undoable([&] { return ImGui::ColorEdit3("Colour", &o->color.x); }); + if (ImGui::Checkbox("Smooth shading", &o->smooth)) pushUndo(), touched(o->id); + return; + } + if (!o) { + ImGui::TextWrapped("Click an object to paint on it.\n\nIts surface gets laid out flat on a picture (the Picture panel), which you can paint on too, " + "or put pictures onto: copy one in any app and press Ctrl+V."); + return; + } + ImGui::TextColored(accentText(), "%s", o->name.c_str()); + if (!o->picture) { + int size = pictureSize_ <= 512 ? 0 : pictureSize_ <= 1024 ? 1 : pictureSize_ <= 2048 ? 2 : 3; + ImGui::SetNextItemWidth(w); + if (ImGui::Combo("##psize", &size, "512 x 512\0001024 x 1024\0002048 x 2048\0004096 x 4096\0")) pictureSize_ = 512 << size; + if (ImGui::Button("Start painting it", ImVec2(w, 0))) readyToPaint(o->id); + ImGui::TextDisabled("(or just paint on it)"); + return; + } + ImGui::TextDisabled("Picture %d x %d", o->picture->w, o->picture->h); + if (o->hasFinish()) ImGui::TextWrapped("It's had rounding or smoothing added since: the picture shows again when you paint (they become part of the shape)."); + ImGui::Checkbox("Show the layout lines", &showUvLines_); + // The pictures put on it, top first: click one to move it (again), the eye hides it. + ImGui::SeparatorText("Pictures on it (layers)"); + if (o->layers.empty()) ImGui::TextDisabled("None yet: paste one (Ctrl+V)"); + int move = -1, by = 0, drop = -1, merge = -1; + for (int i = int(o->layers.size()) - 1; i >= 0; --i) { + PictureLayer& l = o->layers[size_t(i)]; + const bool on = ledit_.object == o->id && ledit_.index == i; + ImGui::PushID(i); + if (rowToggle("##eye", l.visible ? Icon::Eye : Icon::EyeShut, l.visible, l.visible ? "Hide it" : "Show it")) pushUndo(), l.visible = !l.visible; + ImGui::SameLine(); + if (ImGui::Selectable(std::format("{}{}", l.name, on ? " (moving)" : l.sticker ? "" : " (flat)").c_str(), on, 0, ImVec2(w - 92, 0))) + on ? lockLayer() : unlockLayer(o->id, i); + ImGui::SameLine(w - 62); + ImGui::BeginDisabled(i == int(o->layers.size()) - 1); + if (ImGui::ArrowButton("up", ImGuiDir_Up)) move = i, by = 1; + ImGui::EndDisabled(); + ImGui::SameLine(0, 2); + ImGui::BeginDisabled(i == 0); + if (ImGui::ArrowButton("down", ImGuiDir_Down)) move = i, by = -1; + ImGui::EndDisabled(); + ImGui::SameLine(0, 2); + if (ImGui::SmallButton("x")) drop = i; + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Delete it"); + if (ImGui::BeginPopupContextItem("menu")) { + ImGui::SetNextItemWidth(180); + undoable([&] { return ImGui::SliderFloat("##op", &l.opacity, 0.0f, 1.0f, "see-through %.2f"); }); + if (ImGui::MenuItem("Paint it in for good")) merge = i; + if (ImGui::MenuItem("Delete")) drop = i; + ImGui::EndPopup(); + } + ImGui::PopID(); + } + if (move >= 0) pushUndo(), std::swap(o->layers[size_t(move)], o->layers[size_t(move + by)]), ledit_ = {}; + if (drop >= 0) deleteLayer(*o, drop); + if (merge >= 0) mergeLayer(*o, merge); + ImGui::Spacing(); + if (ImGui::Button("Fill it all with the colour", ImVec2(w, 0))) { + Image& img = changePicture(*o); + img = makeImage(img.w, img.h, glm::vec4(glm::vec3(paintColor_), 1.0f)); + paint_.image.reset(); + } + if (ImGui::Button("Save the picture as PNG...", ImVec2(w, 0))) dialog(7); + if (ImGui::Button("Lay it out again", ImVec2(w, 0))) { + pushUndo(); + o->uvs = autoUnwrap(o->mesh); + touched(o->id); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Lays its surface out flat on the picture again (what's painted won't fit it any more)"); + if (ImGui::Button("Remove the picture", ImVec2(w, 0))) { + pushUndo(); + o->picture.reset(), o->uvs.clear(); + touched(o->id); + } + ImGui::Spacing(); + ImGui::TextWrapped("Tip: paint last. Changing the shape's faces (extrude, knife...) lays it out again, and what's painted moves."); +} + +auto App::renderProperties(float w) -> void { + Look& k = scene_.look; + auto slider = [&](const char* label, float* v, float lo, float hi, const char* fmt) { + if (undoable([&] { return ImGui::SliderFloat(label, v, lo, hi, fmt); })) dirty_ = true; + }; + ImGui::SeparatorText("Sun"); + slider("Round", &k.sunTurn, 0, 360, "%.0f deg"); + slider("Up", &k.sunHeight, 2, 90, "%.0f deg"); + slider("Strength", &k.sunStrength, 0, 2, "%.2f"); + undoable([&] { return ImGui::ColorEdit3("Sun colour", &k.sunColor.x); }); + ImGui::SeparatorText("Round it"); + if (ImGui::Checkbox("Sky", &k.sky)) dirty_ = true; + if (!k.sky) undoable([&] { return ImGui::ColorEdit3("Background", &k.background.x); }); + if (ImGui::Checkbox("Ground", &k.ground)) dirty_ = true; + if (k.ground) undoable([&] { return ImGui::ColorEdit3("Ground colour", &k.groundColor.x); }); + ImGui::Checkbox("Grid", &renderGrid_); + ImGui::SeparatorText("Look"); + slider("Brightness", &k.exposure, 0.2f, 3.0f, "%.2f"); + slider("Shade in corners", &k.ao, 0, 1, "%.2f"); + slider("Soft shadows", &k.shadowSoft, 0, 4, "%.2f"); + slider("Glow", &k.bloom, 0, 0.3f, "%.3f"); + slider("Colour", &k.saturation, 0, 2, "%.2f"); + slider("Dark edges", &k.vignette, 0, 1, "%.2f"); + slider("Sharpen", &k.sharpen, 0, 1, "%.2f"); + if (ImGui::Button("Back to how it was", ImVec2(w, 0))) pushUndo(), k = Look{}; + if (ImGui::Button("Save a picture (F12)", ImVec2(w, 0))) dialog(5); + if (Object* o = selected_.size() == 1 ? find(active_) : nullptr) { + ImGui::SeparatorText(o->name.c_str()); + materialPicker(*o); + undoable([&] { return ImGui::ColorEdit3("Colour", &o->color.x); }); + undoable([&] { return ImGui::SliderFloat("Metal", &o->metal, 0, 1); }); + undoable([&] { return ImGui::SliderFloat("Rough", &o->rough, 0, 1); }); + ImGui::TextDisabled("(metal and rough go with it into games)"); + } +} + +// The Paint tab's picture: the selected object's, flat, to paint on and place pictures on. +auto App::picturePanel() -> void { + ImGui::SetNextWindowSizeConstraints(panelMin("Picture"), ImVec2(FLT_MAX, FLT_MAX)); + ImGui::Begin("Picture", nullptr, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + Object* o = selected_.empty() ? nullptr : find(active_); + if (!o || !o->picture) { + if (tab_ == Tab::Layout) { + ImGui::TextWrapped(o ? "It has no picture yet, so nothing's laid out." : "Click an object: how its picture is laid out shows here."); + if (o && ImGui::Button("Lay it out (and start its picture)")) readyToPaint(o->id); + return ImGui::End(); + } + ImGui::TextWrapped(sculpting_ ? "Sculpting changes its shape, not its picture. Tab goes back to painting colours." + : o ? "Paint on it (or press Start painting it in Properties) and its picture shows here." : "Click an object to paint it: its picture shows here."); + return ImGui::End(); + } + uploadPicture(*o, !paint_.active); + const int W = o->picture->w, H = o->picture->h; + const ImVec2 origin = ImGui::GetCursorScreenPos(), avail = ImGui::GetContentRegionAvail(); + if (avail.x < 16 || avail.y < 16) return ImGui::End(); + if (picZoom_ <= 0 || picFor_ != o->id) { + picZoom_ = std::min(avail.x / float(W), avail.y / float(H)) * 0.96f; + picPan_ = ImVec2((avail.x - float(W) * picZoom_) * 0.5f, (avail.y - float(H) * picZoom_) * 0.5f), picFor_ = o->id; + } + ImGui::InvisibleButton("canvas", avail, ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonMiddle | ImGuiButtonFlags_MouseButtonRight); + const bool hovered = ImGui::IsItemHovered(); + ImGuiIO& io = ImGui::GetIO(); + auto toScreen = [&](glm::vec2 p) { return ImVec2(origin.x + picPan_.x + p.x * picZoom_, origin.y + picPan_.y + p.y * picZoom_); }; + const glm::vec2 mouse((io.MousePos.x - origin.x - picPan_.x) / picZoom_, (io.MousePos.y - origin.y - picPan_.y) / picZoom_); + if (hovered && io.MouseWheel != 0) { // zoom about the mouse + const float z = std::clamp(picZoom_ * std::pow(1.2f, io.MouseWheel), 0.05f, 64.0f); + picPan_ = ImVec2(io.MousePos.x - origin.x - mouse.x * z, io.MousePos.y - origin.y - mouse.y * z), picZoom_ = z; + } + if (ImGui::IsItemActive() && (ImGui::IsMouseDown(ImGuiMouseButton_Middle) || ImGui::IsMouseDown(ImGuiMouseButton_Right))) + picPan_.x += io.MouseDelta.x, picPan_.y += io.MouseDelta.y; + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->PushClipRect(origin, ImVec2(origin.x + avail.x, origin.y + avail.y), true); + dl->AddRectFilled(origin, ImVec2(origin.x + avail.x, origin.y + avail.y), IM_COL32(24, 24, 28, 255)); + uiImage(dl, paintTex_[o->id], toScreen({0, 0}), toScreen({float(W), float(H)})); + dl->AddRect(toScreen({0, 0}), toScreen({float(W), float(H)}), IM_COL32(0, 0, 0, 255)); + if (tab_ == Tab::Layout) { + if (o->uvsFit()) layoutCanvas(*o, dl, toScreen, mouse, hovered, W, H); + dl->PopClipRect(); + return ImGui::End(); + } + if (showUvLines_ && o->uvsFit()) + for (const auto& face : o->uvs) { + std::vector pts; + for (const glm::vec2& q : face) pts.push_back(toScreen(q * glm::vec2(W, H))); + dl->AddPolyline(pts.data(), int(pts.size()), IM_COL32(255, 255, 255, 60), ImDrawFlags_Closed, 1.0f); + } + PictureLayer* el = ledit_.object == o->id ? editLayer() : nullptr; + if (el && !el->sticker) { + ImVec2 q[4]; + for (int i = 0; i < 4; ++i) q[i] = toScreen(el->corners[size_t(i)]); + if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + ledit_.drag = -1; + for (int i = 0; i < 4; ++i) + if (std::abs(io.MousePos.x - q[i].x) < 10 && std::abs(io.MousePos.y - q[i].y) < 10) ledit_.drag = i; + if (ledit_.drag < 0 && insidePolygon(io.MousePos, {q[0], q[1], q[2], q[3]})) ledit_.drag = 4; + if (ledit_.drag >= 0) pushUndo(); + else lockLayer(); + ledit_.grab = mouse, ledit_.startCorners = el->corners; + } + if (ledit_.drag >= 0 && ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + const glm::vec2 d = mouse - ledit_.grab; + const int i = ledit_.drag; + if (i == 4) + for (int k = 0; k < 4; ++k) el->corners[size_t(k)] = ledit_.startCorners[size_t(k)] + d; + else if (io.KeyShift) { // bigger or smaller, the opposite corner staying + const glm::vec2 opp = ledit_.startCorners[size_t((i + 2) % 4)]; + const float k = glm::length(mouse - opp) / std::max(glm::length(ledit_.startCorners[size_t(i)] - opp), 1e-3f); + for (int j = 0; j < 4; ++j) el->corners[size_t(j)] = opp + (ledit_.startCorners[size_t(j)] - opp) * k; + } else + el->corners[size_t(i)] = ledit_.startCorners[size_t(i)] + d; + } + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) ledit_.drag = -1; + for (int i = 0; i < 4; ++i) q[i] = toScreen(el->corners[size_t(i)]); + dl->AddPolyline(q, 4, kAccent, ImDrawFlags_Closed, 2.0f); + for (int i = 0; i < 4; ++i) { + const bool hot = ledit_.drag == i || (std::abs(io.MousePos.x - q[i].x) < 10 && std::abs(io.MousePos.y - q[i].y) < 10); + dl->AddRectFilled(ImVec2(q[i].x - 6, q[i].y - 6), ImVec2(q[i].x + 6, q[i].y + 6), hot ? kHot : IM_COL32(255, 255, 255, 240)); + dl->AddRect(ImVec2(q[i].x - 6, q[i].y - 6), ImVec2(q[i].x + 6, q[i].y + 6), IM_COL32(20, 20, 25, 255)); + } + } else { + const bool onPicture = mouse.x >= 0 && mouse.y >= 0 && mouse.x < float(W) && mouse.y < float(H); + const glm::vec2 uv = mouse / glm::vec2(W, H); + // Which layer is on top under the mouse (0: the painting). + int under = 0; + if (const auto it = owner_.find(o->id); onPicture && it != owner_.end() && it->second.size() == size_t(W) * size_t(H)) + under = it->second[size_t(int(mouse.y)) * size_t(W) + size_t(int(mouse.x))]; + if (hovered && under && !paint_.active) ImGui::SetTooltip("%s: click to move it", o->layers[size_t(under - 1)].name.c_str()); + if (hovered && onPicture && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && (under || el)) { + if (under) unlockLayer(o->id, under - 1); // (clicking a placed picture here unlocks it) + else lockLayer(); + } else if (hovered && onPicture && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + if (paintTool_ == PaintTool::Picker || io.KeyAlt) pickColor(*o, uv); + else if (paintTool_ == PaintTool::Fill) fillAt(*o, uv); + else if (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Eraser) { + beginStroke(*o, true); + paint_.stroke.dab(*paint_.image, mouse, brushPx_ / picZoom_, hardness_); + paint_.last2D = mouse, paint_.haveLast = true; + } + } + if (paint_.active && paint_.in2D) { + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) endStroke(); + else { + const float r = brushPx_ / picZoom_, gap = glm::distance(mouse, paint_.last2D); + const int steps = std::max(1, int(std::ceil(gap / std::max(r * 0.3f, 0.5f)))); + for (int i = 1; i <= steps; ++i) paint_.stroke.dab(*paint_.image, glm::mix(paint_.last2D, mouse, float(i) / float(steps)), r, hardness_); + paint_.last2D = mouse; + uploaded_[o->id].dirty = true; + } + } + if (hovered && (paintTool_ == PaintTool::Brush || paintTool_ == PaintTool::Eraser)) dl->AddCircle(io.MousePos, brushPx_, IM_COL32(255, 255, 255, 200), 40, 1.5f); + } + dl->PopClipRect(); + ImGui::End(); +} + +auto App::propertiesPanel() -> void { + ImGui::SetNextWindowSizeConstraints(panelMin("Properties"), ImVec2(FLT_MAX, FLT_MAX)); + ImGui::Begin("Properties", nullptr, ImGuiWindowFlags_NoCollapse); + const float w = ImGui::GetContentRegionAvail().x; + if (tab_ == Tab::Paint) { + paintProperties(w); + return ImGui::End(); + } + if (tab_ == Tab::Render) { + renderProperties(w); + return ImGui::End(); + } + if (tab_ == Tab::Layout) { + layoutProperties(w); + return ImGui::End(); + } + if (tab_ == Tab::Print) { + printProperties(w); + return ImGui::End(); + } + if (Reference* ref = activeRef_ && selected_.empty() ? findRef(activeRef_) : nullptr) { + ImGui::TextWrapped("%s", ref->path.c_str()); + int axis = int(ref->axis); + if (ImGui::Combo("Facing", &axis, "Front view\0Side view\0Top view\0")) pushUndo(), ref->axis = Axis(axis); + undoable([&] { return ImGui::DragFloat2("Middle (m)", &ref->offset.x, 0.005f); }); + undoable([&] { return ImGui::DragFloat("Width (m)", &ref->width, 0.005f, -100.0f, 100.0f, "%.3f"); }); + ImGui::TextDisabled("A minus width flips it."); + undoable([&] { return ImGui::SliderFloat("See-through", &ref->opacity, 0.05f, 1.0f); }); + if (ImGui::Checkbox("Only in its own view", &ref->ownViewOnly)) dirty_ = true; + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Shown only looking straight at it (front, side or top): out of the way in the 3D view and the others"); + if (ImGui::Button("Remove picture", ImVec2(w, 0))) deleteSelected(); + ImGui::End(); + return; + } + if (Object* o = editing()) { + editProperties(*o, w); + ImGui::End(); + return; + } + if (selected_.empty()) { + ImGui::TextWrapped("Nothing selected.\n\nClick an object to select it, or add one with + in the toolbox (or the Add menu): " + "shapes and starter models, with sizes you can change. Draw shape (D) turns an outline you click into a solid."); + ImGui::End(); + return; + } + if (selected_.size() > 1) { + ImGui::Text("%zu objects selected", selected_.size()); + glm::vec3 col = find(active_) ? find(active_)->color : glm::vec3(0.8f); + if (undoable([&] { return ImGui::ColorEdit3("Colour (all)", &col.x); })) + for (uint32_t id : selected_) + if (Object* o = find(id)) o->color = col, dirty_ = true; + if (ImGui::Button("Join into one (Ctrl+J)", ImVec2(w, 0))) joinSelected(); + ImGui::End(); + return; + } + Object* o = find(*selected_.begin()); + if (!o) { + ImGui::End(); + return; + } + ImGui::BeginDisabled(o->locked); + char name[128]; + std::snprintf(name, sizeof name, "%s", o->name.c_str()); + if (undoable([&] { return ImGui::InputText("Name", name, sizeof name); }) && name[0]) o->name = name; + // A library shape: its own sizes, until its shape's edited by hand. + if (const ShapeRecipe* r = findRecipe(o->recipe)) { + ImGui::SeparatorText(std::format("{} settings", r->name).c_str()); + bool changed = false; + o->settings.resize(r->settings.size()); + for (size_t i = 0; i < r->settings.size(); ++i) { + const ShapeSetting& s = r->settings[i]; + const float speed = s.whole ? 0.1f : std::max(0.001f, (s.max - s.min) * 0.0005f); + changed |= undoable([&] { return ImGui::DragFloat(s.name, &o->settings[i], speed, s.min, s.max, s.whole ? "%.0f" : "%.3f"); }); + } + if (changed) o->mesh = makeFromRecipe(*r, o->settings), touched(o->id); + ImGui::TextDisabled("(These go once you edit the shape by hand.)"); + } + ImGui::SeparatorText("Place"); + undoable([&] { return ImGui::DragFloat3("Position (m)", &o->position.x, 0.005f, 0, 0, "%.3f"); }); + glm::vec3 euler = toEuler(o->rotation); + if (undoable([&] { return ImGui::DragFloat3("Turn (deg)", &euler.x, 0.5f, 0, 0, "%.1f"); })) o->rotation = fromEuler(euler); + if (undoable([&] { return ImGui::DragFloat3("Size", &o->scale.x, 0.005f, 0, 0, "%.3f"); })) touched(o->id); + const glm::vec3 lo = boundsMin(shownMesh(*o)) * glm::abs(o->scale), hi = boundsMax(shownMesh(*o)) * glm::abs(o->scale); + // Its size itself, typed (the scale worked out from it): 0.25 makes it 25 cm, whatever it was. + glm::vec3 dims = hi - lo; + const glm::vec3 was = dims; + if (undoable([&] { return ImGui::DragFloat3("Dimensions (m)", &dims.x, 0.002f, 0.0001f, 1e5f, "%.4g"); })) { + int k = -1; + for (int i = 0; i < 3; ++i) + if (std::abs(dims[i] - was[i]) > 1e-9f) k = i; + if (k >= 0 && was[k] > 1e-7f && dims[k] > 1e-7f) { + const float f = dims[k] / was[k]; + if (keepProportions_) o->scale *= f; + else o->scale[k] *= f; + touched(o->id); + } + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Across, deep, high: type a size and it's made that big"); + ImGui::Checkbox("Keep its proportions", &keepProportions_); + ImGui::SeparatorText("Look"); + materialPicker(*o); + undoable([&] { return ImGui::ColorEdit3("Colour", &o->color.x); }); + undoable([&] { return ImGui::SliderFloat("Metal", &o->metal, 0, 1); }); + undoable([&] { return ImGui::SliderFloat("Rough", &o->rough, 0, 1); }); + if (ImGui::Checkbox("Smooth shading", &o->smooth)) pushUndo(), touched(o->id); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Round surfaces look round; edges sharper than the angle stay sharp"); + if (o->smooth) { + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1); + if (undoable([&] { return ImGui::SliderFloat("##angle", &o->smoothAngle, 1, 180, "creases > %.0f deg"); })) touched(o->id); + } + // Finishing touches that stay changeable: rounded edges, smoothing, a mirrored half. + ImGui::SeparatorText("Round, smooth, mirror"); + if (undoable([&] { return ImGui::DragFloat("Round edges", &o->round, 0.001f, 0.0f, 10.0f, o->round > 0 ? "%.3f m" : "off"); })) { + if (o->round > 0 && o->roundSteps > 1) o->smooth = true; + touched(o->id); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rounds off every sharp edge and corner (drag right)"); + if (o->round > 0) { + if (undoable([&] { return ImGui::SliderInt("Steps round", &o->roundSteps, 1, 8); })) touched(o->id); + if (undoable([&] { return ImGui::SliderFloat("Edges sharper than", &o->roundAngle, 1, 179, "%.0f deg"); })) touched(o->id); + } + if (undoable([&] { return ImGui::SliderInt("Smooth out", &o->smoothLevels, 0, 3, o->smoothLevels ? "%d times" : "off"); })) { + if (o->smoothLevels) o->smooth = true; + touched(o->id); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Cuts every face into smaller ones and rounds the whole shape (like clay)"); + if (o->smoothLevels && ImGui::Checkbox("Keep sharp edges sharp", &o->smoothKeepSharp)) pushUndo(), touched(o->id); + ImGui::TextDisabled("Mirror across"); + for (int a = 0; a < 3; ++a) { + ImGui::SameLine(); + bool on = o->mirror & (1 << a); + if (ImGui::Checkbox(a == 0 ? "X" : a == 1 ? "Y" : "Z", &on)) pushUndo(), o->mirror ^= 1 << a, touched(o->id); + } + if (o->hasFinish() && ImGui::Button("Make it part of the shape", ImVec2(w, 0))) applyFinish(*o); + if (o->hasFinish() && ImGui::IsItemHovered()) ImGui::SetTooltip("Bake the rounding, smoothing and mirror into the points (so you can edit them)"); + if (const Object* t = fry::cutTarget(scene_, *o)) { // (an editable cutter) + ImGui::SeparatorText("Cut"); + ImGui::TextColored(accentText(), "Cuts into %s", t->name.c_str()); + ImGui::TextDisabled("Move it, turn it, change its shape: the hole follows."); + const uint32_t target = t->id, me = o->id; + if (ImGui::Button("Apply: make the cut for good", ImVec2(w, 0))) return applyCuts(target, me); + if (ImGui::Button("Stop cutting (keep it as an object)", ImVec2(w, 0))) pushUndo(), o->cuts = 0, dirty_ = true; + } else if (cutKeys_.count(o->id)) { + int n = 0; + for (const Object& c : scene_.objects) n += c.cuts == o->id && isCutter(c); + ImGui::SeparatorText("Cut"); + ImGui::TextDisabled("%d editable cut%s in it", n, n == 1 ? "" : "s"); + if (ImGui::Button(n == 1 ? "Apply: make the cut for good" : "Apply: make them all for good", ImVec2(w, 0))) return applyCuts(o->id); + if (ImGui::Button("Pick the cutters", ImVec2(w, 0))) { + selected_.clear(); + for (const Object& c : scene_.objects) + if (c.cuts == o->id) selected_.insert(c.id), active_ = c.id; + } + } + ImGui::SeparatorText("Do"); + if (ImGui::Button("Edit shape (Tab)", ImVec2(w, 0))) enterEdit(); + if (ImGui::Button("Apply turn and size", ImVec2(w, 0))) applyTransform(*o); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Bake them into the shape: turn becomes 0, size 1"); + if (ImGui::Button("Origin to middle", ImVec2((w - 6) / 2, 0))) originToMiddle(*o); + ImGui::SameLine(0, 6); + if (ImGui::Button("Origin to bottom", ImVec2(-1, 0))) originToBottom(*o); + if (ImGui::Button("Flip X", ImVec2((w - 12) / 3, 0))) mirror(*o, 0); + ImGui::SameLine(0, 6); + if (ImGui::Button("Flip Y", ImVec2((w - 12) / 3, 0))) mirror(*o, 1); + ImGui::SameLine(0, 6); + if (ImGui::Button("Flip Z", ImVec2(-1, 0))) mirror(*o, 2); + ImGui::EndDisabled(); + if (ImGui::Button(o->locked ? "Unlock" : "Lock", ImVec2((w - 6) / 2, 0))) o->locked = !o->locked; + ImGui::SameLine(0, 6); + if (ImGui::Button("Delete", ImVec2(-1, 0))) deleteSelected(); + ImGui::End(); +} + +// A material to start from: sets colour, metal and rough in one go (each a little ball of it). +// Yours (saved here, or from plug-ins and scripts) first; "Save its look as a material" adds one. +auto App::materialPicker(Object& o) -> void { + auto matches = [&](const MaterialPreset& m) { + return std::abs(m.metal - o.metal) < 0.01f && std::abs(m.rough - o.rough) < 0.01f && + (m.keepColor || glm::all(glm::lessThan(glm::abs(m.color - o.color), glm::vec3(0.01f)))); + }; + const MaterialPreset* now = nullptr; + for (const MaterialPreset& m : materialPresets()) + if (matches(m)) now = &m; + for (const MaterialPreset& m : ownMaterials()) + if (matches(m)) now = &m; + ImGui::SetNextItemWidth(ImGui::CalcItemWidth()); + if (ImGui::BeginCombo("Material", now ? now->name.c_str() : "(its own)", ImGuiComboFlags_HeightLarge)) { + std::optional remove; + auto item = [&](const MaterialPreset& m, bool own) { + const ImVec2 p = ImGui::GetCursorScreenPos(); + const float h = ImGui::GetTextLineHeight(); + ImGui::PushID(m.id.c_str()); + ImGui::PushID(own); + if (ImGui::Selectable("##m", now == &m, 0, ImVec2(0, h))) pushUndo(), applyMaterial(o, m), touched(o.id); + if (own && m.from.empty() && ImGui::BeginPopupContextItem()) { + if (ImGui::MenuItem("Remove it")) remove = m.id; + ImGui::EndPopup(); + } + if (own && ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", m.from.empty() ? "Yours (right-click to remove)" : ("From " + m.from).c_str()); + ImGui::PopID(); + ImGui::PopID(); + // A ball: its colour, darker round the edge, a highlight as sharp as it's smooth. + const glm::vec3 c = m.keepColor ? o.color : m.color; + const ImVec2 mid(p.x + h * 0.5f, p.y + h * 0.5f); + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddCircleFilled(mid, h * 0.45f, ImGui::ColorConvertFloat4ToU32(ImVec4(c.r * 0.55f, c.g * 0.55f, c.b * 0.55f, 1))); + dl->AddCircleFilled(ImVec2(mid.x - h * 0.05f, mid.y - h * 0.05f), h * 0.36f, ImGui::ColorConvertFloat4ToU32(ImVec4(c.r, c.g, c.b, 1))); + dl->AddCircleFilled(ImVec2(mid.x - h * 0.14f, mid.y - h * 0.14f), h * (0.06f + 0.1f * m.rough), IM_COL32(255, 255, 255, int(230 * (1.0f - m.rough * 0.8f)))); + dl->AddText(ImVec2(p.x + h * 1.4f, p.y), ImGui::GetColorU32(ImGuiCol_Text), m.name.c_str()); + }; + if (!ownMaterials().empty()) { + ImGui::SeparatorText("Yours"); + for (const MaterialPreset& m : ownMaterials()) item(m, true); + ImGui::SeparatorText("Fry3DX's"); + } + for (const MaterialPreset& m : materialPresets()) item(m, false); + ImGui::Separator(); + if (ImGui::Selectable("Save its look as a material...")) nameMaterial_ = true, materialName_.clear(), materialKeepColor_ = false; + ImGui::EndCombo(); + if (remove && removeOwnMaterial(*remove)) saveOwnMaterials(); + } + if (std::exchange(nameMaterial_, false)) ImGui::OpenPopup("Save as a material"); + ImGui::SetNextWindowSize(ImVec2(340, 0)); + if (ImGui::BeginPopupModal("Save as a material", nullptr, ImGuiWindowFlags_NoResize)) { + ImGui::TextWrapped("Its colour, metal and rough as one of your materials, in the picker for every file."); + if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); + ImGui::SetNextItemWidth(-FLT_MIN); + inputString("##name", materialName_, "Its name (\"Teal plastic\")"); + ImGui::Checkbox("Only how metal and rough (keep each object's colour)", &materialKeepColor_); + const bool taken = std::any_of(materialPresets().begin(), materialPresets().end(), [&](const MaterialPreset& m) { return m.id == materialId(materialName_); }); + if (taken) ImGui::TextColored(roleColor(keel::ui::Role::DangerText), "That's one of Fry3DX's own: another name"); + ImGui::BeginDisabled(materialId(materialName_).empty() || taken); + pushAccent(); + const bool save = ImGui::Button("Save", ImVec2(120, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter, false); + popAccent(); + ImGui::EndDisabled(); + ImGui::SameLine(); + if (save && !materialId(materialName_).empty() && !taken) { + addOwnMaterial({materialId(materialName_), materialName_, o.color, o.metal, o.rough, materialKeepColor_, ""}); + saveOwnMaterials(); + say("Saved \"" + materialName_ + "\": it's in the Material picker now"); + ImGui::CloseCurrentPopup(); + } + if (ImGui::Button("Cancel", ImVec2(120, 0))) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } +} + +auto App::editProperties(Object& o, float w) -> void { + ImGui::Text("Editing the shape of %s", o.name.c_str()); + ImGui::TextDisabled("%zu points, %zu faces", o.mesh.verts.size(), o.mesh.faces.size()); + if (o.hasFinish()) ImGui::TextDisabled("(its rounding and smoothing show again when you're done)"); + const std::set pts = selectedPoints(); + if (!pts.empty()) { + // Where the selection's middle is (in the world); typing moves it there. + const glm::vec3 c = pivotOfSelection(); + glm::vec3 at = c; + if (undoable([&] { return ImGui::DragFloat3("Middle (m)", &at.x, 0.002f, 0, 0, "%.3f"); })) { + const glm::mat4 m = objectMatrix(o), local = glm::inverse(m) * glm::translate(glm::mat4(1.0f), at - c) * m; + for (int v : pts) o.mesh.verts[size_t(v)] = glm::vec3(local * glm::vec4(o.mesh.verts[size_t(v)], 1.0f)); + handMade(o); + touched(o.id); + } + } + ImGui::SeparatorText("Faces"); + ImGui::SetNextItemWidth(w * 0.45f); + ImGui::DragFloat("##ext", &extrudeAmount_, 0.001f, -10, 10, "%.3f m"); + ImGui::SameLine(); + if (ImGui::Button("Extrude", ImVec2(-1, 0))) extrudeNow(extrudeAmount_); + ImGui::SetNextItemWidth(w * 0.45f); + ImGui::SliderFloat("##ins", &insetAmount_, 0.01f, 0.99f, "%.2f"); + ImGui::SameLine(); + if (ImGui::Button("Inset", ImVec2(-1, 0))) insetNow(insetAmount_); + if (ImGui::Button("Flip", ImVec2((w - 6) / 2, 0))) flipNow(); + ImGui::SameLine(0, 6); + if (ImGui::Button("Make face", ImVec2(-1, 0))) makeFaceNow(); + if (ImGui::Button("Bridge two faces", ImVec2(w, 0))) bridgeNow(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Two faces with as many corners: taken out and joined by a tunnel"); + if (ImGui::Button("Make into its own object (P)", ImVec2(w, 0))) separateSelected(); + ImGui::SeparatorText("Edges"); + ImGui::SetNextItemWidth(w * 0.45f); + ImGui::DragFloat("##bw", &bevelWidth_, 0.001f, 0.0f, 10.0f, "%.3f m"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(w * 0.2f); + ImGui::DragInt("##bs", &bevelSteps_, 0.1f, 1, 10); + ImGui::SameLine(); + if (ImGui::Button("Round", ImVec2(-1, 0))) bevelNow(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Round the selected edges (or the edges round the selected faces): width, steps"); + ImGui::SeparatorText("Points"); + if (ImGui::Button("Merge to middle (M)", ImVec2(w, 0))) mergeNow(); + if (ImGui::Button("Smooth", ImVec2((w - 6) / 2, 0))) smoothPointsNow(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Even out bumps: each point towards its neighbours"); + ImGui::SameLine(0, 6); + if (ImGui::Button("Push / pull (Alt+S)", ImVec2(-1, 0))) startPush(); + ImGui::SetNextItemWidth(w * 0.45f); + ImGui::DragFloat("##md", &mergeDistance_, 0.0001f, 0.0f, 1.0f, "%.4f m"); + ImGui::SameLine(); + if (ImGui::Button("Merge close", ImVec2(-1, 0))) { + pushUndo(); + const int gone = mergeByDistance(o.mesh, mergeDistance_); + changedShape(o); + say(std::format("{} points merged", gone)); + } + ImGui::SeparatorText("Whole shape"); + if (ImGui::Button("Cut in four", ImVec2((w - 6) / 2, 0))) subdivideNow(false); + ImGui::SameLine(0, 6); + if (ImGui::Button("Smooth it", ImVec2(-1, 0))) subdivideNow(true); + ImGui::SeparatorText("Select"); + if (ImGui::Button("All", ImVec2((w - 12) / 3, 0))) selectAllElements(); + ImGui::SameLine(0, 6); + if (ImGui::Button("None", ImVec2((w - 12) / 3, 0))) clearElements(); + ImGui::SameLine(0, 6); + if (ImGui::Button("Invert", ImVec2(-1, 0))) invertSelection(); + if (ImGui::Button("More", ImVec2((w - 12) / 3, 0))) growSelection(); + ImGui::SameLine(0, 6); + if (ImGui::Button("Less", ImVec2((w - 12) / 3, 0))) shrinkSelection(); + ImGui::SameLine(0, 6); + if (ImGui::Button("Joined", ImVec2(-1, 0))) selectLinked(); + ImGui::Separator(); + if (ImGui::Button("Delete (Del)", ImVec2(w, 0))) deleteElements(); + if (ImGui::Button("Done (Tab)", ImVec2(w, 0))) leaveEdit(); +} + +auto App::statusBar() -> void { + ImGui::Begin("Status", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoScrollbar); + if (now() - messageAt_ < 4.0) ImGui::TextColored(accentText(), "%s", message_.c_str()); + else if (tab_ == Tab::Layout) { + for (const LayToolInfo& t : kLayTools) + if (t.tool == layTool_) + ImGui::TextDisabled("%s: %s · Tab: frames or corners Right drag: orbit (pan in the Picture panel) F1: all keys", t.name, t.hint); + } else if ((tab_ == Tab::Print && printTool_ != PrintTool::None) || printTool_ == PrintTool::Measure) { + ImGui::TextDisabled(printTool_ == PrintTool::Measure ? "Measure: click two points on the model (corners catch the mouse); a third click starts again · M (Print) or Esc: done" + : "Lay flat: click the face it should stand on: it turns so that face is down, on the bed · L: done"); + } else if (tab_ == Tab::Paint && sculpting_) { + for (const SculptToolInfo& t : kSculptTools) + if (t.brush == sculptBrush_) ImGui::TextDisabled("%s: %s · Shift: smooth X: both sides Tab: colour Right drag: orbit F1: all keys", t.name, t.hint); + } else if (tab_ == Tab::Paint) { + for (const PaintToolInfo& t : kPaintTools) + if (t.tool == paintTool_) ImGui::TextDisabled("%s: %s · Right drag: orbit Wheel: zoom (in the Picture panel too) F1: all keys", t.name, t.hint); + } else + for (const ToolInfo& t : kTools) + if (t.tool == tool_) ImGui::TextDisabled("%s: %s · Right drag: orbit Middle drag: pan Wheel: zoom F1: all keys", t.name, t.hint); + ImGui::End(); +} + +// The view's corner: which way you're looking, buttons to look straight at a side, and +// handles to drag to orbit, pan and zoom (for a touchpad or a pen). +auto App::viewButtons() -> void { + const ImVec2 at(viewMax_.x - 10, viewMin_.y + 10); + ImGui::SetNextWindowPos(at, ImGuiCond_Always, ImVec2(1, 0)); + ImGui::SetNextWindowBgAlpha(0.55f); + ImGui::Begin("##views", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDocking); + static const char* names[] = {"Perspective", "Front", "Back", "Left", "Right", "Top", "Bottom"}; + static const char* shadings[] = {"Solid", "Rendered", "Wireframe"}; + ImGui::TextColored(accentText(), "%s", names[int(view_)]); + ImGui::SameLine(); + ImGui::TextDisabled("%s%s", shadings[int(shading_)], xray_ ? ", X-ray" : ""); + for (int i = 1; i < 7; ++i) { + if (i != 1 && i != 4) ImGui::SameLine(0, 3); + if (ImGui::Button(names[i], ImVec2(56, 0))) setView(View(i)); + } + if (ImGui::Button("3D", ImVec2(56, 0))) leaveFlatView(); + ImGui::SameLine(0, 3); + if (ImGui::Button("Frame", ImVec2(56, 0))) frameSelected(); + ImGui::SameLine(0, 3); + if (ImGui::Button(xray_ ? "Solid" : "X-ray", ImVec2(56, 0))) xray_ = !xray_; + const ImVec2 d = ImGui::GetIO().MouseDelta; + ImGui::Button("Orbit", ImVec2(56, 0)); + if (ImGui::IsItemActive()) orbitBy(d); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Drag to orbit"); + ImGui::SameLine(0, 3); + ImGui::Button("Pan", ImVec2(56, 0)); + if (ImGui::IsItemActive()) panBy(d); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Drag to pan"); + ImGui::SameLine(0, 3); + ImGui::Button("Zoom", ImVec2(56, 0)); + if (ImGui::IsItemActive()) dist_ = std::clamp(dist_ * std::pow(1.01f, d.y), 0.01f, 5000.0f); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Drag up and down to zoom"); + if (tab_ != Tab::Render && ImGui::Button(shading_ == Shading::Rendered ? "Show: Solid" : "Show: Rendered", ImVec2(56 * 3 + 6, 0))) + shading_ = shading_ == Shading::Rendered ? Shading::Solid : Shading::Rendered; + ImGui::End(); +} + +} // namespace fryapp diff --git a/src/app_print.cpp b/src/app_print.cpp new file mode 100644 index 0000000..c80fdfa --- /dev/null +++ b/src/app_print.cpp @@ -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(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 { // (what's picked, or everything shown) + std::vector 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 { + 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 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 diff --git a/src/app_scripts.cpp b/src/app_scripts.cpp new file mode 100644 index 0000000..f3a7f8e --- /dev/null +++ b/src/app_scripts.cpp @@ -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(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(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::set 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 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> 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 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(in), {})}); + } + const std::set off = pluginsOff(); + fry::Scene scratch; // (loading a plug-in can't touch the open file: only its commands, when run, do) + std::set none; + uint32_t noActive = 0; + for (auto& [e, code] : found) { + e.on = !off.count(e.file); + if (e.on) { + e.p = std::make_unique(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& 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(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> 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(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 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> 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 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 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> picked; + if (elem_ == Elem::Edge) + for (auto [a, b] : selEdges_) picked.insert({std::min(a, b), std::max(a, b)}); + else { + const std::set 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 pts = selectedPoints(); + if (pts.empty()) return say("Pick points, edges or faces to slide first"); + std::vector> 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 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(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> 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 diff --git a/src/app_view.cpp b/src/app_view.cpp new file mode 100644 index 0000000..cafecbd --- /dev/null +++ b/src/app_view.cpp @@ -0,0 +1,1620 @@ +// Fry3DX: the view: the camera, touch, what's shown (and kept), picking, the gizmo, drawing it. +#include "app.h" + +using namespace fry; +namespace stdfs = std::filesystem; + +namespace fryapp { + +// ───────────────────────────── touch: phones and tablets ───────────────────────────── + +// How much bigger than its pixels the UI is drawn. +auto App::touchScale() const -> float { + if (fry_uiscale.f() > 0) return fry_uiscale.f(); + if (!touch()) return 1.0f; + const float density = SDL_GetDisplayContentScale(SDL_GetPrimaryDisplay()); + return std::clamp((density > 0 ? density : 2.0f) * 0.9f, 1.0f, 4.0f) * std::clamp(fry_uisize.f(), 0.5f, 2.0f); +} + +auto App::touchFrame(ImGuiIO& io) -> void { + if (shiftLatch_) io.KeyShift = true; + if (ctrlLatch_) io.KeyCtrl = true; + ImGui::GetStyle().FontScaleMain = 1.0f; // (the sizes come from touchScale) + if (gest_.rightUp) { // (the long press's right-click: let go the frame after) + io.AddMouseButtonEvent(ImGuiMouseButton_Right, false); + gest_.rightUp = false; + } + // The fingers on the screen now (where, in the UI's units). + std::vector fingers; + int devices = 0; + if (SDL_TouchID* ids = SDL_GetTouchDevices(&devices)) { + for (int d = 0; d < devices; ++d) { + int n = 0; + if (SDL_Finger** f = SDL_GetTouchFingers(ids[d], &n)) { + for (int i = 0; i < n; ++i) fingers.push_back(ImVec2(f[i]->x * io.DisplaySize.x, f[i]->y * io.DisplaySize.y)); + SDL_free(f); + } + } + SDL_free(ids); + } + auto inView = [&](ImVec2 p) { return p.x >= viewMin_.x && p.y >= viewMin_.y && p.x < viewMax_.x && p.y < viewMax_.y; }; + if (fingers.size() >= 2) { + const ImVec2 mid((fingers[0].x + fingers[1].x) * 0.5f, (fingers[0].y + fingers[1].y) * 0.5f); + const float spread = std::max(std::hypot(fingers[0].x - fingers[1].x, fingers[0].y - fingers[1].y), 1.0f); + if (!gest_.two && !drag_.active && inView(mid) && !ImGui::IsAnyItemActive()) { // two fingers: the first's tap or drag is off + gest_.two = true, gest_.mid = mid, gest_.spread = spread; + boxing_ = lassoing_ = touchOrbit_ = tapDraw_ = false; + io.AddMouseButtonEvent(ImGuiMouseButton_Left, false); + } + if (gest_.two) { + panBy(ImVec2(mid.x - gest_.mid.x, mid.y - gest_.mid.y)); + dist_ = std::clamp(dist_ * gest_.spread / spread, 0.01f, 5000.0f); + gest_.mid = mid, gest_.spread = spread; + } + gest_.downAt = -1; + return; + } + if (fingers.empty()) gest_.two = false; + if (fingers.size() != 1 || gest_.two) return void(gest_.downAt = -1); + // One finger held still: a right-click there (menus in the panels, orbit in the view). + const ImVec2 p = fingers[0]; + if (gest_.downAt < 0) gest_.downAt = now(), gest_.downPos = p, gest_.longDone = false; + if (std::hypot(p.x - gest_.downPos.x, p.y - gest_.downPos.y) > 12) gest_.downAt = now() + 1e9; // (moved: not a long press) + if (!gest_.longDone && now() - gest_.downAt > 0.55 && !drag_.active) { + gest_.longDone = true; + boxing_ = lassoing_ = touchOrbit_ = tapDraw_ = false; + io.AddMouseButtonEvent(ImGuiMouseButton_Left, false); + io.AddMouseButtonEvent(ImGuiMouseButton_Right, true); + gest_.rightUp = true; + } +} + +// The bar along the bottom of the view: big buttons, in rows if the view's narrow. +auto App::touchBar() -> void { + const float maxW = std::max(viewMax_.x - viewMin_.x - 16, 120.0f); + ImGui::SetNextWindowPos(ImVec2((viewMin_.x + viewMax_.x) * 0.5f, viewMax_.y - 6), ImGuiCond_Always, ImVec2(0.5f, 1.0f)); + ImGui::SetNextWindowBgAlpha(0.8f); + ImGui::Begin("##touchbar", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDocking); + if (now() - messageAt_ < 4.0) ImGui::PushTextWrapPos(maxW), ImGui::TextColored(accentText(), "%s", message_.c_str()), ImGui::PopTextWrapPos(); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(12, 8)); + const ImGuiStyle& st = ImGui::GetStyle(); + float x = 0; // (where the row's got to) + auto button = [&](const char* label, bool on = false, const char* tip = nullptr) { + const float w = ImGui::CalcTextSize(label).x + st.FramePadding.x * 2; + if (x > 0 && x + st.ItemSpacing.x + w <= maxW) ImGui::SameLine(), x += st.ItemSpacing.x; + else x = 0; + x += w; + if (on) pushAccent(); + const bool pressed = ImGui::Button(label); + if (on) popAccent(); + if (tip && ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip); + return pressed; + }; + if (button("Undo")) undo(); + if (button("Redo")) redo(); + if (button("Shift", shiftLatch_, "Held down until tapped again: adds to the selection, picks loops, scales both sides")) shiftLatch_ = !shiftLatch_; + if (button("Ctrl", ctrlLatch_, "Held down until tapped again: takes from the selection, snaps")) ctrlLatch_ = !ctrlLatch_; + if (tab_ == Tab::Model) { + if (button(mode_ == Mode::Edit ? "Done" : "Edit shape", mode_ == Mode::Edit)) mode_ == Mode::Edit ? leaveEdit() : enterEdit(); + if (button("Delete")) deleteSelected(); + } + if (button("Frame")) frameSelected(); + if (button("Views", viewsOpen_)) viewsOpen_ = !viewsOpen_; + if (button("Panels", panelsOpen_, "Layers, Properties (and the Picture when painting)")) panelsOpen_ = !panelsOpen_; + ImGui::PopStyleVar(); + ImGui::End(); +} + +// ───────────────────────────── the camera ───────────────────────────── + +// metres across the view's height at the target +auto App::setView(View v) -> void { + const float size = viewHeight(); + view_ = v; + switch (v) { + case View::Front: yaw_ = 180, pitch_ = 0; break; // from +X, looking back at the front + case View::Back: yaw_ = 0, pitch_ = 0; break; + case View::Left: yaw_ = -90, pitch_ = 0; break; // from +Y (the model's left side) + case View::Right: yaw_ = 90, pitch_ = 0; break; + case View::Top: yaw_ = 0, pitch_ = 89.9f; break; // front up the screen + case View::Bottom: yaw_ = 0, pitch_ = -89.9f; break; + default: break; + } + // Side views: nearly flat (a narrow lens from far away), the same size on screen. + fov_ = v == View::Perspective ? 50.0f : 2.0f; + dist_ = size / (2.0f * std::tan(glm::radians(fov_) * 0.5f)); +} + +auto App::leaveFlatView() -> void { + if (view_ == View::Perspective) return; + const float size = viewHeight(); + view_ = View::Perspective, fov_ = 50.0f; + dist_ = size / (2.0f * std::tan(glm::radians(fov_) * 0.5f)); +} + +auto App::frameBox(glm::vec3 lo, glm::vec3 hi) -> void { + if (lo.x > hi.x) return; + target_ = (lo + hi) * 0.5f; + const float radius = std::max(glm::length(hi - lo) * 0.5f, 0.05f); + dist_ = radius * 1.4f / std::tan(glm::radians(fov_) * 0.5f); +} + +auto App::frameAll() -> void { + glm::vec3 lo(1e30f), hi(-1e30f); + for (const Object& o : scene_.objects) { + if (!o.visible) continue; + const glm::mat4 m = objectMatrix(o); + for (const glm::vec3& v : o.mesh.verts) { + const glm::vec3 w(m * glm::vec4(v, 1.0f)); + lo = glm::min(lo, w), hi = glm::max(hi, w); + } + } + frameBox(lo, hi); +} + +auto App::frameSelected() -> void { + glm::vec3 lo(1e30f), hi(-1e30f); + for (const glm::vec3& w : selectionPoints()) lo = glm::min(lo, w), hi = glm::max(hi, w); + if (lo.x > hi.x) return frameAll(); + frameBox(lo, hi); +} + +auto App::mouseInView(const ImGuiIO& io) const -> bool { + const ImVec2 m = io.MousePos; + return m.x >= viewMin_.x && m.y >= viewMin_.y && m.x < viewMax_.x && m.y < viewMax_.y && !io.WantCaptureMouse; +} + +auto App::orbitBy(ImVec2 d) -> void { + if (std::abs(d.x) + std::abs(d.y) > 0.5f) leaveFlatView(); + yaw_ -= d.x * 0.3f; + pitch_ = std::clamp(pitch_ + d.y * 0.3f, -89.9f, 89.9f); +} + +auto App::panBy(ImVec2 d) -> void { + 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))); + const glm::vec3 up = glm::cross(right, f); + const float perPixel = viewHeight() / std::max(ImGui::GetIO().DisplaySize.y, 1.0f); + target_ += (-right * d.x + up * d.y) * perPixel; +} + +auto App::updateCamera(ImGuiIO& io) -> void { + const bool inView = mouseInView(io); + // Orbit: right drag. Pan: middle drag (or Shift + right). Zoom: the wheel. + const bool rightDown = ImGui::IsMouseDown(ImGuiMouseButton_Right), midDown = ImGui::IsMouseDown(ImGuiMouseButton_Middle); + if (inView && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) orbiting_ = !io.KeyShift; + if (inView && (ImGui::IsMouseClicked(ImGuiMouseButton_Middle) || (io.KeyShift && ImGui::IsMouseClicked(ImGuiMouseButton_Right)))) panning_ = true, orbiting_ = false; + if (!rightDown) orbiting_ = false; + if (!midDown && !rightDown) panning_ = false; + const ImVec2 d = io.MouseDelta; + if (orbiting_ && (d.x != 0 || d.y != 0)) orbitBy(d); + if (panning_ && (d.x != 0 || d.y != 0)) panBy(d); + if (inView && io.MouseWheel != 0 && drag_.active && softSel_ && mode_ == Mode::Edit) { // the soft selection's reach, not zoom + softReach_ = std::clamp(softReach_ * std::pow(1.15f, io.MouseWheel), 0.001f, 1000.0f); + findSoft(); + message_ = std::format("Soft selection reaches {:.3f} m (wheel)", softReach_), messageAt_ = now(); + } else if (inView && io.MouseWheel != 0) { + // Towards what's under the mouse: the target slides that way a little. + const float k = std::pow(0.85f, io.MouseWheel); + const Ray ray = mouseRay(io.MousePos); + if (auto p = hitPlane(ray, target_, -viewDir())) target_ += (*p - target_) * (1.0f - k); + dist_ = std::clamp(dist_ * k, 0.01f, 5000.0f); + } + settleView(); +} + +// Where the eye is and how the view maps to the screen, from the camera as it is now: again +// just before drawing, so the model and the lines over it agree (a touch can turn it between). +auto App::settleView() -> void { + const ImGuiIO& io = ImGui::GetIO(); + const float aspect = io.DisplaySize.x / std::max(io.DisplaySize.y, 1.0f); + shift_ = glm::vec2((viewMin_.x + viewMax_.x) / std::max(io.DisplaySize.x, 1.0f) - 1.0f, (viewMin_.y + viewMax_.y) / std::max(io.DisplaySize.y, 1.0f) - 1.0f); + eye_ = target_ - viewDir() * dist_; + nearFar_ = nearFar(); + vp_ = viewProjection(eye_, yaw_, pitch_, fov_, aspect, shift_, nearFar_); +} + +// How near and far the view draws (units), going by how far away it looks: close up to a +// 5 mm part, or out to see a whole town, the same share of the depth's precision. +auto App::nearFar() -> glm::vec2 { + float reach = 1.0f; // (how far from the middle the furthest object reaches, m) + for (const Object& o : scene_.objects) { + auto& [version, radius] = radii_[o.id]; + if (version != shapeKey(o) || !version) { + version = shapeKey(o), radius = 0; + for (const glm::vec3& v : o.mesh.verts) radius = std::max(radius, glm::length(v)); + } + reach = std::max(reach, glm::length(o.position) + radius * std::max({std::abs(o.scale.x), std::abs(o.scale.y), std::abs(o.scale.z)})); + } + const float nearM = std::clamp(dist_ * 0.002f, 1e-5f, 50.0f); + const float farM = std::max({dist_ * 60.0f, (glm::length(eye_) + reach) * 2.0f, 100.0f}); + return glm::vec2(nearM, farM) * kUnits; +} + +auto App::project(const glm::vec3& p, ImVec2& out) const -> bool { + const glm::vec4 c = vp_ * glm::vec4(p, 1.0f); + if (c.w < 1e-4f) return false; + const ImVec2 size = ImGui::GetIO().DisplaySize; + out = ImVec2((c.x / c.w * 0.5f + 0.5f) * size.x, (c.y / c.w * 0.5f + 0.5f) * size.y); + return true; +} + +auto App::mouseRay(ImVec2 m) const -> Ray { + const ImVec2 size = ImGui::GetIO().DisplaySize; + const glm::vec2 ndc(m.x / size.x * 2.0f - 1.0f, m.y / size.y * 2.0f - 1.0f); + const glm::mat4 inv = glm::inverse(vp_); + glm::vec4 a = inv * glm::vec4(ndc, 0.0f, 1.0f), b = inv * glm::vec4(ndc, 1.0f, 1.0f); + a /= a.w, b /= b.w; + return {glm::vec3(a), glm::normalize(glm::vec3(b - a))}; +} + +// How many metres a pixel is, at a point. +auto App::pixelSize(const glm::vec3& at) const -> float { + const float d = std::max(glm::dot(at - eye_, viewDir()), 0.001f); + return 2.0f * d * std::tan(glm::radians(fov_) * 0.5f) / std::max(ImGui::GetIO().DisplaySize.y, 1.0f); +} + +// ───────────────────────────── what's shown ───────────────────────────── + +// The mesh as seen: with its rounding, smoothing and mirror — but while its shape is being +// edited, only the mirror (the rest would hide the points you're moving). +// Editable cuts show (but not while its own shape's being edited: the points you move). +// A big one (smoothed into many faces, cut) is made on another thread: what was shown stays +// till it's ready, so the window doesn't stop. +auto App::shownMesh(const Object& o) -> const Mesh& { + const bool editingThis = mode_ == Mode::Edit && o.id == active_; + const bool cut = !editingThis && cutKeys_.count(o.id); + if ((!o.hasFinish() && !cut) || (editingThis && !o.mirror)) return o.mesh; + Shown& s = shown_[o.id]; + const uint64_t v = shapeKey(o); + if (s.coming.valid() && s.coming.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { + Mesh got = s.coming.get(); + if (s.comingVersion == v && s.comingMirror == editingThis) s.mesh = std::move(got), s.version = v, s.mirrorOnly = editingThis; + } + if (s.version == v && v && s.mirrorOnly == editingThis) return s.mesh; + // (what it'll come to: its faces four times over for each level of smoothing) + const size_t faces = o.mesh.faces.size() * (size_t(1) << (2 * std::clamp(editingThis ? 0 : o.smoothLevels, 0, 6))) * (o.mirror ? 2 : 1); + const bool big = faces > 40000 || (cut && o.mesh.faces.size() > 20000); + if (!big || s.mesh.verts.empty()) { // (small: now; the first time: now too, there's nothing to show yet) + s.mesh = cut ? fry::cutMesh(scene_, o) : finishedMesh(o, editingThis), s.version = v, s.mirrorOnly = editingThis; + return s.mesh; + } + if (!s.coming.valid() || s.comingVersion != v || s.comingMirror != editingThis) { + if (s.coming.valid()) s.coming.wait(); // (one at a time for each object: the old one's thrown away) + Scene part; // (just it and what cuts it: the thread has its own copies) + part.objects.push_back(o); + if (cut) + for (const Object& c : scene_.objects) + if (c.cuts == o.id && c.id != o.id) part.objects.push_back(c); + s.comingVersion = v, s.comingMirror = editingThis; + s.coming = std::async(std::launch::async, [part = std::move(part), cut, editingThis] { + return cut ? fry::cutMesh(part, part.objects[0]) : finishedMesh(part.objects[0], editingThis); + }); + message_ = "Working it out...", messageAt_ = now(); + } + return s.mesh; +} + +// The key of the mesh actually shown (the one before, while a new one's being made). +auto App::meshKey(const Object& o, const Mesh& mesh) -> uint64_t { + if (&mesh == &o.mesh) return shapeKey(o); + auto it = shown_.find(o.id); + return it != shown_.end() && &it->second.mesh == &mesh ? it->second.version * 2 + it->second.mirrorOnly : shapeKey(o); +} + +// (where each cut object and cutter was last frame) +auto App::followCuts() -> void { + // What's cut moved, turned or sized (not by a drag: that takes them itself), and its cutter + // didn't: the cutter goes the same way, so the hole stays where it was on it. + std::map at; + for (Object& c : scene_.objects) { + const Object* t = fry::cutTarget(scene_, c); + if (!t) continue; + const glm::mat4 mt = objectMatrix(*t); + const auto was = followAt_.find(t->id), cWas = followAt_.find(c.id); + if (!drag_.active && was != followAt_.end() && cWas != followAt_.end() && was->second != mt && cWas->second == objectMatrix(c) && !c.locked) { + const glm::mat4 m = mt * glm::inverse(was->second) * objectMatrix(c); + glm::vec3 skew; + glm::vec4 persp; + glm::vec3 scale, pos; + glm::quat rot; + if (glm::decompose(m, scale, rot, pos, skew, persp)) c.position = pos, c.rotation = glm::normalize(rot), c.scale = scale, touched(c.id); + } + at[t->id] = mt, at[c.id] = objectMatrix(c); + } + followAt_ = std::move(at); + std::map now; + for (const Object& c : scene_.objects) { + const Object* t = fry::cutTarget(scene_, c); + if (!t || c.mesh.faces.empty()) continue; + uint64_t& key = now.emplace(t->id, 1469598103934665603ull).first->second; + key = (key ^ versions_[c.id] ^ (uint64_t(c.id) << 40)) * 1099511628211ull; + const glm::mat4 m = objectMatrix(c); + for (int i = 0; i < 16; ++i) { + uint32_t b; + std::memcpy(&b, &m[i / 4][i % 4], 4); + key = (key ^ b) * 1099511628211ull; + } + } + for (const auto& [id, key] : now) + if (auto it = cutKeys_.find(id); it == cutKeys_.end() || it->second != key) versions_[id] = ++versionClock_; + for (const auto& [id, key] : cutKeys_) + if (!now.count(id)) versions_[id] = ++versionClock_; + cutKeys_ = std::move(now); +} + +// Editable cut: the last picked has the others cut out of it, and they stay objects to move. +auto App::cutEditable() -> void { + if (mode_ == Mode::Edit) leaveEdit(); + Object* a = find(active_); + if (!a || selected_.size() < 2) return say("Pick the cutters, then (Shift) what they cut last: they stay, to move and change, and the hole follows"); + pushUndo(); + int n = 0; + for (Object& o : scene_.objects) + if (selected_.count(o.id) && o.id != a->id) o.cuts = a->id, ++n; + for (Object& o : scene_.objects) // (one that was cutting the cutters: not a loop of cuts) + if (o.id == a->id && o.cuts && selected_.count(o.cuts)) o.cuts = 0; + selected_.erase(a->id), active_ = *selected_.rbegin(); + dirty_ = true; + say(std::format("{} cut out of {} (editable: move or change {}; Apply makes it for good)", n == 1 ? "1 object" : std::format("{} objects", n), find(a->id)->name, n == 1 ? "it" : "them")); +} + +// The cut made for good: one cutter (or all of what's cut out of it) into the shape, gone. +auto App::applyCuts(uint32_t target, uint32_t only) -> void { + Object* t = find(target); + if (!t) return; + pushUndo(); + Scene part = scene_; // (just the cutters wanted) + for (Object& o : part.objects) + if (o.cuts == target && only && o.id != only) o.cuts = 0; + Object* pt = &*std::find_if(part.objects.begin(), part.objects.end(), [&](const Object& o) { return o.id == target; }); + t->mesh = fry::cutMesh(part, *pt); + t->mirror = 0, t->round = 0, t->smoothLevels = 0, t->creases.clear(); + std::set gone; + for (const Object& o : scene_.objects) + if (o.cuts == target && (!only || o.id == only)) gone.insert(o.id); + std::erase_if(scene_.objects, [&](const Object& o) { return gone.count(o.id) > 0; }); + for (uint32_t id : gone) selected_.erase(id); + selected_.insert(target), active_ = target; + changedShape(*find(target)); + say(std::format("Cut made for good in {} ({} faces)", find(target)->name, find(target)->mesh.faces.size())); +} + +// ───────────────────────────── picking ───────────────────────────── + +// base: against its own mesh (face numbers of that), not the rounded or smoothed one shown. +auto App::pickObject(const Ray& ray, uint32_t only, bool base) -> Hit { + Hit best; + for (const Object& o : scene_.objects) { + if (!o.visible || (only && o.id != only)) continue; + const Mesh& mesh = base || (mode_ == Mode::Edit && o.id == active_) ? o.mesh : shownMesh(o); + if (auto h = treeOf(o, mesh).nearest(ray.origin, ray.dir, best.t)) best = {o.id, h->face, h->t}; + } + return best; +} + +// (an object's own mesh, or the one shown) +auto App::treeOf(const Object& o, const Mesh& mesh) -> const fry::TriangleTree& { + Tree& t = trees_[{o.id, &mesh != &o.mesh}]; + const glm::mat4 m = objectMatrix(o); + const uint64_t version = meshKey(o, mesh); + if (t.version != version || !version || t.matrix != m || t.faces != mesh.faces.size() || t.tree.points().size() != mesh.verts.size()) + t.version = version, t.matrix = m, t.faces = mesh.faces.size(), t.tree = fry::TriangleTree(mesh, m); + return t.tree; +} + +// Can the eye see this point of the object (nothing of it in the way)? +auto App::visible(const Object& o, const glm::vec3& world) -> bool { + if (xray_) return true; + const glm::vec3 d = world - eye_; + const float len = glm::length(d); + const Hit h = pickObject({eye_, d / len}, o.id); + return h.t > len - std::max(len * 0.002f, 0.0005f); +} + +// (an object's own mesh, or the one shown) +auto App::seen(const Object& o, const Mesh& mesh, int i) -> bool { + Visibility& v = visibility_[{o.id, &mesh != &o.mesh}]; + const uint64_t version = versions_[o.id]; + if (v.version != version || glm::distance(v.eye, eye_) >= 1e-6f || v.vp != vp_ || v.point.size() != mesh.verts.size()) + v.version = version, v.eye = eye_, v.vp = vp_, v.point.assign(mesh.verts.size(), 0); + char& got = v.point[size_t(i)]; + if (got) return got == 1; + const fry::TriangleTree& tree = treeOf(o, mesh); + const glm::vec3 w = tree.points()[size_t(i)]; + const glm::vec4 c = vp_ * glm::vec4(w, 1.0f); + const glm::vec3 d = w - eye_; + const float len = glm::length(d); + if (c.w < 1e-5f || std::abs(c.x) > c.w || std::abs(c.y) > c.w || len < 1e-6f) return (got = 1); // (off the screen: not drawn anyway) + got = tree.blocked(eye_, d / len, len - std::max(len * 0.002f, 0.0005f), i) ? 2 : 1; + return got == 1; +} + +auto App::adjacencyOf(const Object& o, const Mesh& mesh) -> const Adjacency& { + Adjacency& a = adjacency_[{o.id, &mesh != &o.mesh}]; + const uint64_t version = meshKey(o, mesh); + if (a.version == version && version && a.verts == mesh.verts.size() && a.faces == mesh.faces.size()) return a; + a.version = version, a.verts = mesh.verts.size(), a.faces = mesh.faces.size(); + a.edges.clear(), a.plane.resize(mesh.faces.size()); + std::unordered_map at; + at.reserve(mesh.faces.size() * 2); + for (size_t f = 0; f < mesh.faces.size(); ++f) { + const auto& fv = mesh.faces[f]; + const glm::vec3 n = faceNormal(mesh, int(f)); + a.plane[f] = glm::vec4(n, fv.empty() ? 0.0f : glm::dot(n, mesh.verts[size_t(fv[0])])); + for (size_t i = 0; i < fv.size(); ++i) { + const int p = std::min(fv[i], fv[(i + 1) % fv.size()]), q = std::max(fv[i], fv[(i + 1) % fv.size()]); + auto [it, fresh] = at.try_emplace(uint64_t(uint32_t(p)) << 32 | uint32_t(q), int(a.edges.size())); + if (fresh) a.edges.push_back({p, q, int(f), -1}); + else if (a.edges[size_t(it->second)][3] < 0) a.edges[size_t(it->second)][3] = int(f); + } + } + return a; +} + +// The edge of the edited object nearest the mouse on screen (that can be seen), or (-1, -1). +auto App::nearestEdge(Object& o, ImVec2 mouse, float within) -> std::pair { + const glm::mat4 m = objectMatrix(o); + std::pair best{-1, -1}; + for (const auto& e : adjacencyOf(o, o.mesh).edges) { + const int a = e[0], b = e[1]; + ImVec2 sa, sb; + if (!project(glm::vec3(m * glm::vec4(o.mesh.verts[size_t(a)], 1.0f)), sa) || !project(glm::vec3(m * glm::vec4(o.mesh.verts[size_t(b)], 1.0f)), sb)) continue; + const float d = distToSegment(mouse, sa, sb); + if (d < within && (pointSeen(o, a) || pointSeen(o, b))) within = d, best = {a, b}; + } + return best; +} + +auto App::clickSelect(const ImGuiIO& io) -> void { + const Ray ray = mouseRay(io.MousePos); + bool add = io.KeyShift, remove = io.KeyCtrl; + if (Object* o = editing()) { + // Shapes: Shift picks the whole loop (Shift+Ctrl on an edge: the ring across it), Ctrl + // adds or takes away one. + if (io.KeyShift) return loopSelect(io, io.KeyCtrl); + add = io.KeyCtrl, remove = false; + const glm::mat4 m = objectMatrix(*o); + auto toggle = [&](auto& set, auto key) { + if (remove) set.erase(key); + else if (add && set.count(key)) set.erase(key); + else set.insert(key); + }; + if (!add && !remove) clearElements(); + if (elem_ == Elem::Vertex) { + int best = -1; + float bestD = 14.0f; + for (size_t v = 0; v < o->mesh.verts.size(); ++v) { + ImVec2 s; + if (!project(glm::vec3(m * glm::vec4(o->mesh.verts[v], 1.0f)), s)) continue; + const float d = std::hypot(s.x - io.MousePos.x, s.y - io.MousePos.y); + if (d < bestD && pointSeen(*o, int(v))) bestD = d, best = int(v); + } + if (best >= 0) toggle(selVerts_, best); + } else if (elem_ == Elem::Edge) { + const auto e = nearestEdge(*o, io.MousePos, 10.0f); + if (e.first >= 0) toggle(selEdges_, e); + } else { + const Hit h = pickObject(ray, o->id); + if (h.face >= 0) toggle(selFaces_, h.face); + } + return; + } + const Hit h = pickObject(ray); + if (!add && !remove) selected_.clear(); + if (h.object) { + if (remove || (add && selected_.count(h.object))) selected_.erase(h.object); + else selected_.insert(h.object), active_ = h.object; + activeRef_ = 0; + } +} + +// A whole loop: of edges (points), or a ring of faces, through the edge under the mouse. +auto App::loopSelect(const ImGuiIO& io, bool ring) -> void { + Object* o = editing(); + if (!o) return; + if (!io.KeyShift) clearElements(); + if (elem_ == Elem::Face) { + const Hit h = pickObject(mouseRay(io.MousePos), o->id); + if (h.face < 0) return; + // The face's edge nearest the mouse: the ring runs across it. + const auto& fv = o->mesh.faces[size_t(h.face)]; + const glm::mat4 m = objectMatrix(*o); + std::pair best{-1, -1}; + float bestD = 1e30f; + for (size_t i = 0; i < fv.size(); ++i) { + ImVec2 sa, sb; + if (project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(fv[i])], 1.0f)), sa) && + project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(fv[(i + 1) % fv.size()])], 1.0f)), sb)) + if (const float d = distToSegment(io.MousePos, sa, sb); d < bestD) bestD = d, best = {fv[i], fv[(i + 1) % fv.size()]}; + } + if (best.first < 0) return; + const auto ring = faceRing(o->mesh, best); + selFaces_.insert(ring.begin(), ring.end()); + if (ring.empty()) selFaces_.insert(h.face); + return; + } + const auto e = nearestEdge(*o, io.MousePos, 14.0f); + if (e.first < 0) return; + const auto loop = ring ? edgeRing(o->mesh, e) : edgeLoop(o->mesh, e); + if (ring) say(std::format("A ring of {} edges", loop.size())); + if (elem_ == Elem::Edge) selEdges_.insert(loop.begin(), loop.end()); + else + for (auto [a, b] : loop) selVerts_.insert(a), selVerts_.insert(b); +} + +// Selects what's on screen inside a shape (a box, a lasso, the brush). +auto App::regionSelect(const std::function& inside, bool add, bool remove, bool clearFirst) -> void { + auto in = [&](const glm::vec3& w) { + ImVec2 s; + return project(w, s) && inside(s); + }; + if (Object* o = editing()) { + if (clearFirst) clearElements(); + const glm::mat4 m = objectMatrix(*o); + std::vector hit(o->mesh.verts.size()); + for (size_t v = 0; v < hit.size(); ++v) hit[v] = in(glm::vec3(m * glm::vec4(o->mesh.verts[v], 1.0f))) && pointSeen(*o, int(v)); + auto apply = [&](auto& set, auto key, bool yes) { + if (!yes) return; + if (remove) set.erase(key); + else set.insert(key); + }; + if (elem_ == Elem::Vertex) + for (size_t v = 0; v < hit.size(); ++v) apply(selVerts_, int(v), bool(hit[v])); + else if (elem_ == Elem::Edge) + for (auto e : edges(o->mesh)) { + const glm::vec3 mid(m * glm::vec4((o->mesh.verts[size_t(e.first)] + o->mesh.verts[size_t(e.second)]) * 0.5f, 1.0f)); + apply(selEdges_, e, (hit[size_t(e.first)] && hit[size_t(e.second)]) || (in(mid) && pointSeen(*o, e.first) && pointSeen(*o, e.second))); + } + else + for (size_t f = 0; f < o->mesh.faces.size(); ++f) { + const glm::vec3 c(m * glm::vec4(faceCenter(o->mesh, int(f)), 1.0f)); + if (!in(c)) continue; + const glm::vec3 n = glm::transpose(glm::inverse(glm::mat3(m))) * faceNormal(o->mesh, int(f)); + apply(selFaces_, int(f), xray_ || (glm::dot(n, eye_ - c) > 0 && visible(*o, c))); + } + return; + } + if (clearFirst) selected_.clear(); + (void)add; + for (const Object& o : scene_.objects) { + if (!o.visible) continue; + const glm::vec3 c(objectMatrix(o) * glm::vec4((boundsMin(o.mesh) + boundsMax(o.mesh)) * 0.5f, 1.0f)); + if (!in(c)) continue; + if (remove) selected_.erase(o.id); + else selected_.insert(o.id), active_ = o.id; + } +} + +// ───────────────────────────── the gizmo ───────────────────────────── + +auto App::hasSelection() -> bool { + if (mode_ == Mode::Edit) return !selectedPoints().empty(); + return std::any_of(selected_.begin(), selected_.end(), [&](uint32_t id) { const Object* o = find(id); return o && !o->locked; }); +} + +// Every point of the selection, in the world. +auto App::selectionPoints() -> std::vector { + std::vector out; + if (Object* o = editing()) { + const glm::mat4 m = objectMatrix(*o); + for (int v : selectedPoints()) out.push_back(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(v)], 1.0f))); + return out; + } + for (const Object& o : scene_.objects) { + if (!selected_.count(o.id) || o.locked) continue; + const glm::mat4 m = objectMatrix(o); + for (const glm::vec3& v : shownMesh(o).verts) out.push_back(glm::vec3(m * glm::vec4(v, 1.0f))); + } + return out; +} + +auto App::pivotOfSelection() -> glm::vec3 { + glm::vec3 c(0.0f); + int n = 0; + if (Object* o = editing()) { + const glm::mat4 m = objectMatrix(*o); + for (int v : selectedPoints()) c += glm::vec3(m * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)), ++n; + } else { + for (const Object& o : scene_.objects) + if (selected_.count(o.id)) c += o.position, ++n; + } + return n ? c / float(n) : glm::vec3(0.0f); +} + +// The selection's box, in a gizmo's axes. +auto App::selectionBox(const Axes& a) -> std::pair { + glm::vec3 lo(1e30f), hi(-1e30f); + for (const glm::vec3& p : selectionPoints()) { + const glm::vec3 c = inFrame(a, p); + lo = glm::min(lo, c), hi = glm::max(hi, c); + } + if (lo.x > hi.x) lo = hi = inFrame(a, pivotOfSelection()); + return {lo, hi}; +} + +auto App::selectionMiddle(const Axes& a) -> glm::vec3 { + const auto [lo, hi] = selectionBox(a); + return fromFrame(a, (lo + hi) * 0.5f); +} + +// The gizmo's axes: the object's own (turning with it), the world's, or (shape editing) the +// way the selected faces face — so "up" on a side face is out of that face. +auto App::gizmoAxes() -> Axes { + Orient o = orient(); + if (mode_ == Mode::Object && tool_ == Tool::Scale) o = Orient::Local; // (sizes are along an object's own axes) + const Object* a = find(active_); + if (!a && !selected_.empty()) a = find(*selected_.begin()); + const glm::quat q = a && o != Orient::World ? a->rotation : glm::quat(1, 0, 0, 0); + Axes local{q * glm::vec3(1, 0, 0), q * glm::vec3(0, 1, 0), q * glm::vec3(0, 0, 1)}; + if (o != Orient::Normal || mode_ != Mode::Edit) return local; + Object* e = editing(); + if (!e) return local; + const glm::mat4 m = objectMatrix(*e); + const glm::mat3 nm = glm::transpose(glm::inverse(glm::mat3(m))); + const std::set pts = selectedPoints(); + glm::vec3 n(0.0f), along(0.0f); + for (size_t f = 0; f < e->mesh.faces.size(); ++f) { + const auto& fv = e->mesh.faces[f]; + const bool whole = elem_ == Elem::Face ? selFaces_.count(int(f)) > 0 : std::any_of(fv.begin(), fv.end(), [&](int v) { return pts.count(v); }); + if (!whole) continue; + n += nm * faceNormal(e->mesh, int(f)) * std::max(faceArea(e->mesh, int(f)), 1e-6f); + if (glm::length(along) < 1e-9f) // the first face's longest edge: "across" it + for (size_t i = 0; i < fv.size(); ++i) { + const glm::vec3 d = glm::mat3(m) * (e->mesh.verts[size_t(fv[(i + 1) % fv.size()])] - e->mesh.verts[size_t(fv[i])]); + if (glm::length(d) > glm::length(along)) along = d; + } + } + if (elem_ == Elem::Edge && !selEdges_.empty()) { + const auto [a0, b0] = *selEdges_.begin(); + along = glm::mat3(m) * (e->mesh.verts[size_t(b0)] - e->mesh.verts[size_t(a0)]); + } + if (glm::length(n) < 1e-9f) return local; + const glm::vec3 z = glm::normalize(n); + glm::vec3 x = along - z * glm::dot(along, z); + if (glm::length(x) < 1e-6f) x = glm::cross(std::abs(z.z) < 0.9f ? glm::vec3(0, 0, 1) : glm::vec3(1, 0, 0), z); + x = glm::normalize(x); + return {x, glm::cross(z, x), z}; +} + +// The extrude / inset handle: the selected faces' middle, their normal. +auto App::faceHandle(glm::vec3& at, glm::vec3& normal) -> bool { + Object* o = editing(); + if (!o || elem_ != Elem::Face || selFaces_.empty()) return false; + const glm::mat4 m = objectMatrix(*o); + glm::vec3 c(0.0f), n(0.0f); + for (int f : selFaces_) { + if (f >= int(o->mesh.faces.size())) return false; + c += glm::vec3(m * glm::vec4(faceCenter(o->mesh, f), 1.0f)); + n += glm::normalize(glm::transpose(glm::inverse(glm::mat3(m))) * faceNormal(o->mesh, f)); + } + at = c / float(selFaces_.size()); + normal = glm::length(n) > 1e-6f ? glm::normalize(n) : glm::vec3(0, 0, 1); + return true; +} + +// The round-edges handle: off to the right of the selection's middle. +auto App::bevelHandle(ImVec2& centre, ImVec2& handle) -> bool { + if (!editing() || edgesToRound().empty()) return false; + if (!project(drag_.active ? drag_.pivot : pivotOfSelection(), centre)) return false; + handle = ImVec2(centre.x + 80.0f + (drag_.active ? ImGui::GetIO().MousePos.x - drag_.startMouse.x : 0.0f), centre.y); + return true; +} + +// The scale box's handles: 0-2 the far side along each axis, 4-6 the near side, 3 the middle. +// (Pushed out a little where the box is flat, so they don't sit on top of each other.) +auto App::scaleHandle(const Axes& a, glm::vec3 lo, glm::vec3 hi, int h) -> glm::vec3 { + const glm::vec3 c = (lo + hi) * 0.5f; + if (h == 3) return fromFrame(a, c); + const int k = h % 4 == 3 ? 0 : (h < 3 ? h : h - 4); + glm::vec3 q = c; + const float minHalf = pixelSize(fromFrame(a, c)) * 22.0f; + const float half = std::max((hi[k] - lo[k]) * 0.5f, minHalf); + q[k] = c[k] + (h < 3 ? half : -half); + return fromFrame(a, q); +} + +// Which handle is under the mouse (-1 none). +auto App::hoverHandle(ImVec2 mouse) -> int { + if (drag_.active) return drag_.handle; + if (tool_ == Tool::Extrude || tool_ == Tool::Inset) { + glm::vec3 at, n; + if (!faceHandle(at, n)) return -1; + ImVec2 a, b; + if (!project(at, a) || !project(at + n * pixelSize(at) * 90.0f, b)) return -1; + return distToSegment(mouse, a, b) < 10.0f ? 0 : -1; + } + if (tool_ == Tool::Bevel) { + ImVec2 c, h; + return bevelHandle(c, h) && std::hypot(mouse.x - h.x, mouse.y - h.y) < 12.0f ? 0 : -1; + } + if (tool_ != Tool::Move && tool_ != Tool::Rotate && tool_ != Tool::Scale) return -1; + if (!hasSelection()) return -1; + const Axes axes = gizmoAxes(); + if (tool_ == Tool::Scale) { + const auto [lo, hi] = selectionBox(axes); + for (int h : {3, 0, 1, 2, 4, 5, 6}) { + ImVec2 s; + if (project(scaleHandle(axes, lo, hi, h), s) && std::abs(mouse.x - s.x) < 9 && std::abs(mouse.y - s.y) < 9) return h; + } + return -1; + } + const glm::vec3 p = tool_ == Tool::Rotate ? rotatePivot(axes) : selectionMiddle(axes); + ImVec2 c; + if (!project(p, c)) return -1; + const float len = pixelSize(p) * 90.0f; + if (tool_ == Tool::Rotate) { + int best = -1; + float bestD = 9.0f; + for (int k = 0; k < 3; ++k) { + const glm::vec3 u = glm::normalize(glm::cross(axes[size_t(k)], std::abs(axes[size_t(k)].z) < 0.9f ? glm::vec3(0, 0, 1) : glm::vec3(1, 0, 0))); + const glm::vec3 v = glm::cross(axes[size_t(k)], u); + ImVec2 prev; + bool havePrev = false; + for (int i = 0; i <= 48; ++i) { + const float a = 2 * kPi * float(i) / 48.0f; + ImVec2 s; + const glm::vec3 w = p + (u * std::cos(a) + v * std::sin(a)) * len; + if (!project(w, s)) { + havePrev = false; + continue; + } + if (havePrev && glm::dot(w - p, eye_ - p) > -0.2f * len * glm::length(eye_ - p)) // (the front half only) + if (const float d = distToSegment(mouse, prev, s); d < bestD) bestD = d, best = k; + prev = s, havePrev = true; + } + } + const float r = std::hypot(mouse.x - c.x, mouse.y - c.y); + if (best < 0 && r > 95.0f && r < 110.0f) best = 3; // the outer ring: about the view + return best; + } + if (std::abs(mouse.x - c.x) < 9 && std::abs(mouse.y - c.y) < 9) return 3; + for (int k = 0; k < 3; ++k) { + ImVec2 e; + if (project(p + axes[size_t(k)] * len, e) && distToSegment(mouse, c, e) < 9.0f) return k; + } + return -1; +} + +auto App::originPoint() -> glm::vec3 { + if (Object* o = editing()) return o->position; + glm::vec3 c(0.0f); + int n = 0; + for (const Object& o : scene_.objects) + if (selected_.count(o.id)) c += o.position, ++n; + return n ? c / float(n) : c; +} + +auto App::startDrag(const ImGuiIO& io, int handle) -> void { + drag_ = {}; + drag_.active = true, drag_.handle = handle, drag_.tool = tool_; + drag_.startMouse = io.MousePos; + drag_.before = scene_; + pushUndo(); + if (softSel_ && (tool_ == Tool::Move || tool_ == Tool::Rotate || tool_ == Tool::Scale)) findSoft(); + const Ray ray = mouseRay(io.MousePos); + if (tool_ == Tool::Extrude || tool_ == Tool::Inset) { + glm::vec3 at, n; + faceHandle(at, n); + drag_.pivot = at, drag_.startT = closestOnAxis(ray, at, n); + drag_.startHit = n; + return; + } + if (tool_ == Tool::Bevel) { + drag_.pivot = pivotOfSelection(); + drag_.bevelEdges = edgesToRound(); + return; + } + drag_.axes = gizmoAxes(); + const Axes& a = drag_.axes; + if (tool_ == Tool::Scale) { + const auto [lo, hi] = selectionBox(a); + const glm::vec3 c = (lo + hi) * 0.5f; + drag_.handlePoint = scaleHandle(a, lo, hi, handle); + const int k = handle < 3 ? handle : handle - 4; + glm::vec3 q = c; + switch (scalePivot_) { + case Pivot::Opposite: + if (handle == 3) q[2] = lo[2]; + else q[k] = handle < 3 ? lo[k] : hi[k]; + drag_.anchor = fromFrame(a, q); + break; + case Pivot::Bottom: q[2] = lo[2], drag_.anchor = fromFrame(a, q); break; + case Pivot::Origin: drag_.anchor = originPoint(); break; + default: drag_.anchor = fromFrame(a, c); break; + } + drag_.pivot = fromFrame(a, c); + if (handle != 3) drag_.startDist = glm::dot(drag_.handlePoint - drag_.anchor, a[size_t(k)]), drag_.startMid = glm::dot(drag_.handlePoint - drag_.pivot, a[size_t(k)]); + return; + } + drag_.pivot = tool_ == Tool::Rotate ? rotatePivot(a) : selectionMiddle(a); + if (handle < 3) drag_.startT = closestOnAxis(ray, drag_.pivot, a[size_t(handle)]); + if (auto hit = hitPlane(ray, drag_.pivot, -viewDir())) drag_.startHit = *hit; +} + +// The points within reach of the selection (in the world: the object's size counts), each with +// how much it follows: a grid of the selected points, so a big mesh stays quick. +auto App::findSoft() -> void { + drag_.soft.clear(); + const Object* o = editing(); + if (!o) return; + const std::set pts = selectedPoints(); + if (pts.empty()) return; + const glm::mat4 m = objectMatrix(*o); + const float r = std::max(softReach_, 1e-5f); + auto cell = [&](const glm::vec3& p) { return glm::ivec3(glm::floor(p / r)); }; + auto keyOf = [](glm::ivec3 c) { return (int64_t(c.x & 0x1FFFFF) << 42) | (int64_t(c.y & 0x1FFFFF) << 21) | int64_t(c.z & 0x1FFFFF); }; + std::unordered_map> grid; + for (int v : pts) { + const glm::vec3 w(m * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)); + grid[keyOf(cell(w))].push_back(w); + } + for (size_t v = 0; v < o->mesh.verts.size(); ++v) { + if (pts.count(int(v))) continue; + const glm::vec3 w(m * glm::vec4(o->mesh.verts[v], 1.0f)); + const glm::ivec3 c = cell(w); + float best = r; + for (int dx = -1; dx <= 1; ++dx) + for (int dy = -1; dy <= 1; ++dy) + for (int dz = -1; dz <= 1; ++dz) + if (auto it = grid.find(keyOf(c + glm::ivec3(dx, dy, dz))); it != grid.end()) + for (const glm::vec3& q : it->second) best = std::min(best, glm::distance(w, q)); + if (best < r) drag_.soft.push_back({int(v), sculptFalloff(best, r)}); + } +} + +auto App::cancelDrag() -> void { + if (!drag_.active) return; + scene_ = drag_.before; + dropUndo(); + touchAll(); + drag_.active = false; +} + +auto App::updateDrag(const ImGuiIO& io) -> void { + for (ImWchar c : io.InputQueueCharacters) { + if ((c >= '0' && c <= '9') || (c == '.' && drag_.typed.find('.') == std::string::npos)) drag_.typed += char(c); + else if (c == '-') drag_.typed = drag_.typed.starts_with('-') ? drag_.typed.substr(1) : "-" + drag_.typed; + } + if (ImGui::IsKeyPressed(ImGuiKey_Backspace) && !drag_.typed.empty()) drag_.typed.pop_back(); + dragNow(io); + // (the number by the mouse, as it goes: what you'd read in the status line, where you look) + if (!message_.empty()) { + const std::string label = drag_.typed.empty() ? message_ : message_ + " (typed)"; + const ImVec2 at(io.MousePos.x + 18, io.MousePos.y + 14), size = ImGui::CalcTextSize(label.c_str()); + ImDrawList* dl = ImGui::GetForegroundDrawList(); + dl->AddRectFilled(ImVec2(at.x - 6, at.y - 3), ImVec2(at.x + size.x + 6, at.y + size.y + 3), IM_COL32(24, 24, 28, 225), 4.0f); + dl->AddText(at, drag_.typed.empty() ? IM_COL32(235, 235, 240, 255) : kAccent, label.c_str()); + } + if (liveMirror_ && mode_ == Mode::Edit) + if (Object* o = editing()) + for (const Object& b : drag_.before.objects) + if (b.id == o->id) mirrorEdits(*o, b.mesh); +} + +// Mirror editing: each point that moved, its twin across the shape's own X (the point that was +// where it is, mirrored) moved the mirrored way. Points on the middle stay on it. +auto App::mirrorEdits(Object& o, const Mesh& before) -> void { + if (before.verts.size() != o.mesh.verts.size() || before.verts.empty()) return; // (points made or gone: nothing to match) + const float tol = std::max(glm::length(boundsMax(before) - boundsMin(before)) * 1e-5f, 1e-7f); + auto key = [&](const glm::vec3& p) { + const glm::ivec3 c(glm::round(p / tol)); + return (uint64_t(uint32_t(c.x) & 0x1FFFFF) << 42) | (uint64_t(uint32_t(c.y) & 0x1FFFFF) << 21) | uint64_t(uint32_t(c.z) & 0x1FFFFF); + }; + std::unordered_map at; + for (size_t i = 0; i < before.verts.size(); ++i) at.emplace(key(before.verts[i]), int(i)); + std::vector moved(before.verts.size()); + for (size_t i = 0; i < moved.size(); ++i) moved[i] = glm::distance(before.verts[i], o.mesh.verts[i]) > tol * 0.01f; + for (size_t i = 0; i < moved.size(); ++i) { + if (!moved[i]) continue; + const glm::vec3 b = before.verts[i]; + if (std::abs(b.x) <= tol) { // (on the middle) + o.mesh.verts[i].x = 0; + continue; + } + const auto it = at.find(key(glm::vec3(-b.x, b.y, b.z))); + if (it == at.end() || moved[size_t(it->second)]) continue; // (no twin, or it's moved by hand too) + const glm::vec3 n = o.mesh.verts[i]; + o.mesh.verts[size_t(it->second)] = glm::vec3(-n.x, n.y, n.z); + } +} + +// What moving snaps to near the mouse (points, edges' or faces' middles), in the world: not +// what's being moved. Marked with a ring. +auto App::snapTarget(ImVec2 mouse, float within) -> std::optional { + std::optional best; + float bestD = within; + const std::set moving = mode_ == Mode::Edit ? selectedPoints() : std::set{}; + for (const Object& o : scene_.objects) { + if (!o.visible) continue; + const bool edited = mode_ == Mode::Edit && o.id == active_; + if (mode_ != Mode::Edit && selected_.count(o.id)) continue; + const Mesh& mesh = edited ? o.mesh : shownMesh(o); + const glm::mat4 m = objectMatrix(o); + auto tryPoint = [&](const glm::vec3& local) { + const glm::vec3 w = glm::vec3(m * glm::vec4(local, 1.0f)); + ImVec2 sp; + if (!project(w, sp)) return; + if (const float d = std::hypot(sp.x - mouse.x, sp.y - mouse.y); d < bestD) bestD = d, best = w; + }; + if (snapKind_ == 1) { + for (size_t v = 0; v < mesh.verts.size(); ++v) + if (!(edited && moving.count(int(v)))) tryPoint(mesh.verts[v]); + } else if (snapKind_ == 2) { + for (const auto& e : adjacencyOf(o, mesh).edges) + if (!(edited && (moving.count(e[0]) || moving.count(e[1])))) tryPoint((mesh.verts[size_t(e[0])] + mesh.verts[size_t(e[1])]) * 0.5f); + } else if (snapKind_ == 3) { + for (const auto& f : mesh.faces) { + glm::vec3 c(0.0f); + bool mine = false; + for (int v : f) c += mesh.verts[size_t(v)], mine |= edited && moving.count(v) > 0; + if (!mine && !f.empty()) tryPoint(c / float(f.size())); + } + } + } + if (best) { + ImVec2 sp; + if (project(*best, sp)) ImGui::GetForegroundDrawList()->AddCircle(sp, 7.0f, kAccent, 0, 2.0f); + } + return best; +} + +// The number typed while dragging, if any (just "-" or "." is none yet). +auto App::typed() const -> std::optional { + if (drag_.typed.empty() || drag_.typed == "-" || drag_.typed == "." || drag_.typed == "-.") return std::nullopt; + return std::strtof(drag_.typed.c_str(), nullptr); +} + +auto App::dragNow(const ImGuiIO& io) -> void { + const Ray ray = mouseRay(io.MousePos); + const bool snapping = snap_ != io.KeyCtrl; // Ctrl flips snapping while held + scene_ = drag_.before; // every frame from where it started: no drift + const Axes& axes = drag_.axes; + const glm::vec3 p = drag_.pivot; + glm::mat4 T(1.0f); // what happens to the selection, in the world + if (drag_.tool == Tool::Extrude || drag_.tool == Tool::Inset) { + Object* o = editing(); + if (!o) return; + const glm::vec3 n = drag_.startHit; + float d = closestOnAxis(ray, p, n) - drag_.startT; + if (drag_.tool == Tool::Extrude) { + if (snapping) d = snapTo(d, snapMove_); + if (auto v = typed()) d = *v; + // (the pull in the object's own size, so it follows the mouse whatever its scale) + const float scale = std::cbrt(std::abs(glm::determinant(glm::mat3(objectMatrix(*o))))); + extrudeFaces(o->mesh, selFaces_, d / std::max(scale, 1e-6f)); + extrudeAmount_ = d; + message_ = std::format("Extrude {:.3f} m", d), messageAt_ = now(); + } else { + float t = std::clamp(-d / (pixelSize(p) * 120.0f), 0.0f, 0.99f); + if (snapping) t = snapTo(t, 0.05f); + if (auto v = typed()) t = std::clamp(*v / 100.0f, 0.0f, 0.99f); + insetFaces(o->mesh, selFaces_, t); + insetAmount_ = t; + message_ = std::format("Inset {:.0f}%", t * 100), messageAt_ = now(); + } + changedShape(*o, true); + return; + } + if (drag_.tool == Tool::Bevel) { + Object* o = editing(); + if (!o) return; + float w = std::max(0.0f, io.MousePos.x - drag_.startMouse.x) * pixelSize(p) * 0.5f; + if (snapping) w = snapTo(w, 0.005f); + if (auto v = typed()) w = std::max(0.0f, *v); + if (w > 0) bevelEdges(o->mesh, drag_.bevelEdges, w, bevelSteps_); + bevelWidth_ = w; + handMade(*o); + touched(o->id); + message_ = std::format("Round edges {:.3f} m, {} step{}", w, bevelSteps_, bevelSteps_ == 1 ? "" : "s"), messageAt_ = now(); + return; + } + glm::vec3 objectScale(1.0f); + float even = 1.0f; + if (drag_.tool == Tool::Move) { + glm::vec3 delta(0.0f); + if (drag_.handle < 3) { + float t = closestOnAxis(ray, p, axes[size_t(drag_.handle)]) - drag_.startT; + if (snapping && !snapKind_) t = snapTo(t, snapMove_); + if (snapping && snapKind_) + if (auto at = snapTarget(io.MousePos)) t = glm::dot(*at - p, axes[size_t(drag_.handle)]); // (as far along the arrow as it is) + if (auto v = typed()) t = *v; + delta = axes[size_t(drag_.handle)] * t; + } else if (auto hit = hitPlane(ray, p, -viewDir())) { + delta = *hit - drag_.startHit; + if (snapping && !snapKind_) delta = glm::vec3(snapTo(delta.x, snapMove_), snapTo(delta.y, snapMove_), snapTo(delta.z, snapMove_)); + if (snapping && snapKind_) + if (auto at = snapTarget(io.MousePos)) delta = *at - p; // (the arrows' point onto it) + } + T = glm::translate(glm::mat4(1.0f), delta); + message_ = std::format("Move {:.3f}, {:.3f}, {:.3f} m", delta.x, delta.y, delta.z), messageAt_ = now(); + } else if (drag_.tool == Tool::Rotate) { + ImVec2 c; + if (!project(p, c)) return; + const float a0 = std::atan2(drag_.startMouse.y - c.y, drag_.startMouse.x - c.x), a1 = std::atan2(io.MousePos.y - c.y, io.MousePos.x - c.x); + float deg = glm::degrees(std::remainder(a1 - a0, 2 * kPi)); + const glm::vec3 axis = drag_.handle < 3 ? axes[size_t(drag_.handle)] : -viewDir(); + if (glm::dot(axis, viewDir()) < 0) deg = -deg; // (turning the way the mouse goes round, as seen) + if (snapping) deg = snapTo(deg, snapAngle_); + if (auto v = typed()) deg = *v; + T = glm::translate(glm::mat4(1.0f), p) * glm::rotate(glm::mat4(1.0f), glm::radians(deg), axis) * glm::translate(glm::mat4(1.0f), -p); + message_ = std::format("Rotate {:.1f} degrees", deg), messageAt_ = now(); + } else if (drag_.tool == Tool::Scale) { + glm::vec3 s(1.0f); + // Shift (or Alt) held: from both sides, the middle staying put. + const bool both = (io.KeyShift || io.KeyAlt) && drag_.handle != 3; + if (drag_.handle != 3) { + const int k = drag_.handle < 3 ? drag_.handle : drag_.handle - 4; + const float from = both ? drag_.startMid : drag_.startDist; + float dist = from + closestOnAxis(ray, drag_.handlePoint, axes[size_t(k)]); + if (snapping) dist = both ? snapTo(dist * 2, snapMove_) / 2 : snapTo(dist, snapMove_); + if (auto v = typed()) dist = (from < 0 ? -1.0f : 1.0f) * std::abs(*v) / (both ? 2.0f : 1.0f); // (the size it's to be, m) + const float f = std::abs(from) > 1e-6f ? dist / from : 1.0f; + s[k] = f; + message_ = std::format("Scale {:.2f} ({:.3f} m){}", f, std::abs(dist) * (both ? 2 : 1), both ? " from both sides" : " (Shift: from both sides)"), + messageAt_ = now(); + } else { + even = std::max(0.01f, 1.0f + ((io.MousePos.x - drag_.startMouse.x) - (io.MousePos.y - drag_.startMouse.y)) / 200.0f); + if (snapping) even = std::max(snapScale_, snapTo(even, snapScale_)); + if (auto v = typed()) even = std::max(0.001f, *v); + s = glm::vec3(even); + message_ = std::format("Scale {:.2f} (drag right or up to grow)", even), messageAt_ = now(); + } + const glm::mat3 basis(axes[0], axes[1], axes[2]); + const glm::vec3 a = both ? drag_.pivot : drag_.anchor; + T = glm::translate(glm::mat4(1.0f), a) * glm::mat4(basis) * glm::scale(glm::mat4(1.0f), s) * glm::mat4(glm::transpose(basis)) * glm::translate(glm::mat4(1.0f), -a); + objectScale = s; + } + if (Object* o = editing()) { + const glm::mat4 m = objectMatrix(*o), local = glm::inverse(m) * T * m; + for (int v : selectedPoints()) o->mesh.verts[size_t(v)] = glm::vec3(local * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)); + for (const auto& [v, w] : drag_.soft) { // (part of the way, by how near) + glm::vec3& p = o->mesh.verts[size_t(v)]; + p = glm::mix(p, glm::vec3(local * glm::vec4(p, 1.0f)), w); + } + handMade(*o); + touched(o->id); + return; + } + for (Object& o : scene_.objects) { + const bool along = !selected_.count(o.id) && o.cuts && selected_.count(o.cuts) && isCutter(o); // (an editable cutter goes with what it cuts) + if ((!selected_.count(o.id) && !along) || o.locked) continue; + o.position = glm::vec3(T * glm::vec4(o.position, 1.0f)); + if (drag_.tool == Tool::Rotate) o.rotation = glm::normalize(glm::quat_cast(glm::mat3(T)) * o.rotation); + if (drag_.tool == Tool::Scale) { + // On its own axes: the gizmo's where they're the same, else how much each of its + // axes lies along each of the gizmo's. + for (int j = 0; j < 3; ++j) { + const glm::vec3 own = o.rotation * glm::vec3(j == 0, j == 1, j == 2); + float f = 1.0f; + for (int k = 0; k < 3; ++k) f += (objectScale[k] - 1.0f) * glm::dot(own, axes[size_t(k)]) * glm::dot(own, axes[size_t(k)]); + o.scale[j] *= f; + } + } + touched(o.id); + } +} + +// ───────────────────────────── 3D ───────────────────────────── + +// Solid: a plain grey room and soft studio light, no shadows or effects — for seeing shapes. +// Rendered: sky, sun, shadows and effects. +auto App::applyShading() -> void { + const Look& k = scene_.look; + const std::string want = + shading() == Shading::Rendered + ? std::format("r_ssao {}; r_bloom {}; r_vignette {}; r_exposure {}; r_saturation {}; r_sharpen {}; r_shadowsoft {}; r_gi {}; r_fog 0; r_skycolor \"{} {} {}\"", k.ao, + k.bloom, k.vignette, k.exposure, k.saturation, k.sharpen, k.shadowSoft, kRayLight, k.background.r, k.background.g, k.background.b) + : std::string("r_ssao 0; r_bloom 0; r_vignette 0; r_exposure 1; r_saturation 1; r_sharpen 0; r_gi 0; r_fog 0; r_skycolor \"0.2 0.2 0.215\""); + if (want == appliedLook_) return; + appliedLook_ = want; + keel::execute(want); +} + +auto App::modelOf(const Object& o) -> keel::ModelId { + const bool editingThis = mode_ == Mode::Edit && o.id == active_; + const Mesh& mesh = shownMesh(o); + const uint64_t want = (meshKey(o, mesh) * 2 + editingThis) * 2 + painted(o); + auto it = models_.find(o.id); + if (it != models_.end() && it->second.second == want) return it->second.first; + keel::KmdlModel km; + keel::KmdlMaterial mat{}; + mat.baseColor[0] = mat.baseColor[1] = mat.baseColor[2] = mat.baseColor[3] = 1.0f; + const bool withPicture = painted(o); + if (withPicture) std::snprintf(mat.texture, sizeof mat.texture, "fry:paint:%u", o.id); + km.materials.push_back(mat); + const auto normals = cornerNormals(mesh, o.smooth, o.smoothAngle); + for (size_t f = 0; f < mesh.faces.size(); ++f) { + const auto& face = mesh.faces[f]; + for (const auto& tri : triangulate(mesh, int(f))) + for (int k = 0; k < 3; ++k) { + const size_t corner = size_t(std::find(face.begin(), face.end(), tri[size_t(k)]) - face.begin()); + const glm::vec3 p = mesh.verts[size_t(tri[size_t(k)])], n = normals[f][corner]; + km.indices.push_back(uint32_t(km.vertices.size())); + const glm::vec2 uv = withPicture ? o.uvs[f][corner] : glm::vec2(0.0f); + km.vertices.push_back({{p.x, p.y, p.z}, {n.x, n.y, n.z}, {uv.x, uv.y}, {0, 0}}); + } + } + if (km.vertices.empty()) km.vertices.push_back({}), km.indices = {0, 0, 0}; + km.submeshes.push_back({0, uint32_t(km.indices.size()), 0}); + const glm::vec3 lo = boundsMin(mesh), hi = boundsMax(mesh); + std::copy_n(&lo.x, 3, km.header.boundsMin); + std::copy_n(&hi.x, 3, km.header.boundsMax); + const keel::ModelId id = r_->updateModel(std::format("fry:object{}", o.id), km); + models_[o.id] = {id, want}; + return id; +} + +auto App::picture(const Reference& ref) -> const Picture& { + auto it = pictures_.find(ref.path); + if (it != pictures_.end()) return it->second; + Picture& p = pictures_[ref.path]; + int w = 0, h = 0, n = 0; + if (stbi_uc* px = stbi_load(ref.path.c_str(), &w, &h, &n, 4)) { + r_->createTexture("fry:picture:" + ref.path, uint32_t(w), uint32_t(h), px); + stbi_image_free(px); + p.ok = true, p.aspect = float(h) / float(std::max(w, 1)); + } else { + say("Couldn't load the picture " + ref.path); + } + return p; +} + +auto App::loadLogo() -> void { + if (logoTried_) return; + logoTried_ = true; + if (auto png = keel::fs::read("branding/logo.png")) { + int w = 0, h = 0, n = 0; + if (stbi_uc* px = stbi_load_from_memory(png->data(), int(png->size()), &w, &h, &n, 4)) { + logo_ = r_->createTexture("fry:logo", uint32_t(w), uint32_t(h), px); + stbi_image_free(px); + haveLogo_ = true; + } + } +} + +auto App::logoImage(float size) -> void { + if (!haveLogo_) return; + const ImVec2 at = ImGui::GetCursorScreenPos(); + ImGui::Dummy(ImVec2(size, size)); + uiImage(ImGui::GetWindowDrawList(), logo_, at, ImVec2(at.x + size, at.y + size)); +} + +// A picture of ours in the UI, read the way ImGui's colours are (not turned linear: too dark). +auto App::uiImage(ImDrawList* dl, keel::TextureId t, ImVec2 a, ImVec2 b) -> void { + const keel::UiRawRead raw = r_->uiRawRead(t); + if (raw.begin) dl->AddCallback(raw.begin, raw.data); + dl->AddImage(ImTextureID(r_->uiTexture(t)), a, b); + if (raw.end) dl->AddCallback(raw.end, raw.data); +} + +auto App::draw3D(keel::Renderer& r) -> void { + settleView(); + followCuts(); + keel::Camera cam; + cam.origin = eye_ * kUnits, cam.yaw = yaw_, cam.pitch = pitch_, cam.fovY = fov_, cam.shift = shift_, cam.zNear = nearFar_.x, cam.zFar = nearFar_.y; + r.setCamera(cam); + const Look& look = scene_.look; + const bool rendered = shading() == Shading::Rendered; + const float turn = glm::radians(look.sunTurn), up = glm::radians(look.sunHeight); + r.setSun(rendered && look.sunStrength > 0, glm::vec3(std::cos(up) * std::cos(turn), std::cos(up) * std::sin(turn), std::sin(up)), look.sunColor * look.sunStrength, true); + keel::Renderer::Atmosphere atm; + atm.sky = rendered && look.sky ? "skies/day.ksky" : ""; + atm.fogDensity = 0.0f; + r.setAtmosphere(atm); + // Rendered: light from the sky all round. Solid: a studio's — bright from above and the + // front-left, darker below, so every side of a shape reads differently. + const keel::ModelLighting light = + rendered ? keel::ModelLighting{{{0.22f, 0.22f, 0.24f}, {0.13f, 0.13f, 0.15f}, {0.2f, 0.2f, 0.22f}, {0.16f, 0.16f, 0.18f}, {0.3f, 0.31f, 0.35f}, {0.06f, 0.06f, 0.06f}}, 1.0f} + : keel::ModelLighting{{{0.5f, 0.49f, 0.47f}, {0.2f, 0.21f, 0.24f}, {0.72f, 0.71f, 0.69f}, {0.3f, 0.31f, 0.34f}, {0.95f, 0.95f, 0.95f}, {0.08f, 0.08f, 0.09f}}, 0.0f}; + const glm::mat4 toUnits = glm::scale(glm::mat4(1.0f), glm::vec3(kUnits)); + // Painted pictures: laid out again if the shape's faces changed, and sent to the card. + for (Object& o : scene_.objects) + if (o.picture && !o.uvsFit() && !o.hasFinish()) o.uvs = autoUnwrap(o.mesh), touched(o.id); + for (const Object& o : scene_.objects) + if (o.picture) uploadPicture(o, !(paint_.active && paint_.object == o.id)); + if (shading() != Shading::Wire) + for (const Object& o : scene_.objects) { + if (!o.visible || isCutter(o)) continue; + const glm::vec4 tint = painted(o) ? glm::vec4(1.0f) : glm::vec4(glm::pow(o.color, glm::vec3(2.2f)), 1.0f); // (colours are picked as seen: sRGB) + const keel::Surface surface{o.metal, o.rough}; + r.drawModel(modelOf(o), toUnits * objectMatrix(o), tint, nullptr, &light, rendered, nullptr, keel::LiquidStyle::None, &surface); + } + // Editable cutters: see-through, so the hole they make shows (not in a saved picture). + if (shading() != Shading::Wire && !capturing()) + for (const Object& o : scene_.objects) + if (o.visible && isCutter(o)) r.drawModel(modelOf(o), toUnits * objectMatrix(o), glm::vec4(0.9f, 0.35f, 0.3f, 0.3f), nullptr, &light, false); + // Rendered: a ground to stand on and catch the shadows (not part of what you make). + if (rendered && look.ground) { + if (!haveGround_) { + haveGround_ = true; + 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); + const float h = 500.0f; + for (glm::vec2 c : {glm::vec2(-h, -h), glm::vec2(h, -h), glm::vec2(h, h), glm::vec2(-h, h)}) km.vertices.push_back({{c.x, c.y, -0.0005f}, {0, 0, 1}, {0, 0}, {0, 0}}); + km.indices = {0, 1, 2, 0, 2, 3}; + km.submeshes.push_back({0, 6, 0}); + ground_ = r.updateModel("fry:ground", km); + } + r.drawModel(ground_, toUnits, glm::vec4(glm::pow(look.groundColor, glm::vec3(2.2f)), 1.0f), nullptr, &light, false); + } + // Reference pictures: flat, see-through, a little behind the plane through the middle. + for (const Reference& ref : scene_.references) { + if (!ref.visible || (ref.ownViewOnly && !lookingAt(ref.axis))) continue; + const Picture& pic = picture(ref); + if (!pic.ok) continue; + const auto c = referenceCorners(ref, pic.aspect); + const glm::vec3 n = glm::normalize(glm::cross(c[1] - c[0], c[3] - c[0])); + keel::KmdlModel km; + keel::KmdlMaterial mat{}; + std::snprintf(mat.texture, sizeof mat.texture, "fry:picture:%s", ref.path.c_str()); + mat.baseColor[0] = mat.baseColor[1] = mat.baseColor[2] = 1.0f, mat.baseColor[3] = 1.0f; + km.materials.push_back(mat); + const glm::vec2 uv[4] = {{0, 1}, {1, 1}, {1, 0}, {0, 0}}; + for (int side = 0; side < 2; ++side) // (both sides: seen from behind too) + for (int k : {0, 1, 2, 0, 2, 3}) { + const int i = side ? (3 - k + 1) % 4 : k; + const glm::vec3 p = c[size_t(i)] - n * 0.001f, nn = side ? -n : n; + km.indices.push_back(uint32_t(km.vertices.size())); + km.vertices.push_back({{p.x, p.y, p.z}, {nn.x, nn.y, nn.z}, {uv[i].x, uv[i].y}, {0, 0}}); + } + km.submeshes.push_back({0, uint32_t(km.indices.size()), 0}); + const keel::ModelId id = r.updateModel(std::format("fry:ref{}", ref.id), km); + 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}; + r.drawModel(id, toUnits, glm::vec4(1.0f, 1.0f, 1.0f, ref.opacity), nullptr, &flat, false); + } + // The grid on the work plane (the ground, or what a flat view looks at), and the axes. + if (tab_ == Tab::Render ? renderGrid_ : showGrid_) { + glm::vec3 p, n; + workPlane(p, n); + const glm::vec3 u = n.z > 0.5f ? glm::vec3(1, 0, 0) : n.x > 0.5f ? glm::vec3(0, 1, 0) : glm::vec3(1, 0, 0); + const glm::vec3 v = glm::cross(n, u); + const float step = gridStep(); + const int count = 20; + const glm::vec3 centre = target_ - n * glm::dot(target_, n); + const float shade = rendered ? 0.0f : 1.0f; // (dark lines on the sunny ground, light ones in the grey room) + // Two rings: the fine lines round where you look, then lines ten times apart out to + // the horizon (they leave out the middle, so no line is drawn twice). + for (int level = 0; level < 2; ++level) { + const float s = step * (level ? 10.0f : 1.0f), inner = count * step; // (the fine ring's reach) + const float cu = snapTo(glm::dot(centre, u), step * 10), cv = snapTo(glm::dot(centre, v), step * 10); + const float fade = level ? 0.6f : 1.0f; + for (int i = -count; i <= count; ++i) + for (int dir = 0; dir < 2; ++dir) { + const glm::vec3 along = dir ? u : v, across = dir ? v : u; + const float at = (dir ? cv : cu) + float(i) * s, mid = dir ? cu : cv; + const bool major = std::abs(snapTo(at, s * 10) - at) < s * 0.01f; + const bool axis = std::abs(at) < s * 0.01f; + glm::vec4 col = glm::vec4(glm::vec3(shade), (major ? (rendered ? 0.35f : 0.13f) : (rendered ? 0.16f : 0.06f)) * fade); + if (axis) col = dir ? (n.x > 0.5f ? glm::vec4(0.3f, 0.75f, 0.25f, 0.9f) : glm::vec4(0.9f, 0.25f, 0.25f, 0.9f)) + : (n.z > 0.5f ? glm::vec4(0.3f, 0.75f, 0.25f, 0.9f) : glm::vec4(0.25f, 0.45f, 0.95f, 0.9f)); + const float w = pixelSize(target_) * (major || axis ? 1.6f : 1.0f) * kUnits; + const float from = mid - count * s, to = mid + count * s; + const glm::vec3 a = across * at + along * from, b = across * at + along * to; + if (level == 0 || std::abs(at - (dir ? cv : cu)) > inner * 1.0001f) r.drawStreak(a * kUnits, b * kUnits, w, col, keel::kWhiteTexture); + else { // (a far line through the fine ring, which has it already: its two ends) + r.drawStreak(a * kUnits, (across * at + along * (mid - inner)) * kUnits, w, col, keel::kWhiteTexture); + r.drawStreak((across * at + along * (mid + inner)) * kUnits, b * kUnits, w, col, keel::kWhiteTexture); + } + } + } + } + if (tab_ == Tab::Print) drawPrintBed(r, light); +} + +// (the Print tab's: the printer's plate and overhangs) +// On top of the scene: outlines and wires, points, the gizmo, the selection shapes. +// An empty scene: how to start, in the middle of the view (gone once there's anything). +auto App::emptyHints(ImDrawList* dl) -> void { + if (!scene_.objects.empty() || capturing() || tab_ != Tab::Model) return; + const char* lines[] = {touch() ? "Nothing here yet: tap + to add a shape" : "Nothing here yet: Add (menu) for a shape, or draw one (D)", + touch() ? "Hold a finger on the view for more" : "F3 or Ctrl+Space: find any command by name", + touch() ? "" : "Space: tools round the mouse Shift+Space: views", + touch() ? "" : "File > Import: STL, OBJ, 3MF, glTF from elsewhere"}; + const float lh = ImGui::GetTextLineHeightWithSpacing() * 1.25f; + float y = (viewMin_.y + viewMax_.y) * 0.5f - lh * 2, widest = 0; + int shown = 0; + for (const char* l : lines) widest = std::max(widest, ImGui::CalcTextSize(l).x), shown += *l != 0; + const float cx = (viewMin_.x + viewMax_.x) * 0.5f; + dl->AddRectFilled(ImVec2(cx - widest * 0.5f - 18, y - 12), ImVec2(cx + widest * 0.5f + 18, y + lh * float(shown) + 6), IM_COL32(28, 28, 32, 200), 8.0f); + for (int i = 0; i < 4; ++i, y += lh) { + if (!*lines[i]) continue; + const ImVec2 size = ImGui::CalcTextSize(lines[i]); + dl->AddText(ImVec2((viewMin_.x + viewMax_.x - size.x) * 0.5f, y), i == 0 ? IM_COL32(230, 230, 235, 200) : IM_COL32(200, 200, 205, 130), lines[i]); + } +} + +auto App::overlays() -> void { + ImDrawList* dl = ImGui::GetBackgroundDrawList(); + dl->PushClipRect(viewMin_, viewMax_); + const ImGuiIO& io = ImGui::GetIO(); + emptyHints(dl); + // Every edge of a mesh (X-ray, wireframe), or just the edges the eye can see. + auto lines = [&](const Object& o, const Mesh& mesh, ImU32 col, float width, bool seenOnly) { + const glm::mat4 m = objectMatrix(o); + const bool hide = seenOnly && !xray_; + std::vector s(mesh.verts.size()); + std::vector ok(mesh.verts.size()); + for (size_t i = 0; i < s.size(); ++i) ok[i] = project(glm::vec3(m * glm::vec4(mesh.verts[i], 1.0f)), s[i]); + for (const auto& e : adjacencyOf(o, mesh).edges) { + if (!ok[size_t(e[0])] || !ok[size_t(e[1])] || (hide && !(seen(o, mesh, e[0]) && seen(o, mesh, e[1])))) continue; + dl->AddLine(s[size_t(e[0])], s[size_t(e[1])], col, width); + } + }; + // The outline round a selected object (where it turns away from you, and open edges). + auto outline = [&](const Object& o, ImU32 col, float width) { + const Mesh& mesh = shownMesh(o); + const glm::mat4 m = objectMatrix(o); + const Adjacency& adj = adjacencyOf(o, mesh); + // (which faces look at the eye: the eye taken into the object's own space, no point moved) + const glm::vec3 eye = glm::vec3(glm::inverse(m) * glm::vec4(eye_, 1.0f)); + std::vector facing(adj.plane.size()); + for (size_t f = 0; f < facing.size(); ++f) facing[f] = glm::dot(glm::vec3(adj.plane[f]), eye) > adj.plane[f].w; + for (const auto& e : adj.edges) { + const bool silhouette = e[3] >= 0 && facing[size_t(e[2])] != facing[size_t(e[3])], open = e[3] < 0 && facing[size_t(e[2])]; + if (!silhouette && !open) continue; + if (!xray_ && !(seen(o, mesh, e[0]) && seen(o, mesh, e[1]))) continue; // (not the edges of a pocket behind its rim) + ImVec2 a, b; + if (project(glm::vec3(m * glm::vec4(mesh.verts[size_t(e[0])], 1.0f)), a) && project(glm::vec3(m * glm::vec4(mesh.verts[size_t(e[1])], 1.0f)), b)) dl->AddLine(a, b, col, width); + } + }; + const bool clean = capturing(); // (a picture being saved: no outlines or gizmos) + Object* ed = editing(); + for (const Object& o : scene_.objects) { + if (!o.visible) continue; + const bool sel = selected_.count(o.id) > 0 && !clean && tab_ != Tab::Paint; + if (ed && o.id == ed->id) continue; + if (isCutter(o)) { + if (!clean) lines(o, shownMesh(o), sel ? kAccent : IM_COL32(235, 95, 85, 150), sel ? 1.6f : 1.0f, false); + continue; + } + if (shading() == Shading::Wire || xray_) lines(o, shownMesh(o), sel ? kAccent : IM_COL32(200, 200, 205, 150), sel ? 1.6f : 1.0f, false); + else if (sel) outline(o, kAccent, 2.5f); + } + if (tab_ == Tab::Paint) { + if (Object* so = find(ledit_.object); so && editLayer() && editLayer()->sticker) { // the sticker's frame and handles + std::array hw; + ImVec2 q[6]; + bool ok = stickerHandles(*so, *editLayer(), hw); + for (int i = 0; ok && i < 6; ++i) ok = project(hw[size_t(i)], q[i]); + if (ok) { + const int hot = ledit_.drag >= 0 ? ledit_.drag : stickerHandle(*so, *editLayer(), io.MousePos); + dl->AddPolyline(q, 4, hot == 4 ? kHot : kAccent, ImDrawFlags_Closed, 2.0f); + const ImVec2 top((q[0].x + q[1].x) * 0.5f, (q[0].y + q[1].y) * 0.5f); + dl->AddLine(top, q[4], kAccent, 1.5f); + dl->AddCircleFilled(q[4], 7.0f, hot == 5 ? kHot : IM_COL32(255, 255, 255, 240)); + dl->AddCircle(q[4], 7.0f, IM_COL32(20, 20, 25, 255)); + for (int i = 0; i < 4; ++i) { + dl->AddRectFilled(ImVec2(q[i].x - 6, q[i].y - 6), ImVec2(q[i].x + 6, q[i].y + 6), hot == i ? kHot : IM_COL32(255, 255, 255, 240)); + dl->AddRect(ImVec2(q[i].x - 6, q[i].y - 6), ImVec2(q[i].x + 6, q[i].y + 6), IM_COL32(20, 20, 25, 255)); + } + } + } + if (stencil_.size() >= 2) { // (the stencil's edge; where paint can't go, shaded) + dl->AddPolyline(stencil_.data(), int(stencil_.size()), stencilOn_ || drawingStencil_ ? kAccent : IM_COL32(200, 200, 205, 120), drawingStencil_ ? 0 : ImDrawFlags_Closed, 2.0f); + if (stencilOn_ && !drawingStencil_ && stencil_.size() >= 3) dl->AddConcavePolyFilled(stencil_.data(), int(stencil_.size()), IM_COL32(255, 160, 90, 28)); + } + if (sculpting_) { + if (mouseInView(io)) dl->AddCircle(io.MousePos, sculptPx_, io.KeyCtrl ? IM_COL32(120, 180, 255, 220) : IM_COL32(255, 255, 255, 200), 40, 1.5f); + } else if (mouseInView(io) && !editLayer() && paintTool_ != PaintTool::Picture) + dl->AddCircle(io.MousePos, brushPx_, paintTool_ == PaintTool::Fill || paintTool_ == PaintTool::Picker ? IM_COL32(255, 255, 255, 90) : IM_COL32(255, 255, 255, 200), 40, 1.5f); + dl->PopClipRect(); + return; + } + if (tab_ == Tab::Layout) { + layoutOverlay(dl); + dl->PopClipRect(); + return; + } + if ((tab_ == Tab::Print || printTool_ == PrintTool::Measure) && !clean) printOverlay(dl, io); + if (tab_ == Tab::Print && showLayer_ && !clean) layerOverlay(dl); + if (clean) { + dl->PopClipRect(); + return; + } + if (ed) { + if (ed->hasFinish() && ed->mirror && shading() != Shading::Wire) // (the mirrored half: faint) + lines(*ed, shownMesh(*ed), IM_COL32(20, 20, 25, 70), 1.0f, false); + lines(*ed, ed->mesh, shading() == Shading::Wire ? IM_COL32(230, 230, 235, 230) : IM_COL32(12, 12, 15, 235), 1.3f, true); + } + if (ed && !ed->creases.empty()) { // (creased edges: smoothing keeps them sharp) + const glm::mat4 m = objectMatrix(*ed); + const int n = int(ed->mesh.verts.size()); + for (auto [a, b] : ed->creases) { + ImVec2 sa, sb; + if (a < n && b < n && (xray_ || (pointSeen(*ed, a) && pointSeen(*ed, b))) && project(glm::vec3(m * glm::vec4(ed->mesh.verts[size_t(a)], 1.0f)), sa) && + project(glm::vec3(m * glm::vec4(ed->mesh.verts[size_t(b)], 1.0f)), sb)) + dl->AddLine(sa, sb, IM_COL32(230, 70, 200, 255), 2.5f); + } + } + if (Object* o = ed) { + const glm::mat4 m = objectMatrix(*o); + if (elem_ == Elem::Vertex) { + for (size_t v = 0; v < o->mesh.verts.size(); ++v) { + ImVec2 s; + const bool sel = selVerts_.count(int(v)); + if (!project(glm::vec3(m * glm::vec4(o->mesh.verts[v], 1.0f)), s) || (!sel && !pointSeen(*o, int(v)))) continue; + dl->AddRectFilled(ImVec2(s.x - 3, s.y - 3), ImVec2(s.x + 3, s.y + 3), sel ? kAccent : IM_COL32(15, 15, 18, 255)); + } + } else if (elem_ == Elem::Edge) { + for (auto [a, b] : selEdges_) { + ImVec2 sa, sb; + if (a < int(o->mesh.verts.size()) && b < int(o->mesh.verts.size()) && project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(a)], 1.0f)), sa) && + project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(b)], 1.0f)), sb)) + dl->AddLine(sa, sb, kAccent, 3.0f); + } + } else { // the selected faces the eye can see: an orange skin, a dot in the middle + const glm::mat3 nm = glm::transpose(glm::inverse(glm::mat3(m))); + for (int f : selFaces_) { + if (f >= int(o->mesh.faces.size())) continue; + const glm::vec3 c(m * glm::vec4(faceCenter(o->mesh, f), 1.0f)); + const bool front = glm::dot(nm * faceNormal(o->mesh, f), eye_ - c) > 0; + if (!xray_ && !front) continue; + const auto& fv = o->mesh.faces[size_t(f)]; + if (!xray_ && !std::all_of(fv.begin(), fv.end(), [&](int v) { return pointSeen(*o, v); }) && !visible(*o, c)) continue; + std::vector poly(fv.size()); + bool ok = true; + for (size_t k = 0; k < fv.size(); ++k) ok &= project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(fv[k])], 1.0f)), poly[k]); + if (ok) dl->AddConcavePolyFilled(poly.data(), int(poly.size()), IM_COL32(255, 120, 40, 70)); + ImVec2 s; + if (project(c, s)) dl->AddCircleFilled(s, 3.5f, kAccent); + } + } + // The loop cut to be: across each face of the ring. + if (tool_ == Tool::LoopCut && cutHover_.first >= 0) + for (const auto& q : ringQuads(o->mesh, cutHover_)) + for (int c = 1; c <= cuts_; ++c) { + const float t = float(c) / float(cuts_ + 1); + const glm::vec3 a = glm::mix(o->mesh.verts[size_t(q[0])], o->mesh.verts[size_t(q[1])], t), b = glm::mix(o->mesh.verts[size_t(q[3])], o->mesh.verts[size_t(q[2])], t); + ImVec2 sa, sb; + if (project(glm::vec3(m * glm::vec4(a, 1.0f)), sa) && project(glm::vec3(m * glm::vec4(b, 1.0f)), sb)) dl->AddLine(sa, sb, kHot, 2.5f); + } + } + // The knife's line: its points, on to the mouse; cutting through, on past the ends. + if (tool_ == Tool::Knife && (!knifePts_.empty() || knifeDown_)) { + std::vector pts = knifePts_; + if (knifePts_.empty()) pts.push_back(knifePress_); + if (mouseInView(io) || knifeDown_) pts.push_back(knifeEnd(io)); + for (size_t i = 0; i + 1 < pts.size(); ++i) dl->AddLine(pts[i], pts[i + 1], kHot, 2.0f); + if (knifeThrough_ && pts.size() >= 2) + for (int end = 0; end < 2; ++end) { + const ImVec2 a = end ? pts.back() : pts.front(), b = end ? pts[pts.size() - 2] : pts[1]; + const float dx = a.x - b.x, dy = a.y - b.y, l = std::max(std::hypot(dx, dy), 1e-3f); + for (int k = 0; k < 60; k += 2) + dl->AddLine(ImVec2(a.x + dx / l * float(k) * 12, a.y + dy / l * float(k) * 12), ImVec2(a.x + dx / l * float(k + 1) * 12, a.y + dy / l * float(k + 1) * 12), + (kHot & 0x00FFFFFF) | 0x90000000, 1.5f); + } + for (const ImVec2& p : knifePts_) dl->AddCircleFilled(p, 4.0f, kHot); + } + // The hole to be. + if (tool_ == Tool::Hole && (mouseInView(io) || hole_.active)) { + const auto ring = holePreview(io); + std::vector pts; + for (const glm::vec3& w : ring) { + ImVec2 sp; + if (project(w, sp)) pts.push_back(sp); + } + if (pts.size() >= 3) dl->AddPolyline(pts.data(), int(pts.size()), hole_.active ? kHot : IM_COL32(255, 255, 255, 200), ImDrawFlags_Closed, 2.0f); + } + if (boxing_ && !touchOrbit_ && std::hypot(io.MousePos.x - boxStart_.x, io.MousePos.y - boxStart_.y) > 4) { + dl->AddRectFilled(boxStart_, io.MousePos, IM_COL32(255, 140, 60, 30)); + dl->AddRect(boxStart_, io.MousePos, kAccent); + } + if (lassoing_ && lasso_.size() > 1) dl->AddPolyline(lasso_.data(), int(lasso_.size()), kAccent, ImDrawFlags_Closed, 1.5f); + if (selShape_ == SelShape::Brush && tool_ == Tool::Select && mode_ == Mode::Edit && mouseInView(io)) + dl->AddCircle(io.MousePos, brushRadius_, painting_ ? kAccent : IM_COL32(255, 255, 255, 160), 32, 1.5f); + if (tool_ == Tool::Draw) { + ImVec2 prev, s; + for (size_t i = 0; i < drawPts_.size(); ++i) { + if (!project(drawPts_[i], s)) continue; + if (i) dl->AddLine(prev, s, kAccent, 2.0f); + dl->AddCircleFilled(s, i == 0 ? 6.0f : 4.0f, i == 0 ? IM_COL32(255, 255, 255, 255) : kAccent); + prev = s; + } + if (mouseInView(io) && !touch()) // (a finger isn't hovering anywhere) + if (auto p = drawPoint(io); p && project(*p, s)) { + if (!drawPts_.empty()) dl->AddLine(prev, s, IM_COL32(255, 160, 90, 180), 1.5f); + dl->AddCircle(s, 5.0f, IM_COL32(255, 255, 255, 220), 0, 1.5f); + if (!drawPts_.empty()) { // (the line's length by the mouse, and what's typed) + const std::string label = std::format("{:.4g} m{}", glm::distance(drawPts_.back(), *p), sketchTyped_.empty() ? " (type a length, Enter; Shift: 15 deg steps)" : " typed " + sketchTyped_); + const ImVec2 at(io.MousePos.x + 16, io.MousePos.y + 14), size = ImGui::CalcTextSize(label.c_str()); + dl->AddRectFilled(ImVec2(at.x - 5, at.y - 3), ImVec2(at.x + size.x + 5, at.y + size.y + 3), IM_COL32(24, 24, 28, 225), 4.0f); + dl->AddText(at, sketchTyped_.empty() ? IM_COL32(235, 235, 240, 255) : kAccent, label.c_str()); + } + } + } + gizmo(dl, io); + dl->PopClipRect(); +} + +auto App::gizmo(ImDrawList* dl, const ImGuiIO& io) -> void { + const int hot = mouseInView(io) || drag_.active ? hoverHandle(io.MousePos) : -1; + if (softSel_ && mode_ == Mode::Edit && (tool_ == Tool::Move || tool_ == Tool::Rotate || tool_ == Tool::Scale) && !selectedPoints().empty()) { + const glm::vec3 at = drag_.active ? drag_.pivot : pivotOfSelection(); + ImVec2 c; + if (project(at, c)) dl->AddCircle(c, softReach_ / std::max(pixelSize(at), 1e-9f), keel::ui::accent(0.8f), 64, 1.5f); + } + if (tool_ == Tool::Extrude || tool_ == Tool::Inset) { + glm::vec3 at, n; + if (!faceHandle(at, n)) return; + ImVec2 a, b; + if (!project(at, a) || !project(at + n * pixelSize(at) * 90.0f, b)) return; + const ImU32 col = hot == 0 ? kHot : IM_COL32(255, 255, 255, 255); + dl->AddLine(a, b, col, 3.0f); + if (tool_ == Tool::Extrude) dl->AddCircleFilled(b, 7.0f, col); + else dl->AddRectFilled(ImVec2(b.x - 6, b.y - 6), ImVec2(b.x + 6, b.y + 6), col); + return; + } + if (tool_ == Tool::Bevel) { + ImVec2 c, h; + if (!bevelHandle(c, h)) return; + const ImU32 col = hot == 0 ? kHot : IM_COL32(255, 255, 255, 255); + dl->AddLine(c, h, (col & 0x00FFFFFF) | 0x90000000, 2.0f); + dl->AddCircleFilled(h, 9.0f, col); + drawIcon(dl, Icon::Bevel, ImVec2(h.x - 7, h.y - 7), 14.0f, IM_COL32(30, 30, 35, 255)); + return; + } + if (tool_ != Tool::Move && tool_ != Tool::Rotate && tool_ != Tool::Scale) return; + if (!hasSelection()) return; + const Axes axes = drag_.active ? drag_.axes : gizmoAxes(); + if (tool_ == Tool::Scale) { // the box round the selection, a handle on each side + const auto [lo, hi] = selectionBox(axes); + for (int e = 0; e < 12; ++e) { + const int k = e / 4, u = (k + 1) % 3, v = (k + 2) % 3; + glm::vec3 a, b; + a[k] = lo[k], b[k] = hi[k]; + a[u] = b[u] = e & 1 ? hi[u] : lo[u]; + a[v] = b[v] = e & 2 ? hi[v] : lo[v]; + ImVec2 sa, sb; + if (project(fromFrame(axes, a), sa) && project(fromFrame(axes, b), sb)) dl->AddLine(sa, sb, IM_COL32(255, 255, 255, 110), 1.0f); + } + for (int h : {0, 1, 2, 4, 5, 6, 3}) { + ImVec2 s; + if (!project(scaleHandle(axes, lo, hi, h), s)) continue; + const ImU32 col = hot == h ? kHot : h == 3 ? IM_COL32(255, 255, 255, 235) : kAxisCol[h < 3 ? h : h - 4]; + dl->AddRectFilled(ImVec2(s.x - 6, s.y - 6), ImVec2(s.x + 6, s.y + 6), col); + dl->AddRect(ImVec2(s.x - 6, s.y - 6), ImVec2(s.x + 6, s.y + 6), IM_COL32(20, 20, 25, 200)); + } + return; + } + const glm::vec3 p = drag_.active ? (tool_ == Tool::Rotate ? drag_.pivot : selectionMiddle(axes)) : (tool_ == Tool::Rotate ? rotatePivot(axes) : selectionMiddle(axes)); + ImVec2 c; + if (!project(p, c)) return; + const float len = pixelSize(p) * 90.0f; + if (tool_ == Tool::Rotate) { + for (int k = 0; k < 3; ++k) { + const glm::vec3 u = glm::normalize(glm::cross(axes[size_t(k)], std::abs(axes[size_t(k)].z) < 0.9f ? glm::vec3(0, 0, 1) : glm::vec3(1, 0, 0))); + const glm::vec3 v = glm::cross(axes[size_t(k)], u); + ImVec2 prev; + bool havePrev = false; + for (int i = 0; i <= 64; ++i) { + const float a = 2 * kPi * float(i) / 64.0f; + const glm::vec3 w = p + (u * std::cos(a) + v * std::sin(a)) * len; + ImVec2 s; + if (!project(w, s)) { + havePrev = false; + continue; + } + const bool front = glm::dot(w - p, eye_ - p) > -0.2f * len * glm::length(eye_ - p); + if (havePrev) dl->AddLine(prev, s, hot == k ? kHot : (front ? kAxisCol[k] : (kAxisCol[k] & 0x00FFFFFF) | 0x50000000), hot == k ? 3.5f : 2.5f); + prev = s, havePrev = true; + } + } + dl->AddCircle(c, 102.0f, hot == 3 ? kHot : IM_COL32(255, 255, 255, 160), 64, 2.0f); + return; + } + for (int k = 0; k < 3; ++k) { + ImVec2 e; + if (!project(p + axes[size_t(k)] * len, e)) continue; + const ImU32 col = hot == k ? kHot : kAxisCol[k]; + dl->AddLine(c, e, col, 3.0f); + const float dx = e.x - c.x, dy = e.y - c.y, l = std::max(std::hypot(dx, dy), 1.0f); + const ImVec2 dir(dx / l, dy / l), side(-dir.y, dir.x); + dl->AddTriangleFilled(ImVec2(e.x + dir.x * 14, e.y + dir.y * 14), ImVec2(e.x + side.x * 6, e.y + side.y * 6), ImVec2(e.x - side.x * 6, e.y - side.y * 6), col); + } + dl->AddRect(ImVec2(c.x - 8, c.y - 8), ImVec2(c.x + 8, c.y + 8), hot == 3 ? kHot : IM_COL32(255, 255, 255, 230), 0, 0, 2.0f); +} + +} // namespace fryapp