Fry3DX 0.5: builds for Windows, Linux and Android, a phone layout, Apache 2.0

- Apache License 2.0
- Windows: cross-built with clang-cl (cmake --preset windows), a window
  program with its icon; Linux: a portable preset (SDL and the C++
  library inside); Android: android/build_apk.py makes the APK
- tools/package.py packs the downloads; a GitHub workflow builds all
  three and makes a release for a v* tag
- A phone layout (fry_touch, on by default on Android): the UI drawn
  bigger, panels when asked for, a bar of big buttons, one finger to
  turn the view, two to move and pinch, a long press for a right-click;
  files through the phone's file picker
- Settings and autosaves now in a Nexoniarz folder (copied over once)
- README rewritten, the website shows the downloads and the phone

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 22:05:01 +02:00
commit f6f5e5f288
40 changed files with 1180 additions and 158 deletions

View file

@ -5,9 +5,23 @@ target_include_directories(fry_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(fry_core PUBLIC glm::glm libzstd_static)
target_compile_definitions(fry_core PUBLIC GLM_ENABLE_EXPERIMENTAL)
add_executable(fry3dx app.cpp icons.cpp)
if(ANDROID) # SDL's Java side loads the app as libmain.so and calls its (SDL_)main
add_library(fry3dx SHARED app.cpp icons.cpp)
set_target_properties(fry3dx PROPERTIES OUTPUT_NAME main LIBRARY_OUTPUT_DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY})
else()
add_executable(fry3dx app.cpp icons.cpp)
endif()
if(WIN32) # a window program (no console window), with its icon
target_sources(fry3dx PRIVATE fry3dx.rc)
set_target_properties(fry3dx PROPERTIES WIN32_EXECUTABLE ON)
target_link_options(fry3dx PRIVATE /ENTRY:mainCRTStartup)
endif()
target_link_libraries(fry3dx PRIVATE fry_core keel stb)
add_dependencies(fry3dx content)
foreach(t fry_core fry3dx)
target_compile_options(${t} PRIVATE -Wall -Wextra -Wno-missing-field-initializers)
if(MSVC)
target_compile_options(${t} PRIVATE /W3)
else()
target_compile_options(${t} PRIVATE -Wall -Wextra -Wno-missing-field-initializers)
endif()
endforeach()

View file

@ -9,12 +9,16 @@
#include "shapes.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/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>
@ -218,6 +222,13 @@ keel::Cvar fry_printer("fry_printer", "0", keel::CVAR_ARCHIVE, "Fry3DX: the 3D p
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_autosave("fry_autosave", "2", keel::CVAR_ARCHIVE, "Fry3DX: autosave every this many minutes while there are changes (0: off)");
class App : public keel::Game {
@ -341,6 +352,7 @@ public:
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 (a.size() < 9) return;
@ -497,6 +509,7 @@ public:
applyShading();
takeDialogResult();
ImGuiIO& io = ImGui::GetIO();
if (touch()) touchFrame(io);
const ImGuiID dock = ImGui::DockSpaceOverViewport(0, nullptr, ImGuiDockNodeFlags_PassthruCentralNode);
if (!layoutDone_) defaultLayout(dock);
if (ImGuiDockNode* c = ImGui::DockBuilderGetCentralNode(dock)) viewMin_ = c->Pos, viewMax_ = ImVec2(c->Pos.x + c->Size.x, c->Pos.y + c->Size.y);
@ -504,11 +517,14 @@ public:
menuBar();
toolbox();
toolOptions();
layersPanel();
propertiesPanel();
if (tab_ == Tab::Paint || tab_ == Tab::Layout) picturePanel();
statusBar();
if (!capturing()) viewButtons();
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 (showHelp_) helpWindow();
if (showAbout_) aboutWindow();
copiesWindow();
@ -644,6 +660,15 @@ private:
} ledit_;
bool pasteOnSurface_ = true;
uint32_t renamingFolder_ = 0;
// Touch (set when it starts: fry_touch).
bool touchMode_ = 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;
@ -899,8 +924,46 @@ private:
// ───────────────────────────── files ─────────────────────────────
void openFile(const std::string& path) {
// ── 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();
@ -919,21 +982,23 @@ private:
}
std::erase_if(o.layers, [](const PictureLayer& l) { return !l.image; });
}
path_ = path;
path_ = picked;
undo_.clear(), redo_.clear(), selected_.clear();
mode_ = Mode::Object, active_ = 0, dirty_ = false, ledit_ = {};
touchAll();
frameAll();
say("Opened " + stdfs::path(path).filename().string());
say("Opened " + fileName(picked));
}
bool saveFile(std::string path) {
if (path.empty()) return false;
if (stdfs::path(path).extension() != ".fry") path += ".fry";
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(path, scene_, 12, &err)) return afterSave_ = 0, say("Couldn't save: " + err), false;
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 {} ({})", stdfs::path(path).filename().string(), sizeText(stdfs::file_size(path))));
say(std::format("Saved {} ({})", fileName(path), sizeText(stdfs::file_size(local))));
if (const int then = std::exchange(afterSave_, 0)) doAfterAsking(then);
return true;
}
@ -946,8 +1011,16 @@ private:
&out, im.w, im.h, 4, im.px.data(), im.w * 4);
return out;
}
void exportFile(std::string path, int filter = -1) {
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();
@ -979,7 +1052,7 @@ private:
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" : stdfs::path(path_).filename().string();
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();
@ -1051,9 +1124,18 @@ private:
std::string from;
std::getline(in, from);
std::error_code ec;
const auto when = std::chrono::clock_cast<std::chrono::system_clock>(stdfs::last_write_time(f, ec));
const std::string label = std::format("{} {:%Y-%m-%d %H:%M} {}", from.empty() ? "Untitled" : stdfs::path(from).filename().string(),
std::chrono::zoned_time(std::chrono::current_zone(), std::chrono::floor<std::chrono::minutes>(when)), sizeText(stdfs::file_size(f, ec)));
// (in local time: through time_t, as not every C++ library has time zones yet)
const std::time_t t = std::chrono::system_clock::to_time_t(
std::chrono::time_point_cast<std::chrono::system_clock::duration>(stdfs::file_time_type::clock::to_sys(stdfs::last_write_time(f, ec))));
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();
@ -1105,6 +1187,8 @@ private:
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) {
@ -1146,10 +1230,10 @@ private:
else self->dialogCancelled_ = true;
};
SDL_Window* w = SDL_GetKeyboardFocus();
const std::string here = path_.empty() ? std::string() : stdfs::path(path_).parent_path().string();
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() ? at : path_.c_str());
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"}};
@ -1167,6 +1251,16 @@ private:
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 == 1) openFile(file);
else if (kind == 2) saveFile(file);
else if (kind == 3) exportFile(file, filter);
@ -3115,6 +3209,109 @@ private:
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_ = 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_ = 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(kAccentText, "%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) ImGui::PushStyleColor(ImGuiCol_Button, kAccent);
const bool pressed = ImGui::Button(label);
if (on) ImGui::PopStyleColor();
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_); }
@ -3922,8 +4119,11 @@ private:
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;
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);
@ -4004,7 +4204,7 @@ private:
if (!io.KeyShift && !io.KeyCtrl) selectNone();
paint(io);
} else if (selShape_ == SelShape::Lasso) lassoing_ = true, lasso_ = {io.MousePos};
else boxing_ = true, boxStart_ = io.MousePos;
else boxing_ = true, boxStart_ = io.MousePos, touchOrbit_ = touch() && tool_ != Tool::Select; // (a finger: dragging turns the view, but Select's box)
}
}
// Painting: on the model under the mouse.
@ -4644,8 +4844,10 @@ private:
// ───────────────────────────── panels ─────────────────────────────
void setupUi() {
keel::ui::setPixelScale(touchScale());
if (iniPath_.empty()) {
iniPath_ = keel::prefPath() + "layout_v3.ini";
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());
}
@ -4689,6 +4891,17 @@ private:
if (node && (node->IsSplitNode() || node->Windows.Size)) return; // (the user's own, from the .ini)
ImGui::DockBuilderRemoveNodeChildNodes(dock);
ImGuiID center = dock;
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);
}
ImGuiID left = ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.14f, nullptr, &center);
const ImGuiID leftBottom = ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.5f, nullptr, &left);
ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.22f, nullptr, &center);
@ -4705,14 +4918,7 @@ private:
ImGui::DockBuilderFinish(dock);
}
void menuBar() {
if (!ImGui::BeginMainMenuBar()) return;
if (haveLogo_) {
const float h = ImGui::GetFrameHeight() - 4;
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 1);
logoImage(h);
}
ImGui::TextColored(ImVec4(1.0f, 0.47f, 0.16f, 1.0f), "Fry3DX");
void menus() {
if (ImGui::BeginMenu("File")) {
if (ImGui::MenuItem("New", "Ctrl+N")) whenSaved(2);
if (ImGui::MenuItem("Open...", "Ctrl+O")) whenSaved(3);
@ -4827,13 +5033,27 @@ private:
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::TextColored(ImVec4(1.0f, 0.47f, 0.16f, 1.0f), "Fry3DX");
if (!touch()) menus();
else if (ImGui::BeginMenu("Menu")) { // (a phone: one menu, the others inside it)
menus();
ImGui::EndMenu();
}
// The tabs: what you're doing.
{
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 tw = 92.0f;
const float tw = touch() ? 72.0f : 92.0f;
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX() + 20.0f, (ImGui::GetWindowWidth() - tw * 5 - 16) * 0.5f));
for (int i = 0; i < 5; ++i) {
if (i) ImGui::SameLine(0, 4);
@ -4842,7 +5062,8 @@ private:
ImGui::PopID();
}
}
const std::string file = (path_.empty() ? "Untitled" : stdfs::path(path_).filename().string()) + (dirty_ ? " *" : "");
const std::string file = (path_.empty() ? "Untitled" : fileName(path_)) + (dirty_ ? " *" : "");
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();
@ -5845,7 +6066,7 @@ private:
const char* title = "Fry3DX — Frying 3D Extreme";
ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ImGui::CalcTextSize(title).x) * 0.5f);
ImGui::TextColored(ImVec4(1.0f, 0.47f, 0.16f, 1.0f), "%s", title);
ImGui::TextWrapped("A 3D modeller that does what you'd expect: pick a tool, click and drag. Made by Nexoniarz.");
ImGui::TextWrapped("A 3D modeller that does what you'd expect: pick a tool, click and drag. Made by Nexoniarz; open source (Apache License 2.0).");
ImGui::End();
}
};

25
src/fry3dx.rc Normal file
View file

@ -0,0 +1,25 @@
// Windows: the program's icon and details (Explorer's Properties).
1 ICON "../branding/fry3dx.ico"
1 VERSIONINFO
FILEVERSION 0,5,0,0
PRODUCTVERSION 0,5,0,0
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904b0"
BEGIN
VALUE "CompanyName", "Nexoniarz"
VALUE "FileDescription", "Fry3DX - Frying 3D Extreme"
VALUE "FileVersion", "0.5.0"
VALUE "InternalName", "fry3dx"
VALUE "LegalCopyright", "Copyright 2026 Nexoniarz, Apache License 2.0"
VALUE "OriginalFilename", "fry3dx.exe"
VALUE "ProductName", "Fry3DX"
VALUE "ProductVersion", "0.5.0"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200
END
END