Fry3DX 0.5: builds for Windows, Linux and Android, a phone layout, Apache 2.0

- Apache License 2.0
- Windows: cross-built with clang-cl (cmake --preset windows), a window
  program with its icon; Linux: a portable preset (SDL and the C++
  library inside); Android: android/build_apk.py makes the APK
- tools/package.py packs the downloads; a GitHub workflow builds all
  three and makes a release for a v* tag
- A phone layout (fry_touch, on by default on Android): the UI drawn
  bigger, panels when asked for, a bar of big buttons, one finger to
  turn the view, two to move and pinch, a long press for a right-click;
  files through the phone's file picker
- Settings and autosaves now in a Nexoniarz folder (copied over once)
- README rewritten, the website shows the downloads and the phone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NMSrd5oe1GpUD7E6d825KP
This commit is contained in:
Nexoniarz 2026-10-19 22:05:01 +02:00
commit f6f5e5f288
40 changed files with 1180 additions and 158 deletions

89
.github/workflows/build.yml vendored Normal file
View file

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

1
.gitignore vendored
View file

@ -3,3 +3,4 @@ build/
*.blend1
__pycache__/
*.fry~
dist/

View file

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

View file

@ -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" }
]
}

202
LICENSE Normal file
View file

@ -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.

205
README.md
View file

@ -1,7 +1,14 @@
# Fry3DX — Frying 3D Extreme
<p align="center"><img src="branding/logo.png" width="96" alt=""></p>
**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/
<h1 align="center">Fry3DX</h1>
<p align="center"><b>Frying 3D Extreme — a 3D modeller that does what you'd expect.</b><br>
Pick a tool, click, drag. On Windows, Linux and your phone.</p>
<p align="center">
<a href="https://nexoniarz.github.io/Fry3DX/">Website</a> ·
<a href="https://github.com/Nexoniarz/Fry3DX/releases">Download</a> ·
<a href="#build-it-yourself">Build it</a> ·
made by <a href="https://github.com/Nexoniarz">Nexoniarz</a>
</p>
![Fry3DX](docs/img/model.webp)
@ -12,100 +19,122 @@ guess, modes inside modes. Fry3DX is built the way people think — a toolbox li
words on every button, tooltips everywhere — and keeps the useful parts (rounding, mirroring,
combining, holes for screws, checks for 3D printing) one click away.
## Build and run
And it works on a phone. Not a viewer: the whole modeller, with big buttons and fingers.
Linux, a Vulkan graphics card, CMake, Ninja, a C++20 compiler, glslc (shaderc) and SDL3.
Everything else is downloaded while building. On NixOS `shell.nix` has it all:
## Download
git clone https://github.com/Nexoniarz/Fry3DX.git && cd Fry3DX
nix-shell --run 'cmake --preset release && cmake --build build/release'
build/release/bin/fry3dx [file.fry]
nix-shell --run 'cmake --build build/debug --target check' # tests
From [Releases](https://github.com/Nexoniarz/Fry3DX/releases):
## What's in it (v0.4)
| | |
|---|---|
| **Windows** (10 and 11, x64) | `Fry3DX-…-windows-x64.zip`: unpack, run `fry3dx.exe` |
| **Linux** (any distro from 2022 on, x64) | `Fry3DX-…-linux-x64.AppImage`: make it executable, run it. Or the `.tar.gz` |
| **Android** (8.0 and newer, 64-bit, Vulkan) | `Fry3DX-…-android-arm64.apk`: open it on the phone to install |
Five tabs at the top (Ctrl+1 to 5): **Model**, **Paint**, **Layout**, **Render**, **Print**.
Needs a graphics card (or phone) with Vulkan; on a PC, OpenGL 4.6 works too.
- **Knife** (K, shape editing): click points along the cut (or drag a straight line; Ctrl for
45° steps), Enter or double-click cuts. Only what you see, or **right through**; then just the
edges, pieces of one object, each piece its own object, or the smaller piece deleted — the
inside **filled** (solid) or **left open** (hollow).
- **Hole** (O): press on a face, drag out the size (or type it in mm): round (any number of
sides, screw sizes M2-M8 ready), rectangle (width and height), triangle, or hexagon (nut sizes
M2-M8 ready, with a little room added for prints), turned as you like. All the way through, a
pocket of a set depth, or **only the outline**: cut into the face as a new face, picked, ready
to Extrude, Bevel or paint. Round edges then rounds its rim too.
- **Paint tab**: paint straight on the model (brush, eraser, fill, pick colour) or on its flat
picture beside it; the surface is laid out flat by itself. **Paste a picture** (Ctrl+V, copied
in GIMP or anywhere) or add one from a file, then drag its four corners to stretch and bend it
into place — you see it on the model as you go — and Enter places it. Pictures save beside the
.fry and go with the glTF export.
- **Pictures as layers**: a pasted picture goes onto the model as a **sticker** — drag it round
the model and over a corner or edge it bends onto the next face like a real one; its corners
size it (Shift stretches), the round handle turns it. Or put it on the flat picture and drag its
corners. **Enter locks** it as a layer (listed in Properties: hide, reorder, delete, paint in for
good); **clicking it in the Picture panel unlocks it** to move it again.
- **Files**: `.fry` is Fry3DX's own file — one file with the pictures and layers inside, packed
small (pictures as colour planes of predicted differences, then zstd). Old text `.fry` files
still open. **Autosave** (File > Autosave, every 2 minutes by default) keeps a highly packed
copy in the background; after a crash it offers it back. Closing, New or Open with changes asks:
**Save / Don't save / Cancel**.
- **Print tab** (Ctrl+5): your printer's plate drawn in the view (Prusa, Bambu, Creality,
Elegoo, Anycubic, or your own size), the size in millimetres (type one in: it keeps its shape),
fits or not, checks (holes, faces inside out, edges with more than two faces, faces with no
size) with buttons that fix them, overhangs shown red, weight and filament from the material
and infill, Measure (M), Lay flat on a face (L), Put on the bed (B), save 3MF / STL.
- **Object menu**: **add objects together, take one out of another, or keep only where they
overlap** (the last picked is kept; Ctrl+Shift+ / Ctrl+Shift-), always closed and printable;
**make copies** in a row or round in a circle (as objects or joined into one); **make it
thick** (a surface gets a back, a closed shape goes hollow); join flat faces side by side.
- **Layers panel**: **folders** (+ Folder, or Ctrl+G groups the picked ones; folders inside
folders; drag things onto them; a folder's eye and lock work on all in it; click it to pick all
of it), a **go-to button** at the end of every row bringing the camera to it, right-click for
rename, duplicate, show only this, move to a folder, delete.
- **Export** (Ctrl+E): GLB, glTF, **FBX** (Blender, Unity, Unreal, Maya; pictures inside), OBJ (+MTL), 3MF, STL (millimetres, for printing), PLY; painted
pictures and their layers go with it.
the sun (round, up, strength, colour), sky or a plain background, ground,
brightness, soft shadows, shade in corners, glow, colour; Save a picture (F12) saves just the
view, without panels or gizmos.
## What it does
Five tabs along the top: **Model**, **Paint**, **Layout**, **Render**, **Print** (Ctrl+1 to 5).
- **Toolbox like GIMP's**: picture buttons (hover for name and key), and *Tool options* under it.
- **Solid view** for modelling (plain grey room, studio light) and **Rendered** (real sky,
sun and shadows) — Z switches; Shift+Z wireframe; Alt+Z X-ray.
- **Shapes with settings you can change after adding** (+ in the toolbox, or Add): cube, rounded
cube, plane, circle, cylinder, tube, cone, pyramid, sphere, capsule, torus, wedge, stairs, arch;
**starter models**: table, chair, shelf, barrel, bottle, pillar, door frame, crate.
- **Draw shape** (D): click an outline on the ground, a side view's plane, or **straight on any
face of another object** → a new solid standing on it, added to it (raised), or cut into it.
- Move / Rotate / Scale (W/E/R): gizmos turn with the object (or the world's way, or — editing a
shape — out of the faces). Scale has a handle on each side; by default the other side stays put
(or the middle, the bottom, the origin); Shift scales both sides at once. Ctrl snaps.
- **Round, smooth, mirror** on any object, changeable any time: round every sharp edge, smooth it
out like clay (keeping sharp edges if you want), mirror across X/Y/Z. Bake them when you like.
- **Shape editing** (Tab or double-click): points / edges / faces (1/2/3); extrude (X), inset (I),
round edges (B), loop cut (C), cut in four, smooth, merge (M), make face, flip, separate (P).
- **Selecting**: click, box, brush or lasso; Shift+click (or Alt+click, double-click) a whole loop,
Shift+Ctrl+click an edge for the ring across it, Ctrl+click adds or takes one; more/less,
joined (L), invert, all/none.
- Join objects (Ctrl+J), reference pictures to trace over, layers (show, lock, rename).
- Undo / redo, `.fry` files, **export to glTF** (Ctrl+E), save a picture of the view (F12).
- F1 lists every key. If it ever crashes, its own crash screen offers to open it again, with the
last autosave offered back.
**Making shapes**
- **Shapes that stay editable**: cube, rounded cube, plane, circle, cylinder, tube, cone, pyramid,
sphere, capsule, torus, wedge, stairs, arch, and starter models (table, chair, shelf, barrel,
bottle, pillar, door frame, crate). Their sizes stay changeable in Properties.
- **Draw shape** (D): click an outline on the ground, on a side view's plane, or straight on any
face of another object: a new part standing on it, added to it, or cut into it.
- **Move, turn, scale** (W, E, R): the arrows turn with the object. Scale keeps the other side
where it is; Shift scales both sides at once. Ctrl snaps.
- **Round, smooth, mirror** any object, changeable any time, baked in when you like.
**Editing the shape** (Tab, or double-click)
- Points, edges, faces (1, 2, 3). Extrude (X), inset (I), round edges (B), loop cut (C), knife (K),
cut in four, smooth, merge (M), make a face, flip, separate (P).
- **Hole** (O): round (M2–M8 screw sizes ready), rectangle, triangle or hexagon (M2–M8 nut
sizes, with room for prints); all the way through, a pocket, or only its outline as a new face.
- **Selecting**: click, box, brush or lasso. Shift+click a whole loop, Shift+Ctrl+click an
edge's ring, Ctrl+click adds or takes one.
**Whole objects** (the Object menu)
- **Add together, take one out of another, keep only where they overlap**: always closed, so
always printable (Ctrl+Shift+ and Ctrl+Shift−).
- **Copies** in a row or round a circle; **make it thick** (a surface gets a back, a solid goes
hollow); join objects; join flat faces.
- **Layers** with folders inside folders, drag and drop, hide and lock whole groups, and a button
on every row that brings the camera to it.
**Painting**
- Paint on the model or on its flat picture. **Paste any picture** (Ctrl+V): it goes on like a
sticker that bends over edges and corners; Enter locks it as a layer, clicking it in the
Picture panel unlocks it again.
- **Layout tab**: move, turn and size the flat frames of the surface yourself; the paint follows.
**Seeing it**
- **Solid** view for modelling, **Rendered** with a real sky, sun and soft shadows (Z). Wireframe
(Shift+Z), X-ray (Alt+Z). The Render tab: the sun, sky, ground, brightness, shadows, glow;
F12 saves a picture of just the view.
**3D printing** (the Print tab)
- Your printer's plate in the view (Prusa, Bambu, Creality, Elegoo, Anycubic, or your own),
sizes in millimetres, checks for holes and flipped faces with buttons that fix them, overhangs
in red, weight and filament, measuring, lay flat on a face, save 3MF or STL.
**Files**
- `.fry`: Fry3DX's own file, everything in one, packed small. **Autosave** in the background,
offered back after a crash; Save / Don't save / Cancel when closing.
- **Export**: GLB, glTF, FBX (Blender, Unity, Unreal, Maya), OBJ, 3MF, STL, PLY.
**On a phone or tablet**: everything bigger, the side panels when you want them, a bar of big
buttons (undo, redo, Shift and Ctrl that stay down, edit the shape, delete, frame). One finger
turns the view (or drags the arrows), two fingers move it and pinch to zoom, holding a finger
still is a right-click. Files open and save through the phone's own file picker.
F1 in the app lists every key.
## Build it yourself
Everything it uses is downloaded while building; you need CMake 3.25+, Ninja, a C++20 compiler
(GCC 13+, Clang 17+ or clang-cl) and glslc (from shaderc or the Vulkan SDK). On NixOS,
`shell.nix` has it all.
```sh
git clone https://github.com/Nexoniarz/Fry3DX.git && cd Fry3DX
cmake --preset release && cmake --build build/release # (NixOS: inside nix-shell)
build/release/bin/fry3dx [file.fry]
cmake --build build/debug --target check # the tests (after --preset debug)
```
| Preset | What |
|---|---|
| `debug`, `release` | for working on it |
| `portable` | a Linux build to hand out: SDL and the C++ library inside the program |
| `windows` | Windows, cross-built from Linux with clang-cl ([how](cmake/toolchains/windows-msvc.cmake)) |
| `android` | the Android library; `android/build_apk.py` makes the APK ([how](android/build_apk.py)) |
`tools/package.py` packs builds into the downloads (zip, tar.gz, AppImage). Pushing a tag
`v*` makes GitHub build all of them and put them in a release (`.github/workflows/build.yml`).
## What's where
| | |
|---|---|
| `src/app.cpp` | the app: panels, tools, the view, touch |
| `src/mesh.*`, `tools.cpp`, `cut.cpp` | polygon meshes and the edits on them (tested without a window) |
| `src/boolean.cpp`, `modify.cpp` | combining shapes, thickness, joining flat faces |
| `src/shapes.*` | the shape library and starter models |
| `src/scene.*`, `package.cpp` | objects, folders, the `.fry` file |
| `src/export.cpp`, `print.cpp` | GLB/glTF/FBX/OBJ/3MF/STL/PLY, checks for printing |
| `src/uv.cpp`, `sticker.cpp`, `image.cpp` | flat layouts, stickers, pictures |
| `engine/` | window, Vulkan/OpenGL renderer, UI, files |
| `android/`, `tools/`, `.github/` | builds and packages |
| `docs/` | the website (GitHub Pages); its words are all in `docs/content.js` |
| `tests/` | `mesh_test` |
## Coming
Sculpting, materials (metal/rough shown in the view), scripting; animation later.
Sculpting, materials shown in the view, scripting; animation later.
## Layout
## Licence
- `engine/` — Fry3DX's engine: window, Vulkan/OpenGL renderer, UI, files.
- `src/mesh.*`, `src/tools.cpp` — polygon meshes and the edits on them (no window needed; tested).
- `src/shapes.*` — the shape library and starter models.
- `src/icons.*` — the toolbox pictures.
- `branding/` — the logo (SVG, and PNGs made from it with Inkscape).
- `src/scene.*` — objects, folders, `.fry` text; `src/package.cpp` the packed `.fry`.
- `src/export.cpp` — GLB/glTF/FBX/OBJ/3MF/STL/PLY; `src/print.cpp` — print checks.
- `src/boolean.cpp`, `src/modify.cpp` — combining shapes, thickness, joining flat faces.
- `docs/` — the website (GitHub Pages). Its text is all in `docs/content.js`.
- `src/app.cpp` — the app.
[Apache License 2.0](LICENSE). Copyright 2026 Nexoniarz.
Sky pictures: Poly Haven, CC0. DejaVu fonts: their own free licence (`engine/fonts/LICENSE` in builds).

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Fry3DX on Android (android/build_apk.py): arm64, Vulkan. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.github.nexoniarz.fry3dx"
android:installLocation="auto">
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x403000" android:required="true" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application
android:label="Fry3DX"
android:icon="@mipmap/ic_launcher"
android:hasCode="true"
android:extractNativeLibs="true"
android:hardwareAccelerated="true"
android:allowBackup="true"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen">
<activity
android:name="io.github.nexoniarz.fry3dx.FryActivity"
android:exported="true"
android:label="Fry3DX"
android:screenOrientation="sensorLandscape"
android:launchMode="singleInstance"
android:windowSoftInputMode="adjustResize"
android:configChanges="layoutDirection|locale|orientation|uiMode|screenLayout|screenSize|smallestScreenSize|keyboard|keyboardHidden|navigation">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

116
android/build_apk.py Normal file
View file

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

View file

@ -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"};
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

20
android/shell.nix Normal file
View file

@ -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 <nixpkgs> { 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}";
}

BIN
branding/fry3dx.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View file

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

14
cmake/toolchains/clang-cl-xwin Executable file
View file

@ -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" \
"$@"

6
cmake/toolchains/lld-link-xwin Executable file
View file

@ -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" "$@"

View file

@ -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 <Winsock2.h>, <WinSock2.h>, <winsock2.h>... 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)

View file

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

View file

@ -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"] },

BIN
docs/img/phone.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View file

@ -64,6 +64,7 @@
<section id="get">
<h2>Get it</h2>
<div class="downloads" id="downloads"></div>
<p id="build-text"></p>
<div class="code"><button class="copy" id="copy">Copy</button><pre id="build-steps"></pre></div>
<p class="muted" id="build-needs"></p>
@ -77,7 +78,7 @@
<footer>
<p>Fry3DX, made by <a id="author" href="#"></a>.</p>
<p class="muted">Sky pictures from Poly Haven (CC0).</p>
<p class="muted">Open source under the Apache License 2.0 · Sky pictures from Poly Haven (CC0).</p>
</footer>
<script src="content.js"></script>

View file

@ -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;

View file

@ -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; }

View file

@ -312,12 +312,6 @@ 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<uint8_t> 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();
@ -539,17 +533,16 @@ int run(Game& game, int argc, char** argv) {
crash::install(g_prefPath, args, dog);
}
// Content: the engine's own (<exe dir>/keel/), then the game's (<exe dir>/<game>/) over it,
// Content: the engine's own (<exe dir>/engine/), then the game's (<exe dir>/<game>/) 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();

View file

@ -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); }

View file

@ -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); }

View file

@ -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); }

View file

@ -3,6 +3,7 @@
#include "fs/vfs.h"
#include <imgui.h>
#include <imgui_internal.h>
#include <algorithm>
#include <cstring>
@ -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;

View file

@ -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};

View file

@ -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 <nixpkgs> {} }:
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";
}

View file

@ -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)
if(MSVC)
target_compile_options(${t} PRIVATE /W3)
else()
target_compile_options(${t} PRIVATE -Wall -Wextra -Wno-missing-field-initializers)
endif()
endforeach()

View file

@ -9,12 +9,16 @@
#include "shapes.h"
#include <SDL3/SDL.h>
#if defined(__ANDROID__)
#include <SDL3/SDL_main.h> // (main becomes the SDL_main SDL's Java side calls)
#endif
#include <core/cvar.h>
#include <core/log.h>
#include <engine.h>
#include <fs/vfs.h>
#include <render/renderer.h>
#include <render/screenshot.h>
#include <ui/ui.h>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
@ -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();
if (!touch() || panelsOpen_) {
layersPanel();
propertiesPanel();
if (tab_ == Tab::Paint || tab_ == Tab::Layout) picturePanel();
statusBar();
if (!capturing()) viewButtons();
}
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<const char*>(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<char>(in)), std::istreambuf_iterator<char>());
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<std::chrono::system_clock>(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<std::chrono::minutes>(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<std::chrono::system_clock::duration>(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<ImVec2> 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, &center);
const ImGuiID leftBottom = ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.45f, nullptr, &left);
const ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.36f, nullptr, &center);
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, &center);
const ImGuiID leftBottom = ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.5f, nullptr, &left);
ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.22f, nullptr, &center);
@ -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();
}
};

25
src/fry3dx.rc Normal file
View file

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

113
tools/package.py Normal file
View file

@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Packs builds into the files people download, in dist/:
Fry3DX-<version>-linux-x64.tar.gz from build/portable (cmake --preset portable)
Fry3DX-<version>-linux-x64.AppImage the same, as one file (needs appimagetool: $APPIMAGETOOL or on PATH)
Fry3DX-<version>-windows-x64.zip from build/windows, or build/<dir> given with --windows-bin
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()