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.

@@ -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 @@
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()