Fry3DX/src/app.cpp

8602 lines
492 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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"
#include <SDL3/SDL.h>
#if defined(__ANDROID__)
#include <SDL3/SDL_main.h> // (main becomes the SDL_main SDL's Java side calls)
#endif
#include <core/cvar.h>
#include <core/sysinfo.h>
#include <core/log.h>
#include <engine.h>
#include <fs/vfs.h>
#include <render/renderer.h>
#include <render/screenshot.h>
#include <ui/ui.h>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
#include <glm/gtx/quaternion.hpp>
#include <imgui.h>
#include <imgui_internal.h>
#include <stb_image.h>
#include <stb_image_write.h>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <filesystem>
#include <format>
#include <chrono>
#include <ctime>
#include <fstream>
#include <functional>
#include <future>
#include <map>
#include <mutex>
#include <memory>
#include <optional>
#include <span>
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<glm::vec3> 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<glm::vec3>(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<ImVec2>& 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, 3>;
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<float> 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<std::pair<glm::vec3, glm::vec3>> slice;
};
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)");
keel::Cvar fry_bed_y("fry_bed_y", "220", keel::CVAR_ARCHIVE, "Fry3DX: your own printer's build plate deep (mm)");
keel::Cvar fry_bed_z("fry_bed_z", "250", keel::CVAR_ARCHIVE, "Fry3DX: how high your own printer prints (mm)");
#if defined(__ANDROID__)
keel::Cvar fry_touch("fry_touch", "1", keel::CVAR_ARCHIVE, "Fry3DX: the phone layout and touch (takes effect on the next start)");
#else
keel::Cvar fry_touch("fry_touch", "0", keel::CVAR_ARCHIVE, "Fry3DX: the phone layout and touch (takes effect on the next start)");
#endif
keel::Cvar fry_uiscale("fry_uiscale", "0", 0, "Fry3DX: draw the UI this many times bigger (0: by the screen)");
keel::Cvar fry_uisize("fry_uisize", "1", keel::CVAR_ARCHIVE, "Fry3DX: the phone layout's size, times");
keel::Cvar fry_theme("fry_theme", "0", keel::CVAR_ARCHIVE, "Fry3DX: how it looks: 0 like the desktop, 1 dark, 2 light");
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).
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<char>(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<ImVec2> 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;
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<double> 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");
#endif
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();
#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';
#endif
}
void resumeWork() {
#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");
#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();
}
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();
}
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;
struct Snapshot {
Scene scene;
std::set<uint32_t> selected;
uint32_t active;
Mode mode;
std::set<int> verts, faces;
std::set<std::pair<int, int>> edges;
std::string label; // (what was done after it: the message said then)
double at = 0;
};
std::vector<Snapshot> undo_, redo_;
std::vector<Snapshot> 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<uint32_t> 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<int> selVerts_, selFaces_;
std::set<std::pair<int, int>> 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<uint32_t, std::pair<uint64_t, float>> 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;
// ── 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<glm::vec3> 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<std::pair<int, int>> bevelEdges;
std::vector<std::pair<int, float>> 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<int> pushPts_;
Mesh pushFrom_;
uint32_t pushObject_ = 0;
bool boxing_ = false, painting_ = false, lassoing_ = false, tapDraw_ = false;
ImVec2 boxStart_{0, 0};
std::vector<ImVec2> lasso_;
std::pair<int, int> cutHover_{-1, -1};
// The knife: points clicked on the screen (or a straight drag).
std::vector<ImVec2> 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> 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<ImVec2> 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<SculptMesh> 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<std::pair<int, float>> held[2];
std::vector<glm::vec3> 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<glm::vec2, 4> 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<int> frames;
std::set<std::pair<int, int>> points;
int drag = 0; // 1 moving / turning / sizing, 2 a box
glm::vec2 grab{0.0f}, centre{0.0f};
ImVec2 boxFrom{};
std::vector<std::vector<glm::vec2>> start;
std::shared_ptr<const Image> startPicture;
double remapAt = 0;
} lay_;
LayTool layTool_ = LayTool::Move;
bool layKeepPaint_ = true, layChecker_ = false;
// The Print tab.
PrintTool printTool_ = PrintTool::None;
std::vector<glm::vec3> 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<uint32_t, PrintInfo> 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<bool> autosaveJob_;
std::vector<std::string> recover_;
bool showRecover_ = false;
std::map<uint32_t, std::vector<uint8_t>> owner_; // which layer shows at each pixel
struct Uploaded {
uint64_t stamp = 0;
double at = 0;
bool dirty = false;
};
std::map<uint32_t, Uploaded> uploaded_;
std::map<uint32_t, keel::TextureId> 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<uint32_t, std::pair<keel::ModelId, uint64_t>> models_; // object id → its model, the state built
std::map<uint32_t, uint64_t> versions_;
uint64_t versionClock_ = 1;
struct Shown {
uint64_t version = 0;
bool mirrorOnly = false;
Mesh mesh;
};
std::map<uint32_t, Shown> shown_;
struct Picture {
bool ok = false;
float aspect = 1.0f;
};
std::map<std::string, Picture> 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<fry::Plugin> p;
};
std::vector<PluginEntry> plugins_;
bool showPlugins_ = false;
std::pair<std::string, std::string> 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<fry::Command> commands_;
fry::CommandSearch search_;
std::vector<std::string> recentCommands_;
std::map<std::string, fry::Chord> 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<double> values;
};
std::optional<PendingCommand> pendingCommand_;
bool nameMaterial_ = false;
std::string materialName_;
bool materialKeepColor_ = false;
std::vector<std::pair<std::string, bool>> 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) {
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_; }
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_);
}
void pushUndo() {
undo_.push_back({scene_, selected_, active_, mode_, selVerts_, selFaces_, selEdges_, "", 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<fry::SceneScript> scripts = std::move(scene_.scripts);
scene_ = s.scene, selected_ = s.selected, active_ = s.active, mode_ = s.mode;
scene_.scripts = std::move(scripts);
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({scene_, selected_, active_, mode_, selVerts_, selFaces_, selEdges_, 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({scene_, selected_, active_, mode_, selVerts_, selFaces_, selEdges_, redo_.back().label, now() - 10});
restore(redo_.back());
redo_.pop_back();
redoKept_.clear();
}
void newScene() {
scene_ = {};
script_ = 0, scriptPath_.clear();
path_.clear();
undo_.clear(), redo_.clear(), selected_.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;
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<uint32_t> made;
const std::vector<Object> 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<const char*>(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<char>(in)), std::istreambuf_iterator<char>());
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);
// (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<Image>(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<Image>(std::move(im));
}
std::erase_if(o.layers, [](const PictureLayer& l) { return !l.image; });
}
path_ = picked;
undo_.clear(), redo_.clear(), selected_.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<uint8_t> pngBytes(const Image& im) {
std::vector<uint8_t> out;
stbi_write_png_to_func([](void* ctx, void* data, int n) { auto* v = static_cast<std::vector<uint8_t>*>(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;
}
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<std::vector<uint8_t>> 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, scene_, pngs, &err)) return say("Couldn't export: " + err);
say("Exported " + stdfs::path(path).filename().string());
}
// ── 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);
}
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()));
}
// 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?)");
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<std::pair<stdfs::file_time_type, std::string>> 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<std::chrono::system_clock::duration>(age));
std::tm local{};
#if defined(_WIN32)
localtime_s(&local, &t);
#else
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();
}
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)");
}
}
// 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);
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_);
}
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");
}
// 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<std::string> names;
static std::vector<SDL_DialogFileFilter> 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<App*>(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);
}
}
// ───────────────────────────── modes and tools ─────────────────────────────
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<int> selectedPoints() {
std::set<int> 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<int>& 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<int> sel = selectedPoints();
std::set<int> rest;
for (size_t v = 0; v < o->mesh.verts.size(); ++v)
if (!sel.count(int(v))) rest.insert(int(v));
const std::set<int> border = growVerts(o->mesh, rest);
std::set<int> 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<glm::vec3> 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<float> 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<int> count(m.verts.size(), 0);
for (auto [a, b] : edges(m)) ++count[size_t(a)], ++count[size_t(b)];
std::set<int> 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<std::pair<int, int>> 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<uint32_t> 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<int> 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<std::pair<int, int>> inv;
for (auto e : edges(o->mesh))
if (!selEdges_.count(e)) inv.insert(e);
selEdges_ = inv;
} else {
std::set<int> 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<std::pair<int, int>> edgesToRound() {
std::set<std::pair<int, int>> 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;
}
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<int, int> 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<int> 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<int> pts = selectedPoints();
pushUndo();
const glm::vec3 towards = glm::inverse(glm::mat3(objectMatrix(*o))) * (eye_ - pivotOfSelection());
if (!makeFace(o->mesh, std::vector<int>(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<int> 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<int> 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<int> 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);
}
// ───────────────────────────── 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<int>& 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<ImVec2> 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<glm::vec3> path;
for (ImVec2 p : pts) path.push_back(glm::vec3(inv * glm::vec4(eye_ + mouseRay(p).dir * dist_, 1.0f)));
pushUndo();
const auto made = knifeCut(o->mesh, eye, path, knifeThrough_);
if (made.empty()) {
dropUndo();
return say(knifeThrough_ ? "The line didn't cross the shape" : "The line didn't cross any face you can see (Tool options: cut right through)");
}
clearElements();
handMade(*o);
touched(id);
if (!knifeThrough_ || knifeAfter_ == 0) {
elem_ = Elem::Edge, selEdges_ = made;
return say(std::format("Cut: {} new edge{} (selected)", made.size(), made.size() == 1 ? "" : "s"));
}
auto pieces = splitAlong(o->mesh, made, knifeFill_);
if (pieces.size() < 2) {
elem_ = Elem::Edge, selEdges_ = made;
return say("Cut, but not all the way round, so it's still one piece");
}
auto size = [&](const std::set<int>& f) {
if (knifeFill_) return volumeOf(o->mesh, f);
const Mesh p = subMesh(o->mesh, f);
const glm::vec3 d = boundsMax(p) - boundsMin(p);
return d.x * d.y * d.z + 1e-9f * float(f.size());
};
std::sort(pieces.begin(), pieces.end(), [&](const std::set<int>& a, const std::set<int>& b) { return size(a) > size(b); });
const size_t n = pieces.size();
if (knifeAfter_ == 1) {
selFaces_ = pieces[1], elem_ = Elem::Face;
return say(std::format("Cut into {} pieces (the smaller selected: W moves it, P makes it its own object, Del deletes it)", n));
}
std::set<int> others;
for (size_t i = 1; i < n; ++i) others.insert(pieces[i].begin(), pieces[i].end());
std::vector<Mesh> parts;
if (knifeAfter_ == 2)
for (size_t i = 1; i < n; ++i) parts.push_back(subMesh(o->mesh, pieces[i]));
deleteFaces(o->mesh, others);
removeUnused(o->mesh);
const std::string base = o->name;
for (Mesh& part : parts) objectLike(id, std::move(part), base + " piece");
touched(id);
say(knifeAfter_ == 2 ? std::format("Cut into {} objects (see Layers)", n) : "Cut off: the smaller part is gone");
}
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<glm::vec3> 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<glm::vec3> 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<glm::vec3> out;
for (const glm::vec3& p : holeShapeOutline(*o, face, centre, across)) out.push_back(glm::vec3(m * glm::vec4(p, 1.0f)));
return out;
}
// Where the mouse is on a face's plane (the object's own space).
std::optional<glm::vec3> 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<Image>(makeImage(pictureSize_, pictureSize_, glm::vec4(o->color, 1.0f)));
touched(o->id);
picZoom_ = 0;
say(std::format("{} is ready to paint: its surface is laid out flat on a {}x{} picture", o->name, o->picture->w, o->picture->h));
return o;
}
// The object's picture, copied so this change can be undone; the copy is now the object's.
Image& changePicture(Object& o) {
pushUndo();
auto img = std::make_shared<Image>(*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<glm::vec2> uvUnder(const Object& o, const Ray& ray) {
if (!o.uvsFit()) return std::nullopt;
const glm::mat4 m = objectMatrix(o);
float best = 1e30f;
std::optional<glm::vec2> out;
for (size_t f = 0; f < o.mesh.faces.size(); ++f) {
const auto& fv = o.mesh.faces[f];
for (const auto& t : triangulate(o.mesh, int(f))) {
glm::vec3 w[3];
glm::vec2 uv[3];
for (int k = 0; k < 3; ++k) {
w[k] = glm::vec3(m * glm::vec4(o.mesh.verts[size_t(t[size_t(k)])], 1.0f));
uv[k] = o.uvs[f][size_t(std::find(fv.begin(), fv.end(), t[size_t(k)]) - fv.begin())];
}
const glm::vec3 e1 = w[1] - w[0], e2 = w[2] - w[0], p = glm::cross(ray.dir, e2);
const float det = glm::dot(e1, p);
if (std::abs(det) < 1e-12f) continue;
const glm::vec3 sv = ray.origin - w[0], q = glm::cross(sv, e1);
const float u = glm::dot(sv, p) / det, v = glm::dot(ray.dir, q) / det, tt = glm::dot(e2, q) / det;
if (u < 0 || v < 0 || u + v > 1 || tt <= 0 || tt >= best) continue;
best = tt, out = uv[0] * (1 - u - v) + uv[1] * u + uv[2] * v;
}
}
return out;
}
// One dab of the brush on the model: on every face near it, into the picture where that face
// lies (so it carries on across the seams between pieces of the layout).
// One dab of paint at a point of the object (the world), on the faces towards `eye` (the
// mirrored dab: towards the eye mirrored). The stencil: only where the surface shows inside
// it on the screen (the mirrored dab: where its mirror image does, `back` taking it there).
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<SculptMesh>(o->mesh);
if (sculptBrush_ == SculptBrush::Grab && !shift) { // (made finer: the same shape, so the same spot)
sculpt_.start = ray.origin + ray.dir * h.t;
const glm::vec3 at = glm::vec3(glm::inverse(objectMatrix(*o)) * glm::vec4(sculpt_.start, 1.0f));
const float radius = toLocal(*o, sculptPx_ * pixelSize(sculpt_.start));
for (int side = 0; side < (sculptMirror_ ? 2 : 1); ++side) {
sculpt_.held[side] = grabbed(o->mesh, side ? at * other : at, radius);
for (const auto& [i, w] : sculpt_.held[side]) sculpt_.from[side].push_back(o->mesh.verts[size_t(i)]);
}
}
}
if (!sculpt_.active) return;
Object* o = find(sculpt_.object);
if (!o) return endSculpt();
const glm::mat4 inv = glm::inverse(objectMatrix(*o));
if (!sculpt_.from[0].empty()) { // grabbing: it follows the mouse across the view
const auto p = hitPlane(ray, sculpt_.start, forwardOf(yaw_, pitch_));
if (!p) return;
const glm::vec3 d = glm::vec3(inv * glm::vec4(*p - sculpt_.start, 0.0f));
moveGrabbed(o->mesh, sculpt_.held[0], sculpt_.from[0], d);
if (sculptMirror_) moveGrabbed(o->mesh, sculpt_.held[1], sculpt_.from[1], d * other);
return touched(o->id);
}
const Hit h = pickObject(ray, o->id, true);
if (!h.object) {
sculpt_.haveLast = false;
return;
}
const glm::vec3 at = ray.origin + ray.dir * h.t;
const float radius = sculptPx_ * pixelSize(at);
const SculptBrush brush = shift ? SculptBrush::Smooth : sculptBrush_;
const auto dab = [&](const glm::vec3& world) {
Dab d{glm::vec3(inv * glm::vec4(world, 1.0f)), toLocal(*o, radius), sculptStrength_, ctrl};
sculptDab(o->mesh, *sculpt_.mesh, brush, d);
if (sculptMirror_) d.at *= other, sculptDab(o->mesh, *sculpt_.mesh, brush, d);
};
// A dab every quarter of the brush along the way, not one a frame (fast strokes stay even).
const float gap = glm::distance(at, sculpt_.last);
if (!sculpt_.haveLast) dab(at);
else if (gap >= radius * 0.25f && gap < radius * 8) {
const int steps = int(gap / (radius * 0.25f));
for (int i = 1; i <= steps; ++i) dab(glm::mix(sculpt_.last, at, float(i) / float(steps)));
} else if (gap < radius * 0.25f)
return; // (not far enough for the next dab yet)
else
dab(at);
sculpt_.last = at, sculpt_.haveLast = true;
touched(o->id);
}
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<Image>(std::move(img));
l.sticker = pasteOnSurface_;
const float aspect = float(l.image->h) / float(std::max(l.image->w, 1));
if (l.sticker) {
const glm::mat4 m = objectMatrix(*o), inv = glm::inverse(m);
const Ray ray = mouseRay(ImVec2((viewMin_.x + viewMax_.x) * 0.5f, (viewMin_.y + viewMax_.y) * 0.5f));
const Hit h = pickObject(ray, o->id, true);
if (h.object) l.face = h.face, l.centre = glm::vec3(inv * glm::vec4(ray.origin + ray.dir * h.t, 1.0f));
else { // (the face looking most at you)
float best = -2;
const glm::vec3 toEye = glm::normalize(glm::vec3(inv * glm::vec4(-viewDir(), 0.0f)));
for (size_t f = 0; f < o->mesh.faces.size(); ++f)
if (const float d = glm::dot(faceNormal(o->mesh, int(f)), toEye); d > best) best = d, l.face = int(f), l.centre = faceCenter(o->mesh, int(f));
}
// Upright as you see it, a third as big as the object.
const glm::vec3 f = viewDir(), right = glm::normalize(glm::cross(f, std::abs(f.z) > 0.99f ? glm::vec3(std::cos(glm::radians(yaw_)), std::sin(glm::radians(yaw_)), 0) : glm::vec3(0, 0, 1)));
l.up = glm::normalize(glm::vec3(inv * glm::vec4(glm::cross(right, f), 0.0f)));
const glm::vec3 d = boundsMax(o->mesh) - boundsMin(o->mesh);
l.width = std::max({d.x, d.y, d.z}) * 0.35f, l.height = l.width * aspect;
} else {
const float W = float(o->picture->w), H = float(o->picture->h);
const float k = std::min(W * 0.6f / float(l.image->w), H * 0.6f / float(l.image->h));
const glm::vec2 half(float(l.image->w) * k * 0.5f, float(l.image->h) * k * 0.5f), c(W * 0.5f, H * 0.5f);
l.corners = {c - half, glm::vec2(c.x + half.x, c.y - half.y), c + half, glm::vec2(c.x - half.x, c.y + half.y)};
}
o->layers.push_back(std::move(l));
ledit_ = {};
ledit_.object = o->id, ledit_.index = int(o->layers.size()) - 1, ledit_.fresh = true;
paintTool_ = PaintTool::Picture;
if (tab_ != Tab::Paint) setTab(Tab::Paint);
say(pasteOnSurface_ ? "Drag it round the model (it bends over edges); corners size it (Shift: stretch), the round handle turns it. Enter locks it."
: "Drag its corners in the Picture panel to fit (Shift: keep its shape), inside to move it. Enter locks it.");
}
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<const stbi_uc*>(data), int(n), &w, &h, &c, 4);
SDL_free(data);
if (!px) continue;
Image img;
img.w = w, img.h = h, img.px.assign(px, px + size_t(w) * size_t(h) * 4);
stbi_image_free(px);
return startFloating(std::move(img));
}
say("Nothing to paste: copy a picture in another app first (or use Add a picture from a file)");
}
// Enter: it stays where it is, a layer (click it in the Picture panel to move it again).
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<Image>(composePicture(only));
o.layers.erase(o.layers.begin() + i);
ledit_ = {};
}
// The handles of a sticker being moved, in the world: its corners (top-left, top-right,
// bottom-right, bottom-left), its turning handle, its middle — on its face's plane.
bool stickerHandles(const Object& o, const PictureLayer& l, std::array<glm::vec3, 6>& 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<glm::vec3, 6> h;
if (!stickerHandles(o, l, h)) return -1;
ImVec2 s[6];
for (int i = 0; i < 6; ++i)
if (!project(h[size_t(i)], s[i])) return -1;
for (int i = 0; i < 4; ++i)
if (std::abs(mouse.x - s[i].x) < 10 && std::abs(mouse.y - s[i].y) < 10) return i;
if (std::hypot(mouse.x - s[4].x, mouse.y - s[4].y) < 11) return 5;
return insidePolygon(mouse, {s[0], s[1], s[2], s[3]}) ? 4 : -1;
}
// Moving a sticker on the model: it slides over the surface (onto other faces too), its
// corners size it, its handle turns it.
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<const uint8_t*>(p)[i]) * 1099511628211ull;
};
const void* base = o.picture.get();
mix(&base, sizeof base);
const uint64_t version = versions_[o.id];
mix(&version, sizeof version);
for (const PictureLayer& l : o.layers) {
const void* im = l.image.get();
mix(&im, sizeof im), mix(&l.sticker, 1), mix(&l.visible, 1), mix(&l.opacity, 4), mix(l.corners.data(), 32);
mix(&l.face, 4), mix(&l.centre, 12), mix(&l.up, 12), mix(&l.width, 4), mix(&l.height, 4);
}
return h;
}
// The object's picture on the graphics card (its painting with its layers on top): sent again
// when it changes — while painting or dragging, at most 20 times a second.
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<std::pair<int, int>> layoutPicked(const Object& o, const std::vector<std::vector<int>>& islands) const {
std::vector<std::pair<int, int>> out;
if (lay_.object != o.id) return out;
if (lay_.corners) return {lay_.points.begin(), lay_.points.end()};
for (int i : lay_.frames)
if (i < int(islands.size()))
for (int f : islands[size_t(i)])
for (size_t c = 0; c < o.mesh.faces[size_t(f)].size(); ++c) out.push_back({f, int(c)});
return out;
}
// A corner and every other on the same point of the model at the same place on the picture.
std::set<std::pair<int, int>> layoutCorner(const Object& o, int f, int c) const {
std::set<std::pair<int, int>> out;
const int v = o.mesh.faces[size_t(f)][size_t(c)];
const glm::vec2 at = o.uvs[size_t(f)][size_t(c)];
for (size_t g = 0; g < o.mesh.faces.size(); ++g)
for (size_t k = 0; k < o.mesh.faces[g].size(); ++k)
if (o.mesh.faces[g][k] == v && glm::distance(o.uvs[g][k], at) < 1e-5f) out.insert({int(g), int(k)});
return out;
}
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<std::vector<glm::vec2>>& from, const std::shared_ptr<const Image>& picture,
const std::vector<std::pair<int, int>>& picked, const std::function<glm::vec2(glm::vec2)>& to, bool final) {
const glm::vec2 size(float(o.picture->w), float(o.picture->h));
o.uvs = from;
std::set<int> faces;
for (auto [f, c] : picked) o.uvs[size_t(f)][size_t(c)] = to(from[size_t(f)][size_t(c)] * size) / size, faces.insert(f);
touched(o.id);
if (layKeepPaint_ && picture && (final || now() - lay_.remapAt > 0.06)) {
Image img = *picture;
remapPicture(img, o.mesh, from, o.uvs, {faces.begin(), faces.end()});
o.picture = std::make_shared<Image>(std::move(img));
lay_.remapAt = now();
}
}
// Buttons' changes (turn, flip, straighten): about the middle of what's picked.
void layoutApply(const std::function<glm::vec2(glm::vec2, glm::vec2)>& 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<int> faces;
for (auto [f, c] : picked) faces.insert(f);
float best = -1, angle = 0; // (its longest edge, laid level or upright)
for (int f : faces)
for (size_t i = 0; i < o->uvs[size_t(f)].size(); ++i) {
const glm::vec2 d = (o->uvs[size_t(f)][(i + 1) % o->uvs[size_t(f)].size()] - o->uvs[size_t(f)][i]) * glm::vec2(o->picture->w, o->picture->h);
if (const float l = glm::length(d); l > best) best = l, angle = std::atan2(d.y, d.x);
}
const float quarter = glm::half_pi<float>(), turn = std::round(angle / quarter) * quarter - angle;
layoutApply([&](glm::vec2 p, glm::vec2 m) { return m + glm::vec2(std::cos(turn) * (p - m).x - std::sin(turn) * (p - m).y, std::sin(turn) * (p - m).x + std::cos(turn) * (p - m).y); });
}
// Laid out again by itself, what's painted carried over onto the new layout.
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<int> all(o.mesh.faces.size());
for (size_t f = 0; f < all.size(); ++f) all[f] = int(f);
Image fresh = makeImage(img.w, img.h, glm::vec4(glm::vec3(o.color), 1.0f));
fresh.px = img.px;
remapPicture(fresh, o.mesh, before, o.uvs, all);
o.picture = std::make_shared<Image>(std::move(fresh));
}
lay_.frames.clear(), lay_.points.clear();
touched(o.id);
say("Laid out again" + std::string(layKeepPaint_ ? ", what's painted carried over" : ""));
}
template <class F>
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<int> 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<ImVec2> 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<int, int> 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<int> 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<glm::vec2(glm::vec2)> 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<ImVec2> 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<ImVec2> 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<int> faces;
for (auto [f, c] : picked) faces.insert(f);
const glm::mat4 m = objectMatrix(*o);
for (int f : faces) {
std::vector<ImVec2> pts;
bool ok = true;
for (int v : o->mesh.faces[size_t(f)]) {
ImVec2 q;
ok &= project(glm::vec3(m * glm::vec4(o->mesh.verts[size_t(v)], 1.0f)), q);
pts.push_back(q);
}
if (ok) dl->AddPolyline(pts.data(), int(pts.size()), kAccent, ImDrawFlags_Closed, 2.0f);
}
}
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 = 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<float>(p.world.faces.size(), 1e30f);
p.thinFaces = 0, p.thinnest = 1e30f;
km.vertices.clear(), km.indices.clear(), km.submeshes.clear();
for (size_t f = 0; f < p.world.faces.size(); ++f) {
p.thinnest = std::min(p.thinnest, p.thick[f]);
if (p.thick[f] >= minWall_) continue;
++p.thinFaces;
const glm::vec3 n = faceNormal(p.world, int(f));
for (const auto& t : triangulate(p.world, int(f)))
for (int k : t) {
const glm::vec3 q = p.world.verts[size_t(k)] + n * 0.0005f;
km.indices.push_back(uint32_t(km.vertices.size()));
km.vertices.push_back({{q.x, q.y, q.z}, {n.x, n.y, n.z}, {0, 0}, {0, 0}});
}
}
if (p.thinFaces) {
km.submeshes.push_back({0, uint32_t(km.indices.size()), 0});
p.thin = r_->updateModel(std::format("fry:thin:{}", o.id), km);
}
return p;
}
std::vector<Object*> printTargets() { // (what's picked, or everything shown)
std::vector<Object*> out;
for (Object& o : scene_.objects)
if (o.visible && (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)
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)
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<glm::vec3> 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<glm::vec3> pts = measure_;
if (pts.size() == 1 && mouseInView(io))
if (auto p = measurePoint(io.MousePos)) pts.push_back(*p);
ImVec2 q[2];
for (size_t i = 0; i < pts.size(); ++i)
if (project(pts[i], q[i])) dl->AddCircleFilled(q[i], 5, kAccent);
if (pts.size() == 2 && project(pts[0], q[0]) && project(pts[1], q[1])) {
dl->AddLine(q[0], q[1], kAccent, 2.0f);
const std::string t = std::format("{:.2f} mm", glm::distance(pts[0], pts[1]) * 1000.0f);
const ImVec2 mid((q[0].x + q[1].x) * 0.5f, (q[0].y + q[1].y) * 0.5f), ts = ImGui::CalcTextSize(t.c_str());
dl->AddRectFilled(ImVec2(mid.x - ts.x * 0.5f - 6, mid.y - ts.y - 10), ImVec2(mid.x + ts.x * 0.5f + 6, mid.y - 4), IM_COL32(20, 20, 24, 230), 4.0f);
dl->AddText(ImVec2(mid.x - ts.x * 0.5f, mid.y - ts.y - 7), IM_COL32(255, 255, 255, 255), t.c_str());
}
}
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<uint32_t> objectsIn(uint32_t folder) {
std::set<uint32_t> 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<uint32_t>& 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<uint32_t>& 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<uint32_t>& 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<uint32_t>& 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<uint32_t> ids = selected_.count(o.id) ? selected_ : std::set<uint32_t>{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<uint32_t> 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<const uint32_t*>(p->Data);
moveToFolder(selected_.count(oid) ? selected_ : std::set<uint32_t>{oid}, id);
}
if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_FOLDER")) {
const uint32_t fid = *static_cast<const uint32_t*>(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<uint32_t> others = [&] {
std::set<uint32_t> s = selected_;
s.erase(a->id);
return s;
}();
changedShape(*a);
std::erase_if(scene_.objects, [&](const Object& o) { return others.count(o.id) > 0; });
selected_ = {active_};
static const char* done[] = {"added to it", "taken out of it", "overlapping kept"};
say(std::format("{}: {} other{} {} ({} faces)", find(active_)->name, used, used == 1 ? "" : "s", done[int(op)], find(active_)->mesh.faces.size()));
}
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<fry::PathPoint> along;
int pathSkip = 0;
if (c.path) {
const Object* path = find(active_);
if (!path || selected_.size() < 2) return say("Pick what to copy, then (Shift) the path last: a drawn shape, a line, a ring...");
bool loop = false;
Mesh m = path->mesh;
transform(m, objectMatrix(*path));
const std::vector<glm::vec3> line = fry::pathOf(m, loop);
along = fry::alongPath(line, loop, c.count);
if (along.empty()) return say("The path has no length to go along");
// (one already on the path's start: that's the first, the copies after it)
const float near = glm::distance(boundsMin(m), boundsMax(m)) * 1e-3f + 1e-6f;
for (uint32_t id : selected_)
if (const Object* o = find(id); o && id != active_ && glm::distance(o->position, along[0].at) < near) {
along = fry::alongPath(line, loop, c.count + 1);
pathSkip = 1;
break;
}
}
pushUndo();
std::set<uint32_t> made = selected_;
const std::vector<Object> originals = [&] {
std::vector<Object> v;
for (const Object& o : scene_.objects)
if (selected_.count(o.id) && !(c.path && o.id == active_)) v.push_back(o);
return v;
}();
const glm::vec3 axis = c.axis == 0 ? glm::vec3(1, 0, 0) : c.axis == 1 ? glm::vec3(0, 1, 0) : glm::vec3(0, 0, 1);
for (const Object& o : originals) {
Object* joinInto = c.joined ? find(o.id) : nullptr;
for (int k = c.path ? pathSkip : 1; k <= (c.path ? int(along.size()) - 1 : c.count); ++k) {
glm::mat4 T(1.0f);
if (c.path) { // (its middle onto the point; turned as the path turns from its start)
const glm::quat turn = c.follow ? glm::rotation(along[0].along, along[size_t(k)].along) : glm::quat(1, 0, 0, 0);
T = glm::translate(glm::mat4(1.0f), along[size_t(k)].at) * glm::mat4_cast(turn) * glm::translate(glm::mat4(1.0f), -o.position);
} else if (c.round) {
const float a = glm::radians(c.angle) * float(k) / float(c.angle >= 359.99f ? c.count + 1 : c.count);
T = glm::translate(glm::mat4(1.0f), c.centre) * glm::rotate(glm::mat4(1.0f), a, axis) * glm::translate(glm::mat4(1.0f), -c.centre);
} else
T = glm::translate(glm::mat4(1.0f), c.step * float(k));
if (joinInto) { // (into the original's own shape)
Mesh m = o.mesh;
transform(m, glm::inverse(objectMatrix(o)) * T * objectMatrix(o));
append(joinInto->mesh, m);
continue;
}
Object& n = addObject(o.name, o.mesh);
const uint32_t id = n.id;
const std::string name = n.name;
n = o, n.id = id, n.name = name, n.locked = false;
n.position = glm::vec3(T * glm::vec4(o.position, 1.0f));
if (c.round || c.path) n.rotation = glm::normalize(glm::quat_cast(glm::mat3(T)) * o.rotation);
made.insert(id);
}
if (joinInto) changedShape(*joinInto);
}
selected_ = made;
touchAll();
if (c.path) selected_.erase(active_), active_ = selected_.empty() ? 0 : *selected_.rbegin(); // (the path isn't one of them)
const size_t n = c.path ? along.size() - size_t(pathSkip) : size_t(c.count);
say(std::format("{} cop{} of each made{}{}", n, n == 1 ? "y" : "ies", c.path ? " along the path" : "", c.joined ? ", joined into it" : ""));
}
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<fry::ImportedMesh> got = fry::importModel(file, &err);
if (got.empty()) return say(err.empty() ? "Nothing in " + fileName(file) : err);
pushUndo();
if (mode_ == Mode::Edit) leaveEdit();
glm::vec3 lo(1e30f), hi(-1e30f);
for (const auto& g : got) lo = glm::min(lo, boundsMin(g.mesh)), hi = glm::max(hi, boundsMax(g.mesh));
const glm::vec3 shift(-(lo.x + hi.x) / 2, -(lo.y + hi.y) / 2, -lo.z), at(std::round(target_.x * 10) / 10, std::round(target_.y * 10) / 10, 0.0f);
selected_.clear();
size_t faces = 0;
for (auto& g : got) {
// (each object's own middle as its place, so it turns about itself)
const glm::vec3 lo1 = boundsMin(g.mesh) + shift, hi1 = boundsMax(g.mesh) + shift, mid((lo1.x + hi1.x) / 2, (lo1.y + hi1.y) / 2, lo1.z);
transform(g.mesh, glm::translate(glm::mat4(1.0f), shift - mid));
faces += g.mesh.faces.size();
Object& o = addObject(g.name.empty() ? "Imported" : g.name, std::move(g.mesh));
o.position = at + mid;
selected_.insert(o.id), active_ = o.id;
}
const glm::vec3 size = (hi - lo) * 1000.0f;
say(std::format("{}: {} object{}, {} faces, {:.1f} x {:.1f} x {:.1f} mm", fileName(file), got.size(), got.size() == 1 ? "" : "s", faces, size.x, size.y, size.z));
if (glm::length(hi - lo) > 0) frameSelected();
}
// ── 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<uint8_t> font;
if (!textFontFile_.empty()) {
std::ifstream in(textFontFile_, std::ios::binary);
font.assign(std::istreambuf_iterator<char>(in), {});
} else if (auto f = keel::fs::read(kFontFiles[textFont_]))
font = std::move(*f);
fry::TextStyle st;
st.height = textHeight_, st.depth = textDepth_, st.spacing = textSpacing_, st.align = textAlign_;
std::string err;
Mesh m = fry::makeText(font, textBuf_, st, &err);
if (m.faces.empty()) return say(err.empty() ? "No text to make" : err);
// (the letters' middle on the ground at the origin; standing up: facing the front)
const glm::vec3 lo = boundsMin(m), hi = boundsMax(m);
transform(m, glm::translate(glm::mat4(1.0f), glm::vec3(-(lo.x + hi.x) / 2, -(lo.y + hi.y) / 2, 0.0f)));
if (textStanding_) transform(m, glm::translate(glm::mat4(1.0f), glm::vec3(0, 0, (hi.y - lo.y) / 2)) * glm::rotate(glm::mat4(1.0f), kPi / 2, glm::vec3(1, 0, 0)));
pushUndo();
if (mode_ == Mode::Edit) leaveEdit();
std::string name = textBuf_;
if (const size_t nl = name.find('\n'); nl != std::string::npos) name.resize(nl);
if (name.size() > 24) name = name.substr(0, 24) + "...";
Object& o = addObject("Text: " + name, std::move(m));
o.position = glm::vec3(std::round(target_.x * 10) / 10, std::round(target_.y * 10) / 10, 0.0f);
// (reading left to right as the view looks: turned to the nearest quarter)
const glm::vec3 f = viewDir();
if (std::abs(f.x) + std::abs(f.y) > 1e-4f) {
const float turn = std::round(std::atan2(-f.x, f.y) / (kPi / 2)) * (kPi / 2);
o.rotation = glm::angleAxis(turn, glm::vec3(0, 0, 1));
}
selected_ = {o.id}, active_ = o.id;
showText_ = false;
say(std::format("Text added, {:.0f} mm tall letters, {} faces", textHeight_ * 1000, o.mesh.faces.size()));
}
void thickenWindow() {
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<ImVec2> 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 != versions_[o.id] || !version) {
version = versions_[o.id], 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).
const Mesh& shownMesh(const Object& o) {
const bool editingThis = mode_ == Mode::Edit && o.id == active_;
if (!o.hasFinish() || (editingThis && !o.mirror)) return o.mesh;
Shown& s = shown_[o.id];
const uint64_t v = versions_[o.id];
if (!(s.version == v && v && s.mirrorOnly == editingThis)) s.mesh = finishedMesh(o, editingThis), s.version = v, s.mirrorOnly = editingThis;
return s.mesh;
}
// ───────────────────────────── 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) {
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;
}
// 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<std::pair<uint32_t, bool>, 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 = versions_[o.id];
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)?
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);
}
// 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<char> point; // 0 not tried yet, 1 seen, 2 hidden
};
std::map<std::pair<uint32_t, bool>, 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;
}
// 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<std::array<int, 4>> edges; // a, b, a face, the other (or -1)
std::vector<glm::vec4> plane; // each face's normal, and its distance along it
};
std::map<std::pair<uint32_t, bool>, Adjacency> adjacency_;
const Adjacency& adjacencyOf(const Object& o, const Mesh& mesh) {
Adjacency& a = adjacency_[{o.id, &mesh != &o.mesh}];
const uint64_t version = versions_[o.id];
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<uint64_t, int> 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); }
// The edge of the edited object nearest the mouse on screen (that can be seen), or (-1, -1).
std::pair<int, int> nearestEdge(Object& o, ImVec2 mouse, float within) {
const glm::mat4 m = objectMatrix(o);
std::pair<int, int> 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;
}
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;
}
}
// 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<int, int> 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<bool(ImVec2)>& 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<char> 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<glm::vec3> selectionPoints() {
std::vector<glm::vec3> 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<glm::vec3, glm::vec3> 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<int> 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);
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.
void findSoft() {
drag_.soft.clear();
const Object* o = editing();
if (!o) return;
const std::set<int> 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<int64_t, std::vector<glm::vec3>> 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)});
}
}
void cancelDrag() {
if (!drag_.active) return;
scene_ = drag_.before;
dropUndo();
touchAll();
drag_.active = false;
}
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<uint64_t, int> at;
for (size_t i = 0; i < before.verts.size(); ++i) at.emplace(key(before.verts[i]), int(i));
std::vector<char> 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<glm::vec3> snapTarget(ImVec2 mouse, float within = 22.0f) {
std::optional<glm::vec3> best;
float bestD = within;
const std::set<int> moving = mode_ == Mode::Edit ? selectedPoints() : std::set<int>{};
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<float> 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) {
if (!selected_.count(o.id) || 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);
}
}
// ───────────────────────────── 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 {
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<glm::vec3> 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<glm::vec3> local;
for (const glm::vec3& q : drawPts_) local.push_back(q - c);
// On an object it stands on the face (a hair into it, so they join), or goes down into it.
const float t = drawThickness_, e = 0.0001f;
float thick = t;
if (onto && drawOnMode_ == 2) thick = t + 2 * e, c += n * (e - thick / 2);
else if (onto) thick = t + e, c += n * (t - thick / 2);
Mesh m = extrudeOutline(local, n, thick);
drawPts_.clear();
if (m.faces.empty()) return say("That shape doesn't work (points on top of each other?)");
if (onto && drawOnMode_ > 0 && find(onto)->locked) return say("That object is locked");
pushUndo();
if (onto && drawOnMode_ > 0) {
Object* o = find(onto);
if (o->hasFinish()) o->mesh = finishedMesh(*o), o->mirror = 0, o->round = 0, o->smoothLevels = 0;
Mesh shape = m;
transform(shape, glm::inverse(objectMatrix(*o)) * glm::translate(glm::mat4(1.0f), c));
Mesh result = o->mesh;
if (!combine(result, shape, drawOnMode_ == 2 ? Combine::Subtract : Combine::Add)) {
takeBack();
return say("That would leave nothing");
}
o = find(onto);
o->mesh = std::move(result);
changedShape(*o);
selected_ = {o->id}, active_ = o->id;
return say(std::format("{} {:.0f} mm {} {}", drawOnMode_ == 2 ? "Cut" : "Added", t * 1000, drawOnMode_ == 2 ? "deep into" : "high onto", o->name));
}
Object& o = addObject("Shape", std::move(m));
o.position = c;
selected_ = {o.id}, active_ = o.id;
say(std::format("Shape made, {:.0f} mm thick{}. Tab edits its shape; Thickness is in Tool options.", t * 1000, onto ? ", standing on " + find(onto)->name : ""));
}
// ───────────────────────────── the viewport's mouse ─────────────────────────────
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<ImVec2> pts = knifePts_;
knifePts_.clear();
return knifeApply(active_, pts);
}
knifeDown_ = true, knifePress_ = io.MousePos;
}
return;
}
if (tool_ == Tool::Hole) {
if (!ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return;
const Ray ray = mouseRay(io.MousePos);
const Hit h = pickObject(ray, mode_ == Mode::Edit ? active_ : 0, true);
if (!h.object) return;
Object* o = find(h.object);
if (o->locked) return say("That object is locked");
if (mode_ == Mode::Object) selected_ = {h.object}, active_ = h.object;
hole_ = {true, h.object, h.face, glm::vec3(glm::inverse(objectMatrix(*o)) * glm::vec4(ray.origin + ray.dir * h.t, 1.0f)), 0.0f, io.MousePos};
return;
}
if (tool_ == Tool::LoopCut) {
if (Object* o = editing()) {
cutHover_ = nearestEdge(*o, io.MousePos, 30.0f);
if (cutHover_.first >= 0 && ringQuads(o->mesh, cutHover_).empty()) cutHover_ = {-1, -1};
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && cutHover_.first >= 0) cutAt(*o, cutHover_);
}
return;
}
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
if (const int h = hoverHandle(io.MousePos); h >= 0) return startDrag(io, h);
if (mode_ == Mode::Edit && (io.KeyAlt || ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left))) return loopSelect(io);
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) && mode_ == Mode::Object) { // double click: edit its shape
const Hit hit = pickObject(mouseRay(io.MousePos));
if (hit.object) {
selected_ = {hit.object}, active_ = hit.object;
enterEdit();
return;
}
}
if (selShape_ == SelShape::Brush) {
painting_ = true;
if (!io.KeyShift && !io.KeyCtrl) selectNone();
paint(io);
} else if (selShape_ == SelShape::Lasso) lassoing_ = true, lasso_ = {io.MousePos};
else boxing_ = true, boxStart_ = io.MousePos, touchOrbit_ = touch() && tool_ != Tool::Select; // (a finger: dragging turns the view, but Select's box)
}
}
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);
}
// 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);
}
// ───────────────────────────── keys ─────────────────────────────
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;
}
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<ImVec2> 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();
}
// ───────────────────────────── 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);
}
// 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(); }
keel::ModelId modelOf(const Object& o) {
const bool editingThis = mode_ == Mode::Edit && o.id == active_;
const uint64_t want = (versions_[o.id] * 2 + editingThis) * 2 + painted(o);
auto it = models_.find(o.id);
if (it != models_.end() && it->second.second == want && versions_[o.id]) return it->second.first;
const Mesh& mesh = shownMesh(o);
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;
}
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;
}
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;
}
}
}
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));
}
// 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();
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) 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);
}
// 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<ImVec2> s(mesh.verts.size());
std::vector<char> 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<char> 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 (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<glm::vec3, 6> 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<ImVec2> 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<ImVec2> 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<ImVec2> 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();
}
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, &center);
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, &center);
const ImGuiID leftBottom = ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.45f, nullptr, &left);
const ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.36f, nullptr, &center);
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, &center);
const ImGuiID leftBottom = ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.5f, nullptr, &left);
ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, rightShare, nullptr, &center);
const ImGuiID rightBottom = ImGui::DockBuilderSplitNode(right, ImGuiDir_Down, 0.66f, nullptr, &right);
const ImGuiID bottom = ImGui::DockBuilderSplitNode(center, ImGuiDir_Down, 0.04f, nullptr, &center);
const ImGuiID picture = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.45f, nullptr, &center); // (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<void()> run, std::function<bool()> 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<std::pair<SelShape, const char*>>{{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("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<std::tuple<View, const char*, const char*>>{
{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<fry::PieItem> items;
std::vector<std::function<void()>> 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<View, const char*> 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("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 <class F>
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<const ToolInfo*> 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<int, int> 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<ImVec2> 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<const Fit>(nuts) : std::span<const Fit>(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<const uint32_t*>(p->Data);
moveToFolder(selected_.count(oid) ? selected_ : std::set<uint32_t>{oid}, 0);
}
if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("FRY_FOLDER"))
if (Folder* g = findFolder(*static_cast<const uint32_t*>(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 <class F>
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<ImVec2> 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)");
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<std::string> 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<int> 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<char>(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<char>(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<std::string> pluginsOff() const {
std::set<std::string> off;
std::istringstream in(fry_pluginsoff.str());
for (std::string f; std::getline(in, f, '|');)
if (!f.empty()) off.insert(f);
return off;
}
void setPluginOn(const std::string& file, bool on) {
std::set<std::string> off = pluginsOff();
if (on) off.erase(file);
else off.insert(file);
std::string text;
for (const std::string& f : off) text += (text.empty() ? "" : "|") + f;
fry_pluginsoff.set(text);
loadPlugins();
}
void loadPlugins() {
for (const PluginEntry& e : plugins_) fry::clearOwnMaterials(e.file);
plugins_.clear();
std::error_code ec;
stdfs::create_directories(pluginsFolder(), ec);
std::vector<std::pair<PluginEntry, std::string>> found; // (and its code)
for (const std::string& f : keel::fs::list("plugins", ".lua"))
if (auto text = keel::fs::readText(f)) found.push_back({PluginEntry{f, fileName(f), "", true}, *text});
std::vector<stdfs::path> mine;
for (const auto& d : stdfs::directory_iterator(pluginsFolder(), ec))
if (d.path().extension() == ".lua") mine.push_back(d.path());
std::sort(mine.begin(), mine.end());
for (const stdfs::path& f : mine) {
std::ifstream in(f, std::ios::binary);
found.push_back({PluginEntry{f.string(), f.filename().string()}, std::string(std::istreambuf_iterator<char>(in), {})});
}
const std::set<std::string> off = pluginsOff();
fry::Scene scratch; // (loading a plug-in can't touch the open file: only its commands, when run, do)
std::set<uint32_t> none;
uint32_t noActive = 0;
for (auto& [e, code] : found) {
e.on = !off.count(e.file);
if (e.on) {
e.p = std::make_unique<fry::Plugin>(e.file);
fry::ScriptHost host{&scratch, &none, &noActive, [this, f = e.file](const std::string& t) { scriptLine(f + ": " + t); }, {}};
if (!e.p->load(host, code, &e.error)) {
fry::clearOwnMaterials(e.file);
scriptLine(e.error, true);
keel::log::warn("Plug-in {} didn't load: {}", e.file, e.error.substr(0, e.error.find('\n')));
}
}
plugins_.push_back(std::move(e));
}
size_t loaded = 0;
for (const PluginEntry& e : plugins_) loaded += e.p && e.error.empty();
if (loaded) keel::log::info("Plug-ins: {} loaded", loaded);
}
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<double>& 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<unsigned char>(label[0])));
std::replace(label.begin(), label.end(), '_', ' ');
ImGui::InputDouble(label.c_str(), &pendingCommand_->values[k], 0, 0, "%.4g");
}
ImGui::Spacing();
pushAccent();
const bool go = ImGui::Button("Make it", ImVec2(120, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter, false);
popAccent();
ImGui::SameLine();
const bool cancel = ImGui::Button("Cancel", ImVec2(120, 0));
if (go || cancel) {
ImGui::CloseCurrentPopup();
const PendingCommand p = *pendingCommand_;
pendingCommand_.reset();
if (go) runPluginCommand(p.plugin, p.command, p.values);
}
ImGui::EndPopup();
}
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<std::pair<std::string, bool>> toggle;
for (size_t i = 0; i < plugins_.size(); ++i) {
const PluginEntry& e = plugins_[i];
if (!pluginFilter_.empty()) {
std::string all = lower(e.file + " " + (e.p ? e.p->name + " " + e.p->about : std::string()));
if (e.p)
for (const fry::PluginCommand& c : e.p->commands) all += " " + lower(c.name + " " + c.help);
if (e.p)
for (const fry::PluginTool& t : e.p->tools) all += " " + lower(t.name + " " + t.help);
if (all.find(lower(pluginFilter_)) == std::string::npos) continue;
}
ImGui::PushID(int(i));
ImGui::BeginChild("plugin", ImVec2(0, 0), ImGuiChildFlags_FrameStyle | ImGuiChildFlags_AutoResizeY);
bool on = e.on;
if (ImGui::Checkbox("##on", &on)) toggle = {e.file, on};
if (ImGui::IsItemHovered()) ImGui::SetTooltip(on ? "On: turn it off" : "Off: turn it on");
ImGui::SameLine();
ImGui::PushFont(keel::ui::font(keel::ui::Font::Bold), 0.0f);
ImGui::TextUnformatted(e.p ? e.p->name.c_str() : scriptName(e.file).c_str());
ImGui::PopFont();
ImGui::SameLine();
ImGui::TextDisabled("%s%s", e.file.c_str(), e.bundled ? " (comes with Fry3DX)" : "");
if (e.p && !e.p->about.empty()) ImGui::TextWrapped("%s", e.p->about.c_str());
if (!e.error.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, roleColor(Role::DangerText));
ImGui::TextWrapped("Didn't load: %s", e.error.c_str());
ImGui::PopStyleColor();
} else if (e.p) {
std::string adds;
for (const fry::PluginCommand& c : e.p->commands) adds += std::format("{}{} ({} menu)", adds.empty() ? "" : ", ", c.name, c.menu == "add" ? "Add" : c.menu == "object" ? "Object" : "Script > Plug-ins");
for (const fry::PluginTool& t : e.p->tools) adds += std::format("{}{} (a tool in the Toolbox)", adds.empty() ? "" : ", ", t.name);
size_t mats = 0;
for (const MaterialPreset& m : ownMaterials()) mats += m.from == e.file;
if (mats) adds += std::format("{}{} material{}", adds.empty() ? "" : ", ", mats, mats == 1 ? "" : "s");
ImGui::TextColored(roleColor(Role::TextTertiary), "Adds: %s", adds.empty() ? "nothing yet" : adds.c_str());
} else ImGui::TextDisabled("Turned off");
if (e.bundled) { // (to change one that comes with Fry3DX: your own copy, in your folder)
if (ImGui::SmallButton("Make my own copy to change")) copyPlugin(e);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Copies it into your plug-ins folder (as my_%s) and turns this one off", e.file.c_str());
}
ImGui::EndChild();
ImGui::PopID();
}
ImGui::End();
if (toggle) setPluginOn(toggle->first, toggle->second);
}
static int growString(ImGuiInputTextCallbackData* d) {
if (d->EventFlag == ImGuiInputTextFlags_CallbackResize) {
auto* str = static_cast<std::string*>(d->UserData);
str->resize(size_t(d->BufTextLen));
d->Buf = str->data();
}
return 0;
}
// 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<size_t> remove, copy;
for (size_t i = 0; i < scene_.scripts.size(); ++i) {
ImGui::PushID(int(i));
if (ImGui::Selectable(scene_.scripts[i].name.c_str(), i == script_)) script_ = i;
if (ImGui::BeginPopupContextItem()) {
if (ImGui::MenuItem("Run it")) script_ = i, runScriptNow();
if (ImGui::MenuItem("Duplicate")) copy = i;
if (ImGui::MenuItem("Remove from the file")) remove = i;
ImGui::EndPopup();
}
ImGui::PopID();
}
if (scene_.scripts.empty()) ImGui::TextDisabled("None yet");
ImGui::Spacing();
if (ImGui::Button("+ New", ImVec2(-FLT_MIN, 0))) addScript("Script", kFirstScript);
if (ImGui::Button("Examples", ImVec2(-FLT_MIN, 0))) ImGui::OpenPopup("examples");
if (ImGui::BeginPopup("examples")) {
exampleItems(true);
ImGui::EndPopup();
}
if (ImGui::Button("Open...", ImVec2(-FLT_MIN, 0))) dialog(10);
ImGui::EndChild();
if (copy) addScript(scene_.scripts[*copy].name, scene_.scripts[*copy].code + "\n");
if (remove) {
scene_.scripts.erase(scene_.scripts.begin() + long(*remove));
if (script_ >= scene_.scripts.size()) script_ = scene_.scripts.empty() ? 0 : scene_.scripts.size() - 1;
dirty_ = true;
}
ImGui::SameLine();
ImGui::BeginGroup();
fry::SceneScript* sc = currentScript();
if (!sc) {
ImGui::Dummy(ImVec2(0, 40));
ImGui::TextWrapped("This file has no scripts yet. A script is Lua that builds or changes the scene; it's saved in the .fry "
"with the model.");
pushAccent();
if (ImGui::Button("+ New script")) addScript("Script", kFirstScript);
popAccent();
ImGui::SameLine();
if (ImGui::Button("What scripts can do")) addScript("Script", kFirstScript), showScriptHelp_ = true;
ImGui::EndGroup();
return ImGui::End();
}
pushAccent();
if (ImGui::Button("Run (F5)")) runScriptNow();
popAccent();
ImGui::SameLine();
ImGui::SetNextItemWidth(std::max(80.0f, ImGui::GetContentRegionAvail().x - 190));
if (inputString("##name", sc->name)) dirty_ = true;
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Its name (click to change)");
ImGui::SameLine();
if (ImGui::Button("Save as .lua")) dialog(11);
ImGui::SameLine();
if (ImGui::Button(showScriptHelp_ ? "Hide help" : "?")) showScriptHelp_ = !showScriptHelp_;
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Everything a script can use");
const float logH = std::max(90.0f, ImGui::GetContentRegionAvail().y * 0.22f);
ImGui::PushFont(keel::ui::font(keel::ui::Font::Mono), 0.0f);
if (showScriptHelp_) {
ImGui::BeginChild("help", ImVec2(0, ImGui::GetContentRegionAvail().y * 0.45f), ImGuiChildFlags_FrameStyle);
ImGui::TextUnformatted(fry::scriptHelp());
ImGui::EndChild();
}
const ImVec2 editor(-FLT_MIN, std::max(120.0f, ImGui::GetContentRegionAvail().y - logH - ImGui::GetStyle().ItemSpacing.y));
if (ImGui::InputTextMultiline("##code", sc->code.data(), sc->code.capacity() + 1, editor,
ImGuiInputTextFlags_AllowTabInput | ImGuiInputTextFlags_CallbackResize, growString, &sc->code))
dirty_ = true;
const bool typing = ImGui::IsItemActive();
ImGui::BeginChild("log", ImVec2(-FLT_MIN, 0), ImGuiChildFlags_FrameStyle);
if (scriptLog_.empty()) ImGui::TextDisabled("What it prints shows here.");
for (const auto& [line, error] : scriptLog_) {
if (error) ImGui::PushStyleColor(ImGuiCol_Text, roleColor(keel::ui::Role::DangerText));
ImGui::TextUnformatted(line.c_str());
if (error) ImGui::PopStyleColor();
}
if (std::exchange(scriptLogToBottom_, false)) ImGui::SetScrollHereY(1.0f);
ImGui::EndChild();
ImGui::PopFont();
ImGui::EndGroup();
if (typing && ImGui::GetIO().KeyCtrl && (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false))) runScriptNow();
if (typing && ImGui::IsKeyPressed(ImGuiKey_F5, false)) runScriptNow();
ImGui::End();
}
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<std::pair<const char*, const char*>> list) {
ImGui::SeparatorText(title);
if (!ImGui::BeginTable(title, 2)) return;
ImGui::TableSetupColumn("k", ImGuiTableColumnFlags_WidthFixed, 180);
for (auto [k, what] : list) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextColored(accentText(), "%s", k);
ImGui::TableNextColumn();
ImGui::TextWrapped("%s", what);
}
ImGui::EndTable();
};
rows("Finding things", {{"F3 or Ctrl+Space", "Search for any command by name and run it"}, {"Space (held)", "The tools round the mouse: point at one, let go"},
{"Shift+Space (held)", "The views round the mouse"}});
myKeysTable();
rows("Looking around", {{"Right drag", "Orbit round what you look at"}, {"Middle drag / Shift+Right drag", "Pan"}, {"Wheel", "Zoom (towards the mouse)"},
{"Orbit / Pan / Zoom buttons", "Drag them (touchpads)"}, {"F", "Frame the selection"}, {"Home", "Frame everything"},
{"Numpad 1 / 3 / 7", "Front / side / top (Shift: the opposite)"}, {"Numpad 5", "Flat view / 3D"},
{"Z / Shift+Z", "Solid or Rendered / Wireframe"}, {"Alt+Z", "X-ray: see and pick through"}});
rows("Tools", {{"Q", "Select"}, {"W", "Move"}, {"E", "Rotate"}, {"R", "Scale"}, {"D", "Draw shape"}, {"X", "Extrude (shape editing)"}, {"I", "Inset (shape editing)"},
{"B", "Round edges (shape editing)"}, {"C", "Loop cut (shape editing)"}, {"K", "Knife (shape editing)"}, {"O", "Hole"},
{"Ctrl (held)", "Snap while dragging (or don't, if Snap is on)"}});
rows("Tabs", {{"Ctrl+1", "Model"}, {"Ctrl+2", "Paint"}, {"Ctrl+3", "Layout"}, {"Ctrl+4", "Render"}, {"Ctrl+5", "Print"}});
rows("Layout", {{"Q W E R", "select, move, turn, size frames"}, {"Tab", "frames or corners"}, {"Ctrl+A", "all frames"}});
rows("Print", {{"M", "measure"}, {"L", "lay a face flat on the bed"}, {"B", "put it on the bed"}});
rows("Painting", {{"B / E / G / I", "Brush / eraser / fill / pick a colour"}, {"Alt+click", "Pick a colour"}, {"[ / ]", "Smaller / bigger brush"},
{"Ctrl+V", "Paste a picture onto it"}, {"Enter / Esc", "Place the picture / cancel"}, {"Shift + corner drag", "Resize keeping its shape"},
{"Wheel, middle or right drag", "Zoom and move the Picture panel"}});
rows("Sculpting", {{"Tab (Paint tab)", "Colour or Sculpt"}, {"1 - 6", "Raise, puff out, smooth, flatten, pinch, grab"}, {"Ctrl + brush", "The other way (dig, shrink, push apart)"},
{"Shift + brush", "Smooth"}, {"X", "The same on both sides"}, {"[ / ]", "Smaller / bigger brush"}});
rows("Selecting", {{"Click", "Select"}, {"Drag", "Select with a box, brush or lasso (Tool options)"}, {"Shift + click", "Objects: add to the selection. Shape: a whole loop"},
{"Ctrl + click", "Objects: take away. Shape: add or take one"}, {"Shift+Ctrl + click an edge", "The ring across it (shape)"}, {"Alt+click / double-click", "A whole loop (shape editing)"}, {"Ctrl+A / Alt+A", "All / none"},
{"Ctrl+I", "Invert"}, {"Ctrl + / Ctrl -", "More / less"}, {"L", "Everything joined to it"}, {"Esc", "Let go / cancel / leave shape editing"}});
rows("Objects and shape", {{"Tab / double-click", "Edit an object's shape"}, {"1 / 2 / 3", "Points / edges / faces (shape editing)"}, {"M", "Merge points"},
{"P", "Selected faces into their own object"}, {"Shift + Scale drag", "Scale both sides (from the middle)"}, {"Ctrl+G", "Group into a folder"},
{"Ctrl+Shift + / -", "Add together / take the others out (last picked kept)"}, {"Del", "Delete"}, {"Ctrl+D", "Duplicate"}, {"Ctrl+J", "Join objects into one"},
{"H / Alt+H", "Hide / show all"}, {"/ or Shift+H", "Show only the selected (again: all)"},
{"Shift+O", "Soft selection: near points follow (wheel while dragging: how far)"}, {"Alt+S", "Push points out / pull in (shape)"}});
rows("Files", {{"Ctrl+N / O / S", "New / open / save"}, {"Ctrl+Shift+S", "Save as"}, {"Ctrl+E", "Export (GLB, glTF, FBX, OBJ, 3MF, STL, PLY)"}, {"F12", "Save a picture of the view"},
{"Ctrl+Z / Ctrl+Y", "Undo / redo"}});
ImGui::End();
}
// Keys of your own: any command on any key (they come before the usual ones).
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<Object*> targets;
if (tab_ == Tab::Print) targets = printTargets();
else
for (Object& o : scene_.objects)
if (selected_.count(o.id)) targets.push_back(&o);
std::erase_if(targets, [](const Object* o) { return o->locked; });
if (targets.empty()) return say("Pick what to repair first");
if (mode_ == Mode::Edit) leaveEdit();
pushUndo();
fry::Repair all;
int still = 0;
for (Object* o : targets) {
const fry::Repair r = fry::repairForPrint(o->mesh);
all.joinedPoints += r.joinedPoints, all.emptyFaces += r.emptyFaces, all.doubledFaces += r.doubledFaces, all.turnedFaces += r.turnedFaces, all.closedHoles += r.closedHoles;
if (r.total()) handMade(*o), touched(o->id);
still += !checkForPrint(o->mesh).closed();
}
if (!all.total()) return takeBack(), say(still ? "Nothing it can repair by itself: look at what the Print tab says" : "Nothing to repair: ready to print");
std::vector<std::string> did;
if (all.joinedPoints) did.push_back(std::format("{} points joined", all.joinedPoints));
if (all.emptyFaces) did.push_back(std::format("{} empty faces gone", all.emptyFaces));
if (all.doubledFaces) did.push_back(std::format("{} doubled faces gone", all.doubledFaces));
if (all.turnedFaces) did.push_back(std::format("{} faces turned out", all.turnedFaces));
if (all.closedHoles) did.push_back(std::format("{} holes closed", all.closedHoles));
std::string text;
for (const std::string& d : did) text += (text.empty() ? "" : ", ") + d;
say("Repaired: " + text + (still ? std::format(". {} still not closed (see the Print tab)", still) : ". Ready to print"));
}
// Crease (Shift+E): the picked edges (or those between picked points / round picked faces)
// kept sharp when the shape's smoothed; again: not.
void creaseSelected() {
Object* o = editing();
if (!o) return say("Crease works on a shape being edited (Tab)");
std::set<std::pair<int, int>> picked;
if (elem_ == Elem::Edge)
for (auto [a, b] : selEdges_) picked.insert({std::min(a, b), std::max(a, b)});
else {
const std::set<int> pts = selectedPoints();
for (const auto& e : adjacencyOf(*o, o->mesh).edges)
if (pts.count(e[0]) && pts.count(e[1])) picked.insert({e[0], e[1]});
}
if (picked.empty()) return say("Pick the edges to crease first (Edges, 2)");
pushUndo();
const bool all = std::all_of(picked.begin(), picked.end(), [&](const auto& e) { return o->creases.count(e) > 0; });
for (const auto& e : picked) all ? (void)o->creases.erase(e) : (void)o->creases.insert(e);
touched(o->id);
say(all ? std::format("{} edge{} smooth again", picked.size(), picked.size() == 1 ? "" : "s")
: std::format("{} edge{} creased: smoothing keeps {} sharp{}", picked.size(), picked.size() == 1 ? "" : "s", picked.size() == 1 ? "it" : "them",
o->smoothLevels ? "" : " (turn on Smooth out to see)"));
}
// 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<int> pts;
std::vector<std::vector<int>> 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<int> pts = selectedPoints();
if (pts.empty()) return say("Pick points, edges or faces to slide first");
std::vector<std::set<int>> near(o->mesh.verts.size());
for (const auto& e : adjacencyOf(*o, o->mesh).edges)
if (pts.count(e[0]) != pts.count(e[1])) near[size_t(e[0])].insert(e[1]), near[size_t(e[1])].insert(e[0]);
slide_ = {};
for (int v : pts)
if (!near[size_t(v)].empty()) slide_.pts.push_back(v), slide_.rails.emplace_back(near[size_t(v)].begin(), near[size_t(v)].end());
if (slide_.pts.empty()) return say("Nothing for those to slide along (every edge of theirs is picked too)");
pushUndo();
slide_.active = true, slide_.object = o->id, slide_.from = o->mesh, slide_.start = ImGui::GetIO().MousePos;
say("Sliding: move the mouse along the edges, click to keep it (Esc: as it was)");
}
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<stdfs::path> kept;
for (const auto& e : stdfs::directory_iterator(workspaceDir(), ec))
if (e.path().extension() == ".ini") kept.push_back(e.path());
std::sort(kept.begin(), kept.end());
for (const stdfs::path& f : kept) {
ImGui::PushID(f.string().c_str());
if (ImGui::Selectable(f.stem().string().c_str(), false, ImGuiSelectableFlags_AllowOverlap, ImVec2(250, 0))) {
std::ifstream in(f, std::ios::binary);
pendingLayout_.assign(std::istreambuf_iterator<char>(in), {});
say("Workspace: " + f.stem().string());
}
ImGui::SameLine(270);
if (ImGui::SmallButton("Delete")) stdfs::remove(f, ec);
ImGui::PopID();
}
ImGui::End();
}
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<std::pair<const char*, std::string>> rows = {
{"Version", FRY_VERSION}, {"Drawn with", r_->apiName()}, {"Graphics", gpu.name.empty() ? "?" : gpu.name}, {"System", machine.os}, {"Processor", machine.cpu},
{"Plug-ins", std::format("{} loaded", std::count_if(plugins_.begin(), plugins_.end(), [](const PluginEntry& e) { return e.p && e.error.empty(); }))}};
ImGui::BeginChild("details", ImVec2(0, 0), ImGuiChildFlags_FrameStyle | ImGuiChildFlags_AutoResizeY);
if (ImGui::BeginTable("details", 2, ImGuiTableFlags_SizingFixedFit)) {
ImGui::TableSetupColumn("k", ImGuiTableColumnFlags_WidthFixed, 90);
ImGui::TableSetupColumn("v", ImGuiTableColumnFlags_WidthStretch);
for (const auto& [k, v] : rows) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextColored(roleColor(Role::TextTertiary), "%s", k);
ImGui::TableNextColumn();
ImGui::TextWrapped("%s", v.c_str());
}
ImGui::EndTable();
}
ImGui::EndChild();
ImGui::Dummy(ImVec2(0, 4));
const float bw = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x * 2) / 3;
pushAccent();
if (ImGui::Button("Website", ImVec2(bw, 0))) SDL_OpenURL("https://nexoniarz.github.io/Fry3DX/");
popAccent();
ImGui::SameLine();
if (ImGui::Button("Source on GitHub", ImVec2(bw, 0))) SDL_OpenURL("https://github.com/Nexoniarz/Fry3DX");
ImGui::SameLine();
if (ImGui::Button("Copy details", ImVec2(bw, 0))) {
std::string text = "Fry3DX";
for (const auto& [k, v] : rows) text += std::format("\n{}: {}", k, v);
SDL_SetClipboardText(text.c_str());
say("The details are copied: paste them into a bug report");
}
ImGui::Dummy(ImVec2(0, 6));
ImGui::PushStyleColor(ImGuiCol_Text, roleColor(Role::TextTertiary));
ImGui::TextWrapped("Made by Nexoniarz, open source (Apache License 2.0). Built on SDL 3, Dear ImGui, Lua 5.4, GLM, zstd, stb, volk and VMA; "
"the look is Niveus.");
ImGui::PopStyleColor();
ImGui::End();
}
};
} // namespace
int main(int argc, char** argv) {
std::string path;
std::vector<char*> rest = {argv[0]};
for (int i = 1; i < argc; ++i) {
if (std::string(argv[i]).ends_with(".fry") && argv[i - 1][0] != '+') path = argv[i]; // (not a console command's own)
else rest.push_back(argv[i]);
}
#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);
return keel::run(app, int(rest.size()), rest.data());
}