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
89
.github/workflows/build.yml
vendored
Normal 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
|
|
@ -3,3 +3,4 @@ build/
|
|||
*.blend1
|
||||
__pycache__/
|
||||
*.fry~
|
||||
dist/
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.
|
||||
203
README.md
|
|
@ -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>
|
||||
|
||||

|
||||
|
||||
|
|
@ -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
|
||||
|
||||
From [Releases](https://github.com/Nexoniarz/Fry3DX/releases):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **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 |
|
||||
|
||||
Needs a graphics card (or phone) with Vulkan; on a PC, OpenGL 4.6 works too.
|
||||
|
||||
## What it does
|
||||
|
||||
Five tabs along the top: **Model**, **Paint**, **Layout**, **Render**, **Print** (Ctrl+1 to 5).
|
||||
|
||||
**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
|
||||
nix-shell --run 'cmake --preset release && cmake --build build/release'
|
||||
cmake --preset release && cmake --build build/release # (NixOS: inside nix-shell)
|
||||
build/release/bin/fry3dx [file.fry]
|
||||
nix-shell --run 'cmake --build build/debug --target check' # tests
|
||||
cmake --build build/debug --target check # the tests (after --preset debug)
|
||||
```
|
||||
|
||||
## What's in it (v0.4)
|
||||
| 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)) |
|
||||
|
||||
Five tabs at the top (Ctrl+1 to 5): **Model**, **Paint**, **Layout**, **Render**, **Print**.
|
||||
`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`).
|
||||
|
||||
- **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's where
|
||||
|
||||
|
||||
- **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.
|
||||
| | |
|
||||
|---|---|
|
||||
| `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).
|
||||
|
|
|
|||
30
android/AndroidManifest.xml
Normal 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
|
|
@ -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()
|
||||
11
android/java/io/github/nexoniarz/fry3dx/FryActivity.java
Normal 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"};
|
||||
}
|
||||
}
|
||||
BIN
android/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
android/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
android/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 8 KiB |
BIN
android/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
android/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
20
android/shell.nix
Normal 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
|
After Width: | Height: | Size: 37 KiB |
|
|
@ -45,8 +45,14 @@ if(KEEL_YAPF_DIR)
|
|||
endif()
|
||||
|
||||
set(VOLK_PULL_IN_VULKAN 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
|
|
@ -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
|
|
@ -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" "$@"
|
||||
26
cmake/toolchains/make-case-overlay.py
Executable 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)
|
||||
30
cmake/toolchains/windows-msvc.cmake
Normal 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)
|
||||
|
|
@ -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
|
After Width: | Height: | Size: 43 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
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()
|
||||
|
|
|
|||
277
src/app.cpp
|
|
@ -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, ¢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();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
25
src/fry3dx.rc
Normal 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
|
|
@ -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()
|
||||