diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..44a50eb --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,89 @@ +# Builds Fry3DX for Linux (a portable .tar.gz and an AppImage), Windows (.zip) 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 +# the same key: Android only updates an app signed with the key it was installed with. +name: Build +on: + push: + tags: ["v*"] + workflow_dispatch: + +jobs: + linux: + # The oldest supported Ubuntu: what's built here runs on any distro as new (glibc 2.35+). + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: Tools + run: | + sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test + sudo apt-get update + sudo apt-get install -y gcc-13 g++-13 ninja-build libfuse2 \ + libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev libxss-dev libxtst-dev libxfixes-dev \ + libwayland-dev libwayland-bin wayland-protocols libxkbcommon-dev libegl-dev libdecor-0-dev \ + libasound2-dev libpulse-dev libpipewire-0.3-dev libdbus-1-dev libudev-dev libdrm-dev libgbm-dev + pip install cmake==3.31.6 + wget -q https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -O appimagetool + chmod +x appimagetool + - name: Build + env: { CC: gcc-13, CXX: g++-13 } + run: | + cmake --preset portable -DGLSLC=$ANDROID_NDK_HOME/shader-tools/linux-x86_64/glslc + cmake --build build/portable + - name: Package + run: APPIMAGETOOL=$PWD/appimagetool python3 tools/package.py linux appimage + - uses: actions/upload-artifact@v4 + with: { name: linux, path: "dist/*" } + + windows: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + - uses: ilammy/msvc-dev-cmd@v1 + - name: Build + shell: bash + run: | + GLSLC=$(ls -d "$ANDROID_NDK_HOME"/shader-tools/windows-x86_64/glslc.exe) + cmake -S . -B build/windows -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang-cl -DCMAKE_CXX_COMPILER=clang-cl \ + -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded "-DGLSLC=$GLSLC" + cmake --build build/windows + - name: Package + run: python tools/package.py windows + - uses: actions/upload-artifact@v4 + with: { name: windows, path: "dist/*" } + + android: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: { distribution: temurin, java-version: "17" } + - name: SDK + run: | + yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;27.2.12479018" "build-tools;35.0.0" "platforms;android-35" > /dev/null + sudo apt-get install -y ninja-build + - name: Build + env: + ANDROID_NDK_ROOT: ${{ env.ANDROID_HOME }}/ndk/27.2.12479018 + KEY_B64: ${{ secrets.FRY_KEYSTORE_B64 }} + FRY_KEYSTORE_PASS: ${{ secrets.FRY_KEYSTORE_PASS }} + run: | + export ANDROID_NDK_ROOT="$ANDROID_HOME/ndk/27.2.12479018" + if [ -n "$KEY_B64" ]; then echo "$KEY_B64" | base64 -d > "$RUNNER_TEMP/fry.keystore"; export FRY_KEYSTORE="$RUNNER_TEMP/fry.keystore"; fi + python3 android/build_apk.py + - uses: actions/upload-artifact@v4 + with: { name: android, path: "dist/*" } + + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: [linux, windows, android] + runs-on: ubuntu-latest + permissions: { contents: write } + steps: + - uses: actions/download-artifact@v4 + with: { path: dist, merge-multiple: true } + - uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 4234a9a..a1ee074 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ build/ *.blend1 __pycache__/ *.fry~ +dist/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 06fc132..8413d35 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,11 +1,17 @@ cmake_minimum_required(VERSION 3.25) +project(Fry3DX VERSION 0.5.0 LANGUAGES C CXX) if(UNIX AND NOT APPLE) add_link_options(-rdynamic) # function names in crash reports endif() -project(Fry3DX VERSION 0.1.0 LANGUAGES C CXX) +# FRY_PORTABLE: a Linux build to hand out (runs on any distro as new as the one it's built on): +# SDL and the C++ library inside the program. +option(FRY_PORTABLE "Build SDL in and link the C++ runtime statically" OFF) +if(FRY_PORTABLE AND UNIX AND NOT APPLE AND NOT ANDROID) + add_link_options(-static-libstdc++ -static-libgcc) +endif() -# Fry3DX — Frying 3D Extreme: a 3D modelling app that's easy to pick up. Built on its own -# engine's window, renderer and UI (engine/). +# Fry3DX — Frying 3D Extreme: a 3D modelling app that's easy to pick up. Its engine (engine/): +# window, renderer and UI. find_program(FRY_CCACHE ccache) if(FRY_CCACHE AND NOT CMAKE_CXX_COMPILER_LAUNCHER) @@ -29,12 +35,12 @@ include(cmake/Shaders.cmake) add_subdirectory(engine) add_subdirectory(src) -# Content next to the program: bin/keel/ (the engine's: fonts, sky, config); bin/Fry3DX/ (the app's own, none yet). +# Content next to the program: bin/engine/ (the engine's: fonts, sky, config); bin/Fry3DX/ (the app's own, none yet). 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}/keel/fonts ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/Fry3DX + COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/engine/fonts ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/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}/keel/fonts/ + ${dejavu_SOURCE_DIR}/LICENSE ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/engine/fonts/ COMMENT "Copying content" VERBATIM) enable_testing() diff --git a/CMakePresets.json b/CMakePresets.json index a1dbb08..54c2b77 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -3,10 +3,18 @@ "configurePresets": [ { "name": "base", "hidden": true, "generator": "Ninja", "binaryDir": "${sourceDir}/build/${presetName}" }, { "name": "debug", "inherits": "base", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" } }, - { "name": "release", "inherits": "base", "cacheVariables": { "CMAKE_BUILD_TYPE": "RelWithDebInfo" } } + { "name": "release", "inherits": "base", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" } }, + { "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": "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" } } ], "buildPresets": [ { "name": "debug", "configurePreset": "debug" }, - { "name": "release", "configurePreset": "release" } + { "name": "release", "configurePreset": "release" }, + { "name": "portable", "configurePreset": "portable" }, + { "name": "windows", "configurePreset": "windows" }, + { "name": "android", "configurePreset": "android" } ] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index d5907ef..bd88509 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,14 @@ -# Fry3DX — Frying 3D Extreme +

-**A 3D modeller that does what you'd expect: pick a tool, click, drag.** -Made by [Nexoniarz](https://github.com/Nexoniarz) · Website: https://nexoniarz.github.io/Fry3DX/ +

Fry3DX

+

Frying 3D Extreme — a 3D modeller that does what you'd expect.
+Pick a tool, click, drag. On Windows, Linux and your phone.

+

+ Website · + Download · + Build it · + made by Nexoniarz +

![Fry3DX](docs/img/model.webp) @@ -12,100 +19,122 @@ guess, modes inside modes. Fry3DX is built the way people think — a toolbox li words on every button, tooltips everywhere — and keeps the useful parts (rounding, mirroring, combining, holes for screws, checks for 3D printing) one click away. -## Build and run +And it works on a phone. Not a viewer: the whole modeller, with big buttons and fingers. -Linux, a Vulkan graphics card, CMake, Ninja, a C++20 compiler, glslc (shaderc) and SDL3. -Everything else is downloaded while building. On NixOS `shell.nix` has it all: +## Download - git clone https://github.com/Nexoniarz/Fry3DX.git && cd Fry3DX - nix-shell --run 'cmake --preset release && cmake --build build/release' - build/release/bin/fry3dx [file.fry] - nix-shell --run 'cmake --build build/debug --target check' # tests +From [Releases](https://github.com/Nexoniarz/Fry3DX/releases): -## What's in it (v0.4) +| | | +|---|---| +| **Windows** (10 and 11, x64) | `Fry3DX-…-windows-x64.zip`: unpack, run `fry3dx.exe` | +| **Linux** (any distro from 2022 on, x64) | `Fry3DX-…-linux-x64.AppImage`: make it executable, run it. Or the `.tar.gz` | +| **Android** (8.0 and newer, 64-bit, Vulkan) | `Fry3DX-…-android-arm64.apk`: open it on the phone to install | -Five tabs at the top (Ctrl+1 to 5): **Model**, **Paint**, **Layout**, **Render**, **Print**. +Needs a graphics card (or phone) with Vulkan; on a PC, OpenGL 4.6 works too. -- **Knife** (K, shape editing): click points along the cut (or drag a straight line; Ctrl for - 45° steps), Enter or double-click cuts. Only what you see, or **right through**; then just the - edges, pieces of one object, each piece its own object, or the smaller piece deleted — the - inside **filled** (solid) or **left open** (hollow). -- **Hole** (O): press on a face, drag out the size (or type it in mm): round (any number of - sides, screw sizes M2-M8 ready), rectangle (width and height), triangle, or hexagon (nut sizes - M2-M8 ready, with a little room added for prints), turned as you like. All the way through, a - pocket of a set depth, or **only the outline**: cut into the face as a new face, picked, ready - to Extrude, Bevel or paint. Round edges then rounds its rim too. -- **Paint tab**: paint straight on the model (brush, eraser, fill, pick colour) or on its flat - picture beside it; the surface is laid out flat by itself. **Paste a picture** (Ctrl+V, copied - in GIMP or anywhere) or add one from a file, then drag its four corners to stretch and bend it - into place — you see it on the model as you go — and Enter places it. Pictures save beside the - .fry and go with the glTF export. -- **Pictures as layers**: a pasted picture goes onto the model as a **sticker** — drag it round - the model and over a corner or edge it bends onto the next face like a real one; its corners - size it (Shift stretches), the round handle turns it. Or put it on the flat picture and drag its - corners. **Enter locks** it as a layer (listed in Properties: hide, reorder, delete, paint in for - good); **clicking it in the Picture panel unlocks it** to move it again. -- **Files**: `.fry` is Fry3DX's own file — one file with the pictures and layers inside, packed - small (pictures as colour planes of predicted differences, then zstd). Old text `.fry` files - still open. **Autosave** (File > Autosave, every 2 minutes by default) keeps a highly packed - copy in the background; after a crash it offers it back. Closing, New or Open with changes asks: - **Save / Don't save / Cancel**. -- **Print tab** (Ctrl+5): your printer's plate drawn in the view (Prusa, Bambu, Creality, - Elegoo, Anycubic, or your own size), the size in millimetres (type one in: it keeps its shape), - fits or not, checks (holes, faces inside out, edges with more than two faces, faces with no - size) with buttons that fix them, overhangs shown red, weight and filament from the material - and infill, Measure (M), Lay flat on a face (L), Put on the bed (B), save 3MF / STL. -- **Object menu**: **add objects together, take one out of another, or keep only where they - overlap** (the last picked is kept; Ctrl+Shift+ / Ctrl+Shift-), always closed and printable; - **make copies** in a row or round in a circle (as objects or joined into one); **make it - thick** (a surface gets a back, a closed shape goes hollow); join flat faces side by side. -- **Layers panel**: **folders** (+ Folder, or Ctrl+G groups the picked ones; folders inside - folders; drag things onto them; a folder's eye and lock work on all in it; click it to pick all - of it), a **go-to button** at the end of every row bringing the camera to it, right-click for - rename, duplicate, show only this, move to a folder, delete. -- **Export** (Ctrl+E): GLB, glTF, **FBX** (Blender, Unity, Unreal, Maya; pictures inside), OBJ (+MTL), 3MF, STL (millimetres, for printing), PLY; painted - pictures and their layers go with it. - the sun (round, up, strength, colour), sky or a plain background, ground, - brightness, soft shadows, shade in corners, glow, colour; Save a picture (F12) saves just the - view, without panels or gizmos. +## What it does +Five tabs along the top: **Model**, **Paint**, **Layout**, **Render**, **Print** (Ctrl+1 to 5). -- **Toolbox like GIMP's**: picture buttons (hover for name and key), and *Tool options* under it. -- **Solid view** for modelling (plain grey room, studio light) and **Rendered** (real sky, - sun and shadows) — Z switches; Shift+Z wireframe; Alt+Z X-ray. -- **Shapes with settings you can change after adding** (+ in the toolbox, or Add): cube, rounded - cube, plane, circle, cylinder, tube, cone, pyramid, sphere, capsule, torus, wedge, stairs, arch; - **starter models**: table, chair, shelf, barrel, bottle, pillar, door frame, crate. -- **Draw shape** (D): click an outline on the ground, a side view's plane, or **straight on any - face of another object** → a new solid standing on it, added to it (raised), or cut into it. -- Move / Rotate / Scale (W/E/R): gizmos turn with the object (or the world's way, or — editing a - shape — out of the faces). Scale has a handle on each side; by default the other side stays put - (or the middle, the bottom, the origin); Shift scales both sides at once. Ctrl snaps. -- **Round, smooth, mirror** on any object, changeable any time: round every sharp edge, smooth it - out like clay (keeping sharp edges if you want), mirror across X/Y/Z. Bake them when you like. -- **Shape editing** (Tab or double-click): points / edges / faces (1/2/3); extrude (X), inset (I), - round edges (B), loop cut (C), cut in four, smooth, merge (M), make face, flip, separate (P). -- **Selecting**: click, box, brush or lasso; Shift+click (or Alt+click, double-click) a whole loop, - Shift+Ctrl+click an edge for the ring across it, Ctrl+click adds or takes one; more/less, - joined (L), invert, all/none. -- Join objects (Ctrl+J), reference pictures to trace over, layers (show, lock, rename). -- Undo / redo, `.fry` files, **export to glTF** (Ctrl+E), save a picture of the view (F12). -- F1 lists every key. If it ever crashes, its own crash screen offers to open it again, with the - last autosave offered back. +**Making shapes** +- **Shapes that stay editable**: cube, rounded cube, plane, circle, cylinder, tube, cone, pyramid, + sphere, capsule, torus, wedge, stairs, arch, and starter models (table, chair, shelf, barrel, + bottle, pillar, door frame, crate). Their sizes stay changeable in Properties. +- **Draw shape** (D): click an outline on the ground, on a side view's plane, or straight on any + face of another object: a new part standing on it, added to it, or cut into it. +- **Move, turn, scale** (W, E, R): the arrows turn with the object. Scale keeps the other side + where it is; Shift scales both sides at once. Ctrl snaps. +- **Round, smooth, mirror** any object, changeable any time, baked in when you like. + +**Editing the shape** (Tab, or double-click) +- Points, edges, faces (1, 2, 3). Extrude (X), inset (I), round edges (B), loop cut (C), knife (K), + cut in four, smooth, merge (M), make a face, flip, separate (P). +- **Hole** (O): round (M2–M8 screw sizes ready), rectangle, triangle or hexagon (M2–M8 nut + sizes, with room for prints); all the way through, a pocket, or only its outline as a new face. +- **Selecting**: click, box, brush or lasso. Shift+click a whole loop, Shift+Ctrl+click an + edge's ring, Ctrl+click adds or takes one. + +**Whole objects** (the Object menu) +- **Add together, take one out of another, keep only where they overlap**: always closed, so + always printable (Ctrl+Shift+ and Ctrl+Shift−). +- **Copies** in a row or round a circle; **make it thick** (a surface gets a back, a solid goes + hollow); join objects; join flat faces. +- **Layers** with folders inside folders, drag and drop, hide and lock whole groups, and a button + on every row that brings the camera to it. + +**Painting** +- Paint on the model or on its flat picture. **Paste any picture** (Ctrl+V): it goes on like a + sticker that bends over edges and corners; Enter locks it as a layer, clicking it in the + Picture panel unlocks it again. +- **Layout tab**: move, turn and size the flat frames of the surface yourself; the paint follows. + +**Seeing it** +- **Solid** view for modelling, **Rendered** with a real sky, sun and soft shadows (Z). Wireframe + (Shift+Z), X-ray (Alt+Z). The Render tab: the sun, sky, ground, brightness, shadows, glow; + F12 saves a picture of just the view. + +**3D printing** (the Print tab) +- Your printer's plate in the view (Prusa, Bambu, Creality, Elegoo, Anycubic, or your own), + sizes in millimetres, checks for holes and flipped faces with buttons that fix them, overhangs + in red, weight and filament, measuring, lay flat on a face, save 3MF or STL. + +**Files** +- `.fry`: Fry3DX's own file, everything in one, packed small. **Autosave** in the background, + offered back after a crash; Save / Don't save / Cancel when closing. +- **Export**: GLB, glTF, FBX (Blender, Unity, Unreal, Maya), OBJ, 3MF, STL, PLY. + +**On a phone or tablet**: everything bigger, the side panels when you want them, a bar of big +buttons (undo, redo, Shift and Ctrl that stay down, edit the shape, delete, frame). One finger +turns the view (or drags the arrows), two fingers move it and pinch to zoom, holding a finger +still is a right-click. Files open and save through the phone's own file picker. + +F1 in the app lists every key. + +## Build it yourself + +Everything it uses is downloaded while building; you need CMake 3.25+, Ninja, a C++20 compiler +(GCC 13+, Clang 17+ or clang-cl) and glslc (from shaderc or the Vulkan SDK). On NixOS, +`shell.nix` has it all. + +```sh +git clone https://github.com/Nexoniarz/Fry3DX.git && cd Fry3DX +cmake --preset release && cmake --build build/release # (NixOS: inside nix-shell) +build/release/bin/fry3dx [file.fry] +cmake --build build/debug --target check # the tests (after --preset debug) +``` + +| Preset | What | +|---|---| +| `debug`, `release` | for working on it | +| `portable` | a Linux build to hand out: SDL and the C++ library inside the program | +| `windows` | Windows, cross-built from Linux with clang-cl ([how](cmake/toolchains/windows-msvc.cmake)) | +| `android` | the Android library; `android/build_apk.py` makes the APK ([how](android/build_apk.py)) | + +`tools/package.py` packs builds into the downloads (zip, tar.gz, AppImage). Pushing a tag +`v*` makes GitHub build all of them and put them in a release (`.github/workflows/build.yml`). + +## What's where + +| | | +|---|---| +| `src/app.cpp` | the app: panels, tools, the view, touch | +| `src/mesh.*`, `tools.cpp`, `cut.cpp` | polygon meshes and the edits on them (tested without a window) | +| `src/boolean.cpp`, `modify.cpp` | combining shapes, thickness, joining flat faces | +| `src/shapes.*` | the shape library and starter models | +| `src/scene.*`, `package.cpp` | objects, folders, the `.fry` file | +| `src/export.cpp`, `print.cpp` | GLB/glTF/FBX/OBJ/3MF/STL/PLY, checks for printing | +| `src/uv.cpp`, `sticker.cpp`, `image.cpp` | flat layouts, stickers, pictures | +| `engine/` | window, Vulkan/OpenGL renderer, UI, files | +| `android/`, `tools/`, `.github/` | builds and packages | +| `docs/` | the website (GitHub Pages); its words are all in `docs/content.js` | +| `tests/` | `mesh_test` | ## Coming -Sculpting, materials (metal/rough shown in the view), scripting; animation later. +Sculpting, materials shown in the view, scripting; animation later. -## Layout +## Licence -- `engine/` — Fry3DX's engine: window, Vulkan/OpenGL renderer, UI, files. -- `src/mesh.*`, `src/tools.cpp` — polygon meshes and the edits on them (no window needed; tested). -- `src/shapes.*` — the shape library and starter models. -- `src/icons.*` — the toolbox pictures. -- `branding/` — the logo (SVG, and PNGs made from it with Inkscape). -- `src/scene.*` — objects, folders, `.fry` text; `src/package.cpp` the packed `.fry`. -- `src/export.cpp` — GLB/glTF/FBX/OBJ/3MF/STL/PLY; `src/print.cpp` — print checks. -- `src/boolean.cpp`, `src/modify.cpp` — combining shapes, thickness, joining flat faces. -- `docs/` — the website (GitHub Pages). Its text is all in `docs/content.js`. -- `src/app.cpp` — the app. +[Apache License 2.0](LICENSE). Copyright 2026 Nexoniarz. +Sky pictures: Poly Haven, CC0. DejaVu fonts: their own free licence (`engine/fonts/LICENSE` in builds). diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml new file mode 100644 index 0000000..8d6b5b5 --- /dev/null +++ b/android/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + diff --git a/android/build_apk.py b/android/build_apk.py new file mode 100644 index 0000000..1abbfc8 --- /dev/null +++ b/android/build_apk.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Builds Fry3DX's Android APK (arm64, Vulkan) with no Gradle: the NDK build (cmake --preset +android), the content as plain files in assets/, SDL's Java layer, the icon, then aapt2, d8, +zipalign and apksigner. + + nix-shell android/shell.nix --run "python3 android/build_apk.py [--install]" + +Out: dist/Fry3DX--android-arm64.apk. --install puts it on a phone over adb (USB +debugging on) and starts it. The first start copies the content out (a moment). +Signed with ~/.local/share/Nexoniarz/Fry3DX-signing/release.keystore (made the first time), +or the key in $FRY_KEYSTORE / $FRY_KEYSTORE_PASS. Keep it safe and keep using it: Android only +updates an app signed with the key it was installed with. +""" +import os +import secrets +import shutil +import subprocess +import sys +import time +import zipfile + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +BUILD = os.path.join(ROOT, "build", "android") +APP = "Fry3DX" +PACKAGE = "io.github.nexoniarz.fry3dx" + + +def run(*cmd, **kw): + subprocess.run(list(cmd), check=True, **kw) + + +def app_version(): + with open(os.path.join(ROOT, "CMakeLists.txt")) as f: + for line in f: + if line.startswith("project(Fry3DX VERSION "): + return line.split()[2] + return "0" + + +def main(): + sdk, ndk = os.environ["ANDROID_HOME"], os.environ["ANDROID_NDK_ROOT"] + tools = os.path.join(sdk, "build-tools", "35.0.0") + android_jar = os.path.join(sdk, "platforms", "android-35", "android.jar") + llvm = os.path.join(ndk, "toolchains", "llvm", "prebuilt", "linux-x86_64", "bin") + + if not os.path.isdir(BUILD): + glslc = os.environ.get("GLSLC") or os.path.join(ndk, "shader-tools", "linux-x86_64", "glslc") # (the NDK's own, if none's given) + run("cmake", "--preset", "android", *([f"-DGLSLC={glslc}"] if os.path.exists(glslc) else []), cwd=ROOT) + run("cmake", "--build", "--preset", "android", cwd=ROOT) + + stage = os.path.join(BUILD, "apk") + shutil.rmtree(stage, ignore_errors=True) + assets, libs = os.path.join(stage, "assets"), os.path.join(stage, "lib", "arm64-v8a") + os.makedirs(libs) + bin_dir = os.path.join(BUILD, "bin") + sdl = os.path.join(BUILD, "_deps", "sdl3-build", "libSDL3.so") + for src in (os.path.join(bin_dir, "libmain.so"), sdl): + run(os.path.join(llvm, "llvm-strip"), "--strip-debug", "-o", os.path.join(libs, os.path.basename(src)), src) + + # Content: the folders next to the program on a desktop, and the list it unpacks from (a + # version on its first line, so an update unpacks again). + for d in ("engine", APP): + shutil.copytree(os.path.join(bin_dir, d), os.path.join(assets, d)) + commit = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT, capture_output=True, text=True).stdout.strip() or "local" + version = f"{app_version()}-{commit}" + files = [os.path.relpath(os.path.join(r, n), assets).replace(os.sep, "/") for r, _, ns in os.walk(assets) for n in ns] + with open(os.path.join(assets, "content.txt"), "w") as f: + f.write(f"{version}-{int(time.time())}\n" + "\n".join(sorted(files)) + "\n") + + # Java: SDL's activity and helpers, and ours on top; then dex. + java_src = [os.path.join(ROOT, "android", "java"), os.path.join(BUILD, "_deps", "sdl3-src", "android-project", "app", "src", "main", "java")] + sources = [os.path.join(r, n) for d in java_src for r, _, ns in os.walk(d) for n in ns if n.endswith(".java")] + classes = os.path.join(BUILD, "java-classes") + shutil.rmtree(classes, ignore_errors=True) + os.makedirs(classes) + print(f"Compiling {len(sources)} Java files") + run("javac", "-nowarn", "-source", "8", "-target", "8", "-bootclasspath", android_jar, "-d", classes, *sources, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + class_files = [os.path.join(r, n) for r, _, ns in os.walk(classes) for n in ns if n.endswith(".class")] + run(os.path.join(tools, "d8"), "--release", "--min-api", "28", "--lib", android_jar, "--output", stage, *class_files) + + # Resources (the icon), the manifest and the assets; then dex and libraries; aligned, signed. + res_zip = os.path.join(BUILD, "res.zip") + run(os.path.join(tools, "aapt2"), "compile", "--dir", os.path.join(ROOT, "android", "res"), "-o", res_zip) + unsigned = os.path.join(BUILD, "unsigned.apk") + vcode = str(int(time.time()) // 60 % 2000000000) + run(os.path.join(tools, "aapt2"), "link", "-o", unsigned, "-I", android_jar, "--manifest", os.path.join(ROOT, "android", "AndroidManifest.xml"), + "-A", assets, "-R", res_zip, "--auto-add-overlay", "--min-sdk-version", "28", "--target-sdk-version", "35", + "--version-code", vcode, "--version-name", version) + with zipfile.ZipFile(unsigned, "a") as z: + z.write(os.path.join(stage, "classes.dex"), "classes.dex") + for n in os.listdir(libs): + z.write(os.path.join(libs, n), f"lib/arm64-v8a/{n}") + aligned = os.path.join(BUILD, "aligned.apk") + run(os.path.join(tools, "zipalign"), "-p", "-f", "4", unsigned, aligned) + keystore = os.environ.get("FRY_KEYSTORE") or os.path.expanduser("~/.local/share/Nexoniarz/Fry3DX-signing/release.keystore") + pass_file = keystore + ".pass" # (its password, beside it, when made here) + password = os.environ.get("FRY_KEYSTORE_PASS") or (open(pass_file).read().strip() if os.path.exists(pass_file) else "") + if not os.path.exists(keystore): + os.makedirs(os.path.dirname(keystore), exist_ok=True) + password = password or secrets.token_urlsafe(24) + with open(os.open(pass_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as f: + f.write(password + "\n") + run("keytool", "-genkeypair", "-keystore", keystore, "-storepass", password, "-keypass", password, "-alias", "fry3dx", + "-keyalg", "RSA", "-keysize", "2048", "-validity", "10000", "-dname", "CN=Fry3DX", stdout=subprocess.DEVNULL) + os.makedirs(os.path.join(ROOT, "dist"), exist_ok=True) + out = os.path.join(ROOT, "dist", f"{APP}-{app_version()}-android-arm64.apk") + run(os.path.join(tools, "apksigner"), "sign", "--ks", keystore, "--ks-pass", f"pass:{password}", "--out", out, aligned) + print(f"{os.path.relpath(out, ROOT)}: {os.path.getsize(out) // (1024 * 1024)} MB") + if "--install" in sys.argv: + run("adb", "install", "-r", out) + run("adb", "shell", "am", "start", "-n", f"{PACKAGE}/.FryActivity") + + +if __name__ == "__main__": + main() diff --git a/android/java/io/github/nexoniarz/fry3dx/FryActivity.java b/android/java/io/github/nexoniarz/fry3dx/FryActivity.java new file mode 100644 index 0000000..f1b4fc4 --- /dev/null +++ b/android/java/io/github/nexoniarz/fry3dx/FryActivity.java @@ -0,0 +1,11 @@ +package io.github.nexoniarz.fry3dx; + +import org.libsdl.app.SDLActivity; + +// Fry3DX's activity: SDL's, loading SDL and the app (libmain.so, whose SDL_main it runs). +public class FryActivity extends SDLActivity { + @Override + protected String[] getLibraries() { + return new String[] {"SDL3", "main"}; + } +} diff --git a/android/res/mipmap-hdpi/ic_launcher.png b/android/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..33866dc Binary files /dev/null and b/android/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/res/mipmap-mdpi/ic_launcher.png b/android/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..5dd2044 Binary files /dev/null and b/android/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/res/mipmap-xhdpi/ic_launcher.png b/android/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..4526bc4 Binary files /dev/null and b/android/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/res/mipmap-xxhdpi/ic_launcher.png b/android/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..401a2c3 Binary files /dev/null and b/android/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/res/mipmap-xxxhdpi/ic_launcher.png b/android/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..09c7fc1 Binary files /dev/null and b/android/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/shell.nix b/android/shell.nix new file mode 100644 index 0000000..ed084ed --- /dev/null +++ b/android/shell.nix @@ -0,0 +1,20 @@ +# Android build environment for Fry3DX: SDK (platform 35, build-tools), NDK, adb, a JDK. +# nix-shell android/shell.nix --run "python3 android/build_apk.py" +{ pkgs ? import { config = { android_sdk.accept_license = true; allowUnfree = true; }; } }: +let + android = pkgs.androidenv.composeAndroidPackages { + platformVersions = [ "35" ]; + buildToolsVersions = [ "35.0.0" ]; + includeNDK = true; + ndkVersions = [ "27.2.12479018" ]; + includeEmulator = false; + includeSystemImages = false; + }; + sdk = android.androidsdk; +in pkgs.mkShell { + packages = [ sdk pkgs.jdk17 pkgs.cmake pkgs.ninja pkgs.python3 pkgs.glslang pkgs.shaderc pkgs.zip pkgs.unzip ]; + ANDROID_HOME = "${sdk}/libexec/android-sdk"; + ANDROID_SDK_ROOT = "${sdk}/libexec/android-sdk"; + ANDROID_NDK_ROOT = "${sdk}/libexec/android-sdk/ndk/27.2.12479018"; + JAVA_HOME = "${pkgs.jdk17.home}"; +} diff --git a/branding/fry3dx.ico b/branding/fry3dx.ico new file mode 100644 index 0000000..09318c3 Binary files /dev/null and b/branding/fry3dx.ico differ diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index 604b025..be1f667 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -45,8 +45,14 @@ if(KEEL_YAPF_DIR) endif() set(VOLK_PULL_IN_VULKAN OFF) -set(SDL_SHARED ON) -set(SDL_STATIC OFF) +if(FRY_PORTABLE) # (SDL inside the program: one file to run; it opens X11, Wayland and sound itself) + set(FETCHCONTENT_TRY_FIND_PACKAGE_MODE NEVER) + set(SDL_SHARED OFF) + set(SDL_STATIC ON) +else() + set(SDL_SHARED ON) + set(SDL_STATIC OFF) +endif() set(GLM_BUILD_TESTS OFF) set(ZSTD_BUILD_PROGRAMS OFF) set(ZSTD_BUILD_SHARED OFF) diff --git a/cmake/toolchains/clang-cl-xwin b/cmake/toolchains/clang-cl-xwin new file mode 100755 index 0000000..25fa2cf --- /dev/null +++ b/cmake/toolchains/clang-cl-xwin @@ -0,0 +1,14 @@ +#!/bin/sh +# clang-cl with the MSVC CRT + Windows SDK from xwin, for every project in the build (sub-builds +# replaces CMake's flags, so they can't live there). LIB is read by lld-link when clang-cl links. +# Headers are made case-insensitive through a VFS overlay (made once, next to the SDK). +X="${XWIN_DIR:-$HOME/.xwin}" +O="$X/case-overlay.json" +[ -f "$O" ] || python3 "$(dirname "$0")/make-case-overlay.py" "$X" "$O" +# clang's own headers (stddef.h's offsetof, intrinsics) must come before the CRT's. +R="${KEEL_CLANG_INCLUDE:-$(clang-cl -print-resource-dir)/include}" +export LIB="$X/crt/lib/x86_64;$X/sdk/lib/um/x86_64;$X/sdk/lib/ucrt/x86_64" +export LIB="$LIB;$X/case-libs" +exec clang-cl --target=x86_64-pc-windows-msvc -fms-compatibility-version=19.40 -fuse-ld=lld-link -D_CRT_USE_BUILTIN_OFFSETOF -Xclang -ivfsoverlay -Xclang "$O" \ + /imsvc"$R" /imsvc"$X/crt/include" /imsvc"$X/sdk/include/ucrt" /imsvc"$X/sdk/include/um" /imsvc"$X/sdk/include/shared" /imsvc"$X/sdk/include/winrt" \ + "$@" diff --git a/cmake/toolchains/lld-link-xwin b/cmake/toolchains/lld-link-xwin new file mode 100755 index 0000000..09f4d51 --- /dev/null +++ b/cmake/toolchains/lld-link-xwin @@ -0,0 +1,6 @@ +#!/bin/sh +X="${XWIN_DIR:-$HOME/.xwin}" +# Libraries some MSVC objects name in other cases than the SDK's files (tzdb.obj: "Advapi32"). +C="$X/case-libs" +[ -e "$C/Advapi32.lib" ] || { mkdir -p "$C" && ln -sf "$X/sdk/lib/um/x86_64/advapi32.lib" "$C/Advapi32.lib"; } +exec lld-link /libpath:"$X/crt/lib/x86_64" /libpath:"$X/sdk/lib/um/x86_64" /libpath:"$X/sdk/lib/ucrt/x86_64" /libpath:"$C" "$@" diff --git a/cmake/toolchains/make-case-overlay.py b/cmake/toolchains/make-case-overlay.py new file mode 100755 index 0000000..0aca15f --- /dev/null +++ b/cmake/toolchains/make-case-overlay.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Writes a clang VFS overlay that makes the xwin CRT/SDK headers case-insensitive (Windows code +includes , , ... and Linux filesystems care).""" +import json, os, sys + +root, out = sys.argv[1], sys.argv[2] +dirs = ["crt/include", "sdk/include/ucrt", "sdk/include/um", "sdk/include/shared", "sdk/include/winrt"] + +def tree(path): + entries = [] + for name in sorted(os.listdir(path)): + full = os.path.join(path, name) + if os.path.islink(full) and name.lower() == os.readlink(full).lower(): + continue # xwin's lowercase alias: the overlay covers every casing already + if os.path.isdir(full): + entries.append({"type": "directory", "name": name, "contents": tree(full)}) + else: + entries.append({"type": "file", "name": name, "external-contents": os.path.realpath(full)}) + return entries + +overlay = {"version": 0, "case-sensitive": "false", "overlay-relative": False, + "roots": [{"type": "directory", "name": os.path.join(root, d), "contents": tree(os.path.join(root, d))} for d in dirs]} +tmp = out + f".{os.getpid()}" +with open(tmp, "w") as f: + json.dump(overlay, f) +os.replace(tmp, out) diff --git a/cmake/toolchains/windows-msvc.cmake b/cmake/toolchains/windows-msvc.cmake new file mode 100644 index 0000000..7b5bd9a --- /dev/null +++ b/cmake/toolchains/windows-msvc.cmake @@ -0,0 +1,30 @@ +# Cross-compiling for Windows (x64) from Linux with clang-cl + lld-link against the real MSVC CRT +# and Windows SDK, laid out by xwin: +# xwin --accept-license --arch x86_64 splat --output ~/.xwin +# cmake --preset windows && cmake --build --preset windows +set(CMAKE_SYSTEM_NAME Windows) +set(CMAKE_SYSTEM_VERSION 10.0) +set(CMAKE_SYSTEM_PROCESSOR AMD64) + +set(CMAKE_C_COMPILER ${CMAKE_CURRENT_LIST_DIR}/clang-cl-xwin) +set(CMAKE_CXX_COMPILER ${CMAKE_CURRENT_LIST_DIR}/clang-cl-xwin) +set(CMAKE_LINKER ${CMAKE_CURRENT_LIST_DIR}/lld-link-xwin) +set(CMAKE_AR llvm-lib) +set(CMAKE_RC_COMPILER llvm-rc) +set(CMAKE_MT llvm-mt) +set(CMAKE_CXX_FLAGS_INIT "/EHsc") +if(DEFINED ENV{XWIN_DIR}) + set(XWIN "$ENV{XWIN_DIR}") +else() + set(XWIN "$ENV{HOME}/.xwin") +endif() +set(CMAKE_RC_FLAGS_INIT "-I${XWIN}/sdk/include/um -I${XWIN}/sdk/include/shared") + +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +set(CMAKE_CROSSCOMPILING_EMULATOR wine) +# Never pick up the build machine's libraries (e.g. a Linux SDL3): fetch and build everything. +set(CMAKE_FIND_ROOT_PATH "${XWIN}") +set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) +set(FETCHCONTENT_TRY_FIND_PACKAGE_MODE NEVER) diff --git a/content/keel/cfg/default.cfg b/content/engine/cfg/default.cfg similarity index 100% rename from content/keel/cfg/default.cfg rename to content/engine/cfg/default.cfg diff --git a/content/keel/skies/CREDITS.txt b/content/engine/skies/CREDITS.txt similarity index 100% rename from content/keel/skies/CREDITS.txt rename to content/engine/skies/CREDITS.txt diff --git a/content/keel/skies/day.ksky b/content/engine/skies/day.ksky similarity index 100% rename from content/keel/skies/day.ksky rename to content/engine/skies/day.ksky diff --git a/docs/content.js b/docs/content.js index b283f51..3413dbf 100644 --- a/docs/content.js +++ b/docs/content.js @@ -7,7 +7,8 @@ window.FRY = { authorUrl: "https://github.com/Nexoniarz", repo: "https://github.com/Nexoniarz/Fry3DX", tagline: "A 3D modeller that does what you'd expect.", - intro: "Pick a tool, click, drag. Plain words instead of jargon, a toolbox like GIMP's, and the useful stuff from the big modellers without the hundred menus around it.", + intro: "Pick a tool, click, drag. Plain words instead of jargon, a toolbox like GIMP's, and the useful stuff from the big modellers without the hundred menus around it. On Windows, Linux and your phone.", + releases: "https://github.com/Nexoniarz/Fry3DX/releases/latest", why: { title: "Why does this exist?", @@ -29,6 +30,7 @@ window.FRY = { { icon: "⎙", title: "Made for 3D printing", text: "Your printer's plate in the view, sizes in millimetres, checks for holes and flipped faces with fix buttons, overhangs shown red, weight and filament." }, { icon: "☀", title: "See it rendered", text: "Solid view for modelling, a real renderer with sky, sun and soft shadows when you want to see it properly. Save a picture with F12." }, { icon: "🗀", title: "Folders and groups", text: "Layers with folders inside folders, drag and drop, show/hide and lock whole groups, and a button on every row that brings the camera to it." }, + { icon: "📱", title: "On your phone too", text: "Not a viewer: the whole modeller. Big buttons, one finger turns the view, two move and pinch to zoom, holding still is a right-click. Files through the phone's own picker." }, { icon: "💾", title: "Never lose work", text: "Its own packed .fry file, autosave in the background, recovery after a crash, and a Save / Don't save / Cancel when closing." }, ], @@ -36,6 +38,7 @@ window.FRY = { { img: "img/model.webp", title: "Model", text: "Starter models, a folder of parts, a shape drawn onto a cube's face, rendered with sky and shadows." }, { img: "img/paint.webp", title: "Paint", text: "A picture pasted on as a sticker: it bends over the cube's edge like a real one." }, { img: "img/print.webp", title: "Print", text: "The printer's plate, sizes in millimetres, checks and overhangs before you slice." }, + { img: "img/phone.webp", title: "Phone", text: "The phone layout: big buttons along the bottom, panels when you want them, fingers for the view." }, { img: "img/combine.webp", title: "Combine", text: "A cylinder taken out of a cube, copies in a row in a folder, a plane made thick." }, { img: "img/crash.webp", title: "Even the crash screen", text: "If something does get burnt, your work comes back from the last autosave." }, ], @@ -58,18 +61,25 @@ window.FRY = { ["F1", "Every key, in the app"], ], + downloads: [ + { os: "Windows", what: "10 and 11, 64-bit", file: "windows-x64.zip", how: "Unpack it, run fry3dx.exe." }, + { os: "Linux", what: "any distro from 2022 on, 64-bit", file: "linux-x64.AppImage", how: "Make it executable, run it. A .tar.gz is there too." }, + { os: "Android", what: "8.0 and newer, 64-bit, Vulkan", file: "android-arm64.apk", how: "Open it on the phone to install (allow installing from your browser or files app)." }, + ], + build: { - text: "Fry3DX builds from source on Linux with CMake. Everything else it needs is downloaded while building. On NixOS, shell.nix has the tools.", + text: "Or build it from source with CMake: everything else it needs is downloaded while building. On NixOS, shell.nix has the tools.", steps: [ "git clone https://github.com/Nexoniarz/Fry3DX.git", "cd Fry3DX", - "nix-shell --run 'cmake --preset release && cmake --build build/release'", + "cmake --preset release && cmake --build build/release", "build/release/bin/fry3dx", ], - needs: "Needs: a Vulkan graphics card, CMake, Ninja, a C++20 compiler, glslc (shaderc) and SDL3.", + needs: "Needs: CMake 3.25+, Ninja, a C++20 compiler (GCC 13+, Clang 17+) and glslc. Presets for Windows (cross-built) and Android are in the README.", }, versions: [ + { v: "0.5", date: "2026-10-19", items: ["Builds for Windows, Linux (AppImage) and Android", "A phone layout: big buttons, finger gestures, the phone's file picker", "Apache License 2.0"] }, { v: "0.4", date: "2026-10-19", items: ["Combine objects: add, subtract, overlap", "Draw shapes onto other objects", "Copies in a row or circle, thicken and hollow", "FBX export", "Folders and groups in Layers, go-to buttons", "Shift scales both sides, Shift+click loops and rings", "A crash screen of its own"] }, { v: "0.3", date: "2026-10-19", items: ["Stickers that bend over edges, picture layers", "Packed .fry files, autosave and recovery", "Layout tab, Print tab, 3MF / STL / OBJ / PLY / GLB export", "Hole tool: hexagon nut pockets, rectangles, outlines"] }, { v: "0.1", date: "2026-10-19", items: ["First version: shapes, shape editing, knife, painting, rendering"] }, diff --git a/docs/img/phone.webp b/docs/img/phone.webp new file mode 100644 index 0000000..178a897 Binary files /dev/null and b/docs/img/phone.webp differ diff --git a/docs/index.html b/docs/index.html index fdddae0..2127af1 100644 --- a/docs/index.html +++ b/docs/index.html @@ -64,6 +64,7 @@

Get it

+

@@ -77,7 +78,7 @@

Fry3DX, made by .

-

Sky pictures from Poly Haven (CC0).

+

Open source under the Apache License 2.0 · Sky pictures from Poly Haven (CC0).

diff --git a/docs/site.js b/docs/site.js index 9952e8a..1f68522 100644 --- a/docs/site.js +++ b/docs/site.js @@ -65,6 +65,14 @@ keys.append(tr); } + const dl = $("downloads"); + for (const d of F.downloads) { + const a = el("a", "download"); + a.href = F.releases; + a.append(el("strong", null, d.os), el("span", "muted", d.what), el("span", "file", "Fry3DX-…-" + d.file), el("span", null, d.how)); + dl.append(a); + } + $("build-text").textContent = F.build.text; $("build-steps").textContent = F.build.steps.join("\n"); $("build-needs").textContent = F.build.needs; diff --git a/docs/style.css b/docs/style.css index 6799fc1..89e83dc 100644 --- a/docs/style.css +++ b/docs/style.css @@ -82,6 +82,11 @@ section:last-child { border-bottom: 0; } .keys td:first-child { width: 45%; } kbd { font: .85rem ui-monospace, "DejaVu Sans Mono", monospace; background: var(--panel2); border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 5px; padding: 2px 7px; } +.downloads { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-bottom: 28px; } +.download { display: flex; flex-direction: column; gap: 4px; padding: 18px 20px; border-radius: var(--radius); background: var(--panel); border: 1px solid var(--line); color: var(--ink); } +.download:hover { text-decoration: none; border-color: var(--hot); } +.download strong { font-size: 1.15rem; color: var(--hot); } +.download .file { font: .85rem ui-monospace, "DejaVu Sans Mono", monospace; color: #cfcdc7; } .code { position: relative; background: #0f0f11; border: 1px solid var(--line); border-left: 3px solid var(--hot); border-radius: var(--radius); } .code pre { margin: 0; padding: 18px 20px; overflow-x: auto; font: .92rem/1.7 ui-monospace, "DejaVu Sans Mono", monospace; color: #d6d4cd; } .copy { position: absolute; top: 10px; right: 10px; font: inherit; font-size: .85rem; color: var(--ink); background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; padding: 4px 12px; cursor: pointer; } diff --git a/engine/engine.cpp b/engine/engine.cpp index 5442c38..779a7fb 100644 --- a/engine/engine.cpp +++ b/engine/engine.cpp @@ -312,13 +312,7 @@ int runClient(Game& game) { window.setIcon(px, w, h); stbi_image_free(px); } - } else if (auto data = fs::read("branding/keel_emblem.yapf")) - if (yapf_texture_t* tex = yapf_tex_load_memory(data->data(), data->size(), 0, 1, nullptr)) { - std::vector rgba(size_t(tex->width) * tex->height * 4); - if (yapf_tex_to_rgba8(tex, yapf_tex_surface(tex, 0, 0, 0, 0), 0, rgba.data()) == YAPF_OK) - window.setIcon(rgba.data(), int(tex->width), int(tex->height)); - yapf_tex_free(tex); - } + } if (!g_testMode) audio::init(); ui::init(); log::info("Press ` (tilde key) to open the console"); @@ -539,17 +533,16 @@ int run(Game& game, int argc, char** argv) { crash::install(g_prefPath, args, dog); } - // Content: the engine's own (/keel/), then the game's (//) over it, + // Content: the engine's own (/engine/), then the game's (//) over it, // each with its packs — like Source's hl2/ under a mod. #if defined(__ANDROID__) const std::string exeDir = unpackAndroidContent(); - if (const char* cache = SDL_GetAndroidCachePath()) setenv("TMPDIR", cache, 1); // temp files (script modules) go there - const bool firstRun = !std::filesystem::exists(g_configPath); + if (const char* cache = SDL_GetAndroidCachePath()) setenv("TMPDIR", cache, 1); // temp files go there #else const char* base = SDL_GetBasePath(); const std::string exeDir = base ? base : "./"; #endif - if (!fs::mountGameDir(exeDir + "keel")) log::warn("Engine folder {}keel not found; fonts and UI won't load", exeDir); + if (!fs::mountGameDir(exeDir + "engine")) log::warn("Engine folder {}engine not found; fonts and UI won't load", exeDir); const std::string gameDir = exeDir + game.name(); if (!fs::mountGameDir(gameDir)) log::warn("Game folder {} not found; no content will load", gameDir); @@ -558,9 +551,6 @@ int run(Game& game, int argc, char** argv) { // Like Source's config_default.cfg: default binds/settings, then the player's own. if (!g_dedicated && fs::exists("cfg/default.cfg")) execute("exec default.cfg"); if (std::filesystem::exists(g_configPath)) execute("exec \"" + g_configPath + "\""); -#if defined(__ANDROID__) - if (firstRun) execute("preset Low"); // a phone: start light (the options change it) -#endif if (fs::exists("cfg/autoexec.cfg")) execute("exec autoexec.cfg"); executeCommandLine(startup, 0); g_modsDir = exeDir + "mods/" + game.name(); diff --git a/engine/rhi/gl/gl_backend.cpp b/engine/rhi/gl/gl_backend.cpp index 0814840..e3169fb 100644 --- a/engine/rhi/gl/gl_backend.cpp +++ b/engine/rhi/gl/gl_backend.cpp @@ -9,6 +9,7 @@ // window-space winding, so front faces are clockwise. Only the final blit to the window // (which shows row 0 at the bottom) flips. // Requires ARB_bindless_texture (NVIDIA, AMD); without it init fails and the engine uses Vulkan. +#include "ui/ui.h" #include "render/backend.h" #include "render/screenshot.h" #include "rhi/gl/gl_loader.h" @@ -332,6 +333,7 @@ public: void beginUi() override { ImGui_ImplOpenGL3_NewFrame(); ImGui_ImplSDL3_NewFrame(); + ui::applyPixelScale(); ImGui::NewFrame(); } void setVsync(bool on) override { SDL_GL_SetSwapInterval(on ? 1 : 0); } diff --git a/engine/rhi/gl/gl_legacy.cpp b/engine/rhi/gl/gl_legacy.cpp index adf0d11..90251b5 100644 --- a/engine/rhi/gl/gl_legacy.cpp +++ b/engine/rhi/gl/gl_legacy.cpp @@ -6,6 +6,7 @@ // 3.1 lightmaps, sun with shadows (one tap), sky, fog; flat surfaces, no highlights or lights // Neither has clustered lights, light shadows or occlusion culling. Depth is GL's -1..1 here // (no glClipControl), so vertex shaders turn the 0..1 matrices' z into it. +#include "ui/ui.h" #include "render/backend.h" #include "core/log.h" #include "platform/window.h" @@ -480,6 +481,7 @@ public: void beginUi() override { ImGui_ImplOpenGL3_NewFrame(); ImGui_ImplSDL3_NewFrame(); + ui::applyPixelScale(); ImGui::NewFrame(); } void setVsync(bool on) override { SDL_GL_SetSwapInterval(on ? 1 : 0); } diff --git a/engine/rhi/vulkan/vk_backend.cpp b/engine/rhi/vulkan/vk_backend.cpp index 18a5280..fb64939 100644 --- a/engine/rhi/vulkan/vk_backend.cpp +++ b/engine/rhi/vulkan/vk_backend.cpp @@ -1,5 +1,6 @@ // The Vulkan 1.3 backend: bindless textures (one descriptor array), buffer device addresses // for every per-draw and per-frame buffer (passed in push constants), dynamic rendering. +#include "ui/ui.h" #include "render/backend.h" #include "render/screenshot.h" #include "rhi/vulkan/vk_device.h" @@ -398,6 +399,7 @@ public: void beginUi() override { ImGui_ImplVulkan_NewFrame(); ImGui_ImplSDL3_NewFrame(); + ui::applyPixelScale(); ImGui::NewFrame(); } void setVsync(bool on) override { device_.setVsync(on); } diff --git a/engine/ui/ui.cpp b/engine/ui/ui.cpp index 387c2d3..3489d8c 100644 --- a/engine/ui/ui.cpp +++ b/engine/ui/ui.cpp @@ -3,6 +3,7 @@ #include "fs/vfs.h" #include +#include #include #include @@ -95,6 +96,29 @@ ImFont* font(Font f) { float scale() { return g_scale; } +namespace { +float g_pixelScale = 1.0f; +ImU32 g_lastScaledEvent = 0; +} +void setPixelScale(float k) { g_pixelScale = std::clamp(k, 0.5f, 6.0f); } +float pixelScale() { return g_pixelScale; } +void applyPixelScale() { + const float k = g_pixelScale; + ImGuiIO& io = ImGui::GetIO(); + io.DisplaySize.x /= k, io.DisplaySize.y /= k; // (set again each frame by the platform) + io.DisplayFramebufferScale.x *= k, io.DisplayFramebufferScale.y *= k; + // The mouse's positions queued since last frame, in the screen's pixels: into the UI's. + // (Events left over for the next frame were done already: by their number.) + ImU32 last = g_lastScaledEvent; + for (ImGuiInputEvent& e : GImGui->InputEventsQueue) { + if (e.EventId <= g_lastScaledEvent) continue; + last = std::max(last, e.EventId); + if (e.Type == ImGuiInputEventType_MousePos && e.MousePos.PosX > -FLT_MAX) + e.MousePos.PosX /= k, e.MousePos.PosY /= k; + } + g_lastScaledEvent = last; +} + void beginFrame(float windowHeight) { g_scale = std::clamp(windowHeight / 1080.0f, 0.75f, 2.5f); ImGui::GetStyle().FontScaleMain = g_scale; diff --git a/engine/ui/ui.h b/engine/ui/ui.h index bff8636..3130928 100644 --- a/engine/ui/ui.h +++ b/engine/ui/ui.h @@ -18,6 +18,12 @@ ImFont* font(Font f); // Sizes in pixels; scaled with the window height so 1080p looks like Source at 1080p. float scale(); void beginFrame(float windowHeight); +// The UI laid out `k` times smaller than the screen's pixels and drawn scaled up, everything +// alike (a phone: its pixels are tiny). 1: as it is. +void setPixelScale(float k); +float pixelScale(); +// The backends call this between the platform's NewFrame and ImGui::NewFrame. +void applyPixelScale(); // The highlight colour: highlights, the logo. inline constexpr uint8_t kAccent[3] = {227, 34, 45}; diff --git a/shell.nix b/shell.nix index d565327..6aacdac 100644 --- a/shell.nix +++ b/shell.nix @@ -1,4 +1,6 @@ # Dev shell for NixOS: `nix-shell`, then `cmake --preset debug && cmake --build build/debug`. +# Windows builds too (cmake --preset windows, see cmake/toolchains/windows-msvc.cmake); +# Android: android/shell.nix. { pkgs ? import {} }: pkgs.mkShell { packages = with pkgs; [ @@ -7,7 +9,10 @@ pkgs.mkShell { shaderc # glslc sdl3 vulkan-loader vulkan-validation-layers vulkan-tools + llvmPackages_19.clang-unwrapped llvmPackages_19.lld llvmPackages_19.llvm xwin ]; LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath (with pkgs; [ vulkan-loader sdl3 ]); VK_LAYER_PATH = "${pkgs.vulkan-validation-layers}/share/vulkan/explicit_layer.d"; + # clang's own headers for the Windows cross compiler (Nix keeps them outside its resource dir). + KEEL_CLANG_INCLUDE = "${pkgs.llvmPackages_19.libclang.lib}/lib/clang/19/include"; } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 81959ff..c0708d3 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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() diff --git a/src/app.cpp b/src/app.cpp index 839e743..cca894e 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -9,12 +9,16 @@ #include "shapes.h" #include +#if defined(__ANDROID__) +#include // (main becomes the SDL_main SDL's Java side calls) +#endif #include #include #include #include #include #include +#include #include #include @@ -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(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(in)), std::istreambuf_iterator()); + 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(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(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(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 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, ¢er); + const ImGuiID leftBottom = ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.45f, nullptr, &left); + const ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.36f, nullptr, ¢er); + 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, ¢er); const ImGuiID leftBottom = ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.5f, nullptr, &left); ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.22f, nullptr, ¢er); @@ -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(); } }; diff --git a/src/fry3dx.rc b/src/fry3dx.rc new file mode 100644 index 0000000..5ae697b --- /dev/null +++ b/src/fry3dx.rc @@ -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 diff --git a/tools/package.py b/tools/package.py new file mode 100644 index 0000000..4284518 --- /dev/null +++ b/tools/package.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Packs builds into the files people download, in dist/: + + 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 + + python3 tools/package.py linux|appimage|windows [--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.) +""" +import os +import shutil +import subprocess +import sys +import tarfile +import zipfile + +ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +DIST = os.path.join(ROOT, "dist") +README = """Fry3DX {version} - Frying 3D Extreme +===================================== + +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. +Your settings and autosaves: {prefs} + +Open source under the Apache License 2.0 (LICENSE). Sky pictures: Poly Haven, CC0. +""" + + +def version(): + with open(os.path.join(ROOT, "CMakeLists.txt")) as f: + for line in f: + if line.startswith("project(Fry3DX VERSION "): + return line.split()[2] + return "0" + + +def stage(bin_dir, exe, extra, start, prefs): + out = os.path.join(DIST, "stage", "Fry3DX") + shutil.rmtree(os.path.dirname(out), ignore_errors=True) + os.makedirs(out) + for name in [exe, *extra]: + 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)) + return out + + +def linux(bin_dir): + out = stage(bin_dir, "fry3dx", [], "./fry3dx (or double-click it)", "~/.local/share/Nexoniarz/Fry3DX") + subprocess.run(["strip", os.path.join(out, "fry3dx")], check=True) + path = os.path.join(DIST, f"Fry3DX-{version()}-linux-x64.tar.gz") + with tarfile.open(path, "w:gz") as t: + t.add(out, "Fry3DX") + return path + + +def appimage(bin_dir): + tool = os.environ.get("APPIMAGETOOL") or shutil.which("appimagetool") + if not tool: + sys.exit("No appimagetool (set APPIMAGETOOL)") + out = stage(bin_dir, "fry3dx", [], "the AppImage itself", "~/.local/share/Nexoniarz/Fry3DX") + app = os.path.join(DIST, "stage", "Fry3DX.AppDir") + # The program and its content where it looks for them (next to it), in usr/bin. + shutil.copytree(out, os.path.join(app, "usr", "bin")) + subprocess.run(["strip", os.path.join(app, "usr", "bin", "fry3dx")], check=True) + os.symlink("usr/bin/fry3dx", os.path.join(app, "AppRun")) + shutil.copy2(os.path.join(ROOT, "branding", "logo.png"), os.path.join(app, "fry3dx.png")) + with open(os.path.join(app, "fry3dx.desktop"), "w") as f: + f.write("[Desktop Entry]\nType=Application\nName=Fry3DX\nComment=A 3D modeller that does what you'd expect\n" + "Exec=fry3dx %f\nIcon=fry3dx\nCategories=Graphics;3DGraphics;\nTerminal=false\n") + path = os.path.join(DIST, f"Fry3DX-{version()}-linux-x64.AppImage") + subprocess.run([tool, "--no-appstream", app, path], check=True, env={**os.environ, "ARCH": "x86_64"}) + return path + + +def windows(bin_dir): + out = stage(bin_dir, "fry3dx.exe", ["SDL3.dll"], "fry3dx.exe", "%APPDATA%\\Nexoniarz\\Fry3DX") + path = os.path.join(DIST, f"Fry3DX-{version()}-windows-x64.zip") + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as z: + for r, _, names in os.walk(out): + for n in names: + full = os.path.join(r, n) + z.write(full, os.path.join("Fry3DX", os.path.relpath(full, out))) + return path + + +def main(): + args = sys.argv[1:] + bin_dir = None + if "--bin" in args: + i = args.index("--bin") + bin_dir = os.path.abspath(args[i + 1]) + 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)) + print(f"{os.path.relpath(path, ROOT)}: {os.path.getsize(path) / 1048576:.1f} MB") + shutil.rmtree(os.path.join(DIST, "stage"), ignore_errors=True) + + +if __name__ == "__main__": + main()