diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 60ffbc3..cb3268d 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -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:
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 2e08a4b..91e9c29 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -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()
diff --git a/CMakePresets.json b/CMakePresets.json
index 54c2b77..0be504f 100644
--- a/CMakePresets.json
+++ b/CMakePresets.json
@@ -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" }
]
}
diff --git a/a.out b/a.out
new file mode 100755
index 0000000..e69de29
diff --git a/cmake/Info.plist.in b/cmake/Info.plist.in
new file mode 100644
index 0000000..6c353c7
--- /dev/null
+++ b/cmake/Info.plist.in
@@ -0,0 +1,42 @@
+
+
+
+
+
+ CFBundleNameFry3DX
+ CFBundleDisplayNameFry3DX
+ CFBundleExecutableFry3DX
+ CFBundleIdentifierio.github.nexoniarz.fry3dx
+ CFBundleIconFilefry3dx
+ CFBundlePackageTypeAPPL
+ CFBundleInfoDictionaryVersion6.0
+ CFBundleShortVersionString${PROJECT_VERSION}
+ CFBundleVersion${PROJECT_VERSION}
+ NSHumanReadableCopyrightCopyright 2026 Nexoniarz. Apache License 2.0.
+ LSMinimumSystemVersion${CMAKE_OSX_DEPLOYMENT_TARGET}
+ LSApplicationCategoryTypepublic.app-category.graphics-design
+ NSHighResolutionCapable
+ NSSupportsAutomaticGraphicsSwitching
+
+ UTExportedTypeDeclarations
+
+
+ UTTypeIdentifierio.github.nexoniarz.fry3dx.fry
+ UTTypeDescriptionFry3DX model
+ UTTypeConformsTopublic.data
+ UTTypeIconFilefry3dx
+ UTTypeTagSpecification
+ public.filename-extensionfry
+
+
+ CFBundleDocumentTypes
+
+
+ CFBundleTypeNameFry3DX model
+ CFBundleTypeRoleEditor
+ LSHandlerRankOwner
+ LSItemContentTypesio.github.nexoniarz.fry3dx.fry
+
+
+
+
diff --git a/engine/core/sysinfo.cpp b/engine/core/sysinfo.cpp
index e59f42b..1cccf80 100644
--- a/engine/core/sysinfo.cpp
+++ b/engine/core/sysinfo.cpp
@@ -2,6 +2,7 @@
#include
#include
+#include
#include
#include
@@ -15,6 +16,11 @@
#include
#include
#endif
+#if defined(__APPLE__)
+#include
+#include
+#include
+#endif
#if defined(_MSC_VER)
#include
#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(&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), &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
diff --git a/engine/core/sysinfo.h b/engine/core/sysinfo.h
index a14bae5..fc501a8 100644
--- a/engine/core/sysinfo.h
+++ b/engine/core/sysinfo.h
@@ -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
diff --git a/engine/crash.cpp b/engine/crash.cpp
index c82a474..4cfcfb8 100644
--- a/engine/crash.cpp
+++ b/engine/crash.cpp
@@ -1,6 +1,7 @@
#include "crash.h"
#include
+#include
#include
@@ -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& 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, "--"};
diff --git a/engine/engine.cpp b/engine/engine.cpp
index 86f4753..1faad39 100644
--- a/engine/engine.cpp
+++ b/engine/engine.cpp
@@ -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 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 all{RenderApi::Vulkan, RenderApi::OpenGL40};
#else
const std::initializer_list 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& args) {
std::vector 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 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");
diff --git a/engine/engine.h b/engine/engine.h
index 05bd41a..b0f6ab3 100644
--- a/engine/engine.h
+++ b/engine/engine.h
@@ -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& changed) { (void)changed; }
diff --git a/engine/platform/window.cpp b/engine/platform/window.cpp
index 359cfa0..2063a74 100644
--- a/engine/platform/window.cpp
+++ b/engine/platform/window.cpp
@@ -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);
diff --git a/engine/rhi/vulkan/vk_device.cpp b/engine/rhi/vulkan/vk_device.cpp
index 4c29994..eed0e5f 100644
--- a/engine/rhi/vulkan/vk_device.cpp
+++ b/engine/rhi/vulkan/vk_device.cpp
@@ -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(SDL_Vulkan_GetVkGetInstanceProcAddr())) volkInitializeCustom(gipa);
+ else
+#endif
if (volkInitialize() != VK_SUCCESS) {
log::error("No Vulkan loader found - is a Vulkan driver installed?");
return false;
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 91121b5..6bc1be3 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -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 $/Frameworks
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different ${FRY_MOLTENVK} $/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()
diff --git a/src/app.cpp b/src/app.cpp
index 0e3936c..9eab7bf 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -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"}});
diff --git a/tools/package.py b/tools/package.py
index 4284518..2ba8401 100644
--- a/tools/package.py
+++ b/tools/package.py
@@ -4,11 +4,13 @@
Fry3DX--linux-x64.tar.gz from build/portable (cmake --preset portable)
Fry3DX--linux-x64.AppImage the same, as one file (needs appimagetool: $APPIMAGETOOL or on PATH)
Fry3DX--windows-x64.zip from build/windows, or build/ given with --windows-bin
+ Fry3DX--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)