From e22cd2e3e68a4252d113be9cfbe48e794f262cf8 Mon Sep 17 00:00:00 2001 From: Nexoniarz Date: Tue, 20 Oct 2026 17:36:24 +0200 Subject: [PATCH] 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 --- .github/workflows/build.yml | 30 +++++++++++++- CMakeLists.txt | 16 ++++++-- CMakePresets.json | 6 +++ a.out | 0 cmake/Info.plist.in | 42 ++++++++++++++++++++ engine/core/sysinfo.cpp | 70 +++++++++++++++++++++++++++++++++ engine/core/sysinfo.h | 5 +++ engine/crash.cpp | 22 ++--------- engine/engine.cpp | 16 +++++--- engine/engine.h | 3 ++ engine/platform/window.cpp | 9 ++++- engine/rhi/vulkan/vk_device.cpp | 5 +++ src/CMakeLists.txt | 16 ++++++++ src/app.cpp | 17 +++++++- tools/package.py | 56 +++++++++++++++++++++----- 15 files changed, 273 insertions(+), 40 deletions(-) create mode 100755 a.out create mode 100644 cmake/Info.plist.in 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)