Fry3DX 0.4: an easy 3D modeller

Shapes with live settings, shape editing, knife and hole tools, drawing
shapes onto other objects, combining objects (add / subtract / overlap),
copies, thickness, painting with stickers and layers, a Layout tab, a
Print tab with checks, packed .fry files with autosave, export to GLB,
glTF, FBX, OBJ, 3MF, STL and PLY, folders in Layers, its own crash
screen, and a website for GitHub Pages in docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NMSrd5oe1GpUD7E6d825KP
This commit is contained in:
Nexoniarz 2026-10-19 21:18:35 +02:00
commit f3e8eca87b
135 changed files with 23423 additions and 0 deletions

171
engine/platform/input.cpp Normal file
View file

@ -0,0 +1,171 @@
#include "platform/input.h"
#include "platform/touch.h"
#include "core/cvar.h"
#include "core/log.h"
#include <SDL3/SDL.h>
#include <algorithm>
#include <map>
#include <set>
namespace keel::input {
namespace {
float g_dx = 0, g_dy = 0;
bool g_captured = false;
bool g_noCapture = false;
std::map<std::string, std::string> g_binds; // key name → command
std::set<std::string> g_held; // keys whose "+" command is active
void (*g_capture)(const char*) = nullptr;
std::string lower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return char(std::tolower(c)); });
return s;
}
std::string keyName(SDL_Scancode sc) {
switch (sc) {
case SDL_SCANCODE_LCTRL: case SDL_SCANCODE_RCTRL: return "ctrl";
case SDL_SCANCODE_LSHIFT: case SDL_SCANCODE_RSHIFT: return "shift";
case SDL_SCANCODE_LALT: case SDL_SCANCODE_RALT: return "alt";
default: return lower(SDL_GetScancodeName(sc));
}
}
void press(const std::string& key, bool down) {
if (down && g_capture) {
auto cb = g_capture;
g_capture = nullptr;
cb(key.c_str());
return;
}
auto it = g_binds.find(key);
if (it == g_binds.end()) return;
const std::string& cmd = it->second;
if (cmd.starts_with('+')) {
if (down && g_held.insert(key).second) execute(cmd);
else if (!down && g_held.erase(key)) execute("-" + cmd.substr(1));
} else if (down) {
execute(cmd);
}
}
Cmd cmd_bind("bind", [](const Args& a) {
if (a.size() < 2) return log::info("usage: bind <key> [command]");
if (a.size() == 2) {
auto it = g_binds.find(lower(a[1]));
return log::info("\"{}\" = \"{}\"", a[1], it == g_binds.end() ? "" : it->second);
}
std::string cmd;
for (size_t i = 2; i < a.size(); ++i) cmd += (i > 2 ? " " : "") + a[i];
bind(a[1].c_str(), cmd.c_str());
}, "Bind a key to a command");
Cmd cmd_unbind("unbind", [](const Args& a) {
if (a.size() > 1) g_binds.erase(lower(a[1]));
}, "Remove a key binding");
Cmd cmd_unbindall("unbindall", [](const Args&) { g_binds.clear(); }, "Remove all key bindings");
Cmd cmd_bindlist("key_listboundkeys", [](const Args&) {
for (auto& [k, v] : g_binds) log::info(" \"{}\" = \"{}\"", k, v);
log::info("{} keys bound", g_binds.size());
}, "List every key binding");
Cmd cmd_bindlist2("bindlist", [](const Args&) { execute("key_listboundkeys"); }, "List every key binding");
// Key names for the console's suggestions.
std::vector<std::string> keyNames() {
std::vector<std::string> v;
for (char c = 'a'; c <= 'z'; ++c) v.emplace_back(1, c);
for (char c = '0'; c <= '9'; ++c) v.emplace_back(1, c);
for (int f = 1; f <= 12; ++f) v.push_back("f" + std::to_string(f));
for (const char* k : {"space", "tab", "escape", "return", "backspace", "ctrl", "shift", "alt", "left ctrl", "left shift", "left alt", "up", "down",
"left", "right", "insert", "delete", "home", "end", "pageup", "pagedown", "mouse1", "mouse2", "mouse3", "mouse4", "mouse5",
"mwheelup", "mwheeldown", "`", "-", "=", "[", "]", ";", "'", ",", ".", "/"})
v.emplace_back(k);
return v;
}
[[maybe_unused]] const bool g_keyCompleters = [] {
cmd_bind.complete([](std::string_view p, size_t arg) { return matching(arg == 1 ? keyNames() : allNames(), p); });
cmd_unbind.complete([](std::string_view p, size_t) {
std::vector<std::string> v;
for (auto& [k, _] : g_binds) v.push_back(k);
return matching(v, p);
});
return true;
}();
} // namespace
void bind(const char* key, const char* command) {
if (!*command) g_binds.erase(lower(key));
else g_binds[lower(key)] = command;
}
std::string bindingsConfig() {
std::string out = "unbindall\n";
for (auto& [k, v] : g_binds) out += "bind \"" + k + "\" \"" + v + "\"\n";
return out;
}
std::string keyFor(const char* command) {
for (auto& [k, v] : g_binds)
if (v == command) return k;
return {};
}
void captureNextKey(void (*callback)(const char*)) { g_capture = callback; }
void releaseAll() {
for (const std::string& key : std::set<std::string>(g_held)) press(key, false);
}
bool keyDown(int scancode) {
if (!g_captured) return false;
int n = 0;
const bool* keys = SDL_GetKeyboardState(&n);
return scancode >= 0 && scancode < n && keys[scancode];
}
bool mouseDown(int button) { return g_captured && (SDL_GetMouseState(nullptr, nullptr) & SDL_BUTTON_MASK(button)); }
void takeMouseDelta(float& dx, float& dy) {
float tx, ty; // looking with a finger counts as the mouse
touch::takeLook(tx, ty);
dx = g_dx + tx;
dy = g_dy + ty;
g_dx = g_dy = 0;
}
bool captured() { return g_captured; }
void disableCapture() { g_noCapture = true; }
void setCaptured(bool on) {
if (on && g_noCapture) return;
if (on == g_captured) return;
g_captured = on;
if (!on) releaseAll(), touch::releaseAll();
if (SDL_Window* w = SDL_GetKeyboardFocus()) SDL_SetWindowRelativeMouseMode(w, on);
g_dx = g_dy = 0;
}
void handleEvent(const SDL_Event& ev) {
// While playing, touches are the touch controls (platform/touch.cpp), not a mouse.
if ((ev.type == SDL_EVENT_MOUSE_MOTION && ev.motion.which == SDL_TOUCH_MOUSEID) ||
((ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN || ev.type == SDL_EVENT_MOUSE_BUTTON_UP) && ev.button.which == SDL_TOUCH_MOUSEID)) {
if (g_captured) return;
}
if (ev.type == SDL_EVENT_MOUSE_MOTION && g_captured) {
g_dx += ev.motion.xrel;
g_dy += ev.motion.yrel;
}
if (!g_captured && !g_capture) return;
if ((ev.type == SDL_EVENT_KEY_DOWN || ev.type == SDL_EVENT_KEY_UP) && !ev.key.repeat)
press(keyName(ev.key.scancode), ev.type == SDL_EVENT_KEY_DOWN);
if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN || ev.type == SDL_EVENT_MOUSE_BUTTON_UP)
// Source's names: mouse1 left, mouse2 right, mouse3 middle (SDL has middle = 2, right = 3).
press("mouse" + std::to_string(ev.button.button == SDL_BUTTON_RIGHT ? 2 : ev.button.button == SDL_BUTTON_MIDDLE ? 3 : ev.button.button),
ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN);
if (ev.type == SDL_EVENT_MOUSE_WHEEL && ev.wheel.y != 0) {
const std::string key = ev.wheel.y > 0 ? "mwheelup" : "mwheeldown";
press(key, true);
press(key, false);
}
}
} // namespace keel::input

35
engine/platform/input.h Normal file
View file

@ -0,0 +1,35 @@
#pragma once
#include <string>
union SDL_Event;
namespace keel::input {
bool keyDown(int scancode); // SDL_SCANCODE_*
bool mouseDown(int button); // SDL_BUTTON_LEFT etc.
// Mouse movement since the last call, in pixels.
void takeMouseDelta(float& dx, float& dy);
// While captured the cursor is hidden and the game owns the mouse and keyboard.
bool captured();
// Key bindings, Quake/Source style: "bind w +forward" runs "+forward" on press
// and "-forward" on release; other commands run on press. Key names: letters,
// digits, "space", "ctrl", "shift", "escape", "f1".., "mouse1".."mouse5",
// "mwheelup", "mwheeldown".
void bind(const char* key, const char* command);
// Lines "unbindall" + "bind ..." for config.cfg.
std::string bindingsConfig();
// The key bound to `command` ("" if none), for menus.
std::string keyFor(const char* command);
// The next key pressed is reported here instead of running its binding (options menu).
void captureNextKey(void (*callback)(const char* keyName));
// Engine side.
void setCaptured(bool on);
// Never grab the mouse (test mode).
void disableCapture();
void handleEvent(const SDL_Event& ev);
// Releases every held "+" action (when a menu opens mid-press).
void releaseAll();
} // namespace keel::input

190
engine/platform/touch.cpp Normal file
View file

@ -0,0 +1,190 @@
#include "platform/touch.h"
#include "core/cvar.h"
#include <SDL3/SDL.h>
#include <imgui.h>
#include <algorithm>
#include <cmath>
#include <string>
#include <unordered_map>
namespace keel::touch {
namespace {
#if defined(__ANDROID__)
Cvar touch_controls("touch_controls", "1", CVAR_ARCHIVE, "On-screen touch controls (0 off, 1 on)");
#else
Cvar touch_controls("touch_controls", "1", CVAR_ARCHIVE, "On-screen touch controls once the screen is touched (0 never)");
#endif
Cvar touch_sensitivity("touch_sensitivity", "1.6", CVAR_ARCHIVE, "How far looking turns for a finger's drag");
Cvar touch_size("touch_size", "1", CVAR_ARCHIVE, "Size of the touch buttons and stick");
// A button: its command ("+x" held, else run once), label, and where (from the bottom-right
// corner, in shorter-screen-sides), how big.
struct Button {
const char* cmd;
const char* label;
float x, y, r;
};
constexpr Button kButtons[] = {
{"+attack", "FIRE", 0.15f, 0.34f, 0.085f}, {"+jump", "JUMP", 0.33f, 0.14f, 0.07f}, {"+duck", "DUCK", 0.13f, 0.13f, 0.06f},
{"+use", "USE", 0.34f, 0.36f, 0.055f}, {"+attack2", "ALT", 0.13f, 0.57f, 0.055f}, {"+sprint", "RUN", 0.50f, 0.12f, 0.055f},
{"flashlight", "LIGHT", 0.32f, 0.57f, 0.045f}, {"backpack", "BAG", 0.47f, 0.32f, 0.045f},
};
constexpr int kButtonCount = int(sizeof kButtons / sizeof kButtons[0]);
struct Finger {
enum Role { Look, Stick, ButtonHeld, Pause } role;
float x, y; // where it is now (pixels)
float ox, oy; // the stick's centre
int button = -1;
};
std::unordered_map<SDL_FingerID, Finger> g_fingers;
bool g_used = false, g_escape = false;
float g_lookX = 0, g_lookY = 0;
bool g_held[kButtonCount] = {};
#if defined(__ANDROID__)
constexpr bool kAlways = true;
#else
constexpr bool kAlways = false;
#endif
ImVec2 screen() {
const ImVec2 s = ImGui::GetIO().DisplaySize;
return s.x > 0 ? s : ImVec2(1280, 720);
}
float unit() { // the shorter side, scaled
const ImVec2 s = screen();
return std::min(s.x, s.y) * std::clamp(touch_size.f(), 0.5f, 2.0f);
}
ImVec2 buttonAt(const Button& b) {
const ImVec2 s = screen();
const float m = unit();
return ImVec2(s.x - b.x * m, s.y - b.y * m);
}
ImVec2 pauseAt() { return ImVec2(unit() * 0.07f, unit() * 0.07f); }
void press(int i, bool down) {
const Button& b = kButtons[i];
if (b.cmd[0] == '+') {
if (down == g_held[i]) return;
g_held[i] = down;
execute(down ? b.cmd : ("-" + std::string(b.cmd + 1)));
} else if (down) {
execute(b.cmd);
}
}
} // namespace
bool active() { return touch_controls.b() && (kAlways || g_used); }
void handleEvent(const SDL_Event& ev, bool inGame) {
if (ev.type != SDL_EVENT_FINGER_DOWN && ev.type != SDL_EVENT_FINGER_MOTION && ev.type != SDL_EVENT_FINGER_UP) return;
g_used = true;
const ImVec2 s = screen();
const float x = ev.tfinger.x * s.x, y = ev.tfinger.y * s.y;
if (!inGame || !active()) { // menus: SDL's mouse emulation does the taps
if (ev.type == SDL_EVENT_FINGER_UP) g_fingers.erase(ev.tfinger.fingerID);
return;
}
const float m = unit();
if (ev.type == SDL_EVENT_FINGER_DOWN) {
Finger f{Finger::Look, x, y, x, y};
const ImVec2 p = pauseAt();
if (std::hypot(x - p.x, y - p.y) < m * 0.06f) {
f.role = Finger::Pause;
g_escape = true;
} else {
for (int i = 0; i < kButtonCount; ++i) {
const ImVec2 c = buttonAt(kButtons[i]);
if (std::hypot(x - c.x, y - c.y) < kButtons[i].r * m * 1.25f) {
f.role = Finger::ButtonHeld, f.button = i;
press(i, true);
break;
}
}
if (f.role == Finger::Look && x < s.x * 0.4f && y > s.y * 0.3f) f.role = Finger::Stick; // the left thumb's area
}
g_fingers[ev.tfinger.fingerID] = f;
} else if (ev.type == SDL_EVENT_FINGER_MOTION) {
auto it = g_fingers.find(ev.tfinger.fingerID);
if (it == g_fingers.end()) return;
Finger& f = it->second;
if (f.role == Finger::Look) {
g_lookX += (x - f.x) * touch_sensitivity.f();
g_lookY += (y - f.y) * touch_sensitivity.f();
}
f.x = x, f.y = y;
if (f.role == Finger::Stick) { // the stick's centre follows a thumb that goes past its edge
const float r = m * 0.11f, dx = x - f.ox, dy = y - f.oy, d = std::hypot(dx, dy);
if (d > r) f.ox = x - dx / d * r, f.oy = y - dy / d * r;
}
} else {
auto it = g_fingers.find(ev.tfinger.fingerID);
if (it == g_fingers.end()) return;
if (it->second.role == Finger::ButtonHeld) press(it->second.button, false);
g_fingers.erase(it);
}
}
void move(float& forward, float& side) {
forward = side = 0.0f;
const float r = unit() * 0.11f;
for (const auto& [id, f] : g_fingers)
if (f.role == Finger::Stick) {
side = std::clamp((f.x - f.ox) / r, -1.0f, 1.0f);
forward = std::clamp(-(f.y - f.oy) / r, -1.0f, 1.0f);
}
}
void takeLook(float& dx, float& dy) {
dx = g_lookX, dy = g_lookY;
g_lookX = g_lookY = 0;
}
bool takeEscape() { return std::exchange(g_escape, false); }
void releaseAll() {
for (int i = 0; i < kButtonCount; ++i)
if (g_held[i]) press(i, false);
g_fingers.clear();
}
void draw(bool inGame) {
if (!inGame || !active()) return;
ImDrawList* dl = ImGui::GetForegroundDrawList();
const float m = unit();
const ImU32 ring = IM_COL32(255, 255, 255, 110), fill = IM_COL32(20, 20, 24, 70), hot = IM_COL32(220, 40, 40, 150), text = IM_COL32(255, 255, 255, 200);
auto label = [&](ImVec2 c, const char* t) {
const ImVec2 sz = ImGui::CalcTextSize(t);
dl->AddText(ImVec2(c.x - sz.x * 0.5f, c.y - sz.y * 0.5f), text, t);
};
for (int i = 0; i < kButtonCount; ++i) {
const ImVec2 c = buttonAt(kButtons[i]);
const float r = kButtons[i].r * m;
dl->AddCircleFilled(c, r, g_held[i] ? hot : fill, 40);
dl->AddCircle(c, r, ring, 40, 2.0f);
label(c, kButtons[i].label);
}
const ImVec2 p = pauseAt();
dl->AddCircleFilled(p, m * 0.045f, fill, 32);
dl->AddCircle(p, m * 0.045f, ring, 32, 2.0f);
dl->AddRectFilled(ImVec2(p.x - m * 0.016f, p.y - m * 0.02f), ImVec2(p.x - m * 0.006f, p.y + m * 0.02f), text);
dl->AddRectFilled(ImVec2(p.x + m * 0.006f, p.y - m * 0.02f), ImVec2(p.x + m * 0.016f, p.y + m * 0.02f), text);
// The stick, where the thumb is (or a hint of where it goes).
const ImVec2 s = screen();
bool stick = false;
for (const auto& [id, f] : g_fingers)
if (f.role == Finger::Stick) {
stick = true;
dl->AddCircleFilled(ImVec2(f.ox, f.oy), m * 0.11f, fill, 48);
dl->AddCircle(ImVec2(f.ox, f.oy), m * 0.11f, ring, 48, 2.0f);
const float dx = f.x - f.ox, dy = f.y - f.oy, d = std::hypot(dx, dy), k = d > m * 0.11f ? m * 0.11f / d : 1.0f;
dl->AddCircleFilled(ImVec2(f.ox + dx * k, f.oy + dy * k), m * 0.045f, hot, 32);
}
if (!stick) {
const ImVec2 c(m * 0.22f, s.y - m * 0.22f);
dl->AddCircle(c, m * 0.11f, IM_COL32(255, 255, 255, 50), 48, 2.0f);
}
}
} // namespace keel::touch

22
engine/platform/touch.h Normal file
View file

@ -0,0 +1,22 @@
#pragma once
// Touch controls (phones, tablets, touch screens): while you play, a stick under your left thumb
// moves, dragging on the right looks, and buttons on the right press the same "+jump",
// "+attack"... a key would. In menus, touches are taps (SDL turns them into mouse clicks).
// They show on Android, and anywhere once a finger touches the screen (touch_controls 0: never).
union SDL_Event;
namespace keel::touch {
bool active();
// inGame: playing (the mouse is captured), when touches are controls, not taps.
void handleEvent(const SDL_Event& ev, bool inGame);
// The stick: forward and sideways, -1..1.
void move(float& forward, float& side);
// Looking since the last call, in mouse pixels (so mouse sensitivity applies).
void takeLook(float& dx, float& dy);
// The pause button was pressed (once).
bool takeEscape();
// Draws the controls over the game (playing only).
void draw(bool inGame);
// Lets go of everything held (a menu opened).
void releaseAll();
} // namespace keel::touch

View file

@ -0,0 +1,98 @@
#include "platform/window.h"
#include "core/log.h"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cstdlib>
#include <filesystem>
#ifdef _WIN32
// Hybrid laptops: ask the NVIDIA / AMD drivers for the dedicated GPU (read from the executable).
extern "C" {
__declspec(dllexport) unsigned long NvOptimusEnablement = 1;
__declspec(dllexport) int AmdPowerXpressRequestHighPerformance = 1;
}
#endif
namespace keel {
namespace {
// Hybrid laptops on Linux: OpenGL goes to whatever GPU runs the desktop (often the integrated
// one) unless asked. Vulkan picks the discrete GPU itself; this makes OpenGL do the same. Set
// before SDL loads the GL library; anything the user already set wins. KEEL_IGPU=1 opts out.
void preferDedicatedGpu() {
#ifndef _WIN32
if (std::getenv("KEEL_IGPU")) return;
if (std::filesystem::exists("/proc/driver/nvidia")) { // NVIDIA's own driver: PRIME render offload
setenv("__NV_PRIME_RENDER_OFFLOAD", "1", 0);
setenv("__GLX_VENDOR_LIBRARY_NAME", "nvidia", 0);
setenv("__VK_LAYER_NV_optimus", "NVIDIA_only", 0);
} else {
setenv("DRI_PRIME", "1", 0); // Mesa: the other GPU if there is one, else the same
}
#endif
}
} // namespace
bool Window::init(const char* title, int width, int height, bool opengl) {
preferDedicatedGpu();
if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS)) {
log::error("SDL_Init failed: {}", SDL_GetError());
return false;
}
window_ = SDL_CreateWindow(title, width, height, (opengl ? SDL_WINDOW_OPENGL : SDL_WINDOW_VULKAN) | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY);
if (!window_) {
log::error("SDL_CreateWindow failed: {}", SDL_GetError());
return false;
}
return true;
}
void Window::shutdown() {
if (window_) SDL_DestroyWindow(window_);
window_ = nullptr;
SDL_Quit();
}
void Window::setIcon(const uint8_t* rgba, int width, int height) {
if (SDL_Surface* s = SDL_CreateSurfaceFrom(width, height, SDL_PIXELFORMAT_ABGR8888, const_cast<uint8_t*>(rgba), width * 4)) {
SDL_SetWindowIcon(window_, s);
SDL_DestroySurface(s);
}
}
void Window::setMode(int mode, int width, int height) {
if (!window_) return;
if (mode == 0) {
SDL_SetWindowFullscreen(window_, false);
SDL_SetWindowSize(window_, width, height);
SDL_SetWindowPosition(window_, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED);
return;
}
SDL_DisplayMode closest{};
const bool exclusive = mode == 2 && SDL_GetClosestFullscreenDisplayMode(SDL_GetDisplayForWindow(window_), width, height, 0.0f, true, &closest);
SDL_SetWindowFullscreenMode(window_, exclusive ? &closest : nullptr); // nullptr = borderless desktop
SDL_SetWindowFullscreen(window_, true);
}
std::vector<std::pair<int, int>> Window::resolutions() const {
std::vector<std::pair<int, int>> out;
int count = 0;
if (SDL_DisplayMode** modes = SDL_GetFullscreenDisplayModes(SDL_GetDisplayForWindow(window_), &count)) {
for (int i = 0; i < count; ++i) {
const std::pair<int, int> r{modes[i]->w, modes[i]->h};
if (std::find(out.begin(), out.end(), r) == out.end()) out.push_back(r);
}
SDL_free(modes);
}
return out;
}
void Window::pixelSize(uint32_t& w, uint32_t& h) const {
int iw = 0, ih = 0;
if (!(SDL_GetWindowFlags(window_) & SDL_WINDOW_MINIMIZED)) SDL_GetWindowSizeInPixels(window_, &iw, &ih);
w = uint32_t(iw);
h = uint32_t(ih);
}
} // namespace keel

31
engine/platform/window.h Normal file
View file

@ -0,0 +1,31 @@
#pragma once
#include <cstdint>
#include <cstdint>
#include <utility>
#include <vector>
struct SDL_Window;
namespace keel {
class Window {
public:
// opengl: a window for an OpenGL context (the backend creates it); otherwise Vulkan.
bool init(const char* title, int width, int height, bool opengl = false);
void shutdown();
SDL_Window* handle() const { return window_; }
// Size in pixels (not points); 0×0 while minimized.
void pixelSize(uint32_t& w, uint32_t& h) const;
// mode: 0 = windowed (width × height), 1 = borderless fullscreen (desktop size),
// 2 = exclusive fullscreen at the display mode closest to width × height.
void setMode(int mode, int width, int height);
void setIcon(const uint8_t* rgba, int width, int height);
// Fullscreen resolutions the display offers, largest first, no duplicates.
std::vector<std::pair<int, int>> resolutions() const;
private:
SDL_Window* window_ = nullptr;
};
} // namespace keel