macOS (Apple silicon and Intel): Fry3DX.app with its content in Resources and MoltenVK in Frameworks, OpenGL 4.1 when there's no Vulkan; the program's own path, memory, OS and CPU names on a Mac; files dropped on the window (and opened from the Finder) open; presets macos-arm64/macos-x64, a CI job, tools/package.py macos makes one universal app

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-20 17:36:24 +02:00
commit e22cd2e3e6
15 changed files with 273 additions and 40 deletions

View file

@ -1,4 +1,5 @@
# Builds Fry3DX for Linux (a portable .tar.gz and an AppImage), Windows (.zip) and Android (.apk).
# Builds Fry3DX for Linux (a portable .tar.gz and an AppImage), Windows (.zip), macOS (.zip with one
# app for Apple silicon and Intel) and Android (.apk).
# A tag v* (git tag v0.4.0 && git push --tags) also makes a GitHub release with them;
# otherwise they're the run's artifacts (Actions tab).
# Android signing: secrets FRY_KEYSTORE_B64 (the keystore, base64) and FRY_KEYSTORE_PASS. Keep
@ -53,6 +54,31 @@ jobs:
- uses: actions/upload-artifact@v4
with: { name: windows, path: "dist/*" }
macos:
# Built twice (Apple silicon, and Intel cross-built on the same Mac), then joined into one app.
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Tools
run: |
brew install ninja shaderc
# MoltenVK (Vulkan on Metal), for both kinds of Mac, carried inside the app.
curl -sfL https://github.com/KhronosGroup/MoltenVK/releases/download/v1.3.0/MoltenVK-macos.tar | tar -x -C "$RUNNER_TEMP"
MVK=$(find "$RUNNER_TEMP" -name libMoltenVK.dylib -path '*macOS*' | head -1)
lipo -info "$MVK"
echo "MVK=$MVK" >> "$GITHUB_ENV"
- name: Build
run: |
for arch in arm64 x64; do
cmake --preset macos-$arch -DFRY_MOLTENVK="$MVK"
cmake --build build/macos-$arch
done
cmake --build build/macos-arm64 --target check
- name: Package
run: python3 tools/package.py macos
- uses: actions/upload-artifact@v4
with: { name: macos, path: "dist/*" }
android:
runs-on: ubuntu-24.04
steps:
@ -77,7 +103,7 @@ jobs:
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: [linux, windows, android]
needs: [linux, windows, macos, android]
runs-on: ubuntu-latest
permissions: { contents: write }
steps:

View file

@ -30,18 +30,26 @@ set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
# Where the content goes: next to the program; on a Mac, inside the app (Fry3DX.app/Contents/
# Resources, where SDL_GetBasePath points).
if(APPLE)
set(FRY_CONTENT_DIR ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/Fry3DX.app/Contents/Resources)
else()
set(FRY_CONTENT_DIR ${CMAKE_RUNTIME_OUTPUT_DIRECTORY})
endif()
include(cmake/Dependencies.cmake)
include(cmake/Shaders.cmake)
add_subdirectory(engine)
add_subdirectory(src)
# Content next to the program: bin/engine/ (the engine's: fonts, sky, config); bin/Fry3DX/ (the app's own, none yet).
# Content next to the program: engine/ (the engine's: fonts, sky, config); Fry3DX/ (the app's own: scripts, branding).
add_custom_target(content ALL
COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/content ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/engine/fonts ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/Fry3DX
COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/content ${FRY_CONTENT_DIR}
COMMAND ${CMAKE_COMMAND} -E make_directory ${FRY_CONTENT_DIR}/engine/fonts ${FRY_CONTENT_DIR}/Fry3DX
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${dejavu_SOURCE_DIR}/ttf/DejaVuSans.ttf ${dejavu_SOURCE_DIR}/ttf/DejaVuSans-Bold.ttf
${dejavu_SOURCE_DIR}/LICENSE ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/engine/fonts/
${dejavu_SOURCE_DIR}/LICENSE ${FRY_CONTENT_DIR}/engine/fonts/
COMMENT "Copying content" VERBATIM)
enable_testing()

View file

@ -7,6 +7,10 @@
{ "name": "portable", "inherits": "base", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "FRY_PORTABLE": "ON" } },
{ "name": "windows", "inherits": "base", "toolchainFile": "${sourceDir}/cmake/toolchains/windows-msvc.cmake",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "CMAKE_MSVC_RUNTIME_LIBRARY": "MultiThreaded" } },
{ "name": "macos-arm64", "inherits": "base",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "FRY_PORTABLE": "ON", "CMAKE_OSX_ARCHITECTURES": "arm64", "CMAKE_OSX_DEPLOYMENT_TARGET": "13.3" } },
{ "name": "macos-x64", "inherits": "base",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "FRY_PORTABLE": "ON", "CMAKE_OSX_ARCHITECTURES": "x86_64", "CMAKE_OSX_DEPLOYMENT_TARGET": "13.3" } },
{ "name": "android", "inherits": "base", "toolchainFile": "$env{ANDROID_NDK_ROOT}/build/cmake/android.toolchain.cmake",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "ANDROID_ABI": "arm64-v8a", "ANDROID_PLATFORM": "android-28", "ANDROID_STL": "c++_static" } }
],
@ -15,6 +19,8 @@
{ "name": "release", "configurePreset": "release" },
{ "name": "portable", "configurePreset": "portable" },
{ "name": "windows", "configurePreset": "windows" },
{ "name": "macos-arm64", "configurePreset": "macos-arm64" },
{ "name": "macos-x64", "configurePreset": "macos-x64" },
{ "name": "android", "configurePreset": "android" }
]
}

0
a.out Executable file
View file

42
cmake/Info.plist.in Normal file
View file

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- Fry3DX.app's Info.plist (filled in by CMake: src/CMakeLists.txt). -->
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>Fry3DX</string>
<key>CFBundleDisplayName</key><string>Fry3DX</string>
<key>CFBundleExecutable</key><string>Fry3DX</string>
<key>CFBundleIdentifier</key><string>io.github.nexoniarz.fry3dx</string>
<key>CFBundleIconFile</key><string>fry3dx</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleShortVersionString</key><string>${PROJECT_VERSION}</string>
<key>CFBundleVersion</key><string>${PROJECT_VERSION}</string>
<key>NSHumanReadableCopyright</key><string>Copyright 2026 Nexoniarz. Apache License 2.0.</string>
<key>LSMinimumSystemVersion</key><string>${CMAKE_OSX_DEPLOYMENT_TARGET}</string>
<key>LSApplicationCategoryType</key><string>public.app-category.graphics-design</string>
<key>NSHighResolutionCapable</key><true/>
<key>NSSupportsAutomaticGraphicsSwitching</key><true/>
<!-- .fry files open in it from the Finder (they arrive as a dropped file). -->
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key><string>io.github.nexoniarz.fry3dx.fry</string>
<key>UTTypeDescription</key><string>Fry3DX model</string>
<key>UTTypeConformsTo</key><array><string>public.data</string></array>
<key>UTTypeIconFile</key><string>fry3dx</string>
<key>UTTypeTagSpecification</key>
<dict><key>public.filename-extension</key><array><string>fry</string></array></dict>
</dict>
</array>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key><string>Fry3DX model</string>
<key>CFBundleTypeRole</key><string>Editor</string>
<key>LSHandlerRank</key><string>Owner</string>
<key>LSItemContentTypes</key><array><string>io.github.nexoniarz.fry3dx.fry</string></array>
</dict>
</array>
</dict>
</plist>

View file

@ -2,6 +2,7 @@
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <thread>
@ -15,6 +16,11 @@
#include <sys/utsname.h>
#include <unistd.h>
#endif
#if defined(__APPLE__)
#include <mach-o/dyld.h>
#include <mach/mach.h>
#include <sys/sysctl.h>
#endif
#if defined(_MSC_VER)
#include <intrin.h>
#elif defined(__x86_64__) || defined(__i386__)
@ -30,6 +36,14 @@ std::string trim(std::string s) {
return s.substr(i);
}
#if defined(__APPLE__)
std::string sysctlText(const char* name) {
char buf[256] = {};
size_t size = sizeof buf - 1;
return sysctlbyname(name, buf, &size, nullptr, 0) == 0 ? trim(buf) : std::string();
}
#endif
// The CPU's own name for itself (x86: cpuid's brand string; elsewhere /proc/cpuinfo).
std::string cpuName() {
#if defined(_MSC_VER) || defined(__x86_64__) || defined(__i386__)
@ -46,6 +60,9 @@ std::string cpuName() {
char brand[49] = {};
std::memcpy(brand, regs, 48);
if (brand[0]) return trim(brand);
#endif
#if defined(__APPLE__)
if (std::string s = sysctlText("machdep.cpu.brand_string"); !s.empty()) return s; // ("Apple M2 Pro" too)
#endif
std::ifstream in("/proc/cpuinfo");
for (std::string line; std::getline(in, line);)
@ -64,6 +81,11 @@ std::string osName() {
fn(&v);
const char* name = v.dwMajorVersion == 10 ? (v.dwBuildNumber >= 22000 ? "Windows 11" : "Windows 10") : "Windows";
return std::string(name) + " (build " + std::to_string(v.dwBuildNumber) + ")";
#elif defined(__APPLE__)
utsname u{};
uname(&u);
const std::string v = sysctlText("kern.osproductversion");
return "macOS " + (v.empty() ? std::string("?") : v) + " (" + u.machine + ")";
#else
utsname u{};
uname(&u);
@ -95,6 +117,19 @@ Memory memory() {
if (GlobalMemoryStatusEx(&s)) m.total = s.ullTotalPhys, m.used = s.ullTotalPhys - s.ullAvailPhys;
PROCESS_MEMORY_COUNTERS pmc{sizeof pmc};
if (K32GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof pmc)) m.ours = pmc.WorkingSetSize;
#elif defined(__APPLE__)
uint64_t total = 0;
size_t size = sizeof total;
if (sysctlbyname("hw.memsize", &total, &size, nullptr, 0) == 0) m.total = total;
vm_statistics64_data_t vm{};
mach_msg_type_number_t count = HOST_VM_INFO64_COUNT;
if (host_statistics64(mach_host_self(), HOST_VM_INFO64, reinterpret_cast<host_info64_t>(&vm), &count) == KERN_SUCCESS) {
const uint64_t page = uint64_t(sysconf(_SC_PAGESIZE)); // (used as Activity Monitor counts it: in use by apps, wired, compressed)
m.used = (uint64_t(vm.internal_page_count) - vm.purgeable_count + vm.wire_count + vm.compressor_page_count) * page;
}
mach_task_basic_info_data_t task{};
mach_msg_type_number_t taskCount = MACH_TASK_BASIC_INFO_COUNT;
if (task_info(mach_task_self(), MACH_TASK_BASIC_INFO, reinterpret_cast<task_info_t>(&task), &taskCount) == KERN_SUCCESS) m.ours = task.resident_size;
#else
uint64_t available = 0;
std::ifstream in("/proc/meminfo");
@ -114,4 +149,39 @@ Memory memory() {
return m;
}
std::string executablePath() {
#if defined(_WIN32)
char exe[1024] = {};
GetModuleFileNameA(nullptr, exe, sizeof exe);
return exe;
#elif defined(__APPLE__)
char exe[4096] = {};
uint32_t size = sizeof exe;
if (_NSGetExecutablePath(exe, &size) != 0) return {};
std::error_code ec;
const auto real = std::filesystem::canonical(exe, ec);
return ec ? std::string(exe) : real.string();
#else
std::error_code ec;
const auto p = std::filesystem::read_symlink("/proc/self/exe", ec);
return ec ? std::string() : p.string();
#endif
}
bool debuggerAttached() {
#if defined(_WIN32)
return IsDebuggerPresent();
#elif defined(__APPLE__)
kinfo_proc info{};
size_t size = sizeof info;
int mib[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()};
return sysctl(mib, 4, &info, &size, nullptr, 0) == 0 && (info.kp_proc.p_flag & P_TRACED) != 0;
#else
std::ifstream f("/proc/self/status");
for (std::string line; std::getline(f, line);)
if (line.rfind("TracerPid:", 0) == 0) return std::atoi(line.c_str() + 10) != 0;
return false;
#endif
}
} // namespace keel::sys

View file

@ -18,4 +18,9 @@ struct Memory {
};
Memory memory();
// This program's file (for starting it again: crash reports, restarts). Empty if unknown.
std::string executablePath();
// Is a debugger watching (the freeze watchdog waits then)?
bool debuggerAttached();
} // namespace keel::sys

View file

@ -1,6 +1,7 @@
#include "crash.h"
#include <core/log.h>
#include <core/sysinfo.h>
#include <SDL3/SDL.h>
@ -122,17 +123,6 @@ void onSignal(int sig) {
}
#endif
bool debuggerAttached() {
#ifdef _WIN32
return IsDebuggerPresent();
#else
std::ifstream f("/proc/self/status");
for (std::string line; std::getline(f, line);)
if (line.rfind("TracerPid:", 0) == 0) return std::atoi(line.c_str() + 10) != 0;
return false;
#endif
}
void watchdog() {
constexpr double kTimeout = 45.0;
for (;;) {
@ -141,7 +131,7 @@ void watchdog() {
g_beat = seconds();
continue;
}
if (seconds() - g_beat < kTimeout || debuggerAttached()) continue;
if (seconds() - g_beat < kTimeout || sys::debuggerAttached()) continue;
#ifndef _WIN32
pthread_kill(g_main, SIGUSR2); // its handler reports from the stuck thread (its backtrace)
std::this_thread::sleep_for(std::chrono::seconds(3));
@ -153,12 +143,8 @@ void watchdog() {
void install(const std::string& prefPath, const std::vector<std::string>& args, bool dog) {
g_report = prefPath + "crash.txt";
#ifdef _WIN32
char exe[1024] = {};
GetModuleFileNameA(nullptr, exe, sizeof exe);
g_exe = exe;
#else
g_exe = std::filesystem::read_symlink("/proc/self/exe").string();
g_exe = sys::executablePath();
#ifndef _WIN32
g_main = pthread_self();
#endif
g_args = {g_exe, "-crashreport", g_report, "--"};

View file

@ -4,6 +4,7 @@
#include "core/console.h"
#include "core/cvar.h"
#include "core/log.h"
#include "core/sysinfo.h"
#include "core/time.h"
#include "fs/vfs.h"
#include "platform/input.h"
@ -289,6 +290,10 @@ int runClient(Game& game) {
#if defined(__ANDROID__)
if (first != RenderApi::Vulkan) first = RenderApi::GLES30;
const std::initializer_list<RenderApi> all{RenderApi::Vulkan, RenderApi::GLES30};
#elif defined(__APPLE__)
// (A Mac: Vulkan through MoltenVK, else OpenGL as far as macOS has it: 4.1.)
if (first != RenderApi::Vulkan) first = RenderApi::OpenGL40;
const std::initializer_list<RenderApi> all{RenderApi::Vulkan, RenderApi::OpenGL40};
#else
const std::initializer_list<RenderApi> all{RenderApi::Vulkan, RenderApi::OpenGL, RenderApi::OpenGL40, RenderApi::OpenGL31, RenderApi::GLES30};
#endif
@ -356,6 +361,10 @@ int runClient(Game& game) {
else game.onEscape();
continue;
}
if (ev.type == SDL_EVENT_DROP_FILE && ev.drop.data) {
game.onFileDropped(ev.drop.data);
continue;
}
// Don't let the toggle key's character land in the console input.
if (ev.type == SDL_EVENT_TEXT_INPUT && ev.text.text[0] == '`') continue;
input::handleEvent(ev);
@ -443,20 +452,17 @@ const char* buildVersion() { return KEEL_COMMIT ", " KEEL_COMMIT_DATE; }
// Replaces this process with a fresh one (Windows: starts it, then this one exits).
void relaunch(const std::vector<std::string>& args) {
std::vector<std::string> a = args;
const std::string exe = sys::executablePath();
#ifdef _WIN32
char exe[1024] = {};
GetModuleFileNameA(nullptr, exe, sizeof exe);
for (std::string& x : a)
if (x.find(' ') != std::string::npos) x = "\"" + x + "\"";
#else
const std::string exe = std::filesystem::read_symlink("/proc/self/exe").string();
#endif
std::vector<char*> argv;
for (std::string& x : a) argv.push_back(x.data());
argv.push_back(nullptr);
log::info("Restarting");
#ifdef _WIN32
_spawnv(_P_NOWAIT, exe, argv.data());
_spawnv(_P_NOWAIT, exe.c_str(), argv.data());
#else
execv(exe.c_str(), argv.data());
log::error("Restart failed");

View file

@ -33,6 +33,9 @@ public:
// The window's close button (or the OS asking it to close): false keeps it open (to ask
// about saving first; the game quits later itself). The quit command always quits.
virtual bool allowQuit() { return true; }
// A file dropped on the window, or opened with the program from the desktop (a Mac's Finder
// hands it over this way, not on the command line).
virtual void onFileDropped(const std::string& path) { (void)path; }
// Hot reload: called twice a second (hotreload 1) after the engine reloaded what changed
// (`changed`: textures, models, sounds); the game reloads its own (scripts, the map).
virtual void hotReload(const std::vector<std::string>& changed) { (void)changed; }

View file

@ -21,7 +21,14 @@ namespace {
// 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 defined(__APPLE__)
// macOS has no Vulkan of its own: the app carries MoltenVK (Vulkan on Metal) in
// Contents/Frameworks. (Unless the user pointed SDL somewhere else.)
if (const char* base = SDL_GetBasePath(); base && !SDL_GetHint(SDL_HINT_VULKAN_LIBRARY)) {
const std::filesystem::path mvk = std::filesystem::path(base) / ".." / "Frameworks" / "libMoltenVK.dylib";
if (std::filesystem::exists(mvk)) SDL_SetHint(SDL_HINT_VULKAN_LIBRARY, mvk.lexically_normal().string().c_str());
}
#elif !defined(_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);

View file

@ -46,6 +46,11 @@ static VKAPI_ATTR VkBool32 VKAPI_CALL debugCallback(VkDebugUtilsMessageSeverityF
bool Device::init(const Window& window, bool validation) {
window_ = &window;
#if defined(__APPLE__)
// The library SDL loaded for the window (the app's MoltenVK, or a Vulkan SDK's loader).
if (auto gipa = reinterpret_cast<PFN_vkGetInstanceProcAddr>(SDL_Vulkan_GetVkGetInstanceProcAddr())) volkInitializeCustom(gipa);
else
#endif
if (volkInitialize() != VK_SUCCESS) {
log::error("No Vulkan loader found - is a Vulkan driver installed?");
return false;

View file

@ -11,6 +11,22 @@ target_link_libraries(fry_script PUBLIC fry_core PRIVATE lua)
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})
elseif(APPLE) # a Mac app: Fry3DX.app, content in Contents/Resources, MoltenVK in Contents/Frameworks
set(icns ${PROJECT_SOURCE_DIR}/branding/fry3dx.icns)
add_executable(fry3dx MACOSX_BUNDLE app.cpp icons.cpp ${icns})
set_source_files_properties(${icns} PROPERTIES MACOSX_PACKAGE_LOCATION Resources)
set_target_properties(fry3dx PROPERTIES OUTPUT_NAME Fry3DX MACOSX_BUNDLE_INFO_PLIST ${PROJECT_SOURCE_DIR}/cmake/Info.plist.in)
# Vulkan on a Mac is MoltenVK (Vulkan on Metal): FRY_MOLTENVK=/path/to/libMoltenVK.dylib, or
# found (Homebrew's molten-vk, a Vulkan SDK). Without it, the app falls back to OpenGL 4.1.
find_library(FRY_MOLTENVK NAMES libMoltenVK.dylib MoltenVK HINTS $ENV{VULKAN_SDK}/lib /opt/homebrew/lib /usr/local/lib NO_CMAKE_FIND_ROOT_PATH)
if(FRY_MOLTENVK)
add_custom_command(TARGET fry3dx POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory $<TARGET_BUNDLE_CONTENT_DIR:fry3dx>/Frameworks
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${FRY_MOLTENVK} $<TARGET_BUNDLE_CONTENT_DIR:fry3dx>/Frameworks/libMoltenVK.dylib
VERBATIM)
else()
message(WARNING "No MoltenVK found (set FRY_MOLTENVK): Fry3DX.app will only have OpenGL 4.1")
endif()
else()
add_executable(fry3dx app.cpp icons.cpp)
endif()

View file

@ -790,7 +790,8 @@ private:
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;
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.
@ -1138,6 +1139,18 @@ private:
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;
@ -6493,6 +6506,8 @@ private:
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"}});

View file

@ -4,11 +4,13 @@
Fry3DX-<version>-linux-x64.tar.gz from build/portable (cmake --preset portable)
Fry3DX-<version>-linux-x64.AppImage the same, as one file (needs appimagetool: $APPIMAGETOOL or on PATH)
Fry3DX-<version>-windows-x64.zip from build/windows, or build/<dir> given with --windows-bin
Fry3DX-<version>-macos.zip from build/macos-arm64 and build/macos-x64 (on a Mac): one
Fry3DX.app for Apple silicon and Intel
python3 tools/package.py linux|appimage|windows [--bin DIR]
python3 tools/package.py linux|appimage|windows|macos [--bin DIR]
Each unpacks to one Fry3DX/ folder: the program, its content (engine/, Fry3DX/), LICENSE and a
README.txt. (The Android APK: android/build_apk.py.)
Each unpacks to one Fry3DX/ folder: the program, its content (engine/, Fry3DX/; on a Mac inside
the app), LICENSE and a README.txt. (The Android APK: android/build_apk.py.)
"""
import os
import shutil
@ -26,7 +28,7 @@ A 3D modeller that does what you'd expect: pick a tool, click, drag.
Made by Nexoniarz - https://nexoniarz.github.io/Fry3DX/
Start: {start}
Needs a graphics card with Vulkan (or OpenGL 4.6). F1 in the app lists every key.
Needs {needs}. F1 in the app lists every key.
Your settings and autosaves: {prefs}
Open source under the Apache License 2.0 (LICENSE). Sky pictures: Poly Haven, CC0.
@ -41,6 +43,15 @@ def version():
return "0"
PC_NEEDS = "a graphics card with Vulkan (or OpenGL 4.6)"
def readme(folder, start, prefs, needs=PC_NEEDS, crlf=False):
shutil.copy2(os.path.join(ROOT, "LICENSE"), folder)
with open(os.path.join(folder, "README.txt"), "w", newline="\r\n" if crlf else "\n") as f:
f.write(README.format(version=version(), start=start, prefs=prefs, needs=needs))
def stage(bin_dir, exe, extra, start, prefs):
out = os.path.join(DIST, "stage", "Fry3DX")
shutil.rmtree(os.path.dirname(out), ignore_errors=True)
@ -49,9 +60,7 @@ def stage(bin_dir, exe, extra, start, prefs):
shutil.copy2(os.path.join(bin_dir, name), out)
for d in ("engine", "Fry3DX"):
shutil.copytree(os.path.join(bin_dir, d), os.path.join(out, d))
shutil.copy2(os.path.join(ROOT, "LICENSE"), out)
with open(os.path.join(out, "README.txt"), "w", newline="\r\n" if exe.endswith(".exe") else "\n") as f:
f.write(README.format(version=version(), start=start, prefs=prefs))
readme(out, start, prefs, crlf=exe.endswith(".exe"))
return out
@ -94,6 +103,35 @@ def windows(bin_dir):
return path
def macos(bin_dir):
"""One Fry3DX.app for both kinds of Mac: the Apple silicon build's app, its program joined
(lipo) with the Intel build's; signed ad hoc (no Apple account: enough to run, with a right-click
Open the first time)."""
arm = os.path.join(bin_dir, "Fry3DX.app")
intel = os.path.join(ROOT, "build", "macos-x64", "bin", "Fry3DX.app")
out = os.path.join(DIST, "stage", "Fry3DX")
shutil.rmtree(os.path.dirname(out), ignore_errors=True)
os.makedirs(out)
app = os.path.join(out, "Fry3DX.app")
shutil.copytree(arm, app, symlinks=True)
exe = os.path.join("Contents", "MacOS", "Fry3DX")
if os.path.exists(os.path.join(intel, exe)):
subprocess.run(["lipo", "-create", os.path.join(arm, exe), os.path.join(intel, exe), "-output", os.path.join(app, exe)], check=True)
kind = "macos"
else:
print("(no build/macos-x64: Apple silicon only)")
kind = "macos-arm64"
subprocess.run(["strip", "-x", os.path.join(app, exe)], check=True)
subprocess.run(["codesign", "--force", "--deep", "--sign", "-", app], check=True)
readme(out, "Fry3DX.app (move it to Applications if you like). The first time: right-click it, Open,\n"
" as it isn't from the App Store", "~/Library/Application Support/Nexoniarz/Fry3DX",
"macOS 13.3 or newer; Vulkan through MoltenVK (inside the app), else OpenGL 4.1")
path = os.path.join(DIST, f"Fry3DX-{version()}-{kind}.zip")
# (ditto keeps what a Mac app needs: links, permissions, the signature)
subprocess.run(["ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", out, path], check=True)
return path
def main():
args = sys.argv[1:]
bin_dir = None
@ -103,8 +141,8 @@ def main():
del args[i:i + 2]
os.makedirs(DIST, exist_ok=True)
for what in args:
default = {"linux": "build/portable/bin", "appimage": "build/portable/bin", "windows": "build/windows/bin"}[what]
path = {"linux": linux, "appimage": appimage, "windows": windows}[what](bin_dir or os.path.join(ROOT, default))
default = {"linux": "build/portable/bin", "appimage": "build/portable/bin", "windows": "build/windows/bin", "macos": "build/macos-arm64/bin"}[what]
path = {"linux": linux, "appimage": appimage, "windows": windows, "macos": macos}[what](bin_dir or os.path.join(ROOT, default))
print(f"{os.path.relpath(path, ROOT)}: {os.path.getsize(path) / 1048576:.1f} MB")
shutil.rmtree(os.path.join(DIST, "stage"), ignore_errors=True)